mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 00:09:35 +00:00
fix(oncall): fit the on-call pages on phones and tablets (#15033)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user