@rebasepro/admin 0.16.0 → 0.16.1-canary.g0d7af95
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/{CollectionEditorDialog-D2Vf4C3-.js → CollectionEditorDialog-Dt_Wv20U.js} +7 -3
- package/dist/{CollectionEditorDialog-D2Vf4C3-.js.map → CollectionEditorDialog-Dt_Wv20U.js.map} +1 -1
- package/dist/{PropertyEditView-D3NBsK9_.js → PropertyEditView-C4IltWX5.js} +2 -2
- package/dist/{PropertyEditView-D3NBsK9_.js.map → PropertyEditView-C4IltWX5.js.map} +1 -1
- package/dist/{RouterCollectionsStudioView-hPB54l3Q.js → RouterCollectionsStudioView-C-BHWJ0J.js} +4 -4
- package/dist/{RouterCollectionsStudioView-hPB54l3Q.js.map → RouterCollectionsStudioView-C-BHWJ0J.js.map} +1 -1
- package/dist/SchemaChangeDialog-DceT8QFX.js +394 -0
- package/dist/SchemaChangeDialog-DceT8QFX.js.map +1 -0
- package/dist/collection_editor/ConfigControllerProvider.d.ts +4 -4
- package/dist/collection_editor/_cms_internals.d.ts +23 -23
- package/dist/collection_editor/api/index.d.ts +1 -1
- package/dist/collection_editor/extensibility_types.d.ts +1 -1
- package/dist/collection_editor/index.d.ts +21 -21
- package/dist/collection_editor/liveSchemaClient.d.ts +182 -0
- package/dist/collection_editor/serializable_types.d.ts +0 -1
- package/dist/collection_editor/serializable_utils.d.ts +1 -1
- package/dist/collection_editor/types/collection_editor_controller.d.ts +1 -1
- package/dist/collection_editor/types/config_controller.d.ts +11 -0
- package/dist/collection_editor/ui/collection_editor/AICollectionGeneratorPopover.d.ts +1 -1
- package/dist/collection_editor/ui/collection_editor/AIModifiedPathsContext.d.ts +1 -1
- package/dist/collection_editor/ui/collection_editor/CollectionEditorDialog.d.ts +4 -4
- package/dist/collection_editor/ui/collection_editor/CollectionEditorWelcomeView.d.ts +1 -1
- package/dist/collection_editor/ui/collection_editor/CollectionPropertiesEditorForm.d.ts +2 -2
- package/dist/collection_editor/ui/collection_editor/CollectionStudioView.d.ts +1 -1
- package/dist/collection_editor/ui/collection_editor/CollectionsStudioView.d.ts +2 -2
- package/dist/collection_editor/ui/collection_editor/GeneralSettingsForm.d.ts +1 -1
- package/dist/collection_editor/ui/collection_editor/PropertyEditView.d.ts +2 -2
- package/dist/collection_editor/ui/collection_editor/RouterCollectionStudioView.d.ts +1 -1
- package/dist/collection_editor/ui/collection_editor/RouterCollectionsStudioView.d.ts +1 -1
- package/dist/collection_editor/ui/collection_editor/SchemaChangeDialog.d.ts +85 -0
- package/dist/collection_editor/ui/collection_editor/SubcollectionsEditTab.d.ts +2 -2
- package/dist/collection_editor/ui/collection_editor/import/CollectionEditorImportDataPreview.d.ts +1 -1
- package/dist/collection_editor/ui/collection_editor/import/CollectionEditorImportMapping.d.ts +1 -1
- package/dist/collection_editor/ui/collection_editor/import/clean_import_data.d.ts +1 -1
- package/dist/collection_editor/ui/collection_editor/properties/conditions/index.d.ts +6 -6
- package/dist/collection_editor/useCollectionEditorController.d.ts +1 -1
- package/dist/collection_editor/useCollectionsConfigController.d.ts +1 -1
- package/dist/collection_editor/useJsonCollectionsConfigController.d.ts +2 -2
- package/dist/collection_editor/useLiveSchemaEditing.d.ts +71 -0
- package/dist/collection_editor/useLocalCollectionsConfigController.d.ts +1 -1
- package/dist/collection_editor_ui.d.ts +12 -12
- package/dist/collection_editor_ui.js +4 -4
- package/dist/components/CollectionTableBinding/CollectionTableBinding.d.ts +1 -1
- package/dist/components/CollectionTableBinding/PropertyTableCell.d.ts +1 -1
- package/dist/components/CollectionTableBinding/fields/TableStorageUpload.d.ts +1 -1
- package/dist/components/CollectionTableBinding/index.d.ts +5 -5
- package/dist/components/CollectionTableBinding/internal/popup_field/PopupFormField.d.ts +1 -1
- package/dist/components/CollectionViewBinding/SlotValue.d.ts +2 -2
- package/dist/components/CollectionViewBinding/hooks/useKanbanDragAndDrop.d.ts +1 -1
- package/dist/components/DrawerNavigationGroup.d.ts +0 -9
- package/dist/components/DrawerNavigationItem.d.ts +0 -4
- package/dist/components/EditFormActions.d.ts +1 -1
- package/dist/components/EditViewBinding.d.ts +3 -3
- package/dist/components/EntityInspector.d.ts +1 -1
- package/dist/components/EntityPreviewBinding.d.ts +1 -1
- package/dist/components/EntityViewBinding.d.ts +1 -1
- package/dist/components/HomePage/index.d.ts +4 -4
- package/dist/components/PropertyCollectionView.d.ts +1 -1
- package/dist/components/ReferenceWidget.d.ts +1 -1
- package/dist/components/RelationSelector.d.ts +0 -2
- package/dist/components/admin/index.d.ts +1 -1
- package/dist/components/app/AppBar.d.ts +1 -1
- package/dist/components/app/index.d.ts +4 -4
- package/dist/components/common/index.d.ts +1 -1
- package/dist/components/field_configs.d.ts +1 -1
- package/dist/components/history/EntityHistoryEntry.d.ts +2 -2
- package/dist/components/history/index.d.ts +5 -5
- package/dist/components/index.d.ts +45 -45
- package/dist/data_export/export/index.d.ts +4 -4
- package/dist/data_export/index.d.ts +1 -1
- package/dist/data_import/components/DataNewPropertiesMapping.d.ts +1 -1
- package/dist/data_import/components/ImportSaveInProgress.d.ts +1 -1
- package/dist/data_import/components/index.d.ts +4 -4
- package/dist/data_import/hooks/index.d.ts +1 -1
- package/dist/data_import/hooks/useImportConfig.d.ts +1 -1
- package/dist/data_import/import/ImportCollectionAction.d.ts +1 -1
- package/dist/data_import/import/index.d.ts +1 -1
- package/dist/data_import/index.d.ts +5 -5
- package/dist/data_import/types/index.d.ts +1 -1
- package/dist/data_import/utils/file_to_json.d.ts +1 -1
- package/dist/data_import/utils/index.d.ts +5 -5
- package/dist/editor/components/SlashCommandMenu.d.ts +2 -2
- package/dist/editor/components/index.d.ts +5 -5
- package/dist/editor/editor.d.ts +2 -2
- package/dist/editor/index.d.ts +2 -2
- package/dist/editor/nodeViews/ImageComponent.d.ts +1 -1
- package/dist/editor/nodeViews/TaskItemComponent.d.ts +1 -1
- package/dist/editor/types.d.ts +1 -1
- package/dist/{export-oauWlxuu.js → export-CF1-p5nY.js} +2 -2
- package/dist/{export-oauWlxuu.js.map → export-CF1-p5nY.js.map} +1 -1
- package/dist/form/EntityForm.d.ts +1 -1
- package/dist/form/EntityFormActions.d.ts +1 -1
- package/dist/form/EntityFormBinding.d.ts +1 -1
- package/dist/form/PropertyFieldBinding.d.ts +1 -1
- package/dist/form/components/StorageItemPreview.d.ts +1 -1
- package/dist/form/components/index.d.ts +6 -6
- package/dist/form/field_bindings/ArrayCustomShapedFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/ArrayOfReferencesFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/BinaryFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/BlockFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/DateTimeFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/GeopointFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/KeyValueFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/MapFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/MarkdownEditorFieldBinding.d.ts +2 -2
- package/dist/form/field_bindings/MultiSelectFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/MultipleRelationFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/ReadOnlyFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/ReferenceFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/RelationFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/RepeatFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/SelectFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/StorageUploadFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/SwitchFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/TextFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/UserSelectFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/VectorFieldBinding.d.ts +1 -1
- package/dist/form/index.d.ts +24 -24
- package/dist/{history-C2cqc0bw.js → history-BoT8GhO1.js} +2 -2
- package/dist/{history-C2cqc0bw.js.map → history-BoT8GhO1.js.map} +1 -1
- package/dist/hooks/index.d.ts +16 -16
- package/dist/hooks/navigation/contexts/index.d.ts +3 -3
- package/dist/hooks/useBuildSideDialogsController.d.ts +1 -1
- package/dist/hooks/useSelectionDialog.d.ts +1 -1
- package/dist/{import-Bxv5JPm-.js → import-Dg4X01sA.js} +2 -2
- package/dist/{import-Bxv5JPm-.js.map → import-Dg4X01sA.js.map} +1 -1
- package/dist/index.d.ts +17 -15
- package/dist/index.js +262 -21
- package/dist/index.js.map +1 -1
- package/dist/preview/PropertyPreview.d.ts +1 -1
- package/dist/preview/components/ArrayEnumPreview.d.ts +1 -1
- package/dist/preview/components/BooleanPreview.d.ts +1 -1
- package/dist/preview/components/ImagePreview.d.ts +1 -1
- package/dist/preview/components/ReferencePreview.d.ts +1 -1
- package/dist/preview/components/RelationPreview.d.ts +1 -1
- package/dist/preview/components/StorageThumbnail.d.ts +1 -1
- package/dist/preview/components/UrlComponentPreview.d.ts +2 -2
- package/dist/preview/components/UserPreview.d.ts +1 -1
- package/dist/preview/index.d.ts +24 -24
- package/dist/preview/property_previews/ArrayOfMapsPreview.d.ts +1 -1
- package/dist/preview/property_previews/ArrayOfReferencesPreview.d.ts +1 -1
- package/dist/preview/property_previews/ArrayOfRelationsPreview.d.ts +1 -1
- package/dist/preview/property_previews/ArrayOfStorageComponentsPreview.d.ts +1 -1
- package/dist/preview/property_previews/ArrayOfStringsPreview.d.ts +1 -1
- package/dist/preview/property_previews/ArrayOneOfPreview.d.ts +1 -1
- package/dist/preview/property_previews/ArrayPropertyEnumPreview.d.ts +1 -1
- package/dist/preview/property_previews/ArrayPropertyPreview.d.ts +1 -1
- package/dist/preview/property_previews/MapPropertyPreview.d.ts +1 -1
- package/dist/preview/property_previews/NumberPropertyPreview.d.ts +1 -1
- package/dist/preview/property_previews/SkeletonPropertyComponent.d.ts +1 -1
- package/dist/preview/property_previews/StringPropertyPreview.d.ts +1 -1
- package/dist/preview/util.d.ts +1 -1
- package/dist/routes/index.d.ts +2 -2
- package/dist/types/components/EntityFormActionsProps.d.ts +2 -2
- package/dist/types/components/EntityFormProps.d.ts +3 -3
- package/dist/types/components/index.d.ts +3 -3
- package/dist/types/fields.d.ts +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/util/index.d.ts +5 -5
- package/dist/util/previews.d.ts +2 -2
- package/dist/{util-C4qsI1QV.js → util-DUIsQ2zh.js} +132 -9
- package/dist/util-DUIsQ2zh.js.map +1 -0
- package/package.json +11 -11
- package/src/collection_editor/ConfigControllerProvider.tsx +5 -0
- package/src/collection_editor/liveSchemaClient.ts +285 -0
- package/src/collection_editor/serializable_types.ts +0 -1
- package/src/collection_editor/serializable_utils.ts +0 -1
- package/src/collection_editor/types/config_controller.tsx +11 -0
- package/src/collection_editor/ui/collection_editor/CollectionEditorDialog.tsx +10 -0
- package/src/collection_editor/ui/collection_editor/SchemaChangeDialog.tsx +470 -0
- package/src/collection_editor/useLiveSchemaEditing.tsx +315 -0
- package/src/collection_editor/useLocalCollectionsConfigController.tsx +109 -10
- package/src/components/DrawerNavigationGroup.tsx +0 -9
- package/src/components/DrawerNavigationItem.tsx +0 -4
- package/src/components/RelationSelector.tsx +0 -2
- package/src/data_import/utils/data.ts +1 -1
- package/src/data_import/utils/file_to_json.ts +0 -3
- package/src/index.ts +6 -0
- package/src/util/previews.ts +4 -2
- package/dist/util-C4qsI1QV.js.map +0 -1
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plan-then-confirm flow, as a hook.
|
|
3
|
+
*
|
|
4
|
+
* Owns three things that belong together and nothing else: whether the backend
|
|
5
|
+
* can edit its schema, the plan for the change somebody is making, and the
|
|
6
|
+
* dialog they confirm it in. The config controller calls `reviewChange` and
|
|
7
|
+
* awaits it; everything about *how* the confirmation happens stays here.
|
|
8
|
+
*
|
|
9
|
+
* ## Why `reviewChange` returns a promise that can reject
|
|
10
|
+
*
|
|
11
|
+
* Because the editor needs to know. A save that was cancelled must not leave
|
|
12
|
+
* the form thinking it succeeded — the collection on screen would then differ
|
|
13
|
+
* from the one on disk, and the next save would be computed from the wrong
|
|
14
|
+
* `before`. Rejecting with {@link SchemaChangeCancelled} is how "the person
|
|
15
|
+
* said no" reaches the caller as an outcome rather than as silence.
|
|
16
|
+
*/
|
|
17
|
+
import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
18
|
+
import { lazyChunk } from "@rebasepro/ui";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
createLiveSchemaClient,
|
|
22
|
+
LiveSchemaError,
|
|
23
|
+
SchemaChangeCancelled,
|
|
24
|
+
type LiveSchemaClient,
|
|
25
|
+
type LiveSchemaPlan,
|
|
26
|
+
type LiveSchemaResult,
|
|
27
|
+
type LiveSchemaStatus,
|
|
28
|
+
type ProposedCollectionChange
|
|
29
|
+
} from "./liveSchemaClient";
|
|
30
|
+
|
|
31
|
+
// Re-exported so the hook stays the one import a caller needs.
|
|
32
|
+
export { SchemaChangeCancelled, isSchemaChangeCancelled } from "./liveSchemaClient";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Loaded when a change is actually being reviewed, not before.
|
|
36
|
+
*
|
|
37
|
+
* This hook is reached from the collections config controller, which is
|
|
38
|
+
* constructed on every admin boot — so a static import would put the dialog and
|
|
39
|
+
* the `@rebasepro/ui` surface it draws on into the **eager** bundle: everything
|
|
40
|
+
* a browser downloads before the login screen paints. It cost 14 kB there, for
|
|
41
|
+
* a dialog most sessions never open. The bundle budget caught it.
|
|
42
|
+
*/
|
|
43
|
+
const SchemaChangeDialog = lazyChunk(() =>
|
|
44
|
+
import("./ui/collection_editor/SchemaChangeDialog")
|
|
45
|
+
.then(m => ({ default: m.SchemaChangeDialog }))
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
export interface UseLiveSchemaEditingOptions {
|
|
49
|
+
/** Base for the routes, e.g. `https://api.example.com/api/admin/schema`. */
|
|
50
|
+
baseUrl: string;
|
|
51
|
+
getAuthToken?: () => Promise<string | null> | string | null;
|
|
52
|
+
/**
|
|
53
|
+
* Identity of the signed-in user.
|
|
54
|
+
*
|
|
55
|
+
* Only used to re-ask: whether this backend will accept a schema change
|
|
56
|
+
* depends on who is asking, and the answer to an anonymous probe does not
|
|
57
|
+
* survive a sign-in.
|
|
58
|
+
*/
|
|
59
|
+
authKey?: string | null;
|
|
60
|
+
/**
|
|
61
|
+
* Write the collection source and leave the database alone — what the
|
|
62
|
+
* editor did before this existed. Offered in the dialog when a change is
|
|
63
|
+
* refused, so a dev can still delete a field from a collection they are
|
|
64
|
+
* designing. Omit it and the fallback is not offered.
|
|
65
|
+
*/
|
|
66
|
+
writeSourceOnly?: (change: ProposedCollectionChange) => Promise<void>;
|
|
67
|
+
/** Swappable for the tests, which have no server. */
|
|
68
|
+
client?: LiveSchemaClient;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface PendingChange {
|
|
72
|
+
change: ProposedCollectionChange;
|
|
73
|
+
resolve: () => void;
|
|
74
|
+
reject: (err: Error) => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface LiveSchemaEditing {
|
|
78
|
+
/** `undefined` until the backend has answered. For rendering. */
|
|
79
|
+
status?: LiveSchemaStatus;
|
|
80
|
+
/**
|
|
81
|
+
* The same answer, for deciding.
|
|
82
|
+
*
|
|
83
|
+
* `status` is undefined for one round trip after mount, and a caller that
|
|
84
|
+
* read it to choose a code path would take the "not available" branch for
|
|
85
|
+
* that window — so a save issued quickly after a page load would silently
|
|
86
|
+
* skip the confirmation and write source only, while the same save a second
|
|
87
|
+
* later would not. Timing-dependent behaviour with no visible difference is
|
|
88
|
+
* the worst shape this could have.
|
|
89
|
+
*
|
|
90
|
+
* Resolves once, cached; never rejects, because every way of failing to get
|
|
91
|
+
* an answer means the same thing to whoever is deciding.
|
|
92
|
+
*/
|
|
93
|
+
ready: () => Promise<LiveSchemaStatus>;
|
|
94
|
+
/**
|
|
95
|
+
* Plan the change, show it, and settle when the person has decided.
|
|
96
|
+
*
|
|
97
|
+
* Resolves once the change has been applied — or written source-only, if
|
|
98
|
+
* they chose that. Rejects with {@link SchemaChangeCancelled} if they
|
|
99
|
+
* closed the dialog, and with the underlying error if planning or applying
|
|
100
|
+
* failed in a way they did not choose.
|
|
101
|
+
*/
|
|
102
|
+
reviewChange: (change: ProposedCollectionChange) => Promise<void>;
|
|
103
|
+
/** Render this once, high enough that a dialog over the editor is not clipped. */
|
|
104
|
+
dialog: React.ReactNode;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function useLiveSchemaEditing(options: UseLiveSchemaEditingOptions): LiveSchemaEditing {
|
|
108
|
+
const { baseUrl, authKey, writeSourceOnly } = options;
|
|
109
|
+
|
|
110
|
+
const optionsRef = useRef(options);
|
|
111
|
+
optionsRef.current = options;
|
|
112
|
+
|
|
113
|
+
const client = useMemo(
|
|
114
|
+
() => options.client ?? createLiveSchemaClient({
|
|
115
|
+
baseUrl,
|
|
116
|
+
getAuthToken: () => optionsRef.current.getAuthToken?.() ?? null
|
|
117
|
+
}),
|
|
118
|
+
// `client` is either supplied once by a test or built from the URL. The
|
|
119
|
+
// token getter is read through the ref so a new closure on every render
|
|
120
|
+
// does not rebuild the client underneath an in-flight request.
|
|
121
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
122
|
+
[baseUrl, options.client]
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const [status, setStatus] = useState<LiveSchemaStatus | undefined>();
|
|
126
|
+
const [pending, setPending] = useState<PendingChange | undefined>();
|
|
127
|
+
const [plan, setPlan] = useState<LiveSchemaPlan | undefined>();
|
|
128
|
+
const [planError, setPlanError] = useState<string | undefined>();
|
|
129
|
+
const [result, setResult] = useState<LiveSchemaResult | undefined>();
|
|
130
|
+
const [applying, setApplying] = useState(false);
|
|
131
|
+
const [applyError, setApplyError] = useState<string | undefined>();
|
|
132
|
+
|
|
133
|
+
// The in-flight probe, so `ready()` can await the same one the effect
|
|
134
|
+
// started rather than issuing a second. Keyed by client+authKey the same way
|
|
135
|
+
// the effect is: a sign-in changes the answer, so it has to be re-asked.
|
|
136
|
+
// `undefined` passed explicitly: React 19's `useRef` takes no zero-argument
|
|
137
|
+
// overload, and omitting it is an error rather than a default.
|
|
138
|
+
const probe = useRef<{ key: unknown[]; promise: Promise<LiveSchemaStatus> } | undefined>(undefined);
|
|
139
|
+
|
|
140
|
+
/** Which review a plan response belongs to. See `reviewChange`. */
|
|
141
|
+
const reviewToken = useRef(0);
|
|
142
|
+
|
|
143
|
+
const ready = useCallback((): Promise<LiveSchemaStatus> => {
|
|
144
|
+
const key = [client, authKey];
|
|
145
|
+
const current = probe.current;
|
|
146
|
+
if (!current || current.key[0] !== key[0] || current.key[1] !== key[1]) {
|
|
147
|
+
probe.current = { key, promise: client.status() };
|
|
148
|
+
}
|
|
149
|
+
return probe.current!.promise;
|
|
150
|
+
}, [client, authKey]);
|
|
151
|
+
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
let cancelled = false;
|
|
154
|
+
void ready().then(answer => {
|
|
155
|
+
if (!cancelled) setStatus(answer);
|
|
156
|
+
});
|
|
157
|
+
return () => { cancelled = true; };
|
|
158
|
+
}, [ready]);
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Ask what the change would do, and ignore an answer that arrives late.
|
|
162
|
+
*
|
|
163
|
+
* Separate from `reviewChange` so it can be issued *again* without starting
|
|
164
|
+
* a new review: a plan that failed on a dropped connection should cost a
|
|
165
|
+
* click, not the whole wizard — the person has already filled in the form,
|
|
166
|
+
* and making them do it twice for a transient failure is the kind of thing
|
|
167
|
+
* that teaches people to distrust the preview.
|
|
168
|
+
*/
|
|
169
|
+
const requestPlan = useCallback(async (change: ProposedCollectionChange) => {
|
|
170
|
+
// Identifies the review a response belongs to. A plan that arrives after
|
|
171
|
+
// its review was replaced must not be shown: the dialog would describe
|
|
172
|
+
// the *previous* change while the reader believes it describes this one.
|
|
173
|
+
const token = ++reviewToken.current;
|
|
174
|
+
try {
|
|
175
|
+
const answer = await client.plan(change);
|
|
176
|
+
if (token === reviewToken.current) setPlan(answer);
|
|
177
|
+
} catch (err: unknown) {
|
|
178
|
+
if (token !== reviewToken.current) return;
|
|
179
|
+
setPlanError(
|
|
180
|
+
err instanceof LiveSchemaError || err instanceof Error
|
|
181
|
+
? err.message
|
|
182
|
+
: String(err)
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}, [client]);
|
|
186
|
+
|
|
187
|
+
const onRetry = useCallback(() => {
|
|
188
|
+
if (!pending) return;
|
|
189
|
+
setPlanError(undefined);
|
|
190
|
+
setPlan(undefined);
|
|
191
|
+
void requestPlan(pending.change);
|
|
192
|
+
}, [pending, requestPlan]);
|
|
193
|
+
|
|
194
|
+
const clear = useCallback(() => {
|
|
195
|
+
setPending(undefined);
|
|
196
|
+
setPlan(undefined);
|
|
197
|
+
setPlanError(undefined);
|
|
198
|
+
setResult(undefined);
|
|
199
|
+
setApplyError(undefined);
|
|
200
|
+
setApplying(false);
|
|
201
|
+
}, []);
|
|
202
|
+
|
|
203
|
+
const reviewChange = useCallback((change: ProposedCollectionChange) => {
|
|
204
|
+
return new Promise<void>((resolve, reject) => {
|
|
205
|
+
// A second review while one is open — a double-clicked save —
|
|
206
|
+
// replaces the pending one, and the one it replaces has to be
|
|
207
|
+
// settled. `setPending` used to overwrite `resolve`/`reject` with
|
|
208
|
+
// nobody left holding them, so the first caller's promise never
|
|
209
|
+
// settled at all and the editor's save simply never returned.
|
|
210
|
+
setPending(previous => {
|
|
211
|
+
previous?.reject(new SchemaChangeCancelled());
|
|
212
|
+
return { change, resolve, reject };
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Identifies the review a response belongs to. A plan that arrives
|
|
216
|
+
// after its review was replaced must not be shown: the dialog would
|
|
217
|
+
// describe the *previous* change while the reader believes it
|
|
218
|
+
// describes this one, and the SQL on screen would be for something
|
|
219
|
+
// they are not confirming.
|
|
220
|
+
setPlan(undefined);
|
|
221
|
+
setPlanError(undefined);
|
|
222
|
+
setResult(undefined);
|
|
223
|
+
setApplyError(undefined);
|
|
224
|
+
setApplying(false);
|
|
225
|
+
|
|
226
|
+
void requestPlan(change);
|
|
227
|
+
});
|
|
228
|
+
// `client` is not listed: this no longer touches it directly, and
|
|
229
|
+
// `requestPlan` already carries that dependency.
|
|
230
|
+
}, [requestPlan]);
|
|
231
|
+
|
|
232
|
+
const onConfirm = useCallback(() => {
|
|
233
|
+
if (!pending) return;
|
|
234
|
+
setApplying(true);
|
|
235
|
+
setApplyError(undefined);
|
|
236
|
+
void client.apply(pending.change).then(
|
|
237
|
+
answer => {
|
|
238
|
+
setResult(answer);
|
|
239
|
+
setApplying(false);
|
|
240
|
+
// Resolved here, not on close. The change has happened, and the
|
|
241
|
+
// dialog is now a receipt somebody may read for as long as they
|
|
242
|
+
// like — holding the caller open until they close it would
|
|
243
|
+
// leave the editor mid-save for no reason.
|
|
244
|
+
pending.resolve();
|
|
245
|
+
setPending(undefined);
|
|
246
|
+
},
|
|
247
|
+
(err: unknown) => {
|
|
248
|
+
setApplying(false);
|
|
249
|
+
setApplyError(err instanceof Error ? err.message : String(err));
|
|
250
|
+
}
|
|
251
|
+
);
|
|
252
|
+
}, [client, pending]);
|
|
253
|
+
|
|
254
|
+
const onSourceOnly = useCallback(() => {
|
|
255
|
+
if (!pending || !writeSourceOnly) return;
|
|
256
|
+
setApplying(true);
|
|
257
|
+
setApplyError(undefined);
|
|
258
|
+
const settle = pending;
|
|
259
|
+
void writeSourceOnly(settle.change).then(
|
|
260
|
+
() => { clear(); settle.resolve(); },
|
|
261
|
+
(err: unknown) => {
|
|
262
|
+
setApplying(false);
|
|
263
|
+
setApplyError(err instanceof Error ? err.message : String(err));
|
|
264
|
+
}
|
|
265
|
+
);
|
|
266
|
+
}, [pending, writeSourceOnly, clear]);
|
|
267
|
+
|
|
268
|
+
const onClose = useCallback(() => {
|
|
269
|
+
// Escape and the backdrop reach this too, and the Cancel *button* is the
|
|
270
|
+
// only one of the three that was disabled while applying. Closing
|
|
271
|
+
// mid-apply rejected the caller as cancelled while the commit and the
|
|
272
|
+
// DDL carried on — reporting that nothing happened while something did,
|
|
273
|
+
// which is the exact failure this whole feature exists to end.
|
|
274
|
+
if (applying) return;
|
|
275
|
+
|
|
276
|
+
const settle = pending;
|
|
277
|
+
clear();
|
|
278
|
+
// A dialog showing a result has already resolved; closing it is not a
|
|
279
|
+
// cancellation of anything.
|
|
280
|
+
if (settle && !result) settle.reject(new SchemaChangeCancelled());
|
|
281
|
+
}, [applying, pending, result, clear]);
|
|
282
|
+
|
|
283
|
+
// Not even a Suspense boundary until there is something to review — the
|
|
284
|
+
// chunk is fetched by the first `reviewChange`, and an admin session that
|
|
285
|
+
// never edits a collection never pays for it.
|
|
286
|
+
const open = Boolean(pending) || Boolean(result);
|
|
287
|
+
const dialog = open
|
|
288
|
+
? (
|
|
289
|
+
<Suspense fallback={null}>
|
|
290
|
+
<SchemaChangeDialog
|
|
291
|
+
open={open}
|
|
292
|
+
collectionId={pending?.change.collectionId ?? ""}
|
|
293
|
+
plan={plan}
|
|
294
|
+
planError={planError}
|
|
295
|
+
result={result}
|
|
296
|
+
applying={applying}
|
|
297
|
+
applyError={applyError}
|
|
298
|
+
onConfirm={onConfirm}
|
|
299
|
+
onRetry={planError ? onRetry : undefined}
|
|
300
|
+
onSourceOnly={writeSourceOnly ? onSourceOnly : undefined}
|
|
301
|
+
onClose={onClose}
|
|
302
|
+
applyRefusedBecause={
|
|
303
|
+
status && !status.canApply ? status.applyRefusedBecause : undefined
|
|
304
|
+
}
|
|
305
|
+
target={status && {
|
|
306
|
+
repository: status.repository,
|
|
307
|
+
branch: status.branch
|
|
308
|
+
}}
|
|
309
|
+
/>
|
|
310
|
+
</Suspense>
|
|
311
|
+
)
|
|
312
|
+
: null;
|
|
313
|
+
|
|
314
|
+
return { status, ready, reviewChange, dialog };
|
|
315
|
+
}
|
|
@@ -5,6 +5,7 @@ import { getSubcollections } from "@rebasepro/common";
|
|
|
5
5
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
6
6
|
import type { AdminCollection } from "@rebasepro/admin-types";
|
|
7
7
|
import { DEFAULT_API_PATH } from "@rebasepro/app";
|
|
8
|
+
import { useLiveSchemaEditing } from "./useLiveSchemaEditing";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* What the backend said about its schema editor, and how sure we are.
|
|
@@ -172,6 +173,47 @@ export function useLocalCollectionsConfigController(
|
|
|
172
173
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
173
174
|
}, [editorUrl, forcedReadOnly, authKey]);
|
|
174
175
|
|
|
176
|
+
// ── Live schema editing ───────────────────────────────────────────
|
|
177
|
+
// The same edit, against a backend that can also change the database and
|
|
178
|
+
// commit the result. When it is available, a save is planned and shown
|
|
179
|
+
// before anything happens; when it is not, this is inert and writes go
|
|
180
|
+
// straight to the source-only editor exactly as before.
|
|
181
|
+
const liveSchema = useLiveSchemaEditing({
|
|
182
|
+
baseUrl: `${String(clientBaseUrl).replace(/\/$/, "")}${clientApiPath}/admin/schema`,
|
|
183
|
+
getAuthToken: resolveToken,
|
|
184
|
+
authKey,
|
|
185
|
+
// The fallback the dialog offers when a change cannot be applied —
|
|
186
|
+
// removing a property, most often, which the ensure path has no way to
|
|
187
|
+
// carry out. Writing the source and leaving the database alone is what
|
|
188
|
+
// the editor did before any of this existed.
|
|
189
|
+
writeSourceOnly: async ({ collectionId, collection }) => {
|
|
190
|
+
await request("/collection/save", { collectionId, collectionData: collection });
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Perform a write, through the live editor when there is one.
|
|
196
|
+
*
|
|
197
|
+
* `sourceOnly` is the write this controller has always done. It stays the
|
|
198
|
+
* whole behaviour on a backend that cannot edit its schema — a bundle
|
|
199
|
+
* deployment, a non-Postgres driver, a version too old to be asked — so
|
|
200
|
+
* nothing about the editor depends on the feature being there.
|
|
201
|
+
*/
|
|
202
|
+
const write = async (
|
|
203
|
+
collectionId: string,
|
|
204
|
+
collection: Record<string, unknown>,
|
|
205
|
+
sourceOnly: () => Promise<void>
|
|
206
|
+
): Promise<void> => {
|
|
207
|
+
// `ready()`, not `status` — the rendered status is undefined for one
|
|
208
|
+
// round trip after mount, so reading it here would send a save issued
|
|
209
|
+
// in that window down the source-only path with no confirmation, while
|
|
210
|
+
// the same save a second later would open a dialog. One round trip is
|
|
211
|
+
// cheap; behaviour that depends on how fast somebody clicked is not.
|
|
212
|
+
const available = await liveSchema.ready();
|
|
213
|
+
if (!available.enabled) return sourceOnly();
|
|
214
|
+
await liveSchema.reviewChange({ collectionId, collection });
|
|
215
|
+
};
|
|
216
|
+
|
|
175
217
|
const readOnly = forcedReadOnly
|
|
176
218
|
?? (availability.state === "known" ? !availability.enabled : buildModeGuess());
|
|
177
219
|
|
|
@@ -179,24 +221,42 @@ export function useLocalCollectionsConfigController(
|
|
|
179
221
|
? availability.reason
|
|
180
222
|
: "Collections can only be edited against a backend running the schema editor, which is off in production.";
|
|
181
223
|
|
|
224
|
+
const findCollection = (id: string): AdminCollection | undefined =>
|
|
225
|
+
parsedCollections.find(c => (c as AdminCollection & { id?: string }).id === id || c.slug === id);
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The whole collection as it should end up.
|
|
229
|
+
*
|
|
230
|
+
* Live editing computes the proposed state by replacing one collection in
|
|
231
|
+
* the set, so it needs all of it: handed a patch, it would read every
|
|
232
|
+
* property the patch does not mention as a removal and refuse the change.
|
|
233
|
+
* A collection this does not recognise is a new one, and the patch is the
|
|
234
|
+
* whole of it.
|
|
235
|
+
*/
|
|
236
|
+
const wholeCollection = (id: string, patch: Record<string, unknown>): Record<string, unknown> => {
|
|
237
|
+
const current = findCollection(id) as Record<string, unknown> | undefined;
|
|
238
|
+
return current ? { ...current, ...patch } : patch;
|
|
239
|
+
};
|
|
240
|
+
|
|
182
241
|
return useMemo(() => ({
|
|
183
242
|
loading: false,
|
|
184
243
|
readOnly,
|
|
185
244
|
readOnlyReason,
|
|
186
245
|
collections: parsedCollections,
|
|
246
|
+
dialog: liveSchema.dialog,
|
|
187
247
|
getCollection: (id: string) => {
|
|
188
|
-
const found =
|
|
248
|
+
const found = findCollection(id);
|
|
189
249
|
if (found) return found;
|
|
190
250
|
throw Error(`Collection ${id} not found in local mode`);
|
|
191
251
|
},
|
|
192
252
|
|
|
193
253
|
saveCollection: async ({ id, collectionData }: SaveCollectionParams) => {
|
|
194
|
-
await
|
|
195
|
-
collectionData });
|
|
254
|
+
await write(id, collectionData as unknown as Record<string, unknown>, () =>
|
|
255
|
+
request("/collection/save", { collectionId: id, collectionData }));
|
|
196
256
|
},
|
|
197
257
|
updateCollection: async ({ id, collectionData }: UpdateCollectionParams) => {
|
|
198
|
-
await
|
|
199
|
-
collectionData });
|
|
258
|
+
await write(id, wholeCollection(id, collectionData as Record<string, unknown>), () =>
|
|
259
|
+
request("/collection/save", { collectionId: id, collectionData }));
|
|
200
260
|
},
|
|
201
261
|
deleteCollection: async ({ id }: DeleteCollectionParams) => {
|
|
202
262
|
await request("/collection/delete", { collectionId: id });
|
|
@@ -207,14 +267,30 @@ collectionData });
|
|
|
207
267
|
// mention — `securityRules` first, which then falls back to the
|
|
208
268
|
// directory default and widens who can read the collection.
|
|
209
269
|
saveProperty: async ({ path, propertyKey, property, newPropertiesOrder }: SavePropertyParams) => {
|
|
210
|
-
|
|
270
|
+
// Assembled into the whole collection before it is planned. This is
|
|
271
|
+
// the write that most often *is* a schema change — adding a field
|
|
272
|
+
// to a collection is adding a column — and the one whose payload
|
|
273
|
+
// says least about it on its own.
|
|
274
|
+
const current = findCollection(path) as Record<string, unknown> | undefined;
|
|
275
|
+
const properties = {
|
|
276
|
+
...(current?.properties as Record<string, unknown> | undefined ?? {}),
|
|
277
|
+
[propertyKey]: property
|
|
278
|
+
};
|
|
279
|
+
const proposed = wholeCollection(path, {
|
|
280
|
+
properties,
|
|
281
|
+
...(newPropertiesOrder ? { propertiesOrder: newPropertiesOrder } : {})
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
await write(path, proposed, async () => {
|
|
285
|
+
await request("/property/save", { collectionId: path,
|
|
211
286
|
propertyKey,
|
|
212
287
|
propertyConfig: property });
|
|
213
|
-
|
|
214
|
-
|
|
288
|
+
if (newPropertiesOrder) {
|
|
289
|
+
await request("/collection/save", { collectionId: path,
|
|
215
290
|
collectionData: { propertiesOrder: newPropertiesOrder },
|
|
216
291
|
partial: true });
|
|
217
|
-
|
|
292
|
+
}
|
|
293
|
+
});
|
|
218
294
|
},
|
|
219
295
|
deleteProperty: async ({ path, propertyKey, newPropertiesOrder }: DeletePropertyParams) => {
|
|
220
296
|
await request("/property/delete", { collectionId: path,
|
|
@@ -238,5 +314,28 @@ partial: true });
|
|
|
238
314
|
|
|
239
315
|
navigationEntries: [],
|
|
240
316
|
saveNavigationEntries: async () => { }
|
|
241
|
-
|
|
317
|
+
// `liveSchema.dialog` and `liveSchema.status` both change as the flow
|
|
318
|
+
// runs — a plan arrives, a dialog opens — and a controller memoised
|
|
319
|
+
// without them would keep handing back a closed dialog and a stale
|
|
320
|
+
// answer about whether the backend can edit its schema at all.
|
|
321
|
+
//
|
|
322
|
+
// `write`, `request`, `findCollection` and `wholeCollection` are
|
|
323
|
+
// rebuilt on every render, so listing them would defeat the memo
|
|
324
|
+
// entirely. Each is already covered *transitively* by what is listed:
|
|
325
|
+
// `request` closes over `editorUrl` and a ref, and `editorUrl` derives
|
|
326
|
+
// from `clientOrUrl`; `findCollection` and `wholeCollection` close over
|
|
327
|
+
// `parsedCollections`; `write` closes over `liveSchema.status` and
|
|
328
|
+
// `liveSchema.reviewChange`, and the latter is a `useCallback` keyed on
|
|
329
|
+
// a client built from `clientOrUrl`. Every input that can change is in
|
|
330
|
+
// the array under the name it actually varies with.
|
|
331
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
332
|
+
}), [
|
|
333
|
+
clientOrUrl,
|
|
334
|
+
parsedCollections,
|
|
335
|
+
readOnly,
|
|
336
|
+
readOnlyReason,
|
|
337
|
+
options?.getAuthToken,
|
|
338
|
+
liveSchema.dialog,
|
|
339
|
+
liveSchema.status
|
|
340
|
+
]);
|
|
242
341
|
}
|
|
@@ -27,15 +27,6 @@ export interface DrawerNavigationGroupProps {
|
|
|
27
27
|
* Whether the drawer is in open (expanded) state
|
|
28
28
|
*/
|
|
29
29
|
drawerOpen: boolean;
|
|
30
|
-
/**
|
|
31
|
-
* @deprecated No longer read. Entry tooltips are uncontrolled — they follow the
|
|
32
|
-
* pointer and focus, and are suppressed wherever the label is already visible.
|
|
33
|
-
*/
|
|
34
|
-
tooltipsOpen?: boolean;
|
|
35
|
-
/**
|
|
36
|
-
* @deprecated No longer read. See {@link tooltipsOpen}.
|
|
37
|
-
*/
|
|
38
|
-
adminMenuOpen?: boolean;
|
|
39
30
|
/**
|
|
40
31
|
* Optional actions to render in the group header (e.g., "Add collection" button)
|
|
41
32
|
*/
|
|
@@ -10,11 +10,7 @@ import { cls, Tooltip } from "@rebasepro/ui";
|
|
|
10
10
|
export type DrawerNavigationItemProps = {
|
|
11
11
|
icon: React.ReactElement,
|
|
12
12
|
name: string,
|
|
13
|
-
/** @deprecated No longer read: the tooltip is uncontrolled. */
|
|
14
|
-
tooltipsOpen?: boolean,
|
|
15
13
|
drawerOpen: boolean,
|
|
16
|
-
/** @deprecated No longer read: the tooltip is uncontrolled. */
|
|
17
|
-
adminMenuOpen?: boolean,
|
|
18
14
|
url: string,
|
|
19
15
|
onClick?: () => void,
|
|
20
16
|
/**
|
|
@@ -68,8 +68,6 @@ export interface RelationSelectorProps {
|
|
|
68
68
|
size?: "small" | "medium" | "large";
|
|
69
69
|
useChips?: boolean;
|
|
70
70
|
disabled?: boolean;
|
|
71
|
-
error?: boolean; // kept for backwards compatibility (could be used for styling later)
|
|
72
|
-
padding?: boolean; // legacy prop
|
|
73
71
|
invisible?: boolean;
|
|
74
72
|
|
|
75
73
|
relation: Relation;
|
|
@@ -2,7 +2,7 @@ import { getPropertyInPath } from "../../util";
|
|
|
2
2
|
import { Entity, EntityReference, CollectionRegistryController, Properties, Property, Vector } from "@rebasepro/types";
|
|
3
3
|
import { AuthController, AdminCollection } from "@rebasepro/admin-types";
|
|
4
4
|
import { isPropertyBuilder } from "@rebasepro/common";
|
|
5
|
-
import { unflattenObject } from "./
|
|
5
|
+
import { unflattenObject } from "./transforms";
|
|
6
6
|
import { getIn } from "@rebasepro/forms";
|
|
7
7
|
import { inferTypeFromValue } from "@rebasepro/inference";
|
|
8
8
|
import { isPrototypePollutingKey, mergeDeep } from "@rebasepro/utils";
|
|
@@ -3,9 +3,6 @@ import { mapJsonParse, unflattenObject } from "./transforms";
|
|
|
3
3
|
import { parseCsvToObjects } from "./csv";
|
|
4
4
|
import { isPrototypePollutingKey } from "@rebasepro/utils";
|
|
5
5
|
|
|
6
|
-
// Re-export for backwards compat (tests import from this file)
|
|
7
|
-
export { unflattenObject } from "./transforms";
|
|
8
|
-
|
|
9
6
|
type ConversionResult = {
|
|
10
7
|
data: object[];
|
|
11
8
|
propertiesOrder: string[]
|
package/src/index.ts
CHANGED
|
@@ -112,6 +112,12 @@ export * from "./data_export";
|
|
|
112
112
|
|
|
113
113
|
// Collection editor — moved from @rebasepro/studio (admin-dependent visual schema editor)
|
|
114
114
|
export * from "./collection_editor";
|
|
115
|
+
export * from "./collection_editor/liveSchemaClient";
|
|
116
|
+
export * from "./collection_editor/useLiveSchemaEditing";
|
|
117
|
+
// `SchemaChangeDialog` is deliberately absent, like `CollectionEditorDialog`
|
|
118
|
+
// and `PropertyFormDialog` beside it: re-exporting it from the barrel puts it
|
|
119
|
+
// back in the eager bundle and undoes the `lazyChunk` in `useLiveSchemaEditing`.
|
|
120
|
+
// Import it by path if you need the component itself.
|
|
115
121
|
|
|
116
122
|
|
|
117
123
|
export { CreationResultDialog } from "./components/admin/CreationResultDialog";
|
package/src/util/previews.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Property } from "@rebasepro/types";
|
|
2
|
-
import type {
|
|
2
|
+
import type { AdminCollection } from "@rebasepro/admin-types";
|
|
3
3
|
import { getTitlePropertyKey, getTitlePropertyKeyForValues } from "@rebasepro/app";
|
|
4
4
|
import { isPropertyBuilder } from "@rebasepro/common";
|
|
5
5
|
|
|
@@ -21,7 +21,9 @@ export { getEntityPreviewKeys } from "@rebasepro/app";
|
|
|
21
21
|
* called. Identifiers are excluded from the property *schema* — primary keys,
|
|
22
22
|
* foreign keys, UUID columns — not from the key name.
|
|
23
23
|
*/
|
|
24
|
-
export function getEntityTitlePropertyKey<M extends Record<string, unknown>>(
|
|
24
|
+
export function getEntityTitlePropertyKey<M extends Record<string, unknown>>(
|
|
25
|
+
collection: AdminCollection<M>
|
|
26
|
+
): string | undefined {
|
|
25
27
|
return getTitlePropertyKey(collection);
|
|
26
28
|
}
|
|
27
29
|
|