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
1 change: 1 addition & 0 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -292,3 +292,4 @@ jobs:
run: |
flyctl apps destroy "$APP_NAME" --yes || true
flyctl apps destroy "$APP_NAME-db" --yes || true

84 changes: 84 additions & 0 deletions frontend/src/tests/e2e/interface/mobile-workspace-features.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { test, expect, devices } from "../fixtures.js";
import { MobileHelpers } from "../utils/mobile-helpers.js";
import { AuthHelpers } from "../utils/auth-helpers.js";

test.describe("Mobile Workspace — hidden desktop-only features @auth @mobile-only", () => {
let mobileHelpers;

const mobileDevice = { name: "iPhone 12", device: devices["iPhone 12"] };

/**
* Navigate to a workspace by clicking the first file on the home page.
* File items use router.push('/file/:id'), not <a> tags.
*/
async function navigateToWorkspace(page, helpers) {
await page.goto("/");
await page.waitForLoadState("domcontentloaded");
await helpers.waitForMobileRendering();

const fileItem = page.locator('[data-testid^="file-item-"]').first();
await expect(fileItem).toBeVisible({ timeout: 5000 });
await fileItem.click();
await page.waitForURL(/\/file\//, { timeout: 5000 });
await page.waitForLoadState("domcontentloaded");
await helpers.waitForMobileRendering();
}

test("source editor toggle should not appear in mobile bottom bar", async ({ browser }) => {
const context = await browser.newContext({
...mobileDevice.device,
isMobile: undefined,
});
const page = await context.newPage();
mobileHelpers = new MobileHelpers(page);
const auth = new AuthHelpers(page);
await auth.fastAuth();

await navigateToWorkspace(page, mobileHelpers);

// Source editor toggle (label "source") should NOT be in mobile bottom bar
const sourceButton = page.locator('.sb-menu.mobile .sb-item-label:text("source")');
await expect(sourceButton).not.toBeVisible();

await context.close();
});

test("versions and share should not appear in mobile overflow menu", async ({ browser }) => {
const context = await browser.newContext({
...mobileDevice.device,
isMobile: undefined,
});
const page = await context.newPage();
mobileHelpers = new MobileHelpers(page);
const auth = new AuthHelpers(page);
await auth.fastAuth();

await navigateToWorkspace(page, mobileHelpers);

// Open the overflow menu (workspace sidebar, not home page hamburger)
const menuButton = page.locator('[data-testid="mobile-menu-button"]');
await expect(menuButton).toBeVisible({ timeout: 5000 });
await menuButton.click();

const menu = page.locator('[data-testid="context-menu"]').first();
await expect(menu).toBeVisible({ timeout: 5000 });

// Versions should NOT be in the menu
const versionsItem = menu.locator(".item").filter({ hasText: "versions" });
await expect(versionsItem).not.toBeVisible();

// Share should NOT be in the menu
const shareItem = menu.locator(".item").filter({ hasText: "share" });
await expect(shareItem).not.toBeVisible();

// Settings SHOULD be in the menu
const settingsItem = menu.locator(".item").filter({ hasText: "settings" });
await expect(settingsItem).toBeVisible();

// File SHOULD be in the menu
const fileItem = menu.locator(".item").filter({ hasText: "file" });
await expect(fileItem).toBeVisible();

await context.close();
});
});
154 changes: 154 additions & 0 deletions frontend/src/tests/views/workspace/SidebarMenu.mobile.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { ref } from "vue";
import { mount } from "@vue/test-utils";
import SidebarMenu from "@/views/workspace/SidebarMenu.vue";
import * as KSMod from "@/composables/useKeyboardShortcuts.js";

const pushMock = vi.fn();
vi.mock("vue-router", () => ({
useRouter: () => ({ push: pushMock }),
}));

describe("SidebarMenu mobile item filtering", () => {
let useKSSpy;

beforeEach(() => {
useKSSpy = vi.spyOn(KSMod, "useKeyboardShortcuts").mockImplementation(() => {});
pushMock.mockClear();
});

afterEach(() => {
vi.restoreAllMocks();
});

const allItems = [
{ name: "DockableEditor", icon: "Code", label: "source", key: "e", state: false, type: "toggle" },
{ name: "DockableSearch", icon: "Search", label: "search", key: "f", state: false, type: "toggle" },
{ name: "Separator", state: false },
{ name: "DrawerVersions", icon: "Versions", label: "versions", key: "v", state: false, type: "drawer" },
{ name: "DrawerSettings", icon: "AdjustmentsHorizontal", label: "settings", key: "t", state: false, type: "drawer" },
{ name: "DrawerShare", icon: "Users", label: "share", key: "s", state: false, type: "drawer" },
{ name: "DrawerFile", icon: "File", label: "file", key: "a", state: false, type: "drawer" },
{ name: "Separator", state: false },
];

function mountMobile(items = allItems) {
return mount(SidebarMenu, {
props: { items },
global: {
provide: {
mobileMode: ref(true),
xsMode: ref(false),
focusMode: ref(false),
drawerOpen: ref(false),
},
stubs: {
SidebarItem: {
name: "SidebarItem",
template: '<div class="sb-item" :data-label="label" />',
props: ["icon", "label", "modelValue"],
},
ContextMenu: {
name: "ContextMenu",
template: '<div class="context-menu"><slot name="trigger" :toggle="() => {}" /><slot /></div>',
},
ContextMenuItem: {
name: "ContextMenuItem",
template: '<div class="context-menu-item" :data-caption="caption" :data-disabled="disabled || undefined" />',
props: ["icon", "caption", "disabled"],
},
Button: { template: '<button />' },
Separator: { template: '<hr />' },
UserMenu: { template: '<div />' },
},
},
});
}

function mountDesktop(items = allItems) {
return mount(SidebarMenu, {
props: { items },
global: {
provide: {
mobileMode: ref(false),
xsMode: ref(false),
focusMode: ref(false),
drawerOpen: ref(false),
},
stubs: {
SidebarItem: {
name: "SidebarItem",
template: '<div class="sb-item" :data-label="label" />',
props: ["icon", "label", "modelValue", "type"],
},
ContextMenu: {
name: "ContextMenu",
template: '<div class="context-menu"><slot name="trigger" :toggle="() => {}" /><slot /></div>',
},
ContextMenuItem: {
name: "ContextMenuItem",
template: '<div class="context-menu-item" :data-caption="caption" />',
props: ["icon", "caption"],
},
Button: { template: '<button />' },
Separator: { template: '<hr />' },
UserMenu: { template: '<div />' },
},
},
});
}

it("hides source editor toggle on mobile", () => {
const wrapper = mountMobile();
const toggleItems = wrapper.findAll('.sb-item[data-label]');
const labels = toggleItems.map((w) => w.attributes("data-label"));
expect(labels).not.toContain("source");
});

it("hides versions drawer from mobile overflow menu", () => {
const wrapper = mountMobile();
const menuItems = wrapper.findAll('.context-menu-item');
const captions = menuItems.map((w) => w.attributes("data-caption"));
expect(captions).not.toContain("versions");
});

it("hides share drawer from mobile overflow menu", () => {
const wrapper = mountMobile();
const menuItems = wrapper.findAll('.context-menu-item');
const captions = menuItems.map((w) => w.attributes("data-caption"));
expect(captions).not.toContain("share");
});

it("keeps search toggle on mobile", () => {
const wrapper = mountMobile();
const toggleItems = wrapper.findAll('.sb-item[data-label]');
const labels = toggleItems.map((w) => w.attributes("data-label"));
expect(labels).toContain("search");
});

it("keeps settings drawer on mobile", () => {
const wrapper = mountMobile();
const menuItems = wrapper.findAll('.context-menu-item');
const captions = menuItems.map((w) => w.attributes("data-caption"));
expect(captions).toContain("settings");
});

it("keeps file drawer on mobile", () => {
const wrapper = mountMobile();
const menuItems = wrapper.findAll('.context-menu-item');
const captions = menuItems.map((w) => w.attributes("data-caption"));
expect(captions).toContain("file");
});

it("shows all items on desktop", () => {
const wrapper = mountDesktop();
const sidebarItems = wrapper.findAll('.sb-item[data-label]');
const labels = sidebarItems.map((w) => w.attributes("data-label"));
expect(labels).toContain("source");
expect(labels).toContain("search");
expect(labels).toContain("versions");
expect(labels).toContain("settings");
expect(labels).toContain("share");
expect(labels).toContain("file");
});
});
19 changes: 11 additions & 8 deletions frontend/src/views/workspace/SidebarMenu.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup>
import { toRef, watch, watchEffect, inject, useTemplateRef } from "vue";
import { toRef, watch, watchEffect, inject, useTemplateRef, computed } from "vue";
import { useRouter } from "vue-router";
import { useKeyboardShortcuts } from "@/composables/useKeyboardShortcuts.js";
import SidebarItem from "./SidebarItem.vue";
Expand Down Expand Up @@ -70,6 +70,9 @@
const mobileMode = inject("mobileMode");
const xsMode = inject("xsMode");
const router = useRouter();

const MOBILE_HIDDEN = new Set(["DockableEditor", "DrawerVersions", "DrawerShare"]);
const mobileItems = computed(() => items.value.filter((it) => !MOBILE_HIDDEN.has(it.name)));
</script>

<template>
Expand Down Expand Up @@ -99,27 +102,27 @@

<template v-if="mobileMode">
<SidebarItem icon="Home" label="Home" @click="router.push('/')" />
<template v-for="(it, idx) in items" :key="it.name + idx">
<template v-for="(it, idx) in mobileItems" :key="it.name + idx">
<SidebarItem
v-if="it.type === 'toggle'"
v-model="it.state"
:icon="it.icon"
:label="it.label"
@on="emit('on', idx)"
@off="emit('off', idx)"
@on="emit('on', items.indexOf(it))"
@off="emit('off', items.indexOf(it))"
/>
</template>
<ContextMenu variant="slot">
<template #trigger="{ toggle }">
<Button icon="Menu3" kind="ghost" data-testid="mobile-menu-button" @click="toggle" />
<Button icon="Menu3" kind="tertiary" data-testid="mobile-menu-button" @click="toggle" />
</template>
<template v-for="(it, idx) in items" :key="it.name + idx">
<template v-for="(it, idx) in mobileItems" :key="it.name + idx">
<ContextMenuItem
v-if="it.type === 'drawer'"
:icon="it.icon"
:caption="it.label"
@on="emit('on', idx)"
@off="emit('off', idx)"
@on="emit('on', items.indexOf(it))"
@off="emit('off', items.indexOf(it))"
/>
</template>
</ContextMenu>
Expand Down
Loading