@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.
Files changed (178) hide show
  1. package/README.md +1 -1
  2. package/dist/{CollectionEditorDialog-Dpc75wIZ.js → CollectionEditorDialog-CjXkWZ64.js} +75 -62
  3. package/dist/{CollectionEditorDialog-Dpc75wIZ.js.map → CollectionEditorDialog-CjXkWZ64.js.map} +1 -1
  4. package/dist/{PropertyEditView-DH3XZC2x.js → PropertyEditView-W8I3YV89.js} +36 -16
  5. package/dist/{PropertyEditView-DH3XZC2x.js.map → PropertyEditView-W8I3YV89.js.map} +1 -1
  6. package/dist/{RouterCollectionsStudioView-BZLq4MGp.js → RouterCollectionsStudioView-BsLTNoqX.js} +5 -5
  7. package/dist/{RouterCollectionsStudioView-BZLq4MGp.js.map → RouterCollectionsStudioView-BsLTNoqX.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/CollectionTableBinding/column_utils.d.ts +17 -0
  14. package/dist/components/CollectionViewBinding/CollectionViewStartActions.d.ts +7 -2
  15. package/dist/components/CollectionViewBinding/SortButton.d.ts +24 -0
  16. package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +10 -0
  17. package/dist/components/CollectionViewBinding/SplitViewContext.d.ts +23 -0
  18. package/dist/components/CollectionViewBinding/hooks/useKanbanDragAndDrop.d.ts +26 -0
  19. package/dist/components/DetailViewBinding.d.ts +5 -0
  20. package/dist/components/EditFormActions.d.ts +12 -1
  21. package/dist/components/EditViewBinding.d.ts +6 -1
  22. package/dist/components/EntityIdentityBar.d.ts +53 -0
  23. package/dist/components/EntityInspector.d.ts +35 -0
  24. package/dist/components/EntityPreviewNesting.d.ts +25 -0
  25. package/dist/components/EntityViewBinding.d.ts +9 -0
  26. package/dist/components/InlineEntityPreview.d.ts +47 -0
  27. package/dist/components/history/EntityHistoryView.d.ts +4 -1
  28. package/dist/components/index.d.ts +2 -0
  29. package/dist/editor.js +18 -10
  30. package/dist/editor.js.map +1 -1
  31. package/dist/{export-BzIpUdSK.js → export-J79rMoNj.js} +2 -2
  32. package/dist/{export-BzIpUdSK.js.map → export-J79rMoNj.js.map} +1 -1
  33. package/dist/form/EntityForm.d.ts +1 -1
  34. package/dist/form/EntityFormActions.d.ts +11 -1
  35. package/dist/form/PropertyFieldBinding.d.ts +1 -1
  36. package/dist/form/components/FieldBlock.d.ts +30 -0
  37. package/dist/form/components/FormRail.d.ts +18 -0
  38. package/dist/form/components/FormSections.d.ts +16 -0
  39. package/dist/form/components/index.d.ts +3 -2
  40. package/dist/form/components/useRailVisible.d.ts +24 -0
  41. package/dist/form/field_bindings/BinaryFieldBinding.d.ts +1 -1
  42. package/dist/form/field_bindings/DateTimeFieldBinding.d.ts +1 -1
  43. package/dist/form/field_bindings/GeopointFieldBinding.d.ts +1 -1
  44. package/dist/form/field_bindings/MarkdownEditorFieldBinding.d.ts +1 -1
  45. package/dist/form/field_bindings/MultiSelectFieldBinding.d.ts +1 -1
  46. package/dist/form/field_bindings/ReadOnlyFieldBinding.d.ts +1 -1
  47. package/dist/form/field_bindings/SelectFieldBinding.d.ts +1 -1
  48. package/dist/form/field_bindings/StorageUploadFieldBinding.d.ts +1 -1
  49. package/dist/form/field_bindings/SwitchFieldBinding.d.ts +1 -1
  50. package/dist/form/field_bindings/TextFieldBinding.d.ts +1 -1
  51. package/dist/form/field_bindings/UserSelectFieldBinding.d.ts +1 -1
  52. package/dist/form/field_bindings/VectorFieldBinding.d.ts +1 -1
  53. package/dist/{history-g9688IaK.js → history-D7L_JlLO.js} +35 -33
  54. package/dist/history-D7L_JlLO.js.map +1 -0
  55. package/dist/hooks/navigation/utils.d.ts +16 -0
  56. package/dist/hooks/useBuildSidePanel.d.ts +14 -0
  57. package/dist/hooks/useEntityDisplayTitle.d.ts +18 -0
  58. package/dist/hooks/useHistory.d.ts +7 -0
  59. package/dist/hooks/useRecordActions.d.ts +17 -0
  60. package/dist/{import-BjUG-cIY.js → import-BvYl--U4.js} +2 -2
  61. package/dist/{import-BjUG-cIY.js.map → import-BvYl--U4.js.map} +1 -1
  62. package/dist/index.js +106 -35
  63. package/dist/index.js.map +1 -1
  64. package/dist/markdown-CklalUUk.js.map +1 -1
  65. package/dist/preview/components/InlineEntityListPreview.d.ts +15 -0
  66. package/dist/preview/index.d.ts +1 -0
  67. package/dist/preview/property_previews/ArrayOfReferencesPreview.d.ts +1 -1
  68. package/dist/preview/property_previews/ArrayOfRelationsPreview.d.ts +1 -1
  69. package/dist/types/components/EntityFormActionsProps.d.ts +0 -1
  70. package/dist/types/components/EntityFormProps.d.ts +0 -5
  71. package/dist/types/fields.d.ts +24 -0
  72. package/dist/{util-B5fJm1FA.js → util-GiwPgxnL.js} +2106 -1117
  73. package/dist/util-GiwPgxnL.js.map +1 -0
  74. package/package.json +32 -34
  75. package/src/collection_editor/ConfigControllerProvider.tsx +1 -1
  76. package/src/collection_editor/serializable_types.ts +4 -3
  77. package/src/collection_editor/serializable_utils.ts +51 -17
  78. package/src/collection_editor/ui/collection_editor/CollectionEditorDialog.tsx +65 -22
  79. package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +38 -30
  80. package/src/collection_editor/ui/collection_editor/CollectionRelationsTab.tsx +3 -1
  81. package/src/collection_editor/ui/collection_editor/RouterCollectionStudioView.tsx +1 -1
  82. package/src/collection_editor/ui/collection_editor/RouterCollectionsStudioView.tsx +1 -1
  83. package/src/collection_editor/ui/collection_editor/properties/RelationPropertyField.tsx +16 -0
  84. package/src/collection_editor/useLocalCollectionsConfigController.tsx +127 -15
  85. package/src/collection_editor/validateCollectionJson.ts +3 -3
  86. package/src/components/AdminModeSyncer.tsx +1 -1
  87. package/src/components/CollectionTableBinding/PropertyTableCell.tsx +10 -2
  88. package/src/components/CollectionTableBinding/column_utils.tsx +59 -2
  89. package/src/components/CollectionTableBinding/table_bindings.tsx +2 -1
  90. package/src/components/CollectionViewBinding/CollectionBoardViewBinding.tsx +23 -16
  91. package/src/components/CollectionViewBinding/CollectionViewActions.tsx +7 -1
  92. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +9 -2
  93. package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +47 -12
  94. package/src/components/CollectionViewBinding/SortButton.tsx +189 -0
  95. package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +33 -0
  96. package/src/components/CollectionViewBinding/SplitListView.tsx +18 -3
  97. package/src/components/CollectionViewBinding/SplitViewContext.tsx +34 -0
  98. package/src/components/CollectionViewBinding/hooks/useKanbanDragAndDrop.ts +48 -9
  99. package/src/components/CollectionViewBinding/useBoardDataController.tsx +80 -25
  100. package/src/components/CollectionViewBinding/usePreviewSlots.ts +18 -6
  101. package/src/components/DefaultAppBar.tsx +1 -1
  102. package/src/components/DefaultDrawer.tsx +1 -1
  103. package/src/components/DetailViewBinding.tsx +112 -148
  104. package/src/components/DrawerNavigationItem.tsx +1 -1
  105. package/src/components/EditFormActions.tsx +54 -129
  106. package/src/components/EditViewBinding.tsx +202 -99
  107. package/src/components/EntityIdentityBar.tsx +288 -0
  108. package/src/components/EntityInspector.tsx +137 -0
  109. package/src/components/EntityPreviewBinding.tsx +6 -2
  110. package/src/components/EntityPreviewNesting.tsx +50 -0
  111. package/src/components/EntityViewBinding.tsx +35 -32
  112. package/src/components/HomePage/FavouritesView.tsx +1 -1
  113. package/src/components/HomePage/NavigationCardBinding.tsx +1 -1
  114. package/src/components/HomePage/SmallNavigationCard.tsx +1 -1
  115. package/src/components/InlineEntityPreview.tsx +149 -0
  116. package/src/components/RebaseLayout.tsx +1 -1
  117. package/src/components/RebaseNavigation.tsx +45 -5
  118. package/src/components/RebaseRouteDefs.tsx +1 -1
  119. package/src/components/SelectableTable/SelectableTable.tsx +2 -2
  120. package/src/components/SidePanelBinding.tsx +19 -13
  121. package/src/components/UserSelector.tsx +12 -6
  122. package/src/components/common/default_entity_actions.tsx +4 -3
  123. package/src/components/history/EntityHistoryEntry.tsx +28 -7
  124. package/src/components/history/EntityHistoryView.tsx +29 -24
  125. package/src/components/history/LastEditedByIndicator.tsx +4 -3
  126. package/src/components/index.ts +2 -0
  127. package/src/data_import/utils/get_import_inference_type.ts +6 -0
  128. package/src/form/EntityForm.tsx +267 -179
  129. package/src/form/EntityFormActions.tsx +50 -99
  130. package/src/form/EntityFormBinding.tsx +4 -2
  131. package/src/form/PropertyFieldBinding.tsx +4 -0
  132. package/src/form/components/FieldBlock.tsx +119 -0
  133. package/src/form/components/FormRail.tsx +125 -0
  134. package/src/form/components/FormSections.tsx +118 -0
  135. package/src/form/components/index.tsx +3 -2
  136. package/src/form/components/useRailVisible.tsx +46 -0
  137. package/src/form/field_bindings/BinaryFieldBinding.tsx +3 -2
  138. package/src/form/field_bindings/DateTimeFieldBinding.tsx +3 -2
  139. package/src/form/field_bindings/GeopointFieldBinding.tsx +3 -2
  140. package/src/form/field_bindings/MapFieldBinding.tsx +1 -2
  141. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +3 -2
  142. package/src/form/field_bindings/MultiSelectFieldBinding.tsx +2 -1
  143. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +2 -1
  144. package/src/form/field_bindings/ReferenceFieldBinding.tsx +2 -1
  145. package/src/form/field_bindings/RelationFieldBinding.tsx +6 -4
  146. package/src/form/field_bindings/RepeatFieldBinding.tsx +4 -1
  147. package/src/form/field_bindings/SelectFieldBinding.tsx +3 -2
  148. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +31 -8
  149. package/src/form/field_bindings/SwitchFieldBinding.tsx +7 -3
  150. package/src/form/field_bindings/TextFieldBinding.tsx +18 -16
  151. package/src/form/field_bindings/UserSelectFieldBinding.tsx +3 -2
  152. package/src/form/field_bindings/VectorFieldBinding.tsx +2 -1
  153. package/src/hooks/navigation/useBuildUrlController.tsx +1 -1
  154. package/src/hooks/navigation/useTopLevelNavigation.ts +18 -7
  155. package/src/hooks/navigation/utils.ts +20 -0
  156. package/src/hooks/useBuildSideDialogsController.tsx +2 -2
  157. package/src/hooks/useBuildSidePanel.tsx +22 -61
  158. package/src/hooks/useEntityDisplayTitle.tsx +60 -0
  159. package/src/hooks/useHistory.ts +20 -10
  160. package/src/hooks/useRecordActions.tsx +69 -0
  161. package/src/hooks/useResolvedUsers.ts +10 -5
  162. package/src/preview/PropertyPreview.tsx +48 -22
  163. package/src/preview/components/InlineEntityListPreview.tsx +42 -0
  164. package/src/preview/components/ReferencePreview.tsx +34 -14
  165. package/src/preview/components/RelationPreview.tsx +34 -14
  166. package/src/preview/index.ts +1 -0
  167. package/src/preview/property_previews/ArrayOfReferencesPreview.tsx +24 -1
  168. package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +25 -1
  169. package/src/routes/RebaseRoute.tsx +2 -2
  170. package/src/types/components/EntityFormActionsProps.tsx +0 -1
  171. package/src/types/components/EntityFormProps.tsx +0 -6
  172. package/src/types/fields.tsx +27 -0
  173. package/dist/form/components/FormEntry.d.ts +0 -6
  174. package/dist/form/components/FormLayout.d.ts +0 -5
  175. package/dist/history-g9688IaK.js.map +0 -1
  176. package/dist/util-B5fJm1FA.js.map +0 -1
  177. package/src/form/components/FormEntry.tsx +0 -22
  178. 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,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
- // 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";
@@ -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 { 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
+ import { SortButton } from "./SortButton";
11
12
  import { toArray } from "@rebasepro/utils";
12
- import { useNavigate } from "react-router-dom";
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
- const backButton = compact && (
81
- <Tooltip title={t("back")} key={"back_tooltip"}>
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
- <ArrowLeftIcon size={iconSize.small}/>
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
- <Button
111
- variant="text"
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.small}/>
117
- </Button>
130
+ <FilterIcon size={iconSize.smallest}/>
131
+ </IconButton>
118
132
  )}
119
133
  </Badge>
120
134
  </Tooltip>
121
135
  );
122
136
 
123
- const filterPresetsButton = collection.filterPresets?.length ? (
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}