@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.
Files changed (180) hide show
  1. package/dist/{CollectionEditorDialog-D2Vf4C3-.js → CollectionEditorDialog-Dt_Wv20U.js} +7 -3
  2. package/dist/{CollectionEditorDialog-D2Vf4C3-.js.map → CollectionEditorDialog-Dt_Wv20U.js.map} +1 -1
  3. package/dist/{PropertyEditView-D3NBsK9_.js → PropertyEditView-C4IltWX5.js} +2 -2
  4. package/dist/{PropertyEditView-D3NBsK9_.js.map → PropertyEditView-C4IltWX5.js.map} +1 -1
  5. package/dist/{RouterCollectionsStudioView-hPB54l3Q.js → RouterCollectionsStudioView-C-BHWJ0J.js} +4 -4
  6. package/dist/{RouterCollectionsStudioView-hPB54l3Q.js.map → RouterCollectionsStudioView-C-BHWJ0J.js.map} +1 -1
  7. package/dist/SchemaChangeDialog-DceT8QFX.js +394 -0
  8. package/dist/SchemaChangeDialog-DceT8QFX.js.map +1 -0
  9. package/dist/collection_editor/ConfigControllerProvider.d.ts +4 -4
  10. package/dist/collection_editor/_cms_internals.d.ts +23 -23
  11. package/dist/collection_editor/api/index.d.ts +1 -1
  12. package/dist/collection_editor/extensibility_types.d.ts +1 -1
  13. package/dist/collection_editor/index.d.ts +21 -21
  14. package/dist/collection_editor/liveSchemaClient.d.ts +182 -0
  15. package/dist/collection_editor/serializable_types.d.ts +0 -1
  16. package/dist/collection_editor/serializable_utils.d.ts +1 -1
  17. package/dist/collection_editor/types/collection_editor_controller.d.ts +1 -1
  18. package/dist/collection_editor/types/config_controller.d.ts +11 -0
  19. package/dist/collection_editor/ui/collection_editor/AICollectionGeneratorPopover.d.ts +1 -1
  20. package/dist/collection_editor/ui/collection_editor/AIModifiedPathsContext.d.ts +1 -1
  21. package/dist/collection_editor/ui/collection_editor/CollectionEditorDialog.d.ts +4 -4
  22. package/dist/collection_editor/ui/collection_editor/CollectionEditorWelcomeView.d.ts +1 -1
  23. package/dist/collection_editor/ui/collection_editor/CollectionPropertiesEditorForm.d.ts +2 -2
  24. package/dist/collection_editor/ui/collection_editor/CollectionStudioView.d.ts +1 -1
  25. package/dist/collection_editor/ui/collection_editor/CollectionsStudioView.d.ts +2 -2
  26. package/dist/collection_editor/ui/collection_editor/GeneralSettingsForm.d.ts +1 -1
  27. package/dist/collection_editor/ui/collection_editor/PropertyEditView.d.ts +2 -2
  28. package/dist/collection_editor/ui/collection_editor/RouterCollectionStudioView.d.ts +1 -1
  29. package/dist/collection_editor/ui/collection_editor/RouterCollectionsStudioView.d.ts +1 -1
  30. package/dist/collection_editor/ui/collection_editor/SchemaChangeDialog.d.ts +85 -0
  31. package/dist/collection_editor/ui/collection_editor/SubcollectionsEditTab.d.ts +2 -2
  32. package/dist/collection_editor/ui/collection_editor/import/CollectionEditorImportDataPreview.d.ts +1 -1
  33. package/dist/collection_editor/ui/collection_editor/import/CollectionEditorImportMapping.d.ts +1 -1
  34. package/dist/collection_editor/ui/collection_editor/import/clean_import_data.d.ts +1 -1
  35. package/dist/collection_editor/ui/collection_editor/properties/conditions/index.d.ts +6 -6
  36. package/dist/collection_editor/useCollectionEditorController.d.ts +1 -1
  37. package/dist/collection_editor/useCollectionsConfigController.d.ts +1 -1
  38. package/dist/collection_editor/useJsonCollectionsConfigController.d.ts +2 -2
  39. package/dist/collection_editor/useLiveSchemaEditing.d.ts +71 -0
  40. package/dist/collection_editor/useLocalCollectionsConfigController.d.ts +1 -1
  41. package/dist/collection_editor_ui.d.ts +12 -12
  42. package/dist/collection_editor_ui.js +4 -4
  43. package/dist/components/CollectionTableBinding/CollectionTableBinding.d.ts +1 -1
  44. package/dist/components/CollectionTableBinding/PropertyTableCell.d.ts +1 -1
  45. package/dist/components/CollectionTableBinding/fields/TableStorageUpload.d.ts +1 -1
  46. package/dist/components/CollectionTableBinding/index.d.ts +5 -5
  47. package/dist/components/CollectionTableBinding/internal/popup_field/PopupFormField.d.ts +1 -1
  48. package/dist/components/CollectionViewBinding/SlotValue.d.ts +2 -2
  49. package/dist/components/CollectionViewBinding/hooks/useKanbanDragAndDrop.d.ts +1 -1
  50. package/dist/components/DrawerNavigationGroup.d.ts +0 -9
  51. package/dist/components/DrawerNavigationItem.d.ts +0 -4
  52. package/dist/components/EditFormActions.d.ts +1 -1
  53. package/dist/components/EditViewBinding.d.ts +3 -3
  54. package/dist/components/EntityInspector.d.ts +1 -1
  55. package/dist/components/EntityPreviewBinding.d.ts +1 -1
  56. package/dist/components/EntityViewBinding.d.ts +1 -1
  57. package/dist/components/HomePage/index.d.ts +4 -4
  58. package/dist/components/PropertyCollectionView.d.ts +1 -1
  59. package/dist/components/ReferenceWidget.d.ts +1 -1
  60. package/dist/components/RelationSelector.d.ts +0 -2
  61. package/dist/components/admin/index.d.ts +1 -1
  62. package/dist/components/app/AppBar.d.ts +1 -1
  63. package/dist/components/app/index.d.ts +4 -4
  64. package/dist/components/common/index.d.ts +1 -1
  65. package/dist/components/field_configs.d.ts +1 -1
  66. package/dist/components/history/EntityHistoryEntry.d.ts +2 -2
  67. package/dist/components/history/index.d.ts +5 -5
  68. package/dist/components/index.d.ts +45 -45
  69. package/dist/data_export/export/index.d.ts +4 -4
  70. package/dist/data_export/index.d.ts +1 -1
  71. package/dist/data_import/components/DataNewPropertiesMapping.d.ts +1 -1
  72. package/dist/data_import/components/ImportSaveInProgress.d.ts +1 -1
  73. package/dist/data_import/components/index.d.ts +4 -4
  74. package/dist/data_import/hooks/index.d.ts +1 -1
  75. package/dist/data_import/hooks/useImportConfig.d.ts +1 -1
  76. package/dist/data_import/import/ImportCollectionAction.d.ts +1 -1
  77. package/dist/data_import/import/index.d.ts +1 -1
  78. package/dist/data_import/index.d.ts +5 -5
  79. package/dist/data_import/types/index.d.ts +1 -1
  80. package/dist/data_import/utils/file_to_json.d.ts +1 -1
  81. package/dist/data_import/utils/index.d.ts +5 -5
  82. package/dist/editor/components/SlashCommandMenu.d.ts +2 -2
  83. package/dist/editor/components/index.d.ts +5 -5
  84. package/dist/editor/editor.d.ts +2 -2
  85. package/dist/editor/index.d.ts +2 -2
  86. package/dist/editor/nodeViews/ImageComponent.d.ts +1 -1
  87. package/dist/editor/nodeViews/TaskItemComponent.d.ts +1 -1
  88. package/dist/editor/types.d.ts +1 -1
  89. package/dist/{export-oauWlxuu.js → export-CF1-p5nY.js} +2 -2
  90. package/dist/{export-oauWlxuu.js.map → export-CF1-p5nY.js.map} +1 -1
  91. package/dist/form/EntityForm.d.ts +1 -1
  92. package/dist/form/EntityFormActions.d.ts +1 -1
  93. package/dist/form/EntityFormBinding.d.ts +1 -1
  94. package/dist/form/PropertyFieldBinding.d.ts +1 -1
  95. package/dist/form/components/StorageItemPreview.d.ts +1 -1
  96. package/dist/form/components/index.d.ts +6 -6
  97. package/dist/form/field_bindings/ArrayCustomShapedFieldBinding.d.ts +1 -1
  98. package/dist/form/field_bindings/ArrayOfReferencesFieldBinding.d.ts +1 -1
  99. package/dist/form/field_bindings/BinaryFieldBinding.d.ts +1 -1
  100. package/dist/form/field_bindings/BlockFieldBinding.d.ts +1 -1
  101. package/dist/form/field_bindings/DateTimeFieldBinding.d.ts +1 -1
  102. package/dist/form/field_bindings/GeopointFieldBinding.d.ts +1 -1
  103. package/dist/form/field_bindings/KeyValueFieldBinding.d.ts +1 -1
  104. package/dist/form/field_bindings/MapFieldBinding.d.ts +1 -1
  105. package/dist/form/field_bindings/MarkdownEditorFieldBinding.d.ts +2 -2
  106. package/dist/form/field_bindings/MultiSelectFieldBinding.d.ts +1 -1
  107. package/dist/form/field_bindings/MultipleRelationFieldBinding.d.ts +1 -1
  108. package/dist/form/field_bindings/ReadOnlyFieldBinding.d.ts +1 -1
  109. package/dist/form/field_bindings/ReferenceFieldBinding.d.ts +1 -1
  110. package/dist/form/field_bindings/RelationFieldBinding.d.ts +1 -1
  111. package/dist/form/field_bindings/RepeatFieldBinding.d.ts +1 -1
  112. package/dist/form/field_bindings/SelectFieldBinding.d.ts +1 -1
  113. package/dist/form/field_bindings/StorageUploadFieldBinding.d.ts +1 -1
  114. package/dist/form/field_bindings/SwitchFieldBinding.d.ts +1 -1
  115. package/dist/form/field_bindings/TextFieldBinding.d.ts +1 -1
  116. package/dist/form/field_bindings/UserSelectFieldBinding.d.ts +1 -1
  117. package/dist/form/field_bindings/VectorFieldBinding.d.ts +1 -1
  118. package/dist/form/index.d.ts +24 -24
  119. package/dist/{history-C2cqc0bw.js → history-BoT8GhO1.js} +2 -2
  120. package/dist/{history-C2cqc0bw.js.map → history-BoT8GhO1.js.map} +1 -1
  121. package/dist/hooks/index.d.ts +16 -16
  122. package/dist/hooks/navigation/contexts/index.d.ts +3 -3
  123. package/dist/hooks/useBuildSideDialogsController.d.ts +1 -1
  124. package/dist/hooks/useSelectionDialog.d.ts +1 -1
  125. package/dist/{import-Bxv5JPm-.js → import-Dg4X01sA.js} +2 -2
  126. package/dist/{import-Bxv5JPm-.js.map → import-Dg4X01sA.js.map} +1 -1
  127. package/dist/index.d.ts +17 -15
  128. package/dist/index.js +262 -21
  129. package/dist/index.js.map +1 -1
  130. package/dist/preview/PropertyPreview.d.ts +1 -1
  131. package/dist/preview/components/ArrayEnumPreview.d.ts +1 -1
  132. package/dist/preview/components/BooleanPreview.d.ts +1 -1
  133. package/dist/preview/components/ImagePreview.d.ts +1 -1
  134. package/dist/preview/components/ReferencePreview.d.ts +1 -1
  135. package/dist/preview/components/RelationPreview.d.ts +1 -1
  136. package/dist/preview/components/StorageThumbnail.d.ts +1 -1
  137. package/dist/preview/components/UrlComponentPreview.d.ts +2 -2
  138. package/dist/preview/components/UserPreview.d.ts +1 -1
  139. package/dist/preview/index.d.ts +24 -24
  140. package/dist/preview/property_previews/ArrayOfMapsPreview.d.ts +1 -1
  141. package/dist/preview/property_previews/ArrayOfReferencesPreview.d.ts +1 -1
  142. package/dist/preview/property_previews/ArrayOfRelationsPreview.d.ts +1 -1
  143. package/dist/preview/property_previews/ArrayOfStorageComponentsPreview.d.ts +1 -1
  144. package/dist/preview/property_previews/ArrayOfStringsPreview.d.ts +1 -1
  145. package/dist/preview/property_previews/ArrayOneOfPreview.d.ts +1 -1
  146. package/dist/preview/property_previews/ArrayPropertyEnumPreview.d.ts +1 -1
  147. package/dist/preview/property_previews/ArrayPropertyPreview.d.ts +1 -1
  148. package/dist/preview/property_previews/MapPropertyPreview.d.ts +1 -1
  149. package/dist/preview/property_previews/NumberPropertyPreview.d.ts +1 -1
  150. package/dist/preview/property_previews/SkeletonPropertyComponent.d.ts +1 -1
  151. package/dist/preview/property_previews/StringPropertyPreview.d.ts +1 -1
  152. package/dist/preview/util.d.ts +1 -1
  153. package/dist/routes/index.d.ts +2 -2
  154. package/dist/types/components/EntityFormActionsProps.d.ts +2 -2
  155. package/dist/types/components/EntityFormProps.d.ts +3 -3
  156. package/dist/types/components/index.d.ts +3 -3
  157. package/dist/types/fields.d.ts +1 -1
  158. package/dist/types/index.d.ts +2 -2
  159. package/dist/util/index.d.ts +5 -5
  160. package/dist/util/previews.d.ts +2 -2
  161. package/dist/{util-C4qsI1QV.js → util-DUIsQ2zh.js} +132 -9
  162. package/dist/util-DUIsQ2zh.js.map +1 -0
  163. package/package.json +11 -11
  164. package/src/collection_editor/ConfigControllerProvider.tsx +5 -0
  165. package/src/collection_editor/liveSchemaClient.ts +285 -0
  166. package/src/collection_editor/serializable_types.ts +0 -1
  167. package/src/collection_editor/serializable_utils.ts +0 -1
  168. package/src/collection_editor/types/config_controller.tsx +11 -0
  169. package/src/collection_editor/ui/collection_editor/CollectionEditorDialog.tsx +10 -0
  170. package/src/collection_editor/ui/collection_editor/SchemaChangeDialog.tsx +470 -0
  171. package/src/collection_editor/useLiveSchemaEditing.tsx +315 -0
  172. package/src/collection_editor/useLocalCollectionsConfigController.tsx +109 -10
  173. package/src/components/DrawerNavigationGroup.tsx +0 -9
  174. package/src/components/DrawerNavigationItem.tsx +0 -4
  175. package/src/components/RelationSelector.tsx +0 -2
  176. package/src/data_import/utils/data.ts +1 -1
  177. package/src/data_import/utils/file_to_json.ts +0 -3
  178. package/src/index.ts +6 -0
  179. package/src/util/previews.ts +4 -2
  180. 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 = parsedCollections.find(c => (c as AdminCollection & { id?: string }).id === id || c.slug === id);
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 request("/collection/save", { collectionId: id,
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 request("/collection/save", { collectionId: id,
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
- await request("/property/save", { collectionId: path,
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
- if (newPropertiesOrder) {
214
- await request("/collection/save", { collectionId: path,
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
- }), [clientOrUrl, parsedCollections, readOnly, readOnlyReason, options?.getAuthToken]);
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 "./file_to_json";
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";
@@ -1,5 +1,5 @@
1
1
  import type { Property } from "@rebasepro/types";
2
- import type { PropertyConfig, AdminCollection } from "@rebasepro/admin-types";
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>>(collection: AdminCollection<M>, propertyConfigs: Record<string, PropertyConfig>): string | undefined {
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