From de0e4b967fceee452ffd77cf541aba57ee899b82 Mon Sep 17 00:00:00 2001 From: Guillaume Fay Date: Thu, 7 Dec 2023 17:55:37 +0100 Subject: [PATCH] feat: add bal widget config page --- .../bal-widget/bal-widget-config-form.tsx | 176 ++++++++++++++++++ components/header.tsx | 162 ++++++++++------ components/multi-link-input.tsx | 86 +++++++++ components/multi-string-input.tsx | 71 +++++++ lib/bal-widget.ts | 38 ++++ pages/bal-widget/index.tsx | 43 +++++ server/index.js | 2 + server/lib/bal-widget/controller.js | 31 +++ server/lib/bal-widget/schemas.js | 0 server/lib/bal-widget/service.js | 37 ++++ server/utils/mongo-client.js | 4 + types/bal-widget.ts | 20 ++ 12 files changed, 616 insertions(+), 54 deletions(-) create mode 100644 components/bal-widget/bal-widget-config-form.tsx create mode 100644 components/multi-link-input.tsx create mode 100644 components/multi-string-input.tsx create mode 100644 lib/bal-widget.ts create mode 100644 pages/bal-widget/index.tsx create mode 100644 server/lib/bal-widget/controller.js create mode 100644 server/lib/bal-widget/schemas.js create mode 100644 server/lib/bal-widget/service.js create mode 100644 types/bal-widget.ts diff --git a/components/bal-widget/bal-widget-config-form.tsx b/components/bal-widget/bal-widget-config-form.tsx new file mode 100644 index 0000000..9dce7b6 --- /dev/null +++ b/components/bal-widget/bal-widget-config-form.tsx @@ -0,0 +1,176 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import React, { useState, useMemo } from "react"; +import styled from "styled-components"; +import { Input } from "@codegouvfr/react-dsfr/Input"; +import { Button } from "@codegouvfr/react-dsfr/Button"; +import { Checkbox } from "@codegouvfr/react-dsfr/Checkbox"; + +import { BALWidgetConfig } from "../../types/bal-widget"; +import { MultiStringInput } from "../multi-string-input"; +import { MultiLinkInput } from "../multi-link-input"; + +type BALWidgetConfigFormProps = { + config: BALWidgetConfig; + onSubmit: (data: BALWidgetConfig) => Promise; +}; + +const StyledForm = styled.form` + h3, + h4 { + margin-bottom: 1rem; + } + + section { + margin: 1rem 0; + } + + .form-controls { + display: flex; + align-items: center; + + > :not(:first-child) { + margin-left: 1rem; + } + } +`; + +const defaultConfig: BALWidgetConfig = { + global: { + title: "Centre d'aide Base Adresse Locale", + hideWidget: false, + showOnPages: [], + }, + gitbook: { + welcomeBlockTitle: "Ces articles pourraient vous aider", + topArticles: [], + }, + contactUs: { + welcomeBlockTitle: "Nous contacter", + subjects: [], + }, +}; + +export const BALWidgetConfigForm = ({ + onSubmit, + config: baseConfig, +}: BALWidgetConfigFormProps) => { + const initialConfig = baseConfig ? { ...baseConfig } : { ...defaultConfig }; + const [formData, setFormData] = useState(initialConfig); + + const canPublish = useMemo(() => { + return JSON.stringify(formData) !== JSON.stringify(initialConfig); + }, [formData, initialConfig]); + + const handleEdit = + (section: keyof BALWidgetConfig, property: string) => + (e: React.ChangeEvent) => { + const { value } = e.target; + setFormData((state) => ({ + ...state, + [section]: { + ...state[section], + [property]: value, + }, + })); + }; + + const handleToggle = + (section: keyof BALWidgetConfig, property: string) => () => { + setFormData((state) => ({ + ...state, + [section]: { + ...state[section], + [property]: !state[section][property], + }, + })); + }; + + const resetForm = () => { + setFormData(initialConfig); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await onSubmit(formData); + }; + + return ( + +

Configuration du widget

+
+

Globale

+ + + + handleEdit("global", "showOnPages")({ target: { value } } as any) + } + /> +
+
+

Gitbook

+ + + handleEdit("gitbook", "topArticles")({ target: { value } } as any) + } + /> +
+
+

Formulaire de contact

+ + + handleEdit("contactUs", "subjects")({ target: { value } } as any) + } + /> +
+
+ + +
+
+ ); +}; diff --git a/components/header.tsx b/components/header.tsx index 1506c29..ced09b4 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -1,55 +1,80 @@ -import Link from 'next/link' -import {signIn, signOut} from 'next-auth/react' -import type {Session} from 'next-auth' +import React from "react"; +import Link from "next/link"; +import { signIn, signOut } from "next-auth/react"; +import type { Session } from "next-auth"; type HeaderProps = { session: Session; -} +}; -const Header = ({session}: HeaderProps) => ( -
-
-
-
-
-
- -
-

+const Header = ({ session }: HeaderProps) => ( +

+
+
+
+
+
+ +
+

Base Adresse Locale
Admin

-
-
-
- -

+

+ +

Base Adresse Locale / Admin - ANCT

-

Interface d’administration des services Base Adresse Locale

+

+ Interface d’administration des services Base Adresse Locale +

-
-
-
    -
  • - {session ? (<> -
    {session?.user?.name}
    - - - ) - : + + ) : ( + } + + )}
@@ -57,39 +82,68 @@ const Header = ({session}: HeaderProps) => (
-
-) +); -export default Header +export default Header; diff --git a/components/multi-link-input.tsx b/components/multi-link-input.tsx new file mode 100644 index 0000000..ec3ea2a --- /dev/null +++ b/components/multi-link-input.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import { Button } from "@codegouvfr/react-dsfr/Button"; +import styled from "styled-components"; +import { BALWidgetLink } from "../types/bal-widget"; + +type MultiLinkInputProps = { + label: string; + value: BALWidgetLink[]; + onChange: (value: BALWidgetLink[]) => void; +}; + +const StyledWrapper = styled.div` + > div { + display: flex; + align-items: center; + margin-bottom: 1rem; + + > :not(:first-child) { + margin-left: 1rem; + } + } +`; + +export const MultiLinkInput = ({ + label, + value, + onChange, +}: MultiLinkInputProps) => { + const addValue = () => { + onChange([...value, { label: "", url: "" }]); + }; + + const removeValue = (index: number) => { + onChange(value.filter((_, i) => i !== index)); + }; + + const handleChange = ( + index: number, + property: keyof BALWidgetLink, + newValue: string + ) => { + onChange( + value.map((v, i) => (i === index ? { ...v, [property]: newValue } : v)) + ); + }; + + return ( + +
+ +
+ {value.map(({ label, url }, index) => ( +
+ handleChange(index, "label", e.target.value)} + placeholder="Comment puis-je obtenir une adresse ?" + /> + handleChange(index, "url", e.target.value)} + placeholder="https://example.com" + /> +
+ ))} +
+ ); +}; diff --git a/components/multi-string-input.tsx b/components/multi-string-input.tsx new file mode 100644 index 0000000..3594a25 --- /dev/null +++ b/components/multi-string-input.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { Button } from "@codegouvfr/react-dsfr/Button"; +import styled from "styled-components"; + +type MultiStringInputProps = { + label: string; + value: string[]; + onChange: (value: string[]) => void; +}; + +const StyledWrapper = styled.div` + > div { + display: flex; + align-items: center; + margin-bottom: 1rem; + + > :not(:first-child) { + margin-left: 1rem; + } + } +`; + +export const MultiStringInput = ({ + label, + value, + onChange, +}: MultiStringInputProps) => { + const addValue = () => { + onChange([...value, ""]); + }; + + const removeValue = (index: number) => { + onChange(value.filter((_, i) => i !== index)); + }; + + const handleChange = (index: number, newValue: string) => { + onChange(value.map((v, i) => (i === index ? newValue : v))); + }; + + return ( + +
+ +
+ {value.map((page, index) => ( +
+ handleChange(index, e.target.value)} + placeholder="https://example.com" + /> +
+ ))} +
+ ); +}; diff --git a/lib/bal-widget.ts b/lib/bal-widget.ts new file mode 100644 index 0000000..877fd97 --- /dev/null +++ b/lib/bal-widget.ts @@ -0,0 +1,38 @@ +import { BALWidgetConfig } from "../types/bal-widget"; + +// eslint-disable-next-line @typescript-eslint/naming-convention +const NEXT_PUBLIC_BAL_ADMIN_URL = + process.env.NEXT_PUBLIC_BAL_ADMIN_URL || "http://localhost:3000"; + +export async function getBALWidgetConfig() { + const response = await fetch( + `${NEXT_PUBLIC_BAL_ADMIN_URL}/api/bal-widget/config` + ); + if (!response.ok) { + const body = await response.json(); + throw new Error(body.message); + } + + const data = await response.json(); + + return data as BALWidgetConfig; +} + +export async function setBALWidgetConfig(payload) { + const response = await fetch( + `${NEXT_PUBLIC_BAL_ADMIN_URL}/api/bal-widget/config`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + } + ); + if (!response.ok) { + const body = await response.json(); + throw new Error(body.message); + } + + const data = await response.json(); + + return data as BALWidgetConfig; +} diff --git a/pages/bal-widget/index.tsx b/pages/bal-widget/index.tsx new file mode 100644 index 0000000..cac7b17 --- /dev/null +++ b/pages/bal-widget/index.tsx @@ -0,0 +1,43 @@ +import React, { useState } from "react"; +import { getBALWidgetConfig } from "../../lib/bal-widget"; +import { BALWidgetConfig } from "../../types/bal-widget"; +import { BALWidgetConfigForm } from "../../components/bal-widget/bal-widget-config-form"; +import { setBALWidgetConfig } from "../../lib/bal-widget"; +import { toast } from "react-toastify"; + +type BALWidgetPageProps = { + config: BALWidgetConfig; +}; + +const BALWidgetPage = ({ config: baseConfig }: BALWidgetPageProps) => { + const [config, setConfig] = useState(baseConfig); + const onSubmit = async (formData: BALWidgetConfig) => { + try { + const config = await setBALWidgetConfig(formData); + toast("Configuration du widget mise à jour", { type: "success" }); + setConfig(config); + } catch (error: unknown) { + console.log(error); + toast("Erreur lors de la mise à jour de la configuration du widget", { + type: "error", + }); + } + }; + return ( +
+ +
+ ); +}; + +export async function getServerSideProps() { + const config = await getBALWidgetConfig(); + + return { + props: { + config, + }, + }; +} + +export default BALWidgetPage; diff --git a/server/index.js b/server/index.js index d46e98d..55f6393 100644 --- a/server/index.js +++ b/server/index.js @@ -37,7 +37,9 @@ async function main() { // Some Partenaire de la charte routes are public, others are protected by routeGuard server.use('/api/partenaires-de-la-charte', require('./lib/partenaire-de-la-charte/controller')) + server.use('/api/bal-widget', require('./lib/bal-widget/controller')) + server.use(async (req, res) => { // Authentification is handled by the next app using next-auth module await nextAppRequestHandler(req, res) diff --git a/server/lib/bal-widget/controller.js b/server/lib/bal-widget/controller.js new file mode 100644 index 0000000..9eccfcc --- /dev/null +++ b/server/lib/bal-widget/controller.js @@ -0,0 +1,31 @@ +const express = require("express"); +const cors = require("cors"); +const {routeGuard} = require('../../route-guard') +const BALWidgetService = require("./service"); + +const BALWidgetRoutes = new express.Router(); + +BALWidgetRoutes.use(express.json()); +BALWidgetRoutes.use(cors()); + +BALWidgetRoutes.get("/config", async (req, res) => { + try { + const config = await BALWidgetService.getConfig(req.query); + res.json(config); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } +}); + +BALWidgetRoutes.post("/config", routeGuard, async (req, res) => { + try { + const config = await BALWidgetService.setConfig(req.body); + res.json(config); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } +}); + +module.exports = BALWidgetRoutes; diff --git a/server/lib/bal-widget/schemas.js b/server/lib/bal-widget/schemas.js new file mode 100644 index 0000000..e69de29 diff --git a/server/lib/bal-widget/service.js b/server/lib/bal-widget/service.js new file mode 100644 index 0000000..340eefb --- /dev/null +++ b/server/lib/bal-widget/service.js @@ -0,0 +1,37 @@ +const { ObjectId } = require("mongodb"); +const mongoClient = require("../../utils/mongo-client"); +const { validPayload } = require("../../utils/payload"); +const { sendMail } = require("../mailer/service"); + +const collectionName = "bal-widget"; + +async function getConfig(query = {}) { + const { draft } = query; + + const mongoQuery = { + type: draft ? "draft" : "published", + }; + + const {_id, _created, _updated, ...record} = await mongoClient.findOne(collectionName, mongoQuery); + + return record; +} + +async function setConfig(payload) { + const { draft, ...update } = payload; + const type = draft ? "draft" : "published" + const record = await mongoClient.findOne(collectionName, {type}); + + if (!record) { + await mongoClient.insertOne(collectionName, {type, ...update}); + } else { + await mongoClient.updateOne(collectionName, record._id, {type, ...update}); + } + + return update; +} + +module.exports = { + getConfig, + setConfig, +}; diff --git a/server/utils/mongo-client.js b/server/utils/mongo-client.js index b2b39fa..f60849e 100644 --- a/server/utils/mongo-client.js +++ b/server/utils/mongo-client.js @@ -78,6 +78,10 @@ class Mongo { return this.db.collection(collectionName).findOne({_id: this.parseObjectId(id)}) } + findOne(collectionName, options = {}) { + return this.db.collection(collectionName).findOne({_deleted: undefined, ...options}) + } + findMany(collectionName, options = {}) { return this.db.collection(collectionName).find({_deleted: undefined, ...options}).toArray() } diff --git a/types/bal-widget.ts b/types/bal-widget.ts new file mode 100644 index 0000000..472a3d4 --- /dev/null +++ b/types/bal-widget.ts @@ -0,0 +1,20 @@ +export interface BALWidgetLink { + label: string; + url: string; +} + +export interface BALWidgetConfig { + global: { + title: string; + hideWidget: boolean; + showOnPages: string[]; + }; + gitbook: { + welcomeBlockTitle: string; + topArticles: BALWidgetLink[]; + }; + contactUs: { + welcomeBlockTitle: string; + subjects: string[]; + }; +}