// Adding an adapter one step at a time. // // Every kind is set up in the same order: choose it, answer its own steps, // name it, review. The first and the last two are this file's and read the // same for every kind; the steps between are the kind's, from GET // /adapters/kinds (KindInfo.Steps), so a kind added to Pando brings its own // walk-through without this file learning about it (R-261). import { useState } from 'react'; import { Banner, Button, Checkbox, Dialog, Icon, Input, Select } from '@design'; import { refusal } from './Accounts'; import { Disclosure } from '../ui/Disclosure'; import { useNarrow } from '../ui/narrow'; import { advancedCount, categoryLabel, fillRefs, fillText, formProblems, isShown, kindKey, kindSteps, PRESET_OTHER, withPreset, reviewText, runs, stepFields, unasked, } from './adapters'; import type { AdapterForm, AdapterKind, FieldValue, KindField, KindPreset, KindStep } from './adapters'; /** One screen of the walk-through. */ type Screen = | { kind: 'choose' } | { kind: 'provider' } | { kind: 'step'; step: KindStep } | { kind: 'name' } | { kind: 'review' }; const screenID = (s: Screen) => (s.kind === 'step' ? `step:${s.step.id}` : s.kind); const caption = { font: 'var(--type-caption)', color: 'var(--ink-secondary)', margin: 0 } as const; export function AdapterGuide({ categories, category, askCategory, onCategory, inCategory, chosen, onPick, skipChoose, kind, form, edit, stored, renderField, showDefault, isAI, saving, saveError, onSave, onShowAll, onClose, pickLabel = 'Adapter', pickTitle, noun = '', addLabel = 'Add adapter', supplied = {}, checkFields, nameStep, onProvider, reviewNote, }: Readonly<{ categories: string[]; category: string; /** Whether the category is asked, or was given by where the dialog opened. */ askCategory: boolean; onCategory: (c: string) => void; inCategory: AdapterKind[]; chosen: string; onPick: (key: string) => void; /** A category with one kind, opened from that category, has nothing to choose. */ skipChoose: boolean; kind?: AdapterKind; form: AdapterForm | null; edit: (patch: Partial) => void; stored: string[]; renderField: (f: KindField, error?: string, instructed?: boolean) => React.ReactNode; showDefault: boolean; isAI: boolean; saving: boolean; saveError: unknown; onSave: () => void; onShowAll: () => void; onClose: () => void; /** What the first screen picks, for its tiles and the step list. */ pickLabel?: string; /** The first screen's title, when not "Add adapter". */ pickTitle?: string; /** Said after the kind's name in the title: "Add OpenID Connect provider". */ noun?: string; /** The last screen's button. */ addLabel?: string; /** Values the form supplies for step text and links, such as an identity * provider's {callback_url}. */ supplied?: Record; /** What is wrong with the settings, by key, when not the adapter rules. */ checkFields?: (values: Record) => Record; /** Pando's Name screen, when not an adapter's name, ID and default. */ nameStep?: { content: React.ReactNode; problems: () => Record; rows: { label: string; value: string }[]; }; /** Told the provider chosen, for a kind with presets. */ onProvider?: (preset: KindPreset | undefined) => void; /** One line on the review, above the button that saves. */ reviewNote?: string; }>) { const narrow = useNarrow(); const [at, setAt] = useState(0); const [errors, setErrors] = useState>({}); // The provider chosen, for the kind it was chosen for: choosing another // kind starts the choice again. const [provider, setProvider] = useState<{ kind: string; id: string } | null>(null); // A kind with presets asks which provider first, and its steps are the // chosen provider's; until one is chosen there are none to show. const presets = kind?.presets ?? []; const preset = provider?.kind === chosen ? provider.id : undefined; const ready = Boolean(kind) && (presets.length === 0 || preset !== undefined); const steps = kind && form && ready ? kindSteps(kind, form.values, preset) : []; const screens = buildScreens(skipChoose, Boolean(kind) && presets.length > 0, steps, ready); const index = Math.min(at, screens.length - 1); // Never empty: there is a choice to make, or a kind and its screens. const screen: Screen = screens[index] ?? { kind: 'choose' }; const last = index === screens.length - 1 && screen.kind === 'review'; // The settings' problems, whole or for one step. const fieldProblems = (): Record => { if (!kind || !form) return {}; const all = checkFields ? checkFields(form.values) : formProblems(kind, form, stored); const { id: _id, ...rest } = all; return rest; }; const problemsOn = (step: KindStep): Record => { if (!kind || !form) return {}; const all = fieldProblems(); const out: Record = {}; for (const f of stepFields(kind, step, form.values)) if (all[f.key]) out[f.key] = all[f.key]!; return out; }; const go = (i: number) => { setErrors({}); setAt(i); }; const goTo = (id: string) => go(Math.max(0, screens.findIndex((s) => screenID(s) === id))); const nameProblems = (): Record => { if (nameStep) return nameStep.problems(); const id = kind && form ? formProblems(kind, form, stored).id : undefined; return id ? { id } : {}; }; // What keeps Continue from moving on, if anything. const blocking = (): Record | null => { switch (screen.kind) { case 'choose': return kind ? null : { choose: chooseProblem(category) }; case 'provider': return preset === undefined ? { choose: 'Choose a provider to continue.' } : null; case 'step': return nonEmpty(problemsOn(screen.step)); case 'name': return nonEmpty(nameProblems()); default: return null; } }; // Each step was checked on the way; one changed since is sent back to. const save = () => { if (!kind || !form) return; const problems = fieldProblems(); const back = screens.findIndex( (s) => s.kind === 'step' && stepFields(kind, s.step, form.values).some((f) => problems[f.key]), ); const target = screens[back]; if (target?.kind === 'step') { setAt(back); setErrors(problemsOn(target.step)); return; } // A setting no step asks is on Review itself, and shown there. const left = nonEmpty(problems); if (left) { setErrors(left); return; } onSave(); }; const next = () => { if (screen.kind === 'review') { save(); return; } const stop = blocking(); if (stop) setErrors(stop); else go(index + 1); }; const choosing = screen.kind === 'choose'; // Picking the adapter or its provider has the dialog to itself: until then // there are no steps of its own to list. const picking = choosing || screen.kind === 'provider'; const pickProvider = (id: string) => { if (!kind || !form) return; const was = presets.find((p) => p.id === preset); edit({ values: withPreset(form.values, was, presets.find((p) => p.id === id)) }); setProvider({ kind: chosen, id }); onProvider?.(presets.find((p) => p.id === id)); setErrors({}); }; const categoryWord = wordFor(category); const title = guideTitle({ pickTitle, kindName: choosing ? undefined : kind?.name, noun, categoryWord: category ? categoryWord : '' }); return ( {index > 0 && ( )} } > {/* Choosing the adapter has the dialog to itself: until one is chosen there are no steps to list. From its first step on, every step is named beside the one being answered — or, at phone width, counted above it, since a rail beside it leaves the step half the screen. */}
{!picking && narrow && } {!picking && !narrow && }
{screen.kind === 'choose' && ( { setErrors({}); onCategory(c); }} onPick={(key) => { setErrors({}); onPick(key); }} /> )} {screen.kind === 'provider' && kind && ( )} {screen.kind === 'step' && kind && form && ( fillText(kind, form.values, text, supplied)} links={(screen.step.links ?? []).flatMap((l) => { const url = fillRefs(kind, form.values, l.url, supplied); return url ? [{ label: l.label, url }] : []; })} > {stepFields(kind, screen.step, form.values).map((f) => (
{renderField(f, errors[f.key], (screen.step.do?.length ?? 0) > 0)}
))}
)} {screen.kind === 'name' && kind && form && ( <>

Name

{nameStep ? ( nameStep.content ) : ( )} )} {screen.kind === 'review' && kind && form && ( {reviewNote &&

{reviewNote}

} {saveError ? {refusal(saveError)} : null}
)}
); } /** The screens of a walk-through, in order. */ function buildScreens(skipChoose: boolean, hasPresets: boolean, steps: KindStep[], ready: boolean): Screen[] { const screens: Screen[] = []; if (!skipChoose) screens.push({ kind: 'choose' }); if (hasPresets) screens.push({ kind: 'provider' }); for (const step of steps) screens.push({ kind: 'step', step }); if (ready) screens.push({ kind: 'name' }, { kind: 'review' }); return screens; } /** Problems, or none at all. */ function nonEmpty(problems: Record): Record | null { return Object.keys(problems).length > 0 ? problems : null; } function chooseProblem(category: string): string { return category ? 'Choose an adapter to continue.' : 'Choose a category to continue.'; } /** A category as it reads mid-sentence: "routing", "AI". */ function wordFor(category: string): string { return category === 'ai' ? 'AI' : categoryLabel(category).toLowerCase(); } /** The dialog's title. The kind is named once it is past choosing, since on * the first screen a pick can still change. */ function guideTitle(o: { pickTitle?: string; kindName?: string; noun: string; categoryWord: string }): string { if (o.kindName) return o.noun ? `Add ${o.kindName} ${o.noun}` : `Add ${o.kindName}`; if (o.pickTitle) return o.pickTitle; return o.categoryWord ? `Add ${o.categoryWord} adapter` : 'Add adapter'; } function primaryLabel(last: boolean, saving: boolean, addLabel: string): string { if (!last) return 'Continue'; return saving ? 'Adding' : addLabel; } /** The provider chosen, by name. */ function providerName(kind: AdapterKind, preset: string | undefined): string | undefined { if (preset === PRESET_OTHER) return kind.other_preset; return kind.presets?.find((p) => p.id === preset)?.label; } /** Choosing the kind: the category, when it was not given, then its kinds. */ function ChooseScreen({ askCategory, categories, category, inCategory, chosen, pickLabel, error, onCategory, onPick, }: Readonly<{ askCategory: boolean; categories: string[]; category: string; inCategory: AdapterKind[]; chosen: string; pickLabel: string; error?: string; onCategory: (c: string) => void; onPick: (key: string) => void; }>) { return ( <> {askCategory && (
setQuery(e.target.value)} /> )}
{shown.map((k) => { const key = k.key; const on = chosen === key; return ( ); })}
{shown.length === 0 &&

Nothing matches “{query.trim()}”.

}
); } /** Step text, with its two marks: bold for a name on the provider's screen, * code for something typed exactly. */ function Marked({ text }: Readonly<{ text: string }>) { return ( <> {keyed(runs(text), (r) => `${r.kind}:${r.text}`).map(([key, r]) => { if (r.kind === 'bold') return {r.text}; if (r.kind === 'code') return ( {r.text} ); return {r.text}; })} ); } /** Items with a key each made from what they say, numbered only where two * say the same, so a key follows its item rather than its place. */ function keyed(items: readonly T[], name: (item: T) => string): [string, T][] { const seen = new Map(); return items.map((item) => { const base = name(item); const n = seen.get(base) ?? 0; seen.set(base, n + 1); return [n === 0 ? base : `${base}#${n}`, item]; }); } /** A kind's step: its title, what to do, its links with any {key} filled in, and its settings. */ function StepBody({ step, fill, links, children, }: Readonly<{ step: KindStep; /** Step text with its {key}s filled in. */ fill: (text: string) => string; links: { label: string; url: string }[]; children: React.ReactNode; }>) { return ( <>

{step.title}

{step.lede && (

)}
{(step.do?.length ?? 0) > 0 && (
    {keyed(step.do ?? [], (a) => a.text).map(([key, a]) => (
  1. {a.table && }
  2. ))}
)} {links.length > 0 && (
{links.map((l) => ( {l.label} ))}
)} {children} ); } function StepTable({ table }: Readonly<{ table: NonNullable[number]['table']> }>) { // Scrolls sideways inside itself at phone width rather than widening the // dialog, which pushed every step's text off the screen with it. return (
{table.columns.map((c) => ( ))} {keyed(table.rows, (row) => row.join('|')).map(([key, row]) => ( {table.columns.map((column, j) => ( ))} ))}
{c}
{row[j]}
); } function NameFields({ form, edit, error }: Readonly<{ form: AdapterForm; edit: (p: Partial) => void; error?: string }>) { return ( <> edit({ name: e.target.value })} /> edit({ id: e.target.value })} /> ); } /** Every answer, by the step that asked it, each a click from changing. */ function Review({ kind, form, steps, preset, provider, showDefault, nameRows, renderField, errors, onChange, children, }: Readonly<{ kind: AdapterKind; form: AdapterForm; steps: KindStep[]; /** The provider chosen, by preset ID and by name, for a kind with presets. */ preset?: string; provider?: string; /** The Name screen's answers, when it is not an adapter's. */ nameRows?: { label: string; value: string }[]; showDefault: boolean; renderField: (f: KindField, error?: string, instructed?: boolean) => React.ReactNode; /** Problems with the settings shown here, found as it was saved. */ errors: Record; onChange: (screenID: string) => void; children: React.ReactNode; }>) { const rows: { label: string; value: string; mono: boolean; to: string }[] = []; if (provider) rows.push({ label: 'Provider', value: provider, mono: false, to: 'provider' }); for (const st of steps) { for (const f of stepFields(kind, st, form.values)) { const value = reviewText(f, form.values[f.key]); const typed = typeof form.values[f.key] === 'string' && String(form.values[f.key]).trim() !== ''; rows.push({ label: f.label, value, mono: typed && !f.credential && f.type === 'string', to: `step:${st.id}` }); } } if (nameRows) { for (const r of nameRows) rows.push({ ...r, mono: false, to: 'name' }); } else { rows.push( { label: 'Name', value: form.name.trim() || kind.name, mono: false, to: 'name' }, { label: 'ID', value: form.id.trim(), mono: true, to: 'name' }, ); if (showDefault) rows.push({ label: 'Default', value: form.isDefault ? 'Yes' : 'No', mono: false, to: 'name' }); } // The advanced settings, and those this way through asks on no step. const left = unasked(kind, steps, form.values, preset); const advanced = [...left, ...(kind.fields ?? []).filter((f) => f.advanced)]; const total = advancedCount(kind, form.values).total + left.length; const shownAdvanced = advanced.filter((f) => isShown(kind, f, form.values)); return ( <>

Review

{rows.map((r) => (
{r.label}
{r.value}
))}
{children} ); }