Unverified Commit 48bbd90a authored by kevingrilloaccenture's avatar kevingrilloaccenture Committed by GitHub
Browse files

Feature/administration attentats (#150)



* feat: Administration des attentats : création des fonctionnalités de création, modification et suppression des attentats.

* feat: Modification du script de migration Knex afin qu'il soit générique

* feat: Correctifs suite à la Peer-Review de Pierre-Olivier

* feat: Correctif de l'initialisation des données des attentats

* feat: order on knex query for attacks

* fix: attacks seed was not in the new format

* fix: rollback migration was incorrect.

employments_references has a trigger which needs to be dropped before to drop the table itself.

* fix: idem for dev seed attacks
Co-authored-by: pom421's avatarpom421 <mauguet.po@gmail.com>
parent 9d838af3
Pipeline #111862 passed with stage
in 38 seconds
import fetch from "isomorphic-unfetch"
import { API_URL, ATTACKS_ENDPOINT } from "../config"
import { handleAPIResponse } from "../utils/errors"
import { handleAPIResponse2 } from "../utils/errors"
import { METHOD_DELETE, METHOD_POST, METHOD_PUT } from "../utils/http"
export const searchAttacksFuzzy = async ({ search, requestedPage, headers }) => {
const arr = []
if (search) {
arr.push(`fuzzy=${search}`)
}
if (requestedPage) {
arr.push(`requestedPage=${requestedPage}`)
}
const bonus = arr.length ? "?" + arr.join("&") : ""
const response = await fetch(`${API_URL}${ATTACKS_ENDPOINT}${bonus}`, { headers })
return handleAPIResponse2(response)
}
export const findAttack = async ({ id, headers }) => {
const response = await fetch(`${API_URL}${ATTACKS_ENDPOINT}/${id}`, { headers })
return handleAPIResponse2(response)
}
export const findAllAttacks = async ({ headers } = {}) => {
const response = await fetch(API_URL + ATTACKS_ENDPOINT, { headers })
return handleAPIResponse(response)
return handleAPIResponse2(response)
}
export const createAttack = async ({ attack, headers }) => {
const response = await fetch(`${API_URL}${ATTACKS_ENDPOINT}`, {
body: JSON.stringify(attack),
headers: { ...headers, "Content-Type": "application/json" },
method: METHOD_POST,
})
return handleAPIResponse2(response)
}
export const updateAttack = async ({ attack, headers }) => {
const response = await fetch(`${API_URL}${ATTACKS_ENDPOINT}/${attack.id}`, {
body: JSON.stringify(attack),
headers: { ...headers, "Content-Type": "application/json" },
method: METHOD_PUT,
})
return handleAPIResponse2(response)
}
export const deleteAttack = async ({ id, headers }) => {
const response = await fetch(`${API_URL}${ATTACKS_ENDPOINT}/${id}`, { headers, method: METHOD_DELETE })
return handleAPIResponse2(response)
}
......@@ -70,7 +70,7 @@ const VictimEdit = ({ dispatch, state, errors }) => {
"Maltraitance",
"Violence psychologique",
{ title: "Accident", subValues: ["Collectif", "Non collectif"] },
{ title: "Attentat", subValues: getReferenceData("attacks").map((elt) => elt.name) },
{ title: "Attentat", subValues: getReferenceData("attacks").map((elt) => elt.year + ' ' + elt.name) },
]}
mode="toggleMultiple"
dispatch={dispatch}
......
const { triggerUp } = require("../lib")
const { triggerUp, triggerDown } = require("../lib")
exports.up = async function (knex) {
await knex.schema.createTable("employments_references", function (table) {
......@@ -18,6 +18,7 @@ exports.up = async function (knex) {
await knex.raw(triggerUp("employments_references"))
}
exports.down = function (knex) {
knex.schema.dropTable("employments_references")
exports.down = async function (knex) {
await knex.raw(triggerDown("employments_references"))
await knex.schema.dropTable("employments_references")
}
exports.up = async function (knex) {
await knex.schema.table("attacks", function (table) {
table.integer("year").unsigned()
})
const rows = await knex("attacks").select("id", "name", "year")
rows.forEach(async (row) => {
var splitedName = row.name.split(" ")
if (splitedName.length > 1 && Number(splitedName[0])) {
await knex("attacks")
.update({
name: row.name.substring(splitedName[0].length + 1),
year: Number(splitedName[0]),
})
.where("id", row.id)
}
})
}
exports.down = async function (knex) {
const rows = await knex("attacks").select("id", "name", "year")
rows.forEach(async (row) => {
if (row.year != undefined) {
await knex("attacks")
.update({
name: row.year + " " + row.name,
year: undefined,
})
.where("id", row.id)
}
})
await knex.schema.table("attacks", function (table) {
table.dropColumn("year")
})
}
......@@ -4,37 +4,44 @@ exports.seed = function (knex) {
return knex("attacks").insert([
{
id: 1,
name: "2015 Bataclan",
name: "Bataclan",
year: 2015,
},
{
id: 2,
name: "2015 Hyper Cacher",
name: "Hyper Cacher",
year: 2015,
},
{
id: 3,
name: "2015 Les terrasses Paris",
name: "Les terrasses Paris",
year: 2015,
},
{
id: 4,
name: "2016 Nice",
name: "Nice",
year: 2016,
},
{
id: 5,
name: "2020 Villejuif",
name: "Villejuif",
year: 2020,
},
{
id: 6,
name: "2012 École Ozar Hatorah Toulouse",
name: "École Ozar Hatorah Toulouse",
year: 2012,
},
{
id: 7,
name: "2015 Charlie Hebdo",
name: "Charlie Hebdo",
year: 2015,
},
])
})
.then(function () {
return knex.raw(
"select pg_catalog.setval(pg_get_serial_sequence('attacks', 'id'), (select max(id) from attacks) + 1);"
"select pg_catalog.setval(pg_get_serial_sequence('attacks', 'id'), (select max(id) from attacks) + 1);",
)
})
}
......@@ -4,37 +4,44 @@ exports.seed = function (knex) {
return knex("attacks").insert([
{
id: 1,
name: "2015 Bataclan",
name: "Bataclan",
year: 2015,
},
{
id: 2,
name: "2015 Hyper Cacher",
name: "Hyper Cacher",
year: 2015,
},
{
id: 3,
name: "2015 Les terrasses Paris",
name: "Les terrasses Paris",
year: 2015,
},
{
id: 4,
name: "2016 Nice",
name: "Nice",
year: 2016,
},
{
id: 5,
name: "2020 Villejuif",
name: "Villejuif",
year: 2020,
},
{
id: 6,
name: "2012 École Ozar Hatorah Toulouse",
name: "École Ozar Hatorah Toulouse",
year: 2012,
},
{
id: 7,
name: "2015 Charlie Hebdo",
name: "Charlie Hebdo",
year: 2015,
},
])
})
.then(function () {
return knex.raw(
"select pg_catalog.setval(pg_get_serial_sequence('attacks', 'id'), (select max(id) from attacks) + 1);"
"select pg_catalog.setval(pg_get_serial_sequence('attacks', 'id'), (select max(id) from attacks) + 1);",
)
})
}
import * as yup from "yup"
import * as common from "./common"
const JStoDBKeys = {
id: "id",
name: "name",
year: "year",
}
const schema = yup.object().shape({
id: yup.number().positive().integer().nullable(),
name: yup.string(),
year: yup.number().positive().integer(),
})
export const { transform, transformAll, untransform, validate } = common.build({ JStoDBKeys, schema })
import ArrowBackIosIcon from "@material-ui/icons/ArrowBackIos"
import Link from "next/link"
import { useRouter } from "next/router"
import PropTypes from "prop-types"
import React, { useState } from "react"
import { useForm } from "react-hook-form"
import {
Alert,
Button,
Col,
Container,
Form,
FormFeedback,
FormGroup,
Input,
Label,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
} from "reactstrap"
import { createAttack, deleteAttack, findAttack, updateAttack } from "../../../clients/attacks"
import Layout from "../../../components/Layout"
import { Title1 } from "../../../components/StyledComponents"
import { buildAuthHeaders, redirectIfUnauthorized, withAuthentication } from "../../../utils/auth"
import { logDebug, logError } from "../../../utils/logger"
import { isEmpty } from "../../../utils/misc"
import { ADMIN } from "../../../utils/roles"
const MandatorySign = () => <span style={{ color: "red" }}>*</span>
// TODO : vérifier que seul le super admin puisse accéder à cette page
const AttackDetail = ({ attack = {}, currentUser, error: initialError }) => {
const router = useRouter()
const { id } = router.query
const { handleSubmit, register, errors: formErrors, setValue } = useForm({
defaultValues: {
...attack,
},
})
// General error (alert)
const [error, setError] = useState(initialError)
const [success, setsuccess] = useState("")
const [modal, setModal] = useState(false)
const toggle = () => setModal(!modal)
const onDeleteAttack = () => {
toggle()
const del = async (id) => {
try {
const { deleted } = await deleteAttack({ id })
logDebug(`Nb deleteAttack rows: ${deleted}`)
router.push("/administration/attacks")
} catch (error) {
setError("Erreur serveur.")
}
}
del(id)
}
const onSubmit = async (attack) => {
setError("")
setsuccess("")
try {
if (isEmpty(formErrors)) {
if (attack.id) {
const { updated } = await updateAttack({ attack })
logDebug(`Nb updated rows: ${updated}`)
setsuccess("Attentat modifié.")
} else {
attack.id = null
const { id } = await createAttack({ attack })
setValue("id", id || "")
setsuccess("Attentat créé.")
}
}
} catch (error) {
setError(
error.message === "Attack already present"
? "Cet attentat existe déjà avec le même nom."
: "Erreur serveur."
)
}
}
return (
<Layout page="attacks" currentUser={currentUser} admin={true}>
<Container style={{ maxWidth: 720 }} className="mt-5 mb-4">
<div className="d-flex justify-content-between">
<Link href="/administration/attacks">
<a>
<ArrowBackIosIcon width={30} style={{ width: 15 }} />
Retour
</a>
</Link>
<Title1>{"Attentat"}</Title1>
<span>&nbsp;</span>
</div>
{error && <Alert color="danger mt-4">{error}</Alert>}
{success && (
<Alert color="success" className="d-flex justify-content-between align-items-center mt-4">
{success}&nbsp;
<div>
<Link href="/administration/attacks">
<Button className="mr-3" outline color="success">
<a>Retour à la liste</a>
</Button>
</Link>
<Link href="/administration/attacks/[id]" as={`/administration/attacks/new`}>
<Button outline color="success">
<a>Ajouter</a>
</Button>
</Link>
</div>
</Alert>
)}
<Form onSubmit={handleSubmit(onSubmit)} className="mt-4">
<FormGroup row>
<Label for="id" sm={3}>
Id
</Label>
<Col sm={9}>
<Input type="text" name="id" id="id" readOnly innerRef={register} />
</Col>
</FormGroup>
<FormGroup row>
<Label for="year" sm={3}>
Année&nbsp;
<MandatorySign />
</Label>
<Col sm={9}>
<Input
type="text"
name="year"
id="year"
innerRef={register({
required: true,
pattern: {
value: /^(20[0-9]{2})|(19[8-9][0-9])$/i,
},
})}
invalid={!!formErrors.year}
/>
<FormFeedback>{formErrors.year && "L'année a un format incorrect."}</FormFeedback>
</Col>
</FormGroup>
<FormGroup row>
<Label for="name" sm={3}>
Nom&nbsp;
<MandatorySign />
</Label>
<Col sm={9}>
<Input
type="text"
name="name"
id="name"
invalid={!!formErrors.name}
innerRef={register({ required: true })}
/>
<FormFeedback>{formErrors.name && "Le nom est obligatoire."}</FormFeedback>
</Col>
</FormGroup>
<div className="justify-content-center d-flex">
<Link href="/administration/attacks">
<Button className="px-4 mt-5 mr-3" outline color="primary">
Annuler
</Button>
</Link>
<Button className="px-4 mt-5 " color="primary">
{isEmpty(attack) ? "Ajouter" : "Modifier"}
</Button>
</div>
{!isEmpty(attack) && (
<div style={{ border: "1px solid tomato" }} className="px-4 py-3 mt-5 rounded">
<Title1 className="mb-4">Zone dangereuse</Title1>
<div className="d-flex justify-content-between align-items-center">
Je souhaite supprimer cet attentat
<Button className="" color="danger" outline onClick={toggle}>
Supprimer
</Button>
</div>
</div>
)}
</Form>
<div>
<Modal isOpen={modal} toggle={toggle}>
<ModalHeader toggle={toggle}>Voulez-vous vraiment supprimer cet attentat?</ModalHeader>
<ModalBody>
Si vous supprimez cet attentat, il ne sera plus visible ni modifiable dans la liste des attentats.
Merci de confirmer votre choix.
</ModalBody>
<ModalFooter>
<Button color="primary" outline onClick={toggle}>
Annuler
</Button>
<Button color="danger" onClick={onDeleteAttack}>
Supprimer
</Button>
</ModalFooter>
</Modal>
</div>
</Container>
</Layout>
)
}
AttackDetail.getInitialProps = async (ctx) => {
const headers = buildAuthHeaders(ctx)
const { id } = ctx.query
if (!id || isNaN(id)) return { attack: {}, key: Number(new Date()) }
try {
const attack = await findAttack({ id, headers })
return { attack }
} catch (error) {
logError(error)
redirectIfUnauthorized(error, ctx)
return { error: "Erreur serveur" }
}
}
AttackDetail.propTypes = {
attack: PropTypes.object,
currentUser: PropTypes.object.isRequired,
error: PropTypes.string,
}
export default withAuthentication(AttackDetail, ADMIN)
import AddIcon from "@material-ui/icons/Add"
import Link from "next/link"
import { PropTypes } from "prop-types"
import React, { useState } from "react"
import { Alert, Col, Container, Form, FormGroup, Input, Spinner, Table } from "reactstrap"
import { searchAttacksFuzzy } from "../../../clients/attacks"
import { SearchButton } from "../../../components/form/SearchButton"
import Layout from "../../../components/Layout"
import Pagination from "../../../components/Pagination"
import { Title1 } from "../../../components/StyledComponents"
import { usePaginatedData } from "../../../hooks/usePaginatedData"
import { buildAuthHeaders, redirectIfUnauthorized, withAuthentication } from "../../../utils/auth"
import { logError } from "../../../utils/logger"
import { ADMIN } from "../../../utils/roles"
const AdminAttackPage = ({ paginatedData: initialPaginatedData, currentUser }) => {
const [search, setSearch] = useState("")
const [paginatedData, error, loading, fetchPage] = usePaginatedData(searchAttacksFuzzy, initialPaginatedData)
const onChange = (e) => {
setSearch(e.target.value)
}
const onSubmit = async (e) => {
e.preventDefault()
fetchPage({ search })(0)
}
return (
<Layout page="attacks" currentUser={currentUser} admin={true}>
<Container
style={{ maxWidth: 980, minWidth: 740 }}
className="mt-5 mb-5 d-flex justify-content-between align-items-baseline"
>
<Title1 className="">{"Administration des attentats"}</Title1>
<Link href="/administration/attacks/[id]" as={`/administration/attacks/new`}>
<a>
<SearchButton className="btn-outline-primary">
<AddIcon />
&nbsp; Ajouter
</SearchButton>
</a>
</Link>
</Container>
<Container style={{ maxWidth: 980, minWidth: 740 }}>
<Form onSubmit={onSubmit}>
<FormGroup row inline className="mb-4 justify-content-center">
<Col className="flex-grow-1">
<Input
type="text"
name="es"
id="es"
placeholder="Rechercher un attentat par son nom ou son année"
value={search}
onChange={onChange}
autoComplete="off"
/>
</Col>
<Col className="flex-grow-0">
<SearchButton className="btn-primary" disabled={loading} onClick={onSubmit}>
{loading ? <Spinner size="sm" color="light" data-testid="loading" /> : "Chercher"}
</SearchButton>
</Col>
</FormGroup>
</Form>
{error && (
<Alert color="danger" className="mb-4">
{error}
</Alert>
)}
{!error && !paginatedData.elements.length && <div className="text-center">{"Aucun attentat trouvé."}</div>}
{!error && !!paginatedData.elements.length && (
<>
<Pagination data={paginatedData} fn={fetchPage(search)} />
<Table responsive className="table-hover">
<thead>
<tr className="table-light">
<th>Nom</th>
<th>Année</th>
</tr>
</thead>
<tbody>
{paginatedData.elements.map((attack) => (
<Link key={attack.id} href="/administration/attacks/[id]" as={`/administration/attacks/${attack.id}`}>
<tr>
<td>
<b>{`${attack.name}`}</b>
</td>
<td>
<b>{attack.year}</b>
</td>