Merge branch 'fix/oncall-ux-schedule' into fix/oncall-ux-polish

This commit is contained in:
ktx-abhay
2026-11-03 19:01:30 +05:30
32 changed files with 1167 additions and 292 deletions
@@ -80,6 +80,9 @@ export class OnCallTeamDetailPage {
timelineChart: '[data-test="oncall-timeline-chart"]',
timelineEmpty: '[data-test="oncall-timeline-empty"]',
timelineAdd: '[data-test="oncall-timeline-add"]',
// A team with no rotations yet gets a From a template / From scratch menu on Add rotation.
timelineAddScratch: '[data-test="oncall-timeline-add-scratch"]',
timelineAddTemplate: '[data-test="oncall-timeline-add-template"]',
laneMenus: '[data-test^="oncall-lane-menu-"]',
laneStartOver: '[data-test^="oncall-lane-presets-"]',
timelineGap: '[data-test="oncall-timeline-gap"]',
@@ -364,6 +367,8 @@ export class OnCallTeamDetailPage {
*/
async openRotationEditor() {
await this.page.locator(this.locators.timelineAdd).click();
const scratch = this.page.locator(this.locators.timelineAddScratch);
if (await scratch.isVisible({ timeout: 1500 }).catch(() => false)) await scratch.click();
await expect(this.page.locator(this.locators.rotationDrawer)).toBeVisible({ timeout: 20000 });
}
+12 -3
View File
@@ -21,8 +21,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="oncall-members-away-dialog"
>
<div class="flex flex-col gap-3">
<span class="flex items-center gap-2">
<OUserCell v-if="email" :value="email" />
<span v-if="email" class="flex min-w-0 items-center gap-2">
<OAvatar :value="email" :name="nameOf(email)" size="sm" />
<OnCallPersonName
truncate
:email="email"
class="text-text-heading text-sm font-medium"
data-test="oncall-members-away-person"
/>
</span>
<OText variant="meta">{{ t("oncall.awayHintShort") }}</OText>
<!-- The times below are the team's clock, said the way the Schedule tab says it. -->
@@ -88,7 +94,9 @@ import { useStore } from "vuex";
import { useMutation } from "@tanstack/vue-query";
import OButton from "@/lib/core/Button/OButton.vue";
import OUserCell from "@/lib/core/Table/cells/OUserCell.vue";
import OAvatar from "@/lib/core/Avatar/OAvatar.vue";
import OnCallPersonName from "@/components/oncall/OnCallPersonName.vue";
import { useOrgUserNames } from "@/composables/useOrgUserNames";
import OText from "@/lib/core/Typography/OText.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
import ODate from "@/lib/forms/Date/ODate.vue";
@@ -117,6 +125,7 @@ const emit = defineEmits<{ saved: [] }>();
const open = defineModel<boolean>("open", { required: true });
const { t } = useI18nTyped();
const { nameOf } = useOrgUserNames();
const { noteConfigurationDenied } = useOnCallPermissions();
const store = useStore();
const orgId = computed(() => store.state.selectedOrganization.identifier);
@@ -23,6 +23,9 @@ import i18n from "@/locales";
import { MICROS_PER_DAY, MICROS_PER_HOUR, MICROS_PER_WEEK } from "@/ts/interfaces/oncall";
import { toZonedInputValue } from "@/utils/oncall";
import type { UpcomingShift } from "./OnCallCoverForm.vue";
import { queryClient } from "@/composables/query/queryClient";
import { orgUsersQuery } from "@/services/users.queries";
import store from "@/test/unit/helpers/store";
/// Tomorrow, not a frozen instant in 2023. Every fixture below is a window
/// somebody could actually be asked to cover, and a schema that refuses hours
@@ -514,6 +517,55 @@ describe("OnCallCoverForm — taking a cover", () => {
expect(summary).not.toContain("ana@o2.ai");
});
// Names are what a person recognises; the email tells two namesakes apart, so it is the row's second line and the name's tooltip.
it("lists and names people by display name, keeping the email beside it", async () => {
queryClient.setQueryData(orgUsersQuery("default").queryKey, [
{ email: "ana@o2.ai", first_name: "Ana" },
{ email: "bo@o2.ai", first_name: "Bo", last_name: "Lin" },
]);
const wrapper = mount(OnCallCoverForm, {
props: {
open: true,
members: MEMBERS,
timezone: "UTC",
shifts: [],
gap: GAP,
currentHolder: "ana@o2.ai",
defaultUser: "bo@o2.ai",
teamName: "Platform",
},
global: {
plugins: [i18n, store],
stubs: {
...stubs,
OFormSelect: {
name: "OFormSelect",
props: ["name"],
inheritAttrs: false,
template: "<div />",
},
},
},
});
await flushPromises();
const who = wrapper
.findAllComponents({ name: "OFormSelect" })
.find((one: any) => one.props("name") === "user_email")!;
// `options` reaches OSelect through attribute fall-through; OFormSelect declares only `name`.
expect(who.vm.$attrs.options).toEqual([
expect.objectContaining({ label: "Ana", value: "ana@o2.ai", subLabel: "ana@o2.ai" }),
expect.objectContaining({ label: "Bo Lin", value: "bo@o2.ai", subLabel: "bo@o2.ai" }),
]);
expect(wrapper.find('[data-test="oncall-cover-currently"]').text()).toBe(
"Currently on call: Ana",
);
const summary = wrapper.find('[data-test="oncall-cover-summary"]');
expect(summary.text()).toContain("Bo Lin covers Platform");
expect(summary.findComponent({ name: "OnCallPersonName" }).props("email")).toBe("bo@o2.ai");
queryClient.removeQueries({ queryKey: orgUsersQuery("default").queryKey });
});
/// No person picked yet is the same half-answered state as a half-picked
/// window: " covers Platform Sat 18:00 – …" is worse than no sentence.
it("says nothing until somebody has been picked", async () => {
+54 -34
View File
@@ -70,7 +70,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
that says only "Ana and Bo swap" leaves the reader to work out which
week each of them ends up holding. -->
<OBanner v-if="swapSummary" variant="info" data-test="oncall-swap-summary">
{{ swapSummary }}
<i18n-t keypath="oncall.swapSummary" tag="span" scope="global">
<template #first><OnCallPersonName :email="swapSummary.first" /></template>
<template #second><OnCallPersonName :email="swapSummary.second" /></template>
<template #firstWhen>{{ swapSummary.firstWhen }}</template>
<template #secondWhen>{{ swapSummary.secondWhen }}</template>
</i18n-t>
</OBanner>
<p
v-else-if="swapProblem"
@@ -82,14 +87,26 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<OForm v-else :id="FORM_ID" :form="form" class="flex flex-col gap-5">
<OFormSelect
name="user_email"
:label="t('oncall.coverWho')"
:options="memberOptions"
:help-text="currentlyOnCall"
required
data-test="oncall-cover-who"
/>
<div class="flex flex-col gap-1">
<OFormSelect
name="user_email"
:label="t('oncall.coverWho')"
:options="memberOptions"
searchable
required
data-test="oncall-cover-who"
/>
<i18n-t
v-if="currentHolder"
keypath="oncall.coverCurrentlyOnCall"
tag="span"
scope="global"
class="text-input-help-text text-xs"
data-test="oncall-cover-currently"
>
<template #name><OnCallPersonName :email="currentHolder" /></template>
</i18n-t>
</div>
<!-- Only when there is a choice to make. A one-rotation team has one
rotation, and a select with one option is a question with one
@@ -136,7 +153,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- What this will actually do, in the team's own clock, before saving. -->
<OBanner v-if="summary" variant="info" data-test="oncall-cover-summary">
{{ summary }}
<i18n-t keypath="oncall.coverSummary" tag="span" scope="global">
<template #name><OnCallPersonName :email="summary.who" /></template>
<template #team>{{ summary.team }}</template>
<template #range>{{ summary.range }}</template>
</i18n-t>
<span v-if="teamZoneSummary" class="block" data-test="oncall-cover-summary-team-zone">
{{ teamZoneSummary }}
</span>
@@ -182,6 +203,8 @@ export interface UpcomingShift extends Shift {
}
import { formatInZone, fromZonedInputValue, toZonedInputValue } from "@/utils/oncall";
import { makeOnCallCoverSchema } from "./OnCallCoverForm.schema";
import OnCallPersonName from "./OnCallPersonName.vue";
import { useOrgUserNames } from "@/composables/useOrgUserNames";
/**
* What the cover half of this dialog holds.
@@ -324,15 +347,9 @@ watch(
},
);
const memberOptions = computed(() =>
props.members.map((member) => ({ label: raw(member.user_email), value: member.user_email })),
);
const { nameOf, optionOf } = useOrgUserNames();
const currentlyOnCall = computed<I18nText | undefined>(() =>
props.currentHolder
? t("oncall.coverCurrentlyOnCall", { name: raw(props.currentHolder) })
: undefined,
);
const memberOptions = computed(() => props.members.map((member) => optionOf(member.user_email)));
/// A pre-selected person is only honest if the picker can show them: the caller
/// passes whoever is reading the page, and a reader who is not on this team has
@@ -462,23 +479,21 @@ const formatRange = (micros: number, zone: string) =>
timeZoneName: "short",
});
const summary = computed<I18nText | "">(() => {
const summary = computed<{ who: string; team: I18nText; range: I18nText } | null>(() => {
const window = windowValue.value;
const who = String(userValue.value ?? "");
// A half-picked range is not a sentence yet — the picker reports one end at
// a time, and "Sat 18:00 – Invalid Date" is worse than nothing. Nor is a
// range with nobody against it.
if (typeof window?.from !== "number" || typeof window?.to !== "number") return "";
if (!who) return "";
if (typeof window?.from !== "number" || typeof window?.to !== "number") return null;
if (!who) return null;
const fmt = (micros: number) => formatRange(micros, props.viewerTimezone);
// The person TAKING the shift. This read `currentHolder` — whoever is being
// relieved — so the confirmation named the wrong side of the handover, and
// the team was passed as an empty string.
return t("oncall.coverSummary", {
name: raw(who),
// The person TAKING the shift, not `currentHolder` — whoever is being relieved.
return {
who,
team: raw(props.teamName ?? ""),
range: raw(`${fmt(window.from)} – ${fmt(window.to)}`),
});
};
});
/// The same window on the team's clock, and only when that is a different
@@ -527,7 +542,7 @@ const rotationOptions = computed(() =>
/// the wrong rotation.
const shiftLabel = (shift: UpcomingShift): I18nText =>
t(props.rotations.length > 1 ? "oncall.swapShiftOptionInRotation" : "oncall.swapShiftOption", {
name: raw(shift.member),
name: raw(nameOf(shift.member)),
rotation: raw(shift.rotationName),
range: raw(
`${formatInZone(shift.startMicros, props.timezone, {
@@ -580,9 +595,14 @@ const swapReady = computed(() => !!swapPair.value && !swapProblem.value);
/// Named both ways round on purpose: "Ana and Bo swap" leaves the reader to
/// work out which week each of them ends up holding, and getting that backwards
/// is the whole risk of the errand.
const swapSummary = computed<I18nText | "">(() => {
const swapSummary = computed<{
first: string;
second: string;
firstWhen: I18nText;
secondWhen: I18nText;
} | null>(() => {
const pair = swapPair.value;
if (!pair || swapProblem.value) return "";
if (!pair || swapProblem.value) return null;
const when = (shift: UpcomingShift) =>
raw(
formatInZone(shift.startMicros, props.timezone, {
@@ -591,12 +611,12 @@ const swapSummary = computed<I18nText | "">(() => {
month: "short",
}),
);
return t("oncall.swapSummary", {
first: raw(pair.first.member),
second: raw(pair.second.member),
return {
first: pair.first.member,
second: pair.second.member,
firstWhen: when(pair.first),
secondWhen: when(pair.second),
});
};
});
function submitSwap() {
@@ -46,13 +46,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="oncall-cover-table"
>
<template #cell-who="{ row }">
<OUserCell :value="row.user_email" />
<OnCallPersonName truncate :email="row.user_email" />
</template>
<!-- Optional on the wire, and legitimately so: "cover tonight" is a real
request before anybody has worked out whose night it is. -->
<template #cell-coveringFor="{ row }">
<OUserCell v-if="row.covering_for" :value="row.covering_for" />
<OnCallPersonName v-if="row.covering_for" truncate :email="row.covering_for" />
<OText v-else variant="meta">{{ t("oncall.coverForNobodyNamed") }}</OText>
</template>
@@ -104,11 +104,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OText v-if="shownRemove">
<i18n-t keypath="oncall.coverRemoveBody" tag="span">
<template #who>
<span
<OnCallPersonName
:email="shownRemove.user_email"
data-test="oncall-cover-remove-name"
class="text-text-heading font-semibold break-all"
>{{ shownRemove.user_email }}</span
>
class="text-text-heading font-semibold"
/>
</template>
<template #range>{{ windowOf(shownRemove) }}</template>
</i18n-t>
@@ -125,7 +125,7 @@ import { queryClient } from "@/composables/query/queryClient";
import OButton from "@/lib/core/Button/OButton.vue";
import OTable from "@/lib/core/Table/OTable.vue";
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
import OUserCell from "@/lib/core/Table/cells/OUserCell.vue";
import OnCallPersonName from "./OnCallPersonName.vue";
import OText from "@/lib/core/Typography/OText.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
@@ -33,16 +33,34 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<span class="text-text-secondary text-xs">{{ t("oncall.onCallNow") }}</span>
<template v-if="current.primary">
<span data-test="oncall-coverage-now-primary">{{
t("oncall.coveragePrimaryOnly", { primary: raw(current.primary) })
}}</span>
<span
<i18n-t
keypath="oncall.coveragePrimaryOnly"
tag="span"
scope="global"
data-test="oncall-coverage-now-primary"
>
<template #primary>
<OnCallPersonName
:email="current.primary"
data-test="oncall-coverage-now-primary-person"
/>
</template>
</i18n-t>
<i18n-t
v-if="current.secondary"
keypath="oncall.coverageSecondaryOnly"
tag="span"
scope="global"
class="text-text-secondary"
data-test="oncall-coverage-now-secondary"
>
{{ t("oncall.coverageSecondaryOnly", { secondary: raw(current.secondary) }) }}
</span>
<template #secondary>
<OnCallPersonName
:email="current.secondary"
data-test="oncall-coverage-now-secondary-person"
/>
</template>
</i18n-t>
</template>
<span v-else class="text-status-error-text" data-test="oncall-coverage-now-nobody">
{{ t("oncall.coverNobody") }}
@@ -123,6 +141,8 @@ import type {
ScheduleTrack,
} from "@/lib/data/ScheduleTimeline/OScheduleTimeline.types";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OnCallPersonName from "@/components/oncall/OnCallPersonName.vue";
import { useOrgUserNames } from "@/composables/useOrgUserNames";
import type { Rotation } from "@/ts/interfaces/oncall";
import { MICROS_PER_DAY } from "@/ts/interfaces/oncall";
import type { I18nText } from "@/types/i18n";
@@ -146,6 +166,7 @@ const props = withDefaults(
const { t } = useI18nTyped();
const { isMobile } = useBreakpoint();
const { tooltipOf } = useOrgUserNames();
/** One sample per hour: fine enough to catch an hour-long hole. */
const STEP_MICROS = 60 * 60 * 1_000_000;
@@ -338,9 +359,9 @@ function tooltipFor(bandKey: string): TooltipSegment[] {
`${rangeStamp(Math.max(segment.from, run.from))} – ${rangeStamp(Math.min(segment.to, run.to))}`,
)
: null,
primary: t("oncall.coveragePrimaryOnly", { primary: raw(segment.primary ?? "") }),
primary: t("oncall.coveragePrimaryOnly", { primary: tooltipOf(segment.primary ?? "") }),
secondary: segment.secondary
? t("oncall.coverageSecondaryOnly", { secondary: raw(segment.secondary) })
? t("oncall.coverageSecondaryOnly", { secondary: tooltipOf(segment.secondary) })
: null,
}));
@@ -728,6 +728,30 @@ describe("OnCallEscalationLadder", () => {
expect(wrapper.emitted("edit")).toHaveLength(1);
});
// The card body is a pointer shortcut to Edit, so it says so on hover and only for a reader who may edit.
it("opens Edit from anywhere on the ladder for a reader who may change it", async () => {
const editor = render({ canConfigure: true });
const body = editor.find('[data-test="oncall-ladder-body"]');
expect(body.classes()).toContain("cursor-pointer");
await body.trigger("click");
expect(editor.emitted("edit")).toHaveLength(1);
const reader = render();
const readOnly = reader.find('[data-test="oncall-ladder-body"]');
expect(readOnly.classes()).not.toContain("cursor-pointer");
await readOnly.trigger("click");
expect(reader.emitted("edit")).toBeUndefined();
});
// Two people can share a name, so each name on a rung carries its email in a tooltip.
it("names people on a rung with their email one hover away", () => {
const wrapper = render();
const names = wrapper.findAllComponents({ name: "OnCallPersonName" });
expect(names.length).toBeGreaterThan(0);
expect(names[0].props("email")).toBe("ana@o2.ai");
expect(names[0].findComponent({ name: "OTooltip" }).props("content")).toBe("ana@o2.ai");
});
it("offers no Add a step to a reader who cannot change the policy", () => {
const wrapper = render({ preview: preview({ reaches_nobody: true }) });
@@ -102,7 +102,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OInnerLoading v-if="loading" showing />
<template v-else>
<!-- Pointer-only shortcut to Edit; the header's Edit button stays the keyboard path. -->
<div
v-else
class="rounded-b-surface flex flex-col"
:class="canConfigure ? 'hover:bg-table-row-hover-bg cursor-pointer transition-colors' : ''"
data-test="oncall-ladder-body"
@click="canConfigure && emit('edit')"
>
<!-- Every level of every priority aimed at the whole team, on a team that HAS rotations, is a policy minted before it had members. -->
<span
v-if="staleDefaultLadder"
@@ -176,7 +183,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:data-test="`oncall-ladder-person-${group.firstMicros}-${one.email}`"
>
<OAvatar :value="one.email" :name="nameOf(one.email)" />
<OTruncatedText>{{ raw(nameOf(one.email)) }}</OTruncatedText>
<OnCallPersonName
truncate
:email="one.email"
:data-test="`oncall-ladder-person-name-${group.firstMicros}-${one.email}`"
/>
<OTag v-for="seat in one.seats" :key="seat" variant="default-outline" size="sm">
{{ raw(seat) }}
</OTag>
@@ -212,10 +223,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</li>
<!-- The ending is the rail's last stop: when the ladder runs out is what the delays are read against. -->
<li class="relative flex min-h-10 items-center gap-3 py-2" data-test="oncall-ladder-ends">
<li
class="relative flex min-h-10 items-center gap-3 py-2 max-md:items-start"
data-test="oncall-ladder-ends"
>
<span
v-if="rungGroups.length"
class="bg-border-default absolute start-3 top-0 h-1/2 w-px"
class="bg-border-default absolute start-3 top-0 h-1/2 w-px max-md:h-5"
aria-hidden="true"
/>
<span
@@ -224,28 +238,36 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<OIcon name="stop-circle" size="sm" />
</span>
<OText variant="meta" class="w-24 shrink-0" data-test="oncall-ladder-ends-time">
{{ endTime }}
</OText>
<!-- How a page can leave this team, in the server's words, on hover of the ending it qualifies. -->
<span class="text-text-secondary text-sm" data-test="oncall-ladder-ends-line">
{{ endTitle }}
<OTooltip
v-if="preview.cross_team_moves.length"
side="bottom"
:content="raw(preview.cross_team_moves.join(' '))"
/>
</span>
<OButton
v-if="canConfigure"
variant="outline"
size="sm"
icon-left="add"
data-test="oncall-ladder-add-rung"
@click="emit('edit')"
<span
class="flex min-w-0 flex-1 items-center gap-3 max-md:flex-col max-md:items-start max-md:gap-1"
>
{{ t("oncall.ladderAddStep") }}
</OButton>
<OText
variant="meta"
class="w-24 shrink-0 max-md:w-auto"
data-test="oncall-ladder-ends-time"
>
{{ endTime }}
</OText>
<!-- How a page can leave this team, in the server's words, on hover of the ending it qualifies. -->
<span class="text-text-secondary text-sm" data-test="oncall-ladder-ends-line">
{{ endTitle }}
<OTooltip
v-if="preview.cross_team_moves.length"
side="bottom"
:content="raw(preview.cross_team_moves.join(' '))"
/>
</span>
<OButton
v-if="canConfigure"
variant="outline"
size="sm"
icon-left="add"
data-test="oncall-ladder-add-rung"
@click.stop="emit('edit')"
>
{{ t("oncall.ladderAddStep") }}
</OButton>
</span>
</li>
</ol>
@@ -258,7 +280,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OIcon name="auto-awesome" size="sm" class="text-text-secondary" />
<OText variant="meta">{{ l0Line }}</OText>
</span>
</template>
</div>
</div>
</div>
</template>
@@ -273,8 +295,8 @@ import OAvatar from "@/lib/core/Avatar/OAvatar.vue";
import OAvatarGroup from "@/lib/core/Avatar/OAvatarGroup.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OText from "@/lib/core/Typography/OText.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import OnCallChannelIcons from "./OnCallChannelIcons.vue";
import OnCallPersonName from "./OnCallPersonName.vue";
import { useOrgUserNames } from "@/composables/useOrgUserNames";
import OInnerLoading from "@/lib/feedback/InnerLoading/OInnerLoading.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
@@ -0,0 +1,104 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { flushPromises, mount } from "@vue/test-utils";
import { afterEach, describe, expect, it, vi } from "vitest";
import OnCallFitChips from "@/components/oncall/OnCallFitChips.vue";
import i18n from "@/locales";
import { raw } from "@/types/i18n";
const CHIP = 40;
const GAP = 4;
const MORE = 20;
/// jsdom has no layout, so each chip is given a fixed width laid out left to right.
function layout(rowWidth: number) {
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(function (
this: HTMLElement,
) {
return this.dataset.test === "row" ? rowWidth : 0;
});
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (
this: HTMLElement,
) {
const chips = [...(this.parentElement?.querySelectorAll(":scope > [data-fit-chip]") ?? [])];
const index = chips.indexOf(this);
if (index >= 0) {
const left = index * (CHIP + GAP);
return { left, right: left + CHIP, width: CHIP } as DOMRect;
}
if (this.dataset.test === "oncall-fit-chips-more")
return { left: 0, right: MORE, width: MORE } as DOMRect;
return { left: 0, right: rowWidth, width: rowWidth } as DOMRect;
});
vi.spyOn(window, "getComputedStyle").mockReturnValue({
columnGap: `${GAP}px`,
} as CSSStyleDeclaration);
}
function render(items: string[]) {
return mount(OnCallFitChips, {
props: { items, tooltip: raw(items.join(" · ")), dataTest: "row" },
slots: { item: "<template #item='{ item }'><b>{{ item }}</b></template>" },
global: {
plugins: [i18n],
stubs: {
OTag: { name: "OTag", template: "<span><slot /></span>" },
OTooltip: { name: "OTooltip", props: ["content"], template: "<i />" },
},
},
attachTo: document.body,
});
}
const visible = (wrapper: ReturnType<typeof render>) =>
wrapper
.findAll("[data-fit-chip]")
.filter((chip) => (chip.element as HTMLElement).style.display !== "none")
.map((chip) => chip.text());
describe("OnCallFitChips", () => {
afterEach(() => vi.restoreAllMocks());
it("shows every chip and no +N when they all fit on one line", async () => {
layout(200);
const wrapper = render(["a", "b", "c"]);
await flushPromises();
expect(visible(wrapper)).toEqual(["a", "b", "c"]);
expect(
(wrapper.find('[data-test="oncall-fit-chips-more"]').element as HTMLElement).style.display,
).toBe("none");
});
// Rows stay one line high: what does not fit collapses into "+N", whose tooltip names everything.
it("keeps the chips that fit beside +N and names them all in its tooltip", async () => {
layout(100);
const wrapper = render(["a", "b", "c"]);
await flushPromises();
expect(visible(wrapper)).toEqual(["a"]);
const more = wrapper.find('[data-test="oncall-fit-chips-more"]');
expect(more.text()).toBe("+2");
expect(more.findComponent({ name: "OTooltip" }).props("content")).toBe("a · b · c");
});
it("always keeps one chip, so a cell is never only +N", async () => {
layout(30);
const wrapper = render(["a", "b"]);
await flushPromises();
expect(visible(wrapper)).toEqual(["a"]);
expect(wrapper.find('[data-test="oncall-fit-chips-more"]').text()).toBe("+1");
});
});
@@ -0,0 +1,105 @@
<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<!-- One row of chips that never wraps: the chips that fit, then "+N" whose tooltip names them all. -->
<template>
<span
ref="rowRef"
class="flex w-full max-w-full min-w-0 flex-nowrap items-center gap-1 overflow-hidden"
:data-test="dataTest"
>
<span
v-for="(item, index) in items"
v-show="index < shown"
:key="index"
class="flex min-w-0 items-center"
:class="index === shown - 1 && shown < items.length ? 'shrink overflow-hidden' : 'shrink-0'"
data-fit-chip
>
<slot name="item" :item="item" />
</span>
<span
v-show="shown < items.length"
ref="moreRef"
class="shrink-0"
data-test="oncall-fit-chips-more"
>
<OTag variant="default-outline" size="sm">{{ raw(`+${items.length - shown}`) }}</OTag>
<OTooltip side="bottom" :content="tooltip" />
</span>
</span>
</template>
<script setup lang="ts" generic="T">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import type { I18nText } from "@/types/i18n";
import { raw } from "@/types/i18n";
const props = withDefaults(defineProps<{ items: T[]; tooltip: I18nText; dataTest?: string }>(), {
dataTest: undefined,
});
defineSlots<{ item(props: { item: T }): unknown }>();
const rowRef = ref<HTMLElement | null>(null);
const moreRef = ref<HTMLElement | null>(null);
const shown = ref(props.items.length);
/// Shows every chip, then keeps as many as fit beside the "+N" chip; measured, because chip widths follow their text.
async function fit() {
const total = props.items.length;
shown.value = total;
await nextTick();
const row = rowRef.value;
if (!row || !row.clientWidth || !total) return;
const start = row.getBoundingClientRect().left;
const ends = [...row.querySelectorAll<HTMLElement>(":scope > [data-fit-chip]")].map(
(el) => el.getBoundingClientRect().right - start,
);
if ((ends[total - 1] ?? 0) <= row.clientWidth) return;
shown.value = total - 1;
await nextTick();
const gap = parseFloat(getComputedStyle(row).columnGap) || 0;
const more = (moreRef.value?.getBoundingClientRect().width ?? 0) + gap;
let fits = 0;
while (fits < total && ends[fits] + more <= row.clientWidth) fits += 1;
// One chip always shows, so a cell is never just "+N".
shown.value = Math.max(1, fits);
}
let observer: ResizeObserver | null = null;
onMounted(() => {
void fit();
if (typeof ResizeObserver === "undefined" || !rowRef.value) return;
let width = rowRef.value.clientWidth;
observer = new ResizeObserver(() => {
const next = rowRef.value?.clientWidth ?? 0;
if (next === width) return;
width = next;
void fit();
});
observer.observe(rowRef.value);
});
onBeforeUnmount(() => observer?.disconnect());
// By content, not identity: callers build the list fresh on every render.
watch(
() => JSON.stringify(props.items),
() => void fit(),
);
</script>
@@ -45,7 +45,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:class="index ? '-ms-1.5' : ''"
class="ring-surface-base rounded-full ring-2"
>
<OAvatar :value="email" size="sm" />
<OAvatar :value="email" :name="nameOf(email)" size="sm" />
</span>
</span>
<OTruncatedText class="text-sm">{{ summary }}</OTruncatedText>
@@ -63,6 +63,7 @@ import OSelect from "@/lib/forms/Select/OSelect.vue";
import type { SelectModelValue, SelectOption } from "@/lib/forms/Select/OSelect.types";
import type { I18nText } from "@/types/i18n";
import { raw, useI18nTyped } from "@/types/i18n";
import { useOrgUserNames } from "@/composables/useOrgUserNames";
const props = withDefaults(
defineProps<{
@@ -91,10 +92,12 @@ const { t } = useI18nTyped();
const faces = computed(() => model.value.slice(0, 3));
/// The part of the address a person answers to. The picker's job here is
/// recognition at a glance, and nobody scans a domain.
const { nameOf } = useOrgUserNames();
/// The name a person answers to; the local part of the address when they have none, because nobody scans a domain.
function shortName(email: string): string {
return email.split("@")[0] ?? email;
const name = nameOf(email);
return name === email ? (email.split("@")[0] ?? email) : name;
}
const summary = computed<I18nText>(() => {
@@ -124,7 +124,7 @@ const stubs = {
name: "OSelect",
props: ["modelValue", "options", "multiple"],
template: `<select multiple @change="$emit('update:modelValue', pick($event))">
<option v-for="o in options" :key="String(o.value)" :value="o.value" :disabled="o.disabled">{{ o.label }}</option>
<option v-for="o in options" :key="String(o.value)" :value="o.value" :disabled="o.disabled">{{ o.label }}{{ o.subLabel ? " – " + o.subLabel : "" }}{{ o.badge ? " · " + o.badge : "" }}</option>
</select>`,
methods: {
pick(e: any) {
@@ -239,7 +239,8 @@ describe("OnCallMembers", () => {
const picker = wrapper.find('[data-test="oncall-members-user-select"]');
expect(picker.exists()).toBe(true);
expect(wrapper.find('[data-test="oncall-members-email-input"]').exists()).toBe(false);
expect(picker.text()).toContain("Ana Sharma (ana@o2.ai)");
// Name to read, email to tell namesakes apart — one row, not "Name (email)" in one string.
expect(picker.text()).toContain("Ana Sharma – ana@o2.ai");
});
// A name is what a person recognises; the email disambiguates. A user with
@@ -288,6 +289,25 @@ describe("OnCallMembers", () => {
);
});
// Against the real OSelect: the picked people are listed once, below the field, not again as chips inside it.
it("lists picked people once, below the picker, by name with their email", async () => {
const wrapper = mount(OnCallMembers, {
props: { teamId: "team_1", teamName: "Platform", members: [], canConfigure: true },
global: { plugins: [i18n, store], stubs: { ...stubs, OSelect: false } },
});
await flushPromises();
await openAdd(wrapper);
wrapper.findComponent({ name: "OSelect" }).vm.$emit("update:modelValue", ["ana@o2.ai"]);
await flushPromises();
const field = wrapper.find('[data-test="oncall-members-user-select"]');
expect(field.text()).not.toContain("Ana Sharma");
expect(field.text()).toContain("Select a team member to add");
const staged = wrapper.find('[data-test="oncall-members-add-staged-ana@o2.ai"]');
expect(staged.text()).toContain("Ana Sharma");
expect(staged.text()).toContain("ana@o2.ai");
});
it("names the button without a count until somebody is picked", async () => {
const wrapper = render();
await flushPromises();
+34 -15
View File
@@ -223,7 +223,14 @@
:options="userOptions"
:loading="loadingUsers"
data-test="oncall-members-user-select"
/>
>
<!-- The picked people are listed once, below; chips here would say it twice. -->
<template #trigger>
<span class="text-select-placeholder flex-1 truncate text-start text-sm">
{{ t("oncall.memberPickPlaceholder") }}
</span>
</template>
</OSelect>
<OInput
v-else
ref="memberPickerRef"
@@ -245,7 +252,14 @@
class="flex items-center justify-between gap-2"
:data-test="`oncall-members-add-staged-${email}`"
>
<OUserCell :value="email" :name="nameOf(email)" />
<span class="flex min-w-0 flex-col">
<OTruncatedText class="text-text-heading text-sm">{{
raw(nameOf(email))
}}</OTruncatedText>
<OTruncatedText v-if="nameOf(email) !== email" class="text-text-secondary text-xs">{{
raw(email)
}}</OTruncatedText>
</span>
<OButton
variant="ghost"
size="icon-sm"
@@ -336,7 +350,6 @@ 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 OText from "@/lib/core/Typography/OText.vue";
import OUserCell from "@/lib/core/Table/cells/OUserCell.vue";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import type { I18nText } from "@/types/i18n";
import { raw, useI18nTyped } from "@/types/i18n";
@@ -727,19 +740,30 @@ const loadingUsers = ref(false);
// Losing the picker must not lose the ability to add anybody.
const userLookupFailed = ref(false);
const memberEmails = computed(() => new Set(props.members.map((m) => m.user_email)));
const memberEmails = computed(() => new Set(props.members.map((m) => m.user_email.toLowerCase())));
/** Every org user; those already on the team are listed but cannot be picked twice. */
const userOptions = computed(() =>
orgUsers.value.map((u) =>
memberEmails.value.has(u.email.toLowerCase())
orgUsers.value.map((u) => {
const name = nameOf(u.email);
const option = {
label: raw(name),
value: u.email,
subLabel: name === u.email ? undefined : raw(u.email),
subLabelInline: true,
searchText: `${name} ${u.email}`.toLowerCase(),
};
// An inline email row has no room for a badge, so a row already on the team trades its email for "On team".
return memberEmails.value.has(u.email.toLowerCase())
? {
label: t("oncall.membersAddOnTeam", { name: raw(displayName(u)) }),
value: u.email,
...option,
subLabel: undefined,
disabled: true,
badge: t("oncall.membersOnTeamBadge"),
badgeMuted: true,
}
: { label: raw(displayName(u)), value: u.email },
),
: option;
}),
);
/// Mirrors the server's `place_member`: simple rotations get the newcomer appended, layered ones need placing by hand.
@@ -776,11 +800,6 @@ const pendingEmails = computed(() => {
});
/** "Ana Sharma (ana@o2.ai)" when a name exists, the email otherwise. */
function displayName(user: { email: string; first_name?: string; last_name?: string }): string {
const name = [user.first_name, user.last_name].filter(Boolean).join(" ").trim();
return name ? `${name} (${user.email})` : user.email;
}
/** Just the name for the table — the email is already on the line below it. */
function nameOf(email: string): string {
const user = orgUsers.value.find((u) => u.email.toLowerCase() === email.toLowerCase());
@@ -37,18 +37,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<template #cell-match="{ row }">
<span
class="flex w-full max-w-full min-w-0 flex-wrap items-center gap-1 py-1"
<!-- One line per row: the chips that fit, and "+N" naming every condition. -->
<OnCallFitChips
:items="conditionsOf(row)"
:tooltip="conditionsTip(row)"
:data-test="`oncall-routing-says-${row.rule_id}`"
>
<ODimensionChip
v-for="condition in conditionsOf(row)"
:key="condition.name"
:dim-key="condition.name"
:key-label="condition.label"
:value="condition.value"
/>
</span>
<template #item="{ item }">
<ODimensionChip :dim-key="item.name" :key-label="item.label" :value="item.value" />
</template>
</OnCallFitChips>
</template>
<template #cell-team="{ row }">
@@ -58,16 +56,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<template #cell-specificity="{ row }">
<span class="flex flex-wrap gap-1">
<OTag
v-for="condition in conditionsOf(row)"
:key="condition.name"
type="dimensionKey"
:value="condition.name"
:label="raw(condition.field)"
size="sm"
/>
</span>
<OnCallFitChips
:items="conditionsOf(row)"
:tooltip="fieldsTip(row)"
:data-test="`oncall-routing-specificity-${row.rule_id}`"
>
<template #item="{ item }">
<OTag type="dimensionKey" :value="item.name" :label="raw(item.field)" size="sm" />
</template>
</OnCallFitChips>
</template>
<template #cell-caught="{ row }">
@@ -237,6 +234,7 @@ import { computed } from "vue";
import { useStore } from "vuex";
import ODimensionChip from "@/lib/core/Badge/ODimensionChip.vue";
import OnCallFitChips from "./OnCallFitChips.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
@@ -444,6 +442,23 @@ function conditionsOf(rule: OwnershipRuleStats): RuleCondition[] {
});
}
/// Every condition in one line, for the "+N" chip that stands in for the ones that did not fit.
function conditionsTip(rule: OwnershipRuleStats): I18nText {
return raw(
conditionsOf(rule)
.map((condition) => `${condition.label} = ${condition.value}`)
.join(" · "),
);
}
function fieldsTip(rule: OwnershipRuleStats): I18nText {
return raw(
conditionsOf(rule)
.map((condition) => condition.field)
.join(" · "),
);
}
/// A shadowed rule names the team taking its pages, because "shadowed" alone does not say who to talk to.
function healthLabel(rule: OwnershipRuleStats): I18nText {
if (rule.health === "shadowed") {
+8 -14
View File
@@ -16,8 +16,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- A person by display name inside a sentence; the email rides in the tooltip because two people can share a name. -->
<template>
<span :data-test="dataTest">
<OTooltip side="bottom" :content="tooltip" />
<span :data-test="dataTest" :class="truncate ? 'block min-w-0 truncate' : ''">
<OTooltip side="bottom" :content="tooltipOf(email)" />
<span>{{ name }}</span>
</span>
</template>
@@ -28,20 +28,14 @@ import { computed } from "vue";
import { useOrgUserNames } from "@/composables/useOrgUserNames";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import type { I18nText } from "@/types/i18n";
import { raw, useI18nTyped } from "@/types/i18n";
import { raw } from "@/types/i18n";
const props = withDefaults(defineProps<{ email: string; dataTest?: string }>(), {
dataTest: "oncall-person-name",
});
const props = withDefaults(
defineProps<{ email: string; dataTest?: string; truncate?: boolean }>(),
{ dataTest: "oncall-person-name", truncate: false },
);
const { t } = useI18nTyped();
const { nameOf } = useOrgUserNames();
const { nameOf, tooltipOf } = useOrgUserNames();
const name = computed<I18nText>(() => raw(nameOf(props.email)));
const tooltip = computed<I18nText>(() =>
String(name.value) === props.email
? raw(props.email)
: t("oncall.personNameWithEmail", { name: name.value, email: raw(props.email) }),
);
</script>
@@ -158,7 +158,7 @@ describe("OnCallPolicyEditor", () => {
["oncall-policy-channel-1-webhook", "Chat / webhook"],
]);
expect(wrapper.find('[data-test="oncall-policy-channels-hint"]').text()).toBe(
"Everyone paged at P1 gets the same set. Texts and calls go to people with a verified phone.",
"Texts and calls reach only people with a verified phone.",
);
});
@@ -120,7 +120,7 @@
inline-actions
data-test="oncall-policy-sentence"
>
{{ ladderSentence }}
<OTruncatedText class="min-w-0">{{ ladderSentence }}</OTruncatedText>
</OBanner>
<div class="flex flex-col gap-2">
@@ -409,8 +409,6 @@
<OTabPanel name="delivery" data-test="oncall-policy-delivery">
<div class="flex flex-col gap-3 px-4 pt-3 pb-4">
<OText variant="meta">{{ t("oncall.policyDeliveryHint") }}</OText>
<!-- Channels apply to everyone paged at this priority; the primary
and the secondary are not treated differently. -->
<div v-if="current" class="flex flex-col gap-2">
@@ -430,11 +428,7 @@
</div>
<div class="flex flex-col items-start gap-2 ps-34 max-md:ps-0">
<OText variant="meta" data-test="oncall-policy-channels-hint">
{{
t("oncall.policyChannelsHint", {
priority: raw(priorityLabel(current.priority)),
})
}}
{{ t("oncall.policyChannelsHintShort") }}
</OText>
<OText v-if="emailLocked" variant="meta" data-test="oncall-policy-email-locked">
{{ t("oncall.policyEmailLockedBySlack") }}
@@ -487,7 +481,7 @@
>
{{ t("oncall.policyReachesNobody") }}
</OTag>
<OText v-else variant="meta">{{ t("oncall.policyDestinationsHint") }}</OText>
<OText v-else variant="meta">{{ t("oncall.policyDestinationsHintShort") }}</OText>
</div>
<!-- C15/C16: WHERE the team is talked to, beside the policy list it
@@ -495,9 +489,18 @@
channel and pages still go to the old room" has to be answerable
here, not by paging somebody to find out. -->
<div class="flex flex-wrap items-center gap-2" data-test="oncall-team-channel">
<OText variant="label" class="w-32 shrink-0">
{{ t("oncall.teamChannelTitle") }}
</OText>
<span class="flex w-32 shrink-0 items-center gap-1">
<OText variant="label">{{ t("oncall.teamChannelTitle") }}</OText>
<!-- One post per firing, not a live room: the HTTP transport cannot edit what it already sent. -->
<OIcon
name="info-outline"
size="sm"
class="text-text-secondary cursor-help"
data-test="oncall-team-channel-info"
>
<OTooltip side="right" :content="t('oncall.teamChannelHint')" />
</OIcon>
</span>
<span class="w-72">
<OSelect
v-model="teamChannelDraft"
@@ -532,10 +535,6 @@
>
{{ t("oncall.teamChannelUsePolicy") }}
</OButton>
<!-- One post per firing, not a live room: the only transport is an
HTTP destination, which cannot edit what it already sent. Said
here so nobody designs an expectation the engine cannot meet. -->
<OText variant="meta">{{ t("oncall.teamChannelHint") }}</OText>
<!-- Only the team's own empty list is a choice; an empty list inherited from the policy is the policy's. -->
<span
v-if="channelFromTeam && !teamChannelDraft.length"
@@ -618,6 +617,7 @@ import { useOrgUserNames } from "@/composables/useOrgUserNames";
import OText from "@/lib/core/Typography/OText.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
@@ -175,20 +175,26 @@ describe("OnCallRoutingSimulator", () => {
{ email: "ana@o2.ai", first_name: "Ana" },
{ email: "raj@o2.ai", first_name: "Raj" },
]);
const lands = (email: string) => ({
user_email: email,
reason: "",
would_a_page_land: true,
deliverable_channels: ["email"],
});
escalationPreview.mockResolvedValue({
data: {
channels: ["email"],
rungs: [
{ after_micros: 0, targets: [], recipients: [], resolves_to_nobody: false },
{
after_micros: 120_000_000,
after_micros: 0,
targets: [],
recipients: [{ user_email: "ana@o2.ai" }],
recipients: [lands("ana@o2.ai")],
resolves_to_nobody: false,
},
{
after_micros: 300_000_000,
targets: [],
recipients: [{ user_email: "ana@o2.ai" }, { user_email: "raj@o2.ai" }],
recipients: [lands("ana@o2.ai"), lands("raj@o2.ai")],
resolves_to_nobody: false,
},
],
@@ -210,6 +216,79 @@ describe("OnCallRoutingSimulator", () => {
expect(ana!.findComponent(OTooltip).props("content")).toBe("Ana (ana@o2.ai)");
});
// The engine's current responder is one person; a first step paging Primary and Secondary wakes both.
it("names everybody the first step pages as woken now, not only the current responder", async () => {
vi.useRealTimers();
queryClient.setQueryData(orgUsersQuery("default").queryKey, [
{ email: "ana@o2.ai", first_name: "Ana" },
{ email: "raj@o2.ai", first_name: "Raj" },
{ email: "kim@o2.ai", first_name: "Kim" },
]);
const lands = (email: string) => ({
user_email: email,
reason: "",
would_a_page_land: true,
deliverable_channels: ["email", "sms"],
});
escalationPreview.mockResolvedValue({
data: {
channels: ["email", "sms"],
rungs: [
{
after_micros: 0,
targets: [],
recipients: [lands("ana@o2.ai"), lands("raj@o2.ai")],
resolves_to_nobody: false,
},
{
after_micros: 300_000_000,
targets: [],
recipients: [lands("ana@o2.ai"), lands("raj@o2.ai"), lands("kim@o2.ai")],
resolves_to_nobody: false,
},
],
},
});
const wrapper = render({ seed: { service: "payments-api" }, preview: preview() });
await flushPromises();
await flushPromises();
const responder = wrapper.find('[data-test="oncall-simulator-responder"]').text();
expect(responder).toBe("wakes Ana, Raj now (Email, SMS)");
const next = wrapper.find('[data-test="oncall-simulator-next"]').text();
expect(next).toBe("then Kim after 5m");
});
it("says why when nobody on the first step can receive a page", async () => {
vi.useRealTimers();
escalationPreview.mockResolvedValue({
data: {
channels: ["email"],
rungs: [
{
after_micros: 0,
targets: [],
recipients: [
{
user_email: "ana@o2.ai",
reason: "",
would_a_page_land: false,
why_not: "no verified channel",
deliverable_channels: [],
},
],
resolves_to_nobody: false,
},
],
},
});
const wrapper = render({ seed: { service: "payments-api" }, preview: preview() });
await flushPromises();
await flushPromises();
const responder = wrapper.find('[data-test="oncall-simulator-responder"]');
expect(responder.text()).toBe("no verified channel");
expect(responder.classes()).toContain("text-status-error-text");
});
it("says a silent priority pages nobody rather than naming the responder", async () => {
const wrapper = render({ seed: { service: "payments-api" }, preview: preview() });
select(wrapper, "oncall-simulator-priority").vm.$emit("update:modelValue", "P4");
@@ -70,12 +70,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:aria-busy="loading"
data-test="oncall-simulator-result"
>
<span class="flex min-w-0 items-baseline gap-1 text-sm" data-test="oncall-simulator-answer">
<span class="text-text-heading shrink-0 font-semibold" data-test="oncall-simulator-team">
<!-- Wraps rather than cuts: the names are the answer, and a tooltip would hide the one being asked for. -->
<p class="text-sm" data-test="oncall-simulator-answer">
<span class="text-text-heading font-semibold" data-test="oncall-simulator-team">
{{ t("oncall.simulatorAnswerTeam", { team: teamLabel }) }}
</span>
<OTruncatedText v-if="preview.team_id" class="text-text-body flex-1">
<span>{{ raw("· ") }}</span>
<span v-if="preview.team_id" class="text-text-body">
<span>{{ raw(" · ") }}</span>
<i18n-t
v-if="wakes"
keypath="oncall.simulatorWakesNow"
@@ -85,10 +86,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="oncall-simulator-responder"
>
<template #who>
<OnCallPersonName
:email="wakes.email"
data-test="oncall-simulator-responder-person"
/>
<template v-for="(email, index) in wakes.emails" :key="email">
<span v-if="index">{{ raw(", ") }}</span>
<OnCallPersonName :email="email" data-test="oncall-simulator-responder-person" />
</template>
</template>
<template #channels>{{ wakes.channels }}</template>
</i18n-t>
@@ -112,8 +113,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template #after>{{ nextStep.after }}</template>
</i18n-t>
</template>
</OTruncatedText>
</span>
</span>
</p>
<OCollapsible :label="t('oncall.simulatorWhy')" data-test="oncall-simulator-why">
<div class="flex flex-col gap-1 pt-1">
@@ -194,7 +195,6 @@ import OButton from "@/lib/core/Button/OButton.vue";
import OCollapsible from "@/lib/core/Collapsible/OCollapsible.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OText from "@/lib/core/Typography/OText.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import type { SelectOption } from "@/lib/forms/Select/OSelect.types";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
@@ -428,31 +428,6 @@ const sentChannels = computed<Channel[]>(() => {
return person.deliverable_channels.filter((channel) => entry.channels.includes(channel));
});
/// The person and channels when a page would land, so the name renders with its email tooltip.
const wakes = computed<{ email: string; channels: string } | null>(() => {
if (ladderEntry.value && !ladderEntry.value.pages_anyone) return null;
const person = responder.value;
if (!person?.would_a_page_land || !sentChannels.value.length) return null;
const channels = sentChannels.value
.map((channel) => String(t(`oncall.channel_${channel}`)))
.join(", ");
return { email: person.user_email, channels };
});
const responderNote = computed<I18nText>(() => {
if (ladderEntry.value && !ladderEntry.value.pages_anyone) return t("oncall.reachPagesNobody");
const person = responder.value;
if (!person) return t("oncall.simulatorNobodyOnCall");
if (!person.would_a_page_land) return raw(person.why_not) || t("oncall.contactNoChannel");
return t("oncall.simulatorSendsNothing");
});
const responderTone = computed(() => {
if (ladderEntry.value && !ladderEntry.value.pages_anyone) return "text-text-secondary";
if (!responder.value?.would_a_page_land) return "text-status-error-text";
return sentChannels.value.length ? "text-text-body" : "text-status-warning-text";
});
const orgId = computed<string>(() => store.state.selectedOrganization?.identifier ?? "");
const escalation = useQuery(() =>
@@ -466,13 +441,83 @@ const escalation = useQuery(() =>
),
);
/// The next step that wakes somebody new; hidden when the reader may not see the ladder.
/// The ladder's first step, which pages every target on it at once; null until the ladder has loaded.
const firstStep = computed<PreviewRung | null>(() => {
if (escalation.isError.value) return null;
const rungs = [...(escalation.data.value?.rungs ?? [])].sort(
(a, b) => a.after_micros - b.after_micros,
);
return rungs[0] ?? null;
});
/// Everybody the first step reaches and what they are sent, so the names render with their email tooltips.
const wakes = computed<{ emails: string[]; channels: string } | null>(() => {
if (ladderEntry.value && !ladderEntry.value.pages_anyone) return null;
const step = firstStep.value;
if (!step) return wakesFromResponder();
const landing = step.recipients.filter((one) => one.would_a_page_land);
const ladderChannels = escalation.data.value?.channels ?? ladderEntry.value?.channels ?? [];
const sent = ladderChannels.filter((channel) =>
landing.some((one) => one.deliverable_channels.includes(channel)),
);
if (!landing.length || !sent.length) return null;
return {
emails: [...new Set(landing.map((one) => one.user_email))],
channels: sent.map((channel) => String(t(`oncall.channel_${channel}`))).join(", "),
};
});
/// Before the ladder loads, or when the reader may not see it, the engine's current responder is all there is.
function wakesFromResponder(): { emails: string[]; channels: string } | null {
const person = responder.value;
if (!person?.would_a_page_land || !sentChannels.value.length) return null;
const channels = sentChannels.value
.map((channel) => String(t(`oncall.channel_${channel}`)))
.join(", ");
return { emails: [person.user_email], channels };
}
const responderNote = computed<I18nText>(() => {
if (ladderEntry.value && !ladderEntry.value.pages_anyone) return t("oncall.reachPagesNobody");
const step = firstStep.value;
if (step) {
if (!step.recipients.length) return t("oncall.simulatorNobodyOnCall");
const blocked = step.recipients.find((one) => !one.would_a_page_land);
if (!step.recipients.some((one) => one.would_a_page_land)) {
return raw(blocked?.why_not) || t("oncall.contactNoChannel");
}
return t("oncall.simulatorSendsNothing");
}
const person = responder.value;
if (!person) return t("oncall.simulatorNobodyOnCall");
if (!person.would_a_page_land) return raw(person.why_not) || t("oncall.contactNoChannel");
return t("oncall.simulatorSendsNothing");
});
const responderTone = computed(() => {
if (ladderEntry.value && !ladderEntry.value.pages_anyone) return "text-text-secondary";
if (firstStep.value) {
if (wakes.value) return "text-text-body";
return firstStep.value.recipients.some((one) => one.would_a_page_land)
? "text-status-warning-text"
: "text-status-error-text";
}
if (!responder.value?.would_a_page_land) return "text-status-error-text";
return sentChannels.value.length ? "text-text-body" : "text-status-warning-text";
});
/// The next step that wakes somebody the first step did not; hidden when the reader may not see the ladder.
const nextStep = computed<{ emails: string[]; after: string } | null>(() => {
if (escalation.isError.value) return null;
const woken = responder.value?.user_email.toLowerCase();
const rungs = [...(escalation.data.value?.rungs ?? [])].sort(
(a, b) => a.after_micros - b.after_micros,
);
const first = rungs[0];
if (!first) return null;
const woken = new Set(first.recipients.map((one) => one.user_email.toLowerCase()));
const fresh = (rung: PreviewRung) =>
rung.recipients.map((one) => one.user_email).filter((email) => email.toLowerCase() !== woken);
const step = (escalation.data.value?.rungs ?? []).slice(1).find((rung) => fresh(rung).length);
rung.recipients.map((one) => one.user_email).filter((email) => !woken.has(email.toLowerCase()));
const step = rungs.slice(1).find((rung) => fresh(rung).length);
if (!step) return null;
return {
emails: [...new Set(fresh(step))],
+22 -5
View File
@@ -30,7 +30,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
class="text-text-secondary"
aria-hidden="true"
/>
<span class="text-sm" :title="member">{{ raw(nameOf(member)) }}</span>
<OnCallPersonName
:email="member"
class="text-sm"
:data-test="`oncall-rule-people-name-${ruleIndex}-${position}`"
/>
<OTag
v-if="position === 0 && holder"
:variant="holder.now ? 'success-soft' : 'default-soft'"
@@ -96,7 +100,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:label="t('oncall.memberOrderDrag')"
/>
<span class="text-text-secondary min-w-4 text-xs tabular-nums">{{ position + 1 }}</span>
<OUserCell :value="member" />
<OnCallPersonName
truncate
:email="member"
class="text-text-body text-sm"
:data-test="`oncall-rule-person-name-${ruleIndex}-${position}`"
/>
<OTag
v-if="holder?.email === member"
:variant="holder.now ? 'success-soft' : 'default-soft'"
@@ -111,7 +120,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
variant="ghost"
size="icon-xs-sq"
class="ms-auto"
:aria-label="t('oncall.peopleMenu', { name: raw(member) })"
:aria-label="t('oncall.peopleMenu', { name: raw(nameOf(member)) })"
:data-test="`oncall-rule-person-menu-${ruleIndex}-${position}`"
/>
</template>
@@ -182,7 +191,7 @@ import { VueDraggableNext } from "vue-draggable-next";
import OTag from "@/lib/core/Badge/OTag.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OUserCell from "@/lib/core/Table/cells/OUserCell.vue";
import OnCallPersonName from "./OnCallPersonName.vue";
import OText from "@/lib/core/Typography/OText.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
@@ -220,7 +229,15 @@ const hidden = computed(() => Math.max(0, line.value.length - LINE_NAMES));
const addable = computed(() =>
props.teamMembers
.filter((email) => !props.rule.members.includes(email))
.map((email) => ({ label: raw(email), value: email })),
.map((email) => {
const name = props.nameOf(email);
return {
label: raw(name),
value: email,
subLabel: name === email ? undefined : raw(email),
searchText: `${name} ${email}`.toLowerCase(),
};
}),
);
// Same rule as the server's save: whoever is on call now stays on call, newcomers go last.
@@ -55,7 +55,9 @@
<template #label>
<span class="flex min-w-0 flex-col gap-0.5">
<OText variant="body-strong">{{ raw(preset.name) }}</OText>
<OText variant="meta">{{ raw(preset.description) }}</OText>
<OTruncatedText class="text-text-secondary text-xs">{{
raw(preset.description)
}}</OTruncatedText>
</span>
</template>
</ORadio>
@@ -349,6 +351,8 @@ import OIcon from "@/lib/core/Icon/OIcon.vue";
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import OText from "@/lib/core/Typography/OText.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import { useOrgUserNames } from "@/composables/useOrgUserNames";
import ORadio from "@/lib/forms/Radio/ORadio.vue";
import ORadioGroup from "@/lib/forms/Radio/ORadioGroup.vue";
import useBreakpoint from "@/composables/useBreakpoint";
@@ -422,8 +426,10 @@ const overridden = ref<Record<string, boolean>>({});
/// Kept as plain wire values so `apply` is a spread, not a translation.
const model = ref<Record<string, unknown>>({});
const { optionOf } = useOrgUserNames();
const memberOptions = computed<SelectOption[]>(() =>
props.members.map((m) => ({ label: raw(m.user_email), value: m.user_email })),
props.members.map((m) => optionOf(m.user_email, true)),
);
// ── The catalogue ─────────────────────────────────────────────────────────────
@@ -173,6 +173,43 @@ describe("OnCallScheduleTimeline", () => {
expect(add.attributes("data-icon")).toBe("add");
});
// A template rewrites the first rotation, so it can only be the way to build one while there is none.
it("offers a template or scratch from Add rotation only while the team has no rotations", async () => {
const empty = render({ rotations: [], segments: [] });
expect(empty.find('[data-test="oncall-timeline-add"]').text()).toBe("Add rotation");
await empty.find('[data-test="oncall-timeline-add-template"]').trigger("click");
expect(empty.emitted("presets")).toHaveLength(1);
await empty.find('[data-test="oncall-timeline-add-scratch"]').trigger("click");
expect(empty.emitted("add")).toHaveLength(1);
const staffed = render();
expect(staffed.find('[data-test="oncall-timeline-add-template"]').exists()).toBe(false);
await staffed.find('[data-test="oncall-timeline-add"]').trigger("click");
expect(staffed.emitted("add")).toHaveLength(1);
});
// The band only has room for a name; two people can share one, so the email rides the tooltip.
it("gives a shift band a tooltip with the person's name, email and window", () => {
const wrapper = mount(OnCallScheduleTimeline, {
props: {
rotations: [rotation("Primary")],
segments: [seg()],
timezone: "UTC",
window: { from: 0, to: 0 },
"onUpdate:window": () => {},
} as any,
global: {
plugins: [i18n, store],
stubs: { ...stubs, OTooltip: { name: "OTooltip", props: ["content"], template: "<i />" } },
},
});
const band = tracksOf(wrapper)[0].bands[0];
const tip = wrapper
.findAllComponents({ name: "OTooltip" })
.find((one: any) => one.attributes("data-test") === `oncall-shift-tip-${band.key}`);
expect(String(tip!.props("content"))).toMatch(/^ana@o2\.ai · .+ – .+/);
});
it("draws one lane per rotation", () => {
const wrapper = render({
rotations: [rotation("Primary"), rotation("Secondary")],
@@ -97,7 +97,36 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OToggleGroupItem>
</OToggleGroup>
<!-- A template rewrites the first rotation, so it can only build a new one while there is none. -->
<ODropdown v-if="!rotations.length" align="end">
<template #trigger>
<OButton
variant="outline"
size="sm-action"
icon-left="add"
icon-right="arrow-drop-down"
data-test="oncall-timeline-add"
>
{{ t("oncall.addRotation") }}
</OButton>
</template>
<ODropdownItem
icon-left="dashboard-customize"
data-test="oncall-timeline-add-template"
@select="emit('presets')"
>
{{ t("oncall.addRotationFromTemplate") }}
</ODropdownItem>
<ODropdownItem
icon-left="edit"
data-test="oncall-timeline-add-scratch"
@select="emit('add')"
>
{{ t("oncall.addRotationFromScratch") }}
</ODropdownItem>
</ODropdown>
<OButton
v-else
variant="outline"
size="sm-action"
icon-left="add"
@@ -256,10 +285,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</template>
<!-- The tooltip follows the band, so it anchors to the band itself, not to the menu around it. -->
<template #band="{ band }">
<ODropdown v-if="shiftActions(band.key).length" align="start">
<template #trigger>
<OScheduleBand :band="band" role="button" aria-haspopup="menu" class="cursor-pointer" />
<OScheduleBand
:band="band"
:tooltip="bandTip(band.key) ? false : undefined"
role="button"
aria-haspopup="menu"
class="cursor-pointer"
/>
</template>
<ODropdownItem
v-for="action in shiftActions(band.key)"
@@ -271,7 +307,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
{{ action.label }}
</ODropdownItem>
</ODropdown>
<OScheduleBand v-else :band="band" />
<OScheduleBand v-else :band="band" :tooltip="bandTip(band.key) ? false : undefined" />
<OTooltip
v-if="bandTip(band.key)"
side="top"
:content="bandTip(band.key)"
:data-test="`oncall-shift-tip-${band.key}`"
/>
</template>
<!-- Three entries, and every one of them means something. The old key
@@ -432,7 +474,7 @@ const { isMobile } = useBreakpoint();
const nowMicros = useOnCallClock();
// Band labels only: a narrow bar has no room for an email; the aria-label keeps the full address.
const { nameOf } = useOrgUserNames();
const { nameOf, tooltipOf } = useOrgUserNames();
/// The lane dots, spelled out rather than built from the index: Tailwind reads
/// class names as literal text, so a template-built `bg-schedule-band-${n}-...`
@@ -778,6 +820,29 @@ const segmentByBand = computed(() => {
return map;
});
const tipRange = (start: number, end: number) =>
t("oncall.shiftBandTipRange", {
from: raw(fmt(start, { weekday: "short", hour: "2-digit", minute: "2-digit" })),
to: raw(fmt(end, { weekday: "short", hour: "2-digit", minute: "2-digit" })),
});
/// Name and email for a band's person, because the band itself only has room for a name and two people can share one.
function bandTip(bandKey: string): I18nText | undefined {
const segment = segmentByBand.value.get(bandKey);
if (segment?.user_email) {
return t("oncall.shiftBandTip", {
person: tooltipOf(segment.user_email),
range: tipRange(segment.from, segment.to),
});
}
const absence = props.absences.find((one) => `away-${one.id}` === bandKey);
if (!absence) return undefined;
return t("oncall.shiftBandTip", {
person: tooltipOf(absence.user_email),
range: tipRange(absence.start_at, absence.end_at),
});
}
interface ShiftMenuItem {
id: ShiftAction;
icon: IconName;
@@ -61,13 +61,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:key="clash.id"
class="text-status-warning-text text-xs"
>
{{
t("oncall.rotationMemberAway", {
who: raw(clash.user_email),
from: raw(shortDate(clash.start_at)),
to: raw(shortDate(clash.end_at)),
})
}}
<i18n-t keypath="oncall.rotationMemberAway" tag="span" scope="global">
<template #who><OnCallPersonName :email="clash.user_email" /></template>
<template #from>{{ raw(shortDate(clash.start_at)) }}</template>
<template #to>{{ raw(shortDate(clash.end_at)) }}</template>
</i18n-t>
</p>
</div>
@@ -245,6 +243,7 @@ import {
formatMinuteOfDay,
} from "@/utils/oncall";
import OnCallRulePeople from "./OnCallRulePeople.vue";
import OnCallPersonName from "./OnCallPersonName.vue";
const props = defineProps<{
rule: ShiftRule;
+35 -2
View File
@@ -17,11 +17,22 @@ import { computed } from "vue";
import { useStore } from "vuex";
import { useQuery } from "@tanstack/vue-query";
import { orgUsersQuery } from "@/services/users.queries";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
/** A person as a picker row: the name to read, the email to tell two namesakes apart. */
export type PersonOption = {
label: I18nText;
value: string;
subLabel?: I18nText;
subLabelInline?: boolean;
searchText: string;
};
/** A person's display name from the org's cached user list, or their email until it loads or when they have none. */
export function useOrgUserNames() {
const store = useStore();
const orgId = computed<string>(() => store.state.selectedOrganization.identifier);
// Optional chaining: components using this render inside specs that mount no store.
const orgId = computed<string>(() => store?.state?.selectedOrganization?.identifier ?? "");
const users = useQuery(() =>
Object.assign(orgUsersQuery(orgId.value), { enabled: !!orgId.value }),
);
@@ -34,5 +45,27 @@ export function useOrgUserNames() {
return name || email;
}
return { nameOf };
const { t } = useI18nTyped();
/** Name and email in one line, for a tooltip; just the email when there is no name. */
function tooltipOf(email: string): I18nText {
const name = nameOf(email);
return name === email
? raw(email)
: t("oncall.personNameWithEmail", { name: raw(name), email: raw(email) });
}
/** `inline` for multi-selects, whose stacked rows push the checkbox onto a line of its own. */
function optionOf(email: string, inline = false): PersonOption {
const name = nameOf(email);
return {
label: raw(name),
value: email,
subLabel: name === email ? undefined : raw(email),
subLabelInline: inline,
searchText: `${name} ${email}`.toLowerCase(),
};
}
return { nameOf, tooltipOf, optionOf };
}
+2 -2
View File
@@ -287,8 +287,8 @@ export const BADGE_GROUPS = {
shape: "pill",
values: {
covered: { variant: "success-soft", labelKey: "oncall.coverage_covered" },
gap: { variant: "warning-soft", labelKey: "oncall.coverage_gap" },
unreachable: { variant: "error-soft", labelKey: "oncall.coverage_unreachable" },
gap: { variant: "error-soft", labelKey: "oncall.coverage_gap" },
unreachable: { variant: "warning-soft", labelKey: "oncall.coverage_cantReach" },
},
fallback: { variant: "default-soft" },
},
+1 -1
View File
@@ -120,7 +120,7 @@ const surfaceClasses = computed(() => [
v-if="$slots.label || props.label"
:class="[
'o-input-label text-compact leading-tight select-none',
props.variant === 'card' ? 'flex-1' : '',
props.variant === 'card' ? 'min-w-0 flex-1' : '',
props.disabled
? 'text-input-label-text-disabled font-normal'
: 'text-input-label-text font-medium',
+9
View File
@@ -25826,6 +25826,15 @@
"coverage_covered": "Covered",
"coverage_gap": "Nobody on call",
"coverage_unreachable": "Rostered, unreachable",
"coverage_cantReach": "On call but can't be reached",
"coverageCantReachTip": "{count} of {total} on call can't be reached",
"shiftBandTip": "{person} · {range}",
"shiftBandTipRange": "{from} – {to}",
"policyChannelsHintShort": "Texts and calls reach only people with a verified phone.",
"policyDestinationsHintShort": "Any alert destination with a URL — Slack, Teams, PagerDuty.",
"addRotationFromTemplate": "From a template",
"addRotationFromScratch": "From scratch",
"membersOnTeamBadge": "On team",
"eventKind_sys": "System",
"eventKind_page": "Page",
"eventKind_ack": "Ack",
+79 -9
View File
@@ -279,26 +279,41 @@ describe("OnCallTeamDetail", () => {
} as any);
});
const DialogStub = {
name: "ODialog",
props: ["open", "title"],
template: "<div v-if='open'><slot /></div>",
};
async function ask() {
const wrapper = render();
const wrapper = mount(OnCallTeamDetail, {
global: { plugins: [i18n, store], stubs: { ...stubs, ODialog: DialogStub } },
});
await flushPromises();
wrapper.findComponent({ name: "OTabPanels" }).vm.$emit("update:modelValue", "policy");
await flushPromises();
wrapper.findComponent({ name: "OnCallEscalationLadder" }).vm.$emit("send-test");
await flushPromises();
return wrapper
.findAllComponents({ name: "ConfirmDialog" })
const confirm = wrapper
.findAllComponents({ name: "ODialog" })
.find((c: any) => c.props("title") === "Send a test page?")!;
return { wrapper, confirm };
}
// The first step by delay, and only the channels its people can receive, which is what the server sends.
it("names the recipients and channels before sending anything", async () => {
const confirm = await ask();
const { wrapper, confirm } = await ask();
expect(confirm.props("modelValue")).toBe(true);
expect(confirm.props("message")).toBe(
expect(confirm.props("open")).toBe(true);
expect(wrapper.find('[data-test="oncall-team-test-page-message"]').text()).toBe(
"Send a test page for P1 to ana@corp.com, bob@corp.com? Email, SMS. It does not escalate.",
);
// Each name carries its email in a tooltip, because two people can share a name.
const people = wrapper.findAllComponents({ name: "OnCallPersonName" });
expect(people.map((one: any) => one.props("email"))).toEqual([
"ana@corp.com",
"bob@corp.com",
]);
expect(service.testPage).not.toHaveBeenCalled();
});
@@ -313,8 +328,8 @@ describe("OnCallTeamDetail", () => {
],
},
} as any);
const confirm = await ask();
confirm.vm.$emit("update:ok");
const { confirm } = await ask();
confirm.vm.$emit("click:primary");
await flushPromises();
expect(service.testPage).toHaveBeenCalledWith(expect.objectContaining({ priority: 1 }));
@@ -371,7 +386,62 @@ describe("OnCallTeamDetail", () => {
const wrapper = render();
await flushPromises();
expect(chip(wrapper)).toContain("Rostered, unreachable");
expect(chip(wrapper)).toContain("On call but can't be reached");
});
// Green only when every holder can be paged, so the chip agrees with the banner naming the one who cannot.
it("is not green while one of two people on call cannot be reached", async () => {
service.whoIsOnCall.mockResolvedValue({
data: [
{
rotation_id: "rot_primary",
rotation_name: "Primary",
rule: "Base",
user_email: "ana@corp.com",
},
{
rotation_id: "rot_secondary",
rotation_name: "Secondary",
rule: "Base",
user_email: "bob@corp.com",
},
],
} as any);
service.teamReachability.mockResolvedValue({
data: {
team_id: "team_1",
smtp_configured: true,
members: [
{
user_email: "ana@corp.com",
would_a_page_land: false,
channels: [],
deliverable_channels: [],
},
{
user_email: "bob@corp.com",
would_a_page_land: true,
channels: [],
deliverable_channels: ["email"],
},
],
reachable: 1,
total: 2,
unreachable_members: ["ana@corp.com"],
},
} as any);
const wrapper = render();
await flushPromises();
expect(chip(wrapper)).toContain("On call but can't be reached");
const tag = wrapper
.findAllComponents({ name: "OTag" })
.find((one: any) => one.props("type") === "oncallCoverage")!;
expect(tag.props("value")).toBe("unreachable");
const tip = wrapper
.findAllComponents({ name: "OTooltip" })
.find((one: any) => String(one.props("content")).includes("can't be reached"));
expect(tip!.props("content")).toBe("1 of 2 on call can't be reached");
});
it("stays covered while the holder can actually be paged", async () => {
+65 -49
View File
@@ -22,13 +22,15 @@
<!-- Whether a page would reach anybody is the team's headline fact, so it
rides the title instead of sitting in a card below it. -->
<template #title-trail>
<OTag
v-if="loaded"
type="oncallCoverage"
:value="coverageState"
size="sm"
data-test="oncall-team-coverage"
/>
<span v-if="loaded" class="inline-flex">
<OTag
type="oncallCoverage"
:value="coverageState"
size="sm"
data-test="oncall-team-coverage"
/>
<OTooltip v-if="coverageTip" side="bottom" :content="coverageTip" />
</span>
</template>
<template #actions>
@@ -175,7 +177,6 @@
<OnCallTeamAttention
:risks="configRisks"
:timezone="team?.timezone ?? 'UTC'"
:viewer-timezone="store.state.timezone"
:reachability="reachability"
:overview="overview"
:checked-at="insightsCheckedAt"
@@ -218,7 +219,6 @@
:rotations="schedule?.rotations ?? []"
:unreachable="unreachableEmails"
:timezone="team?.timezone ?? 'UTC'"
:viewer-timezone="store.state.timezone"
:days="COVERAGE_DAYS"
/>
</div>
@@ -247,7 +247,6 @@
:members="members"
:rotations="schedule?.rotations ?? []"
:timezone="team?.timezone ?? 'UTC'"
:viewer-timezone="store.state.timezone"
:on-call-now="onCallNow"
:reachability="reachability"
:load="teamLoad"
@@ -279,7 +278,6 @@
:rotations="teamRotations"
:segments="segments"
:timezone="team?.timezone ?? 'UTC'"
:viewer-timezone="store.state.timezone"
:loading="segmentsLoading"
:can-cover="hasMembers !== false && canConfigure"
:absences="teamAbsences"
@@ -312,7 +310,6 @@
:team-id="teamId"
:members="members"
:timezone="team?.timezone ?? 'UTC'"
:viewer-timezone="store.state.timezone"
:primary-rotation-name="schedule?.rotations?.[0]?.name ?? ''"
@applied="onScheduleSaved"
/>
@@ -324,7 +321,6 @@
drawer-only
:team-id="teamId"
:timezone="team?.timezone ?? 'UTC'"
:viewer-timezone="store.state.timezone"
:schedule="schedule"
:members="members"
:intent="scheduleIntent"
@@ -401,14 +397,35 @@
/>
<!-- Named from the loaded preview: the first step can be the whole team, so this is the last look at who gets woken. -->
<ConfirmDialog
:model-value="testPageOpen"
<ODialog
v-model:open="testPageOpen"
size="sm"
:title="t('oncall.testPageConfirmTitle')"
:message="testPageMessage"
:ok-label="t('oncall.ladderSendTest')"
@update:ok="sendTestPage"
@update:cancel="testPageOpen = false"
/>
:primary-button-label="t('oncall.ladderSendTest')"
:secondary-button-label="t('oncall.cancel')"
data-test="oncall-team-test-page-dialog"
@click:primary="sendTestPage"
@click:secondary="testPageOpen = false"
>
<i18n-t
:keypath="
testPageCopy.channels ? 'oncall.testPageConfirm' : 'oncall.testPageConfirmNoChannel'
"
tag="p"
scope="global"
class="text-sm"
data-test="oncall-team-test-page-message"
>
<template #priority>{{ raw(selectedPriority) }}</template>
<template #names>
<template v-for="(email, index) in testPageCopy.emails" :key="email">
<span v-if="index">{{ raw(", ") }}</span>
<OnCallPersonName :email="email" data-test="oncall-team-test-page-person" />
</template>
</template>
<template #channels>{{ testPageCopy.channels }}</template>
</i18n-t>
</ODialog>
<OnCallCoverForm
v-model:open="coverOpen"
@@ -451,7 +468,8 @@ import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
import OTabPanels from "@/lib/navigation/Tabs/OTabPanels.vue";
import OTabPanel from "@/lib/navigation/Tabs/OTabPanel.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import OnCallPersonName from "@/components/oncall/OnCallPersonName.vue";
import OnCallMembers from "@/components/oncall/OnCallMembers.vue";
import OnCallOwnership from "@/components/oncall/OnCallOwnership.vue";
import OnCallPolicyEditor from "@/components/oncall/OnCallPolicyEditor.vue";
@@ -537,7 +555,7 @@ import type {
Unavailability,
} from "@/ts/interfaces/oncall";
import { MICROS_PER_DAY } from "@/ts/interfaces/oncall";
import { raw, useI18nTyped } from "@/types/i18n";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import {
channelList,
formatInZone,
@@ -971,31 +989,32 @@ const silentPriorities = computed(
).length,
);
/// Rostered and reachable are two questions, and the chip used to answer only
/// the first — a green `Covered` sat beside a panel reading "no page can be
/// delivered to anyone", which tells the reader the team is fine at the exact
/// moment it is not. Reachability is folded in: if everybody holding the pager
/// right now would fail to receive a page, the team is rostered, not covered.
///
/// Silent when reachability did not load — an unanswered question is not a
/// finding, and inventing one here would be the same lie in the other
/// direction.
const coverageState = computed<"covered" | "gap" | "unreachable">(() => {
const covered = overview.value?.covered_now ?? onCallNow.value.length > 0;
if (!covered) return "gap";
/// Green only when every person on call now would receive a page, so the chip never disagrees with the banner naming one who would not.
const unreachableHolders = computed(() => {
const verdicts = reachability.value?.members ?? [];
const holders = onCallNow.value
.map((position) => position.user_email?.toLowerCase())
.filter((email): email is string => !!email);
const holders = [
...new Set(
onCallNow.value
.map((position) => position.user_email?.toLowerCase())
.filter((email): email is string => !!email),
),
];
const known = holders
.map((email) => verdicts.find((member) => member.user_email.toLowerCase() === email))
.filter((member) => !!member);
return { total: holders.length, unreachable: known.filter((m) => !m.would_a_page_land).length };
});
// Only when the server actually judged the people on call, and judged every
// one of them unreachable: one reachable holder means a page still lands.
if (known.length && known.every((member) => !member.would_a_page_land)) return "unreachable";
return "covered";
const coverageState = computed<"covered" | "gap" | "unreachable">(() => {
const covered = overview.value?.covered_now ?? onCallNow.value.length > 0;
if (!covered) return "gap";
return unreachableHolders.value.unreachable ? "unreachable" : "covered";
});
const coverageTip = computed<I18nText | undefined>(() => {
if (coverageState.value !== "unreachable") return undefined;
const { unreachable, total } = unreachableHolders.value;
return t("oncall.coverageCantReachTip", { count: unreachable, total });
});
/// Who the strip must not count as cover: the server judged a page to them would not land.
@@ -1006,7 +1025,7 @@ const unreachableEmails = computed(() =>
);
/// The server sends a test to the first step only, so the first step's people are who it names.
const testPageMessage = computed(() => {
const testPageCopy = computed<{ emails: string[]; channels: I18nText | "" }>(() => {
const first = [...(preview.value?.rungs ?? [])].sort(
(a, b) => a.after_micros - b.after_micros,
)[0];
@@ -1015,13 +1034,10 @@ const testPageMessage = computed(() => {
const sent = (preview.value?.channels ?? []).filter((channel) =>
people.some((one) => (one.deliverable_channels ?? []).includes(channel)),
);
const params = {
priority: raw(selectedPriority.value),
names: raw(people.map((one) => one.user_email).join(", ")),
return {
emails: [...new Set(people.map((one) => one.user_email))],
channels: sent.length ? raw(channelList(sent, t)) : "",
};
return sent.length
? t("oncall.testPageConfirm", { ...params, channels: raw(channelList(sent, t)) })
: t("oncall.testPageConfirmNoChannel", params);
});
/// The one honest answer to "would a page actually land": send a real one and
+69 -6
View File
@@ -22,6 +22,7 @@ import i18n from "@/locales";
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
import oncallService from "@/services/oncall";
import { userKeys } from "@/services/users.querykeys";
import { orgUsersQuery } from "@/services/users.queries";
import store from "@/test/unit/helpers/store";
import OnCallTeams from "@/views/OnCall/OnCallTeams.vue";
import {
@@ -73,7 +74,16 @@ const stubs = {
// draws rather than a column-def function OTable never calls.
OTable: {
name: "OTable",
props: ["data", "columns", "error", "currentPage", "pageSize", "sortBy", "sortOrder"],
props: [
"data",
"columns",
"error",
"currentPage",
"pageSize",
"sortBy",
"sortOrder",
"persistColumns",
],
emits: ["update:currentPage", "update:pageSize", "update:sortBy", "update:sortOrder"],
template: `<div>
<slot name='toolbar' />
@@ -110,7 +120,7 @@ const stubs = {
ODropdown: { name: "ODropdown", template: "<div><slot name='trigger' /><slot /></div>" },
ODropdownItem: {
name: "ODropdownItem",
props: ["iconLeft", "variant", "disabled", "description"],
props: ["iconLeft", "variant", "disabled", "description", "focusableUnavailable"],
emits: ["select"],
template: `<button :data-icon="iconLeft" @click="$emit('select')"><slot /></button>`,
},
@@ -198,6 +208,31 @@ describe("OnCallTeams", () => {
});
});
// A create action reads as a button with its icon, like every other On-Call header action.
it("gives New team its add icon", async () => {
service.listTeams.mockResolvedValue({ data: [] } as any);
const wrapper = render();
await flushPromises();
expect(wrapper.find('[data-test="oncall-teams-add-btn"]').attributes("icon-left")).toBe("add");
});
// Search, columns and refresh have nothing to act on until a team exists.
it("shows no search or refresh while the org has no teams", async () => {
service.listTeams.mockResolvedValue({ data: [] } as any);
const wrapper = render();
await flushPromises();
expect(wrapper.find('[data-test="oncall-teams-search"]').exists()).toBe(false);
expect(wrapper.findComponent({ name: "OTable" }).props("persistColumns")).toBe(false);
});
it("keeps search once there is a team to find", async () => {
service.listTeams.mockResolvedValue({ data: [team("team_1", "Platform")] } as any);
const wrapper = render();
await flushPromises();
expect(wrapper.find('[data-test="oncall-teams-search"]').exists()).toBe(true);
expect(wrapper.findComponent({ name: "OTable" }).props("persistColumns")).toBe(true);
});
it("shows who is on call for each team", async () => {
service.listTeams.mockResolvedValue({ data: [team("team_1", "Platform")] } as any);
service.whoIsOnCall.mockResolvedValue({
@@ -207,11 +242,33 @@ describe("OnCallTeams", () => {
const wrapper = render();
await flushPromises();
expect(onCallCell(wrapper).findComponent({ name: "OUserCell" }).props("value")).toBe(
expect(onCallCell(wrapper).findComponent({ name: "OnCallPersonName" }).props("email")).toBe(
"engineer@example.com",
);
});
// Names are what a responder recognises; the email rides in the tooltip because two people can share a name.
it("names the people on call and keeps their email in a tooltip", async () => {
queryClient.setQueryData(orgUsersQuery("default").queryKey, [
{ email: "engineer@example.com", first_name: "Ena", last_name: "Gineer" },
]);
service.listTeams.mockResolvedValue({ data: [team("team_1", "Platform")] } as any);
service.whoIsOnCall.mockResolvedValue({
data: [{ rotation_id: "r1", rotation_name: "Primary", user_email: "engineer@example.com" }],
} as any);
const wrapper = render();
await flushPromises();
const person = wrapper.find('[data-test="oncall-teams-primary-team_1"]');
expect(person.text()).toContain("Ena Gineer");
expect(person.findComponent({ name: "OTooltip" }).props("content")).toBe(
"Ena Gineer (engineer@example.com)",
);
expect(person.attributes("title")).toBeUndefined();
queryClient.removeQueries({ queryKey: orgUsersQuery("default").queryKey });
});
/// A team nobody staffs will page no one, so it earns the one colour on this
/// page rather than an empty cell.
it("flags a team with nobody on call", async () => {
@@ -268,8 +325,8 @@ describe("OnCallTeams", () => {
await flushPromises();
const emails = wrapper
.findAllComponents({ name: "OUserCell" })
.map((cell: any) => cell.props("value"));
.findAllComponents({ name: "OnCallPersonName" })
.map((cell: any) => cell.props("email"));
expect(emails).toEqual(["primary@example.com", "dba@example.com"]);
// It keeps its own name rather than being filed under "Secondary".
expect(wrapper.find('[data-test="oncall-teams-rotation-team_1-rot_db"]').exists()).toBe(true);
@@ -561,8 +618,14 @@ describe("OnCallTeams", () => {
items.find((item) => item.attributes("data-test") === `oncall-team-delete-${id}`);
// Still listed, so every row's menu reads the same, but off and saying why.
expect(deleteOf("team_2")?.props("disabled")).toBe(true);
expect(deleteOf("team_2")?.props("description")).toBe("Make another team the default first");
// One line per item: the reason rides a tooltip, not a second line under the label.
expect(deleteOf("team_2")?.props("description")).toBeUndefined();
expect(deleteOf("team_2")?.findComponent({ name: "OTooltip" }).props("content")).toBe(
"Make another team the default first",
);
expect(deleteOf("team_2")?.props("focusableUnavailable")).toBe(true);
expect(deleteOf("team_1")?.props("disabled")).toBeFalsy();
expect(deleteOf("team_1")?.findComponent({ name: "OTooltip" }).exists()).toBe(false);
});
it("offers to set as default on every team but the current one", async () => {
+34 -11
View File
@@ -27,6 +27,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
v-if="canConfigure"
variant="primary"
size="sm"
icon-left="add"
data-test="oncall-teams-add-btn"
@click="openCreate"
>
@@ -45,7 +46,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
pagination="client"
show-index
table-id="oncall-teams-list"
:persist-columns="true"
:persist-columns="showToolbar"
:show-global-filter="false"
:enable-column-resize="true"
data-test="oncall-teams-table"
@@ -59,7 +60,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@update:sort-by="sortBy = $event"
@update:sort-order="sortOrder = $event"
>
<template #toolbar>
<!-- Search, columns and refresh have nothing to act on until a team exists. -->
<template v-if="showToolbar" #toolbar>
<div class="flex w-full items-center gap-2 max-md:contents">
<OSearchInput
v-model="search"
@@ -71,7 +73,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</template>
<template #toolbar-trailing>
<template v-if="showToolbar" #toolbar-trailing>
<ORefreshButton
layout="inline"
variant="outline"
@@ -122,7 +124,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
size="sm"
:data-test="`oncall-teams-primary-gap-${row.id}`"
/>
<OUserCell v-else :value="positionsByTeam[row.id]!.first!.user_email" />
<OnCallPersonName
v-else
truncate
:email="positionsByTeam[row.id]!.first!.user_email"
:data-test="`oncall-teams-primary-${row.id}`"
/>
</template>
<template #cell-secondary="{ row }">
@@ -140,7 +147,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:key="position.rotation_id"
class="flex flex-wrap items-center gap-1.5"
>
<OUserCell :value="position.user_email" />
<OnCallPersonName
truncate
:email="position.user_email"
:data-test="`oncall-teams-secondary-${row.id}-${position.rotation_id}`"
/>
<!-- The header already says "secondary". Only a rotation the header
does not name has to announce itself. -->
<OTag
@@ -203,13 +214,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
icon-left="delete-outline"
variant="destructive"
:disabled="row.id === defaultTeamId"
:description="
row.id === defaultTeamId ? t('oncall.deleteDefaultTeamBlocked') : undefined
"
:focusable-unavailable="row.id === defaultTeamId"
:data-test="`oncall-team-delete-${row.id}`"
@select="teamToDelete = row"
>
<span>{{ t("oncall.deleteTeam") }}</span>
<span class="flex-1">
{{ t("oncall.deleteTeam") }}
<OTooltip
v-if="row.id === defaultTeamId"
side="right"
:content="t('oncall.deleteDefaultTeamBlocked')"
/>
</span>
</ODropdownItem>
</ODropdown>
</div>
@@ -290,7 +306,8 @@ import ConfirmDialog from "@/components/ConfirmDialog.vue";
import OnCallTeamForm from "@/components/oncall/OnCallTeamForm.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import OUserCell from "@/lib/core/Table/cells/OUserCell.vue";
import OnCallPersonName from "@/components/oncall/OnCallPersonName.vue";
import { useOrgUserNames } from "@/composables/useOrgUserNames";
import { COL, type OTableColumnDef } from "@/lib/core/Table/OTable.types";
import { useConfirmDialog } from "@/composables/useConfirmDialog";
import { useListViewState } from "@/composables/useListViewState";
@@ -320,6 +337,7 @@ const store = useStore();
const route = useRoute();
const router = useRouter();
const { canConfigure, noteConfigurationDenied } = useOnCallPermissions();
const { nameOf } = useOrgUserNames();
const { confirm } = useConfirmDialog();
const { config: routingConfig, load: loadRoutingConfig } = useOnCallRoutingConfig();
@@ -473,6 +491,8 @@ const columns = computed<OTableColumnDef<OnCallTeam>[]>(() => {
];
});
const showToolbar = computed(() => loading.value || teams.value.length > 0 || !!loadError.value);
const filteredTeams = computed(() => {
const q = search.value.trim().toLowerCase();
if (!q) return teams.value;
@@ -484,7 +504,10 @@ const filteredTeams = computed(() => {
// "Which team would page Priya right now" is a question this list can
// answer in one keystroke; without the holders it costs a drill-in per
// team, which is the same search done by hand.
...(onCallByTeam.value[team.id] ?? []).map((slot) => slot.user_email),
...(onCallByTeam.value[team.id] ?? []).flatMap((slot) => [
slot.user_email,
nameOf(slot.user_email),
]),
].some((field) => field.toLowerCase().includes(q)),
);
});