feat(connectors): animated account-section fold with a new-account summary (#15875)

This commit is contained in:
Raunak Bhagat authored and GitHub committed 2026-10-12 09:06:37 +00:00
1 parent b2edc253e6
commit e04e8b2df8
13 files changed
+305 -63

No files matched your search

+13 -1
View File
@@ -2690,7 +2690,19 @@
"label": "إعادة المحاولة"
},
"savedAccounts": {
"label": "{count, plural, one {حساب محفوظ واحد} other {# حسابات محفوظة}}"
"label": "{count, plural, one {حساب محفوظ واحد} other {# حسابات محفوظة}}",
"expandButton": {
"ariaLabel": "إظهار الحسابات المحفوظة"
},
"collapseButton": {
"ariaLabel": "إخفاء الحسابات المحفوظة"
}
},
"accountSummary": {
"newAccountSuffix": "(حساب جديد)",
"editButton": {
"label": "تعديل الحساب"
}
},
"timeout": {
"toast": "انتهت مهلة العملية بعد {seconds} ثانية. هل تتحقق من إعداداتك بحثًا عن أخطاء؟"
+13 -1
View File
@@ -2690,7 +2690,19 @@
"label": "Erneut versuchen"
},
"savedAccounts": {
"label": "{count, plural, one {# gespeichertes Konto} other {# gespeicherte Konten}}"
"label": "{count, plural, one {# gespeichertes Konto} other {# gespeicherte Konten}}",
"expandButton": {
"ariaLabel": "Gespeicherte Konten anzeigen"
},
"collapseButton": {
"ariaLabel": "Gespeicherte Konten ausblenden"
}
},
"accountSummary": {
"newAccountSuffix": "(Neues Konto)",
"editButton": {
"label": "Konto bearbeiten"
}
},
"timeout": {
"toast": "Zeitüberschreitung des Vorgangs nach {seconds} Sekunden. Prüfen Sie Ihre Konfiguration auf Fehler."
+13 -1
View File
@@ -2690,7 +2690,19 @@
"label": "Retry"
},
"savedAccounts": {
"label": "{count, plural, one {# Saved Account} other {# Saved Accounts}}"
"label": "{count, plural, one {# Saved Account} other {# Saved Accounts}}",
"expandButton": {
"ariaLabel": "Show saved accounts"
},
"collapseButton": {
"ariaLabel": "Hide saved accounts"
}
},
"accountSummary": {
"newAccountSuffix": "(New Account)",
"editButton": {
"label": "Edit Account"
}
},
"timeout": {
"toast": "Operation timed out after {seconds} seconds. Check your configuration for errors?"
+13 -1
View File
@@ -2690,7 +2690,19 @@
"label": "Reintentar"
},
"savedAccounts": {
"label": "{count, plural, one {# cuenta guardada} other {# cuentas guardadas}}"
"label": "{count, plural, one {# cuenta guardada} other {# cuentas guardadas}}",
"expandButton": {
"ariaLabel": "Mostrar cuentas guardadas"
},
"collapseButton": {
"ariaLabel": "Ocultar cuentas guardadas"
}
},
"accountSummary": {
"newAccountSuffix": "(Cuenta nueva)",
"editButton": {
"label": "Editar cuenta"
}
},
"timeout": {
"toast": "La operación agotó el tiempo de espera tras {seconds} segundos. ¿Quieres revisar tu configuración por si hay errores?"
+13 -1
View File
@@ -2690,7 +2690,19 @@
"label": "Réessayer"
},
"savedAccounts": {
"label": "{count, plural, one {# compte enregistré} other {# comptes enregistrés}}"
"label": "{count, plural, one {# compte enregistré} other {# comptes enregistrés}}",
"expandButton": {
"ariaLabel": "Afficher les comptes enregistrés"
},
"collapseButton": {
"ariaLabel": "Masquer les comptes enregistrés"
}
},
"accountSummary": {
"newAccountSuffix": "(Nouveau compte)",
"editButton": {
"label": "Modifier le compte"
}
},
"timeout": {
"toast": "L'opération a expiré après {seconds} secondes. Vérifiez si votre configuration contient des erreurs ?"
+13 -1
View File
@@ -2690,7 +2690,19 @@
"label": "再試行"
},
"savedAccounts": {
"label": "{count, plural, one {保存済みアカウント # 件} other {保存済みアカウント # 件}}"
"label": "{count, plural, one {保存済みアカウント # 件} other {保存済みアカウント # 件}}",
"expandButton": {
"ariaLabel": "保存済みアカウントを表示"
},
"collapseButton": {
"ariaLabel": "保存済みアカウントを非表示"
}
},
"accountSummary": {
"newAccountSuffix": "(新しいアカウント)",
"editButton": {
"label": "アカウントを編集"
}
},
"timeout": {
"toast": "{seconds} 秒後に操作がタイムアウトしました。設定に誤りがないか確認してください。"
+13 -1
View File
@@ -2690,7 +2690,19 @@
"label": "다시 시도"
},
"savedAccounts": {
"label": "{count, plural, one {저장된 계정 #개} other {저장된 계정 #개}}"
"label": "{count, plural, one {저장된 계정 #개} other {저장된 계정 #개}}",
"expandButton": {
"ariaLabel": "저장된 계정 표시"
},
"collapseButton": {
"ariaLabel": "저장된 계정 숨기기"
}
},
"accountSummary": {
"newAccountSuffix": "(새 계정)",
"editButton": {
"label": "계정 편집"
}
},
"timeout": {
"toast": "{seconds}초 후 작업 시간이 초과되었습니다. 설정에 오류가 있는지 확인하세요."
+13 -1
View File
@@ -2690,7 +2690,19 @@
"label": "Tentar novamente"
},
"savedAccounts": {
"label": "{count, plural, one {# conta salva} other {# contas salvas}}"
"label": "{count, plural, one {# conta salva} other {# contas salvas}}",
"expandButton": {
"ariaLabel": "Mostrar contas salvas"
},
"collapseButton": {
"ariaLabel": "Ocultar contas salvas"
}
},
"accountSummary": {
"newAccountSuffix": "(Nova conta)",
"editButton": {
"label": "Editar conta"
}
},
"timeout": {
"toast": "A operação expirou após {seconds} segundos. Verifique se há erros na sua configuração."
+13 -1
View File
@@ -2690,7 +2690,19 @@
"label": "重试"
},
"savedAccounts": {
"label": "{count, plural, one {# 个已保存账户} other {# 个已保存账户}}"
"label": "{count, plural, one {# 个已保存账户} other {# 个已保存账户}}",
"expandButton": {
"ariaLabel": "显示已保存的账户"
},
"collapseButton": {
"ariaLabel": "隐藏已保存的账户"
}
},
"accountSummary": {
"newAccountSuffix": "(新账户)",
"editButton": {
"label": "编辑账户"
}
},
"timeout": {
"toast": "操作在 {seconds} 秒后超时。请检查配置是否有误。"
@@ -0,0 +1,56 @@
"use client";
import { useTranslations } from "next-intl";
import { Button } from "@opal/components";
import { Content, Section } from "@opal/layouts";
import { SvgPlusCircle, SvgUserEdit } from "@opal/icons";
import type { DraftCredential } from "@/lib/credentials/types";
import { useUser } from "@/providers/UserProvider";
interface AccountSummaryProps {
/** The new account typed into the form, which Create saves. */
draft: DraftCredential;
/** The source's display name, for an account with no name to show. */
sourceName: string;
}
/**
* One row that names the new account: its display name, else the current
* user's email. The new-account card shows it as its closed header once the
* account is usable.
*/
export function AccountSummary({ draft, sourceName }: AccountSummaryProps) {
const t = useTranslations("admin.connectorsList.add");
const { user } = useUser();
// Named like a saved account: its display name, else its creator (the
// current user).
const title: string =
draft.sharing.name ??
user?.email ??
t("newAccountButton.label", { source: sourceName });
return (
// The action sits level with the title line.
<Section
flexDirection="row"
alignItems="start"
justifyContent="between"
gap={4}
width="full"
>
<Section alignItems="start" padding={2} width="full">
<Content
icon={SvgPlusCircle}
title={title}
suffix={t("accountSummary.newAccountSuffix")}
sizePreset="main-ui"
variant="section"
/>
</Section>
{/* Repeats its card's own action, so it leaves the tab order. */}
<Button prominence="tertiary" rightIcon={SvgUserEdit} tabIndex={-1}>
{t("accountSummary.editButton.label")}
</Button>
</Section>
);
}
@@ -77,6 +77,11 @@ interface AuthenticationAccountCardProps {
* One that cannot is disabled.
*/
selectable: boolean;
/**
* Marks the pick with the "Selected" label only, not the selected card
* style, as in the folded account section.
*/
quietSelection?: boolean;
/** Called when the user picks this account. */
onSelect: () => void;
/** Called when the user picks the selected account again, to drop it. */
@@ -99,6 +104,7 @@ export default function AuthenticationAccountCard({
sourceName,
selected,
selectable,
quietSelection = false,
onSelect,
onDeselect,
onDelete,
@@ -195,7 +201,7 @@ export default function AuthenticationAccountCard({
expandable
expanded={expanded}
expandableContentHeight="full"
state={selected ? "selected" : "filled"}
state={selected && !quietSelection ? "selected" : "filled"}
border="solid"
rounding={4}
padding={2}
@@ -453,11 +453,14 @@ export interface CredentialChecksCardProps {
credential: CredentialRef | null;
/** Locks the Start Checks prompt until the credential section is valid. */
locked: boolean;
/** Called when Start Checks starts the first run. */
onStart?: () => void;
}
export default function CredentialChecksCard({
source,
credential,
locked,
onStart,
}: CredentialChecksCardProps) {
const promptT = useTranslations("admin.connectorChecks.prompt");
const { appName } = useSettings();
@@ -479,7 +482,14 @@ export default function CredentialChecksCard({
center
padding={0}
rightChildren={
<Button icon={SvgPlay} disabled={locked} onClick={checks.begin}>
<Button
icon={SvgPlay}
disabled={locked}
onClick={() => {
checks.begin();
onStart?.();
}}
>
{promptT("startButton.label")}
</Button>
}
@@ -19,7 +19,7 @@ import {
} from "@opal/layouts";
import { Disabled } from "@opal/core";
import { FormikField } from "@/refresh-components/form/FormikField";
import { SvgListTree, SvgPlusCircle } from "@opal/icons";
import { SvgExpand, SvgFold, SvgListTree, SvgPlusCircle } from "@opal/icons";
import type {
Credential,
DraftCredential,
@@ -48,6 +48,7 @@ import { CreateStdOAuthCredential } from "@/lib/credentials/components/CreateStd
import {
credentialMatchesBoundFields,
getCredentialSpec,
isDraftCredential,
realmFields,
shouldRedirectToOAuth,
toCredentialRef,
@@ -58,6 +59,7 @@ import type { ConfigurableSources } from "@/lib/connectors/types/source";
import { credentialBoundFieldNames } from "@/lib/connectors/utils";
import AuthenticationAccountCard from "@/views/admin/connectors/AddConnectorPage/components/AuthenticationAccountCard";
import CredentialChecksCard from "@/views/admin/connectors/AddConnectorPage/components/CredentialChecksCard";
import { AccountSummary } from "@/views/admin/connectors/AddConnectorPage/components/AccountSummary";
interface AuthenticationAccountSectionProps {
/** The source being set up. */
@@ -115,7 +117,14 @@ export default function AuthenticationAccountSection({
isAuthorizing,
} = useCredentialSetup(connector);
const checkReports = useCredentialCheckReports(connector);
const [showSavedAccounts, setShowSavedAccounts] = useState<boolean>(true);
// Expanded, the section lists every account. Folded, only the chosen one
// (or the new-account card when nothing is chosen) stays.
const [accountsExpanded, setAccountsExpanded] = useState<boolean>(true);
/** Folds the section. The new-account card closes too; expanding leaves it closed. */
function foldAccounts() {
close();
setAccountsExpanded(false);
}
// The create card's one label, whatever the number of routes; the tabs
// inside it name the routes.
@@ -324,8 +333,15 @@ export default function AuthenticationAccountSection({
sourceName={displayName}
selected={credential.id === selectedSavedId}
selectable={isSelectable(credential)}
// Folded, the pick is the only card shown: it keeps a light look,
// and a click opens the section rather than dropping the pick.
quietSelection={!accountsExpanded}
onSelect={() => onSwap(credential)}
onDeselect={() => onCredentialChange(null)}
onDeselect={() =>
accountsExpanded
? onCredentialChange(null)
: setAccountsExpanded(true)
}
onDelete={() => onDeleteCredential(credential)}
checkReport={checkReports.reportFor(credential.id)}
onRerunChecks={() => checkReports.rerun(credential.id)}
@@ -333,6 +349,15 @@ export default function AuthenticationAccountSection({
);
}
// The chosen account: the picked saved account, or the new account once it
// is usable and no saved account is picked.
const chosenDraft: DraftCredential | null =
selectedSavedId === null &&
checkedCredential !== null &&
isDraftCredential(checkedCredential)
? checkedCredential
: null;
// One card creates a credential. Its header toggles it; the fold below is a
// plain container, so a click in the open form cannot fold it away. The
// routes into the source are tabs inside the fold.
@@ -351,7 +376,8 @@ export default function AuthenticationAccountSection({
// interactive half is focusable, so a role here folds no other
// control into that name.
role="button"
aria-label={newAccountLabel}
// Showing a summary, the card is named by it (its visible text).
aria-label={!isCreating && chosenDraft ? undefined : newAccountLabel}
aria-expanded={isCreating}
tabIndex={0}
expandedContent={
@@ -391,15 +417,20 @@ export default function AuthenticationAccountSection({
}
onClick={() => (isCreating ? close() : selectMethod(defaultMethod))}
>
<Section padding={2} width="full">
<Content
icon={SvgPlusCircle}
title={newAccountLabel}
sizePreset="main-ui"
variant="body"
color={isCreating ? "interactive" : "muted"}
/>
</Section>
{/* Closed, a usable new account shows its summary as the header. */}
{!isCreating && chosenDraft ? (
<AccountSummary draft={chosenDraft} sourceName={displayName} />
) : (
<Section padding={2} width="full">
<Content
icon={SvgPlusCircle}
title={newAccountLabel}
sizePreset="main-ui"
variant="body"
color={isCreating ? "interactive" : "muted"}
/>
</Section>
)}
</SelectCard>
);
@@ -414,18 +445,33 @@ export default function AuthenticationAccountSection({
variant="section"
padding={0}
rightChildren={
<Button
icon={SvgListTree}
prominence="internal"
// Held in its hover look while the list is open.
interaction={showSavedAccounts ? "hover" : "rest"}
aria-expanded={showSavedAccounts}
onClick={() => setShowSavedAccounts((shown) => !shown)}
>
{t("add.savedAccounts.label", {
count: credentials?.length ?? 0,
})}
</Button>
<Section flexDirection="row" alignItems="center" gap={0} width="fit">
<Section padding={2} width="fit">
<Content
icon={SvgListTree}
title={t("add.savedAccounts.label", {
count: credentials?.length ?? 0,
})}
sizePreset="main-ui"
variant="body"
color="muted"
width="fit"
/>
</Section>
<Button
icon={accountsExpanded ? SvgFold : SvgExpand}
prominence="tertiary"
aria-label={
accountsExpanded
? t("add.savedAccounts.collapseButton.ariaLabel")
: t("add.savedAccounts.expandButton.ariaLabel")
}
aria-expanded={accountsExpanded}
onClick={() =>
accountsExpanded ? foldAccounts() : setAccountsExpanded(true)
}
/>
</Section>
}
/>
@@ -434,39 +480,55 @@ export default function AuthenticationAccountSection({
the types honest. */}
{!credentials ? null : (
<Section gap={6} alignItems="stretch" width="full">
<Section gap={0} alignItems="stretch" width="full" height="fit">
{/* Hidden accounts animate away, then leave the page. The fold
holds the cards' gap, so a closed one leaves no space behind. A
div, as Section's own padding would override the bottom one. */}
<Fold open={showSavedAccounts && credentials.length > 0}>
<div className="flex flex-col gap-2 pb-2">
{credentials.map(renderSavedCard)}
</div>
</Fold>
<Section gap={2} alignItems="stretch" width="full">
{canAuthorize && (
<Section flexDirection="row" justifyContent="start" gap={1}>
<Button
disabled={isAuthorizing}
variant="action"
onClick={handleAuthorize}
>
{isAuthorizing
? t("add.authorizeButton.pendingLabel")
: t("add.authorizeButton.label", {
source: displayName,
})}
</Button>
</Section>
)}
{/* One list in a fixed order. Folded, each card that is not the
chosen one fades, then its height collapses, so the chosen card
moves up on its own. Each item holds its own spacing: a closed fold
takes no height, but a gap around it would stay. */}
<div className="flex flex-col">
{credentials.map((credential) => (
<Fold
key={credential.id}
open={accountsExpanded || credential.id === selectedSavedId}
keepMounted
>
<div className="pb-2">{renderSavedCard(credential)}</div>
</Fold>
))}
{canAuthorize && (
<Fold open={accountsExpanded} keepMounted>
<div className="pb-2">
<Section flexDirection="row" justifyContent="start" gap={1}>
<Button
disabled={isAuthorizing}
variant="action"
onClick={handleAuthorize}
>
{isAuthorizing
? t("add.authorizeButton.pendingLabel")
: t("add.authorizeButton.label", {
source: displayName,
})}
</Button>
</Section>
</div>
</Fold>
)}
{/* Shown unless a saved account is the pick. Its own fold (the
form) is independent of the section's. */}
<Fold
open={accountsExpanded || selectedSavedId === null}
keepMounted
>
{newAccountCard}
</Section>
</Section>
</Fold>
</div>
<CredentialChecksCard
source={connector}
credential={toCredentialRef(checkedCredential)}
locked={checksLocked || !checkedCredential}
// Starting the checks completes the account step, so it folds.
onStart={foldAccounts}
/>
</Section>
)}