diff --git a/client/package-lock.json b/client/package-lock.json index 635b1c5..8552a0f 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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" - } } } } diff --git a/client/package.json b/client/package.json index d5cf484..ad76110 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/components/App.tsx b/client/src/components/App.tsx index ec0367f..89e3dca 100644 --- a/client/src/components/App.tsx +++ b/client/src/components/App.tsx @@ -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 ( - - - - - - } /> - - - + + + + + + } /> + + ); } } \ No newline at end of file diff --git a/client/src/components/HomePage/Dashboard.tsx b/client/src/components/HomePage/Dashboard.tsx new file mode 100644 index 0000000..916fba1 --- /dev/null +++ b/client/src/components/HomePage/Dashboard.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { WorkgroupsPanel } from './WorkgroupsPanel'; + +export function Dashboard() { + return ( +
+
+ +
+
+
+
+
+

D.A.Ds

+
+
+ +
+
+
TODO
+
+
+
+
+
+
+

Assemblées

+
+
+ +
+
+
TODO
+
+
+
+ ); +} \ No newline at end of file diff --git a/client/src/components/HomePage/HomePage.tsx b/client/src/components/HomePage/HomePage.tsx index 7607cee..2e8e26a 100644 --- a/client/src/components/HomePage/HomePage.tsx +++ b/client/src/components/HomePage/HomePage.tsx @@ -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 ( - +
-
-
-
-
- { - currentUser && currentUser.email ? -

Bonjour {currentUser.name ? currentUser.name : currentUser.email} !

: -

Veuillez vous authentifier.

- } +
+ + { + userProfile ? + : +
+
+
+

Veuillez vous authentifier.

+
-
-
+
+ } +
diff --git a/client/src/components/HomePage/WorkgroupsPanel.tsx b/client/src/components/HomePage/WorkgroupsPanel.tsx new file mode 100644 index 0000000..439b931 --- /dev/null +++ b/client/src/components/HomePage/WorkgroupsPanel.tsx @@ -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 ( + + + + + {wg.name} + + ); + }); + + return ( + + ) +} \ No newline at end of file diff --git a/client/src/components/Loader.tsx b/client/src/components/Loader.tsx deleted file mode 100644 index 5632248..0000000 --- a/client/src/components/Loader.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; - -export class Loader extends React.Component { - render() { - return ( -
-
-
-
-
-
- ) - } -} \ No newline at end of file diff --git a/client/src/components/Navbar.tsx b/client/src/components/Navbar.tsx index aebbf99..0bd718f 100644 --- a/client/src/components/Navbar.tsx +++ b/client/src/components/Navbar.tsx @@ -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(state => state.auth.isAuthenticated); - + const userProfileQuery = useUserProfileQuery(); const [ isActive, setActive ] = useState(false); const toggleMenu = () => { @@ -35,28 +35,30 @@ export function Navbar() {
-
- { - isAuthenticated ? - - + +
+ { + userProfileQuery.data && userProfileQuery.data.userProfile ? + + + + + + + + + + + + : + - - - - - - + - : - - - - - - } -
+ } +
+
diff --git a/client/src/components/ProfilePage/ProfilePage.tsx b/client/src/components/ProfilePage/ProfilePage.tsx index 54c72bf..9194d24 100644 --- a/client/src/components/ProfilePage/ProfilePage.tsx +++ b/client/src/components/ProfilePage/ProfilePage.tsx @@ -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() {

Mon profil

+ { - currentUser ? - : - + } +
diff --git a/client/src/components/UserForm.tsx b/client/src/components/UserForm.tsx index 1ba6c7a..8e94f4e 100644 --- a/client/src/components/UserForm.tsx +++ b/client/src/components/UserForm.tsx @@ -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, } }); diff --git a/client/src/components/WithLoader.tsx b/client/src/components/WithLoader.tsx new file mode 100644 index 0000000..cbc55fb --- /dev/null +++ b/client/src/components/WithLoader.tsx @@ -0,0 +1,18 @@ +import React, { Fragment, PropsWithChildren, FunctionComponent } from 'react'; + +export interface WithLoaderProps { + loading?: boolean|boolean[] +} + +export const WithLoader: FunctionComponent = ({ loading, children }) => { + const isLoading = Array.isArray(loading) ? loading.some(l => l) : loading; + return ( + + { + isLoading ? +
Chargement
: + children + } +
+ ) +} \ No newline at end of file diff --git a/client/src/components/WorkgroupPage/InfoForm.tsx b/client/src/components/WorkgroupPage/InfoForm.tsx new file mode 100644 index 0000000..cb3a3f8 --- /dev/null +++ b/client/src/components/WorkgroupPage/InfoForm.tsx @@ -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) { + const value = evt.currentTarget.value; + setState(state => { + return { + ...state, + changed: true, + workgroup: { + ...state.workgroup, + [attrName]: value, + } + }; + }); + }; + + return ( +
+
+ +
+ +
+
+ { + state.workgroup.createdAt ? +
+ +
+

{state.workgroup.createdAt}

+
+
: + null + } + { + state.workgroup.closedAt ? +
+ +
+

{state.workgroup.closedAt}

+
+
: + null + } +
+ +
+
+ ); +} \ No newline at end of file diff --git a/client/src/components/WorkgroupPage/InfoPanel.tsx b/client/src/components/WorkgroupPage/InfoPanel.tsx new file mode 100644 index 0000000..ac69d55 --- /dev/null +++ b/client/src/components/WorkgroupPage/InfoPanel.tsx @@ -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 = ({ 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 ( + + ); +} \ No newline at end of file diff --git a/client/src/components/WorkgroupPage/MembersPanel.tsx b/client/src/components/WorkgroupPage/MembersPanel.tsx new file mode 100644 index 0000000..7092ace --- /dev/null +++ b/client/src/components/WorkgroupPage/MembersPanel.tsx @@ -0,0 +1,35 @@ +import React, { FunctionComponent } from 'react'; +import { User } from '../../types/user'; + +export interface MembersPanelProps { + users: User[] +} + +export const MembersPanel: FunctionComponent = ({ users }) => { + return ( + + ); +} \ No newline at end of file diff --git a/client/src/components/WorkgroupPage/WorkgroupPage.tsx b/client/src/components/WorkgroupPage/WorkgroupPage.tsx new file mode 100644 index 0000000..a0a114c --- /dev/null +++ b/client/src/components/WorkgroupPage/WorkgroupPage.tsx @@ -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 ( + +
+
+
+
+ { + isNew ? +
+
+

Nouveau

+

Groupe de travail

+
+
: +
+
+

{state.workgroup.name}

+

Groupe de travail { isClosed ? '(clos)' : null }

+
+
+ } +
+
+
+ { + isNew || isClosed ? null : + + { + isWorkgroupMember ? + + + + : + + } + + } +
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/client/src/gql/client.tsx b/client/src/gql/client.tsx new file mode 100644 index 0000000..3aff245 --- /dev/null +++ b/client/src/gql/client.tsx @@ -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({ + cache: new InMemoryCache(), + link: link, +}); \ No newline at end of file diff --git a/client/src/gql/mutations/profile.tsx b/client/src/gql/mutations/profile.tsx new file mode 100644 index 0000000..9a681a4 --- /dev/null +++ b/client/src/gql/mutations/profile.tsx @@ -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); +} \ No newline at end of file diff --git a/client/src/gql/mutations/workgroups.tsx b/client/src/gql/mutations/workgroups.tsx new file mode 100644 index 0000000..b9f6770 --- /dev/null +++ b/client/src/gql/mutations/workgroups.tsx @@ -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); +} \ No newline at end of file diff --git a/client/src/gql/queries/profile.tsx b/client/src/gql/queries/profile.tsx new file mode 100644 index 0000000..573fc04 --- /dev/null +++ b/client/src/gql/queries/profile.tsx @@ -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); +} \ No newline at end of file diff --git a/client/src/gql/queries/workgroups.tsx b/client/src/gql/queries/workgroups.tsx new file mode 100644 index 0000000..4693edd --- /dev/null +++ b/client/src/gql/queries/workgroups.tsx @@ -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); +} \ No newline at end of file diff --git a/client/src/index.tsx b/client/src/index.tsx index 9dde128..cee36c1 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -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( - , + + + , document.getElementById('app') ); diff --git a/client/src/sass/_all.scss b/client/src/sass/_all.scss index 8f13fd0..c08fa8c 100644 --- a/client/src/sass/_all.scss +++ b/client/src/sass/_all.scss @@ -1,4 +1,3 @@ @import 'bulma/bulma.sass'; -@import 'bulma-switch/dist/css/bulma-switch.sass'; @import '_base.scss'; @import '_loader.scss'; \ No newline at end of file diff --git a/client/src/store/actions/auth.ts b/client/src/store/actions/auth.ts deleted file mode 100644 index 8a8c608..0000000 --- a/client/src/store/actions/auth.ts +++ /dev/null @@ -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 }; -} \ No newline at end of file diff --git a/client/src/store/actions/profile.ts b/client/src/store/actions/profile.ts deleted file mode 100644 index 8e51900..0000000 --- a/client/src/store/actions/profile.ts +++ /dev/null @@ -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 } -} \ No newline at end of file diff --git a/client/src/store/reducers/auth.ts b/client/src/store/reducers/auth.ts deleted file mode 100644 index 2192326..0000000 --- a/client/src/store/reducers/auth.ts +++ /dev/null @@ -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, - } - }; -}; \ No newline at end of file diff --git a/client/src/store/reducers/flags.ts b/client/src/store/reducers/flags.ts deleted file mode 100644 index f42701b..0000000 --- a/client/src/store/reducers/flags.ts +++ /dev/null @@ -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' - } - } - }; - -} \ No newline at end of file diff --git a/client/src/store/reducers/root.ts b/client/src/store/reducers/root.ts deleted file mode 100644 index 5835800..0000000 --- a/client/src/store/reducers/root.ts +++ /dev/null @@ -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, -}); \ No newline at end of file diff --git a/client/src/store/sagas/failure.ts b/client/src/store/sagas/failure.ts deleted file mode 100644 index de90822..0000000 --- a/client/src/store/sagas/failure.ts +++ /dev/null @@ -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); - }; -} \ No newline at end of file diff --git a/client/src/store/sagas/init.ts b/client/src/store/sagas/init.ts deleted file mode 100644 index b809870..0000000 --- a/client/src/store/sagas/init.ts +++ /dev/null @@ -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()); -} \ No newline at end of file diff --git a/client/src/store/sagas/profile.ts b/client/src/store/sagas/profile.ts deleted file mode 100644 index 9e0864a..0000000 --- a/client/src/store/sagas/profile.ts +++ /dev/null @@ -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 }); -} \ No newline at end of file diff --git a/client/src/store/sagas/root.ts b/client/src/store/sagas/root.ts deleted file mode 100644 index 6a1c770..0000000 --- a/client/src/store/sagas/root.ts +++ /dev/null @@ -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(), - ]); -} diff --git a/client/src/store/selectors/flags.ts b/client/src/store/selectors/flags.ts deleted file mode 100644 index 1f51cb4..0000000 --- a/client/src/store/selectors/flags.ts +++ /dev/null @@ -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); -}; \ No newline at end of file diff --git a/client/src/store/store.ts b/client/src/store/store.ts deleted file mode 100644 index 911fd81..0000000 --- a/client/src/store/store.ts +++ /dev/null @@ -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); \ No newline at end of file diff --git a/client/src/types/user.ts b/client/src/types/user.ts index 4fc773d..2fae681 100644 --- a/client/src/types/user.ts +++ b/client/src/types/user.ts @@ -1,4 +1,5 @@ export interface User { + id: string email: string name?: string connectedAt?: Date diff --git a/client/src/types/workgroup.ts b/client/src/types/workgroup.ts new file mode 100644 index 0000000..459f29a --- /dev/null +++ b/client/src/types/workgroup.ts @@ -0,0 +1,9 @@ +import { User } from "./user"; + +export interface Workgroup { + id: string + name: string + createdAt: Date + closedAt: Date + members: [User] +} \ No newline at end of file diff --git a/client/src/util/daddy.ts b/client/src/util/daddy.ts deleted file mode 100644 index 8c439fd..0000000 --- a/client/src/util/daddy.ts +++ /dev/null @@ -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 - - 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({ - 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; - } - -} \ No newline at end of file diff --git a/cmd/server/migration.go b/cmd/server/migration.go index 083ee3e..f4fc39a 100644 --- a/cmd/server/migration.go +++ b/cmd/server/migration.go @@ -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 { diff --git a/internal/graph/helper.go b/internal/graph/helper.go index 73ec676..3351cc7 100644 --- a/internal/graph/helper.go +++ b/internal/graph/helper.go @@ -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 +} diff --git a/internal/graph/mutation.graphql b/internal/graph/mutation.graphql index 3f0f71a..7dcc5da 100644 --- a/internal/graph/mutation.graphql +++ b/internal/graph/mutation.graphql @@ -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! } \ No newline at end of file diff --git a/internal/graph/mutation.resolvers.go b/internal/graph/mutation.resolvers.go index c979e19..7f8872c 100644 --- a/internal/graph/mutation.resolvers.go +++ b/internal/graph/mutation.resolvers.go @@ -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} } diff --git a/internal/graph/query.graphql b/internal/graph/query.graphql index b11434c..bca4f56 100644 --- a/internal/graph/query.graphql +++ b/internal/graph/query.graphql @@ -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]! } diff --git a/internal/graph/query.resolvers.go b/internal/graph/query.resolvers.go index 33af4b3..f73897e 100644 --- a/internal/graph/query.resolvers.go +++ b/internal/graph/query.resolvers.go @@ -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 } diff --git a/internal/graph/user_profile.go b/internal/graph/user_profile_handler.go similarity index 55% rename from internal/graph/user_profile.go rename to internal/graph/user_profile_handler.go index 8298352..62564ea 100644 --- a/internal/graph/user_profile.go +++ b/internal/graph/user_profile_handler.go @@ -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) } diff --git a/internal/graph/workgroup_handler.go b/internal/graph/workgroup_handler.go new file mode 100644 index 0000000..07f89dd --- /dev/null +++ b/internal/graph/workgroup_handler.go @@ -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 +} diff --git a/internal/model/user.go b/internal/model/user.go index f2877b5..79a88e0 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -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 { diff --git a/internal/model/user_repository.go b/internal/model/user_repository.go index ec24928..f84a70c 100644 --- a/internal/model/user_repository.go +++ b/internal/model/user_repository.go @@ -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") } diff --git a/internal/model/workgroup.go b/internal/model/workgroup.go new file mode 100644 index 0000000..64b2c0a --- /dev/null +++ b/internal/model/workgroup.go @@ -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"` +} diff --git a/internal/model/workgroup_repository.go b/internal/model/workgroup_repository.go new file mode 100644 index 0000000..283c1fe --- /dev/null +++ b/internal/model/workgroup_repository.go @@ -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} +} diff --git a/internal/route/mount.go b/internal/route/mount.go index 973424d..af4d08b 100644 --- a/internal/route/mount.go +++ b/internal/route/mount.go @@ -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( diff --git a/internal/session/user_email.go b/internal/session/user_email.go index b1dc0c3..171f23e 100644 --- a/internal/session/user_email.go +++ b/internal/session/user_email.go @@ -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)