diff --git a/striker-ui/components/CrudList.tsx b/striker-ui/components/CrudList.tsx new file mode 100644 index 00000000..a93da6c7 --- /dev/null +++ b/striker-ui/components/CrudList.tsx @@ -0,0 +1,200 @@ +import { FC, useMemo, useRef, useState } from 'react'; + +import api from '../lib/api'; +import { DialogWithHeader } from './Dialog'; +import handleAPIError from '../lib/handleAPIError'; +import List from './List'; +import useActiveFetch from '../hooks/useActiveFetch'; +import useChecklist from '../hooks/useChecklist'; +import useConfirmDialog from '../hooks/useConfirmDialog'; +import useFetch from '../hooks/useFetch'; + +const reduceHeader = ( + header: R | ((...args: A) => R), + ...args: A +): R => (typeof header === 'function' ? header(...args) : header); + +const CrudList = < + Overview, + Detail, + OverviewList extends Record = Record, +>( + ...[props]: Parameters>> +): ReturnType>> => { + const { + addHeader: rAddHeader, + editHeader: rEditHeader, + entriesUrl, + getAddLoading, + getDeleteErrorMessage, + getDeleteHeader, + getDeleteSuccessMessage, + getEditLoading = (previous?: boolean) => previous, + listEmpty, + listProps, + refreshInterval = 5000, + renderAddForm, + renderDeleteItem, + renderEditForm, + renderListItem, + } = props; + + const addDialogRef = useRef(null); + const editDialogRef = useRef(null); + + const { + confirmDialog, + finishConfirm, + setConfirmDialogLoading, + setConfirmDialogOpen, + setConfirmDialogProps, + } = useConfirmDialog(); + + const [edit, setEdit] = useState(false); + const [entry, setEntry] = useState(); + const [entries, setEntries] = useState(); + + const { loading: loadingEntriesPeriodic } = useFetch( + entriesUrl, + { + onSuccess: (data) => setEntries(data), + refreshInterval, + }, + ); + + const { fetch: getEntries, loading: loadingEntriesActive } = + useActiveFetch({ + onData: (data) => setEntries(data), + url: entriesUrl, + }); + + const { fetch: getEntry, loading: loadingEntry } = useActiveFetch({ + onData: (data) => setEntry(data), + url: entriesUrl, + }); + + const addHeader = useMemo( + () => reduceHeader(rAddHeader), + [rAddHeader], + ); + + const editHeader = useMemo( + () => reduceHeader(rEditHeader, entry), + [entry, rEditHeader], + ); + + const formTools = useMemo( + () => ({ + confirm: { + finish: finishConfirm, + loading: setConfirmDialogLoading, + open: setConfirmDialogOpen, + prepare: setConfirmDialogProps, + }, + }), + [ + finishConfirm, + setConfirmDialogLoading, + setConfirmDialogOpen, + setConfirmDialogProps, + ], + ); + + const loadingEntries = useMemo( + () => loadingEntriesPeriodic || loadingEntriesActive, + [loadingEntriesActive, loadingEntriesPeriodic], + ); + + const { + buildDeleteDialogProps, + checks, + getCheck, + hasAllChecks, + hasChecks, + multipleItems, + resetChecks, + setAllChecks, + setCheck, + } = useChecklist({ list: entries }); + + return ( + <> + + allowCheckAll={multipleItems} + allowEdit + allowItemButton={edit} + disableDelete={!hasChecks} + edit={edit} + getListCheckboxProps={() => ({ + checked: hasAllChecks, + onChange: (event, checked) => setAllChecks(checked), + })} + getListItemCheckboxProps={(key) => ({ + checked: getCheck(key), + onChange: (event, checked) => setCheck(key, checked), + })} + header + listEmpty={listEmpty} + listItems={entries} + loading={loadingEntries} + onAdd={() => addDialogRef?.current?.setOpen(true)} + onDelete={() => { + setConfirmDialogProps( + buildDeleteDialogProps({ + onProceedAppend: () => { + setConfirmDialogLoading(true); + + Promise.all( + checks.map((key) => api.delete(`${entriesUrl}/${key}`)), + ) + .then(() => { + finishConfirm('Success', getDeleteSuccessMessage()); + + getEntries(); + }) + .catch((error) => { + const emsg = handleAPIError(error); + + finishConfirm('Error', getDeleteErrorMessage(emsg)); + }); + + resetChecks(); + }, + getConfirmDialogTitle: getDeleteHeader, + renderEntry: (...args) => renderDeleteItem(entries, ...args), + }), + ); + + setConfirmDialogOpen(true); + }} + onEdit={() => setEdit((previous) => !previous)} + onItemClick={(value, key) => { + editDialogRef?.current?.setOpen(true); + + getEntry(`/${key}`); + }} + renderListItem={renderListItem} + {...listProps} + /> + + {renderAddForm(formTools)} + + + {renderEditForm(formTools, entry)} + + {confirmDialog} + + ); +}; + +export default CrudList; diff --git a/striker-ui/types/CrudList.d.ts b/striker-ui/types/CrudList.d.ts new file mode 100644 index 00000000..beca9681 --- /dev/null +++ b/striker-ui/types/CrudList.d.ts @@ -0,0 +1,40 @@ +type CrudListFormTools = { + confirm: { + finish: (header: React.ReactNode, message: Message) => void; + loading: (value: boolean) => void; + open: (value: boolean) => void; + prepare: (value: React.SetStateAction) => void; + }; +}; + +type CrudListOptionalProps = { + getAddLoading?: (previous?: boolean) => boolean; + getEditLoading?: (previous?: boolean) => boolean; + listProps?: Partial>; + refreshInterval?: number; +}; + +type CrudListProps< + Overview, + Detail, + OverviewList extends Record = Record, +> = Pick, 'listEmpty' | 'renderListItem'> & + CrudListOptionalProps & { + addHeader: React.ReactNode | (() => React.ReactNode); + editHeader: + | React.ReactNode + | ((detail: Detail | undefined) => React.ReactNode); + entriesUrl: string; + getDeleteErrorMessage: (previous: Message) => Message; + getDeleteHeader: BuildDeleteDialogPropsArgs['getConfirmDialogTitle']; + getDeleteSuccessMessage: () => Message; + renderAddForm: (tools: CrudListFormTools) => React.ReactNode; + renderDeleteItem: ( + entries: OverviewList | undefined, + ...args: Parameters + ) => ReturnType; + renderEditForm: ( + tools: CrudListFormTools, + detail: Detail | undefined, + ) => React.ReactNode; + };