diff --git a/frontend/src/app-components/dialogs/DialogTitle.tsx b/frontend/src/app-components/dialogs/DialogTitle.tsx index 5bc70726..ce6db7ce 100644 --- a/frontend/src/app-components/dialogs/DialogTitle.tsx +++ b/frontend/src/app-components/dialogs/DialogTitle.tsx @@ -8,10 +8,10 @@ import CloseIcon from "@mui/icons-material/Close"; import { + IconButton, + DialogTitle as MuiDialogTitle, Typography, styled, - DialogTitle as MuiDialogTitle, - IconButton, } from "@mui/material"; const StyledDialogTitle = styled(Typography)(() => ({ @@ -24,12 +24,20 @@ export const DialogTitle = ({ onClose, }: { children: React.ReactNode; - onClose?: () => void; + onClose?: + | ((event: {}, reason: "backdropClick" | "escapeKeyDown") => void) + | undefined; }) => ( {children} - - - + {onClose && ( + onClose(e, "backdropClick")} + > + + + )} ); diff --git a/frontend/src/app-components/dialogs/FormDialog.tsx b/frontend/src/app-components/dialogs/FormDialog.tsx new file mode 100644 index 00000000..262c83cf --- /dev/null +++ b/frontend/src/app-components/dialogs/FormDialog.tsx @@ -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 = ({ + title, + children, + open, + onClose, + ...rest +}) => { + return ( + + {title} + {children} + + {/* */} + + + ); +}; diff --git a/frontend/src/components/categories/CategoryForm.tsx b/frontend/src/components/categories/CategoryForm.tsx new file mode 100644 index 00000000..59fb1ed2 --- /dev/null +++ b/frontend/src/components/categories/CategoryForm.tsx @@ -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 = ({ 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({ + 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 ( +
+ + + + + +
+ ); +}; diff --git a/frontend/src/components/categories/CategoryFormDialog.tsx b/frontend/src/components/categories/CategoryFormDialog.tsx new file mode 100644 index 00000000..f95f0c35 --- /dev/null +++ b/frontend/src/components/categories/CategoryFormDialog.tsx @@ -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; + +export const CategoryFormDialog: FC = ({ + onClose, + payload, + ...rest +}) => { + const { t } = useTranslate(); + + return ( + await onClose(false)} + > + + + ); +}; diff --git a/frontend/src/components/categories/index.tsx b/frontend/src/components/categories/index.tsx index d9976536..bdb8bff1 100644 --- a/frontend/src/components/categories/index.tsx +++ b/frontend/src/components/categories/index.tsx @@ -13,7 +13,6 @@ import { Button, Grid, Paper } from "@mui/material"; import { GridColDef, GridRowSelectionModel } from "@mui/x-data-grid"; import { useState } from "react"; -import { DeleteDialog } from "@/app-components/dialogs/DeleteDialog"; import { FilterTextfield } from "@/app-components/inputs/FilterTextfield"; import { ActionColumnLabel, @@ -24,7 +23,8 @@ import { DataGrid } from "@/app-components/tables/DataGrid"; import { useDelete } from "@/hooks/crud/useDelete"; import { useDeleteMany } from "@/hooks/crud/useDeleteMany"; 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 { useSearch } from "@/hooks/useSearch"; import { useToast } from "@/hooks/useToast"; @@ -36,13 +36,11 @@ import { getDateTimeFormatter } from "@/utils/date"; import { ICategory } from "../../types/category.types"; -import { CategoryDialog } from "./CategoryDialog"; +import { CategoryFormDialog } from "./CategoryFormDialog"; export const Categories = () => { const { t } = useTranslate(); const { toast } = useToast(); - const addDialogCtl = useDialog(false); - const editDialogCtl = useDialog(false); const deleteDialogCtl = useDialog(false); const hasPermission = useHasPermission(); const { onSearch, searchPayload } = useSearch({ @@ -80,7 +78,7 @@ export const Categories = () => { [ { label: ActionColumnLabel.Edit, - action: (row) => editDialogCtl.openDialog(row), + action: (row) => dialogs.open(CategoryFormDialog, row), requires: [PermissionAction.UPDATE], }, { @@ -128,10 +126,11 @@ export const Categories = () => { const handleSelectionChange = (selection: GridRowSelectionModel) => { setSelectedCategories(selection as string[]); }; + const dialogs = useDialogs(); return ( - + {/* { } } }} - /> + /> */} { startIcon={} variant="contained" sx={{ float: "right" }} - onClick={() => addDialogCtl.openDialog()} + onClick={() => dialogs.open(CategoryFormDialog, null)} > {t("button.add")} diff --git a/frontend/src/contexts/dialogs.context.tsx b/frontend/src/contexts/dialogs.context.tsx new file mode 100644 index 00000000..c893e6ca --- /dev/null +++ b/frontend/src/contexts/dialogs.context.tsx @@ -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 { + /** + * 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; +} + +/** + * The props that are passed to a dialog component. + */ +export interface DialogProps

{ + /** + * 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; +} + +export type DialogComponent = React.ComponentType>; + +export interface OpenDialog { + /** + * Open a dialog without payload. + * @param Component The dialog component to open. + * @param options Additional options for the dialog. + */ +

( + Component: DialogComponent, + payload?: P, + options?: OpenDialogOptions, + ): Promise; + /** + * 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. + */ + ( + Component: DialogComponent, + payload: P, + options?: OpenDialogOptions, + ): Promise; +} + +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. + */ + (dialog: Promise, result: R): Promise; +} + +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 { + key: string; + open: boolean; + promise: Promise; + Component: DialogComponent; + payload: P; + onClose: (result: R) => Promise; + 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[]>([]); + const keyPrefix = React.useId(); + const nextId = React.useRef(0); + const requestDialog = React.useCallback( + function open( + Component: DialogComponent, + payload: P, + options: OpenDialogOptions = {}, + ) { + const { onClose = async () => {} } = options; + let resolve: ((result: R) => void) | undefined; + const promise = new Promise((resolveImpl) => { + resolve = resolveImpl; + }); + + if (!resolve) { + throw new Error("resolve not set"); + } + + const key = `${keyPrefix}-${nextId.current}`; + + nextId.current += 1; + + const newEntry: DialogStackEntry = { + key, + open: true, + promise, + Component, + payload, + onClose, + resolve, + }; + + setStack((prevStack) => [...prevStack, newEntry]); + + return promise; + }, + [keyPrefix], + ); + const closeDialogUi = React.useCallback( + function closeDialogUi(dialog: Promise) { + 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(dialog: Promise, 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 ( + + {children} + {stack.map(({ key, open, Component, payload, promise }) => ( + { + await closeDialog(promise, result); + }} + /> + ))} + + ); +} + +export { DialogsProvider }; diff --git a/frontend/src/hooks/useDialogs.ts b/frontend/src/hooks/useDialogs.ts new file mode 100644 index 00000000..7a481e48 --- /dev/null +++ b/frontend/src/hooks/useDialogs.ts @@ -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( + // async (msg, { onClose, ...options } = {}) => + // open(AlertDialog, { ...options, msg }, { onClose }), + // [open], + // ); + // const confirm = React.useCallback( + // async (msg, { onClose, ...options } = {}) => + // open(ConfirmDialog, { ...options, msg }, { onClose }), + // [open], + // ); + // const prompt = React.useCallback( + // async (msg, { onClose, ...options } = {}) => + // open(PromptDialog, { ...options, msg }, { onClose }), + // [open], + // ); + + return useMemo( + () => ({ + // alert, + // confirm, + // prompt, + open, + close, + }), + // [alert, close, confirm, open, prompt], + [close, open], + ); +} diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index a0b2067d..d41af17f 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -20,6 +20,7 @@ import { SnackbarCloseButton } from "@/app-components/displays/Toast/CloseButton import { ApiClientProvider } from "@/contexts/apiClient.context"; import { AuthProvider } from "@/contexts/auth.context"; import { ConfigProvider } from "@/contexts/config.context"; +import { DialogsProvider } from "@/contexts/dialogs.context"; import { PermissionProvider } from "@/contexts/permission.context"; import { SettingsProvider } from "@/contexts/setting.context"; import { ToastProvider } from "@/hooks/useToast"; @@ -72,31 +73,33 @@ const App = ({ Component, pageProps }: TAppPropsWithLayout) => {

- ( - - )} - > - - - - - - - - - {getLayout()} - - - - - - - - - + + ( + + )} + > + + + + + + + + + {getLayout()} + + + + + + + + + +