|
| 1 | +import * as React from "react"; |
| 2 | +import { useNavigate, useParams } from 'react-router-dom'; |
| 3 | + |
| 4 | +import { Add, Delete, Edit } from '@mui/icons-material'; |
| 5 | +import { Box, Button, ButtonProps, CircularProgress, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material'; |
| 6 | +import Form, { IChangeEvent } from "@rjsf/core"; |
| 7 | +import MuiForm from "@rjsf/mui"; |
| 8 | +import { Field, RJSFSchema, UiSchema } from '@rjsf/utils'; |
| 9 | +import { customizeValidator } from '@rjsf/validator-ajv8'; |
| 10 | +import { ErrorBoundary, Suspense } from "@suspensive/react"; |
| 11 | +import AjvDraft04 from 'ajv-draft-04'; |
| 12 | + |
| 13 | +import * as Common from "@frontend/common"; |
| 14 | + |
| 15 | +import { addErrorSnackbar, addSnackbar } from '../../utils/snackbar'; |
| 16 | +import { BackendAdminSignInGuard } from '../elements/admin_signin_guard'; |
| 17 | + |
| 18 | +type EditorFormDataEventType = IChangeEvent<Record<string, string>, RJSFSchema, { [k in string]: unknown }> |
| 19 | +type onSubmitType = (data: EditorFormDataEventType, event: React.FormEvent<unknown>) => void; |
| 20 | + |
| 21 | +type AppResourceType = { app: string; resource: string; } |
| 22 | +type AppResourceIdType = AppResourceType & { id?: string } |
| 23 | +type AdminEditorPropsType = React.PropsWithChildren<{ |
| 24 | + beforeSubmit?: onSubmitType; |
| 25 | + afterSubmit?: onSubmitType; |
| 26 | + notModifiable?: boolean; |
| 27 | + notDeletable?: boolean; |
| 28 | + extraActions?: ButtonProps[]; |
| 29 | +}> |
| 30 | + |
| 31 | +const processFile = (event: React.ChangeEvent<HTMLInputElement>) => { |
| 32 | + if (!event.target.files || event.target.files.length === 0) |
| 33 | + return Promise.resolve(""); |
| 34 | + |
| 35 | + const f = event.target.files[0]; |
| 36 | + return new Promise((resolve) => { |
| 37 | + const reader = new FileReader(); |
| 38 | + reader.onload = (event) => resolve(event.target ? event.target.result : ""); |
| 39 | + reader.readAsDataURL(f); |
| 40 | + }); |
| 41 | +} |
| 42 | + |
| 43 | +const FileField: Field = (p) => ( |
| 44 | + <input |
| 45 | + type="file" |
| 46 | + required={p.required} |
| 47 | + disabled={p.disabled} |
| 48 | + defaultValue={p.defaultValue} |
| 49 | + onChange={(event) => processFile(event).then(p.onChange)} |
| 50 | + /> |
| 51 | +); |
| 52 | + |
| 53 | +const ReadOnlyValueField: React.FC<{ name: string; value: unknown; uiSchema: UiSchema }> = ({ name, value, uiSchema }) => { |
| 54 | + if (uiSchema[name] && uiSchema[name]["ui:field"] === "file") { |
| 55 | + return <Stack spacing={2} alignItems="flex-start"> |
| 56 | + <img src={value as string} alt={name} style={{ maxWidth: "100%", maxHeight: "600px", objectFit: "contain" }} /> |
| 57 | + <a href={value as string}>링크</a> |
| 58 | + </Stack> |
| 59 | + } |
| 60 | + |
| 61 | + return value as string; |
| 62 | +} |
| 63 | + |
| 64 | +const InnerAdminEditor: React.FC<AppResourceIdType & AdminEditorPropsType> = ErrorBoundary.with( |
| 65 | + { fallback: Common.Components.ErrorFallback }, |
| 66 | + Suspense.with( |
| 67 | + { fallback: <CircularProgress /> }, |
| 68 | + ({ app, resource, id, beforeSubmit, afterSubmit, extraActions, notModifiable, notDeletable, children }) => { |
| 69 | + const navigate = useNavigate(); |
| 70 | + const formRef = React.useRef<Form<Record<string, string>, RJSFSchema, { [k in string]: unknown }> | null>(null); |
| 71 | + const [formDataState, setFormDataState] = React.useState<Record<string, string> | undefined>(undefined); |
| 72 | + const backendAdminClient = Common.Hooks.BackendAdminAPI.useBackendAdminClient(); |
| 73 | + const { data: schemaInfo } = Common.Hooks.BackendAdminAPI.useSchemaQuery(backendAdminClient, app, resource); |
| 74 | + |
| 75 | + const createMutation = Common.Hooks.BackendAdminAPI.useCreateMutation<Record<string, string>>(backendAdminClient, app, resource); |
| 76 | + const modifyMutation = Common.Hooks.BackendAdminAPI.useUpdateMutation<Record<string, string>>(backendAdminClient, app, resource, id || ""); |
| 77 | + const deleteMutation = Common.Hooks.BackendAdminAPI.useRemoveMutation(backendAdminClient, app, resource, id || "undefined"); |
| 78 | + const submitMutation = id ? modifyMutation : createMutation; |
| 79 | + |
| 80 | + React.useEffect(() => { |
| 81 | + ( |
| 82 | + async () => { |
| 83 | + if (!id) { |
| 84 | + setFormDataState({}); |
| 85 | + return |
| 86 | + } |
| 87 | + |
| 88 | + setFormDataState(await Common.BackendAdminAPIs.retrieve<Record<string, string>>(backendAdminClient, app, resource, id)()); |
| 89 | + } |
| 90 | + )() |
| 91 | + }, [id]); |
| 92 | + |
| 93 | + const onSubmitButtonClick: React.MouseEventHandler<HTMLButtonElement> = () => formRef.current && formRef.current.submit(); |
| 94 | + |
| 95 | + const onSubmitFunc: onSubmitType = (data, event) => { |
| 96 | + beforeSubmit && beforeSubmit(data, event); |
| 97 | + submitMutation.mutate(data.formData || {}, { |
| 98 | + onSuccess: () => { |
| 99 | + addSnackbar(id ? '저장했습니다.' : '페이지를 생성했습니다.', 'success'); |
| 100 | + afterSubmit && afterSubmit(data, event); |
| 101 | + |
| 102 | + if (!id && data.formData?.id) navigate(`/${app}/${resource}/${data.formData?.id}`); |
| 103 | + }, |
| 104 | + onError: addErrorSnackbar, |
| 105 | + }) |
| 106 | + }; |
| 107 | + |
| 108 | + const onDeleteFunc = () => { |
| 109 | + if (window.confirm("정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.")) { |
| 110 | + deleteMutation.mutate(undefined, { |
| 111 | + onSuccess: () => { |
| 112 | + addSnackbar('삭제했습니다.', 'success'); |
| 113 | + navigate(`/${app}/${resource}`); |
| 114 | + }, |
| 115 | + onError: addErrorSnackbar, |
| 116 | + }); |
| 117 | + } |
| 118 | + }; |
| 119 | + |
| 120 | + const goToCreateNew = () => navigate(`/${app}/${resource}/create`) |
| 121 | + |
| 122 | + const writableSchema = Common.Utils.filterWritablePropertiesInJsonSchema(schemaInfo.schema); |
| 123 | + const readOnlySchema = Common.Utils.filterReadOnlyPropertiesInJsonSchema(schemaInfo.schema); |
| 124 | + const uiSchema: UiSchema = schemaInfo.ui_schema; |
| 125 | + const disabled = createMutation.isPending || modifyMutation.isPending || deleteMutation.isPending; |
| 126 | + const title = `${app.toUpperCase()} > ${resource.toUpperCase()} > ${id ? "편집: " + id : "새 객체 추가"}` |
| 127 | + |
| 128 | + |
| 129 | + const handleCtrlSAction: (this: GlobalEventHandlers, ev: KeyboardEvent) => any = (event) => { |
| 130 | + if (event.key === "s" && (event.ctrlKey || event.metaKey)) { |
| 131 | + console.log("Ctrl+S pressed, executing save action"); |
| 132 | + event.preventDefault(); |
| 133 | + event.stopPropagation(); |
| 134 | + formRef.current?.submit(); |
| 135 | + } |
| 136 | + }; |
| 137 | + |
| 138 | + React.useEffect( |
| 139 | + () => { |
| 140 | + document.addEventListener("keydown", handleCtrlSAction); |
| 141 | + return () => { |
| 142 | + console.log("Removing event listener for Ctrl+S action"); |
| 143 | + document.removeEventListener("keydown", handleCtrlSAction); |
| 144 | + } |
| 145 | + }, [formRef.current] |
| 146 | + ); |
| 147 | + |
| 148 | + if (formDataState === undefined) |
| 149 | + return <CircularProgress />; |
| 150 | + |
| 151 | + return <Box sx={{ flexGrow: 1, width: "100%", minHeight: "100%" }}> |
| 152 | + <Typography variant="h5">{title}</Typography> |
| 153 | + { |
| 154 | + id && <> |
| 155 | + <Table> |
| 156 | + <TableHead> |
| 157 | + <TableRow> |
| 158 | + <TableCell>필드</TableCell> |
| 159 | + <TableCell>값</TableCell> |
| 160 | + </TableRow> |
| 161 | + </TableHead> |
| 162 | + <TableBody> |
| 163 | + { |
| 164 | + Object.keys(readOnlySchema.properties || {}).map((key) => ( |
| 165 | + <TableRow key={key}> |
| 166 | + <TableCell>{key}</TableCell> |
| 167 | + <TableCell> |
| 168 | + <ReadOnlyValueField name={key} value={formDataState?.[key]} uiSchema={uiSchema} /> |
| 169 | + </TableCell> |
| 170 | + </TableRow> |
| 171 | + )) |
| 172 | + } |
| 173 | + </TableBody> |
| 174 | + </Table> |
| 175 | + <br /> |
| 176 | + </> |
| 177 | + } |
| 178 | + <MuiForm |
| 179 | + ref={formRef} |
| 180 | + schema={writableSchema} |
| 181 | + uiSchema={{ ...uiSchema, "ui:submitButtonOptions": { norender: true } }} |
| 182 | + validator={customizeValidator({ AjvClass: AjvDraft04 })} |
| 183 | + formData={formDataState} |
| 184 | + liveValidate |
| 185 | + focusOnFirstError |
| 186 | + formContext={{ readonlyAsDisabled: true }} |
| 187 | + onChange={({ formData }) => setFormDataState(formData)} |
| 188 | + onSubmit={onSubmitFunc} |
| 189 | + disabled={disabled} |
| 190 | + showErrorList={false} |
| 191 | + fields={{ file: FileField }} |
| 192 | + /> |
| 193 | + {children} |
| 194 | + <Stack direction="row" spacing={2} sx={{ justifyContent: "flex-end" }}> |
| 195 | + { |
| 196 | + id |
| 197 | + ? <> |
| 198 | + {(extraActions || []).map((p, i) => <Button key={i} {...p} />)} |
| 199 | + <Button variant="outlined" color="info" onClick={goToCreateNew} disabled={disabled} startIcon={<Add />}>새 객체 추가</Button> |
| 200 | + {!notDeletable && <Button variant="outlined" color="error" onClick={onDeleteFunc} disabled={disabled} startIcon={<Delete />}>삭제</Button>} |
| 201 | + {!notModifiable && <Button variant="contained" color="primary" onClick={onSubmitButtonClick} disabled={disabled} startIcon={<Edit />}>수정</Button>} |
| 202 | + </> |
| 203 | + : <Button type="submit" variant="contained" color="primary" onClick={onSubmitButtonClick} disabled={disabled} startIcon={<Add />}>새 객체 추가</Button> |
| 204 | + } |
| 205 | + </Stack> |
| 206 | + </Box> |
| 207 | + } |
| 208 | + ) |
| 209 | +) |
| 210 | + |
| 211 | +export const AdminEditor: React.FC<AppResourceIdType & AdminEditorPropsType> = (props) => <BackendAdminSignInGuard> |
| 212 | + <InnerAdminEditor {...props} /> |
| 213 | +</BackendAdminSignInGuard> |
| 214 | + |
| 215 | +export const AdminEditorCreateRoutePage: React.FC<AppResourceType & AdminEditorPropsType> = (props) => <AdminEditor {...props} /> |
| 216 | + |
| 217 | +export const AdminEditorModifyRoutePage: React.FC<AppResourceType & AdminEditorPropsType> = Suspense.with( |
| 218 | + { fallback: <CircularProgress /> }, |
| 219 | + (props) => { |
| 220 | + const { id } = useParams<{ id?: string }>(); |
| 221 | + return <AdminEditor {...props} id={id} /> |
| 222 | + } |
| 223 | +) |
0 commit comments