@rebasepro/admin 0.9.1-canary.fd3754b → 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 (96) hide show
  1. package/dist/{CollectionEditorDialog-DKGlYDPc.js → CollectionEditorDialog-BOlKGGg7.js} +149 -35
  2. package/dist/{CollectionEditorDialog-DKGlYDPc.js.map → CollectionEditorDialog-BOlKGGg7.js.map} +1 -1
  3. package/dist/{PropertyEditView-h0N-AMCY.js → PropertyEditView-i51tFOfM.js} +3 -3
  4. package/dist/PropertyEditView-i51tFOfM.js.map +1 -0
  5. package/dist/{RouterCollectionsStudioView-DdEStpFk.js → RouterCollectionsStudioView-Dh4W53PU.js} +4 -4
  6. package/dist/{RouterCollectionsStudioView-DdEStpFk.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-C0FdqGNz.js → history-CtIPJxvQ.js} +5 -5
  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-DLAFyeZI.js → util-XFwQ9FEc.js} +17584 -21521
  24. package/dist/util-XFwQ9FEc.js.map +1 -0
  25. package/package.json +12 -17
  26. package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +139 -13
  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/FiltersDialog.tsx +18 -0
  36. package/src/components/CollectionViewBinding/SplitListView.tsx +114 -63
  37. package/src/components/CollectionViewBinding/useBoardDataController.tsx +9 -3
  38. package/src/components/DetailViewBinding.tsx +4 -2
  39. package/src/components/EditFormActions.tsx +2 -2
  40. package/src/components/EditViewBinding.tsx +3 -2
  41. package/src/components/EntityPreviewBinding.tsx +2 -1
  42. package/src/components/HomePage/ContentHomePage.tsx +2 -1
  43. package/src/components/HomePage/FavouritesView.tsx +1 -1
  44. package/src/components/RebaseNavigation.tsx +16 -1
  45. package/src/components/RebaseRouteDefs.tsx +1 -1
  46. package/src/components/ReferenceTable/SelectionTableBinding.tsx +2 -1
  47. package/src/components/ReferenceWidget.tsx +1 -1
  48. package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +1 -1
  49. package/src/components/SidePanelBinding.tsx +46 -8
  50. package/src/components/SidePanelProvider.tsx +14 -11
  51. package/src/components/field_configs.tsx +42 -25
  52. package/src/components/history/EntityHistoryView.tsx +3 -3
  53. package/src/contexts/BreacrumbsContext.tsx +1 -1
  54. package/src/data_import/components/ImportNewPropertyFieldPreview.tsx +2 -4
  55. package/src/data_import/import/ImportCollectionAction.tsx +6 -6
  56. package/src/editor/selectors/color-selector.tsx +1 -1
  57. package/src/editor/selectors/node-selector.tsx +1 -1
  58. package/src/editor/selectors/text-buttons.tsx +1 -1
  59. package/src/form/EntityForm.tsx +4 -1
  60. package/src/form/EntityFormBinding.tsx +3 -1
  61. package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +2 -1
  62. package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +3 -2
  63. package/src/form/field_bindings/BlockFieldBinding.tsx +2 -1
  64. package/src/form/field_bindings/DateTimeFieldBinding.tsx +2 -1
  65. package/src/form/field_bindings/KeyValueFieldBinding.tsx +2 -1
  66. package/src/form/field_bindings/MapFieldBinding.tsx +2 -1
  67. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +2 -1
  68. package/src/form/field_bindings/MultiSelectFieldBinding.tsx +2 -1
  69. package/src/form/field_bindings/MultipleRelationFieldBinding.tsx +2 -1
  70. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +2 -1
  71. package/src/form/field_bindings/ReferenceFieldBinding.tsx +3 -2
  72. package/src/form/field_bindings/RelationFieldBinding.tsx +2 -1
  73. package/src/form/field_bindings/RepeatFieldBinding.tsx +2 -1
  74. package/src/form/field_bindings/SelectFieldBinding.tsx +2 -1
  75. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +2 -1
  76. package/src/form/field_bindings/SwitchFieldBinding.tsx +2 -1
  77. package/src/form/field_bindings/TextFieldBinding.tsx +2 -1
  78. package/src/form/field_bindings/UserSelectFieldBinding.tsx +2 -1
  79. package/src/form/field_bindings/VectorFieldBinding.tsx +2 -1
  80. package/src/hooks/useBuildSidePanel.tsx +8 -0
  81. package/src/hooks/useResolvedNavigationFrom.tsx +4 -2
  82. package/src/hooks/useSelectionDialog.tsx +3 -3
  83. package/src/preview/components/ImagePreview.tsx +1 -1
  84. package/src/preview/components/ReferencePreview.tsx +1 -1
  85. package/src/preview/components/RelationPreview.tsx +2 -2
  86. package/src/preview/components/UrlComponentPreview.tsx +2 -9
  87. package/src/preview/property_previews/ArrayOfStringsPreview.tsx +1 -1
  88. package/src/preview/property_previews/NumberPropertyPreview.tsx +1 -1
  89. package/src/preview/util.ts +9 -0
  90. package/src/routes/CustomViewRoute.tsx +2 -1
  91. package/src/routes/RebaseRoute.tsx +20 -25
  92. package/src/util/navigation_utils.ts +2 -5
  93. package/src/util/view_mode.ts +102 -0
  94. package/dist/PropertyEditView-h0N-AMCY.js.map +0 -1
  95. package/dist/history-C0FdqGNz.js.map +0 -1
  96. package/dist/src-DLAFyeZI.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.fd3754b",
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"
@@ -18,7 +18,7 @@
18
18
  "types": "./dist/index.d.ts",
19
19
  "source": "src/index.ts",
20
20
  "engines": {
21
- "node": ">=14"
21
+ "node": ">=20"
22
22
  },
23
23
  "keywords": [
24
24
  "rebase",
@@ -38,7 +38,7 @@
38
38
  "import": "./dist/index.js"
39
39
  },
40
40
  "./editor": {
41
- "types": "./dist/editor.d.ts",
41
+ "types": "./dist/editor/index.d.ts",
42
42
  "development": "./dist/editor.js",
43
43
  "import": "./dist/editor.js"
44
44
  },
@@ -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/common": "0.9.1-canary.fd3754b",
87
- "@rebasepro/app": "0.9.1-canary.fd3754b",
88
- "@rebasepro/inference": "0.9.1-canary.fd3754b",
89
- "@rebasepro/ui": "0.9.1-canary.fd3754b",
90
- "@rebasepro/forms": "0.9.1-canary.fd3754b",
91
- "@rebasepro/types": "0.9.1-canary.fd3754b",
92
- "@rebasepro/utils": "0.9.1-canary.fd3754b"
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",
@@ -153,17 +153,12 @@
153
153
  ],
154
154
  "moduleNameMapper": {
155
155
  "\\.(css|less)$": "<rootDir>/test/__mocks__/styleMock.js",
156
- "^@rebasepro/ui$": "<rootDir>/../ui/src/index.ts",
157
- "^@rebasepro/app$": "<rootDir>/../app/src/index.ts",
158
- "^@rebasepro/common$": "<rootDir>/../common/src/index.ts",
159
- "^@rebasepro/types$": "<rootDir>/../types/src/index.ts",
160
- "^@rebasepro/forms$": "<rootDir>/../forms/src/index.ts",
161
- "^@rebasepro/utils$": "<rootDir>/../utils/src/index.ts"
156
+ "^@rebasepro/([a-z0-9-]+)$": "<rootDir>/../$1/src/index.ts"
162
157
  }
163
158
  },
164
159
  "scripts": {
165
160
  "watch": "vite build --watch",
166
- "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json",
161
+ "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json && node ../../scripts/assert-build-output.mjs",
167
162
  "test:lint": "eslint \"src/**\" --quiet",
168
163
  "test": "jest --passWithNoTests",
169
164
  "clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f",
@@ -1,6 +1,8 @@
1
1
 
2
- import { CollectionConfig } from "@rebasepro/types";
3
- import React, { useState, useEffect } from "react";
2
+ import { CollectionConfig, SecurityRule as GeneratedSecurityRule } from "@rebasepro/types";
3
+ import { getPolicyNamesForRule, getPolicyNamesForRules } from "@rebasepro/utils";
4
+ import { getEffectiveSecurityRules } from "@rebasepro/common";
5
+ import React, { useState, useEffect, useMemo } from "react";
4
6
 
5
7
  /**
6
8
  * Validates and double-quotes a SQL identifier to prevent injection.
@@ -130,7 +132,65 @@ export function CollectionRLSTab() {
130
132
  fetchLivePolicies();
131
133
  }, [databaseAdmin, values.id, values.table, values.alias]);
132
134
 
133
- const unmappedPolicies = dbPolicies.filter(dp => !rules.some(r => r.name === dp.policyname));
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
+
170
+ const tableName = values.id || values.table || values.alias;
171
+
172
+ /**
173
+ * Every policy name `rebase db push` would write for this collection.
174
+ *
175
+ * Two things make this more than `rules.map(r => r.name)`:
176
+ * - a rule without an explicit `name` becomes `<table>_<op>_<hash>`, so
177
+ * comparing `rule.name` to `policyname` never matches for it;
178
+ * - the generator also injects the safe-by-default baseline
179
+ * (`<table>_default_admin_*`), which is in no collection's `securityRules`.
180
+ *
181
+ * Missing either made Rebase's own policies look hand-written, and offered to
182
+ * import them back into the codebase that produced them.
183
+ */
184
+ const generatedPolicyNames = useMemo(() => {
185
+ if (!tableName) return new Set<string>();
186
+ const effectiveRules = getEffectiveSecurityRules(values as CollectionConfig);
187
+ return getPolicyNamesForRules(
188
+ [...(rules as unknown as GeneratedSecurityRule[]), ...effectiveRules],
189
+ tableName
190
+ );
191
+ }, [rules, tableName, values]);
192
+
193
+ const unmappedPolicies = dbPolicies.filter(dp => !generatedPolicyNames.has(dp.policyname));
134
194
 
135
195
  const handleSave = async (newPolicy: Partial<PostgresPolicy>) => {
136
196
  const rule: SecurityRule = {
@@ -175,7 +235,13 @@ export function CollectionRLSTab() {
175
235
  <div className="flex flex-col gap-1.5 min-w-0">
176
236
  <div className="flex items-center gap-2">
177
237
  <KeyIcon size={iconSize.smallest} className="text-text-disabled dark:text-text-disabled-dark shrink-0"/>
178
- <Typography variant="subtitle2" className="truncate">{rule.name}</Typography>
238
+ <Typography variant="subtitle2" className="truncate">
239
+ {/* Unnamed rules are still named in Postgres — show what they compile to
240
+ rather than an empty heading. */}
241
+ {rule.name || (tableName
242
+ ? getPolicyNamesForRule(rule as unknown as GeneratedSecurityRule, tableName).join(", ")
243
+ : "Unnamed policy")}
244
+ </Typography>
179
245
  </div>
180
246
  <div className="flex gap-2 text-xs pl-6 overflow-x-auto hide-scrollbar">
181
247
  <Chip size="small" className="bg-surface-100 dark:bg-surface-900 text-text-secondary border-none">Action: {rule.operation || "ALL"}</Chip>
@@ -188,7 +254,7 @@ export function CollectionRLSTab() {
188
254
  tablename: values.id || values.table || values.alias || "your_table",
189
255
  permissive: (rule.mode || "permissive").toUpperCase() as PostgresPolicy["permissive"],
190
256
  cmd: (rule.operation || "ALL").toUpperCase() as PostgresPolicy["cmd"],
191
- roles: rule.roles || ["public"],
257
+ roles: rule.roles || [],
192
258
  qual: rule.using || null,
193
259
  with_check: rule.withCheck || null
194
260
  })}>
@@ -262,6 +328,8 @@ export function CollectionRLSTab() {
262
328
  <InlinePolicyEditor
263
329
  policy={editingPolicy === "new" ? undefined : editingPolicy}
264
330
  table={values.id || values.table || values.alias || "your_table"}
331
+ availableRoles={availableRoles}
332
+ rolesUnavailable={rolesUnavailable}
265
333
  onSave={handleSave}
266
334
  onCancel={() => setEditingPolicy(null)}
267
335
  />
@@ -276,30 +344,57 @@ export function CollectionRLSTab() {
276
344
 
277
345
  type PolicyCommand = "ALL" | "SELECT" | "INSERT" | "UPDATE" | "DELETE";
278
346
  const COMMAND_OPTIONS: PolicyCommand[] = ["ALL", "SELECT", "INSERT", "UPDATE", "DELETE"];
279
- const ROLE_OPTIONS = ["public", "authenticated", "anon", "admin"];
280
347
 
281
348
  function InlinePolicyEditor({
282
349
  policy,
283
350
  table,
351
+ availableRoles,
352
+ rolesUnavailable,
284
353
  onSave,
285
354
  onCancel
286
355
  }: {
287
356
  policy?: PostgresPolicy;
288
357
  table: string;
358
+ availableRoles: string[];
359
+ rolesUnavailable: boolean;
289
360
  onSave: (policyData: Partial<PostgresPolicy>) => void;
290
361
  onCancel: () => void;
291
362
  }) {
292
363
  const [name, setName] = useState(policy?.policyname || "");
293
364
  const [behavior, setBehavior] = useState<"PERMISSIVE" | "RESTRICTIVE">(policy?.permissive || "PERMISSIVE");
294
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.
295
368
  const [roles, setRoles] = useState<string[]>(
296
- policy?.roles ? (Array.isArray(policy.roles) ? policy.roles : [policy.roles]) : ["public"]
369
+ policy?.roles ? (Array.isArray(policy.roles) ? policy.roles : [policy.roles]) : []
297
370
  );
371
+ const [customRole, setCustomRole] = useState("");
298
372
  const [usingExpr, setUsingExpr] = useState(policy?.qual || "");
299
373
  const [checkExpr, setCheckExpr] = useState(policy?.with_check || "");
300
374
 
301
375
  const showCheck = command === "ALL" || command === "INSERT" || command === "UPDATE";
302
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
+
303
398
  return (
304
399
  <>
305
400
  <DialogTitle variant="h6">
@@ -334,21 +429,52 @@ function InlinePolicyEditor({
334
429
  </div>
335
430
  </div>
336
431
  <div className="flex flex-col gap-1.5">
337
- <Typography variant="caption" className="uppercase tracking-wider text-text-secondary">Target Roles</Typography>
338
- <MultiSelect size="small" value={roles} onValueChange={setRoles} placeholder="Select roles">
339
- {ROLE_OPTIONS.map(r => <MultiSelectItem key={r} value={r}>{r}</MultiSelectItem>)}
340
- </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>
341
467
  </div>
342
468
  {command !== "INSERT" && (
343
469
  <div className="flex flex-col gap-1.5">
344
470
  <Typography variant="caption" className="uppercase tracking-wider text-text-secondary">USING expression</Typography>
345
- <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"/>
346
472
  </div>
347
473
  )}
348
474
  {showCheck && (
349
475
  <div className="flex flex-col gap-1.5">
350
476
  <Typography variant="caption" className="uppercase tracking-wider text-text-secondary">WITH CHECK expression</Typography>
351
- <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"/>
352
478
  </div>
353
479
  )}
354
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