Skip to content

Commit

Permalink
feat(form): add code editor field
Browse files Browse the repository at this point in the history
Supports language selection
Supports jsonschema validation
It's been reused for the itemsDisplayTitle in array fields
  • Loading branch information
miguelgrc committed Apr 30, 2024
1 parent 0be1932 commit 2dd9430
Show file tree
Hide file tree
Showing 13 changed files with 578 additions and 94 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"antd": "^5.7.3",
"axios": "^1.4.0",
"codemirror": "^6.0.1",
"codemirror-json-schema": "^0.7.1",
"immutability-helper": "^3.1.1",
"katex": "^0.16.8",
"markdown-it": "^13.0.1",
Expand All @@ -67,6 +68,7 @@
"@commitlint/cli": "^19.0.3",
"@commitlint/config-conventional": "^19.0.3",
"@testing-library/react": "^14.0.0",
"@types/lodash-es": "^4.17.12",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.0.0",
Expand Down
154 changes: 151 additions & 3 deletions src/admin/utils/fieldTypes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
CalendarOutlined,
CheckSquareOutlined,
CloudDownloadOutlined,
CodeOutlined,
ContainerOutlined,
FontSizeOutlined,
LayoutOutlined,
Expand All @@ -16,6 +17,7 @@ import {
TagOutlined,
UnorderedListOutlined,
} from "@ant-design/icons";
import { placeholder } from "@codemirror/view";

// COMMON / EXTRA PROPERTIES:

Expand Down Expand Up @@ -214,17 +216,25 @@ const collections = {
},
},
optionsUiSchemaUiSchema: {
...common.optionsUiSchemaUiSchema,
"ui:options": {
...common.optionsUiSchemaUiSchema["ui:options"],
itemsDisplayTitle: {
"ui:widget": "itemsDisplayTitle",
"ui:options": {
descriptionIsMarkdown: true,
showAsModal: true,
modal: {
buttonInNewLine: true,
},
codeEditor: {
minimal: true,
language: "jinja",
extraExtensions: [
placeholder("Path: {{item_123}} - Type: {{item_456}}"),
],
height: "200px",
},
},
"ui:field": "codeEditor",
},
},
},
Expand Down Expand Up @@ -388,7 +398,6 @@ const simple = {
},
},
optionsUiSchemaUiSchema: {
...common.optionsUiSchemaUiSchema,
"ui:options": {
...common.optionsUiSchemaUiSchema["ui:options"],
mask: {
Expand Down Expand Up @@ -1081,6 +1090,145 @@ const advanced = {
},
},
},
codeEditor: {
title: "Code Editor",
icon: <CodeOutlined />,
description: "Code editor with syntax highlighting",
child: {},
optionsSchema: {
title: "Code Editor Schema",
type: "object",
properties: {
...common.optionsSchema,
validateWith: {
type: "string",
title: "Validate with",
description:
"You can either provide a URL of a UI Schema to validate against or paste the JSON schema directly",
oneOf: [
{ const: "none", title: "None" },
{ const: "url", title: "URL" },
{ const: "json", title: "JSON" },
],
},
readOnly: extra.optionsSchema.readOnly,
isRequired: extra.optionsSchema.isRequired,
},
dependencies: {
validateWith: {
oneOf: [
{
properties: {
validateWith: {
enum: ["url"],
},
validateWithUrl: {
title: "Validation schema URL",
type: "string",
},
},
},
{
properties: {
validateWith: {
enum: ["json"],
},
validateWithJson: {
title: "Validation JSON schema",
type: "string",
},
},
},
{
properties: {
validateWith: {
enum: ["none"],
},
},
},
],
},
},
},
optionsSchemaUiSchema: {
readOnly: extra.optionsSchemaUiSchema.readOnly,
isRequired: extra.optionsSchemaUiSchema.isRequired,
validateWithUrl: {
"ui:widget": "uri",
},
validateWithJson: {
"ui:field": "codeEditor",
"ui:options": {
showAsModal: true,
modal: {
buttonInNewLine: true,
modalWidth: "800px",
},
codeEditor: {
minimal: true,
language: "json",
height: "600px",
extraExtensions: [placeholder("Paste your JSON Schema here")],
},
},
},
"ui:order": [
"title",
"description",
"validateWith",
"validateWithUrl",
"validateWithJson",
"*",
],
},
optionsUiSchema: {
type: "object",
title: "UI Schema",
properties: {
"ui:options": {
type: "object",
title: "UI Options",
dependencies:
common.optionsUiSchema.properties["ui:options"].dependencies,
properties: {
...common.optionsUiSchema.properties["ui:options"].properties,
height: {
type: "number",
title: "Height",
description: "In pixels",
},
language: {
type: "string",
title: "Language",
oneOf: [
{ const: "none", title: "None" },
{ const: "json", title: "JSON" },
{ const: "jinja", title: "Jinja" },
{ const: "stex", title: "LaTeX (sTeX)" },
],
tooltip:
"This setting will be ignored when passing a validation schema in the schema settings",
},
},
},
},
},
optionsUiSchemaUiSchema: {
...common.optionsUiSchemaUiSchema,
},
default: {
schema: {
type: "string",
validateWith: "none",
},
uiSchema: {
"ui:field": "codeEditor",
"ui:options": {
language: "none",
},
},
},
},
};

// HIDDEN FIELDS (not directly selectable by the user):
Expand Down
49 changes: 16 additions & 33 deletions src/forms/Form.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import FieldTemplate from "./templates/Field/FieldTemplate";
import CAPFields from "./fields";
import CAPWidgets from "./widgets";

import objectPath from "object-path";

import "./Form.less";
import { Form } from "@rjsf/antd";
import validator from "@rjsf/validator-ajv8";
Expand Down Expand Up @@ -35,39 +33,16 @@ const RJSFForm = ({
tagName,
liveValidate = false,
showErrorList = false,
transformErrors,
}) => {
const customizationContext = useContext(CustomizationContext);

const customizationContext = useContext(CustomizationContext)

const dispatch = useDispatch()

// mainly this is used for the drafts forms
// we want to allow forms to be saved even without required fields
// if these fields are not filled in when publishing then an error will be shown
const transformErrors = errors => {
errors = errors
.filter(item => item.name != "required")
.map(error => {
if (error.name == "required") return null;

// Update messages for undefined fields when required,
// from "should be string" ==> "Either edit or remove"
if (error.message == "should be string") {
let errorMessages = objectPath.get(formData, error.property);
if (errorMessages == undefined)
error.message = "Either edit or remove";
}

return error;
});

return errors;
};
const dispatch = useDispatch();

const handleChange = (change) => {
onChange && onChange(change)
dispatch(updateFormData({ value: change.formData }))
}
onChange && onChange(change);
dispatch(updateFormData({ value: change.formData }));
};

const templates = {
FieldTemplate: Fields || FieldTemplate,
Expand All @@ -84,8 +59,16 @@ const RJSFForm = ({
uiSchema={uiSchema}
tagName={tagName}
formData={formData}
fields={{ ...CAPFields, ...customizationContext.customFields, ...fields }}
widgets={{ ...CAPWidgets, ...customizationContext.customWidgets, ...widgets }}
fields={{
...CAPFields,
...customizationContext.customFields,
...fields,
}}
widgets={{
...CAPWidgets,
...customizationContext.customWidgets,
...widgets,
}}
templates={templates}
liveValidate={liveValidate}
showErrorList={showErrorList}
Expand Down
82 changes: 82 additions & 0 deletions src/forms/fields/CodeEditorField.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import CodeEditor from "../../utils/CodeEditor";
import axios from "axios";
import { useEffect, useState } from "react";
import { Typography } from "antd";
import { debounce } from "lodash-es";
import { URL_REGEX } from "../../utils";

const CodeEditorField = ({ value, onChange, schema, uiSchema, readonly }) => {
const { validateWith, validateWithUrl, validateWithJson } = schema;
const uiOptions = uiSchema["ui:options"];
const {
language = uiOptions.codeEditor?.language,
height = uiOptions.codeEditor?.height,
codeEditor,
} = uiOptions || {};

const [error, setError] = useState();

const [validationSchema, setValidationSchema] = useState();

const handleUrlError = () => {
setValidationSchema();
setError("Error querying validation schema URL");
};

useEffect(() => {
setError();
if (validateWith === "url" && validateWithUrl) {
const fetchSchema = debounce(() => {
axios
.get(validateWithUrl)
.then((response) => {
setValidationSchema(response.data);
})
.catch(() => {
handleUrlError();
});
}, 2000);
if (new RegExp(URL_REGEX).test(validateWithUrl)) {
fetchSchema();
} else {
handleUrlError();
}
return () => {
// Cleaning up the debounce when validateWithUrl changes
fetchSchema.cancel();
};
} else if (validateWith === "json" && validateWithJson) {
const parsed = (() => {
try {
return JSON.parse(validateWithJson);
} catch {
setError("Error parsing validation JSON");
return;
}
}).call();
setValidationSchema(parsed);
} else {
setValidationSchema();
}
}, [validateWith, validateWithUrl, validateWithJson]);

return (
<>
<CodeEditor
// Key needed to refresh the component on settings change due to the custom logic in CodeViewer
key={`${language}${readonly}${validationSchema}`}
isReadOnly={readonly}
height={height}
value={value}
handleEdit={(v) => onChange(v)}
reset
lang={!validationSchema && language}
validationSchema={!readonly && validationSchema}
{...codeEditor}
/>
<Typography.Text type="danger">{error}</Typography.Text>
</>
);
};

export default CodeEditorField;
2 changes: 2 additions & 0 deletions src/forms/fields/index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import CodeEditorField from "./CodeEditorField";
import IdFetcher from "./IdFetcher";
import TagsField from "./TagsField";

const fields = {
tags: TagsField,
idFetcher: IdFetcher,
codeEditor: CodeEditorField,
};

export default fields;
Loading

0 comments on commit 2dd9430

Please sign in to comment.