@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.
- package/README.md +1 -1
- package/dist/{CollectionEditorDialog-o7F_zbGY.js → CollectionEditorDialog-Dpc75wIZ.js} +56 -260
- package/dist/CollectionEditorDialog-Dpc75wIZ.js.map +1 -0
- package/dist/{PropertyEditView-DgB2b22q.js → PropertyEditView-DH3XZC2x.js} +2 -2
- package/dist/{PropertyEditView-DgB2b22q.js.map → PropertyEditView-DH3XZC2x.js.map} +1 -1
- package/dist/{RouterCollectionsStudioView-DaLRgPkj.js → RouterCollectionsStudioView-BZLq4MGp.js} +4 -4
- package/dist/{RouterCollectionsStudioView-DaLRgPkj.js.map → RouterCollectionsStudioView-BZLq4MGp.js.map} +1 -1
- package/dist/collection_editor/CollectionEditorDialogsContext.d.ts +1 -1
- package/dist/collection_editor/_cms_internals.d.ts +1 -1
- package/dist/collection_editor/extensibility_types.d.ts +1 -1
- package/dist/collection_editor/serializable_types.d.ts +40 -12
- package/dist/collection_editor/serializable_utils.d.ts +1 -1
- package/dist/collection_editor/ui/collection_editor/CollectionRLSTab.d.ts +1 -11
- package/dist/collection_editor_ui.js +4 -4
- package/dist/components/CollectionEditorDialogs.d.ts +2 -2
- package/dist/components/CollectionTableBinding/column_utils.d.ts +7 -1
- package/dist/components/CollectionViewBinding/CollectionListViewBinding.d.ts +2 -2
- package/dist/components/DefaultDrawer.d.ts +3 -3
- package/dist/components/DrawerNavigationGroup.d.ts +8 -1
- package/dist/components/DrawerNavigationItem.d.ts +7 -1
- package/dist/components/EditViewBinding.d.ts +1 -1
- package/dist/components/RebaseAdmin.d.ts +1 -1
- package/dist/components/RebaseAuthGate.d.ts +1 -1
- package/dist/components/RebaseLayout.d.ts +5 -1
- package/dist/components/RebaseNavigation.d.ts +2 -2
- package/dist/components/RebaseRouteDefs.d.ts +1 -1
- package/dist/components/RebaseShell.d.ts +6 -1
- package/dist/components/RelationSelector.d.ts +14 -0
- package/dist/components/SelectableTable/filters/RelationFilterField.d.ts +27 -0
- package/dist/components/SelectableTable/filters/null_filter.d.ts +46 -0
- package/dist/components/SidePanelProvider.d.ts +2 -2
- package/dist/components/app/Scaffold.d.ts +11 -0
- package/dist/components/field_configs.d.ts +1 -1
- package/dist/{export-BvqhjdEb.js → export-BzIpUdSK.js} +3 -3
- package/dist/export-BzIpUdSK.js.map +1 -0
- package/dist/form/EntityForm.d.ts +2 -2
- package/dist/form/EntityFormBinding.d.ts +5 -5
- package/dist/{history-Dz20hH38.js → history-g9688IaK.js} +2 -2
- package/dist/{history-Dz20hH38.js.map → history-g9688IaK.js.map} +1 -1
- package/dist/hooks/index.d.ts +1 -1
- package/dist/hooks/useAdminContext.d.ts +29 -0
- package/dist/hooks/useHistory.d.ts +7 -11
- package/dist/{import-DINSB-Vu.js → import-BjUG-cIY.js} +2 -2
- package/dist/{import-DINSB-Vu.js.map → import-BjUG-cIY.js.map} +1 -1
- package/dist/index.js +61 -38
- package/dist/index.js.map +1 -1
- package/dist/types/components/EntityFormProps.d.ts +1 -1
- package/dist/types/fields.d.ts +1 -1
- package/dist/{util-BhhfHY1M.js → util-B5fJm1FA.js} +296 -169
- package/dist/util-B5fJm1FA.js.map +1 -0
- package/package.json +9 -9
- package/src/collection_editor/CollectionEditorDialogsContext.tsx +1 -1
- package/src/collection_editor/ConfigControllerProvider.tsx +1 -1
- package/src/collection_editor/_cms_internals.ts +1 -1
- package/src/collection_editor/extensibility_types.ts +1 -1
- package/src/collection_editor/index.ts +2 -2
- package/src/collection_editor/serializable_types.ts +44 -13
- package/src/collection_editor/serializable_utils.ts +68 -59
- package/src/collection_editor/ui/collection_editor/CollectionEditorDialog.tsx +1 -1
- package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +66 -41
- package/src/collection_editor/ui/collection_editor/EntityActionsEditTab.tsx +1 -1
- package/src/collection_editor/ui/collection_editor/GetCodeDialog.tsx +1 -1
- package/src/collection_editor/ui/collection_editor/SubcollectionsEditTab.tsx +2 -2
- package/src/components/CollectionEditorDialogs.tsx +2 -2
- package/src/components/CollectionTableBinding/CollectionRowActions.tsx +5 -4
- package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +8 -2
- package/src/components/CollectionTableBinding/column_utils.tsx +19 -5
- package/src/components/CollectionTableBinding/table_bindings.tsx +10 -10
- package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +8 -7
- package/src/components/CollectionViewBinding/CollectionViewActions.tsx +2 -2
- package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +2 -2
- package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +2 -2
- package/src/components/DefaultDrawer.tsx +19 -8
- package/src/components/DeleteEntityDialog.tsx +2 -2
- package/src/components/DetailViewBinding.tsx +21 -1
- package/src/components/DrawerNavigationGroup.tsx +47 -4
- package/src/components/DrawerNavigationItem.tsx +19 -5
- package/src/components/EditFormActions.tsx +4 -3
- package/src/components/EditViewBinding.tsx +1 -1
- package/src/components/HomePage/ContentHomePage.tsx +4 -4
- package/src/components/RebaseAdmin.tsx +3 -3
- package/src/components/RebaseAuthGate.tsx +1 -1
- package/src/components/RebaseLayout.tsx +8 -2
- package/src/components/RebaseNavigation.tsx +9 -9
- package/src/components/RebaseRouteDefs.tsx +2 -2
- package/src/components/RebaseShell.tsx +10 -1
- package/src/components/RelationSelector.tsx +18 -3
- package/src/components/SelectableTable/filters/FilterFieldBinding.tsx +1 -1
- package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +36 -9
- package/src/components/SelectableTable/filters/RelationFilterField.tsx +93 -17
- package/src/components/SelectableTable/filters/null_filter.ts +63 -0
- package/src/components/SidePanelProvider.tsx +2 -2
- package/src/components/app/Scaffold.tsx +16 -1
- package/src/data_export/export/ExportCollectionAction.tsx +2 -2
- package/src/form/EntityForm.tsx +2 -2
- package/src/form/EntityFormActions.tsx +2 -1
- package/src/form/EntityFormBinding.tsx +7 -7
- package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +3 -3
- package/src/form/field_bindings/MultipleRelationFieldBinding.tsx +16 -5
- package/src/form/field_bindings/ReferenceFieldBinding.tsx +3 -3
- package/src/form/field_bindings/RelationFieldBinding.tsx +17 -6
- package/src/form/field_bindings/RepeatFieldBinding.tsx +2 -2
- package/src/hooks/index.ts +1 -1
- package/src/hooks/navigation/useNavigationResolution.ts +10 -4
- package/src/hooks/{useCMSContext.tsx → useAdminContext.tsx} +10 -10
- package/src/hooks/useHistory.ts +7 -11
- package/src/hooks/useResolvedNavigationFrom.tsx +2 -2
- package/src/index.ts +4 -4
- package/src/preview/PropertyPreview.tsx +6 -6
- package/src/preview/components/RelationPreview.tsx +1 -1
- package/src/preview/property_previews/ArrayOfMapsPreview.tsx +1 -1
- package/src/preview/property_previews/ArrayOfReferencesPreview.tsx +2 -2
- package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +2 -2
- package/src/preview/property_previews/SkeletonPropertyComponent.tsx +2 -2
- package/src/types/components/EntityFormProps.tsx +2 -2
- package/src/types/fields.tsx +1 -1
- package/dist/CollectionEditorDialog-o7F_zbGY.js.map +0 -1
- package/dist/collection_editor/pgColumnToProperty.d.ts +0 -7
- package/dist/editor/extensions/Image/index.d.ts +0 -6
- package/dist/export-BvqhjdEb.js.map +0 -1
- package/dist/hooks/useCMSContext.d.ts +0 -29
- package/dist/util-BhhfHY1M.js.map +0 -1
- package/src/collection_editor/pgColumnToProperty.ts +0 -291
- 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 {
|
|
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 =
|
|
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
|
-
//
|
|
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,
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
207
|
-
// Order:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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=
|
|
233
|
+
htmlFor={nullFilterId}
|
|
206
234
|
>
|
|
207
|
-
<Checkbox id=
|
|
208
|
-
checked={
|
|
235
|
+
<Checkbox id={nullFilterId}
|
|
236
|
+
checked={nullFiltered}
|
|
209
237
|
size={"small"}
|
|
210
|
-
onCheckedChange={(
|
|
211
|
-
if (
|
|
212
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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={
|
|
203
|
+
disabled={nullFiltered}
|
|
141
204
|
size={"medium"}
|
|
142
205
|
/>
|
|
143
206
|
|
|
144
|
-
{
|
|
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=
|
|
222
|
+
htmlFor={nullFilterId}
|
|
147
223
|
>
|
|
148
224
|
<Checkbox
|
|
149
|
-
id=
|
|
150
|
-
checked={
|
|
225
|
+
id={nullFilterId}
|
|
226
|
+
checked={nullFiltered}
|
|
151
227
|
size={"small"}
|
|
152
228
|
onCheckedChange={() => {
|
|
153
|
-
if (
|
|
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
|
|
19
|
-
* in the
|
|
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
|
*
|