diff --git a/apps/api/src/changelog/version/byId.ts b/apps/api/src/changelog/version/byId.ts index e0d6018..1b8432c 100644 --- a/apps/api/src/changelog/version/byId.ts +++ b/apps/api/src/changelog/version/byId.ts @@ -1,7 +1,12 @@ -import { changelog, changelog_version, db } from '@boring.tools/database' +import { + changelog, + changelog_commit, + changelog_version, + db, +} from '@boring.tools/database' import { VersionByIdParams, VersionOutput } from '@boring.tools/schema' import { createRoute } from '@hono/zod-openapi' -import { and, eq } from 'drizzle-orm' +import { and, desc, eq } from 'drizzle-orm' import { HTTPException } from 'hono/http-exception' import type changelogVersionApi from '.' @@ -37,7 +42,9 @@ export const registerVersionById = (api: typeof changelogVersionApi) => { const versionResult = await db.query.changelog_version.findFirst({ where: eq(changelog_version.id, id), with: { - commits: true, + commits: { + orderBy: () => desc(changelog_commit.createdAt), + }, }, }) diff --git a/apps/api/src/changelog/version/update.ts b/apps/api/src/changelog/version/update.ts index f288875..625835b 100644 --- a/apps/api/src/changelog/version/update.ts +++ b/apps/api/src/changelog/version/update.ts @@ -1,7 +1,12 @@ -import { changelog, changelog_version, db } from '@boring.tools/database' +import { + changelog, + changelog_commit, + changelog_version, + db, +} from '@boring.tools/database' import { VersionUpdateInput, VersionUpdateOutput } from '@boring.tools/schema' import { createRoute, type z } from '@hono/zod-openapi' -import { and, eq } from 'drizzle-orm' +import { and, eq, inArray, notInArray } from 'drizzle-orm' import { HTTPException } from 'hono/http-exception' import type changelogVersionApi from '.' @@ -74,6 +79,18 @@ export const registerVersionUpdate = (api: typeof changelogVersionApi) => { .where(and(eq(changelog_version.id, id))) .returning() + if (payload.commitIds) { + await db + .update(changelog_commit) + .set({ versionId: null }) + .where(notInArray(changelog_commit.id, payload.commitIds)) + + await db + .update(changelog_commit) + .set({ versionId: versionUpdateResult.id }) + .where(inArray(changelog_commit.id, payload.commitIds)) + } + if (findChangelog.pageId) { redis.del(findChangelog.pageId) } diff --git a/apps/app/src/routes/changelog.$id.version.$versionId.tsx b/apps/app/src/routes/changelog.$id.version.$versionId.tsx index fc484bd..d545743 100644 --- a/apps/app/src/routes/changelog.$id.version.$versionId.tsx +++ b/apps/app/src/routes/changelog.$id.version.$versionId.tsx @@ -2,6 +2,11 @@ import { VersionUpdateInput } from '@boring.tools/schema' import { Button, Calendar, + Card, + CardContent, + CardHeader, + CardTitle, + Checkbox, Form, FormControl, FormField, @@ -12,12 +17,17 @@ import { Popover, PopoverContent, PopoverTrigger, + ScrollArea, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Separator, + Tabs, + TabsContent, + TabsList, + TabsTrigger, cn, } from '@boring.tools/ui' import { zodResolver } from '@hookform/resolvers/zod' @@ -38,6 +48,7 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router' import { useForm } from 'react-hook-form' import type { z } from 'zod' import { + useChangelogCommitList, useChangelogVersionById, useChangelogVersionUpdate, } from '../hooks/useChangelog' @@ -56,6 +67,7 @@ const Component = () => { const { data, error, isPending, refetch } = useChangelogVersionById({ id: versionId, }) + const commitResult = useChangelogCommitList({ id }) const form = useForm<z.infer<typeof VersionUpdateInput>>({ resolver: zodResolver(VersionUpdateInput), @@ -76,7 +88,10 @@ const Component = () => { useEffect(() => { if (data) { mdxEditorRef.current?.setMarkdown(data.markdown) - form.reset(data) + form.reset({ + ...data, + commitIds: data.commits?.map((commit) => commit.id), + }) } }, [data, form]) @@ -95,102 +110,27 @@ const Component = () => { <div className="flex flex-col gap-5"> <Separator /> {!isPending && data && ( - <div> - <Form {...form}> - <form - onSubmit={form.handleSubmit(onSubmit)} - className="space-y-8 max-w-screen-md" - > - <FormField - control={form.control} - name="version" - render={({ field }) => ( - <FormItem> - <FormLabel>Version</FormLabel> - <FormControl> - <Input placeholder="v1.0.1" {...field} /> - </FormControl>{' '} - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="markdown" - render={({ field }) => ( - <FormItem> - <FormLabel>Notes</FormLabel> - <FormControl> - <MDXEditor - className="dark-theme" - contentEditableClassName="prose dark:prose-invert max-w-none" - markdown={''} - ref={mdxEditorRef} - onChange={field.onChange} - onBlur={field.onBlur} - plugins={[ - headingsPlugin(), - listsPlugin(), - thematicBreakPlugin(), - quotePlugin(), - - toolbarPlugin({ - toolbarContents: () => ( - <> - <BlockTypeSelect /> - <BoldItalicUnderlineToggles /> - <ListsToggle /> - <UndoRedo /> - </> - ), - }), - ]} - /> - </FormControl>{' '} - <FormMessage /> - </FormItem> - )} - /> - - <div className="flex gap-5 items-center"> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex gap-4 w-full" + > + <Card className="w-full"> + <CardHeader> + <div className="flex items-center justify-between"> + <CardTitle>Details</CardTitle> + </div> + </CardHeader> + <CardContent> <FormField control={form.control} - name="status" + name="version" render={({ field }) => ( <FormItem> - <FormLabel>Status</FormLabel> - <Select - onValueChange={field.onChange} - defaultValue={field.value} - value={field.value} - > - <FormControl> - <SelectTrigger> - <SelectValue placeholder="Select your version status" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectItem value="draft"> - <div className="flex gap-2 items-center"> - <VersionStatus status={'draft'} /> - <span>Draft</span> - </div> - </SelectItem> - <SelectItem value="review"> - <div className="flex gap-2 items-center"> - <VersionStatus status={'review'} /> - <span>Review</span> - </div> - </SelectItem> - <SelectItem value="published"> - <div className="flex gap-2 items-center"> - <VersionStatus status={'published'} /> - <span>Published</span> - </div> - </SelectItem> - </SelectContent> - </Select> + <FormLabel>Version</FormLabel> + <FormControl> + <Input placeholder="v1.0.1" {...field} /> + </FormControl>{' '} <FormMessage /> </FormItem> )} @@ -198,62 +138,286 @@ const Component = () => { <FormField control={form.control} - name="releasedAt" + name="markdown" render={({ field }) => ( - <FormItem className="flex flex-col"> - <FormLabel className="mb-2">Released at</FormLabel> - <Popover> - <PopoverTrigger asChild> - <FormControl> - <Button - variant={'outline'} - size={'lg'} - className={cn( - 'w-[240px] pl-3 text-left font-normal', - !field.value && 'text-muted-foreground', - )} - > - {field.value ? ( - format(field.value, 'PPP') - ) : ( - <span>Pick a date</span> - )} - <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-auto p-0" align="start"> - <Calendar - mode="single" - selected={field.value as Date} - onSelect={(date) => field.onChange(date)} - weekStartsOn={1} - /> - </PopoverContent> - </Popover> + <FormItem> + <FormLabel>Notes</FormLabel> + <FormControl> + <MDXEditor + className="dark-theme" + contentEditableClassName="prose dark:prose-invert max-w-none" + markdown={''} + ref={mdxEditorRef} + onChange={field.onChange} + onBlur={field.onBlur} + plugins={[ + headingsPlugin(), + listsPlugin(), + thematicBreakPlugin(), + quotePlugin(), + + toolbarPlugin({ + toolbarContents: () => ( + <> + <BlockTypeSelect /> + <BoldItalicUnderlineToggles /> + <ListsToggle /> + <UndoRedo /> + </> + ), + }), + ]} + /> + </FormControl>{' '} <FormMessage /> </FormItem> )} /> - </div> - <div className="flex gap-5"> - <Button - type="button" - variant={'ghost'} - onClick={() => - navigate({ to: '/changelog/$id', params: { id } }) - } - > - Cancel - </Button> - <Button type="submit">Update</Button> - </div> - </form> - </Form> + <div className="flex gap-5 items-center"> + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>Status</FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + value={field.value} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="Select your version status" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="draft"> + <div className="flex gap-2 items-center"> + <VersionStatus status={'draft'} /> + <span>Draft</span> + </div> + </SelectItem> + <SelectItem value="review"> + <div className="flex gap-2 items-center"> + <VersionStatus status={'review'} /> + <span>Review</span> + </div> + </SelectItem> + <SelectItem value="published"> + <div className="flex gap-2 items-center"> + <VersionStatus status={'published'} /> + <span>Published</span> + </div> + </SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> - <ChangelogVersionDelete id={id} versionId={versionId} /> - </div> + <FormField + control={form.control} + name="releasedAt" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel className="mb-2">Released at</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant={'outline'} + size={'lg'} + className={cn( + 'w-[240px] pl-3 text-left font-normal', + !field.value && 'text-muted-foreground', + )} + > + {field.value ? ( + format(field.value, 'PPP') + ) : ( + <span>Pick a date</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value as Date} + onSelect={(date) => field.onChange(date)} + weekStartsOn={1} + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <div className="flex gap-5"> + <Button + type="button" + variant={'ghost'} + onClick={() => + navigate({ to: '/changelog/$id', params: { id } }) + } + > + Cancel + </Button> + <Button type="submit">Update</Button> + </div> + + <ChangelogVersionDelete id={id} versionId={versionId} /> + </CardContent> + </Card> + + <div className="w-full"> + <Card className="w-full max-w-screen-sm"> + <CardHeader> + <div className="flex items-center justify-between"> + <CardTitle>Commits ({data.commits?.length})</CardTitle> + </div> + </CardHeader> + <CardContent> + <Tabs defaultValue="assigned" className="w-full"> + <TabsList> + <TabsTrigger value="assigned">Assigend</TabsTrigger> + <TabsTrigger value="unassigned">Unassigned</TabsTrigger> + </TabsList> + <TabsContent value="assigned"> + <ScrollArea className="w-full h-[350px]"> + <div className="flex flex-col gap-2"> + {data?.commits?.map((commit) => { + return ( + <FormField + key={commit.id} + control={form.control} + name={'commitIds'} + render={({ field }) => { + return ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md "> + <FormControl> + <Checkbox + value={commit.id} + checked={field.value?.includes( + commit.id, + )} + onCheckedChange={() => { + const exist = field.value?.includes( + commit.id, + ) + if (exist) { + return field.onChange( + field.value?.filter( + (value) => + value !== commit.id, + ), + ) + } + return field.onChange([ + ...(field.value as string[]), + commit.id, + ]) + }} + /> + </FormControl> + <div className="space-y-1 leading-none w-full"> + <FormLabel className="flex gap-2 w-full"> + <span className="text-muted-foreground font-mono"> + {commit.commit}{' '} + </span> + <span className="w-full"> + {commit.subject} + </span> + <span> + {format( + new Date(commit.commiter.date), + 'dd.MM.', + )} + </span> + </FormLabel> + </div> + </FormItem> + ) + }} + /> + ) + })} + </div> + </ScrollArea> + </TabsContent> + <TabsContent value="unassigned"> + <ScrollArea className="w-full h-[350px]"> + <div className="flex flex-col gap-2"> + {commitResult.data?.map((commit) => { + return ( + <FormField + key={commit.id} + control={form.control} + name={'commitIds'} + render={({ field }) => { + return ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md "> + <FormControl> + <Checkbox + value={commit.id} + checked={field.value?.includes( + commit.id, + )} + onCheckedChange={() => { + const exist = field.value?.includes( + commit.id, + ) + if (exist) { + return field.onChange( + field.value?.filter( + (value) => + value !== commit.id, + ), + ) + } + return field.onChange([ + ...(field.value as string[]), + commit.id, + ]) + }} + /> + </FormControl> + <div className="space-y-1 leading-none w-full"> + <FormLabel className="flex gap-2 w-full"> + <span className="text-muted-foreground font-mono"> + {commit.commit} + </span> + <span className="w-full"> + {commit.subject} + </span> + <span> + {format( + new Date(commit.commiter.date), + 'dd.MM.', + )} + </span> + </FormLabel> + </div> + </FormItem> + ) + }} + /> + ) + })} + </div> + </ScrollArea> + </TabsContent> + </Tabs> + </CardContent> + </Card> + </div> + </form> + </Form> )} </div> ) diff --git a/bun.lockb b/bun.lockb index 78ac010..6d066c9 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/schema/src/version/base.ts b/packages/schema/src/version/base.ts index 08d5a4c..b6a9559 100644 --- a/packages/schema/src/version/base.ts +++ b/packages/schema/src/version/base.ts @@ -1,4 +1,5 @@ import { z } from '@hono/zod-openapi' +import { CommitOutput } from '../commit' export const VersionOutput = z .object({ @@ -20,5 +21,6 @@ export const VersionOutput = z status: z.enum(['draft', 'review', 'published']).default('draft').openapi({ example: 'draft', }), + commits: z.array(CommitOutput).optional(), }) .openapi('Version') diff --git a/packages/schema/src/version/update.ts b/packages/schema/src/version/update.ts index de8ceb4..93054ba 100644 --- a/packages/schema/src/version/update.ts +++ b/packages/schema/src/version/update.ts @@ -11,6 +11,7 @@ export const VersionUpdateInput = z .default('draft') .optional(), releasedAt: z.date().or(z.string()).optional().nullable(), + commitIds: z.array(z.string()).optional(), }) .openapi({}) export const VersionUpdateParams = z diff --git a/packages/ui/package.json b/packages/ui/package.json index fd16f79..16a634a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -19,6 +19,7 @@ "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-tooltip": "^1.1.3", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index b34cc3b..de278e8 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -30,3 +30,4 @@ export * from './command' export * from './dialog' export * from './scroll-area' export * from './table' +export * from './tabs' diff --git a/packages/ui/src/tabs.tsx b/packages/ui/src/tabs.tsx new file mode 100644 index 0000000..058dc79 --- /dev/null +++ b/packages/ui/src/tabs.tsx @@ -0,0 +1,53 @@ +import * as TabsPrimitive from '@radix-ui/react-tabs' +import * as React from 'react' + +import { cn } from './lib/cn' + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef<typeof TabsPrimitive.List>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> +>(({ className, ...props }, ref) => ( + <TabsPrimitive.List + ref={ref} + className={cn( + 'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground', + className, + )} + {...props} + /> +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef<typeof TabsPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> +>(({ className, ...props }, ref) => ( + <TabsPrimitive.Trigger + ref={ref} + className={cn( + 'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm', + className, + )} + {...props} + /> +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef<typeof TabsPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> +>(({ className, ...props }, ref) => ( + <TabsPrimitive.Content + ref={ref} + className={cn( + 'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', + className, + )} + {...props} + /> +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent }