storebound/apps/desktop/src/screens/tabs/TestFlight.tsx

150 lines
9.5 KiB
TypeScript

// TestFlight: beta groups (internal / external, testers, builds, public link) and the recent builds with their
// processing and beta review states.
import { Fragment, useState } from 'react';
import type { UnifiedApp } from '../../lib/identity';
import { WriteButton } from '../../ui/WriteButton';
import { t } from '../../i18n';
import { date, num } from '../../lib/format';
import type { TestFlightStatus, TfBuild, TfGroup, Tone } from '../../lib/model';
import { useData, useRead, useWrite } from '../../store';
import { Icon } from '../../ui/icons';
import { Card, Chip, Empty, Notice, ReadControl, Skeleton } from '../../ui/kit';
/** Beta state word -> tone and a short label. */
export function betaState(s: string | null): { tone: Tone; text: string } {
if (!s) return { tone: 'neutral', text: '–' };
if (/IN_BETA_TESTING|BETA_APPROVED|READY_FOR_BETA_TESTING|VALID/.test(s)) return { tone: 'ok', text: s.replace(/_/g, ' ').toLowerCase() };
if (/WAITING|IN_BETA_REVIEW|PROCESSING|READY_FOR_BETA_SUBMISSION/.test(s)) return { tone: 'warn', text: s.replace(/_/g, ' ').toLowerCase() };
if (/EXPIRED/.test(s)) return { tone: 'neutral', text: s.replace(/_/g, ' ').toLowerCase() };
if (/REJECTED|FAILED|INVALID|MISSING_EXPORT_COMPLIANCE/.test(s)) return { tone: 'bad', text: s.replace(/_/g, ' ').toLowerCase() };
return { tone: 'neutral', text: s.replace(/_/g, ' ').toLowerCase() };
}
function State({ s }: { s: string | null }) {
const b = betaState(s);
return s ? <Chip tone={b.tone}>{b.text}</Chip> : <span className="muted small">–</span>;
}
/** One build's "What to Test" and its internal groups: confirm-gated writes (dry run, dialog, apply). */
function BuildEditor({ b, groups, w, mode, onDone }: { b: TfBuild; groups: TfGroup[]; w: ReturnType<typeof useWrite>; mode: 'what' | 'groups'; onDone: () => void }) {
const [text, setText] = useState('');
const internal = groups.filter(g => g.internal);
const [picked, setPicked] = useState<string[]>(internal.map(g => g.name));
const base = { build: b.build ?? '', ...(b.version ? { version: b.version } : {}) };
if (mode === 'what') {
return (
<div className="build-edit">
<label className="small strong" htmlFor={`wtt-${b.build}`}>{t('tf.whatToTest', { build: b.build ?? '' })}</label>
<textarea id={`wtt-${b.build}`} className="input" rows={3} maxLength={4000} value={text} onChange={e => setText(e.target.value)} placeholder={t('tf.whatPlaceholder')} />
<div className="actions">
<WriteButton run={w} icon="check" primary disabled={!text.trim() || text.trim().startsWith('-')} w={{ method: 'apple.testflight_what_to_test', args: { ...base, text: text.trim() }, title: t('tf.whatSave'), done: t('tf.whatSaved'), after: onDone }}>{t('tf.whatSave')}</WriteButton>
<WriteButton run={w} icon="text" w={{ method: 'apple.testflight_what_to_test', args: base, title: t('tf.whatFromFile'), done: t('tf.whatSaved'), after: onDone }} hint={t('tf.whatFromFileHint')}>{t('tf.whatFromFile')}</WriteButton>
</div>
</div>
);
}
return (
<div className="build-edit">
<span className="small strong">{t('tf.toInternal', { build: b.build ?? '' })}</span>
{internal.length ? (
<div className="row" style={{ gap: 14, flexWrap: 'wrap' }}>
{internal.map(g => <label key={g.id} className="check"><input type="checkbox" checked={picked.includes(g.name)} onChange={e => setPicked(p => (e.target.checked ? [...p, g.name] : p.filter(x => x !== g.name)))} />{g.name}</label>)}
</div>
) : <span className="small muted">{t('tf.noInternal')}</span>}
<div className="actions">
<WriteButton run={w} icon="users" primary disabled={!picked.length} w={{ method: 'apple.testflight_distribute', args: { ...base, groups: picked.join(',') }, title: t('tf.distribute'), done: t('tf.distributed'), after: onDone }}>{t('tf.distribute')}</WriteButton>
<span className="small muted">{t('tf.externalNote')}</span>
</div>
</div>
);
}
function Testers({ groups, w }: { groups: TfGroup[]; w: ReturnType<typeof useWrite> }) {
const [group, setGroup] = useState('');
return (
<Card title={t('tf.testersTitle')} icon="users" sub={t('tf.testersSub')}>
<div className="row" style={{ gap: 10, flexWrap: 'wrap' }}>
<select className="input input-sm" value={group} onChange={e => setGroup(e.target.value)} aria-label={t('tf.testersGroup')}>
<option value="">{t('tf.testersAll')}</option>
{groups.map(g => <option key={g.id} value={g.name}>{g.name}</option>)}
</select>
<WriteButton run={w} icon="users" w={{ method: 'owner.testflight_testers', args: group ? { groups: group } : {}, title: t('tf.testersAdd'), done: t('tf.testersDone') }} hint={t('tf.testersHint')}>{t('tf.testersAdd')}</WriteButton>
</div>
<p className="small muted act-note"><Icon name="shield" />{t('tf.testersNote')}</p>
</Card>
);
}
export function TestFlightTab({ app }: { app: UnifiedApp }) {
const d = useData();
const e = d.statusFor(app);
const r = useRead<TestFlightStatus>(app.target, 'apple.testflight_status', { network: true, title: 'TestFlight' });
const w = useWrite(app.project?.id ?? null);
const [open, setOpen] = useState<{ build: string; mode: 'what' | 'groups' } | null>(null);
const writable = !!app.project;
if ((e && !e.app?.platforms.ios) || (!e && !app.project && !app.ios)) return <Card pad={false}><Empty illo="plane" title={t('tf.noIos')}>{t('tf.noIosText')}</Empty></Card>;
if (!r.data) {
if (r.loading && !r.error) return <Card><Skeleton lines={4} block /></Card>;
return (
<Card pad={false}>
<Empty illo="plane" title={t('tf.emptyTitle')} actions={<button className="btn btn-primary" onClick={r.refresh}><Icon name="refresh" />{t('tf.read')}</button>}>{t('tf.emptyText')}</Empty>
{r.error && <div className="card-foot"><Icon name="alertCircle" />{r.error}</div>}
</Card>
);
}
const s = r.data;
return (
<>
<div className="row between">
<span />
<ReadControl r={r} />
</div>
{s.missingGroups.length > 0 && (
<Notice tone="warn" action={writable ? <WriteButton run={w} icon="plus" w={{ method: 'apple.testflight_groups', args: {}, title: t('tf.groupsSync'), done: t('tf.groupsSynced'), after: r.refresh }} hint={t('tf.groupsSyncHint')}>{t('tf.groupsSync')}</WriteButton> : undefined}>
{t('tf.missingGroups', { groups: s.missingGroups.join(', ') })}
</Notice>
)}
<div className="grid-cards">
{s.groups.map(g => (
<div key={g.id} className="tf-group">
<div className="row between"><span className="strong">{g.name}</span><Chip tone={g.internal ? 'info' : 'accent'}>{g.internal ? t('tf.internal') : t('tf.external')}</Chip></div>
<div className="stat">
<div><b>{num(g.testers)}</b><span className="small muted">{t('tf.testers')}</span></div>
<div><b>{g.builds === 'all' ? t('tf.all') : num(g.builds)}</b><span className="small muted">{t('tf.builds')}</span></div>
</div>
<div className="row small muted" style={{ gap: 6 }}><Icon name={g.publicLink ? 'globe' : 'users'} className="ic13" />{g.publicLink ? t('tf.publicOn') : t('tf.publicOff')}</div>
</div>
))}
{!s.groups.length && <Card><Empty compact illo="plane" title={t('tf.noGroups')} /></Card>}
</div>
<Card title={t('tf.buildsTitle')} icon="cube" sub={t('tf.buildsSub')} pad={false}>
{s.builds.length ? (
<table className="table">
<thead><tr><th>{t('col.version')}</th><th>{t('col.build')}</th><th>{t('tf.processing')}</th><th>{t('tf.internalTesting')}</th><th>{t('tf.externalTesting')}</th><th>{t('tf.uploaded')}</th>{writable && <th className="right"><span className="sr">{t('col.actions')}</span></th>}</tr></thead>
<tbody>
{s.builds.map((b, i) => {
const usable = !!b.build && b.processing === 'VALID' && !/EXPIRED/.test(`${b.internal ?? ''}`);
const here = open?.build === b.build ? open.mode : null;
return (
<Fragment key={i}>
<tr>
<td className="strong num">{b.version ?? '–'}</td><td className="num">{b.build ?? '–'}</td>
<td><State s={b.processing} /></td><td><State s={b.internal} /></td><td><State s={b.external} /></td>
<td className="muted">{date(b.uploaded)}</td>
{writable && <td className="right nowrap">{usable && <div className="actions" style={{ justifyContent: 'flex-end' }}>
<button className="btn btn-sm btn-ghost" aria-expanded={here === 'what'} onClick={() => setOpen(here === 'what' ? null : { build: b.build!, mode: 'what' })}><Icon name="text" />{t('tf.what')}</button>
<button className="btn btn-sm btn-ghost" aria-expanded={here === 'groups'} onClick={() => setOpen(here === 'groups' ? null : { build: b.build!, mode: 'groups' })}><Icon name="users" />{t('tf.groupsBtn')}</button>
</div>}</td>}
</tr>
{here && <tr className="edit-row"><td colSpan={7}><BuildEditor b={b} groups={s.groups} w={w} mode={here} onDone={() => { setOpen(null); r.refresh(); }} /></td></tr>}
</Fragment>
);
})}
</tbody>
</table>
) : <Empty compact illo="plane" title={t('tf.noBuilds')} />}
</Card>
{writable && s.groups.length > 0 && <Testers groups={s.groups} w={w} />}
</>
);
}