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));