@rebasepro/admin 0.12.1-canary.gf5f1d39 → 0.13.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/README.md +1 -1
- package/dist/{CollectionEditorDialog-Dpc75wIZ.js → CollectionEditorDialog-CjXkWZ64.js} +75 -62
- package/dist/{CollectionEditorDialog-Dpc75wIZ.js.map → CollectionEditorDialog-CjXkWZ64.js.map} +1 -1
- package/dist/{PropertyEditView-DH3XZC2x.js → PropertyEditView-W8I3YV89.js} +36 -16
- package/dist/{PropertyEditView-DH3XZC2x.js.map → PropertyEditView-W8I3YV89.js.map} +1 -1
- package/dist/{RouterCollectionsStudioView-BZLq4MGp.js → RouterCollectionsStudioView-BsLTNoqX.js} +5 -5
- package/dist/{RouterCollectionsStudioView-BZLq4MGp.js.map → RouterCollectionsStudioView-BsLTNoqX.js.map} +1 -1
- package/dist/collection_editor/serializable_types.d.ts +4 -1
- package/dist/collection_editor/ui/collection_editor/CollectionRelationsTab.d.ts +1 -0
- package/dist/collection_editor/useLocalCollectionsConfigController.d.ts +8 -0
- package/dist/collection_editor_ui.js +5 -5
- package/dist/collection_editor_ui.js.map +1 -1
- package/dist/components/CollectionTableBinding/column_utils.d.ts +17 -0
- package/dist/components/CollectionViewBinding/CollectionViewStartActions.d.ts +7 -2
- package/dist/components/CollectionViewBinding/SortButton.d.ts +24 -0
- package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +10 -0
- package/dist/components/CollectionViewBinding/SplitViewContext.d.ts +23 -0
- package/dist/components/CollectionViewBinding/hooks/useKanbanDragAndDrop.d.ts +26 -0
- package/dist/components/DetailViewBinding.d.ts +5 -0
- package/dist/components/EditFormActions.d.ts +12 -1
- package/dist/components/EditViewBinding.d.ts +6 -1
- package/dist/components/EntityIdentityBar.d.ts +53 -0
- package/dist/components/EntityInspector.d.ts +35 -0
- package/dist/components/EntityPreviewNesting.d.ts +25 -0
- package/dist/components/EntityViewBinding.d.ts +9 -0
- package/dist/components/InlineEntityPreview.d.ts +47 -0
- package/dist/components/history/EntityHistoryView.d.ts +4 -1
- package/dist/components/index.d.ts +2 -0
- package/dist/editor.js +18 -10
- package/dist/editor.js.map +1 -1
- package/dist/{export-BzIpUdSK.js → export-J79rMoNj.js} +2 -2
- package/dist/{export-BzIpUdSK.js.map → export-J79rMoNj.js.map} +1 -1
- package/dist/form/EntityForm.d.ts +1 -1
- package/dist/form/EntityFormActions.d.ts +11 -1
- package/dist/form/PropertyFieldBinding.d.ts +1 -1
- package/dist/form/components/FieldBlock.d.ts +30 -0
- package/dist/form/components/FormRail.d.ts +18 -0
- package/dist/form/components/FormSections.d.ts +16 -0
- package/dist/form/components/index.d.ts +3 -2
- package/dist/form/components/useRailVisible.d.ts +24 -0
- package/dist/form/field_bindings/BinaryFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/DateTimeFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/GeopointFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/MarkdownEditorFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/MultiSelectFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/ReadOnlyFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/SelectFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/StorageUploadFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/SwitchFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/TextFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/UserSelectFieldBinding.d.ts +1 -1
- package/dist/form/field_bindings/VectorFieldBinding.d.ts +1 -1
- package/dist/{history-g9688IaK.js → history-D7L_JlLO.js} +35 -33
- package/dist/history-D7L_JlLO.js.map +1 -0
- package/dist/hooks/navigation/utils.d.ts +16 -0
- package/dist/hooks/useBuildSidePanel.d.ts +14 -0
- package/dist/hooks/useEntityDisplayTitle.d.ts +18 -0
- package/dist/hooks/useHistory.d.ts +7 -0
- package/dist/hooks/useRecordActions.d.ts +17 -0
- package/dist/{import-BjUG-cIY.js → import-BvYl--U4.js} +2 -2
- package/dist/{import-BjUG-cIY.js.map → import-BvYl--U4.js.map} +1 -1
- package/dist/index.js +106 -35
- package/dist/index.js.map +1 -1
- package/dist/markdown-CklalUUk.js.map +1 -1
- package/dist/preview/components/InlineEntityListPreview.d.ts +15 -0
- package/dist/preview/index.d.ts +1 -0
- package/dist/preview/property_previews/ArrayOfReferencesPreview.d.ts +1 -1
- package/dist/preview/property_previews/ArrayOfRelationsPreview.d.ts +1 -1
- package/dist/types/components/EntityFormActionsProps.d.ts +0 -1
- package/dist/types/components/EntityFormProps.d.ts +0 -5
- package/dist/types/fields.d.ts +24 -0
- package/dist/{util-B5fJm1FA.js → util-GiwPgxnL.js} +2106 -1117
- package/dist/util-GiwPgxnL.js.map +1 -0
- package/package.json +32 -34
- package/src/collection_editor/ConfigControllerProvider.tsx +1 -1
- package/src/collection_editor/serializable_types.ts +4 -3
- package/src/collection_editor/serializable_utils.ts +51 -17
- package/src/collection_editor/ui/collection_editor/CollectionEditorDialog.tsx +65 -22
- package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +38 -30
- package/src/collection_editor/ui/collection_editor/CollectionRelationsTab.tsx +3 -1
- package/src/collection_editor/ui/collection_editor/RouterCollectionStudioView.tsx +1 -1
- package/src/collection_editor/ui/collection_editor/RouterCollectionsStudioView.tsx +1 -1
- package/src/collection_editor/ui/collection_editor/properties/RelationPropertyField.tsx +16 -0
- package/src/collection_editor/useLocalCollectionsConfigController.tsx +127 -15
- package/src/collection_editor/validateCollectionJson.ts +3 -3
- package/src/components/AdminModeSyncer.tsx +1 -1
- package/src/components/CollectionTableBinding/PropertyTableCell.tsx +10 -2
- package/src/components/CollectionTableBinding/column_utils.tsx +59 -2
- package/src/components/CollectionTableBinding/table_bindings.tsx +2 -1
- package/src/components/CollectionViewBinding/CollectionBoardViewBinding.tsx +23 -16
- package/src/components/CollectionViewBinding/CollectionViewActions.tsx +7 -1
- package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +9 -2
- package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +47 -12
- package/src/components/CollectionViewBinding/SortButton.tsx +189 -0
- package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +33 -0
- package/src/components/CollectionViewBinding/SplitListView.tsx +18 -3
- package/src/components/CollectionViewBinding/SplitViewContext.tsx +34 -0
- package/src/components/CollectionViewBinding/hooks/useKanbanDragAndDrop.ts +48 -9
- package/src/components/CollectionViewBinding/useBoardDataController.tsx +80 -25
- package/src/components/CollectionViewBinding/usePreviewSlots.ts +18 -6
- package/src/components/DefaultAppBar.tsx +1 -1
- package/src/components/DefaultDrawer.tsx +1 -1
- package/src/components/DetailViewBinding.tsx +112 -148
- package/src/components/DrawerNavigationItem.tsx +1 -1
- package/src/components/EditFormActions.tsx +54 -129
- package/src/components/EditViewBinding.tsx +202 -99
- package/src/components/EntityIdentityBar.tsx +288 -0
- package/src/components/EntityInspector.tsx +137 -0
- package/src/components/EntityPreviewBinding.tsx +6 -2
- package/src/components/EntityPreviewNesting.tsx +50 -0
- package/src/components/EntityViewBinding.tsx +35 -32
- package/src/components/HomePage/FavouritesView.tsx +1 -1
- package/src/components/HomePage/NavigationCardBinding.tsx +1 -1
- package/src/components/HomePage/SmallNavigationCard.tsx +1 -1
- package/src/components/InlineEntityPreview.tsx +149 -0
- package/src/components/RebaseLayout.tsx +1 -1
- package/src/components/RebaseNavigation.tsx +45 -5
- package/src/components/RebaseRouteDefs.tsx +1 -1
- package/src/components/SelectableTable/SelectableTable.tsx +2 -2
- package/src/components/SidePanelBinding.tsx +19 -13
- package/src/components/UserSelector.tsx +12 -6
- package/src/components/common/default_entity_actions.tsx +4 -3
- package/src/components/history/EntityHistoryEntry.tsx +28 -7
- package/src/components/history/EntityHistoryView.tsx +29 -24
- package/src/components/history/LastEditedByIndicator.tsx +4 -3
- package/src/components/index.ts +2 -0
- package/src/data_import/utils/get_import_inference_type.ts +6 -0
- package/src/form/EntityForm.tsx +267 -179
- package/src/form/EntityFormActions.tsx +50 -99
- package/src/form/EntityFormBinding.tsx +4 -2
- package/src/form/PropertyFieldBinding.tsx +4 -0
- package/src/form/components/FieldBlock.tsx +119 -0
- package/src/form/components/FormRail.tsx +125 -0
- package/src/form/components/FormSections.tsx +118 -0
- package/src/form/components/index.tsx +3 -2
- package/src/form/components/useRailVisible.tsx +46 -0
- package/src/form/field_bindings/BinaryFieldBinding.tsx +3 -2
- package/src/form/field_bindings/DateTimeFieldBinding.tsx +3 -2
- package/src/form/field_bindings/GeopointFieldBinding.tsx +3 -2
- package/src/form/field_bindings/MapFieldBinding.tsx +1 -2
- package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +3 -2
- package/src/form/field_bindings/MultiSelectFieldBinding.tsx +2 -1
- package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +2 -1
- package/src/form/field_bindings/ReferenceFieldBinding.tsx +2 -1
- package/src/form/field_bindings/RelationFieldBinding.tsx +6 -4
- package/src/form/field_bindings/RepeatFieldBinding.tsx +4 -1
- package/src/form/field_bindings/SelectFieldBinding.tsx +3 -2
- package/src/form/field_bindings/StorageUploadFieldBinding.tsx +31 -8
- package/src/form/field_bindings/SwitchFieldBinding.tsx +7 -3
- package/src/form/field_bindings/TextFieldBinding.tsx +18 -16
- package/src/form/field_bindings/UserSelectFieldBinding.tsx +3 -2
- package/src/form/field_bindings/VectorFieldBinding.tsx +2 -1
- package/src/hooks/navigation/useBuildUrlController.tsx +1 -1
- package/src/hooks/navigation/useTopLevelNavigation.ts +18 -7
- package/src/hooks/navigation/utils.ts +20 -0
- package/src/hooks/useBuildSideDialogsController.tsx +2 -2
- package/src/hooks/useBuildSidePanel.tsx +22 -61
- package/src/hooks/useEntityDisplayTitle.tsx +60 -0
- package/src/hooks/useHistory.ts +20 -10
- package/src/hooks/useRecordActions.tsx +69 -0
- package/src/hooks/useResolvedUsers.ts +10 -5
- package/src/preview/PropertyPreview.tsx +48 -22
- package/src/preview/components/InlineEntityListPreview.tsx +42 -0
- package/src/preview/components/ReferencePreview.tsx +34 -14
- package/src/preview/components/RelationPreview.tsx +34 -14
- package/src/preview/index.ts +1 -0
- package/src/preview/property_previews/ArrayOfReferencesPreview.tsx +24 -1
- package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +25 -1
- package/src/routes/RebaseRoute.tsx +2 -2
- package/src/types/components/EntityFormActionsProps.tsx +0 -1
- package/src/types/components/EntityFormProps.tsx +0 -6
- package/src/types/fields.tsx +27 -0
- package/dist/form/components/FormEntry.d.ts +0 -6
- package/dist/form/components/FormLayout.d.ts +0 -5
- package/dist/history-g9688IaK.js.map +0 -1
- package/dist/util-B5fJm1FA.js.map +0 -1
- package/src/form/components/FormEntry.tsx +0 -22
- package/src/form/components/FormLayout.tsx +0 -16
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { Properties } from "@rebasepro/types";
|
|
2
|
+
|
|
3
|
+
import React, { useCallback, useMemo, useState } from "react";
|
|
4
|
+
import { EntityTableController } from "@rebasepro/admin-types";
|
|
5
|
+
import {
|
|
6
|
+
ArrowDownIcon,
|
|
7
|
+
ArrowUpDownIcon,
|
|
8
|
+
ArrowUpIcon,
|
|
9
|
+
Button,
|
|
10
|
+
cls,
|
|
11
|
+
iconSize,
|
|
12
|
+
IconButton,
|
|
13
|
+
Popover,
|
|
14
|
+
Typography
|
|
15
|
+
} from "@rebasepro/ui";
|
|
16
|
+
import { useTranslation } from "@rebasepro/app";
|
|
17
|
+
import { getSortablePropertyOptions } from "../CollectionTableBinding/column_utils";
|
|
18
|
+
import { getIconForProperty } from "../../util/property_utils";
|
|
19
|
+
|
|
20
|
+
export type SortButtonProps<M extends Record<string, unknown>> = {
|
|
21
|
+
tableController: EntityTableController<M>;
|
|
22
|
+
/**
|
|
23
|
+
* Resolved properties of the collection. The sortable ones among them are
|
|
24
|
+
* the options offered.
|
|
25
|
+
*/
|
|
26
|
+
properties: Properties;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Toolbar control that orders the collection by one property.
|
|
31
|
+
*
|
|
32
|
+
* The table orders by clicking a column header, which leaves every other view
|
|
33
|
+
* mode — list, cards, and either of them inside a split view — with no way to
|
|
34
|
+
* order at all, even though the controller has always carried `sortBy` and the
|
|
35
|
+
* query has always honoured it. This binds a control to it.
|
|
36
|
+
*
|
|
37
|
+
* Clicking the active property flips its direction, so ascending → descending
|
|
38
|
+
* is one click rather than a trip through a direction selector; clicking a
|
|
39
|
+
* different one starts it ascending. The popover stays open through both.
|
|
40
|
+
*/
|
|
41
|
+
export function SortButton<M extends Record<string, unknown>>({
|
|
42
|
+
tableController,
|
|
43
|
+
properties
|
|
44
|
+
}: SortButtonProps<M>) {
|
|
45
|
+
|
|
46
|
+
const { t } = useTranslation();
|
|
47
|
+
const [open, setOpen] = useState(false);
|
|
48
|
+
|
|
49
|
+
const options = useMemo(() => getSortablePropertyOptions(properties), [properties]);
|
|
50
|
+
|
|
51
|
+
const {
|
|
52
|
+
sortBy,
|
|
53
|
+
setSortBy,
|
|
54
|
+
setItemCount,
|
|
55
|
+
pageSize,
|
|
56
|
+
filterValues,
|
|
57
|
+
checkFilterCombination
|
|
58
|
+
} = tableController;
|
|
59
|
+
|
|
60
|
+
const activeKey = sortBy?.[0];
|
|
61
|
+
const activeDirection = sortBy?.[1];
|
|
62
|
+
|
|
63
|
+
const applySort = useCallback((next?: [string, "asc" | "desc"]) => {
|
|
64
|
+
setSortBy?.(next);
|
|
65
|
+
// Re-ordering a partially loaded collection is the same event as
|
|
66
|
+
// clicking a table header: the rows already fetched are no longer the
|
|
67
|
+
// ones the query answers with, so pagination starts over rather than
|
|
68
|
+
// holding on to a page of the previous order (`SelectableTable` resets
|
|
69
|
+
// it for the same reason).
|
|
70
|
+
if (pageSize !== undefined)
|
|
71
|
+
setItemCount?.(pageSize);
|
|
72
|
+
}, [setSortBy, setItemCount, pageSize]);
|
|
73
|
+
|
|
74
|
+
const onOptionClick = useCallback((key: string) => {
|
|
75
|
+
if (key === activeKey) {
|
|
76
|
+
applySort([key, activeDirection === "asc" ? "desc" : "asc"]);
|
|
77
|
+
} else {
|
|
78
|
+
applySort([key, "asc"]);
|
|
79
|
+
}
|
|
80
|
+
}, [activeKey, activeDirection, applySort]);
|
|
81
|
+
|
|
82
|
+
if (!setSortBy || options.length === 0) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const activeOption = options.find(option => option.key === activeKey);
|
|
87
|
+
const label = activeOption
|
|
88
|
+
? `${t("sort_by")}: ${activeOption.title}`
|
|
89
|
+
: t("sort");
|
|
90
|
+
|
|
91
|
+
// Icon only, at every width. The arrow says which way the order runs; which
|
|
92
|
+
// property it runs on is in the popover, and in the tooltip for anyone who
|
|
93
|
+
// wants it without opening one.
|
|
94
|
+
//
|
|
95
|
+
// No `onClick` here and no `Tooltip` wrapper: the popover is controlled in
|
|
96
|
+
// this component, and Radix's trigger — which reaches the button through
|
|
97
|
+
// `asChild` — is what toggles it. A handler of our own would toggle it a
|
|
98
|
+
// second time in the same click and the popover would never open; a
|
|
99
|
+
// `Tooltip` in between would swallow the trigger's ref.
|
|
100
|
+
const trigger = (
|
|
101
|
+
<IconButton
|
|
102
|
+
size={"small"}
|
|
103
|
+
aria-label={label}
|
|
104
|
+
title={label}
|
|
105
|
+
className={cls(activeOption && "text-primary")}
|
|
106
|
+
>
|
|
107
|
+
{activeDirection === "desc"
|
|
108
|
+
? <ArrowDownIcon size={iconSize.smallest}/>
|
|
109
|
+
: activeDirection === "asc"
|
|
110
|
+
? <ArrowUpIcon size={iconSize.smallest}/>
|
|
111
|
+
: <ArrowUpDownIcon size={iconSize.smallest}/>}
|
|
112
|
+
</IconButton>
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<Popover
|
|
117
|
+
open={open}
|
|
118
|
+
onOpenChange={setOpen}
|
|
119
|
+
modal={false}
|
|
120
|
+
side="bottom"
|
|
121
|
+
align="start"
|
|
122
|
+
trigger={trigger}
|
|
123
|
+
>
|
|
124
|
+
<div className="py-2 min-w-[240px] flex flex-col">
|
|
125
|
+
<Typography
|
|
126
|
+
variant="caption"
|
|
127
|
+
color="secondary"
|
|
128
|
+
className="font-medium uppercase tracking-wider px-3 pb-1"
|
|
129
|
+
>
|
|
130
|
+
{t("sort_by")}
|
|
131
|
+
</Typography>
|
|
132
|
+
|
|
133
|
+
<div className="max-h-[320px] overflow-y-auto flex flex-col">
|
|
134
|
+
{options.map(({ key, title, property }) => {
|
|
135
|
+
const active = key === activeKey;
|
|
136
|
+
// A driver may refuse a sort next to the filter that is
|
|
137
|
+
// already applied. Offering it would turn the click
|
|
138
|
+
// into a failed request, so it is disabled with the
|
|
139
|
+
// same authority the table headers ask.
|
|
140
|
+
const disabled = checkFilterCombination
|
|
141
|
+
? !checkFilterCombination(filterValues ?? {}, [key, active && activeDirection === "asc" ? "desc" : "asc"])
|
|
142
|
+
: false;
|
|
143
|
+
return (
|
|
144
|
+
<button
|
|
145
|
+
key={key}
|
|
146
|
+
type="button"
|
|
147
|
+
role="menuitemradio"
|
|
148
|
+
aria-checked={active}
|
|
149
|
+
disabled={disabled}
|
|
150
|
+
onClick={() => onOptionClick(key)}
|
|
151
|
+
className={cls(
|
|
152
|
+
"flex items-center gap-2 px-3 py-1.5 text-sm text-left w-full",
|
|
153
|
+
disabled
|
|
154
|
+
? "opacity-50 cursor-not-allowed"
|
|
155
|
+
: "cursor-pointer hover:bg-surface-accent-100 dark:hover:bg-surface-accent-900",
|
|
156
|
+
active && "text-primary"
|
|
157
|
+
)}
|
|
158
|
+
>
|
|
159
|
+
<span className="flex-shrink-0 flex items-center">
|
|
160
|
+
{getIconForProperty(property, "smallest")}
|
|
161
|
+
</span>
|
|
162
|
+
<span className="flex-grow truncate">{title}</span>
|
|
163
|
+
{active && (
|
|
164
|
+
<span className="flex-shrink-0 flex items-center">
|
|
165
|
+
{activeDirection === "desc"
|
|
166
|
+
? <ArrowDownIcon size={iconSize.smallest}/>
|
|
167
|
+
: <ArrowUpIcon size={iconSize.smallest}/>}
|
|
168
|
+
</span>
|
|
169
|
+
)}
|
|
170
|
+
</button>
|
|
171
|
+
);
|
|
172
|
+
})}
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<div className="px-3 pt-2">
|
|
176
|
+
<Button
|
|
177
|
+
variant="text"
|
|
178
|
+
size="small"
|
|
179
|
+
className="w-full"
|
|
180
|
+
disabled={!activeOption}
|
|
181
|
+
onClick={() => applySort(undefined)}
|
|
182
|
+
>
|
|
183
|
+
{t("clear_sort")}
|
|
184
|
+
</Button>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
</Popover>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
import { IconButton, iconSize, Tooltip, XIcon } from "@rebasepro/ui";
|
|
4
|
+
import { useTranslation } from "@rebasepro/app";
|
|
5
|
+
|
|
6
|
+
import { useSplitView } from "./SplitViewContext";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Closes the list of a split view, leaving the open record on its own.
|
|
10
|
+
*
|
|
11
|
+
* It leads the record's own app bar, where the breadcrumb starts — this is the
|
|
12
|
+
* entity pane's old "Open full screen" button, moved to that edge and wearing an
|
|
13
|
+
* X: closing the list and opening the record full screen are the same thing, so
|
|
14
|
+
* there is one control and one route (`#full`) rather than two.
|
|
15
|
+
*/
|
|
16
|
+
export function SplitListCloseButton() {
|
|
17
|
+
|
|
18
|
+
const { t } = useTranslation();
|
|
19
|
+
const splitView = useSplitView();
|
|
20
|
+
|
|
21
|
+
if (!splitView?.detailOpen) return null;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<Tooltip title={t("hide_list")}>
|
|
25
|
+
<IconButton
|
|
26
|
+
size="small"
|
|
27
|
+
onClick={splitView.openFullScreen}
|
|
28
|
+
aria-label={t("hide_list")}>
|
|
29
|
+
<XIcon size={iconSize.smallest}/>
|
|
30
|
+
</IconButton>
|
|
31
|
+
</Tooltip>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -11,10 +11,11 @@ import {
|
|
|
11
11
|
} from "@rebasepro/ui";
|
|
12
12
|
import { useLargeLayout, UnsavedChangesDialog, useNavigationBlocker } from "@rebasepro/app";
|
|
13
13
|
import { useCollectionRegistryController } from "../../hooks/navigation/contexts/CollectionRegistryContext";
|
|
14
|
-
import { useNavigate, useLocation } from "react-router
|
|
14
|
+
import { useNavigate, useLocation } from "react-router";
|
|
15
15
|
import { useUrlController } from "../../hooks/navigation/contexts/UrlContext";
|
|
16
16
|
import { ErrorBoundary } from "@rebasepro/ui";
|
|
17
17
|
import { withViewMode } from "../../util/view_mode";
|
|
18
|
+
import { SplitViewProvider } from "./SplitViewContext";
|
|
18
19
|
|
|
19
20
|
export type SplitListViewProps<M extends Record<string, unknown> = Record<string, unknown>> = {
|
|
20
21
|
collection: AdminCollection<M>;
|
|
@@ -408,8 +409,22 @@ export function SplitListView<M extends Record<string, unknown> = Record<string,
|
|
|
408
409
|
</div>
|
|
409
410
|
) : <></>;
|
|
410
411
|
|
|
412
|
+
// Closing the list is the full-screen entity route: the same `#full` the
|
|
413
|
+
// entity pane's expand button used, so there is one way to give the record
|
|
414
|
+
// the whole window and one way back out of it.
|
|
415
|
+
const openFullScreen = useCallback(() => {
|
|
416
|
+
if (selectedEntityId === undefined) return;
|
|
417
|
+
const suffix = isEditMode ? "/edit" : "";
|
|
418
|
+
navigate(`${buildUrl(`${path}/${selectedEntityId}${suffix}`)}#full`);
|
|
419
|
+
}, [navigate, buildUrl, path, selectedEntityId, isEditMode]);
|
|
420
|
+
|
|
421
|
+
const splitViewController = useMemo(() => ({
|
|
422
|
+
detailOpen: selectedEntityId !== undefined,
|
|
423
|
+
openFullScreen
|
|
424
|
+
}), [selectedEntityId, openFullScreen]);
|
|
425
|
+
|
|
411
426
|
return (
|
|
412
|
-
|
|
427
|
+
<SplitViewProvider value={splitViewController}>
|
|
413
428
|
<ResizablePanels
|
|
414
429
|
stacked={!largeLayout}
|
|
415
430
|
firstPanel={listPanel}
|
|
@@ -428,6 +443,6 @@ export function SplitListView<M extends Record<string, unknown> = Record<string,
|
|
|
428
443
|
}}
|
|
429
444
|
handleCancel={() => blocker?.reset?.()}
|
|
430
445
|
body={<>You have unsaved changes in this <b>{collection.singularName ?? collection.name}</b>.</>}/>
|
|
431
|
-
|
|
446
|
+
</SplitViewProvider>
|
|
432
447
|
);
|
|
433
448
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React, { useContext } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The enclosing master-detail split view, exposed to the list panel so it can
|
|
5
|
+
* carry the control that closes it.
|
|
6
|
+
*/
|
|
7
|
+
export type SplitViewController = {
|
|
8
|
+
/** Whether a record is open in the detail pane. */
|
|
9
|
+
detailOpen: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Close the list and leave the open record on its own, full screen. This is
|
|
12
|
+
* the existing full-screen entity route (`#full`), not a second mechanism.
|
|
13
|
+
*/
|
|
14
|
+
openFullScreen: () => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const SplitViewContext = React.createContext<SplitViewController | undefined>(undefined);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The enclosing split view, or `undefined` when the component is not rendered
|
|
21
|
+
* inside one (a side panel, a dialog, a full-screen route).
|
|
22
|
+
*/
|
|
23
|
+
export function useSplitView(): SplitViewController | undefined {
|
|
24
|
+
return useContext(SplitViewContext);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function SplitViewProvider({
|
|
28
|
+
value,
|
|
29
|
+
children
|
|
30
|
+
}: { value: SplitViewController, children: React.ReactNode }) {
|
|
31
|
+
return <SplitViewContext.Provider value={value}>
|
|
32
|
+
{children}
|
|
33
|
+
</SplitViewContext.Provider>;
|
|
34
|
+
}
|
|
@@ -7,6 +7,42 @@ import { BoardItem } from "@rebasepro/ui";
|
|
|
7
7
|
import { BoardDataController } from "../useBoardDataController";
|
|
8
8
|
import { generateKeyBetween } from "fractional-indexing";
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The digits a board order key is built from: base36, lower case only.
|
|
12
|
+
*
|
|
13
|
+
* The library's default alphabet is base62, and its keys only sort correctly
|
|
14
|
+
* under byte ordering. The column is sorted by the *database*, and Postgres'
|
|
15
|
+
* default collation is not byte ordering — under `en_US.UTF-8`, `"aa"` sorts
|
|
16
|
+
* before `"aC"`, so as soon as a board had been dragged around enough to reach
|
|
17
|
+
* the upper-case digits its order stopped matching the keys. One case has no
|
|
18
|
+
* such ambiguity: digits before letters is true of every collation in use.
|
|
19
|
+
*
|
|
20
|
+
* A key written with the old alphabet no longer validates, which is what makes
|
|
21
|
+
* the board offer to initialise the column — one click, and the order is
|
|
22
|
+
* rewritten in a form the database can sort.
|
|
23
|
+
*/
|
|
24
|
+
export const ORDER_KEY_DIGITS = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether a stored order value is a key `fractional-indexing` can interpolate
|
|
28
|
+
* against.
|
|
29
|
+
*
|
|
30
|
+
* Anything else — a plain `"12"` from a seed or a migration, an empty string —
|
|
31
|
+
* makes `generateKeyBetween` throw, and the fallback below then hands out the
|
|
32
|
+
* same first key to every card, so each drag lands at the bottom of the column
|
|
33
|
+
* on top of the last one that did. Asking the library itself is the only
|
|
34
|
+
* definition of "valid" worth trusting.
|
|
35
|
+
*/
|
|
36
|
+
export function isValidOrderKey(value: unknown): value is string {
|
|
37
|
+
if (typeof value !== "string" || value.length === 0) return false;
|
|
38
|
+
try {
|
|
39
|
+
generateKeyBetween(value, null, ORDER_KEY_DIGITS);
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
10
46
|
export interface UseKanbanDragAndDropParams<M extends Record<string, unknown>> {
|
|
11
47
|
collection: AdminCollection<M>;
|
|
12
48
|
fullPath: string;
|
|
@@ -63,13 +99,16 @@ export function useKanbanDragAndDrop<M extends Record<string, unknown>>({
|
|
|
63
99
|
|
|
64
100
|
const movedIndex = targetColumnItems.findIndex(item => item.id === moveInfo.itemId);
|
|
65
101
|
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
: null;
|
|
102
|
+
// The order keys of the neighbours. A neighbour whose key is not a
|
|
103
|
+
// valid one counts as no neighbour at all: interpolating against it
|
|
104
|
+
// throws, and the whole move then falls back to a key that ignores
|
|
105
|
+
// where the card was actually dropped.
|
|
106
|
+
const keyAt = (index: number): string | null => {
|
|
107
|
+
const value = targetColumnItems[index]?.data.values?.[orderProperty];
|
|
108
|
+
return isValidOrderKey(value) ? value : null;
|
|
109
|
+
};
|
|
110
|
+
const prevKey = movedIndex > 0 ? keyAt(movedIndex - 1) : null;
|
|
111
|
+
const nextKey = movedIndex < targetColumnItems.length - 1 ? keyAt(movedIndex + 1) : null;
|
|
73
112
|
|
|
74
113
|
try {
|
|
75
114
|
const a = prevKey;
|
|
@@ -78,12 +117,12 @@ export function useKanbanDragAndDrop<M extends Record<string, unknown>>({
|
|
|
78
117
|
// Handle duplicate or out-of-order keys to prevent fractional-indexing crash
|
|
79
118
|
b = null;
|
|
80
119
|
}
|
|
81
|
-
const newKey = generateKeyBetween(a, b);
|
|
120
|
+
const newKey = generateKeyBetween(a, b, ORDER_KEY_DIGITS);
|
|
82
121
|
updatedValues = setIn(updatedValues, orderProperty, newKey) as Record<string, unknown>;
|
|
83
122
|
} catch (e) {
|
|
84
123
|
// Fallback: if keys are somehow invalid, generate from scratch
|
|
85
124
|
console.warn("fractional-indexing error, falling back:", e);
|
|
86
|
-
const newKey = generateKeyBetween(null, null);
|
|
125
|
+
const newKey = generateKeyBetween(null, null, ORDER_KEY_DIGITS);
|
|
87
126
|
updatedValues = setIn(updatedValues, orderProperty, newKey) as Record<string, unknown>;
|
|
88
127
|
}
|
|
89
128
|
}
|
|
@@ -6,6 +6,13 @@ import type { AdminCollection } from "@rebasepro/admin-types";
|
|
|
6
6
|
|
|
7
7
|
const DEFAULT_PAGE_SIZE = 20;
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* How long a column waits for its subscription's first payload before reading
|
|
11
|
+
* once over HTTP instead. Long enough that a healthy socket always wins the
|
|
12
|
+
* race, short enough that a dead one does not hold a blank board.
|
|
13
|
+
*/
|
|
14
|
+
const LIVE_FIRST_PAINT_TIMEOUT_MS = 2500;
|
|
15
|
+
|
|
9
16
|
/**
|
|
10
17
|
* Shallow equality for entity value records.
|
|
11
18
|
* Handles primitives and reference equality for nested values.
|
|
@@ -392,46 +399,94 @@ values: { ...e.values,
|
|
|
392
399
|
});
|
|
393
400
|
};
|
|
394
401
|
|
|
402
|
+
const accessor = currentDataClient.collection(currentResolvedPath);
|
|
403
|
+
|
|
404
|
+
// Eagerly include relations to avoid N+1 fetches.
|
|
405
|
+
const includeParams = getRelationIncludeParams(currentCollection);
|
|
406
|
+
|
|
407
|
+
const fetchOnce = () => accessor.find({
|
|
408
|
+
where: whereFilter,
|
|
409
|
+
limit: itemCount,
|
|
410
|
+
orderBy: orderByParam,
|
|
411
|
+
include: includeParams
|
|
412
|
+
});
|
|
413
|
+
|
|
395
414
|
const onError = (error: Error) => {
|
|
396
415
|
// Skip error handling if we're cleaning up
|
|
397
416
|
if (isCleaningUpRef.current) return;
|
|
398
417
|
|
|
399
418
|
console.error(`Error loading column ${column}:`, error);
|
|
400
|
-
setColumnData(prev => ({
|
|
401
|
-
...prev,
|
|
402
|
-
[column]: {
|
|
403
|
-
...prev[column],
|
|
404
|
-
entities: [],
|
|
405
|
-
loading: false,
|
|
406
|
-
hasMore: false,
|
|
407
|
-
error
|
|
408
|
-
}
|
|
409
|
-
}));
|
|
410
|
-
};
|
|
411
419
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
420
|
+
// A live subscription that fails is not the same thing as a column
|
|
421
|
+
// with nothing in it, but that is exactly what this used to render:
|
|
422
|
+
// entities cleared, no error surfaced, and a column reading "No
|
|
423
|
+
// items" above a header still counting eleven of them. Read once
|
|
424
|
+
// over HTTP instead — the board loses its live updates, not its
|
|
425
|
+
// contents — and only report the failure if that fails too.
|
|
426
|
+
fetchOnce()
|
|
427
|
+
.then(res => onUpdate(res.data as Entity<M>[]))
|
|
428
|
+
.catch(() => {
|
|
429
|
+
if (isCleaningUpRef.current) return;
|
|
430
|
+
setColumnData(prev => ({
|
|
431
|
+
...prev,
|
|
432
|
+
[column]: {
|
|
433
|
+
...prev[column],
|
|
434
|
+
entities: prev[column]?.entities ?? [],
|
|
435
|
+
loading: false,
|
|
436
|
+
hasMore: false,
|
|
437
|
+
error
|
|
438
|
+
}
|
|
439
|
+
}));
|
|
440
|
+
});
|
|
441
|
+
};
|
|
417
442
|
|
|
418
443
|
if (accessor.listen) {
|
|
444
|
+
let liveDataReceived = false;
|
|
419
445
|
const unsubscribe = accessor.listen({
|
|
420
446
|
where: whereFilter,
|
|
421
447
|
limit: itemCount,
|
|
422
448
|
orderBy: orderByParam,
|
|
423
449
|
include: includeParams
|
|
424
|
-
}, res =>
|
|
425
|
-
|
|
450
|
+
}, res => {
|
|
451
|
+
liveDataReceived = true;
|
|
452
|
+
onUpdate(res.data as Entity<M>[]);
|
|
453
|
+
}, onError);
|
|
454
|
+
|
|
455
|
+
// A subscription that is never going to answer takes the client's
|
|
456
|
+
// full 30s watchdog to say so, and the board spins the whole time.
|
|
457
|
+
// If the socket has not delivered shortly after mount, read once
|
|
458
|
+
// over HTTP and paint that; the live data still wins whenever it
|
|
459
|
+
// arrives. Costs nothing when realtime is healthy.
|
|
460
|
+
const firstPaintFallback = setTimeout(() => {
|
|
461
|
+
if (liveDataReceived || isCleaningUpRef.current) return;
|
|
462
|
+
fetchOnce()
|
|
463
|
+
.then(res => {
|
|
464
|
+
if (liveDataReceived || isCleaningUpRef.current) return;
|
|
465
|
+
onUpdate(res.data as Entity<M>[]);
|
|
466
|
+
})
|
|
467
|
+
.catch(() => undefined);
|
|
468
|
+
}, LIVE_FIRST_PAINT_TIMEOUT_MS);
|
|
469
|
+
|
|
470
|
+
unsubscribersRef.current[column] = () => {
|
|
471
|
+
clearTimeout(firstPaintFallback);
|
|
472
|
+
unsubscribe?.();
|
|
473
|
+
};
|
|
426
474
|
} else {
|
|
427
|
-
|
|
428
|
-
where: whereFilter,
|
|
429
|
-
limit: itemCount,
|
|
430
|
-
orderBy: orderByParam,
|
|
431
|
-
include: includeParams
|
|
432
|
-
})
|
|
475
|
+
fetchOnce()
|
|
433
476
|
.then(res => onUpdate(res.data as Entity<M>[]))
|
|
434
|
-
.catch(
|
|
477
|
+
.catch((error: Error) => {
|
|
478
|
+
if (isCleaningUpRef.current) return;
|
|
479
|
+
setColumnData(prev => ({
|
|
480
|
+
...prev,
|
|
481
|
+
[column]: {
|
|
482
|
+
...prev[column],
|
|
483
|
+
entities: prev[column]?.entities ?? [],
|
|
484
|
+
loading: false,
|
|
485
|
+
hasMore: false,
|
|
486
|
+
error
|
|
487
|
+
}
|
|
488
|
+
}));
|
|
489
|
+
});
|
|
435
490
|
}
|
|
436
491
|
}, []); // No dependencies - uses refs for all values
|
|
437
492
|
|
|
@@ -3,12 +3,12 @@ import type { Property, RelationProperty } from "@rebasepro/types";
|
|
|
3
3
|
import type { Entity, EntityRelation } from "@rebasepro/types";
|
|
4
4
|
import type { PropertyConfig, AdminCollection } from "@rebasepro/admin-types";
|
|
5
5
|
import { getEntityImagePreviewPropertyKey } from "@rebasepro/app";
|
|
6
|
-
import { getTitlePropertyCandidates, looksLikeIdentifierValue } from "@rebasepro/app";
|
|
6
|
+
import { getLeadingRelationTitleKey, getTitlePropertyCandidates, looksLikeIdentifierValue } from "@rebasepro/app";
|
|
7
7
|
import { getEntityFromCache } from "@rebasepro/app";
|
|
8
8
|
import { getEntityPreviewKeys } from "../../util/previews";
|
|
9
9
|
import { getValueInPath } from "@rebasepro/utils";
|
|
10
10
|
import type { AuthController } from "@rebasepro/admin-types";
|
|
11
|
-
import { ChipColorScheme, CHIP_COLORS } from "@rebasepro/ui";
|
|
11
|
+
import { ChipColorScheme, CHIP_COLORS, CHIP_SEED_KEYS } from "@rebasepro/ui";
|
|
12
12
|
|
|
13
13
|
/** Whether an authored relation yields many rows. Derived from its kind. */
|
|
14
14
|
function relationCardinality(relation: { kind?: string; cardinality?: string } | undefined): "one" | "many" | undefined {
|
|
@@ -119,7 +119,15 @@ export function resolveCollectionSlotKeys(
|
|
|
119
119
|
authController: AuthController,
|
|
120
120
|
propertyConfigs: Record<string, PropertyConfig>
|
|
121
121
|
): CollectionSlotKeys {
|
|
122
|
-
|
|
122
|
+
// One row per entity: when the collection leads with a relation and holds no
|
|
123
|
+
// name of its own, that relation *is* what the row is called. See
|
|
124
|
+
// `getLeadingRelationTitleKey` — the general ranking keeps relations last,
|
|
125
|
+
// which is right for a detail header but leaves a junction row titleless.
|
|
126
|
+
const rankedCandidates = getTitlePropertyCandidates(collection);
|
|
127
|
+
const leadingRelationKey = getLeadingRelationTitleKey(collection);
|
|
128
|
+
const titleKeyCandidates = leadingRelationKey
|
|
129
|
+
? [leadingRelationKey, ...rankedCandidates.filter(key => key !== leadingRelationKey)]
|
|
130
|
+
: rankedCandidates;
|
|
123
131
|
const titleKey = titleKeyCandidates[0];
|
|
124
132
|
const imageKey = getEntityImagePreviewPropertyKey(collection);
|
|
125
133
|
|
|
@@ -188,7 +196,9 @@ export function resolveCollectionSlotKeys(
|
|
|
188
196
|
if (!hasExplicitOrder) {
|
|
189
197
|
for (const [key, prop] of Object.entries(collection.properties)) {
|
|
190
198
|
const p = prop as Property;
|
|
191
|
-
|
|
199
|
+
// The relation filling the title slot is already the row's headline;
|
|
200
|
+
// repeating it as a chip below says the same thing twice.
|
|
201
|
+
if (p.type === "relation" && key !== titleKey) {
|
|
192
202
|
relationKeys.push(key);
|
|
193
203
|
}
|
|
194
204
|
}
|
|
@@ -417,8 +427,10 @@ id: relation.id });
|
|
|
417
427
|
|
|
418
428
|
if (items.length > 0) {
|
|
419
429
|
const relIndex = relationKeys.indexOf(relKey);
|
|
420
|
-
|
|
421
|
-
|
|
430
|
+
// Walking the seed keys rather than `Object.values`: the table also
|
|
431
|
+
// holds a bare-hue alias per hue, so raw values would hand the first
|
|
432
|
+
// two relations the same scheme.
|
|
433
|
+
const colorScheme = CHIP_COLORS[CHIP_SEED_KEYS[relIndex % CHIP_SEED_KEYS.length]];
|
|
422
434
|
relations.push({
|
|
423
435
|
property: prop,
|
|
424
436
|
propertyKey: relKey,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
|
|
2
2
|
import React from "react";
|
|
3
3
|
|
|
4
|
-
import { Link, useNavigate } from "react-router
|
|
4
|
+
import { Link, useNavigate } from "react-router";
|
|
5
5
|
import { RebaseLogo, LanguageToggle } from "@rebasepro/app";
|
|
6
6
|
import { ErrorBoundary, iconSize } from "@rebasepro/ui";
|
|
7
7
|
import {
|
|
@@ -6,7 +6,7 @@ import { useUrlController } from "../hooks/navigation/contexts/UrlContext";
|
|
|
6
6
|
import { useNavigationStateController } from "../hooks/navigation/contexts/NavigationStateContext";
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
import { Link, useNavigate } from "react-router
|
|
9
|
+
import { Link, useNavigate } from "react-router";
|
|
10
10
|
import { AnalyticsEvent } from "@rebasepro/admin-types";
|
|
11
11
|
import {
|
|
12
12
|
Avatar,
|