@rebasepro/admin 0.15.0 → 0.16.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 (40) hide show
  1. package/dist/{CollectionEditorDialog-Dxx0ZaDk.js → CollectionEditorDialog-D2Vf4C3-.js} +7 -7
  2. package/dist/{CollectionEditorDialog-Dxx0ZaDk.js.map → CollectionEditorDialog-D2Vf4C3-.js.map} +1 -1
  3. package/dist/{PropertyEditView-D6xNTm0d.js → PropertyEditView-D3NBsK9_.js} +4 -13
  4. package/dist/PropertyEditView-D3NBsK9_.js.map +1 -0
  5. package/dist/{RouterCollectionsStudioView-C4vSn9Pu.js → RouterCollectionsStudioView-hPB54l3Q.js} +4 -4
  6. package/dist/{RouterCollectionsStudioView-C4vSn9Pu.js.map → RouterCollectionsStudioView-hPB54l3Q.js.map} +1 -1
  7. package/dist/collection_editor_ui.js +4 -4
  8. package/dist/components/CollectionViewBinding/CollectionViewStartActions.d.ts +9 -1
  9. package/dist/components/RelationSelector.d.ts +8 -0
  10. package/dist/{export-BZi1MzQ5.js → export-oauWlxuu.js} +2 -2
  11. package/dist/{export-BZi1MzQ5.js.map → export-oauWlxuu.js.map} +1 -1
  12. package/dist/form/useUndoableDiscard.d.ts +15 -0
  13. package/dist/{history-B5pkfldE.js → history-C2cqc0bw.js} +2 -2
  14. package/dist/{history-B5pkfldE.js.map → history-C2cqc0bw.js.map} +1 -1
  15. package/dist/{import-CXpuYo-T.js → import-Bxv5JPm-.js} +2 -2
  16. package/dist/{import-CXpuYo-T.js.map → import-Bxv5JPm-.js.map} +1 -1
  17. package/dist/index.js +17 -25
  18. package/dist/index.js.map +1 -1
  19. package/dist/{util-BKtHPLe4.js → util-C4qsI1QV.js} +163 -55
  20. package/dist/util-C4qsI1QV.js.map +1 -0
  21. package/package.json +12 -9
  22. package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +4 -4
  23. package/src/components/CollectionPanel.tsx +1 -1
  24. package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +1 -1
  25. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +3 -11
  26. package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +32 -2
  27. package/src/components/CollectionViewBinding/EntityCardBinding.tsx +10 -8
  28. package/src/components/DefaultAppBar.tsx +0 -10
  29. package/src/components/DetailViewBinding.tsx +18 -1
  30. package/src/components/EditViewBinding.tsx +26 -2
  31. package/src/components/ReferenceTable/SelectionTableBinding.tsx +10 -1
  32. package/src/components/RelationSelector.tsx +115 -3
  33. package/src/contexts/BreacrumbsContext.tsx +4 -21
  34. package/src/form/EntityForm.tsx +8 -3
  35. package/src/form/components/FormRail.tsx +15 -4
  36. package/src/form/useUndoableDiscard.ts +39 -0
  37. package/src/preview/components/StorageThumbnail.tsx +35 -1
  38. package/src/routes/RebaseRoute.tsx +2 -10
  39. package/dist/PropertyEditView-D6xNTm0d.js.map +0 -1
  40. package/dist/util-BKtHPLe4.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.15.0",
4
+ "version": "0.16.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/admin-types": "0.15.0",
87
- "@rebasepro/common": "0.15.0",
88
- "@rebasepro/forms": "0.15.0",
89
- "@rebasepro/app": "0.15.0",
90
- "@rebasepro/types": "0.15.0",
91
- "@rebasepro/ui": "0.15.0",
92
- "@rebasepro/inference": "0.15.0",
93
- "@rebasepro/utils": "0.15.0"
86
+ "@rebasepro/admin-types": "0.16.0",
87
+ "@rebasepro/app": "0.16.0",
88
+ "@rebasepro/common": "0.16.0",
89
+ "@rebasepro/forms": "0.16.0",
90
+ "@rebasepro/inference": "0.16.0",
91
+ "@rebasepro/ui": "0.16.0",
92
+ "@rebasepro/types": "0.16.0",
93
+ "@rebasepro/utils": "0.16.0"
94
94
  },
95
95
  "peerDependencies": {
96
96
  "react": ">=19.2.7",
@@ -154,6 +154,9 @@
154
154
  "moduleNameMapper": {
155
155
  "\\.(css|less)$": "<rootDir>/test/__mocks__/styleMock.js",
156
156
  "^@rebasepro/([a-z0-9-]+)$": "<rootDir>/../$1/src/index.ts",
157
+ "^react$": "<rootDir>/node_modules/react",
158
+ "^react-dom$": "<rootDir>/node_modules/react-dom",
159
+ "^react/jsx-runtime$": "<rootDir>/node_modules/react/jsx-runtime",
157
160
  "^@rebasepro/admin-types$": "<rootDir>/../admin-types/src/index.ts"
158
161
  }
159
162
  },
@@ -42,7 +42,7 @@ import { useFormex } from "@rebasepro/forms";
42
42
  import { useCollectionsConfigController } from "../../useCollectionsConfigController";
43
43
  import { useRebaseContext, useTranslation } from "@rebasepro/app";
44
44
  import type { AdminCollection } from "@rebasepro/admin-types";
45
- import type { PostgresPolicy, SecurityOperation, SecurityRule } from "@rebasepro/types";
45
+ import { RLS_ROLES_SQL, RLS_UID_SQL, type PostgresPolicy, type SecurityOperation, type SecurityRule } from "@rebasepro/types";
46
46
 
47
47
  export type { PostgresPolicy } from "@rebasepro/types";
48
48
 
@@ -466,7 +466,7 @@ function InlinePolicyEditor({
466
466
  <div className="flex flex-col gap-1.5">
467
467
  <Typography variant="caption" className="uppercase tracking-wider text-text-secondary">Application Roles</Typography>
468
468
  <Typography variant="caption" className="text-text-secondary opacity-80 -mt-1">
469
- Roles held by users of this project, matched via <span className="font-mono">auth.roles()</span>.
469
+ Roles held by users of this project, matched via <span className="font-mono">{RLS_ROLES_SQL}</span>.
470
470
  These are not PostgreSQL roles — leave empty to apply the policy to everyone.
471
471
  </Typography>
472
472
  {roleOptions.length > 0 && (
@@ -503,13 +503,13 @@ function InlinePolicyEditor({
503
503
  {command !== "INSERT" && (
504
504
  <div className="flex flex-col gap-1.5">
505
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
+ <TextField aria-label={t("studio_policy_using_expr")} value={usingExpr} onChange={(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setUsingExpr(e.target.value)} placeholder={`e.g. ${RLS_UID_SQL} = uid`}/>
507
507
  </div>
508
508
  )}
509
509
  {showCheck && (
510
510
  <div className="flex flex-col gap-1.5">
511
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
+ <TextField aria-label={t("studio_policy_check_expr")} value={checkExpr} onChange={(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setCheckExpr(e.target.value)} placeholder={`e.g. ${RLS_UID_SQL} = uid`}/>
513
513
  </div>
514
514
  )}
515
515
  </Paper>
@@ -110,7 +110,7 @@ function CollectionPanelInner({
110
110
  {title !== false && (
111
111
  <Typography
112
112
  variant="subtitle2"
113
- className="font-bold mb-2 text-surface-700 dark:text-surface-300"
113
+ className="font-semibold mb-2 text-surface-700 dark:text-surface-300"
114
114
  >
115
115
  {title ?? mergedCollection.name}
116
116
  </Typography>
@@ -1385,7 +1385,7 @@ function ListHeaderLabel({
1385
1385
  {direction === "asc" && <ArrowUpIcon size={12} className="flex-shrink-0"/>}
1386
1386
  {direction === "desc" && <ArrowDownIcon size={12} className="flex-shrink-0"/>}
1387
1387
  {direction && position !== undefined && (
1388
- <span className="flex-shrink-0 text-[9px] font-bold leading-none tabular-nums">
1388
+ <span className="flex-shrink-0 text-[9px] font-semibold leading-none tabular-nums">
1389
1389
  {position + 1}
1390
1390
  </span>
1391
1391
  )}
@@ -77,7 +77,6 @@ import { CollectionViewStartActions } from "./CollectionViewStartActions";
77
77
  import { addRecentId, getRecentIds } from "./utils";
78
78
  import { isViewMode, OpenEntityMode, resolveOpenEntityMode, VIEW_MODE_PARAM, VIEW_MODES } from "../../util/view_mode";
79
79
  import { mergeDeep } from "@rebasepro/utils";
80
- import { useBreadcrumbsController } from "../../hooks/useBreadcrumbsController";
81
80
  import { useAdminContext } from "../../hooks/useAdminContext";
82
81
  import { useCollectionRegistryController } from "../../hooks/navigation/contexts/CollectionRegistryContext";
83
82
  import { useSidePanel } from "../../hooks/useSidePanel";
@@ -173,7 +172,6 @@ const CollectionViewBindingInner = React.memo(
173
172
  const context = useAdminContext();
174
173
  const collectionRegistry = useCollectionRegistryController();
175
174
  const urlController = useUrlController();
176
- const breadcrumbs = useBreadcrumbsController();
177
175
  const path = pathProp ?? getCollectionDataPath(collectionProp);
178
176
  const dataClient = useData();
179
177
  const sidePanelController = useSidePanel();
@@ -876,14 +874,7 @@ parentEntityIds: parentEntityIds ?? EMPTY_ARRAY,
876
874
 
877
875
  }, [updateLastDeleteTimestamp, usedSelectionController]);
878
876
 
879
- // Update breadcrumb count when count changes
880
- const updateCountRef = React.useRef(breadcrumbs.updateCount);
881
- updateCountRef.current = breadcrumbs.updateCount;
882
- useEffect(() => {
883
- updateCountRef.current(path, docsCount);
884
- }, [docsCount, path]);
885
-
886
- // EntitiesCount fetches count and updates breadcrumb - no visual rendering needed here
877
+ // EntitiesCount fetches the count and reports it — the toolbar renders it
887
878
  const countFetcher = <EntitiesCount
888
879
  path={path}
889
880
  collection={collection}
@@ -1039,6 +1030,7 @@ parentEntityIds,
1039
1030
  collectionEntitiesCount={docsCount ?? undefined}
1040
1031
  resolvedProperties={resolvedCollection.properties}
1041
1032
  viewMode={viewMode}
1033
+ entitiesCount={docsCount}
1042
1034
  openNewDocument={openNewDocument}
1043
1035
  compact={isCompact}/>}
1044
1036
  actions={
@@ -1493,7 +1485,7 @@ export function EntitiesCount({
1493
1485
  // eslint-disable-next-line react-hooks/exhaustive-deps
1494
1486
  }, [path, filterKey, sortKey, searchString]);
1495
1487
 
1496
- // Count is now displayed in the breadcrumb bar, this component only fetches and reports
1488
+ // The toolbar renders the count; this component only fetches and reports it
1497
1489
  return null;
1498
1490
  }
1499
1491
 
@@ -4,7 +4,7 @@ import React, { useState, useCallback } from "react";
4
4
  import { useAuthController, useLargeLayout, useTranslation, useSlot } from "@rebasepro/app";
5
5
  import { CollectionActionsProps, EntityTableController, SelectionController, AdminCollection, ViewMode } from "@rebasepro/admin-types";
6
6
  import { ErrorBoundary, iconSize } from "@rebasepro/ui";
7
- import { Badge, Button, cls, FilterIcon, IconButton, Tooltip, XIcon } from "@rebasepro/ui";
7
+ import { Badge, Button, cls, FilterIcon, IconButton, Skeleton, Tooltip, XIcon } from "@rebasepro/ui";
8
8
  import { ClearFilterSortButton } from "../ClearFilterSortButton";
9
9
  import { FiltersDialog } from "./FiltersDialog";
10
10
  import { FilterPresetsButton } from "./FilterPresetsButton";
@@ -24,6 +24,14 @@ export type CollectionViewStartActionsProps<M extends Record<string, unknown>> =
24
24
  selectionController: SelectionController<M>;
25
25
  tableController: EntityTableController<M>;
26
26
  collectionEntitiesCount?: number;
27
+ /**
28
+ * Number of records the current filters and search resolve to, rendered at
29
+ * the end of this group.
30
+ * - `undefined`: not applicable
31
+ * - `null`: still counting
32
+ * - number: the count
33
+ */
34
+ entitiesCount?: number | null;
27
35
  /**
28
36
  * Resolved properties from the collection for the filters dialog
29
37
  */
@@ -45,6 +53,7 @@ export function CollectionViewStartActions<M extends Record<string, unknown>>({
45
53
  selectionController,
46
54
  tableController,
47
55
  collectionEntitiesCount,
56
+ entitiesCount,
48
57
  resolvedProperties,
49
58
  viewMode,
50
59
  compact,
@@ -174,6 +183,26 @@ parentEntityIds,
174
183
  />
175
184
  ) : null;
176
185
 
186
+ // The count closes the group the filter, sort and preset controls open: it
187
+ // is what they resolve to. It used to live in the breadcrumb trail, which
188
+ // the app bar owns — and a collection rendered without an app bar has no
189
+ // breadcrumbs, so the number simply disappeared.
190
+ //
191
+ // Hidden on the same layouts the trail was (`hidden lg:block` there) and in
192
+ // the split view, where this toolbar is a few hundred pixels wide and
193
+ // scrolls: a passive readout is the first thing that should give up its
194
+ // room to the controls.
195
+ const countBadge = !iconOnlyToolbar && entitiesCount !== undefined ? (
196
+ <Tooltip title={t("records_in_view")} key={"entities_count"}>
197
+ {entitiesCount === null
198
+ ? <Skeleton className={"w-8 h-4 rounded-md mx-1"}/>
199
+ : <span
200
+ className={"mx-1 text-xs text-surface-accent-600 dark:text-surface-accent-400 bg-surface-100 dark:bg-surface-800 px-1.5 py-0.5 rounded tabular-nums"}>
201
+ {entitiesCount.toLocaleString()}
202
+ </span>}
203
+ </Tooltip>
204
+ ) : null;
205
+
177
206
  const actions: React.ReactNode[] = [
178
207
  backButton,
179
208
  filtersButton,
@@ -183,7 +212,8 @@ parentEntityIds,
183
212
  tableController={tableController}
184
213
  compact={iconOnlyToolbar}
185
214
  enabled={!collection.fixedFilter}/>,
186
- filterPresetsButton
215
+ filterPresetsButton,
216
+ countBadge
187
217
  ];
188
218
 
189
219
  const pluginActionsStart = useSlot("collection.actions.start", actionProps);
@@ -154,14 +154,16 @@ export function EntityCardBinding<M extends Record<string, unknown> = Record<str
154
154
 
155
155
  {/* Content area */}
156
156
  <div className="p-3">
157
- {/* Entity ID */}
158
- <Typography
159
- variant="caption"
160
- color="disabled"
161
- className="font-mono truncate block"
162
- >
163
- {entity.id}
164
- </Typography>
157
+ {/* No id line here.
158
+ A card used to print the row id above its title, always —
159
+ a truncated uuid (`isId: "uuid"` is the default) too short
160
+ to copy and not what a card is read for. The list row and
161
+ the board card show an id only when nothing else names the
162
+ record; the card was the one surface that showed it even
163
+ when it had a title, and the id is still the fallback below
164
+ for a record that has none. The table's ID column, which is
165
+ full width, copyable and governed by
166
+ `hideIdFromCollection`, is where an id is actually read. */}
165
167
 
166
168
  {/* Title slot */}
167
169
  <div className="truncate my-1 text-sm font-medium min-h-[20px]">
@@ -194,16 +194,6 @@ export const DefaultAppBar = function DefaultAppBar({
194
194
  <Typography variant={"body2"}>
195
195
  {breadcrumb.title}
196
196
  </Typography>
197
- {/* Show count badge for collection breadcrumbs: undefined = not applicable, null = loading, number = count */}
198
- {breadcrumb.count !== undefined && (
199
- breadcrumb.count !== null ? (
200
- <span className="text-xs text-surface-accent-500 dark:text-surface-accent-400 bg-surface-100 dark:bg-surface-800 px-1 py-0 rounded">
201
- {breadcrumb.count}
202
- </span>
203
- ) : (
204
- <Skeleton className="w-8 h-4 rounded-md"/>
205
- )
206
- )}
207
197
  </div>
208
198
  </Link>
209
199
  </React.Fragment>;
@@ -619,6 +619,24 @@ entityId }
619
619
  onSideTabClick(value);
620
620
  }}>
621
621
 
622
+ {/* Before the record, not after it.
623
+
624
+ `position: "start"` used to mean "first among the custom
625
+ views", which put it after a tab it was already after: the
626
+ record's own tab is drawn here unconditionally, so "start"
627
+ and "end" only ever ordered the custom views against each
628
+ other, and no collection could open with anything but the
629
+ form on the left.
630
+
631
+ A cover — a read-only summary of the record, the thing an
632
+ operator opens a row to see — belongs in front of the form
633
+ that edits it. That is what `defaultSelectedView` already
634
+ allowed, and the strip contradicted it by drawing the
635
+ selected tab second. Views that say nothing still land after
636
+ the record, so nothing moves for a collection that never
637
+ asked. */}
638
+ {customViewTabsStart}
639
+
622
640
  <Tab value={MAIN_TAB_VALUE}
623
641
  className={cls(ENTITY_VIEW_TAB_CLASS, ENTITY_VIEW_TAB_LABEL_CLASS)}>
624
642
  <span className="flex items-center gap-1.5">
@@ -627,7 +645,6 @@ entityId }
627
645
  </span>
628
646
  </Tab>
629
647
 
630
- {customViewTabsStart}
631
648
  {customViewTabsEnd}
632
649
  {subcollectionTabs}
633
650
  </Tabs>
@@ -45,6 +45,7 @@ import type { EntityFormBindingProps } from "../form";
45
45
  import type { OnUpdateParams } from "../types/components/EntityFormProps";
46
46
  import { EditFormActions } from "./EditFormActions";
47
47
  import { EntityIdentityBar } from "./EntityIdentityBar";
48
+ import { useUndoableDiscard } from "../form/useUndoableDiscard";
48
49
  import { SplitListCloseButton } from "./CollectionViewBinding/SplitListCloseButton";
49
50
  import { SplitListShowButton } from "./CollectionViewBinding/SplitListShowButton";
50
51
  import { useRecordActions } from "../hooks/useRecordActions";
@@ -295,6 +296,8 @@ export function EditViewBindingInner<M extends Record<string, unknown>>({
295
296
 
296
297
  const largeLayout = useLargeLayout();
297
298
 
299
+ const discard = useUndoableDiscard();
300
+
298
301
  const customizationController = useCustomizationController();
299
302
  const plugins = customizationController.plugins;
300
303
 
@@ -879,7 +882,11 @@ parentEntityIds,
879
882
  // Welded to Save in the split, its own button in the overlays.
880
883
  saveAndClosePlacement={layout === "split" ? "menu" : "button"}
881
884
  onClose={onCloseRequest}
882
- onDiscard={canEdit && formContext ? () => formContext.formex.resetForm() : undefined}
885
+ // Unlike the form's own Discard, this one does not stop to ask —
886
+ // so it has to be reversible. See {@link useUndoableDiscard}.
887
+ onDiscard={canEdit && formContext
888
+ ? () => discard(formContext.formex, status)
889
+ : undefined}
883
890
  onInspect={includeJsonView ? () => setInspectorTab("json") : undefined}
884
891
  onViewHistory={includeHistoryView ? () => setInspectorTab("history") : undefined}
885
892
  externalLink={usedEntity
@@ -930,6 +937,24 @@ parentEntityIds,
930
937
  onSideTabClick(value);
931
938
  }}>
932
939
 
940
+ {/* Before the record, not after it.
941
+
942
+ `position: "start"` used to mean "first among the custom
943
+ views", which put it after a tab it was already after: the
944
+ record's own tab is drawn here unconditionally, so "start"
945
+ and "end" only ever ordered the custom views against each
946
+ other, and no collection could open with anything but the
947
+ form on the left.
948
+
949
+ A cover — a read-only summary of the record, the thing an
950
+ operator opens a row to see — belongs in front of the form
951
+ that edits it. That is what `defaultSelectedView` already
952
+ allowed, and the strip contradicted it by drawing the
953
+ selected tab second. Views that say nothing still land after
954
+ the record, so nothing moves for a collection that never
955
+ asked. */}
956
+ {customViewTabsStart}
957
+
933
958
  <Tab value={MAIN_TAB_VALUE}
934
959
  className={cls(ENTITY_VIEW_TAB_CLASS, ENTITY_VIEW_TAB_LABEL_CLASS)}>
935
960
  <span className="flex items-center gap-1.5">
@@ -938,7 +963,6 @@ parentEntityIds,
938
963
  </span>
939
964
  </Tab>
940
965
 
941
- {customViewTabsStart}
942
966
 
943
967
  {customViewTabsEnd}
944
968
 
@@ -225,7 +225,16 @@ function SelectionTableBindingInternal<M extends Record<string, unknown>>(
225
225
  sidePanelController.open({
226
226
  path: path,
227
227
  collection,
228
- updateUrl: true,
228
+ // No URL, for the reason `RelationSelector` does the same: this
229
+ // panel is a step inside the selection dialog, which is itself a
230
+ // step inside whatever form opened it. `useUnsavedChangesDialog`
231
+ // blocks on a *pathname* change, and this panel's path is a
232
+ // different collection from the one being edited — so closing it
233
+ // raced the panel clearing its own dirty flag and answered a
234
+ // successful save with "There are unsaved changes", leaving the URL
235
+ // on the target collection. A record that does not exist yet has no
236
+ // address worth restoring anyway.
237
+ updateUrl: false,
229
238
  onUpdate: ({ entity }) => {
230
239
  setEntitiesDisplayedFirst([entity, ...entitiesDisplayedFirst]);
231
240
  onEntityClick(entity);
@@ -14,6 +14,7 @@ import {
14
14
  focusedDisabled,
15
15
  IconButton,
16
16
  iconSize,
17
+ PlusIcon,
17
18
  PopoverPrimitive,
18
19
  SearchIcon,
19
20
  Separator,
@@ -26,7 +27,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
26
27
  import { Command as CommandPrimitive } from "cmdk";
27
28
  import { Entity, EntityRelation, FilterValues, Relation, getCollectionDataPath } from "@rebasepro/types";
28
29
  import { EntityPreviewBindingData } from "./EntityPreviewBinding";
29
- import { useData, useRelationSelector } from "@rebasepro/app";
30
+ import { getTitlePropertyKey, useData, usePermissions, useRelationSelector, useTranslation } from "@rebasepro/app";
30
31
  import { useSidePanel } from "../hooks/useSidePanel";
31
32
  import { normalizeToEntityRelation } from "@rebasepro/common";
32
33
 
@@ -105,6 +106,14 @@ export interface RelationSelectorProps {
105
106
  */
106
107
  emptyCollectionText?: string;
107
108
  loadingText?: string;
109
+ /**
110
+ * Whether the open list offers to create a row in the target collection.
111
+ * Defaults to `true`. The offer is shown only when the user could actually
112
+ * insert into that collection — the same permission check the selection
113
+ * dialog's "Add" button makes — so turning this off is for the cases where
114
+ * creating is possible but not wanted, like a filter.
115
+ */
116
+ allowCreate?: boolean;
108
117
  }
109
118
 
110
119
  export const RelationSelector = React.forwardRef<
@@ -130,14 +139,18 @@ export const RelationSelector = React.forwardRef<
130
139
  noResultsText = "No matches.",
131
140
  emptyText = "Select…",
132
141
  emptyCollectionText,
133
- loadingText = "Loading..."
142
+ loadingText = "Loading...",
143
+ allowCreate = true
134
144
  },
135
145
  ref
136
146
  ) => {
137
147
 
138
148
  const collection = relation.target();
149
+ const dataPath = getCollectionDataPath(collection);
139
150
  const dataClient = useData();
140
151
  const sidePanelController = useSidePanel();
152
+ const { canCreate } = usePermissions();
153
+ const { t } = useTranslation();
141
154
  const contextPortalContainer = usePortalContainer();
142
155
  const multiple = multipleOverride ?? relationCardinality(relation) === "many";
143
156
 
@@ -167,7 +180,7 @@ export const RelationSelector = React.forwardRef<
167
180
  loadMore,
168
181
  entityToRelationItem
169
182
  } = useRelationSelector({
170
- path: getCollectionDataPath(collection),
183
+ path: dataPath,
171
184
  collection,
172
185
  fixedFilter,
173
186
  pageSize,
@@ -393,6 +406,13 @@ relation } as RelationItem;
393
406
  else onValueChange?.(selected[0]?.relation);
394
407
  }, [onValueChange, multiple]);
395
408
 
409
+ // The picker can hand its selection over long after the render that
410
+ // built the callback — a row created in the side panel arrives whenever
411
+ // the person saves it — so the emitter is read from a ref rather than
412
+ // captured.
413
+ const emitValueChangeRef = useRef(emitValueChange);
414
+ emitValueChangeRef.current = emitValueChange;
415
+
396
416
  // Helper: compute the "IDs fingerprint" for a set of RelationItems
397
417
  // so we can mark it in localSelectionIdsRef and skip re-resolution.
398
418
  const computeSelectionFingerprint = useCallback((items: RelationItem[]): string => {
@@ -483,6 +503,73 @@ relation } as RelationItem;
483
503
  pinnedIdsRef.current = null;
484
504
  }, []);
485
505
 
506
+ // Pointing at a row that does not exist yet.
507
+ //
508
+ // Until now the only way out of that was to abandon the form, go to the
509
+ // other collection, create the row, come back and start again — and on
510
+ // a new record that meant losing everything typed so far. The side
511
+ // panel already stacks (the selection dialog creates from inside itself
512
+ // this way), so the picker can open a create form over the form it
513
+ // belongs to and select what comes back.
514
+ const canCreateTarget = allowCreate && canCreate(collection, dataPath);
515
+
516
+ // A search that matched nothing is the name the person was looking for,
517
+ // so it seeds the new row. Only when the title lands on a plain string
518
+ // property: putting free text into an enum, a relation or a computed
519
+ // property would be worse than not prefilling at all.
520
+ const buildDefaultValues = useCallback((searchText: string): Record<string, unknown> | undefined => {
521
+ const trimmed = searchText.trim();
522
+ if (!trimmed) return undefined;
523
+ const titleKey = getTitlePropertyKey(collection);
524
+ if (!titleKey || titleKey.includes(".")) return undefined;
525
+ const titleProperty = collection.properties?.[titleKey];
526
+ // A property builder is a function, and it decides its own value.
527
+ if (!titleProperty || typeof titleProperty === "function") return undefined;
528
+ if (titleProperty.type !== "string" || titleProperty.enum) return undefined;
529
+ return { [titleKey]: trimmed };
530
+ }, [collection]);
531
+
532
+ const handleCreateNew = useCallback(() => {
533
+ const defaultValues = buildDefaultValues(searchString);
534
+ closePopover();
535
+ sidePanelController.open({
536
+ path: dataPath,
537
+ collection,
538
+ // No URL. This panel is a detour inside the form you are
539
+ // filling in, not somewhere you navigated to, and its address
540
+ // would restore nothing anyway — a record that does not exist
541
+ // yet has none.
542
+ //
543
+ // Pushing one made closing it a *pathname* change, which is
544
+ // exactly what the unsaved-changes blocker watches
545
+ // (`useUnsavedChangesDialog`). So saving the new row raced the
546
+ // panel clearing its own dirty flag, and often enough lost:
547
+ // "There are unsaved changes" over a record that had just been
548
+ // saved, with the URL stranded on the target collection. Worst
549
+ // of all in the case this feature exists for — the outer form
550
+ // has content in it, which is the whole reason not to leave.
551
+ updateUrl: false,
552
+ // Saving is the whole point of the detour; the panel goes away
553
+ // and the picker is left holding the row it made.
554
+ closeOnSave: true,
555
+ defaultValues,
556
+ onUpdate: ({ entity }) => {
557
+ if (!entity) return;
558
+ const item = entityToRelationItemRef.current(entity, new EntityRelation(entity.id, dataPath));
559
+ const current = selectedItemsRef.current;
560
+ const newSelected = multiple
561
+ ? [...current.filter(i => String(i.id) !== String(item.id)), item]
562
+ : [item];
563
+ setSelectedItems(newSelected);
564
+ // Same bookkeeping `onItemClick` does: mark the fingerprint
565
+ // so the resolution effect does not re-fetch the row we
566
+ // already hold when the form echoes the value back.
567
+ localSelectionIdsRef.current = newSelected.map(i => String(i.id)).sort().join(",");
568
+ emitValueChangeRef.current(newSelected);
569
+ }
570
+ });
571
+ }, [buildDefaultValues, searchString, closePopover, sidePanelController, dataPath, collection, multiple]);
572
+
486
573
  // Text, not `EmptyValue`. That component draws a small grey rounded bar
487
574
  // with no text in it, which is the same shape the `Skeleton` component
488
575
  // uses while data is in flight — so a relation you simply had not filled
@@ -787,6 +874,31 @@ relation } as RelationItem;
787
874
  )}
788
875
  </CommandPrimitive.Group>
789
876
  </CommandPrimitive.List>
877
+ {canCreateTarget && <>
878
+ <Separator orientation="horizontal" className="my-0"/>
879
+ {/* Outside the scrolling list on purpose: it
880
+ is most wanted exactly when the list is
881
+ long and nothing in it matched. */}
882
+ <button
883
+ type="button"
884
+ data-relation-selector-create
885
+ onMouseDown={(e) => {
886
+ e.preventDefault();
887
+ e.stopPropagation();
888
+ }}
889
+ onClick={handleCreateNew}
890
+ className={cls(
891
+ "w-full flex flex-row items-center gap-2 px-4 py-3 text-sm text-left",
892
+ "text-primary hover:bg-surface-accent-50 dark:hover:bg-surface-800"
893
+ )}>
894
+ <PlusIcon size={iconSize.smallest}/>
895
+ <span className="truncate">
896
+ {searchString.trim()
897
+ ? t("add_named", { name: searchString.trim() })
898
+ : t("add_specific", { name: collection.singularName ?? collection.name })}
899
+ </span>
900
+ </button>
901
+ </>}
790
902
  </CommandPrimitive>
791
903
  </PopoverPrimitive.Content>
792
904
  </PopoverPrimitive.Portal>
@@ -4,8 +4,6 @@ import type { BreadcrumbEntry, BreadcrumbsController } from "@rebasepro/admin-ty
4
4
  const DEFAULT_BREADCRUMBS_CONTROLLER: BreadcrumbsController = {
5
5
  breadcrumbs: [],
6
6
  set: () => {
7
- },
8
- updateCount: () => {
9
7
  }
10
8
  };
11
9
 
@@ -23,17 +21,10 @@ export const BreadcrumbsProvider: React.FC<BreadcrumbsProviderProps> = ({ childr
23
21
  breadcrumbs: BreadcrumbEntry[];
24
22
  }) => {
25
23
  setBreadcrumbs(prev => {
26
- const next = props.breadcrumbs.map(newEntry => {
27
- const prevEntry = newEntry.id ? prev.find(p => p.id === newEntry.id) : undefined;
28
- if (prevEntry && newEntry.count === null && typeof prevEntry.count === "number") {
29
- return { ...newEntry,
30
- count: prevEntry.count };
31
- }
32
- return newEntry;
33
- });
24
+ const next = props.breadcrumbs;
34
25
  // Bail out if nothing changed — return same reference to skip re-render
35
26
  if (prev.length === next.length && prev.every((p, i) =>
36
- p.title === next[i].title && p.url === next[i].url && p.id === next[i].id && p.count === next[i].count
27
+ p.title === next[i].title && p.url === next[i].url && p.id === next[i].id
37
28
  )) {
38
29
  return prev;
39
30
  }
@@ -41,18 +32,10 @@ count: prevEntry.count };
41
32
  });
42
33
  }, []);
43
34
 
44
- const updateCount = useCallback((id: string, count: number | null | undefined) => {
45
- setBreadcrumbs(prev => prev.map(entry =>
46
- entry.id === id ? { ...entry,
47
- count } : entry
48
- ));
49
- }, []);
50
-
51
35
  const value = useMemo(() => ({
52
36
  breadcrumbs,
53
- set,
54
- updateCount
55
- }), [breadcrumbs, set, updateCount]);
37
+ set
38
+ }), [breadcrumbs, set]);
56
39
 
57
40
  return (
58
41
  <BreadcrumbContext.Provider value={value}>
@@ -28,6 +28,7 @@ import { CustomFieldValidator, getEntitySchema } from "./validation";
28
28
  import { EntityFormActions } from "./EntityFormActions";
29
29
  import type { EntityFormActionsProps } from "../types/components/EntityFormActionsProps";
30
30
  import { LocalChangesMenu } from "./components/LocalChangesMenu";
31
+ import { useUndoableDiscard } from "./useUndoableDiscard";
31
32
 
32
33
  import { mergeDeep } from "@rebasepro/utils";
33
34
  import {
@@ -121,6 +122,8 @@ export function EntityForm<M extends Record<string, unknown>>({
121
122
  const [isSavingAutoSave, setIsSavingAutoSave] = useState<boolean>(false);
122
123
  const [discardDialogOpen, setDiscardDialogOpen] = useState<boolean>(false);
123
124
 
125
+ const discard = useUndoableDiscard();
126
+
124
127
  const autoSave = collection.formAutoSave;
125
128
 
126
129
  // Use externally computed initial values if provided, otherwise compute from entity + collection
@@ -618,8 +621,10 @@ export function EntityForm<M extends Record<string, unknown>>({
618
621
  <DialogContent>
619
622
  <Typography>
620
623
  {status === "existing"
621
- ? "All unsaved changes will be lost. This cannot be undone."
622
- : "All entered values will be cleared. This cannot be undone."}
624
+ ? "All unsaved changes will be lost. "
625
+ : "All entered values will be cleared. "}
626
+ You can bring them back with Undo (⌘Z / Ctrl+Z), or from
627
+ the notification, for as long as this form stays open.
623
628
  </Typography>
624
629
  </DialogContent>
625
630
  <DialogActions>
@@ -628,7 +633,7 @@ export function EntityForm<M extends Record<string, unknown>>({
628
633
  </Button>
629
634
  <Button variant={"filled"} color={"error"} onClick={() => {
630
635
  setDiscardDialogOpen(false);
631
- formex.resetForm({ values: baseInitialValues as M });
636
+ discard(formex, status, baseInitialValues);
632
637
  }}>
633
638
  {status === "existing" ? "Discard" : "Clear"}
634
639
  </Button>