import React, { useCallback, useMemo, useState } from "react"; import { Card as MuiCard, CardProps as MuiCardProps, Grid2 as Grid, Grid2Props, } from "@mui/material"; import { useSnackbar } from "notistack"; import { CardContext } from "../../contexts/CardContext"; import { useI18n } from "../../contexts/I18nContext"; import { ValidationError } from "../../util/form_validation"; export * from "./shared"; export interface CardProps extends Omit { /** * If true, the card will always be in edit mode. * Overrides, the `isEditable` prop. * Does not affect `isDisabled` or `isOpen`. * Keeps the card in `edit` mode even after submitting. */ alwaysEditable?: boolean; /** * Number of columns to use in the grid. * Not neccary to include unless you are trying to achive * a very specific layout with multi-column spanning fields or gaps. */ columns?: number; /** * Start the card in `edit` mode. Should be used with `isEdtiable`. * The card will still be able to exit `edit` mode. This is just the default state. * Use `alwaysEditable` to keep the card in `edit` mode permanently. */ defaultEditing?: boolean; /** * Enables `edit` mode and the toggle in `CardHeader` if true. * Use `alwaysEditable` to keep the card in `edit` mode permanently. * Use `defaultEditing` to set the default state of the card. */ isEditable?: boolean; /** * Disables the card and all fields if true. * This is a global disable, not a per-field disable. * Only makes sense when `isEditable` is true. */ isDisabled?: boolean; /** * Uses compact layout for card and all fields if true. * Compact cards are designed to be placed in `RightColumn`. */ isCompact?: boolean; /** * Enables accordion-like behavior if true. * Does not affect `isEditable` or `isDisabled`. */ isCollapsible?: boolean; /** * Start the card in `open` mode. Should be used with `isCollapsible`. * The card will still be able to close. This is just the default state. */ isOpen?: boolean; /** * Function to call when the form is submitted. * All card fields should be treaded as uncontrolled and access via standard DOM Form APIs. * The first arguments provies `Object.fromEntries(new FormData(form))` for convenience. * The second argument is the form event. This can be used to `getAll` or `get` form values * which are "unserializable" by `Object.fromEntries` (such as shared `FormName`s). * * The function should return a string to display as a success message. * If the function returns `null` or `undefined`, no message will be displayed. * Shows a generic error message if the function throws an error. * You can show your own error message by catching the error, queueing a snackbar and returning null. * * Type this function using the generic version of the card component. * * @example * ```tsx * interface MyCardValues { * name: string; * email: string; * } * * ... * * onSubmit={...} /> */ onSubmit?: ( values: T, event: React.FormEvent, ) => Promise; /** * The MUI `Grid2` size of the card. * Defaults to `12` (full width), which is what you want in most cases. * @see https://mui.com/material-ui/react-grid2/ */ size?: Grid2Props["size"]; /** * Additional props to pass to the `Grid2` component. * @see https://mui.com/material-ui/react-grid2/ */ gridProps?: Omit; } function formDataToObject( formData: FormData, ): Record { const values: Record = {}; for (const [key, value] of formData.entries()) { const existing = values[key]; if (existing == null) { values[key] = value; } else if (Array.isArray(existing)) { existing.push(value); } else { values[key] = [existing, value]; } } return values; } /** * A card component that wraps a form with `onSubmit` and provides `cardContext`. * This should be your building block for all admin forms not better represented by a Table. * Generic type `T` is used to type the `FormData` of `onSubmit`. * @example * ```tsx * interface MyCardValues { * name: string; * email: string; * } * * * onSubmit={(values) => { * console.log(values); * return "Success!"; * }}> * * * * * * * * * * * * */ export function Card({ alwaysEditable = false, children, columns = 1, defaultEditing = false, isCollapsible = false, isCompact = false, isDisabled: isDisabledDefault = false, isEditable, isOpen: isOpenDefault = false, onSubmit, sx, size, gridProps, ...props }: CardProps) { isEditable ??= alwaysEditable; const { t } = useI18n(); const { enqueueSnackbar } = useSnackbar(); const [isEditing, setIsEditing] = useState(alwaysEditable || defaultEditing || false); const [hasChanges, setHasChanges] = useState(false); const [isDisabled, setIsDisabled] = useState(isDisabledDefault); const [isOpen, setIsOpen] = useState(isOpenDefault); const handleSubmit = useCallback( async (event: React.FormEvent) => { event.preventDefault(); if (onSubmit == null) throw new Error("No onSubmit function provided."); if (!isEditing && !alwaysEditable) throw new Error("Cannot submit when not in edit mode."); const form = event.currentTarget; const formData = new FormData(form); const values = formDataToObject(formData); setIsDisabled(true); try { const success = await onSubmit(values as T, event); if (success != null) { enqueueSnackbar(success, { variant: "success" }); } if (!alwaysEditable) { setIsEditing(false); } setIsDisabled(false); } catch (error) { // TODO Report error to sentry console.error("[CARD] Error when submitting data", error); if (error instanceof ValidationError) { const message = error.formErrors.at(0) ?? t("Invalid form input. Please correct the highlighted fields."); enqueueSnackbar(message, { variant: "error" }); } else { enqueueSnackbar(t("Failed to submit data. Contact support or try again later."), { variant: "error", }); } // Don't exit edit mode (and clear fields) on error. Give the user the chance to save/fix. setIsDisabled(false); } }, [onSubmit, enqueueSnackbar, t, isEditing, alwaysEditable], ); const contextValues = useMemo( () => ({ alwaysEditable, columns, hasChanges, isCollapsible, isCompact, isDisabled, isEditable, isEditing, isOpen, setHasChanges, setIsEditing, setIsOpen, }), [isDisabled, isEditable, isEditing, columns, hasChanges, isOpen, isCompact, isCollapsible], ); return ( theme.palette.divider, bgcolor: (theme) => theme.palette.background.paper, overflow: "visible", ...sx, }} onSubmit={isEditable || isEditing ? handleSubmit : undefined} {...props} > {children} ); } export default Card;