@spree/dashboard-core 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 +418 -0
- package/package.json +84 -0
- package/src/client.ts +25 -0
- package/src/components/address-form-dialog.tsx +376 -0
- package/src/components/app-sidebar.tsx +70 -0
- package/src/components/bulk-action-bar.tsx +504 -0
- package/src/components/can.tsx +30 -0
- package/src/components/country-combobox.tsx +238 -0
- package/src/components/country-state-fields.tsx +86 -0
- package/src/components/currency-select.tsx +181 -0
- package/src/components/export-button.tsx +156 -0
- package/src/components/file-upload-field.tsx +265 -0
- package/src/components/image-upload-field.tsx +53 -0
- package/src/components/import-button.tsx +202 -0
- package/src/components/locale-select.tsx +268 -0
- package/src/components/market-combobox.tsx +89 -0
- package/src/components/nav-main.tsx +186 -0
- package/src/components/page-header.tsx +286 -0
- package/src/components/page-tabs.tsx +78 -0
- package/src/components/preferences-form.tsx +404 -0
- package/src/components/resource-combobox.tsx +126 -0
- package/src/components/resource-multi-autocomplete.tsx +176 -0
- package/src/components/resource-picker-sheet.tsx +235 -0
- package/src/components/resource-table.tsx +652 -0
- package/src/components/settings-sidebar.tsx +139 -0
- package/src/components/slot.tsx +50 -0
- package/src/components/store-date-picker.tsx +18 -0
- package/src/components/store-switcher.tsx +70 -0
- package/src/components/table-toolbar.tsx +818 -0
- package/src/components/tag-combobox.tsx +99 -0
- package/src/components/top-bar.tsx +210 -0
- package/src/hooks/use-auth.ts +10 -0
- package/src/hooks/use-command-palette.tsx +34 -0
- package/src/hooks/use-countries.ts +15 -0
- package/src/hooks/use-custom-fields.ts +142 -0
- package/src/hooks/use-direct-upload.ts +104 -0
- package/src/hooks/use-display-name.ts +42 -0
- package/src/hooks/use-export.ts +75 -0
- package/src/hooks/use-global-search.ts +79 -0
- package/src/hooks/use-host-form.ts +34 -0
- package/src/hooks/use-import.ts +62 -0
- package/src/hooks/use-resource-mutation.ts +99 -0
- package/src/hooks/use-switch-admin-locale.ts +55 -0
- package/src/index.ts +97 -0
- package/src/lib/custom-field-components.ts +56 -0
- package/src/lib/download.ts +49 -0
- package/src/lib/filters-to-ransack.ts +47 -0
- package/src/lib/form-errors.ts +103 -0
- package/src/lib/form-fields-registry.ts +102 -0
- package/src/lib/form-mappers.ts +30 -0
- package/src/lib/formatters.ts +19 -0
- package/src/lib/i18n.ts +210 -0
- package/src/lib/nav-registry.ts +224 -0
- package/src/lib/permissions.ts +55 -0
- package/src/lib/query-client.ts +11 -0
- package/src/lib/query-keys.ts +69 -0
- package/src/lib/route-registry.ts +158 -0
- package/src/lib/search-registry.ts +129 -0
- package/src/lib/settings-nav-registry.ts +158 -0
- package/src/lib/slot-registry.ts +116 -0
- package/src/lib/table-registry.ts +274 -0
- package/src/lib/timestamp-columns.tsx +58 -0
- package/src/locales/ar.json +629 -0
- package/src/locales/de.json +629 -0
- package/src/locales/en.json +629 -0
- package/src/locales/fr.json +629 -0
- package/src/locales/pl.json +629 -0
- package/src/locales/zh-CN.json +629 -0
- package/src/plugin.ts +222 -0
- package/src/providers/auth-provider.tsx +188 -0
- package/src/providers/permission-provider.tsx +118 -0
- package/src/providers/store-provider.tsx +111 -0
- package/src/virtual.d.ts +5 -0
- package/src/vite/discover.ts +170 -0
- package/src/vite/index.ts +270 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
2
|
+
import type { Address } from '@spree/admin-sdk'
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
Checkbox,
|
|
6
|
+
Field,
|
|
7
|
+
FieldError,
|
|
8
|
+
FieldGroup,
|
|
9
|
+
FieldLabel,
|
|
10
|
+
Input,
|
|
11
|
+
requiredMessage,
|
|
12
|
+
Sheet,
|
|
13
|
+
SheetContent,
|
|
14
|
+
SheetDescription,
|
|
15
|
+
SheetFooter,
|
|
16
|
+
SheetHeader,
|
|
17
|
+
SheetTitle,
|
|
18
|
+
} from '@spree/dashboard-ui'
|
|
19
|
+
import { useEffect, useRef } from 'react'
|
|
20
|
+
import { Controller, useForm } from 'react-hook-form'
|
|
21
|
+
import { useTranslation } from 'react-i18next'
|
|
22
|
+
import { z } from 'zod/v4'
|
|
23
|
+
import { mapSpreeErrorsToForm } from '../lib/form-errors'
|
|
24
|
+
import { CountryCombobox } from './country-combobox'
|
|
25
|
+
import { StateCombobox, useCountryStates } from './country-state-fields'
|
|
26
|
+
|
|
27
|
+
export interface AddressParams {
|
|
28
|
+
first_name: string
|
|
29
|
+
last_name: string
|
|
30
|
+
address1: string
|
|
31
|
+
address2: string
|
|
32
|
+
city: string
|
|
33
|
+
postal_code: string
|
|
34
|
+
country_iso: string
|
|
35
|
+
state_abbr: string
|
|
36
|
+
phone: string
|
|
37
|
+
label?: string
|
|
38
|
+
is_default_billing?: boolean
|
|
39
|
+
is_default_shipping?: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const addressFormSchema = z.object({
|
|
43
|
+
first_name: z
|
|
44
|
+
.string()
|
|
45
|
+
.trim()
|
|
46
|
+
.min(1, { error: requiredMessage('first_name') }),
|
|
47
|
+
last_name: z
|
|
48
|
+
.string()
|
|
49
|
+
.trim()
|
|
50
|
+
.min(1, { error: requiredMessage('last_name') }),
|
|
51
|
+
address1: z
|
|
52
|
+
.string()
|
|
53
|
+
.trim()
|
|
54
|
+
.min(1, { error: requiredMessage('address.address1') }),
|
|
55
|
+
address2: z.string(),
|
|
56
|
+
city: z
|
|
57
|
+
.string()
|
|
58
|
+
.trim()
|
|
59
|
+
.min(1, { error: requiredMessage('city') }),
|
|
60
|
+
postal_code: z
|
|
61
|
+
.string()
|
|
62
|
+
.trim()
|
|
63
|
+
.min(1, { error: requiredMessage('address.postal_code') }),
|
|
64
|
+
country_iso: z
|
|
65
|
+
.string()
|
|
66
|
+
.trim()
|
|
67
|
+
.min(1, { error: requiredMessage('country_iso') }),
|
|
68
|
+
state_abbr: z.string(),
|
|
69
|
+
phone: z.string(),
|
|
70
|
+
label: z.string(),
|
|
71
|
+
is_default_billing: z.boolean(),
|
|
72
|
+
is_default_shipping: z.boolean(),
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
type AddressFormValues = z.infer<typeof addressFormSchema>
|
|
76
|
+
|
|
77
|
+
function buildDefaults(address: Address | null | undefined): AddressFormValues {
|
|
78
|
+
return {
|
|
79
|
+
first_name: address?.first_name ?? '',
|
|
80
|
+
last_name: address?.last_name ?? '',
|
|
81
|
+
address1: address?.address1 ?? '',
|
|
82
|
+
address2: address?.address2 ?? '',
|
|
83
|
+
city: address?.city ?? '',
|
|
84
|
+
postal_code: address?.postal_code ?? '',
|
|
85
|
+
country_iso: address?.country_iso ?? '',
|
|
86
|
+
state_abbr: address?.state_abbr ?? '',
|
|
87
|
+
phone: address?.phone ?? '',
|
|
88
|
+
label: address?.label ?? '',
|
|
89
|
+
is_default_billing: address?.is_default_billing ?? false,
|
|
90
|
+
is_default_shipping: address?.is_default_shipping ?? false,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function AddressFormDialog({
|
|
95
|
+
address,
|
|
96
|
+
open,
|
|
97
|
+
onOpenChange,
|
|
98
|
+
onSave,
|
|
99
|
+
title,
|
|
100
|
+
isPending = false,
|
|
101
|
+
showLabel = false,
|
|
102
|
+
showDefaultFlags = false,
|
|
103
|
+
}: {
|
|
104
|
+
address: Address | null | undefined
|
|
105
|
+
open: boolean
|
|
106
|
+
onOpenChange: (open: boolean) => void
|
|
107
|
+
onSave: (address: AddressParams) => void | Promise<void>
|
|
108
|
+
title?: string
|
|
109
|
+
isPending?: boolean
|
|
110
|
+
showLabel?: boolean
|
|
111
|
+
showDefaultFlags?: boolean
|
|
112
|
+
}) {
|
|
113
|
+
const { t } = useTranslation()
|
|
114
|
+
const resolvedTitle = title ?? t('admin.components.address_form_dialog.edit_title')
|
|
115
|
+
const form = useForm<AddressFormValues>({
|
|
116
|
+
defaultValues: buildDefaults(address),
|
|
117
|
+
resolver: zodResolver(addressFormSchema),
|
|
118
|
+
})
|
|
119
|
+
const { errors } = form.formState
|
|
120
|
+
|
|
121
|
+
// The parent keys the dialog on the address id so a fresh instance mounts
|
|
122
|
+
// for each open, but `address` can also stream in async (loaded after the
|
|
123
|
+
// sheet mounts). Reset only when the *record identity* changes — otherwise a
|
|
124
|
+
// re-render that creates a new `address` object literal would clobber edits
|
|
125
|
+
// mid-flow.
|
|
126
|
+
const prevAddressIdRef = useRef<string | null | undefined>(address?.id)
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (address?.id !== prevAddressIdRef.current) {
|
|
129
|
+
prevAddressIdRef.current = address?.id
|
|
130
|
+
form.reset(buildDefaults(address))
|
|
131
|
+
}
|
|
132
|
+
}, [address, form])
|
|
133
|
+
|
|
134
|
+
const countryIso = form.watch('country_iso')
|
|
135
|
+
const { states, statesRequired } = useCountryStates(countryIso)
|
|
136
|
+
const useStateCombobox = statesRequired && states.length > 0
|
|
137
|
+
|
|
138
|
+
async function onSubmit(values: AddressFormValues) {
|
|
139
|
+
try {
|
|
140
|
+
await onSave({
|
|
141
|
+
first_name: values.first_name,
|
|
142
|
+
last_name: values.last_name,
|
|
143
|
+
address1: values.address1,
|
|
144
|
+
address2: values.address2,
|
|
145
|
+
city: values.city,
|
|
146
|
+
postal_code: values.postal_code,
|
|
147
|
+
country_iso: values.country_iso,
|
|
148
|
+
state_abbr: values.state_abbr,
|
|
149
|
+
phone: values.phone,
|
|
150
|
+
...(showLabel && { label: values.label || undefined }),
|
|
151
|
+
...(showDefaultFlags && {
|
|
152
|
+
is_default_billing: values.is_default_billing,
|
|
153
|
+
is_default_shipping: values.is_default_shipping,
|
|
154
|
+
}),
|
|
155
|
+
})
|
|
156
|
+
} catch (err) {
|
|
157
|
+
// Surface server-side 422 validation errors on the matching fields so
|
|
158
|
+
// the dialog reflects whatever the API rejected (e.g. "phone is too
|
|
159
|
+
// short"). Non-validation errors bubble to the parent's toast.
|
|
160
|
+
if (!mapSpreeErrorsToForm(err, form.setError)) throw err
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Prevent Enter in combobox inputs from submitting the form. RHF's
|
|
165
|
+
// handleSubmit fires on submit events, so swallow Enter inside any
|
|
166
|
+
// role=combobox before it bubbles into the form.
|
|
167
|
+
function handleKeyDown(e: React.KeyboardEvent) {
|
|
168
|
+
const target = e.target as HTMLElement
|
|
169
|
+
if (e.key === 'Enter' && target.getAttribute('role') === 'combobox') {
|
|
170
|
+
e.preventDefault()
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<Sheet open={open} onOpenChange={(o) => onOpenChange(o as boolean)}>
|
|
176
|
+
<SheetContent side="right">
|
|
177
|
+
<SheetHeader>
|
|
178
|
+
<SheetTitle>{resolvedTitle}</SheetTitle>
|
|
179
|
+
<SheetDescription>
|
|
180
|
+
{t('admin.components.address_form_dialog.description')}
|
|
181
|
+
</SheetDescription>
|
|
182
|
+
</SheetHeader>
|
|
183
|
+
<form
|
|
184
|
+
onSubmit={form.handleSubmit(onSubmit)}
|
|
185
|
+
onKeyDown={handleKeyDown}
|
|
186
|
+
className="flex flex-col flex-1 overflow-hidden"
|
|
187
|
+
>
|
|
188
|
+
<div className="flex-1 overflow-y-auto p-4">
|
|
189
|
+
<FieldGroup>
|
|
190
|
+
{showLabel && (
|
|
191
|
+
<Field>
|
|
192
|
+
<FieldLabel htmlFor="addr-label">
|
|
193
|
+
{t('admin.fields.address.label.label')}
|
|
194
|
+
</FieldLabel>
|
|
195
|
+
<Input
|
|
196
|
+
id="addr-label"
|
|
197
|
+
placeholder={t('admin.fields.address.label.placeholder')}
|
|
198
|
+
{...form.register('label')}
|
|
199
|
+
/>
|
|
200
|
+
</Field>
|
|
201
|
+
)}
|
|
202
|
+
<div className="grid grid-cols-2 gap-3">
|
|
203
|
+
<Field>
|
|
204
|
+
<FieldLabel htmlFor="addr-fn">{t('admin.fields.first_name.label')}</FieldLabel>
|
|
205
|
+
<Input
|
|
206
|
+
id="addr-fn"
|
|
207
|
+
aria-invalid={!!errors.first_name || undefined}
|
|
208
|
+
{...form.register('first_name')}
|
|
209
|
+
/>
|
|
210
|
+
<FieldError errors={[errors.first_name]} />
|
|
211
|
+
</Field>
|
|
212
|
+
<Field>
|
|
213
|
+
<FieldLabel htmlFor="addr-ln">{t('admin.fields.last_name.label')}</FieldLabel>
|
|
214
|
+
<Input
|
|
215
|
+
id="addr-ln"
|
|
216
|
+
aria-invalid={!!errors.last_name || undefined}
|
|
217
|
+
{...form.register('last_name')}
|
|
218
|
+
/>
|
|
219
|
+
<FieldError errors={[errors.last_name]} />
|
|
220
|
+
</Field>
|
|
221
|
+
</div>
|
|
222
|
+
<Field>
|
|
223
|
+
<FieldLabel htmlFor="addr-a1">
|
|
224
|
+
{t('admin.fields.address.address1.label')}
|
|
225
|
+
</FieldLabel>
|
|
226
|
+
<Input
|
|
227
|
+
id="addr-a1"
|
|
228
|
+
aria-invalid={!!errors.address1 || undefined}
|
|
229
|
+
{...form.register('address1')}
|
|
230
|
+
/>
|
|
231
|
+
<FieldError errors={[errors.address1]} />
|
|
232
|
+
</Field>
|
|
233
|
+
<Field>
|
|
234
|
+
<FieldLabel htmlFor="addr-a2">
|
|
235
|
+
{t('admin.fields.address.address2.label')}
|
|
236
|
+
</FieldLabel>
|
|
237
|
+
<Input
|
|
238
|
+
id="addr-a2"
|
|
239
|
+
aria-invalid={!!errors.address2 || undefined}
|
|
240
|
+
{...form.register('address2')}
|
|
241
|
+
/>
|
|
242
|
+
<FieldError errors={[errors.address2]} />
|
|
243
|
+
</Field>
|
|
244
|
+
<Field>
|
|
245
|
+
<FieldLabel>{t('admin.fields.country_iso.label')}</FieldLabel>
|
|
246
|
+
<Controller
|
|
247
|
+
name="country_iso"
|
|
248
|
+
control={form.control}
|
|
249
|
+
render={({ field }) => (
|
|
250
|
+
<CountryCombobox
|
|
251
|
+
value={field.value}
|
|
252
|
+
onValueChange={(iso) => {
|
|
253
|
+
field.onChange(iso)
|
|
254
|
+
form.setValue('state_abbr', '', { shouldDirty: true })
|
|
255
|
+
}}
|
|
256
|
+
/>
|
|
257
|
+
)}
|
|
258
|
+
/>
|
|
259
|
+
<FieldError errors={[errors.country_iso]} />
|
|
260
|
+
</Field>
|
|
261
|
+
<div className="grid grid-cols-2 gap-3">
|
|
262
|
+
<Field>
|
|
263
|
+
<FieldLabel htmlFor="addr-city">{t('admin.fields.city.label')}</FieldLabel>
|
|
264
|
+
<Input
|
|
265
|
+
id="addr-city"
|
|
266
|
+
aria-invalid={!!errors.city || undefined}
|
|
267
|
+
{...form.register('city')}
|
|
268
|
+
/>
|
|
269
|
+
<FieldError errors={[errors.city]} />
|
|
270
|
+
</Field>
|
|
271
|
+
{useStateCombobox ? (
|
|
272
|
+
<Field>
|
|
273
|
+
<FieldLabel>{t('admin.fields.state_abbr.label')}</FieldLabel>
|
|
274
|
+
<Controller
|
|
275
|
+
name="state_abbr"
|
|
276
|
+
control={form.control}
|
|
277
|
+
render={({ field }) => (
|
|
278
|
+
<StateCombobox
|
|
279
|
+
countryIso={countryIso}
|
|
280
|
+
states={states}
|
|
281
|
+
value={field.value}
|
|
282
|
+
onValueChange={field.onChange}
|
|
283
|
+
/>
|
|
284
|
+
)}
|
|
285
|
+
/>
|
|
286
|
+
<FieldError errors={[errors.state_abbr]} />
|
|
287
|
+
</Field>
|
|
288
|
+
) : (
|
|
289
|
+
<Field>
|
|
290
|
+
<FieldLabel htmlFor="addr-state-abbr">
|
|
291
|
+
{t('admin.fields.state_abbr.label')}
|
|
292
|
+
</FieldLabel>
|
|
293
|
+
<Input
|
|
294
|
+
id="addr-state-abbr"
|
|
295
|
+
aria-invalid={!!errors.state_abbr || undefined}
|
|
296
|
+
{...form.register('state_abbr')}
|
|
297
|
+
/>
|
|
298
|
+
<FieldError errors={[errors.state_abbr]} />
|
|
299
|
+
</Field>
|
|
300
|
+
)}
|
|
301
|
+
</div>
|
|
302
|
+
<Field>
|
|
303
|
+
<FieldLabel htmlFor="addr-zip">
|
|
304
|
+
{t('admin.fields.address.postal_code.label')}
|
|
305
|
+
</FieldLabel>
|
|
306
|
+
<Input
|
|
307
|
+
id="addr-zip"
|
|
308
|
+
aria-invalid={!!errors.postal_code || undefined}
|
|
309
|
+
{...form.register('postal_code')}
|
|
310
|
+
/>
|
|
311
|
+
<FieldError errors={[errors.postal_code]} />
|
|
312
|
+
</Field>
|
|
313
|
+
<Field>
|
|
314
|
+
<FieldLabel htmlFor="addr-phone">{t('admin.fields.phone.label')}</FieldLabel>
|
|
315
|
+
<Input
|
|
316
|
+
id="addr-phone"
|
|
317
|
+
aria-invalid={!!errors.phone || undefined}
|
|
318
|
+
{...form.register('phone')}
|
|
319
|
+
/>
|
|
320
|
+
<FieldError errors={[errors.phone]} />
|
|
321
|
+
</Field>
|
|
322
|
+
{showDefaultFlags && (
|
|
323
|
+
<>
|
|
324
|
+
<Field>
|
|
325
|
+
<div className="flex items-start justify-between gap-4">
|
|
326
|
+
<FieldLabel htmlFor="addr-default-billing" className="cursor-pointer">
|
|
327
|
+
{t('admin.fields.address.is_default_billing.label')}
|
|
328
|
+
</FieldLabel>
|
|
329
|
+
<Controller
|
|
330
|
+
name="is_default_billing"
|
|
331
|
+
control={form.control}
|
|
332
|
+
render={({ field }) => (
|
|
333
|
+
<Checkbox
|
|
334
|
+
id="addr-default-billing"
|
|
335
|
+
checked={!!field.value}
|
|
336
|
+
onCheckedChange={field.onChange}
|
|
337
|
+
/>
|
|
338
|
+
)}
|
|
339
|
+
/>
|
|
340
|
+
</div>
|
|
341
|
+
</Field>
|
|
342
|
+
<Field>
|
|
343
|
+
<div className="flex items-start justify-between gap-4">
|
|
344
|
+
<FieldLabel htmlFor="addr-default-shipping" className="cursor-pointer">
|
|
345
|
+
{t('admin.fields.address.is_default_shipping.label')}
|
|
346
|
+
</FieldLabel>
|
|
347
|
+
<Controller
|
|
348
|
+
name="is_default_shipping"
|
|
349
|
+
control={form.control}
|
|
350
|
+
render={({ field }) => (
|
|
351
|
+
<Checkbox
|
|
352
|
+
id="addr-default-shipping"
|
|
353
|
+
checked={!!field.value}
|
|
354
|
+
onCheckedChange={field.onChange}
|
|
355
|
+
/>
|
|
356
|
+
)}
|
|
357
|
+
/>
|
|
358
|
+
</div>
|
|
359
|
+
</Field>
|
|
360
|
+
</>
|
|
361
|
+
)}
|
|
362
|
+
</FieldGroup>
|
|
363
|
+
</div>
|
|
364
|
+
<SheetFooter>
|
|
365
|
+
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
366
|
+
{t('admin.actions.cancel')}
|
|
367
|
+
</Button>
|
|
368
|
+
<Button type="submit" disabled={isPending}>
|
|
369
|
+
{isPending ? t('admin.actions.saving') : t('admin.actions.save')}
|
|
370
|
+
</Button>
|
|
371
|
+
</SheetFooter>
|
|
372
|
+
</form>
|
|
373
|
+
</SheetContent>
|
|
374
|
+
</Sheet>
|
|
375
|
+
)
|
|
376
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Sidebar, SidebarContent, SidebarHeader } from '@spree/dashboard-ui'
|
|
2
|
+
import { useParams } from '@tanstack/react-router'
|
|
3
|
+
import { PackageIcon } from 'lucide-react'
|
|
4
|
+
import type { ComponentProps } from 'react'
|
|
5
|
+
import { useAuth } from '../hooks/use-auth'
|
|
6
|
+
import { primarySidebarSide, useTranslation } from '../lib/i18n'
|
|
7
|
+
import { type NavEntry, useNavEntries } from '../lib/nav-registry'
|
|
8
|
+
import { type Permissions, usePermissions } from '../providers/permission-provider'
|
|
9
|
+
import { useStore } from '../providers/store-provider'
|
|
10
|
+
import { type NavItem, NavMain } from './nav-main'
|
|
11
|
+
import { StoreSwitcher } from './store-switcher'
|
|
12
|
+
|
|
13
|
+
function entryToNavItem(entry: NavEntry, storeId: string): NavItem {
|
|
14
|
+
const pathFor = (path: string) => (path === '/' ? `/${storeId}` : `/${storeId}${path}`)
|
|
15
|
+
return {
|
|
16
|
+
title: entry.label,
|
|
17
|
+
url: pathFor(entry.path),
|
|
18
|
+
icon: entry.icon ?? PackageIcon,
|
|
19
|
+
subject: entry.subject,
|
|
20
|
+
badge: entry.badge,
|
|
21
|
+
items: entry.children?.map((child) => ({
|
|
22
|
+
title: child.label,
|
|
23
|
+
url: pathFor(child.path),
|
|
24
|
+
subject: child.subject,
|
|
25
|
+
})),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Hide items the user can't `read`. */
|
|
30
|
+
function filterByPermissions(items: NavItem[], permissions: Permissions): NavItem[] {
|
|
31
|
+
return items
|
|
32
|
+
.filter((item) => !item.subject || permissions.can('read', item.subject))
|
|
33
|
+
.map((item) => ({
|
|
34
|
+
...item,
|
|
35
|
+
items: item.items?.filter((sub) => !sub.subject || permissions.can('read', sub.subject)),
|
|
36
|
+
}))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function AppSidebar(props: ComponentProps<typeof Sidebar>) {
|
|
40
|
+
const { i18n } = useTranslation()
|
|
41
|
+
const { storeId } = useParams({ strict: false }) as { storeId?: string }
|
|
42
|
+
const { permissions } = usePermissions()
|
|
43
|
+
const { store } = useStore()
|
|
44
|
+
const { user } = useAuth()
|
|
45
|
+
const id = storeId || 'default'
|
|
46
|
+
const { main, bottom } = useNavEntries()
|
|
47
|
+
|
|
48
|
+
const visibilityContext = { permissions, store, user }
|
|
49
|
+
const visible = (entry: NavEntry) => !entry.if || entry.if(visibilityContext)
|
|
50
|
+
|
|
51
|
+
const navItems = filterByPermissions(
|
|
52
|
+
main.filter(visible).map((e) => entryToNavItem(e, id)),
|
|
53
|
+
permissions,
|
|
54
|
+
)
|
|
55
|
+
const bottomItems = filterByPermissions(
|
|
56
|
+
bottom.filter(visible).map((e) => entryToNavItem(e, id)),
|
|
57
|
+
permissions,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Sidebar collapsible="icon" side={primarySidebarSide(i18n.language)} {...props}>
|
|
62
|
+
<SidebarHeader>
|
|
63
|
+
<StoreSwitcher />
|
|
64
|
+
</SidebarHeader>
|
|
65
|
+
<SidebarContent>
|
|
66
|
+
<NavMain items={navItems} bottomItems={bottomItems} />
|
|
67
|
+
</SidebarContent>
|
|
68
|
+
</Sidebar>
|
|
69
|
+
)
|
|
70
|
+
}
|