[FEAT] live edit, remove is operational with confirm pop-in.

need to upgrade the code to be reusable
This commit is contained in:
Edouard DUPIN 2024-09-03 00:23:49 +02:00
parent de2a5c2413
commit c6a223d444
8 changed files with 463 additions and 203 deletions

View File

@ -1,18 +1,21 @@
import { ReactNode } from 'react';
import { ReactElement, ReactNode } from 'react';
import { Button, Flex, IconButton, Text, Tooltip } from '@chakra-ui/react';
import {
FormControl,
FormControlProps,
FormErrorMessage,
FormHelperText,
FormLabel,
} from '@chakra-ui/react';
import { MdLabelImportant } from 'react-icons/md';
MdErrorOutline,
MdHelpOutline,
MdLabelImportant,
MdRefresh,
} from 'react-icons/md';
export type FormGroupProps = FormControlProps & {
export type FormGroupProps = {
error?: ReactNode;
help?: ReactNode;
label?: ReactNode;
isModify?: boolean;
onRestore?: () => void;
isRequired?: boolean;
children: ReactNode;
};
export const FormGroup = ({
@ -20,16 +23,45 @@ export const FormGroup = ({
error,
help,
label,
...props
isModify = false,
isRequired = false,
onRestore,
}: FormGroupProps) => (
<FormControl marginTop="4px" {...props}>
{!!label && <FormLabel>{label}</FormLabel>}
<Flex
borderLeftWidth="3px"
borderLeftColor={error ? 'red' : isModify ? 'blue' : '#00000000'}
paddingLeft="7px"
paddingY="4px"
direction="column"
>
<Flex direction="row" width="full" gap="52px">
{!!label && (
<Text marginRight="auto" fontWeight="bold">
{label}{' '}
{isRequired && (
<Text as="span" color="red.600">
*
</Text>
)}
</Text>
)}
{!!onRestore && isModify && (
<MdRefresh size="15px" onClick={onRestore} cursor="pointer" />
)}
</Flex>
{children}
{!!help && <FormHelperText>{help}</FormHelperText>}
{!!help && (
<Flex direction="row">
<MdHelpOutline />
<Text>{help}</Text>
</Flex>
)}
<FormErrorMessage>
<MdLabelImportant />
{error}
</FormErrorMessage>
</FormControl>
{!!error && (
<Flex direction="row">
<MdErrorOutline />
<Text>{error}</Text>
</Flex>
)}
</Flex>
);

View File

@ -1,3 +1,5 @@
import { useEffect, useRef } from 'react';
import { Box, Button, Flex, Text } from '@chakra-ui/react';
import { isNullOrUndefined } from '@/utils/validator';
@ -45,7 +47,15 @@ export const FormSelectList = ({
search,
}: FormSelectListProps) => {
const displayedValue = optionToOptionDisplay(options, selected, search);
const scrollToRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (scrollToRef?.current) {
scrollToRef?.current?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
}, []);
return (
<Box position="relative">
<Flex
@ -75,6 +85,7 @@ export const FormSelectList = ({
backgroundColor={data.isSelected ? 'green.800' : '0x00000000'}
_hover={{ backgroundColor: 'gray.400' }}
onClick={() => onSelectValue(data)}
ref={data.isSelected ? scrollToRef : undefined}
>
<Text marginRight="auto" autoFocus={false}>
{data.name}

View File

@ -1,14 +1,6 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
const getDifferences = (obj1: any, obj2: any): { [key: string]: boolean } => {
const result: { [key: string]: boolean } = {};
for (const key in obj1) {
if (obj1.hasOwnProperty(key)) {
result[key] = obj1[key] !== obj2[key];
}
}
return result;
};
import { isArray, isNullOrUndefined, isObject } from '@/utils/validator';
const hasAnyTrue = (obj: { [key: string]: boolean }): boolean => {
for (const key in obj) {
@ -19,55 +11,153 @@ const hasAnyTrue = (obj: { [key: string]: boolean }): boolean => {
return false;
};
// const onChangeValue = (key: string, data) => {
// console.log(`[${key}] data: ${data}`);
// setNewData((previous) => {
// if (previous === undefined) {
// return previous;
// }
// if (previous[key] === data) {
// return previous;
// }
// const tmp = { ...previous };
// tmp[key] = data;
// console.log(`data = ${JSON.stringify(tmp, null, 2)}`);
// return tmp;
// });
// };
function getDifferences(
obj1: object,
obj2: object
): { [key: string]: boolean } {
// Create an empty object to store the differences
const result: { [key: string]: boolean } = {};
// Recursive function to compare values
function compareValues(value1: any, value2: any): boolean {
// If both values are objects, compare their properties recursively
if (isObject(value1) && isObject(value2)) {
return hasAnyTrue(getDifferences(value1, value2));
}
// If both values are arrays, compare their elements
if (isArray(value1) && isArray(value2)) {
//console.log(`Check is array: ${JSON.stringify(value1)} =?= ${JSON.stringify(value2)}`);
if (value1.length !== value2.length) {
return true;
}
for (let i = 0; i < value1.length; i++) {
if (compareValues(value1[i], value2[i])) {
return true;
}
}
return false;
}
// Otherwise, compare the values directly
//console.log(`compare : ${value1} =?= ${value2}`);
return value1 !== value2;
}
export const useFormidable = <TYPE = object,>({
// Get all keys from both objects
const allKeys = new Set([...Object.keys(obj1), ...Object.keys(obj2)]);
// Iterate over all keys
for (const key of allKeys) {
if (compareValues(obj1[key], obj2[key])) {
result[key] = true;
} else {
result[key] = false;
}
}
return result;
}
export const useFormidable = <TYPE extends object = object>({
initialValues = {} as TYPE,
}: {
initialValues?: TYPE;
}) => {
const [values, setValues] = useState<TYPE>({ ...initialValues } as TYPE);
const initialData = useMemo<TYPE>(() => {
setValues({ ...initialValues } as TYPE);
return { ...initialValues } as TYPE;
}, [initialValues, setValues]);
const [initialData, setInitialData] = useState<TYPE>(initialValues);
const [valueChange, setValueChange] = useState<{ [key: string]: boolean }>(
{}
);
const [isFormModified, setIsFormModified] = useState<boolean>(false);
useEffect(() => {
setInitialData((previous) => {
//console.log(`FORMIDABLE: useMemo initial Values(${JSON.stringify(initialValues)})`);
const previousJson = JSON.stringify(previous);
const newJson = JSON.stringify(initialValues);
if (previousJson === newJson) {
return previous;
}
//console.log(`FORMIDABLE: ==> update new values`);
setValues({ ...initialValues });
const ret = getDifferences(initialValues, initialValues);
setValueChange(ret);
setIsFormModified(hasAnyTrue(ret));
return initialValues;
});
}, [
initialValues,
setInitialData,
setValues,
setValueChange,
setIsFormModified,
]);
const setValuesExternal = useCallback(
(data: object) => {
//console.log(`FORMIDABLE: setValuesExternal(${JSON.stringify(data)}) ==> keys=${Object.keys(data)}`);
setValues((previous) => {
const newValues = { ...previous, ...data };
const ret = getDifferences(initialData, values);
const ret = getDifferences(initialData, newValues);
setValueChange(ret);
setIsFormModified(hasAnyTrue(ret));
console.log(
` ppppppppppppppppppp ${JSON.stringify(ret, null, 2)} ==> ${hasAnyTrue(ret)}`
);
return newValues;
});
},
[setValues, initialData]
);
const restoreValue = useCallback(
(data: object) => {
setValues((previous) => {
const keysInPrevious = Object.keys(previous);
const newValue = { ...previous };
let countModify = 0;
//console.log(`restore value ${JSON.stringify(data, null, 2)}`);
for (const key of Object.keys(data)) {
if (!keysInPrevious.includes(key)) {
continue;
}
if (data[key] === false) {
continue;
}
newValue[key] = initialValues[key];
countModify++;
}
if (countModify === 0) {
return previous;
}
//console.log(`initialData data ${JSON.stringify(initialData, null, 2)}`);
//console.log(`New data ${JSON.stringify(newValue, null, 2)}`);
const ret = getDifferences(initialData, newValue);
setValueChange(ret);
setIsFormModified(hasAnyTrue(ret));
return newValue;
});
},
[setValues, initialData, setIsFormModified, setValueChange]
);
const getDeltaData = useCallback(
({ omit = [], only }: { omit?: string[]; only?: string[] }) => {
const out = {};
Object.keys(valueChange).forEach((key) => {
if (omit.includes(key) || (only && !only.includes(key))) {
return;
}
if (!valueChange[key]) {
return;
}
const tmpValue = values[key];
if (isNullOrUndefined(tmpValue)) {
out[key] = null;
} else {
out[key] = tmpValue;
}
});
return out;
},
[valueChange, values]
);
return {
values,
valueChange,
isFormModified,
setValues: setValuesExternal,
getDeltaData,
restoreValue,
};
};

View File

@ -60,7 +60,7 @@ export const SelectMultiple = ({
const selectValue = (data: SelectMultipleValueDisplayType) => {
const newValues = values?.includes(data.id)
? values.filter((elem) => data.id === elem)
? values.filter((elem) => data.id !== elem)
: [...(values ?? []), data.id];
setShowList(false);
if (onChange) {

View File

@ -0,0 +1,61 @@
import { useRef } from 'react';
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Button,
UseDisclosureReturn,
useDisclosure,
} from '@chakra-ui/react';
export type ConfirmPopUpProps = {
title: string;
body: string;
confirmTitle: string;
onConfirm: () => void;
disclosure: UseDisclosureReturn;
};
export const ConfirmPopUp = ({
title,
body,
confirmTitle,
onConfirm,
disclosure,
}: ConfirmPopUpProps) => {
const onClickConfirm = () => {
onConfirm();
disclosure.onClose();
};
const cancelRef = useRef(null);
return (
<AlertDialog
isOpen={disclosure.isOpen}
leastDestructiveRef={cancelRef}
onClose={disclosure.onClose}
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{title}
</AlertDialogHeader>
<AlertDialogBody>{body}</AlertDialogBody>
<AlertDialogFooter>
<Button onClick={disclosure.onClose} ref={cancelRef}>
Cancel
</Button>
<Button colorScheme="red" onClick={onClickConfirm} ml={3}>
{confirmTitle}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
);
};

View File

@ -2,8 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import {
Button,
FormControl,
FormLabel,
IconButton,
Input,
Modal,
ModalBody,
@ -12,205 +11,246 @@ import {
ModalFooter,
ModalHeader,
ModalOverlay,
NumberDecrementStepper,
NumberIncrementStepper,
NumberInput,
NumberInputField,
NumberInputStepper,
Text,
Textarea,
useDisclosure,
} from '@chakra-ui/react';
import { Formiz, useForm, useFormFields } from '@formiz/core';
import {
MdAdminPanelSettings,
MdDeleteForever,
MdEdit,
MdRemove,
} from 'react-icons/md';
import { useNavigate, useParams } from 'react-router-dom';
import { Album, Track } from '@/back-api';
import { Track, TrackResource } from '@/back-api';
import { FormGroup } from '@/components/form/FormGroup';
import { useFormidable } from '@/components/form/Formidable';
import { SelectMultiple } from '@/components/form/SelectMultiple';
import { SelectSingle } from '@/components/form/SelectSingle';
import { ConfirmPopUp } from '@/components/popup/ConfirmPopUp';
import { useOrderedAlbums } from '@/service/Album';
import { useOrderedArtists } from '@/service/Artist';
import { useOrderedGenders } from '@/service/Gender';
import { useSpecificTrack } from '@/service/Track';
import { useServiceContext } from '@/service/ServiceContext';
import { useSpecificTrack, useTrackService } from '@/service/Track';
import { isNullOrUndefined } from '@/utils/validator';
export type TrackEditPopUpProps = {};
const getDifferences = (obj1: any, obj2: any): { [key: string]: boolean } => {
const result: { [key: string]: boolean } = {};
for (const key in obj1) {
if (obj1.hasOwnProperty(key)) {
result[key] = obj1[key] !== obj2[key];
}
}
return result;
};
const hasAnyTrue = (obj: { [key: string]: boolean }): boolean => {
for (const key in obj) {
if (obj.hasOwnProperty(key) && obj[key] === true) {
return true;
}
}
return false;
};
export const TrackEditPopUp = ({}: TrackEditPopUpProps) => {
const { trackId } = useParams();
const trackIdInt = isNullOrUndefined(trackId)
? undefined
: parseInt(trackId, 10);
const { session } = useServiceContext();
const { dataGenders } = useOrderedGenders(undefined);
const { dataArtist } = useOrderedArtists(undefined);
const { dataAlbums } = useOrderedAlbums(undefined);
const { dataTrack } = useSpecificTrack(
isNullOrUndefined(trackId) ? undefined : parseInt(trackId, 10)
);
const { store } = useTrackService();
const { dataTrack } = useSpecificTrack(trackIdInt);
const [admin, setAdmin] = useState(false);
const navigate = useNavigate();
const disclosure = useDisclosure();
const onClose = () => {
navigate('../../', { relative: 'path' });
};
const onSubmit = (values) => {
console.log(`onSubmit = ${values}`);
};
const onValuesChange = (values, form) => {
console.log(`onValuesChange = ${values}`);
const onRemove = () => {
if (isNullOrUndefined(trackIdInt)) {
return;
}
store.remove(
trackIdInt,
TrackResource.remove({
restConfig: session.getRestConfig(),
params: {
id: trackIdInt,
},
})
);
onClose();
};
const initialRef = useRef(null);
const finalRef = useRef(null);
const form = useForm<Track>({
onSubmit,
onValuesChange,
initialValues: { ...dataTrack },
onValid: () => console.log('onValid'),
onInvalid: () => console.log('onInvalid'),
const form = useFormidable<Track>({
//onSubmit,
//onValuesChange,
initialValues: dataTrack,
//onValid: () => console.log('onValid'),
//onInvalid: () => console.log('onInvalid'),
});
const formValues = useFormFields({
connect: form,
selector: (field) => field.value,
});
/*
const formValues = useFormFields({
connect: form,
fields: ['genderId'] as const,
selector: 'value',
});
*/
console.log(`dataTrack : ${JSON.stringify(dataTrack, null, 2)}`);
console.log(`Redraw : ${JSON.stringify(formValues, null, 2)}`);
const [newData, setNewData] = useState<Track>();
const [changeData, setChangeData] = useState<{ [key: string]: boolean }>();
const [changeOneData, setChangeOneData] = useState<boolean>(true);
// initialize value when ready
useEffect(() => {
setNewData(dataTrack);
}, [dataTrack, setNewData, setChangeData]);
useEffect(() => {
if (!newData) {
const onSave = async () => {
if (isNullOrUndefined(trackIdInt)) {
return;
}
const ret = getDifferences(dataTrack, newData);
setChangeData(ret);
///////////////////////setChangeOneData(hasAnyTrue(ret));
console.log(
`ChangeData=${JSON.stringify(ret, null, 2)} ChangeOneData=${hasAnyTrue(ret)}`
const dataThatNeedToBeUpdated = form.getDeltaData({ omit: ['covers'] });
console.log(`onSave = ${JSON.stringify(dataThatNeedToBeUpdated, null, 2)}`);
store.update(
TrackResource.patch({
restConfig: session.getRestConfig(),
data: dataThatNeedToBeUpdated,
params: {
id: trackIdInt,
},
})
);
}, [dataTrack, newData, setChangeData, setChangeOneData]);
// const onChangeValue = (key: string, data) => {
// console.log(`[${key}] data: ${data}`);
// setNewData((previous) => {
// if (previous === undefined) {
// return previous;
// }
// if (previous[key] === data) {
// return previous;
// }
// const tmp = { ...previous };
// tmp[key] = data;
// console.log(`data = ${JSON.stringify(tmp, null, 2)}`);
// return tmp;
// });
// };
};
return (
<Formiz connect={form} autoForm>
<form noValidate onSubmit={form.submit}>
<Modal
initialFocusRef={initialRef}
finalFocusRef={finalRef}
closeOnOverlayClick={false}
onClose={onClose}
isOpen={true}
>
<ModalOverlay />
<ModalContent>
<ModalHeader>Edit Track</ModalHeader>
<ModalCloseButton ref={finalRef} />
<Modal
initialFocusRef={initialRef}
finalFocusRef={finalRef}
closeOnOverlayClick={false}
onClose={onClose}
isOpen={true}
>
<ModalOverlay />
<ModalContent>
<ModalHeader>Edit Track</ModalHeader>
<ModalCloseButton ref={finalRef} />
<ModalBody pb={6}>
<FormGroup isRequired>
<FormLabel>Title</FormLabel>
<ModalBody pb={6} gap="0px" paddingLeft="18px">
{admin && (
<>
<FormGroup isRequired label="Id">
<Text>{dataTrack?.id}</Text>
</FormGroup>
<FormGroup label="Data Id">
<Text>{dataTrack?.dataId}</Text>
</FormGroup>
<FormGroup label="Action(s):">
<Button
onClick={disclosure.onOpen}
marginRight="auto"
variant="@danger"
>
<MdDeleteForever /> Remove Media
</Button>
</FormGroup>
<ConfirmPopUp
disclosure={disclosure}
title="Remove track"
body={`Remove Media [${dataTrack?.id}] ${dataTrack?.name}`}
confirmTitle="Remove"
onConfirm={onRemove}
/>
</>
)}
{!admin && (
<>
<FormGroup
isRequired
isModify={form.valueChange.name}
onRestore={() => form.restoreValue({ name: true })}
label="Title"
>
<Input
ref={initialRef}
value={formValues.name}
value={form.values.name}
onChange={(e) => form.setValues({ name: e.target.value })}
/>
</FormGroup>
<FormGroup>
<label>Description</label>
<FormGroup
isModify={form.valueChange.description}
onRestore={() => form.restoreValue({ description: true })}
label="Description"
>
<Textarea
value={formValues.description}
value={form.values.description}
onChange={(e) =>
form.setValues({ description: e.target.value })
}
/>
</FormGroup>
<FormGroup>
<label>Gender</label>
<FormGroup
isModify={form.valueChange.genderId}
onRestore={() => form.restoreValue({ genderId: true })}
label="Gender"
>
<SelectSingle
value={formValues.genderId}
value={form.values.genderId}
options={dataGenders}
onChange={(value) => {
form.setValues({ genderId: value });
console.log(`event change value: ${value}`);
console.log(
`values: ${JSON.stringify(formValues, null, 2)}`
);
}}
onChange={(value) => form.setValues({ genderId: value })}
keyValue="name"
/>
</FormGroup>
<FormGroup>
<label>Artist(s)</label>
<FormGroup
isModify={form.valueChange.artists}
onRestore={() => form.restoreValue({ artists: true })}
label="Artist(s)"
>
<SelectMultiple
values={formValues.artists}
values={form.values.artists}
options={dataArtist}
onChange={(value) => form.setValues({ artists: value })}
keyValue="name"
/>
</FormGroup>
<FormGroup>
<label>Album</label>
<FormGroup
isModify={form.valueChange.albumId}
onRestore={() => form.restoreValue({ albumId: true })}
label="Album"
>
<SelectSingle
value={formValues.albumId}
value={form.values.albumId}
options={dataAlbums}
onChange={(value) => form.setValues({ albumId: value })}
keyValue="name"
/>
</FormGroup>
<FormControl>
<FormLabel>track n°</FormLabel>
<FormGroup
isModify={form.valueChange.track}
onRestore={() => form.restoreValue({ track: true })}
label="Track n°"
>
<NumberInput
value={formValues.track}
onChange={(value) => form.setValues({ track: value })}
/>
</FormControl>
</ModalBody>
<ModalFooter>
{changeOneData && (
<Button colorScheme="blue" mr={3} type="submit">
Save
</Button>
)}
<Button onClick={onClose}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Modal>
</form>
</Formiz>
value={form.values.track}
onChange={(_, value) => form.setValues({ track: value })}
step={1}
defaultValue={0}
min={0}
max={1000}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</FormGroup>
</>
)}
</ModalBody>
<ModalFooter>
<Button
onClick={() => setAdmin((value) => !value)}
marginRight="auto"
>
{admin ? (
<>
<MdEdit />
Edit
</>
) : (
<>
<MdAdminPanelSettings />
Admin
</>
)}
</Button>
{!admin && form.isFormModified && (
<Button colorScheme="blue" mr={3} onClick={onSave}>
Save
</Button>
)}
<Button onClick={onClose}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};

View File

@ -70,9 +70,9 @@ export default defineStyleConfig({
'@danger': (props) =>
customVariant({
theme: props.theme,
bg: mode('error.600', 'error.300')(props),
bgHover: mode('error.700', 'error.400')(props),
bgActive: mode('error.600', 'error.300')(props),
bg: mode('error.600', 'error.600')(props),
bgHover: mode('error.700', 'error.500')(props),
bgActive: mode('error.600', 'error.500')(props),
color: mode('white', 'error.900')(props),
boxColorFocus: mode('error.900', 'error.500')(props),
}),

View File

@ -14,6 +14,8 @@ export type DataStoreType<TYPE> = {
data: TYPE[];
get: <MODEL>(value: MODEL, key?: string) => TYPE | undefined;
gets: <MODEL>(value: MODEL[] | undefined, key?: string) => TYPE[];
update: (request: Promise<TYPE>, key?: string) => void;
remove: (id: number | string, request: Promise<void>, key?: string) => void;
};
export const useDataStore = <TYPE>(
@ -85,16 +87,40 @@ export const useDataStore = <TYPE>(
);
const update = useCallback(
(value: TYPE, key?: string) => {
(request: Promise<TYPE>, key?: string) => {
const keyValue = key ?? primaryKey;
const filterData = data.filter(
(localData: TYPE) => localData[keyValue] === value[keyValue]
);
filterData.push(value);
setData(filterData);
request
.then((responseData: TYPE) => {
const filterData = data.filter(
(localData: TYPE) => localData[keyValue] !== responseData[keyValue]
);
filterData.push(responseData);
setData(filterData);
})
.catch((error) => {
console.log(`catch an error: ... ${JSON.stringify(error, null, 2)}`);
});
},
[data, setData]
);
const remove = useCallback(
(id: number | string, request: Promise<void>, key?: string) => {
const keyValue = key ?? primaryKey;
request
.then(() => {
const filterData = data.filter(
(localData: TYPE) => localData[keyValue] !== id
);
setData(filterData);
})
.catch((error) => {
console.log(
`catch an error on delete: ... ${JSON.stringify(error, null, 2)}`
);
});
},
[data, setData]
);
return { isLoading, error, data, get, gets }; //, update};
return { isLoading, error, data, get, gets, update, remove };
};