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

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/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-202509190229",
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-202509190229",
108
+ "@vendure/core": "^3.4.3-master-202509190229",
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": "1a94626a2e07d33d881a751ff394f8351c3521b4"
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
+ }
@@ -1,26 +1,6 @@
1
1
  import { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
2
2
  import { isReadonlyField } from '@/vdb/framework/form-engine/utils.js';
3
- import TextStyle from '@tiptap/extension-text-style';
4
- import { BubbleMenu, Editor, EditorContent, useEditor } from '@tiptap/react';
5
- import StarterKit from '@tiptap/starter-kit';
6
- import { BoldIcon, ItalicIcon, StrikethroughIcon } from 'lucide-react';
7
- import { useLayoutEffect, useRef } from 'react';
8
- import { Button } from '../ui/button.js';
9
-
10
- // define your extension array
11
- const extensions = [
12
- TextStyle.configure(),
13
- StarterKit.configure({
14
- bulletList: {
15
- keepMarks: true,
16
- keepAttributes: false, // TODO : Making this as `false` becase marks are not preserved when I try to preserve attrs, awaiting a bit of help
17
- },
18
- orderedList: {
19
- keepMarks: true,
20
- keepAttributes: false, // TODO : Making this as `false` becase marks are not preserved when I try to preserve attrs, awaiting a bit of help
21
- },
22
- }),
23
- ];
3
+ import { RichTextEditor } from '../shared/rich-text-editor/rich-text-editor.js';
24
4
 
25
5
  /**
26
6
  * @description
@@ -31,99 +11,6 @@ const extensions = [
31
11
  */
32
12
  export function RichTextInput({ value, onChange, fieldDef }: Readonly<DashboardFormComponentProps>) {
33
13
  const readOnly = isReadonlyField(fieldDef);
34
- const isInternalUpdate = useRef(false);
35
-
36
- const editor = useEditor({
37
- parseOptions: {
38
- preserveWhitespace: 'full',
39
- },
40
- extensions: extensions,
41
- content: value,
42
- editable: !readOnly,
43
- onUpdate: ({ editor }) => {
44
- if (!readOnly) {
45
- isInternalUpdate.current = true;
46
- console.log('onUpdate');
47
- const newValue = editor.getHTML();
48
- if (value !== newValue) {
49
- onChange(newValue);
50
- }
51
- }
52
- },
53
- editorProps: {
54
- attributes: {
55
- class: `border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/10 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm max-h-[500px] overflow-y-auto ${readOnly ? 'cursor-not-allowed opacity-50' : ''}`,
56
- },
57
- },
58
- });
59
-
60
- useLayoutEffect(() => {
61
- if (editor && !isInternalUpdate.current) {
62
- const currentContent = editor.getHTML();
63
- if (currentContent !== value) {
64
- const { from, to } = editor.state.selection;
65
- editor.commands.setContent(value, false);
66
- editor.commands.setTextSelection({ from, to });
67
- }
68
- }
69
- isInternalUpdate.current = false;
70
- }, [value, editor]);
71
-
72
- // Update editor's editable state when disabled prop changes
73
- useLayoutEffect(() => {
74
- if (editor) {
75
- editor.setEditable(!readOnly, false);
76
- }
77
- }, [readOnly, editor]);
78
-
79
- if (!editor) {
80
- return null;
81
- }
82
-
83
- return (
84
- <>
85
- <EditorContent editor={editor} />
86
- <CustomBubbleMenu editor={editor} disabled={readOnly} />
87
- </>
88
- );
89
- }
90
14
 
91
- function CustomBubbleMenu({ editor, disabled }: { editor: Editor | null; disabled?: boolean }) {
92
- if (!editor || disabled) return null;
93
- return (
94
- <BubbleMenu editor={editor}>
95
- <div className="flex items-center gap-2 bg-background p-2 rounded-md border">
96
- <Button
97
- type="button"
98
- variant="ghost"
99
- size="icon"
100
- onClick={() => editor.chain().focus().toggleBold().run()}
101
- className={editor.isActive('bold') ? 'bg-accent' : ''}
102
- disabled={disabled}
103
- >
104
- <BoldIcon className="w-4 h-4" />
105
- </Button>
106
- <Button
107
- type="button"
108
- variant="ghost"
109
- size="icon"
110
- onClick={() => editor.chain().focus().toggleItalic().run()}
111
- className={editor.isActive('italic') ? 'bg-accent' : ''}
112
- disabled={disabled}
113
- >
114
- <ItalicIcon className="w-4 h-4" />
115
- </Button>
116
- <Button
117
- type="button"
118
- variant="ghost"
119
- size="icon"
120
- onClick={() => editor.chain().focus().toggleStrike().run()}
121
- className={editor.isActive('strike') ? 'bg-accent' : ''}
122
- disabled={disabled}
123
- >
124
- <StrikethroughIcon className="w-4 h-4" />
125
- </Button>
126
- </div>
127
- </BubbleMenu>
128
- );
15
+ return <RichTextEditor value={value} onChange={onChange} disabled={readOnly} />;
129
16
  }
@@ -0,0 +1,223 @@
1
+ import { Trans } from '@/vdb/lib/trans.js';
2
+ import { SetImageOptions } from '@tiptap/extension-image';
3
+ import { Editor } from '@tiptap/react';
4
+ import { ImageIcon, PaperclipIcon } from 'lucide-react';
5
+ import { useEffect, useState } from 'react';
6
+ import { Button } from '../../ui/button.js';
7
+ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog.js';
8
+ import { Input } from '../../ui/input.js';
9
+ import { Label } from '../../ui/label.js';
10
+ import { Asset } from '../asset/asset-gallery.js';
11
+ import { AssetPickerDialog } from '../asset/asset-picker-dialog.js';
12
+
13
+ export interface ImageDialogProps {
14
+ editor: Editor;
15
+ isOpen: boolean;
16
+ onClose: () => void;
17
+ }
18
+
19
+ export function ImageDialog({ editor, isOpen, onClose }: Readonly<ImageDialogProps>) {
20
+ const [src, setSrc] = useState('');
21
+ const [alt, setAlt] = useState('');
22
+ const [title, setTitle] = useState('');
23
+ const [width, setWidth] = useState('');
24
+ const [height, setHeight] = useState('');
25
+ const [assetPickerOpen, setAssetPickerOpen] = useState(false);
26
+ const [previewUrl, setPreviewUrl] = useState('');
27
+
28
+ useEffect(() => {
29
+ if (isOpen) {
30
+ // Get current image attributes if editing existing image
31
+ const {
32
+ src: currentSrc,
33
+ alt: currentAlt,
34
+ title: currentTitle,
35
+ width: currentWidth,
36
+ height: currentHeight,
37
+ } = editor.getAttributes('image');
38
+
39
+ setSrc(currentSrc || '');
40
+ setAlt(currentAlt || '');
41
+ setTitle(currentTitle || '');
42
+ setWidth(currentWidth || '');
43
+ setHeight(currentHeight || '');
44
+ setPreviewUrl(currentSrc || '');
45
+ }
46
+ }, [isOpen, editor]);
47
+
48
+ const handleInsertImage = () => {
49
+ if (!src) {
50
+ return;
51
+ }
52
+
53
+ const attrs: SetImageOptions = {
54
+ src,
55
+ alt: alt || undefined,
56
+ title: title || undefined,
57
+ };
58
+
59
+ // Only add width/height if they are valid numbers
60
+ if (width && !isNaN(Number(width))) {
61
+ attrs.width = Number(width);
62
+ }
63
+ if (height && !isNaN(Number(height))) {
64
+ attrs.height = Number(height);
65
+ }
66
+
67
+ editor.chain().focus().setImage(attrs).run();
68
+
69
+ handleClose();
70
+ };
71
+
72
+ const handleClose = () => {
73
+ setSrc('');
74
+ setAlt('');
75
+ setTitle('');
76
+ setWidth('');
77
+ setHeight('');
78
+ setPreviewUrl('');
79
+ onClose();
80
+ };
81
+
82
+ const handleAssetSelect = (assets: Asset[]) => {
83
+ if (assets.length > 0) {
84
+ const asset = assets[0];
85
+ setSrc(asset.source);
86
+ setPreviewUrl(asset.preview);
87
+ // Set width and height from asset if available
88
+ if (asset.width) {
89
+ setWidth(asset.width.toString());
90
+ }
91
+ if (asset.height) {
92
+ setHeight(asset.height.toString());
93
+ }
94
+ }
95
+ setAssetPickerOpen(false);
96
+ };
97
+
98
+ const isEditing = editor.isActive('image');
99
+
100
+ return (
101
+ <>
102
+ <Dialog open={isOpen} onOpenChange={open => !open && handleClose()}>
103
+ <DialogContent className="sm:max-w-[525px]">
104
+ <DialogHeader>
105
+ <DialogTitle>
106
+ {isEditing ? <Trans>Edit image</Trans> : <Trans>Insert image</Trans>}
107
+ </DialogTitle>
108
+ </DialogHeader>
109
+ <div className="grid gap-4 py-4">
110
+ <div className="flex items-center justify-center">
111
+ {previewUrl ? (
112
+ <img
113
+ src={previewUrl}
114
+ alt={alt || 'Preview'}
115
+ className="max-w-[200px] max-h-[200px] object-contain border rounded"
116
+ />
117
+ ) : (
118
+ <div className="w-[200px] h-[200px] border rounded flex items-center justify-center bg-muted">
119
+ <ImageIcon className="w-16 h-16 text-muted-foreground" />
120
+ </div>
121
+ )}
122
+ </div>
123
+
124
+ <div className="flex items-center justify-center">
125
+ <Button
126
+ type="button"
127
+ variant="outline"
128
+ size="sm"
129
+ onClick={() => setAssetPickerOpen(true)}
130
+ >
131
+ <PaperclipIcon className="w-4 h-4 mr-2" />
132
+ <Trans>Add asset</Trans>
133
+ </Button>
134
+ </div>
135
+
136
+ <div className="grid gap-2">
137
+ <Label htmlFor="image-source">
138
+ <Trans>Source</Trans>
139
+ </Label>
140
+ <Input
141
+ id="image-source"
142
+ value={src}
143
+ onChange={e => {
144
+ setSrc(e.target.value);
145
+ setPreviewUrl(e.target.value);
146
+ }}
147
+ placeholder="https://example.com/image.jpg"
148
+ autoFocus
149
+ />
150
+ </div>
151
+
152
+ <div className="grid gap-2">
153
+ <Label htmlFor="image-title">
154
+ <Trans>Title</Trans>
155
+ </Label>
156
+ <Input
157
+ id="image-title"
158
+ value={title}
159
+ onChange={e => setTitle(e.target.value)}
160
+ placeholder=""
161
+ />
162
+ </div>
163
+
164
+ <div className="grid gap-2">
165
+ <Label htmlFor="image-alt">
166
+ <Trans>Description (alt)</Trans>
167
+ </Label>
168
+ <Input
169
+ id="image-alt"
170
+ value={alt}
171
+ onChange={e => setAlt(e.target.value)}
172
+ placeholder=""
173
+ />
174
+ </div>
175
+
176
+ <div className="grid grid-cols-2 gap-4">
177
+ <div className="grid gap-2">
178
+ <Label htmlFor="image-width">
179
+ <Trans>Width</Trans>
180
+ </Label>
181
+ <Input
182
+ id="image-width"
183
+ type="number"
184
+ value={width}
185
+ onChange={e => setWidth(e.target.value)}
186
+ placeholder="auto"
187
+ />
188
+ </div>
189
+ <div className="grid gap-2">
190
+ <Label htmlFor="image-height">
191
+ <Trans>Height</Trans>
192
+ </Label>
193
+ <Input
194
+ id="image-height"
195
+ type="number"
196
+ value={height}
197
+ onChange={e => setHeight(e.target.value)}
198
+ placeholder="auto"
199
+ />
200
+ </div>
201
+ </div>
202
+ </div>
203
+ <DialogFooter>
204
+ <Button type="button" variant="outline" onClick={handleClose}>
205
+ <Trans>Cancel</Trans>
206
+ </Button>
207
+ <Button type="button" onClick={handleInsertImage} disabled={!src}>
208
+ <Trans>Insert image</Trans>
209
+ </Button>
210
+ </DialogFooter>
211
+ </DialogContent>
212
+ </Dialog>
213
+
214
+ <AssetPickerDialog
215
+ open={assetPickerOpen}
216
+ onClose={() => setAssetPickerOpen(false)}
217
+ onSelect={handleAssetSelect}
218
+ multiSelect={false}
219
+ title="Select asset"
220
+ />
221
+ </>
222
+ );
223
+ }