@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,45 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
import { useDataGridContext } from './context'
|
|
3
|
+
import type { CellCoords } from './types'
|
|
4
|
+
|
|
5
|
+
interface CellHandlers {
|
|
6
|
+
focus: () => void
|
|
7
|
+
read: () => string
|
|
8
|
+
write: (value: string) => void
|
|
9
|
+
canWrite: (value: string) => boolean
|
|
10
|
+
getElement?: () => HTMLElement | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Registers a cell with the data grid exactly once per `(row, col)` slot,
|
|
15
|
+
* forwarding read/write/focus through a ref so per-render closure changes
|
|
16
|
+
* don't re-trigger registration. Without this, every Controller-driven
|
|
17
|
+
* value change would unregister + re-register the cell, which (a) churns
|
|
18
|
+
* the bounds calculation and (b) lands us in setState-during-render loops
|
|
19
|
+
* when the registration effect tries to update grid state.
|
|
20
|
+
*
|
|
21
|
+
* Also reads `registerCell` from the context via a ref so the effect's deps
|
|
22
|
+
* stay limited to coords — the context object itself changes identity on
|
|
23
|
+
* every selection-state update, but `registerCell` is stable beneath it.
|
|
24
|
+
*/
|
|
25
|
+
export function useStableCellRegistration(coords: CellCoords, handlers: CellHandlers) {
|
|
26
|
+
const ctx = useDataGridContext()
|
|
27
|
+
const handlersRef = useRef(handlers)
|
|
28
|
+
handlersRef.current = handlers
|
|
29
|
+
const registerRef = useRef(ctx.registerCell)
|
|
30
|
+
registerRef.current = ctx.registerCell
|
|
31
|
+
// Destructure to primitives so the effect can list them as deps without
|
|
32
|
+
// pulling in the unstable `coords` object reference from the caller.
|
|
33
|
+
const { row, col } = coords
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
return registerRef.current({
|
|
37
|
+
coords: { row, col },
|
|
38
|
+
focus: () => handlersRef.current.focus(),
|
|
39
|
+
read: () => handlersRef.current.read(),
|
|
40
|
+
write: (v) => handlersRef.current.write(v),
|
|
41
|
+
canWrite: (v) => handlersRef.current.canWrite(v),
|
|
42
|
+
getElement: () => handlersRef.current.getElement?.() ?? null,
|
|
43
|
+
})
|
|
44
|
+
}, [row, col])
|
|
45
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { DraggableAttributes, DraggableSyntheticListeners } from '@dnd-kit/core'
|
|
2
|
+
import { GripVerticalIcon } from 'lucide-react'
|
|
3
|
+
import type { ComponentProps } from 'react'
|
|
4
|
+
import { useTranslation } from 'react-i18next'
|
|
5
|
+
import { cn } from '../lib/utils'
|
|
6
|
+
|
|
7
|
+
interface DragHandleProps extends Omit<ComponentProps<'button'>, 'type'> {
|
|
8
|
+
/** `attributes` from `useSortable` / `useDraggable`. */
|
|
9
|
+
attributes: DraggableAttributes
|
|
10
|
+
/** `listeners` from `useSortable` / `useDraggable`. */
|
|
11
|
+
listeners: DraggableSyntheticListeners
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Standard drag handle for `useSortable` rows. Pair with a `useSortable` row
|
|
16
|
+
* by spreading its `attributes` + `listeners` onto this component:
|
|
17
|
+
*
|
|
18
|
+
* ```tsx
|
|
19
|
+
* const { attributes, listeners, setNodeRef, ... } = useSortable({ id })
|
|
20
|
+
* return (
|
|
21
|
+
* <tr ref={setNodeRef}>
|
|
22
|
+
* <td className="w-8 touch-none p-0">
|
|
23
|
+
* <DragHandle attributes={attributes} listeners={listeners} />
|
|
24
|
+
* </td>
|
|
25
|
+
* ...
|
|
26
|
+
* </tr>
|
|
27
|
+
* )
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* The handle owns its own cursor styling (`cursor-grab` / `active:cursor-grabbing`),
|
|
31
|
+
* hover affordance, and accessible label — the caller only needs to size the
|
|
32
|
+
* surrounding cell (typically `w-8 touch-none p-0`).
|
|
33
|
+
*/
|
|
34
|
+
export function DragHandle({ attributes, listeners, className, ...props }: DragHandleProps) {
|
|
35
|
+
const { t } = useTranslation()
|
|
36
|
+
return (
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
aria-label={t('admin.a11y.drag_to_reorder')}
|
|
40
|
+
className={cn(
|
|
41
|
+
'flex h-full w-full items-center justify-center rounded-lg p-2 text-muted-foreground cursor-grab active:cursor-grabbing hover:bg-accent',
|
|
42
|
+
className,
|
|
43
|
+
)}
|
|
44
|
+
{...attributes}
|
|
45
|
+
{...listeners}
|
|
46
|
+
{...props}
|
|
47
|
+
>
|
|
48
|
+
<GripVerticalIcon className="size-4" />
|
|
49
|
+
</button>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useHotkey } from '@tanstack/react-hotkeys'
|
|
2
|
+
import { Loader2Icon } from 'lucide-react'
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
import { type FieldValues, type UseFormReturn, useFormState } from 'react-hook-form'
|
|
5
|
+
import { useTranslation } from 'react-i18next'
|
|
6
|
+
import { Button } from '../ui/button'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Submit button wired to a `react-hook-form` instance. Always rendered,
|
|
10
|
+
* disabled until the form is dirty. Replaces the legacy "footer pops up
|
|
11
|
+
* when something changes" pattern with an always-visible header.
|
|
12
|
+
*
|
|
13
|
+
* Reads `isDirty` and `isSubmitting` from the RHF form state via
|
|
14
|
+
* `useFormState`, so the button re-renders independently of the parent page
|
|
15
|
+
* and won't churn on every keystroke.
|
|
16
|
+
*
|
|
17
|
+
* Uses `<button type="submit">` and relies on the surrounding `<form>` to
|
|
18
|
+
* dispatch submission — same flow as the legacy sticky footer. ⌘S keyboard
|
|
19
|
+
* submission is handled separately by `useFormSubmitShortcut`.
|
|
20
|
+
*/
|
|
21
|
+
export function FormSaveButton<TFieldValues extends FieldValues>({
|
|
22
|
+
form,
|
|
23
|
+
label,
|
|
24
|
+
savingLabel,
|
|
25
|
+
}: {
|
|
26
|
+
form: UseFormReturn<TFieldValues, any, any>
|
|
27
|
+
label?: string
|
|
28
|
+
savingLabel?: string
|
|
29
|
+
}) {
|
|
30
|
+
const { t } = useTranslation()
|
|
31
|
+
const { isDirty, isSubmitting } = useFormState({ control: form.control })
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<Button type="submit" size="sm" disabled={!isDirty || isSubmitting}>
|
|
35
|
+
{isSubmitting && <Loader2Icon className="animate-spin" />}
|
|
36
|
+
{isSubmitting
|
|
37
|
+
? (savingLabel ?? t('admin.actions.saving'))
|
|
38
|
+
: (label ?? t('admin.actions.save'))}
|
|
39
|
+
</Button>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Discard button wired to a `react-hook-form` instance. Renders only when
|
|
45
|
+
* the form is dirty (so the header stays uncluttered for read-only pages).
|
|
46
|
+
*
|
|
47
|
+
* Calls `form.reset()` by default — pass `onDiscard` to override (e.g., to
|
|
48
|
+
* reset to a freshly-fetched server value rather than the original mount
|
|
49
|
+
* defaults).
|
|
50
|
+
*/
|
|
51
|
+
export function FormDiscardButton<TFieldValues extends FieldValues>({
|
|
52
|
+
form,
|
|
53
|
+
label,
|
|
54
|
+
onDiscard,
|
|
55
|
+
}: {
|
|
56
|
+
form: UseFormReturn<TFieldValues, any, any>
|
|
57
|
+
label?: string
|
|
58
|
+
onDiscard?: () => void
|
|
59
|
+
}) {
|
|
60
|
+
const { t } = useTranslation()
|
|
61
|
+
const { isDirty, isSubmitting } = useFormState({ control: form.control })
|
|
62
|
+
|
|
63
|
+
if (!isDirty) return null
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<Button
|
|
67
|
+
type="button"
|
|
68
|
+
size="sm"
|
|
69
|
+
variant="outline"
|
|
70
|
+
disabled={isSubmitting}
|
|
71
|
+
onClick={onDiscard ?? (() => form.reset())}
|
|
72
|
+
>
|
|
73
|
+
{label ?? t('admin.actions.discard')}
|
|
74
|
+
</Button>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Convenience: render Discard + Save side-by-side. Most pages just want both.
|
|
80
|
+
*/
|
|
81
|
+
export function FormActions<TFieldValues extends FieldValues>({
|
|
82
|
+
form,
|
|
83
|
+
saveLabel,
|
|
84
|
+
savingLabel,
|
|
85
|
+
discardLabel,
|
|
86
|
+
onDiscard,
|
|
87
|
+
}: {
|
|
88
|
+
form: UseFormReturn<TFieldValues, any, any>
|
|
89
|
+
saveLabel?: string
|
|
90
|
+
savingLabel?: string
|
|
91
|
+
discardLabel?: string
|
|
92
|
+
onDiscard?: () => void
|
|
93
|
+
}) {
|
|
94
|
+
return (
|
|
95
|
+
<>
|
|
96
|
+
<FormDiscardButton form={form} label={discardLabel} onDiscard={onDiscard} />
|
|
97
|
+
<FormSaveButton form={form} label={saveLabel} savingLabel={savingLabel} />
|
|
98
|
+
</>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Bind ⌘S / Ctrl+S to submit a `react-hook-form` instance. Mount once per
|
|
104
|
+
* form. Skips when the form is pristine or already submitting — same gate
|
|
105
|
+
* as the visual Save button. Schema validation still runs inside
|
|
106
|
+
* `form.handleSubmit`, so invalid input rejects on its own without us
|
|
107
|
+
* needing to read `isValid` (which is unreliable under the default
|
|
108
|
+
* `onSubmit` validation mode).
|
|
109
|
+
*
|
|
110
|
+
* Also wires `beforeunload` to warn if the user closes the tab or navigates
|
|
111
|
+
* away while the form is dirty. (TanStack Router internal navigation is not
|
|
112
|
+
* intercepted — the SPA's own dirty-route guard would belong elsewhere.)
|
|
113
|
+
*/
|
|
114
|
+
export function useFormSubmitShortcut<TFieldValues extends FieldValues>(
|
|
115
|
+
form: UseFormReturn<TFieldValues, any, any>,
|
|
116
|
+
onSubmit: (values: TFieldValues) => void | Promise<void>,
|
|
117
|
+
) {
|
|
118
|
+
const { isDirty, isSubmitting } = useFormState({ control: form.control })
|
|
119
|
+
|
|
120
|
+
useHotkey(
|
|
121
|
+
'Mod+S',
|
|
122
|
+
() => {
|
|
123
|
+
if (!isDirty || isSubmitting) return
|
|
124
|
+
void form.handleSubmit(onSubmit)()
|
|
125
|
+
},
|
|
126
|
+
// Mod+S targets a form-level action, so it should fire even while focus
|
|
127
|
+
// is inside an input. Default for Mod-combos already does this; pinned
|
|
128
|
+
// here to be explicit and survive future library default changes.
|
|
129
|
+
{ ignoreInputs: false },
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (!isDirty) return
|
|
134
|
+
const handler = (event: BeforeUnloadEvent) => {
|
|
135
|
+
event.preventDefault()
|
|
136
|
+
}
|
|
137
|
+
window.addEventListener('beforeunload', handler)
|
|
138
|
+
return () => window.removeEventListener('beforeunload', handler)
|
|
139
|
+
}, [isDirty])
|
|
140
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
cn,
|
|
4
|
+
Sheet,
|
|
5
|
+
SheetContent,
|
|
6
|
+
SheetDescription,
|
|
7
|
+
SheetHeader,
|
|
8
|
+
SheetTitle,
|
|
9
|
+
useCopyToClipboard,
|
|
10
|
+
} from '@spree/dashboard-ui'
|
|
11
|
+
import {
|
|
12
|
+
CheckIcon,
|
|
13
|
+
ChevronDownIcon,
|
|
14
|
+
ChevronRightIcon,
|
|
15
|
+
CopyIcon,
|
|
16
|
+
ExternalLinkIcon,
|
|
17
|
+
Loader2Icon,
|
|
18
|
+
RotateCcwIcon,
|
|
19
|
+
XIcon,
|
|
20
|
+
} from 'lucide-react'
|
|
21
|
+
import type { ComponentProps } from 'react'
|
|
22
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
23
|
+
import { useTranslation } from 'react-i18next'
|
|
24
|
+
import { type JsonValueLinkResolver, JsonValueView } from './json-value-view'
|
|
25
|
+
|
|
26
|
+
// Re-export so existing imports of `JsonValueLinkResolver` from this module
|
|
27
|
+
// keep working without touching call sites.
|
|
28
|
+
export type { JsonValueLinkResolver } from './json-value-view'
|
|
29
|
+
|
|
30
|
+
export interface JsonPreviewDrawerProps {
|
|
31
|
+
open: boolean
|
|
32
|
+
onOpenChange: (open: boolean) => void
|
|
33
|
+
/** Display title shown in the drawer header. Default "JSON". */
|
|
34
|
+
title?: string
|
|
35
|
+
/**
|
|
36
|
+
* Fetcher that returns the resource JSON. Called every time the drawer
|
|
37
|
+
* opens (so it always shows the latest server state — it's a debug tool)
|
|
38
|
+
* and every time the user clicks the refresh button. The drawer manages
|
|
39
|
+
* its own loading/error state internally; no react-query is involved.
|
|
40
|
+
*/
|
|
41
|
+
fetch: () => Promise<unknown>
|
|
42
|
+
/**
|
|
43
|
+
* Resource path the JSON came from, e.g. `'/api/v3/admin/orders/or_xyz'`.
|
|
44
|
+
* Shown in the toolbar and used as the "Open raw" target.
|
|
45
|
+
*/
|
|
46
|
+
endpoint?: string
|
|
47
|
+
/**
|
|
48
|
+
* Turn string values (typically prefixed IDs) into clickable links to
|
|
49
|
+
* other admin pages. Omit to render every string as inert text — the
|
|
50
|
+
* default for plugin authors who haven't wired up their own ID→route
|
|
51
|
+
* convention.
|
|
52
|
+
*/
|
|
53
|
+
resolveLink?: JsonValueLinkResolver
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function JsonPreviewDrawer({
|
|
57
|
+
open,
|
|
58
|
+
onOpenChange,
|
|
59
|
+
title,
|
|
60
|
+
fetch,
|
|
61
|
+
endpoint,
|
|
62
|
+
resolveLink,
|
|
63
|
+
}: JsonPreviewDrawerProps) {
|
|
64
|
+
const { t } = useTranslation()
|
|
65
|
+
const resolvedTitle = title ?? t('admin.components.json_preview_drawer.title')
|
|
66
|
+
const [data, setData] = useState<unknown>(undefined)
|
|
67
|
+
const [error, setError] = useState<Error | null>(null)
|
|
68
|
+
const [isFetching, setIsFetching] = useState(false)
|
|
69
|
+
// True only on the first load of an opened session — `isFetching` covers
|
|
70
|
+
// subsequent refetches and animates the spinner button without blanking
|
|
71
|
+
// the previously rendered JSON.
|
|
72
|
+
const isLoading = isFetching && data === undefined && error === null
|
|
73
|
+
|
|
74
|
+
// Refetch on every open + when the resource being inspected changes + on
|
|
75
|
+
// manual refresh. The drawer is a debug tool, so staleness would be worse
|
|
76
|
+
// than the extra request. `endpoint` stands in for "which resource is this
|
|
77
|
+
// drawer pointing at" since it's a stable per-resource string; `fetch`
|
|
78
|
+
// intentionally isn't in the deps because a fresh inline-arrow identity
|
|
79
|
+
// every render would otherwise loop.
|
|
80
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: see above
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (!open) return
|
|
83
|
+
let cancelled = false
|
|
84
|
+
setIsFetching(true)
|
|
85
|
+
setError(null)
|
|
86
|
+
fetch()
|
|
87
|
+
.then((result) => {
|
|
88
|
+
if (cancelled) return
|
|
89
|
+
setData(result)
|
|
90
|
+
})
|
|
91
|
+
.catch((err) => {
|
|
92
|
+
if (cancelled) return
|
|
93
|
+
setError(err instanceof Error ? err : new Error(String(err)))
|
|
94
|
+
})
|
|
95
|
+
.finally(() => {
|
|
96
|
+
if (cancelled) return
|
|
97
|
+
setIsFetching(false)
|
|
98
|
+
})
|
|
99
|
+
return () => {
|
|
100
|
+
cancelled = true
|
|
101
|
+
}
|
|
102
|
+
}, [open, endpoint])
|
|
103
|
+
|
|
104
|
+
const refetch = () => {
|
|
105
|
+
setIsFetching(true)
|
|
106
|
+
setError(null)
|
|
107
|
+
fetch()
|
|
108
|
+
.then(setData)
|
|
109
|
+
.catch((err) => setError(err instanceof Error ? err : new Error(String(err))))
|
|
110
|
+
.finally(() => setIsFetching(false))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const [collapsed, setCollapsed] = useState<boolean | number>(2)
|
|
114
|
+
const { copied: copiedAll, copy } = useCopyToClipboard()
|
|
115
|
+
|
|
116
|
+
const formatted = useMemo(() => (data ? JSON.stringify(data, null, 2) : ''), [data])
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
120
|
+
<SheetContent
|
|
121
|
+
side="right"
|
|
122
|
+
showCloseButton={false}
|
|
123
|
+
className="w-full sm:max-w-2xl bg-zinc-950 text-zinc-100 border-zinc-800 overflow-hidden"
|
|
124
|
+
>
|
|
125
|
+
<SheetHeader className="border-b border-zinc-800 bg-zinc-950 text-zinc-100 rounded-t-xl">
|
|
126
|
+
<SheetTitle className="text-zinc-100 font-mono text-base">{resolvedTitle}</SheetTitle>
|
|
127
|
+
{endpoint && (
|
|
128
|
+
<SheetDescription className="text-zinc-400 font-mono text-xs">
|
|
129
|
+
GET {endpoint}
|
|
130
|
+
</SheetDescription>
|
|
131
|
+
)}
|
|
132
|
+
<ToolbarButton
|
|
133
|
+
size="icon-xs"
|
|
134
|
+
className="absolute top-3 right-3"
|
|
135
|
+
onClick={() => onOpenChange(false)}
|
|
136
|
+
aria-label={t('admin.actions.close')}
|
|
137
|
+
>
|
|
138
|
+
<XIcon className="size-4" />
|
|
139
|
+
</ToolbarButton>
|
|
140
|
+
</SheetHeader>
|
|
141
|
+
|
|
142
|
+
<div className="flex items-center gap-1 border-b border-zinc-800 px-3 py-2 text-xs">
|
|
143
|
+
<ToolbarButton
|
|
144
|
+
size="icon-xs"
|
|
145
|
+
onClick={() => setCollapsed(false)}
|
|
146
|
+
aria-label={t('admin.a11y.expand_all')}
|
|
147
|
+
title={t('admin.a11y.expand_all')}
|
|
148
|
+
>
|
|
149
|
+
<ChevronDownIcon className="size-4" />
|
|
150
|
+
</ToolbarButton>
|
|
151
|
+
<ToolbarButton
|
|
152
|
+
size="icon-xs"
|
|
153
|
+
onClick={() => setCollapsed(true)}
|
|
154
|
+
aria-label={t('admin.a11y.collapse_all')}
|
|
155
|
+
title={t('admin.a11y.collapse_all')}
|
|
156
|
+
>
|
|
157
|
+
<ChevronRightIcon className="size-4" />
|
|
158
|
+
</ToolbarButton>
|
|
159
|
+
<DepthControl value={collapsed} onChange={setCollapsed} />
|
|
160
|
+
|
|
161
|
+
<span className="ml-auto inline-flex items-center gap-1">
|
|
162
|
+
<ToolbarButton size="xs" onClick={() => formatted && copy(formatted)} disabled={!data}>
|
|
163
|
+
{copiedAll ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
|
|
164
|
+
{copiedAll
|
|
165
|
+
? t('admin.components.json_preview_drawer.copied')
|
|
166
|
+
: t('admin.components.json_preview_drawer.copy_all')}
|
|
167
|
+
</ToolbarButton>
|
|
168
|
+
<ToolbarButton
|
|
169
|
+
size="icon-xs"
|
|
170
|
+
onClick={() => refetch()}
|
|
171
|
+
aria-label={t('admin.a11y.refetch')}
|
|
172
|
+
title={t('admin.a11y.refetch')}
|
|
173
|
+
disabled={isFetching}
|
|
174
|
+
>
|
|
175
|
+
{isFetching ? (
|
|
176
|
+
<Loader2Icon className="size-3.5 animate-spin" />
|
|
177
|
+
) : (
|
|
178
|
+
<RotateCcwIcon className="size-3.5" />
|
|
179
|
+
)}
|
|
180
|
+
</ToolbarButton>
|
|
181
|
+
{endpoint && (
|
|
182
|
+
<a
|
|
183
|
+
href={endpoint}
|
|
184
|
+
target="_blank"
|
|
185
|
+
rel="noreferrer noopener"
|
|
186
|
+
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100 transition-colors"
|
|
187
|
+
title={t('admin.components.json_preview_drawer.open_raw')}
|
|
188
|
+
>
|
|
189
|
+
<ExternalLinkIcon className="size-3.5" />
|
|
190
|
+
{t('admin.components.json_preview_drawer.raw')}
|
|
191
|
+
</a>
|
|
192
|
+
)}
|
|
193
|
+
</span>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
<div className="themed-scrollbar flex-1 overflow-auto p-4 font-mono text-sm">
|
|
197
|
+
{isLoading ? (
|
|
198
|
+
<p className="text-zinc-500">{t('admin.common.loading')}</p>
|
|
199
|
+
) : error ? (
|
|
200
|
+
<p className="text-red-400">
|
|
201
|
+
{t('admin.components.json_preview_drawer.failed_to_load', { message: error.message })}
|
|
202
|
+
</p>
|
|
203
|
+
) : data !== undefined ? (
|
|
204
|
+
// The drawer paints its own dark surface around the renderer
|
|
205
|
+
// (Sheet content + toolbar), so the view itself runs in `bare`
|
|
206
|
+
// mode — no extra padding/background.
|
|
207
|
+
<JsonValueView value={data} collapsed={collapsed} resolveLink={resolveLink} bare />
|
|
208
|
+
) : null}
|
|
209
|
+
</div>
|
|
210
|
+
</SheetContent>
|
|
211
|
+
</Sheet>
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function ToolbarButton({ className, ...props }: ComponentProps<typeof Button>) {
|
|
216
|
+
return (
|
|
217
|
+
<Button
|
|
218
|
+
type="button"
|
|
219
|
+
variant="ghost"
|
|
220
|
+
className={cn('text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100', className)}
|
|
221
|
+
{...props}
|
|
222
|
+
/>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function DepthControl({
|
|
227
|
+
value,
|
|
228
|
+
onChange,
|
|
229
|
+
}: {
|
|
230
|
+
value: boolean | number
|
|
231
|
+
onChange: (next: boolean | number) => void
|
|
232
|
+
}) {
|
|
233
|
+
const { t } = useTranslation()
|
|
234
|
+
const current = typeof value === 'number' ? `${value}` : value === true ? 'all' : 'open'
|
|
235
|
+
return (
|
|
236
|
+
<select
|
|
237
|
+
value={current}
|
|
238
|
+
onChange={(e) => {
|
|
239
|
+
const v = e.target.value
|
|
240
|
+
if (v === 'all') onChange(true)
|
|
241
|
+
else if (v === 'open') onChange(false)
|
|
242
|
+
else onChange(Number(v))
|
|
243
|
+
}}
|
|
244
|
+
className="ml-1 bg-zinc-900 border border-zinc-700 rounded px-1.5 py-1 text-xs text-zinc-300 hover:bg-zinc-800 focus:outline-none focus:ring-1 focus:ring-zinc-600"
|
|
245
|
+
title={t('admin.components.json_preview_drawer.collapse_depth')}
|
|
246
|
+
>
|
|
247
|
+
<option value="open">{t('admin.a11y.expand_all')}</option>
|
|
248
|
+
<option value="1">{t('admin.components.json_preview_drawer.depth', { level: 1 })}</option>
|
|
249
|
+
<option value="2">{t('admin.components.json_preview_drawer.depth', { level: 2 })}</option>
|
|
250
|
+
<option value="3">{t('admin.components.json_preview_drawer.depth', { level: 3 })}</option>
|
|
251
|
+
<option value="all">{t('admin.a11y.collapse_all')}</option>
|
|
252
|
+
</select>
|
|
253
|
+
)
|
|
254
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { Link } from '@tanstack/react-router'
|
|
2
|
+
import JsonView from '@uiw/react-json-view'
|
|
3
|
+
import { vscodeTheme } from '@uiw/react-json-view/vscode'
|
|
4
|
+
import { cn } from '../lib/utils'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Maps a string value (typically a prefixed ID like `or_abc123`) to an
|
|
8
|
+
* in-app route. Returned `to` is a `<Link to>` template; `params` is the
|
|
9
|
+
* positional-param map passed straight through. Return `null` for values
|
|
10
|
+
* that aren't linkable.
|
|
11
|
+
*
|
|
12
|
+
* No built-in prefix conventions ship with this component —
|
|
13
|
+
* `@spree/dashboard` provides Spree's `or`→orders / `prod`→products /
|
|
14
|
+
* `cus`→customers mapping at the call site so dashboard-ui stays
|
|
15
|
+
* Spree-vocabulary-free.
|
|
16
|
+
*/
|
|
17
|
+
export type JsonValueLinkResolver = (
|
|
18
|
+
value: string,
|
|
19
|
+
) => { to: string; params: Record<string, string> } | null
|
|
20
|
+
|
|
21
|
+
const VALUE_TYPE_STYLES: Record<string, { color: string; format: (v: unknown) => string }> = {
|
|
22
|
+
string: { color: 'text-emerald-300', format: (v) => `"${v}"` },
|
|
23
|
+
number: { color: 'text-amber-300', format: String },
|
|
24
|
+
bigint: { color: 'text-amber-300', format: String },
|
|
25
|
+
boolean: { color: 'text-purple-300', format: String },
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface JsonValueViewProps {
|
|
29
|
+
/** JSON-serializable data to render. */
|
|
30
|
+
value: unknown
|
|
31
|
+
/**
|
|
32
|
+
* Collapse depth. `false` expands everything, `true` collapses everything,
|
|
33
|
+
* a `number` collapses below that depth. Default `2`.
|
|
34
|
+
*/
|
|
35
|
+
collapsed?: boolean | number
|
|
36
|
+
/** Resolve prefixed-ID strings to in-app links. Omit for plain strings. */
|
|
37
|
+
resolveLink?: JsonValueLinkResolver
|
|
38
|
+
/** Class applied to the outer wrapper. The view always uses a dark theme so
|
|
39
|
+
* give it a dark surface (e.g. `bg-zinc-950`) when embedding into a light
|
|
40
|
+
* page; otherwise the syntax colours don't have enough contrast. */
|
|
41
|
+
className?: string
|
|
42
|
+
/** When true, renders no padding/background — useful when the parent already
|
|
43
|
+
* owns the surface chrome (e.g. inside a `<Card>` with its own padding). */
|
|
44
|
+
bare?: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Read-only JSON tree viewer with optional ID-to-link resolution. Built on
|
|
49
|
+
* `@uiw/react-json-view` with a vscode-style theme. Pulls in ~30KB gzip of
|
|
50
|
+
* dependencies, so consumers should lazy-load this component via
|
|
51
|
+
* `React.lazy(() => import('@spree/dashboard-ui/spree/json-value-view'))`
|
|
52
|
+
* if it isn't on the critical path.
|
|
53
|
+
*
|
|
54
|
+
* @example Inline preview inside a Card
|
|
55
|
+
* <Card>
|
|
56
|
+
* <CardContent className="bg-zinc-950">
|
|
57
|
+
* <JsonValueView value={delivery.payload} bare />
|
|
58
|
+
* </CardContent>
|
|
59
|
+
* </Card>
|
|
60
|
+
*
|
|
61
|
+
* @example Linked prefixed IDs
|
|
62
|
+
* <JsonValueView value={order} resolveLink={resolveSpreePrefixToRoute} />
|
|
63
|
+
*/
|
|
64
|
+
export function JsonValueView({
|
|
65
|
+
value,
|
|
66
|
+
collapsed = 2,
|
|
67
|
+
resolveLink,
|
|
68
|
+
className,
|
|
69
|
+
bare = false,
|
|
70
|
+
}: JsonValueViewProps) {
|
|
71
|
+
return (
|
|
72
|
+
<div
|
|
73
|
+
className={cn(
|
|
74
|
+
'font-mono text-sm',
|
|
75
|
+
// The vscode theme is dark — when the parent doesn't already paint a
|
|
76
|
+
// dark background, paint one here so the syntax colors stay legible.
|
|
77
|
+
!bare && 'bg-zinc-950 text-zinc-100 rounded-md p-3',
|
|
78
|
+
className,
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
<JsonView
|
|
82
|
+
value={value as object}
|
|
83
|
+
style={{ ...vscodeTheme, '--w-rjv-background-color': 'transparent' } as React.CSSProperties}
|
|
84
|
+
collapsed={collapsed}
|
|
85
|
+
displayDataTypes={false}
|
|
86
|
+
displayObjectSize
|
|
87
|
+
enableClipboard
|
|
88
|
+
shortenTextAfterLength={60}
|
|
89
|
+
components={{
|
|
90
|
+
value: ({ value: v, type, ...rest }) => (
|
|
91
|
+
<ValueRenderer value={v} type={type} resolveLink={resolveLink} {...rest} />
|
|
92
|
+
),
|
|
93
|
+
}}
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface ValueRendererProps {
|
|
100
|
+
value: unknown
|
|
101
|
+
type: string
|
|
102
|
+
resolveLink?: JsonValueLinkResolver
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function ValueRenderer({
|
|
106
|
+
value,
|
|
107
|
+
type,
|
|
108
|
+
resolveLink,
|
|
109
|
+
...rest
|
|
110
|
+
}: ValueRendererProps & React.HTMLAttributes<HTMLSpanElement>) {
|
|
111
|
+
if (type === 'string' && typeof value === 'string' && resolveLink) {
|
|
112
|
+
const resolved = resolveLink(value)
|
|
113
|
+
if (resolved) {
|
|
114
|
+
return (
|
|
115
|
+
<span {...rest} className="text-emerald-300">
|
|
116
|
+
"
|
|
117
|
+
<Link
|
|
118
|
+
to={resolved.to}
|
|
119
|
+
params={resolved.params}
|
|
120
|
+
className="text-emerald-300 underline decoration-emerald-700 hover:decoration-emerald-300 transition-colors"
|
|
121
|
+
onClick={(e) => e.stopPropagation()}
|
|
122
|
+
>
|
|
123
|
+
{value}
|
|
124
|
+
</Link>
|
|
125
|
+
"
|
|
126
|
+
</span>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return <JsonValueSpan value={value} type={type} {...rest} />
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function JsonValueSpan({
|
|
134
|
+
value,
|
|
135
|
+
type,
|
|
136
|
+
...rest
|
|
137
|
+
}: {
|
|
138
|
+
value: unknown
|
|
139
|
+
type: string
|
|
140
|
+
} & React.HTMLAttributes<HTMLSpanElement>) {
|
|
141
|
+
const style = value === null ? null : VALUE_TYPE_STYLES[type]
|
|
142
|
+
const color = value === null ? 'text-zinc-500' : (style?.color ?? 'text-zinc-300')
|
|
143
|
+
const display = value === null ? 'null' : style ? style.format(value) : String(value)
|
|
144
|
+
return (
|
|
145
|
+
<span {...rest} className={color}>
|
|
146
|
+
{display}
|
|
147
|
+
</span>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { CheckIcon, LanguagesIcon } from 'lucide-react'
|
|
2
|
+
import {
|
|
3
|
+
DropdownMenuItem,
|
|
4
|
+
DropdownMenuSub,
|
|
5
|
+
DropdownMenuSubContent,
|
|
6
|
+
DropdownMenuSubTrigger,
|
|
7
|
+
} from '../ui/dropdown-menu'
|
|
8
|
+
|
|
9
|
+
interface LanguageMenuItemsProps {
|
|
10
|
+
/** Translated "Language" label for the submenu trigger (passed by the app). */
|
|
11
|
+
label: string
|
|
12
|
+
/** Admin UI languages available to switch to ({ code, name } pairs). */
|
|
13
|
+
locales: ReadonlyArray<{ code: string; name: string }>
|
|
14
|
+
/** Currently active language code. */
|
|
15
|
+
value: string
|
|
16
|
+
onSelect: (code: string) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Admin UI language picker rendered inside the user dropdown menu as a nested
|
|
21
|
+
* submenu (a single "Language ▸" row that expands to the list), so the menu
|
|
22
|
+
* stays compact no matter how many languages are installed.
|
|
23
|
+
*
|
|
24
|
+
* Headless: the label, locale list, and change handler are passed in, so this
|
|
25
|
+
* component imports no i18n/data runtime. Renders nothing when fewer than two
|
|
26
|
+
* languages are installed: there's nothing to choose.
|
|
27
|
+
*/
|
|
28
|
+
export function LanguageMenuItems({ label, locales, value, onSelect }: LanguageMenuItemsProps) {
|
|
29
|
+
if (locales.length < 2) return null
|
|
30
|
+
|
|
31
|
+
const current = locales.find((l) => l.code === value)
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<DropdownMenuSub>
|
|
35
|
+
<DropdownMenuSubTrigger>
|
|
36
|
+
<LanguagesIcon className="size-4" />
|
|
37
|
+
{label}
|
|
38
|
+
{current && <span className="ml-auto text-xs text-muted-foreground">{current.name}</span>}
|
|
39
|
+
</DropdownMenuSubTrigger>
|
|
40
|
+
<DropdownMenuSubContent>
|
|
41
|
+
{locales.map((locale) => (
|
|
42
|
+
<DropdownMenuItem
|
|
43
|
+
key={locale.code}
|
|
44
|
+
closeOnClick={false}
|
|
45
|
+
onClick={() => onSelect(locale.code)}
|
|
46
|
+
>
|
|
47
|
+
{locale.name}
|
|
48
|
+
{value === locale.code && <CheckIcon className="ml-auto size-3.5" />}
|
|
49
|
+
</DropdownMenuItem>
|
|
50
|
+
))}
|
|
51
|
+
</DropdownMenuSubContent>
|
|
52
|
+
</DropdownMenuSub>
|
|
53
|
+
)
|
|
54
|
+
}
|