@protobase/ui 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/app.d.ts +3 -0
- package/dist/app/app.js +30 -13
- package/dist/app/custom-section.js +7 -3
- package/dist/app/displays/cards-display.js +57 -32
- package/dist/app/displays/custom-display.js +6 -2
- package/dist/app/displays/list-displays.d.ts +4 -0
- package/dist/app/field-editor.js +31 -2
- package/dist/app/field-types/field-type-cells.js +26 -0
- package/dist/app/field-types/field-widget.d.ts +28 -0
- package/dist/app/field-types/shown-widget.js +21 -0
- package/dist/app/field-types/widget-part.js +33 -0
- package/dist/app/list-page.js +8 -1
- package/dist/app/pages/custom-boundary.js +17 -0
- package/dist/app/pages/field-value.js +19 -2
- package/dist/app/pages/merge-ui.d.ts +3 -3
- package/dist/app/pages/merge-ui.js +11 -5
- package/dist/app/pages/project-ui.d.ts +13 -3
- package/dist/app/pages/project-ui.js +8 -2
- package/dist/app/pages/render-node.js +8 -4
- package/dist/app/record-page.js +16 -4
- package/dist/data/meta-data.d.ts +3 -1
- package/dist/data/use-meta.js +2 -1
- package/dist/data-grid/columns.js +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -2
- package/dist/live/field-values.js +9 -1
- package/dist/package.js +5 -0
- package/dist/plugins/load-plugin.d.ts +16 -0
- package/dist/plugins/load-plugin.js +73 -0
- package/dist/plugins/merge-plugins.js +36 -0
- package/dist/plugins/plugin-api.d.ts +10 -0
- package/dist/plugins/plugin-api.js +12 -0
- package/dist/plugins/plugin-context.d.ts +14 -0
- package/dist/plugins/plugin-context.js +12 -0
- package/dist/plugins/plugin-gate.js +35 -0
- package/dist/plugins/plugin-manifest.js +23 -0
- package/dist/plugins/plugin-notice.js +45 -0
- package/dist/plugins/plugin-ui.js +45 -0
- package/dist/plugins/use-plugin-loads.js +57 -0
- package/dist/shell/assets/app-GUjJKojE.css +1 -0
- package/dist/shell/assets/app-heHm3H5D.js +65 -0
- package/dist/shell/assets/index-B6FP_5El.js +9 -0
- package/dist/shell/assets/index-DheSSTn2.css +2 -0
- package/dist/shell/assets/jsx-runtime-NZYk81nU.js +1 -0
- package/dist/shell/assets/react-Cvdyeg_0.js +1 -0
- package/dist/shell/assets/react-dom-B-VxWsjA.js +1 -0
- package/dist/shell/assets/rolldown-runtime-hePW80VL.js +1 -0
- package/dist/shell/assets/shared-protobase-ui-C4sf7CCI.js +1 -0
- package/dist/shell/assets/shared-react-DhkZ2M4l.js +1 -0
- package/dist/shell/assets/shared-react-dom-D508a1cq.js +1 -0
- package/dist/shell/assets/shared-react-jsx-runtime-xPQrWk0g.js +1 -0
- package/dist/shell/index.html +24 -0
- package/dist/shell/protobase.shell.json +8 -0
- package/dist/styles.css +2 -97
- package/dist/theme.css +42 -0
- package/dist/tokens.css +71 -0
- package/package.json +11 -7
- package/dist/app/pages/project-ui-provider.js +0 -15
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FieldWidget } from "../field-types/field-widget.js";
|
|
1
2
|
import { ComponentType } from "react";
|
|
2
3
|
import { Client } from "@protobase/client";
|
|
3
4
|
import { AppAddress } from "@protobase/schema";
|
|
@@ -16,15 +17,18 @@ export type ActionContext = {
|
|
|
16
17
|
export type ActionHandler = (context: ActionContext) => void | Promise<void>;
|
|
17
18
|
/**
|
|
18
19
|
* The project's own React code for composed pages: custom components by the name their layout declares
|
|
19
|
-
* (`component('UsageChart')`) or a widget part names, handlers for named actions that have no built-in behaviour,
|
|
20
|
-
* the shell's slots. `extends` lists UI configs merged in
|
|
21
|
-
* (`import * as tasksUi from '...'`), their exported
|
|
20
|
+
* (`component('UsageChart')`) or a widget part names, handlers for named actions that have no built-in behaviour,
|
|
21
|
+
* field types by the name a view's `.widget(name)` chooses, and the shell's slots. `extends` lists UI configs merged in
|
|
22
|
+
* before it (see `mergeUi`), or whole `protobase.ui.tsx` modules (`import * as tasksUi from '...'`), their exported
|
|
23
|
+
* components included; `name` names it in their errors.
|
|
22
24
|
*/
|
|
23
25
|
export type ProjectUi = {
|
|
24
26
|
name?: string;
|
|
25
27
|
extends?: Array<ProjectUi | UiModule>;
|
|
26
28
|
components?: Record<string, ComponentType<any>>;
|
|
27
29
|
actions?: Record<string, ActionHandler>;
|
|
30
|
+
/** Field types by name, each with the storage types it shows and its components (see `FieldWidget`). */
|
|
31
|
+
fields?: Record<string, FieldWidget>;
|
|
28
32
|
/** Rendered in the shell on every page: `actions` at the end of the top bar, `rightPanel` beside the page. */
|
|
29
33
|
shell?: {
|
|
30
34
|
actions?: ComponentType;
|
|
@@ -38,4 +42,10 @@ export type UiModule = {
|
|
|
38
42
|
};
|
|
39
43
|
/** Typed identity, for `export default defineUi({ extends, components, actions })` in `protobase.ui.tsx`. */
|
|
40
44
|
export declare const defineUi: (ui: ProjectUi) => ProjectUi;
|
|
45
|
+
/**
|
|
46
|
+
* Typed identity, for `export default definePlugin({ name, components, actions, fields, shell })` in a plugin's
|
|
47
|
+
* `protobase.ui.tsx`: a plugin is a UI config the app loads at runtime, its components exported by name as in a
|
|
48
|
+
* project's. A project's own protobase.ui.tsx is one too, which `protobase build` builds as the plugin `app`.
|
|
49
|
+
*/
|
|
50
|
+
export declare const definePlugin: (plugin: ProjectUi) => ProjectUi;
|
|
41
51
|
//#endregion
|
|
@@ -2,8 +2,14 @@ import { createContext, useContext } from "react";
|
|
|
2
2
|
//#region src/app/pages/project-ui.tsx
|
|
3
3
|
/** Typed identity, for `export default defineUi({ extends, components, actions })` in `protobase.ui.tsx`. */
|
|
4
4
|
const defineUi = (ui) => ui;
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Typed identity, for `export default definePlugin({ name, components, actions, fields, shell })` in a plugin's
|
|
7
|
+
* `protobase.ui.tsx`: a plugin is a UI config the app loads at runtime, its components exported by name as in a
|
|
8
|
+
* project's. A project's own protobase.ui.tsx is one too, which `protobase build` builds as the plugin `app`.
|
|
9
|
+
*/
|
|
10
|
+
const definePlugin = (plugin) => plugin;
|
|
11
|
+
/** The project's UI, merged with the UI configs it extends and the plugins loaded; `PluginGate` or `ProjectUiProvider` provides it. */
|
|
6
12
|
const ProjectUiContext = createContext({});
|
|
7
13
|
const useProjectUi = () => useContext(ProjectUiContext);
|
|
8
14
|
//#endregion
|
|
9
|
-
export { ProjectUiContext, defineUi, useProjectUi };
|
|
15
|
+
export { ProjectUiContext, definePlugin, defineUi, useProjectUi };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useProjectUi } from "./project-ui.js";
|
|
2
2
|
import { Problem } from "./problem.js";
|
|
3
|
+
import { CustomBoundary } from "./custom-boundary.js";
|
|
3
4
|
import { ActionBlock } from "./blocks/action.js";
|
|
4
5
|
import { CardBlock } from "./blocks/card.js";
|
|
5
6
|
import { CardRowBlock } from "./blocks/card-row.js";
|
|
@@ -62,11 +63,14 @@ const RenderNode = ({ node }) => {
|
|
|
62
63
|
if (!Custom) return /* @__PURE__ */ jsxs(Problem, { children: [
|
|
63
64
|
"No component is registered as ",
|
|
64
65
|
node.type,
|
|
65
|
-
".
|
|
66
|
+
". Export it from protobase.ui.tsx, or load the plugin that provides it."
|
|
66
67
|
] });
|
|
67
|
-
return /* @__PURE__ */ jsx(
|
|
68
|
-
|
|
69
|
-
children:
|
|
68
|
+
return /* @__PURE__ */ jsx(CustomBoundary, {
|
|
69
|
+
name: node.type,
|
|
70
|
+
children: /* @__PURE__ */ jsx(Custom, {
|
|
71
|
+
...unmarkFieldRefs(node.props),
|
|
72
|
+
children: node.children.length > 0 ? renderChildren(node.children) : void 0
|
|
73
|
+
})
|
|
70
74
|
});
|
|
71
75
|
}
|
|
72
76
|
const Block = blocks[node.type];
|
package/dist/app/record-page.js
CHANGED
|
@@ -7,11 +7,14 @@ import { useClient } from "../data/api-provider.js";
|
|
|
7
7
|
import { useRecord } from "../data/use-record.js";
|
|
8
8
|
import { recordTitle } from "../live/model-helpers.js";
|
|
9
9
|
import { humanize } from "../live/naming.js";
|
|
10
|
+
import { useProjectUi } from "./pages/project-ui.js";
|
|
10
11
|
import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
|
|
11
12
|
import { useNavigationGuard, useRouter } from "./router.js";
|
|
12
13
|
import { useAppAddress } from "./use-app-address.js";
|
|
13
14
|
import { displayValue } from "../live/display-value.js";
|
|
14
15
|
import { ErrorBanner } from "./error-banner.js";
|
|
16
|
+
import { shownWidget } from "./field-types/shown-widget.js";
|
|
17
|
+
import { WidgetPart } from "./field-types/widget-part.js";
|
|
15
18
|
import { FieldEditor } from "./field-editor.js";
|
|
16
19
|
import { can, fieldAccess } from "./permissions.js";
|
|
17
20
|
import { Field } from "../record-view/field.js";
|
|
@@ -122,6 +125,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
122
125
|
onReview: () => void queryClient.invalidateQueries({ queryKey: keys.record(model.name, recordKey) })
|
|
123
126
|
});
|
|
124
127
|
const related = useRelatedRecords(model, view, meta.resources, meta.views, stored.record, basePath);
|
|
128
|
+
const { fields: fieldTypes } = useProjectUi();
|
|
125
129
|
const layout = layoutFor(model, view);
|
|
126
130
|
const sidebarFields = layout.flatMap((item) => item.kind === "sidebar" ? item.fields : []);
|
|
127
131
|
const status = model.fields.status?.type === "enum" ? String(stored.record.status) : void 0;
|
|
@@ -171,12 +175,20 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
171
175
|
const field = model.fields[name];
|
|
172
176
|
if (!field || !visible(name)) return [];
|
|
173
177
|
const text = displayValue(field, stored.record[name], hint(name));
|
|
178
|
+
const builtIn = field.type === "enum" ? /* @__PURE__ */ jsx(Badge, {
|
|
179
|
+
tone: toneFor(String(stored.record[name])),
|
|
180
|
+
children: text
|
|
181
|
+
}) : /* @__PURE__ */ jsx("strong", { children: text });
|
|
182
|
+
const shown = shownWidget(fieldTypes, field, hint(name));
|
|
174
183
|
return [{
|
|
175
184
|
label: hint(name)?.label ?? humanize(name),
|
|
176
|
-
value:
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
185
|
+
value: shown ? /* @__PURE__ */ jsx(WidgetPart, {
|
|
186
|
+
shown,
|
|
187
|
+
part: "display",
|
|
188
|
+
field,
|
|
189
|
+
value: stored.record[name],
|
|
190
|
+
fallback: builtIn
|
|
191
|
+
}) : builtIn
|
|
180
192
|
}];
|
|
181
193
|
})
|
|
182
194
|
});
|
package/dist/data/meta-data.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ResourcePermissions } from "@protobase/client";
|
|
1
|
+
import { PluginRef, ResourcePermissions } from "@protobase/client";
|
|
2
2
|
import { BottomBarModel, ResourceModel, UserMenuModel, ViewModel } from "@protobase/schema";
|
|
3
3
|
import { PageModel } from "@protobase/layout";
|
|
4
4
|
//#region src/data/meta-data.d.ts
|
|
@@ -20,5 +20,7 @@ export type MetaData = {
|
|
|
20
20
|
runtime?: {
|
|
21
21
|
url: string;
|
|
22
22
|
};
|
|
23
|
+
/** The plugins the app loads for the user (see `PluginGate`). */
|
|
24
|
+
plugins?: PluginRef[];
|
|
23
25
|
};
|
|
24
26
|
//#endregion
|
package/dist/data/use-meta.js
CHANGED
|
@@ -29,7 +29,8 @@ const useMeta = () => {
|
|
|
29
29
|
...result.meta.userMenu && { userMenu: result.meta.userMenu },
|
|
30
30
|
...result.meta.bottomBar && { bottomBar: result.meta.bottomBar },
|
|
31
31
|
...result.meta.assistant && { assistant: result.meta.assistant },
|
|
32
|
-
...result.meta.runtime && { runtime: result.meta.runtime }
|
|
32
|
+
...result.meta.runtime && { runtime: result.meta.runtime },
|
|
33
|
+
...result.meta.plugins && { plugins: result.meta.plugins }
|
|
33
34
|
};
|
|
34
35
|
}
|
|
35
36
|
});
|
|
@@ -10,7 +10,7 @@ const buildColumns = (specs) => helper.columns(specs.map((spec) => helper.access
|
|
|
10
10
|
id: spec.id,
|
|
11
11
|
header: spec.header,
|
|
12
12
|
enableSorting: spec.sortable ?? false,
|
|
13
|
-
cell: (context) => renderCell(spec, context.getValue())
|
|
13
|
+
cell: (context) => spec.render ? spec.render(context.row.original) : renderCell(spec, context.getValue())
|
|
14
14
|
})));
|
|
15
15
|
//#endregion
|
|
16
16
|
export { buildColumns, features };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { FieldWidget, FieldWidgetEditorProps, FieldWidgetProps } from "./app/field-types/field-widget.js";
|
|
2
|
+
import { ActionContext, ActionHandler, ProjectUi, definePlugin, defineUi } from "./app/pages/project-ui.js";
|
|
3
|
+
import { PluginInfo, usePlugin } from "./plugins/plugin-context.js";
|
|
2
4
|
import { App, AppProps } from "./app/app.js";
|
|
3
5
|
import "./app/index.js";
|
|
4
6
|
import { ApiProvider, ApiProviderProps, useClient } from "./data/api-provider.js";
|
|
@@ -8,12 +10,13 @@ import { useList } from "./data/use-list.js";
|
|
|
8
10
|
import { useRecord } from "./data/use-record.js";
|
|
9
11
|
import { useFacets, useHistogram, useSeries } from "./data/use-aggregates.js";
|
|
10
12
|
import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
|
|
13
|
+
import { pluginApi, protobaseVersion } from "./plugins/plugin-api.js";
|
|
11
14
|
import { mergeUi } from "./app/pages/merge-ui.js";
|
|
12
15
|
import { uiFromModule } from "./app/pages/ui-from-module.js";
|
|
13
16
|
import { LayoutRecord, useLayoutRecord } from "./app/pages/record-scope.js";
|
|
14
17
|
import { CurrentAddress, ListAddress, ListChange } from "./app/current-address.js";
|
|
15
18
|
import { AppLink, useAppAddress } from "./app/use-app-address.js";
|
|
16
|
-
import { ListDisplayRow } from "./app/displays/list-displays.js";
|
|
19
|
+
import { ListDisplayRow, RecordCardProps } from "./app/displays/list-displays.js";
|
|
17
20
|
import { ListDisplay, useListDisplay } from "./app/displays/display-context.js";
|
|
18
21
|
import { RecordLink } from "./app/displays/record-link.js";
|
|
19
22
|
import { useApiToken } from "./auth/use-api-token.js";
|
|
@@ -36,4 +39,4 @@ import { Step, StepList, StepState } from "./step-list/step-list.js";
|
|
|
36
39
|
import { DiffView, DiffViewProps } from "./diff-view/diff-view.js";
|
|
37
40
|
import { DiffLine, diffStats, parseDiff } from "./diff-view/diff.js";
|
|
38
41
|
import { CompactTable, CompactTableProps } from "./compact-table/compact-table.js";
|
|
39
|
-
export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ApiProvider, type ApiProviderProps, App, AppLink, type AppProps, AssistantDock, type AssistantDockProps, type BackendData, type BackendRefresh, type CardAction, type CardTone, ChatMessage, ChatThread, type ChatThreadProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type CurrentAddress, type DiffLine, DiffView, type DiffViewProps, FieldList, type LayoutRecord, type ListAddress, type ListChange, type ListDisplay, type ListDisplayRow, type MetaData, PartView, type ProjectUi, RecordLink, SidePanel, type SidePanelProps, type Step, StepList, type StepState, defineUi, diffStats, isConflict, mergeUi, parseDiff, uiFromModule, useApiToken, useAppAddress, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, useRecord, useSeries, useUpdateRecord };
|
|
42
|
+
export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ApiProvider, type ApiProviderProps, App, AppLink, type AppProps, AssistantDock, type AssistantDockProps, type BackendData, type BackendRefresh, type CardAction, type CardTone, ChatMessage, ChatThread, type ChatThreadProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type CurrentAddress, type DiffLine, DiffView, type DiffViewProps, FieldList, type FieldWidget, type FieldWidgetEditorProps, type FieldWidgetProps, type LayoutRecord, type ListAddress, type ListChange, type ListDisplay, type ListDisplayRow, type MetaData, PartView, type PluginInfo, type ProjectUi, type RecordCardProps, RecordLink, SidePanel, type SidePanelProps, type Step, StepList, type StepState, definePlugin, defineUi, diffStats, isConflict, mergeUi, parseDiff, pluginApi, protobaseVersion, uiFromModule, useApiToken, useAppAddress, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, usePlugin, useRecord, useSeries, useUpdateRecord };
|
package/dist/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import { CompactTable } from "./compact-table/compact-table.js";
|
|
|
11
11
|
import { diffStats, parseDiff } from "./diff-view/diff.js";
|
|
12
12
|
import { DiffView } from "./diff-view/diff-view.js";
|
|
13
13
|
import { StepList } from "./step-list/step-list.js";
|
|
14
|
-
import { defineUi } from "./app/pages/project-ui.js";
|
|
14
|
+
import { definePlugin, defineUi } from "./app/pages/project-ui.js";
|
|
15
15
|
import { PartView } from "./assistant-dock/part-view.js";
|
|
16
16
|
import { useBackendData } from "./assistant-dock/use-backend-data.js";
|
|
17
17
|
import { AssistantDock } from "./assistant-dock/assistant-dock.js";
|
|
@@ -24,8 +24,10 @@ import { useListDisplay } from "./app/displays/display-context.js";
|
|
|
24
24
|
import { useLayoutRecord } from "./app/pages/record-scope.js";
|
|
25
25
|
import { uiFromModule } from "./app/pages/ui-from-module.js";
|
|
26
26
|
import { mergeUi } from "./app/pages/merge-ui.js";
|
|
27
|
+
import { usePlugin } from "./plugins/plugin-context.js";
|
|
28
|
+
import { pluginApi, protobaseVersion } from "./plugins/plugin-api.js";
|
|
27
29
|
import { useList } from "./data/use-list.js";
|
|
28
30
|
import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
|
|
29
31
|
import { App } from "./app/app.js";
|
|
30
32
|
import { RecordLink } from "./app/displays/record-link.js";
|
|
31
|
-
export { ActionCard, ApiProvider, App, AppLink, AssistantDock, ChatMessage, ChatThread, CompactTable, Composer, DiffView, FieldList, PartView, RecordLink, SidePanel, StepList, defineUi, diffStats, isConflict, mergeUi, parseDiff, uiFromModule, useApiToken, useAppAddress, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, useRecord, useSeries, useUpdateRecord };
|
|
33
|
+
export { ActionCard, ApiProvider, App, AppLink, AssistantDock, ChatMessage, ChatThread, CompactTable, Composer, DiffView, FieldList, PartView, RecordLink, SidePanel, StepList, definePlugin, defineUi, diffStats, isConflict, mergeUi, parseDiff, pluginApi, protobaseVersion, uiFromModule, useApiToken, useAppAddress, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, usePlugin, useRecord, useSeries, useUpdateRecord };
|
|
@@ -22,6 +22,14 @@ const toPatchValue = (field, draft) => {
|
|
|
22
22
|
if (textEdited.has(field.type) || field.type === "enum") return text;
|
|
23
23
|
throw new Error(`Not implemented: editing ${field.type} fields`);
|
|
24
24
|
};
|
|
25
|
+
/** The value a draft stands for, as the API gives one: null when empty, a boolean or number where the field holds one. */
|
|
26
|
+
const fromDraft = (field, draft) => {
|
|
27
|
+
if (field.type === "boolean") return Boolean(draft);
|
|
28
|
+
if (String(draft ?? "").trim() === "") return null;
|
|
29
|
+
return toPatchValue(field, draft);
|
|
30
|
+
};
|
|
31
|
+
/** The draft for a value, as the editor holds it: a boolean for a boolean field, else its text. */
|
|
32
|
+
const toFieldDraft = (field, value) => field.type === "boolean" ? Boolean(value) : toDraft(value);
|
|
25
33
|
/** Whether the draft differs from what the server holds, comparing numbers by value. */
|
|
26
34
|
const differs = (field, draft, stored) => {
|
|
27
35
|
if (field.type === "boolean") return Boolean(draft) !== Boolean(stored);
|
|
@@ -32,4 +40,4 @@ const differs = (field, draft, stored) => {
|
|
|
32
40
|
return a !== b;
|
|
33
41
|
};
|
|
34
42
|
//#endregion
|
|
35
|
-
export { differs, isEditable, toDraft, toPatchValue };
|
|
43
|
+
export { differs, fromDraft, isEditable, toDraft, toFieldDraft, toPatchValue };
|
package/dist/package.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import "../app/pages/project-ui.js";
|
|
2
|
+
import { PluginRef } from "@protobase/client";
|
|
3
|
+
import "@protobase/schema";
|
|
4
|
+
//#region src/plugins/load-plugin.d.ts
|
|
5
|
+
/** What loading a plugin needs from the page: reading its plugin.json, adding its stylesheet and importing its module. */
|
|
6
|
+
export type PluginLoader = {
|
|
7
|
+
/** The JSON at `url`, or the HTTP status it answered with. */
|
|
8
|
+
readJson: (url: string) => Promise<{
|
|
9
|
+
json: unknown;
|
|
10
|
+
} | {
|
|
11
|
+
status: number;
|
|
12
|
+
}>;
|
|
13
|
+
addStylesheet: (url: string) => void;
|
|
14
|
+
importModule: (url: string) => Promise<Record<string, unknown>>;
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { parseManifest } from "./plugin-manifest.js";
|
|
2
|
+
import { pluginUi } from "./plugin-ui.js";
|
|
3
|
+
import { pluginApi, protobaseVersion } from "./plugin-api.js";
|
|
4
|
+
import { pluginProblem } from "@protobase/schema";
|
|
5
|
+
//#region src/plugins/load-plugin.ts
|
|
6
|
+
/** The page's own: `fetch` without a cached copy, a `<link>` per stylesheet, and `import()`. */
|
|
7
|
+
const browserLoader = {
|
|
8
|
+
readJson: async (url) => {
|
|
9
|
+
const response = await fetch(url, {
|
|
10
|
+
cache: "no-cache",
|
|
11
|
+
credentials: "same-origin"
|
|
12
|
+
});
|
|
13
|
+
return response.ok ? { json: await response.json() } : { status: response.status };
|
|
14
|
+
},
|
|
15
|
+
addStylesheet: (url) => {
|
|
16
|
+
if ([...document.querySelectorAll("link[rel=\"stylesheet\"]")].some((link) => link.href === url)) return;
|
|
17
|
+
const link = document.createElement("link");
|
|
18
|
+
link.rel = "stylesheet";
|
|
19
|
+
link.href = url;
|
|
20
|
+
document.head.append(link);
|
|
21
|
+
},
|
|
22
|
+
importModule: (url) => import(
|
|
23
|
+
/* @vite-ignore */
|
|
24
|
+
url
|
|
25
|
+
)
|
|
26
|
+
};
|
|
27
|
+
/** This app, as a plugin's plugin.json is checked against. */
|
|
28
|
+
const thisApp = {
|
|
29
|
+
api: pluginApi,
|
|
30
|
+
version: protobaseVersion
|
|
31
|
+
};
|
|
32
|
+
const describe = (error) => error instanceof Error ? error.message : String(error);
|
|
33
|
+
/**
|
|
34
|
+
* Loads the plugin `ref` names: its plugin.json, then, when this app may load it (see `pluginProblem`), its stylesheet
|
|
35
|
+
* and module. Every way a plugin can fail ends in `off` with the reason, never in a broken app.
|
|
36
|
+
*/
|
|
37
|
+
const loadPlugin = async (ref, loader, base, host = thisApp) => {
|
|
38
|
+
const url = new URL(ref.url, base).href;
|
|
39
|
+
const off = (reason, name) => ({
|
|
40
|
+
kind: "off",
|
|
41
|
+
url,
|
|
42
|
+
...name && { name },
|
|
43
|
+
reason
|
|
44
|
+
});
|
|
45
|
+
let name;
|
|
46
|
+
try {
|
|
47
|
+
const read = await loader.readJson(url);
|
|
48
|
+
if ("status" in read) return off(`its plugin.json answered ${read.status}`);
|
|
49
|
+
const parsed = parseManifest(read.json);
|
|
50
|
+
if ("problem" in parsed) return off(parsed.problem);
|
|
51
|
+
const { manifest } = parsed;
|
|
52
|
+
name = manifest.name;
|
|
53
|
+
const problem = pluginProblem(manifest, host);
|
|
54
|
+
if (problem) return off(problem, name);
|
|
55
|
+
if (manifest.css) loader.addStylesheet(new URL(manifest.css, url).href);
|
|
56
|
+
const module = await loader.importModule(new URL(manifest.entry, url).href);
|
|
57
|
+
const plugin = {
|
|
58
|
+
name,
|
|
59
|
+
...manifest.version && { version: manifest.version },
|
|
60
|
+
url,
|
|
61
|
+
settings: ref.settings ?? {}
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
kind: "loaded",
|
|
65
|
+
plugin,
|
|
66
|
+
ui: pluginUi(plugin, module)
|
|
67
|
+
};
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return off(`it could not be loaded: ${describe(error)}`, name);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
//#endregion
|
|
73
|
+
export { browserLoader, loadPlugin };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { UiConflictError, mergeUi } from "../app/pages/merge-ui.js";
|
|
2
|
+
//#region src/plugins/merge-plugins.ts
|
|
3
|
+
/**
|
|
4
|
+
* The app's UI config with the loaded plugins' merged before it, in their order, and the plugins that are off: those
|
|
5
|
+
* that did not load, and those that define a component or action handler the app or an earlier plugin defines, which
|
|
6
|
+
* the app's or the earlier one keeps. A conflict within the app's own configs throws, as it does without plugins.
|
|
7
|
+
*/
|
|
8
|
+
const mergePlugins = (project, loads) => {
|
|
9
|
+
const own = mergeUi(project ?? {});
|
|
10
|
+
const kept = [];
|
|
11
|
+
const off = [];
|
|
12
|
+
for (const load of loads) {
|
|
13
|
+
if (load.kind === "off") {
|
|
14
|
+
off.push(load);
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
mergeUi(...kept, load.ui, own);
|
|
19
|
+
kept.push(load.ui);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (!(error instanceof UiConflictError)) throw error;
|
|
22
|
+
off.push({
|
|
23
|
+
kind: "off",
|
|
24
|
+
url: load.plugin.url,
|
|
25
|
+
name: load.plugin.name,
|
|
26
|
+
reason: error.message
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
ui: kept.length === 0 ? own : mergeUi(...kept, own),
|
|
32
|
+
off
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
//#endregion
|
|
36
|
+
export { mergePlugins };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
//#region src/plugins/plugin-api.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The plugin API this @protobase/ui provides. A plugin built for another one is not loaded. It changes only when a
|
|
4
|
+
* plugin built for the previous one could break: an export of @protobase/ui removed or changed, a module shared with
|
|
5
|
+
* plugins added or removed, or a new major version of React. Its exports are pinned by plugin-api.test.ts.
|
|
6
|
+
*/
|
|
7
|
+
export declare const pluginApi: number;
|
|
8
|
+
/** The Protobase version this @protobase/ui is: the app's, since plugins share the app's copy. */
|
|
9
|
+
export declare const protobaseVersion: string;
|
|
10
|
+
//#endregion
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { protobase, version } from "../package.js";
|
|
2
|
+
//#region src/plugins/plugin-api.ts
|
|
3
|
+
/**
|
|
4
|
+
* The plugin API this @protobase/ui provides. A plugin built for another one is not loaded. It changes only when a
|
|
5
|
+
* plugin built for the previous one could break: an export of @protobase/ui removed or changed, a module shared with
|
|
6
|
+
* plugins added or removed, or a new major version of React. Its exports are pinned by plugin-api.test.ts.
|
|
7
|
+
*/
|
|
8
|
+
const pluginApi = protobase.pluginApi;
|
|
9
|
+
/** The Protobase version this @protobase/ui is: the app's, since plugins share the app's copy. */
|
|
10
|
+
const protobaseVersion = version;
|
|
11
|
+
//#endregion
|
|
12
|
+
export { pluginApi, protobaseVersion };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/plugins/plugin-context.d.ts
|
|
2
|
+
/** A loaded plugin, as its components see it with `usePlugin()`. */
|
|
3
|
+
export type PluginInfo = {
|
|
4
|
+
name: string;
|
|
5
|
+
/** Its own version, from its package.json, when it has one. */
|
|
6
|
+
version?: string;
|
|
7
|
+
/** Its plugin.json, from which its own files are found: `new URL('./data.json', url)`. */
|
|
8
|
+
url: string;
|
|
9
|
+
/** What the app gives it in `options.plugins`. */
|
|
10
|
+
settings: Record<string, unknown>;
|
|
11
|
+
};
|
|
12
|
+
/** The plugin the calling component belongs to: its name, version, URL and settings. Only a plugin's components may call it. */
|
|
13
|
+
export declare const usePlugin: () => PluginInfo;
|
|
14
|
+
//#endregion
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
//#region src/plugins/plugin-context.tsx
|
|
3
|
+
const PluginContext = createContext(null);
|
|
4
|
+
const PluginProvider = PluginContext.Provider;
|
|
5
|
+
/** The plugin the calling component belongs to: its name, version, URL and settings. Only a plugin's components may call it. */
|
|
6
|
+
const usePlugin = () => {
|
|
7
|
+
const plugin = useContext(PluginContext);
|
|
8
|
+
if (!plugin) throw new Error("usePlugin is for the components of a plugin the app loaded");
|
|
9
|
+
return plugin;
|
|
10
|
+
};
|
|
11
|
+
//#endregion
|
|
12
|
+
export { PluginProvider, usePlugin };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Spinner } from "../primitives/spinner.js";
|
|
2
|
+
import { ProjectUiContext } from "../app/pages/project-ui.js";
|
|
3
|
+
import { useAdminMeta } from "../app/meta-gate.js";
|
|
4
|
+
import { browserLoader } from "./load-plugin.js";
|
|
5
|
+
import { mergePlugins } from "./merge-plugins.js";
|
|
6
|
+
import { usePluginLoads } from "./use-plugin-loads.js";
|
|
7
|
+
import { createContext, useContext, useMemo } from "react";
|
|
8
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
9
|
+
//#region src/plugins/plugin-gate.tsx
|
|
10
|
+
const PluginsOffContext = createContext([]);
|
|
11
|
+
/** The plugins `/meta` names that the app did not load, and why. */
|
|
12
|
+
const usePluginsOff = () => useContext(PluginsOffContext);
|
|
13
|
+
/**
|
|
14
|
+
* Loads the plugins `/meta` names and provides the project's UI config with theirs merged in. It draws its children
|
|
15
|
+
* once they have loaded, or failed, or after a wait (see `usePluginLoads`); a plugin that fails is left out, and its
|
|
16
|
+
* components draw what any missing component draws.
|
|
17
|
+
*/
|
|
18
|
+
const PluginGate = ({ ui, loader = browserLoader, children }) => {
|
|
19
|
+
const { plugins = [] } = useAdminMeta();
|
|
20
|
+
const { loads, settled } = usePluginLoads(plugins, loader);
|
|
21
|
+
const merged = useMemo(() => mergePlugins(ui, loads), [ui, loads]);
|
|
22
|
+
if (!settled) return /* @__PURE__ */ jsxs("div", {
|
|
23
|
+
className: "flex h-full min-h-48 items-center justify-center gap-2 text-[13px] text-muted-foreground",
|
|
24
|
+
children: [/* @__PURE__ */ jsx(Spinner, {}), " Loading"]
|
|
25
|
+
});
|
|
26
|
+
return /* @__PURE__ */ jsx(ProjectUiContext.Provider, {
|
|
27
|
+
value: merged.ui,
|
|
28
|
+
children: /* @__PURE__ */ jsx(PluginsOffContext.Provider, {
|
|
29
|
+
value: merged.off,
|
|
30
|
+
children
|
|
31
|
+
})
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
35
|
+
export { PluginGate, usePluginsOff };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region src/plugins/plugin-manifest.ts
|
|
2
|
+
const isText = (value) => typeof value === "string" && value.trim() !== "";
|
|
3
|
+
/** The manifest in `value`, or what is wrong with it. */
|
|
4
|
+
const parseManifest = (value) => {
|
|
5
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return { problem: "its plugin.json is not an object" };
|
|
6
|
+
const { name, version, api, protobase, entry, css } = value;
|
|
7
|
+
if (!isText(name)) return { problem: "its plugin.json names no plugin" };
|
|
8
|
+
if (!Number.isInteger(api)) return { problem: "its plugin.json names no plugin API" };
|
|
9
|
+
if (!isText(protobase)) return { problem: "its plugin.json names no Protobase versions" };
|
|
10
|
+
if (!isText(entry)) return { problem: "its plugin.json names no module" };
|
|
11
|
+
if (css !== void 0 && !isText(css)) return { problem: "its plugin.json names a stylesheet that is no URL" };
|
|
12
|
+
if (version !== void 0 && !isText(version)) return { problem: "its plugin.json has a version that is no text" };
|
|
13
|
+
return { manifest: {
|
|
14
|
+
name,
|
|
15
|
+
api,
|
|
16
|
+
protobase,
|
|
17
|
+
entry,
|
|
18
|
+
...version !== void 0 && { version },
|
|
19
|
+
...css !== void 0 && { css }
|
|
20
|
+
} };
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
export { parseManifest };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Puzzle, X } from "lucide-react";
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
//#region src/plugins/plugin-notice.tsx
|
|
5
|
+
/** Across the top of the app for its admins while plugins it should load are off: which, and why. */
|
|
6
|
+
const PluginNotice = ({ off }) => {
|
|
7
|
+
const [dismissed, setDismissed] = useState(false);
|
|
8
|
+
if (dismissed || off.length === 0) return null;
|
|
9
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
10
|
+
role: "region",
|
|
11
|
+
"aria-label": "Plugins off",
|
|
12
|
+
className: "flex items-start gap-3 border-b border-warning/40 bg-warning-soft px-4 py-2 text-[13px] text-warning-text",
|
|
13
|
+
children: [
|
|
14
|
+
/* @__PURE__ */ jsx(Puzzle, {
|
|
15
|
+
"aria-hidden": true,
|
|
16
|
+
className: "mt-0.5 size-4 shrink-0"
|
|
17
|
+
}),
|
|
18
|
+
/* @__PURE__ */ jsx("ul", {
|
|
19
|
+
className: "min-w-0 flex-1 space-y-0.5 break-words",
|
|
20
|
+
children: off.map((plugin) => /* @__PURE__ */ jsxs("li", { children: [
|
|
21
|
+
"The plugin ",
|
|
22
|
+
/* @__PURE__ */ jsx("span", {
|
|
23
|
+
className: "font-semibold",
|
|
24
|
+
children: plugin.name ?? plugin.url
|
|
25
|
+
}),
|
|
26
|
+
" is off: ",
|
|
27
|
+
plugin.reason,
|
|
28
|
+
"."
|
|
29
|
+
] }, plugin.url))
|
|
30
|
+
}),
|
|
31
|
+
/* @__PURE__ */ jsx("button", {
|
|
32
|
+
type: "button",
|
|
33
|
+
"aria-label": "Dismiss",
|
|
34
|
+
className: "rounded p-0.5 hover:bg-warning/10",
|
|
35
|
+
onClick: () => setDismissed(true),
|
|
36
|
+
children: /* @__PURE__ */ jsx(X, {
|
|
37
|
+
"aria-hidden": true,
|
|
38
|
+
className: "size-4"
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
]
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
//#endregion
|
|
45
|
+
export { PluginNotice };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { uiFromModule } from "../app/pages/ui-from-module.js";
|
|
2
|
+
import { mergeUi } from "../app/pages/merge-ui.js";
|
|
3
|
+
import { PluginProvider } from "./plugin-context.js";
|
|
4
|
+
import { jsx } from "react/jsx-runtime";
|
|
5
|
+
//#region src/plugins/plugin-ui.tsx
|
|
6
|
+
const within = (plugin, Component, name) => {
|
|
7
|
+
const Within = (props) => /* @__PURE__ */ jsx(PluginProvider, {
|
|
8
|
+
value: plugin,
|
|
9
|
+
children: /* @__PURE__ */ jsx(Component, { ...props })
|
|
10
|
+
});
|
|
11
|
+
Within.displayName = `${plugin.name}:${name}`;
|
|
12
|
+
return Within;
|
|
13
|
+
};
|
|
14
|
+
const fieldWithin = (plugin, name, widget) => ({
|
|
15
|
+
of: widget.of,
|
|
16
|
+
display: within(plugin, widget.display, `fields.${name}.display`),
|
|
17
|
+
...widget.editor && { editor: within(plugin, widget.editor, `fields.${name}.editor`) },
|
|
18
|
+
...widget.cell && { cell: within(plugin, widget.cell, `fields.${name}.cell`) }
|
|
19
|
+
});
|
|
20
|
+
/**
|
|
21
|
+
* A plugin's UI config from its module, read as a `protobase.ui.tsx` module and merged with the configs it extends,
|
|
22
|
+
* named after the plugin: its components, field types and shell slots draw inside the plugin. Throws `UiConflictError`
|
|
23
|
+
* when the configs it merges define a name twice.
|
|
24
|
+
*/
|
|
25
|
+
const pluginUi = (plugin, module) => {
|
|
26
|
+
const ui = mergeUi({
|
|
27
|
+
...uiFromModule(module),
|
|
28
|
+
name: plugin.name
|
|
29
|
+
});
|
|
30
|
+
const components = Object.fromEntries(Object.entries(ui.components ?? {}).map(([name, Component]) => [name, within(plugin, Component, name)]));
|
|
31
|
+
const fields = ui.fields && Object.fromEntries(Object.entries(ui.fields).map(([name, widget]) => [name, fieldWithin(plugin, name, widget)]));
|
|
32
|
+
const shell = ui.shell && {
|
|
33
|
+
...ui.shell.actions && { actions: within(plugin, ui.shell.actions, "shell.actions") },
|
|
34
|
+
...ui.shell.rightPanel && { rightPanel: within(plugin, ui.shell.rightPanel, "shell.rightPanel") }
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
name: plugin.name,
|
|
38
|
+
components,
|
|
39
|
+
...ui.actions && { actions: ui.actions },
|
|
40
|
+
...fields && { fields },
|
|
41
|
+
...shell && { shell }
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
//#endregion
|
|
45
|
+
export { pluginUi };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { loadPlugin } from "./load-plugin.js";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
//#region src/plugins/use-plugin-loads.ts
|
|
4
|
+
/** How long the app waits for its plugins before it draws without the ones still loading, which arrive later. */
|
|
5
|
+
const pluginWaitMs = 1e4;
|
|
6
|
+
const none = [];
|
|
7
|
+
/**
|
|
8
|
+
* The plugins `refs` names, in their order, as they load. `settled` once every one has loaded or failed, or after
|
|
9
|
+
* `pluginWaitMs`, so a slow plugin delays the app only that long. Without plugins it is settled at once.
|
|
10
|
+
*/
|
|
11
|
+
const usePluginLoads = (refs, loader) => {
|
|
12
|
+
const key = JSON.stringify(refs);
|
|
13
|
+
const [state, setState] = useState({
|
|
14
|
+
key: "[]",
|
|
15
|
+
loads: none,
|
|
16
|
+
settled: true
|
|
17
|
+
});
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (refs.length === 0) return;
|
|
20
|
+
let active = true;
|
|
21
|
+
let waited = false;
|
|
22
|
+
const results = refs.map(() => void 0);
|
|
23
|
+
const publish = () => {
|
|
24
|
+
if (!active) return;
|
|
25
|
+
const loads = results.filter((load) => load !== void 0);
|
|
26
|
+
setState({
|
|
27
|
+
key,
|
|
28
|
+
loads,
|
|
29
|
+
settled: waited || loads.length === refs.length
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
const timer = setTimeout(() => {
|
|
33
|
+
waited = true;
|
|
34
|
+
publish();
|
|
35
|
+
}, pluginWaitMs);
|
|
36
|
+
refs.forEach((ref, index) => {
|
|
37
|
+
loadPlugin(ref, loader, window.location.href).then((load) => {
|
|
38
|
+
results[index] = load;
|
|
39
|
+
publish();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
return () => {
|
|
43
|
+
active = false;
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
};
|
|
46
|
+
}, [key, loader]);
|
|
47
|
+
if (refs.length === 0) return {
|
|
48
|
+
loads: none,
|
|
49
|
+
settled: true
|
|
50
|
+
};
|
|
51
|
+
return state.key === key ? state : {
|
|
52
|
+
loads: none,
|
|
53
|
+
settled: false
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
//#endregion
|
|
57
|
+
export { pluginWaitMs, usePluginLoads };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.pb-range{appearance:none;pointer-events:none;background:0 0;width:100%;height:100%;margin:0;position:absolute;inset:0}.pb-range::-webkit-slider-thumb{appearance:none;pointer-events:auto;background:var(--background);border:2px solid var(--primary);cursor:grab;border-radius:9999px;width:14px;height:14px;box-shadow:0 1px 2px #0003}.pb-range::-moz-range-thumb{pointer-events:auto;background:var(--background);border:2px solid var(--primary);cursor:grab;border-radius:9999px;width:10px;height:10px}.pb-range::-webkit-slider-runnable-track{appearance:none;background:0 0}.pb-range::-moz-range-track{background:0 0}
|