Compare commits

...

10 Commits

25 changed files with 1654 additions and 208 deletions

2
.gitignore vendored
View File

@@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
coverage

90
\
View File

@@ -1,90 +0,0 @@
<script setup lang="ts">
import { Table, TableCaption, TableHeader, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { Button } from './components/ui/button';
import { Plus } from 'lucide-vue-next';
import { entries } from './data/entries';
import { Drawer, DrawerHeader, DrawerTitle, DrawerTrigger, DrawerContent, DrawerFooter } from './components/ui/drawer';
import { Form, FormItem, FormLabel, FormField, FormControl} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from './components/ui/textarea';
import { toTypedSchema } from '@vee-validate/zod';
import { useForm } from 'vee-validate';
import * as z from 'zod'
import moment, { Moment } from 'moment';
const createEntrySchema = toTypedSchema(z.object({
name: z.string()
text: z.string()
}))
function getDifferenceToToday(date: Moment) {
return Math.abs(date.diff(moment(), 'days'))
}
function createEntry(values) {
console.log(values)
}
</script>
<template>
<main class="flex justify-center">
<div class="wrapper sm:w-3/4 w-11/12">
<Table>
<TableCaption>List of Entries</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Last Accident</TableHead>
</TableRow>
</TableHeader>
<TableRow v-for="entry in entries">
<TableCell> {{ entry.name }} </TableCell>
<TableCell> {{ getDifferenceToToday(entry.last_reset) }} </TableCell>
</TableRow>
</Table>
</div>
</main>
<Drawer>
<DrawerTrigger as-child>
<div>
<Button variant="outline" size="icon" class="w-20 h-20 fixed right-10 bottom-10">
<Plus />
</Button>
</div>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Create new Entry</DrawerTitle>
</DrawerHeader>
<div id="content" class="p-5">
<Form @submit="createEntry">
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormControl>
<Input placeholder="Name" v-bind="componentField" />
</FormControl>
<FormDescription />
<FormMessage />
</FormItem>
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea placeholder="Text" v-bind="componentField" />
</FormControl>
<FormDescription />
<FormMessage />
</FormItem>
</FormField>
</Form>
</div>
<DrawerFooter>
<Button>Create</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
</template>
<style scoped></style>

View File

@@ -11,7 +11,9 @@
"build:sw": "workbox generateSW workbox-config.js"
},
"dependencies": {
"@faker-js/faker": "^8.4.1",
"@vee-validate/zod": "^4.12.5",
"@vue/test-utils": "^2.4.4",
"@vueuse/core": "^10.9.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
@@ -30,12 +32,16 @@
"devDependencies": {
"@types/node": "^20.11.24",
"@vitejs/plugin-vue": "^5.0.4",
"@vitest/coverage-istanbul": "^1.3.1",
"@vitest/ui": "^1.3.1",
"autoprefixer": "^10.4.18",
"jsdom": "^24.0.0",
"shadcn-vue": "^0.9.0",
"tailwindcss": "^3.4.1",
"typescript": "^5.2.2",
"vite": "^5.1.4",
"vite-plugin-pwa": "^0.19.0",
"vitest": "^1.3.1",
"vue-tsc": "^1.8.27"
}
}

815
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,141 @@
import { faker } from "@faker-js/faker";
import { describe, expect, it } from "vitest";
import { mount } from '@vue/test-utils'
import EntryForm from './EntryForm.vue'
import { Entry } from "@/data/entries";
describe('Entry Form tests', () => {
function getFormFields(component: ReturnType<typeof mount>) {
return {
nameField: component.get('input'),
textField: component.get('textarea')
}
}
function getSampleValues() {
return {
name: faker.word.noun(),
text: faker.lorem.paragraph()
}
}
function getAdditionalElements(component: ReturnType<typeof mount>, ...ids: string[]) {
const arr = []
for (const id of ids) {
arr.push(component.find(id))
}
return arr;
}
it('takes in values for name and title and displays them in the respected fields', () => {
const { name, text } = getSampleValues()
const component = mount(EntryForm, {
props: {
action: 'edit', inputEntry: <Entry>{
name, text
}
}
})
const { nameField, textField } = getFormFields(component)
expect(nameField.element.value).toBe(name)
expect(textField.element.value).toBe(text)
})
it('takes no entry data and displays empty fields', () => {
const component = mount(EntryForm, {
props: {
action: 'edit'
}
})
const { nameField, textField } = getFormFields(component)
expect(nameField.element.value).toBe('')
expect(textField.element.value).toBe('')
})
it('takes no action and displays create on the Button', () => {
const component = mount(EntryForm)
const button = component.find('button')
expect(button.text()).toBe('Create')
})
it('takes create action and displays Create on the Button', () => {
const component = mount(EntryForm, { props: { action: 'create' } })
const button = component.find('button')
expect(button.text()).toBe('Create')
})
it('takes edit action and displays Edit on the Button', () => {
const component = mount(EntryForm, { props: { action: 'edit' } })
const button = component.find('button')
expect(button.text()).toBe('Edit')
})
it('displays nothing when the form is valid and processed', async () => {
const { name, text } = getSampleValues()
const component = mount(EntryForm)
const { nameField, textField } = getFormFields(component)
const [button] = getAdditionalElements(component, 'button')
await nameField.setValue(name)
await textField.setValue(text)
await button.trigger('click')
const errorSpans = component.findAll('[data-test="error-msg"]')
expect(errorSpans.length).toBe(0)
})
it('displays that the name is missing when no name is entered', async () => {
const { text } = getSampleValues()
const component = mount(EntryForm)
const { textField } = getFormFields(component)
const [button] = getAdditionalElements(component, 'button')
textField.element.value = text
await button.trigger('click')
const errorSpans = component.findAll('[data-test="error-msg"]')
expect(errorSpans.length).toBe(1)
expect(errorSpans[0].text()).toBe('* name is required')
})
it('displays error that the name is not unique when there is another entry in the passed entries with the same name', async () => {
const { text } = getSampleValues()
const component = mount(EntryForm, {props: {entries: [<Entry>{name: 'something', text: 'something else'}]}})
const { textField, nameField } = getFormFields(component)
const [button] = getAdditionalElements(component, 'button')
await textField.setValue(text)
await nameField.setValue('something')
await button.trigger('click')
const errorSpans = component.findAll('[data-test="error-msg"]')
expect(errorSpans.length).toBe(1)
expect(errorSpans[0].text()).toBe('* name must be unique')
})
it('fires submit when there is no issue with the inputted data', async() => {
const { text } = getSampleValues()
const component = mount(EntryForm, {props: {entries: [<Entry>{name: 'something', text: 'something else'}]}})
const { textField, nameField } = getFormFields(component)
const [button] = getAdditionalElements(component, 'button')
await textField.setValue(text)
await nameField.setValue('something else')
await button.trigger('click')
const errorSpans = component.findAll('[data-test="error-msg"]')
expect(errorSpans.length).toBe(0)
expect(component.emitted('submit')).toBeTruthy()
})
})

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import * as z from 'zod'
import { Input } from '@/components/ui/input';
import { Button } from './ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Entry } from '@/data/entries';
import { Ref, ref } from 'vue';
const emit = defineEmits<{
submit: [value: CreateEntrySchema]
}>()
const props = withDefaults(defineProps<{
action?: 'create' | 'edit'
inputEntry?: Entry | undefined
entries?: Entry[]
}>(), {
action: 'create',
})
const nameField = ref(props.inputEntry ? props.inputEntry.name : '')
const textField = ref(props.inputEntry ? props.inputEntry.text : '')
const issues: Ref<Array<string>> = ref([])
const createEntrySchema = z.object({
name: z.string().trim().min(1, { message: 'name is required' }),
text: z.string().trim().optional()
})
export type CreateEntrySchema = z.infer<typeof createEntrySchema>
function submit() {
const result = createEntrySchema.safeParse({
name: nameField.value,
text: textField.value
})
if (result.success) {
const isUnique = props.entries === undefined
|| !props.entries.map(entry => entry.name).includes(result.data.name)
if (isUnique) {
emit('submit', result.data)
return
}
issues.value = ['name must be unique']
} else {
issues.value = result.error.issues.map(issue => issue.message)
}
}
</script>
<template>
<div class="flex gap-3 flex-col">
<div >
<span v-for="issue of issues" data-test="error-msg" class="text-red-800"> * {{ issue }}</span>
</div>
<Input placeholder="Name" v-model:model-value="nameField" />
<Textarea placeholder="Text" v-model:model-value="textField" />
<Button class="w-full" v-if="props.action === 'create'" @click="submit()">Create</Button>
<Button class="w-full" v-else @click="submit()">Edit</Button>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,73 @@
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import { generateList } from "@/data/entries.test";
import { Entry } from "@/data/entries";
import { faker } from "@faker-js/faker";
import moment from "moment";
import EntryList from '@/components/EntryList.vue'
describe('entry list tests', () => {
function mountListWithSampelData(dataAmount: number): { component: ReturnType<typeof mount>, entries: Entry[] } {
const sampleData = generateList(dataAmount, () => <Entry>{
name: faker.word.noun(),
text: faker.lorem.paragraph(),
last_reset: moment()
})
return {
component: mount(EntryList, {
props: {
entries: sampleData
}
}),
entries: sampleData
}
}
it('displays no entry when the inputted list is empty', () => {
const { component } = mountListWithSampelData(0)
const entryButtons = component.findAll('[test-data="entry-button"]')
expect(entryButtons.length).toBe(0)
})
it('displays no entry when 10 Entries are passed to the component', () => {
const { component } = mountListWithSampelData(10)
const entryButtons = component.findAll('[test-data="entry-button"]')
expect(entryButtons.length).toBe(10)
})
it('includes the content of the passed list', () => {
const { component, entries } = mountListWithSampelData(10)
const entryButtons = component.findAll('[test-data="entry-button"]')
const names = entryButtons.map(button => button.text())
for (let entry of entries) {
expect(names).toContain(entry.name + '0 days')
}
})
it('triggers event openDetail when one of the Buttons is pressed', () => {
const { component, entries } = mountListWithSampelData(10)
const entryButtons = component.findAll('[test-data="entry-button"]')
const names = entries.map(entry => entry.name)
for (let i = 0; i < entryButtons.length; i++) {
entryButtons[i].trigger('click')
}
expect(component.emitted('openDetail')).toBeTruthy()
expect(component.emitted('openDetail')).toBeDefined()
expect(component.emitted('openDetail')).toHaveLength(10)
for (let name of names) {
// force unwrap because value is allrady asserted to be defined
expect(component.emitted('openDetail')!.map(emit => emit[0]))
.toContain(name)
}
})
})

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { Entry, getDifferenceToToday } from '@/data/entries';
import { Button } from './ui/button';
import { ScrollArea } from './ui/scroll-area';
import { Badge } from './ui/badge';
defineProps<{
entries: Entry[]
}>()
const emit = defineEmits<{
openDetail: [value: string] // named tuple syntax
}>()
function openDetailWith(name: string) {
emit('openDetail', name)
}
</script>
<template>
<ScrollArea class="w-full">
<div class="w-full flex justify-center flex-col items-center">
<header id="title" class="flex w-full h-56 justify-center items-center flex-row">Accident Board</header>
<main class="wrapper sm:w-3/4 w-11/12 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
<Button v-for="entry in entries" class="w-full p-10 flex flex-col items-center gap-2"
test-data="entry-button"
@click="openDetailWith(entry.name)">
<span>{{ entry.name }}</span>
<Badge variant="secondary">{{ getDifferenceToToday(entry.last_reset) }} days</Badge>
</Button>
</main>
</div>
</ScrollArea>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import { DialogRoot, type DialogRootEmits, type DialogRootProps, useForwardPropsEmits } from 'radix-vue'
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot v-bind="forwarded">
<slot />
</DialogRoot>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import { DialogClose, type DialogCloseProps } from 'radix-vue'
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose v-bind="props">
<slot />
</DialogClose>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from 'vue'
import {
DialogClose,
DialogContent,
type DialogContentEmits,
type DialogContentProps,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from 'radix-vue'
import { X } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<DialogContent
v-bind="forwarded"
:class="
cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
props.class,
)"
>
<slot />
<DialogClose
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
>
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from 'vue'
import { DialogDescription, type DialogDescriptionProps, useForwardProps } from 'radix-vue'
import { cn } from '@/lib/utils'
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogDescription
v-bind="forwardedProps"
:class="cn('text-sm text-muted-foreground', props.class)"
>
<slot />
</DialogDescription>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div
:class="
cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="cn('flex flex-col gap-y-1.5 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from 'vue'
import {
DialogClose,
DialogContent,
type DialogContentEmits,
type DialogContentProps,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from 'radix-vue'
import { X } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="forwarded"
@pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
>
<slot />
<DialogClose
class="absolute top-3 right-3 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from 'vue'
import { DialogTitle, type DialogTitleProps, useForwardProps } from 'radix-vue'
import { cn } from '@/lib/utils'
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogTitle
v-bind="forwardedProps"
:class="
cn(
'text-lg font-semibold leading-none tracking-tight',
props.class,
)
"
>
<slot />
</DialogTitle>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import { DialogTrigger, type DialogTriggerProps } from 'radix-vue'
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger v-bind="props">
<slot />
</DialogTrigger>
</template>

View File

@@ -0,0 +1,9 @@
export { default as Dialog } from './Dialog.vue'
export { default as DialogClose } from './DialogClose.vue'
export { default as DialogTrigger } from './DialogTrigger.vue'
export { default as DialogHeader } from './DialogHeader.vue'
export { default as DialogTitle } from './DialogTitle.vue'
export { default as DialogDescription } from './DialogDescription.vue'
export { default as DialogContent } from './DialogContent.vue'
export { default as DialogScrollContent } from './DialogScrollContent.vue'
export { default as DialogFooter } from './DialogFooter.vue'

56
src/data/entries.test.ts Normal file
View File

@@ -0,0 +1,56 @@
import { expect, it } from "vitest"
import { describe } from "vitest"
import { faker } from '@faker-js/faker'
import { parseFromPossibleString } from "./entries"
import moment from "moment"
export function generateList<E>(length: number, generate: () => E) {
const arr: Array<E> = []
for (let i = 0; i < length; i++) {
arr.push(generate());
}
return arr;
}
describe('function for managing entries data entity', () => {
function generateListWithWrongDate() {
return generateList(10, () => <any>{
name: faker.word.noun(),
text: faker.lorem.paragraph(),
last_reset: faker.word.sample()
})
}
function generateListWithNoName() {
return generateList(10, () => <any>{
text: faker.lorem.paragraph(),
last_reset: moment()
})
}
function generateListWithCorrectData() {
return generateList(10, () => <any>{
name: faker.word.noun(),
text: faker.lorem.paragraph(),
last_reset: moment()
})
}
it('fails to parse localstorage string because of wrong date', () => {
const data = generateListWithWrongDate()
expect(parseFromPossibleString(JSON.stringify(data)).length).toBe(0)
})
it('fails to parse localstorage string because no name was parsed', () => {
const data = generateListWithNoName()
expect(parseFromPossibleString(JSON.stringify(data)).length).toBe(0)
})
it('succeeds to parse localstorage string witht he correct data', () => {
const data = generateListWithCorrectData()
expect(parseFromPossibleString(JSON.stringify(data)).length).toBe(10)
})
})

View File

@@ -1,39 +1,46 @@
import moment, { Moment } from "moment";
import { Ref, ref } from "vue";
const localStorageKey = 'entries'
export const localStorageKey = 'entries'
export function getDifferenceToToday(date: Moment) {
return Math.abs(date.diff(moment(), 'days'))
return Math.abs(date.diff(moment(), 'days'))
}
export const entries: Ref<Entry[]> = ref(parseFromPossibleString(localStorage.getItem(localStorageKey)))
export interface Entry {
name: string
text: string | undefined
last_reset: Moment
name: string
text: string | undefined
last_reset: Moment
}
export function parseFromPossibleString(input: string | null): Entry[] {
if (input === null) {
return []
}
if (input === null) {
return []
}
const entries: Entry[] = []
const rawObjects: any[] = JSON.parse(input)
const entries: Entry[] = []
const rawObjects: any[] = JSON.parse(input)
for (const rawObject of rawObjects) {
const { name, text, last_reset } = rawObject
for (const rawObject of rawObjects) {
const { name, text, last_reset } = rawObject
const date = parseDate(last_reset)
if (name && last_reset) {
entries.push({ name, text, last_reset: moment(last_reset) })
}
}
if (date && name) {
entries.push({ name, text, last_reset: moment(last_reset) })
}
return entries
}
return entries
}
export function save() {
localStorage.setItem(localStorageKey, JSON.stringify(entries.value))
export function parseDate(dateString: string) {
const date = moment(dateString)
if (!date.isValid()) {
return undefined
}
return date
}
export function save(entries: Entry[]) {
localStorage.setItem(localStorageKey, JSON.stringify(entries))
}

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { Card, CardTitle, CardDescription } from '@/components/ui/card';
import { entries, getDifferenceToToday } from '@/data/entries';
import { entries } from '@/state/entry';
import { getDifferenceToToday, save } from '@/data/entries';
import { ArrowBigLeft, MenuIcon, TrashIcon } from 'lucide-vue-next';
import {
DropdownMenu,
@@ -8,13 +9,65 @@ import {
DropdownMenuItem,
DropdownMenuContent
} from '@/components/ui/dropdown-menu';
import { Drawer, DrawerHeader, DrawerTitle, DrawerContent, DrawerDescription } from '@/components/ui/drawer';
import { Dialog, DialogHeader, DialogTitle, DialogContent, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { toast } from 'vue-sonner';
import moment from 'moment';
import { useMediaQuery } from '@vueuse/core';
import { CreateEntrySchema } from '@/components/EntryForm.vue';
import EntryForm from '@/components/EntryForm.vue';
const probs = defineProps<{ name: string }>()
const router = useRouter()
const entry = ref(entries.value.find(entry => entry.name === probs.name))
const confirmDialogState = ref(false)
const isDesktop = useMediaQuery('(min-width: 768px)')
const editEntryDialog = ref(false)
function deleteEntry() {
if (entry.value === undefined) {
toast('Deletion failed')
return
}
const index = entries.value.indexOf(entry.value)
if (index === undefined) {
toast('Deletion failed')
return
}
entries.value.splice(index, 1)
save(entries.value)
router.back()
}
function resetDate() {
if (entry.value === undefined) {
toast('reset failed')
return
}
entry.value.last_reset = moment()
confirmDialogState.value = false
save(entries.value)
}
function editEntry(val: CreateEntrySchema) {
console.log(val)
if (entry.value === undefined) {
toast('edit failed')
return
}
entry.value.name = val.name
entry.value.text = val.text
editEntryDialog.value = false
save(entries.value)
}
</script>
@@ -41,17 +94,63 @@ const entry = ref(entries.value.find(entry => entry.name === probs.name))
</DropdownMenuTrigger>
<DropdownMenuContent class="mr-2">
<DropdownMenuItem>
<DropdownMenuItem @click="() => deleteEntry()">
<TrashIcon color="#ef4444"/>
<span color="#ef4444">Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</nav>
<div id="content" class="w-full flex flex-col gap-5 items-center mt-20">
<Button class="w-full">Edit</Button>
<Button class="w-full" variant="destructive">Reset</Button>
<Button class="w-full" @click="editEntryDialog = true">Edit</Button>
<Button class="w-full" variant="destructive" @click="confirmDialogState = true">Reset</Button>
</div>
<Dialog v-if="isDesktop" v-model:open="confirmDialogState">
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Reset</DialogTitle>
<DialogDescription> Are you sure you want to reset the counter ? </DialogDescription>
<Button variant="destructive" @click="resetDate">Confirm Reset</Button>
</DialogHeader>
</DialogContent>
</Dialog>
<Drawer v-else v-model:open="confirmDialogState">
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Confirm Reset</DrawerTitle>
<DrawerDescription> Are you sure you want to reset the counter ? </DrawerDescription>
<Button variant="destructive" @click="resetDate">Confirm Reset</Button>
</DrawerHeader>
</DrawerContent>
</Drawer>
<Dialog v-if="isDesktop" v-model:open="editEntryDialog">
<DialogContent>
<DialogHeader>
<DialogTitle> Edit Entry </DialogTitle>
</DialogHeader>
<div class="p-5">
<EntryForm action="edit" @submit="(val) => editEntry(val)" :input-entry="entry"> </EntryForm>
</div>
</DialogContent>
</Dialog>
<Drawer v-else v-model:open="editEntryDialog">
<DrawerContent>
<DrawerHeader>
<DrawerTitle> Edit Entry </DrawerTitle>
</DrawerHeader>
<div class="p-5">
<EntryForm action="edit" @submit="(val) => editEntry(val)" :input-entry="entry"> </EntryForm>
</div>
</DrawerContent>
</Drawer>
</main>
</template>

View File

@@ -1,116 +1,99 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button';
import { Plus } from 'lucide-vue-next';
import { entries, save } from '@/data/entries';
import { Drawer, DrawerHeader, DrawerTitle, DrawerTrigger, DrawerContent, DrawerFooter, DrawerClose } from '@/components/ui/drawer';
import { Form, FormItem, FormLabel, FormField, FormControl, FormMessage, FormDescription } from '@/components/ui/form';
import { entries } from '@/state/entry'
import { save } from '@/data/entries';
import { Drawer, DrawerHeader, DrawerTitle, DrawerContent } from '@/components/ui/drawer';
import { Dialog, DialogHeader, DialogTitle, DialogContent } from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { ScrollArea } from '@/components/ui/scroll-area';
import EntryForm from '@/components/EntryForm.vue'
import { CreateEntrySchema } from '@/components/EntryForm.vue'
import { getDifferenceToToday } from '@/data/entries';
import { toTypedSchema } from '@vee-validate/zod';
import * as z from 'zod'
import { useRouter } from 'vue-router';
import { toast } from 'vue-sonner';
import moment from 'moment';
import { ref } from 'vue';
import { useMediaQuery } from '@vueuse/core';
const isDesktop = useMediaQuery('(min-width: 768px)')
const createDrawerState = ref(false)
const router = useRouter()
const createEntryZodSchema = z.object({
name: z.string(),
text: z.string().optional()
})
type CreateEntrySchema = z.infer<typeof createEntryZodSchema>
async function createEntry(value: CreateEntrySchema) {
if (entries.value.map(entry => entry.name).includes(value.name)) {
toast('Accident Entry allready exists', {
important: true,
description: 'please use a unique name'
})
const createEntrySchema = toTypedSchema(createEntryZodSchema)
return
}
entries.value.push({
last_reset: moment(),
name: value.name,
text: value.text && value.text.trim() !== ''
? value.text : undefined
})
function createEntry(value: CreateEntrySchema) {
if (entries.value.map(entry => entry.name).includes(value.name)) {
toast('Accident Entry allready exists', {
important: true,
description: 'please use a unique name'
})
return
}
entries.value.push({
last_reset: moment(),
name: value.name,
text: value.text && value.text.trim() !== ''
? value.text : undefined
})
save()
save(entries.value)
createDrawerState.value = false
}
function openDetailWithName(name: string) {
console.log('opening', name)
router.push({
path: `${name}`,
})
console.log('opening', name)
router.push({
path: `${name}`,
})
}
</script>
<template>
<ScrollArea class="w-full">
<div class="w-full flex justify-center flex-col items-center">
<header id="title" class="flex w-full h-56 justify-center items-center flex-row">Accident Board</header>
<main class="wrapper sm:w-3/4 w-11/12 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
<Button v-for="entry in entries" class="w-full p-10 flex flex-col items-center gap-2"
@click="openDetailWithName(entry.name)">
<span>{{ entry.name }}</span>
<Badge variant="secondary">{{ getDifferenceToToday(entry.last_reset) }} days</Badge>
</Button>
</main>
</div>
</ScrollArea>
<ScrollArea class="w-full">
<div class="w-full flex justify-center flex-col items-center">
<header id="title" class="flex w-full h-56 justify-center items-center flex-row">Accident Board</header>
<main class="wrapper sm:w-3/4 w-11/12 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
<Button v-for="entry in entries" class="w-full p-10 flex flex-col items-center gap-2"
@click="openDetailWithName(entry.name)">
<span>{{ entry.name }}</span>
<Badge variant="secondary">{{ getDifferenceToToday(entry.last_reset) }} days</Badge>
</Button>
</main>
</div>
</ScrollArea>
<Drawer>
<DrawerTrigger as-child>
<div class="fixed bottom-0 w-full h-28 flex justify-center items-center">
<Button size="icon" class="h-20 sm:w-3/4 w-11/12">
<Plus />
</Button>
</div>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Create new Entry</DrawerTitle>
</DrawerHeader>
<div id="content" class="p-5">
<Form :validation-schema="createEntrySchema" @submit="(val) => createEntry(val as CreateEntrySchema)">
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormControl>
<Input placeholder="Name" v-bind="componentField" />
</FormControl>
<FormMessage/>
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="text">
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea placeholder="Text" v-bind="componentField" />
</FormControl>
<FormDescription />
<FormMessage />
</FormItem>
</FormField>
<DrawerFooter>
<DrawerClose>
<Button type="submit">Create</Button>
</DrawerClose>
</DrawerFooter>
</Form>
</div>
</DrawerContent>
</Drawer>
<div id="placeholder" class="h-32"></div>
<div class="fixed bottom-0 w-full h-28 p-5 flex justify-center md:justify-end items-center shadow-2xl shadow-black bg-white md:shadow-transparent md:bg-transparent">
<Button size="icon" class="h-20 md:w-20 w-11/12" @click="createDrawerState = true">
<Plus />
</Button>
</div>
<Dialog v-if="isDesktop" v-model:open="createDrawerState">
<DialogContent>
<DialogHeader>
<DialogTitle> Create new Entry </DialogTitle>
</DialogHeader>
<div class="p-5">
<EntryForm action="create" @submit="(val) => createEntry(val)" :entries="entries"> </EntryForm>
</div>
</DialogContent>
</Dialog>
<Drawer v-else v-model:open="createDrawerState">
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Create new Entry</DrawerTitle>
</DrawerHeader>
<div class="p-5">
<EntryForm action="create" @submit="(val) => createEntry(val)" :entries="entries"> </EntryForm>
</div>
</DrawerContent>
</Drawer>
</template>
<style scoped></style>

1
src/shims-vue.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module '*.vue'

4
src/state/entry.ts Normal file
View File

@@ -0,0 +1,4 @@
import { Entry, localStorageKey, parseFromPossibleString } from "@/data/entries";
import { Ref, ref } from "vue";
export const entries: Ref<Entry[]> = ref(parseFromPossibleString(localStorage.getItem(localStorageKey)))

View File

@@ -1,4 +1,4 @@
import { defineConfig } from 'vite'
import { defineConfig } from 'vitest/config'
import { VitePWA } from 'vite-plugin-pwa'
import vue from '@vitejs/plugin-vue'
import tailwind from 'tailwindcss'
@@ -7,6 +7,13 @@ import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
coverage: {
provider: 'istanbul'
}
},
plugins: [vue(), VitePWA({
registerType: 'autoUpdate',
manifest: {