Compare commits

...

5 Commits

Author SHA1 Message Date
b2ef80d58d feat(app): add changelog list, byId and create page
All checks were successful
Build and Push Docker Image / tests (push) Successful in 34s
Build and Push Docker Image / build (push) Successful in 1m34s
2024-10-03 22:47:47 +02:00
ee3faad379 chore(schema): extend changelog schema with isSemver bool 2024-10-03 22:46:38 +02:00
b45e815f1d feat(ui): add textarea component 2024-10-03 22:15:11 +02:00
290f97dd95 feat(ui): add checkbox component 2024-10-03 22:07:54 +02:00
52d62f831f feat(ui): add form components 2024-10-03 22:05:55 +02:00
15 changed files with 528 additions and 19 deletions

View File

@ -13,13 +13,16 @@
"@boring.tools/schema": "workspace:*",
"@boring.tools/ui": "workspace:*",
"@clerk/clerk-react": "^5.9.4",
"@hookform/resolvers": "^3.9.0",
"@tanstack/react-query": "^5.59.0",
"@tanstack/react-router": "^1.58.15",
"axios": "^1.7.7",
"lucide-react": "^0.446.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss-animate": "^1.0.7"
"react-hook-form": "^7.53.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/js": "^9.9.0",

View File

@ -37,7 +37,6 @@ export const Navigation = () => {
to={route.to}
className="flex items-center gap-3 rounded-lg px-3 py-2 transition-all hover:text-primary"
activeProps={{ className: 'bg-muted text-primary' }}
activeOptions={{ exact: true }}
>
<route.icon className="h-4 w-4" />
{route.name}

View File

@ -28,7 +28,7 @@ export const useChangelogById = ({ id }: { id: string }) => {
return useQuery({
queryKey: ['changelogById', id],
queryFn: async (): Promise<ReadOnlyDict<Changelog>> =>
queryFn: async (): Promise<Readonly<Changelog>> =>
await queryFetch({
path: `changelog/${id}`,
method: 'get',
@ -44,7 +44,7 @@ export const useChangelogCreate = () => {
return useMutation({
mutationFn: async (
payload: ChangelogCreate,
): Promise<ReadonlySet<Changelog>> =>
): Promise<Readonly<Changelog>> =>
await queryFetch({
path: 'changelog',
data: payload,
@ -62,9 +62,7 @@ export const useChangelogRemove = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
id,
}: { id: string }): Promise<ReadOnlyDict<Changelog>> =>
mutationFn: async ({ id }: { id: string }): Promise<Readonly<Changelog>> =>
await queryFetch({
path: `changelog/${id}`,
method: 'delete',

View File

@ -19,6 +19,8 @@ import { Route as rootRoute } from './routes/__root'
const IndexLazyImport = createFileRoute('/')()
const UserIndexLazyImport = createFileRoute('/user/')()
const ChangelogIndexLazyImport = createFileRoute('/changelog/')()
const ChangelogCreateLazyImport = createFileRoute('/changelog/create')()
const ChangelogIdLazyImport = createFileRoute('/changelog/$id')()
// Create/Update Routes
@ -39,6 +41,18 @@ const ChangelogIndexLazyRoute = ChangelogIndexLazyImport.update({
import('./routes/changelog/index.lazy').then((d) => d.Route),
)
const ChangelogCreateLazyRoute = ChangelogCreateLazyImport.update({
path: '/changelog/create',
getParentRoute: () => rootRoute,
} as any).lazy(() =>
import('./routes/changelog/create.lazy').then((d) => d.Route),
)
const ChangelogIdLazyRoute = ChangelogIdLazyImport.update({
path: '/changelog/$id',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/changelog/$id.lazy').then((d) => d.Route))
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
@ -50,6 +64,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexLazyImport
parentRoute: typeof rootRoute
}
'/changelog/$id': {
id: '/changelog/$id'
path: '/changelog/$id'
fullPath: '/changelog/$id'
preLoaderRoute: typeof ChangelogIdLazyImport
parentRoute: typeof rootRoute
}
'/changelog/create': {
id: '/changelog/create'
path: '/changelog/create'
fullPath: '/changelog/create'
preLoaderRoute: typeof ChangelogCreateLazyImport
parentRoute: typeof rootRoute
}
'/changelog/': {
id: '/changelog/'
path: '/changelog'
@ -71,12 +99,16 @@ declare module '@tanstack/react-router' {
export interface FileRoutesByFullPath {
'/': typeof IndexLazyRoute
'/changelog/$id': typeof ChangelogIdLazyRoute
'/changelog/create': typeof ChangelogCreateLazyRoute
'/changelog': typeof ChangelogIndexLazyRoute
'/user': typeof UserIndexLazyRoute
}
export interface FileRoutesByTo {
'/': typeof IndexLazyRoute
'/changelog/$id': typeof ChangelogIdLazyRoute
'/changelog/create': typeof ChangelogCreateLazyRoute
'/changelog': typeof ChangelogIndexLazyRoute
'/user': typeof UserIndexLazyRoute
}
@ -84,27 +116,44 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRoute
'/': typeof IndexLazyRoute
'/changelog/$id': typeof ChangelogIdLazyRoute
'/changelog/create': typeof ChangelogCreateLazyRoute
'/changelog/': typeof ChangelogIndexLazyRoute
'/user/': typeof UserIndexLazyRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/changelog' | '/user'
fullPaths:
| '/'
| '/changelog/$id'
| '/changelog/create'
| '/changelog'
| '/user'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/changelog' | '/user'
id: '__root__' | '/' | '/changelog/' | '/user/'
to: '/' | '/changelog/$id' | '/changelog/create' | '/changelog' | '/user'
id:
| '__root__'
| '/'
| '/changelog/$id'
| '/changelog/create'
| '/changelog/'
| '/user/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexLazyRoute: typeof IndexLazyRoute
ChangelogIdLazyRoute: typeof ChangelogIdLazyRoute
ChangelogCreateLazyRoute: typeof ChangelogCreateLazyRoute
ChangelogIndexLazyRoute: typeof ChangelogIndexLazyRoute
UserIndexLazyRoute: typeof UserIndexLazyRoute
}
const rootRouteChildren: RootRouteChildren = {
IndexLazyRoute: IndexLazyRoute,
ChangelogIdLazyRoute: ChangelogIdLazyRoute,
ChangelogCreateLazyRoute: ChangelogCreateLazyRoute,
ChangelogIndexLazyRoute: ChangelogIndexLazyRoute,
UserIndexLazyRoute: UserIndexLazyRoute,
}
@ -122,6 +171,8 @@ export const routeTree = rootRoute
"filePath": "__root.tsx",
"children": [
"/",
"/changelog/$id",
"/changelog/create",
"/changelog/",
"/user/"
]
@ -129,6 +180,12 @@ export const routeTree = rootRoute
"/": {
"filePath": "index.lazy.tsx"
},
"/changelog/$id": {
"filePath": "changelog/$id.lazy.tsx"
},
"/changelog/create": {
"filePath": "changelog/create.lazy.tsx"
},
"/changelog/": {
"filePath": "changelog/index.lazy.tsx"
},

View File

@ -0,0 +1,33 @@
import { Button } from '@boring.tools/ui'
import { createLazyFileRoute } from '@tanstack/react-router'
import { useChangelogById } from '../../hooks/useChangelog'
const Component = () => {
const { id } = Route.useParams()
const { data, error, isPending, refetch } = useChangelogById({ id })
if (error) {
return (
<div className="flex items-center justify-center mt-32 flex-col">
<h1 className="text-3xl">Changelogs</h1>
<p>Please try again later</p>
<Button onClick={() => refetch()}>Retry</Button>
</div>
)
}
return (
<div className="flex flex-col gap-5">
{!isPending && data && (
<div>
<h1 className="text-3xl">{data.title}</h1>
</div>
)}
</div>
)
}
export const Route = createLazyFileRoute('/changelog/$id')({
component: Component,
})

View File

@ -0,0 +1,117 @@
import { ChangelogCreateInput } from '@boring.tools/schema'
import {
Button,
Checkbox,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Textarea,
} from '@boring.tools/ui'
import { zodResolver } from '@hookform/resolvers/zod'
import { createLazyFileRoute } from '@tanstack/react-router'
import { useNavigate } from '@tanstack/react-router'
import { useForm } from 'react-hook-form'
import type { z } from 'zod'
import { useChangelogCreate } from '../../hooks/useChangelog'
const Component = () => {
const navigate = useNavigate({ from: '/changelog/create' })
const changelogCreate = useChangelogCreate()
const form = useForm<z.infer<typeof ChangelogCreateInput>>({
resolver: zodResolver(ChangelogCreateInput),
defaultValues: {
title: '',
description: '',
isSemver: true,
},
})
const onSubmit = (values: z.infer<typeof ChangelogCreateInput>) => {
changelogCreate.mutate(values, {
onSuccess(data) {
navigate({ to: '/changelog/$id', params: { id: data.id } })
},
})
}
return (
<div className="flex flex-col gap-5">
<h1 className="text-3xl">New changelog</h1>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8 max-w-screen-md"
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="My changelog" {...field} autoFocus />
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Some details about the changelog..."
{...field}
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isSemver"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md ">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Using Semver</FormLabel>
<FormDescription>
If this changelog is following the{' '}
<a
href="https://semver.org/lang/de/"
className="text-emerald-700"
>
semantic versioning?
</a>
</FormDescription>
</div>
</FormItem>
)}
/>
<Button type="submit">Create</Button>
</form>
</Form>
</div>
)
}
export const Route = createLazyFileRoute('/changelog/create')({
component: Component,
})

View File

@ -1,27 +1,67 @@
import { createLazyFileRoute } from '@tanstack/react-router'
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
} from '@boring.tools/ui'
import { Link, createLazyFileRoute } from '@tanstack/react-router'
import { PlusCircleIcon } from 'lucide-react'
import { useChangelogList } from '../../hooks/useChangelog'
const Component = () => {
const { data, error, isPending } = useChangelogList()
const { data, error, isPending, refetch } = useChangelogList()
if (error) {
return (
<div className="flex items-center justify-center mt-32 flex-col">
<h1 className="text-3xl">Changelogs</h1>
<p>Please try again later</p>
<Button onClick={() => refetch()}>Retry</Button>
</div>
)
}
return (
<div>
<div className="flex flex-col gap-5">
<h1 className="text-3xl">Changelogs</h1>
{!isPending &&
data &&
data.map((changelog) => {
return <div key={changelog.id}>{changelog.title}</div>
})}
<div className="flex gap-10 w-full">
{!isPending &&
data &&
data.map((changelog) => {
return (
<Link
to="/changelog/$id"
params={{ id: changelog.id }}
key={changelog.id}
>
<Card className="max-w-56 min-w-56 w-full h-36 hover:border-emerald-700 transition">
<CardHeader className="flex items-center justify-center">
<CardTitle>{changelog.title}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-center flex-col">
<span>Versions: {changelog.computed.versionCount}</span>
<span>Commits: {changelog.computed.commitCount}</span>
</CardContent>
</Card>
</Link>
)
})}
<Link to="/changelog/create">
<Card className="max-w-56 min-w-56 w-full h-36 hover:border-emerald-700 transition">
<CardHeader className="flex items-center justify-center">
<CardTitle>New Changelog</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-center">
<PlusCircleIcon strokeWidth={1.5} className="w-10 h-10" />
</CardContent>
</Card>
</Link>
</div>
</div>
)
}

BIN
bun.lockb

Binary file not shown.

View File

@ -6,6 +6,7 @@ export const ChangelogCreateInput = z
.object({
title: z.string().min(3, 'Title must contain at least 3 charachters.'),
description: z.string(),
isSemver: z.boolean().default(true),
})
.openapi({
required: ['title', 'userId'],

View File

@ -5,17 +5,22 @@
"type": "module",
"main": "./src/index.ts",
"dependencies": {
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-slot": "^1.1.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.446.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/react": "^18.3.3",

View File

@ -0,0 +1,28 @@
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { CheckIcon } from 'lucide-react'
import * as React from 'react'
import { cn } from './lib/cn'
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn('flex items-center justify-center text-current')}
>
<CheckIcon className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

176
packages/ui/src/form.tsx Normal file
View File

@ -0,0 +1,176 @@
import type * as LabelPrimitive from '@radix-ui/react-label'
import { Slot } from '@radix-ui/react-slot'
import * as React from 'react'
import {
Controller,
type ControllerProps,
type FieldPath,
type FieldValues,
FormProvider,
useFormContext,
} from 'react-hook-form'
import { Label } from './label'
import { cn } from './lib/cn'
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>')
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn('space-y-2', className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = 'FormItem'
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && 'text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = 'FormLabel'
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = 'FormControl'
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn('text-[0.8rem] text-muted-foreground', className)}
{...props}
/>
)
})
FormDescription.displayName = 'FormDescription'
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn('text-[0.8rem] font-medium text-destructive', className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = 'FormMessage'
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@ -8,3 +8,7 @@ export * from './card'
export * from './input'
export * from './sheet'
export * from './accordion'
export * from './label'
export * from './form'
export * from './checkbox'
export * from './textarea'

24
packages/ui/src/label.tsx Normal file
View File

@ -0,0 +1,24 @@
import * as LabelPrimitive from '@radix-ui/react-label'
import { type VariantProps, cva } from 'class-variance-authority'
import * as React from 'react'
import { cn } from './lib/cn'
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,24 @@
import * as React from 'react'
import { cn } from './lib/cn'
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
)
},
)
Textarea.displayName = 'Textarea'
export { Textarea }