@rebasepro/admin 0.13.1-canary.gf57a27e → 0.14.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 (85) hide show
  1. package/dist/{CollectionEditorDialog-BnfOcqM_.js → CollectionEditorDialog-CSheud_E.js} +19 -9
  2. package/dist/CollectionEditorDialog-CSheud_E.js.map +1 -0
  3. package/dist/{PropertyEditView-vLXglzIy.js → PropertyEditView-CWZlC1gq.js} +5 -5
  4. package/dist/PropertyEditView-CWZlC1gq.js.map +1 -0
  5. package/dist/{RouterCollectionsStudioView-DF3aiOuv.js → RouterCollectionsStudioView-CwTpdzWF.js} +4 -4
  6. package/dist/{RouterCollectionsStudioView-DF3aiOuv.js.map → RouterCollectionsStudioView-CwTpdzWF.js.map} +1 -1
  7. package/dist/collection_editor_ui.js +4 -4
  8. package/dist/components/ClearFilterSortButton.d.ts +7 -1
  9. package/dist/components/CollectionViewBinding/SortButton.d.ts +7 -1
  10. package/dist/components/RelationSelector.d.ts +13 -0
  11. package/dist/data_export/export/export.d.ts +7 -0
  12. package/dist/data_export/export/fetch_export_data.d.ts +48 -0
  13. package/dist/data_export/export/index.d.ts +1 -0
  14. package/dist/data_import/utils/csv.d.ts +30 -0
  15. package/dist/data_import/utils/index.d.ts +2 -0
  16. package/dist/data_import/utils/save_entities.d.ts +38 -0
  17. package/dist/data_import/utils/transforms.d.ts +9 -0
  18. package/dist/editor.js +3 -0
  19. package/dist/editor.js.map +1 -1
  20. package/dist/{export-C26xQN-g.js → export-L53WektO.js} +175 -39
  21. package/dist/export-L53WektO.js.map +1 -0
  22. package/dist/{history-DSS-Ebwh.js → history-D5SKygpJ.js} +2 -2
  23. package/dist/{history-DSS-Ebwh.js.map → history-D5SKygpJ.js.map} +1 -1
  24. package/dist/{import-Dhx96B6x.js → import-C_4edkoD.js} +5 -4
  25. package/dist/import-C_4edkoD.js.map +1 -0
  26. package/dist/index.js +93 -74
  27. package/dist/index.js.map +1 -1
  28. package/dist/preview/components/date_fns_locales.d.ts +9 -0
  29. package/dist/{util-Bt6VadeZ.js → util-C-D5yD2n.js} +567 -113
  30. package/dist/util-C-D5yD2n.js.map +1 -0
  31. package/package.json +10 -9
  32. package/src/collection_editor/ui/collection_editor/AICollectionGeneratorPopover.tsx +8 -3
  33. package/src/collection_editor/ui/collection_editor/CollectionDetailsForm.tsx +5 -2
  34. package/src/collection_editor/ui/collection_editor/CollectionJsonImportDialog.tsx +1 -0
  35. package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +9 -8
  36. package/src/collection_editor/ui/collection_editor/GeneralSettingsForm.tsx +5 -2
  37. package/src/collection_editor/ui/collection_editor/properties/MapPropertyField.tsx +1 -1
  38. package/src/collection_editor/ui/collection_editor/properties/advanced/AdvancedPropertyValidation.tsx +5 -2
  39. package/src/collection_editor/ui/collection_editor/properties/conditions/ConditionsEditor.tsx +1 -0
  40. package/src/collection_editor/useJsonCollectionsConfigController.tsx +92 -84
  41. package/src/collection_editor/useLocalCollectionsConfigController.tsx +10 -3
  42. package/src/components/ClearFilterSortButton.tsx +16 -7
  43. package/src/components/CollectionTableBinding/table_bindings.tsx +7 -1
  44. package/src/components/CollectionViewBinding/CollectionBoardViewBinding.tsx +13 -7
  45. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +1 -0
  46. package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +9 -0
  47. package/src/components/CollectionViewBinding/SortButton.tsx +16 -4
  48. package/src/components/CollectionViewBinding/ViewModeToggle.tsx +10 -5
  49. package/src/components/CollectionViewBinding/useBoardDataController.tsx +11 -3
  50. package/src/components/DefaultDrawer.tsx +7 -0
  51. package/src/components/DrawerNavigationItem.tsx +12 -0
  52. package/src/components/RelationSelector.tsx +52 -5
  53. package/src/components/SearchIconsView.tsx +14 -33
  54. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +1 -0
  55. package/src/components/UserSelector.tsx +47 -5
  56. package/src/data_export/export/ExportCollectionAction.tsx +101 -48
  57. package/src/data_export/export/export.ts +63 -16
  58. package/src/data_export/export/fetch_export_data.ts +90 -0
  59. package/src/data_export/export/index.ts +1 -0
  60. package/src/data_import/components/DataNewPropertiesMapping.tsx +20 -3
  61. package/src/data_import/components/ImportNewPropertyFieldPreview.tsx +1 -0
  62. package/src/data_import/components/ImportSaveInProgress.tsx +35 -36
  63. package/src/data_import/import/ImportCollectionAction.tsx +13 -2
  64. package/src/data_import/utils/csv.ts +153 -0
  65. package/src/data_import/utils/data.ts +7 -1
  66. package/src/data_import/utils/file_to_json.ts +77 -6
  67. package/src/data_import/utils/index.ts +2 -0
  68. package/src/data_import/utils/save_entities.ts +138 -0
  69. package/src/data_import/utils/transforms.ts +23 -0
  70. package/src/editor/components/image-bubble.tsx +2 -0
  71. package/src/editor/selectors/link-selector.tsx +1 -0
  72. package/src/form/field_bindings/BinaryFieldBinding.tsx +1 -0
  73. package/src/form/field_bindings/DateTimeFieldBinding.tsx +4 -0
  74. package/src/form/field_bindings/KeyValueFieldBinding.tsx +3 -0
  75. package/src/form/field_bindings/TextFieldBinding.tsx +18 -0
  76. package/src/form/field_bindings/VectorFieldBinding.tsx +1 -0
  77. package/src/preview/components/DatePreview.tsx +15 -2
  78. package/src/preview/components/ReferencePreview.tsx +5 -8
  79. package/src/preview/components/RelationPreview.tsx +8 -7
  80. package/src/preview/components/date_fns_locales.ts +125 -0
  81. package/dist/CollectionEditorDialog-BnfOcqM_.js.map +0 -1
  82. package/dist/PropertyEditView-vLXglzIy.js.map +0 -1
  83. package/dist/export-C26xQN-g.js.map +0 -1
  84. package/dist/import-Dhx96B6x.js.map +0 -1
  85. package/dist/util-Bt6VadeZ.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.13.1-canary.gf57a27e",
4
+ "version": "0.14.0",
5
5
  "description": "Rebase CMS — content management views, forms, and routing",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -83,14 +83,14 @@
83
83
  "react-dropzone": "^19.1.1",
84
84
  "react-use-measure": "^2.1.7",
85
85
  "zod": "^4.4.3",
86
- "@rebasepro/app": "0.13.1-canary.gf57a27e",
87
- "@rebasepro/admin-types": "0.13.1-canary.gf57a27e",
88
- "@rebasepro/common": "0.13.1-canary.gf57a27e",
89
- "@rebasepro/inference": "0.13.1-canary.gf57a27e",
90
- "@rebasepro/forms": "0.13.1-canary.gf57a27e",
91
- "@rebasepro/types": "0.13.1-canary.gf57a27e",
92
- "@rebasepro/ui": "0.13.1-canary.gf57a27e",
93
- "@rebasepro/utils": "0.13.1-canary.gf57a27e"
86
+ "@rebasepro/admin-types": "0.14.0",
87
+ "@rebasepro/app": "0.14.0",
88
+ "@rebasepro/forms": "0.14.0",
89
+ "@rebasepro/inference": "0.14.0",
90
+ "@rebasepro/types": "0.14.0",
91
+ "@rebasepro/utils": "0.14.0",
92
+ "@rebasepro/common": "0.14.0",
93
+ "@rebasepro/ui": "0.14.0"
94
94
  },
95
95
  "peerDependencies": {
96
96
  "react": ">=19.2.7",
@@ -107,6 +107,7 @@
107
107
  "@types/react-dom": "^19.2.3",
108
108
  "@types/react-measure": "^2.0.12",
109
109
  "@vitejs/plugin-react": "^6.0.4",
110
+ "fast-check": "^4.9.0",
110
111
  "jest": "^30.4.2",
111
112
  "react-router": "^8.3.0",
112
113
  "ts-jest": "^29.4.12",
@@ -1,5 +1,5 @@
1
1
 
2
- import { AIIcon } from "@rebasepro/app";
2
+ import { AIIcon, useTranslation } from "@rebasepro/app";
3
3
  import { useCollectionRegistryController } from "../../_cms_internals";
4
4
  import React, { useState } from "react";
5
5
  import { useSafeSnackbarController } from "../../useSafeSnackbarController";
@@ -70,6 +70,7 @@ export function AICollectionGeneratorPopover({
70
70
  showLabel = true,
71
71
  onAnalyticsEvent
72
72
  }: AICollectionGeneratorPopoverProps) {
73
+ const { t } = useTranslation();
73
74
  const [menuOpen, setMenuOpen] = useState(false);
74
75
  const [prompt, setPrompt] = useState("");
75
76
  const [loading, setLoading] = useState(false);
@@ -204,8 +205,8 @@ export function AICollectionGeneratorPopover({
204
205
 
205
206
  <Typography variant="caption" color="secondary">
206
207
  {existingCollection
207
- ? "Describe the changes you want to make to this collection."
208
- : "Describe the collection you want to create."
208
+ ? t("describe_changes_to_make")
209
+ : t("describe_collection_to_create")
209
210
  }
210
211
  </Typography>
211
212
 
@@ -213,6 +214,10 @@ export function AICollectionGeneratorPopover({
213
214
  size="small"
214
215
  multiline
215
216
  autoFocus
217
+ aria-label={existingCollection
218
+ ? t("describe_changes_to_make")
219
+ : t("describe_collection_to_create")
220
+ }
216
221
  className="w-full text-text-primary dark:text-text-primary-dark"
217
222
  value={prompt}
218
223
  onChange={(e) => setPrompt(e.target.value)}
@@ -2,7 +2,7 @@
2
2
  import { IconForView } from "@rebasepro/app";
3
3
  import { FieldCaption } from "../../_cms_internals";
4
4
  import React, { useMemo, useState } from "react";
5
- import { useAuthController, useCustomizationController } from "@rebasepro/app";
5
+ import { useAuthController, useCustomizationController, useTranslation } from "@rebasepro/app";
6
6
  import { getFieldConfig, PropertyConfigBadge, SearchIconsView } from "../../_cms_internals";
7
7
  import { Property } from "@rebasepro/types";
8
8
  import {
@@ -372,10 +372,13 @@ function DefaultDatabaseField({
372
372
  onDatabaseIdUpdate
373
373
  }: { databaseId?: string, onDatabaseIdUpdate: (databaseId: string) => void }) {
374
374
 
375
- return <Tooltip title={"Database ID"}
375
+ const { t } = useTranslation();
376
+
377
+ return <Tooltip title={t("database_id")}
376
378
  side={"top"}
377
379
  align={"start"}>
378
380
  <TextField size={"small"}
381
+ aria-label={t("database_id")}
379
382
  invisible={true}
380
383
  inputClassName={"text-end"}
381
384
  value={databaseId ?? ""}
@@ -110,6 +110,7 @@ export function CollectionJsonImportDialog({
110
110
  <TextField
111
111
  multiline
112
112
  minRows={12}
113
+ aria-label="Collection JSON"
113
114
  value={jsonValue}
114
115
  onChange={handleJsonChange}
115
116
  placeholder={EXAMPLE_JSON}
@@ -40,7 +40,7 @@ import {
40
40
  } from "@rebasepro/ui";
41
41
  import { useFormex } from "@rebasepro/forms";
42
42
  import { useCollectionsConfigController } from "../../useCollectionsConfigController";
43
- import { useRebaseContext } from "@rebasepro/app";
43
+ import { useRebaseContext, useTranslation } from "@rebasepro/app";
44
44
  import type { AdminCollection } from "@rebasepro/admin-types";
45
45
  import type { PostgresPolicy, SecurityOperation, SecurityRule } from "@rebasepro/types";
46
46
 
@@ -394,6 +394,7 @@ function InlinePolicyEditor({
394
394
  onSave: (policyData: Partial<PostgresPolicy>) => void;
395
395
  onCancel: () => void;
396
396
  }) {
397
+ const { t } = useTranslation();
397
398
  const [name, setName] = useState(policy?.policyname || "");
398
399
  const [behavior, setBehavior] = useState<"PERMISSIVE" | "RESTRICTIVE">(policy?.permissive || "PERMISSIVE");
399
400
  const [command, setCommand] = useState<PolicyCommand>((policy?.cmd as PolicyCommand) || "ALL");
@@ -441,8 +442,8 @@ function InlinePolicyEditor({
441
442
  <Paper className={cls("p-4 md:p-6 flex flex-col gap-6 bg-white dark:bg-surface-800 border-none sm:border-solid rounded-none sm:rounded-xl", defaultBorderMixin)}>
442
443
  <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
443
444
  <div className="flex flex-col gap-1.5">
444
- <Typography variant="caption" className="uppercase tracking-wider text-text-secondary">Policy Name</Typography>
445
- <TextField value={name} onChange={(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setName(e.target.value)} placeholder="e.g. allow_read_all"/>
445
+ <Typography variant="caption" className="uppercase tracking-wider text-text-secondary">{t("studio_policy_name")}</Typography>
446
+ <TextField aria-label={t("studio_policy_name")} value={name} onChange={(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setName(e.target.value)} placeholder="e.g. allow_read_all"/>
446
447
  </div>
447
448
  <div className="flex flex-col gap-1.5">
448
449
  <Typography variant="caption" className="uppercase tracking-wider text-text-secondary">Behavior</Typography>
@@ -484,7 +485,7 @@ function InlinePolicyEditor({
484
485
  </Typography>
485
486
  )}
486
487
  <div className="flex gap-2 items-center">
487
- <TextField size="small" value={customRole}
488
+ <TextField size="small" aria-label="Add a custom role" value={customRole}
488
489
  onChange={(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setCustomRole(e.target.value)}
489
490
  onKeyDown={(e: React.KeyboardEvent) => {
490
491
  if (e.key === "Enter") {
@@ -501,14 +502,14 @@ function InlinePolicyEditor({
501
502
  </div>
502
503
  {command !== "INSERT" && (
503
504
  <div className="flex flex-col gap-1.5">
504
- <Typography variant="caption" className="uppercase tracking-wider text-text-secondary">USING expression</Typography>
505
- <TextField value={usingExpr} onChange={(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setUsingExpr(e.target.value)} placeholder="e.g. auth.uid() = uid"/>
505
+ <Typography variant="caption" className="uppercase tracking-wider text-text-secondary">{t("studio_policy_using_expr")}</Typography>
506
+ <TextField aria-label={t("studio_policy_using_expr")} value={usingExpr} onChange={(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setUsingExpr(e.target.value)} placeholder="e.g. auth.uid() = uid"/>
506
507
  </div>
507
508
  )}
508
509
  {showCheck && (
509
510
  <div className="flex flex-col gap-1.5">
510
- <Typography variant="caption" className="uppercase tracking-wider text-text-secondary">WITH CHECK expression</Typography>
511
- <TextField value={checkExpr} onChange={(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setCheckExpr(e.target.value)} placeholder="e.g. auth.uid() = uid"/>
511
+ <Typography variant="caption" className="uppercase tracking-wider text-text-secondary">{t("studio_policy_check_expr")}</Typography>
512
+ <TextField aria-label={t("studio_policy_check_expr")} value={checkExpr} onChange={(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setCheckExpr(e.target.value)} placeholder="e.g. auth.uid() = uid"/>
512
513
  </div>
513
514
  )}
514
515
  </Paper>
@@ -1,5 +1,5 @@
1
1
 
2
- import { IconForView } from "@rebasepro/app";
2
+ import { IconForView, useTranslation } from "@rebasepro/app";
3
3
  import { FieldCaption } from "../../_cms_internals";
4
4
  import React, { useState } from "react";
5
5
 
@@ -271,10 +271,13 @@ function DefaultDatabaseField({
271
271
  onDatabaseIdUpdate
272
272
  }: { databaseId?: string, disabled?: boolean, onDatabaseIdUpdate: (databaseId: string) => void }) {
273
273
 
274
- return <Tooltip title={"Database ID"}
274
+ const { t } = useTranslation();
275
+
276
+ return <Tooltip title={t("database_id")}
275
277
  side={"top"}
276
278
  align={"start"}>
277
279
  <TextField size={"small"}
280
+ aria-label={t("database_id")}
278
281
  invisible={true}
279
282
  inputClassName={"text-end"}
280
283
  disabled={disabled}
@@ -111,7 +111,7 @@ export function MapPropertyField({ disabled, getData, allowDataInference, proper
111
111
  position={"start"}
112
112
  size={"medium"}
113
113
  label={t("spread_children_as_columns")}
114
- onValueChange={(v) => setFieldValue("ui.spreadChildren", v)}
114
+ onValueChange={(v) => setFieldValue("admin.spreadChildren", v)}
115
115
  value={values.admin?.spreadChildren ?? false}
116
116
  />
117
117
  <FieldCaption>
@@ -7,8 +7,11 @@ export function AdvancedPropertyValidation({ disabled }: {
7
7
  disabled: boolean
8
8
  }) {
9
9
 
10
- const hideFromCollection = "ui.hideFromCollection";
11
- const readOnly = "ui.readOnly";
10
+ // `ui` was renamed to `admin` in 0.11. Nothing reads `ui.*` — the toggles did
11
+ // nothing — and the boot validator treats the old name as a removed key, so a
12
+ // save through here left a project that would not start.
13
+ const hideFromCollection = "admin.hideFromCollection";
14
+ const readOnly = "admin.readOnly";
12
15
 
13
16
  return (
14
17
 
@@ -302,6 +302,7 @@ function ConditionValueInput({
302
302
  type="number"
303
303
  size="small"
304
304
  disabled={disabled}
305
+ aria-label="Condition value"
305
306
  placeholder="Value"
306
307
  className="flex-1 min-w-[80px]"
307
308
  onChange={(e) => onChange(e.target.value)}
@@ -64,6 +64,24 @@ export function useJsonCollectionsConfigController({
64
64
  const storeRef = useRef(store);
65
65
  storeRef.current = store;
66
66
 
67
+ /**
68
+ * The current collections, readable outside a state updater.
69
+ *
70
+ * Three mutators used to compute their new value inside
71
+ * `setCollections(prev => …)` and persist from in there, which cost them
72
+ * both halves of a save: the promise they returned resolved before the
73
+ * store had answered, so a failed write reached the caller as a success,
74
+ * and a `setState` updater must be pure — under `StrictMode` React invokes
75
+ * it twice, so every one of those saves wrote to the store twice in
76
+ * development. Reading `prev` from a ref lets them do what
77
+ * `updatePropertiesOrder` already did: compute, set, then await.
78
+ */
79
+ const collectionsRef = useRef<AdminCollection[]>([]);
80
+ const applyCollections = useCallback((next: AdminCollection[]) => {
81
+ collectionsRef.current = next;
82
+ setCollections(next);
83
+ }, []);
84
+
67
85
  // ── Load on mount ─────────────────────────────────────────────────
68
86
  useEffect(() => {
69
87
  if (!autoLoad) return;
@@ -78,7 +96,7 @@ export function useJsonCollectionsConfigController({
78
96
  if (cancelled) return;
79
97
  // Deserialized as a set: a relation's target is a slug, and a slug only
80
98
  // resolves against the other collections it arrived with.
81
- setCollections(fromSerializableCollectionConfigs(serialized));
99
+ applyCollections(fromSerializableCollectionConfigs(serialized));
82
100
  setNavigationEntries(navEntries);
83
101
  } catch (e) {
84
102
  console.error("useJsonCollectionsConfigController: failed to load collections", e);
@@ -88,7 +106,10 @@ export function useJsonCollectionsConfigController({
88
106
  })();
89
107
 
90
108
  return () => { cancelled = true; };
91
- }, [autoLoad]);
109
+ // `applyCollections` is `useCallback(…, [])`, so naming it here cannot
110
+ // re-run the mount effect — it is listed because a stable identity is a
111
+ // fact about today's code, not a reason to leave a dependency out.
112
+ }, [autoLoad, applyCollections]);
92
113
 
93
114
  // ── Helpers ────────────────────────────────────────────────────────
94
115
 
@@ -116,49 +137,44 @@ export function useJsonCollectionsConfigController({
116
137
  ) => {
117
138
  const collection = collectionData as AdminCollection;
118
139
  await persistCollection(collection);
119
- setCollections(prev => {
120
- const idx = prev.findIndex(c => c.slug === id);
121
- if (idx >= 0) {
122
- const next = [...prev];
123
- next[idx] = collection;
124
- return next;
125
- }
126
- return [...prev, collection];
127
- });
128
- }, [persistCollection]);
140
+ const prev = collectionsRef.current;
141
+ const idx = prev.findIndex(c => c.slug === id);
142
+ if (idx >= 0) {
143
+ const next = [...prev];
144
+ next[idx] = collection;
145
+ applyCollections(next);
146
+ } else {
147
+ applyCollections([...prev, collection]);
148
+ }
149
+ }, [persistCollection, applyCollections]);
129
150
 
130
151
  const updateCollection = useCallback(async <M extends Record<string, unknown>>(
131
152
  { id, collectionData, previousId }: UpdateCollectionParams<M>
132
153
  ) => {
133
- setCollections(prev => {
134
- const lookupId = previousId ?? id;
135
- const idx = prev.findIndex(c => c.slug === lookupId);
136
- if (idx < 0) return prev;
137
-
138
- const merged = { ...prev[idx], ...collectionData } as AdminCollection;
139
- const next = [...prev];
140
- next[idx] = merged;
141
-
142
- // Persist async — fire-and-forget with error logging
143
- persistCollection(merged).catch(e =>
144
- console.error("useJsonCollectionsConfigController: failed to update collection", e)
145
- );
146
-
147
- // If slug changed, clean up the old entry in the store
148
- if (previousId && previousId !== id) {
149
- storeRef.current.delete(previousId).catch(e =>
150
- console.error("useJsonCollectionsConfigController: failed to delete old slug", e)
151
- );
152
- }
153
-
154
- return next;
155
- });
156
- }, [persistCollection]);
154
+ const prev = collectionsRef.current;
155
+ const lookupId = previousId ?? id;
156
+ const idx = prev.findIndex(c => c.slug === lookupId);
157
+ if (idx < 0) return;
158
+
159
+ const merged = { ...prev[idx], ...collectionData } as AdminCollection;
160
+ const next = [...prev];
161
+ next[idx] = merged;
162
+ applyCollections(next);
163
+
164
+ await persistCollection(merged);
165
+
166
+ // If the slug changed, clean up the old entry in the store. After the
167
+ // save, not beside it: dropping the old key first and then failing to
168
+ // write the new one loses the collection outright.
169
+ if (previousId && previousId !== id) {
170
+ await storeRef.current.delete(previousId);
171
+ }
172
+ }, [persistCollection, applyCollections]);
157
173
 
158
174
  const deleteCollection = useCallback(async ({ id }: DeleteCollectionParams) => {
159
175
  await storeRef.current.delete(id);
160
- setCollections(prev => prev.filter(c => c.slug !== id));
161
- }, []);
176
+ applyCollections(collectionsRef.current.filter(c => c.slug !== id));
177
+ }, [applyCollections]);
162
178
 
163
179
  const saveProperty = useCallback(async ({
164
180
  path,
@@ -166,56 +182,48 @@ export function useJsonCollectionsConfigController({
166
182
  property,
167
183
  newPropertiesOrder,
168
184
  }: SavePropertyParams) => {
169
- setCollections(prev => {
170
- const idx = prev.findIndex(c => c.slug === path);
171
- if (idx < 0) return prev;
172
-
173
- const collection = { ...prev[idx] };
174
- collection.properties = {
175
- ...collection.properties,
176
- [propertyKey]: property,
177
- } as EngineProperties;
178
- if (newPropertiesOrder) {
179
- collection.propertiesOrder = newPropertiesOrder as typeof collection.propertiesOrder;
180
- }
181
-
182
- const next = [...prev];
183
- next[idx] = collection as AdminCollection;
185
+ const prev = collectionsRef.current;
186
+ const idx = prev.findIndex(c => c.slug === path);
187
+ if (idx < 0) return;
188
+
189
+ const collection = { ...prev[idx] };
190
+ collection.properties = {
191
+ ...collection.properties,
192
+ [propertyKey]: property,
193
+ } as EngineProperties;
194
+ if (newPropertiesOrder) {
195
+ collection.propertiesOrder = newPropertiesOrder as typeof collection.propertiesOrder;
196
+ }
184
197
 
185
- persistCollection(next[idx]).catch(e =>
186
- console.error("useJsonCollectionsConfigController: failed to save property", e)
187
- );
198
+ const next = [...prev];
199
+ next[idx] = collection as AdminCollection;
200
+ applyCollections(next);
188
201
 
189
- return next;
190
- });
191
- }, [persistCollection]);
202
+ await persistCollection(next[idx]);
203
+ }, [persistCollection, applyCollections]);
192
204
 
193
205
  const deleteProperty = useCallback(async ({
194
206
  path,
195
207
  propertyKey,
196
208
  newPropertiesOrder,
197
209
  }: DeletePropertyParams) => {
198
- setCollections(prev => {
199
- const idx = prev.findIndex(c => c.slug === path);
200
- if (idx < 0) return prev;
201
-
202
- const collection = { ...prev[idx] };
203
- const { [propertyKey]: _removed, ...remaining } = collection.properties;
204
- collection.properties = remaining;
205
- if (newPropertiesOrder) {
206
- collection.propertiesOrder = newPropertiesOrder as typeof collection.propertiesOrder;
207
- }
208
-
209
- const next = [...prev];
210
- next[idx] = collection as AdminCollection;
210
+ const prev = collectionsRef.current;
211
+ const idx = prev.findIndex(c => c.slug === path);
212
+ if (idx < 0) return;
213
+
214
+ const collection = { ...prev[idx] };
215
+ const { [propertyKey]: _removed, ...remaining } = collection.properties;
216
+ collection.properties = remaining;
217
+ if (newPropertiesOrder) {
218
+ collection.propertiesOrder = newPropertiesOrder as typeof collection.propertiesOrder;
219
+ }
211
220
 
212
- persistCollection(next[idx]).catch(e =>
213
- console.error("useJsonCollectionsConfigController: failed to delete property", e)
214
- );
221
+ const next = [...prev];
222
+ next[idx] = collection as AdminCollection;
223
+ applyCollections(next);
215
224
 
216
- return next;
217
- });
218
- }, [persistCollection]);
225
+ await persistCollection(next[idx]);
226
+ }, [persistCollection, applyCollections]);
219
227
 
220
228
  const updatePropertiesOrder = useCallback(async ({
221
229
  collection,
@@ -226,16 +234,16 @@ export function useJsonCollectionsConfigController({
226
234
  propertiesOrder: newPropertiesOrder,
227
235
  } as AdminCollection;
228
236
 
229
- setCollections(prev => {
230
- const idx = prev.findIndex(c => c.slug === collection.slug);
231
- if (idx < 0) return prev;
237
+ const prev = collectionsRef.current;
238
+ const idx = prev.findIndex(c => c.slug === collection.slug);
239
+ if (idx >= 0) {
232
240
  const next = [...prev];
233
241
  next[idx] = updated;
234
- return next;
235
- });
242
+ applyCollections(next);
243
+ }
236
244
 
237
245
  await persistCollection(updated);
238
- }, [persistCollection]);
246
+ }, [persistCollection, applyCollections]);
239
247
 
240
248
  const updateKanbanColumnsOrder = useCallback(async ({
241
249
  collection,
@@ -202,13 +202,18 @@ collectionData });
202
202
  await request("/collection/delete", { collectionId: id });
203
203
  },
204
204
 
205
+ // The follow-up write carries exactly one key, and `partial` is what says
206
+ // so. Read as a whole-collection save it deletes everything it does not
207
+ // mention — `securityRules` first, which then falls back to the
208
+ // directory default and widens who can read the collection.
205
209
  saveProperty: async ({ path, propertyKey, property, newPropertiesOrder }: SavePropertyParams) => {
206
210
  await request("/property/save", { collectionId: path,
207
211
  propertyKey,
208
212
  propertyConfig: property });
209
213
  if (newPropertiesOrder) {
210
214
  await request("/collection/save", { collectionId: path,
211
- collectionData: { propertiesOrder: newPropertiesOrder } });
215
+ collectionData: { propertiesOrder: newPropertiesOrder },
216
+ partial: true });
212
217
  }
213
218
  },
214
219
  deleteProperty: async ({ path, propertyKey, newPropertiesOrder }: DeletePropertyParams) => {
@@ -216,14 +221,16 @@ collectionData: { propertiesOrder: newPropertiesOrder } });
216
221
  propertyKey });
217
222
  if (newPropertiesOrder) {
218
223
  await request("/collection/save", { collectionId: path,
219
- collectionData: { propertiesOrder: newPropertiesOrder } });
224
+ collectionData: { propertiesOrder: newPropertiesOrder },
225
+ partial: true });
220
226
  }
221
227
  },
222
228
 
223
229
  updatePropertiesOrder: async ({ collection, fullPath, newPropertiesOrder }: UpdatePropertiesOrderParams) => {
224
230
  const collectionId = (collection as AdminCollection & { id?: string }).id || fullPath.split("/").pop();
225
231
  await request("/collection/save", { collectionId,
226
- collectionData: { propertiesOrder: newPropertiesOrder } });
232
+ collectionData: { propertiesOrder: newPropertiesOrder },
233
+ partial: true });
227
234
  },
228
235
  updateKanbanColumnsOrder: async () => {
229
236
  // Kanban order mapping logic can be added later if needed natively.
@@ -1,13 +1,20 @@
1
- import { Button, FilterXIcon, iconSize, Tooltip } from "@rebasepro/ui";
1
+ import { FilterXIcon, iconSize, IconButton, Tooltip } from "@rebasepro/ui";
2
2
  import { EntityTableController } from "@rebasepro/admin-types";
3
3
  import { useTranslation } from "@rebasepro/app";
4
4
 
5
5
  export function ClearFilterSortButton({
6
6
  tableController,
7
- enabled
7
+ enabled,
8
+ compact
8
9
  }: {
9
10
  enabled: boolean;
10
- tableController: EntityTableController
11
+ tableController: EntityTableController;
12
+ /**
13
+ * Toolbar showing icon-only controls (split view, or a layout too narrow
14
+ * for the filters button's label). Only decides the icon size, which
15
+ * follows that button — see `CollectionViewStartActions`.
16
+ */
17
+ compact?: boolean;
11
18
  }) {
12
19
  const { t } = useTranslation();
13
20
 
@@ -29,17 +36,19 @@ export function ClearFilterSortButton({
29
36
  }
30
37
  return (
31
38
  <Tooltip title={label}>
32
- <Button
39
+ {/* An `IconButton`, like every other icon-only control in this
40
+ toolbar. As a `Button` it was a rounded-md box with text
41
+ padding sitting between two circular icon buttons. */}
42
+ <IconButton
33
43
  size={"small"}
34
- variant={"text"}
35
44
  aria-label={label}
36
45
  onClick={() => {
37
46
  tableController.clearFilter?.();
38
47
  tableController.setSortBy?.(undefined);
39
48
  }}
40
49
  >
41
- <FilterXIcon size={iconSize.small}/>
42
- </Button>
50
+ <FilterXIcon size={compact ? iconSize.smallest : iconSize.small}/>
51
+ </IconButton>
43
52
  </Tooltip>
44
53
  );
45
54
  }
@@ -240,7 +240,13 @@ export function getTableBindingForProperty(
240
240
  Component: RelationDialogBindingComponent,
241
241
  allowScroll: false
242
242
  };
243
- } else {
243
+ } else if (selected) {
244
+ // Gated on `selected`, like the number and string editors above.
245
+ // This one mounts a live picker against the *target* collection,
246
+ // and the cell is rendered once per visible row: ungated, opening
247
+ // a table with one relation column fired a request — or, on the
248
+ // realtime path, a subscription — per row, for data nobody had
249
+ // asked to see. Unselected cells fall through to PropertyPreview.
244
250
  return {
245
251
  Component: RelationSelectorBindingComponent,
246
252
  allowScroll: false
@@ -1,6 +1,6 @@
1
1
 
2
2
  import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
- import { Entity, EnumValueConfig } from "@rebasepro/types";
3
+ import { Entity, EnumValueConfig, MAX_LIST_LIMIT } from "@rebasepro/types";
4
4
  import { EntityTableController, SelectionController, AdminCollection } from "@rebasepro/admin-types";
5
5
  import { BoardCardBinding } from "./BoardCardBinding";
6
6
  import {
@@ -376,13 +376,19 @@ parentEntityIds,
376
376
  setBackfillLoading(true);
377
377
 
378
378
  try {
379
- // Fetch ALL documents from collection (not relying on loaded
380
- // entities). No `orderBy`: with no usable order key there is
381
- // nothing to order by, and this is the same query the columns
382
- // themselves run, so the keys are handed out in the order the
383
- // board is showing — which is what the dialog promises.
379
+ // Fetch the documents to key (not relying on loaded entities). No
380
+ // `orderBy`: with no usable order key there is nothing to order by,
381
+ // and this is the same query the columns themselves run, so the
382
+ // keys are handed out in the order the board is showing — which is
383
+ // what the dialog promises.
384
+ //
385
+ // `MAX_LIST_LIMIT`, not a made-up 10 000: that is the most a single
386
+ // read serves, and asking for more is now refused outright rather
387
+ // than quietly answered with the first thousand. A board past the
388
+ // ceiling still gets only its first page backfilled — the same rows
389
+ // as before this became explicit — and wants a paged walk.
384
390
  const allDocsRes = await dataClient.collection(fullPath).find({
385
- limit: 10000 // Fetch all
391
+ limit: MAX_LIST_LIMIT
386
392
  });
387
393
  const allDocs = allDocsRes.data as Entity<M>[];
388
394
 
@@ -1477,6 +1477,7 @@ function EntityIdHeaderWidget({
1477
1477
  <div className="flex p-2 w-full gap-2">
1478
1478
  <TextField
1479
1479
  autoFocus={openPopup}
1480
+ aria-label={t("find_entity_by_id")}
1480
1481
  placeholder={t("find_entity_by_id")}
1481
1482
  size="small"
1482
1483
  onChange={(e) => {
@@ -104,6 +104,13 @@ parentEntityIds,
104
104
  </Tooltip>
105
105
  );
106
106
 
107
+ // Whether the toolbar is showing icon-only controls. The filters button
108
+ // drops its label — and its icon a size with it — on a narrow layout as
109
+ // well as in the split view, and the sort and clear controls beside it
110
+ // follow: keyed on `compact` alone they stayed 20px next to a 16px filter
111
+ // icon at every width below `largeLayout`.
112
+ const iconOnlyToolbar = !largeLayout || Boolean(compact);
113
+
107
114
  const filtersButton = resolvedProperties && tableController.setFilterValues && (
108
115
  <Tooltip title={t("filters")}
109
116
  key={"filters_tooltip"}>
@@ -147,6 +154,7 @@ parentEntityIds,
147
154
  key={"sort_button"}
148
155
  tableController={tableController}
149
156
  properties={resolvedProperties}
157
+ compact={iconOnlyToolbar}
150
158
  />
151
159
  );
152
160
 
@@ -170,6 +178,7 @@ parentEntityIds,
170
178
  <ClearFilterSortButton
171
179
  key={"clear_filter"}
172
180
  tableController={tableController}
181
+ compact={iconOnlyToolbar}
173
182
  enabled={!collection.fixedFilter}/>,
174
183
  filterPresetsButton
175
184
  ];