Unverified Commit 5362e466 authored by Ivan Gabriele's avatar Ivan Gabriele Committed by GitHub
Browse files

feat(app): improve answers management (#1019)

parent 5d046747
......@@ -27,7 +27,9 @@
},
{
"files": [
"packages/app/src/components/Answer/**/*.js",
"packages/app/src/components/LegalReferences/**/*.js",
"packages/app/src/elements/**/*.js",
"packages/app/src/templates/**/*.js"
],
"extends": ["@socialgouv/eslint-config-react-strict", "prettier"],
......@@ -36,7 +38,7 @@
"node": true
},
"rules": {
"jest/no-disabled-tests": "off",
"jest/no-disabled-tests": "error",
"react/jsx-sort-props": "error",
"react/prop-types": "error"
}
......@@ -66,6 +68,7 @@
"jest": true
},
"globals": {
"testClick": false,
"testRender": false,
"waitFor": false
}
......
......@@ -13,8 +13,10 @@ help make it even better than it is today!
- [Docker Compose](#docker-compose)
- [Jest Watch](#jest-watch)
- [Naming Guidelines](#naming-guidelines)
- [API-related Actions](#api-related-actions)
- [React Component Actions](#react-component-actions)
- [API-related methods](#api-related-methods)
- [React methods](#react-methods)
- [Redux states](#redux-states)
- [React variables](#react-variables)
- [Commit Message Guidelines](#commit-message-guidelines)
- [Revert](#revert)
- [Type](#type)
......@@ -128,20 +130,53 @@ echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo s
## Naming Guidelines
### API-related Actions
### API-related methods
This includes React components methods as well as Redux actions, action types and sagas:
This includes React methods as well as Redux actions, action types and sagas:
- Any `GET` call-related method should start with the verb **load**.
- Any `POST` call-related method should start with the verb **create**, or **add** if it targets a
- All `GET` call-related methods must start with the verb **load**.
- All `POST` call-related methods must start with the verb **create**, or **add** if it targets a
foreign entity (i.e.: `addAnswerComment()`).
- Any `PATCH` call-related method should start with the verb **update**.
- Any `DELETE` call-related method should start with the verb **delete** (or **\_delete**), or
- All `PATCH` call-related methods must start with the verb **update**.
- All `DELETE` call-related methods must start with the verb **delete** (or **\_delete**), or
**remove** if it targets a foreign entity (i.e.: `removeAnswerComment()`).
### React Component Actions
### React methods
- All the methods returning a JSX value should start with the verb **render**.
### Redux states
A common state should look like:
```ts
interface {
/** Single entity data (creation, edition) */
data: Object | null;
error: Error | null;
/** Is it fetching data? */
isLoading: boolean;
/**
Total number of entities (listing)
@description
This represents the number of entities available on the API and should be higher than the list
length if there is more than one page.
*/
length: number;
/** Multiple entities data (listing) */
list: Object[];
/** Current page index (listing) */
pagesIndex: number;
/** Total number of pages (listing) */
pagesLength: number;
}
```
### React variables
- Any method returning a JSX value should start with the verb **render**.
- All the variables referencing a component must start with **$**
(i.e.: `<Button ref={node => this.$button = node}>`).
---
......
-------------------------------------- UP --------------------------------------
DROP VIEW api.full_answers;
CREATE VIEW api.full_answers AS
SELECT
answers.*,
questions.index AS question_index,
questions.value AS question_value,
agreements.name AS agreement_name,
agreements.idcc AS agreement_idcc
FROM api.answers answers
LEFT JOIN api.questions questions ON questions.id = answers.question_id
LEFT JOIN api.agreements agreements ON agreements.id = answers.agreement_id;
GRANT SELECT ON api.full_answers TO administrator;
GRANT SELECT ON api.full_answers TO contributor;
------------------------------------- DOWN -------------------------------------
DROP VIEW api.full_answers;
CREATE VIEW api.full_answers AS
SELECT
answers.*,
questions.index AS question_index,
questions.value AS question_value,
agreements.name AS agreement_name,
agreements.idcc AS agreement_idcc
FROM api.answers answers
INNER JOIN api.questions questions ON questions.id = answers.question_id
INNER JOIN api.agreements agreements ON agreements.id = answers.agreement_id;
GRANT SELECT ON api.full_answers TO administrator;
GRANT SELECT ON api.full_answers TO contributor;
const getMigrationQuery = require("../../../scripts/db/getMigrationQuery");
exports.up = async knex => {
await knex.raw(getMigrationQuery("20200430032517_switch_full_answers_view_to_left_join").up());
};
exports.down = async knex => {
await knex.raw(getMigrationQuery("20200430032517_switch_full_answers_view_to_left_join").down());
};
......@@ -12,17 +12,17 @@ function getRandomIp() {
}
function getRandomLog() {
const ip = getRandomIp();
const action = ACTIONS[getRandomIntBetween(0, 2)];
const url = "/path";
const created_at = new Date(Date.now() - getRandomIntBetween(0, 30 * 24 * 60 * 60 * 1000));
const ip = getRandomIp();
const method = ACTIONS[getRandomIntBetween(0, 2)];
const path = "/dummy-path";
const user_id = `00000000-0000-4000-8000-00000000040${getRandomIntBetween(1, 5)}`;
return {
action,
created_at,
ip,
url,
method,
path,
user_id,
};
}
......@@ -32,7 +32,7 @@ exports.seed = async knex => {
const logs = Array.from({ length: 1000 }, getRandomLog);
await knex("api.logs").insert(logs);
await knex("public.logs").insert(logs);
global.spinner.succeed(`Logs generated.`);
};
No preview for this file type
......@@ -58,7 +58,7 @@
"imgur": "0.3.1",
"jest": "25.4.0",
"ora": "4.0.4",
"postgrester": "1.3.1",
"postgrester": "1.4.0",
"prettier": "2.0.5",
"puppeteer": "3.0.1",
"typescript": "3.8.3"
......
......@@ -3,6 +3,7 @@ __tests__/
node_modules/
tests/
*.d.ts
*.md
jest.config.js
......
// https://www.typescriptlang.org/docs/handbook/declaration-files/library-structures.html#dependencies-on-global-libraries
/// <reference types="@socialgouv/code-du-travail-backoffice__typings" />
const http = require("http");
const httpProxy = require("http-proxy");
const log = require("@inspired-beings/log");
......
{
"extends": "../../tsconfig.json",
"compilerOptions": {}
"compilerOptions": {},
"include": ["typings-env.d.ts"]
}
// https://www.typescriptlang.org/docs/handbook/declaration-files/library-structures.html#dependencies-on-global-libraries
/// <reference types="@socialgouv/code-du-travail-backoffice__typings" />
......@@ -25,14 +25,14 @@
"koa-router": "8.0.8",
"lodash.debounce": "4.0.8",
"moment-timezone": "0.5.28",
"next": "9.3.5",
"next": "9.3.6",
"next-cookies": "2.0.3",
"next-redux-saga": "4.1.2",
"next-redux-wrapper": "5.0.0",
"numeral": "2.0.6",
"password-generator": "2.2.3",
"pg": "8.0.3",
"postgrester": "1.3.1",
"postgrester": "1.4.0",
"prop-types": "15.7.2",
"quill": "1.3.7",
"ramda": "0.27.0",
......@@ -41,7 +41,6 @@
"react-dom": "16.13.1",
"react-medixtor": "0.1.0-alpha.16",
"react-onclickoutside": "6.9.0",
"react-paginate": "6.3.2",
"react-redux": "7.2.0",
"react-select": "3.1.0",
"react-table": "6.11.5",
......@@ -63,12 +62,10 @@
"unified": "9.0.0"
},
"devDependencies": {
"@babel/core": "7.9.0",
"@testing-library/jest-dom": "5.5.0",
"@testing-library/react": "10.0.3",
"@types/ramda": "0.27.4",
"@types/react-test-renderer": "16.9.2",
"babel-eslint": "10.1.0",
"babel-jest": "25.4.0",
"dotenv": "8.2.0",
"identity-obj-proxy": "3.0.0",
"jest-emotion": "10.0.32",
......@@ -76,9 +73,7 @@
"nodemon": "2.0.3",
"prettier": "2.0.5",
"react-test-renderer": "16.13.1",
"rimraf": "3.0.2",
"snapshot-diff": "0.7.0",
"uuid": "8.0.0",
"zxcvbn": "4.4.2"
}
}
This diff is collapsed.
import styled from "@emotion/styled";
import debounce from "lodash.debounce";
import Router from "next/router";
import React from "react";
import { connect } from "react-redux";
import { Flex } from "rebass";
import * as actions from "../../../src/actions";
import AdminAnswerBlock from "../../../src/blocks/AdminAnswer";
import Pagination from "../../../src/components/Pagination";
import { ANSWER_STATE_OPTIONS } from "../../../src/constants";
import Answer from "../../../src/components/Answer";
import * as C from "../../../src/constants";
import Button from "../../../src/elements/Button";
import Checkbox from "../../../src/elements/Checkbox";
import Input from "../../../src/elements/Input";
import LoadingSpinner from "../../../src/elements/LoadingSpinner";
import Select from "../../../src/elements/Select";
import Title from "../../../src/elements/Title";
import AdminMainLayout from "../../../src/layouts/AdminMain";
......@@ -20,37 +21,32 @@ const Container = styled(Flex)`
flex-grow: 1;
margin: 0 1rem 1rem;
`;
const List = styled(Flex)`
flex-grow: 1;
padding-right: 1rem;
min-height: 0;
overflow-y: auto;
`;
const Top = styled(Flex)`
margin-bottom: 0.75rem;
const ListSpinner = styled(Flex)`
margin-top: 1rem;
`;
const FiltersContainer = styled(Flex)`
border-bottom: solid 1px var(--color-border);
border-top: solid 1px var(--color-border);
padding: 0.5rem 0;
> * {
flex-grow: 0.25;
}
`;
const FilterSelect = styled(Select)`
margin-left: 1rem;
`;
const ActionsContainer = styled(Flex)`
background-color: var(--color-alice-blue);
border-bottom: solid 1px var(--color-border);
padding: 0.5rem 0.5rem 0.5rem 1rem;
`;
const Text = styled.p`
margin-bottom: 0.5rem;
`;
const HelpText = styled(Text)`
font-size: 0.875rem;
`;
export class AdminAnswersIndexPage extends React.Component {
get queryFilter() {
return this.$queryFilter !== undefined && this.$queryFilter !== null
......@@ -58,6 +54,17 @@ export class AdminAnswersIndexPage extends React.Component {
: "";
}
constructor(props) {
super(props);
this.isListeningListScroll = false;
this.$list = null;
this.onListScroll = this.onListScroll.bind(this);
this.setQueryFilter = debounce(this.setQueryFilter, 250).bind(this);
}
componentDidMount() {
const { isGeneric } = this.props;
......@@ -66,20 +73,41 @@ export class AdminAnswersIndexPage extends React.Component {
this.props.dispatch(
actions.answers.setFilters({
isGeneric,
pageLength: 10,
pageLength: 53,
}),
);
}
componentDidUpdate() {
const { answers } = this.props;
if (this.$list === null || answers.isLoading || this.isListeningListScroll) return;
this.$list.addEventListener("scroll", this.onListScroll);
this.isListeningListScroll = true;
}
onListScroll() {
const { answers, dispatch } = this.props;
if (answers.isLoading || answers.pagesIndex >= answers.pagesLength - 1) return;
const scrollTopLimit = this.$list.scrollHeight - this.$list.clientHeight - 1600;
if (this.$list.scrollTop > scrollTopLimit) {
dispatch(actions.answers.load(answers.pagesIndex + 1));
}
}
getCheckableAnswerIds() {
const { answers } = this.props;
return answers.list.map(({ id }) => id).filter(id => !answers.checked.includes(id));
}
setAgreeementsFilter(selected) {
const agreements = selected !== null ? selected : [];
this.props.dispatch(actions.answers.setFilter("agreements", agreements));
}
setPageFilter({ selected }) {
this.props.dispatch(actions.answers.setFilter("page", selected));
}
setQuestionsFilter(selected) {
const questions = selected !== null ? selected : [];
this.props.dispatch(actions.answers.setFilter("questions", questions));
......@@ -99,8 +127,8 @@ export class AdminAnswersIndexPage extends React.Component {
}
checkAll() {
const { dispatch, answers } = this.props;
const ids = answers.data.map(({ id }) => id).filter(id => !answers.checked.includes(id));
const { dispatch } = this.props;
const ids = this.getCheckableAnswerIds();
dispatch(actions.answers.toggleCheck(ids));
}
......@@ -142,25 +170,34 @@ export class AdminAnswersIndexPage extends React.Component {
}
renderAnswersList() {
const { checked, data, isLoading } = this.props.answers;
if (isLoading || !Array.isArray(data)) {
return <HelpText>Chargement</HelpText>;
const { checked, list, isLoading } = this.props.answers;
if (list.length === 0) {
return (
<List alignItems="center" justifyContent="center">
{isLoading ? <LoadingSpinner /> : T.ADMIN_ANSWERS_INFO_NO_DATA}
</List>
);
}
if (data.length === 0) {
return <p>{T.ADMIN_ANSWERS_INFO_NO_DATA}</p>;
}
return data.map(answer => (
<AdminAnswerBlock
data={answer}
isChecked={checked.includes(answer.id)}
key={answer.id}
onCheck={this.check.bind(this)}
onClick={this.editAnswer.bind(this)}
/>
));
return (
<List flexDirection="column" ref={node => (this.$list = node)}>
{list.map(answer => (
<Answer
data={answer}
isChecked={checked.includes(answer.id)}
key={answer.id}
onCheck={this.check.bind(this)}
onClick={this.editAnswer.bind(this)}
/>
))}
{isLoading && (
<ListSpinner alignItems="center" justifyContent="center">
<LoadingSpinner />
</ListSpinner>
)}
</List>
);
}
render() {
......@@ -169,26 +206,25 @@ export class AdminAnswersIndexPage extends React.Component {
const isLoading = isGeneric
? answers.isLoading
: agreements.isLoading || answers.isLoading || questions.isLoading;
const stateFilterAgreements = agreements.data.map(({ id, idcc, name }) => ({
const stateFilterAgreements = agreements.list.map(({ id, idcc, name }) => ({
label: `[${idcc}] ${name}`,
value: id,
}));
const stateFilterQuestions = questions.data.map(({ id, index, value }) => ({
const stateFilterQuestions = questions.list.map(({ id, index, value }) => ({
label: `${index}) ${value}`,
value: id,
}));
const stateActionOptions = ANSWER_STATE_OPTIONS.filter(({ value }) => value !== answers.state);
return (
<AdminMainLayout hasBareContent>
<Container flexDirection="column">
<Top alignItems="baseline" justifyContent="space-between">
<Flex alignItems="center" justifyContent="space-between">
<Title>{`Réponses${isGeneric ? " génériques" : ""}`}</Title>
<Button disabled={isLoading} onClick={this.printAnswers.bind(this)}>
Imprimer
</Button>
</Top>
</Flex>
{/* Filters */}
{!isGeneric && (
......@@ -196,34 +232,34 @@ export class AdminAnswersIndexPage extends React.Component {
<Input
defaultValue={answers.filters.query}
icon="search"
onChange={this.setQueryFilter.bind(this)}
onChange={this.setQueryFilter}
ref={node => (this.$queryFilter = node)}
/>
{/* We must set the {instanceId} prop to avoid "Prop `id` did not match." warning. */}
{/* https://github.com/trezor/trezor-suite/issues/290#issuecomment-516349580 */}
<FilterSelect
<Select
instanceId="statesFilter"
isLoading={isLoading}
isMulti
onChange={this.setStatesFilter.bind(this)}
options={ANSWER_STATE_OPTIONS}
options={C.ANSWER_STATE_OPTIONS}
value={answers.filters.states}
withMarginLeft
/>
<FilterSelect
<Select
instanceId="agreementsFilter"
isLoading={isLoading}
isLoading={agreements.isLoading}
isMulti
onChange={this.setAgreeementsFilter.bind(this)}
options={stateFilterAgreements}
value={answers.filters.agreements}
withMarginLeft
/>
<FilterSelect
<Select
instanceId="questionsFilter"
isLoading={isLoading}
isLoading={questions.isLoading}
isMulti
onChange={this.setQuestionsFilter.bind(this)}
options={stateFilterQuestions}
value={answers.filters.questions}
withMarginLeft
/>
</FiltersContainer>
)}
......@@ -231,37 +267,30 @@ export class AdminAnswersIndexPage extends React.Component {
{/* Actions */}
<ActionsContainer alignItems="center" justifyContent="space-between">
<Checkbox
icon={answers.checked.length > 0 ? "check-square" : "square"}
isDisabled={isLoading}
isChecked={answers.checked.length > 0}
isDisabled={isLoading || answers.list.length === 0}
onClick={
answers.checked.length > 0 ? this.uncheckAll.bind(this) : this.checkAll.bind(this)
}
/>
<Flex>
<Select
instanceId="stateAction"
isDisabled={isLoading || answers.checked.length === 0}
isLoading={isLoading}
options={stateActionOptions}
options={C.ANSWER_STATE_OPTIONS}
ref={node => (this.$newState = node)}
/>
<Button
isDisabled={isLoading || answers.checked.length === 0}
onClick={this.setCheckedAnswersState.bind(this)}
withLeftMargin
withMarginLeft
>
{`Appliquer (${answers.checked.length})`}
</Button>
</Flex>
</ActionsContainer>
<List flexDirection="column">{this.renderAnswersList()}</List>
{!isLoading && answers.pagesLength > 0 && (