Compare commits

..

22 Commits

Author SHA1 Message Date
2279108f31 Update README.md 2025-10-24 22:22:46 +02:00
bdd3c8755b Update README.md 2025-10-24 22:22:19 +02:00
335a81bf9d updated image 2025-10-24 22:21:17 +02:00
926a4c7a66 add image for readme 2025-10-24 22:14:06 +02:00
789c90ed63 added basic readme 2025-10-24 21:54:47 +02:00
0a5fbc92c7 added base path to the vue router 2025-10-24 19:17:55 +02:00
92f6da672b adjusted github actions to use a base path 2025-10-24 19:02:31 +02:00
a2cd25bf5f migrated to bun and also updated the gh actions 2025-10-24 18:59:09 +02:00
65473a085a incremented version of gh pages action 2025-10-24 18:48:08 +02:00
10c846486e added permissions for the gh pages action 2025-10-24 18:43:20 +02:00
e74f56c4aa removed gitlab ci file 2025-10-24 18:30:43 +02:00
e955d9b94d added gh actions for deploying to gh pages
Some checks failed
Deploy to GitHub Pages / deploy (push) Has been cancelled
2025-10-24 18:29:36 +02:00
b080146017 added test coverage tool 2024-03-14 09:32:56 +01:00
ee8d917bb6 added Test for the Entry List 2024-03-14 09:21:19 +01:00
ae61830683 wip Entry List Tests 2024-03-13 20:43:48 +01:00
9629cff64c finished entry form tests 2024-03-13 19:43:05 +01:00
01d0260898 added some ui tests still wip 2024-03-13 13:09:54 +01:00
6dfd99dc88 test work in progress 2024-03-12 20:58:13 +01:00
4b298fa332 removed \ directory 2024-03-08 19:54:32 +01:00
afd7c629e5 editing now and resetting works 2024-03-08 16:34:51 +01:00
4618db3aad now the app is responsive for desktop 2024-03-08 14:59:20 +01:00
1d7c55f773 removing entries works extracted formular into component 2024-03-08 14:39:29 +01:00
31 changed files with 954 additions and 5067 deletions

BIN
.github/Board.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

38
.github/workflows/main.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: Deploy to GitHub Pages
on:
push:
branches: ["main"] # Replace with your default branch if different
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install
- name: Build
run: bun run build --base=/dwa/
- name: Upload Artifact
uses: actions/upload-pages-artifact@v3
with:
path: dist
deploy:
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

2
.gitignore vendored
View File

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

View File

@@ -1,18 +0,0 @@
# The Docker image that will be used to build your app
image: node:lts
# Functions that should be executed before the build script is run
before_script:
- npm install
pages:
script:
- npx vite build
- rm -rf public
- mv dist public
artifacts:
paths:
# The folder that contains the files to be exposed at the Page URL
- public
rules:
# This ensures that only pushes to the default branch will trigger
# a pages deploy
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH

View File

@@ -1,18 +1,38 @@
# Vue 3 + TypeScript + Vite # DWA
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more. ![](.github/Board.png)
## Recommended IDE Setup This is a motivational app, where you can track how long you went without accident. You can reset your progress when you have an accident.
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin). ## Installation
## Type Support For `.vue` Imports in TS You can just visit [https://quirinecker.github.io/dwa/](https://quirinecker.github.io/dwa/) and install the PWA on your device via chrome(android)/safari(ios).
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types. ## Development
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps: The project is a Vue3/bun/vite project, so the instructions are relatively straight forward.
### Install dependencies
```bash
bun install
```
### Run the development server
```bash
bun run dev
```
### Build the application
```bash
bun run build
```
### Run the tests
```bash
bun run test
```
1. Disable the built-in TypeScript Extension
1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.

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>

BIN
bun.lockb Executable file

Binary file not shown.

View File

@@ -1,41 +1,46 @@
{ {
"name": "dwa", "name": "dwa",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build:app": "vue-tsc && vite build", "build": "vite build",
"build": "pnpm build:app && pnpm build:sw", "preview": "vite preview",
"preview": "vite preview", "build:sw": "workbox generateSW workbox-config.js"
"build:sw": "workbox generateSW workbox-config.js" },
}, "dependencies": {
"dependencies": { "@faker-js/faker": "^8.4.1",
"@vee-validate/zod": "^4.12.5", "@vee-validate/zod": "^4.12.5",
"@vueuse/core": "^10.9.0", "@vue/test-utils": "^2.4.4",
"class-variance-authority": "^0.7.0", "@vueuse/core": "^10.9.0",
"clsx": "^2.1.0", "class-variance-authority": "^0.7.0",
"lucide-vue-next": "^0.344.0", "clsx": "^2.1.0",
"moment": "^2.30.1", "lucide-vue-next": "^0.344.0",
"radix-vue": "^1.4.9", "moment": "^2.30.1",
"tailwind-merge": "^2.2.1", "radix-vue": "^1.4.9",
"tailwindcss-animate": "^1.0.7", "tailwind-merge": "^2.2.1",
"vaul-vue": "^0.1.0", "tailwindcss-animate": "^1.0.7",
"vee-validate": "^4.12.5", "vaul-vue": "^0.1.0",
"vue": "^3.4.19", "vee-validate": "^4.12.5",
"vue-router": "^4.3.0", "vue": "^3.4.19",
"vue-sonner": "^1.1.2", "vue-router": "^4.3.0",
"zod": "^3.22.4" "vue-sonner": "^1.1.2",
}, "zod": "^3.22.4"
"devDependencies": { },
"@types/node": "^20.11.24", "devDependencies": {
"@vitejs/plugin-vue": "^5.0.4", "@types/node": "^20.11.24",
"autoprefixer": "^10.4.18", "@vitejs/plugin-vue": "^5.0.4",
"shadcn-vue": "^0.9.0", "@vitest/coverage-istanbul": "^1.3.1",
"tailwindcss": "^3.4.1", "@vitest/ui": "^1.3.1",
"typescript": "^5.2.2", "autoprefixer": "^10.4.18",
"vite": "^5.1.4", "jsdom": "^24.0.0",
"vite-plugin-pwa": "^0.19.0", "shadcn-vue": "^0.9.0",
"vue-tsc": "^1.8.27" "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"
}
} }

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

View File

@@ -8,11 +8,11 @@ import Detail from '@/pages/Detail.vue'
const app = createApp(App) const app = createApp(App)
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(import.meta.env.BASE_URL),
routes: [ routes: [
{ path: '/', component: Home}, { path: '/', component: Home },
{ path: '/:name', component: Detail, props: true} { path: '/:name', component: Detail, props: true }
] ]
}) })
app.use(router) app.use(router)

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Card, CardTitle, CardDescription } from '@/components/ui/card'; 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 { ArrowBigLeft, MenuIcon, TrashIcon } from 'lucide-vue-next';
import { import {
DropdownMenu, DropdownMenu,
@@ -8,13 +9,65 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuContent DropdownMenuContent
} from '@/components/ui/dropdown-menu'; } 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 { Button } from '@/components/ui/button';
import { ref } from 'vue'; import { ref } from 'vue';
import { useRouter } from 'vue-router'; 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 probs = defineProps<{ name: string }>()
const router = useRouter() const router = useRouter()
const entry = ref(entries.value.find(entry => entry.name === probs.name)) 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> </script>
@@ -41,17 +94,63 @@ const entry = ref(entries.value.find(entry => entry.name === probs.name))
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent class="mr-2"> <DropdownMenuContent class="mr-2">
<DropdownMenuItem> <DropdownMenuItem @click="() => deleteEntry()">
<TrashIcon color="#ef4444"/> <TrashIcon color="#ef4444"/>
<span color="#ef4444">Delete</span> <span color="#ef4444">Delete</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</nav> </nav>
<div id="content" class="w-full flex flex-col gap-5 items-center mt-20"> <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" @click="editEntryDialog = true">Edit</Button>
<Button class="w-full" variant="destructive">Reset</Button> <Button class="w-full" variant="destructive" @click="confirmDialogState = true">Reset</Button>
</div> </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> </main>
</template> </template>

View File

@@ -1,116 +1,99 @@
<script setup lang="ts"> <script setup lang="ts">
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Plus } from 'lucide-vue-next'; import { Plus } from 'lucide-vue-next';
import { entries, save } from '@/data/entries'; import { entries } from '@/state/entry'
import { Drawer, DrawerHeader, DrawerTitle, DrawerTrigger, DrawerContent, DrawerFooter, DrawerClose } from '@/components/ui/drawer'; import { save } from '@/data/entries';
import { Form, FormItem, FormLabel, FormField, FormControl, FormMessage, FormDescription } from '@/components/ui/form'; 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 { 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 { ScrollArea } from '@/components/ui/scroll-area';
import EntryForm from '@/components/EntryForm.vue'
import { CreateEntrySchema } from '@/components/EntryForm.vue'
import { getDifferenceToToday } from '@/data/entries'; import { getDifferenceToToday } from '@/data/entries';
import { toTypedSchema } from '@vee-validate/zod';
import * as z from 'zod'
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
import moment from 'moment'; 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 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) { save(entries.value)
if (entries.value.map(entry => entry.name).includes(value.name)) { createDrawerState.value = false
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()
} }
function openDetailWithName(name: string) { function openDetailWithName(name: string) {
console.log('opening', name) console.log('opening', name)
router.push({ router.push({
path: `${name}`, path: `${name}`,
}) })
} }
</script> </script>
<template> <template>
<ScrollArea class="w-full"> <ScrollArea class="w-full">
<div class="w-full flex justify-center flex-col items-center"> <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> <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"> <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" <Button v-for="entry in entries" class="w-full p-10 flex flex-col items-center gap-2"
@click="openDetailWithName(entry.name)"> @click="openDetailWithName(entry.name)">
<span>{{ entry.name }}</span> <span>{{ entry.name }}</span>
<Badge variant="secondary">{{ getDifferenceToToday(entry.last_reset) }} days</Badge> <Badge variant="secondary">{{ getDifferenceToToday(entry.last_reset) }} days</Badge>
</Button> </Button>
</main> </main>
</div> </div>
</ScrollArea> </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> <div id="placeholder" class="h-32"></div>
<DrawerHeader>
<DrawerTitle>Create new Entry</DrawerTitle> <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">
</DrawerHeader> <Button size="icon" class="h-20 md:w-20 w-11/12" @click="createDrawerState = true">
<div id="content" class="p-5"> <Plus />
<Form :validation-schema="createEntrySchema" @submit="(val) => createEntry(val as CreateEntrySchema)"> </Button>
<FormField v-slot="{ componentField }" name="name"> </div>
<FormItem>
<FormControl> <Dialog v-if="isDesktop" v-model:open="createDrawerState">
<Input placeholder="Name" v-bind="componentField" /> <DialogContent>
</FormControl> <DialogHeader>
<FormMessage/> <DialogTitle> Create new Entry </DialogTitle>
</FormItem> </DialogHeader>
</FormField> <div class="p-5">
<FormField v-slot="{ componentField }" name="text"> <EntryForm action="create" @submit="(val) => createEntry(val)" :entries="entries"> </EntryForm>
<FormItem> </div>
<FormLabel></FormLabel> </DialogContent>
<FormControl> </Dialog>
<Textarea placeholder="Text" v-bind="componentField" />
</FormControl> <Drawer v-else v-model:open="createDrawerState">
<FormDescription /> <DrawerContent>
<FormMessage /> <DrawerHeader>
</FormItem> <DrawerTitle>Create new Entry</DrawerTitle>
</FormField> </DrawerHeader>
<DrawerFooter> <div class="p-5">
<DrawerClose> <EntryForm action="create" @submit="(val) => createEntry(val)" :entries="entries"> </EntryForm>
<Button type="submit">Create</Button> </div>
</DrawerClose> </DrawerContent>
</DrawerFooter> </Drawer>
</Form>
</div>
</DrawerContent>
</Drawer>
</template> </template>
<style scoped></style> <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 { VitePWA } from 'vite-plugin-pwa'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import tailwind from 'tailwindcss' import tailwind from 'tailwindcss'
@@ -7,6 +7,13 @@ import path from 'path'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
coverage: {
provider: 'istanbul'
}
},
plugins: [vue(), VitePWA({ plugins: [vue(), VitePWA({
registerType: 'autoUpdate', registerType: 'autoUpdate',
manifest: { manifest: {