@rebasepro/admin 0.14.1 → 0.14.2-canary.g27a129e

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 (39) hide show
  1. package/dist/{CollectionEditorDialog-BeS4lxGh.js → CollectionEditorDialog-Dxx0ZaDk.js} +11 -5
  2. package/dist/CollectionEditorDialog-Dxx0ZaDk.js.map +1 -0
  3. package/dist/{PropertyEditView-BFJ6H9-Q.js → PropertyEditView-D6xNTm0d.js} +2 -2
  4. package/dist/{PropertyEditView-BFJ6H9-Q.js.map → PropertyEditView-D6xNTm0d.js.map} +1 -1
  5. package/dist/{RouterCollectionsStudioView-sfsUpEJz.js → RouterCollectionsStudioView-C4vSn9Pu.js} +4 -4
  6. package/dist/{RouterCollectionsStudioView-sfsUpEJz.js.map → RouterCollectionsStudioView-C4vSn9Pu.js.map} +1 -1
  7. package/dist/collection_editor/serializable_types.d.ts +6 -0
  8. package/dist/collection_editor_ui.js +4 -4
  9. package/dist/components/CollectionViewBinding/ViewModeToggle.d.ts +11 -5
  10. package/dist/components/RebaseAdmin.d.ts +1 -1
  11. package/dist/{export-KEf57Jso.js → export-BZi1MzQ5.js} +2 -2
  12. package/dist/{export-KEf57Jso.js.map → export-BZi1MzQ5.js.map} +1 -1
  13. package/dist/{history-Ctd2cRn_.js → history-B5pkfldE.js} +2 -2
  14. package/dist/{history-Ctd2cRn_.js.map → history-B5pkfldE.js.map} +1 -1
  15. package/dist/{import-BujhycMq.js → import-CXpuYo-T.js} +2 -2
  16. package/dist/{import-BujhycMq.js.map → import-CXpuYo-T.js.map} +1 -1
  17. package/dist/index.js +19 -14
  18. package/dist/index.js.map +1 -1
  19. package/dist/util/resolutions.d.ts +10 -1
  20. package/dist/util/view_mode.d.ts +24 -6
  21. package/dist/{util-bL-fk6C6.js → util-BKtHPLe4.js} +141 -52
  22. package/dist/util-BKtHPLe4.js.map +1 -0
  23. package/package.json +9 -9
  24. package/src/collection_editor/serializable_types.ts +6 -0
  25. package/src/collection_editor/serializable_utils.ts +8 -0
  26. package/src/collection_editor/ui/collection_editor/ViewModeSwitch.tsx +16 -2
  27. package/src/components/CollectionEditorDialogs.tsx +4 -3
  28. package/src/components/CollectionViewBinding/CollectionViewActions.tsx +4 -4
  29. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +99 -22
  30. package/src/components/CollectionViewBinding/ViewModeToggle.tsx +66 -32
  31. package/src/components/EntityInspector.tsx +3 -2
  32. package/src/components/HomePage/NavigationCardBinding.tsx +2 -2
  33. package/src/components/RebaseAdmin.tsx +3 -2
  34. package/src/components/RebaseNavigation.tsx +13 -6
  35. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +3 -2
  36. package/src/util/resolutions.ts +30 -1
  37. package/src/util/view_mode.ts +49 -12
  38. package/dist/CollectionEditorDialog-BeS4lxGh.js.map +0 -1
  39. package/dist/util-bL-fk6C6.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.14.1",
4
+ "version": "0.14.2-canary.g27a129e",
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.14.1",
87
- "@rebasepro/forms": "0.14.1",
88
- "@rebasepro/app": "0.14.1",
89
- "@rebasepro/common": "0.14.1",
90
- "@rebasepro/inference": "0.14.1",
91
- "@rebasepro/types": "0.14.1",
92
- "@rebasepro/ui": "0.14.1",
93
- "@rebasepro/utils": "0.14.1"
86
+ "@rebasepro/forms": "0.14.2-canary.g27a129e",
87
+ "@rebasepro/common": "0.14.2-canary.g27a129e",
88
+ "@rebasepro/admin-types": "0.14.2-canary.g27a129e",
89
+ "@rebasepro/app": "0.14.2-canary.g27a129e",
90
+ "@rebasepro/types": "0.14.2-canary.g27a129e",
91
+ "@rebasepro/utils": "0.14.2-canary.g27a129e",
92
+ "@rebasepro/ui": "0.14.2-canary.g27a129e",
93
+ "@rebasepro/inference": "0.14.2-canary.g27a129e"
94
94
  },
95
95
  "peerDependencies": {
96
96
  "react": ">=19.2.7",
@@ -432,6 +432,12 @@ export interface SerializableCollectionConfig {
432
432
  defaultEntityAction?: "view" | "edit";
433
433
  defaultViewMode?: ViewMode;
434
434
  enabledViews?: ViewMode[];
435
+ /**
436
+ * Keys only. A custom view declared inline carries a `Builder` function,
437
+ * which does not survive a round trip through a config file — the editor
438
+ * can reference app-registered views, not author new ones.
439
+ */
440
+ customViews?: string[];
435
441
  kanban?: KanbanConfig<Record<string, unknown>>;
436
442
  defaultSize?: CollectionSize;
437
443
  sideDialogWidth?: number | string;
@@ -501,6 +501,14 @@ export function toSerializableCollectionConfig(collection: AdminCollection): Ser
501
501
  if (collection.previewProperties) result.previewProperties = collection.previewProperties;
502
502
  if (collection.listProperties) result.listProperties = collection.listProperties;
503
503
  if (collection.enabledViews) result.enabledViews = collection.enabledViews;
504
+ // Inline views are dropped rather than mangled: their `Builder` cannot be
505
+ // serialized, and writing back a half of one would delete it from the
506
+ // running config. Keys survive, so a collection edited in the panel keeps
507
+ // whichever registered views it named.
508
+ if (collection.customViews) {
509
+ const keys = collection.customViews.filter((v): v is string => typeof v === "string");
510
+ if (keys.length > 0) result.customViews = keys;
511
+ }
504
512
  if (collection.disableDefaultActions) result.disableDefaultActions = collection.disableDefaultActions;
505
513
  if (collection.securityRules) result.securityRules = collection.securityRules;
506
514
  if (isRelationalCollectionConfig(collection) && collection.disableDefaultPolicies !== undefined) {
@@ -1,4 +1,4 @@
1
- import { ViewMode } from "@rebasepro/admin-types";
1
+ import { CollectionCustomView, ViewMode } from "@rebasepro/admin-types";
2
2
  import {
3
3
  cls,
4
4
  KanbanIcon,
@@ -8,6 +8,7 @@ import {
8
8
  ToggleButtonGroup,
9
9
  Typography
10
10
  } from "@rebasepro/ui";
11
+ import { getIcon, useCustomizationController } from "@rebasepro/app";
11
12
 
12
13
  export function ViewModeSwitch({
13
14
  value,
@@ -19,6 +20,14 @@ export function ViewModeSwitch({
19
20
  className?: string;
20
21
  }) {
21
22
 
23
+ const customizationController = useCustomizationController();
24
+
25
+ // Only app-registered views are offerable here. A view declared inline on
26
+ // a collection carries a `Builder` function, which the editor cannot write
27
+ // back into a config file — registering it on `<RebaseAdmin>` and naming
28
+ // the key is what makes it editable, the same bargain `entityViews` makes.
29
+ const registeredViews: CollectionCustomView[] = customizationController.collectionViews ?? [];
30
+
22
31
  return <div className={cls(className)}>
23
32
  <Typography variant={"label"} color={"secondary"} className={"ml-3.5"}>Default collection view</Typography>
24
33
  <div className={"my-2"}>
@@ -45,7 +54,12 @@ export function ViewModeSwitch({
45
54
  value: "kanban",
46
55
  label: "Kanban",
47
56
  icon: <KanbanIcon/>
48
- }
57
+ },
58
+ ...registeredViews.map((view) => ({
59
+ value: view.key,
60
+ label: view.name,
61
+ icon: getIcon(view.icon) ?? <ListIcon/>
62
+ }))
49
63
  ]}
50
64
  />
51
65
  </div>
@@ -1,15 +1,16 @@
1
- import React, { lazy, Suspense } from "react";
1
+ import React, { Suspense } from "react";
2
+ import { lazyChunk } from "@rebasepro/ui";
2
3
  import { useCollectionEditorDialogsState } from "../collection_editor/CollectionEditorDialogsContext";
3
4
 
4
5
  // Dynamic imports targeting the separate collection_editor_ui entry point.
5
6
  // This ensures the heavy editor UI lands in its own chunk and is only fetched
6
7
  // when a dialog is actually opened.
7
- const CollectionEditorDialog = lazy(() =>
8
+ const CollectionEditorDialog = lazyChunk(() =>
8
9
  import("../collection_editor/ui/collection_editor/CollectionEditorDialog")
9
10
  .then(m => ({ default: m.CollectionEditorDialog }))
10
11
  );
11
12
 
12
- const PropertyFormDialog = lazy(() =>
13
+ const PropertyFormDialog = lazyChunk(() =>
13
14
  import("../collection_editor/ui/collection_editor/PropertyEditView")
14
15
  .then(m => ({ default: m.PropertyFormDialog }))
15
16
  );
@@ -1,16 +1,16 @@
1
1
 
2
2
 
3
- import React, { lazy, Suspense } from "react";
3
+ import React, { Suspense } from "react";
4
4
 
5
5
  import { useLargeLayout, useTranslation, useSlot, resolveComponentRef } from "@rebasepro/app";
6
6
  import { CollectionActionsProps, EntityTableController, SelectionController, AdminCollection } from "@rebasepro/admin-types";
7
- import { Button, IconButton, Tooltip, Popover, iconSize } from "@rebasepro/ui";
7
+ import { Button, IconButton, Tooltip, Popover, iconSize, lazyChunk } from "@rebasepro/ui";
8
8
  import { ErrorBoundary, Link2Icon, MoreVerticalIcon, PlusIcon, Trash2Icon } from "@rebasepro/ui";
9
9
  import { usePermissions } from "@rebasepro/app";
10
10
  import { toArray } from "@rebasepro/utils";
11
11
  // Lazy-load import/export — pulls in exceljs only on demand
12
- const ImportCollectionAction = lazy(() => import("../../data_import/import").then(m => ({ default: m.ImportCollectionAction })));
13
- const ExportCollectionAction = lazy(() => import("../../data_export/export").then(m => ({ default: m.ExportCollectionAction })));
12
+ const ImportCollectionAction = lazyChunk(() => import("../../data_import/import").then(m => ({ default: m.ImportCollectionAction })));
13
+ const ExportCollectionAction = lazyChunk(() => import("../../data_export/export").then(m => ({ default: m.ExportCollectionAction })));
14
14
  import { EditorCollectionAction } from "../../collection_editor/ui/EditorCollectionAction";
15
15
  import { useCollectionEditorController } from "../../collection_editor/useCollectionEditorController";
16
16
  import { useAdminContext } from "../../hooks/useAdminContext";
@@ -8,6 +8,7 @@ import {
8
8
  getCollectionDataPath
9
9
  } from "@rebasepro/types";
10
10
  import {
11
+ CollectionCustomViewParams,
11
12
  CollectionSize,
12
13
  EntityTableController,
13
14
  PartialCollectionConfig,
@@ -23,7 +24,7 @@ import { getEntityChildViews } from "@rebasepro/common";
23
24
  import { useCollectionInlineEditor } from "./hooks/useCollectionInlineEditor";
24
25
  import { navigateToEntity } from "../../util/navigation_utils";
25
26
  import { mergeEntityActions } from "../../util/entity_actions";
26
- import { resolveEntityAction } from "../../util/resolutions";
27
+ import { resolveCollectionViews, resolveEntityAction } from "../../util/resolutions";
27
28
  import { getPropertyInPath } from "../../util/property_utils";
28
29
  import { ReferencePreview } from "../../preview";
29
30
  import {
@@ -74,7 +75,7 @@ import { DeleteEntityDialog } from "../DeleteEntityDialog";
74
75
  import { useSelectionController } from "./useSelectionController";
75
76
  import { CollectionViewStartActions } from "./CollectionViewStartActions";
76
77
  import { addRecentId, getRecentIds } from "./utils";
77
- import { isViewMode, OpenEntityMode, resolveOpenEntityMode, VIEW_MODE_PARAM } from "../../util/view_mode";
78
+ import { isViewMode, OpenEntityMode, resolveOpenEntityMode, VIEW_MODE_PARAM, VIEW_MODES } from "../../util/view_mode";
78
79
  import { mergeDeep } from "@rebasepro/utils";
79
80
  import { useBreadcrumbsController } from "../../hooks/useBreadcrumbsController";
80
81
  import { useAdminContext } from "../../hooks/useAdminContext";
@@ -83,7 +84,7 @@ import { useSidePanel } from "../../hooks/useSidePanel";
83
84
  import { useUrlController } from "../../hooks/navigation/contexts/UrlContext";
84
85
  import { useChildViewSource } from "../../hooks/useChildViewSource";
85
86
  import { useSelectionDialog } from "../../hooks/useSelectionDialog";
86
- import { saveEntityWithCallbacks } from "@rebasepro/app";
87
+ import { resolveComponentRef, saveEntityWithCallbacks } from "@rebasepro/app";
87
88
 
88
89
  const EMPTY_ARRAY: never[] = [];
89
90
 
@@ -250,21 +251,47 @@ const CollectionViewBindingInner = React.memo(
250
251
 
251
252
  const [popOverOpen, setPopOverOpen] = useState(false);
252
253
 
254
+ // Custom view modes this collection can render: the ones declared
255
+ // inline plus the ones it names by key from the app-level registry.
256
+ // Anything unresolvable is dropped here, once, so every consumer below
257
+ // — the switcher, `enabledViews`, the URL and saved-config checks —
258
+ // agrees on what exists.
259
+ const resolvedCustomViews = useMemo(
260
+ () => resolveCollectionViews(collection.customViews, customizationController.collectionViews),
261
+ [collection.customViews, customizationController]
262
+ );
263
+
264
+ const customViewKeys = useMemo(
265
+ () => resolvedCustomViews.map((v) => v.key),
266
+ [resolvedCustomViews]
267
+ );
268
+
253
269
  // View mode priority: URL > saved user config > collection.defaultViewMode
254
- const defaultViewMode = collection.defaultViewMode ?? "list";
270
+ const defaultViewMode = isViewMode(collection.defaultViewMode, customViewKeys)
271
+ ? collection.defaultViewMode
272
+ // Covers a `defaultViewMode` naming a custom view that has since
273
+ // been removed from config; without this the collection would open
274
+ // on a mode nothing renders.
275
+ : "list";
255
276
  const [searchParams, setSearchParams] = useSearchParams();
256
277
 
257
278
  // Read view from React Router's searchParams (reactive on back/forward)
258
279
  const urlView = useMemo((): ViewMode | null => {
259
280
  const v = searchParams.get(VIEW_MODE_PARAM);
260
- return isViewMode(v) ? v : null;
261
- }, [searchParams]);
262
-
263
- // Get saved view from local persistence
281
+ return isViewMode(v, customViewKeys) ? v : null;
282
+ }, [searchParams, customViewKeys]);
283
+
284
+ // Get saved view from local persistence.
285
+ //
286
+ // Validated rather than cast: what is stored is whatever the user last
287
+ // picked, and a custom view removed from config since then would come
288
+ // back as a mode nothing renders — a blank panel with a working
289
+ // toolbar above it. An unrecognised value reads as "nothing saved",
290
+ // which falls through to the collection default.
264
291
  const getSavedView = useCallback((): ViewMode | null => {
265
292
  const saved = userConfigPersistence?.getCollectionConfig<M>(path)?.defaultViewMode;
266
- return (saved as ViewMode) ?? null;
267
- }, [userConfigPersistence, path]);
293
+ return isViewMode(saved, customViewKeys) ? saved : null;
294
+ }, [userConfigPersistence, path, customViewKeys]);
268
295
 
269
296
  const [viewMode, setViewModeState] = useState<ViewMode>(() => {
270
297
  // Priority: URL > saved config > collection default
@@ -274,9 +301,15 @@ const CollectionViewBindingInner = React.memo(
274
301
  return defaultViewMode;
275
302
  });
276
303
 
304
+ const activeCustomView = useMemo(
305
+ () => resolvedCustomViews.find((v) => v.key === viewMode),
306
+ [resolvedCustomViews, viewMode]
307
+ );
308
+
277
309
  const openEntityMode = resolveOpenEntityMode({
278
310
  collection,
279
- viewMode
311
+ viewMode,
312
+ customView: activeCustomView
280
313
  });
281
314
 
282
315
  // Sync URL with current view on init (if view came from saved config)
@@ -335,6 +368,12 @@ const CollectionViewBindingInner = React.memo(
335
368
  // Table view size state - controls row height
336
369
  const [tableSize, setTableSize] = useState<CollectionSize>(collection.defaultSize ?? "m");
337
370
 
371
+ // Size for custom views that opted in with `sizeable`. One piece of
372
+ // state for all of them: only the active view reads it, and switching
373
+ // between two sizeable custom views keeping the same density is the
374
+ // less surprising behaviour.
375
+ const [customViewSize, setCustomViewSize] = useState<CollectionSize>(collection.defaultSize ?? "m");
376
+
338
377
  const selectionController = useSelectionController<M>();
339
378
  const usedSelectionController = collection.selectionController ?? selectionController;
340
379
  const {
@@ -513,7 +552,7 @@ parentEntityIds: parentEntityIds ?? EMPTY_ARRAY,
513
552
  onNewClick
514
553
  });
515
554
 
516
- const pluginInsights = useSlot("collection.insights", {
555
+ const pluginWidgets = useSlot("collection.widgets", {
517
556
  path,
518
557
  parentCollectionSlugs: parentCollectionSlugs ?? EMPTY_ARRAY,
519
558
  parentEntityIds: parentEntityIds ?? EMPTY_ARRAY,
@@ -587,19 +626,26 @@ parentEntityIds: parentEntityIds ?? EMPTY_ARRAY,
587
626
  }, [collection.kanban?.columnProperty, resolvedCollection.properties]);
588
627
 
589
628
  // Compute the effective enabled views:
590
- // - Start from collection.enabledViews (defaults to all three)
629
+ // - Start from collection.enabledViews (defaults to every built-in
630
+ // plus every custom view the collection declared)
631
+ // - Drop anything that resolves to nothing renderable
591
632
  // - Filter out kanban if no enum properties exist
592
633
  const hasEnumProperty = useMemo(() => {
593
634
  return Object.values(resolvedCollection.properties).some((p: Property) => p.type === "string" && "enum" in p && p.enum);
594
635
  }, [resolvedCollection.properties]);
595
636
 
596
637
  const enabledViews: ViewMode[] = useMemo(() => {
597
- const configured = collection.enabledViews ?? ["list", "table", "cards", "kanban"];
638
+ // Declaring a custom view is enough to offer it — a collection
639
+ // should not have to restate the built-ins in `enabledViews` just
640
+ // to add one. Setting `enabledViews` explicitly still wins.
641
+ const configured = collection.enabledViews
642
+ ?? [...VIEW_MODES, ...customViewKeys];
643
+ const renderable = configured.filter(v => isViewMode(v, customViewKeys));
598
644
  if (!hasEnumProperty) {
599
- return configured.filter(v => v !== "kanban");
645
+ return renderable.filter(v => v !== "kanban");
600
646
  }
601
- return configured;
602
- }, [collection.enabledViews, hasEnumProperty]);
647
+ return renderable;
648
+ }, [collection.enabledViews, hasEnumProperty, customViewKeys]);
603
649
 
604
650
  // Compute available enum properties for kanban column selection
605
651
  const kanbanPropertyOptions: KanbanPropertyOption[] = useMemo(() => {
@@ -938,8 +984,9 @@ parentEntityIds: parentEntityIds ?? EMPTY_ARRAY
938
984
  viewMode={viewMode}
939
985
  onViewModeChange={onViewModeChange}
940
986
  enabledViews={enabledViews}
941
- size={viewMode === "list" ? listSize : viewMode === "table" ? tableSize : viewMode === "cards" ? cardSize : undefined}
942
- onSizeChanged={viewMode === "list" ? onListSizeChanged : viewMode === "table" ? onTableSizeChanged : viewMode === "cards" ? setCardSize : undefined}
987
+ customViews={resolvedCustomViews}
988
+ size={activeCustomView ? (activeCustomView.sizeable ? customViewSize : undefined) : viewMode === "list" ? listSize : viewMode === "table" ? tableSize : viewMode === "cards" ? cardSize : undefined}
989
+ onSizeChanged={activeCustomView ? (activeCustomView.sizeable ? setCustomViewSize : undefined) : viewMode === "list" ? onListSizeChanged : viewMode === "table" ? onTableSizeChanged : viewMode === "cards" ? setCardSize : undefined}
943
990
  open={viewModePopoverOpen}
944
991
  onOpenChange={setViewModePopoverOpen}
945
992
  kanbanPropertyOptions={kanbanPropertyOptions}
@@ -1014,7 +1061,37 @@ parentEntityIds,
1014
1061
  </ResolvedCollectionActions>
1015
1062
  }
1016
1063
  />
1017
- ); const innerView = viewMode === "kanban" ? (
1064
+ );
1065
+
1066
+ // A custom view wins over the built-in chain, but only after the
1067
+ // built-in keys have been ruled out — `resolveCollectionViews` refuses
1068
+ // a view keyed "table", so this cannot shadow one.
1069
+ const CustomViewBuilder = activeCustomView
1070
+ ? resolveComponentRef<CollectionCustomViewParams<M>>(activeCustomView.Builder)
1071
+ : undefined;
1072
+
1073
+ const innerView = CustomViewBuilder ? (
1074
+ <ErrorBoundary>
1075
+ <CustomViewBuilder
1076
+ key={`custom-view-${activeCustomView!.key}-${path}`}
1077
+ collection={collection}
1078
+ tableController={tableController}
1079
+ path={path}
1080
+ parentCollectionSlugs={parentCollectionSlugs}
1081
+ parentEntityIds={parentEntityIds}
1082
+ onEntityClick={onEntityClick}
1083
+ onNewClick={canCreateEntities ? onNewClick : undefined}
1084
+ canCreate={canCreateEntities}
1085
+ selectionController={usedSelectionController}
1086
+ selectionEnabled={selectionEnabled}
1087
+ highlightedEntities={highlightedEntity ? [highlightedEntity] : []}
1088
+ deletedEntities={deletedEntities}
1089
+ emptyComponent={emptyComponent}
1090
+ size={activeCustomView!.sizeable ? customViewSize : undefined}
1091
+ viewMode={viewMode}
1092
+ />
1093
+ </ErrorBoundary>
1094
+ ) : viewMode === "kanban" ? (
1018
1095
  <CollectionBoardViewBinding
1019
1096
  key={`kanban-view-${path}-${selectedKanbanProperty}`}
1020
1097
  collection={collection}
@@ -1124,7 +1201,7 @@ parentEntityIds,
1124
1201
  </Typography>
1125
1202
  </div>
1126
1203
  </div>
1127
- {pluginInsights.length > 0 && (
1204
+ {pluginWidgets.length > 0 && (
1128
1205
  <div
1129
1206
  className={cls(
1130
1207
  "grid transition-[grid-template-rows] duration-150 ease-out",
@@ -1132,7 +1209,7 @@ parentEntityIds,
1132
1209
  )}
1133
1210
  >
1134
1211
  <div className="overflow-hidden flex-shrink-0">
1135
- {pluginInsights}
1212
+ {pluginWidgets}
1136
1213
  </div>
1137
1214
  </div>
1138
1215
  )}
@@ -1,6 +1,6 @@
1
1
 
2
2
  import React, { useMemo } from "react";
3
- import { CollectionSize, ViewMode } from "@rebasepro/admin-types";
3
+ import { CollectionCustomView, CollectionSize, ViewMode } from "@rebasepro/admin-types";
4
4
  import {
5
5
  Button,
6
6
  ColumnsIcon,
@@ -15,7 +15,7 @@ import {
15
15
  ToggleButtonGroup,
16
16
  ToggleButtonOption
17
17
  } from "@rebasepro/ui";
18
- import { useTranslation } from "@rebasepro/app";
18
+ import { getIcon, useTranslation } from "@rebasepro/app";
19
19
 
20
20
  export type KanbanPropertyOption = {
21
21
  key: string;
@@ -26,11 +26,17 @@ export type ViewModeToggleProps = {
26
26
  viewMode?: ViewMode;
27
27
  onViewModeChange?: (mode: ViewMode) => void;
28
28
  /**
29
- * Which view modes are enabled for this collection.
30
- * Only these modes will appear in the toggle.
31
- * Defaults to all three: ["table", "cards", "kanban"].
29
+ * Which view modes are enabled for this collection, in the order they
30
+ * should appear. Only these modes appear in the toggle.
31
+ * Defaults to the four built-ins.
32
32
  */
33
33
  enabledViews?: ViewMode[];
34
+ /**
35
+ * Custom views this collection can render, already resolved. Their `key`s
36
+ * appear in `enabledViews` alongside the built-ins, and each supplies the
37
+ * name and icon its entry is drawn with.
38
+ */
39
+ customViews?: CollectionCustomView<any>[];
34
40
  /**
35
41
  * Current size for card/table views
36
42
  */
@@ -67,6 +73,7 @@ export function ViewModeToggle({
67
73
  viewMode = "table",
68
74
  onViewModeChange,
69
75
  enabledViews = ALL_VIEW_MODES,
76
+ customViews,
70
77
  size,
71
78
  onSizeChanged,
72
79
  open,
@@ -85,7 +92,16 @@ export function ViewModeToggle({
85
92
  // labelled buttons open the toolbar together, and at 16 against its 20 this
86
93
  // one read as the smaller of the pair. The options inside the popover stay
87
94
  // at `smallest`, which is the size every other menu icon uses.
95
+ const activeCustomView = customViews?.find((v) => v.key === viewMode);
96
+
88
97
  const getViewModeIcon = () => {
98
+ if (activeCustomView) {
99
+ // Falls back to the list glyph rather than rendering nothing, so a
100
+ // custom view that declared no icon still gets a trigger the same
101
+ // width as every other mode's.
102
+ return getIcon(activeCustomView.icon, undefined, undefined, "small")
103
+ ?? <ListIcon size={iconSize.small}/>;
104
+ }
89
105
  if (viewMode === "kanban") return <KanbanIcon size={iconSize.small}/>;
90
106
  if (viewMode === "cards") return <LayoutGridIcon size={iconSize.small}/>;
91
107
  if (viewMode === "table") return <TableIcon size={iconSize.small}/>;
@@ -93,51 +109,69 @@ export function ViewModeToggle({
93
109
  };
94
110
 
95
111
  const getViewModeName = () => {
112
+ // The declared name, untranslated — it is app-supplied copy, and there
113
+ // is no key for it in the admin's bundles.
114
+ if (activeCustomView) return activeCustomView.name;
96
115
  if (viewMode === "kanban") return t("board");
97
116
  if (viewMode === "cards") return t("cards");
98
117
  if (viewMode === "table") return t("table_view_mode");
99
118
  return t("list");
100
119
  };
101
120
 
102
- const showSizeSelector = size && onSizeChanged && (viewMode === "list" || viewMode === "table" || viewMode === "cards");
121
+ const showSizeSelector = size && onSizeChanged &&
122
+ (activeCustomView
123
+ ? activeCustomView.sizeable
124
+ : viewMode === "list" || viewMode === "table" || viewMode === "cards");
103
125
  const showKanbanPropertySelector = viewMode === "kanban" &&
104
126
  kanbanPropertyOptions &&
105
127
  kanbanPropertyOptions.length > 0 &&
106
128
  onKanbanPropertyChange;
107
129
 
108
- // Build toggle options
130
+ // Build toggle options, in `enabledViews` order.
131
+ //
132
+ // This used to return all four built-ins unconditionally: `enabledViews`
133
+ // was destructured and then read nowhere, so a collection that asked for
134
+ // `["list", "table"]` still offered cards and kanban, and picking one
135
+ // rendered it. Honouring the prop is what the doc comment always claimed,
136
+ // and it is what lets a custom-only `enabledViews` hide the switcher.
109
137
  const viewModeOptions: ToggleButtonOption<ViewMode>[] = useMemo(() => {
110
- const allOptions: ToggleButtonOption<ViewMode>[] = [
111
- {
112
- value: "list",
113
- label: t("list"),
114
- icon: <ListIcon size={iconSize.smallest}/>
115
- },
116
- {
117
- value: "table",
118
- label: t("table_view_mode"),
119
- icon: <TableIcon size={iconSize.smallest}/>
120
- },
121
- {
122
- value: "cards",
123
- label: t("cards"),
124
- icon: <LayoutGridIcon size={iconSize.smallest}/>
125
- },
126
- {
127
- value: "kanban",
128
- label: t("board"),
129
- icon: <KanbanIcon size={iconSize.smallest}/>
130
- }
131
- ];
132
-
133
- return allOptions;
134
- }, [t]);
138
+ const builtIns: Record<string, { label: string, icon: React.ReactNode }> = {
139
+ list: { label: t("list"), icon: <ListIcon size={iconSize.smallest}/> },
140
+ table: { label: t("table_view_mode"), icon: <TableIcon size={iconSize.smallest}/> },
141
+ cards: { label: t("cards"), icon: <LayoutGridIcon size={iconSize.smallest}/> },
142
+ kanban: { label: t("board"), icon: <KanbanIcon size={iconSize.smallest}/> }
143
+ };
144
+
145
+ return enabledViews.flatMap((mode) => {
146
+ const builtIn = builtIns[mode];
147
+ if (builtIn) return [{ value: mode, ...builtIn }];
148
+
149
+ const custom = customViews?.find((v) => v.key === mode);
150
+ // An `enabledViews` entry naming nothing registered is skipped
151
+ // rather than drawn as a nameless button.
152
+ if (!custom) return [];
153
+ return [{
154
+ value: mode,
155
+ label: custom.name,
156
+ icon: getIcon(custom.icon, undefined, undefined, "smallest")
157
+ ?? <ListIcon size={iconSize.smallest}/>
158
+ }];
159
+ });
160
+ }, [t, enabledViews, customViews]);
135
161
 
136
162
  // ── Guard (after hooks to preserve Rules of Hooks) ──────────────
137
163
  if (!onViewModeChange) {
138
164
  return null;
139
165
  }
140
166
 
167
+ // Nothing to switch between and nothing else in the popover: a trigger
168
+ // that opens an empty panel is worse than no trigger. The size and
169
+ // group-by selectors live behind the same button, so a single-mode
170
+ // collection that still has one of those keeps it.
171
+ if (viewModeOptions.length <= 1 && !showSizeSelector && !showKanbanPropertySelector) {
172
+ return null;
173
+ }
174
+
141
175
  return (
142
176
  <div className="overflow-visible">
143
177
  <Popover
@@ -1,7 +1,7 @@
1
1
  import type { AdminCollection } from "@rebasepro/admin-types";
2
2
  import type { Entity } from "@rebasepro/types";
3
3
  import type { FormContext } from "../types/fields";
4
- import React, { lazy, Suspense, useEffect } from "react";
4
+ import React, { Suspense, useEffect } from "react";
5
5
  import {
6
6
  CircularProgressCenter,
7
7
  cls,
@@ -9,6 +9,7 @@ import {
9
9
  ErrorBoundary,
10
10
  IconButton,
11
11
  iconSize,
12
+ lazyChunk,
12
13
  Tab,
13
14
  Tabs,
14
15
  XIcon
@@ -16,7 +17,7 @@ import {
16
17
  import { useTranslation } from "@rebasepro/app";
17
18
  import { JsonPreviewBinding } from "./JsonPreviewBinding";
18
19
 
19
- const EntityHistoryView = lazy(() => import("./history").then(m => ({ default: m.EntityHistoryView })));
20
+ const EntityHistoryView = lazyChunk(() => import("./history").then(m => ({ default: m.EntityHistoryView })));
20
21
 
21
22
  export type InspectorTab = "json" | "history";
22
23
 
@@ -76,7 +76,7 @@ export function NavigationCardBinding({
76
76
  context
77
77
  });
78
78
 
79
- const pluginCardInsights = useSlot("home.card.insight", {
79
+ const pluginCardWidgets = useSlot("home.card.widget", {
80
80
  slug: slug,
81
81
  collection: collection!,
82
82
  context
@@ -92,7 +92,7 @@ export function NavigationCardBinding({
92
92
  name={name}
93
93
  description={description}
94
94
  actions={actions}
95
- additionalContent={pluginCardInsights.length > 0 ? <>{pluginCardInsights}</> : undefined}
95
+ additionalContent={pluginCardWidgets.length > 0 ? <>{pluginCardWidgets}</> : undefined}
96
96
  onClick={() => {
97
97
  onClick?.();
98
98
  navigate(url);
@@ -10,7 +10,7 @@ import type { RebaseAdminConfig } from "@rebasepro/admin-types";
10
10
  * is auto-wired as a native feature (slots, provider, Studio view) without
11
11
  * needing any external plugin.
12
12
  */
13
- export function RebaseAdmin({ collections, views, homePage, entityViews, entityActions, collectionEditor, navigationGroupMappings, basePath }: RebaseAdminConfig) {
13
+ export function RebaseAdmin({ collections, views, homePage, entityViews, collectionViews, entityActions, collectionEditor, navigationGroupMappings, basePath }: RebaseAdminConfig) {
14
14
  const dispatch = useRebaseRegistryDispatch();
15
15
 
16
16
  useLayoutEffect(() => {
@@ -18,12 +18,13 @@ export function RebaseAdmin({ collections, views, homePage, entityViews, entityA
18
18
  views,
19
19
  homePage,
20
20
  entityViews,
21
+ collectionViews,
21
22
  entityActions,
22
23
  collectionEditor,
23
24
  navigationGroupMappings,
24
25
  basePath });
25
26
  return () => dispatch.unregisterAdmin();
26
- }, [dispatch, collections, views, homePage, entityViews, entityActions, collectionEditor, navigationGroupMappings, basePath]);
27
+ }, [dispatch, collections, views, homePage, entityViews, collectionViews, entityActions, collectionEditor, navigationGroupMappings, basePath]);
27
28
 
28
29
  return null;
29
30
  }