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 }} + + + + + +

.

- +
+ + + + +
- {{ summary }} + + + + + {{ 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" > @@ -104,11 +104,11 @@ along with this program. If not, see . @@ -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.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 . - +
@@ -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: "" }, + 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 @@ + + + + + + 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" - /> + > + + + - + + {{ + 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 . - - + +

@@ -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(() => { 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(() => { - 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(() => 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(() => { + 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(() => { + 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))], diff --git a/web/src/components/oncall/OnCallRulePeople.vue b/web/src/components/oncall/OnCallRulePeople.vue index 74a1e41300..c45ff90c67 100644 --- a/web/src/components/oncall/OnCallRulePeople.vue +++ b/web/src/components/oncall/OnCallRulePeople.vue @@ -30,7 +30,11 @@ along with this program. If not, see . class="text-text-secondary" aria-hidden="true" /> - {{ raw(nameOf(member)) }} + . :label="t('oncall.memberOrderDrag')" /> {{ position + 1 }} - + . 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}`" /> @@ -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. diff --git a/web/src/components/oncall/OnCallSchedulePresets.vue b/web/src/components/oncall/OnCallSchedulePresets.vue index 6b0b20d61c..a3d07aab0c 100644 --- a/web/src/components/oncall/OnCallSchedulePresets.vue +++ b/web/src/components/oncall/OnCallSchedulePresets.vue @@ -55,7 +55,9 @@ @@ -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>({}); /// Kept as plain wire values so `apply` is a spread, not a translation. const model = ref>({}); +const { optionOf } = useOrgUserNames(); + const memberOptions = computed(() => - props.members.map((m) => ({ label: raw(m.user_email), value: m.user_email })), + props.members.map((m) => optionOf(m.user_email, true)), ); // ── The catalogue ───────────────────────────────────────────────────────────── diff --git a/web/src/components/oncall/OnCallScheduleTimeline.spec.ts b/web/src/components/oncall/OnCallScheduleTimeline.spec.ts index aa83b3c3e8..22b709b2fb 100644 --- a/web/src/components/oncall/OnCallScheduleTimeline.spec.ts +++ b/web/src/components/oncall/OnCallScheduleTimeline.spec.ts @@ -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: "" } }, + }, + }); + 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")], diff --git a/web/src/components/oncall/OnCallScheduleTimeline.vue b/web/src/components/oncall/OnCallScheduleTimeline.vue index d7c6ee8d35..d64541f281 100644 --- a/web/src/components/oncall/OnCallScheduleTimeline.vue +++ b/web/src/components/oncall/OnCallScheduleTimeline.vue @@ -97,7 +97,36 @@ along with this program. If not, see . + + + + + {{ t("oncall.addRotationFromTemplate") }} + + + {{ t("oncall.addRotationFromScratch") }} + + .
+