Add initial project files

This commit is contained in:
Igor Martyniuk
2026-09-01 13:03:41 +03:00
parent 22e111bb44
commit 74cb5b6176
1841 changed files with 242819 additions and 30 deletions
@@ -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();
});
});
@@ -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);
});
});
@@ -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');
});
});
@@ -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']);
});
});