@vendure/dashboard 3.3.6-master-202507040234 → 3.3.6-master-202507050232

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 (25) hide show
  1. package/package.json +4 -4
  2. package/src/app/common/delete-bulk-action.tsx +2 -1
  3. package/src/app/common/duplicate-bulk-action.tsx +1 -1
  4. package/src/app/routes/_authenticated/_collections/components/collection-bulk-actions.tsx +4 -1
  5. package/src/app/routes/_authenticated/_facets/components/facet-bulk-actions.tsx +2 -1
  6. package/src/app/routes/_authenticated/_payment-methods/components/payment-method-bulk-actions.tsx +2 -1
  7. package/src/app/routes/_authenticated/_product-variants/components/product-variant-bulk-actions.tsx +3 -1
  8. package/src/app/routes/_authenticated/_products/components/product-bulk-actions.tsx +3 -1
  9. package/src/app/routes/_authenticated/_products/components/product-variants-table.tsx +1 -1
  10. package/src/app/routes/_authenticated/_promotions/components/promotion-bulk-actions.tsx +2 -1
  11. package/src/app/routes/_authenticated/_shipping-methods/components/shipping-method-bulk-actions.tsx +2 -1
  12. package/src/app/routes/_authenticated/_stock-locations/components/stock-location-bulk-actions.tsx +2 -1
  13. package/src/lib/components/data-input/relation-selector.tsx +144 -26
  14. package/src/lib/components/shared/assign-to-channel-bulk-action.tsx +2 -1
  15. package/src/lib/components/shared/copyable-text.tsx +1 -1
  16. package/src/lib/components/shared/form-field-wrapper.tsx +26 -12
  17. package/src/lib/components/shared/remove-from-channel-bulk-action.tsx +2 -1
  18. package/src/lib/components/shared/translatable-form-field.tsx +26 -12
  19. package/src/lib/framework/dashboard-widget/latest-orders-widget/index.tsx +2 -1
  20. package/src/lib/framework/dashboard-widget/orders-summary/index.tsx +2 -1
  21. package/src/lib/framework/form-engine/overridden-form-component.tsx +51 -0
  22. package/src/lib/framework/layout-engine/location-wrapper.tsx +99 -69
  23. package/src/lib/framework/layout-engine/page-layout.tsx +8 -8
  24. package/src/lib/framework/page/detail-page.tsx +3 -31
  25. package/src/lib/hooks/use-page-block.tsx +10 -2
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-202507040234",
4
+ "version": "3.3.6-master-202507050232",
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-202507040234",
90
- "@vendure/core": "^3.3.6-master-202507040234",
89
+ "@vendure/common": "^3.3.6-master-202507050232",
90
+ "@vendure/core": "^3.3.6-master-202507050232",
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": "6634367c8f5aeb5c25502c51c15686d1d4fa807c"
133
+ "gitHead": "aa108b47a462b2ff023250e4f9e75a02529feb8a"
134
134
  }
@@ -3,8 +3,9 @@ import { TrashIcon } from 'lucide-react';
3
3
  import { toast } from 'sonner';
4
4
 
5
5
  import { DataTableBulkActionItem } from '@/vdb/components/data-table/data-table-bulk-action-item.js';
6
+ import { usePaginatedList } from '@/vdb/components/shared/paginated-list-data-table.js';
7
+ import { getMutationName } from '@/vdb/framework/document-introspection/get-document-structure.js';
6
8
  import { api } from '@/vdb/graphql/api.js';
7
- import { getMutationName, usePaginatedList } from '@/vdb/index.js';
8
9
  import { Trans, useLingui } from '@/vdb/lib/trans.js';
9
10
 
10
11
  interface DeleteBulkActionProps {
@@ -4,9 +4,9 @@ import { useState } from 'react';
4
4
  import { toast } from 'sonner';
5
5
 
6
6
  import { DataTableBulkActionItem } from '@/vdb/components/data-table/data-table-bulk-action-item.js';
7
+ import { usePaginatedList } from '@/vdb/components/shared/paginated-list-data-table.js';
7
8
  import { api } from '@/vdb/graphql/api.js';
8
9
  import { duplicateEntityDocument } from '@/vdb/graphql/common-operations.js';
9
- import { usePaginatedList } from '@/vdb/index.js';
10
10
  import { Trans, useLingui } from '@/vdb/lib/trans.js';
11
11
 
12
12
  interface DuplicateBulkActionProps {
@@ -6,7 +6,10 @@ import { Trans } from '@/vdb/lib/trans.js';
6
6
  import { AssignToChannelBulkAction } from '@/vdb/components/shared/assign-to-channel-bulk-action.js';
7
7
  import { RemoveFromChannelBulkAction } from '@/vdb/components/shared/remove-from-channel-bulk-action.js';
8
8
  import { api } from '@/vdb/graphql/api.js';
9
- import { BulkActionComponent, useChannel, DataTableBulkActionItem, usePaginatedList } from '@/vdb/index.js';
9
+ import { BulkActionComponent } from '@/vdb/framework/extension-api/types/data-table.js';
10
+ import { useChannel } from '@/vdb/hooks/use-channel.js';
11
+ import { DataTableBulkActionItem } from '@/vdb/components/data-table/data-table-bulk-action-item.js';
12
+ import { usePaginatedList } from '@/vdb/components/shared/paginated-list-data-table.js';
10
13
  import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
11
14
  import { DuplicateBulkAction } from '../../../../common/duplicate-bulk-action.js';
12
15
  import {
@@ -4,7 +4,8 @@ import { AssignToChannelBulkAction } from '@/vdb/components/shared/assign-to-cha
4
4
  import { RemoveFromChannelBulkAction } from '@/vdb/components/shared/remove-from-channel-bulk-action.js';
5
5
  import { api } from '@/vdb/graphql/api.js';
6
6
  import { ResultOf } from '@/vdb/graphql/graphql.js';
7
- import { BulkActionComponent, useChannel } from '@/vdb/index.js';
7
+ import { BulkActionComponent } from '@/vdb/framework/extension-api/types/data-table.js';
8
+ import { useChannel } from '@/vdb/hooks/use-channel.js';
8
9
  import { useLingui } from '@/vdb/lib/trans.js';
9
10
  import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
10
11
  import { DuplicateBulkAction } from '../../../../common/duplicate-bulk-action.js';
@@ -1,7 +1,8 @@
1
1
  import { AssignToChannelBulkAction } from '@/vdb/components/shared/assign-to-channel-bulk-action.js';
2
2
  import { RemoveFromChannelBulkAction } from '@/vdb/components/shared/remove-from-channel-bulk-action.js';
3
+ import { BulkActionComponent } from '@/vdb/framework/extension-api/types/data-table.js';
3
4
  import { api } from '@/vdb/graphql/api.js';
4
- import { BulkActionComponent, useChannel } from '@/vdb/index.js';
5
+ import { useChannel } from '@/vdb/hooks/use-channel.js';
5
6
  import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
6
7
 
7
8
  import {
@@ -6,7 +6,9 @@ import { AssignToChannelBulkAction } from '@/vdb/components/shared/assign-to-cha
6
6
  import { usePriceFactor } from '@/vdb/components/shared/assign-to-channel-dialog.js';
7
7
  import { RemoveFromChannelBulkAction } from '@/vdb/components/shared/remove-from-channel-bulk-action.js';
8
8
  import { api } from '@/vdb/graphql/api.js';
9
- import { BulkActionComponent, useChannel, usePaginatedList } from '@/vdb/index.js';
9
+ import { BulkActionComponent } from '@/vdb/framework/extension-api/types/data-table.js';
10
+ import { useChannel } from '@/vdb/hooks/use-channel.js';
11
+ import { usePaginatedList } from '@/vdb/components/shared/paginated-list-data-table.js';
10
12
  import { Trans } from '@/vdb/lib/trans.js';
11
13
  import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
12
14
 
@@ -4,9 +4,11 @@ import { useState } from 'react';
4
4
  import { DataTableBulkActionItem } from '@/vdb/components/data-table/data-table-bulk-action-item.js';
5
5
  import { AssignToChannelBulkAction } from '@/vdb/components/shared/assign-to-channel-bulk-action.js';
6
6
  import { usePriceFactor } from '@/vdb/components/shared/assign-to-channel-dialog.js';
7
+ import { usePaginatedList } from '@/vdb/components/shared/paginated-list-data-table.js';
7
8
  import { RemoveFromChannelBulkAction } from '@/vdb/components/shared/remove-from-channel-bulk-action.js';
9
+ import { BulkActionComponent } from '@/vdb/framework/extension-api/types/data-table.js';
8
10
  import { api } from '@/vdb/graphql/api.js';
9
- import { BulkActionComponent, useChannel, usePaginatedList } from '@/vdb/index.js';
11
+ import { useChannel } from '@/vdb/hooks/use-channel.js';
10
12
  import { Trans } from '@/vdb/lib/trans.js';
11
13
  import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
12
14
  import { DuplicateBulkAction } from '../../../../common/duplicate-bulk-action.js';
@@ -1,4 +1,5 @@
1
1
  import { Money } from '@/vdb/components/data-display/money.js';
2
+ import { DetailPageButton } from '@/vdb/components/shared/detail-page-button.js';
2
3
  import {
3
4
  PaginatedListDataTable,
4
5
  PaginatedListRefresherRegisterFn,
@@ -6,7 +7,6 @@ import {
6
7
  import { StockLevelLabel } from '@/vdb/components/shared/stock-level-label.js';
7
8
  import { graphql } from '@/vdb/graphql/graphql.js';
8
9
  import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
9
- import { DetailPageButton } from '@/vdb/index.js';
10
10
  import { ColumnFiltersState, SortingState } from '@tanstack/react-table';
11
11
  import { useState } from 'react';
12
12
  import { productVariantListDocument } from '../products.graphql.js';
@@ -1,7 +1,8 @@
1
1
  import { AssignToChannelBulkAction } from '@/vdb/components/shared/assign-to-channel-bulk-action.js';
2
2
  import { RemoveFromChannelBulkAction } from '@/vdb/components/shared/remove-from-channel-bulk-action.js';
3
+ import { BulkActionComponent } from '@/vdb/framework/extension-api/types/data-table.js';
3
4
  import { api } from '@/vdb/graphql/api.js';
4
- import { BulkActionComponent, useChannel } from '@/vdb/index.js';
5
+ import { useChannel } from '@/vdb/hooks/use-channel.js';
5
6
  import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
6
7
  import { DuplicateBulkAction } from '../../../../common/duplicate-bulk-action.js';
7
8
 
@@ -1,7 +1,8 @@
1
1
  import { AssignToChannelBulkAction } from '@/vdb/components/shared/assign-to-channel-bulk-action.js';
2
2
  import { RemoveFromChannelBulkAction } from '@/vdb/components/shared/remove-from-channel-bulk-action.js';
3
+ import { BulkActionComponent } from '@/vdb/framework/extension-api/types/data-table.js';
3
4
  import { api } from '@/vdb/graphql/api.js';
4
- import { BulkActionComponent, useChannel } from '@/vdb/index.js';
5
+ import { useChannel } from '@/vdb/hooks/use-channel.js';
5
6
  import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
6
7
 
7
8
  import {
@@ -1,7 +1,8 @@
1
1
  import { AssignToChannelBulkAction } from '@/vdb/components/shared/assign-to-channel-bulk-action.js';
2
2
  import { RemoveFromChannelBulkAction } from '@/vdb/components/shared/remove-from-channel-bulk-action.js';
3
3
  import { api } from '@/vdb/graphql/api.js';
4
- import { BulkActionComponent, useChannel } from '@/vdb/index.js';
4
+ import { BulkActionComponent } from '@/vdb/framework/extension-api/types/data-table.js';
5
+ import { useChannel } from '@/vdb/hooks/use-channel.js';
5
6
  import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
6
7
 
7
8
  import {
@@ -32,6 +32,8 @@ export interface RelationSelectorConfig<T = any> {
32
32
  multiple?: boolean;
33
33
  /** Custom filter function for search */
34
34
  buildSearchFilter?: (searchTerm: string) => any;
35
+ /** Custom filter function for fetching by IDs */
36
+ buildIdsFilter?: (ids: string[]) => any;
35
37
  /** Custom label renderer function for rich display */
36
38
  label?: (item: T) => React.ReactNode;
37
39
  }
@@ -96,6 +98,19 @@ export function useRelationSelector<T>(config: RelationSelectorConfig<T>) {
96
98
  [config.labelKey]: { contains: term },
97
99
  }));
98
100
 
101
+ // Build the default IDs filter if none provided
102
+ const buildIdsFilter = React.useCallback(
103
+ (ids: string[]) => {
104
+ if (config.buildIdsFilter) {
105
+ return config.buildIdsFilter(ids);
106
+ }
107
+ return {
108
+ [config.idKey]: { in: ids },
109
+ };
110
+ },
111
+ [config.idKey, config.buildIdsFilter],
112
+ );
113
+
99
114
  const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage, error } = useInfiniteQuery({
100
115
  queryKey: ['relationSelector', getQueryName(config.listQuery), debouncedSearch],
101
116
  queryFn: async ({ pageParam = 0 }) => {
@@ -125,6 +140,30 @@ export function useRelationSelector<T>(config: RelationSelectorConfig<T>) {
125
140
 
126
141
  const items = data?.pages.flatMap(page => page?.items ?? []) ?? [];
127
142
 
143
+ // Function to fetch items by IDs
144
+ const fetchItemsByIds = React.useCallback(
145
+ async (ids: string[]): Promise<T[]> => {
146
+ if (ids.length === 0) return [];
147
+
148
+ const variables: any = {
149
+ options: {
150
+ take: ids.length,
151
+ filter: buildIdsFilter(ids),
152
+ },
153
+ };
154
+
155
+ try {
156
+ const response = (await api.query(config.listQuery, variables)) as any;
157
+ const result = response[getQueryName(config.listQuery)];
158
+ return result?.items ?? [];
159
+ } catch (error) {
160
+ console.error('Error fetching items by IDs:', error);
161
+ return [];
162
+ }
163
+ },
164
+ [buildIdsFilter, config.listQuery],
165
+ );
166
+
128
167
  return {
129
168
  items,
130
169
  isLoading,
@@ -134,6 +173,7 @@ export function useRelationSelector<T>(config: RelationSelectorConfig<T>) {
134
173
  error,
135
174
  searchTerm,
136
175
  setSearchTerm,
176
+ fetchItemsByIds,
137
177
  };
138
178
  }
139
179
 
@@ -149,19 +189,104 @@ export function RelationSelector<T>({
149
189
  }: Readonly<RelationSelectorProps<T>>) {
150
190
  const [open, setOpen] = useState(false);
151
191
  const [selectedItemsCache, setSelectedItemsCache] = useState<T[]>([]);
192
+ const fetchedIdsRef = React.useRef<Set<string>>(new Set());
193
+ const fetchingIdsRef = React.useRef<Set<string>>(new Set());
152
194
  const isMultiple = config.multiple ?? false;
153
195
 
154
- const { items, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage, searchTerm, setSearchTerm } =
155
- useRelationSelector(config);
196
+ const {
197
+ items,
198
+ isLoading,
199
+ fetchNextPage,
200
+ hasNextPage,
201
+ isFetchingNextPage,
202
+ searchTerm,
203
+ setSearchTerm,
204
+ fetchItemsByIds,
205
+ } = useRelationSelector(config);
206
+
207
+ // Store a stable reference to fetchItemsByIds
208
+ const fetchItemsByIdsRef = React.useRef(fetchItemsByIds);
209
+ fetchItemsByIdsRef.current = fetchItemsByIds;
156
210
 
157
211
  // Normalize value to always be an array for easier handling
158
212
  const selectedIds = React.useMemo(() => {
159
213
  if (isMultiple) {
160
214
  return Array.isArray(value) ? value : value ? [value] : [];
161
215
  }
162
- return value ? [String(value)] : [];
216
+ // For single select, ensure we only have at most one ID
217
+ const singleValue = Array.isArray(value) ? value[0] : value;
218
+ return singleValue ? [String(singleValue)] : [];
163
219
  }, [value, isMultiple]);
164
220
 
221
+ // Fetch selected items by IDs on mount and when selectedIds change
222
+ React.useEffect(() => {
223
+ const fetchSelectedItems = async () => {
224
+ if (selectedIds.length === 0) {
225
+ setSelectedItemsCache([]);
226
+ fetchedIdsRef.current.clear();
227
+ fetchingIdsRef.current.clear();
228
+ return;
229
+ }
230
+
231
+ // Find which selected IDs we haven't fetched yet and aren't currently fetching
232
+ const missingIds = selectedIds.filter(
233
+ id => !fetchedIdsRef.current.has(id) && !fetchingIdsRef.current.has(id),
234
+ );
235
+
236
+ if (missingIds.length > 0) {
237
+ // Mark these IDs as being fetched
238
+ missingIds.forEach(id => fetchingIdsRef.current.add(id));
239
+
240
+ try {
241
+ const fetchedItems = await fetchItemsByIdsRef.current(missingIds);
242
+
243
+ // Mark these IDs as fetched and remove from fetching
244
+ missingIds.forEach(id => {
245
+ fetchedIdsRef.current.add(id);
246
+ fetchingIdsRef.current.delete(id);
247
+ });
248
+
249
+ setSelectedItemsCache(prev => {
250
+ if (!isMultiple) {
251
+ // For single select, replace the entire cache
252
+ return fetchedItems;
253
+ }
254
+ // For multi-select, filter and append
255
+ const stillSelected = prev.filter(item =>
256
+ selectedIds.includes(String(item[config.idKey])),
257
+ );
258
+ return [...stillSelected, ...fetchedItems];
259
+ });
260
+ } catch (error) {
261
+ // Remove from fetching set on error
262
+ missingIds.forEach(id => fetchingIdsRef.current.delete(id));
263
+ console.error('Error fetching items by IDs:', error);
264
+ }
265
+ } else {
266
+ // Just filter out items that are no longer selected
267
+ setSelectedItemsCache(prev => {
268
+ if (!isMultiple) {
269
+ // For single select, if no items need fetching but we have a selection,
270
+ // keep only items that are in the current selection
271
+ return prev.filter(item => selectedIds.includes(String(item[config.idKey])));
272
+ }
273
+ // For multi-select, normal filtering
274
+ return prev.filter(item => selectedIds.includes(String(item[config.idKey])));
275
+ });
276
+ }
277
+
278
+ // Clean up fetched IDs that are no longer selected
279
+ const selectedIdsSet = new Set(selectedIds);
280
+ for (const fetchedId of fetchedIdsRef.current) {
281
+ if (!selectedIdsSet.has(fetchedId)) {
282
+ fetchedIdsRef.current.delete(fetchedId);
283
+ }
284
+ }
285
+ };
286
+
287
+ fetchSelectedItems();
288
+ }, [selectedIds, config.idKey, isMultiple]);
289
+
165
290
  const handleSelect = (item: T) => {
166
291
  const itemId = String(item[config.idKey]);
167
292
 
@@ -182,10 +307,20 @@ export function RelationSelector<T>({
182
307
  }
183
308
  });
184
309
 
310
+ // Mark the item as fetched to prevent duplicate fetching
311
+ if (!isCurrentlySelected) {
312
+ fetchedIdsRef.current.add(itemId);
313
+ } else {
314
+ fetchedIdsRef.current.delete(itemId);
315
+ }
316
+
185
317
  onChange(newSelectedIds);
186
318
  } else {
187
319
  // For single select, update cache with the new item
188
320
  setSelectedItemsCache([item]);
321
+ // Mark as fetched for single select too
322
+ fetchedIdsRef.current.clear();
323
+ fetchedIdsRef.current.add(itemId);
189
324
  onChange(itemId);
190
325
  setOpen(false);
191
326
  setSearchTerm('');
@@ -214,31 +349,14 @@ export function RelationSelector<T>({
214
349
  }
215
350
  };
216
351
 
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
352
  // Get selected items for display from cache, filtered by current selection
239
353
  const selectedItems = React.useMemo(() => {
240
- return selectedItemsCache.filter(item => selectedIds.includes(String(item[config.idKey])));
241
- }, [selectedItemsCache, selectedIds, config.idKey]);
354
+ const filteredItems = selectedItemsCache.filter(item =>
355
+ selectedIds.includes(String(item[config.idKey])),
356
+ );
357
+ // For single select, ensure we only display one item
358
+ return isMultiple ? filteredItems : filteredItems.slice(0, 1);
359
+ }, [selectedItemsCache, selectedIds, config.idKey, isMultiple]);
242
360
 
243
361
  return (
244
362
  <div className={className}>
@@ -3,7 +3,8 @@ import { useState } from 'react';
3
3
 
4
4
  import { DataTableBulkActionItem } from '@/vdb/components/data-table/data-table-bulk-action-item.js';
5
5
  import { AssignToChannelDialog } from '@/vdb/components/shared/assign-to-channel-dialog.js';
6
- import { useChannel, usePaginatedList } from '@/vdb/index.js';
6
+ import { usePaginatedList } from '@/vdb/components/shared/paginated-list-data-table.js';
7
+ import { useChannel } from '@/vdb/hooks/use-channel.js';
7
8
  import { Trans } from '@/vdb/lib/trans.js';
8
9
 
9
10
  interface AssignToChannelBulkActionProps {
@@ -14,7 +14,7 @@ export function CopyableText({ text }: Readonly<{ text: string }>) {
14
14
 
15
15
  return (
16
16
  <div className="flex items-center gap-2">
17
- <div className="font-mono text-sm">{text}</div>
17
+ <div className="font-mono">{text}</div>
18
18
  <button
19
19
  onClick={() => handleCopy(text, 'page')}
20
20
  className="p-1 hover:bg-muted rounded-md transition-colors"
@@ -1,3 +1,5 @@
1
+ import { OverriddenFormComponent } from '@/vdb/framework/form-engine/overridden-form-component.js';
2
+ import { LocationWrapper } from '@/vdb/framework/layout-engine/location-wrapper.js';
1
3
  import { FieldPath, FieldValues } from 'react-hook-form';
2
4
  import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '../ui/form.js';
3
5
 
@@ -30,17 +32,29 @@ export function FormFieldWrapper<
30
32
  renderFormControl = true,
31
33
  }: FormFieldWrapperProps<TFieldValues, TName>) {
32
34
  return (
33
- <FormField
34
- control={control}
35
- name={name}
36
- render={renderArgs => (
37
- <FormItem>
38
- {label && <FormLabel>{label}</FormLabel>}
39
- {renderFormControl ? <FormControl>{render(renderArgs)}</FormControl> : render(renderArgs)}
40
- {description && <FormDescription>{description}</FormDescription>}
41
- <FormMessage />
42
- </FormItem>
43
- )}
44
- />
35
+ <LocationWrapper identifier={name}>
36
+ <FormField
37
+ control={control}
38
+ name={name}
39
+ render={renderArgs => (
40
+ <FormItem>
41
+ {label && <FormLabel>{label}</FormLabel>}
42
+ {renderFormControl ? (
43
+ <FormControl>
44
+ <OverriddenFormComponent field={renderArgs.field} fieldName={name}>
45
+ {render(renderArgs)}
46
+ </OverriddenFormComponent>
47
+ </FormControl>
48
+ ) : (
49
+ <OverriddenFormComponent field={renderArgs.field} fieldName={name}>
50
+ {render(renderArgs)}
51
+ </OverriddenFormComponent>
52
+ )}
53
+ {description && <FormDescription>{description}</FormDescription>}
54
+ <FormMessage />
55
+ </FormItem>
56
+ )}
57
+ />
58
+ </LocationWrapper>
45
59
  );
46
60
  }
@@ -3,8 +3,9 @@ import { LayersIcon } from 'lucide-react';
3
3
  import { toast } from 'sonner';
4
4
 
5
5
  import { DataTableBulkActionItem } from '@/vdb/components/data-table/data-table-bulk-action-item.js';
6
+ import { usePaginatedList } from '@/vdb/components/shared/paginated-list-data-table.js';
6
7
  import { ResultOf } from '@/vdb/graphql/graphql.js';
7
- import { useChannel, usePaginatedList } from '@/vdb/index.js';
8
+ import { useChannel } from '@/vdb/hooks/use-channel.js';
8
9
  import { Trans, useLingui } from '@/vdb/lib/trans.js';
9
10
 
10
11
  interface RemoveFromChannelBulkActionProps {
@@ -1,3 +1,5 @@
1
+ import { OverriddenFormComponent } from '@/vdb/framework/form-engine/overridden-form-component.js';
2
+ import { LocationWrapper } from '@/vdb/framework/layout-engine/location-wrapper.js';
1
3
  import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
2
4
  import { Controller, ControllerProps, FieldPath, FieldValues } from 'react-hook-form';
3
5
  import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from '../ui/form.js';
@@ -53,17 +55,29 @@ export const TranslatableFormFieldWrapper = <
53
55
  ...props
54
56
  }: TranslatableFormFieldWrapperProps<TFieldValues>) => {
55
57
  return (
56
- <TranslatableFormField
57
- control={props.control}
58
- name={name}
59
- render={renderArgs => (
60
- <FormItem>
61
- {label && <FormLabel>{label}</FormLabel>}
62
- {renderFormControl ? <FormControl>{render(renderArgs)}</FormControl> : render(renderArgs)}
63
- {description && <FormDescription>{description}</FormDescription>}
64
- <FormMessage />
65
- </FormItem>
66
- )}
67
- />
58
+ <LocationWrapper identifier={name as string}>
59
+ <TranslatableFormField
60
+ control={props.control}
61
+ name={name}
62
+ render={renderArgs => (
63
+ <FormItem>
64
+ {label && <FormLabel>{label}</FormLabel>}
65
+ {renderFormControl ? (
66
+ <FormControl>
67
+ <OverriddenFormComponent field={renderArgs.field} fieldName={name as string}>
68
+ {render(renderArgs)}
69
+ </OverriddenFormComponent>
70
+ </FormControl>
71
+ ) : (
72
+ <OverriddenFormComponent field={renderArgs.field} fieldName={name as string}>
73
+ {render(renderArgs)}
74
+ </OverriddenFormComponent>
75
+ )}
76
+ {description && <FormDescription>{description}</FormDescription>}
77
+ <FormMessage />
78
+ </FormItem>
79
+ )}
80
+ />
81
+ </LocationWrapper>
68
82
  );
69
83
  };
@@ -1,5 +1,6 @@
1
+ import { PaginatedListDataTable } from '@/vdb/components/shared/paginated-list-data-table.js';
1
2
  import { Button } from '@/vdb/components/ui/button.js';
2
- import { PaginatedListDataTable, useLocalFormat } from '@/vdb/index.js';
3
+ import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
3
4
  import { Link } from '@tanstack/react-router';
4
5
  import { ColumnFiltersState, SortingState } from '@tanstack/react-table';
5
6
  import { formatRelative } from 'date-fns';
@@ -1,7 +1,8 @@
1
1
  import { AnimatedCurrency, AnimatedNumber } from '@/vdb/components/shared/animated-number.js';
2
2
  import { Tabs, TabsList, TabsTrigger } from '@/vdb/components/ui/tabs.js';
3
3
  import { api } from '@/vdb/graphql/api.js';
4
- import { useChannel, useLocalFormat } from '@/vdb/index.js';
4
+ import { useChannel } from '@/vdb/hooks/use-channel.js';
5
+ import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
5
6
  import { useQuery } from '@tanstack/react-query';
6
7
  import { endOfDay, endOfMonth, startOfDay, startOfMonth, subDays, subMonths } from 'date-fns';
7
8
  import { useMemo, useState } from 'react';
@@ -0,0 +1,51 @@
1
+ import {
2
+ DataDisplayComponent,
3
+ DataInputComponent,
4
+ useComponentRegistry,
5
+ } from '@/vdb/framework/component-registry/component-registry.js';
6
+ import { generateInputComponentKey } from '@/vdb/framework/extension-api/input-component-extensions.js';
7
+ import { usePageBlock } from '@/vdb/hooks/use-page-block.js';
8
+ import { usePage } from '@/vdb/hooks/use-page.js';
9
+ import { ControllerRenderProps, FieldPath, FieldValues } from 'react-hook-form';
10
+
11
+ export interface OverriddenFormComponent<
12
+ TFieldValues extends FieldValues = any,
13
+ TName extends FieldPath<TFieldValues> = any,
14
+ > {
15
+ fieldName: string;
16
+ field: ControllerRenderProps<TFieldValues, TName>;
17
+ children?: React.ReactNode;
18
+ }
19
+
20
+ /**
21
+ * @description
22
+ * Based on the pageId and blockId of where this is placed, it will check whether any custom components
23
+ * are registered and render them if so. Otherwise, it will render the children, which act as the
24
+ * default if this location has not been overridden.
25
+ *
26
+ * ```tsx
27
+ * <OverriddenFormComponent fieldName="myField" field={field}>
28
+ * <Input {...field} />
29
+ * </OverriddenFormComponent>
30
+ * ```
31
+ */
32
+ export function OverriddenFormComponent({ fieldName, field, children }: Readonly<OverriddenFormComponent>) {
33
+ const page = usePage();
34
+ const pageBlock = usePageBlock({ optional: true });
35
+ const componentRegistry = useComponentRegistry();
36
+ let DisplayComponent: DataDisplayComponent | undefined;
37
+ let InputComponent: DataInputComponent | undefined;
38
+ if (page.pageId && pageBlock?.blockId) {
39
+ const customInputComponentKey = generateInputComponentKey(page.pageId, pageBlock.blockId, fieldName);
40
+ DisplayComponent = componentRegistry.getDisplayComponent(customInputComponentKey);
41
+ InputComponent = componentRegistry.getInputComponent(customInputComponentKey);
42
+ }
43
+ if (DisplayComponent) {
44
+ return <DisplayComponent {...field} />;
45
+ }
46
+
47
+ if (InputComponent) {
48
+ return <InputComponent {...field} />;
49
+ }
50
+ return children ?? null;
51
+ }
@@ -1,98 +1,128 @@
1
1
  import { CopyableText } from '@/vdb/components/shared/copyable-text.js';
2
2
  import { Button } from '@/vdb/components/ui/button.js';
3
3
  import { Popover, PopoverContent, PopoverTrigger } from '@/vdb/components/ui/popover.js';
4
+ import { usePageBlock } from '@/vdb/hooks/use-page-block.js';
4
5
  import { usePage } from '@/vdb/hooks/use-page.js';
5
6
  import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
6
- import { Trans } from '@/vdb/lib/trans.js';
7
7
  import { cn } from '@/vdb/lib/utils.js';
8
- import { CodeXmlIcon, InfoIcon } from 'lucide-react';
9
- import { createContext, useContext, useState } from 'react';
8
+ import { CodeXmlIcon } from 'lucide-react';
9
+ import React, { useEffect, useState } from 'react';
10
10
 
11
- const LocationWrapperContext = createContext<{
12
- parentId: string | null;
13
- hoveredId: string | null;
14
- setHoveredId: ((id: string | null) => void) | null;
15
- }>({
16
- parentId: null,
17
- hoveredId: null,
18
- setHoveredId: null,
19
- });
11
+ // Singleton state for hover tracking
12
+ let globalHoveredId: string | null = null;
13
+ const hoverListeners: Set<(id: string | null) => void> = new Set();
20
14
 
21
- export function LocationWrapper({
22
- children,
23
- blockId,
24
- }: Readonly<{ children: React.ReactNode; blockId?: string }>) {
15
+ const setGlobalHoveredId = (id: string | null) => {
16
+ globalHoveredId = id;
17
+ hoverListeners.forEach(listener => listener(id));
18
+ };
19
+
20
+ export interface LocationWrapperProps {
21
+ children: React.ReactNode;
22
+ identifier?: string;
23
+ }
24
+
25
+ export function LocationWrapper({ children, identifier }: Readonly<LocationWrapperProps>) {
25
26
  const page = usePage();
27
+ const pageBlock = usePageBlock({ optional: true });
26
28
  const { settings } = useUserSettings();
27
29
  const [isPopoverOpen, setIsPopoverOpen] = useState(false);
30
+ const blockId = pageBlock?.blockId ?? null;
28
31
  const isPageWrapper = !blockId;
29
32
 
30
- const [hoveredIdTopLevel, setHoveredIdTopLevel] = useState<string | null>(null);
31
- const { hoveredId, setHoveredId, parentId } = useContext(LocationWrapperContext);
32
- const id = `${page.pageId}-${blockId ?? 'page'}`;
33
- const isHovered = hoveredId === id || hoveredIdTopLevel === id;
33
+ const [hoveredId, setHoveredId] = useState<string | null>(globalHoveredId);
34
+ const id = `${page.pageId}-${blockId ?? 'page'}-${identifier ?? ''}`;
35
+ const isHovered = hoveredId === id;
36
+
37
+ // Subscribe to global hover changes
38
+ useEffect(() => {
39
+ const listener = (newHoveredId: string | null) => {
40
+ setHoveredId(newHoveredId);
41
+ };
42
+ hoverListeners.add(listener);
43
+ return () => {
44
+ hoverListeners.delete(listener);
45
+ };
46
+ }, []);
34
47
 
35
48
  const setHoverId = (id: string | null) => {
36
- if (setHoveredId) {
37
- setHoveredId(id);
38
- } else {
39
- setHoveredIdTopLevel(id);
49
+ setGlobalHoveredId(id);
50
+ };
51
+
52
+ const handleMouseEnter = () => {
53
+ // Set this element as hovered
54
+ setHoverId(id);
55
+ };
56
+
57
+ const handleMouseLeave = () => {
58
+ // If we're at the top level (page wrapper), go to null
59
+ // If we're at block level, go to page level
60
+ // If we're at identifier level, go to block level
61
+ if (isPageWrapper) {
62
+ setHoverId(null);
63
+ } else if (blockId && !identifier) {
64
+ // Block level - go to page level
65
+ setHoverId(`${page.pageId}-page-`);
66
+ } else if (identifier) {
67
+ // Identifier level - go to block level
68
+ setHoverId(`${page.pageId}-${blockId}-`);
40
69
  }
41
70
  };
42
71
 
43
72
  if (settings.devMode) {
44
73
  const pageId = page.pageId;
45
74
  return (
46
- <LocationWrapperContext.Provider
47
- value={{ hoveredId: hoveredIdTopLevel, setHoveredId: setHoveredIdTopLevel, parentId: id }}
75
+ <div
76
+ className={cn(
77
+ `ring-2 transition-all delay-50 relative`,
78
+ isHovered || isPopoverOpen ? 'ring-dev-mode' : 'ring-transparent',
79
+ isPageWrapper ? 'ring-inset' : '',
80
+ identifier ? 'rounded-md' : 'rounded-xl',
81
+ )}
82
+ onMouseEnter={handleMouseEnter}
83
+ onMouseLeave={handleMouseLeave}
48
84
  >
49
85
  <div
50
- className={cn(
51
- `ring-2 rounded-xl transition-all delay-50 relative`,
52
- isHovered || isPopoverOpen ? 'ring-dev-mode' : 'ring-transparent',
53
- isPageWrapper ? 'ring-inset' : '',
54
- )}
55
- onMouseEnter={() => setHoverId(id)}
56
- onMouseLeave={() => setHoverId(parentId)}
86
+ className={`absolute top-1 right-1 transition-all delay-50 z-10 ${isHovered || isPopoverOpen ? 'visible' : 'invisible'}`}
57
87
  >
58
- <div
59
- className={`absolute top-0.5 right-0.5 transition-all delay-50 z-10 ${isHovered || isPopoverOpen ? 'visible' : 'invisible'}`}
60
- >
61
- <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
62
- <PopoverTrigger asChild>
63
- <Button variant="ghost" size="icon" className="rounded-lg">
64
- <CodeXmlIcon className="text-dev-mode w-5 h-5" />
65
- </Button>
66
- </PopoverTrigger>
67
- <PopoverContent className="w-60">
68
- <div className="space-y-2">
69
- <div className="flex items-center gap-2">
70
- <InfoIcon className="h-4 w-4 text-dev-mode" />
71
- <span className="font-medium">
72
- <Trans>Location Details</Trans>
73
- </span>
74
- </div>
75
- <div className="space-y-1.5">
76
- {pageId && (
77
- <div>
78
- <div className="text-xs text-muted-foreground">pageId</div>
79
- <CopyableText text={pageId} />
80
- </div>
81
- )}
82
- {blockId && (
83
- <div>
84
- <div className="text-xs text-muted-foreground">blockId</div>
85
- <CopyableText text={blockId} />
86
- </div>
87
- )}
88
- </div>
88
+ <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
89
+ <PopoverTrigger asChild>
90
+ <Button
91
+ variant="secondary"
92
+ size="icon"
93
+ className="h-8 w-8 rounded-full bg-dev-mode/10 hover:bg-dev-mode/20 border border-dev-mode/20 shadow-sm"
94
+ >
95
+ <CodeXmlIcon className="text-dev-mode w-4 h-4" />
96
+ </Button>
97
+ </PopoverTrigger>
98
+ <PopoverContent className="w-48 p-3">
99
+ <div className="space-y-2">
100
+ <div className="space-y-1">
101
+ {pageId && (
102
+ <div className="text-xs">
103
+ <div className="text-muted-foreground mb-0.5">pageId</div>
104
+ <CopyableText text={pageId} />
105
+ </div>
106
+ )}
107
+ {blockId && (
108
+ <div className="text-xs">
109
+ <div className="text-muted-foreground mb-0.5">blockId</div>
110
+ <CopyableText text={blockId} />
111
+ </div>
112
+ )}
113
+ {identifier && (
114
+ <div className="text-xs">
115
+ <div className="text-muted-foreground mb-0.5">identifier</div>
116
+ <CopyableText text={identifier} />
117
+ </div>
118
+ )}
89
119
  </div>
90
- </PopoverContent>
91
- </Popover>
92
- </div>
93
- {children}
120
+ </div>
121
+ </PopoverContent>
122
+ </Popover>
94
123
  </div>
95
- </LocationWrapperContext.Provider>
124
+ {children}
125
+ </div>
96
126
  );
97
127
  }
98
128
  return children;
@@ -364,8 +364,8 @@ export function PageBlock({
364
364
  column,
365
365
  }: Readonly<PageBlockProps>) {
366
366
  return (
367
- <LocationWrapper blockId={blockId}>
368
- <PageBlockContext.Provider value={{ blockId, title, description, column }}>
367
+ <PageBlockContext.Provider value={{ blockId, title, description, column }}>
368
+ <LocationWrapper>
369
369
  <Card className={cn('w-full', className)}>
370
370
  {title || description ? (
371
371
  <CardHeader>
@@ -375,8 +375,8 @@ export function PageBlock({
375
375
  ) : null}
376
376
  <CardContent className={cn(!title ? 'pt-6' : '')}>{children}</CardContent>
377
377
  </Card>
378
- </PageBlockContext.Provider>
379
- </LocationWrapper>
378
+ </LocationWrapper>
379
+ </PageBlockContext.Provider>
380
380
  );
381
381
  }
382
382
 
@@ -397,11 +397,11 @@ export function FullWidthPageBlock({
397
397
  blockId,
398
398
  }: Pick<PageBlockProps, 'children' | 'className' | 'blockId'>) {
399
399
  return (
400
- <LocationWrapper blockId={blockId}>
401
- <PageBlockContext.Provider value={{ blockId, column: 'main' }}>
400
+ <PageBlockContext.Provider value={{ blockId, column: 'main' }}>
401
+ <LocationWrapper>
402
402
  <div className={cn('w-full', className)}>{children}</div>
403
- </PageBlockContext.Provider>
404
- </LocationWrapper>
403
+ </LocationWrapper>
404
+ </PageBlockContext.Provider>
405
405
  );
406
406
  }
407
407
 
@@ -19,8 +19,6 @@ import {
19
19
  import { TranslatableFormFieldWrapper } from '@/vdb/components/shared/translatable-form-field.js';
20
20
  import { FormControl } from '@/vdb/components/ui/form.js';
21
21
  import { ControllerRenderProps, FieldPath, FieldValues } from 'react-hook-form';
22
- import { useComponentRegistry } from '../component-registry/component-registry.js';
23
- import { generateInputComponentKey } from '../extension-api/input-component-extensions.js';
24
22
  import {
25
23
  CustomFieldsPageBlock,
26
24
  DetailFormGrid,
@@ -96,8 +94,6 @@ export interface DetailPageFieldProps<
96
94
  > {
97
95
  fieldInfo: FieldInfo;
98
96
  field: ControllerRenderProps<TFieldValues, TName>;
99
- blockId: string;
100
- pageId: string;
101
97
  }
102
98
 
103
99
  /**
@@ -106,21 +102,7 @@ export interface DetailPageFieldProps<
106
102
  function FieldInputRenderer<
107
103
  TFieldValues extends FieldValues = FieldValues,
108
104
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
109
- >({ fieldInfo, field, blockId, pageId }: DetailPageFieldProps<TFieldValues, TName>) {
110
- const componentRegistry = useComponentRegistry();
111
- const customInputComponentKey = generateInputComponentKey(pageId, blockId, fieldInfo.name);
112
-
113
- const DisplayComponent = componentRegistry.getDisplayComponent(customInputComponentKey);
114
- const InputComponent = componentRegistry.getInputComponent(customInputComponentKey);
115
-
116
- if (DisplayComponent) {
117
- return <DisplayComponent {...field} />;
118
- }
119
-
120
- if (InputComponent) {
121
- return <InputComponent {...field} />;
122
- }
123
-
105
+ >({ fieldInfo, field }: DetailPageFieldProps<TFieldValues, TName>) {
124
106
  switch (fieldInfo.type) {
125
107
  case 'Int':
126
108
  case 'Float':
@@ -244,12 +226,7 @@ export function DetailPage<
244
226
  label={fieldInfo.name}
245
227
  renderFormControl={false}
246
228
  render={({ field }) => (
247
- <FieldInputRenderer
248
- fieldInfo={fieldInfo}
249
- field={field}
250
- blockId="main-form"
251
- pageId={pageId}
252
- />
229
+ <FieldInputRenderer fieldInfo={fieldInfo} field={field} />
253
230
  )}
254
231
  />
255
232
  );
@@ -267,12 +244,7 @@ export function DetailPage<
267
244
  label={fieldInfo.name}
268
245
  renderFormControl={false}
269
246
  render={({ field }) => (
270
- <FieldInputRenderer
271
- fieldInfo={fieldInfo}
272
- field={field}
273
- blockId="main-form"
274
- pageId={pageId}
275
- />
247
+ <FieldInputRenderer fieldInfo={fieldInfo} field={field} />
276
248
  )}
277
249
  />
278
250
  );
@@ -1,9 +1,17 @@
1
1
  import { PageBlockContext } from '@/vdb/framework/layout-engine/page-block-provider.js';
2
2
  import { useContext } from 'react';
3
3
 
4
- export function usePageBlock() {
4
+ /**
5
+ * @description
6
+ * Returns the current PageBlock context, which means there must be
7
+ * a PageBlock ancestor component higher in the tree.
8
+ *
9
+ * If `optional` is set to true, the hook will not throw if no PageBlock
10
+ * exists higher in the tree, but will just return undefined.
11
+ */
12
+ export function usePageBlock({ optional }: { optional?: boolean } = {}) {
5
13
  const pageBlock = useContext(PageBlockContext);
6
- if (!pageBlock) {
14
+ if (!pageBlock && !optional) {
7
15
  throw new Error('PageBlockProvider not found');
8
16
  }
9
17
  return pageBlock;