diff --git a/db/migrations/migration_lock.toml b/db/migrations/migration_lock.toml deleted file mode 100644 index fbffa92c..00000000 --- a/db/migrations/migration_lock.toml +++ /dev/null @@ -1,3 +0,0 @@ -# Please do not edit this file manually -# It should be added in your version-control system (i.e. Git) -provider = "postgresql" \ No newline at end of file diff --git a/db/schema.prisma b/db/schema.prisma index e781448a..d570e8c9 100644 --- a/db/schema.prisma +++ b/db/schema.prisma @@ -148,6 +148,9 @@ model Project { notifications Notification[] ProjectPrivilege ProjectPrivilege[] invitations Invitation[] + metadata Json? // Add metadata field + formVersion FormVersion? @relation(fields: [formVersionId], references: [id]) + formVersionId Int? } enum Status { @@ -243,15 +246,16 @@ model Form { } model FormVersion { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) name String formId Int version Int schema Json uiSchema Json? - createdAt DateTime @default(now()) - form Form @relation(fields: [formId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + form Form @relation(fields: [formId], references: [id], onDelete: Cascade) tasks Task[] + projects Project[] @@index([formId, version], name: "formVersionIndex") } diff --git a/src/core/components/DateFormat.tsx b/src/core/components/DateFormat.tsx index bbd39bc1..bafbbd4d 100644 --- a/src/core/components/DateFormat.tsx +++ b/src/core/components/DateFormat.tsx @@ -9,7 +9,7 @@ export default function DateFormat({ date }: DateFormatProps) { const locale = currentUser ? currentUser.language : "en-US" return ( - + <> {" "} {date ? date.toLocaleDateString(locale, { @@ -22,6 +22,6 @@ export default function DateFormat({ date }: DateFormatProps) { hour12: false, // Use 24-hour format }) : ""} - + ) } diff --git a/src/core/components/Table.tsx b/src/core/components/Table.tsx index 5f64ddb7..604b5da0 100644 --- a/src/core/components/Table.tsx +++ b/src/core/components/Table.tsx @@ -208,7 +208,7 @@ const Table = ({ onChange={(e) => { table.setPageSize(Number(e.target.value)) }} - className={`text-secondary input-secondary input-bordered border-2 bg-base-300 rounded input-sm ${ + className={`text-secondary input-secondary input-bordered border-2 bg-base-300 rounded input-sm mt-2 ${ classNames?.pageSizeSelect || "" }`} > diff --git a/src/core/components/fields/RadioFieldTable.tsx b/src/core/components/fields/RadioFieldTable.tsx index abce7193..8aaa43e7 100644 --- a/src/core/components/fields/RadioFieldTable.tsx +++ b/src/core/components/fields/RadioFieldTable.tsx @@ -7,6 +7,8 @@ interface RadioFieldTableProps { options: { id: number; label: string }[] extraData?: T[] extraColumns?: any[] + value?: number | null // New prop for pre-selected value + onChange?: (selectedId: number) => void // Callback for when a radio button is selected } const RadioFieldTable = ({ @@ -14,17 +16,29 @@ const RadioFieldTable = ({ options, extraData = [], extraColumns = [], + value, // Receive the pre-selected value + onChange, // Receive the onChange callback }: RadioFieldTableProps) => { const { input: { value: selectedId, onChange: setSelectedId }, meta, } = useField(name, { subscription: { value: true, touched: true, error: true } }) + // Ensure the selected ID is initialized with the provided value + React.useEffect(() => { + if (value && value !== selectedId) { + setSelectedId(value) + } + }, [value, selectedId, setSelectedId]) + const handleSelection = useCallback( (id) => { setSelectedId(id) + if (onChange) { + onChange(id) // Trigger the parent callback when a selection is made + } }, - [setSelectedId] + [setSelectedId, onChange] ) const columns = React.useMemo( diff --git a/src/forms/components/AddFormTemplates.tsx b/src/forms/components/AddFormTemplates.tsx index 9c27613c..c9e3c316 100644 --- a/src/forms/components/AddFormTemplates.tsx +++ b/src/forms/components/AddFormTemplates.tsx @@ -51,7 +51,7 @@ const AddFormTemplates: React.FC = ({ return ( -

Select Form Templates

+

Select Form Templates

{open && (
{ +interface DownloadJSONProps { + data: any + fileName: string + type?: "button" | "submit" | "reset" // Match the allowed types + className?: string +} + +const DownloadJSON = ({ data, fileName, type = "button", className }: DownloadJSONProps) => { const downloadJSON = () => { const jsonData = new Blob([JSON.stringify(data)], { type: "application/json" }) const jsonURL = URL.createObjectURL(jsonData) @@ -13,7 +20,7 @@ const DownloadJSON = ({ data, fileName, className }) => { } return ( - ) diff --git a/src/forms/components/DownloadXLSX.tsx b/src/forms/components/DownloadXLSX.tsx index b76cdefa..5886dac3 100644 --- a/src/forms/components/DownloadXLSX.tsx +++ b/src/forms/components/DownloadXLSX.tsx @@ -2,7 +2,14 @@ import React from "react" import * as XLSX from "xlsx" import { saveAs } from "file-saver" -function DownloadXLSX({ data, fileName, className }) { +interface DownloadXLSXProps { + data: any + fileName: string + type?: "button" | "submit" | "reset" // Match the allowed types + className?: string +} + +function DownloadXLSX({ data, fileName, type = "button", className }: DownloadXLSXProps) { const exportToExcel = () => { const worksheet = XLSX.utils.json_to_sheet(data) const workbook = XLSX.utils.book_new() @@ -18,7 +25,7 @@ function DownloadXLSX({ data, fileName, className }) { } return ( - ) diff --git a/src/forms/mutations/createForm.ts b/src/forms/mutations/createForm.ts index 68936673..890ac4fa 100644 --- a/src/forms/mutations/createForm.ts +++ b/src/forms/mutations/createForm.ts @@ -30,6 +30,9 @@ export default resolver.pipe( }, }, }, + include: { + versions: true, // Include related formVersion in the return + }, }) return form diff --git a/src/forms/schema/projectSchema.js b/src/forms/schema/projectSchema.js new file mode 100644 index 00000000..bc0a313f --- /dev/null +++ b/src/forms/schema/projectSchema.js @@ -0,0 +1,52 @@ +export const JsonProject = ` +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "title": "Project Information", + "required": [], + "properties": { + "abstract": { + "type": "string", + "title": "Abstract:" + }, + "citation": { + "type": "string", + "title": "Citation:" + }, + "keywords": { + "type": "string", + "title": "Keywords:", + "description": "Keywords separated by commas." + }, + "publisher": { + "type": "string", + "title": "Publisher:" + }, + "identifier": { + "type": "string", + "title": "Identifier:", + "description": "DOI, ISSN, etc." + } + }, + "description": "Default Project Metadata", + "dependencies": {} +} +` + +export const JsonProjectUI = ` +{ + "abstract": { + "ui:widget": "textarea" + }, + "citation": { + "ui:widget": "textarea" + }, + "ui:order": [ + "abstract", + "keywords", + "citation", + "publisher", + "identifier" + ] +} +` diff --git a/src/forms/utils/getDefaultSchemaList.ts b/src/forms/utils/getDefaultSchemaList.ts index 3b86bc4d..80e005f8 100644 --- a/src/forms/utils/getDefaultSchemaList.ts +++ b/src/forms/utils/getDefaultSchemaList.ts @@ -1,9 +1,10 @@ import { JsonProjectMember, JsonProjectMemberUI } from "src/forms/schema/projectMemberSchema" import { JsonFunder, JsonFunderUI } from "src/forms/schema/funderSchema" +import { JsonProject, JsonProjectUI } from "src/forms/schema/projectSchema" export function getDefaultSchemaLists() { - const schemas = [JsonProjectMember, JsonFunder] - const uis = [JsonProjectMemberUI, JsonFunderUI] + const schemas = [JsonProjectMember, JsonFunder, JsonProject] + const uis = [JsonProjectMemberUI, JsonFunderUI, JsonProjectUI] const restructured = schemas.map((schema, index) => { const parsed = JSON.parse(schema) diff --git a/src/pages/projects/[projectId]/contributors/[contributorId]/edit.tsx b/src/pages/projects/[projectId]/contributors/[contributorId]/edit.tsx index 2caf6fc8..4111128a 100644 --- a/src/pages/projects/[projectId]/contributors/[contributorId]/edit.tsx +++ b/src/pages/projects/[projectId]/contributors/[contributorId]/edit.tsx @@ -1,6 +1,5 @@ import { Suspense } from "react" import { Routes } from "@blitzjs/next" -import Head from "next/head" import { useRouter } from "next/router" import { useQuery, useMutation } from "@blitzjs/rpc" import { useParam } from "@blitzjs/next" diff --git a/src/pages/projects/[projectId]/edit.tsx b/src/pages/projects/[projectId]/edit.tsx index e53a5a5c..ef6886ae 100644 --- a/src/pages/projects/[projectId]/edit.tsx +++ b/src/pages/projects/[projectId]/edit.tsx @@ -1,6 +1,5 @@ -import { Suspense } from "react" +import { Suspense, useState } from "react" import { Routes } from "@blitzjs/next" -import Head from "next/head" import { useRouter } from "next/router" import { useQuery, useMutation } from "@blitzjs/rpc" import { useParam } from "@blitzjs/next" @@ -14,6 +13,12 @@ import { FORM_ERROR } from "final-form" import toast from "react-hot-toast" import useProjectMemberAuthorization from "src/projectprivileges/hooks/UseProjectMemberAuthorization" import { MemberPrivileges } from "db" +import { useCurrentUser } from "src/users/hooks/useCurrentUser" +import DownloadJSON from "src/forms/components/DownloadJSON" +import DownloadXLSX from "src/forms/components/DownloadXLSX" +import { JsonFormModal } from "src/core/components/JsonFormModal" +import getJsonSchema from "src/forms/utils/getJsonSchema" +import { MetadataDisplay } from "src/summary/components/MetaDataDisplay" export const EditProject = () => { // Setup @@ -36,11 +41,7 @@ export const EditProject = () => { const initialValues = { name: project.name, description: project.description!, - abstract: project.abstract!, - keywords: project.keywords!, - citation: project.citation!, - publisher: project.publisher!, - identifier: project.identifier!, + selectedFormVersionId: project.formVersionId, } // Handle events @@ -86,19 +87,152 @@ export const EditProject = () => { } } + // get current user for metadata forms + const currentUser = useCurrentUser() + if (!currentUser) { + throw new Error("You must be logged in to access this page.") + } + + // State to store metadata + const [assignmentMetadata, setAssignmentMetadata] = useState(project.metadata) + + const handleJsonFormSubmit = async (data) => { + console.log("Submitting form data:", data) // Debug log + try { + const updatedProject = await updateProjectMutation({ + id: project.id, + name: project.name, + metadata: data.formData, + }) + + // Update local state + setAssignmentMetadata(data.formData) + + // Update the query data to refresh the background + await setQueryData((prevData) => { + if (!prevData) { + throw new Error("No previous data found") + } + + return { + ...prevData, + metadata: updatedProject.metadata, + formVersion: prevData.formVersion ?? null, // Ensure formVersion is explicitly null if undefined + } + }) + + toast.success("Form data has been successfully saved!") + } catch (error) { + console.error("Failed to save form data:", error) + toast.error("Failed to save form data. Please try again.") + } + } + + const handleJsonFormError = (errors) => { + console.log(errors) + } + // Handle reset metadata + // Using hard reset to bypass validation + const handleResetMetadata = async () => { + try { + // Reset the metadata to an empty object + await updateProjectMutation({ + id: project.id, + name: project.name, + metadata: {}, // Reset metadata to an empty object + }) + + // Update local state + setAssignmentMetadata({}) + + // Show a success toast + toast.success("Metadata has been successfully reset!") + + // Refresh the form data + await setQueryData((prevData) => { + if (!prevData) { + throw new Error("No previous data found") + } + + return { + ...prevData, + metadata: {}, // Reset metadata to an empty object + formVersion: prevData.formVersion ?? null, // Ensure formVersion is explicitly null if undefined + } + }) + } catch (error) { + console.error("Failed to reset metadata:", error) + toast.error("Failed to reset metadata. Please try again.") + } + } + return ( <>

Project Settings

Loading...}> - +
+
+
+
Edit Settings
+ +
+
+
+ +
+
+
+
View and Edit Form Data
+ {project.formVersion ? ( +
+
+ + + +
+ +
+ +
+
+ ) : ( +

+ No form has been created for this project yet. +

+ )} +
+
+
+
diff --git a/src/pages/projects/[projectId]/tasks/[taskId]/edit.tsx b/src/pages/projects/[projectId]/tasks/[taskId]/edit.tsx index 13dc24a6..3a397b07 100644 --- a/src/pages/projects/[projectId]/tasks/[taskId]/edit.tsx +++ b/src/pages/projects/[projectId]/tasks/[taskId]/edit.tsx @@ -1,6 +1,5 @@ import { Suspense } from "react" import { Routes } from "@blitzjs/next" -import Head from "next/head" import Link from "next/link" import { useRouter } from "next/router" import { useMutation } from "@blitzjs/rpc" diff --git a/src/pages/projects/[projectId]/tasks/new.tsx b/src/pages/projects/[projectId]/tasks/new.tsx index 03e367a1..7b14f4e2 100644 --- a/src/pages/projects/[projectId]/tasks/new.tsx +++ b/src/pages/projects/[projectId]/tasks/new.tsx @@ -8,7 +8,6 @@ import { TaskForm } from "src/tasks/components/TaskForm" import { FORM_ERROR } from "final-form" import { Suspense } from "react" import Layout from "src/core/layouts/Layout" -import Head from "next/head" import toast from "react-hot-toast" import { useCurrentContributor } from "src/contributors/hooks/useCurrentContributor" import PageHeader from "src/core/components/PageHeader" diff --git a/src/pages/projects/index.tsx b/src/pages/projects/index.tsx index c5e42c23..4d863363 100644 --- a/src/pages/projects/index.tsx +++ b/src/pages/projects/index.tsx @@ -1,6 +1,5 @@ import { Suspense, useState } from "react" import { Routes } from "@blitzjs/next" -import Head from "next/head" import Link from "next/link" import Layout from "src/core/layouts/Layout" import ProjectsList from "src/projects/components/ProjectsList" diff --git a/src/pages/projects/new.tsx b/src/pages/projects/new.tsx index 0dd5d29f..ea91c1d9 100644 --- a/src/pages/projects/new.tsx +++ b/src/pages/projects/new.tsx @@ -8,10 +8,13 @@ import { ProjectForm } from "src/projects/components/ProjectForm" import { FORM_ERROR } from "final-form" import { Suspense } from "react" import toast from "react-hot-toast" +import { useCurrentUser } from "src/users/hooks/useCurrentUser" const NewProjectPage = () => { const router = useRouter() const [createProjectMutation] = useMutation(createProject) + const currentUser = useCurrentUser() + const userId = currentUser?.id! return ( @@ -20,7 +23,9 @@ const NewProjectPage = () => { Loading...}> router.push(Routes.ProjectsPage())} onSubmit={async (values) => { diff --git a/src/pages/tasks/index.tsx b/src/pages/tasks/index.tsx index f9d3551e..4cf3eb85 100644 --- a/src/pages/tasks/index.tsx +++ b/src/pages/tasks/index.tsx @@ -1,6 +1,5 @@ import Layout from "src/core/layouts/Layout" import { AllTasksList } from "src/tasks/components/AllTaskList" -import Head from "next/head" import { Suspense } from "react" import PageHeader from "src/core/components/PageHeader" diff --git a/src/pages/updates/index.tsx b/src/pages/updates/index.tsx new file mode 100644 index 00000000..70fd3832 --- /dev/null +++ b/src/pages/updates/index.tsx @@ -0,0 +1,36 @@ +import Layout from "src/core/layouts/Layout" +import { Suspense } from "react" +import PageHeader from "src/core/components/PageHeader" +import { + CreateMetadata, + LinkDefaultForms, + TriggerDefaultForms, +} from "src/updates/component/2025_01_migration" + +const UpdatesPage = () => { + return ( + +
+ + Loading...}> +
+

Updates January 1, 2025

+ Please run updates in the following order: +
+ +
+
+ +
+
+ +
+
+
+
+
+
+ ) +} + +export default UpdatesPage diff --git a/src/projects/components/ProjectForm.tsx b/src/projects/components/ProjectForm.tsx index 315deb9d..0fbe919c 100644 --- a/src/projects/components/ProjectForm.tsx +++ b/src/projects/components/ProjectForm.tsx @@ -1,12 +1,58 @@ -import React from "react" +import React, { useState } from "react" import { Form, FormProps } from "src/core/components/fields/Form" import { LabeledTextField } from "src/core/components/fields/LabeledTextField" import { LabeledTextAreaField } from "src/core/components/fields/LabeledTextAreaField" import { z } from "zod" +import ProjectSchemaInput from "./ProjectSchemaInput" + +interface ProjectFormProps> extends FormProps { + formResponseSupplied?: boolean + userId: number + initialValues?: { + selectedFormVersionId?: number | null + [key: string]: any // Allows flexibility for additional form values + } +} + +export function ProjectForm>(props: ProjectFormProps) { + const { + formResponseSupplied = true, + userId, + initialValues = { selectedFormVersionId: null }, // Default value for new projects + ...formProps + } = props + + const [formVersionId, setFormVersionId] = useState( + initialValues.selectedFormVersionId ?? null // Use null if undefined + ) + + //console.log("Current formVersionId:", formVersionId) + //console.log("Initial values:", initialValues) + + // Callback to handle default form creation + const handleDefaultFormCreated = (newFormVersionId: number) => { + //console.log("Default form version created with ID:", newFormVersionId) + setFormVersionId(newFormVersionId) // Update state with the new form version ID + } + + // Override onSubmit to include formVersionId + const handleSubmit = async (values: S, form: any, callback?: any) => { + const updatedValues = { ...values, formVersionId } // Add formVersionId to the submission + //console.log("Submitting form with values:", updatedValues) + + // Call the original onSubmit passed via props with all required arguments + if (formProps.onSubmit) { + await formProps.onSubmit(updatedValues, form, callback) + } + } -export function ProjectForm>(props: FormProps) { return ( - {...props}> + + {...formProps} + initialValues={initialValues} + onSubmit={handleSubmit} + encType="multipart/form-data" + > >(props: FormProps) label="Description:" placeholder="Description" /> -
- -
- -
- -
- -
- Project Details: + + {formResponseSupplied ? ( +
+

+ You have previously selected a form to describe this project. If you change to a new + form, you will retain the old information, and it will be transferred to the new form as + long as the object names (question labels) match. The non-matches are kept in the + background, and if you want to clear it out, please reset the form data under Edit Form. +

+
+ ) : ( +
+

+ Add project details by adding a form. Not sure where to start? Click Add Form and select + the default. You can change this later under settings. +

+
+ )} + + - {/* template: <__component__ name="__fieldName__" label="__Field_Name__" placeholder="__Field_Name__" type="__inputType__" /> */} ) } diff --git a/src/projects/components/ProjectSchemaInput.tsx b/src/projects/components/ProjectSchemaInput.tsx new file mode 100644 index 00000000..256a032c --- /dev/null +++ b/src/projects/components/ProjectSchemaInput.tsx @@ -0,0 +1,158 @@ +import { useMutation, useQuery } from "@blitzjs/rpc" +import React, { useState } from "react" +import Modal from "src/core/components/Modal" +import RadioFieldTable from "src/core/components/fields/RadioFieldTable" +import { FormWithVersionAndUser } from "src/tasks/components/TaskSchemaInput" +import getForms from "src/forms/queries/getForms" +import createForm from "src/forms/mutations/createForm" +import { getDefaultSchemaLists } from "src/forms/utils/getDefaultSchemaList" +import { toast } from "react-hot-toast" + +interface ProjectSchemaInputProps { + userId: number + onDefaultFormCreated: (formVersionId: number) => void // Callback to send back to ProjectForm + selectedFormVersionId: number | null +} + +export const ProjectSchemaInput = ({ + userId, + onDefaultFormCreated, + selectedFormVersionId, +}: ProjectSchemaInputProps) => { + const [openSchemaModal, setOpenSchemaModal] = useState(false) + const handleToggleSchemaUpload = () => setOpenSchemaModal((prev) => !prev) + + const [CreateFormMutation] = useMutation(createForm) + // get default project one + const formTemplateOptions = getDefaultSchemaLists().filter( + (template) => template.label === "Project Information" + ) + + // Handle creating the default form + const handleCreateDefaultForm = async () => { + try { + if (formTemplateOptions.length > 0) { + const defaultTemplate = formTemplateOptions[0] // Assuming there's only one match + const newFormVersion = await CreateFormMutation({ + userId: userId, + schema: defaultTemplate?.schema, + uiSchema: defaultTemplate?.uiSchema, + }) + //console.log("New Default Form Created:", newFormVersion) + toast.success("Default form has been successfully created!") + + // Ensure versions exists and has at least one item + const formVersionId = newFormVersion.versions?.[0]?.id + if (formVersionId) { + onDefaultFormCreated(formVersionId) + } else { + console.error("No form versions found in the returned data.") + toast.error("Failed to retrieve the form version ID.") + } + + // Close the modal after creation + setOpenSchemaModal(false) + } else { + console.error("No default template found for Project Information") + toast.error("No default template found for Project Information") + } + } catch (error) { + console.error("Error creating default form:", error) + toast.error("Failed to create the default form. Please try again.") + } + } + + const [userForms] = useQuery(getForms, { + where: { userId: { in: userId }, archived: false }, + }) + + const typeduserForms = userForms as FormWithVersionAndUser[] + + const schemas = typeduserForms + .filter((form) => form.formVersion) + .flatMap((form) => form.formVersion!) + + // Add "(Default)" to the name of the default form + const options = schemas.map((schema) => ({ + id: schema.id, + label: + (schema.schema as { description?: string })?.description === "Default Project Metadata" + ? `${schema.name} (Default)` + : schema.name, + })) + + // Extra columns for the select table + const versionNumber = schemas.map((schema) => schema.version) + + const extraData = versionNumber.map((version) => ({ + version: version, + })) + + const extraColumns = [ + { + id: "version", + header: "Version", + accessorKey: "version", + cell: (info) => {info.getValue()}, + }, + ] + + // Handle radio button selection + const handleRadioChange = (selectedId: number) => { + console.log("Radio selected with formVersionId:", selectedId) + onDefaultFormCreated(selectedId) + } + + return ( +
+ + +
+

Select Form

+

+ If you have a form that includes our default description label, it is marked as + (Default) below. If you want to make and use a new version of that form to use, click + Create Default below. +

+ +
+ + + + + +
+
+
+
+ ) +} + +export default ProjectSchemaInput diff --git a/src/projects/mutations/createProject.ts b/src/projects/mutations/createProject.ts index 8e1eca64..0e7e76fe 100644 --- a/src/projects/mutations/createProject.ts +++ b/src/projects/mutations/createProject.ts @@ -1,16 +1,23 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { CreateProjectSchema } from "../schemas" +import sendNotification from "src/notifications/mutations/sendNotification" export default resolver.pipe( resolver.zod(CreateProjectSchema), resolver.authorize(), - async (input, ctx) => { + async ({ name, description, formVersionId }, ctx) => { const userId = ctx.session.userId const project = await db.project.create({ data: { // Inputs from project creation form - ...input, + name, + description, + formVersion: formVersionId + ? { + connect: { id: formVersionId }, + } + : undefined, // Initialize project with "To Do", "In Progress", "Done" kanban board columns containers: { create: [ @@ -35,7 +42,7 @@ export default resolver.pipe( }) // Create project member row for "individuals" - await db.projectMember.create({ + const projectMember = await db.projectMember.create({ data: { projectId: project.id, users: { @@ -52,6 +59,58 @@ export default resolver.pipe( }, }) + const firstContainerId = project.containers + .filter((container) => container.projectId === project.id) // Ensure it's tied to the created project + .find((container) => container.containerOrder === 0)?.id + + // only if they pick a form metadata + if (formVersionId) { + // Create a do this task + const task = await db.task.create({ + data: { + name: "Complete Project Description Form", + project: { + connect: { id: project.id }, + }, + containerTaskOrder: 0, // has to be first only one + container: { + connect: { id: firstContainerId }, // put it in default to do + }, + description: + "You added a description form to your project. You can complete that form by going to the project > clicking on settings in the left hand menu > and then edit form data.", + createdBy: { + connect: { id: projectMember.id }, + }, + assignedMembers: { + connect: { id: projectMember.id }, + }, + }, + }) + + // create the task log or you will blow this up + await db.taskLog.create({ + data: { + task: { connect: { id: task.id } }, + assignedTo: { connect: { id: projectMember.id } }, + completedAs: "INDIVIDUAL", + }, + }) + + // create announcement + await sendNotification( + { + templateId: "taskAssigned", + recipients: [userId], + data: { + taskName: "Complete Project Description Form", + createdBy: "STAPLE Admin", + }, + projectId: project.id, + }, + ctx + ) + } + return project } ) diff --git a/src/projects/mutations/updateProject.ts b/src/projects/mutations/updateProject.ts index 353d9a2d..b8463ea2 100644 --- a/src/projects/mutations/updateProject.ts +++ b/src/projects/mutations/updateProject.ts @@ -1,14 +1,20 @@ -import { resolver } from "@blitzjs/rpc"; -import db from "db"; -import { UpdateProjectSchema } from "../schemas"; +import { resolver } from "@blitzjs/rpc" +import db from "db" +import { UpdateProjectSchema } from "../schemas" export default resolver.pipe( resolver.zod(UpdateProjectSchema), resolver.authorize(), async ({ id, ...data }) => { // TODO: in multi-tenant app, you must add validation to ensure correct tenant - const project = await db.project.update({ where: { id }, data }); + const project = await db.project.update({ + where: { id }, + include: { + formVersion: true, // Ensure formVersion is included + }, + data, + }) - return project; + return project } -); +) diff --git a/src/projects/queries/getProject.ts b/src/projects/queries/getProject.ts index b6383253..613b1fab 100644 --- a/src/projects/queries/getProject.ts +++ b/src/projects/queries/getProject.ts @@ -9,7 +9,12 @@ const GetProject = z.object({ }) export default resolver.pipe(resolver.zod(GetProject), resolver.authorize(), async ({ id }) => { - const project = await db.project.findFirst({ where: { id } }) + const project = await db.project.findFirst({ + where: { id }, + include: { + formVersion: true, // Include the related formVersion + }, + }) if (!project) throw new NotFoundError() diff --git a/src/projects/schemas.ts b/src/projects/schemas.ts index 9120cdaf..d5bf207f 100644 --- a/src/projects/schemas.ts +++ b/src/projects/schemas.ts @@ -1,24 +1,17 @@ +import { Prisma } from "db" import { z } from "zod" export const FormProjectSchema = z.object({ name: z.string(), description: z.string().optional().nullable(), - abstract: z.string().optional().nullable(), - keywords: z.string().optional().nullable(), - citation: z.string().optional().nullable(), - publisher: z.string().optional().nullable(), - identifier: z.string().optional().nullable(), + formVersionId: z.number().optional().nullable(), // template: __fieldName__: z.__zodType__(), }) export const CreateProjectSchema = z.object({ name: z.string(), description: z.string().optional().nullable(), - abstract: z.string().optional().nullable(), - keywords: z.string().optional().nullable(), - citation: z.string().optional().nullable(), - publisher: z.string().optional().nullable(), - identifier: z.string().optional().nullable(), + formVersionId: z.number().optional().nullable(), columns: z.any(), // template: __fieldName__: z.__zodType__(), }) @@ -27,11 +20,26 @@ export const UpdateProjectSchema = z.object({ id: z.number(), name: z.string(), description: z.string().optional().nullable(), - abstract: z.string().optional().nullable(), - keywords: z.string().optional().nullable(), - citation: z.string().optional().nullable(), - publisher: z.string().optional().nullable(), - identifier: z.string().optional().nullable(), + formVersionId: z.number().optional().nullable(), + metadata: z + .unknown() + .nullable() + .refine( + (data) => { + if (data === null || data === undefined) { + return true // Allow null or undefined + } + + try { + JSON.parse(JSON.stringify(data)) + return true + } catch (error) { + return false + } + }, + { message: "Invalid JSON format" } + ) + .transform((data) => data as Prisma.NullableJsonNullValueInput), // template: __fieldName__: z.__zodType__(), }) diff --git a/src/summary/components/MetaDataDisplay.tsx b/src/summary/components/MetaDataDisplay.tsx new file mode 100644 index 00000000..8b4a9efa --- /dev/null +++ b/src/summary/components/MetaDataDisplay.tsx @@ -0,0 +1,52 @@ +export const MetadataDisplay = ({ metadata }) => { + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) { + return

No metadata available for this project.

+ } + + const toProperCase = (str) => { + return str + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" ") + } + + const renderValue = (value: any) => { + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + // Handle nested objects + return ( +
+ {Object.entries(value).map(([nestedKey, nestedValue]) => ( + + {toProperCase(nestedKey)}: {renderValue(nestedValue)} +
+
+ ))} +
+ ) + } else if (Array.isArray(value)) { + // Handle arrays + return ( +
    + {value.map((item, index) => ( +
  • {renderValue(item)}
  • + ))} +
+ ) + } else { + // Handle primitive values + return {value?.toString() || "N/A"} + } + } + + return ( +
+ Project Form Data:
+ {Object.entries(metadata).map(([key, value]) => ( + + {toProperCase(key)}: {renderValue(value)} +
+
+ ))} +
+ ) +} diff --git a/src/tasks/components/TaskSchemaInput.tsx b/src/tasks/components/TaskSchemaInput.tsx index e9257438..60b8c78d 100644 --- a/src/tasks/components/TaskSchemaInput.tsx +++ b/src/tasks/components/TaskSchemaInput.tsx @@ -77,7 +77,7 @@ export const TaskSchemaInput = ({ projectManagerIds }: TaskSchemaInputProps) => className="btn btn-primary self-end" onClick={handleToggleSchemaUpload} > - Close + Save diff --git a/src/updates/component/2025_01_migration.tsx b/src/updates/component/2025_01_migration.tsx new file mode 100644 index 00000000..08904e45 --- /dev/null +++ b/src/updates/component/2025_01_migration.tsx @@ -0,0 +1,69 @@ +import React from "react" +import { invoke } from "@blitzjs/rpc" +import { toast } from "react-hot-toast" +import createDefaultFormsForUsers from "../queries/20250101_01_defaultform" +import linkDefaultFormToProjects from "../queries/20250101_02_linkdefaultform" +import migrateColumnsToMetadata from "../queries/20250101_03_createmetadata" + +export const TriggerDefaultForms = () => { + const handleCreateDefaultForms = async () => { + try { + // Call the backend function + await invoke(createDefaultFormsForUsers, {}) + toast.success("Default forms created successfully for all users!") + } catch (error) { + console.error("Error creating default forms:", error) + toast.error("Failed to create default forms. Check console for details.") + } + } + + return ( +
+ +
+ ) +} + +export const LinkDefaultForms = () => { + const handleLinkDefaultForms = async () => { + try { + // Call the backend function + await invoke(linkDefaultFormToProjects, {}) + toast.success("Default forms linked to all users!") + } catch (error) { + console.error("Error linking default forms:", error) + toast.error("Failed to link default forms. Check console for details.") + } + } + + return ( +
+ +
+ ) +} + +export const CreateMetadata = () => { + const handleCreateMetadata = async () => { + try { + // Call the backend function + await invoke(migrateColumnsToMetadata, {}) + toast.success("Migrated metadata for all projects!") + } catch (error) { + console.error("Error migrating data:", error) + toast.error("Failed to migrate data. Check console for details.") + } + } + + return ( +
+ +
+ ) +} diff --git a/src/updates/queries/20250101_01_defaultform.ts b/src/updates/queries/20250101_01_defaultform.ts new file mode 100644 index 00000000..a55aeee9 --- /dev/null +++ b/src/updates/queries/20250101_01_defaultform.ts @@ -0,0 +1,46 @@ +import { Ctx } from "@blitzjs/next" +import db from "db" +import { getDefaultSchemaLists } from "src/forms/utils/getDefaultSchemaList" + +export default async function createDefaultFormsForUsers(_: unknown, ctx: Ctx) { + ctx.session.$authorize() // Authorize the user + + try { + console.log("Fetching users...") + const users = await db.user.findMany() + console.log(`Found ${users.length} users.`) + + const formTemplateOptions = getDefaultSchemaLists().filter( + (template) => template.label === "Project Information" + ) + console.log("Form Template Options:", formTemplateOptions) + + if (!formTemplateOptions.length) { + throw new Error("No form template found with label 'Project Information'") + } + + const defaultTemplate = formTemplateOptions[0] + + for (const user of users) { + console.log(`Creating form for user: ${user.email}`) + const form = await db.form.create({ + data: { + userId: user.id, + versions: { + create: { + name: defaultTemplate?.label, + version: 1, + schema: defaultTemplate?.schema, + uiSchema: defaultTemplate?.uiSchema, + }, + }, + }, + }) + + console.log(`Created form ID: ${form.id} for user: ${user.email}`) + } + } catch (error) { + console.error("Error creating default forms:", error) + throw error + } +} diff --git a/src/updates/queries/20250101_02_linkdefaultform.ts b/src/updates/queries/20250101_02_linkdefaultform.ts new file mode 100644 index 00000000..ad4907bc --- /dev/null +++ b/src/updates/queries/20250101_02_linkdefaultform.ts @@ -0,0 +1,56 @@ +import { Ctx } from "@blitzjs/next" +import db from "db" + +export default async function linkDefaultFormToProjects(_: unknown, ctx: Ctx) { + ctx.session.$authorize() // Authorize the user + + const projects = await db.project.findMany({ + include: { + projectMembers: { + include: { + users: true, // Include the linked users for each project member + }, + }, + }, + }) + + for (const project of projects) { + // Assuming the first member is the project owner + // and that they are the only user in the array + const user = project.projectMembers[0]?.users[0] + + if (user) { + const defaultForm = await db.form.findFirst({ + where: { + userId: user.id, + versions: { + some: { + schema: { + path: ["description"], // Path to "description" key in the schema JSON + equals: "Default Project Metadata", + }, + }, + }, + }, + orderBy: { + createdAt: "desc", // Newest first + }, + include: { versions: true }, + }) + + if (defaultForm && defaultForm.versions[0]) { + // Update the project to link to the formVersionId + await db.project.update({ + where: { id: project.id }, + data: { + formVersionId: defaultForm.versions[0].id, + }, + }) + + console.log( + `Linked formVersionId ${defaultForm.versions[0].id} to project ID: ${project.id}` + ) + } + } + } +} diff --git a/src/updates/queries/20250101_03_createmetadata.ts b/src/updates/queries/20250101_03_createmetadata.ts new file mode 100644 index 00000000..9ceeea70 --- /dev/null +++ b/src/updates/queries/20250101_03_createmetadata.ts @@ -0,0 +1,29 @@ +import db from "db" +import { Ctx } from "@blitzjs/next" + +export default async function migrateColumnsToMetadata(_: unknown, ctx: Ctx) { + ctx.session.$authorize() // Authorize the user + const projects = await db.project.findMany() + + for (const project of projects) { + // Combine old columns into metadata + const metadata = { + abstract: project.abstract, + keywords: project.keywords, + citation: project.citation, + publisher: project.publisher, + identifier: project.identifier, + } + + // Update the project to set the metadata column + // leave other columns just in case + await db.project.update({ + where: { id: project.id }, + data: { + metadata, + }, + }) + + console.log(`Migrated columns to metadata for project ID: ${project.id}`) + } +}