├── .eslintignore ├── src ├── apollo │ ├── index.ts │ └── queries.ts ├── react-app-env.d.ts ├── pages │ ├── index.ts │ ├── 404.tsx │ ├── Desks.tsx │ └── Desk.tsx ├── utils │ ├── hooks │ │ ├── index.ts │ │ ├── Group │ │ │ ├── index.ts │ │ │ ├── useGroupName.ts │ │ │ ├── useDeleteGroup.ts │ │ │ └── useCreateGroup.ts │ │ ├── Task │ │ │ ├── index.ts │ │ │ ├── useUpdateTask.ts │ │ │ ├── useDeleteTask.ts │ │ │ ├── useTaskIndex.ts │ │ │ ├── useCreateTask.ts │ │ │ └── useTaskGroupId.ts │ │ ├── Desk │ │ │ ├── index.ts │ │ │ ├── useDesks.ts │ │ │ ├── useDesk.ts │ │ │ ├── useDeleteDesk.ts │ │ │ ├── useDeskName.ts │ │ │ ├── useCreateDesk.ts │ │ │ └── useDeskIndex.ts │ │ └── tests │ │ │ ├── useCreateDesk.test.tsx │ │ │ ├── useDeleteDesk.test.tsx │ │ │ ├── useDesks.test.tsx │ │ │ └── useDesk.test.tsx │ ├── index.ts │ ├── scripts │ │ ├── index.ts │ │ ├── insertIntoArray.ts │ │ ├── compareIndex.ts │ │ └── moveElementInArray.ts │ ├── WithApollo.tsx │ └── Observers │ │ ├── Task.ts │ │ ├── Observer.test.ts │ │ └── Group.ts ├── components │ ├── WorkSpace.tsx │ ├── Group │ │ ├── index.ts │ │ ├── CreateGroup.tsx │ │ ├── CreateGroupCard.tsx │ │ ├── Header.tsx │ │ ├── Group.tsx │ │ └── CreateGroupModalForm.tsx │ ├── Task │ │ ├── index.ts │ │ ├── CreateTask.tsx │ │ ├── Status.tsx │ │ ├── CreateTaskButton.tsx │ │ ├── Task.tsx │ │ ├── CreateTaskModalForm.tsx │ │ └── EditTaskModalForm.tsx │ ├── index.ts │ ├── Desks │ │ ├── index.ts │ │ ├── CreateDesk.tsx │ │ ├── DeskName.tsx │ │ ├── CreateDeskCard.tsx │ │ ├── DeleteDeskButton.tsx │ │ ├── DesksDragContext.tsx │ │ ├── DesksCatalog.tsx │ │ ├── DeskCard.tsx │ │ ├── CreateDeskModalForm.tsx │ │ └── DeskDragContext.tsx │ ├── BackButton.tsx │ ├── Input.tsx │ ├── ButtonBase.tsx │ ├── Header.tsx │ ├── NotFound.tsx │ ├── GitHubButton.tsx │ └── TrashButton.tsx ├── setupTests.ts ├── Users │ ├── ModeratorUser.ts │ ├── AdministratorUser.ts │ └── User.ts ├── index.tsx ├── styles.css └── interfaces │ └── index.ts ├── public ├── tile.png ├── favicon.ico ├── favicon.svg └── index.html ├── .gitignore ├── tsconfig.json ├── .eslintrc ├── README.md └── package.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | react-app-env.d.ts 3 | -------------------------------------------------------------------------------- /src/apollo/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./queries"; 2 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/tile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snelsi/dashboard/HEAD/public/tile.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snelsi/dashboard/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/pages/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./404"; 2 | export * from "./Desk"; 3 | export * from "./Desks"; 4 | -------------------------------------------------------------------------------- /src/utils/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Desk"; 2 | export * from "./Group"; 3 | export * from "./Task"; 4 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./hooks"; 2 | export * from "./scripts"; 3 | 4 | export * from "./WithApollo"; 5 | -------------------------------------------------------------------------------- /src/utils/hooks/Group/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./useDeleteGroup"; 2 | export * from "./useCreateGroup"; 3 | export * from "./useGroupName"; 4 | -------------------------------------------------------------------------------- /src/utils/scripts/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./compareIndex"; 2 | export * from "./insertIntoArray"; 3 | export * from "./moveElementInArray"; 4 | -------------------------------------------------------------------------------- /src/components/WorkSpace.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const WorkSpace = styled.div` 4 | overflow: auto; 5 | height: calc(100vh - 70px); 6 | `; 7 | -------------------------------------------------------------------------------- /src/utils/scripts/insertIntoArray.ts: -------------------------------------------------------------------------------- 1 | export const insertIntoArray = (arr: T[], index: number, newItem: T) => [ 2 | ...arr.slice(0, index), 3 | newItem, 4 | ...arr.slice(index), 5 | ]; 6 | -------------------------------------------------------------------------------- /src/components/Group/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./CreateGroup"; 2 | export * from "./CreateGroupCard"; 3 | export * from "./CreateGroupModalForm"; 4 | export * from "./Group"; 5 | export * from "./Header"; 6 | -------------------------------------------------------------------------------- /src/utils/hooks/Task/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./useCreateTask"; 2 | export * from "./useDeleteTask"; 3 | export * from "./useTaskGroupId"; 4 | export * from "./useTaskIndex"; 5 | export * from "./useUpdateTask"; 6 | -------------------------------------------------------------------------------- /src/utils/hooks/Desk/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./useCreateDesk"; 2 | export * from "./useDeleteDesk"; 3 | export * from "./useDesk"; 4 | export * from "./useDeskIndex"; 5 | export * from "./useDeskName"; 6 | export * from "./useDesks"; 7 | -------------------------------------------------------------------------------- /src/components/Task/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./CreateTask"; 2 | export * from "./CreateTaskButton"; 3 | export * from "./CreateTaskModalForm"; 4 | export * from "./EditTaskModalForm"; 5 | export * from "./Status"; 6 | export * from "./Task"; 7 | -------------------------------------------------------------------------------- /src/utils/scripts/compareIndex.ts: -------------------------------------------------------------------------------- 1 | interface Sortable { 2 | index: number; 3 | } 4 | 5 | /** 6 | * Sort two objects asc by their index 7 | */ 8 | export const compareIndex = (obj1: Sortable, obj2: Sortable) => 9 | obj1?.index - obj2?.index; 10 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./BackButton"; 2 | export * from "./ButtonBase"; 3 | export * from "./GitHubButton"; 4 | export * from "./Header"; 5 | export * from "./Input"; 6 | export * from "./NotFound"; 7 | export * from "./TrashButton"; 8 | export * from "./WorkSpace"; 9 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /src/Users/ModeratorUser.ts: -------------------------------------------------------------------------------- 1 | import { SystemRole } from "interfaces"; 2 | 3 | import { User, UserDecorator } from "Users/User"; 4 | 5 | export class ModeratorUser extends UserDecorator { 6 | public constructor(user: User) { 7 | super(SystemRole.Moderator, user); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { NotFound404 } from "components"; 3 | 4 | export const NotFound: React.FC = () => { 5 | React.useEffect(() => { 6 | document.title = "404 Page not found"; 7 | }, []); 8 | 9 | return ; 10 | }; 11 | -------------------------------------------------------------------------------- /src/Users/AdministratorUser.ts: -------------------------------------------------------------------------------- 1 | import { SystemRole } from "interfaces"; 2 | 3 | import { User, UserDecorator } from "Users/User"; 4 | 5 | export class AdministratorUser extends UserDecorator { 6 | public constructor(user: User) { 7 | super(SystemRole.Admin, user); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/hooks/Desk/useDesks.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@apollo/client"; 2 | import { GET_DESKS, GET_DESKS_VALUE } from "apollo"; 3 | 4 | /** 5 | * Returns list of desks without their content 6 | */ 7 | export const useDesks = () => 8 | useQuery(GET_DESKS, { pollInterval: 2000 }); 9 | -------------------------------------------------------------------------------- /src/utils/scripts/moveElementInArray.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Move element in array to given index 3 | */ 4 | export function moveElementInArray(arr: any[], from: number, to: number) { 5 | const element = arr[from]; 6 | const result = [...arr]; 7 | result.splice(from, 1); 8 | result.splice(to, 0, element); 9 | return result; 10 | } 11 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/components/Desks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./CreateDesk"; 2 | export * from "./CreateDeskCard"; 3 | export * from "./CreateDeskModalForm"; 4 | export * from "./DeleteDeskButton"; 5 | export * from "./DeskCard"; 6 | export * from "./DeskName"; 7 | export * from "./DeskDragContext"; 8 | export * from "./DesksCatalog"; 9 | export * from "./DesksDragContext"; 10 | -------------------------------------------------------------------------------- /src/utils/hooks/Desk/useDesk.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@apollo/client"; 2 | import { GET_DESK, GET_DESK_VALUES, GET_DESK_PROPS } from "apollo"; 3 | 4 | /** 5 | * Returns desk with given id and linked groups and tasks 6 | */ 7 | export const useDesk = (id: number) => 8 | useQuery(GET_DESK, { 9 | variables: { 10 | id, 11 | }, 12 | pollInterval: 2000, 13 | }); 14 | -------------------------------------------------------------------------------- /src/components/BackButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Link } from "react-router-dom"; 3 | import { ChevronLeft } from "@zeit-ui/react-icons"; 4 | import { ButtonBase } from "components"; 5 | 6 | interface BackButtonProps { 7 | to: string; 8 | } 9 | 10 | export const BackButton: React.FC = ({ to }) => ( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | -------------------------------------------------------------------------------- /src/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const Input = styled.input` 4 | border: none; 5 | color: #172b4d; 6 | 7 | font-size: 16px; 8 | font-weight: 700; 9 | padding: 4px 8px; 10 | 11 | &:hover, 12 | &:focus-within { 13 | background-color: rgba(0, 0, 0, 0.05); 14 | } 15 | &:focus-within { 16 | border: 1px solid #0079bf; 17 | } 18 | 19 | &[data-size="big"] { 20 | font-size: 28px; 21 | } 22 | `; 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | server/node_modules 6 | client/node_modules 7 | /.pnp 8 | .pnp.js 9 | 10 | # testing 11 | /coverage 12 | 13 | # production 14 | /build 15 | server/build 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | -------------------------------------------------------------------------------- /src/utils/WithApollo.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { 3 | ApolloProvider, 4 | ApolloClient, 5 | HttpLink, 6 | InMemoryCache, 7 | } from "@apollo/client"; 8 | 9 | const client = new ApolloClient({ 10 | cache: new InMemoryCache(), 11 | link: new HttpLink({ 12 | uri: "https://my-tasks-manager.herokuapp.com/v1/graphql", 13 | }), 14 | }); 15 | 16 | export const WithApollo: React.FC = ({ children }) => ( 17 | {children} 18 | ); 19 | -------------------------------------------------------------------------------- /src/components/ButtonBase.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const ButtonBase = styled.div` 4 | align-items: center; 5 | background-color: #f1f4fc; 6 | border-radius: 8px; 7 | border: none; 8 | display: flex; 9 | justify-content: center; 10 | margin-right: 16px; 11 | 12 | height: 40px; 13 | width: 40px; 14 | 15 | transition: all 0.2s ease-out; 16 | 17 | &:hover, 18 | &:focus { 19 | background-color: #e9ecf3; 20 | } 21 | &:active { 22 | background-color: #d6d9e0; 23 | } 24 | `; 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": false, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react", 17 | "baseUrl": "./src" 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "alloy", 4 | "alloy/react", 5 | "alloy/typescript", 6 | "plugin:jsx-a11y/recommended", 7 | "plugin:testing-library/recommended" 8 | ], 9 | "plugins": ["jsx-a11y", "testing-library"], 10 | "env": { 11 | "browser": true, 12 | "jest": true 13 | }, 14 | "settings": { 15 | "react": { 16 | "version": "detect" 17 | } 18 | }, 19 | "rules": { 20 | "@typescript-eslint/no-unused-vars": "warn", 21 | "@typescript-eslint/no-empty-interface": "off", 22 | "jsx-a11y/no-autofocus": "off" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | Tasks Manager 13 | 14 | 15 | You need to enable JavaScript to run this app. 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/components/Desks/CreateDesk.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { CreateDeskCard, CreateDeskModalForm } from "components/Desks"; 3 | 4 | interface CreateDeskProps { 5 | index: number; 6 | } 7 | export const CreateDesk: React.FC = ({ index }) => { 8 | const [isModalOpen, setModal] = React.useState(false); 9 | 10 | return ( 11 | <> 12 | setModal(true)} /> 13 | setModal(false)} 16 | index={index + 1} 17 | /> 18 | > 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const Header = styled.div` 4 | background-color: #fff; 5 | border: 1px solid #e8e8ef; 6 | height: 70px; 7 | display: flex; 8 | align-items: center; 9 | justify-content: space-between; 10 | padding: 0 20px; 11 | 12 | overflow: auto; 13 | 14 | & > div { 15 | align-items: center; 16 | display: flex; 17 | } 18 | & > div.left { 19 | width: 100%; 20 | & > input { 21 | max-width: 400px; 22 | width: fill-available; 23 | } 24 | } 25 | & > div.right { 26 | width: fit-content; 27 | } 28 | `; 29 | -------------------------------------------------------------------------------- /src/components/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | const NotFound = styled.div` 5 | width: 100%; 6 | height: 100vh; 7 | display: flex; 8 | align-content: center; 9 | justify-content: center; 10 | flex-direction: column; 11 | text-align: center; 12 | `; 13 | 14 | interface NotFound404Props { 15 | description?: string; 16 | } 17 | 18 | export const NotFound404: React.FC = ({ 19 | description = "Page is not found", 20 | }) => ( 21 | 22 | 404 | {description} 23 | Return to main page 24 | 25 | ); 26 | -------------------------------------------------------------------------------- /src/components/Group/CreateGroup.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { CreateGroupCard, CreateGroupModalForm } from "components/Group"; 3 | 4 | interface CreateGroup { 5 | desk_id: number; 6 | index: number; 7 | } 8 | export const CreateGroup: React.FC = ({ desk_id, index }) => { 9 | const [isModalOpen, setModal] = React.useState(false); 10 | 11 | return ( 12 | <> 13 | setModal(true)} /> 14 | setModal(false)} 17 | desk_id={desk_id} 18 | index={index + 1} 19 | /> 20 | > 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/components/Task/CreateTask.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { CreateTaskButton, CreateTaskModalForm } from "components/Task"; 3 | 4 | interface CreateTask { 5 | group_id: number; 6 | index: number; 7 | } 8 | export const CreateTask: React.FC = ({ group_id, index }) => { 9 | const [isModalOpen, setModal] = React.useState(false); 10 | 11 | return ( 12 | <> 13 | setModal(true)} /> 14 | setModal(false)} 17 | group_id={group_id} 18 | index={index + 1} 19 | /> 20 | > 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/components/GitHubButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import { Github } from "@zeit-ui/react-icons"; 4 | import { ButtonBase } from "components"; 5 | 6 | interface GitHubButtonProps {} 7 | 8 | const Link = styled.a` 9 | position: fixed; 10 | right: 16px; 11 | bottom: 16px; 12 | & > div { 13 | background-color: white; 14 | } 15 | `; 16 | 17 | export const GitHubButton: React.FC = () => ( 18 | 23 | 24 | 25 | 26 | 27 | ); 28 | -------------------------------------------------------------------------------- /src/components/Task/Status.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import type { Status as IStatus } from "interfaces"; 3 | 4 | interface StatusProps { 5 | "data-status": IStatus; 6 | } 7 | 8 | export const Status = styled.div` 9 | background-color: #ff9e25; 10 | border-radius: 4px; 11 | height: 8px; 12 | margin: 0 4px 4px 0; 13 | width: 40px; 14 | 15 | &[data-status="planned" i] { 16 | background-color: #6287ff; 17 | } 18 | &[data-status="in progress" i] { 19 | background-color: #ff9e25; 20 | } 21 | &[data-status="done" i] { 22 | background-color: #51e898; 23 | } 24 | &[data-status="failed" i] { 25 | background-color: #ff3131; 26 | } 27 | `; 28 | -------------------------------------------------------------------------------- /src/Users/User.ts: -------------------------------------------------------------------------------- 1 | import { SystemRole } from "interfaces"; 2 | 3 | export class User { 4 | public userId: number; 5 | public name: string; 6 | public login: string; 7 | public password: string; 8 | 9 | private userRole: SystemRole; 10 | 11 | public constructor(role: SystemRole) { 12 | this.setUserRole(role); 13 | } 14 | 15 | public setUserRole(role: SystemRole) { 16 | this.userRole = role; 17 | } 18 | 19 | public getUserRole(): SystemRole { 20 | return this.userRole; 21 | } 22 | } 23 | 24 | export abstract class UserDecorator extends User { 25 | protected user: User; 26 | 27 | public constructor(role: SystemRole, user: User) { 28 | super(role); 29 | this.user = user; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "react-dom"; 3 | import { Switch, BrowserRouter as Router, Route } from "react-router-dom"; 4 | 5 | import { Desks, DeskOverview, NotFound } from "pages"; 6 | 7 | import { WithApollo } from "utils"; 8 | 9 | import "styles.css"; 10 | 11 | const App = () => ( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ); 28 | 29 | render(, document.getElementById("root")); 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Software Development Technologies 2 | В этом репозитории находится реализация проекта на тему "Система управления заданиями проекта". 3 | 4 | Онлайн версия проекта доступна онлайн по этой ссылке: 5 | https://my-dashboard.now.sh/ 6 | 7 | К приложению была создана GraphQL база данных, доступная по открытому [EndPoint](https://my-tasks-manager.herokuapp.com/v1/graphql). 8 | В приложении реализован клиент на базе Apollo, который реализует доступ к базе данных, получает данные через специальные хуки, находящиеся в папке utils/hooks. Полученные данные хранятся в кеше и доступны глобально по всему приложению. 9 | 10 | Приложение позволяет создавать, просматривать и редактировать доски с задачами. Работать с группами внутри них и вести учёт задач на глобальной доске. 11 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | box-sizing: border-box; 5 | margin: 0; 6 | padding: 0; 7 | } 8 | 9 | body { 10 | min-height: 100vh; 11 | min-width: 320px; 12 | } 13 | 14 | html { 15 | font-size: 16px; 16 | } 17 | 18 | body { 19 | background-color: #fafafa; 20 | font: 16px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 21 | "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", 22 | "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 23 | color: rgba(0, 0, 0, 0.9); 24 | 25 | background-image: url(/tile.png); 26 | background-repeat: repeat; 27 | background-size: 30px 30px; 28 | } 29 | 30 | #root { 31 | height: 100vh; 32 | overflow: hidden; 33 | } 34 | 35 | a { 36 | text-decoration: none; 37 | } 38 | -------------------------------------------------------------------------------- /src/utils/hooks/tests/useCreateDesk.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { render } from "@testing-library/react"; 3 | import { MockedProvider } from "@apollo/client/testing"; 4 | import { CreateDesk } from "components/Desks/CreateDesk"; 5 | import { CREATE_DESK } from "utils"; 6 | 7 | const mocks = [ 8 | { 9 | request: { 10 | query: CREATE_DESK, 11 | }, 12 | result: { 13 | data: { 14 | create_desk: { 15 | id: 1, 16 | name: "Next release", 17 | index: 1, 18 | __typename: "desks", 19 | }, 20 | }, 21 | }, 22 | }, 23 | ]; 24 | 25 | it("renders without error", () => { 26 | render( 27 | 28 | 29 | 30 | ); 31 | }); 32 | -------------------------------------------------------------------------------- /src/components/Desks/DeskName.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useThrottle } from "react-use"; 3 | import { useDeskName } from "utils/hooks"; 4 | import { Input } from "components"; 5 | 6 | interface DeskNameProps { 7 | id: number; 8 | initialName: string; 9 | } 10 | 11 | export const DeskName: React.FC = ({ id, initialName }) => { 12 | const [name, setName] = React.useState(initialName); 13 | const debouncedName = useThrottle(name); 14 | const [updateName] = useDeskName({ id, name }); 15 | 16 | React.useEffect(() => { 17 | if (debouncedName && initialName !== debouncedName) { 18 | updateName(); 19 | } 20 | }, [initialName, debouncedName, updateName]); 21 | 22 | return ( 23 | setName(newValue.target.value)} 26 | data-size="big" 27 | /> 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /src/utils/hooks/tests/useDeleteDesk.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { render } from "@testing-library/react"; 3 | import { MockedProvider } from "@apollo/client/testing"; 4 | import { BrowserRouter as Router } from "react-router-dom"; 5 | import { DeleteDeskButton } from "components/Desks/DeleteDeskButton"; 6 | import { DELETE_DESK } from "utils"; 7 | 8 | const mocks = [ 9 | { 10 | request: { 11 | query: DELETE_DESK, 12 | }, 13 | result: { 14 | data: { 15 | create_desk: { 16 | id: 1, 17 | name: "Next release", 18 | index: 1, 19 | __typename: "desks", 20 | }, 21 | }, 22 | }, 23 | }, 24 | ]; 25 | 26 | it("renders without error", () => { 27 | render( 28 | 29 | 30 | 31 | 32 | 33 | ); 34 | }); 35 | -------------------------------------------------------------------------------- /src/components/Group/CreateGroupCard.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | const Button = styled.button` 5 | background: none; 6 | border-radius: 4px; 7 | border: 1px dashed #878d96; 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | font-size: 15px; 12 | color: #000; 13 | cursor: pointer; 14 | outline: none; 15 | padding: 1em 0.5em; 16 | height: fit-content; 17 | 18 | &:hover, 19 | &:focus { 20 | border: 1px solid #4d5358; 21 | background: rgba(200, 200, 200, 0.2); 22 | } 23 | 24 | &:active { 25 | background: rgba(200, 200, 200, 0.4); 26 | } 27 | 28 | width: 280px; 29 | 30 | @media (max-width: 600px) { 31 | width: 100%; 32 | } 33 | `; 34 | 35 | export const CreateGroupCard: React.FC> = (props) => + Добавить новую группу; 38 | -------------------------------------------------------------------------------- /src/components/Task/CreateTaskButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | const AddButton = styled.button` 5 | background: none; 6 | border: none; 7 | border-radius: 4px; 8 | cursor: pointer; 9 | color: #172b4d; 10 | font-size: 15px; 11 | padding: 0.5em; 12 | width: 100%; 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | transition: all 0.2s ease; 17 | 18 | &:hover { 19 | background-color: rgba(0, 0, 0, 0.05); 20 | } 21 | &:active { 22 | background-color: rgba(0, 0, 0, 0.15); 23 | } 24 | & > .plus { 25 | font-size: 18px; 26 | width: 20px; 27 | margin-right: 4px; 28 | } 29 | `; 30 | 31 | export const CreateTaskButton: React.FC> = (props) => ( 34 | 35 | + Добавить ещё одну задачу 36 | 37 | ); 38 | -------------------------------------------------------------------------------- /src/components/Desks/CreateDeskCard.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | const Button = styled.button` 5 | background: none; 6 | border-radius: 12px; 7 | border: 1px dashed #878d96; 8 | height: 100%; 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | font-size: 15px; 13 | color: #4d5358; 14 | cursor: pointer; 15 | outline: none; 16 | 17 | &:focus, 18 | &:hover { 19 | border: 1px solid #4d5358; 20 | } 21 | `; 22 | 23 | export const CreateDeskCard: React.FC> = (props) => ( 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | Добавить новую доску 34 | 35 | 36 | ); 37 | -------------------------------------------------------------------------------- /src/utils/hooks/Desk/useDeleteDesk.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, gql } from "@apollo/client"; 2 | import { GET_DESKS } from "apollo"; 3 | 4 | export interface DELETE_DESK_VALUE { 5 | delete_desk: { 6 | id: number; 7 | }; 8 | } 9 | export interface DELETE_DESK_PROPS { 10 | id: number; 11 | } 12 | export const DELETE_DESK = gql` 13 | mutation deleteDesk($id: Int!) { 14 | delete_desk(id: $id) { 15 | id 16 | } 17 | } 18 | `; 19 | 20 | /** 21 | * Returns function that deletes Desk with given id 22 | */ 23 | export const useDeleteDesk = (id: number) => 24 | useMutation(DELETE_DESK, { 25 | variables: { 26 | id, 27 | }, 28 | update(cache, { data: { delete_desk } }) { 29 | const { desks } = cache.readQuery({ query: GET_DESKS }); 30 | cache.writeQuery({ 31 | query: GET_DESKS, 32 | data: { desks: desks.filter(({ id }) => id !== delete_desk.id) }, 33 | }); 34 | }, 35 | }); 36 | -------------------------------------------------------------------------------- /src/components/Desks/DeleteDeskButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import { useHistory } from "react-router-dom"; 4 | 5 | import { useDeleteDesk } from "utils/hooks"; 6 | 7 | import { TrashButton } from "components"; 8 | 9 | const DeleteButton = styled.div` 10 | transition: all 0.2s ease-out; 11 | 12 | opacity: 0; 13 | 14 | &[data-hidden="false"] { 15 | opacity: 1; 16 | } 17 | `; 18 | 19 | interface DeleteDeskProps { 20 | id: number; 21 | hide?: boolean; 22 | } 23 | 24 | export const DeleteDeskButton: React.FC = ({ 25 | id, 26 | hide = false, 27 | }) => { 28 | const history = useHistory(); 29 | const [deleteDesk, { loading }] = useDeleteDesk(id); 30 | 31 | const deleteThisDesk = () => { 32 | deleteDesk().then(() => history.push("/")); 33 | }; 34 | 35 | return ( 36 | 37 | deleteThisDesk()} 39 | disabled={hide || loading} 40 | /> 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /src/utils/hooks/Desk/useDeskName.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, gql } from "@apollo/client"; 2 | 3 | interface Input { 4 | id: number; 5 | name: string; 6 | } 7 | 8 | export interface CHANGE_DESK_NAME_VALUE { 9 | update_group: { 10 | id: number; 11 | name: string; 12 | }; 13 | } 14 | export interface CHANGE_DESK_NAME_PROPS { 15 | id: number; 16 | name: string; 17 | } 18 | export const CHANGE_DESK_NAME = gql` 19 | mutation updateDeskName($id: Int!, $name: String!) { 20 | update_desk(pk_columns: { id: $id }, _set: { name: $name }) { 21 | id 22 | name 23 | } 24 | } 25 | `; 26 | 27 | /** 28 | * Returns mutation that changes name of desk with given id 29 | */ 30 | export const useDeskName = ({ name, id }: Input) => 31 | useMutation( 32 | CHANGE_DESK_NAME, 33 | { 34 | variables: { 35 | name, 36 | id, 37 | }, 38 | optimisticResponse: { 39 | update_group: { 40 | id, 41 | name, 42 | }, 43 | }, 44 | } 45 | ); 46 | -------------------------------------------------------------------------------- /src/utils/hooks/Group/useGroupName.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, gql } from "@apollo/client"; 2 | 3 | interface Input { 4 | id: number; 5 | name: string; 6 | } 7 | 8 | export interface CHANGE_GROUP_NAME_VALUE { 9 | update_group: { 10 | id: number; 11 | name: string; 12 | }; 13 | } 14 | export interface CHANGE_GROUP_NAME_PROPS { 15 | id: number; 16 | name: string; 17 | } 18 | export const CHANGE_GROUP_NAME = gql` 19 | mutation updateGroupName($id: Int!, $name: String!) { 20 | update_group(pk_columns: { id: $id }, _set: { name: $name }) { 21 | id 22 | name 23 | } 24 | } 25 | `; 26 | 27 | /** 28 | * Returns function that changes name of group with given id 29 | */ 30 | export const useGroupName = ({ name, id }: Input) => 31 | useMutation( 32 | CHANGE_GROUP_NAME, 33 | { 34 | variables: { 35 | name, 36 | id, 37 | }, 38 | optimisticResponse: { 39 | update_group: { 40 | id, 41 | name, 42 | }, 43 | }, 44 | } 45 | ); 46 | -------------------------------------------------------------------------------- /src/utils/hooks/tests/useDesks.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { render } from "@testing-library/react"; 3 | import { MockedProvider } from "@apollo/client/testing"; 4 | import { Desks } from "pages"; 5 | import { GET_DESKS } from "apollo"; 6 | 7 | const mocks = [ 8 | { 9 | request: { 10 | query: GET_DESKS, 11 | }, 12 | result: { 13 | data: { 14 | desks: [ 15 | { 16 | id: 1, 17 | name: "Next release", 18 | index: 1, 19 | __typename: "desks", 20 | }, 21 | { 22 | id: 7, 23 | name: "3.0", 24 | index: 2, 25 | __typename: "desks", 26 | }, 27 | { 28 | id: 5, 29 | name: "2.0", 30 | index: 3, 31 | __typename: "desks", 32 | }, 33 | ], 34 | }, 35 | }, 36 | }, 37 | ]; 38 | 39 | it("renders without error", () => { 40 | render( 41 | 42 | 43 | 44 | ); 45 | }); 46 | -------------------------------------------------------------------------------- /src/apollo/queries.ts: -------------------------------------------------------------------------------- 1 | import { gql } from "@apollo/client"; 2 | import type { IGroup } from "interfaces"; 3 | 4 | export interface GET_DESKS_VALUE { 5 | desks: { 6 | id: number; 7 | name: string; 8 | index: number; 9 | created_at: Date; 10 | updated_at: Date; 11 | }[]; 12 | } 13 | 14 | export const GET_DESKS = gql` 15 | query getDesks { 16 | desks(order_by: { index: asc }) { 17 | id 18 | name 19 | index 20 | created_at 21 | updated_at 22 | } 23 | } 24 | `; 25 | 26 | export interface GET_DESK_PROPS { 27 | id: number; 28 | } 29 | export interface GET_DESK_VALUES { 30 | desk: { 31 | id: number; 32 | name: string; 33 | groups: IGroup[]; 34 | }; 35 | } 36 | export const GET_DESK = gql` 37 | query getDesk($id: Int!) { 38 | desk(id: $id) { 39 | id 40 | name 41 | groups(order_by: { index: asc }) { 42 | id 43 | name 44 | index 45 | tasks(order_by: { index: asc }) { 46 | id 47 | description 48 | status 49 | index 50 | } 51 | } 52 | } 53 | } 54 | `; 55 | -------------------------------------------------------------------------------- /src/pages/Desks.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { useDesks } from "utils/hooks"; 4 | import { compareIndex } from "utils/scripts"; 5 | 6 | import { GitHubButton, Header, WorkSpace } from "components"; 7 | import { DesksCatalog, Desk, DesksDragContext } from "components/Desks"; 8 | 9 | export const Desks: React.FC = () => { 10 | const { data, loading, error } = useDesks(); 11 | 12 | React.useEffect(() => { 13 | document.title = "Desks"; 14 | }, []); 15 | 16 | return ( 17 | 18 | 19 | Desks 20 | {(error && {error.message}) || 21 | (loading && Loading...)} 22 | 23 | 24 | 25 | {data?.desks && ( 26 | 27 | {Array.from(data.desks) 28 | .sort(compareIndex) 29 | .map((desk) => ( 30 | 31 | ))} 32 | 33 | )} 34 | 35 | 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /src/components/TrashButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import { Button } from "@zeit-ui/react"; 4 | import { Trash2 } from "@zeit-ui/react-icons"; 5 | 6 | const Wrapper = styled.div` 7 | transition: all 0.2s ease-out; 8 | 9 | & > button { 10 | display: flex !important; 11 | align-items: center !important; 12 | border: none !important; 13 | height: 40px !important; 14 | width: 40px !important; 15 | padding: 0 !important; 16 | 17 | &:hover { 18 | border: none !important; 19 | } 20 | 21 | &:hover, 22 | &:focus { 23 | background-color: rgba(0, 0, 0, 0.05) !important; 24 | } 25 | &:focus { 26 | border: 1px solid black !important; 27 | } 28 | } 29 | 30 | & > button:not(:hover) { 31 | background: none !important; 32 | } 33 | `; 34 | 35 | interface TrashButtonProps { 36 | onClick: () => void; 37 | disabled?: boolean; 38 | } 39 | export const TrashButton: React.FC = (props) => ( 40 | 41 | 42 | 43 | 44 | 45 | ); 46 | -------------------------------------------------------------------------------- /src/utils/hooks/Task/useUpdateTask.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, gql } from "@apollo/client"; 2 | import type { Status } from "interfaces"; 3 | 4 | interface Input { 5 | id: number; 6 | description: string; 7 | status: Status; 8 | } 9 | 10 | export interface UPDATE_TASK_PROPS { 11 | id: number; 12 | description: string; 13 | status: Status; 14 | updated_at: Date; 15 | } 16 | export const UPDATE_TASK = gql` 17 | mutation updateTask($id: Int!, $description: String!, $status: String!) { 18 | update_task( 19 | pk_columns: { id: $id } 20 | _set: { description: $description, status: $status } 21 | ) { 22 | id 23 | description 24 | status 25 | updated_at 26 | } 27 | } 28 | `; 29 | 30 | /** 31 | * Return function that updates description and status of task with given id 32 | */ 33 | export const useUpdateTask = ({ id, description, status }: Input) => 34 | useMutation(UPDATE_TASK, { 35 | variables: { 36 | id, 37 | description, 38 | status, 39 | }, 40 | optimisticResponse: { 41 | id, 42 | description, 43 | status, 44 | updated_at: new Date(), 45 | }, 46 | }); 47 | -------------------------------------------------------------------------------- /src/utils/Observers/Task.ts: -------------------------------------------------------------------------------- 1 | import { Observer, Subject } from "interfaces"; 2 | import { GroupSubject } from "utils/Observers/Group"; 3 | 4 | export class Task implements Observer { 5 | private _name: string; 6 | private locked = false; 7 | 8 | public constructor(name: string) { 9 | this._name = name; 10 | } 11 | 12 | public getName = () => this._name; 13 | public setName = (newName: string) => { 14 | if (!this.locked) this._name = newName; 15 | }; 16 | public isLocked = () => this.locked; 17 | 18 | public deleteSelf = () => {}; 19 | 20 | public update(subject: Subject): void { 21 | if (!(subject instanceof GroupSubject)) { 22 | console.warn("Observer is wrong typed, GroupSubject expected"); 23 | } 24 | 25 | // @ts-ignore 26 | switch (subject?.state) { 27 | case 0: 28 | console.log("Task: attached Group was deleted, delete task."); 29 | this.deleteSelf(); 30 | break; 31 | case 1: 32 | console.log("Task: attached Group is loaded, unlock task."); 33 | this.locked = false; 34 | break; 35 | case 2: 36 | console.log("Task: attached Group is loading, lock task."); 37 | this.locked = true; 38 | break; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/utils/hooks/Task/useDeleteTask.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, gql } from "@apollo/client"; 2 | 3 | export interface DELETE_TASK_VALUE { 4 | delete_task: { 5 | id: number; 6 | group: { 7 | id: number; 8 | desk_id: number; 9 | }; 10 | }; 11 | } 12 | export interface DELETE_TASK_PROPS { 13 | id: number; 14 | } 15 | 16 | export const DELETE_TASK = gql` 17 | mutation deleteTask($id: Int!) { 18 | delete_task(id: $id) { 19 | id 20 | group { 21 | desk_id 22 | id 23 | } 24 | } 25 | } 26 | `; 27 | 28 | /** 29 | * Return function that deletes task with given id 30 | */ 31 | export const useDeleteTask = (id: number) => 32 | useMutation(DELETE_TASK, { 33 | variables: { 34 | id, 35 | }, 36 | update( 37 | cache, 38 | { 39 | data: { 40 | delete_task: { id, group }, 41 | }, 42 | } 43 | ) { 44 | cache.modify({ 45 | id: cache.identify({ 46 | __typename: "groups", 47 | id: String(group.id), 48 | }), 49 | fields: { 50 | tasks: (tasks) => 51 | tasks.filter((task) => task.__ref.split(":")[1] !== String(id)), 52 | }, 53 | }); 54 | }, 55 | }); 56 | -------------------------------------------------------------------------------- /src/utils/Observers/Observer.test.ts: -------------------------------------------------------------------------------- 1 | import { GroupSubject } from "utils/Observers/Group"; 2 | import { Task } from "utils/Observers/Task"; 3 | 4 | // Tests setup 5 | const group = new GroupSubject(); 6 | const task = new Task("Task name"); 7 | group.attach(task); 8 | 9 | test("Default state is right", () => { 10 | expect(group.getState()).toEqual(1); 11 | expect(task.isLocked()).toEqual(false); 12 | expect(task.getName()).toEqual("Task name"); 13 | }); 14 | 15 | test("Observer updates state and notifies subscribers", () => { 16 | let consoleOutput = []; 17 | const mockedWarn = (output: any) => consoleOutput.push(output); 18 | 19 | beforeEach(() => { 20 | console.log = mockedWarn; 21 | }); 22 | 23 | group.update().then((state) => { 24 | expect(state).toEqual(1); 25 | expect(consoleOutput).toHaveLength(4); 26 | expect(consoleOutput.includes("Group: Updating...")).toBe(true); 27 | expect(task.isLocked()).toEqual(false); 28 | }); 29 | }); 30 | 31 | test("Task is locked when loading", () => { 32 | group.update(); 33 | 34 | expect(group.getState()).toEqual(2); 35 | expect(task.isLocked()).toEqual(true); 36 | 37 | expect(task.getName()).toEqual("Task name"); 38 | task.setName("testName"); 39 | expect(task.getName()).toEqual("Task name"); 40 | }); 41 | -------------------------------------------------------------------------------- /src/utils/hooks/Group/useDeleteGroup.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, gql } from "@apollo/client"; 2 | import { GET_DESK } from "apollo"; 3 | 4 | export interface DELETE_GROUP_VALUE { 5 | delete_group: { 6 | __typename: "groups"; 7 | id: number; 8 | desk_id: number; 9 | }; 10 | } 11 | export interface DELETE_GROUP_PROPS { 12 | id: number; 13 | } 14 | 15 | export const DELETE_GROUP = gql` 16 | mutation deleteGroup($id: Int!) { 17 | delete_group(id: $id) { 18 | id 19 | desk_id 20 | } 21 | } 22 | `; 23 | 24 | /** 25 | * Returns function that deletes group with given id 26 | */ 27 | export const useDeleteGroup = (id: number) => 28 | useMutation(DELETE_GROUP, { 29 | variables: { 30 | id, 31 | }, 32 | update(cache, { data: { delete_group } }) { 33 | const { desk } = cache.readQuery({ 34 | query: GET_DESK, 35 | variables: { id: String(delete_group.desk_id) }, 36 | }); 37 | 38 | const groups = desk.groups.filter( 39 | (group) => group.id !== delete_group.id 40 | ); 41 | 42 | cache.writeQuery({ 43 | query: GET_DESK, 44 | variables: { id: String(delete_group.desk_id) }, 45 | data: { desk: { ...desk, groups } }, 46 | }); 47 | }, 48 | }); 49 | -------------------------------------------------------------------------------- /src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export interface IDesk { 2 | id: number; 3 | name: string; 4 | index: number; 5 | } 6 | 7 | export interface IGroup { 8 | id: number; 9 | name: string; 10 | index: number; 11 | desk_id: number; 12 | tasks: ITask[]; 13 | } 14 | 15 | export interface ITask { 16 | id: number; 17 | description: string; 18 | index: number; 19 | status: Status; 20 | created_at: Date; 21 | updated_at: Date; 22 | } 23 | 24 | export type Status = "Planned" | "In Progress" | "Done" | "Failed"; 25 | 26 | export enum SystemRole { 27 | User = "User", 28 | Moderator = "Moderator", 29 | Admin = "Admin", 30 | } 31 | 32 | /** 33 | * Интферфейс издателя объявляет набор методов для управлениями подписчиками. 34 | */ 35 | export interface Subject { 36 | // Присоединяет наблюдателя к издателю. 37 | attach: (observer: Observer) => void; 38 | 39 | // Отсоединяет наблюдателя от издателя. 40 | detach: (observer: Observer) => void; 41 | 42 | // Уведомляет всех наблюдателей о событии. 43 | notify: () => void; 44 | } 45 | 46 | /** 47 | * Интерфейс Наблюдателя объявляет метод уведомления, который издатели 48 | * используют для оповещения своих подписчиков. 49 | */ 50 | export interface Observer { 51 | // Получить обновление от субъекта. 52 | update: (subject: Subject) => void; 53 | } 54 | -------------------------------------------------------------------------------- /src/utils/hooks/Desk/useCreateDesk.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, gql } from "@apollo/client"; 2 | import { GET_DESKS } from "apollo"; 3 | 4 | interface Input { 5 | name: string; 6 | index: number; 7 | } 8 | export interface CREATE_DESK_VALUE { 9 | create_desk: { 10 | id: number; 11 | name: string; 12 | index: number; 13 | created_at: Date; 14 | updated_at: Date; 15 | }; 16 | } 17 | export interface CREATE_DESK_PROPS { 18 | desk: { 19 | name: string; 20 | index: number; 21 | }; 22 | } 23 | export const CREATE_DESK = gql` 24 | mutation createDesk($desk: desks_insert_input!) { 25 | create_desk(object: $desk) { 26 | id 27 | name 28 | index 29 | created_at 30 | updated_at 31 | } 32 | } 33 | `; 34 | 35 | /** 36 | * Returns function that creates Desk with given name and index 37 | */ 38 | export const useCreateDesk = ({ name, index }: Input) => 39 | useMutation(CREATE_DESK, { 40 | variables: { 41 | desk: { 42 | name, 43 | index, 44 | }, 45 | }, 46 | update(cache, { data: { create_desk } }) { 47 | const { desks } = cache.readQuery({ query: GET_DESKS }); 48 | cache.writeQuery({ 49 | query: GET_DESKS, 50 | data: { desks: desks.concat([create_desk]) }, 51 | }); 52 | }, 53 | }); 54 | -------------------------------------------------------------------------------- /src/components/Group/Header.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import { useThrottle } from "react-use"; 4 | 5 | import type { IGroup } from "interfaces"; 6 | import { useGroupName, useDeleteGroup } from "utils/hooks"; 7 | 8 | import { Input, TrashButton } from "components"; 9 | 10 | const Wrapper = styled.div` 11 | display: flex; 12 | align-items: center; 13 | justify-content: space-between; 14 | height: 40px; 15 | margin-bottom: 8px; 16 | `; 17 | 18 | interface HeaderProps { 19 | group: IGroup; 20 | } 21 | 22 | export const Header: React.FC = ({ group }) => { 23 | const [currentName, setName] = React.useState(group.name); 24 | const debouncedName = useThrottle(currentName); 25 | const [updateName] = useGroupName({ id: group.id, name: currentName }); 26 | const [deleteGroup, { loading }] = useDeleteGroup(group.id); 27 | 28 | React.useEffect(() => { 29 | if (debouncedName && group.name !== debouncedName) { 30 | updateName(); 31 | } 32 | }, [group, debouncedName, updateName]); 33 | 34 | return ( 35 | 36 | setName(newValue.target.value)} 39 | /> 40 | {group.tasks.length === 0 && ( 41 | deleteGroup()} disabled={loading} /> 42 | )} 43 | 44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /src/components/Desks/DesksDragContext.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { DragDropContext, DropResult } from "react-beautiful-dnd"; 3 | 4 | import { useDesks, useDeskIndex } from "utils/hooks"; 5 | import { compareIndex, moveElementInArray } from "utils/scripts"; 6 | 7 | interface DesksDragContextProps {} 8 | 9 | export const DesksDragContext: React.FC = ({ 10 | children, 11 | }) => { 12 | const { data } = useDesks(); 13 | const [setDeskIndex] = useDeskIndex(); 14 | 15 | const onDragEnd = ({ destination, source }: DropResult) => { 16 | if (!destination) return; 17 | 18 | // If source and destination are the same, skip 19 | if ( 20 | destination.droppableId === source.droppableId && 21 | destination.index === source.index 22 | ) { 23 | return; 24 | } 25 | 26 | // Get array of desks after reorder 27 | const endArray = moveElementInArray( 28 | Array.from(data.desks).sort(compareIndex), 29 | source.index - 1, 30 | destination.index - 1 31 | ); 32 | 33 | // Found desks with wrong index 34 | const desksWithWrongIndex = endArray 35 | .map((desk, i) => ({ ...desk, index: i + 1 })) 36 | .filter((desk, i) => desk.index !== endArray[i].index); 37 | 38 | // For each desk with wrong index, update index 39 | for (let desk of desksWithWrongIndex) { 40 | setDeskIndex({ id: desk.id, index: desk.index }); 41 | } 42 | }; 43 | 44 | return {children}; 45 | }; 46 | -------------------------------------------------------------------------------- /src/utils/hooks/Desk/useDeskIndex.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, gql, MutationResult } from "@apollo/client"; 2 | 3 | interface Input { 4 | id: number; 5 | index: number; 6 | } 7 | export interface CHANGE_DESK_INDEX_VALUE { 8 | update_desk: { 9 | id: number; 10 | index: number; 11 | }; 12 | } 13 | export interface CHANGE_DESK_INDEX_PROPS { 14 | id: number; 15 | index: number; 16 | } 17 | export const CHANGE_DESK_INDEX = gql` 18 | mutation updateDeskIndex($id: Int!, $index: Int!) { 19 | update_desk(pk_columns: { id: $id }, _set: { index: $index }) { 20 | id 21 | index 22 | } 23 | } 24 | `; 25 | 26 | /** 27 | * Returns mutation, that changes index of desk with given id 28 | */ 29 | export const useDeskIndex = (): [ 30 | (props: Input) => void, 31 | MutationResult 32 | ] => { 33 | const [mutation, props] = useMutation< 34 | CHANGE_DESK_INDEX_VALUE, 35 | CHANGE_DESK_INDEX_PROPS 36 | >(CHANGE_DESK_INDEX, { 37 | update(cache, { data: { update_desk } }) { 38 | cache.modify({ 39 | id: cache.identify({ 40 | __typename: "desks", 41 | id: update_desk.id, 42 | }), 43 | fields: { 44 | index: () => update_desk.index, 45 | }, 46 | }); 47 | }, 48 | }); 49 | 50 | const update = ({ id, index }: Input) => { 51 | mutation({ 52 | variables: { id, index }, 53 | optimisticResponse: { 54 | update_desk: { 55 | id, 56 | index, 57 | }, 58 | }, 59 | }); 60 | }; 61 | 62 | return [update, props]; 63 | }; 64 | -------------------------------------------------------------------------------- /src/utils/hooks/Task/useTaskIndex.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, gql, MutationResult } from "@apollo/client"; 2 | 3 | interface Input { 4 | id: number; 5 | index: number; 6 | } 7 | export interface CHANGE_TASK_INDEX_VALUE { 8 | update_task: { 9 | id: number; 10 | index: number; 11 | }; 12 | } 13 | export interface CHANGE_TASK_INDEX_PROPS { 14 | id: number; 15 | index: number; 16 | } 17 | export const CHANGE_TASK_INDEX = gql` 18 | mutation updateTaskIndex($id: Int!, $index: Int!) { 19 | update_task(pk_columns: { id: $id }, _set: { index: $index }) { 20 | id 21 | index 22 | } 23 | } 24 | `; 25 | 26 | /** 27 | * Returns mutation, that changes index of task with given id 28 | */ 29 | export const useTaskIndex = (): [ 30 | (props: Input) => void, 31 | MutationResult 32 | ] => { 33 | const [mutation, props] = useMutation< 34 | CHANGE_TASK_INDEX_VALUE, 35 | CHANGE_TASK_INDEX_PROPS 36 | >(CHANGE_TASK_INDEX, { 37 | update(cache, { data: { update_task } }) { 38 | cache.modify({ 39 | id: cache.identify({ 40 | __typename: "tasks", 41 | id: update_task.id, 42 | }), 43 | fields: { 44 | index: () => update_task.index, 45 | }, 46 | }); 47 | }, 48 | }); 49 | 50 | const update = ({ id, index }: Input) => { 51 | mutation({ 52 | variables: { id, index }, 53 | optimisticResponse: { 54 | update_task: { 55 | id: id, 56 | index: index, 57 | }, 58 | }, 59 | }); 60 | }; 61 | 62 | return [update, props]; 63 | }; 64 | -------------------------------------------------------------------------------- /src/components/Desks/DesksCatalog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import { useMedia } from "react-use"; 4 | import { Droppable } from "react-beautiful-dnd"; 5 | 6 | import { CreateDesk } from "components/Desks"; 7 | 8 | const Catalog = styled.div` 9 | display: flex; 10 | padding: 32px; 11 | height: fit-content; 12 | width: fit-content; 13 | 14 | & > * { 15 | height: 200px; 16 | width: 300px; 17 | } 18 | 19 | & > a { 20 | margin-right: 16px; 21 | } 22 | 23 | @media (max-width: 600px) { 24 | flex-direction: column; 25 | padding: 16px; 26 | width: 100%; 27 | 28 | & > a { 29 | margin-right: 0; 30 | margin-bottom: 16px; 31 | } 32 | 33 | & > * { 34 | width: 100%; 35 | } 36 | } 37 | `; 38 | 39 | interface DesksCatalogProps { 40 | numberOfDesks: number; 41 | } 42 | 43 | export const DesksCatalog: React.FC = ({ 44 | children, 45 | numberOfDesks, 46 | }) => { 47 | const isMobile = useMedia("(max-width: 600px)"); 48 | 49 | return ( 50 | 54 | {(provided) => ( 55 | 61 | {children} 62 | {provided.placeholder} 63 | 64 | 65 | )} 66 | 67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /src/utils/hooks/Group/useCreateGroup.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, gql } from "@apollo/client"; 2 | import { GET_DESK } from "apollo"; 3 | 4 | interface Input { 5 | name: string; 6 | index: number; 7 | desk_id: number; 8 | } 9 | export interface CREATE_GROUP_VALUE { 10 | create_group: { 11 | __typename: "groups"; 12 | id: number; 13 | name: string; 14 | index: number; 15 | created_at: Date; 16 | updated_at: Date; 17 | desk_id: number; 18 | }; 19 | } 20 | export interface CREATE_GROUP_PROPS { 21 | group: Input; 22 | } 23 | export const CREATE_GROUP = gql` 24 | mutation createGroup($group: groups_insert_input!) { 25 | create_group(object: $group) { 26 | id 27 | name 28 | index 29 | desk_id 30 | } 31 | } 32 | `; 33 | 34 | /** 35 | * Return mutation that creates group with given name, index and desk_id 36 | */ 37 | export const useCreateGroup = ({ name, desk_id, index }: Input) => 38 | useMutation(CREATE_GROUP, { 39 | variables: { 40 | group: { 41 | name, 42 | desk_id, 43 | index, 44 | }, 45 | }, 46 | update(cache, { data: { create_group } }) { 47 | const { desk } = cache.readQuery({ 48 | query: GET_DESK, 49 | variables: { id: String(create_group.desk_id) }, 50 | }); 51 | 52 | const groups = desk.groups.concat({ ...create_group, tasks: [] }); 53 | 54 | cache.writeQuery({ 55 | query: GET_DESK, 56 | variables: { id: String(create_group.desk_id) }, 57 | data: { desk: { ...desk, groups } }, 58 | }); 59 | }, 60 | }); 61 | -------------------------------------------------------------------------------- /src/components/Group/Group.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import { Droppable } from "react-beautiful-dnd"; 4 | 5 | import type { IGroup } from "interfaces"; 6 | import { compareIndex } from "utils"; 7 | 8 | import { Task, CreateTask } from "components/Task"; 9 | import { Header } from "components/Group"; 10 | 11 | const Wrapper = styled.div` 12 | background-color: #fff; 13 | box-shadow: 0px 4px 30px rgba(22, 33, 74, 0.05); 14 | border-radius: 4px; 15 | border: none; 16 | display: flex; 17 | flex-direction: column; 18 | height: fit-content; 19 | padding: 0.5em; 20 | 21 | & > div.task { 22 | margin-bottom: 12px; 23 | } 24 | 25 | & .trashButton { 26 | opacity: 0; 27 | } 28 | &:hover .trashButton { 29 | opacity: 1; 30 | } 31 | 32 | margin-right: 16px; 33 | width: 280px; 34 | 35 | @media (max-width: 600px) { 36 | margin-right: 0px; 37 | margin-bottom: 16px; 38 | width: 100%; 39 | } 40 | `; 41 | 42 | interface GroupProps { 43 | group: IGroup; 44 | } 45 | 46 | export const Group: React.FC = ({ group }) => ( 47 | 48 | {(provided) => ( 49 | 50 | 51 | {Array.from(group.tasks) 52 | .sort(compareIndex) 53 | .map((task, index) => ( 54 | 55 | ))} 56 | {provided.placeholder} 57 | 58 | 59 | )} 60 | 61 | ); 62 | -------------------------------------------------------------------------------- /src/utils/Observers/Group.ts: -------------------------------------------------------------------------------- 1 | import { Observer, Subject } from "interfaces"; 2 | 3 | const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 4 | 5 | export class GroupSubject implements Subject { 6 | /** 7 | * Внутреннее состояние группы 8 | */ 9 | private state = 1; 10 | 11 | /** 12 | * Список подписчиков 13 | */ 14 | private observers: Observer[] = []; 15 | 16 | public getState = () => this.state; 17 | 18 | public attach(observer: Observer): void { 19 | const isExist = this.observers.includes(observer); 20 | if (isExist) { 21 | return console.log("Subject: Observer has been attached already."); 22 | } 23 | 24 | console.log("Subject: Attached an observer."); 25 | this.observers.push(observer); 26 | } 27 | 28 | public detach(observer: Observer): void { 29 | const observerIndex = this.observers.indexOf(observer); 30 | if (observerIndex === -1) { 31 | return console.log("Subject: Nonexistent observer."); 32 | } 33 | 34 | this.observers.splice(observerIndex, 1); 35 | console.log("Subject: Detached an observer."); 36 | } 37 | 38 | public notify(): void { 39 | console.log("Subject: Notifying observers..."); 40 | for (const observer of this.observers) { 41 | observer.update(this); 42 | } 43 | } 44 | 45 | // Пример бизнес-логики 46 | public async update() { 47 | console.log("Group: Updating..."); 48 | this.state = 2; 49 | this.notify(); 50 | 51 | // Симулировать загрузку 52 | await delay(3000); 53 | 54 | console.log(`Group: Updated`); 55 | this.state = 1; 56 | this.notify(); 57 | 58 | return this.state; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/components/Desks/DeskCard.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import type { IDesk } from "interfaces"; 4 | import { Link } from "react-router-dom"; 5 | import { Draggable } from "react-beautiful-dnd"; 6 | 7 | const Card = styled.div` 8 | align-items: center; 9 | background-color: #fff; 10 | border: none; 11 | border-radius: 12px; 12 | box-shadow: 0px 4px 30px rgba(22, 33, 74, 0.05); 13 | color: #4d5358; 14 | cursor: pointer; 15 | display: flex; 16 | justify-content: center; 17 | font-size: 15px; 18 | height: 100%; 19 | outline: none; 20 | overflow: hidden; 21 | position: relative; 22 | 23 | &:focus, 24 | &:hover { 25 | border: 1px solid #4d5358; 26 | } 27 | 28 | & > h2 { 29 | font-size: 24px; 30 | text-decoration: none; 31 | } 32 | `; 33 | 34 | const Tag = styled.div` 35 | bottom: -22px; 36 | font-weight: 700; 37 | font-size: 88px; 38 | opacity: 0.1; 39 | position: absolute; 40 | right: 20px; 41 | user-select: none; 42 | z-index: 0; 43 | `; 44 | 45 | interface DeskProps { 46 | desk: IDesk; 47 | } 48 | 49 | export const Desk: React.FC = ({ desk }) => ( 50 | 51 | {(provided) => ( 52 | 60 | 61 | {desk.name} 62 | {desk.id} 63 | 64 | 65 | )} 66 | 67 | ); 68 | -------------------------------------------------------------------------------- /src/components/Desks/CreateDeskModalForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useCreateDesk } from "utils/hooks"; 3 | import { Button, Input, Modal, Text } from "@zeit-ui/react"; 4 | import styled from "styled-components"; 5 | 6 | const Form = styled.form` 7 | display: grid; 8 | gap: 1em; 9 | `; 10 | 11 | interface CreateDeskModalFormProps { 12 | isOpen: boolean; 13 | closeModal: () => void; 14 | index: number; 15 | } 16 | 17 | export const CreateDeskModalForm: React.FC = ({ 18 | isOpen, 19 | closeModal, 20 | index, 21 | }) => { 22 | const [name, setName] = React.useState(""); 23 | 24 | const [createDesk, { loading, error }] = useCreateDesk({ name, index }); 25 | 26 | const createNewDesk = async (e: React.FormEvent) => { 27 | e.preventDefault(); 28 | createDesk().then(() => { 29 | setName(""); 30 | closeModal(); 31 | }); 32 | }; 33 | 34 | return ( 35 | 36 | 37 | Добавить новую доску 38 | 39 | setName(e.target.value)} 42 | placeholder="New Desk" 43 | size="large" 44 | width="100%" 45 | > 46 | Доска 47 | 48 | 49 | {error && {error.message}} 50 | 51 | 57 | Создать 58 | 59 | 60 | 61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /src/utils/hooks/tests/useDesk.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { render } from "@testing-library/react"; 3 | import { BrowserRouter as Router } from "react-router-dom"; 4 | import { MockedProvider } from "@apollo/client/testing"; 5 | import { DeskOverview } from "pages"; 6 | import { GET_DESK } from "apollo"; 7 | 8 | const mocks = [ 9 | { 10 | request: { 11 | query: GET_DESK, 12 | }, 13 | result: { 14 | data: { 15 | desk: { 16 | id: 1, 17 | name: "Next release", 18 | __typename: "desks", 19 | groups: [ 20 | { 21 | id: 1, 22 | name: "Planned", 23 | index: 1, 24 | __typename: "groups", 25 | tasks: [ 26 | { 27 | id: 18, 28 | description: "Create sometjing", 29 | status: "In Progress", 30 | index: 1, 31 | __typename: "tasks", 32 | }, 33 | ], 34 | }, 35 | { 36 | id: 1, 37 | name: "Done", 38 | index: 2, 39 | __typename: "groups", 40 | tasks: [], 41 | }, 42 | ], 43 | }, 44 | }, 45 | }, 46 | }, 47 | ]; 48 | 49 | jest.mock("react-router-dom", () => ({ 50 | ...jest.requireActual("react-router-dom"), 51 | useParams: () => ({ 52 | id: "1", 53 | }), 54 | })); 55 | 56 | it("renders without error", () => { 57 | render( 58 | 59 | 60 | 61 | 62 | 63 | ); 64 | }); 65 | -------------------------------------------------------------------------------- /src/components/Group/CreateGroupModalForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Button, Input, Modal, Text } from "@zeit-ui/react"; 3 | import styled from "styled-components"; 4 | 5 | import { useCreateGroup } from "utils/hooks"; 6 | 7 | const Form = styled.form` 8 | display: grid; 9 | gap: 1em; 10 | `; 11 | 12 | interface CreateGroupModalFormProps { 13 | isOpen: boolean; 14 | closeModal: () => void; 15 | desk_id: number; 16 | index: number; 17 | } 18 | 19 | export const CreateGroupModalForm: React.FC = ({ 20 | isOpen, 21 | closeModal, 22 | desk_id, 23 | index, 24 | }) => { 25 | const [name, setName] = React.useState(""); 26 | 27 | const [createGroup, { loading, error }] = useCreateGroup({ 28 | name, 29 | desk_id, 30 | index, 31 | }); 32 | 33 | const createNewGroup = async (e: React.FormEvent) => { 34 | e.preventDefault(); 35 | createGroup().then(() => { 36 | setName(""); 37 | closeModal(); 38 | }); 39 | }; 40 | 41 | return ( 42 | 43 | 44 | Добавить новую группу 45 | 46 | setName(e.target.value)} 50 | placeholder="New Group" 51 | size="large" 52 | width="100%" 53 | > 54 | Название 55 | 56 | 57 | {error && {error.message}} 58 | 59 | 65 | Создать 66 | 67 | 68 | 69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "software-development-dashboard", 3 | "version": "1.0.0", 4 | "private": false, 5 | "scripts": { 6 | "build": "react-scripts build", 7 | "start": "react-scripts start", 8 | "test": "react-scripts test --env=jsdom", 9 | "lint": "eslint . --ext js,jsx,ts,tsx --max-warnings=0" 10 | }, 11 | "dependencies": { 12 | "@apollo/client": "3.0.0-rc.4", 13 | "@zeit-ui/react": "1.7.0-canary.4", 14 | "@zeit-ui/react-icons": "^1.2.2", 15 | "graphql": "^15.1.0", 16 | "react": "^16.13.1", 17 | "react-beautiful-dnd": "^13.0.0", 18 | "react-dom": "^16.13.1", 19 | "react-router-dom": "^5.2.0", 20 | "react-scripts": "3.4.1", 21 | "react-use": "^15.2.2", 22 | "styled-components": "^5.1.1" 23 | }, 24 | "devDependencies": { 25 | "@testing-library/jest-dom": "^5.10.0", 26 | "@testing-library/react": "^10.2.1", 27 | "@testing-library/user-event": "^11.4.2", 28 | "@types/jest": "^26.0.0", 29 | "@types/react": "^16.9.36", 30 | "@types/react-dom": "^16.9.8", 31 | "@types/react-router-dom": "^5.1.5", 32 | "@types/react-test-renderer": "^16.9.2", 33 | "@types/styled-components": "5.1.0", 34 | "@types/react-beautiful-dnd": "^13.0.0", 35 | "@typescript-eslint/eslint-plugin": "3.0.0", 36 | "@typescript-eslint/parser": "3.0.0", 37 | "eslint-config-alloy": "3.7.1", 38 | "eslint-plugin-jsx-a11y": "^6.2.3", 39 | "eslint-plugin-react": "^7.20.0", 40 | "eslint-plugin-testing-library": "^3.2.2", 41 | "typescript": "~3.9.5" 42 | }, 43 | "eslintConfig": { 44 | "extends": "react-app" 45 | }, 46 | "browserslist": { 47 | "production": [ 48 | ">0.2%", 49 | "not dead", 50 | "not op_mini all" 51 | ], 52 | "development": [ 53 | "last 1 chrome version", 54 | "last 1 firefox version", 55 | "last 1 safari version" 56 | ] 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/components/Task/Task.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import { Draggable } from "react-beautiful-dnd"; 4 | 5 | import type { ITask } from "interfaces"; 6 | import { EditTaskModalForm, Status } from "components/Task"; 7 | 8 | interface TaskProps { 9 | task: ITask; 10 | index: number; 11 | } 12 | 13 | const Wrapper = styled.div` 14 | background-color: rgba(248, 248, 250, 1); 15 | border-radius: 0.25em; 16 | box-shadow: 0 1px 0 rgba(9, 30, 66, 0.2); 17 | 18 | cursor: pointer; 19 | 20 | color: #172b4d; 21 | padding: 0.5em 1em; 22 | position: relative; 23 | overflow: hidden; 24 | 25 | white-space: normal; 26 | overflow: hidden; 27 | display: -webkit-box; 28 | -webkit-line-clamp: 4; 29 | -webkit-box-orient: vertical; 30 | `; 31 | 32 | const Tag = styled.div` 33 | bottom: 0px; 34 | font-weight: 700; 35 | font-size: 24px; 36 | opacity: 0.15; 37 | position: absolute; 38 | right: 16px; 39 | user-select: none; 40 | z-index: 0; 41 | `; 42 | 43 | export const Task: React.FC = ({ task, index }) => { 44 | const [mode, setMode] = React.useState(false); 45 | 46 | return ( 47 | <> 48 | 49 | {(provided) => ( 50 | setMode(true)} 53 | {...provided.draggableProps} 54 | {...provided.dragHandleProps} 55 | ref={provided.innerRef} 56 | > 57 | {task.id} 58 | 59 | {task.description} 60 | 61 | )} 62 | 63 | setMode(false)} 66 | id={task.id} 67 | initialDescription={task.description} 68 | initialStatus={task.status} 69 | /> 70 | > 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /src/utils/hooks/Task/useCreateTask.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, gql } from "@apollo/client"; 2 | import { GET_DESK } from "apollo"; 3 | import type { Status } from "interfaces"; 4 | 5 | interface Input { 6 | description: string; 7 | status: Status; 8 | group_id: number; 9 | index: number; 10 | } 11 | 12 | export interface CREATE_TASK_VALUE { 13 | create_task: { 14 | id: number; 15 | description: string; 16 | index: number; 17 | group: { 18 | id: number; 19 | desk_id: number; 20 | }; 21 | created_at: Date; 22 | updated_at: Date; 23 | }; 24 | } 25 | export interface CREATE_TASK_PROPS { 26 | task: { 27 | description: string; 28 | status: Status; 29 | group_id: number; 30 | index: number; 31 | }; 32 | } 33 | export const CREATE_TASK = gql` 34 | mutation createTask($task: tasks_insert_input!) { 35 | create_task(object: $task) { 36 | id 37 | description 38 | index 39 | group { 40 | id 41 | desk_id 42 | } 43 | created_at 44 | updated_at 45 | } 46 | } 47 | `; 48 | 49 | /** 50 | * Return function that creates task with given description, status, group_id and index 51 | */ 52 | export const useCreateTask = ({ 53 | description, 54 | status, 55 | group_id, 56 | index, 57 | }: Input) => 58 | useMutation(CREATE_TASK, { 59 | variables: { 60 | task: { 61 | description, 62 | status, 63 | group_id, 64 | index, 65 | }, 66 | }, 67 | update(cache, { data: { create_task } }) { 68 | const { desk } = cache.readQuery({ 69 | query: GET_DESK, 70 | variables: { id: String(create_task.group.desk_id) }, 71 | }); 72 | 73 | const groups = desk.groups.map((group) => { 74 | if (group.id === create_task.group.id) { 75 | return [...group.tasks, { ...create_task }]; 76 | } 77 | return group; 78 | }); 79 | 80 | cache.writeQuery({ 81 | query: GET_DESK, 82 | variables: { id: String(create_task.group.desk_id) }, 83 | data: { desk: { ...desk, groups } }, 84 | }); 85 | }, 86 | }); 87 | -------------------------------------------------------------------------------- /src/pages/Desk.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import { useParams } from "react-router-dom"; 4 | 5 | import { useDesk } from "utils/hooks"; 6 | import { compareIndex } from "utils/scripts"; 7 | 8 | import { 9 | BackButton, 10 | NotFound404, 11 | Header, 12 | GitHubButton, 13 | WorkSpace, 14 | } from "components"; 15 | import { CreateGroup, Group } from "components/Group"; 16 | import { DeleteDeskButton, DeskDragContext, DeskName } from "components/Desks"; 17 | 18 | const Catalog = styled.div` 19 | display: flex; 20 | padding: 32px; 21 | white-space: nowrap; 22 | 23 | @media (max-width: 600px) { 24 | flex-direction: column; 25 | padding: 16px; 26 | } 27 | `; 28 | 29 | export const DeskOverview: React.FC = () => { 30 | const { id } = useParams(); 31 | const { data, loading, error } = useDesk(id); 32 | 33 | React.useEffect(() => { 34 | document.title = data?.desk?.name || `Desk #${id}`; 35 | }, [id, data]); 36 | 37 | // If loading is false and no data, return 404 38 | if (!loading && !data?.desk) { 39 | return ; 40 | } 41 | 42 | return ( 43 | 44 | 45 | 46 | 47 | {data?.desk ? ( 48 | 49 | ) : ( 50 | Desk 51 | )} 52 | 53 | 54 | 55 | {loading && Loading...} 56 | {data?.desk && ( 57 | 58 | )} 59 | 60 | 61 | 62 | 63 | {data?.desk && ( 64 | 65 | {Array.from(data.desk.groups) 66 | .sort(compareIndex) 67 | .map((group) => ( 68 | 69 | ))} 70 | 71 | 72 | )} 73 | 74 | 75 | 76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /src/utils/hooks/Task/useTaskGroupId.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, gql, MutationResult } from "@apollo/client"; 2 | 3 | interface Input { 4 | id: number; 5 | group_id: number; 6 | old_group_id: number; 7 | } 8 | export interface CHANGE_TASK_GROUP_ID_VALUE { 9 | update_task: { 10 | id: number; 11 | group_id: number; 12 | }; 13 | } 14 | export interface CHANGE_TASK_GROUP_ID_PROPS { 15 | id: number; 16 | group_id: number; 17 | } 18 | export const CHANGE_TASK_GROUP_ID = gql` 19 | mutation updateTaskGroupId($id: Int!, $group_id: Int!) { 20 | update_task(pk_columns: { id: $id }, _set: { group_id: $group_id }) { 21 | id 22 | group_id 23 | } 24 | } 25 | `; 26 | 27 | /** 28 | * Returns mutation, that changes index of task with given id 29 | */ 30 | export const useTaskGroupId = (): [ 31 | (props: Input) => void, 32 | MutationResult 33 | ] => { 34 | const [mutation, props] = useMutation< 35 | CHANGE_TASK_GROUP_ID_VALUE, 36 | CHANGE_TASK_GROUP_ID_PROPS 37 | >(CHANGE_TASK_GROUP_ID); 38 | 39 | const update = ({ id, group_id, old_group_id }: Input) => { 40 | mutation({ 41 | variables: { 42 | id, 43 | group_id, 44 | }, 45 | optimisticResponse: { 46 | update_task: { 47 | id, 48 | group_id, 49 | }, 50 | }, 51 | update(cache, { data: { update_task } }) { 52 | // Update task group_id 53 | cache.modify({ 54 | id: cache.identify({ 55 | __typename: "tasks", 56 | id: update_task.id, 57 | }), 58 | fields: { 59 | group_id: () => update_task.group_id, 60 | }, 61 | }); 62 | 63 | // Remove task from old group 64 | cache.modify({ 65 | id: cache.identify({ 66 | __typename: "groups", 67 | id: String(old_group_id), 68 | }), 69 | fields: { 70 | tasks: (tasks) => 71 | tasks.filter((task) => task.__ref.split(":")[1] !== String(id)), 72 | }, 73 | }); 74 | 75 | // Add task to new group 76 | cache.modify({ 77 | id: cache.identify({ 78 | __typename: "groups", 79 | id: update_task.group_id, 80 | }), 81 | fields: { 82 | tasks: (tasks) => 83 | tasks.concat({ __ref: `tasks:${update_task.id}` }), 84 | }, 85 | }); 86 | }, 87 | }); 88 | }; 89 | 90 | return [update, props]; 91 | }; 92 | -------------------------------------------------------------------------------- /src/components/Task/CreateTaskModalForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Button, Input, Modal, Text, Select } from "@zeit-ui/react"; 3 | import styled from "styled-components"; 4 | 5 | import { useCreateTask } from "utils/hooks"; 6 | import type { Status } from "interfaces"; 7 | 8 | import { Status as StatusTag } from "components/Task"; 9 | 10 | const Form = styled.form` 11 | display: grid; 12 | gap: 1em; 13 | `; 14 | 15 | interface CreateTaskModalFormProps { 16 | isOpen: boolean; 17 | closeModal: () => void; 18 | group_id: number; 19 | index: number; 20 | } 21 | 22 | export const CreateTaskModalForm: React.FC = ({ 23 | isOpen, 24 | closeModal, 25 | group_id, 26 | index, 27 | }) => { 28 | const [description, setDescription] = React.useState(""); 29 | const [status, setStatus] = React.useState("Planned"); 30 | 31 | const [createGroup, { loading, error }] = useCreateTask({ 32 | description, 33 | status, 34 | group_id, 35 | index, 36 | }); 37 | 38 | const createNewDesk = async (e: React.FormEvent) => { 39 | e.preventDefault(); 40 | createGroup().then(() => { 41 | setDescription(""); 42 | closeModal(); 43 | }); 44 | }; 45 | 46 | return ( 47 | 48 | 49 | Добавить новую задачу 50 | 51 | setDescription(e.target.value)} 55 | placeholder="New Group" 56 | size="large" 57 | width="100%" 58 | > 59 | Описание 60 | 61 | 62 | 63 | 64 | 65 | setStatus(newValue)} 67 | initialValue="Planned" 68 | size="large" 69 | > 70 | Planned 71 | In Progress 72 | Done 73 | Failed 74 | 75 | 76 | {error && {error.message}} 77 | 78 | 84 | Создать 85 | 86 | 87 | 88 | ); 89 | }; 90 | -------------------------------------------------------------------------------- /src/components/Task/EditTaskModalForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Button, Input, Modal, Text, Select } from "@zeit-ui/react"; 3 | import styled from "styled-components"; 4 | 5 | import type { Status } from "interfaces"; 6 | import { useUpdateTask, useDeleteTask } from "utils/hooks"; 7 | 8 | import { TrashButton } from "components"; 9 | import { Status as StatusTag } from "components/Task"; 10 | 11 | const Form = styled.form` 12 | display: grid; 13 | gap: 1em; 14 | 15 | & > .header { 16 | display: flex; 17 | align-items: center; 18 | justify-content: space-between; 19 | } 20 | `; 21 | 22 | interface CreateTaskModalFormProps { 23 | isOpen: boolean; 24 | initialDescription: string; 25 | initialStatus: Status; 26 | closeModal: () => void; 27 | id: number; 28 | } 29 | 30 | export const EditTaskModalForm: React.FC = ({ 31 | isOpen, 32 | closeModal, 33 | id, 34 | initialDescription = "", 35 | initialStatus = "Planned", 36 | }) => { 37 | const [description, setDescription] = React.useState(initialDescription); 38 | const [status, setStatus] = React.useState(initialStatus); 39 | 40 | const [updateTask, { loading, error }] = useUpdateTask({ 41 | id, 42 | description, 43 | status, 44 | }); 45 | const [deleteTask] = useDeleteTask(id); 46 | 47 | const onClose = () => { 48 | setDescription(initialDescription); 49 | setStatus(initialStatus); 50 | closeModal(); 51 | }; 52 | 53 | const createNewDesk = async (e: React.FormEvent) => { 54 | e.preventDefault(); 55 | updateTask().then(() => { 56 | closeModal(); 57 | }); 58 | }; 59 | 60 | return ( 61 | 62 | 63 | 64 | Редактировать задачу 65 | deleteTask()} /> 66 | 67 | 68 | setDescription(e.target.value)} 72 | placeholder="New Group" 73 | size="large" 74 | width="100%" 75 | > 76 | Описание 77 | 78 | 79 | 80 | 81 | 82 | setStatus(newValue)} 84 | initialValue={initialStatus} 85 | size="large" 86 | width="100%" 87 | > 88 | Planned 89 | In Progress 90 | Done 91 | Failed 92 | 93 | 94 | {error && {error.message}} 95 | 96 | 102 | Сохранить 103 | 104 | 105 | 106 | ); 107 | }; 108 | -------------------------------------------------------------------------------- /src/components/Desks/DeskDragContext.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { DragDropContext, DropResult } from "react-beautiful-dnd"; 3 | 4 | import { ITask } from "interfaces"; 5 | 6 | import { useDesk, useTaskIndex, useTaskGroupId } from "utils/hooks"; 7 | import { 8 | compareIndex, 9 | insertIntoArray, 10 | moveElementInArray, 11 | } from "utils/scripts"; 12 | 13 | interface DeskDragContextProps { 14 | id: number; 15 | } 16 | 17 | export const DeskDragContext: React.FC = ({ 18 | id, 19 | children, 20 | }) => { 21 | const { data } = useDesk(id); 22 | const [setTaskIndex] = useTaskIndex(); 23 | const [setTaskGroupId] = useTaskGroupId(); 24 | 25 | const onDragEnd = ({ destination, source, draggableId }: DropResult) => { 26 | if (!destination) return; 27 | 28 | // If source and destination are the same, skip 29 | if ( 30 | destination.droppableId === source.droppableId && 31 | destination.index === source.index 32 | ) { 33 | return; 34 | } 35 | 36 | let tasksWithWrongIndex = []; 37 | let wrongGroup; 38 | 39 | // Moved in one group 40 | if (destination.droppableId === source.droppableId) { 41 | // Get array of desks after reorder 42 | const endArray = moveElementInArray( 43 | Array.from( 44 | data.desk.groups.find( 45 | (group) => String(group.id) === source.droppableId 46 | ).tasks 47 | ).sort(compareIndex), 48 | source.index, 49 | destination.index 50 | ); 51 | 52 | // Found tasks with wrong index 53 | tasksWithWrongIndex = endArray 54 | .map((task, i) => ({ ...task, index: i + 1 })) 55 | .filter((task, i) => task.index !== endArray[i].index); 56 | } 57 | // Moved from one group to another 58 | else { 59 | const fromArrayStart = data.desk.groups.find( 60 | (group) => String(group.id) === source.droppableId 61 | ).tasks; 62 | 63 | // Moved element 64 | const element = fromArrayStart.find( 65 | (task) => String(task.id) === draggableId 66 | ); 67 | 68 | const fromArrayEnd = fromArrayStart 69 | .filter((task) => String(task.id) !== draggableId) 70 | .sort(compareIndex); 71 | 72 | const wrongInFrom = fromArrayEnd 73 | .map((task, i) => ({ ...task, index: i + 1 })) 74 | .filter((task, i) => task.index !== fromArrayEnd[i].index); 75 | 76 | const toArrayStart = Array.from( 77 | data.desk.groups.find( 78 | (group) => String(group.id) === destination.droppableId 79 | ).tasks 80 | ).sort(compareIndex); 81 | 82 | const toArrayEnd = insertIntoArray( 83 | toArrayStart, 84 | Number(destination.index), 85 | element 86 | ); 87 | 88 | const wrongInTo = toArrayEnd 89 | .map((task, i) => ({ ...task, index: i + 1 })) 90 | .filter((task, i) => task.index !== toArrayEnd[i].index); 91 | 92 | // Tasks with wrong index 93 | tasksWithWrongIndex = [...wrongInFrom, ...wrongInTo]; 94 | 95 | wrongGroup = { 96 | id: Number(draggableId), 97 | group_id: Number(destination.droppableId), 98 | }; 99 | } 100 | 101 | // For each task with wrong index, update index 102 | for (let task of tasksWithWrongIndex) { 103 | setTaskIndex({ id: task.id, index: task.index }); 104 | } 105 | 106 | // If element was moved between groups, update group_id 107 | if (wrongGroup) { 108 | setTaskGroupId({ 109 | ...wrongGroup, 110 | old_group_id: source.droppableId, 111 | }); 112 | } 113 | }; 114 | 115 | return {children}; 116 | }; 117 | --------------------------------------------------------------------------------