-
Notifications
You must be signed in to change notification settings - Fork 1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #17090 from davelopez/enhance_notification_broadca…
…sts_admin_panel Enhance Notification Broadcasts Admin Panel
- Loading branch information
Showing
4 changed files
with
411 additions
and
177 deletions.
There are no files selected for viewing
206 changes: 206 additions & 0 deletions
206
client/src/components/admin/Notifications/BroadcastCard.vue
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,206 @@ | ||
<script setup lang="ts"> | ||
import { library } from "@fortawesome/fontawesome-svg-core"; | ||
import { | ||
faBroadcastTower, | ||
faClock, | ||
faEdit, | ||
faHourglassHalf, | ||
faInfoCircle, | ||
faTrash, | ||
} from "@fortawesome/free-solid-svg-icons"; | ||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; | ||
import { BButton, BCol, BInputGroup, BRow } from "bootstrap-vue"; | ||
import { computed } from "vue"; | ||
import { useConfirmDialog } from "@/composables/confirmDialog"; | ||
import { useMarkdown } from "@/composables/markdown"; | ||
import { BroadcastNotification } from "@/stores/broadcastsStore"; | ||
import Heading from "@/components/Common/Heading.vue"; | ||
import UtcDate from "@/components/UtcDate.vue"; | ||
library.add(faBroadcastTower, faClock, faEdit, faHourglassHalf, faInfoCircle, faTrash); | ||
const { confirm } = useConfirmDialog(); | ||
const { renderMarkdown } = useMarkdown({ openLinksInNewPage: true }); | ||
interface Props { | ||
broadcastNotification: BroadcastNotification; | ||
} | ||
const props = defineProps<Props>(); | ||
const emit = defineEmits<{ | ||
(e: "edit", broadcastNotification: BroadcastNotification): void; | ||
(e: "expire", broadcastNotification: BroadcastNotification): void; | ||
(e: "go-to-link", link: string): void; | ||
}>(); | ||
const notification = computed(() => props.broadcastNotification); | ||
const hasExpired = computed(() => { | ||
return notification.value.expiration_time && new Date(notification.value.expiration_time) < new Date(); | ||
}); | ||
const hasBeenPublished = computed(() => { | ||
return new Date(notification.value.publication_time) < new Date(); | ||
}); | ||
const notificationVariant = computed(() => { | ||
switch (notification.value.variant) { | ||
case "urgent": | ||
return "danger"; | ||
default: | ||
return notification.value.variant; | ||
} | ||
}); | ||
const publicationTimePrefix = computed(() => { | ||
if (hasBeenPublished.value) { | ||
return "Published"; | ||
} | ||
return "Scheduled to be published"; | ||
}); | ||
const expirationTimePrefix = computed(() => { | ||
if (notification.value.expiration_time) { | ||
if (hasExpired.value) { | ||
return "Expired"; | ||
} | ||
return "Expires"; | ||
} | ||
return "Does not expire"; | ||
}); | ||
function onEditClick() { | ||
emit("edit", notification.value); | ||
} | ||
async function onForceExpirationClick() { | ||
const confirmed = await confirm( | ||
"Are you sure you want to expire this broadcast? It will be automatically deleted on the next cleanup cycle.", | ||
"Expire broadcast" | ||
); | ||
if (confirmed) { | ||
emit("expire", notification.value); | ||
} | ||
} | ||
function onActionClick(link: string) { | ||
emit("go-to-link", link); | ||
} | ||
</script> | ||
|
||
<template> | ||
<div class="broadcast-card mb-2"> | ||
<BRow align-v="center" align-h="between" no-gutters> | ||
<Heading size="md" class="mb-0" :class="hasExpired ? 'expired-broadcast' : ''"> | ||
<FontAwesomeIcon :class="`text-${notificationVariant}`" :icon="faInfoCircle" /> | ||
{{ notification.content.subject }} | ||
</Heading> | ||
|
||
<BRow align-h="end" align-v="center" no-gutters> | ||
<span> | ||
created | ||
<UtcDate class="mr-2" :date="notification.create_time" mode="elapsed" /> | ||
</span> | ||
|
||
<BInputGroup v-if="!hasExpired"> | ||
<BButton | ||
id="edit-broadcast-button" | ||
v-b-tooltip.hover | ||
variant="link" | ||
title="Edit broadcast" | ||
@click="onEditClick"> | ||
<FontAwesomeIcon :icon="faEdit" /> | ||
</BButton> | ||
|
||
<BButton | ||
id="delete-button" | ||
v-b-tooltip.hover | ||
variant="link" | ||
title="Delete broadcast" | ||
@click="onForceExpirationClick"> | ||
<FontAwesomeIcon :icon="faTrash" /> | ||
</BButton> | ||
</BInputGroup> | ||
</BRow> | ||
</BRow> | ||
|
||
<BRow align-v="center" align-h="between" no-gutters> | ||
<BCol cols="auto"> | ||
<BRow align-v="center" no-gutters> | ||
<span | ||
:class="hasExpired ? 'expired-broadcast' : ''" | ||
v-html="renderMarkdown(notification.content.message)" /> | ||
</BRow> | ||
|
||
<BRow no-gutters> | ||
<BButton | ||
v-for="actionLink in notification.content.action_links" | ||
:key="actionLink.action_name" | ||
class="mr-1" | ||
:title="actionLink.action_name" | ||
variant="primary" | ||
@click="onActionClick(actionLink.link)"> | ||
{{ actionLink.action_name }} | ||
</BButton> | ||
</BRow> | ||
</BCol> | ||
|
||
<BCol> | ||
<BRow align-v="center" align-h="end" no-gutters> | ||
<FontAwesomeIcon | ||
:icon="hasBeenPublished ? faBroadcastTower : faClock" | ||
:class="hasBeenPublished ? 'published' : 'scheduled'" | ||
class="mx-1" /> | ||
{{ publicationTimePrefix }} | ||
<UtcDate class="ml-1" :date="notification.publication_time" mode="elapsed" /> | ||
</BRow> | ||
|
||
<BRow align-v="center" align-h="end" no-gutters> | ||
<FontAwesomeIcon | ||
variant="danger" | ||
:icon="faHourglassHalf" | ||
:class="hasExpired ? 'expired' : 'expires'" | ||
class="mx-1" /> | ||
{{ expirationTimePrefix }} | ||
<UtcDate | ||
v-if="notification.expiration_time" | ||
class="ml-1" | ||
:date="notification.expiration_time" | ||
mode="elapsed" /> | ||
</BRow> | ||
</BCol> | ||
</BRow> | ||
</div> | ||
</template> | ||
|
||
<style scoped lang="scss"> | ||
@import "scss/theme/blue.scss"; | ||
.broadcast-card { | ||
border-radius: 0.25rem; | ||
border: 1px solid $gray-300; | ||
padding: 1rem; | ||
.expired-broadcast { | ||
text-decoration: line-through; | ||
} | ||
.published { | ||
color: $brand-primary; | ||
} | ||
.scheduled { | ||
color: $brand-warning; | ||
} | ||
.expires { | ||
color: $brand-info; | ||
} | ||
.expired { | ||
color: $brand-danger; | ||
} | ||
} | ||
</style> |
101 changes: 101 additions & 0 deletions
101
client/src/components/admin/Notifications/BroadcastsList.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
import { createTestingPinia } from "@pinia/testing"; | ||
import { getLocalVue } from "@tests/jest/helpers"; | ||
import { shallowMount } from "@vue/test-utils"; | ||
import flushPromises from "flush-promises"; | ||
import { setActivePinia } from "pinia"; | ||
|
||
import { mockFetcher } from "@/api/schema/__mocks__"; | ||
import { BroadcastNotification } from "@/stores/broadcastsStore"; | ||
|
||
import { generateNewBroadcast } from "./test.utils"; | ||
|
||
import BroadcastsList from "./BroadcastsList.vue"; | ||
|
||
jest.mock("@/api/schema"); | ||
|
||
const localVue = getLocalVue(true); | ||
|
||
const selectors = { | ||
emptyBroadcastsListAlert: "#empty-broadcast-list-alert", | ||
showActiveFilterButton: "#show-active-filter-button", | ||
showScheduledFilterButton: "#show-scheduled-filter-button", | ||
showExpiredFilterButton: "#show-expired-filter-button", | ||
broadcastItem: "[data-test-id='broadcast-item']", | ||
} as const; | ||
|
||
async function mountBroadcastsList(broadcasts?: BroadcastNotification[]) { | ||
const pinia = createTestingPinia(); | ||
setActivePinia(pinia); | ||
|
||
mockFetcher | ||
.path("/api/notifications/broadcast") | ||
.method("get") | ||
.mock({ data: broadcasts ?? [] }); | ||
|
||
const wrapper = shallowMount(BroadcastsList as object, { | ||
localVue, | ||
pinia, | ||
stubs: { | ||
FontAwesomeIcon: true, | ||
}, | ||
}); | ||
|
||
await flushPromises(); | ||
|
||
return wrapper; | ||
} | ||
|
||
describe("BroadcastsList.vue", () => { | ||
it("should render empty list message when there are no broadcasts", async () => { | ||
const wrapper = await mountBroadcastsList(); | ||
|
||
expect(wrapper.findAll(selectors.broadcastItem).length).toBe(0); | ||
expect(wrapper.find(selectors.emptyBroadcastsListAlert).exists()).toBeTruthy(); | ||
}); | ||
|
||
it("should filter broadcasts by active, scheduled and expired", async () => { | ||
const now = Date.now(); | ||
|
||
const activeBroadcast = { | ||
...generateNewBroadcast({}), | ||
publication_time: new Date(now - 1000).toISOString(), | ||
expiration_time: new Date(now + 1000).toISOString(), | ||
}; | ||
|
||
const scheduledBroadcast = { | ||
...generateNewBroadcast({}), | ||
publication_time: new Date(now + 1000).toISOString(), | ||
expiration_time: new Date(now + 2000).toISOString(), | ||
}; | ||
|
||
const expiredBroadcast = { | ||
...generateNewBroadcast({}), | ||
publication_time: new Date(now - 2000).toISOString(), | ||
expiration_time: new Date(now - 1000).toISOString(), | ||
}; | ||
|
||
const wrapper = await mountBroadcastsList([activeBroadcast, scheduledBroadcast, expiredBroadcast]); | ||
|
||
// All broadcasts are shown by default | ||
expect(wrapper.findAll(selectors.broadcastItem).length).toBe(3); | ||
|
||
// Disable all filters | ||
await wrapper.find(selectors.showActiveFilterButton).trigger("click"); | ||
await wrapper.find(selectors.showScheduledFilterButton).trigger("click"); | ||
await wrapper.find(selectors.showExpiredFilterButton).trigger("click"); | ||
expect(wrapper.findAll(selectors.broadcastItem).length).toBe(0); | ||
expect(wrapper.find(selectors.emptyBroadcastsListAlert).exists()).toBeTruthy(); | ||
|
||
// Enable active broadcasts filter | ||
await wrapper.find(selectors.showActiveFilterButton).trigger("click"); | ||
expect(wrapper.findAll(selectors.broadcastItem).length).toBe(1); | ||
|
||
// Enable scheduled broadcasts filter | ||
await wrapper.find(selectors.showScheduledFilterButton).trigger("click"); | ||
expect(wrapper.findAll(selectors.broadcastItem).length).toBe(2); | ||
|
||
// Enable expired broadcasts filter | ||
await wrapper.find(selectors.showExpiredFilterButton).trigger("click"); | ||
expect(wrapper.findAll(selectors.broadcastItem).length).toBe(3); | ||
}); | ||
}); |
Oops, something went wrong.