@vendure/dashboard 3.6.3-master-202605050307 → 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 +4 -4
- package/src/app/routes/_authenticated/_assets/assets.graphql.ts +6 -14
- package/src/lib/components/ui/alert-dialog.tsx +1 -1
- package/src/lib/components/ui/collapsible.tsx +1 -1
- package/src/lib/components/ui/dialog.tsx +1 -1
- package/src/lib/framework/layout-engine/page-layout.spec.tsx +98 -20
- package/src/lib/framework/layout-engine/page-layout.tsx +31 -21
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vendure/dashboard",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "3.6.3
|
|
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
|
|
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": "
|
|
141
|
-
"@vendure/core": "
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
34
|
-
);
|
|
25
|
+
}
|
|
26
|
+
`);
|
|
35
27
|
|
|
36
28
|
export const deleteAssetsDocument = graphql(`
|
|
37
29
|
mutation DeleteAssets($input: DeleteAssetsInput!) {
|
|
@@ -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:
|
|
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
|
-
|
|
192
|
+
<div data-testid="page-block-original">original</div>
|
|
182
193
|
</PageBlock>,
|
|
183
|
-
|
|
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
|
-
|
|
211
|
+
<div data-testid="page-block-original">original</div>
|
|
201
212
|
</PageBlock>,
|
|
202
|
-
|
|
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
|
-
|
|
225
|
+
<div data-testid="page-block-original">original</div>
|
|
215
226
|
</PageBlock>,
|
|
216
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|