fix(oncall): round 3 fixes for My on-call, page detail, Pages and Telephony

This commit is contained in:
ktx-abhay
2026-11-04 14:32:42 +05:30
parent 480f40e969
commit c688651242
36 changed files with 357 additions and 110 deletions
@@ -70,7 +70,6 @@ export class OnCallPagesListPage {
error: '[data-test="oncall-responses-error"]',
unavailable: '[data-test="oncall-responses-unavailable"]',
truncated: '[data-test="oncall-responses-truncated"]',
escalationCapped: '[data-test="oncall-escalation-capped"]',
responderNobody: '[data-test="oncall-responder-nobody"]',
onCallNowGap: '[data-test="oncall-oncallnow-gap"]',
@@ -30,15 +30,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OCardSection>
<OCardSection role="body" dense>
<ODescriptionList dense>
<ODescriptionList dense label-width="narrow">
<!-- The engine records why it picked this team. It was only ever
readable by scrolling the timeline, which is not where somebody
asks "why me". The winning rule draws as the same key|value chips
the routing tab draws it with, not a hand-built equation, so a
dimension reads the same wherever it appears. -->
<ODescriptionItem v-if="showRoutingReason" :label="t('oncall.routedBy')">
<span class="flex flex-col items-start gap-1">
<span data-test="oncall-about-routing-reason">
<span class="flex min-w-0 flex-col items-start gap-1">
<span class="max-w-full wrap-anywhere" data-test="oncall-about-routing-reason">
<!-- Unrecognised wording: show the record's team, not the server's prose. -->
<OTag
v-if="fallbackRouted && teamDeleted"
@@ -82,7 +82,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<span
v-for="(note, index) in routingNotes"
:key="index"
class="text-text-secondary text-xs"
class="text-text-secondary max-w-full text-xs wrap-anywhere"
:data-test="`oncall-about-routing-note-${index}`"
>
{{ raw(note) }}
@@ -117,6 +117,7 @@ describe("OnCallMyDeliveries", () => {
vi.clearAllMocks();
queryClient.clear();
resetListViewState();
window.localStorage.clear();
answer([]);
service.markDeliveriesRead.mockResolvedValue({ data: { updated: 1, unread: 0 } } as any);
service.acknowledgeResponse.mockResolvedValue({ data: {} } as any);
@@ -275,6 +276,57 @@ describe("OnCallMyDeliveries", () => {
await flushPromises();
expect(wrapper.find('[data-test="oncall-my-needs-you"]').exists()).toBe(false);
});
const NEEDS_YOU_KEY = "oncall-my-needs-you:default:example@gmail.com";
/// The card holds its place through the first read when the last visit had open pages.
it("reserves its card while loading when the last visit had open pages", async () => {
window.localStorage.setItem(NEEDS_YOU_KEY, "flat");
service.myDeliveries.mockReturnValue(new Promise(() => {}));
const wrapper = render();
await flushPromises();
expect(wrapper.find('[data-test="oncall-my-needs-you"]').exists()).toBe(true);
expect(
wrapper.findAll('[data-test="oncall-my-needs-you-table"] [data-test="row"]'),
).toHaveLength(0);
});
it("drops the reservation once a load has nothing open", async () => {
window.localStorage.setItem(NEEDS_YOU_KEY, "paged");
answer([delivery({ response_state: "resolved" })]);
const wrapper = render();
await flushPromises();
expect(wrapper.find('[data-test="oncall-my-needs-you"]').exists()).toBe(false);
expect(window.localStorage.getItem(NEEDS_YOU_KEY)).toBe(null);
});
it("remembers open pages for the next visit's skeleton", async () => {
answer([delivery({ response_state: "triggered" })]);
render();
await flushPromises();
expect(window.localStorage.getItem(NEEDS_YOU_KEY)).toBe("flat");
});
/// A pinned column draws a divider; a table with nothing to scroll past keeps its actions unpinned.
it("does not pin the actions column", async () => {
answer([delivery({ response_state: "triggered" })]);
const { OTable: _table, ...realTable } = stubs;
const wrapper = mount(OnCallMyDeliveries, {
global: { plugins: [i18n, store], stubs: realTable },
});
await flushPromises();
await new Promise((resolve) => setTimeout(resolve, 60));
await flushPromises();
const card = wrapper.find('[data-test="oncall-my-needs-you"]');
expect(card.find('[data-test="oncall-my-needs-you-ack-resp_1"]').exists()).toBe(true);
const sticky = card
.findAll("td, th")
.filter((cell) => (cell.attributes("style") ?? "").includes("sticky"));
expect(sticky).toHaveLength(0);
});
});
describe("the unread badge", () => {
@@ -29,8 +29,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
<template v-else>
<!-- Reserved while loading when the last visit had open pages, so the history below does not jump when they arrive. -->
<section
v-if="openRows.length"
v-if="openRows.length || (loading && lastHadOpen)"
class="rounded-surface bg-surface-base border-border-default table-last-row-flush flex min-w-0 flex-col overflow-hidden border"
data-test="oncall-my-needs-you"
>
@@ -46,6 +47,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:columns="openColumns"
row-key="response_id"
:frame="false"
:loading="loading && !openRows.length"
:skeleton-rows="PAGE_ROWS"
:show-global-filter="false"
:pagination="openPaged ? 'client' : 'none'"
:page-size="openPageSize"
@@ -83,7 +86,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:empty-label="raw('—')"
/>
</template>
<template #cell-actions="{ row }">
<template #cell-row-actions="{ row }">
<div class="flex items-center justify-center">
<OButton
v-if="isEscalating(row.response_state)"
@@ -367,6 +370,36 @@ const orgId = computed(() => store.state.selectedOrganization.identifier);
const nowMicros = useOnCallClock();
const rows = ref<MyDelivery[]>([]);
const needsYouKey = computed(
() =>
`oncall-my-needs-you:${orgId.value}:${String(store.state.userInfo?.email ?? "").toLowerCase()}`,
);
type OpenShape = "none" | "flat" | "paged";
function readOpenShape(): OpenShape {
try {
const stored = window.localStorage.getItem(needsYouKey.value);
return stored === "flat" || stored === "paged" ? stored : "none";
} catch {
return "none";
}
}
/// How the last load's open pages looked, kept across reloads so the skeleton matches what is likely to arrive.
const lastOpenShape = ref<OpenShape>(readOpenShape());
const lastHadOpen = computed(() => lastOpenShape.value !== "none");
function rememberOpenShape(shape: OpenShape) {
lastOpenShape.value = shape;
try {
if (shape === "none") window.localStorage.removeItem(needsYouKey.value);
else window.localStorage.setItem(needsYouKey.value, shape);
} catch {
// Storage refused the write (private mode): the reservation lasts for this visit only.
}
}
const unread = ref(0);
const loading = ref(false);
const marking = ref(false);
@@ -473,7 +506,11 @@ const historyRows = computed<HistoryRow[]>(() => {
/// The pager shows once a second page exists, and stays while a larger size is chosen so it can be undone.
const paged = (rows: number, size: number) => rows > PAGE_ROWS || size !== PAGE_ROWS;
const openPaged = computed(() => paged(openRows.value.length, openPageSize.value));
const openPaged = computed(() =>
openRows.value.length || !loading.value
? paged(openRows.value.length, openPageSize.value)
: lastOpenShape.value === "paged",
);
const historyPaged = computed(() => paged(historyRows.value.length, pageSize.value));
const openColumns = computed<OTableColumnDef<MyDelivery>[]>(() => [
@@ -488,14 +525,13 @@ const openColumns = computed<OTableColumnDef<MyDelivery>[]>(() => [
{ id: "state", header: t("oncall.myDeliveriesStatus"), size: 128, sortable: false },
{ id: "team", header: t("oncall.team"), size: 160, sortable: false },
{ id: "at", header: t("oncall.myDeliveriesSent"), size: 120, sortable: false },
// Not `isAction`: a pinned column draws a divider that reads as a stray border on a table with nothing to scroll past.
{
id: "actions",
id: "row-actions",
header: t("oncall.actions"),
isAction: true,
pinned: "right",
sortable: false,
size: 64,
meta: { align: "center", actionCount: 1 },
meta: { align: "center" },
},
]);
@@ -514,7 +550,6 @@ const historyColumns = computed<OTableColumnDef<HistoryRow>[]>(() => [
{ id: "state", header: t("oncall.myDeliveriesStatus"), size: 128, sortable: false },
{ id: "channels", header: t("oncall.myDeliveriesChannels"), size: 104, sortable: false },
{ id: "at", header: t("oncall.myDeliveriesWhen"), size: 176, sortable: false },
// Not `isAction`: that pins the column, and its divider reads as a stray border on a table this narrow.
{
id: "row-actions",
header: t("oncall.actions"),
@@ -561,6 +596,10 @@ async function fetchDeliveries(force = false) {
);
if (readId !== latestDeliveriesRead) return;
rows.value = data?.deliveries ?? [];
if (!unreadOnly.value) {
if (!openRows.value.length) rememberOpenShape("none");
else rememberOpenShape(openPaged.value ? "paged" : "flat");
}
// Pages, not sends: one page that reached the reader on three channels is one unread.
setUnread(new Set(rows.value.filter(countsAsUnread).map((row) => row.response_id)).size);
loadedOnce = true;
@@ -117,7 +117,10 @@ describe("OnCallSetupChecklist", () => {
const wrapper = render({ compact: true, hasTeam: true, firstTeamId: "team_1" });
expect(wrapper.find('[data-test="oncall-setup-next"]').text()).toContain(
"Add people and who's on first",
"Add members and who's on first",
);
expect(wrapper.find('[data-test="oncall-setup-bar-cta-rotation"]').text()).toContain(
"Add members",
);
await wrapper.find('[data-test="oncall-setup-bar-cta-rotation"]').trigger("click");
@@ -27,7 +27,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OCardSection>
<OCardSection role="body" dense>
<ODescriptionList dense>
<ODescriptionList dense label-width="narrow">
<ODescriptionItem :label="t('alerts.groups.criticalCondition')">
<span class="font-mono text-sm wrap-anywhere" data-test="oncall-what-fired-condition">
{{ raw(conditionText) }}
@@ -18,7 +18,9 @@ import { describe, expect, it } from "vitest";
import OnCallWhoIsOn from "@/components/oncall/OnCallWhoIsOn.vue";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import type { DeliveryRecord, OnCallPosition } from "@/ts/interfaces/oncall";
import { formatTimestampInTimezone } from "@/utils/date";
const stubs = {
OCard: { name: "OCard", template: "<div><slot /></div>" },
@@ -53,7 +55,7 @@ function delivery(overrides: Partial<DeliveryRecord>): DeliveryRecord {
function render(props: Record<string, unknown> = {}) {
return mount(OnCallWhoIsOn, {
props: { positions: [] as OnCallPosition[], deliveries: [], ...props },
global: { plugins: [i18n], stubs },
global: { plugins: [i18n, store], stubs },
});
}
@@ -204,6 +206,43 @@ describe("OnCallWhoIsOn", () => {
});
expect(wrapper.text()).toContain("yuki@o2.ai");
expect(wrapper.text()).toContain("(5d)");
expect(wrapper.find('[data-test="oncall-who-is-on-handover-at"]').text()).toBe(
formatTimestampInTimezone(fiveDaysMicros, "EEE, MMM d, HH:mm", store.state.timezone),
);
});
/// Without a next holder the row still says when, not only how long.
it("states the handover time when nobody is known to take it", () => {
const handoverAt = (Date.now() + 2 * 60 * 60 * 1000) * 1000;
const wrapper = render({
positions: [
{
rotation_id: "rot_primary",
rotation_name: "Primary",
rule: "weekly",
user_email: "ana@o2.ai",
},
],
handoverAt,
});
const row = wrapper.find('[data-test="oncall-who-is-on-handover"]');
expect(row.find('[data-test="oncall-who-is-on-handover-at"]').text()).not.toBe("");
expect(row.text()).not.toContain("→");
});
/// A long address is cut inside the card, with the full name and email in its tooltip.
it("truncates the acknowledger with the real person name component", () => {
const wrapper = mount(OnCallWhoIsOn, {
props: {
positions: [],
ackedBy: "oncall_testassist_1103_vteam@example.com",
teamDeleted: true,
},
global: { plugins: [i18n, store], stubs: { ...stubs, OnCallPersonName: false } },
});
expect(wrapper.find('[data-test="oncall-who-is-on-acked-by"]').classes()).toEqual(
expect.arrayContaining(["block", "min-w-0", "truncate"]),
);
});
/// A closed record is history. The roster it is given was resolved as of the
+29 -15
View File
@@ -63,7 +63,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- The roster first, and only the roster: a reach tag pinned to the
right edge gives every person the same two columns to read, which
a tag trailing the address never does. -->
<ODescriptionList dense>
<ODescriptionList dense label-width="narrow">
<ODescriptionItem :label="closed ? t('oncall.onCallThen') : t('oncall.onCallNow')">
<span class="flex w-full items-center gap-2">
<OnCallPersonName
@@ -116,21 +116,29 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
have. `next_user_email` used to get its own row ("Up next"), but
it names the same person `handoverTo` does, so the row was a
second copy of this one rather than a second fact. -->
<ODescriptionList v-if="!closed && handoverAt" dense>
<ODescriptionList v-if="!closed && handoverAt" dense label-width="narrow">
<!-- When the pager changes hands. A page still open at handover is one
the next person inherits without being told, unless a screen says
so before it happens. -->
<ODescriptionItem :label="t('oncall.shiftHandover')">
<span class="flex w-full flex-wrap items-center gap-x-1 gap-y-1">
<OnCallPersonName
v-if="handoverTo"
class="min-w-0"
:email="handoverTo"
data-test="oncall-who-is-on-handover-to"
/>
<span
class="flex w-full flex-wrap items-center gap-x-1 gap-y-1"
data-test="oncall-who-is-on-handover"
>
<span class="whitespace-nowrap" data-test="oncall-who-is-on-handover-at">
{{ raw(handoverAtLabel) }}
</span>
<span class="text-text-secondary text-xs whitespace-nowrap">
{{ raw(`(${handoverInLabel})`) }}
</span>
<template v-if="handoverTo">
<span class="text-text-secondary" aria-hidden="true">{{ raw("→") }}</span>
<OnCallPersonName
class="min-w-0"
:email="handoverTo"
data-test="oncall-who-is-on-handover-to"
/>
</template>
</span>
</ODescriptionItem>
</ODescriptionList>
@@ -140,13 +148,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
that its timing rides the stat strip above the fold. -->
<template v-if="ackedBy">
<OSeparator class="my-3" />
<ODescriptionList dense>
<ODescriptionList dense label-width="narrow">
<ODescriptionItem :label="t('oncall.ackedBy')">
<OnCallPersonName
class="min-w-0 truncate"
:email="ackedBy"
data-test="oncall-who-is-on-acked-by"
/>
<OnCallPersonName truncate :email="ackedBy" data-test="oncall-who-is-on-acked-by" />
</ODescriptionItem>
</ODescriptionList>
</template>
@@ -156,6 +160,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<script setup lang="ts">
import { computed } from "vue";
import { useStore } from "vuex";
import OnCallPersonName from "@/components/oncall/OnCallPersonName.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
@@ -169,6 +174,7 @@ import ODescriptionItem from "@/lib/lists/DescriptionList/ODescriptionItem.vue";
import { useOnCallClock } from "@/composables/useOnCallClock";
import type { DeliveryRecord, OnCallPosition } from "@/ts/interfaces/oncall";
import { raw, useI18nTyped } from "@/types/i18n";
import { formatTimestampInTimezone } from "@/utils/date";
import { formatMicrosDuration } from "@/utils/formatters";
const props = withDefaults(
@@ -213,6 +219,7 @@ const props = withDefaults(
const { t } = useI18nTyped();
const nowMicros = useOnCallClock();
const store = useStore();
const closed = computed(() => Boolean(props.closedAt));
@@ -244,6 +251,13 @@ const handoverInLabel = computed(() =>
props.handoverAt !== null ? formatMicrosDuration(props.handoverAt - nowMicros.value) : "",
);
/// The weekday leads: a handover days away is placed by the day, not by its date alone.
const handoverAtLabel = computed(() =>
props.handoverAt !== null
? formatTimestampInTimezone(props.handoverAt, "EEE, MMM d, HH:mm", store.state.timezone)
: "",
);
/// What this page's own sends did for one address — never the team's general
/// reachability, which answers a different question and would contradict the
/// ledger sitting below it.
-2
View File
@@ -904,13 +904,11 @@ export const emptyStatePresets = {
illustration: "connect",
variant: "create",
titleKey: "emptyState.noPhoneProvider.title",
descriptionKey: "emptyState.noPhoneProvider.description",
actions: [
{
id: "connect",
icon: "call",
titleKey: "emptyState.noPhoneProvider.action",
descriptionKey: "emptyState.noPhoneProvider.actionDesc",
},
],
},
@@ -6,9 +6,13 @@
// what lets the parent lay out PAIRS on its grid instead of laying out the dt
// and the dd as two independent cells that can land in different columns.
import { computed } from "vue";
import { computed, inject } from "vue";
import { raw } from "@/types/i18n";
import type { DescriptionItemProps, DescriptionItemSlots } from "./ODescriptionList.types";
import {
DESCRIPTION_LABEL_WIDTH,
type DescriptionItemProps,
type DescriptionItemSlots,
} from "./ODescriptionList.types";
const props = withDefaults(defineProps<DescriptionItemProps>(), {
emptyLabel: () => raw("—"),
@@ -17,9 +21,13 @@ const props = withDefaults(defineProps<DescriptionItemProps>(), {
defineSlots<DescriptionItemSlots>();
const rowClass = computed(() =>
props.stacked ? "flex flex-col gap-0.5" : "grid grid-cols-[10rem_1fr] items-baseline gap-x-3",
);
const labelWidth = inject(DESCRIPTION_LABEL_WIDTH, null);
const rowClass = computed(() => {
if (props.stacked) return "flex flex-col gap-0.5";
const columns = labelWidth?.value === "narrow" ? "grid-cols-[7rem_1fr]" : "grid-cols-[10rem_1fr]";
return `grid ${columns} items-baseline gap-x-3`;
});
</script>
<template>
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import { h } from "vue";
import { mount } from "@vue/test-utils";
import ODescriptionList from "./ODescriptionList.vue";
import ODescriptionItem from "./ODescriptionItem.vue";
@@ -53,6 +54,17 @@ describe("ODescriptionItem", () => {
expect(w.classes().join(" ")).toContain(expected);
});
it.each([
[undefined, "grid-cols-[10rem_1fr]"],
["narrow", "grid-cols-[7rem_1fr]"],
] as const)("takes its label width %s from the list", (labelWidth, expected) => {
const w = mount(ODescriptionList, {
props: labelWidth ? { labelWidth } : {},
slots: { default: () => h(ODescriptionItem, { label: raw("Team") }) },
});
expect(w.find("[data-test=o2-description-item]").classes()).toContain(expected);
});
it("lets the label be replaced by a slot", () => {
const w = mount(ODescriptionItem, {
props: { label: raw("Team") },
@@ -8,6 +8,7 @@
// Distinct from OFieldList (lib/lists/FieldList), which is a stream-schema field
// BROWSER — searchable, typed, interactive. This is static metadata.
import type { ComputedRef, InjectionKey } from "vue";
import type { I18nText } from "@/types/i18n";
export interface DescriptionListProps {
@@ -18,8 +19,16 @@ export interface DescriptionListProps {
columns?: 1 | 2;
/** Tighter row rhythm, for a list inside a card rather than a page column. */
dense?: boolean;
/** Width of every item's label column; `narrow` gives the value the room in a side-column card. */
labelWidth?: DescriptionLabelWidth;
}
export type DescriptionLabelWidth = "default" | "narrow";
/** The list's label width, read by each item so one prop sets the whole column. */
export const DESCRIPTION_LABEL_WIDTH: InjectionKey<ComputedRef<DescriptionLabelWidth>> =
Symbol("descriptionLabelWidth");
export interface DescriptionItemProps {
label: I18nText;
/**
@@ -4,14 +4,24 @@
// ODescriptionList — the house label/value detail block. See the .types.ts for
// why this is a real <dl> and how it differs from OFieldList.
import { computed } from "vue";
import type { DescriptionListProps, DescriptionListSlots } from "./ODescriptionList.types";
import { computed, provide } from "vue";
import {
DESCRIPTION_LABEL_WIDTH,
type DescriptionListProps,
type DescriptionListSlots,
} from "./ODescriptionList.types";
const props = withDefaults(defineProps<DescriptionListProps>(), {
columns: 1,
dense: false,
labelWidth: "default",
});
provide(
DESCRIPTION_LABEL_WIDTH,
computed(() => props.labelWidth),
);
defineSlots<DescriptionListSlots>();
// One column below md regardless of `columns`: a two-column label/value block on
+2 -2
View File
@@ -26270,7 +26270,7 @@
"setupSubtitle": "يجب أن توجد أربعة أشياء. افعلها بالترتيب — كل واحد يفتح التالي.",
"setupStep1Title": "إنشاء فريق",
"setupStep1Body": "الفريق هو من يتم استدعاؤه. أعطه اسمًا والمنطقة الزمنية التي يجب أن يُقرأ فيها جدوله.",
"setupStep2Title": "إضافة الأشخاص ونظام التناوب",
"setupStep2Title": "إضافة الأعضاء ونظام التناوب",
"setupStep2Body": "أضف المهندسين إلى الفريق، ثم حدد من يتولى الدور الأساسي وكم مرة يتم تسليمه. تظهر المستويات غير المعينة كفجوات تغطية.",
"setupStep3Title": "وجّه التنبيهات إلى الفريق",
"setupStep3Body": "امنح الفريق ملكية مسار هوية مثل k8s-namespace=payments، أو عيّن الفريق مباشرة في تنبيه. استخدم مختبِر التوجيه للتحقق قبل انطلاق أحد التنبيهات.",
@@ -26715,7 +26715,7 @@
"setupStepChange": "تغيير",
"setupStepDone": "تم",
"setupStep1Cta": "إنشاء فريق",
"setupStep2Cta": "افتح الجدول",
"setupStep2Cta": "إضافة أعضاء",
"setupStep3Cta": "إعداد التوجيه",
"setupStep4Cta": "إضافة وجهة",
"setupDismiss": "إخفاء الآن",
+2 -2
View File
@@ -26270,7 +26270,7 @@
"setupSubtitle": "Vier Dinge müssen vorhanden sein. Erledigen Sie sie der Reihe nach – jedes schaltet das nächste frei.",
"setupStep1Title": "Team erstellen",
"setupStep1Body": "Ein Team ist die Gruppe, die alarmiert wird. Geben Sie ihm einen Namen und die Zeitzone, in der sein Dienstplan angezeigt werden soll.",
"setupStep2Title": "Personen und eine Rotation hinzufügen",
"setupStep2Title": "Mitglieder und eine Rotation hinzufügen",
"setupStep2Body": "Fügen Sie Entwickler zum Team hinzu, legen Sie dann fest, wer Primary ist und wie oft diese Rolle wechselt. Nicht besetzte Ebenen werden als Abdeckungslücken angezeigt.",
"setupStep3Title": "Alarme dem Team zuweisen",
"setupStep3Body": "Geben Sie dem Team die Zuständigkeit für einen Identitätspfad wie k8s-namespace=payments oder legen Sie das Team direkt in einem Alarm fest. Verwenden Sie den Routing-Tester, um zu prüfen, wohin ein Alarm weitergeleitet würde, bevor er ausgelöst wird.",
@@ -26715,7 +26715,7 @@
"setupStepChange": "Ändern",
"setupStepDone": "Erledigt",
"setupStep1Cta": "Team erstellen",
"setupStep2Cta": "Dienstplan öffnen",
"setupStep2Cta": "Mitglieder hinzufügen",
"setupStep3Cta": "Routing einrichten",
"setupStep4Cta": "Ziel hinzufügen",
"setupDismiss": "Vorerst ausblenden",
+2 -2
View File
@@ -26378,7 +26378,7 @@
"setupSubtitle": "Four things have to exist. Do them in order — each one unlocks the next.",
"setupStep1Title": "Create your team",
"setupStep1Body": "Name and timezone.",
"setupStep2Title": "Add people and who's on first",
"setupStep2Title": "Add members and who's on first",
"setupStep2Body": "Pick people, then who holds the pager and how often it hands over.",
"setupStep3Title": "Check how they're woken",
"setupStep3Body": "The default ladder is fine to start with.",
@@ -26858,7 +26858,7 @@
"setupStepChange": "Change",
"setupStepDone": "Done",
"setupStep1Cta": "Create a team",
"setupStep2Cta": "Add people",
"setupStep2Cta": "Add members",
"setupStep3Cta": "Review the ladder",
"setupStep4Cta": "Set up routing",
"setupDismiss": "Hide for now",
+2 -2
View File
@@ -26270,7 +26270,7 @@
"setupSubtitle": "Tienen que existir cuatro cosas. Hazlas en orden: cada una desbloquea la siguiente.",
"setupStep1Title": "Crear un equipo",
"setupStep1Body": "Un equipo es a quien se avisa. Ponle un nombre y la zona horaria en la que se leerá su horario.",
"setupStep2Title": "Añade personas y una rotación",
"setupStep2Title": "Añade miembros y una rotación",
"setupStep2Body": "Añade ingenieros al equipo y luego define quién tiene el rol principal y con qué frecuencia se hace el relevo. Los niveles sin cubrir se muestran como brechas de cobertura.",
"setupStep3Title": "Dirige las alertas al equipo",
"setupStep3Body": "Dale al equipo la propiedad de una ruta de identidad como k8s-namespace=payments, o asigna el equipo directamente en una alerta. Usa el probador de enrutamiento para comprobarlo antes de que se dispare una alerta.",
@@ -26715,7 +26715,7 @@
"setupStepChange": "Cambiar",
"setupStepDone": "Hecho",
"setupStep1Cta": "Crear un equipo",
"setupStep2Cta": "Abrir el horario",
"setupStep2Cta": "Añadir miembros",
"setupStep3Cta": "Configurar el enrutamiento",
"setupStep4Cta": "Añadir un destino",
"setupDismiss": "Ocultar por ahora",
+2 -2
View File
@@ -26270,7 +26270,7 @@
"setupSubtitle": "Quatre éléments doivent exister. Faites-les dans l'ordre — chacun débloque le suivant.",
"setupStep1Title": "Créer une équipe",
"setupStep1Body": "Une équipe est le groupe contacté en cas d'alerte. Donnez-lui un nom et le fuseau horaire dans lequel son planning doit être lu.",
"setupStep2Title": "Ajouter des personnes et une rotation",
"setupStep2Title": "Ajouter des membres et une rotation",
"setupStep2Body": "Ajoutez des ingénieurs à l'équipe, puis définissez qui occupe le rôle Principal et à quelle fréquence ce rôle est transmis. Les niveaux sans personne affectée apparaissent comme des lacunes dans la couverture.",
"setupStep3Title": "Acheminer les alertes vers l'équipe",
"setupStep3Body": "Accordez à l'équipe la propriété d'un chemin d'identité tel que k8s-namespace=payments, ou définissez l'équipe directement sur une alerte. Utilisez le testeur de routage pour vérifier avant qu'une alerte ne se déclenche.",
@@ -26715,7 +26715,7 @@
"setupStepChange": "Modifier",
"setupStepDone": "Terminé",
"setupStep1Cta": "Créer une équipe",
"setupStep2Cta": "Ouvrir le planning",
"setupStep2Cta": "Ajouter des membres",
"setupStep3Cta": "Configurer le routage",
"setupStep4Cta": "Ajouter une destination",
"setupDismiss": "Masquer pour l'instant",
+2 -2
View File
@@ -26270,7 +26270,7 @@
"setupSubtitle": "Ci sono quattro cose da fare. Eseguile in ordine — ognuna sblocca la successiva.",
"setupStep1Title": "Crea un team",
"setupStep1Body": "Un team è il gruppo di persone da avvisare. Assegna al team un nome e il fuso orario per il calendario dei turni.",
"setupStep2Title": "Aggiungi persone e una rotazione",
"setupStep2Title": "Aggiungi membri e una rotazione",
"setupStep2Body": "Aggiungi ingegneri al team, poi definisci chi ricopre il ruolo di Primary e ogni quanto avviene il passaggio di consegne. I livelli scoperti vengono mostrati come lacune di copertura.",
"setupStep3Title": "Indirizza gli avvisi al team",
"setupStep3Body": "Assegna al team un percorso di identità come k8s-namespace=payments, oppure imposta il team direttamente nell'avviso. Usa il tester di instradamento per verificare prima che un avviso scatti.",
@@ -26715,7 +26715,7 @@
"setupStepChange": "Modifica",
"setupStepDone": "Fatto",
"setupStep1Cta": "Crea un team",
"setupStep2Cta": "Apri il calendario",
"setupStep2Cta": "Aggiungi membri",
"setupStep3Cta": "Configura l'instradamento",
"setupStep4Cta": "Aggiungi una destinazione",
"setupDismiss": "Nascondi per ora",
+1 -1
View File
@@ -26715,7 +26715,7 @@
"setupStepChange": "変更",
"setupStepDone": "完了",
"setupStep1Cta": "チームを作成",
"setupStep2Cta": "スケジュールを開く",
"setupStep2Cta": "メンバーを追加",
"setupStep3Cta": "ルーティングを設定",
"setupStep4Cta": "宛先を追加",
"setupDismiss": "今は非表示",
+1 -1
View File
@@ -26715,7 +26715,7 @@
"setupStepChange": "변경",
"setupStepDone": "완료",
"setupStep1Cta": "팀 만들기",
"setupStep2Cta": "스케줄 열기",
"setupStep2Cta": "구성원 추가",
"setupStep3Cta": "라우팅 설정",
"setupStep4Cta": "수신처 추가",
"setupDismiss": "지금은 숨기기",
+2 -2
View File
@@ -26270,7 +26270,7 @@
"setupSubtitle": "Er moeten vier dingen aanwezig zijn. Doe ze in volgorde — elke stap ontgrendelt de volgende.",
"setupStep1Title": "Maak een team",
"setupStep1Body": "Een team is wie er opgeroepen wordt. Geef het een naam en de tijdzone die voor het rooster geldt.",
"setupStep2Title": "Voeg mensen en een rotatie toe",
"setupStep2Title": "Voeg leden en een rotatie toe",
"setupStep2Body": "Zet engineers in het team en stel daarna in wie Primary is en hoe vaak die rol wordt overgedragen. Niet-bezette niveaus worden weergegeven als dekkingsgaten.",
"setupStep3Title": "Routeer alarmen naar het team",
"setupStep3Body": "Geef het team eigenaarschap over een identiteitspad zoals k8s-namespace=payments, of vermeld het team rechtstreeks in een alarm. Gebruik de routingtester om dit te controleren voordat een alarm afgaat.",
@@ -26715,7 +26715,7 @@
"setupStepChange": "Wijzigen",
"setupStepDone": "Klaar",
"setupStep1Cta": "Maak een team",
"setupStep2Cta": "Open het schema",
"setupStep2Cta": "Leden toevoegen",
"setupStep3Cta": "Routering instellen",
"setupStep4Cta": "Bestemming toevoegen",
"setupDismiss": "Verberg voor nu",
+2 -2
View File
@@ -26270,7 +26270,7 @@
"setupSubtitle": "Muszą istnieć cztery rzeczy. Zrób je po kolei — każda odblokowuje następną.",
"setupStep1Title": "Utwórz zespół",
"setupStep1Body": "Zespół to ci, którzy są wzywani. Nadaj mu nazwę i strefę czasową, w której ma być odczytywany jego harmonogram.",
"setupStep2Title": "Dodaj osoby i rotację",
"setupStep2Title": "Dodaj członków i rotację",
"setupStep2Body": "Dodaj inżynierów do zespołu, a następnie ustal, kto pełni rolę Primary i jak często następuje przekazanie. Poziomy bez obsady są pokazywane jako luki w pokryciu.",
"setupStep3Title": "Skieruj alerty do zespołu",
"setupStep3Body": "Przypisz zespołowi ścieżkę tożsamości, taką jak k8s-namespace=payments, lub ustaw zespół bezpośrednio w alercie. Użyj testera kierowania, aby sprawdzić to przed uruchomieniem alertu.",
@@ -26715,7 +26715,7 @@
"setupStepChange": "Zmień",
"setupStepDone": "Gotowe",
"setupStep1Cta": "Utwórz zespół",
"setupStep2Cta": "Otwórz harmonogram",
"setupStep2Cta": "Dodaj członków",
"setupStep3Cta": "Skonfiguruj routing",
"setupStep4Cta": "Dodaj miejsce docelowe",
"setupDismiss": "Ukryj na razie",
+2 -2
View File
@@ -26270,7 +26270,7 @@
"setupSubtitle": "Quatro coisas têm de existir. Faça-as por ordem — cada uma desbloqueia a seguinte.",
"setupStep1Title": "Criar uma equipa",
"setupStep1Body": "Uma equipa é o grupo que é acionado. Dê-lhe um nome e o fuso horário em que a sua escala deve ser lida.",
"setupStep2Title": "Adicionar pessoas e uma rotação",
"setupStep2Title": "Adicionar membros e uma rotação",
"setupStep2Body": "Coloque engenheiros na equipa e depois defina quem ocupa o papel primário e com que frequência esta função é transferida. Níveis sem pessoa designada aparecem como lacunas de cobertura.",
"setupStep3Title": "Apontar alertas para a equipa",
"setupStep3Body": "Dê à equipa a responsabilidade por um caminho de identidade, como k8s-namespace=payments, ou defina a equipa diretamente num alerta. Use o testador de encaminhamento para verificar antes que um alerta dispare.",
@@ -26715,7 +26715,7 @@
"setupStepChange": "Alterar",
"setupStepDone": "Concluído",
"setupStep1Cta": "Criar uma equipa",
"setupStep2Cta": "Abrir a escala",
"setupStep2Cta": "Adicionar membros",
"setupStep3Cta": "Configurar encaminhamento",
"setupStep4Cta": "Adicionar um destino",
"setupDismiss": "Ocultar por enquanto",
+2 -2
View File
@@ -26270,7 +26270,7 @@
"setupSubtitle": "Должны существовать четыре вещи. Выполните их по порядку — каждая открывает следующую.",
"setupStep1Title": "Создайте команду",
"setupStep1Body": "Команда — это те, кто получает вызовы. Дайте ей имя и часовой пояс, в котором следует читать её расписание.",
"setupStep2Title": "Добавьте людей и график дежурств",
"setupStep2Title": "Добавьте участников и график дежурств",
"setupStep2Body": "Добавьте инженеров в команду, затем укажите, кто занимает Primary и как часто происходит передача. Уровни без назначенных людей отображаются как пробелы в покрытии.",
"setupStep3Title": "Направьте оповещения на команду",
"setupStep3Body": "Передайте команде ответственность за путь идентичности, например k8s-namespace=payments, или укажите команду напрямую в оповещении. Используйте тестер маршрутизации, чтобы проверить до срабатывания оповещения.",
@@ -26715,7 +26715,7 @@
"setupStepChange": "Изменить",
"setupStepDone": "Готово",
"setupStep1Cta": "Создать команду",
"setupStep2Cta": "Открыть расписание",
"setupStep2Cta": "Добавить участников",
"setupStep3Cta": "Настроить маршрутизацию",
"setupStep4Cta": "Добавить получателя",
"setupDismiss": "Скрыть пока",
+2 -2
View File
@@ -26268,7 +26268,7 @@
"setupSubtitle": "Four things have to exist. Do them in order — each one unlocks the next.",
"setupStep1Title": "Create a team",
"setupStep1Body": "A team is who gets paged. Give it a name and the timezone its schedule should be read in.",
"setupStep2Title": "Add people and a rotation",
"setupStep2Title": "เพิ่มสมาชิกและรอบเวร",
"setupStep2Body": "Put engineers on the team, then set who holds Primary and how often it hands over. Unstaffed levels are shown as coverage gaps.",
"setupStep3Title": "Point alerts at the team",
"setupStep3Body": "Give the team ownership of an identity path such as k8s-namespace=payments, or set the team directly on an alert. Use the routing tester to check before an alert fires.",
@@ -26713,7 +26713,7 @@
"setupStepChange": "Change",
"setupStepDone": "Done",
"setupStep1Cta": "Create a team",
"setupStep2Cta": "Open the schedule",
"setupStep2Cta": "เพิ่มสมาชิก",
"setupStep3Cta": "Set up routing",
"setupStep4Cta": "Add a destination",
"setupDismiss": "Hide for now",
+2 -2
View File
@@ -26270,7 +26270,7 @@
"setupSubtitle": "Dört şeyin var olması gerekir. Bunları sırayla yapın — her biri bir sonrakini açar.",
"setupStep1Title": "Ekip oluşturun",
"setupStep1Body": "Ekip, çağrı alacak kişilerin grubudur. Ona bir ad ve programının okunacağı saat dilimini verin.",
"setupStep2Title": "Kişi ve rotasyon ekleyin",
"setupStep2Title": "Üye ve rotasyon ekleyin",
"setupStep2Body": "Mühendisleri ekibe ekleyin, ardından Primary rolünü kimin üstleneceğini ve bu rolün ne sıklıkla devredileceğini ayarlayın. Ataması yapılmamış seviyeler kapsam boşlukları olarak gösterilir.",
"setupStep3Title": "Uyarıları ekibe yönlendirin",
"setupStep3Body": "Ekibe, k8s-namespace=payments gibi bir kimlik yolunun sahipliğini verin veya ekibi doğrudan bir uyarıya atayın. Uyarı tetiklenmeden önce kontrol etmek için yönlendirme test aracını kullanın.",
@@ -26715,7 +26715,7 @@
"setupStepChange": "Değiştir",
"setupStepDone": "Tamam",
"setupStep1Cta": "Ekip oluştur",
"setupStep2Cta": "Nöbet programını aç",
"setupStep2Cta": "Üye ekle",
"setupStep3Cta": "Yönlendirmeyi ayarla",
"setupStep4Cta": "Hedef ekle",
"setupDismiss": "Şimdilik gizle",
+2 -2
View File
@@ -26270,7 +26270,7 @@
"setupSubtitle": "Cần có bốn thứ. Hãy làm theo thứ tự — mỗi bước sẽ mở khóa bước tiếp theo.",
"setupStep1Title": "Tạo một nhóm",
"setupStep1Body": "Nhóm là những người được gọi. Hãy đặt tên và múi giờ để đọc lịch trực của nhóm.",
"setupStep2Title": "Thêm người và lịch luân phiên",
"setupStep2Title": "Thêm thành viên và lịch luân phiên",
"setupStep2Body": "Thêm kỹ sư vào nhóm, sau đó đặt ai giữ Primary và tần suất bàn giao. Các cấp không có người sẽ được hiển thị là lỗ hổng bao phủ.",
"setupStep3Title": "Hướng cảnh báo đến nhóm",
"setupStep3Body": "Gán quyền sở hữu cho nhóm đối với một đường dẫn định danh như k8s-namespace=payments, hoặc đặt nhóm trực tiếp trên cảnh báo. Dùng công cụ kiểm tra định tuyến để kiểm tra trước khi cảnh báo kích hoạt.",
@@ -26715,7 +26715,7 @@
"setupStepChange": "Thay đổi",
"setupStepDone": "Xong",
"setupStep1Cta": "Tạo nhóm",
"setupStep2Cta": "Mở lịch trực",
"setupStep2Cta": "Thêm thành viên",
"setupStep3Cta": "Thiết lập định tuyến",
"setupStep4Cta": "Thêm đích",
"setupDismiss": "Ẩn tạm thời",
+2 -2
View File
@@ -26270,7 +26270,7 @@
"setupSubtitle": "需要先准备好四件事。请按顺序完成——每一步都会解锁下一步。",
"setupStep1Title": "创建团队",
"setupStep1Body": "团队就是要被呼叫的对象。为它命名,并选择其排班所采用的时区。",
"setupStep2Title": "添加人员和轮班",
"setupStep2Title": "添加成员和轮班",
"setupStep2Body": "将工程师添加到团队中,然后设置谁担任主班以及多久交接一次。无人值守的层级会显示为覆盖缺口。",
"setupStep3Title": "将告警指向团队",
"setupStep3Body": "为团队设置某个身份路径的归属,例如 k8s-namespace=payments,或在告警上直接指定团队。可使用路由测试器在告警触发前验证。",
@@ -26715,7 +26715,7 @@
"setupStepChange": "更改",
"setupStepDone": "完成",
"setupStep1Cta": "创建团队",
"setupStep2Cta": "打开排班",
"setupStep2Cta": "添加成员",
"setupStep3Cta": "设置路由",
"setupStep4Cta": "添加目标",
"setupDismiss": "暂时隐藏",
+1 -1
View File
@@ -26710,7 +26710,7 @@
"setupStepChange": "變更",
"setupStepDone": "完成",
"setupStep1Cta": "建立團隊",
"setupStep2Cta": "開啟班表",
"setupStep2Cta": "新增成員",
"setupStep3Cta": "設定路由",
"setupStep4Cta": "新增目標",
"setupDismiss": "暫時隱藏",
+22 -4
View File
@@ -520,13 +520,13 @@ describe("OnCallMine", () => {
expect(wrapper.find('[data-test="oncall-mine-shifts-toggle"]').exists()).toBe(false);
expect(wrapper.text()).not.toContain("Show all");
// The fold is marked below the frame, never over a row; once reached its line empties but keeps its height.
// The fold is marked inside the fixed frame, so the loaded card keeps the skeleton's height; it goes once reached.
const more = () => wrapper.find('[data-test="oncall-mine-shifts-more"]');
expect(more().text()).toBe("115 more");
expect(frame().find('[data-test="oncall-mine-shifts-more"]').exists()).toBe(false);
expect(frame().find('[data-test="oncall-mine-shifts-more"]').exists()).toBe(true);
expect(more().classes()).toEqual(expect.arrayContaining(["absolute", "pointer-events-none"]));
await scroller.trigger("scroll");
expect(more().exists()).toBe(true);
expect(more().text()).toBe("");
expect(more().exists()).toBe(false);
// Off duty holds no seat, so no team here needs the who-is-on-call read.
expect(service.whoIsOnCall).not.toHaveBeenCalled();
});
@@ -625,4 +625,22 @@ describe("OnCallMine", () => {
expect(row.find('[data-test="oncall-mine-cover-team_1-menu"]').exists()).toBe(true);
expect(row.find('[data-test="oncall-mine-test-page-team_1-menu"]').exists()).toBe(true);
});
/// Off duty leaves one action, so the phone row shows it directly instead of a menu of one.
it("shows the test page as its own button on a phone row that is off duty", async () => {
viewport.mobile = true;
service.myOnCall.mockResolvedValue({
data: {
on_call_now: false,
teams: [{ team_id: "team_1", team_name: "Payments", on_call_now: false, on_call: [] }],
},
} as any);
const wrapper = mount(OnCallMine, { global: { plugins: [i18n, store], stubs } });
await flushPromises();
const row = wrapper.find('[data-test="oncall-mine-shift-row-team_1"]');
expect(row.find('[data-test="oncall-mine-row-menu-team_1"]').exists()).toBe(false);
await row.find('[data-test="oncall-mine-test-page-team_1"]').trigger("click");
expect(wrapper.findComponent({ name: "ConfirmDialog" }).props("modelValue")).toBe(true);
});
});
+29 -18
View File
@@ -175,7 +175,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</span>
</div>
<ODropdown side="bottom" align="end">
<!-- Off duty leaves only the test page, and a menu of one is a button. -->
<ODropdown v-if="canCover(row)" side="bottom" align="end">
<template #trigger>
<OButton
variant="ghost"
@@ -187,7 +188,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</template>
<ODropdownItem
v-if="canCover(row)"
icon-left="person-add"
:data-test="`oncall-mine-cover-${row.team_id}-menu`"
@select="openCover(row)"
@@ -202,13 +202,25 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
{{ t("oncall.mineTestTeam", { team: raw(row.team_name) }) }}
</ODropdownItem>
</ODropdown>
<OButton
v-else
variant="ghost"
size="icon-sm"
:loading="testingId === row.team_id"
:aria-label="t('oncall.mineTestTeam', { team: raw(row.team_name) })"
:data-test="`oncall-mine-test-page-${row.team_id}`"
@click="askTestPage(row.team_id)"
>
<OIcon name="campaign" size="sm" aria-hidden="true" />
<OTooltip :content="t('oncall.mineTestTeam', { team: raw(row.team_name) })" />
</OButton>
</div>
</div>
<!-- Scroll events do not bubble, so the frame listens in the capture phase to the table's own scroller. -->
<div
v-else
class="h-55.5 shrink-0"
class="relative h-55.5 shrink-0"
data-test="oncall-mine-shifts-frame"
@scroll.capture="onShiftsScroll"
>
@@ -307,22 +319,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</template>
</OTable>
</div>
<!-- Below the rows, never over them; the line keeps its height once scrolled so the card does not jump. -->
<div
v-if="!isMobile && loaded && shiftRows.length > SHIFTS_SKELETON_ROWS"
class="border-border-default flex h-7 shrink-0 items-center justify-center gap-1 border-t"
data-test="oncall-mine-shifts-more"
>
<OText
v-if="shiftsBelow > 0"
variant="meta"
as="span"
class="inline-flex items-center gap-1"
<!-- Over the last row's lower edge, inside the frame, so the card is the skeleton's height once loaded. -->
<div
v-if="loaded && shiftsBelow > 0"
class="from-surface-base pointer-events-none absolute inset-x-0 bottom-0 flex h-12 items-end justify-center bg-linear-to-t from-40% to-transparent pb-1.5"
data-test="oncall-mine-shifts-more"
>
<OIcon name="expand-more" size="sm" aria-hidden="true" />
{{ t("oncall.mineShiftsMore", { count: shiftsBelow }, shiftsBelow) }}
</OText>
<OText
variant="meta"
as="span"
class="bg-surface-base border-border-default inline-flex items-center gap-1 rounded-full border px-2 shadow-xs"
>
<OIcon name="expand-more" size="sm" aria-hidden="true" />
{{ t("oncall.mineShiftsMore", { count: shiftsBelow }, shiftsBelow) }}
</OText>
</div>
</div>
</section>
@@ -19,6 +19,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/locales";
import { queryClient } from "@/composables/query/queryClient";
import alertsService from "@/services/alerts";
import { alertKeys } from "@/services/alerts.querykeys";
import oncallService from "@/services/oncall";
import { responseProgressQuery } from "@/services/oncall.queries";
import { oncallKeys } from "@/services/oncall.querykeys";
@@ -62,7 +63,7 @@ vi.mock("@/services/product_analytics", () => ({ default: { track: vi.fn() } }))
import analytics from "@/services/product_analytics";
vi.mock("@/services/alerts", () => ({
default: { get_by_alert_id: vi.fn() },
default: { get_by_alert_id: vi.fn(), listByFolderId: vi.fn() },
}));
/// Escalate's whole point is what it reports back, and the report is a toast.
@@ -702,6 +703,33 @@ describe("OnCallResponseDetail", () => {
);
});
/// A cached org-wide list filled after the page opened settles it with no request at all.
it("learns the alert is gone from an already cached alert list", async () => {
queryClient.setQueryData(
alertKeys.dependencies(store.state.selectedOrganization.identifier),
[{ alert_id: "al_other" }],
);
const wrapper = await renderWith(held());
expect(alerts.get_by_alert_id).not.toHaveBeenCalled();
expect(alerts.listByFolderId).not.toHaveBeenCalled();
expect(unackBtn(wrapper).findComponent({ name: "OTooltip" }).props("content")).toBe(
"Can't unacknowledge: this page's alert was deleted",
);
queryClient.removeQueries({
queryKey: alertKeys.dependencies(store.state.selectedOrganization.identifier),
});
});
it("asks for the alert itself when no list is cached", async () => {
alerts.get_by_alert_id.mockResolvedValue({ data: {} } as any);
const wrapper = await renderWith(held());
expect(alerts.listByFolderId).not.toHaveBeenCalled();
expect(alerts.get_by_alert_id).toHaveBeenCalledWith(expect.any(String), "al_ckt");
expect(unackBtn(wrapper).props("disabled")).toBe(false);
});
it("keeps the button on when the alert lookup failed for another reason", async () => {
alerts.get_by_alert_id.mockRejectedValue({ response: { status: 500 } });
const wrapper = await renderWith(held());
+16 -1
View File
@@ -924,6 +924,7 @@ import analytics from "@/services/product_analytics";
import OTextarea from "@/lib/forms/Input/OTextarea.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import alertsService from "@/services/alerts";
import { alertKeys } from "@/services/alerts.querykeys";
import { useMutation } from "@tanstack/vue-query";
import { queryClient } from "@/composables/query/queryClient";
import {
@@ -1724,8 +1725,13 @@ async function fetchSubjectAlert() {
subjectAlert.value = null;
subjectAlertMissing.value = false;
if (response.value?.subject.subject_type !== "alert") return;
const alertId = response.value.subject.source_id;
if (cachedAlertMissing(alertId, response.value.opened_at)) {
subjectAlertMissing.value = true;
return;
}
try {
const res = await alertsService.get_by_alert_id(orgId.value, response.value.subject.source_id);
const res = await alertsService.get_by_alert_id(orgId.value, alertId);
subjectAlert.value = res.data ?? null;
} catch (err: any) {
subjectAlert.value = null;
@@ -1733,6 +1739,15 @@ async function fetchSubjectAlert() {
}
}
/// Only an org-wide list already cached since the page opened can say the alert is gone; anything else asks the alert itself.
function cachedAlertMissing(alertId: string, openedAt: number): boolean {
const key = alertKeys.dependencies(orgId.value);
const list = queryClient.getQueryData<{ alert_id?: string }[]>(key);
const fetchedAt = queryClient.getQueryState(key)?.dataUpdatedAt ?? 0;
if (!Array.isArray(list) || fetchedAt * 1000 < openedAt) return false;
return !list.some((alert) => alert.alert_id === alertId);
}
async function fetchIncidentTitle() {
const incidentId = response.value?.incident_id;
if (!incidentId) {
+3 -4
View File
@@ -738,16 +738,15 @@ describe("OnCallResponses", () => {
expect(service.escalationProgress).not.toHaveBeenCalled();
});
/// One request per page and no bulk endpoint: past the cap the list says so
/// rather than leaving a blank cell that reads as "nothing has fired".
it("caps how many ladders it loads, and says that it did", async () => {
/// One request per page and no bulk endpoint; a row past the cap speaks for itself, so the footer stays quiet.
it("caps how many ladders it loads without a footer note about it", async () => {
const many = Array.from({ length: 30 }, (_, i) =>
page({ id: `resp_${i}`, opened_at: 1_700_000_000_000_000 + i }, `al_${i}`),
);
const wrapper = await withPages(many);
expect(service.escalationProgress).toHaveBeenCalledTimes(25);
expect(wrapper.find('[data-test="oncall-escalation-capped"]').exists()).toBe(true);
expect(wrapper.find('[data-test="oncall-escalation-capped"]').exists()).toBe(false);
});
/// Rows past the cap have no progress: the record's own step, or no claim, never "Not paged yet".
+4 -11
View File
@@ -293,15 +293,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<!-- Both caps are stated: a capped list under-reports, and a blank ladder cell would read as "nothing has fired". -->
<template v-if="truncated || escalationCapped" #footer-note>
<span class="flex flex-wrap gap-x-3">
<!-- The list endpoint has no count, so the loaded length is never presented as the total. -->
<span v-if="truncated" data-test="oncall-responses-truncated">
{{ t("oncall.listTruncatedNoTotal", { count: responses.length }) }}
</span>
<span v-if="escalationCapped" data-test="oncall-escalation-capped">
{{ t("oncall.escalationDetailCapped", { count: ESCALATION_DETAIL_LIMIT }) }}
</span>
<!-- The list endpoint has no count, so the loaded length is never presented as the total. -->
<template v-if="truncated" #footer-note>
<span data-test="oncall-responses-truncated">
{{ t("oncall.listTruncatedNoTotal", { count: responses.length }) }}
</span>
</template>
@@ -1007,7 +1002,6 @@ const positionsByTeam = ref<Record<string, OnCallPosition[]>>({});
const progressById = ref<Record<string, EscalationProgress>>({});
/// Ids whose progress was asked for and answered; a row past the cap or whose read failed is not in it, so it never reads "Not paged yet".
const progressAnswered = ref<Set<string>>(new Set());
const escalationCapped = ref(false);
const expandedIds = ref<string[]>([]);
const expandedEvents = ref<OnCallResponseEvent[]>([]);
const expandedLoading = ref(false);
@@ -2058,7 +2052,6 @@ async function fetchEscalationProgress(force = false) {
.filter((r) => isEscalating(r.state))
.sort((a, b) => a.opened_at - b.opened_at);
escalationCapped.value = open.length > ESCALATION_DETAIL_LIMIT;
const wanted = open.slice(0, ESCALATION_DETAIL_LIMIT);
const results = await Promise.allSettled(