Skip to content

Commit e955123

Browse files
authored
Add version localization editing (#4)
* Add version localization editing with full test coverage Add inline viewing and editing of App Store version localizations (description, what's new, keywords, promotional text, support URL, marketing URL) directly on the version detail page. Includes backend CRUD routes, frontend API functions, a new VersionLocalizationsSection component, and Vitest test setup with 44 tests across 3 suites. * Replace build flat list with modal picker Show only the selected build in the version detail page with a "Change" button. Builds are now selected via a modal dialog, sorted by upload date (newest first). --------- Co-authored-by: Quang Tran <16215255+trmquang93@users.noreply.github.com>
1 parent d6aa2b4 commit e955123

13 files changed

Lines changed: 2881 additions & 94 deletions

package-lock.json

Lines changed: 1428 additions & 29 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@
2626
"server": "node --watch server/index.js",
2727
"dev": "vite",
2828
"build": "vite build",
29-
"preview": "vite preview"
29+
"preview": "vite preview",
30+
"test": "vitest run",
31+
"test:watch": "vitest"
3032
},
3133
"dependencies": {
3234
"adm-zip": "^0.5.16",
@@ -37,8 +39,14 @@
3739
},
3840
"devDependencies": {
3941
"@tailwindcss/vite": "^4.2.2",
42+
"@testing-library/jest-dom": "^6.9.1",
43+
"@testing-library/react": "^16.3.2",
44+
"@testing-library/user-event": "^14.6.1",
4045
"@vitejs/plugin-react": "^4.3.4",
46+
"jsdom": "^29.0.1",
47+
"supertest": "^7.2.2",
4148
"tailwindcss": "^4.2.2",
42-
"vite": "^6.0.0"
49+
"vite": "^6.0.0",
50+
"vitest": "^4.1.0"
4351
}
4452
}

server/routes/apps.js

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,138 @@ router.patch("/:appId/versions/:versionId/build", async (req, res) => {
424424
}
425425
});
426426

427+
// ── Version Localizations ───────────────────────────────────────────────────
428+
429+
function normalizeVersionLocalization(item) {
430+
return {
431+
id: item.id,
432+
locale: item.attributes.locale,
433+
description: item.attributes.description,
434+
whatsNew: item.attributes.whatsNew,
435+
keywords: item.attributes.keywords,
436+
promotionalText: item.attributes.promotionalText,
437+
supportUrl: item.attributes.supportUrl,
438+
marketingUrl: item.attributes.marketingUrl,
439+
};
440+
}
441+
442+
router.get("/:appId/versions/:versionId/localizations", async (req, res) => {
443+
const { versionId } = req.params;
444+
const { accountId } = req.query;
445+
446+
const cacheKey = `apps:version-locs:${versionId}:${accountId || "default"}`;
447+
const cached = apiCache.get(cacheKey);
448+
if (cached) return res.json(cached);
449+
450+
const accounts = getAccounts();
451+
const account = accounts.find((a) => a.id === accountId) || accounts[0];
452+
453+
try {
454+
const data = await ascFetch(
455+
account,
456+
`/v1/appStoreVersions/${versionId}/appStoreVersionLocalizations?fields[appStoreVersionLocalizations]=locale,description,whatsNew,keywords,promotionalText,supportUrl,marketingUrl`
457+
);
458+
const locs = (data.data || []).map(normalizeVersionLocalization);
459+
apiCache.set(cacheKey, locs);
460+
res.json(locs);
461+
} catch (err) {
462+
console.error(`Failed to fetch version localizations for ${versionId}:`, err.message);
463+
res.status(502).json({ error: err.message });
464+
}
465+
});
466+
467+
router.post("/:appId/versions/:versionId/localizations", async (req, res) => {
468+
const { versionId } = req.params;
469+
const { accountId, locale, description, whatsNew, keywords, promotionalText, supportUrl, marketingUrl } = req.body;
470+
471+
if (!accountId || !locale) {
472+
return res.status(400).json({ error: "accountId and locale are required" });
473+
}
474+
475+
const accounts = getAccounts();
476+
const account = accounts.find((a) => a.id === accountId);
477+
if (!account) return res.status(400).json({ error: "Account not found" });
478+
479+
const attributes = { locale };
480+
if (description !== undefined) attributes.description = description;
481+
if (whatsNew !== undefined) attributes.whatsNew = whatsNew;
482+
if (keywords !== undefined) attributes.keywords = keywords;
483+
if (promotionalText !== undefined) attributes.promotionalText = promotionalText;
484+
if (supportUrl !== undefined) attributes.supportUrl = supportUrl;
485+
if (marketingUrl !== undefined) attributes.marketingUrl = marketingUrl;
486+
487+
try {
488+
const data = await ascFetch(account, "/v1/appStoreVersionLocalizations", {
489+
method: "POST",
490+
body: {
491+
data: {
492+
type: "appStoreVersionLocalizations",
493+
attributes,
494+
relationships: {
495+
appStoreVersion: { data: { type: "appStoreVersions", id: versionId } },
496+
},
497+
},
498+
},
499+
});
500+
apiCache.deleteByPrefix(`apps:version-locs:${versionId}:`);
501+
res.json(normalizeVersionLocalization(data.data));
502+
} catch (err) {
503+
console.error(`Failed to create version localization for ${versionId}:`, err.message);
504+
res.status(502).json({ error: err.message });
505+
}
506+
});
507+
508+
router.patch("/:appId/versions/:versionId/localizations/:locId", async (req, res) => {
509+
const { versionId, locId } = req.params;
510+
const { accountId, description, whatsNew, keywords, promotionalText, supportUrl, marketingUrl } = req.body;
511+
512+
if (!accountId) return res.status(400).json({ error: "accountId is required" });
513+
514+
const accounts = getAccounts();
515+
const account = accounts.find((a) => a.id === accountId);
516+
if (!account) return res.status(400).json({ error: "Account not found" });
517+
518+
const attributes = {};
519+
if (description !== undefined) attributes.description = description;
520+
if (whatsNew !== undefined) attributes.whatsNew = whatsNew;
521+
if (keywords !== undefined) attributes.keywords = keywords;
522+
if (promotionalText !== undefined) attributes.promotionalText = promotionalText;
523+
if (supportUrl !== undefined) attributes.supportUrl = supportUrl;
524+
if (marketingUrl !== undefined) attributes.marketingUrl = marketingUrl;
525+
526+
try {
527+
const data = await ascFetch(account, `/v1/appStoreVersionLocalizations/${locId}`, {
528+
method: "PATCH",
529+
body: {
530+
data: { type: "appStoreVersionLocalizations", id: locId, attributes },
531+
},
532+
});
533+
apiCache.deleteByPrefix(`apps:version-locs:${versionId}:`);
534+
res.json(normalizeVersionLocalization(data.data));
535+
} catch (err) {
536+
console.error(`Failed to update version localization ${locId}:`, err.message);
537+
res.status(502).json({ error: err.message });
538+
}
539+
});
540+
541+
router.delete("/:appId/versions/:versionId/localizations/:locId", async (req, res) => {
542+
const { versionId, locId } = req.params;
543+
const { accountId } = req.query;
544+
545+
const accounts = getAccounts();
546+
const account = accounts.find((a) => a.id === accountId) || accounts[0];
547+
if (!account) return res.status(400).json({ error: "No accounts configured" });
548+
549+
try {
550+
await ascFetch(account, `/v1/appStoreVersionLocalizations/${locId}`, { method: "DELETE" });
551+
apiCache.deleteByPrefix(`apps:version-locs:${versionId}:`);
552+
res.json({ success: true });
553+
} catch (err) {
554+
console.error(`Failed to delete version localization ${locId}:`, err.message);
555+
res.status(502).json({ error: err.message });
556+
}
557+
});
558+
427559
router.post("/:appId/versions/:versionId/submit", async (req, res) => {
428560
const { appId, versionId } = req.params;
429561
const { accountId } = req.body;

src/api/index.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,51 @@ export async function fetchAppLookup(bundleId) {
104104
return res.json();
105105
}
106106

107+
// ── Version Localizations ───────────────────────────────────────────────────
108+
109+
export async function fetchVersionLocalizations(appId, versionId, accountId) {
110+
const params = new URLSearchParams({ accountId });
111+
const res = await fetch(`/api/apps/${appId}/versions/${versionId}/localizations?${params}`);
112+
if (!res.ok) throw new Error(`Failed to fetch version localizations: ${res.status}`);
113+
return res.json();
114+
}
115+
116+
export async function createVersionLocalization(appId, versionId, { accountId, locale, ...fields }) {
117+
const res = await fetch(`/api/apps/${appId}/versions/${versionId}/localizations`, {
118+
method: "POST",
119+
headers: { "Content-Type": "application/json" },
120+
body: JSON.stringify({ accountId, locale, ...fields }),
121+
});
122+
if (!res.ok) {
123+
const err = await res.json().catch(() => ({}));
124+
throw new Error(err.error || `Failed to create version localization: ${res.status}`);
125+
}
126+
return res.json();
127+
}
128+
129+
export async function updateVersionLocalization(appId, versionId, locId, { accountId, ...fields }) {
130+
const res = await fetch(`/api/apps/${appId}/versions/${versionId}/localizations/${locId}`, {
131+
method: "PATCH",
132+
headers: { "Content-Type": "application/json" },
133+
body: JSON.stringify({ accountId, ...fields }),
134+
});
135+
if (!res.ok) {
136+
const err = await res.json().catch(() => ({}));
137+
throw new Error(err.error || `Failed to update version localization: ${res.status}`);
138+
}
139+
return res.json();
140+
}
141+
142+
export async function deleteVersionLocalization(appId, versionId, locId, accountId) {
143+
const params = new URLSearchParams({ accountId });
144+
const res = await fetch(`/api/apps/${appId}/versions/${versionId}/localizations/${locId}?${params}`, { method: "DELETE" });
145+
if (!res.ok) {
146+
const err = await res.json().catch(() => ({}));
147+
throw new Error(err.error || `Failed to delete version localization: ${res.status}`);
148+
}
149+
return res.json();
150+
}
151+
107152
// ── In-App Purchases ─────────────────────────────────────────────────────────
108153

109154
export async function fetchIAPs(appId, accountId) {

src/components/BuildSelector.jsx

Lines changed: 45 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
export default function BuildSelector({ builds, attachedBuild, loading, attaching, attachingBuildId, error, onAttach }) {
1+
import { useState } from "react";
2+
import BuildSelectorModal from "./BuildSelectorModal.jsx";
3+
4+
export default function BuildSelector({ builds, attachedBuild, loading, attaching, attachingBuildId, error, onAttach, isMobile }) {
5+
const [showModal, setShowModal] = useState(false);
6+
27
function formatDate(dateString) {
38
if (!dateString) return "\u2014";
49
const d = new Date(dateString);
@@ -25,6 +30,11 @@ export default function BuildSelector({ builds, attachedBuild, loading, attachin
2530
}
2631
}
2732

33+
async function handleAttachFromModal(buildId) {
34+
await onAttach(buildId);
35+
setShowModal(false);
36+
}
37+
2838
if (loading) {
2939
return (
3040
<div className="text-center py-8 text-dark-dim">
@@ -44,11 +54,9 @@ export default function BuildSelector({ builds, attachedBuild, loading, attachin
4454
}
4555

4656
return (
47-
<div className="space-y-3">
48-
{/* Attached build card */}
49-
{attachedBuild && (
57+
<>
58+
{attachedBuild ? (
5059
<div className="border border-success/30 bg-success/5 rounded-[10px] px-4 py-3">
51-
<div className="text-[10px] text-success font-bold uppercase tracking-wide mb-2">Selected Build</div>
5260
<div className="flex items-center justify-between gap-3">
5361
<div className="min-w-0">
5462
<div className="text-[13px] font-semibold text-dark-text font-mono">
@@ -65,68 +73,42 @@ export default function BuildSelector({ builds, attachedBuild, loading, attachin
6573
)}
6674
</div>
6775
</div>
76+
<button
77+
onClick={() => setShowModal(true)}
78+
className="px-3 py-1.5 rounded-lg text-[11px] font-semibold bg-accent/10 text-accent border border-accent/20 cursor-pointer font-sans hover:bg-accent/20 transition-colors shrink-0"
79+
>
80+
Change
81+
</button>
6882
</div>
6983
</div>
70-
)}
71-
72-
{/* Build list */}
73-
{builds.length === 0 ? (
74-
<div className="text-center py-8 text-dark-ghost">
75-
<div className="text-xs font-semibold">No builds available</div>
76-
<div className="text-[11px] text-dark-dim mt-1">Upload a build via Xcode or Transporter</div>
77-
</div>
7884
) : (
79-
<div className="space-y-1.5">
80-
{builds.map((b) => {
81-
const isAttached = attachedBuild?.id === b.id;
82-
const isAttaching = attaching && attachingBuildId === b.id;
83-
const canSelect = b.processingState === "VALID" && !isAttached;
84-
85-
return (
86-
<div key={b.id} className="bg-dark-surface rounded-[10px] px-4 py-3">
87-
<div className="flex items-center justify-between gap-3">
88-
<div className="min-w-0">
89-
<div className="text-[13px] font-semibold text-dark-text font-mono">
90-
Build {b.version}
91-
</div>
92-
<div className="flex items-center gap-3 mt-1 flex-wrap">
93-
<span className="flex items-center gap-1 text-[11px]">
94-
<span className="w-1.5 h-1.5 rounded-full" style={{ background: stateColor(b.processingState) }} />
95-
<span style={{ color: stateColor(b.processingState) }}>{stateLabel(b.processingState)}</span>
96-
</span>
97-
<span className="text-[11px] text-dark-dim">{formatDate(b.uploadedDate)}</span>
98-
{b.minOsVersion && (
99-
<span className="text-[11px] text-dark-dim">Min OS {b.minOsVersion}</span>
100-
)}
101-
</div>
102-
</div>
103-
<div className="shrink-0">
104-
{isAttached ? (
105-
<span className="px-3 py-1.5 rounded-lg text-[11px] font-semibold text-success bg-success/10 border border-success/20">
106-
Selected
107-
</span>
108-
) : canSelect ? (
109-
<button
110-
onClick={() => onAttach(b.id)}
111-
disabled={attaching}
112-
className="px-3 py-1.5 rounded-lg text-[11px] font-semibold bg-accent/10 text-accent border border-accent/20 cursor-pointer font-sans hover:bg-accent/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
113-
>
114-
{isAttaching ? (
115-
<span className="inline-block" style={{ animation: "asc-spin 1s linear infinite" }}>{"\u21bb"}</span>
116-
) : "Select"}
117-
</button>
118-
) : (
119-
<span className="px-3 py-1.5 rounded-lg text-[11px] font-semibold text-dark-ghost bg-dark-hover">
120-
{stateLabel(b.processingState)}
121-
</span>
122-
)}
123-
</div>
124-
</div>
125-
</div>
126-
);
127-
})}
85+
<div className="bg-dark-surface rounded-[10px] px-4 py-3 flex items-center justify-between">
86+
<div>
87+
<div className="text-[13px] text-dark-dim font-medium">No build selected</div>
88+
<div className="text-[11px] text-dark-ghost mt-0.5">Select a build to attach to this version</div>
89+
</div>
90+
{builds.length > 0 && (
91+
<button
92+
onClick={() => setShowModal(true)}
93+
className="px-3 py-1.5 rounded-lg text-[11px] font-semibold bg-accent/10 text-accent border border-accent/20 cursor-pointer font-sans hover:bg-accent/20 transition-colors shrink-0"
94+
>
95+
Select Build
96+
</button>
97+
)}
12898
</div>
12999
)}
130-
</div>
100+
101+
{showModal && (
102+
<BuildSelectorModal
103+
builds={builds}
104+
attachedBuild={attachedBuild}
105+
attaching={attaching}
106+
attachingBuildId={attachingBuildId}
107+
onAttach={handleAttachFromModal}
108+
onClose={() => setShowModal(false)}
109+
isMobile={isMobile}
110+
/>
111+
)}
112+
</>
131113
);
132114
}

0 commit comments

Comments
 (0)