@bsuite/ui 1.0.3 → 1.2.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 +57 -0
- package/dist/command.d.ts +85 -0
- package/dist/command.d.ts.map +1 -0
- package/dist/command.js +28 -0
- package/dist/data-unavailable.d.ts +98 -0
- package/dist/data-unavailable.d.ts.map +1 -0
- package/dist/data-unavailable.js +162 -0
- package/dist/entity-selector.d.ts +252 -0
- package/dist/entity-selector.d.ts.map +1 -0
- package/dist/entity-selector.js +297 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/popover.d.ts +7 -0
- package/dist/popover.d.ts.map +1 -0
- package/dist/popover.js +9 -0
- package/package.json +28 -4
package/README.md
CHANGED
|
@@ -169,6 +169,60 @@ import { BrandingCard, OklchColorPicker } from '@bsuite/ui'
|
|
|
169
169
|
- `favicon_url` - Favicon URL (16x16 or 32x32)
|
|
170
170
|
- `company_name` - Company name for alt text
|
|
171
171
|
|
|
172
|
+
## `EntitySelector` (v1.2.0+)
|
|
173
|
+
|
|
174
|
+
Generic, schema-agnostic searchable combobox for picking a row out of any
|
|
175
|
+
Supabase table. Extracted from crm7 (AD-5, 2026-08-17) — crm7 had 65 files of
|
|
176
|
+
adoption and two other apps had each started hand-porting their own copy at
|
|
177
|
+
the identical path. This is now the sole implementation.
|
|
178
|
+
|
|
179
|
+
Data access is dependency-injected — the package does not own a Supabase
|
|
180
|
+
client, since every app's client is shaped differently (Vite-SPA singleton,
|
|
181
|
+
per-request `createClient()` factory, distinct `storageKey`, …):
|
|
182
|
+
|
|
183
|
+
```tsx
|
|
184
|
+
import { EntitySelector } from '@bsuite/ui'
|
|
185
|
+
import { supabase } from '@/lib/supabase' // your app's own client
|
|
186
|
+
|
|
187
|
+
<EntitySelector<Employer>
|
|
188
|
+
supabaseClient={supabase}
|
|
189
|
+
table="employers"
|
|
190
|
+
value={form.employer_id}
|
|
191
|
+
onSelect={(emp) => setValue('employer_id', emp?.id ?? null)}
|
|
192
|
+
displayField={(r) => r.business_name}
|
|
193
|
+
secondaryField={(r) => r.abn ?? r.industry}
|
|
194
|
+
searchColumns={['business_name', 'trading_name', 'abn']}
|
|
195
|
+
/>
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
**Recommended per-app pattern:** wrap the import once in a thin local file
|
|
199
|
+
that binds your app's client, so the rest of the app's ~dozens of call sites
|
|
200
|
+
never repeat `supabaseClient={supabase}`:
|
|
201
|
+
|
|
202
|
+
```tsx
|
|
203
|
+
// src/components/entity/EntitySelector.tsx
|
|
204
|
+
import { EntitySelector as BaseEntitySelector, type EntitySelectorProps as BaseProps } from '@bsuite/ui'
|
|
205
|
+
import { supabase } from '@/lib/supabase'
|
|
206
|
+
|
|
207
|
+
export type EntitySelectorProps<T extends Record<string, unknown>> = Omit<BaseProps<T>, 'supabaseClient'>
|
|
208
|
+
|
|
209
|
+
export function EntitySelector<T extends Record<string, unknown>>(props: EntitySelectorProps<T>) {
|
|
210
|
+
return <BaseEntitySelector<T> supabaseClient={supabase} {...props} />
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
**Features:** debounced typeahead (`ilike` across configured columns),
|
|
215
|
+
optional cross-schema reads (`schema` prop), a `filterFn` escape hatch for
|
|
216
|
+
arbitrary PostgREST filter chains, "one-shot" suggested options (rows already
|
|
217
|
+
linked on an earlier screen, shown before any typing), an async-safe
|
|
218
|
+
`onSelect` (no optimistic UI when the caller's select routes through an
|
|
219
|
+
import/RPC step), and a distinct error state (not the same "no results" copy
|
|
220
|
+
a genuine empty search shows).
|
|
221
|
+
|
|
222
|
+
Also exported for building custom pickers on the same visual language:
|
|
223
|
+
`Command`, `CommandInput`, `CommandList`, `CommandGroup`, `CommandItem`,
|
|
224
|
+
`CommandEmpty`, `Popover`, `PopoverTrigger`, `PopoverContent`.
|
|
225
|
+
|
|
172
226
|
## Utils
|
|
173
227
|
|
|
174
228
|
### `cn(...classes)`
|
|
@@ -198,6 +252,9 @@ import { BrandingCard, ColorEditorSheet, OklchColorPicker } from '@bsuite/ui/bra
|
|
|
198
252
|
import { Button } from '@bsuite/ui/button'
|
|
199
253
|
import { DialogContent } from '@bsuite/ui/dialog'
|
|
200
254
|
import { EmptyState } from '@bsuite/ui/empty-state'
|
|
255
|
+
import { EntitySelector } from '@bsuite/ui/entity-selector'
|
|
256
|
+
import { Command, CommandInput } from '@bsuite/ui/command'
|
|
257
|
+
import { Popover, PopoverContent } from '@bsuite/ui/popover'
|
|
201
258
|
|
|
202
259
|
// SVG asset
|
|
203
260
|
import logoSvg from '@bsuite/ui/assets/d2c-default-logo.svg'
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { type DialogProps } from '@radix-ui/react-dialog';
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
declare const Command: React.ForwardRefExoticComponent<Omit<{
|
|
4
|
+
children?: React.ReactNode;
|
|
5
|
+
} & Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof React.HTMLAttributes<HTMLDivElement>> & {
|
|
6
|
+
ref?: React.Ref<HTMLDivElement>;
|
|
7
|
+
} & {
|
|
8
|
+
asChild?: boolean;
|
|
9
|
+
}, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild"> & {
|
|
10
|
+
label?: string;
|
|
11
|
+
shouldFilter?: boolean;
|
|
12
|
+
filter?: (value: string, search: string, keywords?: string[]) => number;
|
|
13
|
+
defaultValue?: string;
|
|
14
|
+
value?: string;
|
|
15
|
+
onValueChange?: (value: string) => void;
|
|
16
|
+
loop?: boolean;
|
|
17
|
+
disablePointerSelection?: boolean;
|
|
18
|
+
vimBindings?: boolean;
|
|
19
|
+
} & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
|
|
20
|
+
interface CommandDialogProps extends DialogProps {
|
|
21
|
+
title?: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
}
|
|
24
|
+
declare const CommandDialog: ({ children, title, description, ...props }: CommandDialogProps) => React.JSX.Element;
|
|
25
|
+
declare const CommandInput: React.ForwardRefExoticComponent<Omit<Omit<Pick<Pick<React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, "key" | keyof React.InputHTMLAttributes<HTMLInputElement>> & {
|
|
26
|
+
ref?: React.Ref<HTMLInputElement>;
|
|
27
|
+
} & {
|
|
28
|
+
asChild?: boolean;
|
|
29
|
+
}, "key" | "asChild" | keyof React.InputHTMLAttributes<HTMLInputElement>>, "type" | "onChange" | "value"> & {
|
|
30
|
+
value?: string;
|
|
31
|
+
onValueChange?: (search: string) => void;
|
|
32
|
+
} & React.RefAttributes<HTMLInputElement>, "ref"> & React.RefAttributes<HTMLInputElement>>;
|
|
33
|
+
declare const CommandList: React.ForwardRefExoticComponent<Omit<{
|
|
34
|
+
children?: React.ReactNode;
|
|
35
|
+
} & Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof React.HTMLAttributes<HTMLDivElement>> & {
|
|
36
|
+
ref?: React.Ref<HTMLDivElement>;
|
|
37
|
+
} & {
|
|
38
|
+
asChild?: boolean;
|
|
39
|
+
}, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild"> & {
|
|
40
|
+
label?: string;
|
|
41
|
+
} & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
|
|
42
|
+
declare const CommandEmpty: React.ForwardRefExoticComponent<Omit<{
|
|
43
|
+
children?: React.ReactNode;
|
|
44
|
+
} & Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof React.HTMLAttributes<HTMLDivElement>> & {
|
|
45
|
+
ref?: React.Ref<HTMLDivElement>;
|
|
46
|
+
} & {
|
|
47
|
+
asChild?: boolean;
|
|
48
|
+
}, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild"> & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
|
|
49
|
+
declare const CommandGroup: React.ForwardRefExoticComponent<Omit<{
|
|
50
|
+
children?: React.ReactNode;
|
|
51
|
+
} & Omit<Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof React.HTMLAttributes<HTMLDivElement>> & {
|
|
52
|
+
ref?: React.Ref<HTMLDivElement>;
|
|
53
|
+
} & {
|
|
54
|
+
asChild?: boolean;
|
|
55
|
+
}, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild">, "heading" | "value"> & {
|
|
56
|
+
heading?: React.ReactNode;
|
|
57
|
+
value?: string;
|
|
58
|
+
forceMount?: boolean;
|
|
59
|
+
} & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
|
|
60
|
+
declare const CommandSeparator: React.ForwardRefExoticComponent<Omit<Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof React.HTMLAttributes<HTMLDivElement>> & {
|
|
61
|
+
ref?: React.Ref<HTMLDivElement>;
|
|
62
|
+
} & {
|
|
63
|
+
asChild?: boolean;
|
|
64
|
+
}, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild"> & {
|
|
65
|
+
alwaysRender?: boolean;
|
|
66
|
+
} & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
|
|
67
|
+
declare const CommandItem: React.ForwardRefExoticComponent<Omit<{
|
|
68
|
+
children?: React.ReactNode;
|
|
69
|
+
} & Omit<Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof React.HTMLAttributes<HTMLDivElement>> & {
|
|
70
|
+
ref?: React.Ref<HTMLDivElement>;
|
|
71
|
+
} & {
|
|
72
|
+
asChild?: boolean;
|
|
73
|
+
}, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild">, "onSelect" | "disabled" | "value"> & {
|
|
74
|
+
disabled?: boolean;
|
|
75
|
+
onSelect?: (value: string) => void;
|
|
76
|
+
value?: string;
|
|
77
|
+
keywords?: string[];
|
|
78
|
+
forceMount?: boolean;
|
|
79
|
+
} & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
|
|
80
|
+
declare const CommandShortcut: {
|
|
81
|
+
({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>): React.JSX.Element;
|
|
82
|
+
displayName: string;
|
|
83
|
+
};
|
|
84
|
+
export { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut, };
|
|
85
|
+
//# sourceMappingURL=command.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"command.d.ts","sourceRoot":"","sources":["../src/command.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,WAAW,EAAE,MAAM,wBAAwB,CAAA;AAGzD,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAK9B,QAAA,MAAM,OAAO;;;;;;;;;;;;;;;;sFAYX,CAAA;AAGF,UAAU,kBAAmB,SAAQ,WAAW;IAG9C,KAAK,CAAC,EAAE,MAAM,CAAA;IAEd,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,QAAA,MAAM,aAAa,GAAI,4CAKpB,kBAAkB,sBAYpB,CAAA;AAED,QAAA,MAAM,YAAY;;;;;;;0FAehB,CAAA;AAIF,QAAA,MAAM,WAAW;;;;;;;;sFASf,CAAA;AAIF,QAAA,MAAM,YAAY;;;;;;uJAKhB,CAAA;AAIF,QAAA,MAAM,YAAY;;;;;;;;;;sFAYhB,CAAA;AAIF,QAAA,MAAM,gBAAgB;;;;;;sFASpB,CAAA;AAGF,QAAA,MAAM,WAAW;;;;;;;;;;;;sFAYf,CAAA;AAIF,QAAA,MAAM,eAAe;8BAA6B,KAAK,CAAC,cAAc,CAAC,eAAe,CAAC;;CAOtF,CAAA;AAGD,OAAO,EACL,OAAO,EACP,aAAa,EACb,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,WAAW,EACX,WAAW,EACX,gBAAgB,EAChB,eAAe,GAChB,CAAA"}
|
package/dist/command.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Command as CommandPrimitive } from 'cmdk';
|
|
3
|
+
import { Search } from 'lucide-react';
|
|
4
|
+
import * as React from 'react';
|
|
5
|
+
import { Dialog, DialogContent, DialogDescription, DialogTitle } from './dialog.js';
|
|
6
|
+
import { cn } from './utils.js';
|
|
7
|
+
const Command = React.forwardRef(({ className, ...props }, ref) => (_jsx(CommandPrimitive, { ref: ref, className: cn('flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground', className), ...props })));
|
|
8
|
+
Command.displayName = CommandPrimitive.displayName;
|
|
9
|
+
const CommandDialog = ({ children, title = 'Command palette', description = 'Search for commands, navigation, and actions', ...props }) => {
|
|
10
|
+
return (_jsx(Dialog, { ...props, children: _jsxs(DialogContent, { className: "overflow-hidden p-0 shadow-lg", children: [_jsx(DialogTitle, { className: "sr-only", children: title }), _jsx(DialogDescription, { className: "sr-only", children: description }), _jsx(Command, { className: "[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5", children: children })] }) }));
|
|
11
|
+
};
|
|
12
|
+
const CommandInput = React.forwardRef(({ className, ...props }, ref) => (_jsxs("div", { className: "flex items-center border-b px-3", "cmdk-input-wrapper": "", children: [_jsx(Search, { className: "mr-2 h-4 w-4 shrink-0 opacity-50", "aria-hidden": "true" }), _jsx(CommandPrimitive.Input, { ref: ref, className: cn('flex h-11 w-full rounded-md bg-transparent py-3 text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', className), ...props })] })));
|
|
13
|
+
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
|
14
|
+
const CommandList = React.forwardRef(({ className, ...props }, ref) => (_jsx(CommandPrimitive.List, { ref: ref, className: cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className), ...props })));
|
|
15
|
+
CommandList.displayName = CommandPrimitive.List.displayName;
|
|
16
|
+
const CommandEmpty = React.forwardRef((props, ref) => (_jsx(CommandPrimitive.Empty, { ref: ref, className: "py-6 text-center text-sm", ...props })));
|
|
17
|
+
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
|
18
|
+
const CommandGroup = React.forwardRef(({ className, ...props }, ref) => (_jsx(CommandPrimitive.Group, { ref: ref, className: cn('overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground', className), ...props })));
|
|
19
|
+
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
|
20
|
+
const CommandSeparator = React.forwardRef(({ className, ...props }, ref) => (_jsx(CommandPrimitive.Separator, { ref: ref, className: cn('-mx-1 h-px bg-border', className), ...props })));
|
|
21
|
+
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
|
22
|
+
const CommandItem = React.forwardRef(({ className, ...props }, ref) => (_jsx(CommandPrimitive.Item, { ref: ref, className: cn("relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", className), ...props })));
|
|
23
|
+
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
|
24
|
+
const CommandShortcut = ({ className, ...props }) => {
|
|
25
|
+
return (_jsx("span", { className: cn('ml-auto text-xs tracking-widest text-muted-foreground', className), ...props }));
|
|
26
|
+
};
|
|
27
|
+
CommandShortcut.displayName = 'CommandShortcut';
|
|
28
|
+
export { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut, };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* DataUnavailable — the honest state.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS
|
|
6
|
+
* ---------------
|
|
7
|
+
* Five live surfaces across crm7 and business-suite-unified shared one idiom:
|
|
8
|
+
*
|
|
9
|
+
* const rows = query.data ?? HARDCODED_FALLBACK
|
|
10
|
+
*
|
|
11
|
+
* That line turns every failure into a confident-looking answer. The worst
|
|
12
|
+
* instance rendered a fabricated 95% compliance score — including "Fair Work
|
|
13
|
+
* Compliance — Award rates verified" — when the underlying query was denied by
|
|
14
|
+
* RLS. An authorization failure was presented to the user as a compliance
|
|
15
|
+
* assurance. Another exported invented dollar figures to a file that then looks
|
|
16
|
+
* authoritative to whoever opens it.
|
|
17
|
+
*
|
|
18
|
+
* The operator's standing rule is: never display mock data in the UI,
|
|
19
|
+
* especially for financial or account-related information.
|
|
20
|
+
*
|
|
21
|
+
* THE CONTRACT
|
|
22
|
+
* ------------
|
|
23
|
+
* This component renders the ABSENCE of data. It therefore:
|
|
24
|
+
*
|
|
25
|
+
* 1. Takes no numeric prop and renders no number of its own. There is
|
|
26
|
+
* nothing here that can be misread as a measurement.
|
|
27
|
+
* 2. Never uses the success tone. A failure is never green.
|
|
28
|
+
* 3. Separates "nothing exists" from "we could not find out", and separates
|
|
29
|
+
* "you may not see this" from "this broke". Collapsing those is precisely
|
|
30
|
+
* the defect it replaces.
|
|
31
|
+
* 4. Surfaces the real reason verbatim, in a monospace diagnostic block, so
|
|
32
|
+
* the reader can tell a permission denial from a network fault.
|
|
33
|
+
* 5. Carries `data-slot="data-unavailable"` and `data-state`, so tests and
|
|
34
|
+
* lints can assert that a surface degraded honestly.
|
|
35
|
+
*
|
|
36
|
+
* Pair it with `resolveDataState` so the call site never re-derives the
|
|
37
|
+
* branching, and the `??` idiom has nowhere to come back.
|
|
38
|
+
*/
|
|
39
|
+
export type DataUnavailableState =
|
|
40
|
+
/** The query is in flight. Nothing is known yet. */
|
|
41
|
+
'loading'
|
|
42
|
+
/** The query succeeded and the answer is genuinely "none". */
|
|
43
|
+
| 'empty'
|
|
44
|
+
/** The query failed. We do not know the answer. */
|
|
45
|
+
| 'error'
|
|
46
|
+
/** The query was refused. The data may exist; this caller may not see it. */
|
|
47
|
+
| 'forbidden'
|
|
48
|
+
/** No queryable source is reachable from this surface at all. */
|
|
49
|
+
| 'unavailable';
|
|
50
|
+
export interface DataUnavailableProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
|
|
51
|
+
state: DataUnavailableState;
|
|
52
|
+
/**
|
|
53
|
+
* What could not be shown, as a specific noun — "Budgets", "Compliance
|
|
54
|
+
* checks", "OAuth clients". Overrides the generic preset heading.
|
|
55
|
+
*/
|
|
56
|
+
title?: ReactNode;
|
|
57
|
+
description?: ReactNode;
|
|
58
|
+
/**
|
|
59
|
+
* The real, verbatim failure reason — an error message, a Postgres code.
|
|
60
|
+
* Rendered as monospace diagnostics, never as prose. Pass the actual thing;
|
|
61
|
+
* a sanitised reason is how "unknown" becomes "fine" again.
|
|
62
|
+
*/
|
|
63
|
+
reason?: ReactNode;
|
|
64
|
+
/** Retry handler. Omit when retrying cannot help (`forbidden`). */
|
|
65
|
+
onRetry?: () => void;
|
|
66
|
+
retryLabel?: string;
|
|
67
|
+
/** Escape hatch for a site-specific action (e.g. "Request access"). */
|
|
68
|
+
action?: ReactNode;
|
|
69
|
+
/** `compact` suits an inline card slot; `block` a full page region. */
|
|
70
|
+
size?: 'compact' | 'block';
|
|
71
|
+
}
|
|
72
|
+
export declare function DataUnavailable({ state, title, description, reason, onRetry, retryLabel, action, size, className, ...props }: DataUnavailableProps): import("react").JSX.Element;
|
|
73
|
+
/**
|
|
74
|
+
* Structural subset of a TanStack Query result. Declared structurally on
|
|
75
|
+
* purpose: `@bsuite/ui` must not take a dependency on the data layer, and this
|
|
76
|
+
* shape is equally satisfiable by a hand-rolled hook.
|
|
77
|
+
*/
|
|
78
|
+
export interface QueryLike<T> {
|
|
79
|
+
data: T | undefined;
|
|
80
|
+
isPending?: boolean;
|
|
81
|
+
isLoading?: boolean;
|
|
82
|
+
isError?: boolean;
|
|
83
|
+
error?: unknown;
|
|
84
|
+
}
|
|
85
|
+
/** Human-readable reason string for the `reason` prop. */
|
|
86
|
+
export declare function describeError(error: unknown): string;
|
|
87
|
+
/**
|
|
88
|
+
* Map a query result to either `'ready'` (render the real data) or the honest
|
|
89
|
+
* state to render instead.
|
|
90
|
+
*
|
|
91
|
+
* The point is that there is no fourth answer. A call site cannot get a
|
|
92
|
+
* fallback out of this function, which is what makes the class fix stick.
|
|
93
|
+
*
|
|
94
|
+
* @param isEmpty how to decide the data is genuinely empty. Defaults to
|
|
95
|
+
* "an array with no elements". Pass explicitly for object-shaped payloads.
|
|
96
|
+
*/
|
|
97
|
+
export declare function resolveDataState<T>(query: QueryLike<T>, isEmpty?: (data: T) => boolean): DataUnavailableState | 'ready';
|
|
98
|
+
//# sourceMappingURL=data-unavailable.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"data-unavailable.d.ts","sourceRoot":"","sources":["../src/data-unavailable.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAGtD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAMH,MAAM,MAAM,oBAAoB;AAC9B,oDAAoD;AAClD,SAAS;AACX,8DAA8D;GAC5D,OAAO;AACT,mDAAmD;GACjD,OAAO;AACT,6EAA6E;GAC3E,WAAW;AACb,iEAAiE;GAC/D,aAAa,CAAA;AA0JjB,MAAM,WAAW,oBAAqB,SAAQ,IAAI,CAAC,cAAc,CAAC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzF,KAAK,EAAE,oBAAoB,CAAA;IAC3B;;;OAGG;IACH,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,WAAW,CAAC,EAAE,SAAS,CAAA;IACvB;;;;OAIG;IACH,MAAM,CAAC,EAAE,SAAS,CAAA;IAClB,mEAAmE;IACnE,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,uEAAuE;IACvE,MAAM,CAAC,EAAE,SAAS,CAAA;IAClB,uEAAuE;IACvE,IAAI,CAAC,EAAE,SAAS,GAAG,OAAO,CAAA;CAC3B;AAED,wBAAgB,eAAe,CAAC,EAC9B,KAAK,EACL,KAAK,EACL,WAAW,EACX,MAAM,EACN,OAAO,EACP,UAAwB,EACxB,MAAM,EACN,IAAc,EACd,SAAS,EACT,GAAG,KAAK,EACT,EAAE,oBAAoB,+BAiEtB;AAMD;;;;GAIG;AACH,MAAM,WAAW,SAAS,CAAC,CAAC;IAC1B,IAAI,EAAE,CAAC,GAAG,SAAS,CAAA;IACnB,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,KAAK,CAAC,EAAE,OAAO,CAAA;CAChB;AAwBD,0DAA0D;AAC1D,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAcpD;AAED;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAChC,KAAK,EAAE,SAAS,CAAC,CAAC,CAAC,EACnB,OAAO,GAAE,CAAC,IAAI,EAAE,CAAC,KAAK,OAAmD,GACxE,oBAAoB,GAAG,OAAO,CAKhC"}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from './utils.js';
|
|
3
|
+
const PRESETS = {
|
|
4
|
+
loading: {
|
|
5
|
+
tone: 'info',
|
|
6
|
+
title: 'Loading…',
|
|
7
|
+
description: 'Fetching the current figures.',
|
|
8
|
+
icon: 'spinner',
|
|
9
|
+
live: 'polite',
|
|
10
|
+
role: 'status',
|
|
11
|
+
},
|
|
12
|
+
empty: {
|
|
13
|
+
tone: 'neutral',
|
|
14
|
+
title: 'Nothing recorded yet',
|
|
15
|
+
description: 'This is a real answer, not a failure — no records exist for the current selection.',
|
|
16
|
+
icon: 'empty',
|
|
17
|
+
live: 'polite',
|
|
18
|
+
role: 'status',
|
|
19
|
+
},
|
|
20
|
+
error: {
|
|
21
|
+
tone: 'destructive',
|
|
22
|
+
title: 'Could not load this',
|
|
23
|
+
description: 'The request failed, so nothing is shown. This is not an empty result — the real value is unknown.',
|
|
24
|
+
icon: 'alert',
|
|
25
|
+
live: 'assertive',
|
|
26
|
+
role: 'alert',
|
|
27
|
+
},
|
|
28
|
+
forbidden: {
|
|
29
|
+
tone: 'warning',
|
|
30
|
+
title: 'You do not have access to this',
|
|
31
|
+
description: 'The request was refused. Data may exist that this account is not permitted to read, so nothing is shown.',
|
|
32
|
+
icon: 'lock',
|
|
33
|
+
live: 'assertive',
|
|
34
|
+
role: 'alert',
|
|
35
|
+
},
|
|
36
|
+
unavailable: {
|
|
37
|
+
tone: 'warning',
|
|
38
|
+
title: 'Not readable from here',
|
|
39
|
+
description: 'No queryable source for this information is reachable from this surface, so nothing is shown.',
|
|
40
|
+
icon: 'unplugged',
|
|
41
|
+
live: 'polite',
|
|
42
|
+
role: 'status',
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
// Role-bound only. `success` is deliberately absent: an honest state is never green.
|
|
46
|
+
const TONE_SURFACE = {
|
|
47
|
+
neutral: 'border-border bg-muted/30 text-muted-foreground',
|
|
48
|
+
info: 'border-primary/30 bg-primary/5 text-primary',
|
|
49
|
+
warning: 'border-status-warning/40 bg-status-warning/5 text-status-warning',
|
|
50
|
+
destructive: 'border-destructive/40 bg-destructive/5 text-destructive',
|
|
51
|
+
};
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Icons — inline SVG, `currentColor` only, so they inherit the tone token.
|
|
54
|
+
// (The package has no icon dependency; `loading-spinner.tsx` sets this
|
|
55
|
+
// precedent. Adding lucide-react here would push it onto every consumer.)
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
function StateIcon({ icon }) {
|
|
58
|
+
const common = {
|
|
59
|
+
viewBox: '0 0 24 24',
|
|
60
|
+
fill: 'none',
|
|
61
|
+
stroke: 'currentColor',
|
|
62
|
+
strokeWidth: 1.75,
|
|
63
|
+
strokeLinecap: 'round',
|
|
64
|
+
strokeLinejoin: 'round',
|
|
65
|
+
className: 'h-6 w-6',
|
|
66
|
+
'aria-hidden': true,
|
|
67
|
+
};
|
|
68
|
+
switch (icon) {
|
|
69
|
+
case 'spinner':
|
|
70
|
+
return (_jsxs("svg", { ...common, className: cn(common.className, 'animate-spin'), children: [_jsx("circle", { cx: "12", cy: "12", r: "9", className: "opacity-25" }), _jsx("path", { d: "M21 12a9 9 0 0 0-9-9", className: "opacity-90" })] }));
|
|
71
|
+
case 'empty':
|
|
72
|
+
// An empty tray — "we looked, there was nothing".
|
|
73
|
+
return (_jsxs("svg", { ...common, children: [_jsx("path", { d: "M3 14h4l1.5 3h7L17 14h4" }), _jsx("path", { d: "M5 14 7 5h10l2 9v5H5z" })] }));
|
|
74
|
+
case 'alert':
|
|
75
|
+
return (_jsxs("svg", { ...common, children: [_jsx("path", { d: "M12 3.5 2.5 20h19L12 3.5z" }), _jsx("path", { d: "M12 10v4" }), _jsx("path", { d: "M12 17.5h.01" })] }));
|
|
76
|
+
case 'lock':
|
|
77
|
+
return (_jsxs("svg", { ...common, children: [_jsx("rect", { x: "4", y: "10", width: "16", height: "10", rx: "2" }), _jsx("path", { d: "M8 10V7a4 4 0 0 1 8 0v3" }), _jsx("path", { d: "M12 14v2" })] }));
|
|
78
|
+
case 'unplugged':
|
|
79
|
+
return (_jsxs("svg", { ...common, children: [_jsx("path", { d: "M7 3v5" }), _jsx("path", { d: "M13 3v5" }), _jsx("path", { d: "M4 8h12v3a6 6 0 0 1-6 6v4" }), _jsx("path", { d: "m3 3 18 18" })] }));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Diagonal hatch. This is the load-bearing visual: hatched ground is not a
|
|
84
|
+
* texture any populated surface in the estate uses, so "this panel is not
|
|
85
|
+
* data" is legible before a single word is read. `currentColor` keeps it on
|
|
86
|
+
* the tone token in both themes.
|
|
87
|
+
*/
|
|
88
|
+
function Hatch() {
|
|
89
|
+
return (_jsxs("svg", { "aria-hidden": true, className: "pointer-events-none absolute inset-0 h-full w-full opacity-[0.07]", preserveAspectRatio: "none", children: [_jsx("defs", { children: _jsx("pattern", { id: "bs-du-hatch", width: "8", height: "8", patternUnits: "userSpaceOnUse", patternTransform: "rotate(45)", children: _jsx("line", { x1: "0", y1: "0", x2: "0", y2: "8", stroke: "currentColor", strokeWidth: "2" }) }) }), _jsx("rect", { width: "100%", height: "100%", fill: "url(#bs-du-hatch)" })] }));
|
|
90
|
+
}
|
|
91
|
+
export function DataUnavailable({ state, title, description, reason, onRetry, retryLabel = 'Try again', action, size = 'block', className, ...props }) {
|
|
92
|
+
const preset = PRESETS[state];
|
|
93
|
+
const showRetry = Boolean(onRetry) && state !== 'forbidden';
|
|
94
|
+
return (_jsxs("div", { "data-slot": "data-unavailable", "data-state": state, role: preset.role, "aria-live": preset.live, "aria-busy": state === 'loading' || undefined, className: cn(
|
|
95
|
+
// Dashed + hatched: deliberately unlike every populated surface.
|
|
96
|
+
'relative isolate flex flex-col items-center justify-center overflow-hidden', 'rounded-lg border border-dashed text-center', size === 'compact' ? 'min-h-32 gap-2 p-4' : 'min-h-48 gap-3 p-8', TONE_SURFACE[preset.tone], className), ...props, children: [_jsx(Hatch, {}), _jsxs("div", { className: "relative flex flex-col items-center gap-3", children: [_jsx(StateIcon, { icon: preset.icon }), _jsxs("div", { className: "space-y-1", children: [_jsx("p", { className: "text-base font-semibold text-foreground", children: title ?? preset.title }), _jsx("p", { className: "mx-auto max-w-prose text-sm text-muted-foreground", children: description ?? preset.description })] }), reason ? (_jsxs("div", { className: "mx-auto w-full max-w-prose", children: [_jsx("p", { className: "mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground", children: "Reason" }), _jsx("pre", { className: "overflow-x-auto whitespace-pre-wrap break-words rounded-md border border-border bg-card/80 px-3 py-2 text-left font-mono text-xs text-muted-foreground", children: reason })] })) : null, showRetry || action ? (_jsxs("div", { className: "mt-1 flex flex-wrap items-center justify-center gap-2", children: [showRetry ? (_jsx("button", { type: "button", onClick: onRetry, className: cn('inline-flex items-center rounded-md border border-border bg-card px-3 py-1.5', 'text-sm font-medium text-foreground', 'hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'), children: retryLabel })) : null, action] })) : null] })] }));
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Postgres/PostgREST codes that mean "refused", not "broken".
|
|
100
|
+
*
|
|
101
|
+
* 42501 insufficient_privilege · PGRST301 JWT problem ·
|
|
102
|
+
* PGRST116 no rows returned where one was required under RLS.
|
|
103
|
+
*/
|
|
104
|
+
const FORBIDDEN_MARKERS = ['42501', 'PGRST301', 'PGRST116', 'permission denied', 'row-level security'];
|
|
105
|
+
function looksForbidden(error) {
|
|
106
|
+
if (!error)
|
|
107
|
+
return false;
|
|
108
|
+
const parts = [];
|
|
109
|
+
if (typeof error === 'string')
|
|
110
|
+
parts.push(error);
|
|
111
|
+
else if (typeof error === 'object') {
|
|
112
|
+
const e = error;
|
|
113
|
+
if (typeof e.message === 'string')
|
|
114
|
+
parts.push(e.message);
|
|
115
|
+
if (typeof e.code === 'string')
|
|
116
|
+
parts.push(e.code);
|
|
117
|
+
if (e.status === 401 || e.status === 403)
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
const haystack = parts.join(' ').toLowerCase();
|
|
121
|
+
return FORBIDDEN_MARKERS.some((m) => haystack.includes(m.toLowerCase()));
|
|
122
|
+
}
|
|
123
|
+
/** Human-readable reason string for the `reason` prop. */
|
|
124
|
+
export function describeError(error) {
|
|
125
|
+
if (!error)
|
|
126
|
+
return 'Unknown error';
|
|
127
|
+
if (typeof error === 'string')
|
|
128
|
+
return error;
|
|
129
|
+
if (error instanceof Error)
|
|
130
|
+
return error.message;
|
|
131
|
+
if (typeof error === 'object') {
|
|
132
|
+
const e = error;
|
|
133
|
+
const code = typeof e.code === 'string' ? `[${e.code}] ` : '';
|
|
134
|
+
if (typeof e.message === 'string')
|
|
135
|
+
return `${code}${e.message}`;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
return JSON.stringify(error);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return String(error);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Map a query result to either `'ready'` (render the real data) or the honest
|
|
146
|
+
* state to render instead.
|
|
147
|
+
*
|
|
148
|
+
* The point is that there is no fourth answer. A call site cannot get a
|
|
149
|
+
* fallback out of this function, which is what makes the class fix stick.
|
|
150
|
+
*
|
|
151
|
+
* @param isEmpty how to decide the data is genuinely empty. Defaults to
|
|
152
|
+
* "an array with no elements". Pass explicitly for object-shaped payloads.
|
|
153
|
+
*/
|
|
154
|
+
export function resolveDataState(query, isEmpty = (d) => Array.isArray(d) && d.length === 0) {
|
|
155
|
+
if (query.isError)
|
|
156
|
+
return looksForbidden(query.error) ? 'forbidden' : 'error';
|
|
157
|
+
if (query.isPending ?? query.isLoading ?? false)
|
|
158
|
+
return 'loading';
|
|
159
|
+
if (query.data === undefined || query.data === null)
|
|
160
|
+
return 'error';
|
|
161
|
+
return isEmpty(query.data) ? 'empty' : 'ready';
|
|
162
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EntitySelector<T>
|
|
3
|
+
*
|
|
4
|
+
* Generic searchable combobox for selecting entities from any Supabase table.
|
|
5
|
+
* Built on cmdk (Command) + Popover primitives shipped alongside this file in
|
|
6
|
+
* `@bsuite/ui`.
|
|
7
|
+
*
|
|
8
|
+
* Extracted from crm7 into `@bsuite/ui` (AD-5, 2026-08-17). crm7 had 65 files
|
|
9
|
+
* of adoption; throughput and conduit had each started hand-porting their own
|
|
10
|
+
* copy at the identical path. A shared package with the old copies still
|
|
11
|
+
* present is worse than no package at all — three divergent implementations
|
|
12
|
+
* instead of one — so this is the SOLE implementation going forward. Apps
|
|
13
|
+
* consume it directly (or via a thin per-app adapter that only binds their
|
|
14
|
+
* own Supabase client instance — see crm7's `src/components/entity/
|
|
15
|
+
* EntitySelector.tsx` for that pattern) rather than re-implementing it.
|
|
16
|
+
*
|
|
17
|
+
* Generalised out of crm7 in two ways, both required so this package does not
|
|
18
|
+
* drag a consumer's local shadcn instance or app-singleton Supabase client
|
|
19
|
+
* with it:
|
|
20
|
+
*
|
|
21
|
+
* 1. UI primitives (`Button`, `Command*`, `Popover*`, `StatusBadge`) are
|
|
22
|
+
* this package's OWN copies (`./button.js`, `./command.js`,
|
|
23
|
+
* `./popover.js`, `./status-badge.js`), not `@/components/ui/*` imports
|
|
24
|
+
* reaching back into crm7. They render from the same semantic Tailwind
|
|
25
|
+
* token classes (`bg-popover`, `text-muted-foreground`, …) every BSuite
|
|
26
|
+
* app already shares via `@bsuite/theme`, so the rendered result is
|
|
27
|
+
* pixel-identical to crm7's former local copy.
|
|
28
|
+
* 2. Data access (`supabaseClient`) and diagnostics (`logger`) are
|
|
29
|
+
* dependency-injected props rather than a hardcoded `@/lib/supabase` /
|
|
30
|
+
* `@/utils/logger` import — every app owns a differently-shaped
|
|
31
|
+
* Supabase client (crm7: a Vite-SPA singleton; conduit: a per-request
|
|
32
|
+
* `createClient()` factory via `@supabase/ssr`), and this package
|
|
33
|
+
* cannot assume either.
|
|
34
|
+
*
|
|
35
|
+
* Features:
|
|
36
|
+
* - Debounced typeahead search against Supabase (ilike across configured columns)
|
|
37
|
+
* - Shows primary + secondary display fields per result
|
|
38
|
+
* - Returns the full row object on select (enables auto-populate in parent forms)
|
|
39
|
+
* - Optional "quick-add" button for inline entity creation
|
|
40
|
+
* - For small datasets (<50 rows) loads all on open; for larger sets requires typing
|
|
41
|
+
* - Supports external value control via `value` prop
|
|
42
|
+
*
|
|
43
|
+
* Usage:
|
|
44
|
+
* <EntitySelector<Employer>
|
|
45
|
+
* supabaseClient={supabase}
|
|
46
|
+
* table="employers"
|
|
47
|
+
* value={form.employer_id}
|
|
48
|
+
* onSelect={(emp) => { setValue('employer_id', emp.id); autoFill(emp); }}
|
|
49
|
+
* displayField={(r) => r.business_name}
|
|
50
|
+
* secondaryField={(r) => r.abn ?? r.industry}
|
|
51
|
+
* searchColumns={['business_name', 'trading_name', 'abn']}
|
|
52
|
+
* />
|
|
53
|
+
*/
|
|
54
|
+
import type { PostgrestFilterBuilder } from '@supabase/postgrest-js';
|
|
55
|
+
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
|
|
56
|
+
/**
|
|
57
|
+
* Schema-agnostic Supabase filter-builder alias used by `filterFn`.
|
|
58
|
+
*
|
|
59
|
+
* `PostgrestFilterBuilder` is parameterised over the concrete DB schema,
|
|
60
|
+
* row, result, and relationship types — which the EntitySelector factory
|
|
61
|
+
* deliberately does not know at this layer (each selector wraps its own
|
|
62
|
+
* `T`). Using `any` for the four generic positions is the canonical escape
|
|
63
|
+
* hatch in `@supabase/postgrest-js` for schema-agnostic filter-builder
|
|
64
|
+
* wrappers. Centralising it here (rather than `(query: any) => any` inline)
|
|
65
|
+
* gives call sites stable input/output types so they get method completion
|
|
66
|
+
* on `.eq()` / `.in()` / `.gte()` chains and no longer need `as typeof
|
|
67
|
+
* query` casts.
|
|
68
|
+
*/
|
|
69
|
+
export type EntitySelectorQuery = PostgrestFilterBuilder<any, any, any, any>;
|
|
70
|
+
/**
|
|
71
|
+
* Minimal diagnostics sink. Defaults to `console` so a consumer that omits
|
|
72
|
+
* `logger` still sees failures during development rather than a silent
|
|
73
|
+
* swallow; production apps typically inject their own structured logger.
|
|
74
|
+
*/
|
|
75
|
+
export interface EntitySelectorLogger {
|
|
76
|
+
warn: (...args: unknown[]) => void;
|
|
77
|
+
error: (...args: unknown[]) => void;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Structural shape this component needs from a Supabase client — NOT the
|
|
81
|
+
* nominal `SupabaseClient` class type from `@supabase/supabase-js`.
|
|
82
|
+
*
|
|
83
|
+
* Deliberate: this package's own `@supabase/supabase-js` (a devDependency,
|
|
84
|
+
* used only for the type) and a consumer's own `@supabase/supabase-js` (a
|
|
85
|
+
* peer dependency, resolved from THEIR lockfile) can end up as two
|
|
86
|
+
* different installed instances in a pnpm monorepo where every app pins
|
|
87
|
+
* its own floating range independently — crm7's own copy has drifted to
|
|
88
|
+
* 2.110.8 against this package's 2.108.1, for instance. TypeScript treats
|
|
89
|
+
* two structurally-identical classes sourced from different physical
|
|
90
|
+
* packages as nominally incompatible ("not assignable" even though every
|
|
91
|
+
* member matches), which would make a real consumer's real client fail to
|
|
92
|
+
* satisfy a nominal `SupabaseClient` prop type for no functional reason.
|
|
93
|
+
* A structural interface sidesteps the whole class of failure: any client
|
|
94
|
+
* with these two methods satisfies it, regardless of which supabase-js
|
|
95
|
+
* instance produced it. The query builder itself is intentionally
|
|
96
|
+
* `any`-erased past this point — see {@link EntitySelectorQuery}, the
|
|
97
|
+
* same escape hatch this component already uses for `filterFn`.
|
|
98
|
+
*/
|
|
99
|
+
export interface EntitySelectorSupabaseClient {
|
|
100
|
+
from: (table: string) => any;
|
|
101
|
+
schema: (schemaName: string) => {
|
|
102
|
+
from: (table: string) => any;
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
export interface EntitySelectorProps<T extends Record<string, unknown>> {
|
|
106
|
+
/**
|
|
107
|
+
* The consumer's own Supabase client instance. `@bsuite/ui` does not own a
|
|
108
|
+
* client — each app's client is shaped differently (singleton vs
|
|
109
|
+
* per-request factory, different `storageKey`, etc.) — so this is
|
|
110
|
+
* required rather than imported internally.
|
|
111
|
+
*/
|
|
112
|
+
supabaseClient: EntitySelectorSupabaseClient;
|
|
113
|
+
/** Diagnostics sink for non-fatal query failures. Defaults to `console`. */
|
|
114
|
+
logger?: EntitySelectorLogger;
|
|
115
|
+
/** Supabase table name */
|
|
116
|
+
table: string;
|
|
117
|
+
/**
|
|
118
|
+
* Optional non-`public` Postgres schema (e.g. `'catalog'`). When set, the
|
|
119
|
+
* query is built via `supabaseClient.schema(schema).from(table)` instead
|
|
120
|
+
* of `supabaseClient.from(table)`. PostgREST embeds do not cross schema
|
|
121
|
+
* boundaries, and cross-schema reads additionally require the querying
|
|
122
|
+
* role (`authenticated`/`anon`) to hold `USAGE` on that schema, which is a
|
|
123
|
+
* DB grant this component cannot verify or fix at runtime. A failed read
|
|
124
|
+
* surfaces through the existing `error` state below (role="alert"), the
|
|
125
|
+
* same path used for a bad embed hint — never a silent "no results".
|
|
126
|
+
*/
|
|
127
|
+
schema?: string;
|
|
128
|
+
/** Currently selected entity ID (controlled) */
|
|
129
|
+
value?: string;
|
|
130
|
+
/**
|
|
131
|
+
* Called with the full row when user selects; null when cleared.
|
|
132
|
+
*
|
|
133
|
+
* May return a Promise (import-on-select: a catalogue selector resolves
|
|
134
|
+
* the pick through an RPC before committing it). When it does,
|
|
135
|
+
* `handleItemSelect` below does NOT optimistically show the clicked row —
|
|
136
|
+
* see that function's comment for why an optimistic update is actively
|
|
137
|
+
* wrong for an async `onSelect`.
|
|
138
|
+
*/
|
|
139
|
+
onSelect: (entity: T | null) => void | Promise<void>;
|
|
140
|
+
/** How to render the primary display text from a row */
|
|
141
|
+
displayField: (row: T) => string;
|
|
142
|
+
/** Optional secondary line (e.g. ABN, email) */
|
|
143
|
+
secondaryField?: (row: T) => string;
|
|
144
|
+
/** Columns to search via ilike */
|
|
145
|
+
searchColumns: readonly string[];
|
|
146
|
+
/** Supabase select string — supports JOINs e.g. "*, contacts(first_name)" */
|
|
147
|
+
selectColumns?: string;
|
|
148
|
+
/**
|
|
149
|
+
* Additional Supabase query filters (e.g. status eq active). Receives
|
|
150
|
+
* and returns a schema-agnostic {@link EntitySelectorQuery} so consumers
|
|
151
|
+
* get stable input/output typing and `.eq()` / `.in()` / `.gte()` method
|
|
152
|
+
* completion without needing `as typeof query` casts at each call site.
|
|
153
|
+
*/
|
|
154
|
+
filterFn?: (query: EntitySelectorQuery) => EntitySelectorQuery;
|
|
155
|
+
/** Placeholder text */
|
|
156
|
+
placeholder?: string;
|
|
157
|
+
/** Disabled state */
|
|
158
|
+
disabled?: boolean;
|
|
159
|
+
/** Show a "+" quick-add button; callback fired on click */
|
|
160
|
+
onQuickAdd?: () => void;
|
|
161
|
+
/** Quick-add button label */
|
|
162
|
+
quickAddLabel?: string;
|
|
163
|
+
/** Min characters before searching (0 = load on open) */
|
|
164
|
+
minChars?: number;
|
|
165
|
+
/** Debounce in ms */
|
|
166
|
+
debounceMs?: number;
|
|
167
|
+
/** Max results to fetch */
|
|
168
|
+
limit?: number;
|
|
169
|
+
/** Allow clearing the selection */
|
|
170
|
+
clearable?: boolean;
|
|
171
|
+
/** Additional className for the trigger button */
|
|
172
|
+
className?: string;
|
|
173
|
+
/** Label shown in the trigger when an entity is selected */
|
|
174
|
+
selectedLabel?: string;
|
|
175
|
+
/**
|
|
176
|
+
* Notifies the parent when the selector's query state changes so the
|
|
177
|
+
* parent form can gate submission ("Resolving company…") while the
|
|
178
|
+
* typeahead is in-flight. Fires on every loading-state transition plus
|
|
179
|
+
* when the query text changes.
|
|
180
|
+
*/
|
|
181
|
+
onQueryStateChange?: (state: {
|
|
182
|
+
isLoading: boolean;
|
|
183
|
+
query: string;
|
|
184
|
+
}) => void;
|
|
185
|
+
/**
|
|
186
|
+
* Optional custom row renderer. When provided, replaces the default
|
|
187
|
+
* display/secondary field layout for each result row, letting selectors
|
|
188
|
+
* visually distinguish inactive/discontinued rows.
|
|
189
|
+
*
|
|
190
|
+
* The renderer receives the row plus a selection indicator so custom
|
|
191
|
+
* implementations can still draw a check/highlight for the currently
|
|
192
|
+
* selected entity. The wrapping `<CommandItem>` (with its own click /
|
|
193
|
+
* keyboard handler) is supplied by EntitySelector — `renderRow` only
|
|
194
|
+
* renders the row's inner content.
|
|
195
|
+
*/
|
|
196
|
+
renderRow?: (row: T, meta: {
|
|
197
|
+
isSelected: boolean;
|
|
198
|
+
}) => ReactNode;
|
|
199
|
+
/** Form control id for aria-labelledby */
|
|
200
|
+
id?: ComponentPropsWithoutRef<'button'>['id'];
|
|
201
|
+
/** Form control name attribute */
|
|
202
|
+
name?: ComponentPropsWithoutRef<'button'>['name'];
|
|
203
|
+
/** Form control aria-describedby */
|
|
204
|
+
'aria-describedby'?: ComponentPropsWithoutRef<'button'>['aria-describedby'];
|
|
205
|
+
/** Form control aria-invalid */
|
|
206
|
+
'aria-invalid'?: ComponentPropsWithoutRef<'button'>['aria-invalid'];
|
|
207
|
+
/** Form control aria-labelledby */
|
|
208
|
+
'aria-labelledby'?: ComponentPropsWithoutRef<'button'>['aria-labelledby'];
|
|
209
|
+
/**
|
|
210
|
+
* Import-on-select: when a selector SEARCHES one table/schema but `value`
|
|
211
|
+
* is a foreign key into a DIFFERENT table (e.g. a catalogue selector
|
|
212
|
+
* searches `catalog.qualifications` but the owning column — and
|
|
213
|
+
* therefore `value` — points at `public.qualifications`, the tenant's
|
|
214
|
+
* own copy created on select), these three let the "resolve an
|
|
215
|
+
* already-selected value" lookup target that different table instead of
|
|
216
|
+
* `table`/`schema`/`selectColumns`. Default (all three omitted) is
|
|
217
|
+
* today's behaviour: resolve from the same place search runs against —
|
|
218
|
+
* every existing caller is unaffected.
|
|
219
|
+
*/
|
|
220
|
+
resolveValueTable?: string;
|
|
221
|
+
resolveValueSchema?: string;
|
|
222
|
+
resolveValueSelectColumns?: string;
|
|
223
|
+
/**
|
|
224
|
+
* ONE-SHOT: ids already linked to this record through an earlier screen.
|
|
225
|
+
*
|
|
226
|
+
* DRY is about one row in one table. This is about the USER never typing
|
|
227
|
+
* the same thing twice: the second screen in a workflow surfaces what the
|
|
228
|
+
* first screen already established, so the answer is one click rather
|
|
229
|
+
* than a search.
|
|
230
|
+
*
|
|
231
|
+
* These are fetched BY ID and shown in their own group at the top, before
|
|
232
|
+
* any typing. That matters — `minChars` means an ordinary picker shows
|
|
233
|
+
* nothing at all until the user types, which is exactly the moment they
|
|
234
|
+
* are being asked to re-derive something the system already knows.
|
|
235
|
+
* Suggested rows bypass that.
|
|
236
|
+
*
|
|
237
|
+
* This narrows nothing. The full catalogue stays searchable underneath,
|
|
238
|
+
* so a genuinely new choice is never blocked — the same
|
|
239
|
+
* advise-don't-block posture the rest of this domain runs on.
|
|
240
|
+
*/
|
|
241
|
+
suggestedIds?: readonly string[];
|
|
242
|
+
/**
|
|
243
|
+
* Heading for the suggested group. Say WHERE the options came from — "From
|
|
244
|
+
* Teagan Taylor's record", "Preferred by Built Management Services" —
|
|
245
|
+
* because a bare "Suggested" gives the user no way to judge whether to
|
|
246
|
+
* trust it.
|
|
247
|
+
*/
|
|
248
|
+
suggestedLabel?: string;
|
|
249
|
+
}
|
|
250
|
+
export declare function EntitySelector<T extends Record<string, unknown>>({ supabaseClient, logger, table, schema, value, onSelect, displayField, secondaryField, searchColumns, selectColumns, filterFn, placeholder, disabled, onQuickAdd, quickAddLabel, minChars, debounceMs, limit, clearable, className, selectedLabel, onQueryStateChange, renderRow, id, name, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, 'aria-labelledby': ariaLabelledBy, resolveValueTable, resolveValueSchema, resolveValueSelectColumns, suggestedIds, suggestedLabel, }: EntitySelectorProps<T>): import("react").JSX.Element;
|
|
251
|
+
export default EntitySelector;
|
|
252
|
+
//# sourceMappingURL=entity-selector.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entity-selector.d.ts","sourceRoot":"","sources":["../src/entity-selector.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoDG;AAEH,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAA;AAEpE,OAAO,KAAK,EAAE,wBAAwB,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAoBhE;;;;;;;;;;;;GAYG;AAEH,MAAM,MAAM,mBAAmB,GAAG,sBAAsB,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAA;AAE5E;;;;GAIG;AACH,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAA;IAClC,KAAK,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAA;CACpC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,WAAW,4BAA4B;IAE3C,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,GAAG,CAAA;IAC5B,MAAM,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK;QAE9B,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,GAAG,CAAA;KAC7B,CAAA;CACF;AAED,MAAM,WAAW,mBAAmB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IACpE;;;;;OAKG;IACH,cAAc,EAAE,4BAA4B,CAAA;IAC5C,4EAA4E;IAC5E,MAAM,CAAC,EAAE,oBAAoB,CAAA;IAC7B,0BAA0B;IAC1B,KAAK,EAAE,MAAM,CAAA;IACb;;;;;;;;;OASG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,gDAAgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;;;;;;OAQG;IACH,QAAQ,EAAE,CAAC,MAAM,EAAE,CAAC,GAAG,IAAI,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACpD,wDAAwD;IACxD,YAAY,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,MAAM,CAAA;IAChC,gDAAgD;IAChD,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,MAAM,CAAA;IACnC,kCAAkC;IAClC,aAAa,EAAE,SAAS,MAAM,EAAE,CAAA;IAChC,6EAA6E;IAC7E,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,mBAAmB,KAAK,mBAAmB,CAAA;IAC9D,uBAAuB;IACvB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,2DAA2D;IAC3D,UAAU,CAAC,EAAE,MAAM,IAAI,CAAA;IACvB,6BAA6B;IAC7B,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,yDAAyD;IACzD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,qBAAqB;IACrB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,2BAA2B;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,mCAAmC;IACnC,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,kDAAkD;IAClD,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,4DAA4D;IAC5D,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB;;;;;OAKG;IACH,kBAAkB,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,SAAS,EAAE,OAAO,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAA;IAC3E;;;;;;;;;;OAUG;IACH,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,EAAE,IAAI,EAAE;QAAE,UAAU,EAAE,OAAO,CAAA;KAAE,KAAK,SAAS,CAAA;IAChE,0CAA0C;IAC1C,EAAE,CAAC,EAAE,wBAAwB,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,CAAA;IAC7C,kCAAkC;IAClC,IAAI,CAAC,EAAE,wBAAwB,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,CAAA;IACjD,oCAAoC;IACpC,kBAAkB,CAAC,EAAE,wBAAwB,CAAC,QAAQ,CAAC,CAAC,kBAAkB,CAAC,CAAA;IAC3E,gCAAgC;IAChC,cAAc,CAAC,EAAE,wBAAwB,CAAC,QAAQ,CAAC,CAAC,cAAc,CAAC,CAAA;IACnE,mCAAmC;IACnC,iBAAiB,CAAC,EAAE,wBAAwB,CAAC,QAAQ,CAAC,CAAC,iBAAiB,CAAC,CAAA;IACzE;;;;;;;;;;OAUG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,yBAAyB,CAAC,EAAE,MAAM,CAAA;IAClC;;;;;;;;;;;;;;;;;OAiBG;IACH,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAChC;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AAMD,wBAAgB,cAAc,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAChE,cAAc,EACd,MAAgB,EAChB,KAAK,EACL,MAAM,EACN,KAAK,EACL,QAAQ,EACR,YAAY,EACZ,cAAc,EACd,aAAa,EACb,aAAmB,EACnB,QAAQ,EACR,WAAyB,EACzB,QAAgB,EAChB,UAAU,EACV,aAA4B,EAC5B,QAAY,EACZ,UAAgB,EAChB,KAAU,EACV,SAAgB,EAChB,SAAS,EACT,aAAa,EACb,kBAAkB,EAClB,SAAS,EACT,EAAE,EACF,IAAI,EACJ,kBAAkB,EAAE,eAAe,EACnC,cAAc,EAAE,WAAW,EAC3B,iBAAiB,EAAE,cAAc,EACjC,iBAAiB,EACjB,kBAAkB,EAClB,yBAAyB,EACzB,YAAY,EACZ,cAAiC,GAClC,EAAE,mBAAmB,CAAC,CAAC,CAAC,+BA2cxB;AAED,eAAe,cAAc,CAAA"}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Check, ChevronsUpDown, Loader2, Plus, X } from 'lucide-react';
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { Button } from './button.js';
|
|
5
|
+
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from './command.js';
|
|
6
|
+
import { Popover, PopoverContent, PopoverTrigger } from './popover.js';
|
|
7
|
+
import { StatusBadge } from './status-badge.js';
|
|
8
|
+
import { cn } from './utils.js';
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// COMPONENT
|
|
11
|
+
// ============================================================================
|
|
12
|
+
export function EntitySelector({ supabaseClient, logger = console, table, schema, value, onSelect, displayField, secondaryField, searchColumns, selectColumns = '*', filterFn, placeholder = 'Search...', disabled = false, onQuickAdd, quickAddLabel = 'Create new', minChars = 0, debounceMs = 250, limit = 50, clearable = true, className, selectedLabel, onQueryStateChange, renderRow, id, name, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, 'aria-labelledby': ariaLabelledBy, resolveValueTable, resolveValueSchema, resolveValueSelectColumns, suggestedIds, suggestedLabel = 'Already linked', }) {
|
|
13
|
+
const [open, setOpen] = useState(false);
|
|
14
|
+
const [query, setQuery] = useState('');
|
|
15
|
+
const [items, setItems] = useState([]);
|
|
16
|
+
const [suggestedItems, setSuggestedItems] = useState([]);
|
|
17
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
18
|
+
const [selectedItem, setSelectedItem] = useState(null);
|
|
19
|
+
/**
|
|
20
|
+
* A failed search (bad `selectColumns` embed, RLS denial, network error,
|
|
21
|
+
* etc.) must not fall through to `setItems([])` with only a logger
|
|
22
|
+
* side-channel — the rendered dropdown would show the exact same
|
|
23
|
+
* `CommandEmpty` "No items found" copy as a genuine zero-result search,
|
|
24
|
+
* which is indistinguishable from the user's perspective. Tracking the
|
|
25
|
+
* error message separately lets the render below show a distinct,
|
|
26
|
+
* `role="alert"` state instead.
|
|
27
|
+
*/
|
|
28
|
+
const [error, setError] = useState(null);
|
|
29
|
+
const debounceRef = useRef(null);
|
|
30
|
+
const hasLoadedInitial = useRef(false);
|
|
31
|
+
/**
|
|
32
|
+
* Returns a schema-scoped `.from(table)` builder. Defaults to the public
|
|
33
|
+
* schema (identical to the pre-existing `supabaseClient.from(table)`
|
|
34
|
+
* behaviour) unless `schema` is supplied, in which case it queries via
|
|
35
|
+
* `supabaseClient.schema(schema).from(table)` — see the `schema` prop doc.
|
|
36
|
+
*
|
|
37
|
+
* {@link EntitySelectorSupabaseClient} is structurally typed for the same
|
|
38
|
+
* reason {@link EntitySelectorQuery} is `any`-erased: this package has no
|
|
39
|
+
* generated `Database` type to parameterise against, and each consumer's
|
|
40
|
+
* own generated types differ. `.schema()` is likewise unparameterised
|
|
41
|
+
* here — a schema-agnostic escape hatch, not a typing gap introduced by
|
|
42
|
+
* this component.
|
|
43
|
+
*/
|
|
44
|
+
const fromTable = useCallback(() => {
|
|
45
|
+
if (schema) {
|
|
46
|
+
return supabaseClient.schema(schema).from(table);
|
|
47
|
+
}
|
|
48
|
+
return supabaseClient.from(table);
|
|
49
|
+
}, [schema, table, supabaseClient]);
|
|
50
|
+
/**
|
|
51
|
+
* Table/schema/columns for resolving an already-selected `value` into a
|
|
52
|
+
* display row. Defaults to the search table when `resolveValueTable` is
|
|
53
|
+
* omitted — see the prop doc for why these can differ.
|
|
54
|
+
*/
|
|
55
|
+
const resolveTableName = resolveValueTable ?? table;
|
|
56
|
+
const resolveSchemaName = resolveValueSchema ?? schema;
|
|
57
|
+
const resolveSelectColumns = resolveValueSelectColumns ?? selectColumns;
|
|
58
|
+
const fromResolveTable = useCallback(() => {
|
|
59
|
+
if (resolveSchemaName) {
|
|
60
|
+
return supabaseClient.schema(resolveSchemaName).from(resolveTableName);
|
|
61
|
+
}
|
|
62
|
+
return supabaseClient.from(resolveTableName);
|
|
63
|
+
}, [resolveSchemaName, resolveTableName, supabaseClient]);
|
|
64
|
+
// ── Resolve selected item from value prop ──────────────────────────────
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!value) {
|
|
67
|
+
setSelectedItem(null);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// If we already have this item loaded, use it — but only when the
|
|
71
|
+
// search results and the resolve target are the SAME table (the
|
|
72
|
+
// `resolveValueTable` override case means `items` come from a
|
|
73
|
+
// different table than `value` points at, so a matching `id` there
|
|
74
|
+
// would be coincidental, not correct).
|
|
75
|
+
if (!resolveValueTable) {
|
|
76
|
+
const existing = items.find((i) => String(i.id) === String(value));
|
|
77
|
+
if (existing) {
|
|
78
|
+
setSelectedItem(existing);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Otherwise fetch the single entity by ID
|
|
83
|
+
let cancelled = false;
|
|
84
|
+
(async () => {
|
|
85
|
+
const { data } = await fromResolveTable()
|
|
86
|
+
.select(resolveSelectColumns)
|
|
87
|
+
.eq('id', value)
|
|
88
|
+
.single();
|
|
89
|
+
if (!cancelled && data) {
|
|
90
|
+
setSelectedItem(data);
|
|
91
|
+
}
|
|
92
|
+
})();
|
|
93
|
+
return () => {
|
|
94
|
+
cancelled = true;
|
|
95
|
+
};
|
|
96
|
+
}, [value, resolveValueTable, resolveTableName, resolveSelectColumns, fromResolveTable]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
97
|
+
// ── Search / load items ────────────────────────────────────────────────
|
|
98
|
+
const fetchItems = useCallback(async (searchTerm) => {
|
|
99
|
+
setIsLoading(true);
|
|
100
|
+
setError(null);
|
|
101
|
+
try {
|
|
102
|
+
// Supabase `.select()` returns a narrow `PostgrestTransformBuilder`
|
|
103
|
+
// subtype that carries the concrete row schema. We widen it to the
|
|
104
|
+
// schema-agnostic {@link EntitySelectorQuery} alias so the public
|
|
105
|
+
// `filterFn` prop type (which is unparameterised by design) can
|
|
106
|
+
// consume and return it without callers needing `as` casts. The
|
|
107
|
+
// single widen-cast here replaces the casts every inner selector
|
|
108
|
+
// would otherwise need.
|
|
109
|
+
let q = fromTable().select(selectColumns);
|
|
110
|
+
// Apply custom filters
|
|
111
|
+
if (filterFn) {
|
|
112
|
+
q = filterFn(q);
|
|
113
|
+
}
|
|
114
|
+
// Apply search if term is long enough
|
|
115
|
+
if (searchTerm.length >= Math.max(minChars, 1)) {
|
|
116
|
+
const escaped = searchTerm
|
|
117
|
+
.replace(/\\/g, '\\\\')
|
|
118
|
+
.replace(/"/g, '\\"')
|
|
119
|
+
.replace(/,/g, '\\,')
|
|
120
|
+
.replace(/\(/g, '\\(')
|
|
121
|
+
.replace(/\)/g, '\\)');
|
|
122
|
+
const term = `%${escaped}%`;
|
|
123
|
+
const orConditions = searchColumns.map((col) => `${col}.ilike.${term}`).join(',');
|
|
124
|
+
q = q.or(orConditions);
|
|
125
|
+
}
|
|
126
|
+
q = q.limit(limit);
|
|
127
|
+
const { data, error: queryError } = await q;
|
|
128
|
+
if (queryError) {
|
|
129
|
+
logger.warn(`EntitySelector(${table}): search error`, queryError.message);
|
|
130
|
+
setError(queryError.message || 'Failed to load results.');
|
|
131
|
+
setItems([]);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
setItems(data ?? []);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
logger.error(`EntitySelector(${table}): unexpected error`, err);
|
|
138
|
+
setError(err instanceof Error ? err.message : 'Failed to load results.');
|
|
139
|
+
setItems([]);
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
setIsLoading(false);
|
|
143
|
+
}
|
|
144
|
+
}, [table, selectColumns, searchColumns, filterFn, minChars, limit, fromTable, logger]);
|
|
145
|
+
/**
|
|
146
|
+
* ONE-SHOT: fetch the already-linked rows BY ID.
|
|
147
|
+
*
|
|
148
|
+
* Deliberately a separate query from the search, for two reasons. It must not
|
|
149
|
+
* be subject to `minChars` — the whole point is that the user sees the answer
|
|
150
|
+
* without typing — and it must not be subject to `limit`, or a suggestion
|
|
151
|
+
* could fall off the end of a busy catalogue and silently stop being offered.
|
|
152
|
+
*
|
|
153
|
+
* `filterFn` is NOT applied. A suggestion is already an established link made
|
|
154
|
+
* by this user on an earlier screen; re-filtering it here could hide a row the
|
|
155
|
+
* system itself put there, which reads as the feature being broken.
|
|
156
|
+
*/
|
|
157
|
+
const suggestedKey = suggestedIds?.length ? [...suggestedIds].sort().join(',') : '';
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
if (!open || !suggestedKey) {
|
|
160
|
+
if (!suggestedKey)
|
|
161
|
+
setSuggestedItems([]);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
let cancelled = false;
|
|
165
|
+
void (async () => {
|
|
166
|
+
try {
|
|
167
|
+
const { data, error: sErr } = await fromTable()
|
|
168
|
+
.select(selectColumns)
|
|
169
|
+
.in('id', suggestedKey.split(','));
|
|
170
|
+
if (cancelled)
|
|
171
|
+
return;
|
|
172
|
+
if (sErr) {
|
|
173
|
+
// Never surface this as a page error — the catalogue below still
|
|
174
|
+
// works, so a failed suggestion degrades to an ordinary search
|
|
175
|
+
// rather than blocking the user.
|
|
176
|
+
logger.warn(`EntitySelector(${table}): suggested fetch failed`, sErr.message);
|
|
177
|
+
setSuggestedItems([]);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
setSuggestedItems(data ?? []);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
if (!cancelled) {
|
|
184
|
+
logger.warn(`EntitySelector(${table}): suggested fetch threw`, err);
|
|
185
|
+
setSuggestedItems([]);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
})();
|
|
189
|
+
return () => {
|
|
190
|
+
cancelled = true;
|
|
191
|
+
};
|
|
192
|
+
}, [open, suggestedKey, fromTable, selectColumns, table, logger]);
|
|
193
|
+
/**
|
|
194
|
+
* One row, shared by the suggested group and the catalogue group.
|
|
195
|
+
*
|
|
196
|
+
* Extracted rather than duplicated: the two groups must stay visually and
|
|
197
|
+
* behaviourally identical, and a copy is how one of them quietly loses the
|
|
198
|
+
* selected-check or the `renderRow` override later.
|
|
199
|
+
*/
|
|
200
|
+
const renderItem = (item) => {
|
|
201
|
+
const itemId = String(item.id);
|
|
202
|
+
const isSelected = String(value) === itemId;
|
|
203
|
+
return (_jsx(CommandItem, { value: itemId, onSelect: () => handleItemSelect(item), className: "flex items-center justify-between", children: renderRow ? (renderRow(item, { isSelected })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex flex-col min-w-0", children: [_jsx("span", { className: "font-medium truncate", children: displayField(item) }), secondaryField && (_jsx("span", { className: "text-xs text-muted-foreground truncate", children: secondaryField(item) }))] }), isSelected && _jsx(Check, { className: "h-4 w-4 shrink-0 text-primary" })] })) }, itemId));
|
|
204
|
+
};
|
|
205
|
+
// ── Load items when popover opens (for small datasets / minChars=0) ───
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (open && !hasLoadedInitial.current) {
|
|
208
|
+
hasLoadedInitial.current = true;
|
|
209
|
+
fetchItems('');
|
|
210
|
+
}
|
|
211
|
+
}, [open, fetchItems]);
|
|
212
|
+
// ── Debounced search on query change ───────────────────────────────────
|
|
213
|
+
const handleQueryChange = useCallback((val) => {
|
|
214
|
+
setQuery(val);
|
|
215
|
+
if (debounceRef.current)
|
|
216
|
+
clearTimeout(debounceRef.current);
|
|
217
|
+
debounceRef.current = setTimeout(() => fetchItems(val), debounceMs);
|
|
218
|
+
}, [fetchItems, debounceMs]);
|
|
219
|
+
// ── Surface loading+query state upward so the parent form can gate submit ─
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
if (onQueryStateChange) {
|
|
222
|
+
onQueryStateChange({ isLoading, query });
|
|
223
|
+
}
|
|
224
|
+
}, [isLoading, query, onQueryStateChange]);
|
|
225
|
+
// Cleanup debounce on unmount
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
return () => {
|
|
228
|
+
if (debounceRef.current)
|
|
229
|
+
clearTimeout(debounceRef.current);
|
|
230
|
+
};
|
|
231
|
+
}, []);
|
|
232
|
+
// ── Selection handlers ─────────────────────────────────────────────────
|
|
233
|
+
/**
|
|
234
|
+
* A PRIOR version of this fix had an import-on-select consumer call the
|
|
235
|
+
* parent's `onSelect(null)` on every import-RPC failure, meant to clear a
|
|
236
|
+
* stale optimistic display. That made things worse: in EDIT mode `value`
|
|
237
|
+
* starts as the SAVED entity id, and `onSelect(null)` overwrote the
|
|
238
|
+
* parent form's state with `undefined` — wiping a link the database
|
|
239
|
+
* actually has, the exact inverse of the bug being fixed, on every failed
|
|
240
|
+
* re-pick.
|
|
241
|
+
*
|
|
242
|
+
* The real defect is here, not in the caller: this function set
|
|
243
|
+
* `selectedItem` SYNCHRONOUSLY AND OPTIMISTICALLY on every click, before
|
|
244
|
+
* `onSelect` (which may be async — see the prop doc) had any chance to
|
|
245
|
+
* fail. For a synchronous `onSelect` (the overwhelming majority of
|
|
246
|
+
* consumers) that is correct — there is no failure mode to protect
|
|
247
|
+
* against. For an async `onSelect` (routing the pick through an
|
|
248
|
+
* import-from-catalogue RPC, say), setting `selectedItem` before the
|
|
249
|
+
* round-trip resolves means: on failure, the trigger shows a row the
|
|
250
|
+
* database was never given, and it stays wrong because `value` never
|
|
251
|
+
* changed, so the "resolve selected item from value" effect (above) has
|
|
252
|
+
* nothing to react to and never corrects it.
|
|
253
|
+
*
|
|
254
|
+
* Fix: detect a thenable return and skip the optimistic update entirely.
|
|
255
|
+
* The eventual state is then driven ONLY by `value`:
|
|
256
|
+
* - success: the async `onSelect` calls the caller's real setter with
|
|
257
|
+
* the new (imported) id, `value` changes, the resolve-effect fetches
|
|
258
|
+
* and displays it — one extra network round trip, correct result.
|
|
259
|
+
* - failure: the async `onSelect` does NOT call the caller's setter,
|
|
260
|
+
* `value` is untouched, `selectedItem` was never touched either — a
|
|
261
|
+
* fresh form keeps showing the placeholder, an edit form keeps
|
|
262
|
+
* showing the saved entity. Neither a false assertion nor a false
|
|
263
|
+
* wipe.
|
|
264
|
+
*/
|
|
265
|
+
const handleItemSelect = (item) => {
|
|
266
|
+
const result = onSelect(item);
|
|
267
|
+
if (!result || typeof result.then !== 'function') {
|
|
268
|
+
setSelectedItem(item);
|
|
269
|
+
}
|
|
270
|
+
setOpen(false);
|
|
271
|
+
setQuery('');
|
|
272
|
+
};
|
|
273
|
+
const handleClear = (e) => {
|
|
274
|
+
e.stopPropagation();
|
|
275
|
+
setSelectedItem(null);
|
|
276
|
+
onSelect(null);
|
|
277
|
+
};
|
|
278
|
+
// ── Render ─────────────────────────────────────────────────────────────
|
|
279
|
+
const triggerLabel = selectedItem ? (selectedLabel ?? displayField(selectedItem)) : placeholder;
|
|
280
|
+
return (_jsxs("div", { className: "flex gap-1.5", children: [_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", role: "combobox", "aria-expanded": open, id: id, name: name, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-labelledby": ariaLabelledBy, disabled: disabled, className: cn('w-full justify-between font-normal', !selectedItem && 'text-muted-foreground', className), children: [_jsx("span", { className: "truncate min-w-0", children: triggerLabel }), _jsxs("div", { className: "flex items-center gap-1 ml-2 shrink-0", children: [clearable && selectedItem && (_jsx(X, { className: "h-3.5 w-3.5 opacity-50 hover:opacity-100", onClick: handleClear })), _jsx(ChevronsUpDown, { className: "h-3.5 w-3.5 opacity-50" })] })] }) }), _jsx(PopoverContent, { className: "w-[--radix-popover-trigger-width] p-0", align: "start", children: _jsxs(Command, { shouldFilter: false, children: [_jsx(CommandInput, { placeholder: placeholder, value: query, onValueChange: handleQueryChange }), _jsxs(CommandList, { children: [isLoading ? (_jsxs("div", { className: "flex items-center justify-center py-6", children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin text-muted-foreground" }), _jsx("span", { className: "ml-2 text-sm text-muted-foreground", children: "Searching..." })] })) : error ? (_jsxs("div", { className: "text-center py-6 px-4 text-sm text-error-text", role: "alert", children: ["Couldn't load results: ", error] })) : items.length === 0 && suggestedItems.length === 0 ? (_jsx(CommandEmpty, { children: query.length > 0 ? `No results for "${query}"` : 'No items found' })) : (_jsxs(_Fragment, { children: [suggestedItems.length > 0 && query.length === 0 && (_jsx(CommandGroup, { heading: suggestedLabel, children: suggestedItems.map((item) => renderItem(item)) })), (() => {
|
|
281
|
+
// De-dupe so a suggested row is not offered twice; showing
|
|
282
|
+
// the same entity in both groups reads as two different
|
|
283
|
+
// records.
|
|
284
|
+
const shown = suggestedItems.length > 0 && query.length === 0
|
|
285
|
+
? items.filter((i) => !suggestedItems.some((s) => String(s.id) === String(i.id)))
|
|
286
|
+
: items;
|
|
287
|
+
if (shown.length === 0)
|
|
288
|
+
return null;
|
|
289
|
+
return (_jsx(CommandGroup, { heading: suggestedItems.length > 0 && query.length === 0
|
|
290
|
+
? 'All options'
|
|
291
|
+
: undefined, children: shown.map((item) => renderItem(item)) }));
|
|
292
|
+
})()] })), onQuickAdd && (_jsx("div", { className: "border-t p-1", children: _jsxs(Button, { variant: "ghost", size: "sm", className: "w-full justify-start text-muted-foreground", onClick: () => {
|
|
293
|
+
setOpen(false);
|
|
294
|
+
onQuickAdd();
|
|
295
|
+
}, children: [_jsx(Plus, { className: "h-4 w-4 mr-2" }), quickAddLabel] }) })), items.length > 0 && (_jsx("div", { className: "border-t px-2 py-1.5 text-center", children: _jsxs(StatusBadge, { tone: "neutral", className: "text-[10px]", children: [items.length, items.length >= limit ? '+' : '', " result", items.length !== 1 ? 's' : ''] }) }))] })] }) })] }), onQuickAdd && (_jsxs(Button, { type: "button", variant: "outline", size: quickAddLabel && quickAddLabel.length > 12 ? 'sm' : 'icon', className: quickAddLabel && quickAddLabel.length > 12 ? 'shrink-0 whitespace-nowrap' : 'shrink-0', disabled: disabled, onClick: onQuickAdd, title: quickAddLabel, "aria-label": quickAddLabel, "data-testid": "entity-selector-quick-add", children: [_jsx(Plus, { className: "h-4 w-4" }), quickAddLabel && quickAddLabel.length > 12 ? (_jsx("span", { className: "ml-1.5 max-w-[10rem] truncate sm:max-w-none", children: quickAddLabel })) : null] }))] }));
|
|
296
|
+
}
|
|
297
|
+
export default EntitySelector;
|
package/dist/index.d.ts
CHANGED
|
@@ -7,9 +7,13 @@ export { D2CDefaultLogo, type D2CDefaultLogoProps } from './default-logo.js';
|
|
|
7
7
|
export { Button, type ButtonProps, type ButtonSize, type ButtonVariant, } from './button.js';
|
|
8
8
|
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, type DialogContentProps, type DialogOverlayProps, } from './dialog.js';
|
|
9
9
|
export { EmptyState, type EmptyStateProps } from './empty-state.js';
|
|
10
|
+
export { DataUnavailable, resolveDataState, describeError, type DataUnavailableProps, type DataUnavailableState, type QueryLike, } from './data-unavailable.js';
|
|
10
11
|
export { ErrorBoundary, type ErrorBoundaryProps } from './error-boundary.js';
|
|
11
12
|
export { LoadingSpinner, type LoadingSpinnerProps } from './loading-spinner.js';
|
|
12
13
|
export { StatusBadge, type StatusBadgeProps, type StatusBadgeTone } from './status-badge.js';
|
|
14
|
+
export { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut, } from './command.js';
|
|
15
|
+
export { Popover, PopoverTrigger, PopoverContent } from './popover.js';
|
|
16
|
+
export { EntitySelector, type EntitySelectorProps, type EntitySelectorQuery, type EntitySelectorLogger, type EntitySelectorSupabaseClient, } from './entity-selector.js';
|
|
13
17
|
export { BrandingCard, ColorEditorSheet, OklchColorPicker, type BrandingCardProps, type ColorEditorSheetProps, type OklchColorPickerProps, } from './branding/index.js';
|
|
14
18
|
export { sanitizeCustomCss } from './branding/sanitizeCustomCss.js';
|
|
15
19
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAA;AACnE,OAAO,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAC7D,OAAO,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAC7D,OAAO,EAAE,EAAE,EAAE,MAAM,YAAY,CAAA;AAC/B,OAAO,EACL,IAAI,EACJ,cAAc,EACd,KAAK,SAAS,EACd,KAAK,QAAQ,EACb,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,OAAO,GACb,MAAM,WAAW,CAAA;AAClB,OAAO,EAAE,cAAc,EAAE,KAAK,mBAAmB,EAAE,MAAM,mBAAmB,CAAA;AAC5E,OAAO,EACL,MAAM,EACN,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,aAAa,GACnB,MAAM,aAAa,CAAA;AACpB,OAAO,EACL,MAAM,EACN,WAAW,EACX,aAAa,EACb,iBAAiB,EACjB,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,YAAY,EACZ,WAAW,EACX,aAAa,EACb,KAAK,kBAAkB,EACvB,KAAK,kBAAkB,GACxB,MAAM,aAAa,CAAA;AACpB,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAA;AACnE,OAAO,EAAE,aAAa,EAAE,KAAK,kBAAkB,EAAE,MAAM,qBAAqB,CAAA;AAC5E,OAAO,EAAE,cAAc,EAAE,KAAK,mBAAmB,EAAE,MAAM,sBAAsB,CAAA;AAC/E,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,KAAK,eAAe,EAAE,MAAM,mBAAmB,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAA;AACnE,OAAO,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAC7D,OAAO,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAC7D,OAAO,EAAE,EAAE,EAAE,MAAM,YAAY,CAAA;AAC/B,OAAO,EACL,IAAI,EACJ,cAAc,EACd,KAAK,SAAS,EACd,KAAK,QAAQ,EACb,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,OAAO,GACb,MAAM,WAAW,CAAA;AAClB,OAAO,EAAE,cAAc,EAAE,KAAK,mBAAmB,EAAE,MAAM,mBAAmB,CAAA;AAC5E,OAAO,EACL,MAAM,EACN,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,aAAa,GACnB,MAAM,aAAa,CAAA;AACpB,OAAO,EACL,MAAM,EACN,WAAW,EACX,aAAa,EACb,iBAAiB,EACjB,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,YAAY,EACZ,WAAW,EACX,aAAa,EACb,KAAK,kBAAkB,EACvB,KAAK,kBAAkB,GACxB,MAAM,aAAa,CAAA;AACpB,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAA;AACnE,OAAO,EACL,eAAe,EACf,gBAAgB,EAChB,aAAa,EACb,KAAK,oBAAoB,EACzB,KAAK,oBAAoB,EACzB,KAAK,SAAS,GACf,MAAM,uBAAuB,CAAA;AAC9B,OAAO,EAAE,aAAa,EAAE,KAAK,kBAAkB,EAAE,MAAM,qBAAqB,CAAA;AAC5E,OAAO,EAAE,cAAc,EAAE,KAAK,mBAAmB,EAAE,MAAM,sBAAsB,CAAA;AAC/E,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,KAAK,eAAe,EAAE,MAAM,mBAAmB,CAAA;AAC5F,OAAO,EACL,OAAO,EACP,aAAa,EACb,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,WAAW,EACX,WAAW,EACX,gBAAgB,EAChB,eAAe,GAChB,MAAM,cAAc,CAAA;AACrB,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AACtE,OAAO,EACL,cAAc,EACd,KAAK,mBAAmB,EACxB,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,4BAA4B,GAClC,MAAM,sBAAsB,CAAA;AAG7B,OAAO,EACL,YAAY,EACZ,gBAAgB,EAChB,gBAAgB,EAChB,KAAK,iBAAiB,EACtB,KAAK,qBAAqB,EAC1B,KAAK,qBAAqB,GAC3B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EAAE,iBAAiB,EAAE,MAAM,iCAAiC,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -7,9 +7,13 @@ export { D2CDefaultLogo } from './default-logo.js';
|
|
|
7
7
|
export { Button, } from './button.js';
|
|
8
8
|
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, } from './dialog.js';
|
|
9
9
|
export { EmptyState } from './empty-state.js';
|
|
10
|
+
export { DataUnavailable, resolveDataState, describeError, } from './data-unavailable.js';
|
|
10
11
|
export { ErrorBoundary } from './error-boundary.js';
|
|
11
12
|
export { LoadingSpinner } from './loading-spinner.js';
|
|
12
13
|
export { StatusBadge } from './status-badge.js';
|
|
14
|
+
export { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut, } from './command.js';
|
|
15
|
+
export { Popover, PopoverTrigger, PopoverContent } from './popover.js';
|
|
16
|
+
export { EntitySelector, } from './entity-selector.js';
|
|
13
17
|
// Branding components (v0.3.0+)
|
|
14
18
|
export { BrandingCard, ColorEditorSheet, OklchColorPicker, } from './branding/index.js';
|
|
15
19
|
export { sanitizeCustomCss } from './branding/sanitizeCustomCss.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
|
3
|
+
declare const Popover: React.FC<PopoverPrimitive.PopoverProps>;
|
|
4
|
+
declare const PopoverTrigger: React.ForwardRefExoticComponent<PopoverPrimitive.PopoverTriggerProps & React.RefAttributes<HTMLButtonElement>>;
|
|
5
|
+
declare const PopoverContent: React.ForwardRefExoticComponent<Omit<PopoverPrimitive.PopoverContentProps & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
|
|
6
|
+
export { Popover, PopoverTrigger, PopoverContent };
|
|
7
|
+
//# sourceMappingURL=popover.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"popover.d.ts","sourceRoot":"","sources":["../src/popover.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAC9B,OAAO,KAAK,gBAAgB,MAAM,yBAAyB,CAAA;AAI3D,QAAA,MAAM,OAAO,yCAAwB,CAAA;AAErC,QAAA,MAAM,cAAc,gHAA2B,CAAA;AAE/C,QAAA,MAAM,cAAc,gKAgBlB,CAAA;AAGF,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,CAAA"}
|
package/dist/popover.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
|
4
|
+
import { cn } from './utils.js';
|
|
5
|
+
const Popover = PopoverPrimitive.Root;
|
|
6
|
+
const PopoverTrigger = PopoverPrimitive.Trigger;
|
|
7
|
+
const PopoverContent = React.forwardRef(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (_jsx(PopoverPrimitive.Portal, { children: _jsx(PopoverPrimitive.Content, { ref: ref, align: align, sideOffset: sideOffset, className: cn('z-50 w-72 rounded-md bg-popover p-4 text-popover-foreground shadow-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', className), ...props }) })));
|
|
8
|
+
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
|
9
|
+
export { Popover, PopoverTrigger, PopoverContent };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bsuite/ui",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"publishConfig": {
|
|
@@ -51,6 +51,10 @@
|
|
|
51
51
|
"import": "./dist/empty-state.js",
|
|
52
52
|
"types": "./dist/empty-state.d.ts"
|
|
53
53
|
},
|
|
54
|
+
"./data-unavailable": {
|
|
55
|
+
"import": "./dist/data-unavailable.js",
|
|
56
|
+
"types": "./dist/data-unavailable.d.ts"
|
|
57
|
+
},
|
|
54
58
|
"./error-boundary": {
|
|
55
59
|
"import": "./dist/error-boundary.js",
|
|
56
60
|
"types": "./dist/error-boundary.d.ts"
|
|
@@ -63,6 +67,18 @@
|
|
|
63
67
|
"import": "./dist/status-badge.js",
|
|
64
68
|
"types": "./dist/status-badge.d.ts"
|
|
65
69
|
},
|
|
70
|
+
"./command": {
|
|
71
|
+
"import": "./dist/command.js",
|
|
72
|
+
"types": "./dist/command.d.ts"
|
|
73
|
+
},
|
|
74
|
+
"./popover": {
|
|
75
|
+
"import": "./dist/popover.js",
|
|
76
|
+
"types": "./dist/popover.d.ts"
|
|
77
|
+
},
|
|
78
|
+
"./entity-selector": {
|
|
79
|
+
"import": "./dist/entity-selector.js",
|
|
80
|
+
"types": "./dist/entity-selector.d.ts"
|
|
81
|
+
},
|
|
66
82
|
"./assets/d2c-default-logo.svg": "./src/assets/d2c-default-logo.svg"
|
|
67
83
|
},
|
|
68
84
|
"scripts": {
|
|
@@ -74,23 +90,31 @@
|
|
|
74
90
|
},
|
|
75
91
|
"dependencies": {
|
|
76
92
|
"@radix-ui/react-dialog": "^1.1.16",
|
|
93
|
+
"@radix-ui/react-popover": "^1.1.16",
|
|
77
94
|
"clsx": "^2.1.1",
|
|
95
|
+
"cmdk": "^1.1.1",
|
|
96
|
+
"lucide-react": "^1.17.0",
|
|
78
97
|
"react-error-boundary": "^6.1.2",
|
|
79
98
|
"tailwind-merge": "^3.6.0"
|
|
80
99
|
},
|
|
81
100
|
"peerDependencies": {
|
|
82
101
|
"@bsuite/theme": ">=0.7.0",
|
|
102
|
+
"@supabase/postgrest-js": "^2",
|
|
83
103
|
"react": "^18.3.0 || ^19.0.0",
|
|
84
104
|
"react-dom": "^18.3.0 || ^19.0.0"
|
|
85
105
|
},
|
|
86
106
|
"peerDependenciesMeta": {
|
|
87
107
|
"@bsuite/theme": {
|
|
88
108
|
"optional": true
|
|
109
|
+
},
|
|
110
|
+
"@supabase/postgrest-js": {
|
|
111
|
+
"optional": true
|
|
89
112
|
}
|
|
90
113
|
},
|
|
91
114
|
"devDependencies": {
|
|
92
|
-
"@bsuite/theme": "^0.
|
|
93
|
-
"@storybook/react-vite": "^10.
|
|
115
|
+
"@bsuite/theme": "^0.11.0",
|
|
116
|
+
"@storybook/react-vite": "^10.5.8",
|
|
117
|
+
"@supabase/postgrest-js": "^2.108.1",
|
|
94
118
|
"@tailwindcss/vite": "^4.3.0",
|
|
95
119
|
"@testing-library/jest-dom": "^6.9.1",
|
|
96
120
|
"@testing-library/react": "^16.3.2",
|
|
@@ -101,7 +125,7 @@
|
|
|
101
125
|
"jsdom": "^29.1.1",
|
|
102
126
|
"react": "^19.2.7",
|
|
103
127
|
"react-dom": "^19.2.7",
|
|
104
|
-
"storybook": "^10.
|
|
128
|
+
"storybook": "^10.5.8",
|
|
105
129
|
"tailwindcss": "^4.3.0",
|
|
106
130
|
"typescript": "~6.0.3",
|
|
107
131
|
"vite": "^8.0.16",
|