diff --git a/apps/pyconkr-admin/src/components/elements/admin_list_filter.tsx b/apps/pyconkr-admin/src/components/elements/admin_list_filter.tsx index e24ad6c..c2ba28f 100644 --- a/apps/pyconkr-admin/src/components/elements/admin_list_filter.tsx +++ b/apps/pyconkr-admin/src/components/elements/admin_list_filter.tsx @@ -1,7 +1,59 @@ import { ChoicesResponse, OpenAPIParameterSchema } from "@frontend/common/schemas/backendAdminAPI"; import { Add, Clear, FilterList, RestartAlt } from "@mui/icons-material"; -import { Box, Button, Chip, FormControl, IconButton, InputLabel, MenuItem, Select, Stack, TextField } from "@mui/material"; -import { FC, useEffect, useState } from "react"; +import { + Autocomplete, + Box, + Button, + Checkbox, + Chip, + FormControl, + FormControlLabel, + IconButton, + InputLabel, + MenuItem, + Select, + Stack, + Switch, + TextField, +} from "@mui/material"; +import { FC, useEffect, useMemo, useState } from "react"; + +import { AdminFilterFieldset } from "@apps/pyconkr-admin/components/elements/admin_filter_fieldset"; + +// django-filter range/lookup suffixes — params sharing a root after stripping these belong together +// (e.g. date_joined_after + date_joined_before → "date_joined", price_min + price_max → "price"). +const RANGE_SUFFIXES = ["_after", "_before", "_min", "_max", "_gte", "_lte"]; +const SINGLETON_GROUP_LABEL = "기타"; + +const getGroupKey = (name: string): string => { + for (const suffix of RANGE_SUFFIXES) { + if (name.endsWith(suffix) && name.length > suffix.length) return name.slice(0, -suffix.length); + } + // Fall back to the first underscore segment — groups user/user_email/user_username under "user", + // category/category_group/category_id under "category", etc. + const firstUnderscore = name.indexOf("_"); + return firstUnderscore === -1 ? name : name.slice(0, firstUnderscore); +}; + +type FilterGroup = { label: string; params: OpenAPIParameterSchema[] }; + +const groupParameters = (parameters: OpenAPIParameterSchema[]): FilterGroup[] => { + const groups = new Map(); + for (const param of parameters) { + const key = getGroupKey(param.name); + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(param); + } + + const multi: FilterGroup[] = []; + const singletons: OpenAPIParameterSchema[] = []; + for (const [key, params] of groups) { + if (params.length >= 2) multi.push({ label: key, params }); + else singletons.push(...params); + } + if (singletons.length > 0) multi.push({ label: SINGLETON_GROUP_LABEL, params: singletons }); + return multi; +}; type AdminListFilterProps = { parameters: OpenAPIParameterSchema[]; values: Record; @@ -11,6 +63,7 @@ type AdminListFilterProps = { export const AdminListFilter: FC = ({ parameters, values, choices, onApply }) => { const [localValues, setLocalValues] = useState>(values); + const groupedParameters = useMemo(() => groupParameters(parameters), [parameters]); useEffect(() => { setLocalValues(values); @@ -39,15 +92,19 @@ export const AdminListFilter: FC = ({ parameters, values, 필터 - - {parameters.map((param) => ( - + + {groupedParameters.map((group) => ( + + {group.params.map((param) => ( + + ))} + ))} @@ -77,22 +134,25 @@ const FilterField: FC = ({ param, value, choices, onChange }) if (schema?.type === "array") return ; if (schema?.enum) return ; + if (schema?.type === "boolean") return ; + if (schema?.type === "string" && (schema?.format === "date-time" || schema?.format === "date")) { + return ; + } if (choices && choices.length > 0) { + const options = choices.map((c) => ({ value: c.const ?? "", label: c.title })); + const currentOption = options.find((opt) => opt.value === value) ?? null; return ( - - {name} - - + onChange(name, newOption?.value ?? "")} + getOptionLabel={(opt) => opt.label || String(opt.value)} + isOptionEqualToValue={(opt, val) => opt.value === val.value} + renderInput={(params) => } + /> ); } @@ -112,6 +172,59 @@ const FilterField: FC = ({ param, value, choices, onChange }) ); }; +type DateFilterFieldProps = { + name: string; + format: "date" | "date-time"; + value: string; + onChange: (name: string, value: string) => void; +}; + +// HTML5 datetime-local / date inputs expect specific local formats. Backend values may include seconds +// or a timezone (e.g. "2024-01-15T10:30:00Z") — trim to the first 16 chars so the input still displays. +const normalizeDateValue = (value: string, format: "date" | "date-time"): string => { + if (!value) return ""; + if (format === "date") return value.slice(0, 10); + const m = value.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})/); + return m ? m[1] : value; +}; + +const DateFilterField: FC = ({ name, format, value, onChange }) => ( + onChange(name, e.target.value)} + size="small" + slotProps={{ inputLabel: { shrink: true } }} + sx={{ minWidth: 200 }} + /> +); + +type BooleanFilterFieldProps = { + name: string; + value: string; + onChange: (name: string, value: string) => void; +}; + +const BooleanFilterField: FC = ({ name, value, onChange }) => { + const active = value === "true" || value === "false"; + const isTrue = value === "true"; + return ( + + onChange(name, e.target.checked ? "true" : "")} />} + label={name} + /> + {active && ( + onChange(name, e.target.checked ? "true" : "false")} />} + label={isTrue ? "true" : "false"} + /> + )} + + ); +}; + type EnumFilterFieldProps = { name: string; options: string[]; @@ -161,16 +274,27 @@ type ArrayFilterFieldProps = { }; const ArrayFilterField: FC = ({ name, items, value, onChange }) => { - const values = value ? value.split(",") : []; + const [values, setValues] = useState(value ? value.split(",") : []); + useEffect(() => { + setValues((prev) => { + const prevNonEmpty = prev.filter((v) => v !== "").join(","); + // Ignore re-emissions of our own non-empty subset; only resync on external changes (clear, etc.) + if (prevNonEmpty === (value ?? "")) return prev; + return value ? value.split(",") : []; + }); + }, [value]); - const updateValues = (newValues: string[]) => onChange(name, newValues.filter((v) => v !== "").join(",")); - const handleAdd = () => updateValues([...values, ""]); - const handleRemove = (index: number) => updateValues(values.filter((_, i) => i !== index)); + const update = (newValues: string[]) => { + setValues(newValues); + onChange(name, newValues.filter((v) => v !== "").join(",")); + }; + const handleAdd = () => update([...values, ""]); + const handleRemove = (index: number) => update(values.filter((_, i) => i !== index)); const handleItemChange = (index: number, newValue: string) => { const newValues = [...values]; newValues[index] = newValue; - updateValues(newValues); + update(newValues); }; const inputType = items?.type === "integer" || items?.type === "number" ? "number" : "text"; diff --git a/apps/pyconkr-admin/src/components/elements/autocomplete_select_widget.tsx b/apps/pyconkr-admin/src/components/elements/autocomplete_select_widget.tsx new file mode 100644 index 0000000..a2f7347 --- /dev/null +++ b/apps/pyconkr-admin/src/components/elements/autocomplete_select_widget.tsx @@ -0,0 +1,45 @@ +import { Autocomplete, TextField } from "@mui/material"; +import { EnumOptionsType, WidgetProps } from "@rjsf/utils"; +import { FC, useMemo } from "react"; + +type FormContextWithChoices = { + choicesData?: Record; +}; + +export const AutocompleteSelectWidget: FC = (props) => { + const { id, value, label, schema, required, disabled, readonly, autofocus, placeholder, options, onChange, onBlur, onFocus, formContext } = props; + + // RJSF strips uiSchema enumOptions when the schema has no enum/oneOf, so we read choices from + // formContext and rebuild enumOptions here. fieldName is derived from the RJSF id (root_). + const enumOptions = useMemo(() => { + const fromRjsf = options.enumOptions as EnumOptionsType[] | undefined; + if (fromRjsf && fromRjsf.length > 0) return fromRjsf; + const fieldName = id.replace(/^root_/, ""); + const items = (formContext as FormContextWithChoices | undefined)?.choicesData?.[fieldName]; + if (!items) return []; + const coerceToNumber = schema.type === "integer" || schema.type === "number"; + return items.map((i) => ({ value: coerceToNumber ? Number(i.const) : i.const, label: i.title || String(i.const) })); + }, [options.enumOptions, formContext, id, schema.type]); + + const currentOption = enumOptions.find((opt) => opt.value === value) ?? null; + + return ( + opt.label || String(opt.value)} + isOptionEqualToValue={(opt, val) => opt.value === val.value} + onChange={(_, newOption) => onChange(newOption?.value ?? undefined)} + onBlur={() => onBlur(id, value)} + onFocus={() => onFocus(id, value)} + disabled={disabled || readonly} + disableClearable={required} + autoHighlight + fullWidth + renderInput={(params) => ( + + )} + /> + ); +}; diff --git a/apps/pyconkr-admin/src/components/layouts/admin_editor.tsx b/apps/pyconkr-admin/src/components/layouts/admin_editor.tsx index d125136..28497e7 100644 --- a/apps/pyconkr-admin/src/components/layouts/admin_editor.tsx +++ b/apps/pyconkr-admin/src/components/layouts/admin_editor.tsx @@ -58,10 +58,11 @@ import { useRef, useState, } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { Link, useNavigate, useParams } from "react-router-dom"; import { addProp, isArray, isNonNullish, isObjectType, isString } from "remeda"; import { BackendAdminSignInGuard } from "@apps/pyconkr-admin/components/elements/admin_signin_guard"; +import { AutocompleteSelectWidget } from "@apps/pyconkr-admin/components/elements/autocomplete_select_widget"; import { ErrorFallback } from "@apps/pyconkr-admin/components/elements/error_fallback"; import { addErrorSnackbar, addSnackbar } from "@apps/pyconkr-admin/utils/snackbar"; @@ -70,6 +71,10 @@ type onSubmitType = (data: Record, event: FormEvent) => type AppResourceType = { app: string; resource: string }; type AppResourceIdType = AppResourceType & { id?: string }; +export type FieldLinkTarget = { + app: string; + resource: string; +}; type AdminEditorPropsType = PropsWithChildren<{ hidingFields?: string[]; context?: Record; @@ -80,6 +85,12 @@ type AdminEditorPropsType = PropsWithChildren<{ notModifiable?: boolean; notDeletable?: boolean; extraActions?: ButtonProps[]; + /** + * For each field, render an "open in new tab" link next to the value pointing at the editor route + * for that field's referenced object. Currently applies to the read-only field table only. + * The field's current value is used as the target id. + */ + fieldLinks?: Record; }>; const processFile = (event: ChangeEvent) => { @@ -268,7 +279,18 @@ const ReadOnlyValueField: FC<{ ); } - return value as string; + if (value === null || value === undefined) return ""; + if (typeof value === "object") { + return ( + + {JSON.stringify(value, null, 2)} + + ); + } + return String(value); }); type InnerAdminEditorStateType = { @@ -293,6 +315,7 @@ const InnerAdminEditor: FC = ErrorBoun extraActions, notModifiable, notDeletable, + fieldLinks, children, }) => { const navigate = useNavigate(); @@ -306,7 +329,10 @@ const InnerAdminEditor: FC = ErrorBoun const { data: schemaInfo } = useSchemaQuery(backendAdminClient, app, resource); const { data: choicesData } = useChoicesQuery(backendAdminClient, app, resource); - // Merge choices into schema for FK/M2M fields + // Merge choices into schema ONLY for M2M (array) fields — M2MSelect reads from schema.items.oneOf. + // Single-value FK choices are NOT merged here because AJV blows up when compiling a oneOf with + // thousands of const entries (e.g. user FK on EmailAddress). Those choices are attached to + // uiSchema below as enumOptions and rendered by AutocompleteSelectWidget instead. useMemo(() => { if (!choicesData || !schemaInfo.schema.properties) return; for (const [fieldName, items] of Object.entries(choicesData)) { @@ -314,8 +340,6 @@ const InnerAdminEditor: FC = ErrorBoun if (!prop) continue; if (prop.type === "array" && prop.items) { (prop.items as RJSFSchema).oneOf = items; - } else { - prop.oneOf = items; } } }, [choicesData, schemaInfo.schema]); @@ -396,7 +420,20 @@ const InnerAdminEditor: FC = ErrorBoun schemaInfo.translation_fields, selectedLanguage ); - const uiSchema: UiSchema = schemaInfo.ui_schema; + const baseUiSchema: UiSchema = schemaInfo.ui_schema; + // Force AutocompleteSelectWidget on single-value FKs that have choices. Choices themselves are + // passed through formContext (see below) rather than uiSchema, because RJSF overwrites + // ui:options.enumOptions with [] when the schema has no enum/oneOf. + const uiSchema: UiSchema = useMemo(() => { + if (!choicesData) return baseUiSchema; + const enriched: UiSchema = { ...baseUiSchema }; + for (const fieldName of Object.keys(choicesData)) { + const prop = (schemaInfo.schema.properties as Record | undefined)?.[fieldName]; + if (!prop || prop.type === "array") continue; + enriched[fieldName] = { ...(enriched[fieldName] ?? {}), "ui:widget": "autocomplete_select" }; + } + return enriched; + }, [choicesData, schemaInfo.schema, baseUiSchema]); const disabled = createMutation.isPending || modifyMutation.isPending || deleteMutation.isPending; const title = `${app.toUpperCase()} > ${resource.toUpperCase()} > ${id ? "편집: " + id : "새 객체 추가"}`; @@ -448,14 +485,18 @@ const InnerAdminEditor: FC = ErrorBoun - {Object.keys(readOnlySchema.properties || {}).map((key) => ( - - {key} - - - - - ))} + {Object.keys(readOnlySchema.properties || {}).map((key) => { + const link = fieldLinks?.[key]; + const value = languageFilteredFormData?.[key]; + const showLink = link && value !== null && value !== undefined && value !== ""; + const field = ; + return ( + + {key} + {showLink ? {field} : field} + + ); + })}
@@ -472,12 +513,13 @@ const InnerAdminEditor: FC = ErrorBoun formData={languageFilteredFormData} liveValidate focusOnFirstError - formContext={{ readonlyAsDisabled: true }} + formContext={{ readonlyAsDisabled: true, choicesData }} onChange={({ formData }) => appendFormDataState(formData)} onSubmit={onSubmitFunc} disabled={disabled} showErrorList={false} fields={{ file: FileField, m2m_select: M2MSelect, markdown: MDEditorField }} + widgets={{ SelectWidget: AutocompleteSelectWidget, autocomplete_select: AutocompleteSelectWidget }} />
diff --git a/apps/pyconkr-admin/src/components/layouts/admin_list.tsx b/apps/pyconkr-admin/src/components/layouts/admin_list.tsx index fed1b82..f2b54b9 100644 --- a/apps/pyconkr-admin/src/components/layouts/admin_list.tsx +++ b/apps/pyconkr-admin/src/components/layouts/admin_list.tsx @@ -1,13 +1,32 @@ import { useBackendAdminClient, + useChoicesQueries, useChoicesQuery, - useListQuery, + useListAutoQuery, useOpenApiSchemaQuery, useRemovePreparedMutation, } from "@frontend/common/hooks/useAdminAPI"; +import { ChoicesResponse } from "@frontend/common/schemas/backendAdminAPI"; import { extractQueryParameters } from "@frontend/common/utils"; import { Add, Delete, Edit } from "@mui/icons-material"; -import { Box, Button, CircularProgress, IconButton, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from "@mui/material"; +import { + Box, + Button, + CircularProgress, + FormControl, + IconButton, + InputLabel, + MenuItem, + Pagination, + Select, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, +} from "@mui/material"; import { ErrorBoundary, Suspense } from "@suspensive/react"; import { FC, type ReactNode, useMemo } from "react"; import { Link, useNavigate, useSearchParams } from "react-router-dom"; @@ -17,6 +36,10 @@ import { BackendAdminSignInGuard } from "@apps/pyconkr-admin/components/elements import { ErrorFallback } from "@apps/pyconkr-admin/components/elements/error_fallback"; import { addErrorSnackbar, addSnackbar } from "@apps/pyconkr-admin/utils/snackbar"; +const DEFAULT_PAGE_SIZE = 25; +const PAGE_SIZE_OPTIONS = [25, 50, 100, 200]; +const PAGINATION_PARAM_KEYS = new Set(["page", "page_size"]); + type ListRowType = { id: string; str_repr: string; @@ -32,6 +55,13 @@ export type AdminListColumn = { render?: (row: Record) => ReactNode; }; +export type FilterChoicesSource = { + app: string; + resource: string; + /** Field name in the source resource's choices response. Defaults to the local field name (the map key). */ + field?: string; +}; + type AdminListProps = { app: string; resource: string; @@ -41,29 +71,77 @@ type AdminListProps = { hideCreateNew?: boolean; columns?: AdminListColumn[]; enableRowActions?: boolean; + filterChoicesFrom?: Record; }; const InnerAdminList: FC = ErrorBoundary.with( { fallback: ErrorFallback }, Suspense.with( { fallback: }, - ({ app, resource, title, hideCreatedAt, hideUpdatedAt, hideCreateNew, columns, enableRowActions }) => { + ({ app, resource, title, hideCreatedAt, hideUpdatedAt, hideCreateNew, columns, enableRowActions, filterChoicesFrom }) => { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const backendAdminClient = useBackendAdminClient(); - const filterParams: Record = Object.fromEntries(searchParams.entries()); - const listQuery = useListQuery>(backendAdminClient, app, resource, filterParams); + const allParams: Record = Object.fromEntries(searchParams.entries()); + const page = Math.max(1, Number(allParams.page) || 1); + const pageSize = Math.max(1, Number(allParams.page_size) || DEFAULT_PAGE_SIZE); + // filterParams = user-facing filters only (page/page_size stripped); used for AdminListFilter UI state. + const filterParams: Record = Object.fromEntries(Object.entries(allParams).filter(([k]) => !PAGINATION_PARAM_KEYS.has(k))); + // apiParams = filters + explicit pagination so non-paginated endpoints just ignore page/page_size. + const apiParams: Record = { ...filterParams, page: String(page), page_size: String(pageSize) }; + const listQuery = useListAutoQuery>(backendAdminClient, app, resource, apiParams); + const items = listQuery.data.items; + const pagination = listQuery.data.pagination; const openApiSchemaQuery = useOpenApiSchemaQuery(backendAdminClient); - const queryParameters = useMemo(() => extractQueryParameters(openApiSchemaQuery.data, app, resource), [openApiSchemaQuery.data, app, resource]); + const queryParameters = useMemo( + () => extractQueryParameters(openApiSchemaQuery.data, app, resource).filter((p) => !PAGINATION_PARAM_KEYS.has(p.name)), + [openApiSchemaQuery.data, app, resource] + ); const choicesQuery = useChoicesQuery(backendAdminClient, app, resource); + const overrideEntries = useMemo(() => Object.entries(filterChoicesFrom ?? {}), [filterChoicesFrom]); + const overrideQueries = useChoicesQueries( + backendAdminClient, + overrideEntries.map(([, src]) => ({ app: src.app, resource: src.resource })) + ); + const mergedChoices = useMemo(() => { + const merged: ChoicesResponse = { ...(choicesQuery.data ?? {}) }; + overrideEntries.forEach(([localField, src], i) => { + const sourceField = src.field ?? localField; + const sourceChoices = overrideQueries[i]?.data?.[sourceField]; + if (sourceChoices) merged[localField] = sourceChoices; + }); + return merged; + }, [choicesQuery.data, overrideEntries, overrideQueries]); + const removeMutation = useRemovePreparedMutation(backendAdminClient, app, resource); - const handleFilterApply = (newParams: Record) => setSearchParams(newParams, { replace: true }); + // Filter changes reset to page 1; page_size is preserved if the user had set one explicitly. + const handleFilterApply = (newParams: Record) => { + const merged: Record = { ...newParams }; + if (allParams.page_size) merged.page_size = allParams.page_size; + setSearchParams(merged, { replace: true }); + }; + + const handlePageChange = (_: unknown, newPage: number) => { + const next = new URLSearchParams(searchParams); + next.set("page", String(newPage)); + setSearchParams(next, { replace: true }); + window.scrollTo({ top: 0, behavior: "smooth" }); + }; + + const handlePageSizeChange = (newSize: number) => { + const next = new URLSearchParams(searchParams); + next.set("page_size", String(newSize)); + next.delete("page"); // page boundaries shift, so reset to 1 + setSearchParams(next, { replace: true }); + }; + + const totalPages = pagination ? Math.max(1, Math.ceil(pagination.count / pageSize)) : 1; const detailPath = (id: string) => `/${app}/${resource}/${id}`; const hasCustomColumns = !!(columns && columns.length > 0); @@ -89,7 +167,7 @@ const InnerAdminList: FC = ErrorBoundary.with( {title ?? `${app.toUpperCase()} > ${resource.toUpperCase()} > 목록`}
- + {!hideCreateNew && ( + +
+ ); + }) +); + +export const EmailAddressSection: FC<{ userId: string }> = (props) => ; diff --git a/apps/pyconkr-admin/src/components/pages/user/social_account_section.tsx b/apps/pyconkr-admin/src/components/pages/user/social_account_section.tsx new file mode 100644 index 0000000..2c28e5a --- /dev/null +++ b/apps/pyconkr-admin/src/components/pages/user/social_account_section.tsx @@ -0,0 +1,100 @@ +import { useBackendAdminClient, useListQuery, useRemovePreparedMutation } from "@frontend/common/hooks/useAdminAPI"; +import { Delete } from "@mui/icons-material"; +import { Alert, CircularProgress, Divider, IconButton, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from "@mui/material"; +import { ErrorBoundary, Suspense } from "@suspensive/react"; +import { FC } from "react"; +import { Link } from "react-router-dom"; + +import { ErrorFallback } from "@apps/pyconkr-admin/components/elements/error_fallback"; +import { addErrorSnackbar, addSnackbar } from "@apps/pyconkr-admin/utils/snackbar"; + +type SocialAccountRow = { + id: number; + user: string; + provider: string; + uid: string; + last_login: string | null; + date_joined: string; + extra_data: Record; + str_repr: string; +}; + +const InnerSocialAccountSection: FC<{ userId: string }> = ErrorBoundary.with( + { fallback: ErrorFallback }, + Suspense.with({ fallback: }, ({ userId }) => { + const client = useBackendAdminClient(); + const listQuery = useListQuery(client, "allauth", "social-account", { user: userId }); + const items = listQuery.data ?? []; + const removeMutation = useRemovePreparedMutation(client, "allauth", "social-account"); + + const handleDelete = (id: number, label: string) => { + if ( + !window.confirm( + `'${label}'을(를) 삭제하시겠습니까?\n\n이 사용자에게 다른 소셜 계정이 남아있지 않다면, 연결된 모든 이메일 주소도 함께 삭제됩니다.` + ) + ) + return; + removeMutation.mutate(String(id), { + onSuccess: () => addSnackbar("소셜 계정을 삭제했습니다.", "success"), + onError: addErrorSnackbar, + }); + }; + + return ( + + + 소셜 계정 ({items.length}) + + 소셜 계정은 OAuth 로그인 시 자동으로 생성됩니다. 어드민에서는 삭제만 가능합니다. + + + + + Provider + UID + 최근 로그인 + 가입일 + + 작업 + + + + + {items.length === 0 && ( + + + 연결된 소셜 계정이 없습니다. + + + )} + {items.map((sa) => ( + + {sa.provider} + + + {sa.uid} + + + {sa.last_login ? new Date(sa.last_login).toLocaleString() : "—"} + {new Date(sa.date_joined).toLocaleString()} + + handleDelete(sa.id, sa.str_repr || `${sa.provider}:${sa.uid}`)} + disabled={removeMutation.isPending} + aria-label="삭제" + > + + + + + ))} + +
+
+ ); + }) +); + +export const SocialAccountSection: FC<{ userId: string }> = (props) => ; diff --git a/apps/pyconkr-admin/src/routes.tsx b/apps/pyconkr-admin/src/routes.tsx index 9a6bbb7..7c21975 100644 --- a/apps/pyconkr-admin/src/routes.tsx +++ b/apps/pyconkr-admin/src/routes.tsx @@ -1,6 +1,7 @@ import { AccountCircle, AccountTree, + AlternateEmail, Apartment, Article, AutoFixHigh, @@ -13,11 +14,13 @@ import { FolderSpecial, Forum, Handshake, + Login, LocalOffer, ManageAccounts, MarkEmailRead, MeetingRoom, NoteAlt, + Person, Public, ReceiptLong, Send, @@ -316,6 +319,34 @@ export const RouteDefinitions: RouteDef[] = [ app: "external-api/google", resource: "oauth2", }, + { + type: "separator", + key: "allauth-separator", + title: "소셜 계정 관리", + }, + { + type: "autoAdminRouteDefinition", + key: "allauth-social-app", + icon: Login, + title: "소셜 앱", + app: "allauth", + resource: "social-app", + }, + { + type: "routeDefinition", + key: "allauth-social-account", + icon: Person, + title: "소셜 계정", + route: "/allauth/social-account", + }, + { + type: "autoAdminRouteDefinition", + key: "allauth-email-address", + icon: AlternateEmail, + title: "이메일 주소", + app: "allauth", + resource: "email-address", + }, { type: "routeDefinition", key: "user-account", @@ -364,6 +395,21 @@ export const RegisteredRoutes = { "/file/publicfile/:id": , "/user/userext": , "/user/userext/:id": , + "/allauth/social-app": , + "/allauth/social-account": ( + + ), + "/allauth/social-account/:id": ( + + ), + "/allauth/email-address": , "/account": , "/account/sign-in": , "/account/manage": , diff --git a/packages/common/src/apis/admin_api.ts b/packages/common/src/apis/admin_api.ts index 0d068bf..83a2f21 100644 --- a/packages/common/src/apis/admin_api.ts +++ b/packages/common/src/apis/admin_api.ts @@ -47,6 +47,21 @@ export const listPaginated = () => client.get>(`v1/admin-api/${app}/${resource}/`, { params }); +export type ListAutoResult = { + items: T[]; + pagination: { count: number; next: string | null; previous: string | null } | null; +}; + +// Probes the list endpoint and normalizes whether the viewset uses DRF pagination: +// paginated responses become {items, pagination}, flat array responses become {items, pagination: null}. +export const listAuto = + (client: BackendAPIClient, app: string, resource: string, params?: Record) => + async (): Promise> => { + const data = await client.get>(`v1/admin-api/${app}/${resource}/`, { params }); + if (Array.isArray(data)) return { items: data, pagination: null }; + return { items: data.results, pagination: { count: data.count, next: data.next, previous: data.previous } }; + }; + export const retrieve = (client: BackendAPIClient, app: string, resource: string, id: string) => () => { diff --git a/packages/common/src/hooks/useAdminAPI.ts b/packages/common/src/hooks/useAdminAPI.ts index d218bdb..262ea6b 100644 --- a/packages/common/src/hooks/useAdminAPI.ts +++ b/packages/common/src/hooks/useAdminAPI.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery, useSuspenseQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useSuspenseQueries, useSuspenseQuery } from "@tanstack/react-query"; import { approveModificationAudit, @@ -8,6 +8,7 @@ import { create, issueGoogleOAuth2AccessToken, list, + listAuto, listPaginated, listSections, me, @@ -108,6 +109,14 @@ export const useChoicesQuery = (client: BackendAPIClient, app: string, resource: queryFn: choices(client, app, resource), }); +export const useChoicesQueries = (client: BackendAPIClient, pairs: { app: string; resource: string }[]) => + useSuspenseQueries({ + queries: pairs.map(({ app, resource }) => ({ + queryKey: [...QUERY_KEYS.ADMIN_CHOICES, app, resource], + queryFn: choices(client, app, resource), + })), + }); + export const useOpenApiSchemaQuery = (client: BackendAPIClient) => useSuspenseQuery({ queryKey: QUERY_KEYS.ADMIN_OPENAPI_SCHEMA, @@ -127,6 +136,12 @@ export const useListPaginatedQuery = (client: BackendAPIClient, app: string, queryFn: listPaginated(client, app, resource, params), }); +export const useListAutoQuery = (client: BackendAPIClient, app: string, resource: string, params?: Record) => + useSuspenseQuery({ + queryKey: [...QUERY_KEYS.ADMIN_LIST, app, resource, "auto", JSON.stringify(params)], + queryFn: listAuto(client, app, resource, params), + }); + export const useRetrieveQuery = (client: BackendAPIClient, app: string, resource: string, id: string) => useSuspenseQuery({ queryKey: [...QUERY_KEYS.ADMIN_RETRIEVE, app, resource, id],