Gestion des groupes de travail #14

Manually merged
wpetit merged 7 commits from feature/workgroups into develop 2020-07-23 08:28:40 +02:00
50 changed files with 1182 additions and 661 deletions

179
client/package-lock.json generated
View File

@ -4,6 +4,43 @@
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
"@apollo/client": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@apollo/client/-/client-3.0.2.tgz",
"integrity": "sha512-4ighan5Anlj4tK/tdUHs4Mi1njqXZ7AxRCVolz/H702DjPphAJfm+FRkIadPTmwz+OLO+d+tX+6V1VBshf02rg==",
"requires": {
"@types/zen-observable": "^0.8.0",
"@wry/context": "^0.5.2",
"@wry/equality": "^0.1.9",
"fast-json-stable-stringify": "^2.0.0",
"graphql-tag": "^2.10.4",
"hoist-non-react-statics": "^3.3.2",
"optimism": "^0.12.1",
"prop-types": "^15.7.2",
"symbol-observable": "^1.2.0",
"ts-invariant": "^0.4.4",
"tslib": "^1.10.0",
"zen-observable": "^0.8.14"
},
"dependencies": {
"@wry/context": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@wry/context/-/context-0.5.2.tgz",
"integrity": "sha512-B/JLuRZ/vbEKHRUiGj6xiMojST1kHhu4WcreLfNN7q9DqQFrb97cWgf/kiYsPSUCAMVN0HzfFc8XjJdzgZzfjw==",
"requires": {
"tslib": "^1.9.3"
}
},
"optimism": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/optimism/-/optimism-0.12.1.tgz",
"integrity": "sha512-t8I7HM1dw0SECitBYAqFOVHoBAHEQBTeKjIL9y9ImHzAVkdyPK4ifTgM4VJRDtTUY4r/u5Eqxs4XcGPHaoPkeQ==",
"requires": {
"@wry/context": "^0.5.2"
}
}
}
},
"@babel/code-frame": { "@babel/code-frame": {
"version": "7.10.1", "version": "7.10.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.1.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.1.tgz",
@ -1230,7 +1267,8 @@
"@types/node": { "@types/node": {
"version": "13.13.12", "version": "13.13.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-13.13.12.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-13.13.12.tgz",
"integrity": "sha512-zWz/8NEPxoXNT9YyF2osqyA9WjssZukYpgI4UYZpOjcyqwIUqWGkcCionaEb9Ki+FULyPyvNFpg/329Kd2/pbw==" "integrity": "sha512-zWz/8NEPxoXNT9YyF2osqyA9WjssZukYpgI4UYZpOjcyqwIUqWGkcCionaEb9Ki+FULyPyvNFpg/329Kd2/pbw==",
"dev": true
}, },
"@types/prop-types": { "@types/prop-types": {
"version": "15.7.3", "version": "15.7.3",
@ -1551,15 +1589,6 @@
"@xtuc/long": "4.2.2" "@xtuc/long": "4.2.2"
} }
}, },
"@wry/context": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/@wry/context/-/context-0.4.4.tgz",
"integrity": "sha512-LrKVLove/zw6h2Md/KZyWxIkFM6AoyKp71OqpH9Hiip1csjPVoD3tPxlbQUNxEnHENks3UGgNpSBCAfq9KWuag==",
"requires": {
"@types/node": ">=6",
"tslib": "^1.9.3"
}
},
"@wry/equality": { "@wry/equality": {
"version": "0.1.11", "version": "0.1.11",
"resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.1.11.tgz", "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.1.11.tgz",
@ -1744,93 +1773,6 @@
} }
} }
}, },
"apollo-cache": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/apollo-cache/-/apollo-cache-1.3.5.tgz",
"integrity": "sha512-1XoDy8kJnyWY/i/+gLTEbYLnoiVtS8y7ikBr/IfmML4Qb+CM7dEEbIUOjnY716WqmZ/UpXIxTfJsY7rMcqiCXA==",
"requires": {
"apollo-utilities": "^1.3.4",
"tslib": "^1.10.0"
}
},
"apollo-cache-inmemory": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/apollo-cache-inmemory/-/apollo-cache-inmemory-1.6.6.tgz",
"integrity": "sha512-L8pToTW/+Xru2FFAhkZ1OA9q4V4nuvfoPecBM34DecAugUZEBhI2Hmpgnzq2hTKZ60LAMrlqiASm0aqAY6F8/A==",
"requires": {
"apollo-cache": "^1.3.5",
"apollo-utilities": "^1.3.4",
"optimism": "^0.10.0",
"ts-invariant": "^0.4.0",
"tslib": "^1.10.0"
}
},
"apollo-client": {
"version": "2.6.10",
"resolved": "https://registry.npmjs.org/apollo-client/-/apollo-client-2.6.10.tgz",
"integrity": "sha512-jiPlMTN6/5CjZpJOkGeUV0mb4zxx33uXWdj/xQCfAMkuNAC3HN7CvYDyMHHEzmcQ5GV12LszWoQ/VlxET24CtA==",
"requires": {
"@types/zen-observable": "^0.8.0",
"apollo-cache": "1.3.5",
"apollo-link": "^1.0.0",
"apollo-utilities": "1.3.4",
"symbol-observable": "^1.0.2",
"ts-invariant": "^0.4.0",
"tslib": "^1.10.0",
"zen-observable": "^0.8.0"
}
},
"apollo-link": {
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/apollo-link/-/apollo-link-1.2.14.tgz",
"integrity": "sha512-p67CMEFP7kOG1JZ0ZkYZwRDa369w5PIjtMjvrQd/HnIV8FRsHRqLqK+oAZQnFa1DDdZtOtHTi+aMIW6EatC2jg==",
"requires": {
"apollo-utilities": "^1.3.0",
"ts-invariant": "^0.4.0",
"tslib": "^1.9.3",
"zen-observable-ts": "^0.8.21"
}
},
"apollo-link-http": {
"version": "1.5.17",
"resolved": "https://registry.npmjs.org/apollo-link-http/-/apollo-link-http-1.5.17.tgz",
"integrity": "sha512-uWcqAotbwDEU/9+Dm9e1/clO7hTB2kQ/94JYcGouBVLjoKmTeJTUPQKcJGpPwUjZcSqgYicbFqQSoJIW0yrFvg==",
"requires": {
"apollo-link": "^1.2.14",
"apollo-link-http-common": "^0.2.16",
"tslib": "^1.9.3"
}
},
"apollo-link-http-common": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/apollo-link-http-common/-/apollo-link-http-common-0.2.16.tgz",
"integrity": "sha512-2tIhOIrnaF4UbQHf7kjeQA/EmSorB7+HyJIIrUjJOKBgnXwuexi8aMecRlqTIDWcyVXCeqLhUnztMa6bOH/jTg==",
"requires": {
"apollo-link": "^1.2.14",
"ts-invariant": "^0.4.0",
"tslib": "^1.9.3"
}
},
"apollo-link-ws": {
"version": "1.0.20",
"resolved": "https://registry.npmjs.org/apollo-link-ws/-/apollo-link-ws-1.0.20.tgz",
"integrity": "sha512-mjSFPlQxmoLArpHBeUb2Xj+2HDYeTaJqFGOqQ+I8NVJxgL9lJe84PDWcPah/yMLv3rB7QgBDSuZ0xoRFBPlySw==",
"requires": {
"apollo-link": "^1.2.14",
"tslib": "^1.9.3"
}
},
"apollo-utilities": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/apollo-utilities/-/apollo-utilities-1.3.4.tgz",
"integrity": "sha512-pk2hiWrCXMAy2fRPwEyhvka+mqwzeP60Jr1tRYi5xru+3ko94HI9o6lK0CT33/w4RDlxWchmdhDCrvdr+pHCig==",
"requires": {
"@wry/equality": "^0.1.2",
"fast-json-stable-stringify": "^2.0.0",
"ts-invariant": "^0.4.0",
"tslib": "^1.10.0"
}
},
"aproba": { "aproba": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
@ -3235,14 +3177,9 @@
"dev": true "dev": true
}, },
"bulma": { "bulma": {
"version": "0.7.5", "version": "0.9.0",
"resolved": "https://registry.npmjs.org/bulma/-/bulma-0.7.5.tgz", "resolved": "https://registry.npmjs.org/bulma/-/bulma-0.9.0.tgz",
"integrity": "sha512-cX98TIn0I6sKba/DhW0FBjtaDpxTelU166pf7ICXpCCuplHWyu6C9LYZmL5PEsnePIeJaiorsTEzzNk3Tsm1hw==" "integrity": "sha512-rV75CJkubNUroAt0qCRkjznZLoaXq/ctfMXsMvKSL84UetbSyx5REl96e8GoQ04G4Tkw0XF3STECffTOQrbzOQ=="
},
"bulma-switch": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/bulma-switch/-/bulma-switch-2.0.0.tgz",
"integrity": "sha512-myD38zeUfjmdduq+pXabhJEe3x2hQP48l/OI+Y0fO3HdDynZUY/VJygucvEAJKRjr4HxD5DnEm4yx+oDOBXpAA=="
}, },
"bytes": { "bytes": {
"version": "3.0.0", "version": "3.0.0",
@ -5615,11 +5552,6 @@
"resolved": "https://registry.npmjs.org/graphql/-/graphql-15.3.0.tgz", "resolved": "https://registry.npmjs.org/graphql/-/graphql-15.3.0.tgz",
"integrity": "sha512-GTCJtzJmkFLWRfFJuoo9RWWa/FfamUHgiFosxi/X1Ani4AVWbeyBenZTNX6dM+7WSbbFfTo/25eh0LLkwHMw2w==" "integrity": "sha512-GTCJtzJmkFLWRfFJuoo9RWWa/FfamUHgiFosxi/X1Ani4AVWbeyBenZTNX6dM+7WSbbFfTo/25eh0LLkwHMw2w=="
}, },
"graphql-request": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-2.0.0.tgz",
"integrity": "sha512-Ww3Ax+G3l2d+mPT8w7HC9LfrKjutnCKtnDq7ZZp2ghVk5IQDjwAk3/arRF1ix17Ky15rm0hrSKVKxRhIVlSuoQ=="
},
"graphql-tag": { "graphql-tag": {
"version": "2.10.4", "version": "2.10.4",
"resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.10.4.tgz", "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.10.4.tgz",
@ -6507,11 +6439,6 @@
"verror": "1.10.0" "verror": "1.10.0"
} }
}, },
"jwt-decode": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-2.2.0.tgz",
"integrity": "sha1-fYa9VmefWM5qhHBKZX3TkruoGnk="
},
"killable": { "killable": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz",
@ -7466,14 +7393,6 @@
"is-wsl": "^1.1.0" "is-wsl": "^1.1.0"
} }
}, },
"optimism": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/optimism/-/optimism-0.10.3.tgz",
"integrity": "sha512-9A5pqGoQk49H6Vhjb9kPgAeeECfUDF6aIICbMDL23kDLStBn1MWk3YvcZ4xWF9CsSf6XEgvRLkXy4xof/56vVw==",
"requires": {
"@wry/context": "^0.4.0"
}
},
"original": { "original": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/original/-/original-1.0.2.tgz", "resolved": "https://registry.npmjs.org/original/-/original-1.0.2.tgz",
@ -8048,11 +7967,6 @@
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
"dev": true "dev": true
}, },
"qs": {
"version": "6.9.4",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz",
"integrity": "sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ=="
},
"querystring": { "querystring": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
@ -11006,15 +10920,6 @@
"version": "0.8.15", "version": "0.8.15",
"resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz", "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz",
"integrity": "sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==" "integrity": "sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ=="
},
"zen-observable-ts": {
"version": "0.8.21",
"resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-0.8.21.tgz",
"integrity": "sha512-Yj3yXweRc8LdRMrCC8nIc4kkjWecPAUVh0TI0OUrWXx6aX790vLcDlWca6I4vsyCGH3LpWxq0dJRcMOFoVqmeg==",
"requires": {
"tslib": "^1.9.3",
"zen-observable": "^0.8.0"
}
} }
} }
} }

View File

@ -51,20 +51,10 @@
"webpack-dev-server": "^3.11.0" "webpack-dev-server": "^3.11.0"
}, },
"dependencies": { "dependencies": {
"@apollo/client": "^3.0.2",
"@types/qs": "^6.9.3", "@types/qs": "^6.9.3",
"apollo-cache-inmemory": "^1.6.6", "bulma": "^0.9.0",
"apollo-client": "^2.6.10",
"apollo-link": "^1.2.14",
"apollo-link-http": "^1.5.17",
"apollo-link-ws": "^1.0.20",
"apollo-utilities": "^1.3.4",
"bulma": "^0.7.2",
"bulma-switch": "^2.0.0",
"graphql": "^15.3.0", "graphql": "^15.3.0",
"graphql-request": "^2.0.0",
"graphql-tag": "^2.10.4",
"jwt-decode": "^2.2.0",
"qs": "^6.9.4",
"react": "^16.12.0", "react": "^16.12.0",
"react-dom": "^16.12.0", "react-dom": "^16.12.0",
"react-redux": "^7.1.3", "react-redux": "^7.1.3",

View File

@ -1,22 +1,20 @@
import React from 'react'; import React from 'react';
import { BrowserRouter, Route, Redirect, Switch } from "react-router-dom"; import { BrowserRouter, Route, Redirect, Switch } from "react-router-dom";
import { HomePage } from './HomePage/HomePage'; import { HomePage } from './HomePage/HomePage';
import { store } from '../store/store';
import { Provider } from 'react-redux';
import { ProfilePage } from './ProfilePage/ProfilePage'; import { ProfilePage } from './ProfilePage/ProfilePage';
import { WorkgroupPage } from './WorkgroupPage/WorkgroupPage';
export class App extends React.Component { export class App extends React.Component {
render() { render() {
return ( return (
<Provider store={store}> <BrowserRouter>
<BrowserRouter> <Switch>
<Switch> <Route path="/" exact component={HomePage} />
<Route path="/" exact component={HomePage} /> <Route path="/profile" exact component={ProfilePage} />
<Route path="/profile" exact component={ProfilePage} /> <Route path="/workgroups/:id" exact component={WorkgroupPage} />
<Route component={() => <Redirect to="/" />} /> <Route component={() => <Redirect to="/" />} />
</Switch> </Switch>
</BrowserRouter> </BrowserRouter>
</Provider>
); );
} }
} }

View File

@ -0,0 +1,38 @@
import React from 'react';
import { WorkgroupsPanel } from './WorkgroupsPanel';
export function Dashboard() {
return (
<div className="columns">
<div className="column">
<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="box">
<div className="level">
<div className="level-left">
<h3 className="is-size-3 subtitle level-item">Assemblées</h3>
</div>
<div className="level-right">
<button disabled className="button is-primary level-item"><i className="fa fa-plus"></i></button>
</div>
</div>
<pre>TODO</pre>
</div>
</div>
</div>
);
}

View File

@ -1,26 +1,31 @@
import React, { useEffect } from 'react'; import React from 'react';
import { Page } from '../Page'; import { Page } from '../Page';
import { useSelector, useDispatch } from 'react-redux'; import { Dashboard } from './Dashboard';
import { RootState } from '../../store/reducers/root'; import { useUserProfileQuery } from '../../gql/queries/profile';
import { WithLoader } from '../WithLoader';
export function HomePage() { export function HomePage() {
const currentUser = useSelector((state: RootState) => state.auth.currentUser); const { data, loading } = useUserProfileQuery();
const { userProfile } = (data || {});
return ( return (
<Page title="Accueil"> <Page title={userProfile ? 'Tableau de bord' : 'Accueil'}>
<div className="container is-fluid"> <div className="container is-fluid">
<section className="section"> <section className="mt-5">
<div className="columns"> <WithLoader loading={loading}>
<div className="column is-4 is-offset-4"> {
<div className="box"> userProfile ?
{ <Dashboard /> :
currentUser && currentUser.email ? <div className="columns">
<p>Bonjour <span className="has-text-weight-bold">{currentUser.name ? currentUser.name : currentUser.email}</span> !</p> : <div className="column is-4 is-offset-4">
<p>Veuillez vous authentifier.</p> <div className="box">
} <p>Veuillez vous authentifier.</p>
</div>
</div> </div>
</div> </div>
</div> }
</WithLoader>
</section> </section>
</div> </div>
</Page> </Page>

View File

@ -0,0 +1,98 @@
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>
)
}

View File

@ -1,14 +0,0 @@
import React from 'react';
export class Loader extends React.Component {
render() {
return (
<div className="loader-container">
<div className="lds-ripple">
<div></div>
<div></div>
</div>
</div>
)
}
}

View File

@ -1,13 +1,13 @@
import React, { Fragment, useState } from 'react'; import React, { Fragment, useState } from 'react';
import logo from '../resources/logo.svg'; import logo from '../resources/logo.svg';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { RootState } from '../store/reducers/root';
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 { WithLoader } from './WithLoader';
export function Navbar() { export function Navbar() {
const isAuthenticated = useSelector<RootState>(state => state.auth.isAuthenticated); const userProfileQuery = useUserProfileQuery();
const [ isActive, setActive ] = useState(false); const [ isActive, setActive ] = useState(false);
const toggleMenu = () => { const toggleMenu = () => {
@ -35,28 +35,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">
<div className="buttons"> <WithLoader loading={userProfileQuery.loading}>
{ <div className="buttons">
isAuthenticated ? {
<Fragment> userProfileQuery.data && userProfileQuery.data.userProfile ?
<Link to="/profile" className="button"> <Fragment>
<Link to="/profile" className="button">
<span className="icon">
<i className="fas fa-user"></i>
</span>
</Link>
<a className="button" href={Config.logoutURL}>
<span className="icon">
<i className="fas fa-sign-out-alt"></i>
</span>
</a>
</Fragment> :
<a className="button" href={Config.loginURL}>
<span className="icon"> <span className="icon">
<i className="fas fa-user"></i> <i className="fas fa-sign-in-alt"></i>
</span>
</Link>
<a className="button" href={Config.logoutURL}>
<span className="icon">
<i className="fas fa-sign-out-alt"></i>
</span> </span>
</a> </a>
</Fragment> : }
<a className="button" href={Config.loginURL}> </div>
<span className="icon"> </WithLoader>
<i className="fas fa-sign-in-alt"></i>
</span>
</a>
}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,19 +1,21 @@
import React, { useEffect } from 'react'; import React from 'react';
import { Page } from '../Page'; import { Page } from '../Page';
import { useSelector, useDispatch } from 'react-redux';
import { RootState } from '../../store/reducers/root';
import { UserForm } from '../UserForm'; import { UserForm } from '../UserForm';
import { Loader } from '../Loader';
import { User } from '../../types/user'; import { User } from '../../types/user';
import { updateProfile } from '../../store/actions/profile'; import { useUserProfileQuery } from '../../gql/queries/profile';
import { useUpdateUserProfileMutation } from '../../gql/mutations/profile';
import { WithLoader } from '../WithLoader';
export function ProfilePage() { export function ProfilePage() {
const currentUser = useSelector((state: RootState) => state.auth.currentUser); const userProfileQuery = useUserProfileQuery();
const dispatch = useDispatch(); const [ updateProfile, updateUserProfileMutation ] = useUpdateUserProfileMutation();
const isLoading = updateUserProfileMutation.loading || userProfileQuery.loading;
const { userProfile } = (userProfileQuery.data || {});
const onUserChange = (user: User) => { const onUserChange = (user: User) => {
if (currentUser.name !== user.name) { if (userProfile.name !== user.name) {
dispatch(updateProfile({ name: user.name })) updateProfile({ variables: {changes: { name: user.name }}});
} }
}; };
@ -24,11 +26,11 @@ export function ProfilePage() {
<div className="columns"> <div className="columns">
<div className="column is-6 is-offset-3"> <div className="column is-6 is-offset-3">
<h2 className="is-size-2 subtitle">Mon profil</h2> <h2 className="is-size-2 subtitle">Mon profil</h2>
<WithLoader loading={isLoading || !userProfile}>
{ {
currentUser ? <UserForm onChange={onUserChange} user={userProfile} />
<UserForm onChange={onUserChange} user={currentUser} /> :
<Loader />
} }
</WithLoader>
</div> </div>
</div> </div>
</section> </section>

View File

@ -10,11 +10,11 @@ export function UserForm({ user, onChange }: UserFormProps) {
const [ state, setState ] = useState({ const [ state, setState ] = useState({
changed: false, changed: false,
user: { user: {
name: '', id: user && user.id ? user.id : '',
email: '', name: user && user.name ? user.name : '',
createdAt: null, email: user && user.email ? user.email : '',
connectedAt: null, createdAt: user && user.createdAt ? user.createdAt : null,
...user, connectedAt: user && user.connectedAt ? user.connectedAt : null,
} }
}); });

View File

@ -0,0 +1,18 @@
import React, { Fragment, PropsWithChildren, FunctionComponent } from 'react';
export interface WithLoaderProps {
loading?: boolean|boolean[]
}
export const WithLoader: FunctionComponent<WithLoaderProps> = ({ loading, children }) => {
const isLoading = Array.isArray(loading) ? loading.some(l => l) : loading;
return (
<Fragment>
{
isLoading ?
<div>Chargement</div> :
children
}
</Fragment>
)
}

View File

@ -0,0 +1,96 @@
import React, { useState, ChangeEvent, useEffect } from 'react';
import { Workgroup } from '../../types/workgroup';
export interface InfoFormProps {
workgroup: Workgroup
onChange?: (workgroup: Workgroup) => void
}
export function InfoForm({ workgroup, onChange }: InfoFormProps) {
const [ state, setState ] = useState({
changed: false,
workgroup: {
id: workgroup && workgroup.id ? workgroup.id : '',
name: workgroup && workgroup.name ? workgroup.name : '',
createdAt: workgroup && workgroup.createdAt ? workgroup.createdAt : null,
closedAt: workgroup && workgroup.closedAt ? workgroup.closedAt : null,
}
});
useEffect(() => {
setState({
changed: false,
workgroup: {
id: workgroup && workgroup.id ? workgroup.id : '',
name: workgroup && workgroup.name ? workgroup.name : '',
createdAt: workgroup && workgroup.createdAt ? workgroup.createdAt : null,
closedAt: workgroup && workgroup.closedAt ? workgroup.closedAt : null,
}
});
}, [workgroup]);
const onSaveClick = () => {
if (!state.changed) return;
if (typeof onChange !== 'function') return;
onChange(state.workgroup as Workgroup);
setState(state => {
return {
...state,
changed: false,
};
})
};
const onWorkgroupAttrChange = function(attrName: string, evt: ChangeEvent<HTMLInputElement>) {
const value = evt.currentTarget.value;
setState(state => {
return {
...state,
changed: true,
workgroup: {
...state.workgroup,
[attrName]: value,
}
};
});
};
return (
<div className="form" style={{width: '100%'}}>
<div className="field">
<label className="label">Nom du groupe</label>
<div className="control">
<input type="text" className="input" value={state.workgroup.name}
onChange={onWorkgroupAttrChange.bind(null, "name")} />
</div>
</div>
{
state.workgroup.createdAt ?
<div className="field">
<label className="label">Date de création</label>
<div className="control">
<p className="input is-static">{state.workgroup.createdAt}</p>
</div>
</div>:
null
}
{
state.workgroup.closedAt ?
<div className="field">
<label className="label">Date de clôture</label>
<div className="control">
<p className="input is-static">{state.workgroup.closedAt}</p>
</div>
</div>:
null
}
<div className="buttons is-right">
<button disabled={!state.changed}
className="button is-success" onClick={onSaveClick}>
<span>Enregistrer</span>
<span className="icon"><i className="fa fa-save"></i></span>
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,52 @@
import React, { FunctionComponent } from 'react';
import { User } from '../../types/user';
import { Workgroup } from '../../types/workgroup';
import { InfoForm } from './InfoForm';
import { WithLoader } from '../WithLoader';
import { useUpdateWorkgroupMutation, useCreateWorkgroupMutation } from '../../gql/mutations/workgroups';
import { useHistory } from 'react-router';
export interface InfoPanelProps {
workgroup: Workgroup
}
export const InfoPanel: FunctionComponent<InfoPanelProps> = ({ workgroup }) => {
const [ updateWorkgroup, updateWorkgroupMutation ] = useUpdateWorkgroupMutation();
const [ createWorkgroup, createWorkgroupMutation ] = useCreateWorkgroupMutation();
const history = useHistory();
const isLoading = updateWorkgroupMutation.loading || createWorkgroupMutation.loading;
const onWorkgroupChange = (formWorkgroup: Workgroup) => {
const variables: any = { changes: {} };
if (workgroup.name !== formWorkgroup.name) {
variables.changes.name = formWorkgroup.name;
}
if (Object.keys(variables.changes).length === 0) return;
const isCreation = workgroup.id === '';
if (isCreation) {
createWorkgroup({variables})
.then(({ data: { createWorkgroup } }) => {
history.push(`/workgroups/${createWorkgroup.id}`);
});
} else {
variables.workgroupId = workgroup.id;
updateWorkgroup({variables});
}
};
return (
<nav className="panel">
<p className="panel-heading">
Informations
</p>
<div className="panel-block">
<WithLoader loading={isLoading}>
<InfoForm workgroup={workgroup} onChange={onWorkgroupChange} />
</WithLoader>
</div>
</nav>
);
}

View File

@ -0,0 +1,35 @@
import React, { FunctionComponent } from 'react';
import { User } from '../../types/user';
export interface MembersPanelProps {
users: User[]
}
export const MembersPanel: FunctionComponent<MembersPanelProps> = ({ users }) => {
return (
<nav className="panel">
<p className="panel-heading">
Membres
</p>
{
users.map(u => {
return (
<div key={`user-${u.id}`} className="panel-block">
<span className="panel-icon">
<i className="fas fa-user" aria-hidden="true"></i>
</span>
<span>{`${ u.name ? (u.name + ' - ') : '' }`}</span><span className="is-italic">{`${u.email}`}</span>
</div>
);
})
}
{
users.length === 0 ?
<a className="panel-block has-text-centered is-block">
<p className="is-italic">Aucun membre pour l'instant.</p>
</a> :
null
}
</nav>
);
}

View File

@ -0,0 +1,138 @@
import React, { useEffect, useState, Fragment } from 'react';
import { Page } from '../Page';
import { WithLoader } from '../WithLoader';
import { useParams } from 'react-router';
import { useWorkgroupsQuery } from '../../gql/queries/workgroups';
import { useUserProfileQuery } from '../../gql/queries/profile';
import { MembersPanel } from './MembersPanel';
import { User } from '../../types/user';
import { InfoPanel } from './InfoPanel';
import { Workgroup } from '../../types/workgroup';
import { useJoinWorkgroupMutation, useLeaveWorkgroupMutation, useCloseWorkgroupMutation } from '../../gql/mutations/workgroups';
export function WorkgroupPage() {
const { id } = useParams();
const workgroupsQuery = useWorkgroupsQuery({
variables:{
filter: {
ids: [id],
}
}
});
const userProfileQuery = useUserProfileQuery();
const [ joinWorkgroup, joinWorkgroupMutation ] = useJoinWorkgroupMutation();
const [ leaveWorkgroup, leaveWorkgroupMutation ] = useLeaveWorkgroupMutation();
const [ closeWorkgroup, closeWorkgroupMutation ] = useCloseWorkgroupMutation();
const [ state, setState ] = useState({
userProfileId: '',
workgroup: {
id: '',
name: '',
closedAt: null,
createdAt: null,
members: [],
}
});
useEffect(() => {
if (!workgroupsQuery.data) return;
setState(state => ({...state, workgroup:{ ...state.workgroup, ...workgroupsQuery.data.workgroups[0]}}));
}, [workgroupsQuery.data]);
useEffect(() => {
if (!userProfileQuery.data) return;
setState(state => ({...state, userProfileId: userProfileQuery.data.userProfile.id }));
}, [userProfileQuery.data]);
const onJoinWorkgroupClick = () => {
joinWorkgroup({
variables: {
workgroupId: state.workgroup.id,
}
});
}
const onLeaveWorkgroupClick = () => {
leaveWorkgroup({
variables: {
workgroupId: state.workgroup.id,
}
});
}
const onCloseWorkgroupClick = () => {
closeWorkgroup({
variables: {
workgroupId: state.workgroup.id,
}
});
}
const isNew = state.workgroup.id === '';
const isWorkgroupMember = state.workgroup.members.some(u => u.id === state.userProfileId);
const isClosed = state.workgroup.closedAt !== null;
return (
<Page title="Groupe de travail">
<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">Groupe de travail</h3>
</div>
</div> :
<div className="level-item">
<div>
<h2 className="is-size-3 title is-spaced">{state.workgroup.name}</h2>
<h3 className="is-size-5 subtitle">Groupe de travail <span className="is-italic">{ isClosed ? '(clos)' : null }</span></h3>
</div>
</div>
}
</div>
<div className="level-right">
<div className="buttons is-right level-item">
{
isNew || isClosed ? null :
<Fragment>
{
isWorkgroupMember ?
<Fragment>
<button onClick={onLeaveWorkgroupClick} className="button is-info is-warning is-medium">
<span>Quitter</span>
<span className="icon"><i className="fas fa-sign-out-alt"></i></span>
</button>
<button onClick={onCloseWorkgroupClick} className="button is-danger is-medium">
<span>Clore</span>
<span className="icon"><i className="far fa-times-circle"></i></span>
</button>
</Fragment> :
<button onClick={onJoinWorkgroupClick} className="button is-info is-medium">
<span>Rejoindre</span>
<span className="icon"><i className="fas fa-user-plus"></i></span>
</button>
}
</Fragment>
}
</div>
</div>
</div>
<WithLoader loading={[workgroupsQuery.loading, userProfileQuery.loading, joinWorkgroupMutation.loading, leaveWorkgroupMutation.loading]}>
<div className="columns">
<div className="column">
<InfoPanel workgroup={state.workgroup as Workgroup} />
</div>
<div className="column">
<MembersPanel users={state.workgroup.members as User[]} />
</div>
</div>
</WithLoader>
</section>
</div>
</Page>
);
}

20
client/src/gql/client.tsx Normal file
View File

@ -0,0 +1,20 @@
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';
import { Config } from '../config';
import { WebSocketLink } from "@apollo/client/link/ws";
import { RetryLink } from "@apollo/client/link/retry";
import { SubscriptionClient } from "subscriptions-transport-ws";
const subscriptionClient = new SubscriptionClient(Config.subscriptionEndpoint, {
reconnect: true,
});
const link = new RetryLink({attempts: {max: 2}}).split(
(operation) => operation.operationName === 'subscription',
new WebSocketLink(subscriptionClient),
new HttpLink({ uri: Config.graphQLEndpoint, credentials: 'include' })
);
export const client = new ApolloClient<any>({
cache: new InMemoryCache(),
link: link,
});

View File

@ -0,0 +1,15 @@
import { gql, useQuery, useMutation } from '@apollo/client';
const MUTATION_UPDATE_USER_PROFILE = gql`
mutation updateUserProfile($changes: ProfileChanges!) {
updateProfile(changes: $changes) {
id,
name,
createdAt,
connectedAt,
}
}`;
export function useUpdateUserProfileMutation() {
return useMutation(MUTATION_UPDATE_USER_PROFILE);
}

View File

@ -0,0 +1,96 @@
import { gql, useQuery, useMutation } from '@apollo/client';
const MUTATION_UPDATE_WORKGROUP = gql`
mutation updateWorkgroup($workgroupId: ID!, $changes: WorkgroupChanges!) {
updateWorkgroup(workgroupId: $workgroupId, changes: $changes) {
id,
name,
createdAt,
closedAt,
members {
id,
name,
email
}
}
}`;
export function useUpdateWorkgroupMutation() {
return useMutation(MUTATION_UPDATE_WORKGROUP);
}
const MUTATION_CREATE_WORKGROUP = gql`
mutation createWorkgroup($changes: WorkgroupChanges!) {
createWorkgroup(changes: $changes) {
id,
name,
createdAt,
closedAt,
members {
id,
name,
email
}
}
}`;
export function useCreateWorkgroupMutation() {
return useMutation(MUTATION_CREATE_WORKGROUP);
}
const MUTATION_JOIN_WORKGROUP = gql`
mutation joinWorkgroup($workgroupId: ID!) {
joinWorkgroup(workgroupId: $workgroupId) {
id,
name,
createdAt,
closedAt,
members {
id,
name,
email
}
}
}`;
export function useJoinWorkgroupMutation() {
return useMutation(MUTATION_JOIN_WORKGROUP);
}
const MUTATION_LEAVE_WORKGROUP = gql`
mutation leaveWorkgroup($workgroupId: ID!) {
leaveWorkgroup(workgroupId: $workgroupId) {
id,
name,
createdAt,
closedAt,
members {
id,
name,
email
}
}
}`;
export function useLeaveWorkgroupMutation() {
return useMutation(MUTATION_LEAVE_WORKGROUP);
}
const MUTATION_CLOSE_WORKGROUP = gql`
mutation closeWorkgroup($workgroupId: ID!) {
closeWorkgroup(workgroupId: $workgroupId) {
id,
name,
createdAt,
closedAt,
members {
id,
name,
email
}
}
}`;
export function useCloseWorkgroupMutation() {
return useMutation(MUTATION_CLOSE_WORKGROUP);
}

View File

@ -0,0 +1,16 @@
import { gql, useQuery } from '@apollo/client';
const QUERY_USER_PROFILE = gql`
query userProfile {
userProfile {
id,
name,
email,
createdAt,
connectedAt
}
}`;
export function useUserProfileQuery() {
return useQuery(QUERY_USER_PROFILE);
}

View File

@ -0,0 +1,21 @@
import { gql, useQuery } from '@apollo/client';
const QUERY_WORKGROUP = gql`
query workgroups($filter: WorkgroupsFilter) {
workgroups(filter: $filter) {
id,
name,
createdAt,
closedAt,
members {
id,
email,
name
}
}
}
`;
export function useWorkgroupsQuery(options = {}) {
return useQuery(QUERY_WORKGROUP, options);
}

View File

@ -2,15 +2,18 @@ import './sass/_all.scss';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { App } from './components/App'; import { App } from './components/App';
import { Config } from './config'; import { client } from './gql/client';
import '@fortawesome/fontawesome-free/js/fontawesome' import '@fortawesome/fontawesome-free/js/fontawesome'
import '@fortawesome/fontawesome-free/js/solid' import '@fortawesome/fontawesome-free/js/solid'
import '@fortawesome/fontawesome-free/js/regular' import '@fortawesome/fontawesome-free/js/regular'
import '@fortawesome/fontawesome-free/js/brands' import '@fortawesome/fontawesome-free/js/brands'
import './resources/favicon.png'; import './resources/favicon.png';
import { ApolloProvider } from '@apollo/client';
ReactDOM.render( ReactDOM.render(
<App />, <ApolloProvider client={client}>
<App />
</ApolloProvider>,
document.getElementById('app') document.getElementById('app')
); );

View File

@ -1,4 +1,3 @@
@import 'bulma/bulma.sass'; @import 'bulma/bulma.sass';
@import 'bulma-switch/dist/css/bulma-switch.sass';
@import '_base.scss'; @import '_base.scss';
@import '_loader.scss'; @import '_loader.scss';

View File

@ -1,11 +0,0 @@
import { Action } from "redux";
export const SET_CURRENT_USER = 'SET_CURRENT_USER';
export interface setCurrentUserAction extends Action {
email: string
}
export function setCurrentUser(email: string): setCurrentUserAction {
return { type: SET_CURRENT_USER, email };
}

View File

@ -1,40 +0,0 @@
import { Action } from "redux";
import { User } from "../../types/user";
export const FETCH_PROFILE_REQUEST = 'FETCH_PROFILE_REQUEST';
export const FETCH_PROFILE_SUCCESS = 'FETCH_PROFILE_SUCCESS';
export const FETCH_PROFILE_FAILURE = 'FETCH_PROFILE_FAILURE';
export interface fetchProfileRequestAction extends Action {
}
export interface fetchProfileSuccessAction extends Action {
profile: User
}
export function fetchProfile(): fetchProfileRequestAction {
return { type: FETCH_PROFILE_REQUEST }
}
export const UPDATE_PROFILE_REQUEST = 'UPDATE_PROFILE_REQUEST';
export const UPDATE_PROFILE_SUCCESS = 'UPDATE_PROFILE_SUCCESS';
export const UPDATE_PROFILE_FAILURE = 'UPDATE_PROFILE_FAILURE';
export interface ProfileChanges {
name?: string
}
export interface updateProfileRequestAction extends Action {
changes: ProfileChanges
}
export interface updateProfileSuccessAction extends Action {
profile: User
}
export function updateProfile(changes: ProfileChanges): updateProfileRequestAction {
return { type: UPDATE_PROFILE_REQUEST, changes }
}

View File

@ -1,57 +0,0 @@
import { Action } from "redux";
import { User } from "../../types/user";
import { SET_CURRENT_USER, setCurrentUserAction } from "../actions/auth";
import { FETCH_PROFILE_SUCCESS, fetchProfileSuccessAction, updateProfileSuccessAction, UPDATE_PROFILE_SUCCESS, updateProfileRequestAction } from "../actions/profile";
export interface AuthState {
isAuthenticated: boolean
currentUser: User
}
const defaultState = {
isAuthenticated: false,
currentUser: null,
};
export function authReducer(state = defaultState, action: Action): AuthState {
switch (action.type) {
case SET_CURRENT_USER:
return handleSetCurrentUser(state, action as setCurrentUserAction);
case FETCH_PROFILE_SUCCESS:
return handleFetchProfileSuccess(state, action as fetchProfileSuccessAction);
case UPDATE_PROFILE_SUCCESS:
return handleFetchProfileSuccess(state, action as updateProfileSuccessAction);
}
return state;
}
function handleSetCurrentUser(state: AuthState, { email }: setCurrentUserAction): AuthState {
return {
...state,
isAuthenticated: true,
currentUser: {
email
}
};
};
function handleFetchProfileSuccess(state: AuthState, { profile }: fetchProfileSuccessAction): AuthState {
return {
...state,
isAuthenticated: true,
currentUser: {
...profile,
}
};
};
function handleUpdateProfileSuccess(state: AuthState, { profile }: updateProfileSuccessAction): AuthState {
return {
...state,
isAuthenticated: true,
currentUser: {
...profile,
}
};
};

View File

@ -1,32 +0,0 @@
import { Action } from "redux";
export interface FlagsState {
actions: { [actionName: string]: ActionState }
}
export interface ActionState {
isLoading: boolean
}
const defaultState = {
actions: {}
};
export function flagsReducer(state = defaultState, action: Action): FlagsState {
const matches = (/^(.*)_((SUCCESS)|(FAILURE)|(REQUEST))$/).exec(action.type);
if(!matches) return state;
const actionPrefix = matches[1];
return {
...state,
actions: {
...state.actions,
[actionPrefix]: {
isLoading: matches[2] === 'REQUEST'
}
}
};
}

View File

@ -1,13 +0,0 @@
import { combineReducers } from 'redux';
import { flagsReducer, FlagsState } from './flags';
import { authReducer, AuthState } from './auth';
export interface RootState {
auth: AuthState,
flags: FlagsState,
}
export const rootReducer = combineReducers({
flags: flagsReducer,
auth: authReducer,
});

View File

@ -1,21 +0,0 @@
import { UnauthorizedError } from "../../util/daddy";
import { all, takeEvery } from 'redux-saga/effects';
export function* failureRootSaga() {
yield all([
takeEvery(patternFromRegExp(/^.*_FAILURE/), failuresSaga),
]);
}
export function* failuresSaga(action) {
if (action.error instanceof UnauthorizedError) {
// TODO Implements better authorization error handling
window.location.reload();
}
}
export function patternFromRegExp(re: any) {
return (action: any) => {
return re.test(action.type);
};
}

View File

@ -1,12 +0,0 @@
import { all, put } from "redux-saga/effects";
import { fetchProfile } from "../actions/profile";
export function* initRootSaga() {
yield all([
fetchUserProfileSaga(),
]);
}
export function* fetchUserProfileSaga() {
yield put(fetchProfile());
}

View File

@ -1,46 +0,0 @@
import { DaddyClient, getClient } from "../../util/daddy";
import { Config } from "../../config";
import { all, takeLatest, put, select } from "redux-saga/effects";
import { FETCH_PROFILE_REQUEST, fetchProfile, FETCH_PROFILE_FAILURE, FETCH_PROFILE_SUCCESS, updateProfileRequestAction, UPDATE_PROFILE_REQUEST, UPDATE_PROFILE_FAILURE, UPDATE_PROFILE_SUCCESS } from "../actions/profile";
import { SET_CURRENT_USER } from "../actions/auth";
import { User } from "../../types/user";
export function* profileRootSaga() {
yield all([
takeLatest(SET_CURRENT_USER, onCurrentUserChangeSaga),
takeLatest(FETCH_PROFILE_REQUEST, fetchProfileSaga),
takeLatest(UPDATE_PROFILE_REQUEST, updateProfileSaga),
]);
}
export function* onCurrentUserChangeSaga() {
yield put(fetchProfile());
}
export function* fetchProfileSaga() {
const client = getClient(Config.graphQLEndpoint, Config.subscriptionEndpoint);
let profile: User;
try {
profile = yield client.fetchProfile().then(result => result.userProfile);
} catch(err) {
yield put({ type: FETCH_PROFILE_FAILURE, err });
return;
}
yield put({type: FETCH_PROFILE_SUCCESS, profile });
}
export function* updateProfileSaga({ changes }: updateProfileRequestAction) {
const client = getClient(Config.graphQLEndpoint, Config.subscriptionEndpoint);
let profile: User;
try {
profile = yield client.updateProfile(changes).then(result => result.updateProfile);
} catch(err) {
yield put({ type: UPDATE_PROFILE_FAILURE, err });
return;
}
yield put({type: UPDATE_PROFILE_SUCCESS, profile });
}

View File

@ -1,12 +0,0 @@
import { all } from 'redux-saga/effects';
import { failureRootSaga } from './failure';
import { initRootSaga } from './init';
import { profileRootSaga } from './profile';
export function* rootSaga() {
yield all([
initRootSaga(),
failureRootSaga(),
profileRootSaga(),
]);
}

View File

@ -1,7 +0,0 @@
export function selectFlagsIsLoading(state: any, ...actionPrefixes: any[]) {
const { actions } = state.flags;
return actionPrefixes.reduce((isLoading, prefix) => {
if (!(prefix in actions)) return isLoading;
return isLoading || actions[prefix].isLoading;
}, false);
};

View File

@ -1,30 +0,0 @@
import { createStore, applyMiddleware, compose } from 'redux'
import createSagaMiddleware from 'redux-saga'
import { rootReducer } from './reducers/root'
import { rootSaga } from './sagas/root'
let reduxMiddlewares = [];
if (process.env.NODE_ENV !== 'production') {
const createLogger = require('redux-logger').createLogger;
const loggerMiddleware = createLogger({
collapsed: true,
diff: true
});
reduxMiddlewares.push(loggerMiddleware);
}
const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
// create the saga middleware
const sagaMiddleware = createSagaMiddleware()
reduxMiddlewares.push(sagaMiddleware);
// mount it on the Store
export const store = createStore(
rootReducer,
composeEnhancers(applyMiddleware(...reduxMiddlewares)),
)
// then run the saga
sagaMiddleware.run(rootSaga);

View File

@ -1,4 +1,5 @@
export interface User { export interface User {
id: string
email: string email: string
name?: string name?: string
connectedAt?: Date connectedAt?: Date

View File

@ -0,0 +1,9 @@
import { User } from "./user";
export interface Workgroup {
id: string
name: string
createdAt: Date
closedAt: Date
members: [User]
}

View File

@ -1,115 +0,0 @@
import ApolloClient, { DefaultOptions } from 'apollo-client';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { split } from 'apollo-link';
import { HttpLink } from 'apollo-link-http';
import { WebSocketLink } from 'apollo-link-ws';
import { getMainDefinition, variablesInOperation } from 'apollo-utilities';
import gql from 'graphql-tag';
import { ProfileChanges } from '../store/actions/profile';
export class UnauthorizedError extends Error {
constructor(...args: any[]) {
super(...args)
Object.setPrototypeOf(this, UnauthorizedError.prototype);
}
}
let client: DaddyClient
export function getClient(graphQLEndpoint: string, subscriptionEndpoint: string): DaddyClient {
if (!client) {
client = new DaddyClient(graphQLEndpoint, subscriptionEndpoint);
}
return client;
}
export class DaddyClient {
gql: ApolloClient<InMemoryCache>
constructor(graphQLEndpoint: string, subscriptionEndpoint: string) {
const wsLink = new WebSocketLink({
uri: subscriptionEndpoint,
options: {
reconnect: true
}
});
const httpLink = new HttpLink({
uri: graphQLEndpoint,
fetchOptions: {
mode: 'cors',
credentials: 'include',
}
});
const link = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
httpLink,
);
const defaultOptions: DefaultOptions = {
watchQuery: {
fetchPolicy: 'no-cache',
errorPolicy: 'ignore',
},
query: {
fetchPolicy: 'no-cache',
errorPolicy: 'all',
},
};
this.gql = new ApolloClient<any>({
link: link,
cache: new InMemoryCache(),
defaultOptions,
});
}
fetchProfile() {
return this.gql.query({
query: gql`
query {
userProfile {
name,
email,
createdAt,
connectedAt
}
}`
})
.then(this.assertAuthorization)
}
updateProfile(changes: ProfileChanges) {
return this.gql.mutate({
variables: {
changes,
},
mutation: gql`
mutation updateProfile($changes: ProfileChanges!) {
updateProfile(changes: $changes) {
name,
email,
createdAt,
connectedAt
}
}`,
})
.then(this.assertAuthorization)
}
assertAuthorization({ status, data }: any) {
if (status === 401) return Promise.reject(new UnauthorizedError());
return data;
}
}

View File

@ -79,6 +79,7 @@ func applyMigration(ctx context.Context, ctn *service.Container) error {
// nolint: gochecknoglobals // nolint: gochecknoglobals
var initialModels = []interface{}{ var initialModels = []interface{}{
&model.User{}, &model.User{},
&model.Workgroup{},
} }
func m000initialSchema() orm.Migration { func m000initialSchema() orm.Migration {

View File

@ -3,7 +3,9 @@ package graph
import ( import (
"context" "context"
"forge.cadoles.com/Cadoles/daddy/internal/model"
"forge.cadoles.com/Cadoles/daddy/internal/orm" "forge.cadoles.com/Cadoles/daddy/internal/orm"
"forge.cadoles.com/Cadoles/daddy/internal/session"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
"github.com/pkg/errors" "github.com/pkg/errors"
@ -23,3 +25,24 @@ func getDB(ctx context.Context) (*gorm.DB, error) {
return orm.DB(), nil return orm.DB(), nil
} }
func getSessionUser(ctx context.Context) (*model.User, *gorm.DB, error) {
db, err := getDB(ctx)
if err != nil {
return nil, nil, errors.WithStack(err)
}
userEmail, err := session.UserEmail(ctx)
if err != nil {
return nil, nil, errors.WithStack(err)
}
repo := model.NewUserRepository(db)
user, err := repo.FindUserByEmail(ctx, userEmail)
if err != nil {
return nil, nil, errors.WithStack(err)
}
return user, db, nil
}

View File

@ -2,6 +2,15 @@ input ProfileChanges {
name: String name: String
} }
input WorkgroupChanges {
name: String
}
type Mutation { type Mutation {
updateProfile(changes: ProfileChanges!): User! updateProfile(changes: ProfileChanges!): User!
joinWorkgroup(workgroupId: ID!): Workgroup!
leaveWorkgroup(workgroupId: ID!): Workgroup!
createWorkgroup(changes: WorkgroupChanges!): Workgroup!
closeWorkgroup(workgroupId: ID!): Workgroup!
updateWorkgroup(workgroupId: ID!, changes: WorkgroupChanges!): Workgroup!
} }

View File

@ -14,6 +14,26 @@ func (r *mutationResolver) UpdateProfile(ctx context.Context, changes model.Prof
return handleUpdateUserProfile(ctx, changes) return handleUpdateUserProfile(ctx, changes)
} }
func (r *mutationResolver) JoinWorkgroup(ctx context.Context, workgroupID string) (*model.Workgroup, error) {
return handleJoinWorkgroup(ctx, workgroupID)
}
func (r *mutationResolver) LeaveWorkgroup(ctx context.Context, workgroupID string) (*model.Workgroup, error) {
return handleLeaveWorkgroup(ctx, workgroupID)
}
func (r *mutationResolver) CreateWorkgroup(ctx context.Context, changes model.WorkgroupChanges) (*model.Workgroup, error) {
return handleCreateWorkgroup(ctx, changes)
}
func (r *mutationResolver) CloseWorkgroup(ctx context.Context, workgroupID string) (*model.Workgroup, error) {
return handleCloseWorkgroup(ctx, workgroupID)
}
func (r *mutationResolver) UpdateWorkgroup(ctx context.Context, workgroupID string, changes model.WorkgroupChanges) (*model.Workgroup, error) {
return handleUpdateWorkgroup(ctx, workgroupID, changes)
}
// Mutation returns generated.MutationResolver implementation. // Mutation returns generated.MutationResolver implementation.
func (r *Resolver) Mutation() generated.MutationResolver { return &mutationResolver{r} } func (r *Resolver) Mutation() generated.MutationResolver { return &mutationResolver{r} }

View File

@ -1,12 +1,27 @@
scalar Time scalar Time
type User { type User {
id: ID!
name: String name: String
email: String! email: String!
connectedAt: Time! connectedAt: Time!
createdAt: Time! createdAt: Time!
workgroups:[Workgroup]!
}
type Workgroup {
id: ID!
name: String
createdAt: Time!
closedAt: Time
members: [User]!
}
input WorkgroupsFilter {
ids: [ID]
} }
type Query { type Query {
userProfile: User userProfile: User
workgroups(filter: WorkgroupsFilter): [Workgroup]!
} }

View File

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

View File

@ -3,26 +3,12 @@ package graph
import ( import (
"context" "context"
"forge.cadoles.com/Cadoles/daddy/internal/session"
"forge.cadoles.com/Cadoles/daddy/internal/model" "forge.cadoles.com/Cadoles/daddy/internal/model"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
func handleUserProfile(ctx context.Context) (*model.User, error) { func handleUserProfile(ctx context.Context) (*model.User, error) {
db, err := getDB(ctx) user, _, err := getSessionUser(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
userEmail, err := session.UserEmail(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
repo := model.NewUserRepository(db)
user, err := repo.FindUserByEmail(ctx, userEmail)
if err != nil { if err != nil {
return nil, errors.WithStack(err) return nil, errors.WithStack(err)
} }
@ -31,12 +17,7 @@ func handleUserProfile(ctx context.Context) (*model.User, error) {
} }
func handleUpdateUserProfile(ctx context.Context, changes model.ProfileChanges) (*model.User, error) { func handleUpdateUserProfile(ctx context.Context, changes model.ProfileChanges) (*model.User, error) {
db, err := getDB(ctx) user, db, err := getSessionUser(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
userEmail, err := session.UserEmail(ctx)
if err != nil { if err != nil {
return nil, errors.WithStack(err) return nil, errors.WithStack(err)
} }
@ -49,7 +30,7 @@ func handleUpdateUserProfile(ctx context.Context, changes model.ProfileChanges)
userChanges.Name = changes.Name userChanges.Name = changes.Name
} }
user, err := repo.UpdateUserByEmail(ctx, userEmail, userChanges) user, err = repo.UpdateUserByEmail(ctx, user.Email, userChanges)
if err != nil { if err != nil {
return nil, errors.WithStack(err) return nil, errors.WithStack(err)
} }

View File

@ -0,0 +1,142 @@
package graph
import (
"context"
"strconv"
"forge.cadoles.com/Cadoles/daddy/internal/model"
"github.com/pkg/errors"
)
func handleWorkgroups(ctx context.Context, filter *model.WorkgroupsFilter) ([]*model.Workgroup, error) {
db, err := getDB(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
repo := model.NewWorkgroupRepository(db)
criteria := make([]interface{}, 0)
if filter != nil {
if len(filter.Ids) > 0 {
criteria = append(criteria, "id in (?)", filter.Ids)
}
}
workgroups, err := repo.FindWorkgroups(ctx, criteria...)
if err != nil {
return nil, errors.WithStack(err)
}
return workgroups, nil
}
func handleJoinWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Workgroup, error) {
workgroupID, err := parseWorkgroupID(rawWorkgroupID)
if err != nil {
return nil, errors.WithStack(err)
}
user, db, err := getSessionUser(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
repo := model.NewWorkgroupRepository(db)
workgroup, err := repo.AddUserToWorkgroup(ctx, user.ID, workgroupID)
if err != nil {
return nil, errors.WithStack(err)
}
return workgroup, nil
}
func handleLeaveWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Workgroup, error) {
workgroupID, err := parseWorkgroupID(rawWorkgroupID)
if err != nil {
return nil, errors.WithStack(err)
}
user, db, err := getSessionUser(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
repo := model.NewWorkgroupRepository(db)
workgroup, err := repo.RemoveUserFromWorkgroup(ctx, user.ID, workgroupID)
if err != nil {
return nil, errors.WithStack(err)
}
return workgroup, nil
}
func handleCreateWorkgroup(ctx context.Context, changes model.WorkgroupChanges) (*model.Workgroup, error) {
db, err := getDB(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
repo := model.NewWorkgroupRepository(db)
workgroup, err := repo.CreateWorkgroup(ctx, changes)
if err != nil {
return nil, errors.WithStack(err)
}
return workgroup, nil
}
func handleCloseWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Workgroup, error) {
workgroupID, err := parseWorkgroupID(rawWorkgroupID)
if err != nil {
return nil, errors.WithStack(err)
}
db, err := getDB(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
repo := model.NewWorkgroupRepository(db)
workgroup, err := repo.CloseWorkgroup(ctx, workgroupID)
if err != nil {
return nil, errors.WithStack(err)
}
return workgroup, nil
}
func handleUpdateWorkgroup(ctx context.Context, rawWorkgroupID string, changes model.WorkgroupChanges) (*model.Workgroup, error) {
workgroupID, err := parseWorkgroupID(rawWorkgroupID)
if err != nil {
return nil, errors.WithStack(err)
}
db, err := getDB(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
repo := model.NewWorkgroupRepository(db)
workgroup, err := repo.UpdateWorkgroup(ctx, workgroupID, changes)
if err != nil {
return nil, errors.WithStack(err)
}
return workgroup, nil
}
func parseWorkgroupID(workgroupID string) (uint, error) {
workgroupID64, err := strconv.ParseUint(workgroupID, 10, 32)
if err != nil {
return 0, errors.WithStack(err)
}
return uint(workgroupID64), nil
}

View File

@ -1,13 +1,17 @@
package model package model
import "time" import (
"time"
"github.com/jinzhu/gorm"
)
type User struct { type User struct {
ID *uint `gorm:"primary_key"` gorm.Model
Name *string `json:"name"` Name *string `json:"name"`
Email string `json:"email" gorm:"unique;not null"` Email string `json:"email" gorm:"unique;not null"`
ConnectedAt time.Time `json:"connectedAt"` ConnectedAt time.Time `json:"connectedAt"`
CreatedAt time.Time `json:"createdAt"` Workgroups []*Workgroup `gorm:"many2many:users_workgroups;"`
} }
type ProfileChanges struct { type ProfileChanges struct {

View File

@ -15,8 +15,7 @@ type UserRepository struct {
func (r *UserRepository) CreateOrConnectUser(ctx context.Context, email string) (*User, error) { func (r *UserRepository) CreateOrConnectUser(ctx context.Context, email string) (*User, error) {
user := &User{ user := &User{
Email: email, Email: email,
CreatedAt: time.Now(),
} }
err := orm.WithTx(ctx, r.db, func(ctx context.Context, tx *gorm.DB) error { err := orm.WithTx(ctx, r.db, func(ctx context.Context, tx *gorm.DB) error {
@ -44,7 +43,7 @@ func (r *UserRepository) FindUserByEmail(ctx context.Context, email string) (*Us
Email: email, Email: email,
} }
err := r.db.First(user, "email = ?", email).Error err := r.db.Model(user).Preload("Workgroups").First(user, "email = ?", email).Error
if err != nil { if err != nil {
return nil, errors.Wrap(err, "could not find user") return nil, errors.Wrap(err, "could not find user")
} }

View File

@ -0,0 +1,18 @@
package model
import (
"time"
"github.com/jinzhu/gorm"
)
type Workgroup struct {
gorm.Model
Name *string `json:"name"`
ClosedAt time.Time `json:"closedAt"`
Members []*User `gorm:"many2many:users_workgroups;"`
}
type WorkgroupChanges struct {
Name *string `json:"name"`
}

View File

@ -0,0 +1,140 @@
package model
import (
"context"
"time"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
)
type WorkgroupRepository struct {
db *gorm.DB
}
func (r *WorkgroupRepository) FindWorkgroups(ctx context.Context, criteria ...interface{}) ([]*Workgroup, error) {
workgroups := make([]*Workgroup, 0)
if err := r.db.Model(&Workgroup{}).Preload("Members").Find(&workgroups, criteria...).Error; err != nil {
return nil, errors.WithStack(err)
}
return workgroups, nil
}
func (r *WorkgroupRepository) UpdateWorkgroup(ctx context.Context, workgroupID uint, changes WorkgroupChanges) (*Workgroup, error) {
workgroup := &Workgroup{
Name: changes.Name,
}
workgroup.ID = workgroupID
err := r.db.Model(workgroup).
Update(workgroup).
Error
if err != nil {
return nil, errors.WithStack(err)
}
err = r.db.Model(workgroup).Preload("Members").First(workgroup, "id = ?", workgroupID).Error
if err != nil {
return nil, errors.WithStack(err)
}
return workgroup, nil
}
func (r *WorkgroupRepository) CreateWorkgroup(ctx context.Context, changes WorkgroupChanges) (*Workgroup, error) {
workgroup := &Workgroup{
Name: changes.Name,
}
if err := r.db.Model(&Workgroup{}).Create(workgroup).Error; err != nil {
return nil, errors.WithStack(err)
}
return workgroup, nil
}
func (r *WorkgroupRepository) CloseWorkgroup(ctx context.Context, workgroupID uint) (*Workgroup, error) {
workgroup := &Workgroup{}
err := r.db.Model(workgroup).
Where("id = ?", workgroupID).
UpdateColumn("closedAt", time.Now()).
Error
if err != nil {
return nil, errors.WithStack(err)
}
err = r.db.Model(workgroup).Preload("Members").First(workgroup, "id = ?", workgroupID).Error
if err != nil {
return nil, errors.WithStack(err)
}
return workgroup, nil
}
func (r *WorkgroupRepository) AddUserToWorkgroup(ctx context.Context, userID, workgroupID uint) (*Workgroup, error) {
user := &User{}
err := r.db.Model(user).Preload("Workgroups").First(user, "id = ?", userID).Error
if err != nil {
return nil, errors.Wrap(err, "could not find user")
}
workgroup := &Workgroup{}
workgroup.ID = workgroupID
err = r.db.Model(user).
Association("Workgroups").
Append(workgroup).
Error
if err != nil {
return nil, errors.Wrap(err, "could not add user to workgroup")
}
err = r.db.Model(workgroup).
Preload("Members").
First(workgroup, "id = ?", workgroupID).
Error
if err != nil {
return nil, errors.WithStack(err)
}
return workgroup, nil
}
func (r *WorkgroupRepository) RemoveUserFromWorkgroup(ctx context.Context, userID, workgroupID uint) (*Workgroup, error) {
user := &User{}
err := r.db.First(user, "id = ?", userID).Error
if err != nil {
return nil, errors.Wrap(err, "could not find user")
}
workgroup := &Workgroup{}
workgroup.ID = workgroupID
err = r.db.Model(user).
Association("Workgroups").
Delete(workgroup).
Error
if err != nil {
return nil, errors.Wrap(err, "could not add user to workgroup")
}
err = r.db.Model(workgroup).
Preload("Members").
First(workgroup, "id = ?", workgroupID).
Error
if err != nil {
return nil, errors.WithStack(err)
}
return workgroup, nil
}
func NewWorkgroupRepository(db *gorm.DB) *WorkgroupRepository {
return &WorkgroupRepository{db}
}

View File

@ -33,7 +33,6 @@ func Mount(r *chi.Mux, config *config.Config) error {
AllowCredentials: config.HTTP.CORS.AllowCredentials, AllowCredentials: config.HTTP.CORS.AllowCredentials,
Debug: config.Debug, Debug: config.Debug,
}).Handler) }).Handler)
r.Use(oidc.Middleware)
r.Use(session.UserEmailMiddleware) r.Use(session.UserEmailMiddleware)
gql := handler.New( gql := handler.New(

View File

@ -21,7 +21,9 @@ func UserEmailMiddleware(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) { fn := func(w http.ResponseWriter, r *http.Request) {
userEmail, err := GetUserEmail(w, r) userEmail, err := GetUserEmail(w, r)
if err != nil { if err != nil {
panic(errors.Wrap(err, "could not find user email")) http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
} }
ctx := WithUserEmail(r.Context(), userEmail) ctx := WithUserEmail(r.Context(), userEmail)