@rebasepro/admin 0.16.0 → 0.16.1-canary.g2d1aec8
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-Cec5jU6R.js} +7 -3
- package/dist/{CollectionEditorDialog-D2Vf4C3-.js.map → CollectionEditorDialog-Cec5jU6R.js.map} +1 -1
- package/dist/{PropertyEditView-D3NBsK9_.js → PropertyEditView-BuSPZwnQ.js} +2 -2
- package/dist/{PropertyEditView-D3NBsK9_.js.map → PropertyEditView-BuSPZwnQ.js.map} +1 -1
- package/dist/{RouterCollectionsStudioView-hPB54l3Q.js → RouterCollectionsStudioView-BiNFpEii.js} +4 -4
- package/dist/{RouterCollectionsStudioView-hPB54l3Q.js.map → RouterCollectionsStudioView-BiNFpEii.js.map} +1 -1
- package/dist/SchemaChangeDialog-WKIbzS6P.js +327 -0
- package/dist/SchemaChangeDialog-WKIbzS6P.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 +170 -0
- 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 +66 -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/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/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-BItpoIu0.js} +2 -2
- package/dist/{export-oauWlxuu.js.map → export-BItpoIu0.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-C7WF14VR.js} +2 -2
- package/dist/{history-C2cqc0bw.js.map → history-C7WF14VR.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-CZgNAlcd.js} +2 -2
- package/dist/{import-Bxv5JPm-.js.map → import-CZgNAlcd.js.map} +1 -1
- package/dist/index.d.ts +17 -15
- package/dist/index.js +238 -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-C4qsI1QV.js → util-wrBH4QSB.js} +130 -7
- package/dist/util-wrBH4QSB.js.map +1 -0
- package/package.json +10 -10
- package/src/collection_editor/ConfigControllerProvider.tsx +5 -0
- package/src/collection_editor/liveSchemaClient.ts +272 -0
- 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 +368 -0
- package/src/collection_editor/useLiveSchemaEditing.tsx +285 -0
- package/src/collection_editor/useLocalCollectionsConfigController.tsx +109 -10
- package/src/index.ts +6 -0
- package/dist/util-C4qsI1QV.js.map +0 -1
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a schema change would do, before it does it.
|
|
3
|
+
*
|
|
4
|
+
* The dialog exists because a schema change has three possible verdicts and two
|
|
5
|
+
* of them are refusals. Without a preview, the only way to discover that a
|
|
6
|
+
* change is refused is to press a button on a live database and read what comes
|
|
7
|
+
* back — which is a fine way to learn and a poor way to work.
|
|
8
|
+
*
|
|
9
|
+
* ## What it shows, in the order somebody reads it
|
|
10
|
+
*
|
|
11
|
+
* The verdict first, in a sentence, because it decides whether the rest is
|
|
12
|
+
* worth reading. Then the changes, each with what it does and — when it is
|
|
13
|
+
* refused — what to do instead. Then the things that are easy to skip past and
|
|
14
|
+
* expensive to have skipped: constraints the change asks for that the database
|
|
15
|
+
* will not end up enforcing. The SQL and the file list come last, collapsed,
|
|
16
|
+
* for the reader who wants them; they are the evidence, not the summary.
|
|
17
|
+
*
|
|
18
|
+
* ## Why the button says "Commit and apply"
|
|
19
|
+
*
|
|
20
|
+
* Because that is the order, and the order is the feature. The commit lands
|
|
21
|
+
* first, so a failed apply leaves the repository ahead of the database — the
|
|
22
|
+
* ordinary state of every project between an edit and a deploy, which the next
|
|
23
|
+
* boot reconciles. A button labelled "Save" would be describing a different,
|
|
24
|
+
* worse thing.
|
|
25
|
+
*/
|
|
26
|
+
import React, { useState } from "react";
|
|
27
|
+
import {
|
|
28
|
+
Alert,
|
|
29
|
+
Button,
|
|
30
|
+
Chip,
|
|
31
|
+
CircularProgress,
|
|
32
|
+
cls,
|
|
33
|
+
Dialog,
|
|
34
|
+
DialogActions,
|
|
35
|
+
DialogContent,
|
|
36
|
+
DialogTitle,
|
|
37
|
+
Typography
|
|
38
|
+
} from "@rebasepro/ui";
|
|
39
|
+
|
|
40
|
+
import type { LiveSchemaPlan, LiveSchemaResult, WithheldConstraint } from "../../liveSchemaClient";
|
|
41
|
+
|
|
42
|
+
export interface SchemaChangeDialogProps {
|
|
43
|
+
open: boolean;
|
|
44
|
+
/** The collection being changed, for the title. */
|
|
45
|
+
collectionId: string;
|
|
46
|
+
/** `undefined` while the plan is still being fetched. */
|
|
47
|
+
plan?: LiveSchemaPlan;
|
|
48
|
+
/** Set when planning itself failed — a network error, a refusal, a 500. */
|
|
49
|
+
planError?: string;
|
|
50
|
+
/** Set once the change has been applied, which turns this into a receipt. */
|
|
51
|
+
result?: LiveSchemaResult;
|
|
52
|
+
applying: boolean;
|
|
53
|
+
/** Set when the apply threw rather than returning a result. */
|
|
54
|
+
applyError?: string;
|
|
55
|
+
onConfirm: () => void;
|
|
56
|
+
/**
|
|
57
|
+
* Write the collection source and leave the database alone.
|
|
58
|
+
*
|
|
59
|
+
* Offered only when the change is refused, and it is what keeps the editor
|
|
60
|
+
* usable while it is. Removing a property is the ordinary case: the ensure
|
|
61
|
+
* path has no `DROP COLUMN`, so a removal can never be applied — and
|
|
62
|
+
* refusing the whole save over it would mean a dev could no longer delete a
|
|
63
|
+
* field from a collection they are still designing.
|
|
64
|
+
*
|
|
65
|
+
* This is exactly what the editor did before live editing existed, so it is
|
|
66
|
+
* not a new hazard; the difference is that it now says what it leaves
|
|
67
|
+
* behind instead of reporting success.
|
|
68
|
+
*/
|
|
69
|
+
onSourceOnly?: () => void;
|
|
70
|
+
onClose: () => void;
|
|
71
|
+
/**
|
|
72
|
+
* Why this caller may preview but not apply, when that is the case.
|
|
73
|
+
*
|
|
74
|
+
* A person signed in with an API key, most often. Shown next to a disabled
|
|
75
|
+
* button so the refusal arrives while they are deciding rather than after
|
|
76
|
+
* they have decided.
|
|
77
|
+
*/
|
|
78
|
+
applyRefusedBecause?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type Verdict = LiveSchemaPlan["verdict"];
|
|
82
|
+
|
|
83
|
+
/** What the verdict means, in the words the reader needs rather than the enum's. */
|
|
84
|
+
const VERDICT_COPY: Record<Verdict, { title: string; body: string; color: "info" | "warning" | "error" }> = {
|
|
85
|
+
safe: {
|
|
86
|
+
title: "Ready to apply",
|
|
87
|
+
body: "Every change here can be made against the running database.",
|
|
88
|
+
color: "info"
|
|
89
|
+
},
|
|
90
|
+
diverges: {
|
|
91
|
+
title: "This would leave the database out of step",
|
|
92
|
+
body: "The change can be written, but the database will not end up matching it. " +
|
|
93
|
+
"The details below say which part, and what would make it applicable.",
|
|
94
|
+
color: "warning"
|
|
95
|
+
},
|
|
96
|
+
"needs-migration": {
|
|
97
|
+
title: "This needs a migration",
|
|
98
|
+
body: "Some of this cannot be done to a running database at all — it drops or rewrites " +
|
|
99
|
+
"something, and that belongs in a migration somebody has read.",
|
|
100
|
+
color: "error"
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const VERDICT_CHIP: Record<Verdict, { label: string; scheme: "greenLighter" | "orangeLighter" | "redLighter" }> = {
|
|
105
|
+
safe: { label: "safe", scheme: "greenLighter" },
|
|
106
|
+
diverges: { label: "diverges", scheme: "orangeLighter" },
|
|
107
|
+
"needs-migration": { label: "needs migration", scheme: "redLighter" }
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const monospace = "font-mono text-xs leading-relaxed";
|
|
111
|
+
const codeBlock = cls(
|
|
112
|
+
monospace,
|
|
113
|
+
"bg-surface-100 dark:bg-surface-800 rounded-md p-3 overflow-x-auto whitespace-pre"
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
/** A disclosure that starts closed. The evidence, for the reader who wants it. */
|
|
117
|
+
function Details({ summary, count, children }: {
|
|
118
|
+
summary: string;
|
|
119
|
+
count: number;
|
|
120
|
+
children: React.ReactNode;
|
|
121
|
+
}) {
|
|
122
|
+
const [open, setOpen] = useState(false);
|
|
123
|
+
if (count === 0) return null;
|
|
124
|
+
return (
|
|
125
|
+
<div>
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
onClick={() => setOpen(o => !o)}
|
|
129
|
+
className="flex items-center gap-2 text-sm text-text-secondary dark:text-text-secondary-dark hover:underline"
|
|
130
|
+
>
|
|
131
|
+
<span className={cls("transition-transform", open && "rotate-90")}>›</span>
|
|
132
|
+
{summary} ({count})
|
|
133
|
+
</button>
|
|
134
|
+
{open && <div className="mt-2">{children}</div>}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function WithheldConstraints({ constraints }: { constraints: WithheldConstraint[] }) {
|
|
140
|
+
if (constraints.length === 0) return null;
|
|
141
|
+
return (
|
|
142
|
+
<Alert color="warning">
|
|
143
|
+
<Typography variant="body2" className="font-medium mb-1">
|
|
144
|
+
{constraints.length === 1
|
|
145
|
+
? "One constraint will not be enforced"
|
|
146
|
+
: `${constraints.length} constraints will not be enforced`}
|
|
147
|
+
</Typography>
|
|
148
|
+
<ul className="space-y-2">
|
|
149
|
+
{constraints.map(constraint => (
|
|
150
|
+
<li key={constraint.target} className="text-sm">
|
|
151
|
+
<code className="bg-surface-200 dark:bg-surface-700 px-1 rounded">
|
|
152
|
+
{constraint.target}
|
|
153
|
+
</code>
|
|
154
|
+
<div className="mt-1">{constraint.reason}</div>
|
|
155
|
+
<div className="mt-1 text-text-secondary dark:text-text-secondary-dark">
|
|
156
|
+
{constraint.remedy}
|
|
157
|
+
</div>
|
|
158
|
+
</li>
|
|
159
|
+
))}
|
|
160
|
+
</ul>
|
|
161
|
+
</Alert>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The receipt. Shown in place of the plan once the change has been applied. */
|
|
166
|
+
function Applied({ result }: { result: LiveSchemaResult }) {
|
|
167
|
+
return (
|
|
168
|
+
<div className="flex flex-col gap-4">
|
|
169
|
+
<Alert color={result.applied ? "success" : "warning"}>
|
|
170
|
+
<Typography variant="body2">{result.summary}</Typography>
|
|
171
|
+
</Alert>
|
|
172
|
+
|
|
173
|
+
{!result.applied && result.applyError && (
|
|
174
|
+
// Deliberately not an error state. The commit landed, which is
|
|
175
|
+
// the durable half; the database is now behind the repository,
|
|
176
|
+
// which is where every project sits between an edit and a
|
|
177
|
+
// deploy, and boot reconciles it.
|
|
178
|
+
<Alert color="warning">
|
|
179
|
+
<Typography variant="body2" className="font-medium mb-1">
|
|
180
|
+
The database was not changed
|
|
181
|
+
</Typography>
|
|
182
|
+
<Typography variant="body2">{result.applyError}</Typography>
|
|
183
|
+
<Typography variant="body2" className="mt-2">
|
|
184
|
+
The commit is on <code>{result.committed.branch}</code> either way, so the
|
|
185
|
+
change will be applied on the next boot.
|
|
186
|
+
</Typography>
|
|
187
|
+
</Alert>
|
|
188
|
+
)}
|
|
189
|
+
|
|
190
|
+
<WithheldConstraints constraints={result.withheldConstraints ?? []}/>
|
|
191
|
+
|
|
192
|
+
<div>
|
|
193
|
+
<Typography variant="label" color="secondary">Committed</Typography>
|
|
194
|
+
<div className={cls(codeBlock, "mt-1")}>
|
|
195
|
+
{result.committed.sha.slice(0, 9)} on {result.committed.branch}
|
|
196
|
+
{"\n"}
|
|
197
|
+
{result.committed.files.map(file => ` ${file}`).join("\n")}
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function SchemaChangeDialog({
|
|
205
|
+
open,
|
|
206
|
+
collectionId,
|
|
207
|
+
plan,
|
|
208
|
+
planError,
|
|
209
|
+
result,
|
|
210
|
+
applying,
|
|
211
|
+
applyError,
|
|
212
|
+
onConfirm,
|
|
213
|
+
onSourceOnly,
|
|
214
|
+
onClose,
|
|
215
|
+
applyRefusedBecause
|
|
216
|
+
}: SchemaChangeDialogProps) {
|
|
217
|
+
|
|
218
|
+
const verdict = plan?.verdict;
|
|
219
|
+
const copy = verdict ? VERDICT_COPY[verdict] : undefined;
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<Dialog open={open} onOpenChange={value => { if (!value) onClose(); }} maxWidth="3xl">
|
|
223
|
+
<DialogTitle className="flex items-center gap-2">
|
|
224
|
+
{result ? "Schema change applied" : "Review schema change"}
|
|
225
|
+
<code className="text-sm font-normal text-text-secondary dark:text-text-secondary-dark">
|
|
226
|
+
{collectionId}
|
|
227
|
+
</code>
|
|
228
|
+
</DialogTitle>
|
|
229
|
+
|
|
230
|
+
<DialogContent className="flex flex-col gap-4">
|
|
231
|
+
{planError && (
|
|
232
|
+
<Alert color="error">
|
|
233
|
+
<Typography variant="body2" className="font-medium mb-1">
|
|
234
|
+
The change could not be planned
|
|
235
|
+
</Typography>
|
|
236
|
+
<Typography variant="body2">{planError}</Typography>
|
|
237
|
+
</Alert>
|
|
238
|
+
)}
|
|
239
|
+
|
|
240
|
+
{!plan && !planError && !result && (
|
|
241
|
+
<div className="flex items-center gap-3 py-6">
|
|
242
|
+
<CircularProgress size="small"/>
|
|
243
|
+
<Typography variant="body2" color="secondary">
|
|
244
|
+
Working out what this change would do…
|
|
245
|
+
</Typography>
|
|
246
|
+
</div>
|
|
247
|
+
)}
|
|
248
|
+
|
|
249
|
+
{result && <Applied result={result}/>}
|
|
250
|
+
|
|
251
|
+
{plan && !result && copy && (
|
|
252
|
+
<>
|
|
253
|
+
<Alert color={copy.color}>
|
|
254
|
+
<Typography variant="body2" className="font-medium mb-1">
|
|
255
|
+
{copy.title}
|
|
256
|
+
</Typography>
|
|
257
|
+
<Typography variant="body2">{copy.body}</Typography>
|
|
258
|
+
</Alert>
|
|
259
|
+
|
|
260
|
+
{applyError && (
|
|
261
|
+
<Alert color="error">
|
|
262
|
+
<Typography variant="body2">{applyError}</Typography>
|
|
263
|
+
</Alert>
|
|
264
|
+
)}
|
|
265
|
+
|
|
266
|
+
{plan.changes.length === 0 && (
|
|
267
|
+
<Typography variant="body2" color="secondary">
|
|
268
|
+
Nothing about the database changes. The collection source is still
|
|
269
|
+
rewritten and committed.
|
|
270
|
+
</Typography>
|
|
271
|
+
)}
|
|
272
|
+
|
|
273
|
+
{!plan.applicable && onSourceOnly && (
|
|
274
|
+
<Typography variant="body2" color="secondary">
|
|
275
|
+
You can still write the change to your collection source and leave
|
|
276
|
+
the database as it is — the editor’s behaviour before this preview
|
|
277
|
+
existed. The column stays, holding whatever is in it, and nothing
|
|
278
|
+
serves it.
|
|
279
|
+
</Typography>
|
|
280
|
+
)}
|
|
281
|
+
|
|
282
|
+
{plan.changes.length > 0 && (
|
|
283
|
+
<ul className="flex flex-col gap-3">
|
|
284
|
+
{plan.changes.map((change, index) => (
|
|
285
|
+
<li
|
|
286
|
+
key={`${change.collection}.${change.property ?? ""}.${index}`}
|
|
287
|
+
className="flex gap-3"
|
|
288
|
+
>
|
|
289
|
+
<Chip
|
|
290
|
+
size="smallest"
|
|
291
|
+
colorScheme={VERDICT_CHIP[change.verdict].scheme}
|
|
292
|
+
className="shrink-0 mt-0.5"
|
|
293
|
+
>
|
|
294
|
+
{VERDICT_CHIP[change.verdict].label}
|
|
295
|
+
</Chip>
|
|
296
|
+
<div className="min-w-0">
|
|
297
|
+
<Typography variant="body2">{change.detail}</Typography>
|
|
298
|
+
{change.remedy && (
|
|
299
|
+
<Typography
|
|
300
|
+
variant="body2"
|
|
301
|
+
color="secondary"
|
|
302
|
+
className="mt-1"
|
|
303
|
+
>
|
|
304
|
+
{change.remedy}
|
|
305
|
+
</Typography>
|
|
306
|
+
)}
|
|
307
|
+
</div>
|
|
308
|
+
</li>
|
|
309
|
+
))}
|
|
310
|
+
</ul>
|
|
311
|
+
)}
|
|
312
|
+
|
|
313
|
+
<WithheldConstraints constraints={plan.withheldConstraints ?? []}/>
|
|
314
|
+
|
|
315
|
+
{applyRefusedBecause && (
|
|
316
|
+
<Alert color="info">
|
|
317
|
+
<Typography variant="body2" className="font-medium mb-1">
|
|
318
|
+
You can preview this change, but not apply it
|
|
319
|
+
</Typography>
|
|
320
|
+
<Typography variant="body2">{applyRefusedBecause}</Typography>
|
|
321
|
+
</Alert>
|
|
322
|
+
)}
|
|
323
|
+
|
|
324
|
+
<Details summary="SQL that will run" count={plan.statements.length}>
|
|
325
|
+
<div className={codeBlock}>{plan.statements.join("\n")}</div>
|
|
326
|
+
</Details>
|
|
327
|
+
|
|
328
|
+
<Details summary="Files that will be committed" count={plan.files.length}>
|
|
329
|
+
<div className={codeBlock}>{plan.files.join("\n")}</div>
|
|
330
|
+
</Details>
|
|
331
|
+
|
|
332
|
+
<Details summary="Commit message" count={plan.message ? 1 : 0}>
|
|
333
|
+
<div className={codeBlock}>{plan.message}</div>
|
|
334
|
+
</Details>
|
|
335
|
+
</>
|
|
336
|
+
)}
|
|
337
|
+
</DialogContent>
|
|
338
|
+
|
|
339
|
+
<DialogActions>
|
|
340
|
+
<Button variant="text" onClick={onClose} disabled={applying}>
|
|
341
|
+
{result ? "Close" : "Cancel"}
|
|
342
|
+
</Button>
|
|
343
|
+
{!result && plan && !plan.applicable && onSourceOnly && (
|
|
344
|
+
<Button variant="outlined" onClick={onSourceOnly} disabled={applying}>
|
|
345
|
+
Edit source only
|
|
346
|
+
</Button>
|
|
347
|
+
)}
|
|
348
|
+
{!result && (
|
|
349
|
+
<Button
|
|
350
|
+
variant="filled"
|
|
351
|
+
color="primary"
|
|
352
|
+
onClick={onConfirm}
|
|
353
|
+
// `applicable` is the server's word, not a
|
|
354
|
+
// re-derivation from the verdicts on screen:
|
|
355
|
+
// `applySchemaChange` re-checks it before it writes
|
|
356
|
+
// anything, so a button that disagreed would only
|
|
357
|
+
// produce the same refusal one click later. The same
|
|
358
|
+
// goes for the permission — `/apply` checks it too, and
|
|
359
|
+
// this only saves somebody the round trip.
|
|
360
|
+
disabled={!plan?.applicable || applying || Boolean(applyRefusedBecause)}
|
|
361
|
+
>
|
|
362
|
+
{applying ? "Applying…" : "Commit and apply"}
|
|
363
|
+
</Button>
|
|
364
|
+
)}
|
|
365
|
+
</DialogActions>
|
|
366
|
+
</Dialog>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
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
|
+
const clear = useCallback(() => {
|
|
161
|
+
setPending(undefined);
|
|
162
|
+
setPlan(undefined);
|
|
163
|
+
setPlanError(undefined);
|
|
164
|
+
setResult(undefined);
|
|
165
|
+
setApplyError(undefined);
|
|
166
|
+
setApplying(false);
|
|
167
|
+
}, []);
|
|
168
|
+
|
|
169
|
+
const reviewChange = useCallback((change: ProposedCollectionChange) => {
|
|
170
|
+
return new Promise<void>((resolve, reject) => {
|
|
171
|
+
// A second review while one is open — a double-clicked save —
|
|
172
|
+
// replaces the pending one, and the one it replaces has to be
|
|
173
|
+
// settled. `setPending` used to overwrite `resolve`/`reject` with
|
|
174
|
+
// nobody left holding them, so the first caller's promise never
|
|
175
|
+
// settled at all and the editor's save simply never returned.
|
|
176
|
+
setPending(previous => {
|
|
177
|
+
previous?.reject(new SchemaChangeCancelled());
|
|
178
|
+
return { change, resolve, reject };
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Identifies the review a response belongs to. A plan that arrives
|
|
182
|
+
// after its review was replaced must not be shown: the dialog would
|
|
183
|
+
// describe the *previous* change while the reader believes it
|
|
184
|
+
// describes this one, and the SQL on screen would be for something
|
|
185
|
+
// they are not confirming.
|
|
186
|
+
const token = ++reviewToken.current;
|
|
187
|
+
setPlan(undefined);
|
|
188
|
+
setPlanError(undefined);
|
|
189
|
+
setResult(undefined);
|
|
190
|
+
setApplyError(undefined);
|
|
191
|
+
setApplying(false);
|
|
192
|
+
|
|
193
|
+
void client.plan(change).then(
|
|
194
|
+
answer => { if (token === reviewToken.current) setPlan(answer); },
|
|
195
|
+
(err: unknown) => {
|
|
196
|
+
if (token !== reviewToken.current) return;
|
|
197
|
+
setPlanError(
|
|
198
|
+
err instanceof LiveSchemaError || err instanceof Error
|
|
199
|
+
? err.message
|
|
200
|
+
: String(err)
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
}, [client]);
|
|
206
|
+
|
|
207
|
+
const onConfirm = useCallback(() => {
|
|
208
|
+
if (!pending) return;
|
|
209
|
+
setApplying(true);
|
|
210
|
+
setApplyError(undefined);
|
|
211
|
+
void client.apply(pending.change).then(
|
|
212
|
+
answer => {
|
|
213
|
+
setResult(answer);
|
|
214
|
+
setApplying(false);
|
|
215
|
+
// Resolved here, not on close. The change has happened, and the
|
|
216
|
+
// dialog is now a receipt somebody may read for as long as they
|
|
217
|
+
// like — holding the caller open until they close it would
|
|
218
|
+
// leave the editor mid-save for no reason.
|
|
219
|
+
pending.resolve();
|
|
220
|
+
setPending(undefined);
|
|
221
|
+
},
|
|
222
|
+
(err: unknown) => {
|
|
223
|
+
setApplying(false);
|
|
224
|
+
setApplyError(err instanceof Error ? err.message : String(err));
|
|
225
|
+
}
|
|
226
|
+
);
|
|
227
|
+
}, [client, pending]);
|
|
228
|
+
|
|
229
|
+
const onSourceOnly = useCallback(() => {
|
|
230
|
+
if (!pending || !writeSourceOnly) return;
|
|
231
|
+
setApplying(true);
|
|
232
|
+
setApplyError(undefined);
|
|
233
|
+
const settle = pending;
|
|
234
|
+
void writeSourceOnly(settle.change).then(
|
|
235
|
+
() => { clear(); settle.resolve(); },
|
|
236
|
+
(err: unknown) => {
|
|
237
|
+
setApplying(false);
|
|
238
|
+
setApplyError(err instanceof Error ? err.message : String(err));
|
|
239
|
+
}
|
|
240
|
+
);
|
|
241
|
+
}, [pending, writeSourceOnly, clear]);
|
|
242
|
+
|
|
243
|
+
const onClose = useCallback(() => {
|
|
244
|
+
// Escape and the backdrop reach this too, and the Cancel *button* is the
|
|
245
|
+
// only one of the three that was disabled while applying. Closing
|
|
246
|
+
// mid-apply rejected the caller as cancelled while the commit and the
|
|
247
|
+
// DDL carried on — reporting that nothing happened while something did,
|
|
248
|
+
// which is the exact failure this whole feature exists to end.
|
|
249
|
+
if (applying) return;
|
|
250
|
+
|
|
251
|
+
const settle = pending;
|
|
252
|
+
clear();
|
|
253
|
+
// A dialog showing a result has already resolved; closing it is not a
|
|
254
|
+
// cancellation of anything.
|
|
255
|
+
if (settle && !result) settle.reject(new SchemaChangeCancelled());
|
|
256
|
+
}, [applying, pending, result, clear]);
|
|
257
|
+
|
|
258
|
+
// Not even a Suspense boundary until there is something to review — the
|
|
259
|
+
// chunk is fetched by the first `reviewChange`, and an admin session that
|
|
260
|
+
// never edits a collection never pays for it.
|
|
261
|
+
const open = Boolean(pending) || Boolean(result);
|
|
262
|
+
const dialog = open
|
|
263
|
+
? (
|
|
264
|
+
<Suspense fallback={null}>
|
|
265
|
+
<SchemaChangeDialog
|
|
266
|
+
open={open}
|
|
267
|
+
collectionId={pending?.change.collectionId ?? ""}
|
|
268
|
+
plan={plan}
|
|
269
|
+
planError={planError}
|
|
270
|
+
result={result}
|
|
271
|
+
applying={applying}
|
|
272
|
+
applyError={applyError}
|
|
273
|
+
onConfirm={onConfirm}
|
|
274
|
+
onSourceOnly={writeSourceOnly ? onSourceOnly : undefined}
|
|
275
|
+
onClose={onClose}
|
|
276
|
+
applyRefusedBecause={
|
|
277
|
+
status && !status.canApply ? status.applyRefusedBecause : undefined
|
|
278
|
+
}
|
|
279
|
+
/>
|
|
280
|
+
</Suspense>
|
|
281
|
+
)
|
|
282
|
+
: null;
|
|
283
|
+
|
|
284
|
+
return { status, ready, reviewChange, dialog };
|
|
285
|
+
}
|