fix(oncall): fit the on-call pages on phones and tablets (#15033)

This commit is contained in:
ktx-vaidehi
2026-10-01 09:16:15 +00:00
committed by GitHub
parent 6bbdab93ce
commit 79bc86d4f3
34 changed files with 1095 additions and 98 deletions
@@ -14,13 +14,28 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import OnCallCoverageStrip from "@/components/oncall/OnCallCoverageStrip.vue";
import i18n from "@/locales";
import type { Rotation } from "@/ts/interfaces/oncall";
import { MICROS_PER_WEEK } from "@/ts/interfaces/oncall";
// Mutable per case, so the phone tests can switch while every other test stays desktop.
const mockViewport = vi.hoisted(() => ({ mdUp: true, lgUp: true }));
vi.mock("@/composables/useBreakpoint", async () => {
const { computed } = await import("vue");
return {
default: () => ({
isMobile: computed(() => !mockViewport.mdUp),
isTablet: computed(() => mockViewport.mdUp && !mockViewport.lgUp),
isDesktop: computed(() => mockViewport.lgUp),
mdUp: computed(() => mockViewport.mdUp),
lgUp: computed(() => mockViewport.lgUp),
}),
};
});
const stubs = {
OScheduleTimeline: {
name: "OScheduleTimeline",
@@ -65,6 +80,11 @@ function render(rotations: Rotation[]) {
}
describe("OnCallCoverageStrip", () => {
afterEach(() => {
mockViewport.mdUp = true;
mockViewport.lgUp = true;
});
/// A covered fortnight is ONE band, not 336 hourly slivers the browser has to
/// lay out.
it("merges an unbroken stretch of cover into a single band", () => {
@@ -153,6 +173,17 @@ describe("OnCallCoverageStrip", () => {
expect(first.offset).toBeGreaterThan(0);
expect(first.offset).toBeLessThan(dayShare);
});
/// A phone-width strip has room for about four date labels; the laptop's seven ran into each other.
it("thins the dates further on a phone", () => {
const laptop = axisOf(render([rotation(["ana@o2.ai"])]));
mockViewport.mdUp = false;
const phone = axisOf(render([rotation(["ana@o2.ai"])]));
expect(phone.length).toBeGreaterThan(1);
expect(phone.length).toBeLessThanOrEqual(4);
expect(phone.length).toBeLessThan(laptop.length);
});
});
/// E5: `Covered` is a binary that answers the wrong question. What somebody
@@ -114,6 +114,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<script setup lang="ts">
import { computed } from "vue";
import useBreakpoint from "@/composables/useBreakpoint";
import OScheduleBand from "@/lib/data/ScheduleTimeline/OScheduleBand.vue";
import OScheduleTimeline from "@/lib/data/ScheduleTimeline/OScheduleTimeline.vue";
import type {
@@ -142,6 +143,7 @@ const props = withDefaults(
);
const { t } = useI18nTyped();
const { isMobile } = useBreakpoint();
/** One sample per hour: fine enough to catch an hour-long hole. */
const STEP_MICROS = 60 * 60 * 1_000_000;
@@ -417,6 +419,8 @@ const summary = computed<I18nText>(() => {
/// Roughly this many labelled marks across the window — enough to place a band
/// within a day, few enough that the labels do not collide.
const TICK_TARGET = 7;
/** A phone-width strip only has room for this many date labels before they collide. */
const TICK_TARGET_PHONE = 4;
/// The next local midnight at or after `at`.
///
@@ -455,7 +459,8 @@ const dayColumns = computed(() => dayBoundaries.value.map((at) => (at - start.va
/// they thin out — but always onto real midnights, never onto even fractions of
/// the window, which would put "Mon 18" at half past two on the Monday.
const axisTicks = computed<ScheduleAxisTick[]>(() => {
const step = Math.max(1, Math.ceil(props.days / TICK_TARGET));
const target = isMobile.value ? TICK_TARGET_PHONE : TICK_TARGET;
const step = Math.max(1, Math.ceil(props.days / target));
return dayBoundaries.value
.filter((_, index) => index % step === 0)
.map((at) => ({
@@ -446,4 +446,46 @@ describe("OnCallMembers", () => {
);
});
});
/// A phone's action column holds one control, so both row actions are mirrored in a menu that must act the same.
describe("the phone row menu", () => {
const menuStubs = {
...stubs,
ODropdown: { name: "ODropdown", template: "<div><slot name='trigger' /><slot /></div>" },
ODropdownItem: {
name: "ODropdownItem",
emits: ["select"],
template: `<button @click="$emit('select')"><slot /></button>`,
},
};
function renderWithMenu() {
return mount(OnCallMembers, {
props: { teamId: "team_1", members: [member("ana@o2.ai")], timezone: "UTC" },
global: { plugins: [i18n, store], stubs: menuStubs },
});
}
it("shows the buttons from md up and the menu below it, never both", async () => {
const wrapper = renderWithMenu();
await flushPromises();
for (const action of ["mark-away", "remove"]) {
expect(
wrapper.find(`[data-test="oncall-members-${action}-ana@o2.ai"]`).classes(),
).toContain("max-md:hidden");
}
expect(wrapper.find('[data-test="oncall-members-row-more-actions"]').classes()).toContain(
"md:hidden",
);
});
it("opens the away dialog for the row's member", async () => {
const wrapper = renderWithMenu();
await flushPromises();
await wrapper.find('[data-test="oncall-members-mark-away-ana@o2.ai-menu"]').trigger("click");
expect(wrapper.find('[data-test="oncall-members-away-save"]').exists()).toBe(true);
});
});
});
+38 -3
View File
@@ -18,11 +18,11 @@
<div class="flex w-full flex-wrap items-center gap-2">
<OSearchInput
v-model="memberFilter"
class="w-full max-w-xs"
class="w-full max-w-xs max-lg:max-w-none"
:placeholder="t('oncall.memberSearchPlaceholder')"
data-test="oncall-members-search"
/>
<OSeparator vertical />
<OSeparator vertical class="max-lg:hidden" />
<div class="min-w-0 flex-1">
<OSelect
v-if="!userLookupFailed"
@@ -58,7 +58,7 @@
<span
v-if="orgTotal"
class="text-text-secondary ms-auto shrink-0 text-xs"
class="text-text-secondary ms-auto shrink-0 text-xs max-md:hidden"
data-test="oncall-members-coverage"
>
{{ t("oncall.membersOfOrg", { onTeam: onTeamCount, total: orgTotal }) }}
@@ -141,6 +141,7 @@
variant="ghost"
size="icon-sm"
icon-left="event"
class="max-md:hidden"
:aria-label="t('oncall.awayMark')"
:data-test="`oncall-members-mark-away-${row.id}`"
@click.stop="openAway(row.user_email)"
@@ -149,10 +150,42 @@
variant="ghost"
size="icon-sm"
icon-left="close"
class="max-md:hidden"
:aria-label="t('oncall.removeMember')"
:data-test="`oncall-members-remove-${row.id}`"
@click.stop="memberToRemove = row"
/>
<ODropdown side="bottom" align="end">
<template #trigger>
<OButton
icon-left="more-vert"
variant="ghost"
size="icon-xs-sq"
class="md:hidden"
:aria-label="t('oncall.moreActions')"
data-test="oncall-members-row-more-actions"
@click.stop
/>
</template>
<ODropdownItem
v-if="canConfigure"
icon-left="event"
class="md:hidden"
:data-test="`oncall-members-mark-away-${row.id}-menu`"
@select="openAway(row.user_email)"
>
<span>{{ t("oncall.awayMark") }}</span>
</ODropdownItem>
<ODropdownItem
icon-left="close"
variant="destructive"
class="md:hidden"
:data-test="`oncall-members-remove-${row.id}-menu`"
@select="memberToRemove = row"
>
<span>{{ t("oncall.removeMember") }}</span>
</ODropdownItem>
</ODropdown>
</template>
<template #empty>
@@ -295,6 +328,8 @@ import type {
import { MICROS_PER_DAY } from "@/ts/interfaces/oncall";
import { formatInZone, rotationMembers } from "@/utils/oncall";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import type { I18nText } from "@/types/i18n";
import { raw, useI18nTyped } from "@/types/i18n";
@@ -351,4 +351,16 @@ describe("OnCallMyDeliveries", () => {
await flushPromises();
expect(wrapper.text()).toContain("Nothing unread");
});
/// A phone's action column is one icon wide, so the label goes screen-reader-only there rather than being clipped.
it("keeps the row action named when a phone shows only its icon", async () => {
service.myDeliveries.mockResolvedValue({
data: { total: 1, unread: 1, deliveries: [delivery({ read: false })] },
} as any);
const wrapper = render();
await flushPromises();
const button = wrapper.find('[data-test="oncall-my-delivery-toggle-ev_88"]');
expect(button.find(".max-md\\:sr-only").text()).toBe("Mark read");
});
});
@@ -72,7 +72,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@row-click="openPage"
>
<template #cell-page="{ row }">
<span class="flex min-w-0 flex-col gap-0.5">
<span class="flex min-w-0 flex-col gap-0.5 max-lg:py-1.5">
<span class="flex items-center gap-1.5">
<!-- Unread is a dot, not a bold row: a list where most rows shout
is a list where none of them do. -->
@@ -120,10 +120,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OButton
variant="ghost"
size="xs"
class="max-md:ps-2 max-md:pe-2"
:data-test="`oncall-my-delivery-toggle-${row.event_id}`"
@click.stop="toggleRead(row)"
>
{{ row.read ? t("oncall.myDeliveriesMarkUnread") : t("oncall.myDeliveriesMarkRead") }}
<OIcon :name="row.read ? 'mail' : 'check'" size="sm" class="md:hidden" />
<span class="max-md:sr-only">
{{ row.read ? t("oncall.myDeliveriesMarkUnread") : t("oncall.myDeliveriesMarkRead") }}
</span>
</OButton>
</template>
</OTable>
@@ -146,6 +150,7 @@ import { useStore } from "vuex";
import OTag from "@/lib/core/Badge/OTag.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTable from "@/lib/core/Table/OTable.vue";
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
import OText from "@/lib/core/Typography/OText.vue";
@@ -282,4 +282,52 @@ describe("OnCallOwnershipRules", () => {
expect(wrapper.emitted("clear-search")).toBeUndefined();
});
});
/// A phone's action column holds one control, so edit and remove are mirrored in a menu that must emit the same events.
describe("the phone row menu", () => {
const menuStubs = {
...stubs,
OTable: {
name: "OTable",
props: ["data"],
template: `<div><div v-for="row in data" :key="row.rule_id"><slot name="cell-actions" :row="row" /></div></div>`,
},
ODropdown: { name: "ODropdown", template: "<div><slot name='trigger' /><slot /></div>" },
ODropdownItem: {
name: "ODropdownItem",
emits: ["select"],
template: `<button @click="$emit('select')"><slot /></button>`,
},
};
function renderWithMenu() {
return mount(OnCallOwnershipRules, {
props: { rules: [rule()], aliases: [{ id: "service", display: "Service" }] },
global: { plugins: [i18n, store], stubs: menuStubs },
});
}
it("shows the buttons from md up and the menu below it, never both", () => {
const wrapper = renderWithMenu();
for (const action of ["edit", "delete"]) {
expect(wrapper.find(`[data-test="oncall-ownership-${action}-r1"]`).classes()).toContain(
"max-md:hidden",
);
}
expect(wrapper.find('[data-test="oncall-ownership-row-more-actions"]').classes()).toContain(
"md:hidden",
);
});
it("edits and removes the rule the row is for", async () => {
const wrapper = renderWithMenu();
await wrapper.find('[data-test="oncall-ownership-edit-r1-menu"]').trigger("click");
await wrapper.find('[data-test="oncall-ownership-delete-r1-menu"]').trigger("click");
expect(wrapper.emitted("edit")?.[0][0]).toMatchObject({ rule_id: "r1" });
expect(wrapper.emitted("remove")?.[0][0]).toMatchObject({ rule_id: "r1" });
});
});
});
@@ -113,6 +113,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
variant="ghost"
size="icon-sm"
icon-left="edit"
class="max-md:hidden"
:aria-label="t('oncall.edit')"
:data-test="`oncall-ownership-edit-${row.rule_id}`"
@click.stop="emit('edit', row)"
@@ -123,10 +124,41 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
variant="ghost"
size="icon-sm"
icon-left="delete-outline"
class="max-md:hidden"
:aria-label="t('oncall.removeRule')"
:data-test="`oncall-ownership-delete-${row.rule_id}`"
@click.stop="emit('remove', row)"
/>
<ODropdown side="bottom" align="end">
<template #trigger>
<OButton
icon-left="more-vert"
variant="ghost"
size="icon-xs-sq"
class="md:hidden"
:aria-label="t('oncall.moreActions')"
data-test="oncall-ownership-row-more-actions"
@click.stop
/>
</template>
<ODropdownItem
icon-left="edit"
class="md:hidden"
:data-test="`oncall-ownership-edit-${row.rule_id}-menu`"
@select="emit('edit', row)"
>
<span>{{ t("oncall.edit") }}</span>
</ODropdownItem>
<ODropdownItem
icon-left="delete-outline"
variant="destructive"
class="md:hidden"
:data-test="`oncall-ownership-delete-${row.rule_id}-menu`"
@select="emit('remove', row)"
>
<span>{{ t("oncall.removeRule") }}</span>
</ODropdownItem>
</ODropdown>
</span>
</template>
@@ -164,6 +196,8 @@ import OTable from "@/lib/core/Table/OTable.vue";
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
import OTimeCell from "@/lib/core/Table/cells/OTimeCell.vue";
import OText from "@/lib/core/Typography/OText.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import type { OwnershipRuleHealth, OwnershipRuleStats } from "@/ts/interfaces/oncall";
import type { I18nText } from "@/types/i18n";
@@ -132,7 +132,7 @@
step's own offset cell, where the reader is already looking. -->
<div
v-if="stepIndex > 0"
class="border-border-default flex flex-wrap items-center gap-2 border-t py-2 ps-24 pe-3"
class="border-border-default flex flex-wrap items-center gap-2 border-t py-2 ps-24 pe-3 max-md:ps-3"
>
<OText variant="label">{{ t("oncall.policyIfNoAck") }}</OText>
<span class="w-36">
@@ -14,13 +14,28 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import OnCallRecentPages from "@/components/oncall/OnCallRecentPages.vue";
import i18n from "@/locales";
import type { OnCallPolicy, OnCallResponse } from "@/ts/interfaces/oncall";
const MINUTE = 60_000_000;
// Mutable per case, so the phone tests can switch while every other test stays desktop.
const mockViewport = vi.hoisted(() => ({ mdUp: true, lgUp: true }));
vi.mock("@/composables/useBreakpoint", async () => {
const { computed } = await import("vue");
return {
default: () => ({
isMobile: computed(() => !mockViewport.mdUp),
isTablet: computed(() => mockViewport.mdUp && !mockViewport.lgUp),
isDesktop: computed(() => mockViewport.lgUp),
mdUp: computed(() => mockViewport.mdUp),
lgUp: computed(() => mockViewport.lgUp),
}),
};
});
const OPENED = 1_700_000_000_000_000;
const page = (over: Partial<OnCallResponse> = {}): OnCallResponse => ({
@@ -88,6 +103,23 @@ const rows = (wrapper: ReturnType<typeof render>) =>
wrapper.findAll('[data-test^="oncall-recent-pages-row-"]');
describe("OnCallRecentPages", () => {
afterEach(() => {
mockViewport.mdUp = true;
mockViewport.lgUp = true;
});
/// A phone table scrolls rather than flexing, and the default column width left the alert name a few characters.
it("gives the name column a real width on a phone only", () => {
const subject = (wrapper: ReturnType<typeof render>) =>
(wrapper.findComponent({ name: "OTable" }).props("columns") as any[]).find(
(column) => column.id === "subject",
);
expect(subject(render([page()])).size).toBeUndefined();
mockViewport.mdUp = false;
expect(subject(render([page()])).size).toBe(200);
});
/// The list is a sample, and a sample that does not say so reads as the whole
/// history of the team.
it("says how many of the window's pages it is showing", () => {
@@ -55,7 +55,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@row-click="(row: OnCallResponse) => emit('open', row)"
>
<template #cell-subject="{ row }">
<span class="flex min-w-0 items-center gap-2">
<span
class="flex min-w-0 items-center gap-2 max-lg:flex-col max-lg:items-start max-lg:gap-1 max-lg:py-1.5"
>
<OCodeCell :value="row.title || row.subject.source_id" :copy="false" />
<!-- Only the two outcomes worth a second look are labelled: a page
answered by the first person it woke is the norm, and tagging the
@@ -95,6 +97,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<script setup lang="ts">
import { computed } from "vue";
import useBreakpoint from "@/composables/useBreakpoint";
import OTag from "@/lib/core/Badge/OTag.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
@@ -128,6 +131,7 @@ const props = withDefaults(
const emit = defineEmits<{ open: [page: OnCallResponse]; "view-all": [] }>();
const { t } = useI18nTyped();
const { isMobile } = useBreakpoint();
/// The delay before this record's ladder would have woken a SECOND person.
function secondRungDelay(page: OnCallResponse): number | null {
@@ -151,6 +155,8 @@ const columns = computed<OTableColumnDef<OnCallResponse>[]>(() => [
id: "subject",
header: t("oncall.subjectColumn"),
accessorFn: (page: OnCallResponse) => page.title || page.subject.source_id,
// A phone table scrolls instead of flexing, and the default width there leaves the alert name a few characters.
...(isMobile.value ? { size: 200 } : {}),
meta: { isName: true, flex: true },
},
{
+38 -29
View File
@@ -119,35 +119,40 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OText>
</span>
<!-- A rule repointed at another team stays visible until the list
refetches, and "it pages us" would be a lie on that row. -->
<OTag
v-if="row.rule.team_id !== teamId"
variant="default-soft"
size="sm"
class="shrink-0"
:data-test="`oncall-routing-elsewhere-${row.rule.rule_id}`"
<!-- A phone row cannot hold the sentence beside its evidence, so there the evidence takes the line below it. -->
<span
class="contents max-md:order-last max-md:flex max-md:basis-full max-md:flex-wrap max-md:items-center max-md:gap-2 max-md:ps-10"
>
{{ t("oncall.routingPagesTeam", { team: teamNameOf(row.rule.team_id) }) }}
</OTag>
<span class="shrink-0" :data-test="`oncall-routing-caught-${row.rule.rule_id}`">
<OTag v-if="!row.rule.last_matched_at" variant="default-soft" size="sm">
{{ t("oncall.routingRuleNeverMatched") }}
</OTag>
<i18n-t
v-else
keypath="oncall.routingCaughtLast"
:plural="row.rule.pages_caught"
tag="span"
scope="global"
class="text-text-secondary flex items-center gap-1 text-xs"
<!-- A rule repointed at another team stays visible until the list
refetches, and "it pages us" would be a lie on that row. -->
<OTag
v-if="row.rule.team_id !== teamId"
variant="default-soft"
size="sm"
class="shrink-0"
:data-test="`oncall-routing-elsewhere-${row.rule.rule_id}`"
>
<template #count>{{ row.rule.pages_caught }}</template>
<template #when>
<OTimeCell :value="row.rule.last_matched_at" unit="us" />
</template>
</i18n-t>
{{ t("oncall.routingPagesTeam", { team: teamNameOf(row.rule.team_id) }) }}
</OTag>
<span class="shrink-0" :data-test="`oncall-routing-caught-${row.rule.rule_id}`">
<OTag v-if="!row.rule.last_matched_at" variant="default-soft" size="sm">
{{ t("oncall.routingRuleNeverMatched") }}
</OTag>
<i18n-t
v-else
keypath="oncall.routingCaughtLast"
:plural="row.rule.pages_caught"
tag="span"
scope="global"
class="text-text-secondary flex items-center gap-1 text-xs"
>
<template #count>{{ row.rule.pages_caught }}</template>
<template #when>
<OTimeCell :value="row.rule.last_matched_at" unit="us" />
</template>
</i18n-t>
</span>
</span>
<OButton
@@ -205,7 +210,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OText variant="meta">{{ t("oncall.routingEverythingElseHint") }}</OText>
</span>
<OText variant="meta" class="shrink-0" data-test="oncall-routing-catch-all-volume">
<OText
variant="meta"
class="shrink-0 max-md:order-last max-md:basis-full max-md:ps-10"
data-test="oncall-routing-catch-all-volume"
>
{{ openSignals.length ? volumeNote : t("oncall.routingNothingLanded") }}
</OText>
@@ -315,7 +324,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
class="border-border-subtle flex flex-wrap items-center gap-x-3 gap-y-1 border-b py-2 last:border-b-0"
:data-test="`oncall-routing-signal-${signal.id}`"
>
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="flex min-w-0 flex-1 flex-col gap-0.5 max-md:basis-full">
<span class="text-text-heading truncate text-sm font-medium">{{
titleOf(signal)
}}</span>
@@ -97,7 +97,10 @@
rest with everybody above. Offering an empty picker instead
read as a required field nobody knew how to fill. -->
<template v-if="row.optional && !overridden[row.key]">
<OText variant="meta" class="min-w-0 flex-1">
<OText
variant="meta"
class="min-w-0 flex-1 max-md:order-last max-md:basis-full max-md:ps-4"
>
{{ t("oncall.presetCatchAllDefault") }}
</OText>
<OButton
@@ -14,7 +14,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { flushPromises, mount } from "@vue/test-utils";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { nextTick } from "vue";
import OnCallScheduleTimeline from "@/components/oncall/OnCallScheduleTimeline.vue";
@@ -26,6 +26,21 @@ import { MICROS_PER_DAY, MICROS_PER_WEEK } from "@/ts/interfaces/oncall";
vi.mock("@/services/users", () => ({ default: { orgUsers: vi.fn() } }));
// Mutable per case, so the phone tests can switch while every other test stays desktop.
const mockViewport = vi.hoisted(() => ({ mdUp: true, lgUp: true }));
vi.mock("@/composables/useBreakpoint", async () => {
const { computed } = await import("vue");
return {
default: () => ({
isMobile: computed(() => !mockViewport.mdUp),
isTablet: computed(() => mockViewport.mdUp && !mockViewport.lgUp),
isDesktop: computed(() => mockViewport.lgUp),
mdUp: computed(() => mockViewport.mdUp),
lgUp: computed(() => mockViewport.lgUp),
}),
};
});
const stubs = {
OText: { name: "OText", template: "<span><slot /></span>" },
OInnerLoading: { name: "OInnerLoading", template: "<div />" },
@@ -117,6 +132,41 @@ const tracksOf = (wrapper: any) =>
wrapper.findComponent({ name: "OScheduleTimeline" }).props("tracks");
describe("OnCallScheduleTimeline", () => {
afterEach(() => {
mockViewport.mdUp = true;
mockViewport.lgUp = true;
});
/// A phone's day column is narrower than "Wed · today", which ran into the next day's label.
it("labels today with the bare weekday on a phone, and keeps it emphasised", () => {
const todayOf = (wrapper: any) =>
wrapper
.findComponent({ name: "OScheduleTimeline" })
.props("axisTicks")
.find((tick: any) => tick.emphasis);
expect(String(todayOf(render()).sublabel)).toContain("today");
mockViewport.mdUp = false;
expect(String(todayOf(render()).sublabel)).not.toContain("today");
});
/// A phone row fits the range and one button, so the two rarer acts are mirrored in a menu that must emit the same events.
it("offers quick start and request cover from the phone menu", async () => {
const wrapper = render();
await wrapper.find('[data-test="oncall-timeline-presets-menu"]').trigger("click");
await wrapper.find('[data-test="oncall-timeline-request-cover-menu"]').trigger("click");
expect(wrapper.emitted("presets")).toHaveLength(1);
expect(wrapper.emitted("request-cover")).toHaveLength(1);
expect(wrapper.find('[data-test="oncall-timeline-presets"]').classes()).toContain(
"max-md:hidden",
);
expect(wrapper.find('[data-test="oncall-timeline-more-actions"]').classes()).toContain(
"md:hidden",
);
});
it("draws one lane per rotation", () => {
const wrapper = render({
rotations: [rotation("Primary"), rotation("Secondary")],
@@ -71,9 +71,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- The team's zone AND the reader's, because the two are usually
different and a handover time is meaningless until you know which. -->
<OText variant="meta" data-test="oncall-timeline-zone">{{ zoneLine }}</OText>
<OText
variant="meta"
class="max-md:order-last max-md:basis-full"
data-test="oncall-timeline-zone"
>
{{ zoneLine }}
</OText>
<span class="ms-auto flex flex-wrap items-center gap-2">
<span class="ms-auto flex flex-wrap items-center gap-2 max-md:ms-0 max-md:w-full">
<OToggleGroup
:model-value="rangeKey"
type="single"
@@ -111,6 +117,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OButton
variant="outline"
size="sm-action"
class="max-md:hidden"
data-test="oncall-timeline-presets"
@click="emit('presets')"
>
@@ -125,11 +132,41 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
v-if="canCover"
variant="outline"
size="sm-action"
class="max-md:hidden"
data-test="oncall-timeline-request-cover"
@click="emit('request-cover')"
>
{{ t("oncall.requestCover") }}
</OButton>
<!-- A phone row fits the range and one button; the two rarer acts share a menu rather than a second row. -->
<ODropdown side="bottom" align="end">
<template #trigger>
<OButton
icon-left="more-vert"
variant="outline"
size="icon-sm"
class="ms-auto md:hidden"
:aria-label="t('oncall.moreActions')"
data-test="oncall-timeline-more-actions"
/>
</template>
<ODropdownItem
class="md:hidden"
data-test="oncall-timeline-presets-menu"
@select="emit('presets')"
>
<span>{{ t("oncall.presetsTitle") }}</span>
</ODropdownItem>
<ODropdownItem
v-if="canCover"
class="md:hidden"
data-test="oncall-timeline-request-cover-menu"
@select="emit('request-cover')"
>
<span>{{ t("oncall.requestCover") }}</span>
</ODropdownItem>
</ODropdown>
</span>
</div>
@@ -170,7 +207,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
aria-hidden="true"
/>
<OText variant="section">{{ track.label }}</OText>
<OText variant="meta" :data-test="`oncall-lane-cadence-${track.key}`">
<OText
variant="meta"
class="max-md:order-last max-md:basis-full"
:data-test="`oncall-lane-cadence-${track.key}`"
>
{{ laneOf(track).cadence }}
</OText>
@@ -312,6 +353,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { computed, onMounted, ref, watch } from "vue";
import { useStore } from "vuex";
import useBreakpoint from "@/composables/useBreakpoint";
import { useOnCallClock } from "@/composables/useOnCallClock";
import OTag from "@/lib/core/Badge/OTag.vue";
import OButton from "@/lib/core/Button/OButton.vue";
@@ -381,6 +423,7 @@ const emit = defineEmits<{
const window = defineModel<{ from: number; to: number }>("window", { required: true });
const { t } = useI18nTyped();
const { isMobile } = useBreakpoint();
const store = useStore();
const nowMicros = useOnCallClock();
@@ -723,8 +766,9 @@ const axisTicks = computed<ScheduleAxisTick[]>(() => {
ticks.push({
offset: share(at),
label: raw(fmt(at, { day: "numeric" })),
// A phone column is narrower than "Wed · today", which would run into the next day; the emphasis still marks it.
sublabel:
at === today
at === today && !isMobile.value
? t("oncall.schedAxisToday", { weekday: raw(fmt(at, { weekday: "short" })) })
: raw(fmt(at, { weekday: "short" })),
emphasis: at === today,
@@ -42,10 +42,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="oncall-setup-banner"
>
<span class="flex min-w-0 items-center gap-x-3">
<span class="text-text-secondary text-2xs shrink-0 tracking-wide uppercase">
<span class="text-text-secondary text-2xs shrink-0 tracking-wide uppercase max-md:hidden">
{{ t("oncall.setupFinish") }}
</span>
<span class="text-text-body min-w-0 truncate text-sm" data-test="oncall-setup-next">
<span
class="text-text-body min-w-0 truncate text-sm max-lg:line-clamp-2 max-lg:whitespace-normal"
data-test="oncall-setup-next"
>
{{ t("oncall.setupNextStep", { step: nextStep.title }) }}
</span>
</span>
@@ -86,7 +86,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Cadence and anchor are one decision — "every week, starting then" —
so they sit on one row rather than reading as two unrelated
questions. -->
<div class="grid gap-3 sm:grid-cols-2">
<!-- Two columns only from lg: below it the drawer is too narrow for three fields to share half of it. -->
<div class="grid gap-3 max-lg:grid-cols-1 lg:grid-cols-2">
<OSelect
v-model="rule.shift_micros"
:label="t('oncall.shiftLength')"
@@ -97,7 +98,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
at 14:32 handed over at 14:32 forever. Timezone rides along as a
disabled third field rather than a hint — there is one zone per
schedule, set on the team, so it is shown here, not chosen here. -->
<div class="flex gap-2">
<div class="flex gap-2 max-md:flex-wrap">
<ODate
class="min-w-0 flex-1"
:model-value="handoverDate"
@@ -114,7 +115,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@update:model-value="setHandoverTime"
/>
<OSelect
class="min-w-0 flex-1"
class="min-w-0 flex-1 max-md:basis-full"
:model-value="timezone"
disabled
:label="t('oncall.timezone')"
@@ -41,7 +41,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="oncall-team-attention"
>
<span class="flex min-w-0 items-center gap-x-3">
<span class="text-text-secondary text-2xs shrink-0 tracking-wide uppercase">
<span class="text-text-secondary text-2xs shrink-0 tracking-wide uppercase max-md:hidden">
{{ t("oncall.attentionHeading") }}
</span>
<span class="flex min-w-0 items-center gap-1.5" data-test="oncall-attention-worst">
@@ -52,7 +52,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
<!-- The worst finding whole, on one line. The count moved into the
disclosure button, so nothing here can wrap the strip. -->
<span class="text-text-body min-w-0 truncate text-sm">
<!-- Touch has no hover to read a clipped sentence by, so below lg it gets a second line instead. -->
<span
class="text-text-body min-w-0 truncate text-sm max-lg:line-clamp-2 max-lg:whitespace-normal"
>
{{ worst.message }}
<OTooltip side="bottom" :content="worst.message" />
</span>
+2 -2
View File
@@ -118,7 +118,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
with it on the same row — a date and time mean nothing on their
own, and this is the one place in create mode that asks for the
zone at all. -->
<div class="flex gap-2" data-test="oncall-team-form-handover">
<div class="flex gap-2 max-md:flex-wrap" data-test="oncall-team-form-handover">
<OFormDate
class="min-w-0 flex-1"
name="first_handover_date"
@@ -133,7 +133,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="oncall-team-form-handover-time"
/>
<OFormSelect
class="min-w-0 flex-1"
class="min-w-0 flex-1 max-md:basis-full"
name="timezone"
:label="t('oncall.timezone')"
:options="timezoneOptions"
@@ -230,4 +230,47 @@ describe("OnCallUnroutedQueue", () => {
expect(wrapper.emitted("retry")).toHaveLength(1);
});
});
/// A phone's action column holds one control, so claim and dismiss are mirrored in a menu that must emit the same events.
describe("the phone row menu", () => {
const menuStubs = {
...stubs,
ODropdown: { name: "ODropdown", template: "<div><slot name='trigger' /><slot /></div>" },
ODropdownItem: {
name: "ODropdownItem",
emits: ["select"],
template: `<button @click="$emit('select')"><slot /></button>`,
},
};
function renderWithMenu() {
return mount(OnCallUnroutedQueue, {
props: { signals: [signal()], teamName: "Payments" },
global: { plugins: [i18n, store], stubs: menuStubs },
});
}
it("shows the buttons from md up and the menu below it, never both", () => {
const wrapper = renderWithMenu();
for (const action of ["claim", "dismiss"]) {
expect(wrapper.find(`[data-test="oncall-unrouted-${action}-s1"]`).classes()).toContain(
"max-md:hidden",
);
}
expect(wrapper.find('[data-test="oncall-unrouted-row-more-actions"]').classes()).toContain(
"md:hidden",
);
});
it("claims and dismisses the signal the row is for", async () => {
const wrapper = renderWithMenu();
await wrapper.find('[data-test="oncall-unrouted-claim-s1-menu"]').trigger("click");
await wrapper.find('[data-test="oncall-unrouted-dismiss-s1-menu"]').trigger("click");
expect(wrapper.emitted("claim")?.[0][0]).toMatchObject({ id: "s1" });
expect(wrapper.emitted("dismiss")?.[0][0]).toMatchObject({ id: "s1" });
});
});
});
@@ -148,23 +148,50 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OButton
variant="outline"
size="xs"
class="max-md:hidden"
:data-test="`oncall-unrouted-claim-${row.id}`"
@click.stop="emit('claim', row)"
>
{{
teamName
? t("oncall.unroutedClaimFor", { team: raw(teamName) })
: t("oncall.unroutedWriteRule")
}}
{{ claimLabel }}
</OButton>
<OButton
variant="ghost"
size="icon-sm"
icon-left="close"
class="max-md:hidden"
:aria-label="t('oncall.unroutedDismiss')"
:data-test="`oncall-unrouted-dismiss-${row.id}`"
@click.stop="emit('dismiss', row)"
/>
<ODropdown side="bottom" align="end">
<template #trigger>
<OButton
icon-left="more-vert"
variant="ghost"
size="icon-xs-sq"
class="md:hidden"
:aria-label="t('oncall.moreActions')"
data-test="oncall-unrouted-row-more-actions"
@click.stop
/>
</template>
<ODropdownItem
icon-left="alt-route"
class="md:hidden"
:data-test="`oncall-unrouted-claim-${row.id}-menu`"
@select="emit('claim', row)"
>
<span>{{ claimLabel }}</span>
</ODropdownItem>
<ODropdownItem
icon-left="close"
class="md:hidden"
:data-test="`oncall-unrouted-dismiss-${row.id}-menu`"
@select="emit('dismiss', row)"
>
<span>{{ t("oncall.unroutedDismiss") }}</span>
</ODropdownItem>
</ODropdown>
</span>
</template>
@@ -203,6 +230,8 @@ import OTable from "@/lib/core/Table/OTable.vue";
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
import OTimeCell from "@/lib/core/Table/cells/OTimeCell.vue";
import OText from "@/lib/core/Typography/OText.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import type { UnroutedSignal } from "@/ts/interfaces/oncall";
import type { I18nText } from "@/types/i18n";
import { raw, useI18nTyped } from "@/types/i18n";
@@ -251,6 +280,12 @@ const emit = defineEmits<{
const { t } = useI18nTyped();
const claimLabel = computed<I18nText>(() =>
props.teamName
? t("oncall.unroutedClaimFor", { team: raw(props.teamName) })
: t("oncall.unroutedWriteRule"),
);
const columns = computed<OTableColumnDef<UnroutedSignal>[]>(() => [
{
id: "signal",
@@ -78,6 +78,17 @@ describe("OScheduleTimeline", () => {
expect(w.text()).toContain("Base rotation");
});
// An unlabelled gutter is dead width a phone needs for the plot; a labelled one must stay at every width.
it("drops the gutter on phones only when no track is labelled", () => {
const gutterOf = (tracks: ScheduleTrack[]) =>
mount(OScheduleTimeline, { props: { tracks } })
.find("[data-test='o2-schedule-track-t1'] > div")
.classes();
expect(gutterOf([track({ label: raw("") })])).toContain("max-md:hidden");
expect(gutterOf([track()])).not.toContain("max-md:hidden");
});
it("renders axis ticks only when supplied", () => {
const without = mount(OScheduleTimeline, { props: { tracks: [track()] } });
expect(without.text()).not.toContain("Mon");
@@ -37,7 +37,11 @@ const LABEL_WIDTH_CLASS = {
md: "w-32",
} as const;
const labelClass = computed(() => LABEL_WIDTH_CLASS[props.labelWidth]);
// A gutter no track writes into is dead width, which a phone cannot spare for the plot.
const gutterClass = computed(() => [
LABEL_WIDTH_CLASS[props.labelWidth],
props.tracks.some((track) => track.label) ? "" : "max-md:hidden",
]);
const guides = computed(() => props.dayColumns.map((offset) => clamp01(offset)));
@@ -115,7 +119,7 @@ const pillSide = (offset: number) => (offset > 0.85 ? "-translate-x-full -ms-1.5
<!-- Axis — labels sit above the plot area, aligned to the same 0-1 scale. -->
<div v-if="ticks.length" class="flex items-end">
<div v-if="!laneHeaders" :class="[labelClass, 'shrink-0']" />
<div v-if="!laneHeaders" :class="[gutterClass, 'shrink-0']" />
<div
:class="[
'text-text-secondary text-2xs relative min-w-0 flex-1',
@@ -155,7 +159,7 @@ const pillSide = (offset: number) => (offset > 0.85 ? "-translate-x-full -ms-1.5
<slot v-if="laneHeaders" name="track-header" :track="track" />
<div
v-else
:class="[labelClass, 'text-text-secondary flex shrink-0 items-center truncate text-xs']"
:class="[gutterClass, 'text-text-secondary flex shrink-0 items-center truncate text-xs']"
>
{{ track.label }}
</div>
+59 -2
View File
@@ -1,5 +1,21 @@
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import { afterEach, describe, it, expect, vi } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
// Mutable per case, so the narrow-viewport tests can switch while every other test stays desktop.
const mockViewport = vi.hoisted(() => ({ mdUp: true, lgUp: true }));
vi.mock("@/composables/useBreakpoint", async () => {
const { computed } = await import("vue");
return {
default: () => ({
isMobile: computed(() => !mockViewport.mdUp),
isTablet: computed(() => mockViewport.mdUp && !mockViewport.lgUp),
isDesktop: computed(() => mockViewport.lgUp),
mdUp: computed(() => mockViewport.mdUp),
lgUp: computed(() => mockViewport.lgUp),
}),
};
});
import OTabs from "./OTabs.vue";
import OTab from "./OTab.vue";
@@ -297,4 +313,45 @@ describe("OTabs", () => {
expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([2]);
expect(wrapper.emitted("change")?.[0]).toEqual([2]);
});
// --- Revealing the active tab on mount ---
describe("a deep link to a tab the strip has scrolled out of view", () => {
const scrollBy = vi.fn();
const realRect = HTMLElement.prototype.getBoundingClientRect;
// jsdom lays nothing out, so the strip is given a window 300 wide with the active tab beyond its right edge.
function stubGeometry() {
(HTMLElement.prototype as any).scrollBy = scrollBy;
HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) {
const active = this.getAttribute("aria-selected") === "true";
return { left: active ? 500 : 0, right: active ? 600 : 300 } as DOMRect;
};
}
afterEach(() => {
mockViewport.mdUp = true;
mockViewport.lgUp = true;
HTMLElement.prototype.getBoundingClientRect = realRect;
delete (HTMLElement.prototype as any).scrollBy;
scrollBy.mockClear();
});
it("scrolls it into view below lg, without animating", async () => {
mockViewport.lgUp = false;
stubGeometry();
mountTabs({ modelValue: "tab3" });
await flushPromises();
expect(scrollBy).toHaveBeenCalledWith({ left: 308, behavior: "auto" });
});
it("leaves the laptop strip where it is", async () => {
stubGeometry();
mountTabs({ modelValue: "tab3" });
await flushPromises();
expect(scrollBy).not.toHaveBeenCalled();
});
});
});
+20 -11
View File
@@ -5,6 +5,9 @@ import { TABS_CONTEXT_KEY } from "./OTabs.types";
import type { TabsContext } from "./OTabs.types";
import { TabsRoot, TabsList } from "reka-ui";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import useBreakpoint from "@/composables/useBreakpoint";
const { lgUp } = useBreakpoint();
const scrollRef = ref<HTMLElement | null>(null);
const tablistRef = ref<HTMLElement | null>(null);
@@ -281,6 +284,8 @@ onMounted(() => {
}
nextTick(() => {
updateScrollState();
// A deep link can open on a tab the narrow strip has scrolled out of view, leaving nothing marked active.
if (!lgUp.value) revealActiveTab("auto");
// Enable the slide animation only after the bar is placed once.
requestAnimationFrame(() => {
indicatorReady.value = true;
@@ -295,6 +300,20 @@ onUnmounted(() => {
cancelAnimationFrame(scrollStateRaf);
});
function revealActiveTab(behavior: ScrollBehavior): void {
const el = scrollRef.value;
if (!el) return;
const activeTab = el.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]');
if (!activeTab) return;
const tabRect = activeTab.getBoundingClientRect();
const containerRect = el.getBoundingClientRect();
if (tabRect.left < containerRect.left) {
el.scrollBy({ left: tabRect.left - containerRect.left - 8, behavior });
} else if (tabRect.right > containerRect.right) {
el.scrollBy({ left: tabRect.right - containerRect.right + 8, behavior });
}
}
// Auto-scroll to reveal the active tab when modelValue changes
watch(
() => props.modelValue,
@@ -303,17 +322,7 @@ watch(
await nextTick();
// Slide the shared underline to the newly active tab.
updateIndicator();
const el = scrollRef.value;
if (!el) return;
const activeTab = el.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]');
if (!activeTab) return;
const tabRect = activeTab.getBoundingClientRect();
const containerRect = el.getBoundingClientRect();
if (tabRect.left < containerRect.left) {
el.scrollBy({ left: tabRect.left - containerRect.left - 8, behavior: "smooth" });
} else if (tabRect.right > containerRect.right) {
el.scrollBy({ left: tabRect.right - containerRect.right + 8, behavior: "smooth" });
}
revealActiveTab("smooth");
},
);
@@ -165,6 +165,20 @@ describe("ODrawer", () => {
});
});
// A subtitle wider than a phone-sized panel pushed the close button off screen while the block could not shrink.
it("lets the title block shrink below lg, and only there", () => {
const wrapper = mount(ODrawer, {
props: { open: true, title: "Quick start", subTitle: "A sentence longer than the panel" },
});
const title = wrapper
.findAll("span")
.find((span) => span.classes().includes("truncate") && span.text().includes("Quick start"));
const block = title!.element.parentElement!;
expect(block.classList.contains("shrink-0")).toBe(true);
expect(block.classList.contains("max-lg:shrink")).toBe(true);
});
describe("sticky layout structure", () => {
it("header has shrink-0 class (pinned, never scrolls)", () => {
const wrapper = mount(ODrawer, {
+2 -2
View File
@@ -433,8 +433,8 @@ watch(internalOpen, (open) => {
<!-- CASE 2: Default / structured layout -->
<template v-else>
<!-- Title + subtitle block — fixed width, never grows -->
<div v-if="title || subTitle" class="min-w-0 shrink-0">
<!-- Below lg a subtitle longer than the panel must truncate, or it pushes the close button off screen. -->
<div v-if="title || subTitle" class="min-w-0 shrink-0 max-lg:shrink">
<span
v-if="title"
class="text-dialog-header-text block truncate text-base font-semibold"
@@ -14,7 +14,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { flushPromises, mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/locales";
import { queryClient } from "@/composables/query/queryClient";
@@ -75,6 +75,21 @@ vi.mock("vue-router", async () => {
};
});
// Mutable per case, so the tablet and phone tests can switch while every other test stays desktop.
const mockViewport = vi.hoisted(() => ({ mdUp: true, lgUp: true }));
vi.mock("@/composables/useBreakpoint", async () => {
const { computed } = await import("vue");
return {
default: () => ({
isMobile: computed(() => !mockViewport.mdUp),
isTablet: computed(() => mockViewport.mdUp && !mockViewport.lgUp),
isDesktop: computed(() => mockViewport.lgUp),
mdUp: computed(() => mockViewport.mdUp),
lgUp: computed(() => mockViewport.lgUp),
}),
};
});
const service = vi.mocked(oncallService);
const alerts = vi.mocked(alertsService);
@@ -175,9 +190,13 @@ const stubs = {
ODropdown: { name: "ODropdown", template: "<div><slot name='trigger' /><slot /></div>" },
ODropdownItem: {
name: "ODropdownItem",
props: ["disabled"],
emits: ["select"],
template: `<button @click="$emit('select')"><slot /></button>`,
template: `<button :disabled="disabled" @click="$emit('select')"><slot /></button>`,
},
// The compact header's menu uses both; unstubbed they need a reka-ui menu root the ODropdown stub does not provide.
ODropdownGroup: { name: "ODropdownGroup", props: ["label"], template: "<div><slot /></div>" },
ODropdownSeparator: { name: "ODropdownSeparator", template: "<hr />" },
OToggleGroup: { name: "OToggleGroup", template: "<div><slot /></div>" },
OToggleGroupItem: {
name: "OToggleGroupItem",
@@ -1327,4 +1346,89 @@ describe("OnCallResponseDetail — what the payload already knew", () => {
expect(String(call.message)).toContain("last step");
});
});
/// Seven header buttons wrap to three rows on a tablet or phone, so everything but claiming and closing shares one menu.
describe("below the laptop breakpoint", () => {
beforeEach(() => {
mockViewport.lgUp = false;
});
afterEach(() => {
mockViewport.mdUp = true;
mockViewport.lgUp = true;
});
it("keeps acknowledge and resolve on the row and folds the rest into one menu", async () => {
const wrapper = await renderWith();
for (const kept of ["ack-btn", "resolve-btn", "more-btn"]) {
expect(wrapper.find(`[data-test="oncall-response-${kept}"]`).exists()).toBe(true);
}
for (const folded of ["escalate-btn", "handoff-btn", "promote-btn"]) {
expect(wrapper.find(`[data-test="oncall-response-${folded}"]`).exists()).toBe(false);
expect(wrapper.find(`[data-test="oncall-response-${folded}-menu"]`).exists()).toBe(true);
}
expect(wrapper.find('[data-test="oncall-response-snooze-btn"]').exists()).toBe(false);
expect(wrapper.find('[data-test="oncall-response-snooze-30-menu"]').exists()).toBe(true);
});
it("snoozes from the menu", async () => {
const wrapper = await renderWith();
service.snoozeResponse.mockResolvedValue({ data: {} } as any);
await wrapper.find('[data-test="oncall-response-snooze-30-menu"]').trigger("click");
await flushPromises();
expect(service.snoozeResponse).toHaveBeenCalledWith(
expect.objectContaining({ response_id: "resp_1" }),
);
});
it("opens the hand-off drawer from the menu", async () => {
const wrapper = await renderWith();
await wrapper.find('[data-test="oncall-response-handoff-btn-menu"]').trigger("click");
expect(wrapper.find('[data-test="oncall-handoff-submit"]').exists()).toBe(true);
});
/// A greyed-out verb has no tooltip to explain it on touch, so the reason is printed on the item.
it("says on the item why escalate is unavailable", async () => {
service.escalationProgress.mockResolvedValue({
data: { fired: [], next_targets: [], next_at: null, exhausted: true },
} as any);
const wrapper = await renderWith();
const item = wrapper.find('[data-test="oncall-response-escalate-btn-menu"]');
expect(item.attributes("disabled")).toBeDefined();
expect(item.text()).toContain("nobody left to escalate to");
});
it("drops the menu when a closed page is already tied to an incident", async () => {
const wrapper = await renderWith({
state: "resolved",
closed_at: 1_700_000_100_000_000,
incident_id: "inc_9",
});
expect(wrapper.find('[data-test="oncall-response-more-btn"]').exists()).toBe(false);
});
/// The title row has no room for both the alert's name and its tags, so the tags open the body instead.
it("leads the body with the state and elapsed time", async () => {
const wrapper = await renderWith();
const meta = wrapper.find('[data-test="oncall-response-meta"]');
expect(meta.find('[data-test="oncall-response-elapsed"]').exists()).toBe(true);
expect(wrapper.findAll('[data-test="oncall-response-elapsed"]')).toHaveLength(1);
});
it("applies on a phone too", async () => {
mockViewport.mdUp = false;
const wrapper = await renderWith();
expect(wrapper.find('[data-test="oncall-response-more-btn"]').exists()).toBe(true);
expect(wrapper.find('[data-test="oncall-response-meta"]').exists()).toBe(true);
});
});
});
+89 -4
View File
@@ -49,7 +49,8 @@
<!-- The two facts a responder needs before anything else ride the title,
rather than sitting in a metadata grid below the fold. -->
<template #title-trail>
<template v-if="response">
<!-- Below lg the title row has room for the alert's name or its tags, not both; there the tags lead the body instead. -->
<template v-if="response && lgUp">
<OTag type="alertPriority" :value="`p${response.priority}`" size="sm" />
<OTag type="oncallResponseState" :value="response.state" size="sm" />
<!-- How long it has been ringing, beside the word "ringing". The two
@@ -98,7 +99,7 @@
</OButton>
<!-- A menu of durations, not a panel that pushes the page down. -->
<ODropdown v-if="canAcknowledge">
<ODropdown v-if="canAcknowledge && lgUp">
<template #trigger>
<OButton
variant="outline"
@@ -124,7 +125,7 @@
them had a button. A disabled button that does not say why is a
dead end — the ladder's own explanation for why there is nobody
left to escalate to belongs right where the button went grey. -->
<span class="inline-flex">
<span v-if="lgUp" class="inline-flex">
<OButton
variant="outline"
size="sm-action"
@@ -139,6 +140,7 @@
</span>
<OButton
v-if="lgUp"
variant="outline"
size="sm-action"
data-test="oncall-response-handoff-btn"
@@ -155,7 +157,7 @@
something larger routinely arrives after it was closed. Hidden once
an incident exists, because the rail links to it from then on. -->
<OButton
v-if="response && !response.incident_id"
v-if="response && !response.incident_id && lgUp"
variant="outline"
size="sm-action"
data-test="oncall-response-promote-btn"
@@ -190,6 +192,59 @@
{{ t("oncall.resolve") }}
</OButton>
</template>
<!-- Below lg the row holds claiming and closing; the verbs used less often share one menu so the header stays two rows. -->
<ODropdown v-if="response && !lgUp && hasMoreActions" side="bottom" align="end">
<template #trigger>
<OButton
variant="outline"
size="icon-toolbar"
icon-left="more-vert"
:loading="snoozing || escalatingNow"
:aria-label="t('oncall.moreActions')"
data-test="oncall-response-more-btn"
/>
</template>
<ODropdownGroup v-if="canAcknowledge" :label="t('oncall.snoozeFor')">
<ODropdownItem
v-for="opt in snoozeOptions"
:key="opt.minutes"
:data-test="`oncall-response-snooze-${opt.minutes}-menu`"
@select="snoozeRecord(opt.minutes)"
>
{{ raw(opt.label) }}
</ODropdownItem>
</ODropdownGroup>
<ODropdownSeparator v-if="canAcknowledge" />
<ODropdownItem
v-if="isOpenState"
:disabled="escalation?.exhausted"
data-test="oncall-response-escalate-btn-menu"
@select="escalateNow"
>
<span class="flex max-w-56 flex-col">
<span>{{ t("oncall.escalate") }}</span>
<!-- Touch has no tooltip, so the reason the verb is greyed out rides on the item itself. -->
<span v-if="escalation?.exhausted" class="text-xs whitespace-normal">
{{ t("oncall.ladderExhausted") }}
</span>
</span>
</ODropdownItem>
<ODropdownItem
v-if="isOpenState"
data-test="oncall-response-handoff-btn-menu"
@select="showHandoff = true"
>
{{ t("oncall.handoff") }}
</ODropdownItem>
<ODropdownItem
v-if="!response.incident_id"
data-test="oncall-response-promote-btn-menu"
@select="promoteOpen = true"
>
{{ t("oncall.promote") }}
</ODropdownItem>
</ODropdown>
</template>
<template v-if="response">
@@ -198,6 +253,29 @@
the scroller — without it the activity thread was clipped at the
fold with no way to reach the rest. -->
<OContent y class="flex min-h-0 flex-1 flex-col overflow-y-auto">
<div
v-if="!lgUp"
class="mb-3 flex flex-wrap items-center gap-2"
data-test="oncall-response-meta"
>
<OTag type="alertPriority" :value="`p${response.priority}`" size="sm" />
<OTag type="oncallResponseState" :value="response.state" size="sm" />
<span class="text-text-secondary text-xs" data-test="oncall-response-elapsed">
{{ elapsedLabel }}
</span>
<OTag v-if="snoozedUntilLabel" variant="warning-soft" size="sm">
{{ t("oncall.snoozed") }}
</OTag>
<OTag
v-if="isImpacted"
variant="info-outline"
size="sm"
data-test="oncall-response-liaison-tag"
>
{{ t("oncall.liaisonTag") }}
</OTag>
</div>
<!-- The one sentence this screen exists to say when it is true:
nobody has seen this page, and here is why. -->
<OnCallReachAlarm
@@ -586,6 +664,7 @@ import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useStore } from "vuex";
import useBreakpoint from "@/composables/useBreakpoint";
import OButton from "@/lib/core/Button/OButton.vue";
import ORefreshButton from "@/lib/core/RefreshButton/ORefreshButton.vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
@@ -608,7 +687,9 @@ import OTabs from "@/lib/navigation/Tabs/OTabs.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import ODrawer from "@/lib/overlay/Drawer/ODrawer.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownGroup from "@/lib/overlay/Dropdown/ODropdownGroup.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import ODropdownSeparator from "@/lib/overlay/Dropdown/ODropdownSeparator.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OnCallFiringHistory from "@/components/oncall/OnCallFiringHistory.vue";
import OnCallPriorCauses from "@/components/oncall/OnCallPriorCauses.vue";
@@ -686,6 +767,7 @@ import { formatMicrosDuration } from "@/utils/formatters";
import { formatTimestampInTimezone } from "@/utils/date";
const { t } = useI18nTyped();
const { lgUp } = useBreakpoint();
const nowMicros = useOnCallClock();
const store = useStore();
const route = useRoute();
@@ -884,6 +966,9 @@ const isOpenState = computed(() => !!response.value && isUnresolved(response.val
/// Snooze are gone and Resolve becomes the primary action.
const canAcknowledge = computed(() => !!response.value && isEscalating(response.value.state));
/// Whether the compact header's menu has anything in it: a closed page already tied to an incident has no verb left.
const hasMoreActions = computed(() => isOpenState.value || !response.value?.incident_id);
/// Where the team-name link in the subtitle goes — same target and tab
/// OnCallAboutPage's own team row links to, so the two agree on what
/// clicking the team means.
@@ -63,6 +63,21 @@ vi.mock("vue-router", () => ({
useRoute: () => ({ query: routeQuery }),
}));
// Mutable per case, so the phone tests can switch while every other test stays desktop.
const mockViewport = vi.hoisted(() => ({ mdUp: true, lgUp: true }));
vi.mock("@/composables/useBreakpoint", async () => {
const { computed } = await import("vue");
return {
default: () => ({
isMobile: computed(() => !mockViewport.mdUp),
isTablet: computed(() => mockViewport.mdUp && !mockViewport.lgUp),
isDesktop: computed(() => mockViewport.lgUp),
mdUp: computed(() => mockViewport.mdUp),
lgUp: computed(() => mockViewport.lgUp),
}),
};
});
const service = vi.mocked(oncallService);
const destinations = vi.mocked(destinationService);
const incidents = vi.mocked(incidentsService);
@@ -175,6 +190,8 @@ const stubs = {
},
},
ODropdown: { name: "ODropdown", template: "<div><slot name='trigger' /><slot /></div>" },
// Open by construction, like ODropdown above: what the phone filter panel holds is the assertion, not the toggle.
OPopover: { name: "OPopover", template: "<div><slot name='trigger' /><slot /></div>" },
ODropdownItem: {
name: "ODropdownItem",
emits: ["select"],
@@ -1297,6 +1314,67 @@ describe("OnCallResponses", () => {
expect(sections(wrapper)).toEqual({ ringing: 1 });
});
});
/// A phone has one toolbar row and one control per table row, so the laptop's spread is folded rather than clipped.
describe("on a phone", () => {
beforeEach(() => {
mockViewport.mdUp = false;
mockViewport.lgUp = false;
});
afterEach(() => {
mockViewport.mdUp = true;
mockViewport.lgUp = true;
});
it("moves the team, priority and cause filters behind one button", async () => {
const wrapper = await withPages([page()]);
expect(wrapper.find('[data-test="oncall-responses-filters-btn"]').exists()).toBe(true);
const panel = wrapper.find('[data-test="oncall-responses-filters-panel"]');
for (const control of ["team-filter", "priority-filter", "cause-filter", "group-toggle"]) {
expect(panel.find(`[data-test="oncall-responses-${control}"]`).exists()).toBe(true);
// Moved, not duplicated: a second copy left inline would wrap the row again.
expect(wrapper.findAll(`[data-test="oncall-responses-${control}"]`)).toHaveLength(1);
}
});
/// The labelled button does not fit the one-control column, so the menu leads with the row's next step.
it("leads the row menu with acknowledge while the page is ringing", async () => {
service.acknowledgeResponse.mockResolvedValue({ data: {} } as any);
const wrapper = await withPages([page()]);
await wrapper.find('[data-test="oncall-row-ack-alert:al_ckt-menu"]').trigger("click");
await flushPromises();
expect(service.acknowledgeResponse).toHaveBeenCalledWith(
expect.objectContaining({ response_id: "resp_1" }),
);
});
it("leads the row menu with resolve once the page is owned", async () => {
const wrapper = await withPages([
page({ state: "acknowledged", acked_by: "engineer@example.com" }),
]);
expect(wrapper.find('[data-test="oncall-row-resolve-alert:al_ckt-menu"]').exists()).toBe(
true,
);
expect(wrapper.find('[data-test="oncall-row-ack-alert:al_ckt-menu"]').exists()).toBe(false);
});
/// A closed row has no menu on a laptop; on a phone its button is gone, so the menu is the only way in.
it("keeps a menu on a closed row so its timeline stays reachable", async () => {
const wrapper = await withPages([
page({ state: "resolved", closed_at: Date.now() * 1000, acked_by: "ana@o2.ai" }),
]);
expect(wrapper.find('[data-test^="oncall-row-more-"]').exists()).toBe(true);
await wrapper.find('[data-test="oncall-row-timeline-alert:al_ckt-menu"]').trigger("click");
expect(push).toHaveBeenCalled();
});
});
});
/// Two things the triage list already held and did not use.
+109 -12
View File
@@ -170,20 +170,20 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
the rows this page happened to have walked hid a team's older
pages entirely past the fetch cap. -->
<OSelect
v-if="!isMobile"
v-model="teamFilter"
:options="teamOptions"
:disabled="!teamsAvailable"
:placeholder="teamsAvailable ? undefined : t('oncall.teamFilterUnavailable')"
width="sm"
class="max-md:w-36"
data-test="oncall-responses-team-filter"
@update:model-value="() => fetchResponses()"
/>
<OSelect
v-if="!isMobile"
v-model="priorityFilter"
:options="priorityOptions"
width="xs"
class="max-md:w-28"
data-test="oncall-responses-priority-filter"
/>
<!-- `basis-40` so the search keeps a usable width once the row
@@ -198,6 +198,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- On by default. A rule firing every minute is one problem, not
ninety-five, and the ungrouped view is for reading history. -->
<OCheckbox
v-if="!isMobile"
v-model="grouped"
:label="t('oncall.groupByAlert')"
data-test="oncall-responses-group-toggle"
@@ -209,15 +210,60 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
rather than `includeResolved`, which stays true once fetched
even after the reader narrows away from resolved rows. -->
<OSelect
v-if="sectionVisibility.resolved"
v-if="!isMobile && sectionVisibility.resolved"
:model-value="causeFilter"
:options="causeOptions"
:placeholder="t('oncall.causeFilterAny')"
width="sm"
class="max-md:w-36"
data-test="oncall-responses-cause-filter"
@update:model-value="onCauseFilter"
/>
<!-- A phone toolbar is one row, so everything that narrows the list waits behind one button. -->
<OPopover
v-if="isMobile"
align="end"
content-class="w-72"
:aria-label="t('oncall.filters')"
>
<template #trigger>
<OButton
variant="outline"
size="icon-sm"
icon-left="filter-list"
:active="narrowed"
:aria-label="t('oncall.filters')"
data-test="oncall-responses-filters-btn"
/>
</template>
<div class="flex flex-col gap-3 p-3" data-test="oncall-responses-filters-panel">
<OSelect
v-model="teamFilter"
:options="teamOptions"
:disabled="!teamsAvailable"
:placeholder="teamsAvailable ? undefined : t('oncall.teamFilterUnavailable')"
data-test="oncall-responses-team-filter"
@update:model-value="() => fetchResponses()"
/>
<OSelect
v-model="priorityFilter"
:options="priorityOptions"
data-test="oncall-responses-priority-filter"
/>
<OSelect
v-if="sectionVisibility.resolved"
:model-value="causeFilter"
:options="causeOptions"
:placeholder="t('oncall.causeFilterAny')"
data-test="oncall-responses-cause-filter"
@update:model-value="onCauseFilter"
/>
<OCheckbox
v-model="grouped"
:label="t('oncall.groupByAlert')"
data-test="oncall-responses-group-toggle"
/>
</div>
</OPopover>
</div>
</template>
@@ -314,8 +360,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
`sectionKey` rather than `key`: Vue reserves `key` on a slot outlet. -->
<template #group-header="{ sectionKey }">
<!-- The cell spans the scrolled table, so a phone pins this to the visible width or the sentence runs off screen. -->
<div
class="px-page-edge flex flex-wrap items-center gap-x-2 gap-y-1 py-1.5"
class="px-page-edge flex flex-wrap items-center gap-x-2 gap-y-1 py-1.5 max-md:sticky max-md:left-0 max-md:w-[100cqw]"
:data-test="`oncall-section-header-${sectionKey}`"
>
<span
@@ -561,7 +608,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- What already happened, without leaving the triage list. The detail
screen stays the place to act; this is the place to read. -->
<template #expansion="{ row }">
<div class="px-page-edge py-3" :data-test="`oncall-expansion-${row.rowKey}`">
<div
class="px-page-edge py-3 max-md:sticky max-md:left-0 max-md:w-[100cqw]"
:data-test="`oncall-expansion-${row.rowKey}`"
>
<OInnerLoading v-if="expandedLoading" showing />
<template v-else>
<div class="mb-2 flex justify-end">
@@ -601,17 +651,23 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
more-menu, as it does on every other list in the app. -->
<template #cell-actions="{ row }">
<!-- The slot's wrapper is inline-flex and shrinks to content by design, so pin to the column's resolved width directly instead of `w-full`. -->
<span class="flex items-center justify-between" :style="{ width: actionsColumnWidthVar }">
<span
class="flex items-center justify-between max-md:w-auto! max-md:justify-center"
:style="{ width: actionsColumnWidthVar }"
>
<ODropdown v-if="!row.latest.team_id">
<template #trigger>
<OButton
variant="outline"
size="xs"
class="max-md:ps-2 max-md:pe-2"
:loading="busyId === row.rowKey"
:aria-label="isMobile ? t('oncall.assignTeamShort') : undefined"
:data-test="`oncall-row-assign-${row.rowKey}`"
@click.stop
>
{{ t("oncall.assignTeamShort") }}
<OIcon v-if="isMobile" name="group-add" size="sm" />
<template v-else>{{ t("oncall.assignTeamShort") }}</template>
</OButton>
</template>
@@ -635,7 +691,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
v-if="primaryAction(row) === 'acknowledge'"
variant="primary"
size="xs"
class="w-28"
class="w-28 max-md:hidden"
:loading="busyId === row.rowKey"
:data-test="`oncall-row-ack-${row.rowKey}`"
@click.stop="acknowledgeRow(row)"
@@ -646,7 +702,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
v-else-if="primaryAction(row) === 'resolve'"
variant="outline"
size="xs"
class="w-28"
class="w-28 max-md:hidden"
:loading="busyId === row.rowKey"
:data-test="`oncall-row-resolve-${row.rowKey}`"
@click.stop="resolveRow(row)"
@@ -657,7 +713,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
v-else
variant="outline"
size="xs"
class="w-28"
class="w-28 max-md:hidden"
:data-test="`oncall-row-timeline-${row.rowKey}`"
@click.stop="openResponse(row)"
>
@@ -667,7 +723,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Hidden when the row has nothing left to offer: a closed page's
only action is already the button beside it, and a menu whose
single item is the thing you just clicked is furniture. -->
<ODropdown v-if="canAcknowledge(row) || menuActions(row).length">
<ODropdown v-if="isMobile || canAcknowledge(row) || menuActions(row).length">
<template #trigger>
<OButton
variant="ghost"
@@ -681,6 +737,41 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OButton>
</template>
<!-- A phone has room for one control per row, so the menu is always there and leads with the row's next step. -->
<template v-if="isMobile">
<ODropdownItem
v-if="primaryAction(row) === 'acknowledge'"
:data-test="`oncall-row-ack-${row.rowKey}-menu`"
@select="acknowledgeRow(row)"
>
<template #icon-left>
<OIcon name="check" size="sm" />
</template>
{{ t("oncall.acknowledge") }}
</ODropdownItem>
<ODropdownItem
v-else-if="primaryAction(row) === 'resolve'"
:data-test="`oncall-row-resolve-${row.rowKey}-menu`"
@select="resolveRow(row)"
>
<template #icon-left>
<OIcon name="task-alt" size="sm" />
</template>
{{ t("oncall.resolve") }}
</ODropdownItem>
<ODropdownItem
v-else
:data-test="`oncall-row-timeline-${row.rowKey}-menu`"
@select="openResponse(row)"
>
<template #icon-left>
<OIcon name="format-list-bulleted" size="sm" />
</template>
{{ t("oncall.timeline") }}
</ODropdownItem>
<ODropdownSeparator v-if="canAcknowledge(row) || menuActions(row).length" />
</template>
<!-- Grouped rather than four loose items: they are one decision
("quiet it") asked at four lengths. -->
<ODropdownGroup v-if="canAcknowledge(row)" :label="t('oncall.snoozeFor')">
@@ -820,6 +911,7 @@ import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownGroup from "@/lib/overlay/Dropdown/ODropdownGroup.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import ODropdownSeparator from "@/lib/overlay/Dropdown/ODropdownSeparator.vue";
import OPopover from "@/lib/overlay/Popover/OPopover.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { useMutation } from "@tanstack/vue-query";
import { queryClient } from "@/composables/query/queryClient";
@@ -1048,6 +1140,11 @@ const isFiltered = computed(
activeFilter.value !== "all",
);
/// Only what the phone filter button hides: it lights up so a narrowed list never looks like the whole one.
const narrowed = computed(
() => teamFilter.value !== "all" || priorityFilter.value !== "all" || !!causeFilter.value,
);
const showChecklist = computed(
() =>
loaded.value &&
+4 -2
View File
@@ -143,10 +143,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
emergencies the row tags name; `include_dismissed` swaps the
outstanding worklist for the raw historical record. -->
<template v-if="tab === 'signals'">
<div class="bg-border-default h-4 w-px shrink-0" />
<div class="bg-border-default h-4 w-px shrink-0 max-md:hidden" />
<OToggleGroup
:model-value="signalFilters.landing || 'both'"
mobile-dropdown
data-test="oncall-unrouted-filter"
@update:model-value="setSignalLanding"
>
<OToggleGroupItem value="both" size="sm" data-test="oncall-unrouted-filter-both">
@@ -168,7 +170,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OToggleGroupItem>
</OToggleGroup>
<div class="bg-border-default h-4 w-px shrink-0" />
<div class="bg-border-default h-4 w-px shrink-0 max-md:hidden" />
<OSwitch
:model-value="signalFilters.include_dismissed"
+50
View File
@@ -509,4 +509,54 @@ describe("OnCallTeams", () => {
expect(spy).toHaveBeenCalledWith(usersExpiry);
spy.mockRestore();
});
/// A phone's action column holds one control, so the row's buttons are mirrored in a menu that must act the same.
describe("the phone row menu", () => {
const menuStubs = {
...stubs,
ODropdown: { name: "ODropdown", template: "<div><slot name='trigger' /><slot /></div>" },
ODropdownItem: {
name: "ODropdownItem",
emits: ["select"],
template: `<button @click="$emit('select')"><slot /></button>`,
},
};
async function rendered() {
service.listTeams.mockResolvedValue({ data: [team("team_1", "Platform")] } as any);
const wrapper = mount(OnCallTeams, { global: { plugins: [i18n, store], stubs: menuStubs } });
await flushPromises();
return wrapper;
}
it("shows the buttons from md up and the menu below it, never both", async () => {
const wrapper = await rendered();
for (const action of ["edit", "delete"]) {
expect(wrapper.find(`[data-test="oncall-team-${action}-team_1"]`).classes()).toContain(
"max-md:hidden",
);
}
expect(wrapper.find('[data-test="oncall-teams-row-more-actions"]').classes()).toContain(
"md:hidden",
);
});
it("edits the team the row is for", async () => {
const wrapper = await rendered();
await wrapper.find('[data-test="oncall-team-edit-team_1-menu"]').trigger("click");
await flushPromises();
expect(wrapper.findComponent({ name: "OnCallTeamForm" }).props("team")).toEqual(
expect.objectContaining({ id: "team_1" }),
);
});
it("asks before deleting, as the button does", async () => {
const wrapper = await rendered();
await wrapper.find('[data-test="oncall-team-delete-team_1-menu"]').trigger("click");
expect(wrapper.find('[data-test="confirm"]').text()).toContain("Platform");
});
});
});
+46 -1
View File
@@ -149,6 +149,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
variant="ghost"
size="icon-sm"
icon-left="star-outline"
class="max-md:hidden"
:loading="settingDefaultTeamId === row.id"
:aria-label="t('oncall.setDefaultTeam')"
:data-test="`oncall-team-set-default-${row.id}`"
@@ -156,12 +157,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<OTooltip side="bottom" :content="t('oncall.setDefaultTeam')" />
</OButton>
<div v-else-if="canConfigure" class="h-8 w-8 shrink-0" />
<div v-else-if="canConfigure" class="h-8 w-8 shrink-0 max-md:hidden" />
<OButton
v-if="canConfigure"
variant="ghost"
size="icon-sm"
icon-left="edit"
class="max-md:hidden"
:aria-label="t('oncall.editTeam')"
:data-test="`oncall-team-edit-${row.id}`"
@click.stop="openEdit(row)"
@@ -173,12 +175,53 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
variant="ghost"
size="icon-sm"
icon-left="delete-outline"
class="max-md:hidden"
:aria-label="t('oncall.deleteTeam')"
:data-test="`oncall-team-delete-${row.id}`"
@click.stop="teamToDelete = row"
>
<OTooltip side="bottom" :content="t('oncall.deleteTeam')" />
</OButton>
<ODropdown v-if="canConfigure" side="bottom" align="end">
<template #trigger>
<OButton
icon-left="more-vert"
variant="ghost"
size="icon-xs-sq"
class="md:hidden"
:loading="settingDefaultTeamId === row.id"
:aria-label="t('oncall.moreActions')"
data-test="oncall-teams-row-more-actions"
@click.stop
/>
</template>
<ODropdownItem
v-if="row.id !== defaultTeamId"
icon-left="star-outline"
class="md:hidden"
:data-test="`oncall-team-set-default-${row.id}-menu`"
@select="setDefaultTeam(row)"
>
<span>{{ t("oncall.setDefaultTeam") }}</span>
</ODropdownItem>
<ODropdownItem
icon-left="edit"
class="md:hidden"
:data-test="`oncall-team-edit-${row.id}-menu`"
@select="openEdit(row)"
>
<span>{{ t("oncall.editTeam") }}</span>
</ODropdownItem>
<ODropdownItem
icon-left="delete-outline"
variant="destructive"
class="md:hidden"
:data-test="`oncall-team-delete-${row.id}-menu`"
@select="teamToDelete = row"
>
<span>{{ t("oncall.deleteTeam") }}</span>
</ODropdownItem>
</ODropdown>
</template>
<template #error>
@@ -244,6 +287,8 @@ import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
import ORefreshButton from "@/lib/core/RefreshButton/ORefreshButton.vue";
import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
import OTable from "@/lib/core/Table/OTable.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";