Création/mise à jour basique d'un DAD #15
@ -3,11 +3,11 @@ import { DecisionSupportFile, DecisionSupportFileStatus } from '../../types/deci
|
||||
import { ItemPanel, TabDefinition, Item } from './ItemPanel';
|
||||
import { useUserProfile } from '../../gql/queries/profile';
|
||||
import { inWorkgroup } from '../../types/workgroup';
|
||||
import { useDecisions } from '../../gql/queries/decisions';
|
||||
import { useDecisionSupportFiles } from '../../gql/queries/dsf';
|
||||
|
||||
export function DecisionSupportFilePanel() {
|
||||
const { user } = useUserProfile();
|
||||
const { decisions } = useDecisions();
|
||||
const { decisionSupportFiles } = useDecisionSupportFiles();
|
||||
|
||||
const tabs: TabDefinition[] = [
|
||||
{
|
||||
@ -31,9 +31,9 @@ export function DecisionSupportFilePanel() {
|
||||
return (
|
||||
<ItemPanel
|
||||
className='is-link'
|
||||
title="D.A.D."
|
||||
title="Dossiers"
|
||||
newItemUrl="/decisions/new"
|
||||
items={decisions}
|
||||
items={decisionSupportFiles}
|
||||
tabs={tabs}
|
||||
itemIconClassName='fas fa-folder'
|
||||
itemKey={item => item.id}
|
||||
|
@ -1,23 +1,35 @@
|
||||
import React, { FunctionComponent, useState, ChangeEvent, useEffect } from 'react';
|
||||
import { DecisionSupportFileUpdaterProps } from './DecisionSupportFileUpdaterProps';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { asDate } from '../../util/date';
|
||||
|
||||
export interface ClarificationSectionProps extends DecisionSupportFileUpdaterProps {};
|
||||
|
||||
const ClarificationSectionName = 'clarification';
|
||||
|
||||
export const ClarificationSection: FunctionComponent<ClarificationSectionProps> = ({ dsf, updateDSF }) => {
|
||||
const [ section, setSection ] = useState({
|
||||
objectives: '',
|
||||
motivations: '',
|
||||
scope: '',
|
||||
nature: '',
|
||||
deadline: undefined,
|
||||
hasDeadline: false,
|
||||
const [ state, setState ] = useState({
|
||||
changed: false,
|
||||
section: {
|
||||
objectives: '',
|
||||
motivations: '',
|
||||
scope: '',
|
||||
nature: '',
|
||||
deadline: undefined,
|
||||
hasDeadline: false,
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
updateDSF({ ...dsf, sections: { ...dsf.sections, [ClarificationSectionName]: { ...section }} })
|
||||
}, [section]);
|
||||
if (!state.changed) return;
|
||||
updateDSF({ ...dsf, sections: { ...dsf.sections, [ClarificationSectionName]: { ...state.section }} })
|
||||
setState(state => ({ ...state, changed: false }));
|
||||
}, [state.changed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dsf.sections[ClarificationSectionName]) return;
|
||||
setState(state => ({ ...state, changed: false, section: {...state.section, ...dsf.sections[ClarificationSectionName] }}));
|
||||
}, [dsf.sections[ClarificationSectionName]]);
|
||||
|
||||
const onTitleChange = (evt: ChangeEvent<HTMLInputElement>) => {
|
||||
const title = (evt.currentTarget).value;
|
||||
@ -27,12 +39,12 @@ export const ClarificationSection: FunctionComponent<ClarificationSectionProps>
|
||||
const onSectionAttrChange = (attrName: string, evt: ChangeEvent<HTMLInputElement>) => {
|
||||
const target = evt.currentTarget;
|
||||
const value = target.hasOwnProperty('checked') ? target.checked : target.value;
|
||||
setSection(section => ({ ...section, [attrName]: value }));
|
||||
setState(state => ({ ...state, changed: true, section: {...state.section, [attrName]: value }}));
|
||||
};
|
||||
|
||||
const onDeadlineChange = (evt: ChangeEvent<HTMLInputElement>) => {
|
||||
const deadline = evt.currentTarget.valueAsDate;
|
||||
setSection(section => ({ ...section, deadline }));
|
||||
setState(state => ({ ...state, changed: true, section: { ...state.section, deadline }}));
|
||||
};
|
||||
|
||||
return (
|
||||
@ -48,7 +60,7 @@ export const ClarificationSection: FunctionComponent<ClarificationSectionProps>
|
||||
<label className="label">Quelle décision devons nous prendre ?</label>
|
||||
<div className="control">
|
||||
<textarea className="textarea"
|
||||
value={section.objectives}
|
||||
value={state.section.objectives}
|
||||
onChange={onSectionAttrChange.bind(null, 'objectives')}
|
||||
placeholder="Décrire globalement les tenants et aboutissants de la décision à prendre."
|
||||
rows={10}>
|
||||
@ -60,7 +72,7 @@ export const ClarificationSection: FunctionComponent<ClarificationSectionProps>
|
||||
<label className="label">Pourquoi devons nous prendre cette décision ?</label>
|
||||
<div className="control">
|
||||
<textarea className="textarea"
|
||||
value={section.motivations}
|
||||
value={state.section.motivations}
|
||||
onChange={onSectionAttrChange.bind(null, 'motivations')}
|
||||
placeholder="Décrire pourquoi il est important de prendre cette décision."
|
||||
rows={10}>
|
||||
@ -74,7 +86,7 @@ export const ClarificationSection: FunctionComponent<ClarificationSectionProps>
|
||||
<div className="select">
|
||||
<select
|
||||
onChange={onSectionAttrChange.bind(null, 'scope')}
|
||||
value={section.scope}>
|
||||
value={state.section.scope}>
|
||||
<option></option>
|
||||
<option value="individual">Individuelle</option>
|
||||
<option value="identified-group">Groupe identifié</option>
|
||||
@ -88,7 +100,7 @@ export const ClarificationSection: FunctionComponent<ClarificationSectionProps>
|
||||
<div className="control">
|
||||
<div className="select">
|
||||
<select onChange={onSectionAttrChange.bind(null, 'nature')}
|
||||
value={section.nature}>
|
||||
value={state.section.nature}>
|
||||
<option></option>
|
||||
<option value="operational">Opérationnelle</option>
|
||||
<option value="tactic">Tactique</option>
|
||||
@ -103,13 +115,13 @@ export const ClarificationSection: FunctionComponent<ClarificationSectionProps>
|
||||
<label className="checkbox">
|
||||
<input type="checkbox"
|
||||
onChange={onSectionAttrChange.bind(null, 'hasDeadline')}
|
||||
checked={section.hasDeadline} />
|
||||
checked={state.section.hasDeadline} />
|
||||
<span className="ml-1">Existe t'il une échéance particulière pour cette décision ?</span>
|
||||
</label>
|
||||
<div className="field">
|
||||
<div className="control">
|
||||
<input disabled={!section.hasDeadline}
|
||||
value={section.deadline ? section.deadline.toISOString().substr(0, 10) : ''}
|
||||
<input disabled={!state.section.hasDeadline}
|
||||
value={state.section.deadline ? asDate(state.section.deadline).toISOString().substr(0, 10) : ''}
|
||||
onChange={onDeadlineChange}
|
||||
type="date" className="input" />
|
||||
</div>
|
||||
|
@ -1,12 +1,13 @@
|
||||
import React, { FunctionComponent, useState } from 'react';
|
||||
import React, { FunctionComponent, useState, useEffect } from 'react';
|
||||
import { Page } from '../Page';
|
||||
import { ClarificationSection } from './ClarificationSection';
|
||||
import { OptionsSection } from './OptionsSection';
|
||||
import { MetadataPanel } from './MetadataPanel';
|
||||
import { AppendixPanel } from './AppendixPanel';
|
||||
import { DecisionSupportFile, newDecisionSupportFile, DecisionSupportFileStatus } from '../../types/decision';
|
||||
import { useParams } from 'react-router';
|
||||
import { useDecisions } from '../../gql/queries/decisions';
|
||||
import { useParams, useHistory } from 'react-router';
|
||||
import { useDecisionSupportFiles } from '../../gql/queries/dsf';
|
||||
import { useCreateDecisionSupportFileMutation, useUpdateDecisionSupportFileMutation } from '../../gql/mutations/dsf';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
|
||||
export interface DecisionSupportFilePageProps {
|
||||
|
||||
@ -14,31 +15,69 @@ export interface DecisionSupportFilePageProps {
|
||||
|
||||
export const DecisionSupportFilePage: FunctionComponent<DecisionSupportFilePageProps> = () => {
|
||||
const { id } = useParams();
|
||||
const { decisions } = useDecisions({
|
||||
const history = useHistory();
|
||||
const { decisionSupportFiles } = useDecisionSupportFiles({
|
||||
variables:{
|
||||
filter: {
|
||||
ids: [id],
|
||||
filter: {
|
||||
ids: id !== 'new' ? [id] : undefined,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const [ state, setState ] = useState({
|
||||
dsf: decisions.length > 0 ? decisions[0] : newDecisionSupportFile(),
|
||||
selectedTabIndex: 0
|
||||
dsf: newDecisionSupportFile(),
|
||||
saved: true,
|
||||
selectedTabIndex: 0,
|
||||
});
|
||||
|
||||
const isNew = state.dsf.id === '';
|
||||
const isClosed = state.dsf.status === DecisionSupportFileStatus.Closed;
|
||||
|
||||
useEffect(() => {
|
||||
const dsf = decisionSupportFiles.length > 0 && decisionSupportFiles[0].id === id ? decisionSupportFiles[0] : {};
|
||||
setState(state => ({ ...state, dsf: { ...state.dsf, ...dsf }}))
|
||||
}, [ decisionSupportFiles ]);
|
||||
|
||||
const selectTab = (tabIndex: number) => {
|
||||
setState(state => ({ ...state, selectedTabIndex: tabIndex }));
|
||||
};
|
||||
|
||||
const updateDSF = (dsf: DecisionSupportFile) => {
|
||||
setState(state => ({...state, dsf}));
|
||||
setState(state => {
|
||||
return { ...state, saved: false, dsf: { ...state.dsf, ...dsf } };
|
||||
});
|
||||
};
|
||||
|
||||
console.log(state.dsf);
|
||||
const [ createDecisionSupportFile ] = useCreateDecisionSupportFileMutation();
|
||||
const [ updateDecisionSupportFile ] = useUpdateDecisionSupportFileMutation();
|
||||
|
||||
const saveDSF = () => {
|
||||
const changes = {
|
||||
title: state.dsf.title !== '' ? state.dsf.title : undefined,
|
||||
status: state.dsf.status,
|
||||
workgroupId: state.dsf.workgroup ? state.dsf.workgroup.id : undefined,
|
||||
sections: state.dsf.sections,
|
||||
};
|
||||
|
||||
if (!changes.workgroupId) return;
|
||||
|
||||
if (state.dsf.id === '') {
|
||||
createDecisionSupportFile({
|
||||
variables: { changes },
|
||||
}).then(({ data }) => {
|
||||
history.push(`/decisions/${data.createDecisionSupportFile.id}`);
|
||||
});
|
||||
} else {
|
||||
updateDecisionSupportFile({
|
||||
variables: { changes, id: state.dsf.id },
|
||||
}).then(({ data }) => {
|
||||
setState(state => {
|
||||
return { ...state, saved: true, dsf: { ...state.dsf, ...data.updateDecisionSupportFile } };
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const canSave = !!state.dsf.workgroup && !state.saved;
|
||||
const isNew = state.dsf.id === '';
|
||||
const isClosed = state.dsf.status === DecisionSupportFileStatus.Closed;
|
||||
|
||||
return (
|
||||
<Page title="Dossier d'Aide à la Décision">
|
||||
@ -62,6 +101,14 @@ export const DecisionSupportFilePage: FunctionComponent<DecisionSupportFilePageP
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className="level-right">
|
||||
<div className="level-item buttons">
|
||||
<button className="button is-medium is-success" disabled={!canSave} onClick={saveDSF}>
|
||||
<span className="icon"><i className="fa fa-save"></i></span>
|
||||
<span>Enregistrer</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div className="columns mt-3">
|
||||
@ -98,7 +145,7 @@ export const DecisionSupportFilePage: FunctionComponent<DecisionSupportFilePageP
|
||||
}
|
||||
</div>
|
||||
<div className="column is-3">
|
||||
<MetadataPanel dsf={state.dsf} />
|
||||
<MetadataPanel dsf={state.dsf} updateDSF={updateDSF} />
|
||||
<AppendixPanel dsf={state.dsf} />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,11 +1,35 @@
|
||||
import React, { FunctionComponent, useState } from 'react';
|
||||
import { DecisionSupportFile } from '../../types/decision';
|
||||
import React, { FunctionComponent, useState, useEffect, ChangeEvent } from 'react';
|
||||
import { DecisionSupportFile, DecisionSupportFileStatus } from '../../types/decision';
|
||||
import { useWorkgroups } from '../../gql/queries/workgroups';
|
||||
import { useUserProfile } from '../../gql/queries/profile';
|
||||
import { inWorkgroup } from '../../types/workgroup';
|
||||
import { DecisionSupportFileUpdaterProps } from './DecisionSupportFileUpdaterProps';
|
||||
import { asDate } from '../../util/date';
|
||||
|
||||
export interface MetadataPanelProps {
|
||||
dsf: DecisionSupportFile,
|
||||
};
|
||||
export interface MetadataPanelProps extends DecisionSupportFileUpdaterProps {};
|
||||
|
||||
export const MetadataPanel: FunctionComponent<MetadataPanelProps> = ({ dsf, updateDSF }) => {
|
||||
const { user } = useUserProfile();
|
||||
const { workgroups } = useWorkgroups();
|
||||
|
||||
const [ userOpenedWorkgroups, setUserOpenedWorkgroups ] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = workgroups.filter(wg => !wg.closedAt && inWorkgroup(user, wg));
|
||||
setUserOpenedWorkgroups(filtered);
|
||||
}, [workgroups, user])
|
||||
|
||||
const onStatusChanged = (evt: ChangeEvent<HTMLSelectElement>) => {
|
||||
const status = evt.currentTarget.value as DecisionSupportFileStatus;
|
||||
updateDSF({ ...dsf, status });
|
||||
};
|
||||
|
||||
const onWorkgroupChanged = (evt: ChangeEvent<HTMLSelectElement>) => {
|
||||
const workgroupId = evt.currentTarget.value;
|
||||
const workgroup = workgroups.find(wg => wg.id === workgroupId);
|
||||
updateDSF({ ...dsf, workgroup });
|
||||
};
|
||||
|
||||
export const MetadataPanel: FunctionComponent<MetadataPanelProps> = ({ dsf }) => {
|
||||
return (
|
||||
<nav className="panel">
|
||||
<p className="panel-heading">
|
||||
@ -17,8 +41,15 @@ export const MetadataPanel: FunctionComponent<MetadataPanelProps> = ({ dsf }) =>
|
||||
<div className="label">Groupe de travail</div>
|
||||
<div className="control is-expanded">
|
||||
<div className="select is-fullwidth">
|
||||
<select>
|
||||
<select onChange={onWorkgroupChanged} value={dsf.workgroup ? dsf.workgroup.id : ''}>
|
||||
<option></option>
|
||||
{
|
||||
userOpenedWorkgroups.map(wg => {
|
||||
return (
|
||||
<option key={`wg-${wg.id}`} value={wg.id}>{wg.name}</option>
|
||||
);
|
||||
})
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@ -27,10 +58,11 @@ export const MetadataPanel: FunctionComponent<MetadataPanelProps> = ({ dsf }) =>
|
||||
<div className="label">Statut</div>
|
||||
<div className="control is-expanded">
|
||||
<div className="select is-fullwidth">
|
||||
<select>
|
||||
<option>En préparation</option>
|
||||
<option>Prêt à voter</option>
|
||||
<option>Voté</option>
|
||||
<select onChange={onStatusChanged} value={dsf.status}>
|
||||
<option value="draft">Brouillon</option>
|
||||
<option value="ready">Prêt à voter</option>
|
||||
<option value="voted">Voté</option>
|
||||
<option value="closed">Clôs</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@ -38,13 +70,13 @@ export const MetadataPanel: FunctionComponent<MetadataPanelProps> = ({ dsf }) =>
|
||||
<div className="field">
|
||||
<div className="label">Créé le</div>
|
||||
<div className="control">
|
||||
<p>--</p>
|
||||
<p>{asDate(dsf.createdAt).toISOString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<div className="label">Dernière modification</div>
|
||||
<div className="label">Voté le</div>
|
||||
<div className="control">
|
||||
<p>--</p>
|
||||
<p>{dsf.votedAt ? dsf.votedAt : '--'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -3,11 +3,10 @@ import logo from '../resources/logo.svg';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Config } from '../config';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useUserProfileQuery } from '../gql/queries/profile';
|
||||
import { WithLoader } from './WithLoader';
|
||||
import { useLoggedIn } from '../hooks/useLoggedIn';
|
||||
|
||||
export function Navbar() {
|
||||
const userProfileQuery = useUserProfileQuery();
|
||||
const loggedIn = useLoggedIn();
|
||||
const [ isActive, setActive ] = useState(false);
|
||||
|
||||
const toggleMenu = () => {
|
||||
@ -35,30 +34,30 @@ export function Navbar() {
|
||||
<div className={`navbar-menu ${isActive ? 'is-active' : ''}`}>
|
||||
<div className="navbar-end">
|
||||
<div className="navbar-item">
|
||||
<WithLoader loading={userProfileQuery.loading}>
|
||||
<div className="buttons">
|
||||
<div className="buttons">
|
||||
{
|
||||
userProfileQuery.data && userProfileQuery.data.userProfile ?
|
||||
loggedIn ?
|
||||
<Fragment>
|
||||
<Link to="/profile" className="button">
|
||||
<span className="icon">
|
||||
<i className="fas fa-user"></i>
|
||||
</span>
|
||||
<span>Mon profil</span>
|
||||
</Link>
|
||||
<a className="button" href={Config.logoutURL}>
|
||||
<a className="button is-warning" href={Config.logoutURL}>
|
||||
<span className="icon">
|
||||
<i className="fas fa-sign-out-alt"></i>
|
||||
</span>
|
||||
</a>
|
||||
</Fragment> :
|
||||
<a className="button" href={Config.loginURL}>
|
||||
<a className="button is-primary" href={Config.loginURL}>
|
||||
<span className="icon">
|
||||
<i className="fas fa-sign-in-alt"></i>
|
||||
</span>
|
||||
<span>S'identifier</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</WithLoader>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -3,6 +3,7 @@ import { Config } from '../config';
|
||||
import { WebSocketLink } from "@apollo/client/link/ws";
|
||||
import { RetryLink } from "@apollo/client/link/retry";
|
||||
import { SubscriptionClient } from "subscriptions-transport-ws";
|
||||
import { User } from '../types/user';
|
||||
|
||||
const subscriptionClient = new SubscriptionClient(Config.subscriptionEndpoint, {
|
||||
reconnect: true,
|
||||
@ -14,7 +15,42 @@ const link = new RetryLink({attempts: {max: 2}}).split(
|
||||
new HttpLink({ uri: Config.graphQLEndpoint, credentials: 'include' })
|
||||
);
|
||||
|
||||
const cache = new InMemoryCache({
|
||||
typePolicies: {
|
||||
Workgroup: {
|
||||
fields: {
|
||||
members: {
|
||||
merge: mergeArrayByField<User>("id"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const client = new ApolloClient<any>({
|
||||
cache: new InMemoryCache(),
|
||||
cache: cache,
|
||||
link: link,
|
||||
});
|
||||
});
|
||||
|
||||
function mergeArrayByField<T>(fieldName: string) {
|
||||
return (existing: T[] = [], incoming: T[], { readField, mergeObjects }) => {
|
||||
const merged: any[] = existing ? existing.slice(0) : [];
|
||||
const objectFieldToIndex: Record<string, number> = Object.create(null);
|
||||
if (existing) {
|
||||
existing.forEach((obj, index) => {
|
||||
objectFieldToIndex[readField(fieldName, obj)] = index;
|
||||
});
|
||||
}
|
||||
incoming.forEach(obj => {
|
||||
const field = readField(fieldName, obj);
|
||||
const index = objectFieldToIndex[field];
|
||||
if (typeof index === "number") {
|
||||
merged[index] = mergeObjects(merged[index], obj);
|
||||
} else {
|
||||
objectFieldToIndex[name] = merged.length;
|
||||
merged.push(obj);
|
||||
}
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
}
|
40
client/src/gql/mutations/dsf.tsx
Normal file
40
client/src/gql/mutations/dsf.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { gql, useQuery, useMutation } from '@apollo/client';
|
||||
import { QUERY_DECISION_SUPPORT_FILES } from '../queries/dsf';
|
||||
|
||||
export const MUTATION_CREATE_DECISION_SUPPORT_FILE = gql`
|
||||
mutation createDecisionSupportFile($changes: DecisionSupportFileChanges!) {
|
||||
createDecisionSupportFile(changes: $changes) {
|
||||
id,
|
||||
title,
|
||||
status,
|
||||
sections,
|
||||
createdAt,
|
||||
updatedAt
|
||||
}
|
||||
}`;
|
||||
|
||||
export function useCreateDecisionSupportFileMutation() {
|
||||
return useMutation(MUTATION_CREATE_DECISION_SUPPORT_FILE, {
|
||||
refetchQueries: [{query: QUERY_DECISION_SUPPORT_FILES}],
|
||||
});
|
||||
}
|
||||
|
||||
export const MUTATION_UPDATE_DECISION_SUPPORT_FILE = gql`
|
||||
mutation updateDecisionSupportFile($id: ID!, $changes: DecisionSupportFileChanges!) {
|
||||
updateDecisionSupportFile(id: $id, changes: $changes) {
|
||||
id,
|
||||
title,
|
||||
status,
|
||||
sections,
|
||||
createdAt,
|
||||
updatedAt
|
||||
}
|
||||
}`;
|
||||
|
||||
export function useUpdateDecisionSupportFileMutation() {
|
||||
return useMutation(MUTATION_UPDATE_DECISION_SUPPORT_FILE, {
|
||||
refetchQueries: [{
|
||||
query: QUERY_DECISION_SUPPORT_FILES,
|
||||
}],
|
||||
});
|
||||
}
|
@ -1,32 +0,0 @@
|
||||
import { gql, useQuery } from '@apollo/client';
|
||||
import { DecisionSupportFile } from '../../types/decision';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useGraphQLData } from './helper';
|
||||
|
||||
export const QUERY_DECISIONS = gql`
|
||||
query decisions($filter: DecisionFilter) {
|
||||
decisions(filter: $filter) {
|
||||
id,
|
||||
title,
|
||||
sections
|
||||
createdAt,
|
||||
closedAt,
|
||||
votedAt,
|
||||
workgroup {
|
||||
id,
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function useDecisionsQuery(options = {}) {
|
||||
return useQuery(QUERY_DECISIONS, options);
|
||||
}
|
||||
|
||||
export function useDecisions(options = {}) {
|
||||
const { data, loading, error } = useGraphQLData<DecisionSupportFile[]>(
|
||||
QUERY_DECISIONS, 'decisions', [], options
|
||||
);
|
||||
return { decisions: data, loading, error };
|
||||
}
|
35
client/src/gql/queries/dsf.tsx
Normal file
35
client/src/gql/queries/dsf.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { gql, useQuery } from '@apollo/client';
|
||||
import { DecisionSupportFile } from '../../types/decision';
|
||||
import { useGraphQLData } from './helper';
|
||||
|
||||
export const QUERY_DECISION_SUPPORT_FILES = gql`
|
||||
query decisionSupportFiles($filter: DecisionSupportFileFilter) {
|
||||
decisionSupportFiles(filter: $filter) {
|
||||
id,
|
||||
title,
|
||||
sections
|
||||
createdAt,
|
||||
closedAt,
|
||||
votedAt,
|
||||
status,
|
||||
workgroup {
|
||||
id,
|
||||
name,
|
||||
members {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function useDecisionSupportFilesQuery(options = {}) {
|
||||
return useQuery(QUERY_DECISION_SUPPORT_FILES, options);
|
||||
}
|
||||
|
||||
export function useDecisionSupportFiles(options = {}) {
|
||||
const { data, loading, error } = useGraphQLData<DecisionSupportFile[]>(
|
||||
QUERY_DECISION_SUPPORT_FILES, 'decisionSupportFiles', [], options
|
||||
);
|
||||
return { decisionSupportFiles: data, loading, error };
|
||||
}
|
19
client/src/hooks/useDebounce.tsx
Normal file
19
client/src/hooks/useDebounce.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useDebounce(value, delay) {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
},
|
||||
[value, delay]
|
||||
);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
4
client/src/util/date.ts
Normal file
4
client/src/util/date.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export function asDate(d: string|Date): Date {
|
||||
if (typeof d === 'string') return new Date(d);
|
||||
return d;
|
||||
}
|
@ -80,6 +80,7 @@ func applyMigration(ctx context.Context, ctn *service.Container) error {
|
||||
var initialModels = []interface{}{
|
||||
&model.User{},
|
||||
&model.Workgroup{},
|
||||
&model.DecisionSupportFile{},
|
||||
}
|
||||
|
||||
func m000initialSchema() orm.Migration {
|
||||
@ -95,8 +96,8 @@ func m000initialSchema() orm.Migration {
|
||||
return nil
|
||||
},
|
||||
func(ctx context.Context, tx *gorm.DB) error {
|
||||
for _, m := range initialModels {
|
||||
if err := tx.DropTableIfExists(m).Error; err != nil {
|
||||
for i := len(initialModels) - 1; i >= 0; i-- {
|
||||
if err := tx.DropTableIfExists(initialModels[i]).Error; err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
|
59
internal/graph/dsf_handler.go
Normal file
59
internal/graph/dsf_handler.go
Normal file
@ -0,0 +1,59 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"forge.cadoles.com/Cadoles/daddy/internal/orm"
|
||||
"gitlab.com/wpetit/goweb/middleware/container"
|
||||
|
||||
"forge.cadoles.com/Cadoles/daddy/internal/model"
|
||||
errs "github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func handleCreateDecisionSupportFile(ctx context.Context, changes *model.DecisionSupportFileChanges) (*model.DecisionSupportFile, error) {
|
||||
ctn := container.Must(ctx)
|
||||
db := orm.Must(ctn).DB()
|
||||
|
||||
repo := model.NewDSFRepository(db)
|
||||
|
||||
dsf, err := repo.Create(ctx, changes)
|
||||
if err != nil {
|
||||
return nil, errs.WithStack(err)
|
||||
}
|
||||
|
||||
return dsf, nil
|
||||
}
|
||||
|
||||
func handleUpdateDecisionSupportFile(ctx context.Context, id string, changes *model.DecisionSupportFileChanges) (*model.DecisionSupportFile, error) {
|
||||
ctn := container.Must(ctx)
|
||||
db := orm.Must(ctn).DB()
|
||||
|
||||
repo := model.NewDSFRepository(db)
|
||||
|
||||
dsf, err := repo.Update(ctx, id, changes)
|
||||
if err != nil {
|
||||
return nil, errs.WithStack(err)
|
||||
}
|
||||
|
||||
return dsf, nil
|
||||
}
|
||||
|
||||
func handleDecisionSupportFiles(ctx context.Context, filter *model.DecisionSupportFileFilter) ([]*model.DecisionSupportFile, error) {
|
||||
ctn := container.Must(ctx)
|
||||
db := orm.Must(ctn).DB()
|
||||
|
||||
repo := model.NewDSFRepository(db)
|
||||
|
||||
return repo.Search(ctx, filter)
|
||||
}
|
||||
|
||||
func handleSections(ctx context.Context, dsf *model.DecisionSupportFile) (map[string]interface{}, error) {
|
||||
sections := make(map[string]interface{})
|
||||
|
||||
if err := json.Unmarshal(dsf.Sections.RawMessage, §ions); err != nil {
|
||||
return nil, errs.WithStack(err)
|
||||
}
|
||||
|
||||
return sections, nil
|
||||
}
|
@ -6,8 +6,21 @@ input WorkgroupChanges {
|
||||
name: String
|
||||
}
|
||||
|
||||
input DecisionSupportFileChanges {
|
||||
title: String
|
||||
sections: Map
|
||||
status: String
|
||||
workgroupId: ID
|
||||
votedAt: Time
|
||||
closedAt: Time
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createDecisionSupportFile(changes: DecisionSupportFileChanges): DecisionSupportFile!
|
||||
updateDecisionSupportFile(id: ID!, changes: DecisionSupportFileChanges): DecisionSupportFile!
|
||||
|
||||
updateProfile(changes: ProfileChanges!): User!
|
||||
|
||||
joinWorkgroup(workgroupId: ID!): Workgroup!
|
||||
leaveWorkgroup(workgroupId: ID!): Workgroup!
|
||||
createWorkgroup(changes: WorkgroupChanges!): Workgroup!
|
||||
|
@ -10,6 +10,14 @@ import (
|
||||
"forge.cadoles.com/Cadoles/daddy/internal/model"
|
||||
)
|
||||
|
||||
func (r *mutationResolver) CreateDecisionSupportFile(ctx context.Context, changes *model.DecisionSupportFileChanges) (*model.DecisionSupportFile, error) {
|
||||
return handleCreateDecisionSupportFile(ctx, changes)
|
||||
}
|
||||
|
||||
func (r *mutationResolver) UpdateDecisionSupportFile(ctx context.Context, id string, changes *model.DecisionSupportFileChanges) (*model.DecisionSupportFile, error) {
|
||||
return handleUpdateDecisionSupportFile(ctx, id, changes)
|
||||
}
|
||||
|
||||
func (r *mutationResolver) UpdateProfile(ctx context.Context, changes model.ProfileChanges) (*model.User, error) {
|
||||
return handleUpdateUserProfile(ctx, changes)
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
scalar Time
|
||||
scalar Map
|
||||
|
||||
type User {
|
||||
id: ID!
|
||||
@ -21,7 +22,24 @@ input WorkgroupsFilter {
|
||||
ids: [ID]
|
||||
}
|
||||
|
||||
type DecisionSupportFile {
|
||||
id: ID!
|
||||
title: String
|
||||
sections: Map
|
||||
status: String
|
||||
workgroup: Workgroup
|
||||
createdAt: Time
|
||||
updatedAt: Time
|
||||
votedAt: Time
|
||||
closedAt: Time
|
||||
}
|
||||
|
||||
input DecisionSupportFileFilter {
|
||||
ids: [ID]
|
||||
}
|
||||
|
||||
type Query {
|
||||
userProfile: User
|
||||
workgroups(filter: WorkgroupsFilter): [Workgroup]!
|
||||
decisionSupportFiles(filter: DecisionSupportFileFilter): [DecisionSupportFile]!
|
||||
}
|
||||
|
@ -11,6 +11,14 @@ import (
|
||||
model1 "forge.cadoles.com/Cadoles/daddy/internal/model"
|
||||
)
|
||||
|
||||
func (r *decisionSupportFileResolver) ID(ctx context.Context, obj *model1.DecisionSupportFile) (string, error) {
|
||||
return strconv.FormatUint(uint64(obj.ID), 10), nil
|
||||
}
|
||||
|
||||
func (r *decisionSupportFileResolver) Sections(ctx context.Context, obj *model1.DecisionSupportFile) (map[string]interface{}, error) {
|
||||
return handleSections(ctx, obj)
|
||||
}
|
||||
|
||||
func (r *queryResolver) UserProfile(ctx context.Context) (*model1.User, error) {
|
||||
return handleUserProfile(ctx)
|
||||
}
|
||||
@ -19,6 +27,10 @@ func (r *queryResolver) Workgroups(ctx context.Context, filter *model1.Workgroup
|
||||
return handleWorkgroups(ctx, filter)
|
||||
}
|
||||
|
||||
func (r *queryResolver) DecisionSupportFiles(ctx context.Context, filter *model1.DecisionSupportFileFilter) ([]*model1.DecisionSupportFile, error) {
|
||||
return handleDecisionSupportFiles(ctx, filter)
|
||||
}
|
||||
|
||||
func (r *userResolver) ID(ctx context.Context, obj *model1.User) (string, error) {
|
||||
return strconv.FormatUint(uint64(obj.ID), 10), nil
|
||||
}
|
||||
@ -27,6 +39,11 @@ func (r *workgroupResolver) ID(ctx context.Context, obj *model1.Workgroup) (stri
|
||||
return strconv.FormatUint(uint64(obj.ID), 10), nil
|
||||
}
|
||||
|
||||
// DecisionSupportFile returns generated.DecisionSupportFileResolver implementation.
|
||||
func (r *Resolver) DecisionSupportFile() generated.DecisionSupportFileResolver {
|
||||
return &decisionSupportFileResolver{r}
|
||||
}
|
||||
|
||||
// Query returns generated.QueryResolver implementation.
|
||||
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }
|
||||
|
||||
@ -36,6 +53,7 @@ func (r *Resolver) User() generated.UserResolver { return &userResolver{r} }
|
||||
// Workgroup returns generated.WorkgroupResolver implementation.
|
||||
func (r *Resolver) Workgroup() generated.WorkgroupResolver { return &workgroupResolver{r} }
|
||||
|
||||
type decisionSupportFileResolver struct{ *Resolver }
|
||||
type queryResolver struct{ *Resolver }
|
||||
type userResolver struct{ *Resolver }
|
||||
type workgroupResolver struct{ *Resolver }
|
||||
|
19
internal/model/dsf.go
Normal file
19
internal/model/dsf.go
Normal file
@ -0,0 +1,19 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/jinzhu/gorm/dialects/postgres"
|
||||
)
|
||||
|
||||
type DecisionSupportFile struct {
|
||||
gorm.Model
|
||||
Title string `json:"title"`
|
||||
Sections postgres.Jsonb `json:"sections"`
|
||||
Status string `json:"status"`
|
||||
WorkgroupID uint `json:"-"`
|
||||
Workgroup *Workgroup `json:"workgroup"`
|
||||
VotedAt time.Time `json:"votedAt"`
|
||||
ClosedAt time.Time `json:"closedAt"`
|
||||
}
|
111
internal/model/dsf_repository.go
Normal file
111
internal/model/dsf_repository.go
Normal file
@ -0,0 +1,111 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/jinzhu/gorm/dialects/postgres"
|
||||
errs "github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrMissingWorkgroup = errs.New("missing workgroup")
|
||||
ErrInvalidWorkgroup = errs.New("invalid workgroup")
|
||||
ErrDecisionSupportFileDoesNotExist = errs.New("decision support file does not exist")
|
||||
)
|
||||
|
||||
type DSFRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func (r *DSFRepository) Create(ctx context.Context, changes *DecisionSupportFileChanges) (*DecisionSupportFile, error) {
|
||||
dsf := &DecisionSupportFile{}
|
||||
|
||||
if err := r.updateFromChanges(dsf, changes); err != nil {
|
||||
return nil, errs.WithStack(err)
|
||||
}
|
||||
|
||||
if err := r.db.Save(&dsf).Error; err != nil {
|
||||
return nil, errs.WithStack(err)
|
||||
}
|
||||
|
||||
return dsf, nil
|
||||
}
|
||||
|
||||
func (r *DSFRepository) Update(ctx context.Context, id string, changes *DecisionSupportFileChanges) (*DecisionSupportFile, error) {
|
||||
dsf := &DecisionSupportFile{}
|
||||
|
||||
if err := r.db.Find(dsf, "id = ?", id).Error; err != nil {
|
||||
return nil, errs.WithStack(err)
|
||||
}
|
||||
|
||||
if err := r.updateFromChanges(dsf, changes); err != nil {
|
||||
return nil, errs.WithStack(err)
|
||||
}
|
||||
|
||||
if err := r.db.Save(dsf).Error; err != nil {
|
||||
return nil, errs.WithStack(err)
|
||||
}
|
||||
|
||||
return dsf, nil
|
||||
}
|
||||
|
||||
func (r *DSFRepository) updateFromChanges(dsf *DecisionSupportFile, changes *DecisionSupportFileChanges) error {
|
||||
if changes.WorkgroupID == nil {
|
||||
return errs.WithStack(ErrMissingWorkgroup)
|
||||
}
|
||||
|
||||
wg := &Workgroup{}
|
||||
if err := r.db.Model(wg).First(wg, "id = ?", changes.WorkgroupID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errs.WithStack(ErrInvalidWorkgroup)
|
||||
}
|
||||
|
||||
return errs.WithStack(err)
|
||||
}
|
||||
|
||||
dsf.Workgroup = wg
|
||||
|
||||
if changes.Sections != nil {
|
||||
rawSections, err := json.Marshal(changes.Sections)
|
||||
if err != nil {
|
||||
return errs.WithStack(err)
|
||||
}
|
||||
|
||||
dsf.Sections = postgres.Jsonb{RawMessage: rawSections}
|
||||
}
|
||||
|
||||
if changes.Title != nil {
|
||||
dsf.Title = *changes.Title
|
||||
}
|
||||
|
||||
if changes.Status != nil {
|
||||
dsf.Status = *changes.Status
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *DSFRepository) Search(ctx context.Context, filter *DecisionSupportFileFilter) ([]*DecisionSupportFile, error) {
|
||||
query := r.db.Model(&DecisionSupportFile{}).Preload("Workgroup")
|
||||
|
||||
if filter != nil {
|
||||
if filter.Ids != nil {
|
||||
query = query.Where("id in (?)", filter.Ids)
|
||||
}
|
||||
}
|
||||
|
||||
dsfs := make([]*DecisionSupportFile, 0)
|
||||
|
||||
if err := query.Find(&dsfs).Error; err != nil {
|
||||
return nil, errs.WithStack(err)
|
||||
}
|
||||
|
||||
return dsfs, nil
|
||||
}
|
||||
|
||||
func NewDSFRepository(db *gorm.DB) *DSFRepository {
|
||||
return &DSFRepository{db}
|
||||
}
|
Loading…
Reference in New Issue
Block a user