@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.
- 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/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,297 @@
|
|
|
1
|
+
import { Copy, Edit, Globe, MoreHorizontal, Trash2 } from 'lucide-react';
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
|
+
import { useSavedViews } from '../../hooks/use-saved-views.js';
|
|
4
|
+
import { useDataTableContext } from './data-table-context.js';
|
|
5
|
+
import { Button } from '../ui/button.js';
|
|
6
|
+
import { Input } from '../ui/input.js';
|
|
7
|
+
import {
|
|
8
|
+
DropdownMenu,
|
|
9
|
+
DropdownMenuContent,
|
|
10
|
+
DropdownMenuItem,
|
|
11
|
+
DropdownMenuTrigger,
|
|
12
|
+
} from '../ui/dropdown-menu.js';
|
|
13
|
+
import {
|
|
14
|
+
Sheet,
|
|
15
|
+
SheetContent,
|
|
16
|
+
SheetDescription,
|
|
17
|
+
SheetHeader,
|
|
18
|
+
SheetTitle,
|
|
19
|
+
} from '../ui/sheet.js';
|
|
20
|
+
import { SavedView } from '../../types/saved-views.js';
|
|
21
|
+
import { toast } from 'sonner';
|
|
22
|
+
import {
|
|
23
|
+
AlertDialog,
|
|
24
|
+
AlertDialogAction,
|
|
25
|
+
AlertDialogCancel,
|
|
26
|
+
AlertDialogContent,
|
|
27
|
+
AlertDialogDescription,
|
|
28
|
+
AlertDialogFooter,
|
|
29
|
+
AlertDialogHeader,
|
|
30
|
+
AlertDialogTitle,
|
|
31
|
+
} from '../ui/alert-dialog.js';
|
|
32
|
+
import { Trans, useLingui } from '@/vdb/lib/trans.js';
|
|
33
|
+
|
|
34
|
+
interface ViewsSheetProps {
|
|
35
|
+
open: boolean;
|
|
36
|
+
onOpenChange: (open: boolean) => void;
|
|
37
|
+
type: 'user' | 'global';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const ViewsSheet: React.FC<ViewsSheetProps> = ({ open, onOpenChange, type }) => {
|
|
41
|
+
const { userViews, globalViews, deleteView, updateView, duplicateView, canManageGlobalViews } = useSavedViews();
|
|
42
|
+
const { handleApplyView } = useDataTableContext();
|
|
43
|
+
const { i18n } = useLingui();
|
|
44
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
45
|
+
const [editingName, setEditingName] = useState('');
|
|
46
|
+
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
|
47
|
+
|
|
48
|
+
const views = type === 'global' ? globalViews : userViews;
|
|
49
|
+
const isGlobal = type === 'global';
|
|
50
|
+
|
|
51
|
+
const handleViewApply = (view: SavedView) => {
|
|
52
|
+
handleApplyView(view.filters, view.searchTerm);
|
|
53
|
+
const message = isGlobal
|
|
54
|
+
? i18n.t(`Applied global view "${view.name}"`)
|
|
55
|
+
: i18n.t(`Applied view "${view.name}"`);
|
|
56
|
+
toast.success(message);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const handleStartEdit = (view: SavedView) => {
|
|
60
|
+
setEditingId(view.id);
|
|
61
|
+
setEditingName(view.name);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const handleSaveEdit = async () => {
|
|
65
|
+
if (!editingId || !editingName.trim()) return;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
await updateView({ id: editingId, name: editingName.trim() });
|
|
69
|
+
const message = isGlobal
|
|
70
|
+
? i18n.t('Global view renamed successfully')
|
|
71
|
+
: i18n.t('View renamed successfully');
|
|
72
|
+
toast.success(message);
|
|
73
|
+
setEditingId(null);
|
|
74
|
+
setEditingName('');
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const message = isGlobal
|
|
77
|
+
? i18n.t('Failed to rename global view')
|
|
78
|
+
: i18n.t('Failed to rename view');
|
|
79
|
+
toast.error(message);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const handleCancelEdit = () => {
|
|
84
|
+
setEditingId(null);
|
|
85
|
+
setEditingName('');
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const handleDelete = async () => {
|
|
89
|
+
if (!deleteConfirmId) return;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
await deleteView(deleteConfirmId);
|
|
93
|
+
const message = isGlobal
|
|
94
|
+
? i18n.t('Global view deleted successfully')
|
|
95
|
+
: i18n.t('View deleted successfully');
|
|
96
|
+
toast.success(message);
|
|
97
|
+
setDeleteConfirmId(null);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
const message = isGlobal
|
|
100
|
+
? i18n.t('Failed to delete global view')
|
|
101
|
+
: i18n.t('Failed to delete view');
|
|
102
|
+
toast.error(message);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const handleDuplicate = async (view: SavedView) => {
|
|
107
|
+
try {
|
|
108
|
+
await duplicateView(view.id, type);
|
|
109
|
+
const message = isGlobal
|
|
110
|
+
? i18n.t('Global view duplicated successfully')
|
|
111
|
+
: i18n.t('View duplicated successfully');
|
|
112
|
+
toast.success(message);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
const message = isGlobal
|
|
115
|
+
? i18n.t('Failed to duplicate global view')
|
|
116
|
+
: i18n.t('Failed to duplicate view');
|
|
117
|
+
toast.error(message);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const handleConvertToUser = async (view: SavedView) => {
|
|
122
|
+
try {
|
|
123
|
+
await duplicateView(view.id, 'user');
|
|
124
|
+
toast.success(i18n.t('Global view converted to personal view successfully'));
|
|
125
|
+
} catch (error) {
|
|
126
|
+
toast.error(i18n.t('Failed to convert global view to personal view'));
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const handleConvertToGlobal = async (view: SavedView) => {
|
|
131
|
+
try {
|
|
132
|
+
await duplicateView(view.id, 'global');
|
|
133
|
+
await deleteView(view.id);
|
|
134
|
+
toast.success(i18n.t('View converted to global successfully'));
|
|
135
|
+
} catch (error) {
|
|
136
|
+
toast.error(i18n.t('Failed to convert view to global'));
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const getTitle = () => {
|
|
141
|
+
return isGlobal ? <Trans>Manage Global Views</Trans> : <Trans>My Saved Views</Trans>;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const getDescription = () => {
|
|
145
|
+
return isGlobal
|
|
146
|
+
? <Trans>Manage global saved views that are visible to all users</Trans>
|
|
147
|
+
: <Trans>Manage your personal saved views for this table</Trans>;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const getEmptyStateMessage = () => {
|
|
151
|
+
if (isGlobal) {
|
|
152
|
+
return (
|
|
153
|
+
<>
|
|
154
|
+
<p><Trans>No global views have been created yet.</Trans></p>
|
|
155
|
+
<p className="text-sm mt-2">
|
|
156
|
+
<Trans>Save a view as "Global" to make it available to all users.</Trans>
|
|
157
|
+
</p>
|
|
158
|
+
</>
|
|
159
|
+
);
|
|
160
|
+
} else {
|
|
161
|
+
return (
|
|
162
|
+
<>
|
|
163
|
+
<p><Trans>You haven't saved any views yet.</Trans></p>
|
|
164
|
+
<p className="text-sm mt-2">
|
|
165
|
+
<Trans>Apply filters to the table and click "Save View" to get started.</Trans>
|
|
166
|
+
</p>
|
|
167
|
+
</>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const getDeleteDialogTitle = () => {
|
|
173
|
+
return isGlobal ? <Trans>Delete Global View</Trans> : <Trans>Delete View</Trans>;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const getDeleteDialogDescription = () => {
|
|
177
|
+
return isGlobal
|
|
178
|
+
? <Trans>Are you sure you want to delete this global view? This action cannot be undone and will affect all users.</Trans>
|
|
179
|
+
: <Trans>Are you sure you want to delete this view? This action cannot be undone.</Trans>;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<>
|
|
184
|
+
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
185
|
+
<SheetContent className="w-[400px] sm:w-[540px]">
|
|
186
|
+
<SheetHeader>
|
|
187
|
+
<SheetTitle>{getTitle()}</SheetTitle>
|
|
188
|
+
<SheetDescription>
|
|
189
|
+
{getDescription()}
|
|
190
|
+
</SheetDescription>
|
|
191
|
+
</SheetHeader>
|
|
192
|
+
<div className="mt-4">
|
|
193
|
+
{views.length === 0 ? (
|
|
194
|
+
<div className="text-center py-8 text-muted-foreground">
|
|
195
|
+
{getEmptyStateMessage()}
|
|
196
|
+
</div>
|
|
197
|
+
) : (
|
|
198
|
+
<div className="divide-y">
|
|
199
|
+
{views.map(view => (
|
|
200
|
+
<div
|
|
201
|
+
key={view.id}
|
|
202
|
+
className="flex items-center justify-between py-3 first:pt-0 last:pb-0 hover:bg-accent/50 transition-colors rounded-md px-2"
|
|
203
|
+
>
|
|
204
|
+
{editingId === view.id ? (
|
|
205
|
+
<div className="flex items-center gap-2 flex-1">
|
|
206
|
+
<Input
|
|
207
|
+
value={editingName}
|
|
208
|
+
onChange={e => setEditingName(e.target.value)}
|
|
209
|
+
onKeyDown={e => {
|
|
210
|
+
if (e.key === 'Enter') handleSaveEdit();
|
|
211
|
+
if (e.key === 'Escape') handleCancelEdit();
|
|
212
|
+
}}
|
|
213
|
+
autoFocus
|
|
214
|
+
className="flex-1"
|
|
215
|
+
/>
|
|
216
|
+
<Button size="sm" onClick={handleSaveEdit}>
|
|
217
|
+
<Trans>Save</Trans>
|
|
218
|
+
</Button>
|
|
219
|
+
<Button size="sm" variant="outline" onClick={handleCancelEdit}>
|
|
220
|
+
<Trans>Cancel</Trans>
|
|
221
|
+
</Button>
|
|
222
|
+
</div>
|
|
223
|
+
) : (
|
|
224
|
+
<>
|
|
225
|
+
<span className="font-medium text-sm truncate flex-1">{view.name}</span>
|
|
226
|
+
<div className="flex items-center gap-1">
|
|
227
|
+
<Button
|
|
228
|
+
size="sm"
|
|
229
|
+
onClick={() => handleViewApply(view)}
|
|
230
|
+
>
|
|
231
|
+
<Trans>Apply</Trans>
|
|
232
|
+
</Button>
|
|
233
|
+
<DropdownMenu>
|
|
234
|
+
<DropdownMenuTrigger asChild>
|
|
235
|
+
<Button variant="ghost" size="sm">
|
|
236
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
237
|
+
</Button>
|
|
238
|
+
</DropdownMenuTrigger>
|
|
239
|
+
<DropdownMenuContent align="end">
|
|
240
|
+
<DropdownMenuItem onClick={() => handleStartEdit(view)}>
|
|
241
|
+
<Edit className="h-4 w-4 mr-2" />
|
|
242
|
+
<Trans>Rename</Trans>
|
|
243
|
+
</DropdownMenuItem>
|
|
244
|
+
<DropdownMenuItem onClick={() => handleDuplicate(view)}>
|
|
245
|
+
<Copy className="h-4 w-4 mr-2" />
|
|
246
|
+
<Trans>Duplicate</Trans>
|
|
247
|
+
</DropdownMenuItem>
|
|
248
|
+
{isGlobal ? (
|
|
249
|
+
<DropdownMenuItem onClick={() => handleConvertToUser(view)}>
|
|
250
|
+
<Copy className="h-4 w-4 mr-2" />
|
|
251
|
+
<Trans>Copy to Personal</Trans>
|
|
252
|
+
</DropdownMenuItem>
|
|
253
|
+
) : (
|
|
254
|
+
canManageGlobalViews && (
|
|
255
|
+
<DropdownMenuItem onClick={() => handleConvertToGlobal(view)}>
|
|
256
|
+
<Globe className="h-4 w-4 mr-2" />
|
|
257
|
+
<Trans>Make Global</Trans>
|
|
258
|
+
</DropdownMenuItem>
|
|
259
|
+
)
|
|
260
|
+
)}
|
|
261
|
+
<DropdownMenuItem
|
|
262
|
+
onClick={() => setDeleteConfirmId(view.id)}
|
|
263
|
+
className="text-destructive"
|
|
264
|
+
>
|
|
265
|
+
<Trash2 className="h-4 w-4 mr-2" />
|
|
266
|
+
<Trans>Delete</Trans>
|
|
267
|
+
</DropdownMenuItem>
|
|
268
|
+
</DropdownMenuContent>
|
|
269
|
+
</DropdownMenu>
|
|
270
|
+
</div>
|
|
271
|
+
</>
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
))}
|
|
275
|
+
</div>
|
|
276
|
+
)}
|
|
277
|
+
</div>
|
|
278
|
+
</SheetContent>
|
|
279
|
+
</Sheet>
|
|
280
|
+
|
|
281
|
+
<AlertDialog open={!!deleteConfirmId} onOpenChange={() => setDeleteConfirmId(null)}>
|
|
282
|
+
<AlertDialogContent>
|
|
283
|
+
<AlertDialogHeader>
|
|
284
|
+
<AlertDialogTitle>{getDeleteDialogTitle()}</AlertDialogTitle>
|
|
285
|
+
<AlertDialogDescription>
|
|
286
|
+
{getDeleteDialogDescription()}
|
|
287
|
+
</AlertDialogDescription>
|
|
288
|
+
</AlertDialogHeader>
|
|
289
|
+
<AlertDialogFooter>
|
|
290
|
+
<AlertDialogCancel><Trans>Cancel</Trans></AlertDialogCancel>
|
|
291
|
+
<AlertDialogAction onClick={handleDelete}><Trans>Delete</Trans></AlertDialogAction>
|
|
292
|
+
</AlertDialogFooter>
|
|
293
|
+
</AlertDialogContent>
|
|
294
|
+
</AlertDialog>
|
|
295
|
+
</>
|
|
296
|
+
);
|
|
297
|
+
};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Button } from '@/vdb/components/ui/button.js';
|
|
4
|
+
import { Calendar } from '@/vdb/components/ui/calendar.js';
|
|
5
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@/vdb/components/ui/popover.js';
|
|
6
|
+
import { DefinedDateRange } from '@/vdb/framework/dashboard-widget/widget-filters-context.js';
|
|
7
|
+
import { cn } from '@/vdb/lib/utils.js';
|
|
8
|
+
import {
|
|
9
|
+
addDays,
|
|
10
|
+
endOfDay,
|
|
11
|
+
endOfMonth,
|
|
12
|
+
endOfWeek,
|
|
13
|
+
format,
|
|
14
|
+
startOfDay,
|
|
15
|
+
startOfMonth,
|
|
16
|
+
startOfWeek,
|
|
17
|
+
subDays
|
|
18
|
+
} from 'date-fns';
|
|
19
|
+
import { CalendarIcon } from 'lucide-react';
|
|
20
|
+
import * as React from 'react';
|
|
21
|
+
import { DateRange } from 'react-day-picker';
|
|
22
|
+
|
|
23
|
+
interface DateRangePickerProps {
|
|
24
|
+
className?: string;
|
|
25
|
+
dateRange: DefinedDateRange;
|
|
26
|
+
onDateRangeChange: (range: DefinedDateRange) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const presets = [
|
|
30
|
+
{
|
|
31
|
+
label: 'Today',
|
|
32
|
+
getValue: () => ({
|
|
33
|
+
from: startOfDay(new Date()),
|
|
34
|
+
to: endOfDay(new Date()),
|
|
35
|
+
}),
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
label: 'Yesterday',
|
|
39
|
+
getValue: () => ({
|
|
40
|
+
from: startOfDay(subDays(new Date(), 1)),
|
|
41
|
+
to: endOfDay(subDays(new Date(), 1)),
|
|
42
|
+
}),
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
label: 'Last 7 days',
|
|
46
|
+
getValue: () => ({
|
|
47
|
+
from: startOfDay(subDays(new Date(), 6)),
|
|
48
|
+
to: endOfDay(new Date()),
|
|
49
|
+
}),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
label: 'This week',
|
|
53
|
+
getValue: () => ({
|
|
54
|
+
from: startOfWeek(new Date(), { weekStartsOn: 1 }),
|
|
55
|
+
to: endOfWeek(new Date(), { weekStartsOn: 1 }),
|
|
56
|
+
}),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
label: 'Last 30 days',
|
|
60
|
+
getValue: () => ({
|
|
61
|
+
from: startOfDay(subDays(new Date(), 29)),
|
|
62
|
+
to: endOfDay(new Date()),
|
|
63
|
+
}),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
label: 'Month to date',
|
|
67
|
+
getValue: () => ({
|
|
68
|
+
from: startOfMonth(new Date()),
|
|
69
|
+
to: endOfDay(new Date()),
|
|
70
|
+
}),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
label: 'This month',
|
|
74
|
+
getValue: () => ({
|
|
75
|
+
from: startOfMonth(new Date()),
|
|
76
|
+
to: endOfMonth(new Date()),
|
|
77
|
+
}),
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
label: 'Last month',
|
|
81
|
+
getValue: () => ({
|
|
82
|
+
from: startOfMonth(subDays(new Date(), 30)),
|
|
83
|
+
to: endOfMonth(subDays(new Date(), 30)),
|
|
84
|
+
}),
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
export function DateRangePicker({ className, dateRange, onDateRangeChange }: DateRangePickerProps) {
|
|
89
|
+
const [open, setOpen] = React.useState(false);
|
|
90
|
+
// Internal state uses react-day-picker's DateRange type for Calendar compatibility
|
|
91
|
+
const [selectedRange, setSelectedRange] = React.useState<DateRange>({
|
|
92
|
+
from: dateRange.from,
|
|
93
|
+
to: dateRange.to
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
React.useEffect(() => {
|
|
97
|
+
setSelectedRange({
|
|
98
|
+
from: dateRange.from,
|
|
99
|
+
to: dateRange.to
|
|
100
|
+
});
|
|
101
|
+
}, [dateRange]);
|
|
102
|
+
|
|
103
|
+
const handleSelect = (range: DateRange | undefined) => {
|
|
104
|
+
if (range?.from) {
|
|
105
|
+
// If no end date is selected, use the from date as the end date
|
|
106
|
+
const to = range.to || range.from;
|
|
107
|
+
const finalRange: DefinedDateRange = {
|
|
108
|
+
from: range.from,
|
|
109
|
+
to: to
|
|
110
|
+
};
|
|
111
|
+
setSelectedRange({ from: range.from, to });
|
|
112
|
+
onDateRangeChange(finalRange);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const handlePresetClick = (preset: typeof presets[number]) => {
|
|
117
|
+
const range = preset.getValue();
|
|
118
|
+
handleSelect(range);
|
|
119
|
+
setOpen(false);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const formatDateRange = () => {
|
|
123
|
+
if (!selectedRange.from) {
|
|
124
|
+
return 'Select date range';
|
|
125
|
+
}
|
|
126
|
+
if (!selectedRange.to || selectedRange.from === selectedRange.to) {
|
|
127
|
+
return format(selectedRange.from, 'MMM dd, yyyy');
|
|
128
|
+
}
|
|
129
|
+
return `${format(selectedRange.from, 'MMM dd, yyyy')} - ${format(selectedRange.to, 'MMM dd, yyyy')}`;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const isPresetActive = (preset: typeof presets[number]) => {
|
|
133
|
+
if (!selectedRange.from || !selectedRange.to) return false;
|
|
134
|
+
const presetRange = preset.getValue();
|
|
135
|
+
return (
|
|
136
|
+
selectedRange.from.getTime() === presetRange.from.getTime() &&
|
|
137
|
+
selectedRange.to.getTime() === presetRange.to.getTime()
|
|
138
|
+
);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
143
|
+
<PopoverTrigger asChild>
|
|
144
|
+
<Button
|
|
145
|
+
variant="outline"
|
|
146
|
+
className={cn(
|
|
147
|
+
'w-[280px] justify-start text-left font-normal',
|
|
148
|
+
className
|
|
149
|
+
)}
|
|
150
|
+
>
|
|
151
|
+
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
152
|
+
{formatDateRange()}
|
|
153
|
+
</Button>
|
|
154
|
+
</PopoverTrigger>
|
|
155
|
+
<PopoverContent className="w-auto p-0" align="start">
|
|
156
|
+
<div className="flex">
|
|
157
|
+
<div className="border-r p-2 space-y-0.5 min-w-0 w-32">
|
|
158
|
+
{presets.map((preset) => (
|
|
159
|
+
<Button
|
|
160
|
+
key={preset.label}
|
|
161
|
+
variant={isPresetActive(preset) ? 'default' : 'ghost'}
|
|
162
|
+
size="sm"
|
|
163
|
+
className="w-full justify-start font-normal text-xs h-7 px-2"
|
|
164
|
+
onClick={() => handlePresetClick(preset)}
|
|
165
|
+
>
|
|
166
|
+
{preset.label}
|
|
167
|
+
</Button>
|
|
168
|
+
))}
|
|
169
|
+
</div>
|
|
170
|
+
<div className="p-3">
|
|
171
|
+
<Calendar
|
|
172
|
+
mode="range"
|
|
173
|
+
defaultMonth={selectedRange?.from}
|
|
174
|
+
selected={selectedRange}
|
|
175
|
+
onSelect={handleSelect}
|
|
176
|
+
numberOfMonths={2}
|
|
177
|
+
showOutsideDays={false}
|
|
178
|
+
/>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</PopoverContent>
|
|
182
|
+
</Popover>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import { DataTable, FacetedFilter } from '
|
|
2
|
-
import { getObjectPathToPaginatedList } from '
|
|
3
|
-
import { useListQueryFields } from '
|
|
4
|
-
import { api } from '
|
|
1
|
+
import { DataTable, FacetedFilter } from '@/vdb/components/data-table/data-table.js';
|
|
2
|
+
import { getObjectPathToPaginatedList } from '@/vdb/framework/document-introspection/get-document-structure.js';
|
|
3
|
+
import { useListQueryFields } from '@/vdb/framework/document-introspection/hooks.js';
|
|
4
|
+
import { api } from '@/vdb/graphql/api.js';
|
|
5
5
|
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
6
6
|
import { useDebounce } from '@uidotdev/usehooks';
|
|
7
7
|
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
8
|
+
import { addCustomFields } from '@/vdb/framework/document-introspection/add-custom-fields.js';
|
|
9
|
+
import { includeOnlySelectedListFields } from '@/vdb/framework/document-introspection/include-only-selected-list-fields.js';
|
|
10
|
+
import { BulkAction } from '@/vdb/framework/extension-api/types/index.js';
|
|
11
|
+
import { ResultOf } from '@/vdb/graphql/graphql.js';
|
|
12
|
+
import { useExtendedListQuery } from '@/vdb/hooks/use-extended-list-query.js';
|
|
11
13
|
import { TypedDocumentNode } from '@graphql-typed-document-node/core';
|
|
12
14
|
import { ColumnFiltersState, ColumnSort, SortingState, Table } from '@tanstack/react-table';
|
|
13
15
|
import { ColumnDef, Row, TableOptions, VisibilityState } from '@tanstack/table-core';
|
|
@@ -88,7 +90,18 @@ export type AllItemFieldKeys<T extends TypedDocumentNode<any, any>> =
|
|
|
88
90
|
| CustomFieldKeysOfItem<PaginatedListItemFields<T>>;
|
|
89
91
|
|
|
90
92
|
export type CustomizeColumnConfig<T extends TypedDocumentNode<any, any>> = {
|
|
91
|
-
[Key in AllItemFieldKeys<T>]?: Partial<ColumnDef<PaginatedListItemFields<T>, any
|
|
93
|
+
[Key in AllItemFieldKeys<T>]?: Partial<ColumnDef<PaginatedListItemFields<T>, any>> & {
|
|
94
|
+
meta?: {
|
|
95
|
+
/**
|
|
96
|
+
* @description
|
|
97
|
+
* Columns that rely on _other_ columns in order to correctly render,
|
|
98
|
+
* can declare those other columns as dependencies in order to ensure that
|
|
99
|
+
* those columns are always fetched, even when those columns are not explicitly
|
|
100
|
+
* included in the visible table columns.
|
|
101
|
+
*/
|
|
102
|
+
dependencies?: Array<AllItemFieldKeys<T>>;
|
|
103
|
+
};
|
|
104
|
+
};
|
|
92
105
|
};
|
|
93
106
|
|
|
94
107
|
export type FacetedFilterConfig<T extends TypedDocumentNode<any, any>> = {
|
|
@@ -365,7 +378,7 @@ export function PaginatedListDataTable<
|
|
|
365
378
|
const [searchTerm, setSearchTerm] = React.useState<string>('');
|
|
366
379
|
const debouncedSearchTerm = useDebounce(searchTerm, 500);
|
|
367
380
|
const queryClient = useQueryClient();
|
|
368
|
-
const extendedListQuery = useExtendedListQuery(listQuery);
|
|
381
|
+
const extendedListQuery = useExtendedListQuery(addCustomFields(listQuery));
|
|
369
382
|
|
|
370
383
|
const sort = sorting?.reduce((acc: any, sort: ColumnSort) => {
|
|
371
384
|
const direction = sort.desc ? 'DESC' : 'ASC';
|
|
@@ -388,9 +401,44 @@ export function PaginatedListDataTable<
|
|
|
388
401
|
}
|
|
389
402
|
: undefined;
|
|
390
403
|
|
|
404
|
+
function refetchPaginatedList() {
|
|
405
|
+
queryClient.invalidateQueries({ queryKey });
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
registerRefresher?.(refetchPaginatedList);
|
|
409
|
+
|
|
410
|
+
// First we get info on _all_ the fields, including all custom fields, for the
|
|
411
|
+
// purpose of configuring the table columns.
|
|
412
|
+
const fields = useListQueryFields(extendedListQuery);
|
|
413
|
+
const paginatedListObjectPath = getObjectPathToPaginatedList(extendedListQuery);
|
|
414
|
+
|
|
415
|
+
const { columns, customFieldColumnNames } = useGeneratedColumns({
|
|
416
|
+
fields,
|
|
417
|
+
customizeColumns,
|
|
418
|
+
rowActions,
|
|
419
|
+
bulkActions,
|
|
420
|
+
deleteMutation,
|
|
421
|
+
additionalColumns,
|
|
422
|
+
defaultColumnOrder,
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const columnVisibility = getColumnVisibility(fields, defaultVisibility, customFieldColumnNames);
|
|
426
|
+
// Get the actual visible columns and only fetch those
|
|
427
|
+
const visibleColumns = columns
|
|
428
|
+
// Filter out invisible columns, but _always_ select "id"
|
|
429
|
+
// because it is usually needed.
|
|
430
|
+
.filter(c => columnVisibility[c.id as string] || c.id === 'id')
|
|
431
|
+
.map(c => ({
|
|
432
|
+
name: c.id as string,
|
|
433
|
+
isCustomField: (c.meta as any)?.isCustomField ?? false,
|
|
434
|
+
dependencies: (c.meta as any)?.dependencies ?? [],
|
|
435
|
+
}));
|
|
436
|
+
const minimalListQuery = includeOnlySelectedListFields(extendedListQuery, visibleColumns);
|
|
437
|
+
|
|
391
438
|
const defaultQueryKey = [
|
|
392
439
|
PaginatedListDataTableKey,
|
|
393
|
-
|
|
440
|
+
minimalListQuery,
|
|
441
|
+
visibleColumns,
|
|
394
442
|
page,
|
|
395
443
|
itemsPerPage,
|
|
396
444
|
sorting,
|
|
@@ -399,12 +447,6 @@ export function PaginatedListDataTable<
|
|
|
399
447
|
];
|
|
400
448
|
const queryKey = transformQueryKey ? transformQueryKey(defaultQueryKey) : defaultQueryKey;
|
|
401
449
|
|
|
402
|
-
function refetchPaginatedList() {
|
|
403
|
-
queryClient.invalidateQueries({ queryKey });
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
registerRefresher?.(refetchPaginatedList);
|
|
407
|
-
|
|
408
450
|
const { data, isFetching } = useQuery({
|
|
409
451
|
queryFn: () => {
|
|
410
452
|
const searchFilter = onSearchTermChange ? onSearchTermChange(debouncedSearchTerm) : {};
|
|
@@ -419,31 +461,16 @@ export function PaginatedListDataTable<
|
|
|
419
461
|
} as V;
|
|
420
462
|
|
|
421
463
|
const transformedVariables = transformVariables ? transformVariables(variables) : variables;
|
|
422
|
-
return api.query(
|
|
464
|
+
return api.query(minimalListQuery, transformedVariables);
|
|
423
465
|
},
|
|
424
466
|
queryKey,
|
|
425
467
|
placeholderData: keepPreviousData,
|
|
426
468
|
});
|
|
427
|
-
|
|
428
|
-
const fields = useListQueryFields(extendedListQuery);
|
|
429
|
-
const paginatedListObjectPath = getObjectPathToPaginatedList(extendedListQuery);
|
|
430
|
-
|
|
431
469
|
let listData = data as any;
|
|
432
470
|
for (const path of paginatedListObjectPath) {
|
|
433
471
|
listData = listData?.[path];
|
|
434
472
|
}
|
|
435
473
|
|
|
436
|
-
const { columns, customFieldColumnNames } = useGeneratedColumns({
|
|
437
|
-
fields,
|
|
438
|
-
customizeColumns,
|
|
439
|
-
rowActions,
|
|
440
|
-
bulkActions,
|
|
441
|
-
deleteMutation,
|
|
442
|
-
additionalColumns,
|
|
443
|
-
defaultColumnOrder,
|
|
444
|
-
});
|
|
445
|
-
|
|
446
|
-
const columnVisibility = getColumnVisibility(fields, defaultVisibility, customFieldColumnNames);
|
|
447
474
|
const transformedData =
|
|
448
475
|
typeof transformData === 'function' ? transformData(listData?.items ?? []) : (listData?.items ?? []);
|
|
449
476
|
return (
|