From 74ac3783b9b4e28bb0d5dbfc6d8461efb246e056 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 24 Oct 2024 17:00:03 +0100 Subject: [PATCH 01/29] adding the basline for the setting api --- src/app/api/portfolio/[portfolioId]/route.ts | 31 +++++ .../settings/PortfolioSettings.tsx | 128 ++++++++++++++++-- .../[slug]/(portfolio)/settings/page.tsx | 16 --- 3 files changed, 146 insertions(+), 29 deletions(-) create mode 100644 src/app/api/portfolio/[portfolioId]/route.ts diff --git a/src/app/api/portfolio/[portfolioId]/route.ts b/src/app/api/portfolio/[portfolioId]/route.ts new file mode 100644 index 00000000..cfec932c --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/route.ts @@ -0,0 +1,31 @@ +import { db } from "@/app/db/db"; +import { NextRequest, NextResponse } from "next/server"; +import { portfolio } from "@/app/db/schema/portfolio"; +import { and, eq, inArray } from "drizzle-orm"; + +export async function PUT(request: NextRequest) { + const body = await request.json(); + console.log("HI WE MADE IT!!"); + console.log(body); + + const portfolioId = body.portfolioId; + const userId = body.userId; + + delete body.userId; + delete body.portfolioId; + + console.log(body); + + // Update the database + await db.update(portfolio).set(body).where(eq(portfolio.id, portfolioId)); + + // Returning a successful response + return new NextResponse(JSON.stringify({}), { + status: 200, + }); +} + +export async function DELETE(request: NextRequest) { + const body = await request.json(); + console.log("WE BE DELETIN'"); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx index d7263825..8f17fc2d 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; import { PortfolioSettingsType } from "../../utils"; import { Button } from "@/app/shadcn_components/ui/button"; import { Input } from "@/app/shadcn_components/ui/input"; @@ -23,6 +24,7 @@ import { } from "@/app/shadcn_components/ui/dialog"; import { PortfolioStatus as PortfolioStatusOptions } from "@/app/db/schema/portfolio"; import { PortfolioGoal as PortfolioGoalOptions } from "@/app/db/schema/portfolio"; +import { useSession } from "next-auth/react"; // dropdown selection component for both goal and status @@ -57,6 +59,74 @@ export function SettingsDropdown({ ); } +type updateSettingsArgs = { + userId: bigint; + portfolioId: string; + name: string | null; + budget: number | string | undefined | null; + goal: (typeof PortfolioGoalOptions)[number] | null; + status: (typeof PortfolioStatusOptions)[number] | null; +}; + +type bodyType = { + userId: string; + portfolioId: string; + name?: string; + budget?: number | string; + goal?: string; + status?: string; +}; + +const updateSettings = async ({ + userId, + portfolioId, + name, + budget, + goal, + status, +}: updateSettingsArgs) => { + // We convert the the bigint to a string since big ints are not serialisable and we don't want to loose precision + + // We will create a js object with the starting values + // We will then update the values that are not null + const body: bodyType = { + userId: userId.toString(), + portfolioId: portfolioId, + }; + + if (name) { + body.name = name; + } + + if (budget) { + body.budget = budget; + } + + if (goal) { + body.goal = goal; + } + + if (status) { + body.status = status; + } + + const requestBody = JSON.stringify(body); + + const response = await fetch(`/api/portfolio/${portfolioId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: requestBody, + }); + + if (!response.ok) { + throw new Error("Network response was not ok"); + } + + return response.json(); +}; + export default function PortfolioSettings({ portfolioId, portfolioSettingsData, @@ -64,12 +134,21 @@ export default function PortfolioSettings({ portfolioId: string; portfolioSettingsData: PortfolioSettingsType; }) { - // Running in the client + // This is a client component so we can access the session directly + const session = useSession(); + const router = useRouter(); - // Set up state for portfolioName, portfolioBudget, portfolioGoal and portfolioStatus + const { mutate, isLoading } = useMutation(updateSettings, { + onSuccess: () => { + router.refresh(); + }, + onError: (error) => { + // handle error + console.log(error); + }, + }); - // Syntax const [variable, function whos only job is to update the value of variable] = useState(initial value) const [portfolioName, setPortfolioName] = useState( portfolioSettingsData.name ); @@ -92,6 +171,14 @@ export default function PortfolioSettings({ const [deleteConfirmationByName, setDeleteConfirmationByName] = useState(""); + if (!session.data) { + // The user is not logged in, redirect them to sign in + router.push("/"); + return null; + } + + const userId = session.data.user.dbId; + function handleOpenDeleteModal() { setDeleteConfirmationByName(""); setIsDeleteModalOpen(true); @@ -120,10 +207,15 @@ export default function PortfolioSettings({ // The onClick function called to update the NAME in the DB - function handleRenameDb() { - // apiRanameFunction(portfolioSettingsData.name) - // Update portfolioName - router.refresh(); + function handleRename() { + mutate({ + userId, + portfolioId, + name: portfolioName, + budget: null, + goal: null, + status: null, + }); } // BUDGET CHANGING FUNCTIONS @@ -136,10 +228,15 @@ export default function PortfolioSettings({ // The onClick function called to update the BUDGET in the DB - function handleBudgetUpdateDb() { - // apiBudgetChangeFunction(portfolioSettingsData.budget) - // Update portfolioBudget - router.refresh(); + function handleBudgetUpdate() { + mutate({ + userId, + portfolioId, + name: null, + budget: portfolioBudget, + goal: null, + status: null, + }); } // CHANGING GOAL AND STATUS FUNCTIONALITY @@ -162,6 +259,11 @@ export default function PortfolioSettings({ // HTML to render the page + // TODO: 1) Set up the useMutate hook + // 2) Set up the api functions + // 3) add the call to mutate() so that when we submit the form, the data is updated in the DB + // 4) Create the API + return ( <>
@@ -174,7 +276,7 @@ export default function PortfolioSettings({
-
@@ -192,7 +294,7 @@ export default function PortfolioSettings({ />
-
diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/page.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/page.tsx index 533713cc..ae3fbe04 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/page.tsx @@ -7,24 +7,8 @@ export default async function PortfolioSettingsPage({ params: { slug: string }; }) { const portfolioId = params.slug; - // fetch data securely on the server - // Stef's page!!!! - // 1) Rename - // 2) Update budget, status, goal - // 3) Delete - much harder - - // fetch data in the server - name, budget, goal, - // pass it to a client component to render and take user input - const portfolioSettingsData = await getPortfolioSettings(portfolioId); - // Get goal options and status options by importing something like this: - // import { - // PortfolioStatus, - // PortfolioGoal, - // } from "./../../db/schema/portfolio"; - // and then pass them to the portfoliosettings component - return ( <>
From 0a7e110bf68f9a348410026682f9c239cddaa882 Mon Sep 17 00:00:00 2001 From: StefanWout Date: Fri, 25 Oct 2024 17:32:00 +0100 Subject: [PATCH 02/29] copied mutate function for status and goal --- .../settings/PortfolioSettings.tsx | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx index 8f17fc2d..658cd293 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx @@ -243,18 +243,28 @@ export default function PortfolioSettings({ // The onClick function called to update the GOAL in the DB - function handleGoalUpdateDb() { - // apiGoalChangeFunction(portfolioSettingsData.goal) - // Update portfolioGoal - router.refresh(); + function handleGoalUpdate() { + mutate({ + userId, + portfolioId, + name: null, + budget: null, + goal: portfolioGoal, + status: null, + }); } // The onClick function called to update the BUDGET in the DB - function handleStatusUpdateDb() { - // apiStatusChangeFunction(portfolioSettingsData.status) - // Update portfolioStatus - router.refresh(); + function handleStatusUpdate() { + mutate({ + userId, + portfolioId, + name: null, + budget: null, + goal: null, + status: portfolioStatus, + }); } // HTML to render the page @@ -311,7 +321,7 @@ export default function PortfolioSettings({ setOption={setPortfolioGoal} />
- @@ -326,15 +336,10 @@ export default function PortfolioSettings({ setOption={setPortfolioStatus} /> - -
Portfolio Name: {portfolioName}
-
Portfolio Budget: {portfolioBudget}
-
Goal value: {portfolioGoal}
-
Status value: {portfolioStatus}
- {/* Row 5: Delete */}
From 2a81b83db7cf109746fd3ae79df221bc9e5e896b Mon Sep 17 00:00:00 2001 From: StefanWout Date: Thu, 31 Oct 2024 17:05:47 +0000 Subject: [PATCH 03/29] attempting to complete delete functionality... userId coming up undefined, userId type also coming up undefined --- src/app/api/portfolio/[portfolioId]/route.ts | 38 +++++++++++++ .../settings/PortfolioSettings.tsx | 56 +++++++++++++++---- 2 files changed, 83 insertions(+), 11 deletions(-) diff --git a/src/app/api/portfolio/[portfolioId]/route.ts b/src/app/api/portfolio/[portfolioId]/route.ts index cfec932c..f4f74007 100644 --- a/src/app/api/portfolio/[portfolioId]/route.ts +++ b/src/app/api/portfolio/[portfolioId]/route.ts @@ -2,6 +2,7 @@ import { db } from "@/app/db/db"; import { NextRequest, NextResponse } from "next/server"; import { portfolio } from "@/app/db/schema/portfolio"; import { and, eq, inArray } from "drizzle-orm"; +import { user } from "@/app/db/schema/users"; export async function PUT(request: NextRequest) { const body = await request.json(); @@ -28,4 +29,41 @@ export async function PUT(request: NextRequest) { export async function DELETE(request: NextRequest) { const body = await request.json(); console.log("WE BE DELETIN'"); + + const portfolioId = body.portfolioId; + const userId = body.userId; + + try { + // Verify the user owns this portfolio before deleting + const existingPortfolio = await db.query.portfolio.findFirst({ + where: and( + eq(portfolio.id, portfolioId), + eq(user.id, userId) + ) + }); + + if (!existingPortfolio) { + return new NextResponse( + JSON.stringify({ error: "Portfolio not found or unauthorized" }), + { status: 404 } + ); + } + + // Delete the portfolio + await db.delete(portfolio).where(eq(portfolio.id, portfolioId)); + + // Optionally, delete related records if needed + // await db.delete(someRelatedTable).where(eq(someRelatedTable.portfolioId, portfolioId)); + + // Return successful response + return new NextResponse(JSON.stringify({ message: "Portfolio deleted successfully" }), { + status: 200, + }); + } catch (error) { + console.error("Error deleting portfolio:", error); + return new NextResponse( + JSON.stringify({ error: "Failed to delete portfolio" }), + { status: 500 } + ); + } } diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx index 658cd293..942d015e 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx @@ -89,6 +89,7 @@ const updateSettings = async ({ // We will create a js object with the starting values // We will then update the values that are not null + const body: bodyType = { userId: userId.toString(), portfolioId: portfolioId, @@ -184,19 +185,39 @@ export default function PortfolioSettings({ setIsDeleteModalOpen(true); } - function handleDeleteConfirmation() { - console.log("we be deletin stuff"); - // if (deleteConfirmationByName === portfolioName) { - // //apiDeletePortfolio(portfolioId) - // router.refresh(); - // setIsDeleteModalOpen(false); - // } else { - // // Error if the names don't match - // console.log("Portfolio name does not match"); - // } - router.push("/home"); + async function handleDeleteConfirmation(portfolioId: number, userId: bigint) + { if (deleteConfirmationByName === portfolioName) + console.log('userId:', userId); + console.log('portfolioId:', portfolioId); + console.log('typeof userId:', typeof userId); + try { + const response = await fetch(`/api/portfolio/${portfolioId}`, { + method: "DELETE", + headers: {"Content-Type": "application/json", + }, + body: JSON.stringify({ + userId: typeof userId === 'bigint' ? userId.toString() : userId, + portfolioId: portfolioId + }) + }); + + if (!response.ok) { + throw new Error('Failed to delete portfolio'); + } + else { + setIsDeleteModalOpen(false); + return await response.json(); + } + + } + + catch (error) { + console.error("The portfolio taunts you and tells you to try harder", error); + throw error; + } } + // RENAMING FUNCTIONS // Change NAME functionality - changing state @@ -266,6 +287,19 @@ export default function PortfolioSettings({ status: portfolioStatus, }); } + + // Delete function + + function handleDelete() { + mutate({ + userId, + portfolioId, + name: portfolioName, + budget: portfolioBudget, + goal: portfolioGoal, + status: portfolioStatus, + }); + } // HTML to render the page From cdcc546bd74a3d64f26fcc2aed773deb9fb98129 Mon Sep 17 00:00:00 2001 From: StefanWout Date: Thu, 7 Nov 2024 10:12:48 +0000 Subject: [PATCH 04/29] attempt at completing the delete api call --- src/app/api/portfolio/[portfolioId]/route.ts | 39 +++++----- .../settings/PortfolioSettings.tsx | 74 +++++++++++-------- 2 files changed, 66 insertions(+), 47 deletions(-) diff --git a/src/app/api/portfolio/[portfolioId]/route.ts b/src/app/api/portfolio/[portfolioId]/route.ts index f4f74007..d551c91b 100644 --- a/src/app/api/portfolio/[portfolioId]/route.ts +++ b/src/app/api/portfolio/[portfolioId]/route.ts @@ -1,6 +1,6 @@ import { db } from "@/app/db/db"; import { NextRequest, NextResponse } from "next/server"; -import { portfolio } from "@/app/db/schema/portfolio"; +import { portfolio, portfolioUsers } from "@/app/db/schema/portfolio"; import { and, eq, inArray } from "drizzle-orm"; import { user } from "@/app/db/schema/users"; @@ -27,18 +27,20 @@ export async function PUT(request: NextRequest) { } export async function DELETE(request: NextRequest) { - const body = await request.json(); - console.log("WE BE DELETIN'"); - - const portfolioId = body.portfolioId; - const userId = body.userId; - + console.log("Incoming DELETE request:", request.method); try { - // Verify the user owns this portfolio before deleting + // Parse the request body + const body = await request.json(); + console.log("DELETE Request Received", body); + + const portfolioId = body.portfolioId; + const userId = body.userId; + + // First verify the portfolio exists and belongs to this user const existingPortfolio = await db.query.portfolio.findFirst({ where: and( - eq(portfolio.id, portfolioId), - eq(user.id, userId) + eq(portfolioUsers.portfolioId, portfolioId), + eq(portfolioUsers.userId, userId) ) }); @@ -50,19 +52,20 @@ export async function DELETE(request: NextRequest) { } // Delete the portfolio - await db.delete(portfolio).where(eq(portfolio.id, portfolioId)); + await db + .delete(portfolio) + .where(eq(portfolio.id, portfolioId)); - // Optionally, delete related records if needed - // await db.delete(someRelatedTable).where(eq(someRelatedTable.portfolioId, portfolioId)); + // Return success response + return new NextResponse( + JSON.stringify({ message: "Portfolio successfully deleted" }), + { status: 200 } + ); - // Return successful response - return new NextResponse(JSON.stringify({ message: "Portfolio deleted successfully" }), { - status: 200, - }); } catch (error) { console.error("Error deleting portfolio:", error); return new NextResponse( - JSON.stringify({ error: "Failed to delete portfolio" }), + JSON.stringify({ error: "Your API has Failed to delete the portfolio" }), { status: 500 } ); } diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx index 942d015e..ae6a3d36 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx @@ -128,6 +128,34 @@ const updateSettings = async ({ return response.json(); }; +async function deletePortfolio({ userId, portfolioId }: { + userId: bigint; + portfolioId: string; +}) { + try { + console.log("Attempting to DELETE portfolio by calling API:", { userId, portfolioId }); + const response = await fetch(`/api/portfolio/${portfolioId}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + userId: userId.toString(), + portfolioId: portfolioId, + }), + }); + + if (!response.ok) { + throw new Error("deletePortfolio has been called into action but utterly failed to do the API handoff"); + } + + return await response.json(); + } catch (error) { + console.error("Error after failing to the try to get a response:", error); + throw error; + } +} + export default function PortfolioSettings({ portfolioId, portfolioSettingsData, @@ -150,6 +178,17 @@ export default function PortfolioSettings({ }, }); + const { mutate: mutateDelete } = useMutation(deletePortfolio, { + onSuccess: () => { + setIsDeleteModalOpen(false); + console.log("Succesfully Deleted") + router.push('/home'); + }, + onError: (error) => { + console.error("Because the API hand off failed, we're right back here at the mutation station", error); + }, + }); + const [portfolioName, setPortfolioName] = useState( portfolioSettingsData.name ); @@ -185,39 +224,16 @@ export default function PortfolioSettings({ setIsDeleteModalOpen(true); } - async function handleDeleteConfirmation(portfolioId: number, userId: bigint) - { if (deleteConfirmationByName === portfolioName) - console.log('userId:', userId); - console.log('portfolioId:', portfolioId); - console.log('typeof userId:', typeof userId); - try { - const response = await fetch(`/api/portfolio/${portfolioId}`, { - method: "DELETE", - headers: {"Content-Type": "application/json", - }, - body: JSON.stringify({ - userId: typeof userId === 'bigint' ? userId.toString() : userId, - portfolioId: portfolioId - }) + function handleDeleteConfirmation() { + if (deleteConfirmationByName === portfolioSettingsData.name) { + mutateDelete({ + userId, + portfolioId, }); - - if (!response.ok) { - throw new Error('Failed to delete portfolio'); - } - else { - setIsDeleteModalOpen(false); - return await response.json(); - } - - } - - catch (error) { - console.error("The portfolio taunts you and tells you to try harder", error); - throw error; + console.log("succesfully called the mututate function") } } - // RENAMING FUNCTIONS // Change NAME functionality - changing state From a1432788cdbd82440ce8da4cc3c2dc5655a7ae24 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 7 Nov 2024 11:55:33 +0000 Subject: [PATCH 05/29] fixing delete --- .../[portfolioId]/permissions/route.ts | 80 ++++++++++ src/app/api/portfolio/[portfolioId]/route.ts | 143 ++++++++++++++---- .../settings/PortfolioSettings.tsx | 68 +++++---- 3 files changed, 231 insertions(+), 60 deletions(-) create mode 100644 src/app/api/portfolio/[portfolioId]/permissions/route.ts diff --git a/src/app/api/portfolio/[portfolioId]/permissions/route.ts b/src/app/api/portfolio/[portfolioId]/permissions/route.ts new file mode 100644 index 00000000..d70652e4 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/permissions/route.ts @@ -0,0 +1,80 @@ +import { db } from "@/app/db/db"; +import { NextRequest, NextResponse } from "next/server"; +import { portfolioUsers } from "@/app/db/schema/portfolio"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; + +const PermissionsBodySchema = z.object({ + userId: z.string(), + action: z.enum(["delete", "update"]), +}); + +export async function POST( + request: NextRequest, + { params }: { params: { portfolioId: string } } +) { + // This endpoint lives at portfolio/{portfolioId}/permissions and will return the permissions level for a given portfolio + // Call this endpoint with a) userId, b) portfolioId, c) an action and this api will tell you if that person can do that thing + + const body = await request.json(); + let validatedBody; + + try { + validatedBody = PermissionsBodySchema.parse(body); + } catch (error) { + console.error("Invalid input: ", error); + return new NextResponse(JSON.stringify({ msg: "Invalid input" }), { + status: 400, + }); + } + + const action = validatedBody.action; + const userId = validatedBody.userId; + const portfolioId = params.portfolioId; + + const existingPortfolioPermissions = await db.query.portfolioUsers.findFirst({ + where: and( + eq(portfolioUsers.portfolioId, BigInt(portfolioId)), + eq(portfolioUsers.userId, BigInt(userId)) + ), + }); + + if (!existingPortfolioPermissions) { + return new NextResponse( + JSON.stringify({ error: "Portfolio not found or unauthorized" }), + { status: 404 } + ); + } + + const role = existingPortfolioPermissions.role; + + let permitted; + + // If the action is a delete, we only allow an admin or creator to delete + if (action === "delete") { + if (role === "admin" || role === "creator") { + permitted = true; + } else { + permitted = false; + } + } + + // If the action is an update, we allow an individual admin, creator or write role to update + if (action === "update") { + if (role === "admin" || role === "creator" || role === "write") { + permitted = true; + } else { + permitted = false; + } + } + + // Returning a successful response + return new NextResponse( + JSON.stringify({ + permitted: permitted, + }), + { + status: 200, + } + ); +} diff --git a/src/app/api/portfolio/[portfolioId]/route.ts b/src/app/api/portfolio/[portfolioId]/route.ts index d551c91b..9fbb9de2 100644 --- a/src/app/api/portfolio/[portfolioId]/route.ts +++ b/src/app/api/portfolio/[portfolioId]/route.ts @@ -1,24 +1,37 @@ import { db } from "@/app/db/db"; import { NextRequest, NextResponse } from "next/server"; import { portfolio, portfolioUsers } from "@/app/db/schema/portfolio"; -import { and, eq, inArray } from "drizzle-orm"; -import { user } from "@/app/db/schema/users"; +import { + recommendation, + recommendationMaterials, + planRecommendations, + plan, + scenario, +} from "@/app/db/schema/recommendations"; +import { + propertyTargets, + propertyDetailsEpc, + property, +} from "@/app/db/schema/property"; +import { eq, inArray } from "drizzle-orm"; -export async function PUT(request: NextRequest) { +export async function PUT( + request: NextRequest, + { params }: { params: { portfolioId: string } } +) { const body = await request.json(); - console.log("HI WE MADE IT!!"); - console.log(body); + const portfolioId = params.portfolioId; - const portfolioId = body.portfolioId; + // We'll eventually veryify the user is authorized to update this portfolio const userId = body.userId; delete body.userId; - delete body.portfolioId; - - console.log(body); // Update the database - await db.update(portfolio).set(body).where(eq(portfolio.id, portfolioId)); + await db + .update(portfolio) + .set(body) + .where(eq(portfolio.id, BigInt(portfolioId))); // Returning a successful response return new NextResponse(JSON.stringify({}), { @@ -26,46 +39,110 @@ export async function PUT(request: NextRequest) { }); } -export async function DELETE(request: NextRequest) { - console.log("Incoming DELETE request:", request.method); +export async function DELETE( + request: NextRequest, + { params }: { params: { portfolioId: string } } +) { try { - // Parse the request body - const body = await request.json(); - console.log("DELETE Request Received", body); + const portfolioId = params.portfolioId; - const portfolioId = body.portfolioId; - const userId = body.userId; + // 1) Fetch the portfolio ids + // 2) Fetch the recommendation ids + // 3) Delete all entries from RecommendationMaterials for these recommendations + // 4) Delete all entries from PlanRecommendations that reference plans in the portfolio + // 5) Delete all Plans associated with the portfolio + // 6) Delete all Scenarios associated with the portfolio + // 7) Delete all Recommendations associated with the properties + // 8) Delete PropertyTargetsModel, PropertyDetailsEpcModel, and PropertyModel + // 9) Delete portfolioUsers + // 10) Then, we finally delete the portfolio!!! - // First verify the portfolio exists and belongs to this user - const existingPortfolio = await db.query.portfolio.findFirst({ - where: and( - eq(portfolioUsers.portfolioId, portfolioId), - eq(portfolioUsers.userId, userId) - ) + // Step 1) Fetch the property ids for the portfolio + const propertyIdsResponse = await db.query.property.findMany({ + columns: { id: true }, + where: eq(property.portfolioId, BigInt(portfolioId)), }); + const propertyIds = propertyIdsResponse.map((property) => property.id); - if (!existingPortfolio) { - return new NextResponse( - JSON.stringify({ error: "Portfolio not found or unauthorized" }), - { status: 404 } + // Step 2) Fetch the recommendation ids - filter the recommendation table, where propertyId is in the propertyIds + // if there are no prpoperty Ids, we make recommendationIds an empty array + let recommendationIds: bigint[] = []; + if (propertyIds.length) { + const recommendations = await db.query.recommendation.findMany({ + columns: { id: true }, + where: inArray(recommendation.propertyId, propertyIds), + }); + recommendationIds = recommendations.map( + (recommendation) => recommendation.id ); } - // Delete the portfolio + // Step 3) Delete all entries from RecommendationMaterials for these recommendations + if (recommendationIds.length) { + await db + .delete(recommendationMaterials) + .where( + inArray(recommendationMaterials.recommendationId, recommendationIds) + ); + } + + // Step 4) Delete all entries from PlanRecommendations that reference plans in the portfolio + // Get the plan ids + const plans = await db.query.plan.findMany({ + columns: { id: true }, + where: eq(plan.portfolioId, BigInt(portfolioId)), + }); + const planIds = plans.map((plan) => plan.id); + + if (planIds.length) { + await db + .delete(planRecommendations) + .where(inArray(planRecommendations.planId, planIds)); + } + + // Step 5) Delete all Plans associated with the portfolio + await db.delete(plan).where(eq(plan.portfolioId, BigInt(portfolioId))); + + // Step 6) Delete all Scenarios associated with the portfolio await db - .delete(portfolio) - .where(eq(portfolio.id, portfolioId)); + .delete(scenario) + .where(eq(scenario.portfolioId, BigInt(portfolioId))); + + // Step 7) Delete all Recommendations associated with the properties + if (propertyIds.length) { + await db + .delete(recommendation) + .where(inArray(recommendation.propertyId, propertyIds)); + } + + // Step 8) Delete PropertyTargets, PropertyDetailsEpc, and Property + await db + .delete(propertyTargets) + .where(eq(propertyTargets.portfolioId, BigInt(portfolioId))); + + await db + .delete(propertyDetailsEpc) + .where(eq(propertyDetailsEpc.portfolioId, BigInt(portfolioId))); + + await db + .delete(property) + .where(eq(property.portfolioId, BigInt(portfolioId))); + + await db + .delete(portfolioUsers) + .where(eq(portfolioUsers.portfolioId, BigInt(portfolioId))); + + await db.delete(portfolio).where(eq(portfolio.id, BigInt(portfolioId))); // Return success response return new NextResponse( - JSON.stringify({ message: "Portfolio successfully deleted" }), + JSON.stringify({ message: "Portfolio successfully deleted" }), { status: 200 } ); - } catch (error) { console.error("Error deleting portfolio:", error); return new NextResponse( - JSON.stringify({ error: "Your API has Failed to delete the portfolio" }), + JSON.stringify({ error: "Your API has Failed to delete the portfolio" }), { status: 500 } ); } diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx index ae6a3d36..c667b238 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx @@ -70,7 +70,6 @@ type updateSettingsArgs = { type bodyType = { userId: string; - portfolioId: string; name?: string; budget?: number | string; goal?: string; @@ -92,7 +91,6 @@ const updateSettings = async ({ const body: bodyType = { userId: userId.toString(), - portfolioId: portfolioId, }; if (name) { @@ -128,25 +126,53 @@ const updateSettings = async ({ return response.json(); }; -async function deletePortfolio({ userId, portfolioId }: { +async function deletePortfolio({ + userId, + portfolioId, +}: { userId: bigint; portfolioId: string; }) { try { - console.log("Attempting to DELETE portfolio by calling API:", { userId, portfolioId }); + console.log("Attempting to DELETE portfolio by calling API:", { + userId, + portfolioId, + }); + + // We'll check if the user is authorized to delete this portfolio + const permissionsReponse = await fetch( + `/api/portfolio/${portfolioId}/permissions`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + userId: userId.toString(), + action: "delete", + }), + } + ); + + const permissionsData = await permissionsReponse.json(); + const permitted = permissionsData.permitted; + + // If the user is not permitted to delete the portfolio, we'll throw an error + if (!permitted) { + throw new Error("User is not permitted to delete this portfolio"); + } + const response = await fetch(`/api/portfolio/${portfolioId}`, { method: "DELETE", headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ - userId: userId.toString(), - portfolioId: portfolioId, - }), }); if (!response.ok) { - throw new Error("deletePortfolio has been called into action but utterly failed to do the API handoff"); + throw new Error( + "deletePortfolio has been called into action but utterly failed to do the API handoff" + ); } return await response.json(); @@ -165,7 +191,6 @@ export default function PortfolioSettings({ }) { // This is a client component so we can access the session directly const session = useSession(); - const router = useRouter(); const { mutate, isLoading } = useMutation(updateSettings, { @@ -181,11 +206,13 @@ export default function PortfolioSettings({ const { mutate: mutateDelete } = useMutation(deletePortfolio, { onSuccess: () => { setIsDeleteModalOpen(false); - console.log("Succesfully Deleted") - router.push('/home'); + router.push("/home"); }, onError: (error) => { - console.error("Because the API hand off failed, we're right back here at the mutation station", error); + console.error( + "Because the API hand off failed, we're right back here at the mutation station", + error + ); }, }); @@ -230,7 +257,7 @@ export default function PortfolioSettings({ userId, portfolioId, }); - console.log("succesfully called the mututate function") + console.log("succesfully called the mututate function"); } } @@ -303,19 +330,6 @@ export default function PortfolioSettings({ status: portfolioStatus, }); } - - // Delete function - - function handleDelete() { - mutate({ - userId, - portfolioId, - name: portfolioName, - budget: portfolioBudget, - goal: portfolioGoal, - status: portfolioStatus, - }); - } // HTML to render the page From d1352a730c7c8bbec89dbb6029ac63c5be1f5994 Mon Sep 17 00:00:00 2001 From: StefanWout Date: Thu, 7 Nov 2024 16:23:56 +0000 Subject: [PATCH 06/29] removed userId from body of updateSettings api cal --- src/app/api/portfolio/[portfolioId]/route.ts | 7 +---- .../settings/PortfolioSettings.tsx | 26 ++++++++++++++++--- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/app/api/portfolio/[portfolioId]/route.ts b/src/app/api/portfolio/[portfolioId]/route.ts index 9fbb9de2..a3a474d2 100644 --- a/src/app/api/portfolio/[portfolioId]/route.ts +++ b/src/app/api/portfolio/[portfolioId]/route.ts @@ -14,6 +14,7 @@ import { property, } from "@/app/db/schema/property"; import { eq, inArray } from "drizzle-orm"; +import { z } from "zod"; export async function PUT( request: NextRequest, @@ -22,12 +23,6 @@ export async function PUT( const body = await request.json(); const portfolioId = params.portfolioId; - // We'll eventually veryify the user is authorized to update this portfolio - const userId = body.userId; - - delete body.userId; - - // Update the database await db .update(portfolio) .set(body) diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx index c667b238..eec3be59 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx @@ -69,7 +69,6 @@ type updateSettingsArgs = { }; type bodyType = { - userId: string; name?: string; budget?: number | string; goal?: string; @@ -84,14 +83,33 @@ const updateSettings = async ({ goal, status, }: updateSettingsArgs) => { + const permissionsReponse = await fetch( + `/api/portfolio/${portfolioId}/permissions`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + userId: userId.toString(), + action: "update", + }), + } + ); + + const permissionsData = await permissionsReponse.json(); + const permitted = permissionsData.permitted; + console.log("USER IS PERMITTED TO DO THIS!!!!") + // If the user is not permitted to delete the portfolio, we'll throw an error + if (!permitted) { + throw new Error("User is not permitted to update this portfolio"); + } // We convert the the bigint to a string since big ints are not serialisable and we don't want to loose precision // We will create a js object with the starting values // We will then update the values that are not null - const body: bodyType = { - userId: userId.toString(), - }; + const body: bodyType = {} if (name) { body.name = name; From b33fdfd34be1068b92ccb08b803f9532ef93e4e4 Mon Sep 17 00:00:00 2001 From: StefanWout Date: Thu, 7 Nov 2024 17:44:38 +0000 Subject: [PATCH 07/29] integrated zod parameter verification into the PUT function for updating the settings on the portfolio settings page --- src/app/api/portfolio/[portfolioId]/route.ts | 27 +++++++++++++++++-- .../settings/PortfolioSettings.tsx | 2 ++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/app/api/portfolio/[portfolioId]/route.ts b/src/app/api/portfolio/[portfolioId]/route.ts index a3a474d2..d0705c5c 100644 --- a/src/app/api/portfolio/[portfolioId]/route.ts +++ b/src/app/api/portfolio/[portfolioId]/route.ts @@ -13,15 +13,38 @@ import { propertyDetailsEpc, property, } from "@/app/db/schema/property"; -import { eq, inArray } from "drizzle-orm"; +import { eq, inArray, Name } from "drizzle-orm"; import { z } from "zod"; +const UpdateBodySchema = z.object({ + name: z.optional(z.string()), + budget: z.optional(z.number()), + goal: z.optional(z.string()), + status: z.optional(z.string()), +}); + export async function PUT( request: NextRequest, { params }: { params: { portfolioId: string } } ) { const body = await request.json(); + let validatedBody; + + try { + validatedBody = UpdateBodySchema.parse(body); + } catch (error) { + console.error("Invalid input: ", error); + return new NextResponse(JSON.stringify({ msg: "Invalid input" }), { + status: 400, + }); + } + const portfolioId = params.portfolioId; + const name = validatedBody.name; + const budget = validatedBody.budget; + const goal = validatedBody.goal; + const status = validatedBody.status; + await db .update(portfolio) @@ -43,7 +66,7 @@ export async function DELETE( // 1) Fetch the portfolio ids // 2) Fetch the recommendation ids - // 3) Delete all entries from RecommendationMaterials for these recommendations + // 3) Delete all entries from RecommendationMaterials for these recommendationsP // 4) Delete all entries from PlanRecommendations that reference plans in the portfolio // 5) Delete all Plans associated with the portfolio // 6) Delete all Scenarios associated with the portfolio diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx index eec3be59..ae222c64 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx @@ -135,6 +135,8 @@ const updateSettings = async ({ "Content-Type": "application/json", }, body: requestBody, + + }); if (!response.ok) { From ac15a4b0035c7c684a31c194779541da3e1ef089 Mon Sep 17 00:00:00 2001 From: StefanWout Date: Mon, 11 Nov 2024 17:03:17 +0000 Subject: [PATCH 08/29] got the input and dropdowns on the portfolio settings page to behave responsively --- .../settings/PortfolioSettings.tsx | 217 +++++++++--------- 1 file changed, 111 insertions(+), 106 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx index ae222c64..73124664 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx @@ -25,6 +25,7 @@ import { import { PortfolioStatus as PortfolioStatusOptions } from "@/app/db/schema/portfolio"; import { PortfolioGoal as PortfolioGoalOptions } from "@/app/db/schema/portfolio"; import { useSession } from "next-auth/react"; +import PortfolioPlanTable from "@/app/components/portfolio/plan/PlanTable"; // dropdown selection component for both goal and status @@ -32,10 +33,12 @@ export function SettingsDropdown({ startingValue, options, setOption, + className, }: { startingValue: string; options: string[]; setOption: (option: string) => void; + className?: string; }) { function handleValueChange(newValue: string) { setOption(newValue); @@ -43,7 +46,7 @@ export function SettingsDropdown({ return ( -
-
- -
- - {/* Row 2: Budget */} -
- Budget: -
-
- handleNumericKeyDown(e)} - /> -
-
- -
- - {/* Row 3: Goal */} -
- Goal: -
- -
- -
- + + + {/* Row 2: Budget */} +
+ Portfolio Budget: +
+
+ handleNumericKeyDown(e)} + /> +
+
+ - - {/* Row 4: Status */} -
- Status: -
-
- -
-
+ + {/* Row 3: Goal */} +
+ Goal: +
+
+ +
+
+ - - {/* Row 5: Delete */} -
-
- -
- {/* Delete portfolio modal */} - - - Are you sure? -

- To confirm, please type the name of the portfolio ( - {portfolioSettingsData.name}) -

- setDeleteConfirmationByName(e.target.value)} - placeholder="Type portfolio name" - /> - - - - -
-
+
+ + {/* Row 4: Status */} +
+ Status: +
+
+ +
+
+ +
+ + {/* Row 5: Delete */} +
+
+
- + + {/* Delete portfolio modal */} + + + Are you sure? +

+ To confirm, please type the name of the portfolio ( + {portfolioSettingsData.name}) +

+ setDeleteConfirmationByName(e.target.value)} + placeholder="Type portfolio name" + /> + + + + +
+
+ ); } From a2b93c5c63fe6647dc726cf6dee93a9f4fb927ce Mon Sep 17 00:00:00 2001 From: StefanWout Date: Tue, 12 Nov 2024 15:55:45 +0000 Subject: [PATCH 09/29] succesfully beautified the settings page, could be made more beautiful but it's currently funcitonal and responsive, not sure about the look of the danger zone or the reminder text on offer but that's easily changed. --- .../settings/PortfolioSettings.tsx | 47 +++++++++++-------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx index 73124664..28a6f2d0 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx @@ -363,23 +363,25 @@ export default function PortfolioSettings({ return (
-
+
{/* Row 1: Name */} -
+
Change the Portfolio Name:
-
+
- +
+ {/* Row 2: Budget */} -
- Portfolio Budget: +
+ Portfolio Budget:
+ Total Budget across ALL Properties. We aim to make sure works stay within this budget.
+
{/* Row 3: Goal */} -
- Goal: +
+ Goal:
+ Adjust the overall aim of the works conducted on this portfolio.
+
{/* Row 4: Status */} -
- Status: + +
+ Status:
+ Adjust where the portfolio stands in the works pipeline.
-
+
- +
+ {/* Row 5: Delete */} -
-
- +
+
DANGER ZONE - Permanently Delete the Entire Portfolio
+
+ +
From cdbb2bbfa0f38c0f9aef97012c57f248aa6147bc Mon Sep 17 00:00:00 2001 From: StefanWout Date: Fri, 15 Nov 2024 17:31:55 +0000 Subject: [PATCH 10/29] changed the settings page into a table to maintain visual consistency --- .../settings/PortfolioSettings.tsx | 282 ++++++++++++------ 1 file changed, 197 insertions(+), 85 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx index 28a6f2d0..5a1cee59 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx @@ -22,6 +22,14 @@ import { DialogTitle, DialogFooter, } from "@/app/shadcn_components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/app/shadcn_components/ui/table"; import { PortfolioStatus as PortfolioStatusOptions } from "@/app/db/schema/portfolio"; import { PortfolioGoal as PortfolioGoalOptions } from "@/app/db/schema/portfolio"; import { useSession } from "next-auth/react"; @@ -362,95 +370,192 @@ export default function PortfolioSettings({ // 4) Create the API return ( -
-
- {/* Row 1: Name */} -
- Change the Portfolio Name: -
-
- -
-
- -
-
+ //
+ //
+ // {/* Row 1: Name */} + //
+ // Change the Portfolio Name: + //
+ //
+ // + //
+ //
+ // + //
+ //
- {/* Row 2: Budget */} -
- Portfolio Budget:
- Total Budget across ALL Properties. We aim to make sure works stay within this budget. -
-
- handleNumericKeyDown(e)} - /> -
-
- -
-
+ // {/* Row 2: Budget */} + //
+ // Portfolio Budget:
+ // Total Budget across ALL Properties. We aim to make sure works stay within this budget. + //
+ //
+ // handleNumericKeyDown(e)} + // /> + //
+ //
+ // + //
+ //
- {/* Row 3: Goal */} -
- Goal:
- Adjust the overall aim of the works conducted on this portfolio. -
-
- -
-
- -
-
+ // {/* Row 3: Goal */} + //
+ // Goal:
+ // Adjust the overall aim of the works conducted on this portfolio. + //
+ //
+ // + //
+ //
+ // + //
+ //
- {/* Row 4: Status */} + // {/* Row 4: Status */} -
- Status:
- Adjust where the portfolio stands in the works pipeline. -
-
- -
-
- -
-
+ //
+ // Status:
+ // Adjust where the portfolio stands in the works pipeline. + //
+ //
+ // + //
+ //
+ // + //
+ //
- {/* Row 5: Delete */} -
-
DANGER ZONE - Permanently Delete the Entire Portfolio
-
- -
-
-
+ // {/* Row 5: Delete */} + //
+ //
DANGER ZONE - Permanently Delete the Entire Portfolio
+ //
+ // + //
+ //
+ //
- {/* Delete portfolio modal */} + // {/* Delete portfolio modal */} + // + // + // Are you sure? + //

+ // To confirm, please type the name of the portfolio ( + // {portfolioSettingsData.name}) + //

+ // setDeleteConfirmationByName(e.target.value)} + // placeholder="Type portfolio name" + // /> + // + // + // + // + //
+ //
+ //
+ +
+
+ + + + + Rename the Portfolio:

Permanently change the name of your portfolio

+
+ + + + + + +
+ + + Change the Portfolio Budget:

The total budget across ALL properties. Works aim to stay within this budget

+
+ + handleNumericKeyDown(e)}/> + + + + +
+ + + Change the Portfolio Goal:

Adjust the overall aim of the works conducted on this portfolio

+
+ + + + + + +
+ + + Change the Status of the Portfolio:

Adjust where the portfolio stands in the works pipeline

+
+ + + + + + +
+
+
+
+
+ + Danger Zone: + + + + Delete the Portfolio:

Permanently delete the portfolio and all property data assigned to this portfolio

+
+ + + +
+
+
Are you sure? @@ -480,7 +585,14 @@ export default function PortfolioSettings({ - -
+ +
+
+ ); + + } + + + \ No newline at end of file From e81db9eda7e04d1271ba8c45b4efb341f32ffe58 Mon Sep 17 00:00:00 2001 From: StefanWout Date: Mon, 18 Nov 2024 11:12:37 +0000 Subject: [PATCH 11/29] final commit for settings page --- .../settings/PortfolioSettings.tsx | 120 ------------------ 1 file changed, 120 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx index 5a1cee59..2c670bd8 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx @@ -370,126 +370,6 @@ export default function PortfolioSettings({ // 4) Create the API return ( - //
- //
- // {/* Row 1: Name */} - //
- // Change the Portfolio Name: - //
- //
- // - //
- //
- // - //
- //
- - // {/* Row 2: Budget */} - //
- // Portfolio Budget:
- // Total Budget across ALL Properties. We aim to make sure works stay within this budget. - //
- //
- // handleNumericKeyDown(e)} - // /> - //
- //
- // - //
- //
- - // {/* Row 3: Goal */} - //
- // Goal:
- // Adjust the overall aim of the works conducted on this portfolio. - //
- //
- // - //
- //
- // - //
- //
- - // {/* Row 4: Status */} - - //
- // Status:
- // Adjust where the portfolio stands in the works pipeline. - //
- //
- // - //
- //
- // - //
- //
- - // {/* Row 5: Delete */} - //
- //
DANGER ZONE - Permanently Delete the Entire Portfolio
- //
- // - //
- //
- //
- - // {/* Delete portfolio modal */} - // - // - // Are you sure? - //

- // To confirm, please type the name of the portfolio ( - // {portfolioSettingsData.name}) - //

- // setDeleteConfirmationByName(e.target.value)} - // placeholder="Type portfolio name" - // /> - // - // - // - // - //
- //
- //
From 2fa086075d60e0c21305009d0a0774fcf5b9f233 Mon Sep 17 00:00:00 2001 From: StefanWout Date: Mon, 18 Nov 2024 11:21:00 +0000 Subject: [PATCH 12/29] removed widths from settings div to make sure it is always centered --- .../portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx | 2 +- src/app/portfolio/[slug]/(portfolio)/settings/page.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx index 2c670bd8..9f4081ba 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx @@ -371,7 +371,7 @@ export default function PortfolioSettings({ return ( -
+
diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/page.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/page.tsx index ae3fbe04..8769262a 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/page.tsx @@ -11,7 +11,7 @@ export default async function PortfolioSettingsPage({ return ( <> -
+
Date: Mon, 18 Nov 2024 17:10:25 +0000 Subject: [PATCH 13/29] form in modal for remote assesment in place --- package-lock.json | 228 ++++++++++++++++-- package.json | 9 +- src/app/components/portfolio/AddNew.tsx | 17 +- src/app/components/portfolio/Toolbar.tsx | 9 + .../components/RemoteAssesmentModal.tsx | 96 ++++++++ src/app/shadcn_components/ui/form.tsx | 178 ++++++++++++++ src/app/shadcn_components/ui/label.tsx | 1 + 7 files changed, 514 insertions(+), 24 deletions(-) create mode 100644 src/app/portfolio/[slug]/components/RemoteAssesmentModal.tsx create mode 100644 src/app/shadcn_components/ui/form.tsx diff --git a/package-lock.json b/package-lock.json index 65e8eb2b..ac28db5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,15 +11,16 @@ "@headlessui-float/react": "^0.11.2", "@headlessui/react": "^1.7.14", "@heroicons/react": "^2.0.18", + "@hookform/resolvers": "^3.9.1", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-dialog": "^1.0.4", "@radix-ui/react-dropdown-menu": "^2.0.5", "@radix-ui/react-hover-card": "^1.0.6", - "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-navigation-menu": "^1.1.3", "@radix-ui/react-select": "^1.2.2", "@radix-ui/react-separator": "^1.0.3", - "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tooltip": "^1.0.7", "@remixicon/react": "^4.2.0", "@tanstack/react-query": "^4.29.12", @@ -44,10 +45,12 @@ "postcss": "8.4.23", "react": "18.2.0", "react-dom": "18.2.0", + "react-hook-form": "^7.53.2", "tailwind-merge": "^1.13.2", "tailwindcss": "^3.4.3", "tailwindcss-animate": "^1.0.6", - "typescript": "5.0.4" + "typescript": "5.0.4", + "zod": "^3.23.8" }, "devDependencies": { "@tailwindcss/forms": "^0.5.7", @@ -1181,6 +1184,15 @@ "react": ">= 16" } }, + "node_modules/@hookform/resolvers": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.1.tgz", + "integrity": "sha512-ud2HqmGBM0P0IABqoskKWI6PEf6ZDDBZkFqe2Vnl+mTHCEHzr3ISjjZyCwTjC/qpL25JC9aIDkloQejvMeq0ug==", + "license": "MIT", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.8", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", @@ -1608,6 +1620,25 @@ } } }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", @@ -1678,6 +1709,25 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz", @@ -1843,18 +1893,41 @@ } }, "node_modules/@radix-ui/react-label": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.0.2.tgz", - "integrity": "sha512-N5ehvlM7qoTLx7nWPodsPYPgMzA5WM8zZChQg8nyFJKnDO5WHdba1vv5/H6IO5LtJMfD2Q3wh1qHFGNtK0w3bQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.0.tgz", + "integrity": "sha512-peLblDlFw/ngk3UWq0VnYaOLy6agTZZ+MUO/WhVfm14vJGML+xH4FAl2XQGLqdefjNb7ApRg6Yn7U42ZhmYXdw==", + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "1.0.3" + "@radix-ui/react-primitive": "2.0.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -1905,6 +1978,25 @@ } } }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-navigation-menu": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.1.3.tgz", @@ -2043,6 +2135,25 @@ } } }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz", @@ -2117,6 +2228,25 @@ } } }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.0.3.tgz", @@ -2141,16 +2271,31 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", - "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.1" + "@radix-ui/react-compose-refs": "1.1.0" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -2274,6 +2419,25 @@ } } }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", @@ -7374,6 +7538,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/next/node_modules/zod": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz", + "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/node-releases": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.12.tgz", @@ -8319,6 +8492,22 @@ "react": "^18.2.0" } }, + "node_modules/react-hook-form": { + "version": "7.53.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.53.2.tgz", + "integrity": "sha512-YVel6fW5sOeedd1524pltpHX+jgU2u3DSDtXEaBORNdqiNrsX/nUI/iGXONegttg0mJVnfrIkiV0cmTU6Oo2xw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -10015,9 +10204,10 @@ } }, "node_modules/zod": { - "version": "3.21.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz", - "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==", + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 1e0a2c7c..382e7468 100644 --- a/package.json +++ b/package.json @@ -17,15 +17,16 @@ "@headlessui-float/react": "^0.11.2", "@headlessui/react": "^1.7.14", "@heroicons/react": "^2.0.18", + "@hookform/resolvers": "^3.9.1", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-dialog": "^1.0.4", "@radix-ui/react-dropdown-menu": "^2.0.5", "@radix-ui/react-hover-card": "^1.0.6", - "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-navigation-menu": "^1.1.3", "@radix-ui/react-select": "^1.2.2", "@radix-ui/react-separator": "^1.0.3", - "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tooltip": "^1.0.7", "@remixicon/react": "^4.2.0", "@tanstack/react-query": "^4.29.12", @@ -50,10 +51,12 @@ "postcss": "8.4.23", "react": "18.2.0", "react-dom": "18.2.0", + "react-hook-form": "^7.53.2", "tailwind-merge": "^1.13.2", "tailwindcss": "^3.4.3", "tailwindcss-animate": "^1.0.6", - "typescript": "5.0.4" + "typescript": "5.0.4", + "zod": "^3.23.8" }, "devDependencies": { "@tailwindcss/forms": "^0.5.7", diff --git a/src/app/components/portfolio/AddNew.tsx b/src/app/components/portfolio/AddNew.tsx index aefb36f8..5a347e02 100644 --- a/src/app/components/portfolio/AddNew.tsx +++ b/src/app/components/portfolio/AddNew.tsx @@ -5,7 +5,7 @@ import { NavigationMenuLink, NavigationMenuTrigger, } from "@/app/shadcn_components/ui/navigation-menu"; -import { PlusIcon, TableCellsIcon } from "@heroicons/react/24/outline"; +import { PlusIcon, TableCellsIcon, DocumentMagnifyingGlassIcon } from "@heroicons/react/24/outline"; import * as React from "react"; import { cn } from "@/lib/utils"; @@ -39,9 +39,13 @@ ListItem.displayName = "ListItem"; export default function AddNewDropDown({ isUploadCsvOpen, setIsUploadCsvOpen, + isRemoteAssesmentOpen, + setIsRemoteAssesmentOpen, }: { isUploadCsvOpen: boolean; setIsUploadCsvOpen: React.Dispatch>; + isRemoteAssesmentOpen: boolean; + setIsRemoteAssesmentOpen: React.Dispatch>; }) { function handleCickAddUnit() { console.log("Add unit"); @@ -51,6 +55,10 @@ export default function AddNewDropDown({ setIsUploadCsvOpen(!isUploadCsvOpen); } + function handleClickRemoteAssesment() { + setIsRemoteAssesmentOpen(!isRemoteAssesmentOpen); + } + return ( @@ -63,7 +71,12 @@ export default function AddNewDropDown({ Add Unit
- + +
+ Remote Assesment +
+ Schedule a remote assesment +
Upload CSV diff --git a/src/app/components/portfolio/Toolbar.tsx b/src/app/components/portfolio/Toolbar.tsx index d09da5c0..b3754437 100644 --- a/src/app/components/portfolio/Toolbar.tsx +++ b/src/app/components/portfolio/Toolbar.tsx @@ -13,6 +13,7 @@ import { import AddNewDropDown from "./AddNew"; import { cva } from "class-variance-authority"; import UploadCsvModal from "@/app/portfolio/[slug]/components/UploadCsvModal"; +import RemoteAssesmentModal from "@/app/portfolio/[slug]/components/RemoteAssesmentModal"; import { useState } from "react"; import { useRouter } from "next/navigation"; @@ -40,6 +41,7 @@ export function Toolbar({ portfolioId }: ToolbarProps) { } const [modalIsOpen, setModalIsOpen] = useState(false); + const [isRemoteAssesmentOpen, setIsRemoteAssesmentOpen] = useState(false); return ( @@ -71,8 +73,15 @@ export function Toolbar({ portfolioId }: ToolbarProps) { + void; + portfolioId: string; +}) { + const [scenario, setScenario] = useState(undefined); + const [housingType, sethousingType] = useState(""); + const [selectedGoal, setSelectedGoal] = useState(""); + const [goalValue, setGoalValue] = useState(""); + const [addressLineOne, setAddressLineOne] = useState(""); + const [postcode, setPostcode] = useState(""); + const [buttonDisabled, setButtonDisabled] = useState(true); + + const form = useForm(); + + + return ( + <> + + setIsOpen(false)} + > + +
+ + +
+
+ + + + Title Goes Here + +
+ Body Goes here +
+ +
+ +
+
+
+
+
+
+
+ + ); +} \ No newline at end of file diff --git a/src/app/shadcn_components/ui/form.tsx b/src/app/shadcn_components/ui/form.tsx new file mode 100644 index 00000000..895f6e82 --- /dev/null +++ b/src/app/shadcn_components/ui/form.tsx @@ -0,0 +1,178 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/app/shadcn_components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +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 ") + } + + 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( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +
@@ -93,4 +279,5 @@ export default function RemoteAssesmentModal({ ); -} \ No newline at end of file +} + diff --git a/src/app/portfolio/[slug]/components/UploadCsvModal.tsx b/src/app/portfolio/[slug]/components/UploadCsvModal.tsx index a046403d..0ebae80f 100644 --- a/src/app/portfolio/[slug]/components/UploadCsvModal.tsx +++ b/src/app/portfolio/[slug]/components/UploadCsvModal.tsx @@ -96,7 +96,7 @@ const selectGoalOptions = [ { label: "Reduce energy consumption", value: "Reduce energy consumption", - disabled: true, + disabled: false, }, ]; @@ -272,7 +272,7 @@ export default function UploadCsvModal({ ); }} /> - {selectedGoal && ( + {selectedGoal === "Increase EPC" && (
- Address Line One + Address Line 1
@@ -286,6 +288,7 @@ export default function RemoteAssesmentModal({ type="button" className="inline-flex justify-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-900 hover:bg-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2" onClick={() => setIsOpen(false)} + > Cancel From eab898fb83618ea2ad562f9e1499bdece94adfbf Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 20 Nov 2024 15:56:48 +0000 Subject: [PATCH 17/29] generating presigned url --- .../components/RemoteAssesmentModal.tsx | 129 ++++++++++++++---- 1 file changed, 104 insertions(+), 25 deletions(-) diff --git a/src/app/portfolio/[slug]/components/RemoteAssesmentModal.tsx b/src/app/portfolio/[slug]/components/RemoteAssesmentModal.tsx index a98dd0b6..818c326f 100644 --- a/src/app/portfolio/[slug]/components/RemoteAssesmentModal.tsx +++ b/src/app/portfolio/[slug]/components/RemoteAssesmentModal.tsx @@ -6,6 +6,8 @@ import { Input } from "@/app/shadcn_components/ui/input"; import { Button } from "@/app/shadcn_components/ui/button"; import { Float } from "@headlessui-float/react"; import { ChevronDownIcon } from "@heroicons/react/20/solid"; +import { useMutation } from "@tanstack/react-query"; +import { useSession } from "next-auth/react"; type Option = { label: string; @@ -41,7 +43,7 @@ const selectGoalOptions = [ { label: "Reduce energy consumption", value: "Reduce energy consumption", - disabled: false, + disabled: false, // TODO: Disable }, ]; @@ -63,8 +65,6 @@ const goalValueOptions = [ }, ]; - - export function SelectDropdown({ options, selectedOption, @@ -113,6 +113,68 @@ export function SelectDropdown({ ); } +async function generatePresignedUrl({ + userId, + portfolioId, + fileKey, +}: { + userId: string; + portfolioId: string; + fileKey: string; +}) { + // fileKey is a location in S3 where we want to upload the file + const response = await fetch("/api/upload/csv", { + method: "POST", + body: JSON.stringify({ + userId, + portfolioId, + fileKey, + }), + }); + + if (!response.ok) { + throw new Error("Failed to generate presigned url"); + } + + return response.json(); +} + +function useCreateRemoteAssessment({ portfolioId }: { portfolioId: string }) { + // 1) We want to upload the asset data. To do this, we format the asset data, generate a presigned URL, and upload the data to S3. + // 2) We then want to upload valuation data. To do this, we format the valuation data, generate a presigned URL, and upload the data to S3. + // 3) Trigger the engine!!!! This is an api at /api/plan/trigger with our body that we looked at in Miro + + // Set up the mutation with react-query, to generate a presigned URL + + const session = useSession(); + const userId = String(session.data?.user.dbId); + const fileKey = "8/-1/asset_list.csv"; + + const { + mutate: mutatePresignedUrl, + isLoading: presignedUrlIsLoading, + isError: presignedUrlIsError, + } = useMutation(generatePresignedUrl, { + onSuccess: (data) => { + console.log(data.url); + // On success, upload to that URL!!!! + }, + onError: (error) => { + console.error(error); + }, + }); + + function handleSubmit() { + mutatePresignedUrl({ userId, portfolioId, fileKey }); + console.log("SUCCESS"); // This is where we would want to trigger some kind of use feedback + } + + return { + handleSubmit, + presignedUrlIsLoading, + presignedUrlIsError, + }; +} export default function RemoteAssesmentModal({ portfolioId, @@ -137,7 +199,9 @@ export default function RemoteAssesmentModal({ setScenario(event.target.value); } - function handleAddressLineOneChange(event: React.ChangeEvent) { + function handleAddressLineOneChange( + event: React.ChangeEvent + ) { setAddressLineOne(event.target.value); } @@ -153,6 +217,11 @@ export default function RemoteAssesmentModal({ setValuation(event.target.value); } + const { handleSubmit, presignedUrlIsLoading, presignedUrlIsError } = + useCreateRemoteAssessment({ + portfolioId, + }); + useEffect(() => { function handleButtonDisabled(): boolean { return !( @@ -163,11 +232,19 @@ export default function RemoteAssesmentModal({ postcode && uprn && valuation - ); + ); } setButtonDisabled(handleButtonDisabled()); - }, [scenario, selectedGoal, housingType, addressLineOne, postcode, uprn, valuation]); + }, [ + scenario, + selectedGoal, + housingType, + addressLineOne, + postcode, + uprn, + valuation, + ]); return ( <> @@ -203,12 +280,17 @@ export default function RemoteAssesmentModal({ - {scenario} + className="text-lg font-medium leading-6 text-brandblue mb-3" + > + {scenario}
Scenario Name - +
- +
- Remote Assesment + Remote Assessment
- Schedule a remote assesment + Schedule a remote assessment
diff --git a/src/app/components/portfolio/Toolbar.tsx b/src/app/components/portfolio/Toolbar.tsx index b3754437..c12bbbf8 100644 --- a/src/app/components/portfolio/Toolbar.tsx +++ b/src/app/components/portfolio/Toolbar.tsx @@ -13,7 +13,7 @@ import { import AddNewDropDown from "./AddNew"; import { cva } from "class-variance-authority"; import UploadCsvModal from "@/app/portfolio/[slug]/components/UploadCsvModal"; -import RemoteAssesmentModal from "@/app/portfolio/[slug]/components/RemoteAssesmentModal"; +import RemoteAssessmentModal from "@/app/portfolio/[slug]/components/RemoteAssessmentModal"; import { useState } from "react"; import { useRouter } from "next/navigation"; @@ -41,7 +41,7 @@ export function Toolbar({ portfolioId }: ToolbarProps) { } const [modalIsOpen, setModalIsOpen] = useState(false); - const [isRemoteAssesmentOpen, setIsRemoteAssesmentOpen] = useState(false); + const [isRemoteAssessmentOpen, setIsRemoteAssessmentOpen] = useState(false); return ( @@ -73,13 +73,13 @@ export function Toolbar({ portfolioId }: ToolbarProps) { - void; -}; - -const selecthousingTypeOptions = [ - { - label: "Social", - value: "Social", - disabled: false, - }, - { - label: "Private", - value: "Private", - disabled: false, - }, -]; - -const selectGoalOptions = [ - { - label: "Increase EPC", - value: "Increase EPC", - disabled: false, - }, - { - label: "Reduce energy consumption", - value: "Reduce energy consumption", - disabled: false, // TODO: Disable - }, -]; - -const goalValueOptions = [ - { - label: "C", - value: "C", - disabled: false, - }, - { - label: "B", - value: "B", - disabled: false, - }, - { - label: "A", - value: "A", - disabled: false, - }, -]; - -export function SelectDropdown({ - options, - selectedOption, - onSelectOption, -}: DropdownProps) { - return ( - - - - {selectedOption || "Select an option"} - - - - {options.map((option) => ( - - {({ active }) => ( - - )} - - ))} - - - - - ); -} - -async function uploadCsvToS3({ - presignedUrl, - file, -}: { - presignedUrl: string; - file: Blob; -}) { - try { - const response = await fetch(presignedUrl, { - method: "PUT", - body: file, - headers: { "Content-Type": "text/csv" }, - }); - - if (!response.ok) { - console.error(response); - throw new Error("Network response was not ok"); - } - } catch (error) { - console.error(error); - throw new Error("Upload failed."); - } - - return { success: true }; -} - -async function generatePresignedUrl({ - userId, - portfolioId, - fileKey, -}: { - userId: string; - portfolioId: string; - fileKey: string; -}) { - // fileKey is a location in S3 where we want to upload the file - const response = await fetch("/api/upload/csv", { - method: "POST", - body: JSON.stringify({ - userId, - portfolioId, - fileKey, - }), - }); - - if (!response.ok) { - throw new Error("Failed to generate presigned url"); - } - - return response.json(); -} - -function generateS3Keys(userId: string, portfolioId: string) { - const timestamp = new Date().toISOString().replace(/[:.-]/g, ""); - const assetListFileKey = `${userId}/${portfolioId}/${timestamp}/asset_list.csv`; - const valuationDataFileKey = `${userId}/${portfolioId}/${timestamp}/valuation_data.csv`; - return { assetListFileKey, valuationDataFileKey }; -} - -type GenericObject = Record; - -const convertToCSV = (data: T[]): string => { - // Get headers (keys from the first object) - const headers = Object.keys(data[0]) as (keyof T)[]; - - // Create CSV rows - const rows = data.map((row) => - headers.map((header) => row[header]).join(",") - ); - - // Combine headers and rows into CSV string - return [headers.join(","), ...rows].join("\n"); -}; - -function useCreateRemoteAssessment({ - portfolioId, - uprn, - addressLineOne, - postcode, -}: { - portfolioId: string; - uprn: number | null; - addressLineOne: string; - postcode: string; -}) { - // 1) We want to upload the asset data. To do this, we format the asset data, generate a presigned URL, and upload the data to S3. - // 2) We then want to upload valuation data. To do this, we format the valuation data, generate a presigned URL, and upload the data to S3. - // 3) Trigger the engine!!!! This is an api at /api/plan/trigger with our body that we looked at in Miro - - // Set up the mutation with react-query, to generate a presigned URL - - const session = useSession(); - const userId = String(session.data?.user.dbId); - - const { assetListFileKey, valuationDataFileKey } = useMemo( - () => generateS3Keys(userId, portfolioId), - [userId, portfolioId] - ); - - const { - mutate: mutateUploadAssetList, - isLoading: uploadAssetListIsLoading, - isError: uploadAssetListIsError, - } = useMutation(uploadCsvToS3, { - onSuccess: (data) => { - console.log("WAS IT A SUCCESS?", data.success); - console.log("TRIGGERING THE ENGINE"); - // This is where we trigger the engine!!! - const body = { - trigger_file_path: assetListFileKey, - }; - // engine API call goes here - }, - onError: (error) => { - console.error(error); - }, - }); - - const { - mutate: mutatePresignedUrl, - isLoading: presignedUrlIsLoading, - isError: presignedUrlIsError, - } = useMutation(generatePresignedUrl, { - onSuccess: (data) => { - console.log(data.url); - // On success, upload to that URL!!!! - const assetList = [ - { - uprn: uprn, - address: addressLineOne, - postcode: postcode, - }, - ]; - const assetListCsvString = convertToCSV(assetList); - const assetListCsv = new Blob([assetListCsvString], { - type: "text/csv", - }); - - mutateUploadAssetList({ presignedUrl: data.url, file: assetListCsv }); - }, - onError: (error) => { - console.error(error); - }, - }); - - function handleSubmit() { - mutatePresignedUrl({ userId, portfolioId, fileKey: assetListFileKey }); - console.log("SUCCESS"); // This is where we would want to trigger some kind of use feedback - } - - return { - handleSubmit, - presignedUrlIsLoading, - presignedUrlIsError, - }; -} - -export default function RemoteAssesmentModal({ - portfolioId, - isOpen, - setIsOpen, -}: { - isOpen: boolean; - setIsOpen: (isOpen: boolean) => void; - portfolioId: string; -}) { - const [scenario, setScenario] = useState(undefined); - const [housingType, sethousingType] = useState(""); - const [selectedGoal, setSelectedGoal] = useState(""); - const [goalValue, setGoalValue] = useState(""); - const [addressLineOne, setAddressLineOne] = useState(""); - const [postcode, setPostcode] = useState(""); - const [uprn, setUprn] = useState(null); - const [valuation, setValuation] = useState(""); - const [buttonDisabled, setButtonDisabled] = useState(true); - - function handleScenarioChange(event: React.ChangeEvent) { - setScenario(event.target.value); - } - - function handleAddressLineOneChange( - event: React.ChangeEvent - ) { - setAddressLineOne(event.target.value); - } - - function handlePostcodeChange(event: React.ChangeEvent) { - setPostcode(event.target.value); - } - - function handleUprnChange(event: React.ChangeEvent) { - setUprn(Number(event.target.value)); - } - - function handleValuationChange(event: React.ChangeEvent) { - setValuation(event.target.value); - } - - const { handleSubmit, presignedUrlIsLoading, presignedUrlIsError } = - useCreateRemoteAssessment({ - portfolioId, - uprn, - addressLineOne, - postcode, - }); - - useEffect(() => { - function handleButtonDisabled(): boolean { - return !( - scenario && - selectedGoal && - housingType && - addressLineOne && - postcode && - uprn && - valuation - ); - } - - setButtonDisabled(handleButtonDisabled()); - }, [ - scenario, - selectedGoal, - housingType, - addressLineOne, - postcode, - uprn, - valuation, - ]); - - return ( - <> - - setIsOpen(false)} - > - -
- - -
-
- - - - {scenario} - -
- Scenario Name - -
-
- - sethousingType(option.value)} - /> -
-
- - setSelectedGoal(option.value)} - /> - {selectedGoal === "Increase EPC" && ( -
- - { - setGoalValue(option.value); - }} - /> -
- )} -
-
- Address Line 1 - -
-
- Postcode - -
-
- UPRN - -
-
- Valuation - -
-
- -
- -
-
-
-
-
-
- - ); -} From 20b4d93b7796f36d3166f8e116e57f326f1867dd Mon Sep 17 00:00:00 2001 From: StefanWout Date: Wed, 20 Nov 2024 17:17:27 +0000 Subject: [PATCH 21/29] disabled the reduce energy consumption option --- .../components/RemoteAssessmentModal.tsx | 480 ++++++++++++++++++ 1 file changed, 480 insertions(+) create mode 100644 src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx diff --git a/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx b/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx new file mode 100644 index 00000000..38ea37a3 --- /dev/null +++ b/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx @@ -0,0 +1,480 @@ +"use client"; + +import { Dialog, Transition, Menu } from "@headlessui/react"; +import { useState, Fragment, useEffect, useMemo } from "react"; +import { Input } from "@/app/shadcn_components/ui/input"; +import { Button } from "@/app/shadcn_components/ui/button"; +import { Float } from "@headlessui-float/react"; +import { ChevronDownIcon } from "@heroicons/react/20/solid"; +import { useMutation } from "@tanstack/react-query"; +import { useSession } from "next-auth/react"; + +type Option = { + label: string; + value: string; + disabled: boolean; +}; + +type DropdownProps = { + options: Option[]; + selectedOption: string; + onSelectOption: (option: Option) => void; +}; + +const selecthousingTypeOptions = [ + { + label: "Social", + value: "Social", + disabled: false, + }, + { + label: "Private", + value: "Private", + disabled: false, + }, +]; + +const selectGoalOptions = [ + { + label: "Increase EPC", + value: "Increase EPC", + disabled: false, + }, + { + label: "Reduce energy consumption", + value: "Reduce energy consumption", + disabled: true, + }, +]; + +const goalValueOptions = [ + { + label: "C", + value: "C", + disabled: false, + }, + { + label: "B", + value: "B", + disabled: false, + }, + { + label: "A", + value: "A", + disabled: false, + }, +]; + +export function SelectDropdown({ + options, + selectedOption, + onSelectOption, +}: DropdownProps) { + return ( + + + + {selectedOption || "Select an option"} + + + + {options.map((option) => ( + + {({ active }) => ( + + )} + + ))} + + + + + ); +} + +async function uploadCsvToS3({ + presignedUrl, + file, +}: { + presignedUrl: string; + file: Blob; +}) { + try { + const response = await fetch(presignedUrl, { + method: "PUT", + body: file, + headers: { "Content-Type": "text/csv" }, + }); + + if (!response.ok) { + console.error(response); + throw new Error("Network response was not ok"); + } + } catch (error) { + console.error(error); + throw new Error("Upload failed."); + } + + return { success: true }; +} + +async function generatePresignedUrl({ + userId, + portfolioId, + fileKey, +}: { + userId: string; + portfolioId: string; + fileKey: string; +}) { + // fileKey is a location in S3 where we want to upload the file + const response = await fetch("/api/upload/csv", { + method: "POST", + body: JSON.stringify({ + userId, + portfolioId, + fileKey, + }), + }); + + if (!response.ok) { + throw new Error("Failed to generate presigned url"); + } + + return response.json(); +} + +function generateS3Keys(userId: string, portfolioId: string) { + const timestamp = new Date().toISOString().replace(/[:.-]/g, ""); + const assetListFileKey = `${userId}/${portfolioId}/${timestamp}/asset_list.csv`; + const valuationDataFileKey = `${userId}/${portfolioId}/${timestamp}/valuation_data.csv`; + return { assetListFileKey, valuationDataFileKey }; +} + +type GenericObject = Record; + +const convertToCSV = (data: T[]): string => { + // Get headers (keys from the first object) + const headers = Object.keys(data[0]) as (keyof T)[]; + + // Create CSV rows + const rows = data.map((row) => + headers.map((header) => row[header]).join(",") + ); + + // Combine headers and rows into CSV string + return [headers.join(","), ...rows].join("\n"); +}; + +function useCreateRemoteAssessment({ + portfolioId, + uprn, + addressLineOne, + postcode, +}: { + portfolioId: string; + uprn: number | null; + addressLineOne: string; + postcode: string; +}) { + // 1) We want to upload the asset data. To do this, we format the asset data, generate a presigned URL, and upload the data to S3. + // 2) We then want to upload valuation data. To do this, we format the valuation data, generate a presigned URL, and upload the data to S3. + // 3) Trigger the engine!!!! This is an api at /api/plan/trigger with our body that we looked at in Miro + + // Set up the mutation with react-query, to generate a presigned URL + + const session = useSession(); + const userId = String(session.data?.user.dbId); + + const { assetListFileKey, valuationDataFileKey } = useMemo( + () => generateS3Keys(userId, portfolioId), + [userId, portfolioId] + ); + + const { + mutate: mutateUploadAssetList, + isLoading: uploadAssetListIsLoading, + isError: uploadAssetListIsError, + } = useMutation(uploadCsvToS3, { + onSuccess: (data) => { + console.log("WAS IT A SUCCESS?", data.success); + console.log("TRIGGERING THE ENGINE"); + // This is where we trigger the engine!!! + const body = { + trigger_file_path: assetListFileKey, + }; + // engine API call goes here + }, + onError: (error) => { + console.error(error); + }, + }); + + const { + mutate: mutatePresignedUrl, + isLoading: presignedUrlIsLoading, + isError: presignedUrlIsError, + } = useMutation(generatePresignedUrl, { + onSuccess: (data) => { + console.log(data.url); + // On success, upload to that URL!!!! + const assetList = [ + { + uprn: uprn, + address: addressLineOne, + postcode: postcode, + }, + ]; + const assetListCsvString = convertToCSV(assetList); + const assetListCsv = new Blob([assetListCsvString], { + type: "text/csv", + }); + + mutateUploadAssetList({ presignedUrl: data.url, file: assetListCsv }); + }, + onError: (error) => { + console.error(error); + }, + }); + + function handleSubmit() { + mutatePresignedUrl({ userId, portfolioId, fileKey: assetListFileKey }); + console.log("SUCCESS"); // This is where we would want to trigger some kind of use feedback + } + + return { + handleSubmit, + presignedUrlIsLoading, + presignedUrlIsError, + }; +} + +export default function RemoteAssessmentModal({ + portfolioId, + isOpen, + setIsOpen, +}: { + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; + portfolioId: string; +}) { + const [scenario, setScenario] = useState(undefined); + const [housingType, sethousingType] = useState(""); + const [selectedGoal, setSelectedGoal] = useState(""); + const [goalValue, setGoalValue] = useState(""); + const [addressLineOne, setAddressLineOne] = useState(""); + const [postcode, setPostcode] = useState(""); + const [uprn, setUprn] = useState(null); + const [valuation, setValuation] = useState(""); + const [buttonDisabled, setButtonDisabled] = useState(true); + + function handleScenarioChange(event: React.ChangeEvent) { + setScenario(event.target.value); + } + + function handleAddressLineOneChange( + event: React.ChangeEvent + ) { + setAddressLineOne(event.target.value); + } + + function handlePostcodeChange(event: React.ChangeEvent) { + setPostcode(event.target.value); + } + + function handleUprnChange(event: React.ChangeEvent) { + setUprn(Number(event.target.value)); + } + + function handleValuationChange(event: React.ChangeEvent) { + setValuation(event.target.value); + } + + const { handleSubmit, presignedUrlIsLoading, presignedUrlIsError } = + useCreateRemoteAssessment({ + portfolioId, + uprn, + addressLineOne, + postcode, + }); + + useEffect(() => { + function handleButtonDisabled(): boolean { + return !( + scenario && + selectedGoal && + housingType && + addressLineOne && + postcode && + uprn && + valuation + ); + } + + setButtonDisabled(handleButtonDisabled()); + }, [ + scenario, + selectedGoal, + housingType, + addressLineOne, + postcode, + uprn, + valuation, + ]); + + return ( + <> + + setIsOpen(false)} + > + +
+ + +
+
+ + + + {scenario} + +
+ Scenario Name + +
+
+ + sethousingType(option.value)} + /> +
+
+ + setSelectedGoal(option.value)} + /> + {selectedGoal === "Increase EPC" && ( +
+ + { + setGoalValue(option.value); + }} + /> +
+ )} +
+
+ Address Line 1 + +
+
+ Postcode + +
+
+ UPRN + +
+
+ Valuation + +
+
+ +
+ +
+
+
+
+
+
+ + ); +} From 3520d0858dba7ff28841d3e8745d878f48b230c4 Mon Sep 17 00:00:00 2001 From: StefanWout Date: Thu, 21 Nov 2024 14:35:54 +0000 Subject: [PATCH 22/29] succesfully implemented the valuation data upload functionality on handleSubmit now to turn this into a form --- .../components/RemoteAssessmentModal.tsx | 59 +++++++++++++++++-- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx b/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx index 38ea37a3..15d78105 100644 --- a/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx +++ b/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx @@ -136,6 +136,7 @@ async function uploadCsvToS3({ throw new Error("Upload failed."); } + console.log("S3 got the stuff"); return { success: true }; } @@ -192,11 +193,13 @@ function useCreateRemoteAssessment({ uprn, addressLineOne, postcode, + valuation, }: { portfolioId: string; uprn: number | null; addressLineOne: string; postcode: string; + valuation: string | number | null; }) { // 1) We want to upload the asset data. To do this, we format the asset data, generate a presigned URL, and upload the data to S3. // 2) We then want to upload valuation data. To do this, we format the valuation data, generate a presigned URL, and upload the data to S3. @@ -218,8 +221,8 @@ function useCreateRemoteAssessment({ isError: uploadAssetListIsError, } = useMutation(uploadCsvToS3, { onSuccess: (data) => { - console.log("WAS IT A SUCCESS?", data.success); - console.log("TRIGGERING THE ENGINE"); + console.log("WAS THE ASSET LIST A SUCCESS?", data.success); + console.log("ASSETS TRIGGERING THE ENGINE"); // This is where we trigger the engine!!! const body = { trigger_file_path: assetListFileKey, @@ -231,6 +234,25 @@ function useCreateRemoteAssessment({ }, }); + const { + mutate: mutateUploadValuationData, + isLoading: uploadValuationDataIsLoading, + isError: uploadValuationDataIsError, + } = useMutation(uploadCsvToS3, { + onSuccess: (data) => { + console.log("WAS VALUATION DATA A SUCCESS?", data.success); + console.log("VALUATION TRIGGERING THE ENGINE"); + // This is where we trigger the engine!!! + const body = { + trigger_file_path: valuationDataFileKey, + }; + // engine API call goes here + }, + onError: (error) => { + console.error(error); + }, + }); + const { mutate: mutatePresignedUrl, isLoading: presignedUrlIsLoading, @@ -246,21 +268,45 @@ function useCreateRemoteAssessment({ postcode: postcode, }, ]; + const valuationData = [ + { + uprn: uprn, + valuation: 100000, + }, + ]; const assetListCsvString = convertToCSV(assetList); const assetListCsv = new Blob([assetListCsvString], { type: "text/csv", }); - mutateUploadAssetList({ presignedUrl: data.url, file: assetListCsv }); + + const valuationDataCsvString = convertToCSV(valuationData); + const valuationDataCsv = new Blob([valuationDataCsvString], { + type: "text/csv", + }); + mutateUploadValuationData({ presignedUrl: data.url, file: valuationDataCsv }); }, onError: (error) => { console.error(error); }, }); - function handleSubmit() { - mutatePresignedUrl({ userId, portfolioId, fileKey: assetListFileKey }); - console.log("SUCCESS"); // This is where we would want to trigger some kind of use feedback + async function handleSubmit() { + try { + // Mutate presigned URL for asset list file + await mutatePresignedUrl({ userId, portfolioId, fileKey: assetListFileKey }); + console.log("ASSET LIST SUCCESS"); + + // Mutate presigned URL for valuation data file + await mutatePresignedUrl({ userId, portfolioId, fileKey: valuationDataFileKey }); + console.log("VALUATION DATA SUCCESS"); + + // Trigger user feedback (e.g., show a success message) + console.log("Both uploads SUCCESS"); + } catch (error) { + // Handle error (e.g., show an error message) + console.error("Error uploading files:", error); + } } return { @@ -317,6 +363,7 @@ export default function RemoteAssessmentModal({ uprn, addressLineOne, postcode, + valuation, }); useEffect(() => { From 710d60b5edbf5ac967d9e2579ae97c070f106d10 Mon Sep 17 00:00:00 2001 From: StefanWout Date: Thu, 21 Nov 2024 16:44:34 +0000 Subject: [PATCH 23/29] got the react hook form with zod to work, FormProvder got rid of the getField errors somehow, all hail Overlord Claude, aesthetic clean up needed as well as linked up to trigger the engine --- .../components/RemoteAssessmentModal.tsx | 355 ++++++++++-------- 1 file changed, 208 insertions(+), 147 deletions(-) diff --git a/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx b/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx index 15d78105..fe736b79 100644 --- a/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx +++ b/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx @@ -8,6 +8,10 @@ import { Float } from "@headlessui-float/react"; import { ChevronDownIcon } from "@heroicons/react/20/solid"; import { useMutation } from "@tanstack/react-query"; import { useSession } from "next-auth/react"; +import { Form, useForm, FormProvider } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/app/shadcn_components/ui/form"; type Option = { label: string; @@ -291,6 +295,8 @@ function useCreateRemoteAssessment({ }, }); + + async function handleSubmit() { try { // Mutate presigned URL for asset list file @@ -325,71 +331,58 @@ export default function RemoteAssessmentModal({ setIsOpen: (isOpen: boolean) => void; portfolioId: string; }) { - const [scenario, setScenario] = useState(undefined); - const [housingType, sethousingType] = useState(""); - const [selectedGoal, setSelectedGoal] = useState(""); - const [goalValue, setGoalValue] = useState(""); - const [addressLineOne, setAddressLineOne] = useState(""); - const [postcode, setPostcode] = useState(""); - const [uprn, setUprn] = useState(null); - const [valuation, setValuation] = useState(""); - const [buttonDisabled, setButtonDisabled] = useState(true); + + const formSchema = z.object({ + scenario: z.string().min(1, "Scenario is required"), + goal: z.string().min(1, "Goal is required"), + goalValue: z.string().min(1, "Goal value is required"), + housingType: z.string().min(1, "Housing type is required"), + addressLineOne: z.string().min(1, "Address is required"), + postcode: z.string().min(1, "Postcode is required"), + uprn: z.number().min(1, "UPRN is required"), + valuation: z.number().min(1, "Valuation is required"), + }); - function handleScenarioChange(event: React.ChangeEvent) { - setScenario(event.target.value); - } + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + scenario: "", + housingType: "", + goal: "", + goalValue: "", + addressLineOne: "", + postcode: "", + uprn: 0, + valuation: 0, + }, + }); - function handleAddressLineOneChange( - event: React.ChangeEvent - ) { - setAddressLineOne(event.target.value); - } + type FormValues = z.infer; - function handlePostcodeChange(event: React.ChangeEvent) { - setPostcode(event.target.value); - } - - function handleUprnChange(event: React.ChangeEvent) { - setUprn(Number(event.target.value)); - } - - function handleValuationChange(event: React.ChangeEvent) { - setValuation(event.target.value); - } + const onSubmit = async (data: FormValues) => { + try { + // First handle the form submission from react-hook-form + const formData = form.getValues(); + + // Then trigger the data upload using handleSubmit from useCreateRemoteAssessment + await handleSubmit(); + + // Close the modal on success + setIsOpen(false); + } catch (error) { + console.error('Error submitting form:', error); + } + }; const { handleSubmit, presignedUrlIsLoading, presignedUrlIsError } = useCreateRemoteAssessment({ portfolioId, - uprn, - addressLineOne, - postcode, - valuation, + uprn: form.watch("uprn"), + addressLineOne: form.watch("addressLineOne"), + postcode: form.watch("postcode"), + valuation: form.watch("valuation"), }); - useEffect(() => { - function handleButtonDisabled(): boolean { - return !( - scenario && - selectedGoal && - housingType && - addressLineOne && - postcode && - uprn && - valuation - ); - } - - setButtonDisabled(handleButtonDisabled()); - }, [ - scenario, - selectedGoal, - housingType, - addressLineOne, - postcode, - uprn, - valuation, - ]); - return ( <> @@ -422,101 +415,169 @@ export default function RemoteAssessmentModal({ leaveTo="opacity-0 scale-95" > - - {scenario} - -
- Scenario Name - + Remote Assessment Details + + +
+ ( + + Scenario Name + + + + + + )} /> -
-
- - sethousingType(option.value)} + + ( + + Housing Type + + field.onChange(option.value)} + /> + + + + )} /> -
-
- - setSelectedGoal(option.value)} + + ( + + Goal + + field.onChange(option.value)} + /> + + + + )} /> - {selectedGoal === "Increase EPC" && ( -
- - { - setGoalValue(option.value); - }} - /> -
+ + ( + + Goal Value + + field.onChange(option.value)} + /> + + + + )} + /> + + ( + + Address + + + + + + )} + /> + + ( + + Postcode + + + + + + )} + /> + + ( + + UPRN + + field.onChange(Number(e.target.value))} + /> + + + + )} + /> + + ( + + Valuation + + field.onChange(Number(e.target.value))} + /> + + + + )} + /> + +
+ + +
+ {presignedUrlIsError && ( +

Error uploading files

)} -
-
- Address Line 1 - -
-
- Postcode - -
-
- UPRN - -
-
- Valuation - -
-
- -
- -
+ + +
From cad43681ad3964a0a09ced2c6acd8a3aa6e90f9c Mon Sep 17 00:00:00 2001 From: StefanWout Date: Thu, 21 Nov 2024 16:53:44 +0000 Subject: [PATCH 24/29] noticed that the form wasn't clearing, added that functionality to the onSubmit function, which also triggers handle submit, could add the handle submit functionality to the onSubmit function but thats a tomorrow problem --- src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx b/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx index fe736b79..383a6dfe 100644 --- a/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx +++ b/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx @@ -366,6 +366,9 @@ export default function RemoteAssessmentModal({ // Then trigger the data upload using handleSubmit from useCreateRemoteAssessment await handleSubmit(); + + // Reset the form + form.reset(); // Close the modal on success setIsOpen(false); From 78d3716899e1c2e6fe337fe0ef0704aa5997d43d Mon Sep 17 00:00:00 2001 From: StefanWout Date: Tue, 26 Nov 2024 14:27:54 +0000 Subject: [PATCH 25/29] all kinds of weird attempts --- package-lock.json | 299 ++++++++++++++++++ package.json | 1 + .../components/RemoteAssessmentModal.tsx | 173 ++++++---- 3 files changed, 403 insertions(+), 70 deletions(-) diff --git a/package-lock.json b/package-lock.json index ac28db5d..687b4924 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@radix-ui/react-select": "^1.2.2", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.0.7", "@remixicon/react": "^4.2.0", "@tanstack/react-query": "^4.29.12", @@ -2303,6 +2304,304 @@ } } }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.2.tgz", + "integrity": "sha512-Z6pqSzmAP/bFJoqMAston4eSNa+ud44NSZTiZUmUen+IOZ5nBY8kzuU5WDBVyFXPtcW6yUalOHsxM/BP6Sv8ww==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-collection": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", + "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz", + "integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz", + "integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-presence": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", + "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", + "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-visually-hidden": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.0.tgz", + "integrity": "sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz", diff --git a/package.json b/package.json index 382e7468..12d2af07 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@radix-ui/react-select": "^1.2.2", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.0.7", "@remixicon/react": "^4.2.0", "@tanstack/react-query": "^4.29.12", diff --git a/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx b/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx index 383a6dfe..0bf4b890 100644 --- a/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx +++ b/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx @@ -12,6 +12,8 @@ import { Form, useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; import { FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/app/shadcn_components/ui/form"; +import { Toast } from "@/app/shadcn_components/ui/toast"; +// import { Form } from "aws-sdk/clients/amplifyuibuilder"; type Option = { label: string; @@ -69,6 +71,35 @@ const goalValueOptions = [ }, ]; +interface EngineTriggerBody { + portfolio_id: string; + housing_type: string; + goal: string; + goal_value: string; + trigger_file_path: string; + already_installed_file_path: string; + patches_file_path: string; + non_invasive_recommendations_file_path: string; + valuation_file_path: string; + scenario_name: string; + multi_plan: boolean; + budget: null; + event_type: string; +} + +const formSchema = z.object({ + scenario: z.string().min(1, "Scenario is required"), + goal: z.string().min(1, "Goal is required"), + goalValue: z.string().min(1, "Goal value is required"), + housingType: z.string().min(1, "Housing type is required"), + addressLineOne: z.string().min(1, "Address is required"), + postcode: z.string().min(1, "Postcode is required"), + uprn: z.number().min(1, "UPRN is required"), + valuation: z.number().min(1, "Valuation is required"), +}); + +type FormValues = z.infer; + export function SelectDropdown({ options, selectedOption, @@ -220,42 +251,26 @@ function useCreateRemoteAssessment({ ); const { - mutate: mutateUploadAssetList, - isLoading: uploadAssetListIsLoading, - isError: uploadAssetListIsError, - } = useMutation(uploadCsvToS3, { - onSuccess: (data) => { - console.log("WAS THE ASSET LIST A SUCCESS?", data.success); - console.log("ASSETS TRIGGERING THE ENGINE"); - // This is where we trigger the engine!!! - const body = { - trigger_file_path: assetListFileKey, - }; - // engine API call goes here + mutate: mutateUploadFiles, + isLoading: uploadFilesIsLoading, + isError: uploadFilesIsError, + } = useMutation(async ({ assetList, valuationData }: { assetList: { presignedUrl: string; file: Blob }; valuationData: { presignedUrl: string; file: Blob } }) => { + + // Upload asset list + await uploadCsvToS3({ presignedUrl: assetList.presignedUrl, file: assetList.file }); + + // Upload valuation data + await uploadCsvToS3({ presignedUrl: valuationData.presignedUrl, file: valuationData.file }); + }, { + onSuccess: (data) => { // Callback for successful mutation + console.log("Files uploaded successfully"); + // Trigger the engine here if needed }, - onError: (error) => { - console.error(error); + onError: (error) => { // Callback for failed mutation + console.error("Error uploading files:", error); }, }); - const { - mutate: mutateUploadValuationData, - isLoading: uploadValuationDataIsLoading, - isError: uploadValuationDataIsError, - } = useMutation(uploadCsvToS3, { - onSuccess: (data) => { - console.log("WAS VALUATION DATA A SUCCESS?", data.success); - console.log("VALUATION TRIGGERING THE ENGINE"); - // This is where we trigger the engine!!! - const body = { - trigger_file_path: valuationDataFileKey, - }; - // engine API call goes here - }, - onError: (error) => { - console.error(error); - }, - }); const { mutate: mutatePresignedUrl, @@ -275,20 +290,21 @@ function useCreateRemoteAssessment({ const valuationData = [ { uprn: uprn, - valuation: 100000, + valuation: 0, }, ]; const assetListCsvString = convertToCSV(assetList); const assetListCsv = new Blob([assetListCsvString], { type: "text/csv", }); - mutateUploadAssetList({ presignedUrl: data.url, file: assetListCsv }); - + const valuationDataCsvString = convertToCSV(valuationData); const valuationDataCsv = new Blob([valuationDataCsvString], { type: "text/csv", }); - mutateUploadValuationData({ presignedUrl: data.url, file: valuationDataCsv }); + + mutateUploadFiles({ assetList: { presignedUrl: data.url, file: assetListCsv }, valuationData: { presignedUrl: data.url, file: valuationDataCsv } }); + }, onError: (error) => { console.error(error); @@ -296,32 +312,66 @@ function useCreateRemoteAssessment({ }); - - async function handleSubmit() { + async function triggerEngine(data: FormValues) { try { - // Mutate presigned URL for asset list file - await mutatePresignedUrl({ userId, portfolioId, fileKey: assetListFileKey }); - console.log("ASSET LIST SUCCESS"); + const triggerBody: EngineTriggerBody ={ + portfolio_id: portfolioId, + housing_type: data.housingType, + goal: data.goal, + goal_value: data.goalValue, + trigger_file_path: assetListFileKey, + already_installed_file_path: "", + patches_file_path: "", + non_invasive_recommendations_file_path: "", + valuation_file_path: valuationDataFileKey, + scenario_name: data.scenario, + multi_plan: true, + budget: null, + event_type: "Remote Assessment" + }; - // Mutate presigned URL for valuation data file - await mutatePresignedUrl({ userId, portfolioId, fileKey: valuationDataFileKey }); - console.log("VALUATION DATA SUCCESS"); + const response = await fetch("/api/plan/trigger", { + method: "POST", + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(triggerBody), + }); - // Trigger user feedback (e.g., show a success message) - console.log("Both uploads SUCCESS"); + if (!response.ok) { + throw new Error('Failed to trigger engine'); + } } catch (error) { - // Handle error (e.g., show an error message) - console.error("Error uploading files:", error); + console.error('Error triggering engine:', error); + throw error; + } + } + + async function handleSubmit(formData: FormValues) { + try { + const [assetListUrl, valuationDataUrl] = await Promise.all([ + mutatePresignedUrl({ userId, portfolioId, fileKey: assetListFileKey }), + mutatePresignedUrl({ userId, portfolioId, fileKey: valuationDataFileKey }) + ]); + + await triggerEngine(formData); + + } catch (error) { + console.error("Error in submission process:", error); } } return { handleSubmit, + triggerEngine, presignedUrlIsLoading, presignedUrlIsError, + uploadFilesIsLoading, + uploadFilesIsError, }; } + export default function RemoteAssessmentModal({ portfolioId, isOpen, @@ -331,17 +381,6 @@ export default function RemoteAssessmentModal({ setIsOpen: (isOpen: boolean) => void; portfolioId: string; }) { - - const formSchema = z.object({ - scenario: z.string().min(1, "Scenario is required"), - goal: z.string().min(1, "Goal is required"), - goalValue: z.string().min(1, "Goal value is required"), - housingType: z.string().min(1, "Housing type is required"), - addressLineOne: z.string().min(1, "Address is required"), - postcode: z.string().min(1, "Postcode is required"), - uprn: z.number().min(1, "UPRN is required"), - valuation: z.number().min(1, "Valuation is required"), - }); const form = useForm({ resolver: zodResolver(formSchema), @@ -357,20 +396,10 @@ export default function RemoteAssessmentModal({ }, }); - type FormValues = z.infer; - const onSubmit = async (data: FormValues) => { try { - // First handle the form submission from react-hook-form - const formData = form.getValues(); - - // Then trigger the data upload using handleSubmit from useCreateRemoteAssessment - await handleSubmit(); - - // Reset the form + await handleSubmit(data); form.reset(); - - // Close the modal on success setIsOpen(false); } catch (error) { console.error('Error submitting form:', error); @@ -589,3 +618,7 @@ export default function RemoteAssessmentModal({ ); } +function setIsOpen(arg0: boolean) { + throw new Error("Function not implemented."); +} + From 369c4ca93c355bebd87d1f794efe59ed1bffc61e Mon Sep 17 00:00:00 2001 From: StefanWout Date: Tue, 26 Nov 2024 18:00:53 +0000 Subject: [PATCH 26/29] fixed the overlap uploads by addinga an if statement to check which data file to manage --- src/app/hooks/use-toast.ts | 194 +++++++ .../components/RemoteAssessmentModal.tsx | 500 ++++++++++-------- src/app/shadcn_components/ui/toast.tsx | 129 +++++ src/app/shadcn_components/ui/toaster.tsx | 35 ++ 4 files changed, 626 insertions(+), 232 deletions(-) create mode 100644 src/app/hooks/use-toast.ts create mode 100644 src/app/shadcn_components/ui/toast.tsx create mode 100644 src/app/shadcn_components/ui/toaster.tsx diff --git a/src/app/hooks/use-toast.ts b/src/app/hooks/use-toast.ts new file mode 100644 index 00000000..b18ceb84 --- /dev/null +++ b/src/app/hooks/use-toast.ts @@ -0,0 +1,194 @@ +"use client" + +// Inspired by react-hot-toast library +import * as React from "react" + +import type { + ToastActionElement, + ToastProps, +} from "src/app/shadcn_components/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast } diff --git a/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx b/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx index 0bf4b890..a0001b1f 100644 --- a/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx +++ b/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx @@ -1,7 +1,7 @@ "use client"; import { Dialog, Transition, Menu } from "@headlessui/react"; -import { useState, Fragment, useEffect, useMemo } from "react"; +import { useState, Fragment, useMemo, useRef } from "react"; import { Input } from "@/app/shadcn_components/ui/input"; import { Button } from "@/app/shadcn_components/ui/button"; import { Float } from "@headlessui-float/react"; @@ -11,9 +11,15 @@ import { useSession } from "next-auth/react"; import { Form, useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; -import { FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/app/shadcn_components/ui/form"; +import { + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@/app/shadcn_components/ui/form"; import { Toast } from "@/app/shadcn_components/ui/toast"; -// import { Form } from "aws-sdk/clients/amplifyuibuilder"; +import { FileKey } from "lucide-react"; type Option = { label: string; @@ -198,7 +204,11 @@ async function generatePresignedUrl({ throw new Error("Failed to generate presigned url"); } - return response.json(); + const data = await response.json(); + + data.fileKey = fileKey; + + return data; } function generateS3Keys(userId: string, portfolioId: string) { @@ -251,26 +261,22 @@ function useCreateRemoteAssessment({ ); const { - mutate: mutateUploadFiles, - isLoading: uploadFilesIsLoading, - isError: uploadFilesIsError, - } = useMutation(async ({ assetList, valuationData }: { assetList: { presignedUrl: string; file: Blob }; valuationData: { presignedUrl: string; file: Blob } }) => { - - // Upload asset list - await uploadCsvToS3({ presignedUrl: assetList.presignedUrl, file: assetList.file }); - - // Upload valuation data - await uploadCsvToS3({ presignedUrl: valuationData.presignedUrl, file: valuationData.file }); - }, { - onSuccess: (data) => { // Callback for successful mutation - console.log("Files uploaded successfully"); - // Trigger the engine here if needed - }, - onError: (error) => { // Callback for failed mutation - console.error("Error uploading files:", error); - }, - }); - + mutate: mutateUploadFile, + isLoading: uploadFileIsLoading, + isError: uploadFileIsError, + } = useMutation(uploadCsvToS3, + { + onSuccess: (data) => { + // Callback for successful mutation + console.log("Files uploaded successfully"); + // Trigger the engine here if needed + }, + onError: (error) => { + // Callback for failed mutation + console.error("Error uploading files:", error); + }, + } + ); const { mutate: mutatePresignedUrl, @@ -278,71 +284,81 @@ function useCreateRemoteAssessment({ isError: presignedUrlIsError, } = useMutation(generatePresignedUrl, { onSuccess: (data) => { - console.log(data.url); - // On success, upload to that URL!!!! - const assetList = [ - { - uprn: uprn, - address: addressLineOne, - postcode: postcode, - }, - ]; - const valuationData = [ - { - uprn: uprn, - valuation: 0, - }, - ]; - const assetListCsvString = convertToCSV(assetList); - const assetListCsv = new Blob([assetListCsvString], { - type: "text/csv", - }); + // console.log(data.url); + // // On success, upload to that URL!!!! - const valuationDataCsvString = convertToCSV(valuationData); - const valuationDataCsv = new Blob([valuationDataCsvString], { - type: "text/csv", - }); + let csvFile: Blob = new Blob(); - mutateUploadFiles({ assetList: { presignedUrl: data.url, file: assetListCsv }, valuationData: { presignedUrl: data.url, file: valuationDataCsv } }); - + if (data.fileKey === assetListFileKey) { + + const assetList = [ + { + uprn: uprn, + address: addressLineOne, + postcode: postcode, + }, + ]; + + csvFile = new Blob([convertToCSV(assetList)], { + type: "text/csv", + }); + + } else if (data.fileKey === valuationDataFileKey) { + + const valuationData = [ + { + uprn: uprn, + valuation: valuation, + }, + ]; + + csvFile = new Blob([convertToCSV(valuationData)], { + type: "text/csv", + }); + } + + mutateUploadFile({ + file: csvFile, + presignedUrl: data.url, + }); }, onError: (error) => { console.error(error); }, - }); - + } + ); async function triggerEngine(data: FormValues) { try { - const triggerBody: EngineTriggerBody ={ - portfolio_id: portfolioId, - housing_type: data.housingType, - goal: data.goal, - goal_value: data.goalValue, - trigger_file_path: assetListFileKey, - already_installed_file_path: "", - patches_file_path: "", - non_invasive_recommendations_file_path: "", - valuation_file_path: valuationDataFileKey, - scenario_name: data.scenario, - multi_plan: true, - budget: null, - event_type: "Remote Assessment" + const triggerBody: EngineTriggerBody = { + portfolio_id: portfolioId, + housing_type: data.housingType, + goal: data.goal, + goal_value: data.goalValue, + trigger_file_path: assetListFileKey, + already_installed_file_path: "", + patches_file_path: "", + non_invasive_recommendations_file_path: "", + valuation_file_path: valuationDataFileKey, + scenario_name: data.scenario, + multi_plan: true, + budget: null, + event_type: "Remote Assessment", }; const response = await fetch("/api/plan/trigger", { method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify(triggerBody), }); if (!response.ok) { - throw new Error('Failed to trigger engine'); + throw new Error("Failed to trigger engine"); } } catch (error) { - console.error('Error triggering engine:', error); + console.error("Error triggering engine:", error); throw error; } } @@ -350,12 +366,18 @@ function useCreateRemoteAssessment({ async function handleSubmit(formData: FormValues) { try { const [assetListUrl, valuationDataUrl] = await Promise.all([ - mutatePresignedUrl({ userId, portfolioId, fileKey: assetListFileKey }), - mutatePresignedUrl({ userId, portfolioId, fileKey: valuationDataFileKey }) + mutatePresignedUrl({ + userId, + portfolioId, + fileKey: assetListFileKey }), + mutatePresignedUrl({ + userId, + portfolioId, + fileKey: valuationDataFileKey, + }), ]); await triggerEngine(formData); - } catch (error) { console.error("Error in submission process:", error); } @@ -364,14 +386,14 @@ function useCreateRemoteAssessment({ return { handleSubmit, triggerEngine, + mutateUploadFile, presignedUrlIsLoading, presignedUrlIsError, - uploadFilesIsLoading, - uploadFilesIsError, + uploadFileIsLoading, + uploadFileIsError, }; } - export default function RemoteAssessmentModal({ portfolioId, isOpen, @@ -381,7 +403,6 @@ export default function RemoteAssessmentModal({ setIsOpen: (isOpen: boolean) => void; portfolioId: string; }) { - const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { @@ -396,13 +417,14 @@ export default function RemoteAssessmentModal({ }, }); + const onSubmit = async (data: FormValues) => { try { await handleSubmit(data); form.reset(); setIsOpen(false); } catch (error) { - console.error('Error submitting form:', error); + console.error("Error submitting form:", error); } }; @@ -447,169 +469,184 @@ export default function RemoteAssessmentModal({ leaveTo="opacity-0 scale-95" > - - Remote Assessment Details - - -
- ( - - Scenario Name - - - - - - )} - /> + + Remote Assessment Details + + + + ( + + Scenario Name + + + + + + )} + /> - ( - - Housing Type - - field.onChange(option.value)} - /> - - - - )} - /> + ( + + Housing Type + + + field.onChange(option.value) + } + /> + + + + )} + /> - ( - - Goal - - field.onChange(option.value)} - /> - - - - )} - /> + ( + + Goal + + + field.onChange(option.value) + } + /> + + + + )} + /> - ( - - Goal Value - - field.onChange(option.value)} - /> - - - - )} - /> + ( + + Goal Value + + + field.onChange(option.value) + } + /> + + + + )} + /> - ( - - Address - - - - - - )} - /> + ( + + Address + + + + + + )} + /> - ( - - Postcode - - - - - - )} - /> + ( + + Postcode + + + + + + )} + /> - ( - - UPRN - - field.onChange(Number(e.target.value))} - /> - - - - )} - /> + ( + + UPRN + + + field.onChange(Number(e.target.value)) + } + /> + + + + )} + /> - ( - - Valuation - - field.onChange(Number(e.target.value))} - /> - - - - )} - /> + ( + + Valuation + + + field.onChange(Number(e.target.value)) + } + /> + + + + )} + /> -
- - -
- {presignedUrlIsError && ( -

Error uploading files

- )} - -
-
+
+ + +
+ {presignedUrlIsError && ( +

+ Error uploading files +

+ )} + + +
@@ -621,4 +658,3 @@ export default function RemoteAssessmentModal({ function setIsOpen(arg0: boolean) { throw new Error("Function not implemented."); } - diff --git a/src/app/shadcn_components/ui/toast.tsx b/src/app/shadcn_components/ui/toast.tsx new file mode 100644 index 00000000..84a73262 --- /dev/null +++ b/src/app/shadcn_components/ui/toast.tsx @@ -0,0 +1,129 @@ +"use client" + +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "s/lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/src/app/shadcn_components/ui/toaster.tsx b/src/app/shadcn_components/ui/toaster.tsx new file mode 100644 index 00000000..04165e35 --- /dev/null +++ b/src/app/shadcn_components/ui/toaster.tsx @@ -0,0 +1,35 @@ +"use client" + +import { useToast } from "src/app/shadcn_components/hooks/use-toast" +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "src/app/shadcn_components/ui/toast" + +export function Toaster() { + const { toasts } = useToast() + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ) + })} + +
+ ) +} From 64ac833d8cc840ea44779b6f93db81abf9c517ec Mon Sep 17 00:00:00 2001 From: StefanWout Date: Tue, 26 Nov 2024 18:15:06 +0000 Subject: [PATCH 27/29] version 1 of the remote assesment modal complete, budget and user feedback to be added --- src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx b/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx index a0001b1f..85b191b8 100644 --- a/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx +++ b/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx @@ -365,7 +365,7 @@ function useCreateRemoteAssessment({ async function handleSubmit(formData: FormValues) { try { - const [assetListUrl, valuationDataUrl] = await Promise.all([ + await Promise.all([ mutatePresignedUrl({ userId, portfolioId, From 7d7167f728274ae5a5f5947a4fccb4ab7ad0085b Mon Sep 17 00:00:00 2001 From: StefanWout Date: Tue, 3 Dec 2024 17:56:19 +0000 Subject: [PATCH 28/29] changed state variable names and tried to implement toast functionality --- src/app/hooks/use-toast.ts | 194 ------------------ .../components/RemoteAssessmentModal.tsx | 17 +- src/app/shadcn_components/ui/toaster.tsx | 4 +- 3 files changed, 16 insertions(+), 199 deletions(-) delete mode 100644 src/app/hooks/use-toast.ts diff --git a/src/app/hooks/use-toast.ts b/src/app/hooks/use-toast.ts deleted file mode 100644 index b18ceb84..00000000 --- a/src/app/hooks/use-toast.ts +++ /dev/null @@ -1,194 +0,0 @@ -"use client" - -// Inspired by react-hot-toast library -import * as React from "react" - -import type { - ToastActionElement, - ToastProps, -} from "src/app/shadcn_components/ui/toast" - -const TOAST_LIMIT = 1 -const TOAST_REMOVE_DELAY = 1000000 - -type ToasterToast = ToastProps & { - id: string - title?: React.ReactNode - description?: React.ReactNode - action?: ToastActionElement -} - -const actionTypes = { - ADD_TOAST: "ADD_TOAST", - UPDATE_TOAST: "UPDATE_TOAST", - DISMISS_TOAST: "DISMISS_TOAST", - REMOVE_TOAST: "REMOVE_TOAST", -} as const - -let count = 0 - -function genId() { - count = (count + 1) % Number.MAX_SAFE_INTEGER - return count.toString() -} - -type ActionType = typeof actionTypes - -type Action = - | { - type: ActionType["ADD_TOAST"] - toast: ToasterToast - } - | { - type: ActionType["UPDATE_TOAST"] - toast: Partial - } - | { - type: ActionType["DISMISS_TOAST"] - toastId?: ToasterToast["id"] - } - | { - type: ActionType["REMOVE_TOAST"] - toastId?: ToasterToast["id"] - } - -interface State { - toasts: ToasterToast[] -} - -const toastTimeouts = new Map>() - -const addToRemoveQueue = (toastId: string) => { - if (toastTimeouts.has(toastId)) { - return - } - - const timeout = setTimeout(() => { - toastTimeouts.delete(toastId) - dispatch({ - type: "REMOVE_TOAST", - toastId: toastId, - }) - }, TOAST_REMOVE_DELAY) - - toastTimeouts.set(toastId, timeout) -} - -export const reducer = (state: State, action: Action): State => { - switch (action.type) { - case "ADD_TOAST": - return { - ...state, - toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), - } - - case "UPDATE_TOAST": - return { - ...state, - toasts: state.toasts.map((t) => - t.id === action.toast.id ? { ...t, ...action.toast } : t - ), - } - - case "DISMISS_TOAST": { - const { toastId } = action - - // ! Side effects ! - This could be extracted into a dismissToast() action, - // but I'll keep it here for simplicity - if (toastId) { - addToRemoveQueue(toastId) - } else { - state.toasts.forEach((toast) => { - addToRemoveQueue(toast.id) - }) - } - - return { - ...state, - toasts: state.toasts.map((t) => - t.id === toastId || toastId === undefined - ? { - ...t, - open: false, - } - : t - ), - } - } - case "REMOVE_TOAST": - if (action.toastId === undefined) { - return { - ...state, - toasts: [], - } - } - return { - ...state, - toasts: state.toasts.filter((t) => t.id !== action.toastId), - } - } -} - -const listeners: Array<(state: State) => void> = [] - -let memoryState: State = { toasts: [] } - -function dispatch(action: Action) { - memoryState = reducer(memoryState, action) - listeners.forEach((listener) => { - listener(memoryState) - }) -} - -type Toast = Omit - -function toast({ ...props }: Toast) { - const id = genId() - - const update = (props: ToasterToast) => - dispatch({ - type: "UPDATE_TOAST", - toast: { ...props, id }, - }) - const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) - - dispatch({ - type: "ADD_TOAST", - toast: { - ...props, - id, - open: true, - onOpenChange: (open) => { - if (!open) dismiss() - }, - }, - }) - - return { - id: id, - dismiss, - update, - } -} - -function useToast() { - const [state, setState] = React.useState(memoryState) - - React.useEffect(() => { - listeners.push(setState) - return () => { - const index = listeners.indexOf(setState) - if (index > -1) { - listeners.splice(index, 1) - } - } - }, [state]) - - return { - ...state, - toast, - dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), - } -} - -export { useToast, toast } diff --git a/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx b/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx index 85b191b8..c864e79e 100644 --- a/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx +++ b/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx @@ -19,7 +19,10 @@ import { FormMessage, } from "@/app/shadcn_components/ui/form"; import { Toast } from "@/app/shadcn_components/ui/toast"; +import { Toaster } from "@/app/shadcn_components/ui/toaster" +import { useToast } from "@/app/shadcn_components/hooks/use-toast"; import { FileKey } from "lucide-react"; +import { use } from "chai"; type Option = { label: string; @@ -417,12 +420,18 @@ export default function RemoteAssessmentModal({ }, }); + const [toastState, setToastState] = useState([]); + const { toast } = useToast(toastState, setToastState); const onSubmit = async (data: FormValues) => { try { await handleSubmit(data); form.reset(); setIsOpen(false); + toast({ + title: "Success", + description: "Your remote assessment request has been sent.", + }); } catch (error) { console.error("Error submitting form:", error); } @@ -631,11 +640,13 @@ export default function RemoteAssessmentModal({ - diff --git a/src/app/shadcn_components/ui/toaster.tsx b/src/app/shadcn_components/ui/toaster.tsx index 04165e35..20722b1f 100644 --- a/src/app/shadcn_components/ui/toaster.tsx +++ b/src/app/shadcn_components/ui/toaster.tsx @@ -1,6 +1,6 @@ "use client" -import { useToast } from "src/app/shadcn_components/hooks/use-toast" +import { useToast } from "../hooks/use-toast"; import { Toast, ToastClose, @@ -8,7 +8,7 @@ import { ToastProvider, ToastTitle, ToastViewport, -} from "src/app/shadcn_components/ui/toast" +} from "../ui/toast" export function Toaster() { const { toasts } = useToast() From d1cbf076dde9fcfb80e9d41c2668ada475f68263 Mon Sep 17 00:00:00 2001 From: StefanWout Date: Wed, 4 Dec 2024 11:17:47 +0000 Subject: [PATCH 29/29] we fully toasty now, user can enjoy the feedback! --- src/app/hooks/use-toast.ts | 194 ++++++++++++++++++ src/app/layout.tsx | 2 + .../components/RemoteAssessmentModal.tsx | 18 +- src/app/shadcn_components/ui/toast.tsx | 2 +- src/app/shadcn_components/ui/toaster.tsx | 4 +- src/lib/utils.ts | 2 +- 6 files changed, 207 insertions(+), 15 deletions(-) create mode 100644 src/app/hooks/use-toast.ts diff --git a/src/app/hooks/use-toast.ts b/src/app/hooks/use-toast.ts new file mode 100644 index 00000000..b18ceb84 --- /dev/null +++ b/src/app/hooks/use-toast.ts @@ -0,0 +1,194 @@ +"use client" + +// Inspired by react-hot-toast library +import * as React from "react" + +import type { + ToastActionElement, + ToastProps, +} from "src/app/shadcn_components/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c2f4e51f..7b30618b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,6 +6,7 @@ import { AuthOptions } from "@/app/api/auth/[...nextauth]/route"; import { getServerSession } from "next-auth/next"; import { cache } from "react"; import { Inter } from "next/font/google"; +import { Toaster } from "@/app/shadcn_components/ui/toaster"; // If loading a variable font, you don't need to specify the font weight const inter = Inter({ @@ -51,6 +52,7 @@ export default async function RootLayout({