From e342d183311081d19c95426c90a61b8e57bbfb55 Mon Sep 17 00:00:00 2001 From: Ben C Date: Sat, 13 Aug 2022 11:04:11 -0400 Subject: [PATCH] Add Some Basic Validation For Manifests --- package.json | 4 +- pnpm-lock.yaml | 60 +++++++++++++++ src-tauri/Cargo.lock | 2 +- src/Common/AppData/Settings.ts | 2 +- src/Common/AppData/SettingsSchema.json | 2 +- src/Common/Popover/IconPopover.tsx | 34 ++++++++ src/Common/Popover/IconPopoverTrigger.tsx | 33 ++++++++ src/MainWindow/MenuBar/AboutGroup.tsx | 7 -- src/MainWindow/MenuBar/FileGroup.tsx | 3 - src/MainWindow/Panels/Editor/Editor.tsx | 8 +- src/MainWindow/Panels/Editor/EditorTab.tsx | 7 +- .../FieldTemplates/DescriptionPopover.tsx | 21 ----- .../FieldTemplates/InfoIconTrigger.tsx | 23 ------ .../InspectorArrayFieldTemplate.tsx | 9 ++- .../FieldTemplates/InspectorFieldTemplate.tsx | 30 ++++++-- .../InspectorObjectFieldTemplate.tsx | 11 ++- .../Inspector/Fields/InspectorBoolean.tsx | 7 +- .../Editor/Editors/Inspector/Inspector.tsx | 36 ++++++++- .../Editors/Inspector/InspectorValidation.ts | 54 +++++++++++++ .../Editor/Editors/Inspector/Validator.ts | 32 -------- .../Panels/Editor/Editors/TextEditor.tsx | 8 ++ .../Editor/Editors/TextEditorValidator.ts | 77 +++++++++++++++++++ src/MainWindow/Store/FileUtils.tsx | 3 +- src/MainWindow/Store/OpenFilesSlice.ts | 47 +++++++++-- src/MainWindow/Validation/GenericRules.ts | 25 ++++++ .../Validation/ManifestValidator.ts | 10 +++ src/MainWindow/Validation/Validator.ts | 58 ++++++++++++++ src/MainWindow/main_window.css | 4 + src/RunWindow/RunWindow.tsx | 8 +- src/SettingsWindow/SettingsWindow.tsx | 24 +++--- 30 files changed, 514 insertions(+), 135 deletions(-) create mode 100644 src/Common/Popover/IconPopover.tsx create mode 100644 src/Common/Popover/IconPopoverTrigger.tsx delete mode 100644 src/MainWindow/Panels/Editor/Editors/Inspector/FieldTemplates/DescriptionPopover.tsx delete mode 100644 src/MainWindow/Panels/Editor/Editors/Inspector/FieldTemplates/InfoIconTrigger.tsx create mode 100644 src/MainWindow/Panels/Editor/Editors/Inspector/InspectorValidation.ts delete mode 100644 src/MainWindow/Panels/Editor/Editors/Inspector/Validator.ts create mode 100644 src/MainWindow/Panels/Editor/Editors/TextEditorValidator.ts create mode 100644 src/MainWindow/Validation/GenericRules.ts create mode 100644 src/MainWindow/Validation/ManifestValidator.ts create mode 100644 src/MainWindow/Validation/Validator.ts diff --git a/package.json b/package.json index 6ed0a4b..3dfac5e 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@rjsf/core": "^4.2.3", "@tauri-apps/api": "^1.0.2", "bootstrap": "^5.2.0", + "jsonpath-plus": "^7.0.0", "monaco-editor": "^0.33.0", "react": "^18.2.0", "react-bootstrap": "^2.5.0", @@ -37,7 +38,8 @@ "react-hotkeys-hook": "^3.4.7", "react-jsonschema-form": "^1.8.1", "react-redux": "^8.0.2", - "sortablejs": "^1.15.0" + "sortablejs": "^1.15.0", + "vscode-json-languageservice": "^5.1.0" }, "devDependencies": { "@tauri-apps/cli": "^1.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6295e5..bec433a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,7 @@ specifiers: eslint-plugin-react: ^7.30.1 husky: ^8.0.1 jsdom: ^20.0.0 + jsonpath-plus: ^7.0.0 lint-staged: ^13.0.3 monaco-editor: ^0.33.0 prettier: ^2.7.1 @@ -42,6 +43,7 @@ specifiers: vite-plugin-html: ^3.2.0 vite-plugin-string: ^1.1.2 vitest: ^0.20.3 + vscode-json-languageservice: ^5.1.0 dependencies: "@monaco-editor/react": 4.4.5_4e2d81f70c46560e05436b87e7bbccf6 @@ -50,6 +52,7 @@ dependencies: "@rjsf/core": 4.2.3_react@18.2.0 "@tauri-apps/api": 1.0.2 bootstrap: 5.2.0_@popperjs+core@2.11.5 + jsonpath-plus: 7.0.0 monaco-editor: 0.33.0 react: 18.2.0 react-bootstrap: 2.5.0_cdd69cb2038decda11a15473ffdbfca6 @@ -59,6 +62,7 @@ dependencies: react-jsonschema-form: 1.8.1_react@18.2.0 react-redux: 8.0.2_ff80cd9dfb626a6c6c4251864122e347 sortablejs: 1.15.0 + vscode-json-languageservice: 5.1.0 devDependencies: "@tauri-apps/cli": 1.0.5 @@ -3664,6 +3668,13 @@ packages: hasBin: true dev: true + /jsonc-parser/3.1.0: + resolution: + { + integrity: sha512-DRf0QjnNeCUds3xTjKlQQ3DpJD51GvDjJfnxUVWg6PZTo2otSm+slzNAxU/35hF8/oJIKoG9slq30JYOsF2azg== + } + dev: false + /jsonfile/6.1.0: resolution: { @@ -3675,6 +3686,14 @@ packages: graceful-fs: 4.2.10 dev: true + /jsonpath-plus/7.0.0: + resolution: + { + integrity: sha512-MH4UnrWrU1hJGVEyEyjvYgONkzNTO6Yol0nq18EMnUQ/ZC5cTuJheirXXIwu1b9mZ6t3XL0P79gPsu+zlTnDIQ== + } + engines: { node: ">=12.0.0" } + dev: false + /jsonpointer/5.0.1: resolution: { @@ -5728,6 +5747,47 @@ packages: - supports-color dev: true + /vscode-json-languageservice/5.1.0: + resolution: + { + integrity: sha512-D5612D7h/Gh4A0JmdttPveWzT9dur21WXvBHWKPdOt0sLO6ILz8vN6+IzWnvwDOVAEFTpzIAMVMZwbKZkwGGiA== + } + dependencies: + jsonc-parser: 3.1.0 + vscode-languageserver-textdocument: 1.0.5 + vscode-languageserver-types: 3.17.2 + vscode-nls: 5.1.0 + vscode-uri: 3.0.3 + dev: false + + /vscode-languageserver-textdocument/1.0.5: + resolution: + { + integrity: sha512-1ah7zyQjKBudnMiHbZmxz5bYNM9KKZYz+5VQLj+yr8l+9w3g+WAhCkUkWbhMEdC5u0ub4Ndiye/fDyS8ghIKQg== + } + dev: false + + /vscode-languageserver-types/3.17.2: + resolution: + { + integrity: sha512-zHhCWatviizPIq9B7Vh9uvrH6x3sK8itC84HkamnBWoDFJtzBf7SWlpLCZUit72b3os45h6RWQNC9xHRDF8dRA== + } + dev: false + + /vscode-nls/5.1.0: + resolution: + { + integrity: sha512-37Ha44QrLFwR2IfSSYdOArzUvOyoWbOYTwQC+wS0NfqKjhW7s0WQ1lMy5oJXgSZy9sAiZS5ifELhbpXodeMR8w== + } + dev: false + + /vscode-uri/3.0.3: + resolution: + { + integrity: sha512-EcswR2S8bpR7fD0YPeS7r2xXExrScVMxg4MedACaWHEtx9ftCF/qHG1xGkolzTPcEmjTavCQgbVzHUIdTMzFGA== + } + dev: false + /w3c-hr-time/1.0.2: resolution: { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index db523df..643a0a2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1580,7 +1580,7 @@ checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54" [[package]] name = "nh-editor" -version = "0.5.0" +version = "0.6.0" dependencies = [ "base64", "fs_extra", diff --git a/src/Common/AppData/Settings.ts b/src/Common/AppData/Settings.ts index f91411c..0de6050 100644 --- a/src/Common/AppData/Settings.ts +++ b/src/Common/AppData/Settings.ts @@ -33,7 +33,7 @@ export type Settings = { minify: boolean; /** - * @description Always use a text editor for files instead of the inspector. (Reload Required) + * @description Always use a text editor for files instead of the inspector. */ alwaysUseTextEditor: boolean; diff --git a/src/Common/AppData/SettingsSchema.json b/src/Common/AppData/SettingsSchema.json index 75d2322..ac5f4d6 100644 --- a/src/Common/AppData/SettingsSchema.json +++ b/src/Common/AppData/SettingsSchema.json @@ -7,7 +7,7 @@ "additionalProperties": false, "properties": { "alwaysUseTextEditor": { - "description": "Always use a text editor for files instead of the inspector. (Reload Required)", + "description": "Always use a text editor for files instead of the inspector.", "type": "boolean" }, "defaultAuthor": { diff --git a/src/Common/Popover/IconPopover.tsx b/src/Common/Popover/IconPopover.tsx new file mode 100644 index 0000000..c508f3f --- /dev/null +++ b/src/Common/Popover/IconPopover.tsx @@ -0,0 +1,34 @@ +import { Placement } from "@popperjs/core"; +import PropTypes from "prop-types"; +import { Popover, PopoverBody, PopoverHeader } from "react-bootstrap"; +import IconPopoverTrigger from "./IconPopoverTrigger"; + +export type DescriptionPopoverProps = { + id: string; + title: string; + body: string; + icon: PropTypes.ReactElementLike; + className?: string; + placement?: Placement; +}; + +function IconPopover(props: DescriptionPopoverProps) { + const popover = ( + + {props.title} + {props.body} + + ); + + return ( + + ); +} + +export default IconPopover; diff --git a/src/Common/Popover/IconPopoverTrigger.tsx b/src/Common/Popover/IconPopoverTrigger.tsx new file mode 100644 index 0000000..04fe03b --- /dev/null +++ b/src/Common/Popover/IconPopoverTrigger.tsx @@ -0,0 +1,33 @@ +import { Placement } from "@popperjs/core"; +import PropTypes from "prop-types"; +import { cloneElement } from "react"; +import { OverlayTrigger } from "react-bootstrap"; + +export type InfoIconTriggerProps = { + popover: PropTypes.ReactElementLike; + icon: PropTypes.ReactElementLike; + ariaLabel: string; + className?: string; + placement?: Placement; +}; + +function IconPopoverTrigger(props: InfoIconTriggerProps) { + // noinspection RequiredAttributes + return ( + + {cloneElement(props.icon, { + className: `ms-2 fs-6 text-secondary${ + props.className ? ` ${props.className}` : "" + }`, + "aria-label": props.ariaLabel + })} + + ); +} + +export default IconPopoverTrigger; diff --git a/src/MainWindow/MenuBar/AboutGroup.tsx b/src/MainWindow/MenuBar/AboutGroup.tsx index 796ca05..1933133 100644 --- a/src/MainWindow/MenuBar/AboutGroup.tsx +++ b/src/MainWindow/MenuBar/AboutGroup.tsx @@ -96,13 +96,6 @@ function ReloadItem() { } }; - window.onbeforeunload = (e) => { - if (filesHaveChanged) { - e.preventDefault(); - e.returnValue = "There are unsaved changes. Are you sure?"; - } - }; - return ( { - console.debug("Save Action"); - console.debug(selectedFile); if (selectedIndex !== -1 && selectedFile !== undefined) { - console.debug("Saving file", selectedFile.name); dispatch(saveFileData({ file: selectedFile, projectPath: project.path })); } }; diff --git a/src/MainWindow/Panels/Editor/Editor.tsx b/src/MainWindow/Panels/Editor/Editor.tsx index 4655c48..f76892a 100644 --- a/src/MainWindow/Panels/Editor/Editor.tsx +++ b/src/MainWindow/Panels/Editor/Editor.tsx @@ -1,4 +1,4 @@ -import { ReactElement, useEffect, useMemo } from "react"; +import { ReactElement, useEffect, useMemo, useRef } from "react"; import Col from "react-bootstrap/Col"; import { connect } from "react-redux"; import { SchemaStore } from "../../../Common/AppData/SchemaStore"; @@ -9,7 +9,8 @@ import { OpenFile, readFileData, selectOpenFileByRelativePath, - selectOpenFileIsSelectedFactory + selectOpenFileIsSelectedFactory, + validateFile } from "../../Store/OpenFilesSlice"; import { isAudio, isImage, usesInspector } from "../../Store/FileUtils"; import { RootState } from "../../Store/Store"; @@ -51,6 +52,7 @@ const determineEditor = ( function Editor(props: EditorProps) { const { alwaysUseTextEditor } = useSettings(); const dispatch = useAppDispatch(); + const currentValidationPromise = useRef<{ abort: () => void } | null>(null); const file = useAppSelector((state: RootState) => selectOpenFileByRelativePath(state.openFiles, props.relativePath) @@ -74,6 +76,8 @@ function Editor(props: EditorProps) { const onDataChanged = (value: string) => { dispatch(fileEdited({ id: file.relativePath, content: value })); + currentValidationPromise.current?.abort(); + currentValidationPromise.current = dispatch(validateFile({ value, file })); }; const ChosenEditor = useMemo( diff --git a/src/MainWindow/Panels/Editor/EditorTab.tsx b/src/MainWindow/Panels/Editor/EditorTab.tsx index cf73a15..a089a97 100644 --- a/src/MainWindow/Panels/Editor/EditorTab.tsx +++ b/src/MainWindow/Panels/Editor/EditorTab.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { cloneElement, useMemo } from "react"; import { X } from "react-bootstrap-icons"; import Col from "react-bootstrap/Col"; import { contextMenu } from "../../Store/ContextMenuSlice"; @@ -41,10 +41,13 @@ function EditorTab(props: EditorTabProps) { const icon = useMemo(() => determineIcon(file), [props.id]); + const hasErrors = + (file.errors.length > 0 || file.otherErrors) && file.tabIndex !== selectedIndex; + return ( - {icon} + {cloneElement(icon, { className: hasErrors ? "text-danger" : "" })} {file.name + (file.memoryData !== file.diskData ? "*" : "")} diff --git a/src/MainWindow/Panels/Editor/Editors/Inspector/FieldTemplates/DescriptionPopover.tsx b/src/MainWindow/Panels/Editor/Editors/Inspector/FieldTemplates/DescriptionPopover.tsx deleted file mode 100644 index d54f62e..0000000 --- a/src/MainWindow/Panels/Editor/Editors/Inspector/FieldTemplates/DescriptionPopover.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Popover, PopoverBody, PopoverHeader } from "react-bootstrap"; -import InfoIconTrigger from "./InfoIconTrigger"; - -export type DescriptionPopoverProps = { - id: string; - title: string; - description: string; -}; - -function DescriptionPopover(props: DescriptionPopoverProps) { - const popover = ( - - {props.title} - {props.description} - - ); - - return ; -} - -export default DescriptionPopover; diff --git a/src/MainWindow/Panels/Editor/Editors/Inspector/FieldTemplates/InfoIconTrigger.tsx b/src/MainWindow/Panels/Editor/Editors/Inspector/FieldTemplates/InfoIconTrigger.tsx deleted file mode 100644 index e9028fc..0000000 --- a/src/MainWindow/Panels/Editor/Editors/Inspector/FieldTemplates/InfoIconTrigger.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import PropTypes from "prop-types"; -import { OverlayTrigger } from "react-bootstrap"; -import { InfoCircle } from "react-bootstrap-icons"; - -export type InfoIconTriggerProps = { - popover: PropTypes.ReactElementLike; -}; - -function InfoIconTrigger(props: InfoIconTriggerProps) { - // noinspection RequiredAttributes - return ( - - - - ); -} - -export default InfoIconTrigger; diff --git a/src/MainWindow/Panels/Editor/Editors/Inspector/FieldTemplates/InspectorArrayFieldTemplate.tsx b/src/MainWindow/Panels/Editor/Editors/Inspector/FieldTemplates/InspectorArrayFieldTemplate.tsx index 14f5199..c33b3c9 100644 --- a/src/MainWindow/Panels/Editor/Editors/Inspector/FieldTemplates/InspectorArrayFieldTemplate.tsx +++ b/src/MainWindow/Panels/Editor/Editors/Inspector/FieldTemplates/InspectorArrayFieldTemplate.tsx @@ -1,9 +1,9 @@ import { ArrayFieldTemplateProps } from "@rjsf/core"; import { cloneElement, useState } from "react"; import { Button, Collapse } from "react-bootstrap"; -import { CaretRightFill } from "react-bootstrap-icons"; +import { CaretRightFill, InfoCircle } from "react-bootstrap-icons"; import { camelToTitleCase } from "../../../../../../Common/Utils"; -import DescriptionPopover from "./DescriptionPopover"; +import IconPopover from "../../../../../../Common/Popover/IconPopover"; function InspectorArrayFieldTemplate({ canAdd, @@ -35,10 +35,11 @@ function InspectorArrayFieldTemplate({ {camelToTitleCase(title)} {schema.description !== undefined && ( - } id={title} title={title} - description={schema.description} + body={schema.description} /> )} diff --git a/src/MainWindow/Panels/Editor/Editors/Inspector/FieldTemplates/InspectorFieldTemplate.tsx b/src/MainWindow/Panels/Editor/Editors/Inspector/FieldTemplates/InspectorFieldTemplate.tsx index 6a3aa34..b1fb3e5 100644 --- a/src/MainWindow/Panels/Editor/Editors/Inspector/FieldTemplates/InspectorFieldTemplate.tsx +++ b/src/MainWindow/Panels/Editor/Editors/Inspector/FieldTemplates/InspectorFieldTemplate.tsx @@ -1,4 +1,5 @@ import { FieldTemplateProps, utils } from "@rjsf/core"; +import { ExclamationTriangleFill, InfoCircle } from "react-bootstrap-icons"; import Col from "react-bootstrap/Col"; import Form from "react-bootstrap/Form"; import Row from "react-bootstrap/Row"; @@ -6,7 +7,7 @@ import { camelToTitleCase } from "../../../../../../Common/Utils"; import InspectorColor from "../Fields/InspectorColor"; import InspectorVector2 from "../Fields/InspectorVector2"; import InspectorVector3 from "../Fields/InspectorVector3"; -import DescriptionPopover from "./DescriptionPopover"; +import IconPopover from "../../../../../../Common/Popover/IconPopover"; import DocsLink from "./DocsLink"; import WrapIfAdditional from "./WrapIfAdditional"; @@ -68,21 +69,24 @@ function InspectorFieldTemplate(props: FieldTemplateProps) { ); } + const hasErrors = props.rawErrors && props.rawErrors.length > 0; + return ( {shouldDisplayLabel && ( <> - + {camelToTitleCase(props.label)} {props.rawDescription === undefined || (props.rawDescription !== "" && ( - } id={props.id} title={props.label} - description={props.rawDescription} + body={props.rawDescription} /> ))} {props.formContext?.docsSchemaLink && ( @@ -91,8 +95,24 @@ function InspectorFieldTemplate(props: FieldTemplateProps) { docsSchema={props.formContext.docsSchemaLink} /> )} + {hasErrors && ( + } + /> + )} + + + {elem} - {elem} )} {!shouldDisplayLabel && {props.children}} diff --git a/src/MainWindow/Panels/Editor/Editors/Inspector/FieldTemplates/InspectorObjectFieldTemplate.tsx b/src/MainWindow/Panels/Editor/Editors/Inspector/FieldTemplates/InspectorObjectFieldTemplate.tsx index e7c9eb4..0a7ebc5 100644 --- a/src/MainWindow/Panels/Editor/Editors/Inspector/FieldTemplates/InspectorObjectFieldTemplate.tsx +++ b/src/MainWindow/Panels/Editor/Editors/Inspector/FieldTemplates/InspectorObjectFieldTemplate.tsx @@ -1,10 +1,10 @@ import { ObjectFieldTemplateProps } from "@rjsf/core"; import { useState } from "react"; import { Button, Collapse } from "react-bootstrap"; -import { CaretRightFill } from "react-bootstrap-icons"; +import { CaretRightFill, InfoCircle } from "react-bootstrap-icons"; import { camelToTitleCase } from "../../../../../../Common/Utils"; -import DescriptionPopover from "./DescriptionPopover"; +import IconPopover from "../../../../../../Common/Popover/IconPopover"; function InspectorObjectFieldTemplate({ title, @@ -36,7 +36,12 @@ function InspectorObjectFieldTemplate({ className={`pb-2 pe-1 object-caret ${open ? "open" : ""}`} /> {camelToTitleCase(title)} - + } + id={title} + title={title} + body={description} + /> )} {schema.additionalProperties && ( diff --git a/src/MainWindow/Panels/Editor/Editors/Inspector/Fields/InspectorBoolean.tsx b/src/MainWindow/Panels/Editor/Editors/Inspector/Fields/InspectorBoolean.tsx index c408787..c0d1a91 100644 --- a/src/MainWindow/Panels/Editor/Editors/Inspector/Fields/InspectorBoolean.tsx +++ b/src/MainWindow/Panels/Editor/Editors/Inspector/Fields/InspectorBoolean.tsx @@ -1,18 +1,15 @@ import { FieldProps } from "@rjsf/core"; -import { useState } from "react"; import { Form } from "react-bootstrap"; function InspectorBoolean(props: FieldProps) { - const [checked, setChecked] = useState((props.formData ?? false) as boolean); return ( { - setChecked(!checked); - props.onChange(!checked); + props.onChange(!(props.formData ?? false)); }} id={props.id} - checked={checked} + checked={props.formData ?? false} /> ); } diff --git a/src/MainWindow/Panels/Editor/Editors/Inspector/Inspector.tsx b/src/MainWindow/Panels/Editor/Editors/Inspector/Inspector.tsx index e969711..872fc63 100644 --- a/src/MainWindow/Panels/Editor/Editors/Inspector/Inspector.tsx +++ b/src/MainWindow/Panels/Editor/Editors/Inspector/Inspector.tsx @@ -1,14 +1,21 @@ import Form, { UiSchema } from "@rjsf/core"; import { useState } from "react"; import { getDocsLinkForNHConfig, getSchemaName } from "../../../../Store/FileUtils"; +import { useAppDispatch } from "../../../../Store/Hooks"; +import { setOtherErrors } from "../../../../Store/OpenFilesSlice"; import { IEditorProps } from "../../Editor"; import InspectorBoolean from "./Fields/InspectorBoolean"; import InspectorArrayFieldTemplate from "./FieldTemplates/InspectorArrayFieldTemplate"; import InspectorFieldTemplate from "./FieldTemplates/InspectorFieldTemplate"; import InspectorObjectFieldTemplate from "./FieldTemplates/InspectorObjectFieldTemplate"; -import baseValidate, { customFormats, transformErrors } from "./Validator"; +import { + customFormats, + transformErrors, + validationErrorsToErrorSchema +} from "./InspectorValidation"; function Inspector(props: IEditorProps) { + const dispatch = useAppDispatch(); const [formData, setFormData] = useState(JSON.parse(props.fileData)); const customFields = { @@ -25,7 +32,7 @@ function Inspector(props: IEditorProps) { const onChange = (newData: object) => { setFormData(newData); - props.onChange?.(JSON.stringify(newData)); + props.onChange?.(JSON.stringify(newData, null, 4)); }; const formContext = { @@ -39,6 +46,16 @@ function Inspector(props: IEditorProps) {
onChange(newData.formData)} + onError={(e) => { + // Why is e set as any?????? + // Docs say it's an array of errors so...? + dispatch( + setOtherErrors({ + id: props.file.relativePath, + otherErrors: (e as string[]).length > 0 + }) + ); + }} className="mx-3 inspector-form" formData={formData} formContext={formContext} @@ -47,9 +64,20 @@ function Inspector(props: IEditorProps) { $schema: "http://json-schema.org/draft-07/schema" }} uiSchema={uiSchema} + extraErrors={validationErrorsToErrorSchema(props.file.errors)} fields={customFields} - transformErrors={transformErrors} - validate={baseValidate} + transformErrors={(e) => { + const errors = transformErrors(e); + if (props.file.otherErrors !== errors.length > 0) { + dispatch( + setOtherErrors({ + id: props.file.relativePath, + otherErrors: errors.length > 0 + }) + ); + } + return errors; + }} customFormats={customFormats} liveValidate={true} ArrayFieldTemplate={InspectorArrayFieldTemplate} diff --git a/src/MainWindow/Panels/Editor/Editors/Inspector/InspectorValidation.ts b/src/MainWindow/Panels/Editor/Editors/Inspector/InspectorValidation.ts new file mode 100644 index 0000000..e25605a --- /dev/null +++ b/src/MainWindow/Panels/Editor/Editors/Inspector/InspectorValidation.ts @@ -0,0 +1,54 @@ +import { AjvError } from "@rjsf/core"; +import { JSONPath } from "jsonpath-plus"; +import { prettyErrorMessage } from "../../../../../Common/Utils"; +import { ValidationError } from "../../../../Validation/Validator"; + +export const customFormats = { + int32: /-?\d+/, + float: /-?\d+\.\d+/ +}; + +export const validationErrorsToErrorSchema = (errors: ValidationError[]) => { + // Transforms validation errors list to an error schema for rjsf + // Based on the function they use to turn AJV errors into an error schema + if (!errors.length) { + return {}; + } + return errors.reduce((errorSchema, error) => { + const message = `${error.message} (${error.id})`; + const path = JSONPath.toPathArray(error.location.substring(1)); + let parent = errorSchema as Record; + + if (path.length > 0 && path[0] === "") { + path.splice(0, 1); + } + + for (const segment of path.slice(0)) { + if (!(segment in parent)) { + parent[segment] = {}; + } + parent = parent[segment] as Record; + } + + if (Array.isArray(parent.__errors)) { + parent.__errors = parent.__errors.concat(message); + } else { + if (message) { + parent.__errors = [message]; + } + } + return errorSchema; + }, {}); +}; + +const transformErrors = (errors: AjvError[]): AjvError[] => { + const newErrors = errors.filter( + (e) => e.name !== "additionalProperties" && !e.message.includes("should be object") + ); + return newErrors.map((e) => { + const message = prettyErrorMessage(e.message); + return { ...e, message }; + }); +}; + +export { transformErrors }; diff --git a/src/MainWindow/Panels/Editor/Editors/Inspector/Validator.ts b/src/MainWindow/Panels/Editor/Editors/Inspector/Validator.ts deleted file mode 100644 index 1fd6da3..0000000 --- a/src/MainWindow/Panels/Editor/Editors/Inspector/Validator.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { AjvError, FieldError } from "@rjsf/core"; -import { prettyErrorMessage } from "../../../../../Common/Utils"; - -// Redefined from rjsf bc they don't export the type for literally no reason :D -type FieldValidation = { - __errors: FieldError[]; - addError: (message: string) => void; -}; - -type FormValidation = FieldValidation & { - [fieldName: string]: FieldValidation; -}; - -export const customFormats = { - int32: /-?\d+/, - float: /-?\d+\.\d+/ -}; - -const transformErrors = (errors: AjvError[]): AjvError[] => { - const newErrors = errors.filter((e) => e.name !== "additionalProperties"); - return newErrors.map((e) => { - const message = prettyErrorMessage(e.message); - return { ...e, message }; - }); -}; - -const baseValidate = (formData: object, errors: FormValidation): FormValidation => { - return errors; -}; - -export default baseValidate; -export { transformErrors }; diff --git a/src/MainWindow/Panels/Editor/Editors/TextEditor.tsx b/src/MainWindow/Panels/Editor/Editors/TextEditor.tsx index 44bf18d..d7a9e9d 100644 --- a/src/MainWindow/Panels/Editor/Editors/TextEditor.tsx +++ b/src/MainWindow/Panels/Editor/Editors/TextEditor.tsx @@ -1,21 +1,25 @@ import Editor, { Monaco } from "@monaco-editor/react"; import { getCurrent } from "@tauri-apps/api/window"; import * as monaco from "monaco-editor"; +import { useEffect, useRef } from "react"; import { getMonacoJsonDiagnostics } from "../../../../Common/AppData/SchemaStore"; import CenteredSpinner from "../../../../Common/Spinner/CenteredSpinner"; import { ThemeMonacoMap } from "../../../../Common/Theme/ThemeManager"; import { getMonacoLanguage } from "../../../Store/FileUtils"; import { useSettings } from "../../../../Wrapper"; import { IEditorProps } from "../Editor"; +import showErrors from "./TextEditorValidator"; import IStandaloneCodeEditor = monaco.editor.IStandaloneCodeEditor; function TextEditor(props: IEditorProps) { + const model = useRef<[Monaco, IStandaloneCodeEditor]>(); const { theme } = useSettings(); const handleComponentDidMount = async ( codeEditor: IStandaloneCodeEditor, monacoInstance: Monaco ) => { + model.current = [monacoInstance, codeEditor]; let chosenTheme = theme; if (theme === "Follow System") { chosenTheme = @@ -24,8 +28,11 @@ function TextEditor(props: IEditorProps) { monacoInstance.editor.setTheme(ThemeMonacoMap[chosenTheme]); const jsonDiagnostics = await getMonacoJsonDiagnostics(); monacoInstance.languages.json.jsonDefaults.setDiagnosticsOptions(jsonDiagnostics); + showErrors(props.file, model); }; + useEffect(() => showErrors(props.file, model), [model.current, props.file.errors]); + return ( { @@ -34,6 +41,7 @@ function TextEditor(props: IEditorProps) { loading={} language={getMonacoLanguage(props.file)} value={props.fileData} + className="z-40" onMount={handleComponentDidMount} /> ); diff --git a/src/MainWindow/Panels/Editor/Editors/TextEditorValidator.ts b/src/MainWindow/Panels/Editor/Editors/TextEditorValidator.ts new file mode 100644 index 0000000..2fd9780 --- /dev/null +++ b/src/MainWindow/Panels/Editor/Editors/TextEditorValidator.ts @@ -0,0 +1,77 @@ +import { Monaco } from "@monaco-editor/react"; +import { JSONPath } from "jsonpath-plus"; +import * as monaco from "monaco-editor"; +import { + JSONDocument, + getLanguageService, + TextDocument, + ObjectASTNode, + ArrayASTNode +} from "vscode-json-languageservice"; +import { MutableRefObject } from "react"; +import { OpenFile } from "../../../Store/OpenFilesSlice"; +import IStandaloneCodeEditor = monaco.editor.IStandaloneCodeEditor; +import IMarkerData = monaco.editor.IMarkerData; +const findPositionOfPath = ( + txtDoc: TextDocument, + jsonDoc: JSONDocument, + content: string, + path: string[] +) => { + let currentNode = jsonDoc.root; + if (currentNode !== undefined) { + for (const key of path) { + if (currentNode!.type === "object") { + currentNode = (currentNode as ObjectASTNode).properties.find( + (p) => p.keyNode.value === key + ); + } else if (currentNode!.type === "array") { + currentNode = (currentNode as ArrayASTNode).items[JSON.parse(key)]; + } + } + if (currentNode?.type === "property") { + currentNode = currentNode.valueNode; + } + return [ + txtDoc.positionAt(currentNode!.offset), + txtDoc.positionAt(currentNode!.offset + currentNode!.length) + ]; + } else { + return [txtDoc.positionAt(0), txtDoc.positionAt(0)]; + } +}; + +const showErrors = ( + file: OpenFile, + model: MutableRefObject<[Monaco, IStandaloneCodeEditor] | undefined> +) => { + if (model.current) { + const [monacoInstance, codeEditor] = model.current; + const codeModel = codeEditor.getModel(); + if (codeModel) { + const txtDoc = TextDocument.create("", "", 0, codeModel.getValue()); + const jsonDoc = getLanguageService({}).parseJSONDocument(txtDoc); + + const errors: IMarkerData[] = file.errors.map((e) => { + const position = findPositionOfPath( + txtDoc, + jsonDoc, + codeModel.getValue(), + JSONPath.toPathArray(e.location).slice(1) + ); + return { + severity: 8, + startLineNumber: position[0].line + 1, + startColumn: position[0].character + 1, + endLineNumber: position[1].line + 1, + endColumn: position[1].character + 1, + message: e.message + }; + }); + + monacoInstance.editor.setModelMarkers(codeModel, "owner", errors); + } + } +}; + +export default showErrors; diff --git a/src/MainWindow/Store/FileUtils.tsx b/src/MainWindow/Store/FileUtils.tsx index 704746a..d730805 100644 --- a/src/MainWindow/Store/FileUtils.tsx +++ b/src/MainWindow/Store/FileUtils.tsx @@ -259,7 +259,8 @@ export const getInitialContent = (rootDir: string) => { return JSON.stringify( { $schema: - "https://raw.githubusercontent.com/xen-42/outer-wilds-new-horizons/main/NewHorizons/Schemas/system_schema.json" + "https://raw.githubusercontent.com/xen-42/outer-wilds-new-horizons/main/NewHorizons/Schemas/system_schema.json", + Vessel: null }, null, 4 diff --git a/src/MainWindow/Store/OpenFilesSlice.ts b/src/MainWindow/Store/OpenFilesSlice.ts index 191b20e..acbb9cf 100644 --- a/src/MainWindow/Store/OpenFilesSlice.ts +++ b/src/MainWindow/Store/OpenFilesSlice.ts @@ -10,8 +10,7 @@ import { dialog } from "@tauri-apps/api"; import { ask, message } from "@tauri-apps/api/dialog"; import { sep } from "@tauri-apps/api/path"; import { tauriCommands } from "../../Common/TauriCommands"; -import { initialLoadState, LoadState } from "./LoadState"; -import { invalidate, ProjectFile } from "./ProjectFilesSlice"; +import validate, { ValidationError } from "../Validation/Validator"; import { determineOpenFunction, getContentToSave, @@ -19,6 +18,8 @@ import { getInitialContent, getRootDirectory } from "./FileUtils"; +import { initialLoadState, LoadState } from "./LoadState"; +import { invalidate, ProjectFile } from "./ProjectFilesSlice"; import { RootState } from "./Store"; type FileData = string | null; @@ -28,12 +29,19 @@ export type OpenFile = { memoryData: FileData; diskData: FileData; loadState: LoadState; + errors: ValidationError[]; + otherErrors: boolean; } & Omit; -export const readFileData = createAsyncThunk("openFiles/readFileData", async (file: OpenFile) => { - const openFunc = determineOpenFunction(file); - return await openFunc(file.absolutePath); -}); +export const readFileData = createAsyncThunk( + "openFiles/readFileData", + async (file: OpenFile, thunkAPI) => { + const openFunc = determineOpenFunction(file); + const data = await openFunc(file.absolutePath); + thunkAPI.dispatch(validateFile({ value: data, file })); + return data; + } +); export const saveFileData = createAsyncThunk( "openFiles/saveFileData", @@ -88,6 +96,16 @@ export const closeTab = createAsyncThunk("openFiles/closeTab", async (file: Open } }); +export const validateFile = createAsyncThunk( + "openFiles/validateFile", + async (payload: { value: string; file: OpenFile }) => { + const { value, file } = payload; + // Wait a sec in case the user isn't done typing + await new Promise((resolve) => setTimeout(resolve, 500)); + return await validate(value, file); + } +); + export const closeTabs = createAsyncThunk( "openFiles/closeTabs", async (files: OpenFile[], thunkAPI) => { @@ -178,6 +196,8 @@ const openFilesSlice = createSlice({ extension: projectFile.extension, diskData: null, memoryData: null, + errors: [], + otherErrors: false, loadState: initialLoadState }; openFilesAdapter.addOne(state, newOpenFile); @@ -199,6 +219,8 @@ const openFilesSlice = createSlice({ absolutePath: "@@void@@", extension: "json", diskData: null, + errors: [], + otherErrors: false, memoryData: getInitialContent(action.payload.rootDir), loadState: { status: "done", error: "Unknown Error" } }; @@ -235,6 +257,10 @@ const openFilesSlice = createSlice({ fileEdited: (state, action: PayloadAction<{ id: EntityId; content: string }>) => { const { id, content } = action.payload; openFilesAdapter.updateOne(state, { id: id, changes: { memoryData: content } }); + }, + setOtherErrors: (state, action: PayloadAction<{ id: EntityId; otherErrors: boolean }>) => { + const { id, otherErrors } = action.payload; + openFilesAdapter.updateOne(state, { id: id, changes: { otherErrors: otherErrors } }); } }, extraReducers: (builder) => { @@ -302,6 +328,12 @@ const openFilesSlice = createSlice({ openFile.loadState.status = "done"; } }); + builder.addCase(validateFile.fulfilled, (state, action) => { + const openFile = state.entities[action.meta.arg.file.relativePath]; + if (openFile !== undefined) { + openFile.errors = action.payload; + } + }); } }); @@ -314,7 +346,8 @@ export const { forceCloseTab, forceCloseTabs, fileEdited, - createVoidFile + createVoidFile, + setOtherErrors } = openFilesSlice.actions; export const { diff --git a/src/MainWindow/Validation/GenericRules.ts b/src/MainWindow/Validation/GenericRules.ts new file mode 100644 index 0000000..dae19e5 --- /dev/null +++ b/src/MainWindow/Validation/GenericRules.ts @@ -0,0 +1,25 @@ +import { sep } from "@tauri-apps/api/path"; +import { JSONPath } from "jsonpath-plus"; +import { tauriCommands } from "../../Common/TauriCommands"; +import { ValidationContext, ValidationRule } from "./Validator"; + +export function fileMustExistRule( + id: string, + propPath: string +): ValidationRule { + return { + id, + perform: async (config: T, context: ValidationContext) => { + const path = JSONPath({ path: propPath, json: config }); + const exists = await tauriCommands.fileExists(`${context.projectPath}${sep}${path}`); + return { + valid: exists, + error: { + id, + message: `File not found: ${path}`, + location: propPath + } + }; + } + }; +} diff --git a/src/MainWindow/Validation/ManifestValidator.ts b/src/MainWindow/Validation/ManifestValidator.ts new file mode 100644 index 0000000..3f608b9 --- /dev/null +++ b/src/MainWindow/Validation/ManifestValidator.ts @@ -0,0 +1,10 @@ +import { fileMustExistRule } from "./GenericRules"; +import { ValidationRule } from "./Validator"; + +type Manifest = { + dllPath: string; +}; + +const checkForDLL: ValidationRule = fileMustExistRule("M001", "$.filename"); + +export default [checkForDLL]; diff --git a/src/MainWindow/Validation/Validator.ts b/src/MainWindow/Validation/Validator.ts new file mode 100644 index 0000000..5e8a6d8 --- /dev/null +++ b/src/MainWindow/Validation/Validator.ts @@ -0,0 +1,58 @@ +import { getFileName } from "../Store/FileUtils"; +import { OpenFile } from "../Store/OpenFilesSlice"; +import manifestRules from "./ManifestValidator"; + +export type ValidationResult = { + valid: boolean; + error: ValidationError; +}; + +export type ValidationRule = { + id: string; + perform: (config: T, context: ValidationContext) => Promise; +}; + +export type ValidationError = { + id: string; + message: string; + location: string; +}; + +export type ValidationContext = { + projectPath: string; +}; + +const determineValidationRules = (configPath: string) => { + const filename = getFileName(configPath); + if (filename === "manifest.json") { + return manifestRules; + } + return []; +}; + +const validateWithRules = async ( + configRaw: string, + rules: ValidationRule[], + context: ValidationContext +) => { + if (rules.length === 0) return []; + const config = JSON.parse(configRaw); + const errors: ValidationError[] = []; + for (const rule of rules) { + const result = await rule.perform(config, context); + if (!result.valid) { + errors.push(result.error); + } + } + return errors; +}; + +const validate = async (content: string, file: OpenFile) => { + const context: ValidationContext = { + projectPath: file.absolutePath.replace(file.relativePath, "") + }; + const rules = determineValidationRules(file.relativePath); + return await validateWithRules(content, rules, context); +}; + +export default validate; diff --git a/src/MainWindow/main_window.css b/src/MainWindow/main_window.css index 54750b6..3b33a47 100644 --- a/src/MainWindow/main_window.css +++ b/src/MainWindow/main_window.css @@ -43,3 +43,7 @@ backdrop-filter: blur(5px); z-index: 100; } + +.tab-danger { + color: var(--bs-danger); +} diff --git a/src/RunWindow/RunWindow.tsx b/src/RunWindow/RunWindow.tsx index c938fbf..e2f790a 100644 --- a/src/RunWindow/RunWindow.tsx +++ b/src/RunWindow/RunWindow.tsx @@ -5,6 +5,7 @@ import { exit } from "@tauri-apps/api/process"; import { appWindow, WebviewWindow } from "@tauri-apps/api/window"; import React, { useEffect } from "react"; import { Form, Spinner } from "react-bootstrap"; +import { InfoCircle } from "react-bootstrap-icons"; import Button from "react-bootstrap/Button"; import Col from "react-bootstrap/Col"; import Container from "react-bootstrap/Container"; @@ -12,7 +13,7 @@ import Row from "react-bootstrap/Row"; import { getModManagerSettings } from "../Common/ModManager"; import { loadProjectFromURLParams, Project } from "../Common/Project"; import CenteredSpinner from "../Common/Spinner/CenteredSpinner"; -import DescriptionPopover from "../MainWindow/Panels/Editor/Editors/Inspector/FieldTemplates/DescriptionPopover"; +import IconPopover from "../Common/Popover/IconPopover"; export const openRunWindow = (projectPath: string) => { const current = WebviewWindow.getByLabel("run-game"); @@ -81,10 +82,11 @@ function RunWindow() { Log Port - } id="logPort" title="Log Port" - description={`The port is the first message displayed in the mod manager. It is + body={`The port is the first message displayed in the mod manager. It is the number after the "Port: ", its usually in the 50000s. This is needed so that the game knows what port to log messages to. This number changes whenever you restart the manager`} /> diff --git a/src/SettingsWindow/SettingsWindow.tsx b/src/SettingsWindow/SettingsWindow.tsx index cac9c65..8e5c6c9 100644 --- a/src/SettingsWindow/SettingsWindow.tsx +++ b/src/SettingsWindow/SettingsWindow.tsx @@ -9,9 +9,10 @@ import Button from "react-bootstrap/Button"; import Col from "react-bootstrap/Col"; import Container from "react-bootstrap/Container"; import Row from "react-bootstrap/Row"; -import { blankSettings, SettingsManager } from "../Common/AppData/Settings"; +import { blankSettings, Settings, SettingsManager } from "../Common/AppData/Settings"; import settingsSchema from "../Common/AppData/SettingsSchema.json"; +import CenteredSpinner from "../Common/Spinner/CenteredSpinner"; import InspectorBoolean from "../MainWindow/Panels/Editor/Editors/Inspector/Fields/InspectorBoolean"; import InspectorArrayFieldTemplate from "../MainWindow/Panels/Editor/Editors/Inspector/FieldTemplates/InspectorArrayFieldTemplate"; import InspectorFieldTemplate from "../MainWindow/Panels/Editor/Editors/Inspector/FieldTemplates/InspectorFieldTemplate"; @@ -34,15 +35,19 @@ export const openSettingsWindow = () => { }; function SettingsWindow() { - const [settings, setSettings] = useState(blankSettings); - const [initialSettings, setInitialSettings] = useState(blankSettings); + const [settings, setSettings] = useState(blankSettings); + const [initialSettings, setInitialSettings] = useState(blankSettings); useEffect(() => { SettingsManager.get().then((s) => { setSettings(s); setInitialSettings(s); }); - }); + }, []); + + if (settings === null || initialSettings === null) { + return ; + } const customFields = { BooleanField: InspectorBoolean @@ -78,11 +83,9 @@ function SettingsWindow() { await emit("nh://settings-changed", settings); const themeChanged = settings.theme !== initialSettings.theme; - const alwaysUseTextEditorChanged = - settings.alwaysUseTextEditor !== initialSettings.alwaysUseTextEditor; const schemaBranchChanged = settings.schemaBranch !== initialSettings.schemaBranch; - if ([themeChanged, alwaysUseTextEditorChanged, schemaBranchChanged].includes(true)) { + if ([themeChanged, schemaBranchChanged].includes(true)) { const result = await ask( "You need to reload the app to apply these changes. Do you want to reload now? (Any unsaved changes will be lost!)", { @@ -106,7 +109,7 @@ function SettingsWindow() { ); if (result) { await SettingsManager.reset(); - setSettings(await SettingsManager.get()); + setSettings(initialSettings); close(); await emit("nh://reload"); } @@ -118,7 +121,10 @@ function SettingsWindow() {
setSettings(e.formData)} + onChange={(e) => { + console.debug(e.formData); + setSettings(e.formData); + }} formData={settings} uiSchema={uiSchema} schema={settingsSchema as JSONSchema7}