Merge branch 'develop' into dist/ubuntu/bionic/develop
This commit is contained in:
commit
bd133fa9d9
179
client/package-lock.json
generated
179
client/package-lock.json
generated
@ -4,6 +4,43 @@
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"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": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.1.tgz",
|
||||
@ -1230,7 +1267,8 @@
|
||||
"@types/node": {
|
||||
"version": "13.13.12",
|
||||
"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": {
|
||||
"version": "15.7.3",
|
||||
@ -1551,15 +1589,6 @@
|
||||
"@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": {
|
||||
"version": "0.1.11",
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
|
||||
@ -3235,14 +3177,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"bulma": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/bulma/-/bulma-0.7.5.tgz",
|
||||
"integrity": "sha512-cX98TIn0I6sKba/DhW0FBjtaDpxTelU166pf7ICXpCCuplHWyu6C9LYZmL5PEsnePIeJaiorsTEzzNk3Tsm1hw=="
|
||||
},
|
||||
"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=="
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/bulma/-/bulma-0.9.0.tgz",
|
||||
"integrity": "sha512-rV75CJkubNUroAt0qCRkjznZLoaXq/ctfMXsMvKSL84UetbSyx5REl96e8GoQ04G4Tkw0XF3STECffTOQrbzOQ=="
|
||||
},
|
||||
"bytes": {
|
||||
"version": "3.0.0",
|
||||
@ -5615,11 +5552,6 @@
|
||||
"resolved": "https://registry.npmjs.org/graphql/-/graphql-15.3.0.tgz",
|
||||
"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": {
|
||||
"version": "2.10.4",
|
||||
"resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.10.4.tgz",
|
||||
@ -6507,11 +6439,6 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz",
|
||||
@ -7466,14 +7393,6 @@
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/original/-/original-1.0.2.tgz",
|
||||
@ -8048,11 +7967,6 @@
|
||||
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
|
||||
"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": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
|
||||
@ -11006,15 +10920,6 @@
|
||||
"version": "0.8.15",
|
||||
"resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -51,20 +51,10 @@
|
||||
"webpack-dev-server": "^3.11.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.0.2",
|
||||
"@types/qs": "^6.9.3",
|
||||
"apollo-cache-inmemory": "^1.6.6",
|
||||
"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",
|
||||
"bulma": "^0.9.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-dom": "^16.12.0",
|
||||
"react-redux": "^7.1.3",
|
||||
|
@ -1,22 +1,20 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Route, Redirect, Switch } from "react-router-dom";
|
||||
import { HomePage } from './HomePage/HomePage';
|
||||
import { store } from '../store/store';
|
||||
import { Provider } from 'react-redux';
|
||||
import { ProfilePage } from './ProfilePage/ProfilePage';
|
||||
import { WorkgroupPage } from './WorkgroupPage/WorkgroupPage';
|
||||
|
||||
export class App extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<Switch>
|
||||
<Route path="/" exact component={HomePage} />
|
||||
<Route path="/profile" exact component={ProfilePage} />
|
||||
<Route component={() => <Redirect to="/" />} />
|
||||
</Switch>
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
<BrowserRouter>
|
||||
<Switch>
|
||||
<Route path="/" exact component={HomePage} />
|
||||
<Route path="/profile" exact component={ProfilePage} />
|
||||
<Route path="/workgroups/:id" exact component={WorkgroupPage} />
|
||||
<Route component={() => <Redirect to="/" />} />
|
||||
</Switch>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
}
|
38
client/src/components/HomePage/Dashboard.tsx
Normal file
38
client/src/components/HomePage/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -1,26 +1,31 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { Page } from '../Page';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { RootState } from '../../store/reducers/root';
|
||||
import { Dashboard } from './Dashboard';
|
||||
import { useUserProfileQuery } from '../../gql/queries/profile';
|
||||
import { WithLoader } from '../WithLoader';
|
||||
|
||||
export function HomePage() {
|
||||
const currentUser = useSelector((state: RootState) => state.auth.currentUser);
|
||||
const { data, loading } = useUserProfileQuery();
|
||||
|
||||
const { userProfile } = (data || {});
|
||||
|
||||
return (
|
||||
<Page title="Accueil">
|
||||
<Page title={userProfile ? 'Tableau de bord' : 'Accueil'}>
|
||||
<div className="container is-fluid">
|
||||
<section className="section">
|
||||
<div className="columns">
|
||||
<div className="column is-4 is-offset-4">
|
||||
<div className="box">
|
||||
{
|
||||
currentUser && currentUser.email ?
|
||||
<p>Bonjour <span className="has-text-weight-bold">{currentUser.name ? currentUser.name : currentUser.email}</span> !</p> :
|
||||
<p>Veuillez vous authentifier.</p>
|
||||
}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</WithLoader>
|
||||
</section>
|
||||
</div>
|
||||
</Page>
|
||||
|
98
client/src/components/HomePage/WorkgroupsPanel.tsx
Normal file
98
client/src/components/HomePage/WorkgroupsPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,13 +1,13 @@
|
||||
import React, { Fragment, useState } from 'react';
|
||||
import logo from '../resources/logo.svg';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState } from '../store/reducers/root';
|
||||
import { Config } from '../config';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useUserProfileQuery } from '../gql/queries/profile';
|
||||
import { WithLoader } from './WithLoader';
|
||||
|
||||
export function Navbar() {
|
||||
const isAuthenticated = useSelector<RootState>(state => state.auth.isAuthenticated);
|
||||
|
||||
const userProfileQuery = useUserProfileQuery();
|
||||
const [ isActive, setActive ] = useState(false);
|
||||
|
||||
const toggleMenu = () => {
|
||||
@ -35,28 +35,30 @@ export function Navbar() {
|
||||
<div className={`navbar-menu ${isActive ? 'is-active' : ''}`}>
|
||||
<div className="navbar-end">
|
||||
<div className="navbar-item">
|
||||
<div className="buttons">
|
||||
{
|
||||
isAuthenticated ?
|
||||
<Fragment>
|
||||
<Link to="/profile" className="button">
|
||||
<WithLoader loading={userProfileQuery.loading}>
|
||||
<div className="buttons">
|
||||
{
|
||||
userProfileQuery.data && userProfileQuery.data.userProfile ?
|
||||
<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">
|
||||
<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>
|
||||
<i className="fas fa-sign-in-alt"></i>
|
||||
</span>
|
||||
</a>
|
||||
</Fragment> :
|
||||
<a className="button" href={Config.loginURL}>
|
||||
<span className="icon">
|
||||
<i className="fas fa-sign-in-alt"></i>
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</WithLoader>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,19 +1,21 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { Page } from '../Page';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { RootState } from '../../store/reducers/root';
|
||||
import { UserForm } from '../UserForm';
|
||||
import { Loader } from '../Loader';
|
||||
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() {
|
||||
const currentUser = useSelector((state: RootState) => state.auth.currentUser);
|
||||
const dispatch = useDispatch();
|
||||
const userProfileQuery = useUserProfileQuery();
|
||||
const [ updateProfile, updateUserProfileMutation ] = useUpdateUserProfileMutation();
|
||||
const isLoading = updateUserProfileMutation.loading || userProfileQuery.loading;
|
||||
|
||||
const { userProfile } = (userProfileQuery.data || {});
|
||||
|
||||
const onUserChange = (user: User) => {
|
||||
if (currentUser.name !== user.name) {
|
||||
dispatch(updateProfile({ name: user.name }))
|
||||
if (userProfile.name !== user.name) {
|
||||
updateProfile({ variables: {changes: { name: user.name }}});
|
||||
}
|
||||
};
|
||||
|
||||
@ -24,11 +26,11 @@ export function ProfilePage() {
|
||||
<div className="columns">
|
||||
<div className="column is-6 is-offset-3">
|
||||
<h2 className="is-size-2 subtitle">Mon profil</h2>
|
||||
<WithLoader loading={isLoading || !userProfile}>
|
||||
{
|
||||
currentUser ?
|
||||
<UserForm onChange={onUserChange} user={currentUser} /> :
|
||||
<Loader />
|
||||
<UserForm onChange={onUserChange} user={userProfile} />
|
||||
}
|
||||
</WithLoader>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
@ -10,11 +10,11 @@ export function UserForm({ user, onChange }: UserFormProps) {
|
||||
const [ state, setState ] = useState({
|
||||
changed: false,
|
||||
user: {
|
||||
name: '',
|
||||
email: '',
|
||||
createdAt: null,
|
||||
connectedAt: null,
|
||||
...user,
|
||||
id: user && user.id ? user.id : '',
|
||||
name: user && user.name ? user.name : '',
|
||||
email: user && user.email ? user.email : '',
|
||||
createdAt: user && user.createdAt ? user.createdAt : null,
|
||||
connectedAt: user && user.connectedAt ? user.connectedAt : null,
|
||||
}
|
||||
});
|
||||
|
||||
|
18
client/src/components/WithLoader.tsx
Normal file
18
client/src/components/WithLoader.tsx
Normal 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>
|
||||
)
|
||||
}
|
96
client/src/components/WorkgroupPage/InfoForm.tsx
Normal file
96
client/src/components/WorkgroupPage/InfoForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
52
client/src/components/WorkgroupPage/InfoPanel.tsx
Normal file
52
client/src/components/WorkgroupPage/InfoPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
35
client/src/components/WorkgroupPage/MembersPanel.tsx
Normal file
35
client/src/components/WorkgroupPage/MembersPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
138
client/src/components/WorkgroupPage/WorkgroupPage.tsx
Normal file
138
client/src/components/WorkgroupPage/WorkgroupPage.tsx
Normal 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
20
client/src/gql/client.tsx
Normal 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,
|
||||
});
|
15
client/src/gql/mutations/profile.tsx
Normal file
15
client/src/gql/mutations/profile.tsx
Normal 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);
|
||||
}
|
96
client/src/gql/mutations/workgroups.tsx
Normal file
96
client/src/gql/mutations/workgroups.tsx
Normal 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);
|
||||
}
|
16
client/src/gql/queries/profile.tsx
Normal file
16
client/src/gql/queries/profile.tsx
Normal 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);
|
||||
}
|
21
client/src/gql/queries/workgroups.tsx
Normal file
21
client/src/gql/queries/workgroups.tsx
Normal 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);
|
||||
}
|
@ -2,15 +2,18 @@ import './sass/_all.scss';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
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/solid'
|
||||
import '@fortawesome/fontawesome-free/js/regular'
|
||||
import '@fortawesome/fontawesome-free/js/brands'
|
||||
import './resources/favicon.png';
|
||||
import { ApolloProvider } from '@apollo/client';
|
||||
|
||||
ReactDOM.render(
|
||||
<App />,
|
||||
<ApolloProvider client={client}>
|
||||
<App />
|
||||
</ApolloProvider>,
|
||||
document.getElementById('app')
|
||||
);
|
||||
|
@ -1,4 +1,3 @@
|
||||
@import 'bulma/bulma.sass';
|
||||
@import 'bulma-switch/dist/css/bulma-switch.sass';
|
||||
@import '_base.scss';
|
||||
@import '_loader.scss';
|
@ -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 };
|
||||
}
|
@ -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 }
|
||||
}
|
@ -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,
|
||||
}
|
||||
};
|
||||
};
|
@ -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'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
}
|
@ -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,
|
||||
});
|
@ -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);
|
||||
};
|
||||
}
|
@ -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());
|
||||
}
|
@ -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 });
|
||||
}
|
@ -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(),
|
||||
]);
|
||||
}
|
@ -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);
|
||||
};
|
@ -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);
|
@ -1,4 +1,5 @@
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
name?: string
|
||||
connectedAt?: Date
|
||||
|
9
client/src/types/workgroup.ts
Normal file
9
client/src/types/workgroup.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { User } from "./user";
|
||||
|
||||
export interface Workgroup {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: Date
|
||||
closedAt: Date
|
||||
members: [User]
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
@ -79,6 +79,7 @@ func applyMigration(ctx context.Context, ctn *service.Container) error {
|
||||
// nolint: gochecknoglobals
|
||||
var initialModels = []interface{}{
|
||||
&model.User{},
|
||||
&model.Workgroup{},
|
||||
}
|
||||
|
||||
func m000initialSchema() orm.Migration {
|
||||
|
@ -3,7 +3,9 @@ package graph
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.cadoles.com/Cadoles/daddy/internal/model"
|
||||
"forge.cadoles.com/Cadoles/daddy/internal/orm"
|
||||
"forge.cadoles.com/Cadoles/daddy/internal/session"
|
||||
"github.com/jinzhu/gorm"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
@ -23,3 +25,24 @@ func getDB(ctx context.Context) (*gorm.DB, error) {
|
||||
|
||||
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
|
||||
}
|
||||
|
@ -2,6 +2,15 @@ input ProfileChanges {
|
||||
name: String
|
||||
}
|
||||
|
||||
input WorkgroupChanges {
|
||||
name: String
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
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!
|
||||
}
|
@ -14,6 +14,26 @@ func (r *mutationResolver) UpdateProfile(ctx context.Context, changes model.Prof
|
||||
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.
|
||||
func (r *Resolver) Mutation() generated.MutationResolver { return &mutationResolver{r} }
|
||||
|
||||
|
@ -1,12 +1,27 @@
|
||||
scalar Time
|
||||
|
||||
type User {
|
||||
id: ID!
|
||||
name: String
|
||||
email: String!
|
||||
connectedAt: Time!
|
||||
createdAt: Time!
|
||||
workgroups:[Workgroup]!
|
||||
}
|
||||
|
||||
type Workgroup {
|
||||
id: ID!
|
||||
name: String
|
||||
createdAt: Time!
|
||||
closedAt: Time
|
||||
members: [User]!
|
||||
}
|
||||
|
||||
input WorkgroupsFilter {
|
||||
ids: [ID]
|
||||
}
|
||||
|
||||
type Query {
|
||||
userProfile: User
|
||||
workgroups(filter: WorkgroupsFilter): [Workgroup]!
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ package graph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"forge.cadoles.com/Cadoles/daddy/internal/graph/generated"
|
||||
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)
|
||||
}
|
||||
|
||||
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.
|
||||
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 userResolver struct{ *Resolver }
|
||||
type workgroupResolver struct{ *Resolver }
|
||||
|
@ -3,26 +3,12 @@ package graph
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.cadoles.com/Cadoles/daddy/internal/session"
|
||||
|
||||
"forge.cadoles.com/Cadoles/daddy/internal/model"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func handleUserProfile(ctx context.Context) (*model.User, error) {
|
||||
db, err := getDB(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)
|
||||
user, _, err := getSessionUser(ctx)
|
||||
if err != nil {
|
||||
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) {
|
||||
db, err := getDB(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
userEmail, err := session.UserEmail(ctx)
|
||||
user, db, err := getSessionUser(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
@ -49,7 +30,7 @@ func handleUpdateUserProfile(ctx context.Context, changes model.ProfileChanges)
|
||||
userChanges.Name = changes.Name
|
||||
}
|
||||
|
||||
user, err := repo.UpdateUserByEmail(ctx, userEmail, userChanges)
|
||||
user, err = repo.UpdateUserByEmail(ctx, user.Email, userChanges)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
142
internal/graph/workgroup_handler.go
Normal file
142
internal/graph/workgroup_handler.go
Normal 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
|
||||
}
|
@ -1,13 +1,17 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID *uint `gorm:"primary_key"`
|
||||
Name *string `json:"name"`
|
||||
Email string `json:"email" gorm:"unique;not null"`
|
||||
ConnectedAt time.Time `json:"connectedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
gorm.Model
|
||||
Name *string `json:"name"`
|
||||
Email string `json:"email" gorm:"unique;not null"`
|
||||
ConnectedAt time.Time `json:"connectedAt"`
|
||||
Workgroups []*Workgroup `gorm:"many2many:users_workgroups;"`
|
||||
}
|
||||
|
||||
type ProfileChanges struct {
|
||||
|
@ -15,8 +15,7 @@ type UserRepository struct {
|
||||
|
||||
func (r *UserRepository) CreateOrConnectUser(ctx context.Context, email string) (*User, error) {
|
||||
user := &User{
|
||||
Email: email,
|
||||
CreatedAt: time.Now(),
|
||||
Email: email,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
err := r.db.First(user, "email = ?", email).Error
|
||||
err := r.db.Model(user).Preload("Workgroups").First(user, "email = ?", email).Error
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not find user")
|
||||
}
|
||||
|
18
internal/model/workgroup.go
Normal file
18
internal/model/workgroup.go
Normal 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"`
|
||||
}
|
140
internal/model/workgroup_repository.go
Normal file
140
internal/model/workgroup_repository.go
Normal 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}
|
||||
}
|
@ -33,7 +33,6 @@ func Mount(r *chi.Mux, config *config.Config) error {
|
||||
AllowCredentials: config.HTTP.CORS.AllowCredentials,
|
||||
Debug: config.Debug,
|
||||
}).Handler)
|
||||
r.Use(oidc.Middleware)
|
||||
r.Use(session.UserEmailMiddleware)
|
||||
|
||||
gql := handler.New(
|
||||
|
@ -21,7 +21,9 @@ func UserEmailMiddleware(next http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
userEmail, err := GetUserEmail(w, r)
|
||||
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)
|
||||
|
Loading…
Reference in New Issue
Block a user