├── .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 | --------------------------------------------------------------------------------