diff --git a/.storybook/main.ts b/.storybook/main.ts
index f8f4a5b6a..196aae951 100644
--- a/.storybook/main.ts
+++ b/.storybook/main.ts
@@ -1,5 +1,6 @@
import {resolve} from 'path';
import WebpackShellPluginNext from 'webpack-shell-plugin-next';
+import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin';
const customAlias = {
widget: resolve(__dirname, '../widget'),
@@ -27,6 +28,7 @@ const config = {
],
webpackFinal: (storybookBaseConfig: any) => {
storybookBaseConfig.plugins.push(
+ new MonacoWebpackPlugin(),
new WebpackShellPluginNext({
onBuildStart: {
scripts: ['npm run build:widget'],
diff --git a/package-lock.json b/package-lock.json
index 480be2929..ec8acc9b4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,8 +15,10 @@
"@gravity-ui/i18n": "^1.0.0",
"@react-spring/web": "^9.7.3",
"ajv": "^8.12.0",
+ "ajv-keywords": "^5.1.0",
"final-form": "^4.20.9",
"github-buttons": "2.23.0",
+ "js-yaml-source-map": "^0.2.2",
"lodash": "^4.17.21",
"monaco-editor": "^0.38.0",
"react-final-form": "^6.5.9",
@@ -88,6 +90,7 @@
"js-yaml": "^4.1.0",
"lint-staged": "^11.2.6",
"markdown-loader": "^6.0.0",
+ "monaco-editor-webpack-plugin": "^7.1.0",
"move-file-cli": "^3.0.0",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.16",
@@ -6102,18 +6105,6 @@
"url": "https://opencollective.com/webpack"
}
},
- "node_modules/@storybook/builder-webpack5/node_modules/schema-utils/node_modules/ajv-keywords": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
- "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
- "dev": true,
- "dependencies": {
- "fast-deep-equal": "^3.1.3"
- },
- "peerDependencies": {
- "ajv": "^8.8.2"
- }
- },
"node_modules/@storybook/builder-webpack5/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -9188,6 +9179,17 @@
}
}
},
+ "node_modules/ajv-keywords": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
+ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3"
+ },
+ "peerDependencies": {
+ "ajv": "^8.8.2"
+ }
+ },
"node_modules/ansi-escapes": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
@@ -18681,6 +18683,14 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/js-yaml-source-map": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/js-yaml-source-map/-/js-yaml-source-map-0.2.2.tgz",
+ "integrity": "sha512-z45Aww8oXJh9GuWUnwmvHsAkB7I/oWrkoHU554UQ8Ik4dyhVrk/nwClTI435feU7QIy7E0XaW8jHvZ4QxaAjog==",
+ "peerDependencies": {
+ "js-yaml": "^4.0.0"
+ }
+ },
"node_modules/jscodeshift": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/jscodeshift/-/jscodeshift-0.14.0.tgz",
@@ -20379,6 +20389,19 @@
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.38.0.tgz",
"integrity": "sha512-11Fkh6yzEmwx7O0YoLxeae0qEGFwmyPRlVxpg7oF9czOOCB/iCjdJrG5I67da5WiXK3YJCxoz9TJFE8Tfq/v9A=="
},
+ "node_modules/monaco-editor-webpack-plugin": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/monaco-editor-webpack-plugin/-/monaco-editor-webpack-plugin-7.1.0.tgz",
+ "integrity": "sha512-ZjnGINHN963JQkFqjjcBtn1XBtUATDZBMgNQhDQwd78w2ukRhFXAPNgWuacaQiDZsUr4h1rWv5Mv6eriKuOSzA==",
+ "dev": true,
+ "dependencies": {
+ "loader-utils": "^2.0.2"
+ },
+ "peerDependencies": {
+ "monaco-editor": ">= 0.31.0",
+ "webpack": "^4.5.0 || 5.x"
+ }
+ },
"node_modules/move-file": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/move-file/-/move-file-3.0.0.tgz",
diff --git a/package.json b/package.json
index 7929a7ab1..aee117746 100644
--- a/package.json
+++ b/package.json
@@ -86,8 +86,10 @@
"@gravity-ui/i18n": "^1.0.0",
"@react-spring/web": "^9.7.3",
"ajv": "^8.12.0",
+ "ajv-keywords": "^5.1.0",
"final-form": "^4.20.9",
"github-buttons": "2.23.0",
+ "js-yaml-source-map": "^0.2.2",
"lodash": "^4.17.21",
"monaco-editor": "^0.38.0",
"react-final-form": "^6.5.9",
@@ -164,6 +166,7 @@
"js-yaml": "^4.1.0",
"lint-staged": "^11.2.6",
"markdown-loader": "^6.0.0",
+ "monaco-editor-webpack-plugin": "^7.1.0",
"move-file-cli": "^3.0.0",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.16",
diff --git a/src/editor/components/CodeEditor/CodeEditor.scss b/src/editor/components/CodeEditor/CodeEditor.scss
new file mode 100644
index 000000000..dc2c459a3
--- /dev/null
+++ b/src/editor/components/CodeEditor/CodeEditor.scss
@@ -0,0 +1,78 @@
+@import '../../../../styles/variables.scss';
+
+$block: '.#{$ns}code-editor';
+
+#{$block} {
+ height: 100%;
+ position: relative;
+ overflow: hidden;
+
+ &_fullscreen {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100vh;
+ z-index: 1000;
+ background: var(--g-color-base-background);
+
+ #{$block}__header {
+ margin-top: var(--pc-editor-header-height);
+ }
+ }
+
+ &__code {
+ width: 100%;
+ height: 100%;
+ }
+
+ &__header,
+ &__footer {
+ padding: 0 $indentS;
+ background: var(--g-color-base-background);
+ }
+
+ &__header {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ height: var(--pc-editor-code-header-height);
+
+ border-bottom: 1px solid var(--g-color-line-generic);
+ }
+
+ &__footer {
+ position: absolute;
+ left: 0;
+ bottom: 0;
+ width: 100%;
+ min-height: var(--pc-editor-code-header-height);
+ border-top: 1px solid var(--g-color-line-generic);
+ }
+
+ &__message-container {
+ max-height: 140px;
+ padding: 12px;
+
+ overflow-y: auto;
+
+ font-family: Menlo, Monaco, 'Courier New', monospace;
+ white-space: pre-wrap;
+ }
+
+ &__message {
+ &_status {
+ &_success {
+ color: var(--g-color-text-positive);
+ }
+
+ &_warning {
+ color: var(--g-color-text-warning-heavy);
+ }
+
+ &_error {
+ color: var(--g-color-text-danger);
+ }
+ }
+ }
+}
diff --git a/src/editor/components/CodeEditor/CodeEditor.tsx b/src/editor/components/CodeEditor/CodeEditor.tsx
new file mode 100644
index 000000000..ee658c5de
--- /dev/null
+++ b/src/editor/components/CodeEditor/CodeEditor.tsx
@@ -0,0 +1,80 @@
+import React, {useCallback, useMemo, useState} from 'react';
+
+import {ChevronsCollapseUpRight, ChevronsExpandUpRight} from '@gravity-ui/icons';
+import {Button, Icon} from '@gravity-ui/uikit';
+import yaml from 'js-yaml';
+import MonacoEditor from 'react-monaco-editor';
+
+import {PageContent} from '../../../models';
+import {block} from '../../../utils';
+import {parseCode} from '../../utils/code';
+import {CodeEditorMessageProps} from '../../utils/validation';
+
+import {options} from './constants';
+
+import './CodeEditor.scss';
+
+const b = block('code-editor');
+
+interface CodeEditorProps {
+ content: PageContent;
+ fullscreenModeOn: boolean;
+ validator: (code: string) => CodeEditorMessageProps;
+ onFullscreenModeOnUpdate: (fullscreenModeOn: boolean) => void;
+ onChange: (content: PageContent) => void;
+ message?: CodeEditorMessageProps;
+}
+
+export const CodeEditor = ({
+ content,
+ onChange,
+ validator,
+ fullscreenModeOn,
+ onFullscreenModeOnUpdate,
+}: CodeEditorProps) => {
+ const value = useMemo(() => yaml.dump(content), [content]);
+ const [message, setMessage] = useState(() => validator(value));
+
+ const onChangeWithValidation = useCallback(
+ (code: string) => {
+ const validationResult = validator(code);
+
+ setMessage(validationResult);
+ onChange(parseCode(code));
+ },
+ [onChange, validator],
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/editor/components/CodeEditor/constants.ts b/src/editor/components/CodeEditor/constants.ts
new file mode 100644
index 000000000..7c68e7f20
--- /dev/null
+++ b/src/editor/components/CodeEditor/constants.ts
@@ -0,0 +1,20 @@
+import {editor} from 'monaco-editor';
+import {monaco} from 'react-monaco-editor';
+
+export const options: monaco.editor.IStandaloneEditorConstructionOptions = {
+ wordWrap: 'on' as editor.IEditorOptions['wordWrap'],
+ renderLineHighlight: 'none' as editor.IEditorOptions['renderLineHighlight'],
+ selectOnLineNumbers: true,
+ renderWhitespace: 'all',
+ automaticLayout: true,
+ minimap: {
+ enabled: false,
+ },
+ overviewRulerLanes: 0,
+ hideCursorInOverviewRuler: true,
+ scrollbar: {
+ vertical: 'hidden',
+ },
+ overviewRulerBorder: false,
+ readOnly: false,
+};
diff --git a/src/editor/components/YamlEditor/YamlEditor.scss b/src/editor/components/YamlEditor/YamlEditor.scss
deleted file mode 100644
index 72fa5344a..000000000
--- a/src/editor/components/YamlEditor/YamlEditor.scss
+++ /dev/null
@@ -1,18 +0,0 @@
-@import '../../../../styles/variables.scss';
-
-$block: '.#{$ns}yaml-editor';
-
-#{$block} {
- height: 100%;
- position: relative;
-
- &__copy-button {
- position: absolute;
- top: 12px;
- right: 12px;
-
- &_hidden {
- visibility: hidden;
- }
- }
-}
diff --git a/src/editor/components/YamlEditor/YamlEditor.tsx b/src/editor/components/YamlEditor/YamlEditor.tsx
deleted file mode 100644
index 4aff94317..000000000
--- a/src/editor/components/YamlEditor/YamlEditor.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import React, {useMemo} from 'react';
-
-import {ClipboardButton} from '@gravity-ui/uikit';
-import yaml from 'js-yaml';
-import MonacoEditor, {monaco} from 'react-monaco-editor';
-
-import {PageContent} from '../../../models';
-import {block} from '../../../utils';
-
-import './YamlEditor.scss';
-
-const b = block('yaml-editor');
-
-interface YamlEditorProps {
- content: PageContent;
-}
-
-export const YamlEditor = ({content}: YamlEditorProps) => {
- const value = useMemo(() => {
- return yaml.dump(content);
- }, [content]);
-
- const options: monaco.editor.IStandaloneEditorConstructionOptions = useMemo(() => {
- return {
- minimap: {
- enabled: false,
- },
- renderWhitespace: 'all',
- overviewRulerLanes: 0,
- hideCursorInOverviewRuler: true,
- scrollbar: {
- vertical: 'hidden',
- },
- overviewRulerBorder: false,
- readOnly: true,
- };
- }, []);
-
- return (
-
-
-
-
- );
-};
diff --git a/src/editor/containers/Editor/Editor.tsx b/src/editor/containers/Editor/Editor.tsx
index 7fecda787..c53879378 100644
--- a/src/editor/containers/Editor/Editor.tsx
+++ b/src/editor/containers/Editor/Editor.tsx
@@ -2,14 +2,16 @@ import React, {useEffect, useMemo} from 'react';
import {PageConstructor, PageConstructorProvider} from '../../../containers/PageConstructor';
import {BlockDecorationProps} from '../../../models';
+import {generateDefaultSchema} from '../../../schema';
import AddBlock from '../../components/AddBlock/AddBlock';
import EditBlock from '../../components/EditBlock/EditBlock';
import {ErrorBoundary} from '../../components/ErrorBoundary/ErrorBoundary';
import Layout from '../../components/Layout/Layout';
import {NotFoundBlock} from '../../components/NotFoundBlock/NotFoundBlock';
import {EditorContext} from '../../context';
-import useFormSpec from '../../hooks/useFormSpec';
-import {useEditorState} from '../../store';
+import {useCodeValidator} from '../../hooks/useCodeValidator';
+import {useMainState} from '../../store/main';
+import {useSettingsState} from '../../store/settings';
import {EditorProps, ViewModeItem} from '../../types';
import {addCustomDecorator, checkIsMobile, getBlockId} from '../../utils';
import {Form} from '../Form/Form';
@@ -26,23 +28,29 @@ export const Editor = ({
content,
activeBlockIndex,
errorBoundaryState,
- viewMode,
- theme,
onContentUpdate,
- onViewModeUpdate,
onAdd,
onSelect,
injectEditBlockProps,
+ } = useMainState(rest);
+ const {
+ viewMode,
+ theme,
+ onViewModeUpdate,
onThemeUpdate,
- } = useEditorState(rest);
- const formSpecs = useFormSpec(customSchema);
+ formTab,
+ onFormTabUpdate,
+ codeFullscreeModeOn,
+ onCodeFullscreeModeOnUpdate,
+ } = useSettingsState();
const isEditingMode = viewMode === ViewModeItem.Edititng;
-
const transformedContent = useMemo(
() => (transformContent ? transformContent(content, {viewMode}) : content),
[content, transformContent, viewMode],
);
+ const schema = useMemo(() => generateDefaultSchema(customSchema), [customSchema]);
+ const codeValidator = useCodeValidator(schema);
const outgoingProps = useMemo(() => {
const custom = isEditingMode
@@ -112,8 +120,13 @@ export const Editor = ({
content={content}
onChange={onContentUpdate}
activeBlockIndex={activeBlockIndex}
+ activeTab={formTab}
+ codeFullscreeModeOn={codeFullscreeModeOn}
+ schema={schema}
+ codeValidator={codeValidator}
+ onActiveTabUpdate={onFormTabUpdate}
+ onCodeFullscreeModeOnUpdate={onCodeFullscreeModeOnUpdate}
onSelect={onSelect}
- spec={formSpecs}
/>
)}
diff --git a/src/editor/containers/Form/Form.scss b/src/editor/containers/Form/Form.scss
index ea8e75997..24c87f8a3 100644
--- a/src/editor/containers/Form/Form.scss
+++ b/src/editor/containers/Form/Form.scss
@@ -20,7 +20,11 @@ $block: '.#{$ns}editor-form';
margin-bottom: $indentXS;
}
- &_yaml-editor-enabled {
- height: 100%;
+ &_code-editor-active {
+ height: calc(100% - var(--pc-editor-code-header-height));
+
+ #{$block}__tabs {
+ margin-bottom: 0;
+ }
}
}
diff --git a/src/editor/containers/Form/Form.tsx b/src/editor/containers/Form/Form.tsx
index fdcb22261..5226cb9bc 100644
--- a/src/editor/containers/Form/Form.tsx
+++ b/src/editor/containers/Form/Form.tsx
@@ -1,22 +1,19 @@
import React, {Fragment, memo} from 'react';
import {Tabs, TabsProps} from '@gravity-ui/uikit';
+import {JSONSchema4} from 'json-schema';
import {Block, PageContent} from '../../../models';
import {block, getBlockKey} from '../../../utils';
import {BlockForm} from '../../components/BlockForm/BlockForm';
+import {CodeEditor} from '../../components/CodeEditor/CodeEditor';
import {PagePropsForm, PagePropsFormData} from '../../components/PagePropsForm/PagePropsForm';
-import {YamlEditor} from '../../components/YamlEditor/YamlEditor';
-import {FormSpecs} from '../../dynamic-forms-custom/parser/types';
+import useFormSpec from '../../hooks/useFormSpec';
+import {FormTab} from '../../types';
+import {CodeEditorMessageProps} from '../../utils/validation';
import './Form.scss';
-enum FormTab {
- Blocks = 'blocks',
- Page = 'page',
- Yaml = 'yaml',
-}
-
const b = block('editor-form');
const tabsItems = Object.values(FormTab).map((tab) => ({
@@ -26,81 +23,110 @@ const tabsItems = Object.values(FormTab).map((tab) => ({
export interface FormProps {
content: PageContent;
+ schema: JSONSchema4;
activeBlockIndex: number;
- spec: FormSpecs;
+ activeTab: FormTab;
+ codeFullscreeModeOn: boolean;
+ onActiveTabUpdate: (tab: FormTab) => void;
+ onCodeFullscreeModeOnUpdate: (codeFullscreeModeOn: boolean) => void;
+ codeValidator: (code: string) => CodeEditorMessageProps;
onChange: (content: PageContent) => void;
onSelect: (index: number) => void;
}
-export const Form = memo(({content, onChange, activeBlockIndex, onSelect, spec}: FormProps) => {
- const [activeTab, setActiveTab] = React.useState(FormTab.Blocks);
- const {blocks, ...page} = content || {};
- const {blocks: blocksSpec, page: pageSpec} = spec || {};
+export const Form = memo(
+ ({
+ content,
+ onChange,
+ activeBlockIndex,
+ onSelect,
+ schema,
+ codeValidator,
+ activeTab,
+ onActiveTabUpdate,
+ codeFullscreeModeOn,
+ onCodeFullscreeModeOnUpdate,
+ }: FormProps) => {
+ const {blocks, ...page} = content || {};
+ const spec = useFormSpec(schema);
+ const {blocks: blocksSpec, page: pageSpec} = spec || {};
- let form;
+ let form;
- switch (activeTab) {
- case FormTab.Page: {
- form = (
- {
- return onChange({
- ...content,
- ...data,
- });
- }}
- />
- );
- break;
+ switch (activeTab) {
+ case FormTab.Page: {
+ form = (
+ {
+ return onChange({
+ ...content,
+ ...data,
+ });
+ }}
+ />
+ );
+ break;
+ }
+ case FormTab.Blocks: {
+ form = (
+
+ {blocks.map((blockData, index) =>
+ blocksSpec[blockData.type] ? (
+
+ {
+ onChange({
+ ...content,
+ blocks: [
+ ...blocks.slice(0, index),
+ data,
+ ...blocks.slice(index + 1),
+ ],
+ });
+ }}
+ onSelect={() => onSelect(index)}
+ />
+
+ ) : null,
+ )}
+
+ );
+ break;
+ }
+ case FormTab.Code: {
+ form = (
+
+ );
+ break;
+ }
}
- case FormTab.Blocks: {
- form = (
-
- {blocks.map((blockData, index) =>
- blocksSpec[blockData.type] ? (
-
- {
- onChange({
- ...content,
- blocks: [
- ...blocks.slice(0, index),
- data,
- ...blocks.slice(index + 1),
- ],
- });
- }}
- onSelect={() => onSelect(index)}
- />
-
- ) : null,
- )}
-
- );
- break;
- }
- case FormTab.Yaml: {
- form = ;
- break;
- }
- }
- return (
-
-
- {form}
-
- );
-});
+ return (
+
+
+ {form}
+
+ );
+ },
+);
Form.displayName = 'Form';
diff --git a/src/editor/hooks/useCodeValidator.ts b/src/editor/hooks/useCodeValidator.ts
new file mode 100644
index 000000000..e7de92f3a
--- /dev/null
+++ b/src/editor/hooks/useCodeValidator.ts
@@ -0,0 +1,13 @@
+import {useCallback, useMemo} from 'react';
+
+import {JSONSchema4} from 'json-schema';
+
+import {CodeEditorMessageProps, createValidator, validate} from '../utils/validation';
+
+export type CodeValidator = (code: string) => CodeEditorMessageProps;
+
+export function useCodeValidator(schema: JSONSchema4): CodeValidator {
+ const validator = useMemo(() => createValidator(schema), [schema]);
+
+ return useCallback((code: string) => validate(code, validator), [validator]);
+}
diff --git a/src/editor/hooks/useFormSpec.ts b/src/editor/hooks/useFormSpec.ts
index ae6340777..4e5363687 100644
--- a/src/editor/hooks/useFormSpec.ts
+++ b/src/editor/hooks/useFormSpec.ts
@@ -1,12 +1,9 @@
import {useMemo} from 'react';
-import {SchemaCustomConfig, generateDefaultSchema} from '../../schema';
-import formSpecParser from '../dynamic-forms-custom/parser';
+import {JSONSchema4} from 'json-schema';
-export default function useFormSpec(customSchema?: SchemaCustomConfig) {
- return useMemo(() => {
- const schema = generateDefaultSchema(customSchema);
+import formSpecParser from '../dynamic-forms-custom/parser';
- return formSpecParser.parse(schema);
- }, [customSchema]);
+export default function useFormSpec(schema: JSONSchema4) {
+ return useMemo(() => formSpecParser.parse(schema), [schema]);
}
diff --git a/src/editor/store/index.ts b/src/editor/store/main/index.ts
similarity index 73%
rename from src/editor/store/index.ts
rename to src/editor/store/main/index.ts
index 1618d0a85..e5622f1c2 100644
--- a/src/editor/store/index.ts
+++ b/src/editor/store/main/index.ts
@@ -1,10 +1,10 @@
import {useMemo, useReducer} from 'react';
-import {DEFAULT_THEME} from '../../components/constants';
-import {Block, BlockDecorationProps, HeaderBlockTypes, PageContent, Theme} from '../../models';
-import {getCustomTypes, getHeaderBlock} from '../../utils';
-import {EditBlockActions, EditBlockControls} from '../components/EditBlock/EditBlock';
-import {EditBlockProps, EditorProps, ViewModeItem} from '../types';
+import {DEFAULT_THEME} from '../../../components/constants';
+import {Block, BlockDecorationProps, HeaderBlockTypes, PageContent} from '../../../models';
+import {getCustomTypes, getHeaderBlock} from '../../../utils';
+import {EditBlockActions, EditBlockControls} from '../../components/EditBlock/EditBlock';
+import {EditBlockProps, EditorProps, ViewModeItem} from '../../types';
import {
ADD_BLOCK,
@@ -13,25 +13,20 @@ import {
ORDER_BLOCK,
SELECT_BLOCK,
UPDATE_CONTENT,
- UPDATE_THEME,
- UPDATE_VIEW_MODE,
reducer,
} from './reducer';
import {addEditorProps} from './utils';
export type EditorBlockId = number | string;
-export function useEditorState({content: intialContent, custom}: Omit) {
- const [{activeBlockIndex, content, errorBoundaryState, viewMode, theme}, dispatch] = useReducer(
- reducer,
- {
- activeBlockIndex: 0,
- errorBoundaryState: 0,
- content: addEditorProps(intialContent),
- viewMode: ViewModeItem.Edititng,
- theme: DEFAULT_THEME,
- },
- );
+export function useMainState({content: intialContent, custom}: Omit) {
+ const [{activeBlockIndex, content, errorBoundaryState}, dispatch] = useReducer(reducer, {
+ activeBlockIndex: 0,
+ errorBoundaryState: 0,
+ content: addEditorProps(intialContent),
+ viewMode: ViewModeItem.Edititng,
+ theme: DEFAULT_THEME,
+ });
return useMemo(() => {
const headerBlockTypes = [...HeaderBlockTypes, ...getCustomTypes(['headers'], custom)];
@@ -58,11 +53,6 @@ export function useEditorState({content: intialContent, custom}: Omit dispatch({type: SELECT_BLOCK, payload: index});
const onContentUpdate = (newContent: PageContent) =>
dispatch({type: UPDATE_CONTENT, payload: newContent});
- const onViewModeUpdate = (newViewMode: ViewModeItem) =>
- dispatch({type: UPDATE_VIEW_MODE, payload: newViewMode});
- const onThemeUpdate = (newTheme: Theme) =>
- dispatch({type: UPDATE_THEME, payload: newTheme});
-
const injectEditBlockProps = ({
type,
index: relativeIndex = 0,
@@ -112,14 +102,10 @@ export function useEditorState({content: intialContent, custom}: Omit {
+export const reducer = (state: MainState, action: EditorAction): MainState => {
const {content} = state;
const getNewState = (blocks: ConstructorBlock[], activeBlockIndex: number) => ({
...state,
@@ -133,16 +119,6 @@ export const reducer = (state: EditorState, action: EditorAction): EditorState =
return getNewState(changeBlocksOrder(content.blocks, oldIndex, newIndex), newIndex);
}
- case UPDATE_VIEW_MODE:
- return {
- ...state,
- viewMode: action.payload,
- };
- case UPDATE_THEME:
- return {
- ...state,
- theme: action.payload,
- };
default:
return state;
}
diff --git a/src/editor/store/utils.ts b/src/editor/store/main/utils.ts
similarity index 94%
rename from src/editor/store/utils.ts
rename to src/editor/store/main/utils.ts
index dcb785ffb..51f0d000a 100644
--- a/src/editor/store/utils.ts
+++ b/src/editor/store/main/utils.ts
@@ -1,6 +1,6 @@
import cloneDeep from 'lodash/cloneDeep';
-import {ConstructorBlock, PageContent} from '../../models';
+import {ConstructorBlock, PageContent} from '../../../models';
import {EditorBlockId} from './reducer';
diff --git a/src/editor/store/settings/index.ts b/src/editor/store/settings/index.ts
new file mode 100644
index 000000000..6759a86da
--- /dev/null
+++ b/src/editor/store/settings/index.ts
@@ -0,0 +1,36 @@
+import {useMemo, useReducer} from 'react';
+
+import {Theme} from '../../../models';
+import {FormTab, ViewModeItem} from '../../types';
+
+import {
+ UPDATE_CODE_FULLSCREEN_MODE_ON,
+ UPDATE_FORM_TAB,
+ UPDATE_THEME,
+ UPDATE_VIEW_MODE,
+ initialState,
+ reducer,
+} from './reducer';
+
+export function useSettingsState() {
+ const [{formTab, viewMode, theme, codeFullscreeModeOn}, dispatch] = useReducer(
+ reducer,
+ initialState,
+ );
+
+ return useMemo(() => {
+ return {
+ formTab,
+ viewMode,
+ theme,
+ codeFullscreeModeOn,
+ onFormTabUpdate: (newFormTab: FormTab) =>
+ dispatch({type: UPDATE_FORM_TAB, payload: newFormTab}),
+ onViewModeUpdate: (newViewMode: ViewModeItem) =>
+ dispatch({type: UPDATE_VIEW_MODE, payload: newViewMode}),
+ onThemeUpdate: (newTheme: Theme) => dispatch({type: UPDATE_THEME, payload: newTheme}),
+ onCodeFullscreeModeOnUpdate: (newCodeFullscreeModeOn: boolean) =>
+ dispatch({type: UPDATE_CODE_FULLSCREEN_MODE_ON, payload: newCodeFullscreeModeOn}),
+ };
+ }, [formTab, viewMode, theme, codeFullscreeModeOn]);
+}
diff --git a/src/editor/store/settings/reducer.ts b/src/editor/store/settings/reducer.ts
new file mode 100644
index 000000000..5b6102a99
--- /dev/null
+++ b/src/editor/store/settings/reducer.ts
@@ -0,0 +1,80 @@
+import {DEFAULT_THEME} from '../../../components/constants';
+import {Theme} from '../../../models';
+import {FormTab, ViewModeItem} from '../../types';
+
+// actions
+export const UPDATE_FORM_TAB = 'UPDATE_FORM_TAB';
+export const UPDATE_CODE_FULLSCREEN_MODE_ON = 'UPDATE_CODE_FULLSCREEN_MODE_ON';
+export const UPDATE_VIEW_MODE = 'UPDATE_VIEW_MODE';
+export const UPDATE_THEME = 'UPDATE_THEME';
+
+interface EditorSettingsState {
+ theme: Theme;
+ viewMode: ViewModeItem;
+ codeFullscreeModeOn: boolean;
+ formTab: FormTab;
+}
+
+interface UpdateViewMode {
+ type: typeof UPDATE_VIEW_MODE;
+ payload: ViewModeItem;
+}
+
+interface UpdateTheme {
+ type: typeof UPDATE_THEME;
+ payload: Theme;
+}
+
+interface UpdateCodeFullscreenModeOn {
+ type: typeof UPDATE_CODE_FULLSCREEN_MODE_ON;
+ payload: boolean;
+}
+
+interface UpdateFormTab {
+ type: typeof UPDATE_FORM_TAB;
+ payload: FormTab;
+}
+
+export type EditorSettingsAction =
+ | UpdateViewMode
+ | UpdateTheme
+ | UpdateCodeFullscreenModeOn
+ | UpdateFormTab;
+
+// reducer
+export const reducer = (
+ state: EditorSettingsState,
+ action: EditorSettingsAction,
+): EditorSettingsState => {
+ switch (action.type) {
+ case UPDATE_VIEW_MODE:
+ return {
+ ...state,
+ viewMode: action.payload,
+ };
+ case UPDATE_THEME:
+ return {
+ ...state,
+ theme: action.payload,
+ };
+ case UPDATE_CODE_FULLSCREEN_MODE_ON:
+ return {
+ ...state,
+ codeFullscreeModeOn: action.payload,
+ };
+ case UPDATE_FORM_TAB:
+ return {
+ ...state,
+ formTab: action.payload,
+ };
+ default:
+ return state;
+ }
+};
+
+export const initialState = {
+ viewMode: ViewModeItem.Edititng,
+ theme: DEFAULT_THEME,
+ codeFullscreeModeOn: false,
+ formTab: FormTab.Blocks,
+};
diff --git a/src/editor/styles/root.scss b/src/editor/styles/root.scss
index 227c47ee1..d69df66c2 100644
--- a/src/editor/styles/root.scss
+++ b/src/editor/styles/root.scss
@@ -1,8 +1,9 @@
body {
--pc-editor-header-height: 48px;
+ --pc-editor-code-header-height: 36px;
--pc-editor-divider-width: 12px;
+ --pc-editor-left-column-width: calc(400px + var(--pc-editor-divider-width));
--pc-editor-base-color: var(--g-color-base-brand);
--pc-editor-control-color: var(--g-color-base-brand);
--pc-editor-control-icon-color: var(--g-color-text-dark-primary);
- --pc-editor-left-column-width: calc(400px + var(--pc-editor-divider-width));
}
diff --git a/src/editor/types/index.ts b/src/editor/types/index.ts
index ddd5b1a52..56702bc5a 100644
--- a/src/editor/types/index.ts
+++ b/src/editor/types/index.ts
@@ -45,3 +45,9 @@ export enum ViewModeItem {
Tablet = 'tablet',
Mobile = 'mobile',
}
+
+export enum FormTab {
+ Blocks = 'blocks',
+ Page = 'page',
+ Code = 'code',
+}
diff --git a/src/editor/utils/code.ts b/src/editor/utils/code.ts
new file mode 100644
index 000000000..3b4663267
--- /dev/null
+++ b/src/editor/utils/code.ts
@@ -0,0 +1,12 @@
+import yaml from 'js-yaml';
+
+import {PageContent} from '../../models';
+
+export function parseCode(code: string) {
+ const pageContent = yaml.load(code) as PageContent;
+
+ return {
+ ...pageContent,
+ blocks: pageContent.blocks?.filter(Boolean),
+ };
+}
diff --git a/src/editor/utils/validation.ts b/src/editor/utils/validation.ts
new file mode 100644
index 000000000..dda99241d
--- /dev/null
+++ b/src/editor/utils/validation.ts
@@ -0,0 +1,67 @@
+import Ajv, {ErrorObject, ValidateFunction} from 'ajv';
+import ajvKeywords from 'ajv-keywords';
+import yaml from 'js-yaml';
+import SourceMap from 'js-yaml-source-map';
+import {JSONSchema4} from 'json-schema';
+import isArray from 'lodash/isArray';
+
+const SUCCESS_MESSAGE = 'Valid';
+export interface CodeEditorMessageProps {
+ text: string;
+ status: CodeEditorMessageStatus;
+}
+
+export enum CodeEditorMessageStatus {
+ SUCCESS = 'success',
+ WARNING = 'warning',
+ ERROR = 'error',
+}
+
+export function createValidator(schema: JSONSchema4) {
+ const ajv = new Ajv({$data: true, allErrors: true, schemas: [schema], strict: false});
+ // TODO: select is deprecated, replace with discriminator:
+ // https://github.com/ajv-validator/ajv-keywords#selectselectcasesselectdefault
+ ajvKeywords(ajv, 'select');
+
+ return ajv.compile(schema);
+}
+
+export function validate(content: string, validator: ValidateFunction) {
+ let result: CodeEditorMessageProps;
+
+ if (!content) {
+ return {status: CodeEditorMessageStatus.SUCCESS, text: SUCCESS_MESSAGE};
+ }
+
+ try {
+ const jsYamlMap = new SourceMap();
+ const data = yaml.load(content, {listener: jsYamlMap.listen()});
+
+ validator(data);
+
+ if (validator.errors) {
+ const messages = validator.errors.map(
+ ({instancePath, schemaPath, message, params}: ErrorObject) => {
+ const pointer = jsYamlMap.lookup(instancePath.split('/').filter(Boolean));
+ const stringParams = Object.entries(params).map(([key, value]) => {
+ if (isArray(value)) {
+ return `${key}: ${value.join(' | ')}`;
+ }
+ return `${key}: ${value}`;
+ });
+ const ref = pointer ? `${pointer.line}: ` : '';
+ return `${ref}${instancePath || schemaPath}: ${message}\n${stringParams.join(
+ '\n',
+ )}`;
+ },
+ );
+ result = {status: CodeEditorMessageStatus.WARNING, text: messages.join('\n\n')};
+ } else {
+ result = {status: CodeEditorMessageStatus.SUCCESS, text: SUCCESS_MESSAGE};
+ }
+ } catch ({message}) {
+ result = {status: CodeEditorMessageStatus.ERROR, text: message as string};
+ }
+
+ return result;
+}