Création/mise à jour basique d'un DAD #15

Manually merged
wpetit merged 7 commits from feature/dad into develop 2020-08-26 14:52:54 +02:00
19 changed files with 536 additions and 97 deletions
Showing only changes of commit 39d266f701 - Show all commits

View File

@ -3,11 +3,11 @@ import { DecisionSupportFile, DecisionSupportFileStatus } from '../../types/deci
import { ItemPanel, TabDefinition, Item } from './ItemPanel'; import { ItemPanel, TabDefinition, Item } from './ItemPanel';
import { useUserProfile } from '../../gql/queries/profile'; import { useUserProfile } from '../../gql/queries/profile';
import { inWorkgroup } from '../../types/workgroup'; import { inWorkgroup } from '../../types/workgroup';
import { useDecisions } from '../../gql/queries/decisions'; import { useDecisionSupportFiles } from '../../gql/queries/dsf';
export function DecisionSupportFilePanel() { export function DecisionSupportFilePanel() {
const { user } = useUserProfile(); const { user } = useUserProfile();
const { decisions } = useDecisions(); const { decisionSupportFiles } = useDecisionSupportFiles();
const tabs: TabDefinition[] = [ const tabs: TabDefinition[] = [
{ {
@ -31,9 +31,9 @@ export function DecisionSupportFilePanel() {
return ( return (
<ItemPanel <ItemPanel
className='is-link' className='is-link'
title="D.A.D." title="Dossiers"
newItemUrl="/decisions/new" newItemUrl="/decisions/new"
items={decisions} items={decisionSupportFiles}
tabs={tabs} tabs={tabs}
itemIconClassName='fas fa-folder' itemIconClassName='fas fa-folder'
itemKey={item => item.id} itemKey={item => item.id}

View File

@ -1,23 +1,35 @@
import React, { FunctionComponent, useState, ChangeEvent, useEffect } from 'react'; import React, { FunctionComponent, useState, ChangeEvent, useEffect } from 'react';
import { DecisionSupportFileUpdaterProps } from './DecisionSupportFileUpdaterProps'; import { DecisionSupportFileUpdaterProps } from './DecisionSupportFileUpdaterProps';
import { useDebounce } from '../../hooks/useDebounce';
import { asDate } from '../../util/date';
export interface ClarificationSectionProps extends DecisionSupportFileUpdaterProps {}; export interface ClarificationSectionProps extends DecisionSupportFileUpdaterProps {};
const ClarificationSectionName = 'clarification'; const ClarificationSectionName = 'clarification';
export const ClarificationSection: FunctionComponent<ClarificationSectionProps> = ({ dsf, updateDSF }) => { export const ClarificationSection: FunctionComponent<ClarificationSectionProps> = ({ dsf, updateDSF }) => {
const [ section, setSection ] = useState({ const [ state, setState ] = useState({
changed: false,
section: {
objectives: '', objectives: '',
motivations: '', motivations: '',
scope: '', scope: '',
nature: '', nature: '',
deadline: undefined, deadline: undefined,
hasDeadline: false, hasDeadline: false,
}
}); });
useEffect(() => { useEffect(() => {
updateDSF({ ...dsf, sections: { ...dsf.sections, [ClarificationSectionName]: { ...section }} }) if (!state.changed) return;
}, [section]); 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 onTitleChange = (evt: ChangeEvent<HTMLInputElement>) => {
const title = (evt.currentTarget).value; const title = (evt.currentTarget).value;
@ -27,12 +39,12 @@ export const ClarificationSection: FunctionComponent<ClarificationSectionProps>
const onSectionAttrChange = (attrName: string, evt: ChangeEvent<HTMLInputElement>) => { const onSectionAttrChange = (attrName: string, evt: ChangeEvent<HTMLInputElement>) => {
const target = evt.currentTarget; const target = evt.currentTarget;
const value = target.hasOwnProperty('checked') ? target.checked : target.value; 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 onDeadlineChange = (evt: ChangeEvent<HTMLInputElement>) => {
const deadline = evt.currentTarget.valueAsDate; const deadline = evt.currentTarget.valueAsDate;
setSection(section => ({ ...section, deadline })); setState(state => ({ ...state, changed: true, section: { ...state.section, deadline }}));
}; };
return ( return (
@ -48,7 +60,7 @@ export const ClarificationSection: FunctionComponent<ClarificationSectionProps>
<label className="label">Quelle décision devons nous prendre ?</label> <label className="label">Quelle décision devons nous prendre ?</label>
<div className="control"> <div className="control">
<textarea className="textarea" <textarea className="textarea"
value={section.objectives} value={state.section.objectives}
onChange={onSectionAttrChange.bind(null, 'objectives')} onChange={onSectionAttrChange.bind(null, 'objectives')}
placeholder="Décrire globalement les tenants et aboutissants de la décision à prendre." placeholder="Décrire globalement les tenants et aboutissants de la décision à prendre."
rows={10}> rows={10}>
@ -60,7 +72,7 @@ export const ClarificationSection: FunctionComponent<ClarificationSectionProps>
<label className="label">Pourquoi devons nous prendre cette décision ?</label> <label className="label">Pourquoi devons nous prendre cette décision ?</label>
<div className="control"> <div className="control">
<textarea className="textarea" <textarea className="textarea"
value={section.motivations} value={state.section.motivations}
onChange={onSectionAttrChange.bind(null, 'motivations')} onChange={onSectionAttrChange.bind(null, 'motivations')}
placeholder="Décrire pourquoi il est important de prendre cette décision." placeholder="Décrire pourquoi il est important de prendre cette décision."
rows={10}> rows={10}>
@ -74,7 +86,7 @@ export const ClarificationSection: FunctionComponent<ClarificationSectionProps>
<div className="select"> <div className="select">
<select <select
onChange={onSectionAttrChange.bind(null, 'scope')} onChange={onSectionAttrChange.bind(null, 'scope')}
value={section.scope}> value={state.section.scope}>
<option></option> <option></option>
<option value="individual">Individuelle</option> <option value="individual">Individuelle</option>
<option value="identified-group">Groupe identifié</option> <option value="identified-group">Groupe identifié</option>
@ -88,7 +100,7 @@ export const ClarificationSection: FunctionComponent<ClarificationSectionProps>
<div className="control"> <div className="control">
<div className="select"> <div className="select">
<select onChange={onSectionAttrChange.bind(null, 'nature')} <select onChange={onSectionAttrChange.bind(null, 'nature')}
value={section.nature}> value={state.section.nature}>
<option></option> <option></option>
<option value="operational">Opérationnelle</option> <option value="operational">Opérationnelle</option>
<option value="tactic">Tactique</option> <option value="tactic">Tactique</option>
@ -103,13 +115,13 @@ export const ClarificationSection: FunctionComponent<ClarificationSectionProps>
<label className="checkbox"> <label className="checkbox">
<input type="checkbox" <input type="checkbox"
onChange={onSectionAttrChange.bind(null, 'hasDeadline')} 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> <span className="ml-1">Existe t'il une échéance particulière pour cette décision ?</span>
</label> </label>
<div className="field"> <div className="field">
<div className="control"> <div className="control">
<input disabled={!section.hasDeadline} <input disabled={!state.section.hasDeadline}
value={section.deadline ? section.deadline.toISOString().substr(0, 10) : ''} value={state.section.deadline ? asDate(state.section.deadline).toISOString().substr(0, 10) : ''}
onChange={onDeadlineChange} onChange={onDeadlineChange}
type="date" className="input" /> type="date" className="input" />
</div> </div>

View File

@ -1,12 +1,13 @@
import React, { FunctionComponent, useState } from 'react'; import React, { FunctionComponent, useState, useEffect } from 'react';
import { Page } from '../Page'; import { Page } from '../Page';
import { ClarificationSection } from './ClarificationSection'; import { ClarificationSection } from './ClarificationSection';
import { OptionsSection } from './OptionsSection';
import { MetadataPanel } from './MetadataPanel'; import { MetadataPanel } from './MetadataPanel';
import { AppendixPanel } from './AppendixPanel'; import { AppendixPanel } from './AppendixPanel';
import { DecisionSupportFile, newDecisionSupportFile, DecisionSupportFileStatus } from '../../types/decision'; import { DecisionSupportFile, newDecisionSupportFile, DecisionSupportFileStatus } from '../../types/decision';
import { useParams } from 'react-router'; import { useParams, useHistory } from 'react-router';
import { useDecisions } from '../../gql/queries/decisions'; import { useDecisionSupportFiles } from '../../gql/queries/dsf';
import { useCreateDecisionSupportFileMutation, useUpdateDecisionSupportFileMutation } from '../../gql/mutations/dsf';
import { useDebounce } from '../../hooks/useDebounce';
export interface DecisionSupportFilePageProps { export interface DecisionSupportFilePageProps {
@ -14,31 +15,69 @@ export interface DecisionSupportFilePageProps {
export const DecisionSupportFilePage: FunctionComponent<DecisionSupportFilePageProps> = () => { export const DecisionSupportFilePage: FunctionComponent<DecisionSupportFilePageProps> = () => {
const { id } = useParams(); const { id } = useParams();
const { decisions } = useDecisions({ const history = useHistory();
const { decisionSupportFiles } = useDecisionSupportFiles({
variables:{ variables:{
filter: { filter: {
ids: [id], ids: id !== 'new' ? [id] : undefined,
} }
} }
}); });
const [ state, setState ] = useState({ const [ state, setState ] = useState({
dsf: decisions.length > 0 ? decisions[0] : newDecisionSupportFile(), dsf: newDecisionSupportFile(),
selectedTabIndex: 0 saved: true,
selectedTabIndex: 0,
}); });
const isNew = state.dsf.id === ''; useEffect(() => {
const isClosed = state.dsf.status === DecisionSupportFileStatus.Closed; const dsf = decisionSupportFiles.length > 0 && decisionSupportFiles[0].id === id ? decisionSupportFiles[0] : {};
setState(state => ({ ...state, dsf: { ...state.dsf, ...dsf }}))
}, [ decisionSupportFiles ]);
const selectTab = (tabIndex: number) => { const selectTab = (tabIndex: number) => {
setState(state => ({ ...state, selectedTabIndex: tabIndex })); setState(state => ({ ...state, selectedTabIndex: tabIndex }));
}; };
const updateDSF = (dsf: DecisionSupportFile) => { 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 ( return (
<Page title="Dossier d'Aide à la Décision"> <Page title="Dossier d'Aide à la Décision">
@ -62,6 +101,14 @@ export const DecisionSupportFilePage: FunctionComponent<DecisionSupportFilePageP
</div> </div>
} }
</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> </div>
</section> </section>
<div className="columns mt-3"> <div className="columns mt-3">
@ -98,7 +145,7 @@ export const DecisionSupportFilePage: FunctionComponent<DecisionSupportFilePageP
} }
</div> </div>
<div className="column is-3"> <div className="column is-3">
<MetadataPanel dsf={state.dsf} /> <MetadataPanel dsf={state.dsf} updateDSF={updateDSF} />
<AppendixPanel dsf={state.dsf} /> <AppendixPanel dsf={state.dsf} />
</div> </div>
</div> </div>

View File

@ -1,11 +1,35 @@
import React, { FunctionComponent, useState } from 'react'; import React, { FunctionComponent, useState, useEffect, ChangeEvent } from 'react';
import { DecisionSupportFile } from '../../types/decision'; 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 { export interface MetadataPanelProps extends DecisionSupportFileUpdaterProps {};
dsf: DecisionSupportFile,
}; 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 ( return (
<nav className="panel"> <nav className="panel">
<p className="panel-heading"> <p className="panel-heading">
@ -17,8 +41,15 @@ export const MetadataPanel: FunctionComponent<MetadataPanelProps> = ({ dsf }) =>
<div className="label">Groupe de travail</div> <div className="label">Groupe de travail</div>
<div className="control is-expanded"> <div className="control is-expanded">
<div className="select is-fullwidth"> <div className="select is-fullwidth">
<select> <select onChange={onWorkgroupChanged} value={dsf.workgroup ? dsf.workgroup.id : ''}>
<option></option> <option></option>
{
userOpenedWorkgroups.map(wg => {
return (
<option key={`wg-${wg.id}`} value={wg.id}>{wg.name}</option>
);
})
}
</select> </select>
</div> </div>
</div> </div>
@ -27,10 +58,11 @@ export const MetadataPanel: FunctionComponent<MetadataPanelProps> = ({ dsf }) =>
<div className="label">Statut</div> <div className="label">Statut</div>
<div className="control is-expanded"> <div className="control is-expanded">
<div className="select is-fullwidth"> <div className="select is-fullwidth">
<select> <select onChange={onStatusChanged} value={dsf.status}>
<option>En préparation</option> <option value="draft">Brouillon</option>
<option>Prêt à voter</option> <option value="ready">Prêt à voter</option>
<option>Voté</option> <option value="voted">Voté</option>
<option value="closed">Clôs</option>
</select> </select>
</div> </div>
</div> </div>
@ -38,13 +70,13 @@ export const MetadataPanel: FunctionComponent<MetadataPanelProps> = ({ dsf }) =>
<div className="field"> <div className="field">
<div className="label">Créé le</div> <div className="label">Créé le</div>
<div className="control"> <div className="control">
<p>--</p> <p>{asDate(dsf.createdAt).toISOString()}</p>
</div> </div>
</div> </div>
<div className="field"> <div className="field">
<div className="label">Dernière modification</div> <div className="label">Voté le</div>
<div className="control"> <div className="control">
<p>--</p> <p>{dsf.votedAt ? dsf.votedAt : '--'}</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -3,11 +3,10 @@ import logo from '../resources/logo.svg';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Config } from '../config'; import { Config } from '../config';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useUserProfileQuery } from '../gql/queries/profile'; import { useLoggedIn } from '../hooks/useLoggedIn';
import { WithLoader } from './WithLoader';
export function Navbar() { export function Navbar() {
const userProfileQuery = useUserProfileQuery(); const loggedIn = useLoggedIn();
const [ isActive, setActive ] = useState(false); const [ isActive, setActive ] = useState(false);
const toggleMenu = () => { const toggleMenu = () => {
@ -35,30 +34,30 @@ export function Navbar() {
<div className={`navbar-menu ${isActive ? 'is-active' : ''}`}> <div className={`navbar-menu ${isActive ? 'is-active' : ''}`}>
<div className="navbar-end"> <div className="navbar-end">
<div className="navbar-item"> <div className="navbar-item">
<WithLoader loading={userProfileQuery.loading}>
<div className="buttons"> <div className="buttons">
{ {
userProfileQuery.data && userProfileQuery.data.userProfile ? loggedIn ?
<Fragment> <Fragment>
<Link to="/profile" className="button"> <Link to="/profile" className="button">
<span className="icon"> <span className="icon">
<i className="fas fa-user"></i> <i className="fas fa-user"></i>
</span> </span>
<span>Mon profil</span>
</Link> </Link>
<a className="button" href={Config.logoutURL}> <a className="button is-warning" href={Config.logoutURL}>
<span className="icon"> <span className="icon">
<i className="fas fa-sign-out-alt"></i> <i className="fas fa-sign-out-alt"></i>
</span> </span>
</a> </a>
</Fragment> : </Fragment> :
<a className="button" href={Config.loginURL}> <a className="button is-primary" href={Config.loginURL}>
<span className="icon"> <span className="icon">
<i className="fas fa-sign-in-alt"></i> <i className="fas fa-sign-in-alt"></i>
</span> </span>
<span>S'identifier</span>
</a> </a>
} }
</div> </div>
</WithLoader>
</div> </div>
</div> </div>
</div> </div>

View File

@ -3,6 +3,7 @@ import { Config } from '../config';
import { WebSocketLink } from "@apollo/client/link/ws"; import { WebSocketLink } from "@apollo/client/link/ws";
import { RetryLink } from "@apollo/client/link/retry"; import { RetryLink } from "@apollo/client/link/retry";
import { SubscriptionClient } from "subscriptions-transport-ws"; import { SubscriptionClient } from "subscriptions-transport-ws";
import { User } from '../types/user';
const subscriptionClient = new SubscriptionClient(Config.subscriptionEndpoint, { const subscriptionClient = new SubscriptionClient(Config.subscriptionEndpoint, {
reconnect: true, reconnect: true,
@ -14,7 +15,42 @@ const link = new RetryLink({attempts: {max: 2}}).split(
new HttpLink({ uri: Config.graphQLEndpoint, credentials: 'include' }) 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>({ export const client = new ApolloClient<any>({
cache: new InMemoryCache(), cache: cache,
link: link, 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;
}
}

View 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,
}],
});
}

View File

@ -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 };
}

View 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 };
}

View 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
View File

@ -0,0 +1,4 @@
export function asDate(d: string|Date): Date {
if (typeof d === 'string') return new Date(d);
return d;
}

View File

@ -80,6 +80,7 @@ func applyMigration(ctx context.Context, ctn *service.Container) error {
var initialModels = []interface{}{ var initialModels = []interface{}{
&model.User{}, &model.User{},
&model.Workgroup{}, &model.Workgroup{},
&model.DecisionSupportFile{},
} }
func m000initialSchema() orm.Migration { func m000initialSchema() orm.Migration {
@ -95,8 +96,8 @@ func m000initialSchema() orm.Migration {
return nil return nil
}, },
func(ctx context.Context, tx *gorm.DB) error { func(ctx context.Context, tx *gorm.DB) error {
for _, m := range initialModels { for i := len(initialModels) - 1; i >= 0; i-- {
if err := tx.DropTableIfExists(m).Error; err != nil { if err := tx.DropTableIfExists(initialModels[i]).Error; err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }
} }

View 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, &sections); err != nil {
return nil, errs.WithStack(err)
}
return sections, nil
}

View File

@ -6,8 +6,21 @@ input WorkgroupChanges {
name: String name: String
} }
input DecisionSupportFileChanges {
title: String
sections: Map
status: String
workgroupId: ID
votedAt: Time
closedAt: Time
}
type Mutation { type Mutation {
createDecisionSupportFile(changes: DecisionSupportFileChanges): DecisionSupportFile!
updateDecisionSupportFile(id: ID!, changes: DecisionSupportFileChanges): DecisionSupportFile!
updateProfile(changes: ProfileChanges!): User! updateProfile(changes: ProfileChanges!): User!
joinWorkgroup(workgroupId: ID!): Workgroup! joinWorkgroup(workgroupId: ID!): Workgroup!
leaveWorkgroup(workgroupId: ID!): Workgroup! leaveWorkgroup(workgroupId: ID!): Workgroup!
createWorkgroup(changes: WorkgroupChanges!): Workgroup! createWorkgroup(changes: WorkgroupChanges!): Workgroup!

View File

@ -10,6 +10,14 @@ import (
"forge.cadoles.com/Cadoles/daddy/internal/model" "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) { func (r *mutationResolver) UpdateProfile(ctx context.Context, changes model.ProfileChanges) (*model.User, error) {
return handleUpdateUserProfile(ctx, changes) return handleUpdateUserProfile(ctx, changes)
} }

View File

@ -1,4 +1,5 @@
scalar Time scalar Time
scalar Map
type User { type User {
id: ID! id: ID!
@ -21,7 +22,24 @@ input WorkgroupsFilter {
ids: [ID] 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 { type Query {
userProfile: User userProfile: User
workgroups(filter: WorkgroupsFilter): [Workgroup]! workgroups(filter: WorkgroupsFilter): [Workgroup]!
decisionSupportFiles(filter: DecisionSupportFileFilter): [DecisionSupportFile]!
} }

View File

@ -11,6 +11,14 @@ import (
model1 "forge.cadoles.com/Cadoles/daddy/internal/model" 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) { func (r *queryResolver) UserProfile(ctx context.Context) (*model1.User, error) {
return handleUserProfile(ctx) return handleUserProfile(ctx)
} }
@ -19,6 +27,10 @@ func (r *queryResolver) Workgroups(ctx context.Context, filter *model1.Workgroup
return handleWorkgroups(ctx, filter) 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) { func (r *userResolver) ID(ctx context.Context, obj *model1.User) (string, error) {
return strconv.FormatUint(uint64(obj.ID), 10), nil 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 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. // Query returns generated.QueryResolver implementation.
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} } 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. // Workgroup returns generated.WorkgroupResolver implementation.
func (r *Resolver) Workgroup() generated.WorkgroupResolver { return &workgroupResolver{r} } func (r *Resolver) Workgroup() generated.WorkgroupResolver { return &workgroupResolver{r} }
type decisionSupportFileResolver struct{ *Resolver }
type queryResolver struct{ *Resolver } type queryResolver struct{ *Resolver }
type userResolver struct{ *Resolver } type userResolver struct{ *Resolver }
type workgroupResolver struct{ *Resolver } type workgroupResolver struct{ *Resolver }

19
internal/model/dsf.go Normal file
View 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"`
}

View 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}
}