Compare commits
2 Commits
feat/redes
...
master
Author | SHA1 | Date | |
---|---|---|---|
|
998db2a5da | ||
|
dfa8a4fba0 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -6,5 +6,3 @@ internal/frontend/frontend_generated.go
|
||||
internal/migrations/migrations_generated.go
|
||||
taskcafe
|
||||
conf/taskcafe.toml
|
||||
scripts/gqlgen
|
||||
scripts/sqlc
|
||||
|
12
Pipfile
Normal file
12
Pipfile
Normal file
@ -0,0 +1,12 @@
|
||||
[[source]]
|
||||
name = "pypi"
|
||||
url = "https://pypi.org/simple"
|
||||
verify_ssl = true
|
||||
|
||||
[dev-packages]
|
||||
|
||||
[packages]
|
||||
pre-commit = "*"
|
||||
|
||||
[requires]
|
||||
python_version = "3.9"
|
124
Pipfile.lock
generated
Normal file
124
Pipfile.lock
generated
Normal file
@ -0,0 +1,124 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "76a59164ad995ef4d02794470696e6f1dd199ede126c2d92a2bc1011eb288f69"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
"python_version": "3.9"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"name": "pypi",
|
||||
"url": "https://pypi.org/simple",
|
||||
"verify_ssl": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"default": {
|
||||
"appdirs": {
|
||||
"hashes": [
|
||||
"sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41",
|
||||
"sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"
|
||||
],
|
||||
"version": "==1.4.4"
|
||||
},
|
||||
"cfgv": {
|
||||
"hashes": [
|
||||
"sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d",
|
||||
"sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"
|
||||
],
|
||||
"markers": "python_full_version >= '3.6.1'",
|
||||
"version": "==3.2.0"
|
||||
},
|
||||
"distlib": {
|
||||
"hashes": [
|
||||
"sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb",
|
||||
"sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"
|
||||
],
|
||||
"version": "==0.3.1"
|
||||
},
|
||||
"filelock": {
|
||||
"hashes": [
|
||||
"sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59",
|
||||
"sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"
|
||||
],
|
||||
"version": "==3.0.12"
|
||||
},
|
||||
"identify": {
|
||||
"hashes": [
|
||||
"sha256:70b638cf4743f33042bebb3b51e25261a0a10e80f978739f17e7fd4837664a66",
|
||||
"sha256:9dfb63a2e871b807e3ba62f029813552a24b5289504f5b071dea9b041aee9fe4"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.5.13"
|
||||
},
|
||||
"nodeenv": {
|
||||
"hashes": [
|
||||
"sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9",
|
||||
"sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c"
|
||||
],
|
||||
"version": "==1.5.0"
|
||||
},
|
||||
"pre-commit": {
|
||||
"hashes": [
|
||||
"sha256:16212d1fde2bed88159287da88ff03796863854b04dc9f838a55979325a3d20e",
|
||||
"sha256:399baf78f13f4de82a29b649afd74bef2c4e28eb4f021661fc7f29246e8c7a3a"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.10.1"
|
||||
},
|
||||
"pyyaml": {
|
||||
"hashes": [
|
||||
"sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf",
|
||||
"sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696",
|
||||
"sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393",
|
||||
"sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77",
|
||||
"sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922",
|
||||
"sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5",
|
||||
"sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8",
|
||||
"sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10",
|
||||
"sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc",
|
||||
"sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018",
|
||||
"sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e",
|
||||
"sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253",
|
||||
"sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183",
|
||||
"sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb",
|
||||
"sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185",
|
||||
"sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db",
|
||||
"sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46",
|
||||
"sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b",
|
||||
"sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63",
|
||||
"sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df",
|
||||
"sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
|
||||
"version": "==5.4.1"
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
|
||||
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.15.0"
|
||||
},
|
||||
"toml": {
|
||||
"hashes": [
|
||||
"sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",
|
||||
"sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"
|
||||
],
|
||||
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==0.10.2"
|
||||
},
|
||||
"virtualenv": {
|
||||
"hashes": [
|
||||
"sha256:147b43894e51dd6bba882cf9c282447f780e2251cd35172403745fc381a0a80d",
|
||||
"sha256:2be72df684b74df0ea47679a7df93fd0e04e72520022c57b479d8f881485dbe3"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==20.4.2"
|
||||
}
|
||||
},
|
||||
"develop": {}
|
||||
}
|
@ -1,4 +1,3 @@
|
||||
//go:build ignore
|
||||
// +build ignore
|
||||
|
||||
package main
|
||||
@ -9,6 +8,4 @@ import (
|
||||
"github.com/magefile/mage/mage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(mage.Main())
|
||||
}
|
||||
func main() { os.Exit(mage.Main()) }
|
||||
|
@ -1,9 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/jordanknott/taskcafe/internal/command"
|
||||
"github.com/jordanknott/taskcafe/internal/commands"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
func main() {
|
||||
command.Execute()
|
||||
commands.Execute()
|
||||
}
|
||||
|
47
conf/air.toml
Normal file
47
conf/air.toml
Normal file
@ -0,0 +1,47 @@
|
||||
# Config file for [Air](https://github.com/cosmtrek/air) in TOML format
|
||||
|
||||
# Working directory
|
||||
# . or absolute path, please note that the directories following must be under root.
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
# Just plain old shell command. You could use `make` as well.
|
||||
cmd = "go build -o ./dist/taskcafe cmd/taskcafe/main.go"
|
||||
# Binary file yields from `cmd`.
|
||||
bin = "dist/taskcafe"
|
||||
# Customize binary.
|
||||
full_bin = "./dist/taskcafe web"
|
||||
# Watch these filename extensions.
|
||||
include_ext = ["go"]
|
||||
# Ignore these filename extensions or directories.
|
||||
exclude_dir = ["dist", "frontend"]
|
||||
# Watch these directories if you specified.
|
||||
include_dir = []
|
||||
# Exclude files.
|
||||
exclude_file = []
|
||||
# This log file places in your tmp_dir.
|
||||
log = "air.log"
|
||||
# It's not necessary to trigger build each time file changes if it's too frequent.
|
||||
delay = 1000 # ms
|
||||
# Stop running old binary when build errors occur.
|
||||
stop_on_error = true
|
||||
# Send Interrupt signal before killing process (windows does not support this feature)
|
||||
send_interrupt = false
|
||||
# Delay after sending Interrupt signal
|
||||
kill_delay = 500 # ms
|
||||
|
||||
[log]
|
||||
# Show log time
|
||||
time = false
|
||||
|
||||
[color]
|
||||
# Customize each part's color. If no color found, use the raw app log.
|
||||
main = "magenta"
|
||||
watcher = "cyan"
|
||||
build = "yellow"
|
||||
runner = "green"
|
||||
|
||||
[misc]
|
||||
# Delete tmp directory on exit
|
||||
clean_on_exit = true
|
25
conf/taskcafe.example.toml
Normal file
25
conf/taskcafe.example.toml
Normal file
@ -0,0 +1,25 @@
|
||||
[server]
|
||||
hostname = '0.0.0.0:3333'
|
||||
|
||||
[email_notifications]
|
||||
enabled = true
|
||||
display_name = "No Reply"
|
||||
from_address = "example.com"
|
||||
|
||||
[storage]
|
||||
storage_system = 'local_storage'
|
||||
upload_dir_path = 'uploads'
|
||||
|
||||
[database]
|
||||
host = 'postgres'
|
||||
name = 'taskcafe'
|
||||
user = 'taskcafe'
|
||||
password = 'taskcafe_test'
|
||||
|
||||
[smtp]
|
||||
username = 'taskcafe@example.com'
|
||||
password = ''
|
||||
from = 'no-reply@taskcafe.com'
|
||||
host = 'localhost'
|
||||
port = 11500
|
||||
skip_verify = false
|
@ -2,30 +2,33 @@ version: "3"
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12.3-alpine
|
||||
restart: always
|
||||
networks:
|
||||
- taskcafe-dev
|
||||
- taskcafe-test
|
||||
environment:
|
||||
POSTGRES_USER: taskcafe
|
||||
POSTGRES_PASSWORD: taskcafe_dev
|
||||
POSTGRES_PASSWORD: taskcafe_test
|
||||
POSTGRES_DB: taskcafe
|
||||
volumes:
|
||||
- taskcafe-dev-postgres:/var/lib/postgresql/data
|
||||
- taskcafe-postgres:/var/lib/postgresql/data
|
||||
ports:
|
||||
- 5433:5432
|
||||
- 8865:5432
|
||||
mailhog:
|
||||
image: mailhog/mailhog:latest
|
||||
restart: always
|
||||
ports:
|
||||
- 1026:1025
|
||||
- 8026:8025
|
||||
- 1025:1025
|
||||
- 8025:8025
|
||||
redis:
|
||||
image: redis:6.2
|
||||
restart: always
|
||||
ports:
|
||||
- 6380:6379
|
||||
- 6379:6379
|
||||
|
||||
volumes:
|
||||
taskcafe-dev-postgres:
|
||||
taskcafe-postgres:
|
||||
external: false
|
||||
|
||||
networks:
|
||||
taskcafe-dev:
|
||||
taskcafe-test:
|
||||
driver: bridge
|
||||
|
12
docker-compose.migrate.yml
Normal file
12
docker-compose.migrate.yml
Normal file
@ -0,0 +1,12 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
migrate:
|
||||
build: .
|
||||
entrypoint: ./taskcafe migrate
|
||||
volumes:
|
||||
- ./migrations:/root/migrations
|
||||
depends_on:
|
||||
- postgres
|
||||
networks:
|
||||
- taskcafe-test
|
38
docker-compose.yml
Normal file
38
docker-compose.yml
Normal file
@ -0,0 +1,38 @@
|
||||
version: "3"
|
||||
services:
|
||||
web:
|
||||
image: taskcafe/taskcafe:latest
|
||||
# build: .
|
||||
ports:
|
||||
- "3333:3333"
|
||||
depends_on:
|
||||
- postgres
|
||||
networks:
|
||||
- taskcafe-test
|
||||
environment:
|
||||
TASKCAFE_DATABASE_HOST: postgres
|
||||
TASKCAFE_MIGRATE: "true"
|
||||
volumes:
|
||||
- taskcafe-uploads:/root/uploads
|
||||
|
||||
postgres:
|
||||
image: postgres:12.3-alpine
|
||||
restart: always
|
||||
networks:
|
||||
- taskcafe-test
|
||||
environment:
|
||||
POSTGRES_USER: taskcafe
|
||||
POSTGRES_PASSWORD: taskcafe_test
|
||||
POSTGRES_DB: taskcafe
|
||||
volumes:
|
||||
- taskcafe-postgres:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
taskcafe-postgres:
|
||||
external: false
|
||||
taskcafe-uploads:
|
||||
external: false
|
||||
|
||||
networks:
|
||||
taskcafe-test:
|
||||
driver: bridge
|
13
frontend/.editorconfig
Normal file
13
frontend/.editorconfig
Normal file
@ -0,0 +1,13 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
indent_size = 2
|
2
frontend/.env
Normal file
2
frontend/.env
Normal file
@ -0,0 +1,2 @@
|
||||
REACT_APP_ENABLE_POLLING=true
|
||||
ESLINT_NO_DEV_ERRORS=true
|
1
frontend/.env.development
Normal file
1
frontend/.env.development
Normal file
@ -0,0 +1 @@
|
||||
REACT_APP_ENABLE_POLLING=false
|
3
frontend/.eslintignore
Normal file
3
frontend/.eslintignore
Normal file
@ -0,0 +1,3 @@
|
||||
src/shared/generated/*.tsx
|
||||
src/shared/generated/*.ts
|
||||
src/react-app-env.d.ts
|
@ -21,8 +21,7 @@
|
||||
"airbnb",
|
||||
"plugin:prettier/recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:storybook/recommended"
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"rules": {
|
||||
"prettier/prettier": "warn",
|
||||
@ -31,12 +30,7 @@
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"react/jsx-filename-extension": [
|
||||
2,
|
||||
{
|
||||
"extensions": [".js", ".jsx", ".ts", ".tsx"]
|
||||
}
|
||||
],
|
||||
"react/jsx-filename-extension": [2, { "extensions": [".js", ".jsx", ".ts", ".tsx"] }],
|
||||
"react/require-default-props": "off",
|
||||
"no-case-declarations": "off",
|
||||
"no-plusplus": "off",
|
||||
@ -56,15 +50,12 @@
|
||||
"tsx": "never"
|
||||
}
|
||||
],
|
||||
"import/prefer-default-export": "off",
|
||||
"no-use-before-define": "off",
|
||||
"react/no-unescaped-entities": "off",
|
||||
"import/no-unresolved": "off",
|
||||
"@typescript-eslint/no-use-before-define": ["error"],
|
||||
"import/no-extraneous-dependencies": [
|
||||
"error",
|
||||
{
|
||||
"devDependencies": ["src/stories/**", "src/shared/components/**/*.stories.tsx", "**/*.stories.tsx"]
|
||||
"devDependencies": [".storybook/**", "src/shared/components/**/*.stories.tsx"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
@ -18,6 +18,7 @@
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
report*
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
@ -1,15 +1,18 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
"stories": [
|
||||
"../src/**/*.stories.mdx",
|
||||
"../src/**/*.stories.@(js|jsx|ts|tsx)"
|
||||
stories: ['../src/shared/components/**/*.stories.tsx'],
|
||||
addons: [
|
||||
'@storybook/addon-actions/register',
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-storysource',
|
||||
'@storybook/addon-knobs/register',
|
||||
'@storybook/addon-docs/register',
|
||||
'@storybook/addon-viewport/register',
|
||||
'@storybook/addon-backgrounds/register',
|
||||
],
|
||||
"addons": [
|
||||
"@storybook/addon-links",
|
||||
"@storybook/addon-essentials",
|
||||
"@storybook/preset-create-react-app"
|
||||
],
|
||||
"framework": "@storybook/react",
|
||||
"core": {
|
||||
"builder": "webpack5"
|
||||
}
|
||||
}
|
||||
webpackFinal: async config => {
|
||||
config.resolve.modules.push(path.resolve(__dirname, '../src'));
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
@ -1,6 +0,0 @@
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
src: url(/assets/fonts/OpenSans-Regular.ttf) format('truetype');
|
||||
}
|
||||
</style>
|
@ -1,47 +0,0 @@
|
||||
import BaseStyles from 'app/BaseStyles';
|
||||
import NormalizeStyles from 'app/NormalizeStyles';
|
||||
import themes from 'app/ThemeStyles';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const globalTypes = {
|
||||
theme: {
|
||||
name: 'Theme',
|
||||
description: 'Global theme for components',
|
||||
defaultValue: 'darkTheme',
|
||||
toolbar: {
|
||||
icon: 'circlehollow',
|
||||
// Array of plain string values or MenuItem shape (see below)
|
||||
items: ['lightTheme', 'darkTheme'],
|
||||
// Property that specifies if the name of the item will be displayed
|
||||
showName: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const decorators = [
|
||||
(Story, context) => {
|
||||
const theme = themes[context.globals.theme];
|
||||
return (
|
||||
<>
|
||||
<ThemeProvider theme={theme}>
|
||||
<BaseStyles />
|
||||
<NormalizeStyles />
|
||||
<MemoryRouter>
|
||||
<Story />
|
||||
</MemoryRouter>
|
||||
</ThemeProvider>
|
||||
</>
|
||||
);
|
||||
},
|
||||
];
|
3
frontend/Makefile
Normal file
3
frontend/Makefile
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
start:
|
||||
yarn start
|
@ -1,46 +0,0 @@
|
||||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.\
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
18
frontend/codegen.yml
Normal file
18
frontend/codegen.yml
Normal file
@ -0,0 +1,18 @@
|
||||
overwrite: true
|
||||
schema:
|
||||
- '../internal/graph/schema/*.gql'
|
||||
documents:
|
||||
- 'src/shared/graphql/*.graphqls'
|
||||
- 'src/shared/graphql/**/*.ts'
|
||||
generates:
|
||||
src/shared/generated/graphql.tsx:
|
||||
plugins:
|
||||
- 'typescript'
|
||||
- 'typescript-operations'
|
||||
- 'typescript-react-apollo'
|
||||
config:
|
||||
withHOC: false
|
||||
withComponent: false
|
||||
withHooks: true
|
||||
scalars:
|
||||
UUID: string
|
@ -1,44 +1,84 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"name": "app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^5.16.2",
|
||||
"@testing-library/react": "^12.1.2",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"color": "^4.2.0",
|
||||
"@apollo/client": "^3.3.16",
|
||||
"@apollo/react-common": "^3.1.4",
|
||||
"@apollo/react-hooks": "^4.0.0",
|
||||
"@taskcafe/rich-markdown-editor": "^11.0.10",
|
||||
"@types/color": "^3.0.1",
|
||||
"@types/dompurify": "^2.2.2",
|
||||
"@types/emoji-mart": "^3.0.4",
|
||||
"@types/jest": "^26.0.23",
|
||||
"@types/lodash": "^4.14.168",
|
||||
"@types/node": "^15.0.1",
|
||||
"@types/react": "^17.0.20",
|
||||
"@types/react-beautiful-dnd": "^13.0.0",
|
||||
"@types/react-datepicker": "^3.1.8",
|
||||
"@types/react-dom": "^17.0.9",
|
||||
"@types/react-router": "^5.1.13",
|
||||
"@types/react-router-dom": "^5.1.7",
|
||||
"@types/react-select": "^4.0.15",
|
||||
"@types/react-timeago": "^4.1.1",
|
||||
"@types/styled-components": "^5.1.0",
|
||||
"apollo-cache-inmemory": "^1.6.5",
|
||||
"apollo-client": "^2.6.8",
|
||||
"apollo-link": "^1.2.13",
|
||||
"apollo-link-error": "^1.1.12",
|
||||
"apollo-link-http": "^1.5.16",
|
||||
"apollo-link-state": "^0.4.2",
|
||||
"apollo-utilities": "^1.3.3",
|
||||
"axios": "^0.21.1",
|
||||
"axios-auth-refresh": "^3.1.0",
|
||||
"color": "^3.1.2",
|
||||
"date-fns": "^2.21.1",
|
||||
"dayjs": "^1.10.4",
|
||||
"dompurify": "^2.2.8",
|
||||
"emoji-mart": "^3.0.1",
|
||||
"emoticon": "^4.0.0",
|
||||
"graphql": "^15.5.0",
|
||||
"graphql-tag": "^2.12.4",
|
||||
"history": "^5.0.0",
|
||||
"immer": "^9.0.2",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"lodash": "^4.17.21",
|
||||
"loglevel": "^1.7.1",
|
||||
"loglevel-plugin-remote": "^0.6.8",
|
||||
"node-emoji": "^1.10.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"query-string": "^7.0.0",
|
||||
"react": "^17.0.2",
|
||||
"react-autosize-textarea": "^7.0.0",
|
||||
"react-beautiful-dnd": "^13.1.0",
|
||||
"react-datepicker": "^3.8.0",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-hook-form": "^7.25.3",
|
||||
"react-router": "^6.2.1",
|
||||
"react-router-dom": "^6.2.1",
|
||||
"react-scripts": "5.0.0",
|
||||
"styled-components": "^5.3.3",
|
||||
"typescript": "^4.5.5"
|
||||
"react-emoji-render": "^1.2.4",
|
||||
"react-hook-form": "^7.3.6",
|
||||
"react-markdown": "^6.0.1",
|
||||
"react-router": "^5.1.2",
|
||||
"react-router-dom": "^5.1.2",
|
||||
"react-scripts": "4.0.3",
|
||||
"react-select": "^4.3.0",
|
||||
"react-timeago": "^5.2.0",
|
||||
"react-toastify": "^7.0.4",
|
||||
"rich-markdown-editor": "^11.17.4-0",
|
||||
"styled-components": "^5.2.3",
|
||||
"typescript": "~4.2.4",
|
||||
"unist-util-visit": "^4.0.0"
|
||||
},
|
||||
"proxy": "http://localhost:3333",
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject",
|
||||
"storybook": "start-storybook -p 6006 -s public",
|
||||
"build-storybook": "build-storybook -s public"
|
||||
"generate": "graphql-codegen",
|
||||
"lint": "eslint --ext js,ts,tsx src",
|
||||
"tsc": "tsc"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"**/*.stories.*"
|
||||
],
|
||||
"rules": {
|
||||
"import/no-anonymous-default-export": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
"extends": "react-app"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
@ -57,32 +97,16 @@
|
||||
"@graphql-codegen/typescript": "^1.22.0",
|
||||
"@graphql-codegen/typescript-operations": "^1.17.16",
|
||||
"@graphql-codegen/typescript-react-apollo": "^2.2.4",
|
||||
"@storybook/addon-actions": "^6.4.16",
|
||||
"@storybook/addon-essentials": "^6.4.16",
|
||||
"@storybook/addon-links": "^6.4.16",
|
||||
"@storybook/builder-webpack5": "^6.4.16",
|
||||
"@storybook/manager-webpack5": "^6.4.16",
|
||||
"@storybook/node-logger": "^6.4.16",
|
||||
"@storybook/preset-create-react-app": "^4.0.0",
|
||||
"@storybook/react": "^6.4.16",
|
||||
"@types/color": "^3.0.2",
|
||||
"@types/jest": "^27.4.0",
|
||||
"@types/node": "^16.11.21",
|
||||
"@types/react": "^17.0.38",
|
||||
"@types/react-dom": "^17.0.11",
|
||||
"@types/styled-components": "^5.1.21",
|
||||
"@typescript-eslint/eslint-plugin": "^4.22.0",
|
||||
"@typescript-eslint/parser": "^4.22.0",
|
||||
"eslint": "^7.25.0",
|
||||
"eslint-config-airbnb": "^18.0.1",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-import": "^2.25.4",
|
||||
"eslint-plugin-import": "^2.20.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.2.3",
|
||||
"eslint-plugin-prettier": "^3.4.0",
|
||||
"eslint-plugin-react": "^7.23.2",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"eslint-plugin-storybook": "^0.5.6",
|
||||
"prettier": "^2.2.1",
|
||||
"webpack": "^5.67.0"
|
||||
"prettier": "^2.2.1"
|
||||
}
|
||||
}
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 295 KiB |
@ -2,19 +2,19 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
@ -24,7 +24,7 @@
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>React App</title>
|
||||
<title>Taskcafé</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
267
frontend/src/Admin/index.tsx
Normal file
267
frontend/src/Admin/index.tsx
Normal file
@ -0,0 +1,267 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import Admin from 'shared/components/Admin';
|
||||
import Select from 'shared/components/Select';
|
||||
import GlobalTopNavbar from 'App/TopNavbar';
|
||||
import {
|
||||
useUsersQuery,
|
||||
useDeleteUserAccountMutation,
|
||||
useDeleteInvitedUserAccountMutation,
|
||||
useCreateUserAccountMutation,
|
||||
UsersDocument,
|
||||
UsersQuery,
|
||||
} from 'shared/generated/graphql';
|
||||
import styled from 'styled-components';
|
||||
import Button from 'shared/components/Button';
|
||||
import { useForm, Controller, UseFormSetError } from 'react-hook-form';
|
||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||
import produce from 'immer';
|
||||
import updateApolloCache from 'shared/utils/cache';
|
||||
import { useCurrentUser } from 'App/context';
|
||||
import { Redirect } from 'react-router';
|
||||
import NOOP from 'shared/utils/noop';
|
||||
import ControlledInput from 'shared/components/ControlledInput';
|
||||
import FormInput from 'shared/components/FormInput';
|
||||
|
||||
const DeleteUserWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const DeleteUserDescription = styled.p`
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const DeleteUserButton = styled(Button)`
|
||||
margin-top: 6px;
|
||||
padding: 6px 12px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type DeleteUserPopupProps = {
|
||||
onDeleteUser: () => void;
|
||||
};
|
||||
|
||||
const DeleteUserPopup: React.FC<DeleteUserPopupProps> = ({ onDeleteUser }) => {
|
||||
return (
|
||||
<DeleteUserWrapper>
|
||||
<DeleteUserDescription>Deleting this user will remove all user related data.</DeleteUserDescription>
|
||||
<DeleteUserButton onClick={() => onDeleteUser()} color="danger">
|
||||
Delete user
|
||||
</DeleteUserButton>
|
||||
</DeleteUserWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
type RoleCodeOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type CreateUserData = {
|
||||
email: string;
|
||||
username: string;
|
||||
fullName: string;
|
||||
initials: string;
|
||||
password: string;
|
||||
roleCode: RoleCodeOption;
|
||||
};
|
||||
|
||||
const CreateUserForm = styled.form`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 12px;
|
||||
`;
|
||||
|
||||
const CreateUserButton = styled(Button)`
|
||||
margin-top: 8px;
|
||||
padding: 6px 12px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const AddUserInput = styled(FormInput)`
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const InputError = styled.span`
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
font-size: 12px;
|
||||
`;
|
||||
|
||||
type AddUserPopupProps = {
|
||||
onAddUser: (user: CreateUserData, setError: UseFormSetError<CreateUserData>) => void;
|
||||
};
|
||||
|
||||
const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setError,
|
||||
control,
|
||||
} = useForm<CreateUserData>();
|
||||
|
||||
const createUser = (data: CreateUserData) => {
|
||||
onAddUser(data, setError);
|
||||
};
|
||||
return (
|
||||
<CreateUserForm onSubmit={handleSubmit(createUser)}>
|
||||
<AddUserInput
|
||||
width="100%"
|
||||
label="Full Name"
|
||||
variant="alternate"
|
||||
{...register('fullName', { required: 'Full name is required' })}
|
||||
/>
|
||||
{errors.fullName && <InputError>{errors.fullName.message}</InputError>}
|
||||
<AddUserInput
|
||||
floatingLabel
|
||||
width="100%"
|
||||
label="Email"
|
||||
variant="alternate"
|
||||
{...register('email', { required: 'Email is required' })}
|
||||
/>
|
||||
{errors.email && <InputError>{errors.email.message}</InputError>}
|
||||
<Controller
|
||||
control={control}
|
||||
name="roleCode"
|
||||
rules={{ required: 'Role is required' }}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
{...field}
|
||||
label="Role"
|
||||
options={[
|
||||
{ label: 'Admin', value: 'admin' },
|
||||
{ label: 'Member', value: 'member' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.roleCode && errors.roleCode.value && <InputError>{errors.roleCode.value.message}</InputError>}
|
||||
<AddUserInput
|
||||
floatingLabel
|
||||
width="100%"
|
||||
label="Username"
|
||||
variant="alternate"
|
||||
{...register('username', { required: 'Username is required' })}
|
||||
/>
|
||||
{errors.username && <InputError>{errors.username.message}</InputError>}
|
||||
<AddUserInput
|
||||
floatingLabel
|
||||
width="100%"
|
||||
label="Initials"
|
||||
variant="alternate"
|
||||
{...register('initials', { required: 'Initials is required' })}
|
||||
/>
|
||||
{errors.initials && <InputError>{errors.initials.message}</InputError>}
|
||||
<AddUserInput
|
||||
floatingLabel
|
||||
width="100%"
|
||||
label="Password"
|
||||
variant="alternate"
|
||||
type="password"
|
||||
{...register('password', { required: 'Password is required' })}
|
||||
/>
|
||||
{errors.password && <InputError>{errors.password.message}</InputError>}
|
||||
<CreateUserButton type="submit">Create</CreateUserButton>
|
||||
</CreateUserForm>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminRoute = () => {
|
||||
useEffect(() => {
|
||||
document.title = 'Admin | Taskcafé';
|
||||
}, []);
|
||||
const { loading, data } = useUsersQuery({ fetchPolicy: 'cache-and-network' });
|
||||
const { showPopup, hidePopup } = usePopup();
|
||||
const { user } = useCurrentUser();
|
||||
const [deleteInvitedUser] = useDeleteInvitedUserAccountMutation({
|
||||
update: (client, response) => {
|
||||
updateApolloCache<UsersQuery>(client, UsersDocument, (cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
draftCache.invitedUsers = cache.invitedUsers.filter(
|
||||
(u) => u.id !== response.data?.deleteInvitedUserAccount.invitedUser.id,
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const [deleteUser] = useDeleteUserAccountMutation({
|
||||
update: (client, response) => {
|
||||
updateApolloCache<UsersQuery>(client, UsersDocument, (cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
draftCache.users = cache.users.filter((u) => u.id !== response.data?.deleteUserAccount.userAccount.id);
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const [createUser] = useCreateUserAccountMutation({
|
||||
update: (client, createData) => {
|
||||
const cacheData: any = client.readQuery({
|
||||
query: UsersDocument,
|
||||
});
|
||||
const newData = produce(cacheData, (draftState: any) => {
|
||||
draftState.users = [...draftState.users, { ...createData.data?.createUserAccount }];
|
||||
});
|
||||
|
||||
client.writeQuery({
|
||||
query: UsersDocument,
|
||||
data: {
|
||||
...newData,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
if (data && user) {
|
||||
/*
|
||||
TODO: add permision check
|
||||
if (user.roles.org !== 'admin') {
|
||||
return <Redirect to="/" />;
|
||||
}
|
||||
*/
|
||||
return (
|
||||
<>
|
||||
<GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} />
|
||||
<Admin
|
||||
initialTab={0}
|
||||
users={data.users}
|
||||
invitedUsers={data.invitedUsers}
|
||||
// canInviteUser={user.roles.org === 'admin'} TODO: add permision check
|
||||
canInviteUser
|
||||
onInviteUser={NOOP}
|
||||
onUpdateUserPassword={() => {
|
||||
hidePopup();
|
||||
}}
|
||||
onDeleteInvitedUser={(invitedUserID) => {
|
||||
deleteInvitedUser({ variables: { invitedUserID } });
|
||||
hidePopup();
|
||||
}}
|
||||
onDeleteUser={(userID, newOwnerID) => {
|
||||
deleteUser({ variables: { userID, newOwnerID } });
|
||||
hidePopup();
|
||||
}}
|
||||
onAddUser={($target) => {
|
||||
showPopup(
|
||||
$target,
|
||||
<Popup tab={0} title="Add member" onClose={() => hidePopup()}>
|
||||
<AddUserPopup
|
||||
onAddUser={(u, setError) => {
|
||||
const { roleCode, ...userData } = u;
|
||||
createUser({
|
||||
variables: { ...userData, roleCode: roleCode.value },
|
||||
})
|
||||
.then(() => hidePopup())
|
||||
.catch((e) => {
|
||||
setError('email', { type: 'validate', message: e.message });
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Popup>,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} />;
|
||||
};
|
||||
|
||||
export default AdminRoute;
|
@ -1,25 +1,26 @@
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
import { font, mixin } from 'shared/utils/styles';
|
||||
import { color, font, mixin } from 'shared/utils/styles';
|
||||
|
||||
export default createGlobalStyle`
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
min-width: 768px;
|
||||
background: #262c49;
|
||||
}
|
||||
|
||||
body {
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
color: ${color.textDarkest};
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
line-height: 1.2;
|
||||
font-size: 14px;
|
||||
${font.size(16)}
|
||||
${font.regular}
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
background: ${(props) => props.theme.colors.bg.secondary};
|
||||
}
|
||||
|
||||
button,
|
||||
@ -82,9 +83,15 @@ export default createGlobalStyle`
|
||||
select::-ms-expand {
|
||||
display: none;
|
||||
}
|
||||
select option {
|
||||
color: ${color.textDarkest};
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.4285;
|
||||
a {
|
||||
${mixin.link()}
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
@ -118,6 +125,8 @@ export default createGlobalStyle`
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
${mixin.placeholderColor(color.textLight)}
|
||||
|
||||
.picker-hidden {
|
||||
display: none;
|
||||
}
|
87
frontend/src/App/Routes.tsx
Normal file
87
frontend/src/App/Routes.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Switch, Route, useHistory, useLocation, Redirect } from 'react-router-dom';
|
||||
import * as H from 'history';
|
||||
|
||||
import Dashboard from 'Dashboard';
|
||||
import Admin from 'Admin';
|
||||
import MyTasks from 'MyTasks';
|
||||
import Confirm from 'Confirm';
|
||||
import Projects from 'Projects';
|
||||
import Project from 'Projects/Project';
|
||||
import Teams from 'Teams';
|
||||
import Login from 'Auth';
|
||||
import Register from 'Register';
|
||||
import Profile from 'Profile';
|
||||
import styled from 'styled-components';
|
||||
import { useCurrentUser } from 'App/context';
|
||||
|
||||
const MainContent = styled.div`
|
||||
padding: 0 0 0 0;
|
||||
background: #262c49;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
type ValidateTokenResponse = {
|
||||
valid: boolean;
|
||||
userID: string;
|
||||
};
|
||||
|
||||
const UserRequiredRoute: React.FC<any> = ({ children }) => {
|
||||
const { user } = useCurrentUser();
|
||||
const location = useLocation();
|
||||
if (user) {
|
||||
return children;
|
||||
}
|
||||
return (
|
||||
<Redirect
|
||||
to={{
|
||||
pathname: '/login',
|
||||
state: { redirect: location.pathname },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Routes: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { setUser } = useCurrentUser();
|
||||
useEffect(() => {
|
||||
fetch('/auth/validate', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}).then(async (x) => {
|
||||
const response: ValidateTokenResponse = await x.json();
|
||||
const { valid, userID } = response;
|
||||
if (valid) {
|
||||
setUser(userID);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
if (loading) return null;
|
||||
return (
|
||||
<Switch>
|
||||
<Route exact path="/login" component={Login} />
|
||||
<Route exact path="/register" component={Register} />
|
||||
<Route exact path="/confirm" component={Confirm} />
|
||||
<Switch>
|
||||
<MainContent>
|
||||
<Route path="/p/:projectID" component={Project} />
|
||||
|
||||
<UserRequiredRoute>
|
||||
<Route exact path="/" component={Projects} />
|
||||
<Route path="/teams/:teamID" component={Teams} />
|
||||
<Route path="/profile" component={Profile} />
|
||||
<Route path="/admin" component={Admin} />
|
||||
<Route path="/tasks" component={MyTasks} />
|
||||
</UserRequiredRoute>
|
||||
</MainContent>
|
||||
</Switch>
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
|
||||
export default Routes;
|
30
frontend/src/App/ThemeStyles.ts
Normal file
30
frontend/src/App/ThemeStyles.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { DefaultTheme } from 'styled-components';
|
||||
import Color from 'color';
|
||||
|
||||
const theme: DefaultTheme = {
|
||||
borderRadius: {
|
||||
primary: '3x',
|
||||
alternate: '6px',
|
||||
},
|
||||
colors: {
|
||||
multiColors: ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f'],
|
||||
primary: 'rgb(115, 103, 240)',
|
||||
secondary: 'rgb(216, 93, 216)',
|
||||
alternate: 'rgb(65, 69, 97)',
|
||||
success: 'rgb(40, 199, 111)',
|
||||
danger: 'rgb(234, 84, 85)',
|
||||
warning: 'rgb(255, 159, 67)',
|
||||
dark: 'rgb(30, 30, 30)',
|
||||
text: {
|
||||
primary: 'rgb(194, 198, 220)',
|
||||
secondary: 'rgb(255, 255, 255)',
|
||||
},
|
||||
border: 'rgb(65, 69, 97)',
|
||||
bg: {
|
||||
primary: 'rgb(16, 22, 58)',
|
||||
secondary: 'rgb(38, 44, 73)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default theme;
|
35
frontend/src/App/Toast.ts
Normal file
35
frontend/src/App/Toast.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import styled from 'styled-components';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
|
||||
const ToastedContainer = styled(ToastContainer).attrs({
|
||||
// custom props
|
||||
})`
|
||||
.Toastify__toast-container {
|
||||
}
|
||||
.Toastify__toast {
|
||||
padding: 5px;
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
border-radius: 10px;
|
||||
background: #7367f0;
|
||||
color: #fff;
|
||||
}
|
||||
.Toastify__toast--error {
|
||||
background: ${props => props.theme.colors.danger};
|
||||
}
|
||||
.Toastify__toast--warning {
|
||||
background: ${props => props.theme.colors.warning};
|
||||
}
|
||||
.Toastify__toast--success {
|
||||
background: ${props => props.theme.colors.success};
|
||||
}
|
||||
.Toastify__toast-body {
|
||||
}
|
||||
.Toastify__progress-bar {
|
||||
}
|
||||
.Toastify__close-button {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default ToastedContainer;
|
237
frontend/src/App/TopNavbar/ProjectFinder.tsx
Normal file
237
frontend/src/App/TopNavbar/ProjectFinder.tsx
Normal file
@ -0,0 +1,237 @@
|
||||
import React, { useState } from 'react';
|
||||
import styled from 'styled-components/macro';
|
||||
import { useGetProjectsQuery } from 'shared/generated/graphql';
|
||||
import { Link } from 'react-router-dom';
|
||||
import LoadingSpinner from 'shared/components/LoadingSpinner';
|
||||
import ControlledInput from 'shared/components/ControlledInput';
|
||||
import { CaretDown, CaretRight } from 'shared/icons';
|
||||
import useStickyState from 'shared/hooks/useStickyState';
|
||||
import { usePopup } from 'shared/components/PopupMenu';
|
||||
|
||||
const TeamContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 4px;
|
||||
`;
|
||||
|
||||
const TeamTitle = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const TeamTitleText = styled.span`
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const TeamProjects = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 4px;
|
||||
`;
|
||||
|
||||
const TeamProjectLink = styled(Link)`
|
||||
display: flex;
|
||||
font-weight: 700;
|
||||
height: 36px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const TeamProjectBackground = styled.div<{ idx: number }>`
|
||||
background-image: url(null);
|
||||
background-color: ${props => props.theme.colors.multiColors[props.idx % 5]};
|
||||
|
||||
background-size: cover;
|
||||
background-position: 50%;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
opacity: 1;
|
||||
border-radius: 3px;
|
||||
&:before {
|
||||
background: ${props => props.theme.colors.bg.secondary};
|
||||
bottom: 0;
|
||||
content: '';
|
||||
left: 0;
|
||||
opacity: 0.88;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
`;
|
||||
const Empty = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const TeamProjectAvatar = styled.div<{ idx: number }>`
|
||||
background-image: url(null);
|
||||
background-color: ${props => props.theme.colors.multiColors[props.idx % 5]};
|
||||
|
||||
display: inline-block;
|
||||
flex: 0 0 auto;
|
||||
background-size: cover;
|
||||
border-radius: 3px 0 0 3px;
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
position: relative;
|
||||
opacity: 0.7;
|
||||
`;
|
||||
|
||||
const TeamProjectContent = styled.div`
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding: 9px 0 9px 10px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const TeamProjectTitle = styled.div`
|
||||
font-weight: 700;
|
||||
display: block;
|
||||
padding-right: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const TeamProjectContainer = styled.div`
|
||||
box-sizing: border-box;
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
margin: 0 4px 4px 0;
|
||||
min-width: 0;
|
||||
&:hover ${TeamProjectTitle} {
|
||||
color: ${props => props.theme.colors.text.secondary};
|
||||
}
|
||||
&:hover ${TeamProjectAvatar} {
|
||||
opacity: 1;
|
||||
}
|
||||
&:hover ${TeamProjectBackground}:before {
|
||||
opacity: 0.78;
|
||||
}
|
||||
`;
|
||||
const Search = styled(ControlledInput)`
|
||||
margin: 0 4px 4px 4px;
|
||||
& input {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
const Minify = styled.div`
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
min-width: 28px;
|
||||
width: 28px;
|
||||
border-radius: 6px;
|
||||
user-select: none;
|
||||
|
||||
margin-right: 4px;
|
||||
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
transition-duration: 0.2s;
|
||||
transition-property: background, border, box-shadow, fill;
|
||||
cursor: pointer;
|
||||
svg {
|
||||
fill: ${props => props.theme.colors.text.primary};
|
||||
transition-duration: 0.2s;
|
||||
transition-property: background, border, box-shadow, fill;
|
||||
}
|
||||
|
||||
&:hover svg {
|
||||
fill: ${props => props.theme.colors.text.secondary};
|
||||
}
|
||||
`;
|
||||
|
||||
const ProjectFinder = () => {
|
||||
const { data } = useGetProjectsQuery({ fetchPolicy: 'cache-and-network' });
|
||||
const [search, setSearch] = useState('');
|
||||
const [minified, setMinified] = useStickyState<Array<string>>([], 'project_finder_minified');
|
||||
const { hidePopup } = usePopup();
|
||||
if (data) {
|
||||
const { teams } = data;
|
||||
const projects = data.projects.filter(p => {
|
||||
if (search.trim() === '') return true;
|
||||
return p.name.toLowerCase().startsWith(search.trim().toLowerCase());
|
||||
});
|
||||
const personalProjects = projects.filter(p => p.team === null);
|
||||
const projectTeams = [
|
||||
{ id: 'personal', name: 'Personal', projects: personalProjects.sort((a, b) => a.name.localeCompare(b.name)) },
|
||||
...teams.map(team => {
|
||||
return {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
projects: projects
|
||||
.filter(project => project.team && project.team.id === team.id)
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
};
|
||||
}),
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<Search
|
||||
autoFocus
|
||||
variant="alternate"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.currentTarget.value)}
|
||||
placeholder="Find projects by name..."
|
||||
/>
|
||||
{projectTeams.map(team => {
|
||||
const isMinified = minified.find(m => m === team.id);
|
||||
if (team.projects.length === 0) return null;
|
||||
return (
|
||||
<TeamContainer key={team.id}>
|
||||
<TeamTitle>
|
||||
<TeamTitleText>{team.name}</TeamTitleText>
|
||||
{isMinified ? (
|
||||
<Minify onClick={() => setMinified(prev => prev.filter(m => m !== team.id))}>
|
||||
<CaretRight width={16} height={16} />
|
||||
</Minify>
|
||||
) : (
|
||||
<Minify onClick={() => setMinified(prev => [...prev, team.id])}>
|
||||
<CaretDown width={16} height={16} />
|
||||
</Minify>
|
||||
)}
|
||||
</TeamTitle>
|
||||
{!isMinified && (
|
||||
<TeamProjects>
|
||||
{team.projects.map((project, idx) => (
|
||||
<TeamProjectContainer key={project.id}>
|
||||
<TeamProjectLink onClick={e => hidePopup()} to={`/projects/${project.id}`}>
|
||||
<TeamProjectBackground idx={idx} />
|
||||
<TeamProjectAvatar idx={idx} />
|
||||
<TeamProjectContent>
|
||||
<TeamProjectTitle>{project.name}</TeamProjectTitle>
|
||||
</TeamProjectContent>
|
||||
</TeamProjectLink>
|
||||
</TeamProjectContainer>
|
||||
))}
|
||||
</TeamProjects>
|
||||
)}
|
||||
</TeamContainer>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Empty>
|
||||
<LoadingSpinner />
|
||||
</Empty>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectFinder;
|
89
frontend/src/App/TopNavbar/ProjectPopup.tsx
Normal file
89
frontend/src/App/TopNavbar/ProjectPopup.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import React, { useState } from 'react';
|
||||
import ProjectSettings, { DeleteConfirm, DELETE_INFO, PublicConfirm } from 'shared/components/ProjectSettings';
|
||||
import {
|
||||
useDeleteProjectMutation,
|
||||
GetProjectsDocument,
|
||||
useToggleProjectVisibilityMutation,
|
||||
} from 'shared/generated/graphql';
|
||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||
import produce from 'immer';
|
||||
|
||||
type ProjectPopupProps = {
|
||||
history: any;
|
||||
name: string;
|
||||
publicOn: string | null;
|
||||
projectID: string;
|
||||
};
|
||||
|
||||
const ProjectPopup: React.FC<ProjectPopupProps> = ({ history, name, projectID, publicOn: initialPublicOn }) => {
|
||||
const { hidePopup, setTab } = usePopup();
|
||||
const [publicOn, setPublicOn] = useState(initialPublicOn);
|
||||
const [toggleProjectVisibility] = useToggleProjectVisibilityMutation({
|
||||
onCompleted: data => {
|
||||
setPublicOn(data.toggleProjectVisibility.project.publicOn);
|
||||
},
|
||||
});
|
||||
const [deleteProject] = useDeleteProjectMutation({
|
||||
update: (client, deleteData) => {
|
||||
const cacheData: any = client.readQuery({
|
||||
query: GetProjectsDocument,
|
||||
});
|
||||
|
||||
const newData = produce(cacheData, (draftState: any) => {
|
||||
draftState.projects = draftState.projects.filter(
|
||||
(project: any) => project.id !== deleteData.data?.deleteProject.project.id,
|
||||
);
|
||||
});
|
||||
|
||||
client.writeQuery({
|
||||
query: GetProjectsDocument,
|
||||
data: {
|
||||
...newData,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Popup title={null} tab={0}>
|
||||
<ProjectSettings
|
||||
publicOn={publicOn}
|
||||
onToggleProjectVisible={visible => {
|
||||
if (visible) {
|
||||
setTab(2, { width: 300 });
|
||||
} else {
|
||||
toggleProjectVisibility({ variables: { projectID, isPublic: false } });
|
||||
}
|
||||
}}
|
||||
onDeleteProject={() => {
|
||||
setTab(1, { width: 300 });
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
<Popup title="Change to public?" tab={1}>
|
||||
<PublicConfirm
|
||||
onConfirm={() => {
|
||||
if (projectID) {
|
||||
toggleProjectVisibility({ variables: { projectID, isPublic: true } });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
<Popup title={`Delete the "${name}" project?`} tab={1}>
|
||||
<DeleteConfirm
|
||||
description={DELETE_INFO.DELETE_PROJECTS.description}
|
||||
deletedItems={DELETE_INFO.DELETE_PROJECTS.deletedItems}
|
||||
onConfirmDelete={() => {
|
||||
if (projectID) {
|
||||
deleteProject({ variables: { projectID } });
|
||||
hidePopup();
|
||||
history.push('/projects');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectPopup;
|
278
frontend/src/App/TopNavbar/index.tsx
Normal file
278
frontend/src/App/TopNavbar/index.tsx
Normal file
@ -0,0 +1,278 @@
|
||||
import React, { useState } from 'react';
|
||||
import TopNavbar, { MenuItem } from 'shared/components/TopNavbar';
|
||||
import LoggedOutNavbar from 'shared/components/TopNavbar/LoggedOut';
|
||||
import { ProfileMenu } from 'shared/components/DropdownMenu';
|
||||
import polling from 'shared/utils/polling';
|
||||
import { useHistory, useRouteMatch } from 'react-router';
|
||||
import { useCurrentUser } from 'App/context';
|
||||
import {
|
||||
RoleCode,
|
||||
useTopNavbarQuery,
|
||||
useNotificationAddedSubscription,
|
||||
useHasUnreadNotificationsQuery,
|
||||
} from 'shared/generated/graphql';
|
||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||
import MiniProfile from 'shared/components/MiniProfile';
|
||||
import cache from 'App/cache';
|
||||
import NotificationPopup, { NotificationItem } from 'shared/components/NotifcationPopup';
|
||||
import theme from 'App/ThemeStyles';
|
||||
import ProjectFinder from './ProjectFinder';
|
||||
|
||||
// TODO: Move to context based navbar?
|
||||
|
||||
type GlobalTopNavbarProps = {
|
||||
nameOnly?: boolean;
|
||||
projectID: string | null;
|
||||
teamID?: string | null;
|
||||
onChangeProjectOwner?: (userID: string) => void;
|
||||
name: string | null;
|
||||
currentTab?: number;
|
||||
popupContent?: JSX.Element;
|
||||
menuType?: Array<MenuItem>;
|
||||
onChangeRole?: (userID: string, roleCode: RoleCode) => void;
|
||||
projectMembers?: null | Array<TaskUser>;
|
||||
projectInvitedMembers?: null | Array<InvitedUser>;
|
||||
onSaveProjectName?: (projectName: string) => void;
|
||||
onInviteUser?: ($target: React.RefObject<HTMLElement>) => void;
|
||||
onSetTab?: (tab: number) => void;
|
||||
onRemoveFromBoard?: (userID: string) => void;
|
||||
onRemoveInvitedFromBoard?: (email: string) => void;
|
||||
};
|
||||
|
||||
const LoggedInNavbar: React.FC<GlobalTopNavbarProps> = ({
|
||||
currentTab,
|
||||
onSetTab,
|
||||
menuType,
|
||||
teamID,
|
||||
onChangeProjectOwner,
|
||||
onChangeRole,
|
||||
name,
|
||||
popupContent,
|
||||
projectMembers,
|
||||
projectInvitedMembers,
|
||||
onInviteUser,
|
||||
onSaveProjectName,
|
||||
onRemoveInvitedFromBoard,
|
||||
onRemoveFromBoard,
|
||||
}) => {
|
||||
const [notifications, setNotifications] = useState<Array<{ id: string; notification: { actionType: string } }>>([]);
|
||||
const { data } = useTopNavbarQuery({
|
||||
onCompleted: (d) => {
|
||||
setNotifications((n) => [...n, ...d.notifications]);
|
||||
},
|
||||
});
|
||||
const { data: nData, loading } = useNotificationAddedSubscription({
|
||||
onSubscriptionData: (d) => {
|
||||
setNotifications((n) => {
|
||||
if (d.subscriptionData.data) {
|
||||
return [...n, d.subscriptionData.data.notificationAdded];
|
||||
}
|
||||
return n;
|
||||
});
|
||||
},
|
||||
});
|
||||
const { showPopup, hidePopup } = usePopup();
|
||||
const { setUser } = useCurrentUser();
|
||||
const { data: unreadData, refetch: refetchHasUnread } = useHasUnreadNotificationsQuery({
|
||||
pollInterval: polling.UNREAD_NOTIFICATIONS,
|
||||
});
|
||||
const history = useHistory();
|
||||
const onLogout = () => {
|
||||
fetch('/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}).then(async (x) => {
|
||||
const { status } = x;
|
||||
if (status === 200) {
|
||||
cache.reset();
|
||||
history.replace('/login');
|
||||
setUser(null);
|
||||
hidePopup();
|
||||
}
|
||||
});
|
||||
};
|
||||
const onProfileClick = ($target: React.RefObject<HTMLElement>) => {
|
||||
showPopup(
|
||||
$target,
|
||||
<Popup title={null} tab={0}>
|
||||
<ProfileMenu
|
||||
onLogout={onLogout}
|
||||
showAdminConsole // TODO: add permision check
|
||||
onAdminConsole={() => {
|
||||
history.push('/admin');
|
||||
hidePopup();
|
||||
}}
|
||||
onProfile={() => {
|
||||
history.push('/profile');
|
||||
hidePopup();
|
||||
}}
|
||||
/>
|
||||
</Popup>,
|
||||
{ width: 195 },
|
||||
);
|
||||
};
|
||||
|
||||
const onOpenSettings = ($target: React.RefObject<HTMLElement>) => {
|
||||
if (popupContent) {
|
||||
showPopup($target, popupContent, { width: 185 });
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: rewrite popup to contain subscription and notification fetch
|
||||
const onNotificationClick = ($target: React.RefObject<HTMLElement>) => {
|
||||
showPopup($target, <NotificationPopup onToggleRead={() => refetchHasUnread()} />, {
|
||||
width: 605,
|
||||
borders: false,
|
||||
diamondColor: theme.colors.primary,
|
||||
});
|
||||
};
|
||||
|
||||
// TODO: readd permision check
|
||||
// const userIsTeamOrProjectAdmin = user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID);
|
||||
const userIsTeamOrProjectAdmin = true;
|
||||
const onInvitedMemberProfile = ($targetRef: React.RefObject<HTMLElement>, email: string) => {
|
||||
const member = projectInvitedMembers ? projectInvitedMembers.find((u) => u.email === email) : null;
|
||||
if (member) {
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<MiniProfile
|
||||
onRemoveFromBoard={() => {
|
||||
if (onRemoveInvitedFromBoard) {
|
||||
onRemoveInvitedFromBoard(member.email);
|
||||
}
|
||||
}}
|
||||
invited
|
||||
user={{
|
||||
id: member.email,
|
||||
fullName: member.email,
|
||||
bio: 'Invited',
|
||||
profileIcon: {
|
||||
bgColor: '#000',
|
||||
url: null,
|
||||
initials: member.email.charAt(0),
|
||||
},
|
||||
}}
|
||||
bio=""
|
||||
/>,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onMemberProfile = ($targetRef: React.RefObject<HTMLElement>, memberID: string) => {
|
||||
const member = projectMembers ? projectMembers.find((u) => u.id === memberID) : null;
|
||||
const warning =
|
||||
'You can’t leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
|
||||
if (member) {
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<MiniProfile
|
||||
warning={member.role && member.role.code === 'owner' ? warning : null}
|
||||
canChangeRole={userIsTeamOrProjectAdmin}
|
||||
onChangeRole={(roleCode) => {
|
||||
if (onChangeRole) {
|
||||
onChangeRole(member.id, roleCode);
|
||||
}
|
||||
}}
|
||||
onRemoveFromBoard={
|
||||
member.role && member.role.code === 'owner'
|
||||
? undefined
|
||||
: () => {
|
||||
if (onRemoveFromBoard) {
|
||||
onRemoveFromBoard(member.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
user={member}
|
||||
bio=""
|
||||
/>,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const user = data ? data.me?.user : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopNavbar
|
||||
hasUnread={unreadData ? unreadData.hasUnreadNotifications.unread : false}
|
||||
name={name}
|
||||
menuType={menuType}
|
||||
onOpenProjectFinder={($target) => {
|
||||
showPopup(
|
||||
$target,
|
||||
<Popup tab={0} title={null}>
|
||||
<ProjectFinder />
|
||||
</Popup>,
|
||||
);
|
||||
}}
|
||||
currentTab={currentTab}
|
||||
user={user ?? null}
|
||||
canEditProjectName={userIsTeamOrProjectAdmin}
|
||||
canInviteUser={userIsTeamOrProjectAdmin}
|
||||
onMemberProfile={onMemberProfile}
|
||||
onInvitedMemberProfile={onInvitedMemberProfile}
|
||||
onInviteUser={onInviteUser}
|
||||
onChangeRole={onChangeRole}
|
||||
onChangeProjectOwner={onChangeProjectOwner}
|
||||
onNotificationClick={onNotificationClick}
|
||||
onSetTab={onSetTab}
|
||||
onRemoveFromBoard={onRemoveFromBoard}
|
||||
onDashboardClick={() => {
|
||||
history.push('/');
|
||||
}}
|
||||
onMyTasksClick={() => {
|
||||
history.push('/tasks');
|
||||
}}
|
||||
projectMembers={projectMembers}
|
||||
projectInvitedMembers={projectInvitedMembers}
|
||||
onProfileClick={onProfileClick}
|
||||
onSaveName={onSaveProjectName}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
|
||||
currentTab,
|
||||
onSetTab,
|
||||
menuType,
|
||||
teamID,
|
||||
onChangeProjectOwner,
|
||||
onChangeRole,
|
||||
name,
|
||||
popupContent,
|
||||
projectMembers,
|
||||
projectInvitedMembers,
|
||||
onInviteUser,
|
||||
onSaveProjectName,
|
||||
onRemoveInvitedFromBoard,
|
||||
onRemoveFromBoard,
|
||||
}) => {
|
||||
const { user } = useCurrentUser();
|
||||
const match = useRouteMatch();
|
||||
if (user) {
|
||||
return (
|
||||
<LoggedInNavbar
|
||||
currentTab={currentTab}
|
||||
projectID={null}
|
||||
onSetTab={onSetTab}
|
||||
menuType={menuType}
|
||||
teamID={teamID}
|
||||
onChangeRole={onChangeRole}
|
||||
onChangeProjectOwner={onChangeProjectOwner}
|
||||
name={name}
|
||||
popupContent={popupContent}
|
||||
projectMembers={projectMembers}
|
||||
projectInvitedMembers={projectInvitedMembers}
|
||||
onInviteUser={onInviteUser}
|
||||
onSaveProjectName={onSaveProjectName}
|
||||
onRemoveInvitedFromBoard={onRemoveInvitedFromBoard}
|
||||
onRemoveFromBoard={onRemoveFromBoard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <LoggedOutNavbar match={match.url} name={name} menuType={menuType} />;
|
||||
};
|
||||
|
||||
export default GlobalTopNavbar;
|
5
frontend/src/App/cache.ts
Normal file
5
frontend/src/App/cache.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { InMemoryCache } from '@apollo/client';
|
||||
|
||||
const cache = new InMemoryCache();
|
||||
|
||||
export default cache;
|
21
frontend/src/App/context.ts
Normal file
21
frontend/src/App/context.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import React, { useContext } from 'react';
|
||||
|
||||
type UserContextState = {
|
||||
user: string | null;
|
||||
setUser: (user: string | null) => void;
|
||||
};
|
||||
|
||||
export const UserContext = React.createContext<UserContextState>({
|
||||
user: null,
|
||||
setUser: _user => null,
|
||||
});
|
||||
|
||||
export const useCurrentUser = () => {
|
||||
const { user, setUser } = useContext(UserContext);
|
||||
return {
|
||||
user,
|
||||
setUser,
|
||||
};
|
||||
};
|
||||
|
||||
export default UserContext;
|
6
frontend/src/App/fonts.css
Normal file
6
frontend/src/App/fonts.css
Normal file
@ -0,0 +1,6 @@
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
src: url(../shared/fonts/OpenSans-Regular.ttf) format('truetype');
|
||||
/* other formats include: 'woff2', 'truetype, 'opentype',
|
||||
'embedded-opentype', and 'svg' */
|
||||
}
|
47
frontend/src/App/index.tsx
Normal file
47
frontend/src/App/index.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { PopupProvider } from 'shared/components/PopupMenu';
|
||||
import styled, { ThemeProvider } from 'styled-components';
|
||||
import NormalizeStyles from './NormalizeStyles';
|
||||
import BaseStyles from './BaseStyles';
|
||||
import theme from './ThemeStyles';
|
||||
import Routes from './Routes';
|
||||
import ToastedContainer from './Toast';
|
||||
import { UserContext } from './context';
|
||||
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import './fonts.css';
|
||||
|
||||
const App = () => {
|
||||
const [user, setUser] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UserContext.Provider value={{ user, setUser }}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
<BrowserRouter>
|
||||
<PopupProvider>
|
||||
<Routes />
|
||||
</PopupProvider>
|
||||
</BrowserRouter>
|
||||
<ToastedContainer
|
||||
position="bottom-right"
|
||||
autoClose={5000}
|
||||
hideProgressBar
|
||||
newestOnTop
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss
|
||||
draggable
|
||||
pauseOnHover
|
||||
limit={5}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</UserContext.Provider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
23
frontend/src/Auth/Styles.ts
Normal file
23
frontend/src/Auth/Styles.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
position: relative;
|
||||
top: 30%;
|
||||
font-size: 150px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const LoginWrapper = styled.div`
|
||||
width: 70%;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
width: 90%;
|
||||
margin-top: 50vh;
|
||||
}
|
||||
`;
|
66
frontend/src/Auth/index.tsx
Normal file
66
frontend/src/Auth/index.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router';
|
||||
import Login from 'shared/components/Login';
|
||||
import UserContext from 'App/context';
|
||||
import { Container, LoginWrapper } from './Styles';
|
||||
|
||||
const Auth = () => {
|
||||
const [invalidLoginAttempt, setInvalidLoginAttempt] = useState(0);
|
||||
const history = useHistory();
|
||||
const location = useLocation<{ redirect: string } | undefined>();
|
||||
const { setUser } = useContext(UserContext);
|
||||
const login = (
|
||||
data: LoginFormData,
|
||||
setComplete: (val: boolean) => void,
|
||||
setError: (name: 'username' | 'password', error: ErrorOption) => void,
|
||||
) => {
|
||||
fetch('/auth/login', {
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
}),
|
||||
}).then(async (x) => {
|
||||
if (x.status === 401) {
|
||||
setInvalidLoginAttempt(invalidLoginAttempt + 1);
|
||||
setError('username', { type: 'error', message: 'Invalid username' });
|
||||
setError('password', { type: 'error', message: 'Invalid password' });
|
||||
setComplete(true);
|
||||
} else {
|
||||
const response = await x.json();
|
||||
const { userID } = response;
|
||||
setUser(userID);
|
||||
if (location.state && location.state.redirect) {
|
||||
history.push(location.state.redirect);
|
||||
} else {
|
||||
history.push('/');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/auth/validate', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}).then(async (x) => {
|
||||
const response = await x.json();
|
||||
const { valid, userID } = response;
|
||||
if (valid) {
|
||||
setUser(userID);
|
||||
history.replace('/projects');
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<LoginWrapper>
|
||||
<Login onSubmit={login} />
|
||||
</LoginWrapper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Auth;
|
13
frontend/src/Confirm/Styles.ts
Normal file
13
frontend/src/Confirm/Styles.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
`;
|
||||
|
||||
export const LoginWrapper = styled.div`
|
||||
width: 60%;
|
||||
`;
|
45
frontend/src/Confirm/index.tsx
Normal file
45
frontend/src/Confirm/index.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Confirm from 'shared/components/Confirm';
|
||||
import { useHistory, useLocation } from 'react-router';
|
||||
import * as QueryString from 'query-string';
|
||||
import { useCurrentUser } from 'App/context';
|
||||
import { Container, LoginWrapper } from './Styles';
|
||||
|
||||
const UsersConfirm = () => {
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const params = QueryString.parse(location.search);
|
||||
const [hasFailed, setFailed] = useState(false);
|
||||
const { setUser } = useCurrentUser();
|
||||
useEffect(() => {
|
||||
fetch('/auth/confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
confirmToken: params.confirmToken,
|
||||
}),
|
||||
})
|
||||
.then(async (x) => {
|
||||
const { status } = x;
|
||||
if (status === 200) {
|
||||
const response = await x.json();
|
||||
const { userID } = response;
|
||||
setUser(userID);
|
||||
history.push('/');
|
||||
} else {
|
||||
setFailed(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setFailed(false);
|
||||
});
|
||||
}, []);
|
||||
return (
|
||||
<Container>
|
||||
<LoginWrapper>
|
||||
<Confirm hasConfirmToken={params.confirmToken !== undefined} hasFailed={hasFailed} />
|
||||
</LoginWrapper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersConfirm;
|
8
frontend/src/Dashboard/index.tsx
Normal file
8
frontend/src/Dashboard/index.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Redirect } from 'react-router';
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
return <Redirect to="/projects" />;
|
||||
};
|
||||
|
||||
export default Dashboard;
|
145
frontend/src/MyTasks/MyTasksSort.tsx
Normal file
145
frontend/src/MyTasks/MyTasksSort.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import styled, { css } from 'styled-components';
|
||||
import { Checkmark, User, Calendar, Tags, Clock } from 'shared/icons';
|
||||
import { TaskMetaFilters, TaskMeta, TaskMetaMatch, DueDateFilterType } from 'shared/components/Lists';
|
||||
import Input from 'shared/components/ControlledInput';
|
||||
import { Popup, usePopup } from 'shared/components/PopupMenu';
|
||||
import produce from 'immer';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
import Member from 'shared/components/Member';
|
||||
import { MyTasksSort } from 'shared/generated/graphql';
|
||||
|
||||
const FilterMember = styled(Member)`
|
||||
margin: 2px 0;
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
background: ${props => props.theme.colors.primary};
|
||||
}
|
||||
`;
|
||||
|
||||
export const Labels = styled.ul`
|
||||
list-style: none;
|
||||
margin: 0 8px;
|
||||
padding: 0;
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
export const Label = styled.li`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export const CardLabel = styled.span<{ active: boolean; color: string }>`
|
||||
${props =>
|
||||
props.active &&
|
||||
css`
|
||||
margin-left: 4px;
|
||||
box-shadow: -8px 0 ${mixin.darken(props.color, 0.12)};
|
||||
border-radius: 3px;
|
||||
`}
|
||||
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
margin: 0 0 4px;
|
||||
min-height: 20px;
|
||||
padding: 6px 12px;
|
||||
position: relative;
|
||||
transition: padding 85ms, margin 85ms, box-shadow 85ms;
|
||||
background-color: ${props => props.color};
|
||||
color: #fff;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-height: 31px;
|
||||
`;
|
||||
|
||||
export const ActionsList = styled.ul`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
export const ActionItem = styled.li`
|
||||
position: relative;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
&:hover {
|
||||
background: ${props => props.theme.colors.primary};
|
||||
}
|
||||
`;
|
||||
|
||||
export const ActionTitle = styled.span`
|
||||
margin-left: 20px;
|
||||
`;
|
||||
|
||||
const ActionItemSeparator = styled.li`
|
||||
color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.4)};
|
||||
font-size: 12px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.25rem;
|
||||
`;
|
||||
|
||||
const ActiveIcon = styled(Checkmark)`
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
`;
|
||||
|
||||
const ItemIcon = styled.div`
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
const TaskNameInput = styled(Input)`
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const ActionItemLine = styled.div`
|
||||
height: 1px;
|
||||
border-top: 1px solid #414561;
|
||||
margin: 0.25rem !important;
|
||||
`;
|
||||
|
||||
type MyTasksSortProps = {
|
||||
sort: MyTasksSort;
|
||||
onChangeSort: (sort: MyTasksSort) => void;
|
||||
};
|
||||
|
||||
const MyTasksSortPopup: React.FC<MyTasksSortProps> = ({ sort: initialSort, onChangeSort }) => {
|
||||
const [sort, setSort] = useState(initialSort);
|
||||
const handleChangeSort = (f: MyTasksSort) => {
|
||||
setSort(f);
|
||||
onChangeSort(f);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup tab={0} title={null}>
|
||||
<ActionsList>
|
||||
<ActionItem onClick={() => handleChangeSort(MyTasksSort.None)}>
|
||||
{sort === MyTasksSort.None && <ActiveIcon width={16} height={16} />}
|
||||
<ActionTitle>None</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => handleChangeSort(MyTasksSort.Project)}>
|
||||
{sort === MyTasksSort.Project && <ActiveIcon width={16} height={16} />}
|
||||
<ActionTitle>Project</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => handleChangeSort(MyTasksSort.DueDate)}>
|
||||
{sort === MyTasksSort.DueDate && <ActiveIcon width={16} height={16} />}
|
||||
<ActionTitle>Due Date</ActionTitle>
|
||||
</ActionItem>
|
||||
</ActionsList>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyTasksSortPopup;
|
151
frontend/src/MyTasks/MyTasksStatus.tsx
Normal file
151
frontend/src/MyTasks/MyTasksStatus.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
import React, { useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { Checkmark } from 'shared/icons';
|
||||
import { TaskStatusFilter, TaskStatus, TaskSince } from 'shared/components/Lists';
|
||||
import { MyTasksStatus } from 'shared/generated/graphql';
|
||||
import { Popup } from 'shared/components/PopupMenu';
|
||||
|
||||
export const ActionsList = styled.ul`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
export const ActionExtraMenuContainer = styled.div`
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: -4px;
|
||||
padding-left: 2px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const ActionItem = styled.li`
|
||||
position: relative;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
&:hover {
|
||||
background: ${props => props.theme.colors.primary};
|
||||
}
|
||||
&:hover ${ActionExtraMenuContainer} {
|
||||
visibility: visible;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ActionTitle = styled.span`
|
||||
margin-left: 20px;
|
||||
`;
|
||||
|
||||
export const ActionExtraMenu = styled.ul`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
padding: 5px;
|
||||
padding-top: 8px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 5px 25px 0 rgba(0, 0, 0, 0.1);
|
||||
|
||||
color: #c2c6dc;
|
||||
background: #262c49;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-color: #414561;
|
||||
`;
|
||||
|
||||
export const ActionExtraMenuItem = styled.li`
|
||||
position: relative;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
&:hover {
|
||||
background: ${props => props.theme.colors.primary};
|
||||
}
|
||||
`;
|
||||
const ActionExtraMenuSeparator = styled.li`
|
||||
color: ${props => props.theme.colors.text.primary};
|
||||
font-size: 12px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
`;
|
||||
|
||||
const ActiveIcon = styled(Checkmark)`
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
type MyTasksStatusProps = {
|
||||
status: MyTasksStatus;
|
||||
onChangeStatus: (status: MyTasksStatus) => void;
|
||||
};
|
||||
|
||||
const MyTasksStatusPopup: React.FC<MyTasksStatusProps> = ({ status: initialStatus, onChangeStatus }) => {
|
||||
const [status, setStatus] = useState(initialStatus);
|
||||
const handleStatusChange = (f: MyTasksStatus) => {
|
||||
setStatus(f);
|
||||
onChangeStatus(f);
|
||||
};
|
||||
return (
|
||||
<Popup tab={0} title={null}>
|
||||
<ActionsList>
|
||||
<ActionItem onClick={() => handleStatusChange(MyTasksStatus.Incomplete)}>
|
||||
{status === MyTasksStatus.Incomplete && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>Incomplete Tasks</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem>
|
||||
{status !== MyTasksStatus.Incomplete && status !== MyTasksStatus.All && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>Compelete Tasks</ActionTitle>
|
||||
<ActionExtraMenuContainer>
|
||||
<ActionExtraMenu>
|
||||
<ActionExtraMenuItem onClick={() => handleStatusChange(MyTasksStatus.CompleteAll)}>
|
||||
{status === MyTasksStatus.CompleteAll && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>All completed tasks</ActionTitle>
|
||||
</ActionExtraMenuItem>
|
||||
<ActionExtraMenuSeparator>Marked complete since</ActionExtraMenuSeparator>
|
||||
<ActionExtraMenuItem onClick={() => handleStatusChange(MyTasksStatus.CompleteToday)}>
|
||||
{status === MyTasksStatus.CompleteToday && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>Today</ActionTitle>
|
||||
</ActionExtraMenuItem>
|
||||
<ActionExtraMenuItem onClick={() => handleStatusChange(MyTasksStatus.CompleteYesterday)}>
|
||||
{status === MyTasksStatus.CompleteYesterday && <ActiveIcon width={12} height={12} />}
|
||||
|
||||
<ActionTitle>Yesterday</ActionTitle>
|
||||
</ActionExtraMenuItem>
|
||||
<ActionExtraMenuItem onClick={() => handleStatusChange(MyTasksStatus.CompleteOneWeek)}>
|
||||
{status === MyTasksStatus.CompleteOneWeek && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>1 week</ActionTitle>
|
||||
</ActionExtraMenuItem>
|
||||
<ActionExtraMenuItem onClick={() => handleStatusChange(MyTasksStatus.CompleteTwoWeek)}>
|
||||
{status === MyTasksStatus.CompleteTwoWeek && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>2 weeks</ActionTitle>
|
||||
</ActionExtraMenuItem>
|
||||
<ActionExtraMenuItem onClick={() => handleStatusChange(MyTasksStatus.CompleteThreeWeek)}>
|
||||
{status === MyTasksStatus.CompleteThreeWeek && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>3 weeks</ActionTitle>
|
||||
</ActionExtraMenuItem>
|
||||
</ActionExtraMenu>
|
||||
</ActionExtraMenuContainer>
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => handleStatusChange(MyTasksStatus.All)}>
|
||||
{status === MyTasksStatus.All && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>All Tasks</ActionTitle>
|
||||
</ActionItem>
|
||||
</ActionsList>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyTasksStatusPopup;
|
415
frontend/src/MyTasks/TaskEntry.tsx
Normal file
415
frontend/src/MyTasks/TaskEntry.tsx
Normal file
@ -0,0 +1,415 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import styled, { css } from 'styled-components/macro';
|
||||
import dayjs from 'dayjs';
|
||||
import { CheckCircleOutline, CheckCircle, Cross, Briefcase, ChevronRight } from 'shared/icons';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
|
||||
const RIGHT_ROW_WIDTH = 327;
|
||||
const TaskName = styled.div<{ focused: boolean }>`
|
||||
flex: 0 1 auto;
|
||||
min-width: 1px;
|
||||
overflow: hidden;
|
||||
margin-right: 4px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 2px;
|
||||
height: 20px;
|
||||
padding: 0 1px;
|
||||
|
||||
max-height: 100%;
|
||||
position: relative;
|
||||
&:hover {
|
||||
${props =>
|
||||
!props.focused &&
|
||||
css`
|
||||
border-color: #9ca6af !important;
|
||||
border: 1px solid ${props.theme.colors.primary} !important;
|
||||
`}
|
||||
}
|
||||
`;
|
||||
|
||||
const DueDateCell = styled.div`
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const CellPlaceholder = styled.div<{ width: number }>`
|
||||
min-width: ${p => p.width}px;
|
||||
width: ${p => p.width}px;
|
||||
`;
|
||||
const DueDateCellDisplay = styled.div`
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const DueDateCellLabel = styled.div`
|
||||
align-items: center;
|
||||
color: ${props => props.theme.colors.text.primary};
|
||||
|
||||
font-size: 11px;
|
||||
flex: 0 1 auto;
|
||||
min-width: 1px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
white-space: pre-wrap;
|
||||
`;
|
||||
|
||||
const DueDateRemoveButton = styled.div`
|
||||
align-items: center;
|
||||
bottom: 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
padding-left: 4px;
|
||||
padding-right: 8px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
visibility: hidden;
|
||||
svg {
|
||||
fill: ${props => props.theme.colors.text.primary};
|
||||
}
|
||||
&:hover svg {
|
||||
fill: ${props => props.theme.colors.text.secondary};
|
||||
}
|
||||
`;
|
||||
const TaskGroupItemCell = styled.div<{ width: number; focused: boolean }>`
|
||||
width: ${p => p.width}px;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
|
||||
border: 1px solid #414561;
|
||||
justify-content: space-between;
|
||||
margin-right: -1px;
|
||||
z-index: 0;
|
||||
padding: 0 8px;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 37px;
|
||||
overflow: hidden;
|
||||
&:hover ${DueDateRemoveButton} {
|
||||
visibility: visible;
|
||||
}
|
||||
&:hover ${TaskName} {
|
||||
${props =>
|
||||
!props.focused &&
|
||||
css`
|
||||
background: ${props.theme.colors.bg.secondary};
|
||||
border: 1px solid ${mixin.darken(props.theme.colors.bg.secondary, 0.25)};
|
||||
border-radius: 2px;
|
||||
cursor: text;
|
||||
`}
|
||||
}
|
||||
`;
|
||||
|
||||
const TaskGroupItem = styled.div`
|
||||
padding-right: 24px;
|
||||
contain: style;
|
||||
display: flex;
|
||||
margin-bottom: -1px;
|
||||
margin-top: -1px;
|
||||
height: 37px;
|
||||
&:hover {
|
||||
background-color: #161d31;
|
||||
}
|
||||
& ${TaskGroupItemCell}:first-child {
|
||||
position: absolute;
|
||||
padding: 0 4px 0 0;
|
||||
margin-left: 24px;
|
||||
left: 0;
|
||||
flex: 1 1 auto;
|
||||
min-width: 1px;
|
||||
border-right: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
& ${TaskGroupItemCell}:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const TaskItemComplete = styled.div`
|
||||
flex: 0 0 auto;
|
||||
margin: 0 3px 0 0;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
height: 16px;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
width: 16px;
|
||||
cursor: pointer;
|
||||
svg {
|
||||
transition: all 0.2 ease;
|
||||
}
|
||||
&:hover svg {
|
||||
fill: ${props => props.theme.colors.primary};
|
||||
}
|
||||
`;
|
||||
|
||||
const TaskDetailsButton = styled.div`
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: 12px;
|
||||
height: 100%;
|
||||
justify-content: flex-end;
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
padding-left: 4px;
|
||||
color: ${props => props.theme.colors.text.primary};
|
||||
svg {
|
||||
fill: ${props => props.theme.colors.text.primary};
|
||||
}
|
||||
`;
|
||||
|
||||
const TaskDetailsArea = styled.div`
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex: 1 0 auto;
|
||||
height: 100%;
|
||||
margin-right: 24px;
|
||||
&:hover ${TaskDetailsButton} {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const TaskDetailsWorkpace = styled(Briefcase)`
|
||||
flex: 0 0 auto;
|
||||
margin-right: 8px;
|
||||
`;
|
||||
const TaskDetailsLabel = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const TaskDetailsChevron = styled(ChevronRight)`
|
||||
margin-left: 4px;
|
||||
flex: 0 0 auto;
|
||||
`;
|
||||
|
||||
const TaskNameShadow = styled.div`
|
||||
box-sizing: border-box;
|
||||
min-height: 1em;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
white-space: pre;
|
||||
border: 0;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
margin: 0;
|
||||
min-width: 20px;
|
||||
padding: 0 4px;
|
||||
text-rendering: optimizeSpeed;
|
||||
`;
|
||||
|
||||
const TaskNameInput = styled.textarea`
|
||||
white-space: pre;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
display: block;
|
||||
color: ${props => props.theme.colors.text.primary};
|
||||
height: 100%;
|
||||
outline: 0;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
resize: none;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
margin: 0;
|
||||
min-width: 20px;
|
||||
padding: 0 4px;
|
||||
text-rendering: optimizeSpeed;
|
||||
`;
|
||||
|
||||
const ProjectPill = styled.div`
|
||||
background-color: ${props => props.theme.colors.bg.primary};
|
||||
text-overflow: ellipsis;
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
overflow: hidden;
|
||||
padding: 0 8px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const ProjectPillContents = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const ProjectPillName = styled.span`
|
||||
flex: 0 1 auto;
|
||||
min-width: 1px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: ${props => props.theme.colors.text.primary};
|
||||
`;
|
||||
|
||||
const ProjectPillColor = styled.svg`
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
margin-right: 4px;
|
||||
fill: #0064fb;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
`;
|
||||
|
||||
type TaskEntryProps = {
|
||||
name: string;
|
||||
dueDate?: string | null;
|
||||
onEditName: (name: string) => void;
|
||||
project: string;
|
||||
hasTime: boolean;
|
||||
autoFocus?: boolean;
|
||||
onEditProject: ($target: React.RefObject<HTMLElement>) => void;
|
||||
onToggleComplete: (complete: boolean) => void;
|
||||
complete: boolean;
|
||||
onEditDueDate: ($target: React.RefObject<HTMLElement>) => void;
|
||||
onTaskDetails: () => void;
|
||||
onRemoveDueDate: () => void;
|
||||
};
|
||||
|
||||
const TaskEntry: React.FC<TaskEntryProps> = ({
|
||||
autoFocus = false,
|
||||
onToggleComplete,
|
||||
onEditName,
|
||||
onTaskDetails,
|
||||
name: initialName,
|
||||
complete,
|
||||
project,
|
||||
dueDate,
|
||||
hasTime,
|
||||
onEditProject,
|
||||
onEditDueDate,
|
||||
onRemoveDueDate,
|
||||
}) => {
|
||||
const leftRow = window.innerWidth - RIGHT_ROW_WIDTH;
|
||||
const [focused, setFocused] = useState(autoFocus);
|
||||
const [name, setName] = useState(initialName);
|
||||
useEffect(() => {
|
||||
setName(initialName);
|
||||
}, [initialName]);
|
||||
const $projects = useRef<HTMLDivElement>(null);
|
||||
const $dueDate = useRef<HTMLDivElement>(null);
|
||||
const $nameInput = useRef<HTMLTextAreaElement>(null);
|
||||
return (
|
||||
<TaskGroupItem>
|
||||
<TaskGroupItemCell focused={focused} width={leftRow}>
|
||||
<TaskItemComplete onClick={() => onToggleComplete(!complete)}>
|
||||
{complete ? <CheckCircle width={16} height={16} /> : <CheckCircleOutline width={16} height={16} />}
|
||||
</TaskItemComplete>
|
||||
<TaskName focused={focused}>
|
||||
<TaskNameShadow>{name}</TaskNameShadow>
|
||||
<TaskNameInput
|
||||
autoFocus={autoFocus}
|
||||
onFocus={() => setFocused(true)}
|
||||
ref={$nameInput}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
onEditName(name);
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.keyCode === 13) {
|
||||
e.preventDefault();
|
||||
if ($nameInput.current) {
|
||||
$nameInput.current.blur();
|
||||
}
|
||||
}
|
||||
}}
|
||||
onChange={e => setName(e.currentTarget.value)}
|
||||
wrap="off"
|
||||
value={name}
|
||||
rows={1}
|
||||
/>
|
||||
</TaskName>
|
||||
<TaskDetailsArea onClick={() => onTaskDetails()}>
|
||||
<TaskDetailsButton>
|
||||
<TaskDetailsWorkpace width={16} height={16} />
|
||||
<TaskDetailsLabel>
|
||||
Details
|
||||
<TaskDetailsChevron width={12} height={12} />
|
||||
</TaskDetailsLabel>
|
||||
</TaskDetailsButton>
|
||||
</TaskDetailsArea>
|
||||
</TaskGroupItemCell>
|
||||
<CellPlaceholder width={leftRow} />
|
||||
<TaskGroupItemCell width={120} focused={false} ref={$dueDate}>
|
||||
<DueDateCell onClick={() => onEditDueDate($dueDate)}>
|
||||
<DueDateCellDisplay>
|
||||
<DueDateCellLabel>
|
||||
{dueDate ? dayjs(dueDate).format(hasTime ? 'MMM D [at] h:mm A' : 'MMM D') : ''}
|
||||
</DueDateCellLabel>
|
||||
</DueDateCellDisplay>
|
||||
</DueDateCell>
|
||||
{dueDate && (
|
||||
<DueDateRemoveButton onClick={() => onRemoveDueDate()}>
|
||||
<Cross width={12} height={12} />
|
||||
</DueDateRemoveButton>
|
||||
)}
|
||||
</TaskGroupItemCell>
|
||||
<TaskGroupItemCell width={120} focused={false} ref={$projects}>
|
||||
<ProjectPill
|
||||
onClick={() => {
|
||||
onEditProject($projects);
|
||||
}}
|
||||
>
|
||||
<ProjectPillContents>
|
||||
<ProjectPillColor viewBox="0 0 24 24" focusable={false}>
|
||||
<path d="M10.4,4h3.2c2.2,0,3,0.2,3.9,0.7c0.8,0.4,1.5,1.1,1.9,1.9s0.7,1.6,0.7,3.9v3.2c0,2.2-0.2,3-0.7,3.9c-0.4,0.8-1.1,1.5-1.9,1.9s-1.6,0.7-3.9,0.7h-3.2c-2.2,0-3-0.2-3.9-0.7c-0.8-0.4-1.5-1.1-1.9-1.9c-0.4-1-0.6-1.8-0.6-4v-3.2c0-2.2,0.2-3,0.7-3.9C5.1,5.7,5.8,5,6.6,4.6C7.4,4.2,8.2,4,10.4,4z" />
|
||||
</ProjectPillColor>
|
||||
<ProjectPillName>{project}</ProjectPillName>
|
||||
</ProjectPillContents>
|
||||
</ProjectPill>
|
||||
</TaskGroupItemCell>
|
||||
<TaskGroupItemCell width={50} focused={false} />
|
||||
</TaskGroupItem>
|
||||
);
|
||||
};
|
||||
export default TaskEntry;
|
||||
type NewTaskEntryProps = {
|
||||
onClick: () => void;
|
||||
};
|
||||
const AddTaskLabel = styled.span`
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
|
||||
color: ${props => props.theme.colors.text.primary};
|
||||
|
||||
justify-content: space-between;
|
||||
z-index: 0;
|
||||
padding: 0 8px;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 37px;
|
||||
flex: 1 1;
|
||||
cursor: pointer;
|
||||
margin-left: 24px;
|
||||
`;
|
||||
|
||||
const NewTaskEntry: React.FC<NewTaskEntryProps> = ({ onClick }) => {
|
||||
const leftRow = window.innerWidth - RIGHT_ROW_WIDTH;
|
||||
return (
|
||||
<TaskGroupItem>
|
||||
<AddTaskLabel onClick={onClick}>Add task...</AddTaskLabel>
|
||||
</TaskGroupItem>
|
||||
);
|
||||
};
|
||||
|
||||
export { NewTaskEntry };
|
947
frontend/src/MyTasks/index.tsx
Normal file
947
frontend/src/MyTasks/index.tsx
Normal file
@ -0,0 +1,947 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import styled, { css } from 'styled-components/macro';
|
||||
import GlobalTopNavbar from 'App/TopNavbar';
|
||||
import Details from 'Projects/Project/Details';
|
||||
import {
|
||||
useMyTasksQuery,
|
||||
MyTasksSort,
|
||||
MyTasksStatus,
|
||||
useCreateTaskMutation,
|
||||
MyTasksQuery,
|
||||
MyTasksDocument,
|
||||
useUpdateTaskNameMutation,
|
||||
useSetTaskCompleteMutation,
|
||||
useUpdateTaskDueDateMutation,
|
||||
} from 'shared/generated/graphql';
|
||||
|
||||
import { Route, useRouteMatch, useHistory, RouteComponentProps } from 'react-router-dom';
|
||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||
import updateApolloCache from 'shared/utils/cache';
|
||||
import produce from 'immer';
|
||||
import NOOP from 'shared/utils/noop';
|
||||
import { Sort, Cogs, CaretDown, CheckCircle, CaretRight, CheckCircleOutline } from 'shared/icons';
|
||||
import Select from 'react-select';
|
||||
import { editorColourStyles } from 'shared/components/Select';
|
||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
||||
import DueDateManager from 'shared/components/DueDateManager';
|
||||
import dayjs from 'dayjs';
|
||||
import useStickyState from 'shared/hooks/useStickyState';
|
||||
import { StaticContext } from 'react-router';
|
||||
import MyTasksSortPopup from './MyTasksSort';
|
||||
import MyTasksStatusPopup from './MyTasksStatus';
|
||||
import TaskEntry from './TaskEntry';
|
||||
|
||||
type TaskRouteProps = {
|
||||
taskID: string;
|
||||
};
|
||||
|
||||
function prettyStatus(status: MyTasksStatus) {
|
||||
switch (status) {
|
||||
case MyTasksStatus.All:
|
||||
return 'All tasks';
|
||||
case MyTasksStatus.Incomplete:
|
||||
return 'Incomplete tasks';
|
||||
case MyTasksStatus.CompleteAll:
|
||||
return 'All completed tasks';
|
||||
case MyTasksStatus.CompleteToday:
|
||||
return 'Completed tasks: today';
|
||||
case MyTasksStatus.CompleteYesterday:
|
||||
return 'Completed tasks: yesterday';
|
||||
case MyTasksStatus.CompleteOneWeek:
|
||||
return 'Completed tasks: 1 week';
|
||||
case MyTasksStatus.CompleteTwoWeek:
|
||||
return 'Completed tasks: 2 weeks';
|
||||
case MyTasksStatus.CompleteThreeWeek:
|
||||
return 'Completed tasks: 3 weeks';
|
||||
default:
|
||||
return 'unknown tasks';
|
||||
}
|
||||
}
|
||||
|
||||
function prettySort(sort: MyTasksSort) {
|
||||
if (sort === MyTasksSort.None) {
|
||||
return 'Sort';
|
||||
}
|
||||
return `Sort: ${sort.charAt(0) + sort.slice(1).toLowerCase().replace(/_/gi, ' ')}`;
|
||||
}
|
||||
|
||||
type Group = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
tasks: Array<Task>;
|
||||
};
|
||||
const DueDateEditorLabel = styled.div`
|
||||
align-items: center;
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
|
||||
font-size: 11px;
|
||||
padding: 0 8px;
|
||||
flex: 0 1 auto;
|
||||
min-width: 1px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
white-space: pre-wrap;
|
||||
height: 35px;
|
||||
`;
|
||||
|
||||
const ProjectBar = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
`;
|
||||
|
||||
const ProjectActions = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const ProjectActionWrapper = styled.div<{ disabled?: boolean }>`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 15px;
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
|
||||
&:not(:last-of-type) {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.colors.text.secondary};
|
||||
}
|
||||
${(props) =>
|
||||
props.disabled &&
|
||||
css`
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
`}
|
||||
`;
|
||||
|
||||
const ProjectActionText = styled.span`
|
||||
padding-left: 4px;
|
||||
`;
|
||||
|
||||
type ProjectActionProps = {
|
||||
onClick?: (target: React.RefObject<HTMLElement>) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const ProjectAction: React.FC<ProjectActionProps> = ({ onClick, disabled = false, children }) => {
|
||||
const $container = useRef<HTMLDivElement>(null);
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick($container);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<ProjectActionWrapper ref={$container} onClick={handleClick} disabled={disabled}>
|
||||
{children}
|
||||
</ProjectActionWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const EditorPositioner = styled.div<{ top: number; left: number }>`
|
||||
position: absolute;
|
||||
top: ${(p) => p.top}px;
|
||||
justify-content: flex-end;
|
||||
margin-left: -100vw;
|
||||
z-index: 10000;
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
font-size: 13px;
|
||||
height: 0;
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
left: ${(p) => p.left}px;
|
||||
`;
|
||||
|
||||
const EditorPositionerContents = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const EditorContainer = styled.div<{ width: number }>`
|
||||
border: 1px solid ${(props) => props.theme.colors.primary};
|
||||
background: ${(props) => props.theme.colors.bg.secondary};
|
||||
position: relative;
|
||||
width: ${(p) => p.width}px;
|
||||
`;
|
||||
|
||||
const EditorCell = styled.div<{ width: number }>`
|
||||
display: flex;
|
||||
width: ${(p) => p.width}px;
|
||||
`;
|
||||
|
||||
// TABLE
|
||||
const VerticalScoller = styled.div`
|
||||
contain: strict;
|
||||
flex: 1 1 auto;
|
||||
overflow-x: hidden;
|
||||
padding-bottom: 1px;
|
||||
position: relative;
|
||||
|
||||
min-height: 1px;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const VerticalScollerInner = styled.div`
|
||||
min-height: 100%;
|
||||
overflow-y: hidden;
|
||||
min-width: 1px;
|
||||
overflow-x: auto;
|
||||
`;
|
||||
|
||||
const VerticalScollerInnerBar = styled.div`
|
||||
display: flex;
|
||||
margin: 0 24px;
|
||||
margin-bottom: 1px;
|
||||
border-top: 1px solid #414561;
|
||||
`;
|
||||
|
||||
const TableContents = styled.div`
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
margin-bottom: 32px;
|
||||
min-width: 100%;
|
||||
`;
|
||||
|
||||
const TaskGroupContainer = styled.div``;
|
||||
|
||||
const TaskGroupHeader = styled.div`
|
||||
height: 50px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const TaskGroupItems = styled.div`
|
||||
overflow: unset;
|
||||
`;
|
||||
|
||||
const ProjectPill = styled.div`
|
||||
background-color: ${(props) => props.theme.colors.bg.primary};
|
||||
text-overflow: ellipsis;
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
overflow: hidden;
|
||||
padding: 0 8px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const ProjectPillContents = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const ProjectPillName = styled.span`
|
||||
flex: 0 1 auto;
|
||||
min-width: 1px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
`;
|
||||
|
||||
const ProjectPillColor = styled.svg`
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
margin-right: 4px;
|
||||
fill: #0064fb;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
`;
|
||||
|
||||
const SingleValue = ({ children, ...props }: any) => {
|
||||
return (
|
||||
<ProjectPill>
|
||||
<ProjectPillContents>
|
||||
<ProjectPillColor viewBox="0 0 24 24" focusable={false}>
|
||||
<path d="M10.4,4h3.2c2.2,0,3,0.2,3.9,0.7c0.8,0.4,1.5,1.1,1.9,1.9s0.7,1.6,0.7,3.9v3.2c0,2.2-0.2,3-0.7,3.9c-0.4,0.8-1.1,1.5-1.9,1.9s-1.6,0.7-3.9,0.7h-3.2c-2.2,0-3-0.2-3.9-0.7c-0.8-0.4-1.5-1.1-1.9-1.9c-0.4-1-0.6-1.8-0.6-4v-3.2c0-2.2,0.2-3,0.7-3.9C5.1,5.7,5.8,5,6.6,4.6C7.4,4.2,8.2,4,10.4,4z" />
|
||||
</ProjectPillColor>
|
||||
<ProjectPillName>{children}</ProjectPillName>
|
||||
</ProjectPillContents>
|
||||
</ProjectPill>
|
||||
);
|
||||
};
|
||||
|
||||
const OptionWrapper = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background: #414561;
|
||||
}
|
||||
`;
|
||||
|
||||
const OptionLabel = styled.div`
|
||||
align-items: baseline;
|
||||
display: flex;
|
||||
min-width: 1px;
|
||||
`;
|
||||
|
||||
const OptionTitle = styled.div`
|
||||
min-width: 50px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
const OptionSubTitle = styled.div`
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
font-size: 11px;
|
||||
margin-left: 8px;
|
||||
min-width: 50px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
const Option = ({ innerProps, data }: any) => {
|
||||
return (
|
||||
<OptionWrapper {...innerProps}>
|
||||
<OptionLabel>
|
||||
<OptionTitle>{data.label}</OptionTitle>
|
||||
<OptionSubTitle>{data.label}</OptionSubTitle>
|
||||
</OptionLabel>
|
||||
</OptionWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const TaskGroupHeaderContents = styled.div<{ width: number }>`
|
||||
width: ${(p) => p.width}px;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-left: 24px;
|
||||
line-height: 20px;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 30px;
|
||||
padding-right: 32px;
|
||||
position: relative;
|
||||
border-bottom: 1px solid transparent;
|
||||
border-top: 1px solid transparent;
|
||||
`;
|
||||
|
||||
const TaskGroupMinify = styled.div`
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
min-width: 28px;
|
||||
width: 28px;
|
||||
border-radius: 6px;
|
||||
user-select: none;
|
||||
|
||||
margin-right: 4px;
|
||||
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
transition-duration: 0.2s;
|
||||
transition-property: background, border, box-shadow, fill;
|
||||
cursor: pointer;
|
||||
svg {
|
||||
fill: ${(props) => props.theme.colors.text.primary};
|
||||
transition-duration: 0.2s;
|
||||
transition-property: background, border, box-shadow, fill;
|
||||
}
|
||||
|
||||
&:hover svg {
|
||||
fill: ${(props) => props.theme.colors.text.secondary};
|
||||
}
|
||||
`;
|
||||
const TaskGroupName = styled.div`
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 50px;
|
||||
min-width: 1px;
|
||||
color: ${(props) => props.theme.colors.text.secondary};
|
||||
font-weight: 400;
|
||||
`;
|
||||
|
||||
// HEADER
|
||||
const ScrollContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 1px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Row = styled.div`
|
||||
box-sizing: border-box;
|
||||
flex: 0 0 auto;
|
||||
height: 37px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const RowHeaderLeft = styled.div<{ width: number }>`
|
||||
width: ${(p) => p.width}px;
|
||||
|
||||
align-items: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 37px;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
`;
|
||||
const RowHeaderLeftInner = styled.div`
|
||||
align-items: stretch;
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
display: flex;
|
||||
flex: 1 0 auto;
|
||||
font-size: 12px;
|
||||
margin-right: -1px;
|
||||
padding-left: 24px;
|
||||
`;
|
||||
const RowHeaderLeftName = styled.div`
|
||||
position: relative;
|
||||
align-items: center;
|
||||
border-right: 1px solid #414561;
|
||||
border-top: 1px solid #414561;
|
||||
border-bottom: 1px solid #414561;
|
||||
display: flex;
|
||||
flex: 1 0 auto;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const RowHeaderLeftNameText = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const RowHeaderRight = styled.div<{ left: number }>`
|
||||
left: ${(p) => p.left}px;
|
||||
right: 0px;
|
||||
height: 37px;
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
const RowScrollable = styled.div`
|
||||
min-width: 1px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const RowScrollContent = styled.div`
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
height: 37px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const RowHeaderRightContainer = styled.div`
|
||||
padding-right: 24px;
|
||||
|
||||
align-items: stretch;
|
||||
display: flex;
|
||||
flex: 1 0 auto;
|
||||
height: 37px;
|
||||
justify-content: flex-end;
|
||||
margin: -1px 0;
|
||||
`;
|
||||
|
||||
const ItemWrapper = styled.div<{ width: number }>`
|
||||
width: ${(p) => p.width}px;
|
||||
align-items: center;
|
||||
border: 1px solid #414561;
|
||||
border-bottom: 0;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
font-size: 12px;
|
||||
justify-content: space-between;
|
||||
margin-right: -1px;
|
||||
padding: 0 8px;
|
||||
position: relative;
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
border-bottom: 1px solid #414561;
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.colors.primary};
|
||||
color: ${(props) => props.theme.colors.text.secondary};
|
||||
}
|
||||
`;
|
||||
const ItemsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
& ${ItemWrapper}:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const ItemName = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
`;
|
||||
type DateEditorState = {
|
||||
open: boolean;
|
||||
pos: { top: number; left: number } | null;
|
||||
task: null | Task;
|
||||
};
|
||||
|
||||
type ProjectEditorState = {
|
||||
open: boolean;
|
||||
pos: { top: number; left: number } | null;
|
||||
task: null | Task;
|
||||
};
|
||||
const RIGHT_ROW_WIDTH = 327;
|
||||
|
||||
const Projects = () => {
|
||||
const leftRow = window.innerWidth - RIGHT_ROW_WIDTH;
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [filters, setFilters] = useStickyState<{ sort: MyTasksSort; status: MyTasksStatus }>(
|
||||
{ sort: MyTasksSort.None, status: MyTasksStatus.All },
|
||||
'my_tasks_filter',
|
||||
);
|
||||
const { data } = useMyTasksQuery({
|
||||
variables: { sort: filters.sort, status: filters.status },
|
||||
fetchPolicy: 'cache-and-network',
|
||||
});
|
||||
const [dateEditor, setDateEditor] = useState<DateEditorState>({ open: false, pos: null, task: null });
|
||||
const onEditDueDate = (task: Task, $target: React.RefObject<HTMLElement>) => {
|
||||
if ($target && $target.current && data) {
|
||||
const pos = $target.current.getBoundingClientRect();
|
||||
setDateEditor({
|
||||
open: true,
|
||||
pos: {
|
||||
top: pos.top,
|
||||
left: pos.right,
|
||||
},
|
||||
task,
|
||||
});
|
||||
}
|
||||
};
|
||||
const [newTask, setNewTask] = useState<{ open: boolean }>({ open: false });
|
||||
const match = useRouteMatch();
|
||||
const history = useHistory();
|
||||
const [projectEditor, setProjectEditor] = useState<ProjectEditorState>({ open: false, pos: null, task: null });
|
||||
const onEditProject = ($target: React.RefObject<HTMLElement>) => {
|
||||
if ($target && $target.current) {
|
||||
const pos = $target.current.getBoundingClientRect();
|
||||
setProjectEditor({
|
||||
open: true,
|
||||
pos: {
|
||||
top: pos.top,
|
||||
left: pos.right,
|
||||
},
|
||||
task: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
const { showPopup, hidePopup } = usePopup();
|
||||
const [updateTaskDueDate] = useUpdateTaskDueDateMutation();
|
||||
const $editorContents = useRef<HTMLDivElement>(null);
|
||||
const $dateContents = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (dateEditor.open && $dateContents.current && dateEditor.task) {
|
||||
showPopup(
|
||||
$dateContents,
|
||||
<Popup tab={0} title={null}>
|
||||
<DueDateManager
|
||||
task={dateEditor.task}
|
||||
onCancel={() => null}
|
||||
onDueDateChange={(task, dueDate, hasTime) => {
|
||||
if (dateEditor.task) {
|
||||
hidePopup();
|
||||
updateTaskDueDate({
|
||||
variables: {
|
||||
taskID: dateEditor.task.id,
|
||||
dueDate,
|
||||
hasTime,
|
||||
deleteNotifications: [],
|
||||
updateNotifications: [],
|
||||
createNotifications: [],
|
||||
},
|
||||
});
|
||||
setDateEditor((prev) => ({
|
||||
...prev,
|
||||
task: { ...task, dueDate: { at: dueDate.toISOString(), notifications: [] }, hasTime },
|
||||
}));
|
||||
}
|
||||
}}
|
||||
onRemoveDueDate={(task) => {
|
||||
if (dateEditor.task) {
|
||||
hidePopup();
|
||||
updateTaskDueDate({
|
||||
variables: {
|
||||
taskID: dateEditor.task.id,
|
||||
dueDate: null,
|
||||
hasTime: false,
|
||||
deleteNotifications: [],
|
||||
updateNotifications: [],
|
||||
createNotifications: [],
|
||||
},
|
||||
});
|
||||
setDateEditor((prev) => ({ ...prev, task: { ...task, hasTime: false } }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popup>,
|
||||
{ onClose: () => setDateEditor({ open: false, task: null, pos: null }) },
|
||||
);
|
||||
}
|
||||
}, [dateEditor]);
|
||||
|
||||
const [createTask] = useCreateTaskMutation({
|
||||
update: (client, newTaskData) => {
|
||||
updateApolloCache<MyTasksQuery>(
|
||||
client,
|
||||
MyTasksDocument,
|
||||
(cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
if (newTaskData.data) {
|
||||
draftCache.myTasks.tasks.unshift(newTaskData.data.createTask);
|
||||
}
|
||||
}),
|
||||
{ status: MyTasksStatus.All, sort: MyTasksSort.None },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const [setTaskComplete] = useSetTaskCompleteMutation();
|
||||
const [updateTaskName] = useUpdateTaskNameMutation();
|
||||
const [minified, setMinified] = useStickyState<Array<string>>([], 'my_tasks_minified');
|
||||
useOnOutsideClick(
|
||||
$editorContents,
|
||||
projectEditor.open,
|
||||
() =>
|
||||
setProjectEditor({
|
||||
open: false,
|
||||
task: null,
|
||||
pos: null,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
if (data) {
|
||||
const groups: Array<Group> = [];
|
||||
if (filters.sort === MyTasksSort.None) {
|
||||
groups.push({
|
||||
id: 'recently-assigned',
|
||||
name: 'Recently Assigned',
|
||||
tasks: data.myTasks.tasks.map((task) => ({
|
||||
...task,
|
||||
labels: [],
|
||||
position: 0,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
let { tasks } = data.myTasks;
|
||||
if (filters.sort === MyTasksSort.DueDate) {
|
||||
const group: Group = { id: 'due_date', name: null, tasks: [] };
|
||||
data.myTasks.tasks.forEach((task) => {
|
||||
if (task.dueDate) {
|
||||
group.tasks.push({ ...task, labels: [], position: 0 });
|
||||
}
|
||||
});
|
||||
groups.push(group);
|
||||
tasks = tasks.filter((t) => t.dueDate === null);
|
||||
}
|
||||
const projects = new Map<string, Array<Task>>();
|
||||
data.myTasks.projects.forEach((p) => {
|
||||
if (!projects.has(p.projectID)) {
|
||||
projects.set(p.projectID, []);
|
||||
}
|
||||
const prev = projects.get(p.projectID);
|
||||
const task = tasks.find((t) => t.id === p.taskID);
|
||||
if (prev && task) {
|
||||
projects.set(p.projectID, [...prev, { ...task, labels: [], position: 0 }]);
|
||||
}
|
||||
});
|
||||
for (const [id, pTasks] of projects) {
|
||||
const project = data.projects.find((c) => c.id === id);
|
||||
if (pTasks.length === 0) continue;
|
||||
if (project) {
|
||||
groups.push({
|
||||
id,
|
||||
name: project.name,
|
||||
tasks: pTasks.sort((a, b) => {
|
||||
if (a.dueDate === null && b.dueDate === null) return 0;
|
||||
if (a.dueDate === null && b.dueDate !== null) return 1;
|
||||
if (a.dueDate !== null && b.dueDate === null) return -1;
|
||||
const first = dayjs(a.dueDate.at);
|
||||
const second = dayjs(b.dueDate.at);
|
||||
if (first.isSame(second, 'minute')) return 0;
|
||||
if (first.isAfter(second)) return -1;
|
||||
return 1;
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
groups.sort((a, b) => {
|
||||
if (a.name === null && b.name === null) return 0;
|
||||
if (a.name === null) return -1;
|
||||
if (b.name === null) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />
|
||||
<ProjectBar>
|
||||
<ProjectActions />
|
||||
<ProjectActions>
|
||||
<ProjectAction
|
||||
onClick={($target) => {
|
||||
showPopup(
|
||||
$target,
|
||||
<MyTasksStatusPopup
|
||||
status={filters.status}
|
||||
onChangeStatus={(status) => {
|
||||
setFilters((prev) => ({ ...prev, status }));
|
||||
hidePopup();
|
||||
}}
|
||||
/>,
|
||||
{ width: 185 },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CheckCircleOutline width={13} height={13} />
|
||||
<ProjectActionText>{prettyStatus(filters.status)}</ProjectActionText>
|
||||
</ProjectAction>
|
||||
<ProjectAction
|
||||
onClick={($target) => {
|
||||
showPopup(
|
||||
$target,
|
||||
<MyTasksSortPopup
|
||||
sort={filters.sort}
|
||||
onChangeSort={(sort) => {
|
||||
setFilters((prev) => ({ ...prev, sort }));
|
||||
hidePopup();
|
||||
}}
|
||||
/>,
|
||||
{ width: 185 },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Sort width={13} height={13} />
|
||||
<ProjectActionText>{prettySort(filters.sort)}</ProjectActionText>
|
||||
</ProjectAction>
|
||||
<ProjectAction disabled>
|
||||
<Cogs width={13} height={13} />
|
||||
<ProjectActionText>Customize</ProjectActionText>
|
||||
</ProjectAction>
|
||||
</ProjectActions>
|
||||
</ProjectBar>
|
||||
<ScrollContainer>
|
||||
<Row>
|
||||
<RowHeaderLeft width={leftRow}>
|
||||
<RowHeaderLeftInner>
|
||||
<RowHeaderLeftName>
|
||||
<RowHeaderLeftNameText>Task name</RowHeaderLeftNameText>
|
||||
</RowHeaderLeftName>
|
||||
</RowHeaderLeftInner>
|
||||
</RowHeaderLeft>
|
||||
<RowHeaderRight left={leftRow}>
|
||||
<RowScrollable>
|
||||
<RowScrollContent>
|
||||
<RowHeaderRightContainer>
|
||||
<ItemsContainer>
|
||||
<ItemWrapper width={120}>
|
||||
<ItemName>Due date</ItemName>
|
||||
</ItemWrapper>
|
||||
<ItemWrapper width={120}>
|
||||
<ItemName>Project</ItemName>
|
||||
</ItemWrapper>
|
||||
<ItemWrapper width={50} />
|
||||
</ItemsContainer>
|
||||
</RowHeaderRightContainer>
|
||||
</RowScrollContent>
|
||||
</RowScrollable>
|
||||
</RowHeaderRight>
|
||||
</Row>
|
||||
<VerticalScoller>
|
||||
<VerticalScollerInner>
|
||||
<TableContents>
|
||||
{groups.map((group) => {
|
||||
const isMinified = minified.find((m) => m === group.id) ?? false;
|
||||
return (
|
||||
<TaskGroupContainer key={group.id}>
|
||||
{group.name && (
|
||||
<TaskGroupHeader>
|
||||
<TaskGroupHeaderContents width={leftRow}>
|
||||
<TaskGroupMinify
|
||||
onClick={() => {
|
||||
setMinified((prev) => {
|
||||
if (isMinified) {
|
||||
return prev.filter((c) => c !== group.id);
|
||||
}
|
||||
return [...prev, group.id];
|
||||
});
|
||||
}}
|
||||
>
|
||||
{isMinified ? (
|
||||
<CaretRight width={16} height={16} />
|
||||
) : (
|
||||
<CaretDown width={16} height={16} />
|
||||
)}
|
||||
</TaskGroupMinify>
|
||||
<TaskGroupName>{group.name}</TaskGroupName>
|
||||
</TaskGroupHeaderContents>
|
||||
</TaskGroupHeader>
|
||||
)}
|
||||
<TaskGroupItems>
|
||||
{!isMinified &&
|
||||
group.tasks.map((task) => {
|
||||
const projectID = data.myTasks.projects.find((t) => t.taskID === task.id)?.projectID;
|
||||
const projectName = data.projects.find((p) => p.id === projectID)?.name;
|
||||
return (
|
||||
<TaskEntry
|
||||
key={task.id}
|
||||
complete={task.complete ?? false}
|
||||
onToggleComplete={(complete) => {
|
||||
setTaskComplete({ variables: { taskID: task.id, complete } });
|
||||
}}
|
||||
onTaskDetails={() => {
|
||||
history.push(`${match.url}/c/${task.id}`);
|
||||
}}
|
||||
onRemoveDueDate={() => {
|
||||
updateTaskDueDate({
|
||||
variables: {
|
||||
taskID: task.id,
|
||||
dueDate: null,
|
||||
hasTime: false,
|
||||
deleteNotifications: [],
|
||||
updateNotifications: [],
|
||||
createNotifications: [],
|
||||
},
|
||||
});
|
||||
}}
|
||||
project={projectName ?? 'none'}
|
||||
dueDate={task.dueDate.at}
|
||||
hasTime={task.hasTime ?? false}
|
||||
name={task.name}
|
||||
onEditName={(name) => updateTaskName({ variables: { taskID: task.id, name } })}
|
||||
onEditProject={onEditProject}
|
||||
onEditDueDate={($target) =>
|
||||
onEditDueDate({ ...task, position: 0, labels: [] }, $target)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TaskGroupItems>
|
||||
</TaskGroupContainer>
|
||||
);
|
||||
})}
|
||||
</TableContents>
|
||||
</VerticalScollerInner>
|
||||
</VerticalScoller>
|
||||
</ScrollContainer>
|
||||
{dateEditor.open && dateEditor.pos !== null && dateEditor.task && (
|
||||
<EditorPositioner left={dateEditor.pos.left} top={dateEditor.pos.top}>
|
||||
<EditorPositionerContents ref={$dateContents}>
|
||||
<EditorContainer width={120}>
|
||||
<EditorCell width={120}>
|
||||
<DueDateEditorLabel>
|
||||
{dateEditor.task.dueDate
|
||||
? dayjs(dateEditor.task.dueDate.at).format(
|
||||
dateEditor.task.hasTime ? 'MMM D [at] h:mm A' : 'MMM D',
|
||||
)
|
||||
: ''}
|
||||
</DueDateEditorLabel>
|
||||
</EditorCell>
|
||||
</EditorContainer>
|
||||
</EditorPositionerContents>
|
||||
</EditorPositioner>
|
||||
)}
|
||||
{projectEditor.open && projectEditor.pos !== null && (
|
||||
<EditorPositioner left={projectEditor.pos.left} top={projectEditor.pos.top}>
|
||||
<EditorPositionerContents ref={$editorContents}>
|
||||
<EditorContainer width={300}>
|
||||
<EditorCell width={300}>
|
||||
<Select
|
||||
components={{ SingleValue, Option }}
|
||||
autoFocus
|
||||
styles={editorColourStyles}
|
||||
options={[{ label: 'hello', value: '1' }]}
|
||||
onInputChange={(query, { action }) => {
|
||||
if (action === 'input-change') {
|
||||
setMenuOpen(true);
|
||||
}
|
||||
}}
|
||||
onChange={() => setMenuOpen(false)}
|
||||
onBlur={() => setMenuOpen(false)}
|
||||
menuIsOpen={menuOpen}
|
||||
/>
|
||||
</EditorCell>
|
||||
</EditorContainer>
|
||||
</EditorPositionerContents>
|
||||
</EditorPositioner>
|
||||
)}
|
||||
<Route
|
||||
path={`${match.path}/c/:taskID`}
|
||||
render={() => {
|
||||
return (
|
||||
<Details
|
||||
refreshCache={NOOP}
|
||||
availableMembers={[]}
|
||||
projectURL={`${match.url}`}
|
||||
onTaskNameChange={(updatedTask, newName) => {
|
||||
updateTaskName({ variables: { taskID: updatedTask.id, name: newName } });
|
||||
}}
|
||||
onTaskDescriptionChange={(updatedTask, newDescription) => {
|
||||
/*
|
||||
updateTaskDescription({
|
||||
variables: { taskID: updatedTask.id, description: newDescription },
|
||||
optimisticResponse: {
|
||||
__typename: 'Mutation',
|
||||
updateTaskDescription: {
|
||||
__typename: 'Task',
|
||||
id: updatedTask.id,
|
||||
description: newDescription,
|
||||
},
|
||||
},
|
||||
});
|
||||
*/
|
||||
}}
|
||||
onDeleteTask={(deletedTask) => {
|
||||
// deleteTask({ variables: { taskID: deletedTask.id } });
|
||||
history.push(`${match.url}`);
|
||||
}}
|
||||
onOpenAddLabelPopup={(task, $targetRef) => {
|
||||
/*
|
||||
taskLabelsRef.current = task.labels;
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<LabelManagerEditor
|
||||
onLabelToggle={labelID => {
|
||||
toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } });
|
||||
}}
|
||||
labelColors={data.labelColors}
|
||||
labels={labelsRef}
|
||||
taskLabels={taskLabelsRef}
|
||||
projectID={projectID}
|
||||
/>,
|
||||
);
|
||||
*/
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default Projects;
|
94
frontend/src/Profile/index.tsx
Normal file
94
frontend/src/Profile/index.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import styled from 'styled-components/macro';
|
||||
import GlobalTopNavbar from 'App/TopNavbar';
|
||||
import Settings from 'shared/components/Settings';
|
||||
import {
|
||||
useMeQuery,
|
||||
useClearProfileAvatarMutation,
|
||||
useUpdateUserPasswordMutation,
|
||||
useUpdateUserInfoMutation,
|
||||
MeQuery,
|
||||
MeDocument,
|
||||
} from 'shared/generated/graphql';
|
||||
import axios from 'axios';
|
||||
import { useCurrentUser } from 'App/context';
|
||||
import NOOP from 'shared/utils/noop';
|
||||
import { toast } from 'react-toastify';
|
||||
import updateApolloCache from 'shared/utils/cache';
|
||||
import produce from 'immer';
|
||||
|
||||
const MainContent = styled.div`
|
||||
padding: 0 0 50px 80px;
|
||||
height: 100%;
|
||||
background: #262c49;
|
||||
`;
|
||||
|
||||
const Projects = () => {
|
||||
const $fileUpload = useRef<HTMLInputElement>(null);
|
||||
const [clearProfileAvatar] = useClearProfileAvatarMutation();
|
||||
const { user } = useCurrentUser();
|
||||
const [updateUserInfo] = useUpdateUserInfoMutation();
|
||||
const [updateUserPassword] = useUpdateUserPasswordMutation();
|
||||
const { loading, data, refetch } = useMeQuery();
|
||||
useEffect(() => {
|
||||
document.title = 'Profile | Taskcafé';
|
||||
}, []);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="file"
|
||||
name="file"
|
||||
style={{ display: 'none' }}
|
||||
ref={$fileUpload}
|
||||
onChange={(e) => {
|
||||
if (e.target.files) {
|
||||
const fileData = new FormData();
|
||||
fileData.append('file', e.target.files[0]);
|
||||
axios
|
||||
.post('/users/me/avatar', fileData, {
|
||||
withCredentials: true,
|
||||
})
|
||||
.then((res) => {
|
||||
if ($fileUpload && $fileUpload.current) {
|
||||
$fileUpload.current.value = '';
|
||||
refetch();
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} />
|
||||
{!loading && data && data.me && (
|
||||
<Settings
|
||||
profile={data.me.user}
|
||||
onProfileAvatarChange={() => {
|
||||
if ($fileUpload && $fileUpload.current) {
|
||||
$fileUpload.current.click();
|
||||
}
|
||||
}}
|
||||
onResetPassword={(password, done) => {
|
||||
updateUserPassword({ variables: { userID: user, password } });
|
||||
toast('Password was changed!');
|
||||
done();
|
||||
}}
|
||||
onChangeUserInfo={(d, done) => {
|
||||
updateUserInfo({
|
||||
variables: { name: d.fullName, bio: d.bio, email: d.email, initials: d.initials },
|
||||
});
|
||||
toast('User info was saved!');
|
||||
done();
|
||||
}}
|
||||
onProfileAvatarRemove={() => {
|
||||
clearProfileAvatar();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Projects;
|
332
frontend/src/Projects/Project/Board/ControlFilter.tsx
Normal file
332
frontend/src/Projects/Project/Board/ControlFilter.tsx
Normal file
@ -0,0 +1,332 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import styled, { css } from 'styled-components';
|
||||
import { Checkmark, User, Calendar, Tags, Clock } from 'shared/icons';
|
||||
import { TaskMetaFilters, TaskMeta, TaskMetaMatch, DueDateFilterType } from 'shared/components/Lists';
|
||||
import Input from 'shared/components/ControlledInput';
|
||||
import { Popup, usePopup } from 'shared/components/PopupMenu';
|
||||
import produce from 'immer';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
import Member from 'shared/components/Member';
|
||||
import { useLabelsQuery } from 'shared/generated/graphql';
|
||||
|
||||
const FilterMember = styled(Member)`
|
||||
margin: 2px 0;
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
background: ${(props) => props.theme.colors.primary};
|
||||
}
|
||||
`;
|
||||
|
||||
export const Labels = styled.ul`
|
||||
list-style: none;
|
||||
margin: 0 8px;
|
||||
padding: 0;
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
export const Label = styled.li`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export const CardLabel = styled.span<{ active: boolean; color: string }>`
|
||||
${(props) =>
|
||||
props.active &&
|
||||
css`
|
||||
margin-left: 4px;
|
||||
box-shadow: -8px 0 ${mixin.darken(props.color, 0.12)};
|
||||
border-radius: 3px;
|
||||
`}
|
||||
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
margin: 0 0 4px;
|
||||
min-height: 20px;
|
||||
padding: 6px 12px;
|
||||
position: relative;
|
||||
transition: padding 85ms, margin 85ms, box-shadow 85ms;
|
||||
background-color: ${(props) => props.color};
|
||||
color: #fff;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-height: 31px;
|
||||
`;
|
||||
|
||||
export const ActionsList = styled.ul`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
export const ActionItem = styled.li`
|
||||
position: relative;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.colors.primary};
|
||||
}
|
||||
`;
|
||||
|
||||
export const ActionTitle = styled.span`
|
||||
margin-left: 20px;
|
||||
`;
|
||||
|
||||
const ActionItemSeparator = styled.li`
|
||||
color: ${(props) => mixin.rgba(props.theme.colors.text.primary, 0.4)};
|
||||
font-size: 12px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.25rem;
|
||||
`;
|
||||
|
||||
const ActiveIcon = styled(Checkmark)`
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
`;
|
||||
|
||||
const ItemIcon = styled.div`
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
const TaskNameInput = styled(Input)`
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const ActionItemLine = styled.div`
|
||||
height: 1px;
|
||||
border-top: 1px solid #414561;
|
||||
margin: 0.25rem !important;
|
||||
`;
|
||||
|
||||
type ControlFilterProps = {
|
||||
filters: TaskMetaFilters;
|
||||
userID: string;
|
||||
projectID: string;
|
||||
members: React.RefObject<Array<TaskUser>>;
|
||||
onChangeTaskMetaFilter: (filters: TaskMetaFilters) => void;
|
||||
};
|
||||
|
||||
const ControlFilter: React.FC<ControlFilterProps> = ({
|
||||
filters,
|
||||
onChangeTaskMetaFilter,
|
||||
userID,
|
||||
projectID,
|
||||
members,
|
||||
}) => {
|
||||
const [currentFilters, setFilters] = useState(filters);
|
||||
const [nameFilter, setNameFilter] = useState(filters.taskName ? filters.taskName.name : '');
|
||||
const [currentLabel, setCurrentLabel] = useState('');
|
||||
const { data } = useLabelsQuery({ variables: { projectID } });
|
||||
|
||||
const handleSetFilters = (f: TaskMetaFilters) => {
|
||||
setFilters(f);
|
||||
onChangeTaskMetaFilter(f);
|
||||
};
|
||||
|
||||
const handleNameChange = (nFilter: string) => {
|
||||
handleSetFilters(
|
||||
produce(currentFilters, (draftFilters) => {
|
||||
draftFilters.taskName = nFilter !== '' ? { name: nFilter } : null;
|
||||
}),
|
||||
);
|
||||
setNameFilter(nFilter);
|
||||
};
|
||||
|
||||
const { setTab } = usePopup();
|
||||
|
||||
const handleSetDueDate = (filterType: DueDateFilterType, label: string) => {
|
||||
handleSetFilters(
|
||||
produce(currentFilters, (draftFilters) => {
|
||||
if (draftFilters.dueDate && draftFilters.dueDate.type === filterType) {
|
||||
draftFilters.dueDate = null;
|
||||
} else {
|
||||
draftFilters.dueDate = {
|
||||
label,
|
||||
type: filterType,
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup tab={0} title={null}>
|
||||
<ActionsList>
|
||||
<TaskNameInput
|
||||
width="100%"
|
||||
onChange={(e) => handleNameChange(e.currentTarget.value)}
|
||||
value={nameFilter}
|
||||
autoFocus
|
||||
variant="alternate"
|
||||
placeholder="Task name..."
|
||||
/>
|
||||
<ActionItemSeparator>QUICK ADD</ActionItemSeparator>
|
||||
<ActionItem
|
||||
onClick={() => {
|
||||
handleSetFilters(
|
||||
produce(currentFilters, (draftFilters) => {
|
||||
if (members.current) {
|
||||
const member = members.current.find((m) => m.id === userID);
|
||||
const draftMember = draftFilters.members.find((m) => m.id === userID);
|
||||
if (member && !draftMember) {
|
||||
draftFilters.members.push({ id: userID, username: member.username ? member.username : '' });
|
||||
} else {
|
||||
draftFilters.members = draftFilters.members.filter((m) => m.id !== userID);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ItemIcon>
|
||||
<User width={12} height={12} />
|
||||
</ItemIcon>
|
||||
<ActionTitle>Just my tasks</ActionTitle>
|
||||
{currentFilters.members.find((m) => m.id === userID) && <ActiveIcon width={12} height={12} />}
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.THIS_WEEK, 'Due this week')}>
|
||||
<ItemIcon>
|
||||
<Calendar width={12} height={12} />
|
||||
</ItemIcon>
|
||||
<ActionTitle>Due this week</ActionTitle>
|
||||
{currentFilters.dueDate && currentFilters.dueDate.type === DueDateFilterType.THIS_WEEK && (
|
||||
<ActiveIcon width={12} height={12} />
|
||||
)}
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.NEXT_WEEK, 'Due next week')}>
|
||||
<ItemIcon>
|
||||
<Calendar width={12} height={12} />
|
||||
</ItemIcon>
|
||||
<ActionTitle>Due next week</ActionTitle>
|
||||
{currentFilters.dueDate && currentFilters.dueDate.type === DueDateFilterType.NEXT_WEEK && (
|
||||
<ActiveIcon width={12} height={12} />
|
||||
)}
|
||||
</ActionItem>
|
||||
<ActionItemLine />
|
||||
<ActionItem onClick={() => setTab(1)}>
|
||||
<ItemIcon>
|
||||
<Tags width={12} height={12} />
|
||||
</ItemIcon>
|
||||
<ActionTitle>By Label</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => setTab(2)}>
|
||||
<ItemIcon>
|
||||
<User width={12} height={12} />
|
||||
</ItemIcon>
|
||||
<ActionTitle>By Member</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => setTab(3)}>
|
||||
<ItemIcon>
|
||||
<Clock width={12} height={12} />
|
||||
</ItemIcon>
|
||||
<ActionTitle>By Due Date</ActionTitle>
|
||||
</ActionItem>
|
||||
</ActionsList>
|
||||
</Popup>
|
||||
<Popup tab={1} title="By Labels">
|
||||
<Labels>
|
||||
{data &&
|
||||
data.findProject.labels
|
||||
// .filter(label => '' === '' || (label.name && label.name.toLowerCase().startsWith(''.toLowerCase())))
|
||||
.map((label) => (
|
||||
<Label key={label.id}>
|
||||
<CardLabel
|
||||
key={label.id}
|
||||
color={label.labelColor.colorHex}
|
||||
active={currentLabel === label.id}
|
||||
onMouseEnter={() => {
|
||||
setCurrentLabel(label.id);
|
||||
}}
|
||||
onClick={() => {
|
||||
handleSetFilters(
|
||||
produce(currentFilters, (draftFilters) => {
|
||||
if (draftFilters.labels.find((l) => l.id === label.id)) {
|
||||
draftFilters.labels = draftFilters.labels.filter((l) => l.id !== label.id);
|
||||
} else {
|
||||
draftFilters.labels.push({
|
||||
id: label.id,
|
||||
name: label.name ?? '',
|
||||
color: label.labelColor.colorHex,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</CardLabel>
|
||||
</Label>
|
||||
))}
|
||||
</Labels>
|
||||
</Popup>
|
||||
<Popup tab={2} title="By Member">
|
||||
<ActionsList>
|
||||
{members.current &&
|
||||
members.current.map((member) => (
|
||||
<FilterMember
|
||||
key={member.id}
|
||||
member={member}
|
||||
showName
|
||||
onCardMemberClick={() => {
|
||||
handleSetFilters(
|
||||
produce(currentFilters, (draftFilters) => {
|
||||
if (draftFilters.members.find((m) => m.id === member.id)) {
|
||||
draftFilters.members = draftFilters.members.filter((m) => m.id !== member.id);
|
||||
} else {
|
||||
draftFilters.members.push({ id: member.id, username: member.username ?? '' });
|
||||
}
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ActionsList>
|
||||
</Popup>
|
||||
<Popup tab={3} title="By Due Date">
|
||||
<ActionsList>
|
||||
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.TODAY, 'Today')}>
|
||||
<ActionTitle>Today</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.THIS_WEEK, 'Due this week')}>
|
||||
<ActionTitle>This week</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.NEXT_WEEK, 'Due next week')}>
|
||||
<ActionTitle>Next week</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.OVERDUE, 'Overdue')}>
|
||||
<ActionTitle>Overdue</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItemLine />
|
||||
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.TOMORROW, 'In the next day')}>
|
||||
<ActionTitle>In the next day</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.ONE_WEEK, 'In the next week')}>
|
||||
<ActionTitle>In the next week</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.TWO_WEEKS, 'In the next two weeks')}>
|
||||
<ActionTitle>In the next two weeks</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.THREE_WEEKS, 'In the next three weeks')}>
|
||||
<ActionTitle>In the next three weeks</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.NO_DUE_DATE, 'Has no due date')}>
|
||||
<ActionTitle>Has no due date</ActionTitle>
|
||||
</ActionItem>
|
||||
</ActionsList>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ControlFilter;
|
87
frontend/src/Projects/Project/Board/ControlSort.tsx
Normal file
87
frontend/src/Projects/Project/Board/ControlSort.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import React, { useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { TaskSorting, TaskSortingType, TaskSortingDirection } from 'shared/utils/sorting';
|
||||
import { Checkmark } from 'shared/icons';
|
||||
|
||||
const ActiveIcon = styled(Checkmark)`
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
export const ActionsList = styled.ul`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
export const ActionItem = styled.li`
|
||||
position: relative;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.colors.primary};
|
||||
}
|
||||
`;
|
||||
|
||||
export const ActionTitle = styled.span`
|
||||
margin-left: 20px;
|
||||
`;
|
||||
|
||||
type ControlSortProps = {
|
||||
sorting: TaskSorting;
|
||||
onChangeTaskSorting: (taskSorting: TaskSorting) => void;
|
||||
};
|
||||
|
||||
const ControlSort: React.FC<ControlSortProps> = ({ sorting, onChangeTaskSorting }) => {
|
||||
const [currentSorting, setSorting] = useState(sorting);
|
||||
const handleSetSorting = (s: TaskSorting) => {
|
||||
setSorting(s);
|
||||
onChangeTaskSorting(s);
|
||||
};
|
||||
return (
|
||||
<ActionsList>
|
||||
<ActionItem onClick={() => handleSetSorting({ type: TaskSortingType.NONE, direction: TaskSortingDirection.ASC })}>
|
||||
{currentSorting.type === TaskSortingType.NONE && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>None</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem
|
||||
onClick={() => handleSetSorting({ type: TaskSortingType.DUE_DATE, direction: TaskSortingDirection.ASC })}
|
||||
>
|
||||
{currentSorting.type === TaskSortingType.DUE_DATE && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>Due date</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem
|
||||
onClick={() => handleSetSorting({ type: TaskSortingType.MEMBERS, direction: TaskSortingDirection.ASC })}
|
||||
>
|
||||
{currentSorting.type === TaskSortingType.MEMBERS && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>Members</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem
|
||||
onClick={() => handleSetSorting({ type: TaskSortingType.LABELS, direction: TaskSortingDirection.ASC })}
|
||||
>
|
||||
{currentSorting.type === TaskSortingType.LABELS && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>Labels</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem
|
||||
onClick={() => handleSetSorting({ type: TaskSortingType.TASK_TITLE, direction: TaskSortingDirection.ASC })}
|
||||
>
|
||||
{currentSorting.type === TaskSortingType.TASK_TITLE && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>Task title</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem
|
||||
onClick={() => handleSetSorting({ type: TaskSortingType.COMPLETE, direction: TaskSortingDirection.ASC })}
|
||||
>
|
||||
{currentSorting.type === TaskSortingType.COMPLETE && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>Complete</ActionTitle>
|
||||
</ActionItem>
|
||||
</ActionsList>
|
||||
);
|
||||
};
|
||||
|
||||
export default ControlSort;
|
149
frontend/src/Projects/Project/Board/ControlStatus.tsx
Normal file
149
frontend/src/Projects/Project/Board/ControlStatus.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import React, { useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { Checkmark } from 'shared/icons';
|
||||
import { TaskStatusFilter, TaskStatus, TaskSince } from 'shared/components/Lists';
|
||||
|
||||
export const ActionsList = styled.ul`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
export const ActionExtraMenuContainer = styled.div`
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: -4px;
|
||||
padding-left: 2px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const ActionItem = styled.li`
|
||||
position: relative;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.colors.primary};
|
||||
}
|
||||
&:hover ${ActionExtraMenuContainer} {
|
||||
visibility: visible;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ActionTitle = styled.span`
|
||||
margin-left: 20px;
|
||||
`;
|
||||
|
||||
export const ActionExtraMenu = styled.ul`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
padding: 5px;
|
||||
padding-top: 8px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 5px 25px 0 rgba(0, 0, 0, 0.1);
|
||||
|
||||
color: #c2c6dc;
|
||||
background: #262c49;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-color: #414561;
|
||||
`;
|
||||
|
||||
export const ActionExtraMenuItem = styled.li`
|
||||
position: relative;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.colors.primary};
|
||||
}
|
||||
`;
|
||||
const ActionExtraMenuSeparator = styled.li`
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
font-size: 12px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
`;
|
||||
|
||||
const ActiveIcon = styled(Checkmark)`
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
type ControlStatusProps = {
|
||||
filter: TaskStatusFilter;
|
||||
onChangeTaskStatusFilter: (filter: TaskStatusFilter) => void;
|
||||
};
|
||||
|
||||
const ControlStatus: React.FC<ControlStatusProps> = ({ filter, onChangeTaskStatusFilter }) => {
|
||||
const [currentFilter, setFilter] = useState(filter);
|
||||
const handleFilterChange = (f: TaskStatusFilter) => {
|
||||
setFilter(f);
|
||||
onChangeTaskStatusFilter(f);
|
||||
};
|
||||
const handleCompleteClick = (s: TaskSince) => {
|
||||
handleFilterChange({ status: TaskStatus.COMPLETE, since: s });
|
||||
};
|
||||
return (
|
||||
<ActionsList>
|
||||
<ActionItem onClick={() => handleFilterChange({ status: TaskStatus.INCOMPLETE, since: TaskSince.ALL })}>
|
||||
{currentFilter.status === TaskStatus.INCOMPLETE && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>Incomplete Tasks</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem>
|
||||
{currentFilter.status === TaskStatus.COMPLETE && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>Compelete Tasks</ActionTitle>
|
||||
<ActionExtraMenuContainer>
|
||||
<ActionExtraMenu>
|
||||
<ActionExtraMenuItem onClick={() => handleCompleteClick(TaskSince.ALL)}>
|
||||
{currentFilter.since === TaskSince.ALL && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>All completed tasks</ActionTitle>
|
||||
</ActionExtraMenuItem>
|
||||
<ActionExtraMenuSeparator>Marked complete since</ActionExtraMenuSeparator>
|
||||
<ActionExtraMenuItem onClick={() => handleCompleteClick(TaskSince.TODAY)}>
|
||||
{currentFilter.since === TaskSince.TODAY && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>Today</ActionTitle>
|
||||
</ActionExtraMenuItem>
|
||||
<ActionExtraMenuItem onClick={() => handleCompleteClick(TaskSince.YESTERDAY)}>
|
||||
{currentFilter.since === TaskSince.YESTERDAY && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>Yesterday</ActionTitle>
|
||||
</ActionExtraMenuItem>
|
||||
<ActionExtraMenuItem onClick={() => handleCompleteClick(TaskSince.ONE_WEEK)}>
|
||||
{currentFilter.since === TaskSince.ONE_WEEK && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>1 week</ActionTitle>
|
||||
</ActionExtraMenuItem>
|
||||
<ActionExtraMenuItem onClick={() => handleCompleteClick(TaskSince.TWO_WEEKS)}>
|
||||
{currentFilter.since === TaskSince.TWO_WEEKS && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>2 weeks</ActionTitle>
|
||||
</ActionExtraMenuItem>
|
||||
<ActionExtraMenuItem onClick={() => handleCompleteClick(TaskSince.THREE_WEEKS)}>
|
||||
{currentFilter.since === TaskSince.THREE_WEEKS && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>3 weeks</ActionTitle>
|
||||
</ActionExtraMenuItem>
|
||||
</ActionExtraMenu>
|
||||
</ActionExtraMenuContainer>
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => handleFilterChange({ status: TaskStatus.ALL, since: TaskSince.ALL })}>
|
||||
{currentFilter.status === TaskStatus.ALL && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>All Tasks</ActionTitle>
|
||||
</ActionItem>
|
||||
</ActionsList>
|
||||
);
|
||||
};
|
||||
|
||||
export default ControlStatus;
|
847
frontend/src/Projects/Project/Board/index.tsx
Normal file
847
frontend/src/Projects/Project/Board/index.tsx
Normal file
@ -0,0 +1,847 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import updateApolloCache from 'shared/utils/cache';
|
||||
import styled, { css } from 'styled-components/macro';
|
||||
import { Bolt, ToggleOn, Tags, CheckCircle, Sort, Filter } from 'shared/icons';
|
||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||
import { useRouteMatch, useHistory } from 'react-router-dom';
|
||||
import {
|
||||
useSetTaskCompleteMutation,
|
||||
useToggleTaskLabelMutation,
|
||||
useFindProjectQuery,
|
||||
useSortTaskGroupMutation,
|
||||
useUpdateTaskGroupNameMutation,
|
||||
useUpdateTaskNameMutation,
|
||||
useCreateTaskMutation,
|
||||
useDeleteTaskMutation,
|
||||
useUpdateTaskLocationMutation,
|
||||
useUpdateTaskGroupLocationMutation,
|
||||
useCreateTaskGroupMutation,
|
||||
useDeleteTaskGroupMutation,
|
||||
useAssignTaskMutation,
|
||||
FindProjectDocument,
|
||||
useUnassignTaskMutation,
|
||||
useUpdateTaskDueDateMutation,
|
||||
FindProjectQuery,
|
||||
useDuplicateTaskGroupMutation,
|
||||
DuplicateTaskGroupMutation,
|
||||
DuplicateTaskGroupDocument,
|
||||
useDeleteTaskGroupTasksMutation,
|
||||
} from 'shared/generated/graphql';
|
||||
|
||||
import QuickCardEditor from 'shared/components/QuickCardEditor';
|
||||
import ListActions from 'shared/components/ListActions';
|
||||
import MemberManager from 'shared/components/MemberManager';
|
||||
import SimpleLists, {
|
||||
TaskStatus,
|
||||
TaskSince,
|
||||
TaskStatusFilter,
|
||||
TaskMeta,
|
||||
TaskMetaMatch,
|
||||
TaskMetaFilters,
|
||||
} from 'shared/components/Lists';
|
||||
import { TaskSorting, TaskSortingType, TaskSortingDirection, sortTasks } from 'shared/utils/sorting';
|
||||
import produce from 'immer';
|
||||
import MiniProfile from 'shared/components/MiniProfile';
|
||||
import DueDateManager from 'shared/components/DueDateManager';
|
||||
import EmptyBoard from 'shared/components/EmptyBoard';
|
||||
import NOOP from 'shared/utils/noop';
|
||||
import LabelManagerEditor from 'Projects/Project/LabelManagerEditor';
|
||||
import Chip from 'shared/components/Chip';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useCurrentUser } from 'App/context';
|
||||
import ControlStatus from './ControlStatus';
|
||||
import ControlFilter from './ControlFilter';
|
||||
import ControlSort from './ControlSort';
|
||||
|
||||
const FilterChip = styled(Chip)`
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
type MetaFilterCloseFn = (meta: TaskMeta, key: string) => void;
|
||||
|
||||
const renderTaskSortingLabel = (sorting: TaskSorting) => {
|
||||
switch (sorting.type) {
|
||||
case TaskSortingType.TASK_TITLE:
|
||||
return 'Sort: Task Title';
|
||||
case TaskSortingType.MEMBERS:
|
||||
return 'Sort: Members';
|
||||
case TaskSortingType.DUE_DATE:
|
||||
return 'Sort: Due Date';
|
||||
case TaskSortingType.LABELS:
|
||||
return 'Sort: Labels';
|
||||
case TaskSortingType.COMPLETE:
|
||||
return 'Sort: Complete';
|
||||
default:
|
||||
return 'Sort';
|
||||
}
|
||||
};
|
||||
|
||||
const renderMetaFilters = (filters: TaskMetaFilters, onClose: MetaFilterCloseFn) => {
|
||||
const filterChips = [];
|
||||
if (filters.taskName) {
|
||||
filterChips.push(
|
||||
<FilterChip
|
||||
key="task-name"
|
||||
label={`Title: ${filters.taskName.name}`}
|
||||
onClose={() => onClose(TaskMeta.TITLE, 'task-name')}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.dueDate) {
|
||||
filterChips.push(
|
||||
<FilterChip
|
||||
key="due-date"
|
||||
label={filters.dueDate.label}
|
||||
onClose={() => onClose(TaskMeta.DUE_DATE, 'due-date')}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
for (const memberFilter of filters.members) {
|
||||
filterChips.push(
|
||||
<FilterChip
|
||||
key={`member-${memberFilter.id}`}
|
||||
label={`Member: ${memberFilter.username}`}
|
||||
onClose={() => onClose(TaskMeta.MEMBER, memberFilter.id)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
for (const labelFilter of filters.labels) {
|
||||
filterChips.push(
|
||||
<FilterChip
|
||||
key={`label-${labelFilter.id}`}
|
||||
label={labelFilter.name === '' ? 'Label' : `Label: ${labelFilter.name}`}
|
||||
color={labelFilter.color}
|
||||
onClose={() => onClose(TaskMeta.LABEL, labelFilter.id)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
return filterChips;
|
||||
};
|
||||
|
||||
const ProjectBar = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
`;
|
||||
|
||||
const ProjectActions = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const ProjectActionWrapper = styled.div<{ disabled?: boolean }>`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 15px;
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
|
||||
&:not(:last-of-type) {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.colors.text.secondary};
|
||||
}
|
||||
${(props) =>
|
||||
props.disabled &&
|
||||
css`
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
`}
|
||||
`;
|
||||
|
||||
const ProjectActionText = styled.span`
|
||||
padding-left: 4px;
|
||||
`;
|
||||
|
||||
type ProjectActionProps = {
|
||||
onClick?: (target: React.RefObject<HTMLElement>) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const ProjectAction: React.FC<ProjectActionProps> = ({ onClick, disabled = false, children }) => {
|
||||
const $container = useRef<HTMLDivElement>(null);
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick($container);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<ProjectActionWrapper ref={$container} onClick={handleClick} disabled={disabled}>
|
||||
{children}
|
||||
</ProjectActionWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
interface QuickCardEditorState {
|
||||
isOpen: boolean;
|
||||
target: React.RefObject<HTMLElement> | null;
|
||||
taskID: string | null;
|
||||
taskGroupID: string | null;
|
||||
}
|
||||
|
||||
const initialQuickCardEditorState: QuickCardEditorState = {
|
||||
taskID: null,
|
||||
taskGroupID: null,
|
||||
isOpen: false,
|
||||
target: null,
|
||||
};
|
||||
|
||||
type ProjectBoardProps = {
|
||||
onCardLabelClick?: () => void;
|
||||
cardLabelVariant?: CardLabelVariant;
|
||||
projectID: string;
|
||||
};
|
||||
|
||||
export const BoardLoading = () => {
|
||||
const { user } = useCurrentUser();
|
||||
return (
|
||||
<>
|
||||
<ProjectBar>
|
||||
<ProjectActions>
|
||||
<ProjectAction>
|
||||
<CheckCircle width={13} height={13} />
|
||||
<ProjectActionText>All Tasks</ProjectActionText>
|
||||
</ProjectAction>
|
||||
<ProjectAction>
|
||||
<Sort width={13} height={13} />
|
||||
<ProjectActionText>Sort</ProjectActionText>
|
||||
</ProjectAction>
|
||||
<ProjectAction>
|
||||
<Filter width={13} height={13} />
|
||||
<ProjectActionText>Filter</ProjectActionText>
|
||||
</ProjectAction>
|
||||
</ProjectActions>
|
||||
{user && (
|
||||
<ProjectActions>
|
||||
<ProjectAction>
|
||||
<Tags width={13} height={13} />
|
||||
<ProjectActionText>Labels</ProjectActionText>
|
||||
</ProjectAction>
|
||||
<ProjectAction disabled>
|
||||
<ToggleOn width={13} height={13} />
|
||||
<ProjectActionText>Fields</ProjectActionText>
|
||||
</ProjectAction>
|
||||
<ProjectAction disabled>
|
||||
<Bolt width={13} height={13} />
|
||||
<ProjectActionText>Rules</ProjectActionText>
|
||||
</ProjectAction>
|
||||
</ProjectActions>
|
||||
)}
|
||||
</ProjectBar>
|
||||
<EmptyBoard />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const initTaskStatusFilter: TaskStatusFilter = {
|
||||
status: TaskStatus.ALL,
|
||||
since: TaskSince.ALL,
|
||||
};
|
||||
|
||||
const initTaskMetaFilters: TaskMetaFilters = {
|
||||
match: TaskMetaMatch.MATCH_ANY,
|
||||
dueDate: null,
|
||||
taskName: null,
|
||||
labels: [],
|
||||
members: [],
|
||||
};
|
||||
|
||||
const initTaskSorting: TaskSorting = {
|
||||
type: TaskSortingType.NONE,
|
||||
direction: TaskSortingDirection.ASC,
|
||||
};
|
||||
|
||||
const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick, cardLabelVariant }) => {
|
||||
const [assignTask] = useAssignTaskMutation();
|
||||
const [unassignTask] = useUnassignTaskMutation();
|
||||
const match = useRouteMatch();
|
||||
const labelsRef = useRef<Array<ProjectLabel>>([]);
|
||||
const membersRef = useRef<Array<TaskUser>>([]);
|
||||
const { showPopup, hidePopup } = usePopup();
|
||||
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
|
||||
const [quickCardEditor, setQuickCardEditor] = useState(initialQuickCardEditorState);
|
||||
const [updateTaskGroupLocation] = useUpdateTaskGroupLocationMutation({});
|
||||
const [taskStatusFilter, setTaskStatusFilter] = useState(initTaskStatusFilter);
|
||||
const [taskMetaFilters, setTaskMetaFilters] = useState(initTaskMetaFilters);
|
||||
const [taskSorting, setTaskSorting] = useState(initTaskSorting);
|
||||
const history = useHistory();
|
||||
const [sortTaskGroup] = useSortTaskGroupMutation({
|
||||
onCompleted: () => {
|
||||
toast('List was sorted');
|
||||
},
|
||||
});
|
||||
const [deleteTaskGroup] = useDeleteTaskGroupMutation({
|
||||
update: (client, deletedTaskGroupData) => {
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
(cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
draftCache.findProject.taskGroups = draftCache.findProject.taskGroups.filter(
|
||||
(taskGroup: TaskGroup) => taskGroup.id !== deletedTaskGroupData.data?.deleteTaskGroup.taskGroup.id,
|
||||
);
|
||||
}),
|
||||
{ projectID },
|
||||
);
|
||||
},
|
||||
});
|
||||
const [updateTaskName] = useUpdateTaskNameMutation();
|
||||
const [createTask] = useCreateTaskMutation({
|
||||
update: (client, newTaskData) => {
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
(cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
const { taskGroups } = cache.findProject;
|
||||
const idx = taskGroups.findIndex((taskGroup) => taskGroup.id === newTaskData.data?.createTask.taskGroup.id);
|
||||
if (idx !== -1) {
|
||||
if (newTaskData.data) {
|
||||
draftCache.findProject.taskGroups[idx].tasks.push({ ...newTaskData.data.createTask });
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ projectID },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const [createTaskGroup] = useCreateTaskGroupMutation({
|
||||
update: (client, newTaskGroupData) => {
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
(cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
if (newTaskGroupData.data) {
|
||||
draftCache.findProject.taskGroups.push({ ...newTaskGroupData.data.createTaskGroup, tasks: [] });
|
||||
}
|
||||
}),
|
||||
{ projectID },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const [updateTaskGroupName] = useUpdateTaskGroupNameMutation({});
|
||||
const { loading, data } = useFindProjectQuery({
|
||||
variables: { projectID },
|
||||
});
|
||||
const [deleteTaskGroupTasks] = useDeleteTaskGroupTasksMutation({
|
||||
update: (client, resp) =>
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
(cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
const idx = cache.findProject.taskGroups.findIndex(
|
||||
(t) => t.id === resp.data?.deleteTaskGroupTasks.taskGroupID,
|
||||
);
|
||||
if (idx !== -1) {
|
||||
draftCache.findProject.taskGroups[idx].tasks = [];
|
||||
}
|
||||
}),
|
||||
{ projectID },
|
||||
),
|
||||
});
|
||||
const [duplicateTaskGroup] = useDuplicateTaskGroupMutation({
|
||||
update: (client, resp) => {
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
(cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
if (resp.data) {
|
||||
draftCache.findProject.taskGroups.push(resp.data.duplicateTaskGroup.taskGroup);
|
||||
}
|
||||
}),
|
||||
{ projectID },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const [updateTaskDueDate] = useUpdateTaskDueDateMutation();
|
||||
const [setTaskComplete] = useSetTaskCompleteMutation();
|
||||
const [updateTaskLocation] = useUpdateTaskLocationMutation({
|
||||
update: (client, newTask) => {
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
(cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
if (newTask.data) {
|
||||
const { previousTaskGroupID, task } = newTask.data.updateTaskLocation;
|
||||
if (previousTaskGroupID !== task.taskGroup.id) {
|
||||
const { taskGroups } = cache.findProject;
|
||||
const oldTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === previousTaskGroupID);
|
||||
const newTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === task.taskGroup.id);
|
||||
if (oldTaskGroupIdx !== -1 && newTaskGroupIdx !== -1) {
|
||||
const previousTask = cache.findProject.taskGroups[oldTaskGroupIdx].tasks.find(
|
||||
(t) => t.id === task.id,
|
||||
);
|
||||
draftCache.findProject.taskGroups[oldTaskGroupIdx].tasks = taskGroups[oldTaskGroupIdx].tasks.filter(
|
||||
(t: Task) => t.id !== task.id,
|
||||
);
|
||||
if (previousTask) {
|
||||
draftCache.findProject.taskGroups[newTaskGroupIdx].tasks = [
|
||||
...taskGroups[newTaskGroupIdx].tasks,
|
||||
{ ...previousTask },
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ projectID },
|
||||
);
|
||||
},
|
||||
});
|
||||
const { user } = useCurrentUser();
|
||||
const [deleteTask] = useDeleteTaskMutation();
|
||||
const [toggleTaskLabel] = useToggleTaskLabelMutation({
|
||||
onCompleted: (newTaskLabel) => {
|
||||
taskLabelsRef.current = newTaskLabel.toggleTaskLabel.task.labels;
|
||||
},
|
||||
});
|
||||
|
||||
const onCreateTask = (taskGroupID: string, name: string) => {
|
||||
if (data) {
|
||||
const taskGroup = data.findProject.taskGroups.find((t) => t.id === taskGroupID);
|
||||
if (taskGroup) {
|
||||
let position = 65535;
|
||||
if (taskGroup.tasks.length !== 0) {
|
||||
const [lastTask] = taskGroup.tasks
|
||||
.slice()
|
||||
.sort((a: any, b: any) => a.position - b.position)
|
||||
.slice(-1);
|
||||
position = Math.ceil(lastTask.position) * 2 + 1;
|
||||
}
|
||||
|
||||
createTask({
|
||||
variables: { taskGroupID, name, position },
|
||||
optimisticResponse: {
|
||||
__typename: 'Mutation',
|
||||
createTask: {
|
||||
__typename: 'Task',
|
||||
id: `${Math.round(Math.random() * -1000000)}`,
|
||||
shortId: '',
|
||||
name,
|
||||
watched: false,
|
||||
complete: false,
|
||||
completedAt: null,
|
||||
hasTime: false,
|
||||
taskGroup: {
|
||||
__typename: 'TaskGroup',
|
||||
id: taskGroup.id,
|
||||
name: taskGroup.name,
|
||||
position: taskGroup.position,
|
||||
},
|
||||
badges: {
|
||||
__typename: 'TaskBadges',
|
||||
checklist: null,
|
||||
},
|
||||
position,
|
||||
dueDate: { at: null },
|
||||
description: null,
|
||||
labels: [],
|
||||
assigned: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onCreateList = (listName: string) => {
|
||||
if (data && projectID) {
|
||||
const [lastColumn] = data.findProject.taskGroups.sort((a, b) => a.position - b.position).slice(-1);
|
||||
let position = 65535;
|
||||
if (lastColumn) {
|
||||
position = lastColumn.position * 2 + 1;
|
||||
}
|
||||
createTaskGroup({ variables: { projectID, name: listName, position } });
|
||||
}
|
||||
};
|
||||
|
||||
const getTaskStatusFilterLabel = (filter: TaskStatusFilter) => {
|
||||
if (filter.status === TaskStatus.COMPLETE) {
|
||||
return 'Complete';
|
||||
}
|
||||
if (filter.status === TaskStatus.INCOMPLETE) {
|
||||
return 'Incomplete';
|
||||
}
|
||||
return 'All Tasks';
|
||||
};
|
||||
|
||||
if (data) {
|
||||
labelsRef.current = data.findProject.labels;
|
||||
membersRef.current = data.findProject.members;
|
||||
const onQuickEditorOpen = ($target: React.RefObject<HTMLElement>, taskID: string, taskGroupID: string) => {
|
||||
const taskGroup = data.findProject.taskGroups.find((t) => t.id === taskGroupID);
|
||||
const currentTask = taskGroup ? taskGroup.tasks.find((t) => t.id === taskID) : null;
|
||||
if (currentTask) {
|
||||
setQuickCardEditor({
|
||||
target: $target,
|
||||
isOpen: true,
|
||||
taskID: currentTask.id,
|
||||
taskGroupID: currentTask.taskGroup.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
let currentQuickTask = null;
|
||||
if (quickCardEditor.taskID && quickCardEditor.taskGroupID) {
|
||||
const targetGroup = data.findProject.taskGroups.find((t) => t.id === quickCardEditor.taskGroupID);
|
||||
if (targetGroup) {
|
||||
currentQuickTask = targetGroup.tasks.find((t) => t.id === quickCardEditor.taskID);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ProjectBar>
|
||||
<ProjectActions>
|
||||
<ProjectAction
|
||||
onClick={(target) => {
|
||||
showPopup(
|
||||
target,
|
||||
<Popup tab={0} title={null}>
|
||||
<ControlStatus
|
||||
filter={taskStatusFilter}
|
||||
onChangeTaskStatusFilter={(filter) => {
|
||||
setTaskStatusFilter(filter);
|
||||
hidePopup();
|
||||
}}
|
||||
/>
|
||||
</Popup>,
|
||||
{ width: 185 },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CheckCircle width={13} height={13} />
|
||||
<ProjectActionText>{getTaskStatusFilterLabel(taskStatusFilter)}</ProjectActionText>
|
||||
</ProjectAction>
|
||||
<ProjectAction
|
||||
onClick={(target) => {
|
||||
showPopup(
|
||||
target,
|
||||
<Popup tab={0} title={null}>
|
||||
<ControlSort
|
||||
sorting={taskSorting}
|
||||
onChangeTaskSorting={(sorting) => {
|
||||
setTaskSorting(sorting);
|
||||
}}
|
||||
/>
|
||||
</Popup>,
|
||||
{ width: 185 },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Sort width={13} height={13} />
|
||||
<ProjectActionText>{renderTaskSortingLabel(taskSorting)}</ProjectActionText>
|
||||
</ProjectAction>
|
||||
<ProjectAction
|
||||
onClick={(target) => {
|
||||
showPopup(
|
||||
target,
|
||||
<ControlFilter
|
||||
filters={taskMetaFilters}
|
||||
onChangeTaskMetaFilter={(filter) => {
|
||||
setTaskMetaFilters(filter);
|
||||
}}
|
||||
userID={user ?? ''}
|
||||
projectID={projectID}
|
||||
members={membersRef}
|
||||
/>,
|
||||
{ width: 200 },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Filter width={13} height={13} />
|
||||
<ProjectActionText>Filter</ProjectActionText>
|
||||
</ProjectAction>
|
||||
{renderMetaFilters(taskMetaFilters, (meta, id) => {
|
||||
setTaskMetaFilters(
|
||||
produce(taskMetaFilters, (draftFilters) => {
|
||||
if (meta === TaskMeta.MEMBER) {
|
||||
draftFilters.members = draftFilters.members.filter((m) => m.id !== id);
|
||||
} else if (meta === TaskMeta.LABEL) {
|
||||
draftFilters.labels = draftFilters.labels.filter((m) => m.id !== id);
|
||||
} else if (meta === TaskMeta.TITLE) {
|
||||
draftFilters.taskName = null;
|
||||
} else if (meta === TaskMeta.DUE_DATE) {
|
||||
draftFilters.dueDate = null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
})}
|
||||
</ProjectActions>
|
||||
{user && (
|
||||
<ProjectActions>
|
||||
<ProjectAction
|
||||
onClick={($labelsRef) => {
|
||||
showPopup(
|
||||
$labelsRef,
|
||||
<LabelManagerEditor taskLabels={null} labelColors={data.labelColors} projectID={projectID ?? ''} />,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Tags width={13} height={13} />
|
||||
<ProjectActionText>Labels</ProjectActionText>
|
||||
</ProjectAction>
|
||||
<ProjectAction disabled>
|
||||
<ToggleOn width={13} height={13} />
|
||||
<ProjectActionText>Fields</ProjectActionText>
|
||||
</ProjectAction>
|
||||
<ProjectAction disabled>
|
||||
<Bolt width={13} height={13} />
|
||||
<ProjectActionText>Rules</ProjectActionText>
|
||||
</ProjectAction>
|
||||
</ProjectActions>
|
||||
)}
|
||||
</ProjectBar>
|
||||
<SimpleLists
|
||||
isPublic={user === null}
|
||||
onTaskClick={(task) => {
|
||||
history.push(`${match.url}/c/${task.shortId}`);
|
||||
}}
|
||||
onCardLabelClick={onCardLabelClick ?? NOOP}
|
||||
cardLabelVariant={cardLabelVariant ?? 'large'}
|
||||
onTaskDrop={(droppedTask, previousTaskGroupID) => {
|
||||
updateTaskLocation({
|
||||
variables: {
|
||||
taskID: droppedTask.id,
|
||||
taskGroupID: droppedTask.taskGroup.id,
|
||||
position: droppedTask.position,
|
||||
},
|
||||
optimisticResponse: {
|
||||
__typename: 'Mutation',
|
||||
updateTaskLocation: {
|
||||
__typename: 'UpdateTaskLocationPayload',
|
||||
previousTaskGroupID,
|
||||
task: {
|
||||
...droppedTask,
|
||||
__typename: 'Task',
|
||||
name: droppedTask.name,
|
||||
id: droppedTask.id,
|
||||
position: droppedTask.position,
|
||||
taskGroup: {
|
||||
id: droppedTask.taskGroup.id,
|
||||
__typename: 'TaskGroup',
|
||||
},
|
||||
createdAt: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
onTaskGroupDrop={(droppedTaskGroup) => {
|
||||
updateTaskGroupLocation({
|
||||
variables: { taskGroupID: droppedTaskGroup.id, position: droppedTaskGroup.position },
|
||||
optimisticResponse: {
|
||||
__typename: 'Mutation',
|
||||
updateTaskGroupLocation: {
|
||||
id: droppedTaskGroup.id,
|
||||
position: droppedTaskGroup.position,
|
||||
__typename: 'TaskGroup',
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
taskGroups={data.findProject.taskGroups}
|
||||
taskStatusFilter={taskStatusFilter}
|
||||
taskMetaFilters={taskMetaFilters}
|
||||
taskSorting={taskSorting}
|
||||
onCreateTask={onCreateTask}
|
||||
onCreateTaskGroup={onCreateList}
|
||||
onCardMemberClick={($targetRef, _taskID, memberID) => {
|
||||
const member = data.findProject.members.find((m) => m.id === memberID);
|
||||
if (member) {
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<MiniProfile
|
||||
user={member}
|
||||
bio="None"
|
||||
onRemoveFromTask={() => {
|
||||
/* unassignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } }); */
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}}
|
||||
onChangeTaskGroupName={(taskGroupID, name) => {
|
||||
updateTaskGroupName({ variables: { taskGroupID, name } });
|
||||
}}
|
||||
onQuickEditorOpen={onQuickEditorOpen}
|
||||
onExtraMenuOpen={(taskGroupID: string, $targetRef: any) => {
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<ListActions
|
||||
taskGroupID={taskGroupID}
|
||||
onDeleteTaskGroupTasks={() => {
|
||||
deleteTaskGroupTasks({ variables: { taskGroupID } });
|
||||
hidePopup();
|
||||
}}
|
||||
onSortTaskGroup={(taskSort) => {
|
||||
const taskGroup = data.findProject.taskGroups.find((t) => t.id === taskGroupID);
|
||||
if (taskGroup) {
|
||||
const tasks: Array<{ taskID: string; position: number }> = taskGroup.tasks
|
||||
.sort((a, b) => sortTasks(a, b, taskSort))
|
||||
.reduce((prevTasks: Array<{ taskID: string; position: number }>, t, idx) => {
|
||||
prevTasks.push({ taskID: t.id, position: (idx + 1) * 2048 });
|
||||
return tasks;
|
||||
}, []);
|
||||
sortTaskGroup({ variables: { taskGroupID, tasks } });
|
||||
hidePopup();
|
||||
}
|
||||
}}
|
||||
onDuplicateTaskGroup={(newName) => {
|
||||
const idx = data.findProject.taskGroups.findIndex((t) => t.id === taskGroupID);
|
||||
if (idx !== -1) {
|
||||
const taskGroups = data.findProject.taskGroups.sort((a, b) => a.position - b.position);
|
||||
const prevPos = taskGroups[idx].position;
|
||||
const next = taskGroups[idx + 1];
|
||||
let newPos = prevPos * 2;
|
||||
if (next) {
|
||||
newPos = (prevPos + next.position) / 2.0;
|
||||
}
|
||||
duplicateTaskGroup({ variables: { projectID, taskGroupID, name: newName, position: newPos } });
|
||||
hidePopup();
|
||||
}
|
||||
}}
|
||||
onArchiveTaskGroup={(tgID) => {
|
||||
deleteTaskGroup({ variables: { taskGroupID: tgID } });
|
||||
hidePopup();
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{quickCardEditor.isOpen && currentQuickTask && quickCardEditor.target && (
|
||||
<QuickCardEditor
|
||||
task={currentQuickTask}
|
||||
onCloseEditor={() => setQuickCardEditor(initialQuickCardEditorState)}
|
||||
onEditCard={(_taskGroupID: string, taskID: string, cardName: string) => {
|
||||
updateTaskName({ variables: { taskID, name: cardName } });
|
||||
}}
|
||||
onOpenMembersPopup={($targetRef, task) => {
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<Popup title="Members" tab={0} onClose={() => hidePopup()}>
|
||||
<MemberManager
|
||||
availableMembers={data.findProject.members}
|
||||
activeMembers={task.assigned ?? []}
|
||||
onMemberChange={(member, isActive) => {
|
||||
if (isActive) {
|
||||
assignTask({ variables: { taskID: task.id, userID: member.id } });
|
||||
} else {
|
||||
unassignTask({ variables: { taskID: task.id, userID: member.id } });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popup>,
|
||||
);
|
||||
}}
|
||||
onCardMemberClick={($targetRef, _taskID, memberID) => {
|
||||
const member = data.findProject.members.find((m) => m.id === memberID);
|
||||
if (member) {
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<MiniProfile
|
||||
bio="None"
|
||||
user={member}
|
||||
onRemoveFromTask={() => {
|
||||
/* unassignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } }); */
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}}
|
||||
onOpenLabelsPopup={($targetRef, task) => {
|
||||
taskLabelsRef.current = task.labels;
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<LabelManagerEditor
|
||||
onLabelToggle={(labelID) => {
|
||||
toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } });
|
||||
}}
|
||||
taskID={task.id}
|
||||
labelColors={data.labelColors}
|
||||
taskLabels={taskLabelsRef}
|
||||
projectID={projectID ?? ''}
|
||||
/>,
|
||||
);
|
||||
}}
|
||||
onArchiveCard={(_listId: string, cardId: string) => {
|
||||
return deleteTask({
|
||||
variables: { taskID: cardId },
|
||||
update: (client) => {
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
(cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
draftCache.findProject.taskGroups = cache.findProject.taskGroups.map((taskGroup) => ({
|
||||
...taskGroup,
|
||||
tasks: taskGroup.tasks.filter((t) => t.id !== cardId),
|
||||
}));
|
||||
}),
|
||||
{ projectID },
|
||||
);
|
||||
},
|
||||
});
|
||||
}}
|
||||
onOpenDueDatePopup={($targetRef, task) => {
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<Popup title="Change Due Date" tab={0} onClose={() => hidePopup()}>
|
||||
<DueDateManager
|
||||
task={task}
|
||||
onRemoveDueDate={(t) => {
|
||||
hidePopup();
|
||||
updateTaskDueDate({
|
||||
variables: {
|
||||
taskID: t.id,
|
||||
dueDate: null,
|
||||
hasTime: false,
|
||||
deleteNotifications: [],
|
||||
updateNotifications: [],
|
||||
createNotifications: [],
|
||||
},
|
||||
});
|
||||
}}
|
||||
onDueDateChange={(t, newDueDate, hasTime) => {
|
||||
hidePopup();
|
||||
updateTaskDueDate({
|
||||
variables: {
|
||||
taskID: t.id,
|
||||
dueDate: newDueDate,
|
||||
hasTime,
|
||||
deleteNotifications: [],
|
||||
updateNotifications: [],
|
||||
createNotifications: [],
|
||||
},
|
||||
});
|
||||
}}
|
||||
onCancel={NOOP}
|
||||
/>
|
||||
</Popup>,
|
||||
);
|
||||
}}
|
||||
onToggleComplete={(task) => {
|
||||
setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } });
|
||||
}}
|
||||
target={quickCardEditor.target}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <BoardLoading />;
|
||||
};
|
||||
|
||||
export default ProjectBoard;
|
741
frontend/src/Projects/Project/Details/index.tsx
Normal file
741
frontend/src/Projects/Project/Details/index.tsx
Normal file
@ -0,0 +1,741 @@
|
||||
import React, { useState } from 'react';
|
||||
import Modal from 'shared/components/Modal';
|
||||
import TaskDetails from 'shared/components/TaskDetails';
|
||||
import TaskDetailsLoading from 'shared/components/TaskDetails/Loading';
|
||||
import { Popup, usePopup } from 'shared/components/PopupMenu';
|
||||
import MemberManager from 'shared/components/MemberManager';
|
||||
import { useRouteMatch, useHistory, useParams } from 'react-router';
|
||||
import {
|
||||
useDeleteTaskChecklistMutation,
|
||||
useToggleTaskWatchMutation,
|
||||
useUpdateTaskChecklistNameMutation,
|
||||
useUpdateTaskChecklistItemLocationMutation,
|
||||
useCreateTaskChecklistMutation,
|
||||
useFindTaskQuery,
|
||||
DueDateNotificationDuration,
|
||||
useUpdateTaskDueDateMutation,
|
||||
useSetTaskCompleteMutation,
|
||||
useAssignTaskMutation,
|
||||
useUnassignTaskMutation,
|
||||
useSetTaskChecklistItemCompleteMutation,
|
||||
useUpdateTaskChecklistLocationMutation,
|
||||
useDeleteTaskChecklistItemMutation,
|
||||
useUpdateTaskChecklistItemNameMutation,
|
||||
useCreateTaskChecklistItemMutation,
|
||||
FindTaskDocument,
|
||||
FindTaskQuery,
|
||||
useCreateTaskCommentMutation,
|
||||
useDeleteTaskCommentMutation,
|
||||
useUpdateTaskCommentMutation,
|
||||
} from 'shared/generated/graphql';
|
||||
import { useCurrentUser } from 'App/context';
|
||||
import MiniProfile from 'shared/components/MiniProfile';
|
||||
import DueDateManager from 'shared/components/DueDateManager';
|
||||
import produce from 'immer';
|
||||
import styled from 'styled-components';
|
||||
import Button from 'shared/components/Button';
|
||||
import Input from 'shared/components/Input';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import updateApolloCache from 'shared/utils/cache';
|
||||
import NOOP from 'shared/utils/noop';
|
||||
import polling from 'shared/utils/polling';
|
||||
|
||||
export const ActionsList = styled.ul`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
export const ActionItem = styled.li`
|
||||
position: relative;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.colors.primary};
|
||||
}
|
||||
`;
|
||||
|
||||
export const ActionTitle = styled.span`
|
||||
margin-left: 20px;
|
||||
`;
|
||||
|
||||
const WarningLabel = styled.p`
|
||||
font-size: 14px;
|
||||
margin: 8px 12px;
|
||||
`;
|
||||
const DeleteConfirm = styled(Button)`
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 6px;
|
||||
`;
|
||||
|
||||
type TaskCommentActionsProps = {
|
||||
onDeleteComment: () => void;
|
||||
onEditComment: () => void;
|
||||
};
|
||||
const TaskCommentActions: React.FC<TaskCommentActionsProps> = ({ onDeleteComment, onEditComment }) => {
|
||||
const { setTab } = usePopup();
|
||||
return (
|
||||
<>
|
||||
<Popup tab={0} title={null}>
|
||||
<ActionsList>
|
||||
<ActionItem>
|
||||
<ActionTitle>Pin to top</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => onEditComment()}>
|
||||
<ActionTitle>Edit comment</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => setTab(1)}>
|
||||
<ActionTitle>Delete comment</ActionTitle>
|
||||
</ActionItem>
|
||||
</ActionsList>
|
||||
</Popup>
|
||||
<Popup tab={1} title="Delete comment?">
|
||||
<WarningLabel>Deleting a comment can not be undone.</WarningLabel>
|
||||
<DeleteConfirm onClick={() => onDeleteComment()} color="danger">
|
||||
Delete comment
|
||||
</DeleteConfirm>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const calculateChecklistBadge = (checklists: Array<TaskChecklist>) => {
|
||||
const total = checklists.reduce((prev: any, next: any) => {
|
||||
return (
|
||||
prev +
|
||||
next.items.reduce((innerPrev: any, _item: any) => {
|
||||
return innerPrev + 1;
|
||||
}, 0)
|
||||
);
|
||||
}, 0);
|
||||
const complete = checklists.reduce(
|
||||
(prev: any, next: any) =>
|
||||
prev +
|
||||
next.items.reduce((innerPrev: any, item: any) => {
|
||||
return innerPrev + (item.complete ? 1 : 0);
|
||||
}, 0),
|
||||
0,
|
||||
);
|
||||
return { total, complete };
|
||||
};
|
||||
|
||||
const DeleteChecklistButton = styled(Button)`
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
margin-top: 8px;
|
||||
`;
|
||||
type CreateChecklistData = {
|
||||
name: string;
|
||||
};
|
||||
const CreateChecklistForm = styled.form`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const CreateChecklistButton = styled(Button)`
|
||||
margin-top: 8px;
|
||||
padding: 6px 12px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const CreateChecklistInput = styled(Input)`
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
type CreateChecklistPopupProps = {
|
||||
onCreateChecklist: (data: CreateChecklistData) => void;
|
||||
};
|
||||
|
||||
const CreateChecklistPopup: React.FC<CreateChecklistPopupProps> = ({ onCreateChecklist }) => {
|
||||
const { register, handleSubmit } = useForm<CreateChecklistData>();
|
||||
const createUser = (data: CreateChecklistData) => {
|
||||
onCreateChecklist(data);
|
||||
};
|
||||
return (
|
||||
<CreateChecklistForm onSubmit={handleSubmit(createUser)}>
|
||||
<CreateChecklistInput
|
||||
floatingLabel
|
||||
autoFocus
|
||||
autoSelect
|
||||
defaultValue="Checklist"
|
||||
width="100%"
|
||||
label="Name"
|
||||
variant="alternate"
|
||||
{...register('name', { required: 'Checklist name is required' })}
|
||||
/>
|
||||
<CreateChecklistButton type="submit">Create</CreateChecklistButton>
|
||||
</CreateChecklistForm>
|
||||
);
|
||||
};
|
||||
|
||||
type DetailsProps = {
|
||||
projectURL: string;
|
||||
onTaskNameChange: (task: Task, newName: string) => void;
|
||||
onTaskDescriptionChange: (task: Task, newDescription: string) => void;
|
||||
onDeleteTask: (task: Task) => void;
|
||||
onOpenAddLabelPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
|
||||
availableMembers: Array<TaskUser>;
|
||||
refreshCache: () => void;
|
||||
};
|
||||
|
||||
const initialMemberPopupState = { taskID: '', isOpen: false, top: 0, left: 0 };
|
||||
|
||||
const Details: React.FC<DetailsProps> = ({
|
||||
projectURL,
|
||||
onTaskNameChange,
|
||||
onTaskDescriptionChange,
|
||||
onDeleteTask,
|
||||
onOpenAddLabelPopup,
|
||||
availableMembers,
|
||||
refreshCache,
|
||||
}) => {
|
||||
const { user } = useCurrentUser();
|
||||
const { taskID } = useParams<{ taskID: string }>();
|
||||
const { showPopup, hidePopup } = usePopup();
|
||||
const history = useHistory();
|
||||
const [deleteTaskComment] = useDeleteTaskCommentMutation({
|
||||
update: (client, response) => {
|
||||
updateApolloCache<FindTaskQuery>(
|
||||
client,
|
||||
FindTaskDocument,
|
||||
(cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
if (response.data) {
|
||||
draftCache.findTask.comments = cache.findTask.comments.filter(
|
||||
(c) => c.id !== response.data?.deleteTaskComment.commentID,
|
||||
);
|
||||
}
|
||||
}),
|
||||
{ taskID },
|
||||
);
|
||||
},
|
||||
});
|
||||
const [toggleTaskWatch] = useToggleTaskWatchMutation();
|
||||
const [createTaskComment] = useCreateTaskCommentMutation({
|
||||
update: (client, response) => {
|
||||
updateApolloCache<FindTaskQuery>(
|
||||
client,
|
||||
FindTaskDocument,
|
||||
(cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
if (response.data) {
|
||||
draftCache.findTask.comments.push({
|
||||
...response.data.createTaskComment.comment,
|
||||
});
|
||||
}
|
||||
}),
|
||||
{ taskID },
|
||||
);
|
||||
},
|
||||
});
|
||||
const [updateTaskChecklistLocation] = useUpdateTaskChecklistLocationMutation();
|
||||
const [updateTaskChecklistItemLocation] = useUpdateTaskChecklistItemLocationMutation({
|
||||
update: (client, response) => {
|
||||
updateApolloCache<FindTaskQuery>(
|
||||
client,
|
||||
FindTaskDocument,
|
||||
(cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
if (response.data) {
|
||||
const { prevChecklistID, taskChecklistID, checklistItem } = response.data.updateTaskChecklistItemLocation;
|
||||
if (taskChecklistID !== prevChecklistID) {
|
||||
const oldIdx = cache.findTask.checklists.findIndex((c) => c.id === prevChecklistID);
|
||||
const newIdx = cache.findTask.checklists.findIndex((c) => c.id === taskChecklistID);
|
||||
if (oldIdx > -1 && newIdx > -1) {
|
||||
const item = cache.findTask.checklists[oldIdx].items.find((i) => i.id === checklistItem.id);
|
||||
if (item) {
|
||||
draftCache.findTask.checklists[oldIdx].items = cache.findTask.checklists[oldIdx].items.filter(
|
||||
(i) => i.id !== checklistItem.id,
|
||||
);
|
||||
draftCache.findTask.checklists[newIdx].items.push({
|
||||
...item,
|
||||
position: checklistItem.position,
|
||||
taskChecklistID,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ taskID },
|
||||
);
|
||||
},
|
||||
});
|
||||
const [setTaskChecklistItemComplete] = useSetTaskChecklistItemCompleteMutation({
|
||||
update: (client) => {
|
||||
updateApolloCache<FindTaskQuery>(
|
||||
client,
|
||||
FindTaskDocument,
|
||||
(cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
||||
draftCache.findTask.badges.checklist = {
|
||||
__typename: 'ChecklistBadge',
|
||||
complete,
|
||||
total,
|
||||
};
|
||||
}),
|
||||
{ taskID },
|
||||
);
|
||||
},
|
||||
});
|
||||
const [deleteTaskChecklist] = useDeleteTaskChecklistMutation({
|
||||
update: (client, deleteData) => {
|
||||
updateApolloCache<FindTaskQuery>(
|
||||
client,
|
||||
FindTaskDocument,
|
||||
(cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
const { checklists } = cache.findTask;
|
||||
draftCache.findTask.checklists = checklists.filter(
|
||||
(c) => c.id !== deleteData.data?.deleteTaskChecklist.taskChecklist.id,
|
||||
);
|
||||
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
||||
draftCache.findTask.badges.checklist = {
|
||||
__typename: 'ChecklistBadge',
|
||||
complete,
|
||||
total,
|
||||
};
|
||||
if (complete === 0 && total === 0) {
|
||||
draftCache.findTask.badges.checklist = null;
|
||||
}
|
||||
}),
|
||||
{ taskID },
|
||||
);
|
||||
},
|
||||
});
|
||||
const [updateTaskChecklistItemName] = useUpdateTaskChecklistItemNameMutation();
|
||||
const [createTaskChecklist] = useCreateTaskChecklistMutation({
|
||||
update: (client, createData) => {
|
||||
updateApolloCache<FindTaskQuery>(
|
||||
client,
|
||||
FindTaskDocument,
|
||||
(cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
if (createData.data) {
|
||||
const item = createData.data.createTaskChecklist;
|
||||
draftCache.findTask.checklists.push({ ...item });
|
||||
}
|
||||
}),
|
||||
{ taskID },
|
||||
);
|
||||
},
|
||||
});
|
||||
const [updateTaskChecklistName] = useUpdateTaskChecklistNameMutation();
|
||||
const [deleteTaskChecklistItem] = useDeleteTaskChecklistItemMutation({
|
||||
update: (client, deleteData) => {
|
||||
updateApolloCache<FindTaskQuery>(
|
||||
client,
|
||||
FindTaskDocument,
|
||||
(cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
if (deleteData.data) {
|
||||
const item = deleteData.data.deleteTaskChecklistItem.taskChecklistItem;
|
||||
const targetIdx = cache.findTask.checklists.findIndex((c) => c.id === item.taskChecklistID);
|
||||
if (targetIdx > -1) {
|
||||
draftCache.findTask.checklists[targetIdx].items = cache.findTask.checklists[targetIdx].items.filter(
|
||||
(c) => item.id !== c.id,
|
||||
);
|
||||
}
|
||||
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
||||
draftCache.findTask.badges.checklist = {
|
||||
__typename: 'ChecklistBadge',
|
||||
complete,
|
||||
total,
|
||||
};
|
||||
}
|
||||
}),
|
||||
{ taskID },
|
||||
);
|
||||
},
|
||||
});
|
||||
const [createTaskChecklistItem] = useCreateTaskChecklistItemMutation({
|
||||
update: (client, newTaskItem) => {
|
||||
updateApolloCache<FindTaskQuery>(
|
||||
client,
|
||||
FindTaskDocument,
|
||||
(cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
if (newTaskItem.data) {
|
||||
const item = newTaskItem.data.createTaskChecklistItem;
|
||||
const { checklists } = cache.findTask;
|
||||
const idx = checklists.findIndex((c) => c.id === item.taskChecklistID);
|
||||
if (idx !== -1) {
|
||||
draftCache.findTask.checklists[idx].items.push({ ...item });
|
||||
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
||||
draftCache.findTask.badges.checklist = {
|
||||
__typename: 'ChecklistBadge',
|
||||
complete,
|
||||
total,
|
||||
};
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ taskID },
|
||||
);
|
||||
},
|
||||
});
|
||||
const { loading, data, refetch } = useFindTaskQuery({
|
||||
variables: { taskID },
|
||||
pollInterval: polling.TASK_DETAILS,
|
||||
fetchPolicy: 'cache-and-network',
|
||||
});
|
||||
const [setTaskComplete] = useSetTaskCompleteMutation();
|
||||
const [updateTaskDueDate] = useUpdateTaskDueDateMutation({
|
||||
onCompleted: () => {
|
||||
refetch();
|
||||
refreshCache();
|
||||
},
|
||||
});
|
||||
const [assignTask] = useAssignTaskMutation({
|
||||
onCompleted: () => {
|
||||
refetch();
|
||||
refreshCache();
|
||||
},
|
||||
});
|
||||
const [unassignTask] = useUnassignTaskMutation({
|
||||
onCompleted: () => {
|
||||
refetch();
|
||||
refreshCache();
|
||||
},
|
||||
});
|
||||
const [updateTaskComment] = useUpdateTaskCommentMutation();
|
||||
const [editableComment, setEditableComment] = useState<null | string>(null);
|
||||
const isLoading = true;
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
width={1070}
|
||||
onClose={() => {
|
||||
history.push(projectURL);
|
||||
hidePopup();
|
||||
}}
|
||||
renderContent={() => {
|
||||
return data ? (
|
||||
<TaskDetails
|
||||
onCancelCommentEdit={() => setEditableComment(null)}
|
||||
onUpdateComment={(commentID, message) => {
|
||||
updateTaskComment({ variables: { commentID, message } });
|
||||
}}
|
||||
editableComment={editableComment}
|
||||
me={data.me ? data.me.user : null}
|
||||
onCommentShowActions={(commentID, $targetRef) => {
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<TaskCommentActions
|
||||
onDeleteComment={() => {
|
||||
deleteTaskComment({ variables: { commentID } });
|
||||
hidePopup();
|
||||
}}
|
||||
onEditComment={() => {
|
||||
setEditableComment(commentID);
|
||||
hidePopup();
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}}
|
||||
task={data.findTask}
|
||||
onToggleTaskWatch={(task, watched) => {
|
||||
toggleTaskWatch({
|
||||
variables: { taskID: task.id },
|
||||
optimisticResponse: {
|
||||
__typename: 'Mutation',
|
||||
toggleTaskWatch: {
|
||||
id: task.id,
|
||||
__typename: 'Task',
|
||||
watched,
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
onCreateComment={(task, message) => {
|
||||
createTaskComment({ variables: { taskID: task.id, message } });
|
||||
}}
|
||||
onChecklistDrop={(checklist) => {
|
||||
updateTaskChecklistLocation({
|
||||
variables: { taskChecklistID: checklist.id, position: checklist.position },
|
||||
|
||||
optimisticResponse: {
|
||||
__typename: 'Mutation',
|
||||
updateTaskChecklistLocation: {
|
||||
__typename: 'UpdateTaskChecklistLocationPayload',
|
||||
checklist: {
|
||||
__typename: 'TaskChecklist',
|
||||
position: checklist.position,
|
||||
id: checklist.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
onChecklistItemDrop={(prevChecklistID, taskChecklistID, checklistItem) => {
|
||||
updateTaskChecklistItemLocation({
|
||||
variables: {
|
||||
taskChecklistID,
|
||||
taskChecklistItemID: checklistItem.id,
|
||||
position: checklistItem.position,
|
||||
},
|
||||
optimisticResponse: {
|
||||
__typename: 'Mutation',
|
||||
updateTaskChecklistItemLocation: {
|
||||
__typename: 'UpdateTaskChecklistItemLocationPayload',
|
||||
prevChecklistID,
|
||||
taskChecklistID,
|
||||
checklistItem: {
|
||||
__typename: 'TaskChecklistItem',
|
||||
position: checklistItem.position,
|
||||
id: checklistItem.id,
|
||||
taskChecklistID,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
onTaskNameChange={onTaskNameChange}
|
||||
onTaskDescriptionChange={onTaskDescriptionChange}
|
||||
onToggleTaskComplete={(task) => {
|
||||
setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } });
|
||||
}}
|
||||
onDeleteTask={onDeleteTask}
|
||||
onChangeItemName={(itemID, itemName) => {
|
||||
updateTaskChecklistItemName({ variables: { taskChecklistItemID: itemID, name: itemName } });
|
||||
}}
|
||||
onCloseModal={() => history.push(projectURL)}
|
||||
onChangeChecklistName={(checklistID, newName) => {
|
||||
updateTaskChecklistName({ variables: { taskChecklistID: checklistID, name: newName } });
|
||||
}}
|
||||
onDeleteItem={(checklistID, itemID) => {
|
||||
deleteTaskChecklistItem({
|
||||
variables: { taskChecklistItemID: itemID },
|
||||
optimisticResponse: {
|
||||
__typename: 'Mutation',
|
||||
deleteTaskChecklistItem: {
|
||||
__typename: 'DeleteTaskChecklistItemPayload',
|
||||
ok: true,
|
||||
taskChecklistItem: {
|
||||
__typename: 'TaskChecklistItem',
|
||||
id: itemID,
|
||||
taskChecklistID: checklistID,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
onToggleChecklistItem={(itemID, complete) => {
|
||||
setTaskChecklistItemComplete({
|
||||
variables: { taskChecklistItemID: itemID, complete },
|
||||
optimisticResponse: {
|
||||
__typename: 'Mutation',
|
||||
setTaskChecklistItemComplete: {
|
||||
__typename: 'TaskChecklistItem',
|
||||
id: itemID,
|
||||
complete,
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
onAddItem={(taskChecklistID, name, position) => {
|
||||
createTaskChecklistItem({ variables: { taskChecklistID, name, position } });
|
||||
}}
|
||||
onMemberProfile={($targetRef, memberID) => {
|
||||
const member = data.findTask.assigned.find((m) => m.id === memberID);
|
||||
if (member) {
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<Popup title={null} onClose={NOOP} tab={0}>
|
||||
<MiniProfile
|
||||
user={member}
|
||||
bio="None"
|
||||
onRemoveFromTask={() => {
|
||||
if (user) {
|
||||
unassignTask({ variables: { taskID: data.findTask.id, userID: member.id ?? '' } });
|
||||
hidePopup();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popup>,
|
||||
);
|
||||
}
|
||||
}}
|
||||
onOpenAddMemberPopup={(_task, $targetRef) => {
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<Popup title="Members" tab={0} onClose={NOOP}>
|
||||
<MemberManager
|
||||
availableMembers={availableMembers}
|
||||
activeMembers={data.findTask.assigned}
|
||||
onMemberChange={(member, isActive) => {
|
||||
if (user) {
|
||||
if (isActive) {
|
||||
assignTask({ variables: { taskID: data.findTask.id, userID: member.id } });
|
||||
} else {
|
||||
unassignTask({ variables: { taskID: data.findTask.id, userID: member.id } });
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popup>,
|
||||
);
|
||||
}}
|
||||
onOpenAddLabelPopup={onOpenAddLabelPopup}
|
||||
onOpenAddChecklistPopup={(_task, $target) => {
|
||||
showPopup(
|
||||
$target,
|
||||
<Popup
|
||||
title="Add checklist"
|
||||
tab={0}
|
||||
onClose={() => {
|
||||
hidePopup();
|
||||
}}
|
||||
>
|
||||
<CreateChecklistPopup
|
||||
onCreateChecklist={(checklistData) => {
|
||||
let position = 65535;
|
||||
if (data.findTask.checklists) {
|
||||
const [lastChecklist] = data.findTask.checklists.slice(-1);
|
||||
if (lastChecklist) {
|
||||
position = lastChecklist.position * 2 + 1;
|
||||
}
|
||||
}
|
||||
createTaskChecklist({
|
||||
variables: {
|
||||
taskID: data.findTask.id,
|
||||
name: checklistData.name,
|
||||
position,
|
||||
},
|
||||
});
|
||||
hidePopup();
|
||||
}}
|
||||
/>
|
||||
</Popup>,
|
||||
);
|
||||
}}
|
||||
onDeleteChecklist={($target, checklistID) => {
|
||||
showPopup(
|
||||
$target,
|
||||
<Popup tab={0} title="Delete checklist?" onClose={() => hidePopup()}>
|
||||
<p>Deleting a checklist is permanent and there is no way to get it back.</p>
|
||||
<DeleteChecklistButton
|
||||
color="danger"
|
||||
onClick={() => {
|
||||
deleteTaskChecklist({ variables: { taskChecklistID: checklistID } });
|
||||
hidePopup();
|
||||
}}
|
||||
>
|
||||
Delete Checklist
|
||||
</DeleteChecklistButton>
|
||||
</Popup>,
|
||||
);
|
||||
}}
|
||||
onOpenDueDatePopop={(task, $targetRef) => {
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<Popup
|
||||
title="Change Due Date"
|
||||
tab={0}
|
||||
onClose={() => {
|
||||
hidePopup();
|
||||
}}
|
||||
>
|
||||
<DueDateManager
|
||||
task={task}
|
||||
onRemoveDueDate={(t) => {
|
||||
updateTaskDueDate({
|
||||
variables: {
|
||||
taskID: t.id,
|
||||
dueDate: null,
|
||||
hasTime: false,
|
||||
deleteNotifications: t.dueDate.notifications
|
||||
? t.dueDate.notifications.map((n) => ({ id: n.id }))
|
||||
: [],
|
||||
updateNotifications: [],
|
||||
createNotifications: [],
|
||||
},
|
||||
});
|
||||
hidePopup();
|
||||
}}
|
||||
onDueDateChange={(t, newDueDate, hasTime, notifications) => {
|
||||
const updatedNotifications = notifications.current
|
||||
.filter((c) => c.externalId !== null)
|
||||
.map((c) => {
|
||||
let duration = DueDateNotificationDuration.Minute;
|
||||
switch (c.duration.value) {
|
||||
case 'hour':
|
||||
duration = DueDateNotificationDuration.Hour;
|
||||
break;
|
||||
case 'day':
|
||||
duration = DueDateNotificationDuration.Day;
|
||||
break;
|
||||
case 'week':
|
||||
duration = DueDateNotificationDuration.Week;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return {
|
||||
id: c.externalId ?? '',
|
||||
period: c.period,
|
||||
duration,
|
||||
};
|
||||
});
|
||||
const newNotifications = notifications.current
|
||||
.filter((c) => c.externalId === null)
|
||||
.map((c) => {
|
||||
let duration = DueDateNotificationDuration.Minute;
|
||||
switch (c.duration.value) {
|
||||
case 'hour':
|
||||
duration = DueDateNotificationDuration.Hour;
|
||||
break;
|
||||
case 'day':
|
||||
duration = DueDateNotificationDuration.Day;
|
||||
break;
|
||||
case 'week':
|
||||
duration = DueDateNotificationDuration.Week;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return {
|
||||
taskID: task.id,
|
||||
period: c.period,
|
||||
duration,
|
||||
};
|
||||
});
|
||||
// const updatedNotifications = notifications.filter(c => c.externalId === null);
|
||||
updateTaskDueDate({
|
||||
variables: {
|
||||
taskID: t.id,
|
||||
dueDate: newDueDate,
|
||||
hasTime,
|
||||
createNotifications: newNotifications,
|
||||
updateNotifications: updatedNotifications,
|
||||
deleteNotifications: notifications.removed.map((n) => ({ id: n })),
|
||||
},
|
||||
});
|
||||
hidePopup();
|
||||
}}
|
||||
onCancel={NOOP}
|
||||
/>
|
||||
</Popup>,
|
||||
{ showDiamond: false, targetPadding: '0' },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TaskDetailsLoading />
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Details;
|
142
frontend/src/Projects/Project/LabelManagerEditor/index.tsx
Normal file
142
frontend/src/Projects/Project/LabelManagerEditor/index.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import React, { useState } from 'react';
|
||||
import updateApolloCache from 'shared/utils/cache';
|
||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||
import produce from 'immer';
|
||||
import {
|
||||
useUpdateProjectLabelMutation,
|
||||
useDeleteProjectLabelMutation,
|
||||
FindProjectDocument,
|
||||
useCreateProjectLabelMutation,
|
||||
FindProjectQuery,
|
||||
useToggleTaskLabelMutation,
|
||||
useLabelsQuery,
|
||||
} from 'shared/generated/graphql';
|
||||
import LabelManager from 'shared/components/PopupMenu/LabelManager';
|
||||
import LabelEditor from 'shared/components/PopupMenu/LabelEditor';
|
||||
|
||||
type LabelManagerEditorProps = {
|
||||
taskID?: string;
|
||||
taskLabels: null | React.RefObject<Array<TaskLabel>>;
|
||||
projectID: string;
|
||||
labelColors: Array<LabelColor>;
|
||||
onLabelToggle?: (labelId: string) => void;
|
||||
};
|
||||
|
||||
const LabelManagerEditor: React.FC<LabelManagerEditorProps> = ({
|
||||
taskID,
|
||||
projectID,
|
||||
labelColors,
|
||||
onLabelToggle,
|
||||
taskLabels: taskLabelsRef,
|
||||
}) => {
|
||||
const [currentLabel, setCurrentLabel] = useState('');
|
||||
const { setTab, hidePopup } = usePopup();
|
||||
const [toggleTaskLabel] = useToggleTaskLabelMutation();
|
||||
const [createProjectLabel] = useCreateProjectLabelMutation({
|
||||
onCompleted: (data) => {
|
||||
if (taskID) {
|
||||
toggleTaskLabel({ variables: { taskID, projectLabelID: data.createProjectLabel.id } });
|
||||
}
|
||||
},
|
||||
update: (client, newLabelData) => {
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
(cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
if (newLabelData.data) {
|
||||
draftCache.findProject.labels.push({ ...newLabelData.data.createProjectLabel });
|
||||
}
|
||||
}),
|
||||
{
|
||||
projectID,
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
const [updateProjectLabel] = useUpdateProjectLabelMutation();
|
||||
const [deleteProjectLabel] = useDeleteProjectLabelMutation({
|
||||
update: (client, newLabelData) => {
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
(cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
draftCache.findProject.labels = cache.findProject.labels.filter(
|
||||
(label) => label.id !== newLabelData.data?.deleteProjectLabel.id,
|
||||
);
|
||||
}),
|
||||
{ projectID },
|
||||
);
|
||||
},
|
||||
});
|
||||
const { data } = useLabelsQuery({ variables: { projectID } });
|
||||
const labels = data ? data.findProject.labels : [];
|
||||
const taskLabels = taskLabelsRef && taskLabelsRef.current ? taskLabelsRef.current : [];
|
||||
const [currentTaskLabels, setCurrentTaskLabels] = useState(taskLabels);
|
||||
return (
|
||||
<>
|
||||
<Popup title="Labels" tab={0} onClose={() => hidePopup()}>
|
||||
<LabelManager
|
||||
labels={data ? data.findProject.labels : []}
|
||||
taskLabels={currentTaskLabels}
|
||||
onLabelCreate={() => {
|
||||
setTab(2);
|
||||
}}
|
||||
onLabelEdit={(labelId) => {
|
||||
setCurrentLabel(labelId);
|
||||
setTab(1);
|
||||
}}
|
||||
onLabelToggle={(labelId) => {
|
||||
if (onLabelToggle) {
|
||||
if (currentTaskLabels.find((t) => t.projectLabel.id === labelId)) {
|
||||
setCurrentTaskLabels(currentTaskLabels.filter((t) => t.projectLabel.id !== labelId));
|
||||
} else if (data) {
|
||||
const newProjectLabel = data.findProject.labels.find((l) => l.id === labelId);
|
||||
if (newProjectLabel) {
|
||||
setCurrentTaskLabels([
|
||||
...currentTaskLabels,
|
||||
{ id: '', assignedDate: '', projectLabel: { ...newProjectLabel } },
|
||||
]);
|
||||
}
|
||||
}
|
||||
setCurrentLabel(labelId);
|
||||
onLabelToggle(labelId);
|
||||
} else {
|
||||
setCurrentLabel(labelId);
|
||||
setTab(1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
<Popup onClose={() => hidePopup()} title="Edit label" tab={1}>
|
||||
<LabelEditor
|
||||
labelColors={labelColors}
|
||||
label={labels.find((label) => label.id === currentLabel) ?? null}
|
||||
onLabelEdit={(projectLabelID, name, color) => {
|
||||
if (projectLabelID) {
|
||||
updateProjectLabel({ variables: { projectLabelID, labelColorID: color.id, name: name ?? '' } });
|
||||
}
|
||||
setTab(0);
|
||||
}}
|
||||
onLabelDelete={(labelID) => {
|
||||
deleteProjectLabel({ variables: { projectLabelID: labelID } });
|
||||
setTab(0);
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
<Popup onClose={() => hidePopup()} title="Create new label" tab={2}>
|
||||
<LabelEditor
|
||||
labelColors={labelColors}
|
||||
label={null}
|
||||
onLabelEdit={(_labelId, name, color) => {
|
||||
createProjectLabel({ variables: { projectID, labelColorID: color.id, name: name ?? '' } });
|
||||
setTab(0);
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LabelManagerEditor;
|
@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { Cross } from 'shared/icons';
|
||||
import * as S from './Styles';
|
||||
|
||||
const OptionValue = ({ data, removeProps }: any) => {
|
||||
return (
|
||||
<S.OptionValueWrapper>
|
||||
<S.OptionValueLabel>{data.label}</S.OptionValueLabel>
|
||||
<S.OptionValueRemove {...removeProps}>
|
||||
<Cross width={14} height={14} />
|
||||
</S.OptionValueRemove>
|
||||
</S.OptionValueWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default OptionValue;
|
64
frontend/src/Projects/Project/UserManagementPopup/Styles.ts
Normal file
64
frontend/src/Projects/Project/UserManagementPopup/Styles.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import styled from 'styled-components';
|
||||
import Button from 'shared/components/Button';
|
||||
|
||||
export const OptionWrapper = styled.div<{ isFocused: boolean }>`
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
${props => props.isFocused && `background: rgba(${props.theme.colors.primary});`}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
export const OptionContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: 12px;
|
||||
`;
|
||||
|
||||
export const OptionLabel = styled.span<{ fontSize: number; quiet: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: ${p => p.fontSize}px;
|
||||
color: rgba(${p => (p.quiet ? p.theme.colors.text.primary : p.theme.colors.text.primary)});
|
||||
`;
|
||||
|
||||
export const OptionValueWrapper = styled.div`
|
||||
background: rgba(${props => props.theme.colors.bg.primary});
|
||||
border-radius: 4px;
|
||||
margin: 2px;
|
||||
padding: 3px 6px 3px 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
export const OptionValueLabel = styled.span`
|
||||
font-size: 12px;
|
||||
color: rgba(${props => props.theme.colors.text.secondary});
|
||||
`;
|
||||
|
||||
export const OptionValueRemove = styled.button`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-left: 4px;
|
||||
`;
|
||||
|
||||
export const InviteButton = styled(Button)`
|
||||
margin-top: 12px;
|
||||
height: 32px;
|
||||
padding: 4px 12px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
export const InviteContainer = styled.div`
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
||||
import * as S from './Styles';
|
||||
|
||||
type UserOptionProps = {
|
||||
innerProps: any;
|
||||
isDisabled: boolean;
|
||||
isFocused: boolean;
|
||||
label: string;
|
||||
data: any;
|
||||
getValue: any;
|
||||
};
|
||||
|
||||
const UserOption: React.FC<UserOptionProps> = ({ isDisabled, isFocused, innerProps, label, data }) => {
|
||||
return !isDisabled ? (
|
||||
<S.OptionWrapper {...innerProps} isFocused={isFocused}>
|
||||
<TaskAssignee
|
||||
size={32}
|
||||
member={{
|
||||
id: '',
|
||||
fullName: data.value.label,
|
||||
profileIcon: data.value.profileIcon,
|
||||
}}
|
||||
/>
|
||||
<S.OptionContent>
|
||||
<S.OptionLabel fontSize={16} quiet={false}>
|
||||
{label}
|
||||
</S.OptionLabel>
|
||||
{data.value.type === 2 && (
|
||||
<S.OptionLabel fontSize={14} quiet>
|
||||
Joined
|
||||
</S.OptionLabel>
|
||||
)}
|
||||
</S.OptionContent>
|
||||
</S.OptionWrapper>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default UserOption;
|
@ -0,0 +1,82 @@
|
||||
import gql from 'graphql-tag';
|
||||
import isValidEmail from 'shared/utils/email';
|
||||
|
||||
type MemberFilterOptions = {
|
||||
projectID?: null | string;
|
||||
teamID?: null | string;
|
||||
organization?: boolean;
|
||||
};
|
||||
|
||||
export default async function(client: any, projectID: string, options: MemberFilterOptions, input: string, cb: any) {
|
||||
if (input && input.trim().length < 3) {
|
||||
return [];
|
||||
}
|
||||
const res = await client.query({
|
||||
query: gql`
|
||||
query {
|
||||
searchMembers(input: {searchFilter:"${input}", projectID:"${projectID}"}) {
|
||||
id
|
||||
similarity
|
||||
status
|
||||
user {
|
||||
id
|
||||
fullName
|
||||
email
|
||||
profileIcon {
|
||||
url
|
||||
initials
|
||||
bgColor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
let results: any = [];
|
||||
const emails: Array<string> = [];
|
||||
if (res.data && res.data.searchMembers) {
|
||||
results = [
|
||||
...res.data.searchMembers.map((m: any) => {
|
||||
if (m.status === 'INVITED') {
|
||||
return {
|
||||
label: m.id,
|
||||
value: {
|
||||
id: m.id,
|
||||
type: 2,
|
||||
profileIcon: {
|
||||
bgColor: '#ccc',
|
||||
initials: m.id.charAt(0),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
emails.push(m.user.email);
|
||||
return {
|
||||
label: m.user.fullName,
|
||||
value: { id: m.user.id, type: 0, profileIcon: m.user.profileIcon },
|
||||
};
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
if (isValidEmail(input) && !emails.find(e => e === input)) {
|
||||
results = [
|
||||
...results,
|
||||
{
|
||||
label: input,
|
||||
value: {
|
||||
id: input,
|
||||
type: 1,
|
||||
profileIcon: {
|
||||
bgColor: '#ccc',
|
||||
initials: input.charAt(0),
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
82
frontend/src/Projects/Project/UserManagementPopup/index.tsx
Normal file
82
frontend/src/Projects/Project/UserManagementPopup/index.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import React, { useState } from 'react';
|
||||
import AsyncSelect from 'react-select/async';
|
||||
import { useApolloClient } from '@apollo/react-hooks';
|
||||
import { colourStyles } from 'shared/components/Select';
|
||||
import { Popup } from 'shared/components/PopupMenu';
|
||||
import OptionValue from './OptionValue';
|
||||
import UserOption from './UserOption';
|
||||
import fetchMembers from './fetchMembers';
|
||||
import * as S from './Styles';
|
||||
|
||||
type InviteUserData = {
|
||||
email?: string;
|
||||
userID?: string;
|
||||
};
|
||||
|
||||
type UserManagementPopupProps = {
|
||||
projectID: string;
|
||||
users: Array<User>;
|
||||
projectMembers: Array<TaskUser>;
|
||||
onInviteProjectMembers: (data: Array<InviteUserData>) => void;
|
||||
};
|
||||
|
||||
const UserManagementPopup: React.FC<UserManagementPopupProps> = ({
|
||||
projectID,
|
||||
users,
|
||||
projectMembers,
|
||||
onInviteProjectMembers,
|
||||
}) => {
|
||||
const client = useApolloClient();
|
||||
const [invitedUsers, setInvitedUsers] = useState<Array<any> | null>(null);
|
||||
return (
|
||||
<Popup tab={0} title="Invite a user">
|
||||
<S.InviteContainer>
|
||||
<AsyncSelect
|
||||
getOptionValue={option => option.value.id}
|
||||
placeholder="Email address or username"
|
||||
noOptionsMessage={() => null}
|
||||
onChange={(e: any) => {
|
||||
setInvitedUsers(e);
|
||||
}}
|
||||
isMulti
|
||||
autoFocus
|
||||
cacheOptions
|
||||
styles={colourStyles}
|
||||
defaultOption
|
||||
components={{
|
||||
MultiValue: OptionValue,
|
||||
Option: UserOption,
|
||||
IndicatorSeparator: null,
|
||||
DropdownIndicator: null,
|
||||
}}
|
||||
loadOptions={(i, cb) => fetchMembers(client, projectID, {}, i, cb)}
|
||||
/>
|
||||
</S.InviteContainer>
|
||||
<S.InviteButton
|
||||
onClick={() => {
|
||||
if (invitedUsers) {
|
||||
onInviteProjectMembers(
|
||||
invitedUsers.map(user => {
|
||||
if (user.value.type === 0) {
|
||||
return {
|
||||
userID: user.value.id,
|
||||
};
|
||||
}
|
||||
return {
|
||||
email: user.value.id,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={invitedUsers === null}
|
||||
hoverVariant="none"
|
||||
fontSize="16px"
|
||||
>
|
||||
Send Invite
|
||||
</S.InviteButton>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserManagementPopup;
|
292
frontend/src/Projects/Project/index.tsx
Normal file
292
frontend/src/Projects/Project/index.tsx
Normal file
@ -0,0 +1,292 @@
|
||||
// LOC830
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import updateApolloCache from 'shared/utils/cache';
|
||||
import GlobalTopNavbar from 'App/TopNavbar';
|
||||
import ProjectPopup from 'App/TopNavbar/ProjectPopup';
|
||||
import { usePopup } from 'shared/components/PopupMenu';
|
||||
import {
|
||||
useParams,
|
||||
Route,
|
||||
useRouteMatch,
|
||||
useHistory,
|
||||
RouteComponentProps,
|
||||
useLocation,
|
||||
Redirect,
|
||||
} from 'react-router-dom';
|
||||
import {
|
||||
useUpdateProjectMemberRoleMutation,
|
||||
useInviteProjectMembersMutation,
|
||||
useDeleteProjectMemberMutation,
|
||||
useToggleTaskLabelMutation,
|
||||
useUpdateProjectNameMutation,
|
||||
useFindProjectQuery,
|
||||
useDeleteInvitedProjectMemberMutation,
|
||||
useUpdateTaskNameMutation,
|
||||
useDeleteTaskMutation,
|
||||
useUpdateTaskDescriptionMutation,
|
||||
FindProjectDocument,
|
||||
FindProjectQuery,
|
||||
} from 'shared/generated/graphql';
|
||||
import produce from 'immer';
|
||||
import NOOP from 'shared/utils/noop';
|
||||
import useStateWithLocalStorage from 'shared/hooks/useStateWithLocalStorage';
|
||||
import localStorage from 'shared/utils/localStorage';
|
||||
import polling from 'shared/utils/polling';
|
||||
import Board, { BoardLoading } from './Board';
|
||||
import Details from './Details';
|
||||
import LabelManagerEditor from './LabelManagerEditor';
|
||||
import UserManagementPopup from './UserManagementPopup';
|
||||
|
||||
type TaskRouteProps = {
|
||||
taskID: string;
|
||||
};
|
||||
|
||||
interface ProjectParams {
|
||||
projectID: string;
|
||||
}
|
||||
|
||||
const Project = () => {
|
||||
const { projectID } = useParams<ProjectParams>();
|
||||
const history = useHistory();
|
||||
const match = useRouteMatch();
|
||||
const location = useLocation();
|
||||
|
||||
const { showPopup, hidePopup } = usePopup();
|
||||
const labelsRef = useRef<Array<ProjectLabel>>([]);
|
||||
const [value, setValue] = useStateWithLocalStorage(localStorage.CARD_LABEL_VARIANT_STORAGE_KEY);
|
||||
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
|
||||
|
||||
const [updateTaskDescription] = useUpdateTaskDescriptionMutation();
|
||||
const [updateProjectMemberRole] = useUpdateProjectMemberRoleMutation();
|
||||
const [updateTaskName] = useUpdateTaskNameMutation();
|
||||
const { data, error } = useFindProjectQuery({
|
||||
variables: { projectID },
|
||||
pollInterval: polling.PROJECT,
|
||||
});
|
||||
const [toggleTaskLabel] = useToggleTaskLabelMutation({
|
||||
onCompleted: (newTaskLabel) => {
|
||||
taskLabelsRef.current = newTaskLabel.toggleTaskLabel.task.labels;
|
||||
},
|
||||
});
|
||||
const [deleteTask] = useDeleteTaskMutation({
|
||||
update: (client, resp) =>
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
(cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
if (resp.data) {
|
||||
const taskGroupIdx = draftCache.findProject.taskGroups.findIndex(
|
||||
(tg) => tg.tasks.findIndex((t) => t.id === resp.data?.deleteTask.taskID) !== -1,
|
||||
);
|
||||
|
||||
if (taskGroupIdx !== -1) {
|
||||
draftCache.findProject.taskGroups[taskGroupIdx].tasks = cache.findProject.taskGroups[
|
||||
taskGroupIdx
|
||||
].tasks.filter((t) => t.id !== resp.data?.deleteTask.taskID);
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ projectID: data ? data.findProject.id : '' },
|
||||
),
|
||||
});
|
||||
|
||||
const [updateProjectName] = useUpdateProjectNameMutation({
|
||||
update: (client, newName) => {
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
(cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
draftCache.findProject.name = newName.data?.updateProjectName.name ?? '';
|
||||
}),
|
||||
{ projectID: data ? data.findProject.id : '' },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const [inviteProjectMembers] = useInviteProjectMembersMutation({
|
||||
update: (client, response) => {
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
(cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
if (response.data) {
|
||||
draftCache.findProject.members = [
|
||||
...cache.findProject.members,
|
||||
...response.data.inviteProjectMembers.members,
|
||||
];
|
||||
draftCache.findProject.invitedMembers = [
|
||||
...cache.findProject.invitedMembers,
|
||||
...response.data.inviteProjectMembers.invitedMembers,
|
||||
];
|
||||
}
|
||||
}),
|
||||
{ projectID: data ? data.findProject.id : '' },
|
||||
);
|
||||
},
|
||||
});
|
||||
const [deleteInvitedProjectMember] = useDeleteInvitedProjectMemberMutation({
|
||||
update: (client, response) => {
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
(cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
draftCache.findProject.invitedMembers = cache.findProject.invitedMembers.filter(
|
||||
(m) => m.email !== response.data?.deleteInvitedProjectMember.invitedMember.email ?? '',
|
||||
);
|
||||
}),
|
||||
{ projectID: data ? data.findProject.id : '' },
|
||||
);
|
||||
},
|
||||
});
|
||||
const [deleteProjectMember] = useDeleteProjectMemberMutation({
|
||||
update: (client, response) => {
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
(cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
draftCache.findProject.members = cache.findProject.members.filter(
|
||||
(m) => m.id !== response.data?.deleteProjectMember.member.id,
|
||||
);
|
||||
}),
|
||||
{ projectID: data ? data.findProject.id : '' },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
document.title = `${data.findProject.name} | Taskcafé`;
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (data) {
|
||||
labelsRef.current = data.findProject.labels;
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalTopNavbar
|
||||
onChangeRole={(userID, roleCode) => {
|
||||
updateProjectMemberRole({ variables: { userID, roleCode, projectID: data ? data.findProject.id : '' } });
|
||||
}}
|
||||
onChangeProjectOwner={() => {
|
||||
hidePopup();
|
||||
}}
|
||||
onRemoveFromBoard={(userID) => {
|
||||
deleteProjectMember({ variables: { userID, projectID: data ? data.findProject.id : '' } });
|
||||
hidePopup();
|
||||
}}
|
||||
onRemoveInvitedFromBoard={(email) => {
|
||||
deleteInvitedProjectMember({ variables: { projectID: data ? data.findProject.id : '', email } });
|
||||
hidePopup();
|
||||
}}
|
||||
onSaveProjectName={(projectName) => {
|
||||
updateProjectName({ variables: { projectID: data ? data.findProject.id : '', name: projectName } });
|
||||
}}
|
||||
onInviteUser={($target) => {
|
||||
showPopup(
|
||||
$target,
|
||||
<UserManagementPopup
|
||||
projectID={data ? data.findProject.id : ''}
|
||||
onInviteProjectMembers={(members) => {
|
||||
inviteProjectMembers({ variables: { projectID: data ? data.findProject.id : '', members } });
|
||||
hidePopup();
|
||||
}}
|
||||
users={data.users}
|
||||
projectMembers={data.findProject.members}
|
||||
/>,
|
||||
);
|
||||
}}
|
||||
popupContent={
|
||||
<ProjectPopup // eslint-disable-line
|
||||
history={history}
|
||||
publicOn={data.findProject.publicOn}
|
||||
name={data.findProject.name}
|
||||
projectID={projectID}
|
||||
/>
|
||||
}
|
||||
menuType={[{ name: 'Board', link: location.pathname }]}
|
||||
currentTab={0}
|
||||
projectMembers={data.findProject.members}
|
||||
projectInvitedMembers={data.findProject.invitedMembers}
|
||||
projectID={projectID}
|
||||
teamID={data.findProject.team ? data.findProject.team.id : null}
|
||||
name={data.findProject.name}
|
||||
/>
|
||||
<Route path={`${match.path}`} exact render={() => <Redirect to={`${match.url}/board`} />} />
|
||||
<Route
|
||||
path={`${match.path}/board`}
|
||||
render={() => (
|
||||
<Board
|
||||
cardLabelVariant={value === 'small' ? 'small' : 'large'}
|
||||
onCardLabelClick={() => {
|
||||
const variant = value === 'small' ? 'large' : 'small';
|
||||
setValue(() => variant);
|
||||
}}
|
||||
projectID={projectID}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path={`${match.path}/board/c/:taskID`}
|
||||
render={() => {
|
||||
return (
|
||||
<Details
|
||||
refreshCache={NOOP}
|
||||
availableMembers={data.findProject.members}
|
||||
projectURL={`${match.url}/board`}
|
||||
onTaskNameChange={(updatedTask, newName) => {
|
||||
updateTaskName({ variables: { taskID: updatedTask.id, name: newName } });
|
||||
}}
|
||||
onTaskDescriptionChange={(updatedTask, newDescription) => {
|
||||
updateTaskDescription({
|
||||
variables: { taskID: updatedTask.id, description: newDescription },
|
||||
optimisticResponse: {
|
||||
__typename: 'Mutation',
|
||||
updateTaskDescription: {
|
||||
__typename: 'Task',
|
||||
id: updatedTask.id,
|
||||
description: newDescription,
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
onDeleteTask={(deletedTask) => {
|
||||
deleteTask({ variables: { taskID: deletedTask.id } });
|
||||
history.push(`${match.url}/board`);
|
||||
}}
|
||||
onOpenAddLabelPopup={(task, $targetRef) => {
|
||||
taskLabelsRef.current = task.labels;
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<LabelManagerEditor
|
||||
onLabelToggle={(labelID) => {
|
||||
toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } });
|
||||
}}
|
||||
taskID={task.id}
|
||||
labelColors={data.labelColors}
|
||||
taskLabels={taskLabelsRef}
|
||||
projectID={projectID}
|
||||
/>,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<GlobalTopNavbar onSaveProjectName={NOOP} name="" projectID={null} />
|
||||
<BoardLoading />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Project;
|
403
frontend/src/Projects/index.tsx
Normal file
403
frontend/src/Projects/index.tsx
Normal file
@ -0,0 +1,403 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import styled from 'styled-components/macro';
|
||||
import GlobalTopNavbar from 'App/TopNavbar';
|
||||
import Empty from 'shared/undraw/Empty';
|
||||
import {
|
||||
useCreateTeamMutation,
|
||||
useGetProjectsQuery,
|
||||
useCreateProjectMutation,
|
||||
GetProjectsDocument,
|
||||
GetProjectsQuery,
|
||||
} from 'shared/generated/graphql';
|
||||
import FormInput from 'shared/components/FormInput';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import NewProject from 'shared/components/NewProject';
|
||||
import { useCurrentUser } from 'App/context';
|
||||
import Button from 'shared/components/Button';
|
||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import ControlledInput from 'shared/components/ControlledInput';
|
||||
import updateApolloCache from 'shared/utils/cache';
|
||||
import produce from 'immer';
|
||||
import NOOP from 'shared/utils/noop';
|
||||
import theme from 'App/ThemeStyles';
|
||||
import polling from 'shared/utils/polling';
|
||||
import { mixin } from '../shared/utils/styles';
|
||||
|
||||
type CreateTeamData = { name: string };
|
||||
|
||||
type CreateTeamFormProps = {
|
||||
onCreateTeam: (teamName: string) => void;
|
||||
};
|
||||
|
||||
const CreateTeamFormContainer = styled.form``;
|
||||
|
||||
const CreateTeamButton = styled(Button)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const ErrorText = styled.span`
|
||||
font-size: 14px;
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
`;
|
||||
const CreateTeamForm: React.FC<CreateTeamFormProps> = ({ onCreateTeam }) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<CreateTeamData>();
|
||||
const createTeam = (data: CreateTeamData) => {
|
||||
onCreateTeam(data.name);
|
||||
};
|
||||
return (
|
||||
<CreateTeamFormContainer onSubmit={handleSubmit(createTeam)}>
|
||||
{errors.name && <ErrorText>{errors.name.message}</ErrorText>}
|
||||
<FormInput width="100%" label="Team name" variant="alternate" {...register('name')} />
|
||||
<CreateTeamButton type="submit">Create</CreateTeamButton>
|
||||
</CreateTeamFormContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const ProjectAddTile = styled.div`
|
||||
background-color: ${(props) => mixin.rgba(props.theme.colors.bg.primary, 0.4)};
|
||||
background-size: cover;
|
||||
background-position: 50%;
|
||||
color: #fff;
|
||||
line-height: 20px;
|
||||
padding: 8px;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
`;
|
||||
|
||||
const ProjectTile = styled(Link)<{ color: string }>`
|
||||
background-color: ${(props) => props.color};
|
||||
background-size: cover;
|
||||
background-position: 50%;
|
||||
color: #fff;
|
||||
line-height: 20px;
|
||||
padding: 8px;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
`;
|
||||
|
||||
const ProjectTileFade = styled.div`
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
`;
|
||||
|
||||
const ProjectListItem = styled.li`
|
||||
width: 23.5%;
|
||||
padding: 0;
|
||||
margin: 0 2% 2% 0;
|
||||
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover ${ProjectTileFade} {
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
`;
|
||||
|
||||
const ProjectList = styled.ul`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
& ${ProjectListItem}:nth-of-type(4n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const ProjectTileDetails = styled.div`
|
||||
display: flex;
|
||||
height: 80px;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const ProjectAddTileDetails = styled.div`
|
||||
display: flex;
|
||||
height: 80px;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const ProjectTileName = styled.div<{ centered?: boolean }>`
|
||||
flex: 0 0 auto;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
max-height: 40px;
|
||||
width: 100%;
|
||||
word-wrap: break-word;
|
||||
${(props) => props.centered && 'text-align: center;'}
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const ProjectSectionTitleWrapper = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
margin-bottom: 24px;
|
||||
padding: 8px 0;
|
||||
position: relative;
|
||||
margin-top: 16px;
|
||||
`;
|
||||
|
||||
const SectionActions = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const SectionAction = styled(Button)`
|
||||
padding: 6px 12px;
|
||||
`;
|
||||
|
||||
const SectionActionLink = styled(Link)`
|
||||
margin-right: 8px;
|
||||
`;
|
||||
|
||||
const ProjectSectionTitle = styled.h3`
|
||||
font-size: 16px;
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
`;
|
||||
|
||||
const ProjectsContainer = styled.div`
|
||||
margin: 40px 16px 0;
|
||||
width: 100%;
|
||||
max-width: 825px;
|
||||
min-width: 288px;
|
||||
`;
|
||||
|
||||
const AddTeamButton = styled(Button)`
|
||||
padding: 6px 12px;
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 12px;
|
||||
`;
|
||||
|
||||
type ShowNewProject = {
|
||||
open: boolean;
|
||||
initialTeamID: null | string;
|
||||
};
|
||||
|
||||
const Projects = () => {
|
||||
const { showPopup, hidePopup } = usePopup();
|
||||
const { loading, data } = useGetProjectsQuery({ pollInterval: polling.PROJECTS, fetchPolicy: 'cache-and-network' });
|
||||
useEffect(() => {
|
||||
document.title = 'Taskcafé';
|
||||
}, []);
|
||||
const [createProject] = useCreateProjectMutation({
|
||||
update: (client, newProject) => {
|
||||
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, (cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
if (newProject.data) {
|
||||
draftCache.projects.push({ ...newProject.data.createProject });
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const [showNewProject, setShowNewProject] = useState<ShowNewProject>({ open: false, initialTeamID: null });
|
||||
const { user } = useCurrentUser();
|
||||
const [createTeam] = useCreateTeamMutation({
|
||||
update: (client, createData) => {
|
||||
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, (cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
if (createData.data) {
|
||||
draftCache.teams.push({ ...createData.data?.createTeam });
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const colors = theme.colors.multiColors;
|
||||
if (data && user) {
|
||||
const { projects, teams, organizations } = data;
|
||||
const organizationID = organizations[0].id ?? null;
|
||||
const personalProjects = projects
|
||||
.filter((p) => p.team === null)
|
||||
.sort((a, b) => {
|
||||
const textA = a.name.toUpperCase();
|
||||
const textB = b.name.toUpperCase();
|
||||
return textA < textB ? -1 : textA > textB ? 1 : 0; // eslint-disable-line no-nested-ternary
|
||||
});
|
||||
const projectTeams = teams
|
||||
.sort((a, b) => {
|
||||
const textA = a.name.toUpperCase();
|
||||
const textB = b.name.toUpperCase();
|
||||
return textA < textB ? -1 : textA > textB ? 1 : 0; // eslint-disable-line no-nested-ternary
|
||||
})
|
||||
.map((team) => {
|
||||
return {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
projects: projects
|
||||
.filter((project) => project.team && project.team.id === team.id)
|
||||
.sort((a, b) => {
|
||||
const textA = a.name.toUpperCase();
|
||||
const textB = b.name.toUpperCase();
|
||||
return textA < textB ? -1 : textA > textB ? 1 : 0; // eslint-disable-line no-nested-ternary
|
||||
}),
|
||||
};
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />
|
||||
<Wrapper>
|
||||
<ProjectsContainer>
|
||||
{true && ( // TODO: add permision check
|
||||
<AddTeamButton
|
||||
variant="outline"
|
||||
onClick={($target) => {
|
||||
showPopup(
|
||||
$target,
|
||||
<Popup
|
||||
title="Create team"
|
||||
tab={0}
|
||||
onClose={() => {
|
||||
hidePopup();
|
||||
}}
|
||||
>
|
||||
<CreateTeamForm
|
||||
onCreateTeam={(teamName) => {
|
||||
if (organizationID) {
|
||||
createTeam({ variables: { name: teamName, organizationID } });
|
||||
hidePopup();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popup>,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Add Team
|
||||
</AddTeamButton>
|
||||
)}
|
||||
<div>
|
||||
<ProjectSectionTitleWrapper>
|
||||
<ProjectSectionTitle>Personal Projects</ProjectSectionTitle>
|
||||
</ProjectSectionTitleWrapper>
|
||||
<ProjectList>
|
||||
{personalProjects.map((project, idx) => (
|
||||
<ProjectListItem key={project.id}>
|
||||
<ProjectTile color={colors[idx % 5]} to={`/p/${project.shortId}`}>
|
||||
<ProjectTileFade />
|
||||
<ProjectTileDetails>
|
||||
<ProjectTileName>{project.name}</ProjectTileName>
|
||||
</ProjectTileDetails>
|
||||
</ProjectTile>
|
||||
</ProjectListItem>
|
||||
))}
|
||||
<ProjectListItem>
|
||||
<ProjectAddTile
|
||||
onClick={() => {
|
||||
setShowNewProject({ open: true, initialTeamID: 'no-team' });
|
||||
}}
|
||||
>
|
||||
<ProjectTileFade />
|
||||
<ProjectAddTileDetails>
|
||||
<ProjectTileName centered>Create new project</ProjectTileName>
|
||||
</ProjectAddTileDetails>
|
||||
</ProjectAddTile>
|
||||
</ProjectListItem>
|
||||
</ProjectList>
|
||||
</div>
|
||||
{projectTeams.map((team) => {
|
||||
return (
|
||||
<div key={team.id}>
|
||||
<ProjectSectionTitleWrapper>
|
||||
<ProjectSectionTitle>{team.name}</ProjectSectionTitle>
|
||||
{true && ( // TODO: add permision check
|
||||
<SectionActions>
|
||||
<SectionActionLink to={`/teams/${team.id}`}>
|
||||
<SectionAction variant="outline">Projects</SectionAction>
|
||||
</SectionActionLink>
|
||||
<SectionActionLink to={`/teams/${team.id}/members`}>
|
||||
<SectionAction variant="outline">Members</SectionAction>
|
||||
</SectionActionLink>
|
||||
<SectionActionLink to={`/teams/${team.id}/settings`}>
|
||||
<SectionAction variant="outline">Settings</SectionAction>
|
||||
</SectionActionLink>
|
||||
</SectionActions>
|
||||
)}
|
||||
</ProjectSectionTitleWrapper>
|
||||
<ProjectList>
|
||||
{team.projects.map((project, idx) => (
|
||||
<ProjectListItem key={project.id}>
|
||||
<ProjectTile color={colors[idx % 5]} to={`/p/${project.shortId}`}>
|
||||
<ProjectTileFade />
|
||||
<ProjectTileDetails>
|
||||
<ProjectTileName>{project.name}</ProjectTileName>
|
||||
</ProjectTileDetails>
|
||||
</ProjectTile>
|
||||
</ProjectListItem>
|
||||
))}
|
||||
{true && ( // TODO: add permision check
|
||||
<ProjectListItem>
|
||||
<ProjectAddTile
|
||||
onClick={() => {
|
||||
setShowNewProject({ open: true, initialTeamID: team.id });
|
||||
}}
|
||||
>
|
||||
<ProjectTileFade />
|
||||
<ProjectAddTileDetails>
|
||||
<ProjectTileName centered>Create new project</ProjectTileName>
|
||||
</ProjectAddTileDetails>
|
||||
</ProjectAddTile>
|
||||
</ProjectListItem>
|
||||
)}
|
||||
</ProjectList>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{showNewProject.open && (
|
||||
<NewProject
|
||||
initialTeamID={showNewProject.initialTeamID}
|
||||
onCreateProject={(name, teamID) => {
|
||||
if (user) {
|
||||
createProject({ variables: { teamID, name } });
|
||||
setShowNewProject({ open: false, initialTeamID: null });
|
||||
}
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowNewProject({ open: false, initialTeamID: null });
|
||||
}}
|
||||
teams={teams}
|
||||
/>
|
||||
)}
|
||||
</ProjectsContainer>
|
||||
</Wrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />;
|
||||
};
|
||||
|
||||
export default Projects;
|
13
frontend/src/Register/Styles.ts
Normal file
13
frontend/src/Register/Styles.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
`;
|
||||
|
||||
export const LoginWrapper = styled.div`
|
||||
width: 60%;
|
||||
`;
|
66
frontend/src/Register/index.tsx
Normal file
66
frontend/src/Register/index.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import React, { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import Register from 'shared/components/Register';
|
||||
import { useHistory, useLocation } from 'react-router';
|
||||
import * as QueryString from 'query-string';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Container, LoginWrapper } from './Styles';
|
||||
|
||||
const UsersRegister = () => {
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const [registered, setRegistered] = useState(false);
|
||||
const params = QueryString.parse(location.search);
|
||||
return (
|
||||
<Container>
|
||||
<LoginWrapper>
|
||||
<Register
|
||||
registered={registered}
|
||||
onSubmit={(data, setComplete, setError) => {
|
||||
let isRedirected = false;
|
||||
if (data.password !== data.password_confirm) {
|
||||
setError('password', { type: 'error', message: 'Passwords must match' });
|
||||
setError('password_confirm', { type: 'error', message: 'Passwords must match' });
|
||||
} else {
|
||||
// TODO: change to fetch?
|
||||
fetch('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
user: {
|
||||
username: data.username,
|
||||
roleCode: 'admin',
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
initials: data.initials,
|
||||
fullname: data.fullname,
|
||||
},
|
||||
}),
|
||||
})
|
||||
.then(async (x) => {
|
||||
const response = await x.json();
|
||||
const { setup } = response;
|
||||
if (setup) {
|
||||
history.replace(`/confirm?confirmToken=xxxx`);
|
||||
isRedirected = true;
|
||||
} else if (params.confirmToken) {
|
||||
history.replace(`/confirm?confirmToken=${params.confirmToken}`);
|
||||
isRedirected = true;
|
||||
} else {
|
||||
setRegistered(true);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
toast('There was an issue trying to register');
|
||||
});
|
||||
}
|
||||
if (!isRedirected) {
|
||||
setComplete(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</LoginWrapper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersRegister;
|
557
frontend/src/Teams/Members/index.tsx
Normal file
557
frontend/src/Teams/Members/index.tsx
Normal file
@ -0,0 +1,557 @@
|
||||
import React, { useState } from 'react';
|
||||
import Input from 'shared/components/Input';
|
||||
import updateApolloCache from 'shared/utils/cache';
|
||||
import produce from 'immer';
|
||||
import polling from 'shared/utils/polling';
|
||||
import Button from 'shared/components/Button';
|
||||
import { useCurrentUser } from 'App/context';
|
||||
import Select from 'shared/components/Select';
|
||||
import {
|
||||
useGetTeamQuery,
|
||||
RoleCode,
|
||||
useCreateTeamMemberMutation,
|
||||
useDeleteTeamMemberMutation,
|
||||
useUpdateTeamMemberRoleMutation,
|
||||
GetTeamQuery,
|
||||
GetTeamDocument,
|
||||
} from 'shared/generated/graphql';
|
||||
import { UserPlus, Checkmark } from 'shared/icons';
|
||||
import styled, { css } from 'styled-components/macro';
|
||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
||||
import Member from 'shared/components/Member';
|
||||
import ControlledInput from 'shared/components/ControlledInput';
|
||||
import NOOP from 'shared/utils/noop';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
|
||||
const MemberListWrapper = styled.div`
|
||||
flex: 1 1;
|
||||
`;
|
||||
|
||||
const SearchInput = styled(ControlledInput)`
|
||||
margin: 0 12px;
|
||||
`;
|
||||
|
||||
const UserMember = styled(Member)`
|
||||
padding: 4px 0;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background: ${(props) => mixin.rgba(props.theme.colors.bg.primary, 0.4)};
|
||||
}
|
||||
border-radius: 6px;
|
||||
`;
|
||||
|
||||
const TeamMemberList = styled.div`
|
||||
margin: 8px 12px;
|
||||
`;
|
||||
|
||||
type UserManagementPopupProps = {
|
||||
users: Array<User>;
|
||||
teamMembers: Array<TaskUser>;
|
||||
onAddTeamMember: (userID: string) => void;
|
||||
};
|
||||
|
||||
const UserManagementPopup: React.FC<UserManagementPopupProps> = ({ users, teamMembers, onAddTeamMember }) => {
|
||||
return (
|
||||
<Popup tab={0} title="Invite a user">
|
||||
<SearchInput width="100%" variant="alternate" placeholder="Email address or name" name="search" />
|
||||
<TeamMemberList>
|
||||
{users
|
||||
.filter((u) => u.id !== teamMembers.find((p) => p.id === u.id)?.id)
|
||||
.map((user) => (
|
||||
<UserMember
|
||||
key={user.id}
|
||||
onCardMemberClick={() => onAddTeamMember(user.id)}
|
||||
showName
|
||||
member={user}
|
||||
taskID=""
|
||||
/>
|
||||
))}
|
||||
</TeamMemberList>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
export const RoleCheckmark = styled(Checkmark)`
|
||||
padding-left: 4px;
|
||||
`;
|
||||
|
||||
const permissions = [
|
||||
{
|
||||
code: 'owner',
|
||||
name: 'Owner',
|
||||
description:
|
||||
'Can view, create and edit team projects, and change settings for the team. Will have admin rights on all projects in this team. Can delete the team and all team projects.',
|
||||
},
|
||||
{
|
||||
code: 'admin',
|
||||
name: 'Admin',
|
||||
description:
|
||||
'Can view, create and edit team projects, and change settings for the team. Will have admin rights on all projects in this team.',
|
||||
},
|
||||
|
||||
{ code: 'member', name: 'Member', description: 'Can view, create, and edit team projects, but not change settings.' },
|
||||
];
|
||||
|
||||
export const RoleName = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
export const RoleDescription = styled.div`
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
export const MiniProfileActions = styled.ul`
|
||||
list-style-type: none;
|
||||
`;
|
||||
|
||||
export const MiniProfileActionWrapper = styled.li``;
|
||||
|
||||
export const MiniProfileActionItem = styled.span<{ disabled?: boolean }>`
|
||||
color: #c2c6dc;
|
||||
display: block;
|
||||
font-weight: 400;
|
||||
padding: 6px 12px;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
|
||||
${(props) =>
|
||||
props.disabled
|
||||
? css`
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
color: ${mixin.rgba(props.theme.colors.text.primary, 0.4)};
|
||||
`
|
||||
: css`
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background: ${props.theme.colors.primary};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export const Content = styled.div`
|
||||
padding: 0 12px 12px;
|
||||
`;
|
||||
|
||||
export const CurrentPermission = styled.span`
|
||||
margin-left: 4px;
|
||||
color: ${(props) => mixin.rgba(props.theme.colors.text.secondary, 0.4)};
|
||||
`;
|
||||
|
||||
export const Separator = styled.div`
|
||||
height: 1px;
|
||||
border-top: 1px solid #414561;
|
||||
margin: 0.25rem !important;
|
||||
`;
|
||||
|
||||
export const WarningText = styled.span`
|
||||
display: flex;
|
||||
color: ${(props) => mixin.rgba(props.theme.colors.text.primary, 0.4)};
|
||||
padding: 6px;
|
||||
`;
|
||||
|
||||
export const DeleteDescription = styled.div`
|
||||
font-size: 14px;
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
`;
|
||||
|
||||
export const RemoveMemberButton = styled(Button)`
|
||||
margin-top: 16px;
|
||||
padding: 6px 12px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type TeamRoleManagerPopupProps = {
|
||||
currentUserID: string;
|
||||
subject: User;
|
||||
members: Array<User>;
|
||||
warning?: string | null;
|
||||
canChangeRole: boolean;
|
||||
onChangeRole: (roleCode: RoleCode) => void;
|
||||
onRemoveFromTeam?: (newOwnerID: string | null) => void;
|
||||
};
|
||||
|
||||
const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
|
||||
members,
|
||||
warning,
|
||||
subject,
|
||||
currentUserID,
|
||||
canChangeRole,
|
||||
onRemoveFromTeam,
|
||||
onChangeRole,
|
||||
}) => {
|
||||
const { hidePopup, setTab } = usePopup();
|
||||
const [orphanedProjectOwner, setOrphanedProjectOwner] = useState<{ label: string; value: string } | null>(null);
|
||||
return (
|
||||
<>
|
||||
<Popup title={null} tab={0}>
|
||||
<MiniProfileActions>
|
||||
<MiniProfileActionWrapper>
|
||||
{subject.role && (
|
||||
<MiniProfileActionItem
|
||||
onClick={() => {
|
||||
setTab(1);
|
||||
}}
|
||||
>
|
||||
Change permissions...
|
||||
<CurrentPermission>{`(${subject.role.name})`}</CurrentPermission>
|
||||
</MiniProfileActionItem>
|
||||
)}
|
||||
{onRemoveFromTeam && (
|
||||
<MiniProfileActionItem
|
||||
onClick={() => {
|
||||
setTab(2);
|
||||
}}
|
||||
>
|
||||
{currentUserID === subject.id ? 'Leave team...' : 'Remove from team...'}
|
||||
</MiniProfileActionItem>
|
||||
)}
|
||||
</MiniProfileActionWrapper>
|
||||
</MiniProfileActions>
|
||||
{warning && (
|
||||
<>
|
||||
<Separator />
|
||||
<WarningText>{warning}</WarningText>
|
||||
</>
|
||||
)}
|
||||
</Popup>
|
||||
<Popup title="Change Permissions" onClose={() => hidePopup()} tab={1}>
|
||||
<MiniProfileActions>
|
||||
<MiniProfileActionWrapper>
|
||||
{permissions
|
||||
.filter((p) => (subject.role && subject.role.code === 'owner') || p.code !== 'owner')
|
||||
.map((perm) => (
|
||||
<MiniProfileActionItem
|
||||
disabled={subject.role && perm.code !== subject.role.code && !canChangeRole}
|
||||
key={perm.code}
|
||||
onClick={() => {
|
||||
if (subject.role && perm.code !== subject.role.code) {
|
||||
switch (perm.code) {
|
||||
case 'owner':
|
||||
onChangeRole(RoleCode.Owner);
|
||||
break;
|
||||
case 'admin':
|
||||
onChangeRole(RoleCode.Admin);
|
||||
break;
|
||||
case 'member':
|
||||
onChangeRole(RoleCode.Member);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
hidePopup();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RoleName>
|
||||
{perm.name}
|
||||
{subject.role && perm.code === subject.role.code && <RoleCheckmark width={12} height={12} />}
|
||||
</RoleName>
|
||||
<RoleDescription>{perm.description}</RoleDescription>
|
||||
</MiniProfileActionItem>
|
||||
))}
|
||||
</MiniProfileActionWrapper>
|
||||
{subject.role && subject.role.code === 'owner' && (
|
||||
<>
|
||||
<Separator />
|
||||
<WarningText>You can not change roles because there must be an owner.</WarningText>
|
||||
</>
|
||||
)}
|
||||
</MiniProfileActions>
|
||||
</Popup>
|
||||
<Popup title="Remove from Team?" onClose={() => hidePopup()} tab={2}>
|
||||
<Content>
|
||||
<DeleteDescription>
|
||||
The member will be removed from all team project tasks. They will receive a notification.
|
||||
</DeleteDescription>
|
||||
{subject.owned && subject.owned.projects.length !== 0 && (
|
||||
<>
|
||||
<DeleteDescription>
|
||||
{`The member is the owner of ${subject.owned.projects.length} project${
|
||||
subject.owned.projects.length > 1 ? 's' : ''
|
||||
}. You can give the projects a new owner but it is not needed`}
|
||||
</DeleteDescription>
|
||||
<Select
|
||||
label="New projects owner"
|
||||
value={orphanedProjectOwner}
|
||||
onChange={(value) => setOrphanedProjectOwner(value)}
|
||||
options={members.filter((m) => m.id !== subject.id).map((m) => ({ label: m.fullName, value: m.id }))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<RemoveMemberButton
|
||||
color="danger"
|
||||
onClick={() => {
|
||||
if (onRemoveFromTeam) {
|
||||
onRemoveFromTeam(orphanedProjectOwner ? orphanedProjectOwner.value : null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove Member
|
||||
</RemoveMemberButton>
|
||||
</Content>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MemberItemOptions = styled.div``;
|
||||
|
||||
const MemberItemOption = styled(Button)`
|
||||
padding: 7px 9px;
|
||||
margin: 4px 0 2px 8px;
|
||||
float: left;
|
||||
min-width: 95px;
|
||||
`;
|
||||
|
||||
const MemberList = styled.div`
|
||||
border-top: 1px solid ${(props) => props.theme.colors.border};
|
||||
`;
|
||||
|
||||
const MemberListItem = styled.div`
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid ${(props) => props.theme.colors.border};
|
||||
min-height: 40px;
|
||||
padding: 12px 0 12px 40px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const MemberListItemDetails = styled.div`
|
||||
float: left;
|
||||
flex: 1 0 auto;
|
||||
padding-left: 8px;
|
||||
`;
|
||||
|
||||
const InviteIcon = styled(UserPlus)`
|
||||
padding-right: 4px;
|
||||
`;
|
||||
|
||||
const MemberProfile = styled(TaskAssignee)`
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const MemberItemName = styled.p`
|
||||
color: ${(props) => props.theme.colors.text.secondary};
|
||||
`;
|
||||
|
||||
const MemberItemUsername = styled.p`
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
`;
|
||||
|
||||
const MemberListHeader = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
const ListTitle = styled.h3`
|
||||
font-size: 18px;
|
||||
color: ${(props) => props.theme.colors.text.secondary};
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
const ListDesc = styled.span`
|
||||
font-size: 16px;
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
`;
|
||||
const FilterSearch = styled(Input)`
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const ListActions = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 18px;
|
||||
`;
|
||||
|
||||
const InviteMemberButton = styled(Button)`
|
||||
padding: 6px 12px;
|
||||
`;
|
||||
const FilterTab = styled.div`
|
||||
max-width: 240px;
|
||||
flex: 0 0 240px;
|
||||
margin: 0;
|
||||
padding-right: 32px;
|
||||
`;
|
||||
|
||||
const FilterTabItems = styled.ul``;
|
||||
const FilterTabItem = styled.li`
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
padding: 6px 8px;
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
&:hover {
|
||||
border-radius: 6px;
|
||||
background: ${(props) => props.theme.colors.primary};
|
||||
color: ${(props) => props.theme.colors.text.secondary};
|
||||
}
|
||||
`;
|
||||
|
||||
const FilterTabTitle = styled.h2`
|
||||
color: #5e6c84;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 16px;
|
||||
margin-top: 16px;
|
||||
text-transform: uppercase;
|
||||
padding: 8px;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const MemberContainer = styled.div`
|
||||
margin-top: 45px;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type MembersProps = {
|
||||
teamID: string;
|
||||
};
|
||||
|
||||
const Members: React.FC<MembersProps> = ({ teamID }) => {
|
||||
const { showPopup, hidePopup } = usePopup();
|
||||
const { loading, data } = useGetTeamQuery({
|
||||
variables: { teamID },
|
||||
fetchPolicy: 'cache-and-network',
|
||||
pollInterval: polling.MEMBERS,
|
||||
});
|
||||
const { user } = useCurrentUser();
|
||||
const warning =
|
||||
'You can’t leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
|
||||
const [createTeamMember] = useCreateTeamMemberMutation({
|
||||
update: (client, response) => {
|
||||
updateApolloCache<GetTeamQuery>(
|
||||
client,
|
||||
GetTeamDocument,
|
||||
(cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
if (response.data) {
|
||||
draftCache.findTeam.members.push({
|
||||
...response.data.createTeamMember.teamMember,
|
||||
member: { __typename: 'MemberList', projects: [], teams: [] },
|
||||
owned: { __typename: 'OwnedList', projects: [], teams: [] },
|
||||
});
|
||||
}
|
||||
}),
|
||||
{ teamID },
|
||||
);
|
||||
},
|
||||
});
|
||||
const [updateTeamMemberRole] = useUpdateTeamMemberRoleMutation();
|
||||
const [deleteTeamMember] = useDeleteTeamMemberMutation({
|
||||
update: (client, response) => {
|
||||
updateApolloCache<GetTeamQuery>(
|
||||
client,
|
||||
GetTeamDocument,
|
||||
(cache) =>
|
||||
produce(cache, (draftCache) => {
|
||||
draftCache.findTeam.members = cache.findTeam.members.filter(
|
||||
(member) => member.id !== response.data?.deleteTeamMember.userID,
|
||||
);
|
||||
}),
|
||||
{ teamID },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (data && user) {
|
||||
return (
|
||||
<MemberContainer>
|
||||
<FilterTab>
|
||||
<FilterTabTitle>MEMBERS OF TEAM PROJECTS</FilterTabTitle>
|
||||
<FilterTabItems>
|
||||
<FilterTabItem>{`Team Members (${data.findTeam.members.length})`}</FilterTabItem>
|
||||
<FilterTabItem>Observers</FilterTabItem>
|
||||
</FilterTabItems>
|
||||
</FilterTab>
|
||||
<MemberListWrapper>
|
||||
<MemberListHeader>
|
||||
<ListTitle>{`Team Members (${data.findTeam.members.length})`}</ListTitle>
|
||||
<ListDesc>
|
||||
Team members can view and join all Team Visible boards and create new boards in the team.
|
||||
</ListDesc>
|
||||
<ListActions>
|
||||
<FilterSearch width="250px" variant="alternate" placeholder="Filter by name" />
|
||||
{true && ( // TODO: add permission check
|
||||
<InviteMemberButton
|
||||
onClick={($target) => {
|
||||
showPopup(
|
||||
$target,
|
||||
<UserManagementPopup
|
||||
users={data.users}
|
||||
teamMembers={data.findTeam.members}
|
||||
onAddTeamMember={(userID) => {
|
||||
createTeamMember({ variables: { userID, teamID } });
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<InviteIcon width={16} height={16} />
|
||||
Invite Team Members
|
||||
</InviteMemberButton>
|
||||
)}
|
||||
</ListActions>
|
||||
</MemberListHeader>
|
||||
<MemberList>
|
||||
{data.findTeam.members.map((member) => (
|
||||
<MemberListItem>
|
||||
<MemberProfile showRoleIcons size={32} onMemberProfile={NOOP} member={member} />
|
||||
<MemberListItemDetails>
|
||||
<MemberItemName>{member.fullName}</MemberItemName>
|
||||
<MemberItemUsername>{`@${member.username}`}</MemberItemUsername>
|
||||
</MemberListItemDetails>
|
||||
<MemberItemOptions>
|
||||
<MemberItemOption variant="flat">On 2 projects</MemberItemOption>
|
||||
<MemberItemOption
|
||||
variant="outline"
|
||||
onClick={($target) => {
|
||||
showPopup(
|
||||
$target,
|
||||
<TeamRoleManagerPopup
|
||||
currentUserID={user ?? ''}
|
||||
subject={member}
|
||||
members={data.findTeam.members}
|
||||
warning={member.role && member.role.code === 'owner' ? warning : null}
|
||||
// canChangeRole={user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)} TODO: add permission check
|
||||
canChangeRole
|
||||
onChangeRole={(roleCode) => {
|
||||
updateTeamMemberRole({ variables: { userID: member.id, teamID, roleCode } });
|
||||
}}
|
||||
onRemoveFromTeam={
|
||||
member.role && member.role.code === 'owner'
|
||||
? undefined
|
||||
: (newOwnerID) => {
|
||||
deleteTeamMember({ variables: { teamID, newOwnerID, userID: member.id } });
|
||||
hidePopup();
|
||||
}
|
||||
}
|
||||
/>,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Manage
|
||||
</MemberItemOption>
|
||||
</MemberItemOptions>
|
||||
</MemberListItem>
|
||||
))}
|
||||
</MemberList>
|
||||
</MemberListWrapper>
|
||||
</MemberContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return <div>loading</div>;
|
||||
};
|
||||
|
||||
export default Members;
|
197
frontend/src/Teams/Projects/index.tsx
Normal file
197
frontend/src/Teams/Projects/index.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
import React from 'react';
|
||||
import styled, { css } from 'styled-components/macro';
|
||||
import {
|
||||
useGetTeamQuery,
|
||||
useDeleteTeamMutation,
|
||||
GetProjectsDocument,
|
||||
GetProjectsQuery,
|
||||
} from 'shared/generated/graphql';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Input from 'shared/components/Input';
|
||||
import theme from 'App/ThemeStyles';
|
||||
import polling from 'shared/utils/polling';
|
||||
|
||||
const FilterSearch = styled(Input)`
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const ProjectsContainer = styled.div`
|
||||
margin-top: 45px;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const FilterTab = styled.div`
|
||||
max-width: 240px;
|
||||
flex: 0 0 240px;
|
||||
margin: 0;
|
||||
padding-right: 32px;
|
||||
`;
|
||||
|
||||
const FilterTabItems = styled.ul``;
|
||||
const FilterTabItem = styled.li`
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
padding: 6px 8px;
|
||||
color: ${props => props.theme.colors.text.primary};
|
||||
&:hover {
|
||||
border-radius: 6px;
|
||||
background: ${props => props.theme.colors.primary};
|
||||
color: ${props => props.theme.colors.text.secondary};
|
||||
}
|
||||
`;
|
||||
|
||||
const FilterTabTitle = styled.h2`
|
||||
color: #5e6c84;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 16px;
|
||||
margin-top: 16px;
|
||||
text-transform: uppercase;
|
||||
padding: 8px;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const ProjectAddTile = styled.div`
|
||||
background-color: ${props => props.theme.colors.bg.primary};
|
||||
background-size: cover;
|
||||
background-position: 50%;
|
||||
color: #fff;
|
||||
line-height: 20px;
|
||||
padding: 8px;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
`;
|
||||
|
||||
const ProjectTile = styled(Link)<{ color: string }>`
|
||||
background-color: ${props => props.color};
|
||||
background-size: cover;
|
||||
background-position: 50%;
|
||||
color: #fff;
|
||||
line-height: 20px;
|
||||
padding: 8px;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
`;
|
||||
|
||||
const ProjectTileFade = styled.div`
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
`;
|
||||
|
||||
const ProjectListItem = styled.li`
|
||||
width: 23.5%;
|
||||
padding: 0;
|
||||
margin: 0 2% 2% 0;
|
||||
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover ${ProjectTileFade} {
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
`;
|
||||
|
||||
const ProjectList = styled.ul`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 16px;
|
||||
|
||||
& ${ProjectListItem}:nth-of-type(4n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const ProjectTileDetails = styled.div`
|
||||
display: flex;
|
||||
height: 80px;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const ProjectAddTileDetails = styled.div`
|
||||
display: flex;
|
||||
height: 80px;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const ProjectTileName = styled.div<{ centered?: boolean }>`
|
||||
flex: 0 0 auto;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
max-height: 40px;
|
||||
width: 100%;
|
||||
word-wrap: break-word;
|
||||
${props => props.centered && 'text-align: center;'}
|
||||
`;
|
||||
const ProjectListWrapper = styled.div`
|
||||
flex: 1 1;
|
||||
`;
|
||||
|
||||
const colors = theme.colors.multiColors;
|
||||
|
||||
type TeamProjectsProps = {
|
||||
teamID: string;
|
||||
};
|
||||
|
||||
const TeamProjects: React.FC<TeamProjectsProps> = ({ teamID }) => {
|
||||
const { loading, data } = useGetTeamQuery({
|
||||
variables: { teamID },
|
||||
fetchPolicy: 'cache-and-network',
|
||||
pollInterval: polling.TEAM_PROJECTS,
|
||||
});
|
||||
if (data) {
|
||||
return (
|
||||
<ProjectsContainer>
|
||||
<FilterTab>
|
||||
<FilterSearch placeholder="Search for projects..." width="100%" variant="alternate" />
|
||||
<FilterTabTitle>SORT</FilterTabTitle>
|
||||
<FilterTabItems>
|
||||
<FilterTabItem>Most Recently Active</FilterTabItem>
|
||||
<FilterTabItem>Number of Members</FilterTabItem>
|
||||
<FilterTabItem>Number of Stars</FilterTabItem>
|
||||
<FilterTabItem>Alphabetical</FilterTabItem>
|
||||
</FilterTabItems>
|
||||
</FilterTab>
|
||||
<ProjectListWrapper>
|
||||
<ProjectList>
|
||||
{data.projects.map((project, idx) => (
|
||||
<ProjectListItem key={project.id}>
|
||||
<ProjectTile color={colors[idx % 5]} to={`/projects/${project.id}`}>
|
||||
<ProjectTileFade />
|
||||
<ProjectTileDetails>
|
||||
<ProjectTileName>{project.name}</ProjectTileName>
|
||||
</ProjectTileDetails>
|
||||
</ProjectTile>
|
||||
</ProjectListItem>
|
||||
))}
|
||||
</ProjectList>
|
||||
</ProjectListWrapper>
|
||||
</ProjectsContainer>
|
||||
);
|
||||
}
|
||||
return <span>loading</span>;
|
||||
};
|
||||
|
||||
export default TeamProjects;
|
7
frontend/src/Teams/Settings/index.tsx
Normal file
7
frontend/src/Teams/Settings/index.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
const TeamSettings = () => {
|
||||
return <h1>HI!</h1>;
|
||||
};
|
||||
|
||||
export default TeamSettings;
|
152
frontend/src/Teams/index.tsx
Normal file
152
frontend/src/Teams/index.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import styled from 'styled-components/macro';
|
||||
import GlobalTopNavbar from 'App/TopNavbar';
|
||||
import updateApolloCache from 'shared/utils/cache';
|
||||
import { Route, Switch, useRouteMatch, Redirect, useParams, useHistory } from 'react-router';
|
||||
import {
|
||||
useGetTeamQuery,
|
||||
useDeleteTeamMutation,
|
||||
GetProjectsDocument,
|
||||
GetProjectsQuery,
|
||||
} from 'shared/generated/graphql';
|
||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||
import { History } from 'history';
|
||||
import produce from 'immer';
|
||||
import { TeamSettings, DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings';
|
||||
import { useCurrentUser } from 'App/context';
|
||||
import NOOP from 'shared/utils/noop';
|
||||
import Members from './Members';
|
||||
import Projects from './Projects';
|
||||
|
||||
const OuterWrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type TeamPopupProps = {
|
||||
history: History<any>;
|
||||
name: string;
|
||||
teamID: string;
|
||||
};
|
||||
|
||||
export const TeamPopup: React.FC<TeamPopupProps> = ({ history, name, teamID }) => {
|
||||
const { hidePopup, setTab } = usePopup();
|
||||
const [deleteTeam] = useDeleteTeamMutation({
|
||||
update: (client, deleteData) => {
|
||||
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
|
||||
produce(cache, draftCache => {
|
||||
draftCache.teams = cache.teams.filter((team: any) => team.id !== deleteData.data?.deleteTeam.team.id);
|
||||
draftCache.projects = cache.projects.filter(
|
||||
(project: any) => project.team.id !== deleteData.data?.deleteTeam.team.id,
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Popup title={null} tab={0}>
|
||||
<TeamSettings
|
||||
onDeleteTeam={() => {
|
||||
setTab(1, { width: 340 });
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
<Popup title={`Delete the "${name}" team?`} tab={1} onClose={() => hidePopup()}>
|
||||
<DeleteConfirm
|
||||
description={DELETE_INFO.DELETE_TEAMS.description}
|
||||
deletedItems={DELETE_INFO.DELETE_TEAMS.deletedItems}
|
||||
onConfirmDelete={() => {
|
||||
if (teamID) {
|
||||
deleteTeam({ variables: { teamID } });
|
||||
hidePopup();
|
||||
history.push('/projects');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type TeamsRouteProps = {
|
||||
teamID: string;
|
||||
};
|
||||
|
||||
const Teams = () => {
|
||||
const { teamID } = useParams<TeamsRouteProps>();
|
||||
const history = useHistory();
|
||||
const { loading, data } = useGetTeamQuery({
|
||||
variables: { teamID },
|
||||
onCompleted: resp => {
|
||||
document.title = `${resp.findTeam.name} | Taskcafé`;
|
||||
},
|
||||
});
|
||||
const { user } = useCurrentUser();
|
||||
const [currentTab, setCurrentTab] = useState(0);
|
||||
const match = useRouteMatch();
|
||||
if (data && user) {
|
||||
/*
|
||||
TODO: re-add permission check
|
||||
if (!user.isVisible(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)) {
|
||||
return <Redirect to="/" />;
|
||||
}
|
||||
*/
|
||||
return (
|
||||
<>
|
||||
<GlobalTopNavbar
|
||||
menuType={[
|
||||
{ name: 'Projects', link: `${match.url}` },
|
||||
{ name: 'Members', link: `${match.url}/members` },
|
||||
]}
|
||||
currentTab={currentTab}
|
||||
onSetTab={tab => {
|
||||
setCurrentTab(tab);
|
||||
}}
|
||||
popupContent={<TeamPopup history={history} name={data.findTeam.name} teamID={teamID} />}
|
||||
onSaveProjectName={NOOP}
|
||||
projectID={null}
|
||||
name={data.findTeam.name}
|
||||
/>
|
||||
<OuterWrapper>
|
||||
<Wrapper>
|
||||
<Switch>
|
||||
<Route exact path={match.path}>
|
||||
<Projects teamID={teamID} />
|
||||
</Route>
|
||||
<Route path={`${match.path}/members`}>
|
||||
<Members teamID={teamID} />
|
||||
</Route>
|
||||
</Switch>
|
||||
</Wrapper>
|
||||
</OuterWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<GlobalTopNavbar
|
||||
menuType={[
|
||||
{ name: 'Projects', link: `${match.url}` },
|
||||
{ name: 'Members', link: `${match.url}/members` },
|
||||
]}
|
||||
currentTab={currentTab}
|
||||
onSetTab={tab => {
|
||||
setCurrentTab(tab);
|
||||
}}
|
||||
onSaveProjectName={NOOP}
|
||||
projectID={null}
|
||||
name={null}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Teams;
|
@ -1,113 +0,0 @@
|
||||
import { DefaultTheme } from 'styled-components';
|
||||
import Color from 'color';
|
||||
|
||||
export const darkTheme: DefaultTheme = {
|
||||
borderRadius: {
|
||||
primary: '3x',
|
||||
alternate: '6px',
|
||||
},
|
||||
colors: {
|
||||
multiColors: ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f'],
|
||||
primary: 'rgb(115, 103, 240)',
|
||||
secondary: 'rgb(216, 93, 216)',
|
||||
alternate: 'rgb(65, 69, 97)',
|
||||
success: 'rgb(40, 199, 111)',
|
||||
danger: 'rgb(234, 84, 85)',
|
||||
warning: 'rgb(255, 159, 67)',
|
||||
dark: 'rgb(30, 30, 30)',
|
||||
form: {
|
||||
textfield: {
|
||||
background: 'rgb(38, 44, 73)',
|
||||
text: 'rgb(255, 255, 255)',
|
||||
label: 'rgb(194, 198, 220)',
|
||||
placeholder: 'rgb(64, 70, 86)',
|
||||
error: 'rgb(234, 84, 85)',
|
||||
borderColor: 'rgb(65, 69, 97)',
|
||||
secondaryLabel: 'rgb(115, 103, 240)',
|
||||
hover: {
|
||||
text: 'rgb(255, 255, 255)',
|
||||
background: 'none',
|
||||
borderColor: 'rgb(115, 103, 240)',
|
||||
},
|
||||
},
|
||||
},
|
||||
icon: {
|
||||
primary: 'rgb(194, 198, 220)',
|
||||
secondary: 'rgb(255, 255, 255)',
|
||||
alternate: 'rgb(0, 0, 0)',
|
||||
danger: 'rgb(234, 84, 85)',
|
||||
colored: 'rgb(115, 103, 240)',
|
||||
},
|
||||
text: {
|
||||
primary: 'rgb(194, 198, 220)',
|
||||
secondary: 'rgb(255, 255, 255)',
|
||||
alternate: 'rgb(0, 0, 0)',
|
||||
},
|
||||
border: {
|
||||
primary: 'rgb(65, 69, 97)',
|
||||
secondary: 'rgb(115, 103, 240)',
|
||||
},
|
||||
bg: {
|
||||
primary: 'rgb(16, 22, 58)',
|
||||
secondary: 'rgb(38, 44, 73)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const lightTheme: DefaultTheme = {
|
||||
borderRadius: {
|
||||
primary: '3x',
|
||||
alternate: '6px',
|
||||
},
|
||||
colors: {
|
||||
multiColors: ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f'],
|
||||
primary: 'rgb(115, 103, 240)',
|
||||
secondary: 'rgb(216, 93, 216)',
|
||||
alternate: 'rgb(65, 69, 97)',
|
||||
success: 'rgb(40, 199, 111)',
|
||||
danger: 'rgb(234, 84, 85)',
|
||||
warning: 'rgb(255, 159, 67)',
|
||||
dark: 'rgb(30, 30, 30)',
|
||||
form: {
|
||||
textfield: {
|
||||
background: 'rgb(250, 251, 252)',
|
||||
error: 'rgb(234, 84, 85)',
|
||||
text: 'rgb(23, 43, 77)',
|
||||
label: 'rgb(94, 108, 132)',
|
||||
secondaryLabel: 'rgb(115, 103, 240)',
|
||||
borderColor: 'rgb( 233, 225, 230)',
|
||||
placeholder: 'rgb(64, 70, 86)',
|
||||
hover: {
|
||||
text: 'rgb(23, 43, 77)',
|
||||
background: 'rgb(255, 255, 255)',
|
||||
borderColor: 'rgb(115, 103, 240)',
|
||||
},
|
||||
},
|
||||
},
|
||||
icon: {
|
||||
primary: 'rgb(194, 198, 220)',
|
||||
secondary: 'rgb(0, 0, 0)',
|
||||
alternate: 'rgb(0, 0, 0)',
|
||||
colored: 'rgb(115, 103, 240)',
|
||||
danger: 'rgb(234, 84, 85)',
|
||||
},
|
||||
text: {
|
||||
primary: 'rgb(194, 198, 220)',
|
||||
secondary: 'rgb(255, 255, 255)',
|
||||
alternate: 'rgb(0, 0, 0)',
|
||||
},
|
||||
border: {
|
||||
primary: 'rgb(223, 225, 230)',
|
||||
secondary: 'rgb(115, 103, 240)',
|
||||
},
|
||||
bg: {
|
||||
primary: 'rgb(0, 22, 58)',
|
||||
secondary: 'rgb(250, 251, 252)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
darkTheme,
|
||||
lightTheme,
|
||||
};
|
@ -1,4 +0,0 @@
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
src: url(/assets/fonts/OpenSans-Regular.ttf) format('truetype');
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import Login from 'pages/Login';
|
||||
|
||||
import NormalizeStyles from './NormalizeStyles';
|
||||
import BaseStyles from './BaseStyles';
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
@ -1,11 +1,57 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { ApolloClient } from '@apollo/client';
|
||||
import { ApolloProvider } from '@apollo/client/react';
|
||||
|
||||
import App from 'app';
|
||||
import { enableMapSet } from 'immer';
|
||||
import dayjs from 'dayjs';
|
||||
import updateLocale from 'dayjs/plugin/updateLocale';
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
||||
import isBetween from 'dayjs/plugin/isBetween';
|
||||
import weekday from 'dayjs/plugin/weekday';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import log from 'loglevel';
|
||||
import remote from 'loglevel-plugin-remote';
|
||||
import cache from './App/cache';
|
||||
import App from './App';
|
||||
|
||||
if (process.env.REACT_APP_NODE_ENV === 'production') {
|
||||
remote.apply(log, { format: remote.json });
|
||||
switch (process.env.REACT_APP_LOG_LEVEL) {
|
||||
case 'info':
|
||||
log.setLevel(log.levels.INFO);
|
||||
break;
|
||||
case 'debug':
|
||||
log.setLevel(log.levels.DEBUG);
|
||||
break;
|
||||
default:
|
||||
log.setLevel(log.levels.ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
enableMapSet();
|
||||
|
||||
dayjs.extend(isSameOrAfter);
|
||||
dayjs.extend(weekday);
|
||||
dayjs.extend(isBetween);
|
||||
dayjs.extend(customParseFormat);
|
||||
dayjs.extend(updateLocale);
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.updateLocale('en', {
|
||||
week: {
|
||||
dow: 1, // First day of week is Monday
|
||||
doy: 7, // First week of year must contain 1 January (7 + 1 - 1)
|
||||
},
|
||||
});
|
||||
|
||||
const client = new ApolloClient({ uri: '/graphql', cache });
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<ApolloProvider client={client}>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
</ApolloProvider>,
|
||||
document.getElementById('root'),
|
||||
);
|
||||
|
0
frontend/src/outline.d.ts
vendored
Normal file
0
frontend/src/outline.d.ts
vendored
Normal file
@ -1,65 +0,0 @@
|
||||
import React from 'react';
|
||||
import { FieldError, useForm, UseFormSetError } from 'react-hook-form';
|
||||
import Form from 'shared/components/form';
|
||||
import styled from 'styled-components';
|
||||
import DefaultFormButton from 'shared/components/form/FormButton';
|
||||
import DefaultFormTextField from 'shared/components/form/FormTextField';
|
||||
import DefaultFormPasswordField from 'shared/components/form/FormPasswordField';
|
||||
|
||||
const FormTextField = styled(DefaultFormTextField)`
|
||||
margin-top: 12px;
|
||||
`;
|
||||
|
||||
const FormPasswordField = styled(DefaultFormPasswordField)`
|
||||
margin-top: 12px;
|
||||
`;
|
||||
|
||||
const FormButton = styled(DefaultFormButton)`
|
||||
margin-top: 20px;
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
|
||||
export type LoginFormData = {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type OnLoginFn = (data: LoginFormData, setError: UseFormSetError<LoginFormData>) => void;
|
||||
|
||||
type LoginFormProps = {
|
||||
onLogin: OnLoginFn;
|
||||
onForgotPassword: () => void;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
const LoginForm: React.FC<LoginFormProps> = ({ onLogin, onForgotPassword, isLoading }) => {
|
||||
const {
|
||||
register,
|
||||
setError,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginFormData>();
|
||||
const onSubmit = (data: LoginFormData) => {
|
||||
onLogin(data, setError);
|
||||
};
|
||||
return (
|
||||
<Form>
|
||||
<FormTextField
|
||||
error={errors.username?.message}
|
||||
label="Username"
|
||||
{...register('username', { required: 'Username is required' })}
|
||||
/>
|
||||
<FormPasswordField
|
||||
label="Password"
|
||||
error={errors.password?.message}
|
||||
secondaryLabel={{ label: 'Forgot Password?', onClick: onForgotPassword }}
|
||||
{...register('password', { required: 'Password is required' })}
|
||||
/>
|
||||
<FormButton disabled={isLoading} width="100%" onClick={handleSubmit(onSubmit)}>
|
||||
Login
|
||||
</FormButton>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
@ -1,26 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import LoginPage from './page/LoginPage';
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const [settings, setSettings] = useState<null | { allowRegistration: string }>(null);
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
fetch('/public_settings')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (!data.isInstalled) {
|
||||
navigate('/register');
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
return (
|
||||
<LoginPage
|
||||
onLogin={(data, setError) => {
|
||||
console.log(data);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
@ -1,29 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import LoginPage from './LoginPage';
|
||||
|
||||
export default {
|
||||
title: 'Pages/LoginPage',
|
||||
component: LoginPage,
|
||||
argTypes: {
|
||||
onLogin: {
|
||||
action: 'on login',
|
||||
},
|
||||
onRegister: {
|
||||
options: ['None', 'Standard'],
|
||||
defaultValue: 'Standard',
|
||||
mapping: {
|
||||
Standard: () => action('on register'),
|
||||
None: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ComponentMeta<typeof LoginPage>;
|
||||
|
||||
const Template: ComponentStory<typeof LoginPage> = ({ children, ...args }) => <LoginPage {...args} />;
|
||||
|
||||
export const Primary = Template.bind({});
|
||||
|
||||
Primary.args = { isLoading: true };
|
@ -1,51 +0,0 @@
|
||||
import React from 'react';
|
||||
import InfoCircle from 'shared/components/icons/solid/InfoCircle';
|
||||
import AuthPageLayout from 'shared/components/layout/AuthPageLayout';
|
||||
import LoginForm, { OnLoginFn } from 'pages/Login/form/LoginForm';
|
||||
import * as S from './Styles';
|
||||
|
||||
type LoginPageProps = {
|
||||
isLoading: boolean;
|
||||
onLogin: OnLoginFn;
|
||||
onRegister?: () => void;
|
||||
alert?: string;
|
||||
};
|
||||
|
||||
const LoginPage: React.FC<LoginPageProps> = ({ isLoading, alert, onRegister }) => {
|
||||
return (
|
||||
<AuthPageLayout>
|
||||
<S.Welcome>Welcome to Taskcafe</S.Welcome>
|
||||
{alert && (
|
||||
<S.Alert>
|
||||
<S.AlertContent>{alert}</S.AlertContent>
|
||||
<S.AlertIcon>
|
||||
<InfoCircle stroke="danger" fill="danger" />
|
||||
</S.AlertIcon>
|
||||
</S.Alert>
|
||||
)}
|
||||
<LoginForm
|
||||
isLoading={isLoading}
|
||||
onLogin={() => {
|
||||
// TODO
|
||||
}}
|
||||
onForgotPassword={() => {
|
||||
// TODO
|
||||
}}
|
||||
/>
|
||||
{!isLoading && (
|
||||
<>
|
||||
{onRegister && (
|
||||
<S.Register>
|
||||
New to Taskcafe? <S.RegisterLink to="/register">Create an account</S.RegisterLink>
|
||||
</S.Register>
|
||||
)}
|
||||
<S.Divider>
|
||||
<S.DividerText>or</S.DividerText>
|
||||
</S.Divider>
|
||||
</>
|
||||
)}
|
||||
</AuthPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
@ -1,72 +0,0 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
|
||||
export const Register = styled.div`
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: break-spaces;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
export const RegisterLink = styled(Link)`
|
||||
color: ${(props) => props.theme.colors.primary};
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
export const Divider = styled.div`
|
||||
margin: 21px 0;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export const Alert = styled.div`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
padding: 10px 14px;
|
||||
background-color: ${(props) => mixin.rgba(props.theme.colors.danger, 0.12)};
|
||||
`;
|
||||
|
||||
export const AlertIcon = styled.div`
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
`;
|
||||
|
||||
export const AlertContent = styled.div`
|
||||
font-size: 14px;
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
`;
|
||||
|
||||
export const DividerText = styled.div`
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding: 0 14px;
|
||||
&:before {
|
||||
right: 100%;
|
||||
border-top: 1px solid ${(props) => props.theme.colors.border.primary};
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 9999px;
|
||||
}
|
||||
&:after {
|
||||
left: 100%;
|
||||
border-top: 1px solid ${(props) => props.theme.colors.border.primary};
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 9999px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Welcome = styled.h2`
|
||||
font-size: 24px;
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
margin-bottom: 16px;
|
||||
`;
|
@ -1 +1,5 @@
|
||||
import '@testing-library/jest-dom';
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
|
112
frontend/src/shared/components/AddList/Styles.ts
Normal file
112
frontend/src/shared/components/AddList/Styles.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import styled, { css } from 'styled-components';
|
||||
import TextareaAutosize from 'react-autosize-textarea';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
import Button from 'shared/components/Button';
|
||||
|
||||
export const Container = styled.div`
|
||||
width: 272px;
|
||||
margin: 0 4px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const Wrapper = styled.div<{ editorOpen: boolean }>`
|
||||
display: inline-block;
|
||||
background-color: hsla(0, 0%, 100%, 0.24);
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
height: auto;
|
||||
min-height: 32px;
|
||||
padding: 4px;
|
||||
transition: background 85ms ease-in, opacity 40ms ease-in, border-color 85ms ease-in;
|
||||
width: 272px;
|
||||
margin: 0 4px;
|
||||
margin-right: 8px;
|
||||
|
||||
${props =>
|
||||
!props.editorOpen &&
|
||||
css`
|
||||
&:hover {
|
||||
background-color: hsla(0, 0%, 100%, 0.32);
|
||||
}
|
||||
`}
|
||||
|
||||
${props =>
|
||||
props.editorOpen &&
|
||||
css`
|
||||
background-color: #10163a;
|
||||
border-radius: 3px;
|
||||
height: auto;
|
||||
min-height: 32px;
|
||||
padding: 8px;
|
||||
transition: background 85ms ease-in, opacity 40ms ease-in, border-color 85ms ease-in;
|
||||
`}
|
||||
`;
|
||||
|
||||
export const AddListButton = styled(Button)`
|
||||
padding: 6px 12px;
|
||||
`;
|
||||
|
||||
export const Placeholder = styled.span`
|
||||
color: #c2c6dc;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
transition: color 85ms ease-in;
|
||||
`;
|
||||
|
||||
export const AddIconWrapper = styled.div`
|
||||
color: #fff;
|
||||
margin-right: 6px;
|
||||
`;
|
||||
|
||||
export const ListNameEditorWrapper = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
export const ListNameEditor = styled(TextareaAutosize)`
|
||||
background-color: ${props => mixin.lighten(props.theme.colors.bg.secondary, 0.05)};
|
||||
border: none;
|
||||
box-shadow: inset 0 0 0 2px #0079bf;
|
||||
transition: margin 85ms ease-in, background 85ms ease-in;
|
||||
line-height: 20px;
|
||||
padding: 8px 12px;
|
||||
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
resize: none;
|
||||
height: 54px;
|
||||
width: 100%;
|
||||
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
box-shadow: none;
|
||||
margin-bottom: 4px;
|
||||
max-height: 162px;
|
||||
min-height: 54px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
|
||||
color: #c2c6dc;
|
||||
l &:focus {
|
||||
background-color: ${props => mixin.lighten(props.theme.colors.bg.secondary, 0.05)};
|
||||
}
|
||||
`;
|
||||
|
||||
export const ListAddControls = styled.div`
|
||||
height: 32px;
|
||||
transition: margin 85ms ease-in, height 85ms ease-in;
|
||||
overflow: hidden;
|
||||
margin: 4px 0 0;
|
||||
`;
|
||||
|
||||
export const CancelAdd = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
cursor: pointer;
|
||||
`;
|
118
frontend/src/shared/components/AddList/index.tsx
Normal file
118
frontend/src/shared/components/AddList/index.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Plus, Cross } from 'shared/icons';
|
||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
||||
import Button from 'shared/components/Button';
|
||||
|
||||
import {
|
||||
Container,
|
||||
Wrapper,
|
||||
Placeholder,
|
||||
AddIconWrapper,
|
||||
AddListButton,
|
||||
ListNameEditor,
|
||||
ListAddControls,
|
||||
CancelAdd,
|
||||
ListNameEditorWrapper,
|
||||
} from './Styles';
|
||||
|
||||
type NameEditorProps = {
|
||||
buttonLabel?: string;
|
||||
onSave: (listName: string) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export const NameEditor: React.FC<NameEditorProps> = ({ onSave: handleSave, onCancel, buttonLabel = 'Save' }) => {
|
||||
const $editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [listName, setListName] = useState('');
|
||||
useEffect(() => {
|
||||
if ($editorRef && $editorRef.current) {
|
||||
$editorRef.current.focus();
|
||||
}
|
||||
});
|
||||
const onSave = (newName: string) => {
|
||||
if (newName.replace(/\s+/g, '') !== '') {
|
||||
handleSave(newName);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onSave(listName);
|
||||
setListName('');
|
||||
if ($editorRef && $editorRef.current) {
|
||||
$editorRef.current.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<ListNameEditorWrapper>
|
||||
<ListNameEditor
|
||||
ref={$editorRef}
|
||||
height={40}
|
||||
onKeyDown={onKeyDown}
|
||||
value={listName}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setListName(e.currentTarget.value)}
|
||||
placeholder="Enter a title for this list..."
|
||||
/>
|
||||
</ListNameEditorWrapper>
|
||||
<ListAddControls>
|
||||
<AddListButton
|
||||
variant="relief"
|
||||
onClick={() => {
|
||||
onSave(listName);
|
||||
setListName('');
|
||||
if ($editorRef && $editorRef.current) {
|
||||
$editorRef.current.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{buttonLabel}
|
||||
</AddListButton>
|
||||
<CancelAdd onClick={() => onCancel()}>
|
||||
<Cross width={16} height={16} />
|
||||
</CancelAdd>
|
||||
</ListAddControls>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type AddListProps = {
|
||||
onSave: (listName: string) => void;
|
||||
};
|
||||
|
||||
const AddList: React.FC<AddListProps> = ({ onSave }) => {
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const $wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const onOutsideClick = () => {
|
||||
setEditorOpen(false);
|
||||
};
|
||||
useOnOutsideClick($wrapperRef, editorOpen, onOutsideClick, null);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Wrapper
|
||||
ref={$wrapperRef}
|
||||
editorOpen={editorOpen}
|
||||
onClick={() => {
|
||||
if (!editorOpen) {
|
||||
setEditorOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{editorOpen ? (
|
||||
<NameEditor onCancel={() => setEditorOpen(false)} onSave={onSave} />
|
||||
) : (
|
||||
<Placeholder>
|
||||
<AddIconWrapper>
|
||||
<Plus width={12} height={12} />
|
||||
</AddIconWrapper>
|
||||
Add another list
|
||||
</Placeholder>
|
||||
)}
|
||||
</Wrapper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddList;
|
714
frontend/src/shared/components/Admin/index.tsx
Normal file
714
frontend/src/shared/components/Admin/index.tsx
Normal file
@ -0,0 +1,714 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import styled, { css } from 'styled-components';
|
||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
||||
import Select from 'shared/components/Select';
|
||||
import { User, UserPlus, Checkmark } from 'shared/icons';
|
||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||
import { RoleCode, useUpdateUserRoleMutation } from 'shared/generated/graphql';
|
||||
import Input from 'shared/components/Input';
|
||||
import Button from 'shared/components/Button';
|
||||
import NOOP from 'shared/utils/noop';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
|
||||
const UserSelect = styled(Select)`
|
||||
margin: 8px 0;
|
||||
padding: 8px 0;
|
||||
`;
|
||||
|
||||
const NewUserPassInput = styled(Input)`
|
||||
margin: 8px 0;
|
||||
`;
|
||||
|
||||
const InviteMemberButton = styled(Button)`
|
||||
padding: 7px 12px;
|
||||
`;
|
||||
|
||||
const UserPassBar = styled.div`
|
||||
display: flex;
|
||||
padding-top: 8px;
|
||||
`;
|
||||
|
||||
const UserPassConfirmButton = styled(Button)`
|
||||
width: 100%;
|
||||
padding: 7px 12px;
|
||||
`;
|
||||
|
||||
const UserPassButton = styled(Button)`
|
||||
width: 50%;
|
||||
padding: 7px 12px;
|
||||
& ~ & {
|
||||
margin-left: 6px;
|
||||
}
|
||||
`;
|
||||
|
||||
const MemberItemOptions = styled.div``;
|
||||
|
||||
const MemberItemOption = styled(Button)`
|
||||
padding: 7px 9px;
|
||||
margin: 4px 0 4px 8px;
|
||||
float: left;
|
||||
min-width: 95px;
|
||||
`;
|
||||
|
||||
const MemberList = styled.div`
|
||||
border-top: 1px solid ${(props) => props.theme.colors.border};
|
||||
`;
|
||||
|
||||
const MemberListItem = styled.div`
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid ${(props) => props.theme.colors.border};
|
||||
min-height: 40px;
|
||||
padding: 12px 0 12px 40px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const MemberListItemDetails = styled.div`
|
||||
float: left;
|
||||
flex: 1 0 auto;
|
||||
padding-left: 8px;
|
||||
`;
|
||||
|
||||
const InviteIcon = styled(UserPlus)`
|
||||
padding-right: 4px;
|
||||
`;
|
||||
|
||||
const MemberProfile = styled(TaskAssignee)`
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const MemberItemName = styled.p`
|
||||
color: ${(props) => props.theme.colors.text.secondary};
|
||||
`;
|
||||
|
||||
const MemberItemUsername = styled.p`
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
`;
|
||||
|
||||
const MemberListHeader = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
const ListTitle = styled.h3`
|
||||
font-size: 18px;
|
||||
color: ${(props) => props.theme.colors.text.secondary};
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
const ListDesc = styled.span`
|
||||
font-size: 16px;
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
`;
|
||||
const FilterSearch = styled(Input)`
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const ListActions = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 18px;
|
||||
`;
|
||||
|
||||
const MemberListWrapper = styled.div`
|
||||
flex: 1 1;
|
||||
`;
|
||||
|
||||
const Container = styled.div`
|
||||
padding: 2.2rem;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
const TabNav = styled.div`
|
||||
float: left;
|
||||
width: 220px;
|
||||
height: 100%;
|
||||
display: block;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const TabNavContent = styled.ul`
|
||||
display: block;
|
||||
width: auto;
|
||||
border-bottom: 0 !important;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.05);
|
||||
`;
|
||||
|
||||
const TabNavItem = styled.li`
|
||||
padding: 0.35rem 0.3rem;
|
||||
height: 48px;
|
||||
display: block;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const TabNavItemButton = styled.button<{ active: boolean }>`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
padding-top: 10px !important;
|
||||
padding-bottom: 10px !important;
|
||||
padding-left: 12px !important;
|
||||
padding-right: 8px !important;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
color: ${(props) => (props.active ? `${props.theme.colors.secondary}` : props.theme.colors.text.primary)};
|
||||
&:hover {
|
||||
color: ${(props) => `${props.theme.colors.primary}`};
|
||||
}
|
||||
&:hover svg {
|
||||
fill: ${(props) => props.theme.colors.primary};
|
||||
}
|
||||
`;
|
||||
const TabItemUser = styled(User)<{ active: boolean }>`
|
||||
fill: ${(props) => (props.active ? `${props.theme.colors.primary}` : props.theme.colors.text.primary)}
|
||||
stroke: ${(props) => (props.active ? `${props.theme.colors.primary}` : props.theme.colors.text.primary)}
|
||||
`;
|
||||
|
||||
const TabNavItemSpan = styled.span`
|
||||
text-align: left;
|
||||
padding-left: 9px;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const TabNavLine = styled.span<{ top: number }>`
|
||||
left: auto;
|
||||
right: 0;
|
||||
width: 2px;
|
||||
height: 48px;
|
||||
transform: scaleX(1);
|
||||
top: ${(props) => props.top}px;
|
||||
|
||||
background: linear-gradient(
|
||||
30deg,
|
||||
${(props) => props.theme.colors.primary},
|
||||
${(props) => props.theme.colors.primary}
|
||||
);
|
||||
box-shadow: 0 0 8px 0 ${(props) => props.theme.colors.primary};
|
||||
display: block;
|
||||
position: absolute;
|
||||
transition: all 0.2s ease;
|
||||
`;
|
||||
|
||||
const TabContentWrapper = styled.div`
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
margin-left: 1rem;
|
||||
`;
|
||||
|
||||
const TabContent = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: block;
|
||||
padding: 0;
|
||||
padding: 1.5rem;
|
||||
background-color: #10163a;
|
||||
border-radius: 0.5rem;
|
||||
`;
|
||||
|
||||
const items = [{ name: 'Members' }];
|
||||
|
||||
export const RoleCheckmark = styled(Checkmark)`
|
||||
padding-left: 4px;
|
||||
`;
|
||||
|
||||
const permissions = [
|
||||
{
|
||||
code: 'owner',
|
||||
name: 'Owner',
|
||||
description:
|
||||
'Can view, create and edit team projects, and change settings for the team. Will have admin rights on all projects in this team. Can delete the team and all team projects.',
|
||||
},
|
||||
{
|
||||
code: 'admin',
|
||||
name: 'Admin',
|
||||
description:
|
||||
'Can view, create and edit team projects, and change settings for the team. Will have admin rights on all projects in this team.',
|
||||
},
|
||||
|
||||
{ code: 'member', name: 'Member', description: 'Can view, create, and edit team projects, but not change settings.' },
|
||||
];
|
||||
|
||||
export const RoleName = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
export const RoleDescription = styled.div`
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
export const MiniProfileActions = styled.ul`
|
||||
list-style-type: none;
|
||||
`;
|
||||
|
||||
export const MiniProfileActionWrapper = styled.li``;
|
||||
|
||||
export const MiniProfileActionItem = styled.span<{ disabled?: boolean }>`
|
||||
color: #c2c6dc;
|
||||
display: block;
|
||||
font-weight: 400;
|
||||
padding: 6px 12px;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
|
||||
${(props) =>
|
||||
props.disabled
|
||||
? css`
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
color: ${mixin.rgba(props.theme.colors.text.primary, 0.4)};
|
||||
`
|
||||
: css`
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background: ${props.theme.colors.primary};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export const Content = styled.div`
|
||||
padding: 0 12px 12px;
|
||||
`;
|
||||
|
||||
export const CurrentPermission = styled.span`
|
||||
margin-left: 4px;
|
||||
color: ${(props) => mixin.rgba(props.theme.colors.text.secondary, 0.4)};
|
||||
`;
|
||||
|
||||
export const Separator = styled.div`
|
||||
height: 1px;
|
||||
border-top: 1px solid #414561;
|
||||
margin: 0.25rem !important;
|
||||
`;
|
||||
|
||||
export const WarningText = styled.span`
|
||||
display: flex;
|
||||
color: ${(props) => mixin.rgba(props.theme.colors.text.primary, 0.4)};
|
||||
padding: 6px;
|
||||
`;
|
||||
|
||||
export const DeleteDescription = styled.div`
|
||||
font-size: 14px;
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
`;
|
||||
|
||||
export const RemoveMemberButton = styled(Button)`
|
||||
margin-top: 16px;
|
||||
padding: 6px 12px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type TeamRoleManagerPopupProps = {
|
||||
user: User;
|
||||
users: Array<User>;
|
||||
warning?: string | null;
|
||||
canChangeRole?: boolean;
|
||||
onChangeRole?: (roleCode: RoleCode) => void;
|
||||
updateUserPassword?: (user: TaskUser, password: string) => void;
|
||||
onDeleteUser?: (userID: string, newOwnerID: string | null) => void;
|
||||
};
|
||||
|
||||
const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
|
||||
warning,
|
||||
user,
|
||||
users,
|
||||
canChangeRole,
|
||||
onDeleteUser,
|
||||
updateUserPassword,
|
||||
onChangeRole,
|
||||
}) => {
|
||||
const { hidePopup, setTab } = usePopup();
|
||||
const [userPass] = useState({ pass: '', passConfirm: '' });
|
||||
const [deleteUser, setDeleteUser] = useState<{ label: string; value: string } | null>(null);
|
||||
const hasOwned = user.owned.projects.length !== 0 || user.owned.teams.length !== 0;
|
||||
return (
|
||||
<>
|
||||
<Popup title={null} tab={0}>
|
||||
<MiniProfileActions>
|
||||
<MiniProfileActionWrapper>
|
||||
{user.role && (
|
||||
<MiniProfileActionItem
|
||||
onClick={() => {
|
||||
setTab(1);
|
||||
}}
|
||||
>
|
||||
Change permissions...
|
||||
<CurrentPermission>{`(${user.role.name})`}</CurrentPermission>
|
||||
</MiniProfileActionItem>
|
||||
)}
|
||||
<MiniProfileActionItem
|
||||
disabled
|
||||
onClick={() => {
|
||||
setTab(3);
|
||||
}}
|
||||
>
|
||||
Reset password...
|
||||
</MiniProfileActionItem>
|
||||
<MiniProfileActionItem onClick={() => setTab(2)}>Remove from organzation...</MiniProfileActionItem>
|
||||
</MiniProfileActionWrapper>
|
||||
</MiniProfileActions>
|
||||
{warning && (
|
||||
<>
|
||||
<Separator />
|
||||
<WarningText>{warning}</WarningText>
|
||||
</>
|
||||
)}
|
||||
</Popup>
|
||||
<Popup title="Change Permissions" onClose={() => hidePopup()} tab={1}>
|
||||
<MiniProfileActions>
|
||||
<MiniProfileActionWrapper>
|
||||
{permissions
|
||||
.filter((p) => (user.role && user.role.code === 'owner') || p.code !== 'owner')
|
||||
.map((perm) => (
|
||||
<MiniProfileActionItem
|
||||
disabled={user.role && perm.code !== user.role.code && !canChangeRole}
|
||||
key={perm.code}
|
||||
onClick={() => {
|
||||
if (onChangeRole && user.role && perm.code !== user.role.code) {
|
||||
switch (perm.code) {
|
||||
case 'owner':
|
||||
onChangeRole(RoleCode.Owner);
|
||||
break;
|
||||
case 'admin':
|
||||
onChangeRole(RoleCode.Admin);
|
||||
break;
|
||||
case 'member':
|
||||
onChangeRole(RoleCode.Member);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
hidePopup();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RoleName>
|
||||
{perm.name}
|
||||
{user.role && perm.code === user.role.code && <RoleCheckmark width={12} height={12} />}
|
||||
</RoleName>
|
||||
<RoleDescription>{perm.description}</RoleDescription>
|
||||
</MiniProfileActionItem>
|
||||
))}
|
||||
</MiniProfileActionWrapper>
|
||||
{user.role && user.role.code === 'owner' && (
|
||||
<>
|
||||
<Separator />
|
||||
<WarningText>You can not change roles because there must be an owner.</WarningText>
|
||||
</>
|
||||
)}
|
||||
</MiniProfileActions>
|
||||
</Popup>
|
||||
<Popup title="Remove from Organization?" onClose={() => hidePopup()} tab={2}>
|
||||
<Content>
|
||||
<DeleteDescription>
|
||||
Removing this user from the organzation will remove them from assigned tasks, projects, and teams.
|
||||
</DeleteDescription>
|
||||
{hasOwned && (
|
||||
<>
|
||||
<DeleteDescription>{`The user is the owner of ${user.owned.projects.length} projects & ${user.owned.teams.length} teams.`}</DeleteDescription>
|
||||
<DeleteDescription>
|
||||
Choose a new user to take over ownership of the users teams & projects.
|
||||
</DeleteDescription>
|
||||
<UserSelect
|
||||
onChange={(v) => setDeleteUser(v)}
|
||||
value={deleteUser}
|
||||
options={users.map((u) => ({ label: u.fullName, value: u.id }))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<UserPassConfirmButton
|
||||
disabled={!(!hasOwned || (hasOwned && deleteUser))}
|
||||
onClick={() => {
|
||||
if (onDeleteUser) {
|
||||
if (!hasOwned || (hasOwned && deleteUser)) {
|
||||
onDeleteUser(user.id, deleteUser ? deleteUser.value : null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
color="danger"
|
||||
>
|
||||
Delete user
|
||||
</UserPassConfirmButton>
|
||||
</Content>
|
||||
</Popup>
|
||||
<Popup title="Really remove from Team?" onClose={() => hidePopup()} tab={4}>
|
||||
<Content>
|
||||
<DeleteDescription>
|
||||
Removing this user from the organzation will remove them from assigned tasks, projects, and teams.
|
||||
</DeleteDescription>
|
||||
<DeleteDescription>{`The user is the owner of ${user.owned.projects.length} projects & ${user.owned.teams.length} teams.`}</DeleteDescription>
|
||||
<UserSelect onChange={NOOP} value={null} options={users.map((u) => ({ label: u.fullName, value: u.id }))} />
|
||||
<UserPassConfirmButton
|
||||
onClick={() => {
|
||||
// onDeleteUser();
|
||||
}}
|
||||
color="danger"
|
||||
>
|
||||
Delete user
|
||||
</UserPassConfirmButton>
|
||||
</Content>
|
||||
</Popup>
|
||||
<Popup title="Reset password?" onClose={() => hidePopup()} tab={3}>
|
||||
<Content>
|
||||
<DeleteDescription>
|
||||
You can either set the users new password directly or send the user an email allowing them to reset their
|
||||
own password.
|
||||
</DeleteDescription>
|
||||
<UserPassBar>
|
||||
<UserPassButton onClick={() => setTab(4)} color="warning">
|
||||
Set password...
|
||||
</UserPassButton>
|
||||
<UserPassButton color="warning" variant="outline">
|
||||
Send reset link
|
||||
</UserPassButton>
|
||||
</UserPassBar>
|
||||
</Content>
|
||||
</Popup>
|
||||
<Popup title="Reset password" onClose={() => hidePopup()} tab={4}>
|
||||
<Content>
|
||||
<NewUserPassInput defaultValue={userPass.pass} width="100%" variant="alternate" placeholder="New password" />
|
||||
<NewUserPassInput
|
||||
defaultValue={userPass.passConfirm}
|
||||
width="100%"
|
||||
variant="alternate"
|
||||
placeholder="New password (confirm)"
|
||||
/>
|
||||
<UserPassConfirmButton
|
||||
disabled={userPass.pass === '' || userPass.passConfirm === ''}
|
||||
onClick={() => {
|
||||
if (userPass.pass === userPass.passConfirm && updateUserPassword) {
|
||||
updateUserPassword(user, userPass.pass);
|
||||
}
|
||||
}}
|
||||
color="danger"
|
||||
>
|
||||
Set password
|
||||
</UserPassConfirmButton>
|
||||
</Content>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type NavItemProps = {
|
||||
active: boolean;
|
||||
name: string;
|
||||
tab: number;
|
||||
onClick: (tab: number, top: number) => void;
|
||||
};
|
||||
const NavItem: React.FC<NavItemProps> = ({ active, name, tab, onClick }) => {
|
||||
const $item = useRef<HTMLLIElement>(null);
|
||||
return (
|
||||
<TabNavItem
|
||||
key={name}
|
||||
ref={$item}
|
||||
onClick={() => {
|
||||
if ($item && $item.current) {
|
||||
const pos = $item.current.getBoundingClientRect();
|
||||
onClick(tab, pos.top);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TabNavItemButton active={active}>
|
||||
<TabItemUser width={14} height={14} active={active} />
|
||||
<TabNavItemSpan>{name}</TabNavItemSpan>
|
||||
</TabNavItemButton>
|
||||
</TabNavItem>
|
||||
);
|
||||
};
|
||||
|
||||
type AdminProps = {
|
||||
initialTab: number;
|
||||
onAddUser: ($target: React.RefObject<HTMLElement>) => void;
|
||||
onDeleteUser: (userID: string, newOwnerID: string | null) => void;
|
||||
onInviteUser: ($target: React.RefObject<HTMLElement>) => void;
|
||||
users: Array<User>;
|
||||
invitedUsers: Array<InvitedUserAccount>;
|
||||
canInviteUser: boolean;
|
||||
onUpdateUserPassword: (user: TaskUser, password: string) => void;
|
||||
onDeleteInvitedUser: (invitedUserID: string) => void;
|
||||
};
|
||||
|
||||
const Admin: React.FC<AdminProps> = ({
|
||||
initialTab,
|
||||
onAddUser,
|
||||
onUpdateUserPassword,
|
||||
canInviteUser,
|
||||
onDeleteUser,
|
||||
onDeleteInvitedUser,
|
||||
onInviteUser,
|
||||
invitedUsers,
|
||||
users,
|
||||
}) => {
|
||||
const warning =
|
||||
'You can’t leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
|
||||
const [currentTop, setTop] = useState(initialTab * 48);
|
||||
const [currentTab, setTab] = useState(initialTab);
|
||||
const { showPopup } = usePopup();
|
||||
const $tabNav = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [updateUserRole] = useUpdateUserRoleMutation();
|
||||
return (
|
||||
<Container>
|
||||
<TabNav ref={$tabNav}>
|
||||
<TabNavContent>
|
||||
{items.map((item, idx) => (
|
||||
<NavItem
|
||||
key={item.name}
|
||||
onClick={(tab, top) => {
|
||||
if ($tabNav && $tabNav.current) {
|
||||
const pos = $tabNav.current.getBoundingClientRect();
|
||||
setTab(tab);
|
||||
setTop(top - pos.top);
|
||||
}
|
||||
}}
|
||||
name={item.name}
|
||||
tab={idx}
|
||||
active={idx === currentTab}
|
||||
/>
|
||||
))}
|
||||
<TabNavLine top={currentTop} />
|
||||
</TabNavContent>
|
||||
</TabNav>
|
||||
<TabContentWrapper>
|
||||
<TabContent>
|
||||
<MemberListWrapper>
|
||||
<MemberListHeader>
|
||||
<ListTitle>{`Members (${users.length + invitedUsers.length})`}</ListTitle>
|
||||
<ListDesc>
|
||||
Organization admins can create / manage / delete all projects & teams. Members only have access to teams
|
||||
or projects they have been added to.
|
||||
</ListDesc>
|
||||
<ListActions>
|
||||
<FilterSearch width="250px" variant="alternate" placeholder="Filter by name" />
|
||||
{canInviteUser && (
|
||||
<InviteMemberButton
|
||||
onClick={($target) => {
|
||||
onAddUser($target);
|
||||
}}
|
||||
>
|
||||
<InviteIcon width={16} height={16} />
|
||||
New Member
|
||||
</InviteMemberButton>
|
||||
)}
|
||||
</ListActions>
|
||||
</MemberListHeader>
|
||||
<MemberList>
|
||||
{users.map((member) => {
|
||||
const projectTotal = member.owned.projects.length + member.member.projects.length;
|
||||
return (
|
||||
<MemberListItem>
|
||||
<MemberProfile showRoleIcons size={32} onMemberProfile={NOOP} member={member} />
|
||||
<MemberListItemDetails>
|
||||
<MemberItemName>{member.fullName}</MemberItemName>
|
||||
<MemberItemUsername>{`@${member.username}`}</MemberItemUsername>
|
||||
</MemberListItemDetails>
|
||||
<MemberItemOptions>
|
||||
<MemberItemOption variant="flat">{`On ${projectTotal} projects`}</MemberItemOption>
|
||||
<MemberItemOption
|
||||
variant="outline"
|
||||
onClick={($target) => {
|
||||
showPopup(
|
||||
$target,
|
||||
<TeamRoleManagerPopup
|
||||
user={member}
|
||||
users={users}
|
||||
warning={member.role && member.role.code === 'owner' ? warning : null}
|
||||
updateUserPassword={(user, password) => {
|
||||
onUpdateUserPassword(user, password);
|
||||
}}
|
||||
canChangeRole={(member.role && member.role.code !== 'owner') ?? false}
|
||||
onChangeRole={(roleCode) => {
|
||||
updateUserRole({ variables: { userID: member.id, roleCode } });
|
||||
}}
|
||||
onDeleteUser={onDeleteUser}
|
||||
/>,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Manage
|
||||
</MemberItemOption>
|
||||
</MemberItemOptions>
|
||||
</MemberListItem>
|
||||
);
|
||||
})}
|
||||
{invitedUsers.map((member) => {
|
||||
return (
|
||||
<MemberListItem>
|
||||
<MemberProfile
|
||||
showRoleIcons
|
||||
size={32}
|
||||
onMemberProfile={NOOP}
|
||||
member={{
|
||||
id: member.id,
|
||||
fullName: member.email,
|
||||
profileIcon: {
|
||||
bgColor: '#fff',
|
||||
url: null,
|
||||
initials: member.email.charAt(0),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<MemberListItemDetails>
|
||||
<MemberItemName>{member.email}</MemberItemName>
|
||||
<MemberItemUsername>Invited</MemberItemUsername>
|
||||
</MemberListItemDetails>
|
||||
<MemberItemOptions>
|
||||
<MemberItemOption
|
||||
variant="outline"
|
||||
onClick={($target) => {
|
||||
showPopup(
|
||||
$target,
|
||||
<TeamRoleManagerPopup
|
||||
user={{
|
||||
id: member.id,
|
||||
fullName: member.email,
|
||||
profileIcon: {
|
||||
bgColor: '#fff',
|
||||
url: null,
|
||||
initials: member.email.charAt(0),
|
||||
},
|
||||
member: {
|
||||
teams: [],
|
||||
projects: [],
|
||||
},
|
||||
owned: {
|
||||
teams: [],
|
||||
projects: [],
|
||||
},
|
||||
}}
|
||||
users={users}
|
||||
onDeleteUser={() => {
|
||||
onDeleteInvitedUser(member.id);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Manage
|
||||
</MemberItemOption>
|
||||
</MemberItemOptions>
|
||||
</MemberListItem>
|
||||
);
|
||||
})}
|
||||
</MemberList>
|
||||
</MemberListWrapper>
|
||||
</TabContent>
|
||||
</TabContentWrapper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Admin;
|
250
frontend/src/shared/components/Button/index.tsx
Normal file
250
frontend/src/shared/components/Button/index.tsx
Normal file
@ -0,0 +1,250 @@
|
||||
import React, { useRef } from 'react';
|
||||
import styled, { css } from 'styled-components/macro';
|
||||
import { mixin } from '../../utils/styles';
|
||||
|
||||
const Text = styled.span<{ fontSize: string; justifyTextContent: string; hasIcon?: boolean }>`
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: ${(props) => props.justifyTextContent};
|
||||
transition: all 0.2s ease;
|
||||
font-size: ${(props) => props.fontSize};
|
||||
color: ${(props) => props.theme.colors.text.secondary};
|
||||
${(props) =>
|
||||
props.hasIcon &&
|
||||
css`
|
||||
padding-left: 4px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Base = styled.button<{ color: string; disabled: boolean }>`
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: ${(props) => props.theme.borderRadius.alternate};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
${(props) =>
|
||||
props.disabled &&
|
||||
css`
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Filled = styled(Base)<{ hoverVariant: HoverVariant }>`
|
||||
background: ${(props) => props.theme.colors[props.color]};
|
||||
${(props) =>
|
||||
props.hoverVariant === 'boxShadow' &&
|
||||
css`
|
||||
&:hover {
|
||||
box-shadow: 0 8px 25px -8px ${props.theme.colors[props.color]};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const Outline = styled(Base)<{ invert: boolean }>`
|
||||
border: 1px solid ${(props) => props.theme.colors[props.color]};
|
||||
background: transparent;
|
||||
${(props) =>
|
||||
props.invert
|
||||
? css`
|
||||
background: ${props.theme.colors[props.color]});
|
||||
& ${Text} {
|
||||
color: ${props.theme.colors.text.secondary});
|
||||
}
|
||||
&:hover {
|
||||
background: ${mixin.rgba(props.theme.colors[props.color], 0.8)};
|
||||
}
|
||||
`
|
||||
: css`
|
||||
& ${Text} {
|
||||
color: ${props.theme.colors[props.color]});
|
||||
}
|
||||
&:hover {
|
||||
background: ${mixin.rgba(props.theme.colors[props.color], 0.08)};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const Flat = styled(Base)`
|
||||
background: transparent;
|
||||
&:hover {
|
||||
background: ${(props) => mixin.rgba(props.theme.colors[props.color], 0.2)};
|
||||
}
|
||||
`;
|
||||
|
||||
const LineX = styled.span<{ color: string }>`
|
||||
transition: all 0.2s ease;
|
||||
position: absolute;
|
||||
height: 2px;
|
||||
width: 0;
|
||||
top: auto;
|
||||
bottom: -2px;
|
||||
left: 50%;
|
||||
transform: translate(-50%);
|
||||
background: ${(props) => mixin.rgba(props.theme.colors[props.color], 1)};
|
||||
`;
|
||||
|
||||
const LineDown = styled(Base)`
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
border-bottom-width: 2px;
|
||||
border-color: ${(props) => mixin.rgba(props.theme.colors[props.color], 0.2)};
|
||||
|
||||
&:hover ${LineX} {
|
||||
width: 100%;
|
||||
}
|
||||
&:hover ${Text} {
|
||||
transform: translateY(2px);
|
||||
}
|
||||
`;
|
||||
|
||||
const Gradient = styled(Base)`
|
||||
background: linear-gradient(
|
||||
30deg,
|
||||
${(props) => mixin.rgba(props.theme.colors[props.color], 1)},
|
||||
${(props) => mixin.rgba(props.theme.colors[props.color], 0.5)}
|
||||
);
|
||||
text-shadow: 1px 2px 4px rgba(0, 0, 0, 0.3);
|
||||
`;
|
||||
|
||||
const Relief = styled(Base)`
|
||||
background: ${(props) => mixin.rgba(props.theme.colors[props.color], 1)};
|
||||
-webkit-box-shadow: 0 -3px 0 0 rgba(0, 0, 0, 0.2) inset;
|
||||
box-shadow: inset 0 -3px 0 0 rgba(0, 0, 0, 0.2);
|
||||
|
||||
&:active {
|
||||
transform: translateY(3px);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
type HoverVariant = 'boxShadow' | 'none';
|
||||
type ButtonProps = {
|
||||
fontSize?: string;
|
||||
variant?: 'filled' | 'outline' | 'flat' | 'lineDown' | 'gradient' | 'relief';
|
||||
hoverVariant?: HoverVariant;
|
||||
color?: 'primary' | 'danger' | 'success' | 'warning' | 'dark';
|
||||
disabled?: boolean;
|
||||
type?: 'button' | 'submit';
|
||||
icon?: JSX.Element;
|
||||
invert?: boolean;
|
||||
className?: string;
|
||||
onClick?: ($target: React.RefObject<HTMLButtonElement>) => void;
|
||||
justifyTextContent?: string;
|
||||
};
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
disabled = false,
|
||||
fontSize = '14px',
|
||||
invert = false,
|
||||
color = 'primary',
|
||||
variant = 'filled',
|
||||
hoverVariant = 'boxShadow',
|
||||
type = 'button',
|
||||
justifyTextContent = 'center',
|
||||
icon,
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
}) => {
|
||||
const $button = useRef<HTMLButtonElement>(null);
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick($button);
|
||||
}
|
||||
};
|
||||
switch (variant) {
|
||||
case 'filled':
|
||||
return (
|
||||
<Filled
|
||||
ref={$button}
|
||||
hoverVariant={hoverVariant}
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
color={color}
|
||||
>
|
||||
{icon && icon}
|
||||
<Text hasIcon={typeof icon !== 'undefined'} justifyTextContent={justifyTextContent} fontSize={fontSize}>
|
||||
{children}
|
||||
</Text>
|
||||
</Filled>
|
||||
);
|
||||
case 'outline':
|
||||
return (
|
||||
<Outline
|
||||
ref={$button}
|
||||
invert={invert}
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
color={color}
|
||||
>
|
||||
<Text justifyTextContent={justifyTextContent} fontSize={fontSize}>
|
||||
{children}
|
||||
</Text>
|
||||
</Outline>
|
||||
);
|
||||
case 'flat':
|
||||
return (
|
||||
<Flat ref={$button} type={type} onClick={handleClick} className={className} disabled={disabled} color={color}>
|
||||
<Text justifyTextContent={justifyTextContent} fontSize={fontSize}>
|
||||
{children}
|
||||
</Text>
|
||||
</Flat>
|
||||
);
|
||||
case 'lineDown':
|
||||
return (
|
||||
<LineDown
|
||||
ref={$button}
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
color={color}
|
||||
>
|
||||
<Text justifyTextContent={justifyTextContent} fontSize={fontSize}>
|
||||
{children}
|
||||
</Text>
|
||||
<LineX color={color} />
|
||||
</LineDown>
|
||||
);
|
||||
case 'gradient':
|
||||
return (
|
||||
<Gradient
|
||||
ref={$button}
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
color={color}
|
||||
>
|
||||
<Text justifyTextContent={justifyTextContent} fontSize={fontSize}>
|
||||
{children}
|
||||
</Text>
|
||||
</Gradient>
|
||||
);
|
||||
case 'relief':
|
||||
return (
|
||||
<Relief ref={$button} type={type} onClick={handleClick} className={className} disabled={disabled} color={color}>
|
||||
<Text justifyTextContent={justifyTextContent} fontSize={fontSize}>
|
||||
{children}
|
||||
</Text>
|
||||
</Relief>
|
||||
);
|
||||
default:
|
||||
throw new Error('not a valid variant');
|
||||
}
|
||||
};
|
||||
|
||||
export default Button;
|
287
frontend/src/shared/components/Card/Styles.ts
Normal file
287
frontend/src/shared/components/Card/Styles.ts
Normal file
@ -0,0 +1,287 @@
|
||||
import styled, { css, keyframes } from 'styled-components';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
import TextareaAutosize from 'react-autosize-textarea';
|
||||
import { CheckCircle, CheckSquareOutline, Clock, Bubble } from 'shared/icons';
|
||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
||||
|
||||
export const CardMember = styled(TaskAssignee)<{ zIndex: number }>`
|
||||
box-shadow: 0 0 0 2px ${(props) => props.theme.colors.bg.secondary},
|
||||
inset 0 0 0 1px ${(props) => mixin.rgba(props.theme.colors.bg.secondary, 0.07)};
|
||||
z-index: ${(props) => props.zIndex};
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export const CommentsIcon = styled(Bubble)<{ color: 'success' | 'normal' }>`
|
||||
${(props) =>
|
||||
props.color === 'success' &&
|
||||
css`
|
||||
fill: ${props.theme.colors.success};
|
||||
stroke: ${props.theme.colors.success};
|
||||
`}
|
||||
`;
|
||||
|
||||
export const ChecklistIcon = styled(CheckSquareOutline)<{ color: 'success' | 'normal' }>`
|
||||
${(props) =>
|
||||
props.color === 'success' &&
|
||||
css`
|
||||
fill: ${props.theme.colors.success};
|
||||
stroke: ${props.theme.colors.success};
|
||||
`}
|
||||
`;
|
||||
|
||||
export const ClockIcon = styled(Clock)<{ color: string }>`
|
||||
fill: ${(props) => props.color};
|
||||
`;
|
||||
|
||||
export const EditorTextarea = styled(TextareaAutosize)`
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
resize: none;
|
||||
height: 54px;
|
||||
width: 100%;
|
||||
|
||||
background: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
margin-bottom: 4px;
|
||||
max-height: 162px;
|
||||
min-height: 54px;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
&:focus {
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ListCardBadges = styled.div`
|
||||
float: left;
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
margin-left: -2px;
|
||||
`;
|
||||
|
||||
export const CommentsBadge = styled.div`
|
||||
color: #5e6c84;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 6px 4px 0;
|
||||
font-size: 12px;
|
||||
max-width: 100%;
|
||||
min-height: 20px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
padding: 2px;
|
||||
text-decoration: none;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: top;
|
||||
`;
|
||||
|
||||
export const ListCardBadge = styled.div`
|
||||
color: #5e6c84;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 6px 4px 0;
|
||||
font-size: 12px;
|
||||
max-width: 100%;
|
||||
min-height: 20px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
padding: 2px;
|
||||
text-decoration: none;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: top;
|
||||
`;
|
||||
|
||||
export const DescriptionBadge = styled(ListCardBadge)`
|
||||
padding-right: 6px;
|
||||
`;
|
||||
|
||||
export const DueDateCardBadge = styled(ListCardBadge)<{ isPastDue: boolean }>`
|
||||
font-size: 12px;
|
||||
${(props) =>
|
||||
props.isPastDue &&
|
||||
css`
|
||||
padding-left: 4px;
|
||||
background-color: #ec9488;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
`}
|
||||
`;
|
||||
|
||||
export const ListCardBadgeText = styled.span<{ color?: 'success' | 'normal' }>`
|
||||
font-size: 12px;
|
||||
padding: 0 4px 0 6px;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
${(props) => props.color === 'success' && `color: ${props.theme.colors.success};`}
|
||||
`;
|
||||
|
||||
export const ListCardContainer = styled.div<{ isActive: boolean; editable: boolean }>`
|
||||
max-width: 256px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 3px;
|
||||
${mixin.boxShadowCard}
|
||||
cursor: pointer !important;
|
||||
position: relative;
|
||||
|
||||
background-color: ${(props) =>
|
||||
props.isActive && !props.editable
|
||||
? mixin.darken(props.theme.colors.bg.secondary, 0.1)
|
||||
: `${props.theme.colors.bg.secondary}`};
|
||||
`;
|
||||
|
||||
export const ListCardInnerContainer = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
export const ListCardDetails = styled.div<{ complete: boolean }>`
|
||||
overflow: hidden;
|
||||
padding: 6px 8px 2px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
${(props) => props.complete && 'opacity: 0.6;'}
|
||||
`;
|
||||
|
||||
const labelVariantExpandAnimation = keyframes`
|
||||
0% {min-width: 40px; width: 40px; height: 8px;}
|
||||
50% {min-width: 56px; width: auto; height: 8px;}
|
||||
100% {min-width: 56px; width: auto; height: 16px;}
|
||||
`;
|
||||
|
||||
const labelTextVariantExpandAnimation = keyframes`
|
||||
0% {transform: scale(0); visibility: hidden; pointer-events: none;}
|
||||
75% {transform: scale(0); visibility: hidden; pointer-events: none;}
|
||||
100% {transform: scale(1); visibility: visible; pointer-events: all;}
|
||||
`;
|
||||
|
||||
const labelVariantShrinkAnimation = keyframes`
|
||||
0% {min-width: 56px; width: auto; height: 16px;}
|
||||
50% {min-width: 56px; width: auto; height: 8px;}
|
||||
100% {min-width: 40px; width: 40px; height: 8px;}
|
||||
`;
|
||||
|
||||
const labelTextVariantShrinkAnimation = keyframes`
|
||||
0% {transform: scale(1); visibility: visible; pointer-events: all;}
|
||||
75% {transform: scale(0); visibility: hidden; pointer-events: none;}
|
||||
100% {transform: scale(0); visibility: hidden; pointer-events: none;}
|
||||
`;
|
||||
export const ListCardLabelText = styled.span`
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
`;
|
||||
|
||||
export const ListCardLabelsWrapper = styled.div`
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export const ListCardLabel = styled.span<{ variant: 'small' | 'large' }>`
|
||||
${(props) =>
|
||||
props.variant === 'small'
|
||||
? css`
|
||||
height: 8px;
|
||||
min-width: 40px;
|
||||
width: 40px;
|
||||
& ${ListCardLabelText} {
|
||||
transform: scale(0);
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
`
|
||||
: css`
|
||||
height: 16px;
|
||||
min-width: 56px;
|
||||
width: auto;
|
||||
`}
|
||||
|
||||
padding: 0 8px;
|
||||
max-width: 198px;
|
||||
float: left;
|
||||
margin: 0 4px 4px 0;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
position: relative;
|
||||
background-color: ${(props) => props.color};
|
||||
`;
|
||||
|
||||
export const ListCardLabels = styled.div<{ toggleLabels: boolean; toggleDirection: 'expand' | 'shrink' }>`
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
${(props) =>
|
||||
props.toggleLabels &&
|
||||
props.toggleDirection === 'expand' &&
|
||||
css`
|
||||
& ${ListCardLabel} {
|
||||
animation: ${labelVariantExpandAnimation} 0.45s ease-out;
|
||||
}
|
||||
& ${ListCardLabelText} {
|
||||
animation: ${labelTextVariantExpandAnimation} 0.45s ease-out;
|
||||
}
|
||||
`}
|
||||
${(props) =>
|
||||
props.toggleLabels &&
|
||||
props.toggleDirection === 'shrink' &&
|
||||
css`
|
||||
& ${ListCardLabel} {
|
||||
animation: ${labelVariantShrinkAnimation} 0.45s ease-out;
|
||||
}
|
||||
& ${ListCardLabelText} {
|
||||
animation: ${labelTextVariantShrinkAnimation} 0.45s ease-out;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
export const ListCardOperation = styled.span`
|
||||
display: flex;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
opacity: 0.8;
|
||||
padding: 6px;
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
top: 2px;
|
||||
z-index: 100;
|
||||
&:hover {
|
||||
background-color: ${(props) => mixin.darken(props.theme.colors.bg.secondary, 0.25)};
|
||||
}
|
||||
`;
|
||||
|
||||
export const CardTitle = styled.div`
|
||||
clear: both;
|
||||
margin: 0 0 4px;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
display: block;
|
||||
align-items: center;
|
||||
`;
|
||||
export const CardTitleText = styled.span`
|
||||
word-wrap: break-word;
|
||||
line-height: 18px;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
export const CardMembers = styled.div`
|
||||
float: right;
|
||||
margin: 0 -2px 4px 0;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
export const CompleteIcon = styled(CheckCircle)`
|
||||
fill: ${(props) => props.theme.colors.success};
|
||||
margin-right: 4px;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: -2px;
|
||||
`;
|
||||
|
||||
export const EditorContent = styled.div`
|
||||
display: flex;
|
||||
`;
|
286
frontend/src/shared/components/Card/index.tsx
Normal file
286
frontend/src/shared/components/Card/index.tsx
Normal file
@ -0,0 +1,286 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Pencil, Eye, List } from 'shared/icons';
|
||||
import {
|
||||
EditorTextarea,
|
||||
CardMember,
|
||||
EditorContent,
|
||||
ChecklistIcon,
|
||||
CompleteIcon,
|
||||
DescriptionBadge,
|
||||
DueDateCardBadge,
|
||||
ListCardBadges,
|
||||
ListCardBadge,
|
||||
ListCardBadgeText,
|
||||
ListCardContainer,
|
||||
ListCardInnerContainer,
|
||||
ListCardDetails,
|
||||
ClockIcon,
|
||||
ListCardLabels,
|
||||
ListCardLabel,
|
||||
ListCardLabelText,
|
||||
ListCardLabelsWrapper,
|
||||
ListCardOperation,
|
||||
CardTitle,
|
||||
CardMembers,
|
||||
CardTitleText,
|
||||
CommentsIcon,
|
||||
CommentsBadge,
|
||||
} from './Styles';
|
||||
|
||||
type DueDate = {
|
||||
isPastDue: boolean;
|
||||
formattedDate: string;
|
||||
};
|
||||
|
||||
type Checklist = {
|
||||
complete: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
taskID: string;
|
||||
taskGroupID: string;
|
||||
complete?: boolean;
|
||||
position?: string | number;
|
||||
onContextMenu?: ($target: React.RefObject<HTMLElement>, taskID: string, taskGroupID: string) => void;
|
||||
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
description?: null | string;
|
||||
dueDate?: DueDate;
|
||||
checklists?: Checklist | null;
|
||||
labels?: Array<ProjectLabel>;
|
||||
comments?: { unread: boolean; total: number } | null;
|
||||
watched?: boolean;
|
||||
wrapperProps?: any;
|
||||
members?: Array<TaskUser> | null;
|
||||
onCardLabelClick?: () => void;
|
||||
onCardMemberClick?: OnCardMemberClick;
|
||||
editable?: boolean;
|
||||
setToggleLabels?: (toggle: false) => void;
|
||||
onEditCard?: (taskGroupID: string, taskID: string, cardName: string) => void;
|
||||
onCardTitleChange?: (name: string) => void;
|
||||
labelVariant?: CardLabelVariant;
|
||||
toggleLabels?: boolean;
|
||||
isPublic?: boolean;
|
||||
toggleDirection?: 'shrink' | 'expand';
|
||||
};
|
||||
|
||||
const Card = React.forwardRef(
|
||||
(
|
||||
{
|
||||
isPublic = false,
|
||||
wrapperProps,
|
||||
onContextMenu,
|
||||
taskID,
|
||||
taskGroupID,
|
||||
complete,
|
||||
toggleLabels = false,
|
||||
comments,
|
||||
toggleDirection = 'shrink',
|
||||
setToggleLabels,
|
||||
onClick,
|
||||
labels,
|
||||
title,
|
||||
dueDate,
|
||||
description,
|
||||
checklists,
|
||||
position,
|
||||
watched,
|
||||
members,
|
||||
labelVariant,
|
||||
onCardMemberClick,
|
||||
editable,
|
||||
onCardLabelClick,
|
||||
onEditCard,
|
||||
onCardTitleChange,
|
||||
}: Props,
|
||||
$cardRef: any,
|
||||
) => {
|
||||
const [currentCardTitle, setCardTitle] = useState(title);
|
||||
const $editorRef: any = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
setCardTitle(title);
|
||||
}, [title]);
|
||||
|
||||
useEffect(() => {
|
||||
if ($editorRef && $editorRef.current) {
|
||||
$editorRef.current.focus();
|
||||
$editorRef.current.select();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: any) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (onEditCard) {
|
||||
onEditCard(taskGroupID, taskID, currentCardTitle);
|
||||
}
|
||||
}
|
||||
};
|
||||
const [isActive, setActive] = useState(false);
|
||||
const $innerCardRef: any = useRef(null);
|
||||
const onOpenComposer = () => {
|
||||
if (onContextMenu) {
|
||||
onContextMenu($innerCardRef, taskID, taskGroupID);
|
||||
}
|
||||
};
|
||||
const onTaskContext = (e: React.MouseEvent) => {
|
||||
if (!isPublic) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onOpenComposer();
|
||||
}
|
||||
};
|
||||
const onOperationClick = (e: React.MouseEvent<HTMLOrSVGElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onOpenComposer();
|
||||
};
|
||||
return (
|
||||
<ListCardContainer
|
||||
onMouseEnter={() => setActive(true)}
|
||||
onMouseLeave={() => setActive(false)}
|
||||
ref={$cardRef}
|
||||
onClick={(e) => {
|
||||
if (onClick) {
|
||||
onClick(e);
|
||||
}
|
||||
}}
|
||||
onContextMenu={onTaskContext}
|
||||
isActive={isActive}
|
||||
editable={editable}
|
||||
{...wrapperProps}
|
||||
>
|
||||
<ListCardInnerContainer ref={$innerCardRef}>
|
||||
{!isPublic && isActive && !editable && (
|
||||
<ListCardOperation
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onContextMenu) {
|
||||
onContextMenu($innerCardRef, taskID, taskGroupID);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Pencil width={8} height={8} />
|
||||
</ListCardOperation>
|
||||
)}
|
||||
<ListCardDetails complete={complete ?? false}>
|
||||
{labels && labels.length !== 0 && (
|
||||
<ListCardLabelsWrapper>
|
||||
<ListCardLabels
|
||||
toggleLabels={toggleLabels}
|
||||
toggleDirection={toggleDirection}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onCardLabelClick) {
|
||||
onCardLabelClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{labels
|
||||
.slice()
|
||||
.sort((a, b) => a.labelColor.position - b.labelColor.position)
|
||||
.map((label) => (
|
||||
<ListCardLabel
|
||||
onAnimationEnd={() => {
|
||||
if (setToggleLabels) {
|
||||
setToggleLabels(false);
|
||||
}
|
||||
}}
|
||||
variant={labelVariant ?? 'large'}
|
||||
color={label.labelColor.colorHex}
|
||||
key={label.id}
|
||||
>
|
||||
<ListCardLabelText>{label.name}</ListCardLabelText>
|
||||
</ListCardLabel>
|
||||
))}
|
||||
</ListCardLabels>
|
||||
</ListCardLabelsWrapper>
|
||||
)}
|
||||
{editable ? (
|
||||
<EditorContent>
|
||||
{complete && <CompleteIcon width={16} height={16} />}
|
||||
<EditorTextarea
|
||||
onChange={(e) => {
|
||||
setCardTitle(e.currentTarget.value);
|
||||
if (onCardTitleChange) {
|
||||
onCardTitleChange(e.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
value={currentCardTitle}
|
||||
ref={$editorRef}
|
||||
/>
|
||||
</EditorContent>
|
||||
) : (
|
||||
<CardTitle>
|
||||
{complete && <CompleteIcon width={16} height={16} />}
|
||||
<CardTitleText>{`${title}${position ? ` - ${position}` : ''}`}</CardTitleText>
|
||||
</CardTitle>
|
||||
)}
|
||||
<ListCardBadges>
|
||||
{watched && (
|
||||
<ListCardBadge>
|
||||
<Eye width={12} height={12} />
|
||||
</ListCardBadge>
|
||||
)}
|
||||
{dueDate && (
|
||||
<DueDateCardBadge isPastDue={dueDate.isPastDue}>
|
||||
<ClockIcon color={dueDate.isPastDue ? '#fff' : '#6b778c'} width={8} height={8} />
|
||||
<ListCardBadgeText>{dueDate.formattedDate}</ListCardBadgeText>
|
||||
</DueDateCardBadge>
|
||||
)}
|
||||
{description && (
|
||||
<DescriptionBadge>
|
||||
<List width={8} height={8} />
|
||||
</DescriptionBadge>
|
||||
)}
|
||||
{comments && (
|
||||
<CommentsBadge>
|
||||
<CommentsIcon color={comments.unread ? 'success' : 'normal'} width={8} height={8} />
|
||||
<ListCardBadgeText color={comments.unread ? 'success' : 'normal'}>{comments.total}</ListCardBadgeText>
|
||||
</CommentsBadge>
|
||||
)}
|
||||
{checklists && (
|
||||
<ListCardBadge>
|
||||
<ChecklistIcon
|
||||
color={checklists.complete === checklists.total ? 'success' : 'normal'}
|
||||
width={8}
|
||||
height={8}
|
||||
/>
|
||||
<ListCardBadgeText color={checklists.complete === checklists.total ? 'success' : 'normal'}>
|
||||
{`${checklists.complete}/${checklists.total}`}
|
||||
</ListCardBadgeText>
|
||||
</ListCardBadge>
|
||||
)}
|
||||
</ListCardBadges>
|
||||
<CardMembers>
|
||||
{members &&
|
||||
members.map((member, idx) => (
|
||||
<CardMember
|
||||
key={member.id}
|
||||
size={28}
|
||||
zIndex={members.length - idx}
|
||||
member={member}
|
||||
onMemberProfile={($target) => {
|
||||
if (onCardMemberClick) {
|
||||
onCardMemberClick($target, taskID, member.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</CardMembers>
|
||||
</ListCardDetails>
|
||||
</ListCardInnerContainer>
|
||||
</ListCardContainer>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Card.displayName = 'Card';
|
||||
|
||||
export default Card;
|
34
frontend/src/shared/components/CardComposer/Styles.ts
Normal file
34
frontend/src/shared/components/CardComposer/Styles.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import styled from 'styled-components';
|
||||
import Button from 'shared/components/Button';
|
||||
import TextareaAutosize from 'react-autosize-textarea';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
|
||||
export const CancelIconWrapper = styled.div`
|
||||
opacity: 0.8;
|
||||
cursor: pointer;
|
||||
font-size: 1.25em;
|
||||
padding-left: 5px;
|
||||
`;
|
||||
|
||||
export const CardComposerWrapper = styled.div<{ isOpen: boolean }>`
|
||||
padding-bottom: 8px;
|
||||
display: ${props => (props.isOpen ? 'flex' : 'none')};
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
export const ComposerControls = styled.div``;
|
||||
|
||||
export const ComposerControlsSaveSection = styled.div`
|
||||
display: flex;
|
||||
float: left;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
export const ComposerControlsActionsSection = styled.div`
|
||||
float: right;
|
||||
`;
|
||||
export const AddCardButton = styled(Button)`
|
||||
margin-right: 4px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 3px;
|
||||
`;
|
82
frontend/src/shared/components/CardComposer/index.tsx
Normal file
82
frontend/src/shared/components/CardComposer/index.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown';
|
||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
||||
import { Cross } from 'shared/icons';
|
||||
|
||||
import {
|
||||
CardComposerWrapper,
|
||||
CancelIconWrapper,
|
||||
AddCardButton,
|
||||
ComposerControls,
|
||||
ComposerControlsSaveSection,
|
||||
ComposerControlsActionsSection,
|
||||
} from './Styles';
|
||||
import Card from '../Card';
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onCreateCard: (cardName: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const CardComposer = ({ isOpen, onCreateCard, onClose }: Props) => {
|
||||
const [cardName, setCardName] = useState('');
|
||||
const $cardRef = useRef<HTMLDivElement>(null);
|
||||
useOnOutsideClick($cardRef, true, onClose, null);
|
||||
useOnEscapeKeyDown(isOpen, onClose);
|
||||
useEffect(() => {
|
||||
if ($cardRef.current) {
|
||||
$cardRef.current.scrollIntoView();
|
||||
}
|
||||
});
|
||||
return (
|
||||
<CardComposerWrapper isOpen={isOpen} ref={$cardRef}>
|
||||
<Card
|
||||
title={cardName}
|
||||
taskID=""
|
||||
taskGroupID=""
|
||||
editable
|
||||
onEditCard={(_taskGroupID, _taskID, name) => {
|
||||
if (cardName.trim() !== '') {
|
||||
onCreateCard(name.trim());
|
||||
setCardName('');
|
||||
}
|
||||
}}
|
||||
onCardTitleChange={name => {
|
||||
setCardName(name);
|
||||
}}
|
||||
/>
|
||||
<ComposerControls>
|
||||
<ComposerControlsSaveSection>
|
||||
<AddCardButton
|
||||
variant="relief"
|
||||
onClick={() => {
|
||||
if (cardName.trim() !== '') {
|
||||
onCreateCard(cardName.trim());
|
||||
setCardName('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add Card
|
||||
</AddCardButton>
|
||||
<CancelIconWrapper onClick={() => onClose()}>
|
||||
<Cross width={12} height={12} />
|
||||
</CancelIconWrapper>
|
||||
</ComposerControlsSaveSection>
|
||||
<ComposerControlsActionsSection />
|
||||
</ComposerControls>
|
||||
</CardComposerWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
CardComposer.propTypes = {
|
||||
isOpen: PropTypes.bool,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onCreateCard: PropTypes.func.isRequired,
|
||||
};
|
||||
CardComposer.defaultProps = {
|
||||
isOpen: true,
|
||||
};
|
||||
|
||||
export default CardComposer;
|
635
frontend/src/shared/components/Checklist/index.tsx
Normal file
635
frontend/src/shared/components/Checklist/index.tsx
Normal file
@ -0,0 +1,635 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { CheckSquare, Trash, Square, CheckSquareOutline, Clock, Cross, AccountPlus } from 'shared/icons';
|
||||
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
|
||||
import {
|
||||
isPositionChanged,
|
||||
getSortedDraggables,
|
||||
getNewDraggablePosition,
|
||||
getAfterDropDraggableList,
|
||||
} from 'shared/utils/draggables';
|
||||
import Button from 'shared/components/Button';
|
||||
import TextareaAutosize from 'react-autosize-textarea';
|
||||
import Control from 'react-select/src/components/Control';
|
||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
margin-bottom: 24px;
|
||||
`;
|
||||
|
||||
const WindowTitle = styled.div`
|
||||
padding: 8px 0;
|
||||
position: relative;
|
||||
margin: 0 0 4px 40px;
|
||||
`;
|
||||
|
||||
const WindowTitleIcon = styled(CheckSquareOutline)`
|
||||
top: 10px;
|
||||
left: -32px;
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
const WindowChecklistTitle = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-flow: row wrap;
|
||||
`;
|
||||
|
||||
const WindowTitleText = styled.h3`
|
||||
cursor: pointer;
|
||||
color: ${props => props.theme.colors.text.primary};
|
||||
margin: 6px 0;
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
min-height: 18px;
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
min-width: 40px;
|
||||
`;
|
||||
|
||||
const WindowOptions = styled.div`
|
||||
margin: 0 2px 0 auto;
|
||||
float: right;
|
||||
`;
|
||||
|
||||
const DeleteButton = styled(Button)`
|
||||
padding: 6px 12px;
|
||||
`;
|
||||
|
||||
const ChecklistProgress = styled.div`
|
||||
margin-bottom: 6px;
|
||||
position: relative;
|
||||
`;
|
||||
const ChecklistProgressPercent = styled.span`
|
||||
color: #5e6c84;
|
||||
font-size: 11px;
|
||||
line-height: 10px;
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: -1px;
|
||||
text-align: center;
|
||||
width: 32px;
|
||||
`;
|
||||
|
||||
const ChecklistProgressBar = styled.div`
|
||||
background: ${props => props.theme.colors.bg.primary};
|
||||
border-radius: 4px;
|
||||
clear: both;
|
||||
height: 8px;
|
||||
margin: 0 0 0 40px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
`;
|
||||
const ChecklistProgressBarCurrent = styled.div<{ width: number }>`
|
||||
width: ${props => props.width}%;
|
||||
background: ${props => (props.width === 100 ? props.theme.colors.success : props.theme.colors.primary)};
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transition: width 0.14s ease-in, background 0.14s ease-in;
|
||||
`;
|
||||
|
||||
export const ChecklistItems = styled.div`
|
||||
min-height: 8px;
|
||||
`;
|
||||
|
||||
const ChecklistItemUncheckedIcon = styled(Square)``;
|
||||
|
||||
const ChecklistIcon = styled.div`
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
margin: 10px;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
`;
|
||||
|
||||
const ChecklistItemCheckedIcon = styled(CheckSquare)`
|
||||
fill: ${props => props.theme.colors.primary};
|
||||
`;
|
||||
|
||||
const ChecklistItemDetails = styled.div`
|
||||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
`;
|
||||
const ChecklistItemRow = styled.div`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
const ChecklistItemTextControls = styled.div`
|
||||
padding: 6px 0;
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const ChecklistItemText = styled.span<{ complete: boolean }>`
|
||||
color: ${props => (props.complete ? '#5e6c84' : `${props.theme.colors.text.primary}`)};
|
||||
${props => props.complete && 'text-decoration: line-through;'}
|
||||
line-height: 20px;
|
||||
font-size: 16px;
|
||||
|
||||
min-height: 20px;
|
||||
margin-bottom: 0;
|
||||
align-self: center;
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const ChecklistControls = styled.div`
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
float: right;
|
||||
`;
|
||||
|
||||
const ControlButton = styled.div`
|
||||
opacity: 0;
|
||||
margin-left: 4px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
background-color: ${props => mixin.rgba(props.theme.colors.bg.primary, 0.8)};
|
||||
display: flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:hover {
|
||||
background-color: ${props => mixin.rgba(props.theme.colors.primary, 1)};
|
||||
}
|
||||
`;
|
||||
|
||||
const ChecklistNameEditorWrapper = styled.div`
|
||||
display: block;
|
||||
float: left;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 8px;
|
||||
z-index: 50;
|
||||
width: 100%;
|
||||
`;
|
||||
export const ChecklistNameEditor = styled(TextareaAutosize)`
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
resize: none;
|
||||
height: 54px;
|
||||
width: 100%;
|
||||
|
||||
background: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
max-height: 162px;
|
||||
min-height: 54px;
|
||||
padding: 8px 12px;
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
border: 1px solid ${props => props.theme.colors.primary};
|
||||
border-radius: 3px;
|
||||
color: ${props => props.theme.colors.text.primary};
|
||||
|
||||
border-color: ${props => props.theme.colors.border};
|
||||
background-color: ${props => mixin.rgba(props.theme.colors.bg.primary, 0.4)};
|
||||
&:focus {
|
||||
border-color: ${props => props.theme.colors.primary};
|
||||
}
|
||||
`;
|
||||
|
||||
const AssignUserButton = styled(AccountPlus)`
|
||||
fill: ${props => props.theme.colors.text.primary};
|
||||
`;
|
||||
|
||||
const ClockButton = styled(Clock)`
|
||||
fill: ${props => props.theme.colors.text.primary};
|
||||
`;
|
||||
|
||||
const TrashButton = styled(Trash)`
|
||||
fill: ${props => props.theme.colors.text.primary};
|
||||
`;
|
||||
|
||||
const ChecklistItemWrapper = styled.div<{ ref: any }>`
|
||||
user-select: none;
|
||||
clear: both;
|
||||
padding-left: 40px;
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
|
||||
& ${ControlButton}:last-child {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: ${props => mixin.rgba(props.theme.colors.bg.primary, 0.4)};
|
||||
}
|
||||
&:hover ${ControlButton} {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const EditControls = styled.div`
|
||||
clear: both;
|
||||
display: flex;
|
||||
padding-bottom: 9px;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
const SaveButton = styled(Button)`
|
||||
margin-right: 4px;
|
||||
padding: 6px 12px;
|
||||
`;
|
||||
const CancelButton = styled.div`
|
||||
cursor: pointer;
|
||||
margin: 5px;
|
||||
& svg {
|
||||
fill: ${props => props.theme.colors.text.primary};
|
||||
}
|
||||
&:hover svg {
|
||||
fill: ${props => props.theme.colors.text.secondary};
|
||||
}
|
||||
`;
|
||||
|
||||
const Spacer = styled.div`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const EditableDeleteButton = styled.button`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
margin: 0 2px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: ${props => mixin.rgba(props.theme.colors.primary, 0.8)};
|
||||
}
|
||||
`;
|
||||
|
||||
const NewItemButton = styled(Button)`
|
||||
padding: 6px 8px;
|
||||
`;
|
||||
|
||||
const ChecklistNewItem = styled.div`
|
||||
margin: 8px 0;
|
||||
margin-left: 40px;
|
||||
`;
|
||||
|
||||
type ChecklistItemProps = {
|
||||
itemID: string;
|
||||
checklistID: string;
|
||||
complete: boolean;
|
||||
name: string;
|
||||
onChangeName: (itemID: string, currentName: string) => void;
|
||||
wrapperProps: any;
|
||||
handleProps: any;
|
||||
onToggleItem: (itemID: string, complete: boolean) => void;
|
||||
onDeleteItem: (checklistIDID: string, itemID: string) => void;
|
||||
};
|
||||
|
||||
export const ChecklistItem = React.forwardRef(
|
||||
(
|
||||
{
|
||||
itemID,
|
||||
checklistID,
|
||||
complete,
|
||||
name,
|
||||
wrapperProps,
|
||||
handleProps,
|
||||
onChangeName,
|
||||
onToggleItem,
|
||||
onDeleteItem,
|
||||
}: ChecklistItemProps,
|
||||
$item,
|
||||
) => {
|
||||
const $editor = useRef<HTMLTextAreaElement>(null);
|
||||
const [editting, setEditting] = useState(false);
|
||||
const [currentName, setCurrentName] = useState(name);
|
||||
useEffect(() => {
|
||||
if (editting && $editor && $editor.current) {
|
||||
$editor.current.focus();
|
||||
$editor.current.select();
|
||||
}
|
||||
}, [editting]);
|
||||
// useOnOutsideClick($item, true, () => setEditting(false), null);
|
||||
return (
|
||||
<ChecklistItemWrapper ref={$item} {...wrapperProps} {...handleProps}>
|
||||
<ChecklistIcon
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onToggleItem(itemID, !complete);
|
||||
}}
|
||||
>
|
||||
{complete ? (
|
||||
<ChecklistItemCheckedIcon width={20} height={20} />
|
||||
) : (
|
||||
<ChecklistItemUncheckedIcon width={20} height={20} />
|
||||
)}
|
||||
</ChecklistIcon>
|
||||
{editting ? (
|
||||
<>
|
||||
<ChecklistNameEditorWrapper>
|
||||
<ChecklistNameEditor
|
||||
ref={$editor}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
onChangeName(itemID, currentName);
|
||||
setEditting(false);
|
||||
}
|
||||
}}
|
||||
onChange={e => {
|
||||
setCurrentName(e.currentTarget.value);
|
||||
}}
|
||||
value={currentName}
|
||||
/>
|
||||
</ChecklistNameEditorWrapper>
|
||||
<EditControls>
|
||||
<SaveButton
|
||||
onClick={() => {
|
||||
onChangeName(itemID, currentName);
|
||||
setEditting(false);
|
||||
}}
|
||||
variant="relief"
|
||||
>
|
||||
Save
|
||||
</SaveButton>
|
||||
<CancelButton
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setEditting(false);
|
||||
}}
|
||||
>
|
||||
<Cross width={20} height={20} />
|
||||
</CancelButton>
|
||||
<Spacer />
|
||||
<EditableDeleteButton
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setEditting(false);
|
||||
onDeleteItem(checklistID, itemID);
|
||||
}}
|
||||
>
|
||||
<Trash width={16} height={16} />
|
||||
</EditableDeleteButton>
|
||||
</EditControls>
|
||||
</>
|
||||
) : (
|
||||
<ChecklistItemDetails
|
||||
onClick={() => {
|
||||
setEditting(true);
|
||||
}}
|
||||
>
|
||||
<ChecklistItemRow>
|
||||
<ChecklistItemTextControls>
|
||||
<ChecklistItemText complete={complete}>{name}</ChecklistItemText>
|
||||
<ChecklistControls>
|
||||
<ControlButton>
|
||||
<AssignUserButton width={14} height={14} />
|
||||
</ControlButton>
|
||||
<ControlButton>
|
||||
<ClockButton width={14} height={14} />
|
||||
</ControlButton>
|
||||
<ControlButton
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onDeleteItem(checklistID, itemID);
|
||||
}}
|
||||
>
|
||||
<TrashButton width={14} height={14} />
|
||||
</ControlButton>
|
||||
</ChecklistControls>
|
||||
</ChecklistItemTextControls>
|
||||
</ChecklistItemRow>
|
||||
</ChecklistItemDetails>
|
||||
)}
|
||||
</ChecklistItemWrapper>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
type AddNewItemProps = {
|
||||
onAddItem: (name: string) => void;
|
||||
};
|
||||
|
||||
const AddNewItem: React.FC<AddNewItemProps> = ({ onAddItem }) => {
|
||||
const $editor = useRef<HTMLTextAreaElement>(null);
|
||||
const $wrapper = useRef<HTMLDivElement>(null);
|
||||
const [currentName, setCurrentName] = useState('');
|
||||
const [editting, setEditting] = useState(false);
|
||||
useEffect(() => {
|
||||
if (editting && $editor && $editor.current) {
|
||||
$editor.current.focus();
|
||||
$editor.current.select();
|
||||
}
|
||||
}, [editting]);
|
||||
useOnOutsideClick($wrapper, true, () => setEditting(false), null);
|
||||
return (
|
||||
<ChecklistNewItem ref={$wrapper}>
|
||||
{editting ? (
|
||||
<>
|
||||
<ChecklistNameEditorWrapper>
|
||||
<ChecklistNameEditor
|
||||
ref={$editor}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onAddItem(currentName);
|
||||
setCurrentName('');
|
||||
}
|
||||
}}
|
||||
onChange={e => {
|
||||
setCurrentName(e.currentTarget.value);
|
||||
}}
|
||||
value={currentName}
|
||||
/>
|
||||
</ChecklistNameEditorWrapper>
|
||||
<EditControls>
|
||||
<SaveButton
|
||||
onClick={() => {
|
||||
onAddItem(currentName);
|
||||
setCurrentName('');
|
||||
if (editting && $editor && $editor.current) {
|
||||
$editor.current.focus();
|
||||
$editor.current.select();
|
||||
}
|
||||
}}
|
||||
variant="relief"
|
||||
>
|
||||
Save
|
||||
</SaveButton>
|
||||
<CancelButton
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setEditting(false);
|
||||
}}
|
||||
>
|
||||
<Cross width={20} height={20} />
|
||||
</CancelButton>
|
||||
</EditControls>
|
||||
</>
|
||||
) : (
|
||||
<NewItemButton onClick={() => setEditting(true)}>Add an item</NewItemButton>
|
||||
)}
|
||||
</ChecklistNewItem>
|
||||
);
|
||||
};
|
||||
|
||||
type ChecklistTitleEditorProps = {
|
||||
name: string;
|
||||
onChangeName: (item: string) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const ChecklistTitleEditor = React.forwardRef(
|
||||
({ name, onChangeName, onCancel }: ChecklistTitleEditorProps, $name: any) => {
|
||||
const [currentName, setCurrentName] = useState(name);
|
||||
return (
|
||||
<>
|
||||
<ChecklistNameEditor
|
||||
ref={$name}
|
||||
value={currentName}
|
||||
onChange={e => {
|
||||
setCurrentName(e.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
onChangeName(currentName);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<EditControls>
|
||||
<SaveButton
|
||||
onClick={() => {
|
||||
onChangeName(currentName);
|
||||
}}
|
||||
variant="relief"
|
||||
>
|
||||
Save
|
||||
</SaveButton>
|
||||
<CancelButton
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
<Cross width={20} height={20} />
|
||||
</CancelButton>
|
||||
</EditControls>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
type ChecklistProps = {
|
||||
checklistID: string;
|
||||
onDeleteChecklist: ($target: React.RefObject<HTMLElement>, checklistID: string) => void;
|
||||
name: string;
|
||||
children: React.ReactNode;
|
||||
onChangeName: (item: string) => void;
|
||||
onToggleItem: (taskID: string, complete: boolean) => void;
|
||||
onChangeItemName: (itemID: string, currentName: string) => void;
|
||||
wrapperProps: any;
|
||||
handleProps: any;
|
||||
onDeleteItem: (checklistID: string, itemID: string) => void;
|
||||
onAddItem: (itemName: string) => void;
|
||||
items: Array<TaskChecklistItem>;
|
||||
};
|
||||
|
||||
const Checklist = React.forwardRef(
|
||||
(
|
||||
{
|
||||
checklistID,
|
||||
children,
|
||||
onDeleteChecklist,
|
||||
name,
|
||||
items,
|
||||
wrapperProps,
|
||||
handleProps,
|
||||
onToggleItem,
|
||||
onAddItem,
|
||||
onChangeItemName,
|
||||
onChangeName,
|
||||
onDeleteItem,
|
||||
}: ChecklistProps,
|
||||
$container,
|
||||
) => {
|
||||
const $name = useRef<HTMLTextAreaElement>(null);
|
||||
const complete = items.reduce((prev, item) => prev + (item.complete ? 1 : 0), 0);
|
||||
const percent = items.length === 0 ? 0 : Math.floor((complete / items.length) * 100);
|
||||
const [editting, setEditting] = useState(false);
|
||||
// useOnOutsideClick($name, true, () => setEditting(false), null);
|
||||
useEffect(() => {
|
||||
if (editting && $name && $name.current) {
|
||||
$name.current.focus();
|
||||
$name.current.select();
|
||||
}
|
||||
}, [editting]);
|
||||
return (
|
||||
<Wrapper ref={$container} {...wrapperProps}>
|
||||
<WindowTitle>
|
||||
<WindowTitleIcon width={24} height={24} />
|
||||
{editting ? (
|
||||
<ChecklistTitleEditor
|
||||
ref={$name}
|
||||
name={name}
|
||||
onChangeName={currentName => {
|
||||
onChangeName(currentName);
|
||||
setEditting(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setEditting(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<WindowChecklistTitle {...handleProps}>
|
||||
<WindowTitleText onClick={() => setEditting(true)}>{name}</WindowTitleText>
|
||||
<WindowOptions>
|
||||
<DeleteButton
|
||||
onClick={$target => {
|
||||
onDeleteChecklist($target, checklistID);
|
||||
}}
|
||||
color="danger"
|
||||
variant="outline"
|
||||
>
|
||||
Delete
|
||||
</DeleteButton>
|
||||
</WindowOptions>
|
||||
</WindowChecklistTitle>
|
||||
)}
|
||||
</WindowTitle>
|
||||
<ChecklistProgress>
|
||||
<ChecklistProgressPercent>{`${percent}%`}</ChecklistProgressPercent>
|
||||
<ChecklistProgressBar>
|
||||
<ChecklistProgressBarCurrent width={percent} />
|
||||
</ChecklistProgressBar>
|
||||
</ChecklistProgress>
|
||||
{children}
|
||||
<AddNewItem onAddItem={onAddItem} />
|
||||
</Wrapper>
|
||||
);
|
||||
},
|
||||
);
|
||||
/*
|
||||
<ChecklistItems>
|
||||
{items
|
||||
.slice()
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((item, idx) => (
|
||||
<ChecklistItem
|
||||
index={idx}
|
||||
key={item.id}
|
||||
itemID={item.id}
|
||||
name={item.name}
|
||||
complete={item.complete}
|
||||
onDeleteItem={onDeleteItem}
|
||||
onChangeName={onChangeItemName}
|
||||
onToggleItem={onToggleItem}
|
||||
/>
|
||||
))}
|
||||
|
||||
</ChecklistItems>
|
||||
*/
|
||||
export default Checklist;
|
71
frontend/src/shared/components/Chip/index.tsx
Normal file
71
frontend/src/shared/components/Chip/index.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import styled, { css } from 'styled-components';
|
||||
import { Cross } from 'shared/icons';
|
||||
|
||||
const LabelText = styled.span`
|
||||
margin-left: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${props => props.theme.colors.text.primary};
|
||||
`;
|
||||
|
||||
const Container = styled.div<{ color?: string }>`
|
||||
min-height: 26px;
|
||||
min-width: 26px;
|
||||
font-size: 0.8rem;
|
||||
border-radius: 20px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
${props =>
|
||||
props.color
|
||||
? css`
|
||||
background: ${props.color};
|
||||
& ${LabelText} {
|
||||
color: ${props.theme.colors.text.secondary};
|
||||
}
|
||||
`
|
||||
: css`
|
||||
background: ${props.theme.colors.bg.primary};
|
||||
`}
|
||||
`;
|
||||
|
||||
const CloseButton = styled.button`
|
||||
cursor: pointer;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin: 0 4px;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
`;
|
||||
|
||||
type ChipProps = {
|
||||
label: string;
|
||||
onClose?: () => void;
|
||||
color?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Chip: React.FC<ChipProps> = ({ label, onClose, color, className }) => {
|
||||
return (
|
||||
<Container className={className} color={color}>
|
||||
<LabelText>{label}</LabelText>
|
||||
{onClose && (
|
||||
<CloseButton onClick={() => onClose()}>
|
||||
<Cross width={12} height={12} />
|
||||
</CloseButton>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Chip;
|
103
frontend/src/shared/components/Confirm/Styles.ts
Normal file
103
frontend/src/shared/components/Confirm/Styles.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import styled from 'styled-components';
|
||||
import Button from 'shared/components/Button';
|
||||
|
||||
export const Wrapper = styled.div`
|
||||
background: #eff2f7;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
export const Column = styled.div`
|
||||
width: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
export const LoginFormWrapper = styled.div`
|
||||
background: #10163a;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const LoginFormContainer = styled.div`
|
||||
min-height: 505px;
|
||||
padding: 2rem;
|
||||
`;
|
||||
|
||||
export const Title = styled.h1`
|
||||
color: #ebeefd;
|
||||
font-size: 18px;
|
||||
margin-bottom: 14px;
|
||||
`;
|
||||
|
||||
export const SubTitle = styled.h2`
|
||||
color: #c2c6dc;
|
||||
font-size: 14px;
|
||||
margin-bottom: 14px;
|
||||
`;
|
||||
export const Form = styled.form`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
export const FormLabel = styled.label`
|
||||
color: #c2c6dc;
|
||||
font-size: 12px;
|
||||
position: relative;
|
||||
margin-top: 14px;
|
||||
`;
|
||||
|
||||
export const FormTextInput = styled.input`
|
||||
width: 100%;
|
||||
background: #262c49;
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
margin-top: 4px;
|
||||
padding: 0.7rem 1rem 0.7rem 3rem;
|
||||
font-size: 1rem;
|
||||
color: #c2c6dc;
|
||||
border-radius: 5px;
|
||||
`;
|
||||
|
||||
export const FormIcon = styled.div`
|
||||
top: 30px;
|
||||
left: 16px;
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
export const FormError = styled.span`
|
||||
font-size: 0.875rem;
|
||||
color: rgb(234, 84, 85);
|
||||
`;
|
||||
|
||||
export const LoginButton = styled(Button)``;
|
||||
|
||||
export const ActionButtons = styled.div`
|
||||
margin-top: 17.5px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
export const RegisterButton = styled(Button)``;
|
||||
|
||||
export const LogoTitle = styled.div`
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-left: 12px;
|
||||
transition: visibility, opacity, transform 0.25s ease;
|
||||
color: #7367f0;
|
||||
`;
|
||||
|
||||
export const LogoWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: row;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 24px;
|
||||
color: rgb(222, 235, 255);
|
||||
border-bottom: 1px solid rgba(65, 69, 97, 0.65);
|
||||
`;
|
55
frontend/src/shared/components/Confirm/index.tsx
Normal file
55
frontend/src/shared/components/Confirm/index.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import AccessAccount from 'shared/undraw/AccessAccount';
|
||||
import { User, Lock, Taskcafe } from 'shared/icons';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import LoadingSpinner from 'shared/components/LoadingSpinner';
|
||||
import {
|
||||
Form,
|
||||
LogoWrapper,
|
||||
LogoTitle,
|
||||
ActionButtons,
|
||||
RegisterButton,
|
||||
FormError,
|
||||
FormIcon,
|
||||
FormLabel,
|
||||
FormTextInput,
|
||||
Wrapper,
|
||||
Column,
|
||||
LoginFormWrapper,
|
||||
LoginFormContainer,
|
||||
Title,
|
||||
SubTitle,
|
||||
} from './Styles';
|
||||
|
||||
const Confirm = ({ hasFailed, hasConfirmToken }: ConfirmProps) => {
|
||||
return (
|
||||
<Wrapper>
|
||||
<Column>
|
||||
<AccessAccount width={275} height={250} />
|
||||
</Column>
|
||||
<Column>
|
||||
<LoginFormWrapper>
|
||||
<LoginFormContainer>
|
||||
<LogoWrapper>
|
||||
<Taskcafe width={42} height={42} />
|
||||
<LogoTitle>Taskcafé</LogoTitle>
|
||||
</LogoWrapper>
|
||||
{hasConfirmToken ? (
|
||||
<>
|
||||
<Title>Confirming user...</Title>
|
||||
{hasFailed ? <SubTitle>There was an error while confirming your user</SubTitle> : <LoadingSpinner />}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Title>There is no confirmation token</Title>
|
||||
<SubTitle>There seems to have been an error.</SubTitle>
|
||||
</>
|
||||
)}
|
||||
</LoginFormContainer>
|
||||
</LoginFormWrapper>
|
||||
</Column>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Confirm;
|
159
frontend/src/shared/components/ControlledInput/index.tsx
Normal file
159
frontend/src/shared/components/ControlledInput/index.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import styled, { css } from 'styled-components/macro';
|
||||
import theme from '../../../App/ThemeStyles';
|
||||
|
||||
const InputWrapper = styled.div<{ width: string }>`
|
||||
position: relative;
|
||||
width: auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
|
||||
margin-bottom: 2.2rem;
|
||||
margin-top: 24px;
|
||||
`;
|
||||
|
||||
const InputLabel = styled.span<{ width: string }>`
|
||||
width: ${(props) => props.width};
|
||||
padding: 0.7rem !important;
|
||||
color: #c2c6dc;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transition: all 0.2s ease;
|
||||
position: absolute;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
font-size: 0.85rem;
|
||||
cursor: text;
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const InputInput = styled.input<{
|
||||
hasValue: boolean;
|
||||
hasIcon: boolean;
|
||||
width: string;
|
||||
focusBg: string;
|
||||
borderColor: string;
|
||||
}>`
|
||||
width: ${(props) => props.width};
|
||||
font-size: 14px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
border-color: ${(props) => props.borderColor};
|
||||
background: #262c49;
|
||||
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.15);
|
||||
${(props) => (props.hasIcon ? 'padding: 0.7rem 1rem 0.7rem 3rem;' : 'padding: 0.7rem;')}
|
||||
line-height: 16px;
|
||||
color: #c2c6dc;
|
||||
position: relative;
|
||||
border-radius: 5px;
|
||||
transition: all 0.3s ease;
|
||||
&:focus {
|
||||
box-shadow: 0 3px 10px 0 rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid rgba(115, 103, 240);
|
||||
background: ${(props) => props.focusBg};
|
||||
}
|
||||
&:focus ~ ${InputLabel} {
|
||||
color: ${(props) => props.theme.colors.primary};
|
||||
transform: translate(-3px, -90%);
|
||||
}
|
||||
${(props) =>
|
||||
props.hasValue &&
|
||||
css`
|
||||
& ~ ${InputLabel} {
|
||||
color: ${props.theme.colors.primary};
|
||||
transform: translate(-3px, -90%);
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const Icon = styled.div`
|
||||
display: flex;
|
||||
left: 16px;
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
type ControlledInputProps = {
|
||||
variant?: 'normal' | 'alternate';
|
||||
label?: string;
|
||||
width?: string;
|
||||
floatingLabel?: boolean;
|
||||
placeholder?: string;
|
||||
icon?: JSX.Element;
|
||||
type?: string;
|
||||
autocomplete?: boolean;
|
||||
autoFocus?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
className?: string;
|
||||
defaultValue?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
value?: string;
|
||||
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const ControlledInput = ({
|
||||
width = 'auto',
|
||||
variant = 'normal',
|
||||
disabled = false,
|
||||
type = 'text',
|
||||
autocomplete,
|
||||
autoFocus = false,
|
||||
label,
|
||||
placeholder,
|
||||
icon,
|
||||
name,
|
||||
className,
|
||||
onChange,
|
||||
value,
|
||||
onClick,
|
||||
floatingLabel = false,
|
||||
defaultValue,
|
||||
id,
|
||||
}: ControlledInputProps) => {
|
||||
const $input = useRef<HTMLInputElement>(null);
|
||||
const [hasValue, setHasValue] = useState(false);
|
||||
const borderColor = variant === 'normal' ? 'rgba(0, 0, 0, 0.2)' : theme.colors.alternate;
|
||||
const focusBg = variant === 'normal' ? theme.colors.bg.secondary : theme.colors.bg.primary;
|
||||
useEffect(() => {
|
||||
if (autoFocus && $input && $input.current) {
|
||||
$input.current.focus();
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<InputWrapper className={className} width={width}>
|
||||
<InputInput
|
||||
disabled={disabled}
|
||||
hasValue={hasValue}
|
||||
onChange={(e) => {
|
||||
if (onChange) {
|
||||
setHasValue(e.currentTarget.value !== '' || floatingLabel);
|
||||
onChange(e);
|
||||
}
|
||||
}}
|
||||
value={value}
|
||||
id={id}
|
||||
type={type}
|
||||
name={name}
|
||||
ref={$input}
|
||||
onClick={onClick}
|
||||
autoComplete={autocomplete ? 'on' : 'off'}
|
||||
defaultValue={defaultValue}
|
||||
hasIcon={typeof icon !== 'undefined'}
|
||||
width={width}
|
||||
placeholder={placeholder}
|
||||
focusBg={focusBg}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
{label && <InputLabel width={width}>{label}</InputLabel>}
|
||||
<Icon>{icon && icon}</Icon>
|
||||
</InputWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ControlledInput;
|
74
frontend/src/shared/components/DropdownMenu/Styles.ts
Normal file
74
frontend/src/shared/components/DropdownMenu/Styles.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import styled from 'styled-components/macro';
|
||||
|
||||
export const Container = styled.div<{ left: number; top: number }>`
|
||||
position: absolute;
|
||||
left: ${props => props.left}px;
|
||||
top: ${props => props.top}px;
|
||||
position: absolute;
|
||||
padding-top: 10px;
|
||||
height: auto;
|
||||
width: auto;
|
||||
transform: translate(-100%);
|
||||
transition: opacity 0.25s, transform 0.25s, width 0.3s ease;
|
||||
z-index: 40000;
|
||||
`;
|
||||
|
||||
export const Wrapper = styled.div`
|
||||
padding: 5px;
|
||||
padding-top: 8px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 5px 25px 0 rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
margin: 0;
|
||||
|
||||
color: #c2c6dc;
|
||||
background: #262c49;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-color: #414561;
|
||||
`;
|
||||
|
||||
export const WrapperDiamond = styled.div`
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
display: block;
|
||||
transform: rotate(45deg) translate(-7px);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
|
||||
background: #262c49;
|
||||
border-color: #414561;
|
||||
`;
|
||||
|
||||
export const ActionsList = styled.ul`
|
||||
min-width: 9rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
`;
|
||||
|
||||
export const ActionItem = styled.li`
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
padding-top: 0.5rem !important;
|
||||
padding-bottom: 0.5rem !important;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
&:hover {
|
||||
background: ${props => props.theme.colors.primary};
|
||||
}
|
||||
`;
|
||||
|
||||
export const ActionTitle = styled.span`
|
||||
margin-left: 0.5rem;
|
||||
`;
|
||||
|
||||
export const Separator = styled.div`
|
||||
height: 1px;
|
||||
border-top: 1px solid #414561;
|
||||
margin: 0.25rem !important;
|
||||
`;
|
72
frontend/src/shared/components/DropdownMenu/index.tsx
Normal file
72
frontend/src/shared/components/DropdownMenu/index.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import React, { useRef } from 'react';
|
||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
||||
import { Exit, User, Cog } from 'shared/icons';
|
||||
import { Separator, Container, WrapperDiamond, Wrapper, ActionsList, ActionItem, ActionTitle } from './Styles';
|
||||
|
||||
type DropdownMenuProps = {
|
||||
left: number;
|
||||
top: number;
|
||||
onLogout: () => void;
|
||||
onCloseDropdown: () => void;
|
||||
onAdminConsole: () => void;
|
||||
};
|
||||
|
||||
const DropdownMenu: React.FC<DropdownMenuProps> = ({ left, top, onLogout, onCloseDropdown, onAdminConsole }) => {
|
||||
const $containerRef = useRef<HTMLDivElement>(null);
|
||||
useOnOutsideClick($containerRef, true, onCloseDropdown, null);
|
||||
return (
|
||||
<Container ref={$containerRef} left={left} top={top}>
|
||||
<Wrapper>
|
||||
<ActionItem onClick={onAdminConsole}>
|
||||
<User width={16} height={16} />
|
||||
<ActionTitle>Profile</ActionTitle>
|
||||
</ActionItem>
|
||||
<Separator />
|
||||
<ActionsList>
|
||||
<ActionItem onClick={onLogout}>
|
||||
<Exit size={16} color="#c2c6dc" />
|
||||
<ActionTitle>Logout</ActionTitle>
|
||||
</ActionItem>
|
||||
</ActionsList>
|
||||
</Wrapper>
|
||||
<WrapperDiamond />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
type ProfileMenuProps = {
|
||||
onProfile: () => void;
|
||||
onLogout: () => void;
|
||||
showAdminConsole: boolean;
|
||||
onAdminConsole: () => void;
|
||||
};
|
||||
|
||||
const ProfileMenu: React.FC<ProfileMenuProps> = ({ showAdminConsole, onAdminConsole, onProfile, onLogout }) => {
|
||||
return (
|
||||
<>
|
||||
{showAdminConsole && (
|
||||
<>
|
||||
<ActionItem onClick={onAdminConsole}>
|
||||
<Cog size={16} color="#c2c6dc" />
|
||||
<ActionTitle>Admin Console</ActionTitle>
|
||||
</ActionItem>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
<ActionItem onClick={onProfile}>
|
||||
<User width={16} height={16} />
|
||||
<ActionTitle>Profile</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionsList>
|
||||
<ActionItem onClick={onLogout}>
|
||||
<Exit size={16} color="#c2c6dc" />
|
||||
<ActionTitle>Logout</ActionTitle>
|
||||
</ActionItem>
|
||||
</ActionsList>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { ProfileMenu };
|
||||
|
||||
export default DropdownMenu;
|
348
frontend/src/shared/components/DueDateManager/Styles.ts
Normal file
348
frontend/src/shared/components/DueDateManager/Styles.ts
Normal file
@ -0,0 +1,348 @@
|
||||
import styled, { css } from 'styled-components';
|
||||
import Button from 'shared/components/Button';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
import ControlledInput from 'shared/components/ControlledInput';
|
||||
import { Bell, Clock } from 'shared/icons';
|
||||
|
||||
export const Wrapper = styled.div`
|
||||
display: flex
|
||||
flex-direction: column;
|
||||
& .react-datepicker {
|
||||
background: #262c49;
|
||||
border: none;
|
||||
}
|
||||
& .react-datepicker__triangle {
|
||||
display: none;
|
||||
}
|
||||
& .react-datepicker-popper {
|
||||
z-index: 10000;
|
||||
margin-top: 0;
|
||||
}
|
||||
& .react-datepicker__close-icon::after {
|
||||
background: none;
|
||||
font-size: 16px;
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
}
|
||||
|
||||
& .react-datepicker-time__header {
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
}
|
||||
& .react-datepicker__time-list-item {
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
}
|
||||
& .react-datepicker__time-container .react-datepicker__time
|
||||
.react-datepicker__time-box ul.react-datepicker__time-list
|
||||
li.react-datepicker__time-list-item:hover {
|
||||
color: ${(props) => props.theme.colors.text.secondary};
|
||||
background: ${(props) => props.theme.colors.bg.secondary};
|
||||
}
|
||||
& .react-datepicker__time-container .react-datepicker__time {
|
||||
background: ${(props) => props.theme.colors.bg.primary};
|
||||
}
|
||||
& .react-datepicker--time-only {
|
||||
background: ${(props) => props.theme.colors.bg.primary};
|
||||
border: 1px solid ${(props) => props.theme.colors.border};
|
||||
}
|
||||
|
||||
& .react-datepicker * {
|
||||
box-sizing: content-box;
|
||||
}
|
||||
& .react-datepicker__day-name {
|
||||
color: #c2c6dc;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
padding: 4px;
|
||||
font-size: 12px40px
|
||||
line-height: 40px;
|
||||
}
|
||||
& .react-datepicker__day-name:hover {
|
||||
background: #10163a;
|
||||
}
|
||||
& .react-datepicker__month {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& .react-datepicker__day,
|
||||
& .react-datepicker__time-name {
|
||||
color: #c2c6dc;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
padding: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
& .react-datepicker__day--outside-month {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
& .react-datepicker__day:hover {
|
||||
border-radius: 50%;
|
||||
background: #10163a;
|
||||
}
|
||||
& .react-datepicker__day--selected {
|
||||
border-radius: 50%;
|
||||
background: ${(props) => props.theme.colors.primary};
|
||||
color: #fff;
|
||||
}
|
||||
& .react-datepicker__day--selected:hover {
|
||||
border-radius: 50%;
|
||||
background: ${(props) => props.theme.colors.primary};
|
||||
color: #fff;
|
||||
}
|
||||
& .react-datepicker__header {
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
& .react-datepicker__header--time {
|
||||
border-bottom: 1px solid ${(props) => props.theme.colors.border};
|
||||
}
|
||||
|
||||
& .react-datepicker__input-container input {
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
border-color: ${(props) => props.theme.colors.alternate};
|
||||
background: #262c49;
|
||||
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.15);
|
||||
padding: 0.7rem;
|
||||
color: #c2c6dc;
|
||||
position: relative;
|
||||
border-radius: 5px;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
padding: 0 12px;
|
||||
&:focus {
|
||||
box-shadow: 0 3px 10px 0 rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid rgba(115, 103, 240);
|
||||
background: ${(props) => props.theme.colors.bg.primary};
|
||||
}
|
||||
`;
|
||||
|
||||
export const DueDatePickerWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
export const ConfirmAddDueDate = styled(Button)`
|
||||
margin: 0 4px 0 0;
|
||||
padding: 6px 12px;
|
||||
`;
|
||||
|
||||
export const RemoveDueDate = styled(Button)`
|
||||
padding: 6px 12px;
|
||||
margin: 0 0 0 4px;
|
||||
`;
|
||||
|
||||
export const AddDateRange = styled.div`
|
||||
opacity: 0.6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: ${(props) => mixin.rgba(props.theme.colors.primary, 0.8)};
|
||||
&:hover {
|
||||
color: ${(props) => mixin.rgba(props.theme.colors.primary, 1)};
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
export const DateRangeInputs = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-left: -4px;
|
||||
& > div:first-child,
|
||||
& > div:last-child {
|
||||
flex: 1 1 92px;
|
||||
margin-bottom: 4px;
|
||||
margin-left: 4px;
|
||||
min-width: 92px;
|
||||
width: initial;
|
||||
}
|
||||
& > ${AddDateRange} {
|
||||
margin-left: 4px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
& > .react-datepicker-wrapper input {
|
||||
padding-bottom: 4px;
|
||||
padding-top: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
export const CancelDueDate = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export const DueDateInput = styled(ControlledInput)`
|
||||
margin-top: 15px;
|
||||
margin-bottom: 5px;
|
||||
padding-right: 10px;
|
||||
`;
|
||||
|
||||
export const ActionsSeparator = styled.div`
|
||||
margin-top: 8px;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: #414561;
|
||||
display: flex;
|
||||
`;
|
||||
export const ActionsWrapper = styled.div`
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
& .react-datepicker-wrapper {
|
||||
margin-left: auto;
|
||||
width: 86px;
|
||||
}
|
||||
& .react-datepicker__input-container input {
|
||||
padding-bottom: 4px;
|
||||
padding-top: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
& .react-period-select__indicators {
|
||||
display: none;
|
||||
}
|
||||
& .react-period {
|
||||
width: 100%;
|
||||
max-width: 86px;
|
||||
}
|
||||
|
||||
& .react-period-select__single-value {
|
||||
color: #c2c6dc;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
& .react-period-select__value-container {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
& .react-period-select__control {
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
min-height: 30px;
|
||||
border-color: rgb(65, 69, 97);
|
||||
background: #262c49;
|
||||
box-shadow: 0 0 0 0 rgb(0 0 0 / 15%);
|
||||
color: #c2c6dc;
|
||||
padding-right: 12px;
|
||||
padding-left: 12px;
|
||||
padding-bottom: 4px;
|
||||
padding-top: 4px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
border-radius: 5px;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ActionClock = styled(Clock)`
|
||||
align-self: center;
|
||||
fill: ${(props) => props.theme.colors.primary};
|
||||
margin: 0 8px;
|
||||
flex: 0 0 auto;
|
||||
`;
|
||||
|
||||
export const ActionBell = styled(Bell)`
|
||||
align-self: center;
|
||||
fill: ${(props) => props.theme.colors.primary};
|
||||
margin: 0 8px;
|
||||
flex: 0 0 auto;
|
||||
`;
|
||||
|
||||
export const ActionLabel = styled.div`
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
`;
|
||||
|
||||
export const ActionIcon = styled.div<{ disabled?: boolean }>`
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
min-width: 36px;
|
||||
width: 36px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
margin-right: 8px;
|
||||
svg {
|
||||
fill: ${(props) => props.theme.colors.text.primary};
|
||||
transition-duration: 0.2s;
|
||||
transition-property: background, border, box-shadow, fill;
|
||||
}
|
||||
&:hover svg {
|
||||
fill: ${(props) => props.theme.colors.text.secondary};
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.disabled &&
|
||||
css`
|
||||
opacity: 0.8;
|
||||
cursor: not-allowed;
|
||||
`}
|
||||
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export const ClearButton = styled.div`
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
padding: 0 12px;
|
||||
margin-left: auto;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
justify-content: center;
|
||||
transition-duration: 0.2s;
|
||||
transition-property: background, border, box-shadow, color, fill;
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.colors.text.secondary};
|
||||
}
|
||||
`;
|
||||
|
||||
export const ControlWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
`;
|
||||
|
||||
export const RightWrapper = styled.div`
|
||||
flex: 1 1 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row-reverse;
|
||||
`;
|
||||
|
||||
export const LeftWrapper = styled.div`
|
||||
flex: 1 1 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
export const SaveButton = styled(Button)`
|
||||
padding: 6px 12px;
|
||||
justify-content: center;
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
export const RemoveButton = styled.div`
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
`;
|
485
frontend/src/shared/components/DueDateManager/index.tsx
Normal file
485
frontend/src/shared/components/DueDateManager/index.tsx
Normal file
@ -0,0 +1,485 @@
|
||||
import React, { useState, useEffect, forwardRef, useRef, useCallback } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import styled from 'styled-components';
|
||||
import DatePicker from 'react-datepicker';
|
||||
import _ from 'lodash';
|
||||
import { colourStyles } from 'shared/components/Select';
|
||||
import produce from 'immer';
|
||||
import Select from 'react-select';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import { getYear, getMonth } from 'date-fns';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import NOOP from 'shared/utils/noop';
|
||||
import { Bell, Clock, Cross, Plus, Trash } from 'shared/icons';
|
||||
|
||||
import {
|
||||
Wrapper,
|
||||
RemoveDueDate,
|
||||
SaveButton,
|
||||
RightWrapper,
|
||||
LeftWrapper,
|
||||
DueDateInput,
|
||||
DueDatePickerWrapper,
|
||||
ConfirmAddDueDate,
|
||||
DateRangeInputs,
|
||||
AddDateRange,
|
||||
ActionIcon,
|
||||
ActionsWrapper,
|
||||
ClearButton,
|
||||
ActionsSeparator,
|
||||
ActionClock,
|
||||
ActionLabel,
|
||||
ControlWrapper,
|
||||
RemoveButton,
|
||||
ActionBell,
|
||||
} from './Styles';
|
||||
|
||||
type DueDateManagerProps = {
|
||||
task: Task;
|
||||
onDueDateChange: (
|
||||
task: Task,
|
||||
newDueDate: Date,
|
||||
hasTime: boolean,
|
||||
notifications: { current: Array<NotificationInternal>; removed: Array<string> },
|
||||
) => void;
|
||||
onRemoveDueDate: (task: Task) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const Form = styled.form`
|
||||
padding-top: 25px;
|
||||
`;
|
||||
|
||||
const FormField = styled.div`
|
||||
width: 50%;
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
const NotificationCount = styled.input``;
|
||||
|
||||
const ActionPlus = styled(Plus)`
|
||||
position: absolute;
|
||||
fill: ${(props) => props.theme.colors.bg.primary} !important;
|
||||
stroke: ${(props) => props.theme.colors.bg.primary};
|
||||
`;
|
||||
|
||||
const ActionInput = styled.input`
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
margin-left: auto;
|
||||
margin-right: 4px;
|
||||
border-color: rgb(65, 69, 97);
|
||||
background: #262c49;
|
||||
box-shadow: 0 0 0 0 rgb(0 0 0 / 15%);
|
||||
color: #c2c6dc;
|
||||
position: relative;
|
||||
border-radius: 5px;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
padding: 0 12px;
|
||||
padding-bottom: 4px;
|
||||
padding-top: 4px;
|
||||
width: 100%;
|
||||
max-width: 48px;
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
const HeaderSelectLabel = styled.div`
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
z-index: 9999;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
padding: 6px 10px;
|
||||
text-decoration: underline;
|
||||
margin: 6px 0;
|
||||
font-size: 14px;
|
||||
line-height: 16px;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
color: #c2c6dc;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.colors.primary};
|
||||
color: #c2c6dc;
|
||||
}
|
||||
`;
|
||||
|
||||
const HeaderSelect = styled.select`
|
||||
text-decoration: underline;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
background: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
display: inline-block;
|
||||
|
||||
& option {
|
||||
color: #c2c6dc;
|
||||
background: ${(props) => props.theme.colors.bg.primary};
|
||||
}
|
||||
|
||||
& option:hover {
|
||||
background: ${(props) => props.theme.colors.bg.secondary};
|
||||
border: 1px solid ${(props) => props.theme.colors.primary};
|
||||
outline: none !important;
|
||||
box-shadow: none;
|
||||
color: #c2c6dc;
|
||||
}
|
||||
|
||||
&::-ms-expand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
z-index: 9998;
|
||||
margin: 0;
|
||||
left: 0;
|
||||
top: 5px;
|
||||
opacity: 0;
|
||||
`;
|
||||
|
||||
const HeaderButton = styled.button`
|
||||
cursor: pointer;
|
||||
color: #c2c6dc;
|
||||
text-decoration: underline;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
padding: 6px 10px;
|
||||
margin: 6px 0;
|
||||
background: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.colors.primary};
|
||||
color: #fff;
|
||||
}
|
||||
`;
|
||||
|
||||
const HeaderActions = styled.div`
|
||||
position: relative;
|
||||
text-align: center;
|
||||
& > button:first-child {
|
||||
float: left;
|
||||
}
|
||||
& > button:last-child {
|
||||
float: right;
|
||||
}
|
||||
`;
|
||||
|
||||
const notificationPeriodOptions = [
|
||||
{ value: 'minute', label: 'Minutes' },
|
||||
{ value: 'hour', label: 'Hours' },
|
||||
{ value: 'day', label: 'Days' },
|
||||
{ value: 'week', label: 'Weeks' },
|
||||
];
|
||||
|
||||
type NotificationInternal = {
|
||||
internalId: string;
|
||||
externalId: string | null;
|
||||
period: number;
|
||||
duration: { value: string; label: string };
|
||||
};
|
||||
|
||||
type NotificationEntryProps = {
|
||||
notification: NotificationInternal;
|
||||
onChange: (period: number, duration: { value: string; label: string }) => void;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
const NotificationEntry: React.FC<NotificationEntryProps> = ({ notification, onChange, onRemove }) => {
|
||||
return (
|
||||
<>
|
||||
<ActionBell width={16} height={16} />
|
||||
<ActionLabel>Notification</ActionLabel>
|
||||
<ActionInput
|
||||
value={notification.period}
|
||||
onChange={(e) => {
|
||||
onChange(parseInt(e.currentTarget.value, 10), notification.duration);
|
||||
}}
|
||||
onKeyPress={(e) => {
|
||||
const isNumber = /^[0-9]$/i.test(e.key);
|
||||
if (!isNumber && e.key !== 'Backspace') {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
dir="ltr"
|
||||
autoComplete="off"
|
||||
min="0"
|
||||
type="number"
|
||||
/>
|
||||
<Select
|
||||
menuPlacement="top"
|
||||
className="react-period"
|
||||
classNamePrefix="react-period-select"
|
||||
styles={colourStyles}
|
||||
isSearchable={false}
|
||||
defaultValue={notification.duration}
|
||||
options={notificationPeriodOptions}
|
||||
onChange={(e) => {
|
||||
if (e !== null) {
|
||||
onChange(notification.period, e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ActionIcon onClick={() => onRemove()}>
|
||||
<Cross width={16} height={16} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
);
|
||||
};
|
||||
const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange, onRemoveDueDate, onCancel }) => {
|
||||
const currentDueDate = task.dueDate.at ? dayjs(task.dueDate.at).toDate() : null;
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
setError,
|
||||
formState: { errors },
|
||||
control,
|
||||
} = useForm<DueDateFormData>();
|
||||
|
||||
const [startDate, setStartDate] = useState<Date | null>(currentDueDate);
|
||||
const [endDate, setEndDate] = useState<Date | null>(currentDueDate);
|
||||
const [hasTime, enableTime] = useState(task.hasTime ?? false);
|
||||
|
||||
const years = _.range(2010, getYear(new Date()) + 10, 1);
|
||||
const months = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
|
||||
const [isRange, setIsRange] = useState(false);
|
||||
const [notDuration, setNotDuration] = useState(10);
|
||||
const [removedNotifications, setRemovedNotifications] = useState<Array<string>>([]);
|
||||
const [notifications, setNotifications] = useState<Array<NotificationInternal>>(
|
||||
task.dueDate.notifications
|
||||
? task.dueDate.notifications.map((c, idx) => {
|
||||
const duration =
|
||||
notificationPeriodOptions.find((o) => o.value === c.duration.toLowerCase()) ?? notificationPeriodOptions[0];
|
||||
return {
|
||||
internalId: `n${idx}`,
|
||||
externalId: c.id,
|
||||
period: c.period,
|
||||
duration,
|
||||
};
|
||||
})
|
||||
: [],
|
||||
);
|
||||
return (
|
||||
<Wrapper>
|
||||
<DateRangeInputs>
|
||||
<DatePicker
|
||||
selected={startDate}
|
||||
onChange={(date) => {
|
||||
if (!Array.isArray(date) && date !== null) {
|
||||
setStartDate(date);
|
||||
}
|
||||
}}
|
||||
popperClassName="picker-hidden"
|
||||
dateFormat="yyyy-MM-dd"
|
||||
disabledKeyboardNavigation
|
||||
placeholderText="Select due date"
|
||||
/>
|
||||
{isRange ? (
|
||||
<DatePicker
|
||||
selected={startDate}
|
||||
onChange={(date) => {
|
||||
if (!Array.isArray(date)) {
|
||||
setStartDate(date);
|
||||
}
|
||||
}}
|
||||
popperClassName="picker-hidden"
|
||||
dateFormat="yyyy-MM-dd"
|
||||
placeholderText="Select from date"
|
||||
/>
|
||||
) : (
|
||||
<AddDateRange>Add date range</AddDateRange>
|
||||
)}
|
||||
</DateRangeInputs>
|
||||
<DatePicker
|
||||
selected={startDate}
|
||||
onChange={(date) => {
|
||||
if (!Array.isArray(date)) {
|
||||
setStartDate(date);
|
||||
}
|
||||
}}
|
||||
startDate={startDate}
|
||||
useWeekdaysShort
|
||||
renderCustomHeader={({
|
||||
date,
|
||||
changeYear,
|
||||
changeMonth,
|
||||
decreaseMonth,
|
||||
increaseMonth,
|
||||
prevMonthButtonDisabled,
|
||||
nextMonthButtonDisabled,
|
||||
}) => (
|
||||
<HeaderActions>
|
||||
<HeaderButton onClick={decreaseMonth} disabled={prevMonthButtonDisabled}>
|
||||
Prev
|
||||
</HeaderButton>
|
||||
<HeaderSelectLabel>
|
||||
{months[date.getMonth()]}
|
||||
<HeaderSelect
|
||||
value={months[getMonth(date)]}
|
||||
onChange={({ target: { value } }) => changeMonth(months.indexOf(value))}
|
||||
>
|
||||
{months.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</HeaderSelect>
|
||||
</HeaderSelectLabel>
|
||||
<HeaderSelectLabel>
|
||||
{date.getFullYear()}
|
||||
<HeaderSelect value={getYear(date)} onChange={({ target: { value } }) => changeYear(parseInt(value, 10))}>
|
||||
{years.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</HeaderSelect>
|
||||
</HeaderSelectLabel>
|
||||
|
||||
<HeaderButton onClick={increaseMonth} disabled={nextMonthButtonDisabled}>
|
||||
Next
|
||||
</HeaderButton>
|
||||
</HeaderActions>
|
||||
)}
|
||||
inline
|
||||
/>
|
||||
<ActionsSeparator />
|
||||
{hasTime && (
|
||||
<ActionsWrapper>
|
||||
<ActionClock width={16} height={16} />
|
||||
<ActionLabel>Due Time</ActionLabel>
|
||||
<DatePicker
|
||||
selected={startDate}
|
||||
onChange={(date) => {
|
||||
if (!Array.isArray(date)) {
|
||||
setStartDate(date);
|
||||
}
|
||||
}}
|
||||
showTimeSelect
|
||||
showTimeSelectOnly
|
||||
timeIntervals={15}
|
||||
timeCaption="Time"
|
||||
dateFormat="h:mm aa"
|
||||
/>
|
||||
<ActionIcon onClick={() => enableTime(false)}>
|
||||
<Cross width={16} height={16} />
|
||||
</ActionIcon>
|
||||
</ActionsWrapper>
|
||||
)}
|
||||
{notifications.map((n, idx) => (
|
||||
<ActionsWrapper key={n.internalId}>
|
||||
<NotificationEntry
|
||||
notification={n}
|
||||
onChange={(period, duration) => {
|
||||
setNotifications((prev) =>
|
||||
produce(prev, (draft) => {
|
||||
draft[idx].duration = duration;
|
||||
draft[idx].period = period;
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onRemove={() => {
|
||||
setNotifications((prev) =>
|
||||
produce(prev, (draft) => {
|
||||
draft.splice(idx, 1);
|
||||
if (n.externalId !== null) {
|
||||
setRemovedNotifications((prev) => {
|
||||
if (n.externalId !== null) {
|
||||
return [...prev, n.externalId];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ActionsWrapper>
|
||||
))}
|
||||
<ControlWrapper>
|
||||
<LeftWrapper>
|
||||
<SaveButton
|
||||
onClick={() => {
|
||||
if (startDate && notifications.findIndex((n) => Number.isNaN(n.period)) === -1) {
|
||||
onDueDateChange(task, startDate, hasTime, { current: notifications, removed: removedNotifications });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</SaveButton>
|
||||
{currentDueDate !== null && (
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
onRemoveDueDate(task);
|
||||
}}
|
||||
>
|
||||
<Trash width={16} height={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</LeftWrapper>
|
||||
<RightWrapper>
|
||||
<ActionIcon
|
||||
disabled={notifications.length === 3}
|
||||
onClick={() => {
|
||||
setNotifications((prev) => [
|
||||
...prev,
|
||||
{
|
||||
externalId: null,
|
||||
internalId: `n${prev.length + 1}`,
|
||||
duration: notificationPeriodOptions[0],
|
||||
period: 10,
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<Bell width={16} height={16} />
|
||||
<ActionPlus width={8} height={8} />
|
||||
</ActionIcon>
|
||||
{!hasTime && (
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
if (startDate === null) {
|
||||
const today = new Date();
|
||||
today.setHours(12, 30, 0);
|
||||
setStartDate(today);
|
||||
}
|
||||
enableTime(true);
|
||||
}}
|
||||
>
|
||||
<Clock width={16} height={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</RightWrapper>
|
||||
</ControlWrapper>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default DueDateManager;
|
95
frontend/src/shared/components/EmptyBoard/index.tsx
Normal file
95
frontend/src/shared/components/EmptyBoard/index.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import styled, { keyframes } from 'styled-components/macro';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
import theme from '../../../App/ThemeStyles';
|
||||
|
||||
export const BoardContainer = styled.div`
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
outline: none;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
export const BoardWrapper = styled.div`
|
||||
display: flex;
|
||||
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 8px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding-bottom: 8px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
`;
|
||||
export const Container = styled.div`
|
||||
width: 272px;
|
||||
margin: 0 4px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const defaultBaseColor = theme.colors.bg.primary;
|
||||
|
||||
export const defaultHighlightColor = mixin.lighten(theme.colors.bg.primary, 0.25);
|
||||
|
||||
export const skeletonKeyframes = keyframes`
|
||||
0% {
|
||||
background-position: -200px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: calc(200px + 100%) 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Wrapper = styled.div`
|
||||
// background-color: #ebecf0;
|
||||
// background: rgb(244, 245, 247);
|
||||
min-height: 120px;
|
||||
opacity: 0.8;
|
||||
background: #10163a;
|
||||
color: #c2c6dc;
|
||||
|
||||
border-radius: 5px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 100%;
|
||||
position: relative;
|
||||
white-space: normal;
|
||||
|
||||
background-image: linear-gradient(90deg, ${defaultBaseColor}, ${defaultHighlightColor}, ${defaultBaseColor});
|
||||
background-size: 200px 100%;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
animation: ${skeletonKeyframes} 1.2s ease-in-out infinite;
|
||||
`;
|
||||
|
||||
const EmptyBoard: React.FC = () => {
|
||||
return (
|
||||
<BoardContainer>
|
||||
<BoardWrapper>
|
||||
<Container>
|
||||
<Wrapper />
|
||||
</Container>
|
||||
<Container>
|
||||
<Wrapper />
|
||||
</Container>
|
||||
<Container>
|
||||
<Wrapper />
|
||||
</Container>
|
||||
<Container>
|
||||
<Wrapper />
|
||||
</Container>
|
||||
</BoardWrapper>
|
||||
</BoardContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyBoard;
|
202
frontend/src/shared/components/FormInput/index.tsx
Normal file
202
frontend/src/shared/components/FormInput/index.tsx
Normal file
@ -0,0 +1,202 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import styled, { css } from 'styled-components/macro';
|
||||
import theme from '../../../App/ThemeStyles';
|
||||
|
||||
const InputWrapper = styled.div<{ width: string }>`
|
||||
position: relative;
|
||||
width: ${(props) => props.width};
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
|
||||
margin-bottom: 2.2rem;
|
||||
margin-top: 24px;
|
||||
`;
|
||||
|
||||
const InputLabel = styled.span<{ width: string }>`
|
||||
width: ${(props) => props.width};
|
||||
padding: 0.7rem !important;
|
||||
color: #c2c6dc;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transition: all 0.2s ease;
|
||||
position: absolute;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
font-size: 0.85rem;
|
||||
cursor: text;
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const InputInput = styled.input<{
|
||||
hasValue: boolean;
|
||||
hasIcon: boolean;
|
||||
width: string;
|
||||
focusBg: string;
|
||||
borderColor: string;
|
||||
}>`
|
||||
width: ${(props) => props.width};
|
||||
font-size: 14px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
border-color: ${(props) => props.borderColor};
|
||||
background: #262c49;
|
||||
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.15);
|
||||
${(props) => (props.hasIcon ? 'padding: 0.7rem 1rem 0.7rem 3rem;' : 'padding: 0.7rem;')}
|
||||
|
||||
&:-webkit-autofill, &:-webkit-autofill:hover, &:-webkit-autofill:focus, &:-webkit-autofill:active {
|
||||
-webkit-box-shadow: 0 0 0 30px #262c49 inset !important;
|
||||
}
|
||||
&:-webkit-autofill {
|
||||
-webkit-text-fill-color: #c2c6dc !important;
|
||||
}
|
||||
line-height: 16px;
|
||||
color: #c2c6dc;
|
||||
position: relative;
|
||||
border-radius: 5px;
|
||||
transition: all 0.3s ease;
|
||||
&:focus {
|
||||
box-shadow: 0 3px 10px 0 rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid ${(props) => props.theme.colors.primary};
|
||||
background: ${(props) => props.focusBg};
|
||||
}
|
||||
&:focus ~ ${InputLabel} {
|
||||
color: ${(props) => props.theme.colors.primary};
|
||||
transform: translate(-3px, -90%);
|
||||
}
|
||||
${(props) =>
|
||||
props.hasValue &&
|
||||
css`
|
||||
& ~ ${InputLabel} {
|
||||
color: ${props.theme.colors.primary};
|
||||
transform: translate(-3px, -90%);
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const Icon = styled.div`
|
||||
display: flex;
|
||||
left: 16px;
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
type FormInputProps = {
|
||||
variant?: 'normal' | 'alternate';
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
width?: string;
|
||||
floatingLabel?: boolean;
|
||||
placeholder?: string;
|
||||
icon?: JSX.Element;
|
||||
type?: string;
|
||||
autocomplete?: boolean;
|
||||
autoFocus?: boolean;
|
||||
autoSelect?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
onChange: any;
|
||||
onBlur: any;
|
||||
className?: string;
|
||||
defaultValue?: string;
|
||||
value?: string;
|
||||
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
function useCombinedRefs(...refs: any) {
|
||||
const targetRef = React.useRef();
|
||||
|
||||
React.useEffect(() => {
|
||||
refs.forEach((ref: any) => {
|
||||
if (!ref) return;
|
||||
|
||||
if (typeof ref === 'function') {
|
||||
ref(targetRef.current);
|
||||
} else {
|
||||
ref.current = targetRef.current;
|
||||
}
|
||||
});
|
||||
}, [refs]);
|
||||
|
||||
return targetRef;
|
||||
}
|
||||
|
||||
const FormInput = React.forwardRef(
|
||||
(
|
||||
{
|
||||
disabled = false,
|
||||
width = 'auto',
|
||||
variant = 'normal',
|
||||
type = 'text',
|
||||
autoFocus = false,
|
||||
autoSelect = false,
|
||||
autocomplete,
|
||||
label,
|
||||
placeholder,
|
||||
onBlur,
|
||||
onChange,
|
||||
icon,
|
||||
name,
|
||||
className,
|
||||
onClick,
|
||||
floatingLabel,
|
||||
defaultValue,
|
||||
value,
|
||||
id,
|
||||
}: FormInputProps,
|
||||
$ref: any,
|
||||
) => {
|
||||
const [hasValue, setHasValue] = useState(defaultValue !== '');
|
||||
const borderColor = variant === 'normal' ? 'rgba(0,0,0,0.2)' : theme.colors.alternate;
|
||||
const focusBg = variant === 'normal' ? theme.colors.bg.secondary : theme.colors.bg.primary;
|
||||
|
||||
// Merge forwarded ref and internal ref in order to be able to access the ref in the useEffect
|
||||
// The forwarded ref is not accessible by itself, which is what the innerRef & combined ref is for
|
||||
// TODO(jordanknott): This is super ugly, find a better approach?
|
||||
const $innerRef = React.useRef<HTMLInputElement>(null);
|
||||
const combinedRef: any = useCombinedRefs($ref, $innerRef);
|
||||
useEffect(() => {
|
||||
if (combinedRef && combinedRef.current) {
|
||||
if (autoFocus) {
|
||||
combinedRef.current.focus();
|
||||
}
|
||||
if (autoSelect) {
|
||||
combinedRef.current.select();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<InputWrapper className={className} width={width}>
|
||||
<InputInput
|
||||
onChange={(e) => {
|
||||
setHasValue((e.currentTarget.value !== '' || floatingLabel) ?? false);
|
||||
onChange(e);
|
||||
}}
|
||||
disabled={disabled}
|
||||
hasValue={hasValue}
|
||||
ref={combinedRef}
|
||||
id={id}
|
||||
type={type}
|
||||
name={name}
|
||||
onClick={onClick}
|
||||
autoComplete={autocomplete ? 'on' : 'off'}
|
||||
defaultValue={defaultValue}
|
||||
onBlur={onBlur}
|
||||
value={value}
|
||||
hasIcon={typeof icon !== 'undefined'}
|
||||
width={width}
|
||||
placeholder={placeholder}
|
||||
focusBg={focusBg}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
{label && <InputLabel width={width}>{label}</InputLabel>}
|
||||
<Icon>{icon && icon}</Icon>
|
||||
</InputWrapper>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default FormInput;
|
189
frontend/src/shared/components/Input/index.tsx
Normal file
189
frontend/src/shared/components/Input/index.tsx
Normal file
@ -0,0 +1,189 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import styled, { css } from 'styled-components/macro';
|
||||
import theme from '../../../App/ThemeStyles';
|
||||
|
||||
const InputWrapper = styled.div<{ width: string }>`
|
||||
position: relative;
|
||||
width: ${props => props.width};
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
|
||||
margin-bottom: 2.2rem;
|
||||
margin-top: 24px;
|
||||
`;
|
||||
|
||||
const InputLabel = styled.span<{ width: string }>`
|
||||
width: ${props => props.width};
|
||||
padding: 0.7rem !important;
|
||||
color: #c2c6dc;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transition: all 0.2s ease;
|
||||
position: absolute;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
font-size: 0.85rem;
|
||||
cursor: text;
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const InputInput = styled.input<{
|
||||
hasValue: boolean;
|
||||
hasIcon: boolean;
|
||||
width: string;
|
||||
focusBg: string;
|
||||
borderColor: string;
|
||||
}>`
|
||||
width: ${props => props.width};
|
||||
font-size: 14px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
border-color: ${props => props.borderColor};
|
||||
background: #262c49;
|
||||
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.15);
|
||||
${props => (props.hasIcon ? 'padding: 0.7rem 1rem 0.7rem 3rem;' : 'padding: 0.7rem;')}
|
||||
line-height: 16px;
|
||||
color: #c2c6dc;
|
||||
position: relative;
|
||||
border-radius: 5px;
|
||||
transition: all 0.3s ease;
|
||||
&:focus {
|
||||
box-shadow: 0 3px 10px 0 rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid ${props => props.theme.colors.primary};
|
||||
background: ${props => props.focusBg};
|
||||
}
|
||||
&:focus ~ ${InputLabel} {
|
||||
color: ${props => props.theme.colors.primary};
|
||||
transform: translate(-3px, -90%);
|
||||
}
|
||||
${props =>
|
||||
props.hasValue &&
|
||||
css`
|
||||
& ~ ${InputLabel} {
|
||||
color: ${props.theme.colors.primary};
|
||||
transform: translate(-3px, -90%);
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const Icon = styled.div`
|
||||
display: flex;
|
||||
left: 16px;
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
type InputProps = {
|
||||
variant?: 'normal' | 'alternate';
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
width?: string;
|
||||
floatingLabel?: boolean;
|
||||
placeholder?: string;
|
||||
icon?: JSX.Element;
|
||||
type?: string;
|
||||
autocomplete?: boolean;
|
||||
autoFocus?: boolean;
|
||||
autoSelect?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
className?: string;
|
||||
defaultValue?: string;
|
||||
value?: string;
|
||||
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
function useCombinedRefs(...refs: any) {
|
||||
const targetRef = React.useRef();
|
||||
|
||||
React.useEffect(() => {
|
||||
refs.forEach((ref: any) => {
|
||||
if (!ref) return;
|
||||
|
||||
if (typeof ref === 'function') {
|
||||
ref(targetRef.current);
|
||||
} else {
|
||||
ref.current = targetRef.current;
|
||||
}
|
||||
});
|
||||
}, [refs]);
|
||||
|
||||
return targetRef;
|
||||
}
|
||||
|
||||
const Input = React.forwardRef(
|
||||
(
|
||||
{
|
||||
disabled = false,
|
||||
width = 'auto',
|
||||
variant = 'normal',
|
||||
type = 'text',
|
||||
autoFocus = false,
|
||||
autoSelect = false,
|
||||
autocomplete,
|
||||
label,
|
||||
placeholder,
|
||||
icon,
|
||||
name,
|
||||
className,
|
||||
onClick,
|
||||
floatingLabel,
|
||||
defaultValue,
|
||||
value,
|
||||
id,
|
||||
}: InputProps,
|
||||
$ref: any,
|
||||
) => {
|
||||
const [hasValue, setHasValue] = useState(defaultValue !== '');
|
||||
const borderColor = variant === 'normal' ? 'rgba(0,0,0,0.2)' : theme.colors.alternate;
|
||||
const focusBg = variant === 'normal' ? theme.colors.bg.secondary : theme.colors.bg.primary;
|
||||
|
||||
// Merge forwarded ref and internal ref in order to be able to access the ref in the useEffect
|
||||
// The forwarded ref is not accessible by itself, which is what the innerRef & combined ref is for
|
||||
// TODO(jordanknott): This is super ugly, find a better approach?
|
||||
const $innerRef = React.useRef<HTMLInputElement>(null);
|
||||
const combinedRef: any = useCombinedRefs($ref, $innerRef);
|
||||
useEffect(() => {
|
||||
if (combinedRef && combinedRef.current) {
|
||||
if (autoFocus) {
|
||||
combinedRef.current.focus();
|
||||
}
|
||||
if (autoSelect) {
|
||||
combinedRef.current.select();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<InputWrapper className={className} width={width}>
|
||||
<InputInput
|
||||
onChange={e => {
|
||||
setHasValue((e.currentTarget.value !== '' || floatingLabel) ?? false);
|
||||
}}
|
||||
disabled={disabled}
|
||||
hasValue={hasValue}
|
||||
ref={combinedRef}
|
||||
id={id}
|
||||
type={type}
|
||||
name={name}
|
||||
onClick={onClick}
|
||||
autoComplete={autocomplete ? 'on' : 'off'}
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
hasIcon={typeof icon !== 'undefined'}
|
||||
width={width}
|
||||
placeholder={placeholder}
|
||||
focusBg={focusBg}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
{label && <InputLabel width={width}>{label}</InputLabel>}
|
||||
<Icon>{icon && icon}</Icon>
|
||||
</InputWrapper>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Input;
|
127
frontend/src/shared/components/List/Styles.ts
Normal file
127
frontend/src/shared/components/List/Styles.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import styled, { css } from 'styled-components';
|
||||
import TextareaAutosize from 'react-autosize-textarea';
|
||||
|
||||
export const Container = styled.div`
|
||||
width: 272px;
|
||||
margin: 0 4px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const AddCardContainer = styled.div`
|
||||
min-height: 38px;
|
||||
max-height: 38px;
|
||||
display: ${props => (props.hidden ? 'none' : 'flex')};
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
export const AddCardButton = styled.a`
|
||||
border-radius: 3px;
|
||||
color: #c2c6dc;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
flex: 1 0 auto;
|
||||
margin: 2px 8px 8px 8px;
|
||||
padding: 4px 8px;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
&:hover {
|
||||
color: #c2c6dc;
|
||||
text-decoration: none;
|
||||
background: ${props => props.theme.colors.primary};
|
||||
}
|
||||
`;
|
||||
export const Wrapper = styled.div`
|
||||
// background-color: #ebecf0;
|
||||
// background: rgb(244, 245, 247);
|
||||
background: #10163a;
|
||||
color: #c2c6dc;
|
||||
|
||||
border-radius: 5px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 100%;
|
||||
position: relative;
|
||||
white-space: normal;
|
||||
`;
|
||||
|
||||
export const HeaderEditTarget = styled.div<{ isHidden: boolean }>`
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: ${props => (props.isHidden ? 'none' : 'block')};
|
||||
`;
|
||||
|
||||
export const HeaderName = styled(TextareaAutosize)`
|
||||
font-size: 14px;
|
||||
border: none;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
box-shadow: none;
|
||||
font-weight: 600;
|
||||
margin: -4px 0;
|
||||
&:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
letter-spacing: normal;
|
||||
word-spacing: normal;
|
||||
text-transform: none;
|
||||
text-indent: 0px;
|
||||
text-shadow: none;
|
||||
flex-direction: column;
|
||||
text-align: start;
|
||||
|
||||
color: #c2c6dc;
|
||||
`;
|
||||
|
||||
export const Header = styled.div<{ isEditing: boolean }>`
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 8px;
|
||||
position: relative;
|
||||
min-height: 20px;
|
||||
padding-right: 36px;
|
||||
|
||||
${props =>
|
||||
props.isEditing &&
|
||||
css`
|
||||
& ${HeaderName} {
|
||||
box-shadow: ${props.theme.colors.primary} 0px 0px 0px 1px;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export const AddCardButtonText = styled.span`
|
||||
padding-left: 5px;
|
||||
`;
|
||||
|
||||
export const ListCards = styled.div`
|
||||
margin: 0 4px;
|
||||
padding: 0 4px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 45px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
`;
|
||||
|
||||
export const ListExtraMenuButtonWrapper = styled.div`
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
z-index: 1;
|
||||
padding: 6px;
|
||||
padding-bottom: 0;
|
||||
`;
|
125
frontend/src/shared/components/List/index.tsx
Normal file
125
frontend/src/shared/components/List/index.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown';
|
||||
import { Plus, Ellipsis } from 'shared/icons';
|
||||
|
||||
import {
|
||||
Container,
|
||||
Wrapper,
|
||||
Header,
|
||||
HeaderName,
|
||||
HeaderEditTarget,
|
||||
AddCardContainer,
|
||||
AddCardButton,
|
||||
AddCardButtonText,
|
||||
ListCards,
|
||||
ListExtraMenuButtonWrapper,
|
||||
} from './Styles';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
id: string;
|
||||
name: string;
|
||||
onSaveName: (name: string) => void;
|
||||
isComposerOpen: boolean;
|
||||
onOpenComposer: (id: string) => void;
|
||||
wrapperProps?: any;
|
||||
headerProps?: any;
|
||||
isPublic: boolean;
|
||||
index?: number;
|
||||
onExtraMenuOpen: (taskGroupID: string, $targetRef: React.RefObject<HTMLElement>) => void;
|
||||
};
|
||||
|
||||
const List = React.forwardRef(
|
||||
(
|
||||
{
|
||||
id,
|
||||
name,
|
||||
onSaveName,
|
||||
isComposerOpen,
|
||||
onOpenComposer,
|
||||
children,
|
||||
isPublic,
|
||||
wrapperProps,
|
||||
headerProps,
|
||||
onExtraMenuOpen,
|
||||
}: Props,
|
||||
$wrapperRef: any,
|
||||
) => {
|
||||
const [listName, setListName] = useState(name);
|
||||
const [isEditingTitle, setEditingTitle] = useState(false);
|
||||
const $listNameRef = useRef<HTMLTextAreaElement>(null);
|
||||
const $extraActionsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onClick = () => {
|
||||
setEditingTitle(true);
|
||||
if ($listNameRef && $listNameRef.current) {
|
||||
$listNameRef.current.select();
|
||||
}
|
||||
};
|
||||
const onBlur = () => {
|
||||
setEditingTitle(false);
|
||||
onSaveName(listName);
|
||||
};
|
||||
const onEscape = () => {
|
||||
if ($listNameRef && $listNameRef.current) {
|
||||
$listNameRef.current.blur();
|
||||
}
|
||||
};
|
||||
const onChange = (event: React.FormEvent<HTMLTextAreaElement>): void => {
|
||||
setListName(event.currentTarget.value);
|
||||
};
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if ($listNameRef && $listNameRef.current) {
|
||||
$listNameRef.current.blur();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleExtraMenuOpen = () => {
|
||||
if ($extraActionsRef && $extraActionsRef.current) {
|
||||
onExtraMenuOpen(id, $extraActionsRef);
|
||||
}
|
||||
};
|
||||
useOnEscapeKeyDown(isEditingTitle, onEscape);
|
||||
|
||||
return (
|
||||
<Container ref={$wrapperRef} {...wrapperProps}>
|
||||
<Wrapper>
|
||||
<Header {...headerProps} isEditing={isEditingTitle}>
|
||||
{!isPublic && <HeaderEditTarget onClick={onClick} isHidden={isEditingTitle} />}
|
||||
<HeaderName
|
||||
ref={$listNameRef}
|
||||
disabled={isPublic}
|
||||
onBlur={onBlur}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
spellCheck={false}
|
||||
value={listName}
|
||||
/>
|
||||
{!isPublic && (
|
||||
<ListExtraMenuButtonWrapper ref={$extraActionsRef} onClick={handleExtraMenuOpen}>
|
||||
<Ellipsis vertical={false} size={16} color="#c2c6dc" />
|
||||
</ListExtraMenuButtonWrapper>
|
||||
)}
|
||||
</Header>
|
||||
{children && children}
|
||||
{!isPublic && (
|
||||
<AddCardContainer hidden={isComposerOpen}>
|
||||
<AddCardButton onClick={() => onOpenComposer(id)}>
|
||||
<Plus width={12} height={12} />
|
||||
<AddCardButtonText>Add another card</AddCardButtonText>
|
||||
</AddCardButton>
|
||||
</AddCardContainer>
|
||||
)}
|
||||
</Wrapper>
|
||||
</Container>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
List.displayName = 'List';
|
||||
export default List;
|
||||
|
||||
export { ListCards };
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user