@rebasepro/admin 0.12.0 → 0.12.1-canary.g009ed95

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 (172) hide show
  1. package/README.md +1 -1
  2. package/dist/{CollectionEditorDialog-Dpc75wIZ.js → CollectionEditorDialog-B-iVE9y3.js} +75 -62
  3. package/dist/{CollectionEditorDialog-Dpc75wIZ.js.map → CollectionEditorDialog-B-iVE9y3.js.map} +1 -1
  4. package/dist/{PropertyEditView-DH3XZC2x.js → PropertyEditView-DoLEH_bQ.js} +36 -16
  5. package/dist/{PropertyEditView-DH3XZC2x.js.map → PropertyEditView-DoLEH_bQ.js.map} +1 -1
  6. package/dist/{RouterCollectionsStudioView-BZLq4MGp.js → RouterCollectionsStudioView-C3669Ezm.js} +5 -5
  7. package/dist/{RouterCollectionsStudioView-BZLq4MGp.js.map → RouterCollectionsStudioView-C3669Ezm.js.map} +1 -1
  8. package/dist/collection_editor/serializable_types.d.ts +4 -1
  9. package/dist/collection_editor/ui/collection_editor/CollectionRelationsTab.d.ts +1 -0
  10. package/dist/collection_editor/useLocalCollectionsConfigController.d.ts +8 -0
  11. package/dist/collection_editor_ui.js +5 -5
  12. package/dist/collection_editor_ui.js.map +1 -1
  13. package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +10 -0
  14. package/dist/components/CollectionViewBinding/SplitViewContext.d.ts +23 -0
  15. package/dist/components/CollectionViewBinding/hooks/useKanbanDragAndDrop.d.ts +26 -0
  16. package/dist/components/DetailViewBinding.d.ts +5 -0
  17. package/dist/components/EditFormActions.d.ts +12 -1
  18. package/dist/components/EditViewBinding.d.ts +6 -1
  19. package/dist/components/EntityIdentityBar.d.ts +53 -0
  20. package/dist/components/EntityInspector.d.ts +29 -0
  21. package/dist/components/EntityPreviewNesting.d.ts +25 -0
  22. package/dist/components/EntityViewBinding.d.ts +9 -0
  23. package/dist/components/InlineEntityPreview.d.ts +47 -0
  24. package/dist/components/index.d.ts +2 -0
  25. package/dist/editor.js +18 -10
  26. package/dist/editor.js.map +1 -1
  27. package/dist/{export-BzIpUdSK.js → export-B8DIn-u8.js} +2 -2
  28. package/dist/{export-BzIpUdSK.js.map → export-B8DIn-u8.js.map} +1 -1
  29. package/dist/form/EntityForm.d.ts +1 -1
  30. package/dist/form/EntityFormActions.d.ts +11 -1
  31. package/dist/form/PropertyFieldBinding.d.ts +1 -1
  32. package/dist/form/components/FieldBlock.d.ts +30 -0
  33. package/dist/form/components/FormRail.d.ts +18 -0
  34. package/dist/form/components/FormSections.d.ts +16 -0
  35. package/dist/form/components/index.d.ts +3 -2
  36. package/dist/form/components/useRailVisible.d.ts +24 -0
  37. package/dist/form/field_bindings/BinaryFieldBinding.d.ts +1 -1
  38. package/dist/form/field_bindings/DateTimeFieldBinding.d.ts +1 -1
  39. package/dist/form/field_bindings/GeopointFieldBinding.d.ts +1 -1
  40. package/dist/form/field_bindings/MarkdownEditorFieldBinding.d.ts +1 -1
  41. package/dist/form/field_bindings/MultiSelectFieldBinding.d.ts +1 -1
  42. package/dist/form/field_bindings/ReadOnlyFieldBinding.d.ts +1 -1
  43. package/dist/form/field_bindings/SelectFieldBinding.d.ts +1 -1
  44. package/dist/form/field_bindings/StorageUploadFieldBinding.d.ts +1 -1
  45. package/dist/form/field_bindings/SwitchFieldBinding.d.ts +1 -1
  46. package/dist/form/field_bindings/TextFieldBinding.d.ts +1 -1
  47. package/dist/form/field_bindings/UserSelectFieldBinding.d.ts +1 -1
  48. package/dist/form/field_bindings/VectorFieldBinding.d.ts +1 -1
  49. package/dist/{history-g9688IaK.js → history-ClwDEtqN.js} +32 -31
  50. package/dist/history-ClwDEtqN.js.map +1 -0
  51. package/dist/hooks/navigation/utils.d.ts +16 -0
  52. package/dist/hooks/useBuildSidePanel.d.ts +14 -0
  53. package/dist/hooks/useEntityDisplayTitle.d.ts +18 -0
  54. package/dist/hooks/useRecordActions.d.ts +17 -0
  55. package/dist/{import-BjUG-cIY.js → import-Cwzf4RW5.js} +2 -2
  56. package/dist/{import-BjUG-cIY.js.map → import-Cwzf4RW5.js.map} +1 -1
  57. package/dist/index.js +106 -35
  58. package/dist/index.js.map +1 -1
  59. package/dist/markdown-CklalUUk.js.map +1 -1
  60. package/dist/preview/components/InlineEntityListPreview.d.ts +15 -0
  61. package/dist/preview/index.d.ts +1 -0
  62. package/dist/preview/property_previews/ArrayOfReferencesPreview.d.ts +1 -1
  63. package/dist/preview/property_previews/ArrayOfRelationsPreview.d.ts +1 -1
  64. package/dist/types/components/EntityFormActionsProps.d.ts +0 -1
  65. package/dist/types/components/EntityFormProps.d.ts +0 -5
  66. package/dist/types/fields.d.ts +24 -0
  67. package/dist/{util-B5fJm1FA.js → util-DSh3HRzK.js} +1974 -1130
  68. package/dist/util-DSh3HRzK.js.map +1 -0
  69. package/package.json +32 -34
  70. package/src/collection_editor/ConfigControllerProvider.tsx +1 -1
  71. package/src/collection_editor/serializable_types.ts +4 -3
  72. package/src/collection_editor/serializable_utils.ts +51 -17
  73. package/src/collection_editor/ui/collection_editor/CollectionEditorDialog.tsx +65 -22
  74. package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +38 -30
  75. package/src/collection_editor/ui/collection_editor/CollectionRelationsTab.tsx +3 -1
  76. package/src/collection_editor/ui/collection_editor/RouterCollectionStudioView.tsx +1 -1
  77. package/src/collection_editor/ui/collection_editor/RouterCollectionsStudioView.tsx +1 -1
  78. package/src/collection_editor/ui/collection_editor/properties/RelationPropertyField.tsx +16 -0
  79. package/src/collection_editor/useLocalCollectionsConfigController.tsx +127 -15
  80. package/src/collection_editor/validateCollectionJson.ts +3 -3
  81. package/src/components/AdminModeSyncer.tsx +1 -1
  82. package/src/components/CollectionTableBinding/PropertyTableCell.tsx +10 -2
  83. package/src/components/CollectionTableBinding/column_utils.tsx +26 -2
  84. package/src/components/CollectionTableBinding/table_bindings.tsx +2 -1
  85. package/src/components/CollectionViewBinding/CollectionBoardViewBinding.tsx +23 -16
  86. package/src/components/CollectionViewBinding/CollectionViewActions.tsx +7 -1
  87. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +8 -2
  88. package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +22 -11
  89. package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +33 -0
  90. package/src/components/CollectionViewBinding/SplitListView.tsx +18 -3
  91. package/src/components/CollectionViewBinding/SplitViewContext.tsx +34 -0
  92. package/src/components/CollectionViewBinding/hooks/useKanbanDragAndDrop.ts +48 -9
  93. package/src/components/CollectionViewBinding/useBoardDataController.tsx +80 -25
  94. package/src/components/CollectionViewBinding/usePreviewSlots.ts +18 -6
  95. package/src/components/DefaultAppBar.tsx +1 -1
  96. package/src/components/DefaultDrawer.tsx +1 -1
  97. package/src/components/DetailViewBinding.tsx +100 -143
  98. package/src/components/DrawerNavigationItem.tsx +1 -1
  99. package/src/components/EditFormActions.tsx +54 -129
  100. package/src/components/EditViewBinding.tsx +182 -93
  101. package/src/components/EntityIdentityBar.tsx +288 -0
  102. package/src/components/EntityInspector.tsx +132 -0
  103. package/src/components/EntityPreviewBinding.tsx +6 -2
  104. package/src/components/EntityPreviewNesting.tsx +50 -0
  105. package/src/components/EntityViewBinding.tsx +35 -32
  106. package/src/components/HomePage/FavouritesView.tsx +1 -1
  107. package/src/components/HomePage/NavigationCardBinding.tsx +1 -1
  108. package/src/components/HomePage/SmallNavigationCard.tsx +1 -1
  109. package/src/components/InlineEntityPreview.tsx +149 -0
  110. package/src/components/RebaseLayout.tsx +1 -1
  111. package/src/components/RebaseNavigation.tsx +45 -5
  112. package/src/components/RebaseRouteDefs.tsx +1 -1
  113. package/src/components/SelectableTable/SelectableTable.tsx +2 -2
  114. package/src/components/SidePanelBinding.tsx +19 -13
  115. package/src/components/UserSelector.tsx +12 -6
  116. package/src/components/common/default_entity_actions.tsx +4 -3
  117. package/src/components/history/EntityHistoryEntry.tsx +28 -7
  118. package/src/components/history/EntityHistoryView.tsx +20 -20
  119. package/src/components/history/LastEditedByIndicator.tsx +4 -3
  120. package/src/components/index.ts +2 -0
  121. package/src/data_import/utils/get_import_inference_type.ts +6 -0
  122. package/src/form/EntityForm.tsx +267 -179
  123. package/src/form/EntityFormActions.tsx +50 -99
  124. package/src/form/EntityFormBinding.tsx +4 -2
  125. package/src/form/PropertyFieldBinding.tsx +4 -0
  126. package/src/form/components/FieldBlock.tsx +119 -0
  127. package/src/form/components/FormRail.tsx +125 -0
  128. package/src/form/components/FormSections.tsx +118 -0
  129. package/src/form/components/index.tsx +3 -2
  130. package/src/form/components/useRailVisible.tsx +46 -0
  131. package/src/form/field_bindings/BinaryFieldBinding.tsx +3 -2
  132. package/src/form/field_bindings/DateTimeFieldBinding.tsx +3 -2
  133. package/src/form/field_bindings/GeopointFieldBinding.tsx +3 -2
  134. package/src/form/field_bindings/MapFieldBinding.tsx +1 -2
  135. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +3 -2
  136. package/src/form/field_bindings/MultiSelectFieldBinding.tsx +2 -1
  137. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +2 -1
  138. package/src/form/field_bindings/ReferenceFieldBinding.tsx +2 -1
  139. package/src/form/field_bindings/RelationFieldBinding.tsx +6 -4
  140. package/src/form/field_bindings/RepeatFieldBinding.tsx +4 -1
  141. package/src/form/field_bindings/SelectFieldBinding.tsx +3 -2
  142. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +31 -8
  143. package/src/form/field_bindings/SwitchFieldBinding.tsx +7 -3
  144. package/src/form/field_bindings/TextFieldBinding.tsx +18 -16
  145. package/src/form/field_bindings/UserSelectFieldBinding.tsx +3 -2
  146. package/src/form/field_bindings/VectorFieldBinding.tsx +2 -1
  147. package/src/hooks/navigation/useBuildUrlController.tsx +1 -1
  148. package/src/hooks/navigation/useTopLevelNavigation.ts +18 -7
  149. package/src/hooks/navigation/utils.ts +20 -0
  150. package/src/hooks/useBuildSideDialogsController.tsx +2 -2
  151. package/src/hooks/useBuildSidePanel.tsx +22 -61
  152. package/src/hooks/useEntityDisplayTitle.tsx +60 -0
  153. package/src/hooks/useHistory.ts +6 -5
  154. package/src/hooks/useRecordActions.tsx +69 -0
  155. package/src/hooks/useResolvedUsers.ts +10 -5
  156. package/src/preview/PropertyPreview.tsx +48 -22
  157. package/src/preview/components/InlineEntityListPreview.tsx +42 -0
  158. package/src/preview/components/ReferencePreview.tsx +34 -14
  159. package/src/preview/components/RelationPreview.tsx +34 -14
  160. package/src/preview/index.ts +1 -0
  161. package/src/preview/property_previews/ArrayOfReferencesPreview.tsx +24 -1
  162. package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +25 -1
  163. package/src/routes/RebaseRoute.tsx +2 -2
  164. package/src/types/components/EntityFormActionsProps.tsx +0 -1
  165. package/src/types/components/EntityFormProps.tsx +0 -6
  166. package/src/types/fields.tsx +27 -0
  167. package/dist/form/components/FormEntry.d.ts +0 -6
  168. package/dist/form/components/FormLayout.d.ts +0 -5
  169. package/dist/history-g9688IaK.js.map +0 -1
  170. package/dist/util-B5fJm1FA.js.map +0 -1
  171. package/src/form/components/FormEntry.tsx +0 -22
  172. 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
- let token = optionsRef.current?.getAuthToken ? await optionsRef.current.getAuthToken() : null;
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(`${baseUrl.replace(/\/$/, "")}/api/schema-editor${endpoint}`, {
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: options?.readOnly ?? false,
73
- readOnlyReason: "Local collection editing is only available in development mode.",
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, options?.readOnly, options?.getAuthToken]);
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-dom";
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,30 @@ export function propertiesToColumns<M extends Record<string, unknown>>({ propert
67
67
  });
68
68
  }
69
69
 
70
+ /**
71
+ * Whether this column can be ordered by.
72
+ *
73
+ * `sortable` arrived as one flag for the whole table while `filter` was
74
+ * computed per property, so every column advertised a sort — including the ones
75
+ * no `ORDER BY` can be written for. A to-many relation is a *set* per row
76
+ * (`posts.tags`), and there is no single value to order on; the driver used to
77
+ * answer by silently dropping the sort and returning rows in whatever order
78
+ * Postgres pleased, which is how a header that did nothing went unnoticed.
79
+ *
80
+ * Now that the driver refuses instead, offering the control would turn a
81
+ * useless header into a failed request. Same authority as the filter side: ask
82
+ * the property, not the table.
83
+ */
84
+ function sortableProperty(property: Property): boolean {
85
+ if (property.type !== "relation") return true;
86
+ const relationProperty = property as RelationProperty;
87
+ const kind = relationProperty.relation?.kind ?? relationProperty.resolvedRelation?.kind;
88
+ // An unresolved relation keeps the benefit of the doubt — the same choice
89
+ // `isFilterableRelation` makes for a kind it cannot see.
90
+ if (!kind) return true;
91
+ return kind === "belongsTo";
92
+ }
93
+
70
94
  function filterableProperty(property: Property, partOfArray = false, engine?: string): boolean {
71
95
  // A relation this engine's driver cannot compile into a `WHERE` has
72
96
  // 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
- // Fallback to checking loaded entities
302
- return allEntities.some((entity: Entity<M>) => {
303
- const orderValue = entity.values?.[orderProperty];
304
- return orderValue === undefined || orderValue === null;
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 entities)
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
- // Find entities missing order property
385
- const entitiesToUpdate = allDocs.filter((entity: Entity<M>) => {
386
- const orderValue = entity.values?.[orderProperty];
387
- return orderValue === undefined || orderValue === null;
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
- <div className="flex-1 overflow-auto no-scrollbar">
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-dom";
16
+ import { useSearchParams } from "react-router";
17
17
 
18
18
  import { deepEqual as equal } from "fast-equals"
19
19
  import { CollectionRowActions, CollectionTableBinding } from "../CollectionTableBinding";
@@ -1166,8 +1166,14 @@ parentEntityIds,
1166
1166
  ) : (
1167
1167
  <div className="flex flex-col w-full h-full">
1168
1168
  {toolbarNode}
1169
+ {/* `min-h-0` is what lets the board scroll its columns.
1170
+ A flex item defaults to `min-height: auto`, so this
1171
+ one grew to the board's full content height — 1230px
1172
+ in a 883px area — and the ancestor's `overflow-hidden`
1173
+ simply cut the rest off, with each column's own
1174
+ scroller never reaching its limit. */}
1169
1175
  <div className={cls(
1170
- "flex-1 flex flex-col",
1176
+ "flex-1 min-h-0 flex flex-col",
1171
1177
  (viewMode === "list" || viewMode === "cards") && "overflow-y-auto"
1172
1178
  )}>
1173
1179
  {collectionSurface}
@@ -4,15 +4,16 @@ import React, { useState, useCallback } from "react";
4
4
  import { useAuthController, useLargeLayout, useTranslation, useSlot } from "@rebasepro/app";
5
5
  import { CollectionActionsProps, EntityTableController, SelectionController, AdminCollection } from "@rebasepro/admin-types";
6
6
  import { ErrorBoundary, iconSize } from "@rebasepro/ui";
7
- import { ArrowLeftIcon, Badge, Button, cls, FilterIcon, IconButton, Tooltip } from "@rebasepro/ui";
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
11
  import { toArray } from "@rebasepro/utils";
12
- import { useNavigate } from "react-router-dom";
12
+ import { useNavigate } from "react-router";
13
13
  import { useUrlController } from "../../hooks/navigation/contexts/UrlContext";
14
14
  import { useAdminContext } from "../../hooks/useAdminContext";
15
15
  import { withViewMode } from "../../util/view_mode";
16
+ import { useSplitView } from "./SplitViewContext";
16
17
 
17
18
  export type CollectionViewStartActionsProps<M extends Record<string, unknown>> = {
18
19
  collection: AdminCollection<M>;
@@ -49,6 +50,7 @@ export function CollectionViewStartActions<M extends Record<string, unknown>>({
49
50
 
50
51
  const navigate = useNavigate();
51
52
  const urlController = useUrlController();
53
+ const splitView = useSplitView();
52
54
 
53
55
  // Filters dialog state
54
56
  const [filtersDialogOpen, setFiltersDialogOpen] = useState(false);
@@ -77,14 +79,20 @@ parentEntityIds,
77
79
  navigate(withViewMode(urlController.buildUrlCollectionPath(path)));
78
80
  }, [navigate, urlController, path]);
79
81
 
80
- const backButton = compact && (
81
- <Tooltip title={t("back")} key={"back_tooltip"}>
82
+ // In a split view the control that closes the list lives at the leading
83
+ // edge of the record's app bar (`SplitListCloseButton`), not here.
84
+ //
85
+ // Outside one — a compact list with no record open beside it — the only
86
+ // thing this can mean is "leave the record behind".
87
+ const backButton = compact && !splitView?.detailOpen && (
88
+ <Tooltip title={t("close")} key={"close_tooltip"}>
82
89
  <IconButton
83
90
  size="small"
84
91
  onClick={handleBackClick}
92
+ aria-label={t("close")}
85
93
  className="mr-1"
86
94
  >
87
- <ArrowLeftIcon size={iconSize.small}/>
95
+ <XIcon size={iconSize.smallest}/>
88
96
  </IconButton>
89
97
  </Tooltip>
90
98
  );
@@ -107,20 +115,23 @@ parentEntityIds,
107
115
  {activeFilterCount > 0 ? `(${activeFilterCount})` : t("filters")}
108
116
  </Button>
109
117
  ) : (
110
- <Button
111
- variant="text"
112
- size="small"
118
+ <IconButton
119
+ size={"small"}
113
120
  onClick={() => setFiltersDialogOpen(true)}
114
121
  className={cls(activeFilterCount > 0 && "text-primary")}
115
122
  >
116
- <FilterIcon size={iconSize.small}/>
117
- </Button>
123
+ <FilterIcon size={iconSize.smallest}/>
124
+ </IconButton>
118
125
  )}
119
126
  </Badge>
120
127
  </Tooltip>
121
128
  );
122
129
 
123
- const filterPresetsButton = collection.filterPresets?.length ? (
130
+ // Not in the split view. The toolbar there is a strip a few hundred pixels
131
+ // wide above the list, and a row of preset chips crowded out the controls
132
+ // that act on what you are looking at. The presets are still one click away
133
+ // inside the filters dialog.
134
+ const filterPresetsButton = !compact && collection.filterPresets?.length ? (
124
135
  <FilterPresetsButton
125
136
  key={"filter_presets"}
126
137
  filterPresets={collection.filterPresets}
@@ -0,0 +1,33 @@
1
+ import React from "react";
2
+
3
+ import { IconButton, iconSize, Tooltip, XIcon } from "@rebasepro/ui";
4
+ import { useTranslation } from "@rebasepro/app";
5
+
6
+ import { useSplitView } from "./SplitViewContext";
7
+
8
+ /**
9
+ * Closes the list of a split view, leaving the open record on its own.
10
+ *
11
+ * It leads the record's own app bar, where the breadcrumb starts — this is the
12
+ * entity pane's old "Open full screen" button, moved to that edge and wearing an
13
+ * X: closing the list and opening the record full screen are the same thing, so
14
+ * there is one control and one route (`#full`) rather than two.
15
+ */
16
+ export function SplitListCloseButton() {
17
+
18
+ const { t } = useTranslation();
19
+ const splitView = useSplitView();
20
+
21
+ if (!splitView?.detailOpen) return null;
22
+
23
+ return (
24
+ <Tooltip title={t("hide_list")}>
25
+ <IconButton
26
+ size="small"
27
+ onClick={splitView.openFullScreen}
28
+ aria-label={t("hide_list")}>
29
+ <XIcon size={iconSize.smallest}/>
30
+ </IconButton>
31
+ </Tooltip>
32
+ );
33
+ }
@@ -11,10 +11,11 @@ import {
11
11
  } from "@rebasepro/ui";
12
12
  import { useLargeLayout, UnsavedChangesDialog, useNavigationBlocker } from "@rebasepro/app";
13
13
  import { useCollectionRegistryController } from "../../hooks/navigation/contexts/CollectionRegistryContext";
14
- import { useNavigate, useLocation } from "react-router-dom";
14
+ import { useNavigate, useLocation } from "react-router";
15
15
  import { useUrlController } from "../../hooks/navigation/contexts/UrlContext";
16
16
  import { ErrorBoundary } from "@rebasepro/ui";
17
17
  import { withViewMode } from "../../util/view_mode";
18
+ import { SplitViewProvider } from "./SplitViewContext";
18
19
 
19
20
  export type SplitListViewProps<M extends Record<string, unknown> = Record<string, unknown>> = {
20
21
  collection: AdminCollection<M>;
@@ -408,8 +409,22 @@ export function SplitListView<M extends Record<string, unknown> = Record<string,
408
409
  </div>
409
410
  ) : <></>;
410
411
 
412
+ // Closing the list is the full-screen entity route: the same `#full` the
413
+ // entity pane's expand button used, so there is one way to give the record
414
+ // the whole window and one way back out of it.
415
+ const openFullScreen = useCallback(() => {
416
+ if (selectedEntityId === undefined) return;
417
+ const suffix = isEditMode ? "/edit" : "";
418
+ navigate(`${buildUrl(`${path}/${selectedEntityId}${suffix}`)}#full`);
419
+ }, [navigate, buildUrl, path, selectedEntityId, isEditMode]);
420
+
421
+ const splitViewController = useMemo(() => ({
422
+ detailOpen: selectedEntityId !== undefined,
423
+ openFullScreen
424
+ }), [selectedEntityId, openFullScreen]);
425
+
411
426
  return (
412
- <>
427
+ <SplitViewProvider value={splitViewController}>
413
428
  <ResizablePanels
414
429
  stacked={!largeLayout}
415
430
  firstPanel={listPanel}
@@ -428,6 +443,6 @@ export function SplitListView<M extends Record<string, unknown> = Record<string,
428
443
  }}
429
444
  handleCancel={() => blocker?.reset?.()}
430
445
  body={<>You have unsaved changes in this <b>{collection.singularName ?? collection.name}</b>.</>}/>
431
- </>
446
+ </SplitViewProvider>
432
447
  );
433
448
  }
@@ -0,0 +1,34 @@
1
+ import React, { useContext } from "react";
2
+
3
+ /**
4
+ * The enclosing master-detail split view, exposed to the list panel so it can
5
+ * carry the control that closes it.
6
+ */
7
+ export type SplitViewController = {
8
+ /** Whether a record is open in the detail pane. */
9
+ detailOpen: boolean;
10
+ /**
11
+ * Close the list and leave the open record on its own, full screen. This is
12
+ * the existing full-screen entity route (`#full`), not a second mechanism.
13
+ */
14
+ openFullScreen: () => void;
15
+ };
16
+
17
+ const SplitViewContext = React.createContext<SplitViewController | undefined>(undefined);
18
+
19
+ /**
20
+ * The enclosing split view, or `undefined` when the component is not rendered
21
+ * inside one (a side panel, a dialog, a full-screen route).
22
+ */
23
+ export function useSplitView(): SplitViewController | undefined {
24
+ return useContext(SplitViewContext);
25
+ }
26
+
27
+ export function SplitViewProvider({
28
+ value,
29
+ children
30
+ }: { value: SplitViewController, children: React.ReactNode }) {
31
+ return <SplitViewContext.Provider value={value}>
32
+ {children}
33
+ </SplitViewContext.Provider>;
34
+ }