@rebasepro/admin 0.12.0 → 0.12.1-canary.g009ed95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/README.md +1 -1
  2. package/dist/{CollectionEditorDialog-Dpc75wIZ.js → CollectionEditorDialog-B-iVE9y3.js} +75 -62
  3. package/dist/{CollectionEditorDialog-Dpc75wIZ.js.map → CollectionEditorDialog-B-iVE9y3.js.map} +1 -1
  4. package/dist/{PropertyEditView-DH3XZC2x.js → PropertyEditView-DoLEH_bQ.js} +36 -16
  5. package/dist/{PropertyEditView-DH3XZC2x.js.map → PropertyEditView-DoLEH_bQ.js.map} +1 -1
  6. package/dist/{RouterCollectionsStudioView-BZLq4MGp.js → RouterCollectionsStudioView-C3669Ezm.js} +5 -5
  7. package/dist/{RouterCollectionsStudioView-BZLq4MGp.js.map → RouterCollectionsStudioView-C3669Ezm.js.map} +1 -1
  8. package/dist/collection_editor/serializable_types.d.ts +4 -1
  9. package/dist/collection_editor/ui/collection_editor/CollectionRelationsTab.d.ts +1 -0
  10. package/dist/collection_editor/useLocalCollectionsConfigController.d.ts +8 -0
  11. package/dist/collection_editor_ui.js +5 -5
  12. package/dist/collection_editor_ui.js.map +1 -1
  13. package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +10 -0
  14. package/dist/components/CollectionViewBinding/SplitViewContext.d.ts +23 -0
  15. package/dist/components/CollectionViewBinding/hooks/useKanbanDragAndDrop.d.ts +26 -0
  16. package/dist/components/DetailViewBinding.d.ts +5 -0
  17. package/dist/components/EditFormActions.d.ts +12 -1
  18. package/dist/components/EditViewBinding.d.ts +6 -1
  19. package/dist/components/EntityIdentityBar.d.ts +53 -0
  20. package/dist/components/EntityInspector.d.ts +29 -0
  21. package/dist/components/EntityPreviewNesting.d.ts +25 -0
  22. package/dist/components/EntityViewBinding.d.ts +9 -0
  23. package/dist/components/InlineEntityPreview.d.ts +47 -0
  24. package/dist/components/index.d.ts +2 -0
  25. package/dist/editor.js +18 -10
  26. package/dist/editor.js.map +1 -1
  27. package/dist/{export-BzIpUdSK.js → export-B8DIn-u8.js} +2 -2
  28. package/dist/{export-BzIpUdSK.js.map → export-B8DIn-u8.js.map} +1 -1
  29. package/dist/form/EntityForm.d.ts +1 -1
  30. package/dist/form/EntityFormActions.d.ts +11 -1
  31. package/dist/form/PropertyFieldBinding.d.ts +1 -1
  32. package/dist/form/components/FieldBlock.d.ts +30 -0
  33. package/dist/form/components/FormRail.d.ts +18 -0
  34. package/dist/form/components/FormSections.d.ts +16 -0
  35. package/dist/form/components/index.d.ts +3 -2
  36. package/dist/form/components/useRailVisible.d.ts +24 -0
  37. package/dist/form/field_bindings/BinaryFieldBinding.d.ts +1 -1
  38. package/dist/form/field_bindings/DateTimeFieldBinding.d.ts +1 -1
  39. package/dist/form/field_bindings/GeopointFieldBinding.d.ts +1 -1
  40. package/dist/form/field_bindings/MarkdownEditorFieldBinding.d.ts +1 -1
  41. package/dist/form/field_bindings/MultiSelectFieldBinding.d.ts +1 -1
  42. package/dist/form/field_bindings/ReadOnlyFieldBinding.d.ts +1 -1
  43. package/dist/form/field_bindings/SelectFieldBinding.d.ts +1 -1
  44. package/dist/form/field_bindings/StorageUploadFieldBinding.d.ts +1 -1
  45. package/dist/form/field_bindings/SwitchFieldBinding.d.ts +1 -1
  46. package/dist/form/field_bindings/TextFieldBinding.d.ts +1 -1
  47. package/dist/form/field_bindings/UserSelectFieldBinding.d.ts +1 -1
  48. package/dist/form/field_bindings/VectorFieldBinding.d.ts +1 -1
  49. package/dist/{history-g9688IaK.js → history-ClwDEtqN.js} +32 -31
  50. package/dist/history-ClwDEtqN.js.map +1 -0
  51. package/dist/hooks/navigation/utils.d.ts +16 -0
  52. package/dist/hooks/useBuildSidePanel.d.ts +14 -0
  53. package/dist/hooks/useEntityDisplayTitle.d.ts +18 -0
  54. package/dist/hooks/useRecordActions.d.ts +17 -0
  55. package/dist/{import-BjUG-cIY.js → import-Cwzf4RW5.js} +2 -2
  56. package/dist/{import-BjUG-cIY.js.map → import-Cwzf4RW5.js.map} +1 -1
  57. package/dist/index.js +106 -35
  58. package/dist/index.js.map +1 -1
  59. package/dist/markdown-CklalUUk.js.map +1 -1
  60. package/dist/preview/components/InlineEntityListPreview.d.ts +15 -0
  61. package/dist/preview/index.d.ts +1 -0
  62. package/dist/preview/property_previews/ArrayOfReferencesPreview.d.ts +1 -1
  63. package/dist/preview/property_previews/ArrayOfRelationsPreview.d.ts +1 -1
  64. package/dist/types/components/EntityFormActionsProps.d.ts +0 -1
  65. package/dist/types/components/EntityFormProps.d.ts +0 -5
  66. package/dist/types/fields.d.ts +24 -0
  67. package/dist/{util-B5fJm1FA.js → util-DSh3HRzK.js} +1974 -1130
  68. package/dist/util-DSh3HRzK.js.map +1 -0
  69. package/package.json +32 -34
  70. package/src/collection_editor/ConfigControllerProvider.tsx +1 -1
  71. package/src/collection_editor/serializable_types.ts +4 -3
  72. package/src/collection_editor/serializable_utils.ts +51 -17
  73. package/src/collection_editor/ui/collection_editor/CollectionEditorDialog.tsx +65 -22
  74. package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +38 -30
  75. package/src/collection_editor/ui/collection_editor/CollectionRelationsTab.tsx +3 -1
  76. package/src/collection_editor/ui/collection_editor/RouterCollectionStudioView.tsx +1 -1
  77. package/src/collection_editor/ui/collection_editor/RouterCollectionsStudioView.tsx +1 -1
  78. package/src/collection_editor/ui/collection_editor/properties/RelationPropertyField.tsx +16 -0
  79. package/src/collection_editor/useLocalCollectionsConfigController.tsx +127 -15
  80. package/src/collection_editor/validateCollectionJson.ts +3 -3
  81. package/src/components/AdminModeSyncer.tsx +1 -1
  82. package/src/components/CollectionTableBinding/PropertyTableCell.tsx +10 -2
  83. package/src/components/CollectionTableBinding/column_utils.tsx +26 -2
  84. package/src/components/CollectionTableBinding/table_bindings.tsx +2 -1
  85. package/src/components/CollectionViewBinding/CollectionBoardViewBinding.tsx +23 -16
  86. package/src/components/CollectionViewBinding/CollectionViewActions.tsx +7 -1
  87. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +8 -2
  88. package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +22 -11
  89. package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +33 -0
  90. package/src/components/CollectionViewBinding/SplitListView.tsx +18 -3
  91. package/src/components/CollectionViewBinding/SplitViewContext.tsx +34 -0
  92. package/src/components/CollectionViewBinding/hooks/useKanbanDragAndDrop.ts +48 -9
  93. package/src/components/CollectionViewBinding/useBoardDataController.tsx +80 -25
  94. package/src/components/CollectionViewBinding/usePreviewSlots.ts +18 -6
  95. package/src/components/DefaultAppBar.tsx +1 -1
  96. package/src/components/DefaultDrawer.tsx +1 -1
  97. package/src/components/DetailViewBinding.tsx +100 -143
  98. package/src/components/DrawerNavigationItem.tsx +1 -1
  99. package/src/components/EditFormActions.tsx +54 -129
  100. package/src/components/EditViewBinding.tsx +182 -93
  101. package/src/components/EntityIdentityBar.tsx +288 -0
  102. package/src/components/EntityInspector.tsx +132 -0
  103. package/src/components/EntityPreviewBinding.tsx +6 -2
  104. package/src/components/EntityPreviewNesting.tsx +50 -0
  105. package/src/components/EntityViewBinding.tsx +35 -32
  106. package/src/components/HomePage/FavouritesView.tsx +1 -1
  107. package/src/components/HomePage/NavigationCardBinding.tsx +1 -1
  108. package/src/components/HomePage/SmallNavigationCard.tsx +1 -1
  109. package/src/components/InlineEntityPreview.tsx +149 -0
  110. package/src/components/RebaseLayout.tsx +1 -1
  111. package/src/components/RebaseNavigation.tsx +45 -5
  112. package/src/components/RebaseRouteDefs.tsx +1 -1
  113. package/src/components/SelectableTable/SelectableTable.tsx +2 -2
  114. package/src/components/SidePanelBinding.tsx +19 -13
  115. package/src/components/UserSelector.tsx +12 -6
  116. package/src/components/common/default_entity_actions.tsx +4 -3
  117. package/src/components/history/EntityHistoryEntry.tsx +28 -7
  118. package/src/components/history/EntityHistoryView.tsx +20 -20
  119. package/src/components/history/LastEditedByIndicator.tsx +4 -3
  120. package/src/components/index.ts +2 -0
  121. package/src/data_import/utils/get_import_inference_type.ts +6 -0
  122. package/src/form/EntityForm.tsx +267 -179
  123. package/src/form/EntityFormActions.tsx +50 -99
  124. package/src/form/EntityFormBinding.tsx +4 -2
  125. package/src/form/PropertyFieldBinding.tsx +4 -0
  126. package/src/form/components/FieldBlock.tsx +119 -0
  127. package/src/form/components/FormRail.tsx +125 -0
  128. package/src/form/components/FormSections.tsx +118 -0
  129. package/src/form/components/index.tsx +3 -2
  130. package/src/form/components/useRailVisible.tsx +46 -0
  131. package/src/form/field_bindings/BinaryFieldBinding.tsx +3 -2
  132. package/src/form/field_bindings/DateTimeFieldBinding.tsx +3 -2
  133. package/src/form/field_bindings/GeopointFieldBinding.tsx +3 -2
  134. package/src/form/field_bindings/MapFieldBinding.tsx +1 -2
  135. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +3 -2
  136. package/src/form/field_bindings/MultiSelectFieldBinding.tsx +2 -1
  137. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +2 -1
  138. package/src/form/field_bindings/ReferenceFieldBinding.tsx +2 -1
  139. package/src/form/field_bindings/RelationFieldBinding.tsx +6 -4
  140. package/src/form/field_bindings/RepeatFieldBinding.tsx +4 -1
  141. package/src/form/field_bindings/SelectFieldBinding.tsx +3 -2
  142. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +31 -8
  143. package/src/form/field_bindings/SwitchFieldBinding.tsx +7 -3
  144. package/src/form/field_bindings/TextFieldBinding.tsx +18 -16
  145. package/src/form/field_bindings/UserSelectFieldBinding.tsx +3 -2
  146. package/src/form/field_bindings/VectorFieldBinding.tsx +2 -1
  147. package/src/hooks/navigation/useBuildUrlController.tsx +1 -1
  148. package/src/hooks/navigation/useTopLevelNavigation.ts +18 -7
  149. package/src/hooks/navigation/utils.ts +20 -0
  150. package/src/hooks/useBuildSideDialogsController.tsx +2 -2
  151. package/src/hooks/useBuildSidePanel.tsx +22 -61
  152. package/src/hooks/useEntityDisplayTitle.tsx +60 -0
  153. package/src/hooks/useHistory.ts +6 -5
  154. package/src/hooks/useRecordActions.tsx +69 -0
  155. package/src/hooks/useResolvedUsers.ts +10 -5
  156. package/src/preview/PropertyPreview.tsx +48 -22
  157. package/src/preview/components/InlineEntityListPreview.tsx +42 -0
  158. package/src/preview/components/ReferencePreview.tsx +34 -14
  159. package/src/preview/components/RelationPreview.tsx +34 -14
  160. package/src/preview/index.ts +1 -0
  161. package/src/preview/property_previews/ArrayOfReferencesPreview.tsx +24 -1
  162. package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +25 -1
  163. package/src/routes/RebaseRoute.tsx +2 -2
  164. package/src/types/components/EntityFormActionsProps.tsx +0 -1
  165. package/src/types/components/EntityFormProps.tsx +0 -6
  166. package/src/types/fields.tsx +27 -0
  167. package/dist/form/components/FormEntry.d.ts +0 -6
  168. package/dist/form/components/FormLayout.d.ts +0 -5
  169. package/dist/history-g9688IaK.js.map +0 -1
  170. package/dist/util-B5fJm1FA.js.map +0 -1
  171. package/src/form/components/FormEntry.tsx +0 -22
  172. package/src/form/components/FormLayout.tsx +0 -16
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/admin",
3
3
  "type": "module",
4
- "version": "0.12.0",
4
+ "version": "0.12.1-canary.g009ed95",
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": ">=20"
21
+ "node": ">=22.22.0"
22
22
  },
23
23
  "keywords": [
24
24
  "rebase",
@@ -54,66 +54,64 @@
54
54
  "@dnd-kit/modifiers": "^9.0.0",
55
55
  "@dnd-kit/sortable": "^10.0.0",
56
56
  "@dnd-kit/utilities": "^3.2.2",
57
- "@floating-ui/dom": "^1.7.6",
57
+ "@floating-ui/dom": "^1.8.0",
58
58
  "cmdk": "^1.1.1",
59
59
  "date-fns": "^4.4.0",
60
60
  "exceljs": "^4.4.0",
61
- "fast-equals": "6.0.0",
62
- "fractional-indexing": "3.2.0",
63
- "fuse.js": "^7.4.2",
61
+ "fast-equals": "6.0.2",
62
+ "fractional-indexing": "4.0.0",
63
+ "fuse.js": "^7.5.0",
64
64
  "json5": "^2.2.3",
65
- "markdown-it": "^14.2.0",
65
+ "markdown-it": "^14.3.0",
66
66
  "markdown-it-ins": "^4.0.0",
67
67
  "markdown-it-mark": "^4.0.0",
68
68
  "markdown-it-task-lists": "^2.1.1",
69
69
  "prism-react-renderer": "^2.4.1",
70
70
  "prosemirror-commands": "^1.7.1",
71
- "prosemirror-dropcursor": "^1.8.2",
71
+ "prosemirror-dropcursor": "^1.8.3",
72
72
  "prosemirror-gapcursor": "^1.4.1",
73
73
  "prosemirror-history": "^1.5.0",
74
74
  "prosemirror-inputrules": "^1.5.1",
75
75
  "prosemirror-keymap": "^1.2.3",
76
- "prosemirror-markdown": "^1.13.4",
77
- "prosemirror-model": "^1.25.8",
76
+ "prosemirror-markdown": "^1.13.5",
77
+ "prosemirror-model": "^1.25.11",
78
78
  "prosemirror-schema-list": "^1.5.1",
79
79
  "prosemirror-state": "^1.4.4",
80
80
  "prosemirror-tables": "^1.8.5",
81
81
  "prosemirror-transform": "^1.12.0",
82
- "prosemirror-view": "^1.41.9",
83
- "react-dropzone": "^15.0.0",
82
+ "prosemirror-view": "^1.42.2",
83
+ "react-dropzone": "^19.1.1",
84
84
  "react-use-measure": "^2.1.7",
85
85
  "zod": "^4.4.3",
86
- "@rebasepro/admin-types": "0.12.0",
87
- "@rebasepro/app": "0.12.0",
88
- "@rebasepro/common": "0.12.0",
89
- "@rebasepro/forms": "0.12.0",
90
- "@rebasepro/types": "0.12.0",
91
- "@rebasepro/inference": "0.12.0",
92
- "@rebasepro/ui": "0.12.0",
93
- "@rebasepro/utils": "0.12.0"
86
+ "@rebasepro/admin-types": "0.12.1-canary.g009ed95",
87
+ "@rebasepro/app": "0.12.1-canary.g009ed95",
88
+ "@rebasepro/inference": "0.12.1-canary.g009ed95",
89
+ "@rebasepro/forms": "0.12.1-canary.g009ed95",
90
+ "@rebasepro/types": "0.12.1-canary.g009ed95",
91
+ "@rebasepro/ui": "0.12.1-canary.g009ed95",
92
+ "@rebasepro/utils": "0.12.1-canary.g009ed95",
93
+ "@rebasepro/common": "0.12.1-canary.g009ed95"
94
94
  },
95
95
  "peerDependencies": {
96
- "react": ">=19.0.0",
97
- "react-dom": ">=19.0.0",
98
- "react-router": "^7.0.0",
99
- "react-router-dom": "^7.0.0"
96
+ "react": ">=19.2.7",
97
+ "react-dom": ">=19.2.7",
98
+ "react-router": "^8.3.0"
100
99
  },
101
100
  "devDependencies": {
102
101
  "@jest/globals": "^30.4.1",
103
102
  "@testing-library/react": "^16.3.2",
104
103
  "@types/jest": "^30.0.0",
105
104
  "@types/markdown-it": "^14.1.2",
106
- "@types/node": "^25.9.3",
105
+ "@types/node": "^26.1.2",
107
106
  "@types/react": "^19.2.17",
108
107
  "@types/react-dom": "^19.2.3",
109
108
  "@types/react-measure": "^2.0.12",
110
- "@vitejs/plugin-react": "^6.0.2",
109
+ "@vitejs/plugin-react": "^6.0.4",
111
110
  "jest": "^30.4.2",
112
- "react-router": "^7.17.0",
113
- "react-router-dom": "^7.17.0",
114
- "ts-jest": "^29.4.11",
111
+ "react-router": "^8.3.0",
112
+ "ts-jest": "^29.4.12",
115
113
  "typescript": "^6.0.3",
116
- "vite": "^8.0.16"
114
+ "vite": "^8.1.5"
117
115
  },
118
116
  "files": [
119
117
  "dist",
@@ -131,13 +129,15 @@
131
129
  },
132
130
  "jest": {
133
131
  "transform": {
134
- "^.+\\.[jt]sx?$": "ts-jest"
132
+ "^.+\\.tsx?$": "ts-jest",
133
+ "^.+\\.m?jsx?$": "<rootDir>/../../scripts/jest/react-router-esm-transform.cjs"
135
134
  },
136
135
  "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$",
137
136
  "moduleFileExtensions": [
138
137
  "ts",
139
138
  "tsx",
140
139
  "js",
140
+ "mjs",
141
141
  "jsx",
142
142
  "json",
143
143
  "node"
@@ -146,9 +146,7 @@
146
146
  "testPathIgnorePatterns": [
147
147
  "/node_modules/"
148
148
  ],
149
- "transformIgnorePatterns": [
150
- "node_modules/(?!.*fractional-indexing)/"
151
- ],
149
+ "transformIgnorePatterns": [],
152
150
  "setupFilesAfterEnv": [
153
151
  "<rootDir>/test/setupTests.ts"
154
152
  ],
@@ -6,7 +6,7 @@ import { deepEqual as equal } from "fast-equals";
6
6
  import { CollectionsConfigController } from "./types/config_controller";
7
7
  import { useCustomizationController, useRebaseContext, useAuthController, useSnackbarController } from "@rebasepro/app";
8
8
  import { getTableName } from "@rebasepro/common";
9
- import { useNavigate } from "react-router-dom";
9
+ import { useNavigate } from "react-router";
10
10
  import { CollectionEditorController } from "./types/collection_editor_controller";
11
11
  import { CollectionInference } from "./types/collection_inference";
12
12
  import { CollectionGenerationCallback } from "./api/generateCollectionApi";
@@ -46,7 +46,8 @@ export interface SerializableAdminBaseOptions {
46
46
  hideFromCollection?: boolean;
47
47
  readOnly?: boolean;
48
48
  disabled?: boolean | SerializablePropertyDisabledConfig;
49
- widthPercentage?: number;
49
+ span?: 1 | 2 | 3 | 4;
50
+ /** Opaque props handed to a custom `Field` / `Preview`. Round-tripped as-is. */
50
51
  customProps?: unknown;
51
52
  /** Narrows the filter operators offered for this property. Plain data. */
52
53
  filterOperators?: readonly WhereFilterOp[];
@@ -291,8 +292,8 @@ export interface SerializableReferenceProperty extends SerializableBaseProperty
291
292
  */
292
293
  export type SerializableRelation =
293
294
  | { kind: "belongsTo"; target?: string; relationName?: string; localKey?: string; onUpdate?: OnAction; onDelete?: OnAction }
294
- | { kind: "hasOne"; target?: string; relationName?: string; foreignKeyOnTarget?: string; onUpdate?: OnAction; onDelete?: OnAction }
295
- | { kind: "hasMany"; target?: string; relationName?: string; foreignKeyOnTarget?: string; onUpdate?: OnAction; onDelete?: OnAction }
295
+ | { kind: "hasOne"; target?: string; relationName?: string; foreignKeyOnTarget?: string; sourceKey?: string; onUpdate?: OnAction; onDelete?: OnAction }
296
+ | { kind: "hasMany"; target?: string; relationName?: string; foreignKeyOnTarget?: string; sourceKey?: string; onUpdate?: OnAction; onDelete?: OnAction }
296
297
  | {
297
298
  kind: "manyToMany";
298
299
  target?: string;
@@ -44,6 +44,41 @@ import type {
44
44
  */
45
45
  export type CollectionLookup = (slug: string) => AdminCollection | undefined;
46
46
 
47
+ /**
48
+ * Rebuild a serialized relation's `target` slug into the thunk consumers call.
49
+ *
50
+ * Shared by the relation *property* and the collection-level `relations` array.
51
+ * They are the same shape on the wire and they need the same treatment coming
52
+ * back, but only the property path had it — so a collection imported from an
53
+ * existing table came off disk with `relations[n].target` still a string, which
54
+ * typechecks (the serializable shape erases the difference) and throws "target
55
+ * is not a function" at the first consumer.
56
+ *
57
+ * The lookup is consulted lazily, inside the thunk, so collections may
58
+ * reference each other in any order, including circularly.
59
+ */
60
+ function fromSerializableRelation<R extends { target?: string; relationName?: string }>(
61
+ relation: R,
62
+ label: string,
63
+ lookup?: CollectionLookup
64
+ ): R & { target: () => AdminCollection } {
65
+ const slug = relation.target;
66
+ return {
67
+ ...relation,
68
+ target: () => {
69
+ const found = slug ? lookup?.(slug) : undefined;
70
+ if (!found) {
71
+ throw new Error(
72
+ `Relation "${relation.relationName ?? label}" targets collection "${slug ?? "(none)"}", ` +
73
+ "which is not among the collections it was deserialized with. Pass the whole set to " +
74
+ "`fromSerializableCollectionConfigs` so slugs can be resolved against each other."
75
+ );
76
+ }
77
+ return found;
78
+ }
79
+ };
80
+ }
81
+
47
82
  // ═══════════════════════════════════════════════════════════════════════
48
83
  // PROPERTY CONVERSION: Original → Serializable
49
84
  // ═══════════════════════════════════════════════════════════════════════
@@ -160,7 +195,8 @@ function toSerializableRelation(link: Relation): SerializableRelation {
160
195
  return {
161
196
  kind: link.kind,
162
197
  ...common,
163
- ...(link.foreignKeyOnTarget ? { foreignKeyOnTarget: link.foreignKeyOnTarget } : {})
198
+ ...(link.foreignKeyOnTarget ? { foreignKeyOnTarget: link.foreignKeyOnTarget } : {}),
199
+ ...(link.sourceKey ? { sourceKey: link.sourceKey } : {})
164
200
  };
165
201
  case "manyToMany":
166
202
  return { kind: "manyToMany", ...common, ...(link.through ? { through: link.through } : {}) };
@@ -600,21 +636,11 @@ type: "date" };
600
636
  const result = { ...rest,
601
637
  type: "relation" } as unknown as RelationProperty;
602
638
  if (relation) {
603
- const slug = relation.target;
604
- result.relation = {
605
- ...relation,
606
- target: () => {
607
- const found = slug ? lookup?.(slug) : undefined;
608
- if (!found) {
609
- throw new Error(
610
- `Relation "${relation.relationName ?? sp.name}" targets collection "${slug ?? "(none)"}", ` +
611
- "which is not among the collections it was deserialized with. Pass the whole set to " +
612
- "`fromSerializableCollectionConfigs` so slugs can be resolved against each other."
613
- );
614
- }
615
- return found;
616
- }
617
- } as unknown as RelationProperty["relation"];
639
+ result.relation = fromSerializableRelation(
640
+ relation,
641
+ sp.name ?? "",
642
+ lookup
643
+ ) as unknown as RelationProperty["relation"];
618
644
  }
619
645
  return result as Property;
620
646
  }
@@ -654,10 +680,18 @@ export function fromSerializableCollectionConfig(
654
680
  serialized: SerializableCollectionConfig,
655
681
  lookup?: CollectionLookup
656
682
  ): AdminCollection {
657
- const { properties, ...rest } = serialized;
683
+ const { properties, relations, ...rest } = serialized as SerializableCollectionConfig & {
684
+ relations?: SerializableRelation[]
685
+ };
658
686
 
659
687
  return {
660
688
  ...rest,
689
+ ...(relations
690
+ ? {
691
+ relations: relations.map((relation, index) =>
692
+ fromSerializableRelation(relation, `relations[${index}]`, lookup))
693
+ }
694
+ : {}),
661
695
  properties: fromSerializableProperties(properties, lookup)
662
696
  } as AdminCollection;
663
697
  }
@@ -30,7 +30,8 @@ import {
30
30
  IconButton,
31
31
  LoadingButton,
32
32
  Tab,
33
- Tabs
33
+ Tabs,
34
+ Tooltip
34
35
  } from "@rebasepro/ui";
35
36
  import { EngineProperties, Entity, getDataSourceCapabilities, MapProperty, Properties, Property, TableMetadata, User } from "@rebasepro/types";
36
37
  import { PropertyConfig, AdminCollection } from "@rebasepro/admin-types";
@@ -254,11 +255,38 @@ export function CollectionEditor(props: CollectionEditorDialogProps & {
254
255
  ? applyPropertyConfigs(initialCollection, propertyConfigs)
255
256
  : copyFromProp
256
257
  ? (() => {
257
- // When duplicating, copy all properties but clear identifiers
258
- const { subcollections: _sub, ...rest } = copyFromProp as unknown as Record<string, unknown>;
258
+ // When duplicating, copy all properties but clear identifiers.
259
+ //
260
+ // "Identifiers" has to include the ones this dialog cannot
261
+ // show. `schema` has no field anywhere in the editor, so a
262
+ // duplicate of the Users collection — the one collection
263
+ // scaffolded with `schema: "rebase"` — silently produced a
264
+ // new collection whose table is created in `rebase` rather
265
+ // than `public`, with nothing on screen to explain why.
266
+ // Panel-authored collections are `public` collections.
267
+ //
268
+ // `slug`/`table` are cleared rather than carried: both are
269
+ // re-derived from the name the user types (see
270
+ // `updateName` in GeneralSettingsForm, which fills untouched
271
+ // fields while `isNewCollection`), and carrying them meant a
272
+ // duplicate pointed at the source's table until then.
273
+ //
274
+ // `auth` goes too — a duplicate must not become a second
275
+ // auth collection wired to the same user store.
276
+ const {
277
+ subcollections: _sub,
278
+ schema: _schema,
279
+ auth: _auth,
280
+ ...rest
281
+ } = copyFromProp as unknown as Record<string, unknown>;
259
282
  return {
260
283
  ...rest,
261
284
  name: "",
285
+ // Same shape the "new collection" branch below starts
286
+ // from, so validation and the registry lookups never see
287
+ // an undefined slug mid-edit.
288
+ slug: fallbackSlug,
289
+ table: "",
262
290
  ownerId: authController.user?.uid ?? ""
263
291
  } as AdminCollection<any>;
264
292
  })()
@@ -341,6 +369,10 @@ function CollectionEditorInternal<M extends Record<string, unknown>>({
341
369
  }
342
370
  ) {
343
371
 
372
+ /** Why the submit buttons below are dead, straight from the backend. */
373
+ const readOnlyTitle = configController?.readOnlyReason
374
+ || "This backend does not accept collection edits.";
375
+
344
376
  const importConfigRaw = useImportConfig();
345
377
  const urlControllerRaw = useUrlController();
346
378
  const collectionRegistryRaw = useCollectionRegistryController();
@@ -720,16 +752,23 @@ function CollectionEditorInternal<M extends Record<string, unknown>>({
720
752
  )}
721
753
  {fullScreen && !isNewCollection && (
722
754
  <div className="flex items-center gap-2">
723
- <LoadingButton
724
- variant="filled"
725
- color="primary"
726
- type="submit"
727
- onClick={() => formController.handleSubmit()}
728
- disabled={!dirty || isSubmitting || configController?.readOnly}
729
- loading={isSubmitting}
730
- >
731
- {configController?.readOnly ? "Update (Read-only)" : "Update"}
732
- </LoadingButton>
755
+ {/* "(Read-only)" says the button will not work but not why.
756
+ The controller now carries the backend's own reason —
757
+ production, `baas` mode, no `collectionsDir` — so show it. */}
758
+ <Tooltip title={configController?.readOnly ? readOnlyTitle : undefined}>
759
+ <div>
760
+ <LoadingButton
761
+ variant="filled"
762
+ color="primary"
763
+ type="submit"
764
+ onClick={() => formController.handleSubmit()}
765
+ disabled={!dirty || isSubmitting || configController?.readOnly}
766
+ loading={isSubmitting}
767
+ >
768
+ {configController?.readOnly ? "Update (Read-only)" : "Update"}
769
+ </LoadingButton>
770
+ </div>
771
+ </Tooltip>
733
772
  </div>
734
773
  )}
735
774
  </div>
@@ -935,15 +974,19 @@ function CollectionEditorInternal<M extends Record<string, unknown>>({
935
974
  {currentView === "properties" && "Create collection"}
936
975
  </LoadingButton>}
937
976
 
938
- {!isNewCollection && !fullScreen && <LoadingButton
939
- variant="filled"
940
- color="primary"
941
- type="submit"
942
- disabled={isSubmitting || configController?.readOnly}
943
- loading={isSubmitting}
944
- >
945
- {configController?.readOnly ? "Update collection (Read-only)" : "Update collection"}
946
- </LoadingButton>}
977
+ {!isNewCollection && !fullScreen && <Tooltip title={configController?.readOnly ? readOnlyTitle : undefined}>
978
+ <div>
979
+ <LoadingButton
980
+ variant="filled"
981
+ color="primary"
982
+ type="submit"
983
+ disabled={isSubmitting || configController?.readOnly}
984
+ loading={isSubmitting}
985
+ >
986
+ {configController?.readOnly ? "Update collection (Read-only)" : "Update collection"}
987
+ </LoadingButton>
988
+ </div>
989
+ </Tooltip>}
947
990
 
948
991
  </div>
949
992
  )}
@@ -1,6 +1,6 @@
1
1
 
2
2
  import { getPolicyNamesForRule, getPolicyNamesForRules } from "@rebasepro/utils";
3
- import { getEffectiveSecurityRules } from "@rebasepro/common";
3
+ import { getGeneratedPolicyNames } from "@rebasepro/common";
4
4
  import React, { useState, useEffect, useMemo } from "react";
5
5
 
6
6
  /**
@@ -39,6 +39,7 @@ import {
39
39
  Typography
40
40
  } from "@rebasepro/ui";
41
41
  import { useFormex } from "@rebasepro/forms";
42
+ import { useCollectionsConfigController } from "../../useCollectionsConfigController";
42
43
  import { useRebaseContext } from "@rebasepro/app";
43
44
  import type { AdminCollection } from "@rebasepro/admin-types";
44
45
  import type { PostgresPolicy, SecurityOperation, SecurityRule } from "@rebasepro/types";
@@ -114,6 +115,14 @@ export function CollectionRLSTab() {
114
115
  const { values, setFieldValue } = useFormex<CollectionWithSecurity>();
115
116
  const [editingPolicy, setEditingPolicy] = useState<PostgresPolicy | "new" | null>(null);
116
117
 
118
+ // Every other tab in this dialog disables its inputs when the backend will
119
+ // not accept an edit — the general and display forms behind a `fieldset
120
+ // disabled`, the properties editor per control. This one did not, so a
121
+ // read-only panel still invited you to write a policy, delete one, and
122
+ // reorder the lot, and only said no at the submit button on the way out.
123
+ const { readOnly, readOnlyReason } = useCollectionsConfigController();
124
+ const readOnlyTitle = readOnlyReason || "This backend does not accept collection edits.";
125
+
117
126
  const rules: SecurityRule[] = values.securityRules || [];
118
127
 
119
128
  const { databaseAdmin } = useRebaseContext();
@@ -209,26 +218,14 @@ export function CollectionRLSTab() {
209
218
 
210
219
  const tableName = values.id || values.table || values.alias;
211
220
 
212
- /**
213
- * Every policy name `rebase db push` would write for this collection.
214
- *
215
- * Two things make this more than `rules.map(r => r.name)`:
216
- * - a rule without an explicit `name` becomes `<table>_<op>_<hash>`, so
217
- * comparing `rule.name` to `policyname` never matches for it;
218
- * - the generator also injects the safe-by-default baseline
219
- * (`<table>_default_admin_*`), which is in no collection's `securityRules`.
220
- *
221
- * Missing either made Rebase's own policies look hand-written, and offered to
222
- * import them back into the codebase that produced them.
223
- */
221
+ // Every policy name `rebase db push` would write for this collection — see
222
+ // `getGeneratedPolicyNames`. This used to be derived here by hand, and the
223
+ // Studio's RLS editor derived a different subset of it by hand, which is
224
+ // exactly how the two came to disagree about which policies were drift.
224
225
  const generatedPolicyNames = useMemo(() => {
225
226
  if (!tableName) return new Set<string>();
226
- const effectiveRules = getEffectiveSecurityRules(values as AdminCollection);
227
- return getPolicyNamesForRules(
228
- [...rules, ...effectiveRules],
229
- tableName
230
- );
231
- }, [rules, tableName, values]);
227
+ return getGeneratedPolicyNames(values as AdminCollection);
228
+ }, [tableName, values]);
232
229
 
233
230
  const unmappedPolicies = dbPolicies.filter(dp => !generatedPolicyNames.has(dp.policyname));
234
231
 
@@ -251,9 +248,13 @@ export function CollectionRLSTab() {
251
248
  <div className="w-full flex flex-col">
252
249
  <div className="flex items-center justify-between mb-8">
253
250
  <Typography variant="h5">Row Level Security</Typography>
254
- <Button variant="filled" color="neutral" onClick={() => setEditingPolicy("new")}>
255
- CREATE POLICY
256
- </Button>
251
+ <Tooltip title={readOnly ? readOnlyTitle : undefined}>
252
+ <div>
253
+ <Button variant="filled" color="neutral" disabled={readOnly} onClick={() => setEditingPolicy("new")}>
254
+ CREATE POLICY
255
+ </Button>
256
+ </div>
257
+ </Tooltip>
257
258
  </div>
258
259
 
259
260
  {rules.length === 0 ? (
@@ -282,7 +283,7 @@ export function CollectionRLSTab() {
282
283
  </div>
283
284
  </div>
284
285
  <div className="flex items-center gap-1 sm:gap-2 shrink-0">
285
- <Button size="small" variant="text" onClick={() => setEditingPolicy({
286
+ <Button size="small" variant="text" disabled={readOnly} onClick={() => setEditingPolicy({
286
287
  policyname: rule.name ?? "",
287
288
  tablename: values.id || values.table || values.alias || "your_table",
288
289
  permissive: (rule.mode || "permissive").toUpperCase() as PostgresPolicy["permissive"],
@@ -293,7 +294,7 @@ export function CollectionRLSTab() {
293
294
  })}>
294
295
  EDIT
295
296
  </Button>
296
- <IconButton size="small" onClick={() => {
297
+ <IconButton size="small" disabled={readOnly} onClick={() => {
297
298
  setFieldValue("securityRules", rules.filter((r: SecurityRule) => r.name !== rule.name));
298
299
  }}>
299
300
  <Trash2Icon size={iconSize.smallest} className="text-text-secondary dark:text-text-secondary-dark hover:text-red-500 dark:hover:text-red-500 transition-colors"/>
@@ -336,12 +337,19 @@ export function CollectionRLSTab() {
336
337
  </div>
337
338
  </div>
338
339
  <div className="flex items-center gap-1 sm:gap-2 shrink-0">
339
- <Button size="small" variant="outlined" color="primary" onClick={() => {
340
- const rule: SecurityRule = toSecurityRule(dp);
341
- setFieldValue("securityRules", [...rules, rule]);
342
- }}>
343
- Import to codebase
344
- </Button>
340
+ {/* "Import to codebase" is the one action here that
341
+ names its destination, and it is the one the
342
+ backend refuses when it has no codebase to write. */}
343
+ <Tooltip title={readOnly ? readOnlyTitle : undefined}>
344
+ <div>
345
+ <Button size="small" variant="outlined" color="primary" disabled={readOnly} onClick={() => {
346
+ const rule: SecurityRule = toSecurityRule(dp);
347
+ setFieldValue("securityRules", [...rules, rule]);
348
+ }}>
349
+ Import to codebase
350
+ </Button>
351
+ </div>
352
+ </Tooltip>
345
353
  </div>
346
354
  </Paper>
347
355
  ))}
@@ -41,6 +41,7 @@ export type RelationDraft = {
41
41
  target?: string;
42
42
  localKey?: string;
43
43
  foreignKeyOnTarget?: string;
44
+ sourceKey?: string;
44
45
  through?: { table?: string; sourceColumn?: string; targetColumn?: string };
45
46
  joinPath?: JoinStep[];
46
47
  cardinality?: "one" | "many";
@@ -119,7 +120,8 @@ kind: "belongsTo",
119
120
  return {
120
121
  ...common,
121
122
  kind: draft.kind,
122
- ...(draft.foreignKeyOnTarget ? { foreignKeyOnTarget: draft.foreignKeyOnTarget } : {})
123
+ ...(draft.foreignKeyOnTarget ? { foreignKeyOnTarget: draft.foreignKeyOnTarget } : {}),
124
+ ...(draft.sourceKey ? { sourceKey: draft.sourceKey } : {})
123
125
  };
124
126
  case "manyToMany":
125
127
  return { ...common,
@@ -10,7 +10,7 @@
10
10
  * (which has no react-router dependency).
11
11
  */
12
12
  import React from "react";
13
- import { useNavigate } from "react-router-dom";
13
+ import { useNavigate } from "react-router";
14
14
  import { useUrlController } from "../../_cms_internals";
15
15
  import { useSnackbarController } from "@rebasepro/app";
16
16
  import { CollectionStudioView, CollectionStudioViewProps } from "./CollectionStudioView";
@@ -9,7 +9,7 @@
9
9
  * (which has no react-router dependency).
10
10
  */
11
11
  import React from "react";
12
- import { useLocation, useNavigate } from "react-router-dom";
12
+ import { useLocation, useNavigate } from "react-router";
13
13
  import { useUrlController } from "../../_cms_internals";
14
14
  import { CollectionsStudioView, CollectionsStudioViewProps } from "./CollectionsStudioView";
15
15
 
@@ -67,6 +67,7 @@ export function RelationPropertyField({
67
67
  const targetSlug = getTargetSlug(link.target);
68
68
  const localKey = link.localKey ?? "";
69
69
  const foreignKeyOnTarget = link.foreignKeyOnTarget ?? "";
70
+ const sourceKey = link.sourceKey ?? "";
70
71
  const through = link.through;
71
72
  const throughTable = through?.table ?? "";
72
73
  const throughSourceColumn = through?.sourceColumn ?? "";
@@ -200,6 +201,21 @@ targetColumn: "" };
200
201
  <FieldCaption>
201
202
  Column on the target table that references this table&apos;s primary key
202
203
  </FieldCaption>
204
+ <TextField
205
+ className={"mt-4"}
206
+ value={sourceKey}
207
+ onChange={(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
208
+ setFieldValue("relation.sourceKey", e.target.value)
209
+ }
210
+ label={"Source key (optional)"}
211
+ disabled={disabled}
212
+ placeholder={"defaults to this table's primary key"}
213
+ />
214
+ <FieldCaption>
215
+ Column on <em>this</em> table that the foreign key above points at. Leave empty
216
+ unless the two sides are joined on a natural key — an external identity id, a
217
+ SKU — rather than on the row id. It must be unique.
218
+ </FieldCaption>
203
219
  </div>
204
220
  )}
205
221