@vendure/dashboard 3.4.3-master-202509260228 → 3.5.0-minor-202509261210

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.
Files changed (74) hide show
  1. package/dist/plugin/api/api-extensions.js +11 -14
  2. package/dist/plugin/api/metrics.resolver.d.ts +2 -2
  3. package/dist/plugin/api/metrics.resolver.js +2 -2
  4. package/dist/plugin/config/metrics-strategies.d.ts +9 -9
  5. package/dist/plugin/config/metrics-strategies.js +6 -6
  6. package/dist/plugin/constants.d.ts +2 -0
  7. package/dist/plugin/constants.js +3 -1
  8. package/dist/plugin/dashboard.plugin.js +13 -0
  9. package/dist/plugin/service/metrics.service.d.ts +3 -3
  10. package/dist/plugin/service/metrics.service.js +37 -53
  11. package/dist/plugin/types.d.ts +9 -12
  12. package/dist/plugin/types.js +7 -11
  13. package/dist/vite/vite-plugin-vendure-dashboard.js +2 -2
  14. package/package.json +4 -4
  15. package/src/app/routes/_authenticated/_collections/collections.tsx +7 -2
  16. package/src/app/routes/_authenticated/_collections/collections_.$id.tsx +15 -2
  17. package/src/app/routes/_authenticated/_facets/facets_.$id.tsx +14 -2
  18. package/src/app/routes/_authenticated/_product-variants/product-variants.graphql.ts +10 -0
  19. package/src/app/routes/_authenticated/_products/components/product-option-group-badge.tsx +19 -0
  20. package/src/app/routes/_authenticated/_products/components/product-options-table.tsx +111 -0
  21. package/src/app/routes/_authenticated/_products/product-option-groups.graphql.ts +103 -0
  22. package/src/app/routes/_authenticated/_products/products.graphql.ts +13 -1
  23. package/src/app/routes/_authenticated/_products/products.tsx +27 -3
  24. package/src/app/routes/_authenticated/_products/products_.$id.tsx +26 -9
  25. package/src/app/routes/_authenticated/_products/products_.$productId.option-groups.$id.tsx +181 -0
  26. package/src/app/routes/_authenticated/_products/products_.$productId.option-groups.$productOptionGroupId.options_.$id.tsx +208 -0
  27. package/src/app/routes/_authenticated/_zones/components/zone-countries-sheet.tsx +4 -1
  28. package/src/app/routes/_authenticated/index.tsx +41 -24
  29. package/src/lib/components/data-display/json.tsx +16 -1
  30. package/src/lib/components/data-input/index.ts +3 -0
  31. package/src/lib/components/data-input/slug-input.tsx +296 -0
  32. package/src/lib/components/data-table/add-filter-menu.tsx +13 -6
  33. package/src/lib/components/data-table/data-table-bulk-action-item.tsx +38 -1
  34. package/src/lib/components/data-table/data-table-context.tsx +91 -0
  35. package/src/lib/components/data-table/data-table-filter-badge.tsx +9 -5
  36. package/src/lib/components/data-table/data-table-view-options.tsx +17 -8
  37. package/src/lib/components/data-table/data-table.tsx +146 -94
  38. package/src/lib/components/data-table/global-views-bar.tsx +97 -0
  39. package/src/lib/components/data-table/global-views-sheet.tsx +11 -0
  40. package/src/lib/components/data-table/manage-global-views-button.tsx +26 -0
  41. package/src/lib/components/data-table/my-views-button.tsx +47 -0
  42. package/src/lib/components/data-table/refresh-button.tsx +12 -3
  43. package/src/lib/components/data-table/save-view-button.tsx +45 -0
  44. package/src/lib/components/data-table/save-view-dialog.tsx +113 -0
  45. package/src/lib/components/data-table/use-generated-columns.tsx +3 -1
  46. package/src/lib/components/data-table/user-views-sheet.tsx +11 -0
  47. package/src/lib/components/data-table/views-sheet.tsx +297 -0
  48. package/src/lib/components/date-range-picker.tsx +184 -0
  49. package/src/lib/components/shared/paginated-list-data-table.tsx +59 -32
  50. package/src/lib/components/ui/button.tsx +1 -1
  51. package/src/lib/framework/dashboard-widget/latest-orders-widget/index.tsx +29 -2
  52. package/src/lib/framework/dashboard-widget/metrics-widget/index.tsx +10 -7
  53. package/src/lib/framework/dashboard-widget/metrics-widget/metrics-widget.graphql.ts +9 -3
  54. package/src/lib/framework/dashboard-widget/orders-summary/index.tsx +19 -75
  55. package/src/lib/framework/dashboard-widget/widget-filters-context.tsx +33 -0
  56. package/src/lib/framework/document-introspection/add-custom-fields.spec.ts +319 -9
  57. package/src/lib/framework/document-introspection/add-custom-fields.ts +60 -31
  58. package/src/lib/framework/document-introspection/get-document-structure.spec.ts +1 -159
  59. package/src/lib/framework/document-introspection/include-only-selected-list-fields.spec.ts +1840 -0
  60. package/src/lib/framework/document-introspection/include-only-selected-list-fields.ts +940 -0
  61. package/src/lib/framework/document-introspection/testing-utils.ts +161 -0
  62. package/src/lib/framework/extension-api/display-component-extensions.tsx +2 -0
  63. package/src/lib/framework/extension-api/types/data-table.ts +62 -4
  64. package/src/lib/framework/extension-api/types/navigation.ts +16 -0
  65. package/src/lib/framework/form-engine/utils.ts +34 -0
  66. package/src/lib/framework/page/list-page.tsx +289 -4
  67. package/src/lib/framework/page/use-extended-router.tsx +59 -17
  68. package/src/lib/graphql/api.ts +4 -2
  69. package/src/lib/graphql/graphql-env.d.ts +13 -10
  70. package/src/lib/hooks/use-extended-list-query.ts +5 -0
  71. package/src/lib/hooks/use-saved-views.ts +230 -0
  72. package/src/lib/index.ts +15 -0
  73. package/src/lib/types/saved-views.ts +39 -0
  74. package/src/lib/utils/saved-views-utils.ts +40 -0
@@ -0,0 +1,208 @@
1
+ import { SlugInput } from '@/vdb/components/data-input/index.js';
2
+ import { ErrorPage } from '@/vdb/components/shared/error-page.js';
3
+ import { FormFieldWrapper } from '@/vdb/components/shared/form-field-wrapper.js';
4
+ import { PermissionGuard } from '@/vdb/components/shared/permission-guard.js';
5
+ import { TranslatableFormFieldWrapper } from '@/vdb/components/shared/translatable-form-field.js';
6
+ import { Button } from '@/vdb/components/ui/button.js';
7
+ import { Input } from '@/vdb/components/ui/input.js';
8
+ import { NEW_ENTITY_PATH } from '@/vdb/constants.js';
9
+ import { extendDetailFormQuery } from '@/vdb/framework/document-extension/extend-detail-form-query.js';
10
+ import { addCustomFields } from '@/vdb/framework/document-introspection/add-custom-fields.js';
11
+ import {
12
+ CustomFieldsPageBlock,
13
+ DetailFormGrid,
14
+ Page,
15
+ PageActionBar,
16
+ PageActionBarRight,
17
+ PageBlock,
18
+ PageLayout,
19
+ PageTitle,
20
+ } from '@/vdb/framework/layout-engine/page-layout.js';
21
+ import { getDetailQueryOptions, useDetailPage } from '@/vdb/framework/page/use-detail-page.js';
22
+ import { api } from '@/vdb/graphql/api.js';
23
+ import { Trans, useLingui } from '@/vdb/lib/trans.js';
24
+ import { createFileRoute, ParsedLocation, useNavigate } from '@tanstack/react-router';
25
+ import { toast } from 'sonner';
26
+ import {
27
+ createProductOptionDocument,
28
+ productIdNameDocument,
29
+ productOptionDetailDocument,
30
+ productOptionGroupIdNameDocument,
31
+ updateProductOptionDocument,
32
+ } from './product-option-groups.graphql.js';
33
+
34
+ const pageId = 'product-option-detail';
35
+
36
+ export const Route = createFileRoute(
37
+ '/_authenticated/_products/products_/$productId/option-groups/$productOptionGroupId/options_/$id',
38
+ )({
39
+ component: ProductOptionDetailPage,
40
+ loader: async ({ context, params }: { context: any; params: any; location: ParsedLocation }) => {
41
+ if (!params.id) {
42
+ throw new Error('ID param is required');
43
+ }
44
+
45
+ const isNew = params.id === NEW_ENTITY_PATH;
46
+ let optionGroupName: string | undefined;
47
+ let optionGroupId = params.productOptionGroupId;
48
+ const { extendedQuery: extendedQueryDocument } = extendDetailFormQuery(
49
+ addCustomFields(productOptionDetailDocument),
50
+ pageId,
51
+ );
52
+
53
+ const result = isNew
54
+ ? null
55
+ : await context.queryClient.ensureQueryData(
56
+ getDetailQueryOptions(extendedQueryDocument, { id: params.id }),
57
+ );
58
+ const productResult = await context.queryClient.fetchQuery({
59
+ queryKey: [pageId, 'productIdName', params.productId],
60
+ queryFn: () => api.query(productIdNameDocument, { id: params.productId }),
61
+ });
62
+ const entityName = 'ProductOption';
63
+
64
+ if (!result?.productOption && !isNew) {
65
+ throw new Error(`${entityName} with the ID ${params.id} was not found`);
66
+ }
67
+ if (isNew) {
68
+ const optionGroupResult = await context.queryClient.fetchQuery({
69
+ queryKey: [pageId, 'optionGroupIdName', optionGroupId],
70
+ queryFn: () => api.query(productOptionGroupIdNameDocument, { id: optionGroupId }),
71
+ });
72
+ optionGroupName = optionGroupResult.productOptionGroup?.name;
73
+ } else {
74
+ optionGroupName = result.productOption.group.name;
75
+ }
76
+ const productId = params.productId;
77
+ return {
78
+ breadcrumb: [
79
+ { path: '/products', label: <Trans>Products</Trans> },
80
+ { path: `/products/${productId}`, label: productResult.product.name },
81
+ { path: `/products/${productId}`, label: <Trans>Option Groups</Trans> },
82
+ {
83
+ path: `/products/${productId}/option-groups/${optionGroupId}`,
84
+ label: optionGroupName,
85
+ },
86
+ isNew ? <Trans>New option</Trans> : result.productOption?.name,
87
+ ],
88
+ };
89
+ },
90
+ errorComponent: ({ error }) => <ErrorPage message={error.message} />,
91
+ });
92
+
93
+ function ProductOptionDetailPage() {
94
+ const params = Route.useParams();
95
+ const navigate = useNavigate();
96
+ const creatingNewEntity = params.id === NEW_ENTITY_PATH;
97
+ const { i18n } = useLingui();
98
+
99
+ const { form, submitHandler, entity, isPending, resetForm } = useDetailPage({
100
+ pageId,
101
+ queryDocument: productOptionDetailDocument,
102
+ createDocument: createProductOptionDocument,
103
+ updateDocument: updateProductOptionDocument,
104
+ setValuesForUpdate: entity => {
105
+ return {
106
+ id: entity.id,
107
+ code: entity.code,
108
+ name: entity.name,
109
+ translations: entity.translations.map(translation => ({
110
+ id: translation.id,
111
+ languageCode: translation.languageCode,
112
+ name: translation.name,
113
+ customFields: (translation as any).customFields,
114
+ })),
115
+ customFields: entity.customFields as any,
116
+ };
117
+ },
118
+ transformCreateInput: (value): any => {
119
+ return {
120
+ ...value,
121
+ productOptionGroupId: params.productOptionGroupId,
122
+ };
123
+ },
124
+ params: { id: params.id },
125
+ onSuccess: async data => {
126
+ toast(
127
+ i18n.t(
128
+ creatingNewEntity
129
+ ? 'Successfully created product option'
130
+ : 'Successfully updated product option',
131
+ ),
132
+ );
133
+ resetForm();
134
+ const created = Array.isArray(data) ? data[0] : data;
135
+ if (creatingNewEntity && created) {
136
+ await navigate({ to: `../$id`, params: { id: (created as any).id } });
137
+ }
138
+ },
139
+ onError: err => {
140
+ toast(
141
+ i18n.t(
142
+ creatingNewEntity ? 'Failed to create product option' : 'Failed to update product option',
143
+ ),
144
+ {
145
+ description: err instanceof Error ? err.message : 'Unknown error',
146
+ },
147
+ );
148
+ },
149
+ });
150
+
151
+ return (
152
+ <Page pageId={pageId} form={form} submitHandler={submitHandler} entity={entity}>
153
+ <PageTitle>
154
+ {creatingNewEntity ? <Trans>New product option</Trans> : ((entity as any)?.name ?? '')}
155
+ </PageTitle>
156
+ <PageActionBar>
157
+ <PageActionBarRight>
158
+ <PermissionGuard requires={['UpdateProduct', 'UpdateCatalog']}>
159
+ <Button
160
+ type="submit"
161
+ disabled={!form.formState.isDirty || !form.formState.isValid || isPending}
162
+ >
163
+ {creatingNewEntity ? <Trans>Create</Trans> : <Trans>Update</Trans>}
164
+ </Button>
165
+ </PermissionGuard>
166
+ </PageActionBarRight>
167
+ </PageActionBar>
168
+ <PageLayout>
169
+ <PageBlock column="side" blockId="option-group-info">
170
+ {entity?.group && (
171
+ <div className="space-y-2">
172
+ <div className="text-sm font-medium">
173
+ <Trans>Product Option Group</Trans>
174
+ </div>
175
+ <div className="text-sm text-muted-foreground">{entity?.group.name}</div>
176
+ <div className="text-xs text-muted-foreground">{entity?.group.code}</div>
177
+ </div>
178
+ )}
179
+ </PageBlock>
180
+ <PageBlock column="main" blockId="main-form">
181
+ <DetailFormGrid>
182
+ <TranslatableFormFieldWrapper
183
+ control={form.control}
184
+ name="name"
185
+ label={<Trans>Name</Trans>}
186
+ render={({ field }) => <Input {...field} />}
187
+ />
188
+ <FormFieldWrapper
189
+ control={form.control}
190
+ name="code"
191
+ label={<Trans>Code</Trans>}
192
+ render={({ field }) => (
193
+ <SlugInput
194
+ fieldName="code"
195
+ watchFieldName="name"
196
+ entityName="ProductOption"
197
+ entityId={entity?.id}
198
+ {...field}
199
+ />
200
+ )}
201
+ />
202
+ </DetailFormGrid>
203
+ </PageBlock>
204
+ <CustomFieldsPageBlock column="main" entityType="ProductOption" control={form.control} />
205
+ </PageLayout>
206
+ </Page>
207
+ );
208
+ }
@@ -1,6 +1,7 @@
1
1
  import { Button } from '@/vdb/components/ui/button.js';
2
2
  import { ScrollArea } from '@/vdb/components/ui/scroll-area.js';
3
3
  import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/vdb/components/ui/sheet.js';
4
+ import { FullWidthPageBlock } from '@/vdb/framework/layout-engine/page-layout.js';
4
5
  import { ZoneCountriesTable } from './zone-countries-table.js';
5
6
 
6
7
  interface ZoneCountriesSheetProps {
@@ -23,7 +24,9 @@ export function ZoneCountriesSheet({ zoneId, zoneName, children }: Readonly<Zone
23
24
  </SheetHeader>
24
25
  <div className="flex items-center gap-2"></div>
25
26
  <ScrollArea className="px-6 max-h-[600px]">
26
- <ZoneCountriesTable zoneId={zoneId} />
27
+ <FullWidthPageBlock blockId="zone-countries">
28
+ <ZoneCountriesTable zoneId={zoneId} />
29
+ </FullWidthPageBlock>
27
30
  </ScrollArea>
28
31
  </SheetContent>
29
32
  </Sheet>
@@ -1,3 +1,4 @@
1
+ import { DateRangePicker } from '@/vdb/components/date-range-picker.js';
1
2
  import { Button } from '@/vdb/components/ui/button.js';
2
3
  import type { GridLayout as GridLayoutType } from '@/vdb/components/ui/grid-layout.js';
3
4
  import { GridLayout } from '@/vdb/components/ui/grid-layout.js';
@@ -5,6 +6,10 @@ import {
5
6
  getDashboardWidget,
6
7
  getDashboardWidgetRegistry,
7
8
  } from '@/vdb/framework/dashboard-widget/widget-extensions.js';
9
+ import {
10
+ DefinedDateRange,
11
+ WidgetFiltersProvider,
12
+ } from '@/vdb/framework/dashboard-widget/widget-filters-context.js';
8
13
  import { DashboardWidgetInstance } from '@/vdb/framework/extension-api/types/widgets.js';
9
14
  import {
10
15
  FullWidthPageBlock,
@@ -16,7 +21,8 @@ import {
16
21
  } from '@/vdb/framework/layout-engine/page-layout.js';
17
22
  import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
18
23
  import { createFileRoute } from '@tanstack/react-router';
19
- import { useEffect, useState, useRef } from 'react';
24
+ import { endOfDay, startOfMonth } from 'date-fns';
25
+ import { useEffect, useRef, useState } from 'react';
20
26
 
21
27
  export const Route = createFileRoute('/_authenticated/')({
22
28
  component: DashboardPage,
@@ -67,12 +73,16 @@ function DashboardPage() {
67
73
  const [editMode, setEditMode] = useState(false);
68
74
  const [isInitialized, setIsInitialized] = useState(false);
69
75
  const prevEditModeRef = useRef(editMode);
76
+ const [dateRange, setDateRange] = useState<DefinedDateRange>({
77
+ from: startOfMonth(new Date()),
78
+ to: endOfDay(new Date()),
79
+ });
70
80
 
71
81
  const { settings, setWidgetLayout } = useUserSettings();
72
82
 
73
83
  useEffect(() => {
74
84
  const savedLayouts = settings.widgetLayout || {};
75
-
85
+
76
86
  const initialWidgets = Array.from(getDashboardWidgetRegistry().entries()).reduce(
77
87
  (acc: DashboardWidgetInstance[], [id, widget]) => {
78
88
  const defaultSize = {
@@ -88,7 +98,7 @@ function DashboardPage() {
88
98
 
89
99
  // Check if we have a saved layout for this widget
90
100
  const savedLayout = savedLayouts[id];
91
-
101
+
92
102
  const layout = {
93
103
  w: savedLayout?.w ?? defaultSize.w,
94
104
  h: savedLayout?.h ?? defaultSize.h,
@@ -125,7 +135,7 @@ function DashboardPage() {
125
135
  setWidgets(initialWidgets);
126
136
  setIsInitialized(true);
127
137
  }, [settings.widgetLayout]);
128
-
138
+
129
139
  // Save layout when edit mode is turned off
130
140
  useEffect(() => {
131
141
  // Only save when transitioning from edit mode ON to OFF
@@ -141,7 +151,7 @@ function DashboardPage() {
141
151
  });
142
152
  setWidgetLayout(layoutConfig);
143
153
  }
144
-
154
+
145
155
  // Update the ref for next render
146
156
  prevEditModeRef.current = editMode;
147
157
  }, [editMode, isInitialized, widgets, setWidgetLayout]);
@@ -168,11 +178,16 @@ function DashboardPage() {
168
178
  <PageTitle>Insights</PageTitle>
169
179
  <PageActionBar>
170
180
  <PageActionBarRight>
171
- <Button
172
- variant={editMode ? "default" : "outline"}
181
+ <DateRangePicker
182
+ dateRange={dateRange}
183
+ onDateRangeChange={setDateRange}
184
+ className="mr-2"
185
+ />
186
+ <Button
187
+ variant={editMode ? 'default' : 'outline'}
173
188
  onClick={() => setEditMode(prev => !prev)}
174
189
  >
175
- {editMode ? "Save Layout" : "Edit Layout"}
190
+ {editMode ? 'Save Layout' : 'Edit Layout'}
176
191
  </Button>
177
192
  </PageActionBarRight>
178
193
  </PageActionBar>
@@ -180,22 +195,24 @@ function DashboardPage() {
180
195
  <FullWidthPageBlock blockId="widgets">
181
196
  <div className="w-full">
182
197
  {widgets.length > 0 ? (
183
- <GridLayout
184
- layouts={widgets.map(w => ({ ...w.layout, i: w.id }))}
185
- onLayoutChange={handleLayoutChange}
186
- cols={12}
187
- rowHeight={100}
188
- isDraggable={editMode}
189
- isResizable={editMode}
190
- className="min-h-[400px]"
191
- gutter={10}
192
- >
193
- {
194
- widgets
195
- .map(widget => renderWidget(widget))
196
- .filter(Boolean) as React.ReactElement[]
197
- }
198
- </GridLayout>
198
+ <WidgetFiltersProvider filters={{ dateRange }}>
199
+ <GridLayout
200
+ layouts={widgets.map(w => ({ ...w.layout, i: w.id }))}
201
+ onLayoutChange={handleLayoutChange}
202
+ cols={12}
203
+ rowHeight={100}
204
+ isDraggable={editMode}
205
+ isResizable={editMode}
206
+ className="min-h-[400px]"
207
+ gutter={10}
208
+ >
209
+ {
210
+ widgets
211
+ .map(widget => renderWidget(widget))
212
+ .filter(Boolean) as React.ReactElement[]
213
+ }
214
+ </GridLayout>
215
+ </WidgetFiltersProvider>
199
216
  ) : (
200
217
  <div
201
218
  className="flex items-center justify-center text-muted-foreground"
@@ -1,5 +1,20 @@
1
1
  import { JsonEditor } from 'json-edit-react';
2
+ import { FileJson } from 'lucide-react';
3
+
4
+ import { Button } from '../ui/button.js';
5
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '../ui/dropdown-menu.js';
2
6
 
3
7
  export function Json({ value }: Readonly<{ value: any }>) {
4
- return <JsonEditor data={value} />;
8
+ return (
9
+ <DropdownMenu>
10
+ <DropdownMenuTrigger asChild>
11
+ <Button variant="secondary" size="icon">
12
+ <FileJson />
13
+ </Button>
14
+ </DropdownMenuTrigger>
15
+ <DropdownMenuContent className="w-96 max-h-96 overflow-auto p-2">
16
+ <JsonEditor viewOnly data={value} collapse={1} rootFontSize={12} />
17
+ </DropdownMenuContent>
18
+ </DropdownMenu>
19
+ );
5
20
  }
@@ -15,3 +15,6 @@ export * from './product-multi-selector-input.js';
15
15
  // Relation selector components
16
16
  export * from './relation-input.js';
17
17
  export * from './relation-selector.js';
18
+
19
+ // Slug input component
20
+ export * from './slug-input.js';
@@ -0,0 +1,296 @@
1
+ import { Button } from '@/vdb/components/ui/button.js';
2
+ import { Input } from '@/vdb/components/ui/input.js';
3
+ import { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
4
+ import { isReadonlyField } from '@/vdb/framework/form-engine/utils.js';
5
+ import { api } from '@/vdb/graphql/api.js';
6
+ import { graphql } from '@/vdb/graphql/graphql.js';
7
+ import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
8
+ import { useLingui } from '@/vdb/lib/trans.js';
9
+ import { cn } from '@/vdb/lib/utils.js';
10
+ import { useQuery } from '@tanstack/react-query';
11
+ import { useDebounce } from '@uidotdev/usehooks';
12
+ import { Edit, Lock, RefreshCw } from 'lucide-react';
13
+ import { useEffect, useState } from 'react';
14
+ import { useFormContext, useWatch } from 'react-hook-form';
15
+
16
+ const slugForEntityDocument = graphql(`
17
+ query SlugForEntity($input: SlugForEntityInput!) {
18
+ slugForEntity(input: $input)
19
+ }
20
+ `);
21
+
22
+ function resolveWatchFieldPath(
23
+ currentFieldName: string,
24
+ watchFieldName: string,
25
+ formValues: any,
26
+ contentLanguage: string,
27
+ ): string {
28
+ const translationsMatch = currentFieldName.match(/^translations\.(\d+)\./);
29
+
30
+ if (translationsMatch) {
31
+ const index = translationsMatch[1];
32
+
33
+ if (formValues?.translations?.[index]?.hasOwnProperty(watchFieldName)) {
34
+ return `translations.${index}.${watchFieldName}`;
35
+ }
36
+
37
+ if (formValues?.hasOwnProperty(watchFieldName)) {
38
+ return watchFieldName;
39
+ }
40
+
41
+ return `translations.${index}.${watchFieldName}`;
42
+ }
43
+
44
+ if (formValues?.translations && Array.isArray(formValues.translations)) {
45
+ const translations = formValues.translations;
46
+ const existingIndex = translations.findIndex(
47
+ (translation: any) => translation?.languageCode === contentLanguage,
48
+ );
49
+ const index = existingIndex === -1 ? (translations.length > 0 ? 0 : -1) : existingIndex;
50
+
51
+ if (index >= 0 && translations[index]?.hasOwnProperty(watchFieldName)) {
52
+ return `translations.${index}.${watchFieldName}`;
53
+ }
54
+ }
55
+
56
+ return watchFieldName;
57
+ }
58
+
59
+ export interface SlugInputProps extends DashboardFormComponentProps {
60
+ /**
61
+ * @description
62
+ * The name of the entity (e.g., 'Product', 'Collection')
63
+ */
64
+ entityName: string;
65
+ /**
66
+ * @description
67
+ * The name of the field to check for uniqueness (e.g., 'slug', 'code')
68
+ */
69
+ fieldName: string;
70
+ /**
71
+ * @description
72
+ * The name of the field to watch for changes (e.g., 'name', 'title', 'enabled').
73
+ * The component automatically resolves whether this field exists in translations
74
+ * or on the base entity. For translatable fields like 'name', it will watch
75
+ * 'translations.X.name'. For non-translatable fields like 'enabled', it will
76
+ * watch 'enabled' directly.
77
+ */
78
+ watchFieldName: string;
79
+ /**
80
+ * @description
81
+ * Optional entity ID for updates (excludes from uniqueness check)
82
+ */
83
+ entityId?: string | number;
84
+ /**
85
+ * @description
86
+ * Whether the input should start in readonly mode. Default: true
87
+ */
88
+ defaultReadonly?: boolean;
89
+
90
+ /**
91
+ * @description Class name for the <Input> component
92
+ */
93
+ className?: string;
94
+ }
95
+
96
+ /**
97
+ * @description
98
+ * A component for generating and displaying slugs based on a watched field.
99
+ * The component watches a source field for changes, debounces the input,
100
+ * and generates a unique slug via the Admin API. The slug is only auto-generated
101
+ * when it's empty. For existing slugs, a regenerate button allows manual regeneration.
102
+ * The input is readonly by default but can be made editable with a toggle button.
103
+ *
104
+ * @example
105
+ * ```tsx
106
+ * // In a TranslatableFormFieldWrapper context with translatable field
107
+ * <SlugInput
108
+ * {...field}
109
+ * entityName="Product"
110
+ * fieldName="slug"
111
+ * watchFieldName="name" // Automatically resolves to "translations.X.name"
112
+ * entityId={productId}
113
+ * />
114
+ *
115
+ * // In a TranslatableFormFieldWrapper context with non-translatable field
116
+ * <SlugInput
117
+ * {...field}
118
+ * entityName="Product"
119
+ * fieldName="slug"
120
+ * watchFieldName="enabled" // Uses "enabled" directly (base entity field)
121
+ * entityId={productId}
122
+ * />
123
+ *
124
+ * // For non-translatable entities
125
+ * <SlugInput
126
+ * {...field}
127
+ * entityName="Channel"
128
+ * fieldName="code"
129
+ * watchFieldName="name" // Uses "name" directly
130
+ * entityId={channelId}
131
+ * />
132
+ * ```
133
+ *
134
+ * @docsCategory form-components
135
+ * @docsPage SlugInput
136
+ */
137
+ export function SlugInput({
138
+ value,
139
+ onChange,
140
+ fieldDef,
141
+ entityName,
142
+ fieldName,
143
+ watchFieldName,
144
+ entityId,
145
+ defaultReadonly = true,
146
+ className,
147
+ name,
148
+ ...props
149
+ }: SlugInputProps) {
150
+ const { i18n } = useLingui();
151
+ const form = useFormContext();
152
+ const { contentLanguage } = useUserSettings().settings;
153
+ const isFormReadonly = isReadonlyField(fieldDef);
154
+ const [isManuallyReadonly, setIsManuallyReadonly] = useState(defaultReadonly);
155
+ const isReadonly = isFormReadonly || isManuallyReadonly;
156
+
157
+ const actualWatchFieldName = resolveWatchFieldPath(
158
+ name || '',
159
+ watchFieldName,
160
+ form?.getValues(),
161
+ contentLanguage,
162
+ );
163
+
164
+ const watchedValue = useWatch({
165
+ control: form?.control,
166
+ name: actualWatchFieldName,
167
+ });
168
+
169
+ const watchFieldState = form.getFieldState(actualWatchFieldName);
170
+ const debouncedWatchedValue = useDebounce(watchedValue, 500);
171
+
172
+ const shouldAutoGenerate = isReadonly && !value && watchFieldState.isDirty;
173
+
174
+ const {
175
+ data: generatedSlug,
176
+ isLoading,
177
+ refetch,
178
+ } = useQuery({
179
+ queryKey: ['slugForEntity', entityName, fieldName, debouncedWatchedValue, entityId],
180
+ queryFn: async () => {
181
+ if (!debouncedWatchedValue) {
182
+ return '';
183
+ }
184
+
185
+ const result = await api.query(slugForEntityDocument, {
186
+ input: {
187
+ entityName,
188
+ fieldName,
189
+ inputValue: debouncedWatchedValue,
190
+ entityId: entityId?.toString(),
191
+ },
192
+ });
193
+
194
+ return result.slugForEntity;
195
+ },
196
+ enabled: !!debouncedWatchedValue && shouldAutoGenerate,
197
+ });
198
+
199
+ useEffect(() => {
200
+ if (isReadonly && generatedSlug && generatedSlug !== value) {
201
+ onChange?.(generatedSlug);
202
+ }
203
+ }, [generatedSlug, isReadonly, value, onChange]);
204
+
205
+ const toggleReadonly = () => {
206
+ if (!isFormReadonly) {
207
+ setIsManuallyReadonly(!isManuallyReadonly);
208
+ }
209
+ };
210
+
211
+ const handleRegenerate = async () => {
212
+ if (watchedValue) {
213
+ const result = await refetch();
214
+ if (result.data) {
215
+ onChange?.(result.data);
216
+ }
217
+ }
218
+ };
219
+
220
+ const handleChange = (newValue: string) => {
221
+ onChange?.(newValue);
222
+ };
223
+
224
+ const displayValue = isReadonly && generatedSlug ? generatedSlug : value || '';
225
+ const showLoading = isLoading && isReadonly;
226
+
227
+ return (
228
+ <div className="relative flex items-center gap-2">
229
+ <div className="flex-1 relative">
230
+ <Input
231
+ value={displayValue}
232
+ onChange={e => handleChange(e.target.value)}
233
+ disabled={isReadonly}
234
+ placeholder={
235
+ isReadonly
236
+ ? value
237
+ ? i18n.t('Slug is set')
238
+ : i18n.t('Slug will be generated automatically...')
239
+ : i18n.t('Enter slug manually')
240
+ }
241
+ className={cn(
242
+ 'pr-8',
243
+ isReadonly && 'bg-muted text-muted-foreground',
244
+ showLoading && 'text-muted-foreground',
245
+ className,
246
+ )}
247
+ {...props}
248
+ />
249
+ {showLoading && (
250
+ <div className="absolute right-3 top-1/2 -translate-y-1/2">
251
+ <div className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
252
+ </div>
253
+ )}
254
+ </div>
255
+
256
+ {!isFormReadonly && (
257
+ <>
258
+ {isManuallyReadonly && value && (
259
+ <Button
260
+ type="button"
261
+ variant="outline"
262
+ size="sm"
263
+ onClick={handleRegenerate}
264
+ className="shrink-0"
265
+ title={i18n.t('Regenerate slug from source field')}
266
+ aria-label={i18n.t('Regenerate slug from source field')}
267
+ disabled={!watchedValue || isLoading}
268
+ >
269
+ <RefreshCw className="h-4 w-4" />
270
+ </Button>
271
+ )}
272
+
273
+ <Button
274
+ type="button"
275
+ variant="outline"
276
+ size="sm"
277
+ onClick={toggleReadonly}
278
+ className="shrink-0"
279
+ title={
280
+ isManuallyReadonly
281
+ ? i18n.t('Edit slug manually')
282
+ : i18n.t('Generate slug automatically')
283
+ }
284
+ aria-label={
285
+ isManuallyReadonly
286
+ ? i18n.t('Edit slug manually')
287
+ : i18n.t('Generate slug automatically')
288
+ }
289
+ >
290
+ {isManuallyReadonly ? <Edit className="h-4 w-4" /> : <Lock className="h-4 w-4" />}
291
+ </Button>
292
+ </>
293
+ )}
294
+ </div>
295
+ );
296
+ }