Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions db/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ model FormVersion {
version Int
schema Json
uiSchema Json?
archived Boolean @default(false)
createdAt DateTime @default(now())
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
tasks Task[]
Expand Down
7 changes: 5 additions & 2 deletions src/core/components/Filter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ function Filter({ column }: { column: Column<any, unknown> }) {
const selectOptions = column.columnDef.meta?.selectOptions as
| { label: string; value: string }[]
| undefined
const filterPlaceholder = column.columnDef.meta?.filterPlaceholder ?? "Search..."
const columnFilterValue = column.getFilterValue()
const facetedUniqueValues = column.getFacetedUniqueValues()

Expand All @@ -29,6 +30,8 @@ function Filter({ column }: { column: Column<any, unknown> }) {

const sharedInputStyles =
"input w-36 text-primary input-primary input-bordered border-2 bg-base-300 rounded input-sm focus:outline-secondary focus:outline-offset-0 focus:outline-width-3"
const sharedSelectStyles =
"select w-36 text-primary select-primary select-bordered border-2 bg-base-300 rounded select-sm mt-2 focus:outline-secondary focus:outline-offset-0 focus:outline-width-3"

return filterVariant === "range" ? (
<div>
Expand Down Expand Up @@ -64,7 +67,7 @@ function Filter({ column }: { column: Column<any, unknown> }) {
<select
onChange={(e) => column.setFilterValue(e.target.value)}
value={columnFilterValue?.toString()}
className={sharedInputStyles}
className={sharedSelectStyles}
>
<option value="">All</option>
{selectOptions
Expand Down Expand Up @@ -134,7 +137,7 @@ function Filter({ column }: { column: Column<any, unknown> }) {
value={(columnFilterValue ?? "") as string}
onChange={onChangeCallback}
// placeholder={`Search... (${column.getFacetedUniqueValues().size})`}
placeholder="Search..."
placeholder={filterPlaceholder}
className={sharedInputStyles}
list={column.id + "list"}
/>
Expand Down
2 changes: 1 addition & 1 deletion src/core/components/SearchButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const SearchButton = ({ onChange, debounceTime = 500, className }: Props) => {
value={currentSearchTerm}
onChange={handleSearch}
placeholder="Search"
className="pr-3 pl-10 py-2 h-10 w-full pr-3 pl-10 font-semibold
className="pr-3 !pl-10 py-2 h-10 w-full font-semibold
rounded-2xl input text-primary input-primary
nput-bordered border-2 bg-base-300 rounded
focus:outline-secondary focus:outline-offset-0
Expand Down
2 changes: 1 addition & 1 deletion src/core/components/fields/LabelSelectField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export const LabelSelectField = forwardRef<HTMLSelectElement, LabeledSelectField
padding: 0.25rem 0.75rem !important;
border-radius: 3px;
appearance: none;
margin-top: 0.5rem;
margin-top: 0;
}
select:focus {
outline-color: oklch(var(--s)) !important;
Expand Down
2 changes: 1 addition & 1 deletion src/core/components/fields/SelectField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export const SelectField = forwardRef<HTMLSelectElement, SelectFieldProps>(
padding: 0.25rem 0.75rem !important;
border-radius: 3px;
appearance: none;
margin-top: 0.5rem;
margin-top: 0;
}
select:focus {
outline-color: oklch(var(--s)) !important;
Expand Down
4 changes: 2 additions & 2 deletions src/core/components/sidebar/SidebarItems.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
ArchiveBoxIcon,
FolderIcon,
ClipboardDocumentListIcon,
Cog6ToothIcon,
RectangleGroupIcon,
Expand Down Expand Up @@ -132,7 +132,7 @@ export const HomeSidebarItems = (t: TFunction): SidebarItemProps[] => {
userPrivilege: ["USER", "ADMIN"],
},
{
icon: ArchiveBoxIcon,
icon: FolderIcon,
text: t("sidebar.home.projects"),
route: Routes.ProjectsPage(),
tooltipId: "projects-tooltip",
Expand Down
38 changes: 20 additions & 18 deletions src/forms/components/ArchiveFormButton.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,41 @@
import { useMutation } from "@blitzjs/rpc"
import archiveForm from "../mutations/archiveForm"
import toast from "react-hot-toast"
import { Form } from "@prisma/client"
import { TrashIcon } from "@heroicons/react/24/outline"
import { ArchiveBoxIcon, ArrowUturnLeftIcon } from "@heroicons/react/24/outline"

interface ArchiveFormButtonProps {
formId: number
onArchived?: (form: Form) => void // Optional callback for when the form is archived
isArchived?: boolean
onDone?: () => void | Promise<void>
}

const ArchiveFormButton = ({ formId, onArchived }: ArchiveFormButtonProps) => {
const ArchiveFormButton = ({ formId, isArchived = false, onDone }: ArchiveFormButtonProps) => {
const [archiveFormMutation] = useMutation(archiveForm)

const handleArchive = async () => {
const isConfirmed = window.confirm(
"The form will be deleted. You cannot assign it to tasks anymore, but contributors can still finish tasks with this form assigned. Are you sure to continue?"
)
const handleToggle = async () => {
const message = isArchived
? "This will unarchive the form and all of its versions. Are you sure?"
: "This will archive the form and all of its versions. Are you sure?"

if (!isConfirmed) {
return
}
if (!window.confirm(message)) return

try {
const form = await archiveFormMutation({ formId })
toast.success("Form deleted successfully!")
if (onArchived) onArchived(form) // Trigger any additional logic when the form is archived
await archiveFormMutation({ formId, archived: !isArchived })
toast.success(isArchived ? "Form unarchived." : "Form archived.")
await onDone?.()
} catch (error) {
console.error("Failed to delete form:", error)
toast.error("There was an error deleting the form.")
console.error("Failed to update form archive state:", error)
toast.error("There was an error updating the form.")
}
}

return (
<button className="btn btn-ghost" onClick={handleArchive}>
<TrashIcon aria-hidden="true" width={25} className="stroke-primary" />
<button className="btn btn-ghost" onClick={handleToggle} type="button">
{isArchived ? (
<ArrowUturnLeftIcon aria-hidden="true" width={25} className="stroke-primary" />
) : (
<ArchiveBoxIcon aria-hidden="true" width={25} className="stroke-primary" />
)}
</button>
)
}
Expand Down
42 changes: 42 additions & 0 deletions src/forms/components/DeleteFormButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useMutation } from "@blitzjs/rpc"
import deleteForm from "../mutations/deleteForm"
import toast from "react-hot-toast"
import { TrashIcon } from "@heroicons/react/24/outline"

interface DeleteFormButtonProps {
formId: number
onDeleted?: () => void | Promise<void>
}

const DeleteFormButton = ({ formId, onDeleted }: DeleteFormButtonProps) => {
const [deleteFormMutation] = useMutation(deleteForm)

const handleDelete = async () => {
const isConfirmed = window.confirm(
"Versions with no tasks or projects will be permanently deleted. Versions currently in use will be archived instead. Are you sure?"
)

if (!isConfirmed) return

try {
const result = await deleteFormMutation({ formId })
if (result.action === "archived") {
toast.success("Form archived — some versions are still in use by tasks or projects.")
} else {
toast.success("Form deleted.")
}
await onDeleted?.()
} catch (error) {
console.error("Failed to delete form:", error)
toast.error("There was an error deleting the form.")
}
}

return (
<button className="btn btn-ghost" onClick={handleDelete} type="button">
<TrashIcon aria-hidden="true" width={25} className="stroke-primary" />
</button>
)
}

export default DeleteFormButton
198 changes: 157 additions & 41 deletions src/forms/components/FormDeployments.tsx
Original file line number Diff line number Diff line change
@@ -1,49 +1,165 @@
import { useQuery } from "@blitzjs/rpc"
import getFormDeployments from "src/forms/queries/getFormDeployments"
import { useCallback, useMemo } from "react"
import { useMutation } from "@blitzjs/rpc"
import toast from "react-hot-toast"
import Link from "next/link"
import { Routes } from "@blitzjs/next"
import { createColumnHelper } from "@tanstack/react-table"
import deleteFormVersion from "src/forms/mutations/deleteFormVersion"
import { FormVersionWithRelations } from "src/forms/queries/getForm"
import { TrashIcon } from "@heroicons/react/24/outline"
import Table from "src/core/components/Table"

type VersionRow = {
id: number
version: number
name: string
isArchived: boolean
isInUse: boolean
tasks: { id: number; name: string; project: { id: number; name: string } }[]
uniqueProjects: { id: number; name: string }[]
}

const columnHelper = createColumnHelper<VersionRow>()

type Props = {
formId: number
versions: FormVersionWithRelations[]
currentVersionId?: number
formArchived?: boolean
onDeleted?: () => Promise<void> | void
}

export default function FormDeployments({ formId }: Props) {
const [deployments] = useQuery(getFormDeployments, { formId })
export default function FormDeployments({
versions,
currentVersionId: _currentVersionId,
formArchived = false,
onDeleted,
}: Props) {
const [deleteFormVersionMutation] = useMutation(deleteFormVersion)
const handleDelete = useCallback(
async (versionId: number, isInUse: boolean) => {
const confirmed = window.confirm(
isInUse
? "This version is still in use. Deleting it will archive the version instead. Continue?"
: "This form version will be permanently deleted. Are you sure to continue?"
)
if (!confirmed) return
try {
await toast.promise(deleteFormVersionMutation({ id: versionId }), {
loading: isInUse ? "Archiving version..." : "Deleting version...",
success: (result) =>
result?.action === "archived" ? "Form version archived." : "Form version deleted.",
error: (error) =>
error instanceof Error ? error.message : "Failed to delete form version.",
})
await onDeleted?.()
} catch (error) {
console.error("Failed to delete form version:", error)
}
},
[deleteFormVersionMutation, onDeleted]
)

if (deployments.length === 0) {
return (
<p className="text-md italic text-base-content/80">
This form has not been assigned to any tasks or projects yet.
</p>
)
}
const tableData = useMemo<VersionRow[]>(
() =>
versions.map((version) => {
const allProjects = [...version.projects, ...version.tasks.map((t) => t.project)]
return {
id: version.id,
version: version.version,
name: version.name,
isArchived: !!version.archived || formArchived,
isInUse: version.tasks.length > 0 || version.projects.length > 0,
tasks: version.tasks,
uniqueProjects: Array.from(new Map(allProjects.map((p) => [p.id, p])).values()),
}
}),
[versions, formArchived]
)

return (
<table className="table w-full">
<thead className="text-xl text-base-content">
<tr>
<th>Name</th>
<th>Type</th>
<th>Project</th>
<th>Version</th>
</tr>
</thead>
<tbody className="text-lg">
{deployments.map((d, i) => (
<tr key={i}>
<td>{d.name}</td>
<td>
<span
className={`badge badge-sm ${
d.type === "task" ? "badge-primary" : "badge-secondary"
}`}
>
{d.type}
</span>
</td>
<td>{d.projectName ?? "—"}</td>
<td>v{d.version}</td>
</tr>
))}
</tbody>
</table>
const columns = useMemo(
() => [
columnHelper.accessor("version", {
header: "Version",
enableSorting: true,
enableColumnFilter: false,
cell: (info) => `v${info.getValue()}`,
}),
columnHelper.accessor("name", {
header: "Name",
enableSorting: true,
enableColumnFilter: true,
meta: { filterVariant: "text" },
}),
columnHelper.accessor("tasks", {
header: "Tasks",
enableSorting: false,
enableColumnFilter: false,
cell: (info) => {
const tasks = info.getValue()
if (tasks.length === 0) return <span className="text-base-content/40">—</span>
return (
<div className="flex flex-wrap gap-2">
{tasks.map((task) => (
<Link
key={task.id}
className="btn btn-sm btn-primary"
href={Routes.ShowTaskPage({ projectId: task.project.id, taskId: task.id })}
>
{task.name}
</Link>
))}
</div>
)
},
}),
columnHelper.accessor("uniqueProjects", {
header: "Projects",
enableSorting: false,
enableColumnFilter: false,
cell: (info) => {
const projects = info.getValue()
if (projects.length === 0) return <span className="text-base-content/40">—</span>
return (
<div className="flex flex-col gap-2 items-start">
{projects.map((project) => (
<Link
key={project.id}
className="btn btn-sm btn-secondary"
href={Routes.ShowProjectPage({ projectId: project.id })}
>
{project.name}
</Link>
))}
</div>
)
},
}),
columnHelper.display({
id: "delete",
header: "Delete",
enableSorting: false,
enableColumnFilter: false,
cell: (info) =>
info.row.original.isInUse ? (
<span className="badge badge-warning whitespace-nowrap">in use</span>
) : (
<button
type="button"
className="btn btn-ghost text-primary"
onClick={() => void handleDelete(info.row.original.id, false)}
title="Delete version"
>
<TrashIcon className="w-6 h-6" />
</button>
),
}),
],
[handleDelete]
)

if (versions.length === 0) {
return <p className="text-md italic text-base-content/80">No form versions found.</p>
}

return <Table columns={columns} data={tableData} addPagination={false} />
}
Loading
Loading