Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions apps/hosted/web/src/implementation/pages/app-accounts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,23 +156,28 @@ export function AppAccounts({
? { pending, choose: (slot, value) => void chooser.choose(slot, value) }
: undefined
}
removeAccountAction={(slot, account, label) => {
removeAccountAction={(slot, account, label, bound) => {
if (!canUse || profile === undefined) return null;
const saved = accounts.find((item) => item.id === account);
// An unselected account offers only its own actions; with none, it has no menu.
if (!bound && saved === undefined) return null;
const requirement = app.requirements.accounts[slot];
return (
<AccountBindingMenu
profile={profile}
slot={slot}
account={account}
label={label}
bound={bound}
update={profileMutations({ organization, app: app.id, profile: profile.id }).update}
Failure={HostedFailure}
onRemoved={(removed) => void unused.check(removed)}
>
{saved && (
<>
<DropdownMenuItem onSelect={() => setRenaming(saved.id)}>Rename</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setRenaming(saved.id)}>
Edit details
</DropdownMenuItem>
{editable && requirement !== undefined && (
<DropdownMenuItem disabled={pending} onSelect={() => reconnect(slot, saved)}>
{requirement.definition.auth[saved.method]?.type === "oauth2"
Expand Down
9 changes: 7 additions & 2 deletions apps/local/web/src/implementation/pages/app-accounts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,23 +158,28 @@ export function AppAccounts({
pending: chooser.pending,
choose: (slot, value) => void chooser.choose(slot, value),
}}
removeAccountAction={(slot, account, label) => {
removeAccountAction={(slot, account, label, bound) => {
if (profile === undefined) return null;
const saved = accounts.find((item) => item.id === account);
// An unselected account offers only its own actions; with none, it has no menu.
if (!bound && saved === undefined) return null;
const requirement = app.requirements.accounts[slot];
return (
<AccountBindingMenu
profile={profile}
slot={slot}
account={account}
label={label}
bound={bound}
update={profileMutations({ app: app.id, profile: profile.id }).update}
Failure={Failure}
onRemoved={(removed) => void unused.check(removed)}
>
{saved && (
<>
<DropdownMenuItem onSelect={() => setRenaming(saved.id)}>Rename</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setRenaming(saved.id)}>
Edit details
</DropdownMenuItem>
{requirement !== undefined && (
<DropdownMenuItem onSelect={() => replace(slot, saved.id)}>
{requirement.definition.auth[saved.method]?.type === "oauth2"
Expand Down
77 changes: 77 additions & 0 deletions e2e/tests/local-account-naming.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,83 @@ export default defineApp({ accounts: { service } }, async () => ({ tools: router
expect(yield* savedLabel(fromDialog.id)).toBe("Dialog key");
yield* browser.checkpoint("Named account selected for the app");

// Every account row keeps its menu in view, not only the selected one or on hover.
expect(
yield* browser.use("Unselected accounts show their menu without hover", (page) =>
page.mouse
.move(0, 0)
.then(() =>
page
.getByRole("button", { name: "Manage Supplied name", exact: true })
.evaluate((element) => getComputedStyle(element).opacity),
),
),
).toBe("1");
expect(
yield* browser.use("An unselected account's menu cannot remove it", (page) =>
page
.getByRole("button", { name: "Manage Supplied name", exact: true })
.click()
.then(() =>
page.getByRole("menuitem", { name: "Edit details", exact: true }).waitFor(),
)
// Let the menu finish opening so the checkpoint shows it, not its fade-in.
.then(() =>
page
.getByRole("menu")
.evaluate((menu) =>
Promise.all(menu.getAnimations().map((animation) => animation.finished)),
),
)
.then(() => page.getByRole("menuitem").allTextContents()),
),
).toEqual(["Edit details", "Update credentials"]);
yield* browser.checkpoint("Unselected account menu");
yield* browser.use("Close the unselected account's menu", (page) =>
page.keyboard.press("Escape"),
);

// An unselected account's menu opens the same dialogs as a selected one.
const edit = yield* browser.use("Edit an unselected account's details", (page) =>
page
.getByRole("button", { name: "Manage Supplied name", exact: true })
.click()
.then(() => page.getByRole("menuitem", { name: "Edit details", exact: true }).click())
.then(() => page.getByRole("dialog", { name: "Edit account", exact: true }))
.then((dialog) => dialog.waitFor({ state: "visible" }).then(() => dialog)),
);
yield* browser.checkpoint("Edit details from an unselected account");
yield* browser.use("Cancel editing the details", () =>
edit
.getByRole("button", { name: "Cancel", exact: true })
.click()
.then(() => edit.waitFor({ state: "hidden" })),
);
const unselectedCredentials = yield* browser.use(
"Update an unselected account's credentials",
(page) =>
page
.getByRole("button", { name: "Manage Supplied name", exact: true })
.click()
.then(() =>
page.getByRole("menuitem", { name: "Update credentials", exact: true }).click(),
)
.then(() => page.getByRole("dialog", { name: "Update credentials", exact: true }))
.then((dialog) =>
dialog
.getByLabel("Token", { exact: true })
.waitFor({ state: "visible" })
.then(() => dialog),
),
);
yield* browser.checkpoint("Update credentials from an unselected account");
yield* browser.use("Close the credentials dialog", () =>
unselectedCredentials
.getByRole("button", { name: "Close", exact: true })
.click()
.then(() => unselectedCredentials.waitFor({ state: "hidden" })),
);

// Credentials are replaced from the app that selects the account, through its menu.
yield* browser.use("Update the named account's credentials", (page) =>
page
Expand Down
39 changes: 22 additions & 17 deletions packages/ui/src/implementation/dashboard/app-accounts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ type AccountBindingProps<E> = {
readonly slot: string;
readonly account: AccountId;
readonly label: string;
/** Whether this profile selects the account; only a selected account can be removed from it. */
readonly bound: boolean;
readonly update: Atom.AtomResultFn<
Omit<typeof ProfileInputs.update.Type, "app" | "profile">,
Profile,
Expand Down Expand Up @@ -122,12 +124,9 @@ function useRemoveAccountBinding<E>({
};
}

const bindingActionClass =
"shrink-0 text-muted-foreground [@media(hover:hover)]:opacity-0 group-hover/account:opacity-100 group-focus-within/account:opacity-100 focus-visible:opacity-100 data-loading:opacity-100";

/**
* A selected account's actions in this app: the host's items, then removal from the profile.
* Removal keeps the reusable account and every other provider selection.
* An account's actions in this app: the host's items, then removal from the profile when the
* profile selects it. Removal keeps the reusable account and every other provider selection.
*/
export function AccountBindingMenu<E>({
children,
Expand All @@ -143,22 +142,24 @@ export function AccountBindingMenu<E>({
variant="ghost"
size="icon-xs"
aria-label={`Manage ${props.label}`}
className={`${bindingActionClass} hover:text-foreground data-[state=open]:opacity-100`}
className="shrink-0 text-muted-foreground hover:text-foreground"
loading={removal.pending}
>
<HugeiconsIcon icon={MoreHorizontalIcon} size={14} strokeWidth={2} aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-44">
{children}
{children && <DropdownMenuSeparator />}
<DropdownMenuItem
variant="destructive"
disabled={removal.disabled}
onSelect={() => void removal.remove()}
>
Remove
</DropdownMenuItem>
{children && props.bound && <DropdownMenuSeparator />}
{props.bound && (
<DropdownMenuItem
variant="destructive"
disabled={removal.disabled}
onSelect={() => void removal.remove()}
>
Remove
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{removal.error && (
Expand Down Expand Up @@ -362,7 +363,12 @@ export function AppAccounts({
readonly chooseAction?: ReactNode;
readonly reconnectAction?: (account: AccountSummary) => ReactNode;
readonly accountActions?: (slot: string, requirement: AccountRequirement) => ReactNode;
readonly removeAccountAction?: (slot: string, account: AccountId, label: string) => ReactNode;
readonly removeAccountAction?: (
slot: string,
account: AccountId,
label: string,
bound: boolean,
) => ReactNode;
readonly onCreateProfile?: (() => void) | undefined;
/** Check one account again; its result reaches `accounts` through the host's queries. */
readonly revalidate?: ((account: AccountId) => Promise<unknown>) | undefined;
Expand Down Expand Up @@ -501,8 +507,7 @@ export function AppAccounts({
)}
{status}
{/* Unchecking only unselects; removal is its own action and may offer deletion. */}
{bound &&
removeAccountAction?.(slot, id, label ?? "Account disconnected")}
{removeAccountAction?.(slot, id, label ?? "Account disconnected", bound)}
</li>
);
})}
Expand Down