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 (
+
+
+
+
+
+
+
+ );
+}
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 (
+
+
+
+
+
+
+ );
+}
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 (
+
+
+
+
+
+
+ );
+}
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 && }
+
+
+
+
+ >
+ );
+}
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 && }
+
+
+
+ >
+ );
+}
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 && }
+
+
+
+
+ >
+ );
+}
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 (
+
+ );
+}
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 (
+
+ );
+}
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 (
+
+
+
+
+ );
+}
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 && (
+
+
+ )}
+ {!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 (
+
+
+
+ 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 (
+
+ );
+}
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()}`}
+
+
+ {
+ navigator.clipboard.writeText(`${URL}/embed?${searchParams.toString()}`);
+ setCopiedUrl(true);
+ setTimeout(() => setCopiedUrl(false), 3000);
+ }}
+ >
+ {copiedUrl ? "Copied!" : "Copy"}
+
+
+
+
+
+
iframe
+
{`
+ `}
+
+
+ {
+ navigator.clipboard.writeText(
+ ``,
+ );
+ setCopiedIframe(true);
+ setTimeout(() => setCopiedIframe(false), 3000);
+ }}
+ >
+ {copiedIframe ? "Copied!" : "Copy"}
+
+
+
+
+ );
+};
+
+export default EmbedContent;
diff --git a/client/src/components/map/controls/embed/index.tsx b/client/src/components/map/controls/embed/index.tsx
new file mode 100644
index 00000000..047ad6cb
--- /dev/null
+++ b/client/src/components/map/controls/embed/index.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import { FC, HTMLAttributes, PropsWithChildren } from "react";
+
+import { PopoverArrow } from "@radix-ui/react-popover";
+import { TooltipPortal } from "@radix-ui/react-tooltip";
+import { LuCode } from "react-icons/lu";
+
+import { cn } from "@/lib/classnames";
+
+import EmbedContent from "@/components/map/controls/embed/content";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { Tooltip, TooltipArrow, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+
+import { CONTROL_BUTTON_STYLES } from "../constants";
+
+interface EmbedControlProps {
+ className?: HTMLAttributes["className"];
+}
+
+export const EmbedControl: FC> = ({
+ className,
+}: PropsWithChildren) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Map Embed
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default EmbedControl;
diff --git a/client/src/components/map/layers/countries-layer/index.tsx b/client/src/components/map/layers/countries-layer/index.tsx
index 436629c2..65f74177 100644
--- a/client/src/components/map/layers/countries-layer/index.tsx
+++ b/client/src/components/map/layers/countries-layer/index.tsx
@@ -4,8 +4,8 @@ import { Source, Layer, GeoJSONSourceRaw } from "react-map-gl";
import { Feature } from "geojson";
-import { isDatasetValueProperty } from "@/lib/datasets";
import { parseConfig } from "@/lib/json-converter";
+import { isDatasetValueProperty } from "@/lib/utils/datasets";
import { getResourceParamConfig } from "@/lib/utils/layer-config";
import { useGetCountries } from "@/types/generated/country";
@@ -36,7 +36,13 @@ const CountriesLayer = ({
filters: {
dataset: layer?.dataset?.data?.id,
},
- populate: ["country", "resources"],
+ "pagination[pageSize]": 300,
+ populate: {
+ country: {
+ fields: ["name", "iso3"],
+ },
+ resources: true,
+ },
});
const config = useMemo(() => {
diff --git a/client/src/components/new-dataset/form-controls.tsx b/client/src/components/new-dataset/form-controls.tsx
new file mode 100644
index 00000000..fc4bc01b
--- /dev/null
+++ b/client/src/components/new-dataset/form-controls.tsx
@@ -0,0 +1,211 @@
+"use client";
+
+import { FC, useCallback } from "react";
+import { useForm } from "react-hook-form";
+
+import { useSession } from "next-auth/react";
+
+import { LuTrash2 } from "react-icons/lu";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+import { UsersPermissionsRole, UsersPermissionsUser } from "@/types/generated/strapi.schemas";
+import { useGetUsersId } from "@/types/generated/users-permissions-users-roles";
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import { DialogContent, Dialog, DialogTrigger, DialogTitle } from "@/components/ui/dialog";
+import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
+import { Textarea } from "@/components/ui/textarea";
+
+type DashboardFormControls = {
+ isNew?: boolean;
+ id: string;
+ title: string;
+ handleReject?: (arg: { message: string }) => void;
+ handleCancel: () => void;
+ handleDelete: () => void;
+ status: "approved" | "pending" | "declined";
+ message?: string;
+};
+
+export const DashboardFormControls: FC = ({
+ isNew,
+ title,
+ id,
+ handleReject,
+ handleCancel,
+ handleDelete,
+ status,
+ message,
+}: DashboardFormControls) => {
+ const { data } = useSession();
+ const { user } = data ?? {};
+ const { data: meData } = useGetUsersId(`${user?.id}`, {
+ populate: "role",
+ });
+
+ const ME_DATA = meData as UsersPermissionsUser & { role: UsersPermissionsRole };
+ const isAdmin = ME_DATA?.role?.type === "admin";
+
+ const formSchema = z.object({
+ message: z.string().min(1, { message: "Please provide a reason for the rejection" }),
+ });
+
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ message: "",
+ },
+ });
+
+ const handleDeclinedStatus = useCallback(
+ (values: z.infer) => {
+ !!handleReject && handleReject(values);
+ },
+ [handleReject],
+ );
+
+ return (
+
+
+
+
{title}
+ {status && (
+
+
+ Status: {status}
+
+ {message && status === "declined" && (
+
+ Reason for Declining:{" "}
+ {message}
+
+ )}
+
+ )}
+
+
+ {isAdmin && !isNew && (
+
+
+
+ Delete
+
+
+
+
+
+ Are you absolutely sure?
+
+ This action cannot be undone. This will permanently remove your data from our
+ database.
+
+
+
+ Cancel
+
+ Delete
+
+
+
+
+ )}
+ {(isAdmin && isNew) ||
+ (!isAdmin && (
+
+ Cancel
+
+ ))}
+ {isAdmin && !isNew && (
+
+ e.stopPropagation()} asChild>
+
+ Reject
+
+
+
+ Reasons for Suggestion Rejection
+
+
+
+
+ )}
+
+ {!isAdmin && !isNew && (
+
+
+
+ Delete
+
+
+
+
+
+ Are you absolutely sure?
+
+ This action cannot be undone. This will permanently remove your data from our
+ database.
+
+
+
+ Cancel
+
+ Delete
+
+
+
+
+ )}
+
+
+ {!isAdmin && "Continue"}
+ {isAdmin && isNew && "Submit"}
+ {isAdmin && !isNew && "Approve"}
+
+
+
+
+ );
+};
+
+export default DashboardFormControls;
diff --git a/client/src/components/new-dataset/form-navigation.tsx b/client/src/components/new-dataset/form-navigation.tsx
new file mode 100644
index 00000000..053939d3
--- /dev/null
+++ b/client/src/components/new-dataset/form-navigation.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import { useCallback } from "react";
+
+import { FieldValues, UseFormReturn } from "react-hook-form";
+
+import { useAtom, useSetAtom } from "jotai";
+import isEmpty from "lodash-es/isEmpty";
+import { SlPencil } from "react-icons/sl";
+
+import { cn } from "@/lib/classnames";
+import { getKeys } from "@/lib/utils/objects";
+
+import { datasetStepAtom, datasetValuesAtom } from "@/app/store";
+
+import { Data } from "@/components/forms/dataset/types";
+import { Separator } from "@/components/ui/separator";
+
+type Steps = "settings" | "data" | "colors";
+type StepsObject = {
+ step: number;
+ value: Steps;
+ title: string;
+};
+
+const STEPS: StepsObject[] = [
+ {
+ step: 1,
+ value: "settings",
+ title: "Settings",
+ },
+ {
+ step: 2,
+ value: "data",
+ title: "Data",
+ },
+ {
+ step: 3,
+ value: "colors",
+ title: "Colors",
+ },
+];
+
+const getErrorData = (data: Data["settings"] | Data["data"]): boolean => {
+ if (!data || isEmpty(data)) return true;
+
+ return getKeys(data).every((key) => {
+ if (typeof data[`${key}`] === "number" || typeof data[`${key}`] === "boolean") {
+ return false;
+ }
+
+ return isEmpty(data[`${key}`]);
+ });
+};
+
+const Navigation = ({
+ data,
+ form,
+}: {
+ id: string;
+ data: Data;
+ form: UseFormReturn;
+}): JSX.Element => {
+ const [step, setStep] = useAtom(datasetStepAtom);
+ const setFormValues = useSetAtom(datasetValuesAtom);
+
+ const handleStep = useCallback(
+ (s: number) => {
+ form.trigger().then(() => {
+ form.handleSubmit((values) => {
+ setFormValues((prev) => ({
+ ...prev,
+ [`${STEPS[step - 1].value}`]: values,
+ }));
+ setStep(s);
+ })();
+ });
+ },
+ [form, step, setStep, setFormValues],
+ );
+
+ return (
+
+
+ {STEPS.map(({ step: s, title, value }, i) => {
+ const prevScreen = STEPS[i - 1]?.value;
+ const disabled = prevScreen && getErrorData(data?.[`${prevScreen}`]);
+
+ return (
+
+ handleStep(s)}
+ >
+ {!isEmpty(data?.[value]) ? : s}
+
+
+ {title}
+
+ {i !== STEPS.length - 1 && (
+
+
+
+ )}
+
+ );
+ })}
+
+
+ );
+};
+
+export default Navigation;
diff --git a/client/src/components/new-dataset/step-description/constants.ts b/client/src/components/new-dataset/step-description/constants.ts
new file mode 100644
index 00000000..ce9b2407
--- /dev/null
+++ b/client/src/components/new-dataset/step-description/constants.ts
@@ -0,0 +1,62 @@
+export const PROJECTS_CSV_CONTENT = {
+ title: "Project/s",
+ columns: [
+ "name",
+ "status",
+ "objective",
+ "amount",
+ "countries",
+ "source_country",
+ "sdgs",
+ "pillar",
+ "organization_type",
+ "info",
+ "funding",
+ ],
+ examples: [
+ "Import test 1, In Execution, Seeking Collaborative Partnerships, 120000, Jamaica; Bahamas; Belize, The United States, SDG 12 - Responsible production and consumption; SDG 13 - Climate Action; SDG 17 - Partnership for the goals, 1.5% New Green Jobs for Physical & Economic Resilience, For-profit, example info 1, Grant",
+ "Import test 2, Completed, Building Public Awareness and Engagement, 120001, Trinidad and Tobago; Belize, Trinidad & Tobago, SDG 7 - Affordable and clean energy; SDG 8 - Decent work and economic growth; SDG 9 - Industry Innovation and Infrastructure; SDG 11 - Sustainable Cities and Communities; SDG 12 - Responsible production and consumption; SDG 13 - Climate Action; SDG 17 - Partnership for the goals, 90% Renewable Energy for All, For-profit, example info 2, Loan",
+ "Import test 3, Start-up to Early Stage, An Opportunity to Scale to New jurisdictions, 120002, Belize; Bahamas, Belize, SDG 7 - Affordable and clean energy; SDG 13 - Climate Action; SDG 17 - Partnership for the goals, 90% Renewable Energy for All, For-profit, example info 3, Venture Capital",
+ "Import test 4, Start-up to Early Stage, Attracting Investment and Securing Funding, 120003, Bahamas, Barbados, SDG 7 - Affordable and clean energy; SDG 8 - Decent work and economic growth; SDG 9 - Industry Innovation and Infrastructure; SDG 11 - Sustainable Cities and Communities; SDG 12 - Responsible production and consumption; SDG 13 - Climate Action; SDG 17 - Partnership for the goals, 90% Renewable Energy for All, For-profit, example info 3, Venture Debt",
+ ],
+};
+
+export const OTHER_TOOLS_CSV_CONTENT = {
+ title: "Other Tools",
+ columns: ["name", "link", "category", "description"],
+ categories: [
+ "Biodiversity",
+ "Blue Economy",
+ "Climate Impacts",
+ "Conservation",
+ "Data",
+ "Energy",
+ "General",
+ "Trade",
+ "Vulnerability",
+ ],
+ examples: [
+ "Tool A, http://example.com, Data, A tool for data analysis and visualization.",
+ "Tool B, http://example2.com, Climate Impacts, A tool for assessing climate impacts.",
+ ],
+};
+
+export const COLLABORATORS_CSV_CONTENT = {
+ title: "Collaborators",
+ columns: ["name", "type", "link"],
+ examples: [
+ "John Doe, Donor, http://university.edu",
+ "Jane Smith, Collaborator, http://company.com",
+ ],
+};
+
+export const LINK_CSV_CONTENT = {
+ title: "Link",
+ columns: ["country_id", "link_title", "link_url", "description"],
+ examples: [
+ "AIA, Title 1, http://example1.com, Description 1",
+ "AIA, Title 2, http://example2.com, Description 2",
+ "MEX, Title 3, http://example3.com, Description 3",
+ "VIR, Title 4, http://example4.com, Description 4",
+ ],
+};
diff --git a/client/src/components/new-dataset/step-description/csv-import.tsx b/client/src/components/new-dataset/step-description/csv-import.tsx
new file mode 100644
index 00000000..66fa9d47
--- /dev/null
+++ b/client/src/components/new-dataset/step-description/csv-import.tsx
@@ -0,0 +1,163 @@
+import React, { useCallback, useRef } from "react";
+
+import { useDropzone } from "react-dropzone";
+
+import { useSetAtom } from "jotai";
+import { useSession } from "next-auth/react";
+import { LuInfo } from "react-icons/lu";
+
+import { useRouter } from "next/navigation";
+
+import { Dialog, DialogTrigger, DialogContent } from "@/components/ui/dialog";
+
+import { downloadCSV } from "@/lib/utils/csv";
+import { UsersPermissionsRole, UsersPermissionsUser } from "@/types/generated/strapi.schemas";
+import { datasetValuesJsonUploadedAtom } from "@/app/store";
+import { useGetUsersId } from "@/types/generated/users-permissions-users-roles";
+
+import { validateDatasetValuesCsv } from "@/services/datasets";
+
+import { uploadProjectsCsv, uploadProjectsSuggestionCsv } from "@/services/projects";
+import CSVInfoContent from "./csv-info-content";
+
+import type { CSVImportTypes } from "./types";
+import {
+ uploadCollaboratorsCsv,
+ uploadCollaboratorEditSuggestionsCsv,
+} from "@/services/collaborators";
+import { uploadOtherToolsCsv, uploadToolEditSuggestionCsv } from "@/services/other-tools";
+import { toast } from "react-toastify";
+
+export default function CSVImport({
+ valueType,
+ values,
+}: {
+ valueType: CSVImportTypes;
+ values: any;
+}) {
+ const { push } = useRouter();
+ const setDatasetValues = useSetAtom(datasetValuesJsonUploadedAtom);
+ const fileInputRef = useRef(null);
+
+ const { data: session } = useSession();
+ const apiToken = session?.apiToken;
+
+ const user = session?.user;
+
+ const { data: meData } = useGetUsersId(`${user?.id}`, {
+ populate: "role",
+ });
+
+ const ME_DATA = meData as UsersPermissionsUser & { role: UsersPermissionsRole };
+
+ const { getInputProps, getRootProps } = useDropzone({
+ multiple: false,
+ accept: { "text/csv": [".csv"] },
+ onDropAccepted(files) {
+ if (files.length > 0) {
+ if (
+ valueType === "boolean" ||
+ valueType === "number" ||
+ valueType === "text" ||
+ valueType === "resource"
+ ) {
+ validateDatasetValuesCsv(files, {
+ Authorization: `Bearer ${apiToken}`,
+ }).then(({ data }) => {
+ setDatasetValues(data);
+ });
+ }
+ if (valueType === "project") {
+ ME_DATA?.role?.type === "admin" &&
+ uploadProjectsCsv(files, {
+ Authorization: `Bearer ${apiToken}`,
+ }).then((data) => {
+ if (data.status === 200) {
+ push("/projects");
+ }
+ });
+ ME_DATA?.role?.type === "authenticated" &&
+ uploadProjectsSuggestionCsv(files, {
+ Authorization: `Bearer ${apiToken}`,
+ }).then((data) => {
+ if (data.status === 200) {
+ push("/dashboard");
+ }
+ });
+ }
+ if (valueType === "collaborators") {
+ ME_DATA?.role?.type === "admin" &&
+ uploadCollaboratorsCsv(files, {
+ Authorization: `Bearer ${apiToken}`,
+ }).then((data) => {
+ if (data?.status === 200 || data?.failures?.length === 0) {
+ push("/collaborators");
+ }
+ });
+ ME_DATA?.role?.type === "authenticated" &&
+ uploadCollaboratorEditSuggestionsCsv(files, {
+ Authorization: `Bearer ${apiToken}`,
+ }).then((data) => {
+ if (data?.status === 200 || data?.failures?.length === 0) {
+ push("/dashboard");
+ }
+ });
+ }
+ if (valueType === "other-tools") {
+ ME_DATA?.role?.type === "admin" &&
+ uploadOtherToolsCsv(files, {
+ Authorization: `Bearer ${apiToken}`,
+ }).then((data) => {
+ if (data.status === 200) {
+ push("/other-tools");
+ }
+ });
+ ME_DATA?.role?.type === "authenticated" &&
+ uploadToolEditSuggestionCsv(files, {
+ Authorization: `Bearer ${apiToken}`,
+ }).then((data) => {
+ if (data.status === 200) {
+ push("/dashboard");
+ }
+ });
+ }
+ }
+ },
+ });
+
+ const handleDownload = useCallback(() => {
+ downloadCSV(values?.data, valueType, "myData.csv");
+ }, [values?.data, valueType]);
+
+ return (
+
+
+
+ Add data manually or
+
+ import a CSV
+
+
+
+
+ e.stopPropagation()}>
+
+
+
+
+
+
+
+
+
+ Download template
+
+
+ );
+}
diff --git a/client/src/components/new-dataset/step-description/csv-info-content.tsx b/client/src/components/new-dataset/step-description/csv-info-content.tsx
new file mode 100644
index 00000000..f43455cb
--- /dev/null
+++ b/client/src/components/new-dataset/step-description/csv-info-content.tsx
@@ -0,0 +1,182 @@
+import { CSVImportTypes } from "./types";
+
+import {
+ PROJECTS_CSV_CONTENT,
+ OTHER_TOOLS_CSV_CONTENT,
+ COLLABORATORS_CSV_CONTENT,
+ LINK_CSV_CONTENT,
+} from "./constants";
+
+export default function CSVInfoContent({ valueType }: { valueType: CSVImportTypes }) {
+ return (
+
+
CSV Import Information
+
This feature allows you to import a CSV file with the following format:
+
+ {valueType === "text" && (
+
+
Text
+
+
+ Columns: country_id, text
.
+
+
+ Where country_id
refers to the ISO3 country code and text
{" "}
+ refers to a textual value.
+
+
Example:
+
+ country_id, text
+ AIA, Medium
+
+
+
+ )}
+
+ {valueType === "boolean" && (
+
+
Boolean
+
+
+ Columns: country_id, boolean
.
+
+
+ Where country_id
refers to the ISO3 country code and boolean
{" "}
+ refers to a boolean value (true
or false
).
+
+
Example:
+
+ country_id, boolean
+ MEX, true
+
+
+
+ )}
+
+ {valueType === "number" && (
+
+
Number
+
+
+ Columns: country_id, number
.
+
+
+ Where country_id
refers to the ISO3 country code and number
{" "}
+ refers to a numeric value.
+
+
Example:
+
+ country_id, number
+ VIR, 123
+
+
+
+ )}
+
+ {valueType === "resource" && (
+
+
{LINK_CSV_CONTENT.title}
+
+
+ Columns: {" "}
+ {LINK_CSV_CONTENT.columns.join(", ")}
.
+
+
+ Where country_id
refers to the ISO3 country code, link_title
{" "}
+ is the title of the link, link_url
is the URL, and{" "}
+ description
provides additional details about the link. There can be more
+ than one resource for each country, so link_title
, link_url
,
+ and description
can be arrays.
+
+
Example:
+
+ {LINK_CSV_CONTENT.columns.join(", ")}
+ {LINK_CSV_CONTENT.examples[0]}
+
+
+ If there are multiple resources for a single country, the CSV should look like this:
+
+
+ {LINK_CSV_CONTENT.columns.join(", ")}
+ {LINK_CSV_CONTENT.examples.map((example) => (
+ {example}
+ ))}
+
+
+
+ )}
+
+ {valueType === "project" && (
+
+
{PROJECTS_CSV_CONTENT.title}
+
+
+ Columns: {" "}
+ {PROJECTS_CSV_CONTENT.columns.join(", ")}
.
+
+
Example:
+
+ {PROJECTS_CSV_CONTENT.columns.join(", ")}
+ {PROJECTS_CSV_CONTENT.examples.map((example) => (
+ {example}
+ ))}
+
+
+
+ )}
+
+ {valueType === "other-tools" && (
+
+
{OTHER_TOOLS_CSV_CONTENT.title}
+
+
+ Columns: {" "}
+ {OTHER_TOOLS_CSV_CONTENT.columns.join(", ")}
.
+
+
Categories:
+
+ {OTHER_TOOLS_CSV_CONTENT.categories.map((category) => (
+ {category}
+ ))}
+
+
Example:
+
+ {OTHER_TOOLS_CSV_CONTENT.columns.join(", ")}
+ {OTHER_TOOLS_CSV_CONTENT.examples.map((example) => (
+ {example}
+ ))}
+
+
+
+ )}
+
+ {valueType === "collaborators" && (
+
+
{COLLABORATORS_CSV_CONTENT.title}
+
+
+ Columns: {" "}
+ {COLLABORATORS_CSV_CONTENT.columns.join(", ")}
.
+
+
+ Type: The type field should be either{" "}
+ Donor
or Collaborator
.
+
+
Example:
+
+ {COLLABORATORS_CSV_CONTENT.columns.join(", ")}
+ {COLLABORATORS_CSV_CONTENT.examples.map((example) => (
+ {example}
+ ))}
+
+
+
+ )}
+
+
+ If you are not sure how to proceed, you can download a template on this page with all
+ available countries to fill in the values.
+
+
+ );
+}
diff --git a/client/src/components/new-dataset/step-description/index.tsx b/client/src/components/new-dataset/step-description/index.tsx
new file mode 100644
index 00000000..bc3a6e68
--- /dev/null
+++ b/client/src/components/new-dataset/step-description/index.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { useAtomValue } from "jotai";
+
+import { datasetStepAtom } from "@/app/store";
+
+import Step1 from "./step1";
+import Step2 from "./step2";
+import Step3 from "./step3";
+
+const STEPS: Record JSX.Element> = {
+ 1: Step1,
+ 2: Step2,
+ 3: Step3,
+};
+
+export default function NewDatasetPagePage() {
+ const step = useAtomValue(datasetStepAtom);
+ const Description = STEPS[step];
+
+ return (
+
+ {" "}
+
+ );
+}
diff --git a/client/src/components/new-dataset/step-description/step1.tsx b/client/src/components/new-dataset/step-description/step1.tsx
new file mode 100644
index 00000000..51e49a40
--- /dev/null
+++ b/client/src/components/new-dataset/step-description/step1.tsx
@@ -0,0 +1,8 @@
+export default function Step1() {
+ return (
+
+ Fill the dataset's settings before continuing to add the dataset (
+ * required fields).
+
+ );
+}
diff --git a/client/src/components/new-dataset/step-description/step2.tsx b/client/src/components/new-dataset/step-description/step2.tsx
new file mode 100644
index 00000000..a4326279
--- /dev/null
+++ b/client/src/components/new-dataset/step-description/step2.tsx
@@ -0,0 +1,19 @@
+import CSVImport from "./csv-import";
+
+import { usePathname } from "next/navigation";
+
+import { useAtom } from "jotai";
+import { datasetValuesNewAtom, datasetValuesAtom } from "@/app/store";
+
+import { CSVImportTypes } from "./types";
+
+export default function Step2() {
+ const [formValuesNew] = useAtom(datasetValuesNewAtom);
+ const [formValuesEdit] = useAtom(datasetValuesAtom);
+ const path = usePathname();
+
+ const values = path.includes("new") ? formValuesNew : formValuesEdit;
+ const valueType = values.settings.value_type as CSVImportTypes;
+
+ return ;
+}
diff --git a/client/src/components/new-dataset/step-description/step3.tsx b/client/src/components/new-dataset/step-description/step3.tsx
new file mode 100644
index 00000000..27d2ec96
--- /dev/null
+++ b/client/src/components/new-dataset/step-description/step3.tsx
@@ -0,0 +1,7 @@
+export default function Step3() {
+ return (
+
+ Choose colors for the layer that visualize the data on the map.
+
+ );
+}
diff --git a/client/src/components/new-dataset/step-description/types.d.ts b/client/src/components/new-dataset/step-description/types.d.ts
new file mode 100644
index 00000000..7e86687a
--- /dev/null
+++ b/client/src/components/new-dataset/step-description/types.d.ts
@@ -0,0 +1,3 @@
+import { VALUE_TYPE } from "@/components/forms/dataset/types";
+
+export type CSVImportTypes = VALUE_TYPE | "project" | "collaborators" | "other-tools";
diff --git a/client/src/components/ui/alert-dialog.tsx b/client/src/components/ui/alert-dialog.tsx
new file mode 100644
index 00000000..174d9341
--- /dev/null
+++ b/client/src/components/ui/alert-dialog.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import * as React from "react";
+
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
+
+import { cn } from "@/lib/classnames";
+
+import { ButtonVariantProps, buttonVariants } from "@/components/ui/button";
+import { cva, type VariantProps } from "class-variance-authority";
+
+const AlertDialog = AlertDialogPrimitive.Root;
+
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
+
+const AlertDialogPortal = AlertDialogPrimitive.Portal;
+
+const AlertDialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
+
+const AlertDialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+));
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
+
+const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
+
+);
+AlertDialogHeader.displayName = "AlertDialogHeader";
+
+const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
+
+);
+AlertDialogFooter.displayName = "AlertDialogFooter";
+
+const AlertDialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
+
+const AlertDialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
+
+const AlertDialogAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ className?: string;
+ variant: ButtonVariantProps["variant"];
+ }
+>(({ className, variant, ...props }, ref) => (
+
+));
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
+
+const AlertDialogCancel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & { className?: string }
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+};
diff --git a/client/src/components/ui/breadcrumb.tsx b/client/src/components/ui/breadcrumb.tsx
new file mode 100644
index 00000000..c70a69f0
--- /dev/null
+++ b/client/src/components/ui/breadcrumb.tsx
@@ -0,0 +1,107 @@
+import * as React from "react";
+
+import { Slot } from "@radix-ui/react-slot";
+import { LuChevronRight, LuMoreHorizontal } from "react-icons/lu";
+
+import { cn } from "@/lib/classnames";
+
+const Breadcrumb = React.forwardRef<
+ HTMLElement,
+ React.ComponentPropsWithoutRef<"nav"> & {
+ separator?: React.ReactNode;
+ }
+>(({ ...props }, ref) => );
+Breadcrumb.displayName = "Breadcrumb";
+
+const BreadcrumbList = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+BreadcrumbList.displayName = "BreadcrumbList";
+
+const BreadcrumbItem = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+BreadcrumbItem.displayName = "BreadcrumbItem";
+
+const BreadcrumbLink = React.forwardRef<
+ HTMLAnchorElement,
+ React.ComponentPropsWithoutRef<"a"> & {
+ asChild?: boolean;
+ }
+>(({ asChild, className, ...props }, ref) => {
+ const Comp = asChild ? Slot : "a";
+
+ return (
+
+ );
+});
+BreadcrumbLink.displayName = "BreadcrumbLink";
+
+const BreadcrumbPage = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+BreadcrumbPage.displayName = "BreadcrumbPage";
+
+const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
+ svg]:size-3.5", className)}
+ {...props}
+ >
+ {children ?? }
+
+);
+BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
+
+const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
+
+
+ More
+
+);
+
+BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+};
diff --git a/client/src/components/ui/button.tsx b/client/src/components/ui/button.tsx
index 870a2615..33ad3fbb 100644
--- a/client/src/components/ui/button.tsx
+++ b/client/src/components/ui/button.tsx
@@ -5,6 +5,8 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/classnames";
+export type ButtonVariantProps = VariantProps;
+
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
@@ -15,7 +17,7 @@ const buttonVariants = cva(
"destructive-outline":
"text-destructive border border-destructive hover:bg-destructive/60 hover:text-destructive-foreground hover:border-destructive/60",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
- "primary-outline": "border border-primary bg-transparent text-primary hover:bg-primary/10 ",
+ "primary-outline": "border border-primary bg-transparent text-primary hover:bg-primary/10",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
diff --git a/client/src/components/ui/card.tsx b/client/src/components/ui/card.tsx
new file mode 100644
index 00000000..9b2677a0
--- /dev/null
+++ b/client/src/components/ui/card.tsx
@@ -0,0 +1,56 @@
+import * as React from "react";
+
+import { cn } from "@/lib/classnames";
+
+const Card = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+Card.displayName = "Card";
+
+const CardHeader = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+CardHeader.displayName = "CardHeader";
+
+const CardTitle = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+CardTitle.displayName = "CardTitle";
+
+const CardDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardDescription.displayName = "CardDescription";
+
+const CardContent = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+CardContent.displayName = "CardContent";
+
+const CardFooter = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+CardFooter.displayName = "CardFooter";
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
diff --git a/client/src/components/ui/colorpicker.tsx b/client/src/components/ui/colorpicker.tsx
new file mode 100644
index 00000000..d72e80f6
--- /dev/null
+++ b/client/src/components/ui/colorpicker.tsx
@@ -0,0 +1,60 @@
+import React from "react";
+import { FC, useState } from "react";
+
+import { LuChevronDown, LuChevronUp } from "react-icons/lu";
+
+import { cn } from "@/lib/classnames";
+
+import { Input } from "./input";
+
+type ColorPickerProps = {
+ id: string;
+ value: string;
+ className?: string;
+ onChange: (e: React.ChangeEvent) => void;
+ disabled?: boolean;
+};
+
+const ColorPicker: FC = ({
+ id,
+ value,
+ className,
+ onChange,
+ disabled,
+}: ColorPickerProps) => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+
+
setIsOpen(!isOpen)}
+ className={cn(className, {
+ "absolute inset-0 top-2 h-full w-full cursor-pointer bg-transparent opacity-0": true,
+ "hidden bg-transparent bg-none": disabled,
+ })}
+ disabled={disabled}
+ />
+
+
+ {value && (
+
+ )}
+ {value || "Select one"}
+
+ {isOpen ?
:
}
+
+
+ );
+};
+
+export default ColorPicker;
diff --git a/client/src/components/ui/dialog.tsx b/client/src/components/ui/dialog.tsx
index c548769b..54351a60 100644
--- a/client/src/components/ui/dialog.tsx
+++ b/client/src/components/ui/dialog.tsx
@@ -7,6 +7,10 @@ import { LuX } from "react-icons/lu";
import { cn } from "@/lib/classnames";
+interface DialogContentProps
+ extends React.ComponentPropsWithoutRef {
+ close?: boolean;
+}
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
@@ -32,32 +36,36 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, children, ...props }, ref) => (
+ DialogContentProps
+>(({ className, children, close = true, ...props }, ref) => (
{children}
- e.stopPropagation()}
- >
-
- Close
-
+
+ {close && (
+ e.stopPropagation()}
+ >
+
+ Close
+
+ )}
diff --git a/client/src/components/ui/form.tsx b/client/src/components/ui/form.tsx
index 5cbef5ed..6249a16a 100644
--- a/client/src/components/ui/form.tsx
+++ b/client/src/components/ui/form.tsx
@@ -13,12 +13,13 @@ import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/lib/classnames";
+import { getKeys } from "@/lib/utils/objects";
import { Label } from "@/components/ui/label";
const Form = FormProvider;
-type FormFieldContextValue<
+export type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath = FieldPath,
> = {
@@ -139,6 +140,7 @@ const FormMessage = React.forwardRef<
React.HTMLAttributes
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
+
const body = error ? String(error?.message) : children;
if (!body) {
@@ -158,6 +160,43 @@ const FormMessage = React.forwardRef<
});
FormMessage.displayName = "FormMessage";
+const FormMessageArray = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes & { index: number }
+>(({ className, children, index, ...props }, ref) => {
+ const { error, formMessageId } = useFormField();
+
+ const body = React.useMemo(() => {
+ if (error && Array.isArray(error)) {
+ const e = error[index];
+
+ if (!e) return children;
+
+ return getKeys(e)
+ .map((k) => e[k]?.message)
+ .join(", ");
+ }
+
+ return error ? String(error?.message) : children;
+ }, [error, children, index]);
+
+ if (!body) {
+ return null;
+ }
+
+ return (
+
+ {body}
+
+ );
+});
+FormMessageArray.displayName = "FormMessage";
+
export {
useFormField,
Form,
@@ -166,5 +205,6 @@ export {
FormControl,
FormDescription,
FormMessage,
+ FormMessageArray,
FormField,
};
diff --git a/client/src/components/ui/markdown-editor.tsx b/client/src/components/ui/markdown-editor.tsx
new file mode 100644
index 00000000..06ccf63f
--- /dev/null
+++ b/client/src/components/ui/markdown-editor.tsx
@@ -0,0 +1,19 @@
+import { forwardRef } from "react";
+
+import dynamic from "next/dynamic";
+
+import { MDEditorProps, commands } from "@uiw/react-md-editor";
+const MDEditor = dynamic(() => import("@uiw/react-md-editor"), { ssr: false });
+
+const MarkdownEditor = forwardRef((props, ref) => {
+ const customCommands = [commands.bold, commands.italic, commands.link];
+ return (
+
+
+
+ );
+});
+
+MarkdownEditor.displayName = "MarkdownEditor";
+
+export default MarkdownEditor;
diff --git a/client/src/components/ui/multicombobox.tsx b/client/src/components/ui/multicombobox.tsx
index 3e601f21..b78a42f2 100644
--- a/client/src/components/ui/multicombobox.tsx
+++ b/client/src/components/ui/multicombobox.tsx
@@ -17,10 +17,11 @@ import {
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
interface MultiComboboxProps {
- values?: string[];
- options?: { value: string; label: string }[];
+ values?: (string | number)[];
+ options?: { value: string | number; label: string }[];
placeholder?: string;
- onChange: (value: string[]) => void;
+ onChange: (value: (string | number)[]) => void;
+ disabled?: boolean;
}
export function MultiCombobox({
@@ -28,6 +29,7 @@ export function MultiCombobox({
options = [],
placeholder,
onChange,
+ disabled,
}: MultiComboboxProps) {
const [open, setOpen] = React.useState(false);
@@ -35,7 +37,10 @@ export function MultiCombobox({
if (!values || !values.length) return placeholder || "Select...";
if (values.length === 1) {
- return options?.find((c) => c?.value.toLowerCase() === values[0].toLowerCase())?.label;
+ const selectedOption = options?.find(
+ (c) => c?.value.toString().toLowerCase() === values[0].toString().toLowerCase(),
+ );
+ return selectedOption?.label;
}
return `${values.length} selected`;
@@ -49,6 +54,7 @@ export function MultiCombobox({
role="combobox"
aria-expanded={open}
className="w-full justify-between"
+ disabled={disabled}
>
{SELECTED}
diff --git a/client/src/components/ui/select.tsx b/client/src/components/ui/select.tsx
index 41372f6d..f735a794 100644
--- a/client/src/components/ui/select.tsx
+++ b/client/src/components/ui/select.tsx
@@ -11,7 +11,15 @@ const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
-const SelectValue = SelectPrimitive.Value;
+const SelectValue = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+));
+SelectValue.displayName = SelectPrimitive.Value.displayName;
const SelectTrigger = React.forwardRef<
React.ElementRef,
diff --git a/client/src/components/ui/separator.tsx b/client/src/components/ui/separator.tsx
new file mode 100644
index 00000000..1bb7a021
--- /dev/null
+++ b/client/src/components/ui/separator.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+import * as React from "react";
+
+import * as SeparatorPrimitive from "@radix-ui/react-separator";
+
+import { cn } from "@/lib/classnames";
+
+const Separator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
+
+));
+Separator.displayName = SeparatorPrimitive.Root.displayName;
+
+export { Separator };
diff --git a/client/src/components/ui/table.tsx b/client/src/components/ui/table.tsx
new file mode 100644
index 00000000..84277d84
--- /dev/null
+++ b/client/src/components/ui/table.tsx
@@ -0,0 +1,91 @@
+import * as React from "react";
+
+import { cn } from "@/lib/classnames";
+
+const Table = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+Table.displayName = "Table";
+
+const TableHeader = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+TableHeader.displayName = "TableHeader";
+
+const TableBody = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+TableBody.displayName = "TableBody";
+
+const TableFooter = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+ tr]:last:border-b-0", className)}
+ {...props}
+ />
+));
+TableFooter.displayName = "TableFooter";
+
+const TableRow = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+TableRow.displayName = "TableRow";
+
+const TableHead = React.forwardRef<
+ HTMLTableCellElement,
+ React.ThHTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+TableHead.displayName = "TableHead";
+
+const TableCell = React.forwardRef<
+ HTMLTableCellElement,
+ React.TdHTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+TableCell.displayName = "TableCell";
+
+const TableCaption = React.forwardRef<
+ HTMLTableCaptionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+TableCaption.displayName = "TableCaption";
+
+export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
diff --git a/client/src/components/ui/tabs.tsx b/client/src/components/ui/tabs.tsx
new file mode 100644
index 00000000..46a46c22
--- /dev/null
+++ b/client/src/components/ui/tabs.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import * as React from "react";
+
+import * as TabsPrimitive from "@radix-ui/react-tabs";
+
+import { cn } from "@/lib/classnames";
+
+const Tabs = TabsPrimitive.Root;
+
+const TabsList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsList.displayName = TabsPrimitive.List.displayName;
+
+const TabsTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
+
+const TabsContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsContent.displayName = TabsPrimitive.Content.displayName;
+
+export { Tabs, TabsList, TabsTrigger, TabsContent };
diff --git a/client/src/components/ui/textarea.tsx b/client/src/components/ui/textarea.tsx
new file mode 100644
index 00000000..1b784fe8
--- /dev/null
+++ b/client/src/components/ui/textarea.tsx
@@ -0,0 +1,23 @@
+import * as React from "react";
+
+import { cn } from "@/lib/classnames";
+
+export interface TextareaProps extends React.TextareaHTMLAttributes {}
+
+const Textarea = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ },
+);
+Textarea.displayName = "Textarea";
+
+export { Textarea };
diff --git a/client/src/containers/collaborators/form/index.tsx b/client/src/containers/collaborators/form/index.tsx
new file mode 100644
index 00000000..fbf5f492
--- /dev/null
+++ b/client/src/containers/collaborators/form/index.tsx
@@ -0,0 +1,577 @@
+"use client";
+import { useCallback, useState, useRef, useEffect } from "react";
+
+import { useDropzone } from "react-dropzone";
+import { useForm } from "react-hook-form";
+import { toast } from "react-toastify";
+
+import Image from "next/image";
+import { useParams, useRouter } from "next/navigation";
+import CSVImport from "@/components/new-dataset/step-description/csv-import";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useSession } from "next-auth/react";
+import { z } from "zod";
+
+import { cn } from "@/lib/classnames";
+import { getObjectDifferences } from "@/lib/utils/objects";
+
+import { useDeleteCollaboratorsId, useGetCollaboratorsId } from "@/types/generated/collaborator";
+import {
+ useGetCollaboratorEditSuggestionsId,
+ usePutCollaboratorEditSuggestionsId,
+ usePostCollaboratorEditSuggestions,
+ useDeleteCollaboratorEditSuggestionsId,
+} from "@/types/generated/collaborator-edit-suggestion";
+import {
+ UsersPermissionsRole,
+ UsersPermissionsUser,
+ CollaboratorEditSuggestionCollaboratorDataAttributesType,
+} from "@/types/generated/strapi.schemas";
+import { useGetUsersId } from "@/types/generated/users-permissions-users-roles";
+
+import { useSyncSearchParams } from "@/app/store";
+
+import DashboardFormWrapper from "@/components/forms/dataset/wrapper";
+import DashboardFormControls from "@/components/new-dataset/form-controls";
+import { Button } from "@/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectTrigger,
+ SelectContent,
+ SelectItem,
+ SelectValue,
+} from "@/components/ui/select";
+
+import { updateOrCreateCollaborator } from "@/services/collaborators";
+import { uploadImage } from "@/services/datasets";
+
+export default function CollaboratorForm() {
+ const [isChrome, setIsChrome] = useState(false);
+
+ useEffect(() => {
+ // Detect if the user agent is Chrome only on the client side
+ if (typeof window !== "undefined" && navigator.userAgent.includes("Chrome")) {
+ setIsChrome(true);
+ }
+ }, []);
+ const [imageId, setImageId] = useState(null);
+ const { push } = useRouter();
+ const URLParams = useSyncSearchParams();
+ const fileInputRef = useRef(null);
+
+ const params = useParams();
+
+ const { id } = params;
+
+ const { data } = useSession();
+ const user = data?.user;
+
+ const { data: meData } = useGetUsersId(`${user?.id}`, {
+ populate: "role",
+ });
+
+ const ME_DATA = meData as UsersPermissionsUser & { role: UsersPermissionsRole };
+
+ // if there is no id in the route, we are creating a new collaborator, no need to look for
+ // an existing one
+ const { data: collaboratorData } = useGetCollaboratorsId(
+ +id,
+ {
+ populate: "*",
+ },
+ {
+ query: {
+ enabled: !!id,
+ },
+ },
+ );
+
+ const { data: collaboratorSuggestedDataId } = useGetCollaboratorEditSuggestionsId(
+ +id,
+ {
+ populate: "*",
+ },
+ {
+ query: {
+ enabled: !!id,
+ },
+ },
+ );
+
+ const previousData =
+ collaboratorData?.data?.attributes || collaboratorSuggestedDataId?.data?.attributes;
+
+ const { mutate: mutatePutCollaboratorsEditSuggestionId } = usePutCollaboratorEditSuggestionsId({
+ mutation: {
+ onSuccess: (data) => {
+ console.info("Success updating a new collaborator:", data);
+ if (
+ data?.data?.attributes?.review_status === "declined" ||
+ data?.data?.attributes?.review_status === "pending" ||
+ ME_DATA?.role?.type === "authenticated"
+ ) {
+ push(`/dashboard`);
+ }
+ push(`/collaborators`);
+ },
+ onError: (error) => {
+ console.error("Error updating a new collaborator:", error);
+ },
+ },
+ request: {},
+ });
+
+ const { mutate: mutatePostCollaboratorsEditSuggestion } = usePostCollaboratorEditSuggestions({
+ mutation: {
+ onSuccess: (data) => {
+ console.info("Success creating a new collaborator:", data);
+ ME_DATA?.role?.type === "authenticated" && push(`/dashboard`);
+ },
+ onError: (error) => {
+ console.error("Error creating a new collaborator:", error);
+ },
+ },
+ request: {},
+ });
+
+ const { mutate: mutateDeleteCollaboratorId } = useDeleteCollaboratorsId({
+ mutation: {
+ onSuccess: (data) => {
+ console.info("Success deleting collaborator:", data);
+ push(`/collaborators`);
+ },
+ onError: (error) => {
+ console.error("Error deleting collaborator:", error);
+ },
+ },
+ });
+
+ const { mutate: mutateDeleteCollaboratorEditSuggestionsId } =
+ useDeleteCollaboratorEditSuggestionsId({
+ mutation: {
+ onSuccess: (data) => {
+ console.info("Success deleting collaborator suggestion:", data);
+ push(`/collaborators`);
+ },
+ onError: (error) => {
+ console.error("Error deleting collaborator suggestion:", error);
+ },
+ },
+ });
+
+ const relationshipOptions = [
+ {
+ label: "Collaborator",
+ value: "collaborator",
+ },
+ {
+ label: "Donor",
+ value: "donor",
+ },
+ ];
+
+ const relationTypes = ["collaborator", "donor"] as const;
+
+ const formSchema = z.object({
+ name: z.string().refine((val) => !!val, {
+ message: "Please enter organization name",
+ }),
+ type: z
+ .enum(relationTypes)
+ .optional()
+ .refine((val) => !!val, {
+ message: "Please select a relation type",
+ }),
+ link: z.string().refine(
+ (value) => {
+ // Allow URLs starting with "www." or valid URLs starting with "http" or "https"
+ return /^(https?:\/\/)?(www\.)?[a-zA-Z0-9-]+\.[a-zA-Z]{2,}(\/[^\s]*)?$/.test(value);
+ },
+ { message: "Please enter a valid URL" },
+ ),
+ image: z.number().min(1, { message: "Please ass image" }),
+ });
+
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ values: {
+ name: previousData?.name || "",
+ type: (previousData?.type as CollaboratorEditSuggestionCollaboratorDataAttributesType) || "",
+ link: previousData?.link || "",
+ image: previousData?.image?.data?.id as number,
+ },
+ });
+
+ const handleCancel = () => {
+ push(`/?${URLParams.toString()}`);
+ };
+
+ const handleSubmit = useCallback(
+ (values: z.infer) => {
+ if (ME_DATA?.role?.type === "authenticated") {
+ if (!!id && !!collaboratorSuggestedDataId) {
+ mutatePutCollaboratorsEditSuggestionId({
+ id: +id,
+ data: {
+ data: {
+ review_status: "pending",
+ ...values,
+ image: imageId as number,
+ },
+ },
+ });
+ }
+ if ((!!id && !collaboratorSuggestedDataId) || !id) {
+ // is an edition to an existing one, has there is a suggestion
+ // we have to post to a new one
+ // no need to send author because BE handles it
+ mutatePostCollaboratorsEditSuggestion({
+ data: {
+ // @ts-expect-error wrong strapi typing
+ data: {
+ review_status: "pending",
+ ...values,
+ image: imageId as number,
+ ...(id && {
+ collaborator: {
+ connect: [+id],
+ disconnect: [],
+ },
+ }),
+ },
+ },
+ });
+ }
+ }
+
+ if (ME_DATA?.role?.type === "admin" && data?.apiToken) {
+ updateOrCreateCollaborator(
+ {
+ ...(id && !collaboratorSuggestedDataId && { id }),
+ ...(id &&
+ !!collaboratorSuggestedDataId && {
+ id: collaboratorSuggestedDataId?.data?.attributes?.collaborator?.data?.id,
+ }),
+ ...values,
+ image: imageId,
+ },
+ data?.apiToken,
+ )
+ .then((data) => {
+ console.info("Success creating collaborator:", data);
+ toast.success("Success creating collaborator");
+
+ mutatePostCollaboratorsEditSuggestion({
+ data: {
+ data: {
+ review_status: "approved",
+ ...values,
+ image: imageId as number,
+ // @ts-expect-error TO-DO - fix types
+ collaborator: {
+ connect: [+id],
+ disconnect: [],
+ },
+ },
+ },
+ });
+ push(`/collaborators`);
+ })
+ .catch((error: Error) => {
+ toast.error("There was a problem creating the collaborator");
+ console.error("Error creating collaborator:", error);
+ });
+ }
+ },
+ [
+ data?.apiToken,
+ push,
+ ME_DATA?.role?.type,
+ id,
+ collaboratorSuggestedDataId,
+ mutatePutCollaboratorsEditSuggestionId,
+ mutatePostCollaboratorsEditSuggestion,
+ imageId,
+ ],
+ );
+
+ const handleReject = ({ message }: { message: string }) => {
+ if (ME_DATA?.role?.type === "admin" && !!id) {
+ mutatePutCollaboratorsEditSuggestionId({
+ id: +id[0],
+ data: {
+ data: {
+ review_status: "declined",
+ review_decision_details: message,
+ },
+ },
+ });
+ }
+ };
+
+ const { getInputProps, getRootProps, acceptedFiles } = useDropzone({
+ multiple: false,
+ maxFiles: 1,
+ maxSize: 50000000,
+ accept: { "image/*": [".gif", ".jpeg", ".jpg", ".webp", ".png", ".svgs"] },
+ onDropAccepted(files) {
+ if (files.length > 0) {
+ uploadImage(files, {
+ Authorization: `Bearer ${data?.apiToken}`,
+ })
+ .then((data) => {
+ form.setValue("image", data[0].id);
+ setImageId(data[0].id);
+ toast.success(`Image ${data?.[0].name} uploaded successfully`);
+ })
+ .catch((error) => {
+ console.error("Error uploading image:", error[0]?.message);
+ toast.error("Error uploading image");
+ });
+ }
+ },
+ onDropRejected(error) {
+ console.error("Error uploading image:", error[0]?.errors[0]?.message);
+ toast.error("Error uploading image: " + error[0]?.errors[0]?.message);
+ },
+ });
+
+ const handleDelete = useCallback(() => {
+ if (collaboratorData?.data?.id) {
+ mutateDeleteCollaboratorId({ id: +id });
+ } else if (collaboratorSuggestedDataId?.data?.id) {
+ mutateDeleteCollaboratorEditSuggestionsId({ id: +id });
+ }
+ }, [id, mutateDeleteCollaboratorId]);
+
+ const handleClick = () => {
+ if (fileInputRef.current) {
+ fileInputRef.current.click();
+ }
+ };
+
+ const changes =
+ !collaboratorData?.data?.attributes && !!id && collaboratorSuggestedDataId?.data?.attributes
+ ? []
+ : getObjectDifferences(collaboratorData?.data?.attributes, form.getValues());
+
+ const suggestionStatus = collaboratorSuggestedDataId?.data?.attributes?.review_status;
+
+ return (
+ <>
+
+
+
+
+
+
+ Fill the organization's information{" "}
+
+ (* required fields)
+
+
+
+
+
+ (
+
+
+ Organization name*
+
+
+
+
+
+
+ )}
+ />
+
+ (
+
+
+ Type of relationship*
+
+
+ field.onChange(v)}>
+
+
+
+
+ {relationshipOptions?.map(({ label, value }) => (
+
+ {label}
+
+ ))}
+
+
+
+
+
+ )}
+ />
+ (
+
+
+ Website link*
+
+
+
+
+
+
+ )}
+ />
+
+ (
+
+
+ Logo image*
+
+
+
+
+
+
+
+ {!previousData?.image?.data?.attributes?.url && !isChrome && (
+
+
+ Drag and drop here, or{" "}
+
+ browse
+
+
+
Supports: PNG, JPG, JPEG, GIF, WEBP
+
+ )}
+ {!previousData?.image?.data?.attributes?.url && isChrome && (
+
+
+ Drag and drop here, or browse
+
+
Supports: PNG, JPG, JPEG, GIF, WEBP
+
+ )}
+
+
+
+
+ )}
+ />
+ {!!acceptedFiles.length && (
+
+ {acceptedFiles[0]?.name}
+
+ )}
+
+
+ Submit
+
+
+
+
+ >
+ );
+}
diff --git a/client/src/containers/collaborators/index.tsx b/client/src/containers/collaborators/index.tsx
index 7c2eba9d..9c7ec546 100644
--- a/client/src/containers/collaborators/index.tsx
+++ b/client/src/containers/collaborators/index.tsx
@@ -27,9 +27,13 @@ const CollaboratorsList = () => {
isFetching,
isFetched,
isError,
- } = useGetCollaborators({
- ...(search ? { filters: { name: { $containsi: search } } } : {}),
- });
+ } = useGetCollaborators(
+ {
+ populate: "*",
+ ...(search ? { filters: { name: { $containsi: search } } } : {}),
+ },
+ {},
+ );
const collaborators = useMemo(
() => groupBy(collaboratorsData?.data, "attributes.type"),
@@ -62,7 +66,7 @@ const CollaboratorsList = () => {
isError={isError}
>
diff --git a/client/src/containers/collaborators/item.tsx b/client/src/containers/collaborators/item.tsx
index 3cffd265..22be7fc8 100644
--- a/client/src/containers/collaborators/item.tsx
+++ b/client/src/containers/collaborators/item.tsx
@@ -1,48 +1,123 @@
+import { useEffect, useState } from "react";
+
import Image from "next/image";
+import Link from "next/link";
import { capitalize } from "lodash-es";
+import { useSession } from "next-auth/react";
import { LuChevronDown, LuExternalLink } from "react-icons/lu";
+import { useGetCollaboratorsId } from "@/types/generated/collaborator";
+
import { cn } from "@/lib/classnames";
import { Collaborator, CollaboratorListResponseDataItem } from "@/types/generated/strapi.schemas";
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
+import env from "@/env.mjs";
+
type CollaboratorTypeItemProps = {
id?: number;
attributes?: Collaborator;
+ status?: "authenticated" | "loading" | "unauthenticated";
};
-const CollaboratorTypeItem = ({ id, attributes }: CollaboratorTypeItemProps) => (
-
-
-
-
-
+function checkImage(url: string, callback: (isValid: boolean) => void) {
+ const img = document.createElement("img"); // Create an HTML image element
+
+ img.onload = function () {
+ callback(true); // Image is valid
+ };
+
+ img.onerror = function () {
+ callback(false); // Image is broken
+ };
+
+ img.src = url; // Set the image source
+}
+
+const CollaboratorTypeItem = ({ id, attributes, status }: CollaboratorTypeItemProps) => {
+ const [imageUrl, setImageUrl] = useState
(null);
+ const { data: collaboratorData } = useGetCollaboratorsId(
+ id as number,
+ {
+ populate: "*",
+ },
+ {
+ query: {
+ enabled: !!id,
+ },
+ },
+ );
+
+ useEffect(() => {
+ const fallbackUrl = `/images/collaborators/collaborator-${id}.png`;
+ const placeholderUrl = `/images/collaborators/no-image-placeholder.png`;
+
+ // First image to check (from collaboratorData)
+ const initialUrl =
+ collaboratorData?.data?.attributes?.image?.data?.attributes?.url || fallbackUrl;
+
+ checkImage(initialUrl, (isValid) => {
+ if (isValid) {
+ setImageUrl(initialUrl);
+ } else {
+ checkImage(fallbackUrl, (isFallbackValid) => {
+ if (isFallbackValid) {
+ setImageUrl(fallbackUrl);
+ } else {
+ setImageUrl(placeholderUrl); // Use the placeholder if both images are broken
+ }
+ });
+ }
+ });
+ }, [id, collaboratorData]);
+
+ return (
+
+ {status === "authenticated" && (
+
+ Edit
+
+ )}
+
+
+
+
+
+
-
-);
+ );
+};
type CollaboratorItemProps = {
collaboratorType: string;
@@ -50,6 +125,7 @@ type CollaboratorItemProps = {
};
const CollaboratorItem = ({ collaboratorType, collaborators }: CollaboratorItemProps) => {
+ const { status } = useSession();
return (
@@ -60,7 +136,7 @@ const CollaboratorItem = ({ collaboratorType, collaborators }: CollaboratorItemP
{collaborators.map(({ attributes, id }) => (
-
+
))}
diff --git a/client/src/containers/collaborators/title/index.tsx b/client/src/containers/collaborators/title/index.tsx
new file mode 100644
index 00000000..d0bb77a3
--- /dev/null
+++ b/client/src/containers/collaborators/title/index.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import Link from "next/link";
+
+import { useSession } from "next-auth/react";
+
+export default function CollaboratorsTitle() {
+ const session = useSession();
+ return (
+
+
Collaborators
+ {session.status === "authenticated" && (
+
+ Add new
+
+ )}
+
+ );
+}
diff --git a/client/src/containers/countries/countries-detail.tsx b/client/src/containers/countries/countries-detail.tsx
new file mode 100644
index 00000000..8be35e12
--- /dev/null
+++ b/client/src/containers/countries/countries-detail.tsx
@@ -0,0 +1,88 @@
+"use client";
+import Flag from "react-world-flags";
+
+import { cn } from "@/lib/classnames";
+
+import { useGetCountries } from "@/types/generated/country";
+
+import { useSyncCountriesComparison, useSyncCountry } from "@/app/store";
+
+import CountryDataDialog from "@/containers/countries/data-dialog";
+import CountryDownloadDialog from "@/containers/countries/download-dialog";
+import { MultiCombobox } from "@/containers/countries/multicombobox";
+
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
+
+import CountriesTable from "./countries-table";
+
+const CountryDetail = ({ embed }: { embed?: boolean }) => {
+ const [country] = useSyncCountry();
+ const [countriesComparison, setComparisonCountries] = useSyncCountriesComparison();
+
+ const { data: countriesData } = useGetCountries({
+ "pagination[pageSize]": 100,
+ sort: "name:asc",
+ });
+
+ const COUNTRY = countriesData?.data?.find((c) => c.attributes?.iso3 === country);
+
+ if (!COUNTRY && embed) return null;
+
+ return (
+ <>
+
+
+
+
+
+
+ {!!countriesComparison?.length && (
+ setComparisonCountries([])} variant="destructive-outline">
+ Clear
+
+ )}
+
+
+
+
+
+
+
+ Open Table detail
+
+
+
+
+
+
+
+
+ Download data
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default CountryDetail;
diff --git a/client/src/containers/countries/countries-table.tsx b/client/src/containers/countries/countries-table.tsx
index 3c28132f..2ef86902 100644
--- a/client/src/containers/countries/countries-table.tsx
+++ b/client/src/containers/countries/countries-table.tsx
@@ -57,6 +57,11 @@ const CountriesTable = () => {
return (
+ {((!!TABLE_ROWS_DATA && !TABLE_ROWS_DATA.length) || !TABLE_ROWS_DATA) && (
+
+ Please activate at least one dataset to view and download its details.
+
+ )}
{!!TABLE_ROWS_DATA && !!TABLE_ROWS_DATA.length && (
diff --git a/client/src/containers/countries/popup.tsx b/client/src/containers/countries/popup.tsx
index 067c662f..18e95ef8 100644
--- a/client/src/containers/countries/popup.tsx
+++ b/client/src/containers/countries/popup.tsx
@@ -1,85 +1,16 @@
"use client";
-import Flag from "react-world-flags";
-import { cn } from "@/lib/classnames";
+import { useSyncCountry } from "@/app/store";
-import { useGetCountries } from "@/types/generated/country";
-
-import { useSyncCountriesComparison, useSyncCountry } from "@/app/store";
-
-import CountryDataDialog from "@/containers/countries/data-dialog";
-import CountryDownloadDialog from "@/containers/countries/download-dialog";
-import { MultiCombobox } from "@/containers/countries/multicombobox";
+import CountryDetail from "@/containers/countries/countries-detail";
import Popup from "@/containers/popup";
-import { Button } from "@/components/ui/button";
-import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
-
-import CountriesTable from "./countries-table";
-
const CountryPopup = () => {
const [country] = useSyncCountry();
- const [countriesComparison, setComparisonCountries] = useSyncCountriesComparison();
-
- const { data: countriesData } = useGetCountries({
- "pagination[pageSize]": 100,
- sort: "name:asc",
- });
-
- const COUNTRY = countriesData?.data?.find((c) => c.attributes?.iso3 === country);
return (
-
-
-
-
-
-
- {!!countriesComparison?.length && (
- setComparisonCountries([])} variant="destructive-outline">
- Clear
-
- )}
-
-
-
-
-
-
-
- Open Table detail
-
-
-
-
-
-
-
-
- Download data
-
-
-
-
-
-
-
-
+
);
};
diff --git a/client/src/containers/countries/utils.ts b/client/src/containers/countries/utils.ts
index 2d5de8bd..2a905884 100644
--- a/client/src/containers/countries/utils.ts
+++ b/client/src/containers/countries/utils.ts
@@ -1,4 +1,4 @@
-import { isDatasetValueProperty } from "@/lib/datasets";
+import { isDatasetValueProperty } from "@/lib/utils/datasets";
import { useGetCountries } from "@/types/generated/country";
import { useGetDatasets } from "@/types/generated/dataset";
@@ -65,7 +65,14 @@ const useTableData = () => {
dataset: { id: { $in: datasets } },
country: { iso3: { $in: [country, ...countriesComparison] } },
},
- populate: "dataset,country,resources",
+ "pagination[pageSize]": 300,
+ populate: {
+ country: {
+ fields: ["name", "iso3"],
+ },
+ resources: true,
+ dataset: true,
+ },
},
getDatasetParams.options,
);
@@ -112,11 +119,11 @@ const useTableData = () => {
: undefined;
// If is not a resource dataset get the value
- const valueType = attributes?.value_type && `value_${attributes?.value_type}`;
+ const value_type = attributes?.value_type && `value_${attributes?.value_type}`;
const value =
!isResource &&
- isDatasetValueProperty(valueType) &&
- datasetValue?.attributes?.[valueType];
+ isDatasetValueProperty(value_type) &&
+ datasetValue?.attributes?.[value_type];
const country = countriesData?.data?.find((c1) => c1.attributes?.iso3 === c);
return {
iso3: c,
diff --git a/client/src/containers/dashboard-header/index.tsx b/client/src/containers/dashboard-header/index.tsx
new file mode 100644
index 00000000..68126667
--- /dev/null
+++ b/client/src/containers/dashboard-header/index.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import Image from "next/image";
+import Link from "next/link";
+
+import { useSession, signOut } from "next-auth/react";
+import { LuLogOut } from "react-icons/lu";
+
+import { cn } from "@/lib/classnames";
+
+import { useSyncSearchParams } from "@/app/store";
+
+import { buttonVariants } from "@/components/ui/button";
+
+const DashboardHeader = (): JSX.Element => {
+ const { data: session } = useSession();
+
+ const URLparams = useSyncSearchParams();
+
+ return (
+
+
+
+
+
+
+ {session?.user.email}
+
+ signOut()}>
+ Log out
+
+
+
+
+
+ Open Map
+
+
+
+ );
+};
+
+export default DashboardHeader;
diff --git a/client/src/containers/dashboard/admin/index.tsx b/client/src/containers/dashboard/admin/index.tsx
new file mode 100644
index 00000000..2d57c8eb
--- /dev/null
+++ b/client/src/containers/dashboard/admin/index.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+import PendingChangesAdmin from "@/containers/datasets/pending-changes-admin";
+import PersonalData from "@/containers/personal-data";
+
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+
+export default function DashboardContentAdmin() {
+ return (
+
+
+ Suggested updates
+ Account information
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/containers/dashboard/authenticated/index.tsx b/client/src/containers/dashboard/authenticated/index.tsx
new file mode 100644
index 00000000..7db0421b
--- /dev/null
+++ b/client/src/containers/dashboard/authenticated/index.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+import DatasetPendingChanges from "@/containers/datasets/pending-changes-contributor";
+import PersonalData from "@/containers/personal-data";
+
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+
+export default function DatasetPendingChangesContentContributor() {
+ return (
+
+
+ Suggested updates
+ Account information
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/containers/dashboard/index.tsx b/client/src/containers/dashboard/index.tsx
new file mode 100644
index 00000000..27d48e69
--- /dev/null
+++ b/client/src/containers/dashboard/index.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import dynamic from "next/dynamic";
+
+import { useSession } from "next-auth/react";
+
+import type { UsersPermissionsRole, UsersPermissionsUser } from "@/types/generated/strapi.schemas";
+import { useGetUsersId } from "@/types/generated/users-permissions-users-roles";
+
+export default function DashboardContent() {
+ const { data: session } = useSession();
+ const { data: meData } = useGetUsersId(`${session?.user?.id}`, {
+ populate: "role",
+ });
+
+ const ME_DATA = meData as UsersPermissionsUser & { role: UsersPermissionsRole };
+
+ const role = ME_DATA?.role?.type || "admin";
+
+ const DynamicContent = dynamic(() => import(`@/containers/dashboard/${role}`), {
+ ssr: false,
+ });
+
+ return (
+
+
+
+ );
+}
diff --git a/client/src/containers/datasets/changes-to-approve/approve-changes-form-legend/index.tsx b/client/src/containers/datasets/changes-to-approve/approve-changes-form-legend/index.tsx
new file mode 100644
index 00000000..05472108
--- /dev/null
+++ b/client/src/containers/datasets/changes-to-approve/approve-changes-form-legend/index.tsx
@@ -0,0 +1,42 @@
+"use client";
+
+import { Change } from "@/components/forms/dataset/data";
+
+export default function ApproveChangesFormLegend({
+ isNewDataset,
+ changes,
+ status,
+ message,
+}: {
+ isNewDataset: boolean;
+ changes: string[] | Change[];
+ status?: "approved" | "pending" | "declined";
+ message?: string;
+}) {
+ return (
+
+
+
+ {!isNewDataset && status !== "declined" &&
}
+ {!isNewDataset && status !== "declined" &&
New changes }
+ {isNewDataset && status !== "declined" &&
Changes pending to be approved }
+ {status === "declined" && (
+
+
+ Declined
+
+ {message &&
{message}
}
+
+ )}
+
+ {!isNewDataset && status !== "declined" && (
+
+ {changes?.length > 0
+ ? "Changes summary. Please see the attached recommended edit. If any further changes are required to complete the submission and make it ready for upload, please make the adjustments here."
+ : "No changes have been applied."}
+
+ )}
+
+
+ );
+}
diff --git a/client/src/containers/datasets/changes-to-approve/colors-content/index.tsx b/client/src/containers/datasets/changes-to-approve/colors-content/index.tsx
new file mode 100644
index 00000000..bafd127f
--- /dev/null
+++ b/client/src/containers/datasets/changes-to-approve/colors-content/index.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import ApproveChangesFormLegend from "@/containers/datasets/changes-to-approve/approve-changes-form-legend";
+
+import DatasetColorsForm from "@/components/forms/dataset/colors";
+import type { Data } from "@/components/forms/dataset/types";
+
+export default function ColorsContentToApprove({
+ data,
+ id,
+ isNewDataset,
+ changes,
+ handleSubmit,
+ status,
+ message,
+}: {
+ data: Data;
+ id: string;
+ isNewDataset: boolean;
+ changes: string[];
+ handleSubmit: (data: Data["colors"]) => void;
+ status: "approved" | "pending" | "declined" | undefined;
+ message?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/client/src/containers/datasets/changes-to-approve/data-content/index.tsx b/client/src/containers/datasets/changes-to-approve/data-content/index.tsx
new file mode 100644
index 00000000..0a732822
--- /dev/null
+++ b/client/src/containers/datasets/changes-to-approve/data-content/index.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import ApproveChangesFormLegend from "@/containers/datasets/changes-to-approve/approve-changes-form-legend";
+
+import DatasetDataForm, { Change } from "@/components/forms/dataset/data";
+import type { Data } from "@/components/forms/dataset/types";
+
+export default function DataContentToApprove({
+ data,
+ id,
+ isNewDataset,
+ changes,
+ handleSubmit,
+ status,
+ message,
+}: {
+ data: Data;
+ id: string;
+ isNewDataset: boolean;
+ changes: string[] | Change[];
+ handleSubmit: (data: Data["data"]) => void;
+ status: "approved" | "pending" | "declined" | undefined;
+ message?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/client/src/containers/datasets/changes-to-approve/index.tsx b/client/src/containers/datasets/changes-to-approve/index.tsx
new file mode 100644
index 00000000..5b66231f
--- /dev/null
+++ b/client/src/containers/datasets/changes-to-approve/index.tsx
@@ -0,0 +1,568 @@
+"use client";
+
+import { useState, useCallback, useMemo } from "react";
+
+import { useForm } from "react-hook-form";
+
+import { toast } from "react-toastify";
+
+import Link from "next/link";
+import { useParams, useRouter } from "next/navigation";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useQueryClient } from "@tanstack/react-query";
+import { useSession } from "next-auth/react";
+import { z } from "zod";
+
+import { getDataParsed } from "@/lib/utils/datasets";
+import { formatDate } from "@/lib/utils/formats";
+import { compareDatasetsDataObjects, getObjectDifferences } from "@/lib/utils/objects";
+
+import { useGetDatasetsId } from "@/types/generated/dataset";
+import {
+ getGetDatasetEditSuggestionsIdQueryKey,
+ useGetDatasetEditSuggestionsId,
+} from "@/types/generated/dataset-edit-suggestion";
+import { usePutDatasetEditSuggestionsId } from "@/types/generated/dataset-edit-suggestion";
+import { useGetDatasetValues } from "@/types/generated/dataset-value";
+import type {
+ CategoryResponse,
+ DatasetEditSuggestion,
+ Resource,
+ UsersPermissionsRole,
+ UsersPermissionsUser,
+} from "@/types/generated/strapi.schemas";
+
+import { useGetUsersId } from "@/types/generated/users-permissions-users-roles";
+
+import { INITIAL_DATASET_VALUES } from "@/app/store";
+
+import { Data, VALUE_TYPE } from "@/components/forms/dataset/types";
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogTrigger, DialogContent } from "@/components/ui/dialog";
+import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+
+import { updateOrCreateDataset } from "@/services/datasets";
+
+import ColorsContentToApprove from "./colors-content";
+import DataContentToApprove from "./data-content";
+import SettingsContentToApprove from "./settings-content";
+import { DialogTitle } from "@radix-ui/react-dialog";
+import { Textarea } from "@/components/ui/textarea";
+
+type TabsProps = "settings" | "data" | "colors";
+
+export default function FormToApprove() {
+ const [tab, setTab] = useState("settings");
+ const { data: session } = useSession();
+ const params = useParams();
+ const { push } = useRouter();
+ const queryClient = useQueryClient();
+ const { id } = params;
+
+ const { data: datasetDataPendingToApprove } = useGetDatasetEditSuggestionsId(Number(id), {
+ populate: "*",
+ });
+
+ const datasetId = datasetDataPendingToApprove?.data?.attributes?.dataset?.data?.id;
+
+ // Check previous data for that dataset
+ const { data: datasetData } = useGetDatasetsId(
+ Number(datasetId) || Number(id),
+ {
+ populate: "*",
+ },
+ {
+ query: {
+ enabled: !!datasetId || !!id,
+ },
+ },
+ );
+
+ const { data: meData } = useGetUsersId(`${session?.user?.id}`, {
+ populate: "role",
+ });
+ const ME_DATA = meData as UsersPermissionsUser & { role: UsersPermissionsRole };
+
+ const { data: datasetValuesData } = useGetDatasetValues(
+ {
+ filters: {
+ dataset: id,
+ },
+ "pagination[pageSize]": 300,
+ populate: {
+ country: {
+ fields: ["name", "iso3"],
+ },
+ resources: true,
+ },
+ },
+ {
+ query: {
+ enabled: !!id,
+ },
+ },
+ );
+
+ const { mutate: mutatePutDatasetEditSuggestion } = usePutDatasetEditSuggestionsId({
+ mutation: {
+ onSuccess: (data) => {
+ queryClient.invalidateQueries({
+ queryKey: getGetDatasetEditSuggestionsIdQueryKey(Number(id)),
+ });
+ console.info("Success updating dataset:", data);
+ if (ME_DATA?.role?.type === "authenticated") {
+ toast.success("Success updating dataset suggestion");
+ push(`/dashboard`);
+ }
+ if (
+ data?.data?.attributes?.review_status === "declined" ||
+ data?.data?.attributes?.review_status === "pending"
+ ) {
+ push(`/dashboard`);
+ }
+ push(`/`);
+ },
+ onError: (error) => {
+ toast.error("There was a problem updating the dataset suggestion");
+ console.error("Error updating dataset:", error);
+ },
+ },
+ request: {},
+ });
+
+ const previousDataSource = datasetDataPendingToApprove || datasetData;
+
+ const previousData = useMemo(() => {
+ if (!previousDataSource) {
+ return null;
+ }
+
+ const settings = {
+ name: datasetDataPendingToApprove?.data?.attributes?.name || "",
+ description: datasetDataPendingToApprove?.data?.attributes?.description || "",
+ value_type: datasetDataPendingToApprove?.data?.attributes?.value_type || undefined,
+ category: datasetDataPendingToApprove?.data?.attributes?.category?.data?.id || undefined,
+ unit: datasetDataPendingToApprove?.data?.attributes?.unit,
+ };
+
+ const data =
+ (datasetDataPendingToApprove?.data?.attributes?.data as Data["data"]) ||
+ datasetValuesData?.data?.reduce(
+ (acc, curr) => {
+ const countryIso = curr?.attributes?.country?.data?.attributes?.iso3;
+
+ if (previousDataSource?.data?.attributes?.value_type === "number") {
+ return { ...acc, [`${countryIso}`]: curr?.attributes?.value_number };
+ }
+
+ if (previousDataSource?.data?.attributes?.value_type === "text") {
+ return { ...acc, [`${countryIso}`]: curr?.attributes?.value_text };
+ }
+
+ if (previousDataSource?.data?.attributes?.value_type === "boolean") {
+ return { ...acc, [`${countryIso}`]: curr?.attributes?.value_boolean };
+ }
+
+ if (previousDataSource?.data?.attributes?.value_type === "resource") {
+ return {
+ ...acc,
+ [`${countryIso}`]: curr?.attributes?.resources?.data?.map(
+ ({ attributes }) => attributes as Resource,
+ ),
+ };
+ }
+
+ return acc;
+ },
+ {} as Data["data"],
+ ) ||
+ {};
+
+ const colors =
+ datasetDataPendingToApprove?.data?.attributes?.colors ||
+ (previousDataSource?.data?.attributes?.layers?.data || [])[0]?.attributes?.colors ||
+ ({} as Data["colors"]);
+ return { settings, data, colors };
+ }, [datasetValuesData, previousDataSource]);
+
+ const DATA_PREVIOUS_VALUES = useMemo(() => {
+ previousDataSource?.data?.attributes || ({} as DatasetEditSuggestion);
+
+ return {
+ settings: {
+ ...datasetData?.data?.attributes,
+ category: datasetData?.data?.attributes?.category?.data?.id,
+ value_type: datasetData?.data?.attributes?.value_type as VALUE_TYPE,
+ },
+ data:
+ {
+ ...datasetValuesData?.data?.reduce(
+ (acc, curr) => {
+ const countryIso = curr?.attributes?.country?.data?.attributes?.iso3;
+
+ if (datasetData?.data?.attributes?.value_type === "number") {
+ return { ...acc, [`${countryIso}`]: curr?.attributes?.value_number };
+ }
+
+ if (datasetData?.data?.attributes?.value_type === "text") {
+ return { ...acc, [`${countryIso}`]: curr?.attributes?.value_text };
+ }
+
+ if (datasetData?.data?.attributes?.value_type === "boolean") {
+ return { ...acc, [`${countryIso}`]: curr?.attributes?.value_boolean };
+ }
+
+ if (previousDataSource?.data?.attributes?.value_type === "resource") {
+ return {
+ ...acc,
+ [`${countryIso}`]: curr?.attributes?.resources?.data?.map(
+ ({ attributes }) => attributes as Resource,
+ ),
+ };
+ }
+ return acc;
+ },
+ {} as Data["data"],
+ ),
+ } || {},
+ colors: datasetData?.data?.attributes?.layers?.data?.[0]?.attributes?.colors,
+ } as Data;
+ }, [datasetData, datasetValuesData, previousDataSource?.data?.attributes?.value_type]);
+
+
+ const PENDING_TO_APPROVE_DATA = useMemo(() => {
+ datasetDataPendingToApprove?.data?.attributes || ({} as DatasetEditSuggestion);
+
+ return {
+ settings: {
+ ...datasetDataPendingToApprove?.data?.attributes,
+ category: datasetDataPendingToApprove?.data?.attributes?.category?.data?.id,
+ value_type: datasetDataPendingToApprove?.data?.attributes?.value_type as VALUE_TYPE,
+ },
+ data: datasetDataPendingToApprove?.data?.attributes?.data || {},
+ // {
+ // ...datasetValuesData?.data?.reduce(
+ // (acc, curr) => {
+ // const countryIso = curr?.attributes?.country?.data?.attributes?.iso3;
+
+ // if (datasetData?.data?.attributes?.value_type === "number") {
+ // return { ...acc, [`${countryIso}`]: curr?.attributes?.value_number };
+ // }
+
+ // if (datasetData?.data?.attributes?.value_type === "text") {
+ // return { ...acc, [`${countryIso}`]: curr?.attributes?.value_text };
+ // }
+
+ // if (datasetData?.data?.attributes?.value_type === "boolean") {
+ // return { ...acc, [`${countryIso}`]: curr?.attributes?.value_boolean };
+ // }
+
+ // if (previousDataSource?.data?.attributes?.value_type === "resource") {
+ // return {
+ // ...acc,
+ // [`${countryIso}`]: curr?.attributes?.resources?.data?.map(
+ // ({ attributes }) => attributes as Resource,
+ // ),
+ // };
+ // }
+ // return acc;
+ // },
+ // {} as Data["data"],
+ // ),
+ // } || {},
+ colors: datasetData?.data?.attributes?.layers?.data?.[0]?.attributes?.colors,
+ } as Data;
+ }, [datasetData, datasetValuesData, previousDataSource?.data?.attributes?.value_type]);
+
+ const [formValues, setFormValues] = useState(PENDING_TO_APPROVE_DATA);
+
+
+ const formSchema = z.object({
+ message: z.string().min(1, { message: "Please provide a reason for the rejection" }),
+ });
+
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ message: "",
+ },
+ });
+
+ const ControlsStateId = {
+ settings: "dataset-settings-approve-edition",
+ data: "dataset-data-approve-edition",
+ colors: "dataset-colors-approve-edition",
+ } satisfies { [key in TabsProps]: string };
+
+ const handleReject = useCallback((values: z.infer) => {
+ if (ME_DATA?.role?.type === "admin" && datasetDataPendingToApprove?.data?.id) {
+ mutatePutDatasetEditSuggestion({
+ id: datasetDataPendingToApprove?.data?.id,
+ data: {
+ data: {
+ review_status: "declined",
+ review_decision_details: values.message,
+ },
+ },
+ });
+ push(`/dashboard`);
+ }
+ }, []);
+
+ const handleSettingsSubmit = useCallback(
+ (values: Data["settings"]) => {
+ setFormValues({ ...formValues, settings: values });
+ setTab("data");
+ },
+ [formValues],
+ );
+
+ const handleDataSubmit = useCallback(
+ (values: Data["data"]) => {
+ setFormValues({ ...formValues, data: values });
+ setTab("colors");
+ },
+ [formValues],
+ );
+
+ const handleColorsSubmit = useCallback(
+ (values: Data["colors"]) => {
+ const data = { ...formValues, colors: values };
+ setFormValues(data);
+
+ // not updating correctly
+ if (ME_DATA?.role?.type === "authenticated" && datasetDataPendingToApprove?.data?.id) {
+ const { value_type } = data?.settings || {};
+
+ const parsedData = getDataParsed(value_type, data);
+
+ mutatePutDatasetEditSuggestion({
+ id: datasetDataPendingToApprove?.data?.id,
+ data: {
+ data: {
+ ...data.settings,
+ category: data?.settings?.category as number,
+ value_type: data.settings?.value_type as VALUE_TYPE,
+ data: data.data,
+ colors: data.colors,
+ review_status: "pending",
+ },
+ },
+ });
+ }
+
+ if (ME_DATA?.role?.type === "admin" && session?.apiToken) {
+ const { value_type } = data?.settings || {};
+ const parsedData = getDataParsed(value_type, data);
+
+ updateOrCreateDataset(
+ {
+ ...(id && !datasetDataPendingToApprove && { dataset_id: id }),
+ ...(id &&
+ !!datasetDataPendingToApprove && {
+ dataset_id: datasetData?.data?.id,
+ }),
+
+ ...parsedData,
+ // @ts-expect-error TO-DO - fix types
+ dataset_edit_suggestion_ids: parsedData?.dataset_edit_suggestions?.data.map(
+ (d: { id: number }) => d?.id,
+ ),
+ },
+ session?.apiToken,
+ // to do review data + change sug status
+ )
+ .then((data) => {
+ console.info("Success creating dataset:", data);
+ toast.success("Success creating dataset");
+
+ if (datasetDataPendingToApprove?.data?.id) {
+ mutatePutDatasetEditSuggestion({
+ id: datasetDataPendingToApprove?.data?.id,
+ data: {
+ data: {
+ ...data.settings,
+ value_type: data.value_type,
+ data: data.data,
+ colors: data.colors,
+ review_status: "approved",
+ },
+ },
+ });
+ }
+ setFormValues(INITIAL_DATASET_VALUES);
+ push(`/`);
+ })
+ .catch((error: Error) => {
+ if (error) {
+ toast.error("There was a problem creating the dataset");
+ console.error("Error creating dataset:", error);
+ }
+ });
+ }
+ },
+ [
+ ME_DATA,
+ formValues,
+ datasetDataPendingToApprove,
+ mutatePutDatasetEditSuggestion,
+ datasetData?.data?.id,
+ id,
+ session?.apiToken,
+ push,
+ ],
+ );
+
+ const isNewDataset = !datasetDataPendingToApprove?.data?.attributes?.dataset?.data;
+ interface ParsedSettings extends Omit {
+ category: CategoryResponse;
+ }
+
+ // Need to parse data that comes from relations to be able to compare it
+ const parseSettings = {
+ ...previousData?.settings,
+ // @ts-expect-error TO-DO - fix types - category comes from a relation
+ category: previousData?.settings?.category?.data?.id || previousData?.settings?.category,
+ } as ParsedSettings;
+
+ const parsedFormValues = {
+ ...formValues?.settings,
+ // @ts-expect-error TO-DO - fix types - category comes from a relation
+ category: formValues?.settings?.category?.data?.id || previousData?.settings?.category,
+ } as ParsedSettings;
+
+ const settingsChanges = !previousData?.settings
+ ? []
+ : getObjectDifferences(parsedFormValues, parseSettings);
+
+ const dataChanges =
+ !previousData?.data && !formValues.settings.value_type
+ ? []
+ : compareDatasetsDataObjects(
+ formValues.data,
+ previousData?.data,
+ formValues.settings?.value_type,
+ );
+
+ const colorsChanges = !previousData?.colors
+ ? []
+ : getObjectDifferences(formValues.colors, previousData?.colors);
+
+ return (
+ <>
+
+
+
{formValues?.settings?.name}
+ {formValues?.settings?.updatedAt && (
+
+ Last update: {formatDate(formValues?.settings?.updatedAt as string)}
+
+ )}
+
+
+
+
+ Cancel
+
+
+
+ {ME_DATA?.role?.type === "admin" && (
+
+ e.stopPropagation()} asChild>
+
+ Reject
+
+
+
+
+ Reasons for Suggestion Rejection
+
+
+ (
+
+
+
+
+
+
+ )}
+ />
+
+
+
+ Submit
+
+
+
+
+
+
+ )}
+
+
+ {ME_DATA?.role?.type === "authenticated" ? "Submit" : "Approve"}
+
+
+
+ setTab(e as TabsProps)}
+ >
+
+ Settings
+ Data
+ Colors
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/client/src/containers/datasets/changes-to-approve/settings-content/index.tsx b/client/src/containers/datasets/changes-to-approve/settings-content/index.tsx
new file mode 100644
index 00000000..7c7a328a
--- /dev/null
+++ b/client/src/containers/datasets/changes-to-approve/settings-content/index.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import ApproveChangesFormLegend from "@/containers/datasets/changes-to-approve/approve-changes-form-legend";
+
+import DatasetSettingsForm from "@/components/forms/dataset/settings";
+import { Data } from "@/components/forms/dataset/types";
+
+export default function SettingsContentToApprove({
+ data,
+ id,
+ isNewDataset,
+ changes,
+ handleSubmit,
+ status,
+ message,
+}: {
+ data: Data;
+ id: string;
+ isNewDataset: boolean;
+ changes: string[];
+ handleSubmit: (data: Data["settings"]) => void;
+ status: "approved" | "pending" | "declined" | undefined;
+ message?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/client/src/containers/datasets/edit/index.tsx b/client/src/containers/datasets/edit/index.tsx
new file mode 100644
index 00000000..c67492cf
--- /dev/null
+++ b/client/src/containers/datasets/edit/index.tsx
@@ -0,0 +1,339 @@
+"use client";
+
+import { useCallback, useMemo } from "react";
+
+import { toast } from "react-toastify";
+
+import { useParams, useRouter } from "next/navigation";
+
+import { useAtom } from "jotai";
+import { useSession } from "next-auth/react";
+
+import { getDataParsed } from "@/lib/utils/datasets";
+
+import { useGetDatasetsId } from "@/types/generated/dataset";
+import {
+ useGetDatasetEditSuggestionsId,
+ usePostDatasetEditSuggestions,
+ usePutDatasetEditSuggestionsId,
+} from "@/types/generated/dataset-edit-suggestion";
+import { useGetDatasetValues } from "@/types/generated/dataset-value";
+import type {
+ DatasetValueListResponse,
+ UsersPermissionsRole,
+ UsersPermissionsUser,
+ DatasetEditSuggestionResponse,
+} from "@/types/generated/strapi.schemas";
+import { useGetUsersId } from "@/types/generated/users-permissions-users-roles";
+
+import { datasetStepAtom, datasetValuesAtom, INITIAL_DATASET_VALUES } from "@/app/store";
+
+import DatasetColorsForm from "@/components/forms/dataset/colors";
+import DatasetDataForm from "@/components/forms/dataset/data";
+import DatasetSettingsForm from "@/components/forms/dataset/settings";
+import { Data, VALUE_TYPE } from "@/components/forms/dataset/types";
+
+import { updateOrCreateDataset } from "@/services/datasets";
+
+const getDatasetValues = (
+ editionFromSuggestion: boolean,
+ datasetValuesData: DatasetValueListResponse | undefined,
+ datasetEditData: DatasetEditSuggestionResponse | undefined,
+ type: VALUE_TYPE | undefined,
+) => {
+ if (editionFromSuggestion) {
+ return datasetEditData?.data?.attributes?.data as Data["data"];
+ } else {
+ return (datasetValuesData?.data?.reduce(
+ (acc, curr) => {
+ const countryIso = curr?.attributes?.country?.data?.attributes?.iso3;
+
+ if (type === "number") {
+ return { ...acc, [`${countryIso}`]: curr?.attributes?.value_number };
+ }
+
+ if (type === "text") {
+ return { ...acc, [`${countryIso}`]: curr?.attributes?.value_text };
+ }
+
+ if (type === "boolean") {
+ return { ...acc, [`${countryIso}`]: curr?.attributes?.value_boolean ? true : false };
+ }
+
+ if (type === "resource") {
+ return {
+ ...acc,
+ [`${countryIso}`]: curr?.attributes?.resources?.data?.map(({ attributes }) => ({
+ description: attributes?.description,
+ link_title: attributes?.link_title,
+ link_url: attributes?.link_url,
+ })),
+ };
+ }
+ return acc;
+ },
+ {} as Data["data"],
+ ) || {}) as Data["data"];
+ }
+};
+
+export default function EditDatasetForm() {
+ const { data: session } = useSession();
+ const { push } = useRouter();
+
+ const params = useParams();
+ const { id } = params;
+ const [currentStep, setCurrentStep] = useAtom(datasetStepAtom);
+ const [formValues, setFormValues] = useAtom(datasetValuesAtom);
+
+ const { data: meData } = useGetUsersId(`${session?.user?.id}`, {
+ populate: "role",
+ });
+ const ME_DATA = meData as UsersPermissionsUser & { role: UsersPermissionsRole };
+
+ const { data: datasetData } = useGetDatasetsId(Number(id), {
+ populate: "*",
+ });
+
+ // useGetDatasetId returns dataset values but there is no relation within the iso3 field
+ // so we need to use useGetDatasetValues to get the country iso3 field for dataset values
+ const { data: datasetValuesData } = useGetDatasetValues({
+ filters: {
+ dataset: id,
+ },
+ "pagination[pageSize]": 300,
+ populate: {
+ country: {
+ fields: ["name", "iso3"],
+ },
+ resources: true,
+ dataset_values: true,
+ },
+ });
+
+ const { data: datasetEditData } = useGetDatasetEditSuggestionsId(Number(id), {
+ populate: "*",
+ });
+
+ const { mutate: mutatePutDatasetEditSuggestionId } = usePutDatasetEditSuggestionsId({
+ mutation: {
+ onSuccess: (data) => {
+ console.info("Success updating dataset suggestion:", data);
+ push(`/dashboard`);
+ },
+ onError: (error) => {
+ console.error("Error updating dataset suggestion:", error);
+ },
+ },
+ request: {},
+ });
+
+ const { mutate: mutatePostDatasetEditSuggestion } = usePostDatasetEditSuggestions({
+ mutation: {
+ onSuccess: (data) => {
+ console.info("Success creating dataset:", data);
+ setCurrentStep(1);
+ push(`/dashboard`);
+ },
+ onError: (error) => {
+ console.error("Error creating dataset:", error);
+ },
+ },
+ request: {},
+ });
+
+ const previousData = datasetEditData?.data?.attributes || datasetData?.data?.attributes;
+ const editionFromSuggestion = !!datasetEditData;
+
+ useMemo(() => {
+ const settings = {
+ name: previousData?.name || "",
+ description: previousData?.description || "",
+ value_type: previousData?.value_type || undefined,
+ category: previousData?.category?.data?.id || undefined,
+ unit: previousData?.unit || undefined,
+ };
+
+ const data = getDatasetValues(
+ editionFromSuggestion,
+ datasetValuesData,
+ datasetEditData,
+ datasetData?.data?.attributes?.value_type,
+ );
+
+ const colors = (datasetEditData?.data?.attributes?.colors ||
+ (datasetData?.data?.attributes?.layers?.data || [])[0]?.attributes?.colors) as Data["colors"];
+
+ setFormValues({ settings, data, colors });
+ }, [
+ datasetData,
+ datasetValuesData,
+ datasetEditData,
+ editionFromSuggestion,
+ previousData,
+ setFormValues,
+ ]);
+
+ const handleSettingsSubmit = useCallback(
+ (values: Data["settings"]) => {
+ setFormValues({ ...formValues, settings: values });
+ setCurrentStep(2);
+ },
+ [formValues, setFormValues, setCurrentStep],
+ );
+
+ const handleDataSubmit = useCallback(
+ (values: Data["data"]) => {
+ setFormValues({ ...formValues, data: values });
+ setCurrentStep(3);
+ },
+ [formValues, setFormValues, setCurrentStep],
+ );
+
+ const handleColorsSubmit = useCallback(
+ (values: Data["colors"]) => {
+ const data = { ...formValues, colors: values };
+ setFormValues(data);
+
+ if (ME_DATA?.role?.type === "authenticated") {
+ if (!id || (!!id && !datasetEditData)) {
+ mutatePostDatasetEditSuggestion({
+ data: {
+ // @ts-expect-error TO-DO - fix types
+ data: {
+ ...data.settings,
+ value_type: data.settings.value_type,
+ review_status: "pending",
+ colors: data.colors,
+ data: {
+ ...data.data,
+ },
+ ...(id &&
+ !datasetEditData && {
+ dataset: {
+ connect: [+id],
+ disconnect: [],
+ },
+ }),
+ },
+ },
+ });
+ }
+
+ if (!!id && !!datasetEditData) {
+ const categoryId =
+ typeof data.settings.category === "number"
+ ? data.settings?.category
+ : data?.settings?.category?.data?.id;
+
+ mutatePutDatasetEditSuggestionId({
+ id: +id,
+ data: {
+ data: {
+ ...data.settings,
+ value_type: data.settings.value_type,
+ category: categoryId as number,
+ review_status: "pending",
+ colors: data.colors,
+ data: {
+ ...data.data,
+ },
+ },
+ },
+ });
+ }
+ }
+
+ if (ME_DATA?.role?.type === "admin" && session?.apiToken) {
+ const { value_type } = data.settings;
+ const parsedData = getDataParsed(value_type, data);
+
+ updateOrCreateDataset(
+ {
+ ...(id && !datasetEditData && { dataset_id: id }),
+ ...(id &&
+ !!datasetEditData?.data?.id && {
+ dataset_id: +datasetEditData?.data?.id,
+ }),
+ ...parsedData,
+ category_ids: [data.settings.category],
+ },
+ session?.apiToken,
+ // to do review data + change sug status
+ )
+ .then(() => {
+ console.info("Success creating dataset:", data);
+ toast.success("Success creating dataset");
+ if (!!id && !!datasetEditData) {
+ mutatePutDatasetEditSuggestionId({
+ id: +id,
+ data: {
+ data: {
+ ...data.settings,
+ value_type: data.settings.value_type,
+ category: datasetEditData?.data?.attributes?.category?.data?.id,
+ review_status: "approved",
+ colors: data.colors,
+ data: {
+ ...data.data,
+ },
+ },
+ },
+ });
+ }
+ setFormValues(INITIAL_DATASET_VALUES);
+ push(`/`);
+ })
+ .catch((error: Error) => {
+ if (error) {
+ toast.error("There was a problem creating the dataset");
+ console.error("Error creating dataset:", error);
+ }
+ });
+ }
+ },
+ [
+ formValues,
+ setFormValues,
+ ME_DATA?.role?.type,
+ mutatePostDatasetEditSuggestion,
+ datasetEditData,
+ id,
+ mutatePutDatasetEditSuggestionId,
+ session?.apiToken,
+ push,
+ ],
+ );
+
+ return (
+ <>
+ {currentStep === 1 && (
+
+ )}
+ {currentStep === 2 && (
+
+ )}
+ {currentStep === 3 && (
+
+ )}
+ >
+ );
+}
diff --git a/client/src/containers/datasets/header.tsx b/client/src/containers/datasets/header.tsx
index b015331f..381fc659 100644
--- a/client/src/containers/datasets/header.tsx
+++ b/client/src/containers/datasets/header.tsx
@@ -1,14 +1,30 @@
"use client";
+import Link from "next/link";
+
+import { useSession } from "next-auth/react";
+
import { useSyncDatasets } from "@/app/store";
const DatasetsHeader = () => {
const [datasets] = useSyncDatasets();
+ const session = useSession();
+
return (
- All datasets
+
+
All datasets
+ {session.status === "authenticated" && (
+
+ Add new
+
+ )}
+
Active layers:
diff --git a/client/src/containers/datasets/item.tsx b/client/src/containers/datasets/item.tsx
index 8b9180c8..06d5db8d 100644
--- a/client/src/containers/datasets/item.tsx
+++ b/client/src/containers/datasets/item.tsx
@@ -2,7 +2,10 @@
import Markdown from "react-markdown";
+import Link from "next/link";
+
import { useAtomValue } from "jotai";
+import { useSession } from "next-auth/react";
import { LuInfo } from "react-icons/lu";
import { DatasetListResponseDataItem } from "@/types/generated/strapi.schemas";
@@ -19,6 +22,8 @@ const DatasetsItem = ({ id, attributes }: DatasetListResponseDataItem) => {
const [, setLayers] = useSyncLayers();
const datasetSearch = useAtomValue(datasetSearchAtom);
+ const { data: user } = useSession();
+
const handleToogle = () => {
const lys = attributes?.layers;
@@ -66,18 +71,29 @@ const DatasetsItem = ({ id, attributes }: DatasetListResponseDataItem) => {
{attributes?.name}
-
-
- e.stopPropagation()}>
-
-
-
-
-
- {attributes?.description}
-
-
-
+
+ {user && (
+ e.stopPropagation()}
+ >
+ Edit
+
+ )}
+
+
+ e.stopPropagation()}>
+
+
+
+
+
+ {attributes?.description}
+
+
+
+
);
};
diff --git a/client/src/containers/datasets/new/index.tsx b/client/src/containers/datasets/new/index.tsx
new file mode 100644
index 00000000..9df93f0e
--- /dev/null
+++ b/client/src/containers/datasets/new/index.tsx
@@ -0,0 +1,158 @@
+"use client";
+
+import { useCallback } from "react";
+
+import { toast } from "react-toastify";
+
+import { useRouter } from "next/navigation";
+
+import { useAtom } from "jotai";
+import { useSession } from "next-auth/react";
+
+import { getDataParsed } from "@/lib/utils/datasets";
+
+import { usePostDatasetEditSuggestions } 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 { datasetStepAtom, INITIAL_DATASET_VALUES, datasetValuesNewAtom } from "@/app/store";
+
+import DatasetColorsForm from "@/components/forms/dataset/colors";
+import DatasetDataForm from "@/components/forms/dataset/data";
+import DatasetSettingsForm from "@/components/forms/dataset/settings";
+import { Data } from "@/components/forms/dataset/types";
+
+import { updateOrCreateDataset } from "@/services/datasets";
+
+export default function NewDatasetForm() {
+ const { data: session } = useSession();
+
+ const { push } = useRouter();
+
+ const [step, setStep] = useAtom(datasetStepAtom);
+ // we are using useSate instead of useAtom because we need to reset the form values
+ // every time we enter as this is always going to be a new form, no need to pull values from anywhere else
+ // but need to keep the info through the steps
+ const [formValues, setFormValues] = useAtom(datasetValuesNewAtom);
+ const { data: meData } = useGetUsersId(`${session?.user?.id}`, {
+ populate: "role",
+ });
+ const ME_DATA = meData as UsersPermissionsUser & { role: UsersPermissionsRole };
+
+ // contributor role can just suggest changes
+ const { mutate: mutatePostDatasetEditSuggestion } = usePostDatasetEditSuggestions({
+ mutation: {
+ onSuccess: (data) => {
+ console.info("Success creating dataset:", data);
+ toast.success("Success creating dataset suggestion");
+ push(`/dashboard`);
+ setFormValues(INITIAL_DATASET_VALUES);
+ setStep(1);
+ },
+ onError: (error: Error) => {
+ console.error("Error creating dataset:", error);
+ toast.error("There was a problem creating the dataset suggestion");
+ },
+ },
+ request: {},
+ });
+
+ const handleSettingsSubmit = useCallback(
+ (values: Data["settings"]) => {
+ setFormValues({ ...formValues, settings: values });
+ setStep(2);
+ },
+ [formValues, setFormValues, setStep],
+ );
+
+ const handleDataSubmit = useCallback(
+ (values: Data["data"]) => {
+ setFormValues({ ...formValues, data: values });
+ setStep(3);
+ },
+ [formValues, setFormValues, setStep],
+ );
+
+ const handleColorsSubmit = useCallback(
+ (values: Data["colors"]) => {
+ const data = { ...formValues, colors: values };
+ setFormValues(data);
+
+ if (ME_DATA?.role?.type === "authenticated") {
+ mutatePostDatasetEditSuggestion({
+ data: {
+ data: {
+ ...data.settings,
+ value_type: data.settings.value_type,
+ data: data.data,
+ colors: data.colors,
+ review_status: "pending",
+ // @ts-expect-error TO-DO - fix types
+ categories: {
+ connect: [values.categories],
+ disconnect: [],
+ },
+ },
+ },
+ });
+ }
+
+ if (ME_DATA?.role?.type === "admin") {
+ const { value_type } = data.settings;
+ const parsedData = getDataParsed(value_type, data);
+ updateOrCreateDataset(parsedData, session?.apiToken as string)
+ .then(() => {
+ console.info("Success creating dataset:", data);
+ toast.success("Success creating dataset");
+ setFormValues(INITIAL_DATASET_VALUES);
+ push(`/`);
+ setStep(1);
+ })
+ .catch((error: Error) => {
+ if (error) {
+ toast.error("There was a problem creating the dataset");
+ console.error("Error creating dataset:", error);
+ }
+ });
+ }
+ },
+ [
+ formValues,
+ setFormValues,
+ ME_DATA,
+ mutatePostDatasetEditSuggestion,
+ session?.apiToken,
+ push,
+ setStep,
+ ],
+ );
+
+ return (
+ <>
+ {step === 1 && (
+
+ )}
+ {step === 2 && (
+
+ )}
+ {step === 3 && (
+
+ )}
+ >
+ );
+}
diff --git a/client/src/containers/datasets/pending-changes-admin/approved-changes-row/index.tsx b/client/src/containers/datasets/pending-changes-admin/approved-changes-row/index.tsx
new file mode 100644
index 00000000..ac5b6d4e
--- /dev/null
+++ b/client/src/containers/datasets/pending-changes-admin/approved-changes-row/index.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import { formatDate } from "@/lib/utils/formats";
+
+import {
+ extendedCollaboratorData,
+ extendedDataset,
+ extendedProjectData,
+ extendedToolData,
+} from "@/components/forms/dataset/types";
+import { TableCell, TableRow } from "@/components/ui/table";
+
+type ApprovedProps =
+ | extendedDataset
+ | extendedToolData
+ | extendedCollaboratorData
+ | extendedProjectData;
+
+export default function ApprovedContributorsRow(data: ApprovedProps) {
+ return (
+
+ {data.label}
+ {data.name}
+ {data.author?.data?.attributes?.email}
+
+
+ {data.review_status}
+
+
+
+
+ {data.updatedAt && data.createdAt && formatDate(data.updatedAt)}
+ {!data.updatedAt && data.createdAt && formatDate(data.createdAt)}
+
+
+
+ );
+}
diff --git a/client/src/containers/datasets/pending-changes-admin/index.tsx b/client/src/containers/datasets/pending-changes-admin/index.tsx
new file mode 100644
index 00000000..fd1ad8e0
--- /dev/null
+++ b/client/src/containers/datasets/pending-changes-admin/index.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import Link from "next/link";
+
+import { cn } from "@/lib/classnames";
+import { formatDate } from "@/lib/utils/formats";
+
+import { useGetCollaboratorEditSuggestions } from "@/types/generated/collaborator-edit-suggestion";
+import { useGetDatasetEditSuggestions } from "@/types/generated/dataset-edit-suggestion";
+import { useGetProjectEditSuggestions } from "@/types/generated/project-edit-suggestion";
+import { useGetToolEditSuggestions } from "@/types/generated/tool-edit-suggestion";
+
+import { DataTypes, Label, Route } from "@/components/forms/dataset/types";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import ApprovedChangesRow from "./approved-changes-row";
+import PendingDeclinedChangesRow from "./pending-declined-changes-row";
+export default function PendingChangesAdmin() {
+ const { data: datasetsDataSuggestions } = useGetDatasetEditSuggestions({
+ populate: "*",
+ });
+ const { data: otherToolDataSuggestions } = useGetToolEditSuggestions({
+ populate: "*",
+ });
+ const { data: collaboratorsDataSuggestions } = useGetCollaboratorEditSuggestions({
+ populate: "*",
+ });
+ const { data: projectsDataSuggestions } = useGetProjectEditSuggestions({
+ populate: "*",
+ });
+
+ const data: DataTypes = [
+ ...(datasetsDataSuggestions?.data?.map(({ id, attributes }) => ({
+ id,
+ ...attributes,
+ review_status: attributes?.review_status || "pending",
+ label: "Datasets" as Label,
+ route: "datasets/changes-to-approve" as Route,
+ })) || []),
+
+ ...(otherToolDataSuggestions?.data?.map(({ id, attributes }) => ({
+ id,
+ ...attributes,
+ review_status: attributes?.review_status || "pending",
+ label: "Tool" as Label,
+ route: "other-tools" as Route,
+ })) || []),
+
+ ...(collaboratorsDataSuggestions?.data?.map(({ id, attributes }) => ({
+ id,
+ ...attributes,
+ review_status: attributes?.review_status || "pending",
+ label: "Collaborator" as Label,
+ route: "collaborators" as Route,
+ })) || []),
+
+ ...(projectsDataSuggestions?.data?.map(({ id, attributes }) => ({
+ id,
+ ...attributes,
+ review_status: attributes?.review_status || "pending",
+ label: "Project" as Label,
+ route: "projects" as Route,
+ })) || []),
+ ];
+
+ function orderAndFlattenData(data: DataTypes): DataTypes {
+ const statusOrder = ["pending", "approved", "declined"];
+
+ return data.sort((a, b) => {
+ const statusComparison =
+ statusOrder.indexOf(a.review_status) - statusOrder.indexOf(b.review_status);
+ if (statusComparison !== 0) {
+ return statusComparison;
+ }
+
+ const dateA = new Date(a.updatedAt || 0);
+ const dateB = new Date(b.updatedAt || 0);
+ return dateB.getTime() - dateA.getTime();
+ });
+ }
+
+ const orderedData = orderAndFlattenData(data);
+
+ return (
+
+
+
+
+ Change type
+ name
+ Author
+ State
+ Date
+
+
+
+ {!!orderedData.length &&
+ orderedData.map((d) => {
+ if (d.review_status !== "approved") {
+ return ;
+ } else {
+ return ;
+ }
+ })}
+
+
+
+ );
+}
diff --git a/client/src/containers/datasets/pending-changes-admin/pending-declined-changes-row/index.tsx b/client/src/containers/datasets/pending-changes-admin/pending-declined-changes-row/index.tsx
new file mode 100644
index 00000000..269b7964
--- /dev/null
+++ b/client/src/containers/datasets/pending-changes-admin/pending-declined-changes-row/index.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import Link from "next/link";
+
+import { cn } from "@/lib/classnames";
+import { formatDate } from "@/lib/utils/formats";
+
+import {
+ extendedToolData,
+ extendedDataset,
+ extendedCollaboratorData,
+ extendedProjectData,
+} from "@/components/forms/dataset/types";
+import { TableCell, TableRow } from "@/components/ui/table";
+
+type PendingChangesCell =
+ | extendedDataset
+ | extendedToolData
+ | extendedCollaboratorData
+ | extendedProjectData;
+
+export default function PendingDeclinedChangesContributorRow(data: PendingChangesCell) {
+ return (
+
+
+
+ {data.label}
+
+
+
+ {data.name}
+
+
+
+ {data.author?.data?.attributes?.email}
+
+
+
+
+
+ {data.review_status}
+
+
+
+
+
+ {data.updatedAt && data.createdAt && formatDate(data.updatedAt)}
+ {!data.updatedAt && data.createdAt && formatDate(data.createdAt)}
+
+
+
+ );
+}
diff --git a/client/src/containers/datasets/pending-changes-contributor/approved-changes-row/index.tsx b/client/src/containers/datasets/pending-changes-contributor/approved-changes-row/index.tsx
new file mode 100644
index 00000000..df929b51
--- /dev/null
+++ b/client/src/containers/datasets/pending-changes-contributor/approved-changes-row/index.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import { formatDate } from "@/lib/utils/formats";
+
+import {
+ extendedCollaboratorData,
+ extendedDataset,
+ extendedProjectData,
+ extendedToolData,
+} from "@/components/forms/dataset/types";
+import { TableCell, TableRow } from "@/components/ui/table";
+
+type ApprovedProps =
+ | extendedDataset
+ | extendedToolData
+ | extendedCollaboratorData
+ | extendedProjectData;
+
+export default function ApprovedContributorsRow(data: ApprovedProps) {
+ return (
+
+
+ {data.label}
+
+
+ {data.name}
+
+
+
+ {data.review_status}
+
+
+
+
+ {data.updatedAt && data.createdAt && formatDate(data.updatedAt)}
+ {!data.updatedAt && data.createdAt && formatDate(data.createdAt)}
+
+
+
+ );
+}
diff --git a/client/src/containers/datasets/pending-changes-contributor/index.tsx b/client/src/containers/datasets/pending-changes-contributor/index.tsx
new file mode 100644
index 00000000..60f85811
--- /dev/null
+++ b/client/src/containers/datasets/pending-changes-contributor/index.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import { useGetCollaboratorEditSuggestions } from "@/types/generated/collaborator-edit-suggestion";
+import { useGetDatasetEditSuggestions } from "@/types/generated/dataset-edit-suggestion";
+import { useGetProjectEditSuggestions } from "@/types/generated/project-edit-suggestion";
+import { useGetToolEditSuggestions } from "@/types/generated/tool-edit-suggestion";
+
+import { DataTypes, Label, Route } from "@/components/forms/dataset/types";
+import { Table, TableBody, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+
+import ApprovedChangesRow from "./approved-changes-row";
+import PendingDeclinedChangesRow from "./pending-declined-changes-row";
+
+export default function PendingChangesContributor() {
+ const { data: datasetsDataSuggestions } = useGetDatasetEditSuggestions();
+ const { data: otherToolDataSuggestions } = useGetToolEditSuggestions();
+ const { data: collaboratorsDataSuggestions } = useGetCollaboratorEditSuggestions();
+ const { data: projectsDataSuggestions } = useGetProjectEditSuggestions();
+ const data: DataTypes = [
+ ...(datasetsDataSuggestions?.data?.map(({ id, attributes }) => ({
+ id,
+ ...attributes,
+ review_status: attributes?.review_status || "pending",
+ label: "Datasets" as Label,
+ route: "datasets/edit" as Route,
+ })) || []),
+
+ ...(otherToolDataSuggestions?.data?.map(({ id, attributes }) => ({
+ id,
+ ...attributes,
+ review_status: attributes?.review_status || "pending",
+ label: "Tool" as Label,
+ route: "other-tools" as Route,
+ })) || []),
+
+ ...(collaboratorsDataSuggestions?.data?.map(({ id, attributes }) => ({
+ id,
+ ...attributes,
+ review_status: attributes?.review_status || "pending",
+ label: "Collaborator" as Label,
+ route: "collaborators" as Route,
+ })) || []),
+
+ ...(projectsDataSuggestions?.data?.map(({ id, attributes }) => ({
+ id,
+ ...attributes,
+ review_status: attributes?.review_status || "pending",
+ label: "Project" as Label,
+ route: "projects" as Route,
+ })) || []),
+ ];
+
+ function orderAndFlattenData(data: DataTypes): DataTypes {
+ const statusOrder = ["pending", "approved", "declined"];
+
+ return data.sort((a, b) => {
+ const statusComparison =
+ statusOrder.indexOf(a.review_status) - statusOrder.indexOf(b.review_status);
+ if (statusComparison !== 0) {
+ return statusComparison;
+ }
+
+ const dateA = new Date(a.updatedAt || 0);
+ const dateB = new Date(b.updatedAt || 0);
+ return dateB.getTime() - dateA.getTime();
+ });
+ }
+
+ const orderedData = orderAndFlattenData(data);
+
+ return (
+
+
Suggested updates
+
+
+
+ Change type
+ Name
+ State
+ Date
+
+
+
+ {!!orderedData.length &&
+ orderedData.map((d) => {
+ if (d.review_status !== "approved") {
+ return ;
+ } else {
+ return ;
+ }
+ })}
+
+
+
+ );
+}
diff --git a/client/src/containers/datasets/pending-changes-contributor/pending-declined-changes-row/index.tsx b/client/src/containers/datasets/pending-changes-contributor/pending-declined-changes-row/index.tsx
new file mode 100644
index 00000000..949721b9
--- /dev/null
+++ b/client/src/containers/datasets/pending-changes-contributor/pending-declined-changes-row/index.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import Link from "next/link";
+
+import { cn } from "@/lib/classnames";
+import { formatDate } from "@/lib/utils/formats";
+
+import {
+ extendedToolData,
+ extendedDataset,
+ extendedCollaboratorData,
+ extendedProjectData,
+} from "@/components/forms/dataset/types";
+import { TableCell, TableRow } from "@/components/ui/table";
+
+type PendingChangesCell =
+ | extendedDataset
+ | extendedToolData
+ | extendedCollaboratorData
+ | extendedProjectData;
+
+export default function PendingDeclinedChangesContributorRow(data: PendingChangesCell) {
+ return (
+
+
+
+ {data.label}
+
+
+
+ {data.name}
+
+
+
+
+ {data.review_status}
+
+
+
+
+
+ {data.updatedAt && data.createdAt && formatDate(data.updatedAt)}
+ {!data.updatedAt && data.createdAt && formatDate(data.createdAt)}
+
+
+
+ );
+}
diff --git a/client/src/containers/footer/index.tsx b/client/src/containers/footer/index.tsx
new file mode 100644
index 00000000..ed8f71da
--- /dev/null
+++ b/client/src/containers/footer/index.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+
+const Footer = (): JSX.Element => {
+ const pathname = usePathname();
+ return (
+
+
+ {pathname === "/signin" ? (
+
+ {"Don't"} have an account?
+
+ Register
+ {" "}
+
+ ) : (
+
+ Already have an account?
+
+ Log in
+ {" "}
+
+ )}
+
+
+ );
+};
+
+export default Footer;
diff --git a/client/src/containers/header/index.tsx b/client/src/containers/header/index.tsx
new file mode 100644
index 00000000..4d97df02
--- /dev/null
+++ b/client/src/containers/header/index.tsx
@@ -0,0 +1,14 @@
+"use client";
+
+import Image from "next/image";
+import Link from "next/link";
+
+const Header = (): JSX.Element => (
+
+
+
+
+
+);
+
+export default Header;
diff --git a/client/src/containers/map/index.tsx b/client/src/containers/map/index.tsx
index e769b748..6c848237 100644
--- a/client/src/containers/map/index.tsx
+++ b/client/src/containers/map/index.tsx
@@ -15,10 +15,17 @@ import MapSettingsManager from "@/containers/map-settings/manager";
import Map from "@/components/map";
import Controls from "@/components/map/controls";
+import EmbedControl from "@/components/map/controls/embed";
import SettingsControl from "@/components/map/controls/settings";
import ZoomControl from "@/components/map/controls/zoom";
-export default function MapContainer({ id = "default" }: { id?: string }) {
+export default function MapContainer({
+ id = "default",
+ embed = false,
+}: {
+ id?: string;
+ embed?: boolean;
+}) {
const [cursor, setCursor] = useState("");
const { [id]: map } = useMap();
@@ -141,9 +148,16 @@ export default function MapContainer({ id = "default" }: { id?: string }) {
>
-
-
-
+ {!embed && (
+
+
+
+ )}
+ {!embed && (
+
+ Embed
+
+ )}
diff --git a/client/src/containers/map/legend/item.tsx b/client/src/containers/map/legend/item.tsx
index ef26771a..7834af4a 100644
--- a/client/src/containers/map/legend/item.tsx
+++ b/client/src/containers/map/legend/item.tsx
@@ -71,6 +71,7 @@ const MapLegendItem = ({ id, ...props }: MapLegendItemProps) => {
filters: {
dataset: data?.data?.attributes?.dataset?.data?.id,
},
+ "pagination[pageSize]": 300,
populate: ["resources"],
},
{
diff --git a/client/src/containers/navigation/index.tsx b/client/src/containers/navigation/index.tsx
index 12b5c70b..4b075c15 100644
--- a/client/src/containers/navigation/index.tsx
+++ b/client/src/containers/navigation/index.tsx
@@ -4,22 +4,29 @@ import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
+import { LuUser2 } from "react-icons/lu";
+
import { cn } from "@/lib/classnames";
+import { useGetUsersMe } from "@/types/generated/users-permissions-users-roles";
+
import { useSyncSearchParams } from "@/app/store";
+import CollaboratorsSvg from "@/svgs/collaborators.svg";
import ExploreSVG from "@/svgs/explore.svg";
import OtherToolsSvg from "@/svgs/other-tools.svg";
import ProjectsSVG from "@/svgs/projects.svg";
-import CollaboratorsSvg from "@/svgs/collaborators.svg";
const Navigation = (): JSX.Element => {
const pathname = usePathname();
const sp = useSyncSearchParams();
+ const { data: user } = useGetUsersMe();
+
+ const userNameWithoutSpaces = user?.username?.replace(" ", "");
return (
-
+
@@ -135,6 +142,42 @@ const Navigation = (): JSX.Element => {
+
+
+
+
+
15,
+ })}
+ >
+ {user ? user?.username : "Log in"}
+
+
+
);
};
diff --git a/client/src/containers/other-tools/form/index.tsx b/client/src/containers/other-tools/form/index.tsx
new file mode 100644
index 00000000..85f4f423
--- /dev/null
+++ b/client/src/containers/other-tools/form/index.tsx
@@ -0,0 +1,525 @@
+"use client";
+
+import { useCallback } 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 { useSession } from "next-auth/react";
+import { z } from "zod";
+
+import { cn } from "@/lib/classnames";
+import { getObjectDifferences } from "@/lib/utils/objects";
+
+import { useDeleteOtherToolsId, useGetOtherToolsId } from "@/types/generated/other-tool";
+import { useGetOtherToolsCategories } from "@/types/generated/other-tools-category";
+import type { UsersPermissionsRole, UsersPermissionsUser } from "@/types/generated/strapi.schemas";
+import {
+ useGetToolEditSuggestionsId,
+ usePostToolEditSuggestions,
+ usePutToolEditSuggestionsId,
+ useDeleteToolEditSuggestionsId,
+} from "@/types/generated/tool-edit-suggestion";
+import { useGetUsersId } from "@/types/generated/users-permissions-users-roles";
+
+import { useSyncSearchParams } from "@/app/store";
+import CSVImport from "@/components/new-dataset/step-description/csv-import";
+import { GET_CATEGORIES_OPTIONS } from "@/constants/datasets";
+
+import DashboardFormWrapper from "@/components/forms/dataset/wrapper";
+import DashboardFormControls from "@/components/new-dataset/form-controls";
+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 { updateOrCreateOtherTools } from "@/services/other-tools";
+
+export default function ToolForm() {
+ const { push } = useRouter();
+ const URLParams = useSyncSearchParams();
+
+ const params = useParams();
+
+ const { id } = params;
+
+ const { data: categoriesData } = useGetOtherToolsCategories(GET_CATEGORIES_OPTIONS(), {
+ query: {
+ select: (data) =>
+ data?.data?.map((data) => ({
+ label: data.attributes?.name as string,
+ value: data.id as number,
+ })),
+ },
+ });
+ const { data } = useSession();
+ const user = data?.user;
+
+ const { data: meData } = useGetUsersId(`${user?.id}`, {
+ populate: "role",
+ });
+ const ME_DATA = meData as UsersPermissionsUser & { role: UsersPermissionsRole };
+
+ // if there is no id in the route, we are creating a new tool, no need to look for
+ // an existing tool
+ const { data: otherToolData } = useGetOtherToolsId(
+ +id,
+ {
+ populate: "*",
+ },
+ {
+ query: {
+ enabled: !!id,
+ },
+ },
+ );
+
+ const { data: editSuggestionIdData } = useGetToolEditSuggestionsId(
+ +id,
+ {
+ populate: "*",
+ },
+ {
+ query: {
+ enabled: !!id,
+ },
+ },
+ );
+
+ const { mutate: mutatePostToolEditSuggestion } = usePostToolEditSuggestions({
+ mutation: {
+ onSuccess: (data) => {
+ if (ME_DATA?.role?.type === "authenticated") {
+ console.info("Success creating a new tool suggestion:", data);
+ toast.success("Success creating a new tool suggestion");
+ push(`/dashboard`);
+ }
+ },
+ onError: (error) => {
+ console.error("Error creating a new tool:", error);
+ },
+ },
+ request: {},
+ });
+
+ const { mutate: mutateDeleteOtherToolsId } = useDeleteOtherToolsId({
+ mutation: {
+ onSuccess: (data) => {
+ console.info("Success deleting the tool:", data);
+ toast.success("Success deleting the tool");
+ push(`/other-tools`);
+ },
+ onError: (error: Error) => {
+ console.error("Error deleting the tool:", error);
+ },
+ },
+ });
+
+ const { mutate: mutateDeleteToolSuggestionsId } = useDeleteToolEditSuggestionsId({
+ mutation: {
+ onSuccess: (data) => {
+ console.info("Success deleting the suggested tool:", data);
+ toast.success("Success deleting the suggested tool");
+ push(`/other-tools`);
+ },
+ onError: (error: Error) => {},
+ },
+ });
+
+ const { mutate: mutatePutToolEditSuggestionId } = usePutToolEditSuggestionsId({
+ mutation: {
+ onSuccess: (data) => {
+ console.info("Success updating the tool suggestion:", data);
+ toast.success("Success updating the tool suggestion");
+ if (
+ ME_DATA?.role?.type === "authenticated" ||
+ data?.data?.attributes?.review_status === "declined" ||
+ data?.data?.attributes?.review_status === "pending"
+ ) {
+ push(`/dashboard`);
+ }
+ push(`/other-tools`);
+ },
+ onError: (error: Error) => {
+ console.error("Error updating the tool suggestion:", error);
+ console.error("Error updating the tool suggestion", error);
+ },
+ },
+ request: {},
+ });
+
+ const changes =
+ !!id && otherToolData?.data?.attributes && editSuggestionIdData?.data?.attributes
+ ? getObjectDifferences(
+ editSuggestionIdData?.data?.attributes,
+ otherToolData?.data?.attributes,
+ )
+ : [];
+
+ const formSchema = z.object({
+ name: z.string().min(1, { message: "Please enter tool name" }),
+ link: z.string().url({ message: "Please enter a valid URL" }),
+ category: z
+ .number()
+ .optional()
+ .refine((val) => typeof val !== "undefined", {
+ message: "Please select a category",
+ }),
+ description: z.string().min(6, {
+ message: "Please enter a description with at least 6 characters",
+ }),
+ });
+
+ const previousData = editSuggestionIdData?.data?.attributes || otherToolData?.data?.attributes;
+
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ values: {
+ name: previousData?.name || "",
+ link: previousData?.link || "",
+ category: previousData?.other_tools_category?.data?.id as number,
+ description: previousData?.description || "",
+ },
+ });
+
+ const handleCancel = () => {
+ push(`/?${URLParams.toString()}`);
+ };
+
+ const handleSubmit = useCallback(
+ (values: z.infer) => {
+ if (ME_DATA?.role?.type === "authenticated") {
+ if (!!id && !!editSuggestionIdData) {
+ mutatePutToolEditSuggestionId({
+ id: +id[0],
+ data: {
+ // @ts-expect-error TO-DO - fix types
+ data: {
+ review_status: "pending",
+ ...values,
+ ...(values?.category && {
+ other_tools_category: {
+ disconnect: [],
+ connect: [+values?.category],
+ },
+ }),
+ },
+ },
+ });
+ }
+ if ((!!id && !editSuggestionIdData) || !id) {
+ mutatePostToolEditSuggestion({
+ data: {
+ // @ts-expect-error TO-DO - fix types
+ data: {
+ review_status: "pending",
+ ...values,
+ ...(values?.category && {
+ other_tools_category: {
+ disconnect: [],
+ connect: [+values?.category],
+ },
+ }),
+ ...(id && {
+ other_tool: {
+ disconnect: [+id],
+ connect: [+id],
+ },
+ }),
+ },
+ },
+ });
+ }
+ }
+
+ // TO - DO
+ if (ME_DATA?.role?.type === "admin") {
+ if (data?.apiToken) {
+ updateOrCreateOtherTools(
+ {
+ ...(id && !editSuggestionIdData && { id }),
+ ...(id &&
+ !!editSuggestionIdData && {
+ id: editSuggestionIdData?.data?.attributes?.other_tool?.data?.id,
+ }),
+ ...values,
+ other_tools_category: values.category,
+ },
+ data?.apiToken,
+ )
+ .then(() => {
+ if (!id) {
+ console.info("Success creating the tool");
+ toast.success("Success creating the tool");
+ }
+
+ if (!!id && !editSuggestionIdData) {
+ console.info("Success updating the tool");
+ toast.success("Success updating the tool");
+ mutatePostToolEditSuggestion({
+ data: {
+ // @ts-expect-error TO-DO - fix types
+ data: {
+ review_status: "approved",
+ ...values,
+ ...(values?.category && {
+ category: {
+ disconnect: [],
+ connect: [+values?.category],
+ },
+ }),
+ ...(values?.category && {
+ category: {
+ disconnect: [],
+ connect: [+values?.category],
+ },
+ }),
+ ...(id && {
+ other_tool: {
+ disconnect: [+id],
+ connect: [+id],
+ },
+ }),
+ },
+ },
+ });
+ }
+ push(`/other-tools`);
+ })
+ .catch((error: Error) => {
+ toast.error("There was a problem creating the tool");
+ console.error("Error creating the tool:", error);
+ });
+ }
+ }
+ },
+ [
+ mutatePostToolEditSuggestion,
+ ME_DATA,
+ id,
+ mutatePutToolEditSuggestionId,
+ data?.apiToken,
+ push,
+ editSuggestionIdData,
+ ],
+ );
+
+ const handleReject = ({ message }: { message?: string }) => {
+ if (ME_DATA?.role?.type === "admin" && editSuggestionIdData?.data?.id) {
+ mutatePutToolEditSuggestionId({
+ id: editSuggestionIdData?.data?.id,
+ data: {
+ data: {
+ review_status: "declined",
+ review_decision_details: message,
+ },
+ },
+ });
+ }
+ };
+
+ const handleDelete = useCallback(() => {
+ if (otherToolData?.data?.id) {
+ mutateDeleteOtherToolsId({ id: +id });
+ } else if (editSuggestionIdData?.data?.id) {
+ mutateDeleteToolSuggestionsId({ id: editSuggestionIdData?.data?.id });
+ }
+ }, [mutateDeleteOtherToolsId, id]);
+
+ const suggestionStatus = editSuggestionIdData?.data?.attributes?.review_status;
+
+ return (
+ <>
+
+
+
+
+
+
+ Fill the tool's information{" "}
+
+ (* required fields)
+
+
+
+
+
+ (
+
+
+ Tool name*
+
+
+
+
+
+
+ )}
+ />
+ (
+
+
+ Website link*
+
+
+
+
+
+
+ )}
+ />
+
+ (
+
+
+ Description*
+
+
+
+
+
+
+ )}
+ />
+ (
+
+
+ Category*
+
+
+ field.onChange(+v)}
+ defaultValue={
+ categoriesData?.find(
+ (category) =>
+ category.value === previousData?.other_tools_category?.data?.id,
+ )?.label
+ }
+ disabled={
+ ME_DATA?.role?.type === "authenticated" && suggestionStatus === "declined"
+ }
+ >
+
+
+
+
+ {categoriesData?.map(({ label, value }) => (
+
+ {label}
+
+ ))}
+
+
+
+
+
+ )}
+ />
+
+
+ Submit
+
+
+
+
+ >
+ );
+}
diff --git a/client/src/containers/other-tools/index.tsx b/client/src/containers/other-tools/index.tsx
index c5124db9..6450d303 100644
--- a/client/src/containers/other-tools/index.tsx
+++ b/client/src/containers/other-tools/index.tsx
@@ -63,7 +63,7 @@ const OtherTools = () => {
isError={isError}
>
- {otherTools?.data?.map((a) => )}
+ {otherTools?.data?.map((a) => )}
diff --git a/client/src/containers/other-tools/title/index.tsx b/client/src/containers/other-tools/title/index.tsx
new file mode 100644
index 00000000..d3a4681f
--- /dev/null
+++ b/client/src/containers/other-tools/title/index.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import Link from "next/link";
+
+import { useSession } from "next-auth/react";
+
+export default function OtherToolsTitle() {
+ const session = useSession();
+ return (
+
+
Other Tools
+ {session.status === "authenticated" && (
+
+ Add new
+
+ )}
+
+ );
+}
diff --git a/client/src/containers/other-tools/tool-card/index.tsx b/client/src/containers/other-tools/tool-card/index.tsx
index f3bc9f5d..6a6bb8ea 100644
--- a/client/src/containers/other-tools/tool-card/index.tsx
+++ b/client/src/containers/other-tools/tool-card/index.tsx
@@ -1,12 +1,19 @@
"use client";
import { useState } from "react";
+import Link from "next/link";
+
+import { useAtomValue } from "jotai";
+import { useSession } from "next-auth/react";
import { LuExternalLink, LuInfo } from "react-icons/lu";
import { cn } from "@/lib/classnames";
import { OtherTool } from "@/types/generated/strapi.schemas";
+import { otherToolsSearchAtom } from "@/app/store";
+
+import SearchHighlight from "@/components/ui/search-highlight";
import {
Tooltip,
TooltipTrigger,
@@ -14,17 +21,16 @@ import {
TooltipArrow,
TooltipProvider,
} from "@/components/ui/tooltip";
-import { useAtomValue } from "jotai";
-import { otherToolsSearchAtom } from "@/app/store";
-import SearchHighlight from "@/components/ui/search-highlight";
type ToolCardProps = {
tool?: OtherTool;
+ id?: number;
};
-const ToolCard = ({ tool }: ToolCardProps) => {
+const ToolCard = ({ tool, id }: ToolCardProps) => {
const [isHovered, setIsHovered] = useState(false);
const search = useAtomValue(otherToolsSearchAtom);
+ const { data: session } = useSession();
return (
@@ -38,7 +44,16 @@ const ToolCard = ({ tool }: ToolCardProps) => {
{tool?.name}
-
+
+ {!!session && (
+
e.stopPropagation()}
+ >
+ Edit
+
+ )}
{!!tool?.description && (
diff --git a/client/src/containers/personal-data/constants.ts b/client/src/containers/personal-data/constants.ts
new file mode 100644
index 00000000..a54f1b87
--- /dev/null
+++ b/client/src/containers/personal-data/constants.ts
@@ -0,0 +1,55 @@
+type FormDataFieldsProps = {
+ label: string;
+ name: "email" | "username" | "organization";
+ type: string;
+ placeholder: string;
+}[];
+
+export type FormPasswordFieldsProps = {
+ label: string;
+ name: "password" | "newPassword" | "passwordConfirmation";
+ type: string;
+ placeholder: string;
+}[];
+
+export const FORM_DATA_FIELDS: FormDataFieldsProps = [
+ {
+ label: "Name",
+ name: "username",
+ type: "text",
+ placeholder: "Enter your name",
+ },
+ {
+ label: "Email",
+ name: "email",
+ type: "email",
+ placeholder: "Enter your email address",
+ },
+ {
+ label: "Organization",
+ name: "organization",
+ type: "text",
+ placeholder: "Enter your organization name",
+ },
+];
+
+export const FORM_PASSWORD_FIELDS: FormPasswordFieldsProps = [
+ {
+ label: "Current password",
+ name: "password",
+ type: "password",
+ placeholder: "Enter your current password",
+ },
+ {
+ label: "New password",
+ name: "newPassword",
+ type: "password",
+ placeholder: "Enter your new password",
+ },
+ {
+ label: "Confirm new password",
+ name: "passwordConfirmation",
+ type: "password",
+ placeholder: "Confirm your new password",
+ },
+];
diff --git a/client/src/containers/personal-data/index.tsx b/client/src/containers/personal-data/index.tsx
new file mode 100644
index 00000000..96862e6a
--- /dev/null
+++ b/client/src/containers/personal-data/index.tsx
@@ -0,0 +1,311 @@
+"use client";
+
+import { useCallback, useState } from "react";
+
+import { useForm } from "react-hook-form";
+import { toast } from "react-toastify";
+
+import { useRouter, useSearchParams } from "next/navigation";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import isEmpty from "lodash/isEmpty";
+import { useSession } from "next-auth/react";
+import { LuEye, LuEyeOff } from "react-icons/lu";
+import { RiDeleteBinLine } from "react-icons/ri";
+import { z } from "zod";
+
+import { usePostAuthChangePassword } from "@/types/generated/users-permissions-auth";
+import {
+ useDeleteUsersId,
+ useGetUsersId,
+ usePutUsersId,
+} from "@/types/generated/users-permissions-users-roles";
+
+import { Button } from "@/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+
+import { FORM_DATA_FIELDS, FORM_PASSWORD_FIELDS } from "./constants";
+
+type FormSchemaData = z.infer;
+
+const formSchemaData = z.object({
+ email: z.string().email({ message: "Please enter your email address" }).optional(),
+ username: z.string().min(1, { message: "Please enter your name" }).optional(),
+ organization: z.string().min(1, { message: "Please enter your organization name" }).optional(),
+});
+
+const formSchemaPassword = z
+ .object({
+ password: z.string().min(1, { message: "Current password is required" }),
+ 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 PersonalDataForm() {
+ const [fieldsVisibility, setFieldsVisibility] = useState<{
+ [key: string]: boolean;
+ }>({
+ newPassword: false,
+ passwordConfirmation: false,
+ });
+
+ const { push } = useRouter();
+ const searchParams = useSearchParams();
+ const { data: session } = useSession();
+ const user = session?.user;
+
+ const { data: meData } = useGetUsersId(`${user?.id}`);
+
+ // 1. Define your form.
+ const formData = useForm>({
+ resolver: zodResolver(formSchemaData),
+ values: {
+ username: meData?.username || "",
+ email: meData?.email || "",
+ // @ts-expect-error TO-DO - fix types
+ organization: meData?.organization || "",
+ },
+ });
+
+ const formPassword = useForm>({
+ resolver: zodResolver(formSchemaPassword),
+ defaultValues: {
+ password: user?.password || "",
+ newPassword: "",
+ passwordConfirmation: "",
+ },
+ });
+
+ const { mutate: deleteAccount } = useDeleteUsersId({
+ mutation: {
+ onSuccess: () => {
+ push(`/signin?${searchParams.toString()}`);
+ },
+ onError: (error: Error) => {
+ console.error("Error deleting account:", error);
+ },
+ },
+ });
+
+ const { mutate: updateUserData } = usePutUsersId({
+ mutation: {
+ onSuccess: () => {
+ console.info("Data updated successfully");
+ toast.success("Data updated successfully");
+ },
+ onError: (error: Error) => {
+ console.error("Error updating data:", error);
+ toast.error("Error updating data");
+ },
+ },
+ });
+
+ const { mutate: updateUserPassword } = usePostAuthChangePassword({
+ mutation: {
+ onSuccess: () => {
+ console.info("Password updated successfully");
+ toast.success("Password updated successfully");
+ },
+ 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("password", { message: response?.message });
+ } else if (!isEmpty(response.details)) {
+ formPassword.setError("newPassword", { message: response?.message });
+ }
+ }
+ toast.error("Error updating password");
+ },
+ },
+ request: {
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${session?.apiToken}`,
+ },
+ },
+ });
+
+ // const { mutate: mutateDatasets } = usePostDatasets({
+ // mutation: {
+ // onSuccess: (data) => {
+ // queryClient.invalidateQueries(["/datasets"]);
+ // },
+ // onError: (error) => {
+ // console.error("Error creating dataset:", error);
+ // },
+ // },
+
+ // },
+ // });
+ // 2. Define a submit handler.
+ function onSubmitData(values: FormSchemaData) {
+ const fieldsToUpdate = Object.keys(formData.formState.dirtyFields) as (keyof FormSchemaData)[];
+ const infoToUpdate = fieldsToUpdate.reduce((acc, key) => {
+ acc[key] = values[key];
+ return acc;
+ }, {} as Partial);
+
+ if (user?.id) {
+ updateUserData({ id: user.id.toString(), data: { ...user, ...infoToUpdate } });
+ }
+ }
+
+ function onSubmitPassword(values: z.infer) {
+ // const fieldsToUpdate = form.formState.dirtyFields;
+ if (!user?.id) return;
+ updateUserPassword({
+ data: {
+ currentPassword: values.password,
+ password: values.newPassword,
+ passwordConfirmation: values.passwordConfirmation,
+ },
+ });
+ }
+
+ const handleAccount = useCallback(() => {
+ if (!user?.id) return;
+ deleteAccount({ id: user?.id.toString() });
+ }, [user?.id, deleteAccount]);
+
+ return (
+
+
+
+
Account information
+
+ Delete account
+
+
+
+
+
+
+
+
Edit data
+
+ {FORM_DATA_FIELDS.map(({ name, label, type, placeholder }) => (
+ (
+
+ {label}
+
+
+
+
+
+ )}
+ />
+ ))}
+
+
+
+ Save
+
+
+
+
+
+
+
Edit Password
+
+ {FORM_PASSWORD_FIELDS.map(({ name, label, type, placeholder }) => (
+ (
+
+ {label}
+
+
+
+
+ {name !== "password" && !fieldsVisibility?.[name] && (
+
+ setFieldsVisibility({
+ ...fieldsVisibility,
+ [name]: !fieldsVisibility?.[name],
+ })
+ }
+ />
+ )}
+
+ {name !== "password" && fieldsVisibility?.[name] && (
+
+ setFieldsVisibility({
+ ...fieldsVisibility,
+ [name]: !fieldsVisibility?.[name],
+ })
+ }
+ />
+ )}
+
+
+
+
+ )}
+ />
+ ))}
+
+
+
+ Save
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/containers/projects/form/index.tsx b/client/src/containers/projects/form/index.tsx
new file mode 100644
index 00000000..5f8b8175
--- /dev/null
+++ b/client/src/containers/projects/form/index.tsx
@@ -0,0 +1,923 @@
+"use client";
+
+import { useCallback } from "react";
+
+import { useForm } from "react-hook-form";
+import { toast } from "react-toastify";
+
+import Error from "next/error";
+import { useParams, useRouter } from "next/navigation";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useSession } from "next-auth/react";
+import { z } from "zod";
+
+import { cn } from "@/lib/classnames";
+import { getObjectDifferences } from "@/lib/utils/objects";
+
+import { useGetCountries } from "@/types/generated/country";
+import { useGetPillars } from "@/types/generated/pillar";
+import { useDeleteProjectsId, useGetProjectsId } from "@/types/generated/project";
+import {
+ useGetProjectEditSuggestionsId,
+ usePostProjectEditSuggestions,
+ usePutProjectEditSuggestionsId,
+ useDeleteProjectEditSuggestionsId,
+} from "@/types/generated/project-edit-suggestion";
+import { useGetProjectStatuses } from "@/types/generated/project-status";
+import { useGetOrganizationTypes } from "@/types/generated/organization-type";
+import { useGetSdgs } from "@/types/generated/sdg";
+import type { UsersPermissionsRole, UsersPermissionsUser } from "@/types/generated/strapi.schemas";
+import { useGetTypesOfFundings } from "@/types/generated/types-of-funding";
+import { useGetUsersId } from "@/types/generated/users-permissions-users-roles";
+import { useGetWorldCountries } from "@/types/generated/world-country";
+
+import { useSyncSearchParams } from "@/app/store";
+
+import { GET_COUNTRIES_OPTIONS } from "@/constants/countries";
+import { GET_PILLARS_OPTIONS } from "@/constants/pillars";
+import DashboardFormWrapper from "@/components/forms/dataset/wrapper";
+import DashboardFormControls from "@/components/new-dataset/form-controls";
+import { Button } from "@/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { MultiCombobox } from "@/components/ui/multicombobox";
+import {
+ Select,
+ SelectTrigger,
+ SelectContent,
+ SelectItem,
+ SelectValue,
+} from "@/components/ui/select";
+
+import { updateOrCreateProject } from "@/services/projects";
+import CSVImport from "@/components/new-dataset/step-description/csv-import";
+import { useGetObjectives } from "@/types/generated/objective";
+
+export default function ProjectForm() {
+ const { push } = useRouter();
+ const URLParams = useSyncSearchParams();
+ const params = useParams();
+
+ const { id } = params;
+
+ const { data } = useSession();
+ const user = data?.user;
+
+ const { data: meData } = useGetUsersId(`${user?.id}`, {
+ populate: "role",
+ });
+
+ const ME_DATA = meData as UsersPermissionsUser & { role: UsersPermissionsRole };
+
+ const { data: pillarsData } = useGetPillars(GET_PILLARS_OPTIONS, {
+ query: {
+ select: (data) =>
+ data?.data?.map((pillar) => ({
+ label: pillar?.attributes?.name as string,
+ value: pillar?.id as number,
+ })),
+ },
+ });
+
+ const { data: countriesData } = useGetCountries(GET_COUNTRIES_OPTIONS, {
+ query: {
+ select: (data) =>
+ data?.data?.map((country) => ({
+ label: country?.attributes?.name as string,
+ value: country?.id as number,
+ })),
+ },
+ });
+
+ const { data: sdgsData } = useGetSdgs(
+ {
+ "pagination[pageSize]": 100,
+ sort: "name:asc",
+ },
+ {
+ query: {
+ select: (data) =>
+ data?.data?.map((sdg) => ({
+ label: sdg?.attributes?.name as string,
+ value: sdg?.id as number,
+ })),
+ },
+ },
+ );
+
+ const { data: typesOfFundingData } = useGetTypesOfFundings(
+ {
+ "pagination[pageSize]": 100,
+ sort: "name:asc",
+ },
+ {
+ query: {
+ select: (data) =>
+ data?.data?.map((funding) => ({
+ label: funding?.attributes?.name as string,
+ value: funding?.id as number,
+ })),
+ },
+ },
+ );
+
+ const { data: typesOfProjectStatus } = useGetProjectStatuses(
+ {
+ "pagination[pageSize]": 100,
+ sort: "name:asc",
+ },
+ {
+ query: {
+ select: (data) =>
+ data?.data?.map((status) => ({
+ label: status?.attributes?.name as string,
+ value: status?.id as number,
+ })),
+ },
+ },
+ );
+
+ const { data: organizationTypes } = useGetOrganizationTypes(
+ {
+ "pagination[pageSize]": 100,
+ sort: "name:asc",
+ },
+ {
+ query: {
+ select: (data) =>
+ data?.data?.map((status) => ({
+ label: status?.attributes?.name as string,
+ value: status?.id as number,
+ })),
+ },
+ },
+ );
+
+ const { data: worldCountries } = useGetWorldCountries(
+ {
+ "pagination[pageSize]": 300,
+ sort: "name:asc",
+ },
+ {
+ query: {
+ select: (data) =>
+ data?.data?.map((status) => ({
+ label: status?.attributes?.name as string,
+ value: status?.attributes?.code as string,
+ })),
+ },
+ },
+ );
+
+ const { data: projectObjectives } = useGetObjectives(
+ {},
+ {
+ query: {
+ select: (data) =>
+ data?.data?.map((objective) => ({
+ label: objective?.attributes?.type as string,
+ value: objective?.id as number,
+ })),
+ },
+ },
+ );
+
+ // if there is no id in the route, we are creating a new project, no need to look for
+ // an existing one
+ const { data: projectData } = useGetProjectsId(
+ +id,
+ {
+ populate: "*",
+ },
+ {
+ query: {
+ enabled: !!id,
+ },
+ },
+ );
+
+ const { data: projectsSuggestedData } = useGetProjectEditSuggestionsId(
+ +id,
+ {
+ populate: "*",
+ },
+ {
+ query: {
+ enabled: !!id,
+ },
+ },
+ );
+
+ const { mutate: mutatePostProjectEditSuggestion } = usePostProjectEditSuggestions({
+ mutation: {
+ onSuccess: (data) => {
+ console.info("Success creating a project suggestion:", data);
+ toast.success("Success creating a project suggestion");
+ push(`/dashboard`);
+ },
+ onError: (error: Error) => {
+ console.error("Error creating a project suggestion:", error);
+ toast.error("There was a problem creating the project suggestion");
+ },
+ },
+ request: {},
+ });
+
+ const { mutate: mutatePutProjectEditSuggestionId } = usePutProjectEditSuggestionsId({
+ mutation: {
+ onSuccess: (data) => {
+ console.info("Success updating a project suggestion");
+ toast.success("Success updating a project suggestion");
+ if (
+ data?.data?.attributes?.review_status === "declined" ||
+ data?.data?.attributes?.review_status === "pending" ||
+ ME_DATA?.role?.type === "authenticated"
+ ) {
+ push(`/dashboard`);
+ }
+ push(`/projects`);
+ },
+ onError: (error: Error) => {
+ console.error("Error updating a project suggestion:", error);
+ toast.error("There was a problem updating the project suggestion");
+ },
+ },
+ request: {},
+ });
+
+ const { mutate: mutateDeleteProjectsId } = useDeleteProjectsId({
+ mutation: {
+ onSuccess: () => {
+ console.info("Success deleting a project");
+ toast.success("Success deleting a project");
+ push(`/dashboard`);
+ },
+ onError: (error: Error) => {
+ console.error("Error deleting a project:", error);
+ toast.error("There was a problem deleting the project");
+ },
+ },
+ });
+
+ const { mutate: mutateDeleteProjectEditSuggestionId } = useDeleteProjectEditSuggestionsId({
+ mutation: {
+ onSuccess: () => {
+ console.info("Success deleting a suggested project");
+ toast.success("Success deleting a suggested project");
+ push(`/dashboard`);
+ },
+ onError: (error: Error) => {
+ console.error("Error deleting a project suggestion:", error);
+ toast.error("There was a problem deleting the project suggestion");
+ },
+ },
+ });
+
+ const previousData = projectsSuggestedData?.data?.attributes || projectData?.data?.attributes;
+
+ const formSchema = z.object({
+ name: z.string().min(1, { message: "Please enter project's details" }),
+ info: z.string().optional(),
+ pillar: z.coerce.number().min(1, {
+ message: "Please select at least one pillar",
+ }),
+ amount: z.coerce
+ .number()
+ .optional()
+ .refine((val) => typeof val !== "undefined", {
+ message: "Please enter amount",
+ }),
+ countries: z.array(z.number().min(1, { message: "Please select at least one country" })),
+
+ sdgs: z.array(
+ z.number().min(1, {
+ message: "Please select a sdg",
+ }),
+ ),
+ status: z.string().min(1, {
+ message: "Please enter status",
+ }),
+ funding: z.coerce.number().min(1, {
+ message: "Please select type of funding",
+ }),
+ organization_type: z.string().min(1, {
+ message: "Please enter organization type",
+ }),
+ source_country: z.string().min(1, { message: "Please select a country" }),
+ objective: z.string().min(1, { message: "Please enter objective" }),
+ });
+
+ // TO - DO - add category from edit when API gets fixed
+ // projectsSuggestedData?.data?.attributes?.other_tools_category ||
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ ...(id && {
+ values: {
+ name: previousData?.name || "",
+ info: previousData?.info || "",
+ pillar:
+ // previousData.updatedAt ||
+ previousData?.pillar?.data?.id as number,
+ amount: previousData?.amount as number,
+ countries:
+ previousData?.countries?.data?.map(({ id }: { id?: number }) => id as number) || [],
+ sdgs: previousData?.sdgs?.data?.map(({ id }: { id?: number }) => id as number) || [],
+ status: previousData?.status || "",
+ funding: previousData?.funding?.data?.id as number,
+ organization_type: previousData?.organization_type || "",
+ source_country: previousData?.source_country || "",
+ objective: previousData?.objective as string,
+ },
+ }),
+ });
+
+ const handleCancel = () => {
+ push(`/?${URLParams.toString()}`);
+ };
+
+ const handleSubmit = useCallback(
+ (values: z.infer) => {
+ if (ME_DATA?.role?.type === "authenticated") {
+ if (!!id && !!projectsSuggestedData) {
+ mutatePutProjectEditSuggestionId({
+ id: +id,
+ data: {
+ data: {
+ ...values,
+ countries: {
+ // @ts-expect-error TO-DO - fix types
+ connect: values.countries,
+ disconnect: [],
+ },
+ ...(values.pillar && {
+ pillar: {
+ disconnect: [],
+ connect: [values.pillar],
+ },
+ }),
+ ...(values.sdgs && {
+ sdgs: {
+ disconnect: [],
+ connect: values.sdgs,
+ },
+ }),
+ review_status: "pending",
+ },
+ },
+ });
+ }
+ if ((!!id && !projectsSuggestedData) || !id) {
+ mutatePostProjectEditSuggestion({
+ data: {
+ data: {
+ ...values,
+ ...(id && {
+ project: {
+ disconnect: [],
+ connect: [+id],
+ },
+ }),
+ countries: {
+ // @ts-expect-error TO-DO - fix types
+ connect: values.countries,
+ disconnect: [],
+ },
+ ...(values.pillar && {
+ pillar: {
+ disconnect: [],
+ connect: [values.pillar],
+ },
+ }),
+ ...(values.sdgs && {
+ sdgs: {
+ disconnect: [],
+ connect: values.sdgs,
+ },
+ }),
+ review_status: "pending",
+ },
+ },
+ });
+ }
+ }
+
+ if (ME_DATA?.role?.type === "admin" && data?.apiToken) {
+ // if there is no id, or the id comes from a
+ // suggestion, we are creating a new project
+
+ updateOrCreateProject(
+ {
+ ...(id && !projectsSuggestedData && { id }),
+ ...(id &&
+ !!projectsSuggestedData && {
+ id: 366,
+ }),
+ ...values,
+ },
+ data?.apiToken,
+ // to do review data + change sug status
+ )
+ .then(() => {
+ console.info("Success creating a new project esta entrando aqui");
+ toast.success("Success creating a new project");
+
+ if (projectsSuggestedData) {
+ mutatePutProjectEditSuggestionId({
+ id: +id,
+ data: {
+ data: {
+ ...values,
+ countries: {
+ // @ts-expect-error TO-DO - fix types
+ connect: values.countries,
+ disconnect: [],
+ },
+ ...(values.pillar && {
+ pillar: {
+ disconnect: [],
+ connect: [values.pillar],
+ },
+ }),
+ ...(values.sdgs && {
+ sdgs: {
+ disconnect: [],
+ connect: values.sdgs,
+ },
+ }),
+ review_status: "approved",
+ },
+ },
+ });
+ }
+ push(`/projects`);
+ })
+ .catch((error: Error) => {
+ toast.error("There was a problem creating the dataset");
+ console.error("Error creating dataset:", error);
+ });
+ }
+ },
+
+ [
+ ME_DATA?.role?.type,
+ data?.apiToken,
+ push,
+ id,
+ mutatePutProjectEditSuggestionId,
+ mutatePostProjectEditSuggestion,
+ projectsSuggestedData,
+ ],
+ );
+
+ const handleReject = ({ message }: { message?: string }) => {
+ if (ME_DATA?.role?.type === "admin" && projectsSuggestedData?.data?.id) {
+ mutatePutProjectEditSuggestionId({
+ id: projectsSuggestedData?.data?.id,
+ data: {
+ data: {
+ review_status: "declined",
+ review_decision_details: message,
+ },
+ },
+ });
+ }
+ };
+
+ const handleDelete = useCallback(() => {
+ if (projectData?.data?.id) {
+ mutateDeleteProjectsId({ id: +id });
+ } else if (projectsSuggestedData?.data?.id) {
+ mutateDeleteProjectEditSuggestionId({
+ id: projectsSuggestedData?.data?.id,
+ });
+ }
+ }, [id, mutateDeleteProjectsId]);
+
+ const changes =
+ !projectData?.data?.attributes && !!id && projectsSuggestedData?.data?.attributes
+ ? []
+ : getObjectDifferences(projectData?.data?.attributes, form.getValues());
+
+ const suggestionStatus = projectsSuggestedData?.data?.attributes?.review_status;
+ return (
+ <>
+
+
+
+
+
+
+
+ Fill the project's information{" "}
+
+ (* required fields)
+
+
+
+
+
+ (
+
+
+ Project detail*
+
+
+
+
+
+
+ )}
+ />
+ (
+
+
+ Info*
+
+
+
+
+
+
+ )}
+ />
+
+ {
+ return (
+
+
+ Pillar*
+
+
+
+
+
+
+ {(pillarsData || []).map(({ label, value }) => {
+ return (
+
+ {label}
+
+ );
+ })}
+
+
+
+
+ );
+ }}
+ />
+ (
+
+
+ Amount (USD)*
+
+
+
+
+
+
+ )}
+ />
+ {
+ return (
+
+
+ Countries*
+
+
+
+
+
+
+ );
+ }}
+ />
+ {
+ return (
+
+
+ SDG*
+
+
+
+
+
+
+ );
+ }}
+ />
+ (
+
+
+ Status*
+
+
+
+
+
+
+
+ {(typesOfProjectStatus || []).map(({ label, value }) => {
+ return (
+
+ {label}
+
+ );
+ })}
+
+
+
+
+
+ )}
+ />
+
+ (
+
+
+ Type of funding*
+
+
+
+
+
+
+
+ {(typesOfFundingData || []).map(({ label, value }) => {
+ return (
+
+ {label}
+
+ );
+ })}
+
+
+
+
+
+ )}
+ />
+
+ (
+
+
+ Organization type*
+
+
+
+
+
+
+
+ {(organizationTypes || []).map(({ label, value }) => {
+ return (
+
+ {label}
+
+ );
+ })}
+
+
+
+
+
+ )}
+ />
+ (
+
+
+ Source country*
+
+
+
+
+
+
+
+ {(worldCountries || []).map(({ label, value }) => {
+ return (
+
+ {label}
+
+ );
+ })}
+
+
+
+
+
+ )}
+ />
+
+ (
+
+
+ Objective*
+
+
+
+
+
+
+
+ {(projectObjectives || []).map(({ label, value }) => {
+ return (
+
+ {label}
+
+ );
+ })}
+
+
+
+
+
+ )}
+ />
+
+
+ Submit
+
+
+
+
+ >
+ );
+}
diff --git a/client/src/containers/projects/header.tsx b/client/src/containers/projects/header.tsx
index 86df62cd..2382870c 100644
--- a/client/src/containers/projects/header.tsx
+++ b/client/src/containers/projects/header.tsx
@@ -1,6 +1,9 @@
"use client";
+import Link from "next/link";
+
import { useAtomValue } from "jotai";
+import { useSession } from "next-auth/react";
import { useGetProjects } from "@/types/generated/project";
@@ -12,6 +15,7 @@ const ProjectsHeader = () => {
const projectSearch = useAtomValue(projectSearchAtom);
const [pillars] = useSyncPillars();
const [countries] = useSyncCountries();
+ const session = useSession();
const { data } = useGetProjects(
GET_PROJECTS_OPTIONS(projectSearch, {
@@ -27,8 +31,17 @@ const ProjectsHeader = () => {
return (