feat: add reactions (#1074)

This commit is contained in:
Jonas Plum
2024-07-20 06:39:02 +02:00
committed by GitHub
parent 82ad50d228
commit e2c8f1d223
78 changed files with 3270 additions and 257 deletions

View File

@@ -63,11 +63,11 @@ const deleteMutation = useMutation({
>
</DialogHeader>
<DialogFooter class="mt-2">
<DialogFooter class="mt-2 sm:justify-start">
<Button type="button" variant="destructive" @click="deleteMutation.mutate"> Delete </Button>
<DialogClose as-child>
<Button type="button" variant="secondary">Cancel</Button>
</DialogClose>
<Button type="button" variant="destructive" @click="deleteMutation.mutate"> Delete </Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import { Textarea } from '@/components/ui/textarea'
import { useVModel } from '@vueuse/core'
import { type HTMLAttributes, onMounted, ref } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
defaultValue?: string | number
modelValue?: string | number
}>()
const emits = defineEmits<{
(e: 'update:modelValue', payload: string | number): void
}>()
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue
})
const textarea = ref<HTMLElement | null>(null)
const resize = () => {
if (!textarea.value) return
textarea.value.style.height = 'auto' // Reset to default or minimum height
textarea.value.style.height = textarea.value.scrollHeight + 2 + 'px'
}
onMounted(() => resize())
</script>
<template>
<textarea
ref="textarea"
rows="1"
@focus="resize"
@input="resize"
v-model="modelValue"
:class="
cn(
'flex min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
props.class
)
"
/>
</template>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import TextInput from '@/components/form/TextInput.vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Plus, Trash2 } from 'lucide-vue-next'
import { ref } from 'vue'
const props = withDefaults(
defineProps<{
modelValue?: string[]
placeholder?: string
}>(),
{
modelValue: () => [],
placeholder: ''
}
)
const emit = defineEmits(['update:modelValue'])
const newItem = ref('')
const updateModelValue = (value: string, index: number) => {
const newValue = props.modelValue
newValue[index] = value
emit('update:modelValue', newValue)
}
const addModelValue = () => {
emit('update:modelValue', [...props.modelValue, newItem.value])
newItem.value = ''
}
const removeModelValue = (index: number) =>
emit(
'update:modelValue',
props.modelValue.filter((_, i) => i !== index)
)
</script>
<template>
<div class="flex flex-col gap-2">
<div v-for="(item, index) in modelValue" :key="item" class="flex flex-row items-center gap-2">
<TextInput
:modelValue="item"
@update:modelValue="updateModelValue($event, index)"
:placeholder="placeholder"
/>
<Button variant="outline" size="icon" @click="removeModelValue(index)" class="shrink-0">
<Trash2 class="size-4" />
<span class="sr-only">Remove item</span>
</Button>
</div>
<div class="flex flex-row items-center gap-2">
<Input v-model="newItem" :placeholder="placeholder" @keydown.enter.prevent="addModelValue" />
<Button
variant="outline"
size="icon"
@click="addModelValue"
:disabled="newItem === ''"
class="shrink-0"
>
<Plus class="size-4" />
<span class="sr-only">Add item</span>
</Button>
</div>
</div>
</template>

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
import { CommandEmpty, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'
import {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
} from '@/components/ui/tags-input'
import { ComboboxAnchor, ComboboxInput, ComboboxPortal, ComboboxRoot } from 'radix-vue'
import { computed, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
modelValue?: string[]
items: string[]
placeholder?: string
}>(),
{
modelValue: () => [],
items: () => [],
placeholder: ''
}
)
const emit = defineEmits(['update:modelValue'])
const open = ref(false)
const searchTerm = ref('')
const selectedItems = ref<string[]>(props.modelValue)
watch(selectedItems.value, (value) => emit('update:modelValue', value))
const filteredItems = computed(() => {
if (!selectedItems.value) return props.items
return props.items.filter((i) => !selectedItems.value.includes(i))
})
</script>
<template>
<TagsInput class="flex items-center gap-2 px-0" :modelValue="selectedItems">
<div class="flex flex-wrap items-center">
<TagsInputItem v-for="item in selectedItems" :key="item" :value="item" class="ml-2">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
</div>
<ComboboxRoot
v-model="selectedItems"
v-model:open="open"
v-model:searchTerm="searchTerm"
class="flex-1"
>
<ComboboxAnchor as-child>
<ComboboxInput
:placeholder="placeholder"
as-child
:class="selectedItems.length < items.length ? '' : 'hidden'"
>
<TagsInputInput @keydown.enter.prevent @focus="open = true" @blur="open = false" />
</ComboboxInput>
</ComboboxAnchor>
<ComboboxPortal>
<CommandList
v-if="selectedItems.length < items.length"
position="popper"
class="mt-2 w-[--radix-popper-anchor-width] rounded-md border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
>
<CommandEmpty />
<CommandGroup>
<CommandItem
v-for="item in filteredItems"
:key="item"
:value="item"
@select.prevent="
(ev) => {
if (typeof ev.detail.value === 'string') {
searchTerm = ''
selectedItems.push(ev.detail.value)
}
if (filteredItems.length === 0) {
open = false
}
}
"
>
{{ item }}
</CommandItem>
</CommandGroup>
</CommandList>
</ComboboxPortal>
</ComboboxRoot>
</TagsInput>
</template>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Save, X } from 'lucide-vue-next'
import { type HTMLAttributes, ref, watchEffect } from 'vue'
const props = withDefaults(
defineProps<{
modelValue?: string
placeholder?: string
class?: HTMLAttributes['class']
}>(),
{
modelValue: '',
placeholder: ''
}
)
const emit = defineEmits(['update:modelValue'])
const text = ref(props.modelValue)
const editMode = ref(false)
const input = ref<HTMLInputElement | null>(null)
const setValue = () => emit('update:modelValue', text.value)
watchEffect(() => {
if (editMode.value && input.value) {
input.value.$el.focus()
}
})
const edit = () => {
text.value = props.modelValue
editMode.value = true
}
const cancel = () => {
text.value = props.modelValue
editMode.value = false
}
</script>
<template>
<Button v-if="!editMode" variant="outline" size="icon" @click="edit" class="flex-1">
<div class="ml-3 w-full text-start font-normal">
{{ text }}
</div>
</Button>
<div v-else class="flex w-full flex-row gap-2">
<Input
ref="input"
v-model="text"
:placeholder="placeholder"
@keydown.enter="setValue"
class="flex-1"
/>
<Button variant="outline" size="icon" @click="cancel" class="shrink-0">
<X class="size-4" />
<span class="sr-only">Cancel</span>
</Button>
<Button
variant="outline"
size="icon"
@click="setValue"
:disabled="text === modelValue"
class="shrink-0"
>
<Save class="size-4" />
<span class="sr-only">Save</span>
</Button>
</div>
</template>

View File

@@ -29,7 +29,6 @@ const catalystStore = useCatalystStore()
<h1 class="text-xl font-bold" v-if="!catalystStore.sidebarCollapsed">Catalyst</h1>
</div>
<NavList
class="mt-auto"
:is-collapsed="catalystStore.sidebarCollapsed"
:links="[
{
@@ -43,10 +42,20 @@ const catalystStore = useCatalystStore()
<Separator />
<IncidentNav :is-collapsed="catalystStore.sidebarCollapsed" />
<Separator />
<div class="flex-1" />
<Separator />
<NavList
:is-collapsed="catalystStore.sidebarCollapsed"
:links="[
{
title: 'Reactions',
icon: 'Zap',
variant: 'ghost',
to: '/reactions'
}
]"
/>
<Separator />
<UserDropDown :is-collapsed="catalystStore.sidebarCollapsed" />
<Separator />

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import GrowTextarea from '@/components/form/GrowTextarea.vue'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
</script>
<template>
<FormField name="actiondata.requirements" v-slot="{ componentField }">
<FormItem>
<FormLabel for="requirements" class="text-right">requirements.txt</FormLabel>
<FormControl>
<GrowTextarea id="requirements" class="col-span-3" v-bind="componentField" />
</FormControl>
<FormDescription> Specify the Python packages required to run the script. </FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField name="actiondata.script" v-slot="{ componentField }" validate-on-input>
<FormItem>
<FormLabel for="script" class="text-right">Script</FormLabel>
<FormControl>
<GrowTextarea id="script" class="col-span-3" v-bind="componentField" />
</FormControl>
<FormDescription>
Write a Python script to run when the reaction is triggered.
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import GrowListTextarea from '@/components/form/ListInput.vue'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
</script>
<template>
<FormField name="actiondata.headers" v-slot="{ value, handleChange }">
<FormItem>
<FormLabel for="headers" class="text-right">Headers</FormLabel>
<FormControl>
<GrowListTextarea
id="headers"
:modelValue="value"
@update:modelValue="handleChange"
placeholder="Content-Type: application/json"
/>
</FormControl>
<FormDescription> Specify the headers to include in the request. </FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField name="actiondata.url" v-slot="{ componentField }" validate-on-input>
<FormItem>
<FormLabel for="url" class="text-right">URL</FormLabel>
<FormControl>
<Input id="url" v-bind="componentField" placeholder="https://example.com/webhook" />
</FormControl>
<FormDescription> Specify the URL to send the request to. </FormDescription>
<FormMessage />
</FormItem>
</FormField>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import TanView from '@/components/TanView.vue'
import DeleteDialog from '@/components/common/DeleteDialog.vue'
import ReactionForm from '@/components/reaction/ReactionForm.vue'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { pb } from '@/lib/pocketbase'
import type { Reaction } from '@/lib/types'
import { handleError } from '@/lib/utils'
const queryClient = useQueryClient()
const props = defineProps<{
id: string
}>()
const {
isPending,
isError,
data: reaction,
error
} = useQuery({
queryKey: ['reactions', props.id],
queryFn: (): Promise<Reaction> => pb.collection('reactions').getOne(props.id)
})
const updateReactionMutation = useMutation({
mutationFn: (update: any) => pb.collection('reactions').update(props.id, update),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['reactions'] }),
onError: handleError
})
</script>
<template>
<TanView :isError="isError" :isPending="isPending" :error="error" :value="reaction">
<div class="flex h-full flex-1 flex-col overflow-hidden">
<div class="flex items-center bg-background px-4 py-2">
<div class="ml-auto">
<DeleteDialog
v-if="reaction"
collection="reactions"
:id="reaction.id"
:name="reaction.name"
:singular="'Reaction'"
:to="{ name: 'reactions' }"
:queryKey="['reactions']"
/>
</div>
</div>
<Separator />
<ScrollArea v-if="reaction" class="flex-1">
<div class="flex max-w-[640px] flex-col gap-4 p-4">
<ReactionForm :reaction="reaction" @submit="updateReactionMutation.mutate" hide-cancel />
</div>
</ScrollArea>
</div>
</TanView>
</template>

View File

@@ -0,0 +1,318 @@
<script setup lang="ts">
import ActionPythonFormFields from '@/components/reaction/ActionPythonFormFields.vue'
import ActionWebhookFormFields from '@/components/reaction/ActionWebhookFormFields.vue'
import TriggerHookFormFields from '@/components/reaction/TriggerHookFormFields.vue'
import TriggerWebhookFormFields from '@/components/reaction/TriggerWebhookFormFields.vue'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { defineRule, useForm } from 'vee-validate'
import { computed, ref, watch } from 'vue'
import type { Reaction } from '@/lib/types'
const submitDisabledReason = ref<string>('')
const props = defineProps<{
reaction?: Reaction
}>()
const emit = defineEmits(['submit'])
defineRule('required', (value: string) => {
if (!value || !value.length) {
return 'This field is required'
}
return true
})
defineRule('triggerdata.token', (value: string) => {
return true
})
defineRule('triggerdata.path', (value: string) => {
if (values.trigger !== 'webhook') {
return true
}
if (!value) {
return 'This field is required'
}
const expression = /^[a-zA-Z0-9-_]+$/
if (!value.match(expression)) {
return 'Invalid path, only letters, numbers, dashes, and underscores are allowed'
}
return true
})
defineRule('triggerdata.collections', (value: string[]) => {
if (values.trigger !== 'hook') {
return true
}
if (!value) {
return 'This field is required'
}
if (value.length === 0) {
return 'At least one collection is required'
}
return true
})
defineRule('triggerdata.events', (value: string[]) => {
if (values.trigger !== 'hook') {
return true
}
if (!value) {
return 'This field is required'
}
if (value.length === 0) {
return 'At least one event is required'
}
return true
})
defineRule('actiondata.script', (value: string) => {
if (values.action !== 'python') {
return true
}
if (!value) {
return 'This field is required'
}
return true
})
defineRule('actiondata.url', (value: string) => {
if (values.action !== 'webhook') {
return true
}
if (!value) {
return 'This field is required'
}
if (!(value.startsWith('http://') || value.startsWith('https://'))) {
return 'Invalid URL, must start with http:// or https://'
}
return true
})
const { handleSubmit, validate, values } = useForm({
initialValues: props.reaction || {
name: '',
trigger: '',
triggerdata: {},
action: '',
actiondata: {}
},
validationSchema: {
name: 'required',
trigger: 'required',
'triggerdata.token': 'triggerdata.token',
'triggerdata.path': 'triggerdata.path',
'triggerdata.collections': 'triggerdata.collections',
'triggerdata.events': 'triggerdata.events',
'actiondata.script': 'actiondata.script',
'actiondata.url': 'actiondata.url',
action: 'required'
}
})
const equalReaction = (values: Reaction, reaction?: Reaction): boolean => {
if (!reaction) return false
return (
reaction.name === values.name &&
reaction.trigger === values.trigger &&
JSON.stringify(reaction.triggerdata) === JSON.stringify(values.triggerdata) &&
reaction.action === values.action &&
JSON.stringify(reaction.actiondata) === JSON.stringify(values.actiondata)
)
}
watch(
() => props.reaction,
() => {
if (equalReaction(values, props.reaction)) {
submitDisabledReason.value = 'Make changes to save'
}
},
{ immediate: true }
)
watch(
values,
() => {
if (equalReaction(values, props.reaction)) {
submitDisabledReason.value = 'Make changes to save'
return
}
validate({ mode: 'silent' }).then((res) => {
if (res.valid) {
submitDisabledReason.value = ''
} else {
submitDisabledReason.value = 'Please fix the errors'
}
})
},
{ deep: true, immediate: true }
)
const onSubmit = handleSubmit((values) => emit('submit', values))
const curlExample = computed(() => {
let cmd = `curl`
if (values.triggerdata.token) {
cmd += ` -H "Auth: Bearer ${values.triggerdata.token}"`
}
if (values.triggerdata.path) {
cmd += ` https://${location.hostname}/reaction/${values.triggerdata.path}`
}
return cmd
})
</script>
<template>
<form @submit="onSubmit" class="flex flex-col items-start gap-4">
<FormField name="name" v-slot="{ componentField }" validate-on-input>
<FormItem class="w-full">
<FormLabel for="name" class="text-right">Name</FormLabel>
<Input id="name" class="col-span-3" v-bind="componentField" />
<FormMessage />
</FormItem>
</FormField>
<Card class="w-full">
<CardHeader>
<CardTitle>Trigger</CardTitle>
</CardHeader>
<CardContent class="flex flex-col gap-4">
<FormField name="trigger" v-slot="{ componentField }" validate-on-input>
<FormItem>
<FormLabel for="trigger" class="text-right">Type</FormLabel>
<FormControl>
<Select id="trigger" class="col-span-3" v-bind="componentField">
<SelectTrigger class="font-medium">
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="webhook">HTTP / Webhook</SelectItem>
<SelectItem value="hook">Collection Hook</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<p>HTTP / Webhook: Receive a HTTP request.</p>
<p>Collection Hook: Triggered by a collection and event.</p>
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<TriggerWebhookFormFields v-if="values.trigger === 'webhook'" />
<TriggerHookFormFields v-else-if="values.trigger === 'hook'" />
<div v-if="values.trigger === 'webhook'">
<Label for="url">Usage</Label>
<Input id="url" readonly :modelValue="curlExample" class="bg-accent" />
</div>
</CardContent>
</Card>
<Card class="w-full">
<CardHeader>
<CardTitle>Action</CardTitle>
</CardHeader>
<CardContent class="flex flex-col gap-4">
<FormField name="action" v-slot="{ componentField }" validate-on-input>
<FormItem>
<FormLabel for="action" class="text-right">Type</FormLabel>
<FormControl>
<Select id="action" class="col-span-3" v-bind="componentField">
<SelectTrigger class="font-medium">
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="python">Python</SelectItem>
<SelectItem value="webhook">HTTP / Webhook</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<p>Python: Execute a Python script.</p>
<p>HTTP / Webhook: Send an HTTP request.</p>
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<ActionPythonFormFields v-if="values.action === 'python'" />
<ActionWebhookFormFields v-else-if="values.action === 'webhook'" />
</CardContent>
</Card>
<div class="flex gap-4">
<TooltipProvider :delay-duration="0">
<Tooltip>
<TooltipTrigger class="cursor-default">
<Button
type="submit"
:variant="submitDisabledReason !== '' ? 'secondary' : 'default'"
:disabled="submitDisabledReason !== ''"
:title="submitDisabledReason"
>
Save
</Button>
</TooltipTrigger>
<TooltipContent>
<span v-if="submitDisabledReason !== ''">
{{ submitDisabledReason }}
</span>
<span v-else> Save the reaction. </span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<slot name="cancel" />
</div>
</form>
</template>

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import TanView from '@/components/TanView.vue'
import ResourceListElement from '@/components/common/ResourceListElement.vue'
import ReactionNewDialog from '@/components/reaction/ReactionNewDialog.vue'
import { Separator } from '@/components/ui/separator'
import { useQuery } from '@tanstack/vue-query'
import { useRoute } from 'vue-router'
import { pb } from '@/lib/pocketbase'
import type { Reaction } from '@/lib/types'
const route = useRoute()
const {
isPending,
isError,
data: reactions,
error
} = useQuery({
queryKey: ['reactions'],
queryFn: (): Promise<Array<Reaction>> =>
pb.collection('reactions').getFullList({
sort: '-created'
})
})
const subtitle = (reaction: Reaction) =>
triggerNiceName(reaction) + ' to ' + reactionNiceName(reaction)
const triggerNiceName = (reaction: Reaction) => {
if (reaction.trigger === 'hook') {
return 'Collection Hook'
} else if (reaction.trigger === 'webhook') {
return 'HTTP / Webhook'
} else {
return 'Unknown'
}
}
const reactionNiceName = (reaction: Reaction) => {
if (reaction.action === 'python') {
return 'Python'
} else if (reaction.action === 'webhook') {
return 'HTTP / Webhook'
} else {
return 'Unknown'
}
}
</script>
<template>
<TanView :isError="isError" :isPending="isPending" :error="error" :value="reactions">
<div class="flex h-screen flex-col">
<div class="flex items-center bg-background px-4 py-2">
<h1 class="text-xl font-bold">Reactions</h1>
<div class="ml-auto">
<ReactionNewDialog />
</div>
</div>
<Separator />
<div class="mt-2 flex flex-1 flex-col gap-2 p-4 pt-0">
<TransitionGroup name="list" appear>
<ResourceListElement
v-for="reaction in reactions"
:key="reaction.id"
:title="reaction.name"
:created="reaction.created"
:subtitle="subtitle(reaction)"
description=""
:active="route.params.id === reaction.id"
:to="{ name: 'reactions', params: { id: reaction.id } }"
:open="false"
>
{{ reaction.name }}
</ResourceListElement>
</TransitionGroup>
</div>
</div>
</TanView>
</template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import ReactionForm from '@/components/reaction/ReactionForm.vue'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogScrollContent,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { pb } from '@/lib/pocketbase'
import type { Reaction, Ticket } from '@/lib/types'
import { handleError } from '@/lib/utils'
const queryClient = useQueryClient()
const router = useRouter()
const isOpen = ref(false)
const addReactionMutation = useMutation({
mutationFn: (values: Reaction): Promise<Reaction> => pb.collection('reactions').create(values),
onSuccess: (data: Ticket) => {
router.push({ name: 'reactions', params: { id: data.id } })
queryClient.invalidateQueries({ queryKey: ['reactions'] })
isOpen.value = false
},
onError: handleError
})
const cancel = () => (isOpen.value = false)
</script>
<template>
<Dialog v-model:open="isOpen">
<DialogTrigger as-child>
<Button variant="ghost">New Reaction</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>New Reaction</DialogTitle>
<DialogDescription>Create a new reaction</DialogDescription>
</DialogHeader>
<DialogScrollContent>
<ReactionForm @submit="addReactionMutation.mutate">
<template #cancel>
<DialogClose as-child>
<Button type="button" variant="secondary">Cancel</Button>
</DialogClose>
</template>
</ReactionForm>
</DialogScrollContent>
</DialogContent>
</Dialog>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import MultiSelect from '@/components/form/MultiSelect.vue'
import { computed } from 'vue'
const modelValue = defineModel<string[]>({
default: []
})
const items = ['Tickets', 'Tasks', 'Comments', 'Timeline', 'Links', 'Files']
const mapping: Record<string, string> = {
tickets: 'Tickets',
tasks: 'Tasks',
comments: 'Comments',
timeline: 'Timeline',
links: 'Links',
files: 'Files'
}
const niceNames = computed(() => modelValue.value.map((collection) => mapping[collection]))
const updateModelValue = (values: string[]) => {
modelValue.value = values.map(
(value) => Object.keys(mapping).find((key) => mapping[key] === value)!
)
}
</script>
<template>
<MultiSelect
:modelValue="niceNames"
@update:modelValue="updateModelValue"
:items="items"
placeholder="Select collections..."
/>
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import MultiSelect from '@/components/form/MultiSelect.vue'
import { computed } from 'vue'
const modelValue = defineModel<string[]>({
default: []
})
const items = ['Create Events', 'Update Events', 'Delete Events']
const mapping: Record<string, string> = {
create: 'Create Events',
update: 'Update Events',
delete: 'Delete Events'
}
const niceNames = computed(() =>
modelValue.value.map((collection) => mapping[collection] as string)
)
const updateModelValue = (values: string[]) => {
modelValue.value = values.map(
(value) => Object.keys(mapping).find((key) => mapping[key] === value)!
)
}
</script>
<template>
<MultiSelect
:modelValue="niceNames"
@update:modelValue="updateModelValue"
:items="items"
placeholder="Select events..."
/>
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import TriggerHookFormFieldCollections from '@/components/reaction/TriggerHookFormFieldCollections.vue'
import TriggerHookFormFieldEvents from '@/components/reaction/TriggerHookFormFieldEvents.vue'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
</script>
<template>
<FormField name="triggerdata.collections" v-slot="{ componentField }" validate-on-input>
<FormItem>
<FormLabel for="collections" class="text-right">Collections</FormLabel>
<FormControl>
<TriggerHookFormFieldCollections id="collections" v-bind="componentField" />
</FormControl>
<FormDescription> Specify the collections to trigger the reaction. </FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField name="triggerdata.events" v-slot="{ componentField }" validate-on-input>
<FormItem>
<FormLabel for="events" class="text-right">Events</FormLabel>
<FormControl>
<TriggerHookFormFieldEvents id="events" v-bind="componentField" />
</FormControl>
<FormDescription> Specify the events to trigger the reaction. </FormDescription>
<FormMessage />
</FormItem>
</FormField>
</template>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
</script>
<template>
<FormField name="triggerdata.token" v-slot="{ componentField }" validate-on-input>
<FormItem>
<FormLabel for="token" class="text-right">Token</FormLabel>
<FormControl>
<Input
id="token"
class="col-span-3"
v-bind="componentField"
placeholder="Enter a token (e.g. 'xyz...')"
/>
</FormControl>
<FormDescription>
Optional. Include an authorization token in the request headers.
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField name="triggerdata.path" v-slot="{ componentField }" validate-on-input>
<FormItem>
<FormLabel for="path" class="text-right">Path</FormLabel>
<FormControl>
<Input
id="path"
class="col-span-3"
v-bind="componentField"
placeholder="Enter a path (e.g. 'action1')"
/>
</FormControl>
<FormDescription> Specify the path to trigger the reaction. </FormDescription>
<FormMessage />
</FormItem>
</FormField>
</template>

View File

@@ -56,13 +56,13 @@ const closeTicketMutation = useMutation({
<Textarea v-model="resolution" placeholder="Closing reason" />
<DialogFooter class="mt-2">
<DialogClose as-child>
<Button type="button" variant="secondary"> Cancel</Button>
</DialogClose>
<DialogFooter class="mt-2 sm:justify-start">
<Button type="button" variant="default" @click="closeTicketMutation.mutate()">
Close
</Button>
<DialogClose as-child>
<Button type="button" variant="secondary">Cancel</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -153,7 +153,7 @@ watch([tab, props.selectedType, page, perPage], () => refetch())
</span>
<div>
<TooltipProvider>
<TooltipProvider :delay-duration="0">
<Tooltip>
<TooltipTrigger as-child>
<Info class="ml-2 size-4 text-muted-foreground" />

View File

@@ -139,11 +139,11 @@ watch(isOpen, () => {
<JSONSchemaFormFields v-model="state" :schema="selectedType.schema" />
<DialogFooter class="mt-4">
<DialogFooter class="mt-4 sm:justify-start">
<Button type="submit"> Save </Button>
<DialogClose as-child>
<Button type="button" variant="secondary">Cancel</Button>
</DialogClose>
<Button type="submit"> Save </Button>
</DialogFooter>
</form>
</DialogContent>

View File

@@ -25,7 +25,7 @@ withDefaults(defineProps<Props>(), {
<Button v-if="!hideAdd" variant="ghost" size="icon" class="h-8 w-8" @click="emit('add')">
<Plus class="size-4" />
<span class="sr-only">Add link</span>
<span class="sr-only">Add item</span>
</Button>
</div>
<Card v-if="$slots.default" class="p-0">

View File

@@ -99,11 +99,11 @@ const save = () => editCommentMutation.mutate()
<DialogTitle>Delete comment</DialogTitle>
<DialogDescription> Are you sure you want to delete this comment?</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogFooter class="sm:justify-start">
<Button @click="deleteCommentMutation.mutate" variant="destructive">Delete</Button>
<DialogClose as-child>
<Button type="button" variant="secondary"> Cancel</Button>
<Button type="button" variant="secondary">Cancel</Button>
</DialogClose>
<Button @click="deleteCommentMutation.mutate" variant="destructive"> Delete </Button>
</DialogFooter>
</DialogContent>
</DropdownMenu>

View File

@@ -65,11 +65,11 @@ function handleFileUpload($event: Event) {
<Input id="file" type="file" class="mt-2" @change="handleFileUpload($event)" />
<DialogFooter class="mt-2">
<DialogClose as-child>
<Button type="button" variant="secondary"> Cancel</Button>
</DialogClose>
<DialogFooter class="mt-2 sm:justify-start">
<Button @click="save">Upload</Button>
<DialogClose as-child>
<Button type="button" variant="secondary">Cancel</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -90,10 +90,7 @@ const change = () => validate({ mode: 'silent' }).then((res) => (submitDisabled.
</FormItem>
</FormField>
<DialogFooter class="mt-2">
<DialogClose as-child>
<Button type="button" variant="secondary"> Cancel</Button>
</DialogClose>
<DialogFooter class="mt-2 sm:justify-start">
<Button
:title="submitDisabled ? 'Please fill out all required fields' : undefined"
:disabled="submitDisabled"
@@ -101,6 +98,9 @@ const change = () => validate({ mode: 'silent' }).then((res) => (submitDisabled.
>
Save
</Button>
<DialogClose as-child>
<Button type="button" variant="secondary"> Cancel</Button>
</DialogClose>
</DialogFooter>
</form>
</DialogContent>

View File

@@ -7,7 +7,6 @@ import TaskAddDialog from '@/components/ticket/task/TaskAddDialog.vue'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { toast } from '@/components/ui/toast'
import { Trash2, User2 } from 'lucide-vue-next'

View File

@@ -120,13 +120,13 @@ const save = () =>
Are you sure you want to delete this timeline item?</DialogDescription
>
</DialogHeader>
<DialogFooter>
<DialogClose as-child>
<Button type="button" variant="secondary"> Cancel</Button>
</DialogClose>
<DialogFooter class="sm:justify-start">
<Button @click="deleteTimelineItemMutation.mutate" variant="destructive">
Delete
</Button>
<DialogClose as-child>
<Button type="button" variant="secondary"> Cancel</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</DropdownMenu>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import {
TagsInputRoot,
type TagsInputRootEmits,
type TagsInputRootProps,
useForwardPropsEmits
} from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<TagsInputRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<TagsInputRootEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<TagsInputRoot
v-bind="forwarded"
:class="
cn(
'flex flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm',
props.class
)
"
>
<slot />
</TagsInputRoot>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { TagsInputInput, type TagsInputInputProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<TagsInputInputProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<TagsInputInput
v-bind="forwardedProps"
:class="cn('min-h-6 flex-1 bg-transparent px-1 text-sm focus:outline-none', props.class)"
/>
</template>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { TagsInputItem, type TagsInputItemProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<TagsInputItemProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<TagsInputItem
v-bind="forwardedProps"
:class="
cn(
'flex h-6 items-center rounded bg-secondary ring-offset-background data-[state=active]:ring-2 data-[state=active]:ring-ring data-[state=active]:ring-offset-2',
props.class
)
"
>
<slot />
</TagsInputItem>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { X } from 'lucide-vue-next'
import { TagsInputItemDelete, type TagsInputItemDeleteProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<TagsInputItemDeleteProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<TagsInputItemDelete
v-bind="forwardedProps"
:class="cn('mr-1 flex rounded bg-transparent', props.class)"
>
<slot>
<X class="h-4 w-4" />
</slot>
</TagsInputItemDelete>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { TagsInputItemText, type TagsInputItemTextProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<TagsInputItemTextProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<TagsInputItemText
v-bind="forwardedProps"
:class="cn('rounded bg-transparent px-2 py-1 text-sm', props.class)"
/>
</template>

View File

@@ -0,0 +1,5 @@
export { default as TagsInput } from './TagsInput.vue'
export { default as TagsInputInput } from './TagsInputInput.vue'
export { default as TagsInputItem } from './TagsInputItem.vue'
export { default as TagsInputItemDelete } from './TagsInputItemDelete.vue'
export { default as TagsInputItemText } from './TagsInputItemText.vue'

View File

@@ -130,3 +130,16 @@ export interface JSONSchema {
>
required?: Array<string>
}
export interface Reaction {
id: string
name: string
trigger: string
triggerdata: any
action: string
actiondata: any
created: string
updated: string
}

View File

@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import DashboardView from '@/views/DashboardView.vue'
import LoginView from '@/views/LoginView.vue'
import ReactionView from '@/views/ReactionView.vue'
import TicketView from '@/views/TicketView.vue'
const router = createRouter({
@@ -11,6 +12,11 @@ const router = createRouter({
path: '/',
redirect: '/dashboard'
},
{
path: '/reactions/:id?',
name: 'reactions',
component: ReactionView
},
{
path: '/dashboard',
name: 'dashboard',

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import ThreeColumn from '@/components/layout/ThreeColumn.vue'
import ReactionDisplay from '@/components/reaction/ReactionDisplay.vue'
import ReactionList from '@/components/reaction/ReactionList.vue'
import { computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { pb } from '@/lib/pocketbase'
const route = useRoute()
const router = useRouter()
const id = computed(() => route.params.id as string)
onMounted(() => {
if (!pb.authStore.model) {
router.push({ name: 'login' })
}
})
</script>
<template>
<ThreeColumn>
<template #list>
<ReactionList />
</template>
<template #single>
<div v-if="!id" class="flex h-full w-full items-center justify-center text-lg text-gray-500">
No reaction selected
</div>
<ReactionDisplay v-else :key="id" :id="id" />
</template>
</ThreeColumn>
</template>

15
ui/ui.go Normal file
View File

@@ -0,0 +1,15 @@
package ui
import (
"embed"
"io/fs"
)
//go:embed dist/*
var ui embed.FS
func UI() fs.FS {
fsys, _ := fs.Sub(ui, "dist")
return fsys
}

42
ui/ui_test.go Normal file
View File

@@ -0,0 +1,42 @@
package ui
import (
"io/fs"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUI(t *testing.T) {
tests := []struct {
name string
wantFiles []string
}{
{
name: "TestUI",
wantFiles: []string{
"index.html",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := UI()
var gotFiles []string
require.NoError(t, fs.WalkDir(got, ".", func(path string, d fs.DirEntry, _ error) error {
if !d.IsDir() {
gotFiles = append(gotFiles, path)
}
return nil
}))
for _, wantFile := range tt.wantFiles {
assert.Contains(t, gotFiles, wantFile)
}
})
}
}