refactor(submissions): improve submission forms and data table

- Update icon submission forms with better validation
- Enhance advanced submission form with TanStack
- Improve submissions data table functionality
- Update submissions hook for better data management
This commit is contained in:
Thomas Camlong
2025-11-07 08:11:14 +01:00
parent 938facc889
commit 56289820f0
4 changed files with 191 additions and 182 deletions

View File

@@ -1,7 +1,8 @@
"use client" "use client"
import { Check, FileImage, FileType, Plus, X } from "lucide-react"
import { useForm } from "@tanstack/react-form" import { useForm } from "@tanstack/react-form"
import { Check, FileImage, FileType, Plus, X } from "lucide-react"
import { useState } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { IconNameCombobox } from "@/components/icon-name-combobox" import { IconNameCombobox } from "@/components/icon-name-combobox"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
@@ -12,9 +13,9 @@ import { Label } from "@/components/ui/label"
import { MultiSelect, type MultiSelectOption } from "@/components/ui/multi-select" import { MultiSelect, type MultiSelectOption } from "@/components/ui/multi-select"
import { Dropzone, DropzoneContent, DropzoneEmptyState } from "@/components/ui/shadcn-io/dropzone" import { Dropzone, DropzoneContent, DropzoneEmptyState } from "@/components/ui/shadcn-io/dropzone"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { pb } from "@/lib/pb"
import { useExistingIconNames } from "@/hooks/use-submissions" import { useExistingIconNames } from "@/hooks/use-submissions"
import { useState } from "react" import { pb } from "@/lib/pb"
import { revalidateAllSubmissions } from "@/app/actions/submissions"
interface VariantConfig { interface VariantConfig {
id: string id: string
@@ -168,6 +169,9 @@ export function AdvancedIconSubmissionFormTanStack() {
await pb.collection("submissions").create(submissionData) await pb.collection("submissions").create(submissionData)
// Revalidate Next.js cache for community pages
await revalidateAllSubmissions()
toast.success("Icon submitted!", { toast.success("Icon submitted!", {
description: `Your icon "${value.iconName}" has been submitted for review`, description: `Your icon "${value.iconName}" has been submitted for review`,
}) })
@@ -188,7 +192,10 @@ export function AdvancedIconSubmissionFormTanStack() {
if (variantId !== "base") { if (variantId !== "base") {
// Remove from selected variants // Remove from selected variants
const currentVariants = form.getFieldValue("selectedVariants") const currentVariants = form.getFieldValue("selectedVariants")
form.setFieldValue("selectedVariants", currentVariants.filter((v) => v !== variantId)) form.setFieldValue(
"selectedVariants",
currentVariants.filter((v) => v !== variantId),
)
// Remove files // Remove files
const currentFiles = form.getFieldValue("files") const currentFiles = form.getFieldValue("files")
@@ -205,9 +212,7 @@ export function AdvancedIconSubmissionFormTanStack() {
const handleVariantSelectionChange = (selectedValues: string[]) => { const handleVariantSelectionChange = (selectedValues: string[]) => {
// Ensure base is always included // Ensure base is always included
const finalValues = selectedValues.includes("base") const finalValues = selectedValues.includes("base") ? selectedValues : ["base", ...selectedValues]
? selectedValues
: ["base", ...selectedValues]
return finalValues return finalValues
} }
@@ -222,7 +227,7 @@ export function AdvancedIconSubmissionFormTanStack() {
if (droppedFiles.length > 0) { if (droppedFiles.length > 0) {
const reader = new FileReader() const reader = new FileReader()
reader.onload = (e) => { reader.onload = (e) => {
if (typeof e.target?.result === 'string') { if (typeof e.target?.result === "string") {
setFilePreviews({ setFilePreviews({
...filePreviews, ...filePreviews,
[variantId]: e.target.result, [variantId]: e.target.result,
@@ -247,13 +252,19 @@ export function AdvancedIconSubmissionFormTanStack() {
const handleRemoveAlias = (alias: string) => { const handleRemoveAlias = (alias: string) => {
const currentAliases = form.getFieldValue("aliases") const currentAliases = form.getFieldValue("aliases")
form.setFieldValue("aliases", currentAliases.filter((a) => a !== alias)) form.setFieldValue(
"aliases",
currentAliases.filter((a) => a !== alias),
)
} }
const toggleCategory = (category: string) => { const toggleCategory = (category: string) => {
const currentCategories = form.getFieldValue("categories") const currentCategories = form.getFieldValue("categories")
if (currentCategories.includes(category)) { if (currentCategories.includes(category)) {
form.setFieldValue("categories", currentCategories.filter((c) => c !== category)) form.setFieldValue(
"categories",
currentCategories.filter((c) => c !== category),
)
} else { } else {
form.setFieldValue("categories", [...currentCategories, category]) form.setFieldValue("categories", [...currentCategories, category])
} }
@@ -326,11 +337,7 @@ export function AdvancedIconSubmissionFormTanStack() {
{Object.entries(filePreviews).map(([variantId, preview]) => ( {Object.entries(filePreviews).map(([variantId, preview]) => (
<div key={variantId} className="flex flex-col gap-2"> <div key={variantId} className="flex flex-col gap-2">
<div className="relative aspect-square rounded-lg border bg-card p-4 flex items-center justify-center"> <div className="relative aspect-square rounded-lg border bg-card p-4 flex items-center justify-center">
<img <img alt={`${variantId} preview`} className="max-h-full max-w-full object-contain" src={preview} />
alt={`${variantId} preview`}
className="max-h-full max-w-full object-contain"
src={preview}
/>
</div> </div>
<div className="text-center"> <div className="text-center">
<p className="text-xs font-mono text-muted-foreground">{state.iconName || "preview"}</p> <p className="text-xs font-mono text-muted-foreground">{state.iconName || "preview"}</p>
@@ -386,11 +393,7 @@ export function AdvancedIconSubmissionFormTanStack() {
return ( return (
<Card <Card
key={variantId} key={variantId}
className={`relative transition-all ${ className={`relative transition-all ${hasFile ? "border-primary bg-primary/5" : "border-border"}`}
hasFile
? "border-primary bg-primary/5"
: "border-border"
}`}
> >
{/* Remove button at top-right corner */} {/* Remove button at top-right corner */}
{!isBase && ( {!isBase && (
@@ -410,7 +413,11 @@ export function AdvancedIconSubmissionFormTanStack() {
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h4 className="text-sm font-semibold">{variant.label}</h4> <h4 className="text-sm font-semibold">{variant.label}</h4>
{isBase && <Badge variant="secondary" className="text-xs">Required</Badge>} {isBase && (
<Badge variant="secondary" className="text-xs">
Required
</Badge>
)}
{hasFile && ( {hasFile && (
<Badge variant="default" className="text-xs"> <Badge variant="default" className="text-xs">
<Check className="h-3 w-3 mr-1" /> <Check className="h-3 w-3 mr-1" />

View File

@@ -51,7 +51,7 @@ export function IconSubmissionContent({ onClose }: { onClose?: () => void }) {
if (files.length > 0) { if (files.length > 0) {
const reader = new FileReader() const reader = new FileReader()
reader.onload = (e) => { reader.onload = (e) => {
if (typeof e.target?.result === 'string') { if (typeof e.target?.result === "string") {
setFilePreview(e.target?.result) setFilePreview(e.target?.result)
} }
} }
@@ -65,7 +65,7 @@ export function IconSubmissionContent({ onClose }: { onClose?: () => void }) {
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-sm font-medium">Upload Icon Files</h3> <h3 className="text-sm font-medium">Upload Icon Files</h3>
<Dropzone <Dropzone
accept={{ 'image/*': ['.png', '.jpg', '.jpeg', '.svg', '.webp'] }} accept={{ "image/*": [".png", ".jpg", ".jpeg", ".svg", ".webp"] }}
onDrop={handleDrop} onDrop={handleDrop}
onError={console.error} onError={console.error}
src={files} src={files}
@@ -76,20 +76,12 @@ export function IconSubmissionContent({ onClose }: { onClose?: () => void }) {
<DropzoneContent> <DropzoneContent>
{filePreview && ( {filePreview && (
<div className="h-[102px] w-full"> <div className="h-[102px] w-full">
<img <img alt="Preview" className="absolute top-0 left-0 h-full w-full object-cover" src={filePreview} />
alt="Preview"
className="absolute top-0 left-0 h-full w-full object-cover"
src={filePreview}
/>
</div> </div>
)} )}
</DropzoneContent> </DropzoneContent>
</Dropzone> </Dropzone>
{files && files.length > 0 && ( {files && files.length > 0 && <div className="text-xs text-muted-foreground">{files.length} file(s) selected</div>}
<div className="text-xs text-muted-foreground">
{files.length} file(s) selected
</div>
)}
</div> </div>
{/* Divider */} {/* Divider */}
@@ -121,7 +113,7 @@ export function IconSubmissionContent({ onClose }: { onClose?: () => void }) {
</div> </div>
) )
} }
export function IconSubmissionForm({ trigger, onClick }: { trigger?: React.ReactNode, onClick?: () => void }) { export function IconSubmissionForm({ trigger, onClick }: { trigger?: React.ReactNode; onClick?: () => void }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
return ( return (

View File

@@ -140,149 +140,152 @@ export function SubmissionsDataTable({
[userFilter], [userFilter],
) )
const columns: ColumnDef<Submission>[] = React.useMemo(() => [ const columns: ColumnDef<Submission>[] = React.useMemo(
{ () => [
id: "expander", {
header: () => null, id: "expander",
cell: ({ row }) => { header: () => null,
return ( cell: ({ row }) => {
<button
onClick={(e) => {
e.stopPropagation()
handleRowToggle(row.id, row.getIsExpanded())
}}
className="flex items-center justify-center w-8 h-8 hover:bg-muted rounded transition-colors"
>
{row.getIsExpanded() ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</button>
)
},
},
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Name
<SortDesc className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => <div className="font-medium capitalize">{row.getValue("name")}</div>,
},
{
accessorKey: "status",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Status
<SortDesc className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const status = row.getValue("status") as Submission["status"]
return (
<Badge variant="outline" className={getStatusColor(status)}>
{getStatusDisplayName(status)}
</Badge>
)
},
},
{
accessorKey: "created_by",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Submitted By
<SortDesc className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const submission = row.original
const expandedData = (submission as any).expand
const displayName = getDisplayName(submission, expandedData)
const userId = submission.created_by
return (
<div className="flex items-center gap-1">
<UserDisplay
userId={userId}
avatar={expandedData.created_by.avatar}
displayName={displayName}
onClick={handleUserFilter}
size="md"
/>
{userFilter?.userId === userId && <X className="h-3 w-3 text-muted-foreground" />}
</div>
)
},
},
{
accessorKey: "updated",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Updated
<SortDesc className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const date = row.getValue("updated") as string
return (
<div className="text-sm text-muted-foreground" title={dayjs(date).format("MMMM D, YYYY h:mm A")}>
{dayjs(date).fromNow()}
</div>
)
},
},
{
accessorKey: "assets",
header: "Preview",
cell: ({ row }) => {
const assets = row.getValue("assets") as string[]
const name = row.getValue("name") as string
if (assets.length > 0) {
return ( return (
<div className="w-12 h-12 rounded border flex items-center justify-center bg-background p-2"> <button
<img onClick={(e) => {
src={`${pb.baseUrl}/api/files/submissions/${row.original.id}/${assets[0]}?thumb=100x100` || "/placeholder.svg"} e.stopPropagation()
alt={name} handleRowToggle(row.id, row.getIsExpanded())
className="w-full h-full object-contain" }}
className="flex items-center justify-center w-8 h-8 hover:bg-muted rounded transition-colors"
>
{row.getIsExpanded() ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</button>
)
},
},
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Name
<SortDesc className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => <div className="font-medium capitalize">{row.getValue("name")}</div>,
},
{
accessorKey: "status",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Status
<SortDesc className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const status = row.getValue("status") as Submission["status"]
return (
<Badge variant="outline" className={getStatusColor(status)}>
{getStatusDisplayName(status)}
</Badge>
)
},
},
{
accessorKey: "created_by",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Submitted By
<SortDesc className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const submission = row.original
const expandedData = (submission as any).expand
const displayName = getDisplayName(submission, expandedData)
const userId = submission.created_by
return (
<div className="flex items-center gap-1">
<UserDisplay
userId={userId}
avatar={expandedData.created_by.avatar}
displayName={displayName}
onClick={handleUserFilter}
size="md"
/> />
{userFilter?.userId === userId && <X className="h-3 w-3 text-muted-foreground" />}
</div> </div>
) )
} },
return (
<div className="w-12 h-12 rounded border flex items-center justify-center bg-muted">
<ImageIcon className="w-6 h-6 text-muted-foreground" />
</div>
)
}, },
}, {
], [handleRowToggle, handleUserFilter, userFilter]) accessorKey: "updated",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Updated
<SortDesc className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const date = row.getValue("updated") as string
return (
<div className="text-sm text-muted-foreground" title={dayjs(date).format("MMMM D, YYYY h:mm A")}>
{dayjs(date).fromNow()}
</div>
)
},
},
{
accessorKey: "assets",
header: "Preview",
cell: ({ row }) => {
const assets = row.getValue("assets") as string[]
const name = row.getValue("name") as string
if (assets.length > 0) {
return (
<div className="w-12 h-12 rounded border flex items-center justify-center bg-background p-2">
<img
src={`${pb.baseUrl}/api/files/submissions/${row.original.id}/${assets[0]}?thumb=100x100` || "/placeholder.svg"}
alt={name}
className="w-full h-full object-contain"
/>
</div>
)
}
return (
<div className="w-12 h-12 rounded border flex items-center justify-center bg-muted">
<ImageIcon className="w-6 h-6 text-muted-foreground" />
</div>
)
},
},
],
[handleRowToggle, handleUserFilter, userFilter],
)
const table = useReactTable({ const table = useReactTable({
data: groupedData, data: groupedData,

View File

@@ -1,7 +1,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner" import { toast } from "sonner"
import { pb, type Submission } from "@/lib/pb"
import { getAllIcons } from "@/lib/api" import { getAllIcons } from "@/lib/api"
import { pb, type Submission } from "@/lib/pb"
import { revalidateAllSubmissions } from "@/app/actions/submissions"
// Query key factory // Query key factory
export const submissionKeys = { export const submissionKeys = {
@@ -46,10 +47,13 @@ export function useApproveSubmission() {
}, },
) )
}, },
onSuccess: (data) => { onSuccess: async (data) => {
// Invalidate and refetch submissions // Invalidate and refetch submissions
queryClient.invalidateQueries({ queryKey: submissionKeys.lists() }) queryClient.invalidateQueries({ queryKey: submissionKeys.lists() })
// Revalidate Next.js cache for community pages
await revalidateAllSubmissions()
toast.success("Submission approved", { toast.success("Submission approved", {
description: "The submission has been approved successfully", description: "The submission has been approved successfully",
}) })
@@ -82,10 +86,13 @@ export function useRejectSubmission() {
}, },
) )
}, },
onSuccess: () => { onSuccess: async () => {
// Invalidate and refetch submissions // Invalidate and refetch submissions
queryClient.invalidateQueries({ queryKey: submissionKeys.lists() }) queryClient.invalidateQueries({ queryKey: submissionKeys.lists() })
// Revalidate Next.js cache for community pages
await revalidateAllSubmissions()
toast.success("Submission rejected", { toast.success("Submission rejected", {
description: "The submission has been rejected", description: "The submission has been rejected",
}) })