@vendure/dashboard 3.3.6-master-202507030234 → 3.3.6-master-202507030732
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 +4 -4
- package/src/app/routes/_authenticated/_collections/collections_.$id.tsx +1 -1
- package/src/app/routes/_authenticated/_payment-methods/payment-methods_.$id.tsx +1 -1
- package/src/app/routes/_authenticated/_products/products_.$id.tsx +1 -1
- package/src/app/routes/_authenticated/_promotions/promotions_.$id.tsx +1 -1
- package/src/lib/components/data-input/index.ts +11 -0
- package/src/lib/components/data-input/money-input.tsx +2 -9
- package/src/lib/components/data-input/relation-input.tsx +156 -0
- package/src/lib/components/data-input/relation-selector.tsx +350 -0
- package/src/lib/components/shared/form-field-wrapper.tsx +22 -13
- package/src/lib/framework/component-registry/component-registry.tsx +5 -3
- package/src/lib/framework/page/detail-page.tsx +28 -17
- package/src/lib/index.ts +3 -1
- /package/src/lib/components/data-input/{richt-text-input.tsx → rich-text-input.tsx} +0 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vendure/dashboard",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "3.3.6-master-
|
|
4
|
+
"version": "3.3.6-master-202507030732",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -86,8 +86,8 @@
|
|
|
86
86
|
"@types/react-dom": "^19.0.4",
|
|
87
87
|
"@types/react-grid-layout": "^1.3.5",
|
|
88
88
|
"@uidotdev/usehooks": "^2.4.1",
|
|
89
|
-
"@vendure/common": "^3.3.6-master-
|
|
90
|
-
"@vendure/core": "^3.3.6-master-
|
|
89
|
+
"@vendure/common": "^3.3.6-master-202507030732",
|
|
90
|
+
"@vendure/core": "^3.3.6-master-202507030732",
|
|
91
91
|
"@vitejs/plugin-react": "^4.3.4",
|
|
92
92
|
"awesome-graphql-client": "^2.1.0",
|
|
93
93
|
"class-variance-authority": "^0.7.1",
|
|
@@ -130,5 +130,5 @@
|
|
|
130
130
|
"lightningcss-linux-arm64-musl": "^1.29.3",
|
|
131
131
|
"lightningcss-linux-x64-musl": "^1.29.1"
|
|
132
132
|
},
|
|
133
|
-
"gitHead": "
|
|
133
|
+
"gitHead": "0caf3b1c7a4e75924dab2e3e92673c34dadc36f9"
|
|
134
134
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RichTextInput } from '@/vdb/components/data-input/
|
|
1
|
+
import { RichTextInput } from '@/vdb/components/data-input/rich-text-input.js';
|
|
2
2
|
import { EntityAssets } from '@/vdb/components/shared/entity-assets.js';
|
|
3
3
|
import { ErrorPage } from '@/vdb/components/shared/error-page.js';
|
|
4
4
|
import { FormFieldWrapper } from '@/vdb/components/shared/form-field-wrapper.js';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RichTextInput } from '@/vdb/components/data-input/
|
|
1
|
+
import { RichTextInput } from '@/vdb/components/data-input/rich-text-input.js';
|
|
2
2
|
import { ErrorPage } from '@/vdb/components/shared/error-page.js';
|
|
3
3
|
import { FormFieldWrapper } from '@/vdb/components/shared/form-field-wrapper.js';
|
|
4
4
|
import { PermissionGuard } from '@/vdb/components/shared/permission-guard.js';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RichTextInput } from '@/vdb/components/data-input/
|
|
1
|
+
import { RichTextInput } from '@/vdb/components/data-input/rich-text-input.js';
|
|
2
2
|
import { AssignedFacetValues } from '@/vdb/components/shared/assigned-facet-values.js';
|
|
3
3
|
import { EntityAssets } from '@/vdb/components/shared/entity-assets.js';
|
|
4
4
|
import { ErrorPage } from '@/vdb/components/shared/error-page.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DateTimeInput } from '@/vdb/components/data-input/datetime-input.js';
|
|
2
|
-
import { RichTextInput } from '@/vdb/components/data-input/
|
|
2
|
+
import { RichTextInput } from '@/vdb/components/data-input/rich-text-input.js';
|
|
3
3
|
import { ErrorPage } from '@/vdb/components/shared/error-page.js';
|
|
4
4
|
import { FormFieldWrapper } from '@/vdb/components/shared/form-field-wrapper.js';
|
|
5
5
|
import { PermissionGuard } from '@/vdb/components/shared/permission-guard.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Existing data input components
|
|
2
|
+
export * from './affixed-input.js';
|
|
3
|
+
export * from './customer-group-input.js';
|
|
4
|
+
export * from './datetime-input.js';
|
|
5
|
+
export * from './facet-value-input.js';
|
|
6
|
+
export * from './money-input.js';
|
|
7
|
+
export * from './rich-text-input.js';
|
|
8
|
+
|
|
9
|
+
// Relation selector components
|
|
10
|
+
export * from './relation-input.js';
|
|
11
|
+
export * from './relation-selector.js';
|
|
@@ -1,18 +1,11 @@
|
|
|
1
|
+
import { DataInputComponentProps } from '@/vdb/framework/component-registry/component-registry.js';
|
|
1
2
|
import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
|
|
2
3
|
import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
|
|
3
4
|
import { useEffect, useMemo, useState } from 'react';
|
|
4
5
|
import { AffixedInput } from './affixed-input.js';
|
|
5
6
|
|
|
6
7
|
// Original component
|
|
7
|
-
function MoneyInputInternal({
|
|
8
|
-
value,
|
|
9
|
-
currency,
|
|
10
|
-
onChange,
|
|
11
|
-
}: {
|
|
12
|
-
value: number;
|
|
13
|
-
currency: string;
|
|
14
|
-
onChange: (value: number) => void;
|
|
15
|
-
}) {
|
|
8
|
+
function MoneyInputInternal({ value, currency, onChange }: DataInputComponentProps) {
|
|
16
9
|
const {
|
|
17
10
|
settings: { displayLanguage, displayLocale },
|
|
18
11
|
} = useUserSettings();
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { graphql } from '@/vdb/graphql/graphql.js';
|
|
2
|
+
import { createRelationSelectorConfig, RelationSelector } from './relation-selector.js';
|
|
3
|
+
|
|
4
|
+
// Re-export for convenience
|
|
5
|
+
export { createRelationSelectorConfig };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Single relation input component
|
|
9
|
+
*/
|
|
10
|
+
export interface SingleRelationInputProps<T = any> {
|
|
11
|
+
value: string;
|
|
12
|
+
onChange: (value: string) => void;
|
|
13
|
+
config: Parameters<typeof createRelationSelectorConfig<T>>[0];
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function SingleRelationInput<T>({
|
|
19
|
+
value,
|
|
20
|
+
onChange,
|
|
21
|
+
config,
|
|
22
|
+
disabled,
|
|
23
|
+
className,
|
|
24
|
+
}: Readonly<SingleRelationInputProps<T>>) {
|
|
25
|
+
const singleConfig = createRelationSelectorConfig<T>({
|
|
26
|
+
...config,
|
|
27
|
+
multiple: false,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<RelationSelector
|
|
32
|
+
config={singleConfig}
|
|
33
|
+
value={value}
|
|
34
|
+
onChange={newValue => onChange(newValue as string)}
|
|
35
|
+
disabled={disabled}
|
|
36
|
+
className={className}
|
|
37
|
+
/>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Multi relation input component
|
|
43
|
+
*/
|
|
44
|
+
export interface MultiRelationInputProps<T = any> {
|
|
45
|
+
value: string[];
|
|
46
|
+
onChange: (value: string[]) => void;
|
|
47
|
+
config: Parameters<typeof createRelationSelectorConfig<T>>[0];
|
|
48
|
+
disabled?: boolean;
|
|
49
|
+
className?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function MultiRelationInput<T>({
|
|
53
|
+
value,
|
|
54
|
+
onChange,
|
|
55
|
+
config,
|
|
56
|
+
disabled,
|
|
57
|
+
className,
|
|
58
|
+
}: Readonly<MultiRelationInputProps<T>>) {
|
|
59
|
+
const multiConfig = createRelationSelectorConfig<T>({
|
|
60
|
+
...config,
|
|
61
|
+
multiple: true,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<RelationSelector
|
|
66
|
+
config={multiConfig}
|
|
67
|
+
value={value}
|
|
68
|
+
onChange={newValue => onChange(newValue as string[])}
|
|
69
|
+
disabled={disabled}
|
|
70
|
+
className={className}
|
|
71
|
+
/>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Example configurations for common entities
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Product relation selector configuration
|
|
79
|
+
*/
|
|
80
|
+
export const productRelationConfig = createRelationSelectorConfig({
|
|
81
|
+
listQuery: graphql(`
|
|
82
|
+
query GetProductsForRelationSelector($options: ProductListOptions) {
|
|
83
|
+
products(options: $options) {
|
|
84
|
+
items {
|
|
85
|
+
id
|
|
86
|
+
name
|
|
87
|
+
slug
|
|
88
|
+
featuredAsset {
|
|
89
|
+
id
|
|
90
|
+
preview
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
totalItems
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
`),
|
|
97
|
+
idKey: 'id' as const,
|
|
98
|
+
labelKey: 'name' as const,
|
|
99
|
+
placeholder: 'Search products...',
|
|
100
|
+
buildSearchFilter: (term: string) => ({
|
|
101
|
+
name: { contains: term },
|
|
102
|
+
}),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Customer relation selector configuration
|
|
107
|
+
*/
|
|
108
|
+
export const customerRelationConfig = createRelationSelectorConfig({
|
|
109
|
+
listQuery: graphql(`
|
|
110
|
+
query GetCustomersForRelationSelector($options: CustomerListOptions) {
|
|
111
|
+
customers(options: $options) {
|
|
112
|
+
items {
|
|
113
|
+
id
|
|
114
|
+
firstName
|
|
115
|
+
lastName
|
|
116
|
+
emailAddress
|
|
117
|
+
}
|
|
118
|
+
totalItems
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
`),
|
|
122
|
+
idKey: 'id' as const,
|
|
123
|
+
labelKey: 'emailAddress' as const,
|
|
124
|
+
placeholder: 'Search customers...',
|
|
125
|
+
buildSearchFilter: (term: string) => ({
|
|
126
|
+
emailAddress: { contains: term },
|
|
127
|
+
}),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Collection relation selector configuration
|
|
132
|
+
*/
|
|
133
|
+
export const collectionRelationConfig = createRelationSelectorConfig({
|
|
134
|
+
listQuery: graphql(`
|
|
135
|
+
query GetCollectionsForRelationSelector($options: CollectionListOptions) {
|
|
136
|
+
collections(options: $options) {
|
|
137
|
+
items {
|
|
138
|
+
id
|
|
139
|
+
name
|
|
140
|
+
slug
|
|
141
|
+
featuredAsset {
|
|
142
|
+
id
|
|
143
|
+
preview
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
totalItems
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
`),
|
|
150
|
+
idKey: 'id' as const,
|
|
151
|
+
labelKey: 'name' as const,
|
|
152
|
+
placeholder: 'Search collections...',
|
|
153
|
+
buildSearchFilter: (term: string) => ({
|
|
154
|
+
name: { contains: term },
|
|
155
|
+
}),
|
|
156
|
+
});
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { Button } from '@/vdb/components/ui/button.js';
|
|
2
|
+
import { Checkbox } from '@/vdb/components/ui/checkbox.js';
|
|
3
|
+
import {
|
|
4
|
+
Command,
|
|
5
|
+
CommandEmpty,
|
|
6
|
+
CommandInput,
|
|
7
|
+
CommandItem,
|
|
8
|
+
CommandList,
|
|
9
|
+
} from '@/vdb/components/ui/command.js';
|
|
10
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@/vdb/components/ui/popover.js';
|
|
11
|
+
import { getQueryName } from '@/vdb/framework/document-introspection/get-document-structure.js';
|
|
12
|
+
import { api } from '@/vdb/graphql/api.js';
|
|
13
|
+
import { Trans } from '@/vdb/lib/trans.js';
|
|
14
|
+
import { useInfiniteQuery } from '@tanstack/react-query';
|
|
15
|
+
import { useDebounce } from '@uidotdev/usehooks';
|
|
16
|
+
import type { DocumentNode } from 'graphql';
|
|
17
|
+
import { CheckIcon, Loader2, Plus, X } from 'lucide-react';
|
|
18
|
+
import React, { useState } from 'react';
|
|
19
|
+
|
|
20
|
+
export interface RelationSelectorConfig<T = any> {
|
|
21
|
+
/** The GraphQL query document for fetching items */
|
|
22
|
+
listQuery: DocumentNode;
|
|
23
|
+
/** The property key for the entity ID */
|
|
24
|
+
idKey: keyof T;
|
|
25
|
+
/** The property key for the display label */
|
|
26
|
+
labelKey: keyof T;
|
|
27
|
+
/** Number of items to load per page */
|
|
28
|
+
pageSize?: number;
|
|
29
|
+
/** Placeholder text for the search input */
|
|
30
|
+
placeholder?: string;
|
|
31
|
+
/** Whether to enable multi-select mode */
|
|
32
|
+
multiple?: boolean;
|
|
33
|
+
/** Custom filter function for search */
|
|
34
|
+
buildSearchFilter?: (searchTerm: string) => any;
|
|
35
|
+
/** Custom label renderer function for rich display */
|
|
36
|
+
label?: (item: T) => React.ReactNode;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RelationSelectorProps<T = any> {
|
|
40
|
+
config: RelationSelectorConfig<T>;
|
|
41
|
+
value?: string | string[];
|
|
42
|
+
onChange: (value: string | string[]) => void;
|
|
43
|
+
disabled?: boolean;
|
|
44
|
+
className?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RelationSelectorItemProps<T = any> {
|
|
48
|
+
item: T;
|
|
49
|
+
config: RelationSelectorConfig<T>;
|
|
50
|
+
isSelected: boolean;
|
|
51
|
+
onSelect: () => void;
|
|
52
|
+
showCheckbox?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Abstract relation selector item component
|
|
57
|
+
*/
|
|
58
|
+
export function RelationSelectorItem<T>({
|
|
59
|
+
item,
|
|
60
|
+
config,
|
|
61
|
+
isSelected,
|
|
62
|
+
onSelect,
|
|
63
|
+
showCheckbox = false,
|
|
64
|
+
}: Readonly<RelationSelectorItemProps<T>>) {
|
|
65
|
+
const id = String(item[config.idKey]);
|
|
66
|
+
const label = config.label ? config.label(item) : String(item[config.labelKey]);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<CommandItem key={id} value={id} onSelect={onSelect} className="flex items-center gap-2">
|
|
70
|
+
{showCheckbox && (
|
|
71
|
+
<Checkbox
|
|
72
|
+
checked={isSelected}
|
|
73
|
+
onChange={onSelect}
|
|
74
|
+
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
|
75
|
+
/>
|
|
76
|
+
)}
|
|
77
|
+
{isSelected && !showCheckbox && <CheckIcon className="h-4 w-4" />}
|
|
78
|
+
<span className="flex-1">{label}</span>
|
|
79
|
+
</CommandItem>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Hook for managing relation selector state and queries
|
|
85
|
+
*/
|
|
86
|
+
export function useRelationSelector<T>(config: RelationSelectorConfig<T>) {
|
|
87
|
+
const [searchTerm, setSearchTerm] = useState('');
|
|
88
|
+
const debouncedSearch = useDebounce(searchTerm, 300);
|
|
89
|
+
|
|
90
|
+
const pageSize = config.pageSize ?? 25;
|
|
91
|
+
|
|
92
|
+
// Build the default search filter if none provided
|
|
93
|
+
const buildFilter =
|
|
94
|
+
config.buildSearchFilter ??
|
|
95
|
+
((term: string) => ({
|
|
96
|
+
[config.labelKey]: { contains: term },
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage, error } = useInfiniteQuery({
|
|
100
|
+
queryKey: ['relationSelector', getQueryName(config.listQuery), debouncedSearch],
|
|
101
|
+
queryFn: async ({ pageParam = 0 }) => {
|
|
102
|
+
const variables: any = {
|
|
103
|
+
options: {
|
|
104
|
+
skip: pageParam * pageSize,
|
|
105
|
+
take: pageSize,
|
|
106
|
+
sort: { [config.labelKey]: 'ASC' },
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Add search filter if there's a search term
|
|
111
|
+
if (debouncedSearch.trim().length > 0) {
|
|
112
|
+
variables.options.filter = buildFilter(debouncedSearch.trim());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const response = (await api.query(config.listQuery, variables)) as any;
|
|
116
|
+
return response[getQueryName(config.listQuery)];
|
|
117
|
+
},
|
|
118
|
+
getNextPageParam: (lastPage, allPages) => {
|
|
119
|
+
if (!lastPage) return undefined;
|
|
120
|
+
const totalFetched = allPages.length * pageSize;
|
|
121
|
+
return totalFetched < lastPage.totalItems ? allPages.length : undefined;
|
|
122
|
+
},
|
|
123
|
+
initialPageParam: 0,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const items = data?.pages.flatMap(page => page?.items ?? []) ?? [];
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
items,
|
|
130
|
+
isLoading,
|
|
131
|
+
fetchNextPage,
|
|
132
|
+
hasNextPage,
|
|
133
|
+
isFetchingNextPage,
|
|
134
|
+
error,
|
|
135
|
+
searchTerm,
|
|
136
|
+
setSearchTerm,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Abstract relation selector component
|
|
142
|
+
*/
|
|
143
|
+
export function RelationSelector<T>({
|
|
144
|
+
config,
|
|
145
|
+
value,
|
|
146
|
+
onChange,
|
|
147
|
+
disabled,
|
|
148
|
+
className,
|
|
149
|
+
}: Readonly<RelationSelectorProps<T>>) {
|
|
150
|
+
const [open, setOpen] = useState(false);
|
|
151
|
+
const [selectedItemsCache, setSelectedItemsCache] = useState<T[]>([]);
|
|
152
|
+
const isMultiple = config.multiple ?? false;
|
|
153
|
+
|
|
154
|
+
const { items, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage, searchTerm, setSearchTerm } =
|
|
155
|
+
useRelationSelector(config);
|
|
156
|
+
|
|
157
|
+
// Normalize value to always be an array for easier handling
|
|
158
|
+
const selectedIds = React.useMemo(() => {
|
|
159
|
+
if (isMultiple) {
|
|
160
|
+
return Array.isArray(value) ? value : value ? [value] : [];
|
|
161
|
+
}
|
|
162
|
+
return value ? [String(value)] : [];
|
|
163
|
+
}, [value, isMultiple]);
|
|
164
|
+
|
|
165
|
+
const handleSelect = (item: T) => {
|
|
166
|
+
const itemId = String(item[config.idKey]);
|
|
167
|
+
|
|
168
|
+
if (isMultiple) {
|
|
169
|
+
const isCurrentlySelected = selectedIds.includes(itemId);
|
|
170
|
+
const newSelectedIds = isCurrentlySelected
|
|
171
|
+
? selectedIds.filter(id => id !== itemId)
|
|
172
|
+
: [...selectedIds, itemId];
|
|
173
|
+
|
|
174
|
+
// Update cache: add item if selecting, remove if deselecting
|
|
175
|
+
setSelectedItemsCache(prev => {
|
|
176
|
+
if (isCurrentlySelected) {
|
|
177
|
+
return prev.filter(prevItem => String(prevItem[config.idKey]) !== itemId);
|
|
178
|
+
} else {
|
|
179
|
+
// Only add if not already in cache
|
|
180
|
+
const alreadyInCache = prev.some(prevItem => String(prevItem[config.idKey]) === itemId);
|
|
181
|
+
return alreadyInCache ? prev : [...prev, item];
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
onChange(newSelectedIds);
|
|
186
|
+
} else {
|
|
187
|
+
// For single select, update cache with the new item
|
|
188
|
+
setSelectedItemsCache([item]);
|
|
189
|
+
onChange(itemId);
|
|
190
|
+
setOpen(false);
|
|
191
|
+
setSearchTerm('');
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const handleRemove = (itemId: string) => {
|
|
196
|
+
if (isMultiple) {
|
|
197
|
+
const newSelectedIds = selectedIds.filter(id => id !== itemId);
|
|
198
|
+
// Remove from cache as well
|
|
199
|
+
setSelectedItemsCache(prev => prev.filter(prevItem => String(prevItem[config.idKey]) !== itemId));
|
|
200
|
+
onChange(newSelectedIds);
|
|
201
|
+
} else {
|
|
202
|
+
// Clear cache for single select
|
|
203
|
+
setSelectedItemsCache([]);
|
|
204
|
+
onChange('');
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
|
209
|
+
const target = e.currentTarget;
|
|
210
|
+
const scrolledToBottom = Math.abs(target.scrollHeight - target.clientHeight - target.scrollTop) < 1;
|
|
211
|
+
|
|
212
|
+
if (scrolledToBottom && hasNextPage && !isFetchingNextPage) {
|
|
213
|
+
fetchNextPage();
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// Clean up cache when selectedIds change externally (e.g., form reset)
|
|
218
|
+
React.useEffect(() => {
|
|
219
|
+
setSelectedItemsCache(prev => prev.filter(item => selectedIds.includes(String(item[config.idKey]))));
|
|
220
|
+
}, [selectedIds, config.idKey]);
|
|
221
|
+
|
|
222
|
+
// Populate cache with items from search results that are selected but not yet cached
|
|
223
|
+
React.useEffect(() => {
|
|
224
|
+
const itemsToAdd = items.filter(item => {
|
|
225
|
+
const itemId = String(item[config.idKey]);
|
|
226
|
+
const isSelected = selectedIds.includes(itemId);
|
|
227
|
+
const isAlreadyCached = selectedItemsCache.some(
|
|
228
|
+
cachedItem => String(cachedItem[config.idKey]) === itemId,
|
|
229
|
+
);
|
|
230
|
+
return isSelected && !isAlreadyCached;
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
if (itemsToAdd.length > 0) {
|
|
234
|
+
setSelectedItemsCache(prev => [...prev, ...itemsToAdd]);
|
|
235
|
+
}
|
|
236
|
+
}, [items, selectedIds, selectedItemsCache, config.idKey]);
|
|
237
|
+
|
|
238
|
+
// Get selected items for display from cache, filtered by current selection
|
|
239
|
+
const selectedItems = React.useMemo(() => {
|
|
240
|
+
return selectedItemsCache.filter(item => selectedIds.includes(String(item[config.idKey])));
|
|
241
|
+
}, [selectedItemsCache, selectedIds, config.idKey]);
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<div className={className}>
|
|
245
|
+
{/* Display selected items */}
|
|
246
|
+
{selectedItems.length > 0 && (
|
|
247
|
+
<div className="flex flex-wrap gap-2 mb-2">
|
|
248
|
+
{selectedItems.map(item => {
|
|
249
|
+
const itemId = String(item[config.idKey]);
|
|
250
|
+
const label = config.label ? config.label(item) : String(item[config.labelKey]);
|
|
251
|
+
return (
|
|
252
|
+
<div
|
|
253
|
+
key={itemId}
|
|
254
|
+
className="inline-flex items-center gap-1 bg-secondary text-secondary-foreground px-2 py-1 rounded-md text-sm"
|
|
255
|
+
>
|
|
256
|
+
<span>{label}</span>
|
|
257
|
+
{!disabled && (
|
|
258
|
+
<button
|
|
259
|
+
type="button"
|
|
260
|
+
onClick={() => handleRemove(itemId)}
|
|
261
|
+
className="text-secondary-foreground/70 hover:text-secondary-foreground"
|
|
262
|
+
>
|
|
263
|
+
<X className="h-3 w-3" />
|
|
264
|
+
</button>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
);
|
|
268
|
+
})}
|
|
269
|
+
</div>
|
|
270
|
+
)}
|
|
271
|
+
|
|
272
|
+
{/* Selector trigger */}
|
|
273
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
274
|
+
<PopoverTrigger asChild>
|
|
275
|
+
<Button variant="outline" size="sm" type="button" disabled={disabled} className="gap-2">
|
|
276
|
+
<Plus className="h-4 w-4" />
|
|
277
|
+
<Trans>
|
|
278
|
+
{isMultiple
|
|
279
|
+
? selectedItems.length > 0
|
|
280
|
+
? `Add more (${selectedItems.length} selected)`
|
|
281
|
+
: 'Select items'
|
|
282
|
+
: selectedItems.length > 0
|
|
283
|
+
? 'Change selection'
|
|
284
|
+
: 'Select item'}
|
|
285
|
+
</Trans>
|
|
286
|
+
</Button>
|
|
287
|
+
</PopoverTrigger>
|
|
288
|
+
<PopoverContent className="p-0 w-[400px]" align="start">
|
|
289
|
+
<Command shouldFilter={false}>
|
|
290
|
+
<CommandInput
|
|
291
|
+
placeholder={config.placeholder ?? 'Search...'}
|
|
292
|
+
value={searchTerm}
|
|
293
|
+
onValueChange={setSearchTerm}
|
|
294
|
+
disabled={disabled}
|
|
295
|
+
/>
|
|
296
|
+
<CommandList className="h-[300px] overflow-y-auto" onScroll={handleScroll}>
|
|
297
|
+
<CommandEmpty>
|
|
298
|
+
{isLoading ? (
|
|
299
|
+
<div className="flex items-center justify-center py-6">
|
|
300
|
+
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
301
|
+
<Trans>Loading...</Trans>
|
|
302
|
+
</div>
|
|
303
|
+
) : (
|
|
304
|
+
<Trans>No results found</Trans>
|
|
305
|
+
)}
|
|
306
|
+
</CommandEmpty>
|
|
307
|
+
|
|
308
|
+
{items.map(item => {
|
|
309
|
+
const itemId = String(item[config.idKey]);
|
|
310
|
+
const isSelected = selectedIds.includes(itemId);
|
|
311
|
+
|
|
312
|
+
return (
|
|
313
|
+
<RelationSelectorItem
|
|
314
|
+
key={itemId}
|
|
315
|
+
item={item}
|
|
316
|
+
config={config}
|
|
317
|
+
isSelected={isSelected}
|
|
318
|
+
onSelect={() => handleSelect(item)}
|
|
319
|
+
showCheckbox={isMultiple}
|
|
320
|
+
/>
|
|
321
|
+
);
|
|
322
|
+
})}
|
|
323
|
+
|
|
324
|
+
{(isFetchingNextPage || isLoading) && (
|
|
325
|
+
<div className="flex items-center justify-center py-2">
|
|
326
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
327
|
+
</div>
|
|
328
|
+
)}
|
|
329
|
+
|
|
330
|
+
{!hasNextPage && items.length > 0 && (
|
|
331
|
+
<div className="text-center py-2 text-sm text-muted-foreground">
|
|
332
|
+
<Trans>No more items</Trans>
|
|
333
|
+
</div>
|
|
334
|
+
)}
|
|
335
|
+
</CommandList>
|
|
336
|
+
</Command>
|
|
337
|
+
</PopoverContent>
|
|
338
|
+
</Popover>
|
|
339
|
+
</div>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Utility function to create a relation selector configuration
|
|
345
|
+
*/
|
|
346
|
+
export function createRelationSelectorConfig<T>(
|
|
347
|
+
config: Readonly<RelationSelectorConfig<T>>,
|
|
348
|
+
): RelationSelectorConfig<T> {
|
|
349
|
+
return config;
|
|
350
|
+
}
|
|
@@ -1,25 +1,34 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
FormDescription,
|
|
4
|
-
FormItem,
|
|
5
|
-
FormLabel,
|
|
6
|
-
FormMessage,
|
|
7
|
-
FormField,
|
|
8
|
-
} from '../ui/form.js';
|
|
9
|
-
import { FieldValues, FieldPath } from 'react-hook-form';
|
|
1
|
+
import { FieldPath, FieldValues } from 'react-hook-form';
|
|
2
|
+
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '../ui/form.js';
|
|
10
3
|
|
|
11
4
|
export type FormFieldWrapperProps<
|
|
12
5
|
TFieldValues extends FieldValues = FieldValues,
|
|
13
|
-
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues
|
|
6
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
14
7
|
> = React.ComponentProps<typeof FormField<TFieldValues, TName>> & {
|
|
15
8
|
label?: React.ReactNode;
|
|
16
9
|
description?: React.ReactNode;
|
|
10
|
+
/**
|
|
11
|
+
* @description
|
|
12
|
+
* Whether to render the form control.
|
|
13
|
+
* If false, the form control will not be rendered.
|
|
14
|
+
* This is useful when you want to render the form control in a custom way, e.g. for <Select/> components,
|
|
15
|
+
* where the FormControl needs to nested in the root component.
|
|
16
|
+
* @default true
|
|
17
|
+
*/
|
|
18
|
+
renderFormControl?: boolean;
|
|
17
19
|
};
|
|
18
20
|
|
|
19
21
|
export function FormFieldWrapper<
|
|
20
22
|
TFieldValues extends FieldValues = FieldValues,
|
|
21
|
-
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues
|
|
22
|
-
>({
|
|
23
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
24
|
+
>({
|
|
25
|
+
control,
|
|
26
|
+
name,
|
|
27
|
+
render,
|
|
28
|
+
label,
|
|
29
|
+
description,
|
|
30
|
+
renderFormControl = true,
|
|
31
|
+
}: FormFieldWrapperProps<TFieldValues, TName>) {
|
|
23
32
|
return (
|
|
24
33
|
<FormField
|
|
25
34
|
control={control}
|
|
@@ -27,7 +36,7 @@ export function FormFieldWrapper<
|
|
|
27
36
|
render={renderArgs => (
|
|
28
37
|
<FormItem>
|
|
29
38
|
{label && <FormLabel>{label}</FormLabel>}
|
|
30
|
-
<FormControl>{render(renderArgs)}</FormControl>
|
|
39
|
+
{renderFormControl ? <FormControl>{render(renderArgs)}</FormControl> : render(renderArgs)}
|
|
31
40
|
{description && <FormDescription>{description}</FormDescription>}
|
|
32
41
|
<FormMessage />
|
|
33
42
|
</FormItem>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
+
import { ControllerRenderProps, FieldPath, FieldValues } from 'react-hook-form';
|
|
2
3
|
import { addDisplayComponent, getDisplayComponent } from '../extension-api/display-component-extensions.js';
|
|
3
4
|
import { addInputComponent, getInputComponent } from '../extension-api/input-component-extensions.js';
|
|
4
5
|
|
|
@@ -13,9 +14,10 @@ export interface DataDisplayComponentProps {
|
|
|
13
14
|
[key: string]: any;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
export interface DataInputComponentProps
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
export interface DataInputComponentProps<
|
|
18
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
19
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
20
|
+
> extends ControllerRenderProps<TFieldValues, TName> {
|
|
19
21
|
[key: string]: any;
|
|
20
22
|
}
|
|
21
23
|
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from '../document-introspection/get-document-structure.js';
|
|
18
18
|
|
|
19
19
|
import { TranslatableFormFieldWrapper } from '@/vdb/components/shared/translatable-form-field.js';
|
|
20
|
+
import { FormControl } from '@/vdb/components/ui/form.js';
|
|
20
21
|
import { ControllerRenderProps, FieldPath, FieldValues } from 'react-hook-form';
|
|
21
22
|
import { useComponentRegistry } from '../component-registry/component-registry.js';
|
|
22
23
|
import { generateInputComponentKey } from '../extension-api/input-component-extensions.js';
|
|
@@ -120,23 +121,31 @@ function FieldInputRenderer<
|
|
|
120
121
|
return <InputComponent {...field} />;
|
|
121
122
|
}
|
|
122
123
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
124
|
+
const DefaultComponent = () => {
|
|
125
|
+
switch (fieldInfo.type) {
|
|
126
|
+
case 'Int':
|
|
127
|
+
case 'Float':
|
|
128
|
+
return (
|
|
129
|
+
<Input
|
|
130
|
+
type="number"
|
|
131
|
+
value={field.value}
|
|
132
|
+
onChange={e => field.onChange(e.target.valueAsNumber)}
|
|
133
|
+
/>
|
|
134
|
+
);
|
|
135
|
+
case 'DateTime':
|
|
136
|
+
return <DateTimeInput {...field} />;
|
|
137
|
+
case 'Boolean':
|
|
138
|
+
return <Checkbox value={field.value} onCheckedChange={field.onChange} />;
|
|
139
|
+
default:
|
|
140
|
+
return <Input {...field} />;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<FormControl>
|
|
146
|
+
<DefaultComponent />
|
|
147
|
+
</FormControl>
|
|
148
|
+
);
|
|
140
149
|
}
|
|
141
150
|
|
|
142
151
|
/**
|
|
@@ -227,6 +236,7 @@ export function DetailPage<
|
|
|
227
236
|
control={form.control}
|
|
228
237
|
name={fieldInfo.name as never}
|
|
229
238
|
label={fieldInfo.name}
|
|
239
|
+
renderFormControl={false}
|
|
230
240
|
render={({ field }) => (
|
|
231
241
|
<FieldInputRenderer
|
|
232
242
|
fieldInfo={fieldInfo}
|
|
@@ -249,6 +259,7 @@ export function DetailPage<
|
|
|
249
259
|
control={form.control}
|
|
250
260
|
name={fieldInfo.name as never}
|
|
251
261
|
label={fieldInfo.name}
|
|
262
|
+
renderFormControl={false}
|
|
252
263
|
render={({ field }) => (
|
|
253
264
|
<FieldInputRenderer
|
|
254
265
|
fieldInfo={fieldInfo}
|
package/src/lib/index.ts
CHANGED
|
@@ -9,7 +9,9 @@ export * from './components/data-input/customer-group-input.js';
|
|
|
9
9
|
export * from './components/data-input/datetime-input.js';
|
|
10
10
|
export * from './components/data-input/facet-value-input.js';
|
|
11
11
|
export * from './components/data-input/money-input.js';
|
|
12
|
-
export * from './components/data-input/
|
|
12
|
+
export * from './components/data-input/relation-input.js';
|
|
13
|
+
export * from './components/data-input/relation-selector.js';
|
|
14
|
+
export * from './components/data-input/rich-text-input.js';
|
|
13
15
|
export * from './components/data-table/add-filter-menu.js';
|
|
14
16
|
export * from './components/data-table/data-table-bulk-action-item.js';
|
|
15
17
|
export * from './components/data-table/data-table-bulk-actions.js';
|
|
File without changes
|