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 ( +
+
+ +
+
+ +
+
+ {message && ( +
+
{message.text}
+
+ )} +
+
+ ); +}; 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; +}