diff --git a/apps/console/app/api-answers.test.ts b/apps/console/app/api-answers.test.ts index c7c87f96..8240319b 100644 --- a/apps/console/app/api-answers.test.ts +++ b/apps/console/app/api-answers.test.ts @@ -148,8 +148,9 @@ describe('what the console keeps of a sign-in (FRU-148, FRU-216)', () => { assert.equal(redeemed.ok, true); assert.deepEqual(written, []); assert.deepEqual(sent, ['POST /console/auth/email/redeem', 'GET /console/me', 'POST /console/auth/sign-out']); - // The control: the storage is there and it is the one `api.ts` writes in, for the one thing it keeps. - api.rememberWorkspaceName('Acme'); - assert.deepEqual(written, ['localStorage.setItem(fruitback:pending-workspace, Acme)']); + // The control: the storage is there, and a write in it is seen. `api.ts` keeps nothing in it + // since the name of a workspace comes from the address of its person (FRU-239). + globalThis.localStorage.setItem('a-key', 'a-value'); + assert.deepEqual(written, ['localStorage.setItem(a-key, a-value)']); }); }); diff --git a/apps/console/app/api.ts b/apps/console/app/api.ts index fa302363..9fc09867 100644 --- a/apps/console/app/api.ts +++ b/apps/console/app/api.ts @@ -471,28 +471,11 @@ export function pairingLink(code: string): string { } /** - * The name typed before the link was sent, kept for the moment the link is opened. A convenience: when - * storage is refused, the person types it again. + * Renames a workspace (FRU-236): its owner or an admin. The worker answers the workspace as + * `/console/me` lists it, with the role of the person. */ -const PENDING = 'fruitback:pending-workspace'; - -export function rememberWorkspaceName(name: string): void { - try { - localStorage.setItem(PENDING, name); - } catch { - // Private browsing can refuse storage. The name is asked again after the sign-in. - } -} - -export function takeWorkspaceName(): string | undefined { - try { - const name = localStorage.getItem(PENDING) ?? undefined; - localStorage.removeItem(PENDING); - - return name === '' ? undefined : name; - } catch { - return undefined; - } +export function renameWorkspace(workspace: Workspace, name: string): Promise> { + return call('POST', `/console/workspaces/${workspace.id}`, { name }); } /** diff --git a/apps/console/app/install-options.tsx b/apps/console/app/install-options.tsx new file mode 100644 index 00000000..f0be0655 --- /dev/null +++ b/apps/console/app/install-options.tsx @@ -0,0 +1,102 @@ +import { useState } from 'react'; +import { API, type Site, type Workspace, call, pairingLink } from './api'; +import { msg, t } from './i18n'; +import { copyText, problemOf } from './problems'; +import { Button, Problem, Snippet } from './ui'; +import { useLocale } from './use-locale'; + +/** + * The two ways to put Fruitback on a site, as the Sites screen shows them for a site: the script + * tag, and the browser of the person. The setup has its own drawing of them since FRU-239. + */ + +/** The script tag a site embeds, pointing at this console's copy of the widget. */ +export function snippetFor(site: Site): string { + return [ + '', + ].join('\n'); +} + +// Every role connects a browser, so no code of the worker has a sentence of its own here. +const CONNECT_PROBLEMS: Readonly> = {}; +const BROWSER_NOT_CONNECTED = msg('This browser was not connected. Try again.'); + +export function InstallOptions({ workspace, site }: { workspace: Workspace; site: Site }) { + useLocale(); + const [copied, setCopied] = useState<'no' | 'yes' | 'refused'>('no'); + const [link, setLink] = useState(); + const [problem, setProblem] = useState(); + const snippet = snippetFor(site); + + async function connect() { + // Open the tab in the click, before the await: a browser blocks a window opened after one. + // The tab loses its `opener` before it loads the pairing page. + const tab = window.open('', '_blank'); + setProblem(undefined); + const minted = await call<{ code: string }>('POST', `/console/workspaces/${workspace.id}/connect`, {}); + if (!minted.ok) { + // The tab that just opened closes: the reason is said under the button (FRU-164). + tab?.close(); + return setProblem(problemOf(minted, CONNECT_PROBLEMS, BROWSER_NOT_CONNECTED)); + } + const target = pairingLink(minted.data.code); + setLink(target); + if (tab !== null) { + tab.opener = null; + tab.location.href = target; + } + } + + return ( +
+
+

{t('Script')}

+

{t('Paste it before the closing body tag of the site.')}

+ {/* The box of the script takes focus, so a keyboard scrolls its long lines (axe, FRU-239). */} + {snippet} + + {copied === 'refused' ? ( +

+ {t('The browser refused the copy. Select the script above and copy it by hand.')} +

+ ) : null} +
+
+

{t('Extension')}

+

+ {t( + 'Nothing to change on the site. Connect this browser, then click the Fruitback icon on the page that opens.', + )} +

+ + {problem === undefined ? null : ( +
+ {t(problem)} +
+ )} + {link === undefined ? null : ( +

+ {t('A page opened in a new tab. If it did not, open')}{' '} + + {t('this link')} + + {t(': it works once, for fifteen minutes.')} +

+ )} +
+
+ ); +} diff --git a/apps/console/app/messages.fr.ts b/apps/console/app/messages.fr.ts index 8ffcdd6b..21ee18b7 100644 --- a/apps/console/app/messages.fr.ts +++ b/apps/console/app/messages.fr.ts @@ -6,7 +6,6 @@ export const FRENCH: Readonly> = { Fruitback: 'Fruitback', Linear: 'Linear', - 'Fruitback Cloud': 'Fruitback Cloud', 'Fruitback cannot reach its server. Check your connection.': 'Fruitback ne joint pas son serveur. Vérifiez votre connexion.', @@ -102,8 +101,6 @@ export const FRENCH: Readonly> = { 'Google n’a pas d’adresse vérifiée pour ce compte. Utilisez un lien par e-mail.', 'The sign-in with Google did not finish. Try again, or use an email link.': 'La connexion avec Google n’a pas abouti. Réessayez, ou utilisez un lien par e-mail.', - 'Google sign-in is not set up on this Fruitback': 'La connexion Google n’est pas configurée sur ce Fruitback', - 'GitHub sign-in is not set up on this Fruitback': 'La connexion GitHub n’est pas configurée sur ce Fruitback', 'Not set up': 'Non configuré', 'Could not check': 'Vérification impossible', 'Where you are signed in': 'Où vous êtes connecté', @@ -248,20 +245,7 @@ export const FRENCH: Readonly> = { 'Seulement les sites partagés avec lui. Gratuit, et ne voit jamais le tracker.', Workspace: 'Workspace', - 'Sign in, name it': 'Se connecter, le nommer', - Source: 'Source', - Sources: 'Sources', - 'Where feedback goes': 'Où vont les retours', - Site: 'Site', - 'The address you review': 'L’adresse que vous relisez', Install: 'Installer', - 'Script or extension': 'Script ou extension', - 'Set up': 'Mise en place', - 'How to run Fruitback': 'Comment faire tourner Fruitback', - 'Self-hosted': 'Auto-hébergé', - Steps: 'Étapes', - 'Step {step} of {total}': 'Étape {step} sur {total}', - 'Create your workspace': 'Créez votre workspace', 'That is not an e-mail address. Check it and send again.': 'Ce n’est pas une adresse e-mail. Vérifiez-la et renvoyez.', 'Several links went to this address already. Use the last one, or wait fifteen minutes.': @@ -286,45 +270,19 @@ export const FRENCH: Readonly> = { "La connexion n'a pas abouti. Réessayez, ou utilisez un lien par e-mail.", 'The link could not be sent just now. Try again in a minute.': 'Le lien n’a pas pu être envoyé. Réessayez dans une minute.', - 'One workspace for your team, your sites and your sources. Hosted in Europe.': - 'Un workspace pour votre équipe, vos sites et vos sources. Hébergé en Europe.', 'Sending…': 'Envoi…', Continue: 'Continuer', 'Continue with Google': 'Continuer avec Google', 'Continue with GitHub': 'Continuer avec GitHub', - 'or with an email link': 'ou avec un lien par e-mail', 'Work email': 'E-mail professionnel', 'Workspace name': 'Nom du workspace', 'Check your inbox': 'Regardez votre boîte de réception', - 'A sign-in link is on its way to {email}. It works once, for fifteen minutes.': - 'Un lien de connexion est en route vers {email}. Il fonctionne une fois, pendant quinze minutes.', 'Use another address': 'Utiliser une autre adresse', - 'Open it in this browser. Nothing in it asks for a password, and ignoring it is safe.': - 'Ouvrez-le dans ce navigateur. Rien n’y demande un mot de passe, et l’ignorer est sans risque.', 'A workspace needs a name of one line, up to 80 characters.': 'Un workspace a besoin d’un nom d’une ligne, de 80 caractères au plus.', - 'Name your workspace': 'Nommez votre workspace', - 'You are signed in. One more word, and the workspace exists.': - 'Vous êtes connecté. Encore un mot, et le workspace existe.', - 'Notes stay in this workspace. Nothing to connect.': 'Les notes restent dans ce workspace. Rien à connecter.', - 'Where should feedback go?': 'Où doivent aller les retours ?', - Selected: 'Sélectionné', - 'In Connectors, after the set-up': 'Dans Connecteurs, après la mise en place', - 'For now the notes stay in this workspace. Linear and a REST API connect after the set-up, in Connectors.': - 'Pour l’instant, les notes restent dans ce workspace. Linear et une API REST se connectent après la mise en place, dans Connecteurs.', 'Paste the full address of the site, starting with https://.': 'Collez l’adresse complète du site, en commençant par https://.', - 'Which site do you review?': 'Quel site relisez-vous ?', - 'Paste its address. Any page of it will do.': 'Collez son adresse. N’importe laquelle de ses pages convient.', 'Site address': 'Adresse du site', - 'Who sees the feedback?': 'Qui voit les retours ?', - 'The members of {workspace}': 'Les membres de {workspace}', - 'Visitors of the site see no note.': 'Les visiteurs du site ne voient aucune note.', - 'Everyone who visits the site': 'Tous ceux qui visitent le site', - 'For a public « report a problem ».': 'Pour un « signaler un problème » public.', - 'Install it': 'Installez-le', - 'Two ways to put Fruitback on {site}. Use one, or both.': - 'Deux façons de mettre Fruitback sur {site}. Utilisez l’une, ou les deux.', Finish: 'Terminer', Script: 'Script', 'Paste it before the closing body tag of the site.': 'Collez-le avant la balise body fermante du site.', @@ -501,4 +459,42 @@ export const FRENCH: Readonly> = { 'A link works once, for fifteen minutes. This one was already used or ran out of time.': 'Un lien fonctionne une fois, pendant quinze minutes. Celui-ci a déjà servi, ou son délai est passé.', 'Send me a new link': 'M’envoyer un nouveau lien', + // The setup on one screen (FRU-239). + 'Set up Fruitback': 'Mettre en place Fruitback', + 'Current step:': 'Étape en cours :', + 'Running your own server?': 'Vous hébergez votre propre serveur ?', + 'Self-hosting guide': 'Guide d’auto-hébergement', + 'Three steps to your first note on a live page. Hosted in Europe.': + 'Trois étapes jusqu’à votre première note sur une page en ligne. Hébergé en Europe.', + 'Your account': 'Votre compte', + 'Your site': 'Votre site', + 'The address you review.': 'L’adresse que vous relisez.', + 'Put it on the site': 'Le mettre sur le site', + 'The extension, or a script tag.': 'L’extension, ou une balise de script.', + 'Preview of your site': 'Aperçu de votre site', + 'your site': 'votre site', + 'Your site shows here once you add it, with its pins.': 'Votre site s’affiche ici une fois ajouté, avec ses pins.', + 'A preview of your site. Pins show the same way on the real page.': + 'Un aperçu de votre site. Les pins s’affichent de la même façon sur la vraie page.', + 'We send you a sign-in link. No password to make up.': + 'Nous vous envoyons un lien de connexion. Aucun mot de passe à inventer.', + 'Your workspace takes the name of your domain. Rename it whenever you like.': + 'Votre workspace prend le nom de votre domaine. Renommez-le quand vous voulez.', + 'A sign-in link is on its way to {email}. Open it in this browser: this page carries on by itself.': + 'Un lien de connexion est en route vers {email}. Ouvrez-le dans ce navigateur : cette page continue toute seule.', + 'The link works once, for fifteen minutes. Nothing in it asks for a password.': + 'Le lien fonctionne une fois, pendant quinze minutes. Rien n’y demande un mot de passe.', + 'Signed in as {email}': 'Connecté en tant que {email}', + 'The workspace was not renamed. Try again.': 'Le workspace n’a pas été renommé. Réessayez.', + 'Only an owner or an admin of the workspace renames it.': 'Seul un propriétaire ou un admin du workspace le renomme.', + Save: 'Enregistrer', + Rename: 'Renommer', + 'Any page of it will do.': 'N’importe laquelle de ses pages convient.', + 'Members of {workspace} see the notes': 'Les membres de {workspace} voient les notes', + 'Everyone who visits sees the notes': 'Tous les visiteurs voient les notes', + 'Fruitback did not answer. Your sites are not lost.': 'Fruitback n’a pas répondu. Vos sites ne sont pas perdus.', + Site: 'Site', + Change: 'Modifier', + '{site} is still in your sites, with its notes. Remove it in Sites if you no longer review it.': + '{site} est toujours dans vos sites, avec ses notes. Retirez-le dans Sites si vous ne le relisez plus.', }; diff --git a/apps/console/app/messages.test.ts b/apps/console/app/messages.test.ts index 86e37c31..fd85047d 100644 --- a/apps/console/app/messages.test.ts +++ b/apps/console/app/messages.test.ts @@ -15,6 +15,7 @@ const APP = new URL('./', import.meta.url); // (FRU-164): a sentence marked there is shown by a screen, so the guard reads them too. // `invitation-message.ts` holds the three notices of the members screen (FRU-209). // `sign-in-form.tsx` holds the ways to sign in, for the sign-in page and the setup (FRU-235). +// `install-options.tsx` holds the script tag and the browser of a site, for the Sites screen (FRU-239). const screens = [ ...readdirSync(new URL('routes/', APP)) .map((name) => `routes/${name}`) @@ -26,6 +27,7 @@ const screens = [ 'problems.ts', 'invitation-message.ts', 'sign-in-form.tsx', + 'install-options.tsx', ]; const sources = new Map(screens.map((name) => [name, readFileSync(new URL(name, APP), 'utf8')])); diff --git a/apps/console/app/routes/connector-parts.tsx b/apps/console/app/routes/connector-parts.tsx index 8fa58bda..9528dedb 100644 --- a/apps/console/app/routes/connector-parts.tsx +++ b/apps/console/app/routes/connector-parts.tsx @@ -14,7 +14,8 @@ import { useLocale } from '../use-locale'; /** How each kind of source is drawn. The mark is a letter of the mockup, not a logo. */ export const KINDS = { linear: { mark: 'L', name: 'Linear' }, - rest: { mark: '{ }', name: 'REST API' }, + // « REST API » is said « API REST » in French: `msg` marks it for the guard of the words. + rest: { mark: '{ }', name: msg('REST API') }, } as const; /** What a refused key means for the person, and what to do. */ diff --git a/apps/console/app/routes/home.tsx b/apps/console/app/routes/home.tsx index 65c833a3..b00e59e8 100644 --- a/apps/console/app/routes/home.tsx +++ b/apps/console/app/routes/home.tsx @@ -1,12 +1,12 @@ import { useEffect } from 'react'; import { useNavigate } from 'react-router'; -import { type Me, callUntilAnswered, myInvitations } from '../api'; +import { type Me, type Site, call, callUntilAnswered, myInvitations } from '../api'; import { t } from '../i18n'; import { useLocale } from '../use-locale'; /** * The front door: the invitations that wait for a signed-in person, then their first workspace, or - * the setup. + * the setup, which a workspace with no site goes back to. * * Somebody who was invited (FRU-104) signs in from the link of the message, and lands here: they see * the invitation before the setup offers to make a workspace of their own. @@ -25,7 +25,16 @@ export default function Home() { // An invitation that could not be read does not hold the front door: it is on its own page. if (invitations.ok && invitations.data.length > 0) return navigate('/invitations', { replace: true }); const workspace = me.data.workspaces[0]; - navigate(workspace === undefined ? '/setup' : `/w/${workspace.id}/sites`, { replace: true }); + if (workspace === undefined) return navigate('/setup', { replace: true }); + const sites = `/w/${workspace.id}/sites`; + // Only somebody who adds a site is sent back to the step that adds one. + if (workspace.role !== 'owner' && workspace.role !== 'admin') return navigate(sites, { replace: true }); + void call<{ sites: Site[] }>('GET', `/console/workspaces/${workspace.id}/sites`).then((listed) => { + if (stopped) return; + // A workspace with no site is a setup that stopped at its second step (FRU-239). A list + // that could not be read is not an empty list: the Sites screen says so itself. + navigate(listed.ok && listed.data.sites.length === 0 ? '/setup' : sites, { replace: true }); + }); }); }); diff --git a/apps/console/app/routes/setup-account.tsx b/apps/console/app/routes/setup-account.tsx new file mode 100644 index 00000000..9ad0ee50 --- /dev/null +++ b/apps/console/app/routes/setup-account.tsx @@ -0,0 +1,234 @@ +import { type FormEvent, useState } from 'react'; +import { useSearchParams } from 'react-router'; +import { type Account, type Workspace, renameWorkspace } from '../api'; +import { msg, t } from '../i18n'; +import { NAME_PROBLEMS, problemOf } from '../problems'; +import { type Made, manages } from '../setup-stage'; +import { + AnotherAddress, + EmailForm, + ProviderButtons, + SignInClosed, + providerProblem, + useCarryOn, + useSendAgain, + useSignInWays, + withStrong, +} from '../sign-in-form'; +import { Button, Callout, Field, LinkButton, Problem } from '../ui'; +import { useLocale } from '../use-locale'; +import { workspaceNameFrom } from '../workspace-name'; + +/** Step 1 of the setup, « Your account » (FRU-239): each of its states, as `setup.tsx` shows them. */ + +type AskProps = { email: string; onEmail: (email: string) => void; onSent: (email: string) => void }; + +/** Step 1, to do: the address a link goes to, or a provider. */ +export function Ask({ email, onEmail, onSent }: AskProps) { + useLocale(); + const [search] = useSearchParams(); + const { providers, closed } = useSignInWays(); + + return ( +
+

{t('We send you a sign-in link. No password to make up.')}

+ {closed ? ( + + ) : ( + <> + +

+ {t('Your workspace takes the name of your domain. Rename it whenever you like.')} +

+ + + )} +
+ ); +} + +/** Step 1, the link is on its way: this page goes on by itself when it is opened (FRU-235). */ +export function Sent({ email, onBack, onSignedIn }: { email: string; onBack: () => void; onSignedIn: () => void }) { + useLocale(); + useCarryOn(onSignedIn); + const again = useSendAgain(email); + + return ( + +

{t('Check your inbox')}

+

+ {withStrong( + msg('A sign-in link is on its way to {email}. Open it in this browser: this page carries on by itself.'), + 'email', + email, + )} +

+

+ {t('The link works once, for fifteen minutes. Nothing in it asks for a password.')} +

+
+ + +
+ {again.problem === undefined ? null : {t(again.problem)}} +
+ ); +} + +/** + * The workspace of a person who signed in, made again by hand. A try was made for them already, so + * the form opens on what the worker answered to it (`tried`), and the button says « Try again ». + */ +export function Unnamed({ + account, + tried, + make, + onCreated, +}: { + account: Account; + tried: string; + /** Makes the workspace with this name: `firstWorkspace`, under its lock. */ + make: (name: string) => Promise; + onCreated: (workspace: Workspace) => void; +}) { + useLocale(); + const [name, setName] = useState(workspaceNameFrom(account.email)); + const [busy, setBusy] = useState(false); + const [problem, setProblem] = useState(tried); + + async function submit(event: FormEvent) { + event.preventDefault(); + setBusy(true); + setProblem(undefined); + const made = await make(name); + setBusy(false); + if (made.workspace !== undefined) return onCreated(made.workspace); + setProblem(made.problem); + } + + return ( +
void submit(event)} className="mt-3 flex flex-col gap-4"> +

{withStrong(msg('Signed in as {email}'), 'email', account.email)}

+ setName(event.target.value)} + maxLength={80} + /> + {problem === undefined ? null : {t(problem)}} +
+ +
+ + ); +} + +const WORKSPACE_NOT_RENAMED = msg('The workspace was not renamed. Try again.'); +/** What the worker refuses when a workspace is renamed: the rule of its name, and who may. */ +const RENAME_PROBLEMS: Readonly> = { + ...NAME_PROBLEMS, + forbidden: msg('Only an owner or an admin of the workspace renames it.'), +}; + +/** Step 1, done: who is signed in, and the workspace, which « Rename » renames in place. */ +export function Account({ + account, + workspace, + onRenamed, +}: { + account: Account; + workspace: Workspace; + onRenamed: (workspace: Workspace) => void; +}) { + useLocale(); + const [renaming, setRenaming] = useState(false); + const [name, setName] = useState(workspace.name); + const [busy, setBusy] = useState(false); + const [problem, setProblem] = useState(); + // Set once the form closed: « Rename » then takes focus again, where the keyboard left it. + const [back, setBack] = useState(false); + + function close() { + setRenaming(false); + setProblem(undefined); + setBack(true); + } + + async function submit(event: FormEvent) { + event.preventDefault(); + if (name.trim() === workspace.name) return close(); + setBusy(true); + setProblem(undefined); + const renamed = await renameWorkspace(workspace, name); + setBusy(false); + // A refusal keeps the form open, with the name as it was typed. + if (!renamed.ok) return setProblem(problemOf(renamed, RENAME_PROBLEMS, WORKSPACE_NOT_RENAMED)); + onRenamed(renamed.data); + close(); + } + + return ( +
+

+ {withStrong(msg('Signed in as {email}'), 'email', account.email, 'text-ink')} +

+ {renaming ? ( +
void submit(event)} + onKeyDown={(event) => { + if (event.key !== 'Escape') return; + setName(workspace.name); + close(); + }} + className="mt-2 flex flex-col gap-3" + > +
+
+ setName(event.target.value)} + maxLength={80} + autoFocus + /> +
+ + { + setName(workspace.name); + close(); + }} + > + {t('Cancel')} + +
+ {problem === undefined ? null : {t(problem)}} +
+ ) : ( +

+ {withStrong(msg('Workspace {name}'), 'name', workspace.name, 'text-ink')} + {manages(workspace) ? ( + setRenaming(true)}> + {t('Rename')} + + ) : null} +

+ )} +
+ ); +} diff --git a/apps/console/app/routes/setup-site.tsx b/apps/console/app/routes/setup-site.tsx new file mode 100644 index 00000000..0bb47755 --- /dev/null +++ b/apps/console/app/routes/setup-site.tsx @@ -0,0 +1,142 @@ +import { type FormEvent, useState } from 'react'; +import { type Site, type Visibility, type Workspace, call } from '../api'; +import { t } from '../i18n'; +import { SITE_NOT_ADDED, SITE_PROBLEMS, problemOf } from '../problems'; +import { type Added, manages } from '../setup-stage'; +import { siteAddress } from '../site-address'; +import { Button, Field, LinkButton, Pick, Problem } from '../ui'; +import { useLocale } from '../use-locale'; + +/** Step 2 of the setup, « Your site » (FRU-239): the form that adds it, and its summary. */ + +type SiteFormProps = { + workspace: Workspace; + /** The site this form changes: its values are in the form, and « Cancel » keeps it. */ + replaces?: Added | undefined; + onDone: (added: Added) => void; +}; + +/** + * Step 2: the address of the site, and who sees its notes. + * + * WARNING: the worker has no route that changes a site, and a site that is removed takes its notes + * with it. So « Change » adds the site again with the new values, and removes the one it replaces + * only when that one was added in this visit: nothing was noted on it yet. A site of a visit before + * stays, the summary says so, and the Sites screen removes it with its two presses (FRU-165). + */ +export function SiteForm({ workspace, replaces, onDone }: SiteFormProps) { + useLocale(); + const before = replaces?.site; + const [url, setUrl] = useState(before === undefined ? '' : new URL(before.origin).host); + const [visibility, setVisibility] = useState(before?.visibility ?? 'members'); + const [busy, setBusy] = useState(false); + const [problem, setProblem] = useState(); + + async function submit(event: FormEvent) { + event.preventDefault(); + setProblem(undefined); + // The same reading as the Sites screen: a host with no scheme is https (FRU-167). + const address = siteAddress(url); + if (address === undefined) return setProblem(SITE_PROBLEMS['invalid-url'] ?? SITE_NOT_ADDED); + // Nothing changed: the site stays as it is, and nothing is asked of the worker. + if (replaces !== undefined && new URL(address).origin === before?.origin && visibility === before.visibility) { + return onDone(replaces); + } + setBusy(true); + const base = `/console/workspaces/${workspace.id}/sites`; + const added = await call('POST', base, { url: address, visibility }); + if (!added.ok) { + setBusy(false); + + // No failure clears the field: the address stays as it was typed. + return setProblem(problemOf(added, SITE_PROBLEMS, SITE_NOT_ADDED)); + } + const removed = + before !== undefined && replaces?.fresh === true && (await call('DELETE', `${base}/${before.id}`)).ok; + setBusy(false); + onDone({ site: added.data, fresh: true, ...(before === undefined || removed ? {} : { left: before }) }); + } + + return ( +
void submit(event)} className="mt-3 flex flex-col gap-5"> + setUrl(event.target.value)} + /> + + {problem === undefined ? null : {t(problem)}} +
+ + {replaces === undefined ? null : ( + onDone(replaces)}> + {t('Cancel')} + + )} +
+ + ); +} + +/** Step 2, done: the site, who sees its notes, and « Change ». */ +export function SiteSummary({ + workspace, + site, + left, + onChange, +}: { + workspace: Workspace; + site: Site; + left: Site | undefined; + onChange: () => void; +}) { + useLocale(); + + return ( +
+ {/* A long host takes the lines it needs: the page does not scroll sideways (FRU-159). */} +

{new URL(site.origin).host}

+

+ {site.visibility === 'members' + ? t('Members of {workspace} see the notes', { workspace: workspace.name }) + : t('Everyone who visits sees the notes')} + {manages(workspace) ? ( + + {t('Change')} + + ) : null} +

+ {left === undefined ? null : ( +

+ {t('{site} is still in your sites, with its notes. Remove it in Sites if you no longer review it.', { + site: new URL(left.origin).host, + })} +

+ )} +
+ ); +} diff --git a/apps/console/app/routes/setup.tsx b/apps/console/app/routes/setup.tsx index d5da895a..6af3d956 100644 --- a/apps/console/app/routes/setup.tsx +++ b/apps/console/app/routes/setup.tsx @@ -1,625 +1,239 @@ -import { type FormEvent, type ReactNode, useEffect, useState } from 'react'; -import { useNavigate, useSearchParams } from 'react-router'; -import { - API, - providerStart, - type Me, - type Site, - type Visibility, - type Workspace, - call, - pairingLink, - rememberWorkspaceName, - requestLink, - saveLanguage, - takeWorkspaceName, -} from '../api'; -import { NAME_PROBLEMS, SITE_NOT_ADDED, SITE_PROBLEMS, WORKSPACE_NOT_CREATED, copyText, problemOf } from '../problems'; -import { Button, Field, Mark, Pick, Problem, WideButton } from '../ui'; -import { adoptLanguage, locale, msg, t } from '../i18n'; +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router'; +import { type Me, type Site, type Workspace, call, myInvitations, saveLanguage } from '../api'; +import { adoptLanguage, t } from '../i18n'; +import { InstallOptions } from '../install-options'; +import { type SetupCalls, type Stage, firstWorkspace, leaveFor, resolveStage, siteStage } from '../setup-stage'; +import { BrowserFrame, Button, EntryPage, Lead, Step, type StepState, Steps, Title } from '../ui'; import { useLocale } from '../use-locale'; -import { - LINK_NOT_SENT, - SEND_PROBLEMS, - SignInClosed, - providerProblem, - useCarryOn, - useSignInWays, -} from '../sign-in-form'; -import { siteAddress } from '../site-address'; +import { Account, Ask, Sent, Unnamed } from './setup-account'; +import { SiteForm, SiteSummary } from './setup-site'; /** - * « Create your workspace », the four steps of the setup (design/boards/2-onboarding.png). + * The setup, on one screen (FRU-239, screen 01 of the mockup of 2026-10-13): « Your account », + * « Your site », « Put it on the site ». The three steps show together; one that is done is a + * summary, and opens again. * - * The steps are the design's. What this beta has not built yet is shown and disabled, with the reason - * in words: Google sign-in, and the sources other than the workspace's own (FRU-102). + * Nobody names a workspace here: it takes its name from the address of the person + * (`workspace-name.ts`), and « Rename » changes it. The choice of a tracker is not a step any more: + * it is in Connectors, after. */ -const STEPS = [ - { title: msg('Workspace'), hint: msg('Sign in, name it') }, - { title: msg('Source'), hint: msg('Where feedback goes') }, - { title: msg('Site'), hint: msg('The address you review') }, - { title: msg('Install'), hint: msg('Script or extension') }, -] as const; - -const DOCS = 'https://fruitback.com'; - -type Stage = - | { kind: 'loading' } - | { kind: 'sign-in' } - | { kind: 'sent'; email: string } - | { kind: 'name' } - | { kind: 'source'; workspace: Workspace } - | { kind: 'site'; workspace: Workspace } - | { kind: 'install'; workspace: Workspace; site: Site }; - -function stepOf(stage: Stage): number { - if (stage.kind === 'source') return 2; - if (stage.kind === 'site') return 3; - if (stage.kind === 'install') return 4; - - return 1; +// `InstallOptions` moved to its own module. The Sites screen still reads it from here. +export { InstallOptions }; + +const SITE = 'https://fruitback.com'; + +/** What the setup asks of the worker, and the lock of the browser. Where it goes: `setup-stage.ts`. */ +const CALLS: SetupCalls = { + // Quiet: a person who is not signed in gets the first step, not the notice of a session that ended. + me: () => call('GET', '/console/me', undefined, { quiet: true }), + invitations: myInvitations, + sites: (workspace) => call<{ sites: Site[] }>('GET', `/console/workspaces/${workspace.id}/sites`), + createWorkspace: (name) => call('POST', '/console/workspaces', { name }), + // No lock outside a secure context (`http` on a host other than localhost): two tabs can then each + // make one. docs/decisions/console.md says so. + alone: (task) => + typeof navigator === 'undefined' || navigator.locks === undefined + ? task() + : navigator.locks.request('fruitback:first-workspace', task), +}; + +/** The state of a step whose step before is in `before`: the one to do now, or one to come. */ +function stepAfter(before: StepState): StepState { + return before === 'done' ? 'current' : 'pending'; } let resolving: Promise | undefined; /** - * WARNING: one resolution at a time. It can create the workspace named before the link was sent, and - * two resolutions in flight would create it twice, or let the second ask for a name the first took. + * WARNING: one resolution at a time in this tab. It can make the first workspace, and two + * resolutions in flight would ask for it twice. */ -function resolveOnce(step: string | null): Promise { - resolving ??= resolveStage(step).finally(() => { +function resolveOnce(): Promise { + resolving ??= resolveStage(CALLS, (me) => adoptLanguage(me.account, saveLanguage)).finally(() => { resolving = undefined; }); return resolving; } -async function resolveStage(step: string | null): Promise { - // Quiet: a person who is not signed in gets the sign-in screen, not the notice of a session that ended. - const me = await call('GET', '/console/me', undefined, { quiet: true }); - if (!me.ok) return { kind: 'sign-in' }; - adoptLanguage(me.data.account, saveLanguage); - - const workspace = me.data.workspaces[0]; - if (workspace !== undefined) return step === 'site' ? { kind: 'site', workspace } : { kind: 'source', workspace }; - - // The name typed before the link was sent: the workspace is made without asking again. - const pending = takeWorkspaceName(); - if (pending === undefined) return { kind: 'name' }; - const created = await call('POST', '/console/workspaces', { name: pending }); - - return created.ok ? { kind: 'source', workspace: created.data } : { kind: 'name' }; -} - export default function Setup() { useLocale(); + const navigate = useNavigate(); const [stage, setStage] = useState({ kind: 'loading' }); - const [search] = useSearchParams(); + // Kept above the stages: « Use another address » gives the form back with the address in it. + const [email, setEmail] = useState(''); useEffect(() => { let live = true; - void resolveOnce(search.get('step')).then((resolved) => { + void resolveOnce().then((resolved) => { if (live) setStage(resolved); }); return () => { live = false; }; - }, [search]); - - return ( -
-
-
- {/* On a phone each label stays on one line (FRU-160): « Set up » is left to the screen - reader, which the card's title repeats, and the tabs are tighter. Under 360 px the name - goes too, and the mark stays. */} -
- - {t('Fruitback')} - {t('Set up')} -
- -
-
- - {/* Under md the steps are a row of numbers, with the title of the current one, so the card and its - action come up the screen (FRU-160). */} -
-
    - {STEPS.map((step, index) => { - const number = index + 1; - const current = number === stepOf(stage); - - return ( -
  1. - - {number} - - - {t(step.title)} - {t(step.hint)} - -
  2. - ); - })} -
- - -
-
- ); -} - -function StageCard({ stage, onStage }: { stage: Stage; onStage: (stage: Stage) => void }) { - useLocale(); - if (stage.kind === 'loading') return ; - if (stage.kind === 'sign-in') return onStage({ kind: 'sent', email })} />; - - if (stage.kind === 'sent') { - return ( - onStage({ kind: 'sign-in' })} - // Signed in from another tab: the same resolution as a visit, which makes the workspace - // named before the link was sent. - onSignedIn={() => void resolveOnce(null).then(onStage)} - /> - ); - } - if (stage.kind === 'name') return onStage({ kind: 'source', workspace })} />; - if (stage.kind === 'source') return onStage({ kind: 'site', workspace: stage.workspace })} />; - if (stage.kind === 'site') { - return ( - onStage({ kind: 'install', workspace: stage.workspace, site })} - /> - ); - } - - return ; -} - -/** The card of a step, with the footer of the board: the step number and the one action. */ -function Frame({ - step, - title, - lead, - children, - action, - onSubmit, -}: { - step: number; - title: string; - lead?: string; - children?: ReactNode; - action?: ReactNode; - onSubmit?: (event: FormEvent) => void; -}) { - useLocale(); - return ( -
event.preventDefault())} - className="flex min-w-0 flex-col rounded-[14px] border border-line bg-surface shadow-[0_24px_60px_-30px_rgba(28,25,23,0.25)] md:min-h-[540px]" - > -
-

{title}

- {lead === undefined ? null :

{lead}

} -
{children}
-
-
- {t('Step {step} of {total}', { step, total: 4 })} - {action} -
-
- ); -} - -function SignIn({ onSent }: { onSent: (email: string) => void }) { - useLocale(); - const [search] = useSearchParams(); - const [email, setEmail] = useState(''); - const [name, setName] = useState(''); - const [busy, setBusy] = useState(false); - const [problem, setProblem] = useState(providerProblem(search.get('error'))); - const { providers, closed } = useSignInWays(); - - async function submit(event: FormEvent) { - event.preventDefault(); - setBusy(true); - setProblem(undefined); - if (name.trim() !== '') rememberWorkspaceName(name.trim()); - const sent = await requestLink(email, locale()); - setBusy(false); - if (sent.ok) return onSent(email.trim()); - - setProblem(SEND_PROBLEMS[sent.error] ?? LINK_NOT_SENT); - } - - if (closed) { - return ( - - - - ); - } - - return ( - - {busy ? t('Sending…') : t('Continue')} - - } - > - {providers.google ? ( - (window.location.href = providerStart('google'))}> - {t('Continue with Google')} - - ) : ( - - {t('Continue with Google')} - - )} - {providers.github ? ( - (window.location.href = providerStart('github'))}> - {t('Continue with GitHub')} - - ) : ( - - {t('Continue with GitHub')} - - )} -
- - {t('or with an email link')} - -
- setEmail(event.target.value)} - required - /> - setName(event.target.value)} - maxLength={80} - /> - {problem === undefined ? null : {t(problem)}} - - ); -} - -function Sent({ email, onBack, onSignedIn }: { email: string; onBack: () => void; onSignedIn: () => void }) { - useLocale(); - // The link is opened in another tab: this one goes on to the next step by itself (FRU-235). - useCarryOn(onSignedIn); - - return ( - - {t('Use another address')} - - } - > -

- {t('Open it in this browser. Nothing in it asks for a password, and ignoring it is safe.')} -

- - ); -} + }, []); -function NameIt({ onCreated }: { onCreated: (workspace: Workspace) => void }) { - useLocale(); - const [name, setName] = useState(''); - const [problem, setProblem] = useState(); + useEffect(() => { + const away = leaveFor(stage); + if (away !== undefined) void navigate(away, { replace: true }); + }, [stage, navigate]); - async function submit(event: FormEvent) { - event.preventDefault(); - const created = await call('POST', '/console/workspaces', { name }); - if (created.ok) return onCreated(created.data); - setProblem(problemOf(created, NAME_PROBLEMS, WORKSPACE_NOT_CREATED)); - } + // The title of the tab, as the mockup names it. The cleanup gives back the name of the product. + useEffect(() => { + const before = document.title; + document.title = t('Set up Fruitback'); - return ( - - {t('Continue')} - - } - > - setName(event.target.value)} - maxLength={80} - /> - {problem === undefined ? null : {t(problem)}} - - ); -} + return () => { + document.title = before; + }; + }); -/** - * Where the notes go, as it is at the end of the set-up (FRU-166). The step chooses nothing: Fruitback - * keeps the notes, and the sources that the Connectors screen connects say so, rather than « After the - * beta ». When the step connects a source itself, it becomes a choice again. - */ -const SOURCES = [ - { - key: 'fruitback', - name: 'Fruitback', - detail: msg('Notes stay in this workspace. Nothing to connect.'), - state: msg('Selected'), - }, - { - key: 'linear', - name: 'Linear', - detail: msg('An issue per note, in the team you choose.'), - state: msg('In Connectors, after the set-up'), - }, - { - key: 'rest', - name: msg('REST API'), - detail: msg('POST each note to your own endpoint.'), - state: msg('In Connectors, after the set-up'), - }, - { - key: 'github', - name: 'GitHub Issues', - detail: msg('An issue per note, in one repository.'), - state: msg('After the beta'), - }, -] as const; + const account = 'account' in stage ? stage.account : undefined; + const workspace = 'workspace' in stage ? stage.workspace : undefined; + // The first step is to do until somebody is signed in and has a workspace: invitations that could + // not be read, or a workspace that was not made, keep it current. The last one is the current one + // once a site is there. + const signedIn = account !== undefined && workspace !== undefined; + const installing = stage.kind === 'install'; + const first: StepState = signedIn ? 'done' : 'current'; + const second: StepState = installing ? 'done' : stepAfter(first); + const third: StepState = installing ? 'current' : 'pending'; + const prefix = t('Current step:'); -function Source({ onNext }: { onNext: () => void }) { - useLocale(); return ( - {t('Continue')}} - > - {/* A list to read, not a choice: nothing here is chosen, so it is no radio group (FRU-166). */} - - - ); -} - -function SiteStep({ workspace, onAdded }: { workspace: Workspace; onAdded: (site: Site) => void }) { - useLocale(); - const [url, setUrl] = useState(''); - const [visibility, setVisibility] = useState('members'); - const [problem, setProblem] = useState(); - - async function submit(event: FormEvent) { - event.preventDefault(); - setProblem(undefined); - // The same reading as the Sites screen: a host with no scheme is https (FRU-167). - const address = siteAddress(url); - if (address === undefined) return setProblem(SITE_PROBLEMS['invalid-url'] ?? SITE_NOT_ADDED); - const added = await call('POST', `/console/workspaces/${workspace.id}/sites`, { url: address, visibility }); - if (added.ok) return onAdded(added.data); - setProblem(problemOf(added, SITE_PROBLEMS, SITE_NOT_ADDED)); - } - - return ( - - {t('Continue')} - + {t('Self-hosting guide')} + +

} > - setUrl(event.target.value)} - /> - - {problem === undefined ? null : {t(problem)}} - - ); -} - -/** The script tag a site embeds, pointing at this console's copy of the widget. */ -export function snippetFor(site: Site): string { - return [ - '', - ].join('\n'); -} - -function Install({ workspace, site }: { workspace: Workspace; site: Site }) { - useLocale(); - const navigate = useNavigate(); - - return ( - navigate(`/w/${workspace.id}/sites`)}>{t('Finish')}} - > - - - ); -} - -// Every role connects a browser, so no code of the worker has a sentence of its own here. -const CONNECT_PROBLEMS: Readonly> = {}; -const BROWSER_NOT_CONNECTED = msg('This browser was not connected. Try again.'); - -export function InstallOptions({ workspace, site }: { workspace: Workspace; site: Site }) { - useLocale(); - const [copied, setCopied] = useState<'no' | 'yes' | 'refused'>('no'); - const [link, setLink] = useState(); - const [problem, setProblem] = useState(); - const snippet = snippetFor(site); - - async function connect() { - // Open the tab in the click, before the await: a browser blocks a window opened after one. - // The tab loses its `opener` before it loads the pairing page. - const tab = window.open('', '_blank'); - setProblem(undefined); - const minted = await call<{ code: string }>('POST', `/console/workspaces/${workspace.id}/connect`, {}); - if (!minted.ok) { - // The tab that just opened closes: the reason is said under the button (FRU-164). - tab?.close(); - return setProblem(problemOf(minted, CONNECT_PROBLEMS, BROWSER_NOT_CONNECTED)); - } - const target = pairingLink(minted.data.code); - setLink(target); - if (tab !== null) { - tab.opener = null; - tab.location.href = target; - } - } - - return ( -
-
-

{t('Script')}

-

{t('Paste it before the closing body tag of the site.')}

-
{snippet}
- - {copied === 'refused' ? ( -

- {t('The browser refused the copy. Select the script above and copy it by hand.')} -

- ) : null} -
-
-

{t('Extension')}

-

- {t( - 'Nothing to change on the site. Connect this browser, then click the Fruitback icon on the page that opens.', - )} -

- - {problem === undefined ? null : ( -
- {t(problem)} -
- )} - {link === undefined ? null : ( -

- {t('A page opened in a new tab. If it did not, open')}{' '} - - {t('this link')} - - {t(': it works once, for fifteen minutes.')} -

- )} -
-
+
+
+ {t('Set up Fruitback')} +
+ {t('Three steps to your first note on a live page. Hosted in Europe.')} + + + + {stage.kind === 'loading' ?

: null} + {stage.kind === 'email' ? ( + setStage({ kind: 'sent', email: sent })} /> + ) : null} + {stage.kind === 'sent' ? ( + setStage({ kind: 'email' })} + // Signed in from another tab: the same resolution as a visit. + onSignedIn={() => void resolveOnce().then(setStage)} + /> + ) : null} + {stage.kind === 'unchecked' ? ( +

+

+ {t('Fruitback did not answer. Try again.')} +

+ +
+ ) : null} + {stage.kind === 'unnamed' ? ( + firstWorkspace(CALLS, stage.account, name)} + onCreated={(made) => void siteStage(CALLS, stage.account, made).then(setStage)} + /> + ) : null} + {account !== undefined && workspace !== undefined ? ( + setStage((now) => ('workspace' in now ? { ...now, workspace: renamed } : now))} + /> + ) : null} +
+ + + {second === 'pending' ? ( +

{t('The address you review.')}

+ ) : null} + {stage.kind === 'unread' ? ( +
+

+ {t('Fruitback did not answer. Your sites are not lost.')} +

+ +
+ ) : null} + {stage.kind === 'site' ? ( + + setStage({ kind: 'install', account: stage.account, workspace: stage.workspace, ...added }) + } + /> + ) : null} + {stage.kind === 'install' ? ( + + setStage({ + kind: 'site', + account: stage.account, + workspace: stage.workspace, + replaces: { site: stage.site, ...(stage.fresh === true ? { fresh: true } : {}) }, + }) + } + /> + ) : null} +
+ + + {stage.kind === 'install' ? ( + // No `items-start` here: the script would take the width of its longest line, and the page + // would scroll sideways. It scrolls in its own box. +
+ +
+ +
+
+ ) : ( +

{t('The extension, or a script tag.')}

+ )} +
+
+
+ +
+ +
+ ); } diff --git a/apps/console/app/setup-stage.test.ts b/apps/console/app/setup-stage.test.ts new file mode 100644 index 00000000..9c793f1e --- /dev/null +++ b/apps/console/app/setup-stage.test.ts @@ -0,0 +1,160 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import type { Answer, Workspace } from './api.ts'; +import { type SetupCalls, firstWorkspace, leaveFor, resolveStage, siteStage } from './setup-stage.ts'; + +/** + * Where the setup takes a person (FRU-239), with a worker of fakes: each case says what the worker + * answers, and the test reads the stage and what was asked of it. + */ +const account = { id: 'a1', email: 'ana@acme.dev' }; +const owner: Workspace = { id: 'w1', name: 'Acme', role: 'owner' }; +const member: Workspace = { id: 'w1', name: 'Acme', role: 'member' }; +const site = { id: 's1', origin: 'https://staging.acme.dev', visibility: 'members' as const }; + +const yes = (data: T): Answer => ({ ok: true, data }); +const no = (status: number, error = 'unavailable'): Answer => ({ ok: false, status, error }); + +type Worker = { + signedIn?: boolean; + workspaces?: Workspace[]; + invitations?: Answer | number; + sites?: Answer | number; + creates?: Answer; +}; + +/** A worker of fakes, and the list of what the setup asked of it. */ +function fake(worker: Worker): { calls: SetupCalls; asked: string[] } { + const asked: string[] = []; + let workspaces = worker.workspaces ?? []; + const calls: SetupCalls = { + me: async () => { + asked.push('me'); + + return worker.signedIn === false ? no(401, 'identity-required') : yes({ account, workspaces }); + }, + invitations: async () => { + asked.push('invitations'); + const answer = worker.invitations ?? 0; + + return typeof answer === 'number' ? yes(Array.from({ length: answer }, () => ({}) as never)) : answer; + }, + sites: async () => { + asked.push('sites'); + const answer = worker.sites ?? 0; + + return typeof answer === 'number' ? yes({ sites: answer === 0 ? [] : [site] }) : answer; + }, + createWorkspace: async (name) => { + asked.push(`create ${name}`); + if (worker.creates !== undefined) return worker.creates; + const made = { ...owner, name }; + workspaces = [made]; + + return yes(made); + }, + alone: (task) => { + asked.push('lock'); + + return task(); + }, + }; + + return { calls, asked }; +} + +describe('where the setup takes a person (FRU-239)', () => { + it('asks for an address when nobody is signed in, and asks nothing else', async () => { + const { calls, asked } = fake({ signedIn: false }); + + assert.deepEqual(await resolveStage(calls), { kind: 'email' }); + assert.deepEqual(asked, ['me']); + }); + + it('makes the first workspace under the lock, named after the domain, then asks for a site', async () => { + const { calls, asked } = fake({}); + + const stage = await resolveStage(calls); + assert.equal(stage.kind, 'site'); + assert.deepEqual(asked, ['me', 'invitations', 'lock', 'me', 'create Acme', 'sites']); + }); + + it('makes no workspace for somebody an invitation waits for', async () => { + const { calls, asked } = fake({ invitations: 1 }); + + assert.deepEqual(await resolveStage(calls), { kind: 'invited' }); + assert.ok(!asked.some((each) => each.startsWith('create'))); + }); + + it('makes no workspace while the invitations cannot be read', async () => { + const { calls, asked } = fake({ invitations: no(503) }); + + assert.deepEqual(await resolveStage(calls), { kind: 'unchecked', account }); + assert.ok(!asked.some((each) => each.startsWith('create'))); + }); + + it('goes on when the worker has no invitations at all (404)', async () => { + const { calls, asked } = fake({ invitations: no(404, 'not-found') }); + + assert.equal((await resolveStage(calls)).kind, 'site'); + assert.ok(asked.includes('create Acme')); + }); + + it('says what the worker answered when it did not make the workspace', async () => { + const refused = await resolveStage(fake({ creates: no(400, 'invalid-name') }).calls); + assert.deepEqual(refused, { + kind: 'unnamed', + account, + problem: 'A workspace needs a name of one line, up to 80 characters.', + }); + const silent = await resolveStage(fake({ creates: no(503) }).calls); + assert.equal(silent.kind === 'unnamed' && silent.problem, 'Fruitback did not answer. Try again.'); + }); + + it('finds the workspace a tab before it made, under the lock, and makes none', async () => { + const { calls, asked } = fake({}); + // The other tab made it between the first reading and the lock: the reading under the lock finds it. + const original = calls.me; + calls.me = async () => { + const answer = await original(); + + return answer.ok && asked.includes('lock') ? yes({ ...answer.data, workspaces: [owner] }) : answer; + }; + + assert.deepEqual(await resolveStage(calls), { kind: 'site', account, workspace: owner }); + assert.ok(!asked.some((each) => each.startsWith('create'))); + // The control: with no other tab, the same calls make it. + const alone = fake({}); + await firstWorkspace(alone.calls, account); + assert.ok(alone.asked.includes('create Acme')); + }); + + it('takes the name the person typed, when they type one', async () => { + const { calls, asked } = fake({}); + + await firstWorkspace(calls, account, 'Acme Studio'); + assert.ok(asked.includes('create Acme Studio')); + }); + + it('resumes at the last step when the workspace has a site', async () => { + const stage = await resolveStage(fake({ workspaces: [owner], sites: 1 }).calls); + + assert.deepEqual(stage, { kind: 'install', account, workspace: owner, site }); + }); + + it('says « unread » when the sites cannot be read, and never « no site »', async () => { + const stage = await siteStage(fake({ sites: no(503) }).calls, account, owner); + + assert.deepEqual(stage, { kind: 'unread', account, workspace: owner }); + }); + + it('sends a member of a workspace with no site to its Sites screen, and an invited person to theirs', async () => { + const stage = await siteStage(fake({}).calls, account, member); + + assert.deepEqual(stage, { kind: 'waiting', workspace: member }); + assert.equal(leaveFor(stage), '/w/w1/sites'); + assert.equal(leaveFor({ kind: 'invited' }), '/invitations'); + // The control: the owner of the same workspace stays, at the form. + assert.equal(leaveFor(await siteStage(fake({}).calls, account, owner)), undefined); + }); +}); diff --git a/apps/console/app/setup-stage.ts b/apps/console/app/setup-stage.ts new file mode 100644 index 00000000..328cfe05 --- /dev/null +++ b/apps/console/app/setup-stage.ts @@ -0,0 +1,123 @@ +import type { Account, Answer, Invitation, Me, Site, Workspace } from './api.ts'; +import { NAME_PROBLEMS, WORKSPACE_NOT_CREATED, problemOf } from './problems.ts'; +import { workspaceNameFrom } from './workspace-name.ts'; + +/** + * Where the setup of a person is (FRU-239), decided from what the worker answers. No React and no + * network here: the screen gives the calls (`SetupCalls`), and `setup-stage.test.ts` gives fakes. + */ + +/** + * The site of the setup. `fresh`: it was added in this visit, so nothing was noted on it yet. + * `left`: a site that « Change » replaced and did not remove, which the Sites screen still lists. + */ +export type Added = { site: Site; fresh?: boolean; left?: Site }; + +export type Stage = + | { kind: 'loading' } + | { kind: 'email' } + | { kind: 'sent'; email: string } + /** + * Signed in, and the workspace could not be made: the person tries again, with the name they like. + * `problem`: what the worker said of the try that was made for them. + */ + | { kind: 'unnamed'; account: Account; problem: string } + /** The sites of the workspace could not be read. That is not « no site »: nothing is asked again. */ + | { kind: 'unread'; account: Account; workspace: Workspace } + /** `replaces`: the site that « Change » opened again, with its values in the form. */ + | { kind: 'site'; account: Account; workspace: Workspace; replaces?: Added } + | ({ kind: 'install'; account: Account; workspace: Workspace } & Added) + /** An invitation waits for this person: they came to join a workspace, not to make one. */ + | { kind: 'invited' } + /** + * Signed in, with no workspace, and the invitations could not be read: none is made until they + * are, as this person may have come to join one. + */ + | { kind: 'unchecked'; account: Account } + /** + * A member or a guest of a workspace with no site: only an owner or an admin adds one. The Sites + * screen says so, as the front door sends them there too. + */ + | { kind: 'waiting'; workspace: Workspace }; + +export type Made = { workspace: Workspace; problem?: undefined } | { workspace?: undefined; problem: string }; + +/** What the setup asks of the worker and of the browser. */ +export type SetupCalls = { + /** `GET /console/me`, quiet: a person who is not signed in is no notice. */ + me: () => Promise>; + invitations: () => Promise>; + sites: (workspace: Workspace) => Promise>; + createWorkspace: (name: string) => Promise>; + /** Runs `task` alone in every tab of the browser, when the browser has a lock. */ + alone: (task: () => Promise) => Promise; +}; + +/** Who changes the name of a workspace and adds a site: `can` of the worker says the same. */ +export function manages(workspace: Workspace): boolean { + return workspace.role === 'owner' || workspace.role === 'admin'; +} + +/** + * The first workspace of a person, named after their address. + * + * WARNING: one at a time, in every tab of the browser. The tab that waited for the link and the tab + * of the link can both arrive here in the same second, and each would make a workspace. `alone` is + * the lock of the browser (`navigator.locks`); the tab that gets it second reads `/console/me` again + * and finds the workspace of the first. + */ +export function firstWorkspace(calls: SetupCalls, account: Account, name?: string): Promise { + return calls.alone(async (): Promise => { + const again = await calls.me(); + const made = again.ok ? again.data.workspaces[0] : undefined; + if (made !== undefined) return { workspace: made }; + const created = await calls.createWorkspace(name ?? workspaceNameFrom(account.email)); + + return created.ok + ? { workspace: created.data } + : { problem: problemOf(created, NAME_PROBLEMS, WORKSPACE_NOT_CREATED) }; + }); +} + +/** + * Where the setup of this person is: read from the worker at each visit, so it resumes anywhere. + * `signedIn` hears the account, for the language of the person. + */ +export async function resolveStage(calls: SetupCalls, signedIn: (me: Me) => void = () => {}): Promise { + const me = await calls.me(); + if (!me.ok) return { kind: 'email' }; + signedIn(me.data); + const { account } = me.data; + + let workspace = me.data.workspaces[0]; + if (workspace === undefined) { + // Somebody who was invited joins a workspace: none is made for them behind their back. + // A worker with no invitations at all answers 404: nobody can have been invited there. + const invitations = await calls.invitations(); + if (!invitations.ok && invitations.status !== 404) return { kind: 'unchecked', account }; + if (invitations.ok && invitations.data.length > 0) return { kind: 'invited' }; + const made = await firstWorkspace(calls, account); + if (made.workspace === undefined) return { kind: 'unnamed', account, problem: made.problem }; + workspace = made.workspace; + } + + return siteStage(calls, account, workspace); +} + +/** The step of a person who has a workspace: its first site, or the form that adds one. */ +export async function siteStage(calls: SetupCalls, account: Account, workspace: Workspace): Promise { + const listed = await calls.sites(workspace); + if (!listed.ok) return { kind: 'unread', account, workspace }; + const site = listed.data.sites[0]; + if (site !== undefined) return { kind: 'install', account, workspace, site }; + + return manages(workspace) ? { kind: 'site', account, workspace } : { kind: 'waiting', workspace }; +} + +/** Where a stage sends the person, away from the setup: none for a stage the setup shows. */ +export function leaveFor(stage: Stage): string | undefined { + if (stage.kind === 'invited') return '/invitations'; + if (stage.kind === 'waiting') return `/w/${stage.workspace.id}/sites`; + + return undefined; +} diff --git a/apps/console/app/sign-in-form.tsx b/apps/console/app/sign-in-form.tsx index 6d403d27..53a50e63 100644 --- a/apps/console/app/sign-in-form.tsx +++ b/apps/console/app/sign-in-form.tsx @@ -2,7 +2,7 @@ import { type FormEvent, type ReactNode, useEffect, useRef, useState } from 'rea import { type Providers, call, offersSignIn, providerStart, requestLink, signInProviders } from './api'; import { locale, msg, t } from './i18n'; import { awaitSignIn } from './tab-signal'; -import { Button, Divider, Field, LinkButton, Problem, WideButton } from './ui'; +import { AutoGrid, Button, Divider, Field, LinkButton, Problem, WideButton } from './ui'; import { useLocale } from './use-locale'; /** @@ -117,8 +117,8 @@ type EmailFormProps = { onSent: (email: string) => void; /** A refusal that came from elsewhere, as the code a provider sent back, shown until a link is asked for. */ problem?: string | undefined; - /** Runs before the link is asked for: the setup keeps what it needs after the sign-in. */ - before?: () => void; + /** « row »: the field and the button on one line while there is room, as in the setup. */ + layout?: 'column' | 'row'; }; /** @@ -127,7 +127,7 @@ type EmailFormProps = { * WARNING: nothing here clears the field. A link that is refused leaves the address as it was typed, * with the reason under it (`CLAUDE.md`: no error path clears a field). */ -export function EmailForm({ action, email, onEmail, onSent, problem: given, before }: EmailFormProps) { +export function EmailForm({ action, email, onEmail, onSent, problem: given, layout = 'column' }: EmailFormProps) { useLocale(); const [busy, setBusy] = useState(false); const [problem, setProblem] = useState(given); @@ -138,7 +138,6 @@ export function EmailForm({ action, email, onEmail, onSent, problem: given, befo if (email.trim() === '') return setProblem(SEND_PROBLEMS['invalid-email']); setBusy(true); setProblem(undefined); - before?.(); const sent = await requestLink(email, locale()); setBusy(false); if (sent.ok) return onSent(email.trim()); @@ -146,23 +145,36 @@ export function EmailForm({ action, email, onEmail, onSent, problem: given, befo setProblem(SEND_PROBLEMS[sent.error] ?? LINK_NOT_SENT); } + const row = layout === 'row'; + return ( // `noValidate`: the browser would stop the form with its own bubble, in its own language, and the // sentence of the console would never show (FRU-167). The field stays required for a screen reader. -
void submit(event)} className="flex flex-col gap-3.5"> - onEmail(event.target.value)} - required - /> - - {problem === undefined ? null : {t(problem)}} + {problem === undefined ? null : ( +
+ {t(problem)} +
+ )} ); } @@ -171,37 +183,56 @@ export function EmailForm({ action, email, onEmail, onSent, problem: given, befo * The providers this worker signs people in with, under « or ». A provider the worker does not offer * has no button, and with none there is nothing here, not even the word. */ -export function ProviderButtons({ providers }: { providers: Providers }) { +export function ProviderButtons({ + providers, + layout = 'column', +}: { + providers: Providers; + /** « grid »: side by side while there is room, in a column that spaces its own parts (the setup). */ + layout?: 'column' | 'grid'; +}) { useLocale(); if (!providers.google && !providers.github) return null; + const buttons = ( + <> + {providers.google ? ( + (window.location.href = providerStart('google'))}> + {t('Continue with Google')} + + ) : null} + {providers.github ? ( + (window.location.href = providerStart('github'))}> + {t('Continue with GitHub')} + + ) : null} + + ); + if (layout === 'grid') { + return ( + <> + {t('or')} + {buttons} + + ); + } return ( <> {t('or')} -
- {providers.google ? ( - (window.location.href = providerStart('google'))}> - {t('Continue with Google')} - - ) : null} - {providers.github ? ( - (window.location.href = providerStart('github'))}> - {t('Continue with GitHub')} - - ) : null} -
+
{buttons}
); } /** A sentence with one value in bold: the words around `{name}` come from the translation. */ -export function withStrong(sentence: string, name: string, value: string): ReactNode { +export function withStrong(sentence: string, name: string, value: string, tone = ''): ReactNode { const [before = '', after = ''] = t(sentence).split(`{${name}}`); return ( <> {before} - {value} + {/* `tone`: the colour of the value where the sentence around it is muted. */} + {value} {after} ); @@ -300,11 +331,11 @@ export function useCarryOn(carryOn: () => void): void { } /** « Use another address »: back to the form, with the address still in it. */ -export function AnotherAddress({ onBack }: { onBack: () => void }) { +export function AnotherAddress({ onBack, space = 'loose' }: { onBack: () => void; space?: 'normal' | 'loose' }) { useLocale(); return ( - + {t('Use another address')} ); diff --git a/apps/console/app/workspace-name.test.ts b/apps/console/app/workspace-name.test.ts new file mode 100644 index 00000000..6d0e4e07 --- /dev/null +++ b/apps/console/app/workspace-name.test.ts @@ -0,0 +1,51 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { workspaceNameFrom } from './workspace-name.ts'; + +describe('the name a workspace takes from the address of its first person (FRU-239)', () => { + it('is the domain of a company, with a capital and no suffix', () => { + assert.equal(workspaceNameFrom('ana@acme.dev'), 'Acme'); + assert.equal(workspaceNameFrom('tom@client.fr'), 'Client'); + assert.equal(workspaceNameFrom('paul@lumen.studio'), 'Lumen'); + }); + + it('is what is before the @ for a public mailbox, less what follows a +', () => { + assert.equal(workspaceNameFrom('ana.martin@gmail.com'), 'Ana.martin'); + assert.equal(workspaceNameFrom('tom+fruitback@proton.me'), 'Tom'); + assert.equal(workspaceNameFrom('lea@googlemail.com'), 'Lea'); + // The control: the same person at a company is named after the company. + assert.equal(workspaceNameFrom('ana.martin@acme.dev'), 'Acme'); + }); + + it('reads past a subdomain', () => { + assert.equal(workspaceNameFrom('ana@mail.acme.dev'), 'Acme'); + assert.equal(workspaceNameFrom('ana@eu.team.acme.dev'), 'Acme'); + }); + + it('reads an address in capitals, and with spaces around it', () => { + assert.equal(workspaceNameFrom(' ANA@ACME.DEV '), 'Acme'); + assert.equal(workspaceNameFrom('Tom@GMail.com'), 'Tom'); + }); + + it('reads past a suffix of two parts', () => { + assert.equal(workspaceNameFrom('ana@acme.co.uk'), 'Acme'); + assert.equal(workspaceNameFrom('ana@mail.acme.com.au'), 'Acme'); + assert.equal(workspaceNameFrom('ana@acme.ac.nz'), 'Acme'); + // The control: `co` before a suffix of three letters is the name itself. + assert.equal(workspaceNameFrom('ana@co.dev'), 'Co'); + // And a company named like a second level, under a country: its two labels are all there is. + assert.equal(workspaceNameFrom('ana@com.fr'), 'Com'); + }); + + it('always answers a name the worker takes: one line, 1 to 80 characters', () => { + assert.equal(workspaceNameFrom(''), 'Workspace'); + assert.equal(workspaceNameFrom('@'), 'Workspace'); + assert.equal(workspaceNameFrom('ana'), 'Ana'); + assert.equal(workspaceNameFrom('ana@localhost'), 'Localhost'); + assert.equal(workspaceNameFrom(`ana@${'a'.repeat(120)}.dev`).length, 80); + assert.equal(workspaceNameFrom('a\nb@gmail.com'), 'Ab'); + // Only spaces, of any kind, is no name: the worker would refuse it. + assert.equal(workspaceNameFrom('\u2003\u00a0@gmail.com'), 'Workspace'); + assert.equal(workspaceNameFrom('ana\u2003lee@gmail.com'), 'Ana lee'); + }); +}); diff --git a/apps/console/app/workspace-name.ts b/apps/console/app/workspace-name.ts new file mode 100644 index 00000000..2af6ff5a --- /dev/null +++ b/apps/console/app/workspace-name.ts @@ -0,0 +1,63 @@ +/** + * The name a workspace takes when its first person signs in (FRU-239): nobody is asked for one. + * + * It is read in the address of the person. An address of a company gives the name of its domain; an + * address of a public mailbox gives what is before the `@`. The person renames it whenever they like. + */ + +/** Mailboxes anybody opens: their domain names no team. A short list, on purpose. */ +const PUBLIC_MAILBOXES = new Set([ + 'gmail.com', + 'googlemail.com', + 'outlook.com', + 'hotmail.com', + 'live.com', + 'yahoo.com', + 'icloud.com', + 'me.com', + 'proton.me', + 'protonmail.com', + 'gmx.com', +]); + +/** + * The labels that stand before a country in a suffix of two parts: `co.uk`, `com.au`, `ac.nz`. + * With one of these before a country of two letters, the name of the company is one label further. + */ +const SECOND_LEVEL = new Set(['co', 'com', 'org', 'net', 'ac', 'gov', 'edu', 'ne', 'or']); + +/** The rule of the worker for the name of a workspace: one line, 80 characters at the most. */ +const LONGEST = 80; +/** For an address that gives nothing to read: the worker refuses an empty name. */ +const NO_NAME = 'Workspace'; + +function capital(word: string): string { + return word.charAt(0).toUpperCase() + word.slice(1); +} + +/** The label of a domain that names its owner: `acme` in `mail.acme.co.uk`. */ +function owner(domain: string): string { + const labels = domain.split('.').filter((label) => label !== ''); + const last = labels.at(-1) ?? ''; + const double = labels.length >= 3 && last.length === 2 && SECOND_LEVEL.has(labels.at(-2) ?? ''); + + return labels.at(double ? -3 : -2) ?? labels[0] ?? ''; +} + +export function workspaceNameFrom(email: string): string { + const address = email.trim().toLowerCase(); + const at = address.lastIndexOf('@'); + // What is after a `+` sorts the mail of a person, and is not a part of their name. + const local = (at === -1 ? address : address.slice(0, at)).split('+')[0] ?? ''; + const domain = at === -1 ? '' : address.slice(at + 1); + const read = domain === '' || PUBLIC_MAILBOXES.has(domain) ? local : owner(domain); + // One line, as the worker reads it: no control character, spaces of any kind as one, none at the ends. + const name = capital( + (read === '' ? local : read) + .replaceAll(/\p{Cc}/gu, '') + .replaceAll(/\s+/g, ' ') + .trim(), + ).slice(0, LONGEST); + + return name === '' ? NO_NAME : name; +} diff --git a/docs/decisions/console.md b/docs/decisions/console.md index de48cc7f..7bbbc051 100644 --- a/docs/decisions/console.md +++ b/docs/decisions/console.md @@ -81,6 +81,40 @@ The console of the Cloud, `apps/console`: its words, its kit, and what it shows kept ». What is asked under a row stays in what is seen of a table that scrolls. `e2e/console-members.spec.ts` reads the values and the boxes of the mockup at 1440 and 390 px, the keyboard, the contrasts and axe. +- **The setup is one screen, and resumes where the worker says the person is** (FRU-239, screen 01 of + the mockup of 2026-10-13). `setup.tsx` shows three steps together (step 1 in `setup-account.tsx`, + step 2 in `setup-site.tsx`; where the person is, decided from the answers of the worker, in + `setup-stage.ts`, which knows no React and no network and has its own `setup-stage.test.ts`): « Your account », « Your site », + « Put it on the site ». The step is read from the worker at each visit: nobody signed in, no + workspace, no site, a site. So a visit goes on anywhere, and the address has no `?step=`. **The + front door sends a workspace with no site back to the setup**, for who adds a site (an owner, an + admin); a list of sites that could not be read is not an empty list, there as everywhere. The step + « Source » is gone: a tracker is chosen in Connectors, after. +- **Nobody names a workspace** (FRU-239). The first one of a person takes its name from their + address (`workspace-name.ts`: the label of the domain that names its owner, or what is before the + `@` for a public mailbox), and « Rename » changes it in place (`POST /console/workspaces/`, + FRU-236). Nothing is kept in `localStorage` any more. **One workspace is made, in whatever tab**: + the tab that waited for the link and the tab of the link can both arrive at the setup in the same + second. `firstWorkspace` takes a lock of the browser (`navigator.locks`), and the tab that gets it + second reads `/console/me` again and finds the workspace of the first. **The lock needs a secure context**: a + console served over `http` on a host other than `localhost` has no `navigator.locks`, and there two + tabs that arrive in the same second can each make a workspace. The worker does not refuse a second + one: that would be a rule of the worker (one first workspace per account), not of the console. + `e2e/console-setup.spec.ts` holds the first request open and lets a second tab arrive: without the + lock the worker is asked twice (planted). **Somebody who was invited gets no workspace made behind + their back**: with an invitation that waits, the setup sends to `/invitations`, and while the + invitations cannot be read it makes nothing and offers « Try again » (a worker that has no + invitations at all answers 404, and the setup goes on). +- **« Change » never removes a site that may hold notes.** The worker has no route that changes a + site, and a site that is removed takes its notes with it. « Change » adds the site again with the + new values; it removes the one it replaces only when that one was added in this visit of the + setup. A site of a visit before stays, the summary says so, and the Sites screen removes it with + its two presses (FRU-165). +- **What the setup does not say, on purpose.** The hint of « Site address » stops at « Any page of it + will do. »: the mockup goes on with a pattern for the previews, which a domain that is proven + covers (FRU-103, decided on 2026-10-13), and the worker takes no pattern as an address. So what is + under the hint is 19.5 px higher than on the mockup. The last step keeps the script and the + browser of `install-options.tsx` until FRU-242 draws it. - **The address of a site is read by `site-address.ts`, on every screen that asks for one** (FRU-167). A host with no scheme is `https://`, as in the extension. The field is not `type="url"`: the browser stopped the form with its own bubble, in its own language, and the sentence of the diff --git a/e2e/console-account.spec.ts b/e2e/console-account.spec.ts index c9571c1a..2bcb40e8 100644 --- a/e2e/console-account.spec.ts +++ b/e2e/console-account.spec.ts @@ -442,11 +442,13 @@ test.describe('the browsers of a person, on a worker with accounts', () => { return answer.status; } - /** A browser of its own, signed in as `email` by the link of the mail, on the sites of the workspace. */ + /** + * A browser of its own, signed in as `email` by the link of the mail, on the sites of the workspace. + * The first one makes the workspace, named after the domain of the address (FRU-239). + */ async function browserOf( browser: import('@playwright/test').Browser, email: string, - workspace?: string, ): Promise<{ page: Page; context: BrowserContext }> { const context = await browser.newContext(); contexts.push(context); @@ -455,19 +457,19 @@ test.describe('the browsers of a person, on a worker with accounts', () => { const before = (await worker.sent()).length; await page.goto(`${WORKER_CONSOLE}/setup`); await page.getByRole('textbox', { name: 'Work email' }).fill(email); - if (workspace !== undefined) await page.getByRole('textbox', { name: 'Workspace name' }).fill(workspace); - await page.getByRole('button', { name: 'Continue', exact: true }).click(); + await page.getByRole('button', { name: 'Email me a link', exact: true }).click(); await expect.poll(async () => (await worker.sent()).length).toBe(before + 1); const link = /http:\/\/\S+\/sign-in#\S+/.exec((await worker.sent()).at(-1) ?? '')?.[0]; expect(link, 'no sign-in link in the message').toBeDefined(); await page.goto(link as string); // The link is spent when the worker takes the cookie of this browser: the front door is asked after. await expect.poll(() => accepted(context)).toBe(200); - // The name typed before makes the workspace, after the sign-in: the next step of the setup says it is made. - if (workspace !== undefined) - await expect(page.getByRole('heading', { name: 'Where should feedback go?' })).toBeVisible(); - await page.goto(WORKER_CONSOLE); - await expect(page).toHaveURL(/\/w\/ws_[0-9a-f]{20}\/sites$/); + // The setup is at its second step: the workspace is there, with no site yet. + await expect(page.getByRole('heading', { name: 'Current step: Your site' })).toBeVisible(); + const read = (await worker.received()) + .map((each) => /^\/console\/workspaces\/(ws_[0-9a-f]{20})\/sites$/.exec(each.path)?.[1]) + .filter((id) => id !== undefined); + await page.goto(`${WORKER_CONSOLE}/w/${read.at(-1) as string}/sites`); await expect(page.getByRole('heading', { name: 'No site yet' })).toBeVisible(); return { page, context }; @@ -504,7 +506,7 @@ test.describe('the browsers of a person, on a worker with accounts', () => { test('the extension stays connected when My account disconnects a browser, and when it signs out everywhere', async ({ browser, }) => { - const first = await browserOf(browser, 'ana@acme.dev', 'Acme'); + const first = await browserOf(browser, 'ana@acme.dev'); const second = await browserOf(browser, 'ana@acme.dev'); const workspace = /\/w\/(ws_[0-9a-f]{20})\//.exec(first.page.url())?.[1]; expect(workspace).toBeDefined(); @@ -554,7 +556,7 @@ test.describe('the browsers of a person, on a worker with accounts', () => { test('one browser disconnects the other, which learns it at its next call; « Sign out everywhere » ends both', async ({ browser, }) => { - const first = await browserOf(browser, 'ana@acme.dev', 'Acme'); + const first = await browserOf(browser, 'ana@acme.dev'); const second = await browserOf(browser, 'ana@acme.dev'); expect(await accepted(first.context)).toBe(200); expect(await accepted(second.context)).toBe(200); diff --git a/e2e/console-cookie-session.spec.ts b/e2e/console-cookie-session.spec.ts index 6f457064..42dea774 100644 --- a/e2e/console-cookie-session.spec.ts +++ b/e2e/console-cookie-session.spec.ts @@ -10,22 +10,39 @@ import { WORKER_ORIGIN } from './pin.ts'; * the worker: no stub decides an answer. The link is read where the worker was asked to send it, in the * mail, and never in the database. */ -/** From the sign-in screen to the list of the sites, by a link. */ -async function signInByLink(page: Page, worker: Worker, email: string): Promise { +/** + * From the setup to the list of the sites, by a link. Answers the id of the workspace. + * + * The workspace takes its name from the address (FRU-239): « Acme » for `…@acme.dev`. A workspace + * with no site goes back to the setup from the front door, so the Sites screen is opened by its + * address. + */ +async function signInByLink(page: Page, worker: Worker, email: string): Promise { await page.goto(`${CONSOLE}/setup`); await page.getByRole('textbox', { name: 'Work email' }).fill(email); - await page.getByRole('textbox', { name: 'Workspace name' }).fill('Acme'); - await page.getByRole('button', { name: 'Continue', exact: true }).click(); + await page.getByRole('button', { name: 'Email me a link' }).click(); await expect.poll(async () => (await worker.sent()).length).toBe(1); await page.goto(await linkIn(worker)); - // The link signs in, the name typed before makes the workspace, and the front door leads to its sites. - await expect(page.getByRole('heading', { name: 'Where should feedback go?' })).toBeVisible(); - await page.goto(CONSOLE); - await expect(page).toHaveURL(/\/w\/ws_[0-9a-f]{20}\/sites$/); + // The link signs in, the workspace is made, and the setup is at its second step. + await expect(page.getByRole('heading', { name: 'Current step: Your site' })).toBeVisible(); + const workspace = await workspaceOf(worker); + await page.goto(`${CONSOLE}/w/${workspace}/sites`); await expect(page.getByRole('heading', { name: 'Sites', level: 1 })).toBeVisible(); // The list was read: the title is there before the answer, and a test starts from a screen at rest. await expect(page.getByRole('heading', { name: 'No site yet' })).toBeVisible(); + + return workspace; +} + +/** The id of the workspace the console last read the sites of, as the worker received it. */ +async function workspaceOf(worker: Worker): Promise { + const read = (await worker.received()) + .map((each) => /^\/console\/workspaces\/(ws_[0-9a-f]{20})\/sites$/.exec(each.path)?.[1]) + .filter((id) => id !== undefined); + expect(read.length, 'the console read the sites of no workspace').toBeGreaterThan(0); + + return read.at(-1) as string; } test.describe('the console, signed in by the cookie of better-auth (FRU-148)', () => { @@ -95,7 +112,7 @@ test.describe('the console, signed in by the cookie of better-auth (FRU-148)', ( // A link that works, held while the worker answers: the token is gone before the answer. await page.goto(`${CONSOLE}/setup`); await page.getByRole('textbox', { name: 'Work email' }).fill('ana@acme.dev'); - await page.getByRole('button', { name: 'Continue', exact: true }).click(); + await page.getByRole('button', { name: 'Email me a link' }).click(); await expect.poll(async () => (await worker.sent()).length).toBe(1); const link = await linkIn(worker); const token = link.split('#')[1] as string; @@ -168,7 +185,8 @@ test.describe('the console, signed in by the cookie of better-auth (FRU-148)', ( await tab.getByRole('button', { name: 'Email me a link' }).click(); await expect.poll(async () => (await worker.sent()).length).toBe(2); await tab.goto(await linkIn(worker)); - await expect(tab).toHaveURL(/\/sites$/); + // Signed in again. The workspace has no site yet, so the front door leads to the setup. + await expect(tab).toHaveURL(/\/setup$|\/sites$/); await page.getByRole('alert').getByRole('button', { name: 'Continue', exact: true }).click(); // The notice goes. The sentence of the screen, « the site was not added », stays until the next try. @@ -187,7 +205,7 @@ test.describe('the console, signed in by the cookie of better-auth (FRU-148)', ( await page.getByRole('link', { name: 'My account' }).click(); await page.getByRole('button', { name: 'Sign out', exact: true }).click(); - await expect(page.getByRole('heading', { name: 'Create your workspace' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Set up Fruitback' })).toBeVisible(); expect((await worker.received()).map((each) => each.path)).toContain('/console/auth/sign-out'); // The cookie the browser held is refused by the worker, whoever presents it. @@ -228,7 +246,7 @@ test.describe('the console, signed in by the cookie of better-auth (FRU-148)', ( await page.unroute(refused); await page.getByRole('button', { name: 'Sign out', exact: true }).click(); - await expect(page.getByRole('heading', { name: 'Create your workspace' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Set up Fruitback' })).toBeVisible(); expect((await me()).status).toBe(401); }); @@ -237,11 +255,10 @@ test.describe('the console, signed in by the cookie of better-auth (FRU-148)', ( const tab = await context.newPage(); await tab.goto(`${CONSOLE}/setup`); await tab.getByRole('textbox', { name: 'Work email' }).fill(email); - await tab.getByRole('textbox', { name: 'Workspace name' }).fill('Other'); - await tab.getByRole('button', { name: 'Continue', exact: true }).click(); + await tab.getByRole('button', { name: 'Email me a link' }).click(); await expect.poll(async () => (await worker.sent()).length).toBe(mails); await tab.goto(await linkIn(worker)); - await expect(tab.getByRole('heading', { name: 'Where should feedback go?' })).toBeVisible(); + await expect(tab.getByRole('heading', { name: 'Current step: Your site' })).toBeVisible(); return tab; } @@ -284,15 +301,17 @@ test.describe('the console, signed in by the cookie of better-auth (FRU-148)', ( const written = await sitesWritten(worker); // Bob signs in in another tab, and somebody presses « Continue » on the screen of Ana. - const other = await signInElsewhere(context, worker, 'bob@acme.dev', 2); + const other = await signInElsewhere(context, worker, 'bob@client.fr', 2); await notice.getByRole('button', { name: 'Continue', exact: true }).click(); await expectStopped(page, 'Another account is signed in in this browser'); expect(await sitesWritten(worker)).toBe(written); // The one way on loads the console for whoever is signed in now. await page.getByRole('button', { name: 'Load the console again', exact: true }).click(); - await expect(page.getByRole('heading', { name: 'Sites', level: 1 })).toBeVisible(); - await expect(page.getByText('Other', { exact: true })).toBeVisible(); + // The workspace of Bob, named after his address, has no site yet: the front door leads to the + // setup, at its second step. + await expect(page.getByRole('heading', { name: 'Current step: Your site' })).toBeVisible(); + await expect(page.getByText('Client', { exact: true })).toBeVisible(); await expect(page.getByRole('textbox', { name: 'Site address' })).toHaveValue(''); await other.close(); }); @@ -301,7 +320,7 @@ test.describe('the console, signed in by the cookie of better-auth (FRU-148)', ( page, context, }) => { - await signInByLink(page, worker, 'ana@acme.dev'); + const workspace = await signInByLink(page, worker, 'ana@acme.dev'); await page.getByRole('textbox', { name: 'Site address' }).fill('staging.acme.dev'); /** The tab is shown again: what a browser tells a page when somebody comes back to it. */ const comesBack = (tab: Page) => tab.evaluate(() => void window.dispatchEvent(new Event('focus'))); @@ -313,12 +332,12 @@ test.describe('the console, signed in by the cookie of better-auth (FRU-148)', ( // In a second tab Ana signs out, and Bob signs in. const second = await context.newPage(); - await second.goto(CONSOLE); + await second.goto(`${CONSOLE}/w/${workspace}/sites`); await second.getByRole('link', { name: 'My account' }).click(); await second.getByRole('button', { name: 'Sign out', exact: true }).click(); - await expect(second.getByRole('heading', { name: 'Create your workspace' })).toBeVisible(); + await expect(second.getByRole('heading', { name: 'Set up Fruitback' })).toBeVisible(); await second.close(); - const other = await signInElsewhere(context, worker, 'bob@acme.dev', 2); + const other = await signInElsewhere(context, worker, 'bob@client.fr', 2); await comesBack(page); await expectStopped(page, 'Another account is signed in in this browser'); @@ -330,13 +349,13 @@ test.describe('the console, signed in by the cookie of better-auth (FRU-148)', ( page, context, }) => { - await signInByLink(page, worker, 'ana@acme.dev'); + const workspace = await signInByLink(page, worker, 'ana@acme.dev'); await page.getByRole('textbox', { name: 'Site address' }).fill('staging.acme.dev'); const second = await context.newPage(); - await second.goto(CONSOLE); + await second.goto(`${CONSOLE}/w/${workspace}/sites`); await second.getByRole('link', { name: 'My account' }).click(); await second.getByRole('button', { name: 'Sign out', exact: true }).click(); - await expect(second.getByRole('heading', { name: 'Create your workspace' })).toBeVisible(); + await expect(second.getByRole('heading', { name: 'Set up Fruitback' })).toBeVisible(); await second.close(); await page.evaluate(() => void window.dispatchEvent(new Event('focus'))); diff --git a/e2e/console-invitation.spec.ts b/e2e/console-invitation.spec.ts index 0f76d651..5bf238a1 100644 --- a/e2e/console-invitation.spec.ts +++ b/e2e/console-invitation.spec.ts @@ -30,13 +30,15 @@ async function lastTo(worker: Worker, to: string): Promise { return found as string; } -/** Signs `email` in by the link of the setup, in this browser. */ -async function signIn(page: Page, worker: Worker, email: string, workspace?: string): Promise { +/** + * Signs `email` in by the link of the setup, in this browser. Somebody with no workspace and no + * invitation gets one named after their address (FRU-239): « Acme » for `owner@acme.dev`. + */ +async function signIn(page: Page, worker: Worker, email: string): Promise { const before = (await worker.mail()).filter((each) => each.to === email).length; await page.goto(`${CONSOLE}/setup`); await page.getByRole('textbox', { name: 'Work email' }).fill(email); - if (workspace !== undefined) await page.getByRole('textbox', { name: 'Workspace name' }).fill(workspace); - await page.getByRole('button', { name: 'Continue', exact: true }).click(); + await page.getByRole('button', { name: 'Email me a link' }).click(); await expect.poll(async () => (await worker.mail()).filter((each) => each.to === email).length).toBe(before + 1); const link = /http:\/\/\S+\/sign-in#\S+/.exec(await lastTo(worker, email))?.[0]; expect(link, 'no sign-in link in the message').toBeDefined(); @@ -68,13 +70,15 @@ test.describe('an invited reviewer (FRU-104)', () => { const paul = await pauls.newPage(); // The owner: a workspace, a site, and an invitation. - await signIn(owner, worker, 'owner@acme.dev', 'Acme'); - await expect(owner.getByRole('heading', { name: 'Where should feedback go?' })).toBeVisible(); + await signIn(owner, worker, 'owner@acme.dev'); + // The second step of the setup adds the site, and the front door then leads to the sites. + await expect(owner.getByRole('heading', { name: 'Current step: Your site' })).toBeVisible(); + await owner.getByRole('textbox', { name: 'Site address' }).fill(SITE); + await owner.getByRole('button', { name: 'Add site' }).click(); + await expect(owner.getByRole('heading', { name: 'Current step: Put it on the site' })).toBeVisible(); await owner.goto(CONSOLE); await expect(owner).toHaveURL(/\/w\/ws_[0-9a-f]{20}\/sites$/); const workspace = /\/w\/(ws_[0-9a-f]{20})\//.exec(owner.url())?.[1] as string; - await owner.getByRole('textbox', { name: 'Site address' }).fill(SITE); - await owner.getByRole('button', { name: 'Add site' }).click(); await expect(owner.getByText('staging.acme.dev', { exact: true })).toBeVisible(); await owner.goto(`${CONSOLE}/w/${workspace}/members`); @@ -175,11 +179,14 @@ test.describe('an invited reviewer (FRU-104)', () => { contexts.push(owners); await served(owners, worker); const owner = await owners.newPage(); - await signIn(owner, worker, 'owner@acme.dev', 'Acme'); - await expect(owner.getByRole('heading', { name: 'Where should feedback go?' })).toBeVisible(); - await owner.goto(CONSOLE); - await expect(owner).toHaveURL(/\/w\/ws_[0-9a-f]{20}\/sites$/); - const workspace = /\/w\/(ws_[0-9a-f]{20})\//.exec(owner.url())?.[1] as string; + await signIn(owner, worker, 'owner@acme.dev'); + await expect(owner.getByRole('heading', { name: 'Current step: Your site' })).toBeVisible(); + // The workspace has no site, so the front door leads back to the setup: its id is read where + // the worker received it. + const workspace = (await worker.received()) + .map((each) => /^\/console\/workspaces\/(ws_[0-9a-f]{20})\/sites$/.exec(each.path)?.[1]) + .findLast((id) => id !== undefined) as string; + expect(workspace).toMatch(/^ws_[0-9a-f]{20}$/); const toPaul = async (): Promise => (await worker.mail()).filter((each) => each.to === 'paul@client.dev').length; diff --git a/e2e/console-kit.spec.ts b/e2e/console-kit.spec.ts index e8068542..a6c8e43a 100644 --- a/e2e/console-kit.spec.ts +++ b/e2e/console-kit.spec.ts @@ -153,8 +153,9 @@ async function boxOf(locator: Locator): Promise<{ radius: string; border: number } test('the wide buttons and the fields have the shape and the border of the board (FRU-168)', async ({ page }) => { - // `design/boards/2-onboarding.png`: four controls of one shape in a column, and the pill kept for - // the action of the step. The screen drew the wide buttons as pills too, with a border of 1.26:1. + // `design/boards/2-onboarding.png`, then screen 01 of the mockup of 2026-10-13: controls of one + // shape, and the pill kept for the action of the step. The screen drew the wide buttons as pills + // too, with a border of 1.26:1. await openSetup(page); const white = [255, 255, 255]; @@ -166,17 +167,15 @@ test('the wide buttons and the fields have the shape and the border of the board expect.soft(contrast(button.border, white), name).toBeGreaterThanOrEqual(3); } - for (const name of ['Work email', 'Workspace name']) { - const field = await boxOf(page.getByRole('textbox', { name })); - expect.soft(Number.parseFloat(field.radius), name).toBeGreaterThanOrEqual(8); - expect.soft(Number.parseFloat(field.radius), name).toBeLessThanOrEqual(10); - expect.soft(contrast(field.border, white), name).toBeGreaterThanOrEqual(3); - // Flat: no shadow, or one that paints nothing. - expect.soft(field.shadow, name).toMatch(/^none$|^(rgba\(0, 0, 0, 0\) 0px 0px 0px 0px(, )?)+$/); - } + const field = await boxOf(page.getByRole('textbox', { name: 'Work email' })); + expect.soft(Number.parseFloat(field.radius)).toBeGreaterThanOrEqual(8); + expect.soft(Number.parseFloat(field.radius)).toBeLessThanOrEqual(10); + expect.soft(contrast(field.border, white)).toBeGreaterThanOrEqual(3); + // Flat: no shadow, or one that paints nothing. + expect.soft(field.shadow).toMatch(/^none$|^(rgba\(0, 0, 0, 0\) 0px 0px 0px 0px(, )?)+$/); - // The action of the step keeps the pill: its shape is what tells it from the four controls. - const action = await boxOf(page.getByRole('button', { name: 'Continue', exact: true })); + // The action of the step keeps the pill: its shape is what tells it from the controls. + const action = await boxOf(page.getByRole('button', { name: 'Email me a link' })); expect.soft(Number.parseFloat(action.radius)).toBeGreaterThanOrEqual(20); }); diff --git a/e2e/console-narrow.spec.ts b/e2e/console-narrow.spec.ts index 75d7c9f1..a2246598 100644 --- a/e2e/console-narrow.spec.ts +++ b/e2e/console-narrow.spec.ts @@ -72,92 +72,4 @@ test('at 390 px, the title follows the sections, and a long host reads in full', expect(box!.x + box!.width).toBeLessThanOrEqual(390); }); -/** The worker as `/setup` meets it (FRU-160): nobody signed in, or a person with one workspace. */ -async function stubSetup(page: Page, signedIn: boolean): Promise { - const me = { - account: { id: 'a1', email: 'ana@acme.dev', name: 'Ana' }, - workspaces: [{ id: 'w1', name: 'Acme', role: 'owner' }], - }; - const site = { id: 's1', origin: `https://${LONG_HOST}`, visibility: 'members' }; - await page.route(`${WORKER_ORIGIN}/**`, (route) => { - const { pathname } = new URL(route.request().url()); - const json = (body: unknown, status = 200) => route.fulfill({ status, json: body }); - if (pathname === '/console/me') return signedIn ? json(me) : json({ error: 'unauthorized' }, 401); - if (pathname === '/console/workspaces/w1/domains') return json({ domains: [] }); - if (pathname === '/console/workspaces/w1/sites') return json(site, 201); - // A worker that signs people in by a link: one that says nothing there shows no form (FRU-217). - if (pathname === '/auth/providers') return json({ betterAuth: { email: true, github: false, google: false } }); - - return route.fulfill({ status: 204 }); - }); -} - -/** The lines each visible label of the header of `/setup` takes. */ -const headerLines = (page: Page) => - page.locator('header').evaluate((header) => - [...header.querySelectorAll('span, a')] - .filter((element) => element.children.length === 0 && element.textContent?.trim() && element.offsetWidth > 1) - .map((element) => { - const range = document.createRange(); - range.selectNodeContents(element); - const tops = new Set([...range.getClientRects()].map((rect) => Math.round(rect.top))); - - return { text: element.textContent?.trim(), lines: tops.size }; - }), - ); - -/** How far the title of the current step is cut, and the lines it takes. A whole title: 0 and 1. */ -const currentStepTitle = (page: Page) => - page - .locator('ol [aria-current="step"] span span') - .first() - .evaluate((title) => { - const range = document.createRange(); - range.selectNodeContents(title); - const tops = new Set([...range.getClientRects()].map((rect) => Math.round(rect.top))); - - return { cut: title.scrollWidth - title.clientWidth, lines: tops.size }; - }); - -for (const width of [390, 320]) { - for (const locale of ['en-US', 'fr-FR']) { - test(`no step of /setup scrolls sideways at ${width} px, in ${locale}`, async ({ browser }) => { - const context = await browser.newContext({ locale, viewport: { width, height: 844 } }); - const page = await context.newPage(); - const continueButton = page.getByRole('button', { name: /^(Continue|Continuer)$/ }); - - // Step 1, signed out. - await stubSetup(page, false); - await page.goto(`${CONSOLE}/setup`); - await expect(continueButton).toBeVisible(); - expect.soft(await sideScroll(page), 'step 1 scrolls sideways').toBe(0); - expect.soft(await currentStepTitle(page), 'the title of step 1').toEqual({ cut: 0, lines: 1 }); - if (width === 390) { - expect.soft((await headerLines(page)).filter((label) => label.lines > 1)).toEqual([]); - const box = await continueButton.boundingBox(); - expect.soft(box!.y + box!.height, 'the action of step 1 is under the fold').toBeLessThanOrEqual(844); - } - await page.unrouteAll(); - - // Steps 2, 3 and 4, with a workspace. - await stubSetup(page, true); - await page.goto(`${CONSOLE}/setup`); - await expect(page.locator('ol [aria-current="step"]')).toContainText('2'); - expect.soft(await sideScroll(page), 'step 2 scrolls sideways').toBe(0); - expect.soft(await currentStepTitle(page), 'the title of step 2').toEqual({ cut: 0, lines: 1 }); - await page.goto(`${CONSOLE}/setup?step=site`); - await expect(page.locator('ol [aria-current="step"]')).toContainText('3'); - expect.soft(await sideScroll(page), 'step 3 scrolls sideways').toBe(0); - expect.soft(await currentStepTitle(page), 'the title of step 3').toEqual({ cut: 0, lines: 1 }); - await page.getByRole('textbox').first().fill(`https://${LONG_HOST}`); - await continueButton.click(); - await expect(page.locator('ol [aria-current="step"]')).toContainText('4'); - await expect(page.locator('pre')).toBeVisible(); - expect.soft(await sideScroll(page), 'step 4 scrolls sideways').toBe(0); - expect.soft(await currentStepTitle(page), 'the title of step 4').toEqual({ cut: 0, lines: 1 }); - // The script scrolls inside its block, and stays whole. - expect(await page.locator('pre').evaluate((pre) => pre.scrollWidth > pre.clientWidth)).toBe(true); - await context.close(); - }); - } -} +// The setup on a narrow screen (FRU-160) is measured with the setup, in `console-setup.spec.ts`. diff --git a/e2e/console-no-sign-in.spec.ts b/e2e/console-no-sign-in.spec.ts index 52c4dbe8..6476a243 100644 --- a/e2e/console-no-sign-in.spec.ts +++ b/e2e/console-no-sign-in.spec.ts @@ -37,10 +37,10 @@ test('says that nobody can sign in, and shows no form, when the worker offers no await page.goto(`${CONSOLE}/setup`); await expect(page.getByRole('alert').filter({ hasText: CLOSED })).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Create your workspace' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Set up Fruitback' })).toBeVisible(); // Nothing to type in, and nothing to press: a link asked for here is never sent. await expect(page.getByRole('textbox')).toHaveCount(0); - await expect(page.getByRole('button', { name: /Continue/ })).toHaveCount(0); + await expect(page.getByRole('button', { name: /Email me a link|Continue with/ })).toHaveCount(0); expect(asked.filter((each) => each.startsWith('POST'))).toEqual([]); // The screen fits a phone. await page.setViewportSize({ width: 390, height: 844 }); diff --git a/e2e/console-setup-sources.spec.ts b/e2e/console-setup-sources.spec.ts deleted file mode 100644 index 904d8258..00000000 --- a/e2e/console-setup-sources.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { type Page, expect, test } from '@playwright/test'; -import { WORKER_ORIGIN } from './pin.ts'; - -/** - * Step 2 of the set-up, « Where should feedback go? » (FRU-166). It chooses nothing yet: Fruitback - * keeps the notes. So it is a list to read, not a radio group whose radios take no focus, and a source - * that the Connectors screen connects is not said to come « After the beta ». - * - * The worker is answered by stubs: a person signed in, with one workspace. - */ -const CONSOLE = 'http://localhost:5178'; - -async function stubWorker(page: Page): Promise { - const me = { - account: { id: 'a1', email: 'ana@acme.dev', name: 'Ana' }, - workspaces: [{ id: 'w1', name: 'Acme', role: 'owner' }], - }; - await page.route(`${WORKER_ORIGIN}/**`, (route) => { - const { pathname } = new URL(route.request().url()); - const json = (body: unknown) => route.fulfill({ json: body }); - if (pathname === '/console/me') return json(me); - // A worker that signs people in by a link: one that says nothing there shows no form (FRU-217). - if (pathname === '/auth/providers') return json({ betterAuth: { email: true, github: false, google: false } }); - - return route.fulfill({ status: 204 }); - }); -} - -const WORDS = { - 'en-US': { - list: 'Sources', - later: 'In Connectors, after the set-up', - beta: 'After the beta', - rest: 'REST API', - }, - 'fr-FR': { - list: 'Sources', - later: 'Dans Connecteurs, après la mise en place', - beta: 'Après la bêta', - rest: 'API REST', - }, -} as const; - -for (const [locale, words] of Object.entries(WORDS)) { - test(`step 2 says where each source connects, in ${locale}`, async ({ browser }) => { - const context = await browser.newContext({ locale, viewport: { width: 390, height: 844 } }); - const page = await context.newPage(); - await stubWorker(page); - await page.goto(`${CONSOLE}/setup`); - await expect(page.locator('ol [aria-current="step"]')).toContainText('2'); - - // Nothing here is a choice, so nothing claims to be one. - await expect(page.getByRole('radiogroup')).toHaveCount(0); - await expect(page.getByRole('radio')).toHaveCount(0); - - const rows = page.getByRole('list', { name: words.list }).getByRole('listitem'); - const row = (name: string) => rows.filter({ hasText: name }); - await expect(rows).toHaveCount(4); - // The sources the Connectors screen connects say where, and not « After the beta ». - for (const name of ['Linear', words.rest]) { - await expect(row(name)).toContainText(words.later); - await expect(row(name)).not.toContainText(words.beta); - } - // A source that nothing connects yet still says so. - await expect(row('GitHub Issues')).toContainText(words.beta); - await context.close(); - }); -} diff --git a/e2e/console-setup.spec.ts b/e2e/console-setup.spec.ts new file mode 100644 index 00000000..6fff8a4c --- /dev/null +++ b/e2e/console-setup.spec.ts @@ -0,0 +1,719 @@ +import AxeBuilder from '@axe-core/playwright'; +import { type Locator, type Page, expect, test } from '@playwright/test'; +import { CONSOLE, type Worker, cloudWorker, finished, linkIn, served } from './cloud-worker.ts'; +import { WORKER_ORIGIN } from './pin.ts'; + +/** + * The setup on one screen (FRU-239, screen 01 of the mockup of 2026-10-13): « Your account », + * « Your site », then the step that puts Fruitback on the site. + * + * The first part runs against a worker whose accounts are in PostgreSQL (PGlite): no stub decides an + * answer, and the link is read in the mail. The second part measures the states against the mockup, + * and there the worker is answered by stubs: it measures boxes and colours. + */ +const CURRENT = 'Current step: '; + +test.describe('the setup, against a worker with accounts (FRU-239)', () => { + let worker: Worker; + + test.beforeEach(async ({ context }) => { + worker = await cloudWorker(); + await served(context, worker); + }); + test.afterEach(({ context }) => finished(context, worker)); + + /** The workspaces of the person signed in in `page`, as the worker lists them. */ + const workspaces = (page: Page): Promise<{ id: string; name: string }[]> => + page.evaluate(async (origin) => { + const answer = await fetch(`${origin}/console/me`, { credentials: 'include' }); + + return ((await answer.json()) as { workspaces: { id: string; name: string }[] }).workspaces; + }, WORKER_ORIGIN); + + test('a new address goes from the link to its site, with a workspace named after its domain', async ({ + page, + context, + }) => { + await page.goto(`${CONSOLE}/setup`); + await expect(page.getByRole('heading', { name: `${CURRENT}Your account` })).toBeVisible(); + await page.getByRole('textbox', { name: 'Work email' }).fill('ana@acme.dev'); + await page.getByRole('button', { name: 'Email me a link' }).click(); + await expect(page.getByRole('status')).toContainText( + 'A sign-in link is on its way to ana@acme.dev. Open it in this browser: this page carries on by itself.', + ); + await expect.poll(async () => (await worker.sent()).length).toBe(1); + + // The mail opens the link in another tab of the same browser. + const other = await context.newPage(); + await other.goto(await linkIn(worker)); + + // The setup goes on by itself, to its second step: nobody was asked for a name. + await expect(page.getByRole('heading', { name: `${CURRENT}Your site` })).toBeVisible(); + await expect(page.getByText('Signed in as ana@acme.dev')).toBeVisible(); + await expect(page.getByText('Workspace Acme')).toBeVisible(); + await expect(other.getByRole('heading', { name: 'You are signed in' })).toBeVisible(); + // One workspace, though two tabs of the browser were signed in at that moment. + expect((await workspaces(page)).map((each) => each.name)).toEqual(['Acme']); + // The step that is done is no longer the current one, and the last one is still to come. + await expect(page.getByRole('heading', { name: 'Your account', exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Put it on the site', exact: true })).toBeVisible(); + + // « Rename »: the name becomes a field, and the worker keeps the new one. + await page.getByRole('button', { name: 'Rename' }).click(); + const name = page.getByRole('textbox', { name: 'Workspace name' }); + await expect(name).toBeFocused(); + await expect(name).toHaveValue('Acme'); + await name.fill('Acme Studio'); + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByText('Workspace Acme Studio')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Rename' })).toBeFocused(); + expect((await workspaces(page)).map((each) => each.name)).toEqual(['Acme Studio']); + // The new name is in the words of the second step too. + await expect(page.getByRole('radio', { name: /^Members of Acme Studio/ })).toBeChecked(); + + // « Add site »: a host with no scheme, and the setup is at its last step. + await page.getByRole('textbox', { name: 'Site address' }).fill('staging.acme.dev'); + await page.getByRole('button', { name: 'Add site' }).click(); + await expect(page.getByRole('heading', { name: `${CURRENT}Put it on the site` })).toBeVisible(); + await expect(page.getByText('staging.acme.dev', { exact: true }).first()).toBeVisible(); + await expect(page.getByText('Members of Acme Studio see the notes')).toBeVisible(); + + // Somebody who comes back is where they left: the front door leads to the sites now. + await page.goto(`${CONSOLE}/setup`); + await expect(page.getByRole('heading', { name: `${CURRENT}Put it on the site` })).toBeVisible(); + await page.goto(CONSOLE); + await expect(page).toHaveURL(/\/w\/ws_[0-9a-f]{20}\/sites$/); + }); + + test('two tabs of one browser make one workspace, not one each', async ({ page, context }) => { + await page.goto(`${CONSOLE}/sign-in`); + await page.getByRole('textbox', { name: 'Work email' }).fill('ana@acme.dev'); + await page.getByRole('button', { name: 'Email me a link' }).click(); + await expect.poll(async () => (await worker.sent()).length).toBe(1); + // The worker is slow to make the workspace: the first tab is still asking when the second arrives. + let release = (): void => undefined; + const held = new Promise((resolve) => (release = resolve)); + const making = `${WORKER_ORIGIN}/console/workspaces`; + await context.route(making, async (route) => { + await held; + await route.fallback(); + }); + const asked = page.waitForRequest(making); + await page.goto(await linkIn(worker)); + await asked; + + const second = await context.newPage(); + await second.goto(`${CONSOLE}/setup`); + // The control: the second tab is signed in, has no workspace yet, and is past its own reading. + // The first tab read its invitations twice (the front door, then the setup): the third reading + // is the second tab's, and what it does next is make the workspace, or wait for the first. + await expect + .poll(async () => (await worker.received()).filter((each) => each.path.endsWith('/list-user-invitations')).length) + .toBe(3); + await second.waitForTimeout(300); + release(); + + await expect(page.getByRole('heading', { name: `${CURRENT}Your site` })).toBeVisible(); + await expect(second.getByRole('heading', { name: `${CURRENT}Your site` })).toBeVisible(); + expect((await workspaces(page)).map((each) => each.name)).toEqual(['Acme']); + const made = (await worker.received()).filter( + (each) => each.method === 'POST' && each.path === '/console/workspaces', + ); + expect(made).toHaveLength(1); + }); + + test('a name the worker refuses stays in its field, with the reason', async ({ page }) => { + await page.goto(`${CONSOLE}/setup`); + await page.getByRole('textbox', { name: 'Work email' }).fill('ana@acme.dev'); + await page.getByRole('button', { name: 'Email me a link' }).click(); + await expect.poll(async () => (await worker.sent()).length).toBe(1); + await page.goto(await linkIn(worker)); + await expect(page.getByRole('heading', { name: `${CURRENT}Your site` })).toBeVisible(); + + await page.getByRole('button', { name: 'Rename' }).click(); + const name = page.getByRole('textbox', { name: 'Workspace name' }); + // The field stops at 80 characters, as the rule of the worker: a name of spaces is what it refuses. + await name.fill(' '); + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByRole('alert')).toHaveText('A workspace needs a name of one line, up to 80 characters.'); + await expect(name).toHaveValue(' '); + expect((await workspaces(page)).map((each) => each.name)).toEqual(['Acme']); + + // Escape gives the name back as it was, and focus to « Rename ». + await name.press('Escape'); + await expect(page.getByText('Workspace Acme')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Rename' })).toBeFocused(); + }); + + test('« Change » replaces a site added in this visit, and keeps one of a visit before', async ({ page }) => { + await page.goto(`${CONSOLE}/setup`); + await page.getByRole('textbox', { name: 'Work email' }).fill('ana@acme.dev'); + await page.getByRole('button', { name: 'Email me a link' }).click(); + await expect.poll(async () => (await worker.sent()).length).toBe(1); + await page.goto(await linkIn(worker)); + await page.getByRole('textbox', { name: 'Site address' }).fill('stagin.acme.dev'); + await page.getByRole('button', { name: 'Add site' }).click(); + await expect(page.getByRole('heading', { name: `${CURRENT}Put it on the site` })).toBeVisible(); + const [workspace] = await workspaces(page); + const sites = (): Promise => + page.evaluate( + async ([origin, id]) => { + const answer = await fetch(`${origin}/console/workspaces/${id}/sites`, { credentials: 'include' }); + + return ((await answer.json()) as { sites: { origin: string }[] }).sites.map((site) => site.origin); + }, + [WORKER_ORIGIN, workspace!.id], + ); + + // A typing mistake, seen at once: the form opens with its values, and the site is replaced. + await page.getByRole('button', { name: 'Change' }).click(); + const address = page.getByRole('textbox', { name: 'Site address' }); + await expect(address).toHaveValue('stagin.acme.dev'); + await address.fill('staging.acme.dev'); + await page.getByText('Everyone who visits', { exact: true }).click(); + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByText('Everyone who visits sees the notes')).toBeVisible(); + expect(await sites()).toEqual(['https://staging.acme.dev']); + + // « Cancel » and a form sent as it was change nothing. + await page.getByRole('button', { name: 'Change' }).click(); + await page.getByRole('button', { name: 'Cancel' }).click(); + await page.getByRole('button', { name: 'Change' }).click(); + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByText('Everyone who visits sees the notes')).toBeVisible(); + expect(await sites()).toEqual(['https://staging.acme.dev']); + + // Another visit: the site may hold notes now, so it is not removed behind the person's back. + await page.goto(`${CONSOLE}/setup`); + await page.getByRole('button', { name: 'Change' }).click(); + await page.getByRole('textbox', { name: 'Site address' }).fill('preview.acme.dev'); + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByText('preview.acme.dev', { exact: true }).first()).toBeVisible(); + await expect(page.getByRole('status').filter({ hasText: 'is still in your sites' })).toHaveText( + 'staging.acme.dev is still in your sites, with its notes. Remove it in Sites if you no longer review it.', + ); + expect((await sites()).toSorted()).toEqual(['https://preview.acme.dev', 'https://staging.acme.dev']); + }); + + test('somebody who was invited is sent to their invitation: no workspace is made for them', async ({ + page, + browser, + }) => { + // An owner, with a workspace, invites Tom. + const owners = await browser.newContext({ locale: 'en-US' }); + await served(owners, worker); + const owner = await owners.newPage(); + await owner.goto(`${CONSOLE}/setup`); + await owner.getByRole('textbox', { name: 'Work email' }).fill('ana@acme.dev'); + await owner.getByRole('button', { name: 'Email me a link' }).click(); + await expect.poll(async () => (await worker.sent()).length).toBe(1); + await owner.goto(await linkIn(worker)); + await expect(owner.getByRole('heading', { name: `${CURRENT}Your site` })).toBeVisible(); + const [workspace] = await workspaces(owner); + await owner.goto(`${CONSOLE}/w/${workspace!.id}/members`); + await owner.getByRole('textbox', { name: 'Email', exact: true }).fill('tom@client.fr'); + await owner.getByRole('button', { name: 'Invite', exact: true }).click(); + await expect.poll(async () => (await worker.mail()).filter((each) => each.to === 'tom@client.fr').length).toBe(1); + + // Tom opens the setup, not the link of his invitation, and signs in there. + await page.goto(`${CONSOLE}/setup`); + await page.getByRole('textbox', { name: 'Work email' }).fill('tom@client.fr'); + await page.getByRole('button', { name: 'Email me a link' }).click(); + await expect.poll(async () => (await worker.mail()).filter((each) => each.to === 'tom@client.fr').length).toBe(2); + const link = /http:\/\/\S+\/sign-in#\S+/.exec( + (await worker.mail()).findLast((each) => each.to === 'tom@client.fr')!.text, + ); + // The tab of the setup goes on by itself, and finds the invitation. + const other = await page.context().newPage(); + await other.goto(link![0]); + await expect(page).toHaveURL(`${CONSOLE}/invitations`); + await expect(page.getByRole('button', { name: 'Join Acme' })).toBeVisible(); + expect(await workspaces(page)).toEqual([]); + await finished(owners, undefined); + await owners.close(); + }); +}); + +type Stub = { + /** Nobody signed in, somebody with a workspace, or somebody with a workspace and a site. */ + as: 'nobody' | 'named' | 'sited'; + role?: 'owner' | 'member'; + /** What the worker answers when a workspace is made or a site is added. */ + refuses?: { workspace?: boolean | 'invalid-name'; site?: number; sites?: boolean; invitations?: 503 | 404 }; + locale?: 'en' | 'fr'; +}; + +const LONG_HOST = 'preview-branch-feature-very-long-name-1234.staging.acme.dev'; + +/** + * The sample words are as wide as the mockup's in the system font, so a line wraps where the + * mockup wraps it: « Havane » for the name of the workspace, `marianne@acme.dev` for the address. + */ +const EMAIL = 'marianne@acme.dev'; +const NAME = 'Havane'; + +async function stubWorker(page: Page, stub: Stub): Promise { + const asked: string[] = []; + const workspace = { id: 'w1', name: NAME, role: stub.role ?? 'owner' }; + const account = { id: 'a1', email: EMAIL, name: 'Marianne', locale: stub.locale ?? 'en' }; + const site = { id: 's1', origin: 'https://staging.acme.dev', visibility: 'members' }; + let made = stub.as !== 'nobody'; + await page.context().route(`${WORKER_ORIGIN}/**`, (route) => { + const request = route.request(); + const { pathname } = new URL(request.url()); + asked.push(`${request.method()} ${pathname}`); + const json = (body: unknown, status = 200) => route.fulfill({ status, json: body }); + if (pathname === '/auth/providers') return json({ betterAuth: { email: true, github: true, google: true } }); + if (pathname === '/console/me') { + if (stub.as === 'nobody') return json({ error: 'identity-required' }, 401); + + return json({ account, workspaces: made && stub.refuses?.workspace === undefined ? [workspace] : [] }); + } + if (pathname === '/console/auth/email') return json({ sent: true }); + if (pathname.endsWith('/list-user-invitations')) { + const refused = stub.refuses?.invitations; + + return refused === undefined ? json([]) : json({ error: 'unavailable' }, refused); + } + if (pathname === '/console/workspaces') { + if (stub.refuses?.workspace === 'invalid-name') return json({ error: 'invalid-name' }, 400); + if (stub.refuses?.workspace === true) return json({ error: 'unavailable' }, 503); + made = true; + + return json(workspace, 201); + } + if (pathname === '/console/workspaces/w1/sites') { + if (request.method() === 'GET') { + if (stub.refuses?.sites === true) return json({ error: 'unavailable' }, 503); + + return json({ sites: stub.as === 'sited' ? [site] : [] }); + } + if (stub.refuses?.site !== undefined) return json({ error: 'forbidden' }, stub.refuses.site); + const { url, visibility } = request.postDataJSON() as { url: string; visibility: string }; + + return json({ id: 's2', origin: new URL(url).origin, visibility }, 201); + } + + return route.fulfill({ status: 204 }); + }); + + return asked; +} + +type Box = { x: number; y: number; width: number; height: number }; + +/** The box of an element in the page, to a tenth of a pixel. */ +async function boxOf(locator: Locator): Promise { + const box = await locator.first().evaluate((element) => { + const rect = element.getBoundingClientRect(); + + return { x: rect.x + window.scrollX, y: rect.y + window.scrollY, width: rect.width, height: rect.height }; + }); + const round = (value: number) => Math.round(value * 10) / 10; + + return { x: round(box.x), y: round(box.y), width: round(box.width), height: round(box.height) }; +} + +/** + * The mockup is measured in the system font of macOS: a line of text with no line height of its own + * is 1 or 2 px taller in the system font of Linux, which the CI draws, and a word is not as wide. + * So the boxes are compared where the font is the mockup's. Everything else is measured everywhere. + */ +const FONT_OF_THE_MOCKUP = process.platform === 'darwin'; + +/** Each side of `built` is within `tolerance` pixels of the mockup's. */ +function expectNear(built: Box, mockup: Box, name: string, tolerance = 2): void { + if (!FONT_OF_THE_MOCKUP) return; + for (const side of ['x', 'y', 'width', 'height'] as const) { + expect + .soft(Math.abs(built[side] - mockup[side]), `${name}: ${side} is ${built[side]}, the mockup has ${mockup[side]}`) + .toBeLessThanOrEqual(tolerance); + } +} + +const sideScroll = (page: Page) => + page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + +const violations = async (page: Page): Promise => + (await new AxeBuilder({ page }).analyze()).violations.map( + (violation) => `${violation.id}: ${violation.nodes[0]?.target.join(' ')}`, + ); + +test.describe('the states of the setup, against the mockup (FRU-239)', () => { + // The boxes of the mockup at 1440 × 900, read in its `measures.json`. Tolerance: 2 px. + test('« Your account », to do: the boxes of the mockup at 1440 px', async ({ page }) => { + await stubWorker(page, { as: 'nobody' }); + await page.goto(`${CONSOLE}/setup`); + await expect(page.getByRole('heading', { name: `${CURRENT}Your account` })).toBeVisible(); + + expectNear(await boxOf(page.locator('header')), { x: 0, y: 0, width: 1440, height: 65 }, 'header'); + expectNear( + await boxOf(page.getByRole('img', { name: 'Fruitback' })), + { x: 80, y: 21, width: 113, height: 22 }, + 'logo', + ); + const guide = page.getByRole('link', { name: 'Self-hosting guide' }); + expectNear(await boxOf(guide), { x: 1241, y: 24, width: 118.8, height: 17 }, 'guide'); + await expect(guide).toHaveAttribute('href', 'https://fruitback.com/self-hosting.html'); + expectNear( + await boxOf(page.getByRole('heading', { level: 1 })), + { x: 112, y: 113, width: 400.2, height: 34.5 }, + 'title', + ); + expectNear( + await boxOf(page.getByRole('heading', { name: `${CURRENT}Your account` })), + { x: 160, y: 240, width: 352.2, height: 26 }, + 'step 1', + ); + expectNear( + await boxOf(page.getByRole('textbox', { name: 'Work email' })), + { x: 160, y: 361, width: 352.2, height: 44 }, + 'field', + ); + expectNear( + await boxOf(page.getByRole('button', { name: 'Email me a link' })), + { x: 160, y: 415, width: 142.5, height: 44 }, + 'main button', + ); + expectNear( + await boxOf(page.getByRole('button', { name: 'Continue with Google' })), + { x: 160, y: 562, width: 352.2, height: 44 }, + 'Google', + ); + expectNear( + await boxOf(page.getByRole('button', { name: 'Continue with GitHub' })), + { x: 160, y: 616, width: 352.2, height: 44 }, + 'GitHub', + ); + expectNear( + await boxOf(page.getByRole('heading', { name: 'Your site', exact: true })), + { x: 160, y: 692, width: 352.2, height: 26 }, + 'step 2', + ); + expectNear( + await boxOf(page.getByRole('heading', { name: 'Put it on the site', exact: true })), + { x: 160, y: 773, width: 352.2, height: 26 }, + 'step 3', + ); + const preview = page.getByRole('region', { name: 'Preview of your site' }); + expectNear(await boxOf(preview), { x: 568, y: 113, width: 759.8, height: 698.5 }, 'preview'); + expectNear(await boxOf(preview.locator('svg')), { x: 920, y: 404, width: 56, height: 68 }, 'drop of the preview'); + await expect(preview.getByText('your site', { exact: true })).toBeVisible(); + await expect(page).toHaveTitle('Set up Fruitback'); + + // The steps are a list in order, and the current one is said as such to a screen reader only. + await expect(page.locator('main ol > li')).toHaveCount(3); + await expect(page.locator('main ol > li[aria-current="step"]')).toHaveCount(1); + const hidden = await page.getByText('Current step:').first().boundingBox(); + expect.soft(hidden!.width, 'the words « Current step: » are not drawn').toBeLessThanOrEqual(1); + expect(await violations(page)).toEqual([]); + }); + + test('« Check your inbox »: the boxes of the mockup at 1440 px', async ({ page }) => { + // The clock is set, so the button reads « Send again in 0:41 », as on the mockup. + await page.clock.install(); + await stubWorker(page, { as: 'nobody' }); + await page.goto(`${CONSOLE}/setup`); + await page.getByRole('textbox', { name: 'Work email' }).fill(EMAIL); + await page.getByRole('button', { name: 'Email me a link' }).click(); + const sent = page.getByRole('status'); + await expect(sent).toContainText('Check your inbox'); + await page.clock.runFor(19_000); + + expectNear(await boxOf(sent), { x: 160, y: 278, width: 352.2, height: 283.5 }, 'callout'); + const again = page.getByRole('button', { name: 'Send again in 0:41' }); + expectNear(await boxOf(again), { x: 181, y: 455, width: 157.5, height: 40 }, '« Send again »'); + await expect(again).toBeDisabled(); + expectNear( + await boxOf(page.getByRole('button', { name: 'Use another address' })), + { x: 181, y: 505, width: 139.9, height: 37 }, + '« Use another address »', + ); + expectNear( + await boxOf(page.getByRole('heading', { name: 'Your site', exact: true })), + { x: 160, y: 593, width: 352.2, height: 26 }, + 'step 2', + ); + expectNear( + await boxOf(page.getByRole('heading', { name: 'Put it on the site', exact: true })), + { x: 160, y: 674, width: 352.2, height: 26 }, + 'step 3', + ); + expect(await violations(page)).toEqual([]); + + // « Use another address » gives the form back, with the address in it. + await page.getByRole('button', { name: 'Use another address' }).click(); + await expect(page.getByRole('textbox', { name: 'Work email' })).toHaveValue(EMAIL); + }); + + test('« Your site », to do: the boxes of the mockup at 1440 px', async ({ page }) => { + await stubWorker(page, { as: 'named' }); + await page.goto(`${CONSOLE}/setup`); + await expect(page.getByRole('heading', { name: `${CURRENT}Your site` })).toBeVisible(); + + expectNear( + await boxOf(page.getByRole('heading', { name: 'Your account', exact: true })), + { x: 160, y: 240, width: 352.2, height: 26 }, + 'step 1', + ); + expectNear( + await boxOf(page.locator('main ol > li').first().locator('.kit-step__mark svg')), + { x: 122, y: 247, width: 13, height: 13 }, + 'check of step 1', + ); + // « Rename » is where the mockup has it, 6 px higher and 12 px taller: its target (A23). + const rename = await boxOf(page.getByRole('button', { name: 'Rename' })); + expectNear( + { ...rename, y: rename.y + 6, height: rename.height - 12 }, + { x: 314, y: 299, width: 54.4, height: 21 }, + '« Rename »', + ); + expect.soft(rename.height, 'the target of « Rename »').toBeGreaterThanOrEqual(24); + expectNear( + await boxOf(page.getByRole('heading', { name: `${CURRENT}Your site` })), + { x: 160, y: 353, width: 352.2, height: 26 }, + 'step 2', + ); + expectNear( + await boxOf(page.getByRole('textbox', { name: 'Site address' })), + { x: 160, y: 413, width: 352.2, height: 44 }, + 'field', + ); + // Under the field the mockup has a hint of two lines, and its second sentence waits for a + // decision on the previews (FRU-103): one line here, so what follows is 19.5 px higher. + const hint = 19.5; + const cards = page.locator('.kit-pick__card'); + expectNear(await boxOf(cards.nth(0)), { x: 160, y: 546 - hint, width: 352.2, height: 90 }, 'card, members'); + expectNear(await boxOf(cards.nth(1)), { x: 160, y: 644 - hint, width: 352.2, height: 70.5 }, 'card, everyone'); + expectNear( + await boxOf(page.getByRole('button', { name: 'Add site' })), + { x: 160, y: 734 - hint, width: 98.6, height: 44 }, + '« Add site »', + ); + expectNear( + await boxOf(page.getByRole('heading', { name: 'Put it on the site', exact: true })), + { x: 160, y: 810 - hint, width: 352.2, height: 26 }, + 'step 3', + ); + await expect(page.getByText('Any page of it will do.')).toBeVisible(); + await expect(page.getByText(/preview\.acme\.dev/)).toHaveCount(0); + + // The question is the name of its group, and the first answer is chosen. + const group = page.getByRole('radiogroup', { name: 'Who sees the notes?' }); + await expect(group.getByRole('radio')).toHaveCount(2); + await expect(group.getByRole('radio', { name: /^Members of Havane/ })).toBeChecked(); + expect(await violations(page)).toEqual([]); + }); + + test('the keyboard walks the setup in the order it reads', async ({ page }) => { + await stubWorker(page, { as: 'nobody' }); + await page.goto(`${CONSOLE}/setup`); + await expect(page.getByRole('textbox', { name: 'Work email' })).toBeVisible(); + const order = [ + page.getByRole('link', { name: 'Fruitback' }), + page.getByRole('link', { name: 'Self-hosting guide' }), + page.getByRole('textbox', { name: 'Work email' }), + page.getByRole('button', { name: 'Email me a link' }), + page.getByRole('button', { name: 'Continue with Google' }), + page.getByRole('button', { name: 'Continue with GitHub' }), + ]; + for (const control of order) { + await page.keyboard.press('Tab'); + await expect(control).toBeFocused(); + } + // The ring of the mockup on the control the keyboard is on. + const focused = page.getByRole('button', { name: 'Continue with GitHub' }); + expect(await focused.evaluate((element) => getComputedStyle(element).outline)).toBe('rgb(221, 44, 39) solid 2px'); + }); + + test('a refusal keeps what is typed, at each step', async ({ page }) => { + // The site: the worker refuses, the address stays, and the sentence says why. + const asked = await stubWorker(page, { as: 'named', refuses: { site: 403 } }); + await page.goto(`${CONSOLE}/setup`); + const address = page.getByRole('textbox', { name: 'Site address' }); + await address.fill('staging.acme.dev'); + await page.getByText('Everyone who visits', { exact: true }).click(); + await page.getByRole('button', { name: 'Add site' }).click(); + await expect(page.getByRole('alert')).toHaveText('Only an owner or an admin of the workspace adds a site.'); + await expect(address).toHaveValue('staging.acme.dev'); + await expect(page.getByRole('radio', { name: /^Everyone who visits/ })).toBeChecked(); + await expect(page.getByRole('heading', { name: `${CURRENT}Your site` })).toBeVisible(); + + // What is not an address: the sentence of the console, and nothing asked of the worker. + const before = asked.filter((each) => each.startsWith('POST')).length; + await address.fill('pas une adresse'); + await page.getByRole('button', { name: 'Add site' }).click(); + await expect(page.getByRole('alert')).toHaveText('Paste the full address of the site, starting with https://.'); + await expect(address).toHaveValue('pas une adresse'); + expect(asked.filter((each) => each.startsWith('POST')).length).toBe(before); + }); + + test('no workspace is made while the invitations cannot be read, and one is once they can', async ({ page }) => { + let refused: 503 | undefined = 503; + const asked = await stubWorker(page, { + as: 'named', + refuses: { + workspace: true, + get invitations() { + return refused; + }, + }, + }); + await page.goto(`${CONSOLE}/setup`); + + await expect(page.getByRole('alert')).toHaveText('Fruitback did not answer. Try again.'); + // The first step is still the one to do, and the second is to come: a screen reader says so. + await expect(page.getByRole('heading', { name: `${CURRENT}Your account` })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Your site', exact: true })).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Workspace name' })).toHaveCount(0); + expect(asked.filter((each) => each === 'POST /console/workspaces')).toEqual([]); + + // The control: the same person, once the worker answers, gets a workspace made. + refused = undefined; + await page.getByRole('button', { name: 'Try again' }).click(); + await expect.poll(() => asked.filter((each) => each === 'POST /console/workspaces').length).toBe(1); + }); + + test('a worker with no invitations at all (404) does not stop the setup', async ({ page }) => { + const asked = await stubWorker(page, { as: 'named', refuses: { workspace: true, invitations: 404 } }); + await page.goto(`${CONSOLE}/setup`); + + await expect.poll(() => asked.filter((each) => each === 'POST /console/workspaces').length).toBe(1); + }); + + test('the form of a workspace that was not made opens on what the worker said of the try', async ({ page }) => { + await stubWorker(page, { as: 'named', refuses: { workspace: 'invalid-name' } }); + await page.goto(`${CONSOLE}/setup`); + + await expect(page.getByRole('alert')).toHaveText('A workspace needs a name of one line, up to 80 characters.'); + await expect(page.getByRole('textbox', { name: 'Workspace name' })).toHaveValue('Acme'); + }); + + test('a workspace the worker did not make is asked for again, with the name in a field', async ({ page }) => { + const asked = await stubWorker(page, { as: 'named', refuses: { workspace: true } }); + await page.goto(`${CONSOLE}/setup`); + + // Signed in, and no workspace: the first step is still the current one, and says what failed: the + // worker did not answer the try that was made for the person. + await expect(page.getByRole('heading', { name: `${CURRENT}Your account` })).toBeVisible(); + await expect(page.getByRole('alert')).toHaveText('Fruitback did not answer. Try again.'); + const name = page.getByRole('textbox', { name: 'Workspace name' }); + // The name the workspace would have taken: the domain of the address. + await expect(name).toHaveValue('Acme'); + await name.fill('Acme Studio'); + await page.getByRole('button', { name: 'Try again' }).click(); + await expect.poll(() => asked.filter((each) => each === 'POST /console/workspaces').length).toBe(2); + await expect(name).toHaveValue('Acme Studio'); + }); + + test('sites that could not be read are not « no site »: the step says so, and asks nothing again', async ({ + page, + }) => { + await stubWorker(page, { as: 'named', refuses: { sites: true } }); + await page.goto(`${CONSOLE}/setup`); + + await expect(page.getByRole('alert')).toHaveText('Fruitback did not answer. Your sites are not lost.'); + await expect(page.getByRole('textbox', { name: 'Site address' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible(); + }); + + test('somebody who does not manage the workspace sees no « Rename » and no « Change »', async ({ page }) => { + await stubWorker(page, { as: 'sited', role: 'member' }); + await page.goto(`${CONSOLE}/setup`); + + await expect(page.getByRole('heading', { name: `${CURRENT}Put it on the site` })).toBeVisible(); + await expect(page.getByText('Workspace Havane')).toBeVisible(); + await expect(page.getByRole('button', { name: /Rename|Change/ })).toHaveCount(0); + }); + + test('a member of a workspace with no site is sent to its Sites screen, with no form that cannot work', async ({ + page, + }) => { + const asked = await stubWorker(page, { as: 'named', role: 'member' }); + await page.goto(`${CONSOLE}/setup`); + + await expect(page).toHaveURL(`${CONSOLE}/w/w1/sites`); + await expect(page.getByRole('textbox', { name: 'Site address' })).toHaveCount(0); + expect(asked.filter((each) => each === 'POST /console/workspaces/w1/sites')).toEqual([]); + // The control: the owner of the same workspace stays on the setup, at the form. + await page.context().unrouteAll({ behavior: 'ignoreErrors' }); + await stubWorker(page, { as: 'named', role: 'owner' }); + await page.goto(`${CONSOLE}/setup`); + await expect(page.getByRole('textbox', { name: 'Site address' })).toBeVisible(); + await expect(page).toHaveURL(`${CONSOLE}/setup`); + }); + + for (const locale of ['en', 'fr'] as const) { + test(`no state of the setup scrolls sideways on a phone, in ${locale}`, async ({ browser }) => { + const context = await browser.newContext({ + locale: locale === 'en' ? 'en-US' : 'fr-FR', + viewport: { width: 390, height: 844 }, + }); + const page = await context.newPage(); + const check = async (state: string) => { + for (const width of [390, 320]) { + await page.setViewportSize({ width, height: 844 }); + expect.soft(await sideScroll(page), `${state} scrolls sideways at ${width} px`).toBe(0); + } + await page.setViewportSize({ width: 390, height: 844 }); + }; + + // To do, then the link on its way. + await stubWorker(page, { as: 'nobody', locale }); + await page.goto(`${CONSOLE}/setup`); + const email = page.getByRole('textbox', { name: locale === 'en' ? 'Work email' : 'E-mail professionnel' }); + await expect(email).toBeVisible(); + await check('step 1'); + // The two columns are one: the preview is under the steps, as on the mockup at 390 px. + const steps = await boxOf(page.locator('main ol')); + const preview = await boxOf(page.locator('main > section').nth(1)); + expect.soft(preview.y, 'the preview is under the steps').toBeGreaterThan(steps.y + steps.height); + // In French the sentence of the header takes two lines there, and all that follows is lower. + if (locale === 'en') { + expectNear( + await boxOf(page.getByRole('heading', { level: 1 })), + { x: 16, y: 113, width: 358, height: 34.5 }, + 'title', + 4, + ); + } + await email.fill(`someone.with.a.long.name@${LONG_HOST}`); + await page + .getByRole('button', { name: locale === 'en' ? 'Email me a link' : 'Recevoir un lien par e-mail' }) + .click(); + await expect(page.getByRole('status')).toBeVisible(); + await check('the link on its way, to a long address'); + await context.unrouteAll({ behavior: 'ignoreErrors' }); + + // The site, then the last step with a long host. + await stubWorker(page, { as: 'named', locale }); + await page.goto(`${CONSOLE}/setup`); + const address = page.getByRole('textbox', { name: locale === 'en' ? 'Site address' : 'Adresse du site' }); + await expect(address).toBeVisible(); + await check('step 2'); + await address.fill(LONG_HOST); + await page.getByRole('button', { name: locale === 'en' ? 'Add site' : 'Ajouter le site' }).click(); + await expect(page.locator('main ol > li[aria-current="step"]')).toContainText('3'); + await check('step 3, with a long host'); + // The long host reads in full: it takes the lines it needs. + const host = page.getByText(LONG_HOST, { exact: true }).first(); + expect.soft(await host.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true); + expect(await violations(page)).toEqual([]); + await context.close(); + }); + } + + test('the setup is in French for a French browser', async ({ browser }) => { + const context = await browser.newContext({ locale: 'fr-FR' }); + const page = await context.newPage(); + await stubWorker(page, { as: 'named', locale: 'fr' }); + await page.goto(`${CONSOLE}/setup`); + + await expect(page.getByRole('heading', { name: 'Mettre en place Fruitback' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Étape en cours : Votre site' })).toBeVisible(); + await expect(page.getByText('Connecté en tant que marianne@acme.dev')).toBeVisible(); + await expect(page.getByRole('radio', { name: /^Les membres de Havane/ })).toBeChecked(); + await expect(page).toHaveTitle('Mettre en place Fruitback'); + await context.close(); + }); +}); diff --git a/e2e/console-sign-in.spec.ts b/e2e/console-sign-in.spec.ts index 6f159d3d..d531a02c 100644 --- a/e2e/console-sign-in.spec.ts +++ b/e2e/console-sign-in.spec.ts @@ -46,10 +46,10 @@ test.describe('the sign-in page, against a worker with accounts (FRU-235)', () = const other = await context.newPage(); await other.goto(link); - // The first tab is in the console, with no press of anything: a new account has no workspace - // yet, so the front door leads it to the setup, signed in. + // The first tab is in the console, with no press of anything: a new account has no site yet, + // so the front door leads it to the setup, signed in, at its second step. await expect(page).toHaveURL(`${CONSOLE}/setup`); - await expect(page.getByRole('heading', { name: 'Name your workspace' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Current step: Your site' })).toBeVisible(); // The tab of the link says so, and stays. await expect(other.getByRole('heading', { name: 'You are signed in' })).toBeVisible(); await expect(other.getByRole('status')).toHaveText( @@ -69,27 +69,8 @@ test.describe('the sign-in page, against a worker with accounts (FRU-235)', () = await expect(other).toHaveURL(`${CONSOLE}/setup`); }); - test('the setup goes on to its next step by itself when its link is opened in another tab', async ({ - page, - context, - }) => { - // The link is asked for from the setup, which is still the way in of somebody new. - await page.goto(`${CONSOLE}/setup`); - await page.getByRole('textbox', { name: 'Work email' }).fill('ana@acme.dev'); - await page.getByRole('button', { name: 'Continue', exact: true }).click(); - await expect(page.getByRole('heading', { name: INBOX })).toBeVisible(); - await expect.poll(async () => (await worker.sent()).length).toBe(1); - - const other = await context.newPage(); - await other.goto(await linkIn(worker)); - - // The setup read the worker again: a resolution is kept only while it is in flight, so the - // answer of the first visit, « nobody signed in », is not the one it shows. - await expect(page.getByRole('heading', { name: 'Name your workspace' })).toBeVisible(); - await expect(page.getByRole('textbox', { name: 'Work email' })).toHaveCount(0); - expect(page.url()).toBe(`${CONSOLE}/setup`); - await expect(other.getByRole('heading', { name: 'You are signed in' })).toBeVisible(); - }); + // The setup that goes on by itself when its link is opened in another tab is measured with the + // setup, in `console-setup.spec.ts` (FRU-239). test('the tab of the link is the console when no tab waits for it', async ({ page }) => { const link = await askForLink(page, worker, 'ana@acme.dev'); @@ -98,7 +79,7 @@ test.describe('the sign-in page, against a worker with accounts (FRU-235)', () = await page.goto(link); await expect(page).toHaveURL(`${CONSOLE}/setup`); - await expect(page.getByRole('heading', { name: 'Name your workspace' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Current step: Your site' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'You are signed in' })).toHaveCount(0); }); diff --git a/e2e/console-site-address.spec.ts b/e2e/console-site-address.spec.ts index 3a831911..6be55a24 100644 --- a/e2e/console-site-address.spec.ts +++ b/e2e/console-site-address.spec.ts @@ -3,7 +3,7 @@ import { WORKER_ORIGIN } from './pin.ts'; /** * The address of a site, typed in the console (FRU-167): on the Sites screen, in the card of a - * workspace with no site (FRU-243), and at step 3 of the set-up. A host with no scheme is read as `https://`, and what is not an address gets the sentence + * workspace with no site (FRU-243), and at the second step of the set-up (FRU-239). A host with no scheme is read as `https://`, and what is not an address gets the sentence * of the console instead of the bubble of the browser. * * The worker is answered by stubs, which keep what the console sent. @@ -40,7 +40,7 @@ async function stubWorker(page: Page): Promise { const screens = [ { name: 'the Sites screen', path: '/w/w1/sites', submit: 'Add site' }, - { name: 'step 3 of the set-up', path: '/setup?step=site', submit: 'Continue' }, + { name: 'the second step of the set-up', path: '/setup', submit: 'Add site' }, ] as const; for (const screen of screens) {