@rebasepro/admin 0.12.1-canary.gf5f1d39 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/{CollectionEditorDialog-Dpc75wIZ.js → CollectionEditorDialog-CjXkWZ64.js} +75 -62
- package/dist/{CollectionEditorDialog-Dpc75wIZ.js.map → CollectionEditorDialog-CjXkWZ64.js.map} +1 -1
- package/dist/{PropertyEditView-DH3XZC2x.js → PropertyEditView-W8I3YV89.js} +36 -16
- package/dist/{PropertyEditView-DH3XZC2x.js.map → PropertyEditView-W8I3YV89.js.map} +1 -1
- package/dist/{RouterCollectionsStudioView-BZLq4MGp.js → RouterCollectionsStudioView-BsLTNoqX.js} +5 -5
- package/dist/{RouterCollectionsStudioView-BZLq4MGp.js.map → RouterCollectionsStudioView-BsLTNoqX.js.map} +1 -1
- package/dist/collection_editor/serializable_types.d.ts +4 -1
- package/dist/collection_editor/ui/collection_editor/CollectionRelationsTab.d.ts +1 -0
- package/dist/collection_editor/useLocalCollectionsConfigController.d.ts +8 -0
- package/dist/collection_editor_ui.js +5 -5
- package/dist/collection_editor_ui.js.map +1 -1
- package/dist/components/CollectionTableBinding/column_utils.d.ts +17 -0
- package/dist/components/CollectionViewBinding/CollectionViewStartActions.d.ts +7 -2
- package/dist/components/CollectionViewBinding/SortButton.d.ts +24 -0
- package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +10 -0
- package/dist/components/CollectionViewBinding/SplitViewContext.d.ts +23 -0
- package/dist/components/CollectionViewBinding/hooks/useKanbanDragAndDrop.d.ts +26 -0
- package/dist/components/DetailViewBinding.d.ts +5 -0
- package/dist/components/EditFormActions.d.ts +12 -1
- package/dist/components/EditViewBinding.d.ts +6 -1
- package/dist/components/EntityIdentityBar.d.ts +53 -0
- package/dist/components/EntityInspector.d.ts +35 -0
- package/dist/components/EntityPreviewNesting.d.ts +25 -0
- package/dist/components/EntityViewBinding.d.ts +9 -0
- package/dist/components/InlineEntityPreview.d.ts +47 -0
- package/dist/components/history/EntityHistoryView.d.ts +4 -1
- package/dist/components/index.d.ts +2 -0
- package/dist/editor.js +18 -10
- package/dist/editor.js.map +1 -1
- package/dist/{export-BzIpUdSK.js → export-J79rMoNj.js} +2 -2
- package/dist/{export-BzIpUdSK.js.map → export-J79rMoNj.js.map} +1 -1
- package/dist/form/EntityForm.d.ts +1 -1
- package/dist/form/EntityFormActions.d.ts +11 -1
- package/dist/form/PropertyFieldBinding.d.ts +1 -1
- package/dist/form/components/FieldBlock.d.ts +30 -0
- package/dist/form/components/FormRail.d.ts +18 -0
- package/dist/form/components/FormSections.d.ts +16 -0
- package/dist/form/components/index.d.ts +3 -2
- package/dist/form/components/useRailVisible.d.ts +24 -0
- package/dist/form/field_bindings/BinaryFieldBinding.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/MarkdownEditorFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/MultiSelectFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/ReadOnlyFieldBinding.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/{history-g9688IaK.js → history-D7L_JlLO.js} +35 -33
- package/dist/history-D7L_JlLO.js.map +1 -0
- package/dist/hooks/navigation/utils.d.ts +16 -0
- package/dist/hooks/useBuildSidePanel.d.ts +14 -0
- package/dist/hooks/useEntityDisplayTitle.d.ts +18 -0
- package/dist/hooks/useHistory.d.ts +7 -0
- package/dist/hooks/useRecordActions.d.ts +17 -0
- package/dist/{import-BjUG-cIY.js → import-BvYl--U4.js} +2 -2
- package/dist/{import-BjUG-cIY.js.map → import-BvYl--U4.js.map} +1 -1
- package/dist/index.js +106 -35
- package/dist/index.js.map +1 -1
- package/dist/markdown-CklalUUk.js.map +1 -1
- package/dist/preview/components/InlineEntityListPreview.d.ts +15 -0
- package/dist/preview/index.d.ts +1 -0
- package/dist/preview/property_previews/ArrayOfReferencesPreview.d.ts +1 -1
- package/dist/preview/property_previews/ArrayOfRelationsPreview.d.ts +1 -1
- package/dist/types/components/EntityFormActionsProps.d.ts +0 -1
- package/dist/types/components/EntityFormProps.d.ts +0 -5
- package/dist/types/fields.d.ts +24 -0
- package/dist/{util-B5fJm1FA.js → util-GiwPgxnL.js} +2106 -1117
- package/dist/util-GiwPgxnL.js.map +1 -0
- package/package.json +32 -34
- package/src/collection_editor/ConfigControllerProvider.tsx +1 -1
- package/src/collection_editor/serializable_types.ts +4 -3
- package/src/collection_editor/serializable_utils.ts +51 -17
- package/src/collection_editor/ui/collection_editor/CollectionEditorDialog.tsx +65 -22
- package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +38 -30
- package/src/collection_editor/ui/collection_editor/CollectionRelationsTab.tsx +3 -1
- package/src/collection_editor/ui/collection_editor/RouterCollectionStudioView.tsx +1 -1
- package/src/collection_editor/ui/collection_editor/RouterCollectionsStudioView.tsx +1 -1
- package/src/collection_editor/ui/collection_editor/properties/RelationPropertyField.tsx +16 -0
- package/src/collection_editor/useLocalCollectionsConfigController.tsx +127 -15
- package/src/collection_editor/validateCollectionJson.ts +3 -3
- package/src/components/AdminModeSyncer.tsx +1 -1
- package/src/components/CollectionTableBinding/PropertyTableCell.tsx +10 -2
- package/src/components/CollectionTableBinding/column_utils.tsx +59 -2
- package/src/components/CollectionTableBinding/table_bindings.tsx +2 -1
- package/src/components/CollectionViewBinding/CollectionBoardViewBinding.tsx +23 -16
- package/src/components/CollectionViewBinding/CollectionViewActions.tsx +7 -1
- package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +9 -2
- package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +47 -12
- package/src/components/CollectionViewBinding/SortButton.tsx +189 -0
- package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +33 -0
- package/src/components/CollectionViewBinding/SplitListView.tsx +18 -3
- package/src/components/CollectionViewBinding/SplitViewContext.tsx +34 -0
- package/src/components/CollectionViewBinding/hooks/useKanbanDragAndDrop.ts +48 -9
- package/src/components/CollectionViewBinding/useBoardDataController.tsx +80 -25
- package/src/components/CollectionViewBinding/usePreviewSlots.ts +18 -6
- package/src/components/DefaultAppBar.tsx +1 -1
- package/src/components/DefaultDrawer.tsx +1 -1
- package/src/components/DetailViewBinding.tsx +112 -148
- package/src/components/DrawerNavigationItem.tsx +1 -1
- package/src/components/EditFormActions.tsx +54 -129
- package/src/components/EditViewBinding.tsx +202 -99
- package/src/components/EntityIdentityBar.tsx +288 -0
- package/src/components/EntityInspector.tsx +137 -0
- package/src/components/EntityPreviewBinding.tsx +6 -2
- package/src/components/EntityPreviewNesting.tsx +50 -0
- package/src/components/EntityViewBinding.tsx +35 -32
- package/src/components/HomePage/FavouritesView.tsx +1 -1
- package/src/components/HomePage/NavigationCardBinding.tsx +1 -1
- package/src/components/HomePage/SmallNavigationCard.tsx +1 -1
- package/src/components/InlineEntityPreview.tsx +149 -0
- package/src/components/RebaseLayout.tsx +1 -1
- package/src/components/RebaseNavigation.tsx +45 -5
- package/src/components/RebaseRouteDefs.tsx +1 -1
- package/src/components/SelectableTable/SelectableTable.tsx +2 -2
- package/src/components/SidePanelBinding.tsx +19 -13
- package/src/components/UserSelector.tsx +12 -6
- package/src/components/common/default_entity_actions.tsx +4 -3
- package/src/components/history/EntityHistoryEntry.tsx +28 -7
- package/src/components/history/EntityHistoryView.tsx +29 -24
- package/src/components/history/LastEditedByIndicator.tsx +4 -3
- package/src/components/index.ts +2 -0
- package/src/data_import/utils/get_import_inference_type.ts +6 -0
- package/src/form/EntityForm.tsx +267 -179
- package/src/form/EntityFormActions.tsx +50 -99
- package/src/form/EntityFormBinding.tsx +4 -2
- package/src/form/PropertyFieldBinding.tsx +4 -0
- package/src/form/components/FieldBlock.tsx +119 -0
- package/src/form/components/FormRail.tsx +125 -0
- package/src/form/components/FormSections.tsx +118 -0
- package/src/form/components/index.tsx +3 -2
- package/src/form/components/useRailVisible.tsx +46 -0
- package/src/form/field_bindings/BinaryFieldBinding.tsx +3 -2
- package/src/form/field_bindings/DateTimeFieldBinding.tsx +3 -2
- package/src/form/field_bindings/GeopointFieldBinding.tsx +3 -2
- package/src/form/field_bindings/MapFieldBinding.tsx +1 -2
- package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +3 -2
- package/src/form/field_bindings/MultiSelectFieldBinding.tsx +2 -1
- package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +2 -1
- package/src/form/field_bindings/ReferenceFieldBinding.tsx +2 -1
- package/src/form/field_bindings/RelationFieldBinding.tsx +6 -4
- package/src/form/field_bindings/RepeatFieldBinding.tsx +4 -1
- package/src/form/field_bindings/SelectFieldBinding.tsx +3 -2
- package/src/form/field_bindings/StorageUploadFieldBinding.tsx +31 -8
- package/src/form/field_bindings/SwitchFieldBinding.tsx +7 -3
- package/src/form/field_bindings/TextFieldBinding.tsx +18 -16
- package/src/form/field_bindings/UserSelectFieldBinding.tsx +3 -2
- package/src/form/field_bindings/VectorFieldBinding.tsx +2 -1
- package/src/hooks/navigation/useBuildUrlController.tsx +1 -1
- package/src/hooks/navigation/useTopLevelNavigation.ts +18 -7
- package/src/hooks/navigation/utils.ts +20 -0
- package/src/hooks/useBuildSideDialogsController.tsx +2 -2
- package/src/hooks/useBuildSidePanel.tsx +22 -61
- package/src/hooks/useEntityDisplayTitle.tsx +60 -0
- package/src/hooks/useHistory.ts +20 -10
- package/src/hooks/useRecordActions.tsx +69 -0
- package/src/hooks/useResolvedUsers.ts +10 -5
- package/src/preview/PropertyPreview.tsx +48 -22
- package/src/preview/components/InlineEntityListPreview.tsx +42 -0
- package/src/preview/components/ReferencePreview.tsx +34 -14
- package/src/preview/components/RelationPreview.tsx +34 -14
- package/src/preview/index.ts +1 -0
- package/src/preview/property_previews/ArrayOfReferencesPreview.tsx +24 -1
- package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +25 -1
- package/src/routes/RebaseRoute.tsx +2 -2
- package/src/types/components/EntityFormActionsProps.tsx +0 -1
- package/src/types/components/EntityFormProps.tsx +0 -6
- package/src/types/fields.tsx +27 -0
- package/dist/form/components/FormEntry.d.ts +0 -6
- package/dist/form/components/FormLayout.d.ts +0 -5
- package/dist/history-g9688IaK.js.map +0 -1
- package/dist/util-B5fJm1FA.js.map +0 -1
- package/src/form/components/FormEntry.tsx +0 -22
- package/src/form/components/FormLayout.tsx +0 -16
|
@@ -2,42 +2,87 @@ import { CollectionsConfigController, SaveCollectionParams, UpdateCollectionPara
|
|
|
2
2
|
import { Properties } from "@rebasepro/types";
|
|
3
3
|
import { getSubcollections } from "@rebasepro/common";
|
|
4
4
|
|
|
5
|
-
import React, { useMemo, useRef } from "react";
|
|
5
|
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
6
6
|
import type { AdminCollection } from "@rebasepro/admin-types";
|
|
7
|
+
import { DEFAULT_API_PATH } from "@rebasepro/app";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What the backend said about its schema editor, and how sure we are.
|
|
11
|
+
*
|
|
12
|
+
* `unknown` covers both "still asking" and "could not ask" — a backend too old
|
|
13
|
+
* to have the endpoint, or one that is unreachable. Those fall back to the
|
|
14
|
+
* build-mode guess below rather than locking the editor for someone whose
|
|
15
|
+
* editor used to work.
|
|
16
|
+
*/
|
|
17
|
+
type SchemaEditorAvailability =
|
|
18
|
+
| { state: "unknown" }
|
|
19
|
+
| { state: "known", enabled: boolean, reason?: string };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The guess this hook used to make on its own.
|
|
23
|
+
*
|
|
24
|
+
* `process.env.NODE_ENV` here is the *frontend bundle's* build mode. Whether
|
|
25
|
+
* the schema editor exists is decided by a different process entirely — the
|
|
26
|
+
* server's own `NODE_ENV`, its `collectionsDir`, whether its collections were
|
|
27
|
+
* introspected, whether `ts-morph` is installed. The two disagree in every
|
|
28
|
+
* ordinary setup: a dev frontend against a deployed API, a `baas` project, a
|
|
29
|
+
* hosted console. When they did, the editor offered itself and every save came
|
|
30
|
+
* back `404 Not Found`. It is kept only as the answer for a backend too old to
|
|
31
|
+
* be asked.
|
|
32
|
+
*/
|
|
33
|
+
const buildModeGuess = (): boolean => process.env.NODE_ENV === "production";
|
|
34
|
+
|
|
7
35
|
export function useLocalCollectionsConfigController(
|
|
8
36
|
clientOrUrl: any,
|
|
9
37
|
baseCollections: AdminCollection[] = [],
|
|
10
38
|
options?: {
|
|
11
39
|
readOnly?: boolean;
|
|
12
40
|
getAuthToken?: () => Promise<string | null>;
|
|
41
|
+
/**
|
|
42
|
+
* Identity of the signed-in user, if any.
|
|
43
|
+
*
|
|
44
|
+
* Only used to re-ask the backend when it changes: whether the editor
|
|
45
|
+
* will accept a write depends on who is asking, and the answer to an
|
|
46
|
+
* anonymous probe does not survive a sign-in.
|
|
47
|
+
*/
|
|
48
|
+
authKey?: string | null;
|
|
13
49
|
}
|
|
14
50
|
): CollectionsConfigController {
|
|
15
51
|
|
|
16
52
|
const parsedCollections = baseCollections;
|
|
17
|
-
|
|
53
|
+
|
|
18
54
|
// Store latest options in a ref to prevent stale closures in the `request` function
|
|
19
55
|
// due to useMemo caching the saveCollection function.
|
|
20
56
|
const optionsRef = useRef(options);
|
|
21
57
|
optionsRef.current = options;
|
|
22
58
|
|
|
59
|
+
// `/api` only by default — a backend configured with another `basePath`
|
|
60
|
+
// mounts the schema editor under that instead.
|
|
61
|
+
const clientBaseUrl = typeof clientOrUrl === "string"
|
|
62
|
+
? clientOrUrl
|
|
63
|
+
: (clientOrUrl?.baseUrl ?? "");
|
|
64
|
+
const clientApiPath = (typeof clientOrUrl === "object" && clientOrUrl !== null && clientOrUrl.apiPath)
|
|
65
|
+
|| DEFAULT_API_PATH;
|
|
66
|
+
const editorUrl = `${String(clientBaseUrl).replace(/\/$/, "")}${clientApiPath}/schema-editor`;
|
|
67
|
+
|
|
68
|
+
const resolveToken = async (): Promise<string | null> => {
|
|
69
|
+
let token = optionsRef.current?.getAuthToken ? await optionsRef.current.getAuthToken() : null;
|
|
70
|
+
if (!token && typeof clientOrUrl === "object" && clientOrUrl !== null && clientOrUrl.resolveToken) {
|
|
71
|
+
token = await clientOrUrl.resolveToken();
|
|
72
|
+
}
|
|
73
|
+
return token ?? null;
|
|
74
|
+
};
|
|
75
|
+
|
|
23
76
|
const request = async (endpoint: string, payload: Record<string, unknown>) => {
|
|
24
77
|
try {
|
|
25
|
-
|
|
26
|
-
let baseUrl = typeof clientOrUrl === "string" ? clientOrUrl : "";
|
|
27
|
-
|
|
28
|
-
if (typeof clientOrUrl === "object" && clientOrUrl !== null) {
|
|
29
|
-
baseUrl = clientOrUrl.baseUrl || baseUrl;
|
|
30
|
-
if (!token && clientOrUrl.resolveToken) {
|
|
31
|
-
token = await clientOrUrl.resolveToken();
|
|
32
|
-
}
|
|
33
|
-
}
|
|
78
|
+
const token = await resolveToken();
|
|
34
79
|
|
|
35
80
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
36
81
|
if (token) {
|
|
37
82
|
headers["Authorization"] = `Bearer ${token}`;
|
|
38
83
|
}
|
|
39
84
|
|
|
40
|
-
const response = await fetch(`${
|
|
85
|
+
const response = await fetch(`${editorUrl}${endpoint}`, {
|
|
41
86
|
method: "POST",
|
|
42
87
|
headers,
|
|
43
88
|
body: JSON.stringify(payload)
|
|
@@ -67,10 +112,77 @@ export function useLocalCollectionsConfigController(
|
|
|
67
112
|
}
|
|
68
113
|
};
|
|
69
114
|
|
|
115
|
+
// ── Ask the backend whether it will accept an edit ────────────────
|
|
116
|
+
const forcedReadOnly = options?.readOnly;
|
|
117
|
+
const authKey = options?.authKey ?? null;
|
|
118
|
+
const [availability, setAvailability] = useState<SchemaEditorAvailability>({ state: "unknown" });
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
// Nothing to ask about: the caller has already decided.
|
|
122
|
+
if (forcedReadOnly === true) return;
|
|
123
|
+
|
|
124
|
+
let cancelled = false;
|
|
125
|
+
(async () => {
|
|
126
|
+
try {
|
|
127
|
+
const token = await resolveToken();
|
|
128
|
+
const response = await fetch(`${editorUrl}/status`, {
|
|
129
|
+
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
|
130
|
+
});
|
|
131
|
+
if (cancelled) return;
|
|
132
|
+
|
|
133
|
+
// A backend from before the endpoint existed. Guessing from the
|
|
134
|
+
// bundle's build mode is wrong, but it is what that backend's
|
|
135
|
+
// admin panel has always done, and locking the editor for
|
|
136
|
+
// someone whose editor works would be worse.
|
|
137
|
+
if (response.status === 404) {
|
|
138
|
+
setAvailability({ state: "unknown" });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const body = await response.json().catch(() => ({})) as {
|
|
143
|
+
enabled?: boolean,
|
|
144
|
+
reason?: string,
|
|
145
|
+
error?: { message?: string }
|
|
146
|
+
};
|
|
147
|
+
if (cancelled) return;
|
|
148
|
+
|
|
149
|
+
if (response.ok) {
|
|
150
|
+
setAvailability({
|
|
151
|
+
state: "known",
|
|
152
|
+
enabled: body.enabled === true,
|
|
153
|
+
reason: body.reason
|
|
154
|
+
});
|
|
155
|
+
} else {
|
|
156
|
+
// 401/403 (not an admin) or 501 (no auth configured at all)
|
|
157
|
+
// — all of them mean this session cannot edit collections,
|
|
158
|
+
// and the body says why better than we could.
|
|
159
|
+
setAvailability({
|
|
160
|
+
state: "known",
|
|
161
|
+
enabled: false,
|
|
162
|
+
reason: body.error?.message
|
|
163
|
+
?? `The backend refused to say whether collections are editable (HTTP ${response.status}).`
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
if (!cancelled) setAvailability({ state: "unknown" });
|
|
168
|
+
}
|
|
169
|
+
})();
|
|
170
|
+
|
|
171
|
+
return () => { cancelled = true; };
|
|
172
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
173
|
+
}, [editorUrl, forcedReadOnly, authKey]);
|
|
174
|
+
|
|
175
|
+
const readOnly = forcedReadOnly
|
|
176
|
+
?? (availability.state === "known" ? !availability.enabled : buildModeGuess());
|
|
177
|
+
|
|
178
|
+
const readOnlyReason = availability.state === "known" && availability.reason
|
|
179
|
+
? availability.reason
|
|
180
|
+
: "Collections can only be edited against a backend running the schema editor, which is off in production.";
|
|
181
|
+
|
|
70
182
|
return useMemo(() => ({
|
|
71
183
|
loading: false,
|
|
72
|
-
readOnly
|
|
73
|
-
readOnlyReason
|
|
184
|
+
readOnly,
|
|
185
|
+
readOnlyReason,
|
|
74
186
|
collections: parsedCollections,
|
|
75
187
|
getCollection: (id: string) => {
|
|
76
188
|
const found = parsedCollections.find(c => (c as AdminCollection & { id?: string }).id === id || c.slug === id);
|
|
@@ -119,5 +231,5 @@ collectionData: { propertiesOrder: newPropertiesOrder } });
|
|
|
119
231
|
|
|
120
232
|
navigationEntries: [],
|
|
121
233
|
saveNavigationEntries: async () => { }
|
|
122
|
-
}), [clientOrUrl, parsedCollections,
|
|
234
|
+
}), [clientOrUrl, parsedCollections, readOnly, readOnlyReason, options?.getAuthToken]);
|
|
123
235
|
}
|
|
@@ -135,12 +135,12 @@ function validateProperty(
|
|
|
135
135
|
// silently honoured by whichever consumer read it first.
|
|
136
136
|
const allowedByKind: Record<string, string[]> = {
|
|
137
137
|
belongsTo: ["localKey"],
|
|
138
|
-
hasOne: ["foreignKeyOnTarget"],
|
|
139
|
-
hasMany: ["foreignKeyOnTarget"],
|
|
138
|
+
hasOne: ["foreignKeyOnTarget", "sourceKey"],
|
|
139
|
+
hasMany: ["foreignKeyOnTarget", "sourceKey"],
|
|
140
140
|
manyToMany: ["through"],
|
|
141
141
|
via: ["joinPath", "cardinality"]
|
|
142
142
|
};
|
|
143
|
-
const linkFields = ["localKey", "foreignKeyOnTarget", "through", "joinPath", "cardinality"];
|
|
143
|
+
const linkFields = ["localKey", "foreignKeyOnTarget", "sourceKey", "through", "joinPath", "cardinality"];
|
|
144
144
|
const allowed = allowedByKind[String(link.kind)] ?? [];
|
|
145
145
|
for (const field of linkFields) {
|
|
146
146
|
if (link[field] !== undefined && !allowed.includes(field)) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AppView } from "@rebasepro/admin-types";
|
|
2
2
|
import React, { useEffect } from "react";
|
|
3
|
-
import { useLocation } from "react-router
|
|
3
|
+
import { useLocation } from "react-router";
|
|
4
4
|
;
|
|
5
5
|
import { useUrlController } from "../hooks/navigation/contexts/UrlContext";
|
|
6
6
|
import { useAdminModeController } from "@rebasepro/app";
|
|
@@ -205,6 +205,16 @@ export const PropertyTableCell = React.memo<PropertyTableCellProps<any>>(
|
|
|
205
205
|
}
|
|
206
206
|
};
|
|
207
207
|
|
|
208
|
+
// `getTableBindingForProperty` builds its `Component` as a fresh inline
|
|
209
|
+
// arrow function on every call. Calling it during render therefore gave
|
|
210
|
+
// React a new component *type* each pass, so the cell was unmounted and
|
|
211
|
+
// remounted instead of updated — which is why a BooleanSwitch in the
|
|
212
|
+
// table never animated (a replaced element cannot run a CSS transition)
|
|
213
|
+
// and why every cell re-created its subtree on unrelated renders.
|
|
214
|
+
const tableBinding = useMemo(
|
|
215
|
+
() => getTableBindingForProperty(property, selected),
|
|
216
|
+
[property, selected]);
|
|
217
|
+
|
|
208
218
|
let innerComponent: React.ReactNode | undefined;
|
|
209
219
|
let allowScroll = false;
|
|
210
220
|
let showExpandIcon = false;
|
|
@@ -243,8 +253,6 @@ export const PropertyTableCell = React.memo<PropertyTableCellProps<any>>(
|
|
|
243
253
|
}
|
|
244
254
|
|
|
245
255
|
if (!customField && (!customPreview || selected)) {
|
|
246
|
-
const tableBinding = getTableBindingForProperty(property, selected);
|
|
247
|
-
|
|
248
256
|
if (tableBinding) {
|
|
249
257
|
const Component = tableBinding.Component;
|
|
250
258
|
innerComponent = <Component
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Properties, Property } from "@rebasepro/types";
|
|
1
|
+
import type { Properties, Property, RelationProperty } from "@rebasepro/types";
|
|
2
2
|
import React from "react";
|
|
3
3
|
import { getTableCellAlignment, getTablePropertyColumnWidth } from "./internal/common";
|
|
4
4
|
import { FilterValues } from "@rebasepro/types";
|
|
@@ -52,7 +52,7 @@ export function propertiesToColumns<M extends Record<string, unknown>>({ propert
|
|
|
52
52
|
align: getTableCellAlignment(property),
|
|
53
53
|
icon: getIconForProperty(property, "small"),
|
|
54
54
|
title: property.name ?? key as string,
|
|
55
|
-
sortable: sortable,
|
|
55
|
+
sortable: sortable && sortableProperty(property),
|
|
56
56
|
filter: !disabledFilter && filterable,
|
|
57
57
|
width: getTablePropertyColumnWidth(property),
|
|
58
58
|
resizable: true,
|
|
@@ -67,6 +67,63 @@ export function propertiesToColumns<M extends Record<string, unknown>>({ propert
|
|
|
67
67
|
});
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
export type SortablePropertyOption = {
|
|
71
|
+
key: string;
|
|
72
|
+
title: string;
|
|
73
|
+
property: Property;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The properties a sort can be applied to, as keys and titles.
|
|
78
|
+
*
|
|
79
|
+
* Only the table renders headers, so the list, card and split views had no way
|
|
80
|
+
* to order anything — the controller has carried `sortBy` all along, with no
|
|
81
|
+
* control bound to it outside the table. This is the same walk
|
|
82
|
+
* `propertiesToColumns` makes, under the same keys and the same
|
|
83
|
+
* `sortableProperty` authority, so a sort picked from the toolbar means exactly
|
|
84
|
+
* what clicking the header would have meant, and the two cannot drift into
|
|
85
|
+
* offering different columns.
|
|
86
|
+
*/
|
|
87
|
+
export function getSortablePropertyOptions(properties: Properties): SortablePropertyOption[] {
|
|
88
|
+
return Object.entries<Property>(properties)
|
|
89
|
+
.flatMap(([key, property]) => getColumnKeysForProperty(property, key))
|
|
90
|
+
.flatMap(({ key }) => {
|
|
91
|
+
// Unlike the table, a missing property here is not worth throwing
|
|
92
|
+
// over: the sort control simply has one fewer option to offer.
|
|
93
|
+
const property = getResolvedPropertyInPath(properties, key) as Property | undefined;
|
|
94
|
+
if (!property || !sortableProperty(property)) return [];
|
|
95
|
+
return [{
|
|
96
|
+
key: key as string,
|
|
97
|
+
title: property.name ?? key as string,
|
|
98
|
+
property
|
|
99
|
+
}];
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Whether this column can be ordered by.
|
|
105
|
+
*
|
|
106
|
+
* `sortable` arrived as one flag for the whole table while `filter` was
|
|
107
|
+
* computed per property, so every column advertised a sort — including the ones
|
|
108
|
+
* no `ORDER BY` can be written for. A to-many relation is a *set* per row
|
|
109
|
+
* (`posts.tags`), and there is no single value to order on; the driver used to
|
|
110
|
+
* answer by silently dropping the sort and returning rows in whatever order
|
|
111
|
+
* Postgres pleased, which is how a header that did nothing went unnoticed.
|
|
112
|
+
*
|
|
113
|
+
* Now that the driver refuses instead, offering the control would turn a
|
|
114
|
+
* useless header into a failed request. Same authority as the filter side: ask
|
|
115
|
+
* the property, not the table.
|
|
116
|
+
*/
|
|
117
|
+
function sortableProperty(property: Property): boolean {
|
|
118
|
+
if (property.type !== "relation") return true;
|
|
119
|
+
const relationProperty = property as RelationProperty;
|
|
120
|
+
const kind = relationProperty.relation?.kind ?? relationProperty.resolvedRelation?.kind;
|
|
121
|
+
// An unresolved relation keeps the benefit of the doubt — the same choice
|
|
122
|
+
// `isFilterableRelation` makes for a kind it cannot see.
|
|
123
|
+
if (!kind) return true;
|
|
124
|
+
return kind === "belongsTo";
|
|
125
|
+
}
|
|
126
|
+
|
|
70
127
|
function filterableProperty(property: Property, partOfArray = false, engine?: string): boolean {
|
|
71
128
|
// A relation this engine's driver cannot compile into a `WHERE` has
|
|
72
129
|
// nothing to filter on, so the header's filter control would open onto a
|
|
@@ -189,13 +189,14 @@ export function getTableBindingForProperty(
|
|
|
189
189
|
};
|
|
190
190
|
} else if (property.type === "date") {
|
|
191
191
|
return {
|
|
192
|
-
Component: ({ propertyKey, error, validationError, disabled, selected, property, internalValue, updateValue }: TableFieldBindingProps) => {
|
|
192
|
+
Component: ({ propertyKey, error, validationError, disabled, selected, size, property, internalValue, updateValue }: TableFieldBindingProps) => {
|
|
193
193
|
const { locale } = useCustomizationController();
|
|
194
194
|
return (
|
|
195
195
|
<VirtualTableDateField
|
|
196
196
|
name={propertyKey}
|
|
197
197
|
error={validationError ?? error}
|
|
198
198
|
disabled={disabled}
|
|
199
|
+
small={getPreviewSizeFrom(size) !== "medium"}
|
|
199
200
|
mode={(property as DateProperty).mode}
|
|
200
201
|
focused={selected}
|
|
201
202
|
internalValue={internalValue as Date}
|
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
import { useAnalyticsController } from "@rebasepro/app";
|
|
39
39
|
import { setIn } from "@rebasepro/forms";
|
|
40
40
|
import { useBoardDataController } from "./useBoardDataController";
|
|
41
|
-
import { useKanbanDragAndDrop } from "./hooks/useKanbanDragAndDrop";
|
|
41
|
+
import { isValidOrderKey, ORDER_KEY_DIGITS, useKanbanDragAndDrop } from "./hooks/useKanbanDragAndDrop";
|
|
42
42
|
import { useSidePanel } from "../../hooks/useSidePanel";
|
|
43
43
|
import { generateNKeysBetween } from "fractional-indexing";
|
|
44
44
|
|
|
@@ -298,11 +298,11 @@ parentEntityIds,
|
|
|
298
298
|
if (!orderProperty || dataLoading) return false;
|
|
299
299
|
// Use collection-level count detection
|
|
300
300
|
if (missingOrderCount > 0) return true;
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
301
|
+
// A value that is present but is not a fractional-indexing key is no
|
|
302
|
+
// more usable than a missing one — dropping a card next to it cannot
|
|
303
|
+
// produce a key between the two — so it earns the same offer to
|
|
304
|
+
// initialise the column.
|
|
305
|
+
return allEntities.some((entity: Entity<M>) => !isValidOrderKey(entity.values?.[orderProperty]));
|
|
306
306
|
}, [allEntities, orderProperty, dataLoading, missingOrderCount]);
|
|
307
307
|
|
|
308
308
|
// Create a lookup map of entity ID → column from boardDataController data
|
|
@@ -337,7 +337,8 @@ parentEntityIds,
|
|
|
337
337
|
loading: colData?.loading ?? true,
|
|
338
338
|
hasMore: colData?.hasMore ?? false,
|
|
339
339
|
itemCount: colData?.entities?.length ?? 0,
|
|
340
|
-
totalCount: colData?.totalCount
|
|
340
|
+
totalCount: colData?.totalCount,
|
|
341
|
+
error: colData?.error
|
|
341
342
|
};
|
|
342
343
|
});
|
|
343
344
|
return state;
|
|
@@ -375,20 +376,24 @@ parentEntityIds,
|
|
|
375
376
|
setBackfillLoading(true);
|
|
376
377
|
|
|
377
378
|
try {
|
|
378
|
-
// Fetch ALL documents from collection (not relying on loaded
|
|
379
|
+
// Fetch ALL documents from collection (not relying on loaded
|
|
380
|
+
// entities). No `orderBy`: with no usable order key there is
|
|
381
|
+
// nothing to order by, and this is the same query the columns
|
|
382
|
+
// themselves run, so the keys are handed out in the order the
|
|
383
|
+
// board is showing — which is what the dialog promises.
|
|
379
384
|
const allDocsRes = await dataClient.collection(fullPath).find({
|
|
380
385
|
limit: 10000 // Fetch all
|
|
381
386
|
});
|
|
382
387
|
const allDocs = allDocsRes.data as Entity<M>[];
|
|
383
388
|
|
|
384
|
-
//
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
+
// Entities without a usable order key. Testing only for null here
|
|
390
|
+
// meant a column full of unusable values — `"12"`, `"6"` — offered
|
|
391
|
+
// an Initialize button that updated nothing and never went away.
|
|
392
|
+
const entitiesToUpdate = allDocs.filter((entity: Entity<M>) =>
|
|
393
|
+
!isValidOrderKey(entity.values?.[orderProperty]));
|
|
389
394
|
|
|
390
395
|
// Generate string fractional keys for all entities that need them
|
|
391
|
-
const keys = generateNKeysBetween(null, null, entitiesToUpdate.length);
|
|
396
|
+
const keys = generateNKeysBetween(null, null, entitiesToUpdate.length, ORDER_KEY_DIGITS);
|
|
392
397
|
const updates: Promise<void>[] = [];
|
|
393
398
|
entitiesToUpdate.forEach((entity: Entity<M>, index: number) => {
|
|
394
399
|
const updatedValues = setIn({ ...entity.values }, orderProperty, keys[index]);
|
|
@@ -591,8 +596,10 @@ parentEntityIds,
|
|
|
591
596
|
</div>
|
|
592
597
|
)}
|
|
593
598
|
|
|
594
|
-
{/* Main board
|
|
595
|
-
|
|
599
|
+
{/* Main board. It scrolls its own columns vertically and its own
|
|
600
|
+
row horizontally, so an `overflow-auto` here only added a second
|
|
601
|
+
scroll container around both. */}
|
|
602
|
+
<div className="flex-1 min-h-0 overflow-hidden">
|
|
596
603
|
<KanbanView
|
|
597
604
|
data={boardItems}
|
|
598
605
|
columns={columns}
|
|
@@ -113,8 +113,14 @@ export function CollectionViewActions<M extends Record<string, unknown>>({
|
|
|
113
113
|
|
|
114
114
|
const multipleDeleteEnabled = canDelete(collection, path, null);
|
|
115
115
|
|
|
116
|
+
// Only once something is selected. A permanently-disabled bin sat in every
|
|
117
|
+
// collection toolbar — and in the split view, where the toolbar is a narrow
|
|
118
|
+
// strip above the list, it was a third of the visible controls doing
|
|
119
|
+
// nothing. It appears the moment you tick a row, which is when you want it.
|
|
120
|
+
const hasSelection = Boolean(selectedEntities?.length);
|
|
121
|
+
|
|
116
122
|
let multipleDeleteButton: React.ReactNode | undefined;
|
|
117
|
-
{
|
|
123
|
+
if (hasSelection) {
|
|
118
124
|
const button = largeLayout && !compact
|
|
119
125
|
? <Button
|
|
120
126
|
variant={"text"}
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
ViewMode
|
|
14
14
|
} from "@rebasepro/admin-types";
|
|
15
15
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
16
|
-
import { useSearchParams } from "react-router
|
|
16
|
+
import { useSearchParams } from "react-router";
|
|
17
17
|
|
|
18
18
|
import { deepEqual as equal } from "fast-equals"
|
|
19
19
|
import { CollectionRowActions, CollectionTableBinding } from "../CollectionTableBinding";
|
|
@@ -970,6 +970,7 @@ parentEntityIds,
|
|
|
970
970
|
selectionController={usedSelectionController}
|
|
971
971
|
collectionEntitiesCount={docsCount ?? undefined}
|
|
972
972
|
resolvedProperties={resolvedCollection.properties}
|
|
973
|
+
viewMode={viewMode}
|
|
973
974
|
openNewDocument={openNewDocument}
|
|
974
975
|
compact={isCompact}/>}
|
|
975
976
|
actions={
|
|
@@ -1166,8 +1167,14 @@ parentEntityIds,
|
|
|
1166
1167
|
) : (
|
|
1167
1168
|
<div className="flex flex-col w-full h-full">
|
|
1168
1169
|
{toolbarNode}
|
|
1170
|
+
{/* `min-h-0` is what lets the board scroll its columns.
|
|
1171
|
+
A flex item defaults to `min-height: auto`, so this
|
|
1172
|
+
one grew to the board's full content height — 1230px
|
|
1173
|
+
in a 883px area — and the ancestor's `overflow-hidden`
|
|
1174
|
+
simply cut the rest off, with each column's own
|
|
1175
|
+
scroller never reaching its limit. */}
|
|
1169
1176
|
<div className={cls(
|
|
1170
|
-
"flex-1 flex flex-col",
|
|
1177
|
+
"flex-1 min-h-0 flex flex-col",
|
|
1171
1178
|
(viewMode === "list" || viewMode === "cards") && "overflow-y-auto"
|
|
1172
1179
|
)}>
|
|
1173
1180
|
{collectionSurface}
|
|
@@ -2,17 +2,19 @@ import type { Properties } from "@rebasepro/types";
|
|
|
2
2
|
|
|
3
3
|
import React, { useState, useCallback } from "react";
|
|
4
4
|
import { useAuthController, useLargeLayout, useTranslation, useSlot } from "@rebasepro/app";
|
|
5
|
-
import { CollectionActionsProps, EntityTableController, SelectionController, AdminCollection } from "@rebasepro/admin-types";
|
|
5
|
+
import { CollectionActionsProps, EntityTableController, SelectionController, AdminCollection, ViewMode } from "@rebasepro/admin-types";
|
|
6
6
|
import { ErrorBoundary, iconSize } from "@rebasepro/ui";
|
|
7
|
-
import {
|
|
7
|
+
import { Badge, Button, cls, FilterIcon, IconButton, Tooltip, XIcon } from "@rebasepro/ui";
|
|
8
8
|
import { ClearFilterSortButton } from "../ClearFilterSortButton";
|
|
9
9
|
import { FiltersDialog } from "./FiltersDialog";
|
|
10
10
|
import { FilterPresetsButton } from "./FilterPresetsButton";
|
|
11
|
+
import { SortButton } from "./SortButton";
|
|
11
12
|
import { toArray } from "@rebasepro/utils";
|
|
12
|
-
import { useNavigate } from "react-router
|
|
13
|
+
import { useNavigate } from "react-router";
|
|
13
14
|
import { useUrlController } from "../../hooks/navigation/contexts/UrlContext";
|
|
14
15
|
import { useAdminContext } from "../../hooks/useAdminContext";
|
|
15
16
|
import { withViewMode } from "../../util/view_mode";
|
|
17
|
+
import { useSplitView } from "./SplitViewContext";
|
|
16
18
|
|
|
17
19
|
export type CollectionViewStartActionsProps<M extends Record<string, unknown>> = {
|
|
18
20
|
collection: AdminCollection<M>;
|
|
@@ -26,6 +28,11 @@ export type CollectionViewStartActionsProps<M extends Record<string, unknown>> =
|
|
|
26
28
|
* Resolved properties from the collection for the filters dialog
|
|
27
29
|
*/
|
|
28
30
|
resolvedProperties?: Properties;
|
|
31
|
+
/**
|
|
32
|
+
* Which view is being displayed. Decides whether the sort control is
|
|
33
|
+
* offered — see `sortButton`.
|
|
34
|
+
*/
|
|
35
|
+
viewMode?: ViewMode;
|
|
29
36
|
compact?: boolean;
|
|
30
37
|
openNewDocument: (defaultValues?: Record<string, unknown>) => void;
|
|
31
38
|
}
|
|
@@ -39,6 +46,7 @@ export function CollectionViewStartActions<M extends Record<string, unknown>>({
|
|
|
39
46
|
tableController,
|
|
40
47
|
collectionEntitiesCount,
|
|
41
48
|
resolvedProperties,
|
|
49
|
+
viewMode,
|
|
42
50
|
compact,
|
|
43
51
|
openNewDocument
|
|
44
52
|
}: CollectionViewStartActionsProps<M>) {
|
|
@@ -49,6 +57,7 @@ export function CollectionViewStartActions<M extends Record<string, unknown>>({
|
|
|
49
57
|
|
|
50
58
|
const navigate = useNavigate();
|
|
51
59
|
const urlController = useUrlController();
|
|
60
|
+
const splitView = useSplitView();
|
|
52
61
|
|
|
53
62
|
// Filters dialog state
|
|
54
63
|
const [filtersDialogOpen, setFiltersDialogOpen] = useState(false);
|
|
@@ -77,14 +86,20 @@ parentEntityIds,
|
|
|
77
86
|
navigate(withViewMode(urlController.buildUrlCollectionPath(path)));
|
|
78
87
|
}, [navigate, urlController, path]);
|
|
79
88
|
|
|
80
|
-
|
|
81
|
-
|
|
89
|
+
// In a split view the control that closes the list lives at the leading
|
|
90
|
+
// edge of the record's app bar (`SplitListCloseButton`), not here.
|
|
91
|
+
//
|
|
92
|
+
// Outside one — a compact list with no record open beside it — the only
|
|
93
|
+
// thing this can mean is "leave the record behind".
|
|
94
|
+
const backButton = compact && !splitView?.detailOpen && (
|
|
95
|
+
<Tooltip title={t("close")} key={"close_tooltip"}>
|
|
82
96
|
<IconButton
|
|
83
97
|
size="small"
|
|
84
98
|
onClick={handleBackClick}
|
|
99
|
+
aria-label={t("close")}
|
|
85
100
|
className="mr-1"
|
|
86
101
|
>
|
|
87
|
-
<
|
|
102
|
+
<XIcon size={iconSize.smallest}/>
|
|
88
103
|
</IconButton>
|
|
89
104
|
</Tooltip>
|
|
90
105
|
);
|
|
@@ -107,20 +122,39 @@ parentEntityIds,
|
|
|
107
122
|
{activeFilterCount > 0 ? `(${activeFilterCount})` : t("filters")}
|
|
108
123
|
</Button>
|
|
109
124
|
) : (
|
|
110
|
-
<
|
|
111
|
-
|
|
112
|
-
size="small"
|
|
125
|
+
<IconButton
|
|
126
|
+
size={"small"}
|
|
113
127
|
onClick={() => setFiltersDialogOpen(true)}
|
|
114
128
|
className={cls(activeFilterCount > 0 && "text-primary")}
|
|
115
129
|
>
|
|
116
|
-
<FilterIcon size={iconSize.
|
|
117
|
-
</
|
|
130
|
+
<FilterIcon size={iconSize.smallest}/>
|
|
131
|
+
</IconButton>
|
|
118
132
|
)}
|
|
119
133
|
</Badge>
|
|
120
134
|
</Tooltip>
|
|
121
135
|
);
|
|
122
136
|
|
|
123
|
-
|
|
137
|
+
// Ordering the collection is a click on a column header — which only the
|
|
138
|
+
// table has. The list and card views showed rows in whatever order the
|
|
139
|
+
// query returned with no way to change it, so they get the control in the
|
|
140
|
+
// toolbar instead.
|
|
141
|
+
//
|
|
142
|
+
// Not the table, where the headers already do this and say which column is
|
|
143
|
+
// sorted; and not the board, which orders its columns by its own order key
|
|
144
|
+
// and ignores `sortBy` entirely, so the control would appear to do nothing.
|
|
145
|
+
const sortButton = resolvedProperties && (viewMode === "list" || viewMode === "cards") && (
|
|
146
|
+
<SortButton
|
|
147
|
+
key={"sort_button"}
|
|
148
|
+
tableController={tableController}
|
|
149
|
+
properties={resolvedProperties}
|
|
150
|
+
/>
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// Not in the split view. The toolbar there is a strip a few hundred pixels
|
|
154
|
+
// wide above the list, and a row of preset chips crowded out the controls
|
|
155
|
+
// that act on what you are looking at. The presets are still one click away
|
|
156
|
+
// inside the filters dialog.
|
|
157
|
+
const filterPresetsButton = !compact && collection.filterPresets?.length ? (
|
|
124
158
|
<FilterPresetsButton
|
|
125
159
|
key={"filter_presets"}
|
|
126
160
|
filterPresets={collection.filterPresets}
|
|
@@ -132,6 +166,7 @@ parentEntityIds,
|
|
|
132
166
|
const actions: React.ReactNode[] = [
|
|
133
167
|
backButton,
|
|
134
168
|
filtersButton,
|
|
169
|
+
sortButton,
|
|
135
170
|
<ClearFilterSortButton
|
|
136
171
|
key={"clear_filter"}
|
|
137
172
|
tableController={tableController}
|