Remplacement de Redux/Saga par Apollo

This commit is contained in:
wpetit 2020-07-21 22:25:39 +02:00
parent 8708e30020
commit c4373cce46
33 changed files with 230 additions and 728 deletions

173
client/package-lock.json generated
View File

@ -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",
@ -3239,11 +3181,6 @@
"resolved": "https://registry.npmjs.org/bulma/-/bulma-0.9.0.tgz",
"integrity": "sha512-rV75CJkubNUroAt0qCRkjznZLoaXq/ctfMXsMvKSL84UetbSyx5REl96e8GoQ04G4Tkw0XF3STECffTOQrbzOQ=="
},
"bulma-switch": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/bulma-switch/-/bulma-switch-2.0.0.tgz",
"integrity": "sha512-myD38zeUfjmdduq+pXabhJEe3x2hQP48l/OI+Y0fO3HdDynZUY/VJygucvEAJKRjr4HxD5DnEm4yx+oDOBXpAA=="
},
"bytes": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
@ -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"
}
}
}
}

View File

@ -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.9.0",
"bulma-switch": "^2.0.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",

View File

@ -1,22 +1,18 @@
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';
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 component={() => <Redirect to="/" />} />
</Switch>
</BrowserRouter>
);
}
}

View File

@ -1,7 +1,4 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchWorkgroups } from '../../store/actions/workgroups';
import { RootState } from '../../store/reducers/root';
import React from 'react';
import { WorkgroupsPanel } from './WorkgroupsPanel';
export function Dashboard() {

View File

@ -1,18 +1,26 @@
import React, { useEffect } from 'react';
import React from 'react';
import { Page } from '../Page';
import { useSelector } from 'react-redux';
import { RootState } from '../../store/reducers/root';
import { Dashboard } from './Dashboard';
import { useUserProfileQuery } from '../../gql/queries/profile';
import { Loader } from '../Loader';
export function HomePage() {
const currentUser = useSelector((state: RootState) => state.auth.currentUser);
const { data, loading } = useUserProfileQuery();
if (loading) {
return (
<Loader />
);
}
const { userProfile } = (data || {});
return (
<Page title={currentUser ? 'Tableau de bord' : 'Accueil'}>
<Page title={userProfile ? 'Tableau de bord' : 'Accueil'}>
<div className="container is-fluid">
<section className="section">
{
currentUser ?
userProfile ?
<Dashboard /> :
<div className="columns">
<div className="column is-4 is-offset-4">

View File

@ -1,44 +1,49 @@
import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { RootState } from '../../store/reducers/root';
import { fetchWorkgroups } from '../../store/actions/workgroups';
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 { Loader } from '../Loader';
export function WorkgroupsPanel() {
const dispatch = useDispatch();
const workgroups = useSelector<RootState>(state => state.workgroups.workgroupsById);
const currentUserId = useSelector<RootState>(state => state.auth.currentUser.id);
const workgroupsQuery = useWorkgroupsQuery();
const userProfileQuery = useUserProfileQuery();
const [ state, setState ] = useState({ selectedTab: 0 });
const isLoading = userProfileQuery.loading || workgroupsQuery.loading;
if (isLoading) {
return <Loader />;
}
let { data: { userProfile }} = userProfileQuery;
let { data: { workgroups }} = workgroupsQuery;
const filterTabs = [
{
label: "Mes groupes",
filter: workgroups => Object.values(workgroups).filter((wg: Workgroup) => {
return wg.members.some((u: User) => u.id === currentUserId);
filter: workgroups => workgroups.filter((wg: Workgroup) => {
return wg.members.some((u: User) => u.id === userProfile.id);
})
},
{
label: "Ouverts",
filter: workgroups => Object.values(workgroups).filter((wg: Workgroup) => !wg.closedAt)
filter: workgroups => workgroups.filter((wg: Workgroup) => !wg.closedAt)
},
{
label: "Clôs",
filter: workgroups => Object.values(workgroups).filter((wg: Workgroup) => !!wg.closedAt)
filter: workgroups => workgroups.filter((wg: Workgroup) => !!wg.closedAt)
}
];
const [ state, setState ] = useState({ selectedTab: 0 });
const selectTab = (tabIndex: number) => {
setState(state => ({ ...state, selectedTab: tabIndex }));
}
useEffect(() => {
dispatch(fetchWorkgroups());
}, []);
const workgroupsItems = filterTabs[state.selectedTab].filter(workgroups).map((wg: Workgroup) => {
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">
@ -47,7 +52,7 @@ export function WorkgroupsPanel() {
{wg.name}
</Link>
);
})
});
return (
<nav className="panel is-info">

View File

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

View File

@ -1,19 +1,22 @@
import React, { useEffect } 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 [ updatProfile, 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) {
updatProfile({ variables: {changes: { name: user.name }}});
}
};
@ -24,11 +27,13 @@ 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}>
{
currentUser ?
<UserForm onChange={onUserChange} user={currentUser} /> :
userProfile ?
<UserForm onChange={onUserChange} user={userProfile} /> :
<Loader />
}
</WithLoader>
</div>
</div>
</section>

View File

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

View File

@ -0,0 +1,22 @@
import React, { Fragment, PropsWithChildren, FunctionComponent } from 'react';
export interface WithLoaderProps {
loading?: boolean
}
export const WithLoader: FunctionComponent<WithLoaderProps> = ({ loading, children }) => {
return (
<Fragment>
{
loading ?
<div className="loader-container">
<div className="lds-ripple">
<div></div>
<div></div>
</div>
</div> :
children
}
</Fragment>
)
}

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

@ -0,0 +1,21 @@
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().split(
(operation) => operation.operationName === 'subscription',
new WebSocketLink(subscriptionClient),
new HttpLink({ uri: Config.graphQLEndpoint, credentials: 'include' })
);
export const client = new ApolloClient<any>({
cache: new InMemoryCache(),
link: link,
});

View File

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

View File

@ -0,0 +1,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, { fetchPolicy: "network-only" });
}

View File

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

View File

@ -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')
);

View File

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

View File

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

View File

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

View File

@ -1,19 +0,0 @@
import { Action } from "redux";
import { Workgroup } from "../../types/workgroup";
export const FETCH_WORKGROUPS_REQUEST = 'FETCH_WORKGROUPS_REQUEST';
export const FETCH_WORKGROUPS_SUCCESS = 'FETCH_WORKGROUPS_SUCCESS';
export const FETCH_WORKGROUPS_FAILURE = 'FETCH_WORKGROUPS_FAILURE';
export interface fetchWorkgroupsRequestAction extends Action {
}
export interface fetchWorkgroupsSuccessAction extends Action {
workgroups: [Workgroup]
}
export function fetchWorkgroups(): fetchWorkgroupsRequestAction {
return { type: FETCH_WORKGROUPS_REQUEST }
}

View File

@ -1,58 +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: {
id: '',
email
}
};
};
function handleFetchProfileSuccess(state: AuthState, { profile }: fetchProfileSuccessAction): AuthState {
return {
...state,
isAuthenticated: true,
currentUser: {
...profile,
}
};
};
function handleUpdateProfileSuccess(state: AuthState, { profile }: updateProfileSuccessAction): AuthState {
return {
...state,
isAuthenticated: true,
currentUser: {
...profile,
}
};
};

View File

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

View File

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

View File

@ -1,38 +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";
import { Workgroup } from "../../types/workgroup";
import { FETCH_WORKGROUPS_SUCCESS, fetchWorkgroupsSuccessAction } from "../actions/workgroups";
export interface WorkgroupsState {
workgroupsById: { [id in string]: Workgroup }
}
const defaultState = {
workgroupsById: {}
};
export function workgroupsReducer(state = defaultState, action: Action): WorkgroupsState {
switch (action.type) {
case FETCH_WORKGROUPS_SUCCESS:
return handleFetchWorkgroups(state, action as fetchWorkgroupsSuccessAction);
}
return state;
}
function handleFetchWorkgroups(state: WorkgroupsState, { workgroups }: fetchWorkgroupsSuccessAction): WorkgroupsState {
const workgroupsById = {
...state.workgroupsById,
};
workgroups.forEach(wg => {
workgroupsById[wg.id] = wg;
});
return {
...state,
workgroupsById,
};
};

View File

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

View File

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

View File

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

View File

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

View File

@ -1,25 +0,0 @@
import { getClient } from "../../util/daddy";
import { Config } from "../../config";
import { all, takeLatest, put } from "redux-saga/effects";
import { FETCH_WORKGROUPS_SUCCESS, FETCH_WORKGROUPS_FAILURE, FETCH_WORKGROUPS_REQUEST } from "../actions/workgroups";
import { Workgroup } from "../../types/workgroup";
export function* workgroupsRootSaga() {
yield all([
takeLatest(FETCH_WORKGROUPS_REQUEST, fetchWorkgroupsSaga),
]);
}
export function* fetchWorkgroupsSaga() {
const client = getClient(Config.graphQLEndpoint, Config.subscriptionEndpoint);
let workgroups: [Workgroup];
try {
workgroups = yield client.fetchWorkgroups().then(result => result.workgroups);
} catch(err) {
yield put({ type: FETCH_WORKGROUPS_FAILURE, err });
return;
}
yield put({type: FETCH_WORKGROUPS_SUCCESS, workgroups });
}

View File

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

View File

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

View File

@ -1,134 +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 {
id,
name,
email,
createdAt,
connectedAt
}
}`
})
.then(this.assertAuthorization)
}
fetchWorkgroups() {
return this.gql.query({
query: gql`
query {
workgroups {
id,
name,
createdAt,
closedAt,
members {
id
}
}
}`
})
.then(this.assertAuthorization)
}
updateProfile(changes: ProfileChanges) {
return this.gql.mutate({
variables: {
changes,
},
mutation: gql`
mutation updateProfile($changes: ProfileChanges!) {
updateProfile(changes: $changes) {
name,
email,
createdAt,
connectedAt
}
}`,
})
.then(this.assertAuthorization)
}
assertAuthorization({ status, data }: any) {
if (status === 401) return Promise.reject(new UnauthorizedError());
return data;
}
}

View File

@ -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(

View File

@ -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)