├── .eslintrc.json
├── .gitignore
├── .prettierignore
├── README.md
├── package-lock.json
├── package.json
├── public
├── favicon.ico
├── index.html
├── logo192.png
├── logo512.png
├── manifest.json
└── robots.txt
└── src
├── App.js
├── App.test.js
├── RealmApp.js
├── TaskApp.js
├── components
├── ButtonGroup.js
├── Card.js
├── EditPermissionsModal.js
├── Loading.js
├── LoginScreen.js
├── ProjectScreen.js
├── Sidebar.js
├── StatusBadge.js
├── StatusChange.js
├── TaskContent.js
├── TaskDetailModal.js
└── useChangeTaskStatusButton.js
├── graphql
├── RealmApolloProvider.js
├── useProjects.js
├── useTaskMutations.js
├── useTaskQueries.js
└── useTasks.js
├── index.css
├── index.js
├── serviceWorker.js
└── setupTests.js
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "parserOptions": {
4 | "sourceType": "module",
5 | "ecmaVersion": 2020
6 | },
7 | "extends": ["eslint:recommended", "prettier", "plugin:react/recommended"],
8 | "plugins": ["prettier"],
9 | "rules": {
10 | "prettier/prettier": "error",
11 | "react/prop-types": 0
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | **/node_modules
2 | ./public
3 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Realm Web and GraphQL Tutorial
2 |
3 | Follow along at https://www.mongodb.com/docs/realm/tutorial/web-graphql/
4 |
5 | ## Troubleshooting
6 |
7 | - Be sure to **check the logs in Realm UI** for more information as well as the console in your app.
8 | - Be sure to **deploy your changes** in the Realm UI.
9 |
10 | ## Issues & Pull Requests
11 |
12 | If you find an issue or have a suggestion, please let us know using the feedback
13 | widget on the [docs site](http://www.mongodb.com/docs/realm/tutorial).
14 |
15 | This repo is automatically derived from our main docs repo. If you'd like to
16 | submit a pull request -- thanks! -- please feel free to do so at
17 | https://github.com/mongodb/docs-realm/ (see the tutorial/ subdirectory).
18 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "task-tracker",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@apollo/client": "^3.1.4",
7 | "@emotion/core": "^10.0.35",
8 | "@emotion/styled": "^10.0.27",
9 | "@leafygreen-ui/badge": "^3.0.2",
10 | "@leafygreen-ui/button": "^6.0.2",
11 | "@leafygreen-ui/card": "^4.1.0",
12 | "@leafygreen-ui/icon": "^6.6.0",
13 | "@leafygreen-ui/icon-button": "^8.0.0",
14 | "@leafygreen-ui/leafygreen-provider": "^1.1.4",
15 | "@leafygreen-ui/modal": "^4.1.0",
16 | "@leafygreen-ui/palette": "^2.0.2",
17 | "@leafygreen-ui/text-input": "^3.0.4",
18 | "@testing-library/jest-dom": "^4.2.4",
19 | "@testing-library/react": "^9.3.2",
20 | "@testing-library/user-event": "^7.1.2",
21 | "graphql": "^15.3.0",
22 | "graphql-tag": "^2.11.0",
23 | "react": "^16.13.1",
24 | "react-dom": "^16.13.1",
25 | "react-scripts": "5.0.1",
26 | "react-spinners": "^0.13.3",
27 | "realm-web": "^1.5.1",
28 | "validator": "^13.7.0"
29 | },
30 | "scripts": {
31 | "start": "react-scripts start",
32 | "build": "react-scripts build",
33 | "test": "react-scripts test",
34 | "format": "prettier --write \"**/*.js\"",
35 | "eject": "react-scripts eject"
36 | },
37 | "eslintConfig": {
38 | "extends": "react-app"
39 | },
40 | "browserslist": {
41 | "production": [
42 | ">0.2%",
43 | "not dead",
44 | "not op_mini all"
45 | ],
46 | "development": [
47 | "last 1 chrome version",
48 | "last 1 firefox version",
49 | "last 1 safari version"
50 | ]
51 | },
52 | "devDependencies": {
53 | "prettier": "^2.5.0"
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mongodb-university/realm-tutorial-web/480e49c25182f969db0b66991d8a23ccc7154e58/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | React App
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mongodb-university/realm-tutorial-web/480e49c25182f969db0b66991d8a23ccc7154e58/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mongodb-university/realm-tutorial-web/480e49c25182f969db0b66991d8a23ccc7154e58/public/logo512.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import LoginScreen from "./components/LoginScreen";
3 | import TaskApp from "./TaskApp";
4 | import RealmApolloProvider from "./graphql/RealmApolloProvider";
5 | import { useRealmApp, RealmAppProvider } from "./RealmApp";
6 |
7 | export const APP_ID = "";
8 |
9 | const RequireLoggedInUser = ({ children }) => {
10 | // Only render children if there is a logged in user.
11 | const app = useRealmApp();
12 | return app.currentUser ? children : ;
13 | };
14 |
15 | export default function App() {
16 | return (
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/App.test.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { render } from "@testing-library/react";
3 | import App from "./App";
4 |
5 | test("renders learn react link", () => {
6 | const { getByText } = render();
7 | const linkElement = getByText(/learn react/i);
8 | expect(linkElement).toBeInTheDocument();
9 | });
10 |
--------------------------------------------------------------------------------
/src/RealmApp.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import * as Realm from "realm-web";
3 |
4 | const RealmAppContext = React.createContext();
5 |
6 | export const useRealmApp = () => {
7 | const app = React.useContext(RealmAppContext);
8 | if (!app) {
9 | throw new Error(
10 | `You must call useRealmApp() inside of a `
11 | );
12 | }
13 | return app;
14 | };
15 |
16 | export const RealmAppProvider = ({ appId, children }) => {
17 | const [app, setApp] = React.useState(new Realm.App(appId));
18 | React.useEffect(() => {
19 | setApp(new Realm.App(appId));
20 | }, [appId]);
21 |
22 | // Wrap the Realm.App object's user state with React state
23 | const [currentUser, setCurrentUser] = React.useState(app.currentUser);
24 | async function logIn(credentials) {
25 | await app.logIn(credentials);
26 | // If successful, app.currentUser is the user that just logged in
27 | setCurrentUser(app.currentUser);
28 | }
29 | async function logOut() {
30 | // Log out the currently active user
31 | await app.currentUser?.logOut();
32 | // If another user was logged in too, they're now the current user.
33 | // Otherwise, app.currentUser is null.
34 | setCurrentUser(app.currentUser);
35 | }
36 |
37 | const wrapped = { ...app, currentUser, logIn, logOut };
38 |
39 | return (
40 |
41 | {children}
42 |
43 | );
44 | };
45 |
--------------------------------------------------------------------------------
/src/TaskApp.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { css } from "@emotion/core";
3 | import styled from "@emotion/styled";
4 | import { useRealmApp } from "./RealmApp";
5 | import ProjectScreen from "./components/ProjectScreen";
6 | import Sidebar from "./components/Sidebar";
7 |
8 | export default function TaskApp() {
9 | const app = useRealmApp();
10 | const [currentProject, setCurrentProject] = React.useState(
11 | // set the current project as "My Project"
12 | app.currentUser.customData.memberOf[0]
13 | );
14 | const [isEditingPermissions, setIsEditingPermissions] = React.useState(false);
15 | return (
16 |
17 |
23 |
29 |
30 | );
31 | }
32 |
33 | const sidebarWidth = "420px";
34 | const Container = styled.div`
35 | box-sizing: border-box;
36 | height: 100vh;
37 | width: 100vh;
38 | display: grid;
39 | grid-template-columns: ${sidebarWidth} calc(100vw - ${sidebarWidth});
40 | grid-template-rows: 1fr;
41 | grid-template-areas: "sidebar main";
42 | `;
43 |
44 | const gridAreaSidebar = css`
45 | grid-area: sidebar;
46 | `;
47 | const gridAreaMain = css`
48 | grid-area: sidebar;
49 | `;
50 |
--------------------------------------------------------------------------------
/src/components/ButtonGroup.js:
--------------------------------------------------------------------------------
1 | import styled from "@emotion/styled";
2 | import { css } from "@emotion/core";
3 |
4 | const ButtonGroup = styled.div(
5 | (props) => css`
6 | display: flex;
7 | flex-direction: ${props.direction === "row" ? "row" : "column"};
8 | justify-content: center;
9 | gap: 8px;
10 | margin-top: 8px;
11 | `
12 | );
13 | export default ButtonGroup;
14 |
--------------------------------------------------------------------------------
/src/components/Card.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import LGCard from "@leafygreen-ui/card";
3 | import styled from "@emotion/styled";
4 |
5 | const Layout = styled.div`
6 | padding: 8px;
7 | color: black;
8 | display: flex;
9 | flex-direction: column;
10 | text-align: left;
11 | `;
12 |
13 | export default function Card({ children, ...props }) {
14 | return (
15 |
16 | {children}
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/EditPermissionsModal.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { css } from "@emotion/core";
3 | import styled from "@emotion/styled";
4 |
5 | import LGButton from "@leafygreen-ui/button";
6 | import Modal from "@leafygreen-ui/modal";
7 | import XIcon from "@leafygreen-ui/icon/dist/X";
8 | import PlusIcon from "@leafygreen-ui/icon/dist/Plus";
9 | import IconButton from "@leafygreen-ui/icon-button";
10 | import TextInput from "@leafygreen-ui/text-input";
11 | import { uiColors } from "@leafygreen-ui/palette";
12 | import { useRealmApp } from "../RealmApp";
13 |
14 | function useTeamMembers() {
15 | const [teamMembers, setTeamMembers] = React.useState(null);
16 | const [newUserEmailError, setNewUserEmailError] = React.useState(null);
17 | const app = useRealmApp();
18 | const { addTeamMember, removeTeamMember, getMyTeamMembers } =
19 | app.currentUser.functions;
20 | const updateTeamMembers = () => {
21 | getMyTeamMembers().then(setTeamMembers);
22 | };
23 | // display team members on load
24 | React.useEffect(updateTeamMembers, []);
25 | return {
26 | teamMembers,
27 | errorMessage: newUserEmailError,
28 | addTeamMember: async (email) => {
29 | const { error } = await addTeamMember(email);
30 | if (error) {
31 | setNewUserEmailError(error);
32 | return { error };
33 | } else {
34 | updateTeamMembers();
35 | }
36 | },
37 | removeTeamMember: async (email) => {
38 | await removeTeamMember(email);
39 | updateTeamMembers();
40 | },
41 | };
42 | }
43 |
44 | export default function EditPermissionsModal({
45 | isEditingPermissions,
46 | setIsEditingPermissions,
47 | }) {
48 | const { teamMembers, errorMessage, addTeamMember, removeTeamMember } =
49 | useTeamMembers();
50 | return (
51 |
56 |
57 | Team Members
58 |
59 | These users can add, read, modify, and delete tasks in your project
60 |
61 | Add a new user by email:
62 |
66 |
67 | {teamMembers?.length ? (
68 | teamMembers.map((teamMember) => {
69 | return (
70 |
71 |
72 | {teamMember.name}
73 | {
77 | await removeTeamMember(teamMember.name);
78 | }}
79 | >
80 |
81 |
82 |
83 |
84 | );
85 | })
86 | ) : (
87 | No team members
88 | )}
89 |
90 |
91 |
92 | );
93 | }
94 |
95 | function AddTeamMemberInput({ addTeamMember, errorMessage }) {
96 | const [inputValue, setInputValue] = React.useState("");
97 | return (
98 |
99 |
100 | {
107 | setInputValue(e.target.value);
108 | }}
109 | value={inputValue}
110 | />
111 |
112 |
125 |
126 | );
127 | }
128 |
129 | const Button = styled(LGButton)`
130 | height: 36px;
131 | `;
132 |
133 | const Row = styled.div`
134 | display: flex;
135 | align-items: end;
136 | `;
137 | const InputContainer = styled.div`
138 | flex-grow: 1;
139 | `;
140 | const ModalHeading = styled.h2`
141 | margin: 0;
142 | font-size: 24px;
143 | `;
144 | const ModalText = styled.p`
145 | margin: 8px 2px;
146 | `;
147 |
148 | const ContentContainer = styled.div`
149 | display: flex;
150 | flex-direction: column;
151 | `;
152 | const List = styled.ul`
153 | list-style: none;
154 | padding: 8px 0;
155 | margin: 0;
156 | `;
157 | const ListItem = styled.li(
158 | () => css`
159 | padding: 8px 12px;
160 | border-radius: 8px;
161 | :hover {
162 | background: ${uiColors.gray.light2};
163 | }
164 | `
165 | );
166 | const TeamMemberContainer = styled.div`
167 | display: flex;
168 | align-items: center;
169 | `;
170 | const TeamMemberName = styled.div`
171 | flex-grow: 1;
172 | padding: 8px 0;
173 | `;
174 |
--------------------------------------------------------------------------------
/src/components/Loading.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { uiColors } from "@leafygreen-ui/palette";
3 | import GridLoader from "react-spinners/GridLoader";
4 |
5 | export default function Loading() {
6 | return ;
7 | }
8 |
--------------------------------------------------------------------------------
/src/components/LoginScreen.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import * as Realm from "realm-web";
3 | import { useRealmApp } from "../RealmApp";
4 | import styled from "@emotion/styled";
5 | import Button from "@leafygreen-ui/button";
6 | import TextInput from "@leafygreen-ui/text-input";
7 | import LGCard from "./Card";
8 | import { uiColors } from "@leafygreen-ui/palette";
9 | import validator from "validator";
10 | import Loading from "./Loading";
11 |
12 | export default function LoginScreen() {
13 | const app = useRealmApp();
14 | // Toggle between logging users in and registering new users
15 | const [mode, setMode] = React.useState("login");
16 | const toggleMode = () => {
17 | setMode((oldMode) => (oldMode === "login" ? "register" : "login"));
18 | };
19 | // Keep track of form input state
20 | const [email, setEmail] = React.useState("");
21 | const [password, setPassword] = React.useState("");
22 | // Keep track of input validation/errors
23 | const [error, setError] = React.useState({});
24 | // Whenever the mode changes, clear the form inputs
25 | React.useEffect(() => {
26 | setEmail("sampleemail@example.com");
27 | setPassword("password");
28 | setError({});
29 | }, [mode]);
30 |
31 | const [isLoggingIn, setIsLoggingIn] = React.useState(false);
32 | const handleLogin = async () => {
33 | setIsLoggingIn(true);
34 | setError((e) => ({ ...e, password: null }));
35 | try {
36 | await app.logIn(Realm.Credentials.emailPassword(email, password));
37 | } catch (err) {
38 | handleAuthenticationError(err, setError);
39 | }
40 | };
41 |
42 | const handleRegistrationAndLogin = async () => {
43 | const isValidEmailAddress = validator.isEmail(email);
44 | setError((e) => ({ ...e, password: null }));
45 | if (isValidEmailAddress) {
46 | try {
47 | // Register the user and, if successful, log them in
48 | await app.emailPasswordAuth.registerUser({ email, password });
49 | return await handleLogin();
50 | } catch (err) {
51 | handleAuthenticationError(err, setError);
52 | }
53 | } else {
54 | setError((err) => ({ ...err, email: "Email is invalid." }));
55 | }
56 | };
57 |
58 | return (
59 |
60 | {isLoggingIn ? (
61 |
62 | ) : (
63 |
64 |
65 |
66 | {mode === "login" ? "Log In" : "Register an Account"}
67 |
68 |
69 |
70 | {
75 | setError((e) => ({ ...e, email: null }));
76 | setEmail(e.target.value);
77 | }}
78 | value={email}
79 | state={
80 | error.email
81 | ? "error"
82 | : validator.isEmail(email)
83 | ? "valid"
84 | : "none"
85 | }
86 | errorMessage={error.email}
87 | />
88 |
89 |
90 | {
95 | setPassword(e.target.value);
96 | }}
97 | value={password}
98 | state={
99 | error.password ? "error" : error.password ? "valid" : "none"
100 | }
101 | errorMessage={error.password}
102 | />
103 |
104 | {mode === "login" ? (
105 |
108 | ) : (
109 |
115 | )}
116 |
117 |
118 | {mode === "login"
119 | ? "Don't have an account?"
120 | : "Already have an account?"}
121 |
122 | {
124 | e.preventDefault();
125 | toggleMode();
126 | }}
127 | >
128 | {mode === "login" ? "Register one now." : "Log in instead."}
129 |
130 |
131 |
132 | )}
133 |
134 | );
135 | }
136 |
137 | function handleAuthenticationError(err, setError) {
138 | const { status, message } = parseAuthenticationError(err);
139 | const errorType = message || status;
140 | switch (errorType) {
141 | case "invalid username":
142 | setError((prevErr) => ({ ...prevErr, email: "Invalid email address." }));
143 | break;
144 | case "invalid username/password":
145 | case "invalid password":
146 | case "401":
147 | setError((err) => ({ ...err, password: "Incorrect password." }));
148 | break;
149 | case "name already in use":
150 | case "409":
151 | setError((err) => ({ ...err, email: "Email is already registered." }));
152 | break;
153 | case "password must be between 6 and 128 characters":
154 | case "400":
155 | setError((err) => ({
156 | ...err,
157 | password: "Password must be between 6 and 128 characters.",
158 | }));
159 | break;
160 | default:
161 | break;
162 | }
163 | }
164 |
165 | function parseAuthenticationError(err) {
166 | const parts = err.message.split(":");
167 | const reason = parts[parts.length - 1].trimStart();
168 | if (!reason) return { status: "", message: "" };
169 | const reasonRegex = /(?.+)\s\(status (?[0-9][0-9][0-9])/;
170 | const match = reason.match(reasonRegex);
171 | const { status, message } = match?.groups ?? {};
172 | return { status, message };
173 | }
174 |
175 | const Card = styled(LGCard)`
176 | width: 420px;
177 | `;
178 | const ToggleContainer = styled.div`
179 | margin-top: 8px;
180 | font-size: 12px;
181 | display: flex;
182 | justify-content: center;
183 | `;
184 |
185 | const ToggleText = styled.span`
186 | line-height: 18px;
187 | `;
188 |
189 | const ToggleLink = styled.button`
190 | background: none;
191 | border: none;
192 | font-size: 12px;
193 | color: ${uiColors.green.dark2};
194 | `;
195 |
196 | const Container = styled.div`
197 | height: 100vh;
198 | justify-content: center;
199 | align-items: center;
200 | display: flex;
201 | flex-direction: column;
202 | background: ${uiColors.gray.light2};
203 | `;
204 |
205 | const LoginHeading = styled.h1`
206 | margin: 0;
207 | font-size: 32px;
208 | `;
209 |
210 | const LoginFormRow = styled.div`
211 | margin-bottom: 16px;
212 | `;
213 |
--------------------------------------------------------------------------------
/src/components/ProjectScreen.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "@emotion/styled";
3 | import useTasks from "../graphql/useTasks";
4 | import TaskContent from "./TaskContent";
5 | import TaskDetailModal from "./TaskDetailModal";
6 | import EditPermissionsModal from "./EditPermissionsModal";
7 | import Card from "./Card";
8 | import Button from "@leafygreen-ui/button";
9 | import ButtonGroup from "./ButtonGroup";
10 | import TextInput from "@leafygreen-ui/text-input";
11 | import { uiColors } from "@leafygreen-ui/palette";
12 |
13 | import Loading from "./Loading";
14 |
15 | export default function ProjectScreen({
16 | currentProject,
17 | isEditingPermissions,
18 | setIsEditingPermissions,
19 | }) {
20 | return (
21 |
22 | {currentProject && }
23 |
27 |
28 | );
29 | }
30 |
31 | const Container = styled.div`
32 | height: 100vh;
33 | justify-content: center;
34 | align-items: center;
35 | display: flex;
36 | flex-direction: column;
37 | grid-area: main;
38 | background: ${uiColors.gray.light2};
39 | `;
40 |
41 | function useDraftTask({ addTask }) {
42 | const [draftTask, setDraftTask] = React.useState(null);
43 | const createDraftTask = () => {
44 | setDraftTask({ name: "" });
45 | };
46 | const deleteDraftTask = () => {
47 | setDraftTask(null);
48 | };
49 | const setDraftTaskName = (name) => {
50 | setDraftTask({ name });
51 | };
52 | const submitDraftTask = async () => {
53 | await addTask(draftTask);
54 | setDraftTask(null);
55 | };
56 | return {
57 | draftTask,
58 | createDraftTask,
59 | deleteDraftTask,
60 | setDraftTaskName,
61 | submitDraftTask,
62 | };
63 | }
64 |
65 | function TaskList({ currentProject }) {
66 | const { tasks, addTask, loading } = useTasks(currentProject);
67 | const getTaskById = (id) => tasks.find((task) => task._id === id);
68 | const [selectedTaskId, setSelectedTaskId] = React.useState(null);
69 | const selectedTask = getTaskById(selectedTaskId);
70 |
71 | const {
72 | draftTask,
73 | createDraftTask,
74 | deleteDraftTask,
75 | setDraftTaskName,
76 | submitDraftTask,
77 | } = useDraftTask({ addTask });
78 |
79 | return loading ? (
80 |
81 | ) : (
82 | <>
83 |
84 | {tasks.length === 0 ? (
85 |
86 | No Tasks
87 | Click the button below to add a task to this project
88 |
89 | ) : (
90 | tasks.map((task) => (
91 |
92 | setSelectedTaskId(task._id)}>
93 |
94 |
95 |
96 | ))
97 | )}
98 | {draftTask ? (
99 |
100 |
101 | {
106 | setDraftTaskName(e.target.value);
107 | }}
108 | value={draftTask.name}
109 | />
110 |
111 |
120 |
128 |
129 |
130 |
131 | ) : (
132 |
133 |
134 |
135 |
136 |
137 | )}
138 |
139 |
144 | >
145 | );
146 | }
147 |
148 | const List = styled.ul`
149 | list-style-type: none;
150 | padding-left: 0;
151 | width: 400px;
152 | `;
153 | const ListItem = styled.li`
154 | :not(:first-of-type) {
155 | margin-top: 8px;
156 | }
157 | `;
158 |
159 | const TaskListHeader = styled.div`
160 | line-height: 24px;
161 | letter-spacing: 0px;
162 | text-align: center;
163 | font-size: 16px;
164 | `;
165 |
--------------------------------------------------------------------------------
/src/components/Sidebar.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { css } from "@emotion/core";
3 | import styled from "@emotion/styled";
4 | import { useRealmApp } from "../RealmApp";
5 | import useProjects from "../graphql/useProjects";
6 | import Card from "./Card";
7 | import { uiColors } from "@leafygreen-ui/palette";
8 |
9 | export default function Sidebar({
10 | currentProject,
11 | setCurrentProject,
12 | setIsEditingPermissions,
13 | }) {
14 | const projects = useProjects();
15 | const app = useRealmApp();
16 |
17 | return (
18 |
19 |
20 | Projects
21 |
22 | {projects.map((project) => (
23 | setCurrentProject(project)}
26 | isSelected={project.partition === currentProject?.partition}
27 | >
28 | {project.name}
29 |
30 | ))}
31 |
32 | {
35 | app.logOut();
36 | }}
37 | handleEditPermissions={() => {
38 | setIsEditingPermissions(true);
39 | }}
40 | />
41 |
42 |
43 | );
44 | }
45 |
46 | const SidebarContainer = styled.div`
47 | display: flex;
48 | background: ${uiColors.gray.light2};
49 | flex-direction: column;
50 | padding: 40px;
51 | `;
52 |
53 | const SectionHeading = styled.h2`
54 | margin: 0;
55 | padding: 8px;
56 | `;
57 | const SectionList = styled.ul`
58 | list-style: none;
59 | padding: 0;
60 | margin: 0;
61 | `;
62 | const SectionListItem = styled.li(
63 | (props) => css`
64 | padding: 8px 12px;
65 | border-radius: 8px;
66 | background: ${props.isSelected && uiColors.green.light2};
67 | :hover {
68 | background: ${!props.isSelected && uiColors.gray.light1};
69 | }
70 | `
71 | );
72 |
73 | function UserDetails({ user, handleLogout, handleEditPermissions }) {
74 | return (
75 |
76 | {user.profile.email}
77 | Manage My Project
78 | Log Out
79 |
80 | );
81 | }
82 |
83 | const UserDetailsContainer = styled.div`
84 | padding: 8px 0;
85 | display: flex;
86 | flex-direction: column;
87 | `;
88 |
89 | const Username = styled.div`
90 | font-weight: bold;
91 | text-align: center;
92 | margin-bottom: 4px;
93 | `;
94 |
95 | const TextButton = styled.button`
96 | background: none;
97 | border: none;
98 | padding: 4px;
99 | color: #069;
100 | text-decoration: none;
101 | cursor: pointer;
102 | color: ${uiColors.green.dark2};
103 | `;
104 |
--------------------------------------------------------------------------------
/src/components/StatusBadge.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Badge from "@leafygreen-ui/badge";
3 |
4 | export default function StatusBadge({ status }) {
5 | return (
6 |
7 | {getTaskStatusDisplayName(status)}
8 |
9 | );
10 | }
11 |
12 | const getColorForStatus = (status) => {
13 | switch (status) {
14 | case "Open":
15 | return "blue";
16 | case "InProgress":
17 | return "yellow";
18 | case "Complete":
19 | return "green";
20 | default:
21 | return "gray";
22 | }
23 | };
24 |
25 | const getTaskStatusDisplayName = (status) => {
26 | if (status === "InProgress") {
27 | // Add a non-breaking space so that the string is always a single line
28 | const nbsp = String.fromCharCode(160);
29 | return `In${nbsp}Progress`;
30 | }
31 | return status;
32 | };
33 |
--------------------------------------------------------------------------------
/src/components/StatusChange.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "@emotion/styled";
3 | import StatusBadge from "./StatusBadge";
4 | import ArrowRightIcon from "@leafygreen-ui/icon/dist/ArrowRight";
5 |
6 | export default function StatusChange({ from, to }) {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 | );
14 | }
15 |
16 | const Container = styled.div`
17 | display: flex;
18 | align-items: center;
19 | gap: 8px;
20 | `;
21 |
--------------------------------------------------------------------------------
/src/components/TaskContent.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "@emotion/styled";
3 | import StatusBadge from "./StatusBadge";
4 |
5 | export default function TaskContent({ task }) {
6 | return (
7 |
8 | {task.name}
9 |
10 |
11 | );
12 | }
13 |
14 | const TaskDescription = styled.div`
15 | display: flex;
16 | width: 100%;
17 | `;
18 | const TaskName = styled.span`
19 | flex-grow: 1;
20 | `;
21 |
--------------------------------------------------------------------------------
/src/components/TaskDetailModal.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import TaskContent from "./TaskContent";
3 | import Modal from "@leafygreen-ui/modal";
4 | import ButtonGroup from "./ButtonGroup";
5 | import useChangeTaskStatusButton from "./useChangeTaskStatusButton";
6 |
7 | export default function TaskDetailModal({ project, task, unselectTask }) {
8 | const ChangeTaskStatusButton = useChangeTaskStatusButton(project);
9 | return (
10 |
14 | {task && (
15 | <>
16 |
17 |
18 | {task.status === "Open" && (
19 |
24 | Start Progress
25 |
26 | )}
27 | {task.status === "InProgress" && (
28 | <>
29 |
34 | Stop Progress
35 |
36 |
41 | Complete Task
42 |
43 | >
44 | )}
45 | {task.status === "Complete" && (
46 |
51 | Resume Task
52 |
53 | )}
54 |
55 | >
56 | )}
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/src/components/useChangeTaskStatusButton.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "@emotion/styled";
3 | import LGButton from "@leafygreen-ui/button";
4 | import StatusChange from "./StatusChange";
5 | import useTaskMutations from "../graphql/useTaskMutations";
6 |
7 | // Use a hook to dynamically create status update buttons for the specified project
8 | export default function useChangeTaskStatusButton(project) {
9 | const { updateTask } = useTaskMutations(project);
10 | const ChangeTaskStatusButton = ({ task, fromStatus, toStatus, children }) => {
11 | return (
12 |
18 | );
19 | };
20 | return ChangeTaskStatusButton;
21 | }
22 |
23 | const Button = styled(LGButton)`
24 | height: 100%;
25 | `;
26 |
27 | const ButtonContent = styled.div`
28 | display: flex;
29 | flex-direction: column;
30 | align-items: center;
31 | padding: 8px 0;
32 | gap: 8px;
33 | `;
34 |
--------------------------------------------------------------------------------
/src/graphql/RealmApolloProvider.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useRealmApp } from "../RealmApp";
3 | import {
4 | ApolloClient,
5 | HttpLink,
6 | InMemoryCache,
7 | ApolloProvider,
8 | } from "@apollo/client";
9 |
10 | // Create an ApolloClient that connects to the provided Realm.App's GraphQL API
11 | const createRealmApolloClient = (app) => {
12 | const link = new HttpLink({
13 | // Realm apps use a standard GraphQL endpoint, identified by their App ID
14 | uri: `https://realm.mongodb.com/api/client/v2.0/app/${app.id}/graphql`,
15 | // A custom fetch handler adds the logged in user's access token to GraphQL requests
16 | fetch: async (uri, options) => {
17 | if (!app.currentUser) {
18 | throw new Error(`Must be logged in to use the GraphQL API`);
19 | }
20 | // Refreshing a user's custom data also refreshes their access token
21 | await app.currentUser.refreshCustomData();
22 | // The handler adds a bearer token Authorization header to the otherwise unchanged request
23 | options.headers.Authorization = `Bearer ${app.currentUser.accessToken}`;
24 | return fetch(uri, options);
25 | },
26 | });
27 |
28 | const cache = new InMemoryCache();
29 |
30 | return new ApolloClient({ link, cache });
31 | };
32 |
33 | export default function RealmApolloProvider({ children }) {
34 | const app = useRealmApp();
35 | const [client, setClient] = React.useState(createRealmApolloClient(app));
36 | React.useEffect(() => {
37 | setClient(createRealmApolloClient(app));
38 | }, [app]);
39 | return {children};
40 | }
41 |
--------------------------------------------------------------------------------
/src/graphql/useProjects.js:
--------------------------------------------------------------------------------
1 | import { useRealmApp } from "../RealmApp";
2 | import React from "react";
3 |
4 | function setProjectsFromChange(change, setProjects) {
5 | const {
6 | fullDocument: { memberOf },
7 | } = change;
8 | setProjects(memberOf);
9 | }
10 |
11 | export default function useProjects() {
12 | const app = useRealmApp();
13 | const [projects, setProjects] = React.useState(
14 | app.currentUser.customData.memberOf
15 | );
16 | if (!app.currentUser) {
17 | throw new Error("Cannot list projects if there is no logged in user.");
18 | }
19 | const mongodb = app.currentUser.mongoClient("mongodb-atlas");
20 | const users = mongodb.db("tracker").collection("User");
21 |
22 | // set asynchronous event watcher to react to any changes in the users collection
23 | React.useEffect(() => {
24 | let changeWatcher;
25 | (async () => {
26 | changeWatcher = users.watch();
27 | for await (const change of changeWatcher) {
28 | setProjectsFromChange(change, setProjects);
29 | }
30 | })();
31 |
32 | // close connection when component unmounts
33 | return () => changeWatcher.return();
34 | });
35 |
36 | return projects;
37 | }
38 |
--------------------------------------------------------------------------------
/src/graphql/useTaskMutations.js:
--------------------------------------------------------------------------------
1 | import { ObjectId } from "bson";
2 | import { useMutation } from "@apollo/client";
3 | import gql from "graphql-tag";
4 |
5 | export default function useTaskMutations(project) {
6 | return {
7 | addTask: useAddTask(project),
8 | updateTask: useUpdateTask(project),
9 | deleteTask: useDeleteTask(project),
10 | };
11 | }
12 |
13 | const AddTaskMutation = gql`
14 | mutation AddTask($task: TaskInsertInput!) {
15 | addedTask: insertOneTask(data: $task) {
16 | _id
17 | _partition
18 | name
19 | status
20 | }
21 | }
22 | `;
23 |
24 | const UpdateTaskMutation = gql`
25 | mutation UpdateTask($taskId: ObjectId!, $updates: TaskUpdateInput!) {
26 | updatedTask: updateOneTask(query: { _id: $taskId }, set: $updates) {
27 | _id
28 | _partition
29 | name
30 | status
31 | }
32 | }
33 | `;
34 |
35 | const DeleteTaskMutation = gql`
36 | mutation DeleteTask($taskId: ObjectId!) {
37 | deletedTask: deleteOneTask(query: { _id: taskId }) {
38 | _id
39 | _partition
40 | name
41 | status
42 | }
43 | }
44 | `;
45 |
46 | const TaskFieldsFragment = gql`
47 | fragment TaskFields on Task {
48 | _id
49 | _partition
50 | status
51 | name
52 | }
53 | `;
54 |
55 | function useAddTask(project) {
56 | const [addTaskMutation] = useMutation(AddTaskMutation, {
57 | // Manually save added Tasks into the Apollo cache so that Task queries automatically update
58 | // For details, refer to https://www.apollographql.com/docs/react/data/mutations/#making-all-other-cache-updates
59 | update: (cache, { data: { addedTask } }) => {
60 | cache.modify({
61 | fields: {
62 | tasks: (existingTasks = []) => [
63 | ...existingTasks,
64 | cache.writeFragment({
65 | data: addedTask,
66 | fragment: TaskFieldsFragment,
67 | }),
68 | ],
69 | },
70 | });
71 | },
72 | });
73 |
74 | const addTask = async (task) => {
75 | const { addedTask } = await addTaskMutation({
76 | variables: {
77 | task: {
78 | _id: new ObjectId(),
79 | _partition: project.partition,
80 | status: "Open",
81 | ...task,
82 | },
83 | },
84 | });
85 | return addedTask;
86 | };
87 |
88 | return addTask;
89 | }
90 |
91 | function useUpdateTask(project) {
92 | const [updateTaskMutation] = useMutation(UpdateTaskMutation);
93 | const updateTask = async (task, updates) => {
94 | const { updatedTask } = await updateTaskMutation({
95 | variables: { taskId: task._id, updates },
96 | });
97 | return updatedTask;
98 | };
99 | return updateTask;
100 | }
101 |
102 | function useDeleteTask(project) {
103 | const [deleteTaskMutation] = useMutation(DeleteTaskMutation);
104 | const deleteTask = async (task) => {
105 | const { deletedTask } = await deleteTaskMutation({
106 | variables: { taskId: task._id },
107 | });
108 | return deletedTask;
109 | };
110 | return deleteTask;
111 | }
112 |
--------------------------------------------------------------------------------
/src/graphql/useTaskQueries.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useQuery } from "@apollo/client";
3 | import gql from "graphql-tag";
4 |
5 | const GetAllTasksQuery = gql`
6 | query GetAllTasksForProject($partition: String!) {
7 | tasks(query: { _partition: $partition }) {
8 | _id
9 | name
10 | status
11 | }
12 | }
13 | `;
14 |
15 | export function useAllTasksInProject(project) {
16 | const { data, loading, error, startPolling, stopPolling } = useQuery(
17 | GetAllTasksQuery,
18 | {
19 | variables: {
20 | partition: project.partition,
21 | },
22 | }
23 | );
24 | React.useEffect(() => {
25 | // check server for updates every 1000ms
26 | startPolling(1000);
27 | // stop polling server for data when component unmounts
28 | return () => stopPolling();
29 | }, [startPolling, stopPolling]);
30 | if (error) {
31 | throw new Error(`Failed to fetch tasks: ${error.message}`);
32 | }
33 |
34 | // If the query has finished, return the tasks from the result data
35 | // Otherwise, return an empty list
36 | const tasks = data?.tasks ?? [];
37 | return {
38 | tasks,
39 | loading,
40 | };
41 | }
42 |
--------------------------------------------------------------------------------
/src/graphql/useTasks.js:
--------------------------------------------------------------------------------
1 | import useTaskMutations from "./useTaskMutations";
2 | import { useAllTasksInProject } from "./useTaskQueries";
3 |
4 | const useTasks = (project) => {
5 | const { tasks, loading } = useAllTasksInProject(project);
6 | const { addTask, updateTask } = useTaskMutations(project);
7 | return {
8 | loading,
9 | tasks,
10 | updateTask,
11 | addTask,
12 | };
13 | };
14 | export default useTasks;
15 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
12 | monospace;
13 | }
14 |
15 | * {
16 | box-sizing: border-box;
17 | }
18 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 | import "./index.css";
4 | import App from "./App";
5 | import * as serviceWorker from "./serviceWorker";
6 |
7 | ReactDOM.render(
8 |
9 |
10 | ,
11 | document.getElementById("root")
12 | );
13 |
14 | // If you want your app to work offline and load faster, you can change
15 | // unregister() to register() below. Note this comes with some pitfalls.
16 | // Learn more about service workers: https://bit.ly/CRA-PWA
17 | serviceWorker.unregister();
18 |
--------------------------------------------------------------------------------
/src/serviceWorker.js:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read https://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === "localhost" ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === "[::1]" ||
17 | // 127.0.0.0/8 are considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | export function register(config) {
24 | if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
25 | // The URL constructor is available in all browsers that support SW.
26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
27 | if (publicUrl.origin !== window.location.origin) {
28 | // Our service worker won't work if PUBLIC_URL is on a different origin
29 | // from what our page is served on. This might happen if a CDN is used to
30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
31 | return;
32 | }
33 |
34 | window.addEventListener("load", () => {
35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
36 |
37 | if (isLocalhost) {
38 | // This is running on localhost. Let's check if a service worker still exists or not.
39 | checkValidServiceWorker(swUrl, config);
40 |
41 | // Add some additional logging to localhost, pointing developers to the
42 | // service worker/PWA documentation.
43 | navigator.serviceWorker.ready.then(() => {
44 | console.log(
45 | "This web app is being served cache-first by a service " +
46 | "worker. To learn more, visit https://bit.ly/CRA-PWA"
47 | );
48 | });
49 | } else {
50 | // Is not localhost. Just register service worker
51 | registerValidSW(swUrl, config);
52 | }
53 | });
54 | }
55 | }
56 |
57 | function registerValidSW(swUrl, config) {
58 | navigator.serviceWorker
59 | .register(swUrl)
60 | .then((registration) => {
61 | registration.onupdatefound = () => {
62 | const installingWorker = registration.installing;
63 | if (installingWorker == null) {
64 | return;
65 | }
66 | installingWorker.onstatechange = () => {
67 | if (installingWorker.state === "installed") {
68 | if (navigator.serviceWorker.controller) {
69 | // At this point, the updated precached content has been fetched,
70 | // but the previous service worker will still serve the older
71 | // content until all client tabs are closed.
72 | console.log(
73 | "New content is available and will be used when all " +
74 | "tabs for this page are closed. See https://bit.ly/CRA-PWA."
75 | );
76 |
77 | // Execute callback
78 | if (config && config.onUpdate) {
79 | config.onUpdate(registration);
80 | }
81 | } else {
82 | // At this point, everything has been precached.
83 | // It's the perfect time to display a
84 | // "Content is cached for offline use." message.
85 | console.log("Content is cached for offline use.");
86 |
87 | // Execute callback
88 | if (config && config.onSuccess) {
89 | config.onSuccess(registration);
90 | }
91 | }
92 | }
93 | };
94 | };
95 | })
96 | .catch((error) => {
97 | console.error("Error during service worker registration:", error);
98 | });
99 | }
100 |
101 | function checkValidServiceWorker(swUrl, config) {
102 | // Check if the service worker can be found. If it can't reload the page.
103 | fetch(swUrl, {
104 | headers: { "Service-Worker": "script" },
105 | })
106 | .then((response) => {
107 | // Ensure service worker exists, and that we really are getting a JS file.
108 | const contentType = response.headers.get("content-type");
109 | if (
110 | response.status === 404 ||
111 | (contentType != null && contentType.indexOf("javascript") === -1)
112 | ) {
113 | // No service worker found. Probably a different app. Reload the page.
114 | navigator.serviceWorker.ready.then((registration) => {
115 | registration.unregister().then(() => {
116 | window.location.reload();
117 | });
118 | });
119 | } else {
120 | // Service worker found. Proceed as normal.
121 | registerValidSW(swUrl, config);
122 | }
123 | })
124 | .catch(() => {
125 | console.log(
126 | "No internet connection found. App is running in offline mode."
127 | );
128 | });
129 | }
130 |
131 | export function unregister() {
132 | if ("serviceWorker" in navigator) {
133 | navigator.serviceWorker.ready
134 | .then((registration) => {
135 | registration.unregister();
136 | })
137 | .catch((error) => {
138 | console.error(error.message);
139 | });
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import "@testing-library/jest-dom/extend-expect";
6 |
--------------------------------------------------------------------------------