@spree/dashboard-ui 0.1.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/LICENSE +21 -0
- package/README.md +26 -0
- package/package.json +86 -0
- package/src/hooks/use-copy-to-clipboard.ts +44 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-scrolled.ts +30 -0
- package/src/index.ts +99 -0
- package/src/lib/date-locale.ts +29 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/validation-messages.ts +19 -0
- package/src/spree/address-block.tsx +74 -0
- package/src/spree/back-button.tsx +48 -0
- package/src/spree/bulk-dialog.tsx +71 -0
- package/src/spree/bulk-price-table.tsx +254 -0
- package/src/spree/calculator-summary.tsx +120 -0
- package/src/spree/color-picker.tsx +202 -0
- package/src/spree/confirm-dialog.tsx +82 -0
- package/src/spree/copy-to-clipboard-button.tsx +90 -0
- package/src/spree/country-flag.tsx +30 -0
- package/src/spree/data-grid/cells.tsx +415 -0
- package/src/spree/data-grid/context.ts +59 -0
- package/src/spree/data-grid/data-grid.tsx +214 -0
- package/src/spree/data-grid/fill-handle.tsx +153 -0
- package/src/spree/data-grid/helpers.ts +18 -0
- package/src/spree/data-grid/index.ts +5 -0
- package/src/spree/data-grid/types.ts +36 -0
- package/src/spree/data-grid/use-data-grid-keyboard.ts +254 -0
- package/src/spree/data-grid/use-stable-cell-registration.ts +45 -0
- package/src/spree/drag-handle.tsx +51 -0
- package/src/spree/form-actions.tsx +140 -0
- package/src/spree/json-preview-drawer.tsx +254 -0
- package/src/spree/json-value-view.tsx +149 -0
- package/src/spree/language-menu-items.tsx +54 -0
- package/src/spree/metadata/metadata-card.tsx +62 -0
- package/src/spree/relative-time.tsx +49 -0
- package/src/spree/resource-combobox.tsx +108 -0
- package/src/spree/resource-layout.tsx +31 -0
- package/src/spree/resource-multi-autocomplete.tsx +180 -0
- package/src/spree/resource-name-cell.tsx +44 -0
- package/src/spree/route-error-boundary.tsx +102 -0
- package/src/spree/row-actions.tsx +85 -0
- package/src/spree/row-click-bridge.tsx +17 -0
- package/src/spree/secret-input.tsx +137 -0
- package/src/spree/storefront-visible-switch.tsx +58 -0
- package/src/spree/tag-list.tsx +26 -0
- package/src/spree/theme-provider.tsx +83 -0
- package/src/spree/theme-toggle.tsx +35 -0
- package/src/styles.css +338 -0
- package/src/ui/attachment.tsx +187 -0
- package/src/ui/avatar.tsx +94 -0
- package/src/ui/badge.tsx +161 -0
- package/src/ui/breadcrumb.tsx +109 -0
- package/src/ui/button.tsx +60 -0
- package/src/ui/calendar.tsx +66 -0
- package/src/ui/card.tsx +72 -0
- package/src/ui/chart.tsx +94 -0
- package/src/ui/checkbox.tsx +50 -0
- package/src/ui/collapsible.tsx +18 -0
- package/src/ui/combobox.tsx +270 -0
- package/src/ui/command.tsx +172 -0
- package/src/ui/data-table.tsx +60 -0
- package/src/ui/date-picker.tsx +253 -0
- package/src/ui/date-range-picker.tsx +150 -0
- package/src/ui/dialog.tsx +214 -0
- package/src/ui/dropdown-menu.tsx +345 -0
- package/src/ui/empty.tsx +94 -0
- package/src/ui/field.tsx +221 -0
- package/src/ui/input-group.tsx +141 -0
- package/src/ui/input.tsx +19 -0
- package/src/ui/label.tsx +29 -0
- package/src/ui/pagination.tsx +123 -0
- package/src/ui/popover.tsx +76 -0
- package/src/ui/progress.tsx +73 -0
- package/src/ui/radio-group.tsx +42 -0
- package/src/ui/rich-text-editor.tsx +263 -0
- package/src/ui/select.tsx +213 -0
- package/src/ui/separator.tsx +31 -0
- package/src/ui/sheet.tsx +208 -0
- package/src/ui/sidebar.tsx +679 -0
- package/src/ui/skeleton.tsx +13 -0
- package/src/ui/slot.tsx +35 -0
- package/src/ui/sonner.tsx +47 -0
- package/src/ui/switch.tsx +39 -0
- package/src/ui/textarea.tsx +18 -0
- package/src/ui/tooltip.tsx +100 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { BracesIcon } from 'lucide-react'
|
|
2
|
+
import { Fragment } from 'react'
|
|
3
|
+
import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card'
|
|
4
|
+
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '../../ui/empty'
|
|
5
|
+
|
|
6
|
+
interface MetadataCardProps {
|
|
7
|
+
/**
|
|
8
|
+
* Free-form metadata stored on the record. Read-only — apps and integrations
|
|
9
|
+
* write to this; admins just see what's there. Always an object (possibly
|
|
10
|
+
* empty); the API never returns `null`.
|
|
11
|
+
*/
|
|
12
|
+
metadata: Record<string, unknown>
|
|
13
|
+
/** Translated copy (passed by the app — this component stays headless). */
|
|
14
|
+
title: string
|
|
15
|
+
emptyTitle: string
|
|
16
|
+
emptyDescription: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function MetadataCard({ metadata, title, emptyTitle, emptyDescription }: MetadataCardProps) {
|
|
20
|
+
const entries = Object.entries(metadata)
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<Card>
|
|
24
|
+
<CardHeader>
|
|
25
|
+
<CardTitle>{title}</CardTitle>
|
|
26
|
+
</CardHeader>
|
|
27
|
+
<CardContent>
|
|
28
|
+
{entries.length === 0 ? (
|
|
29
|
+
<Empty className="border-0 p-0">
|
|
30
|
+
<EmptyHeader>
|
|
31
|
+
<EmptyMedia variant="icon">
|
|
32
|
+
<BracesIcon />
|
|
33
|
+
</EmptyMedia>
|
|
34
|
+
<EmptyTitle>{emptyTitle}</EmptyTitle>
|
|
35
|
+
<EmptyDescription>{emptyDescription}</EmptyDescription>
|
|
36
|
+
</EmptyHeader>
|
|
37
|
+
</Empty>
|
|
38
|
+
) : (
|
|
39
|
+
<dl className="grid grid-cols-[minmax(160px,1fr)_2fr] gap-x-4 gap-y-2 text-sm">
|
|
40
|
+
{entries.map(([key, value]) => (
|
|
41
|
+
<Fragment key={key}>
|
|
42
|
+
<dt>
|
|
43
|
+
<code className="text-xs text-muted-foreground">{key}</code>
|
|
44
|
+
</dt>
|
|
45
|
+
<dd className="text-foreground/90 break-words font-mono text-xs">
|
|
46
|
+
{formatValue(value)}
|
|
47
|
+
</dd>
|
|
48
|
+
</Fragment>
|
|
49
|
+
))}
|
|
50
|
+
</dl>
|
|
51
|
+
)}
|
|
52
|
+
</CardContent>
|
|
53
|
+
</Card>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function formatValue(value: unknown): string {
|
|
58
|
+
if (value === null || value === undefined) return '—'
|
|
59
|
+
if (typeof value === 'string') return value
|
|
60
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
61
|
+
return JSON.stringify(value)
|
|
62
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { format, formatDistanceToNow } from 'date-fns'
|
|
2
|
+
import type { ComponentProps, ReactNode } from 'react'
|
|
3
|
+
import { activeDateLocale } from '../lib/date-locale'
|
|
4
|
+
import { cn } from '../lib/utils'
|
|
5
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
|
6
|
+
|
|
7
|
+
type TooltipContentProps = ComponentProps<typeof TooltipContent>
|
|
8
|
+
|
|
9
|
+
type RelativeTimeProps = {
|
|
10
|
+
iso: string | null | undefined
|
|
11
|
+
fallback?: ReactNode
|
|
12
|
+
prefix?: ReactNode
|
|
13
|
+
className?: string
|
|
14
|
+
side?: TooltipContentProps['side']
|
|
15
|
+
align?: TooltipContentProps['align']
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function RelativeTime({
|
|
19
|
+
iso,
|
|
20
|
+
fallback = '—',
|
|
21
|
+
prefix,
|
|
22
|
+
className,
|
|
23
|
+
side,
|
|
24
|
+
align,
|
|
25
|
+
}: RelativeTimeProps) {
|
|
26
|
+
if (!iso) return <>{fallback}</>
|
|
27
|
+
|
|
28
|
+
const date = new Date(iso)
|
|
29
|
+
if (Number.isNaN(date.getTime())) return <>{fallback}</>
|
|
30
|
+
|
|
31
|
+
const locale = activeDateLocale()
|
|
32
|
+
const relative = formatDistanceToNow(date, { addSuffix: true, locale })
|
|
33
|
+
const absolute = format(date, 'PPpp', { locale })
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Tooltip>
|
|
37
|
+
<TooltipTrigger asChild>
|
|
38
|
+
<time dateTime={iso} className={cn('cursor-default', className)}>
|
|
39
|
+
{prefix}
|
|
40
|
+
{prefix ? ' ' : null}
|
|
41
|
+
{relative}
|
|
42
|
+
</time>
|
|
43
|
+
</TooltipTrigger>
|
|
44
|
+
<TooltipContent side={side} align={align}>
|
|
45
|
+
{absolute}
|
|
46
|
+
</TooltipContent>
|
|
47
|
+
</Tooltip>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Combobox,
|
|
4
|
+
ComboboxContent,
|
|
5
|
+
ComboboxEmpty,
|
|
6
|
+
ComboboxInput,
|
|
7
|
+
ComboboxItem,
|
|
8
|
+
ComboboxList,
|
|
9
|
+
} from '../ui/combobox'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Anything we can pick. Records must have a stable string `id` so they round-
|
|
13
|
+
* trip through the `value` prop (the form/parent state keeps the ID, not the
|
|
14
|
+
* full record).
|
|
15
|
+
*/
|
|
16
|
+
export type ComboboxOption = { id: string }
|
|
17
|
+
|
|
18
|
+
export interface ResourceComboboxProps<T extends ComboboxOption> {
|
|
19
|
+
/** Currently selected ID. */
|
|
20
|
+
value: string | undefined | null
|
|
21
|
+
/** Fires with the picked record's ID, or `undefined` when cleared. */
|
|
22
|
+
onChange: (id: string | undefined, record: T | null) => void
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Records available to render in the dropdown. The caller owns fetching +
|
|
26
|
+
* caching; pass whatever subset of options should currently be visible.
|
|
27
|
+
* Must include the record matching `value` so the trigger can render its
|
|
28
|
+
* label, otherwise the trigger falls back to the raw ID.
|
|
29
|
+
*/
|
|
30
|
+
items: T[]
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Called as the user types — receives the raw input string. The caller
|
|
34
|
+
* typically debounces and queries with it. The combobox does NOT manage
|
|
35
|
+
* any internal input/query state.
|
|
36
|
+
*/
|
|
37
|
+
onInputChange?: (next: string) => void
|
|
38
|
+
|
|
39
|
+
/** Renders the trigger + item label. */
|
|
40
|
+
getOptionLabel: (option: T) => string
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Optional richer item renderer for the dropdown. Falls back to
|
|
44
|
+
* `getOptionLabel` when omitted.
|
|
45
|
+
*/
|
|
46
|
+
renderOption?: (option: T) => ReactNode
|
|
47
|
+
|
|
48
|
+
/** Placeholder for the search input. */
|
|
49
|
+
placeholder?: string
|
|
50
|
+
/** Empty state text shown in the dropdown when items is empty. */
|
|
51
|
+
emptyText?: string
|
|
52
|
+
disabled?: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Headless search-driven single-select picker. Doesn't fetch anything itself —
|
|
57
|
+
* pass `items` plus an `onInputChange` callback and wire the data layer
|
|
58
|
+
* outside (TanStack Query, SWR, manual state, whatever).
|
|
59
|
+
*
|
|
60
|
+
* `@spree/dashboard-core` ships a convenience `<ResourceCombobox>` (same name,
|
|
61
|
+
* same props shape minus `items`/`onInputChange`, plus a `search`/`hydrate`/
|
|
62
|
+
* `queryKey` interface) that wraps this one with TanStack Query — that's what
|
|
63
|
+
* most callsites use. Reach for the pure version here when the data flow
|
|
64
|
+
* doesn't match the wrapper's shape.
|
|
65
|
+
*/
|
|
66
|
+
export function ResourceCombobox<T extends ComboboxOption>({
|
|
67
|
+
value,
|
|
68
|
+
onChange,
|
|
69
|
+
items,
|
|
70
|
+
onInputChange,
|
|
71
|
+
getOptionLabel,
|
|
72
|
+
renderOption,
|
|
73
|
+
placeholder,
|
|
74
|
+
emptyText,
|
|
75
|
+
disabled,
|
|
76
|
+
}: ResourceComboboxProps<T>) {
|
|
77
|
+
const selected = items.find((r) => r.id === value) ?? null
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<Combobox
|
|
81
|
+
items={items}
|
|
82
|
+
value={selected}
|
|
83
|
+
onValueChange={(record: T | null) => onChange(record?.id, record)}
|
|
84
|
+
onInputValueChange={onInputChange}
|
|
85
|
+
itemToStringLabel={(record: T | null) => (record ? getOptionLabel(record) : '')}
|
|
86
|
+
itemToStringValue={(record: T | null) => record?.id ?? ''}
|
|
87
|
+
// Filtering is server-side: the caller's `onInputChange` already narrows
|
|
88
|
+
// the items list. Disable Base UI's built-in client filter so it doesn't
|
|
89
|
+
// hide rows whose label doesn't substring-match the typed query.
|
|
90
|
+
filter={null}
|
|
91
|
+
// Mirror multi-autocomplete: `disabled` on the root also disables the
|
|
92
|
+
// dropdown trigger + selection mechanics, not just the input.
|
|
93
|
+
disabled={disabled}
|
|
94
|
+
>
|
|
95
|
+
<ComboboxInput placeholder={placeholder} disabled={disabled} showClear />
|
|
96
|
+
<ComboboxContent>
|
|
97
|
+
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
|
|
98
|
+
<ComboboxList>
|
|
99
|
+
{(record: T) => (
|
|
100
|
+
<ComboboxItem key={record.id} value={record}>
|
|
101
|
+
{renderOption ? renderOption(record) : getOptionLabel(record)}
|
|
102
|
+
</ComboboxItem>
|
|
103
|
+
)}
|
|
104
|
+
</ComboboxList>
|
|
105
|
+
</ComboboxContent>
|
|
106
|
+
</Combobox>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
interface ResourceLayoutProps {
|
|
4
|
+
/** Rendered above the two columns. Typically <PageHeader />. */
|
|
5
|
+
header?: ReactNode
|
|
6
|
+
/** Left column content (8/12 on lg+). */
|
|
7
|
+
main: ReactNode
|
|
8
|
+
/** Right column content (4/12 on lg+). Optional — when omitted, main spans full width. */
|
|
9
|
+
sidebar?: ReactNode
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Standard detail-page scaffold: header above, two columns below (8/4 on lg+,
|
|
14
|
+
* stacked under). Replaces the ad-hoc grid duplicated in product/order detail
|
|
15
|
+
* pages and the legacy `_edit_resource.html.erb` skeleton.
|
|
16
|
+
*/
|
|
17
|
+
export function ResourceLayout({ header, main, sidebar }: ResourceLayoutProps) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="flex flex-col gap-6">
|
|
20
|
+
{header}
|
|
21
|
+
{sidebar ? (
|
|
22
|
+
<div className="grid grid-cols-12 gap-6">
|
|
23
|
+
<div className="col-span-12 lg:col-span-8 flex flex-col gap-6">{main}</div>
|
|
24
|
+
<div className="col-span-12 lg:col-span-4 flex flex-col gap-6">{sidebar}</div>
|
|
25
|
+
</div>
|
|
26
|
+
) : (
|
|
27
|
+
<div className="flex flex-col gap-6">{main}</div>
|
|
28
|
+
)}
|
|
29
|
+
</div>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { useCallback } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import {
|
|
4
|
+
Combobox,
|
|
5
|
+
ComboboxChip,
|
|
6
|
+
ComboboxChips,
|
|
7
|
+
ComboboxChipsInput,
|
|
8
|
+
ComboboxContent,
|
|
9
|
+
ComboboxEmpty,
|
|
10
|
+
ComboboxItem,
|
|
11
|
+
ComboboxList,
|
|
12
|
+
ComboboxValue,
|
|
13
|
+
useComboboxAnchor,
|
|
14
|
+
} from '../ui/combobox'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Anything we can pick. Items must have a stable string `id`. The label
|
|
18
|
+
* (and any other rendering) is up to the caller via `getOptionLabel`.
|
|
19
|
+
*/
|
|
20
|
+
export type AutocompleteOption = { id: string }
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Safely resolve an option's label. The caller's `getOptionLabel` typically
|
|
24
|
+
* reads hydrated fields (e.g. `c.email`), which throw or return blank when
|
|
25
|
+
* called against a stub `{ id }`. Detect that case (single own key, the `id`)
|
|
26
|
+
* and fall back to the raw id so chips for un-hydrated selections stay
|
|
27
|
+
* readable until hydration lands.
|
|
28
|
+
*/
|
|
29
|
+
function labelOrFallback<T extends AutocompleteOption>(
|
|
30
|
+
option: T,
|
|
31
|
+
getOptionLabel: (option: T) => string,
|
|
32
|
+
): string {
|
|
33
|
+
const isStub = Object.keys(option).length === 1 && 'id' in option
|
|
34
|
+
if (isStub) return option.id
|
|
35
|
+
try {
|
|
36
|
+
return getOptionLabel(option) || option.id
|
|
37
|
+
} catch {
|
|
38
|
+
return option.id
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ResourceMultiAutocompleteProps<T extends AutocompleteOption> {
|
|
43
|
+
/** Currently selected IDs (controlled). */
|
|
44
|
+
value: string[]
|
|
45
|
+
onChange: (next: string[]) => void
|
|
46
|
+
/**
|
|
47
|
+
* Optional companion callback fired alongside `onChange` carrying the
|
|
48
|
+
* resolved option records for the selected IDs. Use this to stamp display
|
|
49
|
+
* labels on the parent state without re-fetching. IDs whose record isn't
|
|
50
|
+
* yet hydrated will be absent from this list.
|
|
51
|
+
*/
|
|
52
|
+
onResolvedOptionsChange?: (options: T[]) => void
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Records available to render in the dropdown — typically search results
|
|
56
|
+
* for the current query plus any selected records pulled from a cache.
|
|
57
|
+
* The caller owns fetching, caching, and merging.
|
|
58
|
+
*/
|
|
59
|
+
items: T[]
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Records matching `value` that we already have details for. Used to
|
|
63
|
+
* render chips with labels (and dispatch onResolvedOptionsChange with
|
|
64
|
+
* the right records). IDs missing from `selectedOptions` render as
|
|
65
|
+
* stubs (just the raw ID) until the caller hydrates them.
|
|
66
|
+
*/
|
|
67
|
+
selectedOptions: T[]
|
|
68
|
+
|
|
69
|
+
/** Current text in the search input. */
|
|
70
|
+
inputValue: string
|
|
71
|
+
/** Called when the user types or selects (selection clears the input). */
|
|
72
|
+
onInputChange: (next: string) => void
|
|
73
|
+
|
|
74
|
+
/** Renders the chip and dropdown row label. */
|
|
75
|
+
getOptionLabel: (option: T) => string
|
|
76
|
+
|
|
77
|
+
/** Placeholder for the search input. */
|
|
78
|
+
placeholder?: string
|
|
79
|
+
/** Empty state text shown in the dropdown when items is empty. */
|
|
80
|
+
emptyText?: string
|
|
81
|
+
/** Override emptyText when the consumer's search is in-flight. */
|
|
82
|
+
fetchingText?: string
|
|
83
|
+
/** True while the consumer's data source is loading. */
|
|
84
|
+
isFetching?: boolean
|
|
85
|
+
/** Disable the whole picker. */
|
|
86
|
+
disabled?: boolean
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Headless search-driven multi-select picker. Doesn't fetch anything — the
|
|
91
|
+
* caller passes `items` (visible options) + `selectedOptions` (records
|
|
92
|
+
* matching `value`) + `inputValue`/`onInputChange` for the typed query.
|
|
93
|
+
*
|
|
94
|
+
* `@spree/dashboard-core` ships a convenience `<ResourceMultiAutocomplete>`
|
|
95
|
+
* that wraps this with TanStack Query — that's what most callsites use.
|
|
96
|
+
* Reach for the pure version here when the data flow doesn't match the
|
|
97
|
+
* wrapper's shape.
|
|
98
|
+
*/
|
|
99
|
+
export function ResourceMultiAutocomplete<T extends AutocompleteOption>({
|
|
100
|
+
value,
|
|
101
|
+
onChange,
|
|
102
|
+
onResolvedOptionsChange,
|
|
103
|
+
items,
|
|
104
|
+
selectedOptions,
|
|
105
|
+
inputValue,
|
|
106
|
+
onInputChange,
|
|
107
|
+
getOptionLabel,
|
|
108
|
+
placeholder,
|
|
109
|
+
emptyText,
|
|
110
|
+
fetchingText,
|
|
111
|
+
isFetching,
|
|
112
|
+
disabled,
|
|
113
|
+
}: ResourceMultiAutocompleteProps<T>) {
|
|
114
|
+
const { t } = useTranslation()
|
|
115
|
+
const resolvedPlaceholder = placeholder ?? t('admin.common.search_placeholder')
|
|
116
|
+
const resolvedEmptyText = emptyText ?? t('admin.common.no_results')
|
|
117
|
+
const resolvedFetchingText = fetchingText ?? t('admin.common.searching')
|
|
118
|
+
const anchorRef = useComboboxAnchor()
|
|
119
|
+
|
|
120
|
+
const handleChange = useCallback(
|
|
121
|
+
(next: T[]) => {
|
|
122
|
+
onChange(next.map((o) => o.id))
|
|
123
|
+
// Filter out stub `{id}` placeholders for un-hydrated IDs — callers
|
|
124
|
+
// typically stamp these on parent state and stubs would overwrite
|
|
125
|
+
// earlier real records.
|
|
126
|
+
onResolvedOptionsChange?.(next.filter((o) => Object.keys(o).length > 1))
|
|
127
|
+
onInputChange('')
|
|
128
|
+
},
|
|
129
|
+
[onChange, onResolvedOptionsChange, onInputChange],
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
void value // silence unused (value is consumed via selectedOptions)
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<Combobox
|
|
136
|
+
multiple
|
|
137
|
+
items={items}
|
|
138
|
+
value={selectedOptions}
|
|
139
|
+
onValueChange={handleChange as never}
|
|
140
|
+
itemToStringLabel={(o) => getOptionLabel(o as T)}
|
|
141
|
+
itemToStringValue={(o) => (o as T).id}
|
|
142
|
+
isItemEqualToValue={(a, b) => (a as T).id === (b as T).id}
|
|
143
|
+
// Filtering is server-side: the caller's data source already narrows
|
|
144
|
+
// the items list. Disable Base UI's client filter so it doesn't hide
|
|
145
|
+
// rows whose label doesn't substring-match the typed query (the typed
|
|
146
|
+
// query is often shorter than the actual label — partial email, etc.).
|
|
147
|
+
filter={null}
|
|
148
|
+
disabled={disabled}
|
|
149
|
+
>
|
|
150
|
+
<ComboboxChips ref={anchorRef}>
|
|
151
|
+
<ComboboxValue>
|
|
152
|
+
{(selected: T[]) =>
|
|
153
|
+
selected.map((opt) => (
|
|
154
|
+
// Stubs (un-hydrated IDs) come through with only `{ id }`. Fall
|
|
155
|
+
// back to the raw ID so the chip stays readable instead of going
|
|
156
|
+
// blank while hydration is in flight; `getOptionLabel` can also
|
|
157
|
+
// throw on stubs whose resolver assumes hydrated fields.
|
|
158
|
+
<ComboboxChip key={opt.id}>{labelOrFallback(opt, getOptionLabel)}</ComboboxChip>
|
|
159
|
+
))
|
|
160
|
+
}
|
|
161
|
+
</ComboboxValue>
|
|
162
|
+
<ComboboxChipsInput
|
|
163
|
+
placeholder={resolvedPlaceholder}
|
|
164
|
+
value={inputValue}
|
|
165
|
+
onChange={(e) => onInputChange((e.target as HTMLInputElement).value)}
|
|
166
|
+
/>
|
|
167
|
+
</ComboboxChips>
|
|
168
|
+
<ComboboxContent anchor={anchorRef}>
|
|
169
|
+
<ComboboxEmpty>{isFetching ? resolvedFetchingText : resolvedEmptyText}</ComboboxEmpty>
|
|
170
|
+
<ComboboxList>
|
|
171
|
+
{(option: T) => (
|
|
172
|
+
<ComboboxItem key={option.id} value={option}>
|
|
173
|
+
{getOptionLabel(option)}
|
|
174
|
+
</ComboboxItem>
|
|
175
|
+
)}
|
|
176
|
+
</ComboboxList>
|
|
177
|
+
</ComboboxContent>
|
|
178
|
+
</Combobox>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { cn } from '../lib/utils'
|
|
3
|
+
|
|
4
|
+
interface ResourceNameCellProps {
|
|
5
|
+
/** Resource identifier, mirrored into the bridge data attribute. */
|
|
6
|
+
id: string
|
|
7
|
+
/** Data attribute the `useRowClickBridge` listener targets (e.g. `data-payment-method-id`). */
|
|
8
|
+
dataAttr: `data-${string}`
|
|
9
|
+
/** Primary text — the resource's display name. */
|
|
10
|
+
name: ReactNode
|
|
11
|
+
/** Optional secondary line. Falsy values render nothing. */
|
|
12
|
+
secondary?: ReactNode
|
|
13
|
+
/** Extra classes for the primary `<span>` — useful for `tabular-nums` on ID/number columns. */
|
|
14
|
+
nameClassName?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Standard "name + secondary" cell used as the primary clickable target in
|
|
19
|
+
* resource index tables. The wrapping `<button>` carries the bridge data
|
|
20
|
+
* attribute so `useRowClickBridge` resolves the row → detail navigation.
|
|
21
|
+
*
|
|
22
|
+
* Stays purely presentational — routing is the parent table's job via the
|
|
23
|
+
* bridge hook, not this component's. If you need a custom layout (icon
|
|
24
|
+
* thumbnail, status pill inline with name, etc.), compose it inline rather
|
|
25
|
+
* than overloading this cell with props.
|
|
26
|
+
*/
|
|
27
|
+
export function ResourceNameCell({
|
|
28
|
+
id,
|
|
29
|
+
dataAttr,
|
|
30
|
+
name,
|
|
31
|
+
secondary,
|
|
32
|
+
nameClassName,
|
|
33
|
+
}: ResourceNameCellProps) {
|
|
34
|
+
return (
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
{...{ [dataAttr]: id }}
|
|
38
|
+
className="flex h-full w-full cursor-pointer flex-col items-start rounded text-left focus-visible:outline-none"
|
|
39
|
+
>
|
|
40
|
+
<span className={cn('font-medium', nameClassName)}>{name}</span>
|
|
41
|
+
{secondary && <span className="text-xs text-muted-foreground">{secondary}</span>}
|
|
42
|
+
</button>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { AlertTriangleIcon, RotateCcwIcon } from 'lucide-react'
|
|
2
|
+
import { Component, type ReactNode } from 'react'
|
|
3
|
+
import { useTranslation } from 'react-i18next'
|
|
4
|
+
import { Button } from '../ui/button'
|
|
5
|
+
import {
|
|
6
|
+
Empty,
|
|
7
|
+
EmptyContent,
|
|
8
|
+
EmptyDescription,
|
|
9
|
+
EmptyHeader,
|
|
10
|
+
EmptyMedia,
|
|
11
|
+
EmptyTitle,
|
|
12
|
+
} from '../ui/empty'
|
|
13
|
+
|
|
14
|
+
interface ErrorStateProps {
|
|
15
|
+
/** Headline. Defaults to "Something went wrong". */
|
|
16
|
+
title?: ReactNode
|
|
17
|
+
/** Smaller text below the title. Pass an Error and we'll show its message. */
|
|
18
|
+
description?: ReactNode
|
|
19
|
+
/** Error object — when supplied, its message is shown if `description` is omitted. */
|
|
20
|
+
error?: Error | null
|
|
21
|
+
/** Called by the retry button. When omitted, the button is hidden. */
|
|
22
|
+
onRetry?: () => void
|
|
23
|
+
/** Override the retry label. Default "Try again". */
|
|
24
|
+
retryLabel?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Friendly error display. Use directly when an async operation has failed
|
|
29
|
+
* (replacing the inline `<p className="text-destructive">Failed to load…</p>`
|
|
30
|
+
* scattered through detail pages), or as a route's `errorComponent`.
|
|
31
|
+
*/
|
|
32
|
+
export function ErrorState({ title, description, error, onRetry, retryLabel }: ErrorStateProps) {
|
|
33
|
+
const { t } = useTranslation()
|
|
34
|
+
const resolvedTitle = title ?? t('admin.errors.generic')
|
|
35
|
+
const resolvedRetryLabel = retryLabel ?? t('admin.components.error_state.retry')
|
|
36
|
+
const message = description ?? error?.message ?? t('admin.errors.unexpected_retry')
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Empty>
|
|
40
|
+
<EmptyHeader>
|
|
41
|
+
<EmptyMedia variant="icon">
|
|
42
|
+
<AlertTriangleIcon />
|
|
43
|
+
</EmptyMedia>
|
|
44
|
+
<EmptyTitle>{resolvedTitle}</EmptyTitle>
|
|
45
|
+
<EmptyDescription>{message}</EmptyDescription>
|
|
46
|
+
</EmptyHeader>
|
|
47
|
+
{onRetry && (
|
|
48
|
+
<EmptyContent>
|
|
49
|
+
<Button variant="outline" size="sm" onClick={onRetry}>
|
|
50
|
+
<RotateCcwIcon className="size-4" />
|
|
51
|
+
{resolvedRetryLabel}
|
|
52
|
+
</Button>
|
|
53
|
+
</EmptyContent>
|
|
54
|
+
)}
|
|
55
|
+
</Empty>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface RouteErrorBoundaryProps {
|
|
60
|
+
children: ReactNode
|
|
61
|
+
/** Optional fallback renderer; receives the caught error and a reset function. */
|
|
62
|
+
fallback?: (error: Error, reset: () => void) => ReactNode
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface RouteErrorBoundaryState {
|
|
66
|
+
error: Error | null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Route-level error boundary. Wrap a route component (or any subtree) so a
|
|
71
|
+
* thrown render error shows an `<ErrorState>` instead of crashing the SPA.
|
|
72
|
+
*
|
|
73
|
+
* For TanStack Router data errors (loader/component throws), prefer the
|
|
74
|
+
* route's `errorComponent: ErrorState` — that surface receives `{ error, reset }`
|
|
75
|
+
* directly. This boundary is for cases where a child component throws during
|
|
76
|
+
* render outside the loader path.
|
|
77
|
+
*/
|
|
78
|
+
export class RouteErrorBoundary extends Component<
|
|
79
|
+
RouteErrorBoundaryProps,
|
|
80
|
+
RouteErrorBoundaryState
|
|
81
|
+
> {
|
|
82
|
+
state: RouteErrorBoundaryState = { error: null }
|
|
83
|
+
|
|
84
|
+
static getDerivedStateFromError(error: Error): RouteErrorBoundaryState {
|
|
85
|
+
return { error }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
reset = () => {
|
|
89
|
+
this.setState({ error: null })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
render() {
|
|
93
|
+
const { error } = this.state
|
|
94
|
+
if (!error) return this.props.children
|
|
95
|
+
|
|
96
|
+
if (this.props.fallback) {
|
|
97
|
+
return this.props.fallback(error, this.reset)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return <ErrorState error={error} onRetry={this.reset} />
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { CopyIcon, EllipsisVerticalIcon, EyeIcon, PencilIcon, Trash2Icon } from 'lucide-react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { useTranslation } from 'react-i18next'
|
|
4
|
+
import { Button } from '../ui/button'
|
|
5
|
+
import {
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
DropdownMenuContent,
|
|
8
|
+
DropdownMenuItem,
|
|
9
|
+
DropdownMenuTrigger,
|
|
10
|
+
} from '../ui/dropdown-menu'
|
|
11
|
+
|
|
12
|
+
export interface RowAction {
|
|
13
|
+
/** Stable key for React reconciliation. Doubles as the i18n action slug
|
|
14
|
+
* (`'edit' | 'clone' | 'delete' | …`) when `label` is omitted: the helper
|
|
15
|
+
* looks up `admin.row_actions.<key>` for built-ins, so callers don't have
|
|
16
|
+
* to repeat the translation key. */
|
|
17
|
+
key: string
|
|
18
|
+
/** Override the auto-resolved label for custom (non-built-in) actions. */
|
|
19
|
+
label?: string
|
|
20
|
+
/** Override the default icon — required for actions other than the
|
|
21
|
+
* three built-ins (edit/clone/delete). */
|
|
22
|
+
icon?: ReactNode
|
|
23
|
+
/** Skips rendering when false. Use for CanCanCan gates. */
|
|
24
|
+
visible?: boolean
|
|
25
|
+
/** Greys out the item without removing it (in-flight mutations). */
|
|
26
|
+
disabled?: boolean
|
|
27
|
+
/** Styles the item red — use for destructive actions. */
|
|
28
|
+
destructive?: boolean
|
|
29
|
+
onSelect: () => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface RowActionsProps {
|
|
33
|
+
/** Items in render order. Items with `visible: false` are filtered out. */
|
|
34
|
+
actions: RowAction[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const BUILTIN_ICONS: Record<string, ReactNode> = {
|
|
38
|
+
edit: <PencilIcon className="size-4" />,
|
|
39
|
+
clone: <CopyIcon className="size-4" />,
|
|
40
|
+
delete: <Trash2Icon className="size-4" />,
|
|
41
|
+
view: <EyeIcon className="size-4" />,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Kebab dropdown shared by every `<ResourceTable rowActions={…}>` consumer
|
|
46
|
+
* (products, customers, payment methods, …). Keeps the icon, alignment, and
|
|
47
|
+
* a11y label consistent across the SPA.
|
|
48
|
+
*
|
|
49
|
+
* Built-in keys (`edit`, `clone`, `delete`) auto-resolve their label via
|
|
50
|
+
* `admin.row_actions.<key>` and pick up the matching icon, so the typical
|
|
51
|
+
* call site is:
|
|
52
|
+
*
|
|
53
|
+
* <RowActions actions={[
|
|
54
|
+
* { key: 'edit', onSelect: openEdit },
|
|
55
|
+
* { key: 'delete', destructive: true, onSelect: openConfirm },
|
|
56
|
+
* ]} />
|
|
57
|
+
*/
|
|
58
|
+
export function RowActions({ actions }: RowActionsProps) {
|
|
59
|
+
const { t } = useTranslation()
|
|
60
|
+
const visible = actions.filter((a) => a.visible !== false)
|
|
61
|
+
if (visible.length === 0) return null
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<DropdownMenu>
|
|
65
|
+
<DropdownMenuTrigger asChild>
|
|
66
|
+
<Button variant="ghost" size="icon-xs" aria-label={t('admin.row_actions.menu_label')}>
|
|
67
|
+
<EllipsisVerticalIcon className="size-4" />
|
|
68
|
+
</Button>
|
|
69
|
+
</DropdownMenuTrigger>
|
|
70
|
+
<DropdownMenuContent align="end">
|
|
71
|
+
{visible.map((action) => (
|
|
72
|
+
<DropdownMenuItem
|
|
73
|
+
key={action.key}
|
|
74
|
+
onClick={action.onSelect}
|
|
75
|
+
disabled={action.disabled}
|
|
76
|
+
className={action.destructive ? 'text-destructive focus:text-destructive' : undefined}
|
|
77
|
+
>
|
|
78
|
+
{action.icon ?? BUILTIN_ICONS[action.key]}
|
|
79
|
+
{action.label ?? t(`admin.row_actions.${action.key}`)}
|
|
80
|
+
</DropdownMenuItem>
|
|
81
|
+
))}
|
|
82
|
+
</DropdownMenuContent>
|
|
83
|
+
</DropdownMenu>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
export function useRowClickBridge(dataAttribute: string, onClick: (id: string) => void) {
|
|
4
|
+
useEffect(() => {
|
|
5
|
+
function handler(e: MouseEvent) {
|
|
6
|
+
const target = e.target as HTMLElement | null
|
|
7
|
+
const cell = target?.closest(`[${dataAttribute}]`) as HTMLElement | null
|
|
8
|
+
if (!cell) return
|
|
9
|
+
const id = cell.getAttribute(dataAttribute)
|
|
10
|
+
if (!id) return
|
|
11
|
+
e.preventDefault()
|
|
12
|
+
onClick(id)
|
|
13
|
+
}
|
|
14
|
+
document.addEventListener('click', handler)
|
|
15
|
+
return () => document.removeEventListener('click', handler)
|
|
16
|
+
}, [dataAttribute, onClick])
|
|
17
|
+
}
|