diff --git a/tests/ui-testing/pages/oncallPages/oncallTeamDetailPage.js b/tests/ui-testing/pages/oncallPages/oncallTeamDetailPage.js
index 25b66922a3..ac197199aa 100644
--- a/tests/ui-testing/pages/oncallPages/oncallTeamDetailPage.js
+++ b/tests/ui-testing/pages/oncallPages/oncallTeamDetailPage.js
@@ -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 });
}
diff --git a/web/src/components/oncall/OnCallAwayDialog.vue b/web/src/components/oncall/OnCallAwayDialog.vue
index e6c8d9d571..19cd1a1e05 100644
--- a/web/src/components/oncall/OnCallAwayDialog.vue
+++ b/web/src/components/oncall/OnCallAwayDialog.vue
@@ -21,8 +21,14 @@ along with this program. If not, see .
data-test="oncall-members-away-dialog"
>
-
-
+
+
+ {{ t("oncall.awayHintShort") }}
@@ -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("open", { required: true });
const { t } = useI18nTyped();
+const { nameOf } = useOrgUserNames();
const { noteConfigurationDenied } = useOnCallPermissions();
const store = useStore();
const orgId = computed(() => store.state.selectedOrganization.identifier);
diff --git a/web/src/components/oncall/OnCallCoverForm.spec.ts b/web/src/components/oncall/OnCallCoverForm.spec.ts
index a91023048c..39065171d2 100644
--- a/web/src/components/oncall/OnCallCoverForm.spec.ts
+++ b/web/src/components/oncall/OnCallCoverForm.spec.ts
@@ -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: "",
+ },
+ },
+ },
+ });
+ 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 () => {
diff --git a/web/src/components/oncall/OnCallCoverForm.vue b/web/src/components/oncall/OnCallCoverForm.vue
index c46550fbb0..57d71cf916 100644
--- a/web/src/components/oncall/OnCallCoverForm.vue
+++ b/web/src/components/oncall/OnCallCoverForm.vue
@@ -70,7 +70,12 @@ along with this program. If not, see .
that says only "Ana and Bo swap" leaves the reader to work out which
week each of them ends up holding. -->
- {{ swapSummary }}
+
+
+
+ {{ swapSummary.firstWhen }}
+ {{ swapSummary.secondWhen }}
+
.
-
+
+
+
+
+
+
- {{ summary }}
+
+
+ {{ summary.team }}
+ {{ summary.range }}
+
{{ teamZoneSummary }}
@@ -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(() =>
- 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(() => {
+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(() => {
+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(() => {
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() {
diff --git a/web/src/components/oncall/OnCallCoverList.vue b/web/src/components/oncall/OnCallCoverList.vue
index f970bf40e8..b58a841b2e 100644
--- a/web/src/components/oncall/OnCallCoverList.vue
+++ b/web/src/components/oncall/OnCallCoverList.vue
@@ -46,13 +46,13 @@ along with this program. If not, see .
data-test="oncall-cover-table"
>
-
+
-
+ {{ t("oncall.coverForNobodyNamed") }}
@@ -104,11 +104,11 @@ along with this program. If not, see .
- {{ shownRemove.user_email }}
+ class="text-text-heading font-semibold"
+ />
{{ windowOf(shownRemove) }}
@@ -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";
diff --git a/web/src/components/oncall/OnCallCoverageStrip.vue b/web/src/components/oncall/OnCallCoverageStrip.vue
index 9c74481e0a..d35e18d24c 100644
--- a/web/src/components/oncall/OnCallCoverageStrip.vue
+++ b/web/src/components/oncall/OnCallCoverageStrip.vue
@@ -33,16 +33,34 @@ along with this program. If not, see .
>
{{ t("oncall.onCallNow") }}
- {{
- t("oncall.coveragePrimaryOnly", { primary: raw(current.primary) })
- }}
-
+
+
+
+
+
- {{ t("oncall.coverageSecondaryOnly", { secondary: raw(current.secondary) }) }}
-
+
+
+
+
{{ 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,
}));
diff --git a/web/src/components/oncall/OnCallEscalationLadder.spec.ts b/web/src/components/oncall/OnCallEscalationLadder.spec.ts
index 0673b62c0c..46eb9cf3b6 100644
--- a/web/src/components/oncall/OnCallEscalationLadder.spec.ts
+++ b/web/src/components/oncall/OnCallEscalationLadder.spec.ts
@@ -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 }) });
diff --git a/web/src/components/oncall/OnCallEscalationLadder.vue b/web/src/components/oncall/OnCallEscalationLadder.vue
index 1244b84f4e..cfd8e53715 100644
--- a/web/src/components/oncall/OnCallEscalationLadder.vue
+++ b/web/src/components/oncall/OnCallEscalationLadder.vue
@@ -102,7 +102,14 @@ along with this program. If not, see .
-
+
+
.
:data-test="`oncall-ladder-person-${group.firstMicros}-${one.email}`"
>
- {{ raw(nameOf(one.email)) }}
+
{{ raw(seat) }}
@@ -212,10 +223,13 @@ along with this program. If not, see .
-
@@ -258,7 +280,7 @@ along with this program. If not, see .
{{ l0Line }}
-
+
@@ -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";
diff --git a/web/src/components/oncall/OnCallFitChips.spec.ts b/web/src/components/oncall/OnCallFitChips.spec.ts
new file mode 100644
index 0000000000..98b941577f
--- /dev/null
+++ b/web/src/components/oncall/OnCallFitChips.spec.ts
@@ -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 .
+
+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: "{{ item }}" },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ OTag: { name: "OTag", template: "" },
+ OTooltip: { name: "OTooltip", props: ["content"], template: "" },
+ },
+ },
+ attachTo: document.body,
+ });
+}
+
+const visible = (wrapper: ReturnType) =>
+ 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");
+ });
+});
diff --git a/web/src/components/oncall/OnCallFitChips.vue b/web/src/components/oncall/OnCallFitChips.vue
new file mode 100644
index 0000000000..10a466282b
--- /dev/null
+++ b/web/src/components/oncall/OnCallFitChips.vue
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+
+
+ {{ raw(`+${items.length - shown}`) }}
+
+
+
+
+
+
diff --git a/web/src/components/oncall/OnCallMemberPicker.vue b/web/src/components/oncall/OnCallMemberPicker.vue
index 28194e972e..8ad9b36a61 100644
--- a/web/src/components/oncall/OnCallMemberPicker.vue
+++ b/web/src/components/oncall/OnCallMemberPicker.vue
@@ -45,7 +45,7 @@ along with this program. If not, see .
:class="index ? '-ms-1.5' : ''"
class="ring-surface-base rounded-full ring-2"
>
-
+ {{ summary }}
@@ -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(() => {
diff --git a/web/src/components/oncall/OnCallMembers.spec.ts b/web/src/components/oncall/OnCallMembers.spec.ts
index d2e26e3618..a58573d808 100644
--- a/web/src/components/oncall/OnCallMembers.spec.ts
+++ b/web/src/components/oncall/OnCallMembers.spec.ts
@@ -124,7 +124,7 @@ const stubs = {
name: "OSelect",
props: ["modelValue", "options", "multiple"],
template: ``,
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();
diff --git a/web/src/components/oncall/OnCallMembers.vue b/web/src/components/oncall/OnCallMembers.vue
index ffc3bdcbaf..59a1786678 100644
--- a/web/src/components/oncall/OnCallMembers.vue
+++ b/web/src/components/oncall/OnCallMembers.vue
@@ -223,7 +223,14 @@
:options="userOptions"
:loading="loadingUsers"
data-test="oncall-members-user-select"
- />
+ >
+
+
+
+ {{ t("oncall.memberPickPlaceholder") }}
+
+
+
-
+
+ {{
+ raw(nameOf(email))
+ }}
+ {{
+ raw(email)
+ }}
+ 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());
diff --git a/web/src/components/oncall/OnCallOwnershipRules.vue b/web/src/components/oncall/OnCallOwnershipRules.vue
index 906a2a5bc2..056bb73550 100644
--- a/web/src/components/oncall/OnCallOwnershipRules.vue
+++ b/web/src/components/oncall/OnCallOwnershipRules.vue
@@ -37,18 +37,16 @@ along with this program. If not, see .
-
+
-
-
+
+
+
+
@@ -58,16 +56,15 @@ along with this program. If not, see .
-
-
-
+
+
+
+
+
@@ -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") {
diff --git a/web/src/components/oncall/OnCallPersonName.vue b/web/src/components/oncall/OnCallPersonName.vue
index c131512c64..85ececdd10 100644
--- a/web/src/components/oncall/OnCallPersonName.vue
+++ b/web/src/components/oncall/OnCallPersonName.vue
@@ -16,8 +16,8 @@ along with this program. If not, see .
-
-
+
+ {{ name }}
@@ -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(() => raw(nameOf(props.email)));
-
-const tooltip = computed(() =>
- String(name.value) === props.email
- ? raw(props.email)
- : t("oncall.personNameWithEmail", { name: name.value, email: raw(props.email) }),
-);
diff --git a/web/src/components/oncall/OnCallPolicyEditor.spec.ts b/web/src/components/oncall/OnCallPolicyEditor.spec.ts
index 247ed5dd88..1a2312caf1 100644
--- a/web/src/components/oncall/OnCallPolicyEditor.spec.ts
+++ b/web/src/components/oncall/OnCallPolicyEditor.spec.ts
@@ -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.",
);
});
diff --git a/web/src/components/oncall/OnCallPolicyEditor.vue b/web/src/components/oncall/OnCallPolicyEditor.vue
index e5335abaee..847af5f513 100644
--- a/web/src/components/oncall/OnCallPolicyEditor.vue
+++ b/web/src/components/oncall/OnCallPolicyEditor.vue
@@ -120,7 +120,7 @@
inline-actions
data-test="oncall-policy-sentence"
>
- {{ ladderSentence }}
+ {{ ladderSentence }}
" },
ODropdownItem: {
name: "ODropdownItem",
- props: ["iconLeft", "variant", "disabled", "description"],
+ props: ["iconLeft", "variant", "disabled", "description", "focusableUnavailable"],
emits: ["select"],
template: ``,
},
@@ -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 () => {
diff --git a/web/src/views/OnCall/OnCallTeams.vue b/web/src/views/OnCall/OnCallTeams.vue
index 1e16b9b0b2..2252a9f63b 100644
--- a/web/src/views/OnCall/OnCallTeams.vue
+++ b/web/src/views/OnCall/OnCallTeams.vue
@@ -27,6 +27,7 @@ along with this program. If not, see .
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 .
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 .
@update:sort-by="sortBy = $event"
@update:sort-order="sortOrder = $event"
>
-
+
+