diff --git a/lib/src/components/HalForm/HalForm.tsx b/lib/src/components/HalForm/HalForm.tsx new file mode 100644 index 0000000..fd1ddaf --- /dev/null +++ b/lib/src/components/HalForm/HalForm.tsx @@ -0,0 +1,49 @@ +import { DxcAlert, DxcFlex } from "@dxc-technology/halstack-react"; +import React, { ReactNode } from "react"; + +import useHalFormChildrenProps from "./useHalFormChildrenProps"; + +interface HalFormProps { + children: ReactNode; + apiEndpoint: string; + authHeaders?: Record; + onSubmit?: (formState: Record, onlyUpdatedFields: Record) => void; + selfManagedSave?: boolean; +} + +const HalForm: React.FC = ({ + children, + apiEndpoint, + authHeaders, + onSubmit, + selfManagedSave, +}) => { + const { processChildren, formState, onlyUpdatedFields, apiUpdateError } = useHalFormChildrenProps( + children, + apiEndpoint, + authHeaders, + selfManagedSave + ); + const handleSubmit = async (e: React.FormEvent) => { + /** + * For future set up of action buttons + */ + e.preventDefault(); + if (onSubmit) { + onSubmit(formState, onlyUpdatedFields); + } + }; + + return ( + + {apiUpdateError?.body?.messages?.map((err: any) => ( + + + + ))} +
{processChildren(children)}
+
+ ); +}; + +export default React.memo(HalForm); diff --git a/lib/src/components/HalForm/HalOptions.tsx b/lib/src/components/HalForm/HalOptions.tsx new file mode 100644 index 0000000..3d47e0e --- /dev/null +++ b/lib/src/components/HalForm/HalOptions.tsx @@ -0,0 +1,218 @@ +/* eslint-disable no-prototype-builtins */ + +// on GET, we have _options, on OPTIONS, we got the option detail directly on response +const getOptionFromResponse: (response: any) => { links: any[]; title: string; properties: any; required: string[] } = ( + response +) => (response?.hasOwnProperty('_options') ? response['_options'] : response); + +type MethodType = 'GET' | 'PATCH' | 'DELETE' | 'POST' | string; + +interface MethodInterface { + href: string; + mediaType: string; + method: MethodType; + schema?: any; + rel: string; + title: string; +} + +type PropertyType = +| string +| string[] +| number +| number[] +| boolean +| undefined +| null; + +interface OptionsProperty { + + /** + * + * Return true if the property is patchable + */ + canPatch: () => boolean; + visible: boolean; + minLength: number; + maxLength: number; + min: number; + max: number; + + type: string; + + /** + * + */ + isRequired: boolean; + + /** + * If the property is a complex list + */ + isOneOf: boolean; + + /** + * Return the list of values of the oneOf property + */ + getValuesOfOneOf: () => T[]; + + /** + * Return the label for a given value + * @param value - value of the property + * @returns - + */ + getLabelOfOneOf: (value: PropertyType) => string; +}; + +export interface OnOptionsReturn { + + /** + * Check if the method is allow + * @param method - method type + * @returns true if its allowed + */ + canMethod: (method: MethodType) => boolean; + + /** + * Check if the POST method is allow + * @returns true if its allowed + */ + canPost: () => boolean; + + /** + * Check if the DELETE method is allow + * @returns true if its allowed + */ + canDelete: () => boolean; + + /** + * Check if the PATCH method is allow + * @returns true if its allowed + */ + canPatch: () => boolean; + + /** + * Get the method detail of a rel + * @param rel - rel to check, most of the time it's the inquery + * @returns method detail + */ + getMethodByRel: (rel: string) => MethodInterface; + + /** + * Get the options for a property or a sub property of a complexlist + * @param property - property name + * @param subProperty - sub property name for complexlist only + * @returns property options + */ + getProperty: (property: string, subProperty?: string) => OptionsProperty; +} + +export const onOptions = (response: any, forCollection = false): OnOptionsReturn => { + const options = getOptionFromResponse(response); + + + + const getMethod = (method: MethodType): MethodInterface => options['links']?.find((item: any) => item.method === method); + const getMethodByRel = (rel: string): MethodInterface => options['links']?.find((item: any) => item.rel === rel); + const getMethodSchema = (method: MethodType): any => getMethod(method)?.schema; + const canMethod = (method: MethodType): boolean => !!getMethod(method); + const canPost = () => canMethod('POST'); + const canPatch = () => canMethod('PATCH'); + const canDelete = () => canMethod('DELETE'); + + const getProperty = (property: string, subProperty?: string): OptionsProperty => { + // subProperty in case of complex list + const targetProperty = subProperty ?? property; + + // return the property object depends if the property is a complex list or not + // base can be schema or options + const getOptionOrSchemaProperties = (base: any) => { + + /** + * For Normal/Root properties + */ + if (!subProperty) { + return base.properties; + } + + /** + * For ComplexList or SimpleList properties + */ + + if (!forCollection) { + return base.properties?.[property]?.items.items ?? {}; + } + + /** + * For Collection properties + */ + const baseProperties = base?.properties?._links?.properties?.item?.properties?.[property].properties; + return baseProperties?.oneOf?.[0] ?? baseProperties; + }; + + const propertiesOptions = getOptionOrSchemaProperties(options); + + // complexList attributes are in [property].items.items + const canPatchProperty = () => { + if (!canPatch()) { + return false; + } + + const schema = getMethodSchema('PATCH'); + // Check is the property can be patchable + return schema ? getOptionOrSchemaProperties(schema).hasOwnProperty(targetProperty) : false; + }; + + const isRequired = !!(options?.required?.indexOf(property) >= 0); + const isVisible = propertiesOptions.hasOwnProperty(targetProperty); + + // If it's not visible, that means this property doesn't exist, so we return an empty object + const propertyOption = isVisible ? propertiesOptions[targetProperty] : {}; + + const isOneOf: boolean = propertyOption.hasOwnProperty('oneOf') || propertyOption.enum?.length > 0; + const { minLength, maxLength, minimum, maximum, type } = propertyOption; + const getValuesOfOneOf = () => { + if (!isOneOf) { + return []; + } + + if (propertyOption?.['oneOf']) { + return propertyOption?.['oneOf'].map(({ enum: [value], title }: { enum: any[]; title: string }) => ({ + value, + label: title + })); + } + else { + return propertyOption?.['enum'].map((enumItem: any) => ({ + value: enumItem, + label: enumItem.toString() + })); + } + }; + + // Get the label of a oneOf, if the value doesn't exist, it will return undefined + const getLabelOfOneOf = (givenValue: any) => getValuesOfOneOf().find(({ value }: { value: any }) => value === givenValue)?.label; + + return { + canPatch: canPatchProperty, + visible: isVisible, + minLength, + maxLength, + min: minimum, + max: maximum, + type, + isRequired, + isOneOf, + getValuesOfOneOf, + getLabelOfOneOf + }; + }; + + return { + canMethod, + canPost, + canDelete, + canPatch, + getMethodByRel, + getProperty + }; +}; diff --git a/lib/src/components/HalForm/HalStackClientModule.d.ts b/lib/src/components/HalForm/HalStackClientModule.d.ts new file mode 100644 index 0000000..0879eae --- /dev/null +++ b/lib/src/components/HalForm/HalStackClientModule.d.ts @@ -0,0 +1,3 @@ +// temp +// types must come from halstack-client library +declare module "@dxc-technology/halstack-client"; \ No newline at end of file diff --git a/lib/src/components/HalForm/useHalFormChildrenProps.tsx b/lib/src/components/HalForm/useHalFormChildrenProps.tsx new file mode 100644 index 0000000..e3928bc --- /dev/null +++ b/lib/src/components/HalForm/useHalFormChildrenProps.tsx @@ -0,0 +1,177 @@ +import { ChangeEvent, Children, useState } from "react"; +import React, { useEffect } from "react"; + +import { HalApiCaller } from "@dxc-technology/halstack-client"; +import { onOptions } from "./HalOptions"; + +/** + * State management is not Production quality + * This is only for POC + * If this project gets approved this will be first item to be refactored + */ + +type InputProps = { + formState: Record; + onChange: (e: ChangeEvent) => void; + name: string; + type?: string; + value: string; + onBlur?: any; +}; + +type errorType = { + status?: string; + message?: string; + body?: { + _outcome?: any; + messages?: any; + message?: any; + }; +}; + +const useHalFormChildrenProps = ( + children: React.ReactNode, + apiEndpoint: string, + authHeaders: any, + selfManagedSave?: boolean +) => { + const [formFieldState, setFormFieldState] = useState>({}); + const [onlyUpdatedFields, setOnlyUpdatedFields] = useState>({}); + const [apiUpdateError, setAPIUpdateError] = useState({}); + const [apiOptions, setAPIOptions] = useState>({}); + + const setFormState = (newState: Record) => { + setFormFieldState((prevState: Record) => ({ ...prevState, ...newState })); + }; + + useEffect(() => { + const values: Record = { ...formFieldState, ...onlyUpdatedFields }; + HalApiCaller.get({ + url: apiEndpoint, + headers: { ...authHeaders }, + }).then((response: any) => { + extractFormValues(children, response); + setFormState(values); + }); + const extractFormValues = (children: React.ReactNode, response: any) => { + Children.forEach(children, (child) => { + if (React.isValidElement(child)) { + const { props } = child; + if (props.children) { + return extractFormValues(props.children, response); + } + if (props.name) { + values[props.name] = response.halResource.resourceRepresentation[props.name] ?? null; + } + } + }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const options: Record = { ...apiOptions }; + HalApiCaller.options({ + url: apiEndpoint, + headers: { ...authHeaders }, + }).then((response: any) => { + const processedOptions = onOptions({ _options: response.halResource.resourceRepresentation }); + extractOptions(children, processedOptions); + setAPIOptions(options); + }); + const extractOptions = (children: React.ReactNode, processedOptions: any) => { + Children.forEach(children, (child) => { + if (React.isValidElement(child)) { + const { props } = child; + if (props.children) { + return extractOptions(props.children, processedOptions); + } + if (props.name) { + options[props.name] = processedOptions.getProperty(props.name) ?? null; + } + } + }); + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const updateHandler = async (payload: any) => { + if (selfManagedSave && payload && Object.keys(payload).length) { + try { + await HalApiCaller.patch({ + url: apiEndpoint, + headers: { ...authHeaders }, + body: { ...payload }, + }); + setOnlyUpdatedFields({}); + setAPIUpdateError({}); + } catch (error: any) { + setAPIUpdateError({body: {messages: error?.response?.data?.messages? [...error.response.data.messages] : [error?.response?.data?.message]}}); + } + } + }; + + const obtainRenderProps = (child: any) => { + if (!child?.props?.name) { + return {}; + } + const isOneOf: boolean = apiOptions?.[child.props.name]?.isOneOf || false; + const properties: any = { + key: child.props.name, + value: formFieldState?.[child.props.name] || "", + min: apiOptions?.[child.props.name]?.min, + max: apiOptions?.[child.props.name]?.max, + disabled: !apiOptions?.[child.props.name]?.canPatch?.(), + optional: !apiOptions?.[child.props.name]?.isRequired, + minLength: apiOptions?.[child.props.name]?.minLength, + maxLength: apiOptions?.[child.props.name]?.maxLength, + onChange: (e: any) => { + const { name } = child.props; + let { value } = e; + if (!value) { + // this is due to inconsistent change event param + value = e; + } + setFormState({ [name]: value }); + setOnlyUpdatedFields({ ...onlyUpdatedFields, [name]: value }); + if (isOneOf || e.date){ + updateHandler({ [name]: value }); + } + }, + onBlur: async (e: any) => { + if (!isOneOf){ + await updateHandler(onlyUpdatedFields); + } + }, + }; + if (isOneOf) { + properties.options = apiOptions?.[child.props.name]?.getValuesOfOneOf(); + } + + return properties; + }; + const processChildren = (children: React.ReactNode) => { + return Children.map(children, (child) => { + if (React.isValidElement(child)) { + if (child.props.children) { + const processedGrandchildren: any = processChildren(child.props.children); + return React.cloneElement(child, {}, processedGrandchildren); + } + const inputProps: InputProps = { + ...obtainRenderProps(child), + }; + return React.cloneElement(child, inputProps); + } + return child; + }); + }; + return { + formState: formFieldState, + onlyUpdatedFields, + processChildren, + apiUpdateError, + }; +}; + +export default useHalFormChildrenProps; diff --git a/lib/src/index.ts b/lib/src/index.ts index b2b886a..ecd12b4 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -1,5 +1,6 @@ -import useHalResource from './hooks/useHalResource'; -import HalTable from './components/HalTable'; import HalAutocomplete from './components/HalAutocomplete'; +import HalForm from './components/HalForm/HalForm'; +import HalTable from './components/HalTable'; +import useHalResource from './hooks/useHalResource'; -export { HalTable, HalAutocomplete, useHalResource }; +export { HalTable, HalAutocomplete, useHalResource, HalForm }; diff --git a/package-lock.json b/package-lock.json index e3b7162..1576cac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "@halstack-react-hal/source", "version": "0.0.0", "dependencies": { - "@dxc-technology/halstack-client": "^1.5.0", + "@dxc-technology/halstack-client": "^1.5.1", "@nx/next": "16.10.0", "@swc/helpers": "~0.5.2", "next": "^14.2.11", diff --git a/package.json b/package.json index 574cd27..f45eaab 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ }, "private": true, "dependencies": { - "@dxc-technology/halstack-client": "^1.5.0", + "@dxc-technology/halstack-client": "^1.5.1", "@nx/next": "16.10.0", "@swc/helpers": "~0.5.2", "next": "^14.2.11",