mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
cee05a57b5
* feat(viewer): split Share / Export / Handoff back into three header entries
0.18.0 merged sharing, exporting, and editor handoff into one Share
popover with three tabs. Export reach halved (per-capita export success
fell from ~34% to ~17% among 0.18.x users) and the handoff panel's reach
fell from 41% to 7% of artifact creators — the tabs buried both flows one
level deep behind a default Share tab.
- Share and Export are separate header buttons again; they keep sharing
one popover shell so switching intents keeps the menu anchored, but the
tab strip inside the popover is gone.
- Handoff returns as the standalone split button (trigger + caret) next
to them, restoring the pre-0.18 control and its trigger/caret analytics.
- Outside-click dismissal is scoped so the popover and the handoff menu
are mutually exclusive.
- HandoffButton now mounts on every artifact view, so a malformed
/api/editors response degrades to an empty editor list instead of
crashing the viewer.
- The caret picks up the 28px Design-Files-row height override that the
trigger already had.
Both viewer chromes (HtmlViewer and ReactComponentViewer) get the same
structure. Header analytics elements share_dropdown / download_dropdown /
handoff trigger reconnect with their pre-0.18 history.
* feat(viewer): Share panel owns publish-online, social share, and save-as-template
Product feedback on the three-entry split (孙庆雨, 8/9): everything that
produces a link or a reusable asset is sharing, so 发布到线上 (Deploy to
Vercel / Cloudflare Pages, plus the copy/open share-link rows once
deployed), 社媒分享, and 保存为模板 move from the Export panel into the
Share panel. Export becomes a pure file-format menu (PDF / image / .zip /
standalone HTML / deck- and markdown-gated formats).
The 'publish online first to enable share' guide row is deleted rather
than moved: on the Share panel the publish button directly above IS that
step, so the row only restated the obvious.
* fix(viewer): keep the 6px gap between the Share and Export header buttons
The unified wrapper's inline-flex + gap rule was silently losing to
viewer/tools.css's later-imported `.share-menu { display: inline-block }`
(equal specificity, later cascade), so the two buttons rendered flush.
Doubling the class selector wins regardless of import order.
* feat(viewer): reorganize the Share panel by intent — hero publish, inline social, secondary deploy
Product alignment (孙庆雨, 8/9): the panel read as four unrelated piles
(publish card / publish-online / social / save). Reorganized around the
one dominant intent — get a link:
- The publish card is the hero, retitled 发布并获取链接 / 'Publish & get
a link' across all 19 locales.
- Social icons render inline (SocialShareGrid) only once a CLEAN link
exists — published file or a deployment whose share URL is live. The
'deploy first, then share to social…' teaser row and its whole section
are gone, along with the now-unused openSocialShareFlow entry and
socialShareMenuLabel. A published-file URL now unlocks social sharing
exactly like a ready deployment (socialShareDisplayUrl falls back to
publishedFileUrl); protected/preparing deployments offer no icons.
- Vercel / Cloudflare demote to a 更多发布方式 / 'MORE WAYS TO PUBLISH'
secondary tier (shareMenuPublishOnline retitled in all 19 locales).
- Save-as-template keeps its spot at the bottom.
* feat(viewer): de-shell the publish hero — the button is the headline
Product feedback (孙庆雨, 8/9): the grey publish card spoke a different
visual language from the menu rows below it, and its copy talked past
itself (title said 'publish & get a link', button said 'publish file',
and the failure line said 'try again' when retrying wasn't the fix).
- The card container and separate title row are gone: the panel opens
with one solid primary button carrying the headline (发布并获取链接),
a muted one-liner under it (任何拿到链接的人都可在线查看 — description
shortened in all 19 locales), and the error line last.
- Failure copy now offers a way out: 发布失败,请稍后重试,或改用下方的
部署方式 (all 19 locales). The signed-out failure branch is untouched
in code but per product no longer reachable — everyone signs in now.
- Published state keeps the link block + copy + unpublish, unchanged.
- Both viewer copies (HtmlViewer, ReactComponentViewer) share the
structure; new .chrome-publish-plain / .chrome-publish-hint styles.
* feat(viewer): publish hero gets a section title and a help tooltip
Product feedback (孙庆雨, 8/9): the hero should align with the tiers
below it — a section label like theirs, not a floating button.
- New section label 通过 Open Design 发布 / PUBLISH VIA OPEN DESIGN
(new i18n key in types.ts + all 19 locales) sits above the button,
matching 更多发布方式 and 保存.
- The always-visible hint line is replaced by a small "?" beside the
button whose od-tooltip explains reach AND the current limitation:
only a single file can be published — embedded local assets such as
images aren't supported yet (description re-written in 19 locales).
- Both viewer copies share the structure; new .chrome-publish-cta /
.chrome-publish-help styles, .chrome-publish-hint removed.
* fix(viewer): align the publish hero column with the menu rows
The section rows inset their content 10px; the publish stack only 2px,
so the button jutted 8px left of the text line and the help ? touched
the popover edge. The hero now shares the rows' horizontal inset.
* feat(viewer): publish becomes a menu row like every other share action
The hero still read as a foreign body: a solid button + floating help
circle next to plain icon+label rows. Publishing now uses the exact same
row as 部署到 Vercel / 保存为模板 — same section label, same
icon + label, same 36px row box — with the help '?' pushed to the row
end as a tooltip span inside the row.
Measured parity after the change: all four rows share x/width/height
(346x36) and 8px 10px padding; all three section labels share the same
left edge.
The published state keeps its link block (content, not an action) and
the failure line moves outside it so it carries the rows' own inset.
Dead .chrome-publish-primary / -cta / -help styles removed.
* feat(viewer): the workspace-access control joins the same row language
With publishing converted to a menu row, the team card was the last grey
box in the panel. It now uses the section-label + control layout of every
other tier: label 在工作空间中分享项目 with a trailing '?' carrying the
state-dependent explanation (private vs workspace), and the access
dropdown inset to the rows' 10px so its left edge lines up with the row
icons below.
Verified with the team gate forced on: all four section labels share one
left edge and the dropdown aligns with the rows' content column.
Dead .chrome-share-card* styles removed.
* feat(viewer): export leads the header pair and takes the dark treatment
Export is the far more used of the two intents (30 days: ~14k users
exported successfully, ~13.9k; only ~620 attempted a deploy and ~1.2k
saved a template), so it takes the lead slot and the solid dark button
while share steps back to the light secondary.
Applied in both chromes (workspace header and the React component
viewer toolbar). The dark modifier is renamed .chrome-action-share-dark
-> .chrome-action-dark since it now marks the lead action, not sharing.
* fix(viewer): the share/export popover hugs its content
The popover was pinned to 360px while its rows are short labels, so both
menus rendered with a wide empty gutter on the right. It now sizes to
max-content between a 248px floor (keeps the four-row Export menu from
looking cramped) and the previous 360px ceiling (long deploy labels and
a published URL still fit; the URL block already ellipsises).
Measured after the change: both menus render at 248px with no row
overflow, down from 360px.
* feat(viewer): the share panel speaks share, not publish
The button says 分享 / Share but every line inside said 发布 / publish,
so users had to translate mechanism words back into their intent. The
panel now describes outcomes in link terms and lets the mechanism serve
the goal (孙庆雨, 8/9):
- 通过 Open Design 发布 -> 快速分享(Open Design 托管) / QUICK SHARE · OPEN DESIGN
- 发布并获取链接 -> 生成分享链接 / Get a share link
- 更多发布方式 -> 分享到自有平台 / SHARE ON YOUR OWN HOSTING
- 发布中… -> 生成中… ; 取消发布 -> 停止分享
- failure, tooltip and signed-out copy re-worded to match
Deploy rows keep 部署到 Vercel / Cloudflare Pages: deploy is those
platforms' own verb, and the section header now frames them as a way of
sharing. All eight keys updated across 19 locales (152 values); no
analytics names change (publish_file / copy_publish_link /
artifact_publish_result stay as-is).
* fix(viewer): harden the /api/editors response against off-contract hosts
HandoffButton now mounts in the artifact viewer chrome on every artifact,
which puts it on the viewer's crash path. `fetchHostEditors` casts the raw
/api/editors JSON to HostEditorsResponse without validating it, so the
previous `resp.editors ?? []` guard only covered null/undefined: a body of
`{"editors": {}}` or `{"editors": [null]}` still reached `editors.filter`
and took the whole viewer down with a TypeError.
Normalize at the boundary instead. `normalizeHostEditors` requires an
array and keeps only entries carrying a non-empty string id and label,
coerces `available` to a real boolean so a truthy-but-wrong value reads as
not-detected rather than a launch target, and de-dupes ids that would
otherwise collide as React keys. `normalizeHostPlatform` degrades an
unrecognized platform to `unknown` instead of leaking the raw string into
the open-in call. A non-object body is null-guarded before either runs.
The new spec is red on the parent commit: 3 of its 5 cases fail, including
both payloads from review, which throw at the `editors.filter` call.
* fix(viewer): reading the publish help must not publish
The publish row's trailing "?" carries the reach + single-file-limitation
copy — exactly what a user wants to read BEFORE committing — but it was
nested inside the same role="menuitem" button whose onClick calls
publishCurrentFilePublic() unconditionally. Activating the help therefore
created a public link. Hover hides this on desktop; on touch there is no
hover path at all, so pressing was the only way to read it. A gesture
whose intent is "explain this" was silently making an externally visible
URL.
Move the "?" onto the shareMenuPublishViaOd section label, outside the
actionable row — the same structure the workspace-access help above
already uses, so the two help affordances now behave identically. Both
viewer chromes are updated. .share-menu-help keeps its margin-inline-start
auto, so it still sits at the end of its label.
Red spec in FileViewer.test.tsx covers both viewer copies, locating the
help by the explanation it carries rather than by a testid the fix added,
so it fails on the pre-fix nesting rather than on a missing hook.
Verified on a tools-dev runtime: clicking the "?" issues no publish-public
POST and leaves the panel open; light and dark both render the "?" at the
label end with the section labels still sharing one left edge.
* test(e2e): follow the header split in the Playwright suites
The header split removed the share popover's tab strip, but the Playwright
suites still drove the old shape — they clicked Share and then reached for
a role=tab named Export/Share/Send to…. Those tabs no longer exist, so the
UI P0 shards project-workspace and project-workspace-editor both failed on
this branch's only CI run (85a1cb14, 8/9); every later push has failed to
trigger ci.yml at all, so the breakage stayed invisible.
Two different corrections, because the split also moved rows between the
two panels:
- project-management-flows: the share-link / share-page / deploy-target
rows used to sit under the popover's Export tab. Share owns them now, so
the helper takes the Share door (openShareMenu). Reaching them through
Export opens the right popover and then finds nothing.
- app-manual-edit: the file-format rows (PDF, PPTX, standalone HTML) are
Export's, and Export is its own header button, so openShareExportMenu
clicks it directly instead of Share-then-tab. Two call sites that opened
Share and then looked for "Export as …" are routed through the helper.
The toolbar smoke that asserted the tab strip now asserts the split it
replaced: all three header controls present, and Share not offering file
formats.
Verified per-case against a tools-dev runtime (workspace prebuild first —
without it the seeding API fails and every case dies in setup):
- @critical preview toolbar keeps share/download/comment/zoom — passed
- share menu copies the current share link — passed
- share menu opens the current share page — passed
- @critical share menu publish opens the deploy flow — passed
- first-loop onboarding completes after an artifact export — passed
The PPTX call site is not covered by a green run: its case dies earlier in
seedDeckArtifact, a daemon-side upload failure this branch cannot cause —
the diff against main touches only apps/web and its unit tests.
* fix(viewer): make the panel help reachable, and level the split button
Three review findings, all verified against the source before acting.
**The help "?" was unreachable without a pointer.** The tooltip layer opens on
`focusin` (TooltipLayer.tsx), but the affordance was a `<span>`, which never
receives focus — and touch has no hover path at all. So the reach + single-file
limitation copy, and the workspace-access explanation, were pointer-only. Both
are now real `<button type="button">`, which also means shedding the global
button box (36px, bordered, filled) to stay a 14px inline glyph. They keep the
"outside the actionable row" invariant the publish-help specs already pin.
**The split button was uneven wherever nothing sized it.** `.handoff-caret` had
no height, so it inherited `button { height: 36px }` while `.handoff-trigger` is
30px, and `.handoff-split`'s `align-items: stretch` rendered the mismatch. The
`.ws-tabs-actions` override sized both to 28px, which hid this on the Html path
while the viewer-toolbar mount stayed uneven. The caret now declares 30px to
match its trigger, so the pair is correct before any host override.
**Share wore Export's primary treatment.** One hard-coded class string served
both tabs in ReactComponentViewer, so Share got the accent fill too and the
intended hierarchy — Export leads, Share is the quieter neighbour, as the Html
chrome already does — was flat. The class is now branched by tab.
Specs assert the tag, the button type, and that focus actually lands, for both
viewer chromes plus the workspace-access copy; they fail on the parent commit
with `expected 'SPAN' to be 'BUTTON'`.
Verified on a tools-dev runtime: one Tab from the Share button lands on the "?"
and opens the tooltip carrying the limitation copy, with no publish request;
trigger and caret both measure 30px in the viewer toolbar; Export renders dark
filled against Share's transparent ground.
Web suite 609 files / 6311 passed. An earlier run showed 4 failures — favicon
URL, BYOK array length, iframe download timeout, annotation File instance —
none reachable from this diff; a stashed baseline and a second run were both
green, so they are concurrency flake, not regressions.
---------
Co-authored-by: elifive555555 <296440099+elifive555555@users.noreply.github.com>
183 lines
6.4 KiB
TypeScript
183 lines
6.4 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
|
|
import { FileViewer } from '../../src/components/FileViewer';
|
|
import type { ProjectFile } from '../../src/types';
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
function baseFile(overrides: Partial<ProjectFile>): ProjectFile {
|
|
return {
|
|
name: 'asset.png',
|
|
path: 'asset.png',
|
|
type: 'file',
|
|
size: 1024,
|
|
mtime: 1710000000,
|
|
kind: 'image',
|
|
mime: 'image/png',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function deployableHtmlFile(): ProjectFile {
|
|
return baseFile({
|
|
name: 'index.html',
|
|
path: 'index.html',
|
|
mime: 'text/html',
|
|
kind: 'html',
|
|
artifactManifest: {
|
|
version: 1,
|
|
kind: 'html',
|
|
title: 'Page',
|
|
entry: 'index.html',
|
|
renderer: 'html',
|
|
exports: ['html'],
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Wires the fetch routes the Cloudflare Pages deploy modal exercises on open
|
|
* and on submit, and reports the JSON body of the outgoing deploy POST back
|
|
* to the caller so a test can assert what target the UI forwarded.
|
|
*/
|
|
function mockDeployFetch(onDeployBody: (body: Record<string, unknown>) => void) {
|
|
return vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
|
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
|
|
const method = init?.method || (input instanceof Request ? input.method : 'GET');
|
|
|
|
if (url === '/api/projects/project-1/deployments') {
|
|
return new Response(JSON.stringify({ deployments: [] }), { status: 200 });
|
|
}
|
|
if (url === '/api/deploy/config?providerId=cloudflare-pages') {
|
|
return new Response(JSON.stringify({
|
|
providerId: 'cloudflare-pages',
|
|
configured: true,
|
|
tokenMask: 'saved-cloudflare-token',
|
|
teamId: '',
|
|
teamSlug: '',
|
|
accountId: 'account-123',
|
|
projectName: '',
|
|
target: 'preview',
|
|
}), { status: 200 });
|
|
}
|
|
if (url === '/api/deploy/cloudflare-pages/zones') {
|
|
return new Response(JSON.stringify({ zones: [] }), { status: 200 });
|
|
}
|
|
if (url === '/api/projects/project-1/deploy' && method === 'POST') {
|
|
const body = JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>;
|
|
onDeployBody(body);
|
|
return new Response(JSON.stringify({
|
|
id: 'cloudflare-deploy',
|
|
projectId: 'project-1',
|
|
fileName: 'index.html',
|
|
providerId: 'cloudflare-pages',
|
|
url: 'https://demo-pages.pages.dev',
|
|
deploymentId: 'cf-dep-1',
|
|
deploymentCount: 1,
|
|
target: body.target ?? 'preview',
|
|
status: 'ready',
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
}), { status: 200 });
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
});
|
|
}
|
|
|
|
async function openCloudflareDeployModal(file: ProjectFile) {
|
|
render(
|
|
<FileViewer projectId="project-1" projectKind="prototype" file={file}
|
|
liveHtml="<html><body><h1>Hello</h1></body></html>"
|
|
/>,
|
|
);
|
|
|
|
// Deploy providers live on the Share panel ("publish online" is sharing),
|
|
// so reaching a provider takes Share button -> menu item.
|
|
fireEvent.click(screen.getByRole('button', { name: /^share$/i }));
|
|
fireEvent.click(await screen.findByRole('menuitem', { name: /Deploy to Cloudflare Pages/i }));
|
|
|
|
const providerSelect = await screen.findByRole('combobox', { name: /Provider/i });
|
|
await waitFor(() => {
|
|
expect((providerSelect as HTMLSelectElement).value).toBe('cloudflare-pages');
|
|
});
|
|
}
|
|
|
|
function clickDeploySubmitButton() {
|
|
const deployButtons = screen.getAllByRole('button', { name: /^Deploy$/i });
|
|
// The share-menu trigger is also labelled "Deploy to Cloudflare Pages"; the
|
|
// modal's own submit button is the last "Deploy"-named button on screen.
|
|
fireEvent.click(deployButtons[deployButtons.length - 1]!);
|
|
}
|
|
|
|
describe('FileViewer deploy target selector', () => {
|
|
it('shows a deploy target selector defaulted to Production and forwards that default on deploy', async () => {
|
|
let deployBody: Record<string, unknown> | null = null;
|
|
vi.stubGlobal('fetch', mockDeployFetch((body) => { deployBody = body; }));
|
|
|
|
await openCloudflareDeployModal(deployableHtmlFile());
|
|
|
|
const targetSelect = await screen.findByRole('combobox', { name: /target/i });
|
|
expect((targetSelect as HTMLSelectElement).value).toBe('production');
|
|
|
|
clickDeploySubmitButton();
|
|
|
|
await waitFor(() => {
|
|
expect(deployBody).not.toBeNull();
|
|
});
|
|
// Default semantics: the daemon already treats an absent target as
|
|
// production (apps/daemon/src/routes/deploy.ts), so the UI's default
|
|
// must match that and explicitly send 'production' — leaving it
|
|
// undefined or sending 'preview' would silently deploy to preview
|
|
// instead of updating the live site, which is the regression this test
|
|
// guards against.
|
|
expect(deployBody!.target).toBe('production');
|
|
});
|
|
|
|
it('sends target: "preview" in the deploy request when the user selects the Preview target', async () => {
|
|
let deployBody: Record<string, unknown> | null = null;
|
|
vi.stubGlobal('fetch', mockDeployFetch((body) => { deployBody = body; }));
|
|
|
|
await openCloudflareDeployModal(deployableHtmlFile());
|
|
|
|
const targetSelect = await screen.findByRole('combobox', { name: /target/i });
|
|
fireEvent.change(targetSelect, { target: { value: 'preview' } });
|
|
await waitFor(() => {
|
|
expect((targetSelect as HTMLSelectElement).value).toBe('preview');
|
|
});
|
|
|
|
clickDeploySubmitButton();
|
|
|
|
await waitFor(() => {
|
|
expect(deployBody).not.toBeNull();
|
|
});
|
|
expect(deployBody!.target).toBe('preview');
|
|
});
|
|
|
|
it('sends target: "production" in the deploy request when the user selects the Production target', async () => {
|
|
let deployBody: Record<string, unknown> | null = null;
|
|
vi.stubGlobal('fetch', mockDeployFetch((body) => { deployBody = body; }));
|
|
|
|
await openCloudflareDeployModal(deployableHtmlFile());
|
|
|
|
const targetSelect = await screen.findByRole('combobox', { name: /target/i });
|
|
fireEvent.change(targetSelect, { target: { value: 'production' } });
|
|
await waitFor(() => {
|
|
expect((targetSelect as HTMLSelectElement).value).toBe('production');
|
|
});
|
|
|
|
clickDeploySubmitButton();
|
|
|
|
await waitFor(() => {
|
|
expect(deployBody).not.toBeNull();
|
|
});
|
|
expect(deployBody!.target).toBe('production');
|
|
});
|
|
});
|