feat(project-editor): centralize schema-driven validation

Milestone 2 of the Configuration Engine sprint.

- Add schema/validators/primitives: pure isValidHexColor/HttpUrl/Email,
  validateJson, validateCss, extractStyleBlocks, normalizeRoute. One function
  per concern, no duplicated validator logic.
- Refactor ProjectValidator to compose the primitives and tag every issue with
  section + fieldKey + severity ('error' blocks publish, 'warning' advisory).
  Preserves all existing codes/messages; adds duplicate-routes, invalid-css,
  invalid-widget-config checks.
- Facade: issuesByField, issuesBySection, blockingIssues, hasBlockingIssues;
  publish() now gates on severity==='error' instead of any issue.
- i18n: add validationInvalidJson/Css/DuplicateRoutes/InvalidWidgetConfig to
  the Translations interface + en/ru/hy.
- Specs: primitives + ProjectValidator (25 passing total).

Gate: tsc --noEmit, npm test (25/25), arch:check, build all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-07-17 01:58:36 +04:00
parent 3bfe820443
commit 7b8382131d
9 changed files with 439 additions and 15 deletions

View File

@@ -8,7 +8,8 @@ import { ProjectEditorPreviewService } from '../services/project-editor-preview.
import { LocaleSyncService } from '../services/locale-sync.service';
import { ProjectEditorState } from '../models/project-editor.model';
import { PlatformRuntimeService } from '../../../core/runtime/platform-runtime.service';
import { ProjectValidator } from '../services/project-validator.service';
import { ProjectValidator, ProjectValidationIssue } from '../services/project-validator.service';
import { ProjectEditorSectionId } from '../models/project-editor.model';
import { ProjectEditorDraftStorageService } from '../services/project-editor-draft-storage.service';
import { EDITOR_SECTION_BOOTSTRAP_KEYS } from '../models/project-editor.model';
@@ -47,6 +48,36 @@ export class ProjectEditorFacade {
const current = this.bootstrap();
return current ? this.validator.validate(current) : [];
});
/** Issues that block publishing (`severity === 'error'`), in declaration order. */
readonly blockingIssues = computed(() => this.validationIssues().filter(issue => issue.severity === 'error'));
readonly hasBlockingIssues = computed(() => this.blockingIssues().length > 0);
/** Issues grouped by the schema field key they map to, for inline field errors. */
readonly issuesByField = computed<Map<string, ProjectValidationIssue[]>>(() => {
const map = new Map<string, ProjectValidationIssue[]>();
for (const issue of this.validationIssues()) {
if (!issue.fieldKey) {
continue;
}
const bucket = map.get(issue.fieldKey);
if (bucket) {
bucket.push(issue);
} else {
map.set(issue.fieldKey, [issue]);
}
}
return map;
});
/** Blocking-issue count per editor section, for nav badges. */
readonly issuesBySection = computed<Map<ProjectEditorSectionId, number>>(() => {
const map = new Map<ProjectEditorSectionId, number>();
for (const issue of this.blockingIssues()) {
if (!issue.section) {
continue;
}
map.set(issue.section, (map.get(issue.section) ?? 0) + 1);
}
return map;
});
readonly dirty = computed(() => {
const current = this.bootstrap();
if (!current) {
@@ -173,7 +204,7 @@ export class ProjectEditorFacade {
publish(): boolean {
const current = this.state().bootstrap;
if (!current || this.validationIssues().length > 0) {
if (!current || this.hasBlockingIssues()) {
return false;
}
this.runtime.reloadFromBootstrap(current);

View File

@@ -0,0 +1,80 @@
import {
extractStyleBlocks,
isValidEmail,
isValidHexColor,
isValidHttpUrl,
normalizeRoute,
validateCss,
validateJson,
} from './primitives';
describe('validation primitives', () => {
describe('isValidHexColor', () => {
it('accepts 3- and 6-digit hex', () => {
expect(isValidHexColor('#fff')).toBeTrue();
expect(isValidHexColor('#497671')).toBeTrue();
});
it('rejects non-hex', () => {
expect(isValidHexColor('#zzz')).toBeFalse();
expect(isValidHexColor('497671')).toBeFalse();
expect(isValidHexColor('rgb(0,0,0)')).toBeFalse();
});
});
describe('isValidHttpUrl', () => {
it('accepts http/https', () => {
expect(isValidHttpUrl('https://dexar.market')).toBeTrue();
expect(isValidHttpUrl('http://localhost:4200')).toBeTrue();
});
it('rejects other schemes and bare strings', () => {
expect(isValidHttpUrl('ftp://x')).toBeFalse();
expect(isValidHttpUrl('dexar.market')).toBeFalse();
});
});
describe('isValidEmail', () => {
it('accepts a normal address and rejects malformed', () => {
expect(isValidEmail('sales@dexar.market')).toBeTrue();
expect(isValidEmail('sales@dexar')).toBeFalse();
expect(isValidEmail('nope')).toBeFalse();
});
});
describe('validateJson', () => {
it('accepts valid JSON', () => {
expect(validateJson('{"a":1}').ok).toBeTrue();
});
it('rejects malformed and empty JSON', () => {
expect(validateJson('{a:1}').ok).toBeFalse();
expect(validateJson(' ').ok).toBeFalse();
});
});
describe('validateCss', () => {
it('accepts balanced rules and ignores comments', () => {
expect(validateCss('.a{color:red} /* x */ .b{color:blue}').ok).toBeTrue();
});
it('rejects unbalanced braces', () => {
expect(validateCss('.a{color:red').ok).toBeFalse();
expect(validateCss('.a}').ok).toBeFalse();
});
});
describe('extractStyleBlocks', () => {
it('pulls the CSS text out of every style tag', () => {
const html = '<p>hi</p><style>.a{color:red}</style><style> .b{} </style>';
expect(extractStyleBlocks(html)).toEqual(['.a{color:red}', ' .b{} ']);
});
it('returns an empty array when there are no style tags', () => {
expect(extractStyleBlocks('<p>plain</p>')).toEqual([]);
});
});
describe('normalizeRoute', () => {
it('trims, strips wrapping slashes and lowercases', () => {
expect(normalizeRoute('/About/')).toBe('about');
expect(normalizeRoute(' Contact ')).toBe('contact');
expect(normalizeRoute('/')).toBe('');
});
});
});

View File

@@ -0,0 +1,81 @@
/**
* Pure, reusable validation primitives. One function per concern so no
* validator logic is duplicated across the schema, the config validator, or
* the import boundary. All are side-effect free and framework-agnostic.
*/
const HEX_COLOR = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
const HTTP_URL = /^https?:\/\/\S+$/;
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export interface StringCheckResult {
readonly ok: boolean;
/** Human-oriented detail for the failure (not an i18n key). */
readonly error?: string;
}
export function isValidHexColor(value: string): boolean {
return HEX_COLOR.test(value);
}
export function isValidHttpUrl(value: string): boolean {
return HTTP_URL.test(value);
}
export function isValidEmail(value: string): boolean {
return EMAIL.test(value);
}
/** Validates that a string parses as JSON. Empty/whitespace is treated as invalid. */
export function validateJson(raw: string): StringCheckResult {
if (!raw || !raw.trim()) {
return { ok: false, error: 'Empty JSON' };
}
try {
JSON.parse(raw);
return { ok: true };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : 'Invalid JSON' };
}
}
/**
* Best-effort CSS syntax check: comments are stripped, then braces must be
* balanced and every rule block must contain a selector. This is intentionally
* lightweight (no full CSS parser); it catches the common breakages (unclosed
* block, stray brace) without pulling in a parser dependency.
*/
export function validateCss(raw: string): StringCheckResult {
const source = (raw ?? '').replace(/\/\*[\s\S]*?\*\//g, '');
let depth = 0;
for (const char of source) {
if (char === '{') {
depth++;
} else if (char === '}') {
depth--;
if (depth < 0) {
return { ok: false, error: 'Unexpected "}"' };
}
}
}
if (depth !== 0) {
return { ok: false, error: 'Unbalanced braces' };
}
return { ok: true };
}
/** Extracts the CSS text of every `<style>` block in an HTML string. */
export function extractStyleBlocks(html: string): string[] {
const blocks: string[] = [];
const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
let match: RegExpExecArray | null;
while ((match = re.exec(html ?? '')) !== null) {
blocks.push(match[1]);
}
return blocks;
}
/** Normalizes a route/slug for duplicate comparison: trim, strip leading/trailing slashes, lowercase. */
export function normalizeRoute(route: string): string {
return (route ?? '').trim().replace(/^\/+|\/+$/g, '').toLowerCase();
}

View File

@@ -0,0 +1,98 @@
import { BootstrapConfig } from '../../../shared/models/config';
import { ProjectValidator } from './project-validator.service';
function makeBootstrap(): BootstrapConfig {
return {
branding: { logoUrl: 'logo.png' },
localization: { supportedLocales: ['en'], defaultLocale: 'en' },
tenant: { websiteBaseUrl: 'https://dexar.market' },
staticPages: {},
pages: [
{
key: 'home',
route: { path: '/' },
sections: [
{
order: 0,
layout: { strategy: 'stack' },
widgets: [{ id: 'w1', type: 'hero', version: '1', props: {} }],
},
],
},
],
navigation: { header: [], footer: [] },
theme: {
palette: {
primary: '#497671',
secondary: '#a1b4b5',
accent: '#a7ceca',
success: '#10b981',
warning: '#f59e0b',
danger: '#ef4444',
info: '#3b82f6',
textPrimary: '#1e3c38',
textSecondary: '#667a77',
backgroundPrimary: '#ffffff',
backgroundSecondary: '#f5f5f5',
border: '#d3dad9',
},
},
layout: { type: 'default' },
} as unknown as BootstrapConfig;
}
describe('ProjectValidator', () => {
let validator: ProjectValidator;
beforeEach(() => {
validator = new ProjectValidator();
});
it('reports no issues for a valid baseline config', () => {
expect(validator.validate(makeBootstrap())).toEqual([]);
});
it('flags an invalid palette color against its field key with error severity', () => {
const bootstrap = makeBootstrap();
(bootstrap.theme.palette as unknown as Record<string, string>)['primary'] = '#zzz';
const issues = validator.validate(bootstrap);
const color = issues.find(issue => issue.code === 'invalid-colors');
expect(color).toBeDefined();
expect(color?.fieldKey).toBe('theme.palette.primary');
expect(color?.section).toBe('theme');
expect(color?.severity).toBe('error');
});
it('flags a missing logo mapped to the branding field', () => {
const bootstrap = makeBootstrap();
bootstrap.branding.logoUrl = '';
const issue = validator.validate(bootstrap).find(i => i.code === 'missing-logo');
expect(issue?.fieldKey).toBe('branding.logoUrl');
expect(issue?.severity).toBe('error');
});
it('flags a malformed widget (missing version) as invalid-widget-config', () => {
const bootstrap = makeBootstrap();
delete (bootstrap.pages[0].sections[0].widgets[0] as { version?: string }).version;
const issue = validator.validate(bootstrap).find(i => i.code === 'invalid-widget-config');
expect(issue).toBeDefined();
expect(issue?.section).toBe('widgets');
expect(issue?.severity).toBe('error');
});
it('flags duplicate page routes as a non-blocking warning', () => {
const bootstrap = makeBootstrap();
bootstrap.pages.push({ ...bootstrap.pages[0], key: 'about', route: { path: '/about' } });
bootstrap.pages.push({ ...bootstrap.pages[0], key: 'about-copy', route: { path: '/about' } });
const issue = validator.validate(bootstrap).find(i => i.code === 'duplicate-routes');
expect(issue).toBeDefined();
expect(issue?.severity).toBe('warning');
});
it('rejects an invalid website URL', () => {
const bootstrap = makeBootstrap();
bootstrap.tenant.websiteBaseUrl = 'dexar.market';
const issue = validator.validate(bootstrap).find(i => i.code === 'invalid-url');
expect(issue?.fieldKey).toBe('tenant.websiteBaseUrl');
});
});

View File

@@ -1,16 +1,45 @@
import { Injectable } from '@angular/core';
import { BootstrapConfig } from '../../../shared/models/config';
import { ProjectEditorSectionId } from '../models/project-editor.model';
import {
extractStyleBlocks,
isValidHexColor,
isValidHttpUrl,
normalizeRoute,
validateCss,
} from '../schema/validators/primitives';
export type ValidationSeverity = 'error' | 'warning';
export interface ProjectValidationIssue {
/** Stable machine code, kept for back-compat across the sprint. */
code: string;
/** i18n key for the human-facing message. */
message: string;
/** Editor section the issue belongs to (drives per-section badges). */
section?: ProjectEditorSectionId;
/** Schema field key the issue maps to (drives inline field errors). */
fieldKey?: string;
/** `error` blocks publishing; `warning` is advisory. */
severity: ValidationSeverity;
}
const HEX_COLOR = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
const HTTP_URL = /^https?:\/\/\S+$/;
const KNOWN_PLATFORM_LAYOUT_TYPES = new Set(['default', 'sidebar-left', 'carousel-home', 'minimal']);
const KNOWN_SECTION_LAYOUT_STRATEGIES = new Set(['stack', 'grid', 'hero', 'carousel', 'split']);
function error(code: string, message: string, section: ProjectEditorSectionId, fieldKey?: string): ProjectValidationIssue {
return { code, message, section, fieldKey, severity: 'error' };
}
function warning(code: string, message: string, section: ProjectEditorSectionId, fieldKey?: string): ProjectValidationIssue {
return { code, message, section, fieldKey, severity: 'warning' };
}
/**
* Centralized, schema-aware validator. Composes the pure primitives under
* `schema/validators/` and tags every issue with a section + field key so the
* UI can surface it inline and gate publishing on `severity === 'error'`.
*/
@Injectable({ providedIn: 'root' })
export class ProjectValidator {
validate(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
@@ -19,25 +48,34 @@ export class ProjectValidator {
...this.languageIssues(bootstrap),
...this.urlIssues(bootstrap),
...this.duplicateSlugIssues(bootstrap),
...this.duplicateRouteIssues(bootstrap),
...this.homepageIssues(bootstrap),
...this.widgetConfigIssues(bootstrap),
...this.navigationIssues(bootstrap),
...this.colorIssues(bootstrap),
...this.cssIssues(bootstrap),
...this.translationIssues(bootstrap),
...this.layoutIssues(bootstrap),
];
}
private brandingIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
return bootstrap.branding.logoUrl ? [] : [{ code: 'missing-logo', message: 'builder.validationMissingLogo' }];
return bootstrap.branding.logoUrl
? []
: [error('missing-logo', 'builder.validationMissingLogo', 'branding', 'branding.logoUrl')];
}
private languageIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
return bootstrap.localization.supportedLocales.length > 0 ? [] : [{ code: 'no-languages', message: 'builder.validationNoLanguages' }];
return bootstrap.localization.supportedLocales.length > 0
? []
: [error('no-languages', 'builder.validationNoLanguages', 'languages', 'localization.supportedLocales')];
}
private urlIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
const url = bootstrap.tenant.websiteBaseUrl;
return !url || HTTP_URL.test(url) ? [] : [{ code: 'invalid-url', message: 'builder.validationInvalidUrl' }];
return !url || isValidHttpUrl(url)
? []
: [error('invalid-url', 'builder.validationInvalidUrl', 'general', 'tenant.websiteBaseUrl')];
}
private duplicateSlugIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
@@ -49,16 +87,67 @@ export class ProjectValidator {
page.slug && page.slug.trim() ? page.slug : (page.route ?? '').replace(/^\//, ''),
);
const hasDuplicates = slugs.some((slug, index) => slugs.indexOf(slug) !== index);
return hasDuplicates ? [{ code: 'duplicate-slugs', message: 'builder.validationDuplicateSlugs' }] : [];
return hasDuplicates
? [error('duplicate-slugs', 'builder.validationDuplicateSlugs', 'static-pages', 'staticPages')]
: [];
}
/**
* Duplicate route ownership: two `pages` claiming the same path, or a page
* route colliding with a static-page route. Advisory (warning) - a duplicate
* here is usually a mistake but does not corrupt the config.
*/
private duplicateRouteIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
const pageRoutes = bootstrap.pages
.map(page => normalizeRoute(page.route?.path ?? ''))
.filter(route => route.length > 0);
const hasPageDuplicates = pageRoutes.some((route, index) => pageRoutes.indexOf(route) !== index);
const staticPages = bootstrap.staticPages;
const staticRoutes = staticPages && !Array.isArray(staticPages)
? Object.values(staticPages)
.map(page => normalizeRoute(page.slug && page.slug.trim() ? page.slug : (page.route ?? '')))
.filter(route => route.length > 0)
: [];
const pageRouteSet = new Set(pageRoutes);
const collides = staticRoutes.some(route => pageRouteSet.has(route));
return hasPageDuplicates || collides
? [warning('duplicate-routes', 'builder.validationDuplicateRoutes', 'homepage', 'pages')]
: [];
}
private homepageIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
const homePage = bootstrap.pages.find(page => page.key === 'home' || page.route.path === '/');
if (!homePage || homePage.sections.length === 0) {
return [{ code: 'empty-homepage', message: 'builder.validationEmptyHomepage' }];
return [error('empty-homepage', 'builder.validationEmptyHomepage', 'homepage', 'pages')];
}
const hasMissingWidgetType = homePage.sections.some(section => section.widgets.some(widget => !widget.type?.trim()));
return hasMissingWidgetType ? [{ code: 'missing-widget', message: 'builder.validationMissingWidget' }] : [];
return hasMissingWidgetType
? [error('missing-widget', 'builder.validationMissingWidget', 'homepage', 'pages')]
: [];
}
/**
* Structural widget integrity across every page: each widget needs an id, a
* non-empty type, a version, and a props object. Complements the homepage
* `missing-widget` check with a config-shape guard the widget engine relies on.
*/
private widgetConfigIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
const malformed = bootstrap.pages.some(page =>
page.sections.some(section =>
section.widgets.some(widget =>
!widget.id?.trim() ||
!widget.type?.trim() ||
!widget.version?.trim() ||
widget.props === null ||
typeof widget.props !== 'object',
),
),
);
return malformed
? [error('invalid-widget-config', 'builder.validationInvalidWidgetConfig', 'widgets', 'pages')]
: [];
}
private navigationIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
@@ -66,12 +155,37 @@ export class ProjectValidator {
`${typeof item.label === 'string' ? item.label : JSON.stringify(item.label ?? {})}|${item.route ?? ''}`;
const keys = bootstrap.navigation.header.map(keyOf);
const hasDuplicates = keys.some((key, index) => keys.indexOf(key) !== index);
return hasDuplicates ? [{ code: 'duplicate-nav-links', message: 'builder.validationDuplicateNavLinks' }] : [];
return hasDuplicates
? [error('duplicate-nav-links', 'builder.validationDuplicateNavLinks', 'navigation', 'navigation.header')]
: [];
}
private colorIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
const invalid = Object.values(bootstrap.theme.palette).some(value => !HEX_COLOR.test(value));
return invalid ? [{ code: 'invalid-colors', message: 'builder.validationInvalidColors' }] : [];
return Object.entries(bootstrap.theme.palette)
.filter(([, value]) => !isValidHexColor(value))
.map(([key]) => error('invalid-colors', 'builder.validationInvalidColors', 'theme', `theme.palette.${key}`));
}
/** Validates CSS inside `<style>` blocks of static-page HTML content. */
private cssIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
const staticPages = bootstrap.staticPages;
if (!staticPages || Array.isArray(staticPages)) {
return [];
}
const htmlStrings: string[] = [];
for (const page of Object.values(staticPages)) {
if (typeof page.html === 'string') {
htmlStrings.push(page.html);
} else if (page.html && typeof page.html === 'object') {
htmlStrings.push(...Object.values(page.html));
}
}
const hasInvalidCss = htmlStrings
.flatMap(html => extractStyleBlocks(html))
.some(css => !validateCss(css).ok);
return hasInvalidCss
? [warning('invalid-css', 'builder.validationInvalidCss', 'static-pages', 'staticPages')]
: [];
}
private translationIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
@@ -93,7 +207,9 @@ export class ProjectValidator {
? Object.values(staticPages).some(page => page.translations && otherLocales.some(locale => !page.translations![locale]))
: false;
return headerMissing || staticPagesMissing ? [{ code: 'missing-translations', message: 'builder.validationMissingTranslations' }] : [];
return headerMissing || staticPagesMissing
? [error('missing-translations', 'builder.validationMissingTranslations', 'languages', 'localization.supportedLocales')]
: [];
}
private layoutIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
@@ -102,6 +218,8 @@ export class ProjectValidator {
page.sections.some(section => !!section.layout?.strategy && !KNOWN_SECTION_LAYOUT_STRATEGIES.has(section.layout.strategy)),
);
return invalidPlatformLayout || invalidSectionLayout ? [{ code: 'invalid-layouts', message: 'builder.validationInvalidLayouts' }] : [];
return invalidPlatformLayout || invalidSectionLayout
? [error('invalid-layouts', 'builder.validationInvalidLayouts', 'theme', 'layout.type')]
: [];
}
}

View File

@@ -531,6 +531,10 @@ export const en: Translations = {
validationInvalidColors: 'One or more theme colors are not valid hex colors.',
validationMissingTranslations: 'Some content is missing translations for a supported language.',
validationInvalidLayouts: 'One or more layouts reference an unknown layout type.',
validationInvalidJson: 'The configuration is not valid JSON.',
validationInvalidCss: 'A static page contains invalid CSS.',
validationDuplicateRoutes: 'Two or more pages share the same route.',
validationInvalidWidgetConfig: 'A widget is missing a required field (id, type, version, or props).',
statusDraft: 'Draft',
statusPublished: 'Published',
unsavedChanges: 'Unsaved changes',

View File

@@ -531,6 +531,10 @@ export const hy: Translations = {
validationInvalidColors: 'Թեմայի գույներից մեկը կամ մի քանիսը վավեր hex գույն չեն։',
validationMissingTranslations: 'Որոշ բովանդակություն թարգմանված չէ սատարվող լեզուներից մեկով։',
validationInvalidLayouts: 'Մեկ կամ մի քանի դասավորություններ հղում են անհայտ տեսակի։',
validationInvalidJson: 'Կոնֆիգուրացիան վավեր JSON չէ։',
validationInvalidCss: 'Ստատիկ էջը պարունակում է անվավեր CSS։',
validationDuplicateRoutes: 'Երկու կամ ավելի էջ ունեն նույն երթուղին։',
validationInvalidWidgetConfig: 'Վիջեթին բացակայում է պարտադիր դաշտ (id, type, version կամ props)։',
statusDraft: 'Սևագիր',
statusPublished: 'Հրապարակված',
unsavedChanges: 'Չպահված փոփոխություններ',

View File

@@ -531,6 +531,10 @@ export const ru: Translations = {
validationInvalidColors: 'Один или несколько цветов темы указаны некорректно.',
validationMissingTranslations: 'Часть контента не переведена на один из поддерживаемых языков.',
validationInvalidLayouts: 'Один или несколько макетов ссылаются на неизвестный тип раскладки.',
validationInvalidJson: 'Конфигурация не является допустимым JSON.',
validationInvalidCss: 'Статическая страница содержит недопустимый CSS.',
validationDuplicateRoutes: 'Две или более страницы используют один и тот же маршрут.',
validationInvalidWidgetConfig: 'У виджета отсутствует обязательное поле (id, type, version или props).',
statusDraft: 'Черновик',
statusPublished: 'Опубликовано',
unsavedChanges: 'Есть несохранённые изменения',

View File

@@ -529,6 +529,10 @@ export interface Translations {
validationInvalidColors: string;
validationMissingTranslations: string;
validationInvalidLayouts: string;
validationInvalidJson: string;
validationInvalidCss: string;
validationDuplicateRoutes: string;
validationInvalidWidgetConfig: string;
statusDraft: string;
statusPublished: string;
unsavedChanges: string;