@vendure/dashboard 3.6.3-master-202605030307 → 3.6.3

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.6.3-master-202605030307",
4
+ "version": "3.6.3",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -96,7 +96,7 @@
96
96
  "@types/react-dom": "^19.0.4",
97
97
  "@uidotdev/usehooks": "^2.4.1",
98
98
  "@vendure-io/design-tokens": "^1.1.2",
99
- "@vendure-io/ui": "^1.0.5",
99
+ "@vendure-io/ui": "^1.1.0",
100
100
  "@vitejs/plugin-react": "^5.2.0",
101
101
  "acorn": "^8.16.0",
102
102
  "acorn-walk": "^8.3.5",
@@ -137,8 +137,8 @@
137
137
  "@storybook/addon-vitest": "^10.3.1",
138
138
  "@storybook/react-vite": "^10.3.1",
139
139
  "@types/node": "^22.19.0",
140
- "@vendure/common": "^3.6.3-master-202605030307",
141
- "@vendure/core": "^3.6.3-master-202605030307",
140
+ "@vendure/common": "3.6.3",
141
+ "@vendure/core": "3.6.3",
142
142
  "@vitest/browser": "^3.2.4",
143
143
  "@vitest/coverage-v8": "^3.2.4",
144
144
  "eslint": "^9.39.0",
@@ -17,21 +17,13 @@ export const assetDetailDocument = graphql(
17
17
  [assetFragment],
18
18
  );
19
19
 
20
- export const assetUpdateDocument = graphql(
21
- `
22
- mutation AssetUpdate($input: UpdateAssetInput!) {
23
- updateAsset(input: $input) {
24
- ...Asset
25
- tags {
26
- id
27
- value
28
- }
29
- customFields
30
- }
20
+ export const assetUpdateDocument = graphql(`
21
+ mutation AssetUpdate($input: UpdateAssetInput!) {
22
+ updateAsset(input: $input) {
23
+ id
31
24
  }
32
- `,
33
- [assetFragment],
34
- );
25
+ }
26
+ `);
35
27
 
36
28
  export const deleteAssetsDocument = graphql(`
37
29
  mutation DeleteAssets($input: DeleteAssetsInput!) {
@@ -27,6 +27,7 @@ function PromotionListPage() {
27
27
  listQuery={promotionListDocument}
28
28
  route={Route}
29
29
  title={<Trans>Promotions</Trans>}
30
+ defaultSort={[{ id: 'createdAt', desc: true }]}
30
31
  defaultVisibility={{
31
32
  name: true,
32
33
  couponCode: true,
@@ -1,5 +1,5 @@
1
1
  import { cn } from '@/vdb/lib/utils.js';
2
- import { AlertDialog as AlertDialogPrimitive } from '@base-ui/react/alert-dialog';
2
+ import { AlertDialogPrimitive } from '@vendure-io/ui/lib/base-ui';
3
3
 
4
4
  export {
5
5
  AlertDialog,
@@ -1,4 +1,4 @@
1
- import { Collapsible as CollapsiblePrimitive } from '@base-ui/react/collapsible';
1
+ import { CollapsiblePrimitive } from '@vendure-io/ui/lib/base-ui';
2
2
  import { cn } from '@/vdb/lib/utils.js';
3
3
 
4
4
  function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
@@ -1,5 +1,5 @@
1
1
  import { cn } from '@/vdb/lib/utils.js';
2
- import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
2
+ import { DialogPrimitive } from '@vendure-io/ui/lib/base-ui';
3
3
 
4
4
  export {
5
5
  Dialog,
@@ -2,16 +2,17 @@ import React from 'react';
2
2
  import { renderToStaticMarkup } from 'react-dom/server';
3
3
  import { beforeEach, describe, expect, it, vi } from 'vitest';
4
4
 
5
+ import { UserSettingsContext, type UserSettingsContextType } from '../../providers/user-settings.js';
6
+ import type { ActionBarItemPosition } from '../extension-api/types/layout.js';
7
+ import { globalRegistry } from '../registry/global-registry.js';
5
8
  import { ActionBarItem } from './action-bar-item-wrapper.js';
6
- import { PageActionBar, PageBlock, PageLayout } from './page-layout.js';
7
9
  import { registerDashboardActionBarItem, registerDashboardPageBlock } from './layout-extensions.js';
10
+ import { PageActionBar, PageBlock, PageLayout } from './page-layout.js';
8
11
  import { PageContext } from './page-provider.js';
9
- import { globalRegistry } from '../registry/global-registry.js';
10
- import { UserSettingsContext, type UserSettingsContextType } from '../../providers/user-settings.js';
11
- import type { ActionBarItemPosition } from '../extension-api/types/layout.js';
12
12
 
13
13
  const useIsMobileMock = vi.hoisted(() => vi.fn(() => false));
14
14
  const useCopyToClipboardMock = vi.hoisted(() => vi.fn(() => [null, vi.fn()]));
15
+ const hasPermissionsMock = vi.hoisted(() => vi.fn((_perms: string[]) => true));
15
16
 
16
17
  vi.mock('@/vdb/hooks/use-mobile.js', () => ({
17
18
  useIsMobile: useIsMobileMock,
@@ -23,7 +24,7 @@ vi.mock('@uidotdev/usehooks', () => ({
23
24
 
24
25
  vi.mock('@/vdb/hooks/use-permissions.js', () => ({
25
26
  usePermissions: () => ({
26
- hasPermissions: () => true,
27
+ hasPermissions: hasPermissionsMock,
27
28
  }),
28
29
  }));
29
30
 
@@ -37,6 +38,8 @@ function registerBlock(
37
38
  id: string,
38
39
  order: 'before' | 'after' | 'replace',
39
40
  pageId = 'customer-list',
41
+ requiresPermission: string[] = [],
42
+ shouldRender?: () => boolean,
40
43
  ): void {
41
44
  registerDashboardPageBlock({
42
45
  id,
@@ -47,14 +50,12 @@ function registerBlock(
47
50
  position: { blockId: 'list-table', order },
48
51
  },
49
52
  component: ({ context }) => <div data-testid={`page-block-${id}`}>{context.pageId}</div>,
50
- });
53
+ requiresPermission,
54
+ shouldRender,
55
+ });
51
56
  }
52
57
 
53
- function registerActionBarItem(
54
- id: string,
55
- position?: ActionBarItemPosition,
56
- pageId = 'customer-list',
57
- ): void {
58
+ function registerActionBarItem(id: string, position?: ActionBarItemPosition, pageId = 'customer-list'): void {
58
59
  registerDashboardActionBarItem({
59
60
  pageId,
60
61
  id,
@@ -152,6 +153,15 @@ function renderActionBar(
152
153
  );
153
154
  }
154
155
 
156
+ function renderWithOriginal({ isDesktop = true } = {}) {
157
+ return renderPageLayout(
158
+ <PageBlock column="main" blockId="list-table">
159
+ <div data-testid="page-block-original">original</div>
160
+ </PageBlock>,
161
+ { isDesktop },
162
+ );
163
+ }
164
+
155
165
  function getRenderedBlockIds(markup: string) {
156
166
  return Array.from(markup.matchAll(/data-testid="(page-block-[^"]+)"/g)).map(match => match[1]);
157
167
  }
@@ -165,6 +175,7 @@ describe('PageLayout', () => {
165
175
  useIsMobileMock.mockReset();
166
176
  useCopyToClipboardMock.mockReset();
167
177
  useCopyToClipboardMock.mockReturnValue([null, vi.fn()]);
178
+ hasPermissionsMock.mockReset();
168
179
  const pageBlockRegistry = globalRegistry.get('dashboardPageBlockRegistry');
169
180
  pageBlockRegistry.clear();
170
181
  const actionBarItemRegistry = globalRegistry.get('dashboardActionBarItemRegistry');
@@ -178,10 +189,10 @@ describe('PageLayout', () => {
178
189
 
179
190
  const markup = renderPageLayout(
180
191
  <PageBlock column="main" blockId="list-table">
181
- <div data-testid="page-block-original">original</div>
192
+ <div data-testid="page-block-original">original</div>
182
193
  </PageBlock>,
183
- { isDesktop: true },
184
- );
194
+ { isDesktop: true },
195
+ );
185
196
 
186
197
  expect(getRenderedBlockIds(markup)).toEqual([
187
198
  'page-block-before-1',
@@ -197,10 +208,10 @@ describe('PageLayout', () => {
197
208
 
198
209
  const markup = renderPageLayout(
199
210
  <PageBlock column="main" blockId="list-table">
200
- <div data-testid="page-block-original">original</div>
211
+ <div data-testid="page-block-original">original</div>
201
212
  </PageBlock>,
202
- { isDesktop: true },
203
- );
213
+ { isDesktop: true },
214
+ );
204
215
 
205
216
  expect(getRenderedBlockIds(markup)).toEqual(['page-block-replacement-1', 'page-block-replacement-2']);
206
217
  });
@@ -211,10 +222,10 @@ describe('PageLayout', () => {
211
222
 
212
223
  const markup = renderPageLayout(
213
224
  <PageBlock column="main" blockId="list-table">
214
- <div data-testid="page-block-original">original</div>
225
+ <div data-testid="page-block-original">original</div>
215
226
  </PageBlock>,
216
- { isDesktop: false },
217
- );
227
+ { isDesktop: false },
228
+ );
218
229
 
219
230
  expect(getRenderedBlockIds(markup)).toEqual([
220
231
  'page-block-before-mobile',
@@ -223,6 +234,73 @@ describe('PageLayout', () => {
223
234
  ]);
224
235
  });
225
236
 
237
+ it("won't render blocks without required permissions", () => {
238
+ hasPermissionsMock.mockReturnValue(false);
239
+
240
+ registerBlock('permission-guard', 'before', 'customer-list', ['permission-2']);
241
+
242
+ expect(getRenderedBlockIds(renderWithOriginal())).toEqual(['page-block-original']);
243
+ });
244
+
245
+ // #4679 follow-up — when a `replace`-ordered extension block requires a permission the user
246
+ // does not have, the slot is rendered empty instead of falling back to the original child.
247
+ // Root cause: `replacementBlockExists` is computed in page-layout.tsx without consulting
248
+ // `hasPermissions`, so the original block is suppressed even though the replacement is then
249
+ // filtered out by the new permission check at the `ExtensionBlock` resolution site.
250
+ it('falls back to the original block when a replace-ordered extension is denied by permissions', () => {
251
+ hasPermissionsMock.mockReturnValue(false);
252
+
253
+ registerBlock('permission-replacement', 'replace', 'customer-list', ['restricted-permission']);
254
+
255
+ expect(getRenderedBlockIds(renderWithOriginal())).toEqual(['page-block-original']);
256
+ });
257
+
258
+ // Multi-block variant of the regression test above. With the bug present, `replacementBlockExists`
259
+ // would be `true` (any block with `order === 'replace'` triggered it, regardless of permissions),
260
+ // suppressing the original. The expected result `[]` would have failed this assertion.
261
+ it('falls back to the original block when ALL replace-ordered extensions are permission-denied', () => {
262
+ hasPermissionsMock.mockReturnValue(false);
263
+
264
+ registerBlock('replacement-1', 'replace', 'customer-list', ['perm-1']);
265
+ registerBlock('replacement-2', 'replace', 'customer-list', ['perm-2']);
266
+
267
+ expect(getRenderedBlockIds(renderWithOriginal())).toEqual(['page-block-original']);
268
+ });
269
+
270
+ // Documents the mixed-permission case: when at least one replacement is allowed,
271
+ // the original child is suppressed (since a real replacement renders) and only
272
+ // permitted replacements appear. Note: this scenario passes both pre- and post-fix
273
+ // — it covers the per-block JSX gate, not `replacementBlockExists`.
274
+ it('renders only allowed replacements when replace permissions are mixed (original suppressed)', () => {
275
+ hasPermissionsMock.mockImplementation((perms: string[]) => perms.includes('allowed-perm'));
276
+
277
+ registerBlock('replacement-allowed', 'replace', 'customer-list', ['allowed-perm']);
278
+ registerBlock('replacement-denied', 'replace', 'customer-list', ['denied-perm']);
279
+
280
+ // toEqual is exact — proves replacement-denied is absent AND original is absent
281
+ expect(getRenderedBlockIds(renderWithOriginal())).toEqual(['page-block-replacement-allowed']);
282
+ });
283
+
284
+ it('falls back to the original block when a replace-ordered extension shouldRender returns false', () => {
285
+ // Explicit: hasPermissions must return true so that shouldRender is the *only* veto under test.
286
+ // Without this, the post-`mockReset` mock returns undefined (falsy) and the test would pass
287
+ // for the wrong reason if check ordering inside `willBlockRender` were ever changed.
288
+ hasPermissionsMock.mockReturnValue(true);
289
+
290
+ registerBlock('replacement-disabled', 'replace', 'customer-list', [], () => false);
291
+
292
+ expect(getRenderedBlockIds(renderWithOriginal())).toEqual(['page-block-original']);
293
+ });
294
+
295
+ it('renders only the original when both before- and replace-ordered extensions are permission-denied', () => {
296
+ hasPermissionsMock.mockReturnValue(false);
297
+
298
+ registerBlock('before-denied', 'before', 'customer-list', ['restricted']);
299
+ registerBlock('replace-denied', 'replace', 'customer-list', ['restricted']);
300
+
301
+ expect(getRenderedBlockIds(renderWithOriginal())).toEqual(['page-block-original']);
302
+ });
303
+
226
304
  it('positions an extension action bar item before another extension item', () => {
227
305
  registerActionBarItem('a');
228
306
  registerActionBarItem('b', { itemId: 'a', order: 'before' });
@@ -7,6 +7,7 @@ import { useCustomFieldConfig } from '@/vdb/hooks/use-custom-field-config.js';
7
7
  import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
8
8
  import { useIsMobile } from '@/vdb/hooks/use-mobile.js';
9
9
  import { usePage } from '@/vdb/hooks/use-page.js';
10
+ import { usePermissions } from '@/vdb/hooks/use-permissions.js';
10
11
  import { cn } from '@/vdb/lib/utils.js';
11
12
  import { useCopyToClipboard } from '@uidotdev/usehooks';
12
13
  import { CheckIcon, CopyIcon, EllipsisVerticalIcon, InfoIcon } from 'lucide-react';
@@ -218,6 +219,7 @@ export function PageLayout({ children, className }: Readonly<PageLayoutProps>) {
218
219
  // Separate blocks into categories
219
220
  const childArray: React.ReactElement<PageBlockProps>[] = [];
220
221
  const extensionBlocks = getDashboardPageBlocks(page.pageId ?? '');
222
+ const { hasPermissions } = usePermissions();
221
223
  React.Children.forEach(children, child => {
222
224
  if (isPageBlock(child)) {
223
225
  childArray.push(child);
@@ -250,18 +252,29 @@ export function PageLayout({ children, className }: Readonly<PageLayoutProps>) {
250
252
  return orderPriority[a.location.position.order] - orderPriority[b.location.position.order];
251
253
  });
252
254
 
255
+ type ExtensionBlockEntry = (typeof arrangedExtensionBlocks)[number];
256
+
257
+ // using `hasPermissions` over `PermissionGuard` as this would defeat the `isPageBlock` typeguard
258
+ const willBlockRender = (
259
+ block: ExtensionBlockEntry,
260
+ ): block is ExtensionBlockEntry & { component: NonNullable<ExtensionBlockEntry['component']> } => {
261
+ if (!block.component) return false;
262
+ if (typeof block.shouldRender === 'function' && !block.shouldRender(page)) {
263
+ return false;
264
+ }
265
+ const required = block.requiresPermission ?? [];
266
+ return hasPermissions(Array.isArray(required) ? required : [required]);
267
+ };
268
+
269
+ // A `replace`-ordered block only counts as a replacement when it would actually render —
270
+ // otherwise the original child must be kept as a fallback.
253
271
  const replacementBlockExists = arrangedExtensionBlocks.some(
254
- block => block.location.position.order === 'replace',
272
+ block => block.location.position.order === 'replace' && willBlockRender(block),
255
273
  );
256
274
 
257
275
  let childBlockInserted = false;
258
276
  if (matchingExtensionBlocks.length > 0) {
259
277
  for (const extensionBlock of arrangedExtensionBlocks) {
260
- let extensionBlockShouldRender = true;
261
- if (typeof extensionBlock?.shouldRender === 'function') {
262
- extensionBlockShouldRender = extensionBlock.shouldRender(page);
263
- }
264
-
265
278
  // Insert child block before the first non-"before" block
266
279
  if (
267
280
  !childBlockInserted &&
@@ -272,24 +285,21 @@ export function PageLayout({ children, className }: Readonly<PageLayoutProps>) {
272
285
  childBlockInserted = true;
273
286
  }
274
287
 
288
+ if (!willBlockRender(extensionBlock)) continue;
289
+
275
290
  const isFullWidth = extensionBlock.location.column === 'full';
276
291
  const BlockComponent = isFullWidth ? FullWidthPageBlock : PageBlock;
277
292
 
278
- const ExtensionBlock =
279
- extensionBlock.component && extensionBlockShouldRender ? (
280
- <BlockComponent
281
- key={extensionBlock.id}
282
- column={extensionBlock.location.column}
283
- blockId={extensionBlock.id}
284
- title={extensionBlock.title}
285
- >
286
- {<extensionBlock.component context={page} />}
287
- </BlockComponent>
288
- ) : undefined;
289
-
290
- if (extensionBlockShouldRender && ExtensionBlock) {
291
- finalChildArray.push(ExtensionBlock);
292
- }
293
+ finalChildArray.push(
294
+ <BlockComponent
295
+ key={extensionBlock.id}
296
+ column={extensionBlock.location.column}
297
+ blockId={extensionBlock.id}
298
+ title={extensionBlock.title}
299
+ >
300
+ <extensionBlock.component context={page} />
301
+ </BlockComponent>,
302
+ );
293
303
  }
294
304
 
295
305
  // If all blocks were "before", insert child block at the end