From 0dc4a0e0e0ead38890464121d0c59771c3481489 Mon Sep 17 00:00:00 2001 From: Miguel Garcia Garcia Date: Tue, 10 Dec 2024 18:09:23 +0100 Subject: [PATCH] feat(form): add steps component Closes #80 --- formule-demo/cypress/e2e/builder.cy.ts | 28 ++++ formule-demo/src/App.tsx | 8 +- formule-demo/src/theme.ts | 3 + src/admin/utils/fieldTypes.jsx | 115 +++++++++++++- src/forms/Form.less | 12 +- src/forms/templates/ObjectFieldTemplate.jsx | 9 ++ src/forms/templates/StepsField.jsx | 168 ++++++++++++++++++++ src/forms/templates/Tabs/TabField.jsx | 2 +- 8 files changed, 332 insertions(+), 13 deletions(-) create mode 100644 src/forms/templates/StepsField.jsx diff --git a/formule-demo/cypress/e2e/builder.cy.ts b/formule-demo/cypress/e2e/builder.cy.ts index 1b34f32..1bb0ad1 100644 --- a/formule-demo/cypress/e2e/builder.cy.ts +++ b/formule-demo/cypress/e2e/builder.cy.ts @@ -648,6 +648,34 @@ describe("test basic functionality", () => { .should("exist"); }); + it("tests steps field", () => { + cy.addFieldWithName("stepsView", "mysteps"); + cy.getByDataCy("treeItem").contains("mysteps").as("stepsField"); + cy.addFieldWithName("text", "myfield1", "@stepsField"); + cy.addFieldWithName("text", "myfield2", "@stepsField"); + + // Direct navigation + cy.getByDataCy("formPreview") + .find(`input#root${SEP}mysteps${SEP}myfield1`) + .should("exist"); + cy.getByDataCy("formPreview") + .find(".ant-steps-item") + .contains("myfield2") + .click(); + cy.getByDataCy("formPreview") + .find(`input#root${SEP}mysteps${SEP}myfield2`) + .should("exist"); + // Navigation with previous and next buttons + cy.getByDataCy("formPreview").find("button").contains("Previous").click(); + cy.getByDataCy("formPreview") + .find(`input#root${SEP}mysteps${SEP}myfield1`) + .should("exist"); + cy.getByDataCy("formPreview").find("button").contains("Next").click(); + cy.getByDataCy("formPreview") + .find(`input#root${SEP}mysteps${SEP}myfield2`) + .should("exist"); + }); + it("tests code editor field", () => { const shouldHaveValidationErrors = (point: boolean, range: boolean) => { if (!point && !range) { diff --git a/formule-demo/src/App.tsx b/formule-demo/src/App.tsx index 36ad7ae..e3d9c5b 100644 --- a/formule-demo/src/App.tsx +++ b/formule-demo/src/App.tsx @@ -10,6 +10,7 @@ import { UploadOutlined, RollbackOutlined, ToolOutlined, + DownOutlined, } from "@ant-design/icons"; import { Button, @@ -326,7 +327,7 @@ function App() { setOpenFloatButtons(!openFloatButtons)} icon={} + closeIcon={} type="primary" badge={ !openFloatButtons && hasUnsavedChanges diff --git a/formule-demo/src/theme.ts b/formule-demo/src/theme.ts index e6f3ba2..7faac85 100644 --- a/formule-demo/src/theme.ts +++ b/formule-demo/src/theme.ts @@ -13,5 +13,8 @@ export const theme = { Segmented: { trackBg: "#E4E8EC", }, + Progress: { + defaultColor: PRIMARY_COLOR, + }, }, }; diff --git a/src/admin/utils/fieldTypes.jsx b/src/admin/utils/fieldTypes.jsx index d7cafd9..ee4a3d4 100644 --- a/src/admin/utils/fieldTypes.jsx +++ b/src/admin/utils/fieldTypes.jsx @@ -1,14 +1,13 @@ import { AimOutlined, AppstoreOutlined, - BookOutlined, BorderHorizontalOutlined, BorderTopOutlined, CalendarOutlined, CheckSquareOutlined, CloudDownloadOutlined, CodeOutlined, - ContainerOutlined, + AlignCenterOutlined, FontSizeOutlined, LayoutOutlined, LinkOutlined, @@ -16,6 +15,9 @@ import { SwapOutlined, TagOutlined, UnorderedListOutlined, + FieldNumberOutlined, + FileMarkdownOutlined, + NodeIndexOutlined, } from "@ant-design/icons"; import { placeholder } from "@codemirror/view"; @@ -54,6 +56,7 @@ export const common = { type: "boolean", }, }, + // Using dependencies here instead of if-then-else simplifies reusing the common properties dependencies: { showAsModal: { oneOf: [ @@ -317,6 +320,108 @@ const collections = { }, }, }, + stepsView: { + title: "Steps", + icon: , + child: {}, + optionsSchema: { + type: "object", + title: "Steps Field Schema", + properties: { + ...common.optionsSchema, + }, + }, + optionsSchemaUiSchema: {}, + 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, + hideSteps: { + type: "boolean", + title: "Hide steps", + tooltip: + "Hide the steps and display a simple progress bar instead", + }, + }, + if: { + properties: { + hideSteps: { + const: false, + }, + }, + }, + then: { + properties: { + stepsPlacement: { + type: "string", + title: "Steps placement", + oneOf: [ + { const: "horizontal", title: "Horizontal" }, + { const: "vertical", title: "Vertical" }, + ], + }, + hideButtons: { + type: "boolean", + title: "Hide buttons", + tooltip: "Hide the next and previous buttons", + }, + hideNumbers: { + type: "boolean", + title: "Hide numbers", + tooltip: "Hide the step numbers and show a simple dot instead", + }, + markAsCompleted: { + type: "boolean", + title: "Mark as completed", + tooltip: + "Mark the steps as completed (if correct) after moving to the next one", + }, + }, + }, + }, + "ui:label": common.optionsUiSchema.properties["ui:label"], + }, + }, + optionsUiSchemaUiSchema: { + "ui:options": { + ...common.optionsUiSchemaUiSchema["ui:options"], + hideSteps: { + "ui:widget": "switch", + }, + hideButtons: { + "ui:widget": "switch", + }, + hideNumbers: { + "ui:widget": "switch", + }, + markAsCompleted: { + "ui:widget": "switch", + }, + }, + "ui:label": common.optionsUiSchemaUiSchema["ui:label"], + }, + default: { + schema: { + type: "object", + properties: {}, + }, + uiSchema: { + "ui:object": "stepsView", + "ui:options": { + stepsPlacement: "horizontal", + markAsCompleted: true, + }, + }, + }, + }, layerObjectField: { title: "Layer", icon: , @@ -433,7 +538,7 @@ const simple = { }, textarea: { title: "Text area", - icon: , + icon: , description: "Text Area field", child: {}, optionsSchema: { @@ -499,7 +604,7 @@ const simple = { }, number: { title: "Number", - icon: , + icon: , description: "IDs, order number, rating, quantity", child: {}, optionsSchema: { @@ -973,7 +1078,7 @@ const advanced = { }, richeditor: { title: "Rich/LaTeX editor", - icon: , + icon: , description: "Rich/LaTeX Editor Field", child: {}, optionsSchema: { diff --git a/src/forms/Form.less b/src/forms/Form.less index ca7e29a..0e67ef1 100644 --- a/src/forms/Form.less +++ b/src/forms/Form.less @@ -4,6 +4,7 @@ .__Form__ { height: 100%; fieldset { + min-width: 0; // prevents overflow of antd Steps component border: 0; } label { @@ -114,10 +115,6 @@ display: none; } - .ant-select-dropdown .ant-select-item-option-content { - white-space: break-spaces; - } - .formule-field-modal { .ant-modal-content .ant-modal-body { margin-top: 20px; @@ -157,6 +154,13 @@ .bounceShadow { animation: bounceShadow 5s forwards; } + + .ant-steps-item-icon { + border-radius: unset !important; + .ant-steps-icon { + font-weight: bold; + } + } } .tabItemError { diff --git a/src/forms/templates/ObjectFieldTemplate.jsx b/src/forms/templates/ObjectFieldTemplate.jsx index 6a16616..9eff26f 100644 --- a/src/forms/templates/ObjectFieldTemplate.jsx +++ b/src/forms/templates/ObjectFieldTemplate.jsx @@ -6,6 +6,7 @@ import { PlusCircleOutlined } from "@ant-design/icons"; import TabField from "./Tabs/TabField"; import PropTypes from "prop-types"; import FieldHeader from "./Field/FieldHeader"; +import StepsField from "./StepsField"; const ObjectFieldTemplate = ({ description, @@ -85,6 +86,14 @@ const ObjectFieldTemplate = ({ idSchema={idSchema} /> ); + if (uiSchema["ui:object"] == "stepsView") + return ( + + ); return (
diff --git a/src/forms/templates/StepsField.jsx b/src/forms/templates/StepsField.jsx new file mode 100644 index 0000000..0116309 --- /dev/null +++ b/src/forms/templates/StepsField.jsx @@ -0,0 +1,168 @@ +import { Button, Col, Grid, Progress, Row, Steps, Typography } from "antd"; +import { _filterTabs, isFieldContainsError } from "./utils"; +import { useContext, useEffect, useRef, useState } from "react"; +import CustomizationContext from "../../contexts/CustomizationContext"; + +const StepsField = ({ uiSchema, properties, idSchema }) => { + const options = uiSchema["ui:options"] || {}; + + const tabs = _filterTabs(options.tabs, options, properties); + + const { + hideButtons, + hideSteps, + hideNumbers, + stepsPlacement, + markAsCompleted, + } = options; + const isVertical = stepsPlacement === "vertical"; + + const { useBreakpoint } = Grid; + const screens = useBreakpoint(); + const customizationContext = useContext(CustomizationContext); + const [current, setCurrent] = useState(0); + const [anchor, setAnchor] = useState(""); + const [scroll, setScroll] = useState(false); + const [progressColor, setProgressColor] = useState(""); + const stepsRef = useRef(null); + + window.addEventListener("hashchange", () => { + setAnchor(window.location.hash.replace("#", "")); + }); + + useEffect(() => { + if (window.location.hash) { + setAnchor(window.location.hash.replace("#", "")); + } + }, []); + + useEffect(() => { + if (anchor) { + const items = anchor.split(customizationContext.separator); + items.forEach((item, index) => { + if (idSchema.$id.includes(item)) { + const tabName = items[index + 1]; + const activeIndex = tabs.findIndex((tab) => tab.name === tabName); + if (activeIndex > -1) { + setCurrent(activeIndex); + setScroll(true); + } + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [anchor]); + + useEffect(() => { + if (scroll) { + const elem = document.getElementById(anchor); + if (elem) { + setTimeout(() => { + elem.scrollIntoView({ behavior: "smooth" }); + }, 500); + } + } + }, [current, anchor, scroll]); + + useEffect(() => { + if (!hideSteps && !isVertical) { + // Horizontal scroll to the current step + stepsRef.current + .querySelector(".ant-steps-item-active") + ?.scrollIntoView({ behavior: "smooth" }); + } else if (tabs.some((tab) => isFieldContainsError(tab))) { + setProgressColor("red"); + } else { + setProgressColor(""); + } + }, [current, hideSteps, isVertical, tabs]); + + const items = tabs.map((tab, index) => ({ + key: tab.name, + title: screens.md && ( + + {tab.title || tab.content.props.schema.title || tab.name} + + ), + status: isFieldContainsError(tab) + ? "error" + : !markAsCompleted && index != current && "wait", + })); + + const updateCurrent = (value) => { + setCurrent(value); + setScroll(false); + }; + + const content = ( + + +
{tabs[current]?.content}
+ {(!hideButtons || hideSteps) && ( + + + {current > 0 && ( + + )} + + + {current < tabs.length - 1 && ( + + )} + + + )} + +
+ ); + + return ( + <> + {hideSteps ? ( + + ) : ( + + + + + {isVertical && {content}} + + )} + {(!isVertical || hideSteps) && content} + + ); +}; + +export default StepsField; diff --git a/src/forms/templates/Tabs/TabField.jsx b/src/forms/templates/Tabs/TabField.jsx index 5d5ec66..4e5ffbb 100644 --- a/src/forms/templates/Tabs/TabField.jsx +++ b/src/forms/templates/Tabs/TabField.jsx @@ -98,7 +98,7 @@ const TabField = ({ uiSchema, properties, idSchema }) => { if (scroll) { const elem = document.getElementById(anchor); if (elem) { - elem.scrollIntoView(true); + elem.scrollIntoView({ behavior: "smooth" }); } } }, [activeTabContent, anchor, scroll]);