@rebasepro/admin 0.11.1-canary.gfd39654 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/README.md +1 -1
  2. package/dist/{CollectionEditorDialog-o7F_zbGY.js → CollectionEditorDialog-Dpc75wIZ.js} +56 -260
  3. package/dist/CollectionEditorDialog-Dpc75wIZ.js.map +1 -0
  4. package/dist/{PropertyEditView-DgB2b22q.js → PropertyEditView-DH3XZC2x.js} +2 -2
  5. package/dist/{PropertyEditView-DgB2b22q.js.map → PropertyEditView-DH3XZC2x.js.map} +1 -1
  6. package/dist/{RouterCollectionsStudioView-DaLRgPkj.js → RouterCollectionsStudioView-BZLq4MGp.js} +4 -4
  7. package/dist/{RouterCollectionsStudioView-DaLRgPkj.js.map → RouterCollectionsStudioView-BZLq4MGp.js.map} +1 -1
  8. package/dist/collection_editor/CollectionEditorDialogsContext.d.ts +1 -1
  9. package/dist/collection_editor/_cms_internals.d.ts +1 -1
  10. package/dist/collection_editor/extensibility_types.d.ts +1 -1
  11. package/dist/collection_editor/serializable_types.d.ts +40 -12
  12. package/dist/collection_editor/serializable_utils.d.ts +1 -1
  13. package/dist/collection_editor/ui/collection_editor/CollectionRLSTab.d.ts +1 -11
  14. package/dist/collection_editor_ui.js +4 -4
  15. package/dist/components/CollectionEditorDialogs.d.ts +2 -2
  16. package/dist/components/CollectionTableBinding/column_utils.d.ts +7 -1
  17. package/dist/components/CollectionViewBinding/CollectionListViewBinding.d.ts +2 -2
  18. package/dist/components/DefaultDrawer.d.ts +3 -3
  19. package/dist/components/DrawerNavigationGroup.d.ts +8 -1
  20. package/dist/components/DrawerNavigationItem.d.ts +7 -1
  21. package/dist/components/EditViewBinding.d.ts +1 -1
  22. package/dist/components/RebaseAdmin.d.ts +1 -1
  23. package/dist/components/RebaseAuthGate.d.ts +1 -1
  24. package/dist/components/RebaseLayout.d.ts +5 -1
  25. package/dist/components/RebaseNavigation.d.ts +2 -2
  26. package/dist/components/RebaseRouteDefs.d.ts +1 -1
  27. package/dist/components/RebaseShell.d.ts +6 -1
  28. package/dist/components/RelationSelector.d.ts +14 -0
  29. package/dist/components/SelectableTable/filters/RelationFilterField.d.ts +27 -0
  30. package/dist/components/SelectableTable/filters/null_filter.d.ts +46 -0
  31. package/dist/components/SidePanelProvider.d.ts +2 -2
  32. package/dist/components/app/Scaffold.d.ts +11 -0
  33. package/dist/components/field_configs.d.ts +1 -1
  34. package/dist/{export-BvqhjdEb.js → export-BzIpUdSK.js} +3 -3
  35. package/dist/export-BzIpUdSK.js.map +1 -0
  36. package/dist/form/EntityForm.d.ts +2 -2
  37. package/dist/form/EntityFormBinding.d.ts +5 -5
  38. package/dist/{history-Dz20hH38.js → history-g9688IaK.js} +2 -2
  39. package/dist/{history-Dz20hH38.js.map → history-g9688IaK.js.map} +1 -1
  40. package/dist/hooks/index.d.ts +1 -1
  41. package/dist/hooks/useAdminContext.d.ts +29 -0
  42. package/dist/hooks/useHistory.d.ts +7 -11
  43. package/dist/{import-DINSB-Vu.js → import-BjUG-cIY.js} +2 -2
  44. package/dist/{import-DINSB-Vu.js.map → import-BjUG-cIY.js.map} +1 -1
  45. package/dist/index.js +61 -38
  46. package/dist/index.js.map +1 -1
  47. package/dist/types/components/EntityFormProps.d.ts +1 -1
  48. package/dist/types/fields.d.ts +1 -1
  49. package/dist/{util-BhhfHY1M.js → util-B5fJm1FA.js} +296 -169
  50. package/dist/util-B5fJm1FA.js.map +1 -0
  51. package/package.json +9 -9
  52. package/src/collection_editor/CollectionEditorDialogsContext.tsx +1 -1
  53. package/src/collection_editor/ConfigControllerProvider.tsx +1 -1
  54. package/src/collection_editor/_cms_internals.ts +1 -1
  55. package/src/collection_editor/extensibility_types.ts +1 -1
  56. package/src/collection_editor/index.ts +2 -2
  57. package/src/collection_editor/serializable_types.ts +44 -13
  58. package/src/collection_editor/serializable_utils.ts +68 -59
  59. package/src/collection_editor/ui/collection_editor/CollectionEditorDialog.tsx +1 -1
  60. package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +66 -41
  61. package/src/collection_editor/ui/collection_editor/EntityActionsEditTab.tsx +1 -1
  62. package/src/collection_editor/ui/collection_editor/GetCodeDialog.tsx +1 -1
  63. package/src/collection_editor/ui/collection_editor/SubcollectionsEditTab.tsx +2 -2
  64. package/src/components/CollectionEditorDialogs.tsx +2 -2
  65. package/src/components/CollectionTableBinding/CollectionRowActions.tsx +5 -4
  66. package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +8 -2
  67. package/src/components/CollectionTableBinding/column_utils.tsx +19 -5
  68. package/src/components/CollectionTableBinding/table_bindings.tsx +10 -10
  69. package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +8 -7
  70. package/src/components/CollectionViewBinding/CollectionViewActions.tsx +2 -2
  71. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +2 -2
  72. package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +2 -2
  73. package/src/components/DefaultDrawer.tsx +19 -8
  74. package/src/components/DeleteEntityDialog.tsx +2 -2
  75. package/src/components/DetailViewBinding.tsx +21 -1
  76. package/src/components/DrawerNavigationGroup.tsx +47 -4
  77. package/src/components/DrawerNavigationItem.tsx +19 -5
  78. package/src/components/EditFormActions.tsx +4 -3
  79. package/src/components/EditViewBinding.tsx +1 -1
  80. package/src/components/HomePage/ContentHomePage.tsx +4 -4
  81. package/src/components/RebaseAdmin.tsx +3 -3
  82. package/src/components/RebaseAuthGate.tsx +1 -1
  83. package/src/components/RebaseLayout.tsx +8 -2
  84. package/src/components/RebaseNavigation.tsx +9 -9
  85. package/src/components/RebaseRouteDefs.tsx +2 -2
  86. package/src/components/RebaseShell.tsx +10 -1
  87. package/src/components/RelationSelector.tsx +18 -3
  88. package/src/components/SelectableTable/filters/FilterFieldBinding.tsx +1 -1
  89. package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +36 -9
  90. package/src/components/SelectableTable/filters/RelationFilterField.tsx +93 -17
  91. package/src/components/SelectableTable/filters/null_filter.ts +63 -0
  92. package/src/components/SidePanelProvider.tsx +2 -2
  93. package/src/components/app/Scaffold.tsx +16 -1
  94. package/src/data_export/export/ExportCollectionAction.tsx +2 -2
  95. package/src/form/EntityForm.tsx +2 -2
  96. package/src/form/EntityFormActions.tsx +2 -1
  97. package/src/form/EntityFormBinding.tsx +7 -7
  98. package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +3 -3
  99. package/src/form/field_bindings/MultipleRelationFieldBinding.tsx +16 -5
  100. package/src/form/field_bindings/ReferenceFieldBinding.tsx +3 -3
  101. package/src/form/field_bindings/RelationFieldBinding.tsx +17 -6
  102. package/src/form/field_bindings/RepeatFieldBinding.tsx +2 -2
  103. package/src/hooks/index.ts +1 -1
  104. package/src/hooks/navigation/useNavigationResolution.ts +10 -4
  105. package/src/hooks/{useCMSContext.tsx → useAdminContext.tsx} +10 -10
  106. package/src/hooks/useHistory.ts +7 -11
  107. package/src/hooks/useResolvedNavigationFrom.tsx +2 -2
  108. package/src/index.ts +4 -4
  109. package/src/preview/PropertyPreview.tsx +6 -6
  110. package/src/preview/components/RelationPreview.tsx +1 -1
  111. package/src/preview/property_previews/ArrayOfMapsPreview.tsx +1 -1
  112. package/src/preview/property_previews/ArrayOfReferencesPreview.tsx +2 -2
  113. package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +2 -2
  114. package/src/preview/property_previews/SkeletonPropertyComponent.tsx +2 -2
  115. package/src/types/components/EntityFormProps.tsx +2 -2
  116. package/src/types/fields.tsx +1 -1
  117. package/dist/CollectionEditorDialog-o7F_zbGY.js.map +0 -1
  118. package/dist/collection_editor/pgColumnToProperty.d.ts +0 -7
  119. package/dist/editor/extensions/Image/index.d.ts +0 -6
  120. package/dist/export-BvqhjdEb.js.map +0 -1
  121. package/dist/hooks/useCMSContext.d.ts +0 -29
  122. package/dist/util-BhhfHY1M.js.map +0 -1
  123. package/src/collection_editor/pgColumnToProperty.ts +0 -291
  124. package/src/editor/extensions/Image/index.ts +0 -133
@@ -24,7 +24,7 @@ import { useRestoreScroll } from "@rebasepro/app";
24
24
 
25
25
  import { SchemaDriftBanner } from "@rebasepro/app";
26
26
  import { useBreadcrumbsController } from "../../hooks/useBreadcrumbsController";
27
- import { useCMSContext } from "../../hooks/useCMSContext";
27
+ import { useAdminContext } from "../../hooks/useAdminContext";
28
28
 
29
29
  export const DEFAULT_GROUP_NAME = "Views";
30
30
  export const ADMIN_GROUP_NAME = "Admin";
@@ -43,7 +43,7 @@ export function ContentHomePage({
43
43
  hiddenGroups?: string[];
44
44
  }) {
45
45
 
46
- const context = useCMSContext();
46
+ const context = useAdminContext();
47
47
  const { navigationStateController } = context;
48
48
  const customizationController = useCustomizationController();
49
49
  const adminModeController = useAdminModeController();
@@ -65,7 +65,7 @@ export function ContentHomePage({
65
65
  } = navigationStateController.topLevelNavigation || {};
66
66
 
67
67
  // Build a set of Studio-only dev view slugs so we can distinguish them from
68
- // CMS custom views added via <RebaseAdmin views={...}> or plugins.
68
+ // admin custom views added via <RebaseAdmin views={...}> or plugins.
69
69
  const studioViewSlugs = useMemo(() => {
70
70
  const slugs = new Set<string>();
71
71
  (registry.studioConfig?.devViews ?? []).forEach(v => slugs.add(v.slug));
@@ -74,7 +74,7 @@ export function ContentHomePage({
74
74
  }, [registry.studioConfig, registry.cmsConfig?.collectionEditor]);
75
75
 
76
76
  // Studio mode shows Studio dev views + admin entries (Users/Roles).
77
- // Content mode shows collections, CMS custom views, and admin entries — but not Studio dev views.
77
+ // Content mode shows collections, admin custom views, and admin entries — but not Studio dev views.
78
78
  const rawNavigationEntries = useMemo(() => {
79
79
  if (adminModeController.mode === "studio") {
80
80
  return unFilteredNavigationEntries.filter(e => (e.type === "view" && studioViewSlugs.has(e.slug)) || e.type === "admin");
@@ -3,7 +3,7 @@ import { useRebaseRegistryDispatch } from "@rebasepro/app";
3
3
  import type { RebaseAdminConfig } from "@rebasepro/admin-types";
4
4
 
5
5
  /**
6
- * Declarative component to configure the CMS in Rebase.
6
+ * Declarative component to configure the admin in Rebase.
7
7
  * Renders nothing — purely registers config into the RebaseRegistry.
8
8
  *
9
9
  * When `collectionEditor` is provided, the built-in visual schema editor
@@ -14,7 +14,7 @@ export function RebaseAdmin({ collections, views, homePage, entityViews, entityA
14
14
  const dispatch = useRebaseRegistryDispatch();
15
15
 
16
16
  useLayoutEffect(() => {
17
- dispatch.registerCMS({ collections,
17
+ dispatch.registerAdmin({ collections,
18
18
  views,
19
19
  homePage,
20
20
  entityViews,
@@ -22,7 +22,7 @@ entityActions,
22
22
  collectionEditor,
23
23
  navigationGroupMappings,
24
24
  basePath });
25
- return () => dispatch.unregisterCMS();
25
+ return () => dispatch.unregisterAdmin();
26
26
  }, [dispatch, collections, views, homePage, entityViews, entityActions, collectionEditor, navigationGroupMappings, basePath]);
27
27
 
28
28
  return null;
@@ -15,7 +15,7 @@ import type { AuthControllerExtended } from "@rebasepro/admin-types";
15
15
  * - Renders `children` when a user is authenticated.
16
16
  *
17
17
  * **Independently usable**: Use this alone when you want auth gating
18
- * without the full CMS layout or navigation.
18
+ * without the full admin layout or navigation.
19
19
  *
20
20
  * @example
21
21
  * ```tsx
@@ -19,8 +19,12 @@ export interface RebaseLayoutProps {
19
19
  appBar?: React.ReactNode;
20
20
  /** Custom Drawer to override the default. */
21
21
  drawer?: React.ReactNode;
22
- /** Whether to auto-open the drawer on load. */
22
+ /** Expand the drawer while the pointer is over it. Not a load-time default — that is `defaultDrawerOpen`. */
23
23
  autoOpenDrawer?: boolean;
24
+ /** Start with the drawer expanded instead of collapsed to icons. Ignored on small layouts. */
25
+ defaultDrawerOpen?: boolean;
26
+ /** Path to the logo shown in the drawer and top bar. Falls back to the Rebase mark. */
27
+ logo?: string;
24
28
  /** Dev views passed to AdminModeSyncer (resolved from RebaseNavigation). */
25
29
  devViews?: AppView[];
26
30
  }
@@ -49,6 +53,8 @@ export function RebaseLayout(props: RebaseLayoutProps) {
49
53
  appBar,
50
54
  drawer,
51
55
  autoOpenDrawer = false,
56
+ defaultDrawerOpen = false,
57
+ logo,
52
58
  devViews = []
53
59
  } = props;
54
60
 
@@ -63,7 +69,7 @@ export function RebaseLayout(props: RebaseLayoutProps) {
63
69
  );
64
70
 
65
71
  return (
66
- <Scaffold autoOpenDrawer={autoOpenDrawer}>
72
+ <Scaffold autoOpenDrawer={autoOpenDrawer} defaultDrawerOpen={defaultDrawerOpen} logo={logo}>
67
73
  <AdminModeSyncer devViews={devViews}/>
68
74
  {appBar}
69
75
  {ActiveDrawer}
@@ -50,13 +50,13 @@ export interface RebaseNavigationProps {
50
50
  }
51
51
 
52
52
  /**
53
- * Navigation layer — builds and provides all CMS navigation controllers:
53
+ * Navigation layer — builds and provides all admin navigation controllers:
54
54
  * collection registry, URL controller, navigation state, side entity,
55
55
  * and the self-assembling Studio bridge.
56
56
  *
57
57
  * Also handles the collection editor config controller when enabled.
58
58
  *
59
- * **Independently usable**: Use this when you need CMS navigation
59
+ * **Independently usable**: Use this when you need admin navigation
60
60
  * (entity tables, side panels) in a custom layout.
61
61
  *
62
62
  * @example
@@ -76,7 +76,7 @@ export function RebaseNavigation({ children }: RebaseNavigationProps) {
76
76
 
77
77
  // ── Collection Editor resolution ──────────────────────────────────
78
78
  // The collection editor is ALWAYS enabled when Studio is registered.
79
- // The `collectionEditor` CMS config is for fine-tuning (readOnly, auth, etc.),
79
+ // The `collectionEditor` admin config is for fine-tuning (readOnly, auth, etc.),
80
80
  // not for opting-in. When omitted, the editor defaults to enabled
81
81
  // (read-only in production).
82
82
  const collectionEditorConfig = registry.cmsConfig?.collectionEditor;
@@ -86,7 +86,7 @@ export function RebaseNavigation({ children }: RebaseNavigationProps) {
86
86
  return collectionEditorConfig;
87
87
  }, [collectionEditorConfig]);
88
88
 
89
- // ── Combine CMS and Studio Configs ────────────────────────────────
89
+ // ── Combine admin and Studio Configs ────────────────────────────────
90
90
  const cmsCollections = registry.cmsConfig?.collections ?? EMPTY_COLLECTIONS;
91
91
 
92
92
  // ── Build the navigation controllers ──────────────────────────────
@@ -203,8 +203,8 @@ export function RebaseNavigation({ children }: RebaseNavigationProps) {
203
203
  return base;
204
204
  }, [registry.studioConfig?.devViews, schemaView]);
205
205
 
206
- // Merge CMS-registered views with Studio dev views.
207
- // Order: CMS views (developer's primary content) → Studio dev views (tooling).
206
+ // Merge admin-registered views with Studio dev views.
207
+ // Order: admin views (developer's primary content) → Studio dev views (tooling).
208
208
  // Plugin views are merged later inside resolveAppViews.
209
209
  const cmsViews = registry.cmsConfig?.views;
210
210
  const mergedViews = useMemo(() => {
@@ -234,7 +234,7 @@ export function RebaseNavigation({ children }: RebaseNavigationProps) {
234
234
  adminMode: adminModeController?.mode
235
235
  });
236
236
 
237
- // ── Merge CMS-registered entityViews/entityActions into the customization controller ──
237
+ // ── Merge admin-registered entityViews/entityActions into the customization controller ──
238
238
  // The <Rebase> component builds the customizationController from its own props,
239
239
  // but entity views passed to <RebaseAdmin> are stored in the registry and not
240
240
  // automatically merged. We re-provide an enriched controller here so that
@@ -260,7 +260,7 @@ export function RebaseNavigation({ children }: RebaseNavigationProps) {
260
260
  }, [parentCustomizationController, registry.cmsConfig?.entityViews, registry.cmsConfig?.entityActions]);
261
261
 
262
262
  // ── Inner content with all context providers ──────────────────────
263
- // Re-provide RebaseDataContext with the routed data so that every CMS
263
+ // Re-provide RebaseDataContext with the routed data so that every admin
264
264
  // consumer (list/entity views, references, board, import/export, and
265
265
  // `context.data`) is routed to the correct driver by collection path.
266
266
  const navigationContent = (
@@ -302,7 +302,7 @@ export function RebaseNavigation({ children }: RebaseNavigationProps) {
302
302
  }
303
303
 
304
304
  /**
305
- * Internal component that auto-registers CMS controllers into the
305
+ * Internal component that auto-registers admin controllers into the
306
306
  * self-assembling Studio bridge. Must be rendered inside both the
307
307
  * navigation contexts and the StudioBridgeRegistryProvider.
308
308
  */
@@ -45,7 +45,7 @@ export interface RebaseRouteDefsProps {
45
45
  }
46
46
 
47
47
  /**
48
- * Route definitions for the CMS.
48
+ * Route definitions for the admin.
49
49
  *
50
50
  * Defines all standard routes: home, studio home, collection view,
51
51
  * settings, debug, custom views, and a catch-all 404.
@@ -77,7 +77,7 @@ export function RebaseRouteDefs({ children, layout }: RebaseRouteDefsProps) {
77
77
 
78
78
  const routeContents = (
79
79
  <>
80
- {/* Core CMS Routes */}
80
+ {/* Core admin Routes */}
81
81
  <Route path={"/"} element={cmsHomePage}/>
82
82
  {registry.studioConfig && (
83
83
  <Route path={"/s"} element={studioHomePage}/>
@@ -14,12 +14,17 @@ export interface RebaseShellProps {
14
14
  /** Top bar. Only rendered if provided — pass `<AppBar/>` for the default one. */
15
15
  appBar?: React.ReactNode;
16
16
  drawer?: React.ReactNode;
17
+ /** Expand the drawer while the pointer is over it. Not a load-time default — that is `defaultDrawerOpen`. */
17
18
  autoOpenDrawer?: boolean;
19
+ /** Start with the drawer expanded instead of collapsed to icons. Ignored on small layouts. */
20
+ defaultDrawerOpen?: boolean;
21
+ /** Path to the logo shown in the drawer and top bar. Falls back to the Rebase mark. */
22
+ logo?: string;
18
23
  children?: React.ReactNode;
19
24
  }
20
25
 
21
26
  /**
22
- * Convenience component that composes all four CMS layers:
27
+ * Convenience component that composes all four admin layers:
23
28
  *
24
29
  * ```
25
30
  * <RebaseAuthGate>
@@ -40,6 +45,8 @@ export function RebaseShell(props: RebaseShellProps) {
40
45
  appBar,
41
46
  drawer,
42
47
  autoOpenDrawer = false,
48
+ defaultDrawerOpen = false,
49
+ logo,
43
50
  children
44
51
  } = props;
45
52
 
@@ -61,6 +68,8 @@ export function RebaseShell(props: RebaseShellProps) {
61
68
  appBar={appBar}
62
69
  drawer={drawer}
63
70
  autoOpenDrawer={autoOpenDrawer}
71
+ defaultDrawerOpen={defaultDrawerOpen}
72
+ logo={logo}
64
73
  devViews={devViews}
65
74
  />
66
75
  }
@@ -66,6 +66,20 @@ export interface RelationSelectorProps {
66
66
  invisible?: boolean;
67
67
 
68
68
  relation: Relation;
69
+ /**
70
+ * Whether the selector picks many rows or one.
71
+ *
72
+ * Defaults to what the relation implies — a `hasMany` or `manyToMany`
73
+ * picks many — which is right wherever the selector *edits* the relation,
74
+ * because there the cardinality is the shape of the stored value.
75
+ *
76
+ * A filter is the case where it is not. `tags == <one tag>` asks a single
77
+ * question about a to-many link, and the operator is what decides how many
78
+ * values it takes. Deriving multiplicity from the relation there meant a
79
+ * to-many relation could only ever emit a list, so the filter field had to
80
+ * hide every single-value operator to avoid handing `==` an array.
81
+ */
82
+ multiple?: boolean;
69
83
  fixedFilter?: FilterValues<string>;
70
84
  pageSize?: number;
71
85
  emptyPlaceholder?: string;
@@ -89,6 +103,7 @@ export const RelationSelector = React.forwardRef<
89
103
  useChips = true,
90
104
  className,
91
105
  relation,
106
+ multiple: multipleOverride,
92
107
  fixedFilter,
93
108
  pageSize,
94
109
  emptyPlaceholder,
@@ -102,7 +117,7 @@ export const RelationSelector = React.forwardRef<
102
117
  const collection = relation.target();
103
118
  const dataClient = useData();
104
119
  const sidePanelController = useSidePanel();
105
- const multiple = relationCardinality(relation) === "many";
120
+ const multiple = multipleOverride ?? relationCardinality(relation) === "many";
106
121
 
107
122
  const [isPopoverOpen, setIsPopoverOpen] = useState(false);
108
123
  const isPopoverOpenRef = useRef(false);
@@ -223,7 +238,7 @@ export const RelationSelector = React.forwardRef<
223
238
 
224
239
  if (hasEmbeddedData) {
225
240
  const r = rel as EntityRelation;
226
- // CMS wire format embeds relation data as { id, path, values }
241
+ // admin wire format embeds relation data as { id, path, values }
227
242
  newResolvedItems.push(toRelationItem(r.data as unknown as Entity, r));
228
243
  } else if (currentSelectedMap.has(relId)) {
229
244
  // We already have it in currentSelected
@@ -259,7 +274,7 @@ export const RelationSelector = React.forwardRef<
259
274
  const relation = isPrimitive ? new EntityRelation(relId, path) : rel as EntityRelation;
260
275
 
261
276
  if (!isPrimitive && (rel as EntityRelation).data) {
262
- // CMS wire format embeds relation data as { id, path, values }
277
+ // admin wire format embeds relation data as { id, path, values }
263
278
  return toRelationItem((rel as EntityRelation).data as unknown as Entity, relation);
264
279
  }
265
280
  if (currentSelectedMap.has(String(relId))) {
@@ -138,7 +138,7 @@ function DefaultFilterField({
138
138
  operators={operators}
139
139
  path={referenceProperty.path}
140
140
  title={title}
141
- includeId={referenceProperty.includeId}
141
+ includeId={referenceProperty.admin?.includeId}
142
142
  previewProperties={referenceProperty.admin?.previewProperties}
143
143
  hidden={hidden ?? false}
144
144
  setHidden={setHidden ?? (() => undefined)}/>;
@@ -1,6 +1,6 @@
1
1
  import { useSelectionDialog } from "../../../hooks/useSelectionDialog";
2
2
 
3
- import React, { useMemo, useState } from "react";
3
+ import React, { useId, useMemo, useState } from "react";
4
4
  import { VirtualTableWhereFilterOp } from "@rebasepro/ui";
5
5
  import { Entity, EntityReference } from "@rebasepro/types";
6
6
  import { ReferencePreview } from "../../../preview";
@@ -9,6 +9,7 @@ import { getReferenceFrom } from "@rebasepro/common";
9
9
  import { useTranslation } from "@rebasepro/app";
10
10
  import { useCollectionRegistryController } from "../../../hooks/navigation/contexts/CollectionRegistryContext";
11
11
  import type { AdminCollection } from "@rebasepro/admin-types";
12
+ import { isNullFilterOperator, nullFilterOperatorFor, valueOperatorFor } from "./null_filter";
12
13
 
13
14
  interface ReferenceFilterFieldProps {
14
15
  name: string,
@@ -39,7 +40,9 @@ const operationLabels = {
39
40
  in: "In",
40
41
  "not-in": "Not in",
41
42
  "array-contains": "Contains",
42
- "array-contains-any": "Contains Any"
43
+ "array-contains-any": "Contains Any",
44
+ "is-null": "Is null",
45
+ "is-not-null": "Is not null"
43
46
  };
44
47
 
45
48
  const multipleSelectOperations = ["array-contains-any", "in", "not-in"];
@@ -66,6 +69,9 @@ export function ReferenceFilterField({
66
69
  possibleOperations.push("in", "not-in");
67
70
  }
68
71
 
72
+ // The null checks take no operand, so they apply either way.
73
+ possibleOperations.push("is-null", "is-not-null");
74
+
69
75
  if (operators) {
70
76
  possibleOperations = possibleOperations.filter(op => (operators as readonly string[]).includes(op));
71
77
  }
@@ -97,6 +103,14 @@ export function ReferenceFilterField({
97
103
  setOperation(op);
98
104
  setInternalValue(newValue);
99
105
 
106
+ // A null check takes no operand, so it is complete on its own — the
107
+ // value gate below would otherwise clear the filter the moment one is
108
+ // chosen from the dropdown, where there is no value to accompany it.
109
+ if (isNullFilterOperator(op)) {
110
+ setValue([op, null]);
111
+ return;
112
+ }
113
+
100
114
  const hasNewValue = newValue !== null && Array.isArray(newValue)
101
115
  ? newValue.length > 0
102
116
  : newValue !== undefined;
@@ -125,6 +139,12 @@ export function ReferenceFilterField({
125
139
  };
126
140
 
127
141
  const multiple = multipleSelectOperations.includes(operation);
142
+ const nullFiltered = isNullFilterOperator(operation);
143
+
144
+ // The filters dialog renders every filterable property at once, and this
145
+ // id was the literal string "null-filter" — so clicking any label toggled
146
+ // whichever checkbox the document matched first, never the one clicked.
147
+ const nullFilterId = useId();
128
148
 
129
149
  const referenceDialogController = useSelectionDialog({
130
150
  multiselect: multiple,
@@ -200,17 +220,24 @@ export function ReferenceFilterField({
200
220
  </Button>
201
221
  }
202
222
 
223
+ {/*
224
+ * Switches the operator to the matching null check rather
225
+ * than pairing a null value with whatever operator is
226
+ * selected. Only the operator travels to the driver, and a
227
+ * null *operand* means different things to different ones —
228
+ * Mongo rejects `$in: null` outright. See
229
+ * `nullFilterOperatorFor`.
230
+ */}
203
231
  {!isArray && <Label
204
232
  className="border cursor-pointer rounded-md p-2 flex items-center gap-2 bg-surface-50 dark:bg-surface-900 hover:bg-surface-100 dark:hover:bg-surface-800"
205
- htmlFor="null-filter"
233
+ htmlFor={nullFilterId}
206
234
  >
207
- <Checkbox id="null-filter"
208
- checked={internalValue === null}
235
+ <Checkbox id={nullFilterId}
236
+ checked={nullFiltered}
209
237
  size={"small"}
210
- onCheckedChange={(checked) => {
211
- if (internalValue !== null)
212
- updateFilter(operation, null);
213
- else updateFilter(operation, undefined);
238
+ onCheckedChange={() => {
239
+ if (!nullFiltered) updateFilter(nullFilterOperatorFor(operation), null);
240
+ else updateFilter(valueOperatorFor(operation, possibleOperations) ?? operation, undefined);
214
241
  }}/>
215
242
  {t("filter_for_null_values")}
216
243
  </Label>}
@@ -1,8 +1,9 @@
1
- import React, { useMemo, useState } from "react";
1
+ import React, { useId, useMemo, useState } from "react";
2
2
  import { VirtualTableWhereFilterOp } from "@rebasepro/ui";
3
3
  import { EntityRelation, Relation } from "@rebasepro/types";
4
4
  import { Checkbox, Label, Select, SelectItem } from "@rebasepro/ui";
5
5
  import { RelationSelector } from "../../RelationSelector";
6
+ import { isNullFilterOperator, nullFilterOperatorFor, valueOperatorFor } from "./null_filter";
6
7
 
7
8
  /** Whether an authored relation yields many rows. Derived from its kind. */
8
9
  function relationCardinality(relation: { kind?: string; cardinality?: string } | undefined): "one" | "many" | undefined {
@@ -39,11 +40,60 @@ const operationLabels = {
39
40
  in: "In",
40
41
  "not-in": "Not in",
41
42
  "array-contains": "Contains",
42
- "array-contains-any": "Contains Any"
43
+ "array-contains-any": "Contains Any",
44
+ "is-null": "Is empty",
45
+ "is-not-null": "Is not empty"
43
46
  };
44
47
 
45
48
  const multipleSelectOperations = ["array-contains-any", "in", "not-in"];
46
49
 
50
+ /**
51
+ * What this field can render, before `operators` narrows it.
52
+ *
53
+ * Every relation gets the whole set. The value selector is told how many
54
+ * values to take by the *operator* — see the `multiple` prop passed below —
55
+ * so a to-many relation can be asked `== <one tag>` as readily as
56
+ * `in <several>`, and the question of what shape the relation stores never
57
+ * enters into it.
58
+ *
59
+ * That is a change: this list used to be split by the value the selector
60
+ * produced, because the selector took its multiplicity from the relation's
61
+ * cardinality and so a to-many one could only ever emit a list. Every
62
+ * single-value operator had to be withheld to keep an array from reaching
63
+ * `==`. `ReferenceFilterField` never had the problem — it has always sized its
64
+ * dialog from the operator — which is what made the split look like a
65
+ * property of relations rather than a limitation of one component.
66
+ *
67
+ * The array operators are the exception, and belong only to a to-many link,
68
+ * which is the only one that *is* a list. On a to-one relation the filter
69
+ * compiles to a comparison on a scalar foreign key, and `@>` against one is a
70
+ * type error rather than an empty result.
71
+ */
72
+ const RELATION_OPERATIONS: (keyof typeof operationLabels)[] =
73
+ ["==", "!=", ">", "<", ">=", "<=", "in", "not-in", "is-null", "is-not-null"];
74
+ const LIST_ONLY_OPERATIONS: (keyof typeof operationLabels)[] =
75
+ ["array-contains", "array-contains-any"];
76
+
77
+ /**
78
+ * The operators this field will actually put on screen.
79
+ *
80
+ * Exported because the failure mode is silence: when the intersection of what
81
+ * the field can render and what the engine can run comes out empty, the field
82
+ * returns null and the filter control the table header offered opens onto
83
+ * nothing. That is a rendering outcome no unit test of either side alone
84
+ * catches, so the intersection itself is the thing worth pinning.
85
+ */
86
+ export function renderableRelationOperators(
87
+ relation: { kind?: string; cardinality?: string } | undefined,
88
+ operators?: readonly VirtualTableWhereFilterOp[]
89
+ ): (keyof typeof operationLabels)[] {
90
+ const possible = relationCardinality(relation) === "many"
91
+ ? [...RELATION_OPERATIONS, ...LIST_ONLY_OPERATIONS]
92
+ : RELATION_OPERATIONS;
93
+ if (!operators) return possible;
94
+ return possible.filter(op => (operators as readonly string[]).includes(op));
95
+ }
96
+
47
97
  export function RelationFilterField({
48
98
  value,
49
99
  setValue,
@@ -56,13 +106,13 @@ export function RelationFilterField({
56
106
 
57
107
  const manyRelation = relationCardinality(relation) === "many";
58
108
 
59
- let possibleOperations: (keyof typeof operationLabels)[] = manyRelation
60
- ? ["array-contains", "array-contains-any"]
61
- : ["==", "!=", ">", "<", ">=", "<=", "in", "not-in"];
109
+ // The filters dialog renders every filterable property at once, and the
110
+ // checkbox's id was the literal string "null-filter" — so clicking any
111
+ // label toggled whichever checkbox the document matched first. Harmless
112
+ // while one relation per table could show it; not once every relation can.
113
+ const nullFilterId = useId();
62
114
 
63
- if (operators) {
64
- possibleOperations = possibleOperations.filter(op => (operators as readonly string[]).includes(op));
65
- }
115
+ const possibleOperations = renderableRelationOperators(relation, operators);
66
116
 
67
117
  const [fieldOperation, fieldValue] = value || [possibleOperations[0], undefined];
68
118
  const [operation, setOperation] = useState<VirtualTableWhereFilterOp>(fieldOperation);
@@ -80,6 +130,14 @@ export function RelationFilterField({
80
130
  setOperation(op);
81
131
  setInternalValue(newValue);
82
132
 
133
+ // A null check takes no operand, so it is complete on its own — the
134
+ // value gate below would otherwise clear the filter the moment one is
135
+ // chosen from the dropdown, where there is no value to accompany it.
136
+ if (isNullFilterOperator(op)) {
137
+ setValue([op, null]);
138
+ return;
139
+ }
140
+
83
141
  const hasNewValue = newValue !== null && Array.isArray(newValue)
84
142
  ? newValue.length > 0
85
143
  : newValue !== undefined;
@@ -91,6 +149,7 @@ export function RelationFilterField({
91
149
  }
92
150
 
93
151
  const multiple = multipleSelectOperations.includes(operation);
152
+ const nullFiltered = isNullFilterOperator(operation);
94
153
 
95
154
  const relationSelectorValue = useMemo(() => {
96
155
  if (internalValue === null || internalValue === undefined) return undefined;
@@ -135,27 +194,44 @@ export function RelationFilterField({
135
194
  <div className="grow ml-2 h-full gap-2 flex flex-col w-[340px]">
136
195
  <RelationSelector
137
196
  relation={relation}
197
+ // The operator, not the relation, decides how many values
198
+ // are being asked for — `updateFilter` already coerces the
199
+ // held value on the same test, so the two cannot disagree.
200
+ multiple={multiple}
138
201
  value={relationSelectorValue}
139
202
  onValueChange={handleRelationSelectorChange}
140
- disabled={internalValue === null}
203
+ disabled={nullFiltered}
141
204
  size={"medium"}
142
205
  />
143
206
 
144
- {!manyRelation && <Label
207
+ {/*
208
+ * Shown for a to-many relation too. It used to be hidden
209
+ * there because the only operators offered were the array
210
+ * ones, which have no null to ask about — but "posts with no
211
+ * tags" is the question a filter on a link is most often
212
+ * for, and the driver answers it with `NOT EXISTS`.
213
+ *
214
+ * It switches the operator to the matching null check rather
215
+ * than pairing a null value with whatever operator is
216
+ * selected. Only the operator travels to the driver, and
217
+ * `is-null` is a thing every engine implements; a null
218
+ * *operand* is not — see `nullFilterOperatorFor`.
219
+ */}
220
+ <Label
145
221
  className="border cursor-pointer rounded-md p-2 flex items-center gap-2 bg-surface-50 dark:bg-surface-900 hover:bg-surface-100 dark:hover:bg-surface-800"
146
- htmlFor="null-filter"
222
+ htmlFor={nullFilterId}
147
223
  >
148
224
  <Checkbox
149
- id="null-filter"
150
- checked={internalValue === null}
225
+ id={nullFilterId}
226
+ checked={nullFiltered}
151
227
  size={"small"}
152
228
  onCheckedChange={() => {
153
- if (internalValue !== null) updateFilter(operation, null);
154
- else updateFilter(operation, undefined);
229
+ if (!nullFiltered) updateFilter(nullFilterOperatorFor(operation), null);
230
+ else updateFilter(valueOperatorFor(operation, possibleOperations) ?? operation, undefined);
155
231
  }}
156
232
  />
157
- Filter for null values
158
- </Label>}
233
+ {manyRelation ? "Filter for empty relations" : "Filter for null values"}
234
+ </Label>
159
235
  </div>
160
236
  </div>
161
237
  );
@@ -0,0 +1,63 @@
1
+ import { WhereFilterOp } from "@rebasepro/types";
2
+
3
+ /**
4
+ * The "filter for null values" checkbox, expressed portably.
5
+ *
6
+ * The checkbox used to emit `[whichever operator is selected, null]` and leave
7
+ * the driver to work out that a null value means a null check. That reads as
8
+ * an engine-neutral convention and is not one:
9
+ *
10
+ * - Postgres took `["==", null]` as `IS NULL`, but dropped `["in", null]`
11
+ * entirely — a dropped condition widens, so ticking the box returned every
12
+ * row instead of the ones with no value.
13
+ * - MongoDB maps `in`/`not-in` straight onto `$in`/`$nin`, and Mongo rejects
14
+ * those with a non-array: `$in needs an array`. The read fails outright.
15
+ * - A Firestore driver is supplied by the developer, and the Firestore SDK
16
+ * likewise refuses `in` without a non-empty array.
17
+ *
18
+ * Only `==`/`!=` ever worked, and only because two engines independently chose
19
+ * to special-case a null operand. Nothing in the filter contract promises that,
20
+ * and a filter field is shared by every engine the admin can talk to.
21
+ *
22
+ * `is-null`/`is-not-null` are the operators that mean this, they are in
23
+ * {@link ALL_WHERE_FILTER_OPS}, and every driver already handles them
24
+ * explicitly rather than by inference. So the checkbox emits those, and the
25
+ * question of what a null operand means to a given engine stops being asked.
26
+ */
27
+ export const NULL_FILTER_OPERATORS: readonly WhereFilterOp[] = ["is-null", "is-not-null"];
28
+
29
+ /** Whether `op` asks about absence rather than about a value. */
30
+ export function isNullFilterOperator(op: WhereFilterOp | undefined): boolean {
31
+ return op === "is-null" || op === "is-not-null";
32
+ }
33
+
34
+ /**
35
+ * The null check carrying the same sense as `op`.
36
+ *
37
+ * The checkbox is a toggle, not an operator, so the operator it is ticked
38
+ * alongside is what says which way round it means: `==`/`in` are asking for
39
+ * a match, so their null form is "has nothing"; `!=`/`not-in` are asking for
40
+ * the absence of one, so theirs is "has something".
41
+ */
42
+ export function nullFilterOperatorFor(op: WhereFilterOp | undefined): WhereFilterOp {
43
+ if (op === "!=" || op === "not-in" || op === "is-not-null") return "is-not-null";
44
+ return "is-null";
45
+ }
46
+
47
+ /**
48
+ * The value operator to return to when the checkbox is unticked.
49
+ *
50
+ * `is-null` could have been reached from `==` or from `in` — the field cannot
51
+ * know which, and it does not matter. What matters is landing on an operator
52
+ * the dropdown actually offers, in the same sense, so the control does not
53
+ * come back showing something it cannot render.
54
+ */
55
+ export function valueOperatorFor(
56
+ op: WhereFilterOp | undefined,
57
+ offered: readonly WhereFilterOp[]
58
+ ): WhereFilterOp | undefined {
59
+ const negative = op === "is-not-null";
60
+ const wanted: WhereFilterOp[] = negative ? ["!=", "not-in"] : ["==", "in", "array-contains"];
61
+ return wanted.find(candidate => offered.includes(candidate))
62
+ ?? offered.find(candidate => !isNullFilterOperator(candidate));
63
+ }
@@ -15,8 +15,8 @@ import { useAuthController, useBridgeRegistration, NavigationBlockerProvider } f
15
15
  * Provider that builds the SidePanelController and makes it available
16
16
  * via the SidePanelControllerContext from @rebasepro/app.
17
17
  *
18
- * After the CMS extraction refactor, `useBuildSidePanel` lives
19
- * in the CMS package while the context it feeds into lives in core.
18
+ * After the admin extraction refactor, `useBuildSidePanel` lives
19
+ * in the admin package while the context it feeds into lives in core.
20
20
  * This provider bridges the two: place it inside the `<Rebase>` tree and
21
21
  * above any component that calls `useSidePanel()`.
22
22
  *