feat: JSON import + mapping presets in SPA
- Accept .json files in import upload, send format to backend
- Backend endpoint PUT /import/sessions/{id}/profile/{profile_id}
- Load saved presets on mapping step, auto-apply and skip to preview
- Save current mapping as preset on confirm step
- Delete presets from mapping step
This commit is contained in:
@@ -14,9 +14,10 @@ use std::collections::HashMap;
|
||||
use crate::render::render_page;
|
||||
use application::import::{
|
||||
apply_mapping as apply_import_mapping,
|
||||
apply_profile as apply_import_profile,
|
||||
commands::{
|
||||
ApplyImportMappingCommand, CreateImportSessionCommand, DeleteImportProfileCommand,
|
||||
ExecuteImportCommand, SaveImportProfileCommand,
|
||||
ApplyImportMappingCommand, ApplyImportProfileCommand, CreateImportSessionCommand,
|
||||
DeleteImportProfileCommand, ExecuteImportCommand, SaveImportProfileCommand,
|
||||
},
|
||||
create_session as create_import_session, delete_profile as delete_import_profile,
|
||||
execute as execute_import, list_profiles as list_import_profiles,
|
||||
@@ -859,3 +860,96 @@ pub async fn api_delete_profile(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put, path = "/api/v1/import/sessions/{id}/profile/{profile_id}",
|
||||
params(
|
||||
("id" = String, Path, description = "Import session UUID"),
|
||||
("profile_id" = String, Path, description = "Import profile UUID"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Profile applied and mapping regenerated", body = inline(serde_json::Value)),
|
||||
(status = 400, description = "Invalid ID"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Session or profile not found"),
|
||||
(status = 422, description = "Mapping error"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_apply_profile(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path((session_id_str, profile_id_str)): Path<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let Ok(session_id) = session_id_str.parse::<uuid::Uuid>() else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
axum::Json(serde_json::json!({"error": "invalid session id"})),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
let Ok(profile_id) = profile_id_str.parse::<uuid::Uuid>() else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
axum::Json(serde_json::json!({"error": "invalid profile id"})),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
if let Err(e) = apply_import_profile::execute(
|
||||
&state.app_ctx,
|
||||
ApplyImportProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id,
|
||||
profile_id,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
let status = if matches!(e, domain::errors::DomainError::NotFound(_)) {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
};
|
||||
return (status, axum::Json(serde_json::json!({"error": e.to_string()}))).into_response();
|
||||
}
|
||||
|
||||
let session = match state
|
||||
.app_ctx
|
||||
.repos
|
||||
.import_session
|
||||
.get(
|
||||
&ImportSessionId::from_uuid(session_id),
|
||||
&user_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(s)) => s,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
axum::Json(serde_json::json!({"error": "session not found after profile apply"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let mappings = session.field_mappings.unwrap_or_default();
|
||||
match apply_import_mapping::execute(
|
||||
&state.app_ctx,
|
||||
ApplyImportMappingCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id,
|
||||
mappings,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(rows) => axum::Json(serde_json::json!({"row_count": rows.len()})).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
axum::Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,6 +365,10 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
|
||||
"/import/profiles/{id}",
|
||||
routing::delete(handlers::import::api_delete_profile),
|
||||
)
|
||||
.route(
|
||||
"/import/sessions/{id}/profile/{profile_id}",
|
||||
routing::put(handlers::import::api_apply_profile),
|
||||
)
|
||||
.route(
|
||||
"/profile",
|
||||
routing::get(handlers::users::get_profile).put(handlers::users::update_profile_handler),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import {
|
||||
applyImportProfile,
|
||||
applyMapping,
|
||||
confirmImport,
|
||||
createImportSession,
|
||||
@@ -93,3 +94,10 @@ export function useDeleteImportProfile() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useApplyImportProfile() {
|
||||
return useMutation({
|
||||
mutationFn: ({ sessionId, profileId }: { sessionId: string; profileId: string }) =>
|
||||
applyImportProfile(sessionId, profileId),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -118,3 +118,17 @@ export async function upload<T>(
|
||||
body: form,
|
||||
})
|
||||
}
|
||||
|
||||
export async function uploadWithFields<T>(
|
||||
path: string,
|
||||
file: File,
|
||||
fields: Record<string, string>,
|
||||
): Promise<T> {
|
||||
const form = new FormData()
|
||||
form.append("file", file)
|
||||
for (const [k, v] of Object.entries(fields)) form.append(k, v)
|
||||
return request<T>(buildUrl(path), {
|
||||
method: "POST",
|
||||
body: form,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod"
|
||||
import { del, get, post, put, upload } from "./client"
|
||||
import { del, get, post, put, uploadWithFields } from "./client"
|
||||
|
||||
export const sessionCreatedResponseSchema = z.object({
|
||||
session_id: z.string(),
|
||||
@@ -41,7 +41,9 @@ export const saveProfileRequestSchema = z.object({
|
||||
export type SaveProfileRequest = z.infer<typeof saveProfileRequestSchema>
|
||||
|
||||
export function createImportSession(file: File) {
|
||||
return upload<SessionCreatedResponse>("/import/sessions", file)
|
||||
const ext = file.name.split(".").pop()?.toLowerCase()
|
||||
const format = ext === "json" ? "json" : "csv"
|
||||
return uploadWithFields<SessionCreatedResponse>("/import/sessions", file, { format })
|
||||
}
|
||||
|
||||
export function getImportSession(id: string) {
|
||||
@@ -76,14 +78,24 @@ export function confirmImport(sessionId: string, data: ConfirmRequest) {
|
||||
return post(`/import/sessions/${sessionId}/confirm`, data)
|
||||
}
|
||||
|
||||
export type ImportProfile = {
|
||||
id: string
|
||||
name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function getImportProfiles() {
|
||||
return get<unknown[]>("/import/profiles")
|
||||
return get<ImportProfile[]>("/import/profiles")
|
||||
}
|
||||
|
||||
export function saveImportProfile(data: SaveProfileRequest) {
|
||||
return post("/import/profiles", data)
|
||||
return post<{ id: string }>("/import/profiles", data)
|
||||
}
|
||||
|
||||
export function deleteImportProfile(id: string) {
|
||||
return del(`/import/profiles/${id}`)
|
||||
}
|
||||
|
||||
export function applyImportProfile(sessionId: string, profileId: string) {
|
||||
return put<{ row_count: number }>(`/import/sessions/${sessionId}/profile/${profileId}`)
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
"editProfile": "Edit Profile",
|
||||
"editProfileDesc": "Username, bio",
|
||||
"import": "Import",
|
||||
"importDesc": "Import from CSV",
|
||||
"importDesc": "Import from CSV or JSON",
|
||||
"yearWrapUp": "Year Wrap-Up",
|
||||
"yearWrapUpDesc": "Annual summaries",
|
||||
"webhookTokens": "Webhook Tokens",
|
||||
@@ -337,7 +337,7 @@
|
||||
"scale1to10": "1–10 → 1–5 (×0.5)",
|
||||
"scale1to100": "1–100 → 1–5 (×0.05)",
|
||||
"scaleLetterboxd": "0–4 Letterboxd → 1–5 (×1.25)",
|
||||
"dropCsv": "Drop a CSV file or tap to browse",
|
||||
"dropCsv": "Drop a CSV or JSON file, or tap to browse",
|
||||
"uploading": "Uploading...",
|
||||
"preview": "Preview",
|
||||
"rowsCols": "{{rows}} rows · {{cols}} columns",
|
||||
@@ -357,6 +357,14 @@
|
||||
"importing": "Importing...",
|
||||
"importRows": "Import {{count}} rows",
|
||||
"importComplete": "Import complete!",
|
||||
"viewDiary": "View your diary"
|
||||
"viewDiary": "View your diary",
|
||||
"presets": "Presets",
|
||||
"loadPreset": "Load preset",
|
||||
"savePreset": "Save as preset",
|
||||
"presetName": "Preset name",
|
||||
"presetNamePlaceholder": "e.g. Letterboxd",
|
||||
"presetSaved": "Preset saved",
|
||||
"presetDeleted": "Preset deleted",
|
||||
"noPresets": "No saved presets"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router"
|
||||
import { useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ArrowLeft, CheckCircle, Upload } from "lucide-react"
|
||||
import { ArrowLeft, CheckCircle, Trash2, Upload } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -25,8 +26,12 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
useCreateImportSession,
|
||||
useApplyMapping,
|
||||
useApplyImportProfile,
|
||||
useConfirmImport,
|
||||
useImportPreview,
|
||||
useImportProfiles,
|
||||
useSaveImportProfile,
|
||||
useDeleteImportProfile,
|
||||
} from "@/hooks/use-imports"
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||
import type { SessionCreatedResponse } from "@/lib/api/imports"
|
||||
@@ -65,7 +70,10 @@ function ImportPage() {
|
||||
|
||||
const createSession = useCreateImportSession()
|
||||
const applyMapping = useApplyMapping()
|
||||
const applyProfile = useApplyImportProfile()
|
||||
const confirmImport = useConfirmImport()
|
||||
const { data: profiles } = useImportProfiles()
|
||||
const deleteProfile = useDeleteImportProfile()
|
||||
|
||||
const handleFile = (file: File) => {
|
||||
createSession.mutate(file, {
|
||||
@@ -115,6 +123,14 @@ function ImportPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const handleApplyProfile = (profileId: string) => {
|
||||
if (!session) return
|
||||
applyProfile.mutate(
|
||||
{ sessionId: session.session_id, profileId },
|
||||
{ onSuccess: () => setStep(2) },
|
||||
)
|
||||
}
|
||||
|
||||
const hasRatingMapping = Object.values(mappings).includes("rating")
|
||||
|
||||
return (
|
||||
@@ -162,7 +178,7 @@ function ImportPage() {
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
accept=".csv,.json"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
@@ -214,6 +230,40 @@ function ImportPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Presets */}
|
||||
{profiles && profiles.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{t("import.presets")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{profiles.map((p) => (
|
||||
<div key={p.id} className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 justify-start"
|
||||
onClick={() => handleApplyProfile(p.id)}
|
||||
disabled={applyProfile.isPending}
|
||||
>
|
||||
{p.name}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground"
|
||||
onClick={() => deleteProfile.mutate(p.id, {
|
||||
onSuccess: () => toast.success(t("import.presetDeleted")),
|
||||
})}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Column mapping */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -332,6 +382,8 @@ function ConfirmStep({
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { data, isPending: previewLoading } = useImportPreview(sessionId)
|
||||
const saveProfile = useSaveImportProfile()
|
||||
const [presetName, setPresetName] = useState("")
|
||||
|
||||
if (previewLoading) return <Skeleton className="h-40 w-full rounded-xl" />
|
||||
|
||||
@@ -387,6 +439,33 @@ function ConfirmStep({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{t("import.savePreset")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex gap-2">
|
||||
<Input
|
||||
value={presetName}
|
||||
onChange={(e) => setPresetName(e.target.value)}
|
||||
placeholder={t("import.presetNamePlaceholder")}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!presetName.trim() || saveProfile.isPending || saveProfile.isSuccess}
|
||||
onClick={() =>
|
||||
saveProfile.mutate(
|
||||
{ session_id: sessionId, name: presetName.trim() },
|
||||
{ onSuccess: () => toast.success(t("import.presetSaved")) },
|
||||
)
|
||||
}
|
||||
>
|
||||
{saveProfile.isSuccess ? t("import.presetSaved") : t("common.save")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button onClick={onConfirm} disabled={isPending || valid.length === 0} className="w-full">
|
||||
{isPending ? t("import.importing") : t("import.importRows", { count: valid.length })}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user