mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
fix(oncall): round 3 fixes for My on-call, page detail, Pages and Telephony
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "إخفاء الآن",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -26715,7 +26715,7 @@
|
||||
"setupStepChange": "変更",
|
||||
"setupStepDone": "完了",
|
||||
"setupStep1Cta": "チームを作成",
|
||||
"setupStep2Cta": "スケジュールを開く",
|
||||
"setupStep2Cta": "メンバーを追加",
|
||||
"setupStep3Cta": "ルーティングを設定",
|
||||
"setupStep4Cta": "宛先を追加",
|
||||
"setupDismiss": "今は非表示",
|
||||
|
||||
@@ -26715,7 +26715,7 @@
|
||||
"setupStepChange": "변경",
|
||||
"setupStepDone": "완료",
|
||||
"setupStep1Cta": "팀 만들기",
|
||||
"setupStep2Cta": "스케줄 열기",
|
||||
"setupStep2Cta": "구성원 추가",
|
||||
"setupStep3Cta": "라우팅 설정",
|
||||
"setupStep4Cta": "수신처 추가",
|
||||
"setupDismiss": "지금은 숨기기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Скрыть пока",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "暂时隐藏",
|
||||
|
||||
@@ -26710,7 +26710,7 @@
|
||||
"setupStepChange": "變更",
|
||||
"setupStepDone": "完成",
|
||||
"setupStep1Cta": "建立團隊",
|
||||
"setupStep2Cta": "開啟班表",
|
||||
"setupStep2Cta": "新增成員",
|
||||
"setupStep3Cta": "設定路由",
|
||||
"setupStep4Cta": "新增目標",
|
||||
"setupDismiss": "暫時隱藏",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user