diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..674c9fbe Binary files /dev/null and b/.DS_Store differ diff --git a/client/.env.development b/client/.env.development index aa334de9..e92f3e4d 100644 --- a/client/.env.development +++ b/client/.env.development @@ -1,4 +1,6 @@ NEXT_PUBLIC_URL=http://localhost:$PORT -NEXT_PUBLIC_API_URL=http://0.0.0.0:1337/cms +NEXT_PUBLIC_API_URL=http://0.0.0.0:1337/api +NEXT_PUBLIC_CMS_URL=http://0.0.0.0:1337 NEXT_PUBLIC_GA_TRACKING_ID=UA-000000-01 NEXT_PUBLIC_MAPBOX_API_TOKEN=your-token-here + diff --git a/client/.eslintignore b/client/.eslintignore index d85a7996..9baa56c4 100644 --- a/client/.eslintignore +++ b/client/.eslintignore @@ -1,3 +1,4 @@ /.next /node_modules -/public \ No newline at end of file +/public +/src/types/generated diff --git a/client/.husky/post-checkout b/client/.husky/post-checkout new file mode 100644 index 00000000..882ff8bd --- /dev/null +++ b/client/.husky/post-checkout @@ -0,0 +1,13 @@ +#!/bin/bash +. "$(dirname -- "$0")/_/husky.sh" + +SCRIPT_DIR="$(pwd)" +CMS_ENV_FILE="$SCRIPT_DIR/cms/.env" +echo $CMS_ENV_FILE +if [ -f "$CMS_ENV_FILE" ] ; then + echo "Importing CMS config.." + cd ./cms && yarn config-sync import -y +else + echo "CMS env file does not exist, can't import config" + echo "DEBUG: looking for env file in $CMS_ENV_FILE" +fi \ No newline at end of file diff --git a/client/.husky/post-merge b/client/.husky/post-merge new file mode 100755 index 00000000..02859624 --- /dev/null +++ b/client/.husky/post-merge @@ -0,0 +1,29 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +# If client yarn.lock has changed, run yarn to install dependencies +CLIENT_CHANGED=`git diff HEAD@{1} --stat -- ./client/yarn.lock | wc -l` +if [ "$CLIENT_CHANGED" -gt 0 ]; +then + echo "client/yarn.lock has changed!" + cd ./client && yarn +fi + +# If CMS yarn.lock has changed, run yarn to install dependencies +CMS_CHANGED=`git diff HEAD@{1} --stat -- ./cms/yarn.lock | wc -l` +if [ "$CMS_CHANGED" -gt 0 ]; +then + echo "cms/yarn.lock has changed!" + cd ./cms && yarn +fi + +# If CMS env file exists and there are changes to the config sync files, import them into the DB +CMS_CONFIG_CHANGED=`git diff HEAD@{1} --stat -- ./cms/config/sync/ | wc -l` +SCRIPT_DIR="$(pwd)" +CMS_ENV_FILE="$SCRIPT_DIR/../cms/.env" +if [[ ! -f "$CMS_ENV_FILE" ]] ; then + echo "CMS env file does not exist, can't import config." +elif [[ -f "$CMS_ENV_FILE" && "$CMS_CONFIG_CHANGED" -gt 0 ]]; then + echo "Importing CMS config..." + cd ./cms && yarn config-sync import -y +fi diff --git a/client/next.config.mjs b/client/next.config.mjs index 1ce5c6e2..c94c9916 100644 --- a/client/next.config.mjs +++ b/client/next.config.mjs @@ -3,10 +3,23 @@ import("./src/env.mjs"); /** @type {import('next').NextConfig} */ const nextConfig = { images: { + unoptimized: true, remotePatterns: [ { - protocol: 'https', - hostname: 'api.mapbox.com', + protocol: "https", + hostname: "api.mapbox.com", + }, + { + protocol: "http", + hostname: "0.0.0.0", + }, + { + protocol: "https", + hostname: "staging.ccsa.dev-vizzuality.com", + }, + { + protocol: "https", + hostname: "map.caribbeanaccelerator.org", }, ], }, diff --git a/client/orval.config.ts b/client/orval.config.ts index 836d8719..68994500 100644 --- a/client/orval.config.ts +++ b/client/orval.config.ts @@ -31,9 +31,23 @@ module.exports = { "Pillar", "Sdg", "Download-email", + "Objective", + "Organization-type", "Other-tool", + "Other-tools-category", + "Project-edit-suggestion", + "Project-status", + "Types-of-funding", + "Tool-edit-suggestion", "Dataset-value", + "Dataset-edit-suggestion", "Collaborator", + "Collaborator-edit-suggestion", + "User", + "Users-Permissions - Auth", + "Users-Permissions - Users & Roles", + "Welcome-message", + "World-country", ], }, }, diff --git a/client/package.json b/client/package.json index aeb51dcc..a127bee3 100644 --- a/client/package.json +++ b/client/package.json @@ -25,6 +25,7 @@ "@hookform/resolvers": "^3.3.2", "@next/third-parties": "^14.1.0", "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-checkbox": "1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-label": "^2.0.2", @@ -41,15 +42,19 @@ "@tailwindcss/typography": "^0.5.10", "@tanstack/react-query": "4.35.3", "@turf/centroid": "7.0.0-alpha.2", + "@types/chroma-js": "^2.4.4", "@types/react-world-flags": "^1.4.5", "@typescript-eslint/eslint-plugin": "6.7.3", "@typescript-eslint/parser": "6.7.3", + "@uiw/react-md-editor": "^4.0.4", + "@vinhpd/react-simple-captcha": "^9.0.1", "autoprefixer": "10.4.16", "axios": "^1.5.1", "class-variance-authority": "0.7.0", "clsx": "2.0.0", "cmdk": "^0.2.0", "deck.gl": "^8.9.31", + "eslint-plugin-import": "2.29.1", "export-to-csv": "1.2.1", "express": "4.18.2", "glslify": "^7.1.1", @@ -57,19 +62,25 @@ "lodash-es": "^4.17.21", "mapbox-gl": "2.15.0", "next": "^14.0.3", + "next-auth": "4.24.7", "next-usequerystate": "^1.9.1", "pino": "8.15.1", "pino-http": "8.5.0", "pino-pretty": "10.2.0", "postcss": "8.4.30", "react": "18.2.0", + "react-cookie": "^7.1.4", "react-dom": "18.2.0", + "react-dropzone": "^14.2.3", "react-hook-form": "^7.48.2", "react-icons": "4.11.0", "react-map-gl": "7.1.6", "react-markdown": "^9.0.0", + "react-player": "^2.16.0", + "react-toastify": "^10.0.5", "react-world-flags": "^1.6.0", "rooks": "7.14.1", + "screenfull": "^6.0.2", "tailwind-merge": "1.14.0", "tailwindcss": "3.3.3", "tailwindcss-animate": "1.0.7", @@ -78,6 +89,8 @@ }, "devDependencies": { "@playwright/test": "1.38.1", + "@radix-ui/react-separator": "1.0.3", + "@radix-ui/react-tabs": "1.0.4", "@tanstack/eslint-plugin-query": "4.34.1", "@types/express": "4.17.18", "@types/geojson": "^7946.0.12", @@ -86,10 +99,13 @@ "@types/node": "20.7.0", "@types/react": "18.2.23", "@types/react-dom": "18.2.8", - "eslint": "8.50.0", + "chroma-js": "2.6.0", + "date-fns": "3.6.0", + "eslint": "^9.8.0", "eslint-config-next": "13.5.3", "eslint-config-prettier": "9.0.0", "eslint-plugin-prettier": "5.0.0", + "eslint-plugin-simple-import-sort": "^12.1.1", "husky": "8.0.3", "orval": "^6.19.0", "prettier": "3.0.3", diff --git a/client/public/images/collaborators/no-image-placeholder.png b/client/public/images/collaborators/no-image-placeholder.png new file mode 100644 index 00000000..477ff1bd Binary files /dev/null and b/client/public/images/collaborators/no-image-placeholder.png differ diff --git a/client/public/images/image-file.png b/client/public/images/image-file.png new file mode 100644 index 00000000..85e7c1f8 Binary files /dev/null and b/client/public/images/image-file.png differ diff --git a/client/public/images/welcome-message.jpeg b/client/public/images/welcome-message.jpeg new file mode 100644 index 00000000..6e3a2b2c Binary files /dev/null and b/client/public/images/welcome-message.jpeg differ diff --git a/client/src/app/(app)/collaborators/page.tsx b/client/src/app/(app)/collaborators/page.tsx index 9af8d071..8b8adf24 100644 --- a/client/src/app/(app)/collaborators/page.tsx +++ b/client/src/app/(app)/collaborators/page.tsx @@ -1,18 +1,15 @@ import CollaboratorsList from "@/containers/collaborators"; +import CollaboratorsTitle from "@/containers/collaborators/title"; import PageTitle from "@/components/ui/page-title"; -export const metadata = { - title: "Collaborators", -}; - export default function CollaboratorsPage() { return (
-

Collaborators

+
diff --git a/client/src/app/(app)/layout-providers.tsx b/client/src/app/(app)/layout-providers.tsx index 408ddc33..3d23920b 100644 --- a/client/src/app/(app)/layout-providers.tsx +++ b/client/src/app/(app)/layout-providers.tsx @@ -2,14 +2,21 @@ import { PropsWithChildren } from "react"; +import { CookiesProvider } from "react-cookie"; import { MapProvider } from "react-map-gl"; import { Provider as JotaiProvider } from "jotai"; export default function LayoutProviders({ children }: PropsWithChildren) { return ( - - {children} - + + + {children} + + ); } diff --git a/client/src/app/(app)/layout.tsx b/client/src/app/(app)/layout.tsx index 8ea1589c..093d3486 100644 --- a/client/src/app/(app)/layout.tsx +++ b/client/src/app/(app)/layout.tsx @@ -1,5 +1,6 @@ import { PropsWithChildren } from "react"; +import dynamic from "next/dynamic"; import { headers } from "next/headers"; import { Hydrate, dehydrate } from "@tanstack/react-query"; @@ -7,6 +8,7 @@ import { Hydrate, dehydrate } from "@tanstack/react-query"; import getQueryClient from "@/lib/react-query/getQueryClient"; import { getGetCategoriesQueryKey, getGetCategoriesQueryOptions } from "@/types/generated/category"; +import { getGetCollaboratorsQueryOptions } from "@/types/generated/collaborator"; import { getGetCountriesQueryOptions } from "@/types/generated/country"; import { getGetDatasetsQueryOptions } from "@/types/generated/dataset"; import { getGetPillarsQueryOptions } from "@/types/generated/pillar"; @@ -27,7 +29,8 @@ import Navigation from "@/containers/navigation"; import Sidebar from "@/containers/sidebar"; import LayoutProviders from "./layout-providers"; -import { getGetCollaboratorsQueryOptions } from "@/types/generated/collaborator"; + +const WelcomeMessage = dynamic(() => import("@/containers/welcome-message"), { ssr: false }); export default async function AppLayout({ children }: PropsWithChildren) { const url = new URL(headers().get("x-url")!); @@ -87,6 +90,8 @@ export default async function AppLayout({ children }: PropsWithChildren) { {children} + + ); diff --git a/client/src/app/(app)/other-tools/page.tsx b/client/src/app/(app)/other-tools/page.tsx index 7b4f8792..22c48389 100644 --- a/client/src/app/(app)/other-tools/page.tsx +++ b/client/src/app/(app)/other-tools/page.tsx @@ -1,7 +1,12 @@ import { dehydrate, Hydrate } from "@tanstack/react-query"; -import { getGetOtherToolsQueryOptions } from "@/types/generated/other-tool"; + import getQueryClient from "@/lib/react-query/getQueryClient"; + +import { getGetOtherToolsQueryOptions } from "@/types/generated/other-tool"; + import OtherToolsList from "@/containers/other-tools"; +import OtherToolsTitle from "@/containers/other-tools/title"; + import PageTitle from "@/components/ui/page-title"; export const metadata = { @@ -37,7 +42,7 @@ export default async function OtherTools() {
-

Other Tools

+
diff --git a/client/src/app/(app)/page.tsx b/client/src/app/(app)/page.tsx index fdceaba6..48de2cf0 100644 --- a/client/src/app/(app)/page.tsx +++ b/client/src/app/(app)/page.tsx @@ -1,9 +1,10 @@ -import PageTitle from "@/components/ui/page-title"; import CountryPopup from "@/containers/countries/popup"; import DatasetsCategories from "@/containers/datasets/categories"; import DatasetsHeader from "@/containers/datasets/header"; import DatasetsSearch from "@/containers/datasets/search"; +import PageTitle from "@/components/ui/page-title"; + export default function HomePage() { return ( <> diff --git a/client/src/app/(app)/projects/page.tsx b/client/src/app/(app)/projects/page.tsx index d7bc5402..6e5b5027 100644 --- a/client/src/app/(app)/projects/page.tsx +++ b/client/src/app/(app)/projects/page.tsx @@ -1,9 +1,10 @@ -import PageTitle from "@/components/ui/page-title"; import Projects from "@/containers/projects"; import ProjectsFilters from "@/containers/projects/filters"; import ProjectsHeader from "@/containers/projects/header"; import ProjectPopup from "@/containers/projects/popup"; +import PageTitle from "@/components/ui/page-title"; + export const metadata = { title: "Projects", }; diff --git a/client/src/app/(auth)/layout.tsx b/client/src/app/(auth)/layout.tsx new file mode 100644 index 00000000..4512e631 --- /dev/null +++ b/client/src/app/(auth)/layout.tsx @@ -0,0 +1,23 @@ +import { dehydrate } from "@tanstack/react-query"; +import { Hydrate } from "@tanstack/react-query"; + +import getQueryClient from "@/lib/react-query/getQueryClient"; + +import Footer from "@/containers/footer"; +import Header from "@/containers/header"; + +export default async function AuthLayout({ children }: { children: React.ReactNode }) { + const queryClient = getQueryClient(); + + const dehydratedState = dehydrate(queryClient); + + return ( + +
+
+
{children}
+
+
+
+ ); +} diff --git a/client/src/app/(auth)/signin/page.tsx b/client/src/app/(auth)/signin/page.tsx new file mode 100644 index 00000000..b2021344 --- /dev/null +++ b/client/src/app/(auth)/signin/page.tsx @@ -0,0 +1,17 @@ +import { Metadata } from "next"; + +import Signin from "@/components/forms/signin"; + +export const metadata: Metadata = { + title: "Sign in | Caribbean Climate smart map", + description: "Generated by create next app", +}; + +export default function SigninPage() { + return ( + <> +

Log in

+ + + ); +} diff --git a/client/src/app/(auth)/signup/page.tsx b/client/src/app/(auth)/signup/page.tsx new file mode 100644 index 00000000..d0b2a91f --- /dev/null +++ b/client/src/app/(auth)/signup/page.tsx @@ -0,0 +1,17 @@ +import { Metadata } from "next"; + +import Signup from "@/components/forms/signup"; + +export const metadata: Metadata = { + title: "Sign up | Caribbean Climate smart map", + description: "Caribbean Climate smart map", +}; + +export default function SignupPage() { + return ( + <> +

Sign up

+ + + ); +} diff --git a/client/src/app/(dashboard)/dashboard/(profile)/[[...id]]/page.tsx b/client/src/app/(dashboard)/dashboard/(profile)/[[...id]]/page.tsx new file mode 100644 index 00000000..4b98f4de --- /dev/null +++ b/client/src/app/(dashboard)/dashboard/(profile)/[[...id]]/page.tsx @@ -0,0 +1,16 @@ +import { Metadata } from "next"; + +import DashboardContent from "@/containers/dashboard"; + +export const metadata: Metadata = { + title: "Dashboard | Caribbean Climate smart map", + description: "Generated by create next app", +}; + +export default function DashboardPage() { + return ( +
+ +
+ ); +} diff --git a/client/src/app/(dashboard)/dashboard/collaborators/[[...id]]/page.tsx b/client/src/app/(dashboard)/dashboard/collaborators/[[...id]]/page.tsx new file mode 100644 index 00000000..08fd1c18 --- /dev/null +++ b/client/src/app/(dashboard)/dashboard/collaborators/[[...id]]/page.tsx @@ -0,0 +1,16 @@ +import { Metadata } from "next"; + +import CollaboratorsForm from "@/containers/collaborators/form"; + +export const metadata: Metadata = { + title: "Create new collaborator | Caribbean Climate smart map", + description: "Generated by create next app", +}; + +export default function CollaboratorPage() { + return ( +
+ +
+ ); +} diff --git a/client/src/app/(dashboard)/dashboard/collaborators/layout.tsx b/client/src/app/(dashboard)/dashboard/collaborators/layout.tsx new file mode 100644 index 00000000..9110a125 --- /dev/null +++ b/client/src/app/(dashboard)/dashboard/collaborators/layout.tsx @@ -0,0 +1,47 @@ +import Link from "next/link"; + +import { dehydrate, Hydrate } from "@tanstack/react-query"; + +import getQueryClient from "@/lib/react-query/getQueryClient"; + +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; + +export default async function DashboardCollaboratorsLayout({ + children, +}: { + children: React.ReactNode; +}) { + const queryClient = getQueryClient(); + + const dehydratedState = dehydrate(queryClient); + + return ( + +
+
+ + + + + Map + + + + + Collaborators + + + +
+ {children} +
+
+ ); +} diff --git a/client/src/app/(dashboard)/dashboard/datasets/changes-to-approve/[id]/page.tsx b/client/src/app/(dashboard)/dashboard/datasets/changes-to-approve/[id]/page.tsx new file mode 100644 index 00000000..e370e2c0 --- /dev/null +++ b/client/src/app/(dashboard)/dashboard/datasets/changes-to-approve/[id]/page.tsx @@ -0,0 +1,115 @@ +import { Metadata } from "next"; + +import Link from "next/link"; + +import { Hydrate, dehydrate } from "@tanstack/react-query"; + +import getQueryClient from "@/lib/react-query/getQueryClient"; + +import { getGetDatasetsIdQueryOptions } from "@/types/generated/dataset"; +import { + getGetDatasetEditSuggestionsIdQueryKey, + getGetDatasetEditSuggestionsIdQueryOptions, +} from "@/types/generated/dataset-edit-suggestion"; +import { getGetDatasetValuesQueryOptions } from "@/types/generated/dataset-value"; +import { DatasetEditSuggestionResponse } from "@/types/generated/strapi.schemas"; + +import DatasetChangesToApprove from "@/containers/datasets/changes-to-approve"; + +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; + +export const metadata: Metadata = { + title: "Changes to approve | Caribbean Climate smart map", + description: "Generated by create next app", +}; + +export default async function ChangesToApprovePage({ params }: { params: { id: number } }) { + const { id } = params; + const queryClient = getQueryClient(); + + await queryClient.prefetchQuery( + getGetDatasetEditSuggestionsIdQueryOptions(id, { + populate: "*", + }), + ); + + const ds = queryClient.getQueryData( + getGetDatasetEditSuggestionsIdQueryKey(id, { populate: "*" }), + ); + + const datasetId = ds?.data?.attributes?.dataset?.data?.id; + + if (datasetId) { + await queryClient.prefetchQuery( + getGetDatasetsIdQueryOptions( + datasetId, + { + populate: "*", + }, + { + query: { + enabled: !!datasetId, + }, + }, + ), + ); + + await queryClient.prefetchQuery( + getGetDatasetValuesQueryOptions( + { + filters: { + dataset: datasetId, + }, + "pagination[pageSize]": 300, + populate: { + country: { + fields: ["name", "iso3"], + }, + resources: true, + }, + }, + { + query: { + enabled: !!datasetId, + }, + }, + ), + ); + } + + const dehydratedState = dehydrate(queryClient); + + return ( + +
+ + + + + Map + + + + + + My profile + + + + + Suggested changes + + + +
+ +
+ ); +} diff --git a/client/src/app/(dashboard)/dashboard/datasets/edit/[id]/page.tsx b/client/src/app/(dashboard)/dashboard/datasets/edit/[id]/page.tsx new file mode 100644 index 00000000..689a8202 --- /dev/null +++ b/client/src/app/(dashboard)/dashboard/datasets/edit/[id]/page.tsx @@ -0,0 +1,81 @@ +import { Metadata } from "next"; + +import Link from "next/link"; + +import { Hydrate, dehydrate } from "@tanstack/react-query"; + +import getQueryClient from "@/lib/react-query/getQueryClient"; + +import { getGetDatasetsIdQueryOptions } from "@/types/generated/dataset"; +import { getGetDatasetValuesQueryOptions } from "@/types/generated/dataset-value"; + +import EditDatasetForm from "@/containers/datasets/edit"; + +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; + +export const metadata: Metadata = { + title: "Edit dataset form | Caribbean Climate smart map", + description: "Generated by create next app", +}; + +export default async function EditDatasetPage({ params }: { params: { id: number } }) { + const { id } = params; + const queryClient = getQueryClient(); + + await queryClient.prefetchQuery( + getGetDatasetsIdQueryOptions(id, { + populate: "*", + }), + ); + + await queryClient.prefetchQuery( + getGetDatasetValuesQueryOptions({ + filters: { + dataset: id, + }, + "pagination[pageSize]": 300, + populate: { + country: { + fields: ["name", "iso3"], + }, + resources: true, + }, + }), + ); + + const dehydratedState = dehydrate(queryClient); + + return ( + +
+ + + + + Map + + + + + + My profile + + + + + Edit dataset + + + +
+ +
+ ); +} diff --git a/client/src/app/(dashboard)/dashboard/datasets/layout.tsx b/client/src/app/(dashboard)/dashboard/datasets/layout.tsx new file mode 100644 index 00000000..935505cf --- /dev/null +++ b/client/src/app/(dashboard)/dashboard/datasets/layout.tsx @@ -0,0 +1,25 @@ +import { dehydrate, Hydrate } from "@tanstack/react-query"; + +import getQueryClient from "@/lib/react-query/getQueryClient"; + +import { getGetCategoriesQueryOptions } from "@/types/generated/category"; +import { getGetCountriesQueryOptions } from "@/types/generated/country"; + +import { GET_COUNTRIES_OPTIONS } from "@/constants/countries"; +import { GET_CATEGORIES_OPTIONS } from "@/constants/datasets"; + +export default async function DashboardDatasetsLayout({ children }: { children: React.ReactNode }) { + const queryClient = getQueryClient(); + + await queryClient.prefetchQuery(getGetCountriesQueryOptions(GET_COUNTRIES_OPTIONS)); + + await queryClient.prefetchQuery(getGetCategoriesQueryOptions(GET_CATEGORIES_OPTIONS())); + + const dehydratedState = dehydrate(queryClient); + + return ( + +
{children}
+
+ ); +} diff --git a/client/src/app/(dashboard)/dashboard/datasets/new/page.tsx b/client/src/app/(dashboard)/dashboard/datasets/new/page.tsx new file mode 100644 index 00000000..143a7e2f --- /dev/null +++ b/client/src/app/(dashboard)/dashboard/datasets/new/page.tsx @@ -0,0 +1,48 @@ +import { Metadata } from "next"; + +import Link from "next/link"; + +import NewDatasetForm from "@/containers/datasets/new"; + +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; + +export const metadata: Metadata = { + title: "New dataset form | Caribbean Climate smart map", + description: "Generated by create next app", +}; + +export default function NewDatasetPage() { + return ( + <> +
+ + + + + Map + + + + + + My profile + + + + + New dataset + + + +
+ + + ); +} diff --git a/client/src/app/(dashboard)/dashboard/other-tools/[[...id]]/page.tsx b/client/src/app/(dashboard)/dashboard/other-tools/[[...id]]/page.tsx new file mode 100644 index 00000000..d59149d6 --- /dev/null +++ b/client/src/app/(dashboard)/dashboard/other-tools/[[...id]]/page.tsx @@ -0,0 +1,16 @@ +import { Metadata } from "next"; + +import ToolForm from "@/containers/other-tools/form"; + +export const metadata: Metadata = { + title: "Create / edit a tool | Caribbean Climate smart map", + description: "Generated by create next app", +}; + +export default function OtherToolsFormPage() { + return ( +
+ +
+ ); +} diff --git a/client/src/app/(dashboard)/dashboard/other-tools/layout.tsx b/client/src/app/(dashboard)/dashboard/other-tools/layout.tsx new file mode 100644 index 00000000..0dad612e --- /dev/null +++ b/client/src/app/(dashboard)/dashboard/other-tools/layout.tsx @@ -0,0 +1,46 @@ +import Link from "next/link"; + +import { dehydrate, Hydrate } from "@tanstack/react-query"; + +import getQueryClient from "@/lib/react-query/getQueryClient"; + +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; + +export default async function DashboardOtherToolsLayout({ + children, +}: { + children: React.ReactNode; +}) { + const queryClient = getQueryClient(); + + const dehydratedState = dehydrate(queryClient); + return ( + +
+
+ + + + + Map + + + + + Other tools + + + +
+ {children} +
+
+ ); +} diff --git a/client/src/app/(dashboard)/dashboard/projects/[[...id]]/page.tsx b/client/src/app/(dashboard)/dashboard/projects/[[...id]]/page.tsx new file mode 100644 index 00000000..e21f9c66 --- /dev/null +++ b/client/src/app/(dashboard)/dashboard/projects/[[...id]]/page.tsx @@ -0,0 +1,16 @@ +import { Metadata } from "next"; + +import ProjectForm from "@/containers/projects/form"; + +export const metadata: Metadata = { + title: "Create / edit a project | Caribbean Climate smart map", + description: "Generated by create next app", +}; + +export default function NewProjectsPage() { + return ( +
+ +
+ ); +} diff --git a/client/src/app/(dashboard)/dashboard/projects/layout.tsx b/client/src/app/(dashboard)/dashboard/projects/layout.tsx new file mode 100644 index 00000000..a2261478 --- /dev/null +++ b/client/src/app/(dashboard)/dashboard/projects/layout.tsx @@ -0,0 +1,43 @@ +import Link from "next/link"; + +import { dehydrate, Hydrate } from "@tanstack/react-query"; + +import getQueryClient from "@/lib/react-query/getQueryClient"; + +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; + +export default async function DashboardProjectsLayout({ children }: { children: React.ReactNode }) { + const queryClient = getQueryClient(); + + const dehydratedState = dehydrate(queryClient); + + return ( + +
+
+ + + + + Map + + + + + Projects + + + +
+ {children} +
+
+ ); +} diff --git a/client/src/app/(dashboard)/layout.tsx b/client/src/app/(dashboard)/layout.tsx new file mode 100644 index 00000000..e611959a --- /dev/null +++ b/client/src/app/(dashboard)/layout.tsx @@ -0,0 +1,20 @@ +import { dehydrate, Hydrate } from "@tanstack/react-query"; + +import getQueryClient from "@/lib/react-query/getQueryClient"; + +import DashboardHeader from "@/containers/dashboard-header"; + +export default async function DashboardLayout({ children }: { children: React.ReactNode }) { + const queryClient = getQueryClient(); + + const dehydratedState = dehydrate(queryClient); + + return ( + +
+ +
{children}
+
+
+ ); +} diff --git a/client/src/app/(embed)/embed/layout-providers.tsx b/client/src/app/(embed)/embed/layout-providers.tsx new file mode 100644 index 00000000..408ddc33 --- /dev/null +++ b/client/src/app/(embed)/embed/layout-providers.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { PropsWithChildren } from "react"; + +import { MapProvider } from "react-map-gl"; + +import { Provider as JotaiProvider } from "jotai"; + +export default function LayoutProviders({ children }: PropsWithChildren) { + return ( + + {children} + + ); +} diff --git a/client/src/app/(embed)/embed/layout.tsx b/client/src/app/(embed)/embed/layout.tsx new file mode 100644 index 00000000..4f6e9621 --- /dev/null +++ b/client/src/app/(embed)/embed/layout.tsx @@ -0,0 +1,90 @@ +import { PropsWithChildren } from "react"; + +import { headers } from "next/headers"; + +import { Hydrate, dehydrate } from "@tanstack/react-query"; + +import getQueryClient from "@/lib/react-query/getQueryClient"; + +import { getGetCategoriesQueryKey, getGetCategoriesQueryOptions } from "@/types/generated/category"; +import { getGetCollaboratorsQueryOptions } from "@/types/generated/collaborator"; +import { getGetCountriesQueryOptions } from "@/types/generated/country"; +import { getGetDatasetsQueryOptions } from "@/types/generated/dataset"; +import { getGetPillarsQueryOptions } from "@/types/generated/pillar"; +import { getGetProjectsQueryOptions } from "@/types/generated/project"; +import { getGetSdgsQueryOptions } from "@/types/generated/sdg"; +import { CategoryListResponse } from "@/types/generated/strapi.schemas"; + +import { countriesParser, pillarsParser } from "@/app/parsers"; + +import { GET_COUNTRIES_OPTIONS } from "@/constants/countries"; +import { GET_CATEGORIES_OPTIONS, GET_DATASETS_OPTIONS } from "@/constants/datasets"; +import { GET_PILLARS_OPTIONS } from "@/constants/pillars"; +import { GET_PROJECTS_OPTIONS } from "@/constants/projects"; +import { GET_SDGs_OPTIONS } from "@/constants/sdgs"; + +import Map from "@/containers/map"; + +import LayoutProviders from "./layout-providers"; + +export default async function EmbedLayout({ children }: PropsWithChildren) { + const url = new URL(headers().get("x-url")!); + const searchParams = url.searchParams; + + const queryClient = getQueryClient(); + + // Prefetch countries + await queryClient.prefetchQuery(getGetCountriesQueryOptions(GET_COUNTRIES_OPTIONS)); + + // Prefetch categories + await queryClient.prefetchQuery( + getGetCategoriesQueryOptions(GET_CATEGORIES_OPTIONS("", searchParams.get("preview") || "")), + ); + + const CATEGORIES = queryClient.getQueryData( + getGetCategoriesQueryKey(GET_CATEGORIES_OPTIONS("", searchParams.get("preview") || "")), + ); + + for (const category of CATEGORIES?.data || []) { + if (!category.id) continue; + + // Prefetch datasets + await queryClient.prefetchQuery( + getGetDatasetsQueryOptions( + GET_DATASETS_OPTIONS("", category.id, searchParams.get("preview") || ""), + ), + ); + } + + // Prefetch projects + await queryClient.prefetchQuery( + getGetProjectsQueryOptions( + GET_PROJECTS_OPTIONS("", { + pillars: pillarsParser.parseServerSide(searchParams.get("pillars") || []), + countries: countriesParser.parseServerSide(searchParams.get("countries") || []), + }), + ), + ); + + // Prefetch pillars + await queryClient.prefetchQuery(getGetPillarsQueryOptions(GET_PILLARS_OPTIONS)); + + // Prefetch sdgs + await queryClient.prefetchQuery(getGetSdgsQueryOptions(GET_SDGs_OPTIONS)); + + // Prefetch collaborators + await queryClient.prefetchQuery(getGetCollaboratorsQueryOptions()); + + const dehydratedState = dehydrate(queryClient); + + return ( + + +
+ {children} + +
+
+
+ ); +} diff --git a/client/src/app/(embed)/embed/page.tsx b/client/src/app/(embed)/embed/page.tsx new file mode 100644 index 00000000..ff1e1633 --- /dev/null +++ b/client/src/app/(embed)/embed/page.tsx @@ -0,0 +1,9 @@ +import CountryDetail from "@/containers/countries/countries-detail"; + +export default function EmbedPage() { + return ( +
+ +
+ ); +} diff --git a/client/src/app/(password)/layout.tsx b/client/src/app/(password)/layout.tsx new file mode 100644 index 00000000..267703d0 --- /dev/null +++ b/client/src/app/(password)/layout.tsx @@ -0,0 +1,21 @@ +import { dehydrate } from "@tanstack/react-query"; +import { Hydrate } from "@tanstack/react-query"; + +import getQueryClient from "@/lib/react-query/getQueryClient"; + +import Header from "@/containers/header"; + +export default async function AuthLayout({ children }: { children: React.ReactNode }) { + const queryClient = getQueryClient(); + + const dehydratedState = dehydrate(queryClient); + + return ( + +
+
+
{children}
+
+
+ ); +} diff --git a/client/src/app/(password)/reset-password/page.tsx b/client/src/app/(password)/reset-password/page.tsx new file mode 100644 index 00000000..b465ea26 --- /dev/null +++ b/client/src/app/(password)/reset-password/page.tsx @@ -0,0 +1,23 @@ +import { Metadata } from "next"; + +import ResetPassword from "@/components/forms/reset-password"; + +export const metadata: Metadata = { + title: "Reset Password | Caribbean Climate smart map", + description: "Generated by create next app", +}; + +export default function ResetPasswordPage() { + return ( + <> +
+

Reset your password

+

+ Enter your email address or username, and we'll send you a link to get back into your + account. +

+
+ + + ); +} diff --git a/client/src/app/(password)/update-password/page.tsx b/client/src/app/(password)/update-password/page.tsx new file mode 100644 index 00000000..936de8b1 --- /dev/null +++ b/client/src/app/(password)/update-password/page.tsx @@ -0,0 +1,19 @@ +import { Metadata } from "next"; + +import PasswordUpdate from "@/components/forms/password-update"; + +export const metadata: Metadata = { + title: "Reset Password | Caribbean Climate smart map", + description: "Generated by create next app", +}; + +export default function UpdatePasswordPage() { + return ( + <> +
+

Reset your password

+
+ + + ); +} diff --git a/client/src/app/api/auth/[...nextauth]/options.ts b/client/src/app/api/auth/[...nextauth]/options.ts new file mode 100644 index 00000000..b5e74180 --- /dev/null +++ b/client/src/app/api/auth/[...nextauth]/options.ts @@ -0,0 +1,61 @@ +import { AuthOptions, Awaitable, User } from "next-auth"; +import { JWT } from "next-auth/jwt"; +import CredentialsProvider from "next-auth/providers/credentials"; + +import { postAuthLocal } from "@/types/generated/users-permissions-auth"; + +export const authOptions: AuthOptions = { + providers: [ + CredentialsProvider({ + name: "Credentials", + credentials: { + email: { label: "Email", type: "text" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + try { + const u = await postAuthLocal({ + identifier: credentials?.email, + password: credentials?.password, + }); + const { jwt: apiToken, user } = u; + + if (user) { + return { ...user, apiToken } as unknown as Awaitable; + } + + return null; + } catch (error) { + console.error(error); + } + + return null; + }, + }), + ], + session: { strategy: "jwt" }, + callbacks: { + async session({ session, token }) { + const sanitizedToken = Object.keys(token).reduce((p, c) => { + // strip unnecessary properties + if (c !== "iat" && c !== "exp" && c !== "jti" && c !== "apiToken") { + return { ...p, [c]: token[c] }; + } else { + return p; + } + }, {}); + return { ...session, user: sanitizedToken, apiToken: token.apiToken }; + }, + async jwt({ token, user }) { + if (typeof user !== "undefined") { + // user has just signed in so the user object is populated + return user as unknown as JWT; + } + return token; + }, + }, + pages: { + signIn: "/signin", + error: "/signin", + }, +}; diff --git a/client/src/app/api/auth/[...nextauth]/route.ts b/client/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 00000000..a51e223b --- /dev/null +++ b/client/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,7 @@ +import NextAuth from "next-auth"; + +import { authOptions } from "@/app/api/auth/[...nextauth]/options"; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; diff --git a/client/src/app/layout-providers.tsx b/client/src/app/layout-providers.tsx index f4ee7600..c215884a 100644 --- a/client/src/app/layout-providers.tsx +++ b/client/src/app/layout-providers.tsx @@ -3,17 +3,24 @@ import { PropsWithChildren, useState } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Session } from "next-auth"; +import { SessionProvider } from "next-auth/react"; import { TooltipProvider } from "@/components/ui/tooltip"; -export default function LayoutProviders({ children }: PropsWithChildren) { +export default function LayoutProviders({ + children, + session, +}: PropsWithChildren<{ session?: Session | null }>) { const [queryClient] = useState(() => new QueryClient()); return ( <> - - {children} - + + + {children} + + ); } diff --git a/client/src/app/layout.tsx b/client/src/app/layout.tsx index dd9fdf24..0f819bd0 100644 --- a/client/src/app/layout.tsx +++ b/client/src/app/layout.tsx @@ -1,14 +1,25 @@ import "@/styles/globals.css"; import "mapbox-gl/dist/mapbox-gl.css"; +import "react-toastify/dist/ReactToastify.css"; import { PropsWithChildren } from "react"; +import { ToastContainer } from "react-toastify"; + import type { Metadata } from "next"; import { GoogleAnalytics } from "@next/third-parties/google"; +import { Hydrate, dehydrate } from "@tanstack/react-query"; +import { getServerSession } from "next-auth"; import env from "@/env.mjs"; +import getQueryClient from "@/lib/react-query/getQueryClient"; + +import { getGetUsersIdQueryOptions } from "@/types/generated/users-permissions-users-roles"; + +import { authOptions } from "@/app/api/auth/[...nextauth]/options"; + import PoweredBy from "@/containers/powered-by"; import { metropolis, openSans } from "@/styles/fonts"; @@ -21,19 +32,46 @@ export const metadata: Metadata = { }; export default async function RootLayout({ children }: PropsWithChildren) { + const session = await getServerSession(authOptions); + + const queryClient = getQueryClient(); + + // Prefetch user + if (session?.user?.id) { + await queryClient.prefetchQuery( + getGetUsersIdQueryOptions(`${session?.user?.id}`, { populate: "role" }), + ); + } + + const dehydratedState = dehydrate(queryClient); + return ( - - - - {children} - -
- -
- - - - + + + + + {children} + +
+ +
+ + + + + +
); } diff --git a/client/src/app/parsers.ts b/client/src/app/parsers.ts index 2b64eb0a..3503b6fb 100644 --- a/client/src/app/parsers.ts +++ b/client/src/app/parsers.ts @@ -28,3 +28,4 @@ export const pillarsParser = parseAsArrayOf(parseAsInteger).withDefault([]); export const availableForFundingParser = parseAsBoolean.withDefault(false); export const countriesParser = parseAsArrayOf(parseAsString).withDefault([]); export const publicationStateParser = parseAsString.withDefault("live"); +export const datasetStepParser = parseAsInteger.withDefault(1); diff --git a/client/src/app/store.ts b/client/src/app/store.ts index 19061178..86d01d43 100644 --- a/client/src/app/store.ts +++ b/client/src/app/store.ts @@ -14,8 +14,23 @@ import { pillarsParser, publicationStateParser, projectParser, + datasetStepParser, } from "@/app/parsers"; +import { Data, DatasetValuesCSV } from "@/components/forms/dataset/types"; + +export const INITIAL_DATASET_VALUES: Data = { + settings: { + name: "", + value_type: undefined, + category: undefined, + unit: "", + description: "", + }, + data: {}, + colors: {}, +}; + export const useSyncDatasets = () => { return useQueryState("datasets", datasetsParser); }; @@ -64,6 +79,14 @@ export const useSyncPublicationState = () => { return useQueryState("publicationState", publicationStateParser); }; +export const useSyncOtherToolsSearch = () => { + return useQueryState("other-tools-search", { defaultValue: "" }); +}; + +export const useSyncDatasetStep = () => { + return useQueryState("step", datasetStepParser); +}; + export const useSyncSearchParams = () => { const [datasets] = useSyncDatasets(); const [layers] = useSyncLayers(); @@ -77,7 +100,6 @@ export const useSyncSearchParams = () => { const [countries] = useSyncCountries(); const [availableForFunding] = useSyncAvailableForFunding(); const [publicationState] = useSyncPublicationState(); - const sp = new URLSearchParams(); // Datatsets @@ -126,3 +148,13 @@ export const layersInteractiveIdsAtom = atom<(number | string)[]>([]); export const otherToolsSearchAtom = atom(undefined); export const collaboratorsSearchAtom = atom(undefined); + +export const personalDetailsAtom = atom<"account" | "changes">("changes"); + +export const datasetStepAtom = atom(1); + +export const datasetValuesAtom = atom(INITIAL_DATASET_VALUES); + +export const datasetValuesNewAtom = atom(INITIAL_DATASET_VALUES); + +export const datasetValuesJsonUploadedAtom = atom([]); diff --git a/client/src/components/forms/dataset/colors.tsx b/client/src/components/forms/dataset/colors.tsx new file mode 100644 index 00000000..4f58792c --- /dev/null +++ b/client/src/components/forms/dataset/colors.tsx @@ -0,0 +1,468 @@ +"use client"; + +import { useCallback, useMemo } from "react"; + +import { useForm } from "react-hook-form"; +import { toast } from "react-toastify"; + +import { useParams, useRouter } from "next/navigation"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQueryClient } from "@tanstack/react-query"; +import { uniq, compact } from "lodash-es"; +import { useSession } from "next-auth/react"; +import { z } from "zod"; + +import { cn } from "@/lib/classnames"; + +import chroma from "chroma-js"; + +import { + getGetDatasetEditSuggestionsIdQueryKey, + useDeleteDatasetEditSuggestionsId, + useGetDatasetEditSuggestionsId, +} from "@/types/generated/dataset-edit-suggestion"; +import { usePutDatasetEditSuggestionsId } from "@/types/generated/dataset-edit-suggestion"; +import type { UsersPermissionsRole, UsersPermissionsUser } from "@/types/generated/strapi.schemas"; +import { useGetUsersId } from "@/types/generated/users-permissions-users-roles"; + +import { useSyncSearchParams, INITIAL_DATASET_VALUES } from "@/app/store"; + +import DashboardFormControls from "@/components/new-dataset/form-controls"; +import NewDatasetNavigation from "@/components/new-dataset/form-navigation"; +import StepDescription from "@/components/new-dataset/step-description"; +import ColorPicker from "@/components/ui/colorpicker"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; + +import { DEFAULT_COLORS } from "@/components/forms/dataset/constants"; +import type { Data, VALUE_TYPE } from "./types"; +import DashboardFormWrapper from "./wrapper"; +import { useDeleteDatasetsId } from "@/types/generated/dataset"; + +const hexColorRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/; + +const getDefaultFormSchema = () => + z.object({ + min: z + .string() + .min(1, { message: "Please enter a valid hex color for min" }) + .regex(hexColorRegex, { message: "Please enter a valid hex color for min" }) + .optional(), + + max: z + .string() + .min(1, { message: "Please enter a valid hex color for min" }) + .regex(hexColorRegex, { message: "Please enter a valid hex color for max" }) + .optional(), + }); + +const getBooleanFormSchema = () => + z.object({ + yes: z + .string() + .min(1, { message: "Please enter a valid hex color for YES" }) + .regex(hexColorRegex, { message: "Please enter a valid hex color for YES" }) + .optional(), + no: z + .string() + .min(1, { message: "Please enter a valid hex color for NO" }) + .regex(hexColorRegex, { message: "Please enter a valid hex color for NO" }) + .optional(), + }); + +const getTextFormSchema = (categories: string[] | null): z.ZodObject => { + if (!categories) return z.object({}); + + const schemaShape = categories.reduce( + (acc: Record>, category) => { + acc[category] = z + .string() + .min(1, { message: "Please choose a color" }) + .regex(hexColorRegex, { message: "Please enter a valid hex color" }) + .optional(); + return acc; + }, + {}, + ); + + return z.object(schemaShape); +}; + +const getFormSchema = ({ + categories, + value_type, +}: { + categories: string[] | null; + value_type?: VALUE_TYPE; +}) => { + if (value_type === "text") { + return getTextFormSchema(categories); + } + + if (value_type === "boolean") { + return getBooleanFormSchema(); + } + + return getDefaultFormSchema(); +}; + +const getCategories = ({ + data, + value_type, +}: { + data: Data["data"]; + value_type?: VALUE_TYPE; +}): string[] | null => { + if (!value_type || !data) return null; + + if (value_type === "text") { + return compact(uniq(Object.values(data).map((value) => value as string))); + } + + return ["min", "max"]; +}; + +export default function DatasetColorsForm({ + title, + id, + header = true, + data: rawData, + onSubmit, + changes, + status, + message, +}: { + title: string; + id: string; + header?: boolean; + data: Data; + onSubmit: (data: Data["colors"]) => void; + changes?: string[]; + status?: "approved" | "pending" | "declined" | undefined; + message?: string; +}) { + const data = rawData.data; + const colors = rawData.colors; + const { push } = useRouter(); + const queryClient = useQueryClient(); + const URLParams = useSyncSearchParams(); + + const params = useParams(); + const { id: datasetId } = params; + + const { data: datasetDataPendingToApprove } = useGetDatasetEditSuggestionsId(Number(datasetId), { + populate: "*", + }); + + const { data: session } = useSession(); + const user = session?.user; + + const { data: meData } = useGetUsersId(`${user?.id}`, { + populate: "role", + }); + + const previousData = (datasetDataPendingToApprove?.data?.attributes?.colors || + colors) as Data["colors"]; + const ME_DATA = meData as UsersPermissionsUser & { role: UsersPermissionsRole }; + + const value_type = rawData?.settings?.value_type; + + const categories = getCategories({ data, value_type }); + + const { mutate: mutatePutDatasetEditSuggestion } = usePutDatasetEditSuggestionsId({ + mutation: { + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: getGetDatasetEditSuggestionsIdQueryKey(Number(id)), + }); + console.info("Success updating dataset:", data); + toast.success("Dataset updating dataset suggestion"); + if ( + data?.data?.attributes?.review_status === "declined" || + data?.data?.attributes?.review_status === "pending" || + ME_DATA?.role?.type === "authenticated" + ) { + push(`/dataset/${id}`); + } + push(`/dashboard`); + }, + onError: (error) => { + toast.error("Error updating dataset suggestion"); + console.error("Error updating dataset:", error); + }, + }, + request: {}, + }); + + const { mutate: mutateDeleteDatasetsId } = useDeleteDatasetsId({ + mutation: { + onSuccess: (data) => { + console.info("Success deleting dataset:", data); + toast.success("Dataset deleted"); + push(`/dashboard`); + }, + onError: (error) => { + toast.error("Error deleting dataset"); + console.error("Error deleting dataset:", error); + }, + }, + }); + + const { mutate: mutateDeleteDatasetEditSuggestionsId } = useDeleteDatasetEditSuggestionsId({ + mutation: { + onSuccess: (data) => { + console.info("Success deleting suggested dataset:", data); + toast.success("Success deleting suggested dataset"); + push(`/dashboard`); + }, + onError: (error) => { + toast.error("Error deleting suggested dataset"); + console.error("Error deleting suggested dataset :", error); + }, + }, + }); + + const values = useMemo(() => { + if (value_type === "text") { + const colorsLength = categories?.length; + const defaultColors = chroma + .scale([DEFAULT_COLORS.min, DEFAULT_COLORS.max]) + .colors(colorsLength); + return categories!.reduce( + (acc, category, i) => { + return { + ...acc, + [category]: previousData?.[category] || defaultColors[i], + }; + }, + {} as Record, + ); + } + if (value_type === "boolean") { + return { + yes: previousData?.yes || DEFAULT_COLORS.max, + no: previousData.no || DEFAULT_COLORS.min, + }; + } + return { + min: previousData?.min || DEFAULT_COLORS.min, + max: previousData?.max || DEFAULT_COLORS.max, + }; + }, [previousData, categories, value_type]); + + const formSchema = getFormSchema({ categories, value_type }); + const form = useForm>({ + resolver: zodResolver(formSchema), + values, + }); + + const handleCancel = () => { + onSubmit(INITIAL_DATASET_VALUES.colors); + push(`/?${URLParams.toString()}`); + }; + + const handleDelete = useCallback(() => { + mutateDeleteDatasetsId({ id: +datasetId }); + }, [mutateDeleteDatasetsId, datasetId]); + + const handleSubmit = useCallback( + (values: z.infer) => { + onSubmit(values); + }, + [onSubmit], + ); + const handleReject = ({ message }: { message?: string }) => { + if (ME_DATA?.role?.type === "admin" && datasetDataPendingToApprove?.data?.id) { + mutatePutDatasetEditSuggestion({ + id: datasetDataPendingToApprove?.data?.id, + data: { + data: { + review_status: "declined", + review_decision_details: message, + }, + }, + }); + } + }; + if (!value_type) return null; + + return ( + <> + {header && ( + + )} + + {header && } + {header && } + +
+ +
+ {/* {value_type === "number" && } */} + {value_type === "text" && + categories?.map((category) => ( + ( + + {category} + + { + return field.onChange(e.target.value); + }} + disabled={status === "declined" && ME_DATA?.role?.type !== "admin"} + /> + + + + )} + /> + ))} + + {(value_type === "number" || value_type === "resource") && ( + <> + ( + + Min value + + { + return field.onChange(e.target.value); + }} + disabled={status === "declined" && ME_DATA?.role?.type !== "admin"} + /> + + + + )} + /> + + ( + + Max value + + { + return field.onChange(e.target.value); + }} + disabled={status === "declined" && ME_DATA?.role?.type !== "admin"} + /> + + + + )} + /> + + )} + + {value_type === "boolean" && ( + <> + ( + + YES value + + { + return field.onChange(e.target.value); + }} + disabled={status === "declined" && ME_DATA?.role?.type !== "admin"} + /> + + + + )} + /> + + ( + + NO value + + { + return field.onChange(e.target.value); + }} + disabled={status === "declined" && ME_DATA?.role?.type !== "admin"} + /> + + + + )} + /> + + )} +
+
+ +
+ + ); +} diff --git a/client/src/components/forms/dataset/constants.ts b/client/src/components/forms/dataset/constants.ts new file mode 100644 index 00000000..d2633499 --- /dev/null +++ b/client/src/components/forms/dataset/constants.ts @@ -0,0 +1,130 @@ +import exp from "constants"; +import type { DATA_COLUMN, VALUE_TYPE } from "./types"; + +// Form DATA (step 2) +export const DATA_COLUMNS_TYPE: Record = { + number: [ + { + value_type: "number", + label: "Country id", + value: "country_id", + }, + { + value_type: "number", + label: "Number", + value: "number", + }, + ], + text: [ + { + value_type: "text", + label: "Country id", + value: "country_id", + }, + { + value_type: "text", + label: "Text", + value: "text", + }, + ], + boolean: [ + { + value_type: "boolean", + label: "Country id", + value: "country_id", + }, + { + value_type: "boolean", + label: "Boolean", + value: "boolean", + }, + ], + resource: [ + { + value_type: "resource", + label: "Country id", + value: "country_id", + }, + { + value_type: "resource", + label: "Resource", + value: "resource", + }, + ], +}; + +// FORM COLORS (step 3) + +export const COLORS_FIELDS_NUMBER = [ + { + value_type: "number", + label: "Min value", + value: "min_value", + }, + { + value_type: "number", + label: "Max value", + value: "max_value", + }, +]; + +export const COLORS_FIELDS_TEXT = [ + { + value_type: "text", + label: "Country id", + value: "country_id", + }, + { + value_type: "text", + label: "Text", + value: "text", + }, +]; + +export const COLORS_FIELDS_BOOLEAN = [ + { + value_type: "boolean", + label: "TRUE value", + value: "true_value", + }, + { + value_type: "boolean", + label: "FALSE value", + value: "false_value", + }, +]; + +export const COLORS_FIELDS_RESOURCE = [ + { + value_type: "resource", + label: "Country id", + value: "country_id", + }, + { + value_type: "resource", + label: "Title", + value: "link_title", + }, + { + value_type: "resource", + label: "Description", + value: "description", + }, + { + value_type: "resource", + label: "Link", + value: "link_url", + }, +]; + +export const VALUE_TYPE_DICTIONARY = { + text: "Text", + number: "Number", + boolean: "True / False", + resource: "Resource / Link", +}; + +export const DEFAULT_COLORS = { + max: "#84DAF4", + min: "#999", +}; diff --git a/client/src/components/forms/dataset/data-form-schema.tsx b/client/src/components/forms/dataset/data-form-schema.tsx new file mode 100644 index 00000000..7a05ed86 --- /dev/null +++ b/client/src/components/forms/dataset/data-form-schema.tsx @@ -0,0 +1,60 @@ +import { z } from "zod"; + +import type { VALUE_TYPE } from "./types"; + +export const getFormSchema = (value_type: VALUE_TYPE, countries: string[]) => { + if (value_type === "number") { + return z.object( + countries.reduce( + (acc, country) => { + acc[`${country}`] = z.coerce.number().optional(); + return acc; + }, + {} as Record>, + ), + ); + } + + if (value_type === "resource") { + return z.object( + countries.reduce((acc, country) => { + acc[`${country}`] = z + .array( + z.object({ + link_title: z.string().min(1, { message: "Please enter a title" }), + link_url: z.string().url({ message: "Please enter a valid URL" }), + description: z.string().min(1, { message: "Please enter a description" }), + }), + ) + .optional(); + return acc; + }, {} as z.ZodRawShape), + ); + } + + if (value_type === "text") { + return z.object( + countries.reduce( + (acc, country) => { + acc[`${country}`] = z.string().optional(); + return acc; + }, + {} as Record>, + ), + ); + } + + if (value_type === "boolean") { + return z.object( + countries.reduce( + (acc, country) => { + acc[`${country}`] = z.boolean().optional(); + return acc; + }, + {} as Record>, + ), + ); + } + + return z.object({}); +}; diff --git a/client/src/components/forms/dataset/data.tsx b/client/src/components/forms/dataset/data.tsx new file mode 100644 index 00000000..b00c0532 --- /dev/null +++ b/client/src/components/forms/dataset/data.tsx @@ -0,0 +1,642 @@ +"use client"; + +import { useCallback, useMemo } from "react"; + +import { toast } from "react-toastify"; +import { useForm } from "react-hook-form"; + +import { useRouter, useParams } from "next/navigation"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useAtom } from "jotai"; +import { useSession } from "next-auth/react"; +import { LuTrash2 } from "react-icons/lu"; +import { z } from "zod"; + +import { cn } from "@/lib/classnames"; +import { isEmpty } from "@/lib/utils/objects"; + +import { useGetCountries } from "@/types/generated/country"; +import { useGetDatasetValues } from "@/types/generated/dataset-value"; +import { + CountryListResponseDataItem, + Resource, + UsersPermissionsUser, + UsersPermissionsRole, +} from "@/types/generated/strapi.schemas"; +import { useGetUsersId } from "@/types/generated/users-permissions-users-roles"; + +import { + useSyncSearchParams, + datasetValuesJsonUploadedAtom, + INITIAL_DATASET_VALUES, +} from "@/app/store"; + +import { GET_COUNTRIES_OPTIONS } from "@/constants/countries"; + +import DashboardFormControls from "@/components/new-dataset/form-controls"; +import NewDatasetNavigation from "@/components/new-dataset/form-navigation"; +import StepDescription from "@/components/new-dataset/step-description"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormMessageArray, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +import { DATA_COLUMNS_TYPE } from "./constants"; +import { getFormSchema } from "./data-form-schema"; +import type { VALUE_TYPE, Data, DatasetValuesCSV } from "./types"; +import DashboardFormWrapper from "./wrapper"; +import { + useDeleteDatasetEditSuggestionsId, + useGetDatasetEditSuggestionsId, +} from "@/types/generated/dataset-edit-suggestion"; +import { useDeleteDatasetsId } from "@/types/generated/dataset"; + +// Types for deep nested objects function +type ParamType = "link_url" | "description" | "link_title"; + +interface Field { + name: string; +} + +export interface Change { + [key: string]: { attr: ParamType; index: number }[]; +} + +export default function DatasetDataForm({ + title, + id, + header = true, + data: rawData, + onSubmit, + changes, + status, +}: { + title: string; + id: string; + header?: boolean; + data: Data; + onSubmit: (data: Data["data"]) => void; + changes?: (Change | string)[]; + status?: "approved" | "pending" | "declined" | undefined; +}) { + const [datasetValues] = useAtom(datasetValuesJsonUploadedAtom); + const data = rawData.data; + const { push } = useRouter(); + + const URLParams = useSyncSearchParams(); + const params = useParams(); + const { id: datasetId } = params; + const editedSuggestionData = useGetDatasetEditSuggestionsId(+datasetId); + + const { data: session } = useSession(); + const user = session?.user; + const { data: meData } = useGetUsersId(`${user?.id}`, { + populate: "role", + }); + const ME_DATA = meData as UsersPermissionsUser & { role: UsersPermissionsRole }; + + const isDatasetNew = isEmpty(data); + + const { data: datasetValuesData } = useGetDatasetValues( + { + filters: { + dataset: datasetId, + }, + "pagination[pageSize]": 300, + populate: { + country: { + fields: ["name", "iso3"], + }, + resources: true, + dataset_values: true, + }, + }, + { + query: { + enabled: !isDatasetNew, + }, + }, + ); + + const parsedPreviousDatasetValuesResources = useMemo<{ + [key: string]: Resource[]; + }>(() => { + const transformedObject: { [key: string]: Resource[] } = {}; + datasetValuesData?.data?.forEach(({ attributes }) => { + const countryCode = attributes?.country?.data?.attributes?.iso3; + if (countryCode) { + transformedObject[countryCode] = + attributes?.resources?.data?.map((d) => ({ + description: d?.attributes?.description, + link_title: d?.attributes?.link_title, + link_url: d?.attributes?.link_url, + })) || []; + } + }); + + return transformedObject; + }, [datasetValuesData]); + + const { data: countriesData } = useGetCountries(GET_COUNTRIES_OPTIONS); + + const countries = useMemo( + () => countriesData?.data?.map((country) => country) || [], + [countriesData], + ); + + const formSchema = useMemo( + () => + getFormSchema( + rawData.settings.value_type as VALUE_TYPE, + countries.map((c) => c?.attributes?.iso3) as string[], + ), + [rawData.settings.value_type, countries], + ); + + function transformData(data: DatasetValuesCSV[]): { + [key: string]: number | string | Resource[] | boolean | undefined; + } { + const result: { [key: string]: number | string | Resource[] | boolean | undefined } = {}; + const type = rawData.settings.value_type as unknown as keyof DatasetValuesCSV; + + if (!!rawData.settings.value_type && rawData.settings.value_type !== "resource") { + data?.forEach((item) => { + result[item.country_id] = item[type]; + }); + } else if (rawData.settings.value_type === "resource") { + data?.forEach((item) => { + const resource: Resource = { + link_title: item.link_title!, + link_url: item.link_url!, + description: item.description!, + }; + + if (!result[item.country_id]) { + result[item.country_id] = []; + } + + (result[item.country_id] as Resource[]).push(resource); + }); + } + + return result; + } + + const parsedDatasetCSVValues = transformData(datasetValues); + + const values = useMemo(() => { + // Check if parsedDatasetCSVValues is an empty object + const isParsedDatasetCSVValuesEmpty = Object.keys(parsedDatasetCSVValues || {}).length === 0; + if (rawData.settings.value_type === "resource" && isParsedDatasetCSVValuesEmpty) { + return rawData.data; + } + + if (editedSuggestionData?.data?.data?.attributes?.data) + return editedSuggestionData?.data?.data?.attributes?.data as Data["data"]; + + const c = countries + .map((c) => c?.attributes?.iso3 as string) + .reduce( + (acc, country) => { + return { + ...acc, + [`${country}`]: isParsedDatasetCSVValuesEmpty + ? data?.[country] + : parsedDatasetCSVValues?.[country] || + data?.[country] || + parsedPreviousDatasetValuesResources?.[`${country}`], + }; + }, + {} as Data["data"], + ); + + return c; + }, [countries, data, parsedDatasetCSVValues, parsedPreviousDatasetValuesResources, rawData]); + + const form = useForm({ + resolver: zodResolver(formSchema), + values, + }); + + const { mutate: mutateDeleteDatasetsId } = useDeleteDatasetsId({ + mutation: { + onSuccess: (data) => { + console.info("Success deleting dataset:", data); + toast.success("Dataset deleted"); + push(`/dashboard`); + }, + onError: (error) => { + toast.error("Error deleting dataset"); + console.error("Error deleting dataset:", error); + }, + }, + }); + + const { mutate: mutateDeleteDatasetEditSuggestionsId } = useDeleteDatasetEditSuggestionsId({ + mutation: { + onSuccess: (data) => { + console.info("Success deleting suggested dataset:", data); + toast.success("Success deleting suggested dataset"); + push(`/dashboard`); + }, + onError: (error) => { + toast.error("Error deleting suggested dataset"); + console.error("Error deleting suggested dataset :", error); + }, + }, + }); + + const COLUMNS = DATA_COLUMNS_TYPE[rawData.settings.value_type as VALUE_TYPE]; + + const handleCancel = () => { + onSubmit(INITIAL_DATASET_VALUES.data); + push(`/?${URLParams.toString()}`); + }; + + const handleAddResource = (country: CountryListResponseDataItem) => { + let newValues: Resource[] = [ + { + link_title: "", + description: "", + link_url: "", + }, + ]; + + const values = form.getValues()[`${country.attributes?.iso3}`] as Resource[]; + + if (Array.isArray(values)) { + newValues = [ + ...values, + { + link_title: "", + description: "", + link_url: "", + }, + ]; + } + + form.setValue(`${country.attributes?.iso3}`, newValues); + }; + + const handleDeleteResource = (country: CountryListResponseDataItem, index: number) => { + let newValues: Resource[] = [ + { + link_title: "", + description: "", + link_url: "", + }, + ]; + + const values = form.getValues()[`${country.attributes?.iso3}`] as Resource[]; + + if (Array.isArray(values)) { + newValues = values.filter((_, i) => i !== index); + } + + form.setValue(`${country.attributes?.iso3}`, newValues); + }; + + const handleSubmit = useCallback( + (values: z.infer) => { + // Save this into useState + onSubmit(values); + }, + [onSubmit], + ); + + const handleDelete = useCallback(() => { + mutateDeleteDatasetsId({ id: +datasetId }); + }, [mutateDeleteDatasetsId, datasetId]); + + const checkChanges = useCallback( + (field: Field, index: number, param: ParamType) => { + return changes?.find( + (c) => + Object.keys(c)?.[0] === field.name && + Object.values(c)?.[0]?.find( + (v: { attr: string; index: number }) => v.attr === param && v.index === index, + ), + ); + }, + [changes], + ); + + if (!rawData.settings.value_type) return null; + + return ( + <> + {header && ( + + )} + + {header && } + {header && } +
+ + + + + {COLUMNS.map(({ value, label }) => ( + {label} + ))} + + + + {countries.map((country) => ( + + {COLUMNS.map((c) => { + const { label, value } = c; + + if (value === "country_id") { + return ( + + {country?.attributes?.name} + + ); + } + + if (value === "resource") { + return ( + + ( + + {`${country.attributes?.iso3}-${label}`} + +
+ {value === "resource" && + Array.isArray(field?.value) && + field?.value?.map((resource, index) => ( +
+
+
+ + { + let newValues: Resource[] = [ + { + link_title: "", + description: "", + link_url: "", + }, + ]; + + if (Array.isArray(field?.value)) { + newValues = field?.value?.map((r, i) => { + if (i === index) { + return { + ...r, + link_title: e.target.value, + }; + } + return r; + }); + } + field.onChange(newValues); + }} + className={cn({ + "h-9 border-none bg-gray-300/20 placeholder:text-gray-300/95": + true, + "bg-green-400 placeholder:text-gray-400": + checkChanges(field, index, "link_title"), + })} + disabled={ + status === "declined" && + ME_DATA?.role?.type !== "admin" + } + /> +
+
+ + { + let newValues: Resource[] = [ + { + link_title: "", + description: "", + link_url: "", + }, + ]; + + if (Array.isArray(field?.value)) { + newValues = field?.value?.map((r, i) => { + if (i === index) { + return { + ...r, + description: e.target.value, + }; + } + return r; + }); + } + field.onChange(newValues); + }} + className={cn({ + "h-9 border-none bg-gray-300/20 placeholder:text-gray-300/95": + true, + "bg-green-400 placeholder:text-gray-400": + checkChanges(field, index, "link_title"), + })} + disabled={ + status === "declined" && + ME_DATA?.role?.type !== "admin" + } + /> +
+
+ + { + let newValues: Resource[] = [ + { + link_title: "", + description: "", + link_url: "", + }, + ]; + + if (Array.isArray(field?.value)) { + newValues = field?.value?.map((r, i) => { + if (i === index) { + return { + ...r, + link_url: e.target.value, + }; + } + return r; + }); + } + field.onChange(newValues); + }} + className={cn({ + "h-9 border-none bg-gray-300/20 placeholder:text-gray-300/95": + true, + "bg-green-400 placeholder:text-gray-400": + checkChanges(field, index, "link_title"), + })} + disabled={ + status === "declined" && + ME_DATA?.role?.type !== "admin" + } + /> +
+ + +
+ +
+ ))} +
+
+
+ )} + /> + + +
+ ); + } + + return ( + + { + return ( + + {`${country.attributes?.iso3}-${label}`} + + <> + {value === "text" && ( + + )} + + {value === "number" && ( + + )} + + {value === "boolean" && ( + field.onChange(bool)} + /> + )} + + + + + ); + }} + /> + + ); + })} +
+ ))} +
+
+
+ +
+ + ); +} diff --git a/client/src/components/forms/dataset/settings.tsx b/client/src/components/forms/dataset/settings.tsx new file mode 100644 index 00000000..d90d1c90 --- /dev/null +++ b/client/src/components/forms/dataset/settings.tsx @@ -0,0 +1,379 @@ +"use client"; + +import { useCallback } from "react"; + +import { toast } from "react-toastify"; +import { useForm } from "react-hook-form"; + +import { useRouter, useParams } from "next/navigation"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useSetAtom } from "jotai"; +import { useSession } from "next-auth/react"; +import { z } from "zod"; + +import { cn } from "@/lib/classnames"; +import { isEmpty } from "@/lib/utils/objects"; + +import { useGetCategories } from "@/types/generated/category"; +import { + CategoryResponse, + UsersPermissionsRole, + UsersPermissionsUser, +} from "@/types/generated/strapi.schemas"; +import { useGetUsersId } from "@/types/generated/users-permissions-users-roles"; + +import { datasetStepAtom, useSyncSearchParams, INITIAL_DATASET_VALUES } from "@/app/store"; + +import { GET_CATEGORIES_OPTIONS } from "@/constants/datasets"; + +import { Data, VALUE_TYPE } from "@/components/forms/dataset/types"; +import DashboardFormControls from "@/components/new-dataset/form-controls"; +import NewDatasetNavigation from "@/components/new-dataset/form-navigation"; +import StepDescription from "@/components/new-dataset/step-description"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import MarkdownEditor from "@/components/ui/markdown-editor"; +import { + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectValue, +} from "@/components/ui/select"; + +import { VALUE_TYPE_DICTIONARY } from "./constants"; +import DashboardFormWrapper from "./wrapper"; +import { useDeleteDatasetsId } from "@/types/generated/dataset"; +import { useDeleteDatasetEditSuggestionsId } from "@/types/generated/dataset-edit-suggestion"; + +export default function DatasetSettingsForm({ + title, + id, + header = true, + data: rawData, + changes, + onSubmit, + status, +}: { + title: string; + id: string; + header?: boolean; + data: Data; + changes?: string[]; + onSubmit: (data: Data["settings"]) => void; + status?: "approved" | "pending" | "declined" | undefined; +}) { + const setStep = useSetAtom(datasetStepAtom); + const data = rawData.settings; + const { push } = useRouter(); + const URLParams = useSyncSearchParams(); + const { data: session } = useSession(); + const user = session?.user; + const { data: meData } = useGetUsersId(`${user?.id}`, { + populate: "role", + }); + const ME_DATA = meData as UsersPermissionsUser & { role: UsersPermissionsRole }; + + const isDatasetNew = isEmpty(data); + + const params = useParams(); + const { id: datasetId } = params; + + const { data: categoriesData } = useGetCategories(GET_CATEGORIES_OPTIONS(), { + query: { + select: (data) => + data?.data?.map((data) => ({ + label: data.attributes?.name as string, + value: data.id as number, + })), + }, + }); + + const { mutate: mutateDeleteDatasetsId } = useDeleteDatasetsId({ + mutation: { + onSuccess: (data) => { + console.info("Success deleting dataset:", data); + toast.success("Dataset deleted"); + push(`/dashboard`); + }, + onError: (error) => { + toast.error("Error deleting dataset"); + console.error("Error deleting dataset:", error); + }, + }, + }); + + const { mutate: mutateDeleteDatasetEditSuggestionsId } = useDeleteDatasetEditSuggestionsId({ + mutation: { + onSuccess: (data) => { + console.info("Success deleting suggested dataset:", data); + toast.success("Success deleting suggested dataset"); + push(`/dashboard`); + }, + onError: (error) => { + toast.error("Error deleting suggested dataset"); + console.error("Error deleting suggested dataset :", error); + }, + }, + }); + + const value_types = ["text", "number", "boolean", "resource"] as const; + const valueTypesOptions = value_types.map((type) => ({ + label: VALUE_TYPE_DICTIONARY[type], + value: type, + })); + + const formSchema = z.object({ + name: z.string().min(1, { message: "Please enter dataset name" }), + value_type: z + .enum(value_types) + .optional() + .refine((val) => !!val, { + message: "Please select a value type", + }), + category: z + .number() + .optional() + .refine((val) => typeof val !== "undefined", { + message: "Please select a category", + }), + unit: z + .string() + .refine((val) => val === "" || (val && typeof val === "string"), { + message: "Please enter a valid unit", + }) + .optional(), + description: z.string().min(6, { + message: "Please enter a description with at least 6 characters", + }), + }); + + // Type Guard to Check if Category is CategoryResponse + const isCategoryResponse = ( + category: number | CategoryResponse, + ): category is CategoryResponse => { + return (category as CategoryResponse).data !== undefined; + }; + + const form = useForm>({ + resolver: zodResolver(formSchema), + + values: { + name: data.name, + value_type: data.value_type as VALUE_TYPE, + + category: + data?.category && isCategoryResponse(data?.category) + ? (data?.category?.data?.id as number) + : (data.category as number), + + unit: data.unit, + description: data.description, + }, + }); + + const handleCancel = () => { + onSubmit(INITIAL_DATASET_VALUES.settings); + setStep(1); + push(`/?${URLParams.toString()}`); + }; + + const handleSubmit = useCallback( + (values: z.infer) => { + // Save this into useState + const categoryId = + typeof data.category === "number" ? data.category : data?.category?.data?.id; + onSubmit({ + ...values, + category: categoryId || values.category, + }); + }, + [onSubmit, data], + ); + + const handleDelete = useCallback(() => { + mutateDeleteDatasetsId({ id: +datasetId }); + }, [mutateDeleteDatasetsId, datasetId]); + + return ( + <> + {header && ( + + )} + + {header && } + {header && } + +
+ +
+ ( + + + Name* + + + + + + + )} + /> + ( + + + Type of value* + + + + + + + )} + /> + ( + + + Category* + + + + + + + )} + /> + ( + + + Unit + (optional) + + + <> + +

This will appear in the legend (e.g. dollars)

+ +
+ +
+ )} + /> + ( + + + Description* + + + + + + + )} + /> +
+ +
+ +
+ + ); +} diff --git a/client/src/components/forms/dataset/types.ts b/client/src/components/forms/dataset/types.ts new file mode 100644 index 00000000..4dff0968 --- /dev/null +++ b/client/src/components/forms/dataset/types.ts @@ -0,0 +1,102 @@ +import type { + CategoryResponse, + Dataset, + Resource, + DatasetEditSuggestion, + ToolEditSuggestion, + ProjectEditSuggestion, + CollaboratorEditSuggestion, +} from "@/types/generated/strapi.schemas"; + +export type VALUE_TYPE = Dataset["value_type"]; + +export interface DatasetValuesCSV { + country_id: string; + number?: number; + boolean?: boolean; + text?: string; + link_title?: string; + link_url?: string; + description?: string; +} + +export interface Data { + settings: { + name: string; + description: string; + value_type?: Dataset["value_type"]; + category?: number | CategoryResponse; + unit?: string; + updatedAt?: string; + review_status?: "approved" | "pending" | "declined"; + review_decision_details?: string; + }; + data: { + [key: string]: string | number | boolean | undefined | Resource[]; + }; + colors: Record; +} + +interface NumberDataColumn { + value_type: "number"; + label: string; + value: "country_id" | "number"; +} + +interface TextDataColumn { + value_type: "text"; + label: string; + value: "country_id" | "text"; +} + +interface BooleanDataColumn { + value_type: "boolean"; + label: string; + value: "country_id" | "boolean"; +} + +interface ResourceDataColumn { + value_type: "resource"; + label: string; + value: "country_id" | "resource"; +} + +export type DATA_COLUMN = + | NumberDataColumn + | TextDataColumn + | BooleanDataColumn + | ResourceDataColumn; + +export type Label = "Datasets" | "Tool" | "Collaborator" | "Project"; +export type Route = "datasets/changes-to-approve" | "other-tools" | "collaborators" | "projects"; + +export interface extendedDataset extends DatasetEditSuggestion { + id?: number; + label: Label; + route: Route; +} + +export interface extendedCollaboratorData extends CollaboratorEditSuggestion { + id?: number; + label: Label; + route: Route; +} + +export interface extendedProjectData extends ProjectEditSuggestion { + id?: number; + label: Label; + route: Route; +} + +export interface extendedToolData extends ToolEditSuggestion { + id?: number; + label: Label; + route: Route; +} + +export type DataTypes = ( + | extendedDataset + | extendedToolData + | extendedCollaboratorData + | extendedProjectData +)[]; diff --git a/client/src/components/forms/dataset/wrapper.tsx b/client/src/components/forms/dataset/wrapper.tsx new file mode 100644 index 00000000..92112b05 --- /dev/null +++ b/client/src/components/forms/dataset/wrapper.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { cn } from "@/lib/classnames"; + +export default function DashboardFormWrapper({ + children, + header = true, + className, +}: { + children: React.ReactNode; + header?: boolean; + className?: string; +}) { + return ( +
+
{children}
+
+ ); +} diff --git a/client/src/components/forms/password-update.tsx b/client/src/components/forms/password-update.tsx new file mode 100644 index 00000000..8a440c40 --- /dev/null +++ b/client/src/components/forms/password-update.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { useState } from "react"; + +import { useForm } from "react-hook-form"; +import { toast } from "react-toastify"; + +import { useSearchParams, useRouter } from "next/navigation"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import isEmpty from "lodash/isEmpty"; +import { LuEye, LuEyeOff } from "react-icons/lu"; +import { z } from "zod"; +import { signIn } from "next-auth/react"; +import { usePostAuthResetPassword } from "@/types/generated/users-permissions-auth"; + +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +export type FormPasswordFieldsProps = { + label: string; + name: "newPassword" | "passwordConfirmation"; + placeholder: string; +}[]; + +export const FORM_PASSWORD_FIELDS: FormPasswordFieldsProps = [ + { + label: "New password", + name: "newPassword", + placeholder: "Enter your new password", + }, + { + label: "Confirm new password", + name: "passwordConfirmation", + placeholder: "Confirm your new password", + }, +]; + +const formSchemaPassword = z + .object({ + newPassword: z + .string() + .nonempty("New password is required") + .refine((value) => /^[a-zA-Z0-9]*$/.test(value), { + message: "New password must be numeric, string, or alphanumeric", + }), + passwordConfirmation: z.string().nonempty("Password confirmation is required"), + }) + .refine((data) => data.newPassword === data.passwordConfirmation, { + message: "New password and confirmation must match", + path: ["passwordConfirmation"], + }); + +export default function UpdatePasswordForm() { + const [fieldsVisibility, setFieldsVisibility] = useState<{ + [key: string]: boolean; + }>({ + newPassword: false, + passwordConfirmation: false, + }); + + const params = useSearchParams(); + const recoveryCode = params.get("code") as string; + const { push } = useRouter(); + + const formPassword = useForm>({ + resolver: zodResolver(formSchemaPassword), + defaultValues: { + newPassword: "", + passwordConfirmation: "", + }, + }); + + const { mutate: updateUserPassword } = usePostAuthResetPassword({ + mutation: { + onSuccess: (data) => { + console.info("Password updated successfully"); + toast.success("Password updated successfully"); + + // TO - DO - test this properly + // if (data.jwt) { + // signIn("credentials", { + // redirect: false, + // jwt: data.jwt, + // user: data.user, + // }); + + // } + push(`/signin?code=${recoveryCode}`); + }, + onError: (error) => { + console.error("Error updating updating password:", error); + const response = error?.response?.data.error; + if (response?.status === 400 && !!response?.message) { + if (!isEmpty(response.details)) { + formPassword.setError("newPassword", { message: response?.message }); + } + } + toast.error("Error updating password"); + }, + }, + request: {}, + }); + + function onSubmitPassword(values: z.infer) { + updateUserPassword({ + data: { + code: recoveryCode, + password: values.newPassword, + passwordConfirmation: values.passwordConfirmation, + }, + }); + } + + return ( +
+
+ +
+
+ {FORM_PASSWORD_FIELDS.map(({ name, label, placeholder }) => ( + ( + + {label} + +
+ + + {!fieldsVisibility?.[name] && ( + + setFieldsVisibility({ + ...fieldsVisibility, + [name]: !fieldsVisibility?.[name], + }) + } + /> + )} + + {fieldsVisibility?.[name] && ( + + setFieldsVisibility({ + ...fieldsVisibility, + [name]: !fieldsVisibility?.[name], + }) + } + /> + )} +
+
+ +
+ )} + /> + ))} +
+
+ +
+ +
+ ); +} diff --git a/client/src/components/forms/personal-data.tsx b/client/src/components/forms/personal-data.tsx new file mode 100644 index 00000000..6a9a2050 --- /dev/null +++ b/client/src/components/forms/personal-data.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useForm } from "react-hook-form"; + +import { useRouter } from "next/navigation"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { usePostAuthForgotPassword } from "@/types/generated/users-permissions-auth"; + +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +const formSchema = z.object({ + email: z.string().email({ message: "Please enter your email address" }), + username: z.string().min(1, { message: "Please enter your name" }), + organization: z.string().min(1, { message: "Please enter your organization name" }), + password: z.string().nonempty({ message: "Please enter your password" }).min(6, { + message: "Please enter a password with at least 6 characters", + }), +}); + +export default function PersonalData() { + const { push } = useRouter(); + + // 1. Define your form. + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + username: "", + email: "", + organization: "", + password: "", + }, + }); + + const { mutate } = usePostAuthForgotPassword({ + mutation: { + onSuccess: () => { + const searchParams = new URLSearchParams(); + push(`/signin?${searchParams.toString()}`); + }, + onError: (error) => { + console.error(`Failed to send password reset email: ${error.message}`); + }, + }, + }); + + // 2. Define a submit handler. + function onSubmit(values: z.infer) { + mutate({ data: { email: values.email } }); + } + + return ( +
+
+ +
+ ( + + Name + + + + + + )} + /> + ( + + Email + + + + + + )} + /> + ( + + Organization name + + + + + + )} + /> + ( + + Password + + + + + + )} + /> +
+ +
+ +
+ ); +} diff --git a/client/src/components/forms/reset-password.tsx b/client/src/components/forms/reset-password.tsx new file mode 100644 index 00000000..fae5fbbe --- /dev/null +++ b/client/src/components/forms/reset-password.tsx @@ -0,0 +1,92 @@ +"use client"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { usePostAuthForgotPassword } from "@/types/generated/users-permissions-auth"; + +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +const formSchema = z.object({ + email: z.string().email({ message: "Please enter your email address" }), +}); + +export default function ResetPassword() { + const [inputEmailVisibility, setInputEmailVisibility] = useState(true); + + // 1. Define your form. + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: "", + }, + }); + + const { mutate } = usePostAuthForgotPassword({ + mutation: { + onSuccess: () => { + setInputEmailVisibility(false); + }, + onError: (error) => { + console.error("Error creating dataset:", error); + }, + }, + }); + + // 2. Define a submit handler. + function onSubmit(values: z.infer) { + mutate({ data: { email: values.email } }); + } + + return ( +
+ {inputEmailVisibility && ( +
+ +
+ ( + + Email + + + + + + )} + /> +
+ +
+ + )} + {!inputEmailVisibility && ( +

+ Please take a moment to check your email inbox. We’ve sent you a message with a secure + link to reset your password. Simply click on the link provided to proceed with updating + your account credentials. If you don’t see the email, be sure to check your spam or junk + folder, just in case it ended up there. +

+ )} +
+ ); +} diff --git a/client/src/components/forms/signin.tsx b/client/src/components/forms/signin.tsx new file mode 100644 index 00000000..8b25bb37 --- /dev/null +++ b/client/src/components/forms/signin.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useForm } from "react-hook-form"; + +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { signIn } from "next-auth/react"; +import { z } from "zod"; + +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +const formSchema = z.object({ + email: z.string().email({ message: "Please enter your email address" }), + password: z.string().min(1, { message: "Please enter your password" }), +}); + +export default function Signin() { + const searchParams = useSearchParams(); + + // 1. Define your form. + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: "", + password: "", + }, + }); + + // 2. Define a submit handler. + function onSubmit(values: z.infer) { + // Do something with the form values. + // ✅ This will be type-safe and validated. + signIn("credentials", { + email: values.email, + password: values.password, + callbackUrl: searchParams.get("callbackUrl") ?? "/", + }); + } + + return ( +
+
+ + {!!searchParams.get("error") && ( +
+ Invalid username or password. Please try again. +
+ )} +
+ ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> +
+ {/* {error &&

{error}

} */} + +
+ + Forgot your password? + + +
+ ); +} diff --git a/client/src/components/forms/signup.tsx b/client/src/components/forms/signup.tsx new file mode 100644 index 00000000..26fe3b1a --- /dev/null +++ b/client/src/components/forms/signup.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { useEffect } from "react"; + +import { useForm } from "react-hook-form"; + +import { useRouter, useSearchParams } from "next/navigation"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { + LoadCanvasTemplate, + validateCaptcha, + loadCaptchaEnginge, +} from "@vinhpd/react-simple-captcha"; +import { signIn } from "next-auth/react"; +import { z } from "zod"; + +import { usePostAuthLocalRegister } from "@/types/generated/users-permissions-auth"; + +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +const formSchema = z + .object({ + username: z.string().min(1, { message: "Please enter your name" }), + email: z.string().email({ message: "Please enter your email address" }), + organization: z.string().min(1, { message: "Please enter your organization name" }), + password: z.string().nonempty({ message: "Please enter your password" }).min(6, { + message: "Please enter a password with at least 6 characters", + }), + "confirm-password": z + .string() + .nonempty({ message: "Please enter your confirmed password" }) + .min(6, { message: "Please enter a password with at least 6 characters" }), + captcha: z.string().nonempty({ message: "Please enter the captcha" }), + }) + .superRefine((data, ctx) => { + if (data.password !== data["confirm-password"]) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Passwords do not match", + path: ["confirm-password"], // Path to the specific field + }); + } + }); + +interface ExtendedProps { + reloadColor?: string; +} + +const LoadCanvasTemplateComp: React.FC = (props) => { + return ; +}; + +export default function Signup() { + const { replace } = useRouter(); + const searchParams = useSearchParams(); + const signupMutation = usePostAuthLocalRegister(); + + // 1. Define your form. + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + username: "", + email: "", + organization: "", + password: "", + "confirm-password": "", + captcha: "", + }, + }); + + useEffect(() => { + loadCaptchaEnginge(6); + }, []); + + // 2. Define a submit handler. + function onSubmit(values: z.infer) { + if (!!validateCaptcha(values.captcha)) { + // ✅ This will be type-safe and validated. + // 3. Submit the form. + signupMutation.mutate( + { + data: values, + }, + { + onSuccess: () => { + signIn("credentials", { + email: values.email, + password: values.password, + callbackUrl: searchParams.get("callbackUrl") ?? "/", + }); + }, + onError: (error) => { + const searchParams = new URLSearchParams(); + searchParams.set("error", error?.response?.data?.error?.message ?? "Unknown error"); + replace(`/signup?${searchParams.toString()}`); + }, + }, + ); + } else { + form.setError("captcha", { message: "Captcha does not match" }); + } + } + + return ( +
+
+ + {!!searchParams.get("error") && ( +
+ {searchParams.get("error")} +
+ )} + +
+ ( + + Name + + + + + + )} + /> + ( + + Organization name + + + + + + )} + /> + ( + + Email + + + + + + )} + /> + + ( + + Password + + + + + + )} + /> + ( + + Confirm Password + + + + + + )} + /> + +
+
+ +
+ ( + + Type image values + + + + + + )} + /> +
+
+
+ +
+
+ +
+ ); +} diff --git a/client/src/components/map/controls/embed/content.tsx b/client/src/components/map/controls/embed/content.tsx new file mode 100644 index 00000000..5579c541 --- /dev/null +++ b/client/src/components/map/controls/embed/content.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useState } from "react"; + +import env from "@/env.mjs"; + +import { useSyncSearchParams } from "@/app/store"; + +import { Button } from "@/components/ui/button"; + +const EmbedContent = () => { + const searchParams = useSyncSearchParams(); + const [copiedUrl, setCopiedUrl] = useState(false); + const [copiedIframe, setCopiedIframe] = useState(false); + + const URL = process.env.NEXT_PUBLIC_VERCEL_URL + ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` + : env.NEXT_PUBLIC_URL; + + return ( +
+

Map embed

+ +
+

url

+
{`${URL}/embed?${searchParams.toString()}`}
+ +
+ +
+
+ +
+

iframe

+
{`