@vendure/dashboard 3.4.3-master-202509250229 → 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.
- package/dist/plugin/api/api-extensions.js +11 -14
- package/dist/plugin/api/metrics.resolver.d.ts +2 -2
- package/dist/plugin/api/metrics.resolver.js +2 -2
- package/dist/plugin/config/metrics-strategies.d.ts +9 -9
- package/dist/plugin/config/metrics-strategies.js +6 -6
- package/dist/plugin/constants.d.ts +2 -0
- package/dist/plugin/constants.js +3 -1
- package/dist/plugin/dashboard.plugin.js +13 -0
- package/dist/plugin/service/metrics.service.d.ts +3 -3
- package/dist/plugin/service/metrics.service.js +37 -53
- package/dist/plugin/types.d.ts +9 -12
- package/dist/plugin/types.js +7 -11
- package/dist/vite/utils/compiler.js +2 -0
- package/dist/vite/vite-plugin-vendure-dashboard.js +2 -2
- package/package.json +4 -4
- package/src/app/routes/_authenticated/_collections/collections.tsx +7 -2
- package/src/app/routes/_authenticated/_collections/collections_.$id.tsx +15 -2
- package/src/app/routes/_authenticated/_facets/facets_.$id.tsx +14 -2
- package/src/app/routes/_authenticated/_product-variants/product-variants.graphql.ts +10 -0
- package/src/app/routes/_authenticated/_products/components/product-option-group-badge.tsx +19 -0
- package/src/app/routes/_authenticated/_products/components/product-options-table.tsx +111 -0
- package/src/app/routes/_authenticated/_products/product-option-groups.graphql.ts +103 -0
- package/src/app/routes/_authenticated/_products/products.graphql.ts +13 -1
- package/src/app/routes/_authenticated/_products/products.tsx +27 -3
- package/src/app/routes/_authenticated/_products/products_.$id.tsx +26 -9
- package/src/app/routes/_authenticated/_products/products_.$productId.option-groups.$id.tsx +181 -0
- package/src/app/routes/_authenticated/_products/products_.$productId.option-groups.$productOptionGroupId.options_.$id.tsx +208 -0
- package/src/app/routes/_authenticated/_zones/components/zone-countries-sheet.tsx +4 -1
- package/src/app/routes/_authenticated/index.tsx +41 -24
- package/src/lib/components/data-display/json.tsx +16 -1
- package/src/lib/components/data-input/index.ts +3 -0
- package/src/lib/components/data-input/slug-input.tsx +296 -0
- package/src/lib/components/data-table/add-filter-menu.tsx +13 -6
- package/src/lib/components/data-table/data-table-bulk-action-item.tsx +38 -1
- package/src/lib/components/data-table/data-table-context.tsx +91 -0
- package/src/lib/components/data-table/data-table-filter-badge.tsx +9 -5
- package/src/lib/components/data-table/data-table-view-options.tsx +17 -8
- package/src/lib/components/data-table/data-table.tsx +146 -94
- package/src/lib/components/data-table/global-views-bar.tsx +97 -0
- package/src/lib/components/data-table/global-views-sheet.tsx +11 -0
- package/src/lib/components/data-table/manage-global-views-button.tsx +26 -0
- package/src/lib/components/data-table/my-views-button.tsx +47 -0
- package/src/lib/components/data-table/refresh-button.tsx +12 -3
- package/src/lib/components/data-table/save-view-button.tsx +45 -0
- package/src/lib/components/data-table/save-view-dialog.tsx +113 -0
- package/src/lib/components/data-table/use-generated-columns.tsx +3 -1
- package/src/lib/components/data-table/user-views-sheet.tsx +11 -0
- package/src/lib/components/data-table/views-sheet.tsx +297 -0
- package/src/lib/components/date-range-picker.tsx +184 -0
- package/src/lib/components/shared/paginated-list-data-table.tsx +59 -32
- package/src/lib/components/ui/button.tsx +1 -1
- package/src/lib/framework/dashboard-widget/latest-orders-widget/index.tsx +29 -2
- package/src/lib/framework/dashboard-widget/metrics-widget/index.tsx +10 -7
- package/src/lib/framework/dashboard-widget/metrics-widget/metrics-widget.graphql.ts +9 -3
- package/src/lib/framework/dashboard-widget/orders-summary/index.tsx +19 -75
- package/src/lib/framework/dashboard-widget/widget-filters-context.tsx +33 -0
- package/src/lib/framework/document-introspection/add-custom-fields.spec.ts +319 -9
- package/src/lib/framework/document-introspection/add-custom-fields.ts +60 -31
- package/src/lib/framework/document-introspection/get-document-structure.spec.ts +1 -159
- package/src/lib/framework/document-introspection/include-only-selected-list-fields.spec.ts +1840 -0
- package/src/lib/framework/document-introspection/include-only-selected-list-fields.ts +940 -0
- package/src/lib/framework/document-introspection/testing-utils.ts +161 -0
- package/src/lib/framework/extension-api/display-component-extensions.tsx +2 -0
- package/src/lib/framework/extension-api/types/data-table.ts +62 -4
- package/src/lib/framework/extension-api/types/navigation.ts +16 -0
- package/src/lib/framework/form-engine/utils.ts +34 -0
- package/src/lib/framework/page/list-page.tsx +289 -4
- package/src/lib/framework/page/use-extended-router.tsx +59 -17
- package/src/lib/graphql/api.ts +4 -2
- package/src/lib/graphql/graphql-env.d.ts +13 -10
- package/src/lib/hooks/use-extended-list-query.ts +5 -0
- package/src/lib/hooks/use-saved-views.ts +230 -0
- package/src/lib/index.ts +15 -0
- package/src/lib/types/saved-views.ts +39 -0
- 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
|
-
<
|
|
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 {
|
|
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
|
-
<
|
|
172
|
-
|
|
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 ?
|
|
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
|
-
<
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
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
|
}
|
|
@@ -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
|
+
}
|