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:
Roman authored and GitHub committed 2020-12-22 15:29:25 +02:00
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
});
+15 -10
View File
@@ -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);
});
}
}
+15 -29
View File
@@ -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();
+91 -64
View File
@@ -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;
}
}