Skip to content
Merged
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
143 changes: 71 additions & 72 deletions dashboard/src/components/UserMenu.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
<script setup lang="ts">
import {
Avatar,
Button,
Divider,
Dropdown,
KeyboardShortcut,
Popover,
Tooltip,
sidebarCollapsedKey,
useColorScheme,
type DropdownOptions,
} from "frappe-ui"
import { computed, inject, ref } from "vue"
import { computed, h, inject, ref } from "vue"

import UserSettingsDialog from "@/components/UserSettingsDialog.vue"
import { session } from "@/data/session"
Expand All @@ -21,26 +20,73 @@ const isCollapsed = inject(

const { colorScheme, setColorScheme } = useColorScheme()

const themes = [
{ value: "light", icon: "lucide-sun", label: "Light" },
{ value: "dark", icon: "lucide-moon", label: "Dark" },
{ value: "system", icon: "lucide-monitor", label: "System" },
] as const

const settingsOpen = ref(false)

// The popover sits above the dialog's overlay, so it never gets the outside-click.
function openSettings(closeMenu: () => void) {
closeMenu()
settingsOpen.value = true
}
const NEW_ISSUE_URL = "https://github.com/bwhtech/buzz/issues/new"

// preventDefault keeps the menu open, so themes can be compared without reopening it.
const themeOptions = computed<DropdownOptions>(() =>
(
[
{ value: "light", icon: "lucide-sun", label: __("Light") },
{ value: "dark", icon: "lucide-moon", label: __("Dark") },
{ value: "system", icon: "lucide-monitor", label: __("System") },
] as const
).map((theme) => ({
label: theme.label,
icon: theme.icon,
selected: colorScheme.value === theme.value,
onClick: (event: Event) => {
event.preventDefault()
setColorScheme(theme.value)
},
})),
)

const menu = computed<DropdownOptions>(() => [
{
group: "settings",
hideLabel: true,
options: [
{
label: __("Settings"),
icon: "lucide-settings",
onClick: () => (settingsOpen.value = true),
slots: { suffix: () => h(KeyboardShortcut, { combo: "G+S", bg: true }) },
},
{
label: __("Theme"),
icon: "lucide-sun-moon",
submenu: themeOptions.value,
},
{
label: __("Report an Issue"),
icon: "lucide-bug",
onClick: () => window.open(NEW_ISSUE_URL, "_blank", "noopener"),
},
],
},
{
group: "session",
hideLabel: true,
options: [
{
label: session.logout.loading ? __("Signing out…") : __("Log Out"),
icon: "lucide-log-out",
theme: "red",
disabled: session.logout.loading,
onClick: () => session.logout.fetch(),
},
],
},
])
</script>

<template>
<Popover match-trigger-width side="top" align="start">
<template #trigger="{ open: isOpen }">
<Dropdown :options="menu" side="top" align="start" match-trigger-width>
<template #default="{ open: isOpen }">
<button
aria-label="Account menu"
data-testid="account-menu"
class="flex h-12 w-full items-center gap-2 rounded-4 px-1.5 transition-[background-color,transform] duration-150 ease-out hover:bg-surface-gray-2 active:scale-[0.98] focus-visible:outline-none focus-visible:focus-ring"
:class="{ 'bg-surface-gray-2': isOpen }"
>
Expand All @@ -51,69 +97,22 @@ function openSettings(closeMenu: () => void) {
class="flex min-w-0 flex-col text-left transition-opacity duration-150 ease-out"
:class="isCollapsed ? 'w-0 flex-none overflow-hidden opacity-0' : 'flex-1 opacity-100'"
>
<span class="truncate text-base font-medium text-ink-gray-8">
<span :title="session.fullName" class="truncate text-base font-medium text-ink-gray-8">
{{ session.fullName }}
</span>
<span class="truncate text-sm text-ink-gray-6">{{ session.user }}</span>
<span :title="session.user" class="truncate text-sm text-ink-gray-6">
{{ session.user }}
</span>
</span>
<!-- Names the control without replacing the name the button already shows. -->
<span class="sr-only">{{ __("Account menu") }}</span>
<span
class="lucide-chevrons-up-down size-4 shrink-0 text-ink-gray-5 transition-opacity duration-150 ease-out"
:class="{ 'w-0 overflow-hidden opacity-0': isCollapsed }"
/>
</button>
</template>

<template #default="{ close }">
<div class="p-2">
<div class="flex flex-col px-1.5 py-1">
<span class="truncate text-base text-ink-gray-8">{{ session.fullName }}</span>
<span class="truncate text-sm text-ink-gray-5">{{ session.user }}</span>
</div>

<Divider class="my-2" />

<Button
size="sm"
label="Settings"
variant="ghost"
class="w-full !justify-start"
@click="openSettings(close)"
>
<template #suffix>
<span class="ml-auto flex items-center gap-1">
<KeyboardShortcut combo="G+S" bg />
</span>
</template>
</Button>

<div class="flex h-8 items-center justify-between gap-2 px-1.5">
<span class="text-base text-ink-gray-8 pl-0.5">Theme</span>
<div class="flex items-center gap-1">
<Button
v-for="theme in themes"
:key="theme.value"
:variant="colorScheme === theme.value ? 'subtle' : 'ghost'"
:icon="theme.icon"
:label="theme.label"
:tooltip="theme.label"
@click="setColorScheme(theme.value)"
/>
</div>
</div>

<Divider class="my-2" />

<Button
size="sm"
label="Log Out"
variant="ghost"
theme="red"
class="w-full !justify-start"
@click="session.logout.fetch()"
/>
</div>
</template>
</Popover>
</Dropdown>

<UserSettingsDialog v-model:open="settingsOpen" />
</template>
6 changes: 5 additions & 1 deletion dashboard/src/data/session.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createResource } from "frappe-ui"
import { createResource, toast } from "frappe-ui"
import { computed, reactive } from "vue"

import { clearBookingCache } from "@/utils"
Expand Down Expand Up @@ -40,6 +40,10 @@ export const session = reactive({
clearBookingCache()
window.location.reload()
},
// A silent failure leaves the menu open on a session that is still live.
onError() {
toast.error(__("Could not sign you out. Check your connection and try again."))
},
}),
user: sessionUser(),
fullName: computed((): string => userResource.data?.full_name || session.user),
Expand Down
10 changes: 7 additions & 3 deletions e2e/tests/user-settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ const unique = (prefix: string) => `${prefix} ${Date.now()}`
// getByLabel("Email") also matches an event card's "Open E2E Guest Email OTP" button.
const settings = (page: Page) => page.getByRole("dialog")

// By test id, not by role: the open settings dialog hides the sidebar from the
// accessibility tree, and the name assertion below runs while it is open.
const accountMenu = (page: Page) => page.getByTestId("account-menu")

async function openSettings(page: Page) {
await expect(page.getByRole("heading", { name: "Events", level: 1 })).toBeVisible()
await page.getByLabel("Account menu").click()
await page.getByRole("button", { name: "Settings" }).click()
await accountMenu(page).click()
await page.getByRole("menuitem", { name: "Settings" }).click()
await expect(settings(page).getByRole("heading", { name: "Profile" })).toBeVisible()
}

Expand Down Expand Up @@ -42,7 +46,7 @@ test.describe("User settings", () => {
await save.click()

// The sidebar reads full_name, which the server derives on save.
await expect(page.getByLabel("Account menu")).toContainText(edited)
await expect(accountMenu(page)).toContainText(edited)

await firstName.fill(SETTINGS_FIRST_NAME)
await save.click()
Expand Down
Loading