Add initial project files
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AdminDTO } from '#core/AccessControl/data/api/AccessControlDTO';
|
||||
import AdminMapper from '#core/AccessControl/data/mappers/AdminMapper';
|
||||
import { AdminStatusEnum } from '#core/AccessControl/entities/AdminEntity';
|
||||
|
||||
describe('AdminMapper', () => {
|
||||
it('maps an admin DTO with nested roles and permissions', () => {
|
||||
const mapper = new AdminMapper();
|
||||
const dto: AdminDTO = {
|
||||
id: 12,
|
||||
status: 'active',
|
||||
firstName: 'Grace',
|
||||
lastName: 'Hopper',
|
||||
email: 'grace@example.com',
|
||||
createdAt: '2026-01-15T10:20:30.000Z',
|
||||
updatedAt: '2026-01-16T10:20:30.000Z',
|
||||
roles: [
|
||||
{
|
||||
id: 9,
|
||||
name: 'Supervisor',
|
||||
createdAt: '2026-01-10T10:20:30.000Z',
|
||||
updatedAt: '2026-01-11T10:20:30.000Z',
|
||||
permissions: [
|
||||
{
|
||||
name: 'Read admins',
|
||||
slug: 'admin.admins.read'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
expect(mapper.toEntity(dto)).toEqual({
|
||||
id: 12,
|
||||
status: AdminStatusEnum.ACTIVE,
|
||||
firstName: 'Grace',
|
||||
lastName: 'Hopper',
|
||||
email: 'grace@example.com',
|
||||
createdAt: '2026-01-15T10:20:30.000Z',
|
||||
updatedAt: '2026-01-16T10:20:30.000Z',
|
||||
roles: [
|
||||
{
|
||||
id: 9,
|
||||
name: 'Supervisor',
|
||||
createdAt: '2026-01-10T10:20:30.000Z',
|
||||
updatedAt: '2026-01-11T10:20:30.000Z',
|
||||
permissions: [
|
||||
{
|
||||
name: 'Read admins',
|
||||
slug: 'admin.admins.read'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults missing roles to an empty array', () => {
|
||||
const mapper = new AdminMapper();
|
||||
const dto = {
|
||||
id: 13,
|
||||
status: 'inactive',
|
||||
firstName: 'Linus',
|
||||
lastName: 'Torvalds',
|
||||
email: 'linus@example.com',
|
||||
createdAt: '2026-01-15T10:20:30.000Z',
|
||||
updatedAt: '2026-01-16T10:20:30.000Z'
|
||||
} as AdminDTO;
|
||||
|
||||
expect(mapper.toEntity(dto)).toEqual({
|
||||
id: 13,
|
||||
status: AdminStatusEnum.INACTIVE,
|
||||
firstName: 'Linus',
|
||||
lastName: 'Torvalds',
|
||||
email: 'linus@example.com',
|
||||
createdAt: '2026-01-15T10:20:30.000Z',
|
||||
updatedAt: '2026-01-16T10:20:30.000Z',
|
||||
roles: []
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { PermissionDTO } from '#core/AccessControl/data/api/AccessControlDTO';
|
||||
import PermissionMapper from '#core/AccessControl/data/mappers/PermissionMapper';
|
||||
|
||||
describe('PermissionMapper', () => {
|
||||
it('maps a permission DTO to an entity', () => {
|
||||
const mapper = new PermissionMapper();
|
||||
const dto: PermissionDTO = {
|
||||
name: 'Manage users',
|
||||
slug: 'admin.users.manage'
|
||||
};
|
||||
|
||||
expect(mapper.toEntity(dto)).toEqual({
|
||||
name: 'Manage users',
|
||||
slug: 'admin.users.manage'
|
||||
});
|
||||
});
|
||||
|
||||
it('maps permission DTO arrays', () => {
|
||||
const mapper = new PermissionMapper();
|
||||
|
||||
expect(
|
||||
mapper.toEntities([
|
||||
{
|
||||
name: 'Read users',
|
||||
slug: 'admin.users.read'
|
||||
},
|
||||
{
|
||||
name: 'Create users',
|
||||
slug: 'admin.users.create'
|
||||
}
|
||||
])
|
||||
).toEqual([
|
||||
{
|
||||
name: 'Read users',
|
||||
slug: 'admin.users.read'
|
||||
},
|
||||
{
|
||||
name: 'Create users',
|
||||
slug: 'admin.users.create'
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { RoleDTO } from '#core/AccessControl/data/api/AccessControlDTO';
|
||||
import RoleMapper from '#core/AccessControl/data/mappers/RoleMapper';
|
||||
|
||||
describe('RoleMapper', () => {
|
||||
it('maps a role DTO with nested permissions', () => {
|
||||
const mapper = new RoleMapper();
|
||||
const dto: RoleDTO = {
|
||||
id: 3,
|
||||
name: 'Manager',
|
||||
createdAt: '2026-01-15T10:20:30.000Z',
|
||||
updatedAt: '2026-01-16T10:20:30.000Z',
|
||||
permissions: [
|
||||
{
|
||||
name: 'Read admins',
|
||||
slug: 'admin.admins.read'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
expect(mapper.toEntity(dto)).toEqual({
|
||||
id: 3,
|
||||
name: 'Manager',
|
||||
createdAt: '2026-01-15T10:20:30.000Z',
|
||||
updatedAt: '2026-01-16T10:20:30.000Z',
|
||||
permissions: [
|
||||
{
|
||||
name: 'Read admins',
|
||||
slug: 'admin.admins.read'
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults missing permissions to an empty array', () => {
|
||||
const mapper = new RoleMapper();
|
||||
const dto = {
|
||||
id: 4,
|
||||
name: 'Empty role'
|
||||
} as RoleDTO;
|
||||
|
||||
expect(mapper.toEntity(dto)).toEqual({
|
||||
id: 4,
|
||||
name: 'Empty role',
|
||||
createdAt: undefined,
|
||||
updatedAt: undefined,
|
||||
permissions: []
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { GetUserResponse } from '#core/Auth/data/api/dto';
|
||||
import UserMapper from '#core/Auth/data/mappers/UserMapper';
|
||||
|
||||
describe('UserMapper', () => {
|
||||
it('maps authenticated user response to entity', () => {
|
||||
const dto: GetUserResponse = {
|
||||
user: {
|
||||
id: 42,
|
||||
email: 'admin@example.com',
|
||||
firstName: 'Ada',
|
||||
lastName: 'Lovelace',
|
||||
status: 'active',
|
||||
createdAt: '2026-01-15T10:20:30.000Z',
|
||||
roles: [
|
||||
{
|
||||
id: 7,
|
||||
name: 'Owner'
|
||||
}
|
||||
]
|
||||
},
|
||||
permissions: ['admin.users.read', 'admin.users.write']
|
||||
};
|
||||
|
||||
expect(UserMapper.toEntity(dto)).toEqual({
|
||||
id: 42,
|
||||
email: 'admin@example.com',
|
||||
firstName: 'Ada',
|
||||
lastName: 'Lovelace',
|
||||
status: 'active',
|
||||
createdAt: '2026-01-15T10:20:30.000Z',
|
||||
roles: [
|
||||
{
|
||||
id: 7,
|
||||
name: 'Owner'
|
||||
}
|
||||
],
|
||||
permissions: ['admin.users.read', 'admin.users.write']
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { EntryDTO } from '#core/FileManager/data/api/dto';
|
||||
import EntryMapper from '#core/FileManager/data/mappers/EntryMapper';
|
||||
import { EntryType } from '#core/FileManager/entities/entry';
|
||||
|
||||
describe('EntryMapper', () => {
|
||||
it('maps folder DTOs and normalizes paths', () => {
|
||||
const mapper = new EntryMapper('/parent');
|
||||
const dto: EntryDTO = {
|
||||
name: 'Invoices',
|
||||
path: 'documents/invoices',
|
||||
type: 'folder',
|
||||
updatedAt: '2026-01-15T10:20:30.000Z'
|
||||
};
|
||||
|
||||
expect(mapper.toEntity(dto)).toEqual({
|
||||
name: 'Invoices',
|
||||
path: '/documents/invoices',
|
||||
updatedAt: '2026-01-15T10:20:30.000Z',
|
||||
type: EntryType.FOLDER,
|
||||
parent: '/parent'
|
||||
});
|
||||
});
|
||||
|
||||
it('maps file DTOs with size, URL, and extension', () => {
|
||||
const mapper = new EntryMapper('/documents');
|
||||
const dto: EntryDTO = {
|
||||
name: 'report.final.pdf',
|
||||
path: '/documents/report.final.pdf',
|
||||
type: 'file',
|
||||
size: 2048,
|
||||
updatedAt: '2026-01-15T10:20:30.000Z',
|
||||
url: 'https://example.com/report.final.pdf'
|
||||
};
|
||||
|
||||
expect(mapper.toEntity(dto)).toEqual({
|
||||
name: 'report.final.pdf',
|
||||
path: '/documents/report.final.pdf',
|
||||
updatedAt: '2026-01-15T10:20:30.000Z',
|
||||
type: EntryType.FILE,
|
||||
size: 2048,
|
||||
parent: '/documents',
|
||||
url: 'https://example.com/report.final.pdf',
|
||||
extension: '.pdf'
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults missing file size and empty extensions', () => {
|
||||
const mapper = new EntryMapper('/');
|
||||
const dto: EntryDTO = {
|
||||
name: 'README',
|
||||
path: 'README',
|
||||
type: 'file'
|
||||
};
|
||||
|
||||
expect(mapper.toEntity(dto)).toEqual({
|
||||
name: 'README',
|
||||
path: '/README',
|
||||
updatedAt: undefined,
|
||||
type: EntryType.FILE,
|
||||
size: 0,
|
||||
parent: '/',
|
||||
url: undefined,
|
||||
extension: ''
|
||||
});
|
||||
});
|
||||
|
||||
it('maps entry DTO arrays', () => {
|
||||
const mapper = new EntryMapper('/root');
|
||||
|
||||
expect(
|
||||
mapper.toEntities([
|
||||
{
|
||||
name: 'Images',
|
||||
path: 'images',
|
||||
type: 'folder'
|
||||
},
|
||||
{
|
||||
name: 'avatar.png',
|
||||
path: 'images/avatar.png',
|
||||
type: 'file',
|
||||
size: 512
|
||||
}
|
||||
])
|
||||
).toEqual([
|
||||
{
|
||||
name: 'Images',
|
||||
path: '/images',
|
||||
updatedAt: undefined,
|
||||
type: EntryType.FOLDER,
|
||||
parent: '/root'
|
||||
},
|
||||
{
|
||||
name: 'avatar.png',
|
||||
path: '/images/avatar.png',
|
||||
updatedAt: undefined,
|
||||
type: EntryType.FILE,
|
||||
size: 512,
|
||||
parent: '/root',
|
||||
url: undefined,
|
||||
extension: '.png'
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { hasPermission } from '#core/Permission/lib/hasPermission';
|
||||
|
||||
describe('hasPermission', () => {
|
||||
it('returns true for an admin regardless of permissions', () => {
|
||||
expect(hasPermission([], ['pages.write'], { isAdmin: true })).toBe(true);
|
||||
expect(hasPermission([], 'pages.write', { isAdmin: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when no permissions are required', () => {
|
||||
expect(hasPermission([], [])).toBe(true);
|
||||
expect(hasPermission(['pages.read'], [])).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a single required permission as a string', () => {
|
||||
expect(hasPermission(['pages.read'], 'pages.read')).toBe(true);
|
||||
expect(hasPermission(['pages.read'], 'pages.write')).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults to mode "all"', () => {
|
||||
expect(hasPermission(['pages.read'], ['pages.read', 'pages.write'])).toBe(false);
|
||||
expect(hasPermission(['pages.read', 'pages.write'], ['pages.read', 'pages.write'])).toBe(true);
|
||||
});
|
||||
|
||||
it('mode "any" passes when at least one permission matches', () => {
|
||||
expect(hasPermission(['pages.read'], ['pages.read', 'pages.write'], { mode: 'any' })).toBe(
|
||||
true
|
||||
);
|
||||
expect(hasPermission(['users.read'], ['pages.read', 'pages.write'], { mode: 'any' })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false when the user has no permissions and one is required', () => {
|
||||
expect(hasPermission([], ['pages.read'])).toBe(false);
|
||||
expect(hasPermission([], ['pages.read'], { mode: 'any' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createIntl } from 'react-intl';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { WEB_STUDIO_BLOCK_CONFIG_BY_TYPE, WEB_STUDIO_BLOCK_CONFIGS } from '#core/WebStudio/config/blocks';
|
||||
import en from '#core/WebStudio/config/translation/en.json';
|
||||
import { BlockType } from '#core/WebStudio/entities/BlockType';
|
||||
import { createBlockSchema } from '#core/WebStudio/view/forms/blocks/blockSchema';
|
||||
|
||||
const intl = createIntl({ locale: 'en', messages: en });
|
||||
const blockSchema = createBlockSchema(intl);
|
||||
|
||||
describe('WEB_STUDIO_BLOCK_CONFIGS', () => {
|
||||
it('covers every block type exactly once', () => {
|
||||
const types = WEB_STUDIO_BLOCK_CONFIGS.map((config) => config.type);
|
||||
|
||||
expect([...types].sort()).toEqual(Object.values(BlockType).sort());
|
||||
expect(new Set(types).size).toBe(types.length);
|
||||
});
|
||||
|
||||
it.each(WEB_STUDIO_BLOCK_CONFIGS.map((config) => [config.type, config] as const))(
|
||||
'createBlock for "%s" produces a block that passes the block schema',
|
||||
(type, config) => {
|
||||
const block = config.createBlock('some-id');
|
||||
const result = blockSchema.safeParse(block);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(block.type).toBe(type);
|
||||
expect(block.id).toBe('some-id');
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual(block);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('indexes every config by its type', () => {
|
||||
for (const config of WEB_STUDIO_BLOCK_CONFIGS) {
|
||||
expect(WEB_STUDIO_BLOCK_CONFIG_BY_TYPE[config.type]).toBe(config);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { IntlShape } from 'react-intl';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getPermittedMenuItems } from '#app/layouts/06__MultipleMenus/lib';
|
||||
import { createWebStudioMenu } from '#core/WebStudio/config/menu';
|
||||
|
||||
const intl = { formatMessage: ({ id }: { id: string }) => id } as IntlShape;
|
||||
|
||||
/** Flattens the surviving tree to the set of reachable paths. */
|
||||
function visiblePaths(permissions: string[]): string[] {
|
||||
const [menu] = getPermittedMenuItems([createWebStudioMenu(intl)], permissions);
|
||||
const paths: string[] = [];
|
||||
|
||||
const walk = (items: NonNullable<ReturnType<typeof createWebStudioMenu>['children']>) => {
|
||||
for (const item of items) {
|
||||
if (item.path) paths.push(item.path);
|
||||
if (item.children) walk(item.children);
|
||||
}
|
||||
};
|
||||
|
||||
if (menu?.children) walk(menu.children);
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
describe('Web Studio menu permissions', () => {
|
||||
it('hides the whole module from an admin without web_manager permissions', () => {
|
||||
expect(getPermittedMenuItems([createWebStudioMenu(intl)], [])).toEqual([]);
|
||||
expect(getPermittedMenuItems([createWebStudioMenu(intl)], ['file_manager.view'])).toEqual([]);
|
||||
});
|
||||
|
||||
it('shows only the listing to a view-only admin', () => {
|
||||
const paths = visiblePaths(['web_manager.view']);
|
||||
|
||||
expect(paths).toContain('/web-studio/pages/listing');
|
||||
expect(paths).not.toContain('/web-studio/pages/-/create');
|
||||
expect(paths).not.toContain('/web-studio/settings/header');
|
||||
});
|
||||
|
||||
it('reveals page creation only with view and create together', () => {
|
||||
expect(visiblePaths(['web_manager.create'])).not.toContain('/web-studio/pages/-/create');
|
||||
expect(visiblePaths(['web_manager.view', 'web_manager.create'])).toContain(
|
||||
'/web-studio/pages/-/create'
|
||||
);
|
||||
});
|
||||
|
||||
it('reveals settings only with view and settings together', () => {
|
||||
expect(visiblePaths(['web_manager.settings'])).not.toContain('/web-studio/settings/header');
|
||||
|
||||
const paths = visiblePaths(['web_manager.view', 'web_manager.settings']);
|
||||
expect(paths).toContain('/web-studio/settings/header');
|
||||
expect(paths).toContain('/web-studio/settings/footer');
|
||||
expect(paths).toContain('/web-studio/settings/legal');
|
||||
});
|
||||
|
||||
it('shows everything to an admin holding every web_manager permission', () => {
|
||||
const paths = visiblePaths([
|
||||
'web_manager.view',
|
||||
'web_manager.create',
|
||||
'web_manager.update',
|
||||
'web_manager.publish',
|
||||
'web_manager.delete',
|
||||
'web_manager.settings'
|
||||
]);
|
||||
|
||||
expect(paths).toContain('/web-studio/pages/listing');
|
||||
expect(paths).toContain('/web-studio/pages/-/create');
|
||||
expect(paths).toContain('/web-studio/settings/legal');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import PagePayloadSerializer from '#core/WebStudio/data/api/PagePayloadSerializer';
|
||||
import type { CreatePagePayloadDTO } from '#core/WebStudio/data/api/WebStudioDTO';
|
||||
|
||||
const createPayload = (): CreatePagePayloadDTO => ({
|
||||
locale: 'en',
|
||||
path: 'about-us',
|
||||
groupKey: 'about-us',
|
||||
pageType: 'landing',
|
||||
title: 'About us',
|
||||
hero: {
|
||||
type: 'middle',
|
||||
title: '',
|
||||
ctaItems: [
|
||||
{
|
||||
linkType: 'external',
|
||||
externalUrl: 'https://example.com',
|
||||
openInNewTab: false,
|
||||
label: 'Go',
|
||||
variant: 'primary'
|
||||
}
|
||||
],
|
||||
media: {
|
||||
url: {
|
||||
name: 'local-hero.png',
|
||||
file: new File(['hero'], 'local-hero.png', { type: 'image/png' })
|
||||
},
|
||||
alt: ''
|
||||
}
|
||||
},
|
||||
seoTitle: '',
|
||||
seoDescription: '',
|
||||
components: [
|
||||
{
|
||||
type: 'media',
|
||||
data: { media: { url: { name: 'cloud.png', ref: '/cloud.png' }, alt: 'Cloud' } }
|
||||
},
|
||||
{
|
||||
type: 'media',
|
||||
data: {
|
||||
media: {
|
||||
url: { name: 'local-2.png', file: new File(['x'], 'local-2.png', { type: 'image/png' }) },
|
||||
alt: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
describe('PagePayloadSerializer', () => {
|
||||
it('replaces local files with file keys and appends them as binary parts', () => {
|
||||
const formData = PagePayloadSerializer.serialize(createPayload());
|
||||
const json = JSON.parse(formData.get('payload') as string);
|
||||
|
||||
expect(json.hero.media.url).toEqual({ name: 'local-hero.png', fileKey: 'file-0' });
|
||||
expect(json.components[1].data.media.url).toEqual({ name: 'local-2.png', fileKey: 'file-1' });
|
||||
|
||||
expect(formData.get('file-0')).toBeInstanceOf(File);
|
||||
expect((formData.get('file-0') as File).name).toBe('local-hero.png');
|
||||
expect((formData.get('file-1') as File).name).toBe('local-2.png');
|
||||
});
|
||||
|
||||
it('keeps cloud references untouched', () => {
|
||||
const formData = PagePayloadSerializer.serialize(createPayload());
|
||||
const json = JSON.parse(formData.get('payload') as string);
|
||||
|
||||
expect(json.components[0].data.media.url).toEqual({ name: 'cloud.png', ref: '/cloud.png' });
|
||||
});
|
||||
|
||||
it('preserves value types that bracket-notation multipart would lose', () => {
|
||||
const formData = PagePayloadSerializer.serialize(createPayload());
|
||||
const json = JSON.parse(formData.get('payload') as string);
|
||||
|
||||
expect(json.hero.ctaItems[0].openInNewTab).toBe(false);
|
||||
expect(json.hero.title).toBe('');
|
||||
});
|
||||
|
||||
it('sends no binary parts when the payload has no local files', () => {
|
||||
const payload = createPayload();
|
||||
payload.hero = { type: 'middle', ctaItems: [], media: { url: null, alt: '' } };
|
||||
payload.components = [payload.components[0]];
|
||||
|
||||
const formData = PagePayloadSerializer.serialize(payload);
|
||||
const json = JSON.parse(formData.get('payload') as string);
|
||||
|
||||
expect(formData.get('file-0')).toBeNull();
|
||||
expect(json.hero.media.url).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { PageListItemDTO } from '#core/WebStudio/data/api/WebStudioDTO';
|
||||
import PageListMapper from '#core/WebStudio/data/mappers/PageListMapper';
|
||||
import { PageStatus } from '#core/WebStudio/entities/PageStatus';
|
||||
|
||||
describe('PageListMapper', () => {
|
||||
it('maps list fields and the draft clone identifier', () => {
|
||||
const dto: PageListItemDTO = {
|
||||
id: 12,
|
||||
status: 'published',
|
||||
draftOfId: null,
|
||||
locale: 'en',
|
||||
path: 'company/about-us',
|
||||
groupKey: 'about',
|
||||
pageType: 'landing',
|
||||
title: 'About us',
|
||||
seoTitle: 'About Crux',
|
||||
updatedAt: '2026-07-23T10:20:30.000Z',
|
||||
draft: { id: 18 }
|
||||
};
|
||||
|
||||
expect(PageListMapper.toEntity(dto)).toEqual({
|
||||
id: 12,
|
||||
status: PageStatus.PUBLISHED,
|
||||
draftOfId: null,
|
||||
locale: 'en',
|
||||
path: 'company/about-us',
|
||||
groupKey: 'about',
|
||||
pageType: 'landing',
|
||||
title: 'About us',
|
||||
seoTitle: 'About Crux',
|
||||
updatedAt: '2026-07-23T10:20:30.000Z',
|
||||
draftId: 18
|
||||
});
|
||||
});
|
||||
|
||||
it('uses null when a page has no draft clone', () => {
|
||||
const dto = {
|
||||
id: 13,
|
||||
status: 'draft',
|
||||
draftOfId: null,
|
||||
locale: 'uk',
|
||||
path: 'pro-nas',
|
||||
groupKey: 'about',
|
||||
pageType: 'landing',
|
||||
title: 'Про нас',
|
||||
seoTitle: 'Про нас',
|
||||
updatedAt: '2026-07-23T10:20:30.000Z'
|
||||
} satisfies PageListItemDTO;
|
||||
|
||||
expect(PageListMapper.toEntity(dto).draftId).toBeNull();
|
||||
});
|
||||
});
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import { createIntl } from 'react-intl';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import en from '#core/WebStudio/config/translation/en.json';
|
||||
import type { PageDetailsDTO } from '#core/WebStudio/data/api/WebStudioDTO';
|
||||
import toEditorValues from '#core/WebStudio/pages/PageEditPage/helpers/toEditorValues';
|
||||
import { createValidationSchema } from '#core/WebStudio/view/forms/PageEditorForm/validationSchema';
|
||||
|
||||
const intl = createIntl({ locale: 'en', messages: en });
|
||||
const validationSchema = createValidationSchema(intl);
|
||||
|
||||
const createDetails = (): PageDetailsDTO => ({
|
||||
page: {
|
||||
id: 9,
|
||||
status: 'draft',
|
||||
draftOfId: null,
|
||||
locale: 'en',
|
||||
path: 'about',
|
||||
groupKey: 'about',
|
||||
pageType: 'landing',
|
||||
title: 'About',
|
||||
hero: {
|
||||
type: 'middle',
|
||||
media: { alt: null, url: null },
|
||||
title: null,
|
||||
content: { content: null },
|
||||
ctaItems: [],
|
||||
subtitle: null
|
||||
},
|
||||
seoTitle: '',
|
||||
seoDescription: '',
|
||||
publishedAt: null,
|
||||
createdAt: '2026-07-24T07:26:32.285+00:00',
|
||||
updatedAt: '2026-07-24T07:26:32.285+00:00'
|
||||
},
|
||||
components: [
|
||||
{
|
||||
id: 8,
|
||||
pageId: 9,
|
||||
type: 'content',
|
||||
data: {
|
||||
title: null,
|
||||
subtitle: null,
|
||||
columns: [{ content: { content: null }, ctaItems: null }]
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
describe('toEditorValues', () => {
|
||||
it('replaces legacy null values with editor defaults', () => {
|
||||
const values = toEditorValues(createDetails());
|
||||
|
||||
expect(values.hero.title).toBe('');
|
||||
expect(values.hero.subtitle).toBe('');
|
||||
expect(values.hero.content).toEqual({ content: '' });
|
||||
expect(values.hero.media).toEqual({ url: null, alt: '' });
|
||||
|
||||
const contentBlock = values.blocks[0] as {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
columns: Array<{ content: { content: string }; ctaItems: unknown[] }>;
|
||||
};
|
||||
expect(contentBlock.title).toBe('');
|
||||
expect(contentBlock.subtitle).toBe('');
|
||||
expect(contentBlock.columns[0].content).toEqual({ content: '' });
|
||||
expect(contentBlock.columns[0].ctaItems).toEqual([]);
|
||||
});
|
||||
|
||||
it('produces values that pass the editor validation schema', () => {
|
||||
const values = toEditorValues(createDetails());
|
||||
const result = validationSchema.safeParse(values);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps stored values when they are present', () => {
|
||||
const details = createDetails();
|
||||
details.page.hero = {
|
||||
type: 'high',
|
||||
media: { alt: 'Alt', url: { name: 'a.png', ref: '/a.png' } },
|
||||
title: 'Hero title',
|
||||
content: { content: '<p>Body</p>' },
|
||||
ctaItems: [],
|
||||
subtitle: 'Sub'
|
||||
};
|
||||
|
||||
const values = toEditorValues(details);
|
||||
|
||||
expect(values.hero.type).toBe('high');
|
||||
expect(values.hero.title).toBe('Hero title');
|
||||
expect(values.hero.media).toEqual({ alt: 'Alt', url: { name: 'a.png', ref: '/a.png' } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { toApiPath, toFormPath } from '#core/WebStudio/services/pagePath';
|
||||
|
||||
describe('pagePath', () => {
|
||||
describe('toFormPath', () => {
|
||||
it('converts the home API path to "/"', () => {
|
||||
expect(toFormPath('')).toBe('/');
|
||||
});
|
||||
|
||||
it('prefixes an API path with a slash', () => {
|
||||
expect(toFormPath('blog/post')).toBe('/blog/post');
|
||||
});
|
||||
|
||||
it('keeps an already-prefixed path unchanged', () => {
|
||||
expect(toFormPath('/about')).toBe('/about');
|
||||
});
|
||||
|
||||
it('collapses repeated leading slashes to one', () => {
|
||||
expect(toFormPath('///about')).toBe('/about');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toApiPath', () => {
|
||||
it('converts the home form path to ""', () => {
|
||||
expect(toApiPath('/')).toBe('');
|
||||
});
|
||||
|
||||
it('strips the leading slash', () => {
|
||||
expect(toApiPath('/blog/post')).toBe('blog/post');
|
||||
});
|
||||
|
||||
it('strips repeated leading slashes', () => {
|
||||
expect(toApiPath('///about')).toBe('about');
|
||||
});
|
||||
|
||||
it('leaves a path without a leading slash unchanged', () => {
|
||||
expect(toApiPath('about')).toBe('about');
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip', () => {
|
||||
it.each(['', 'about', 'blog/post'])('toApiPath(toFormPath("%s")) returns the input', (path) => {
|
||||
expect(toApiPath(toFormPath(path))).toBe(path);
|
||||
});
|
||||
|
||||
it.each(['/', '/about', '/blog/post'])(
|
||||
'toFormPath(toApiPath("%s")) returns the input',
|
||||
(path) => {
|
||||
expect(toFormPath(toApiPath(path))).toBe(path);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not strip inner or trailing slashes', () => {
|
||||
expect(toApiPath('/a//b/')).toBe('a//b/');
|
||||
expect(toFormPath('a//b/')).toBe('/a//b/');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createIntl } from 'react-intl';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import en from '#core/WebStudio/config/translation/en.json';
|
||||
import { getSeoDescriptionQuality, getSeoTextQuality, getSeoTitleQuality } from '#core/WebStudio/services/seo';
|
||||
import { createPageEditorInitialValues } from '#core/WebStudio/view/forms/PageEditorForm/form';
|
||||
import { createValidationSchema } from '#core/WebStudio/view/forms/PageEditorForm/validationSchema';
|
||||
|
||||
const intl = createIntl({ locale: 'en', messages: en });
|
||||
const validationSchema = createValidationSchema(intl);
|
||||
|
||||
describe('SEO text quality', () => {
|
||||
it('marks empty text as bad', () => {
|
||||
expect(getSeoTextQuality(' ', 30, 60)).toMatchObject({
|
||||
status: 'bad',
|
||||
reason: 'empty',
|
||||
length: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('marks text below the recommended minimum as a warning', () => {
|
||||
expect(getSeoTitleQuality('Short title')).toMatchObject({
|
||||
status: 'warning',
|
||||
reason: 'short'
|
||||
});
|
||||
});
|
||||
|
||||
it('marks text inside the recommended range as good', () => {
|
||||
expect(getSeoTitleQuality('A clear and descriptive page title')).toMatchObject({
|
||||
status: 'good',
|
||||
reason: 'good'
|
||||
});
|
||||
});
|
||||
|
||||
it('marks text over the maximum as bad', () => {
|
||||
expect(getSeoDescriptionQuality('x'.repeat(161))).toMatchObject({
|
||||
status: 'bad',
|
||||
reason: 'long',
|
||||
length: 161
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts SEO text over the recommended length; the quality indicator flags it instead', () => {
|
||||
const values = createPageEditorInitialValues({
|
||||
title: 'Page title',
|
||||
groupKey: 'page-title'
|
||||
});
|
||||
values.seo.title = 'x'.repeat(61);
|
||||
values.seo.description = 'x'.repeat(161);
|
||||
|
||||
expect(validationSchema.safeParse(values).success).toBe(true);
|
||||
expect(getSeoTitleQuality(values.seo.title)).toMatchObject({ status: 'bad', reason: 'long' });
|
||||
expect(getSeoDescriptionQuality(values.seo.description)).toMatchObject({
|
||||
status: 'bad',
|
||||
reason: 'long'
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects SEO text over the backend storage bounds', () => {
|
||||
const values = createPageEditorInitialValues({
|
||||
title: 'Page title',
|
||||
groupKey: 'page-title'
|
||||
});
|
||||
values.seo.title = 'x'.repeat(256);
|
||||
|
||||
expect(validationSchema.safeParse(values).success).toBe(false);
|
||||
|
||||
values.seo.title = '';
|
||||
values.seo.description = 'x'.repeat(2001);
|
||||
|
||||
expect(validationSchema.safeParse(values).success).toBe(false);
|
||||
});
|
||||
});
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { WEB_STUDIO_BLOCK_CONFIG_BY_TYPE } from '#core/WebStudio/config/blocks';
|
||||
import { BlockType } from '#core/WebStudio/entities/BlockType';
|
||||
import { HeroType } from '#core/WebStudio/entities/HeroType';
|
||||
import { PageType } from '#core/WebStudio/entities/PageType';
|
||||
import { createPageEditorInitialValues } from '#core/WebStudio/view/forms/PageEditorForm/form';
|
||||
import { toCreatePayload } from '#core/WebStudio/view/forms/PageEditorForm/helpers/toCreatePayload';
|
||||
|
||||
const createBlock = (type: BlockType, id: string) =>
|
||||
WEB_STUDIO_BLOCK_CONFIG_BY_TYPE[type].createBlock(id);
|
||||
|
||||
describe('toCreatePayload', () => {
|
||||
it('filters out blocks that are not allowed for the page type', () => {
|
||||
const values = createPageEditorInitialValues({
|
||||
title: 'Post',
|
||||
groupKey: 'post',
|
||||
pageType: PageType.BLOG_ITEM,
|
||||
path: '/blog/post',
|
||||
blocks: [createBlock(BlockType.CONTENT, 'b1'), createBlock(BlockType.ARTICLES, 'b2')]
|
||||
});
|
||||
|
||||
const payload = toCreatePayload(values, 'en');
|
||||
|
||||
expect(payload.components).toHaveLength(1);
|
||||
expect(payload.components[0].type).toBe(BlockType.CONTENT);
|
||||
});
|
||||
|
||||
it('forces the fixed path for the blog page type regardless of the form path', () => {
|
||||
const values = createPageEditorInitialValues({
|
||||
title: 'Blog',
|
||||
groupKey: 'blog',
|
||||
pageType: PageType.BLOG,
|
||||
path: '/whatever'
|
||||
});
|
||||
|
||||
const payload = toCreatePayload(values, 'en');
|
||||
|
||||
expect(payload.path).toBe('blog');
|
||||
});
|
||||
|
||||
it('strips block ids and nests remaining block fields under data', () => {
|
||||
const values = createPageEditorInitialValues({
|
||||
title: 'Landing',
|
||||
groupKey: 'landing',
|
||||
pageType: PageType.LANDING,
|
||||
blocks: [createBlock(BlockType.FAQ, 'faq-id')]
|
||||
});
|
||||
|
||||
const payload = toCreatePayload(values, 'en');
|
||||
|
||||
expect(payload.components).toEqual([
|
||||
{
|
||||
type: BlockType.FAQ,
|
||||
data: {
|
||||
title: '',
|
||||
subtitle: '',
|
||||
items: [{ question: '', answer: '' }]
|
||||
}
|
||||
}
|
||||
]);
|
||||
expect(payload.components[0]).not.toHaveProperty('id');
|
||||
expect(payload.components[0].data).not.toHaveProperty('id');
|
||||
});
|
||||
|
||||
it('converts the form path to an API path (leading slash removed)', () => {
|
||||
const values = createPageEditorInitialValues({
|
||||
title: 'About',
|
||||
groupKey: 'about',
|
||||
pageType: PageType.LANDING,
|
||||
path: '/about/team'
|
||||
});
|
||||
|
||||
expect(toCreatePayload(values, 'en').path).toBe('about/team');
|
||||
});
|
||||
|
||||
it('converts the home form path "/" to the empty API path', () => {
|
||||
const values = createPageEditorInitialValues({
|
||||
title: 'Home',
|
||||
groupKey: 'home',
|
||||
pageType: PageType.LANDING,
|
||||
path: '/'
|
||||
});
|
||||
|
||||
expect(toCreatePayload(values, 'en').path).toBe('');
|
||||
});
|
||||
|
||||
it('passes through locale, hero and SEO fields', () => {
|
||||
const values = createPageEditorInitialValues({
|
||||
title: 'About',
|
||||
groupKey: 'about',
|
||||
pageType: PageType.LANDING,
|
||||
path: '/about'
|
||||
});
|
||||
values.hero = {
|
||||
type: HeroType.HIGH,
|
||||
title: 'Hero title',
|
||||
subtitle: 'Hero subtitle',
|
||||
content: { content: '<p>Body</p>' },
|
||||
ctaItems: [],
|
||||
media: { url: null, alt: '' }
|
||||
};
|
||||
values.seo.title = 'SEO title';
|
||||
values.seo.description = 'SEO description';
|
||||
|
||||
const payload = toCreatePayload(values, 'uk');
|
||||
|
||||
expect(payload.locale).toBe('uk');
|
||||
expect(payload.groupKey).toBe('about');
|
||||
expect(payload.pageType).toBe(PageType.LANDING);
|
||||
expect(payload.title).toBe('About');
|
||||
expect(payload.hero).toEqual(values.hero);
|
||||
expect(payload.hero).not.toBe(values.hero);
|
||||
expect(payload.seoTitle).toBe('SEO title');
|
||||
expect(payload.seoDescription).toBe('SEO description');
|
||||
});
|
||||
});
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import { createIntl } from 'react-intl';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { WEB_STUDIO_BLOCK_CONFIG_BY_TYPE } from '#core/WebStudio/config/blocks';
|
||||
import en from '#core/WebStudio/config/translation/en.json';
|
||||
import { BlockType } from '#core/WebStudio/entities/BlockType';
|
||||
import { HeroType } from '#core/WebStudio/entities/HeroType';
|
||||
import { createPageEditorInitialValues } from '#core/WebStudio/view/forms/PageEditorForm/form';
|
||||
import { createValidationSchema } from '#core/WebStudio/view/forms/PageEditorForm/validationSchema';
|
||||
|
||||
const intl = createIntl({ locale: 'en', messages: en });
|
||||
const validationSchema = createValidationSchema(intl);
|
||||
|
||||
const createValues = () =>
|
||||
createPageEditorInitialValues({ title: 'Page title', groupKey: 'page-title' });
|
||||
|
||||
const parseWith = (patch: Partial<ReturnType<typeof createValues>>) =>
|
||||
validationSchema.safeParse({ ...createValues(), ...patch });
|
||||
|
||||
describe('page editor validation schema', () => {
|
||||
describe('path', () => {
|
||||
it.each(['/', '/about', '/a-b/c'])('accepts "%s"', (path) => {
|
||||
expect(parseWith({ path }).success).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['/about/', '/About', '//a', 'about', '/a--b', '/-a'])('rejects "%s"', (path) => {
|
||||
expect(parseWith({ path }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace before validating', () => {
|
||||
const result = parseWith({ path: ' /about ' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.data.path).toBe('/about');
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupKey', () => {
|
||||
it.each(['about', 'a-b', 'a1-b2'])('accepts "%s"', (groupKey) => {
|
||||
expect(parseWith({ groupKey }).success).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['', 'About', 'a_b', '-a', 'a-', 'a b', 'a--b'])('rejects "%s"', (groupKey) => {
|
||||
expect(parseWith({ groupKey }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a group key over 255 characters', () => {
|
||||
expect(parseWith({ groupKey: 'a'.repeat(256) }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('title', () => {
|
||||
it('rejects an empty title', () => {
|
||||
expect(parseWith({ title: '' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a whitespace-only title (trimmed before the min check)', () => {
|
||||
expect(parseWith({ title: ' ' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('trims the title', () => {
|
||||
const result = parseWith({ title: ' Hello ' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.data.title).toBe('Hello');
|
||||
});
|
||||
|
||||
it('rejects a title over 255 characters and accepts one at the limit', () => {
|
||||
expect(parseWith({ title: 'x'.repeat(255) }).success).toBe(true);
|
||||
expect(parseWith({ title: 'x'.repeat(256) }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('blocks', () => {
|
||||
const buildBlocks = (count: number) =>
|
||||
Array.from({ length: count }, (_, index) =>
|
||||
WEB_STUDIO_BLOCK_CONFIG_BY_TYPE[BlockType.CONTENT].createBlock(`block-${index}`)
|
||||
);
|
||||
|
||||
it('accepts 200 blocks', () => {
|
||||
expect(parseWith({ blocks: buildBlocks(200) }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects 201 blocks', () => {
|
||||
expect(parseWith({ blocks: buildBlocks(201) }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hero', () => {
|
||||
const heroBase = {
|
||||
title: 'Hero',
|
||||
subtitle: 'Sub',
|
||||
content: { content: '' },
|
||||
ctaItems: [],
|
||||
media: { url: null, alt: '' }
|
||||
};
|
||||
|
||||
it.each([HeroType.NONE, HeroType.LOW, HeroType.MIDDLE, HeroType.HIGH])(
|
||||
'accepts hero type "%s"',
|
||||
(type) => {
|
||||
expect(parseWith({ hero: { ...heroBase, type } }).success).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects an unknown hero type', () => {
|
||||
const result = validationSchema.safeParse({
|
||||
...createValues(),
|
||||
hero: { ...heroBase, type: 'huge' }
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { createIntl } from 'react-intl';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import en from '#core/WebStudio/config/translation/en.json';
|
||||
import { createMediaFieldSchema } from '#core/WebStudio/view/forms/fields/MediaField/schema';
|
||||
import { createNavigationLinkSchema } from '#core/WebStudio/view/forms/fields/NavigationLinkField/schema';
|
||||
|
||||
const intl = createIntl({ locale: 'en', messages: en });
|
||||
|
||||
describe('navigation link schema', () => {
|
||||
const schema = createNavigationLinkSchema(intl);
|
||||
|
||||
const base = {
|
||||
openInNewTab: false,
|
||||
label: 'Read more',
|
||||
variant: 'primary' as const
|
||||
};
|
||||
|
||||
it('accepts an internal link with a pageId', () => {
|
||||
const result = schema.safeParse({ ...base, linkType: 'internal', pageId: '42' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an internal link with an empty pageId', () => {
|
||||
expect(schema.safeParse({ ...base, linkType: 'internal', pageId: '' }).success).toBe(false);
|
||||
expect(schema.safeParse({ ...base, linkType: 'internal', pageId: ' ' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an internal link without a pageId', () => {
|
||||
expect(schema.safeParse({ ...base, linkType: 'internal' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts an external link with a valid URL', () => {
|
||||
const result = schema.safeParse({
|
||||
...base,
|
||||
linkType: 'external',
|
||||
externalUrl: 'https://example.com/page'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an external link with an invalid URL', () => {
|
||||
expect(
|
||||
schema.safeParse({ ...base, linkType: 'external', externalUrl: 'not-a-url' }).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an unknown link type', () => {
|
||||
expect(schema.safeParse({ ...base, linkType: 'anchor', pageId: '42' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an unknown variant', () => {
|
||||
expect(
|
||||
schema.safeParse({ ...base, variant: 'danger', linkType: 'internal', pageId: '42' }).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('media field schema', () => {
|
||||
const schema = createMediaFieldSchema(intl);
|
||||
|
||||
it('accepts a cloud file value ({name, ref})', () => {
|
||||
const result = schema.safeParse({
|
||||
url: { name: 'photo.png', ref: '/media/photo.png' },
|
||||
alt: 'Photo'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a local file value ({name, file: File})', () => {
|
||||
const result = schema.safeParse({
|
||||
url: { name: 'x.png', file: new File([], 'x.png', { type: 'image/png' }) },
|
||||
alt: ''
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a null url', () => {
|
||||
expect(schema.safeParse({ url: null, alt: '' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a file value with neither ref nor file', () => {
|
||||
expect(schema.safeParse({ url: { name: 'x.png' }, alt: '' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts optional dimensions and mime type', () => {
|
||||
const result = schema.safeParse({
|
||||
url: { name: 'photo.png', ref: '/media/photo.png' },
|
||||
alt: 'Photo',
|
||||
width: 100,
|
||||
height: 50,
|
||||
mimeType: 'image/png'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a missing alt', () => {
|
||||
expect(schema.safeParse({ url: null }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { IntlShape } from 'react-intl';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createWebStudioMenu } from '#core/WebStudio/config/menu';
|
||||
// `useMenu` holds no React state — it returns plain path helpers — so the tests call it
|
||||
// directly. Aliased on import because the `use` prefix otherwise trips the rules-of-hooks lint.
|
||||
import { useMenu as menuFor } from '#shared/hooks/useMenu';
|
||||
|
||||
const intl = { formatMessage: ({ id }: { id: string }) => id } as IntlShape;
|
||||
|
||||
const webStudioMenu = [createWebStudioMenu(intl)];
|
||||
|
||||
describe('useMenu', () => {
|
||||
describe('isActive', () => {
|
||||
it('stays true for a section while a sub-route is open', () => {
|
||||
const { isActive } = menuFor('/web-studio/pages/-/create');
|
||||
|
||||
expect(isActive('/web-studio/pages/-/')).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a path that only shares a prefix', () => {
|
||||
const { isActive } = menuFor('/web-studio/pages/listing-archive');
|
||||
|
||||
expect(isActive('/web-studio/pages/listing')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSelected', () => {
|
||||
it('drops the ancestor once a deeper entry matches', () => {
|
||||
const { isSelected } = menuFor('/web-studio/pages/-/create');
|
||||
|
||||
expect(isSelected('/web-studio/pages/-/create', webStudioMenu)).toBe(true);
|
||||
expect(isSelected('/web-studio/pages/listing', webStudioMenu)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBreadcrumb', () => {
|
||||
it('resolves the trail for a listing route', () => {
|
||||
const { getBreadcrumb } = menuFor('/web-studio/pages/listing');
|
||||
|
||||
expect(getBreadcrumb(webStudioMenu).map((item) => item.title)).toEqual([
|
||||
'WEB_STUDIO.MENU.TITLE',
|
||||
'WEB_STUDIO.PAGES.TITLE'
|
||||
]);
|
||||
});
|
||||
|
||||
it('reaches the editor entry from a page id route', () => {
|
||||
const { getBreadcrumb } = menuFor('/web-studio/pages/-/42');
|
||||
|
||||
expect(getBreadcrumb(webStudioMenu).map((item) => item.title)).toEqual([
|
||||
'WEB_STUDIO.MENU.TITLE',
|
||||
'WEB_STUDIO.PAGES.TITLE',
|
||||
'WEB_STUDIO.EDITOR.EDIT_TITLE'
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasActiveChild', () => {
|
||||
it('is true for the section holding the current route', () => {
|
||||
const { hasActiveChild } = menuFor('/web-studio/settings/footer');
|
||||
const settings = createWebStudioMenu(intl).children?.[1];
|
||||
|
||||
expect(hasActiveChild(settings?.children)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { IntlShape } from 'react-intl';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { MenuConfig } from '#app/config/types';
|
||||
import { createDevtoolsMenu } from '#devtools/config/menu';
|
||||
import { createMenuPathMatcher, isMenuPathActive } from '#shared/lib/menuPath';
|
||||
|
||||
const intl = { formatMessage: ({ id }: { id: string }) => id } as IntlShape;
|
||||
|
||||
const devtoolsMenu: MenuConfig = [createDevtoolsMenu(intl)];
|
||||
|
||||
/** Every menu path the matcher marks active for a route — i.e. what the sidebar highlights. */
|
||||
function highlighted(menu: MenuConfig, pathname: string): string[] {
|
||||
const matches = createMenuPathMatcher(menu, pathname);
|
||||
const active: string[] = [];
|
||||
|
||||
const walk = (items: MenuConfig) => {
|
||||
for (const item of items) {
|
||||
if (item.path && matches(item.path)) active.push(item.path);
|
||||
if (item.children) walk(item.children);
|
||||
}
|
||||
};
|
||||
|
||||
walk(menu);
|
||||
return active;
|
||||
}
|
||||
|
||||
describe('isMenuPathActive', () => {
|
||||
it('matches the route itself', () => {
|
||||
expect(isMenuPathActive('/devtools/module-generator', '/devtools/module-generator')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches routes nested under the path', () => {
|
||||
expect(isMenuPathActive('/devtools/module-generator', '/devtools/module-generator/docs')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('does not match a path that merely shares a prefix', () => {
|
||||
expect(isMenuPathActive('/devtools/module-generator', '/devtools/module-generator-v2')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores a trailing slash on the menu path', () => {
|
||||
expect(
|
||||
isMenuPathActive(
|
||||
'/file-manager/tools/image-editor/-/',
|
||||
'/file-manager/tools/image-editor/-/8'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('matches the root only exactly', () => {
|
||||
expect(isMenuPathActive('/', '/')).toBe(true);
|
||||
expect(isMenuPathActive('/', '/devtools')).toBe(false);
|
||||
});
|
||||
|
||||
it('is false without a path', () => {
|
||||
expect(isMenuPathActive(undefined, '/devtools')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMenuPathMatcher', () => {
|
||||
it('highlights the section and its index item on the section route', () => {
|
||||
expect(highlighted(devtoolsMenu, '/devtools/module-generator')).toEqual([
|
||||
'/devtools/module-generator',
|
||||
'/devtools/module-generator'
|
||||
]);
|
||||
});
|
||||
|
||||
it('highlights only the sub-page on a sub-route, not its ancestors', () => {
|
||||
expect(highlighted(devtoolsMenu, '/devtools/module-generator/docs')).toEqual([
|
||||
'/devtools/module-generator/docs'
|
||||
]);
|
||||
expect(highlighted(devtoolsMenu, '/devtools/module-generator/version')).toEqual([
|
||||
'/devtools/module-generator/version'
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves a sibling section alone', () => {
|
||||
expect(highlighted(devtoolsMenu, '/devtools/sandbox/ui')).toEqual(['/devtools/sandbox/ui']);
|
||||
});
|
||||
|
||||
it('keeps the closest entry highlighted on a route with no entry of its own', () => {
|
||||
const menu: MenuConfig = [
|
||||
{
|
||||
title: 'File manager',
|
||||
children: [
|
||||
{ title: 'Explorer', path: '/file-manager/drive/explorer' },
|
||||
{ title: 'Settings', path: '/file-manager/drive/settings' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
expect(highlighted(menu, '/file-manager/drive/explorer/photos/2026')).toEqual([
|
||||
'/file-manager/drive/explorer'
|
||||
]);
|
||||
});
|
||||
|
||||
it('highlights nothing when no entry matches', () => {
|
||||
expect(highlighted(devtoolsMenu, '/web-studio/pages/listing')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveOpenChain } from '#shared/ui/AccordionMenu/openChain';
|
||||
|
||||
/** Stand-ins for the accordion components; only their identity matters to the walk. */
|
||||
const Section = 'section';
|
||||
const Content = 'content';
|
||||
const Group = 'group';
|
||||
const Item = 'item';
|
||||
|
||||
const element = (type: string, props: Record<string, unknown>, ...children: React.ReactNode[]) =>
|
||||
React.createElement(type, props, ...children);
|
||||
|
||||
/** A collapsible section, wrapping its children the way the accordion nests them. */
|
||||
const section = (value: string, ...children: React.ReactNode[]) =>
|
||||
element(
|
||||
Section,
|
||||
{ value },
|
||||
element(Content, { parentValue: value }, element(Group, {}, children))
|
||||
);
|
||||
|
||||
/** A plain item. Its children are content, not further accordion structure. */
|
||||
const item = (value: string) => element(Item, { value }, element('a', { href: '#' }, value));
|
||||
|
||||
const open = (children: React.ReactNode, selected: string) =>
|
||||
resolveOpenChain(children, {
|
||||
isSelected: (value) => value === selected,
|
||||
isSection: (node) => node.type === Section
|
||||
});
|
||||
|
||||
describe('resolveOpenChain', () => {
|
||||
const tree = [
|
||||
section(
|
||||
'outer',
|
||||
section('first', item('first-a'), item('first-b')),
|
||||
section('second', item('second-a'))
|
||||
)
|
||||
];
|
||||
|
||||
it('collects the sections wrapping the selected item', () => {
|
||||
expect(open(tree, 'first-b')).toEqual(['outer', 'first']);
|
||||
});
|
||||
|
||||
it('leaves sibling sections out', () => {
|
||||
expect(open(tree, 'second-a')).toEqual(['outer', 'second']);
|
||||
});
|
||||
|
||||
it('omits the selected item, since an item does not open', () => {
|
||||
expect(open(tree, 'first-a')).not.toContain('first-a');
|
||||
});
|
||||
|
||||
it('opens a selected section rather than only its parent', () => {
|
||||
expect(open(tree, 'first')).toEqual(['outer', 'first']);
|
||||
});
|
||||
|
||||
it('opens the section when it shares its value with an item inside it', () => {
|
||||
const shared = [section('outer', section('shared', item('shared'), item('other')))];
|
||||
|
||||
expect(open(shared, 'shared')).toEqual(['outer', 'shared']);
|
||||
});
|
||||
|
||||
it('handles sections nested three deep', () => {
|
||||
const deep = [section('outer', section('middle', section('inner', item('leaf'))))];
|
||||
|
||||
expect(open(deep, 'leaf')).toEqual(['outer', 'middle', 'inner']);
|
||||
});
|
||||
|
||||
it('returns nothing when no value is selected', () => {
|
||||
expect(open(tree, 'absent')).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores non-element children', () => {
|
||||
const withText = [section('outer', 'plain text', null, item('leaf'))];
|
||||
|
||||
expect(open(withText, 'leaf')).toEqual(['outer']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user