diff --git a/web/src/components/oncall/OnCallEscalationCell.spec.ts b/web/src/components/oncall/OnCallEscalationCell.spec.ts index c8dcf3c3d2..63f467fa59 100644 --- a/web/src/components/oncall/OnCallEscalationCell.spec.ts +++ b/web/src/components/oncall/OnCallEscalationCell.spec.ts @@ -13,8 +13,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { mount, type VueWrapper } from "@vue/test-utils"; -import { describe, expect, it } from "vitest"; +import { flushPromises, mount, type VueWrapper } from "@vue/test-utils"; +import { describe, expect, it, vi } from "vitest"; import OnCallEscalationCell from "@/components/oncall/OnCallEscalationCell.vue"; import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue"; @@ -22,6 +22,14 @@ import i18n from "@/locales"; import store from "@/test/unit/helpers/store"; import type { EscalationProgress, FiredRung, OnCallResponse } from "@/ts/interfaces/oncall"; +vi.mock("@/services/users", () => ({ + default: { + orgUsers: vi.fn().mockResolvedValue({ + data: { data: [{ email: "pt@russia.com", first_name: "Pyotr", last_name: "Tkachenko" }] }, + }), + }, +})); + const stubs = { OIcon: { name: "OIcon", template: "" }, OProgressBar: { name: "OProgressBar", props: ["value", "variant"], template: "
" }, @@ -73,6 +81,14 @@ const text = (wrapper: VueWrapper) => wrapper.find('[data-test="oncall-escalation-cell-level"]').text(); describe("OnCallEscalationCell", () => { + /// Two people can share a name, so the email rides in the tooltip rather than the phrase. + it("names who acknowledged by display name, with name and email in the tooltip", async () => { + const wrapper = render({ state: "acknowledged", acked_by: "pt@russia.com" }, null); + await flushPromises(); + expect(text(wrapper)).toBe("Acknowledged by Pyotr Tkachenko"); + expect(tooltipText(wrapper)).toContain("Acknowledged by Pyotr Tkachenko (pt@russia.com)"); + }); + /// One phrase per row, and never the bare state word the group header already says. it.each([ [{}, { fired: firedRungs(2, ["bo@o2.ai"]) }, {}, "Paging step 2 of 3 · bo@o2.ai"], diff --git a/web/src/components/oncall/OnCallEscalationCell.vue b/web/src/components/oncall/OnCallEscalationCell.vue index 3553838f65..98a495faa7 100644 --- a/web/src/components/oncall/OnCallEscalationCell.vue +++ b/web/src/components/oncall/OnCallEscalationCell.vue @@ -53,6 +53,7 @@ import OTag from "@/lib/core/Badge/OTag.vue"; import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue"; import { useOnCallClock } from "@/composables/useOnCallClock"; +import { useOrgUserNames } from "@/composables/useOrgUserNames"; import type { EscalationProgress, OnCallResponse } from "@/ts/interfaces/oncall"; import type { I18nText } from "@/types/i18n"; import { raw, useI18nTyped } from "@/types/i18n"; @@ -79,6 +80,7 @@ const props = withDefaults( const { t } = useI18nTyped(); const store = useStore(); const nowMicros = useOnCallClock(); +const { nameOf, tooltipOf } = useOrgUserNames(); const phrase = computed(() => pagePhrase({ @@ -133,7 +135,7 @@ const headline = computed(() => { : t("oncall.resolvedAfter", { duration: raw(formatMicrosDuration(p.afterMicros)) }); case "acknowledged": return p.by - ? t("oncall.escalationAckedBy", { who: raw(p.by) }) + ? t("oncall.escalationAckedBy", { who: raw(nameOf(p.by)) }) : t("oncall.state_acknowledged"); case "snoozed": return t("oncall.escalationSnoozedUntil", { @@ -197,7 +199,15 @@ const detail = computed(() => { return t("oncall.escalationTo", { who: raw(progress.next_targets.join(", ")), when }); }); +/// The headline names the person; the tooltip adds their email, since two people can share a name. +const tooltipHeadline = computed(() => { + const p = phrase.value; + return p.kind === "acknowledged" && p.by + ? t("oncall.escalationAckedBy", { who: tooltipOf(p.by) }) + : headline.value; +}); + const tooltipContent = computed(() => - detail.value ? raw(`${headline.value} — ${detail.value}`) : headline.value, + detail.value ? raw(`${tooltipHeadline.value} — ${detail.value}`) : tooltipHeadline.value, ); diff --git a/web/src/components/oncall/OnCallFiringHistory.spec.ts b/web/src/components/oncall/OnCallFiringHistory.spec.ts index 80efe64a16..87fa05d76d 100644 --- a/web/src/components/oncall/OnCallFiringHistory.spec.ts +++ b/web/src/components/oncall/OnCallFiringHistory.spec.ts @@ -25,7 +25,7 @@ import store from "@/test/unit/helpers/store"; const stubs = { OTable: { name: "OTable", - props: ["data", "columns", "loading"], + props: ["data", "columns", "loading", "pagination"], template: `
@@ -79,6 +79,9 @@ describe("OnCallFiringHistory", () => { it("explains an empty history rather than showing a bare frame", () => { const wrapper = render([]); expect(wrapper.find('[data-test="oncall-firing-history-empty"]').exists()).toBe(true); + expect(wrapper.text()).toContain("No earlier firings"); + // A pager reading "0 - 0 of 0" under an empty state says nothing. + expect(wrapper.findComponent({ name: "OTable" }).props("pagination")).toBe("none"); }); /// Before `loading` existed, an in-flight fetch and a genuinely first-ever diff --git a/web/src/components/oncall/OnCallFiringHistory.vue b/web/src/components/oncall/OnCallFiringHistory.vue index 4b267cb321..95d865fdea 100644 --- a/web/src/components/oncall/OnCallFiringHistory.vue +++ b/web/src/components/oncall/OnCallFiringHistory.vue @@ -33,7 +33,7 @@ along with this program. If not, see . row-key="id" :frame="false" :loading="loading" - pagination="client" + :pagination="firings.length ? 'client' : 'none'" :show-global-filter="false" table-id="oncall-firing-history" data-test="oncall-firing-history-table" @@ -61,13 +61,12 @@ along with this program. If not, see . {{ ABSENT }} - diff --git a/web/src/components/oncall/OnCallMyDeliveries.spec.ts b/web/src/components/oncall/OnCallMyDeliveries.spec.ts index c949b01d28..15c3bfed77 100644 --- a/web/src/components/oncall/OnCallMyDeliveries.spec.ts +++ b/web/src/components/oncall/OnCallMyDeliveries.spec.ts @@ -151,7 +151,13 @@ describe("OnCallMyDeliveries", () => { answer([ delivery({ event_id: "ev_1", response_id: "resp_1", at: AT }), delivery({ event_id: "ev_2", response_id: "resp_2", at: AT + 1 }), - delivery({ event_id: "ev_3", response_id: "resp_3", subject_id: "al_other", at: AT + 2 }), + delivery({ + event_id: "ev_3", + response_id: "resp_3", + subject_id: "al_other", + title: "disk full", + at: AT + 2, + }), ]); const wrapper = render(); await flushPromises(); @@ -160,6 +166,35 @@ describe("OnCallMyDeliveries", () => { expect(historyRows(wrapper)[1].text()).toContain("×2"); }); + /// An alert and the incident it opened page under one name; two rows the reader cannot tell apart are one. + it("groups pages that share a name across subjects and teams", async () => { + answer([ + delivery({ event_id: "ev_1", response_id: "resp_1", team_id: "team_dev", at: AT }), + delivery({ + event_id: "ev_2", + response_id: "resp_2", + subject_type: "incident", + subject_id: "inc_1#1", + team_id: "team_dev", + at: AT + 1, + }), + delivery({ + event_id: "ev_3", + response_id: "resp_3", + subject_type: "incident", + subject_id: "inc_2#1", + team_id: "team_sre", + at: AT + 2, + }), + ]); + const wrapper = render({ teamNames: { team_dev: "Dev Team", team_sre: "Platform SRE" } }); + await flushPromises(); + + expect(historyRows(wrapper)).toHaveLength(1); + expect(historyRows(wrapper)[0].text()).toContain("×3"); + expect(historyRows(wrapper)[0].text()).toContain("Platform SRE, Dev Team"); + }); + /// The subject id ends in the firing number, so two firings of one alert are still one row. it("groups an alert's firings into one row", async () => { answer([ @@ -296,7 +331,9 @@ describe("OnCallMyDeliveries", () => { answer([delivery({ read: true, response_state: "resolved" })]); const wrapper = render(); await flushPromises(); - expect(wrapper.find('[data-test="oncall-my-deliveries-unread-toggle"]').exists()).toBe(false); + expect(wrapper.find('[data-test="oncall-my-deliveries-controls"]').classes()).toContain( + "invisible", + ); expect(wrapper.find('[data-test="oncall-my-delivery-more-ev_88"]').exists()).toBe(true); const toggle = wrapper.findComponent('[data-test="oncall-my-delivery-toggle-ev_88"]'); expect(toggle.props("iconLeft")).toBe("mail"); @@ -307,11 +344,35 @@ describe("OnCallMyDeliveries", () => { ); }); - /// An empty list has nothing to narrow, so it offers no filter. - it("offers no unread filter on an empty list", async () => { + /// An empty list has nothing to narrow, so the filter is hidden; it keeps its slot so the header never reflows. + it("hides the unread filter on an empty list without removing its slot", async () => { const wrapper = render(); await flushPromises(); - expect(wrapper.find('[data-test="oncall-my-deliveries-unread-toggle"]').exists()).toBe(false); + expect(wrapper.find('[data-test="oncall-my-deliveries-controls"]').classes()).toContain( + "invisible", + ); + }); + + it("shows the filter and Mark all read once something is unread", async () => { + answer([delivery({ read: false })]); + const wrapper = render(); + await flushPromises(); + expect(wrapper.find('[data-test="oncall-my-deliveries-controls"]').classes()).not.toContain( + "invisible", + ); + expect(wrapper.find('[data-test="oncall-my-deliveries-read-all"]').classes()).not.toContain( + "invisible", + ); + }); + + /// A reader on no team has no page to test, so the empty history points them at a team instead. + it("offers Join a team on an empty history when the reader is on no team", async () => { + const wrapper = render({ noTeam: true }); + await flushPromises(); + const action = wrapper.find('[data-test="empty-action"]'); + expect(action.text()).toBe("Join a team"); + await action.trigger("click"); + expect(wrapper.emitted("join-team")).toHaveLength(1); }); /// Unticking answers from the cache at once, so the slower "unread only" answer must not narrow a widened inbox. @@ -476,8 +537,8 @@ describe("OnCallMyDeliveries", () => { ); }); - /// A column of empty dots says nothing, so the unread column only exists while something is unread. - it("leaves out the unread column when nothing is unread", async () => { + /// The dot column is always there: arriving with the first unread row shifted every other column. Actions are not pinned. + it("keeps the unread column when nothing is unread", async () => { answer([delivery({ read: true })]); const wrapper = render(); await flushPromises(); @@ -486,13 +547,15 @@ describe("OnCallMyDeliveries", () => { id: string; }[]; expect(columns.map((c) => c.id)).toEqual([ + "unread", "page", "team", "state", "channels", "at", - "actions", + "row-actions", ]); + expect(columns.some((c) => "pinned" in c)).toBe(false); }); it("names the team that the empty history's test page goes to", async () => { diff --git a/web/src/components/oncall/OnCallMyDeliveries.vue b/web/src/components/oncall/OnCallMyDeliveries.vue index 6347239bee..c363be0bc6 100644 --- a/web/src/components/oncall/OnCallMyDeliveries.vue +++ b/web/src/components/oncall/OnCallMyDeliveries.vue @@ -109,7 +109,12 @@ along with this program. If not, see . {{ t("oncall.myDeliveriesUnread", { count: unread }, unread) }} - + + . @update:model-value="(v: CheckboxModelValue) => setUnreadOnly(!!v)" /> . {{ raw(clock(row.latest.at)) }} -