@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.
- package/dist/{CollectionEditorDialog-Dxx0ZaDk.js → CollectionEditorDialog-D2Vf4C3-.js} +7 -7
- package/dist/{CollectionEditorDialog-Dxx0ZaDk.js.map → CollectionEditorDialog-D2Vf4C3-.js.map} +1 -1
- package/dist/{PropertyEditView-D6xNTm0d.js → PropertyEditView-D3NBsK9_.js} +4 -13
- package/dist/PropertyEditView-D3NBsK9_.js.map +1 -0
- package/dist/{RouterCollectionsStudioView-C4vSn9Pu.js → RouterCollectionsStudioView-hPB54l3Q.js} +4 -4
- package/dist/{RouterCollectionsStudioView-C4vSn9Pu.js.map → RouterCollectionsStudioView-hPB54l3Q.js.map} +1 -1
- package/dist/collection_editor_ui.js +4 -4
- package/dist/components/CollectionViewBinding/CollectionViewStartActions.d.ts +9 -1
- package/dist/components/RelationSelector.d.ts +8 -0
- package/dist/{export-BZi1MzQ5.js → export-oauWlxuu.js} +2 -2
- package/dist/{export-BZi1MzQ5.js.map → export-oauWlxuu.js.map} +1 -1
- package/dist/form/useUndoableDiscard.d.ts +15 -0
- package/dist/{history-B5pkfldE.js → history-C2cqc0bw.js} +2 -2
- package/dist/{history-B5pkfldE.js.map → history-C2cqc0bw.js.map} +1 -1
- package/dist/{import-CXpuYo-T.js → import-Bxv5JPm-.js} +2 -2
- package/dist/{import-CXpuYo-T.js.map → import-Bxv5JPm-.js.map} +1 -1
- package/dist/index.js +17 -25
- package/dist/index.js.map +1 -1
- package/dist/{util-BKtHPLe4.js → util-C4qsI1QV.js} +163 -55
- package/dist/util-C4qsI1QV.js.map +1 -0
- package/package.json +12 -9
- package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +4 -4
- package/src/components/CollectionPanel.tsx +1 -1
- package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +1 -1
- package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +3 -11
- package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +32 -2
- package/src/components/CollectionViewBinding/EntityCardBinding.tsx +10 -8
- package/src/components/DefaultAppBar.tsx +0 -10
- package/src/components/DetailViewBinding.tsx +18 -1
- package/src/components/EditViewBinding.tsx +26 -2
- package/src/components/ReferenceTable/SelectionTableBinding.tsx +10 -1
- package/src/components/RelationSelector.tsx +115 -3
- package/src/contexts/BreacrumbsContext.tsx +4 -21
- package/src/form/EntityForm.tsx +8 -3
- package/src/form/components/FormRail.tsx +15 -4
- package/src/form/useUndoableDiscard.ts +39 -0
- package/src/preview/components/StorageThumbnail.tsx +35 -1
- package/src/routes/RebaseRoute.tsx +2 -10
- package/dist/PropertyEditView-D6xNTm0d.js.map +0 -1
- 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.
|
|
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.
|
|
87
|
-
"@rebasepro/
|
|
88
|
-
"@rebasepro/
|
|
89
|
-
"@rebasepro/
|
|
90
|
-
"@rebasepro/
|
|
91
|
-
"@rebasepro/ui": "0.
|
|
92
|
-
"@rebasepro/
|
|
93
|
-
"@rebasepro/utils": "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
|
|
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">
|
|
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=
|
|
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=
|
|
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-
|
|
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-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
{/*
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
55
|
-
}), [breadcrumbs, set, updateCount]);
|
|
37
|
+
set
|
|
38
|
+
}), [breadcrumbs, set]);
|
|
56
39
|
|
|
57
40
|
return (
|
|
58
41
|
<BreadcrumbContext.Provider value={value}>
|
package/src/form/EntityForm.tsx
CHANGED
|
@@ -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.
|
|
622
|
-
: "All entered values will be cleared.
|
|
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
|
|
636
|
+
discard(formex, status, baseInitialValues);
|
|
632
637
|
}}>
|
|
633
638
|
{status === "existing" ? "Discard" : "Clear"}
|
|
634
639
|
</Button>
|