From db381842cce50f00f49d04c281395b462eaaa86f Mon Sep 17 00:00:00 2001 From: Manu Date: Wed, 7 Oct 2020 18:56:58 +0200 Subject: [PATCH] feat: add themes (#126) --- shared/id-generator/package.json | 17 + shared/id-generator/src/index.d.ts | 8 + shared/id-generator/src/index.js | 35 + shared/id-generator/tsconfig.json | 15 + targets/frontend/Dockerfile | 1 + targets/frontend/package.json | 8 + .../src/components/alerts/AlertTitle.js | 4 +- .../frontend/src/components/alerts/Status.js | 6 +- .../changes/FicheTravailDataChange.js | 1 + .../frontend/src/components/dialog/index.js | 11 +- .../forms/ContentPicker/ContentSearch.js | 201 ++ .../forms/ContentPicker/SortableList.js | 84 + .../components/forms/ContentPicker/index.js | 64 + .../src/components/forms/IconPicker.js | 130 ++ targets/frontend/src/components/layout/Nav.js | 26 +- .../frontend/src/components/layout/Sidebar.js | 0 .../frontend/src/components/themes/Form.js | 157 ++ .../frontend/src/components/themes/List.js | 147 ++ targets/frontend/src/components/themes/Map.js | 188 ++ .../src/components/themes/MapModal.js | 51 + .../themes/updateContentsMutation.js | 42 + targets/frontend/src/hoc/CustomUrqlClient.js | 3 +- targets/frontend/src/hooks/index.js | 15 +- targets/frontend/src/lib/debounce.js | 10 + targets/frontend/src/lib/me.js | 13 - targets/frontend/src/lib/relations.js | 4 + .../frontend/src/pages/themes/[[...id]].js | 240 ++ .../frontend/src/pages/themes/[id]/create.js | 146 ++ targets/frontend/src/pages/themes/create.js | 5 + .../frontend/src/pages/themes/edit/[id].js | 210 ++ targets/frontend/src/theme.js | 51 +- targets/hasura/Dockerfile | 2 +- targets/hasura/metadata/cron_triggers.yaml | 1 + targets/hasura/metadata/tables.yaml | 62 + .../migrations/1588758007277_init/up.sql | 15 +- .../1600793735536_document_relations/down.sql | 1 + .../1600793735536_document_relations/up.sql | 14 + yarn.lock | 2017 +++++++++++------ 38 files changed, 3230 insertions(+), 775 deletions(-) create mode 100644 shared/id-generator/package.json create mode 100644 shared/id-generator/src/index.d.ts create mode 100644 shared/id-generator/src/index.js create mode 100644 shared/id-generator/tsconfig.json create mode 100644 targets/frontend/src/components/forms/ContentPicker/ContentSearch.js create mode 100644 targets/frontend/src/components/forms/ContentPicker/SortableList.js create mode 100644 targets/frontend/src/components/forms/ContentPicker/index.js create mode 100644 targets/frontend/src/components/forms/IconPicker.js delete mode 100644 targets/frontend/src/components/layout/Sidebar.js create mode 100644 targets/frontend/src/components/themes/Form.js create mode 100644 targets/frontend/src/components/themes/List.js create mode 100644 targets/frontend/src/components/themes/Map.js create mode 100644 targets/frontend/src/components/themes/MapModal.js create mode 100644 targets/frontend/src/components/themes/updateContentsMutation.js create mode 100644 targets/frontend/src/lib/debounce.js delete mode 100644 targets/frontend/src/lib/me.js create mode 100644 targets/frontend/src/lib/relations.js create mode 100644 targets/frontend/src/pages/themes/[[...id]].js create mode 100644 targets/frontend/src/pages/themes/[id]/create.js create mode 100644 targets/frontend/src/pages/themes/create.js create mode 100644 targets/frontend/src/pages/themes/edit/[id].js create mode 100644 targets/hasura/metadata/cron_triggers.yaml create mode 100644 targets/hasura/migrations/1600793735536_document_relations/down.sql create mode 100644 targets/hasura/migrations/1600793735536_document_relations/up.sql diff --git a/shared/id-generator/package.json b/shared/id-generator/package.json new file mode 100644 index 000000000..9387feb22 --- /dev/null +++ b/shared/id-generator/package.json @@ -0,0 +1,17 @@ +{ + "name": "@shared/id-generator", + "version": "1.0.0", + "dependencies": { + "uuid": "^8.3.0", + "xxhashjs": "^0.2.2" + }, + "main": "src/index.js", + "module": "src/index.js", + "types": "src/index.d.ts", + "private": true, + "scripts": {}, + "devDependencies": { + "@types/uuid": "^8.3.0", + "@types/xxhashjs": "^0.2.2" + } +} diff --git a/shared/id-generator/src/index.d.ts b/shared/id-generator/src/index.d.ts new file mode 100644 index 000000000..b4853ecc2 --- /dev/null +++ b/shared/id-generator/src/index.d.ts @@ -0,0 +1,8 @@ +export as namespace idGenerator + +export type generatedId = { + cdtn_id: string + initial_id: string +}; + + diff --git a/shared/id-generator/src/index.js b/shared/id-generator/src/index.js new file mode 100644 index 000000000..8eb8a170c --- /dev/null +++ b/shared/id-generator/src/index.js @@ -0,0 +1,35 @@ +import { v4 as uuidv4 } from "uuid"; +import * as XXH from "xxhashjs"; + +const H = XXH.h64(0x1e7f); + + +export const MAX_ID_LENGTH = 10; + +// use xxhash to hash source + newly generated UUID +/** + * + * @param {string} content + * @param {number} maxIdLength + * @returns {string} + */ +export const generateCdtnId = (content, maxIdLength = MAX_ID_LENGTH) => + // save 64bits hash as Hexa string up to maxIdLength chars (can be changed later in case of collision) + // as the xxhash function ensure distribution property + H.update(content).digest().toString(16).slice(0, maxIdLength); + +export const generateInitialId = uuidv4; + +// Beware, you might be generating an already existing cdtn_id +/** + * @param {string} source + * @param {number} maxIdLength + * @returns {idGenerator.generatedId} + */ +export const generateIds = (source, maxIdLength = MAX_ID_LENGTH) => { + const uuid = uuidv4(); + return { + cdtn_id: generateCdtnId(source + uuid, maxIdLength), + initial_id: generateInitialId(), + }; +}; diff --git a/shared/id-generator/tsconfig.json b/shared/id-generator/tsconfig.json new file mode 100644 index 000000000..906cf8732 --- /dev/null +++ b/shared/id-generator/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "downlevelIteration": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "lib": ["es2019"], + "baseUrl": ".", + } +} diff --git a/targets/frontend/Dockerfile b/targets/frontend/Dockerfile index 459554648..ed8f0d5cb 100644 --- a/targets/frontend/Dockerfile +++ b/targets/frontend/Dockerfile @@ -4,6 +4,7 @@ WORKDIR /app COPY package.json yarn.lock /app/ COPY shared/graphql-client/package.json /app/shared/graphql-client/ +COPY shared/id-generator/package.json /app/shared/id-generator/ COPY targets/frontend/package.json /app/targets/frontend/ RUN npx @socialgouv/yarn-workspace-focus-install --cwd targets/frontend --production -- --cache-folder /dev/shm/yarn diff --git a/targets/frontend/package.json b/targets/frontend/package.json index b296ccf54..3b5f60072 100644 --- a/targets/frontend/package.json +++ b/targets/frontend/package.json @@ -13,12 +13,17 @@ "@sentry/integrations": "^5.23.0", "@sentry/node": "^5.23.0", "@shared/graphql-client": "^1.0.0", + "@shared/id-generator": "^1.0.0", "@socialgouv/cdtn-slugify": "^4.30.0", "@socialgouv/cdtn-sources": "^4.30.0", "@socialgouv/matomo-next": "^1.1.2", + "@socialgouv/react-ui": "^4.17.0", "@zeit/next-source-maps": "0.0.4-canary.1", "ace-builds": "^1.4.12", "argon2": "^0.27.0", + "cookie": "^0.4.1", + "d3": "^6.1.1", + "d3-hierarchy": "^2.0.0", "diff": "^4.0.2", "graphql": "^15.3.0", "http-proxy-middleware": "^1.0.5", @@ -31,12 +36,15 @@ "polished": "^3.6.7", "react": "^16.13.1", "react-ace": "^9.1.4", + "react-autosuggest": "^10.0.2", "react-dom": "^16.13.1", "react-hook-form": "^6.8.6", "react-icons": "^3.11.0", "react-is": "^16.13.1", + "react-sortable-hoc": "^1.11.0", "semver": "^7.3.2", "sentry-testkit": "^3.2.1", + "styled-components": "^5.1.1", "theme-ui": "^0.3.1", "unist-util-parents": "^1.0.3", "unist-util-select": "^3.0.1", diff --git a/targets/frontend/src/components/alerts/AlertTitle.js b/targets/frontend/src/components/alerts/AlertTitle.js index 5667b9ab1..055a88503 100644 --- a/targets/frontend/src/components/alerts/AlertTitle.js +++ b/targets/frontend/src/components/alerts/AlertTitle.js @@ -25,7 +25,7 @@ export function AlertTitle({ alertId, info, ...props }) { @@ -38,7 +38,7 @@ export function AlertTitle({ alertId, info, ...props }) { > {showComment && } diff --git a/targets/frontend/src/components/alerts/Status.js b/targets/frontend/src/components/alerts/Status.js index 37af6c05a..d62d7baee 100644 --- a/targets/frontend/src/components/alerts/Status.js +++ b/targets/frontend/src/components/alerts/Status.js @@ -33,10 +33,12 @@ export function AlertStatus({ alertId }) { En cours updateStatus("done")}> - Traité + {" "} + Traité updateStatus("rejected")}> - Rejeté + {" "} + Rejeté ); diff --git a/targets/frontend/src/components/changes/FicheTravailDataChange.js b/targets/frontend/src/components/changes/FicheTravailDataChange.js index 4d9939f36..007e304cd 100644 --- a/targets/frontend/src/components/changes/FicheTravailDataChange.js +++ b/targets/frontend/src/components/changes/FicheTravailDataChange.js @@ -90,5 +90,6 @@ FicheTravailDiffchange.propTypes = { }) ), title: PropTypes.string.isRequired, + url: PropTypes.string.isRequired, }), }; diff --git a/targets/frontend/src/components/dialog/index.js b/targets/frontend/src/components/dialog/index.js index e64a2ae6a..93fe8dc3b 100644 --- a/targets/frontend/src/components/dialog/index.js +++ b/targets/frontend/src/components/dialog/index.js @@ -9,13 +9,20 @@ import { css, jsx } from "theme-ui"; import { IconButton } from "../button"; import { Stack } from "../layout/Stack"; -export function Dialog({ ariaLabel, isOpen = false, onDismiss, children }) { +export function Dialog({ + ariaLabel, + isOpen = false, + onDismiss, + children, + ...props +}) { return ( Close @@ -28,7 +35,7 @@ export function Dialog({ ariaLabel, isOpen = false, onDismiss, children }) { const styles = { closeBt: css({ - position: "absolute", + position: "fixed", right: "xxsmall", top: "xxsmall", }), diff --git a/targets/frontend/src/components/forms/ContentPicker/ContentSearch.js b/targets/frontend/src/components/forms/ContentPicker/ContentSearch.js new file mode 100644 index 000000000..cb30b87c0 --- /dev/null +++ b/targets/frontend/src/components/forms/ContentPicker/ContentSearch.js @@ -0,0 +1,201 @@ +/** @jsx jsx */ + +import { getLabelBySource, SOURCES } from "@socialgouv/cdtn-sources"; +import PropTypes from "prop-types"; +import { useEffect, useState } from "react"; +import Autosuggest from "react-autosuggest"; +import { useDebouncedState } from "src/hooks/index"; +import { Input, jsx } from "theme-ui"; +import { useQuery } from "urql"; + +const sources = [ + SOURCES.SHEET_MT_PAGE, + SOURCES.SHEET_SP, + SOURCES.LETTERS, + SOURCES.TOOLS, + SOURCES.CONTRIBUTIONS, + SOURCES.EXTERNALS, + SOURCES.THEMATIC_FILES, + SOURCES.EDITORIAL_CONTENT, + SOURCES.CDT, + SOURCES.THEMES, +]; + +const AUTOSUGGEST_MAX_RESULTS = 15; + +const searchDocumentsQuery = ` +query searchDocuments($sources: [String!]! = "", $search: String = "") { + documents(where: {title: {_ilike: $search}, source: {_in: $sources}}, limit: ${AUTOSUGGEST_MAX_RESULTS}) { + source + title + cdtnId: cdtn_id + } +} +`; + +export const ContentSearch = ({ contents = [], onChange }) => { + const [suggestions, setSuggestions] = useState([]); + const [inputSearchValue, setInputSearchValue] = useState(""); + const [searchValue, , setDebouncedSearchValue] = useDebouncedState("", 500); + + const [results] = useQuery({ + pause: searchValue.length < 3, + query: searchDocumentsQuery, + variables: { + search: `%${searchValue}%`, + sources, + }, + }); + + useEffect(() => { + const allDocuments = results.data?.documents || []; + const documents = allDocuments.filter( + (document) => + document.source !== SOURCES.THEMES && document.source !== SOURCES.CDT + ); + documents.forEach((document) => { + document.category = "document"; + }); + const themes = allDocuments.filter( + (document) => document.source === SOURCES.THEMES + ); + const articles = allDocuments.filter( + (document) => document.source === SOURCES.CDT + ); + setSuggestions([ + { + suggestions: documents, + title: "Documents", + }, + { suggestions: articles, title: "Articles" }, + { suggestions: themes, title: "Thèmes" }, + ]); + }, [results.data]); + + const onSearchValueChange = (event, { newValue }) => { + setInputSearchValue(newValue); + setDebouncedSearchValue(newValue); + }; + const onSuggestionSelected = ( + event, + { suggestion: { cdtnId, source, title = null } } + ) => { + if (contents.find((content) => content.cdtnId === cdtnId)) { + return; + } + onChange(contents.concat([{ cdtnId, source, title }])); + setInputSearchValue(""); + setSuggestions([]); + }; + + const onSuggestionsFetchRequested = async ({ value }) => { + setInputSearchValue(value); + setDebouncedSearchValue(value); + }; + + const onSuggestionsClearRequested = () => { + setSuggestions([]); + }; + + const inputProps = { + onChange: onSearchValueChange, + placeholder: "Rechercher et ajouter un contenu", + value: inputSearchValue, + }; + + return ( + + ); +}; + +ContentSearch.propTypes = { + contents: PropTypes.array, + onChange: PropTypes.func.isRequired, +}; + +const renderInputComponent = (inputProps) => ( + +); + +function shouldRenderSuggestions(value) { + return value.trim().length > 2; +} +function renderSectionTitle(section) { + return section.suggestions.length ? ( +
+ {section.title} +
+ ) : null; +} + +function getSectionSuggestions(section) { + return section.suggestions; +} + +const getSuggestionValue = (content) => content.title; + +const renderSuggestion = (content) => ( +
+ {content.title} + {content.category === "document" && ( + | {getLabelBySource(content.source)} + )} +
+); + +const renderSuggestionsContainer = ({ containerProps, children }) => ( +
+ {children} +
+); diff --git a/targets/frontend/src/components/forms/ContentPicker/SortableList.js b/targets/frontend/src/components/forms/ContentPicker/SortableList.js new file mode 100644 index 000000000..874f41702 --- /dev/null +++ b/targets/frontend/src/components/forms/ContentPicker/SortableList.js @@ -0,0 +1,84 @@ +/** @jsx jsx */ + +import { getLabelBySource } from "@socialgouv/cdtn-sources"; +import { IoIosReorder, IoMdTrash } from "react-icons/io"; +import { + SortableContainer, + SortableElement, + SortableHandle, +} from "react-sortable-hoc"; +import { Button, IconButton } from "src/components/button"; +import { List } from "src/components/list"; +import { Alert, Box, Flex, jsx } from "theme-ui"; + +export const SortableList = SortableContainer(({ contents, ...props }) => { + return ( + + {contents + .sort(({ position: a }, { position: b }) => a - b) + .map((content, index) => ( + 1} + {...props} + /> + ))} + + ); +}); + +const SortableRow = SortableElement( + ({ + content: { cdtnId, source, title }, + isAdmin, + sortable, + onDeleteContent, + }) => ( +
  • + {isAdmin && sortable && } + + {`${title} - ${getLabelBySource(source)}`} + + {isAdmin && ( + + + + )} +
  • + ) +); + +const SortHandle = SortableHandle(() => ( + + + +)); diff --git a/targets/frontend/src/components/forms/ContentPicker/index.js b/targets/frontend/src/components/forms/ContentPicker/index.js new file mode 100644 index 000000000..5a6d216a9 --- /dev/null +++ b/targets/frontend/src/components/forms/ContentPicker/index.js @@ -0,0 +1,64 @@ +/** @jsx jsx */ + +import PropTypes from "prop-types"; +import { Controller } from "react-hook-form"; +import { useUser } from "src/hooks/useUser"; +import { jsx } from "theme-ui"; + +import { ContentSearch } from "./ContentSearch"; +import { SortableList } from "./SortableList"; + +const ContentPicker = ({ defaultValue, disabled, ...props }) => { + return ( + } + /> + ); +}; + +ContentPicker.propTypes = { + defaultValue: PropTypes.array, + disabled: PropTypes.bool, +}; + +export { ContentPicker }; + +function RootContentPicker({ disabled, value: contents = [], onChange }) { + const { isAdmin } = useUser(); + + const onDeleteContent = (cdtnId) => { + onChange(contents.filter((content) => content.cdtnId !== cdtnId)); + }; + + return ( + <> + { + const contentsCopy = [...contents]; + contentsCopy.splice(newIndex, 0, contentsCopy.splice(oldIndex, 1)[0]); + onChange(contentsCopy); + }} + onDeleteContent={onDeleteContent} + /> + {!disabled && ( + + )} + + ); +} + +RootContentPicker.propTypes = { + disabled: PropTypes.bool, + onChange: PropTypes.func.isRequired, + value: PropTypes.array, +}; diff --git a/targets/frontend/src/components/forms/IconPicker.js b/targets/frontend/src/components/forms/IconPicker.js new file mode 100644 index 000000000..b62177c45 --- /dev/null +++ b/targets/frontend/src/components/forms/IconPicker.js @@ -0,0 +1,130 @@ +/** @jsx jsx */ + +import { icons } from "@socialgouv/react-ui"; +import PropTypes from "prop-types"; +import { useState } from "react"; +import { Controller } from "react-hook-form"; +import { IoMdCloseCircle } from "react-icons/io"; +import { IconButton } from "src/components/button"; +import { Dialog } from "src/components/dialog"; +import { Card, jsx } from "theme-ui"; + +const IconPicker = ({ defaultValue = null, disabled, ...props }) => { + return ( + } + /> + ); +}; + +IconPicker.propTypes = { + defaultValue: PropTypes.string, + disabled: PropTypes.bool, +}; + +export { IconPicker }; + +function RootIconPicker({ disabled, value, onChange }) { + const [showIconList, setShowIconList] = useState(false); + const Icon = icons[value]; + return ( + <> + setShowIconList(false)} + aria-label="Voir toutes les icones" + > +
    + {Object.keys(icons).map((key) => { + const Icon = icons[key]; + return ( + { + setShowIconList(false); + onChange(key); + }} + key={key} + title={key} + sx={generateIconCardStyles()} + > + + + ); + })} +
    +
    +
    + !disabled && setShowIconList(true)} + > + {Icon ? : } + + {value && !disabled && ( + { + onChange(null); + }} + sx={{ + bg: "white", + height: "iconMedium", + ml: "xsmall", + position: "absolute", + right: "-0.5rem", + top: "-0.5rem", + width: "iconMedium", + }} + > + + + )} +
    + + ); +} + +RootIconPicker.propTypes = { + disabled: PropTypes.bool, + onChange: PropTypes.func.isRequired, + value: PropTypes.string, +}; + +const iconBaseStyle = { + height: "3rem", + width: "3rem", +}; + +const generateIconCardStyles = (disabled = false) => ({ + background: "transparent", + border: "none", + color: "text", + display: "inline-flex", + flexShrink: 0, + fontFamily: "Muli", + fontSize: "1rem", + m: "xxsmall", + padding: "1.1rem", + position: "relative", + ...(!disabled && { + ":hover": { boxShadow: "cardHover" }, + cursor: "pointer", + }), +}); + +const NoIcon = () =>
    Pas d’icône
    ; diff --git a/targets/frontend/src/components/layout/Nav.js b/targets/frontend/src/components/layout/Nav.js index 016303423..a59b0fce8 100644 --- a/targets/frontend/src/components/layout/Nav.js +++ b/targets/frontend/src/components/layout/Nav.js @@ -4,7 +4,7 @@ import { useRouter } from "next/router"; import PropTypes from "prop-types"; import { useMemo } from "react"; import { useUser } from "src/hooks/useUser"; -import { Badge, Box, css, jsx, Message, NavLink, Text } from "theme-ui"; +import { Badge, Box, jsx, Message, NavLink, Text } from "theme-ui"; import { useQuery } from "urql"; import { Li, List } from "../list"; @@ -39,18 +39,19 @@ export function Nav() { ); } + return ( Accueil {isAdmin && ( <> - Utilisateurs + Utilisateurs
  • Gestion des utilisateurs @@ -60,7 +61,7 @@ export function Nav() { )} - Alertes + Alertes {!fetching && ( {data.sources.map((source) => { @@ -86,13 +87,18 @@ export function Nav() { )} - Administration + Administration
  • Contenus
  • +
  • + + Thèmes + +
  • @@ -123,9 +129,7 @@ ActiveLink.propTypes = { href: PropTypes.string.isRequired, }; -const styles = css({ - titleSection: { - fontWeight: "light", - textTransform: "uppercase", - }, -}); +const TitleStyles = { + fontWeight: "light", + textTransform: "uppercase", +}; diff --git a/targets/frontend/src/components/layout/Sidebar.js b/targets/frontend/src/components/layout/Sidebar.js deleted file mode 100644 index e69de29bb..000000000 diff --git a/targets/frontend/src/components/themes/Form.js b/targets/frontend/src/components/themes/Form.js new file mode 100644 index 000000000..9181748d3 --- /dev/null +++ b/targets/frontend/src/components/themes/Form.js @@ -0,0 +1,157 @@ +/** @jsx jsx */ + +import Link from "next/link"; +import PropTypes from "prop-types"; +import { useForm } from "react-hook-form"; +import { Button } from "src/components/button"; +import { ContentPicker } from "src/components/forms/ContentPicker/index"; +import { FormErrorMessage } from "src/components/forms/ErrorMessage"; +import { IconPicker } from "src/components/forms/IconPicker"; +import { useUser } from "src/hooks/useUser"; +import { Field, Flex, jsx, Label, NavLink, Textarea } from "theme-ui"; + +const ThemeForm = ({ parentId, onSubmit, loading = false, theme = {} }) => { + const { isAdmin } = useUser(); + const { control, register, handleSubmit, errors } = useForm(); + const hasError = Object.keys(errors).length > 0; + let buttonLabel = "Créer"; + let backLink = `/themes`; + if (theme.cdtnId) { + buttonLabel = "Enregistrer les changements"; + backLink += `/${theme.cdtnId}`; + } else if (parentId) { + backLink += `/${parentId}`; + } + return ( +
    + <> +
    + + +
    + +
    + +
    + +
    + +
    + +
    + +