Skip to content
This repository was archived by the owner on Sep 3, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/dispatch/static/dispatch/src/incident/EditSheet.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
</template>
<v-tabs color="primary" fixed-tabs v-model="tab" :disabled="id == null">
<v-tab value="details"> Details </v-tab>
<v-tab value="reports"> Reports </v-tab>
<v-tab value="resources"> Resources </v-tab>
<v-tab value="participants"> Participants </v-tab>
<v-tab value="timeline"> Timeline </v-tab>
Expand All @@ -46,6 +47,9 @@
<v-window-item value="details">
<incident-details-tab />
</v-window-item>
<v-window-item value="reports">
<incident-timeline-report-tab />
</v-window-item>
<v-window-item value="resources">
<incident-resources-tab />
</v-window-item>
Expand Down Expand Up @@ -81,6 +85,7 @@ import IncidentCostsTab from "@/incident/CostsTab.vue"
import IncidentDetailsTab from "@/incident/DetailsTab.vue"
import IncidentParticipantsTab from "@/incident/ParticipantsTab.vue"
import IncidentResourcesTab from "@/incident/ResourcesTab.vue"
import IncidentTimelineReportTab from "@/incident/TimelineReportTab.vue"
import IncidentTasksTab from "@/incident/TasksTab.vue"
import IncidentTimelineTab from "@/incident/TimelineTab.vue"
import WorkflowInstanceTab from "@/workflow/WorkflowInstanceTab.vue"
Expand All @@ -94,6 +99,7 @@ export default {
IncidentDetailsTab,
IncidentParticipantsTab,
IncidentResourcesTab,
IncidentTimelineReportTab,
IncidentTasksTab,
IncidentTimelineTab,
WorkflowInstanceTab,
Expand Down
73 changes: 73 additions & 0 deletions src/dispatch/static/dispatch/src/incident/TimelineReportTab.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<template>
<v-container>
<v-timeline density="compact" clipped>
<v-timeline-item hide-dot>
<v-row>
<v-col class="text-right text-caption">(times in UTC)</v-col>
</v-row>
</v-timeline-item>
<v-timeline-item
v-for="event in tacticalReports"
:key="event.id"
:icon="'mdi-text-box-check'"
class="mb-4"
dot-color="blue"
>
<template #icon>
<v-icon color="white" />
</template>
<v-row justify="space-between">
<v-col cols="7">
{{ event.description }}
<v-card v-if="event.details">
<v-card-title class="text-subtitle-1">Conditions</v-card-title>
<v-card-text>{{ event.details.conditions }}</v-card-text>
<v-card-title class="text-subtitle-1">Actions</v-card-title>
<v-card-text>{{ event.details.actions }}</v-card-text>
<v-card-title class="text-subtitle-1">Needs</v-card-title>
<v-card-text>{{ event.details.needs }}</v-card-text>
</v-card>
<div class="text-caption">
{{ event.source }}
</div>
</v-col>
<v-col class="text-right" cols="4">
<v-tooltip location="bottom">
<template #activator="{ props }">
<span v-bind="props" class="wavy-underline">{{
formatToUTC(event.started_at)
}}</span>
</template>
<span class="pre-formatted">{{ formatToTimeZones(event.started_at) }}</span>
</v-tooltip>
</v-col>
</v-row>
</v-timeline-item>
</v-timeline>
</v-container>
</template>

<script>
import { mapFields } from "vuex-map-fields"
import { formatToUTC, formatToTimeZones } from "@/filters"

export default {
name: "IncidentTimelineReportTab",

setup() {
return { formatToUTC, formatToTimeZones }
},

computed: {
...mapFields("incident", ["selected.events"]),

tacticalReports() {
return this.events
.filter((event) => event.description.includes("created a new tactical report"))
.sort((a, b) => new Date(a.started_at) - new Date(b.started_at))
},
},
}
</script>

<style scoped src="@/styles/timeline.css" />
163 changes: 163 additions & 0 deletions src/dispatch/static/dispatch/src/tests/TimelineReportTab.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { mount, flushPromises } from "@vue/test-utils"
import { expect, test, vi, beforeEach } from "vitest"
import { createVuetify } from "vuetify"
import * as components from "vuetify/components"
import * as directives from "vuetify/directives"
import { createStore } from "vuex"
import { getField } from "vuex-map-fields"
import TimelineReportTab from "@/incident/TimelineReportTab.vue"

// Mock the filters
vi.mock("@/filters", () => ({
formatToUTC: vi.fn((date) => date),
formatToTimeZones: vi.fn((date) => date),
}))

const vuetify = createVuetify({
components,
directives,
})

global.ResizeObserver = require("resize-observer-polyfill")

// Mock data
const mockEvents = [
{
id: 1,
started_at: "2023-01-01T10:00:00Z",
source: "Incident Participant",
description: "John Doe created a new tactical report",
details: {
conditions: "Current conditions",
actions: "Planned actions",
needs: "Resource needs",
},
},
{
id: 2,
started_at: "2023-01-01T11:00:00Z",
source: "Incident Participant",
description: "Jane Smith created a new tactical report",
details: {
conditions: "Updated conditions",
actions: "New actions",
needs: "Additional needs",
},
},
{
id: 3,
started_at: "2023-01-01T12:00:00Z",
source: "Other Source",
description: "This is not a tactical report",
details: {},
},
]

// Create a proper Vuex store mock
function createMockStore(events = mockEvents) {
return createStore({
modules: {
incident: {
namespaced: true,
state: {
selected: {
events: events,
},
},
getters: {
getField, // This is required for vuex-map-fields to work
},
},
},
})
}

// Helper function to create wrapper
function createWrapper(events = mockEvents) {
const store = createMockStore(events)
return mount(TimelineReportTab, {
global: {
plugins: [vuetify, store],
},
})
}

beforeEach(() => {
vi.clearAllMocks()
})

test("mounts correctly and displays tactical reports", async () => {
const wrapper = createWrapper()
await flushPromises()

const timelineItems = wrapper.findAllComponents({ name: "v-timeline-item" })
expect(timelineItems.length).toBe(3) // 2 tactical reports + 1 header item
})

test("filters out non-tactical report events", async () => {
const wrapper = createWrapper()
await flushPromises()

const descriptions = wrapper.findAll(".v-col").map((col) => col.text())
expect(descriptions.filter((d) => d.includes("tactical report")).length).toBe(2)
expect(descriptions.filter((d) => d.includes("not a tactical report")).length).toBe(0)
})

test("displays event details correctly", async () => {
const wrapper = createWrapper()
await flushPromises()

const cards = wrapper.findAllComponents({ name: "v-card" })
const firstCard = cards[0]

expect(firstCard.text()).toContain("Current conditions")
expect(firstCard.text()).toContain("Planned actions")
expect(firstCard.text()).toContain("Resource needs")
})

test("sorts events chronologically", async () => {
const wrapper = createWrapper([...mockEvents].reverse())
await flushPromises()

const descriptions = wrapper.findAll(".v-col").map((col) => col.text())
const tacticalReports = descriptions.filter((d) => d.includes("tactical report"))

expect(tacticalReports[0]).toContain("John Doe")
expect(tacticalReports[1]).toContain("Jane Smith")
})

test("displays UTC time notice", async () => {
const wrapper = createWrapper()
await flushPromises()

const utcNotice = wrapper.find(".text-caption")
expect(utcNotice.text()).toContain("UTC")
})

test("handles empty events array", async () => {
const wrapper = createWrapper([])
await flushPromises()

const timelineItems = wrapper.findAllComponents({ name: "v-timeline-item" })
expect(timelineItems.length).toBe(1) // Just the header item
})

test("displays correct icon for tactical reports", async () => {
const wrapper = createWrapper()
await flushPromises()

const timelineItems = wrapper.findAllComponents({ name: "v-timeline-item" })
// Subtract 1 for the header item which doesn't have an icon
const itemsWithIcons = timelineItems.length - 1

expect(itemsWithIcons).toBe(2) // Should have 2 tactical reports with icons

const icons = wrapper.findAll(".v-icon")
// Filter out any utility icons (like those in tooltips)
const tacticalReportIcons = icons.filter((icon) => {
const classes = icon.classes()
return classes.includes("mdi-text-box-check")
})

expect(tacticalReportIcons.length).toBeGreaterThan(0)
})
Loading