mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Navigation refactoring, handling extension page params (#1651)
* decentralizing page url-params management -- PoC / tsc 4.1 random fixes Signed-off-by: Roman <ixrock@gmail.com> * fixes, tweak example-extension for demo Signed-off-by: Roman <ixrock@gmail.com> * lint fixes, revert tests Signed-off-by: Roman <ixrock@gmail.com> * removed occasional changes related to typescript 4.1 Signed-off-by: Roman <ixrock@gmail.com> * updated example with 2 menu-items targeting same page with different params Signed-off-by: Roman <ixrock@gmail.com> * fix: merge page url chunks with native URL()-api, simplified default page-params registration Signed-off-by: Roman <ixrock@gmail.com> * fix: make lint happy Signed-off-by: Roman <ixrock@gmail.com> * fix: unit-tests Signed-off-by: Roman <ixrock@gmail.com> * renaming by jim's request: UrlParam => PageParam (type), createUrlParam => createPageParam (helper) Signed-off-by: Roman <ixrock@gmail.com> * fix: reverting NamespaceStore public-api breaking changes Signed-off-by: Roman <ixrock@gmail.com> * lint fix Signed-off-by: Roman <ixrock@gmail.com> * fine-tuning Signed-off-by: Roman <ixrock@gmail.com> * yes, lint always unhappy Signed-off-by: Roman <ixrock@gmail.com> * fix build Signed-off-by: Roman <ixrock@gmail.com> * small fixes Signed-off-by: Roman <ixrock@gmail.com> * fix merge-conflicts Signed-off-by: Roman <ixrock@gmail.com> * removed `isSystem` page-param's init field exposed to extensions-api Signed-off-by: Roman <ixrock@gmail.com>
This commit is contained in:
1 parent
11c611dabe
commit
be4e1aa15c
64 files changed
+751
-518
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { getExtensionPageUrl, globalPageRegistry } from "../page-registry";
|
||||
import { getExtensionPageUrl, globalPageRegistry, PageParams } from "../page-registry";
|
||||
import { LensExtension } from "../../lens-extension";
|
||||
import React from "react";
|
||||
|
||||
@@ -17,6 +17,16 @@ describe("getPageUrl", () => {
|
||||
isBundled: false,
|
||||
isEnabled: true
|
||||
});
|
||||
globalPageRegistry.add({
|
||||
id: "page-with-params",
|
||||
components: {
|
||||
Page: () => React.createElement("Page with params")
|
||||
},
|
||||
params: {
|
||||
test1: "test1-default",
|
||||
test2: "" // no default value, just declaration
|
||||
},
|
||||
}, ext);
|
||||
});
|
||||
|
||||
it("returns a page url for extension", () => {
|
||||
@@ -34,6 +44,24 @@ describe("getPageUrl", () => {
|
||||
it("adds / prefix", () => {
|
||||
expect(getExtensionPageUrl({ extensionId: ext.name, pageId: "test" })).toBe("/extension/foo-bar/test");
|
||||
});
|
||||
|
||||
it("normalize possible multi-slashes in page.id", () => {
|
||||
expect(getExtensionPageUrl({ extensionId: ext.name, pageId: "//test/" })).toBe("/extension/foo-bar/test");
|
||||
});
|
||||
|
||||
it("gets page url with custom params", () => {
|
||||
const params: PageParams<string> = { test1: "one", test2: "2" };
|
||||
const searchParams = new URLSearchParams(params);
|
||||
const pageUrl = getExtensionPageUrl({ extensionId: ext.name, pageId: "page-with-params", params });
|
||||
|
||||
expect(pageUrl).toBe(`/extension/foo-bar/page-with-params?${searchParams}`);
|
||||
});
|
||||
|
||||
it("gets page url with default custom params", () => {
|
||||
const defaultPageUrl = getExtensionPageUrl({ extensionId: ext.name, pageId: "page-with-params", });
|
||||
|
||||
expect(defaultPageUrl).toBe(`/extension/foo-bar/page-with-params?test1=test1-default`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("globalPageRegistry", () => {
|
||||
@@ -70,17 +98,17 @@ describe("globalPageRegistry", () => {
|
||||
], ext);
|
||||
});
|
||||
|
||||
describe("getByPageMenuTarget", () => {
|
||||
describe("getByPageTarget", () => {
|
||||
it("matching to first registered page without id", () => {
|
||||
const page = globalPageRegistry.getByPageMenuTarget({ extensionId: ext.name });
|
||||
const page = globalPageRegistry.getByPageTarget({ extensionId: ext.name });
|
||||
|
||||
expect(page.id).toEqual(undefined);
|
||||
expect(page.extensionId).toEqual(ext.name);
|
||||
expect(page.routePath).toEqual(getExtensionPageUrl({ extensionId: ext.name }));
|
||||
expect(page.url).toEqual(getExtensionPageUrl({ extensionId: ext.name }));
|
||||
});
|
||||
|
||||
it("returns matching page", () => {
|
||||
const page = globalPageRegistry.getByPageMenuTarget({
|
||||
const page = globalPageRegistry.getByPageTarget({
|
||||
pageId: "test-page",
|
||||
extensionId: ext.name
|
||||
});
|
||||
@@ -89,7 +117,7 @@ describe("globalPageRegistry", () => {
|
||||
});
|
||||
|
||||
it("returns null if target not found", () => {
|
||||
const page = globalPageRegistry.getByPageMenuTarget({
|
||||
const page = globalPageRegistry.getByPageTarget({
|
||||
pageId: "wrong-page",
|
||||
extensionId: ext.name
|
||||
});
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
// Base class for extensions-api registries
|
||||
import { action, observable } from "mobx";
|
||||
import { LensExtension } from "../lens-extension";
|
||||
import { rectify } from "../../common/utils";
|
||||
|
||||
export class BaseRegistry<T> {
|
||||
private items = observable<T>([], { deep: false });
|
||||
export class BaseRegistry<T, I = T> {
|
||||
private items = observable.map<T, I>();
|
||||
|
||||
getItems(): T[] {
|
||||
return this.items.toJS();
|
||||
getItems(): I[] {
|
||||
return Array.from(this.items.values());
|
||||
}
|
||||
|
||||
add(items: T | T[], ext?: LensExtension): () => void; // allow method overloading with required "ext"
|
||||
@action
|
||||
add(items: T | T[]) {
|
||||
const itemArray = rectify(items);
|
||||
add(items: T | T[], extension?: LensExtension) {
|
||||
const itemArray = [items].flat() as T[];
|
||||
|
||||
this.items.push(...itemArray);
|
||||
itemArray.forEach(item => {
|
||||
this.items.set(item, this.getRegisteredItem(item, extension));
|
||||
});
|
||||
|
||||
return () => this.remove(...itemArray);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars-ts
|
||||
protected getRegisteredItem(item: T, extension?: LensExtension): I {
|
||||
return item as any;
|
||||
}
|
||||
|
||||
@action
|
||||
remove(...items: T[]) {
|
||||
items.forEach(item => {
|
||||
this.items.remove(item); // works because of {deep: false};
|
||||
this.items.delete(item);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,13 @@
|
||||
// Extensions-api -> Register page menu items
|
||||
import type { IconProps } from "../../renderer/components/icon";
|
||||
import type React from "react";
|
||||
import type { PageTarget, RegisteredPage } from "./page-registry";
|
||||
import { action } from "mobx";
|
||||
import { BaseRegistry } from "./base-registry";
|
||||
import { LensExtension } from "../lens-extension";
|
||||
import { RegisteredPage } from "./page-registry";
|
||||
|
||||
export interface PageMenuTarget<P extends object = any> {
|
||||
extensionId?: string;
|
||||
pageId?: string;
|
||||
params?: P;
|
||||
}
|
||||
|
||||
export interface PageMenuRegistration {
|
||||
target?: PageMenuTarget;
|
||||
target?: PageTarget;
|
||||
title: React.ReactNode;
|
||||
components: PageMenuComponents;
|
||||
}
|
||||
@@ -27,9 +21,9 @@ export interface PageMenuComponents {
|
||||
Icon: React.ComponentType<IconProps>;
|
||||
}
|
||||
|
||||
export class GlobalPageMenuRegistry extends BaseRegistry<PageMenuRegistration> {
|
||||
export class PageMenuRegistry<T extends PageMenuRegistration> extends BaseRegistry<T> {
|
||||
@action
|
||||
add(items: PageMenuRegistration[], ext: LensExtension) {
|
||||
add(items: T[], ext: LensExtension) {
|
||||
const normalizedItems = items.map(menuItem => {
|
||||
menuItem.target = {
|
||||
extensionId: ext.name,
|
||||
@@ -43,33 +37,25 @@ export class GlobalPageMenuRegistry extends BaseRegistry<PageMenuRegistration> {
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterPageMenuRegistry extends BaseRegistry<ClusterPageMenuRegistration> {
|
||||
@action
|
||||
add(items: PageMenuRegistration[], ext: LensExtension) {
|
||||
const normalizedItems = items.map(menuItem => {
|
||||
menuItem.target = {
|
||||
extensionId: ext.name,
|
||||
...(menuItem.target || {}),
|
||||
};
|
||||
|
||||
return menuItem;
|
||||
});
|
||||
|
||||
return super.add(normalizedItems);
|
||||
}
|
||||
|
||||
export class ClusterPageMenuRegistry extends PageMenuRegistry<ClusterPageMenuRegistration> {
|
||||
getRootItems() {
|
||||
return this.getItems().filter((item) => !item.parentId);
|
||||
}
|
||||
|
||||
getSubItems(parent: ClusterPageMenuRegistration) {
|
||||
return this.getItems().filter((item) => item.parentId === parent.id && item.target.extensionId === parent.target.extensionId);
|
||||
return this.getItems().filter((item) => (
|
||||
item.parentId === parent.id &&
|
||||
item.target.extensionId === parent.target.extensionId
|
||||
));
|
||||
}
|
||||
|
||||
getByPage(page: RegisteredPage) {
|
||||
return this.getItems().find((item) => item.target?.pageId == page.id && item.target?.extensionId === page.extensionId);
|
||||
getByPage({ id: pageId, extensionId }: RegisteredPage) {
|
||||
return this.getItems().find((item) => (
|
||||
item.target.pageId == pageId &&
|
||||
item.target.extensionId === extensionId
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
export const globalPageMenuRegistry = new GlobalPageMenuRegistry();
|
||||
export const globalPageMenuRegistry = new PageMenuRegistry();
|
||||
export const clusterPageMenuRegistry = new ClusterPageMenuRegistry();
|
||||
@@ -1,93 +1,120 @@
|
||||
// Extensions-api -> Custom page registration
|
||||
import type { PageMenuTarget } from "./page-menu-registry";
|
||||
import type React from "react";
|
||||
import path from "path";
|
||||
import { action } from "mobx";
|
||||
import { compile } from "path-to-regexp";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { BaseRegistry } from "./base-registry";
|
||||
import { LensExtension, sanitizeExtensionName } from "../lens-extension";
|
||||
import logger from "../../main/logger";
|
||||
import { rectify } from "../../common/utils";
|
||||
import { PageParam, PageParamInit } from "../../renderer/navigation/page-param";
|
||||
import { createPageParam } from "../../renderer/navigation/helpers";
|
||||
|
||||
export interface PageRegistration {
|
||||
/**
|
||||
* Page ID or additional route path to indicate uniqueness within current extension registered pages
|
||||
* Might contain special url placeholders, e.g. "/users/:userId?" (? - marks as optional param)
|
||||
* Page ID, part of extension's page url, must be unique within same extension
|
||||
* When not provided, first registered page without "id" would be used for page-menus without target.pageId for same extension
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* Strict route matching to provided page-id, read also: https://reactrouter.com/web/api/NavLink/exact-bool
|
||||
* In case when more than one page registered at same extension "pageId" is required to identify different pages,
|
||||
* It might be useful to provide `exact: true` in some cases to avoid overlapping routes.
|
||||
* Without {exact:true} second page never matches since first page-id/route already includes partial route.
|
||||
* @example const pages = [
|
||||
* {id: "/users", exact: true},
|
||||
* {id: "/users/:userId?"}
|
||||
* ]
|
||||
* Pro-tip: registering pages in opposite order will make same effect without "exact".
|
||||
*/
|
||||
exact?: boolean;
|
||||
params?: PageParams<string | ExtensionPageParamInit>;
|
||||
components: PageComponents;
|
||||
}
|
||||
|
||||
export interface RegisteredPage extends PageRegistration {
|
||||
extensionId: string; // required for compiling registered page to url with page-menu-target to compare
|
||||
routePath: string; // full route-path to registered extension page
|
||||
}
|
||||
// exclude "name" field since provided as key in page.params
|
||||
export type ExtensionPageParamInit = Omit<PageParamInit, "name" | "isSystem">;
|
||||
|
||||
export interface PageComponents {
|
||||
Page: React.ComponentType<any>;
|
||||
}
|
||||
|
||||
export function getExtensionPageUrl<P extends object>({ extensionId, pageId = "", params }: PageMenuTarget<P>): string {
|
||||
const extensionBaseUrl = compile(`/extension/:name`)({
|
||||
name: sanitizeExtensionName(extensionId), // compile only with extension-id first and define base path
|
||||
});
|
||||
const extPageRoutePath = path.posix.join(extensionBaseUrl, pageId);
|
||||
|
||||
if (params) {
|
||||
return compile(extPageRoutePath)(params); // might throw error when required params not passed
|
||||
}
|
||||
|
||||
return extPageRoutePath;
|
||||
export interface PageTarget<P = PageParams> {
|
||||
extensionId?: string;
|
||||
pageId?: string;
|
||||
params?: P;
|
||||
}
|
||||
|
||||
export class PageRegistry extends BaseRegistry<RegisteredPage> {
|
||||
@action
|
||||
add(items: PageRegistration | PageRegistration[], ext: LensExtension) {
|
||||
const itemArray = rectify(items);
|
||||
let registeredPages: RegisteredPage[] = [];
|
||||
export interface PageParams<V = any> {
|
||||
[paramName: string]: V;
|
||||
}
|
||||
|
||||
try {
|
||||
registeredPages = itemArray.map(page => ({
|
||||
...page,
|
||||
extensionId: ext.name,
|
||||
routePath: getExtensionPageUrl({ extensionId: ext.name, pageId: page.id }),
|
||||
}));
|
||||
} catch (err) {
|
||||
logger.error(`[EXTENSION]: page-registration failed`, {
|
||||
items,
|
||||
extension: ext,
|
||||
error: String(err),
|
||||
});
|
||||
export interface PageComponentProps<P extends PageParams = {}> {
|
||||
params?: {
|
||||
[N in keyof P]: PageParam<P[N]>;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RegisteredPage {
|
||||
id: string;
|
||||
extensionId: string;
|
||||
url: string; // registered extension's page URL (without page params)
|
||||
params: PageParams<PageParam>; // normalized params
|
||||
components: PageComponents; // normalized components
|
||||
}
|
||||
|
||||
export function getExtensionPageUrl(target: PageTarget): string {
|
||||
const { extensionId, pageId = "", params: targetParams = {} } = target;
|
||||
|
||||
const pagePath = ["/extension", sanitizeExtensionName(extensionId), pageId]
|
||||
.filter(Boolean)
|
||||
.join("/").replace(/\/+/g, "/").replace(/\/$/, ""); // normalize multi-slashes (e.g. coming from page.id)
|
||||
|
||||
const pageUrl = new URL(pagePath, `http://localhost`);
|
||||
|
||||
// stringify params to matched target page
|
||||
const registeredPage = globalPageRegistry.getByPageTarget(target) || clusterPageRegistry.getByPageTarget(target);
|
||||
|
||||
if (registeredPage?.params) {
|
||||
Object.entries(registeredPage.params).forEach(([name, param]) => {
|
||||
const paramValue = param.stringify(targetParams[name]);
|
||||
|
||||
if (param.init.skipEmpty && param.isEmpty(paramValue)) {
|
||||
pageUrl.searchParams.delete(name);
|
||||
} else {
|
||||
pageUrl.searchParams.set(name, paramValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return pageUrl.href.replace(pageUrl.origin, "");
|
||||
}
|
||||
|
||||
export class PageRegistry extends BaseRegistry<PageRegistration, RegisteredPage> {
|
||||
protected getRegisteredItem(page: PageRegistration, ext: LensExtension): RegisteredPage {
|
||||
const { id: pageId } = page;
|
||||
const extensionId = ext.name;
|
||||
const params = this.normalizeParams(page.params);
|
||||
const components = this.normalizeComponents(page.components, params);
|
||||
const url = getExtensionPageUrl({ extensionId, pageId });
|
||||
|
||||
return {
|
||||
id: pageId, extensionId, params, components, url,
|
||||
};
|
||||
}
|
||||
|
||||
protected normalizeComponents(components: PageComponents, params?: PageParams<PageParam>): PageComponents {
|
||||
if (params) {
|
||||
const { Page } = components;
|
||||
|
||||
components.Page = observer((props: object) => React.createElement(Page, { params, ...props }));
|
||||
}
|
||||
|
||||
return super.add(registeredPages);
|
||||
return components;
|
||||
}
|
||||
|
||||
getUrl<P extends object>({ extensionId, id: pageId }: RegisteredPage, params?: P) {
|
||||
return getExtensionPageUrl({ extensionId, pageId, params });
|
||||
protected normalizeParams(params?: PageParams<string | ExtensionPageParamInit>): PageParams<PageParam> {
|
||||
if (!params) {
|
||||
return;
|
||||
}
|
||||
Object.entries(params).forEach(([name, value]) => {
|
||||
const paramInit: PageParamInit = typeof value === "object"
|
||||
? { name, ...value }
|
||||
: { name, defaultValue: value };
|
||||
|
||||
params[paramInit.name] = createPageParam(paramInit);
|
||||
});
|
||||
|
||||
return params as PageParams<PageParam>;
|
||||
}
|
||||
|
||||
getByPageMenuTarget(target: PageMenuTarget = {}): RegisteredPage | null {
|
||||
const targetUrl = getExtensionPageUrl(target);
|
||||
|
||||
return this.getItems().find(({ id: pageId, extensionId }) => {
|
||||
const pageUrl = getExtensionPageUrl({ extensionId, pageId, params: target.params }); // compiled with provided params
|
||||
|
||||
return targetUrl === pageUrl;
|
||||
}) || null;
|
||||
getByPageTarget(target: PageTarget): RegisteredPage | null {
|
||||
return this.getItems().find(page => page.extensionId === target.extensionId && page.id === target.pageId) || null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user