Création/mise à jour basique d'un DAD #15
@ -1,20 +1,49 @@
|
||||
import React from 'react';
|
||||
import React, { FunctionComponent, useState } from 'react';
|
||||
import { BrowserRouter, Route, Redirect, Switch } from "react-router-dom";
|
||||
import { HomePage } from './HomePage/HomePage';
|
||||
import { ProfilePage } from './ProfilePage/ProfilePage';
|
||||
import { WorkgroupPage } from './WorkgroupPage/WorkgroupPage';
|
||||
import { DecisionSupportFilePage } from './DecisionSupportFilePage/DecisionSupportFilePage';
|
||||
import { DashboardPage } from './DashboardPage/DashboardPage';
|
||||
import { useUserProfile } from '../gql/queries/profile';
|
||||
import { LoggedInContext } from '../hooks/useLoggedIn';
|
||||
import { PrivateRoute } from './PrivateRoute';
|
||||
import { useKonamiCode } from '../hooks/useKonamiCode';
|
||||
import { Modal } from './Modal';
|
||||
|
||||
export class App extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
export interface AppProps {
|
||||
|
||||
}
|
||||
|
||||
export const App: FunctionComponent<AppProps> = () => {
|
||||
const { user } = useUserProfile();
|
||||
|
||||
const [ showBoneyM, setShowBoneyM ] = useState(false);
|
||||
useKonamiCode(() => setShowBoneyM(true));
|
||||
|
||||
return (
|
||||
<LoggedInContext.Provider value={user.id !== ''}>
|
||||
<BrowserRouter>
|
||||
<Switch>
|
||||
<Route path="/" exact component={HomePage} />
|
||||
<Route path="/profile" exact component={ProfilePage} />
|
||||
<Route path="/workgroups/:id" exact component={WorkgroupPage} />
|
||||
<PrivateRoute path="/profile" exact component={ProfilePage} />
|
||||
<PrivateRoute path="/workgroups/:id" exact component={WorkgroupPage} />
|
||||
<PrivateRoute path="/decisions/:id" exact component={DecisionSupportFilePage} />
|
||||
<PrivateRoute path="/dashboard" exact component={DashboardPage} />
|
||||
<Route component={() => <Redirect to="/" />} />
|
||||
</Switch>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
{
|
||||
showBoneyM ?
|
||||
<Modal active={true} showCloseButton={true} onClose={() => setShowBoneyM(false)}>
|
||||
<iframe width={560} height={315}
|
||||
frameBorder={0}
|
||||
allowFullScreen={true}
|
||||
src="https://www.youtube.com/embed/uVzT5QEEQ2c?autoplay=1" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture">
|
||||
</iframe>
|
||||
</Modal> :
|
||||
null
|
||||
}
|
||||
</LoggedInContext.Provider>
|
||||
);
|
||||
}
|
@ -1,26 +1,17 @@
|
||||
import React from 'react';
|
||||
import { WorkgroupsPanel } from './WorkgroupsPanel';
|
||||
import { DecisionSupportFilePanel } from './DecisionSupportFilePanel';
|
||||
|
||||
export function Dashboard() {
|
||||
return (
|
||||
<div className="columns">
|
||||
<div className="column">
|
||||
<div className="column is-6">
|
||||
<DecisionSupportFilePanel />
|
||||
</div>
|
||||
<div className="column is-3">
|
||||
<WorkgroupsPanel />
|
||||
</div>
|
||||
<div className="column">
|
||||
<div className="box">
|
||||
<div className="level">
|
||||
<div className="level-left">
|
||||
<h3 className="is-size-3 subtitle level-item">D.A.Ds</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 className="column">
|
||||
<div className="column is-3">
|
||||
<div className="box">
|
||||
<div className="level">
|
||||
<div className="level-left">
|
15
client/src/components/DashboardPage/DashboardPage.tsx
Normal file
15
client/src/components/DashboardPage/DashboardPage.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { Page } from '../Page';
|
||||
import { Dashboard } from './Dashboard';
|
||||
|
||||
export function DashboardPage() {
|
||||
return (
|
||||
<Page title={'Tableau de bord'}>
|
||||
<div className="container is-fluid">
|
||||
<section className="mt-5">
|
||||
<Dashboard />
|
||||
</section>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { DecisionSupportFile, DecisionSupportFileStatus } from '../../types/decision';
|
||||
import { ItemPanel, TabDefinition, Item } from './ItemPanel';
|
||||
import { useUserProfile } from '../../gql/queries/profile';
|
||||
import { inWorkgroup } from '../../types/workgroup';
|
||||
import { useDecisionSupportFiles } from '../../gql/queries/dsf';
|
||||
|
||||
export function DecisionSupportFilePanel() {
|
||||
const { user } = useUserProfile();
|
||||
const { decisionSupportFiles } = useDecisionSupportFiles();
|
||||
|
||||
const tabs: TabDefinition[] = [
|
||||
{
|
||||
label: 'Mes dossiers en cours',
|
||||
itemFilter: (item: Item) => {
|
||||
const dsf = item as DecisionSupportFile;
|
||||
return (dsf.status === DecisionSupportFileStatus.Draft || dsf.status === DecisionSupportFileStatus.Ready) && inWorkgroup(user, dsf.workgroup);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Brouillons',
|
||||
itemFilter: (item: Item) => (item as DecisionSupportFile).status === DecisionSupportFileStatus.Draft
|
||||
},
|
||||
{
|
||||
label: 'Clos',
|
||||
itemFilter: (item: Item) => (item as DecisionSupportFile).status === DecisionSupportFileStatus.Closed
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
return (
|
||||
<ItemPanel
|
||||
className='is-link'
|
||||
title="Dossiers"
|
||||
newItemUrl="/decisions/new"
|
||||
items={decisionSupportFiles}
|
||||
tabs={tabs}
|
||||
itemIconClassName='fas fa-folder'
|
||||
itemKey={item => item.id}
|
||||
itemLabel={item => item.title}
|
||||
itemUrl={item => `/decisions/${item.id}`}
|
||||
/>
|
||||
);
|
||||
}
|
121
client/src/components/DashboardPage/ItemPanel.tsx
Normal file
121
client/src/components/DashboardPage/ItemPanel.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import React, { FunctionComponent, useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { WithLoader } from "../WithLoader";
|
||||
|
||||
export interface Item {
|
||||
id: string
|
||||
[propName: string]: any;
|
||||
}
|
||||
|
||||
export interface TabDefinition {
|
||||
label: string
|
||||
itemFilter?: (item: Item) => boolean
|
||||
}
|
||||
|
||||
|
||||
export interface ItemPanelProps {
|
||||
className?: string
|
||||
itemIconClassName?: string
|
||||
title?: string
|
||||
newItemUrl: string
|
||||
isLoading?: boolean
|
||||
items: Item[]
|
||||
tabs?: TabDefinition[],
|
||||
itemKey: (item: Item, index: number) => string
|
||||
itemLabel: (item: Item, index: number) => string
|
||||
itemUrl: (item: Item, index: number) => string
|
||||
}
|
||||
|
||||
export const ItemPanel: FunctionComponent<ItemPanelProps> = (props) => {
|
||||
const {
|
||||
title, className, newItemUrl,
|
||||
itemKey, itemLabel,
|
||||
itemIconClassName, itemUrl
|
||||
} = props;
|
||||
|
||||
const [ state, setState ] = useState({ selectedTab: 0, filteredItems: [] });
|
||||
|
||||
const filterItemsForTab = (tab: TabDefinition, items: Item[]) => {
|
||||
const itemFilter = tab && typeof tab.itemFilter === 'function' ? tab.itemFilter : () => true;
|
||||
return items.filter(itemFilter);
|
||||
};
|
||||
|
||||
const selectTab = (tabIndex: number) => {
|
||||
setState(state => {
|
||||
const { tabs, items } = props;
|
||||
const newTab = Array.isArray(tabs) && tabs.length > 0 ? tabs[tabIndex] : null;
|
||||
return {
|
||||
...state,
|
||||
selectedTab: tabIndex,
|
||||
filteredItems: filterItemsForTab(newTab, items)
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setState(state => {
|
||||
const { tabs, items } = props;
|
||||
const newTab = Array.isArray(tabs) && tabs.length > 0 ? tabs[state.selectedTab] : null;
|
||||
return {
|
||||
...state,
|
||||
filteredItems: filterItemsForTab(newTab, items),
|
||||
}
|
||||
});
|
||||
}, [props.items, props.tabs]);
|
||||
|
||||
const itemElements = state.filteredItems.map((item: Item, i: number) => {
|
||||
return (
|
||||
<Link to={itemUrl(item, i)} key={`item-${itemKey(item, i)}`} className="panel-block">
|
||||
<span className="panel-icon">
|
||||
<i className={itemIconClassName} aria-hidden="true"></i>
|
||||
</span>
|
||||
{itemLabel(item, i)}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
const tabs = props.tabs || [];
|
||||
|
||||
return (
|
||||
<nav className={`panel ${className}`}>
|
||||
<div className="level is-mobile panel-heading mb-0">
|
||||
<div className="level-left">
|
||||
<p className="level-item">{title}</p>
|
||||
</div>
|
||||
<div className="level-right">
|
||||
<Link to={newItemUrl} className="button level-item is-outlined is-info is-inverted">
|
||||
<i className="icon fa fa-plus"></i>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel-block">
|
||||
<p className="control has-icons-left">
|
||||
<input className="input" type="text" placeholder="Filtrer..." />
|
||||
<span className="icon is-left">
|
||||
<i className="fas fa-search" aria-hidden="true"></i>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="panel-tabs">
|
||||
{
|
||||
tabs.map((tab, i) => {
|
||||
return (
|
||||
<a key={`workgroup-tab-${i}`}
|
||||
onClick={selectTab.bind(null, i)}
|
||||
className={i === state.selectedTab ? 'is-active' : ''}>
|
||||
{tab.label}
|
||||
</a>
|
||||
)
|
||||
})
|
||||
}
|
||||
</p>
|
||||
{
|
||||
itemElements.length > 0 ?
|
||||
itemElements :
|
||||
<a className="panel-block has-text-centered is-block">
|
||||
<em>Aucun élément pour l'instant.</em>
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
)
|
||||
};
|
45
client/src/components/DashboardPage/WorkgroupsPanel.tsx
Normal file
45
client/src/components/DashboardPage/WorkgroupsPanel.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Workgroup, inWorkgroup } from '../../types/workgroup';
|
||||
import { User } from '../../types/user';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useWorkgroupsQuery, useWorkgroups } from '../../gql/queries/workgroups';
|
||||
import { useUserProfileQuery, useUserProfile } from '../../gql/queries/profile';
|
||||
import { WithLoader } from '../WithLoader';
|
||||
import { ItemPanel, Item } from './ItemPanel';
|
||||
|
||||
export function WorkgroupsPanel() {
|
||||
const { workgroups } = useWorkgroups();
|
||||
const { user } = useUserProfile();
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
label: "Mes groupes en cours",
|
||||
itemFilter: (item: Item) => {
|
||||
const wg = item as Workgroup;
|
||||
return wg.closedAt === null && inWorkgroup(user, wg);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Ouverts",
|
||||
itemFilter: (item: Item) => !(item as Workgroup).closedAt
|
||||
},
|
||||
{
|
||||
label: "Clos",
|
||||
itemFilter: (item: Item) => !!(item as Workgroup).closedAt
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<ItemPanel
|
||||
className='is-info'
|
||||
title="Groupes de travail"
|
||||
newItemUrl="/workgroups/new"
|
||||
items={workgroups}
|
||||
tabs={tabs}
|
||||
itemIconClassName='fas fa-users'
|
||||
itemKey={item => item.id}
|
||||
itemLabel={item => item.name}
|
||||
itemUrl={item => `/workgroups/${item.id}`}
|
||||
/>
|
||||
);
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
import React, { FunctionComponent, useState } from 'react';
|
||||
import { DecisionSupportFile } from '../../types/decision';
|
||||
|
||||
export interface AppendixPanelProps {
|
||||
dsf: DecisionSupportFile,
|
||||
};
|
||||
|
||||
export const AppendixPanel: FunctionComponent<AppendixPanelProps> = ({ dsf }) => {
|
||||
return (
|
||||
<nav className="panel">
|
||||
<p className="panel-heading">
|
||||
Annexes
|
||||
</p>
|
||||
<div className="panel-block">
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
@ -0,0 +1,134 @@
|
||||
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 [ state, setState ] = useState({
|
||||
changed: false,
|
||||
section: {
|
||||
objectives: '',
|
||||
motivations: '',
|
||||
scope: '',
|
||||
nature: '',
|
||||
deadline: undefined,
|
||||
hasDeadline: false,
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
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;
|
||||
updateDSF({ ...dsf, title });
|
||||
};
|
||||
|
||||
const onSectionAttrChange = (attrName: string, evt: ChangeEvent<HTMLInputElement>) => {
|
||||
const target = evt.currentTarget;
|
||||
const value = target.hasOwnProperty('checked') ? target.checked : target.value;
|
||||
setState(state => ({ ...state, changed: true, section: {...state.section, [attrName]: value }}));
|
||||
};
|
||||
|
||||
const onDeadlineChange = (evt: ChangeEvent<HTMLInputElement>) => {
|
||||
const deadline = evt.currentTarget.valueAsDate;
|
||||
setState(state => ({ ...state, changed: true, section: { ...state.section, deadline }}));
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="box">
|
||||
<div className="field">
|
||||
<label className="label">Intitulé du dossier</label>
|
||||
<div className="control">
|
||||
<input className="input" type="text" value={dsf.title} onChange={onTitleChange} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="label">Quelle décision devons nous prendre ?</label>
|
||||
<div className="control">
|
||||
<textarea className="textarea"
|
||||
value={state.section.objectives}
|
||||
onChange={onSectionAttrChange.bind(null, 'objectives')}
|
||||
placeholder="Décrire globalement les tenants et aboutissants de la décision à prendre."
|
||||
rows={10}>
|
||||
</textarea>
|
||||
</div>
|
||||
<p className="help is-info"><i className="fa fa-info-circle"></i> Ne pas essayer de rentrer trop dans les détails ici. Préférer l'utilisation des annexes et y faire référence.</p>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="label">Pourquoi devons nous prendre cette décision ?</label>
|
||||
<div className="control">
|
||||
<textarea className="textarea"
|
||||
value={state.section.motivations}
|
||||
onChange={onSectionAttrChange.bind(null, 'motivations')}
|
||||
placeholder="Décrire pourquoi il est important de prendre cette décision."
|
||||
rows={10}>
|
||||
</textarea>
|
||||
</div>
|
||||
<p className="help is-info"><i className="fa fa-info-circle"></i> Penser à indiquer si des obligations légales pèsent sur cette prise de décision.</p>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="label">Portée de la décision</label>
|
||||
<div className="control">
|
||||
<div className="select">
|
||||
<select
|
||||
onChange={onSectionAttrChange.bind(null, 'scope')}
|
||||
value={state.section.scope}>
|
||||
<option></option>
|
||||
<option value="individual">Individuelle</option>
|
||||
<option value="identified-group">Groupe identifié</option>
|
||||
<option value="collective">Collective</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="label">Nature de la décision</label>
|
||||
<div className="control">
|
||||
<div className="select">
|
||||
<select onChange={onSectionAttrChange.bind(null, 'nature')}
|
||||
value={state.section.nature}>
|
||||
<option></option>
|
||||
<option value="operational">Opérationnelle</option>
|
||||
<option value="tactic">Tactique</option>
|
||||
<option value="strategic">Stratégique</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="columns">
|
||||
<div className="column">
|
||||
<label className="checkbox">
|
||||
<input type="checkbox"
|
||||
onChange={onSectionAttrChange.bind(null, '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={!state.section.hasDeadline}
|
||||
value={state.section.deadline ? asDate(state.section.deadline).toISOString().substr(0, 10) : ''}
|
||||
onChange={onDeadlineChange}
|
||||
type="date" className="input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
@ -0,0 +1,155 @@
|
||||
import React, { FunctionComponent, useState, useEffect } from 'react';
|
||||
import { Page } from '../Page';
|
||||
import { ClarificationSection } from './ClarificationSection';
|
||||
import { MetadataPanel } from './MetadataPanel';
|
||||
import { AppendixPanel } from './AppendixPanel';
|
||||
import { DecisionSupportFile, newDecisionSupportFile, DecisionSupportFileStatus } from '../../types/decision';
|
||||
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 {
|
||||
|
||||
};
|
||||
|
||||
export const DecisionSupportFilePage: FunctionComponent<DecisionSupportFilePageProps> = () => {
|
||||
const { id } = useParams();
|
||||
const history = useHistory();
|
||||
const { decisionSupportFiles } = useDecisionSupportFiles({
|
||||
variables:{
|
||||
filter: {
|
||||
ids: id !== 'new' ? [id] : undefined,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const [ state, setState ] = useState({
|
||||
dsf: newDecisionSupportFile(),
|
||||
saved: true,
|
||||
selectedTabIndex: 0,
|
||||
});
|
||||
|
||||
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 => {
|
||||
return { ...state, saved: false, dsf: { ...state.dsf, ...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">
|
||||
<div className="container is-fluid">
|
||||
<section className="mt-5">
|
||||
<div className="level">
|
||||
<div className="level-left">
|
||||
{
|
||||
isNew ?
|
||||
<div className="level-item">
|
||||
<div>
|
||||
<h2 className="is-size-3 title is-spaced">Nouveau</h2>
|
||||
<h3 className="is-size-5 subtitle">Dossier d'Aide à la Décision</h3>
|
||||
</div>
|
||||
</div> :
|
||||
<div className="level-item">
|
||||
<div>
|
||||
<h2 className="is-size-3 title is-spaced">{state.dsf.title}</h2>
|
||||
<h3 className="is-size-5 subtitle">Dossier d'Aide à la Décision <span className="is-italic">{ isClosed ? '(clos)' : null }</span></h3>
|
||||
</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>
|
||||
</section>
|
||||
<div className="columns mt-3">
|
||||
<div className="column is-9">
|
||||
<div className="tabs is-medium is-toggle">
|
||||
<ul>
|
||||
<li className={`has-background-white ${state.selectedTabIndex === 0 ? 'is-active': ''}`}
|
||||
onClick={selectTab.bind(null, 0)}>
|
||||
<a>
|
||||
<span className="icon is-small"><i className="fas fa-pen" aria-hidden="true"></i></span>
|
||||
<span>Clarifier la proposition</span>
|
||||
</a>
|
||||
</li>
|
||||
<li className={`has-background-white ${state.selectedTabIndex === 1 ? 'is-active': ''}`}
|
||||
onClick={selectTab.bind(null, 1)}>
|
||||
<a>
|
||||
<span className="icon is-small"><i className="fas fa-search" aria-hidden="true"></i></span>
|
||||
<span>Explorer les options</span>
|
||||
</a>
|
||||
</li>
|
||||
<li className={`has-background-white ${state.selectedTabIndex === 2 ? 'is-active': ''}`}
|
||||
onClick={selectTab.bind(null, 2)}>
|
||||
<a>
|
||||
<span className="icon is-small"><i className="fas fa-person-booth" aria-hidden="true"></i></span>
|
||||
<span>Prendre la décision</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{
|
||||
state.selectedTabIndex === 0 ?
|
||||
<ClarificationSection dsf={state.dsf} updateDSF={updateDSF} /> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
<div className="column is-3">
|
||||
<MetadataPanel dsf={state.dsf} updateDSF={updateDSF} />
|
||||
<AppendixPanel dsf={state.dsf} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
};
|
@ -0,0 +1,6 @@
|
||||
import { DecisionSupportFile } from "../../types/decision";
|
||||
|
||||
export interface DecisionSupportFileUpdaterProps {
|
||||
dsf: DecisionSupportFile
|
||||
updateDSF: (dsf: DecisionSupportFile) => void
|
||||
}
|
@ -0,0 +1,86 @@
|
||||
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 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 });
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="panel">
|
||||
<p className="panel-heading">
|
||||
Métadonnées
|
||||
</p>
|
||||
<div className="panel-block">
|
||||
<div style={{width:'100%'}}>
|
||||
<div className="field">
|
||||
<div className="label">Groupe de travail</div>
|
||||
<div className="control is-expanded">
|
||||
<div className="select is-fullwidth">
|
||||
<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>
|
||||
</div>
|
||||
<div className="field">
|
||||
<div className="label">Statut</div>
|
||||
<div className="control is-expanded">
|
||||
<div className="select is-fullwidth">
|
||||
<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>
|
||||
</div>
|
||||
<div className="field">
|
||||
<div className="label">Créé le</div>
|
||||
<div className="control">
|
||||
<p>{asDate(dsf.createdAt).toISOString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<div className="label">Voté le</div>
|
||||
<div className="control">
|
||||
<p>{dsf.votedAt ? dsf.votedAt : '--'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
@ -0,0 +1,17 @@
|
||||
import React, { FunctionComponent, useState } from 'react';
|
||||
import { DecisionSupportFile } from '../../types/decision';
|
||||
|
||||
export interface OptionsSectionProps {
|
||||
dsf: DecisionSupportFile,
|
||||
};
|
||||
|
||||
export const OptionsSection: FunctionComponent<OptionsSectionProps> = ({ dsf }) => {
|
||||
return (
|
||||
<section>
|
||||
<h4 id="options-section" className="is-size-4 title is-spaced"><a href="#options-section">Explorer les options</a></h4>
|
||||
<div className="box">
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
@ -1,33 +1,20 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Page } from '../Page';
|
||||
import { Dashboard } from './Dashboard';
|
||||
import { useUserProfileQuery } from '../../gql/queries/profile';
|
||||
import { WithLoader } from '../WithLoader';
|
||||
import { WelcomeContent } from './WelcomeContent';
|
||||
import { useUserProfile } from '../../gql/queries/profile';
|
||||
import { useHistory } from 'react-router';
|
||||
|
||||
export function HomePage() {
|
||||
const { data, loading } = useUserProfileQuery();
|
||||
const { user } = useUserProfile();
|
||||
const history = useHistory();
|
||||
|
||||
const { userProfile } = (data || {});
|
||||
useEffect(() => {
|
||||
if (user.id !== '') history.push('/dashboard');
|
||||
}, [user.id])
|
||||
|
||||
return (
|
||||
<Page title={userProfile ? 'Tableau de bord' : 'Accueil'}>
|
||||
<div className="container is-fluid">
|
||||
<section className="mt-5">
|
||||
<WithLoader loading={loading}>
|
||||
{
|
||||
userProfile ?
|
||||
<Dashboard /> :
|
||||
<div className="columns">
|
||||
<div className="column is-4 is-offset-4">
|
||||
<div className="box">
|
||||
<p>Veuillez vous authentifier.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</WithLoader>
|
||||
</section>
|
||||
</div>
|
||||
<Page title="Accueil">
|
||||
<WelcomeContent />
|
||||
</Page>
|
||||
);
|
||||
}
|
75
client/src/components/HomePage/WelcomeContent.tsx
Normal file
75
client/src/components/HomePage/WelcomeContent.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import React, { FunctionComponent, Fragment } from "react";
|
||||
|
||||
export interface WelcomeContentProps {
|
||||
|
||||
}
|
||||
|
||||
export const WelcomeContent: FunctionComponent<WelcomeContentProps> = () => {
|
||||
return (
|
||||
<Fragment>
|
||||
<section className="hero is-normal is-light is-bold">
|
||||
<div className="hero-body has-text-centered">
|
||||
<div className="container">
|
||||
<h1 className="title">
|
||||
Bienvenue sur Daddy !
|
||||
</h1>
|
||||
<h2 className="subtitle">
|
||||
L'outil de suivi de la vie d'entreprise démocratique.
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div className="box cta">
|
||||
<p className="has-text-centered">
|
||||
<span className="tag is-info">Attention</span> Le service est actuellement en alpha. L'accès est restreint aux adresses autorisées.
|
||||
</p>
|
||||
</div>
|
||||
<section className="container mt-5">
|
||||
<div className="columns">
|
||||
<div className="column is-4">
|
||||
<div className="card is-shady">
|
||||
<div className="card-image has-text-centered">
|
||||
<i className="fa fa-at mt-5" style={{fontSize: '8rem'}}></i>
|
||||
</div>
|
||||
<div className="card-content">
|
||||
<div className="content">
|
||||
<h4>Une adresse courriel et c'est parti !</h4>
|
||||
<p>Pas de création de compte, pas de mot de passe à retenir. Entrez votre adresse courriel et commencez directement à travailler !</p>
|
||||
{/* <p><a href="#">En savoir plus</a></p> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="column is-4">
|
||||
<div className="card is-shady">
|
||||
<div className="card-image has-text-centered">
|
||||
<i className="fa fa-edit mt-5" style={{fontSize: '8rem'}}></i>
|
||||
</div>
|
||||
<div className="card-content">
|
||||
<div className="content">
|
||||
<h4>Préparer vos dossiers d'aide à la décision</h4>
|
||||
<p>Une décision à prendre ? Un nouveau projet à lancer ? Crééz votre groupe de travail et rédigez un dossier pour faciliter la prise de décision collective !</p>
|
||||
{/* <p><a href="#">En savoir plus</a></p> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="column is-4">
|
||||
<div className="card is-shady">
|
||||
<div className="card-image has-text-centered">
|
||||
<i className="fa fa-users mt-5" style={{fontSize: '8rem'}}></i>
|
||||
</div>
|
||||
<div className="card-content">
|
||||
<div className="content">
|
||||
<h4>Travaillez collaborativement</h4>
|
||||
<p>Éditez à plusieurs vos dossiers d'aide à la décision, suivi l'avancée des débats et retrouvez simplement les décisions prises dans l'entreprise.</p>
|
||||
{/* <p><a href="#">En savoir plus</a></p> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
@ -1,98 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Workgroup } from '../../types/workgroup';
|
||||
import { User } from '../../types/user';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useWorkgroupsQuery } from '../../gql/queries/workgroups';
|
||||
import { useUserProfileQuery } from '../../gql/queries/profile';
|
||||
import { WithLoader } from '../WithLoader';
|
||||
|
||||
export function WorkgroupsPanel() {
|
||||
const workgroupsQuery = useWorkgroupsQuery();
|
||||
const userProfileQuery = useUserProfileQuery();
|
||||
const [ state, setState ] = useState({ selectedTab: 0 });
|
||||
|
||||
const isLoading = userProfileQuery.loading || workgroupsQuery.loading;
|
||||
const { userProfile } = (userProfileQuery.data || {});
|
||||
const { workgroups } = (workgroupsQuery.data || {});
|
||||
|
||||
const filterTabs = [
|
||||
{
|
||||
label: "Mes groupes en cours",
|
||||
filter: workgroups => workgroups.filter((wg: Workgroup) => {
|
||||
return wg.closedAt === null && wg.members.some((u: User) => u.id === (userProfile ? userProfile.id : ''));
|
||||
})
|
||||
},
|
||||
{
|
||||
label: "Ouverts",
|
||||
filter: workgroups => workgroups.filter((wg: Workgroup) => !wg.closedAt)
|
||||
},
|
||||
{
|
||||
label: "Clos",
|
||||
filter: workgroups => workgroups.filter((wg: Workgroup) => !!wg.closedAt)
|
||||
}
|
||||
];
|
||||
|
||||
const selectTab = (tabIndex: number) => {
|
||||
setState(state => ({ ...state, selectedTab: tabIndex }));
|
||||
};
|
||||
|
||||
let workgroupsItems = [];
|
||||
|
||||
workgroupsItems = filterTabs[state.selectedTab].filter(workgroups || []).map((wg: Workgroup) => {
|
||||
return (
|
||||
<Link to={`/workgroups/${wg.id}`} key={`wg-${wg.id}`} className="panel-block">
|
||||
<span className="panel-icon">
|
||||
<i className="fas fa-users" aria-hidden="true"></i>
|
||||
</span>
|
||||
{wg.name}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<nav className="panel is-info">
|
||||
<div className="level panel-heading mb-0">
|
||||
<div className="level-left">
|
||||
<p className="level-item">
|
||||
Groupes de travail
|
||||
</p>
|
||||
</div>
|
||||
<div className="level-right">
|
||||
<Link to="/workgroups/new" className="button level-item is-outlined is-info is-inverted">
|
||||
<i className="icon fa fa-plus"></i>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className="panel-block">
|
||||
<p className="control has-icons-left">
|
||||
<input className="input" type="text" placeholder="Filtrer..." />
|
||||
<span className="icon is-left">
|
||||
<i className="fas fa-search" aria-hidden="true"></i>
|
||||
</span>
|
||||
</p>
|
||||
</div> */}
|
||||
<WithLoader loading={isLoading}>
|
||||
<p className="panel-tabs">
|
||||
{
|
||||
filterTabs.map((tab, i) => {
|
||||
return (
|
||||
<a key={`workgroup-tab-${i}`}
|
||||
onClick={selectTab.bind(null, i)}
|
||||
className={i === state.selectedTab ? 'is-active' : ''}>
|
||||
{tab.label}
|
||||
</a>
|
||||
)
|
||||
})
|
||||
}
|
||||
</p>
|
||||
{
|
||||
workgroupsItems.length > 0 ?
|
||||
workgroupsItems :
|
||||
<a className="panel-block has-text-centered is-block">
|
||||
<em>Aucun groupe dans cet catégorie pour l'instant.</em>
|
||||
</a>
|
||||
}
|
||||
</WithLoader>
|
||||
</nav>
|
||||
)
|
||||
}
|
@ -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 = () => {
|
||||
@ -15,7 +14,7 @@ export function Navbar() {
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="navbar" role="navigation" aria-label="main navigation">
|
||||
<nav className="navbar is-fixed-top" role="navigation" aria-label="main navigation">
|
||||
<div className="container is-fluid">
|
||||
<div className="navbar-brand">
|
||||
<Link className="navbar-item" to="/">
|
||||
@ -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>
|
||||
|
18
client/src/components/PrivateRoute.tsx
Normal file
18
client/src/components/PrivateRoute.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import React, { FunctionComponent, Component, ReactType } from "react"
|
||||
import { Route, Redirect, RouteProps } from "react-router"
|
||||
import { useLoggedIn } from "../hooks/useLoggedIn";
|
||||
|
||||
export interface PrivateRouteProps extends RouteProps {
|
||||
}
|
||||
|
||||
export const PrivateRoute: FunctionComponent<PrivateRouteProps> = ({component: Component, ...rest}) => {
|
||||
const loggedIn = useLoggedIn();
|
||||
return (
|
||||
<Route
|
||||
{...rest}
|
||||
render={(props) => loggedIn === true
|
||||
? <Component {...props} />
|
||||
: <Redirect to={{pathname: '/', state: {from: props.location}}} />}
|
||||
/>
|
||||
)
|
||||
}
|
@ -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,6 +1,6 @@
|
||||
import { gql, useQuery, useMutation } from '@apollo/client';
|
||||
|
||||
const MUTATION_UPDATE_USER_PROFILE = gql`
|
||||
export const MUTATION_UPDATE_USER_PROFILE = gql`
|
||||
mutation updateUserProfile($changes: ProfileChanges!) {
|
||||
updateProfile(changes: $changes) {
|
||||
id,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { gql, useQuery, useMutation } from '@apollo/client';
|
||||
import { QUERY_WORKGROUP } from '../queries/workgroups';
|
||||
|
||||
const MUTATION_UPDATE_WORKGROUP = gql`
|
||||
export const MUTATION_UPDATE_WORKGROUP = gql`
|
||||
mutation updateWorkgroup($workgroupId: ID!, $changes: WorkgroupChanges!) {
|
||||
updateWorkgroup(workgroupId: $workgroupId, changes: $changes) {
|
||||
id,
|
||||
@ -19,7 +20,7 @@ export function useUpdateWorkgroupMutation() {
|
||||
return useMutation(MUTATION_UPDATE_WORKGROUP);
|
||||
}
|
||||
|
||||
const MUTATION_CREATE_WORKGROUP = gql`
|
||||
export const MUTATION_CREATE_WORKGROUP = gql`
|
||||
mutation createWorkgroup($changes: WorkgroupChanges!) {
|
||||
createWorkgroup(changes: $changes) {
|
||||
id,
|
||||
@ -35,10 +36,12 @@ mutation createWorkgroup($changes: WorkgroupChanges!) {
|
||||
}`;
|
||||
|
||||
export function useCreateWorkgroupMutation() {
|
||||
return useMutation(MUTATION_CREATE_WORKGROUP);
|
||||
return useMutation(MUTATION_CREATE_WORKGROUP, {
|
||||
refetchQueries: [{query: QUERY_WORKGROUP}],
|
||||
});
|
||||
}
|
||||
|
||||
const MUTATION_JOIN_WORKGROUP = gql`
|
||||
export const MUTATION_JOIN_WORKGROUP = gql`
|
||||
mutation joinWorkgroup($workgroupId: ID!) {
|
||||
joinWorkgroup(workgroupId: $workgroupId) {
|
||||
id,
|
||||
|
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 };
|
||||
}
|
11
client/src/gql/queries/helper.ts
Normal file
11
client/src/gql/queries/helper.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { useQuery, DocumentNode } from "@apollo/client";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function useGraphQLData<T>(q: DocumentNode, key: string, defaultValue: T, options = {}) {
|
||||
const query = useQuery(q, options);
|
||||
const [ data, setData ] = useState<T>(defaultValue);
|
||||
useEffect(() => {
|
||||
setData(query.data ? query.data[key] as T : defaultValue);
|
||||
}, [query.loading, query.data]);
|
||||
return { data, loading: query.loading, error: query.error };
|
||||
}
|
@ -1,6 +1,9 @@
|
||||
import { gql, useQuery } from '@apollo/client';
|
||||
import { User } from '../../types/user';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useGraphQLData } from './helper';
|
||||
|
||||
const QUERY_USER_PROFILE = gql`
|
||||
export const QUERY_USER_PROFILE = gql`
|
||||
query userProfile {
|
||||
userProfile {
|
||||
id,
|
||||
@ -13,4 +16,11 @@ query userProfile {
|
||||
|
||||
export function useUserProfileQuery() {
|
||||
return useQuery(QUERY_USER_PROFILE);
|
||||
}
|
||||
|
||||
export function useUserProfile() {
|
||||
const { data, loading, error } = useGraphQLData<User>(
|
||||
QUERY_USER_PROFILE, 'userProfile', {id: '', email: ''}
|
||||
);
|
||||
return { user: data, loading, error };
|
||||
}
|
@ -1,6 +1,8 @@
|
||||
import { gql, useQuery } from '@apollo/client';
|
||||
import { Workgroup } from '../../types/workgroup';
|
||||
import { useGraphQLData } from './helper';
|
||||
|
||||
const QUERY_WORKGROUP = gql`
|
||||
export const QUERY_WORKGROUP = gql`
|
||||
query workgroups($filter: WorkgroupsFilter) {
|
||||
workgroups(filter: $filter) {
|
||||
id,
|
||||
@ -18,4 +20,12 @@ const QUERY_WORKGROUP = gql`
|
||||
|
||||
export function useWorkgroupsQuery(options = {}) {
|
||||
return useQuery(QUERY_WORKGROUP, options);
|
||||
}
|
||||
|
||||
export function useWorkgroups(options = {}) {
|
||||
const { data, loading, error } = useGraphQLData<Workgroup[]>(
|
||||
QUERY_WORKGROUP, 'workgroups', [],
|
||||
options
|
||||
);
|
||||
return { workgroups: 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;
|
||||
}
|
20
client/src/hooks/useKonamiCode.tsx
Normal file
20
client/src/hooks/useKonamiCode.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function useKonamiCode(cb: Function) {
|
||||
const KONAMI_CODE = [38, 38, 40, 40, 37, 39, 37, 39, 66, 65];
|
||||
let cursor = 0;
|
||||
|
||||
useEffect(()=> {
|
||||
const onKeyDown = (e) => {
|
||||
cursor = (e.keyCode == KONAMI_CODE[cursor]) ? cursor + 1 : 0;
|
||||
if (cursor == KONAMI_CODE.length) {
|
||||
cb();
|
||||
cursor = 0;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, []);
|
||||
}
|
7
client/src/hooks/useLoggedIn.tsx
Normal file
7
client/src/hooks/useLoggedIn.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import React, { useState, useContext } from "react";
|
||||
|
||||
export const LoggedInContext = React.createContext(false);
|
||||
|
||||
export const useLoggedIn = () => {
|
||||
return useContext(LoggedInContext);
|
||||
};
|
@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="has-navbar-fixed-top">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
|
@ -1,6 +1,8 @@
|
||||
html, body {
|
||||
height: 100%;
|
||||
background-color: #f7f7f7;
|
||||
background-color: #ffffff;
|
||||
// Generated with https://www.svgbackgrounds.com/
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='351' height='292.5' viewBox='0 0 1080 900'%3E%3Cg fill-opacity='0.04'%3E%3Cpolygon fill='%23444' points='90 150 0 300 180 300'/%3E%3Cpolygon points='90 150 180 0 0 0'/%3E%3Cpolygon fill='%23AAA' points='270 150 360 0 180 0'/%3E%3Cpolygon fill='%23DDD' points='450 150 360 300 540 300'/%3E%3Cpolygon fill='%23999' points='450 150 540 0 360 0'/%3E%3Cpolygon points='630 150 540 300 720 300'/%3E%3Cpolygon fill='%23DDD' points='630 150 720 0 540 0'/%3E%3Cpolygon fill='%23444' points='810 150 720 300 900 300'/%3E%3Cpolygon fill='%23FFF' points='810 150 900 0 720 0'/%3E%3Cpolygon fill='%23DDD' points='990 150 900 300 1080 300'/%3E%3Cpolygon fill='%23444' points='990 150 1080 0 900 0'/%3E%3Cpolygon fill='%23DDD' points='90 450 0 600 180 600'/%3E%3Cpolygon points='90 450 180 300 0 300'/%3E%3Cpolygon fill='%23666' points='270 450 180 600 360 600'/%3E%3Cpolygon fill='%23AAA' points='270 450 360 300 180 300'/%3E%3Cpolygon fill='%23DDD' points='450 450 360 600 540 600'/%3E%3Cpolygon fill='%23999' points='450 450 540 300 360 300'/%3E%3Cpolygon fill='%23999' points='630 450 540 600 720 600'/%3E%3Cpolygon fill='%23FFF' points='630 450 720 300 540 300'/%3E%3Cpolygon points='810 450 720 600 900 600'/%3E%3Cpolygon fill='%23DDD' points='810 450 900 300 720 300'/%3E%3Cpolygon fill='%23AAA' points='990 450 900 600 1080 600'/%3E%3Cpolygon fill='%23444' points='990 450 1080 300 900 300'/%3E%3Cpolygon fill='%23222' points='90 750 0 900 180 900'/%3E%3Cpolygon points='270 750 180 900 360 900'/%3E%3Cpolygon fill='%23DDD' points='270 750 360 600 180 600'/%3E%3Cpolygon points='450 750 540 600 360 600'/%3E%3Cpolygon points='630 750 540 900 720 900'/%3E%3Cpolygon fill='%23444' points='630 750 720 600 540 600'/%3E%3Cpolygon fill='%23AAA' points='810 750 720 900 900 900'/%3E%3Cpolygon fill='%23666' points='810 750 900 600 720 600'/%3E%3Cpolygon fill='%23999' points='990 750 900 900 1080 900'/%3E%3Cpolygon fill='%23999' points='180 0 90 150 270 150'/%3E%3Cpolygon fill='%23444' points='360 0 270 150 450 150'/%3E%3Cpolygon fill='%23FFF' points='540 0 450 150 630 150'/%3E%3Cpolygon points='900 0 810 150 990 150'/%3E%3Cpolygon fill='%23222' points='0 300 -90 450 90 450'/%3E%3Cpolygon fill='%23FFF' points='0 300 90 150 -90 150'/%3E%3Cpolygon fill='%23FFF' points='180 300 90 450 270 450'/%3E%3Cpolygon fill='%23666' points='180 300 270 150 90 150'/%3E%3Cpolygon fill='%23222' points='360 300 270 450 450 450'/%3E%3Cpolygon fill='%23FFF' points='360 300 450 150 270 150'/%3E%3Cpolygon fill='%23444' points='540 300 450 450 630 450'/%3E%3Cpolygon fill='%23222' points='540 300 630 150 450 150'/%3E%3Cpolygon fill='%23AAA' points='720 300 630 450 810 450'/%3E%3Cpolygon fill='%23666' points='720 300 810 150 630 150'/%3E%3Cpolygon fill='%23FFF' points='900 300 810 450 990 450'/%3E%3Cpolygon fill='%23999' points='900 300 990 150 810 150'/%3E%3Cpolygon points='0 600 -90 750 90 750'/%3E%3Cpolygon fill='%23666' points='0 600 90 450 -90 450'/%3E%3Cpolygon fill='%23AAA' points='180 600 90 750 270 750'/%3E%3Cpolygon fill='%23444' points='180 600 270 450 90 450'/%3E%3Cpolygon fill='%23444' points='360 600 270 750 450 750'/%3E%3Cpolygon fill='%23999' points='360 600 450 450 270 450'/%3E%3Cpolygon fill='%23666' points='540 600 630 450 450 450'/%3E%3Cpolygon fill='%23222' points='720 600 630 750 810 750'/%3E%3Cpolygon fill='%23FFF' points='900 600 810 750 990 750'/%3E%3Cpolygon fill='%23222' points='900 600 990 450 810 450'/%3E%3Cpolygon fill='%23DDD' points='0 900 90 750 -90 750'/%3E%3Cpolygon fill='%23444' points='180 900 270 750 90 750'/%3E%3Cpolygon fill='%23FFF' points='360 900 450 750 270 750'/%3E%3Cpolygon fill='%23AAA' points='540 900 630 750 450 750'/%3E%3Cpolygon fill='%23FFF' points='720 900 810 750 630 750'/%3E%3Cpolygon fill='%23222' points='900 900 990 750 810 750'/%3E%3Cpolygon fill='%23222' points='1080 300 990 450 1170 450'/%3E%3Cpolygon fill='%23FFF' points='1080 300 1170 150 990 150'/%3E%3Cpolygon points='1080 600 990 750 1170 750'/%3E%3Cpolygon fill='%23666' points='1080 600 1170 450 990 450'/%3E%3Cpolygon fill='%23DDD' points='1080 900 1170 750 990 750'/%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.is-fullheight {
|
||||
@ -18,4 +20,8 @@ html, body {
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background-color: #ffffff;
|
||||
}
|
35
client/src/types/decision.tsx
Normal file
35
client/src/types/decision.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { Workgroup } from "./workgroup";
|
||||
|
||||
export enum DecisionSupportFileStatus {
|
||||
Draft = "draft",
|
||||
Ready = "ready",
|
||||
Voted = "voted",
|
||||
Closed = "closed",
|
||||
}
|
||||
|
||||
export interface DecisionSupportFileSection {
|
||||
name: string
|
||||
}
|
||||
|
||||
// aka Dossier d'aide à la décision
|
||||
export interface DecisionSupportFile {
|
||||
id: string
|
||||
title: string
|
||||
sections: {[name: string]: any}
|
||||
status: DecisionSupportFileStatus
|
||||
workgroup?: Workgroup,
|
||||
createdAt: Date
|
||||
votedAt?: Date
|
||||
closedAt?: Date
|
||||
}
|
||||
|
||||
export function newDecisionSupportFile(): DecisionSupportFile {
|
||||
return {
|
||||
id: '',
|
||||
title: '',
|
||||
sections: {},
|
||||
status: DecisionSupportFileStatus.Draft,
|
||||
workgroup: null,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
}
|
@ -1,9 +1,18 @@
|
||||
import { User } from "./user";
|
||||
|
||||
export interface Workgroup {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: Date
|
||||
closedAt: Date
|
||||
members: [User]
|
||||
members: User[]
|
||||
}
|
||||
|
||||
export function inWorkgroup(u: User, wg: Workgroup): boolean {
|
||||
for (let m, i = 0; (m = wg.members[i]); i++) {
|
||||
if(m.id === u.id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
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;
|
||||
}
|
@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/wader/gormstore"
|
||||
|
||||
"forge.cadoles.com/Cadoles/daddy/internal/auth"
|
||||
"forge.cadoles.com/Cadoles/daddy/internal/orm"
|
||||
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
@ -96,5 +97,7 @@ func getServiceContainer(ctx context.Context, conf *config.Config) (*service.Con
|
||||
oidc.WithScopes("email", "openid"),
|
||||
))
|
||||
|
||||
ctn.Provide(auth.ServiceName, auth.ServiceProvider(conf.Auth.Rules))
|
||||
|
||||
return ctn, nil
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
1
go.mod
1
go.mod
@ -5,6 +5,7 @@ go 1.14
|
||||
require (
|
||||
forge.cadoles.com/wpetit/goweb-oidc v0.0.0-20200619080035-4bbf7b016032
|
||||
github.com/99designs/gqlgen v0.11.3
|
||||
github.com/antonmedv/expr v1.8.8
|
||||
github.com/caarlos0/env/v6 v6.2.2
|
||||
github.com/cortesi/modd v0.0.0-20200630120222-8983974e5450 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1
|
||||
|
17
go.sum
17
go.sum
@ -20,6 +20,7 @@ github.com/99designs/gqlgen v0.11.3 h1:oFSxl1DFS9X///uHV3y6CEfpcXWrDUxVblR4Xib2b
|
||||
github.com/99designs/gqlgen v0.11.3/go.mod h1:RgX5GRRdDWNkh4pBrdzNpNPFVsdoUFY2+adM6nb1N+4=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
|
||||
github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0=
|
||||
github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0=
|
||||
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
|
||||
@ -44,6 +45,8 @@ github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d h1:UQZhZ2O0vMHr2c
|
||||
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
|
||||
github.com/antonmedv/expr v1.8.8 h1:uVwIkIBNO2yn4vY2u2DQUqXTmv9jEEMCEcHa19G5weY=
|
||||
github.com/antonmedv/expr v1.8.8/go.mod h1:5qsM3oLGDND7sDmQGDXHkYfkjYMUX14qsgqmHhwGEk8=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
||||
github.com/bmatcuk/doublestar v1.3.0/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE=
|
||||
github.com/bmatcuk/doublestar v1.3.1 h1:rT8rxDPsavp9G+4ZULzqhhUSaI/OPsTZNG88Z3i0xvY=
|
||||
@ -71,6 +74,7 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
|
||||
github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E=
|
||||
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
|
||||
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
|
||||
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@ -90,6 +94,8 @@ github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
|
||||
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
|
||||
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
|
||||
github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM=
|
||||
github.com/go-chi/chi v3.3.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||
github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||
github.com/go-chi/chi v4.1.0+incompatible h1:ETj3cggsVIY2Xao5ExCu6YhEh5MD6JTfcBzS37R260w=
|
||||
@ -230,6 +236,8 @@ github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=
|
||||
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
|
||||
github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s=
|
||||
github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007 h1:reVOUXwnhsYv/8UqjvhrMOu5CNT9UapHFLbQ2JcXsmg=
|
||||
github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
@ -250,6 +258,8 @@ github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGe
|
||||
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
|
||||
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||
github.com/mattn/go-runewidth v0.0.8/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-sqlite3 v1.14.0 h1:mLyGNKR8+Vv9CAU7PphKa2hkEqxxhn8i32J6FPj1/QA=
|
||||
github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
|
||||
github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
@ -267,11 +277,14 @@ github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 h1:J9b7z+QKAmPf4YLrFg6oQUotqHQeUNWwkvo7jZp1GLU=
|
||||
github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/rivo/tview v0.0.0-20200219210816-cd38d7432498/go.mod h1:6lkG1x+13OShEf0EaOCaTQYyB7d5nSbb181KtjlS+84=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rjeczalik/notify v0.0.0-20181126183243-629144ba06a1 h1:FLWDC+iIP9BWgYKvWKKtOUZux35LIQNAuIzp/63RQJU=
|
||||
github.com/rjeczalik/notify v0.0.0-20181126183243-629144ba06a1/go.mod h1:aErll2f0sUX9PXZnVNyeiObbmTlk5jnMoCa4QEjJeqM=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
@ -283,6 +296,7 @@ github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OK
|
||||
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
||||
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sanity-io/litter v1.2.0/go.mod h1:JF6pZUFgu2Q0sBZ+HSV35P8TVPI1TTzEwyu9FXAw2W4=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
|
||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||
@ -299,6 +313,7 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
@ -410,6 +425,7 @@ golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@ -418,6 +434,7 @@ golang.org/x/sys v0.0.0-20191210023423-ac6580df4449 h1:gSbV7h1NRL2G1xTg/owz62CST
|
||||
golang.org/x/sys v0.0.0-20191210023423-ac6580df4449/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
71
internal/auth/auth.go
Normal file
71
internal/auth/auth.go
Normal file
@ -0,0 +1,71 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"forge.cadoles.com/Cadoles/daddy/internal/model"
|
||||
"github.com/antonmedv/expr"
|
||||
"github.com/antonmedv/expr/vm"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnexpectedRuleResult = errors.New("unexpected rule result")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
rules []*vm.Program
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
func (s *Service) LoadRules(rawRules ...string) error {
|
||||
rules := make([]*vm.Program, 0, len(rawRules))
|
||||
|
||||
for _, rr := range rawRules {
|
||||
r, err := expr.Compile(rr)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
rules = append(rules, r)
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
s.rules = rules
|
||||
s.mutex.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Authorize(user *model.User) (bool, error) {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
env := map[string]interface{}{
|
||||
"user": user,
|
||||
}
|
||||
|
||||
for _, r := range s.rules {
|
||||
result, err := expr.Run(r, env)
|
||||
if err != nil {
|
||||
return false, errors.WithStack(err)
|
||||
}
|
||||
|
||||
authorized, ok := result.(bool)
|
||||
if !ok {
|
||||
return false, errors.WithStack(ErrUnexpectedRuleResult)
|
||||
}
|
||||
|
||||
if !authorized {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{
|
||||
rules: make([]*vm.Program, 0),
|
||||
}
|
||||
}
|
20
internal/auth/provider.go
Normal file
20
internal/auth/provider.go
Normal file
@ -0,0 +1,20 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/service"
|
||||
)
|
||||
|
||||
func ServiceProvider(rules []string) service.Provider {
|
||||
srv := NewService()
|
||||
|
||||
err := srv.LoadRules(rules...)
|
||||
|
||||
return func(ctn *service.Container) (interface{}, error) {
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return srv, nil
|
||||
}
|
||||
}
|
33
internal/auth/service.go
Normal file
33
internal/auth/service.go
Normal file
@ -0,0 +1,33 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/service"
|
||||
)
|
||||
|
||||
const ServiceName service.Name = "auth"
|
||||
|
||||
// From retrieves the auth service in the given container.
|
||||
func From(container *service.Container) (*Service, error) {
|
||||
service, err := container.Service(ServiceName)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "error while retrieving '%s' service", ServiceName)
|
||||
}
|
||||
|
||||
srv, ok := service.(*Service)
|
||||
if !ok {
|
||||
return nil, errors.Errorf("retrieved service is not a valid '%s' service", ServiceName)
|
||||
}
|
||||
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// Must retrieves the auth service in the given container or panic otherwise.
|
||||
func Must(container *service.Container) *Service {
|
||||
srv, err := From(container)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return srv
|
||||
}
|
@ -18,6 +18,7 @@ type Config struct {
|
||||
HTTP HTTPConfig `yaml:"http"`
|
||||
OIDC OIDCConfig `yaml:"oidc"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
}
|
||||
|
||||
// NewFromFile retrieves the configuration from the given file
|
||||
@ -69,6 +70,10 @@ type DatabaseConfig struct {
|
||||
DSN string `yaml:"dsn" env:"DATABASE_DSN"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
Rules []string `yaml:"rules" env:"AUTH_RULES"`
|
||||
}
|
||||
|
||||
func NewDumpDefault() *Config {
|
||||
config := NewDefault()
|
||||
return config
|
||||
@ -102,6 +107,11 @@ func NewDefault() *Config {
|
||||
Database: DatabaseConfig{
|
||||
DSN: "host=localhost database=daddy",
|
||||
},
|
||||
Auth: AuthConfig{
|
||||
Rules: []string{
|
||||
"user.Email endsWith 'cadoles.com'",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
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}
|
||||
}
|
@ -1,8 +1,11 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"forge.cadoles.com/Cadoles/daddy/internal/auth"
|
||||
|
||||
"forge.cadoles.com/Cadoles/daddy/internal/model"
|
||||
"forge.cadoles.com/Cadoles/daddy/internal/orm"
|
||||
|
||||
@ -31,6 +34,7 @@ func handleLoginCallback(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
ctn := container.Must(ctx)
|
||||
conf := config.Must(ctn)
|
||||
auth := auth.Must(ctn)
|
||||
|
||||
idToken, err := oidc.IDToken(w, r)
|
||||
if err != nil {
|
||||
@ -65,10 +69,26 @@ func handleLoginCallback(w http.ResponseWriter, r *http.Request) {
|
||||
db := orm.Must(ctn).DB()
|
||||
repo := model.NewUserRepository(db)
|
||||
|
||||
if _, err := repo.CreateOrConnectUser(ctx, claims.Email); err != nil {
|
||||
user, err := repo.CreateOrConnectUser(ctx, claims.Email)
|
||||
if err != nil {
|
||||
panic(errors.Wrap(err, "could not upsert user"))
|
||||
}
|
||||
|
||||
authorized, err := auth.Authorize(user)
|
||||
if err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
|
||||
if !authorized {
|
||||
message := fmt.Sprintf(
|
||||
"You are not authorized to access this application. Disconnect by navigating to %s.",
|
||||
"http://"+r.Host+"/logout",
|
||||
)
|
||||
http.Error(w, message, http.StatusForbidden)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err := session.SaveUserEmail(w, r, claims.Email); err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user