mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 00:09:35 +00:00
Merge branch 'fix/oncall-ux-schedule' into fix/oncall-ux-polish
This commit is contained in:
@@ -80,6 +80,9 @@ export class OnCallTeamDetailPage {
|
||||
timelineChart: '[data-test="oncall-timeline-chart"]',
|
||||
timelineEmpty: '[data-test="oncall-timeline-empty"]',
|
||||
timelineAdd: '[data-test="oncall-timeline-add"]',
|
||||
// A team with no rotations yet gets a From a template / From scratch menu on Add rotation.
|
||||
timelineAddScratch: '[data-test="oncall-timeline-add-scratch"]',
|
||||
timelineAddTemplate: '[data-test="oncall-timeline-add-template"]',
|
||||
laneMenus: '[data-test^="oncall-lane-menu-"]',
|
||||
laneStartOver: '[data-test^="oncall-lane-presets-"]',
|
||||
timelineGap: '[data-test="oncall-timeline-gap"]',
|
||||
@@ -364,6 +367,8 @@ export class OnCallTeamDetailPage {
|
||||
*/
|
||||
async openRotationEditor() {
|
||||
await this.page.locator(this.locators.timelineAdd).click();
|
||||
const scratch = this.page.locator(this.locators.timelineAddScratch);
|
||||
if (await scratch.isVisible({ timeout: 1500 }).catch(() => false)) await scratch.click();
|
||||
await expect(this.page.locator(this.locators.rotationDrawer)).toBeVisible({ timeout: 20000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -21,8 +21,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
data-test="oncall-members-away-dialog"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="flex items-center gap-2">
|
||||
<OUserCell v-if="email" :value="email" />
|
||||
<span v-if="email" class="flex min-w-0 items-center gap-2">
|
||||
<OAvatar :value="email" :name="nameOf(email)" size="sm" />
|
||||
<OnCallPersonName
|
||||
truncate
|
||||
:email="email"
|
||||
class="text-text-heading text-sm font-medium"
|
||||
data-test="oncall-members-away-person"
|
||||
/>
|
||||
</span>
|
||||
<OText variant="meta">{{ t("oncall.awayHintShort") }}</OText>
|
||||
<!-- The times below are the team's clock, said the way the Schedule tab says it. -->
|
||||
@@ -88,7 +94,9 @@ import { useStore } from "vuex";
|
||||
import { useMutation } from "@tanstack/vue-query";
|
||||
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OUserCell from "@/lib/core/Table/cells/OUserCell.vue";
|
||||
import OAvatar from "@/lib/core/Avatar/OAvatar.vue";
|
||||
import OnCallPersonName from "@/components/oncall/OnCallPersonName.vue";
|
||||
import { useOrgUserNames } from "@/composables/useOrgUserNames";
|
||||
import OText from "@/lib/core/Typography/OText.vue";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import ODate from "@/lib/forms/Date/ODate.vue";
|
||||
@@ -117,6 +125,7 @@ const emit = defineEmits<{ saved: [] }>();
|
||||
const open = defineModel<boolean>("open", { required: true });
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
const { nameOf } = useOrgUserNames();
|
||||
const { noteConfigurationDenied } = useOnCallPermissions();
|
||||
const store = useStore();
|
||||
const orgId = computed(() => store.state.selectedOrganization.identifier);
|
||||
|
||||
@@ -23,6 +23,9 @@ import i18n from "@/locales";
|
||||
import { MICROS_PER_DAY, MICROS_PER_HOUR, MICROS_PER_WEEK } from "@/ts/interfaces/oncall";
|
||||
import { toZonedInputValue } from "@/utils/oncall";
|
||||
import type { UpcomingShift } from "./OnCallCoverForm.vue";
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import { orgUsersQuery } from "@/services/users.queries";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
|
||||
/// Tomorrow, not a frozen instant in 2023. Every fixture below is a window
|
||||
/// somebody could actually be asked to cover, and a schema that refuses hours
|
||||
@@ -514,6 +517,55 @@ describe("OnCallCoverForm — taking a cover", () => {
|
||||
expect(summary).not.toContain("ana@o2.ai");
|
||||
});
|
||||
|
||||
// Names are what a person recognises; the email tells two namesakes apart, so it is the row's second line and the name's tooltip.
|
||||
it("lists and names people by display name, keeping the email beside it", async () => {
|
||||
queryClient.setQueryData(orgUsersQuery("default").queryKey, [
|
||||
{ email: "ana@o2.ai", first_name: "Ana" },
|
||||
{ email: "bo@o2.ai", first_name: "Bo", last_name: "Lin" },
|
||||
]);
|
||||
const wrapper = mount(OnCallCoverForm, {
|
||||
props: {
|
||||
open: true,
|
||||
members: MEMBERS,
|
||||
timezone: "UTC",
|
||||
shifts: [],
|
||||
gap: GAP,
|
||||
currentHolder: "ana@o2.ai",
|
||||
defaultUser: "bo@o2.ai",
|
||||
teamName: "Platform",
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n, store],
|
||||
stubs: {
|
||||
...stubs,
|
||||
OFormSelect: {
|
||||
name: "OFormSelect",
|
||||
props: ["name"],
|
||||
inheritAttrs: false,
|
||||
template: "<div />",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const who = wrapper
|
||||
.findAllComponents({ name: "OFormSelect" })
|
||||
.find((one: any) => one.props("name") === "user_email")!;
|
||||
// `options` reaches OSelect through attribute fall-through; OFormSelect declares only `name`.
|
||||
expect(who.vm.$attrs.options).toEqual([
|
||||
expect.objectContaining({ label: "Ana", value: "ana@o2.ai", subLabel: "ana@o2.ai" }),
|
||||
expect.objectContaining({ label: "Bo Lin", value: "bo@o2.ai", subLabel: "bo@o2.ai" }),
|
||||
]);
|
||||
expect(wrapper.find('[data-test="oncall-cover-currently"]').text()).toBe(
|
||||
"Currently on call: Ana",
|
||||
);
|
||||
const summary = wrapper.find('[data-test="oncall-cover-summary"]');
|
||||
expect(summary.text()).toContain("Bo Lin covers Platform");
|
||||
expect(summary.findComponent({ name: "OnCallPersonName" }).props("email")).toBe("bo@o2.ai");
|
||||
queryClient.removeQueries({ queryKey: orgUsersQuery("default").queryKey });
|
||||
});
|
||||
|
||||
/// No person picked yet is the same half-answered state as a half-picked
|
||||
/// window: " covers Platform Sat 18:00 – …" is worse than no sentence.
|
||||
it("says nothing until somebody has been picked", async () => {
|
||||
|
||||
@@ -70,7 +70,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
that says only "Ana and Bo swap" leaves the reader to work out which
|
||||
week each of them ends up holding. -->
|
||||
<OBanner v-if="swapSummary" variant="info" data-test="oncall-swap-summary">
|
||||
{{ swapSummary }}
|
||||
<i18n-t keypath="oncall.swapSummary" tag="span" scope="global">
|
||||
<template #first><OnCallPersonName :email="swapSummary.first" /></template>
|
||||
<template #second><OnCallPersonName :email="swapSummary.second" /></template>
|
||||
<template #firstWhen>{{ swapSummary.firstWhen }}</template>
|
||||
<template #secondWhen>{{ swapSummary.secondWhen }}</template>
|
||||
</i18n-t>
|
||||
</OBanner>
|
||||
<p
|
||||
v-else-if="swapProblem"
|
||||
@@ -82,14 +87,26 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
|
||||
<OForm v-else :id="FORM_ID" :form="form" class="flex flex-col gap-5">
|
||||
<OFormSelect
|
||||
name="user_email"
|
||||
:label="t('oncall.coverWho')"
|
||||
:options="memberOptions"
|
||||
:help-text="currentlyOnCall"
|
||||
required
|
||||
data-test="oncall-cover-who"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<OFormSelect
|
||||
name="user_email"
|
||||
:label="t('oncall.coverWho')"
|
||||
:options="memberOptions"
|
||||
searchable
|
||||
required
|
||||
data-test="oncall-cover-who"
|
||||
/>
|
||||
<i18n-t
|
||||
v-if="currentHolder"
|
||||
keypath="oncall.coverCurrentlyOnCall"
|
||||
tag="span"
|
||||
scope="global"
|
||||
class="text-input-help-text text-xs"
|
||||
data-test="oncall-cover-currently"
|
||||
>
|
||||
<template #name><OnCallPersonName :email="currentHolder" /></template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<!-- Only when there is a choice to make. A one-rotation team has one
|
||||
rotation, and a select with one option is a question with one
|
||||
@@ -136,7 +153,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<!-- What this will actually do, in the team's own clock, before saving. -->
|
||||
<OBanner v-if="summary" variant="info" data-test="oncall-cover-summary">
|
||||
{{ summary }}
|
||||
<i18n-t keypath="oncall.coverSummary" tag="span" scope="global">
|
||||
<template #name><OnCallPersonName :email="summary.who" /></template>
|
||||
<template #team>{{ summary.team }}</template>
|
||||
<template #range>{{ summary.range }}</template>
|
||||
</i18n-t>
|
||||
<span v-if="teamZoneSummary" class="block" data-test="oncall-cover-summary-team-zone">
|
||||
{{ teamZoneSummary }}
|
||||
</span>
|
||||
@@ -182,6 +203,8 @@ export interface UpcomingShift extends Shift {
|
||||
}
|
||||
import { formatInZone, fromZonedInputValue, toZonedInputValue } from "@/utils/oncall";
|
||||
import { makeOnCallCoverSchema } from "./OnCallCoverForm.schema";
|
||||
import OnCallPersonName from "./OnCallPersonName.vue";
|
||||
import { useOrgUserNames } from "@/composables/useOrgUserNames";
|
||||
|
||||
/**
|
||||
* What the cover half of this dialog holds.
|
||||
@@ -324,15 +347,9 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
const memberOptions = computed(() =>
|
||||
props.members.map((member) => ({ label: raw(member.user_email), value: member.user_email })),
|
||||
);
|
||||
const { nameOf, optionOf } = useOrgUserNames();
|
||||
|
||||
const currentlyOnCall = computed<I18nText | undefined>(() =>
|
||||
props.currentHolder
|
||||
? t("oncall.coverCurrentlyOnCall", { name: raw(props.currentHolder) })
|
||||
: undefined,
|
||||
);
|
||||
const memberOptions = computed(() => props.members.map((member) => optionOf(member.user_email)));
|
||||
|
||||
/// A pre-selected person is only honest if the picker can show them: the caller
|
||||
/// passes whoever is reading the page, and a reader who is not on this team has
|
||||
@@ -462,23 +479,21 @@ const formatRange = (micros: number, zone: string) =>
|
||||
timeZoneName: "short",
|
||||
});
|
||||
|
||||
const summary = computed<I18nText | "">(() => {
|
||||
const summary = computed<{ who: string; team: I18nText; range: I18nText } | null>(() => {
|
||||
const window = windowValue.value;
|
||||
const who = String(userValue.value ?? "");
|
||||
// A half-picked range is not a sentence yet — the picker reports one end at
|
||||
// a time, and "Sat 18:00 – Invalid Date" is worse than nothing. Nor is a
|
||||
// range with nobody against it.
|
||||
if (typeof window?.from !== "number" || typeof window?.to !== "number") return "";
|
||||
if (!who) return "";
|
||||
if (typeof window?.from !== "number" || typeof window?.to !== "number") return null;
|
||||
if (!who) return null;
|
||||
const fmt = (micros: number) => formatRange(micros, props.viewerTimezone);
|
||||
// The person TAKING the shift. This read `currentHolder` — whoever is being
|
||||
// relieved — so the confirmation named the wrong side of the handover, and
|
||||
// the team was passed as an empty string.
|
||||
return t("oncall.coverSummary", {
|
||||
name: raw(who),
|
||||
// The person TAKING the shift, not `currentHolder` — whoever is being relieved.
|
||||
return {
|
||||
who,
|
||||
team: raw(props.teamName ?? ""),
|
||||
range: raw(`${fmt(window.from)} – ${fmt(window.to)}`),
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
/// The same window on the team's clock, and only when that is a different
|
||||
@@ -527,7 +542,7 @@ const rotationOptions = computed(() =>
|
||||
/// the wrong rotation.
|
||||
const shiftLabel = (shift: UpcomingShift): I18nText =>
|
||||
t(props.rotations.length > 1 ? "oncall.swapShiftOptionInRotation" : "oncall.swapShiftOption", {
|
||||
name: raw(shift.member),
|
||||
name: raw(nameOf(shift.member)),
|
||||
rotation: raw(shift.rotationName),
|
||||
range: raw(
|
||||
`${formatInZone(shift.startMicros, props.timezone, {
|
||||
@@ -580,9 +595,14 @@ const swapReady = computed(() => !!swapPair.value && !swapProblem.value);
|
||||
/// Named both ways round on purpose: "Ana and Bo swap" leaves the reader to
|
||||
/// work out which week each of them ends up holding, and getting that backwards
|
||||
/// is the whole risk of the errand.
|
||||
const swapSummary = computed<I18nText | "">(() => {
|
||||
const swapSummary = computed<{
|
||||
first: string;
|
||||
second: string;
|
||||
firstWhen: I18nText;
|
||||
secondWhen: I18nText;
|
||||
} | null>(() => {
|
||||
const pair = swapPair.value;
|
||||
if (!pair || swapProblem.value) return "";
|
||||
if (!pair || swapProblem.value) return null;
|
||||
const when = (shift: UpcomingShift) =>
|
||||
raw(
|
||||
formatInZone(shift.startMicros, props.timezone, {
|
||||
@@ -591,12 +611,12 @@ const swapSummary = computed<I18nText | "">(() => {
|
||||
month: "short",
|
||||
}),
|
||||
);
|
||||
return t("oncall.swapSummary", {
|
||||
first: raw(pair.first.member),
|
||||
second: raw(pair.second.member),
|
||||
return {
|
||||
first: pair.first.member,
|
||||
second: pair.second.member,
|
||||
firstWhen: when(pair.first),
|
||||
secondWhen: when(pair.second),
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
function submitSwap() {
|
||||
|
||||
@@ -46,13 +46,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
data-test="oncall-cover-table"
|
||||
>
|
||||
<template #cell-who="{ row }">
|
||||
<OUserCell :value="row.user_email" />
|
||||
<OnCallPersonName truncate :email="row.user_email" />
|
||||
</template>
|
||||
|
||||
<!-- Optional on the wire, and legitimately so: "cover tonight" is a real
|
||||
request before anybody has worked out whose night it is. -->
|
||||
<template #cell-coveringFor="{ row }">
|
||||
<OUserCell v-if="row.covering_for" :value="row.covering_for" />
|
||||
<OnCallPersonName v-if="row.covering_for" truncate :email="row.covering_for" />
|
||||
<OText v-else variant="meta">{{ t("oncall.coverForNobodyNamed") }}</OText>
|
||||
</template>
|
||||
|
||||
@@ -104,11 +104,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<OText v-if="shownRemove">
|
||||
<i18n-t keypath="oncall.coverRemoveBody" tag="span">
|
||||
<template #who>
|
||||
<span
|
||||
<OnCallPersonName
|
||||
:email="shownRemove.user_email"
|
||||
data-test="oncall-cover-remove-name"
|
||||
class="text-text-heading font-semibold break-all"
|
||||
>{{ shownRemove.user_email }}</span
|
||||
>
|
||||
class="text-text-heading font-semibold"
|
||||
/>
|
||||
</template>
|
||||
<template #range>{{ windowOf(shownRemove) }}</template>
|
||||
</i18n-t>
|
||||
@@ -125,7 +125,7 @@ import { queryClient } from "@/composables/query/queryClient";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OTable from "@/lib/core/Table/OTable.vue";
|
||||
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
|
||||
import OUserCell from "@/lib/core/Table/cells/OUserCell.vue";
|
||||
import OnCallPersonName from "./OnCallPersonName.vue";
|
||||
import OText from "@/lib/core/Typography/OText.vue";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
|
||||
|
||||
@@ -33,16 +33,34 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
>
|
||||
<span class="text-text-secondary text-xs">{{ t("oncall.onCallNow") }}</span>
|
||||
<template v-if="current.primary">
|
||||
<span data-test="oncall-coverage-now-primary">{{
|
||||
t("oncall.coveragePrimaryOnly", { primary: raw(current.primary) })
|
||||
}}</span>
|
||||
<span
|
||||
<i18n-t
|
||||
keypath="oncall.coveragePrimaryOnly"
|
||||
tag="span"
|
||||
scope="global"
|
||||
data-test="oncall-coverage-now-primary"
|
||||
>
|
||||
<template #primary>
|
||||
<OnCallPersonName
|
||||
:email="current.primary"
|
||||
data-test="oncall-coverage-now-primary-person"
|
||||
/>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<i18n-t
|
||||
v-if="current.secondary"
|
||||
keypath="oncall.coverageSecondaryOnly"
|
||||
tag="span"
|
||||
scope="global"
|
||||
class="text-text-secondary"
|
||||
data-test="oncall-coverage-now-secondary"
|
||||
>
|
||||
{{ t("oncall.coverageSecondaryOnly", { secondary: raw(current.secondary) }) }}
|
||||
</span>
|
||||
<template #secondary>
|
||||
<OnCallPersonName
|
||||
:email="current.secondary"
|
||||
data-test="oncall-coverage-now-secondary-person"
|
||||
/>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</template>
|
||||
<span v-else class="text-status-error-text" data-test="oncall-coverage-now-nobody">
|
||||
{{ t("oncall.coverNobody") }}
|
||||
@@ -123,6 +141,8 @@ import type {
|
||||
ScheduleTrack,
|
||||
} from "@/lib/data/ScheduleTimeline/OScheduleTimeline.types";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import OnCallPersonName from "@/components/oncall/OnCallPersonName.vue";
|
||||
import { useOrgUserNames } from "@/composables/useOrgUserNames";
|
||||
import type { Rotation } from "@/ts/interfaces/oncall";
|
||||
import { MICROS_PER_DAY } from "@/ts/interfaces/oncall";
|
||||
import type { I18nText } from "@/types/i18n";
|
||||
@@ -146,6 +166,7 @@ const props = withDefaults(
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
const { isMobile } = useBreakpoint();
|
||||
const { tooltipOf } = useOrgUserNames();
|
||||
|
||||
/** One sample per hour: fine enough to catch an hour-long hole. */
|
||||
const STEP_MICROS = 60 * 60 * 1_000_000;
|
||||
@@ -338,9 +359,9 @@ function tooltipFor(bandKey: string): TooltipSegment[] {
|
||||
`${rangeStamp(Math.max(segment.from, run.from))} – ${rangeStamp(Math.min(segment.to, run.to))}`,
|
||||
)
|
||||
: null,
|
||||
primary: t("oncall.coveragePrimaryOnly", { primary: raw(segment.primary ?? "") }),
|
||||
primary: t("oncall.coveragePrimaryOnly", { primary: tooltipOf(segment.primary ?? "") }),
|
||||
secondary: segment.secondary
|
||||
? t("oncall.coverageSecondaryOnly", { secondary: raw(segment.secondary) })
|
||||
? t("oncall.coverageSecondaryOnly", { secondary: tooltipOf(segment.secondary) })
|
||||
: null,
|
||||
}));
|
||||
|
||||
|
||||
@@ -728,6 +728,30 @@ describe("OnCallEscalationLadder", () => {
|
||||
expect(wrapper.emitted("edit")).toHaveLength(1);
|
||||
});
|
||||
|
||||
// The card body is a pointer shortcut to Edit, so it says so on hover and only for a reader who may edit.
|
||||
it("opens Edit from anywhere on the ladder for a reader who may change it", async () => {
|
||||
const editor = render({ canConfigure: true });
|
||||
const body = editor.find('[data-test="oncall-ladder-body"]');
|
||||
expect(body.classes()).toContain("cursor-pointer");
|
||||
await body.trigger("click");
|
||||
expect(editor.emitted("edit")).toHaveLength(1);
|
||||
|
||||
const reader = render();
|
||||
const readOnly = reader.find('[data-test="oncall-ladder-body"]');
|
||||
expect(readOnly.classes()).not.toContain("cursor-pointer");
|
||||
await readOnly.trigger("click");
|
||||
expect(reader.emitted("edit")).toBeUndefined();
|
||||
});
|
||||
|
||||
// Two people can share a name, so each name on a rung carries its email in a tooltip.
|
||||
it("names people on a rung with their email one hover away", () => {
|
||||
const wrapper = render();
|
||||
const names = wrapper.findAllComponents({ name: "OnCallPersonName" });
|
||||
expect(names.length).toBeGreaterThan(0);
|
||||
expect(names[0].props("email")).toBe("ana@o2.ai");
|
||||
expect(names[0].findComponent({ name: "OTooltip" }).props("content")).toBe("ana@o2.ai");
|
||||
});
|
||||
|
||||
it("offers no Add a step to a reader who cannot change the policy", () => {
|
||||
const wrapper = render({ preview: preview({ reaches_nobody: true }) });
|
||||
|
||||
|
||||
@@ -102,7 +102,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<OInnerLoading v-if="loading" showing />
|
||||
|
||||
<template v-else>
|
||||
<!-- Pointer-only shortcut to Edit; the header's Edit button stays the keyboard path. -->
|
||||
<div
|
||||
v-else
|
||||
class="rounded-b-surface flex flex-col"
|
||||
:class="canConfigure ? 'hover:bg-table-row-hover-bg cursor-pointer transition-colors' : ''"
|
||||
data-test="oncall-ladder-body"
|
||||
@click="canConfigure && emit('edit')"
|
||||
>
|
||||
<!-- Every level of every priority aimed at the whole team, on a team that HAS rotations, is a policy minted before it had members. -->
|
||||
<span
|
||||
v-if="staleDefaultLadder"
|
||||
@@ -176,7 +183,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:data-test="`oncall-ladder-person-${group.firstMicros}-${one.email}`"
|
||||
>
|
||||
<OAvatar :value="one.email" :name="nameOf(one.email)" />
|
||||
<OTruncatedText>{{ raw(nameOf(one.email)) }}</OTruncatedText>
|
||||
<OnCallPersonName
|
||||
truncate
|
||||
:email="one.email"
|
||||
:data-test="`oncall-ladder-person-name-${group.firstMicros}-${one.email}`"
|
||||
/>
|
||||
<OTag v-for="seat in one.seats" :key="seat" variant="default-outline" size="sm">
|
||||
{{ raw(seat) }}
|
||||
</OTag>
|
||||
@@ -212,10 +223,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</li>
|
||||
|
||||
<!-- The ending is the rail's last stop: when the ladder runs out is what the delays are read against. -->
|
||||
<li class="relative flex min-h-10 items-center gap-3 py-2" data-test="oncall-ladder-ends">
|
||||
<li
|
||||
class="relative flex min-h-10 items-center gap-3 py-2 max-md:items-start"
|
||||
data-test="oncall-ladder-ends"
|
||||
>
|
||||
<span
|
||||
v-if="rungGroups.length"
|
||||
class="bg-border-default absolute start-3 top-0 h-1/2 w-px"
|
||||
class="bg-border-default absolute start-3 top-0 h-1/2 w-px max-md:h-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
@@ -224,28 +238,36 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
>
|
||||
<OIcon name="stop-circle" size="sm" />
|
||||
</span>
|
||||
<OText variant="meta" class="w-24 shrink-0" data-test="oncall-ladder-ends-time">
|
||||
{{ endTime }}
|
||||
</OText>
|
||||
<!-- How a page can leave this team, in the server's words, on hover of the ending it qualifies. -->
|
||||
<span class="text-text-secondary text-sm" data-test="oncall-ladder-ends-line">
|
||||
{{ endTitle }}
|
||||
<OTooltip
|
||||
v-if="preview.cross_team_moves.length"
|
||||
side="bottom"
|
||||
:content="raw(preview.cross_team_moves.join(' '))"
|
||||
/>
|
||||
</span>
|
||||
<OButton
|
||||
v-if="canConfigure"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon-left="add"
|
||||
data-test="oncall-ladder-add-rung"
|
||||
@click="emit('edit')"
|
||||
<span
|
||||
class="flex min-w-0 flex-1 items-center gap-3 max-md:flex-col max-md:items-start max-md:gap-1"
|
||||
>
|
||||
{{ t("oncall.ladderAddStep") }}
|
||||
</OButton>
|
||||
<OText
|
||||
variant="meta"
|
||||
class="w-24 shrink-0 max-md:w-auto"
|
||||
data-test="oncall-ladder-ends-time"
|
||||
>
|
||||
{{ endTime }}
|
||||
</OText>
|
||||
<!-- How a page can leave this team, in the server's words, on hover of the ending it qualifies. -->
|
||||
<span class="text-text-secondary text-sm" data-test="oncall-ladder-ends-line">
|
||||
{{ endTitle }}
|
||||
<OTooltip
|
||||
v-if="preview.cross_team_moves.length"
|
||||
side="bottom"
|
||||
:content="raw(preview.cross_team_moves.join(' '))"
|
||||
/>
|
||||
</span>
|
||||
<OButton
|
||||
v-if="canConfigure"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon-left="add"
|
||||
data-test="oncall-ladder-add-rung"
|
||||
@click.stop="emit('edit')"
|
||||
>
|
||||
{{ t("oncall.ladderAddStep") }}
|
||||
</OButton>
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
@@ -258,7 +280,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<OIcon name="auto-awesome" size="sm" class="text-text-secondary" />
|
||||
<OText variant="meta">{{ l0Line }}</OText>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -273,8 +295,8 @@ import OAvatar from "@/lib/core/Avatar/OAvatar.vue";
|
||||
import OAvatarGroup from "@/lib/core/Avatar/OAvatarGroup.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OText from "@/lib/core/Typography/OText.vue";
|
||||
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
|
||||
import OnCallChannelIcons from "./OnCallChannelIcons.vue";
|
||||
import OnCallPersonName from "./OnCallPersonName.vue";
|
||||
import { useOrgUserNames } from "@/composables/useOrgUserNames";
|
||||
import OInnerLoading from "@/lib/feedback/InnerLoading/OInnerLoading.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import OnCallFitChips from "@/components/oncall/OnCallFitChips.vue";
|
||||
import i18n from "@/locales";
|
||||
import { raw } from "@/types/i18n";
|
||||
|
||||
const CHIP = 40;
|
||||
const GAP = 4;
|
||||
const MORE = 20;
|
||||
|
||||
/// jsdom has no layout, so each chip is given a fixed width laid out left to right.
|
||||
function layout(rowWidth: number) {
|
||||
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(function (
|
||||
this: HTMLElement,
|
||||
) {
|
||||
return this.dataset.test === "row" ? rowWidth : 0;
|
||||
});
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (
|
||||
this: HTMLElement,
|
||||
) {
|
||||
const chips = [...(this.parentElement?.querySelectorAll(":scope > [data-fit-chip]") ?? [])];
|
||||
const index = chips.indexOf(this);
|
||||
if (index >= 0) {
|
||||
const left = index * (CHIP + GAP);
|
||||
return { left, right: left + CHIP, width: CHIP } as DOMRect;
|
||||
}
|
||||
if (this.dataset.test === "oncall-fit-chips-more")
|
||||
return { left: 0, right: MORE, width: MORE } as DOMRect;
|
||||
return { left: 0, right: rowWidth, width: rowWidth } as DOMRect;
|
||||
});
|
||||
vi.spyOn(window, "getComputedStyle").mockReturnValue({
|
||||
columnGap: `${GAP}px`,
|
||||
} as CSSStyleDeclaration);
|
||||
}
|
||||
|
||||
function render(items: string[]) {
|
||||
return mount(OnCallFitChips, {
|
||||
props: { items, tooltip: raw(items.join(" · ")), dataTest: "row" },
|
||||
slots: { item: "<template #item='{ item }'><b>{{ item }}</b></template>" },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
OTag: { name: "OTag", template: "<span><slot /></span>" },
|
||||
OTooltip: { name: "OTooltip", props: ["content"], template: "<i />" },
|
||||
},
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
}
|
||||
|
||||
const visible = (wrapper: ReturnType<typeof render>) =>
|
||||
wrapper
|
||||
.findAll("[data-fit-chip]")
|
||||
.filter((chip) => (chip.element as HTMLElement).style.display !== "none")
|
||||
.map((chip) => chip.text());
|
||||
|
||||
describe("OnCallFitChips", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("shows every chip and no +N when they all fit on one line", async () => {
|
||||
layout(200);
|
||||
const wrapper = render(["a", "b", "c"]);
|
||||
await flushPromises();
|
||||
expect(visible(wrapper)).toEqual(["a", "b", "c"]);
|
||||
expect(
|
||||
(wrapper.find('[data-test="oncall-fit-chips-more"]').element as HTMLElement).style.display,
|
||||
).toBe("none");
|
||||
});
|
||||
|
||||
// Rows stay one line high: what does not fit collapses into "+N", whose tooltip names everything.
|
||||
it("keeps the chips that fit beside +N and names them all in its tooltip", async () => {
|
||||
layout(100);
|
||||
const wrapper = render(["a", "b", "c"]);
|
||||
await flushPromises();
|
||||
expect(visible(wrapper)).toEqual(["a"]);
|
||||
const more = wrapper.find('[data-test="oncall-fit-chips-more"]');
|
||||
expect(more.text()).toBe("+2");
|
||||
expect(more.findComponent({ name: "OTooltip" }).props("content")).toBe("a · b · c");
|
||||
});
|
||||
|
||||
it("always keeps one chip, so a cell is never only +N", async () => {
|
||||
layout(30);
|
||||
const wrapper = render(["a", "b"]);
|
||||
await flushPromises();
|
||||
expect(visible(wrapper)).toEqual(["a"]);
|
||||
expect(wrapper.find('[data-test="oncall-fit-chips-more"]').text()).toBe("+1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<!-- One row of chips that never wraps: the chips that fit, then "+N" whose tooltip names them all. -->
|
||||
<template>
|
||||
<span
|
||||
ref="rowRef"
|
||||
class="flex w-full max-w-full min-w-0 flex-nowrap items-center gap-1 overflow-hidden"
|
||||
:data-test="dataTest"
|
||||
>
|
||||
<span
|
||||
v-for="(item, index) in items"
|
||||
v-show="index < shown"
|
||||
:key="index"
|
||||
class="flex min-w-0 items-center"
|
||||
:class="index === shown - 1 && shown < items.length ? 'shrink overflow-hidden' : 'shrink-0'"
|
||||
data-fit-chip
|
||||
>
|
||||
<slot name="item" :item="item" />
|
||||
</span>
|
||||
<span
|
||||
v-show="shown < items.length"
|
||||
ref="moreRef"
|
||||
class="shrink-0"
|
||||
data-test="oncall-fit-chips-more"
|
||||
>
|
||||
<OTag variant="default-outline" size="sm">{{ raw(`+${items.length - shown}`) }}</OTag>
|
||||
<OTooltip side="bottom" :content="tooltip" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
|
||||
import OTag from "@/lib/core/Badge/OTag.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import type { I18nText } from "@/types/i18n";
|
||||
import { raw } from "@/types/i18n";
|
||||
|
||||
const props = withDefaults(defineProps<{ items: T[]; tooltip: I18nText; dataTest?: string }>(), {
|
||||
dataTest: undefined,
|
||||
});
|
||||
|
||||
defineSlots<{ item(props: { item: T }): unknown }>();
|
||||
|
||||
const rowRef = ref<HTMLElement | null>(null);
|
||||
const moreRef = ref<HTMLElement | null>(null);
|
||||
const shown = ref(props.items.length);
|
||||
|
||||
/// Shows every chip, then keeps as many as fit beside the "+N" chip; measured, because chip widths follow their text.
|
||||
async function fit() {
|
||||
const total = props.items.length;
|
||||
shown.value = total;
|
||||
await nextTick();
|
||||
const row = rowRef.value;
|
||||
if (!row || !row.clientWidth || !total) return;
|
||||
const start = row.getBoundingClientRect().left;
|
||||
const ends = [...row.querySelectorAll<HTMLElement>(":scope > [data-fit-chip]")].map(
|
||||
(el) => el.getBoundingClientRect().right - start,
|
||||
);
|
||||
if ((ends[total - 1] ?? 0) <= row.clientWidth) return;
|
||||
shown.value = total - 1;
|
||||
await nextTick();
|
||||
const gap = parseFloat(getComputedStyle(row).columnGap) || 0;
|
||||
const more = (moreRef.value?.getBoundingClientRect().width ?? 0) + gap;
|
||||
let fits = 0;
|
||||
while (fits < total && ends[fits] + more <= row.clientWidth) fits += 1;
|
||||
// One chip always shows, so a cell is never just "+N".
|
||||
shown.value = Math.max(1, fits);
|
||||
}
|
||||
|
||||
let observer: ResizeObserver | null = null;
|
||||
onMounted(() => {
|
||||
void fit();
|
||||
if (typeof ResizeObserver === "undefined" || !rowRef.value) return;
|
||||
let width = rowRef.value.clientWidth;
|
||||
observer = new ResizeObserver(() => {
|
||||
const next = rowRef.value?.clientWidth ?? 0;
|
||||
if (next === width) return;
|
||||
width = next;
|
||||
void fit();
|
||||
});
|
||||
observer.observe(rowRef.value);
|
||||
});
|
||||
onBeforeUnmount(() => observer?.disconnect());
|
||||
// By content, not identity: callers build the list fresh on every render.
|
||||
watch(
|
||||
() => JSON.stringify(props.items),
|
||||
() => void fit(),
|
||||
);
|
||||
</script>
|
||||
@@ -45,7 +45,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:class="index ? '-ms-1.5' : ''"
|
||||
class="ring-surface-base rounded-full ring-2"
|
||||
>
|
||||
<OAvatar :value="email" size="sm" />
|
||||
<OAvatar :value="email" :name="nameOf(email)" size="sm" />
|
||||
</span>
|
||||
</span>
|
||||
<OTruncatedText class="text-sm">{{ summary }}</OTruncatedText>
|
||||
@@ -63,6 +63,7 @@ import OSelect from "@/lib/forms/Select/OSelect.vue";
|
||||
import type { SelectModelValue, SelectOption } from "@/lib/forms/Select/OSelect.types";
|
||||
import type { I18nText } from "@/types/i18n";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
import { useOrgUserNames } from "@/composables/useOrgUserNames";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -91,10 +92,12 @@ const { t } = useI18nTyped();
|
||||
|
||||
const faces = computed(() => model.value.slice(0, 3));
|
||||
|
||||
/// The part of the address a person answers to. The picker's job here is
|
||||
/// recognition at a glance, and nobody scans a domain.
|
||||
const { nameOf } = useOrgUserNames();
|
||||
|
||||
/// The name a person answers to; the local part of the address when they have none, because nobody scans a domain.
|
||||
function shortName(email: string): string {
|
||||
return email.split("@")[0] ?? email;
|
||||
const name = nameOf(email);
|
||||
return name === email ? (email.split("@")[0] ?? email) : name;
|
||||
}
|
||||
|
||||
const summary = computed<I18nText>(() => {
|
||||
|
||||
@@ -124,7 +124,7 @@ const stubs = {
|
||||
name: "OSelect",
|
||||
props: ["modelValue", "options", "multiple"],
|
||||
template: `<select multiple @change="$emit('update:modelValue', pick($event))">
|
||||
<option v-for="o in options" :key="String(o.value)" :value="o.value" :disabled="o.disabled">{{ o.label }}</option>
|
||||
<option v-for="o in options" :key="String(o.value)" :value="o.value" :disabled="o.disabled">{{ o.label }}{{ o.subLabel ? " – " + o.subLabel : "" }}{{ o.badge ? " · " + o.badge : "" }}</option>
|
||||
</select>`,
|
||||
methods: {
|
||||
pick(e: any) {
|
||||
@@ -239,7 +239,8 @@ describe("OnCallMembers", () => {
|
||||
const picker = wrapper.find('[data-test="oncall-members-user-select"]');
|
||||
expect(picker.exists()).toBe(true);
|
||||
expect(wrapper.find('[data-test="oncall-members-email-input"]').exists()).toBe(false);
|
||||
expect(picker.text()).toContain("Ana Sharma (ana@o2.ai)");
|
||||
// Name to read, email to tell namesakes apart — one row, not "Name (email)" in one string.
|
||||
expect(picker.text()).toContain("Ana Sharma – ana@o2.ai");
|
||||
});
|
||||
|
||||
// A name is what a person recognises; the email disambiguates. A user with
|
||||
@@ -288,6 +289,25 @@ describe("OnCallMembers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Against the real OSelect: the picked people are listed once, below the field, not again as chips inside it.
|
||||
it("lists picked people once, below the picker, by name with their email", async () => {
|
||||
const wrapper = mount(OnCallMembers, {
|
||||
props: { teamId: "team_1", teamName: "Platform", members: [], canConfigure: true },
|
||||
global: { plugins: [i18n, store], stubs: { ...stubs, OSelect: false } },
|
||||
});
|
||||
await flushPromises();
|
||||
await openAdd(wrapper);
|
||||
wrapper.findComponent({ name: "OSelect" }).vm.$emit("update:modelValue", ["ana@o2.ai"]);
|
||||
await flushPromises();
|
||||
|
||||
const field = wrapper.find('[data-test="oncall-members-user-select"]');
|
||||
expect(field.text()).not.toContain("Ana Sharma");
|
||||
expect(field.text()).toContain("Select a team member to add");
|
||||
const staged = wrapper.find('[data-test="oncall-members-add-staged-ana@o2.ai"]');
|
||||
expect(staged.text()).toContain("Ana Sharma");
|
||||
expect(staged.text()).toContain("ana@o2.ai");
|
||||
});
|
||||
|
||||
it("names the button without a count until somebody is picked", async () => {
|
||||
const wrapper = render();
|
||||
await flushPromises();
|
||||
|
||||
@@ -223,7 +223,14 @@
|
||||
:options="userOptions"
|
||||
:loading="loadingUsers"
|
||||
data-test="oncall-members-user-select"
|
||||
/>
|
||||
>
|
||||
<!-- The picked people are listed once, below; chips here would say it twice. -->
|
||||
<template #trigger>
|
||||
<span class="text-select-placeholder flex-1 truncate text-start text-sm">
|
||||
{{ t("oncall.memberPickPlaceholder") }}
|
||||
</span>
|
||||
</template>
|
||||
</OSelect>
|
||||
<OInput
|
||||
v-else
|
||||
ref="memberPickerRef"
|
||||
@@ -245,7 +252,14 @@
|
||||
class="flex items-center justify-between gap-2"
|
||||
:data-test="`oncall-members-add-staged-${email}`"
|
||||
>
|
||||
<OUserCell :value="email" :name="nameOf(email)" />
|
||||
<span class="flex min-w-0 flex-col">
|
||||
<OTruncatedText class="text-text-heading text-sm">{{
|
||||
raw(nameOf(email))
|
||||
}}</OTruncatedText>
|
||||
<OTruncatedText v-if="nameOf(email) !== email" class="text-text-secondary text-xs">{{
|
||||
raw(email)
|
||||
}}</OTruncatedText>
|
||||
</span>
|
||||
<OButton
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
@@ -336,7 +350,6 @@ import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
|
||||
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import OText from "@/lib/core/Typography/OText.vue";
|
||||
import OUserCell from "@/lib/core/Table/cells/OUserCell.vue";
|
||||
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
|
||||
import type { I18nText } from "@/types/i18n";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
@@ -727,19 +740,30 @@ const loadingUsers = ref(false);
|
||||
// Losing the picker must not lose the ability to add anybody.
|
||||
const userLookupFailed = ref(false);
|
||||
|
||||
const memberEmails = computed(() => new Set(props.members.map((m) => m.user_email)));
|
||||
const memberEmails = computed(() => new Set(props.members.map((m) => m.user_email.toLowerCase())));
|
||||
|
||||
/** Every org user; those already on the team are listed but cannot be picked twice. */
|
||||
const userOptions = computed(() =>
|
||||
orgUsers.value.map((u) =>
|
||||
memberEmails.value.has(u.email.toLowerCase())
|
||||
orgUsers.value.map((u) => {
|
||||
const name = nameOf(u.email);
|
||||
const option = {
|
||||
label: raw(name),
|
||||
value: u.email,
|
||||
subLabel: name === u.email ? undefined : raw(u.email),
|
||||
subLabelInline: true,
|
||||
searchText: `${name} ${u.email}`.toLowerCase(),
|
||||
};
|
||||
// An inline email row has no room for a badge, so a row already on the team trades its email for "On team".
|
||||
return memberEmails.value.has(u.email.toLowerCase())
|
||||
? {
|
||||
label: t("oncall.membersAddOnTeam", { name: raw(displayName(u)) }),
|
||||
value: u.email,
|
||||
...option,
|
||||
subLabel: undefined,
|
||||
disabled: true,
|
||||
badge: t("oncall.membersOnTeamBadge"),
|
||||
badgeMuted: true,
|
||||
}
|
||||
: { label: raw(displayName(u)), value: u.email },
|
||||
),
|
||||
: option;
|
||||
}),
|
||||
);
|
||||
|
||||
/// Mirrors the server's `place_member`: simple rotations get the newcomer appended, layered ones need placing by hand.
|
||||
@@ -776,11 +800,6 @@ const pendingEmails = computed(() => {
|
||||
});
|
||||
|
||||
/** "Ana Sharma (ana@o2.ai)" when a name exists, the email otherwise. */
|
||||
function displayName(user: { email: string; first_name?: string; last_name?: string }): string {
|
||||
const name = [user.first_name, user.last_name].filter(Boolean).join(" ").trim();
|
||||
return name ? `${name} (${user.email})` : user.email;
|
||||
}
|
||||
|
||||
/** Just the name for the table — the email is already on the line below it. */
|
||||
function nameOf(email: string): string {
|
||||
const user = orgUsers.value.find((u) => u.email.toLowerCase() === email.toLowerCase());
|
||||
|
||||
@@ -37,18 +37,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<template #cell-match="{ row }">
|
||||
<span
|
||||
class="flex w-full max-w-full min-w-0 flex-wrap items-center gap-1 py-1"
|
||||
<!-- One line per row: the chips that fit, and "+N" naming every condition. -->
|
||||
<OnCallFitChips
|
||||
:items="conditionsOf(row)"
|
||||
:tooltip="conditionsTip(row)"
|
||||
:data-test="`oncall-routing-says-${row.rule_id}`"
|
||||
>
|
||||
<ODimensionChip
|
||||
v-for="condition in conditionsOf(row)"
|
||||
:key="condition.name"
|
||||
:dim-key="condition.name"
|
||||
:key-label="condition.label"
|
||||
:value="condition.value"
|
||||
/>
|
||||
</span>
|
||||
<template #item="{ item }">
|
||||
<ODimensionChip :dim-key="item.name" :key-label="item.label" :value="item.value" />
|
||||
</template>
|
||||
</OnCallFitChips>
|
||||
</template>
|
||||
|
||||
<template #cell-team="{ row }">
|
||||
@@ -58,16 +56,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<template #cell-specificity="{ row }">
|
||||
<span class="flex flex-wrap gap-1">
|
||||
<OTag
|
||||
v-for="condition in conditionsOf(row)"
|
||||
:key="condition.name"
|
||||
type="dimensionKey"
|
||||
:value="condition.name"
|
||||
:label="raw(condition.field)"
|
||||
size="sm"
|
||||
/>
|
||||
</span>
|
||||
<OnCallFitChips
|
||||
:items="conditionsOf(row)"
|
||||
:tooltip="fieldsTip(row)"
|
||||
:data-test="`oncall-routing-specificity-${row.rule_id}`"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<OTag type="dimensionKey" :value="item.name" :label="raw(item.field)" size="sm" />
|
||||
</template>
|
||||
</OnCallFitChips>
|
||||
</template>
|
||||
|
||||
<template #cell-caught="{ row }">
|
||||
@@ -237,6 +234,7 @@ import { computed } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
|
||||
import ODimensionChip from "@/lib/core/Badge/ODimensionChip.vue";
|
||||
import OnCallFitChips from "./OnCallFitChips.vue";
|
||||
import OTag from "@/lib/core/Badge/OTag.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
@@ -444,6 +442,23 @@ function conditionsOf(rule: OwnershipRuleStats): RuleCondition[] {
|
||||
});
|
||||
}
|
||||
|
||||
/// Every condition in one line, for the "+N" chip that stands in for the ones that did not fit.
|
||||
function conditionsTip(rule: OwnershipRuleStats): I18nText {
|
||||
return raw(
|
||||
conditionsOf(rule)
|
||||
.map((condition) => `${condition.label} = ${condition.value}`)
|
||||
.join(" · "),
|
||||
);
|
||||
}
|
||||
|
||||
function fieldsTip(rule: OwnershipRuleStats): I18nText {
|
||||
return raw(
|
||||
conditionsOf(rule)
|
||||
.map((condition) => condition.field)
|
||||
.join(" · "),
|
||||
);
|
||||
}
|
||||
|
||||
/// A shadowed rule names the team taking its pages, because "shadowed" alone does not say who to talk to.
|
||||
function healthLabel(rule: OwnershipRuleStats): I18nText {
|
||||
if (rule.health === "shadowed") {
|
||||
|
||||
@@ -16,8 +16,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<!-- A person by display name inside a sentence; the email rides in the tooltip because two people can share a name. -->
|
||||
<template>
|
||||
<span :data-test="dataTest">
|
||||
<OTooltip side="bottom" :content="tooltip" />
|
||||
<span :data-test="dataTest" :class="truncate ? 'block min-w-0 truncate' : ''">
|
||||
<OTooltip side="bottom" :content="tooltipOf(email)" />
|
||||
<span>{{ name }}</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -28,20 +28,14 @@ import { computed } from "vue";
|
||||
import { useOrgUserNames } from "@/composables/useOrgUserNames";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import type { I18nText } from "@/types/i18n";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
import { raw } from "@/types/i18n";
|
||||
|
||||
const props = withDefaults(defineProps<{ email: string; dataTest?: string }>(), {
|
||||
dataTest: "oncall-person-name",
|
||||
});
|
||||
const props = withDefaults(
|
||||
defineProps<{ email: string; dataTest?: string; truncate?: boolean }>(),
|
||||
{ dataTest: "oncall-person-name", truncate: false },
|
||||
);
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
const { nameOf } = useOrgUserNames();
|
||||
const { nameOf, tooltipOf } = useOrgUserNames();
|
||||
|
||||
const name = computed<I18nText>(() => raw(nameOf(props.email)));
|
||||
|
||||
const tooltip = computed<I18nText>(() =>
|
||||
String(name.value) === props.email
|
||||
? raw(props.email)
|
||||
: t("oncall.personNameWithEmail", { name: name.value, email: raw(props.email) }),
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -158,7 +158,7 @@ describe("OnCallPolicyEditor", () => {
|
||||
["oncall-policy-channel-1-webhook", "Chat / webhook"],
|
||||
]);
|
||||
expect(wrapper.find('[data-test="oncall-policy-channels-hint"]').text()).toBe(
|
||||
"Everyone paged at P1 gets the same set. Texts and calls go to people with a verified phone.",
|
||||
"Texts and calls reach only people with a verified phone.",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
inline-actions
|
||||
data-test="oncall-policy-sentence"
|
||||
>
|
||||
{{ ladderSentence }}
|
||||
<OTruncatedText class="min-w-0">{{ ladderSentence }}</OTruncatedText>
|
||||
</OBanner>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -409,8 +409,6 @@
|
||||
|
||||
<OTabPanel name="delivery" data-test="oncall-policy-delivery">
|
||||
<div class="flex flex-col gap-3 px-4 pt-3 pb-4">
|
||||
<OText variant="meta">{{ t("oncall.policyDeliveryHint") }}</OText>
|
||||
|
||||
<!-- Channels apply to everyone paged at this priority; the primary
|
||||
and the secondary are not treated differently. -->
|
||||
<div v-if="current" class="flex flex-col gap-2">
|
||||
@@ -430,11 +428,7 @@
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-2 ps-34 max-md:ps-0">
|
||||
<OText variant="meta" data-test="oncall-policy-channels-hint">
|
||||
{{
|
||||
t("oncall.policyChannelsHint", {
|
||||
priority: raw(priorityLabel(current.priority)),
|
||||
})
|
||||
}}
|
||||
{{ t("oncall.policyChannelsHintShort") }}
|
||||
</OText>
|
||||
<OText v-if="emailLocked" variant="meta" data-test="oncall-policy-email-locked">
|
||||
{{ t("oncall.policyEmailLockedBySlack") }}
|
||||
@@ -487,7 +481,7 @@
|
||||
>
|
||||
{{ t("oncall.policyReachesNobody") }}
|
||||
</OTag>
|
||||
<OText v-else variant="meta">{{ t("oncall.policyDestinationsHint") }}</OText>
|
||||
<OText v-else variant="meta">{{ t("oncall.policyDestinationsHintShort") }}</OText>
|
||||
</div>
|
||||
|
||||
<!-- C15/C16: WHERE the team is talked to, beside the policy list it
|
||||
@@ -495,9 +489,18 @@
|
||||
channel and pages still go to the old room" has to be answerable
|
||||
here, not by paging somebody to find out. -->
|
||||
<div class="flex flex-wrap items-center gap-2" data-test="oncall-team-channel">
|
||||
<OText variant="label" class="w-32 shrink-0">
|
||||
{{ t("oncall.teamChannelTitle") }}
|
||||
</OText>
|
||||
<span class="flex w-32 shrink-0 items-center gap-1">
|
||||
<OText variant="label">{{ t("oncall.teamChannelTitle") }}</OText>
|
||||
<!-- One post per firing, not a live room: the HTTP transport cannot edit what it already sent. -->
|
||||
<OIcon
|
||||
name="info-outline"
|
||||
size="sm"
|
||||
class="text-text-secondary cursor-help"
|
||||
data-test="oncall-team-channel-info"
|
||||
>
|
||||
<OTooltip side="right" :content="t('oncall.teamChannelHint')" />
|
||||
</OIcon>
|
||||
</span>
|
||||
<span class="w-72">
|
||||
<OSelect
|
||||
v-model="teamChannelDraft"
|
||||
@@ -532,10 +535,6 @@
|
||||
>
|
||||
{{ t("oncall.teamChannelUsePolicy") }}
|
||||
</OButton>
|
||||
<!-- One post per firing, not a live room: the only transport is an
|
||||
HTTP destination, which cannot edit what it already sent. Said
|
||||
here so nobody designs an expectation the engine cannot meet. -->
|
||||
<OText variant="meta">{{ t("oncall.teamChannelHint") }}</OText>
|
||||
<!-- Only the team's own empty list is a choice; an empty list inherited from the policy is the policy's. -->
|
||||
<span
|
||||
v-if="channelFromTeam && !teamChannelDraft.length"
|
||||
@@ -618,6 +617,7 @@ import { useOrgUserNames } from "@/composables/useOrgUserNames";
|
||||
import OText from "@/lib/core/Typography/OText.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
|
||||
import OTag from "@/lib/core/Badge/OTag.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
|
||||
|
||||
@@ -175,20 +175,26 @@ describe("OnCallRoutingSimulator", () => {
|
||||
{ email: "ana@o2.ai", first_name: "Ana" },
|
||||
{ email: "raj@o2.ai", first_name: "Raj" },
|
||||
]);
|
||||
const lands = (email: string) => ({
|
||||
user_email: email,
|
||||
reason: "",
|
||||
would_a_page_land: true,
|
||||
deliverable_channels: ["email"],
|
||||
});
|
||||
escalationPreview.mockResolvedValue({
|
||||
data: {
|
||||
channels: ["email"],
|
||||
rungs: [
|
||||
{ after_micros: 0, targets: [], recipients: [], resolves_to_nobody: false },
|
||||
{
|
||||
after_micros: 120_000_000,
|
||||
after_micros: 0,
|
||||
targets: [],
|
||||
recipients: [{ user_email: "ana@o2.ai" }],
|
||||
recipients: [lands("ana@o2.ai")],
|
||||
resolves_to_nobody: false,
|
||||
},
|
||||
{
|
||||
after_micros: 300_000_000,
|
||||
targets: [],
|
||||
recipients: [{ user_email: "ana@o2.ai" }, { user_email: "raj@o2.ai" }],
|
||||
recipients: [lands("ana@o2.ai"), lands("raj@o2.ai")],
|
||||
resolves_to_nobody: false,
|
||||
},
|
||||
],
|
||||
@@ -210,6 +216,79 @@ describe("OnCallRoutingSimulator", () => {
|
||||
expect(ana!.findComponent(OTooltip).props("content")).toBe("Ana (ana@o2.ai)");
|
||||
});
|
||||
|
||||
// The engine's current responder is one person; a first step paging Primary and Secondary wakes both.
|
||||
it("names everybody the first step pages as woken now, not only the current responder", async () => {
|
||||
vi.useRealTimers();
|
||||
queryClient.setQueryData(orgUsersQuery("default").queryKey, [
|
||||
{ email: "ana@o2.ai", first_name: "Ana" },
|
||||
{ email: "raj@o2.ai", first_name: "Raj" },
|
||||
{ email: "kim@o2.ai", first_name: "Kim" },
|
||||
]);
|
||||
const lands = (email: string) => ({
|
||||
user_email: email,
|
||||
reason: "",
|
||||
would_a_page_land: true,
|
||||
deliverable_channels: ["email", "sms"],
|
||||
});
|
||||
escalationPreview.mockResolvedValue({
|
||||
data: {
|
||||
channels: ["email", "sms"],
|
||||
rungs: [
|
||||
{
|
||||
after_micros: 0,
|
||||
targets: [],
|
||||
recipients: [lands("ana@o2.ai"), lands("raj@o2.ai")],
|
||||
resolves_to_nobody: false,
|
||||
},
|
||||
{
|
||||
after_micros: 300_000_000,
|
||||
targets: [],
|
||||
recipients: [lands("ana@o2.ai"), lands("raj@o2.ai"), lands("kim@o2.ai")],
|
||||
resolves_to_nobody: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const wrapper = render({ seed: { service: "payments-api" }, preview: preview() });
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
const responder = wrapper.find('[data-test="oncall-simulator-responder"]').text();
|
||||
expect(responder).toBe("wakes Ana, Raj now (Email, SMS)");
|
||||
const next = wrapper.find('[data-test="oncall-simulator-next"]').text();
|
||||
expect(next).toBe("then Kim after 5m");
|
||||
});
|
||||
|
||||
it("says why when nobody on the first step can receive a page", async () => {
|
||||
vi.useRealTimers();
|
||||
escalationPreview.mockResolvedValue({
|
||||
data: {
|
||||
channels: ["email"],
|
||||
rungs: [
|
||||
{
|
||||
after_micros: 0,
|
||||
targets: [],
|
||||
recipients: [
|
||||
{
|
||||
user_email: "ana@o2.ai",
|
||||
reason: "",
|
||||
would_a_page_land: false,
|
||||
why_not: "no verified channel",
|
||||
deliverable_channels: [],
|
||||
},
|
||||
],
|
||||
resolves_to_nobody: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const wrapper = render({ seed: { service: "payments-api" }, preview: preview() });
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
const responder = wrapper.find('[data-test="oncall-simulator-responder"]');
|
||||
expect(responder.text()).toBe("no verified channel");
|
||||
expect(responder.classes()).toContain("text-status-error-text");
|
||||
});
|
||||
|
||||
it("says a silent priority pages nobody rather than naming the responder", async () => {
|
||||
const wrapper = render({ seed: { service: "payments-api" }, preview: preview() });
|
||||
select(wrapper, "oncall-simulator-priority").vm.$emit("update:modelValue", "P4");
|
||||
|
||||
@@ -70,12 +70,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:aria-busy="loading"
|
||||
data-test="oncall-simulator-result"
|
||||
>
|
||||
<span class="flex min-w-0 items-baseline gap-1 text-sm" data-test="oncall-simulator-answer">
|
||||
<span class="text-text-heading shrink-0 font-semibold" data-test="oncall-simulator-team">
|
||||
<!-- Wraps rather than cuts: the names are the answer, and a tooltip would hide the one being asked for. -->
|
||||
<p class="text-sm" data-test="oncall-simulator-answer">
|
||||
<span class="text-text-heading font-semibold" data-test="oncall-simulator-team">
|
||||
{{ t("oncall.simulatorAnswerTeam", { team: teamLabel }) }}
|
||||
</span>
|
||||
<OTruncatedText v-if="preview.team_id" class="text-text-body flex-1">
|
||||
<span>{{ raw("· ") }}</span>
|
||||
<span v-if="preview.team_id" class="text-text-body">
|
||||
<span>{{ raw(" · ") }}</span>
|
||||
<i18n-t
|
||||
v-if="wakes"
|
||||
keypath="oncall.simulatorWakesNow"
|
||||
@@ -85,10 +86,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
data-test="oncall-simulator-responder"
|
||||
>
|
||||
<template #who>
|
||||
<OnCallPersonName
|
||||
:email="wakes.email"
|
||||
data-test="oncall-simulator-responder-person"
|
||||
/>
|
||||
<template v-for="(email, index) in wakes.emails" :key="email">
|
||||
<span v-if="index">{{ raw(", ") }}</span>
|
||||
<OnCallPersonName :email="email" data-test="oncall-simulator-responder-person" />
|
||||
</template>
|
||||
</template>
|
||||
<template #channels>{{ wakes.channels }}</template>
|
||||
</i18n-t>
|
||||
@@ -112,8 +113,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<template #after>{{ nextStep.after }}</template>
|
||||
</i18n-t>
|
||||
</template>
|
||||
</OTruncatedText>
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<OCollapsible :label="t('oncall.simulatorWhy')" data-test="oncall-simulator-why">
|
||||
<div class="flex flex-col gap-1 pt-1">
|
||||
@@ -194,7 +195,6 @@ import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OCollapsible from "@/lib/core/Collapsible/OCollapsible.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OText from "@/lib/core/Typography/OText.vue";
|
||||
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
|
||||
import OSelect from "@/lib/forms/Select/OSelect.vue";
|
||||
import type { SelectOption } from "@/lib/forms/Select/OSelect.types";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
@@ -428,31 +428,6 @@ const sentChannels = computed<Channel[]>(() => {
|
||||
return person.deliverable_channels.filter((channel) => entry.channels.includes(channel));
|
||||
});
|
||||
|
||||
/// The person and channels when a page would land, so the name renders with its email tooltip.
|
||||
const wakes = computed<{ email: string; channels: string } | null>(() => {
|
||||
if (ladderEntry.value && !ladderEntry.value.pages_anyone) return null;
|
||||
const person = responder.value;
|
||||
if (!person?.would_a_page_land || !sentChannels.value.length) return null;
|
||||
const channels = sentChannels.value
|
||||
.map((channel) => String(t(`oncall.channel_${channel}`)))
|
||||
.join(", ");
|
||||
return { email: person.user_email, channels };
|
||||
});
|
||||
|
||||
const responderNote = computed<I18nText>(() => {
|
||||
if (ladderEntry.value && !ladderEntry.value.pages_anyone) return t("oncall.reachPagesNobody");
|
||||
const person = responder.value;
|
||||
if (!person) return t("oncall.simulatorNobodyOnCall");
|
||||
if (!person.would_a_page_land) return raw(person.why_not) || t("oncall.contactNoChannel");
|
||||
return t("oncall.simulatorSendsNothing");
|
||||
});
|
||||
|
||||
const responderTone = computed(() => {
|
||||
if (ladderEntry.value && !ladderEntry.value.pages_anyone) return "text-text-secondary";
|
||||
if (!responder.value?.would_a_page_land) return "text-status-error-text";
|
||||
return sentChannels.value.length ? "text-text-body" : "text-status-warning-text";
|
||||
});
|
||||
|
||||
const orgId = computed<string>(() => store.state.selectedOrganization?.identifier ?? "");
|
||||
|
||||
const escalation = useQuery(() =>
|
||||
@@ -466,13 +441,83 @@ const escalation = useQuery(() =>
|
||||
),
|
||||
);
|
||||
|
||||
/// The next step that wakes somebody new; hidden when the reader may not see the ladder.
|
||||
/// The ladder's first step, which pages every target on it at once; null until the ladder has loaded.
|
||||
const firstStep = computed<PreviewRung | null>(() => {
|
||||
if (escalation.isError.value) return null;
|
||||
const rungs = [...(escalation.data.value?.rungs ?? [])].sort(
|
||||
(a, b) => a.after_micros - b.after_micros,
|
||||
);
|
||||
return rungs[0] ?? null;
|
||||
});
|
||||
|
||||
/// Everybody the first step reaches and what they are sent, so the names render with their email tooltips.
|
||||
const wakes = computed<{ emails: string[]; channels: string } | null>(() => {
|
||||
if (ladderEntry.value && !ladderEntry.value.pages_anyone) return null;
|
||||
const step = firstStep.value;
|
||||
if (!step) return wakesFromResponder();
|
||||
const landing = step.recipients.filter((one) => one.would_a_page_land);
|
||||
const ladderChannels = escalation.data.value?.channels ?? ladderEntry.value?.channels ?? [];
|
||||
const sent = ladderChannels.filter((channel) =>
|
||||
landing.some((one) => one.deliverable_channels.includes(channel)),
|
||||
);
|
||||
if (!landing.length || !sent.length) return null;
|
||||
return {
|
||||
emails: [...new Set(landing.map((one) => one.user_email))],
|
||||
channels: sent.map((channel) => String(t(`oncall.channel_${channel}`))).join(", "),
|
||||
};
|
||||
});
|
||||
|
||||
/// Before the ladder loads, or when the reader may not see it, the engine's current responder is all there is.
|
||||
function wakesFromResponder(): { emails: string[]; channels: string } | null {
|
||||
const person = responder.value;
|
||||
if (!person?.would_a_page_land || !sentChannels.value.length) return null;
|
||||
const channels = sentChannels.value
|
||||
.map((channel) => String(t(`oncall.channel_${channel}`)))
|
||||
.join(", ");
|
||||
return { emails: [person.user_email], channels };
|
||||
}
|
||||
|
||||
const responderNote = computed<I18nText>(() => {
|
||||
if (ladderEntry.value && !ladderEntry.value.pages_anyone) return t("oncall.reachPagesNobody");
|
||||
const step = firstStep.value;
|
||||
if (step) {
|
||||
if (!step.recipients.length) return t("oncall.simulatorNobodyOnCall");
|
||||
const blocked = step.recipients.find((one) => !one.would_a_page_land);
|
||||
if (!step.recipients.some((one) => one.would_a_page_land)) {
|
||||
return raw(blocked?.why_not) || t("oncall.contactNoChannel");
|
||||
}
|
||||
return t("oncall.simulatorSendsNothing");
|
||||
}
|
||||
const person = responder.value;
|
||||
if (!person) return t("oncall.simulatorNobodyOnCall");
|
||||
if (!person.would_a_page_land) return raw(person.why_not) || t("oncall.contactNoChannel");
|
||||
return t("oncall.simulatorSendsNothing");
|
||||
});
|
||||
|
||||
const responderTone = computed(() => {
|
||||
if (ladderEntry.value && !ladderEntry.value.pages_anyone) return "text-text-secondary";
|
||||
if (firstStep.value) {
|
||||
if (wakes.value) return "text-text-body";
|
||||
return firstStep.value.recipients.some((one) => one.would_a_page_land)
|
||||
? "text-status-warning-text"
|
||||
: "text-status-error-text";
|
||||
}
|
||||
if (!responder.value?.would_a_page_land) return "text-status-error-text";
|
||||
return sentChannels.value.length ? "text-text-body" : "text-status-warning-text";
|
||||
});
|
||||
|
||||
/// The next step that wakes somebody the first step did not; hidden when the reader may not see the ladder.
|
||||
const nextStep = computed<{ emails: string[]; after: string } | null>(() => {
|
||||
if (escalation.isError.value) return null;
|
||||
const woken = responder.value?.user_email.toLowerCase();
|
||||
const rungs = [...(escalation.data.value?.rungs ?? [])].sort(
|
||||
(a, b) => a.after_micros - b.after_micros,
|
||||
);
|
||||
const first = rungs[0];
|
||||
if (!first) return null;
|
||||
const woken = new Set(first.recipients.map((one) => one.user_email.toLowerCase()));
|
||||
const fresh = (rung: PreviewRung) =>
|
||||
rung.recipients.map((one) => one.user_email).filter((email) => email.toLowerCase() !== woken);
|
||||
const step = (escalation.data.value?.rungs ?? []).slice(1).find((rung) => fresh(rung).length);
|
||||
rung.recipients.map((one) => one.user_email).filter((email) => !woken.has(email.toLowerCase()));
|
||||
const step = rungs.slice(1).find((rung) => fresh(rung).length);
|
||||
if (!step) return null;
|
||||
return {
|
||||
emails: [...new Set(fresh(step))],
|
||||
|
||||
@@ -30,7 +30,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
class="text-text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-sm" :title="member">{{ raw(nameOf(member)) }}</span>
|
||||
<OnCallPersonName
|
||||
:email="member"
|
||||
class="text-sm"
|
||||
:data-test="`oncall-rule-people-name-${ruleIndex}-${position}`"
|
||||
/>
|
||||
<OTag
|
||||
v-if="position === 0 && holder"
|
||||
:variant="holder.now ? 'success-soft' : 'default-soft'"
|
||||
@@ -96,7 +100,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:label="t('oncall.memberOrderDrag')"
|
||||
/>
|
||||
<span class="text-text-secondary min-w-4 text-xs tabular-nums">{{ position + 1 }}</span>
|
||||
<OUserCell :value="member" />
|
||||
<OnCallPersonName
|
||||
truncate
|
||||
:email="member"
|
||||
class="text-text-body text-sm"
|
||||
:data-test="`oncall-rule-person-name-${ruleIndex}-${position}`"
|
||||
/>
|
||||
<OTag
|
||||
v-if="holder?.email === member"
|
||||
:variant="holder.now ? 'success-soft' : 'default-soft'"
|
||||
@@ -111,7 +120,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
variant="ghost"
|
||||
size="icon-xs-sq"
|
||||
class="ms-auto"
|
||||
:aria-label="t('oncall.peopleMenu', { name: raw(member) })"
|
||||
:aria-label="t('oncall.peopleMenu', { name: raw(nameOf(member)) })"
|
||||
:data-test="`oncall-rule-person-menu-${ruleIndex}-${position}`"
|
||||
/>
|
||||
</template>
|
||||
@@ -182,7 +191,7 @@ import { VueDraggableNext } from "vue-draggable-next";
|
||||
import OTag from "@/lib/core/Badge/OTag.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OUserCell from "@/lib/core/Table/cells/OUserCell.vue";
|
||||
import OnCallPersonName from "./OnCallPersonName.vue";
|
||||
import OText from "@/lib/core/Typography/OText.vue";
|
||||
import OSelect from "@/lib/forms/Select/OSelect.vue";
|
||||
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
|
||||
@@ -220,7 +229,15 @@ const hidden = computed(() => Math.max(0, line.value.length - LINE_NAMES));
|
||||
const addable = computed(() =>
|
||||
props.teamMembers
|
||||
.filter((email) => !props.rule.members.includes(email))
|
||||
.map((email) => ({ label: raw(email), value: email })),
|
||||
.map((email) => {
|
||||
const name = props.nameOf(email);
|
||||
return {
|
||||
label: raw(name),
|
||||
value: email,
|
||||
subLabel: name === email ? undefined : raw(email),
|
||||
searchText: `${name} ${email}`.toLowerCase(),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Same rule as the server's save: whoever is on call now stays on call, newcomers go last.
|
||||
|
||||
@@ -55,7 +55,9 @@
|
||||
<template #label>
|
||||
<span class="flex min-w-0 flex-col gap-0.5">
|
||||
<OText variant="body-strong">{{ raw(preset.name) }}</OText>
|
||||
<OText variant="meta">{{ raw(preset.description) }}</OText>
|
||||
<OTruncatedText class="text-text-secondary text-xs">{{
|
||||
raw(preset.description)
|
||||
}}</OTruncatedText>
|
||||
</span>
|
||||
</template>
|
||||
</ORadio>
|
||||
@@ -349,6 +351,8 @@ import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
|
||||
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
|
||||
import OText from "@/lib/core/Typography/OText.vue";
|
||||
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
|
||||
import { useOrgUserNames } from "@/composables/useOrgUserNames";
|
||||
import ORadio from "@/lib/forms/Radio/ORadio.vue";
|
||||
import ORadioGroup from "@/lib/forms/Radio/ORadioGroup.vue";
|
||||
import useBreakpoint from "@/composables/useBreakpoint";
|
||||
@@ -422,8 +426,10 @@ const overridden = ref<Record<string, boolean>>({});
|
||||
/// Kept as plain wire values so `apply` is a spread, not a translation.
|
||||
const model = ref<Record<string, unknown>>({});
|
||||
|
||||
const { optionOf } = useOrgUserNames();
|
||||
|
||||
const memberOptions = computed<SelectOption[]>(() =>
|
||||
props.members.map((m) => ({ label: raw(m.user_email), value: m.user_email })),
|
||||
props.members.map((m) => optionOf(m.user_email, true)),
|
||||
);
|
||||
|
||||
// ── The catalogue ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -173,6 +173,43 @@ describe("OnCallScheduleTimeline", () => {
|
||||
expect(add.attributes("data-icon")).toBe("add");
|
||||
});
|
||||
|
||||
// A template rewrites the first rotation, so it can only be the way to build one while there is none.
|
||||
it("offers a template or scratch from Add rotation only while the team has no rotations", async () => {
|
||||
const empty = render({ rotations: [], segments: [] });
|
||||
expect(empty.find('[data-test="oncall-timeline-add"]').text()).toBe("Add rotation");
|
||||
await empty.find('[data-test="oncall-timeline-add-template"]').trigger("click");
|
||||
expect(empty.emitted("presets")).toHaveLength(1);
|
||||
await empty.find('[data-test="oncall-timeline-add-scratch"]').trigger("click");
|
||||
expect(empty.emitted("add")).toHaveLength(1);
|
||||
|
||||
const staffed = render();
|
||||
expect(staffed.find('[data-test="oncall-timeline-add-template"]').exists()).toBe(false);
|
||||
await staffed.find('[data-test="oncall-timeline-add"]').trigger("click");
|
||||
expect(staffed.emitted("add")).toHaveLength(1);
|
||||
});
|
||||
|
||||
// The band only has room for a name; two people can share one, so the email rides the tooltip.
|
||||
it("gives a shift band a tooltip with the person's name, email and window", () => {
|
||||
const wrapper = mount(OnCallScheduleTimeline, {
|
||||
props: {
|
||||
rotations: [rotation("Primary")],
|
||||
segments: [seg()],
|
||||
timezone: "UTC",
|
||||
window: { from: 0, to: 0 },
|
||||
"onUpdate:window": () => {},
|
||||
} as any,
|
||||
global: {
|
||||
plugins: [i18n, store],
|
||||
stubs: { ...stubs, OTooltip: { name: "OTooltip", props: ["content"], template: "<i />" } },
|
||||
},
|
||||
});
|
||||
const band = tracksOf(wrapper)[0].bands[0];
|
||||
const tip = wrapper
|
||||
.findAllComponents({ name: "OTooltip" })
|
||||
.find((one: any) => one.attributes("data-test") === `oncall-shift-tip-${band.key}`);
|
||||
expect(String(tip!.props("content"))).toMatch(/^ana@o2\.ai · .+ – .+/);
|
||||
});
|
||||
|
||||
it("draws one lane per rotation", () => {
|
||||
const wrapper = render({
|
||||
rotations: [rotation("Primary"), rotation("Secondary")],
|
||||
|
||||
@@ -97,7 +97,36 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</OToggleGroupItem>
|
||||
</OToggleGroup>
|
||||
|
||||
<!-- A template rewrites the first rotation, so it can only build a new one while there is none. -->
|
||||
<ODropdown v-if="!rotations.length" align="end">
|
||||
<template #trigger>
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="sm-action"
|
||||
icon-left="add"
|
||||
icon-right="arrow-drop-down"
|
||||
data-test="oncall-timeline-add"
|
||||
>
|
||||
{{ t("oncall.addRotation") }}
|
||||
</OButton>
|
||||
</template>
|
||||
<ODropdownItem
|
||||
icon-left="dashboard-customize"
|
||||
data-test="oncall-timeline-add-template"
|
||||
@select="emit('presets')"
|
||||
>
|
||||
{{ t("oncall.addRotationFromTemplate") }}
|
||||
</ODropdownItem>
|
||||
<ODropdownItem
|
||||
icon-left="edit"
|
||||
data-test="oncall-timeline-add-scratch"
|
||||
@select="emit('add')"
|
||||
>
|
||||
{{ t("oncall.addRotationFromScratch") }}
|
||||
</ODropdownItem>
|
||||
</ODropdown>
|
||||
<OButton
|
||||
v-else
|
||||
variant="outline"
|
||||
size="sm-action"
|
||||
icon-left="add"
|
||||
@@ -256,10 +285,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- The tooltip follows the band, so it anchors to the band itself, not to the menu around it. -->
|
||||
<template #band="{ band }">
|
||||
<ODropdown v-if="shiftActions(band.key).length" align="start">
|
||||
<template #trigger>
|
||||
<OScheduleBand :band="band" role="button" aria-haspopup="menu" class="cursor-pointer" />
|
||||
<OScheduleBand
|
||||
:band="band"
|
||||
:tooltip="bandTip(band.key) ? false : undefined"
|
||||
role="button"
|
||||
aria-haspopup="menu"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
</template>
|
||||
<ODropdownItem
|
||||
v-for="action in shiftActions(band.key)"
|
||||
@@ -271,7 +307,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
{{ action.label }}
|
||||
</ODropdownItem>
|
||||
</ODropdown>
|
||||
<OScheduleBand v-else :band="band" />
|
||||
<OScheduleBand v-else :band="band" :tooltip="bandTip(band.key) ? false : undefined" />
|
||||
<OTooltip
|
||||
v-if="bandTip(band.key)"
|
||||
side="top"
|
||||
:content="bandTip(band.key)"
|
||||
:data-test="`oncall-shift-tip-${band.key}`"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Three entries, and every one of them means something. The old key
|
||||
@@ -432,7 +474,7 @@ const { isMobile } = useBreakpoint();
|
||||
const nowMicros = useOnCallClock();
|
||||
|
||||
// Band labels only: a narrow bar has no room for an email; the aria-label keeps the full address.
|
||||
const { nameOf } = useOrgUserNames();
|
||||
const { nameOf, tooltipOf } = useOrgUserNames();
|
||||
|
||||
/// The lane dots, spelled out rather than built from the index: Tailwind reads
|
||||
/// class names as literal text, so a template-built `bg-schedule-band-${n}-...`
|
||||
@@ -778,6 +820,29 @@ const segmentByBand = computed(() => {
|
||||
return map;
|
||||
});
|
||||
|
||||
const tipRange = (start: number, end: number) =>
|
||||
t("oncall.shiftBandTipRange", {
|
||||
from: raw(fmt(start, { weekday: "short", hour: "2-digit", minute: "2-digit" })),
|
||||
to: raw(fmt(end, { weekday: "short", hour: "2-digit", minute: "2-digit" })),
|
||||
});
|
||||
|
||||
/// Name and email for a band's person, because the band itself only has room for a name and two people can share one.
|
||||
function bandTip(bandKey: string): I18nText | undefined {
|
||||
const segment = segmentByBand.value.get(bandKey);
|
||||
if (segment?.user_email) {
|
||||
return t("oncall.shiftBandTip", {
|
||||
person: tooltipOf(segment.user_email),
|
||||
range: tipRange(segment.from, segment.to),
|
||||
});
|
||||
}
|
||||
const absence = props.absences.find((one) => `away-${one.id}` === bandKey);
|
||||
if (!absence) return undefined;
|
||||
return t("oncall.shiftBandTip", {
|
||||
person: tooltipOf(absence.user_email),
|
||||
range: tipRange(absence.start_at, absence.end_at),
|
||||
});
|
||||
}
|
||||
|
||||
interface ShiftMenuItem {
|
||||
id: ShiftAction;
|
||||
icon: IconName;
|
||||
|
||||
@@ -61,13 +61,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:key="clash.id"
|
||||
class="text-status-warning-text text-xs"
|
||||
>
|
||||
{{
|
||||
t("oncall.rotationMemberAway", {
|
||||
who: raw(clash.user_email),
|
||||
from: raw(shortDate(clash.start_at)),
|
||||
to: raw(shortDate(clash.end_at)),
|
||||
})
|
||||
}}
|
||||
<i18n-t keypath="oncall.rotationMemberAway" tag="span" scope="global">
|
||||
<template #who><OnCallPersonName :email="clash.user_email" /></template>
|
||||
<template #from>{{ raw(shortDate(clash.start_at)) }}</template>
|
||||
<template #to>{{ raw(shortDate(clash.end_at)) }}</template>
|
||||
</i18n-t>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -245,6 +243,7 @@ import {
|
||||
formatMinuteOfDay,
|
||||
} from "@/utils/oncall";
|
||||
import OnCallRulePeople from "./OnCallRulePeople.vue";
|
||||
import OnCallPersonName from "./OnCallPersonName.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
rule: ShiftRule;
|
||||
|
||||
@@ -17,11 +17,22 @@ import { computed } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
import { orgUsersQuery } from "@/services/users.queries";
|
||||
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
|
||||
|
||||
/** A person as a picker row: the name to read, the email to tell two namesakes apart. */
|
||||
export type PersonOption = {
|
||||
label: I18nText;
|
||||
value: string;
|
||||
subLabel?: I18nText;
|
||||
subLabelInline?: boolean;
|
||||
searchText: string;
|
||||
};
|
||||
|
||||
/** A person's display name from the org's cached user list, or their email until it loads or when they have none. */
|
||||
export function useOrgUserNames() {
|
||||
const store = useStore();
|
||||
const orgId = computed<string>(() => store.state.selectedOrganization.identifier);
|
||||
// Optional chaining: components using this render inside specs that mount no store.
|
||||
const orgId = computed<string>(() => store?.state?.selectedOrganization?.identifier ?? "");
|
||||
const users = useQuery(() =>
|
||||
Object.assign(orgUsersQuery(orgId.value), { enabled: !!orgId.value }),
|
||||
);
|
||||
@@ -34,5 +45,27 @@ export function useOrgUserNames() {
|
||||
return name || email;
|
||||
}
|
||||
|
||||
return { nameOf };
|
||||
const { t } = useI18nTyped();
|
||||
|
||||
/** Name and email in one line, for a tooltip; just the email when there is no name. */
|
||||
function tooltipOf(email: string): I18nText {
|
||||
const name = nameOf(email);
|
||||
return name === email
|
||||
? raw(email)
|
||||
: t("oncall.personNameWithEmail", { name: raw(name), email: raw(email) });
|
||||
}
|
||||
|
||||
/** `inline` for multi-selects, whose stacked rows push the checkbox onto a line of its own. */
|
||||
function optionOf(email: string, inline = false): PersonOption {
|
||||
const name = nameOf(email);
|
||||
return {
|
||||
label: raw(name),
|
||||
value: email,
|
||||
subLabel: name === email ? undefined : raw(email),
|
||||
subLabelInline: inline,
|
||||
searchText: `${name} ${email}`.toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
return { nameOf, tooltipOf, optionOf };
|
||||
}
|
||||
|
||||
@@ -287,8 +287,8 @@ export const BADGE_GROUPS = {
|
||||
shape: "pill",
|
||||
values: {
|
||||
covered: { variant: "success-soft", labelKey: "oncall.coverage_covered" },
|
||||
gap: { variant: "warning-soft", labelKey: "oncall.coverage_gap" },
|
||||
unreachable: { variant: "error-soft", labelKey: "oncall.coverage_unreachable" },
|
||||
gap: { variant: "error-soft", labelKey: "oncall.coverage_gap" },
|
||||
unreachable: { variant: "warning-soft", labelKey: "oncall.coverage_cantReach" },
|
||||
},
|
||||
fallback: { variant: "default-soft" },
|
||||
},
|
||||
|
||||
@@ -120,7 +120,7 @@ const surfaceClasses = computed(() => [
|
||||
v-if="$slots.label || props.label"
|
||||
:class="[
|
||||
'o-input-label text-compact leading-tight select-none',
|
||||
props.variant === 'card' ? 'flex-1' : '',
|
||||
props.variant === 'card' ? 'min-w-0 flex-1' : '',
|
||||
props.disabled
|
||||
? 'text-input-label-text-disabled font-normal'
|
||||
: 'text-input-label-text font-medium',
|
||||
|
||||
@@ -25826,6 +25826,15 @@
|
||||
"coverage_covered": "Covered",
|
||||
"coverage_gap": "Nobody on call",
|
||||
"coverage_unreachable": "Rostered, unreachable",
|
||||
"coverage_cantReach": "On call but can't be reached",
|
||||
"coverageCantReachTip": "{count} of {total} on call can't be reached",
|
||||
"shiftBandTip": "{person} · {range}",
|
||||
"shiftBandTipRange": "{from} – {to}",
|
||||
"policyChannelsHintShort": "Texts and calls reach only people with a verified phone.",
|
||||
"policyDestinationsHintShort": "Any alert destination with a URL — Slack, Teams, PagerDuty.",
|
||||
"addRotationFromTemplate": "From a template",
|
||||
"addRotationFromScratch": "From scratch",
|
||||
"membersOnTeamBadge": "On team",
|
||||
"eventKind_sys": "System",
|
||||
"eventKind_page": "Page",
|
||||
"eventKind_ack": "Ack",
|
||||
|
||||
@@ -279,26 +279,41 @@ describe("OnCallTeamDetail", () => {
|
||||
} as any);
|
||||
});
|
||||
|
||||
const DialogStub = {
|
||||
name: "ODialog",
|
||||
props: ["open", "title"],
|
||||
template: "<div v-if='open'><slot /></div>",
|
||||
};
|
||||
|
||||
async function ask() {
|
||||
const wrapper = render();
|
||||
const wrapper = mount(OnCallTeamDetail, {
|
||||
global: { plugins: [i18n, store], stubs: { ...stubs, ODialog: DialogStub } },
|
||||
});
|
||||
await flushPromises();
|
||||
wrapper.findComponent({ name: "OTabPanels" }).vm.$emit("update:modelValue", "policy");
|
||||
await flushPromises();
|
||||
wrapper.findComponent({ name: "OnCallEscalationLadder" }).vm.$emit("send-test");
|
||||
await flushPromises();
|
||||
return wrapper
|
||||
.findAllComponents({ name: "ConfirmDialog" })
|
||||
const confirm = wrapper
|
||||
.findAllComponents({ name: "ODialog" })
|
||||
.find((c: any) => c.props("title") === "Send a test page?")!;
|
||||
return { wrapper, confirm };
|
||||
}
|
||||
|
||||
// The first step by delay, and only the channels its people can receive, which is what the server sends.
|
||||
it("names the recipients and channels before sending anything", async () => {
|
||||
const confirm = await ask();
|
||||
const { wrapper, confirm } = await ask();
|
||||
|
||||
expect(confirm.props("modelValue")).toBe(true);
|
||||
expect(confirm.props("message")).toBe(
|
||||
expect(confirm.props("open")).toBe(true);
|
||||
expect(wrapper.find('[data-test="oncall-team-test-page-message"]').text()).toBe(
|
||||
"Send a test page for P1 to ana@corp.com, bob@corp.com? Email, SMS. It does not escalate.",
|
||||
);
|
||||
// Each name carries its email in a tooltip, because two people can share a name.
|
||||
const people = wrapper.findAllComponents({ name: "OnCallPersonName" });
|
||||
expect(people.map((one: any) => one.props("email"))).toEqual([
|
||||
"ana@corp.com",
|
||||
"bob@corp.com",
|
||||
]);
|
||||
expect(service.testPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -313,8 +328,8 @@ describe("OnCallTeamDetail", () => {
|
||||
],
|
||||
},
|
||||
} as any);
|
||||
const confirm = await ask();
|
||||
confirm.vm.$emit("update:ok");
|
||||
const { confirm } = await ask();
|
||||
confirm.vm.$emit("click:primary");
|
||||
await flushPromises();
|
||||
|
||||
expect(service.testPage).toHaveBeenCalledWith(expect.objectContaining({ priority: 1 }));
|
||||
@@ -371,7 +386,62 @@ describe("OnCallTeamDetail", () => {
|
||||
const wrapper = render();
|
||||
await flushPromises();
|
||||
|
||||
expect(chip(wrapper)).toContain("Rostered, unreachable");
|
||||
expect(chip(wrapper)).toContain("On call but can't be reached");
|
||||
});
|
||||
|
||||
// Green only when every holder can be paged, so the chip agrees with the banner naming the one who cannot.
|
||||
it("is not green while one of two people on call cannot be reached", async () => {
|
||||
service.whoIsOnCall.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
rotation_id: "rot_primary",
|
||||
rotation_name: "Primary",
|
||||
rule: "Base",
|
||||
user_email: "ana@corp.com",
|
||||
},
|
||||
{
|
||||
rotation_id: "rot_secondary",
|
||||
rotation_name: "Secondary",
|
||||
rule: "Base",
|
||||
user_email: "bob@corp.com",
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
service.teamReachability.mockResolvedValue({
|
||||
data: {
|
||||
team_id: "team_1",
|
||||
smtp_configured: true,
|
||||
members: [
|
||||
{
|
||||
user_email: "ana@corp.com",
|
||||
would_a_page_land: false,
|
||||
channels: [],
|
||||
deliverable_channels: [],
|
||||
},
|
||||
{
|
||||
user_email: "bob@corp.com",
|
||||
would_a_page_land: true,
|
||||
channels: [],
|
||||
deliverable_channels: ["email"],
|
||||
},
|
||||
],
|
||||
reachable: 1,
|
||||
total: 2,
|
||||
unreachable_members: ["ana@corp.com"],
|
||||
},
|
||||
} as any);
|
||||
const wrapper = render();
|
||||
await flushPromises();
|
||||
|
||||
expect(chip(wrapper)).toContain("On call but can't be reached");
|
||||
const tag = wrapper
|
||||
.findAllComponents({ name: "OTag" })
|
||||
.find((one: any) => one.props("type") === "oncallCoverage")!;
|
||||
expect(tag.props("value")).toBe("unreachable");
|
||||
const tip = wrapper
|
||||
.findAllComponents({ name: "OTooltip" })
|
||||
.find((one: any) => String(one.props("content")).includes("can't be reached"));
|
||||
expect(tip!.props("content")).toBe("1 of 2 on call can't be reached");
|
||||
});
|
||||
|
||||
it("stays covered while the holder can actually be paged", async () => {
|
||||
|
||||
@@ -22,13 +22,15 @@
|
||||
<!-- Whether a page would reach anybody is the team's headline fact, so it
|
||||
rides the title instead of sitting in a card below it. -->
|
||||
<template #title-trail>
|
||||
<OTag
|
||||
v-if="loaded"
|
||||
type="oncallCoverage"
|
||||
:value="coverageState"
|
||||
size="sm"
|
||||
data-test="oncall-team-coverage"
|
||||
/>
|
||||
<span v-if="loaded" class="inline-flex">
|
||||
<OTag
|
||||
type="oncallCoverage"
|
||||
:value="coverageState"
|
||||
size="sm"
|
||||
data-test="oncall-team-coverage"
|
||||
/>
|
||||
<OTooltip v-if="coverageTip" side="bottom" :content="coverageTip" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
@@ -175,7 +177,6 @@
|
||||
<OnCallTeamAttention
|
||||
:risks="configRisks"
|
||||
:timezone="team?.timezone ?? 'UTC'"
|
||||
:viewer-timezone="store.state.timezone"
|
||||
:reachability="reachability"
|
||||
:overview="overview"
|
||||
:checked-at="insightsCheckedAt"
|
||||
@@ -218,7 +219,6 @@
|
||||
:rotations="schedule?.rotations ?? []"
|
||||
:unreachable="unreachableEmails"
|
||||
:timezone="team?.timezone ?? 'UTC'"
|
||||
:viewer-timezone="store.state.timezone"
|
||||
:days="COVERAGE_DAYS"
|
||||
/>
|
||||
</div>
|
||||
@@ -247,7 +247,6 @@
|
||||
:members="members"
|
||||
:rotations="schedule?.rotations ?? []"
|
||||
:timezone="team?.timezone ?? 'UTC'"
|
||||
:viewer-timezone="store.state.timezone"
|
||||
:on-call-now="onCallNow"
|
||||
:reachability="reachability"
|
||||
:load="teamLoad"
|
||||
@@ -279,7 +278,6 @@
|
||||
:rotations="teamRotations"
|
||||
:segments="segments"
|
||||
:timezone="team?.timezone ?? 'UTC'"
|
||||
:viewer-timezone="store.state.timezone"
|
||||
:loading="segmentsLoading"
|
||||
:can-cover="hasMembers !== false && canConfigure"
|
||||
:absences="teamAbsences"
|
||||
@@ -312,7 +310,6 @@
|
||||
:team-id="teamId"
|
||||
:members="members"
|
||||
:timezone="team?.timezone ?? 'UTC'"
|
||||
:viewer-timezone="store.state.timezone"
|
||||
:primary-rotation-name="schedule?.rotations?.[0]?.name ?? ''"
|
||||
@applied="onScheduleSaved"
|
||||
/>
|
||||
@@ -324,7 +321,6 @@
|
||||
drawer-only
|
||||
:team-id="teamId"
|
||||
:timezone="team?.timezone ?? 'UTC'"
|
||||
:viewer-timezone="store.state.timezone"
|
||||
:schedule="schedule"
|
||||
:members="members"
|
||||
:intent="scheduleIntent"
|
||||
@@ -401,14 +397,35 @@
|
||||
/>
|
||||
|
||||
<!-- Named from the loaded preview: the first step can be the whole team, so this is the last look at who gets woken. -->
|
||||
<ConfirmDialog
|
||||
:model-value="testPageOpen"
|
||||
<ODialog
|
||||
v-model:open="testPageOpen"
|
||||
size="sm"
|
||||
:title="t('oncall.testPageConfirmTitle')"
|
||||
:message="testPageMessage"
|
||||
:ok-label="t('oncall.ladderSendTest')"
|
||||
@update:ok="sendTestPage"
|
||||
@update:cancel="testPageOpen = false"
|
||||
/>
|
||||
:primary-button-label="t('oncall.ladderSendTest')"
|
||||
:secondary-button-label="t('oncall.cancel')"
|
||||
data-test="oncall-team-test-page-dialog"
|
||||
@click:primary="sendTestPage"
|
||||
@click:secondary="testPageOpen = false"
|
||||
>
|
||||
<i18n-t
|
||||
:keypath="
|
||||
testPageCopy.channels ? 'oncall.testPageConfirm' : 'oncall.testPageConfirmNoChannel'
|
||||
"
|
||||
tag="p"
|
||||
scope="global"
|
||||
class="text-sm"
|
||||
data-test="oncall-team-test-page-message"
|
||||
>
|
||||
<template #priority>{{ raw(selectedPriority) }}</template>
|
||||
<template #names>
|
||||
<template v-for="(email, index) in testPageCopy.emails" :key="email">
|
||||
<span v-if="index">{{ raw(", ") }}</span>
|
||||
<OnCallPersonName :email="email" data-test="oncall-team-test-page-person" />
|
||||
</template>
|
||||
</template>
|
||||
<template #channels>{{ testPageCopy.channels }}</template>
|
||||
</i18n-t>
|
||||
</ODialog>
|
||||
|
||||
<OnCallCoverForm
|
||||
v-model:open="coverOpen"
|
||||
@@ -451,7 +468,8 @@ import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
|
||||
import OTabPanels from "@/lib/navigation/Tabs/OTabPanels.vue";
|
||||
import OTabPanel from "@/lib/navigation/Tabs/OTabPanel.vue";
|
||||
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
|
||||
import OnCallPersonName from "@/components/oncall/OnCallPersonName.vue";
|
||||
import OnCallMembers from "@/components/oncall/OnCallMembers.vue";
|
||||
import OnCallOwnership from "@/components/oncall/OnCallOwnership.vue";
|
||||
import OnCallPolicyEditor from "@/components/oncall/OnCallPolicyEditor.vue";
|
||||
@@ -537,7 +555,7 @@ import type {
|
||||
Unavailability,
|
||||
} from "@/ts/interfaces/oncall";
|
||||
import { MICROS_PER_DAY } from "@/ts/interfaces/oncall";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
|
||||
import {
|
||||
channelList,
|
||||
formatInZone,
|
||||
@@ -971,31 +989,32 @@ const silentPriorities = computed(
|
||||
).length,
|
||||
);
|
||||
|
||||
/// Rostered and reachable are two questions, and the chip used to answer only
|
||||
/// the first — a green `Covered` sat beside a panel reading "no page can be
|
||||
/// delivered to anyone", which tells the reader the team is fine at the exact
|
||||
/// moment it is not. Reachability is folded in: if everybody holding the pager
|
||||
/// right now would fail to receive a page, the team is rostered, not covered.
|
||||
///
|
||||
/// Silent when reachability did not load — an unanswered question is not a
|
||||
/// finding, and inventing one here would be the same lie in the other
|
||||
/// direction.
|
||||
const coverageState = computed<"covered" | "gap" | "unreachable">(() => {
|
||||
const covered = overview.value?.covered_now ?? onCallNow.value.length > 0;
|
||||
if (!covered) return "gap";
|
||||
|
||||
/// Green only when every person on call now would receive a page, so the chip never disagrees with the banner naming one who would not.
|
||||
const unreachableHolders = computed(() => {
|
||||
const verdicts = reachability.value?.members ?? [];
|
||||
const holders = onCallNow.value
|
||||
.map((position) => position.user_email?.toLowerCase())
|
||||
.filter((email): email is string => !!email);
|
||||
const holders = [
|
||||
...new Set(
|
||||
onCallNow.value
|
||||
.map((position) => position.user_email?.toLowerCase())
|
||||
.filter((email): email is string => !!email),
|
||||
),
|
||||
];
|
||||
const known = holders
|
||||
.map((email) => verdicts.find((member) => member.user_email.toLowerCase() === email))
|
||||
.filter((member) => !!member);
|
||||
return { total: holders.length, unreachable: known.filter((m) => !m.would_a_page_land).length };
|
||||
});
|
||||
|
||||
// Only when the server actually judged the people on call, and judged every
|
||||
// one of them unreachable: one reachable holder means a page still lands.
|
||||
if (known.length && known.every((member) => !member.would_a_page_land)) return "unreachable";
|
||||
return "covered";
|
||||
const coverageState = computed<"covered" | "gap" | "unreachable">(() => {
|
||||
const covered = overview.value?.covered_now ?? onCallNow.value.length > 0;
|
||||
if (!covered) return "gap";
|
||||
return unreachableHolders.value.unreachable ? "unreachable" : "covered";
|
||||
});
|
||||
|
||||
const coverageTip = computed<I18nText | undefined>(() => {
|
||||
if (coverageState.value !== "unreachable") return undefined;
|
||||
const { unreachable, total } = unreachableHolders.value;
|
||||
return t("oncall.coverageCantReachTip", { count: unreachable, total });
|
||||
});
|
||||
|
||||
/// Who the strip must not count as cover: the server judged a page to them would not land.
|
||||
@@ -1006,7 +1025,7 @@ const unreachableEmails = computed(() =>
|
||||
);
|
||||
|
||||
/// The server sends a test to the first step only, so the first step's people are who it names.
|
||||
const testPageMessage = computed(() => {
|
||||
const testPageCopy = computed<{ emails: string[]; channels: I18nText | "" }>(() => {
|
||||
const first = [...(preview.value?.rungs ?? [])].sort(
|
||||
(a, b) => a.after_micros - b.after_micros,
|
||||
)[0];
|
||||
@@ -1015,13 +1034,10 @@ const testPageMessage = computed(() => {
|
||||
const sent = (preview.value?.channels ?? []).filter((channel) =>
|
||||
people.some((one) => (one.deliverable_channels ?? []).includes(channel)),
|
||||
);
|
||||
const params = {
|
||||
priority: raw(selectedPriority.value),
|
||||
names: raw(people.map((one) => one.user_email).join(", ")),
|
||||
return {
|
||||
emails: [...new Set(people.map((one) => one.user_email))],
|
||||
channels: sent.length ? raw(channelList(sent, t)) : "",
|
||||
};
|
||||
return sent.length
|
||||
? t("oncall.testPageConfirm", { ...params, channels: raw(channelList(sent, t)) })
|
||||
: t("oncall.testPageConfirmNoChannel", params);
|
||||
});
|
||||
|
||||
/// The one honest answer to "would a page actually land": send a real one and
|
||||
|
||||
@@ -22,6 +22,7 @@ import i18n from "@/locales";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
import oncallService from "@/services/oncall";
|
||||
import { userKeys } from "@/services/users.querykeys";
|
||||
import { orgUsersQuery } from "@/services/users.queries";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
import OnCallTeams from "@/views/OnCall/OnCallTeams.vue";
|
||||
import {
|
||||
@@ -73,7 +74,16 @@ const stubs = {
|
||||
// draws rather than a column-def function OTable never calls.
|
||||
OTable: {
|
||||
name: "OTable",
|
||||
props: ["data", "columns", "error", "currentPage", "pageSize", "sortBy", "sortOrder"],
|
||||
props: [
|
||||
"data",
|
||||
"columns",
|
||||
"error",
|
||||
"currentPage",
|
||||
"pageSize",
|
||||
"sortBy",
|
||||
"sortOrder",
|
||||
"persistColumns",
|
||||
],
|
||||
emits: ["update:currentPage", "update:pageSize", "update:sortBy", "update:sortOrder"],
|
||||
template: `<div>
|
||||
<slot name='toolbar' />
|
||||
@@ -110,7 +120,7 @@ const stubs = {
|
||||
ODropdown: { name: "ODropdown", template: "<div><slot name='trigger' /><slot /></div>" },
|
||||
ODropdownItem: {
|
||||
name: "ODropdownItem",
|
||||
props: ["iconLeft", "variant", "disabled", "description"],
|
||||
props: ["iconLeft", "variant", "disabled", "description", "focusableUnavailable"],
|
||||
emits: ["select"],
|
||||
template: `<button :data-icon="iconLeft" @click="$emit('select')"><slot /></button>`,
|
||||
},
|
||||
@@ -198,6 +208,31 @@ describe("OnCallTeams", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// A create action reads as a button with its icon, like every other On-Call header action.
|
||||
it("gives New team its add icon", async () => {
|
||||
service.listTeams.mockResolvedValue({ data: [] } as any);
|
||||
const wrapper = render();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-test="oncall-teams-add-btn"]').attributes("icon-left")).toBe("add");
|
||||
});
|
||||
|
||||
// Search, columns and refresh have nothing to act on until a team exists.
|
||||
it("shows no search or refresh while the org has no teams", async () => {
|
||||
service.listTeams.mockResolvedValue({ data: [] } as any);
|
||||
const wrapper = render();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-test="oncall-teams-search"]').exists()).toBe(false);
|
||||
expect(wrapper.findComponent({ name: "OTable" }).props("persistColumns")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps search once there is a team to find", async () => {
|
||||
service.listTeams.mockResolvedValue({ data: [team("team_1", "Platform")] } as any);
|
||||
const wrapper = render();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-test="oncall-teams-search"]').exists()).toBe(true);
|
||||
expect(wrapper.findComponent({ name: "OTable" }).props("persistColumns")).toBe(true);
|
||||
});
|
||||
|
||||
it("shows who is on call for each team", async () => {
|
||||
service.listTeams.mockResolvedValue({ data: [team("team_1", "Platform")] } as any);
|
||||
service.whoIsOnCall.mockResolvedValue({
|
||||
@@ -207,11 +242,33 @@ describe("OnCallTeams", () => {
|
||||
const wrapper = render();
|
||||
await flushPromises();
|
||||
|
||||
expect(onCallCell(wrapper).findComponent({ name: "OUserCell" }).props("value")).toBe(
|
||||
expect(onCallCell(wrapper).findComponent({ name: "OnCallPersonName" }).props("email")).toBe(
|
||||
"engineer@example.com",
|
||||
);
|
||||
});
|
||||
|
||||
// Names are what a responder recognises; the email rides in the tooltip because two people can share a name.
|
||||
it("names the people on call and keeps their email in a tooltip", async () => {
|
||||
queryClient.setQueryData(orgUsersQuery("default").queryKey, [
|
||||
{ email: "engineer@example.com", first_name: "Ena", last_name: "Gineer" },
|
||||
]);
|
||||
service.listTeams.mockResolvedValue({ data: [team("team_1", "Platform")] } as any);
|
||||
service.whoIsOnCall.mockResolvedValue({
|
||||
data: [{ rotation_id: "r1", rotation_name: "Primary", user_email: "engineer@example.com" }],
|
||||
} as any);
|
||||
|
||||
const wrapper = render();
|
||||
await flushPromises();
|
||||
|
||||
const person = wrapper.find('[data-test="oncall-teams-primary-team_1"]');
|
||||
expect(person.text()).toContain("Ena Gineer");
|
||||
expect(person.findComponent({ name: "OTooltip" }).props("content")).toBe(
|
||||
"Ena Gineer (engineer@example.com)",
|
||||
);
|
||||
expect(person.attributes("title")).toBeUndefined();
|
||||
queryClient.removeQueries({ queryKey: orgUsersQuery("default").queryKey });
|
||||
});
|
||||
|
||||
/// A team nobody staffs will page no one, so it earns the one colour on this
|
||||
/// page rather than an empty cell.
|
||||
it("flags a team with nobody on call", async () => {
|
||||
@@ -268,8 +325,8 @@ describe("OnCallTeams", () => {
|
||||
await flushPromises();
|
||||
|
||||
const emails = wrapper
|
||||
.findAllComponents({ name: "OUserCell" })
|
||||
.map((cell: any) => cell.props("value"));
|
||||
.findAllComponents({ name: "OnCallPersonName" })
|
||||
.map((cell: any) => cell.props("email"));
|
||||
expect(emails).toEqual(["primary@example.com", "dba@example.com"]);
|
||||
// It keeps its own name rather than being filed under "Secondary".
|
||||
expect(wrapper.find('[data-test="oncall-teams-rotation-team_1-rot_db"]').exists()).toBe(true);
|
||||
@@ -561,8 +618,14 @@ describe("OnCallTeams", () => {
|
||||
items.find((item) => item.attributes("data-test") === `oncall-team-delete-${id}`);
|
||||
// Still listed, so every row's menu reads the same, but off and saying why.
|
||||
expect(deleteOf("team_2")?.props("disabled")).toBe(true);
|
||||
expect(deleteOf("team_2")?.props("description")).toBe("Make another team the default first");
|
||||
// One line per item: the reason rides a tooltip, not a second line under the label.
|
||||
expect(deleteOf("team_2")?.props("description")).toBeUndefined();
|
||||
expect(deleteOf("team_2")?.findComponent({ name: "OTooltip" }).props("content")).toBe(
|
||||
"Make another team the default first",
|
||||
);
|
||||
expect(deleteOf("team_2")?.props("focusableUnavailable")).toBe(true);
|
||||
expect(deleteOf("team_1")?.props("disabled")).toBeFalsy();
|
||||
expect(deleteOf("team_1")?.findComponent({ name: "OTooltip" }).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("offers to set as default on every team but the current one", async () => {
|
||||
|
||||
@@ -27,6 +27,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
v-if="canConfigure"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon-left="add"
|
||||
data-test="oncall-teams-add-btn"
|
||||
@click="openCreate"
|
||||
>
|
||||
@@ -45,7 +46,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
pagination="client"
|
||||
show-index
|
||||
table-id="oncall-teams-list"
|
||||
:persist-columns="true"
|
||||
:persist-columns="showToolbar"
|
||||
:show-global-filter="false"
|
||||
:enable-column-resize="true"
|
||||
data-test="oncall-teams-table"
|
||||
@@ -59,7 +60,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
>
|
||||
<template #toolbar>
|
||||
<!-- Search, columns and refresh have nothing to act on until a team exists. -->
|
||||
<template v-if="showToolbar" #toolbar>
|
||||
<div class="flex w-full items-center gap-2 max-md:contents">
|
||||
<OSearchInput
|
||||
v-model="search"
|
||||
@@ -71,7 +73,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #toolbar-trailing>
|
||||
<template v-if="showToolbar" #toolbar-trailing>
|
||||
<ORefreshButton
|
||||
layout="inline"
|
||||
variant="outline"
|
||||
@@ -122,7 +124,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
size="sm"
|
||||
:data-test="`oncall-teams-primary-gap-${row.id}`"
|
||||
/>
|
||||
<OUserCell v-else :value="positionsByTeam[row.id]!.first!.user_email" />
|
||||
<OnCallPersonName
|
||||
v-else
|
||||
truncate
|
||||
:email="positionsByTeam[row.id]!.first!.user_email"
|
||||
:data-test="`oncall-teams-primary-${row.id}`"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #cell-secondary="{ row }">
|
||||
@@ -140,7 +147,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:key="position.rotation_id"
|
||||
class="flex flex-wrap items-center gap-1.5"
|
||||
>
|
||||
<OUserCell :value="position.user_email" />
|
||||
<OnCallPersonName
|
||||
truncate
|
||||
:email="position.user_email"
|
||||
:data-test="`oncall-teams-secondary-${row.id}-${position.rotation_id}`"
|
||||
/>
|
||||
<!-- The header already says "secondary". Only a rotation the header
|
||||
does not name has to announce itself. -->
|
||||
<OTag
|
||||
@@ -203,13 +214,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
icon-left="delete-outline"
|
||||
variant="destructive"
|
||||
:disabled="row.id === defaultTeamId"
|
||||
:description="
|
||||
row.id === defaultTeamId ? t('oncall.deleteDefaultTeamBlocked') : undefined
|
||||
"
|
||||
:focusable-unavailable="row.id === defaultTeamId"
|
||||
:data-test="`oncall-team-delete-${row.id}`"
|
||||
@select="teamToDelete = row"
|
||||
>
|
||||
<span>{{ t("oncall.deleteTeam") }}</span>
|
||||
<span class="flex-1">
|
||||
{{ t("oncall.deleteTeam") }}
|
||||
<OTooltip
|
||||
v-if="row.id === defaultTeamId"
|
||||
side="right"
|
||||
:content="t('oncall.deleteDefaultTeamBlocked')"
|
||||
/>
|
||||
</span>
|
||||
</ODropdownItem>
|
||||
</ODropdown>
|
||||
</div>
|
||||
@@ -290,7 +306,8 @@ import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import OnCallTeamForm from "@/components/oncall/OnCallTeamForm.vue";
|
||||
import OTag from "@/lib/core/Badge/OTag.vue";
|
||||
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
|
||||
import OUserCell from "@/lib/core/Table/cells/OUserCell.vue";
|
||||
import OnCallPersonName from "@/components/oncall/OnCallPersonName.vue";
|
||||
import { useOrgUserNames } from "@/composables/useOrgUserNames";
|
||||
import { COL, type OTableColumnDef } from "@/lib/core/Table/OTable.types";
|
||||
import { useConfirmDialog } from "@/composables/useConfirmDialog";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
@@ -320,6 +337,7 @@ const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { canConfigure, noteConfigurationDenied } = useOnCallPermissions();
|
||||
const { nameOf } = useOrgUserNames();
|
||||
const { confirm } = useConfirmDialog();
|
||||
const { config: routingConfig, load: loadRoutingConfig } = useOnCallRoutingConfig();
|
||||
|
||||
@@ -473,6 +491,8 @@ const columns = computed<OTableColumnDef<OnCallTeam>[]>(() => {
|
||||
];
|
||||
});
|
||||
|
||||
const showToolbar = computed(() => loading.value || teams.value.length > 0 || !!loadError.value);
|
||||
|
||||
const filteredTeams = computed(() => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
if (!q) return teams.value;
|
||||
@@ -484,7 +504,10 @@ const filteredTeams = computed(() => {
|
||||
// "Which team would page Priya right now" is a question this list can
|
||||
// answer in one keystroke; without the holders it costs a drill-in per
|
||||
// team, which is the same search done by hand.
|
||||
...(onCallByTeam.value[team.id] ?? []).map((slot) => slot.user_email),
|
||||
...(onCallByTeam.value[team.id] ?? []).flatMap((slot) => [
|
||||
slot.user_email,
|
||||
nameOf(slot.user_email),
|
||||
]),
|
||||
].some((field) => field.toLowerCase().includes(q)),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user