@vendure/dashboard 3.4.3-master-202509180227 → 3.4.3-master-202509200226

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 (54) hide show
  1. package/dist/vite/vite-plugin-config.js +1 -0
  2. package/package.json +11 -7
  3. package/src/app/common/duplicate-bulk-action.tsx +37 -23
  4. package/src/app/common/duplicate-entity-dialog.tsx +117 -0
  5. package/src/app/routes/_authenticated/_administrators/administrators.tsx +1 -2
  6. package/src/app/routes/_authenticated/_assets/assets.graphql.ts +39 -0
  7. package/src/app/routes/_authenticated/_assets/assets_.$id.tsx +18 -7
  8. package/src/app/routes/_authenticated/_assets/components/asset-tag-filter.tsx +206 -0
  9. package/src/app/routes/_authenticated/_assets/components/asset-tags-editor.tsx +226 -0
  10. package/src/app/routes/_authenticated/_assets/components/manage-tags-dialog.tsx +217 -0
  11. package/src/app/routes/_authenticated/_channels/channels.tsx +1 -2
  12. package/src/app/routes/_authenticated/_collections/collections.tsx +2 -16
  13. package/src/app/routes/_authenticated/_countries/countries.tsx +1 -2
  14. package/src/app/routes/_authenticated/_customer-groups/customer-groups.tsx +1 -2
  15. package/src/app/routes/_authenticated/_customers/customers.tsx +1 -2
  16. package/src/app/routes/_authenticated/_facets/facets.tsx +0 -1
  17. package/src/app/routes/_authenticated/_payment-methods/payment-methods.tsx +1 -2
  18. package/src/app/routes/_authenticated/_product-variants/product-variants.tsx +1 -2
  19. package/src/app/routes/_authenticated/_products/products.tsx +1 -2
  20. package/src/app/routes/_authenticated/_promotions/promotions.tsx +1 -2
  21. package/src/app/routes/_authenticated/_roles/roles.tsx +1 -2
  22. package/src/app/routes/_authenticated/_sellers/sellers.tsx +1 -2
  23. package/src/app/routes/_authenticated/_shipping-methods/shipping-methods.tsx +1 -2
  24. package/src/app/routes/_authenticated/_stock-locations/stock-locations.tsx +1 -2
  25. package/src/app/routes/_authenticated/_tax-categories/tax-categories.tsx +1 -2
  26. package/src/app/routes/_authenticated/_tax-rates/tax-rates.tsx +1 -2
  27. package/src/app/routes/_authenticated/_zones/zones.tsx +1 -2
  28. package/src/lib/components/data-input/rich-text-input.tsx +2 -115
  29. package/src/lib/components/data-table/data-table-bulk-actions.tsx +5 -14
  30. package/src/lib/components/data-table/use-all-bulk-actions.ts +19 -0
  31. package/src/lib/components/data-table/use-generated-columns.tsx +12 -3
  32. package/src/lib/components/layout/nav-main.tsx +50 -25
  33. package/src/lib/components/shared/asset/asset-focal-point-editor.tsx +1 -1
  34. package/src/lib/components/shared/asset/asset-gallery.tsx +83 -50
  35. package/src/lib/components/shared/paginated-list-data-table.tsx +1 -0
  36. package/src/lib/components/shared/rich-text-editor/image-dialog.tsx +223 -0
  37. package/src/lib/components/shared/rich-text-editor/link-dialog.tsx +151 -0
  38. package/src/lib/components/shared/rich-text-editor/responsive-toolbar.tsx +439 -0
  39. package/src/lib/components/shared/rich-text-editor/rich-text-editor.tsx +338 -0
  40. package/src/lib/components/shared/rich-text-editor/table-delete-menu.tsx +104 -0
  41. package/src/lib/components/shared/rich-text-editor/table-edit-icons.tsx +225 -0
  42. package/src/lib/components/shared/vendure-image.tsx +9 -1
  43. package/src/lib/framework/defaults.ts +24 -0
  44. package/src/lib/framework/extension-api/types/navigation.ts +8 -0
  45. package/src/lib/framework/nav-menu/nav-menu-extensions.ts +26 -0
  46. package/src/lib/framework/page/list-page.tsx +7 -0
  47. package/src/lib/graphql/common-operations.ts +19 -0
  48. package/src/lib/graphql/fragments.ts +23 -13
  49. package/src/lib/hooks/use-custom-field-config.ts +19 -2
  50. package/src/lib/index.ts +0 -1
  51. package/src/lib/providers/channel-provider.tsx +22 -6
  52. package/src/lib/providers/server-config.tsx +1 -0
  53. package/src/app/routes/_authenticated/_collections/components/move-single-collection.tsx +0 -33
  54. package/src/lib/components/shared/asset/focal-point-control.tsx +0 -57
@@ -47,6 +47,7 @@ export function viteConfigPlugin({ packageRoot }) {
47
47
  ...(((_e = config.optimizeDeps) === null || _e === void 0 ? void 0 : _e.include) || []),
48
48
  '@/components > recharts',
49
49
  '@/components > react-dropzone',
50
+ '@/components > @tiptap/react', // https://github.com/fawmi/vue-google-maps/issues/148#issuecomment-1235143844
50
51
  '@vendure/common/lib/generated-types',
51
52
  ] });
52
53
  return config;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vendure/dashboard",
3
3
  "private": false,
4
- "version": "3.4.3-master-202509180227",
4
+ "version": "3.4.3-master-202509200226",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -94,14 +94,18 @@
94
94
  "@tanstack/react-table": "^8.21.2",
95
95
  "@tanstack/router-devtools": "^1.105.0",
96
96
  "@tanstack/router-plugin": "^1.105.0",
97
- "@tiptap/pm": "^2.11.5",
98
- "@tiptap/react": "^2.11.5",
99
- "@tiptap/starter-kit": "^2.11.5",
97
+ "@tiptap/extension-floating-menu": "^3.4.4",
98
+ "@tiptap/extension-image": "^3.4.4",
99
+ "@tiptap/extension-table": "^3.4.4",
100
+ "@tiptap/extension-text-style": "^3.4.4",
101
+ "@tiptap/pm": "^3.4.4",
102
+ "@tiptap/react": "^3.4.4",
103
+ "@tiptap/starter-kit": "^3.4.4",
100
104
  "@types/react": "^19.0.10",
101
105
  "@types/react-dom": "^19.0.4",
102
106
  "@uidotdev/usehooks": "^2.4.1",
103
- "@vendure/common": "^3.4.3-master-202509180227",
104
- "@vendure/core": "^3.4.3-master-202509180227",
107
+ "@vendure/common": "^3.4.3-master-202509200226",
108
+ "@vendure/core": "^3.4.3-master-202509200226",
105
109
  "@vitejs/plugin-react": "^4.3.4",
106
110
  "acorn": "^8.11.3",
107
111
  "acorn-walk": "^8.3.2",
@@ -152,5 +156,5 @@
152
156
  "lightningcss-linux-arm64-musl": "^1.29.3",
153
157
  "lightningcss-linux-x64-musl": "^1.29.1"
154
158
  },
155
- "gitHead": "abb4130cebdea8f79dcfb0823d806293bb0d2a1b"
159
+ "gitHead": "e2f1befe89f08f48faf285a6a929ca2a4042c903"
156
160
  }
@@ -1,4 +1,5 @@
1
1
  import { useMutation } from '@tanstack/react-query';
2
+ import { ConfigurableOperationInput } from '@vendure/common/lib/generated-types';
2
3
  import { CopyIcon } from 'lucide-react';
3
4
  import { useState } from 'react';
4
5
  import { toast } from 'sonner';
@@ -8,13 +9,13 @@ import { usePaginatedList } from '@/vdb/components/shared/paginated-list-data-ta
8
9
  import { api } from '@/vdb/graphql/api.js';
9
10
  import { duplicateEntityDocument } from '@/vdb/graphql/common-operations.js';
10
11
  import { Trans, useLingui } from '@/vdb/lib/trans.js';
12
+ import { DuplicateEntityDialog } from './duplicate-entity-dialog.js';
11
13
 
12
14
  interface DuplicateBulkActionProps {
13
15
  entityType: 'Product' | 'Collection' | 'Facet' | 'Promotion';
14
16
  duplicatorCode: string;
15
- duplicatorArguments?: Array<{ name: string; value: string }>;
16
17
  requiredPermissions: string[];
17
- entityName: string; // For display purposes in error messages
18
+ entityName: string;
18
19
  onSuccess?: () => void;
19
20
  selection: any[];
20
21
  table: any;
@@ -23,23 +24,28 @@ interface DuplicateBulkActionProps {
23
24
  export function DuplicateBulkAction({
24
25
  entityType,
25
26
  duplicatorCode,
26
- duplicatorArguments = [],
27
27
  requiredPermissions,
28
28
  entityName,
29
29
  onSuccess,
30
30
  selection,
31
31
  table,
32
- }: DuplicateBulkActionProps) {
32
+ }: Readonly<DuplicateBulkActionProps>) {
33
33
  const { refetchPaginatedList } = usePaginatedList();
34
34
  const { i18n } = useLingui();
35
35
  const [isDuplicating, setIsDuplicating] = useState(false);
36
36
  const [progress, setProgress] = useState({ completed: 0, total: 0 });
37
+ const [dialogOpen, setDialogOpen] = useState(false);
37
38
 
38
39
  const { mutateAsync } = useMutation({
39
40
  mutationFn: api.mutate(duplicateEntityDocument),
40
41
  });
41
42
 
42
- const handleDuplicate = async () => {
43
+ const handleStartDuplication = () => {
44
+ if (isDuplicating) return;
45
+ setDialogOpen(true);
46
+ };
47
+
48
+ const handleConfirmDuplication = async (duplicatorInput: ConfigurableOperationInput) => {
43
49
  if (isDuplicating) return;
44
50
 
45
51
  setIsDuplicating(true);
@@ -61,10 +67,7 @@ export function DuplicateBulkAction({
61
67
  input: {
62
68
  entityName: entityType,
63
69
  entityId: entity.id,
64
- duplicatorInput: {
65
- code: duplicatorCode,
66
- arguments: duplicatorArguments,
67
- },
70
+ duplicatorInput,
68
71
  },
69
72
  });
70
73
 
@@ -116,19 +119,30 @@ export function DuplicateBulkAction({
116
119
  };
117
120
 
118
121
  return (
119
- <DataTableBulkActionItem
120
- requiresPermission={requiredPermissions}
121
- onClick={handleDuplicate}
122
- label={
123
- isDuplicating ? (
124
- <Trans>
125
- Duplicating... ({progress.completed}/{progress.total})
126
- </Trans>
127
- ) : (
128
- <Trans>Duplicate</Trans>
129
- )
130
- }
131
- icon={CopyIcon}
132
- />
122
+ <>
123
+ <DataTableBulkActionItem
124
+ requiresPermission={requiredPermissions}
125
+ onClick={handleStartDuplication}
126
+ label={
127
+ isDuplicating ? (
128
+ <Trans>
129
+ Duplicating... ({progress.completed}/{progress.total})
130
+ </Trans>
131
+ ) : (
132
+ <Trans>Duplicate</Trans>
133
+ )
134
+ }
135
+ icon={CopyIcon}
136
+ />
137
+ <DuplicateEntityDialog
138
+ open={dialogOpen}
139
+ onOpenChange={setDialogOpen}
140
+ entityType={entityType}
141
+ entityName={entityName}
142
+ entities={selection}
143
+ duplicatorCode={duplicatorCode}
144
+ onConfirm={handleConfirmDuplication}
145
+ />
146
+ </>
133
147
  );
134
148
  }
@@ -0,0 +1,117 @@
1
+ import { ConfigurableOperationInput as ConfigurableOperationInputComponent } from '@/vdb/components/shared/configurable-operation-input.js';
2
+ import { Button } from '@/vdb/components/ui/button.js';
3
+ import {
4
+ Dialog,
5
+ DialogContent,
6
+ DialogFooter,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ } from '@/vdb/components/ui/dialog.js';
10
+ import { api } from '@/vdb/graphql/api.js';
11
+ import { getEntityDuplicatorsDocument } from '@/vdb/graphql/common-operations.js';
12
+ import { Trans } from '@/vdb/lib/trans.js';
13
+ import { useQuery } from '@tanstack/react-query';
14
+ import { ConfigurableOperationInput } from '@vendure/common/lib/generated-types';
15
+ import React, { useState } from 'react';
16
+
17
+ interface DuplicateEntityDialogProps {
18
+ open: boolean;
19
+ onOpenChange: (open: boolean) => void;
20
+ entityType: 'Product' | 'Collection' | 'Facet' | 'Promotion';
21
+ entityName: string;
22
+ entities: Array<{ id: string; name?: string }>;
23
+ duplicatorCode: string;
24
+ onConfirm: (duplicatorInput: ConfigurableOperationInput) => void;
25
+ }
26
+
27
+ export function DuplicateEntityDialog({
28
+ open,
29
+ onOpenChange,
30
+ entityType,
31
+ entityName,
32
+ duplicatorCode,
33
+ onConfirm,
34
+ }: Readonly<DuplicateEntityDialogProps>) {
35
+ const [selectedDuplicator, setSelectedDuplicator] = useState<ConfigurableOperationInput | undefined>();
36
+
37
+ const { data } = useQuery({
38
+ queryKey: ['entityDuplicators'],
39
+ queryFn: () => api.query(getEntityDuplicatorsDocument),
40
+ staleTime: 1000 * 60 * 60 * 5,
41
+ });
42
+
43
+ // Find the duplicator that matches the provided code and supports this entity type
44
+ const matchingDuplicator = data?.entityDuplicators?.find(
45
+ duplicator => duplicator.code === duplicatorCode && duplicator.forEntities.includes(entityType),
46
+ );
47
+
48
+ // Auto-initialize the duplicator when found
49
+ React.useEffect(() => {
50
+ if (matchingDuplicator && !selectedDuplicator) {
51
+ setSelectedDuplicator({
52
+ code: matchingDuplicator.code,
53
+ arguments:
54
+ matchingDuplicator.args?.map(arg => ({
55
+ name: arg.name,
56
+ value: arg.defaultValue != null ? arg.defaultValue.toString() : '',
57
+ })) || [],
58
+ });
59
+ }
60
+ }, [matchingDuplicator, selectedDuplicator]);
61
+
62
+ const onDuplicatorValueChange = (newVal: ConfigurableOperationInput) => {
63
+ setSelectedDuplicator(newVal);
64
+ };
65
+
66
+ const handleConfirm = () => {
67
+ if (!selectedDuplicator) return;
68
+ onConfirm(selectedDuplicator);
69
+ onOpenChange(false);
70
+ setSelectedDuplicator(undefined);
71
+ };
72
+
73
+ const handleCancel = () => {
74
+ onOpenChange(false);
75
+ setSelectedDuplicator(undefined);
76
+ };
77
+
78
+ return (
79
+ <Dialog open={open} onOpenChange={onOpenChange}>
80
+ <DialogContent className="sm:max-w-lg">
81
+ <DialogHeader>
82
+ <DialogTitle>
83
+ <Trans>Duplicate {entityName.toLowerCase()}s</Trans>
84
+ </DialogTitle>
85
+ </DialogHeader>
86
+
87
+ <div className="space-y-4">
88
+ {selectedDuplicator && matchingDuplicator && (
89
+ <ConfigurableOperationInputComponent
90
+ operationDefinition={matchingDuplicator}
91
+ value={selectedDuplicator}
92
+ onChange={onDuplicatorValueChange}
93
+ removable={false}
94
+ />
95
+ )}
96
+
97
+ {!matchingDuplicator && (
98
+ <div className="text-sm text-muted-foreground">
99
+ <Trans>
100
+ No duplicator found with code "{duplicatorCode}" for {entityName}s
101
+ </Trans>
102
+ </div>
103
+ )}
104
+ </div>
105
+
106
+ <DialogFooter>
107
+ <Button variant="outline" onClick={handleCancel}>
108
+ <Trans>Cancel</Trans>
109
+ </Button>
110
+ <Button onClick={handleConfirm} disabled={!selectedDuplicator}>
111
+ <Trans>Duplicate</Trans>
112
+ </Button>
113
+ </DialogFooter>
114
+ </DialogContent>
115
+ </Dialog>
116
+ );
117
+ }
@@ -8,7 +8,7 @@ import { ListPage } from '@/vdb/framework/page/list-page.js';
8
8
  import { Trans } from '@/vdb/lib/trans.js';
9
9
  import { createFileRoute, Link } from '@tanstack/react-router';
10
10
  import { PlusIcon } from 'lucide-react';
11
- import { administratorListDocument, deleteAdministratorDocument } from './administrators.graphql.js';
11
+ import { administratorListDocument } from './administrators.graphql.js';
12
12
  import { DeleteAdministratorsBulkAction } from './components/administrator-bulk-actions.js';
13
13
 
14
14
  export const Route = createFileRoute('/_authenticated/_administrators/administrators')({
@@ -22,7 +22,6 @@ function AdministratorListPage() {
22
22
  pageId="administrator-list"
23
23
  title="Administrators"
24
24
  listQuery={administratorListDocument}
25
- deleteMutation={deleteAdministratorDocument}
26
25
  route={Route}
27
26
  onSearchTermChange={searchTerm => {
28
27
  return {
@@ -35,3 +35,42 @@ export const deleteAssetsDocument = graphql(`
35
35
  }
36
36
  }
37
37
  `);
38
+
39
+ export const tagListDocument = graphql(`
40
+ query TagList($options: TagListOptions) {
41
+ tags(options: $options) {
42
+ items {
43
+ id
44
+ value
45
+ }
46
+ totalItems
47
+ }
48
+ }
49
+ `);
50
+
51
+ export const createTagDocument = graphql(`
52
+ mutation CreateTag($input: CreateTagInput!) {
53
+ createTag(input: $input) {
54
+ id
55
+ value
56
+ }
57
+ }
58
+ `);
59
+
60
+ export const updateTagDocument = graphql(`
61
+ mutation UpdateTag($input: UpdateTagInput!) {
62
+ updateTag(input: $input) {
63
+ id
64
+ value
65
+ }
66
+ }
67
+ `);
68
+
69
+ export const deleteTagDocument = graphql(`
70
+ mutation DeleteTag($id: ID!) {
71
+ deleteTag(id: $id) {
72
+ result
73
+ message
74
+ }
75
+ }
76
+ `);
@@ -2,7 +2,6 @@ import { AssetFocalPointEditor } from '@/vdb/components/shared/asset/asset-focal
2
2
  import { AssetPreviewSelector } from '@/vdb/components/shared/asset/asset-preview-selector.js';
3
3
  import { PreviewPreset } from '@/vdb/components/shared/asset/asset-preview.js';
4
4
  import { AssetProperties } from '@/vdb/components/shared/asset/asset-properties.js';
5
- import { Point } from '@/vdb/components/shared/asset/focal-point-control.js';
6
5
  import { ErrorPage } from '@/vdb/components/shared/error-page.js';
7
6
  import { PermissionGuard } from '@/vdb/components/shared/permission-guard.js';
8
7
  import { VendureImage } from '@/vdb/components/shared/vendure-image.js';
@@ -25,6 +24,7 @@ import { FocusIcon } from 'lucide-react';
25
24
  import { useRef, useState } from 'react';
26
25
  import { toast } from 'sonner';
27
26
  import { assetDetailDocument, assetUpdateDocument } from './assets.graphql.js';
27
+ import { AssetTagsEditor } from './components/asset-tags-editor.js';
28
28
 
29
29
  const pageId = 'asset-detail';
30
30
 
@@ -51,9 +51,8 @@ function AssetDetailPage() {
51
51
  const [size, setSize] = useState<PreviewPreset>('medium');
52
52
  const [width, setWidth] = useState(0);
53
53
  const [height, setHeight] = useState(0);
54
- const [focalPoint, setFocalPoint] = useState<Point | undefined>(undefined);
55
54
  const [settingFocalPoint, setSettingFocalPoint] = useState(false);
56
- const { form, submitHandler, entity, isPending, resetForm } = useDetailPage({
55
+ const { form, submitHandler, entity, isPending, refreshEntity } = useDetailPage({
57
56
  pageId,
58
57
  queryDocument: assetDetailDocument,
59
58
  updateDocument: assetUpdateDocument,
@@ -106,12 +105,12 @@ function AssetDetailPage() {
106
105
  </PageActionBar>
107
106
  <PageLayout>
108
107
  <PageBlock column="main" blockId="asset-preview">
109
- <div className="relative flex items-center justify-center bg-muted/30 rounded-lg min-h-[300px] overflow-auto">
108
+ <div className="relative flex items-center justify-center bg-muted/30 rounded-lg min-h-[300px] overflow-auto resize-y">
110
109
  <AssetFocalPointEditor
111
110
  width={width}
112
111
  height={height}
113
112
  settingFocalPoint={settingFocalPoint}
114
- focalPoint={form.getValues().focalPoint ?? { x: 0.5, y: 0.5 }}
113
+ focalPoint={entity.focalPoint ?? { x: 0.5, y: 0.5 }}
115
114
  onFocalPointChange={point => {
116
115
  form.setValue('focalPoint.x', point.x, { shouldDirty: true });
117
116
  form.setValue('focalPoint.y', point.y, { shouldDirty: true });
@@ -125,10 +124,9 @@ function AssetDetailPage() {
125
124
  ref={imageRef}
126
125
  asset={entity}
127
126
  preset={size || undefined}
128
- mode="resize"
129
127
  useFocalPoint={true}
130
128
  onLoad={updateDimensions}
131
- className="max-w-full max-h-full object-contain"
129
+ className="max-w-full object-contain"
132
130
  />
133
131
  </AssetFocalPointEditor>
134
132
  </div>
@@ -164,6 +162,19 @@ function AssetDetailPage() {
164
162
  </div>
165
163
  </div>
166
164
  </PageBlock>
165
+ <PageBlock column="side" blockId="asset-tags">
166
+ <AssetTagsEditor
167
+ selectedTags={form.watch('tags') || []}
168
+ onTagsChange={tags => {
169
+ form.setValue('tags', tags, { shouldDirty: true });
170
+ }}
171
+ onTagsUpdated={() => {
172
+ // Refresh the asset entity to get updated tag values
173
+ refreshEntity();
174
+ }}
175
+ disabled={isPending}
176
+ />
177
+ </PageBlock>
167
178
  </PageLayout>
168
179
  </Page>
169
180
  );
@@ -0,0 +1,206 @@
1
+ import { Badge } from '@/vdb/components/ui/badge.js';
2
+ import { Button } from '@/vdb/components/ui/button.js';
3
+ import {
4
+ Command,
5
+ CommandEmpty,
6
+ CommandGroup,
7
+ CommandInput,
8
+ CommandItem,
9
+ CommandList,
10
+ } from '@/vdb/components/ui/command.js';
11
+ import { Popover, PopoverContent, PopoverTrigger } from '@/vdb/components/ui/popover.js';
12
+ import { api } from '@/vdb/graphql/api.js';
13
+ import { Trans } from '@/vdb/lib/trans.js';
14
+ import { cn } from '@/vdb/lib/utils.js';
15
+ import { useInfiniteQuery } from '@tanstack/react-query';
16
+ import { useDebounce } from '@uidotdev/usehooks';
17
+ import { Check, Filter, Loader2, X } from 'lucide-react';
18
+ import { useState } from 'react';
19
+ import { tagListDocument } from '../assets.graphql.js';
20
+
21
+ interface AssetTagFilterProps {
22
+ selectedTags: string[];
23
+ onTagsChange: (tags: string[]) => void;
24
+ }
25
+
26
+ export function AssetTagFilter({ selectedTags, onTagsChange }: Readonly<AssetTagFilterProps>) {
27
+ const [open, setOpen] = useState(false);
28
+ const [searchValue, setSearchValue] = useState('');
29
+
30
+ const debouncedSearch = useDebounce(searchValue, 300);
31
+ const pageSize = 25;
32
+
33
+ // Fetch available tags with infinite query
34
+ const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
35
+ queryKey: ['tags', debouncedSearch],
36
+ queryFn: async ({ pageParam = 0 }) => {
37
+ const options: any = {
38
+ skip: pageParam * pageSize,
39
+ take: pageSize,
40
+ sort: { value: 'ASC' },
41
+ };
42
+
43
+ if (debouncedSearch.trim()) {
44
+ options.filter = {
45
+ value: { contains: debouncedSearch.trim() },
46
+ };
47
+ }
48
+
49
+ const response = await api.query(tagListDocument, { options });
50
+ return response.tags;
51
+ },
52
+ getNextPageParam: (lastPage, allPages) => {
53
+ if (!lastPage) return undefined;
54
+ const totalFetched = allPages.length * pageSize;
55
+ return totalFetched < lastPage.totalItems ? allPages.length : undefined;
56
+ },
57
+ initialPageParam: 0,
58
+ staleTime: 1000 * 60 * 5,
59
+ });
60
+
61
+ const availableTags = data?.pages.flatMap(page => page?.items ?? []) ?? [];
62
+ const totalTags = data?.pages[0]?.totalItems ?? 0;
63
+
64
+ // Tags are already filtered server-side, so use them directly
65
+ const filteredTags = availableTags;
66
+
67
+ const handleSelectTag = (tagValue: string) => {
68
+ if (!selectedTags.includes(tagValue)) {
69
+ onTagsChange([...selectedTags, tagValue]);
70
+ }
71
+ setSearchValue('');
72
+ };
73
+
74
+ const handleRemoveTag = (tagToRemove: string) => {
75
+ onTagsChange(selectedTags.filter(tag => tag !== tagToRemove));
76
+ };
77
+
78
+ const handleClearAll = () => {
79
+ onTagsChange([]);
80
+ setOpen(false);
81
+ };
82
+
83
+ const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
84
+ const target = e.currentTarget;
85
+ const scrolledToBottom = Math.abs(target.scrollHeight - target.clientHeight - target.scrollTop) < 1;
86
+
87
+ if (scrolledToBottom && hasNextPage && !isFetchingNextPage) {
88
+ fetchNextPage();
89
+ }
90
+ };
91
+
92
+ return (
93
+ <div className="flex items-center gap-2">
94
+ <Popover open={open} onOpenChange={setOpen}>
95
+ <PopoverTrigger asChild>
96
+ <Button
97
+ variant="outline"
98
+ size="sm"
99
+ role="combobox"
100
+ aria-expanded={open}
101
+ className="justify-start"
102
+ >
103
+ <Filter className="h-4 w-4 mr-2" />
104
+ <Trans>Filter by tags</Trans>
105
+ {selectedTags.length > 0 && (
106
+ <Badge variant="secondary" className="ml-2">
107
+ {selectedTags.length}
108
+ </Badge>
109
+ )}
110
+ </Button>
111
+ </PopoverTrigger>
112
+ <PopoverContent className="w-80 p-0" align="start">
113
+ <Command shouldFilter={false}>
114
+ <CommandInput
115
+ placeholder="Search tags..."
116
+ value={searchValue}
117
+ onValueChange={setSearchValue}
118
+ />
119
+ <CommandList className="max-h-[300px] overflow-y-auto" onScroll={handleScroll}>
120
+ <CommandEmpty>
121
+ {isLoading ? (
122
+ <div className="flex items-center justify-center py-6">
123
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />
124
+ <Trans>Loading...</Trans>
125
+ </div>
126
+ ) : (
127
+ <div className="p-2 text-sm">
128
+ <Trans>No tags found</Trans>
129
+ </div>
130
+ )}
131
+ </CommandEmpty>
132
+ <CommandGroup>
133
+ {filteredTags.map(tag => {
134
+ const isSelected = selectedTags.includes(tag.value);
135
+ return (
136
+ <CommandItem
137
+ key={tag.id}
138
+ onSelect={() => {
139
+ if (isSelected) {
140
+ handleRemoveTag(tag.value);
141
+ } else {
142
+ handleSelectTag(tag.value);
143
+ }
144
+ }}
145
+ >
146
+ <Check
147
+ className={cn(
148
+ 'mr-2 h-4 w-4',
149
+ isSelected ? 'opacity-100' : 'opacity-0',
150
+ )}
151
+ />
152
+ {tag.value}
153
+ </CommandItem>
154
+ );
155
+ })}
156
+
157
+ {(isFetchingNextPage || isLoading) && (
158
+ <div className="flex items-center justify-center py-2">
159
+ <Loader2 className="h-4 w-4 animate-spin" />
160
+ </div>
161
+ )}
162
+
163
+ {!hasNextPage &&
164
+ filteredTags.length > 0 &&
165
+ totalTags > filteredTags.length && (
166
+ <div className="text-center py-2 text-xs text-muted-foreground">
167
+ <Trans>Showing all {filteredTags.length} results</Trans>
168
+ </div>
169
+ )}
170
+ </CommandGroup>
171
+ </CommandList>
172
+ {selectedTags.length > 0 && (
173
+ <div className="border-t p-2">
174
+ <Button
175
+ variant="ghost"
176
+ size="sm"
177
+ className="w-full justify-start"
178
+ onClick={handleClearAll}
179
+ >
180
+ <Trans>Clear all</Trans>
181
+ </Button>
182
+ </div>
183
+ )}
184
+ </Command>
185
+ </PopoverContent>
186
+ </Popover>
187
+
188
+ {/* Display selected tags */}
189
+ {selectedTags.length > 0 && (
190
+ <div className="flex flex-wrap gap-1">
191
+ {selectedTags.map(tag => (
192
+ <Badge key={tag} variant="secondary" className="text-xs">
193
+ {tag}
194
+ <button
195
+ onClick={() => handleRemoveTag(tag)}
196
+ className="ml-1 hover:bg-destructive/20 rounded-full p-0.5 transition-colors"
197
+ >
198
+ <X className="h-3 w-3" />
199
+ </button>
200
+ </Badge>
201
+ ))}
202
+ </div>
203
+ )}
204
+ </div>
205
+ );
206
+ }