@rebasepro/admin 0.9.1-canary.ff338b5 → 0.10.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 (92) hide show
  1. package/dist/{CollectionEditorDialog-DbPpDDwb.js → CollectionEditorDialog-BOlKGGg7.js} +123 -31
  2. package/dist/{CollectionEditorDialog-DbPpDDwb.js.map → CollectionEditorDialog-BOlKGGg7.js.map} +1 -1
  3. package/dist/{PropertyEditView-DfJbZ67t.js → PropertyEditView-i51tFOfM.js} +3 -3
  4. package/dist/PropertyEditView-i51tFOfM.js.map +1 -0
  5. package/dist/{RouterCollectionsStudioView-C6XQ7Il5.js → RouterCollectionsStudioView-Dh4W53PU.js} +4 -4
  6. package/dist/{RouterCollectionsStudioView-C6XQ7Il5.js.map → RouterCollectionsStudioView-Dh4W53PU.js.map} +1 -1
  7. package/dist/collection_editor_ui.js +4 -4
  8. package/dist/components/field_configs.d.ts +17 -0
  9. package/dist/contexts/BreacrumbsContext.d.ts +1 -1
  10. package/dist/editor.js.map +1 -1
  11. package/dist/export-BM6HPujv.js +518 -0
  12. package/dist/export-BM6HPujv.js.map +1 -0
  13. package/dist/{history-GSa4BgDt.js → history-CtIPJxvQ.js} +3 -3
  14. package/dist/history-CtIPJxvQ.js.map +1 -0
  15. package/dist/hooks/useSelectionDialog.d.ts +1 -1
  16. package/dist/import-DzQnT03v.js +328 -0
  17. package/dist/import-DzQnT03v.js.map +1 -0
  18. package/dist/index.js +3299 -1
  19. package/dist/index.js.map +1 -0
  20. package/dist/preview/components/UrlComponentPreview.d.ts +2 -1
  21. package/dist/preview/util.d.ts +1 -0
  22. package/dist/util/view_mode.d.ts +54 -0
  23. package/dist/{src-B8WuGSPZ.js → util-XFwQ9FEc.js} +17388 -21329
  24. package/dist/util-XFwQ9FEc.js.map +1 -0
  25. package/package.json +8 -8
  26. package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +104 -9
  27. package/src/collection_editor/ui/collection_editor/properties/CommonPropertyFields.tsx +1 -1
  28. package/src/collection_editor/ui/collection_editor/properties/EnumPropertyField.tsx +1 -1
  29. package/src/components/CollectionTableBinding/CollectionRowActions.tsx +1 -1
  30. package/src/components/CollectionTableBinding/fields/TableReferenceField.tsx +1 -1
  31. package/src/components/CollectionViewBinding/CollectionBoardViewBinding.tsx +1 -1
  32. package/src/components/CollectionViewBinding/CollectionViewActions.tsx +1 -1
  33. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +76 -126
  34. package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +4 -8
  35. package/src/components/CollectionViewBinding/SplitListView.tsx +114 -63
  36. package/src/components/DetailViewBinding.tsx +4 -2
  37. package/src/components/EditFormActions.tsx +2 -2
  38. package/src/components/EditViewBinding.tsx +3 -2
  39. package/src/components/EntityPreviewBinding.tsx +2 -1
  40. package/src/components/HomePage/ContentHomePage.tsx +2 -1
  41. package/src/components/HomePage/FavouritesView.tsx +1 -1
  42. package/src/components/RebaseRouteDefs.tsx +1 -1
  43. package/src/components/ReferenceTable/SelectionTableBinding.tsx +2 -1
  44. package/src/components/ReferenceWidget.tsx +1 -1
  45. package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +1 -1
  46. package/src/components/SidePanelBinding.tsx +46 -8
  47. package/src/components/SidePanelProvider.tsx +14 -11
  48. package/src/components/field_configs.tsx +41 -24
  49. package/src/components/history/EntityHistoryView.tsx +1 -1
  50. package/src/contexts/BreacrumbsContext.tsx +1 -1
  51. package/src/data_import/components/ImportNewPropertyFieldPreview.tsx +2 -4
  52. package/src/data_import/import/ImportCollectionAction.tsx +6 -6
  53. package/src/editor/selectors/color-selector.tsx +1 -1
  54. package/src/editor/selectors/node-selector.tsx +1 -1
  55. package/src/editor/selectors/text-buttons.tsx +1 -1
  56. package/src/form/EntityForm.tsx +4 -1
  57. package/src/form/EntityFormBinding.tsx +3 -1
  58. package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +2 -1
  59. package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +3 -2
  60. package/src/form/field_bindings/BlockFieldBinding.tsx +2 -1
  61. package/src/form/field_bindings/DateTimeFieldBinding.tsx +2 -1
  62. package/src/form/field_bindings/KeyValueFieldBinding.tsx +2 -1
  63. package/src/form/field_bindings/MapFieldBinding.tsx +2 -1
  64. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +2 -1
  65. package/src/form/field_bindings/MultiSelectFieldBinding.tsx +2 -1
  66. package/src/form/field_bindings/MultipleRelationFieldBinding.tsx +2 -1
  67. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +2 -1
  68. package/src/form/field_bindings/ReferenceFieldBinding.tsx +3 -2
  69. package/src/form/field_bindings/RelationFieldBinding.tsx +2 -1
  70. package/src/form/field_bindings/RepeatFieldBinding.tsx +2 -1
  71. package/src/form/field_bindings/SelectFieldBinding.tsx +2 -1
  72. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +2 -1
  73. package/src/form/field_bindings/SwitchFieldBinding.tsx +2 -1
  74. package/src/form/field_bindings/TextFieldBinding.tsx +2 -1
  75. package/src/form/field_bindings/UserSelectFieldBinding.tsx +2 -1
  76. package/src/form/field_bindings/VectorFieldBinding.tsx +2 -1
  77. package/src/hooks/useBuildSidePanel.tsx +8 -0
  78. package/src/hooks/useSelectionDialog.tsx +3 -3
  79. package/src/preview/components/ImagePreview.tsx +1 -1
  80. package/src/preview/components/ReferencePreview.tsx +1 -1
  81. package/src/preview/components/RelationPreview.tsx +2 -2
  82. package/src/preview/components/UrlComponentPreview.tsx +2 -9
  83. package/src/preview/property_previews/ArrayOfStringsPreview.tsx +1 -1
  84. package/src/preview/property_previews/NumberPropertyPreview.tsx +1 -1
  85. package/src/preview/util.ts +9 -0
  86. package/src/routes/CustomViewRoute.tsx +2 -1
  87. package/src/routes/RebaseRoute.tsx +20 -25
  88. package/src/util/navigation_utils.ts +2 -5
  89. package/src/util/view_mode.ts +102 -0
  90. package/dist/PropertyEditView-DfJbZ67t.js.map +0 -1
  91. package/dist/history-GSa4BgDt.js.map +0 -1
  92. package/dist/src-B8WuGSPZ.js.map +0 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/admin",
3
3
  "type": "module",
4
- "version": "0.9.1-canary.ff338b5",
4
+ "version": "0.10.0",
5
5
  "description": "Rebase CMS — content management views, forms, and routing",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -83,13 +83,13 @@
83
83
  "react-dropzone": "^15.0.0",
84
84
  "react-use-measure": "^2.1.7",
85
85
  "zod": "^4.4.3",
86
- "@rebasepro/app": "0.9.1-canary.ff338b5",
87
- "@rebasepro/common": "0.9.1-canary.ff338b5",
88
- "@rebasepro/forms": "0.9.1-canary.ff338b5",
89
- "@rebasepro/types": "0.9.1-canary.ff338b5",
90
- "@rebasepro/ui": "0.9.1-canary.ff338b5",
91
- "@rebasepro/utils": "0.9.1-canary.ff338b5",
92
- "@rebasepro/inference": "0.9.1-canary.ff338b5"
86
+ "@rebasepro/common": "0.10.0",
87
+ "@rebasepro/app": "0.10.0",
88
+ "@rebasepro/forms": "0.10.0",
89
+ "@rebasepro/inference": "0.10.0",
90
+ "@rebasepro/types": "0.10.0",
91
+ "@rebasepro/ui": "0.10.0",
92
+ "@rebasepro/utils": "0.10.0"
93
93
  },
94
94
  "peerDependencies": {
95
95
  "react": ">=19.0.0",
@@ -132,6 +132,41 @@ export function CollectionRLSTab() {
132
132
  fetchLivePolicies();
133
133
  }, [databaseAdmin, values.id, values.table, values.alias]);
134
134
 
135
+ /**
136
+ * Application roles available to `SecurityRule.roles`.
137
+ *
138
+ * Deliberately NOT `fetchAvailableRoles` — that returns native pg_roles
139
+ * (postgres, rebase_user, …), which can never satisfy an application-role
140
+ * condition.
141
+ */
142
+ const [availableRoles, setAvailableRoles] = useState<string[]>([]);
143
+ const [rolesUnavailable, setRolesUnavailable] = useState(false);
144
+
145
+ useEffect(() => {
146
+ let mounted = true;
147
+ const fetchRoles = async () => {
148
+ if (!databaseAdmin?.fetchApplicationRoles) {
149
+ // Older backend, or a driver with no application-role concept.
150
+ if (mounted) setRolesUnavailable(true);
151
+ return;
152
+ }
153
+ try {
154
+ const fetched = await databaseAdmin.fetchApplicationRoles();
155
+ if (mounted) {
156
+ setAvailableRoles(fetched);
157
+ setRolesUnavailable(false);
158
+ }
159
+ } catch (e) {
160
+ console.error("Failed to fetch application roles", e);
161
+ if (mounted) setRolesUnavailable(true);
162
+ }
163
+ };
164
+ fetchRoles();
165
+ return () => {
166
+ mounted = false;
167
+ };
168
+ }, [databaseAdmin]);
169
+
135
170
  const tableName = values.id || values.table || values.alias;
136
171
 
137
172
  /**
@@ -219,7 +254,7 @@ export function CollectionRLSTab() {
219
254
  tablename: values.id || values.table || values.alias || "your_table",
220
255
  permissive: (rule.mode || "permissive").toUpperCase() as PostgresPolicy["permissive"],
221
256
  cmd: (rule.operation || "ALL").toUpperCase() as PostgresPolicy["cmd"],
222
- roles: rule.roles || ["public"],
257
+ roles: rule.roles || [],
223
258
  qual: rule.using || null,
224
259
  with_check: rule.withCheck || null
225
260
  })}>
@@ -293,6 +328,8 @@ export function CollectionRLSTab() {
293
328
  <InlinePolicyEditor
294
329
  policy={editingPolicy === "new" ? undefined : editingPolicy}
295
330
  table={values.id || values.table || values.alias || "your_table"}
331
+ availableRoles={availableRoles}
332
+ rolesUnavailable={rolesUnavailable}
296
333
  onSave={handleSave}
297
334
  onCancel={() => setEditingPolicy(null)}
298
335
  />
@@ -307,30 +344,57 @@ export function CollectionRLSTab() {
307
344
 
308
345
  type PolicyCommand = "ALL" | "SELECT" | "INSERT" | "UPDATE" | "DELETE";
309
346
  const COMMAND_OPTIONS: PolicyCommand[] = ["ALL", "SELECT", "INSERT", "UPDATE", "DELETE"];
310
- const ROLE_OPTIONS = ["public", "authenticated", "anon", "admin"];
311
347
 
312
348
  function InlinePolicyEditor({
313
349
  policy,
314
350
  table,
351
+ availableRoles,
352
+ rolesUnavailable,
315
353
  onSave,
316
354
  onCancel
317
355
  }: {
318
356
  policy?: PostgresPolicy;
319
357
  table: string;
358
+ availableRoles: string[];
359
+ rolesUnavailable: boolean;
320
360
  onSave: (policyData: Partial<PostgresPolicy>) => void;
321
361
  onCancel: () => void;
322
362
  }) {
323
363
  const [name, setName] = useState(policy?.policyname || "");
324
364
  const [behavior, setBehavior] = useState<"PERMISSIVE" | "RESTRICTIVE">(policy?.permissive || "PERMISSIVE");
325
365
  const [command, setCommand] = useState<PolicyCommand>((policy?.cmd as PolicyCommand) || "ALL");
366
+ // No roles means "not restricted by role". Seeding a value here would
367
+ // silently narrow every new policy to it.
326
368
  const [roles, setRoles] = useState<string[]>(
327
- policy?.roles ? (Array.isArray(policy.roles) ? policy.roles : [policy.roles]) : ["public"]
369
+ policy?.roles ? (Array.isArray(policy.roles) ? policy.roles : [policy.roles]) : []
328
370
  );
371
+ const [customRole, setCustomRole] = useState("");
329
372
  const [usingExpr, setUsingExpr] = useState(policy?.qual || "");
330
373
  const [checkExpr, setCheckExpr] = useState(policy?.with_check || "");
331
374
 
332
375
  const showCheck = command === "ALL" || command === "INSERT" || command === "UPDATE";
333
376
 
377
+ /**
378
+ * Roles offered in the dropdown: those in use in the project, plus any the
379
+ * rule already carries. The union matters — application roles are derived
380
+ * from what users hold, so a role that is referenced here but assigned to
381
+ * nobody would otherwise vanish from its own policy on the next save.
382
+ */
383
+ const roleOptions = useMemo(
384
+ () => Array.from(new Set([...availableRoles, ...roles])).sort(),
385
+ [availableRoles, roles]
386
+ );
387
+
388
+ const addCustomRole = () => {
389
+ const trimmed = customRole.trim();
390
+ if (!trimmed || roles.includes(trimmed)) {
391
+ setCustomRole("");
392
+ return;
393
+ }
394
+ setRoles([...roles, trimmed]);
395
+ setCustomRole("");
396
+ };
397
+
334
398
  return (
335
399
  <>
336
400
  <DialogTitle variant="h6">
@@ -365,21 +429,52 @@ function InlinePolicyEditor({
365
429
  </div>
366
430
  </div>
367
431
  <div className="flex flex-col gap-1.5">
368
- <Typography variant="caption" className="uppercase tracking-wider text-text-secondary">Target Roles</Typography>
369
- <MultiSelect size="small" value={roles} onValueChange={setRoles} placeholder="Select roles">
370
- {ROLE_OPTIONS.map(r => <MultiSelectItem key={r} value={r}>{r}</MultiSelectItem>)}
371
- </MultiSelect>
432
+ <Typography variant="caption" className="uppercase tracking-wider text-text-secondary">Application Roles</Typography>
433
+ <Typography variant="caption" className="text-text-secondary opacity-80 -mt-1">
434
+ Roles held by users of this project, matched via <span className="font-mono">auth.roles()</span>.
435
+ These are not PostgreSQL roles — leave empty to apply the policy to everyone.
436
+ </Typography>
437
+ {roleOptions.length > 0 && (
438
+ <MultiSelect size="small" value={roles} onValueChange={setRoles} placeholder="Select roles">
439
+ {roleOptions.map(r => <MultiSelectItem key={r} value={r}>{r}</MultiSelectItem>)}
440
+ </MultiSelect>
441
+ )}
442
+ {rolesUnavailable && roleOptions.length === 0 && (
443
+ <Typography variant="caption" className="text-text-secondary opacity-80">
444
+ Could not load the project&apos;s roles — enter them manually below.
445
+ </Typography>
446
+ )}
447
+ {!rolesUnavailable && roleOptions.length === 0 && (
448
+ <Typography variant="caption" className="text-text-secondary opacity-80">
449
+ No roles are assigned to any user yet — enter one manually below.
450
+ </Typography>
451
+ )}
452
+ <div className="flex gap-2 items-center">
453
+ <TextField size="small" value={customRole}
454
+ onChange={(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setCustomRole(e.target.value)}
455
+ onKeyDown={(e: React.KeyboardEvent) => {
456
+ if (e.key === "Enter") {
457
+ e.preventDefault();
458
+ addCustomRole();
459
+ }
460
+ }}
461
+ placeholder="Add a role not listed, e.g. editor"/>
462
+ <Button size="small" variant="outlined" color="neutral"
463
+ disabled={!customRole.trim()} onClick={addCustomRole}>
464
+ Add
465
+ </Button>
466
+ </div>
372
467
  </div>
373
468
  {command !== "INSERT" && (
374
469
  <div className="flex flex-col gap-1.5">
375
470
  <Typography variant="caption" className="uppercase tracking-wider text-text-secondary">USING expression</Typography>
376
- <TextField value={usingExpr} onChange={(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setUsingExpr(e.target.value)} placeholder="e.g. auth.uid() = user_id"/>
471
+ <TextField value={usingExpr} onChange={(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setUsingExpr(e.target.value)} placeholder="e.g. auth.uid() = uid"/>
377
472
  </div>
378
473
  )}
379
474
  {showCheck && (
380
475
  <div className="flex flex-col gap-1.5">
381
476
  <Typography variant="caption" className="uppercase tracking-wider text-text-secondary">WITH CHECK expression</Typography>
382
- <TextField value={checkExpr} onChange={(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setCheckExpr(e.target.value)} placeholder="e.g. auth.uid() = user_id"/>
477
+ <TextField value={checkExpr} onChange={(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setCheckExpr(e.target.value)} placeholder="e.g. auth.uid() = uid"/>
383
478
  </div>
384
479
  )}
385
480
  </Paper>
@@ -1,7 +1,7 @@
1
1
  import { FieldCaption } from "../../../_cms_internals";
2
2
  import { Field, getIn, useFormex } from "@rebasepro/forms";
3
3
  import { DebouncedTextField } from "@rebasepro/ui";
4
- import { PropertyWithId } from "../PropertyEditView";
4
+ import type { PropertyWithId } from "../PropertyEditView";
5
5
  import React from "react";
6
6
  import { useTranslation } from "@rebasepro/app";
7
7
  ;
@@ -9,7 +9,7 @@ import { EnumForm } from "../EnumForm";
9
9
  import { StringPropertyValidation } from "./validation/StringPropertyValidation";
10
10
  import { ArrayPropertyValidation } from "./validation/ArrayPropertyValidation";
11
11
  import { ValidationPanel } from "./validation/ValidationPanel";
12
- import { PropertyWithId } from "../PropertyEditView";
12
+ import type { PropertyWithId } from "../PropertyEditView";
13
13
 
14
14
  export function EnumPropertyField({
15
15
  multiselect,
@@ -17,7 +17,7 @@ import {
17
17
  import { useTranslation } from "@rebasepro/app";
18
18
  import { getEntityFromCache } from "@rebasepro/app";
19
19
  import { getLocalChangesBackup } from "@rebasepro/common";
20
- import { useCMSContext } from "../../index";
20
+ import { useCMSContext } from "../../hooks/useCMSContext";
21
21
 
22
22
  /**
23
23
  *
@@ -11,7 +11,7 @@ import { useComponentOverride, ErrorView, CollectionScopeProvider } from "@rebas
11
11
  import { cls, PencilIcon } from "@rebasepro/ui";
12
12
  import { EntityPreviewContainer } from "../../EntityPreviewBinding";
13
13
  import { getReferenceFrom } from "@rebasepro/common";
14
- import { useCollectionRegistryController } from "../../../index";
14
+ import { useCollectionRegistryController } from "../../../hooks/navigation/contexts/CollectionRegistryContext";
15
15
 
16
16
  type TableReferenceFieldProps = {
17
17
  name: string;
@@ -38,7 +38,7 @@ import { useAnalyticsController } from "@rebasepro/app";
38
38
  import { setIn } from "@rebasepro/forms";
39
39
  import { useBoardDataController } from "./useBoardDataController";
40
40
  import { useKanbanDragAndDrop } from "./hooks/useKanbanDragAndDrop";
41
- import { useSidePanel } from "../../index";
41
+ import { useSidePanel } from "../../hooks/useSidePanel";
42
42
  import { generateNKeysBetween } from "fractional-indexing";
43
43
 
44
44
  export type CollectionBoardViewBindingProps<M extends Record<string, unknown> = Record<string, unknown>> = {
@@ -13,7 +13,7 @@ const ImportCollectionAction = lazy(() => import("../../data_import/import").the
13
13
  const ExportCollectionAction = lazy(() => import("../../data_export/export").then(m => ({ default: m.ExportCollectionAction })));
14
14
  import { EditorCollectionAction } from "../../collection_editor/ui/EditorCollectionAction";
15
15
  import { useCollectionEditorController } from "../../collection_editor/useCollectionEditorController";
16
- import { useCMSContext } from "../../index";
16
+ import { useCMSContext } from "../../hooks/useCMSContext";
17
17
 
18
18
  export type CollectionViewActionsProps<M extends Record<string, unknown>> = {
19
19
  collection: CollectionConfig<M>;
@@ -69,28 +69,15 @@ import { DeleteEntityDialog } from "../DeleteEntityDialog";
69
69
  import { useSelectionController } from "./useSelectionController";
70
70
  import { CollectionViewStartActions } from "./CollectionViewStartActions";
71
71
  import { addRecentId, getRecentIds } from "./utils";
72
+ import { isViewMode, OpenEntityMode, resolveOpenEntityMode, VIEW_MODE_PARAM } from "../../util/view_mode";
72
73
  import { mergeDeep } from "@rebasepro/utils";
73
- import {
74
- useBreadcrumbsController,
75
- useCMSContext,
76
- useCollectionRegistryController,
77
- useSidePanel,
78
- useUrlController
79
- } from "../../index";
74
+ import { useBreadcrumbsController } from "../../hooks/useBreadcrumbsController";
75
+ import { useCMSContext } from "../../hooks/useCMSContext";
76
+ import { useCollectionRegistryController } from "../../hooks/navigation/contexts/CollectionRegistryContext";
77
+ import { useSidePanel } from "../../hooks/useSidePanel";
78
+ import { useUrlController } from "../../hooks/navigation/contexts/UrlContext";
80
79
 
81
80
  const EMPTY_ARRAY: never[] = [];
82
- const DEFAULT_ENTITY_OPEN_MODE = "split";
83
-
84
- function getOpenEntityMode(
85
- viewMode: ViewMode,
86
- configuredMode?: "side_panel" | "full_screen" | "split" | "dialog"
87
- ): "side_panel" | "full_screen" | "split" | "dialog" {
88
- if (configuredMode) return configuredMode;
89
- if (viewMode === "kanban") return "side_panel";
90
- if (viewMode === "table" || viewMode === "cards") return "full_screen";
91
- // "list" view defaults to split
92
- return "split";
93
- }
94
81
 
95
82
  /**
96
83
  * @group Components
@@ -249,11 +236,8 @@ const CollectionViewBindingInner = React.memo(
249
236
 
250
237
  // Read view from React Router's searchParams (reactive on back/forward)
251
238
  const urlView = useMemo((): ViewMode | null => {
252
- const v = searchParams.get("__view");
253
- if (v && ["list", "table", "kanban", "cards"].includes(v)) {
254
- return v as ViewMode;
255
- }
256
- return null;
239
+ const v = searchParams.get(VIEW_MODE_PARAM);
240
+ return isViewMode(v) ? v : null;
257
241
  }, [searchParams]);
258
242
 
259
243
  // Get saved view from local persistence
@@ -270,7 +254,10 @@ const CollectionViewBindingInner = React.memo(
270
254
  return defaultViewMode;
271
255
  });
272
256
 
273
- const openEntityMode = getOpenEntityMode(viewMode, collection?.openEntityMode);
257
+ const openEntityMode = resolveOpenEntityMode({
258
+ collection,
259
+ viewMode
260
+ });
274
261
 
275
262
  // Sync URL with current view on init (if view came from saved config)
276
263
  useEffect(() => {
@@ -278,7 +265,7 @@ const CollectionViewBindingInner = React.memo(
278
265
  // View came from saved config but URL doesn't have it - update URL without push
279
266
  setSearchParams((prev) => {
280
267
  const next = new URLSearchParams(prev);
281
- next.set("__view", viewMode);
268
+ next.set(VIEW_MODE_PARAM, viewMode);
282
269
  return next;
283
270
  }, { replace: true });
284
271
  }
@@ -292,9 +279,9 @@ const CollectionViewBindingInner = React.memo(
292
279
  setSearchParams((prev) => {
293
280
  const next = new URLSearchParams(prev);
294
281
  if (newMode === defaultViewMode) {
295
- next.delete("__view");
282
+ next.delete(VIEW_MODE_PARAM);
296
283
  } else {
297
- next.set("__view", newMode);
284
+ next.set(VIEW_MODE_PARAM, newMode);
298
285
  }
299
286
  return next;
300
287
  });
@@ -974,6 +961,7 @@ parentEntityIds,
974
961
  highlightedEntities={highlightedEntity ? [highlightedEntity] : []}
975
962
  size={listSize}
976
963
  emptyComponent={emptyComponent}
964
+ selectedEntityId={selectedEntityIdProp}
977
965
  getActionsForEntity={getActionsForEntity}
978
966
  path={path}
979
967
  openEntityMode={openEntityMode}
@@ -1008,12 +996,58 @@ parentEntityIds,
1008
996
  additionalIDHeaderWidget={<EntityIdHeaderWidget
1009
997
  path={path}
1010
998
  idPath={path}
1011
- collection={collection}/>}
999
+ collection={collection}
1000
+ openEntityMode={openEntityMode}/>}
1012
1001
  openEntityMode={openEntityMode}
1013
1002
  onColumnsOrderChange={onColumnsOrderChange}
1014
1003
  />
1015
1004
  );
1016
1005
 
1006
+ const detailOpen = selectedEntityIdProp !== undefined;
1007
+
1008
+ // List view gets a centered, titled reading surface. Opening a detail
1009
+ // panel collapses the title and drops the centering so the list can
1010
+ // shrink into the master column.
1011
+ const collectionSurface = viewMode === "list"
1012
+ ? (
1013
+ <div
1014
+ className={cls(
1015
+ "flex flex-col w-full",
1016
+ detailOpen ? "" : "max-w-6xl mx-auto px-3 md:px-4 lg:px-6 py-4"
1017
+ )}
1018
+ >
1019
+ {/* Collapsible title — grid-rows for smooth height, transform for GPU */}
1020
+ <div
1021
+ className={cls(
1022
+ "grid transition-[grid-template-rows,transform,margin] duration-150 ease-out",
1023
+ detailOpen
1024
+ ? "grid-rows-[0fr] -translate-y-2 mt-0 mb-0"
1025
+ : "grid-rows-[1fr] translate-y-0 mt-12 mb-6"
1026
+ )}
1027
+ >
1028
+ <div className="overflow-hidden flex items-center gap-4">
1029
+ <Typography gutterBottom variant="h4" className="grow mb-0" component="h4">
1030
+ {collection.name}
1031
+ </Typography>
1032
+ </div>
1033
+ </div>
1034
+ {pluginInsights.length > 0 && (
1035
+ <div
1036
+ className={cls(
1037
+ "grid transition-[grid-template-rows] duration-150 ease-out",
1038
+ detailOpen ? "grid-rows-[0fr]" : "grid-rows-[1fr]"
1039
+ )}
1040
+ >
1041
+ <div className="overflow-hidden flex-shrink-0">
1042
+ {pluginInsights}
1043
+ </div>
1044
+ </div>
1045
+ )}
1046
+ {innerView}
1047
+ </div>
1048
+ )
1049
+ : innerView;
1050
+
1017
1051
  const mainContent = (
1018
1052
  <div className={cls("overflow-hidden h-full w-full rounded-md flex flex-col dark:bg-surface-800", className)}
1019
1053
  ref={containerRef}>
@@ -1054,66 +1088,10 @@ parentEntityIds,
1054
1088
  selectedTab={selectedTabProp}
1055
1089
  toolbar={toolbarNode}
1056
1090
  >
1057
- {/* When detail panel is open, left panel is always the list
1058
- view regardless of which view mode triggered the entity
1059
- click. When no detail, show the active view mode. */}
1060
- {(selectedEntityIdProp !== undefined || viewMode === "list") ? (
1061
- <div
1062
- className={cls(
1063
- "flex flex-col w-full",
1064
- selectedEntityIdProp === undefined
1065
- ? "max-w-6xl mx-auto px-3 md:px-4 lg:px-6 py-4"
1066
- : ""
1067
- )}
1068
- >
1069
- {/* Collapsible title — grid-rows for smooth height, transform for GPU */}
1070
- <div
1071
- className={cls(
1072
- "grid transition-[grid-template-rows,transform,margin] duration-150 ease-out",
1073
- selectedEntityIdProp === undefined
1074
- ? "grid-rows-[1fr] translate-y-0 mt-12 mb-6"
1075
- : "grid-rows-[0fr] -translate-y-2 mt-0 mb-0"
1076
- )}
1077
- >
1078
- <div className="overflow-hidden flex items-center gap-4">
1079
- <Typography gutterBottom variant="h4" className="grow mb-0" component="h4">
1080
- {collection.name}
1081
- </Typography>
1082
- </div>
1083
- </div>
1084
- {pluginInsights.length > 0 && (
1085
- <div
1086
- className={cls(
1087
- "grid transition-[grid-template-rows] duration-150 ease-out",
1088
- selectedEntityIdProp === undefined
1089
- ? "grid-rows-[1fr]"
1090
- : "grid-rows-[0fr]"
1091
- )}
1092
- >
1093
- <div className="overflow-hidden flex-shrink-0">
1094
- {pluginInsights}
1095
- </div>
1096
- </div>
1097
- )}
1098
- <CollectionListViewBinding
1099
- key={`list-view-${path}`}
1100
- collection={collection}
1101
- tableController={tableController}
1102
- onEntityClick={onEntityClick}
1103
- selectionController={usedSelectionController}
1104
- selectionEnabled={selectionEnabled}
1105
- highlightedEntities={highlightedEntity ? [highlightedEntity] : []}
1106
- size={listSize}
1107
- emptyComponent={emptyComponent}
1108
- selectedEntityId={selectedEntityIdProp}
1109
- getActionsForEntity={getActionsForEntity}
1110
- path={path}
1111
- openEntityMode={openEntityMode}
1112
- />
1113
- </div>
1114
- ) : (
1115
- innerView
1116
- )}
1091
+ {/* The left panel keeps rendering whichever view mode is
1092
+ active. Swapping it to a list on selection would make
1093
+ a board or card grid vanish on every click. */}
1094
+ {collectionSurface}
1117
1095
  </SplitListView>
1118
1096
  ) : (
1119
1097
  <div className="flex flex-col w-full h-full">
@@ -1122,39 +1100,7 @@ parentEntityIds,
1122
1100
  "flex-1 flex flex-col",
1123
1101
  (viewMode === "list" || viewMode === "cards") && "overflow-y-auto"
1124
1102
  )}>
1125
- {viewMode === "list" ? (
1126
- <div
1127
- className={cls(
1128
- "flex flex-col w-full",
1129
- selectedEntityIdProp === undefined
1130
- ? "max-w-6xl mx-auto px-3 md:px-4 lg:px-6 py-4"
1131
- : ""
1132
- )}
1133
- >
1134
- <div
1135
- className={cls(
1136
- "grid transition-[grid-template-rows,transform,margin] duration-150 ease-out",
1137
- selectedEntityIdProp === undefined
1138
- ? "grid-rows-[1fr] translate-y-0 mt-12 mb-6"
1139
- : "grid-rows-[0fr] -translate-y-2 mt-0 mb-0"
1140
- )}
1141
- >
1142
- <div className="overflow-hidden flex items-center gap-4">
1143
- <Typography gutterBottom variant="h4" className="grow mb-0" component="h4">
1144
- {collection.name}
1145
- </Typography>
1146
- </div>
1147
- </div>
1148
- {pluginInsights.length > 0 && (
1149
- <div className="flex-shrink-0">
1150
- {pluginInsights}
1151
- </div>
1152
- )}
1153
- {innerView}
1154
- </div>
1155
- ) : (
1156
- innerView
1157
- )}
1103
+ {collectionSurface}
1158
1104
  </div>
1159
1105
  </div>
1160
1106
  )}
@@ -1358,11 +1304,17 @@ function buildPropertyWidthOverwrite(key: string, width: number): PartialCollect
1358
1304
  function EntityIdHeaderWidget({
1359
1305
  collection,
1360
1306
  path,
1361
- idPath
1307
+ idPath,
1308
+ openEntityMode
1362
1309
  }: {
1363
1310
  collection: CollectionConfig,
1364
1311
  path: string,
1365
- idPath: string
1312
+ idPath: string,
1313
+ /**
1314
+ * Resolved by the parent so that finding an entity by id lands it in the
1315
+ * same surface a row click would — not a different one.
1316
+ */
1317
+ openEntityMode: OpenEntityMode
1366
1318
  }) {
1367
1319
 
1368
1320
  const { t } = useTranslation();
@@ -1372,8 +1324,6 @@ function EntityIdHeaderWidget({
1372
1324
  const [recentIds, setRecentIds] = React.useState<string[]>(getRecentIds(collection.slug).map(String));
1373
1325
  const sidePanelController = useSidePanel();
1374
1326
 
1375
- const openEntityMode = collection?.openEntityMode ?? DEFAULT_ENTITY_OPEN_MODE;
1376
-
1377
1327
  return (
1378
1328
  <Tooltip title={!openPopup ? t("find_by_id") : undefined} asChild={false}>
1379
1329
  <Popover
@@ -10,7 +10,9 @@ import { FiltersDialog } from "./FiltersDialog";
10
10
  import { FilterPresetsButton } from "./FilterPresetsButton";
11
11
  import { toArray } from "@rebasepro/utils";
12
12
  import { useNavigate } from "react-router-dom";
13
- import { useUrlController, useCMSContext } from "../../index";
13
+ import { useUrlController } from "../../hooks/navigation/contexts/UrlContext";
14
+ import { useCMSContext } from "../../hooks/useCMSContext";
15
+ import { withViewMode } from "../../util/view_mode";
14
16
 
15
17
  export type CollectionViewStartActionsProps<M extends Record<string, unknown>> = {
16
18
  collection: CollectionConfig<M>;
@@ -72,13 +74,7 @@ parentEntityIds,
72
74
  };
73
75
 
74
76
  const handleBackClick = useCallback(() => {
75
- let collectionUrl = urlController.buildUrlCollectionPath(path);
76
- // Preserve the __view query param so the view mode is retained
77
- const currentViewParam = new URLSearchParams(window.location.search).get("__view");
78
- if (currentViewParam) {
79
- collectionUrl += `${collectionUrl.includes("?") ? "&" : "?"}__view=${currentViewParam}`;
80
- }
81
- navigate(collectionUrl);
77
+ navigate(withViewMode(urlController.buildUrlCollectionPath(path)));
82
78
  }, [navigate, urlController, path]);
83
79
 
84
80
  const backButton = compact && (