890 lines
29 KiB
Vue
890 lines
29 KiB
Vue
<template>
|
|
<main class="w-full min-w-0 max-w-full space-y-6">
|
|
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
|
<div class="flex min-w-0 items-center gap-3">
|
|
<span
|
|
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
|
|
>
|
|
<UIcon :name="pageShell.icon" class="size-5" />
|
|
</span>
|
|
|
|
<div class="min-w-0 space-y-2">
|
|
<div
|
|
class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
|
|
>
|
|
<span>{{ pageShell.sectionLabel }}</span>
|
|
<span>/</span>
|
|
<span>{{ pageShell.pageLabel }}</span>
|
|
</div>
|
|
|
|
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
|
|
<UButton
|
|
v-if="notifications.length > 0"
|
|
color="neutral"
|
|
:variant="showFilter ? 'soft' : 'outline'"
|
|
size="sm"
|
|
icon="i-lucide-filter"
|
|
@click="toggleFilterPanel"
|
|
>
|
|
<span>Filter</span>
|
|
</UButton>
|
|
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
size="sm"
|
|
icon="i-lucide-plus"
|
|
@click="openCreate"
|
|
>
|
|
<span>New Notification</span>
|
|
</UButton>
|
|
|
|
<UButton
|
|
v-if="notifications.length > 0"
|
|
color="neutral"
|
|
variant="outline"
|
|
size="sm"
|
|
icon="i-lucide-send"
|
|
:loading="sendingTest"
|
|
:disabled="sendingTest"
|
|
@click="() => void sendTest()"
|
|
>
|
|
<span>Test</span>
|
|
</UButton>
|
|
|
|
<UButton
|
|
v-if="notifications.length > 0"
|
|
color="neutral"
|
|
variant="outline"
|
|
size="sm"
|
|
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
|
|
class="hidden sm:inline-flex"
|
|
@click="toggleDisplayStyle"
|
|
>
|
|
<span class="hidden sm:inline">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
|
|
</UButton>
|
|
|
|
<UButton
|
|
v-if="notifications.length > 0"
|
|
color="neutral"
|
|
variant="outline"
|
|
size="sm"
|
|
icon="i-lucide-refresh-cw"
|
|
:loading="isLoading"
|
|
:disabled="isLoading"
|
|
@click="() => void loadContent(page)"
|
|
>
|
|
<span>Reload</span>
|
|
</UButton>
|
|
|
|
<UInput
|
|
v-if="showFilter && notifications.length > 0"
|
|
id="filter"
|
|
ref="filterInput"
|
|
v-model="query"
|
|
type="search"
|
|
placeholder="Filter displayed content"
|
|
icon="i-lucide-filter"
|
|
size="sm"
|
|
class="order-last w-full sm:order-first sm:w-80"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="!isLoading && filteredTargets.length > 0"
|
|
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-default bg-default px-3 py-3"
|
|
>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
size="sm"
|
|
:icon="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
|
|
@click="toggleMasterSelection"
|
|
>
|
|
{{ allSelected ? 'Unselect' : 'Select' }}
|
|
</UButton>
|
|
|
|
<UBadge v-if="selectedIds.length > 0" color="error" variant="soft" size="sm">
|
|
{{ selectedIds.length }}
|
|
</UBadge>
|
|
|
|
<UDropdownMenu :items="bulkActionGroups" :modal="false">
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
size="sm"
|
|
icon="i-lucide-list"
|
|
trailing-icon="i-lucide-chevron-down"
|
|
>
|
|
Actions
|
|
</UButton>
|
|
</UDropdownMenu>
|
|
</div>
|
|
|
|
<UPagination
|
|
v-if="paging?.total_pages > 1"
|
|
:page="paging.page"
|
|
:total="paging.total"
|
|
:items-per-page="paging.per_page"
|
|
:disabled="isLoading"
|
|
show-edges
|
|
:sibling-count="0"
|
|
@update:page="loadContent"
|
|
size="sm"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
v-if="contentStyle === 'list' && filteredTargets.length > 0"
|
|
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
|
|
>
|
|
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
|
|
<table class="min-w-235 w-full text-sm">
|
|
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
|
|
<tr
|
|
class="text-center [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
|
|
>
|
|
<th class="w-12">
|
|
<button type="button" class="cursor-pointer" @click="toggleMasterSelection">
|
|
<UIcon
|
|
:name="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
|
|
class="size-4"
|
|
/>
|
|
</button>
|
|
</th>
|
|
<th class="w-full text-left">Targets</th>
|
|
<th class="w-48 whitespace-nowrap">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
|
|
<tbody class="divide-y divide-default">
|
|
<tr
|
|
v-for="item in filteredTargets"
|
|
:key="item.id"
|
|
class="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
|
|
>
|
|
<td class="px-3 py-3 text-center align-middle">
|
|
<label class="inline-flex cursor-pointer items-center justify-center">
|
|
<input
|
|
v-model="selectedIds"
|
|
class="completed-checkbox size-4 rounded border-default"
|
|
type="checkbox"
|
|
:value="item.id"
|
|
/>
|
|
</label>
|
|
</td>
|
|
|
|
<td class="px-3 py-3 align-middle">
|
|
<div class="space-y-2">
|
|
<div class="min-w-0 text-sm font-semibold text-highlighted">
|
|
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @
|
|
<a
|
|
:href="item.request.url"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
class="break-all text-primary hover:underline"
|
|
>
|
|
{{ item.name }}
|
|
</a>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center gap-3 text-xs text-toned">
|
|
<button
|
|
type="button"
|
|
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
|
|
:disabled="addInProgress"
|
|
@click="() => void toggleEnabled(item)"
|
|
>
|
|
<UIcon
|
|
name="i-lucide-power"
|
|
class="size-3.5"
|
|
:class="item.enabled !== false ? 'text-success' : 'text-error'"
|
|
/>
|
|
<span>{{ item.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
|
|
</button>
|
|
|
|
<span
|
|
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
|
>
|
|
<UIcon name="i-lucide-bell-ring" class="size-3.5" />
|
|
<span>On: {{ joinEvents(item.on) }}</span>
|
|
</span>
|
|
|
|
<span
|
|
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
|
>
|
|
<UIcon name="i-lucide-sliders-horizontal" class="size-3.5" />
|
|
<span>Presets: {{ joinPresets(item.presets) }}</span>
|
|
</span>
|
|
|
|
<span
|
|
v-if="headerKeys(item).length > 0"
|
|
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
|
>
|
|
<UIcon name="i-lucide-key" class="size-3.5" />
|
|
<span>Headers: {{ headerKeys(item).length }}</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
|
|
<td class="w-48 px-3 py-3 align-middle whitespace-nowrap">
|
|
<div class="flex items-center justify-end gap-2">
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
size="xs"
|
|
icon="i-lucide-file-up"
|
|
@click="exportItem(item)"
|
|
>
|
|
<span class="hidden sm:inline">Export</span>
|
|
</UButton>
|
|
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
size="xs"
|
|
icon="i-lucide-pencil"
|
|
@click="editItem(item)"
|
|
>
|
|
<span class="hidden sm:inline">Edit</span>
|
|
</UButton>
|
|
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
size="xs"
|
|
icon="i-lucide-trash"
|
|
@click="() => void deleteItem(item)"
|
|
>
|
|
<span class="hidden sm:inline">Delete</span>
|
|
</UButton>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="filteredTargets.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
<div v-for="item in filteredTargets" :key="item.id" class="min-w-0 w-full max-w-full">
|
|
<UCard
|
|
class="flex h-full min-w-0 w-full max-w-full flex-col"
|
|
:ui="{
|
|
root: 'bg-default border border-default',
|
|
header: 'p-4 pb-3',
|
|
body: 'flex flex-1 flex-col gap-4 p-4 pt-0',
|
|
footer: 'border-t border-default px-4 py-4',
|
|
}"
|
|
>
|
|
<template #header>
|
|
<div class="flex min-w-0 items-start justify-between gap-3">
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex items-start gap-2">
|
|
<button
|
|
type="button"
|
|
class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted"
|
|
@click="toggleExpand(item.id, 'title')"
|
|
>
|
|
<span :class="['block', expandClass(item.id, 'title')]">
|
|
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @
|
|
{{ item.name }}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex shrink-0 items-center gap-2">
|
|
<UButton
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="xs"
|
|
icon="i-lucide-file-up"
|
|
square
|
|
@click="exportItem(item)"
|
|
>
|
|
<span>Export Target</span>
|
|
</UButton>
|
|
|
|
<label class="inline-flex cursor-pointer items-center justify-center">
|
|
<input
|
|
v-model="selectedIds"
|
|
class="completed-checkbox size-4 rounded border-default"
|
|
type="checkbox"
|
|
:value="item.id"
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="space-y-2 text-sm text-default">
|
|
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
|
|
<button
|
|
type="button"
|
|
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
|
|
:disabled="addInProgress"
|
|
@click="() => void toggleEnabled(item)"
|
|
>
|
|
<UIcon
|
|
name="i-lucide-power"
|
|
class="size-3.5"
|
|
:class="item.enabled !== false ? 'text-success' : 'text-error'"
|
|
/>
|
|
<span>{{ item.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
|
|
</button>
|
|
|
|
<span
|
|
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
|
>
|
|
<UIcon name="i-lucide-bell-ring" class="size-3.5" />
|
|
<span>Events: {{ item.on.length || 'All' }}</span>
|
|
</span>
|
|
|
|
<span
|
|
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
|
>
|
|
<UIcon name="i-lucide-sliders-horizontal" class="size-3.5" />
|
|
<span>Presets: {{ item.presets.length || 'All' }}</span>
|
|
</span>
|
|
|
|
<span
|
|
v-if="headerKeys(item).length > 0"
|
|
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
|
>
|
|
<UIcon name="i-lucide-key" class="size-3.5" />
|
|
<span>Headers: {{ headerKeys(item).length }}</span>
|
|
</span>
|
|
</div>
|
|
|
|
<div class="feature-meta-grid">
|
|
<button
|
|
type="button"
|
|
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
|
|
@click="toggleExpand(item.id, 'url')"
|
|
>
|
|
<UIcon name="i-lucide-link" class="mt-0.5 size-4 shrink-0 text-toned" />
|
|
<div class="min-w-0 flex-1">
|
|
<div class="text-xs font-medium text-toned">Target URL</div>
|
|
<a
|
|
:href="item.request.url"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
class="block text-highlighted hover:underline"
|
|
@click.stop
|
|
>
|
|
<span :class="['block', expandClass(item.id, 'url')]">
|
|
{{ item.request.url }}
|
|
</span>
|
|
</a>
|
|
</div>
|
|
</button>
|
|
|
|
<button
|
|
v-if="headerKeys(item).length > 0"
|
|
type="button"
|
|
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
|
|
@click="toggleExpand(item.id, 'headers')"
|
|
>
|
|
<UIcon name="i-lucide-key" class="mt-0.5 size-4 shrink-0 text-toned" />
|
|
<div class="min-w-0 flex-1">
|
|
<div class="text-xs font-medium text-toned">Headers</div>
|
|
<span :class="['block', expandClass(item.id, 'headers')]">
|
|
{{ headerKeys(item).join(', ') }}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
|
|
@click="toggleExpand(item.id, 'events')"
|
|
>
|
|
<UIcon name="i-lucide-bell-ring" class="mt-0.5 size-4 shrink-0 text-toned" />
|
|
<div class="min-w-0 flex-1">
|
|
<div class="text-xs font-medium text-toned">Events</div>
|
|
<span :class="['block', expandClass(item.id, 'events')]">{{
|
|
joinEvents(item.on)
|
|
}}</span>
|
|
</div>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
|
|
@click="toggleExpand(item.id, 'presets')"
|
|
>
|
|
<UIcon
|
|
name="i-lucide-sliders-horizontal"
|
|
class="mt-0.5 size-4 shrink-0 text-toned"
|
|
/>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="text-xs font-medium text-toned">Presets</div>
|
|
<span :class="['block', expandClass(item.id, 'presets')]">
|
|
{{ joinPresets(item.presets) }}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-pencil"
|
|
class="w-full justify-center"
|
|
@click="editItem(item)"
|
|
>
|
|
Edit
|
|
</UButton>
|
|
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-trash"
|
|
class="w-full justify-center"
|
|
@click="() => void deleteItem(item)"
|
|
>
|
|
Delete
|
|
</UButton>
|
|
</div>
|
|
</template>
|
|
</UCard>
|
|
</div>
|
|
</div>
|
|
|
|
<UAlert
|
|
v-if="isLoading"
|
|
color="info"
|
|
variant="soft"
|
|
icon="i-lucide-loader-circle"
|
|
title="Loading"
|
|
description="Loading data. Please wait..."
|
|
/>
|
|
|
|
<div v-else-if="query && filteredTargets.length < 1" class="space-y-3">
|
|
<UAlert
|
|
color="warning"
|
|
variant="soft"
|
|
icon="i-lucide-search"
|
|
title="No Results"
|
|
:description="`No results found for the query: ${query}. Please try a different search term.`"
|
|
/>
|
|
</div>
|
|
|
|
<UAlert
|
|
v-else-if="!filteredTargets.length"
|
|
color="warning"
|
|
variant="soft"
|
|
icon="i-lucide-circle-alert"
|
|
title="No targets"
|
|
description="No notification targets found. Click on the New Notification button to add your first notification target."
|
|
/>
|
|
|
|
<div v-if="filteredTargets.length > 0 && paging?.total_pages > 1" class="flex justify-end">
|
|
<UPagination
|
|
:page="paging.page"
|
|
:total="paging.total"
|
|
:items-per-page="paging.per_page"
|
|
:disabled="isLoading"
|
|
show-edges
|
|
:sibling-count="0"
|
|
@update:page="loadContent"
|
|
size="sm"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
v-if="!query && filteredTargets.length > 0"
|
|
class="rounded-lg border border-info/30 bg-info/10 p-4 text-sm text-default"
|
|
>
|
|
<ul class="list-disc space-y-2 pl-5 text-sm text-default">
|
|
<li>
|
|
When you export notification target, We remove <code>Authorization</code> header key by
|
|
default, However this might not be enough to remove credentials from the exported data.
|
|
it's your responsibility to ensure that the exported data does not contain any sensitive
|
|
information for sharing.
|
|
</li>
|
|
<li>
|
|
When you set the request type as <code>Form</code>, the event data will be JSON encoded
|
|
and sent as <code>...&data_key=json_string</code>, only the <code>data</code> field
|
|
will be JSON encoded. The other keys <code>id</code>, <code>event</code> and
|
|
<code>created_at</code> will be sent as they are.
|
|
</li>
|
|
<li>
|
|
We also send two special headers <code>X-Event-ID</code> and <code>X-Event</code> with the
|
|
request.
|
|
</li>
|
|
<li>
|
|
If you have selected specific presets or events, this will take priority, For example, if
|
|
you limited the target to <code>default</code> preset and selected
|
|
<code>ALL</code> events, only events that reference the <code>default</code> preset will
|
|
be sent to that target. Like wise, if you have limited both events and presets, then ONLY
|
|
events that satisfy both conditions will be sent to that target. Only the
|
|
<code>test</code> events can bypass these conditions.
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<UModal
|
|
v-if="editorOpen"
|
|
:open="editorOpen"
|
|
:title="targetRef ? `Edit - ${target.name}` : 'Add new notification target'"
|
|
description="Send notifications to your webhooks based on specified events or presets."
|
|
:dismissible="!addInProgress"
|
|
:ui="{ content: 'w-full sm:max-w-5xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
|
|
@update:open="handleEditorOpenChange"
|
|
>
|
|
<template #body>
|
|
<NotificationForm
|
|
:key="modalKey"
|
|
:addInProgress="addInProgress"
|
|
:reference="targetRef"
|
|
:item="target"
|
|
:allowedEvents="allowedEvents"
|
|
@cancel="() => void requestCloseEditor()"
|
|
@dirty-change="(dirty) => (editorDirty = dirty)"
|
|
@submit="updateItem"
|
|
/>
|
|
</template>
|
|
</UModal>
|
|
</main>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { DropdownMenuItem } from '@nuxt/ui';
|
|
import { useStorage } from '@vueuse/core';
|
|
import { useDialog } from '~/composables/useDialog';
|
|
import { useExpandableMeta } from '~/composables/useExpandableMeta';
|
|
import { useConfirm } from '~/composables/useConfirm';
|
|
import { useNotifications } from '~/composables/useNotifications';
|
|
import { copyText, encode, parse_api_error, request, ucFirst } from '~/utils';
|
|
import type { ImportedItem } from '~/types';
|
|
import type { notification } from '~/types/notification';
|
|
import { requirePageShell } from '~/utils/topLevelNavigation';
|
|
|
|
const toast = useNotification();
|
|
const box = useConfirm();
|
|
const { confirmDialog } = useDialog();
|
|
const { toggleExpand, expandClass } = useExpandableMeta();
|
|
const pageShell = requirePageShell('notifications');
|
|
const displayStyleState = useStorage<'list' | 'grid' | 'cards'>(
|
|
'notification_display_style',
|
|
'cards',
|
|
);
|
|
const isMobile = useMediaQuery({ maxWidth: 639 });
|
|
|
|
const notificationsStore = useNotifications();
|
|
const notifications = notificationsStore.notifications;
|
|
const paging = notificationsStore.pagination;
|
|
const allowedEvents = notificationsStore.events;
|
|
const isLoading = notificationsStore.isLoading;
|
|
const addInProgress = notificationsStore.addInProgress;
|
|
const lastError = notificationsStore.lastError;
|
|
|
|
const page = ref(1);
|
|
const targetRef = ref<number | undefined>(undefined);
|
|
const target = ref<notification>(defaultState());
|
|
const editorOpen = ref(false);
|
|
const editorDirty = ref(false);
|
|
const sendingTest = ref(false);
|
|
const query = ref('');
|
|
const showFilter = ref(false);
|
|
const filterInput = ref<{ inputRef?: { value?: HTMLInputElement | null } } | null>(null);
|
|
const selectedIds = ref<number[]>([]);
|
|
const massDelete = ref(false);
|
|
|
|
const displayStyle = computed<'list' | 'grid'>(() =>
|
|
displayStyleState.value === 'list' ? 'list' : 'grid',
|
|
);
|
|
const contentStyle = computed<'list' | 'grid'>(() =>
|
|
isMobile.value ? 'grid' : displayStyle.value,
|
|
);
|
|
|
|
const modalKey = computed(
|
|
() => `${targetRef.value ?? 'new'}-${editorOpen.value ? 'open' : 'closed'}`,
|
|
);
|
|
|
|
const discardEditor = (): void => {
|
|
editorDirty.value = false;
|
|
target.value = defaultState();
|
|
targetRef.value = undefined;
|
|
};
|
|
|
|
const { handleOpenChange: handleEditorOpenChange, requestClose: requestCloseEditor } =
|
|
useDirtyCloseGuard(editorOpen, {
|
|
dirty: editorDirty,
|
|
message: 'You have unsaved notification changes. Do you want to discard them?',
|
|
onDiscard: async () => {
|
|
discardEditor();
|
|
},
|
|
});
|
|
|
|
const filteredTargets = computed<notification[]>(() => {
|
|
const normalizedQuery = query.value?.toLowerCase();
|
|
const items = notifications.value as notification[];
|
|
|
|
if (!normalizedQuery) {
|
|
return items;
|
|
}
|
|
|
|
return items.filter((item) => deepIncludes(item, normalizedQuery, new WeakSet()));
|
|
});
|
|
|
|
const selectableNotificationIds = computed(() =>
|
|
filteredTargets.value.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
|
|
);
|
|
|
|
const allSelected = computed(
|
|
() =>
|
|
selectableNotificationIds.value.length > 0 &&
|
|
selectableNotificationIds.value.every((id) => selectedIds.value.includes(id)),
|
|
);
|
|
|
|
const hasSelected = computed(() => selectedIds.value.length > 0);
|
|
|
|
const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [
|
|
[
|
|
{
|
|
label: 'Remove Selected',
|
|
icon: 'i-lucide-trash',
|
|
color: 'error',
|
|
disabled: !hasSelected.value || massDelete.value,
|
|
onSelect: () => void deleteSelected(),
|
|
},
|
|
],
|
|
]);
|
|
|
|
watch(showFilter, (value) => {
|
|
if (!value) {
|
|
query.value = '';
|
|
}
|
|
});
|
|
|
|
watch(
|
|
filteredTargets,
|
|
(items) => {
|
|
const validIds = new Set(
|
|
items.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
|
|
);
|
|
selectedIds.value = selectedIds.value.filter((id) => validIds.has(id));
|
|
},
|
|
{ deep: true },
|
|
);
|
|
|
|
function defaultState(): notification {
|
|
return {
|
|
name: '',
|
|
on: [],
|
|
presets: [],
|
|
enabled: true,
|
|
request: { method: 'POST', url: '', type: 'json', headers: [], data_key: 'data' },
|
|
};
|
|
}
|
|
|
|
const toggleFilterPanel = async (): Promise<void> => {
|
|
showFilter.value = !showFilter.value;
|
|
if (!showFilter.value) {
|
|
query.value = '';
|
|
return;
|
|
}
|
|
|
|
await nextTick();
|
|
filterInput.value?.inputRef?.value?.focus?.({ preventScroll: true });
|
|
};
|
|
|
|
const loadContent = async (pageNumber = page.value): Promise<void> => {
|
|
page.value = pageNumber;
|
|
await notificationsStore.loadNotifications(pageNumber);
|
|
};
|
|
|
|
const resetEditor = (): void => {
|
|
target.value = defaultState();
|
|
targetRef.value = undefined;
|
|
editorDirty.value = false;
|
|
};
|
|
|
|
const closeEditor = (): void => {
|
|
editorOpen.value = false;
|
|
resetEditor();
|
|
};
|
|
|
|
const openCreate = (): void => {
|
|
resetEditor();
|
|
editorOpen.value = true;
|
|
};
|
|
|
|
const toggleMasterSelection = (): void => {
|
|
if (allSelected.value) {
|
|
selectedIds.value = [];
|
|
return;
|
|
}
|
|
|
|
selectedIds.value = [...selectableNotificationIds.value];
|
|
};
|
|
|
|
const editItem = (item: notification): void => {
|
|
editorDirty.value = false;
|
|
target.value = JSON.parse(JSON.stringify(item)) as notification;
|
|
targetRef.value = item.id ?? undefined;
|
|
editorOpen.value = true;
|
|
};
|
|
|
|
const deleteItem = async (item: notification): Promise<void> => {
|
|
if (true !== (await box.confirm(`Delete '${item.name}'?`))) {
|
|
return;
|
|
}
|
|
|
|
if (!item.id) {
|
|
toast.error('Notification target not found.');
|
|
return;
|
|
}
|
|
|
|
await notificationsStore.deleteNotification(item.id);
|
|
};
|
|
|
|
const deleteSelected = async (): Promise<void> => {
|
|
if (selectedIds.value.length < 1) {
|
|
return;
|
|
}
|
|
|
|
const { status } = await confirmDialog({
|
|
title: 'Delete Selected Notifications',
|
|
message:
|
|
`Delete ${selectedIds.value.length} notification target/s?` +
|
|
'\n\n' +
|
|
selectedIds.value
|
|
.map((id) => {
|
|
const item = filteredTargets.value.find((target) => target.id === id);
|
|
return item ? `${item.id}: ${item.name}` : '';
|
|
})
|
|
.filter(Boolean)
|
|
.join('\n'),
|
|
confirmText: 'Delete',
|
|
confirmColor: 'error',
|
|
});
|
|
|
|
if (true !== status) {
|
|
return;
|
|
}
|
|
|
|
const itemsToDelete = filteredTargets.value.filter(
|
|
(item) => item.id && selectedIds.value.includes(item.id),
|
|
);
|
|
if (itemsToDelete.length < 1) {
|
|
return;
|
|
}
|
|
|
|
massDelete.value = true;
|
|
|
|
for (const item of itemsToDelete) {
|
|
if (!item.id) {
|
|
continue;
|
|
}
|
|
await notificationsStore.deleteNotification(item.id);
|
|
}
|
|
|
|
selectedIds.value = [];
|
|
massDelete.value = false;
|
|
};
|
|
|
|
const toggleEnabled = async (item: notification): Promise<void> => {
|
|
if (!item.id) {
|
|
toast.error('Notification target not found.');
|
|
return;
|
|
}
|
|
|
|
await notificationsStore.patchNotification(item.id, { enabled: !item.enabled });
|
|
};
|
|
|
|
const updateItem = async ({
|
|
reference,
|
|
item,
|
|
}: {
|
|
reference: number | undefined;
|
|
item: notification;
|
|
}): Promise<void> => {
|
|
if (reference) {
|
|
await notificationsStore.updateNotification(reference, item);
|
|
} else {
|
|
await notificationsStore.createNotification(item);
|
|
}
|
|
|
|
if (!lastError.value) {
|
|
closeEditor();
|
|
}
|
|
};
|
|
|
|
const toggleDisplayStyle = (): void => {
|
|
displayStyleState.value = displayStyle.value === 'list' ? 'grid' : 'list';
|
|
};
|
|
|
|
const joinEvents = (events: string[]): string =>
|
|
!events || events.length < 1 ? 'ALL' : events.map((event) => ucFirst(event)).join(', ');
|
|
|
|
const joinPresets = (presets: string[]): string =>
|
|
!presets || presets.length < 1 ? 'ALL' : presets.map((preset) => ucFirst(preset)).join(', ');
|
|
|
|
const headerKeys = (item: notification): string[] =>
|
|
item.request?.headers?.map((header) => header.key).filter(Boolean) ?? [];
|
|
|
|
const sendTest = async (): Promise<void> => {
|
|
if (true !== (await box.confirm('Send test notification?'))) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
sendingTest.value = true;
|
|
const response = await request('/api/notifications/test', { method: 'POST' });
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
const message = await parse_api_error(data);
|
|
toast.error(`Failed to send test notification. ${message}`);
|
|
return;
|
|
}
|
|
|
|
toast.success('Test notification sent.');
|
|
} catch (error: any) {
|
|
const message = error?.message || 'Unknown error';
|
|
toast.error(`Failed to send test notification. ${message}`);
|
|
} finally {
|
|
sendingTest.value = false;
|
|
}
|
|
};
|
|
|
|
const exportItem = async (item: notification): Promise<void> => {
|
|
const data: notification & ImportedItem = {
|
|
...JSON.parse(JSON.stringify(item)),
|
|
_type: 'notification',
|
|
_version: '1.0',
|
|
};
|
|
|
|
const keys = ['id', 'raw'];
|
|
keys.forEach((key) => {
|
|
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
|
const { [key]: _, ...rest } = data as Record<string, unknown>;
|
|
Object.assign(data, rest);
|
|
}
|
|
});
|
|
|
|
if (data.request?.headers?.length) {
|
|
data.request.headers = data.request.headers.filter(
|
|
(header) => 'authorization' !== header.key.toLowerCase(),
|
|
);
|
|
}
|
|
|
|
copyText(encode(data));
|
|
};
|
|
|
|
onMounted(async () => await loadContent(page.value));
|
|
</script>
|