From 9ba85026815da17320a2dab58e0e0eee09873412 Mon Sep 17 00:00:00 2001 From: Lionel <lionel.breduillieard@beta.gouv.fr> Date: Thu, 4 Feb 2021 09:45:59 +0100 Subject: [PATCH] feat(frontend): handle unthemed documents (#287) * wip * feat(frontend): handle unthemed documents * rename * rename * fix(frontend): update layout * fix(frontend): review * fix: review * fix: review * fix: review --- .../forms/ContentPicker/ThemePicker.js | 56 ++++++ .../forms/ContentPicker/ThemeSearch.js | 169 ++++++++++++++++++ .../src/components/home/UnThemedContent.js | 84 +++++++++ targets/frontend/src/components/layout/Nav.js | 5 + .../frontend/src/components/login/index.js | 1 - targets/frontend/src/pages/index.js | 12 +- targets/frontend/src/pages/unthemed.js | 144 +++++++++++++++ 7 files changed, 467 insertions(+), 4 deletions(-) create mode 100644 targets/frontend/src/components/forms/ContentPicker/ThemePicker.js create mode 100644 targets/frontend/src/components/forms/ContentPicker/ThemeSearch.js create mode 100644 targets/frontend/src/components/home/UnThemedContent.js create mode 100644 targets/frontend/src/pages/unthemed.js diff --git a/targets/frontend/src/components/forms/ContentPicker/ThemePicker.js b/targets/frontend/src/components/forms/ContentPicker/ThemePicker.js new file mode 100644 index 000000000..5885b4e1c --- /dev/null +++ b/targets/frontend/src/components/forms/ContentPicker/ThemePicker.js @@ -0,0 +1,56 @@ +import PropTypes from "prop-types"; +import React from "react"; +import { Controller } from "react-hook-form"; +import { Alert, Close, Text } from "theme-ui"; + +import { ThemeSearch } from "./ThemeSearch"; + +function ThemePicker({ ...props }) { + return ( + <Controller + {...props} + // eslint-disable-next-line no-unused-vars + render={({ ref, ...renderProps }) => { + if (renderProps.value) { + return ( + <Alert + variant="highlight" + sx={{ + minWidth: 0, + p: "xxsmall", + paddingRight: "medium", + position: "relative", + }} + > + <Text + sx={{ + display: "block", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }} + > + {renderProps.value.title} + </Text> + <Close + sx={{ position: "absolute", right: 0 }} + onClick={() => { + renderProps.onChange(""); + }} + /> + </Alert> + ); + } + return <ThemeSearch {...renderProps} />; + }} + /> + ); +} + +ThemePicker.propTypes = { + disabled: PropTypes.bool, +}; + +const MemoThemePicker = React.memo(ThemePicker); + +export { MemoThemePicker as ThemePicker }; diff --git a/targets/frontend/src/components/forms/ContentPicker/ThemeSearch.js b/targets/frontend/src/components/forms/ContentPicker/ThemeSearch.js new file mode 100644 index 000000000..2a310121f --- /dev/null +++ b/targets/frontend/src/components/forms/ContentPicker/ThemeSearch.js @@ -0,0 +1,169 @@ +/** @jsxImportSource theme-ui */ + +import { 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 { Box, Input, Text } from "theme-ui"; +import { useQuery } from "urql"; + +const sources = [SOURCES.THEMES]; + +const AUTOSUGGEST_MAX_RESULTS = 15; + +const searchThemesQuery = ` +query searchThemes($sources: [String!]! = "", $search: String = "") { + documents(where: { + title: {_ilike: $search}, + source: {_in: $sources}, + }, limit: ${AUTOSUGGEST_MAX_RESULTS}) { + source + title + cdtnId: cdtn_id + themeDocuments: relation_a_aggregate(where: {type: {_eq: "theme-content"}}) {aggregate{count}} + parentRelation: relation_b(where: {type: {_eq: "theme"}}) { + document: a { + title + } + } + } +} +`; + +export function ThemeSearch({ onChange }) { + const [suggestions, setSuggestions] = useState([]); + const [inputSearchValue, setInputSearchValue] = useState(""); + const [searchValue, , setDebouncedSearchValue] = useDebouncedState("", 500); + + const [results] = useQuery({ + pause: searchValue.length < 3, + query: searchThemesQuery, + variables: { + search: `%${searchValue}%`, + sources, + }, + }); + + useEffect(() => { + setSuggestions(results.data?.documents || []); + }, [results.data]); + + const onSearchValueChange = (event, { newValue }) => { + setInputSearchValue(newValue); + }; + + const onSuggestionSelected = ( + event, + { suggestion: { cdtnId, source, title = null, themeDocuments } } + ) => { + const position = themeDocuments.aggregate.count; + onChange({ cdtnId, position, source, title }); + }; + + const onSuggestionsFetchRequested = async ({ value }) => { + setDebouncedSearchValue(value); + setInputSearchValue(value); + }; + + const onSuggestionsClearRequested = () => { + setSuggestions([]); + }; + + const inputProps = { + onChange: onSearchValueChange, + placeholder: "Entrer le nom d'un thème et sélectionner le (ex: travail)", + value: inputSearchValue, + }; + return ( + <Autosuggest + suggestions={suggestions} + onSuggestionsFetchRequested={onSuggestionsFetchRequested} + onSuggestionsClearRequested={onSuggestionsClearRequested} + onSuggestionSelected={onSuggestionSelected} + getSuggestionValue={getSuggestionValue} + shouldRenderSuggestions={shouldRenderSuggestions} + renderInputComponent={renderInputComponent} + renderSuggestion={renderSuggestion} + renderSuggestionsContainer={renderSuggestionsContainer} + inputProps={inputProps} + alwaysRenderSuggestions + /> + ); +} + +ThemeSearch.propTypes = { + onChange: PropTypes.func.isRequired, +}; + +const renderInputComponent = (inputProps) => ( + <Input {...inputProps} sx={{ fontSize: "small", padding: "xxsmall" }} /> +); + +function shouldRenderSuggestions(value) { + return value.trim().length >= 2; +} + +const getSuggestionValue = (content) => content.title; + +function renderSuggestion(content) { + const parent = content.parentRelation[0]?.document?.title; + const parentTitle = parent; + return ( + <Box sx={{ lineHeight: 1.2 }}> + <Text sx={{ color: "muted", fontSize: "small", fontWeight: "300" }}> + {parentTitle} + </Text> + <Text sx={{ display: "block" }}>{content.title}</Text> + </Box> + ); +} + +function renderSuggestionsContainer({ containerProps, children }) { + return ( + <Box + {...containerProps} + sx={{ + position: "relative", + }} + > + <Box + sx={{ + ".react-autosuggest__suggestion--highlighted": { + bg: "info", + }, + '[class*="container--open"] &': { + border: "1px solid", + borderColor: "neutral", + borderRadius: "4px", + boxShadow: "medium", + left: 0, + maxHeight: "300px", + overflow: "scroll", + position: "absolute", + right: 0, + top: "4px", + }, + bg: "white", + li: { + ":nth-of-type(2n + 1):not(.react-autosuggest__suggestion--highlighted)": { + bg: "highlight", + }, + cursor: "pointer", + m: "0", + p: "xxsmall", + }, + + ul: { + listStyleType: "none", + m: "0", + p: "0", + }, + zIndex: 1, + }} + > + {children} + </Box> + </Box> + ); +} diff --git a/targets/frontend/src/components/home/UnThemedContent.js b/targets/frontend/src/components/home/UnThemedContent.js new file mode 100644 index 000000000..0778570d7 --- /dev/null +++ b/targets/frontend/src/components/home/UnThemedContent.js @@ -0,0 +1,84 @@ +import { SOURCES } from "@socialgouv/cdtn-sources"; +import Link from "next/link"; +import { RELATIONS } from "src/lib/relations"; +import { Box, Card, Flex, Message, NavLink, Text } from "theme-ui"; +import { useQuery } from "urql"; + +export const getUnthemedContentQuery = ` +query getUnthemed($themeSources: [String!]!) { + documents (where: { + is_available: {_eq: true} + is_published: {_eq: true} + source: { + _in: $themeSources + } + _and: [ + {_not: { + relation_b: {type: {_eq: "${RELATIONS.THEME_CONTENT}"} a :{source: {_eq: "${SOURCES.THEMES}"}} } + }} + {_not: {document: {_has_key: "split"}}} + ] + + }) { + source + slug + title + cdtnId: cdtn_id + } +} +`; + +export const THEMABLE_CONTENT = [ + SOURCES.CONTRIBUTIONS, + SOURCES.EDITORIAL_CONTENT, + SOURCES.EXTERNALS, + SOURCES.LETTERS, + SOURCES.SHEET_MT_PAGE, + SOURCES.SHEET_SP, + SOURCES.THEMATIC_FILES, + SOURCES.TOOLS, +]; + +export function UnThemedContent() { + const [result] = useQuery({ + query: getUnthemedContentQuery, + variables: { + themeSources: THEMABLE_CONTENT, + }, + }); + + const { data, fetching, error } = result; + + if (fetching) { + return null; + } + if (error) { + return ( + <Message> + <pre>{JSON.stringify(error, 2)}</pre> + </Message> + ); + } + return ( + <Link href="/unthemed" passHref> + <NavLink> + <Card> + <Flex sx={{ justifyContent: "flex-end" }}> + <Text + color="secondary" + sx={{ + fontSize: "xxlarge", + fontWeight: "600", + }} + > + {data.documents.length} + </Text> + </Flex> + <Box> + <Text> Contenus non thémés</Text> + </Box> + </Card> + </NavLink> + </Link> + ); +} diff --git a/targets/frontend/src/components/layout/Nav.js b/targets/frontend/src/components/layout/Nav.js index 7167cea30..b59ba30d9 100644 --- a/targets/frontend/src/components/layout/Nav.js +++ b/targets/frontend/src/components/layout/Nav.js @@ -131,6 +131,11 @@ export function Nav() { Fichiers </ActiveLink> </Li> + <Li> + <ActiveLink href="/unthemed" passHref> + Contenus sans thème + </ActiveLink> + </Li> </List> </Box> </Box> diff --git a/targets/frontend/src/components/login/index.js b/targets/frontend/src/components/login/index.js index 1536e6d00..215233608 100644 --- a/targets/frontend/src/components/login/index.js +++ b/targets/frontend/src/components/login/index.js @@ -56,7 +56,6 @@ const LoginForm = ({ authenticate, resetPassword, onSuccess }) => { label="Mot de passe" name="password" type="password" - placeholder="•••••••••" defaultValue={password} onChange={(e) => setPassword(e.target.value)} /> diff --git a/targets/frontend/src/pages/index.js b/targets/frontend/src/pages/index.js index 849a58488..e48a96739 100644 --- a/targets/frontend/src/pages/index.js +++ b/targets/frontend/src/pages/index.js @@ -1,20 +1,26 @@ import { GitlabButton } from "src/components/button/GitlabButton"; +import { UnThemedContent } from "src/components/home/UnThemedContent"; import { Layout } from "src/components/layout/auth.layout"; import { Inline } from "src/components/layout/Inline"; import { Stack } from "src/components/layout/Stack"; import { withCustomUrqlClient } from "src/hoc/CustomUrqlClient"; import { withUserProvider } from "src/hoc/UserProvider"; -import { Text } from "theme-ui"; +import { Heading } from "theme-ui"; export function IndexPage() { return ( - <Layout title="Home"> + <Layout title="Administration des contenus et gestion des alertes"> <Stack> - <Text>Administration des contenus et gestion des alertes</Text> <Inline> <GitlabButton env="prod">Mettre à jour la prod</GitlabButton> <GitlabButton env="preprod">Mettre à jour la preprod</GitlabButton> </Inline> + <Heading as="h2" sx={{ fontSize: "large" }}> + Tableau de bord + </Heading> + <Inline> + <UnThemedContent /> + </Inline> </Stack> </Layout> ); diff --git a/targets/frontend/src/pages/unthemed.js b/targets/frontend/src/pages/unthemed.js new file mode 100644 index 000000000..0968aef2b --- /dev/null +++ b/targets/frontend/src/pages/unthemed.js @@ -0,0 +1,144 @@ +import { getLabelBySource, getRouteBySource } from "@socialgouv/cdtn-sources"; +import { useForm } from "react-hook-form"; +import { Button } from "src/components/button"; +import { ThemePicker } from "src/components/forms/ContentPicker/ThemePicker"; +import { + getUnthemedContentQuery, + THEMABLE_CONTENT, +} from "src/components/home/UnThemedContent"; +import { Layout } from "src/components/layout/auth.layout"; +import { Stack } from "src/components/layout/Stack"; +import { Li, List } from "src/components/list"; +import { withCustomUrqlClient } from "src/hoc/CustomUrqlClient"; +import { withUserProvider } from "src/hoc/UserProvider"; +import { RELATIONS } from "src/lib/relations"; +import { Box, Flex, Heading, Message, NavLink, Spinner } from "theme-ui"; +import { useMutation, useQuery } from "urql"; + +const insertRelationMutation = ` +mutation insertRelation ($relations: [document_relations_insert_input!]!){ + insert_document_relations(objects: $relations) { + affected_rows + } +} +`; + +export function UnthemedPage() { + const { handleSubmit, control } = useForm(); + + const [result, reexecuteQuery] = useQuery({ + query: getUnthemedContentQuery, + variables: { + themeSources: THEMABLE_CONTENT, + }, + }); + + const [resultInsert, insertRelations] = useMutation(insertRelationMutation); + + function onSubmit(data) { + const themedDocuments = Object.entries(data).flatMap( + ([contentId, theme]) => { + if (!theme) return []; + return { + data: { position: theme.position }, + document_a: theme.cdtnId, + document_b: contentId, + type: RELATIONS.THEME_CONTENT, + }; + } + ); + insertRelations({ relations: themedDocuments }).then(() => { + reexecuteQuery({ requestPolicy: "network-only" }); + }); + } + const { data, fetching, error } = result; + const documentMap = + data?.documents.reduce((state, { cdtnId, source, title, slug }) => { + // eslint-disable-next-line no-prototype-builtins + if (state.hasOwnProperty(source)) { + state[source].push({ cdtnId, slug, title }); + } else state[source] = [{ cdtnId, slug, title }]; + return state; + }, {}) || {}; + const documentsBySource = Object.entries(documentMap); + if (error) { + return ( + <Layout title="Contenus sans thèmes"> + <Stack> + <Message> + <pre>{JSON.stringify(error, 2)}</pre> + </Message> + </Stack> + </Layout> + ); + } + + return ( + <Layout title="Contenus sans thème"> + {!data && fetching && <Spinner />} + <form onSubmit={handleSubmit(onSubmit)}> + <Stack> + {documentsBySource.map(([source, documents]) => { + return ( + <Stack key={source} gap="small"> + <Heading as="h2" p="0" sx={{ fontSize: "large" }}> + {getLabelBySource(source)} + </Heading> + <List> + {documents.map(({ cdtnId, title, slug }) => ( + <Li key={cdtnId}> + <Flex paddingBottom="xxsmall"> + <Box + sx={{ flex: 1, marginRight: "small", minWidth: 0 }} + title={title} + > + <NavLink + href={`https://cdtn-preprod-code-travail.dev2.fabrique.social.gouv.fr/${getRouteBySource( + source + )}/${slug}`} + target="_blank" + rel="noreferrer noopener" + sx={{ + display: "block", + fontWeight: "300", + overflow: "hidden", + textDecoration: "underline", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }} + > + {title} + </NavLink> + </Box> + <Box sx={{ flex: 1 }}> + <ThemePicker + name={`${cdtnId}`} + control={control} + defaultValue="" + /> + </Box> + </Flex> + </Li> + ))} + </List> + </Stack> + ); + })} + + {documentsBySource.length > 0 && ( + <Box> + <Button + type="submit" + disabled={fetching || resultInsert.fetching} + > + Enregistrer + </Button> + </Box> + )} + </Stack> + </form> + </Layout> + ); +} + +export default withCustomUrqlClient(withUserProvider(UnthemedPage));