Compare commits
10 Commits
2cca52fcdc
...
b080146017
| Author | SHA1 | Date | |
|---|---|---|---|
| b080146017 | |||
| ee8d917bb6 | |||
| ae61830683 | |||
| 9629cff64c | |||
| 01d0260898 | |||
| 6dfd99dc88 | |||
| 4b298fa332 | |||
| afd7c629e5 | |||
| 4618db3aad | |||
| 1d7c55f773 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -22,3 +22,5 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
coverage
|
||||
|
||||
90
\
90
\
@@ -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>
|
||||
@@ -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
815
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
141
src/components/EntryForm.test.ts
Normal file
141
src/components/EntryForm.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
71
src/components/EntryForm.vue
Normal file
71
src/components/EntryForm.vue
Normal 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>
|
||||
73
src/components/EntryList.test.ts
Normal file
73
src/components/EntryList.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
35
src/components/EntryList.vue
Normal file
35
src/components/EntryList.vue
Normal 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>
|
||||
14
src/components/ui/dialog/Dialog.vue
Normal file
14
src/components/ui/dialog/Dialog.vue
Normal 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>
|
||||
11
src/components/ui/dialog/DialogClose.vue
Normal file
11
src/components/ui/dialog/DialogClose.vue
Normal 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>
|
||||
50
src/components/ui/dialog/DialogContent.vue
Normal file
50
src/components/ui/dialog/DialogContent.vue
Normal 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>
|
||||
24
src/components/ui/dialog/DialogDescription.vue
Normal file
24
src/components/ui/dialog/DialogDescription.vue
Normal 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>
|
||||
19
src/components/ui/dialog/DialogFooter.vue
Normal file
19
src/components/ui/dialog/DialogFooter.vue
Normal 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>
|
||||
16
src/components/ui/dialog/DialogHeader.vue
Normal file
16
src/components/ui/dialog/DialogHeader.vue
Normal 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>
|
||||
59
src/components/ui/dialog/DialogScrollContent.vue
Normal file
59
src/components/ui/dialog/DialogScrollContent.vue
Normal 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>
|
||||
29
src/components/ui/dialog/DialogTitle.vue
Normal file
29
src/components/ui/dialog/DialogTitle.vue
Normal 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>
|
||||
11
src/components/ui/dialog/DialogTrigger.vue
Normal file
11
src/components/ui/dialog/DialogTrigger.vue
Normal 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>
|
||||
9
src/components/ui/dialog/index.ts
Normal file
9
src/components/ui/dialog/index.ts
Normal 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
56
src/data/entries.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
1
src/shims-vue.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module '*.vue'
|
||||
4
src/state/entry.ts
Normal file
4
src/state/entry.ts
Normal 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)))
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user