Skip to content

Commit c7c1f8b

Browse files
committed
feat: 발표의 발표자 설정 필드 추가
1 parent 436f8d5 commit c7c1f8b

File tree

4 files changed

+245
-5
lines changed

4 files changed

+245
-5
lines changed
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import * as Common from "@frontend/common";
2+
import { Autocomplete, Box, Button, Card, CardContent, CircularProgress, Stack, styled, Tab, Tabs, TextField, Typography } from "@mui/material";
3+
import { ErrorBoundary, Suspense } from "@suspensive/react";
4+
import { enqueueSnackbar, OptionsObject } from "notistack";
5+
import * as React from "react";
6+
import { useParams } from "react-router-dom";
7+
8+
import { AdminEditor } from "../../layouts/admin_editor";
9+
10+
const DUMMY_UUID = "00000000-0000-4000-8000-000000000000";
11+
12+
type enumItemType = { const: string | null; title: string };
13+
14+
type SpeakerSchemaType = {
15+
schema: {
16+
type: "object";
17+
properties: {
18+
id: { type: ["string", "null"]; format: "uuid"; readOnly: true };
19+
str_repr: { type: "string"; readOnly: true };
20+
user: { type: "integer"; oneOf: enumItemType[] };
21+
image: { type: ["string", "null"]; oneOf: enumItemType[] };
22+
biography_ko?: { type: ["string", "null"] };
23+
biography_en?: { type: ["string", "null"] };
24+
};
25+
required?: string[];
26+
$schema?: string;
27+
};
28+
ui_schema?: Record<string, { "ui:widget"?: string; "ui:field"?: string }>;
29+
translation_fields?: string[];
30+
};
31+
32+
type OnMemoeryPresentationSpeaker = {
33+
id?: string;
34+
trackId: string;
35+
presentation: string;
36+
user: string | null;
37+
image: string | null;
38+
biography_ko: string;
39+
biography_en: string;
40+
};
41+
42+
type PresentationSpeaker = Omit<OnMemoeryPresentationSpeaker, "trackId"> & {
43+
id: string;
44+
user: string;
45+
};
46+
47+
const MUIStyledFieldset = styled("fieldset")(({ theme }) => ({
48+
color: theme.palette.text.secondary,
49+
margin: 0,
50+
51+
border: `1px solid ${theme.palette.info}`,
52+
borderRadius: theme.shape.borderRadius,
53+
}));
54+
55+
const MDXRendererContainer = styled(Box)(({ theme }) => ({
56+
width: "50%",
57+
maxWidth: "50%",
58+
59+
"& .markdown-body": {
60+
width: "100%",
61+
p: { margin: theme.spacing(2, 0) },
62+
},
63+
}));
64+
65+
type PresentationSpeakerFormPropType = {
66+
schema: SpeakerSchemaType;
67+
disabled?: boolean;
68+
speaker: OnMemoeryPresentationSpeaker;
69+
onChange: (speaker: OnMemoeryPresentationSpeaker) => void;
70+
onRemove: (speaker: OnMemoeryPresentationSpeaker) => void;
71+
};
72+
73+
type PresentationSpeakerFormStateType = {
74+
tab: "ko" | "en";
75+
};
76+
77+
type AutoCompleteType = {
78+
name: string;
79+
value: string | null;
80+
label: string;
81+
};
82+
83+
const PresentationSpeakerForm: React.FC<PresentationSpeakerFormPropType> = ({ disabled, schema, speaker, onChange, onRemove }) => {
84+
const [formState, setFormState] = React.useState<PresentationSpeakerFormStateType>({ tab: "ko" });
85+
const setLanguage = (_: React.SyntheticEvent, tab: "ko" | "en") => setFormState((ps) => ({ ...ps, tab }));
86+
87+
const userOptions: AutoCompleteType[] = schema.schema.properties.user.oneOf.map((item) => ({
88+
name: "user",
89+
value: item.const || "",
90+
label: item.title,
91+
}));
92+
const currentSelectedUser = userOptions.find((u) => u.value === speaker.user?.toString());
93+
const imageOptions: AutoCompleteType[] = schema.schema.properties.image.oneOf.map((item) => ({
94+
name: "image",
95+
value: item.const || "",
96+
label: item.title,
97+
}));
98+
const currentSelectedImage = imageOptions.find((u) => u.value === speaker.image?.toString());
99+
console.log(userOptions, currentSelectedUser, speaker);
100+
101+
const bioField = formState.tab === "ko" ? "biography_ko" : "biography_en";
102+
const onSpeakerBioChange = (value?: string) => onChange({ ...speaker, [bioField]: value || "" });
103+
const onSpeakerChange = (fieldName: string) => (_: React.SyntheticEvent, selected: AutoCompleteType | null) => {
104+
onChange({ ...speaker, [fieldName]: selected?.value || "" });
105+
};
106+
const onSpeakerRemove = () => {
107+
if (window.confirm("발표자를 삭제하시겠습니까?")) onRemove(speaker);
108+
};
109+
110+
return (
111+
<Card>
112+
<CardContent>
113+
<Stack spacing={2}>
114+
<Autocomplete
115+
fullWidth
116+
defaultValue={currentSelectedUser}
117+
value={currentSelectedUser}
118+
onChange={onSpeakerChange("user")}
119+
inputValue={currentSelectedUser?.label || ""}
120+
options={userOptions}
121+
renderInput={(params) => <TextField {...params} label="발표자" />}
122+
/>
123+
<Autocomplete
124+
fullWidth
125+
defaultValue={currentSelectedImage}
126+
value={currentSelectedImage}
127+
inputValue={currentSelectedImage?.label || ""}
128+
options={imageOptions}
129+
renderInput={(params) => <TextField {...params} label="발표자 이미지" />}
130+
onChange={onSpeakerChange("image")}
131+
/>
132+
<Stack direction="row" spacing={2}>
133+
<Tabs orientation="vertical" onChange={setLanguage} value={formState.tab} scrollButtons={false}>
134+
<Tab value="ko" label="한국어" />
135+
<Tab value="en" label="영어" />
136+
</Tabs>
137+
<Stack direction="column" spacing={2} sx={{ width: "100%", maxWidth: "100%" }}>
138+
<MUIStyledFieldset>
139+
<Typography variant="subtitle2" component="legend" children="발표자 소개" />
140+
<Stack direction="row" spacing={2}>
141+
<Box sx={{ width: "50%", maxWidth: "50%" }}>
142+
<Common.Components.MarkdownEditor disabled={disabled} value={speaker[bioField]} name={bioField} onChange={onSpeakerBioChange} />
143+
</Box>
144+
<MDXRendererContainer>
145+
<Common.Components.MDXRenderer text={speaker[bioField]} format="md" />
146+
</MDXRendererContainer>
147+
</Stack>
148+
</MUIStyledFieldset>
149+
</Stack>
150+
</Stack>
151+
<Button variant="outlined" color="error" onClick={onSpeakerRemove} children="발표자 삭제" />
152+
</Stack>
153+
</CardContent>
154+
</Card>
155+
);
156+
};
157+
158+
type PresentationEditorStateType = {
159+
speakers: OnMemoeryPresentationSpeaker[];
160+
};
161+
162+
export const AdminPresentationEditor: React.FC = ErrorBoundary.with(
163+
{ fallback: Common.Components.ErrorFallback },
164+
Suspense.with({ fallback: <CircularProgress /> }, () => {
165+
const { id } = useParams<{ id?: string }>();
166+
167+
const addSnackbar = (c: string | React.ReactNode, variant: OptionsObject["variant"]) =>
168+
enqueueSnackbar(c, { variant, anchorOrigin: { vertical: "bottom", horizontal: "center" } });
169+
170+
const backendAdminAPIClient = Common.Hooks.BackendAdminAPI.useBackendAdminClient();
171+
const speakerQueryParams = [backendAdminAPIClient, "event", "presentationspeaker"] as const;
172+
const presentation = id || DUMMY_UUID;
173+
const speakerCreateMutation = Common.Hooks.BackendAdminAPI.useCreateMutation<OnMemoeryPresentationSpeaker>(...speakerQueryParams);
174+
const speakerUpdateMutation = Common.Hooks.BackendAdminAPI.useUpdatePreparedMutation<PresentationSpeaker>(...speakerQueryParams);
175+
const speakerDeleteMutation = Common.Hooks.BackendAdminAPI.useRemovePreparedMutation(...speakerQueryParams);
176+
const { data: speakerJsonSchema } = Common.Hooks.BackendAdminAPI.useSchemaQuery(...speakerQueryParams);
177+
const { data: speakerInitialData } = Common.Hooks.BackendAdminAPI.useListQuery<PresentationSpeaker>(...speakerQueryParams, { presentation });
178+
const speakers = speakerInitialData.map((s) => ({ ...s, trackId: s.id || Math.random().toString(36).substring(2, 15) }));
179+
180+
const createEmptySpeaker = (): OnMemoeryPresentationSpeaker => ({
181+
trackId: Math.random().toString(36).substring(2, 15),
182+
presentation,
183+
user: null,
184+
image: null,
185+
biography_ko: "",
186+
biography_en: "",
187+
});
188+
189+
const [editorState, setEditorState] = React.useState<PresentationEditorStateType>({ speakers });
190+
const onSpeakerCreate = () => setEditorState((ps) => ({ ...ps, speakers: [...ps.speakers, createEmptySpeaker()] }));
191+
const onSpeakerRemove = (oldSpeaker: OnMemoeryPresentationSpeaker) =>
192+
setEditorState((ps) => ({ ...ps, speakers: ps.speakers.filter((s) => s.trackId !== oldSpeaker.trackId) }));
193+
const onSpeakerChange = (newSpeaker: OnMemoeryPresentationSpeaker) =>
194+
setEditorState((ps) => ({ ...ps, speakers: ps.speakers.map((s) => (s.trackId === newSpeaker.trackId ? newSpeaker : s)) }));
195+
196+
const onSpeakerSubmit = () => {
197+
if (!id) return;
198+
199+
addSnackbar("발표자 정보를 저장하는 중입니다...", "info");
200+
const newSpeakers = editorState.speakers;
201+
const editorSpeakerIds = newSpeakers.filter((s) => s.id).map((s) => s.id!);
202+
const deletedSpeakerIds = speakerInitialData.filter((s) => !editorSpeakerIds.includes(s.id)).map((s) => s.id!);
203+
204+
const deleteMut = deletedSpeakerIds.map((id) => speakerDeleteMutation.mutateAsync(id));
205+
const createMut = newSpeakers.filter((s) => s.id === undefined).map((s) => speakerCreateMutation.mutateAsync(s));
206+
const updateMut = newSpeakers.filter((s) => s.id !== undefined).map((s) => speakerUpdateMutation.mutateAsync(s as PresentationSpeaker));
207+
Promise.all([...deleteMut, ...createMut, ...updateMut]).then(() => addSnackbar("발표자 정보가 저장되었습니다.", "success"));
208+
};
209+
210+
return (
211+
<AdminEditor app="event" resource="presentation" id={id} afterSubmit={onSpeakerSubmit}>
212+
{id ? (
213+
<Stack sx={{ mb: 2 }} spacing={2}>
214+
<Typography variant="h6">발표자 정보</Typography>
215+
<Stack spacing={2}>
216+
{editorState.speakers.map((s) => (
217+
<PresentationSpeakerForm
218+
key={s.id}
219+
schema={speakerJsonSchema as SpeakerSchemaType}
220+
speaker={s}
221+
onChange={onSpeakerChange}
222+
onRemove={onSpeakerRemove}
223+
/>
224+
))}
225+
<Button variant="outlined" onClick={onSpeakerCreate} children="발표자 추가" />
226+
</Stack>
227+
</Stack>
228+
) : (
229+
<Stack>
230+
<Typography variant="h6">발표자 정보</Typography>
231+
<Typography variant="body1">발표자를 추가하려면 발표를 먼저 저장하세요.</Typography>
232+
</Stack>
233+
)}
234+
</AdminEditor>
235+
);
236+
})
237+
);

apps/pyconkr-admin/src/routes.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { AccountManagementPage } from "./components/pages/account/manage";
2222
import { SignInPage } from "./components/pages/account/sign_in";
2323
import { PublicFileUploadPage } from "./components/pages/file/upload";
2424
import { AdminCMSPageEditor } from "./components/pages/page/editor";
25+
import { AdminPresentationEditor } from "./components/pages/presentation/editor";
2526
import { SiteMapList } from "./components/pages/sitemap/list";
2627
import { AdminUserExtEditor } from "./components/pages/user/editor";
2728

@@ -194,4 +195,6 @@ export const RegisteredRoutes = {
194195
"/cms/sitemap": <SiteMapList />,
195196
"/cms/sitemap/create": <SiteMapList />,
196197
"/cms/sitemap/:id": <SiteMapList />,
198+
"/event/presentation/create": <AdminPresentationEditor />,
199+
"/event/presentation/:id": <AdminPresentationEditor />,
197200
};

packages/common/src/apis/admin_api.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ namespace BackendAdminAPIs {
2323
client.delete<void, void>(`v1/admin-api/user/userext/${id}/password/`);
2424

2525
export const list =
26-
<T>(client: BackendAPIClient, app: string, resource: string) =>
26+
<T>(client: BackendAPIClient, app: string, resource: string, params?: Record<string, string>) =>
2727
() =>
28-
client.get<T[]>(`v1/admin-api/${app}/${resource}/`);
28+
client.get<T[]>(`v1/admin-api/${app}/${resource}/`, { params });
2929

3030
export const retrieve =
3131
<T>(client: BackendAPIClient, app: string, resource: string, id: string) =>

packages/common/src/hooks/useAdminAPI.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,10 @@ namespace BackendAdminAPIHooks {
6363
queryFn: BackendAdminAPIs.schema(client, app, resource),
6464
});
6565

66-
export const useListQuery = <T>(client: BackendAPIClient, app: string, resource: string) =>
66+
export const useListQuery = <T>(client: BackendAPIClient, app: string, resource: string, params?: Record<string, string>) =>
6767
useSuspenseQuery({
68-
queryKey: [...QUERY_KEYS.ADMIN_LIST, app, resource],
69-
queryFn: BackendAdminAPIs.list<T>(client, app, resource),
68+
queryKey: [...QUERY_KEYS.ADMIN_LIST, app, resource, JSON.stringify(params)],
69+
queryFn: BackendAdminAPIs.list<T>(client, app, resource, params),
7070
});
7171

7272
export const useRetrieveQuery = <T>(client: BackendAPIClient, app: string, resource: string, id: string) =>

0 commit comments

Comments
 (0)