mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-28 05:22:59 +08:00
fix(whats-new): support localized Arena CTA labels (#7943)
Co-authored-by: zhouzheng <zhouzheng@refly.ai>
This commit is contained in:
co-authored by
zhouzheng
parent
f197844e27
commit
21bba485b6
@@ -82,9 +82,11 @@ function parseLocaleOverrides(value: unknown): Record<string, WhatsNewLocaleCont
|
||||
const title = readNonEmptyString(entry.title);
|
||||
const body = readNonEmptyString(entry.body);
|
||||
const linkUrl = readHttpsUrl(entry.linkUrl);
|
||||
const ctaLabel = readNonEmptyString(entry.ctaLabel);
|
||||
if (title != null) override.title = title;
|
||||
if (body != null) override.body = body;
|
||||
if (linkUrl != null) override.linkUrl = linkUrl;
|
||||
if (ctaLabel != null) override.ctaLabel = ctaLabel;
|
||||
if (Object.keys(override).length > 0) locales[locale] = override;
|
||||
}
|
||||
return Object.keys(locales).length > 0 ? locales : undefined;
|
||||
@@ -105,6 +107,7 @@ export function parseWhatsNewDocument(payload: unknown): { id: string | null; co
|
||||
|
||||
const imageUrl = readHttpsUrl(payload.imageUrl);
|
||||
const linkUrl = readHttpsUrl(payload.linkUrl);
|
||||
const ctaLabel = readNonEmptyString(payload.ctaLabel);
|
||||
const locales = parseLocaleOverrides(payload.locales);
|
||||
return {
|
||||
id,
|
||||
@@ -113,6 +116,7 @@ export function parseWhatsNewDocument(payload: unknown): { id: string | null; co
|
||||
body,
|
||||
...(imageUrl != null ? { imageUrl } : {}),
|
||||
...(linkUrl != null ? { linkUrl } : {}),
|
||||
...(ctaLabel != null ? { ctaLabel } : {}),
|
||||
...(locales != null ? { locales } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -50,6 +50,27 @@ describe('parseWhatsNewDocument', () => {
|
||||
expect(content?.locales?.['zh-CN']?.title).toBe('设计系统同步');
|
||||
});
|
||||
|
||||
it('preserves and trims configured CTA labels through the document parser', () => {
|
||||
const { content } = parseWhatsNewDocument({
|
||||
...DOC,
|
||||
ctaLabel: ' View Arena ',
|
||||
locales: { 'zh-CN': { ctaLabel: ' 查看评测站 ' } },
|
||||
});
|
||||
expect(content).toMatchObject({
|
||||
ctaLabel: 'View Arena',
|
||||
locales: { 'zh-CN': { ctaLabel: '查看评测站' } },
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['', ' ', 42, null])('drops an invalid optional CTA label (%j) without hiding content', (ctaLabel) => {
|
||||
const { content } = parseWhatsNewDocument({
|
||||
...DOC, ctaLabel, locales: { 'zh-CN': { title: '设计系统同步', ctaLabel } },
|
||||
});
|
||||
expect(content?.title).toBe(DOC.title);
|
||||
expect(content).not.toHaveProperty('ctaLabel');
|
||||
expect(content?.locales?.['zh-CN']).toEqual({ title: '设计系统同步' });
|
||||
});
|
||||
|
||||
it('rejects a document without an id (no show-once key)', () => {
|
||||
const { id, content } = parseWhatsNewDocument({ ...DOC, id: '' });
|
||||
expect(id).toBeNull();
|
||||
|
||||
@@ -26,7 +26,7 @@ import styles from './WhatsNewPopup.module.css';
|
||||
// `id` (see ../lib/whats-new).
|
||||
//
|
||||
// It reports what already happened, so its only actions are "close" and "open
|
||||
// the release notes". Applying an update belongs to the real updater indicator
|
||||
// the highlight link". Applying an update belongs to the real updater indicator
|
||||
// (see ./UpdaterPopup.tsx) — this surface must never claim to install anything.
|
||||
|
||||
// Fallback for the CTA when the highlight document omits an explicit link.
|
||||
@@ -65,6 +65,7 @@ type CardModel = {
|
||||
notes: string[];
|
||||
imageUrl: string | null;
|
||||
linkUrl: string;
|
||||
ctaLabel: string | null;
|
||||
};
|
||||
|
||||
// `active` reports whether Home is the active entry view. EntryShell keeps
|
||||
@@ -103,6 +104,7 @@ export function WhatsNewPopup({ active }: { active: boolean }) {
|
||||
notes: whatsNewNotesFromBody(localized.body),
|
||||
imageUrl: info.content.imageUrl ?? null,
|
||||
linkUrl: localized.linkUrl ?? RELEASES_INDEX_URL,
|
||||
ctaLabel: localized.ctaLabel,
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
@@ -195,7 +197,7 @@ export function WhatsNewPopup({ active }: { active: boolean }) {
|
||||
variant="primary"
|
||||
onClick={openLink}
|
||||
>
|
||||
{t('whatsNew.cta')}
|
||||
{card.ctaLabel ?? t('whatsNew.cta')}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@@ -68,11 +68,12 @@ export function whatsNewNotesFromBody(body: string): string[] {
|
||||
export function localizedWhatsNewContent(
|
||||
content: WhatsNewContent,
|
||||
locale: string,
|
||||
): { title: string; body: string; linkUrl: string | null } {
|
||||
): { title: string; body: string; linkUrl: string | null; ctaLabel: string | null } {
|
||||
const override = content.locales?.[locale] ?? content.locales?.[locale.split('-')[0] ?? ''] ?? null;
|
||||
return {
|
||||
title: override?.title ?? content.title,
|
||||
body: override?.body ?? content.body,
|
||||
linkUrl: override?.linkUrl ?? content.linkUrl ?? null,
|
||||
ctaLabel: override?.ctaLabel ?? content.ctaLabel ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -320,6 +320,31 @@ describe('WhatsNewPopup actions', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['en', 'View Arena', 'https://open-design.ai/llm-arena-for-design/'],
|
||||
['zh-CN', '查看评测站', 'https://open-design.ai/zh/llm-arena-for-design/'],
|
||||
])('uses the configured %s CTA and matching Arena destination', async (locale, label, url) => {
|
||||
window.localStorage.setItem('open-design:locale', locale!);
|
||||
window.localStorage.setItem('open-design:locale-source', 'manual');
|
||||
mockedFetchWhatsNew.mockResolvedValue({
|
||||
...SHOW_PAYLOAD,
|
||||
content: {
|
||||
...SHOW_PAYLOAD.content!,
|
||||
ctaLabel: 'View Arena',
|
||||
linkUrl: 'https://open-design.ai/llm-arena-for-design/',
|
||||
locales: {
|
||||
'zh-CN': { ctaLabel: '查看评测站', linkUrl: 'https://open-design.ai/zh/llm-arena-for-design/' },
|
||||
},
|
||||
},
|
||||
});
|
||||
renderCard(true);
|
||||
const cta = await screen.findByRole('button', { name: label });
|
||||
fireEvent.click(cta);
|
||||
expect(mockedOpenExternalUrl).toHaveBeenCalledWith(url);
|
||||
expect(window.localStorage.getItem(WHATS_NEW_LAST_SEEN_STORAGE_KEY)).toBe(SHOW_PAYLOAD.id);
|
||||
expect(screen.queryByTestId('whats-new-popup')).toBeNull();
|
||||
});
|
||||
|
||||
it('close marks the highlight seen and opens no link', async () => {
|
||||
renderCard(true);
|
||||
|
||||
|
||||
@@ -67,6 +67,18 @@ describe('localizedWhatsNewContent', () => {
|
||||
expect(zh.linkUrl).toBe('https://example.com/blog');
|
||||
});
|
||||
|
||||
it('resolves CTA labels through the existing locale and base fallback', () => {
|
||||
const content = {
|
||||
...CONTENT,
|
||||
ctaLabel: 'View Arena',
|
||||
locales: { zh: { ctaLabel: '查看评测站' }, 'zh-CN': { ctaLabel: '查看评测站(中文)' } },
|
||||
};
|
||||
expect(localizedWhatsNewContent(content, 'zh-CN')).toHaveProperty('ctaLabel', '查看评测站(中文)');
|
||||
expect(localizedWhatsNewContent(content, 'zh-TW')).toHaveProperty('ctaLabel', '查看评测站');
|
||||
expect(localizedWhatsNewContent(content, 'fr')).toHaveProperty('ctaLabel', 'View Arena');
|
||||
expect(localizedWhatsNewContent(CONTENT, 'en')).toHaveProperty('ctaLabel', null);
|
||||
});
|
||||
|
||||
it('falls back to the base copy for unknown locales', () => {
|
||||
const fr = localizedWhatsNewContent(CONTENT, 'fr');
|
||||
expect(fr.title).toBe(CONTENT.title);
|
||||
|
||||
+3
-1
@@ -4,11 +4,13 @@
|
||||
"body": "Over a dozen models tested: Compare design quality and cost across models evaluated with the OpenDesign Harness.\nDeepSeek V4.1 Flash Leads in Scores: DeepSeek V4.1 Flash achieved 98% of top-ranked GPT-6 Astra’s average score at just 1% of its average cost.\nFind the right model for you: Explore the benchmarks to choose a model for your design needs and budget.",
|
||||
"imageUrl": "https://whatsnew.open-design.ai/0.18.0.webp",
|
||||
"linkUrl": "https://open-design.ai/llm-arena-for-design/",
|
||||
"ctaLabel": "View Arena",
|
||||
"locales": {
|
||||
"zh-CN": {
|
||||
"title": "OpenDesign Arena 正式上线",
|
||||
"body": "十多款模型实测:使用 OpenDesign Harness,对比模型的设计表现与成本。\nDeepSeek V4.1 Flash 评分领先:DeepSeek V4.1 Flash 的均分达到榜首 GPT-6 Astra 的 98%,平均成本仅为其 1%。\n找到适合你的模型:前往 Arena,根据创作需求与预算选择模型。",
|
||||
"linkUrl": "https://open-design.ai/zh/llm-arena-for-design/"
|
||||
"linkUrl": "https://open-design.ai/zh/llm-arena-for-design/",
|
||||
"ctaLabel": "查看评测站"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-2
@@ -64,11 +64,13 @@ down is an explicit act rather than something a typo can do for you.
|
||||
"body": "Import, edit and sync design systems with cleaner release highlights on Home.",
|
||||
"imageUrl": "https://whatsnew.open-design.ai/0.13.0.png",
|
||||
"linkUrl": "https://github.com/nexu-io/open-design/releases/tag/open-design-v0.13.0",
|
||||
"ctaLabel": "View release notes",
|
||||
"locales": {
|
||||
"zh-CN": {
|
||||
"title": "设计系统同步",
|
||||
"body": "在首页导入、编辑并同步设计系统,发布亮点更清晰。",
|
||||
"linkUrl": "https://open-design.ai/zh/blog/0-13-0/"
|
||||
"linkUrl": "https://open-design.ai/zh/blog/0-13-0/",
|
||||
"ctaLabel": "查看更新说明"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,8 +85,11 @@ parser rather than trusting review:
|
||||
- `imageUrl` — optional, must be `https:`. Omitted → text-only card.
|
||||
- `linkUrl` — optional, must be `https:`. Omitted → the CTA falls back to the
|
||||
GitHub releases index.
|
||||
- `ctaLabel` — optional, non-empty plain-text button label. Omitted → the
|
||||
client's localized release-notes label. Clients predating this field ignore
|
||||
it and keep their built-in label; they need a client update to support it.
|
||||
- `locales` — optional per-locale overrides keyed by app locale id (`en`,
|
||||
`zh-CN`, …); each may override `title`/`body`/`linkUrl`. An exact locale wins,
|
||||
`zh-CN`, …); each may override `title`/`body`/`linkUrl`/`ctaLabel`. An exact locale wins,
|
||||
then the bare language (`zh` for `zh-TW`), then the base fields.
|
||||
|
||||
## Updating the card
|
||||
|
||||
@@ -68,6 +68,20 @@ describe("what's new document guard", () => {
|
||||
await expect(check(validDocument)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
test("accepts configurable base and localized CTA labels", async () => {
|
||||
await expect(check({
|
||||
...validDocument, ctaLabel: "View Arena",
|
||||
locales: { "zh-CN": { ctaLabel: "查看评测站" } },
|
||||
})).resolves.toBe(true);
|
||||
});
|
||||
|
||||
test.each(["", " ", 42, null])("rejects invalid CTA labels (%j) before publishing", async (ctaLabel) => {
|
||||
await expect(check({ ...validDocument, ctaLabel })).resolves.toBe(false);
|
||||
await expect(check({
|
||||
...validDocument, locales: { "zh-CN": { title: "标题", ctaLabel } },
|
||||
})).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test("the empty retirement document passes", async () => {
|
||||
await expect(check({})).resolves.toBe(true);
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface WhatsNewLocaleContent {
|
||||
title?: string;
|
||||
body?: string;
|
||||
linkUrl?: string;
|
||||
/** Optional plain-text action label; omitted uses the client's localized default. */
|
||||
ctaLabel?: string;
|
||||
}
|
||||
|
||||
export interface WhatsNewContent {
|
||||
@@ -21,6 +23,8 @@ export interface WhatsNewContent {
|
||||
imageUrl?: string;
|
||||
/** HTTPS link the "view the release notes" action opens. */
|
||||
linkUrl?: string;
|
||||
/** Optional plain-text action label; omitted uses the client's localized default. */
|
||||
ctaLabel?: string;
|
||||
locales?: Record<string, WhatsNewLocaleContent>;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,8 +43,8 @@ export const WHATS_NEW_DOCUMENT_PATH = "docs/whats-new.json";
|
||||
// typo (`imageURL`, `link_url`, `locale`) that the parser would drop without
|
||||
// complaint, which is exactly the silent-no-card failure this guard exists
|
||||
// to catch.
|
||||
const KNOWN_TOP_LEVEL_KEYS = new Set(["id", "title", "body", "imageUrl", "linkUrl", "locales"]);
|
||||
const KNOWN_LOCALE_KEYS = new Set(["title", "body", "linkUrl"]);
|
||||
const KNOWN_TOP_LEVEL_KEYS = new Set(["id", "title", "body", "imageUrl", "linkUrl", "ctaLabel", "locales"]);
|
||||
const KNOWN_LOCALE_KEYS = new Set(["title", "body", "linkUrl", "ctaLabel"]);
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
@@ -112,6 +112,10 @@ export async function checkWhatsNewDocument(root: string = repoRoot): Promise<bo
|
||||
violations.push("`linkUrl` is present but the parser dropped it; it must be a non-empty https: URL");
|
||||
}
|
||||
|
||||
if (payload.ctaLabel !== undefined && content.ctaLabel == null) {
|
||||
violations.push("`ctaLabel` is present but the parser dropped it; it must be a non-empty string");
|
||||
}
|
||||
|
||||
const rawLocales = payload.locales;
|
||||
if (rawLocales !== undefined) {
|
||||
if (!isObject(rawLocales)) {
|
||||
@@ -119,7 +123,7 @@ export async function checkWhatsNewDocument(root: string = repoRoot): Promise<bo
|
||||
} else {
|
||||
for (const [locale, entry] of Object.entries(rawLocales)) {
|
||||
if (!isObject(entry)) {
|
||||
violations.push(`locale ${JSON.stringify(locale)} must be an object of title/body/linkUrl overrides`);
|
||||
violations.push(`locale ${JSON.stringify(locale)} must be an object of title/body/linkUrl/ctaLabel overrides`);
|
||||
continue;
|
||||
}
|
||||
for (const key of Object.keys(entry)) {
|
||||
@@ -134,7 +138,7 @@ export async function checkWhatsNewDocument(root: string = repoRoot): Promise<bo
|
||||
}
|
||||
for (const key of Object.keys(entry)) {
|
||||
if (!KNOWN_LOCALE_KEYS.has(key)) continue;
|
||||
if (overrides[key as "title" | "body" | "linkUrl"] == null) {
|
||||
if (overrides[key as "title" | "body" | "linkUrl" | "ctaLabel"] == null) {
|
||||
violations.push(
|
||||
`locale ${JSON.stringify(locale)} declares ${JSON.stringify(key)} but the parser dropped it; strings must be non-empty and linkUrl must be https:`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user