diff --git a/.dockerignore b/.dockerignore index 7f2c6dd27..05cbc7845 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,3 +4,4 @@ *.md **/node_modules **/.next/cache +data/* diff --git a/.gitignore b/.gitignore index 033103d36..71797681d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ *.DS_Store node_modules .env.production +data/* diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4e952baa4..01a0d300d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -17,6 +17,10 @@ variables: ENABLE_AZURE_POSTGRES: 1 VALUES_FILE: ./.k8s/app.values.yml +Install: + extends: .autodevops_install + image: node:12.18.0-alpine3.11 + Build: extends: .autodevops_build variables: diff --git a/Dockerfile b/Dockerfile index 2227fd4ae..b05b690b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,18 @@ -FROM node:14.4-alpine3.11 +FROM node:12.18.0-alpine3.11 WORKDIR /app COPY package.json yarn.lock ./ -RUN yarn --frozen-lockfile +RUN apk add --no-cache build-base python --virtual .build-deps \ + && yarn --production --frozen-lockfile \ + && apk del .build-deps COPY next.config.js ./ COPY .env ./.env COPY .next/ ./.next +COPY scripts/ ./scripts +COPY data/ ./data COPY public/ ./public USER node diff --git a/hasura/Dockerfile b/hasura/Dockerfile index a917f2eb1..c1ad6af03 100644 --- a/hasura/Dockerfile +++ b/hasura/Dockerfile @@ -1,4 +1,4 @@ -FROM hasura/graphql-engine:v1.2.1.cli-migrations-v2 +FROM hasura/graphql-engine:v1.2.2.cli-migrations-v2 ENV HASURA_GRAPHQL_ENABLE_TELEMETRY false COPY ./migrations /hasura-migrations COPY ./metadata /hasura-metadata diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml index 4f6aedc3c..c57eab83e 100644 --- a/hasura/metadata/tables.yaml +++ b/hasura/metadata/tables.yaml @@ -98,6 +98,24 @@ headers: - name: email-secret value_from_env: ACCOUNT_EMAIL_SECRET +- table: + schema: public + name: alert_status + array_relationships: + - name: alerts + using: + foreign_key_constraint_on: + column: status + table: + schema: public + name: alerts +- table: + schema: public + name: alerts + object_relationships: + - name: source + using: + foreign_key_constraint_on: repository - table: schema: public name: roles @@ -122,3 +140,14 @@ columns: - role filter: {} +- table: + schema: public + name: sources + array_relationships: + - name: alerts + using: + foreign_key_constraint_on: + column: repository + table: + schema: public + name: alerts diff --git a/hasura/migrations/1591618615701_alerts/down.sql b/hasura/migrations/1591618615701_alerts/down.sql new file mode 100644 index 000000000..a084e3530 --- /dev/null +++ b/hasura/migrations/1591618615701_alerts/down.sql @@ -0,0 +1,6 @@ + +DROP TABLE "public"."alerts"; + +DROP TABLE "public"."sources"; + +DROP TABLE "public"."alert_status"; diff --git a/hasura/migrations/1591618615701_alerts/up.sql b/hasura/migrations/1591618615701_alerts/up.sql new file mode 100644 index 000000000..d23c307d5 --- /dev/null +++ b/hasura/migrations/1591618615701_alerts/up.sql @@ -0,0 +1,45 @@ + +CREATE TABLE "public"."alert_status"("name" text NOT NULL DEFAULT 'new', PRIMARY KEY ("name") ); +COMMENT ON TABLE "public"."alert_status" IS E'alert statuses'; + +INSERT INTO public.alert_status (name) VALUES ('todo'); +INSERT INTO public.alert_status (name) VALUES ('doing'); +INSERT INTO public.alert_status (name) VALUES ('done'); +INSERT INTO public.alert_status (name) VALUES ('rejected'); + +CREATE TABLE "public"."sources"( + "repository" text NOT NULL, + "label" text NOT NULL, + "tag" text NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ("repository") +); + +COMMENT ON TABLE "public"."sources" IS E'sources are git repository that acts as data sources to track changes'; + +INSERT INTO public.sources (repository, label, tag) VALUES ('socialgouv/legi-data', 'code du travail', 'v1.12.0'); +INSERT INTO public.sources (repository, label, tag) VALUES ('socialgouv/kali-data', 'conventions collectives', 'v1.64.0'); + +CREATE TABLE "public"."alerts"( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "info" jsonb NOT NULL, + "status" text NOT NULL DEFAULT 'todo', + "repository" text NOT NULL, + "ref" text NOT NULL, + "changes" jsonb NOT NULL, + "created_at" timestamptz NULL DEFAULT now(), + "updated_at" timestamptz NULL DEFAULT now(), + PRIMARY KEY ("id") , + FOREIGN KEY ("status") REFERENCES "public"."alert_status"("name") ON UPDATE restrict ON DELETE restrict, + FOREIGN KEY ("repository") REFERENCES "public"."sources"("repository") ON UPDATE restrict ON DELETE cascade); + +COMMENT ON TABLE "public"."alerts" IS + E'alerts reprensent a change in a text from a source'; + +CREATE TRIGGER "set_public_alerts_updated_at" + BEFORE UPDATE ON public.alerts + FOR EACH ROW + EXECUTE PROCEDURE trigger_set_timestamp(); + +COMMENT ON TRIGGER "set_public_alerts_updated_at" ON public.alerts + IS 'trigger to set value of column "updated_at" to current timestamp on row update'; diff --git a/package.json b/package.json index 8a6061d83..47957e752 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dependencies": { "@hapi/boom": "^9.1.0", "@hapi/joi": "^17.1.1", + "@reach/accordion": "^0.10.3", "@reach/dialog": "^0.10.3", "@reach/menu-button": "^0.10.3", "@reach/visually-hidden": "^0.10.2", @@ -17,12 +18,14 @@ "@zeit/next-source-maps": "0.0.4-canary.1", "argon2": "^0.26.2", "cookie": "^0.4.1", - "dotenv": "^8.2.0", + "diff": "^4.0.2", "graphql": "^15.0.0", "http-proxy-middleware": "^1.0.4", + "isomorphic-unfetch": "^3.0.0", "jsonwebtoken": "^8.5.1", "next": "^9.4.4", "next-urql": "^0.3.8", + "nodegit": "^0.26.5", "nodemailer": "^6.4.8", "polished": "^3.6.5", "react": "^16.13.1", @@ -30,8 +33,11 @@ "react-hook-form": "^5.7.2", "react-icons": "^3.10.0", "react-is": "^16.13.1", + "semver": "^7.3.2", "sentry-testkit": "^3.2.1", "theme-ui": "^0.3.1", + "unist-util-parents": "^1.0.3", + "unist-util-select": "^3.0.1", "urql": "^1.9.8", "uuid": "^8.1.0", "wonka": "^4.0.14" @@ -50,7 +56,12 @@ "scripts": { "dev": "next dev", "build": "next build", + "prestart": "node scripts/update-alerts.js", "start": "next start", + "alert": " node scripts/update-alerts.js", + "alert:dev": "GRAPHQL_ENDPOINT=http://localhost:8080/v1/graphql HASURA_GRAPHQL_ADMIN_SECRET=admin1 node scripts/update-alerts.js", + "alert:dump": "GRAPHQL_ENDPOINT=http://localhost:8080/v1/graphql HASURA_GRAPHQL_ADMIN_SECRET=admin1 DUMP=true node scripts/update-alerts.js > data/dump.json", + "alert:populate": "GRAPHQL_ENDPOINT=http://localhost:8080/v1/graphql HASURA_GRAPHQL_ADMIN_SECRET=admin1 node scripts/add-alerts.js", "lint": "eslint src/*", "test": "jest" }, diff --git a/scripts/add-alerts.js b/scripts/add-alerts.js new file mode 100644 index 000000000..1cc8e19b8 --- /dev/null +++ b/scripts/add-alerts.js @@ -0,0 +1,30 @@ +const { promises: fs } = require("fs"); +const path = require("path"); +const filename = + process.env.DUMP_FILE || path.join(__dirname, "..", "data", "dump.json"); +const { updateSource, insertAlert } = require("./update-alerts"); + +async function main() { + console.log(filename); + const fileContent = await fs.readFile(filename); + const data = JSON.parse(fileContent); + + for (const result of data) { + if (result.changes.length === 0) { + console.log(`no update for ${result.repository}`); + continue; + } + const inserts = await Promise.all( + result.changes.map((diff) => insertAlert(result.repository, diff)) + ); + inserts.forEach((insert) => { + const { ref, repository, info } = insert.returning[0]; + console.log(`insert alert for ${ref} on ${repository} (${info.file})`); + }); + console.log(`create ${inserts.length} alert for ${result.repository}`); + const update = await updateSource(result.repository, result.newRef); + console.log(`update source ${update.repository} to ${update.tag}`); + } +} + +main().catch(console.error); diff --git a/scripts/lib/ccn-list.js b/scripts/lib/ccn-list.js new file mode 100644 index 000000000..1de82d3a3 --- /dev/null +++ b/scripts/lib/ccn-list.js @@ -0,0 +1,55 @@ +const ccns = [ + { id: "KALICONT000005635624", num: 16 }, + { id: "KALICONT000005635234", num: 29 }, + { id: "KALICONT000005635613", num: 44 }, + { id: "KALICONT000005635630", num: 86 }, + { id: "KALICONT000005635184", num: 176 }, + { id: "KALICONT000005635872", num: 275 }, + { id: "KALICONT000005635856", num: 292 }, + { id: "KALICONT000005635407", num: 413 }, + { id: "KALICONT000005635373", num: 573 }, + { id: "KALICONT000005635842", num: 650 }, + { id: "KALICONT000005635617", num: 675 }, + { id: "KALICONT000005635826", num: 787 }, + { id: "KALICONT000005635886", num: 843 }, + { id: "KALICONT000005635953", num: 1043 }, + { id: "KALICONT000005635191", num: 1090 }, + { id: "KALICONT000005635409", num: 1147 }, + { id: "KALICONT000005635418", num: 1266 }, + { id: "KALICONT000005635405", num: 1351 }, + { id: "KALICONT000005635653", num: 1404 }, + { id: "KALICONT000005635444", num: 1480 }, + { id: "KALICONT000005635594", num: 1483 }, + { id: "KALICONT000005635173", num: 1486 }, + { id: "KALICONT000005635596", num: 1501 }, + { id: "KALICONT000005635421", num: 1505 }, + { id: "KALICONT000005635435", num: 1516 }, + { id: "KALICONT000005635870", num: 1517 }, + { id: "KALICONT000005635177", num: 1518 }, + { id: "KALICONT000005635413", num: 1527 }, + { id: "KALICONT000005635221", num: 1596 }, + { id: "KALICONT000005635220", num: 1597 }, + { id: "KALICONT000005635871", num: 1606 }, + { id: "KALICONT000005635918", num: 1672 }, + { id: "KALICONT000005635467", num: 1702 }, + { id: "KALICONT000005635685", num: 1740 }, + { id: "KALICONT000005635534", num: 1979 }, + { id: "KALICONT000005635528", num: 1996 }, + { id: "KALICONT000005635550", num: 2098 }, + { id: "KALICONT000005635792", num: 2111 }, + { id: "KALICONT000005635780", num: 2120 }, + { id: "KALICONT000005635557", num: 2148 }, + { id: "KALICONT000005635085", num: 2216 }, + { id: "KALICONT000005635813", num: 2264 }, + { id: "KALICONT000005635807", num: 2395 }, + { id: "KALICONT000017941839", num: 2420 }, + { id: "KALICONT000017577652", num: 2511 }, + { id: "KALICONT000018563755", num: 2596 }, + { id: "KALICONT000018773893", num: 2609 }, + { id: "KALICONT000018926209", num: 2614 }, + { id: "KALICONT000025805800", num: 2941 }, + { id: "KALICONT000027172335", num: 3043 }, + { id: "KALICONT000027084096", num: 3127 }, +]; + +module.exports = { ccns }; diff --git a/scripts/lib/compareTree.js b/scripts/lib/compareTree.js new file mode 100644 index 000000000..b6d7897b4 --- /dev/null +++ b/scripts/lib/compareTree.js @@ -0,0 +1,150 @@ +const parents = require("unist-util-parents"); +const { selectAll } = require("unist-util-select"); + +const getParents = (node) => { + var chain = []; + while (node) { + node.data.title && chain.unshift(node.data.title); + node = node.parent; + } + return chain; +}; + +// find the first parent text id to make legifrance links later +const getParentTextId = (node) => { + let id; + node = node.parent; + while (node) { + if ( + node.data && + node.data.id && + node.data.id.match(/^(KALI|LEGI)TEXT\d+$/) + ) { + id = node.data.id; + break; + } + node = node.parent; + } + return id || null; +}; + +// find the root text id to make legifrance links later +const getRootId = (node) => { + let id; + while (node) { + id = node.data.id; + node = node.parent; + } + return id || null; +}; + +const addContext = (node) => ({ + ...node, + parents: getParents(node), + textId: getParentTextId(node) || null, + rootId: getRootId(node) || null, +}); + +// dont include children in final results +const stripChildren = (node) => node; //({ children, ...props }) => props; + +// return diffed articles nodes +const compareArticles = (tree1, tree2, comparator) => { + const parentsTree1 = parents(tree1); + const parentsTree2 = parents(tree2); + + // all articles from tree1 + const articles1 = selectAll("article", parentsTree1).map(addContext); + const articles1cids = articles1 + .map((a) => a && a.data && a.data.cid) + .filter(Boolean); + // all articles from tree2 + const articles2 = selectAll("article", parentsTree2).map(addContext); + const articles2cids = articles2 + .map((a) => a && a.data && a.data.cid) + .filter(Boolean); + + // new : articles in tree2 not in tree1 + const newArticles = articles2.filter( + (art) => art && art.data && !articles1cids.includes(art.data.cid) + ); + const newArticlesCids = newArticles.map((a) => a.data.cid); + + // supressed: articles in tree1 not in tree2 + const missingArticles = articles1.filter( + (art) => art && art.data && !articles2cids.includes(art.data.cid) + ); + + // modified : articles with modified texte + const modifiedArticles = articles2.filter( + (art) => + art && + art.data && + // exclude new articles + !newArticlesCids.includes(art.data.cid) && + articles1.find( + // same article, different texte + (art2) => + art2 && + art2.data && + art2.data.cid === art.data.cid && + comparator(art, art2) + ) + ); + + // all sections from tree1 + const sections1 = selectAll("section", parentsTree1.children).map(addContext); + + const sections1cids = sections1.map((a) => a.data.cid); + + // all sections from tree2 + const sections2 = selectAll("section", parentsTree2.children).map(addContext); + const sections2cids = sections2.map((a) => a.data.cid); + + // new : sections in tree2 not in tree1 + const newSections = sections2.filter( + (section) => !sections1cids.includes(section.data.cid) + ); + const newSectionsCids = newSections.map((a) => a.data.cid); + + // supressed: sections in tree1 not in tree2 + const missingSections = sections1.filter( + (section) => !sections2cids.includes(section.data.cid) + ); + + // modified : sections with modified texte + const modifiedSections = sections2.filter( + (section) => + // exclude new sections + !newSectionsCids.includes(section.data.cid) && + sections1.find( + // same section, different etat + (section2) => + section2.data.cid === section.data.cid && + section2.data.etat !== section.data.etat + ) + ); + + const changes = { + added: [...newSections, ...newArticles].map(stripChildren), + removed: [...missingSections, ...missingArticles].map(stripChildren), + modified: [ + ...modifiedSections.map((modif) => ({ + ...modif, + // add the previous version in the result so we can diff later + previous: sections1.find( + (a) => a.data[idField] === modif.data[idField] + ), + })), + ...modifiedArticles.map((modif) => ({ + ...modif, + // add the previous version in the result so we can diff later + previous: articles1.find((a) => a.data.cid === modif.data.cid), + })), + ].map(stripChildren), + }; + + return changes; +}; + +module.exports = { compareArticles }; diff --git a/scripts/update-alerts.js b/scripts/update-alerts.js new file mode 100644 index 000000000..716a75efd --- /dev/null +++ b/scripts/update-alerts.js @@ -0,0 +1,279 @@ +const { client } = require("../src/lib/graphqlApiClient.js"); +const path = require("path"); +const nodegit = require("nodegit"); +const semver = require("semver"); +const { ccns } = require("./lib/ccn-list.js"); +const { compareArticles } = require("./lib/compareTree.js"); + +const sourcesQuery = ` +query getSources { + sources { + repository + tag + } +} +`; + +const insertAlertsMutation = ` +mutation insert_alerts($data: alerts_insert_input!) { + alert: insert_alerts(objects: [$data]) { + returning { + ref, + repository, + info + } + } +} +`; + +const updateSourceMutation = ` +mutation updateSource($repository: String!, $tag: String!){ + source: update_sources_by_pk( + _set:{ + tag: $tag + }, + pk_columns: { + repository: $repository + } + ){ + repository, tag + } +} +`; + +function getFileFilter(repository) { + switch (repository) { + case "socialgouv/legi-data": + // only code-du-travail + return (path) => /LEGITEXT000006072050\.json$/.test(path); + case "socialgouv/kali-data": + // only a ccn matching our list + return (path) => ccns.some((ccn) => new RegExp(ccn.id).test(path)); + default: + return () => true; + } +} + +function getFileComparator(repository) { + switch (repository) { + case "socialgouv/legi-data": + // only code-du-travail + return (art1, art2) => + art1.data.texte !== art2.data.texte || + art1.data.etat !== art2.data.etat || + art1.data.nota !== art2.data.nota; + case "socialgouv/kali-data": + // only a ccn matching our list + return (art1, art2) => + art1.data.content !== art2.data.content || + art1.data.etat !== art2.data.etat; + default: + return () => true; + } +} + +function getFilename(patche) { + return patche.newFile().path(); +} + +async function getSources() { + const result = await client.query(sourcesQuery).toPromise(); + if (result.error) { + console.error(result.error); + throw new Error("getSources"); + } + return result.data.sources; +} + +async function insertAlert(repository, changes) { + const data = { + repository, + info: { + num: changes.num, + title: changes.title, + id: changes.id, + file: changes.file, + }, + ref: changes.ref, + changes: { + added: changes.added, + removed: changes.removed, + modified: changes.modified, + }, + }; + const result = await client + .mutation(insertAlertsMutation, { data }) + .toPromise(); + if (result.error) { + console.error(result.error); + throw new Error("insertAlert"); + } + return result.data.alert; +} + +async function updateSource(repository, tag) { + const result = await client + .mutation(updateSourceMutation, { + repository, + tag, + }) + .toPromise(); + + if (result.error) { + console.error(result.error); + throw new Error("updateSource"); + } + return result.data.source; +} + +async function openRepo({ repository }) { + const [org, repositoryName] = repository.split("/"); + const localPath = path.join(__dirname, "..", "data", repositoryName); + let repo; + try { + repo = await nodegit.Repository.open(localPath); + await repo.checkoutBranch("master"); + await repo.mergeBranches("master", "origin/master"); + } catch (err) { + repo = await nodegit.Clone( + `git://github.com/${org}/${repositoryName}`, + localPath + ); + } + return repo; +} + +async function getNewerTagsFromRepo(repo, tag) { + const tags = await nodegit.Tag.list(repo); + return await Promise.all( + tags + .flatMap((t) => { + if (!semver.valid(t)) { + return []; + } + if (semver.lt(t, tag)) { + return []; + } + return t; + }) + .sort((a, b) => (semver.lt(a, b) ? -1 : 1)) + .map(async (tag) => { + const reference = await repo.getReference(tag); + const targetRef = await reference.peel(nodegit.Object.TYPE.COMMIT); + const commit = await repo.getCommit(targetRef); + return { + ref: tag, + commit, + }; + }) + ); +} + +async function getDiffFromTags(tags, id) { + let [previousTag] = tags; + const [, ...newTags] = tags; + const changes = []; + const fileFilter = getFileFilter(id); + + for (const tag of newTags) { + const { commit: previousCommit } = previousTag; + const { commit } = tag; + const [prevTree, currTree] = await Promise.all([ + previousCommit.getTree(), + commit.getTree(), + ]); + + const patches = await currTree + .diff(prevTree) + .then((diff) => diff.patches()); + + const files = patches.map(getFilename).filter(fileFilter); + + if (files.length > 0) { + const fileChanges = await Promise.all( + files.map((file) => + getFileDiffFromTrees(file, currTree, prevTree, getFileComparator(id)) + ) + ); + fileChanges + .filter( + (file) => + file.modified.length > 0 || + file.removed.length > 0 || + file.added.length > 0 + ) + .forEach((change) => { + changes.push({ ref: tag.ref, ...change }); + }); + } + previousTag = tag; + } + return changes; +} + +async function getFileDiffFromTrees( + filePath, + currGitTree, + prevGitTree, + compareFn +) { + const [currentFile, prevFile] = await Promise.all([ + currGitTree.getEntry(filePath).then((entry) => entry.getBlob()), + prevGitTree.getEntry(filePath).then((entry) => entry.getBlob()), + ]); + const currTree = JSON.parse(currentFile.toString()); + const prevTree = JSON.parse(prevFile.toString()); + return { + file: filePath, + id: currTree.data.id, + num: currTree.data.num, + title: currTree.data.title, + ...compareArticles(prevTree, currTree, compareFn), + }; +} + +async function main() { + const sources = await getSources(); + const results = []; + for (const source of sources) { + const repo = await openRepo(source); + const tags = await getNewerTagsFromRepo(repo, source.tag); + const diffs = await getDiffFromTags(tags, source.repository); + const [lastTag] = tags.slice(-1); + + results.push({ + repository: source.repository, + changes: diffs, + newRef: lastTag.ref, + }); + } + + if (process.env.DUMP) { + console.log(JSON.stringify(results, 0, 2)); + } else { + for (const result of results) { + if (result.changes.length === 0) { + console.log(`no update for ${result.repository}`); + continue; + } + const inserts = await Promise.all( + result.changes.map((diff) => insertAlert(result.repository, diff)) + ); + inserts.forEach((insert) => { + const { ref, repository, info } = insert.returning[0]; + console.log(`insert alert for ${ref} on ${repository} (${info.file})`); + }); + console.log(`create ${inserts.length} alert for ${result.repository}`); + const update = await updateSource(result.repository, result.newRef); + console.log(`update source ${update.repository} to ${update.tag}`); + } + } +} + +main().catch(console.error); + +module.exports = { + getSources, + insertAlert, + updateSource, +}; diff --git a/src/components/alerts/AlertTitle.js b/src/components/alerts/AlertTitle.js new file mode 100644 index 000000000..c682750f0 --- /dev/null +++ b/src/components/alerts/AlertTitle.js @@ -0,0 +1,18 @@ +/** @jsx jsx */ + +import { jsx, Flex, Box } from "theme-ui"; +import PropTypes from "prop-types"; +import { AlertStatus } from "./Status"; + +export function AlertTitle({ alertId, ...props }) { + return ( + + + + + ); +} + +AlertTitle.propTypes = { + alertId: PropTypes.string.isRequired, +}; diff --git a/src/components/alerts/Status.js b/src/components/alerts/Status.js new file mode 100644 index 000000000..b80905dc5 --- /dev/null +++ b/src/components/alerts/Status.js @@ -0,0 +1,45 @@ +/** @jsx jsx */ + +import { IoIosCheckmark, IoIosClose } from "react-icons/io"; +import { MenuButton, MenuItem } from "../button"; +import { jsx } from "theme-ui"; +import PropTypes from "prop-types"; +import { useMutation } from "urql"; + +export const alertMutation = ` +mutation updateAlertStatus($id:uuid!, $status:String!) { + update_alerts_by_pk( + pk_columns: { + id: $id + } + _set: { status: $status } + ){ + __typename + } +} +`; + +export function AlertStatus({ alertId }) { + const [, executeUpdate] = useMutation(alertMutation); + function updateStatus(status) { + console.log("update statys", alertId, status); + executeUpdate({ id: alertId, status }); + } + return ( + + updateStatus("doing")}> + + En cours + + updateStatus("done")}> + Traité + + updateStatus("rejected")}> + Rejeté + + + ); +} +AlertStatus.propTypes = { + alertId: PropTypes.string.isRequired, +}; diff --git a/src/components/button/index.js b/src/components/button/index.js index e9e54e8c8..823f75f51 100644 --- a/src/components/button/index.js +++ b/src/components/button/index.js @@ -13,7 +13,13 @@ import { MenuList, MenuItem as ReachMenuItem, } from "@reach/menu-button"; -import { IoMdMore } from "react-icons/io"; + +import { + AccordionButton as ReachAccordionButton, + useAccordionItemContext, +} from "@reach/accordion"; + +import { IoMdMore, IoIosArrowForward, IoIosArrowDown } from "react-icons/io"; const buttonPropTypes = { variant: PropTypes.oneOf(["secondary", "primary", "link"]), @@ -188,3 +194,28 @@ export function MenuItem(props) { /> ); } + +export function AccordionButton({ children, ...props }) { + return ( + + + {children} + + ); +} + +export function ExpandedIcon() { + const { isExpanded } = useAccordionItemContext(); + return isExpanded ? : ; +} diff --git a/src/components/changes/ChangeGroup.js b/src/components/changes/ChangeGroup.js new file mode 100644 index 000000000..8e6610d69 --- /dev/null +++ b/src/components/changes/ChangeGroup.js @@ -0,0 +1,27 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { AccordionButton } from "src/components/button"; +import { AccordionItem, AccordionPanel } from "@reach/accordion"; + +export const ChangesGroup = ({ changes, label, renderChange }) => { + return changes.length > 0 ? ( + + {label} + +
    {changes.map(renderChange)}
+
+
+ ) : null; +}; + +ChangesGroup.propTypes = { + label: PropTypes.string.isRequired, + renderChange: PropTypes.func.isRequired, + changes: PropTypes.arrayOf( + PropTypes.shape({ + added: PropTypes.object, + removed: PropTypes.object, + modified: PropTypes.object, + }) + ), +}; diff --git a/src/components/changes/ViewDiff.js b/src/components/changes/ViewDiff.js new file mode 100644 index 000000000..145354730 --- /dev/null +++ b/src/components/changes/ViewDiff.js @@ -0,0 +1,69 @@ +// adapted from https://github.com/davidmason/react-stylable-diff/blob/master/lib/react-diff.js +/** @jsx jsx */ + +import { useState, useMemo } from "react"; +import { jsx } from "theme-ui"; +var jsdiff = require("diff"); + +const fnMap = { + chars: jsdiff.diffChars, + words: jsdiff.diffWords, + sentences: jsdiff.diffSentences, + json: jsdiff.diffJson, +}; + +export const ViewDiff = ({ sx, type, inputA, inputB }) => { + const [mode, setMode] = useState(type); + const diff = fnMap[mode](inputA, inputB); + + const groupName = useMemo(() => Math.random()); + + const result = diff.map((part, index) => { + if (part.added) { + return ( + + {part.value} + + ); + } + if (part.removed) { + return ( + + {part.value} + + ); + } + return {part.value}; + }); + return ( +
+
+ Diff mode : + setMode("words")} + style={{ marginLeft: 10 }} + checked={mode === "words"} + />{" "} + Mots + setMode("sentences")} + style={{ marginLeft: 10 }} + checked={mode === "sentences"} + />{" "} + Phrases +
+ {result} +
+ ); +}; + +ViewDiff.defaultProps = { + inputA: "", + inputB: "", + type: "chars", + className: "Difference", +}; diff --git a/src/components/changes/index.js b/src/components/changes/index.js new file mode 100644 index 000000000..d732ce283 --- /dev/null +++ b/src/components/changes/index.js @@ -0,0 +1,85 @@ +/** @jsx jsx */ +import { jsx, Badge, Card } from "theme-ui"; +import { ViewDiff } from "./ViewDiff"; +import { Collapsible } from "../collapsible"; +import PropTypes from "prop-types"; + +export function DiffChange({ change, repository }) { + const { data, previous } = change; + const textFieldname = /legi-data/.test(repository) ? "texte" : "content"; + const content = data[textFieldname] || ""; + const previousContent = previous?.data[textFieldname] || ""; + const showDiff = previous && content !== previousContent; + const showNotaDiff = previous && previous.data.nota !== data.nota; + return ( +
+ Article {data.num}{" "} + {previous?.data.etat && previous?.data.etat !== data.etat && ( + <> + + {previous.data.etat} + {" "} + ›{" "} + + )} + + {data.etat} + + {showDiff && ( + + + + + + )} + {showNotaDiff && ( + + + + + + )} +
+ ); +} + +DiffChange.propTypes = { + repository: PropTypes.string.isRequired, + change: PropTypes.object.isRequired, +}; + +function getBadgeColor(etat) { + switch (etat) { + case "VIGUEUR": + return "positive"; + case "MOIFIE": + return "caution"; + case "ABROGE": + case "ABROGE_DIFF": + return "critical"; + default: + return "info"; + } +} diff --git a/src/components/collapsible/index.js b/src/components/collapsible/index.js new file mode 100644 index 000000000..8ddb5e8dc --- /dev/null +++ b/src/components/collapsible/index.js @@ -0,0 +1,16 @@ +import { Button } from "../button"; + +import React, { useState } from "react"; +import { IoMdGitNetwork } from "react-icons/io"; + +export function Collapsible({ label, children, ...props }) { + const [isVisible, setVisible] = useState(false); + return ( +
+ + {isVisible && children} +
+ ); +} diff --git a/src/components/layout/Nav.js b/src/components/layout/Nav.js index 9d908335e..9c6c3c88c 100644 --- a/src/components/layout/Nav.js +++ b/src/components/layout/Nav.js @@ -1,17 +1,40 @@ -import { jsx, Box, NavLink, Text } from "theme-ui"; +/** @jsx jsx */ + +import { jsx, Box, NavLink, Text, Badge } from "theme-ui"; import { useAuth } from "src/hooks/useAuth"; import Link from "next/link"; import { Li, List } from "../list"; +import { useQuery } from "urql"; +import { useMemo } from "react"; + +const getSourcesQuery = ` +query getAlerts{ + sources { + repository, + label, + alerts: alerts_aggregate(where: {status: {_eq: "todo"}}) { + aggregate { + count + } + } + } +} +`; -/** @jsx jsx */ export function Nav() { const { user } = useAuth(); const isAdmin = user?.roles.some(({ role }) => role === "admin"); - + // https://formidable.com/open-source/urql/docs/basics/document-caching/#adding-typenames + const context = useMemo( + () => ({ additionalTypenames: ["alerts", "sources"] }), + [] + ); + const [result] = useQuery({ query: getSourcesQuery, context }); + const { fetching, data } = result; return ( - Navigation + Utilisateurs {isAdmin && (
  • @@ -21,6 +44,28 @@ export function Nav() {
  • )}
    + Alertes + {!fetching && ( + + {data.sources.map((source) => ( +
  • + + {source.label} + + {" "} + {source.alerts.aggregate.count > 0 && ( + + {source.alerts.aggregate.count} + + )} +
  • + ))} +
    + )}
    ); diff --git a/src/components/tabs/index.js b/src/components/tabs/index.js new file mode 100644 index 000000000..99b1c177a --- /dev/null +++ b/src/components/tabs/index.js @@ -0,0 +1,50 @@ +/** @jsx jsx */ +import { jsx } from "theme-ui"; +import PropTypes from "prop-types"; + +export function Tabs(props) { + return ( +