Skip to content

Commit 5cab899

Browse files
committed
feat: 업로드 프로필 및 필드 속성 추가로 파일 업로드 기능 개선
1 parent d246dd3 commit 5cab899

11 files changed

Lines changed: 131 additions & 63 deletions

File tree

apps/pyconkr-admin/src/components/elements/choice_picker.tsx

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
import { ErrorBoundary, Suspense } from "@suspensive/react";
3939
import { DragEvent, FC, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
4040

41+
import { DEFAULT_UPLOAD_PROFILE, UPLOAD_PROFILES, UploadProfileName } from "@apps/pyconkr-admin/consts/file_extensions";
4142
import { addErrorSnackbar, addSnackbar } from "@apps/pyconkr-admin/utils/snackbar";
4243

4344
export type ChoicePickerOption = {
@@ -52,6 +53,7 @@ type BaseProps = {
5253
options?: ChoicePickerOption[]; // enum(비관계형 choices) 폴백. source 지정 시 selectables 결과로 대체됨
5354
source?: { app: string; resource: string };
5455
optionFilter?: (option: ChoicePickerOption) => boolean; // source 조회 결과 사전 필터 (예: 확장자)
56+
uploadProfile?: UploadProfileName;
5557
required?: boolean;
5658
disabled?: boolean;
5759
};
@@ -172,6 +174,7 @@ type ImplProps = {
172174
metaSchema?: ChoiceMetaSchema;
173175
persistKey?: string;
174176
publicFile?: boolean;
177+
uploadProfile?: UploadProfileName;
175178
required?: boolean;
176179
disabled?: boolean;
177180
multiple: boolean;
@@ -186,6 +189,7 @@ const ChoicePickerImpl: FC<ImplProps> = ({
186189
metaSchema,
187190
persistKey,
188191
publicFile,
192+
uploadProfile,
189193
required,
190194
disabled,
191195
multiple,
@@ -384,7 +388,7 @@ const ChoicePickerImpl: FC<ImplProps> = ({
384388
) : null}
385389
<DialogContent dividers sx={{ height: "80vh", display: "flex", flexDirection: "column" }}>
386390
{publicFile && tab === 1 ? (
387-
<PublicFileUploadPanel onUploaded={commitFromTab} />
391+
<PublicFileUploadPanel profile={uploadProfile ?? DEFAULT_UPLOAD_PROFILE} onUploaded={commitFromTab} />
388392
) : (
389393
<Stack spacing={1.5} sx={{ flex: 1, minHeight: 0 }}>
390394
<TextField autoFocus size="small" fullWidth label="이름 검색" value={titleQuery} onChange={(e) => setTitleQuery(e.target.value)} />
@@ -546,6 +550,7 @@ const ResolvedChoicePicker: FC<ChoicePickerProps & { metaSchema?: ChoiceMetaSche
546550
metaSchema: props.metaSchema,
547551
persistKey: props.persistKey,
548552
publicFile: props.publicFile,
553+
uploadProfile: props.uploadProfile,
549554
required: props.required,
550555
disabled: props.disabled,
551556
};
@@ -646,7 +651,8 @@ const ImagePreview: FC<{ id: string }> = ErrorBoundary.with(
646651
)
647652
);
648653

649-
const PublicFileUploadPanel: FC<{ onUploaded: (id: string) => void }> = ({ onUploaded }) => {
654+
const PublicFileUploadPanel: FC<{ profile: UploadProfileName; onUploaded: (id: string) => void }> = ({ profile, onUploaded }) => {
655+
const { accept, description, isAllowed } = UPLOAD_PROFILES[profile];
650656
const client = useBackendAdminClient();
651657
const upload = useUploadPublicFileMutation(client);
652658
const inputRef = useRef<HTMLInputElement>(null);
@@ -664,18 +670,21 @@ const PublicFileUploadPanel: FC<{ onUploaded: (id: string) => void }> = ({ onUpl
664670
return () => URL.revokeObjectURL(url);
665671
}, [file]);
666672

667-
const pickFile = useCallback((f: File | null | undefined) => {
668-
if (!f) return;
669-
if (f.size === 0) {
670-
addSnackbar("선택한 파일의 크기가 0입니다.", "error");
671-
return;
672-
}
673-
if (!(f.type.startsWith("image/") || f.type === "application/json")) {
674-
addSnackbar("이미지 또는 JSON 파일만 업로드할 수 있습니다.", "error");
675-
return;
676-
}
677-
setFile(f);
678-
}, []);
673+
const pickFile = useCallback(
674+
(f: File | null | undefined) => {
675+
if (!f) return;
676+
if (f.size === 0) {
677+
addSnackbar("선택한 파일의 크기가 0입니다.", "error");
678+
return;
679+
}
680+
if (!isAllowed(f)) {
681+
addSnackbar(description, "error");
682+
return;
683+
}
684+
setFile(f);
685+
},
686+
[description, isAllowed]
687+
);
679688

680689
const onDrop = (e: DragEvent<HTMLDivElement>) => {
681690
e.preventDefault();
@@ -700,7 +709,7 @@ const PublicFileUploadPanel: FC<{ onUploaded: (id: string) => void }> = ({ onUpl
700709
<input
701710
ref={inputRef}
702711
type="file"
703-
accept="image/*,application/json"
712+
accept={accept}
704713
hidden
705714
onChange={(e) => {
706715
pickFile(e.target.files?.[0]);
@@ -741,7 +750,7 @@ const PublicFileUploadPanel: FC<{ onUploaded: (id: string) => void }> = ({ onUpl
741750
클릭해서 파일을 선택하거나 이 영역에 끌어다 놓으세요.
742751
</Typography>
743752
<Typography variant="caption" color="text.secondary" component="p">
744-
이미지 또는 JSON 파일만 업로드할 수 있습니다.
753+
{description}
745754
</Typography>
746755
{file && (
747756
<Typography variant="body2" sx={{ mt: 1 }}>

apps/pyconkr-admin/src/components/elements/choice_picker_widget.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import { EnumOptionsType, WidgetProps } from "@rjsf/utils";
22
import { FC, useMemo } from "react";
33

44
import { ChoicePicker, ChoicePickerOption } from "@apps/pyconkr-admin/components/elements/choice_picker";
5+
import { UploadProfileName } from "@apps/pyconkr-admin/consts/file_extensions";
56

67
export const ChoicePickerWidget: FC<WidgetProps> = (props) => {
7-
const { id, value, label, schema, required, disabled, readonly, options, onChange } = props;
8+
const { id, name, value, label, schema, required, disabled, readonly, options, formContext, onChange } = props;
89
const choiceApp = options.choiceApp as string | undefined;
910
const choiceResource = options.choiceResource as string | undefined;
11+
const { fieldProps } = (formContext ?? {}) as { fieldProps?: Record<string, { uploadProfile?: UploadProfileName }> };
1012
const source = useMemo(() => (choiceApp && choiceResource ? { app: choiceApp, resource: choiceResource } : undefined), [choiceApp, choiceResource]);
1113

1214
const pickerOptions = useMemo<ChoicePickerOption[]>(() => {
@@ -21,6 +23,7 @@ export const ChoicePickerWidget: FC<WidgetProps> = (props) => {
2123
label={label || schema.title}
2224
source={source}
2325
options={pickerOptions}
26+
uploadProfile={fieldProps?.[name]?.uploadProfile}
2427
value={value ?? null}
2528
required={required}
2629
disabled={disabled || readonly}

apps/pyconkr-admin/src/components/layouts/admin_editor.tsx

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,14 @@ import {
4545
useState,
4646
} from "react";
4747
import { Link, useNavigate, useParams } from "react-router-dom";
48-
import { isArray, isNonNullish, isObjectType, isString } from "remeda";
48+
import { isArray, isObjectType, isString } from "remeda";
4949

5050
import { BackendAdminSignInGuard } from "@apps/pyconkr-admin/components/elements/admin_signin_guard";
5151
import { ChoicePicker } from "@apps/pyconkr-admin/components/elements/choice_picker";
5252
import { ChoicePickerWidget } from "@apps/pyconkr-admin/components/elements/choice_picker_widget";
5353
import { ColorPickerWidget } from "@apps/pyconkr-admin/components/elements/color_picker_widget";
5454
import { ErrorFallback } from "@apps/pyconkr-admin/components/elements/error_fallback";
55-
import { IMAGE_FILE_EXTENSIONS } from "@apps/pyconkr-admin/consts/file_extensions";
55+
import { IMAGE_FILE_EXTENSIONS, UploadProfileName } from "@apps/pyconkr-admin/consts/file_extensions";
5656
import { addErrorSnackbar, addSnackbar } from "@apps/pyconkr-admin/utils/snackbar";
5757

5858
type EditorFormDataEventType = IChangeEvent<Record<string, string>, RJSFSchema, { [k in string]: unknown }>;
@@ -64,9 +64,19 @@ export type FieldLinkTarget = {
6464
app: string;
6565
resource: string;
6666
};
67+
export type AdminEditorFieldProps = {
68+
value?: unknown;
69+
hidden?: boolean;
70+
/**
71+
* Render an "open in new tab" link next to the value pointing at the editor route for the
72+
* referenced object. Currently applies to the read-only field table only.
73+
* The field's current value is used as the target id.
74+
*/
75+
link?: FieldLinkTarget;
76+
uploadProfile?: UploadProfileName;
77+
};
78+
6779
type AdminEditorPropsType = PropsWithChildren<{
68-
hidingFields?: string[];
69-
context?: Record<string, unknown>;
7080
onCreated?: (data: Record<string, string>) => void;
7181
onClose?: () => void;
7282
beforeSubmit?: onSubmitType;
@@ -75,12 +85,7 @@ type AdminEditorPropsType = PropsWithChildren<{
7585
notDeletable?: boolean;
7686
extraReadOnlyData?: Record<string, ReactNode>;
7787
extraActions?: ButtonProps[];
78-
/**
79-
* For each field, render an "open in new tab" link next to the value pointing at the editor route
80-
* for that field's referenced object. Currently applies to the read-only field table only.
81-
* The field's current value is used as the target id.
82-
*/
83-
fieldLinks?: Record<string, FieldLinkTarget>;
88+
fieldProps?: Record<string, AdminEditorFieldProps>;
8489
}>;
8590

8691
const processFile = (event: ChangeEvent<HTMLInputElement>) => {
@@ -269,8 +274,6 @@ const InnerAdminEditor: FC<AppResourceIdType & AdminEditorPropsType> = ErrorBoun
269274
app,
270275
resource,
271276
id,
272-
hidingFields,
273-
context,
274277
onCreated,
275278
onClose,
276279
beforeSubmit,
@@ -279,7 +282,7 @@ const InnerAdminEditor: FC<AppResourceIdType & AdminEditorPropsType> = ErrorBoun
279282
extraReadOnlyData,
280283
notModifiable,
281284
notDeletable,
282-
fieldLinks,
285+
fieldProps,
283286
children,
284287
}) => {
285288
const navigate = useNavigate();
@@ -303,18 +306,38 @@ const InnerAdminEditor: FC<AppResourceIdType & AdminEditorPropsType> = ErrorBoun
303306
const deleteMutation = useRemoveMutation(backendAdminClient, app, resource, id || "undefined");
304307
const submitMutation = id ? modifyMutation : createMutation;
305308

309+
const hiddenFields = useMemo(
310+
() =>
311+
new Set(
312+
Object.entries(fieldProps ?? {})
313+
.filter(([, { hidden }]) => hidden)
314+
.map(([fieldName]) => fieldName)
315+
),
316+
[fieldProps]
317+
);
318+
319+
const initialFieldValues = useMemo(
320+
() =>
321+
Object.fromEntries(
322+
Object.entries(fieldProps ?? {})
323+
.filter(([, { value }]) => value !== undefined)
324+
.map(([fieldName, { value }]) => [fieldName, value])
325+
) as Record<string, string>,
326+
[fieldProps]
327+
);
328+
306329
useEffect(() => {
307330
(async () => {
308331
if (!id) {
309-
setFormData((context ?? {}) as Record<string, string>);
332+
setFormData(initialFieldValues);
310333
return;
311334
}
312335

313336
const initialData = await retrieve<Record<string, string>>(backendAdminClient, app, resource, id)();
314-
setFormData({ ...initialData, ...context } as Record<string, string>);
337+
setFormData({ ...initialData, ...initialFieldValues });
315338
})();
316339
// eslint-disable-next-line react-hooks/exhaustive-deps
317-
}, [app, resource, id, context]);
340+
}, [app, resource, id, initialFieldValues]);
318341

319342
const onSubmitButtonClick: MouseEventHandler<HTMLButtonElement> = () => formRef.current && formRef.current.submit();
320343

@@ -352,9 +375,9 @@ const InnerAdminEditor: FC<AppResourceIdType & AdminEditorPropsType> = ErrorBoun
352375

353376
const goToCreateNew = () => navigate(`/${app}/${resource}/create`);
354377

355-
if (isNonNullish(hidingFields) && isObjectType(schemaInfo.schema.properties)) {
378+
if (hiddenFields.size && isObjectType(schemaInfo.schema.properties)) {
356379
schemaInfo.schema.properties = Object.entries(schemaInfo.schema.properties || {})
357-
.filter(([key]) => !hidingFields.includes(key))
380+
.filter(([key]) => !hiddenFields.has(key))
358381
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {} as RJSFSchema);
359382
}
360383

@@ -432,7 +455,7 @@ const InnerAdminEditor: FC<AppResourceIdType & AdminEditorPropsType> = ErrorBoun
432455
</TableHead>
433456
<TableBody>
434457
{Object.keys(readOnlySchema.properties || {}).map((key) => {
435-
const link = fieldLinks?.[key];
458+
const link = fieldProps?.[key]?.link;
436459
const value = languageFilteredFormData?.[key];
437460
const showLink = link && value !== null && value !== undefined && value !== "";
438461
const field = <ReadOnlyValueField name={key} value={value} uiSchema={uiSchema} />;
@@ -465,7 +488,7 @@ const InnerAdminEditor: FC<AppResourceIdType & AdminEditorPropsType> = ErrorBoun
465488
formData={languageFilteredFormData}
466489
liveValidate
467490
focusOnFirstError
468-
formContext={{ readonlyAsDisabled: true }}
491+
formContext={{ readonlyAsDisabled: true, fieldProps }}
469492
onChange={({ formData }) => appendFormDataState(formData)}
470493
onSubmit={onSubmitFunc}
471494
disabled={disabled}

apps/pyconkr-admin/src/components/pages/page/editor.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ export const AdminCMSPageEditor: FC = ErrorBoundary.with(
157157
app="cms"
158158
resource="page"
159159
id={id}
160-
context={id ? undefined : { show_bottom_sponsor_banner: true }}
160+
fieldProps={id ? undefined : { show_bottom_sponsor_banner: { value: true } }}
161161
extraActions={[openOnSiteButton]}
162162
afterSubmit={onSubmit}
163163
>

apps/pyconkr-admin/src/components/pages/presentation/editor.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,13 @@ export const AdminPresentationEditor: FC = ErrorBoundary.with(
390390
};
391391

392392
return (
393-
<AdminEditor app="event" resource="presentation" id={id} afterSubmit={onPresentationSubmit}>
393+
<AdminEditor
394+
app="event"
395+
resource="presentation"
396+
id={id}
397+
afterSubmit={onPresentationSubmit}
398+
fieldProps={{ public_slideshow_file: { uploadProfile: "slideshow" } }}
399+
>
394400
{id ? (
395401
<Stack sx={{ mb: 2 }} spacing={2}>
396402
<Fieldset legend="스케줄 정보">

apps/pyconkr-admin/src/components/pages/shop/category_group/editor.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -258,13 +258,7 @@ export const ShopCategoryGroupEditorPage: FC = () => {
258258
const { id } = useParams<{ id?: string }>();
259259

260260
return (
261-
<AdminEditor
262-
app="shop"
263-
resource="categorygroup"
264-
id={id}
265-
hidingFields={["categories"]}
266-
context={id ? undefined : ({ categories: [] } as unknown as Record<string, string>)}
267-
>
261+
<AdminEditor app="shop" resource="categorygroup" id={id} fieldProps={{ categories: { hidden: true, ...(id ? {} : { value: [] }) } }}>
268262
{id && <InnerChildCategoryList groupId={id} />}
269263
</AdminEditor>
270264
);

apps/pyconkr-admin/src/components/pages/sitemap/list.tsx

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,10 @@ const InnerSiteMapList: FC<InnerSiteMapListProps> = ErrorBoundary.with(
136136

137137
const disabled = deleteMutation.isPending;
138138
const editorId = state.editorSiteMapId === "add" ? undefined : state.editorSiteMapId;
139-
const editorContext = {
140-
domain_group: domainGroupId,
141-
...(state.parentSiteMapId ? { parent_sitemap: state.parentSiteMapId } : {}),
139+
const editorFieldProps = {
140+
domain_group: { value: domainGroupId, hidden: true },
141+
parent_sitemap: { hidden: true, ...(state.parentSiteMapId ? { value: state.parentSiteMapId } : {}) },
142+
order: { hidden: true },
142143
};
143144

144145
const resetFlatSiteMap = () => setState((ps) => ({ ...ps, flatSiteMap: data }));
@@ -244,16 +245,7 @@ const InnerSiteMapList: FC<InnerSiteMapListProps> = ErrorBoundary.with(
244245
<Node node={nestedSiteMap} index={[0]} parentRoute="" depth={0} />
245246
</Stack>
246247
<Box sx={{ flexGrow: 1, width: "60%", height: "100%" }}>
247-
{state.editorSiteMapId && (
248-
<AdminEditor
249-
app="cms"
250-
resource="sitemap"
251-
id={editorId}
252-
onClose={closeEditor}
253-
context={editorContext}
254-
hidingFields={["domain_group", "parent_sitemap", "order"]}
255-
/>
256-
)}
248+
{state.editorSiteMapId && <AdminEditor app="cms" resource="sitemap" id={editorId} onClose={closeEditor} fieldProps={editorFieldProps} />}
257249
</Box>
258250
</Stack>
259251
</>

apps/pyconkr-admin/src/components/pages/user/editor.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export const AdminUserExtEditor: FC = ErrorBoundary.with(
104104
app="user"
105105
resource="userext"
106106
id={id}
107-
hidingFields={["email_addresses", "social_accounts"]}
107+
fieldProps={{ email_addresses: { hidden: true }, social_accounts: { hidden: true } }}
108108
extraActions={[resetUserPasswordButton]}
109109
onCreated={onCreated}
110110
beforeSubmit={stripNestedFromSubmit}

apps/pyconkr-admin/src/components/pages/user/merge/detail.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,12 @@ import { addErrorSnackbar, addSnackbar } from "@apps/pyconkr-admin/utils/snackba
2424

2525
import { MergeDirection, MergedObjectsTable } from "./components";
2626

27-
const HIDDEN_FIELDS = ["source", "target", "merged_objects", "reverted_at", "updated_at", "updated_by", "deleted_at", "deleted_by", "str_repr"];
27+
const HIDDEN_FIELD_PROPS = Object.fromEntries(
28+
["source", "target", "merged_objects", "reverted_at", "updated_at", "updated_by", "deleted_at", "deleted_by", "str_repr"].map((fieldName) => [
29+
fieldName,
30+
{ hidden: true },
31+
])
32+
);
2833

2934
const InnerAdminUserMergeDetail: FC<{ id: string }> = ({ id }) => {
3035
const client = useBackendAdminClient();
@@ -72,7 +77,7 @@ const InnerAdminUserMergeDetail: FC<{ id: string }> = ({ id }) => {
7277
id={id}
7378
notModifiable
7479
notDeletable
75-
hidingFields={HIDDEN_FIELDS}
80+
fieldProps={HIDDEN_FIELD_PROPS}
7681
extraActions={extraActions}
7782
extraReadOnlyData={extraReadOnlyData}
7883
>

0 commit comments

Comments
 (0)