refactor: dialog system (partial)

This commit is contained in:
Mohamed Marrouchi 2025-01-31 12:38:52 +01:00
parent 1288eb87cf
commit 407581a27e
8 changed files with 560 additions and 40 deletions

View File

@ -8,10 +8,10 @@
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import { import {
IconButton,
DialogTitle as MuiDialogTitle,
Typography, Typography,
styled, styled,
DialogTitle as MuiDialogTitle,
IconButton,
} from "@mui/material"; } from "@mui/material";
const StyledDialogTitle = styled(Typography)(() => ({ const StyledDialogTitle = styled(Typography)(() => ({
@ -24,12 +24,20 @@ export const DialogTitle = ({
onClose, onClose,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
onClose?: () => void; onClose?:
| ((event: {}, reason: "backdropClick" | "escapeKeyDown") => void)
| undefined;
}) => ( }) => (
<MuiDialogTitle> <MuiDialogTitle>
<StyledDialogTitle>{children}</StyledDialogTitle> <StyledDialogTitle>{children}</StyledDialogTitle>
<IconButton size="small" aria-label="close" onClick={onClose}> {onClose && (
<CloseIcon /> <IconButton
</IconButton> size="small"
aria-label="close"
onClick={(e) => onClose(e, "backdropClick")}
>
<CloseIcon />
</IconButton>
)}
</MuiDialogTitle> </MuiDialogTitle>
); );

View File

@ -0,0 +1,40 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import {
Dialog,
DialogActions,
DialogContent,
DialogProps,
} from "@mui/material";
import { FC, ReactNode } from "react";
import { DialogTitle } from "@/app-components/dialogs/DialogTitle";
export interface FormDialogProps extends DialogProps {
title: string;
children: ReactNode;
}
export const FormDialog: FC<FormDialogProps> = ({
title,
children,
open,
onClose,
...rest
}) => {
return (
<Dialog open={open} fullWidth onClose={onClose} {...rest}>
<DialogTitle onClose={onClose}>{title}</DialogTitle>
<DialogContent>{children}</DialogContent>
<DialogActions>
{/* <DialogButtons closeDialog={closeDialog} /> */}
</DialogActions>
</Dialog>
);
};

View File

@ -0,0 +1,90 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { FC, useEffect } from "react";
import { useForm } from "react-hook-form";
import { ContentContainer } from "@/app-components/dialogs/layouts/ContentContainer";
import { ContentItem } from "@/app-components/dialogs/layouts/ContentItem";
import { Input } from "@/app-components/inputs/Input";
import { useCreate } from "@/hooks/crud/useCreate";
import { useUpdate } from "@/hooks/crud/useUpdate";
import { useToast } from "@/hooks/useToast";
import { useTranslate } from "@/hooks/useTranslate";
import { EntityType } from "@/services/types";
import { ICategory, ICategoryAttributes } from "@/types/category.types";
export type CategoryFormProps = {
data: ICategory | null;
};
export const CategoryForm: FC<CategoryFormProps> = ({ data }) => {
const { t } = useTranslate();
const { toast } = useToast();
const { mutateAsync: createCategory } = useCreate(EntityType.CATEGORY, {
onError: (error) => {
toast.error(error);
},
onSuccess: () => {
toast.success(t("message.success_save"));
},
});
const { mutateAsync: updateCategory } = useUpdate(EntityType.CATEGORY, {
onError: () => {
toast.error(t("message.internal_server_error"));
},
onSuccess: () => {
toast.success(t("message.success_save"));
},
});
const {
reset,
register,
formState: { errors },
handleSubmit,
} = useForm<ICategoryAttributes>({
defaultValues: { label: data?.label || "" },
});
const validationRules = {
label: {
required: t("message.label_is_required"),
},
};
const onSubmitForm = async (params: ICategoryAttributes) => {
if (data) {
updateCategory({ id: data.id, params });
} else {
createCategory(params);
}
};
useEffect(() => {
if (data) {
reset({
label: data.label,
});
} else {
reset();
}
}, [data, reset]);
return (
<form onSubmit={handleSubmit(onSubmitForm)}>
<ContentContainer>
<ContentItem>
<Input
label={t("placeholder.label")}
{...register("label", validationRules.label)}
autoFocus
helperText={errors.label ? errors.label.message : null}
/>
</ContentItem>
</ContentContainer>
</form>
);
};

View File

@ -0,0 +1,37 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { FC } from "react";
import { FormDialog } from "@/app-components/dialogs/FormDialog";
import { DialogProps } from "@/contexts/dialogs.context";
import { useTranslate } from "@/hooks/useTranslate";
import { ICategory } from "@/types/category.types";
import { CategoryForm } from "./CategoryForm";
export type CategoryFormProps = DialogProps<ICategory | null, boolean>;
export const CategoryFormDialog: FC<CategoryFormProps> = ({
onClose,
payload,
...rest
}) => {
const { t } = useTranslate();
return (
<FormDialog
title={payload ? t("title.edit_category") : t("title.new_category")}
{...rest}
// @TODO: fix typing
onClose={async () => await onClose(false)}
>
<CategoryForm data={payload} />
</FormDialog>
);
};

View File

@ -13,7 +13,6 @@ import { Button, Grid, Paper } from "@mui/material";
import { GridColDef, GridRowSelectionModel } from "@mui/x-data-grid"; import { GridColDef, GridRowSelectionModel } from "@mui/x-data-grid";
import { useState } from "react"; import { useState } from "react";
import { DeleteDialog } from "@/app-components/dialogs/DeleteDialog";
import { FilterTextfield } from "@/app-components/inputs/FilterTextfield"; import { FilterTextfield } from "@/app-components/inputs/FilterTextfield";
import { import {
ActionColumnLabel, ActionColumnLabel,
@ -24,7 +23,8 @@ import { DataGrid } from "@/app-components/tables/DataGrid";
import { useDelete } from "@/hooks/crud/useDelete"; import { useDelete } from "@/hooks/crud/useDelete";
import { useDeleteMany } from "@/hooks/crud/useDeleteMany"; import { useDeleteMany } from "@/hooks/crud/useDeleteMany";
import { useFind } from "@/hooks/crud/useFind"; import { useFind } from "@/hooks/crud/useFind";
import { getDisplayDialogs, useDialog } from "@/hooks/useDialog"; import { useDialog } from "@/hooks/useDialog";
import { useDialogs } from "@/hooks/useDialogs";
import { useHasPermission } from "@/hooks/useHasPermission"; import { useHasPermission } from "@/hooks/useHasPermission";
import { useSearch } from "@/hooks/useSearch"; import { useSearch } from "@/hooks/useSearch";
import { useToast } from "@/hooks/useToast"; import { useToast } from "@/hooks/useToast";
@ -36,13 +36,11 @@ import { getDateTimeFormatter } from "@/utils/date";
import { ICategory } from "../../types/category.types"; import { ICategory } from "../../types/category.types";
import { CategoryDialog } from "./CategoryDialog"; import { CategoryFormDialog } from "./CategoryFormDialog";
export const Categories = () => { export const Categories = () => {
const { t } = useTranslate(); const { t } = useTranslate();
const { toast } = useToast(); const { toast } = useToast();
const addDialogCtl = useDialog<ICategory>(false);
const editDialogCtl = useDialog<ICategory>(false);
const deleteDialogCtl = useDialog<string>(false); const deleteDialogCtl = useDialog<string>(false);
const hasPermission = useHasPermission(); const hasPermission = useHasPermission();
const { onSearch, searchPayload } = useSearch<ICategory>({ const { onSearch, searchPayload } = useSearch<ICategory>({
@ -80,7 +78,7 @@ export const Categories = () => {
[ [
{ {
label: ActionColumnLabel.Edit, label: ActionColumnLabel.Edit,
action: (row) => editDialogCtl.openDialog(row), action: (row) => dialogs.open(CategoryFormDialog, row),
requires: [PermissionAction.UPDATE], requires: [PermissionAction.UPDATE],
}, },
{ {
@ -128,10 +126,11 @@ export const Categories = () => {
const handleSelectionChange = (selection: GridRowSelectionModel) => { const handleSelectionChange = (selection: GridRowSelectionModel) => {
setSelectedCategories(selection as string[]); setSelectedCategories(selection as string[]);
}; };
const dialogs = useDialogs();
return ( return (
<Grid container gap={3} flexDirection="column"> <Grid container gap={3} flexDirection="column">
<CategoryDialog {...getDisplayDialogs(addDialogCtl)} /> {/* <CategoryDialog {...getDisplayDialogs(addDialogCtl)} />
<CategoryDialog {...getDisplayDialogs(editDialogCtl)} /> <CategoryDialog {...getDisplayDialogs(editDialogCtl)} />
<DeleteDialog <DeleteDialog
{...deleteDialogCtl} {...deleteDialogCtl}
@ -146,7 +145,7 @@ export const Categories = () => {
} }
} }
}} }}
/> /> */}
<Grid> <Grid>
<PageHeader icon={FolderIcon} title={t("title.categories")}> <PageHeader icon={FolderIcon} title={t("title.categories")}>
<Grid <Grid
@ -166,7 +165,7 @@ export const Categories = () => {
startIcon={<AddIcon />} startIcon={<AddIcon />}
variant="contained" variant="contained"
sx={{ float: "right" }} sx={{ float: "right" }}
onClick={() => addDialogCtl.openDialog()} onClick={() => dialogs.open(CategoryFormDialog, null)}
> >
{t("button.add")} {t("button.add")}
</Button> </Button>

View File

@ -0,0 +1,217 @@
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import * as React from "react";
export interface OpenDialogOptions<R> {
/**
* A function that is called before closing the dialog closes. The dialog
* stays open as long as the returned promise is not resolved. Use this if
* you want to perform an async action on close and show a loading state.
*
* @param result The result that the dialog will return after closing.
* @returns A promise that resolves when the dialog can be closed.
*/
onClose?: (result: R) => Promise<void>;
}
/**
* The props that are passed to a dialog component.
*/
export interface DialogProps<P = undefined, R = void> {
/**
* The payload that was passed when the dialog was opened.
*/
payload: P;
/**
* Whether the dialog is open.
*/
open: boolean;
/**
* A function to call when the dialog should be closed. If the dialog has a return
* value, it should be passed as an argument to this function. You should use the promise
* that is returned to show a loading state while the dialog is performing async actions
* on close.
* @param result The result to return from the dialog.
* @returns A promise that resolves when the dialog can be fully closed.
*/
onClose: (result: R) => Promise<void>;
}
export type DialogComponent<P, R> = React.ComponentType<DialogProps<P, R>>;
export interface OpenDialog {
/**
* Open a dialog without payload.
* @param Component The dialog component to open.
* @param options Additional options for the dialog.
*/
<P extends undefined, R>(
Component: DialogComponent<P, R>,
payload?: P,
options?: OpenDialogOptions<R>,
): Promise<R>;
/**
* Open a dialog and pass a payload.
* @param Component The dialog component to open.
* @param payload The payload to pass to the dialog.
* @param options Additional options for the dialog.
*/
<P, R>(
Component: DialogComponent<P, R>,
payload: P,
options?: OpenDialogOptions<R>,
): Promise<R>;
}
export interface CloseDialog {
/**
* Close a dialog and return a result.
* @param dialog The dialog to close. The promise returned by `open`.
* @param result The result to return from the dialog.
* @returns A promise that resolves when the dialog is fully closed.
*/
<R>(dialog: Promise<R>, result: R): Promise<R>;
}
export interface DialogHook {
// alert: OpenAlertDialog;
// confirm: OpenConfirmDialog;
// prompt: OpenPromptDialog;
open: OpenDialog;
close: CloseDialog;
}
export const DialogsContext = React.createContext<{
open: OpenDialog;
close: CloseDialog;
} | null>(null);
interface DialogStackEntry<P, R> {
key: string;
open: boolean;
promise: Promise<R>;
Component: DialogComponent<P, R>;
payload: P;
onClose: (result: R) => Promise<void>;
resolve: (result: R) => void;
}
export interface DialogProviderProps {
children?: React.ReactNode;
unmountAfter?: number;
}
/**
* Provider for Dialog stacks. The subtree of this component can use the `useDialogs` hook to
* access the dialogs API. The dialogs are rendered in the order they are requested.
*
* Demos:
*
* - [useDialogs](https://mui.com/toolpad/core/react-use-dialogs/)
*
* API:
*
* - [DialogsProvider API](https://mui.com/toolpad/core/api/dialogs-provider)
*/
function DialogsProvider(props: DialogProviderProps) {
const { children, unmountAfter = 1000 } = props;
const [stack, setStack] = React.useState<DialogStackEntry<any, any>[]>([]);
const keyPrefix = React.useId();
const nextId = React.useRef(0);
const requestDialog = React.useCallback<OpenDialog>(
function open<P, R>(
Component: DialogComponent<P, R>,
payload: P,
options: OpenDialogOptions<R> = {},
) {
const { onClose = async () => {} } = options;
let resolve: ((result: R) => void) | undefined;
const promise = new Promise<R>((resolveImpl) => {
resolve = resolveImpl;
});
if (!resolve) {
throw new Error("resolve not set");
}
const key = `${keyPrefix}-${nextId.current}`;
nextId.current += 1;
const newEntry: DialogStackEntry<P, R> = {
key,
open: true,
promise,
Component,
payload,
onClose,
resolve,
};
setStack((prevStack) => [...prevStack, newEntry]);
return promise;
},
[keyPrefix],
);
const closeDialogUi = React.useCallback(
function closeDialogUi<R>(dialog: Promise<R>) {
setStack((prevStack) =>
prevStack.map((entry) =>
entry.promise === dialog ? { ...entry, open: false } : entry,
),
);
setTimeout(() => {
// wait for closing animation
setStack((prevStack) =>
prevStack.filter((entry) => entry.promise !== dialog),
);
}, unmountAfter);
},
[unmountAfter],
);
const closeDialog = React.useCallback(
async function closeDialog<R>(dialog: Promise<R>, result: R) {
const entryToClose = stack.find((entry) => entry.promise === dialog);
if (!entryToClose) {
throw new Error("dialog not found");
}
await entryToClose.onClose(result);
entryToClose.resolve(result);
closeDialogUi(dialog);
return dialog;
},
[stack, closeDialogUi],
);
const contextValue = React.useMemo(
() => ({ open: requestDialog, close: closeDialog }),
[requestDialog, closeDialog],
);
return (
<DialogsContext.Provider value={contextValue}>
{children}
{stack.map(({ key, open, Component, payload, promise }) => (
<Component
key={key}
payload={payload}
open={open}
onClose={async (result) => {
await closeDialog(promise, result);
}}
/>
))}
</DialogsContext.Provider>
);
}
export { DialogsProvider };

View File

@ -0,0 +1,126 @@
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { useContext, useMemo } from "react";
import {
CloseDialog,
DialogsContext,
OpenDialog,
} from "@/contexts/dialogs.context";
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
export interface DialogHook {
// alert: OpenAlertDialog;
// confirm: OpenConfirmDialog;
// prompt: OpenPromptDialog;
open: OpenDialog;
close: CloseDialog;
}
export function useDialogs(): DialogHook {
const { open, close } = useContext(DialogsContext);
// const alert = React.useCallback<OpenAlertDialog>(
// async (msg, { onClose, ...options } = {}) =>
// open(AlertDialog, { ...options, msg }, { onClose }),
// [open],
// );
// const confirm = React.useCallback<OpenConfirmDialog>(
// async (msg, { onClose, ...options } = {}) =>
// open(ConfirmDialog, { ...options, msg }, { onClose }),
// [open],
// );
// const prompt = React.useCallback<OpenPromptDialog>(
// async (msg, { onClose, ...options } = {}) =>
// open(PromptDialog, { ...options, msg }, { onClose }),
// [open],
// );
return useMemo(
() => ({
// alert,
// confirm,
// prompt,
open,
close,
}),
// [alert, close, confirm, open, prompt],
[close, open],
);
}

View File

@ -20,6 +20,7 @@ import { SnackbarCloseButton } from "@/app-components/displays/Toast/CloseButton
import { ApiClientProvider } from "@/contexts/apiClient.context"; import { ApiClientProvider } from "@/contexts/apiClient.context";
import { AuthProvider } from "@/contexts/auth.context"; import { AuthProvider } from "@/contexts/auth.context";
import { ConfigProvider } from "@/contexts/config.context"; import { ConfigProvider } from "@/contexts/config.context";
import { DialogsProvider } from "@/contexts/dialogs.context";
import { PermissionProvider } from "@/contexts/permission.context"; import { PermissionProvider } from "@/contexts/permission.context";
import { SettingsProvider } from "@/contexts/setting.context"; import { SettingsProvider } from "@/contexts/setting.context";
import { ToastProvider } from "@/hooks/useToast"; import { ToastProvider } from "@/hooks/useToast";
@ -72,31 +73,33 @@ const App = ({ Component, pageProps }: TAppPropsWithLayout) => {
<main className={roboto.className}> <main className={roboto.className}>
<ConfigProvider> <ConfigProvider>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<ToastProvider <DialogsProvider>
maxSnack={3} <ToastProvider
anchorOrigin={{ vertical: "top", horizontal: "center" }} maxSnack={3}
action={(snackbarKey) => ( anchorOrigin={{ vertical: "top", horizontal: "center" }}
<SnackbarCloseButton snackbarKey={snackbarKey} /> action={(snackbarKey) => (
)} <SnackbarCloseButton snackbarKey={snackbarKey} />
> )}
<StyledEngineProvider injectFirst> >
<QueryClientProvider client={queryClient}> <StyledEngineProvider injectFirst>
<CssBaseline /> <QueryClientProvider client={queryClient}>
<ApiClientProvider> <CssBaseline />
<AuthProvider> <ApiClientProvider>
<PermissionProvider> <AuthProvider>
<SettingsProvider> <PermissionProvider>
<SocketProvider> <SettingsProvider>
{getLayout(<Component {...pageProps} />)} <SocketProvider>
</SocketProvider> {getLayout(<Component {...pageProps} />)}
</SettingsProvider> </SocketProvider>
</PermissionProvider> </SettingsProvider>
</AuthProvider> </PermissionProvider>
</ApiClientProvider> </AuthProvider>
<ReactQueryDevtools initialIsOpen={false} /> </ApiClientProvider>
</QueryClientProvider> <ReactQueryDevtools initialIsOpen={false} />
</StyledEngineProvider> </QueryClientProvider>
</ToastProvider> </StyledEngineProvider>
</ToastProvider>
</DialogsProvider>
</ThemeProvider> </ThemeProvider>
</ConfigProvider> </ConfigProvider>
</main> </main>