├── backend ├── boards │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_apps.py │ │ ├── test_models.py │ │ ├── test_utils.py │ │ ├── test_factories.py │ │ ├── factories.py │ │ └── test_comment_viewset.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0003_auto_20200322_1633.py │ │ ├── 0011_auto_20200517_1011.py │ │ ├── 0005_auto_20200406_1433.py │ │ ├── 0009_task_labels.py │ │ ├── 0008_auto_20200510_1646.py │ │ ├── 0006_auto_20200410_1542.py │ │ ├── 0010_auto_20200517_0826.py │ │ ├── 0002_auto_20200321_0631.py │ │ ├── 0004_auto_20200329_0919.py │ │ ├── 0007_label.py │ │ ├── 0012_comment.py │ │ └── 0001_initial.py │ ├── apps.py │ ├── viewsets.py │ ├── admin.py │ ├── utils.py │ ├── permissions.py │ ├── management │ │ └── commands │ │ │ └── load_benchmark.py │ └── models.py ├── config │ ├── __init__.py │ ├── settings │ │ ├── __init__.py │ │ ├── local.py │ │ └── production.py │ ├── env_utils.py │ ├── wsgi.py │ └── urls.py ├── accounts │ ├── tests │ │ ├── __init__.py │ │ └── test_models.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0004_user_is_guest.py │ │ ├── 0005_auto_20201006_1436.py │ │ ├── 0003_auto_20200505_1756.py │ │ └── 0002_auto_20200327_1817.py │ ├── __init__.py │ ├── apps.py │ ├── permissions.py │ ├── admin.py │ ├── management │ │ └── commands │ │ │ ├── db.py │ │ │ └── load.py │ ├── models.py │ └── serializers.py ├── requirements │ ├── production.txt │ ├── local.txt │ └── base.txt ├── media │ └── avatars │ │ ├── bat.png │ │ ├── bear.png │ │ ├── bee.png │ │ ├── bird.png │ │ ├── bug.png │ │ ├── camel.png │ │ ├── cat.png │ │ ├── cobra.png │ │ ├── cow.png │ │ ├── dog.png │ │ ├── dove.png │ │ ├── duck.png │ │ ├── eagle.png │ │ ├── fish.png │ │ ├── fox.png │ │ ├── frog.png │ │ ├── hen.png │ │ ├── horse.png │ │ ├── koala.png │ │ ├── lion.png │ │ ├── mouse.png │ │ ├── panda.png │ │ ├── shark.png │ │ ├── sheep.png │ │ ├── tiger.png │ │ ├── zebra.png │ │ ├── cheetah.png │ │ ├── dolphin.png │ │ ├── giraffe.png │ │ ├── gorilla.png │ │ ├── leopard.png │ │ ├── monkey.png │ │ ├── parrot.png │ │ ├── penguin.png │ │ ├── spider.png │ │ ├── turtle.png │ │ ├── butterfly.png │ │ ├── crocodile.png │ │ ├── dinosaur.png │ │ ├── elephant.png │ │ ├── flamingo.png │ │ ├── kangaroo.png │ │ ├── squirrel.png │ │ └── starfish.png ├── .pre-commit-config.yaml ├── pytest.ini ├── manage.py ├── fixtures │ ├── users.yaml │ └── tasks.yaml └── conftest.py ├── frontend ├── .env ├── src │ ├── features │ │ ├── board │ │ │ ├── index.ts │ │ │ ├── NewBoardDialog.test.tsx │ │ │ └── BoardList.test.tsx │ │ ├── column │ │ │ ├── index.ts │ │ │ └── Column.tsx │ │ ├── home │ │ │ ├── Home.test.tsx │ │ │ └── Home.tsx │ │ ├── auth │ │ │ ├── __snapshots__ │ │ │ │ └── Footer.test.tsx.snap │ │ │ ├── Footer.test.tsx │ │ │ ├── Auth.tsx │ │ │ ├── EnterAsGuest.tsx │ │ │ └── Footer.tsx │ │ ├── toast │ │ │ ├── __snapshots__ │ │ │ │ └── Toast.test.tsx.snap │ │ │ ├── ToastSlice.tsx │ │ │ ├── Toast.tsx │ │ │ └── Toast.test.tsx │ │ ├── responsive │ │ │ └── ResponsiveSlice.tsx │ │ ├── task │ │ │ ├── TaskLabels.tsx │ │ │ └── AddTask.tsx │ │ ├── comment │ │ │ ├── CommentTextarea.tsx │ │ │ ├── CommentItem.test.tsx │ │ │ ├── CommentSection.test.tsx │ │ │ └── CommentItem.tsx │ │ ├── member │ │ │ ├── MemberDetail.tsx │ │ │ ├── MemberSlice.tsx │ │ │ └── MemberListDialog.test.tsx │ │ ├── label │ │ │ ├── LabelCreate.tsx │ │ │ ├── LabelSlice.tsx │ │ │ └── LabelRow.tsx │ │ └── profile │ │ │ └── Profile.test.tsx │ ├── react-app-env.d.ts │ ├── static │ │ ├── font │ │ │ ├── Inter-Black.woff │ │ │ ├── Inter-Bold.woff │ │ │ ├── Inter-Bold.woff2 │ │ │ ├── Inter-Light.woff │ │ │ ├── Inter-Thin.woff │ │ │ ├── Inter-Thin.woff2 │ │ │ ├── Inter.var.woff2 │ │ │ ├── Inter-Black.woff2 │ │ │ ├── Inter-Italic.woff │ │ │ ├── Inter-Italic.woff2 │ │ │ ├── Inter-Light.woff2 │ │ │ ├── Inter-Medium.woff │ │ │ ├── Inter-Medium.woff2 │ │ │ ├── Inter-Regular.woff │ │ │ ├── Inter-BoldItalic.woff │ │ │ ├── Inter-ExtraBold.woff │ │ │ ├── Inter-ExtraBold.woff2 │ │ │ ├── Inter-ExtraLight.woff │ │ │ ├── Inter-Regular.woff2 │ │ │ ├── Inter-SemiBold.woff │ │ │ ├── Inter-SemiBold.woff2 │ │ │ ├── Inter-ThinItalic.woff │ │ │ ├── Inter-roman.var.woff2 │ │ │ ├── Inter-BlackItalic.woff │ │ │ ├── Inter-BlackItalic.woff2 │ │ │ ├── Inter-BoldItalic.woff2 │ │ │ ├── Inter-ExtraLight.woff2 │ │ │ ├── Inter-LightItalic.woff │ │ │ ├── Inter-LightItalic.woff2 │ │ │ ├── Inter-MediumItalic.woff │ │ │ ├── Inter-ThinItalic.woff2 │ │ │ ├── Inter-italic.var.woff2 │ │ │ ├── Inter-ExtraBoldItalic.woff │ │ │ ├── Inter-MediumItalic.woff2 │ │ │ ├── Inter-SemiBoldItalic.woff │ │ │ ├── Inter-SemiBoldItalic.woff2 │ │ │ ├── Inter-ExtraBoldItalic.woff2 │ │ │ ├── Inter-ExtraLightItalic.woff │ │ │ └── Inter-ExtraLightItalic.woff2 │ │ └── svg │ │ │ ├── times.svg │ │ │ └── github.svg │ ├── routing.tsx │ ├── utils │ │ ├── shortcuts.ts │ │ ├── localStorage.tsx │ │ ├── utils.test.tsx │ │ ├── reorder.tsx │ │ └── testHelpers.tsx │ ├── components │ │ ├── Flex.tsx │ │ ├── SEO.tsx │ │ ├── FullPageSpinner.tsx │ │ ├── PriorityOption.test.tsx │ │ ├── MemberAvatar.tsx │ │ ├── Spinner.tsx │ │ ├── PageError.tsx │ │ ├── AvatarTag.tsx │ │ ├── PriorityOption.tsx │ │ ├── AvatarOption.tsx │ │ ├── Close.tsx │ │ ├── LabelChip.tsx │ │ ├── AssigneeAutoComplete.tsx │ │ ├── Navbar.tsx │ │ ├── BoardName.tsx │ │ └── UserMenu.tsx │ ├── index.css │ ├── index.tsx │ ├── setupTests.ts │ ├── api.tsx │ ├── AuthenticatedApp.tsx │ ├── store.ts │ ├── App.tsx │ ├── types.ts │ ├── styles.tsx │ └── const.ts ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── index.html ├── cypress.json ├── cypress │ ├── support │ │ ├── index.js │ │ └── commands.js │ ├── fixtures │ │ ├── board_create.json │ │ ├── dog_avatar.json │ │ ├── board_list.json │ │ ├── testuser.json │ │ ├── testuser_update.json │ │ ├── users.json │ │ └── os_board.json │ ├── util.js │ ├── plugins │ │ └── index.js │ └── integration │ │ ├── e2e │ │ └── auth.spec.js │ │ └── stubbed │ │ ├── auth.spec.js │ │ └── profile.spec.js ├── .vscode │ ├── extensions.json │ └── settings.json ├── craco.config.js ├── tsconfig.json ├── .eslintrc └── README.md ├── .dockerignore ├── .pyup.yml ├── ansible ├── ansible.cfg ├── hosts ├── setup.yml ├── README.md ├── deploy.yml └── roles │ ├── nginx │ ├── tasks │ │ └── main.yml │ └── templates │ │ └── proxy.conf │ ├── certbot │ ├── tasks │ │ └── main.yml │ └── templates │ │ └── letsencrypt.sh │ ├── common │ └── tasks │ │ └── checkout.yml │ ├── docker-compose │ └── tasks │ │ └── main.yml │ └── security │ └── tasks │ └── main.yml ├── .codeclimate.yml ├── services.yml ├── .production.env.example ├── nginx.Dockerfile ├── django.Dockerfile ├── LICENSE ├── docker-compose.yml ├── .github └── workflows │ └── codeql-analysis.yml ├── .gitignore └── CODE_OF_CONDUCT.md /backend/boards/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/boards/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/.env: -------------------------------------------------------------------------------- 1 | EXTEND_ESLINT=true -------------------------------------------------------------------------------- /backend/accounts/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/boards/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/config/settings/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/accounts/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.git 2 | **/node_modules 3 | -------------------------------------------------------------------------------- /frontend/src/features/board/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Board"; 2 | -------------------------------------------------------------------------------- /frontend/src/features/column/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Column"; 2 | -------------------------------------------------------------------------------- /frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /backend/requirements/production.txt: -------------------------------------------------------------------------------- 1 | -r ./base.txt 2 | 3 | gunicorn==20.0.4 4 | -------------------------------------------------------------------------------- /backend/accounts/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "accounts.apps.AccountsConfig" 2 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /frontend/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3000", 3 | "video": false 4 | } 5 | -------------------------------------------------------------------------------- /frontend/cypress/support/index.js: -------------------------------------------------------------------------------- 1 | import "@cypress/code-coverage/support"; 2 | import "./commands"; 3 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/public/logo192.png -------------------------------------------------------------------------------- /frontend/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/public/logo512.png -------------------------------------------------------------------------------- /backend/media/avatars/bat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/bat.png -------------------------------------------------------------------------------- /backend/media/avatars/bear.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/bear.png -------------------------------------------------------------------------------- /backend/media/avatars/bee.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/bee.png -------------------------------------------------------------------------------- /backend/media/avatars/bird.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/bird.png -------------------------------------------------------------------------------- /backend/media/avatars/bug.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/bug.png -------------------------------------------------------------------------------- /backend/media/avatars/camel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/camel.png -------------------------------------------------------------------------------- /backend/media/avatars/cat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/cat.png -------------------------------------------------------------------------------- /backend/media/avatars/cobra.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/cobra.png -------------------------------------------------------------------------------- /backend/media/avatars/cow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/cow.png -------------------------------------------------------------------------------- /backend/media/avatars/dog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/dog.png -------------------------------------------------------------------------------- /backend/media/avatars/dove.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/dove.png -------------------------------------------------------------------------------- /backend/media/avatars/duck.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/duck.png -------------------------------------------------------------------------------- /backend/media/avatars/eagle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/eagle.png -------------------------------------------------------------------------------- /backend/media/avatars/fish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/fish.png -------------------------------------------------------------------------------- /backend/media/avatars/fox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/fox.png -------------------------------------------------------------------------------- /backend/media/avatars/frog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/frog.png -------------------------------------------------------------------------------- /backend/media/avatars/hen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/hen.png -------------------------------------------------------------------------------- /backend/media/avatars/horse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/horse.png -------------------------------------------------------------------------------- /backend/media/avatars/koala.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/koala.png -------------------------------------------------------------------------------- /backend/media/avatars/lion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/lion.png -------------------------------------------------------------------------------- /backend/media/avatars/mouse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/mouse.png -------------------------------------------------------------------------------- /backend/media/avatars/panda.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/panda.png -------------------------------------------------------------------------------- /backend/media/avatars/shark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/shark.png -------------------------------------------------------------------------------- /backend/media/avatars/sheep.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/sheep.png -------------------------------------------------------------------------------- /backend/media/avatars/tiger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/tiger.png -------------------------------------------------------------------------------- /backend/media/avatars/zebra.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/zebra.png -------------------------------------------------------------------------------- /frontend/cypress/fixtures/board_create.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 5, 3 | "name": "Physics", 4 | "owner": 1 5 | } 6 | -------------------------------------------------------------------------------- /backend/media/avatars/cheetah.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/cheetah.png -------------------------------------------------------------------------------- /backend/media/avatars/dolphin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/dolphin.png -------------------------------------------------------------------------------- /backend/media/avatars/giraffe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/giraffe.png -------------------------------------------------------------------------------- /backend/media/avatars/gorilla.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/gorilla.png -------------------------------------------------------------------------------- /backend/media/avatars/leopard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/leopard.png -------------------------------------------------------------------------------- /backend/media/avatars/monkey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/monkey.png -------------------------------------------------------------------------------- /backend/media/avatars/parrot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/parrot.png -------------------------------------------------------------------------------- /backend/media/avatars/penguin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/penguin.png -------------------------------------------------------------------------------- /backend/media/avatars/spider.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/spider.png -------------------------------------------------------------------------------- /backend/media/avatars/turtle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/turtle.png -------------------------------------------------------------------------------- /backend/media/avatars/butterfly.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/butterfly.png -------------------------------------------------------------------------------- /backend/media/avatars/crocodile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/crocodile.png -------------------------------------------------------------------------------- /backend/media/avatars/dinosaur.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/dinosaur.png -------------------------------------------------------------------------------- /backend/media/avatars/elephant.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/elephant.png -------------------------------------------------------------------------------- /backend/media/avatars/flamingo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/flamingo.png -------------------------------------------------------------------------------- /backend/media/avatars/kangaroo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/kangaroo.png -------------------------------------------------------------------------------- /backend/media/avatars/squirrel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/squirrel.png -------------------------------------------------------------------------------- /backend/media/avatars/starfish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/backend/media/avatars/starfish.png -------------------------------------------------------------------------------- /frontend/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /backend/boards/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BoardsConfig(AppConfig): 5 | name = "boards" 6 | -------------------------------------------------------------------------------- /frontend/cypress/fixtures/dog_avatar.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 3, 3 | "photo": "/media/avatars/dog.png", 4 | "name": "dog" 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-Black.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-Black.woff -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-Bold.woff -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-Bold.woff2 -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-Light.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-Light.woff -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-Thin.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-Thin.woff -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-Thin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-Thin.woff2 -------------------------------------------------------------------------------- /frontend/src/static/font/Inter.var.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter.var.woff2 -------------------------------------------------------------------------------- /backend/accounts/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AccountsConfig(AppConfig): 5 | name = "accounts" 6 | -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-Black.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-Black.woff2 -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-Italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-Italic.woff -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-Italic.woff2 -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-Light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-Light.woff2 -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-Medium.woff -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-Medium.woff2 -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-Regular.woff -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-BoldItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-BoldItalic.woff -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-ExtraBold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-ExtraBold.woff -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-ExtraBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-ExtraBold.woff2 -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-ExtraLight.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-ExtraLight.woff -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-Regular.woff2 -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-SemiBold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-SemiBold.woff -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-SemiBold.woff2 -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-ThinItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-ThinItalic.woff -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-roman.var.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-roman.var.woff2 -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-BlackItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-BlackItalic.woff -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-BlackItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-BlackItalic.woff2 -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-BoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-BoldItalic.woff2 -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-ExtraLight.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-ExtraLight.woff2 -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-LightItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-LightItalic.woff -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-LightItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-LightItalic.woff2 -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-MediumItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-MediumItalic.woff -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-ThinItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-ThinItalic.woff2 -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-italic.var.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-italic.var.woff2 -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-ExtraBoldItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-ExtraBoldItalic.woff -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-MediumItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-MediumItalic.woff2 -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-SemiBoldItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-SemiBoldItalic.woff -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-SemiBoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-SemiBoldItalic.woff2 -------------------------------------------------------------------------------- /frontend/src/routing.tsx: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from "history"; 2 | 3 | const history = createBrowserHistory(); 4 | 5 | export default history; 6 | -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-ExtraBoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-ExtraBoldItalic.woff2 -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-ExtraLightItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-ExtraLightItalic.woff -------------------------------------------------------------------------------- /frontend/src/static/font/Inter-ExtraLightItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rrebase/knboard/HEAD/frontend/src/static/font/Inter-ExtraLightItalic.woff2 -------------------------------------------------------------------------------- /frontend/src/utils/shortcuts.ts: -------------------------------------------------------------------------------- 1 | const getMetaKey = () => 2 | navigator.platform.indexOf("Mac") > -1 ? "⌘" : "ctrl"; 3 | 4 | export default getMetaKey; 5 | -------------------------------------------------------------------------------- /backend/accounts/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def test_custom_user_model(): 5 | assert settings.AUTH_USER_MODEL == "accounts.User" 6 | -------------------------------------------------------------------------------- /backend/.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/ambv/black 3 | rev: 19.10b0 4 | hooks: 5 | - id: black 6 | language_version: python3.8 7 | -------------------------------------------------------------------------------- /.pyup.yml: -------------------------------------------------------------------------------- 1 | # PyUp is used to keep Python dependencies secure & up-to-date 2 | # See https://pyup.io/docs/configuration/ for all available options 3 | 4 | update: all 5 | schedule: "every week" 6 | -------------------------------------------------------------------------------- /frontend/src/components/Flex.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | 3 | const Flex = styled.div` 4 | display: flex; 5 | justify-content: space-between; 6 | `; 7 | 8 | export default Flex; 9 | -------------------------------------------------------------------------------- /backend/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = config.settings.local 3 | python_files = test_*.py 4 | norecursedirs = .venv .git 5 | addopts = -p no:warnings --strict-markers 6 | junit_family = xunit1 7 | -------------------------------------------------------------------------------- /backend/requirements/local.txt: -------------------------------------------------------------------------------- 1 | -r ./base.txt 2 | 3 | black==19.10b0 4 | django-debug-toolbar==2.2 5 | django-debug-toolbar-request-history==0.1.3 6 | pytest-django==3.9.0 7 | pytest-factoryboy==2.0.3 8 | pytest-cov==2.9.0 9 | -------------------------------------------------------------------------------- /backend/accounts/permissions.py: -------------------------------------------------------------------------------- 1 | from rest_framework import permissions 2 | 3 | 4 | class IsSelf(permissions.BasePermission): 5 | def has_object_permission(self, request, view, obj): 6 | return obj == request.user 7 | -------------------------------------------------------------------------------- /ansible/ansible.cfg: -------------------------------------------------------------------------------- 1 | # ansible.cfg 2 | 3 | [defaults] 4 | transport = ssh 5 | inventory = ./hosts 6 | interpreter_python = python3 7 | 8 | [ssh_connection] 9 | pipelining = True 10 | control_path = /tmp/ansible-ssh-%%h-%%p-%%r 11 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | checks: 4 | similar-code: 5 | enabled: false 6 | 7 | exclude_patterns: 8 | - "**/node_modules/" 9 | - "**/migrations/" 10 | - "**/tests/" 11 | - "**/*.test.*" 12 | - "**/*.spec.*" 13 | -------------------------------------------------------------------------------- /backend/boards/tests/test_apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | 3 | from boards.apps import BoardsConfig 4 | 5 | 6 | def test_config(): 7 | assert BoardsConfig.name == "boards" 8 | assert apps.get_app_config("boards").name == "boards" 9 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | @import url("static/font/inter.css"); 2 | 3 | html { 4 | font-family: "Inter", sans-serif; 5 | } 6 | @supports (font-variation-settings: normal) { 7 | html { 8 | font-family: "Inter var", sans-serif; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/static/svg/times.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /services.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | db: 5 | image: postgres:11 6 | environment: 7 | - POSTGRES_DB=knboard 8 | - POSTGRES_USER=knboard 9 | - POSTGRES_PASSWORD=knboard 10 | ports: 11 | - "5432:5432" 12 | -------------------------------------------------------------------------------- /frontend/cypress/fixtures/board_list.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "id": 1, "name": "Internals", "owner": 1 }, 3 | { "id": 2, "name": "Operating Systems", "owner": 2 }, 4 | { "id": 3, "name": "Fundamentals of Computation", "owner": 2 }, 5 | { "id": 4, "name": "Data Science", "owner": 3 } 6 | ] 7 | -------------------------------------------------------------------------------- /frontend/cypress/fixtures/testuser.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1, 3 | "username": "testuser", 4 | "first_name": "Ragnar", 5 | "last_name": "Rebase", 6 | "email": "t@t.com", 7 | "avatar": null, 8 | "date_joined": "2020-04-01T04:06:21.792404Z", 9 | "is_guest": false 10 | } 11 | -------------------------------------------------------------------------------- /frontend/cypress/fixtures/testuser_update.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1, 3 | "username": "newname", 4 | "first_name": "Ragnar", 5 | "last_name": "Rebase", 6 | "email": "t@t.com", 7 | "avatar": null, 8 | "date_joined": "2020-04-01T04:25:04.833718Z", 9 | "is_guest": false 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/components/SEO.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Helmet, HelmetProps } from "react-helmet"; 3 | 4 | const SEO = ({ title }: Pick) => ( 5 | 6 | ); 7 | 8 | export default SEO; 9 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | import React from "react"; 3 | import ReactDOM from "react-dom"; 4 | import "react-markdown-editor-lite/lib/index.css"; 5 | import "./index.css"; 6 | import App from "./App"; 7 | 8 | ReactDOM.render(, document.getElementById("root")); 9 | -------------------------------------------------------------------------------- /frontend/craco.config.js: -------------------------------------------------------------------------------- 1 | const emotionPresetOptions = {}; 2 | 3 | const emotionBabelPreset = require("@emotion/babel-preset-css-prop").default( 4 | undefined, 5 | emotionPresetOptions 6 | ); 7 | 8 | module.exports = { 9 | babel: { 10 | plugins: emotionBabelPreset.plugins, 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /ansible/hosts: -------------------------------------------------------------------------------- 1 | [all:vars] 2 | ansible_become=true 3 | ansible_become_user=root 4 | env_file=../.env 5 | 6 | [knboard] 7 | knboard.com 8 | 9 | [knboard:vars] 10 | repo_folder=/srv/knboard/ 11 | repo_name=rrebase/knboard 12 | repo_branch=live 13 | letsencrypt_email=info@knboard.com 14 | domain_name=knboard.com 15 | create_user=rareba 16 | -------------------------------------------------------------------------------- /frontend/src/components/FullPageSpinner.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { CircularProgress, Fade } from "@material-ui/core"; 3 | 4 | const FullPageSpinner = () => ( 5 | 6 | 7 | 8 | ); 9 | 10 | export default FullPageSpinner; 11 | -------------------------------------------------------------------------------- /backend/config/env_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | 5 | 6 | def get_env(env_variable): 7 | try: 8 | return os.environ[env_variable] 9 | except KeyError: 10 | error_msg = f"Set the {env_variable} environment variable" 11 | raise ImproperlyConfigured(error_msg) 12 | -------------------------------------------------------------------------------- /frontend/src/features/home/Home.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { screen } from "@testing-library/react"; 3 | import Home from "./Home"; 4 | import { renderWithProviders } from "utils/testHelpers"; 5 | 6 | it("should have visit boards", () => { 7 | renderWithProviders(); 8 | expect(screen.getByText(/View Boards/i)).toBeVisible(); 9 | }); 10 | -------------------------------------------------------------------------------- /ansible/setup.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - hosts: knboard 4 | vars: 5 | docker_compose_version: 1.25.5 6 | sys_packages: ["curl", "vim", "git", "ufw", "haveged"] 7 | copy_local_key: "{{ lookup('file', lookup('env','HOME') + '/.ssh/id_rsa.pub') }}" 8 | 9 | roles: 10 | - role: security 11 | - role: docker-compose 12 | - role: nginx 13 | - role: certbot 14 | -------------------------------------------------------------------------------- /ansible/README.md: -------------------------------------------------------------------------------- 1 | ## Ansible playbooks for server setup and deployment 2 | 3 | Setup the server: 4 | ```sh 5 | ansible-playbook setup.yml --verbose 6 | ``` 7 | 8 | Initial request for SSL certs: 9 | ```sh 10 | ssh knboard.com 11 | sudo su - 12 | cd /srv/knboard/ 13 | ./init-letsencrypt.sh 14 | ``` 15 | 16 | Deploy: 17 | ```sh 18 | ansible-playbook deploy.yml --verbose 19 | ``` 20 | -------------------------------------------------------------------------------- /backend/requirements/base.txt: -------------------------------------------------------------------------------- 1 | Django>=3.1 2 | djangorestframework==3.12.1 3 | django-filter==2.4.0 4 | django-extensions==3.0.3 5 | django-allauth==0.42.0 6 | django-model-utils==4.0.0 7 | django-admin-sortable==2.2.3 8 | dj-rest-auth==1.0.7 9 | markdown==3.2.2 10 | PyYAML==5.3.1 11 | Pillow==7.1.2 12 | pre-commit==2.4.0 13 | shortuuid==1.0.1 14 | psycopg2==2.8.5 --no-binary psycopg2 15 | -------------------------------------------------------------------------------- /frontend/src/features/auth/__snapshots__/Footer.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should render github link correctly 1`] = ` 4 | 10 | GitHub 11 | 12 | `; 13 | -------------------------------------------------------------------------------- /frontend/cypress/util.js: -------------------------------------------------------------------------------- 1 | // keycodes 2 | export const tab = 9; 3 | export const enter = 13; 4 | export const escape = 27; 5 | export const space = 32; 6 | export const pageUp = 33; 7 | export const pageDown = 34; 8 | export const end = 35; 9 | export const home = 36; 10 | export const arrowLeft = 37; 11 | export const arrowUp = 38; 12 | export const arrowRight = 39; 13 | export const arrowDown = 40; 14 | -------------------------------------------------------------------------------- /ansible/deploy.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - hosts: knboard 4 | gather_facts: false 5 | 6 | tasks: 7 | - include: roles/common/tasks/checkout.yml 8 | 9 | - name: Run `docker-compose up --build --detach` 10 | command: 11 | docker-compose up --build --detach 12 | args: 13 | chdir: "{{ repo_folder }}" 14 | register: output 15 | 16 | - debug: 17 | var: output 18 | -------------------------------------------------------------------------------- /ansible/roles/nginx/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Ensures /etc/nginx dir exists 4 | file: path=/etc/nginx state=directory 5 | 6 | - name: Create nginx.config from template 7 | template: 8 | src: templates/nginx.conf 9 | dest: /etc/nginx/nginx.conf 10 | 11 | - name: Create proxy.config from template 12 | template: 13 | src: templates/proxy.conf 14 | dest: /etc/nginx/proxy.conf 15 | -------------------------------------------------------------------------------- /backend/boards/migrations/0003_auto_20200322_1633.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.4 on 2020-03-22 16:33 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("boards", "0002_auto_20200321_0631"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions(name="board", options={"ordering": ["id"]},), 14 | ] 15 | -------------------------------------------------------------------------------- /frontend/cypress/fixtures/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 2, 4 | "username": "steveapple1", 5 | "avatar": null 6 | }, 7 | { 8 | "id": 3, 9 | "username": "daveice", 10 | "avatar": null 11 | }, 12 | { 13 | "id": 4, 14 | "username": "timwoodstock", 15 | "avatar": null 16 | }, 17 | { 18 | "id": 5, 19 | "username": "stenwood55", 20 | "avatar": null 21 | } 22 | ] 23 | -------------------------------------------------------------------------------- /ansible/roles/certbot/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - include: roles/common/tasks/checkout.yml 4 | 5 | - name: Build containers (may take a few minutes) 6 | command: 7 | docker-compose build 8 | args: 9 | chdir: "{{ repo_folder }}" 10 | 11 | - name: Create init-letsencrypt from template 12 | template: 13 | src: templates/letsencrypt.sh 14 | dest: "{{ repo_folder }}init-letsencrypt.sh" 15 | mode: a+x 16 | -------------------------------------------------------------------------------- /frontend/cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | /// 3 | 4 | /** 5 | * @type {Cypress.PluginConfig} 6 | */ 7 | module.exports = (on, config) => { 8 | require("@cypress/code-coverage/task")(on, config); 9 | on( 10 | "file:preprocessor", 11 | require("@cypress/code-coverage/use-browserify-istanbul") 12 | ); 13 | return config; 14 | }; 15 | -------------------------------------------------------------------------------- /.production.env.example: -------------------------------------------------------------------------------- 1 | POSTGRES_HOST=postgres 2 | POSTGRES_PORT=5432 3 | POSTGRES_DB=knboard 4 | POSTGRES_USER=knboard 5 | POSTGRES_PASSWORD=examplepassword 6 | 7 | DJANGO_SECRET_KEY=01tHOatUVZDm508jsRlEdtwbNuDFnsPFkrBcz2PUGhDWz5H7xO 8 | DJANGO_STATIC_URL=/django-static/ 9 | DJANGO_STATIC_ROOT=/app/django-static/ 10 | DJANGO_SETTINGS_MODULE=config.settings.production 11 | DJANGO_ALLOWED_HOSTS=example.com 12 | DJANGO_ALLOW_GUEST_ACCESS= 13 | -------------------------------------------------------------------------------- /frontend/src/features/toast/__snapshots__/Toast.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should show toast and auto hide 1`] = ` 4 | Array [ 5 | Object { 6 | "payload": undefined, 7 | "type": "toast/clearToast", 8 | }, 9 | ] 10 | `; 11 | 12 | exports[`should show toast and auto hide 2`] = ` 13 | Array [ 14 | Object { 15 | "payload": undefined, 16 | "type": "toast/clearToast", 17 | }, 18 | ] 19 | `; 20 | -------------------------------------------------------------------------------- /ansible/roles/common/tasks/checkout.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Checkout repo 4 | git: 5 | repo: https://github.com/{{ repo_name }}.git 6 | accept_hostkey: yes 7 | dest: "{{ repo_folder }}" 8 | version: "{{ repo_branch }}" 9 | key_file: /root/.ssh/id_rsa 10 | 11 | - name: Copy .env file to remote 12 | copy: 13 | src: "{{ env_file }}" 14 | dest: "{{ repo_folder }}" 15 | owner: root 16 | group: root 17 | mode: u=rw,g=r,o=r 18 | -------------------------------------------------------------------------------- /backend/boards/viewsets.py: -------------------------------------------------------------------------------- 1 | from rest_framework import viewsets, mixins 2 | 3 | 4 | class ModelDetailViewSet( 5 | mixins.CreateModelMixin, 6 | mixins.RetrieveModelMixin, 7 | mixins.UpdateModelMixin, 8 | mixins.DestroyModelMixin, 9 | viewsets.GenericViewSet, 10 | ): 11 | """ 12 | A viewset that provides default `create()`, `retrieve()`, `update()`, 13 | `partial_update()` and `destroy()`` actions. 14 | """ 15 | 16 | pass 17 | -------------------------------------------------------------------------------- /backend/boards/migrations/0011_auto_20200517_1011.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-17 10:11 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("boards", "0010_auto_20200517_0826"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="board", name="name", field=models.CharField(max_length=50), 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /ansible/roles/nginx/templates/proxy.conf: -------------------------------------------------------------------------------- 1 | proxy_http_version 1.1; 2 | proxy_cache_bypass $http_upgrade; 3 | 4 | proxy_set_header Upgrade $http_upgrade; 5 | proxy_set_header Connection "upgrade"; 6 | proxy_set_header Host $host; 7 | proxy_set_header X-Real-IP $remote_addr; 8 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 9 | proxy_set_header X-Forwarded-Proto $scheme; 10 | proxy_set_header X-Forwarded-Host $host; 11 | proxy_set_header X-Forwarded-Port $server_port; 12 | -------------------------------------------------------------------------------- /backend/boards/migrations/0005_auto_20200406_1433.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.4 on 2020-04-06 14:33 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("boards", "0004_auto_20200329_0919"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="task", name="description", field=models.TextField(blank=True), 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /backend/boards/tests/test_models.py: -------------------------------------------------------------------------------- 1 | def test_board_owner_added_to_members(board_factory, steve, leo): 2 | board = board_factory(owner=steve) 3 | members_ids = [member.id for member in board.members.all()] 4 | assert steve.id in members_ids 5 | assert leo.id not in members_ids 6 | 7 | 8 | def test_str_methods(board, column, task): 9 | assert str(board) == board.name 10 | assert str(column) == column.title 11 | assert str(task) == f"{task.id} - {task.title}" 12 | -------------------------------------------------------------------------------- /backend/config/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for knboard project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /frontend/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 | import "mutationobserver-shim"; 7 | import "jest-localstorage-mock"; 8 | 9 | import { axiosMock } from "utils/testHelpers"; 10 | 11 | beforeEach(() => { 12 | axiosMock.reset(); 13 | }); 14 | -------------------------------------------------------------------------------- /frontend/src/components/PriorityOption.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen } from "@testing-library/react"; 3 | import PriorityOption from "./PriorityOption"; 4 | import { PRIO1 } from "utils/colors"; 5 | 6 | it("should render without errors", () => { 7 | render(); 8 | expect(screen.getByText("High")).toBeVisible(); 9 | expect(screen.getByTestId("priority-icon")).toHaveAttribute("color", PRIO1); 10 | }); 11 | -------------------------------------------------------------------------------- /backend/accounts/migrations/0004_user_is_guest.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-17 16:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("accounts", "0003_auto_20200505_1756"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="user", 15 | name="is_guest", 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /backend/boards/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from boards.utils import reverse_querystring 4 | 5 | 6 | def test_reverse_querystring_multiple(): 7 | assert ( 8 | reverse_querystring( 9 | "user-search", query_kwargs={"board": "1", "search": "steve"} 10 | ) 11 | == "/api/u/search/?board=1&search=steve" 12 | ) 13 | 14 | 15 | def test_reverse_querystring_exception_when_no_qparams(): 16 | with pytest.raises(ValueError): 17 | reverse_querystring("user-search") 18 | -------------------------------------------------------------------------------- /backend/boards/migrations/0009_task_labels.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.4 on 2020-05-13 15:31 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("boards", "0008_auto_20200510_1646"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="task", 15 | name="labels", 16 | field=models.ManyToManyField(related_name="tasks", to="boards.Label"), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /frontend/src/components/MemberAvatar.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar } from "@material-ui/core"; 2 | import React from "react"; 3 | import { avatarStyles } from "styles"; 4 | import { BoardMember } from "types"; 5 | 6 | interface Props { 7 | member?: BoardMember; 8 | } 9 | 10 | const MemberAvatar = ({ member }: Props) => { 11 | return ( 12 | 13 | {member?.username?.charAt(0) || "-"} 14 | 15 | ); 16 | }; 17 | 18 | export default MemberAvatar; 19 | -------------------------------------------------------------------------------- /backend/boards/admin.py: -------------------------------------------------------------------------------- 1 | from adminsortable.admin import SortableAdmin 2 | from django.contrib import admin 3 | 4 | from .models import Board, Label, Column, Task 5 | 6 | 7 | class LabelAdmin(admin.ModelAdmin): 8 | list_display = ["name", "board"] 9 | list_filter = ["board"] 10 | search_fields = ["name"] 11 | 12 | class Meta: 13 | model = Label 14 | 15 | 16 | admin.site.register(Label, LabelAdmin) 17 | admin.site.register(Board) 18 | admin.site.register(Column, SortableAdmin) 19 | admin.site.register(Task, SortableAdmin) 20 | -------------------------------------------------------------------------------- /backend/boards/migrations/0008_auto_20200510_1646.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.4 on 2020-05-10 16:46 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("boards", "0007_label"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddConstraint( 14 | model_name="label", 15 | constraint=models.UniqueConstraint( 16 | fields=("name", "board"), name="unique_name_board" 17 | ), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /backend/accounts/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.admin import UserAdmin 3 | 4 | from .models import Avatar, User 5 | 6 | 7 | class CustomUserAdmin(UserAdmin): 8 | list_display = ( 9 | "username", 10 | "email", 11 | "first_name", 12 | "last_name", 13 | "is_staff", 14 | "is_guest", 15 | ) 16 | list_filter = ("is_staff", "is_superuser", "is_active", "groups", "is_guest") 17 | 18 | 19 | admin.site.register(Avatar) 20 | admin.site.register(User, CustomUserAdmin) 21 | -------------------------------------------------------------------------------- /backend/accounts/management/commands/db.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core import management 3 | from django.core.management.base import BaseCommand, CommandError 4 | 5 | 6 | class Command(BaseCommand): 7 | help = "Helpful command to run both makemigrations and migrate in one go" 8 | 9 | def handle(self, *args, **options): 10 | if not settings.DEBUG: 11 | raise CommandError("Command not meant for production") 12 | 13 | management.call_command("makemigrations") 14 | management.call_command("migrate") 15 | -------------------------------------------------------------------------------- /frontend/src/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Fade, CircularProgress } from "@material-ui/core"; 3 | import { css } from "@emotion/core"; 4 | 5 | interface Props { 6 | loading: boolean; 7 | } 8 | 9 | const Spinner = ({ loading }: Props) => ( 10 | 20 | 21 | 22 | ); 23 | 24 | export default Spinner; 25 | -------------------------------------------------------------------------------- /backend/accounts/migrations/0005_auto_20201006_1436.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1 on 2020-10-06 14:36 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("accounts", "0004_user_is_guest"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="user", 15 | name="first_name", 16 | field=models.CharField( 17 | blank=True, max_length=150, verbose_name="first name" 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 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 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Knboard", 3 | "name": "Knboard", 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": "192x177" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x471" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /backend/accounts/management/commands/load.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core import management 3 | from django.core.management.base import BaseCommand, CommandError 4 | 5 | 6 | class Command(BaseCommand): 7 | help = "Helpful command to load all fixtures" 8 | 9 | def handle(self, *args, **options): 10 | if not settings.DEBUG: 11 | raise CommandError("Command not meant for production") 12 | 13 | management.call_command("loaddata", "users") 14 | management.call_command("loaddata", "tasks") 15 | management.call_command("loaddata", "avatars") 16 | -------------------------------------------------------------------------------- /frontend/src/utils/localStorage.tsx: -------------------------------------------------------------------------------- 1 | import { LOCAL_STORAGE_KEY } from "const"; 2 | 3 | export const loadState = () => { 4 | try { 5 | const serializedState = localStorage.getItem(LOCAL_STORAGE_KEY); 6 | if (serializedState === null) { 7 | return undefined; 8 | } 9 | return JSON.parse(serializedState); 10 | } catch (err) { 11 | return undefined; 12 | } 13 | }; 14 | 15 | export const saveState = (state: any) => { 16 | const serializedState = JSON.stringify(state); 17 | localStorage.setItem(LOCAL_STORAGE_KEY, serializedState); 18 | }; 19 | 20 | export default { loadState, saveState }; 21 | -------------------------------------------------------------------------------- /frontend/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.settings.editor": "json", 3 | "editor.formatOnSave": true, 4 | "editor.tabSize": 2, 5 | "javascript.format.enable": false, 6 | "typescript.tsdk": "node_modules/typescript/lib", 7 | "eslint.packageManager": "yarn", 8 | "eslint.validate": [ 9 | "javascript", 10 | "javascriptreact", 11 | "typescript", 12 | "typescriptreact" 13 | ], 14 | "eslint.workingDirectories": [{ "mode": "auto" }], 15 | "[javascript]": { "editor.formatOnSave": false }, 16 | "[typescript]": { "editor.formatOnSave": false }, 17 | "editor.codeActionsOnSave": { "source.fixAll": true } 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/components/PageError.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Alert } from "@material-ui/lab"; 3 | import styled from "@emotion/styled"; 4 | 5 | const Container = styled.div` 6 | margin-top: 10rem; 7 | display: flex; 8 | justify-content: center; 9 | margin-left: auto; 10 | margin-right: auto; 11 | font-size: 1.25rem; 12 | `; 13 | 14 | interface Props { 15 | children: React.ReactNode; 16 | } 17 | 18 | const PageError = ({ children }: Props) => ( 19 | 20 | 21 | {children} 22 | 23 | 24 | ); 25 | 26 | export default PageError; 27 | -------------------------------------------------------------------------------- /backend/boards/utils.py: -------------------------------------------------------------------------------- 1 | from django.utils.http import urlencode 2 | from rest_framework.reverse import reverse 3 | 4 | 5 | def reverse_querystring( 6 | view, urlconf=None, args=None, kwargs=None, current_app=None, query_kwargs=None 7 | ): 8 | """ 9 | Custom reverse to handle query strings. 10 | 11 | Usage: 12 | reverse('app.views.my_view', kwargs={'pk': 123}, query_kwargs={'search', 'Steve'}) 13 | """ 14 | base_url = reverse( 15 | view, urlconf=urlconf, args=args, kwargs=kwargs, current_app=current_app 16 | ) 17 | if query_kwargs is None: 18 | raise ValueError("Use reverse()") 19 | 20 | return f"{base_url}?{urlencode(query_kwargs)}" 21 | -------------------------------------------------------------------------------- /backend/boards/permissions.py: -------------------------------------------------------------------------------- 1 | from rest_framework import permissions 2 | 3 | 4 | class IsOwner(permissions.BasePermission): 5 | def has_object_permission(self, request, view, obj): 6 | return obj.owner == request.user 7 | 8 | 9 | class IsOwnerForDangerousMethods(permissions.BasePermission): 10 | """ 11 | Object-level permission to only allow owners of an object to delete it. 12 | Assumes the model instance has an `owner` attribute. 13 | """ 14 | 15 | def has_object_permission(self, request, view, obj): 16 | if request.method in ["POST", "PATCH", "DELETE"]: 17 | return obj.owner == request.user 18 | 19 | return request.method in permissions.SAFE_METHODS 20 | -------------------------------------------------------------------------------- /backend/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local") 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /frontend/src/components/AvatarTag.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Avatar as UserAvatar } from "types"; 3 | import { Chip, Avatar, ChipProps } from "@material-ui/core"; 4 | 5 | interface Option { 6 | id: number; 7 | username: string; 8 | avatar: UserAvatar | null; 9 | } 10 | 11 | interface Props extends ChipProps { 12 | option: Option; 13 | } 14 | 15 | const AvatarTag = ({ option, ...rest }: Props) => { 16 | return ( 17 | } 20 | variant="outlined" 21 | label={option.username} 22 | size="small" 23 | {...rest} 24 | /> 25 | ); 26 | }; 27 | 28 | export default AvatarTag; 29 | -------------------------------------------------------------------------------- /frontend/src/features/auth/Footer.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { fireEvent, render, screen, waitFor } from "@testing-library/react"; 3 | import Footer from "./Footer"; 4 | 5 | it("should open popover and have text visible", () => { 6 | render(); 7 | fireEvent.click(screen.getByRole("button", { name: "About" })); 8 | expect(screen.getByRole("link", { name: "GitHub" })).toBeVisible(); 9 | }); 10 | 11 | it("should render github link correctly", async () => { 12 | render(); 13 | fireEvent.click(screen.getByText("About")); 14 | await waitFor(() => { 15 | expect(screen.getByRole("alert")).toBeVisible(); 16 | }); 17 | expect(screen.getByRole("link", { name: "GitHub" })).toMatchSnapshot(); 18 | }); 19 | -------------------------------------------------------------------------------- /nginx.Dockerfile: -------------------------------------------------------------------------------- 1 | # --- Stage 1: Latest LTS Node.js to build the React app 2 | FROM node:12 as build-stage 3 | 4 | # Set working directory 5 | WORKDIR /app 6 | 7 | # Copy npm package requirements to the workdir 8 | COPY ./frontend/package.json /app/ 9 | 10 | # Install dependencies into workdir node_modules 11 | RUN yarn install 12 | 13 | # Copy the project source code (node_modules not copied, it's in .dockerignore) 14 | COPY ./frontend/ /app/ 15 | 16 | # Build static files inside the container environment, that will be served by nginx 17 | RUN yarn build 18 | 19 | # --- Stage 2: Serve static files with nginx 20 | FROM nginx:1.17 21 | 22 | # Copy the React app over from node container to nginx container 23 | COPY --from=build-stage /app/build/ /usr/share/nginx/html 24 | -------------------------------------------------------------------------------- /frontend/src/features/responsive/ResponsiveSlice.tsx: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | import { RootState } from "store"; 3 | 4 | interface InitialState { 5 | mobileDrawerOpen: boolean; 6 | } 7 | 8 | export const initialState: InitialState = { 9 | mobileDrawerOpen: false, 10 | }; 11 | 12 | export const slice = createSlice({ 13 | name: "responsive", 14 | initialState, 15 | reducers: { 16 | setMobileDrawerOpen: (state, action: PayloadAction) => { 17 | state.mobileDrawerOpen = action.payload; 18 | }, 19 | }, 20 | }); 21 | 22 | export const { setMobileDrawerOpen } = slice.actions; 23 | 24 | export const mobileDrawerOpen = (store: RootState) => 25 | store.responsive.mobileDrawerOpen; 26 | 27 | export default slice.reducer; 28 | -------------------------------------------------------------------------------- /frontend/cypress/integration/e2e/auth.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context.skip("E2E Auth", () => { 4 | beforeEach(() => { 5 | cy.visit("/"); 6 | }); 7 | 8 | it("should login successfully", () => { 9 | cy.findByText(/login/i).click(); 10 | cy.findByLabelText(/username/i).type("t@t.com"); 11 | cy.findByLabelText(/password/i).type("test"); 12 | cy.findByTestId("submit-login-btn").click(); 13 | }); 14 | 15 | it("should register", () => { 16 | cy.findByText(/register/i).click(); 17 | cy.findByLabelText("Username").type("testuser"); 18 | cy.findByLabelText("Email").type("testuser@gmail.com"); 19 | cy.findByLabelText("Password").type("TestPw123"); 20 | cy.findByLabelText("Confirm Password").type("TestPw123"); 21 | cy.findByTestId("submit-register-btn").click(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /frontend/src/features/task/TaskLabels.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ITask, Label } from "types"; 3 | import { useSelector } from "react-redux"; 4 | import LabelChip from "components/LabelChip"; 5 | import styled from "@emotion/styled"; 6 | import { selectLabelEntities } from "features/label/LabelSlice"; 7 | 8 | const Container = styled.div` 9 | display: flex; 10 | flex-wrap: wrap; 11 | margin-top: 4px; 12 | `; 13 | 14 | interface Props { 15 | task: ITask; 16 | } 17 | 18 | const TaskLabels = ({ task }: Props) => { 19 | const labelsById = useSelector(selectLabelEntities); 20 | const labels = task.labels.map((labelId) => labelsById[labelId]) as Label[]; 21 | 22 | return ( 23 | 24 | {labels.map((label) => ( 25 | 26 | ))} 27 | 28 | ); 29 | }; 30 | 31 | export default TaskLabels; 32 | -------------------------------------------------------------------------------- /frontend/src/components/PriorityOption.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import { faArrowUp } from "@fortawesome/free-solid-svg-icons"; 4 | import { PRIO_COLORS } from "const"; 5 | import { Priority } from "types"; 6 | import styled from "@emotion/styled"; 7 | 8 | interface Props { 9 | option: Priority; 10 | } 11 | 12 | const Container = styled.div` 13 | display: flex; 14 | align-items: center; 15 | `; 16 | 17 | const PrioLabel = styled.div` 18 | margin-left: 1rem; 19 | `; 20 | 21 | const PriorityOption = ({ option }: Props) => { 22 | return ( 23 | 24 | 29 | {option.label} 30 | 31 | ); 32 | }; 33 | 34 | export default PriorityOption; 35 | -------------------------------------------------------------------------------- /frontend/src/components/AvatarOption.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Avatar as UserAvatar } from "types"; 3 | import { Avatar, ChipProps } from "@material-ui/core"; 4 | import { css } from "@emotion/core"; 5 | import styled from "@emotion/styled"; 6 | 7 | const Username = styled.span` 8 | margin-left: 0.5rem; 9 | word-break: break-all; 10 | `; 11 | 12 | interface Option { 13 | id: number; 14 | username: string; 15 | avatar: UserAvatar | null; 16 | } 17 | 18 | interface Props extends ChipProps { 19 | option: Option; 20 | } 21 | 22 | const AvatarOption = ({ option }: Props) => { 23 | return ( 24 | <> 25 | 33 | {option.username} 34 | > 35 | ); 36 | }; 37 | 38 | export default AvatarOption; 39 | -------------------------------------------------------------------------------- /django.Dockerfile: -------------------------------------------------------------------------------- 1 | # Latest Python version supported by Django 2 | FROM python:3.8 3 | 4 | # Make sure logs are received in a timely manner 5 | ENV PYTHONBUFFERED 1 6 | 7 | # Install system packages 8 | RUN apt-get update && apt-get upgrade -y 9 | 10 | # Set working directory 11 | WORKDIR /app 12 | 13 | # Install Python packages 14 | COPY ./backend/requirements/base.txt /app/requirements/base.txt 15 | COPY ./backend/requirements/production.txt /app/requirements/production.txt 16 | RUN pip install --no-cache-dir -r /app/requirements/production.txt 17 | 18 | # Copy the project files 19 | COPY ./backend/ /app/ 20 | 21 | # Wait for database to be ready, run database migrations, collectstatic for nginx to serve and then start the app 22 | CMD bash -c "./wait-for-it.sh postgres:5432 && ./manage.py migrate --settings=config.settings.production && ./manage.py collectstatic --no-input && gunicorn config.wsgi:application --bind :8000" 23 | 24 | # Make it accessible for the nginx container 25 | EXPOSE 8000 26 | -------------------------------------------------------------------------------- /backend/boards/migrations/0006_auto_20200410_1542.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.4 on 2020-04-10 15:42 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ("boards", "0005_auto_20200406_1433"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="task", 17 | name="assignees", 18 | field=models.ManyToManyField( 19 | related_name="tasks", to=settings.AUTH_USER_MODEL 20 | ), 21 | ), 22 | migrations.AddField( 23 | model_name="task", 24 | name="priority", 25 | field=models.CharField( 26 | choices=[("H", "High"), ("M", "Medium"), ("L", "Low")], 27 | default="M", 28 | max_length=1, 29 | ), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /frontend/src/features/auth/Auth.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "@emotion/styled"; 3 | import { ReactComponent as Board } from "static/svg/board.svg"; 4 | import LoginDialog from "./LoginDialog"; 5 | import RegisterDialog from "./RegisterDialog"; 6 | import EnterAsGuest from "./EnterAsGuest"; 7 | import Footer from "./Footer"; 8 | import SEO from "components/SEO"; 9 | 10 | const Container = styled.div` 11 | margin-top: 20vh; 12 | display: flex; 13 | flex-direction: column; 14 | align-items: center; 15 | `; 16 | 17 | const Title = styled.h1` 18 | margin-top: 0; 19 | margin-bottom: 0.75rem; 20 | `; 21 | 22 | const Auth = () => { 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | Knboard 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | export default Auth; 41 | -------------------------------------------------------------------------------- /backend/boards/migrations/0010_auto_20200517_0826.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-17 08:26 2 | 3 | import django.utils.timezone 4 | import model_utils.fields 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("boards", "0009_task_labels"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="task", 17 | name="created", 18 | field=model_utils.fields.AutoCreatedField( 19 | default=django.utils.timezone.now, 20 | editable=False, 21 | verbose_name="created", 22 | ), 23 | ), 24 | migrations.AddField( 25 | model_name="task", 26 | name="modified", 27 | field=model_utils.fields.AutoLastModifiedField( 28 | default=django.utils.timezone.now, 29 | editable=False, 30 | verbose_name="modified", 31 | ), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /backend/boards/migrations/0002_auto_20200321_0631.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.4 on 2020-03-21 06:31 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ("boards", "0001_initial"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name="board", 18 | name="members", 19 | field=models.ManyToManyField(to=settings.AUTH_USER_MODEL), 20 | ), 21 | migrations.AddField( 22 | model_name="board", 23 | name="owner", 24 | field=models.ForeignKey( 25 | default=1, 26 | on_delete=django.db.models.deletion.PROTECT, 27 | related_name="boards", 28 | to=settings.AUTH_USER_MODEL, 29 | ), 30 | preserve_default=False, 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /backend/boards/migrations/0004_auto_20200329_0919.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.4 on 2020-03-29 09:19 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ("boards", "0003_auto_20200322_1633"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name="board", 18 | name="members", 19 | field=models.ManyToManyField( 20 | related_name="boards", to=settings.AUTH_USER_MODEL 21 | ), 22 | ), 23 | migrations.AlterField( 24 | model_name="board", 25 | name="owner", 26 | field=models.ForeignKey( 27 | on_delete=django.db.models.deletion.PROTECT, 28 | related_name="owned_boards", 29 | to=settings.AUTH_USER_MODEL, 30 | ), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /frontend/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "plugin:react/recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:prettier/recommended", 7 | "prettier/@typescript-eslint" 8 | ], 9 | "rules": { 10 | "@typescript-eslint/explicit-function-return-type": "off", 11 | "@typescript-eslint/no-explicit-any": "off", 12 | "@typescript-eslint/ban-ts-ignore": "off", 13 | "@typescript-eslint/interface-name-prefix": "off", 14 | "@typescript-eslint/no-unused-vars": [ 15 | "warn", 16 | { "argsIgnorePattern": "^_" } 17 | ], 18 | "prettier/prettier": [ 19 | "error", 20 | { 21 | "endOfLine": "auto" 22 | } 23 | ] 24 | }, 25 | "overrides": [ 26 | { 27 | "files": ["**/*.tsx"], 28 | "rules": { 29 | "react/prop-types": "off" 30 | } 31 | } 32 | ], 33 | "settings": { 34 | "react": { 35 | "version": "detect" 36 | } 37 | }, 38 | "ignorePatterns": ["coverage/", "node_modules/", "src/serviceWorker.ts"] 39 | } 40 | -------------------------------------------------------------------------------- /backend/boards/tests/test_factories.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def test_user_factory(user_factory): 5 | user = user_factory() 6 | assert re.match(r"jack\d+", user.username) 7 | assert re.match(r"jack\d+@stargate.com", user.email) 8 | 9 | user = user_factory(username="steve") 10 | assert user.username == "steve" 11 | 12 | 13 | def test_board_factory(board_factory): 14 | board = board_factory() 15 | assert re.match(r"uni\d+", board.name) 16 | assert board.owner 17 | 18 | 19 | def test_column_factory(column_factory): 20 | column = column_factory(title="In Progress") 21 | assert column.title == "In Progress" 22 | 23 | 24 | def test_task_factory(task_factory): 25 | task = task_factory() 26 | assert re.match(r"task\d+", task.title) 27 | 28 | 29 | def test_label_factory(label_factory): 30 | label = label_factory() 31 | assert re.match(r"label\d+", label.name) 32 | assert re.match(r"#\w{6}", label.color) 33 | 34 | 35 | def test_comment_factory(comment_factory): 36 | comment = comment_factory() 37 | assert comment.task 38 | assert comment.author 39 | assert comment.text 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2020 Ragnar Rebase 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. -------------------------------------------------------------------------------- /frontend/src/features/home/Home.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button } from "@material-ui/core"; 3 | import { Link } from "react-router-dom"; 4 | import styled from "@emotion/styled"; 5 | import { ReactComponent as Hero } from "static/svg/thoughts.svg"; 6 | import { css } from "@emotion/core"; 7 | import SEO from "components/SEO"; 8 | 9 | const Container = styled.div` 10 | display: flex; 11 | flex-direction: column; 12 | align-items: center; 13 | `; 14 | 15 | const HeroContainer = styled.div``; 16 | 17 | const Home = () => { 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | 25 | 32 | 37 | View Boards 38 | 39 | 40 | 41 | ); 42 | }; 43 | 44 | export default Home; 45 | -------------------------------------------------------------------------------- /backend/accounts/models.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.contrib.auth.models import AbstractUser 4 | from django.contrib.auth.models import UnicodeUsernameValidator 5 | from django.core.validators import MinLengthValidator 6 | from django.db import models 7 | from django.utils.translation import gettext_lazy as _ 8 | 9 | 10 | class Avatar(models.Model): 11 | photo = models.ImageField(upload_to="avatars") 12 | 13 | def __str__(self): 14 | return os.path.basename(self.photo.name) 15 | 16 | 17 | class User(AbstractUser): 18 | username = models.CharField( 19 | _("username"), 20 | max_length=150, 21 | unique=True, 22 | help_text=_( 23 | "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only." 24 | ), 25 | validators=[UnicodeUsernameValidator(), MinLengthValidator(3)], 26 | error_messages={"unique": _("A user with that username already exists."),}, 27 | ) 28 | avatar = models.ForeignKey( 29 | "Avatar", null=True, blank=True, on_delete=models.PROTECT 30 | ) 31 | is_guest = models.BooleanField(default=False) 32 | 33 | class Meta: 34 | ordering = ["-id"] 35 | -------------------------------------------------------------------------------- /frontend/src/components/Close.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "@emotion/styled"; 3 | import { IconButton } from "@material-ui/core"; 4 | import { css } from "@emotion/core"; 5 | import { ReactComponent as TimesIcon } from "static/svg/times.svg"; 6 | import { TASK_G } from "utils/colors"; 7 | 8 | const Container = styled.div` 9 | position: absolute; 10 | top: 1rem; 11 | right: 1rem; 12 | `; 13 | 14 | interface Props { 15 | onClose: () => void; 16 | onPopper?: boolean; 17 | } 18 | 19 | const Close = ({ onClose, onPopper = false }: Props) => { 20 | return ( 21 | 26 | 38 | 39 | 40 | 41 | ); 42 | }; 43 | 44 | export default Close; 45 | -------------------------------------------------------------------------------- /backend/accounts/migrations/0003_auto_20200505_1756.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.4 on 2020-05-05 17:56 2 | 3 | import django.contrib.auth.validators 4 | import django.core.validators 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("accounts", "0002_auto_20200327_1817"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterModelOptions(name="user", options={"ordering": ["-id"]},), 16 | migrations.AlterField( 17 | model_name="user", 18 | name="username", 19 | field=models.CharField( 20 | error_messages={"unique": "A user with that username already exists."}, 21 | help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", 22 | max_length=150, 23 | unique=True, 24 | validators=[ 25 | django.contrib.auth.validators.UnicodeUsernameValidator(), 26 | django.core.validators.MinLengthValidator(3), 27 | ], 28 | verbose_name="username", 29 | ), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /frontend/src/features/comment/CommentTextarea.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/core"; 2 | import { 3 | TextareaAutosize, 4 | TextareaAutosizeProps, 5 | useTheme, 6 | } from "@material-ui/core"; 7 | import { commentBoxWidth, commentBoxWidthMobile } from "const"; 8 | import React from "react"; 9 | import { N800 } from "utils/colors"; 10 | 11 | const CommentTextarea = (props: TextareaAutosizeProps) => { 12 | const theme = useTheme(); 13 | 14 | return ( 15 | 39 | ); 40 | }; 41 | 42 | export default CommentTextarea; 43 | -------------------------------------------------------------------------------- /backend/accounts/migrations/0002_auto_20200327_1817.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.4 on 2020-03-27 18:17 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("accounts", "0001_initial"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="Avatar", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("photo", models.ImageField(upload_to="avatars")), 27 | ], 28 | ), 29 | migrations.AddField( 30 | model_name="user", 31 | name="avatar", 32 | field=models.ForeignKey( 33 | blank=True, 34 | null=True, 35 | on_delete=django.db.models.deletion.PROTECT, 36 | to="accounts.Avatar", 37 | ), 38 | ), 39 | ] 40 | -------------------------------------------------------------------------------- /backend/boards/migrations/0007_label.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.4 on 2020-05-07 06:52 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("boards", "0006_auto_20200410_1542"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="Label", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("name", models.CharField(max_length=255)), 27 | ("color", models.CharField(max_length=7)), 28 | ( 29 | "board", 30 | models.ForeignKey( 31 | on_delete=django.db.models.deletion.CASCADE, 32 | related_name="labels", 33 | to="boards.Board", 34 | ), 35 | ), 36 | ], 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /frontend/src/features/toast/ToastSlice.tsx: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | import { Color } from "@material-ui/lab/Alert"; 3 | 4 | interface InitialState { 5 | open: boolean; 6 | message: string | null; 7 | severity: Color; 8 | } 9 | 10 | export const initialState: InitialState = { 11 | open: false, 12 | message: null, 13 | severity: "success", 14 | }; 15 | 16 | export const slice = createSlice({ 17 | name: "toast", 18 | initialState, 19 | reducers: { 20 | createSuccessToast: (state, action: PayloadAction) => { 21 | state.open = true; 22 | state.message = action.payload; 23 | state.severity = "success"; 24 | }, 25 | createInfoToast: (state, action: PayloadAction) => { 26 | state.open = true; 27 | state.message = action.payload; 28 | state.severity = "info"; 29 | }, 30 | createErrorToast: (state, action: PayloadAction) => { 31 | state.open = true; 32 | state.message = action.payload; 33 | state.severity = "error"; 34 | }, 35 | clearToast: (state) => { 36 | state.open = false; 37 | }, 38 | }, 39 | }); 40 | 41 | export const { 42 | createSuccessToast, 43 | createInfoToast, 44 | createErrorToast, 45 | clearToast, 46 | } = slice.actions; 47 | 48 | export default slice.reducer; 49 | -------------------------------------------------------------------------------- /backend/boards/management/commands/load_benchmark.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.management.base import BaseCommand, CommandError 3 | 4 | from accounts.models import User 5 | from boards.models import Board 6 | from boards.tests.factories import BoardFactory, ColumnFactory, TaskFactory, UserFactory 7 | 8 | 9 | class Command(BaseCommand): 10 | help = "Load board with a lot of tasks for benchmarking" 11 | 12 | def add_arguments(self, parser): 13 | parser.add_argument("-f", "--force", required=False, type=bool) 14 | 15 | def handle(self, *args, **options): 16 | force = options["force"] 17 | 18 | if not settings.DEBUG: 19 | raise CommandError("Command not meant for production") 20 | 21 | if force: 22 | Board.objects.filter(name="Benchmark").delete() 23 | elif Board.objects.filter(name="Benchmark").exists(): 24 | raise CommandError("Aborting. Benchmark already exists.") 25 | 26 | if User.objects.filter(username="steve").exists(): 27 | user = User.objects.filter(username="steve").first() 28 | else: 29 | user = UserFactory(username="steve") 30 | 31 | board = BoardFactory(name="Benchmark", owner=user) 32 | for _ in range(10): 33 | column = ColumnFactory(board=board) 34 | for _ in range(50): 35 | TaskFactory(column=column) 36 | -------------------------------------------------------------------------------- /frontend/src/api.tsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { logout } from "features/auth/AuthSlice"; 3 | 4 | // Config global defaults for axios/django 5 | axios.defaults.xsrfHeaderName = "X-CSRFToken"; 6 | axios.defaults.xsrfCookieName = "csrftoken"; 7 | 8 | export const setupInterceptors = (store: any) => { 9 | axios.interceptors.response.use( 10 | (response) => response, 11 | (error) => { 12 | // Handle expired sessions 13 | if (error.response && error.response.status === 401) { 14 | store.dispatch(logout()); 15 | } 16 | return Promise.reject(error); 17 | } 18 | ); 19 | }; 20 | 21 | // Available endpoints 22 | export const API_LOGIN = "/auth/login/"; 23 | export const API_LOGOUT = "/auth/logout/"; 24 | export const API_REGISTER = "/auth/registration/"; 25 | export const API_GUEST_REGISTER = "/auth/guest/"; 26 | export const API_AUTH_SETUP = "/auth/setup/"; 27 | 28 | export const API_AVATARS = "/api/avatars/"; 29 | export const API_BOARDS = "/api/boards/"; 30 | export const API_COLUMNS = "/api/columns/"; 31 | export const API_TASKS = "/api/tasks/"; 32 | export const API_COMMENTS = "/api/comments/"; 33 | export const API_LABELS = "/api/labels/"; 34 | export const API_SORT_COLUMNS = "/api/sort/column/"; 35 | export const API_SORT_TASKS = "/api/sort/task/"; 36 | export const API_USERS = "/api/users/"; 37 | export const API_SEARCH_USERS = "/api/u/search/"; 38 | 39 | export default axios; 40 | -------------------------------------------------------------------------------- /frontend/src/features/board/NewBoardDialog.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { screen, fireEvent, waitFor } from "@testing-library/react"; 3 | import { 4 | rootInitialState, 5 | renderWithProviders, 6 | axiosMock, 7 | } from "utils/testHelpers"; 8 | import NewBoardDialog from "./NewBoardDialog"; 9 | import { createBoard } from "./BoardSlice"; 10 | import { API_BOARDS } from "api"; 11 | 12 | it("should not show dialog", async () => { 13 | renderWithProviders(); 14 | expect(screen.queryByText(/Create a new private board./i)).toBeNull(); 15 | }); 16 | 17 | it("should show dialog", async () => { 18 | axiosMock 19 | .onPost(API_BOARDS) 20 | .reply(201, { id: 50, name: "Recipes", owner: 1 }); 21 | const { getActionsTypes } = renderWithProviders(, { 22 | ...rootInitialState, 23 | board: { ...rootInitialState.board, createDialogOpen: true }, 24 | }); 25 | expect(screen.getByText(/Create a new private board./i)).toBeVisible(); 26 | fireEvent.change(screen.getByLabelText("Board name"), { 27 | target: { value: "Science" }, 28 | }); 29 | fireEvent.click(screen.getByTestId("create-board-btn")); 30 | 31 | await waitFor(() => 32 | expect(getActionsTypes().includes(createBoard.fulfilled.type)).toBe(true) 33 | ); 34 | 35 | expect(getActionsTypes()).toEqual([ 36 | createBoard.pending.type, 37 | createBoard.fulfilled.type, 38 | ]); 39 | }); 40 | -------------------------------------------------------------------------------- /frontend/src/features/member/MemberDetail.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Avatar } from "@material-ui/core"; 3 | import { BoardMember } from "types"; 4 | import { avatarStyles } from "styles"; 5 | import { css, keyframes } from "@emotion/core"; 6 | import { useDispatch } from "react-redux"; 7 | import { setDialogMember } from "features/member/MemberSlice"; 8 | import { OWNER_COLOR } from "utils/colors"; 9 | 10 | const scaleUp = keyframes` 11 | 0% { 12 | transform: scale(1.0); 13 | } 14 | 100% { 15 | transform: scale(1.15); 16 | } 17 | `; 18 | 19 | const animationStyles = css` 20 | animation: 0.2s ${scaleUp} forwards; 21 | `; 22 | 23 | interface Props { 24 | member: BoardMember; 25 | isOwner: boolean; 26 | } 27 | 28 | const MemberDetail = ({ member, isOwner }: Props) => { 29 | const dispatch = useDispatch(); 30 | 31 | const handleClick = () => { 32 | dispatch(setDialogMember(member.id)); 33 | }; 34 | 35 | return ( 36 | 50 | {member.username.charAt(0)} 51 | 52 | ); 53 | }; 54 | 55 | export default MemberDetail; 56 | -------------------------------------------------------------------------------- /frontend/src/features/comment/CommentItem.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/camelcase */ 2 | import { screen } from "@testing-library/react"; 3 | import { formatISO } from "date-fns"; 4 | import React from "react"; 5 | import { BoardMember, TaskComment } from "types"; 6 | import { renderWithProviders, rootInitialState } from "utils/testHelpers"; 7 | import CommentItem from "./CommentItem"; 8 | 9 | const member: BoardMember = { 10 | id: 1, 11 | username: "foobar", 12 | email: "foobar@gmail.com", 13 | first_name: "Foo", 14 | last_name: "Bar", 15 | avatar: null, 16 | }; 17 | 18 | const comment: TaskComment = { 19 | id: 1, 20 | author: 1, 21 | created: formatISO(new Date(2012, 8, 18, 19, 0, 52)), 22 | modified: formatISO(new Date(2015, 9, 15, 20, 2, 53)), 23 | task: 1, 24 | text: "foobar", 25 | }; 26 | 27 | const initialReduxState = { 28 | ...rootInitialState, 29 | member: { 30 | ...rootInitialState.member, 31 | ids: [member.id], 32 | entities: { [member.id]: member }, 33 | }, 34 | }; 35 | 36 | it("should render comment text and author first name", () => { 37 | renderWithProviders(, initialReduxState); 38 | expect(screen.getByText(comment.text)).toBeVisible(); 39 | expect(screen.getByText(member.first_name)).toBeVisible(); 40 | }); 41 | 42 | it("should be empty with invalid author", () => { 43 | const { container } = renderWithProviders(); 44 | expect(container).toMatchInlineSnapshot(``); 45 | }); 46 | -------------------------------------------------------------------------------- /frontend/src/static/svg/github.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /frontend/cypress/fixtures/os_board.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 2, 3 | "name": "Operating Systems", 4 | "owner": 2, 5 | "members": [ 6 | { 7 | "id": 1, 8 | "username": "testuser", 9 | "email": "t@t.com", 10 | "first_name": "Ragnar", 11 | "last_name": "Rebase", 12 | "avatar": null 13 | }, 14 | { 15 | "id": 2, 16 | "username": "steveapple1", 17 | "email": "steve@gmail.com", 18 | "first_name": "Steve", 19 | "last_name": "Apple", 20 | "avatar": null 21 | } 22 | ], 23 | "labels": [], 24 | "columns": [ 25 | { 26 | "id": 5, 27 | "title": "Exams", 28 | "tasks": [ 29 | { 30 | "id": 5, 31 | "created": "2020-05-17T08:26:51.806330Z", 32 | "modified": "2020-05-17T09:02:10.724890Z", 33 | "title": "Security", 34 | "description": "Fundamentals of Security.", 35 | "task_order": 5, 36 | "priority": "L", 37 | "assignees": [], 38 | "labels": [], 39 | "column": 5 40 | }, 41 | { 42 | "id": 6, 43 | "created": "2020-05-17T08:26:51.806330Z", 44 | "modified": "2020-05-17T09:02:10.724890Z", 45 | "title": "Bash", 46 | "description": "The basics of Bash.", 47 | "task_order": 6, 48 | "priority": "L", 49 | "assignees": [], 50 | "labels": [], 51 | "column": 5 52 | } 53 | ], 54 | "column_order": 5 55 | } 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /frontend/src/features/task/AddTask.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import React from "react"; 3 | import { Button } from "@material-ui/core"; 4 | import { N80A, N900 } from "utils/colors"; 5 | import { Id } from "types"; 6 | import { faPlus } from "@fortawesome/free-solid-svg-icons"; 7 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 8 | import { css } from "@emotion/core"; 9 | import { useDispatch } from "react-redux"; 10 | import { setCreateDialogColumn, setCreateDialogOpen } from "./TaskSlice"; 11 | 12 | interface Props { 13 | columnId: Id; 14 | index: number; 15 | } 16 | 17 | const AddTask = ({ columnId, index }: Props) => { 18 | const dispatch = useDispatch(); 19 | 20 | const handleOnClick = () => { 21 | dispatch(setCreateDialogColumn(columnId)); 22 | dispatch(setCreateDialogOpen(true)); 23 | }; 24 | 25 | return ( 26 | *:first-of-type { 40 | font-size: 12px; 41 | } 42 | `} 43 | startIcon={} 44 | onClick={handleOnClick} 45 | fullWidth 46 | > 47 | Add card 48 | 49 | ); 50 | }; 51 | 52 | export default AddTask; 53 | -------------------------------------------------------------------------------- /frontend/src/components/LabelChip.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { css } from "@emotion/core"; 3 | import { Chip, ChipProps } from "@material-ui/core"; 4 | import { getContrastColor, WHITE } from "utils/colors"; 5 | import { Label } from "types"; 6 | 7 | interface Props extends ChipProps { 8 | label: Label; 9 | onCard?: boolean; 10 | } 11 | 12 | const LabelChip = ({ label, onCard = false, ...rest }: Props) => { 13 | const contrastColor = getContrastColor(label.color); 14 | 15 | return ( 16 | 51 | ); 52 | }; 53 | 54 | export default LabelChip; 55 | -------------------------------------------------------------------------------- /frontend/cypress/integration/stubbed/auth.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context("Stubbed Auth", () => { 4 | beforeEach(() => { 5 | cy.stubbedSetup(); 6 | cy.visit("/boards/"); 7 | cy.title().should("eq", "Boards | Knboard"); 8 | }); 9 | 10 | it("should show login view after clicking logout via user menu", () => { 11 | cy.findByTestId("user-menu").click(); 12 | cy.findByText(/Logout/i).click(); 13 | cy.findByTestId("open-login-btn").should("be.visible"); 14 | }); 15 | }); 16 | 17 | context("Stubbed Auth User Not Logged In", () => { 18 | beforeEach(() => { 19 | cy.server({ force404: true }); 20 | cy.visit("/"); 21 | cy.title().should("eq", "Knboard"); 22 | }); 23 | 24 | it("should not register if no email entered", () => { 25 | cy.route({ 26 | method: "POST", 27 | url: "/auth/registration/", 28 | status: 400, 29 | response: { email: ["This field is required."] }, 30 | }).as("register"); 31 | cy.findByText(/register/i).click(); 32 | cy.findByLabelText("Username").type("testuser"); 33 | cy.get("p[id='email-helper-text']").should( 34 | "not.have.text", 35 | "Can be left empty." 36 | ); 37 | cy.findByLabelText("Password").type("TestPw123"); 38 | cy.findByLabelText("Confirm Password").type("TestPw123"); 39 | cy.findByTestId("submit-register-btn").click(); 40 | cy.wait("@register").then(() => { 41 | cy.get("p[id='email-helper-text']") 42 | .should("be.visible") 43 | .should("have.text", "This field is required."); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /ansible/roles/docker-compose/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Install required system packages 4 | apt: name={{ item }} state=latest 5 | loop: 6 | - "apt-transport-https" 7 | - "ca-certificates" 8 | 9 | - name: Add Docker GPG apt Key 10 | apt_key: 11 | url: https://download.docker.com/linux/ubuntu/gpg 12 | state: present 13 | 14 | - name: Add Docker Repository 15 | apt_repository: 16 | repo: deb https://download.docker.com/linux/ubuntu bionic stable 17 | state: present 18 | 19 | - name: Install docker-ce 20 | apt: update_cache=yes name=docker-ce state=latest 21 | 22 | - name: Install Docker Python bindings 23 | apt: update_cache=yes name=python3-docker state=present 24 | 25 | - name: Check if /etc/default/ufw exists 26 | stat: 27 | path: /etc/default/ufw 28 | register: ufw_default_exists 29 | 30 | - name: Change ufw default forward policy from drop to accept 31 | lineinfile: 32 | dest: /etc/default/ufw 33 | regexp: "^DEFAULT_FORWARD_POLICY=" 34 | line: "DEFAULT_FORWARD_POLICY=\"ACCEPT\"" 35 | when: ufw_default_exists.stat.exists 36 | 37 | - name: Start docker 38 | service: 39 | name: docker 40 | state: started 41 | 42 | - name: Install Docker Compose 43 | get_url: 44 | url: https://github.com/docker/compose/releases/download/{{ docker_compose_version }}/docker-compose-Linux-x86_64 45 | dest: /usr/local/bin/docker-compose 46 | mode: a+x 47 | 48 | - include: roles/common/tasks/checkout.yml 49 | 50 | - name: Copy wait.sh 51 | copy: 52 | src: wait.sh 53 | dest: "{{ repo_folder }}backend/wait-for-it.sh" 54 | mode: a+x 55 | -------------------------------------------------------------------------------- /frontend/src/components/AssigneeAutoComplete.tsx: -------------------------------------------------------------------------------- 1 | import { TextField } from "@material-ui/core"; 2 | import { Autocomplete } from "@material-ui/lab"; 3 | import React from "react"; 4 | import { BoardMember } from "types"; 5 | import AvatarOption from "./AvatarOption"; 6 | import AvatarTag from "./AvatarTag"; 7 | 8 | interface Props { 9 | controlId: string; 10 | dataTestId: string; 11 | members: BoardMember[]; 12 | assignee: BoardMember[]; 13 | setAssignee: (assignees: BoardMember[]) => void; 14 | } 15 | 16 | const AssigneeAutoComplete = ({ 17 | controlId, 18 | dataTestId, 19 | members, 20 | assignee, 21 | setAssignee, 22 | }: Props) => { 23 | return ( 24 | option.username} 35 | value={assignee} 36 | onChange={(_event, value) => setAssignee(value)} 37 | renderOption={(option) => } 38 | renderInput={(params) => ( 39 | 40 | )} 41 | renderTags={(value, getTagProps) => 42 | value.map((option, index) => ( 43 | 48 | )) 49 | } 50 | /> 51 | ); 52 | }; 53 | 54 | export default AssigneeAutoComplete; 55 | -------------------------------------------------------------------------------- /backend/config/settings/local.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | # Dummy value for development 4 | SECRET_KEY = "*m(r@4mdh*!zabwg&6tp%mgs_ezkprs9g+$@x@cdq-z_)dtf2i" 5 | 6 | DEBUG_TOOLBAR = False 7 | 8 | DATABASES = { 9 | "default": { 10 | "ENGINE": "django.db.backends.postgresql", 11 | "NAME": "knboard", 12 | "USER": "knboard", 13 | "PASSWORD": "knboard", 14 | "HOST": "localhost", 15 | "PORT": 5432, 16 | } 17 | } 18 | 19 | if DEBUG_TOOLBAR: 20 | # Add django extensions 21 | INSTALLED_APPS += ["debug_toolbar"] 22 | MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware"] + MIDDLEWARE 23 | 24 | # Configure django-debug-toolbar 25 | DEBUG_TOOLBAR_PANELS = [ 26 | "ddt_request_history.panels.request_history.RequestHistoryPanel", 27 | "debug_toolbar.panels.versions.VersionsPanel", 28 | "debug_toolbar.panels.timer.TimerPanel", 29 | "debug_toolbar.panels.settings.SettingsPanel", 30 | "debug_toolbar.panels.headers.HeadersPanel", 31 | "debug_toolbar.panels.request.RequestPanel", 32 | "debug_toolbar.panels.sql.SQLPanel", 33 | "debug_toolbar.panels.templates.TemplatesPanel", 34 | "debug_toolbar.panels.staticfiles.StaticFilesPanel", 35 | "debug_toolbar.panels.cache.CachePanel", 36 | "debug_toolbar.panels.signals.SignalsPanel", 37 | "debug_toolbar.panels.logging.LoggingPanel", 38 | "debug_toolbar.panels.redirects.RedirectsPanel", 39 | "debug_toolbar.panels.profiling.ProfilingPanel", 40 | ] 41 | 42 | # Needed for django-debug-toolbar 43 | INTERNAL_IPS = [ 44 | "127.0.0.1", 45 | ] 46 | -------------------------------------------------------------------------------- /frontend/src/utils/utils.test.tsx: -------------------------------------------------------------------------------- 1 | import { saveState, loadState } from "utils/localStorage"; 2 | import { LOCAL_STORAGE_KEY } from "const"; 3 | import { getContrastColor, getRandomHexColor } from "./colors"; 4 | 5 | describe("localStorage", () => { 6 | beforeEach(() => { 7 | localStorage.clear(); 8 | }); 9 | 10 | it("should handle empty data", () => { 11 | loadState(); 12 | expect(window.localStorage.getItem).toBeCalled(); 13 | expect(loadState()).toBeUndefined(); 14 | }); 15 | 16 | it("should handle valid data", () => { 17 | const data = { message: "hello" }; 18 | localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(data)); 19 | 20 | loadState(); 21 | expect(window.localStorage.getItem).toBeCalled(); 22 | expect(loadState()).toEqual(data); 23 | }); 24 | 25 | it("should handle bad data", () => { 26 | localStorage.setItem(LOCAL_STORAGE_KEY, "$"); 27 | const result = loadState(); 28 | expect(result).toBeUndefined(); 29 | }); 30 | 31 | it("should save data", () => { 32 | const stateToSave = { message: "Fix covid-19" }; 33 | saveState(stateToSave); 34 | expect(localStorage.__STORE__[LOCAL_STORAGE_KEY]).toBe( 35 | JSON.stringify(stateToSave) 36 | ); 37 | }); 38 | }); 39 | 40 | describe("color utilities", () => { 41 | it("should return white as contrast for black and vice versa", () => { 42 | expect(getContrastColor("#FFFFFF")).toEqual("#000000"); 43 | expect(getContrastColor("#000000")).toEqual("#FFFFFF"); 44 | }); 45 | 46 | it("should return a valid random hex color", () => { 47 | expect(/^#(?:[0-9a-fA-F]{3}){1,2}$/.test(getRandomHexColor())).toBeTruthy(); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | django: 5 | build: 6 | context: . 7 | dockerfile: django.Dockerfile 8 | image: knboard_production_django 9 | restart: unless-stopped 10 | volumes: 11 | - "django-static:/app/django-static" 12 | - "media:/app/media" 13 | - "./backend/settings:/app/settings" 14 | env_file: 15 | - .env 16 | depends_on: 17 | - postgres 18 | 19 | nginx: 20 | build: 21 | context: . 22 | dockerfile: nginx.Dockerfile 23 | command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" 24 | image: knboard_production_nginx 25 | restart: unless-stopped 26 | ports: 27 | - "80:80" 28 | - "443:443" 29 | volumes: 30 | - "django-static:/usr/share/nginx/django-static" 31 | - "media:/usr/share/nginx/html/media" 32 | - "/etc/nginx/nginx.conf:/etc/nginx/nginx.conf" 33 | - "/etc/nginx/proxy.conf:/etc/nginx/proxy.conf" 34 | - "./data/certbot/conf:/etc/letsencrypt" 35 | - "./data/certbot/www:/var/www/certbot" 36 | depends_on: 37 | - django 38 | - postgres 39 | 40 | certbot: 41 | image: certbot/certbot 42 | entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" 43 | volumes: 44 | - "./data/certbot/conf:/etc/letsencrypt" 45 | - "./data/certbot/www:/var/www/certbot" 46 | 47 | postgres: 48 | image: postgres:11 49 | restart: unless-stopped 50 | volumes: 51 | - "/var/lib/postgresql/knboard:/var/lib/postgresql/data" 52 | env_file: 53 | - .env 54 | 55 | volumes: 56 | media: 57 | django-static: 58 | -------------------------------------------------------------------------------- /backend/fixtures/users.yaml: -------------------------------------------------------------------------------- 1 | - model: accounts.User 2 | pk: 1 3 | fields: 4 | username: testuser 5 | password: pbkdf2_sha256$180000$k48VxRZbQfhp$+BLXapUGNiSy1hCEAmc+gNViN83OHK37fKcJ0BfgwQ4= 6 | first_name: Ragnar 7 | last_name: Rebase 8 | email: t@t.com 9 | is_superuser: true 10 | is_staff: true 11 | is_active: true 12 | 13 | - model: accounts.User 14 | pk: 2 15 | fields: 16 | username: steveapple1 17 | password: pbkdf2_sha256$180000$k48VxRZbQfhp$+BLXapUGNiSy1hCEAmc+gNViN83OHK37fKcJ0BfgwQ4= 18 | first_name: Steve 19 | last_name: Apple 20 | email: steve@gmail.com 21 | is_superuser: false 22 | is_staff: true 23 | is_active: true 24 | 25 | - model: accounts.User 26 | pk: 3 27 | fields: 28 | username: daveice 29 | password: pbkdf2_sha256$180000$k48VxRZbQfhp$+BLXapUGNiSy1hCEAmc+gNViN83OHK37fKcJ0BfgwQ4= 30 | first_name: Dave 31 | last_name: Ice 32 | email: dave@ice.com 33 | is_superuser: false 34 | is_staff: true 35 | is_active: true 36 | 37 | - model: accounts.User 38 | pk: 4 39 | fields: 40 | username: timwoodstock 41 | password: pbkdf2_sha256$180000$k48VxRZbQfhp$+BLXapUGNiSy1hCEAmc+gNViN83OHK37fKcJ0BfgwQ4= 42 | first_name: Tim 43 | last_name: Woodstock 44 | email: tim@gmail.com 45 | is_superuser: false 46 | is_staff: true 47 | is_active: true 48 | 49 | - model: accounts.User 50 | pk: 5 51 | fields: 52 | username: stenwood55 53 | password: pbkdf2_sha256$180000$k48VxRZbQfhp$+BLXapUGNiSy1hCEAmc+gNViN83OHK37fKcJ0BfgwQ4= 54 | first_name: Sten 55 | last_name: Woodstock 56 | email: sten@gmail.com 57 | is_superuser: false 58 | is_staff: true 59 | is_active: true 60 | -------------------------------------------------------------------------------- /frontend/src/features/label/LabelCreate.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import LabelFields from "./LabelFields"; 3 | import { useDispatch, useSelector } from "react-redux"; 4 | import { RootState } from "store"; 5 | import { useForm, FormContext } from "react-hook-form"; 6 | import { createLabel, selectAllLabels } from "./LabelSlice"; 7 | import { getRandomHexColor } from "utils/colors"; 8 | import styled from "@emotion/styled"; 9 | 10 | const Container = styled.div` 11 | margin: 0 0.5rem; 12 | `; 13 | 14 | interface Props { 15 | setCreating: (creating: boolean) => void; 16 | } 17 | 18 | interface DialogFormData { 19 | name: string; 20 | color: string; 21 | } 22 | 23 | const LabelCreate = ({ setCreating }: Props) => { 24 | const dispatch = useDispatch(); 25 | const boardId = useSelector((state: RootState) => state.board.detail?.id); 26 | const labels = useSelector(selectAllLabels); 27 | const methods = useForm({ 28 | defaultValues: { name: "", color: getRandomHexColor() }, 29 | mode: "onChange", 30 | }); 31 | 32 | const onSubmit = methods.handleSubmit(({ name, color }) => { 33 | if (labels.map((label) => label.name).includes(name)) { 34 | methods.setError("name", "Label already exists"); 35 | return; 36 | } 37 | if (boardId) { 38 | dispatch(createLabel({ name, color, board: boardId })); 39 | setCreating(false); 40 | } 41 | }); 42 | 43 | return ( 44 | 45 | 46 | 51 | 52 | 53 | ); 54 | }; 55 | 56 | export default LabelCreate; 57 | -------------------------------------------------------------------------------- /backend/boards/tests/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | from django.contrib.auth import get_user_model 3 | from boards.models import Board, Column, Task, Label, Comment 4 | from factory.django import DjangoModelFactory 5 | 6 | User = get_user_model() 7 | 8 | 9 | class UserFactory(DjangoModelFactory): 10 | class Meta: 11 | model = User 12 | 13 | username = factory.Sequence(lambda n: f"jack{n}") 14 | email = factory.Sequence(lambda n: f"jack{n}@stargate.com") 15 | password = factory.Faker("password") 16 | 17 | 18 | class BoardFactory(DjangoModelFactory): 19 | class Meta: 20 | model = Board 21 | 22 | name = factory.Sequence(lambda n: f"uni{n}") 23 | owner = factory.SubFactory(UserFactory) 24 | 25 | 26 | class ColumnFactory(DjangoModelFactory): 27 | class Meta: 28 | model = Column 29 | 30 | board = factory.SubFactory(BoardFactory) 31 | title = factory.Sequence(lambda n: f"col{n}") 32 | 33 | 34 | class TaskFactory(DjangoModelFactory): 35 | class Meta: 36 | model = Task 37 | 38 | title = factory.Sequence(lambda n: f"task{n}") 39 | description = factory.Sequence(lambda n: f"Some description{n}") 40 | column = factory.SubFactory(ColumnFactory) 41 | 42 | 43 | class CommentFactory(DjangoModelFactory): 44 | class Meta: 45 | model = Comment 46 | 47 | task = factory.SubFactory(TaskFactory) 48 | author = factory.SubFactory(UserFactory) 49 | text = factory.Sequence(lambda n: f"Comment Text{n}") 50 | 51 | 52 | class LabelFactory(DjangoModelFactory): 53 | class Meta: 54 | model = Label 55 | 56 | name = factory.Sequence(lambda n: f"label{n}") 57 | color = factory.Faker("hex_color") 58 | board = factory.SubFactory(BoardFactory) 59 | -------------------------------------------------------------------------------- /frontend/src/features/comment/CommentSection.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, screen } from "@testing-library/react"; 2 | import React from "react"; 3 | import { renderWithProviders } from "utils/testHelpers"; 4 | import CommentSection from "./CommentSection"; 5 | import { createComment, fetchComments } from "./CommentSlice"; 6 | 7 | const taskId = 1; 8 | 9 | it("should render comment section without errors", () => { 10 | renderWithProviders(); 11 | expect(screen.getByText("Discussion")).toBeVisible(); 12 | }); 13 | 14 | it("should dispatch fetchComments for current task", () => { 15 | const { mockStore } = renderWithProviders(); 16 | expect( 17 | mockStore 18 | .getActions() 19 | .filter((t) => t.type === fetchComments.pending.type)[0].meta.arg 20 | ).toEqual(taskId); 21 | }); 22 | 23 | it("should dispatch createComment on button press", () => { 24 | const { mockStore } = renderWithProviders(); 25 | const postBtn = screen.getByRole("button", { name: /post comment/i }); 26 | const textarea = screen.getByRole("textbox", { name: /comment/i }); 27 | 28 | // Should not dispatch an API call with empty text 29 | fireEvent.click(postBtn); 30 | expect( 31 | mockStore.getActions().filter((t) => t.type === createComment.pending.type) 32 | ).toHaveLength(0); 33 | 34 | // Should dispatch createComment with correct args 35 | fireEvent.change(textarea, { target: { value: "some comment" } }); 36 | fireEvent.click(postBtn); 37 | expect( 38 | mockStore 39 | .getActions() 40 | .filter((t) => t.type === createComment.pending.type)[0].meta.arg 41 | ).toEqual({ text: "some comment", task: taskId }); 42 | }); 43 | -------------------------------------------------------------------------------- /frontend/cypress/integration/stubbed/profile.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context("Profile", () => { 4 | beforeEach(() => { 5 | cy.stubbedSetup(); 6 | cy.route("GET", "/api/users/1/", "fixture:testuser.json"); 7 | cy.route("/api/avatars/", "fixture:avatars.json"); 8 | cy.visit("/profile/"); 9 | cy.title().should("eq", "Profile | Knboard"); 10 | }); 11 | 12 | it("should change username", () => { 13 | cy.route("PUT", "/api/users/1/", "fixture:testuser_update.json"); 14 | cy.findByText("About").should("be.visible"); 15 | cy.findByLabelText("Username").clear(); 16 | cy.findByText("This field is required").should("be.visible"); 17 | cy.findByLabelText("Username").type("newname"); 18 | cy.findByText("Save").click(); 19 | cy.findByText("User saved").should("be.visible"); 20 | }); 21 | 22 | it("should clear name fiels and update email", () => { 23 | cy.route("PUT", "/api/users/1/", "fixture:testuser_update.json"); 24 | cy.findByLabelText("First name").clear(); 25 | cy.findByLabelText("Last name").clear(); 26 | cy.findByLabelText("Email").clear().type("newmail@gmail.com"); 27 | cy.findByText("Save").click(); 28 | cy.findByText("User saved").should("be.visible"); 29 | }); 30 | 31 | it("should change avatar", () => { 32 | cy.route("POST", "/api/users/1/update_avatar/", "fixture:dog_avatar.json"); 33 | cy.findByTestId("change-avatar").click(); 34 | cy.findByText("Pick an Avatar").should("be.visible"); 35 | cy.findByTestId("avatar-dog").click(); 36 | cy.findByTestId("close-avatar-picker").click({ force: true }); 37 | cy.findByText("Avatar saved").should("be.visible"); 38 | cy.get("img[alt='dog']").should("be.visible"); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /frontend/src/components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "@emotion/styled"; 3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 4 | import { barHeight } from "const"; 5 | import UserMenu from "./UserMenu"; 6 | import { faRocket, faBars } from "@fortawesome/free-solid-svg-icons"; 7 | import { setMobileDrawerOpen } from "features/responsive/ResponsiveSlice"; 8 | import { useDispatch } from "react-redux"; 9 | import { Hidden } from "@material-ui/core"; 10 | 11 | const Container = styled.div` 12 | min-height: ${barHeight}px; 13 | display: flex; 14 | align-items: center; 15 | justify-content: space-between; 16 | padding-left: 0.5rem; 17 | padding-right: 0.5rem; 18 | border-bottom: 1px solid #999; 19 | `; 20 | 21 | const Item = styled.div` 22 | font-size: 1rem; 23 | color: #333; 24 | `; 25 | 26 | const Icons = styled.div` 27 | font-size: 1.25rem; 28 | a { 29 | color: #888; 30 | &:hover { 31 | color: #333; 32 | } 33 | } 34 | .active { 35 | color: #333; 36 | } 37 | `; 38 | 39 | const Navbar = () => { 40 | const dispatch = useDispatch(); 41 | 42 | return ( 43 | 44 | 45 | 46 | 47 | dispatch(setMobileDrawerOpen(true))} 50 | /> 51 | 52 | 53 | 54 | 55 | 56 | 57 | Knboard 58 | 59 | 60 | 61 | 62 | ); 63 | }; 64 | 65 | export default Navbar; 66 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [cra-template-typekit](https://github.com/rrebase/cra-template-typekit) 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `yarn start` 8 | 9 | Runs the app in the development mode. 10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits. 13 | You will also see any lint errors in the console. 14 | 15 | ### `yarn test` 16 | 17 | Launches the test runner in the interactive watch mode. 18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `yarn build` 21 | 22 | Builds the app for production to the `build` folder. 23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes. 26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `test:coverage` 31 | 32 | Run all tests with `--coverage`. Html output will be in `coverage/`. 33 | 34 | ### `yarn lint` 35 | 36 | Lints the code for eslint errors and compiles TypeScript for typing errors. 37 | Update `rules` in `.eslintrc` if you are not satisifed with some of the default errors/warnings. 38 | 39 | ### `yarn lint:fix` 40 | 41 | Same as `yarn lint`, but also automatically fixes problems. 42 | 43 | ## Learn More 44 | 45 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 46 | 47 | To learn React, check out the [React documentation](https://reactjs.org/). 48 | -------------------------------------------------------------------------------- /backend/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import uuid 3 | from pytest_factoryboy import register 4 | 5 | from boards.tests.factories import ( 6 | UserFactory, 7 | BoardFactory, 8 | ColumnFactory, 9 | TaskFactory, 10 | LabelFactory, 11 | CommentFactory, 12 | ) 13 | 14 | register(UserFactory) 15 | register(BoardFactory) 16 | register(ColumnFactory) 17 | register(TaskFactory) 18 | register(LabelFactory) 19 | register(CommentFactory) 20 | 21 | 22 | @pytest.fixture(autouse=True) 23 | def enable_db_access(db): 24 | pass 25 | 26 | 27 | @pytest.fixture 28 | def api_client(): 29 | from rest_framework.test import APIClient 30 | 31 | return APIClient() 32 | 33 | 34 | @pytest.fixture 35 | def test_password(): 36 | return "strong-test-pass" 37 | 38 | 39 | @pytest.fixture 40 | def create_user(django_user_model, test_password): 41 | def make_user(**kwargs): 42 | kwargs["password"] = test_password 43 | if "username" not in kwargs: 44 | kwargs["username"] = str(uuid.uuid4()) 45 | return django_user_model.objects.create_user(**kwargs) 46 | 47 | return make_user 48 | 49 | 50 | @pytest.fixture 51 | def api_client_with_credentials(create_user, api_client): 52 | user = create_user() 53 | api_client.force_authenticate(user=user) 54 | yield api_client 55 | api_client.force_authenticate(user=None) 56 | 57 | 58 | @pytest.fixture 59 | def steve(user_factory): 60 | return user_factory(username="steve") 61 | 62 | 63 | @pytest.fixture 64 | def amy(user_factory): 65 | return user_factory(username="amy") 66 | 67 | 68 | @pytest.fixture 69 | def leo(user_factory): 70 | return user_factory(username="leo") 71 | 72 | 73 | @pytest.fixture 74 | def mike(user_factory): 75 | return user_factory(username="mike") 76 | -------------------------------------------------------------------------------- /frontend/src/features/toast/Toast.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import { Snackbar, Button } from "@material-ui/core"; 4 | import { Alert } from "@material-ui/lab"; 5 | import { RootState } from "store"; 6 | import { clearToast } from "./ToastSlice"; 7 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 8 | import { faTimes } from "@fortawesome/free-solid-svg-icons"; 9 | import { css } from "@emotion/core"; 10 | import { TOAST_AUTO_HIDE_DURATION } from "const"; 11 | 12 | const Toast = () => { 13 | const dispatch = useDispatch(); 14 | 15 | const open = useSelector((state: RootState) => state.toast.open); 16 | const message = useSelector((state: RootState) => state.toast.message); 17 | const severity = useSelector((state: RootState) => state.toast.severity); 18 | 19 | let timer: ReturnType; 20 | 21 | function handleClose() { 22 | dispatch(clearToast()); 23 | } 24 | 25 | useEffect(() => { 26 | if (open) { 27 | timer = setTimeout(() => { 28 | handleClose(); 29 | }, TOAST_AUTO_HIDE_DURATION); 30 | } 31 | return () => clearTimeout(timer); 32 | }, [open]); 33 | 34 | return ( 35 | 36 | 48 | 49 | 50 | } 51 | > 52 | {message} 53 | 54 | 55 | ); 56 | }; 57 | 58 | export default Toast; 59 | -------------------------------------------------------------------------------- /frontend/src/features/member/MemberSlice.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createSlice, 3 | PayloadAction, 4 | createEntityAdapter, 5 | } from "@reduxjs/toolkit"; 6 | import { BoardMember } from "types"; 7 | import { fetchBoardById } from "features/board/BoardSlice"; 8 | import { RootState } from "store"; 9 | 10 | const memberAdapter = createEntityAdapter({ 11 | sortComparer: (a, b) => a.username.localeCompare(b.username), 12 | }); 13 | 14 | interface ExtraInitialState { 15 | dialogMember: number | null; 16 | memberListOpen: boolean; 17 | } 18 | 19 | export const initialState = memberAdapter.getInitialState({ 20 | dialogMember: null, 21 | memberListOpen: false, 22 | }); 23 | 24 | export const slice = createSlice({ 25 | name: "member", 26 | initialState, 27 | reducers: { 28 | addBoardMembers: memberAdapter.addMany, 29 | removeBoardMember: memberAdapter.removeOne, 30 | setDialogMember: (state, action: PayloadAction) => { 31 | state.dialogMember = action.payload; 32 | }, 33 | setMemberListOpen: (state, action: PayloadAction) => { 34 | state.memberListOpen = action.payload; 35 | }, 36 | }, 37 | extraReducers: (builder) => { 38 | builder.addCase(fetchBoardById.fulfilled, (state, action) => { 39 | memberAdapter.setAll(state, action.payload.members); 40 | }); 41 | }, 42 | }); 43 | 44 | export const { 45 | addBoardMembers, 46 | removeBoardMember, 47 | setDialogMember, 48 | setMemberListOpen, 49 | } = slice.actions; 50 | 51 | const memberSelectors = memberAdapter.getSelectors( 52 | (state: RootState) => state.member 53 | ); 54 | 55 | export const { 56 | selectAll: selectAllMembers, 57 | selectEntities: selectMembersEntities, 58 | selectTotal: selectMembersTotal, 59 | } = memberSelectors; 60 | 61 | export default slice.reducer; 62 | -------------------------------------------------------------------------------- /frontend/src/utils/reorder.tsx: -------------------------------------------------------------------------------- 1 | import { TasksByColumn, Id } from "types"; 2 | import { DraggableLocation } from "react-beautiful-dnd"; 3 | 4 | // a little function to help us with reordering the result 5 | const reorder = (list: any[], startIndex: number, endIndex: number): any[] => { 6 | const result = Array.from(list); 7 | const [removed] = result.splice(startIndex, 1); 8 | result.splice(endIndex, 0, removed); 9 | 10 | return result; 11 | }; 12 | 13 | export default reorder; 14 | 15 | interface ReorderTasksArgs { 16 | tasksByColumn: TasksByColumn; 17 | source: DraggableLocation; 18 | destination: DraggableLocation; 19 | } 20 | 21 | export interface ReorderTasksResult { 22 | tasksByColumn: TasksByColumn; 23 | } 24 | 25 | export const reorderTasks = ({ 26 | tasksByColumn, 27 | source, 28 | destination, 29 | }: ReorderTasksArgs): ReorderTasksResult => { 30 | const current: Id[] = [...tasksByColumn[source.droppableId]]; 31 | const next: Id[] = [...tasksByColumn[destination.droppableId]]; 32 | const target: Id = current[source.index]; 33 | 34 | // moving to same list 35 | if (source.droppableId === destination.droppableId) { 36 | const reordered: Id[] = reorder(current, source.index, destination.index); 37 | const result: TasksByColumn = { 38 | ...tasksByColumn, 39 | [source.droppableId]: reordered, 40 | }; 41 | return { 42 | tasksByColumn: result, 43 | }; 44 | } 45 | 46 | // moving to different list 47 | 48 | // remove from original 49 | current.splice(source.index, 1); 50 | // insert into next 51 | next.splice(destination.index, 0, target); 52 | 53 | const result: TasksByColumn = { 54 | ...tasksByColumn, 55 | [source.droppableId]: current, 56 | [destination.droppableId]: next, 57 | }; 58 | 59 | return { 60 | tasksByColumn: result, 61 | }; 62 | }; 63 | -------------------------------------------------------------------------------- /frontend/src/AuthenticatedApp.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Switch, Route, RouteProps } from "react-router-dom"; 3 | import styled from "@emotion/styled"; 4 | 5 | import Board from "features/board"; 6 | import BoardList from "features/board/BoardList"; 7 | import Navbar from "components/Navbar"; 8 | import Home from "features/home/Home"; 9 | import BoardBar from "features/board/BoardBar"; 10 | import Profile from "features/profile/Profile"; 11 | import Sidebar from "features/sidebar/Sidebar"; 12 | import PageError from "components/PageError"; 13 | import { sidebarWidth } from "const"; 14 | import { useTheme, WithTheme } from "@material-ui/core"; 15 | 16 | const Main = styled.div` 17 | ${(props) => props.theme.breakpoints.up("sm")} { 18 | margin-left: ${sidebarWidth + 8}px; 19 | } 20 | `; 21 | 22 | const Wrapper: React.FC = ({ children }) => { 23 | const theme = useTheme(); 24 | 25 | return ( 26 | <> 27 | 28 | 29 | 30 | {children} 31 | 32 | > 33 | ); 34 | }; 35 | 36 | const AppRoute = (props: RouteProps) => ( 37 | 38 | {props.children} 39 | 40 | ); 41 | 42 | const AuthenticatedApp = () => { 43 | return ( 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | Page not found. 61 | 62 | 63 | ); 64 | }; 65 | 66 | export default AuthenticatedApp; 67 | -------------------------------------------------------------------------------- /frontend/src/features/member/MemberListDialog.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/camelcase */ 2 | import React from "react"; 3 | import { screen, fireEvent } from "@testing-library/react"; 4 | import { rootInitialState, renderWithProviders } from "utils/testHelpers"; 5 | import MemberListDialog from "./MemberList"; 6 | 7 | const member1 = { 8 | id: 1, 9 | username: "testuser", 10 | email: "t@t.com", 11 | first_name: "Ragnar", 12 | last_name: "Rebase", 13 | avatar: null, 14 | }; 15 | 16 | const member2 = { 17 | id: 2, 18 | username: "steveapple1", 19 | email: "steve@gmail.com", 20 | first_name: "Steve", 21 | last_name: "Apple", 22 | avatar: null, 23 | }; 24 | 25 | it("should display and search members", async () => { 26 | renderWithProviders(, { 27 | ...rootInitialState, 28 | member: { 29 | ...rootInitialState.member, 30 | memberListOpen: true, 31 | ids: [member1.id, member2.id], 32 | entities: { [member1.id]: member1, [member2.id]: member2 }, 33 | }, 34 | }); 35 | expect(screen.getByText("2 members")).toBeVisible(); 36 | expect(screen.getByText(member1.username)).toBeVisible(); 37 | expect(screen.getByText(member2.username)).toBeVisible(); 38 | 39 | fireEvent.change(screen.getByPlaceholderText("Search members"), { 40 | target: { value: member1.username }, 41 | }); 42 | 43 | expect(screen.getByText("1 member")).toBeVisible(); 44 | expect(screen.getByText(member1.username)).toBeVisible(); 45 | expect(screen.queryByText(member2.username)).toBeNull(); 46 | 47 | fireEvent.change(screen.getByPlaceholderText("Search members"), { 48 | target: { value: "match nothing" }, 49 | }); 50 | 51 | expect(screen.getByText("0 members")).toBeVisible(); 52 | expect(screen.queryByText(member1.username)).toBeNull(); 53 | expect(screen.queryByText(member2.username)).toBeNull(); 54 | }); 55 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "Code scanning - action" 2 | 3 | on: 4 | push: 5 | branches: [master, ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [master] 9 | schedule: 10 | - cron: '0 5 * * 6' 11 | 12 | jobs: 13 | CodeQL-Build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v2 20 | with: 21 | # We must fetch at least the immediate parents so that if this is 22 | # a pull request then we can checkout the head. 23 | fetch-depth: 2 24 | 25 | # If this run was triggered by a pull request event, then checkout 26 | # the head of the pull request instead of the merge commit. 27 | - run: git checkout HEAD^2 28 | if: ${{ github.event_name == 'pull_request' }} 29 | 30 | # Initializes the CodeQL tools for scanning. 31 | - name: Initialize CodeQL 32 | uses: github/codeql-action/init@v1 33 | # Override language selection by uncommenting this and choosing your languages 34 | # with: 35 | # languages: go, javascript, csharp, python, cpp, java 36 | 37 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 38 | # If this step fails, then you should remove it and run the build manually (see below) 39 | - name: Autobuild 40 | uses: github/codeql-action/autobuild@v1 41 | 42 | # ℹ️ Command-line programs to run using the OS shell. 43 | # 📚 https://git.io/JvXDl 44 | 45 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 46 | # and modify them (or add more) to build your code if your project 47 | # uses a compiled language 48 | 49 | #- run: | 50 | # make bootstrap 51 | # make release 52 | 53 | - name: Perform CodeQL Analysis 54 | uses: github/codeql-action/analyze@v1 55 | -------------------------------------------------------------------------------- /backend/config/settings/production.py: -------------------------------------------------------------------------------- 1 | import os 2 | from .base import * 3 | from ..env_utils import get_env 4 | 5 | DEBUG = False 6 | SECRET_KEY = get_env("DJANGO_SECRET_KEY") 7 | STATIC_URL = get_env("DJANGO_STATIC_URL") 8 | STATIC_ROOT = get_env("DJANGO_STATIC_ROOT") 9 | ALLOWED_HOSTS = get_env("DJANGO_ALLOWED_HOSTS").split(",") 10 | 11 | # Nginx is used instead of SecurityMiddleware 12 | # for setting all the recommended security headers 13 | SILENCED_SYSTEM_CHECKS = [ 14 | "security.W001", 15 | ] 16 | SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") 17 | 18 | DATABASES = { 19 | "default": { 20 | "ENGINE": "django.db.backends.postgresql", 21 | "PORT": get_env("POSTGRES_PORT"), 22 | "HOST": get_env("POSTGRES_HOST"), 23 | "NAME": get_env("POSTGRES_DB"), 24 | "USER": get_env("POSTGRES_USER"), 25 | "PASSWORD": get_env("POSTGRES_PASSWORD"), 26 | "ATOMIC_REQUESTS": True, 27 | } 28 | } 29 | 30 | # TODO: Add proper handlers 31 | LOGGING = { 32 | "version": 1, 33 | "disable_existing_loggers": False, 34 | "filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}}, 35 | "formatters": { 36 | "verbose": { 37 | "format": "%(levelname)s %(asctime)s %(module)s " 38 | "%(process)d %(thread)d %(message)s" 39 | } 40 | }, 41 | "handlers": { 42 | "console": { 43 | "level": "DEBUG", 44 | "class": "logging.StreamHandler", 45 | "formatter": "verbose", 46 | }, 47 | }, 48 | "root": {"level": "INFO", "handlers": ["console"]}, 49 | "loggers": { 50 | "django.request": {"handlers": [], "level": "ERROR", "propagate": True,}, 51 | "django.security.DisallowedHost": { 52 | "level": "ERROR", 53 | "handlers": ["console"], 54 | "propagate": True, 55 | }, 56 | }, 57 | } 58 | -------------------------------------------------------------------------------- /frontend/src/store.ts: -------------------------------------------------------------------------------- 1 | import { loadState, saveState } from "./utils/localStorage"; 2 | import { Action, configureStore, combineReducers } from "@reduxjs/toolkit"; 3 | import { ThunkAction } from "redux-thunk"; 4 | 5 | import authReducer from "./features/auth/AuthSlice"; 6 | import profileReducer from "./features/profile/ProfileSlice"; 7 | import toastReducer from "./features/toast/ToastSlice"; 8 | import boardReducer from "./features/board/BoardSlice"; 9 | import columnReducer from "./features/column/ColumnSlice"; 10 | import taskReducer from "./features/task/TaskSlice"; 11 | import commentReducer from "./features/comment/CommentSlice"; 12 | import labelReducer from "./features/label/LabelSlice"; 13 | import memberReducer from "./features/member/MemberSlice"; 14 | import responsiveReducer from "./features/responsive/ResponsiveSlice"; 15 | 16 | import authInitialState from "./features/auth/AuthSlice"; 17 | import { setupInterceptors } from "api"; 18 | 19 | export const rootReducer = combineReducers({ 20 | auth: authReducer, 21 | profile: profileReducer, 22 | toast: toastReducer, 23 | board: boardReducer, 24 | column: columnReducer, 25 | task: taskReducer, 26 | comment: commentReducer, 27 | member: memberReducer, 28 | label: labelReducer, 29 | responsive: responsiveReducer, 30 | }); 31 | 32 | const store = configureStore({ 33 | devTools: process.env.NODE_ENV !== "production", 34 | preloadedState: loadState() || {}, 35 | reducer: rootReducer, 36 | }); 37 | 38 | store.subscribe(() => { 39 | const state = store.getState(); 40 | saveState({ 41 | auth: { 42 | ...authInitialState, 43 | user: state.auth.user, 44 | }, 45 | }); 46 | }); 47 | 48 | setupInterceptors(store); 49 | 50 | export type RootState = ReturnType; 51 | export type AppThunk = ThunkAction>; 52 | export type AppDispatch = typeof store.dispatch; 53 | 54 | export default store; 55 | -------------------------------------------------------------------------------- /frontend/src/features/column/Column.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import ColumnTitle from "components/ColumnTitle"; 3 | import { grid } from "const"; 4 | import TaskList from "features/task/TaskList"; 5 | import React from "react"; 6 | import { 7 | Draggable, 8 | DraggableProvided, 9 | DraggableStateSnapshot, 10 | } from "react-beautiful-dnd"; 11 | import { ITask } from "types"; 12 | import { COLUMN_COLOR } from "utils/colors"; 13 | 14 | const Container = styled.div` 15 | margin: ${grid / 2}px; 16 | display: flex; 17 | flex-direction: column; 18 | border-top: 3px solid #cfd3dc; 19 | `; 20 | 21 | const Header = styled.div<{ isDragging: boolean }>` 22 | display: flex; 23 | align-items: center; 24 | justify-content: center; 25 | background-color: ${COLUMN_COLOR}; 26 | transition: background-color 0.2s ease; 27 | [data-rbd-drag-handle-context-id="0"] { 28 | cursor: initial; 29 | } 30 | `; 31 | 32 | type Props = { 33 | id: number; 34 | title: string; 35 | tasks: ITask[]; 36 | index: number; 37 | }; 38 | 39 | const Column = ({ id, title, tasks, index }: Props) => { 40 | return ( 41 | 42 | {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => ( 43 | 48 | 49 | 57 | 58 | 59 | 60 | )} 61 | 62 | ); 63 | }; 64 | 65 | export default Column; 66 | -------------------------------------------------------------------------------- /ansible/roles/security/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Install Prerequisites 4 | apt: name=aptitude update_cache=yes state=latest force_apt_get=yes 5 | 6 | - name: Make sure we have a 'wheel' group 7 | group: 8 | name: wheel 9 | state: present 10 | 11 | - name: Allow 'wheel' group to have passwordless sudo 12 | lineinfile: 13 | path: /etc/sudoers 14 | state: present 15 | regexp: '^%wheel' 16 | line: '%wheel ALL=(ALL) NOPASSWD: ALL' 17 | validate: '/usr/sbin/visudo -cf %s' 18 | 19 | - name: Create a new regular user with sudo privileges 20 | user: 21 | name: "{{ create_user }}" 22 | state: present 23 | groups: wheel 24 | append: true 25 | create_home: true 26 | shell: /bin/bash 27 | 28 | - name: Set authorized key for remote user 29 | authorized_key: 30 | user: "{{ create_user }}" 31 | state: present 32 | key: "{{ copy_local_key }}" 33 | 34 | - name: Disallow password authentication 35 | lineinfile: 36 | path: /etc/ssh/sshd_config 37 | state: present 38 | regexp: "^#?PasswordAuthentication" 39 | line: "PasswordAuthentication no" 40 | register: disallow_pw 41 | 42 | - name: Disallow root SSH access 43 | lineinfile: 44 | path: /etc/ssh/sshd_config 45 | regexp: "^#?PermitRootLogin" 46 | line: "PermitRootLogin no" 47 | register: disallow_root_ssh 48 | 49 | - name: Restart sshd 50 | service: 51 | name: sshd 52 | state: restarted 53 | when: disallow_pw.changed or disallow_root_ssh.changed 54 | 55 | - name: Update apt package cache 56 | apt: update_cache=yes cache_valid_time=3600 57 | 58 | - name: Upgrade apt to the latest packages 59 | apt: upgrade=safe 60 | 61 | - name: Install required system packages 62 | apt: name={{ sys_packages }} state=latest 63 | 64 | - name: UFW - Allow SSH connections 65 | ufw: 66 | rule: allow 67 | name: OpenSSH 68 | 69 | - name: UFW - Deny all other incoming traffic by default 70 | ufw: 71 | state: enabled 72 | policy: deny 73 | direction: incoming 74 | -------------------------------------------------------------------------------- /backend/fixtures/tasks.yaml: -------------------------------------------------------------------------------- 1 | - model: boards.Board 2 | pk: 1 3 | fields: 4 | name: Internals 5 | owner: 1 6 | members: [1] 7 | 8 | - model: boards.Board 9 | pk: 2 10 | fields: 11 | name: Operating Systems 12 | owner: 1 13 | members: [1] 14 | 15 | - model: boards.Board 16 | pk: 3 17 | fields: 18 | name: Fundamentals of Computation 19 | owner: 1 20 | members: [1] 21 | 22 | - model: boards.Board 23 | pk: 4 24 | fields: 25 | name: Data Science 26 | owner: 1 27 | members: [1] 28 | 29 | - model: boards.Column 30 | pk: 1 31 | fields: 32 | title: Backlog 33 | board: 1 34 | column_order: 1 35 | 36 | - model: boards.Column 37 | pk: 2 38 | fields: 39 | title: Todo 40 | board: 1 41 | column_order: 2 42 | 43 | - model: boards.Column 44 | pk: 3 45 | fields: 46 | title: In progress 47 | board: 1 48 | column_order: 3 49 | 50 | - model: boards.Column 51 | pk: 4 52 | fields: 53 | title: Done 54 | board: 1 55 | column_order: 4 56 | 57 | - model: boards.Task 58 | pk: 1 59 | fields: 60 | title: Implement Landing page 61 | description: Implement the landing page. Use figma designs provided by Steve. 62 | column: 1 63 | task_order: 1 64 | 65 | - model: boards.Task 66 | pk: 2 67 | fields: 68 | title: Profile page detail view 69 | description: Implement the profile page detail view. 70 | column: 1 71 | task_order: 2 72 | 73 | - model: boards.Task 74 | pk: 3 75 | fields: 76 | title: User friends 77 | description: Implement the user friends component. Building the API is not part of this task. 78 | column: 2 79 | task_order: 1 80 | 81 | - model: boards.Task 82 | pk: 4 83 | fields: 84 | title: User settings 85 | description: Implement user settings. 86 | column: 3 87 | task_order: 1 88 | 89 | - model: boards.Task 90 | pk: 5 91 | fields: 92 | title: Cookie Consent 93 | description: Add cookie consent functionality. 94 | column: 3 95 | task_order: 1 96 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, Suspense } from "react"; 2 | import { Provider, useSelector } from "react-redux"; 3 | import { ThemeProvider, CssBaseline } from "@material-ui/core"; 4 | import { BrowserRouter as Router } from "react-router-dom"; 5 | import { Global, css } from "@emotion/core"; 6 | 7 | import FullPageSpinner from "components/FullPageSpinner"; 8 | import Toast from "features/toast/Toast"; 9 | import { theme, modalPopperAutocompleteModalIndex } from "./const"; 10 | import store, { RootState } from "./store"; 11 | import { FOCUS_BOX_SHADOW } from "utils/colors"; 12 | 13 | const loadAuthenticatedApp = () => import("./AuthenticatedApp"); 14 | const AuthenticatedApp = React.lazy(loadAuthenticatedApp); 15 | const UnauthenticatedApp = React.lazy(() => import("./features/auth/Auth")); 16 | 17 | const AuthWrapper = () => { 18 | const user = useSelector((state: RootState) => state.auth.user); 19 | 20 | useEffect(() => { 21 | // Preload the AuthenticatedApp 22 | // while the user is logging in 23 | loadAuthenticatedApp(); 24 | }, []); 25 | 26 | return ( 27 | }> 28 | {user ? : } 29 | 30 | ); 31 | }; 32 | 33 | const App = () => { 34 | return ( 35 | 36 | 37 | 38 | 39 | 40 | 41 | 54 | 55 | 56 | 57 | ); 58 | }; 59 | 60 | export default App; 61 | -------------------------------------------------------------------------------- /frontend/src/features/auth/EnterAsGuest.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Fade } from "@material-ui/core"; 2 | import { css } from "@emotion/core"; 3 | import { guestRegister } from "./AuthSlice"; 4 | import React, { useState, useEffect } from "react"; 5 | import styled from "@emotion/styled"; 6 | import { useDispatch } from "react-redux"; 7 | import { useHistory } from "react-router-dom"; 8 | import api, { API_AUTH_SETUP } from "api"; 9 | import { AuthSetup } from "types"; 10 | import { AxiosResponse } from "axios"; 11 | 12 | const Separator = styled.div` 13 | margin-top: 1rem; 14 | `; 15 | 16 | const Container = styled.div` 17 | display: flex; 18 | flex-direction: column; 19 | align-items: center; 20 | `; 21 | 22 | const EnterAsGuest = () => { 23 | const dispatch = useDispatch(); 24 | const history = useHistory(); 25 | const [allowGuest, setAllowGuest] = useState(false); 26 | 27 | useEffect(() => { 28 | const source = api.CancelToken.source(); 29 | 30 | const fetchData = async () => { 31 | try { 32 | const response: AxiosResponse = await api( 33 | `${API_AUTH_SETUP}`, 34 | { 35 | cancelToken: source.token, 36 | } 37 | ); 38 | setAllowGuest(response.data.ALLOW_GUEST_ACCESS); 39 | } catch (err) { 40 | if (!api.isCancel(err)) { 41 | console.error(err); 42 | } 43 | } 44 | }; 45 | fetchData(); 46 | 47 | return () => source.cancel(); 48 | }, []); 49 | 50 | const handleClick = () => { 51 | dispatch(guestRegister()); 52 | history.push("/"); 53 | }; 54 | 55 | if (!allowGuest) { 56 | return null; 57 | } 58 | 59 | return ( 60 | 61 | 62 | or 63 | 69 | Enter as a guest 70 | 71 | 72 | 73 | ); 74 | }; 75 | 76 | export default EnterAsGuest; 77 | -------------------------------------------------------------------------------- /frontend/src/utils/testHelpers.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import React from "react"; 3 | import { Provider } from "react-redux"; 4 | import configureStore from "redux-mock-store"; 5 | import { MemoryRouter } from "react-router-dom"; 6 | import thunk from "redux-thunk"; 7 | import axios from "axios"; 8 | import MockAdapter from "axios-mock-adapter"; 9 | 10 | import { initialState as authInitialState } from "features/auth/AuthSlice"; 11 | import { initialState as profileInitialState } from "features/profile/ProfileSlice"; 12 | import { initialState as toastInitialState } from "features/toast/ToastSlice"; 13 | import { initialState as boardInitialState } from "features/board/BoardSlice"; 14 | import { initialState as columnInitialState } from "features/column/ColumnSlice"; 15 | import { initialState as taskInitialState } from "features/task/TaskSlice"; 16 | import { initialState as commentInitialState } from "features/comment/CommentSlice"; 17 | import { initialState as memberInitialState } from "features/member/MemberSlice"; 18 | import { initialState as labelInitialState } from "features/label/LabelSlice"; 19 | import { initialState as responsiveInitialState } from "features/responsive/ResponsiveSlice"; 20 | import { RootState } from "store"; 21 | 22 | export const rootInitialState = { 23 | auth: authInitialState, 24 | profile: profileInitialState, 25 | toast: toastInitialState, 26 | board: boardInitialState, 27 | column: columnInitialState, 28 | task: taskInitialState, 29 | comment: commentInitialState, 30 | member: memberInitialState, 31 | label: labelInitialState, 32 | responsive: responsiveInitialState, 33 | }; 34 | 35 | export const axiosMock = new MockAdapter(axios); 36 | 37 | export const renderWithProviders = ( 38 | ui: React.ReactNode, 39 | initialState: RootState = rootInitialState 40 | ) => { 41 | const store = configureStore([thunk])(initialState); 42 | return { 43 | ...render( 44 | 45 | {ui} 46 | 47 | ), 48 | mockStore: store, 49 | getActionsTypes: () => store.getActions().map((a) => a.type), 50 | }; 51 | }; 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Jetbrains 2 | .idea/ 3 | 4 | # Database 5 | db.sqlite3 6 | 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # Unit test / coverage reports 13 | htmlcov/ 14 | reports/ 15 | test-results/ 16 | *coverage/ 17 | .tox/ 18 | .coverage 19 | .coverage.* 20 | .cache 21 | nosetests.xml 22 | coverage.xml 23 | *.cover 24 | .hypothesis/ 25 | .nyc_output 26 | coverage 27 | lib-cov 28 | junit.xml 29 | 30 | screenshots/ 31 | 32 | # Translations 33 | *.mo 34 | *.pot 35 | 36 | # Django stuff: 37 | staticfiles/ 38 | static/cache/ 39 | .pytest_cache 40 | 41 | # Sphinx documentation 42 | docs/_build/ 43 | 44 | # PyBuilder 45 | target/ 46 | 47 | # pyenv 48 | .python-version 49 | 50 | # Environments 51 | .venv 52 | venv/ 53 | env/ 54 | 55 | # Rope project settings 56 | .ropeproject 57 | 58 | # mypy 59 | .mypy_cache/ 60 | 61 | 62 | ### Node template 63 | # Logs 64 | logs 65 | *.log 66 | npm-debug.log* 67 | yarn-debug.log* 68 | yarn-error.log* 69 | 70 | # Runtime data 71 | pids 72 | *.pid 73 | *.seed 74 | *.pid.lock 75 | 76 | # Dependency directories 77 | node_modules/ 78 | 79 | # Optional npm cache directory 80 | .npm 81 | 82 | # Optional eslint cache 83 | .eslintcache 84 | 85 | # Optional REPL history 86 | .node_repl_history 87 | 88 | # Yarn Integrity file 89 | .yarn-integrity 90 | 91 | ### VisualStudioCode template 92 | .vscode/* 93 | !.vscode/settings.json 94 | !.vscode/tasks.json 95 | !.vscode/launch.json 96 | !.vscode/extensions.json 97 | 98 | ### macOS template 99 | # General 100 | *.DS_Store 101 | .AppleDouble 102 | .LSOverride 103 | 104 | ### Vim template 105 | # Swap 106 | [._]*.s[a-v][a-z] 107 | [._]*.sw[a-p] 108 | [._]s[a-v][a-z] 109 | [._]sw[a-p] 110 | 111 | # Session 112 | Session.vim 113 | 114 | # Experimental files 115 | Experiment*.tsx 116 | 117 | # Enviroment variables 118 | .env 119 | 120 | # Ignore the docker-compose override 121 | docker-compose.override.yml 122 | 123 | # Generic data directories 124 | .data/ 125 | data/ 126 | 127 | # Managed by ansible 128 | init-letsencrypt.sh 129 | wait-for-it.sh 130 | backend/.vscode/settings.json 131 | -------------------------------------------------------------------------------- /frontend/cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/camelcase */ 2 | import "@testing-library/cypress/add-commands"; 3 | 4 | Cypress.Commands.add("e2eLogin", () => { 5 | cy.request({ 6 | method: "POST", 7 | url: "http://localhost:3000/auth/login/", 8 | body: { 9 | username: "t@t.com", 10 | password: "test", 11 | }, 12 | }).then((response) => { 13 | localStorage.setItem( 14 | "knboard-data", 15 | JSON.stringify({ 16 | auth: { 17 | user: response.body, 18 | }, 19 | }) 20 | ); 21 | }); 22 | }); 23 | 24 | Cypress.Commands.add("stubbedSetup", () => { 25 | cy.server({ force404: true }); 26 | localStorage.setItem( 27 | "knboard-data", 28 | JSON.stringify({ 29 | auth: { 30 | user: { 31 | id: 1, 32 | username: "testuser", 33 | photo_url: null, 34 | }, 35 | }, 36 | }) 37 | ); 38 | }); 39 | 40 | const dragHandleDraggableId = "data-rbd-drag-handle-draggable-id"; 41 | const draggableId = "data-rbd-draggable-id"; 42 | const droppableId = "data-rbd-droppable-id"; 43 | const testId = "data-testid"; 44 | 45 | Cypress.Commands.add("draggable", (id) => { 46 | return cy.get(`[${dragHandleDraggableId}='${id}']`); 47 | }); 48 | 49 | Cypress.Commands.add("droppable", (id) => { 50 | return cy.get(`[${droppableId}='${id}']`); 51 | }); 52 | 53 | Cypress.Commands.add("expectColumns", (columns) => { 54 | cy.droppable("board") 55 | .children() 56 | .each(($el, index) => { 57 | expect($el[0].attributes[draggableId].value).to.eq(columns[index]); 58 | }); 59 | }); 60 | 61 | Cypress.Commands.add("expectTasks", (column, tasks) => { 62 | cy.droppable(column.replace("col-", "")).within(() => { 63 | cy.findByTestId("drop-zone") 64 | .children() 65 | .each(($el, index) => { 66 | expect($el[0].attributes[draggableId].value).to.eq(tasks[index]); 67 | }); 68 | }); 69 | }); 70 | 71 | Cypress.Commands.add("expectMembers", (members) => { 72 | cy.findByTestId("member-group") 73 | .children() 74 | .each(($el, index) => { 75 | expect($el[0].attributes[testId].value).to.eq(`member-${members[index]}`); 76 | }); 77 | }); 78 | 79 | Cypress.Commands.add("closeDialog", () => { 80 | cy.get(".MuiDialog-container").click("left"); 81 | }); 82 | -------------------------------------------------------------------------------- /backend/boards/migrations/0012_comment.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1 on 2020-10-06 14:36 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import django.utils.timezone 7 | import model_utils.fields 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ("boards", "0011_auto_20200517_1011"), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="Comment", 20 | fields=[ 21 | ( 22 | "id", 23 | models.AutoField( 24 | auto_created=True, 25 | primary_key=True, 26 | serialize=False, 27 | verbose_name="ID", 28 | ), 29 | ), 30 | ( 31 | "created", 32 | model_utils.fields.AutoCreatedField( 33 | default=django.utils.timezone.now, 34 | editable=False, 35 | verbose_name="created", 36 | ), 37 | ), 38 | ( 39 | "modified", 40 | model_utils.fields.AutoLastModifiedField( 41 | default=django.utils.timezone.now, 42 | editable=False, 43 | verbose_name="modified", 44 | ), 45 | ), 46 | ("text", models.TextField()), 47 | ( 48 | "author", 49 | models.ForeignKey( 50 | on_delete=django.db.models.deletion.PROTECT, 51 | related_name="comments", 52 | to=settings.AUTH_USER_MODEL, 53 | ), 54 | ), 55 | ( 56 | "task", 57 | models.ForeignKey( 58 | on_delete=django.db.models.deletion.CASCADE, 59 | related_name="comments", 60 | to="boards.task", 61 | ), 62 | ), 63 | ], 64 | options={"ordering": ["created"],}, 65 | ), 66 | ] 67 | -------------------------------------------------------------------------------- /frontend/src/types.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from "@material-ui/core"; 2 | 3 | export type Id = number; 4 | 5 | export interface BoardMember { 6 | id: number; 7 | username: string; 8 | email: string; 9 | first_name: string; 10 | last_name: string; 11 | avatar: Avatar | null; 12 | } 13 | 14 | export interface Label { 15 | id: number; 16 | name: string; 17 | color: string; 18 | board: Id; 19 | } 20 | 21 | export interface NanoBoard { 22 | id: number; 23 | name: string; 24 | owner: Id; 25 | } 26 | 27 | export interface Board { 28 | id: number; 29 | name: string; 30 | owner: Id; 31 | members: BoardMember[]; 32 | } 33 | 34 | export interface IColumn { 35 | id: number; 36 | title: string; 37 | board: Id; 38 | } 39 | 40 | export type PriorityValue = "H" | "M" | "L"; 41 | 42 | export interface Priority { 43 | value: PriorityValue; 44 | label: "High" | "Medium" | "Low"; 45 | } 46 | 47 | export interface ITask { 48 | id: Id; 49 | created: string; 50 | modified: string; 51 | title: string; 52 | description: string; 53 | labels: Id[]; 54 | assignees: Id[]; 55 | priority: PriorityValue; 56 | } 57 | 58 | export interface NewTask extends Omit { 59 | column: Id; 60 | } 61 | 62 | export interface TasksByColumn { 63 | [key: string]: Id[]; 64 | } 65 | 66 | export interface User { 67 | id: number; 68 | username: string; 69 | photo_url: string | null; 70 | } 71 | 72 | export interface UserDetail { 73 | id: number; 74 | username: string; 75 | first_name?: string; 76 | last_name?: string; 77 | email: string; 78 | avatar: Avatar | null; 79 | date_joined: string; 80 | is_guest: boolean; 81 | } 82 | 83 | export interface TaskComment { 84 | id: number; 85 | task: number; 86 | author: number; 87 | text: string; 88 | created: string; 89 | modified: string; 90 | } 91 | 92 | export type NewTaskComment = Omit< 93 | TaskComment, 94 | "id" | "author" | "created" | "modified" 95 | >; 96 | 97 | export interface Avatar { 98 | id: number; 99 | photo: string; 100 | name: string; 101 | } 102 | 103 | export interface WithTheme { 104 | theme: Theme; 105 | } 106 | 107 | export interface AuthSetup { 108 | ALLOW_GUEST_ACCESS: boolean; 109 | } 110 | 111 | export type Status = "idle" | "loading" | "succeeded" | "failed"; 112 | -------------------------------------------------------------------------------- /frontend/src/features/toast/Toast.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { fireEvent, screen } from "@testing-library/react"; 3 | import Toast from "./Toast"; 4 | import toastReducer, { 5 | createSuccessToast, 6 | createInfoToast, 7 | createErrorToast, 8 | clearToast, 9 | } from "./ToastSlice"; 10 | import { renderWithProviders, rootInitialState } from "utils/testHelpers"; 11 | import { TOAST_AUTO_HIDE_DURATION } from "const"; 12 | 13 | it("should show toast and auto hide", () => { 14 | jest.useFakeTimers(); 15 | const { mockStore } = renderWithProviders(, { 16 | ...rootInitialState, 17 | toast: { 18 | ...rootInitialState.toast, 19 | open: true, 20 | message: "Ready!", 21 | severity: "success", 22 | }, 23 | }); 24 | expect(screen.getByText("Ready!")).toBeVisible(); 25 | fireEvent.click(screen.getByTestId("toast-close")); 26 | expect(mockStore.getActions()).toMatchSnapshot(); 27 | 28 | mockStore.clearActions(); 29 | jest.advanceTimersByTime(TOAST_AUTO_HIDE_DURATION); 30 | expect(mockStore.getActions()).toMatchSnapshot(); 31 | }); 32 | 33 | it("should create success toast", () => { 34 | expect( 35 | toastReducer(rootInitialState.toast, { 36 | type: createSuccessToast.type, 37 | payload: "Ready!", 38 | }) 39 | ).toEqual({ 40 | ...rootInitialState.toast, 41 | open: true, 42 | message: "Ready!", 43 | severity: "success", 44 | }); 45 | }); 46 | 47 | it("should create info toast", () => { 48 | expect( 49 | toastReducer(rootInitialState.toast, { 50 | type: createInfoToast.type, 51 | payload: "Update available!", 52 | }) 53 | ).toEqual({ 54 | ...rootInitialState.toast, 55 | open: true, 56 | message: "Update available!", 57 | severity: "info", 58 | }); 59 | }); 60 | 61 | it("should create error toast", () => { 62 | expect( 63 | toastReducer(rootInitialState.toast, { 64 | type: createErrorToast.type, 65 | payload: "Failed!", 66 | }) 67 | ).toEqual({ 68 | ...rootInitialState.toast, 69 | open: true, 70 | message: "Failed!", 71 | severity: "error", 72 | }); 73 | }); 74 | 75 | it("should clear toast", () => { 76 | expect( 77 | toastReducer( 78 | { ...rootInitialState.toast, open: true }, 79 | { 80 | type: clearToast.type, 81 | payload: undefined, 82 | } 83 | ) 84 | ).toEqual({ 85 | ...rootInitialState.toast, 86 | open: false, 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /backend/config/urls.py: -------------------------------------------------------------------------------- 1 | """knboard URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.conf import settings 17 | from django.conf.urls.static import static 18 | from django.contrib import admin 19 | from django.urls import path, include 20 | from rest_framework import routers 21 | 22 | from accounts.api import ( 23 | UserViewSet, 24 | UserSearchView, 25 | AvatarViewSet, 26 | GuestRegistration, 27 | AuthSetup, 28 | ) 29 | from boards.api import ( 30 | BoardViewSet, 31 | ColumnViewSet, 32 | LabelViewSet, 33 | TaskViewSet, 34 | SortColumn, 35 | SortTask, 36 | CommentViewSet, 37 | ) 38 | 39 | router = routers.DefaultRouter() 40 | router.register(r"avatars", AvatarViewSet) 41 | router.register(r"users", UserViewSet) 42 | router.register(r"boards", BoardViewSet) 43 | router.register(r"columns", ColumnViewSet) 44 | router.register(r"labels", LabelViewSet) 45 | router.register(r"tasks", TaskViewSet) 46 | router.register(r"comments", CommentViewSet) 47 | 48 | urlpatterns = [ 49 | path("api/", include(router.urls)), 50 | path("api/u/search/", UserSearchView.as_view(), name="user-search"), 51 | path("api/sort/column/", SortColumn.as_view(), name="sort-column"), 52 | path("api/sort/task/", SortTask.as_view(), name="sort-task"), 53 | path("api-auth/", include("rest_framework.urls")), 54 | path("auth/", include("dj_rest_auth.urls")), 55 | path("auth/registration/", include("dj_rest_auth.registration.urls")), 56 | path("auth/setup/", AuthSetup.as_view(), name="auth-setup"), 57 | path("auth/guest/", GuestRegistration.as_view(), name="guest-registration"), 58 | path("backdoor/", admin.site.urls), 59 | ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 60 | 61 | if settings.DEBUG: 62 | try: 63 | import debug_toolbar 64 | 65 | urlpatterns = [path("__debug__/", include(debug_toolbar.urls))] + urlpatterns 66 | except ModuleNotFoundError: 67 | pass 68 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | 51 | Knboard 52 | 53 | 54 | You need to enable JavaScript to run this app. 55 | 56 | 57 | 58 | 59 | 60 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /frontend/src/features/comment/CommentItem.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { Box } from "@material-ui/core"; 3 | import MemberAvatar from "components/MemberAvatar"; 4 | import { formatDistanceToNow } from "date-fns"; 5 | import { selectMembersEntities } from "features/member/MemberSlice"; 6 | import React from "react"; 7 | import { deleteComment } from "./CommentSlice"; 8 | import { useDispatch, useSelector } from "react-redux"; 9 | import { RootState } from "store"; 10 | import { TaskComment } from "types"; 11 | import { HINT } from "utils/colors"; 12 | 13 | interface Props { 14 | comment: TaskComment; 15 | } 16 | 17 | const CommentActionRow = ({ comment }: Props) => { 18 | const dispatch = useDispatch(); 19 | const user = useSelector((state: RootState) => state.auth.user); 20 | const memberEntities = useSelector(selectMembersEntities); 21 | const author = memberEntities[comment.author]; 22 | 23 | if (!user || !author || user.id !== author.id) { 24 | return null; 25 | } 26 | 27 | const handleDelete = () => { 28 | if (window.confirm("Are you sure? Deleting a comment cannot be undone.")) { 29 | dispatch(deleteComment(comment.id)); 30 | } 31 | }; 32 | 33 | return ( 34 | 35 | 36 | Delete 37 | 38 | 39 | ); 40 | }; 41 | 42 | const CommentItem = ({ comment }: Props) => { 43 | const memberEntities = useSelector(selectMembersEntities); 44 | const author = memberEntities[comment.author]; 45 | 46 | if (!author) { 47 | return null; 48 | } 49 | 50 | return ( 51 | 52 | 53 | 54 | 55 | 56 | 57 | {author.first_name || author.username} 58 | 59 | {formatDistanceToNow(new Date(comment.created), { 60 | addSuffix: true, 61 | })} 62 | 63 | 64 | {comment.text} 65 | {CommentActionRow({ comment })} 66 | 67 | 68 | ); 69 | }; 70 | 71 | const Link = styled.a` 72 | font-size: 0.75rem; 73 | color: ${HINT}; 74 | text-decoration: none; 75 | cursor: pointer; 76 | &:hover { 77 | text-decoration: underline; 78 | } 79 | `; 80 | 81 | const Name = styled.div` 82 | font-size: 0.75rem; 83 | font-weight: bold; 84 | `; 85 | 86 | const Text = styled.p` 87 | font-size: 0.75rem; 88 | margin-top: 4px; 89 | `; 90 | 91 | const TimeAgo = styled.div` 92 | font-size: 0.75rem; 93 | color: ${HINT}; 94 | margin-left: 8px; 95 | `; 96 | 97 | export default CommentItem; 98 | -------------------------------------------------------------------------------- /backend/accounts/serializers.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from dj_rest_auth.models import TokenModel 4 | from django.contrib.auth import get_user_model 5 | from rest_framework import serializers 6 | from rest_framework.validators import UniqueValidator 7 | 8 | from .models import Avatar 9 | 10 | User = get_user_model() 11 | 12 | 13 | class AvatarSerializer(serializers.ModelSerializer): 14 | name = serializers.SerializerMethodField() 15 | 16 | class Meta: 17 | model = Avatar 18 | fields = ["id", "photo", "name"] 19 | 20 | def get_name(self, obj): 21 | return Path(obj.photo.name).stem 22 | 23 | 24 | class UserSerializer(serializers.ModelSerializer): 25 | class Meta: 26 | model = User 27 | fields = ["id", "username", "email"] 28 | 29 | 30 | class UserSearchSerializer(serializers.ModelSerializer): 31 | avatar = AvatarSerializer(read_only=True) 32 | 33 | class Meta: 34 | model = User 35 | fields = ["id", "username", "avatar"] 36 | 37 | 38 | class UserDetailSerializer(serializers.ModelSerializer): 39 | avatar = AvatarSerializer(read_only=True) 40 | email = serializers.EmailField( 41 | validators=[UniqueValidator(queryset=User.objects.all())], required=False 42 | ) 43 | 44 | class Meta: 45 | model = User 46 | fields = [ 47 | "id", 48 | "username", 49 | "first_name", 50 | "last_name", 51 | "email", 52 | "avatar", 53 | "date_joined", 54 | "is_guest", 55 | ] 56 | read_only_fields = [ 57 | "id", 58 | "avatar", 59 | "date_joined", 60 | ] 61 | 62 | 63 | class BoardOwnerSerializer(serializers.ModelSerializer): 64 | class Meta: 65 | model = User 66 | fields = ["id"] 67 | 68 | 69 | class BoardMemberSerializer(serializers.ModelSerializer): 70 | avatar = AvatarSerializer(read_only=True) 71 | 72 | class Meta: 73 | model = User 74 | fields = ["id", "username", "email", "first_name", "last_name", "avatar"] 75 | 76 | 77 | class TokenSerializer(serializers.ModelSerializer): 78 | id = serializers.IntegerField(source="user.id", read_only=True) 79 | username = serializers.CharField(source="user.username", read_only=True) 80 | photo_url = serializers.SerializerMethodField() 81 | 82 | class Meta: 83 | model = TokenModel 84 | # Include field "key" once frontend actually uses token auth 85 | # instead of the current session auth 86 | fields = ("id", "username", "photo_url") 87 | 88 | def get_photo_url(self, obj): 89 | if not obj.user.avatar: 90 | return None 91 | return obj.user.avatar.photo.url 92 | -------------------------------------------------------------------------------- /frontend/src/components/BoardName.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from "react"; 2 | import styled from "@emotion/styled"; 3 | import { Key } from "const"; 4 | import { TextareaAutosize } from "@material-ui/core"; 5 | import { css } from "@emotion/core"; 6 | import { useDispatch } from "react-redux"; 7 | import { Id } from "types"; 8 | import { patchBoard } from "features/board/BoardSlice"; 9 | 10 | const Container = styled.div` 11 | color: #6869f6; 12 | textarea { 13 | color: #6869f6; 14 | font-weight: bold; 15 | } 16 | `; 17 | 18 | interface Props { 19 | id: Id; 20 | name: string; 21 | isOwner: boolean; 22 | } 23 | 24 | const BoardName = ({ id, name, isOwner, ...props }: Props) => { 25 | const dispatch = useDispatch(); 26 | const [pendingName, setPendingName] = useState(name); 27 | const [editing, setEditing] = useState(false); 28 | const nameTextAreaRef = useRef(null); 29 | 30 | useEffect(() => { 31 | if (!editing && name === pendingName) { 32 | nameTextAreaRef?.current?.blur(); 33 | } 34 | }, [pendingName, editing]); 35 | 36 | const handleKeyDown = (e: React.KeyboardEvent) => { 37 | if (e.keyCode === Key.Enter) { 38 | e.preventDefault(); 39 | if (pendingName.length > 0) { 40 | nameTextAreaRef?.current?.blur(); 41 | } 42 | } 43 | if (e.keyCode === Key.Escape) { 44 | e.preventDefault(); 45 | setPendingName(name); 46 | setEditing(false); 47 | // blur via useEffect 48 | } 49 | }; 50 | 51 | const handleSave = () => { 52 | if (editing && pendingName.length > 0) { 53 | setEditing(false); 54 | if (pendingName !== name) { 55 | dispatch(patchBoard({ id, fields: { name: pendingName } })); 56 | } 57 | } 58 | }; 59 | 60 | const handleChange = (e: React.ChangeEvent) => { 61 | setPendingName(e.target.value); 62 | }; 63 | 64 | const handleFocus = (e: React.FocusEvent) => { 65 | e.target.select(); 66 | }; 67 | 68 | return ( 69 | 70 | {editing ? ( 71 | 72 | 82 | 83 | ) : ( 84 | setEditing(isOwner)} 89 | > 90 | {pendingName} 91 | 92 | )} 93 | 94 | ); 95 | }; 96 | 97 | export default BoardName; 98 | -------------------------------------------------------------------------------- /ansible/roles/certbot/templates/letsencrypt.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # based off: https://github.com/wmnnd/nginx-certbot 3 | 4 | if ! [ -x "$(command -v docker-compose)" ]; then 5 | echo 'Error: docker-compose is not installed.' >&2 6 | exit 1 7 | fi 8 | 9 | domains=({{ domain_name }}) 10 | rsa_key_size=4096 11 | data_path="./data/certbot" 12 | email="{{ letsencrypt_email }}" # Adding a valid address is strongly recommended 13 | staging=0 # Set to 1 if you're testing your setup to avoid hitting request limits 14 | 15 | if [ -d "$data_path" ]; then 16 | read -p "Existing data found for $domains. Continue and replace existing certificate? (y/N) " decision 17 | if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then 18 | exit 19 | fi 20 | fi 21 | 22 | 23 | if [ ! -e "$data_path/conf/options-ssl-nginx.conf" ] || [ ! -e "$data_path/conf/ssl-dhparams.pem" ]; then 24 | echo "### Downloading recommended TLS parameters ..." 25 | mkdir -p "$data_path/conf" 26 | curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf > "$data_path/conf/options-ssl-nginx.conf" 27 | curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem > "$data_path/conf/ssl-dhparams.pem" 28 | echo 29 | fi 30 | 31 | echo "### Creating dummy certificate for $domains ..." 32 | path="/etc/letsencrypt/live/$domains" 33 | mkdir -p "$data_path/conf/live/$domains" 34 | docker-compose run --rm --entrypoint "\ 35 | openssl req -x509 -nodes -newkey rsa:1024 -days 1\ 36 | -keyout '$path/privkey.pem' \ 37 | -out '$path/fullchain.pem' \ 38 | -subj '/CN=localhost'" certbot 39 | echo 40 | 41 | 42 | echo "### Starting nginx ..." 43 | docker-compose up --force-recreate -d nginx 44 | echo 45 | 46 | echo "### Deleting dummy certificate for $domains ..." 47 | docker-compose run --rm --entrypoint "\ 48 | rm -Rf /etc/letsencrypt/live/$domains && \ 49 | rm -Rf /etc/letsencrypt/archive/$domains && \ 50 | rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot 51 | echo 52 | 53 | 54 | echo "### Requesting Let's Encrypt certificate for $domains ..." 55 | #Join $domains to -d args 56 | domain_args="" 57 | for domain in "${domains[@]}"; do 58 | domain_args="$domain_args -d $domain" 59 | done 60 | 61 | # Select appropriate email arg 62 | case "$email" in 63 | "") email_arg="--register-unsafely-without-email" ;; 64 | *) email_arg="--email $email" ;; 65 | esac 66 | 67 | # Enable staging mode if needed 68 | if [ $staging != "0" ]; then staging_arg="--staging"; fi 69 | 70 | docker-compose run --rm --entrypoint "\ 71 | certbot certonly --webroot -w /var/www/certbot \ 72 | $staging_arg \ 73 | $email_arg \ 74 | $domain_args \ 75 | --rsa-key-size $rsa_key_size \ 76 | --agree-tos \ 77 | --force-renewal" certbot 78 | echo 79 | 80 | echo "### Reloading nginx ..." 81 | docker-compose exec nginx nginx -s reload 82 | -------------------------------------------------------------------------------- /frontend/src/features/auth/Footer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "@emotion/styled"; 3 | import { Popover, Button, Link } from "@material-ui/core"; 4 | import { css } from "@emotion/core"; 5 | import { Alert, AlertTitle } from "@material-ui/lab"; 6 | 7 | const Container = styled.div` 8 | position: absolute; 9 | padding-bottom: 1rem; 10 | width: 100%; 11 | bottom: 0; 12 | `; 13 | 14 | const List = styled.div` 15 | display: flex; 16 | justify-content: center; 17 | `; 18 | 19 | const Item = styled.div` 20 | padding: 0 0.5rem; 21 | display: flex; 22 | align-items: center; 23 | `; 24 | 25 | const Footer = () => { 26 | const [anchorEl, setAnchorEl] = React.useState( 27 | null 28 | ); 29 | 30 | const handleClick = (event: React.MouseEvent) => { 31 | setAnchorEl(event.currentTarget); 32 | }; 33 | 34 | const handleClose = () => { 35 | setAnchorEl(null); 36 | }; 37 | 38 | const open = Boolean(anchorEl); 39 | const id = open ? "about-popover" : undefined; 40 | 41 | return ( 42 | 43 | 44 | 45 | 58 | About 59 | 60 | 61 | 62 | 76 | 83 | About 84 | 85 | Knboard is an app that helps visualize your work using kanban 86 | boards, maximizing efficiency. 87 | 88 | It is an open-source project built using Django & React, 89 | available on{" "} 90 | 95 | GitHub 96 | 97 | . 98 | 99 | 100 | 101 | ); 102 | }; 103 | 104 | export default Footer; 105 | -------------------------------------------------------------------------------- /frontend/src/features/board/BoardList.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { screen, fireEvent } from "@testing-library/react"; 3 | import BoardList from "./BoardList"; 4 | import { 5 | renderWithProviders, 6 | rootInitialState, 7 | axiosMock, 8 | } from "utils/testHelpers"; 9 | import { fetchAllBoards } from "./BoardSlice"; 10 | import { API_BOARDS } from "api"; 11 | import boardReducer from "features/board/BoardSlice"; 12 | import { Board } from "types"; 13 | 14 | const boards: Board[] = [{ id: 1, name: "Internals", owner: 1, members: [] }]; 15 | 16 | it("should fetch and render board list", async () => { 17 | axiosMock.onGet(API_BOARDS).reply(200, boards); 18 | const { mockStore } = renderWithProviders(, { 19 | ...rootInitialState, 20 | board: { ...rootInitialState.board, all: boards }, 21 | }); 22 | 23 | expect(screen.getByText(/All Boards/i)).toBeVisible(); 24 | expect(screen.getByText(/Create new board/i)).toBeVisible(); 25 | 26 | await screen.findByText("Internals"); 27 | 28 | expect(screen.queryAllByTestId("fade")).toHaveLength(0); 29 | fireEvent.mouseOver(screen.getByText("Internals")); 30 | expect(screen.queryAllByTestId("fade")).toHaveLength(1); 31 | fireEvent.mouseLeave(screen.getByText("Internals")); 32 | expect(screen.queryAllByTestId("fade")).toHaveLength(0); 33 | 34 | const actions = mockStore.getActions(); 35 | expect(actions[0].type).toEqual(fetchAllBoards.pending.type); 36 | expect(actions[1].type).toEqual(fetchAllBoards.fulfilled.type); 37 | expect(actions[1].payload).toEqual(boards); 38 | }); 39 | 40 | it("should handle failure to fetch boards", async () => { 41 | axiosMock.onGet(API_BOARDS).networkErrorOnce(); 42 | const { mockStore } = renderWithProviders(); 43 | 44 | // failure is not dispatched yet 45 | expect(mockStore.getActions()[0].type).toEqual(fetchAllBoards.pending.type); 46 | }); 47 | 48 | it("should set loading start on start", () => { 49 | expect( 50 | boardReducer( 51 | { ...rootInitialState.board, fetchLoading: false }, 52 | fetchAllBoards.pending 53 | ) 54 | ).toEqual({ ...rootInitialState.board, fetchLoading: true }); 55 | }); 56 | 57 | it("should set boards on success", () => { 58 | const boards = [{ id: 1, name: "Internals" }]; 59 | expect( 60 | boardReducer( 61 | { ...rootInitialState.board, fetchLoading: true, all: [] }, 62 | { type: fetchAllBoards.fulfilled, payload: boards } 63 | ) 64 | ).toEqual({ 65 | ...rootInitialState.board, 66 | fetchLoading: false, 67 | all: boards, 68 | }); 69 | }); 70 | 71 | it("should set error on fail", () => { 72 | const errorMsg = "Failed to fetch boards."; 73 | expect( 74 | boardReducer( 75 | { ...rootInitialState.board, fetchLoading: true, fetchError: null }, 76 | { type: fetchAllBoards.rejected, payload: errorMsg } 77 | ) 78 | ).toEqual({ 79 | ...rootInitialState.board, 80 | fetchLoading: false, 81 | fetchError: errorMsg, 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /frontend/src/components/UserMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button, Menu, MenuItem, Avatar } from "@material-ui/core"; 3 | import { useSelector, useDispatch } from "react-redux"; 4 | import { RootState } from "store"; 5 | import { createInfoToast } from "features/toast/ToastSlice"; 6 | import { logout } from "features/auth/AuthSlice"; 7 | import { css } from "@emotion/core"; 8 | import styled from "@emotion/styled"; 9 | import { avatarStyles } from "styles"; 10 | import { useHistory } from "react-router-dom"; 11 | 12 | const Username = styled.div` 13 | color: #333; 14 | text-align: center; 15 | border-bottom: 1px solid #ccc; 16 | padding-bottom: 0.5rem; 17 | max-width: 200px; 18 | word-break: break-all; 19 | padding: 0.25rem 1rem 0.5rem 1rem; 20 | &:focus { 21 | outline: none; 22 | } 23 | `; 24 | 25 | const UserMenu = () => { 26 | const user = useSelector((state: RootState) => state.auth.user); 27 | const [anchorEl, setAnchorEl] = React.useState(null); 28 | const dispatch = useDispatch(); 29 | const history = useHistory(); 30 | 31 | const handleClick = (event: React.MouseEvent) => { 32 | setAnchorEl(event.currentTarget); 33 | }; 34 | 35 | const handleClose = () => { 36 | setAnchorEl(null); 37 | }; 38 | 39 | const handleNotImplemented = () => { 40 | dispatch(createInfoToast("Not implemented yet 😟")); 41 | }; 42 | 43 | const handleLogout = () => { 44 | setAnchorEl(null); 45 | dispatch(logout()); 46 | history.push("/"); 47 | }; 48 | 49 | const handleToProfile = () => { 50 | setAnchorEl(null); 51 | history.push("/profile"); 52 | }; 53 | 54 | return ( 55 | <> 56 | 70 | 75 | {user?.username.charAt(0)} 76 | 77 | 78 | 89 | {user?.username} 90 | Profile 91 | Available Shortcuts 92 | Logout 93 | 94 | > 95 | ); 96 | }; 97 | 98 | export default UserMenu; 99 | -------------------------------------------------------------------------------- /backend/boards/models.py: -------------------------------------------------------------------------------- 1 | from adminsortable.fields import SortableForeignKey 2 | from adminsortable.models import SortableMixin 3 | from django.contrib.auth import get_user_model 4 | from django.db import models 5 | from model_utils.models import TimeStampedModel 6 | 7 | User = get_user_model() 8 | 9 | 10 | class Board(models.Model): 11 | name = models.CharField(max_length=50) 12 | owner = models.ForeignKey( 13 | User, on_delete=models.PROTECT, related_name="owned_boards" 14 | ) 15 | members = models.ManyToManyField(User, related_name="boards") 16 | 17 | class Meta: 18 | ordering = ["id"] 19 | 20 | def __str__(self): 21 | return self.name 22 | 23 | def save( 24 | self, force_insert=False, force_update=False, using=None, update_fields=None 25 | ): 26 | is_new = self.pk is None 27 | super().save(force_insert, force_update, using, update_fields) 28 | if is_new: 29 | self.members.add(self.owner) 30 | 31 | 32 | class Column(SortableMixin): 33 | title = models.CharField(max_length=255) 34 | board = models.ForeignKey("Board", related_name="columns", on_delete=models.CASCADE) 35 | column_order = models.PositiveIntegerField(default=0, editable=False, db_index=True) 36 | 37 | class Meta: 38 | ordering = ["column_order"] 39 | 40 | def __str__(self): 41 | return f"{self.title}" 42 | 43 | 44 | class Label(models.Model): 45 | name = models.CharField(max_length=255) 46 | color = models.CharField(max_length=7) 47 | board = models.ForeignKey("Board", related_name="labels", on_delete=models.CASCADE) 48 | 49 | def __str__(self): 50 | return self.name 51 | 52 | class Meta: 53 | constraints = [ 54 | models.UniqueConstraint(fields=["name", "board"], name="unique_name_board") 55 | ] 56 | 57 | 58 | class Priority(models.TextChoices): 59 | HIGH = "H", "High" 60 | MEDIUM = "M", "Medium" 61 | LOW = "L", "Low" 62 | 63 | 64 | class Task(SortableMixin, TimeStampedModel): 65 | title = models.CharField(max_length=255) 66 | description = models.TextField(blank=True) 67 | priority = models.CharField( 68 | max_length=1, choices=Priority.choices, default=Priority.MEDIUM 69 | ) 70 | labels = models.ManyToManyField(Label, related_name="tasks") 71 | assignees = models.ManyToManyField(User, related_name="tasks") 72 | column = SortableForeignKey(Column, related_name="tasks", on_delete=models.CASCADE) 73 | task_order = models.PositiveIntegerField(default=0, editable=False, db_index=True) 74 | 75 | def __str__(self): 76 | return f"{self.id} - {self.title}" 77 | 78 | class Meta: 79 | ordering = ["task_order"] 80 | 81 | 82 | class Comment(TimeStampedModel): 83 | task = models.ForeignKey(Task, on_delete=models.CASCADE, related_name="comments") 84 | author = models.ForeignKey(User, on_delete=models.PROTECT, related_name="comments") 85 | text = models.TextField() 86 | 87 | class Meta: 88 | ordering = ["created"] 89 | -------------------------------------------------------------------------------- /backend/boards/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.4 on 2020-03-18 17:45 2 | 3 | import adminsortable.fields 4 | import django.db.models.deletion 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="Board", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("name", models.CharField(max_length=255)), 28 | ], 29 | ), 30 | migrations.CreateModel( 31 | name="Column", 32 | fields=[ 33 | ( 34 | "id", 35 | models.AutoField( 36 | auto_created=True, 37 | primary_key=True, 38 | serialize=False, 39 | verbose_name="ID", 40 | ), 41 | ), 42 | ("title", models.CharField(max_length=255)), 43 | ( 44 | "column_order", 45 | models.PositiveIntegerField( 46 | db_index=True, default=0, editable=False 47 | ), 48 | ), 49 | ( 50 | "board", 51 | models.ForeignKey( 52 | on_delete=django.db.models.deletion.CASCADE, 53 | related_name="columns", 54 | to="boards.Board", 55 | ), 56 | ), 57 | ], 58 | options={"ordering": ["column_order"],}, 59 | ), 60 | migrations.CreateModel( 61 | name="Task", 62 | fields=[ 63 | ( 64 | "id", 65 | models.AutoField( 66 | auto_created=True, 67 | primary_key=True, 68 | serialize=False, 69 | verbose_name="ID", 70 | ), 71 | ), 72 | ("title", models.CharField(max_length=255)), 73 | ("description", models.TextField()), 74 | ( 75 | "task_order", 76 | models.PositiveIntegerField( 77 | db_index=True, default=0, editable=False 78 | ), 79 | ), 80 | ( 81 | "column", 82 | adminsortable.fields.SortableForeignKey( 83 | on_delete=django.db.models.deletion.CASCADE, 84 | related_name="tasks", 85 | to="boards.Column", 86 | ), 87 | ), 88 | ], 89 | options={"ordering": ["task_order"],}, 90 | ), 91 | ] 92 | -------------------------------------------------------------------------------- /backend/boards/tests/test_comment_viewset.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | 3 | from boards.models import Comment 4 | 5 | 6 | def test_create_comment_success(api_client, task_factory, steve): 7 | task = task_factory() 8 | board = task.column.board 9 | board.members.set([steve]) 10 | 11 | # Steve is a board member, can add a comment 12 | api_client.force_authenticate(user=steve) 13 | assert Comment.objects.all().count() == 0 14 | response = api_client.post( 15 | reverse("comment-list"), {"task": task.id, "text": "Steve's comment"}, 16 | ) 17 | assert response.status_code == 201 18 | assert Comment.objects.all().count() == 1 19 | assert Comment.objects.first().author == steve 20 | 21 | 22 | def test_create_comment_not_authenticated(api_client, task_factory): 23 | task = task_factory() 24 | 25 | # Not authenticated 26 | response = api_client.post( 27 | reverse("comment-list"), {"task": task.id, "text": "anonymous comment"}, 28 | ) 29 | assert response.status_code == 401 30 | assert Comment.objects.all().count() == 0 31 | 32 | 33 | def test_create_comment_invalid_task(api_client, task_factory, amy): 34 | task = task_factory() 35 | 36 | # Amy not a member of the board where this task is 37 | api_client.force_authenticate(user=amy) 38 | response = api_client.post( 39 | reverse("comment-list"), {"task": task.id, "text": "Amy's comment"}, 40 | ) 41 | assert response.status_code == 400 42 | assert Comment.objects.all().count() == 0 43 | 44 | 45 | def test_delete_comment_success(api_client, task_factory, comment_factory, steve): 46 | task = task_factory() 47 | task.column.board.members.set([steve]) 48 | comment = comment_factory(task=task, author=steve) 49 | 50 | # Steve is the author of the comment, he can delete it 51 | api_client.force_authenticate(user=steve) 52 | assert Comment.objects.all().count() == 1 53 | response = api_client.delete(reverse("comment-detail", kwargs={"pk": comment.id})) 54 | assert response.status_code == 204 55 | assert Comment.objects.all().count() == 0 56 | 57 | 58 | def test_delete_comment_not_authenticated( 59 | api_client, task_factory, comment_factory, steve 60 | ): 61 | task = task_factory() 62 | task.column.board.members.set([steve]) 63 | comment = comment_factory(task=task, author=steve) 64 | 65 | # Not authenticated 66 | assert Comment.objects.all().count() == 1 67 | response = api_client.delete(reverse("comment-detail", kwargs={"pk": comment.id})) 68 | assert response.status_code == 401 69 | assert Comment.objects.all().count() == 1 70 | 71 | 72 | def test_delete_comment_not_author( 73 | api_client, task_factory, comment_factory, steve, amy 74 | ): 75 | task = task_factory() 76 | task.column.board.members.set([steve, amy]) 77 | comment = comment_factory(task=task, author=steve) 78 | 79 | # Amy is not the author of the comment, she can't delete it 80 | api_client.force_authenticate(user=amy) 81 | assert Comment.objects.all().count() == 1 82 | response = api_client.delete(reverse("comment-detail", kwargs={"pk": comment.id})) 83 | assert response.status_code == 400 84 | assert Comment.objects.all().count() == 1 85 | -------------------------------------------------------------------------------- /frontend/src/styles.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/core"; 2 | import { borderRadius, imageSize, grid } from "const"; 3 | import { N900 } from "utils/colors"; 4 | 5 | export const boardCardBaseStyles = css` 6 | position: relative; 7 | display: block; 8 | height: 100px; 9 | border-radius: 6px; 10 | padding: 0.5rem; 11 | text-decoration: none; 12 | &:hover { 13 | cursor: pointer; 14 | } 15 | `; 16 | 17 | export const iconBoxStyles = css` 18 | cursor: pointer; 19 | width: 24px; 20 | height: 24px; 21 | display: flex; 22 | align-items: center; 23 | justify-content: center; 24 | border-radius: 5px; 25 | margin: 1px; 26 | color: rgba(0, 0, 0, 0.5); 27 | opacity: 0.9; 28 | &:hover { 29 | opacity: 1; 30 | background: rgba(220, 220, 220, 1); 31 | } 32 | `; 33 | 34 | export const taskContainerStyles = css` 35 | border-radius: ${borderRadius}px; 36 | border: 2px solid transparent; 37 | box-shadow: 0 1px 2px rgba(10, 30, 60, 0.25); 38 | box-sizing: border-box; 39 | padding: ${grid}px; 40 | min-height: ${imageSize}px; 41 | margin-bottom: ${grid}px; 42 | user-select: none; 43 | color: ${N900}; 44 | 45 | &:hover, 46 | &:active { 47 | color: ${N900}; 48 | background-color: #f5f5f5; 49 | text-decoration: none; 50 | cursor: pointer; 51 | } 52 | 53 | &:focus { 54 | outline: none; 55 | box-shadow: none; 56 | } 57 | 58 | /* flexbox */ 59 | display: flex; 60 | `; 61 | 62 | export const avatarStyles = css` 63 | height: 2rem; 64 | width: 2rem; 65 | font-size: 12px; 66 | margin-left: -4px; 67 | `; 68 | 69 | export const createMdEditorStyles = (editing: boolean) => css` 70 | .rc-md-editor { 71 | border-color: #c4c4c4; 72 | border-radius: ${borderRadius}px; 73 | 74 | .rc-md-navigation { 75 | border-top-left-radius: ${borderRadius}px; 76 | border-top-right-radius: ${borderRadius}px; 77 | } 78 | .section-container { 79 | border-bottom-left-radius: ${borderRadius}px; 80 | border-bottom-right-radius: ${borderRadius}px; 81 | ${editing && 82 | `border-top-left-radius: ${borderRadius}px; 83 | border-top-right-radius: ${borderRadius}px;`} 84 | } 85 | } 86 | `; 87 | 88 | export const descriptionStyles = css` 89 | h1 { 90 | font-weight: 600; 91 | font-size: 24px; 92 | line-height: 28px; 93 | margin: 0 0 12px; 94 | } 95 | h2 { 96 | font-size: 20px; 97 | line-height: 24px; 98 | margin: 16px 0 8px; 99 | } 100 | h2, 101 | h3, 102 | h4, 103 | h5, 104 | h6 { 105 | font-weight: 600; 106 | margin: 0 0 8px; 107 | } 108 | h3, 109 | h4, 110 | h5, 111 | h6 { 112 | font-size: 16px; 113 | line-height: 20px; 114 | } 115 | ol, 116 | ul { 117 | margin: 8px 0; 118 | padding: 0; 119 | 120 | margin-block-start: 0.25em; 121 | margin-block-end: 0.25em; 122 | padding-inline-start: 16px; 123 | } 124 | li { 125 | margin-bottom: 0; 126 | } 127 | p { 128 | margin: 0; 129 | margin-bottom: 10px; 130 | line-height: 20px; 131 | } 132 | pre { 133 | padding: 10px 16px; 134 | line-height: 16px; 135 | } 136 | code { 137 | font-size: 12px; 138 | } 139 | blockquote p { 140 | margin: 0; 141 | } 142 | `; 143 | -------------------------------------------------------------------------------- /frontend/src/features/profile/Profile.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { screen, fireEvent, act } from "@testing-library/react"; 3 | import Profile from "./Profile"; 4 | import { 5 | renderWithProviders, 6 | rootInitialState, 7 | axiosMock, 8 | } from "utils/testHelpers"; 9 | import { API_USERS } from "api"; 10 | import { UserDetail } from "types"; 11 | import { steveAuthUser } from "features/auth/Auth.test"; 12 | 13 | /* eslint-disable @typescript-eslint/camelcase */ 14 | const steveDetail: UserDetail = { 15 | id: 1, 16 | username: "steve", 17 | first_name: "Steve", 18 | last_name: "Apple", 19 | email: "steve@gmail.com", 20 | avatar: null, 21 | date_joined: new Date().toISOString(), 22 | is_guest: false, 23 | }; 24 | 25 | it("should handle null userDetail", () => { 26 | renderWithProviders(); 27 | expect(screen.queryByText("about")).toBeNull(); 28 | }); 29 | 30 | it("should render default values", () => { 31 | renderWithProviders(, { 32 | ...rootInitialState, 33 | profile: { 34 | ...rootInitialState.profile, 35 | userDetail: steveDetail, 36 | }, 37 | }); 38 | expect(screen.getByText(/About/i)).toBeVisible(); 39 | 40 | expect(screen.getByLabelText("Username")).toHaveValue("steve"); 41 | expect(screen.getByLabelText("First name")).toHaveValue("Steve"); 42 | expect(screen.getByLabelText("Last name")).toHaveValue("Apple"); 43 | expect(screen.getByLabelText("Email")).toHaveValue("steve@gmail.com"); 44 | }); 45 | 46 | it("should update username", async () => { 47 | axiosMock.onPut(`${API_USERS}${steveDetail.id}/`).reply(200, { 48 | ...steveDetail, 49 | username: "newsteve", 50 | }); 51 | 52 | renderWithProviders(, { 53 | ...rootInitialState, 54 | auth: { 55 | ...rootInitialState.auth, 56 | user: steveAuthUser, 57 | }, 58 | profile: { 59 | ...rootInitialState.profile, 60 | userDetail: steveDetail, 61 | }, 62 | }); 63 | 64 | fireEvent.change(screen.getByLabelText("Username"), { 65 | target: { value: "newsteve" }, 66 | }); 67 | await act(async () => { 68 | fireEvent.click(screen.getByTestId("profile-save")); 69 | }); 70 | }); 71 | 72 | it("should show validation error for email field", async () => { 73 | renderWithProviders(, { 74 | ...rootInitialState, 75 | auth: { 76 | ...rootInitialState.auth, 77 | user: steveAuthUser, 78 | }, 79 | profile: { 80 | ...rootInitialState.profile, 81 | userDetail: steveDetail, 82 | }, 83 | }); 84 | 85 | await act(async () => { 86 | fireEvent.change(screen.getByLabelText("Email"), { 87 | target: { value: "bad@." }, 88 | }); 89 | fireEvent.click(screen.getByTestId("profile-save")); 90 | }); 91 | expect(screen.getByText(/invalid email/i)).toBeVisible(); 92 | }); 93 | 94 | it("should show warning for guest", async () => { 95 | renderWithProviders(, { 96 | ...rootInitialState, 97 | auth: { 98 | ...rootInitialState.auth, 99 | user: steveAuthUser, 100 | }, 101 | profile: { 102 | ...rootInitialState.profile, 103 | userDetail: { ...steveDetail, is_guest: true }, 104 | }, 105 | }); 106 | 107 | expect(screen.getByText("Warning")).toBeVisible(); 108 | }); 109 | -------------------------------------------------------------------------------- /frontend/src/features/label/LabelSlice.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createSlice, 3 | createEntityAdapter, 4 | createAsyncThunk, 5 | PayloadAction, 6 | } from "@reduxjs/toolkit"; 7 | import { Label, Id } from "types"; 8 | import { fetchBoardById } from "features/board/BoardSlice"; 9 | import { RootState } from "store"; 10 | import api, { API_LABELS } from "api"; 11 | import { createInfoToast, createErrorToast } from "features/toast/ToastSlice"; 12 | import { AxiosError } from "axios"; 13 | 14 | export const createLabel = createAsyncThunk>( 15 | "label/createLabelStatus", 16 | async (label, { dispatch }) => { 17 | const response = await api.post(`${API_LABELS}`, label); 18 | dispatch(createInfoToast("Label created")); 19 | return response.data; 20 | } 21 | ); 22 | 23 | export const patchLabel = createAsyncThunk< 24 | Label, 25 | { id: Id; fields: Partial } 26 | >( 27 | "label/patchLabelStatus", 28 | async ({ id, fields }, { dispatch, rejectWithValue }) => { 29 | try { 30 | const response = await api.patch(`${API_LABELS}${id}/`, fields); 31 | dispatch(createInfoToast("Label updated")); 32 | return response.data; 33 | } catch (err) { 34 | const error: AxiosError = err; 35 | if (!error.response) { 36 | throw err; 37 | } 38 | dispatch(createErrorToast(error.response.data)); 39 | return rejectWithValue(error.response.data); 40 | } 41 | } 42 | ); 43 | 44 | export const deleteLabel = createAsyncThunk( 45 | "label/deleteLabelStatus", 46 | async (id, { dispatch }) => { 47 | await api.delete(`${API_LABELS}${id}/`); 48 | dispatch(createInfoToast("Label deleted")); 49 | return id; 50 | } 51 | ); 52 | 53 | const labelAdapter = createEntityAdapter({ 54 | sortComparer: (a, b) => a.name.localeCompare(b.name), 55 | }); 56 | 57 | interface ExtraInitialState { 58 | dialogOpen: boolean; 59 | } 60 | 61 | export const initialState = labelAdapter.getInitialState({ 62 | dialogOpen: false, 63 | }); 64 | 65 | export const slice = createSlice({ 66 | name: "label", 67 | initialState, 68 | reducers: { 69 | setDialogOpen: (state, action: PayloadAction) => { 70 | state.dialogOpen = action.payload; 71 | }, 72 | }, 73 | extraReducers: (builder) => { 74 | builder.addCase(fetchBoardById.fulfilled, (state, action) => { 75 | labelAdapter.setAll(state, action.payload.labels); 76 | }); 77 | builder.addCase(createLabel.fulfilled, (state, action) => { 78 | labelAdapter.addOne(state, action.payload); 79 | }); 80 | builder.addCase(patchLabel.fulfilled, (state, action) => { 81 | const { id, name, color } = action.payload; 82 | labelAdapter.updateOne(state, { id, changes: { name, color } }); 83 | }); 84 | builder.addCase(deleteLabel.fulfilled, (state, action) => { 85 | labelAdapter.removeOne(state, action.payload); 86 | }); 87 | }, 88 | }); 89 | 90 | export const { setDialogOpen } = slice.actions; 91 | 92 | export const labelSelectors = labelAdapter.getSelectors( 93 | (state: RootState) => state.label 94 | ); 95 | 96 | export const { 97 | selectAll: selectAllLabels, 98 | selectEntities: selectLabelEntities, 99 | } = labelSelectors; 100 | 101 | export default slice.reducer; 102 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of 9 | experience, nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or reject 41 | comments, commits, code, wiki edits, issues, and other contributions that are 42 | not aligned to this Code of Conduct, or to ban temporarily or permanently any 43 | contributor for other behaviors that they deem inappropriate, threatening, 44 | offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at kent+coc@doddsfamily.us. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an 62 | incident. Further details of specific enforcement policies may be posted 63 | separately. 64 | 65 | Project maintainers who do not follow or enforce the Code of Conduct in good 66 | faith may face temporary or permanent repercussions as determined by other 67 | members of the project's leadership. 68 | 69 | ## Attribution 70 | 71 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 72 | version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 73 | 74 | [homepage]: http://contributor-covenant.org 75 | [version]: http://contributor-covenant.org/version/1/4/ 76 | -------------------------------------------------------------------------------- /frontend/src/const.ts: -------------------------------------------------------------------------------- 1 | import { createMuiTheme } from "@material-ui/core/styles"; 2 | import { Priority, PriorityValue } from "types"; 3 | import { PRIO1, PRIO2, PRIO3, PRIMARY_MAIN } from "utils/colors"; 4 | import { grey } from "@material-ui/core/colors"; 5 | 6 | export const TOAST_AUTO_HIDE_DURATION = 4000; 7 | export const LOCAL_STORAGE_KEY = "knboard-data"; 8 | 9 | export const grid = 8; 10 | export const borderRadius = 4; 11 | export const imageSize = 40; 12 | export const barHeight = 50; 13 | export const sidebarWidth = 120; 14 | export const taskHeaderTextareaWidth = 180; 15 | export const taskWidth = 250; 16 | export const taskSideWidth = 220; 17 | export const taskDialogHeight = 800; 18 | export const commentBoxWidth = 390; 19 | export const commentBoxWidthMobile = 300; 20 | 21 | export const PRIORITY_1: Priority = { value: "H", label: "High" }; 22 | export const PRIORITY_2: Priority = { value: "M", label: "Medium" }; 23 | export const PRIORITY_3: Priority = { value: "L", label: "Low" }; 24 | 25 | export const PRIORITY_OPTIONS: Priority[] = [ 26 | PRIORITY_1, 27 | PRIORITY_2, 28 | PRIORITY_3, 29 | ]; 30 | 31 | export const PRIORITY_MAP = PRIORITY_OPTIONS.reduce((acc, curr) => { 32 | acc[curr.value] = curr; 33 | return acc; 34 | }, {} as Record); 35 | 36 | export const PRIO_COLORS = { 37 | H: PRIO1, 38 | M: PRIO2, 39 | L: PRIO3, 40 | }; 41 | 42 | export const MD_EDITOR_PLUGINS = [ 43 | "header", 44 | "fonts", 45 | "table", 46 | "link", 47 | "mode-toggle", 48 | "full-screen", 49 | ]; 50 | 51 | export const MD_EDITOR_CONFIG = { 52 | view: { 53 | menu: true, 54 | md: true, 55 | html: false, 56 | }, 57 | canView: { 58 | menu: true, 59 | md: true, 60 | html: true, 61 | fullScreen: true, 62 | hideMenu: false, 63 | }, 64 | }; 65 | 66 | export const MD_EDITING_CONFIG = { 67 | view: { 68 | menu: false, 69 | md: true, 70 | html: false, 71 | }, 72 | canView: { 73 | menu: false, 74 | md: true, 75 | html: false, 76 | fullScreen: false, 77 | hideMenu: false, 78 | }, 79 | }; 80 | 81 | export const MD_READ_ONLY_CONFIG = { 82 | view: { 83 | menu: false, 84 | md: false, 85 | html: true, 86 | }, 87 | canView: { 88 | menu: false, 89 | md: false, 90 | html: true, 91 | fullScreen: false, 92 | hideMenu: false, 93 | }, 94 | }; 95 | 96 | export const theme = createMuiTheme({ 97 | palette: { 98 | type: "light", 99 | primary: { 100 | main: PRIMARY_MAIN, 101 | }, 102 | secondary: { 103 | light: grey[700], 104 | main: "#FDB915", 105 | }, 106 | }, 107 | typography: { 108 | fontFamily: '"Inter var", sans-serif', 109 | }, 110 | props: { 111 | MuiButtonBase: { 112 | disableRipple: true, 113 | }, 114 | MuiDialog: { 115 | transitionDuration: 100, 116 | }, 117 | }, 118 | overrides: { 119 | MuiButton: { 120 | root: { 121 | "&:hover": { 122 | transition: "none", 123 | }, 124 | }, 125 | }, 126 | }, 127 | }); 128 | 129 | export const modalPopperIndex = theme.zIndex.modal + 100; 130 | export const modalPopperAutocompleteIndex = modalPopperIndex + 100; 131 | export const modalPopperAutocompleteModalIndex = 132 | modalPopperAutocompleteIndex + 100; 133 | export const modalPopperWidth = 300; 134 | 135 | export enum Key { 136 | Enter = 13, 137 | Escape = 27, 138 | } 139 | -------------------------------------------------------------------------------- /frontend/src/features/label/LabelRow.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import styled from "@emotion/styled"; 3 | import { Button } from "@material-ui/core"; 4 | import { useDispatch, useSelector } from "react-redux"; 5 | import { RootState } from "store"; 6 | import { patchLabel, deleteLabel } from "./LabelSlice"; 7 | import { css } from "@emotion/core"; 8 | import { Label } from "types"; 9 | import { useForm, FormContext } from "react-hook-form"; 10 | import { borderRadius } from "const"; 11 | import Flex from "components/Flex"; 12 | import LabelFields from "./LabelFields"; 13 | import LabelChip from "components/LabelChip"; 14 | 15 | const RowDiv = styled.div` 16 | padding: 0.5rem; 17 | border-top: 1px solid #ccc; 18 | border-left: 1px solid #ccc; 19 | border-right: 1px solid #ccc; 20 | &:first-of-type { 21 | border-top-left-radius: ${borderRadius}px; 22 | border-top-right-radius: ${borderRadius}px; 23 | } 24 | &:last-of-type { 25 | border-bottom: 1px solid #ccc; 26 | border-bottom-left-radius: ${borderRadius}px; 27 | border-bottom-right-radius: ${borderRadius}px; 28 | } 29 | `; 30 | 31 | interface RowProps { 32 | label: Label; 33 | } 34 | 35 | interface DialogFormData { 36 | name: string; 37 | color: string; 38 | } 39 | 40 | const LabelRow = ({ label }: RowProps) => { 41 | const dispatch = useDispatch(); 42 | const [editing, setEditing] = useState(false); 43 | const detail = useSelector((state: RootState) => state.board.detail); 44 | const methods = useForm({ 45 | defaultValues: { name: label.name, color: label.color }, 46 | mode: "onChange", 47 | }); 48 | 49 | const onSubmit = methods.handleSubmit(({ name, color }) => { 50 | if (detail) { 51 | dispatch( 52 | patchLabel({ id: label.id, fields: { name, color, board: detail.id } }) 53 | ); 54 | setEditing(false); 55 | } 56 | }); 57 | 58 | const handleDelete = () => { 59 | if ( 60 | window.confirm( 61 | "Are you sure? Deleting a label will remove it from all tasks." 62 | ) 63 | ) { 64 | dispatch(deleteLabel(label.id)); 65 | } 66 | }; 67 | 68 | return ( 69 | 70 | 76 | 77 | 78 | {!editing && ( 79 | setEditing(true)} 82 | css={css` 83 | margin-left: 0.5rem; 84 | font-size: 0.675rem; 85 | `} 86 | > 87 | Edit 88 | 89 | )} 90 | 98 | Delete 99 | 100 | 101 | 102 | 103 | {editing && ( 104 | 109 | )} 110 | 111 | 112 | ); 113 | }; 114 | 115 | export default LabelRow; 116 | --------------------------------------------------------------------------------
85 | Knboard is an app that helps visualize your work using kanban 86 | boards, maximizing efficiency. 87 |