diff --git a/.eslintrc.js b/.eslintrc.js index fce21f396..b7df0fcfb 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -19,8 +19,10 @@ module.exports = { "import" ], "rules": { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": ["error", { "ignoreRestSiblings": true }], + "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": ["error"], "import/no-default-export": "error" } } diff --git a/package.json b/package.json index bb84369bd..f4e0faf29 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,8 @@ "typescript": "^4.7.4" }, "dependencies": { + "crypto": "npm:crypto-browserify@^3.12.0", + "stream": "npm:stream-browserify@^3.0.0", "@sentry/react": "^7.48.0", "classnames": "^2.3.1" } diff --git a/src/components/forms/Forms.stories.tsx b/src/components/forms/Forms.stories.tsx new file mode 100644 index 000000000..b0e01f7e9 --- /dev/null +++ b/src/components/forms/Forms.stories.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import styled from 'styled-components'; +import {Controlled as Forms, Uncontrolled} from '.'; +import { fetchSuccess } from "@openstax/ts-utils/fetch"; + +export const BasicControlledForm = () => { + const data = { + name: 'Test McTesterson', + } + + type Data = typeof data; + const state = fetchSuccess(data); + const [submitted, setSubmitted] = React.useState>({}); + + return ) => setSubmitted(data)}> + + +
{JSON.stringify(submitted, null, 2)}
+
+}; + +const FormRow = styled(Forms.FormSection)` + display: flex; + flex-direction: row; + + > *:not(:first-child) { + margin-top: 0; + margin-left: 5px; + } +`; +export const FormRepeatableElements = () => { + const data = { + name: 'Test McTesterson', + interests: [{interest: 'sleeping'}], + } + + type Data = typeof data; + const state = fetchSuccess(data); + const [submitted, setSubmitted] = React.useState>({}); + const [sortable, setSortable] = React.useState(false); + + return ) => setSubmitted(data)}> + + setSortable(!!checked)} /> + + + + {sortable ? : null} + + remove interest + + + add interest + + +
{JSON.stringify(submitted, null, 2)}
+
+}; + +export const BasicDataReferences = () => { + const data = { + name: 'Test McTesterson', + } + + type Data = typeof data; + const state = fetchSuccess(data); + + return + + + + {data =>
{JSON.stringify(data, null, 2)}
} +
+ + {data =>
{JSON.stringify(data, null, 2)}
} +
+
+}; + +export const DataReferencesInNamespaces = () => { + const data = { + name: 'Test McTesterson', + interests: [{interest: 'sleeping'}], + address: {line1: '1 main st'} + } + + type Data = typeof data; + const state = fetchSuccess(data); + + return + +

in a loop or namespace Forms.GetFormData and Forms.GetFormValue will return only values within the namespace

+ {formData => <> + + + + {data =>
{JSON.stringify(data, null, 2)}
} +
+ +

if you need the parent context data in a subcomponent, you can wrap the entire section

+
{JSON.stringify(formData, null, 2)}
+
+ + + + + remove interest + + + {data =>
{JSON.stringify(data, null, 2)}
} +
+
+ add interest +
+ + + {data =>
{JSON.stringify(data, null, 2)}
} +
+ }
+
+}; diff --git a/src/components/forms/controlled/hooks.ts b/src/components/forms/controlled/hooks.ts new file mode 100644 index 000000000..a956e43d5 --- /dev/null +++ b/src/components/forms/controlled/hooks.ts @@ -0,0 +1,138 @@ +import React from 'react'; +import { FetchState } from "@openstax/ts-utils/fetch.js"; +import { merge } from "@openstax/ts-utils"; + +const randomId = () => window.crypto.getRandomValues(new Uint32Array(1))[0].toString(16) + +export type AbstractFormData = Record; + +type FormStateHelpers = { + data: Partial; + submit: () => void; + namespace: string; + state: FetchState; + setInput: { + fields: React.Dispatch>>; + field: (fieldName: F) => (value: T[F]) => void; + merge: (input: Partial) => void; + }; +}; + +export const FormStateContext = React.createContext<() => FormStateHelpers>( + () => { throw new Error('form helpers not provided');} +); + +export const useFormHelpers = () => React.useContext(FormStateContext)(); + +const makeSetInput = (setState: React.Dispatch>) => { + const mergeFields = (input: Partial) => setState(previous => merge(previous, input) as T); + + const setInputField = (fieldName: F) => (value: T[F]) => { + setState(previous => ({...previous, [fieldName]: value})); + }; + + return {field: setInputField, fields: setState, merge: mergeFields}; +}; + +export const useFormState = ( + state: FormStateHelpers['state'], + defaultValue?: Partial, + onSubmit?: (data: Partial) => void +): FormStateHelpers => { + const [inputFields, setInputFields] = React.useState>( + 'data' in state && state.data ? state.data : (defaultValue || {}) + ); + const inputFieldsRef = React.useRef>(inputFields); + inputFieldsRef.current = inputFields; + const submitHandler = React.useCallback(() => { + onSubmit?.(inputFieldsRef.current); + }, [onSubmit]); + return {namespace: 'form', submit: submitHandler, data: inputFields, state, setInput: makeSetInput(setInputFields)}; +}; + +export const useFormNameSpace = (field: string): FormStateHelpers => { + const parentState = useFormHelpers(); + + const setInputFields: React.Dispatch> = (input) => + parentState.setInput.fields(previous => ({ + ...previous, + [field]: input instanceof Function ? input(previous[field] || {}) : input + })) + ; + + return { + namespace: parentState.namespace + '.' + field, + submit: parentState.submit, + data: parentState.data[field] || {}, + state: parentState.state, + setInput: makeSetInput(setInputFields) + }; +}; + +export const FormListContext = React.createContext<() => ListStateHelpers>( + () => { throw new Error('form list helpers not provided');} +); + +export const useFormListHelpers = () => React.useContext(FormListContext)(); + +type ListStateHelpers = { + addRecord: (record?: AbstractFormData) => void; + removeRecord: (id: string) => void; + data: Array; + setData: React.Dispatch>; + makeRecordHelpers: (record: AbstractFormData & {id: string}) => FormStateHelpers; +}; + +export type FormListConfig = { + name: string; +}; +export const useFormList = ({name}: FormListConfig): ListStateHelpers => { + const parentState = useFormHelpers(); + + const setData: React.Dispatch> = React.useCallback((input) => + parentState.setInput.fields(previous => ({ + ...previous, + [name]: input instanceof Function ? input(previous[name] || []) : input + })) + , [name, parentState.setInput]); + + const makeRecordHelpers = (data: ListStateHelpers['data'][number]) => ({ + data, + state: parentState.state, + submit: parentState.submit, + namespace: parentState.namespace + '.' + data.id, + setInput: makeSetInput((input) => + setData(previous => previous.map( + (record: AbstractFormData) => record.id === data.id + ? input instanceof Function ? input(record) : input + : record + )) + ) + }); + + const value = React.useMemo(() => parentState.data[name] || [], [name, parentState.data]); + const hasIds = React.useMemo(() => value.every((record: any) => !!record.id), [value]); + + React.useEffect(() => { + if (!hasIds) { + parentState.setInput.fields(previous => ({ + ...previous, + [name]: value.map((record: any) => record.id === undefined ? {...record, id: randomId()} : record) + })); + } + }, [value, hasIds, name, parentState.setInput]); + + return { + addRecord: (record) => parentState.setInput.fields(previous => ({ + ...previous, + [name]: [...(previous[name] || []), {id: randomId(), ...record}] + })), + removeRecord: (id) => parentState.setInput.fields(previous => ({ + ...previous, + [name]: (previous[name] || []).filter((record: AbstractFormData) => record.id !== id) + })), + data: hasIds ? value : [], + setData, + makeRecordHelpers, + }; +}; diff --git a/src/components/forms/controlled/index.tsx b/src/components/forms/controlled/index.tsx new file mode 100644 index 000000000..c7ce44633 --- /dev/null +++ b/src/components/forms/controlled/index.tsx @@ -0,0 +1,180 @@ +import React from 'react'; +import { FetchState } from "@openstax/ts-utils/fetch.js"; +import * as Uncontrolled from '../uncontrolled'; +import { + useFormHelpers, + useFormState, + FormStateContext, + AbstractFormData, + useFormNameSpace, + FormListContext, + useFormListHelpers, + useFormList, + FormListConfig +} from "./hooks"; + +export * from './inputs'; +export * from './hooks'; +export {Submit, Cancel, FormSection} from '../uncontrolled'; + +/* + * form element + */ +type FormProps = Omit, 'onSubmit'> & { + state: FetchState; + defaultData?: AbstractFormData; + onSubmit?: (data: AbstractFormData) => void; +}; +export const Form = ({children, state, onSubmit, defaultData, ...props}: FormProps) => { + const formHelpers = useFormState(state, defaultData, onSubmit); + + return formHelpers}> + { + e.preventDefault(); + formHelpers.submit(); + }}> + {children} + + ; +}; + +export const Messages = (props: Omit, 'state'>) => { + const {state} = useFormHelpers(); + return ; +}; + +export const Buttons = (props: Omit, 'state'>) => { + const {state} = useFormHelpers(); + return ; +}; + +type GetFormValueProps = { + name: string; + children: (value: AbstractFormData[string]) => JSX.Element | null; +}; +export const GetFormValue = (props: GetFormValueProps) => { + const {data} = useFormHelpers(); + return props.children(data[props.name]); +}; + +type GetFormDataProps = { + children: (value: AbstractFormData) => JSX.Element | null; +}; +export const GetFormData = (props: GetFormDataProps) => { + const {data} = useFormHelpers(); + return props.children(data); +}; + +export const NameSpace = (props: React.PropsWithChildren<{name: string}>) => { + const formHelpers = useFormNameSpace(props.name); + + return formHelpers}> + {props.children} + ; +}; + +export const List = ({children, ...props}: React.PropsWithChildren) => { + const listHelpers = useFormList(props); + + return listHelpers}> + {children} + ; +}; + +const SortableContext = React.createContext<() => React.MutableRefObject>( + () => {throw new Error('context not provided');} +); + +export const ListItems = (props: {children: React.ReactNode}) => { + const listState = useFormListHelpers(); + const sortableEnabledRef = React.useRef(); + const draggingElementRef = React.useRef(); + + const dragOver = (record: {id: string}) => (e: React.DragEvent) => { + if (!draggingElementRef.current) { + return; + } + e.preventDefault(); + const current = listState.data.findIndex(r => r.id === draggingElementRef.current); + const target = listState.data.findIndex(r => r.id === record.id); + + if (current !== target) { + const copy = [...listState.data]; + copy.splice(target, 0, copy.splice(current, 1)[0]); + listState.setData(copy); + } + }; + + const dragEnd = (e: React.DragEvent) => { + e.preventDefault(); + sortableEnabledRef.current = false; + }; + + const dragStart = (record: {id: string}) => (e: React.DragEvent) => { + if (!sortableEnabledRef.current) { + e.preventDefault(); + return; + } + e.dataTransfer.effectAllowed = "move"; + draggingElementRef.current = record.id; + }; + + return + {listState.data.map(record => + sortableEnabledRef}> + listState.makeRecordHelpers(record)}> + + {props.children} + + + + )} + ; +}; + +type ListRecordSortableHandleProps = React.ComponentPropsWithoutRef<'div'>; +export const ListRecordSortableHandle = (props: ListRecordSortableHandleProps) => { + const sortableEnabledRef = React.useContext(SortableContext)(); + + return
sortableEnabledRef.current = true} + style={{ + cursor: 'move', + backgroundImage: 'radial-gradient(circle at 1px 1px, #aaa 1px, transparent 0), ' + + 'radial-gradient(circle at 4px 4px, #aaa 1px, transparent 0)', + backgroundSize: '5px 6px', + height: '11px', + width: '11px', + ...props.style + }} + />; +}; + +type ListRecordRemoveButtonProps = React.ComponentPropsWithoutRef<'button'>; +export const ListRecordRemoveButton = (props: ListRecordRemoveButtonProps) => { + const formHelpers = useFormHelpers(); + const listHelpers = useFormListHelpers(); + + return