From b17a8bed89df640372d157e06ec8a8df98f8e47e Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Mon, 23 Mar 2026 05:43:40 +0700 Subject: [PATCH] Redesign version localizations UI to match App Store Connect Replace card-per-locale layout with ASC-style single-form editor: - Locale dropdown with human-readable names and prev/next navigation - Always-editable form with character counters and field limits - Draft map preserves unsaved edits across locale switches - Global Save/Discard operates on all dirty locales at once - en-US auto-selected and sorted to top of locale list --- .../VersionLocalizationsSection.jsx | 552 ++++++++++++------ src/constants/index.js | 42 ++ .../VersionLocalizationsSection.test.jsx | 493 ++++++++++++---- 3 files changed, 780 insertions(+), 307 deletions(-) diff --git a/src/components/VersionLocalizationsSection.jsx b/src/components/VersionLocalizationsSection.jsx index 1b0d517..d8fdcc4 100644 --- a/src/components/VersionLocalizationsSection.jsx +++ b/src/components/VersionLocalizationsSection.jsx @@ -1,270 +1,438 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { fetchVersionLocalizations, createVersionLocalization, updateVersionLocalization, deleteVersionLocalization, } from "../api/index.js"; - -const labelCls = "text-[11px] uppercase tracking-wide font-semibold text-dark-dim mb-1.5 block"; -const inputCls = "w-full px-3.5 py-2.5 bg-dark-surface border border-dark-border-light rounded-lg text-dark-text outline-none font-sans text-[13px] transition-colors"; +import { LOCALE_DISPLAY_NAMES } from "../constants/index.js"; const FIELDS = [ - { key: "description", label: "Description", type: "textarea", height: "h-[100px]" }, - { key: "whatsNew", label: "What's New", type: "textarea", height: "h-[80px]" }, - { key: "keywords", label: "Keywords", type: "input" }, - { key: "promotionalText", label: "Promotional Text", type: "textarea", height: "h-[60px]" }, + { key: "promotionalText", label: "Promotional Text", type: "textarea", rows: 3, limit: 170 }, + { key: "description", label: "Description", type: "textarea", rows: 6, limit: 4000 }, + { key: "whatsNew", label: "What's New in This Version", type: "textarea", rows: 4, limit: 4000 }, + { key: "keywords", label: "Keywords", type: "input", limit: 100 }, { key: "supportUrl", label: "Support URL", type: "input" }, { key: "marketingUrl", label: "Marketing URL", type: "input" }, ]; +const labelCls = "text-[11px] uppercase tracking-wide font-semibold text-dark-dim mb-1.5 block"; +const inputCls = "w-full px-3.5 py-2.5 bg-dark-surface border border-dark-border-light rounded-lg text-dark-text outline-none font-sans text-[13px] transition-colors focus:border-accent"; + function emptyFields() { - return { description: "", whatsNew: "", keywords: "", promotionalText: "", supportUrl: "", marketingUrl: "" }; + return { promotionalText: "", description: "", whatsNew: "", keywords: "", supportUrl: "", marketingUrl: "" }; +} + +function fieldsFromLoc(loc) { + return { + promotionalText: loc.promotionalText || "", + description: loc.description || "", + whatsNew: loc.whatsNew || "", + keywords: loc.keywords || "", + supportUrl: loc.supportUrl || "", + marketingUrl: loc.marketingUrl || "", + }; +} + +function localeName(code) { + return LOCALE_DISPLAY_NAMES[code] || code; } export default function VersionLocalizationsSection({ appId, versionId, accountId, isMobile }) { - const [expanded, setExpanded] = useState(false); const [locs, setLocs] = useState([]); - const [locsLoading, setLocsLoading] = useState(false); - const [locsError, setLocsError] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); - const [editingLocId, setEditingLocId] = useState(null); - const [editFields, setEditFields] = useState(emptyFields()); - const [savingLocId, setSavingLocId] = useState(null); - const [deletingLocId, setDeletingLocId] = useState(null); + const [selectedLocId, setSelectedLocId] = useState(null); + const [draftMap, setDraftMap] = useState({}); + + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); const [saveError, setSaveError] = useState(null); - const [newLocale, setNewLocale] = useState(""); - const [newFields, setNewFields] = useState(emptyFields()); - const [addingLoc, setAddingLoc] = useState(false); + const [addingNewLocale, setAddingNewLocale] = useState(false); + const [newLocaleCode, setNewLocaleCode] = useState(""); + const [creatingLocale, setCreatingLocale] = useState(false); + + const selectedLoc = useMemo( + () => locs.find((l) => l.id === selectedLocId) || null, + [locs, selectedLocId] + ); + + const formFields = useMemo(() => { + if (selectedLocId && draftMap[selectedLocId]) return draftMap[selectedLocId]; + if (selectedLoc) return fieldsFromLoc(selectedLoc); + return emptyFields(); + }, [draftMap, selectedLocId, selectedLoc]); + + const selectedIndex = useMemo( + () => locs.findIndex((l) => l.id === selectedLocId), + [locs, selectedLocId] + ); + + const hasPrev = selectedIndex > 0; + const hasNext = selectedIndex >= 0 && selectedIndex < locs.length - 1; + + function goToPrev() { + if (hasPrev) selectLocale(locs[selectedIndex - 1].id); + } + + function goToNext() { + if (hasNext) selectLocale(locs[selectedIndex + 1].id); + } + + function updateField(key, value) { + setDraftMap((prev) => ({ + ...prev, + [selectedLocId]: { + ...(prev[selectedLocId] || fieldsFromLoc(selectedLoc)), + [key]: value, + }, + })); + } + + const anyDirty = useMemo(() => { + for (const locId of Object.keys(draftMap)) { + const loc = locs.find((l) => l.id === locId); + if (!loc) continue; + const stored = fieldsFromLoc(loc); + const draft = draftMap[locId]; + if (FIELDS.some((f) => (draft[f.key] || "") !== (stored[f.key] || ""))) return true; + } + return false; + }, [draftMap, locs]); useEffect(() => { - if (!expanded) return; let cancelled = false; - setLocsLoading(true); - setLocsError(null); + setLoading(true); + setError(null); fetchVersionLocalizations(appId, versionId, accountId) - .then((data) => { if (!cancelled) setLocs(data); }) - .catch((err) => { if (!cancelled) setLocsError(err.message); }) - .finally(() => { if (!cancelled) setLocsLoading(false); }); + .then((data) => { + if (cancelled) return; + const sorted = [...data].sort((a, b) => { + if (a.locale === "en-US") return -1; + if (b.locale === "en-US") return 1; + return 0; + }); + setLocs(sorted); + if (sorted.length > 0) { + setSelectedLocId(sorted[0].id); + } + }) + .catch((err) => { + if (!cancelled) setError(err.message); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); return () => { cancelled = true; }; - }, [expanded, appId, versionId, accountId]); + }, [appId, versionId, accountId]); - function startEdit(loc) { - setEditingLocId(loc.id); + function selectLocale(locId) { + if (!locs.find((l) => l.id === locId)) return; + setSelectedLocId(locId); setSaveError(null); - setEditFields({ - description: loc.description || "", - whatsNew: loc.whatsNew || "", - keywords: loc.keywords || "", - promotionalText: loc.promotionalText || "", - supportUrl: loc.supportUrl || "", - marketingUrl: loc.marketingUrl || "", - }); + setAddingNewLocale(false); } - function cancelEdit() { - setEditingLocId(null); + function handleDiscard() { + setDraftMap({}); setSaveError(null); } - async function handleSave(loc) { - setSavingLocId(loc.id); + async function handleSave() { + if (!anyDirty) return; + setSaving(true); setSaveError(null); try { - const updated = await updateVersionLocalization(appId, versionId, loc.id, { accountId, ...editFields }); - setLocs((prev) => prev.map((l) => (l.id === loc.id ? updated : l))); - setEditingLocId(null); + const dirtyEntries = Object.entries(draftMap).filter(([locId]) => { + const loc = locs.find((l) => l.id === locId); + if (!loc) return false; + const stored = fieldsFromLoc(loc); + return FIELDS.some((f) => (draftMap[locId][f.key] || "") !== (stored[f.key] || "")); + }); + const results = await Promise.all( + dirtyEntries.map(([locId, fields]) => + updateVersionLocalization(appId, versionId, locId, { accountId, ...fields }) + ) + ); + setLocs((prev) => + prev.map((l) => { + const updated = results.find((r) => r.id === l.id); + return updated || l; + }) + ); + setDraftMap({}); } catch (err) { setSaveError(err.message); } finally { - setSavingLocId(null); + setSaving(false); } } - async function handleDelete(loc) { - setDeletingLocId(loc.id); + async function handleDelete() { + if (!selectedLoc) return; + const deletedId = selectedLoc.id; + setDeleting(true); setSaveError(null); try { - await deleteVersionLocalization(appId, versionId, loc.id, accountId); - setLocs((prev) => prev.filter((l) => l.id !== loc.id)); - if (editingLocId === loc.id) setEditingLocId(null); + await deleteVersionLocalization(appId, versionId, deletedId, accountId); + const remaining = locs.filter((l) => l.id !== deletedId); + setLocs(remaining); + setDraftMap((prev) => { + const next = { ...prev }; + delete next[deletedId]; + return next; + }); + if (remaining.length > 0) { + setSelectedLocId(remaining[0].id); + } else { + setSelectedLocId(null); + } } catch (err) { setSaveError(err.message); } finally { - setDeletingLocId(null); + setDeleting(false); + } + } + + function handleDropdownChange(e) { + const value = e.target.value; + if (value === "__add__") { + setAddingNewLocale(true); + setNewLocaleCode(""); + } else { + selectLocale(value); } } - async function handleAdd() { - if (!newLocale.trim()) return; - setAddingLoc(true); + async function handleCreateLocale() { + const code = newLocaleCode.trim(); + if (!code) return; + setCreatingLocale(true); setSaveError(null); try { const created = await createVersionLocalization(appId, versionId, { accountId, - locale: newLocale.trim(), - ...newFields, + locale: code, + ...emptyFields(), }); setLocs((prev) => [...prev, created]); - setNewLocale(""); - setNewFields(emptyFields()); + setSelectedLocId(created.id); + setAddingNewLocale(false); + setNewLocaleCode(""); } catch (err) { setSaveError(err.message); } finally { - setAddingLoc(false); + setCreatingLocale(false); } } - function renderFieldInputs(fields, setFields, prefix) { + function cancelAddLocale() { + setAddingNewLocale(false); + setNewLocaleCode(""); + } + + if (loading) { return ( -
- {FIELDS.map((f) => ( -
- - {f.type === "textarea" ? ( -