diff --git a/README.md b/README.md index 7cf03cb..8a3b5ff 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Formule includes a variety of predefined field types, grouped in three categorie - `Accordion`: When containing a `List`, it works as a `List` with collapsible entries. - `Layer`: When containing a `List`, it works as a `List` whose entries will open in a dialog window. - `Tab`: It's commonly supposed to be used as a wrapper around the rest of the elements. You will normally want to add an `Object` inside and you can use it to separate the form in different pages or sections. -- **Advanced fields**: More complex or situational fields such as `URI`, `Rich/Latex editor`, `Tags`, `ID Fetcher`, `Code Editor` and `Files`. +- **Advanced fields**: More complex or situational fields such as `URI`, `Rich/Latex editor`, `Tags`, `ID Fetcher`, `Code Editor`, `Files` and `Slider`. You can freely remove some of these predefined fields and add your own custom fields and widgets following the JSON Schema specifications. More details below. diff --git a/formule-demo/cypress/e2e/builder.cy.ts b/formule-demo/cypress/e2e/builder.cy.ts index bb93245..085435b 100644 --- a/formule-demo/cypress/e2e/builder.cy.ts +++ b/formule-demo/cypress/e2e/builder.cy.ts @@ -875,4 +875,114 @@ describe("test basic functionality", () => { .find('[data-cy="fileAllowedExtensionsText"]') .should("contain.text", "Allowed file extensions: .pdf"); }); + + it("tests slider field", () => { + cy.get("span").contains("Advanced fields").click(); + cy.addFieldWithName("slider", "myfield"); + cy.getByDataCy("treeItem").click(); + + // Test continuous type + cy.get(`input#root${SEP}kind`) + .parent() + .parent() + .find('[title="Continuous"]') + .should("exist"); + + // min, max and step + cy.get(`input#root${SEP}minimum`).clearTypeBlur("0"); + cy.get(`input#root${SEP}maximum`).clearTypeBlur("100"); + cy.get(`input#root${SEP}step`).clearTypeBlur("10", { force: true }); + + // slider interaction + cy.getByDataCy("formPreview") + .find(".ant-slider") + .as("slider") + .click("center"); + cy.getByDataCy("formPreview") + .find(".ant-input-number-input") + .should("have.value", "50"); + + // input interaction + cy.getByDataCy("formPreview") + .find(".ant-input-number-input") + .clearTypeBlur("30"); + cy.get("@slider") + .find(".ant-slider-handle") + .invoke("attr", "aria-valuenow") + .should("eq", "30"); + + // hide input + cy.getByDataCy("fieldSettings") + .find(".scrollableTabs .ant-tabs-nav-list") + .find("[data-node-key=2]") + .click(); + cy.get(`button#root${SEP}ui\\:options${SEP}hideInput`).click(); + cy.getByDataCy("formPreview").find(".ant-input-number").should("not.exist"); + cy.get(`button#root${SEP}ui\\:options${SEP}hideInput`).click(); + + // suffix in tooltip and input + cy.get(`input#root${SEP}ui\\:options${SEP}suffix`).clearTypeBlur("px"); + cy.getByDataCy("formPreview") + .find(".ant-slider-handle") + .trigger("mouseover"); + cy.get(".ant-tooltip").should("contain.text", "px"); + cy.getByDataCy("formPreview") + .find(".ant-input-number-suffix") + .should("contain.text", "px"); + + // Test discrete type + cy.getByDataCy("fieldSettings") + .find(".scrollableTabs .ant-tabs-nav-list") + .find("[data-node-key=1]") + .click({ force: true }); + cy.get(`input#root${SEP}kind`).type("{downArrow}{enter}", { force: true }); + cy.get(`input#root${SEP}kind`) + .parent() + .parent() + .find('[title="Discrete"]') + .should("exist"); + + // values and labels + cy.get(`fieldset#root${SEP}values`) + .find('[data-cy="addItemButton"]') + .click(); + cy.get(`input#root${SEP}values${SEP}0`).clearTypeBlur("25"); + cy.get(`fieldset#root${SEP}values`) + .find('[data-cy="addItemButton"]') + .click(); + cy.get(`input#root${SEP}values${SEP}1`).clearTypeBlur("50"); + cy.get(`fieldset#root${SEP}values`) + .find('[data-cy="addItemButton"]') + .click(); + cy.get(`input#root${SEP}values${SEP}2`).clearTypeBlur("100"); + cy.get(`fieldset#root${SEP}labels`) + .find('[data-cy="addItemButton"]') + .click(); + cy.get(`input#root${SEP}labels${SEP}0`).clearTypeBlur("Small"); + cy.get(`fieldset#root${SEP}labels`) + .find('[data-cy="addItemButton"]') + .click(); + cy.get(`input#root${SEP}labels${SEP}1`).clearTypeBlur("Medium"); + + // marks and interaction + cy.getByDataCy("formPreview") + .find(".ant-slider-mark-text") + .first() + .should("contain.text", "Small"); + cy.getByDataCy("formPreview") + .find(".ant-slider-mark-text") + .last() + .should("contain.text", "100"); // no label for 100 provided + cy.getByDataCy("formPreview").find(".ant-slider").click("right"); + cy.getByDataCy("formPreview") + .find(".ant-slider-handle") + .invoke("attr", "aria-valuenow") + .should("eq", "2"); // last value (index 2) + + // Test readonly + cy.get(`button#root${SEP}readOnly`).click(); + cy.getByDataCy("formPreview") + .find(".ant-slider") + .should("have.class", "ant-slider-disabled"); + }); }); diff --git a/src/admin/components/PropKeyEditorForm.jsx b/src/admin/components/PropKeyEditorForm.jsx index 0c5b038..d6fa396 100644 --- a/src/admin/components/PropKeyEditorForm.jsx +++ b/src/admin/components/PropKeyEditorForm.jsx @@ -1,7 +1,6 @@ import PropTypes from "prop-types"; import Form from "../../forms/Form"; import { hiddenFields } from "../utils/fieldTypes"; -import widgets from "../formComponents/widgets"; import { useContext } from "react"; import CustomizationContext from "../../contexts/CustomizationContext"; @@ -59,7 +58,6 @@ const PropertyKeyEditorForm = ({
{ - const { defaultValue, values, labels } = schema; - - const [marks] = useState(() => { - const m = {}; - labels.forEach((l, i) => (m[i] = l)); - return m; - }); - - const handleChange = event => { - onChange(values[event]); - }; - - return ( - = 0 ? values.indexOf(value) : defaultValue - } - step={null} - min={0} - max={Object.keys(marks).length - 1} - onChange={handleChange} - tooltip={{ - open: false, - }} - /> - ); -}; - -SliderWidget.propTypes = { - schema: PropTypes.object, - onChange: PropTypes.func, - value: PropTypes.number, -}; - -export default SliderWidget; diff --git a/src/admin/formComponents/widgets/index.js b/src/admin/formComponents/widgets/index.js deleted file mode 100644 index 8f76ae4..0000000 --- a/src/admin/formComponents/widgets/index.js +++ /dev/null @@ -1,7 +0,0 @@ -import SliderWidget from "./SliderWidget"; - -const widgets = { - slider: SliderWidget, -}; - -export default widgets; diff --git a/src/admin/utils/fieldTypes.jsx b/src/admin/utils/fieldTypes.jsx index e094890..10e9a9a 100644 --- a/src/admin/utils/fieldTypes.jsx +++ b/src/admin/utils/fieldTypes.jsx @@ -19,6 +19,7 @@ import { FileMarkdownOutlined, NodeIndexOutlined, UploadOutlined, + DashOutlined, } from "@ant-design/icons"; import { placeholder } from "@codemirror/view"; @@ -48,6 +49,7 @@ export const common = { span: { title: "Field Width", type: "integer", + kind: "discrete", defaultValue: 24, values: [6, 8, 12, 16, 18, 24], labels: ["25%", "33%", "50%", "66%", "75%", "100%"], @@ -78,7 +80,23 @@ export const common = { type: "object", properties: { buttonText: { title: "Button title", type: "string" }, - modalWidth: { title: "Modal width", type: "integer" }, + modalWidth: { + title: "Modal width", + type: "integer", + tooltip: + "On small screens modals will ignore this setting and use full screen width", + kind: "discrete", + values: [0, 25, 33, 50, 66, 75, 100], + labels: [ + "auto", + "25%", + "33%", + "50%", + "66%", + "75%", + "100%", + ], + }, buttonInNewLine: { title: "Button in new line", type: "boolean", @@ -109,8 +127,15 @@ export const common = { showAsModal: true, modal: { buttonInNewLine: true, + modalWidth: 33, }, }, + buttonInNewLine: { + "ui:widget": "switch", + }, + modalWidth: { + "ui:widget": "slider", + }, }, showAsModal: { "ui:widget": "switch", @@ -1096,10 +1121,40 @@ const advanced = { isRequired: extra.optionsSchemaUiSchema.isRequired, }, optionsUiSchema: { - ...common.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: { + title: "Height", + type: "integer", + kind: "continuous", + minimum: 200, + maximum: 1000, + step: 100, + }, + }, + }, + "ui:label": common.optionsUiSchema.properties["ui:label"], + }, }, optionsUiSchemaUiSchema: { - ...common.optionsUiSchemaUiSchema, + "ui:options": { + ...common.optionsUiSchemaUiSchema["ui:options"], + height: { + "ui:widget": "slider", + "ui:options": { + suffix: "px", + }, + }, + }, + "ui:label": common.optionsUiSchemaUiSchema["ui:label"], }, default: { schema: { @@ -1107,6 +1162,9 @@ const advanced = { }, uiSchema: { "ui:widget": "richeditor", + "ui:options": { + height: 200, + }, }, }, }, @@ -1315,9 +1373,13 @@ const advanced = { properties: { ...common.optionsUiSchema.properties["ui:options"].properties, height: { - type: "number", title: "Height", - description: "In pixels", + type: "integer", + tooltip: "Set to 0 for auto", + kind: "continuous", + minimum: 0, + maximum: 1000, + step: 100, }, language: { type: "string", @@ -1337,7 +1399,16 @@ const advanced = { }, }, optionsUiSchemaUiSchema: { - ...common.optionsUiSchemaUiSchema, + "ui:options": { + ...common.optionsUiSchemaUiSchema["ui:options"], + height: { + "ui:widget": "slider", + "ui:options": { + suffix: "px", + }, + }, + }, + "ui:label": common.optionsUiSchemaUiSchema["ui:label"], }, default: { schema: { @@ -1421,6 +1492,133 @@ const advanced = { }, }, }, + slider: { + title: "Slider", + icon: , + description: "Select a value within a range", + child: {}, + optionsSchema: { + type: "object", + title: "Slider Schema", + properties: { + ...common.optionsSchema, + kind: { + title: "Type", + type: "string", + oneOf: [ + { const: "continuous", title: "Continuous" }, + { const: "discrete", title: "Discrete" }, + ], + }, + readOnly: extra.optionsSchema.readOnly, + isRequired: extra.optionsSchema.isRequired, + }, + allOf: [ + { + if: { + properties: { + kind: { + const: "continuous", + }, + }, + }, + then: { + properties: { + minimum: { + type: "number", + title: "Minimum value", + }, + maximum: { + type: "number", + title: "Maximum value", + }, + step: { + type: "number", + title: "Step size", + }, + }, + }, + }, + { + if: { + properties: { + kind: { + const: "discrete", + }, + }, + }, + then: { + properties: { + values: { + title: "Values", + type: "array", + items: { + type: "number", + }, + }, + labels: { + title: "Labels", + type: "array", + items: { + type: "string", + }, + }, + }, + }, + }, + ], + }, + optionsSchemaUiSchema: { + readOnly: extra.optionsSchemaUiSchema.readOnly, + isRequired: extra.optionsSchemaUiSchema.isRequired, + }, + 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, + suffix: { + title: "Suffix", + type: "string", + tooltip: + "For visual purposes. Only the plain numeric value will be stored on the form data.", + }, + hideInput: { + type: "boolean", + title: "Hide input", + tooltip: + "Hide numeric input field at the right. On `discrete` sliders the input is never shown and this setting is ignored.", + }, + }, + }, + "ui:label": common.optionsUiSchema.properties["ui:label"], + }, + }, + optionsUiSchemaUiSchema: { + "ui:options": { + ...common.optionsUiSchemaUiSchema["ui:options"], + hideInput: { + "ui:widget": "switch", + }, + }, + "ui:label": common.optionsUiSchemaUiSchema["ui:label"], + }, + default: { + schema: { + type: "number", + kind: "continuous", + }, + uiSchema: { + "ui:widget": "slider", + }, + }, + }, }; // HIDDEN FIELDS (not directly selectable by the user): diff --git a/src/forms/fields/base/CodeEditorField.jsx b/src/forms/fields/base/CodeEditorField.jsx index f38fe3f..acc53df 100644 --- a/src/forms/fields/base/CodeEditorField.jsx +++ b/src/forms/fields/base/CodeEditorField.jsx @@ -74,7 +74,7 @@ const CodeEditorField = ({ // Key needed to refresh the component on settings change due to the custom logic in CodeViewer key={`${language}${readonly}${validationSchema}`} isReadOnly={readonly} - height={height} + height={height || undefined} initialValue={initialValue} handleEdit={(v) => onChange(v)} reset diff --git a/src/forms/templates/Field/FieldModal.jsx b/src/forms/templates/Field/FieldModal.jsx index 5807283..1ae7f75 100644 --- a/src/forms/templates/Field/FieldModal.jsx +++ b/src/forms/templates/Field/FieldModal.jsx @@ -1,11 +1,14 @@ import { useState } from "react"; -import { Button, Modal, Space, Tooltip, theme } from "antd"; +import { Button, Grid, Modal, Space, Tooltip, theme } from "antd"; import { EditOutlined, QuestionCircleOutlined } from "@ant-design/icons"; const FieldModal = ({ id, label, content, options, tooltip }) => { const [modalOpen, setModalOpen] = useState(false); const { token } = theme.useToken(); + const { useBreakpoint } = Grid; + const screens = useBreakpoint(); + return ( <> { getContainer={false} open={modalOpen} onCancel={() => setModalOpen(false)} + title={label} footer={null} className="formule-field-modal" data-cy="fieldModal" - width={options?.modalWidth ? options?.modalWidth : 400} + width={ + screens.md && (options?.modalWidth ? `${options.modalWidth}%` : "50%") + } > {content} diff --git a/src/forms/widgets/base/RichEditorWidget.jsx b/src/forms/widgets/base/RichEditorWidget.jsx index 609ca12..77eb874 100644 --- a/src/forms/widgets/base/RichEditorWidget.jsx +++ b/src/forms/widgets/base/RichEditorWidget.jsx @@ -9,7 +9,18 @@ import { useRef } from "react"; import MdEditor from "react-markdown-editor-lite"; import "react-markdown-editor-lite/lib/index.css"; -const RichEditorWidget = (props) => { +const RichEditorWidget = ({ + onChange, + value, + readonly, + disabled, + canViewProps, + viewProps, + noBorder, + options, +}) => { + const { height } = options; + const mdParser = new MarkdownIt(); mdParser.use(tm, { engine: katex, @@ -22,7 +33,7 @@ const RichEditorWidget = (props) => { return mdParser.render(text); }; const handleEditorChange = (values) => { - props.onChange(values.text); + onChange(values.text); }; MdEditor.use(Toggler, { @@ -32,21 +43,16 @@ const RichEditorWidget = (props) => { return ( 0 - ? props.height - : "500px", - border: props.noBorder ? "none" : undefined, + height: height || undefined, + border: noBorder ? "none" : undefined, }} config={{ canView: { fullScreen: false, md: false, html: false, - ...props.canViewProps, - ...(props.readonly || props.disabled + ...canViewProps, + ...(readonly || disabled ? { md: false, html: true, @@ -60,8 +66,8 @@ const RichEditorWidget = (props) => { fullScreen: false, md: true, html: false, - ...props.viewProps, - ...(props.readonly || props.disabled + ...viewProps, + ...(readonly || disabled ? { md: false, html: true, @@ -72,12 +78,12 @@ const RichEditorWidget = (props) => { : {}), }, }} - readOnly={props.readonly} + readOnly={readonly} renderHTML={renderHTML} onChange={handleEditorChange} - value={props.value} + value={value} ref={myEditor} - key={props.noBorder} + key={noBorder} /> ); }; diff --git a/src/forms/widgets/base/SliderWidget.tsx b/src/forms/widgets/base/SliderWidget.tsx new file mode 100644 index 0000000..2ff681e --- /dev/null +++ b/src/forms/widgets/base/SliderWidget.tsx @@ -0,0 +1,114 @@ +import { Col, InputNumber, Row, Slider } from "antd"; +import { useState } from "react"; + +interface SliderSchema { + defaultValue?: number; + values?: number[]; + labels?: string[]; + minimum?: number; + maximum?: number; + step?: number; + kind?: "continuous" | "discrete"; +} + +interface SliderOptions { + suffix?: string; + hideInput?: boolean; +} + +interface SliderWidgetProps { + schema: SliderSchema; + onChange: (value: number) => void; + value?: number; + options: SliderOptions; + readonly?: boolean; +} + +const SliderWidget = ({ + schema, + onChange, + value, + options, + readonly, +}: SliderWidgetProps) => { + const { defaultValue, values, labels, minimum, maximum, step, kind } = schema; + const { suffix, hideInput } = options; + + const isDiscrete = kind === "discrete"; + + const marks = values?.reduce((acc, _, index) => { + const label = labels?.[index] ?? values[index]; + acc[index] = suffix ? ( + + {label} {suffix} + + ) : ( + label + ); + return acc; + }, {}); + + const [inputValue, setInputValue] = useState( + value || defaultValue, + ); + + const handleChangeDiscrete = (event: number): void => { + values && onChange(values[event]); + }; + + const handleChangeContinuous = (event: number | null): void => { + if (event === null) return; + + setInputValue(event); + onChange(event); + }; + + return ( + + + {isDiscrete ? ( + + ) : ( + val + (suffix ? ` ${suffix}` : "") }} + disabled={readonly} + /> + )} + + {!isDiscrete && !hideInput && ( + + + + )} + + ); +}; + +export default SliderWidget; diff --git a/src/forms/widgets/base/index.js b/src/forms/widgets/base/index.js index 1e69b6f..2dc219c 100644 --- a/src/forms/widgets/base/index.js +++ b/src/forms/widgets/base/index.js @@ -6,6 +6,7 @@ import UriWidget from "./UriWidget"; import DateWidget from "./DateWidget"; import RequiredWidget from "./RequiredWidget"; import SelectWidget from "./SelectWidget"; +import SliderWidget from "./SliderWidget"; const widgets = { text: TextWidget, @@ -17,6 +18,7 @@ const widgets = { checkbox: CheckboxWidget, date: DateWidget, select: SelectWidget, + slider: SliderWidget, }; export default widgets;