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 crate::render::render_page;
|
||||||
use application::import::{
|
use application::import::{
|
||||||
apply_mapping as apply_import_mapping,
|
apply_mapping as apply_import_mapping,
|
||||||
|
apply_profile as apply_import_profile,
|
||||||
commands::{
|
commands::{
|
||||||
ApplyImportMappingCommand, CreateImportSessionCommand, DeleteImportProfileCommand,
|
ApplyImportMappingCommand, ApplyImportProfileCommand, CreateImportSessionCommand,
|
||||||
ExecuteImportCommand, SaveImportProfileCommand,
|
DeleteImportProfileCommand, ExecuteImportCommand, SaveImportProfileCommand,
|
||||||
},
|
},
|
||||||
create_session as create_import_session, delete_profile as delete_import_profile,
|
create_session as create_import_session, delete_profile as delete_import_profile,
|
||||||
execute as execute_import, list_profiles as list_import_profiles,
|
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}",
|
"/import/profiles/{id}",
|
||||||
routing::delete(handlers::import::api_delete_profile),
|
routing::delete(handlers::import::api_delete_profile),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/import/sessions/{id}/profile/{profile_id}",
|
||||||
|
routing::put(handlers::import::api_apply_profile),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/profile",
|
"/profile",
|
||||||
routing::get(handlers::users::get_profile).put(handlers::users::update_profile_handler),
|
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import {
|
import {
|
||||||
|
applyImportProfile,
|
||||||
applyMapping,
|
applyMapping,
|
||||||
confirmImport,
|
confirmImport,
|
||||||
createImportSession,
|
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,
|
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 { z } from "zod"
|
||||||
import { del, get, post, put, upload } from "./client"
|
import { del, get, post, put, uploadWithFields } from "./client"
|
||||||
|
|
||||||
export const sessionCreatedResponseSchema = z.object({
|
export const sessionCreatedResponseSchema = z.object({
|
||||||
session_id: z.string(),
|
session_id: z.string(),
|
||||||
@@ -41,7 +41,9 @@ export const saveProfileRequestSchema = z.object({
|
|||||||
export type SaveProfileRequest = z.infer<typeof saveProfileRequestSchema>
|
export type SaveProfileRequest = z.infer<typeof saveProfileRequestSchema>
|
||||||
|
|
||||||
export function createImportSession(file: File) {
|
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) {
|
export function getImportSession(id: string) {
|
||||||
@@ -76,14 +78,24 @@ export function confirmImport(sessionId: string, data: ConfirmRequest) {
|
|||||||
return post(`/import/sessions/${sessionId}/confirm`, data)
|
return post(`/import/sessions/${sessionId}/confirm`, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ImportProfile = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export function getImportProfiles() {
|
export function getImportProfiles() {
|
||||||
return get<unknown[]>("/import/profiles")
|
return get<ImportProfile[]>("/import/profiles")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveImportProfile(data: SaveProfileRequest) {
|
export function saveImportProfile(data: SaveProfileRequest) {
|
||||||
return post("/import/profiles", data)
|
return post<{ id: string }>("/import/profiles", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteImportProfile(id: string) {
|
export function deleteImportProfile(id: string) {
|
||||||
return del(`/import/profiles/${id}`)
|
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",
|
"editProfile": "Edit Profile",
|
||||||
"editProfileDesc": "Username, bio",
|
"editProfileDesc": "Username, bio",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
"importDesc": "Import from CSV",
|
"importDesc": "Import from CSV or JSON",
|
||||||
"yearWrapUp": "Year Wrap-Up",
|
"yearWrapUp": "Year Wrap-Up",
|
||||||
"yearWrapUpDesc": "Annual summaries",
|
"yearWrapUpDesc": "Annual summaries",
|
||||||
"webhookTokens": "Webhook Tokens",
|
"webhookTokens": "Webhook Tokens",
|
||||||
@@ -337,7 +337,7 @@
|
|||||||
"scale1to10": "1–10 → 1–5 (×0.5)",
|
"scale1to10": "1–10 → 1–5 (×0.5)",
|
||||||
"scale1to100": "1–100 → 1–5 (×0.05)",
|
"scale1to100": "1–100 → 1–5 (×0.05)",
|
||||||
"scaleLetterboxd": "0–4 Letterboxd → 1–5 (×1.25)",
|
"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...",
|
"uploading": "Uploading...",
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
"rowsCols": "{{rows}} rows · {{cols}} columns",
|
"rowsCols": "{{rows}} rows · {{cols}} columns",
|
||||||
@@ -357,6 +357,14 @@
|
|||||||
"importing": "Importing...",
|
"importing": "Importing...",
|
||||||
"importRows": "Import {{count}} rows",
|
"importRows": "Import {{count}} rows",
|
||||||
"importComplete": "Import complete!",
|
"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 { createFileRoute, Link } from "@tanstack/react-router"
|
||||||
import { useRef, useState } from "react"
|
import { useRef, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
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 { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -25,8 +26,12 @@ import { Skeleton } from "@/components/ui/skeleton"
|
|||||||
import {
|
import {
|
||||||
useCreateImportSession,
|
useCreateImportSession,
|
||||||
useApplyMapping,
|
useApplyMapping,
|
||||||
|
useApplyImportProfile,
|
||||||
useConfirmImport,
|
useConfirmImport,
|
||||||
useImportPreview,
|
useImportPreview,
|
||||||
|
useImportProfiles,
|
||||||
|
useSaveImportProfile,
|
||||||
|
useDeleteImportProfile,
|
||||||
} from "@/hooks/use-imports"
|
} from "@/hooks/use-imports"
|
||||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||||
import type { SessionCreatedResponse } from "@/lib/api/imports"
|
import type { SessionCreatedResponse } from "@/lib/api/imports"
|
||||||
@@ -65,7 +70,10 @@ function ImportPage() {
|
|||||||
|
|
||||||
const createSession = useCreateImportSession()
|
const createSession = useCreateImportSession()
|
||||||
const applyMapping = useApplyMapping()
|
const applyMapping = useApplyMapping()
|
||||||
|
const applyProfile = useApplyImportProfile()
|
||||||
const confirmImport = useConfirmImport()
|
const confirmImport = useConfirmImport()
|
||||||
|
const { data: profiles } = useImportProfiles()
|
||||||
|
const deleteProfile = useDeleteImportProfile()
|
||||||
|
|
||||||
const handleFile = (file: File) => {
|
const handleFile = (file: File) => {
|
||||||
createSession.mutate(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")
|
const hasRatingMapping = Object.values(mappings).includes("rating")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -162,7 +178,7 @@ function ImportPage() {
|
|||||||
<input
|
<input
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".csv"
|
accept=".csv,.json"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
@@ -214,6 +230,40 @@ function ImportPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Column mapping */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -332,6 +382,8 @@ function ConfirmStep({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { data, isPending: previewLoading } = useImportPreview(sessionId)
|
const { data, isPending: previewLoading } = useImportPreview(sessionId)
|
||||||
|
const saveProfile = useSaveImportProfile()
|
||||||
|
const [presetName, setPresetName] = useState("")
|
||||||
|
|
||||||
if (previewLoading) return <Skeleton className="h-40 w-full rounded-xl" />
|
if (previewLoading) return <Skeleton className="h-40 w-full rounded-xl" />
|
||||||
|
|
||||||
@@ -387,6 +439,33 @@ function ConfirmStep({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<Button onClick={onConfirm} disabled={isPending || valid.length === 0} className="w-full">
|
||||||
{isPending ? t("import.importing") : t("import.importRows", { count: valid.length })}
|
{isPending ? t("import.importing") : t("import.importRows", { count: valid.length })}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user