Add initial project files
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user