Unverified Commit 0c1b276e authored by Ivan Gabriele's avatar Ivan Gabriele Committed by GitHub
Browse files

feat(app): add tracking board (#1091)

parent c42ffa27
......@@ -17,6 +17,11 @@ APP_PORT=3100
APP_PORT_PUBLIC=3100
APP_SCHEME=http
# ------------------------------------------------
# External endpoints
# CDTN_API_URL=http://localhost:3300
# ------------------------------------------------
# Development & test variables
......
......@@ -6,6 +6,7 @@ help make it even better than it is today!
- [Contribute](#contribute)
- [Prerequisites](#prerequisites)
- [Get Started](#get-started)
- [Standalone](#standalone)
- [Test](#test)
- [Scripts](#scripts)
- [Recommended IDE Settings](#recommended-ide-settings)
......@@ -63,6 +64,18 @@ The website should now be available at: http://localhost:3100.
- Email: `marin@sea.com`<br>
Mot de passe: `Azerty123`
### Standalone
Standalone dev also runs [**ctdn-api**](https://github.com/SocialGouv/cdtn-api) locally:
First, uncomment `CDTN_API_URL=http://localhost:3300` in `.env` file.
Then run:
```sh
yarn dev:standalone
```
### Test
- All Tests: `yarn test`
......
# CDTN Back-office
[![licence Apache 2.0][img-license]][link-license]
[![Travis CI Status][img-build]][link-build]
[![Coveralls Code Coverage][img-coverage]][link-coverage]
[![License][img-license]][link-license]
[![Build Status][img-build]][link-build]
[![Code Coverage][img-coverage]][link-coverage]
Data administration portal for the official [Code du travail numérique][link-cdtn] (French Labor
Code and Agreements).
......
......@@ -8,3 +8,26 @@ services:
postgrest:
ports:
- ${DEV_POSTGREST_PORT}:3000
cdtn_api:
image: igabriele/cdtn-api:latest
restart: always
environment:
PORT: 3300
REDIS_URL: redis://redis:6379
ports:
- 3300:3300
depends_on:
- redis
redis:
image: redis:6
restart: always
ports:
- 6379:6379
volumes:
- redis-data:/data
volumes:
postgre-data:
redis-data:
......@@ -27,7 +27,6 @@ services:
- db
api:
restart: always
build:
context: ./packages/api
args:
......@@ -35,13 +34,13 @@ services:
DB_URI: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
NODE_ENV: ${NODE_ENV}
POSTGREST_URI: http://postgrest:3000
restart: always
ports:
- ${API_PORT}:${API_PORT}
depends_on:
- postgrest
app:
restart: always
build:
context: ./packages/app
args:
......@@ -49,9 +48,11 @@ services:
API_PORT_PUBLIC: ${API_PORT_PUBLIC}
API_SCHEME: ${API_SCHEME}
API_URI_DOCKER: http://api:${API_PORT}
CDTN_API_URL: ${CDTN_API_URL}
DB_URI: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
NODE_ENV: ${NODE_ENV}
APP_PORT: ${APP_PORT}
restart: always
ports:
- ${APP_PORT}:${APP_PORT}
depends_on:
......
......@@ -5,6 +5,7 @@
"license": "Apache-2.0",
"private": true,
"scripts": {
"api:cache": "node -r dotenv/config -r esm ./scripts/dev/updateApiCache.js",
"db:backup": "node -r dotenv/config ./scripts/db/backup.js",
"db:migrate": "knex migrate:latest",
"db:migrate:make": "node ./scripts/db/generateMigration.js",
......@@ -13,7 +14,9 @@
"db:snapshot:restore": "node -r dotenv/config ./scripts/db/restore.js --dev",
"db:snapshot:update": "node -r dotenv/config ./scripts/db/backup.js --dev",
"dev": "yarn dev:docker && yarn dev:packages",
"dev:standalone": "yarn dev:docker:standalone && yarn dev:packages",
"dev:docker": "docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d postgrest",
"dev:docker:standalone": "docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d cdtn_api postgrest",
"dev:packages": "lerna run --parallel --scope \"@socialgouv/code-du-travail-backoffice__api\" --scope \"@socialgouv/code-du-travail-backoffice__app\" dev",
"preinstall": "node ./scripts/ci/removeWorkspaces.js && node ./scripts/ci/deleteYarnLock.js",
"setup": "yarn setup:env && node -r dotenv/config ./scripts/dev/setup.js",
......@@ -32,8 +35,10 @@
"dependencies": {
"colors": "1.4.0",
"dotenv": "8.2.0",
"esm": "3.2.25",
"knex": "0.21.1",
"lerna": "3.22.1",
"npmlog": "4.1.2",
"pg": "8.2.1",
"shelljs": "0.8.4"
},
......@@ -43,6 +48,7 @@
"@testing-library/jest-dom": "5.11.0",
"@types/cucumber": "6.0.1",
"@types/jest": "26.0.3",
"axios": "0.19.2",
"boxen": "4.2.0",
"chai": "4.2.0",
"cpy-cli": "3.1.1",
......
# API Image
FROM node:12.16.1-alpine
FROM node:14.5.0-alpine
ARG API_PORT
ARG DB_URI
......
......@@ -6,12 +6,6 @@ const COMMON_HEADERS = {
"Content-Type": "application/json",
};
const LEGAL_REFERENCE_CATEGORY = {
AGREEMENT: "agreement",
LABOR_CODE: "labor_code",
};
module.exports = {
COMMON_HEADERS,
LEGAL_REFERENCE_CATEGORY,
};
# Application Image
FROM node:12.16.1-alpine
FROM node:14.5.0-alpine
ARG API_DOMAIN
ARG API_PORT_PUBLIC
ARG API_SCHEME
ARG API_URI_DOCKER
ARG CDTN_API_URL
ARG DB_URI
ARG NODE_ENV
ARG APP_PORT
......@@ -13,6 +14,7 @@ ARG APP_PORT
ENV API_DOMAIN=$API_DOMAIN
ENV API_PORT_PUBLIC=$API_PORT_PUBLIC
ENV API_SCHEME=$API_SCHEME
ENV CDTN_API_URL=$CDTN_API_URL
ENV DB_URI=$DB_URI
ENV NODE_ENV=$NODE_ENV
ENV APP_PORT=$APP_PORT
......
const withCss = require("@zeit/next-css");
const { API_DOMAIN, API_PORT_PUBLIC, API_SCHEME, API_URI_DOCKER } = process.env;
const { API_DOMAIN, API_PORT_PUBLIC, API_SCHEME, API_URI_DOCKER, CDTN_API_URL } = process.env;
const API_URI = `${API_SCHEME}://${API_DOMAIN}:${API_PORT_PUBLIC}`;
......@@ -9,5 +9,6 @@ module.exports = withCss({
env: {
API_URI,
API_URI_DOCKER,
CDTN_API_URL,
},
});
......@@ -162,7 +162,7 @@ export class AdminAnwsersEditPage extends React.Component {
this.setState({
isFirstLoad: false,
selectedAgreementIdcc: answers.data.agreement.idcc,
selectedAgreementIdcc: !this.isGeneric ? answers.data.agreement.idcc : null,
});
}
......
......@@ -142,7 +142,6 @@ export default class AdminIndexPage extends React.Component {
isCalculating: true,
isLoading: true,
isPercentage: true,
locationsStats: [],
};
}
......@@ -168,11 +167,11 @@ export default class AdminIndexPage extends React.Component {
return locations;
}
async fetchAnswersForAgreements(agreementIds) {
async fetchAnswersForAgreement(agreementId) {
const { data: answers } = await this.postgrest
.select("*")
.select("agreement(*)")
.in("agreement_id", agreementIds)
.eq("agreement_id", agreementId)
.get("/answers");
return answers;
......@@ -190,66 +189,19 @@ export default class AdminIndexPage extends React.Component {
totals: [0, 0, 0, 0, 0, 0, 0],
}));
const locationsStats = locations.map(location => ({
agreementIds: location.agreements.map(({ id }) => id),
agreements: location.agreements,
locationId: location.id,
locationName: location.name,
}));
this.setState({
agreementsStats,
isLoading: false,
locationsStats,
});
}
async updateStats() {
const { agreementsStats, locationsStats } = this.state;
const nextAgreementsStats = agreementsStats.map(agreementsStatsEntry => ({
...agreementsStatsEntry,
totals: [0, 0, 0, 0, 0, 0, 0],
}));
const nextlocationsStats = await Promise.all(
locationsStats.map(async locationsStatsEntry => {
const { agreementIds } = locationsStatsEntry;
const answers = await this.fetchAnswersForAgreements(agreementIds);
answers.forEach(({ agreement_id, is_published, state }) => {
const agreementsStatsIndex = nextAgreementsStats.findIndex(
({ id }) => id === agreement_id,
);
switch (state) {
case ANSWER_STATE.TO_DO:
nextAgreementsStats[agreementsStatsIndex].totals[0] += 1;
break;
case ANSWER_STATE.DRAFT:
nextAgreementsStats[agreementsStatsIndex].totals[1] += 1;
break;
case ANSWER_STATE.PENDING_REVIEW:
nextAgreementsStats[agreementsStatsIndex].totals[2] += 1;
break;
case ANSWER_STATE.UNDER_REVIEW:
nextAgreementsStats[agreementsStatsIndex].totals[3] += 1;
break;
case ANSWER_STATE.VALIDATED:
nextAgreementsStats[agreementsStatsIndex].totals[4] += 1;
break;
}
if (is_published) {
nextAgreementsStats[agreementsStatsIndex].totals[5] += 1;
}
const { agreementsStats } = this.state;
nextAgreementsStats[agreementsStatsIndex].totals[6] += 1;
});
const nextAgreementsStats = await Promise.all(
agreementsStats.map(async agreementStatsEntry => {
const { id: agreementId } = agreementStatsEntry;
const answers = await this.fetchAnswersForAgreement(agreementId);
const totals = answers.reduce(
(totals, { is_published, state }) => {
......@@ -287,13 +239,13 @@ export default class AdminIndexPage extends React.Component {
);
return {
...locationsStatsEntry,
...agreementStatsEntry,
totals,
};
}),
);
const nextGlobalStats = nextlocationsStats.reduce(
const nextGlobalStats = nextAgreementsStats.reduce(
(globalTotals, { totals }) => [
globalTotals[0] + totals[0],
globalTotals[1] + totals[1],
......@@ -310,7 +262,6 @@ export default class AdminIndexPage extends React.Component {
agreementsStats: nextAgreementsStats,
globalStats: nextGlobalStats,
isCalculating: false,
locationsStats: nextlocationsStats,
});
this.timeout = setTimeout(this.updateStats.bind(this), REFRESH_DELAY);
......@@ -344,14 +295,14 @@ export default class AdminIndexPage extends React.Component {
};
}
getGlobalStats() {
renderGlobalStats() {
const { globalStats, isCalculating, isPercentage } = this.state;
const data = [this.generateDataRow("Total", globalStats, isCalculating)];
return <StatsTable data={data} isPercentage={isPercentage} sortable={false} />;
}
getAgreementsStats(isNational = false) {
renderAgreementsStats(isNational = false) {
const { agreementsStats, isCalculating, isPercentage } = this.state;
const data = agreementsStats
.filter(agreement => agreement.isNational === isNational)
......@@ -380,13 +331,13 @@ export default class AdminIndexPage extends React.Component {
</Flex>
<Subtitle isFirst>Global</Subtitle>
{isLoading ? <p>Calcul en cours</p> : this.getGlobalStats()}
{isLoading ? <p>Calcul en cours</p> : this.renderGlobalStats()}
<Subtitle>Par convention collective</Subtitle>
<ContentTitle isFirst>Conventions nationales</ContentTitle>
{isLoading ? <p>Calcul en cours</p> : this.getAgreementsStats(true)}
{isLoading ? <p>Calcul en cours</p> : this.renderAgreementsStats(true)}
<ContentTitle>Conventions locales</ContentTitle>
{isLoading ? <p>Calcul en cours</p> : this.getAgreementsStats()}
{isLoading ? <p>Calcul en cours</p> : this.renderAgreementsStats()}
</Container>
</AdminMainLayout>
);
......
import styled from "@emotion/styled";
import React from "react";
import { Flex } from "rebass";
import LegalReferenceTag from "../../../src/components/LegalReferences/Tag";
import * as C from "../../../src/constants";
import Button from "../../../src/elements/Button";
import Idcc from "../../../src/elements/Idcc";
import LoadingSpinner from "../../../src/elements/LoadingSpinner";
import Title from "../../../src/elements/Title";
import shortenAgreementName from "../../../src/helpers/shortenAgreementName";
import AdminMainLayout from "../../../src/layouts/AdminMain";
import customPostgrester from "../../../src/libs/customPostgrester";
import dilaApi from "../../../src/libs/dilaApi";
import T from "../../../src/texts";
const Container = styled(Flex)`
margin: 0 1rem 1rem;
`;
const List = styled(Flex)`
flex-grow: 1;
padding-right: 0.5rem;
min-height: 0;
overflow-y: auto;
`;
const ListRow = styled(Flex)`
background-color: white;
border: solid 1px var(--color-border);
border-radius: 0.4rem;
margin-top: 0.5rem;
padding: 0 0.75rem 0.5rem;
`;
export const OpenButton = styled(Button)`
font-size: 0.875rem;
padding: 0.325rem 0.375rem 0.375rem 0.5rem;
`;
class AdminTrackerAgreementIdPage extends React.Component {
constructor(props) {
super(props);
this.state = {
agreement: null,
answers: [],
answersReferences: [],
isLoading: true,
};
}
async componentDidMount() {
this.postgrest = customPostgrester();
const { id: agreementId } = this.props;
const { data: agreements } = await this.postgrest.eq("id", agreementId).get("/agreements");
const agreement = agreements[0];
this.setState({ agreement });
const { data: answers } = await this.postgrest
.select("*")
.select("question(index)")
.eq("agreement_id", agreementId)
.orderBy("question.index")
.get("/answers");
this.setState({ answers });
const answerIds = answers.map(({ id }) => id);
const { data: answersReferences } = await this.postgrest
.in("answer_id", answerIds)
.get("/answers_references");
this.setState({
answersReferences,
isLoading: false,
});
}
async fetchAnswersForAgreement(agreementId) {
const { data: answers } = await this.postgrest.eq("agreement_id", agreementId).get("/answers");
return answers;
}
async findObsoleteAnswersReferences(answersReferences) {
// TODO Remove `dila_cid !== null` check once all the references are cleaned.
const localAgreementAnswersReferences = answersReferences.filter(
({ category, dila_cid }) =>
category === C.ANSWER_REFERENCE_CATEGORY.AGREEMENT && dila_cid !== null,
);
const obsoleteAgreementAnswersReference = [];
for (const localAgreementAnswerReference of localAgreementAnswersReferences) {
try {
const { dila_id } = localAgreementAnswerReference;
await dilaApi.get(`/agreement/articles?articleIdsOrCids=${dila_id}`);
} catch (err) {
obsoleteAgreementAnswersReference.push(localAgreementAnswerReference);
}
}
return [...obsoleteAgreementAnswersReference];
}
open(answerId) {
window.open(`/admin/answers/${answerId}`, "_blank");
}
renderAnswerReferences() {
const { answers, answersReferences, isLoading } = this.state;
if (isLoading) {
return (
<List alignItems="center" justifyContent="center">
<LoadingSpinner />
</List>
);
}
if (answersReferences.length === 0) {
return (
<List alignItems="center" justifyContent="center">
{T.ADMIN_TRACKER_INFO_NO_DATA}
</List>
);
}
return (
<List flexDirection="column">
{answersReferences.map(answerReference => {
const answer = answers.find(({ id }) => id === answerReference.answer_id);
return (
<ListRow alignItems="baseline" justifyContent="space-between" key={answerReference.id}>
<Idcc code={String(answer.question.index)} name={answer.question.value} />
<LegalReferenceTag isReadOnly {...answerReference} />
<OpenButton
color="secondary"
icon="external-link-alt"
onClick={() => this.open(answerReference.answer_id)}
/>
</ListRow>
);
})}
</List>
);
}
render() {
const { agreement } = this.state;
return (
<AdminMainLayout hasBareContent>
<Container flexDirection="column" flexGrow="1">
<Flex alignItems="center" justifyContent="space-between">
{agreement === null && <Title>Tableau de veille » </Title>}
{agreement !== null && (
<Title>{`Tableau de veille » [${agreement.idcc}] ${shortenAgreementName(
agreement.name,
)}`}</Title>
)}
</Flex>
{this.renderAnswerReferences()}
</Container>
</AdminMainLayout>
);
}
}
export default AdminTrackerAgreementIdPage;
import styled from "@emotion/styled";
import React from "react";
import { Flex } from "rebass";
import * as C from "../../../src/constants";
import LoadingSpinner from "../../../src/elements/LoadingSpinner";
import _Table from "../../../src/elements/Table";
import Title from "../../../src/elements/Title";
import shortenAgreementName from "../../../src/helpers/shortenAgreementName";
import AdminMainLayout from "../../../src/layouts/AdminMain";
import numeral from "../../../src/libs/customNumeral";
import customPostgrester from "../../../src/libs/customPostgrester";
import dilaApi from "../../../src/libs/dilaApi";
const Container = styled(Flex)`
margin: 0 1rem 1rem;
`;
const Table = styled(_Table)`
display: fles;
flex-grow: 1;
font-size: 0.875rem;
margin-top: 0.5rem;
overflow-y: auto;
.rt-tr > .rt-th,
.rt-tr > .rt-td {
:first-of-type {
width: 50% !important;
}
:not(:first-of-type) {
width: 50% !important;
}
}
.rt-tr > .rt-td {
:first-of-type {
cursor: pointer;
}
:not(:first-of-type) {
text-align: right;
}
}
`;
// TODO Clean these columns.
/* eslint-disable react/display-name */
const DASHBOARD_TABLE_COLUMNS = [
{
Cell: ({ row, value }) => {
return (
<span
onClick={() => window.open(`/admin/tracker/${row._original.id}`)}
onKeyPress={() => window.open(`/admin/tracker/${row._original.id}`)}
role="link"
tabIndex="0"