From 1a817d2de386088a7a04f1e4f74dafea3c6a5c80 Mon Sep 17 00:00:00 2001 From: Miguel Garcia Garcia Date: Wed, 25 Sep 2024 18:38:32 +0200 Subject: [PATCH] feat(form): add (auto)save and local schema repo --- README.md | 33 ++- formule-demo/README.md | 2 +- formule-demo/src/App.tsx | 356 +++++++++++++++++++++++-- src/admin/components/SchemaPreview.jsx | 41 ++- src/admin/components/SchemaWizard.jsx | 10 +- src/admin/utils/index.js | 10 - src/exposed.tsx | 100 ++++++- src/forms/Form.jsx | 1 - src/index.ts | 5 + src/store/configureStore.js | 23 +- src/store/schemaWizard.ts | 30 +-- src/utils/index.js | 2 + 12 files changed, 518 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index 98a25bb..2e39fd8 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Formule consists of the following main components: It also exports the following functions: -- **`initFormuleSchema`**: Inits the JSONSchema, **_needs_** to be run on startup. +- **`initFormuleSchema`**: Inits or resets the JSONSchema. You can also load an existing schema by passing it as an argument. - **`getFormuleState`**: Formule has its own internal redux state. You can retrieve it at any moment if you so require for more advanced use cases. If you want to continuosly synchronize the Formule state in your app, you can pass a callback function to FormuleContext instead (see below), which will be called every time the form state changes. And the following utilities: @@ -52,6 +52,16 @@ And the following utilities: - **`CodeViewer`**: Useful if you want to visualize the JSON schemas that are being generated (as you can see in the demo). - **`CodeDiffViewer`**: Useful if you want to compare two different JSON schemas, for example to see the changes since the last save. +As well as the following utility functions to handle saving and loading schemas from local storage if you need and for unsaved change detection: + +- `getAllFromLocalStorage` +- `saveToLocalStorage` +- `deleteFromLocalStorage` +- `loadFromLocalStorage` +- `isUnsaved` + +Have a look at `src/index.ts` to see all exported components and functions. You can also have a look at `formule-demo` to see how they are used there. + ### Field types Formule includes a variety of predefined field types, grouped in three categories: @@ -83,20 +93,19 @@ yarn add react-formule ```jsx import { - FormuleContext, - SelectOrEdit, - SchemaPreview, - FormPreview, - initFormuleSchema + FormuleContext, + SelectOrEdit, + SchemaPreview, + FormPreview, } from "react-formule"; -const useEffect(() => initFormuleSchema(), []); - - +return ( + - + +); ``` ### Customizing and adding new field types @@ -168,8 +177,8 @@ const handleFormuleStateChange = (newState) => { Alternatively, you can pull the current state on demand by calling `getFormuleState` at any moment. > [!TIP] -> For more examples, feel free to browse around the [CERN Analysis Preservation](https://github.com/cernanalysispreservation/analysispreservation.cern.ch) repository, where we use all the features mentioned above. +> For more examples, feel free to browse around formule-demo and the [CERN Analysis Preservation](https://github.com/cernanalysispreservation/analysispreservation.cern.ch) repository, where we use all the features mentioned above. ## :space_invader: Local demo & how to contribute -Apart from trying the online [demo](https://cern-sis.github.io/react-formule/) you can clone the repo and run `formule-demo` to play around. Follow the instructions in its [README](./formule-demo/README.md): it will explain how to install `react-formule` as a local dependency so that you can modify Formule and test the changes live in your host app, which will be ideal if you want to troubleshoot or contribute to the project. +Apart from trying the online [demo](https://cern-sis.github.io/react-formule/) you can clone the repo and run `formule-demo` to play around. Follow the instructions in its [README](./formule-demo/README.md): it will explain how to install `react-formule` as a local dependency so that you can modify Formule and test the changes live in your host app, which will be ideal if you want to troubleshoot or contribute to the project. Your contributions are welcome! :rocket: diff --git a/formule-demo/README.md b/formule-demo/README.md index 9abf99b..a207244 100644 --- a/formule-demo/README.md +++ b/formule-demo/README.md @@ -6,7 +6,7 @@ This is a small application that serves as a playground to test react-formule. ### The easy way -Simply run `yarn install` and `yarn dev` in react-formule and visit `localhost:3030`. You will see any changes in react-formule immediately in the demo app. +Simply run `yarn install` in react-formule, `yarn install` and `yarn dev` in formule-demo and visit `localhost:3030`. You will see any changes in react-formule immediately in the demo app. **Note:** If you look at `formule-demo/vite.config.local.ts` you will see an alias for `react-formule`. What this does is essentially equivalent to using `yarn link` with `./src/index.ts` as entry point. diff --git a/formule-demo/src/App.tsx b/formule-demo/src/App.tsx index 29d7c8d..7def0f0 100644 --- a/formule-demo/src/App.tsx +++ b/formule-demo/src/App.tsx @@ -1,5 +1,32 @@ -import { FileTextOutlined } from "@ant-design/icons"; -import { Col, FloatButton, Layout, Modal, Row, Space, Typography } from "antd"; +import { + FileTextOutlined, + FolderOpenOutlined, + DeleteOutlined, + SaveOutlined, + FileAddOutlined, + CheckOutlined, + InfoCircleOutlined, + DownloadOutlined, + UploadOutlined, + RollbackOutlined, + ToolOutlined, +} from "@ant-design/icons"; +import { + Button, + Col, + Drawer, + FloatButton, + Layout, + List, + message, + Modal, + Popconfirm, + Row, + Space, + Tooltip, + Typography, + Upload, +} from "antd"; import { useEffect, useState } from "react"; import { CodeViewer, @@ -8,7 +35,12 @@ import { SchemaPreview, SchemaWizardState, SelectOrEdit, + deleteFromLocalStorage, + getAllFromLocalStorage, initFormuleSchema, + isUnsaved, + saveToLocalStorage, + loadFromLocalStorage, } from "react-formule"; import { theme } from "./theme"; @@ -17,23 +49,70 @@ import "./style.css"; const { Content, Footer } = Layout; function App() { + const [formuleState, setFormuleState] = useState(); + const [localSchemas, setLocalSchemas] = useState(getAllFromLocalStorage()); + const [viewerOpen, setViewerOpen] = useState(false); + const [listOpen, setListOpen] = useState(false); + const [helpOpen, setHelpOpen] = useState(false); + const [justSaved, setJustSaved] = useState(false); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [openFloatButtons, setOpenFloatButtons] = useState(true); + useEffect(() => { - initFormuleSchema(); - }, []); + setHasUnsavedChanges(isUnsaved()); + }, [formuleState]); - const [formuleState, setFormuleState] = useState(); - const [modalOpen, setModalOpen] = useState(false); + useEffect(() => { + const handleKeyDowEvent = (e: KeyboardEvent) => { + if (e.ctrlKey || (e.metaKey && e.key === "s")) { + e.preventDefault(); + saveLocalSchema(); + } + }; + window.addEventListener("keydown", handleKeyDowEvent); + return () => { + window.removeEventListener("keydown", handleKeyDowEvent); + }; + }, []); const handleFormuleStateChange = (newState: SchemaWizardState) => { setFormuleState(newState); }; + const handleDownload = (id: string, schema: object) => { + const a = document.createElement("a"); + const file = new Blob([JSON.stringify(schema, null, 4)], { + type: "text/json", + }); + a.href = URL.createObjectURL(file); + a.download = `formuleForm_${id}.json`; + a.click(); + }; + + const deleteLocalSchema = (id: string) => { + deleteFromLocalStorage(id).then((list) => setLocalSchemas(list)); + if (id === formuleState?.id) { + initFormuleSchema(); + } + }; + + const saveLocalSchema = () => { + saveToLocalStorage().then((list) => { + setHasUnsavedChanges(isUnsaved()); + setLocalSchemas(list); + setJustSaved(true); + setTimeout(() => { + setJustSaved(false); + }, 500); + }); + }; + return ( <> setModalOpen(false)} + open={viewerOpen} + onCancel={() => setViewerOpen(false)} width={1000} footer={null} > @@ -85,6 +164,159 @@ function App() { + setHelpOpen(false)} + width={500} + footer={null} + > + + The state of the current form is saved automatically on + change. However, this should simply be taken as a security measure to + prevent your from accidentally losing data, as changes are not + autosaved into your local schemas repository. + + + If you want to persist the local changes to your schema in your local + repository (local storage) you can{" "} + click on the save button or press [ctrl+s] (your saved + schema won't be updated with the latest changes until you do this). + You can switch between schemas and load them later on. You can tell + when you have unsaved changes by looking at the blue dot{" "} + on the save button. You can also revert unsaved changes by clicking on + the corresponding button. + + + You are encouraged to download any important data as a + JSON file for more safety in case your browser's storage gets wiped. + You can do so from the "Saved Schemas" drawer. + + + Additionally, you can also upload schemas from a JSON + file in the same drawer. + + + setListOpen(false)} + width={600} + footer={ + + The schemas displayed here are stored in your browser's local + storage. They are preserved between sessions unless you wipe your + browser's storage. Please download your schemas if you don't + want to risk losing them. + + } + placement="left" + extra={ + + + { + if (file.type != "application/json") { + message.error("The file format should be json"); + } + const reader = new FileReader(); + reader.onload = (event) => { + const newSchema = JSON.parse( + event?.target?.result as string, + ); + const { schema, uiSchema } = newSchema; + if (schema && uiSchema) { + initFormuleSchema(newSchema); + saveLocalSchema(); + message.success("Uploaded and loaded successfully"); + } else { + message.error( + "Your json should include a schema and a uiSchema key", + ); + } + }; + reader.readAsText(file); + // Prevent POST upload + return false; + }} + > + + + )} + + } + > +
+ + Make sure you have saved your progress in the current schema + before switching to another one! + +
+ ( + + Delete schema permanently? + + } + okType="danger" + placement="right" + onConfirm={() => deleteLocalSchema(item.id)} + > + , + ]} + > + +
+ + {item.value.id} + +
+
+ )} + /> +
- + - setModalOpen(true)} - shape="square" - description={ -
- View generated schemas -
- } - style={{ width: "200px" }} - type="primary" - /> + + setOpenFloatButtons(!openFloatButtons)} + icon={} + type="primary" + badge={ + !openFloatButtons && hasUnsavedChanges + ? { + dot: true, + color: "firebrick", + } + : undefined + } + > + {hasUnsavedChanges && + localSchemas.some((s) => s.id === formuleState?.id) && ( + + Revert and discard the current changes? + + } + okType="danger" + placement="right" + onConfirm={() => { + loadFromLocalStorage(formuleState?.id ?? ""); + }} + > + } + tooltip="Revert changes (reload save)" + /> + + )} + + You will lose the current changes + + } + okType="danger" + placement="right" + onConfirm={() => initFormuleSchema()} + > + } tooltip="New schema" /> + + saveLocalSchema()} + icon={justSaved ? : } + style={ + justSaved + ? { backgroundColor: theme.token.colorPrimary } + : undefined + } + badge={ + hasUnsavedChanges + ? { + dot: true, + color: theme.token.colorPrimary, + } + : undefined + } + tooltip="Save schema to local repository [ctrl+s]" + /> + setListOpen(true)} + icon={} + badge={{ + count: localSchemas.length, + style: { borderRadius: theme.token.borderRadius }, + color: theme.token.colorPrimary, + size: "small", + }} + tooltip="Load schema from local repository" + /> + setViewerOpen(true)} + icon={} + tooltip="View generated schemas" + /> + setHelpOpen(true)} + icon={} + tooltip="Information" + /> + + ); } diff --git a/src/admin/components/SchemaPreview.jsx b/src/admin/components/SchemaPreview.jsx index 929652f..dd02fe8 100644 --- a/src/admin/components/SchemaPreview.jsx +++ b/src/admin/components/SchemaPreview.jsx @@ -5,11 +5,11 @@ import { SettingOutlined } from "@ant-design/icons"; import { useDispatch, useSelector } from "react-redux"; import { selectProperty } from "../../store/schemaWizard"; -const SchemaPreview = () => { +const SchemaPreview = ({ hideSchemaKey }) => { + const schema = useSelector((state) => state.schemaWizard.current.schema); + const id = useSelector((state) => state.schemaWizard.id); - const schema = useSelector((state) => state.schemaWizard.current.schema) - - const dispatch = useDispatch() + const dispatch = useDispatch(); return (
@@ -17,11 +17,17 @@ const SchemaPreview = () => { Schema tree + {!hideSchemaKey && ( + {id} + )} { align="middle" style={{ padding: "0 10px" }} > - + {(schema && schema.title) || "root"} -