One ContextMenu in the design system (keyboard, type-ahead, submenus, check items, disabled reasons, Shift+F10 / Menu key, viewport placement, light and dark). The native menu is prevented globally; text fields keep Cut / Copy / Paste / Select all. Menus on sidebar and portfolio apps, app hero, releases, store art, live art, reviews, checks, editor fields, catalog, prices, accounts, app folders, ignored.
250 lines
16 KiB
TypeScript
250 lines
16 KiB
TypeScript
// Releases: the release timeline (one version on both stores on one track: build -> testing -> review -> live ->
|
|
// rollout %), then every version read from the stores, then sending for review (Submit.tsx): App Store submit /
|
|
// resubmit / in-app purchases and the Google Play production release as owner actions, the Play steps without an API
|
|
// as console plans. Releasing an approved version (manual release) stays in App Store Connect.
|
|
import { plainError } from '../../../../../cli/lib/google-errors';
|
|
import { type CSSProperties, type ReactNode, useState } from 'react';
|
|
import type { AppStatus, ReleaseTrain, StoreRelease } from '../../api';
|
|
import { laneGap, storesDiffer } from '../../../../../cli/lib/train';
|
|
import type { UnifiedApp } from '../../lib/identity';
|
|
import { WriteButton } from '../../ui/WriteButton';
|
|
import { type Key, t } from '../../i18n';
|
|
import { ago, date, share } from '../../lib/format';
|
|
import { type Lane, STAGES, type TestFlightStatus, trainLanes, versionHistory } from '../../lib/model';
|
|
import { useData, useRead, useWrite } from '../../store';
|
|
import { Icon, StoreGlyph } from '../../ui/icons';
|
|
import { RolloutBar } from '../../ui/charts';
|
|
import { Card, Empty, Notice, PhaseChip, phaseLabel, Skeleton, storeName } from '../../ui/kit';
|
|
import { EmptyState, Section, StatusPill, tintStyle, useTint } from '../../ui/ds';
|
|
import { AppleSubmitCard, PlayProductionCard } from './Submit';
|
|
import { useContextMenu } from '../../ui/ContextMenu';
|
|
import { releaseMenu } from '../../ui/app-menu';
|
|
import '../../styles/releases.css';
|
|
|
|
const STAGE_KEY: Record<(typeof STAGES)[number], Key> = { build: 'stage.build', testing: 'stage.testing', review: 'stage.review', live: 'stage.live', rollout: 'stage.rollout' };
|
|
|
|
function stageCaption(l: Lane, i: number): string {
|
|
if (i !== l.at || !l.release) return '';
|
|
const r = l.release;
|
|
if (i === 1) return r.store === 'ios' ? 'TestFlight' : (r.track ?? t('stage.testing'));
|
|
if (i === 2) return r.store === 'android' ? phaseLabel(r).text : t(`phase.${r.phase}` as Key);
|
|
if (i === 3 && r.phase === 'released') return phaseLabel(r).text;
|
|
return '';
|
|
}
|
|
|
|
/** The text of a lane that does not carry this version: couldn't read, the store's own current version, not there yet,
|
|
* or (only without any record of the app on that store) "Not on this store". */
|
|
function gapText(lane: Lane, version: string, st: AppStatus): { who: string; line: string; kind: string } {
|
|
const g = laneGap(lane.store, st.release.all ?? [], st.storeErrors[lane.store]);
|
|
const store = storeName(lane.store);
|
|
if (g.kind === 'unreadable') return { kind: g.kind, who: t('tl.unknown'), line: t('tl.unreadable', { store }) };
|
|
if (g.kind === 'other') return { kind: g.kind, who: g.now.version, line: t('tl.otherVersion', { now: g.now.version, phase: t(`phase.${g.now.phase}` as Key), version }) };
|
|
if (g.kind === 'missing') return { kind: g.kind, who: t('tl.notThisVersion'), line: t('tl.noVersion', { version, store }) };
|
|
return { kind: g.kind, who: t('tl.notOnStore'), line: t('tl.notOnStore') };
|
|
}
|
|
|
|
/** One store's card of a release train: store mark, version (build), the current state as a pill, the five stages
|
|
* as a stepper, a big rollout ring when it is rolling out, and that store's rollout controls. */
|
|
function LaneRow({ lane, version, st, app }: { lane: Lane; version: string; st: AppStatus; app: UnifiedApp }) {
|
|
const r = lane.release;
|
|
const gap = lane.at < 0 ? gapText(lane, version, st) : null;
|
|
const ph = r ? phaseLabel(r) : null;
|
|
const rolling = lane.at === STAGES.length - 1 && lane.share !== null;
|
|
const ringTone = lane.tone === 'bad' ? 'bad' : (lane.share ?? 0) >= 1 ? 'ok' : 'accent';
|
|
const d = useData();
|
|
const menu = useContextMenu();
|
|
return (
|
|
<article {...menu(() => releaseMenu(d, app, lane.store, r?.version ?? version, r?.build))} className="tl-lane rel-store" data-store={lane.store} data-tone={lane.tone} aria-label={storeName(lane.store)}>
|
|
<header className="rel-store-h">
|
|
<span className="rel-mark"><StoreGlyph store={lane.store} /></span>
|
|
<div className="rel-who">
|
|
<div className="rel-store-name">{storeName(lane.store)}</div>
|
|
<div className="rel-store-ver num">{r ? `${r.version ?? version}${r.build ? ` (${r.build})` : ''}` : gap!.who}</div>
|
|
</div>
|
|
{ph && <StatusPill tone={ph.tone} title={r?.state} solid={lane.tone === 'bad'}>{ph.text}</StatusPill>}
|
|
</header>
|
|
{gap ? <div className="tl-none rel-gap" data-gap={gap.kind}><Icon name={gap.kind === 'unreadable' ? 'alert' : 'info'} />{gap.line}</div> : (
|
|
<div className={`rel-body${rolling ? ' has-ring' : ''}`}>
|
|
<ol className="rel-steps" aria-label={t('v2rel.stages', { store: storeName(lane.store) })}>
|
|
{STAGES.map((s, i) => {
|
|
const cls = i < lane.at ? 'done' : i === lane.at ? `cur ${lane.tone}` : 'todo';
|
|
const cap = stageCaption(lane, i);
|
|
return (
|
|
<li key={s} className={`rel-step ${cls}`} title={i === lane.at ? r?.state : undefined} aria-current={i === lane.at ? 'step' : undefined}>
|
|
<span className="rel-node">{i < lane.at ? <Icon name="check" /> : <span className="num">{i + 1}</span>}</span>
|
|
<span className="rel-step-l">{t(STAGE_KEY[s])}</span>
|
|
{cap && <span className="rel-step-c">{cap}</span>}
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
{rolling && lane.share !== null && (
|
|
<div className={`tl-roll rel-ring-wrap ${ringTone}`}>
|
|
<div className="rel-ring" style={{ '--p': String(Math.round(Math.min(1, lane.share) * 100)) } as CSSProperties} aria-hidden="true">
|
|
<span className="rel-ring-v num">{lane.share >= 1 ? '100' : Math.round(lane.share * 100)}<small>%</small></span>
|
|
</div>
|
|
<div className="rel-ring-side">
|
|
<span className="pct">{lane.share >= 1 ? t('tl.everyone') : `${share(lane.share)}${r?.phased?.day ? ` · ${t('tl.day', { day: r.phased.day })}` : ''}`}</span>
|
|
<span className="rel-ring-sub">{t('v2rel.ofUsers')}</span>
|
|
<RolloutBar value={lane.share} tone={ringTone} label={t('tl.rolloutAria')} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{r && app.project && <RolloutControls app={app} r={r} />}
|
|
</article>
|
|
);
|
|
}
|
|
|
|
const SHARES = [1, 2, 5, 10, 20, 50];
|
|
|
|
/** Rollout controls of one store release: Play staged rollout share / halt / resume / complete, iOS phased release
|
|
* pause / resume / everyone, or turning the phased release on for an upcoming iOS version. Owner actions: each one is
|
|
* a dry run first, then a system dialog that names it. */
|
|
function RolloutControls({ app, r }: { app: UnifiedApp; r: StoreRelease }) {
|
|
const w = useWrite(app.project?.id ?? null);
|
|
const pct = r.fraction !== null ? Math.round(r.fraction * 100) : null;
|
|
const [share, setShare] = useState(() => String(SHARES.find(s => pct === null || s > pct) ?? 50));
|
|
if (!app.project) return null;
|
|
const v = r.version ?? '';
|
|
if (r.store === 'android' && (r.phase === 'rollout' || r.phase === 'halted')) {
|
|
const track = r.track && ['production', 'beta', 'alpha', 'internal'].includes(r.track) ? r.track : 'production';
|
|
const next = SHARES.filter(s => pct === null || s > pct);
|
|
return (
|
|
<div className="roll-ctl">
|
|
<span className="small muted">{t('roll.playNow', { pct: pct ?? 0 })}</span>
|
|
{r.phase === 'rollout' && next.length > 0 && <>
|
|
<select className="input input-sm" value={share} onChange={e => setShare(e.target.value)} aria-label={t('roll.share')}>{next.map(s => <option key={s} value={s}>{s} %</option>)}</select>
|
|
<WriteButton run={w} icon="arrowRight" w={{ method: 'owner.play_rollout', args: { action: 'percent', percent: Number(share), track }, title: t('roll.toShare', { pct: share }), done: t('roll.done') }}>{t('roll.toShare', { pct: share })}</WriteButton>
|
|
</>}
|
|
{r.phase === 'rollout' && <WriteButton run={w} icon="pause" w={{ method: 'owner.play_rollout', args: { action: 'halt', track }, title: t('roll.halt'), done: t('roll.halted') }} hint={t('roll.haltHint')}>{t('roll.halt')}</WriteButton>}
|
|
{r.phase === 'halted' && <WriteButton run={w} icon="play" w={{ method: 'owner.play_rollout', args: { action: 'resume', track }, title: t('roll.resume'), done: t('roll.done') }}>{t('roll.resume')}</WriteButton>}
|
|
<WriteButton run={w} icon="users" w={{ method: 'owner.play_rollout', args: { action: 'complete', track }, title: t('roll.complete'), done: t('roll.done') }} hint={t('roll.completeHint')}>{t('roll.complete')}</WriteButton>
|
|
</div>
|
|
);
|
|
}
|
|
if (r.store === 'ios' && r.phased && (r.phased.state === 'ACTIVE' || r.phased.state === 'PAUSED')) {
|
|
return (
|
|
<div className="roll-ctl">
|
|
<span className="small muted">{t('roll.iosNow', { day: r.phased.day ?? 0 })}</span>
|
|
{r.phased.state === 'ACTIVE' && <WriteButton run={w} icon="pause" w={{ method: 'owner.ios_phased', args: { action: 'pause', version: v }, title: t('roll.pause'), done: t('roll.paused') }} hint={t('roll.pauseHint')}>{t('roll.pause')}</WriteButton>}
|
|
{r.phased.state === 'PAUSED' && <WriteButton run={w} icon="play" w={{ method: 'owner.ios_phased', args: { action: 'resume', version: v }, title: t('roll.resume'), done: t('roll.done') }}>{t('roll.resume')}</WriteButton>}
|
|
<WriteButton run={w} icon="users" w={{ method: 'owner.ios_phased', args: { action: 'complete', version: v }, title: t('roll.everyone'), done: t('roll.done') }} hint={t('roll.everyoneHint')}>{t('roll.everyone')}</WriteButton>
|
|
</div>
|
|
);
|
|
}
|
|
if (r.store === 'ios' && !r.phased && ['draft', 'review', 'approved'].includes(r.phase) && /^\d+(\.\d+){0,2}$/.test(v)) {
|
|
return (
|
|
<div className="roll-ctl">
|
|
<WriteButton run={w} icon="layers" w={{ method: 'owner.ios_phased', args: { action: 'on', version: v }, title: t('roll.phasedOn'), done: t('roll.phasedOnDone') }} hint={t('roll.phasedOnHint')}>{t('roll.phasedOn')}</WriteButton>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function Train({ title, train, tf, tone, app, st }: { title: string; train: ReleaseTrain; tf: TestFlightStatus | null; tone: 'accent' | 'ok'; app: UnifiedApp; st: AppStatus }) {
|
|
const lanes = trainLanes(train, tf);
|
|
// the stores' current versions differ (normal: they do not always move together): a neutral "Current" header
|
|
const differ = tone === 'ok' ? storesDiffer(st.release.all ?? []) : null;
|
|
// "Live" only when a store has it live or rolling out; a Play release whose store page could not be checked is not
|
|
const label = differ ? t('tl.current') : tone === 'ok' && !lanes.some(l => l.release && ['live', 'rollout', 'halted'].includes(l.release.phase)) ? t('tl.released') : title;
|
|
const ver = differ ? t('tl.bothVersions', { play: differ.android, ios: differ.ios }) : train.version;
|
|
const created = train.ios?.date ? t('tl.created', { date: date(train.ios.date) }) : null;
|
|
return (
|
|
<section className={`rel-train ${tone}`} aria-label={`${label} ${ver}`}>
|
|
<header className="rel-train-h">
|
|
<div className="rel-train-title">
|
|
<span className="rel-train-eyebrow">{t('v2rel.version')}</span>
|
|
<span className="rel-train-ver num">{ver}</span>
|
|
</div>
|
|
<StatusPill tone={differ ? 'neutral' : tone === 'ok' && label !== title ? 'info' : tone} solid={tone === 'accent' && !differ}>{label}</StatusPill>
|
|
<span className="grow" />
|
|
{created && <span className="rel-train-meta"><Icon name="clock" />{created}</span>}
|
|
</header>
|
|
<div className="rel-lanes">
|
|
{lanes.map(l => <LaneRow key={l.store} lane={l} version={train.version} st={st} app={app} />)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function ReleasesTab({ app }: { app: UnifiedApp }) {
|
|
const d = useData();
|
|
const menu = useContextMenu();
|
|
const l = d.liveFor(app);
|
|
const project = app.project;
|
|
const hue = useTint(d.iconFor(app), app.name);
|
|
const tf = useRead<TestFlightStatus>(app.target, 'apple.testflight_status', { network: true, title: 'TestFlight', skip: !app.ios && !d.statusFor(app)?.app?.platforms.ios });
|
|
const sum = project ? d.statusOf(project)?.summary : null;
|
|
const submit = project && (sum?.apple || sum?.play) ? (
|
|
<section className="rel-submit stack" aria-labelledby="sub-section" data-section="submit">
|
|
<div className="rel-sec-h">
|
|
<h2 id="sub-section" className="rel-sec-title"><span className="ds-sec-ic"><Icon name="send" /></span>{t('sub.section')}</h2>
|
|
<p className="rel-sec-sub">{t('releases.ownerNote')}</p>
|
|
</div>
|
|
<div className="rel-submit-grid">
|
|
{sum?.apple && <AppleSubmitCard app={app} project={project} />}
|
|
{sum?.play && <PlayProductionCard app={app} project={project} />}
|
|
</div>
|
|
</section>
|
|
) : null;
|
|
const wrap = (children: ReactNode) => <div className="scr-releases" style={tintStyle(hue)}>{children}</div>;
|
|
|
|
if (!l && d.portfolioBusy === 'live') return wrap(<><Card><Skeleton lines={5} block /></Card>{submit}</>);
|
|
if (!l) {
|
|
return wrap(
|
|
<>
|
|
<Card pad={false}>
|
|
<Empty illo="cloud" title={t('releases.notReadTitle')} actions={<>
|
|
<button className="btn btn-primary" onClick={() => d.refreshPortfolio(true)} disabled={!!d.portfolioBusy}><Icon name="refresh" />{t('action.readStores')}</button>
|
|
{project && !project.playAccount && !project.appleAccount && <span className="small muted">{t('releases.noAccounts')}</span>}
|
|
</>}>{t('releases.notReadText')}</Empty>
|
|
</Card>
|
|
{submit}
|
|
</>,
|
|
);
|
|
}
|
|
const history = versionHistory(l.release.all ?? []);
|
|
const errs = Object.entries(l.storeErrors);
|
|
return wrap(
|
|
<>
|
|
{errs.map(([s, msg]) => {
|
|
const when = l.staleStores?.[s as 'ios'];
|
|
return <Notice key={s} tone="warn">{when ? t('releases.storeStale', { store: storeName(s as 'ios'), when: ago(when), error: plainError(msg) }) : t('releases.storeUnreadable', { store: storeName(s as 'ios'), error: plainError(msg) })}</Notice>;
|
|
})}
|
|
{l.release.next && <Train title={t('tl.next')} train={l.release.next} tf={tf.data} tone="accent" app={app} st={l} />}
|
|
{l.release.current && <Train title={t('tl.live')} train={l.release.current} tf={tf.data} tone="ok" app={app} st={l} />}
|
|
{!l.release.next && !l.release.current && <Section><EmptyState icon="layers">{t('releases.noVersions')} {t('releases.noVersionsText')}</EmptyState></Section>}
|
|
|
|
<Section title={t('releases.history')} icon="layers" sub={t('releases.historySub', { when: ago(d.live?.at ?? d.portfolio?.at) })} pad={false} className="rel-history"
|
|
actions={history.length ? <span className="rel-count">{t('v2rel.versions', { count: history.length })}</span> : undefined}>
|
|
{history.length ? (
|
|
<div className="ds-table-wrap">
|
|
<table className="table v2">
|
|
<thead><tr><th>{t('col.version')}</th><th>App Store</th><th>{t('col.build')}</th><th>Google Play</th><th>{t('col.versionCode')}</th><th>{t('col.track')}</th></tr></thead>
|
|
<tbody>
|
|
{history.map(h => (
|
|
<tr key={h.version} {...menu(() => [
|
|
...(h.ios ? [{ label: 'App Store', icon: 'phone' as const, submenu: releaseMenu(d, app, 'ios', h.ios.version, h.ios.build) }] : []),
|
|
...(h.android ? [{ label: 'Google Play', icon: 'phone' as const, submenu: releaseMenu(d, app, 'android', h.android.version, h.android.build) }] : []),
|
|
])}>
|
|
<td className="strong num rel-hv">{[...new Set([h.version, h.ios?.version, h.android?.version].filter(Boolean))].join(' / ')}</td>
|
|
<td>{h.ios ? <PhaseChip r={h.ios} /> : <span className="muted small">–</span>}</td>
|
|
<td className="num muted">{h.ios?.build ?? '–'}</td>
|
|
<td>{h.android ? <PhaseChip r={h.android} /> : <span className="muted small">–</span>}</td>
|
|
<td className="num muted">{h.android?.build ?? '–'}</td>
|
|
<td className="muted">{h.android?.track ?? '–'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : <div className="ds-sec-b"><EmptyState icon="layers">{t('releases.noVersions')}</EmptyState></div>}
|
|
</Section>
|
|
|
|
{submit}
|
|
</>,
|
|
);
|
|
}
|