@vendure/dashboard 3.5.0-minor-202510012036 → 3.5.0-minor-202510071456

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 (38) hide show
  1. package/dist/plugin/dashboard.plugin.d.ts +25 -6
  2. package/dist/plugin/dashboard.plugin.js +184 -27
  3. package/dist/plugin/default-page.html +188 -0
  4. package/dist/vite/utils/tsconfig-utils.js +2 -1
  5. package/package.json +10 -9
  6. package/src/app/routes/_authenticated/_global-settings/global-settings.tsx +4 -8
  7. package/src/app/routes/_authenticated/_global-settings/utils/global-languages.ts +268 -0
  8. package/src/app/routes/_authenticated/_orders/components/order-address.tsx +15 -15
  9. package/src/app/routes/_authenticated/_orders/components/order-detail-shared.tsx +4 -4
  10. package/src/app/routes/_authenticated/_product-variants/components/add-currency-dropdown.tsx +49 -0
  11. package/src/app/routes/_authenticated/_product-variants/components/add-stock-location-dropdown.tsx +56 -0
  12. package/src/app/routes/_authenticated/_product-variants/product-variants.graphql.ts +12 -0
  13. package/src/app/routes/_authenticated/_product-variants/product-variants_.$id.tsx +178 -50
  14. package/src/app/routes/_authenticated/_products/components/product-variants-table.tsx +0 -11
  15. package/src/app/routes/_authenticated/_promotions/promotions_.$id.tsx +3 -14
  16. package/src/app/routes/_authenticated/_shipping-methods/components/test-address-form.tsx +13 -12
  17. package/src/app/routes/_authenticated/_shipping-methods/components/test-order-builder.tsx +3 -2
  18. package/src/lib/components/data-input/customer-group-input.tsx +0 -1
  19. package/src/lib/components/data-input/money-input.tsx +7 -11
  20. package/src/lib/components/data-input/number-input.tsx +6 -1
  21. package/src/lib/components/data-table/data-table-filter-badge.tsx +15 -8
  22. package/src/lib/components/data-table/data-table.tsx +2 -2
  23. package/src/lib/components/data-table/my-views-button.tsx +12 -12
  24. package/src/lib/components/data-table/save-view-button.tsx +5 -1
  25. package/src/lib/components/layout/generated-breadcrumbs.tsx +4 -12
  26. package/src/lib/components/shared/configurable-operation-input.tsx +1 -1
  27. package/src/lib/constants.ts +10 -0
  28. package/src/lib/framework/dashboard-widget/latest-orders-widget/index.tsx +0 -2
  29. package/src/lib/framework/extension-api/types/layout.ts +41 -1
  30. package/src/lib/framework/form-engine/value-transformers.ts +8 -1
  31. package/src/lib/framework/layout-engine/page-layout.tsx +58 -48
  32. package/src/lib/framework/page/detail-page.tsx +12 -15
  33. package/src/lib/graphql/api.ts +17 -4
  34. package/src/lib/graphql/graphql-env.d.ts +29 -50
  35. package/src/lib/hooks/use-saved-views.ts +7 -0
  36. package/src/lib/providers/auth.tsx +2 -2
  37. package/src/lib/providers/channel-provider.tsx +4 -2
  38. package/src/lib/providers/user-settings.tsx +46 -5
@@ -7,9 +7,8 @@ import {
7
7
  } from '@/vdb/components/ui/breadcrumb.js';
8
8
  import type { NavMenuItem, NavMenuSection } from '@/vdb/framework/nav-menu/nav-menu-extensions.js';
9
9
  import { getNavMenuConfig } from '@/vdb/framework/nav-menu/nav-menu-extensions.js';
10
- import { useDisplayLocale } from '@/vdb/hooks/use-display-locale.js';
11
10
  import { useLingui } from '@lingui/react';
12
- import { Link, useRouter, useRouterState } from '@tanstack/react-router';
11
+ import { Link, useRouterState } from '@tanstack/react-router';
13
12
  import * as React from 'react';
14
13
  import { Fragment } from 'react';
15
14
 
@@ -25,11 +24,8 @@ export type PageBreadcrumb = BreadcrumbPair | BreadcrumbShorthand;
25
24
  export function GeneratedBreadcrumbs() {
26
25
  const matches = useRouterState({ select: s => s.matches });
27
26
  const currentPath = useRouterState({ select: s => s.location.pathname });
28
- const router = useRouter();
29
27
  const { i18n } = useLingui();
30
28
  const navMenuConfig = getNavMenuConfig();
31
- const { bcp47Tag } = useDisplayLocale();
32
- const basePath = router.basepath || '';
33
29
 
34
30
  const normalizeBreadcrumb = (breadcrumb: any, pathname: string): BreadcrumbPair[] => {
35
31
  if (typeof breadcrumb === 'string') {
@@ -58,12 +54,11 @@ export function GeneratedBreadcrumbs() {
58
54
  .flatMap(({ pathname, loaderData }) => normalizeBreadcrumb(loaderData.breadcrumb, pathname));
59
55
  }, [matches]);
60
56
 
61
- const isBaseRoute = (p: string) => p === basePath || p === `${basePath}/`;
57
+ const isBaseRoute = (p: string) => p === '' || p === `/`;
62
58
  const pageCrumbs: BreadcrumbPair[] = rawCrumbs.filter(c => !isBaseRoute(c.path));
63
59
 
64
60
  const normalizePath = (path: string): string => {
65
- const normalizedPath = basePath && path.startsWith(basePath) ? path.slice(basePath.length) : path;
66
- return normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`;
61
+ return path.startsWith('/') ? path : `/${path}`;
67
62
  };
68
63
 
69
64
  const pathMatches = (cleanPath: string, rawUrl?: string): boolean => {
@@ -115,10 +110,7 @@ export function GeneratedBreadcrumbs() {
115
110
  return undefined;
116
111
  };
117
112
 
118
- const sectionCrumb = React.useMemo(
119
- () => findSectionCrumb(currentPath),
120
- [currentPath, basePath, navMenuConfig],
121
- );
113
+ const sectionCrumb = React.useMemo(() => findSectionCrumb(currentPath), [currentPath, navMenuConfig]);
122
114
  const breadcrumbs: BreadcrumbPair[] = React.useMemo(() => {
123
115
  const arr = sectionCrumb ? [sectionCrumb, ...pageCrumbs] : pageCrumbs;
124
116
  return arr.filter(
@@ -144,7 +144,7 @@ export function interpolateDescription(
144
144
  (substring: string, argName: string) => {
145
145
  const normalizedArgName = argName.toLowerCase();
146
146
  const value = values.find(v => v.name === normalizedArgName)?.value;
147
- if (value == null) {
147
+ if (value == null || value === '') {
148
148
  return '_';
149
149
  }
150
150
  let formatted = value;
@@ -3,6 +3,16 @@ export const AUTHENTICATED_ROUTE_PREFIX = '/_authenticated';
3
3
  export const DEFAULT_CHANNEL_CODE = '__default_channel__';
4
4
  export const SUPER_ADMIN_ROLE_CODE = '__super_admin_role__';
5
5
  export const CUSTOMER_ROLE_CODE = '__customer_role__';
6
+
7
+ /**
8
+ * Local storage keys
9
+ */
10
+ export const LS_KEY_SESSION_TOKEN = 'vendure-session-token';
11
+ export const LS_KEY_USER_SETTINGS = 'vendure-user-settings';
12
+ export const LS_KEY_SELECTED_CHANNEL_TOKEN = 'vendure-selected-channel-token';
13
+ export const LS_KEY_SHIPPING_TEST_ORDER = 'vendure-shipping-test-order';
14
+ export const LS_KEY_SHIPPING_TEST_ADDRESS = 'vendure-shipping-test-address';
15
+
6
16
  /**
7
17
  * This is copied from the generated types from @vendure/common/lib/generated-types.d.ts
8
18
  * It is used to provide a list of available currency codes for the user to select from.
@@ -5,7 +5,6 @@ import {
5
5
  OrderStateCell,
6
6
  } from '@/vdb/components/shared/table-cell/order-table-cell-components.js';
7
7
  import { Button } from '@/vdb/components/ui/button.js';
8
- import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
9
8
  import { useLingui } from '@lingui/react/macro';
10
9
  import { Link } from '@tanstack/react-router';
11
10
  import { ColumnFiltersState, SortingState } from '@tanstack/react-table';
@@ -39,7 +38,6 @@ export function LatestOrdersWidget() {
39
38
  },
40
39
  },
41
40
  ]);
42
- const { formatCurrency } = useLocalFormat();
43
41
 
44
42
  // Update filters when date range changes
45
43
  useEffect(() => {
@@ -93,9 +93,49 @@ export type PageBlockLocation = {
93
93
  * @since 3.3.0
94
94
  */
95
95
  export interface DashboardPageBlockDefinition {
96
+ /**
97
+ * @description
98
+ * An ID for the page block. Should be unique at least
99
+ * to the page in which it appears.
100
+ */
96
101
  id: string;
102
+ /**
103
+ * @description
104
+ * An optional title for the page block
105
+ */
97
106
  title?: React.ReactNode;
107
+ /**
108
+ * @description
109
+ * The location of the page block. It specifies the pageId, and then the
110
+ * relative location compared to another existing block.
111
+ */
98
112
  location: PageBlockLocation;
99
- component: React.FunctionComponent<{ context: PageContextValue }>;
113
+ /**
114
+ * @description
115
+ * The component to be rendered inside the page block.
116
+ */
117
+ component?: React.FunctionComponent<{ context: PageContextValue }>;
118
+ /**
119
+ * @description
120
+ * Control whether to render the page block depending on your custom
121
+ * logic.
122
+ *
123
+ * This can also be used to disable any built-in blocks you
124
+ * do not need to display.
125
+ *
126
+ * If you need to query aspects about the current context not immediately
127
+ * provided in the `PageContextValue`, you can also use hooks such as
128
+ * `useChannel` in this function.
129
+ *
130
+ * @since 3.5.0
131
+ */
132
+ shouldRender?: (context: PageContextValue) => boolean;
133
+ /**
134
+ * @description
135
+ * If provided, the logged-in user must have one or more of the specified
136
+ * permissions in order for the block to render.
137
+ *
138
+ * For more advanced control over rendering, use the `shouldRender` function.
139
+ */
100
140
  requiresPermission?: string | string[];
101
141
  }
@@ -29,9 +29,16 @@ export const nativeValueTransformer: ValueTransformer = {
29
29
  */
30
30
  export const jsonStringValueTransformer: ValueTransformer = {
31
31
  parse: (value: string, fieldDef: ConfigurableFieldDef) => {
32
- if (!value) {
32
+ if (value === undefined) {
33
33
  return getDefaultValue(fieldDef);
34
34
  }
35
+ // This case arises often when the administrator is actively editing
36
+ // values and clears out the input. At that point, we don't want to suddenly
37
+ // switch to the default value otherwise it results in poor UX, e.g. pressing
38
+ // backspace to delete a number would result in `0` suddenly appearing as the value.
39
+ if (value === '') {
40
+ return value;
41
+ }
35
42
 
36
43
  try {
37
44
  // For JSON string mode, parse the string to get the native value
@@ -119,12 +119,12 @@ export function Page({ children, pageId, entity, form, submitHandler, ...props }
119
119
  }
120
120
 
121
121
  function PageContent({
122
- pageHeader,
123
- pageContent,
124
- form,
125
- submitHandler,
126
- ...props
127
- }: {
122
+ pageHeader,
123
+ pageContent,
124
+ form,
125
+ submitHandler,
126
+ ...props
127
+ }: {
128
128
  pageHeader: React.ReactNode;
129
129
  pageContent: React.ReactNode;
130
130
  form?: UseFormReturn<any>;
@@ -146,11 +146,11 @@ function PageContent({
146
146
  }
147
147
 
148
148
  export function PageContentWithOptionalForm({
149
- form,
150
- pageHeader,
151
- pageContent,
152
- submitHandler,
153
- }: {
149
+ form,
150
+ pageHeader,
151
+ pageContent,
152
+ submitHandler,
153
+ }: {
154
154
  form?: UseFormReturn<any>;
155
155
  pageHeader: React.ReactNode;
156
156
  pageContent: React.ReactNode;
@@ -235,22 +235,32 @@ export function PageLayout({ children, className }: Readonly<PageLayoutProps>) {
235
235
  childBlock.props.blockId ??
236
236
  (isOfType(childBlock, CustomFieldsPageBlock) ? 'custom-fields' : undefined);
237
237
  const extensionBlock = extensionBlocks.find(block => block.location.position.blockId === blockId);
238
+
238
239
  if (extensionBlock) {
239
- const ExtensionBlock = (
240
- <PageBlock
241
- key={childBlock.key}
242
- column={extensionBlock.location.column}
243
- blockId={extensionBlock.id}
244
- title={extensionBlock.title}
245
- >
246
- {<extensionBlock.component context={page} />}
247
- </PageBlock>
248
- );
240
+ let extensionBlockShouldRender = true;
241
+ if (typeof extensionBlock?.shouldRender === 'function') {
242
+ extensionBlockShouldRender = extensionBlock.shouldRender(page);
243
+ }
244
+ const ExtensionBlock =
245
+ extensionBlock.component && extensionBlockShouldRender ? (
246
+ <PageBlock
247
+ key={childBlock.key}
248
+ column={extensionBlock.location.column}
249
+ blockId={extensionBlock.id}
250
+ title={extensionBlock.title}
251
+ >
252
+ {<extensionBlock.component context={page} />}
253
+ </PageBlock>
254
+ ) : undefined;
249
255
  if (extensionBlock.location.position.order === 'before') {
250
- finalChildArray.push(ExtensionBlock, childBlock);
256
+ finalChildArray.push(...[ExtensionBlock, childBlock].filter(x => !!x));
251
257
  } else if (extensionBlock.location.position.order === 'after') {
252
- finalChildArray.push(childBlock, ExtensionBlock);
253
- } else if (extensionBlock.location.position.order === 'replace') {
258
+ finalChildArray.push(...[childBlock, ExtensionBlock].filter(x => !!x));
259
+ } else if (
260
+ extensionBlock.location.position.order === 'replace' &&
261
+ extensionBlockShouldRender &&
262
+ ExtensionBlock
263
+ ) {
254
264
  finalChildArray.push(ExtensionBlock);
255
265
  }
256
266
  } else {
@@ -425,9 +435,9 @@ function EntityInfoDropdown({ entity }: Readonly<{ entity: any }>) {
425
435
  * @since 3.3.0
426
436
  */
427
437
  export function PageActionBarRight({
428
- children,
429
- dropdownMenuItems,
430
- }: Readonly<{
438
+ children,
439
+ dropdownMenuItems,
440
+ }: Readonly<{
431
441
  children: React.ReactNode;
432
442
  dropdownMenuItems?: InlineDropdownItem[];
433
443
  }>) {
@@ -458,9 +468,9 @@ export function PageActionBarRight({
458
468
  }
459
469
 
460
470
  function PageActionBarItem({
461
- item,
462
- page,
463
- }: Readonly<{ item: DashboardActionBarItem; page: PageContextValue }>) {
471
+ item,
472
+ page,
473
+ }: Readonly<{ item: DashboardActionBarItem; page: PageContextValue }>) {
464
474
  return (
465
475
  <PermissionGuard requires={item.requiresPermission ?? []}>
466
476
  <item.component context={page} />
@@ -469,9 +479,9 @@ function PageActionBarItem({
469
479
  }
470
480
 
471
481
  function PageActionBarDropdown({
472
- items,
473
- page,
474
- }: Readonly<{ items: DashboardActionBarItem[]; page: PageContextValue }>) {
482
+ items,
483
+ page,
484
+ }: Readonly<{ items: DashboardActionBarItem[]; page: PageContextValue }>) {
475
485
  return (
476
486
  <DropdownMenu>
477
487
  <DropdownMenuTrigger asChild>
@@ -550,13 +560,13 @@ export type PageBlockProps = {
550
560
  * @since 3.3.0
551
561
  */
552
562
  export function PageBlock({
553
- children,
554
- title,
555
- description,
556
- className,
557
- blockId,
558
- column,
559
- }: Readonly<PageBlockProps>) {
563
+ children,
564
+ title,
565
+ description,
566
+ className,
567
+ blockId,
568
+ column,
569
+ }: Readonly<PageBlockProps>) {
560
570
  const contextValue = useMemo(
561
571
  () => ({
562
572
  blockId,
@@ -595,10 +605,10 @@ export function PageBlock({
595
605
  * @since 3.3.0
596
606
  */
597
607
  export function FullWidthPageBlock({
598
- children,
599
- className,
600
- blockId,
601
- }: Readonly<Pick<PageBlockProps, 'children' | 'className' | 'blockId'>>) {
608
+ children,
609
+ className,
610
+ blockId,
611
+ }: Readonly<Pick<PageBlockProps, 'children' | 'className' | 'blockId'>>) {
602
612
  const contextValue = useMemo(() => ({ blockId, column: 'main' as const }), [blockId]);
603
613
  return (
604
614
  <PageBlockContext.Provider value={contextValue}>
@@ -625,10 +635,10 @@ export function FullWidthPageBlock({
625
635
  * @since 3.3.0
626
636
  */
627
637
  export function CustomFieldsPageBlock({
628
- column,
629
- entityType,
630
- control,
631
- }: Readonly<{
638
+ column,
639
+ entityType,
640
+ control,
641
+ }: Readonly<{
632
642
  column: 'main' | 'side';
633
643
  entityType: string;
634
644
  control: Control<any, any>;
@@ -5,8 +5,8 @@ import { Checkbox } from '@/vdb/components/ui/checkbox.js';
5
5
  import { Input } from '@/vdb/components/ui/input.js';
6
6
  import { NEW_ENTITY_PATH } from '@/vdb/constants.js';
7
7
  import { useDetailPage } from '@/vdb/framework/page/use-detail-page.js';
8
- import { Trans } from '@lingui/react/macro';
9
8
  import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
9
+ import { Trans } from '@lingui/react/macro';
10
10
  import { AnyRoute, useNavigate } from '@tanstack/react-router';
11
11
  import { ResultOf, VariablesOf } from 'gql.tada';
12
12
  import { toast } from 'sonner';
@@ -16,6 +16,7 @@ import {
16
16
  getOperationVariablesFields,
17
17
  } from '../document-introspection/get-document-structure.js';
18
18
 
19
+ import { NumberInput } from '@/vdb/components/data-input/number-input.js';
19
20
  import { TranslatableFormFieldWrapper } from '@/vdb/components/shared/translatable-form-field.js';
20
21
  import { FormControl } from '@/vdb/components/ui/form.js';
21
22
  import { ControllerRenderProps, FieldPath, FieldValues } from 'react-hook-form';
@@ -108,11 +109,7 @@ function FieldInputRenderer<
108
109
  case 'Float':
109
110
  return (
110
111
  <FormControl>
111
- <Input
112
- type="number"
113
- value={field.value}
114
- onChange={e => field.onChange(e.target.valueAsNumber)}
115
- />
112
+ <NumberInput {...field} />
116
113
  </FormControl>
117
114
  );
118
115
  case 'DateTime':
@@ -152,15 +149,15 @@ export function DetailPage<
152
149
  C extends TypedDocumentNode<any, any>,
153
150
  U extends TypedDocumentNode<any, any>,
154
151
  >({
155
- pageId,
156
- route,
157
- entityName: passedEntityName,
158
- queryDocument,
159
- createDocument,
160
- updateDocument,
161
- setValuesForUpdate,
162
- title,
163
- }: DetailPageProps<T, C, U>) {
152
+ pageId,
153
+ route,
154
+ entityName: passedEntityName,
155
+ queryDocument,
156
+ createDocument,
157
+ updateDocument,
158
+ setValuesForUpdate,
159
+ title,
160
+ }: DetailPageProps<T, C, U>) {
164
161
  const params = route.useParams();
165
162
  const creatingNewEntity = params.id === NEW_ENTITY_PATH;
166
163
  const navigate = useNavigate();
@@ -1,3 +1,8 @@
1
+ import {
2
+ LS_KEY_SELECTED_CHANNEL_TOKEN,
3
+ LS_KEY_SESSION_TOKEN,
4
+ LS_KEY_USER_SETTINGS,
5
+ } from '@/vdb/constants.js';
1
6
  import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
2
7
  import { AwesomeGraphQLClient } from 'awesome-graphql-client';
3
8
  import { DocumentNode, print } from 'graphql';
@@ -12,8 +17,6 @@ const API_URL =
12
17
  `:${uiConfig.api.port !== 'auto' ? uiConfig.api.port : window.location.port}` +
13
18
  `/${uiConfig.api.adminApiPath}`;
14
19
 
15
- export const SELECTED_CHANNEL_TOKEN_KEY = 'vendure-selected-channel-token';
16
-
17
20
  export type Variables = object;
18
21
  export type RequestDocument = string | DocumentNode;
19
22
 
@@ -21,9 +24,13 @@ const awesomeClient = new AwesomeGraphQLClient({
21
24
  endpoint: API_URL,
22
25
  fetch: async (url: string, options: RequestInit = {}) => {
23
26
  // Get the active channel token from localStorage
24
- const channelToken = localStorage.getItem(SELECTED_CHANNEL_TOKEN_KEY);
27
+ const channelToken = localStorage.getItem(LS_KEY_SELECTED_CHANNEL_TOKEN);
28
+ const sessionToken = localStorage.getItem(LS_KEY_SESSION_TOKEN);
25
29
  const headers = new Headers(options.headers);
26
30
 
31
+ if (sessionToken) {
32
+ headers.set('Authorization', `Bearer ${sessionToken}`);
33
+ }
27
34
  if (channelToken) {
28
35
  headers.set(uiConfig.api.channelTokenKey, channelToken);
29
36
  }
@@ -31,7 +38,7 @@ const awesomeClient = new AwesomeGraphQLClient({
31
38
  // Get the content language from user settings and add as query parameter
32
39
  let finalUrl = url;
33
40
  try {
34
- const userSettings = localStorage.getItem('vendure-user-settings');
41
+ const userSettings = localStorage.getItem(LS_KEY_USER_SETTINGS);
35
42
  if (userSettings) {
36
43
  const settings = JSON.parse(userSettings);
37
44
  const contentLanguage = settings.contentLanguage;
@@ -52,6 +59,12 @@ const awesomeClient = new AwesomeGraphQLClient({
52
59
  headers,
53
60
  credentials: 'include',
54
61
  mode: 'cors',
62
+ }).then(res => {
63
+ const authToken = res.headers.get('vendure-auth-token');
64
+ if (authToken) {
65
+ localStorage.setItem(LS_KEY_SESSION_TOKEN, authToken);
66
+ }
67
+ return res;
55
68
  });
56
69
  },
57
70
  });