Enregistrement et affichage d'un flux d'évènements

- Ajout d'une nouvelle entité "Event"
- Affichage d'une "timeline" sur le tableau de bord
- Création semi-automatique des évènements lors des modifications par
  les utilisateurs
This commit is contained in:
2020-10-02 16:37:24 +02:00
parent 61eacefd6c
commit f169169bc7
27 changed files with 692 additions and 98 deletions

View File

@ -1125,9 +1125,9 @@
"integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="
},
"@fortawesome/fontawesome-free": {
"version": "5.13.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.13.0.tgz",
"integrity": "sha512-xKOeQEl5O47GPZYIMToj6uuA2syyFlq9EMSl2ui0uytjY9xbe8XS0pexNWmxrdcCyNGyDmLyYw5FtKsalBUeOg==",
"version": "5.15.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.0.tgz",
"integrity": "sha512-wXetjQBNMTP59MAYNR1tdahMDOLx3FYj3PKdso7PLFLDpTvmAIqhSSEqnSTmWKahRjD+Sh5I5635+5qaoib5lw==",
"dev": true
},
"@nodelib/fs.scandir": {
@ -3197,6 +3197,11 @@
"resolved": "https://registry.npmjs.org/bulma/-/bulma-0.9.0.tgz",
"integrity": "sha512-rV75CJkubNUroAt0qCRkjznZLoaXq/ctfMXsMvKSL84UetbSyx5REl96e8GoQ04G4Tkw0XF3STECffTOQrbzOQ=="
},
"bulma-timeline": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/bulma-timeline/-/bulma-timeline-3.0.4.tgz",
"integrity": "sha512-gCUOcSUuzHoeVMkCpLF49j5Z5yl78XQ+KgJcT+1ju5WIGgBgVytRUob/dw5NHAxPLO2rmcvwYNbCJFp7w4WT4Q=="
},
"bytes": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
@ -4484,9 +4489,9 @@
"dev": true
},
"elliptic": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz",
"integrity": "sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw==",
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.3.tgz",
"integrity": "sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==",
"dev": true,
"requires": {
"bn.js": "^4.4.0",
@ -6551,9 +6556,9 @@
}
},
"lodash": {
"version": "4.17.15",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
"version": "4.17.20",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA=="
},
"loglevel": {
"version": "1.6.8",
@ -7052,9 +7057,9 @@
}
},
"node-forge": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.9.0.tgz",
"integrity": "sha512-7ASaDa3pD+lJ3WvXFsxekJQelBKRpne+GOVbLbtHYdd7pFspyeuJHnWfLplGf3SwKGbfs/aYl5V/JCIaHVUKKQ==",
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz",
"integrity": "sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==",
"dev": true
},
"node-gyp": {
@ -8745,12 +8750,12 @@
"dev": true
},
"selfsigned": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-1.10.7.tgz",
"integrity": "sha512-8M3wBCzeWIJnQfl43IKwOmC4H/RAp50S8DF60znzjW5GVqTcSe2vWclt7hmYVPkKPlHWOu5EaWOMZ2Y6W8ZXTA==",
"version": "1.10.8",
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-1.10.8.tgz",
"integrity": "sha512-2P4PtieJeEwVgTU9QEcwIRDQ/mXJLX8/+I3ur+Pg16nS8oNbrGxEso9NyYWy8NAmXiNl4dlAp5MwoNeCWzON4w==",
"dev": true,
"requires": {
"node-forge": "0.9.0"
"node-forge": "^0.10.0"
}
},
"semver": {

View File

@ -25,7 +25,7 @@
"@babel/plugin-transform-runtime": "^7.7.4",
"@babel/preset-env": "^7.7.1",
"@babel/preset-react": "^7.7.4",
"@fortawesome/fontawesome-free": "^5.11.2",
"@fortawesome/fontawesome-free": "^5.14.0",
"@types/node": "^13.13.4",
"@types/react-dom": "^16.9.7",
"@types/react-redux": "^7.1.7",
@ -55,6 +55,7 @@
"@types/qs": "^6.9.3",
"bs58": "^4.0.1",
"bulma": "^0.9.0",
"bulma-timeline": "^3.0.4",
"graphql": "^15.3.0",
"react": "^16.12.0",
"react-dom": "^16.12.0",

View File

@ -74,7 +74,7 @@ const UserSessionCheck: FunctionComponent<UserSessionCheckProps> = ({ setLoggedI
useEffect(() => {
if (loading) return;
setLoggedIn(user.id !== '');
setLoggedIn(user && user.id !== '');
}, [user]);
return null;

View File

@ -1,29 +1,35 @@
import React from 'react';
import { WorkgroupsPanel } from './WorkgroupsPanel';
import { DecisionSupportFilePanel } from './DecisionSupportFilePanel';
import { Timeline } from '../Timeline';
import { useEvents } from '../../gql/queries/event';
const from = new Date();
from.setDate(from.getDate() - 7);
export function Dashboard() {
const { events } = useEvents({
variables: {
filter: {
from,
}
}
});
return (
<div className="columns">
<div className="column is-6">
<div className="column is-5">
<div className="box">
<h3 className="is-size-3 mb-3">Ces 7 derniers jours</h3>
<Timeline events={events} />
</div>
</div>
<div className="column is-4">
<DecisionSupportFilePanel />
</div>
<div className="column is-3">
<WorkgroupsPanel />
</div>
<div className="column is-3">
<div className="box">
<div className="level">
<div className="level-left">
<h3 className="is-size-3 subtitle level-item">Assemblées</h3>
</div>
<div className="level-right">
<button disabled className="button is-primary level-item"><i className="fa fa-plus"></i></button>
</div>
</div>
<pre>TODO</pre>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,25 @@
import React, { FunctionComponent } from "react";
import { Link } from "react-router-dom";
import { useWorkgroups } from "../gql/queries/workgroups";
import { useDecisionSupportFiles } from "../gql/queries/dsf";
export interface DecisioSupportFileLinkProps {
decisionSupportFileId: number
}
export const DecisioSupportFileLink: FunctionComponent<DecisioSupportFileLinkProps> = ({ decisionSupportFileId }) => {
const { decisionSupportFiles } = useDecisionSupportFiles({
fetchPolicy: "cache-first",
variables: {
filter: {
ids: [decisionSupportFileId]
}
}
});
const title = decisionSupportFiles.length > 0 ? decisionSupportFiles[0].title : `#${decisionSupportFileId}`;
return (
<Link to={`/decisions/${decisionSupportFileId}`}>{title}</Link>
);
};

View File

@ -1,6 +1,5 @@
import React, { Fragment, useState } from 'react';
import logo from '../resources/logo.svg';
import { useSelector } from 'react-redux';
import { Config } from '../config';
import { Link } from 'react-router-dom';
import { useLoggedIn } from '../hooks/useLoggedIn';

View File

@ -0,0 +1,208 @@
import React, { FunctionComponent } from "react";
import { formatDate } from "../util/date";
import { Event } from "../types/event";
import { Link } from "react-router-dom";
import { WorkgroupLink } from "./WorkgroupLink";
import { DecisioSupportFileLink } from "./DecisionSupportFileLink";
export interface TimelineProps {
events?: Event[]
}
export const Timeline: FunctionComponent<TimelineProps> = ({ events }) => {
events = debounceEvents(events) || [];
return (
<React.Fragment>
<div className="timeline">
{
events.map(evt => {
return (
<div key={evt.id} className="timeline-item">
{renderEventMarker(evt)}
<div className="timeline-content">
<p className="heading">{formatDate(evt.createdAt)}</p>
{renderEventContent(evt)}
</div>
</div>
);
})
}
{
events.length === 0 ?
<p className="has-text-centered is-italic">Aucun évènement.</p> :
null
}
</div>
</React.Fragment>
);
}
function debounceEvents(events: Event[]): Event[] {
const debounced = [];
for(let evt: Event, i = 0; (evt = events[i]); ++i) {
const prev = i > 0 ? events[i-1] : null;
if (!prev) {
debounced.push(evt);
continue;
}
const isSame = evt.objectId === prev.objectId &&
evt.objectType === prev.objectType &&
evt.type === prev.type &&
evt.user.id === prev.user.id
;
if (isSame) continue;
debounced.push(evt);
}
return debounced;
}
const eventMarkerMap = {
"closed": (evt:Event) => (
<div className="timeline-marker is-icon is-danger">
<i className="fa fa-times"></i>
</div>
),
"created": (evt:Event) => (
<div className="timeline-marker is-icon is-success">
<i className="fa fa-plus"></i>
</div>
),
"updated": (evt:Event) => (
<div className="timeline-marker is-icon is-info">
<i className="fa fa-pen"></i>
</div>
),
"title-changed": (evt:Event) => (
<div className="timeline-marker is-icon is-info">
<i className="fa fa-pen"></i>
</div>
),
"status-changed": (evt:Event) => (
<div className="timeline-marker is-icon is-primary">
<i className="fa fa-star"></i>
</div>
),
"joined": (evt:Event) => (
<div className="timeline-marker is-icon is-info">
<i className="fa fa-users"></i>
</div>
),
"leaved": (evt:Event) => (
<div className="timeline-marker is-icon is-warning">
<i className="fas fa-users-slash"></i>
</div>
),
}
function renderEventMarker(evt: Event) {
const render = eventMarkerMap[evt.type];
if (!render) return ( <div className="timeline-marker"></div> );
return render(evt);
}
const eventContentMap = {
"created": {
"workgroup": (evt:Event) => {
return (
<React.Fragment>
<span>{`${evt.user.name ? evt.user.name : evt.user.email} a créé le groupe de travail `}</span>
"<WorkgroupLink workgroupId={evt.objectId} />".
</React.Fragment>
);
},
"dsf": (evt:Event) => {
return (
<React.Fragment>
<span>{`${evt.user.name ? evt.user.name : evt.user.email} a créé le dossier d'aide à la décision `}</span>
"<DecisioSupportFileLink decisionSupportFileId={evt.objectId} />".
</React.Fragment>
);
},
},
"title-changed": {
"dsf": (evt:Event) => {
return (
<React.Fragment>
<span>{`${evt.user.name ? evt.user.name : evt.user.email} a modifié le titre du dossier d'aide à la décision `}</span>
"<DecisioSupportFileLink decisionSupportFileId={evt.objectId} />".
</React.Fragment>
)
}
},
"status-changed": {
"dsf": (evt:Event) => {
return (
<React.Fragment>
<span>{`${evt.user.name ? evt.user.name : evt.user.email} a modifié le statut du dossier d'aide à la décision `}</span>
"<DecisioSupportFileLink decisionSupportFileId={evt.objectId} />".
</React.Fragment>
)
}
},
"joined": {
"workgroup": (evt:Event) => {
return (
<React.Fragment>
<span>{`${evt.user.name ? evt.user.name : evt.user.email} a rejoint le groupe de travail `}</span>
"<WorkgroupLink workgroupId={evt.objectId} />".
</React.Fragment>
);
},
},
"updated": {
"workgroup": (evt:Event) => {
return (
<React.Fragment>
<span>{`${evt.user.name ? evt.user.name : evt.user.email} a mis à jour le groupe de travail `}</span>
"<WorkgroupLink workgroupId={evt.objectId} />".
</React.Fragment>
);
},
"dsf": (evt:Event) => {
return (
<React.Fragment>
<span>{`${evt.user.name ? evt.user.name : evt.user.email} a modifié le dossier d'aide à la décision `}</span>
"<DecisioSupportFileLink decisionSupportFileId={evt.objectId} />".
</React.Fragment>
);
},
},
"leaved": {
"workgroup": (evt:Event) => {
return (
<React.Fragment>
<span>{`${evt.user.name ? evt.user.name : evt.user.email} a quitté le groupe de travail `}</span>
"<WorkgroupLink workgroupId={evt.objectId} />".
</React.Fragment>
);
},
},
"closed": {
"workgroup": (evt:Event) => {
return (
<React.Fragment>
<span>{`${evt.user.name ? evt.user.name : evt.user.email} a clos le groupe de travail `}</span>
"<WorkgroupLink workgroupId={evt.objectId} />".
</React.Fragment>
);
},
},
};
function renderEventContent(evt: Event) {
const eventTypeMap = eventContentMap[evt.type];
const render = eventTypeMap && eventTypeMap[evt.objectType];
if (!eventTypeMap || !render) {
return (
<span className="is-italic">{`Type d'évènement "${evt.type}/${evt.objectType}" inconnu.`}</span>
);
}
return render(evt);
}

View File

@ -0,0 +1,24 @@
import React, { FunctionComponent } from "react";
import { Link } from "react-router-dom";
import { useWorkgroups } from "../gql/queries/workgroups";
export interface WorkgroupLinkProps {
workgroupId: number
}
export const WorkgroupLink: FunctionComponent<WorkgroupLinkProps> = ({ workgroupId }) => {
const { workgroups } = useWorkgroups({
fetchPolicy: "cache-first",
variables: {
filter: {
ids: [workgroupId]
}
}
});
const workgroupName = workgroups.length > 0 ? workgroups[0].name : `#${workgroupId}`;
return (
<Link to={`/workgroups/${workgroupId}`}>{workgroupName}</Link>
);
};

View File

@ -1,6 +1,7 @@
import React, { useState, ChangeEvent, useEffect } from 'react';
import { Workgroup } from '../../types/workgroup';
import { useIsAuthorized } from '../../gql/queries/authorization';
import { formatDate } from '../../util/date';
export interface InfoFormProps {
workgroup: Workgroup
@ -80,7 +81,7 @@ export function InfoForm({ workgroup, onChange }: InfoFormProps) {
<div className="field">
<label className="label">Date de création</label>
<div className="control">
<p className="input is-static">{state.workgroup.createdAt}</p>
<p className="input is-static">{formatDate(state.workgroup.createdAt)}</p>
</div>
</div>:
null
@ -90,7 +91,7 @@ export function InfoForm({ workgroup, onChange }: InfoFormProps) {
<div className="field">
<label className="label">Date de clôture</label>
<div className="control">
<p className="input is-static">{state.workgroup.closedAt}</p>
<p className="input is-static">{formatDate(state.workgroup.closedAt)}</p>
</div>
</div>:
null

View File

@ -0,0 +1,31 @@
import { gql, useQuery, QueryHookOptions } from '@apollo/client';
import { useGraphQLData } from './helper';
import { Event } from '../../types/event';
export const QUERY_EVENTS = gql`
query events($filter: EventFilter) {
events(filter: $filter) {
id
user {
id
name
email
}
type
objectType
objectId
createdAt
}
}
`;
export function useEventsQuery<A = any, R = Record<string, any>>(options: QueryHookOptions<A, R> = {}) {
return useQuery(QUERY_EVENTS, options);
}
export function useEvents<A = any, R = Record<string, any>>(options: QueryHookOptions<A, R> = {}) {
const { data, loading, error } = useGraphQLData<Event[]>(
QUERY_EVENTS, 'events', [], options
);
return { events: data, loading, error };
}

View File

@ -8,7 +8,6 @@ import '@fortawesome/fontawesome-free/js/solid'
import '@fortawesome/fontawesome-free/js/regular'
import '@fortawesome/fontawesome-free/js/brands'
import './resources/favicon.png';
import { ApolloProvider } from '@apollo/client';
ReactDOM.render(
<App />,

View File

@ -1,3 +1,5 @@
@import 'bulma/bulma.sass';
@import 'bulma-timeline/dist/css/bulma-timeline.sass';
@import '_bulma-timeline.scss';
@import '_base.scss';
@import '_loader.scss';

View File

@ -0,0 +1,12 @@
.timeline {
.timeline-item {
.timeline-marker {
&.is-icon {
> svg {
color: $white;
font-size: $timeline-icon-size !important;
}
}
}
}
}

11
client/src/types/event.ts Normal file
View File

@ -0,0 +1,11 @@
import { User } from "./user";
export interface Event {
id: string
createdAt: Date
updatedAt: Date
user: User
objectType: string
objectId: number
type: string
}