initial commit
This commit is contained in:
commit
9611105364
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
node_modules
|
13
api/Pipfile
Normal file
13
api/Pipfile
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
[[source]]
|
||||||
|
name = "pypi"
|
||||||
|
url = "https://pypi.org/simple"
|
||||||
|
verify_ssl = true
|
||||||
|
|
||||||
|
[dev-packages]
|
||||||
|
|
||||||
|
[packages]
|
||||||
|
click = "*"
|
||||||
|
requests = "*"
|
||||||
|
|
||||||
|
[requires]
|
||||||
|
python_version = "3.8"
|
18
api/README.md
Normal file
18
api/README.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
## Authentication
|
||||||
|
|
||||||
|
Uses a refresh_token and access_token system.
|
||||||
|
|
||||||
|
The refresh_token is an opaque UUID based token. The access_token is a JWT
|
||||||
|
token containing several claims such as `sub` & `roles`
|
||||||
|
|
||||||
|
The refresh_token is stored in a database and is long lived (24 hours). It is sent to the client
|
||||||
|
as a cookie set to be `HttpOnly`.
|
||||||
|
|
||||||
|
The access_token is not stored in the database & is only stored in memory on the client side.
|
||||||
|
It is short lived (5 minutes).
|
||||||
|
|
||||||
|
The access_token is used to authenticate all endpoints except endpoints under /auth
|
||||||
|
|
||||||
|
The access_token is refreshed using the refresh_token through the /auth/refresh_token endpoint.
|
||||||
|
This endpoint takes in the refresh_token set VIA a cookie header & returns a new refresh_token & access_token
|
||||||
|
if the refresh_token is still valid. The old refresh_token is invalidated.
|
28
api/cmd/citadel/main.go
Normal file
28
api/cmd/citadel/main.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"github.com/jordanknott/project-citadel/api/router"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
Formatter := new(log.TextFormatter)
|
||||||
|
Formatter.TimestampFormat = "02-01-2006 15:04:05"
|
||||||
|
Formatter.FullTimestamp = true
|
||||||
|
log.SetFormatter(Formatter)
|
||||||
|
db, err := sqlx.Connect("postgres", "user=postgres password=test host=0.0.0.0 dbname=citadel sslmode=disable")
|
||||||
|
if err != nil {
|
||||||
|
log.Panic(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
fmt.Println("starting graphql server on http://localhost:3333")
|
||||||
|
fmt.Println("starting graphql playground on http://localhost:3333/__graphql")
|
||||||
|
r, _ := router.NewRouter(db)
|
||||||
|
http.ListenAndServe(":3333", r)
|
||||||
|
}
|
16
api/cmd/citadelctl/main.go
Normal file
16
api/cmd/citadelctl/main.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/jordanknott/project-citadel/api/router"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dur := time.Hour * 24 * 7 * 30
|
||||||
|
token, err := router.NewAccessTokenCustomExpiration("21345076-6423-4a00-a6bd-cd9f830e2764", dur)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Println(token)
|
||||||
|
}
|
18
api/go.mod
Normal file
18
api/go.mod
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
module github.com/jordanknott/project-citadel/api
|
||||||
|
|
||||||
|
go 1.13
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/99designs/gqlgen v0.11.1
|
||||||
|
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
||||||
|
github.com/go-chi/chi v3.3.2+incompatible
|
||||||
|
github.com/go-chi/cors v1.0.0
|
||||||
|
github.com/google/martian v2.1.0+incompatible
|
||||||
|
github.com/google/uuid v1.1.1
|
||||||
|
github.com/jmoiron/sqlx v1.2.0
|
||||||
|
github.com/lib/pq v1.0.0
|
||||||
|
github.com/pkg/errors v0.8.1
|
||||||
|
github.com/sirupsen/logrus v1.4.2
|
||||||
|
github.com/vektah/gqlparser/v2 v2.0.1
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550
|
||||||
|
)
|
105
api/go.sum
Normal file
105
api/go.sum
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
github.com/99designs/gqlgen v0.11.1 h1:QoSL8/AAJ2T3UOeQbdnBR32JcG4pO08+P/g5jdbFkUg=
|
||||||
|
github.com/99designs/gqlgen v0.11.1/go.mod h1:vjFOyBZ7NwDl+GdSD4PFn7BQn5Fy7ohJwXn7Vk8zz+c=
|
||||||
|
github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=
|
||||||
|
github.com/agnivade/levenshtein v1.0.3 h1:M5ZnqLOoZR8ygVq0FfkXsNOKzMCk0xRiow0R5+5VkQ0=
|
||||||
|
github.com/agnivade/levenshtein v1.0.3/go.mod h1:4SFRZbbXWLF4MU1T9Qg0pGgH3Pjs+t6ie5efyrwRJXs=
|
||||||
|
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||||
|
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
|
||||||
|
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||||
|
github.com/dgryski/trifles v0.0.0-20190318185328-a8d75aae118c/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
|
||||||
|
github.com/go-chi/chi v3.3.2+incompatible h1:uQNcQN3NsV1j4ANsPh42P4ew4t6rnRbJb8frvpp31qQ=
|
||||||
|
github.com/go-chi/chi v3.3.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||||
|
github.com/go-chi/cors v1.0.0 h1:e6x8k7uWbUwYs+aXDoiUzeQFT6l0cygBYyNhD7/1Tg0=
|
||||||
|
github.com/go-chi/cors v1.0.0/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
|
||||||
|
github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk=
|
||||||
|
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||||
|
github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||||
|
github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
|
||||||
|
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||||
|
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
|
||||||
|
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
||||||
|
github.com/gorilla/mux v1.6.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||||
|
github.com/gorilla/websocket v1.2.0 h1:VJtLvh6VQym50czpZzx07z/kw9EgAxI3x1ZB8taTMQQ=
|
||||||
|
github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
|
github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA=
|
||||||
|
github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
|
||||||
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
|
||||||
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
|
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=
|
||||||
|
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
|
github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
|
||||||
|
github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007 h1:reVOUXwnhsYv/8UqjvhrMOu5CNT9UapHFLbQ2JcXsmg=
|
||||||
|
github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ=
|
||||||
|
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||||
|
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||||
|
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.9.0 h1:pDRiWfl+++eC2FEFRy6jXmQlvp4Yh3z1MJKg4UeYM/4=
|
||||||
|
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||||
|
github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047 h1:zCoDWFD5nrJJVjbXiDZcVhOBSzKn3o9LgRLLMRNuru8=
|
||||||
|
github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||||
|
github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74=
|
||||||
|
github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
|
||||||
|
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
|
||||||
|
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||||
|
github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
|
||||||
|
github.com/shurcooL/vfsgen v0.0.0-20180121065927-ffb13db8def0/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
|
||||||
|
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
|
||||||
|
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw=
|
||||||
|
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
|
||||||
|
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e h1:+w0Zm/9gaWpEAyDlU1eKOuk5twTjAjuevXqcJJw8hrg=
|
||||||
|
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=
|
||||||
|
github.com/vektah/gqlparser/v2 v2.0.1 h1:xgl5abVnsd4hkN9rk65OJID9bfcLSMuTaTcZj777q1o=
|
||||||
|
github.com/vektah/gqlparser/v2 v2.0.1/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg=
|
||||||
|
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190515012406-7d7faa4812bd/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20200114235610-7ae403b6b589 h1:rjUrONFu4kLchcZTfp3/96bR8bW8dIa8uz3cR5n0cgM=
|
||||||
|
golang.org/x/tools v0.0.0-20200114235610-7ae403b6b589/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
|
||||||
|
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
sourcegraph.com/sourcegraph/appdash v0.0.0-20180110180208-2cc67fd64755/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=
|
||||||
|
sourcegraph.com/sourcegraph/appdash-data v0.0.0-20151005221446-73f23eafcf67/go.mod h1:L5q+DGLGOQFpo1snNEkLOJT2d1YTW66rWNzatr3He1k=
|
47
api/gqlgen.yml
Normal file
47
api/gqlgen.yml
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# Where are all the schema files located? globs are supported eg src/**/*.graphqls
|
||||||
|
schema:
|
||||||
|
- graph/*.graphqls
|
||||||
|
|
||||||
|
# Where should the generated server code go?
|
||||||
|
exec:
|
||||||
|
filename: graph/generated.go
|
||||||
|
package: graph
|
||||||
|
|
||||||
|
# Uncomment to enable federation
|
||||||
|
# federation:
|
||||||
|
# filename: graph/generated/federation.go
|
||||||
|
# package: generated
|
||||||
|
|
||||||
|
# Where should any generated models go?
|
||||||
|
model:
|
||||||
|
filename: graph/models_gen.go
|
||||||
|
package: graph
|
||||||
|
|
||||||
|
# Where should the resolver implementations go?
|
||||||
|
resolver:
|
||||||
|
layout: follow-schema
|
||||||
|
dir: graph
|
||||||
|
package: graph
|
||||||
|
|
||||||
|
# Optional: turn on to use []Thing instead of []*Thing
|
||||||
|
omit_slice_element_pointers: true
|
||||||
|
|
||||||
|
# gqlgen will search for any type names in the schema in these go packages
|
||||||
|
# if they match it will use them, otherwise it will generate them.
|
||||||
|
autobind:
|
||||||
|
- "github.com/jordanknott/project-citadel/api/pg"
|
||||||
|
|
||||||
|
# This section declares type mapping between the GraphQL and go type systems
|
||||||
|
#
|
||||||
|
# The first line in each type will be used as defaults for resolver arguments and
|
||||||
|
# modelgen, the others will be allowed when binding to fields. Configure them to
|
||||||
|
# your liking
|
||||||
|
models:
|
||||||
|
ID:
|
||||||
|
model: github.com/jordanknott/project-citadel/api/graph.UUID
|
||||||
|
Int:
|
||||||
|
model:
|
||||||
|
- github.com/99designs/gqlgen/graphql.Int
|
||||||
|
|
||||||
|
UUID:
|
||||||
|
model: github.com/jordanknott/project-citadel/api/graph.UUID
|
6217
api/graph/generated.go
Normal file
6217
api/graph/generated.go
Normal file
File diff suppressed because it is too large
Load Diff
22
api/graph/graph.go
Normal file
22
api/graph/graph.go
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/99designs/gqlgen/handler"
|
||||||
|
"github.com/jordanknott/project-citadel/api/pg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewHandler returns a new graphql endpoint handler.
|
||||||
|
func NewHandler(repo pg.Repository) http.Handler {
|
||||||
|
return handler.GraphQL(NewExecutableSchema(Config{
|
||||||
|
Resolvers: &Resolver{
|
||||||
|
Repository: repo,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPlaygroundHandler returns a new GraphQL Playground handler.
|
||||||
|
func NewPlaygroundHandler(endpoint string) http.Handler {
|
||||||
|
return handler.Playground("GraphQL Playground", endpoint)
|
||||||
|
}
|
75
api/graph/models_gen.go
Normal file
75
api/graph/models_gen.go
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.
|
||||||
|
|
||||||
|
package graph
|
||||||
|
|
||||||
|
type DeleteTaskInput struct {
|
||||||
|
TaskID string `json:"taskID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteTaskPayload struct {
|
||||||
|
TaskID string `json:"taskID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FindProject struct {
|
||||||
|
ProjectID string `json:"projectId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FindUser struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogoutUser struct {
|
||||||
|
UserID string `json:"userID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NewOrganization struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NewProject struct {
|
||||||
|
TeamID string `json:"teamID"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NewRefreshToken struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NewTask struct {
|
||||||
|
TaskGroupID string `json:"taskGroupID"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Position float64 `json:"position"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NewTaskGroup struct {
|
||||||
|
ProjectID string `json:"projectID"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Position float64 `json:"position"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NewTaskLocation struct {
|
||||||
|
TaskID string `json:"taskID"`
|
||||||
|
TaskGroupID string `json:"taskGroupID"`
|
||||||
|
Position float64 `json:"position"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NewTeam struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
OrganizationID string `json:"organizationID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NewUserAccount struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectsFilter struct {
|
||||||
|
TeamID *string `json:"teamID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateTaskName struct {
|
||||||
|
TaskID string `json:"taskID"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
13
api/graph/resolver.go
Normal file
13
api/graph/resolver.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
//go:generate go run github.com/99designs/gqlgen
|
||||||
|
package graph
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/jordanknott/project-citadel/api/pg"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Resolver struct {
|
||||||
|
Repository pg.Repository
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
23
api/graph/scalars.go
Normal file
23
api/graph/scalars.go
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/99designs/gqlgen/graphql"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func MarshalUUID(uuid uuid.UUID) graphql.Marshaler {
|
||||||
|
return graphql.WriterFunc(func(w io.Writer) {
|
||||||
|
w.Write([]byte(strconv.Quote(uuid.String())))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func UnmarshalUUID(v interface{}) (uuid.UUID, error) {
|
||||||
|
if uuidRaw, ok := v.(string); ok {
|
||||||
|
return uuid.Parse(uuidRaw)
|
||||||
|
}
|
||||||
|
return uuid.UUID{}, errors.New("uuid must be a string")
|
||||||
|
}
|
151
api/graph/schema.graphqls
Normal file
151
api/graph/schema.graphqls
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
scalar Time
|
||||||
|
|
||||||
|
scalar UUID
|
||||||
|
|
||||||
|
type RefreshToken {
|
||||||
|
tokenId: ID!
|
||||||
|
userId: UUID!
|
||||||
|
expiresAt: Time!
|
||||||
|
createdAt: Time!
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserAccount {
|
||||||
|
userID: ID!
|
||||||
|
email: String!
|
||||||
|
createdAt: Time!
|
||||||
|
displayName: String!
|
||||||
|
username: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Organization {
|
||||||
|
organizationID: ID!
|
||||||
|
createdAt: Time!
|
||||||
|
name: String!
|
||||||
|
teams: [Team!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Team {
|
||||||
|
teamID: ID!
|
||||||
|
createdAt: Time!
|
||||||
|
name: String!
|
||||||
|
projects: [Project!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Project {
|
||||||
|
projectID: ID!
|
||||||
|
teamID: String!
|
||||||
|
createdAt: Time!
|
||||||
|
name: String!
|
||||||
|
taskGroups: [TaskGroup!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskGroup {
|
||||||
|
taskGroupID: ID!
|
||||||
|
projectID: String!
|
||||||
|
createdAt: Time!
|
||||||
|
name: String!
|
||||||
|
position: Float!
|
||||||
|
tasks: [Task!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Task {
|
||||||
|
taskID: ID!
|
||||||
|
taskGroupID: String!
|
||||||
|
createdAt: Time!
|
||||||
|
name: String!
|
||||||
|
position: Float!
|
||||||
|
}
|
||||||
|
|
||||||
|
input ProjectsFilter {
|
||||||
|
teamID: String
|
||||||
|
}
|
||||||
|
|
||||||
|
input FindUser {
|
||||||
|
userId: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input FindProject {
|
||||||
|
projectId: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
organizations: [Organization!]!
|
||||||
|
users: [UserAccount!]!
|
||||||
|
findUser(input: FindUser!): UserAccount!
|
||||||
|
findProject(input: FindProject!): Project!
|
||||||
|
teams: [Team!]!
|
||||||
|
projects(input: ProjectsFilter): [Project!]!
|
||||||
|
taskGroups: [TaskGroup!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
input NewRefreshToken {
|
||||||
|
userId: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input NewUserAccount {
|
||||||
|
username: String!
|
||||||
|
email: String!
|
||||||
|
displayName: String!
|
||||||
|
password: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input NewTeam {
|
||||||
|
name: String!
|
||||||
|
organizationID: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input NewProject {
|
||||||
|
teamID: String!
|
||||||
|
name: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input NewTaskGroup {
|
||||||
|
projectID: String!
|
||||||
|
name: String!
|
||||||
|
position: Float!
|
||||||
|
}
|
||||||
|
|
||||||
|
input NewOrganization {
|
||||||
|
name: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input LogoutUser {
|
||||||
|
userID: String!
|
||||||
|
}
|
||||||
|
input NewTask {
|
||||||
|
taskGroupID: String!
|
||||||
|
name: String!
|
||||||
|
position: Float!
|
||||||
|
}
|
||||||
|
input NewTaskLocation {
|
||||||
|
taskID: String!
|
||||||
|
taskGroupID: String!
|
||||||
|
position: Float!
|
||||||
|
}
|
||||||
|
|
||||||
|
input DeleteTaskInput {
|
||||||
|
taskID: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteTaskPayload {
|
||||||
|
taskID: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdateTaskName {
|
||||||
|
taskID: String!
|
||||||
|
name: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mutation {
|
||||||
|
createRefreshToken(input: NewRefreshToken!): RefreshToken!
|
||||||
|
createUserAccount(input: NewUserAccount!): UserAccount!
|
||||||
|
createOrganization(input: NewOrganization!): Organization!
|
||||||
|
createTeam(input: NewTeam!): Team!
|
||||||
|
createProject(input: NewProject!): Project!
|
||||||
|
createTaskGroup(input: NewTaskGroup!): TaskGroup!
|
||||||
|
createTask(input: NewTask!): Task!
|
||||||
|
updateTaskLocation(input: NewTaskLocation!): Task!
|
||||||
|
logoutUser(input: LogoutUser!): Boolean!
|
||||||
|
updateTaskName(input: UpdateTaskName!): Task!
|
||||||
|
deleteTask(input: DeleteTaskInput!): DeleteTaskPayload!
|
||||||
|
}
|
232
api/graph/schema.resolvers.go
Normal file
232
api/graph/schema.resolvers.go
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
package graph
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jordanknott/project-citadel/api/pg"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/vektah/gqlparser/v2/gqlerror"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *mutationResolver) CreateRefreshToken(ctx context.Context, input NewRefreshToken) (*pg.RefreshToken, error) {
|
||||||
|
userID := uuid.MustParse("0183d9ab-d0ed-4c9b-a3df-77a0cdd93dca")
|
||||||
|
refreshCreatedAt := time.Now().UTC()
|
||||||
|
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
|
||||||
|
refreshToken, err := r.Repository.CreateRefreshToken(ctx, pg.CreateRefreshTokenParams{userID, refreshCreatedAt, refreshExpiresAt})
|
||||||
|
return &refreshToken, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) CreateUserAccount(ctx context.Context, input NewUserAccount) (*pg.UserAccount, error) {
|
||||||
|
createdAt := time.Now().UTC()
|
||||||
|
userAccount, err := r.Repository.CreateUserAccount(ctx, pg.CreateUserAccountParams{input.Username, input.Email, input.DisplayName, createdAt, input.Password})
|
||||||
|
return &userAccount, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) CreateOrganization(ctx context.Context, input NewOrganization) (*pg.Organization, error) {
|
||||||
|
createdAt := time.Now().UTC()
|
||||||
|
organization, err := r.Repository.CreateOrganization(ctx, pg.CreateOrganizationParams{createdAt, input.Name})
|
||||||
|
return &organization, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) CreateTeam(ctx context.Context, input NewTeam) (*pg.Team, error) {
|
||||||
|
organizationID, err := uuid.Parse(input.OrganizationID)
|
||||||
|
if err != nil {
|
||||||
|
return &pg.Team{}, err
|
||||||
|
}
|
||||||
|
createdAt := time.Now().UTC()
|
||||||
|
team, err := r.Repository.CreateTeam(ctx, pg.CreateTeamParams{organizationID, createdAt, input.Name})
|
||||||
|
return &team, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) CreateProject(ctx context.Context, input NewProject) (*pg.Project, error) {
|
||||||
|
createdAt := time.Now().UTC()
|
||||||
|
teamID, err := uuid.Parse(input.TeamID)
|
||||||
|
if err != nil {
|
||||||
|
return &pg.Project{}, err
|
||||||
|
}
|
||||||
|
project, err := r.Repository.CreateProject(ctx, pg.CreateProjectParams{teamID, createdAt, input.Name})
|
||||||
|
return &project, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) CreateTaskGroup(ctx context.Context, input NewTaskGroup) (*pg.TaskGroup, error) {
|
||||||
|
createdAt := time.Now().UTC()
|
||||||
|
projectID, err := uuid.Parse(input.ProjectID)
|
||||||
|
if err != nil {
|
||||||
|
return &pg.TaskGroup{}, err
|
||||||
|
}
|
||||||
|
project, err := r.Repository.CreateTaskGroup(ctx,
|
||||||
|
pg.CreateTaskGroupParams{projectID, createdAt, input.Name, input.Position})
|
||||||
|
return &project, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) CreateTask(ctx context.Context, input NewTask) (*pg.Task, error) {
|
||||||
|
taskGroupID, err := uuid.Parse(input.TaskGroupID)
|
||||||
|
createdAt := time.Now().UTC()
|
||||||
|
if err != nil {
|
||||||
|
return &pg.Task{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := r.Repository.CreateTask(ctx, pg.CreateTaskParams{taskGroupID, createdAt, input.Name, input.Position})
|
||||||
|
return &task, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) UpdateTaskLocation(ctx context.Context, input NewTaskLocation) (*pg.Task, error) {
|
||||||
|
taskID, err := uuid.Parse(input.TaskID)
|
||||||
|
if err != nil {
|
||||||
|
return &pg.Task{}, err
|
||||||
|
}
|
||||||
|
taskGroupID, err := uuid.Parse(input.TaskGroupID)
|
||||||
|
if err != nil {
|
||||||
|
return &pg.Task{}, err
|
||||||
|
}
|
||||||
|
task, err := r.Repository.UpdateTaskLocation(ctx, pg.UpdateTaskLocationParams{taskID, taskGroupID, input.Position})
|
||||||
|
|
||||||
|
return &task, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) LogoutUser(ctx context.Context, input LogoutUser) (bool, error) {
|
||||||
|
userID, err := uuid.Parse(input.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = r.Repository.DeleteRefreshTokenByUserID(ctx, userID)
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) UpdateTaskName(ctx context.Context, input UpdateTaskName) (*pg.Task, error) {
|
||||||
|
taskID, err := uuid.Parse(input.TaskID)
|
||||||
|
if err != nil {
|
||||||
|
return &pg.Task{}, err
|
||||||
|
}
|
||||||
|
task, err := r.Repository.UpdateTaskName(ctx, pg.UpdateTaskNameParams{taskID, input.Name})
|
||||||
|
return &task, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) DeleteTask(ctx context.Context, input DeleteTaskInput) (*DeleteTaskPayload, error) {
|
||||||
|
taskID, err := uuid.Parse(input.TaskID)
|
||||||
|
if err != nil {
|
||||||
|
return &DeleteTaskPayload{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"taskID": taskID.String(),
|
||||||
|
}).Info("deleting task")
|
||||||
|
err = r.Repository.DeleteTaskByID(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return &DeleteTaskPayload{}, err
|
||||||
|
}
|
||||||
|
return &DeleteTaskPayload{taskID.String()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *organizationResolver) Teams(ctx context.Context, obj *pg.Organization) ([]pg.Team, error) {
|
||||||
|
teams, err := r.Repository.GetTeamsForOrganization(ctx, obj.OrganizationID)
|
||||||
|
return teams, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *projectResolver) TeamID(ctx context.Context, obj *pg.Project) (string, error) {
|
||||||
|
return obj.TeamID.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *projectResolver) TaskGroups(ctx context.Context, obj *pg.Project) ([]pg.TaskGroup, error) {
|
||||||
|
return r.Repository.GetTaskGroupsForProject(ctx, obj.ProjectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *queryResolver) Organizations(ctx context.Context) ([]pg.Organization, error) {
|
||||||
|
return r.Repository.GetAllOrganizations(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *queryResolver) Users(ctx context.Context) ([]pg.UserAccount, error) {
|
||||||
|
return r.Repository.GetAllUserAccounts(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *queryResolver) FindUser(ctx context.Context, input FindUser) (*pg.UserAccount, error) {
|
||||||
|
userId, err := uuid.Parse(input.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return &pg.UserAccount{}, err
|
||||||
|
}
|
||||||
|
account, err := r.Repository.GetUserAccountByID(ctx, userId)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return &pg.UserAccount{}, &gqlerror.Error{
|
||||||
|
Message: "User not found",
|
||||||
|
Extensions: map[string]interface{}{
|
||||||
|
"code": "10-404",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &account, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *queryResolver) FindProject(ctx context.Context, input FindProject) (*pg.Project, error) {
|
||||||
|
projectID, err := uuid.Parse(input.ProjectID)
|
||||||
|
if err != nil {
|
||||||
|
return &pg.Project{}, err
|
||||||
|
}
|
||||||
|
project, err := r.Repository.GetProjectByID(ctx, projectID)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return &pg.Project{}, &gqlerror.Error{
|
||||||
|
Message: "Project not found",
|
||||||
|
Extensions: map[string]interface{}{
|
||||||
|
"code": "11-404",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &project, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *queryResolver) Teams(ctx context.Context) ([]pg.Team, error) {
|
||||||
|
return r.Repository.GetAllTeams(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *queryResolver) Projects(ctx context.Context, input *ProjectsFilter) ([]pg.Project, error) {
|
||||||
|
if input != nil {
|
||||||
|
teamID, err := uuid.Parse(*input.TeamID)
|
||||||
|
if err != nil {
|
||||||
|
return []pg.Project{}, err
|
||||||
|
}
|
||||||
|
return r.Repository.GetAllProjectsForTeam(ctx, teamID)
|
||||||
|
}
|
||||||
|
return r.Repository.GetAllProjects(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *queryResolver) TaskGroups(ctx context.Context) ([]pg.TaskGroup, error) {
|
||||||
|
return r.Repository.GetAllTaskGroups(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *taskResolver) TaskGroupID(ctx context.Context, obj *pg.Task) (string, error) {
|
||||||
|
return obj.TaskGroupID.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *taskGroupResolver) ProjectID(ctx context.Context, obj *pg.TaskGroup) (string, error) {
|
||||||
|
return obj.ProjectID.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *taskGroupResolver) Tasks(ctx context.Context, obj *pg.TaskGroup) ([]pg.Task, error) {
|
||||||
|
tasks, err := r.Repository.GetTasksForTaskGroupID(ctx, obj.TaskGroupID)
|
||||||
|
return tasks, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *teamResolver) Projects(ctx context.Context, obj *pg.Team) ([]pg.Project, error) {
|
||||||
|
return r.Repository.GetAllProjectsForTeam(ctx, obj.TeamID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
|
||||||
|
func (r *Resolver) Organization() OrganizationResolver { return &organizationResolver{r} }
|
||||||
|
func (r *Resolver) Project() ProjectResolver { return &projectResolver{r} }
|
||||||
|
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
|
||||||
|
func (r *Resolver) Task() TaskResolver { return &taskResolver{r} }
|
||||||
|
func (r *Resolver) TaskGroup() TaskGroupResolver { return &taskGroupResolver{r} }
|
||||||
|
func (r *Resolver) Team() TeamResolver { return &teamResolver{r} }
|
||||||
|
|
||||||
|
type mutationResolver struct{ *Resolver }
|
||||||
|
type organizationResolver struct{ *Resolver }
|
||||||
|
type projectResolver struct{ *Resolver }
|
||||||
|
type queryResolver struct{ *Resolver }
|
||||||
|
type taskResolver struct{ *Resolver }
|
||||||
|
type taskGroupResolver struct{ *Resolver }
|
||||||
|
type teamResolver struct{ *Resolver }
|
6
api/migrations/0001_add-refresh-token-table.up.sql
Normal file
6
api/migrations/0001_add-refresh-token-table.up.sql
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE refresh_token (
|
||||||
|
token_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id uuid NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
expires_at timestamptz NOT NULL
|
||||||
|
);
|
8
api/migrations/0002_add-users-table.up.sql
Normal file
8
api/migrations/0002_add-users-table.up.sql
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE user_account (
|
||||||
|
user_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
display_name text NOT NULL,
|
||||||
|
email text NOT NULL UNIQUE,
|
||||||
|
username text NOT NULL UNIQUE,
|
||||||
|
password_hash text NOT NULL
|
||||||
|
);
|
5
api/migrations/0003_add-team-table.up.sql
Normal file
5
api/migrations/0003_add-team-table.up.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE team (
|
||||||
|
team_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
name text NOT NULL
|
||||||
|
);
|
6
api/migrations/0004_add-project-table.up.sql
Normal file
6
api/migrations/0004_add-project-table.up.sql
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE project (
|
||||||
|
project_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
team_id uuid NOT NULL REFERENCES team(team_id),
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
name text NOT NULL
|
||||||
|
);
|
7
api/migrations/0005_add-task-group-table.up.sql
Normal file
7
api/migrations/0005_add-task-group-table.up.sql
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE task_group (
|
||||||
|
task_group_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
project_id uuid NOT NULL REFERENCES project(project_id),
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
position float NOT NULL
|
||||||
|
);
|
7
api/migrations/0006_add-task.up.sql
Normal file
7
api/migrations/0006_add-task.up.sql
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE task (
|
||||||
|
task_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
task_group_id uuid NOT NULL REFERENCES task_group(task_group_id),
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
position float NOT NULL
|
||||||
|
);
|
5
api/migrations/0007_add-organization-table.up.sql
Normal file
5
api/migrations/0007_add-organization-table.up.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE organization (
|
||||||
|
organization_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
name text NOT NULL
|
||||||
|
);
|
1
api/migrations/0008_add-org-id-to-team-table.up.sql
Normal file
1
api/migrations/0008_add-org-id-to-team-table.up.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE team ADD COLUMN organization_id uuid NOT NULL REFERENCES organization(organization_id);
|
29
api/pg/db.go
Normal file
29
api/pg/db.go
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
|
||||||
|
package pg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DBTX interface {
|
||||||
|
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
|
||||||
|
PrepareContext(context.Context, string) (*sql.Stmt, error)
|
||||||
|
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
|
||||||
|
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db DBTX) *Queries {
|
||||||
|
return &Queries{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Queries struct {
|
||||||
|
db DBTX
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
|
||||||
|
return &Queries{
|
||||||
|
db: tx,
|
||||||
|
}
|
||||||
|
}
|
61
api/pg/models.go
Normal file
61
api/pg/models.go
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
|
||||||
|
package pg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Organization struct {
|
||||||
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Project struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
TeamID uuid.UUID `json:"team_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RefreshToken struct {
|
||||||
|
TokenID uuid.UUID `json:"token_id"`
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Task struct {
|
||||||
|
TaskID uuid.UUID `json:"task_id"`
|
||||||
|
TaskGroupID uuid.UUID `json:"task_group_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Position float64 `json:"position"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskGroup struct {
|
||||||
|
TaskGroupID uuid.UUID `json:"task_group_id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Position float64 `json:"position"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Team struct {
|
||||||
|
TeamID uuid.UUID `json:"team_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserAccount struct {
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
PasswordHash string `json:"password_hash"`
|
||||||
|
}
|
52
api/pg/organization.sql.go
Normal file
52
api/pg/organization.sql.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// source: organization.sql
|
||||||
|
|
||||||
|
package pg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createOrganization = `-- name: CreateOrganization :one
|
||||||
|
INSERT INTO organization (created_at, name) VALUES ($1, $2) RETURNING organization_id, created_at, name
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateOrganizationParams struct {
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (Organization, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, createOrganization, arg.CreatedAt, arg.Name)
|
||||||
|
var i Organization
|
||||||
|
err := row.Scan(&i.OrganizationID, &i.CreatedAt, &i.Name)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAllOrganizations = `-- name: GetAllOrganizations :many
|
||||||
|
SELECT organization_id, created_at, name FROM organization
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetAllOrganizations(ctx context.Context) ([]Organization, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getAllOrganizations)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Organization
|
||||||
|
for rows.Next() {
|
||||||
|
var i Organization
|
||||||
|
if err := rows.Scan(&i.OrganizationID, &i.CreatedAt, &i.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
51
api/pg/pg.go
Normal file
51
api/pg/pg.go
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
package pg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
CreateTeam(ctx context.Context, arg CreateTeamParams) (Team, error)
|
||||||
|
DeleteTeamByID(ctx context.Context, teamID uuid.UUID) error
|
||||||
|
GetTeamByID(ctx context.Context, teamID uuid.UUID) (Team, error)
|
||||||
|
GetAllTeams(ctx context.Context) ([]Team, error)
|
||||||
|
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
|
||||||
|
GetAllProjects(ctx context.Context) ([]Project, error)
|
||||||
|
GetAllProjectsForTeam(ctx context.Context, teamID uuid.UUID) ([]Project, error)
|
||||||
|
GetProjectByID(ctx context.Context, projectID uuid.UUID) (Project, error)
|
||||||
|
GetAllUserAccounts(ctx context.Context) ([]UserAccount, error)
|
||||||
|
GetUserAccountByID(ctx context.Context, userID uuid.UUID) (UserAccount, error)
|
||||||
|
CreateUserAccount(ctx context.Context, arg CreateUserAccountParams) (UserAccount, error)
|
||||||
|
GetUserAccountByUsername(ctx context.Context, username string) (UserAccount, error)
|
||||||
|
CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error)
|
||||||
|
GetRefreshTokenByID(ctx context.Context, tokenID uuid.UUID) (RefreshToken, error)
|
||||||
|
DeleteRefreshTokenByID(ctx context.Context, tokenID uuid.UUID) error
|
||||||
|
DeleteRefreshTokenByUserID(ctx context.Context, userID uuid.UUID) error
|
||||||
|
CreateTaskGroup(ctx context.Context, arg CreateTaskGroupParams) (TaskGroup, error)
|
||||||
|
GetAllTaskGroups(ctx context.Context) ([]TaskGroup, error)
|
||||||
|
GetAllOrganizations(ctx context.Context) ([]Organization, error)
|
||||||
|
GetTaskGroupsForProject(ctx context.Context, projectID uuid.UUID) ([]TaskGroup, error)
|
||||||
|
CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (Organization, error)
|
||||||
|
GetTeamsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]Team, error)
|
||||||
|
CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error)
|
||||||
|
GetAllTasks(ctx context.Context) ([]Task, error)
|
||||||
|
GetTasksForTaskGroupID(ctx context.Context, taskGroupID uuid.UUID) ([]Task, error)
|
||||||
|
UpdateTaskLocation(ctx context.Context, arg UpdateTaskLocationParams) (Task, error)
|
||||||
|
DeleteTaskByID(ctx context.Context, taskID uuid.UUID) error
|
||||||
|
UpdateTaskName(ctx context.Context, arg UpdateTaskNameParams) (Task, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type repoSvc struct {
|
||||||
|
*Queries
|
||||||
|
db *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRepository returns an implementation of the Repository interface.
|
||||||
|
func NewRepository(db *sqlx.DB) Repository {
|
||||||
|
return &repoSvc{
|
||||||
|
Queries: New(db.DB),
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
}
|
113
api/pg/project.sql.go
Normal file
113
api/pg/project.sql.go
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// source: project.sql
|
||||||
|
|
||||||
|
package pg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createProject = `-- name: CreateProject :one
|
||||||
|
INSERT INTO project(team_id, created_at, name) VALUES ($1, $2, $3) RETURNING project_id, team_id, created_at, name
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateProjectParams struct {
|
||||||
|
TeamID uuid.UUID `json:"team_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, createProject, arg.TeamID, arg.CreatedAt, arg.Name)
|
||||||
|
var i Project
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.TeamID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Name,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAllProjects = `-- name: GetAllProjects :many
|
||||||
|
SELECT project_id, team_id, created_at, name FROM project
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetAllProjects(ctx context.Context) ([]Project, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getAllProjects)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Project
|
||||||
|
for rows.Next() {
|
||||||
|
var i Project
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.TeamID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Name,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAllProjectsForTeam = `-- name: GetAllProjectsForTeam :many
|
||||||
|
SELECT project_id, team_id, created_at, name FROM project WHERE team_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetAllProjectsForTeam(ctx context.Context, teamID uuid.UUID) ([]Project, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getAllProjectsForTeam, teamID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Project
|
||||||
|
for rows.Next() {
|
||||||
|
var i Project
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.TeamID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Name,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProjectByID = `-- name: GetProjectByID :one
|
||||||
|
SELECT project_id, team_id, created_at, name FROM project WHERE project_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetProjectByID(ctx context.Context, projectID uuid.UUID) (Project, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getProjectByID, projectID)
|
||||||
|
var i Project
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.TeamID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Name,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
43
api/pg/querier.go
Normal file
43
api/pg/querier.go
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
|
||||||
|
package pg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Querier interface {
|
||||||
|
CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (Organization, error)
|
||||||
|
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
|
||||||
|
CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error)
|
||||||
|
CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error)
|
||||||
|
CreateTaskGroup(ctx context.Context, arg CreateTaskGroupParams) (TaskGroup, error)
|
||||||
|
CreateTeam(ctx context.Context, arg CreateTeamParams) (Team, error)
|
||||||
|
CreateUserAccount(ctx context.Context, arg CreateUserAccountParams) (UserAccount, error)
|
||||||
|
DeleteExpiredTokens(ctx context.Context) error
|
||||||
|
DeleteRefreshTokenByID(ctx context.Context, tokenID uuid.UUID) error
|
||||||
|
DeleteRefreshTokenByUserID(ctx context.Context, userID uuid.UUID) error
|
||||||
|
DeleteTaskByID(ctx context.Context, taskID uuid.UUID) error
|
||||||
|
DeleteTeamByID(ctx context.Context, teamID uuid.UUID) error
|
||||||
|
GetAllOrganizations(ctx context.Context) ([]Organization, error)
|
||||||
|
GetAllProjects(ctx context.Context) ([]Project, error)
|
||||||
|
GetAllProjectsForTeam(ctx context.Context, teamID uuid.UUID) ([]Project, error)
|
||||||
|
GetAllTaskGroups(ctx context.Context) ([]TaskGroup, error)
|
||||||
|
GetAllTasks(ctx context.Context) ([]Task, error)
|
||||||
|
GetAllTeams(ctx context.Context) ([]Team, error)
|
||||||
|
GetAllUserAccounts(ctx context.Context) ([]UserAccount, error)
|
||||||
|
GetProjectByID(ctx context.Context, projectID uuid.UUID) (Project, error)
|
||||||
|
GetRefreshTokenByID(ctx context.Context, tokenID uuid.UUID) (RefreshToken, error)
|
||||||
|
GetTaskGroupsForProject(ctx context.Context, projectID uuid.UUID) ([]TaskGroup, error)
|
||||||
|
GetTasksForTaskGroupID(ctx context.Context, taskGroupID uuid.UUID) ([]Task, error)
|
||||||
|
GetTeamByID(ctx context.Context, teamID uuid.UUID) (Team, error)
|
||||||
|
GetTeamsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]Team, error)
|
||||||
|
GetUserAccountByID(ctx context.Context, userID uuid.UUID) (UserAccount, error)
|
||||||
|
GetUserAccountByUsername(ctx context.Context, username string) (UserAccount, error)
|
||||||
|
UpdateTaskLocation(ctx context.Context, arg UpdateTaskLocationParams) (Task, error)
|
||||||
|
UpdateTaskName(ctx context.Context, arg UpdateTaskNameParams) (Task, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Querier = (*Queries)(nil)
|
161
api/pg/task.sql.go
Normal file
161
api/pg/task.sql.go
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// source: task.sql
|
||||||
|
|
||||||
|
package pg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createTask = `-- name: CreateTask :one
|
||||||
|
INSERT INTO task (task_group_id, created_at, name, position)
|
||||||
|
VALUES($1, $2, $3, $4) RETURNING task_id, task_group_id, created_at, name, position
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateTaskParams struct {
|
||||||
|
TaskGroupID uuid.UUID `json:"task_group_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Position float64 `json:"position"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, createTask,
|
||||||
|
arg.TaskGroupID,
|
||||||
|
arg.CreatedAt,
|
||||||
|
arg.Name,
|
||||||
|
arg.Position,
|
||||||
|
)
|
||||||
|
var i Task
|
||||||
|
err := row.Scan(
|
||||||
|
&i.TaskID,
|
||||||
|
&i.TaskGroupID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Name,
|
||||||
|
&i.Position,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteTaskByID = `-- name: DeleteTaskByID :exec
|
||||||
|
DELETE FROM task WHERE task_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteTaskByID(ctx context.Context, taskID uuid.UUID) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deleteTaskByID, taskID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAllTasks = `-- name: GetAllTasks :many
|
||||||
|
SELECT task_id, task_group_id, created_at, name, position FROM task
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetAllTasks(ctx context.Context) ([]Task, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getAllTasks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Task
|
||||||
|
for rows.Next() {
|
||||||
|
var i Task
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.TaskID,
|
||||||
|
&i.TaskGroupID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Name,
|
||||||
|
&i.Position,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTasksForTaskGroupID = `-- name: GetTasksForTaskGroupID :many
|
||||||
|
SELECT task_id, task_group_id, created_at, name, position FROM task WHERE task_group_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetTasksForTaskGroupID(ctx context.Context, taskGroupID uuid.UUID) ([]Task, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getTasksForTaskGroupID, taskGroupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Task
|
||||||
|
for rows.Next() {
|
||||||
|
var i Task
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.TaskID,
|
||||||
|
&i.TaskGroupID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Name,
|
||||||
|
&i.Position,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTaskLocation = `-- name: UpdateTaskLocation :one
|
||||||
|
UPDATE task SET task_group_id = $2, position = $3 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateTaskLocationParams struct {
|
||||||
|
TaskID uuid.UUID `json:"task_id"`
|
||||||
|
TaskGroupID uuid.UUID `json:"task_group_id"`
|
||||||
|
Position float64 `json:"position"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateTaskLocation(ctx context.Context, arg UpdateTaskLocationParams) (Task, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, updateTaskLocation, arg.TaskID, arg.TaskGroupID, arg.Position)
|
||||||
|
var i Task
|
||||||
|
err := row.Scan(
|
||||||
|
&i.TaskID,
|
||||||
|
&i.TaskGroupID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Name,
|
||||||
|
&i.Position,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTaskName = `-- name: UpdateTaskName :one
|
||||||
|
UPDATE task SET name = $2 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateTaskNameParams struct {
|
||||||
|
TaskID uuid.UUID `json:"task_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateTaskName(ctx context.Context, arg UpdateTaskNameParams) (Task, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, updateTaskName, arg.TaskID, arg.Name)
|
||||||
|
var i Task
|
||||||
|
err := row.Scan(
|
||||||
|
&i.TaskID,
|
||||||
|
&i.TaskGroupID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Name,
|
||||||
|
&i.Position,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
107
api/pg/task_group.sql.go
Normal file
107
api/pg/task_group.sql.go
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// source: task_group.sql
|
||||||
|
|
||||||
|
package pg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createTaskGroup = `-- name: CreateTaskGroup :one
|
||||||
|
INSERT INTO task_group (project_id, created_at, name, position)
|
||||||
|
VALUES($1, $2, $3, $4) RETURNING task_group_id, project_id, created_at, name, position
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateTaskGroupParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Position float64 `json:"position"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateTaskGroup(ctx context.Context, arg CreateTaskGroupParams) (TaskGroup, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, createTaskGroup,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.CreatedAt,
|
||||||
|
arg.Name,
|
||||||
|
arg.Position,
|
||||||
|
)
|
||||||
|
var i TaskGroup
|
||||||
|
err := row.Scan(
|
||||||
|
&i.TaskGroupID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Name,
|
||||||
|
&i.Position,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAllTaskGroups = `-- name: GetAllTaskGroups :many
|
||||||
|
SELECT task_group_id, project_id, created_at, name, position FROM task_group
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetAllTaskGroups(ctx context.Context) ([]TaskGroup, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getAllTaskGroups)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []TaskGroup
|
||||||
|
for rows.Next() {
|
||||||
|
var i TaskGroup
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.TaskGroupID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Name,
|
||||||
|
&i.Position,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTaskGroupsForProject = `-- name: GetTaskGroupsForProject :many
|
||||||
|
SELECT task_group_id, project_id, created_at, name, position FROM task_group WHERE project_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetTaskGroupsForProject(ctx context.Context, projectID uuid.UUID) ([]TaskGroup, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getTaskGroupsForProject, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []TaskGroup
|
||||||
|
for rows.Next() {
|
||||||
|
var i TaskGroup
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.TaskGroupID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Name,
|
||||||
|
&i.Position,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
122
api/pg/team.sql.go
Normal file
122
api/pg/team.sql.go
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// source: team.sql
|
||||||
|
|
||||||
|
package pg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createTeam = `-- name: CreateTeam :one
|
||||||
|
INSERT INTO team (organization_id, created_at, name) VALUES ($1, $2, $3) RETURNING team_id, created_at, name, organization_id
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateTeamParams struct {
|
||||||
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateTeam(ctx context.Context, arg CreateTeamParams) (Team, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, createTeam, arg.OrganizationID, arg.CreatedAt, arg.Name)
|
||||||
|
var i Team
|
||||||
|
err := row.Scan(
|
||||||
|
&i.TeamID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Name,
|
||||||
|
&i.OrganizationID,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteTeamByID = `-- name: DeleteTeamByID :exec
|
||||||
|
DELETE FROM team WHERE team_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteTeamByID(ctx context.Context, teamID uuid.UUID) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deleteTeamByID, teamID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAllTeams = `-- name: GetAllTeams :many
|
||||||
|
SELECT team_id, created_at, name, organization_id FROM team
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetAllTeams(ctx context.Context) ([]Team, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getAllTeams)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Team
|
||||||
|
for rows.Next() {
|
||||||
|
var i Team
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.TeamID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Name,
|
||||||
|
&i.OrganizationID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTeamByID = `-- name: GetTeamByID :one
|
||||||
|
SELECT team_id, created_at, name, organization_id FROM team WHERE team_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetTeamByID(ctx context.Context, teamID uuid.UUID) (Team, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getTeamByID, teamID)
|
||||||
|
var i Team
|
||||||
|
err := row.Scan(
|
||||||
|
&i.TeamID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Name,
|
||||||
|
&i.OrganizationID,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTeamsForOrganization = `-- name: GetTeamsForOrganization :many
|
||||||
|
SELECT team_id, created_at, name, organization_id FROM team WHERE organization_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetTeamsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]Team, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getTeamsForOrganization, organizationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Team
|
||||||
|
for rows.Next() {
|
||||||
|
var i Team
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.TeamID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Name,
|
||||||
|
&i.OrganizationID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
76
api/pg/token.sql.go
Normal file
76
api/pg/token.sql.go
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// source: token.sql
|
||||||
|
|
||||||
|
package pg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createRefreshToken = `-- name: CreateRefreshToken :one
|
||||||
|
INSERT INTO refresh_token (user_id, created_at, expires_at) VALUES ($1, $2, $3) RETURNING token_id, user_id, created_at, expires_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateRefreshTokenParams struct {
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, createRefreshToken, arg.UserID, arg.CreatedAt, arg.ExpiresAt)
|
||||||
|
var i RefreshToken
|
||||||
|
err := row.Scan(
|
||||||
|
&i.TokenID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteExpiredTokens = `-- name: DeleteExpiredTokens :exec
|
||||||
|
DELETE FROM refresh_token WHERE expires_at <= NOW()
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteExpiredTokens(ctx context.Context) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deleteExpiredTokens)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteRefreshTokenByID = `-- name: DeleteRefreshTokenByID :exec
|
||||||
|
DELETE FROM refresh_token WHERE token_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteRefreshTokenByID(ctx context.Context, tokenID uuid.UUID) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deleteRefreshTokenByID, tokenID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteRefreshTokenByUserID = `-- name: DeleteRefreshTokenByUserID :exec
|
||||||
|
DELETE FROM refresh_token WHERE user_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteRefreshTokenByUserID(ctx context.Context, userID uuid.UUID) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deleteRefreshTokenByUserID, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRefreshTokenByID = `-- name: GetRefreshTokenByID :one
|
||||||
|
SELECT token_id, user_id, created_at, expires_at FROM refresh_token WHERE token_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetRefreshTokenByID(ctx context.Context, tokenID uuid.UUID) (RefreshToken, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getRefreshTokenByID, tokenID)
|
||||||
|
var i RefreshToken
|
||||||
|
err := row.Scan(
|
||||||
|
&i.TokenID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
115
api/pg/user_accounts.sql.go
Normal file
115
api/pg/user_accounts.sql.go
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// source: user_accounts.sql
|
||||||
|
|
||||||
|
package pg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createUserAccount = `-- name: CreateUserAccount :one
|
||||||
|
INSERT INTO user_account(display_name, email, username, created_at, password_hash)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING user_id, created_at, display_name, email, username, password_hash
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateUserAccountParams struct {
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
PasswordHash string `json:"password_hash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateUserAccount(ctx context.Context, arg CreateUserAccountParams) (UserAccount, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, createUserAccount,
|
||||||
|
arg.DisplayName,
|
||||||
|
arg.Email,
|
||||||
|
arg.Username,
|
||||||
|
arg.CreatedAt,
|
||||||
|
arg.PasswordHash,
|
||||||
|
)
|
||||||
|
var i UserAccount
|
||||||
|
err := row.Scan(
|
||||||
|
&i.UserID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.DisplayName,
|
||||||
|
&i.Email,
|
||||||
|
&i.Username,
|
||||||
|
&i.PasswordHash,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAllUserAccounts = `-- name: GetAllUserAccounts :many
|
||||||
|
SELECT user_id, created_at, display_name, email, username, password_hash FROM user_account
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetAllUserAccounts(ctx context.Context) ([]UserAccount, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getAllUserAccounts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []UserAccount
|
||||||
|
for rows.Next() {
|
||||||
|
var i UserAccount
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.UserID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.DisplayName,
|
||||||
|
&i.Email,
|
||||||
|
&i.Username,
|
||||||
|
&i.PasswordHash,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUserAccountByID = `-- name: GetUserAccountByID :one
|
||||||
|
SELECT user_id, created_at, display_name, email, username, password_hash FROM user_account WHERE user_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetUserAccountByID(ctx context.Context, userID uuid.UUID) (UserAccount, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getUserAccountByID, userID)
|
||||||
|
var i UserAccount
|
||||||
|
err := row.Scan(
|
||||||
|
&i.UserID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.DisplayName,
|
||||||
|
&i.Email,
|
||||||
|
&i.Username,
|
||||||
|
&i.PasswordHash,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUserAccountByUsername = `-- name: GetUserAccountByUsername :one
|
||||||
|
SELECT user_id, created_at, display_name, email, username, password_hash FROM user_account WHERE username = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetUserAccountByUsername(ctx context.Context, username string) (UserAccount, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getUserAccountByUsername, username)
|
||||||
|
var i UserAccount
|
||||||
|
err := row.Scan(
|
||||||
|
&i.UserID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.DisplayName,
|
||||||
|
&i.Email,
|
||||||
|
&i.Username,
|
||||||
|
&i.PasswordHash,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
5
api/query/organization.sql
Normal file
5
api/query/organization.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
-- name: GetAllOrganizations :many
|
||||||
|
SELECT * FROM organization;
|
||||||
|
|
||||||
|
-- name: CreateOrganization :one
|
||||||
|
INSERT INTO organization (created_at, name) VALUES ($1, $2) RETURNING *;
|
11
api/query/project.sql
Normal file
11
api/query/project.sql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
-- name: GetAllProjects :many
|
||||||
|
SELECT * FROM project;
|
||||||
|
|
||||||
|
-- name: GetAllProjectsForTeam :many
|
||||||
|
SELECT * FROM project WHERE team_id = $1;
|
||||||
|
|
||||||
|
-- name: GetProjectByID :one
|
||||||
|
SELECT * FROM project WHERE project_id = $1;
|
||||||
|
|
||||||
|
-- name: CreateProject :one
|
||||||
|
INSERT INTO project(team_id, created_at, name) VALUES ($1, $2, $3) RETURNING *;
|
18
api/query/task.sql
Normal file
18
api/query/task.sql
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
-- name: CreateTask :one
|
||||||
|
INSERT INTO task (task_group_id, created_at, name, position)
|
||||||
|
VALUES($1, $2, $3, $4) RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetTasksForTaskGroupID :many
|
||||||
|
SELECT * FROM task WHERE task_group_id = $1;
|
||||||
|
|
||||||
|
-- name: GetAllTasks :many
|
||||||
|
SELECT * FROM task;
|
||||||
|
|
||||||
|
-- name: UpdateTaskLocation :one
|
||||||
|
UPDATE task SET task_group_id = $2, position = $3 WHERE task_id = $1 RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteTaskByID :exec
|
||||||
|
DELETE FROM task WHERE task_id = $1;
|
||||||
|
|
||||||
|
-- name: UpdateTaskName :one
|
||||||
|
UPDATE task SET name = $2 WHERE task_id = $1 RETURNING *;
|
9
api/query/task_group.sql
Normal file
9
api/query/task_group.sql
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
-- name: CreateTaskGroup :one
|
||||||
|
INSERT INTO task_group (project_id, created_at, name, position)
|
||||||
|
VALUES($1, $2, $3, $4) RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetTaskGroupsForProject :many
|
||||||
|
SELECT * FROM task_group WHERE project_id = $1;
|
||||||
|
|
||||||
|
-- name: GetAllTaskGroups :many
|
||||||
|
SELECT * FROM task_group;
|
14
api/query/team.sql
Normal file
14
api/query/team.sql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
-- name: GetAllTeams :many
|
||||||
|
SELECT * FROM team;
|
||||||
|
|
||||||
|
-- name: GetTeamByID :one
|
||||||
|
SELECT * FROM team WHERE team_id = $1;
|
||||||
|
|
||||||
|
-- name: CreateTeam :one
|
||||||
|
INSERT INTO team (organization_id, created_at, name) VALUES ($1, $2, $3) RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteTeamByID :exec
|
||||||
|
DELETE FROM team WHERE team_id = $1;
|
||||||
|
|
||||||
|
-- name: GetTeamsForOrganization :many
|
||||||
|
SELECT * FROM team WHERE organization_id = $1;
|
14
api/query/token.sql
Normal file
14
api/query/token.sql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
-- name: GetRefreshTokenByID :one
|
||||||
|
SELECT * FROM refresh_token WHERE token_id = $1;
|
||||||
|
|
||||||
|
-- name: CreateRefreshToken :one
|
||||||
|
INSERT INTO refresh_token (user_id, created_at, expires_at) VALUES ($1, $2, $3) RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteRefreshTokenByID :exec
|
||||||
|
DELETE FROM refresh_token WHERE token_id = $1;
|
||||||
|
|
||||||
|
-- name: DeleteRefreshTokenByUserID :exec
|
||||||
|
DELETE FROM refresh_token WHERE user_id = $1;
|
||||||
|
|
||||||
|
-- name: DeleteExpiredTokens :exec
|
||||||
|
DELETE FROM refresh_token WHERE expires_at <= NOW();
|
13
api/query/user_accounts.sql
Normal file
13
api/query/user_accounts.sql
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
-- name: GetUserAccountByID :one
|
||||||
|
SELECT * FROM user_account WHERE user_id = $1;
|
||||||
|
|
||||||
|
-- name: GetAllUserAccounts :many
|
||||||
|
SELECT * FROM user_account;
|
||||||
|
|
||||||
|
-- name: GetUserAccountByUsername :one
|
||||||
|
SELECT * FROM user_account WHERE username = $1;
|
||||||
|
|
||||||
|
-- name: CreateUserAccount :one
|
||||||
|
INSERT INTO user_account(display_name, email, username, created_at, password_hash)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING *;
|
113
api/router/auth.go
Normal file
113
api/router/auth.go
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jordanknott/project-citadel/api/pg"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var jwtKey = []byte("citadel_test_key")
|
||||||
|
|
||||||
|
type authResource struct{}
|
||||||
|
|
||||||
|
func (h *CitadelHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := r.Cookie("refreshToken")
|
||||||
|
if err != nil {
|
||||||
|
if err == http.ErrNoCookie {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshTokenID := uuid.MustParse(c.Value)
|
||||||
|
token, err := h.repo.GetRefreshTokenByID(r.Context(), refreshTokenID)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshCreatedAt := time.Now().UTC()
|
||||||
|
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
|
||||||
|
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), pg.CreateRefreshTokenParams{token.UserID, refreshCreatedAt, refreshExpiresAt})
|
||||||
|
|
||||||
|
err = h.repo.DeleteRefreshTokenByID(r.Context(), token.TokenID)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
accessTokenString, err := NewAccessToken("1")
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-type", "application/json")
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "refreshToken",
|
||||||
|
Value: refreshTokenString.TokenID.String(),
|
||||||
|
Expires: refreshExpiresAt,
|
||||||
|
HttpOnly: true,
|
||||||
|
})
|
||||||
|
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: accessTokenString})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CitadelHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var requestData LoginRequestData
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&requestData)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
log.Debug("bad request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.repo.GetUserAccountByUsername(r.Context(), requestData.Username)
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"username": requestData.Username,
|
||||||
|
}).Warn("user account not found")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(requestData.Password))
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"password": requestData.Password,
|
||||||
|
"password_hash": user.PasswordHash,
|
||||||
|
}).Warn("password incorrect")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := uuid.MustParse("0183d9ab-d0ed-4c9b-a3df-77a0cdd93dca")
|
||||||
|
refreshCreatedAt := time.Now().UTC()
|
||||||
|
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
|
||||||
|
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), pg.CreateRefreshTokenParams{userID, refreshCreatedAt, refreshExpiresAt})
|
||||||
|
|
||||||
|
accessTokenString, err := NewAccessToken("1")
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-type", "application/json")
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "refreshToken",
|
||||||
|
Value: refreshTokenString.TokenID.String(),
|
||||||
|
Expires: refreshExpiresAt,
|
||||||
|
HttpOnly: true,
|
||||||
|
})
|
||||||
|
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs authResource) Routes(citadelHandler CitadelHandler) chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Post("/login", citadelHandler.LoginHandler)
|
||||||
|
r.Post("/refresh_token", citadelHandler.RefreshTokenHandler)
|
||||||
|
return r
|
||||||
|
}
|
13
api/router/errors.go
Normal file
13
api/router/errors.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
type ErrExpiredToken struct{}
|
||||||
|
|
||||||
|
func (r *ErrExpiredToken) Error() string {
|
||||||
|
return "token is expired"
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrMalformedToken struct{}
|
||||||
|
|
||||||
|
func (r *ErrMalformedToken) Error() string {
|
||||||
|
return "token is malformed"
|
||||||
|
}
|
7
api/router/handlers.go
Normal file
7
api/router/handlers.go
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import "github.com/jordanknott/project-citadel/api/pg"
|
||||||
|
|
||||||
|
type CitadelHandler struct {
|
||||||
|
repo pg.Repository
|
||||||
|
}
|
93
api/router/logger.go
Normal file
93
api/router/logger.go
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/middleware"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StructuredLogger is a simple, but powerful implementation of a custom structured
|
||||||
|
// logger backed on logrus. I encourage users to copy it, adapt it and make it their
|
||||||
|
// own. Also take a look at https://github.com/pressly/lg for a dedicated pkg based
|
||||||
|
// on this work, designed for context-based http routers.
|
||||||
|
|
||||||
|
func NewStructuredLogger(logger *logrus.Logger) func(next http.Handler) http.Handler {
|
||||||
|
return middleware.RequestLogger(&StructuredLogger{logger})
|
||||||
|
}
|
||||||
|
|
||||||
|
type StructuredLogger struct {
|
||||||
|
Logger *logrus.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *StructuredLogger) NewLogEntry(r *http.Request) middleware.LogEntry {
|
||||||
|
entry := &StructuredLoggerEntry{Logger: logrus.NewEntry(l.Logger)}
|
||||||
|
logFields := logrus.Fields{}
|
||||||
|
|
||||||
|
if reqID := middleware.GetReqID(r.Context()); reqID != "" {
|
||||||
|
logFields["req_id"] = reqID
|
||||||
|
}
|
||||||
|
|
||||||
|
scheme := "http"
|
||||||
|
if r.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
logFields["http_scheme"] = scheme
|
||||||
|
logFields["http_proto"] = r.Proto
|
||||||
|
logFields["http_method"] = r.Method
|
||||||
|
|
||||||
|
logFields["remote_addr"] = r.RemoteAddr
|
||||||
|
logFields["user_agent"] = r.UserAgent()
|
||||||
|
|
||||||
|
logFields["uri"] = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI)
|
||||||
|
|
||||||
|
entry.Logger = entry.Logger.WithFields(logFields)
|
||||||
|
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
type StructuredLoggerEntry struct {
|
||||||
|
Logger logrus.FieldLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *StructuredLoggerEntry) Write(status, bytes int, elapsed time.Duration) {
|
||||||
|
l.Logger = l.Logger.WithFields(logrus.Fields{
|
||||||
|
"resp_status": status, "resp_bytes_length": bytes,
|
||||||
|
"resp_elapsed_ms": float64(elapsed.Nanoseconds()) / 1000000.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
l.Logger.Infoln("request complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *StructuredLoggerEntry) Panic(v interface{}, stack []byte) {
|
||||||
|
l.Logger = l.Logger.WithFields(logrus.Fields{
|
||||||
|
"stack": string(stack),
|
||||||
|
"panic": fmt.Sprintf("%+v", v),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods used by the application to get the request-scoped
|
||||||
|
// logger entry and set additional fields between handlers.
|
||||||
|
//
|
||||||
|
// This is a useful pattern to use to set state on the entry as it
|
||||||
|
// passes through the handler chain, which at any point can be logged
|
||||||
|
// with a call to .Print(), .Info(), etc.
|
||||||
|
|
||||||
|
func GetLogEntry(r *http.Request) logrus.FieldLogger {
|
||||||
|
entry := middleware.GetLogEntry(r).(*StructuredLoggerEntry)
|
||||||
|
return entry.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogEntrySetField(r *http.Request, key string, value interface{}) {
|
||||||
|
if entry, ok := r.Context().Value(middleware.LogEntryCtxKey).(*StructuredLoggerEntry); ok {
|
||||||
|
entry.Logger = entry.Logger.WithField(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogEntrySetFields(r *http.Request, fields map[string]interface{}) {
|
||||||
|
if entry, ok := r.Context().Value(middleware.LogEntryCtxKey).(*StructuredLoggerEntry); ok {
|
||||||
|
entry.Logger = entry.Logger.WithFields(fields)
|
||||||
|
}
|
||||||
|
}
|
44
api/router/middleware.go
Normal file
44
api/router/middleware.go
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AuthenticationMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
bearerTokenRaw := r.Header.Get("Authorization")
|
||||||
|
splitToken := strings.Split(bearerTokenRaw, "Bearer")
|
||||||
|
if len(splitToken) != 2 {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accessTokenString := strings.TrimSpace(splitToken[1])
|
||||||
|
accessClaims, err := ValidateAccessToken(accessTokenString)
|
||||||
|
if err != nil {
|
||||||
|
if _, ok := err.(*ErrExpiredToken); ok {
|
||||||
|
w.Write([]byte(`{
|
||||||
|
"data": {},
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"extensions": {
|
||||||
|
"code": "UNAUTHENTICATED"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Error(err)
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(r.Context(), "accessClaims", accessClaims)
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
28
api/router/models.go
Normal file
28
api/router/models.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/dgrijalva/jwt-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AccessTokenClaims struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
jwt.StandardClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type RefreshTokenClaims struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
jwt.StandardClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginRequestData struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginResponseData struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RefreshTokenResponseData struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
}
|
61
api/router/router.go
Normal file
61
api/router/router.go
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
"github.com/go-chi/chi/middleware"
|
||||||
|
"github.com/go-chi/cors"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/jordanknott/project-citadel/api/graph"
|
||||||
|
"github.com/jordanknott/project-citadel/api/pg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *CitadelHandler) PingHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("pong"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRouter(db *sqlx.DB) (chi.Router, error) {
|
||||||
|
formatter := new(log.TextFormatter)
|
||||||
|
formatter.TimestampFormat = "02-01-2006 15:04:05"
|
||||||
|
formatter.FullTimestamp = true
|
||||||
|
|
||||||
|
routerLogger := log.New()
|
||||||
|
routerLogger.SetLevel(log.WarnLevel)
|
||||||
|
routerLogger.Formatter = formatter
|
||||||
|
r := chi.NewRouter()
|
||||||
|
cors := cors.New(cors.Options{
|
||||||
|
// AllowedOrigins: []string{"https://foo.com"}, // Use this to allow specific origin hosts
|
||||||
|
AllowedOrigins: []string{"*"},
|
||||||
|
// AllowOriginFunc: func(r *http.Request, origin string) bool { return true },
|
||||||
|
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||||
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "Cookie"},
|
||||||
|
ExposedHeaders: []string{"Link"},
|
||||||
|
AllowCredentials: true,
|
||||||
|
MaxAge: 300, // Maximum value not ignored by any of major browsers
|
||||||
|
})
|
||||||
|
r.Use(cors.Handler)
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
r.Use(middleware.RealIP)
|
||||||
|
r.Use(NewStructuredLogger(routerLogger))
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
r.Use(middleware.Timeout(60 * time.Second))
|
||||||
|
|
||||||
|
repository := pg.NewRepository(db)
|
||||||
|
citadelHandler := CitadelHandler{repository}
|
||||||
|
|
||||||
|
r.Group(func(mux chi.Router) {
|
||||||
|
mux.Mount("/auth", authResource{}.Routes(citadelHandler))
|
||||||
|
mux.Handle("/__graphql", graph.NewPlaygroundHandler("/graphql"))
|
||||||
|
})
|
||||||
|
r.Group(func(mux chi.Router) {
|
||||||
|
mux.Use(AuthenticationMiddleware)
|
||||||
|
mux.Get("/ping", citadelHandler.PingHandler)
|
||||||
|
mux.Handle("/graphql", graph.NewHandler(repository))
|
||||||
|
})
|
||||||
|
|
||||||
|
return r, nil
|
||||||
|
}
|
77
api/router/tokens.go
Normal file
77
api/router/tokens.go
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dgrijalva/jwt-go"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewAccessToken(userID string) (string, error) {
|
||||||
|
accessExpirationTime := time.Now().Add(5 * time.Second)
|
||||||
|
accessClaims := &AccessTokenClaims{
|
||||||
|
UserID: userID,
|
||||||
|
StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()},
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
|
||||||
|
accessTokenString, err := accessToken.SignedString(jwtKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return accessTokenString, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAccessTokenCustomExpiration(userID string, dur time.Duration) (string, error) {
|
||||||
|
accessExpirationTime := time.Now().Add(dur)
|
||||||
|
accessClaims := &AccessTokenClaims{
|
||||||
|
UserID: userID,
|
||||||
|
StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()},
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
|
||||||
|
accessTokenString, err := accessToken.SignedString(jwtKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return accessTokenString, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateAccessToken(accessTokenString string) (AccessTokenClaims, error) {
|
||||||
|
accessClaims := &AccessTokenClaims{}
|
||||||
|
accessToken, err := jwt.ParseWithClaims(accessTokenString, accessClaims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return jwtKey, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if accessToken.Valid {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"token": accessTokenString,
|
||||||
|
"timeToExpire": time.Unix(accessClaims.ExpiresAt, 0),
|
||||||
|
}).Info("token is valid")
|
||||||
|
return *accessClaims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if ve, ok := err.(*jwt.ValidationError); ok {
|
||||||
|
if ve.Errors&jwt.ValidationErrorMalformed != 0 {
|
||||||
|
return AccessTokenClaims{}, &ErrMalformedToken{}
|
||||||
|
} else if ve.Errors&(jwt.ValidationErrorExpired|jwt.ValidationErrorNotValidYet) != 0 {
|
||||||
|
return AccessTokenClaims{}, &ErrExpiredToken{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return AccessTokenClaims{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRefreshToken(userID string) (string, time.Time, error) {
|
||||||
|
refreshExpirationTime := time.Now().Add(24 * time.Hour)
|
||||||
|
refreshClaims := &RefreshTokenClaims{
|
||||||
|
UserID: userID,
|
||||||
|
StandardClaims: jwt.StandardClaims{ExpiresAt: refreshExpirationTime.Unix()},
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
|
||||||
|
refreshTokenString, err := refreshToken.SignedString(jwtKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
return refreshTokenString, refreshExpirationTime, nil
|
||||||
|
}
|
26
api/scripts/auth
Executable file
26
api/scripts/auth
Executable file
@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# vi: ft=python
|
||||||
|
|
||||||
|
import click
|
||||||
|
import requests
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
def authenticate():
|
||||||
|
s = requests.Session()
|
||||||
|
r = s.post('http://localhost:3333/auth/login', json={
|
||||||
|
'username': 'hello',
|
||||||
|
'password': 'test',
|
||||||
|
})
|
||||||
|
if r.status_code != 200:
|
||||||
|
print('issue during login status_code={}'.format(r.status_code))
|
||||||
|
return
|
||||||
|
|
||||||
|
access_code = r.json()['access_token']
|
||||||
|
r = s.get('http://localhost:3333/ping', headers={
|
||||||
|
'Authorization': 'Bearer {}'.format(access_code)
|
||||||
|
})
|
||||||
|
print(r.status_code)
|
||||||
|
print(r.text)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
authenticate()
|
10
api/sqlc.yaml
Normal file
10
api/sqlc.yaml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
version: "1"
|
||||||
|
packages:
|
||||||
|
- name: "pg"
|
||||||
|
emit_json_tags: true
|
||||||
|
emit_prepared_queries: false
|
||||||
|
emit_interface: true
|
||||||
|
path: "pg"
|
||||||
|
queries: "./query/"
|
||||||
|
schema: "./migrations/"
|
||||||
|
|
9
web/.editorconfig
Normal file
9
web/.editorconfig
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
62
web/.eslintrc.json
Normal file
62
web/.eslintrc.json
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es6": true
|
||||||
|
},
|
||||||
|
"globals": {
|
||||||
|
"Atomics": "readonly",
|
||||||
|
"SharedArrayBuffer": "readonly"
|
||||||
|
},
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaFeatures": {
|
||||||
|
"jsx": true
|
||||||
|
},
|
||||||
|
"ecmaVersion": 2018,
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"plugins": ["react", "@typescript-eslint", "prettier"],
|
||||||
|
"extends": [
|
||||||
|
"plugin:react/recommended",
|
||||||
|
"airbnb",
|
||||||
|
"plugin:prettier/recommended",
|
||||||
|
"plugin:@typescript-eslint/eslint-recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"prettier/prettier": "error",
|
||||||
|
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
|
||||||
|
|
||||||
|
"@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/prop-types": 0,
|
||||||
|
"react/jsx-props-no-spreading": "off",
|
||||||
|
"import/extensions": [
|
||||||
|
"error",
|
||||||
|
"ignorePackages",
|
||||||
|
{
|
||||||
|
"js": "never",
|
||||||
|
"mjs": "never",
|
||||||
|
"jsx": "never",
|
||||||
|
"ts": "never",
|
||||||
|
"tsx": "never"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"import/no-extraneous-dependencies": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"devDependencies": [".storybook/**", "src/shared/components/**/*.stories.tsx"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"import/resolver": {
|
||||||
|
"node": {
|
||||||
|
"paths": ["src"],
|
||||||
|
"extensions": [".js", ".jsx", ".ts", ".tsx"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
23
web/.gitignore
vendored
Normal file
23
web/.gitignore
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
7
web/.prettierrc.js
Normal file
7
web/.prettierrc.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
semi: true,
|
||||||
|
trailingComma: "all",
|
||||||
|
singleQuote: true,
|
||||||
|
printWidth: 120,
|
||||||
|
tabWidth: 2
|
||||||
|
};
|
18
web/.storybook/main.js
Normal file
18
web/.storybook/main.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
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',
|
||||||
|
],
|
||||||
|
webpackFinal: async config => {
|
||||||
|
config.resolve.modules.push(path.resolve(__dirname, '../src'));
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
};
|
44
web/README.md
Normal file
44
web/README.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||||
|
|
||||||
|
## Available Scripts
|
||||||
|
|
||||||
|
In the project directory, you can run:
|
||||||
|
|
||||||
|
### `yarn start`
|
||||||
|
|
||||||
|
Runs the app in the development mode.<br />
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||||
|
|
||||||
|
The page will reload if you make edits.<br />
|
||||||
|
You will also see any lint errors in the console.
|
||||||
|
|
||||||
|
### `yarn test`
|
||||||
|
|
||||||
|
Launches the test runner in the interactive watch mode.<br />
|
||||||
|
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||||
|
|
||||||
|
### `yarn build`
|
||||||
|
|
||||||
|
Builds the app for production to the `build` folder.<br />
|
||||||
|
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.<br />
|
||||||
|
Your app is ready to be deployed!
|
||||||
|
|
||||||
|
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||||
|
|
||||||
|
### `yarn 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/).
|
95
web/package.json
Normal file
95
web/package.json
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"name": "app",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@apollo/react-hooks": "^3.1.3",
|
||||||
|
"@fortawesome/fontawesome-svg-core": "^1.2.27",
|
||||||
|
"@fortawesome/free-brands-svg-icons": "^5.12.1",
|
||||||
|
"@fortawesome/free-regular-svg-icons": "^5.12.1",
|
||||||
|
"@fortawesome/free-solid-svg-icons": "^5.12.1",
|
||||||
|
"@fortawesome/react-fontawesome": "^0.1.8",
|
||||||
|
"@storybook/addon-backgrounds": "^5.3.17",
|
||||||
|
"@storybook/addon-docs": "^5.3.17",
|
||||||
|
"@storybook/addon-knobs": "^5.3.17",
|
||||||
|
"@storybook/addon-storysource": "^5.3.17",
|
||||||
|
"@storybook/addon-viewport": "^5.3.17",
|
||||||
|
"@testing-library/jest-dom": "^4.2.4",
|
||||||
|
"@testing-library/react": "^9.3.2",
|
||||||
|
"@testing-library/user-event": "^7.1.2",
|
||||||
|
"@types/color": "^3.0.1",
|
||||||
|
"@types/jest": "^24.0.0",
|
||||||
|
"@types/lodash": "^4.14.149",
|
||||||
|
"@types/node": "^12.0.0",
|
||||||
|
"@types/react": "^16.9.21",
|
||||||
|
"@types/react-beautiful-dnd": "^12.1.1",
|
||||||
|
"@types/react-dom": "^16.9.5",
|
||||||
|
"@types/react-router": "^5.1.4",
|
||||||
|
"@types/react-router-dom": "^5.1.3",
|
||||||
|
"@types/styled-components": "^5.0.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",
|
||||||
|
"color": "^3.1.2",
|
||||||
|
"graphql": "^14.6.0",
|
||||||
|
"graphql-tag": "^2.10.3",
|
||||||
|
"history": "^4.10.1",
|
||||||
|
"lodash": "^4.17.15",
|
||||||
|
"prop-types": "^15.7.2",
|
||||||
|
"react": "^16.12.0",
|
||||||
|
"react-autosize-textarea": "^7.0.0",
|
||||||
|
"react-beautiful-dnd": "^13.0.0",
|
||||||
|
"react-dom": "^16.12.0",
|
||||||
|
"react-hook-form": "^5.2.0",
|
||||||
|
"react-router": "^5.1.2",
|
||||||
|
"react-router-dom": "^5.1.2",
|
||||||
|
"react-scripts": "3.4.0",
|
||||||
|
"styled-components": "^5.0.1",
|
||||||
|
"typescript": "~3.7.2"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject",
|
||||||
|
"storybook": "start-storybook -p 9009 -s public",
|
||||||
|
"build-storybook": "build-storybook -s public"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": "react-app"
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@storybook/addon-actions": "^5.3.13",
|
||||||
|
"@storybook/addon-links": "^5.3.13",
|
||||||
|
"@storybook/addons": "^5.3.13",
|
||||||
|
"@storybook/preset-create-react-app": "^1.5.2",
|
||||||
|
"@storybook/react": "^5.3.13",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^2.20.0",
|
||||||
|
"@typescript-eslint/parser": "^2.20.0",
|
||||||
|
"eslint": "^6.8.0",
|
||||||
|
"eslint-config-airbnb": "^18.0.1",
|
||||||
|
"eslint-config-prettier": "^6.10.0",
|
||||||
|
"eslint-plugin-import": "^2.20.1",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.2.3",
|
||||||
|
"eslint-plugin-prettier": "^3.1.2",
|
||||||
|
"eslint-plugin-react": "^7.18.3",
|
||||||
|
"eslint-plugin-react-hooks": "^1.7.0",
|
||||||
|
"prettier": "^1.19.1"
|
||||||
|
}
|
||||||
|
}
|
BIN
web/public/favicon.ico
Normal file
BIN
web/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.1 KiB |
43
web/public/index.html
Normal file
43
web/public/index.html
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/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" />
|
||||||
|
<!--
|
||||||
|
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" />
|
||||||
|
<!--
|
||||||
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
|
Only files inside the `public` folder can be referenced from the HTML.
|
||||||
|
|
||||||
|
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||||
|
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>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<!--
|
||||||
|
This HTML file is a template.
|
||||||
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
|
You can add webfonts, meta tags, or analytics to this file.
|
||||||
|
The build step will place the bundled scripts into the <body> tag.
|
||||||
|
|
||||||
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
|
-->
|
||||||
|
</body>
|
||||||
|
</html>
|
BIN
web/public/logo192.png
Normal file
BIN
web/public/logo192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.2 KiB |
BIN
web/public/logo512.png
Normal file
BIN
web/public/logo512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.4 KiB |
25
web/public/manifest.json
Normal file
25
web/public/manifest.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"short_name": "React App",
|
||||||
|
"name": "Create React App Sample",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
3
web/public/robots.txt
Normal file
3
web/public/robots.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
110
web/src/App/BaseStyles.ts
Normal file
110
web/src/App/BaseStyles.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { createGlobalStyle } from 'styled-components';
|
||||||
|
|
||||||
|
import { color, font, mixin } from 'shared/utils/styles';
|
||||||
|
|
||||||
|
export default createGlobalStyle`
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
min-width: 768px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: ${color.textDarkest};
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
line-height: 1.2;
|
||||||
|
${font.size(16)}
|
||||||
|
${font.regular}
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
optgroup,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
${font.regular}
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *:after, *:before, input[type="search"] {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, li, ol, dd, h1, h2, h3, h4, h5, h6, p {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6, strong {
|
||||||
|
${font.bold}
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Workaround for IE11 focus highlighting for select elements */
|
||||||
|
select::-ms-value {
|
||||||
|
background: none;
|
||||||
|
color: #42413d;
|
||||||
|
}
|
||||||
|
|
||||||
|
[role="button"], button, input, select, textarea {
|
||||||
|
outline: none;
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
&:disabled {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[role="button"], button, input, textarea {
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
select:-moz-focusring {
|
||||||
|
color: transparent;
|
||||||
|
text-shadow: 0 0 0 #000;
|
||||||
|
}
|
||||||
|
select::-ms-expand {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
select option {
|
||||||
|
color: ${color.textDarkest};
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
line-height: 1.4285;
|
||||||
|
a {
|
||||||
|
${mixin.link()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
line-height: 1.4285;
|
||||||
|
}
|
||||||
|
|
||||||
|
body, select {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
${mixin.placeholderColor(color.textLight)}
|
||||||
|
`;
|
26
web/src/App/Navbar.tsx
Normal file
26
web/src/App/Navbar.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Home, Stack } from 'shared/icons';
|
||||||
|
import Navbar, { ActionButton, ButtonContainer, PrimaryLogo } from 'shared/components/Navbar';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
const GlobalNavbar = () => {
|
||||||
|
return (
|
||||||
|
<Navbar>
|
||||||
|
<PrimaryLogo />
|
||||||
|
<ButtonContainer>
|
||||||
|
<Link to="/">
|
||||||
|
<ActionButton name="Home">
|
||||||
|
<Home size={28} color="#c2c6dc" />
|
||||||
|
</ActionButton>
|
||||||
|
</Link>
|
||||||
|
<Link to="/projects">
|
||||||
|
<ActionButton name="Projects">
|
||||||
|
<Stack size={28} color="#c2c6dc" />
|
||||||
|
</ActionButton>
|
||||||
|
</Link>
|
||||||
|
</ButtonContainer>
|
||||||
|
</Navbar>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GlobalNavbar;
|
152
web/src/App/NormalizeStyles.ts
Normal file
152
web/src/App/NormalizeStyles.ts
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import { createGlobalStyle } from 'styled-components';
|
||||||
|
|
||||||
|
/** DO NOT ALTER THIS FILE. It is a copy of https://necolas.github.io/normalize.css/ */
|
||||||
|
|
||||||
|
export default createGlobalStyle`
|
||||||
|
html {
|
||||||
|
line-height: 1.15;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
margin: 0.67em 0;
|
||||||
|
}
|
||||||
|
hr {
|
||||||
|
box-sizing: content-box;
|
||||||
|
height: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
font-family: monospace, monospace;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
abbr[title] {
|
||||||
|
border-bottom: none;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
}
|
||||||
|
b,
|
||||||
|
strong {
|
||||||
|
font-weight: bolder;
|
||||||
|
}
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
samp {
|
||||||
|
font-family: monospace, monospace;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
small {
|
||||||
|
font-size: 80%;
|
||||||
|
}
|
||||||
|
sub,
|
||||||
|
sup {
|
||||||
|
font-size: 75%;
|
||||||
|
line-height: 0;
|
||||||
|
position: relative;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
sub {
|
||||||
|
bottom: -0.25em;
|
||||||
|
}
|
||||||
|
sup {
|
||||||
|
top: -0.5em;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
border-style: none;
|
||||||
|
}
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
optgroup,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 100%;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
button,
|
||||||
|
select {
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
button,
|
||||||
|
[type="button"],
|
||||||
|
[type="reset"],
|
||||||
|
[type="submit"] {
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
button::-moz-focus-inner,
|
||||||
|
[type="button"]::-moz-focus-inner,
|
||||||
|
[type="reset"]::-moz-focus-inner,
|
||||||
|
[type="submit"]::-moz-focus-inner {
|
||||||
|
border-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
button:-moz-focusring,
|
||||||
|
[type="button"]:-moz-focusring,
|
||||||
|
[type="reset"]:-moz-focusring,
|
||||||
|
[type="submit"]:-moz-focusring {
|
||||||
|
outline: 1px dotted ButtonText;
|
||||||
|
}
|
||||||
|
fieldset {
|
||||||
|
padding: 0.35em 0.75em 0.625em;
|
||||||
|
}
|
||||||
|
legend {
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: inherit;
|
||||||
|
display: table;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
progress {
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
[type="checkbox"],
|
||||||
|
[type="radio"] {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
[type="number"]::-webkit-inner-spin-button,
|
||||||
|
[type="number"]::-webkit-outer-spin-button {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
[type="search"] {
|
||||||
|
-webkit-appearance: textfield;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
[type="search"]::-webkit-search-decoration {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
::-webkit-file-upload-button {
|
||||||
|
-webkit-appearance: button;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
details {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
summary {
|
||||||
|
display: list-item;
|
||||||
|
}
|
||||||
|
template {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`;
|
23
web/src/App/Routes.tsx
Normal file
23
web/src/App/Routes.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Router, Switch, Route } from 'react-router-dom';
|
||||||
|
import * as H from 'history';
|
||||||
|
|
||||||
|
import Projects from 'Projects';
|
||||||
|
import Project from 'Projects/Project';
|
||||||
|
import Login from 'Auth';
|
||||||
|
|
||||||
|
type RoutesProps = {
|
||||||
|
history: H.History;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Routes = ({ history }: RoutesProps) => (
|
||||||
|
<Router history={history}>
|
||||||
|
<Switch>
|
||||||
|
<Route exact path="/projects" component={Projects} />
|
||||||
|
<Route exact path="/projects/:projectId" component={Project} />
|
||||||
|
<Route exact path="/login" component={Login} />
|
||||||
|
</Switch>
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Routes;
|
26
web/src/App/TopNavbar.tsx
Normal file
26
web/src/App/TopNavbar.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import TopNavbar from 'shared/components/TopNavbar';
|
||||||
|
import DropdownMenu from 'shared/components/DropdownMenu';
|
||||||
|
|
||||||
|
const GlobalTopNavbar: React.FC = () => {
|
||||||
|
const [menu, setMenu] = useState({
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
isOpen: false,
|
||||||
|
});
|
||||||
|
const onProfileClick = (bottom: number, right: number) => {
|
||||||
|
setMenu({
|
||||||
|
isOpen: !menu.isOpen,
|
||||||
|
left: right,
|
||||||
|
top: bottom,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TopNavbar onNotificationClick={() => console.log('beep')} onProfileClick={onProfileClick} />
|
||||||
|
{menu.isOpen && <DropdownMenu left={menu.left} top={menu.top} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GlobalTopNavbar;
|
43
web/src/App/index.tsx
Normal file
43
web/src/App/index.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { createBrowserHistory } from 'history';
|
||||||
|
import { setAccessToken } from 'shared/utils/accessToken';
|
||||||
|
import NormalizeStyles from './NormalizeStyles';
|
||||||
|
import BaseStyles from './BaseStyles';
|
||||||
|
import Routes from './Routes';
|
||||||
|
|
||||||
|
const history = createBrowserHistory();
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('http://localhost:3333/auth/refresh_token', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
}).then(async x => {
|
||||||
|
const { status } = x;
|
||||||
|
if (status === 400) {
|
||||||
|
history.replace('/login');
|
||||||
|
} else {
|
||||||
|
const response: RefreshTokenResponse = await x.json();
|
||||||
|
const { accessToken } = response;
|
||||||
|
setAccessToken(accessToken);
|
||||||
|
}
|
||||||
|
// }
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div>loading...</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NormalizeStyles />
|
||||||
|
<BaseStyles />
|
||||||
|
<Routes history={history} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
13
web/src/Auth/Styles.ts
Normal file
13
web/src/Auth/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%;
|
||||||
|
`;
|
62
web/src/Auth/index.tsx
Normal file
62
web/src/Auth/index.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useHistory } from 'react-router';
|
||||||
|
|
||||||
|
import { setAccessToken } from 'shared/utils/accessToken';
|
||||||
|
|
||||||
|
import Login from 'shared/components/Login';
|
||||||
|
import { Container, LoginWrapper } from './Styles';
|
||||||
|
|
||||||
|
const Auth = () => {
|
||||||
|
const [invalidLoginAttempt, setInvalidLoginAttempt] = useState(0);
|
||||||
|
const history = useHistory();
|
||||||
|
const login = (
|
||||||
|
data: LoginFormData,
|
||||||
|
setComplete: (val: boolean) => void,
|
||||||
|
setError: (field: string, eType: string, message: string) => void,
|
||||||
|
) => {
|
||||||
|
fetch('http://localhost:3333/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', 'invalid', 'Invalid username');
|
||||||
|
setError('password', 'invalid', 'Invalid password');
|
||||||
|
setComplete(true);
|
||||||
|
} else {
|
||||||
|
const response = await x.json();
|
||||||
|
const { accessToken } = response;
|
||||||
|
setAccessToken(accessToken);
|
||||||
|
setComplete(true);
|
||||||
|
history.push('/');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('http://localhost:3333/auth/refresh_token', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
}).then(async x => {
|
||||||
|
const { status } = x;
|
||||||
|
if (status === 200) {
|
||||||
|
history.replace('/projects');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<LoginWrapper>
|
||||||
|
<Login onSubmit={login} />
|
||||||
|
</LoginWrapper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Auth;
|
322
web/src/Projects/Project/index.tsx
Normal file
322
web/src/Projects/Project/index.tsx
Normal file
@ -0,0 +1,322 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import styled from 'styled-components/macro';
|
||||||
|
import { useQuery, useMutation } from '@apollo/react-hooks';
|
||||||
|
import gql from 'graphql-tag';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import Navbar from 'App/Navbar';
|
||||||
|
import TopNavbar from 'App/TopNavbar';
|
||||||
|
import Lists from 'shared/components/Lists';
|
||||||
|
import QuickCardEditor from 'shared/components/QuickCardEditor';
|
||||||
|
|
||||||
|
interface ColumnState {
|
||||||
|
[key: string]: TaskGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskState {
|
||||||
|
[key: string]: RemoteTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
columns: ColumnState;
|
||||||
|
tasks: TaskState;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuickCardEditorState {
|
||||||
|
isOpen: boolean;
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
task?: RemoteTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MainContent = styled.div`
|
||||||
|
padding: 0 0 50px 100px;
|
||||||
|
height: 100%;
|
||||||
|
background: #262c49;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Wrapper = styled.div`
|
||||||
|
font-size: 16px;
|
||||||
|
background-color: red;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Title = styled.span`
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #fff;
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface ProjectData {
|
||||||
|
findProject: Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateTaskLocationData {
|
||||||
|
updateTaskLocation: Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateTaskLocationVars {
|
||||||
|
taskID: string;
|
||||||
|
taskGroupID: string;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectVars {
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateTaskVars {
|
||||||
|
taskGroupID: string;
|
||||||
|
name: string;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateTaskData {
|
||||||
|
createTask: RemoteTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectParams {
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeleteTaskData {
|
||||||
|
deleteTask: { taskID: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeleteTaskVars {
|
||||||
|
taskID: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateTaskNameData {
|
||||||
|
updateTaskName: RemoteTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateTaskNameVars {
|
||||||
|
taskID: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UPDATE_TASK_NAME = gql`
|
||||||
|
mutation updateTaskName($taskID: String!, $name: String!) {
|
||||||
|
updateTaskName(input: { taskID: $taskID, name: $name }) {
|
||||||
|
taskID
|
||||||
|
name
|
||||||
|
position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const GET_PROJECT = gql`
|
||||||
|
query getProject($projectId: String!) {
|
||||||
|
findProject(input: { projectId: $projectId }) {
|
||||||
|
name
|
||||||
|
taskGroups {
|
||||||
|
taskGroupID
|
||||||
|
name
|
||||||
|
position
|
||||||
|
tasks {
|
||||||
|
taskID
|
||||||
|
name
|
||||||
|
position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CREATE_TASK = gql`
|
||||||
|
mutation createTask($taskGroupID: String!, $name: String!, $position: Float!) {
|
||||||
|
createTask(input: { taskGroupID: $taskGroupID, name: $name, position: $position }) {
|
||||||
|
taskID
|
||||||
|
taskGroupID
|
||||||
|
name
|
||||||
|
position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DELETE_TASK = gql`
|
||||||
|
mutation deleteTask($taskID: String!) {
|
||||||
|
deleteTask(input: { taskID: $taskID }) {
|
||||||
|
taskID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const UPDATE_TASK_LOCATION = gql`
|
||||||
|
mutation updateTaskLocation($taskID: String!, $taskGroupID: String!, $position: Float!) {
|
||||||
|
updateTaskLocation(input: { taskID: $taskID, taskGroupID: $taskGroupID, position: $position }) {
|
||||||
|
taskID
|
||||||
|
createdAt
|
||||||
|
name
|
||||||
|
position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const initialState: State = { tasks: {}, columns: {} };
|
||||||
|
const initialQuickCardEditorState: QuickCardEditorState = { isOpen: false, top: 0, left: 0 };
|
||||||
|
|
||||||
|
const Project = () => {
|
||||||
|
const { projectId } = useParams<ProjectParams>();
|
||||||
|
const [listsData, setListsData] = useState(initialState);
|
||||||
|
const [quickCardEditor, setQuickCardEditor] = useState(initialQuickCardEditorState);
|
||||||
|
const [updateTaskLocation, updateTaskLocationData] = useMutation<UpdateTaskLocationData, UpdateTaskLocationVars>(
|
||||||
|
UPDATE_TASK_LOCATION,
|
||||||
|
);
|
||||||
|
const [createTask, createTaskData] = useMutation<CreateTaskData, CreateTaskVars>(CREATE_TASK, {
|
||||||
|
onCompleted: newTaskData => {
|
||||||
|
const newListsData = {
|
||||||
|
...listsData,
|
||||||
|
tasks: {
|
||||||
|
...listsData.tasks,
|
||||||
|
[newTaskData.createTask.taskID]: {
|
||||||
|
taskGroupID: newTaskData.createTask.taskGroupID,
|
||||||
|
taskID: newTaskData.createTask.taskID,
|
||||||
|
name: newTaskData.createTask.name,
|
||||||
|
position: newTaskData.createTask.position,
|
||||||
|
labels: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setListsData(newListsData);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [deleteTask, deleteTaskData] = useMutation<DeleteTaskData, DeleteTaskVars>(DELETE_TASK, {
|
||||||
|
onCompleted: deletedTask => {
|
||||||
|
const { [deletedTask.deleteTask.taskID]: removedTask, ...remainingTasks } = listsData.tasks;
|
||||||
|
const newListsData = {
|
||||||
|
...listsData,
|
||||||
|
tasks: remainingTasks,
|
||||||
|
};
|
||||||
|
setListsData(newListsData);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [updateTaskName, updateTaskNameData] = useMutation<UpdateTaskNameData, UpdateTaskNameVars>(UPDATE_TASK_NAME, {
|
||||||
|
onCompleted: newTaskData => {
|
||||||
|
const newListsData = {
|
||||||
|
...listsData,
|
||||||
|
tasks: {
|
||||||
|
...listsData.tasks,
|
||||||
|
[newTaskData.updateTaskName.taskID]: {
|
||||||
|
...listsData.tasks[newTaskData.updateTaskName.taskID],
|
||||||
|
name: newTaskData.updateTaskName.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setListsData(newListsData);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { loading, data } = useQuery<ProjectData, ProjectVars>(GET_PROJECT, {
|
||||||
|
variables: { projectId },
|
||||||
|
onCompleted: newData => {
|
||||||
|
let newListsData: State = { tasks: {}, columns: {} };
|
||||||
|
newData.findProject.taskGroups.forEach((taskGroup: TaskGroup) => {
|
||||||
|
newListsData.columns[taskGroup.taskGroupID] = {
|
||||||
|
taskGroupID: taskGroup.taskGroupID,
|
||||||
|
name: taskGroup.name,
|
||||||
|
position: taskGroup.position,
|
||||||
|
tasks: [],
|
||||||
|
};
|
||||||
|
taskGroup.tasks.forEach((task: RemoteTask) => {
|
||||||
|
newListsData.tasks[task.taskID] = {
|
||||||
|
taskID: task.taskID,
|
||||||
|
taskGroupID: taskGroup.taskGroupID,
|
||||||
|
name: task.name,
|
||||||
|
position: task.position,
|
||||||
|
labels: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setListsData(newListsData);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const onCardDrop = (droppedTask: any) => {
|
||||||
|
updateTaskLocation({
|
||||||
|
variables: { taskID: droppedTask.taskID, taskGroupID: droppedTask.taskGroupID, position: droppedTask.position },
|
||||||
|
});
|
||||||
|
const newState = {
|
||||||
|
...listsData,
|
||||||
|
tasks: {
|
||||||
|
...listsData.tasks,
|
||||||
|
[droppedTask.taskID]: droppedTask,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setListsData(newState);
|
||||||
|
};
|
||||||
|
const onListDrop = (droppedColumn: any) => {
|
||||||
|
const newState = {
|
||||||
|
...listsData,
|
||||||
|
columns: {
|
||||||
|
...listsData.columns,
|
||||||
|
[droppedColumn.taskGroupID]: droppedColumn,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setListsData(newState);
|
||||||
|
};
|
||||||
|
const onCardCreate = (taskGroupID: string, name: string) => {
|
||||||
|
const taskGroupTasks = Object.values(listsData.tasks).filter(
|
||||||
|
(task: RemoteTask) => task.taskGroupID === taskGroupID,
|
||||||
|
);
|
||||||
|
var position = 65535;
|
||||||
|
console.log(taskGroupID);
|
||||||
|
console.log(taskGroupTasks);
|
||||||
|
if (taskGroupTasks.length !== 0) {
|
||||||
|
const [lastTask] = taskGroupTasks.sort((a: any, b: any) => a.position - b.position).slice(-1);
|
||||||
|
console.log(`last tasks position ${lastTask.position}`);
|
||||||
|
position = Math.ceil(lastTask.position) * 2 + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
createTask({ variables: { taskGroupID: taskGroupID, name: name, position: position } });
|
||||||
|
};
|
||||||
|
const onQuickEditorOpen = (e: ContextMenuEvent) => {
|
||||||
|
const task = Object.values(listsData.tasks).find(task => task.taskID === e.cardId);
|
||||||
|
setQuickCardEditor({
|
||||||
|
top: e.top,
|
||||||
|
left: e.left,
|
||||||
|
isOpen: true,
|
||||||
|
task,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Wrapper>Loading</Wrapper>;
|
||||||
|
}
|
||||||
|
if (data) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Navbar />
|
||||||
|
<MainContent>
|
||||||
|
<TopNavbar />
|
||||||
|
<Title>{data.findProject.name}</Title>
|
||||||
|
<Lists
|
||||||
|
onQuickEditorOpen={onQuickEditorOpen}
|
||||||
|
onCardCreate={onCardCreate}
|
||||||
|
{...listsData}
|
||||||
|
onCardDrop={onCardDrop}
|
||||||
|
onListDrop={onListDrop}
|
||||||
|
/>
|
||||||
|
</MainContent>
|
||||||
|
{quickCardEditor.isOpen && (
|
||||||
|
<QuickCardEditor
|
||||||
|
isOpen={true}
|
||||||
|
listId={quickCardEditor.task ? quickCardEditor.task.taskGroupID : ''}
|
||||||
|
cardId={quickCardEditor.task ? quickCardEditor.task.taskID : ''}
|
||||||
|
cardTitle={quickCardEditor.task ? quickCardEditor.task.name : ''}
|
||||||
|
onCloseEditor={() => setQuickCardEditor(initialQuickCardEditorState)}
|
||||||
|
onEditCard={(listId: string, cardId: string, cardName: string) =>
|
||||||
|
updateTaskName({ variables: { taskID: cardId, name: cardName } })
|
||||||
|
}
|
||||||
|
onOpenPopup={() => console.log()}
|
||||||
|
onArchiveCard={(listId: string, cardId: string) => deleteTask({ variables: { taskID: cardId } })}
|
||||||
|
labels={[]}
|
||||||
|
top={quickCardEditor.top}
|
||||||
|
left={quickCardEditor.left}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Wrapper>Error</Wrapper>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Project;
|
88
web/src/Projects/index.tsx
Normal file
88
web/src/Projects/index.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import styled from 'styled-components/macro';
|
||||||
|
import { useQuery } from '@apollo/react-hooks';
|
||||||
|
import gql from 'graphql-tag';
|
||||||
|
|
||||||
|
import TopNavbar from 'App/TopNavbar';
|
||||||
|
import ProjectGridItem from 'shared/components/ProjectGridItem';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import Navbar from 'App/Navbar';
|
||||||
|
|
||||||
|
const MainContent = styled.div`
|
||||||
|
padding: 0 0 50px 80px;
|
||||||
|
height: 100%;
|
||||||
|
background: #262c49;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ProjectGrid = styled.div`
|
||||||
|
width: 60%;
|
||||||
|
margin: 25px auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
`;
|
||||||
|
const Wrapper = styled.div`
|
||||||
|
font-size: 16px;
|
||||||
|
background-color: red;
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface ProjectData {
|
||||||
|
name: string;
|
||||||
|
organizations: Organization[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const GET_PROJECTS = gql`
|
||||||
|
query getProjects {
|
||||||
|
organizations {
|
||||||
|
name
|
||||||
|
teams {
|
||||||
|
name
|
||||||
|
projects {
|
||||||
|
name
|
||||||
|
projectID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Projects = () => {
|
||||||
|
const { loading, data } = useQuery<ProjectData>(GET_PROJECTS);
|
||||||
|
console.log(loading, data);
|
||||||
|
if (loading) {
|
||||||
|
return <Wrapper>Loading</Wrapper>;
|
||||||
|
}
|
||||||
|
if (data) {
|
||||||
|
const { teams } = data.organizations[0];
|
||||||
|
const projects: Project[] = [];
|
||||||
|
teams.forEach(team =>
|
||||||
|
team.projects.forEach(project => {
|
||||||
|
projects.push({
|
||||||
|
taskGroups: [],
|
||||||
|
projectID: project.projectID,
|
||||||
|
teamTitle: team.name,
|
||||||
|
name: project.name,
|
||||||
|
color: '#aa62e3',
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Navbar />
|
||||||
|
<MainContent>
|
||||||
|
<TopNavbar />
|
||||||
|
<ProjectGrid>
|
||||||
|
{projects.map(project => (
|
||||||
|
<Link to={`/projects/${project.projectID}/`}>
|
||||||
|
<ProjectGridItem project={project} />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</ProjectGrid>
|
||||||
|
</MainContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Wrapper>Error</Wrapper>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Projects;
|
65
web/src/citadel.d.ts
vendored
Normal file
65
web/src/citadel.d.ts
vendored
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
type ContextMenuEvent = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
cardId: string;
|
||||||
|
listId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RemoteTask {
|
||||||
|
taskID: string;
|
||||||
|
taskGroupID: string;
|
||||||
|
name: string;
|
||||||
|
position: number;
|
||||||
|
labels: Label[];
|
||||||
|
}
|
||||||
|
type TaskGroup = {
|
||||||
|
taskGroupID: string;
|
||||||
|
name: string;
|
||||||
|
position: number;
|
||||||
|
tasks: RemoteTask[];
|
||||||
|
};
|
||||||
|
type Project = {
|
||||||
|
projectID: string;
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
teamTitle?: string;
|
||||||
|
taskGroups: TaskGroup[];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Organization {
|
||||||
|
name: string;
|
||||||
|
teams: Team[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Team {
|
||||||
|
name: string;
|
||||||
|
projects: Project[];
|
||||||
|
}
|
||||||
|
type Label = {
|
||||||
|
labelId: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Task = {
|
||||||
|
title: string;
|
||||||
|
position: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RefreshTokenResponse = {
|
||||||
|
accessToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LoginFormData = {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LoginProps = {
|
||||||
|
onSubmit: (
|
||||||
|
data: LoginFormData,
|
||||||
|
setComplete: (val: boolean) => void,
|
||||||
|
setError: (field: string, eType: string, message: string) => void,
|
||||||
|
) => void;
|
||||||
|
};
|
130
web/src/index.tsx
Normal file
130
web/src/index.tsx
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
import { ApolloProvider } from '@apollo/react-hooks';
|
||||||
|
import { ApolloClient } from 'apollo-client';
|
||||||
|
import { InMemoryCache } from 'apollo-cache-inmemory';
|
||||||
|
import { HttpLink } from 'apollo-link-http';
|
||||||
|
import { onError } from 'apollo-link-error';
|
||||||
|
import { ApolloLink, Observable, fromPromise } from 'apollo-link';
|
||||||
|
|
||||||
|
import { getAccessToken, getNewToken, setAccessToken } from 'shared/utils/accessToken';
|
||||||
|
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
// https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8
|
||||||
|
|
||||||
|
let isRefreshing = false;
|
||||||
|
let pendingRequests: any = [];
|
||||||
|
|
||||||
|
const resolvePendingRequests = () => {
|
||||||
|
pendingRequests.map((callback: any) => callback());
|
||||||
|
pendingRequests = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
|
||||||
|
if (graphQLErrors) {
|
||||||
|
for (const err of graphQLErrors) {
|
||||||
|
switch (err!.extensions!.code) {
|
||||||
|
case 'UNAUTHENTICATED':
|
||||||
|
// error code is set to UNAUTHENTICATED
|
||||||
|
// when AuthenticationError thrown in resolver
|
||||||
|
let forward$;
|
||||||
|
|
||||||
|
if (!isRefreshing) {
|
||||||
|
isRefreshing = true;
|
||||||
|
forward$ = fromPromise(
|
||||||
|
getNewToken()
|
||||||
|
.then((response: any) => {
|
||||||
|
// Store the new tokens for your auth link
|
||||||
|
setAccessToken(response.accessToken);
|
||||||
|
resolvePendingRequests();
|
||||||
|
return response.accessToken;
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
pendingRequests = [];
|
||||||
|
// TODO
|
||||||
|
// Handle token refresh errors e.g clear stored tokens, redirect to login, ...
|
||||||
|
return;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
isRefreshing = false;
|
||||||
|
}),
|
||||||
|
).filter(value => Boolean(value));
|
||||||
|
} else {
|
||||||
|
// Will only emit once the Promise is resolved
|
||||||
|
forward$ = fromPromise(
|
||||||
|
new Promise(resolve => {
|
||||||
|
pendingRequests.push(() => resolve());
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return forward$.flatMap(() => forward(operation));
|
||||||
|
default:
|
||||||
|
// pass
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (networkError) {
|
||||||
|
console.log(`[Network error]: ${networkError}`);
|
||||||
|
// if you would also like to retry automatically on
|
||||||
|
// network errors, we recommend that you use
|
||||||
|
// apollo-link-retry
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestLink = new ApolloLink(
|
||||||
|
(operation, forward) =>
|
||||||
|
new Observable((observer: any) => {
|
||||||
|
let handle: any;
|
||||||
|
Promise.resolve(operation)
|
||||||
|
.then((operation: any) => {
|
||||||
|
const accessToken = getAccessToken();
|
||||||
|
if (accessToken) {
|
||||||
|
operation.setContext({
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
handle = forward(operation).subscribe({
|
||||||
|
next: observer.next.bind(observer),
|
||||||
|
error: observer.error.bind(observer),
|
||||||
|
complete: observer.complete.bind(observer),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(observer.error.bind(observer));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (handle) handle.unsubscribe();
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const client = new ApolloClient({
|
||||||
|
link: ApolloLink.from([
|
||||||
|
onError(({ graphQLErrors, networkError }) => {
|
||||||
|
if (graphQLErrors)
|
||||||
|
graphQLErrors.forEach(({ message, locations, path }) =>
|
||||||
|
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`),
|
||||||
|
);
|
||||||
|
if (networkError) console.log(`[Network error]: ${networkError}`);
|
||||||
|
}),
|
||||||
|
errorLink,
|
||||||
|
requestLink,
|
||||||
|
new HttpLink({
|
||||||
|
uri: 'http://localhost:3333/graphql',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
cache: new InMemoryCache(),
|
||||||
|
});
|
||||||
|
|
||||||
|
ReactDOM.render(
|
||||||
|
<ApolloProvider client={client}>
|
||||||
|
<App />
|
||||||
|
</ApolloProvider>,
|
||||||
|
document.getElementById('root'),
|
||||||
|
);
|
1
web/src/react-app-env.d.ts
vendored
Normal file
1
web/src/react-app-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="react-scripts" />
|
5
web/src/setupTests.ts
Normal file
5
web/src/setupTests.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
// 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';
|
116
web/src/shared/components/Card/Card.stories.tsx
Normal file
116
web/src/shared/components/Card/Card.stories.tsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import LabelColors from 'shared/constants/labelColors';
|
||||||
|
import Card from './index';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: Card,
|
||||||
|
title: 'Card',
|
||||||
|
parameters: {
|
||||||
|
backgrounds: [
|
||||||
|
{ name: 'gray', value: '#f8f8f8', default: true },
|
||||||
|
{ name: 'white', value: '#ffffff' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelData = [
|
||||||
|
{
|
||||||
|
labelId: 'development',
|
||||||
|
name: 'Development',
|
||||||
|
color: LabelColors.BLUE,
|
||||||
|
active: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelId: 'general',
|
||||||
|
name: 'General',
|
||||||
|
color: LabelColors.PINK,
|
||||||
|
active: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Default = () => {
|
||||||
|
const $ref = useRef<HTMLDivElement>(null);
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
cardId="1"
|
||||||
|
listId="1"
|
||||||
|
description=""
|
||||||
|
ref={$ref}
|
||||||
|
title="Hello, world"
|
||||||
|
onClick={action('on click')}
|
||||||
|
onContextMenu={action('on context click')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Labels = () => {
|
||||||
|
const $ref = useRef<HTMLDivElement>(null);
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
cardId="1"
|
||||||
|
listId="1"
|
||||||
|
description=""
|
||||||
|
ref={$ref}
|
||||||
|
title="Hello, world"
|
||||||
|
labels={labelData}
|
||||||
|
onClick={action('on click')}
|
||||||
|
onContextMenu={action('on context click')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Badges = () => {
|
||||||
|
const $ref = useRef<HTMLDivElement>(null);
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
cardId="1"
|
||||||
|
listId="1"
|
||||||
|
description="hello!"
|
||||||
|
ref={$ref}
|
||||||
|
title="Hello, world"
|
||||||
|
onClick={action('on click')}
|
||||||
|
onContextMenu={action('on context click')}
|
||||||
|
watched
|
||||||
|
checklists={{ complete: 1, total: 4 }}
|
||||||
|
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PastDue = () => {
|
||||||
|
const $ref = useRef<HTMLDivElement>(null);
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
cardId="1"
|
||||||
|
listId="1"
|
||||||
|
description="hello!"
|
||||||
|
ref={$ref}
|
||||||
|
title="Hello, world"
|
||||||
|
onClick={action('on click')}
|
||||||
|
onContextMenu={action('on context click')}
|
||||||
|
watched
|
||||||
|
checklists={{ complete: 1, total: 4 }}
|
||||||
|
dueDate={{ isPastDue: true, formattedDate: 'Oct 26, 2020' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Everything = () => {
|
||||||
|
const $ref = useRef<HTMLDivElement>(null);
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
cardId="1"
|
||||||
|
listId="1"
|
||||||
|
description="hello!"
|
||||||
|
ref={$ref}
|
||||||
|
title="Hello, world"
|
||||||
|
onClick={action('on click')}
|
||||||
|
onContextMenu={action('on context click')}
|
||||||
|
watched
|
||||||
|
labels={labelData}
|
||||||
|
checklists={{ complete: 1, total: 4 }}
|
||||||
|
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
122
web/src/shared/components/Card/Styles.ts
Normal file
122
web/src/shared/components/Card/Styles.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import styled, { css } from 'styled-components';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { mixin } from 'shared/utils/styles';
|
||||||
|
|
||||||
|
export const ClockIcon = styled(FontAwesomeIcon)``;
|
||||||
|
|
||||||
|
export const ListCardBadges = styled.div`
|
||||||
|
float: left;
|
||||||
|
display: flex;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-left: -2px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ListCardBadge = styled.div`
|
||||||
|
color: #5e6c84;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0 6px 4px 0;
|
||||||
|
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 }>`
|
||||||
|
${props =>
|
||||||
|
props.isPastDue &&
|
||||||
|
css`
|
||||||
|
padding-left: 4px;
|
||||||
|
background-color: #ec9488;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #fff;
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ListCardBadgeText = styled.span`
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 0 4px 0 6px;
|
||||||
|
vertical-align: top;
|
||||||
|
white-space: nowrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ListCardContainer = styled.div<{ isActive: boolean }>`
|
||||||
|
max-width: 256px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 3px;
|
||||||
|
${mixin.boxShadowCard}
|
||||||
|
cursor: pointer !important;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
background-color: ${props => (props.isActive ? mixin.darken('#262c49', 0.1) : mixin.lighten('#262c49', 0.05))};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ListCardInnerContainer = styled.div`
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ListCardDetails = styled.div`
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 6px 8px 2px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ListCardLabels = styled.div`
|
||||||
|
overflow: auto;
|
||||||
|
position: relative;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ListCardLabel = styled.span`
|
||||||
|
height: 16px;
|
||||||
|
line-height: 16px;
|
||||||
|
padding: 0 8px;
|
||||||
|
max-width: 198px;
|
||||||
|
float: left;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 4px 4px 0;
|
||||||
|
width: auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
background-color: ${props => props.color};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ListCardOperation = styled.span`
|
||||||
|
display: flex;
|
||||||
|
align-content: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: ${props => mixin.darken('#262c49', 0.15)};
|
||||||
|
background-clip: padding-box;
|
||||||
|
background-origin: padding-box;
|
||||||
|
border-radius: 3px;
|
||||||
|
opacity: 0.8;
|
||||||
|
padding: 6px;
|
||||||
|
position: absolute;
|
||||||
|
right: 2px;
|
||||||
|
top: 2px;
|
||||||
|
z-index: 10;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CardTitle = styled.span`
|
||||||
|
font-family: 'Droid Sans';
|
||||||
|
clear: both;
|
||||||
|
display: block;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-decoration: none;
|
||||||
|
word-wrap: break-word;
|
||||||
|
color: #c2c6dc;
|
||||||
|
`;
|
144
web/src/shared/components/Card/index.tsx
Normal file
144
web/src/shared/components/Card/index.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import { DraggableProvidedDraggableProps } from 'react-beautiful-dnd';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faPencilAlt, faList } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { faClock, faCheckSquare, faEye } from '@fortawesome/free-regular-svg-icons';
|
||||||
|
import {
|
||||||
|
DescriptionBadge,
|
||||||
|
DueDateCardBadge,
|
||||||
|
ListCardBadges,
|
||||||
|
ListCardBadge,
|
||||||
|
ListCardBadgeText,
|
||||||
|
ListCardContainer,
|
||||||
|
ListCardInnerContainer,
|
||||||
|
ListCardDetails,
|
||||||
|
ClockIcon,
|
||||||
|
ListCardLabels,
|
||||||
|
ListCardLabel,
|
||||||
|
ListCardOperation,
|
||||||
|
CardTitle,
|
||||||
|
} from './Styles';
|
||||||
|
|
||||||
|
type DueDate = {
|
||||||
|
isPastDue: boolean;
|
||||||
|
formattedDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Checklist = {
|
||||||
|
complete: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
cardId: string;
|
||||||
|
listId: string;
|
||||||
|
onContextMenu: (e: ContextMenuEvent) => void;
|
||||||
|
onClick: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||||
|
dueDate?: DueDate;
|
||||||
|
checklists?: Checklist;
|
||||||
|
watched?: boolean;
|
||||||
|
labels?: Label[];
|
||||||
|
wrapperProps?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Card = React.forwardRef(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
wrapperProps,
|
||||||
|
onContextMenu,
|
||||||
|
cardId,
|
||||||
|
listId,
|
||||||
|
onClick,
|
||||||
|
labels,
|
||||||
|
title,
|
||||||
|
dueDate,
|
||||||
|
description,
|
||||||
|
checklists,
|
||||||
|
watched,
|
||||||
|
}: Props,
|
||||||
|
$cardRef: any,
|
||||||
|
) => {
|
||||||
|
const [isActive, setActive] = useState(false);
|
||||||
|
const $innerCardRef: any = useRef(null);
|
||||||
|
const onOpenComposer = () => {
|
||||||
|
if (typeof $innerCardRef.current !== 'undefined') {
|
||||||
|
const pos = $innerCardRef.current.getBoundingClientRect();
|
||||||
|
onContextMenu({
|
||||||
|
top: pos.top,
|
||||||
|
left: pos.left,
|
||||||
|
listId,
|
||||||
|
cardId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onTaskContext = (e: React.MouseEvent) => {
|
||||||
|
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={onClick}
|
||||||
|
onContextMenu={onTaskContext}
|
||||||
|
isActive={isActive}
|
||||||
|
{...wrapperProps}
|
||||||
|
>
|
||||||
|
<ListCardInnerContainer ref={$innerCardRef}>
|
||||||
|
<ListCardOperation>
|
||||||
|
<FontAwesomeIcon onClick={onOperationClick} color="#c2c6dc" size="xs" icon={faPencilAlt} />
|
||||||
|
</ListCardOperation>
|
||||||
|
<ListCardDetails>
|
||||||
|
<ListCardLabels>
|
||||||
|
{labels &&
|
||||||
|
labels.map(label => (
|
||||||
|
<ListCardLabel color={label.color} key={label.name}>
|
||||||
|
{label.name}
|
||||||
|
</ListCardLabel>
|
||||||
|
))}
|
||||||
|
</ListCardLabels>
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
<ListCardBadges>
|
||||||
|
{watched && (
|
||||||
|
<ListCardBadge>
|
||||||
|
<FontAwesomeIcon color="#6b778c" icon={faEye} size="xs" />
|
||||||
|
</ListCardBadge>
|
||||||
|
)}
|
||||||
|
{dueDate && (
|
||||||
|
<DueDateCardBadge isPastDue={dueDate.isPastDue}>
|
||||||
|
<ClockIcon color={dueDate.isPastDue ? '#fff' : '#6b778c'} icon={faClock} size="xs" />
|
||||||
|
<ListCardBadgeText>{dueDate.formattedDate}</ListCardBadgeText>
|
||||||
|
</DueDateCardBadge>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<DescriptionBadge>
|
||||||
|
<FontAwesomeIcon color="#6b778c" icon={faList} size="xs" />
|
||||||
|
</DescriptionBadge>
|
||||||
|
)}
|
||||||
|
{checklists && (
|
||||||
|
<ListCardBadge>
|
||||||
|
<FontAwesomeIcon color="#6b778c" icon={faCheckSquare} size="xs" />
|
||||||
|
<ListCardBadgeText>{`${checklists.complete}/${checklists.total}`}</ListCardBadgeText>
|
||||||
|
</ListCardBadge>
|
||||||
|
)}
|
||||||
|
</ListCardBadges>
|
||||||
|
</ListCardDetails>
|
||||||
|
</ListCardInnerContainer>
|
||||||
|
</ListCardContainer>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
|
export default Card;
|
@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import CardComposer from './index';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: CardComposer,
|
||||||
|
title: 'CardComposer',
|
||||||
|
parameters: {
|
||||||
|
backgrounds: [
|
||||||
|
{ name: 'gray', value: '#f8f8f8', default: true },
|
||||||
|
{ name: 'white', value: '#ffffff' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Default = () => {
|
||||||
|
return <CardComposer isOpen onClose={action('on close')} onCreateCard={action('on create card')} />;
|
||||||
|
};
|
89
web/src/shared/components/CardComposer/Styles.ts
Normal file
89
web/src/shared/components/CardComposer/Styles.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import styled from 'styled-components';
|
||||||
|
import TextareaAutosize from 'react-autosize-textarea';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { mixin } from 'shared/utils/styles';
|
||||||
|
|
||||||
|
export const CancelIcon = styled(FontAwesomeIcon)`
|
||||||
|
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 ListCard = styled.div`
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 3px;
|
||||||
|
${mixin.boxShadowCard}
|
||||||
|
cursor: pointer;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
max-width: 300px;
|
||||||
|
min-height: 20px;
|
||||||
|
position: relative;
|
||||||
|
text-decoration: none;
|
||||||
|
z-index: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ListCardDetails = styled.div`
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 6px 8px 2px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ListCardLabels = styled.div``;
|
||||||
|
|
||||||
|
export const ListCardEditor = styled(TextareaAutosize)`
|
||||||
|
font-family: 'Droid Sans';
|
||||||
|
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: 20px;
|
||||||
|
&:focus {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
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`
|
||||||
|
background-color: #5aac44;
|
||||||
|
box-shadow: none;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 20px;
|
||||||
|
margin-right: 4px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 3px;
|
||||||
|
`;
|
85
web/src/shared/components/CardComposer/index.tsx
Normal file
85
web/src/shared/components/CardComposer/index.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown';
|
||||||
|
import { faTimes } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import TextareaAutosize from 'react-autosize-textarea';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CardComposerWrapper,
|
||||||
|
CancelIcon,
|
||||||
|
AddCardButton,
|
||||||
|
ListCard,
|
||||||
|
ListCardDetails,
|
||||||
|
ListCardEditor,
|
||||||
|
ComposerControls,
|
||||||
|
ComposerControlsSaveSection,
|
||||||
|
ComposerControlsActionsSection,
|
||||||
|
} from './Styles';
|
||||||
|
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onCreateCard: (cardName: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CardComposer = ({ isOpen, onCreateCard, onClose }: Props) => {
|
||||||
|
const [cardName, setCardName] = useState('');
|
||||||
|
const $cardEditor: any = useRef(null);
|
||||||
|
const onClick = () => {
|
||||||
|
onCreateCard(cardName);
|
||||||
|
};
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
onCreateCard(cardName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onBlur = () => {
|
||||||
|
if (cardName === '') {
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
onCreateCard(cardName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
useOnEscapeKeyDown(isOpen, onClose);
|
||||||
|
useOnOutsideClick($cardEditor, true, () => onClose(), null);
|
||||||
|
useEffect(() => {
|
||||||
|
$cardEditor.current.focus();
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<CardComposerWrapper isOpen={isOpen}>
|
||||||
|
<ListCard>
|
||||||
|
<ListCardDetails>
|
||||||
|
<ListCardEditor
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
ref={$cardEditor}
|
||||||
|
onChange={e => {
|
||||||
|
setCardName(e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
value={cardName}
|
||||||
|
placeholder="Enter a title for this card..."
|
||||||
|
/>
|
||||||
|
</ListCardDetails>
|
||||||
|
</ListCard>
|
||||||
|
<ComposerControls>
|
||||||
|
<ComposerControlsSaveSection>
|
||||||
|
<AddCardButton onClick={onClick}>Add Card</AddCardButton>
|
||||||
|
<CancelIcon onClick={onClose} icon={faTimes} color="#42526e" />
|
||||||
|
</ComposerControlsSaveSection>
|
||||||
|
<ComposerControlsActionsSection />
|
||||||
|
</ComposerControls>
|
||||||
|
</CardComposerWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
CardComposer.propTypes = {
|
||||||
|
isOpen: PropTypes.bool,
|
||||||
|
onClose: PropTypes.func.isRequired,
|
||||||
|
onCreateCard: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
CardComposer.defaultProps = {
|
||||||
|
isOpen: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CardComposer;
|
@ -0,0 +1,56 @@
|
|||||||
|
import React, { createRef, useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
|
||||||
|
import DropdownMenu from './index';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: DropdownMenu,
|
||||||
|
title: 'DropdownMenu',
|
||||||
|
parameters: {
|
||||||
|
backgrounds: [
|
||||||
|
{ name: 'white', value: '#ffffff' },
|
||||||
|
{ name: 'gray', value: '#f8f8f8' },
|
||||||
|
{ name: 'darkBlue', value: '#262c49', default: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
`;
|
||||||
|
const Button = styled.div`
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 15px 20px;
|
||||||
|
color: #fff;
|
||||||
|
background: #000;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Default = () => {
|
||||||
|
const [menu, setMenu] = useState({
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
isOpen: false,
|
||||||
|
});
|
||||||
|
const $buttonRef: any = createRef();
|
||||||
|
const onClick = () => {
|
||||||
|
console.log($buttonRef.current.getBoundingClientRect());
|
||||||
|
setMenu({
|
||||||
|
isOpen: !menu.isOpen,
|
||||||
|
left: $buttonRef.current.getBoundingClientRect().right,
|
||||||
|
top: $buttonRef.current.getBoundingClientRect().bottom,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Container>
|
||||||
|
<Button onClick={onClick} ref={$buttonRef}>
|
||||||
|
Click me
|
||||||
|
</Button>
|
||||||
|
</Container>
|
||||||
|
{menu.isOpen && <DropdownMenu left={menu.left} top={menu.top} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
74
web/src/shared/components/DropdownMenu/Styles.ts
Normal file
74
web/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;
|
||||||
|
padding-top: 10px;
|
||||||
|
position: absolute;
|
||||||
|
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);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
position: relative;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
color: #c2c6dc;
|
||||||
|
background: #262c49;
|
||||||
|
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: rgb(115, 103, 240);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
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;
|
||||||
|
`;
|
32
web/src/shared/components/DropdownMenu/index.tsx
Normal file
32
web/src/shared/components/DropdownMenu/index.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Exit, User } from 'shared/icons';
|
||||||
|
import { Separator, Container, WrapperDiamond, Wrapper, ActionsList, ActionItem, ActionTitle } from './Styles';
|
||||||
|
|
||||||
|
type DropdownMenuProps = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DropdownMenu: React.FC<DropdownMenuProps> = ({ left, top }) => {
|
||||||
|
return (
|
||||||
|
<Container left={left} top={top}>
|
||||||
|
<Wrapper>
|
||||||
|
<ActionItem>
|
||||||
|
<User size={16} color="#c2c6dc" />
|
||||||
|
<ActionTitle>Profile</ActionTitle>
|
||||||
|
</ActionItem>
|
||||||
|
<Separator />
|
||||||
|
<ActionsList>
|
||||||
|
<ActionItem>
|
||||||
|
<Exit size={16} color="#c2c6dc" />
|
||||||
|
<ActionTitle>Logout</ActionTitle>
|
||||||
|
</ActionItem>
|
||||||
|
</ActionsList>
|
||||||
|
</Wrapper>
|
||||||
|
<WrapperDiamond />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DropdownMenu;
|
178
web/src/shared/components/List/List.stories.tsx
Normal file
178
web/src/shared/components/List/List.stories.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import React, { createRef } from 'react';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import Card from 'shared/components/Card';
|
||||||
|
import CardComposer from 'shared/components/CardComposer';
|
||||||
|
import LabelColors from 'shared/constants/labelColors';
|
||||||
|
import List, { ListCards } from './index';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: List,
|
||||||
|
title: 'List',
|
||||||
|
parameters: {
|
||||||
|
backgrounds: [
|
||||||
|
{ name: 'white', value: '#ffffff', default: true },
|
||||||
|
{ name: 'gray', value: '#f8f8f8' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelData = [
|
||||||
|
{
|
||||||
|
labelId: 'development',
|
||||||
|
name: 'Development',
|
||||||
|
color: LabelColors.BLUE,
|
||||||
|
active: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelId: 'general',
|
||||||
|
name: 'General',
|
||||||
|
color: LabelColors.PINK,
|
||||||
|
active: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const createCard = () => {
|
||||||
|
const $ref = createRef<HTMLDivElement>();
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
cardId="1"
|
||||||
|
listId="1"
|
||||||
|
description="hello!"
|
||||||
|
ref={$ref}
|
||||||
|
title="Hello, world"
|
||||||
|
onClick={action('on click')}
|
||||||
|
onContextMenu={action('on context click')}
|
||||||
|
watched
|
||||||
|
labels={labelData}
|
||||||
|
checklists={{ complete: 1, total: 4 }}
|
||||||
|
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Default = () => {
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
id=""
|
||||||
|
name="General"
|
||||||
|
isComposerOpen={false}
|
||||||
|
onSaveName={action('on save name')}
|
||||||
|
onOpenComposer={action('on open composer')}
|
||||||
|
tasks={[]}
|
||||||
|
>
|
||||||
|
<ListCards>
|
||||||
|
<CardComposer
|
||||||
|
onClose={() => {
|
||||||
|
console.log('close!');
|
||||||
|
}}
|
||||||
|
onCreateCard={name => {
|
||||||
|
console.log(name);
|
||||||
|
}}
|
||||||
|
isOpen={false}
|
||||||
|
/>
|
||||||
|
</ListCards>
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithCardComposer = () => {
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
id="1"
|
||||||
|
name="General"
|
||||||
|
isComposerOpen
|
||||||
|
onSaveName={action('on save name')}
|
||||||
|
onOpenComposer={action('on open composer')}
|
||||||
|
tasks={[]}
|
||||||
|
>
|
||||||
|
<ListCards>
|
||||||
|
<CardComposer
|
||||||
|
onClose={() => {
|
||||||
|
console.log('close!');
|
||||||
|
}}
|
||||||
|
onCreateCard={name => {
|
||||||
|
console.log(name);
|
||||||
|
}}
|
||||||
|
isOpen
|
||||||
|
/>
|
||||||
|
</ListCards>
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithCard = () => {
|
||||||
|
const $cardRef: any = createRef();
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
id="1"
|
||||||
|
name="General"
|
||||||
|
isComposerOpen={false}
|
||||||
|
onSaveName={action('on save name')}
|
||||||
|
onOpenComposer={action('on open composer')}
|
||||||
|
tasks={[]}
|
||||||
|
>
|
||||||
|
<ListCards>
|
||||||
|
<Card
|
||||||
|
cardId="1"
|
||||||
|
listId="1"
|
||||||
|
description="hello!"
|
||||||
|
ref={$cardRef}
|
||||||
|
title="Hello, world"
|
||||||
|
onClick={action('on click')}
|
||||||
|
onContextMenu={action('on context click')}
|
||||||
|
watched
|
||||||
|
labels={labelData}
|
||||||
|
checklists={{ complete: 1, total: 4 }}
|
||||||
|
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
|
||||||
|
/>
|
||||||
|
<CardComposer
|
||||||
|
onClose={() => {
|
||||||
|
console.log('close!');
|
||||||
|
}}
|
||||||
|
onCreateCard={name => {
|
||||||
|
console.log(name);
|
||||||
|
}}
|
||||||
|
isOpen={false}
|
||||||
|
/>
|
||||||
|
</ListCards>
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export const WithCardAndComposer = () => {
|
||||||
|
const $cardRef: any = createRef();
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
id="1"
|
||||||
|
name="General"
|
||||||
|
isComposerOpen
|
||||||
|
onSaveName={action('on save name')}
|
||||||
|
onOpenComposer={action('on open composer')}
|
||||||
|
tasks={[]}
|
||||||
|
>
|
||||||
|
<ListCards>
|
||||||
|
<Card
|
||||||
|
cardId="1"
|
||||||
|
listId="1"
|
||||||
|
description="hello!"
|
||||||
|
ref={$cardRef}
|
||||||
|
title="Hello, world"
|
||||||
|
onClick={action('on click')}
|
||||||
|
onContextMenu={action('on context click')}
|
||||||
|
watched
|
||||||
|
labels={labelData}
|
||||||
|
checklists={{ complete: 1, total: 4 }}
|
||||||
|
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
|
||||||
|
/>
|
||||||
|
<CardComposer
|
||||||
|
onClose={() => {
|
||||||
|
console.log('close!');
|
||||||
|
}}
|
||||||
|
onCreateCard={name => {
|
||||||
|
console.log(name);
|
||||||
|
}}
|
||||||
|
isOpen
|
||||||
|
/>
|
||||||
|
</ListCards>
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
};
|
119
web/src/shared/components/List/Styles.ts
Normal file
119
web/src/shared/components/List/Styles.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import styled, { css } from 'styled-components';
|
||||||
|
import TextareaAutosize from 'react-autosize-textarea';
|
||||||
|
import { mixin } from 'shared/utils/styles';
|
||||||
|
|
||||||
|
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: #5e6c84;
|
||||||
|
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 {
|
||||||
|
background-color: rgba(9, 30, 66, 0.08);
|
||||||
|
color: #172b4d;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
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-family: 'Droid Sans';
|
||||||
|
border: none;
|
||||||
|
resize: none;
|
||||||
|
overflow: hidden;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: none;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: -4px 0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
|
||||||
|
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} {
|
||||||
|
background: #fff;
|
||||||
|
border: none;
|
||||||
|
box-shadow: inset 0 0 0 2px #0079bf;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const AddCardButtonText = styled.span`
|
||||||
|
padding-left: 5px;
|
||||||
|
font-family: 'Droid Sans';
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ListCards = styled.div`
|
||||||
|
margin: 0 4px;
|
||||||
|
padding: 0 4px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 30px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
`;
|
101
web/src/shared/components/List/index.tsx
Normal file
101
web/src/shared/components/List/index.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faPlus } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Wrapper,
|
||||||
|
Header,
|
||||||
|
HeaderName,
|
||||||
|
HeaderEditTarget,
|
||||||
|
AddCardContainer,
|
||||||
|
AddCardButton,
|
||||||
|
AddCardButtonText,
|
||||||
|
ListCards,
|
||||||
|
} from './Styles';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
onSaveName: (name: string) => void;
|
||||||
|
isComposerOpen: boolean;
|
||||||
|
onOpenComposer: (id: string) => void;
|
||||||
|
tasks: Task[];
|
||||||
|
wrapperProps?: any;
|
||||||
|
headerProps?: any;
|
||||||
|
index?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const List = React.forwardRef(
|
||||||
|
(
|
||||||
|
{ id, name, onSaveName, isComposerOpen, onOpenComposer, children, wrapperProps, headerProps }: Props,
|
||||||
|
$wrapperRef: any,
|
||||||
|
) => {
|
||||||
|
const [listName, setListName] = useState(name);
|
||||||
|
const [isEditingTitle, setEditingTitle] = useState(false);
|
||||||
|
const $listNameRef: any = useRef<HTMLTextAreaElement>();
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
setEditingTitle(true);
|
||||||
|
if ($listNameRef) {
|
||||||
|
$listNameRef.current.select();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onBlur = () => {
|
||||||
|
setEditingTitle(false);
|
||||||
|
onSaveName(listName);
|
||||||
|
};
|
||||||
|
const onEscape = () => {
|
||||||
|
$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();
|
||||||
|
$listNameRef.current.blur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
useOnEscapeKeyDown(isEditingTitle, onEscape);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container ref={$wrapperRef} {...wrapperProps}>
|
||||||
|
<Wrapper>
|
||||||
|
<Header {...headerProps} isEditing={isEditingTitle}>
|
||||||
|
<HeaderEditTarget onClick={onClick} isHidden={isEditingTitle} />
|
||||||
|
<HeaderName
|
||||||
|
ref={$listNameRef}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onChange={onChange}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
spellCheck={false}
|
||||||
|
value={listName}
|
||||||
|
/>
|
||||||
|
</Header>
|
||||||
|
{children && children}
|
||||||
|
<AddCardContainer hidden={isComposerOpen}>
|
||||||
|
<AddCardButton onClick={() => onOpenComposer(id)}>
|
||||||
|
<FontAwesomeIcon icon={faPlus} size="xs" color="#42526e" />
|
||||||
|
<AddCardButtonText>Add another card</AddCardButtonText>
|
||||||
|
</AddCardButton>
|
||||||
|
</AddCardContainer>
|
||||||
|
</Wrapper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
List.defaultProps = {
|
||||||
|
children: null,
|
||||||
|
isComposerOpen: false,
|
||||||
|
wrapperProps: {},
|
||||||
|
headerProps: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
List.displayName = 'List';
|
||||||
|
export default List;
|
||||||
|
|
||||||
|
export { ListCards };
|
184
web/src/shared/components/Lists/Lists.stories.tsx
Normal file
184
web/src/shared/components/Lists/Lists.stories.tsx
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import Lists from './index';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: Lists,
|
||||||
|
title: 'Lists',
|
||||||
|
parameters: {
|
||||||
|
backgrounds: [
|
||||||
|
{ name: 'white', value: '#ffffff', default: true },
|
||||||
|
{ name: 'gray', value: '#f8f8f8' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialListsData = {
|
||||||
|
columns: {
|
||||||
|
'column-1': {
|
||||||
|
taskGroupID: 'column-1',
|
||||||
|
name: 'General',
|
||||||
|
taskIds: ['task-3', 'task-4', 'task-1', 'task-2'],
|
||||||
|
position: 1,
|
||||||
|
tasks: [],
|
||||||
|
},
|
||||||
|
'column-2': {
|
||||||
|
taskGroupID: 'column-2',
|
||||||
|
name: 'Development',
|
||||||
|
taskIds: [],
|
||||||
|
position: 2,
|
||||||
|
tasks: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
'task-1': {
|
||||||
|
taskID: 'task-1',
|
||||||
|
taskGroupID: 'column-1',
|
||||||
|
name: 'Create roadmap',
|
||||||
|
position: 2,
|
||||||
|
labels: [],
|
||||||
|
},
|
||||||
|
'task-2': {
|
||||||
|
taskID: 'task-2',
|
||||||
|
taskGroupID: 'column-1',
|
||||||
|
position: 1,
|
||||||
|
name: 'Create authentication',
|
||||||
|
labels: [],
|
||||||
|
},
|
||||||
|
'task-3': {
|
||||||
|
taskID: 'task-3',
|
||||||
|
taskGroupID: 'column-1',
|
||||||
|
position: 3,
|
||||||
|
name: 'Create login',
|
||||||
|
labels: [],
|
||||||
|
},
|
||||||
|
'task-4': {
|
||||||
|
taskID: 'task-4',
|
||||||
|
taskGroupID: 'column-1',
|
||||||
|
position: 4,
|
||||||
|
name: 'Create plugins',
|
||||||
|
labels: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Default = () => {
|
||||||
|
const [listsData, setListsData] = useState(initialListsData);
|
||||||
|
const onCardDrop = (droppedTask: any) => {
|
||||||
|
console.log(droppedTask);
|
||||||
|
const newState = {
|
||||||
|
...listsData,
|
||||||
|
tasks: {
|
||||||
|
...listsData.tasks,
|
||||||
|
[droppedTask.id]: droppedTask,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
console.log(newState);
|
||||||
|
setListsData(newState);
|
||||||
|
};
|
||||||
|
const onListDrop = (droppedColumn: any) => {
|
||||||
|
const newState = {
|
||||||
|
...listsData,
|
||||||
|
columns: {
|
||||||
|
...listsData.columns,
|
||||||
|
[droppedColumn.id]: droppedColumn,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setListsData(newState);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Lists
|
||||||
|
{...listsData}
|
||||||
|
onQuickEditorOpen={action('card composer open')}
|
||||||
|
onCardDrop={onCardDrop}
|
||||||
|
onListDrop={onListDrop}
|
||||||
|
onCardCreate={action('card create')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createColumn = (id: any, name: any, position: any) => {
|
||||||
|
return {
|
||||||
|
taskGroupID: id,
|
||||||
|
name,
|
||||||
|
position,
|
||||||
|
tasks: [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialListsDataLarge = {
|
||||||
|
columns: {
|
||||||
|
'column-1': createColumn('column-1', 'General', 1),
|
||||||
|
'column-2': createColumn('column-2', 'General', 2),
|
||||||
|
'column-3': createColumn('column-3', 'General', 3),
|
||||||
|
'column-4': createColumn('column-4', 'General', 4),
|
||||||
|
'column-5': createColumn('column-5', 'General', 5),
|
||||||
|
'column-6': createColumn('column-6', 'General', 6),
|
||||||
|
'column-7': createColumn('column-7', 'General', 7),
|
||||||
|
'column-8': createColumn('column-8', 'General', 8),
|
||||||
|
'column-9': createColumn('column-9', 'General', 9),
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
'task-1': {
|
||||||
|
taskID: 'task-1',
|
||||||
|
taskGroupID: 'column-1',
|
||||||
|
name: 'Create roadmap',
|
||||||
|
position: 2,
|
||||||
|
labels: [],
|
||||||
|
},
|
||||||
|
'task-2': {
|
||||||
|
taskID: 'task-2',
|
||||||
|
taskGroupID: 'column-1',
|
||||||
|
position: 1,
|
||||||
|
name: 'Create authentication',
|
||||||
|
labels: [],
|
||||||
|
},
|
||||||
|
'task-3': {
|
||||||
|
taskID: 'task-3',
|
||||||
|
taskGroupID: 'column-1',
|
||||||
|
position: 3,
|
||||||
|
name: 'Create login',
|
||||||
|
labels: [],
|
||||||
|
},
|
||||||
|
'task-4': {
|
||||||
|
taskID: 'task-4',
|
||||||
|
taskGroupID: 'column-1',
|
||||||
|
position: 4,
|
||||||
|
name: 'Create plugins',
|
||||||
|
labels: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ListsWithManyList = () => {
|
||||||
|
const [listsData, setListsData] = useState(initialListsDataLarge);
|
||||||
|
const onCardDrop = (droppedTask: any) => {
|
||||||
|
const newState = {
|
||||||
|
...listsData,
|
||||||
|
tasks: {
|
||||||
|
...listsData.tasks,
|
||||||
|
[droppedTask.id]: droppedTask,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setListsData(newState);
|
||||||
|
};
|
||||||
|
const onListDrop = (droppedColumn: any) => {
|
||||||
|
const newState = {
|
||||||
|
...listsData,
|
||||||
|
columns: {
|
||||||
|
...listsData.columns,
|
||||||
|
[droppedColumn.id]: droppedColumn,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setListsData(newState);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Lists
|
||||||
|
{...listsData}
|
||||||
|
onQuickEditorOpen={action('card composer open')}
|
||||||
|
onCardCreate={action('card create')}
|
||||||
|
onCardDrop={onCardDrop}
|
||||||
|
onListDrop={onListDrop}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
11
web/src/shared/components/Lists/Styles.ts
Normal file
11
web/src/shared/components/Lists/Styles.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
export const Container = styled.div`
|
||||||
|
flex-grow: 1;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
`;
|
196
web/src/shared/components/Lists/index.tsx
Normal file
196
web/src/shared/components/Lists/index.tsx
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
|
||||||
|
|
||||||
|
import { moveItemWithinArray, insertItemIntoArray } from 'shared/utils/arrays';
|
||||||
|
import List, { ListCards } from 'shared/components/List';
|
||||||
|
import Card from 'shared/components/Card';
|
||||||
|
import { Container } from './Styles';
|
||||||
|
import CardComposer from 'shared/components/CardComposer';
|
||||||
|
|
||||||
|
const getNewDraggablePosition = (afterDropDraggables: any, draggableIndex: any) => {
|
||||||
|
const prevDraggable = afterDropDraggables[draggableIndex - 1];
|
||||||
|
const nextDraggable = afterDropDraggables[draggableIndex + 1];
|
||||||
|
if (!prevDraggable && !nextDraggable) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (!prevDraggable) {
|
||||||
|
return nextDraggable.position - 1;
|
||||||
|
}
|
||||||
|
if (!nextDraggable) {
|
||||||
|
return prevDraggable.position + 1;
|
||||||
|
}
|
||||||
|
const newPos = (prevDraggable.position + nextDraggable.position) / 2.0;
|
||||||
|
return newPos;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSortedDraggables = (draggables: any) => {
|
||||||
|
return draggables.sort((a: any, b: any) => a.position - b.position);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPositionChanged = (source: any, destination: any) => {
|
||||||
|
if (!destination) return false;
|
||||||
|
const isSameList = destination.droppableId === source.droppableId;
|
||||||
|
const isSamePosition = destination.index === source.index;
|
||||||
|
return !isSameList || !isSamePosition;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAfterDropDraggableList = (
|
||||||
|
beforeDropDraggables: any,
|
||||||
|
droppedDraggable: any,
|
||||||
|
isList: any,
|
||||||
|
isSameList: any,
|
||||||
|
destination: any,
|
||||||
|
) => {
|
||||||
|
if (isList) {
|
||||||
|
return moveItemWithinArray(beforeDropDraggables, droppedDraggable, destination.index);
|
||||||
|
}
|
||||||
|
return isSameList
|
||||||
|
? moveItemWithinArray(beforeDropDraggables, droppedDraggable, destination.index)
|
||||||
|
: insertItemIntoArray(beforeDropDraggables, droppedDraggable, destination.index);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Columns {
|
||||||
|
[key: string]: TaskGroup;
|
||||||
|
}
|
||||||
|
interface Tasks {
|
||||||
|
[key: string]: RemoteTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
columns: Columns;
|
||||||
|
tasks: Tasks;
|
||||||
|
onCardDrop: any;
|
||||||
|
onListDrop: any;
|
||||||
|
onCardCreate: (taskGroupID: string, name: string) => void;
|
||||||
|
onQuickEditorOpen: (e: ContextMenuEvent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OnDragEndProps = {
|
||||||
|
draggableId: any;
|
||||||
|
source: any;
|
||||||
|
destination: any;
|
||||||
|
type: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Lists = ({ columns, tasks, onCardDrop, onListDrop, onCardCreate, onQuickEditorOpen }: Props) => {
|
||||||
|
const onDragEnd = ({ draggableId, source, destination, type }: DropResult) => {
|
||||||
|
if (typeof destination === 'undefined') return;
|
||||||
|
if (!isPositionChanged(source, destination)) return;
|
||||||
|
|
||||||
|
const isList = type === 'column';
|
||||||
|
const isSameList = destination.droppableId === source.droppableId;
|
||||||
|
const droppedDraggable = isList ? columns[draggableId] : tasks[draggableId];
|
||||||
|
const beforeDropDraggables = isList
|
||||||
|
? getSortedDraggables(Object.values(columns))
|
||||||
|
: getSortedDraggables(Object.values(tasks).filter((t: any) => t.taskGroupID === destination.droppableId));
|
||||||
|
|
||||||
|
const afterDropDraggables = getAfterDropDraggableList(
|
||||||
|
beforeDropDraggables,
|
||||||
|
droppedDraggable,
|
||||||
|
isList,
|
||||||
|
isSameList,
|
||||||
|
destination,
|
||||||
|
);
|
||||||
|
const newPosition = getNewDraggablePosition(afterDropDraggables, destination.index);
|
||||||
|
|
||||||
|
if (isList) {
|
||||||
|
onListDrop({
|
||||||
|
...droppedDraggable,
|
||||||
|
position: newPosition,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const newCard = {
|
||||||
|
...droppedDraggable,
|
||||||
|
position: newPosition,
|
||||||
|
taskGroupID: destination.droppableId,
|
||||||
|
};
|
||||||
|
onCardDrop(newCard);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const orderedColumns = getSortedDraggables(Object.values(columns));
|
||||||
|
|
||||||
|
const [currentComposer, setCurrentComposer] = useState('');
|
||||||
|
return (
|
||||||
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
|
<Droppable direction="horizontal" type="column" droppableId="root">
|
||||||
|
{provided => (
|
||||||
|
<Container {...provided.droppableProps} ref={provided.innerRef}>
|
||||||
|
{orderedColumns.map((column: TaskGroup, index: number) => {
|
||||||
|
const columnCards = getSortedDraggables(
|
||||||
|
Object.values(tasks).filter((t: any) => t.taskGroupID === column.taskGroupID),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Draggable draggableId={column.taskGroupID} key={column.taskGroupID} index={index}>
|
||||||
|
{columnDragProvided => (
|
||||||
|
<List
|
||||||
|
id={column.taskGroupID}
|
||||||
|
name={column.name}
|
||||||
|
key={column.taskGroupID}
|
||||||
|
onOpenComposer={id => setCurrentComposer(id)}
|
||||||
|
isComposerOpen={currentComposer === column.taskGroupID}
|
||||||
|
onSaveName={name => console.log(name)}
|
||||||
|
index={index}
|
||||||
|
tasks={columnCards}
|
||||||
|
ref={columnDragProvided.innerRef}
|
||||||
|
wrapperProps={columnDragProvided.draggableProps}
|
||||||
|
headerProps={columnDragProvided.dragHandleProps}
|
||||||
|
>
|
||||||
|
<Droppable type="tasks" droppableId={column.taskGroupID}>
|
||||||
|
{columnDropProvided => (
|
||||||
|
<ListCards ref={columnDropProvided.innerRef} {...columnDropProvided.droppableProps}>
|
||||||
|
{columnCards.map((task: RemoteTask, taskIndex: any) => {
|
||||||
|
return (
|
||||||
|
<Draggable key={task.taskID} draggableId={task.taskID} index={taskIndex}>
|
||||||
|
{taskProvided => {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
wrapperProps={{
|
||||||
|
...taskProvided.draggableProps,
|
||||||
|
...taskProvided.dragHandleProps,
|
||||||
|
}}
|
||||||
|
ref={taskProvided.innerRef}
|
||||||
|
cardId={task.taskID}
|
||||||
|
listId={column.taskGroupID}
|
||||||
|
description=""
|
||||||
|
title={task.name}
|
||||||
|
labels={task.labels}
|
||||||
|
onClick={e => console.log(e)}
|
||||||
|
onContextMenu={onQuickEditorOpen}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Draggable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{columnDropProvided.placeholder}
|
||||||
|
|
||||||
|
{currentComposer === column.taskGroupID && (
|
||||||
|
<CardComposer
|
||||||
|
onClose={() => {
|
||||||
|
setCurrentComposer('');
|
||||||
|
}}
|
||||||
|
onCreateCard={name => {
|
||||||
|
setCurrentComposer('');
|
||||||
|
onCardCreate(column.taskGroupID, name);
|
||||||
|
}}
|
||||||
|
isOpen={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ListCards>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{provided.placeholder}
|
||||||
|
</Container>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</DragDropContext>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Lists;
|
67
web/src/shared/components/Login/Login.stories.tsx
Normal file
67
web/src/shared/components/Login/Login.stories.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import NormalizeStyles from 'App/NormalizeStyles';
|
||||||
|
import BaseStyles from 'App/BaseStyles';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import Login from './index';
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: Login,
|
||||||
|
title: 'Login',
|
||||||
|
parameters: {
|
||||||
|
backgrounds: [
|
||||||
|
{ name: 'white', value: '#ffffff' },
|
||||||
|
{ name: 'gray', value: '#cdd3e1', default: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const LoginWrapper = styled.div`
|
||||||
|
width: 60%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Default = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NormalizeStyles />
|
||||||
|
<BaseStyles />
|
||||||
|
<Container>
|
||||||
|
<LoginWrapper>
|
||||||
|
<Login onSubmit={action('on submit')} />
|
||||||
|
</LoginWrapper>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithSubmission = () => {
|
||||||
|
const onSubmit = async (data: LoginFormData, setComplete: (val: boolean) => void, setError: any) => {
|
||||||
|
await sleep(2000);
|
||||||
|
if (data.username !== 'test' || data.password !== 'test') {
|
||||||
|
setError('username', 'invalid', 'Invalid username');
|
||||||
|
setError('password', 'invalid', 'Invalid password');
|
||||||
|
}
|
||||||
|
setComplete(true);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NormalizeStyles />
|
||||||
|
<BaseStyles />
|
||||||
|
<Container>
|
||||||
|
<LoginWrapper>
|
||||||
|
<Login onSubmit={onSubmit} />
|
||||||
|
</LoginWrapper>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
103
web/src/shared/components/Login/Styles.ts
Normal file
103
web/src/shared/components/Login/Styles.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
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.input`
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgb(115, 103, 240);
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #fff;
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ActionButtons = styled.div`
|
||||||
|
margin-top: 17.5px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const RegisterButton = styled.button`
|
||||||
|
padding: 0.679rem 2rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgb(115, 103, 240);
|
||||||
|
background: transparent;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: rgba(115, 103, 240);
|
||||||
|
cursor: pointer;
|
||||||
|
`;
|
81
web/src/shared/components/Login/index.tsx
Normal file
81
web/src/shared/components/Login/index.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import AccessAccount from 'shared/undraw/AccessAccount';
|
||||||
|
import { User, Lock } from 'shared/icons';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
ActionButtons,
|
||||||
|
RegisterButton,
|
||||||
|
LoginButton,
|
||||||
|
FormError,
|
||||||
|
FormIcon,
|
||||||
|
FormLabel,
|
||||||
|
FormTextInput,
|
||||||
|
Wrapper,
|
||||||
|
Column,
|
||||||
|
LoginFormWrapper,
|
||||||
|
LoginFormContainer,
|
||||||
|
Title,
|
||||||
|
SubTitle,
|
||||||
|
} from './Styles';
|
||||||
|
|
||||||
|
const Login = ({ onSubmit }: LoginProps) => {
|
||||||
|
const [isComplete, setComplete] = useState(true);
|
||||||
|
const { register, handleSubmit, errors, setError, formState } = useForm<LoginFormData>();
|
||||||
|
console.log(formState);
|
||||||
|
const loginSubmit = (data: LoginFormData) => {
|
||||||
|
setComplete(false);
|
||||||
|
onSubmit(data, setComplete, setError);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
<Column>
|
||||||
|
<AccessAccount width={275} height={250} />
|
||||||
|
</Column>
|
||||||
|
<Column>
|
||||||
|
<LoginFormWrapper>
|
||||||
|
<LoginFormContainer>
|
||||||
|
<Title>Login</Title>
|
||||||
|
<SubTitle>Welcome back, please login into your account.</SubTitle>
|
||||||
|
<Form onSubmit={handleSubmit(loginSubmit)}>
|
||||||
|
<FormLabel htmlFor="username">
|
||||||
|
Username
|
||||||
|
<FormTextInput
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
ref={register({ required: 'Username is required' })}
|
||||||
|
/>
|
||||||
|
<FormIcon>
|
||||||
|
<User color="#c2c6dc" size={20} />
|
||||||
|
</FormIcon>
|
||||||
|
</FormLabel>
|
||||||
|
{errors.username && <FormError>{errors.username.message}</FormError>}
|
||||||
|
<FormLabel htmlFor="password">
|
||||||
|
Password
|
||||||
|
<FormTextInput
|
||||||
|
type="text"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
ref={register({ required: 'Password is required' })}
|
||||||
|
/>
|
||||||
|
<FormIcon>
|
||||||
|
<Lock color="#c2c6dc" size={20} />
|
||||||
|
</FormIcon>
|
||||||
|
</FormLabel>
|
||||||
|
{errors.password && <FormError>{errors.password.message}</FormError>}
|
||||||
|
|
||||||
|
<ActionButtons>
|
||||||
|
<RegisterButton>Register</RegisterButton>
|
||||||
|
<LoginButton type="submit" value="Login" disabled={!isComplete} />
|
||||||
|
</ActionButtons>
|
||||||
|
</Form>
|
||||||
|
</LoginFormContainer>
|
||||||
|
</LoginFormWrapper>
|
||||||
|
</Column>
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Login;
|
41
web/src/shared/components/Navbar/Navbar.stories.tsx
Normal file
41
web/src/shared/components/Navbar/Navbar.stories.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import NormalizeStyles from 'App/NormalizeStyles';
|
||||||
|
import BaseStyles from 'App/BaseStyles';
|
||||||
|
import { Home, Stack, Users, Question } from 'shared/icons';
|
||||||
|
import Navbar, { ActionButton, ButtonContainer, PrimaryLogo } from './index';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: Navbar,
|
||||||
|
title: 'Navbar',
|
||||||
|
parameters: {
|
||||||
|
backgrounds: [
|
||||||
|
{ name: 'white', value: '#ffffff', default: true },
|
||||||
|
{ name: 'gray', value: '#cdd3e1' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const MainContent = styled.div`
|
||||||
|
padding: 0 0 50px 80px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Default = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NormalizeStyles />
|
||||||
|
<BaseStyles />
|
||||||
|
<Navbar>
|
||||||
|
<PrimaryLogo />
|
||||||
|
<ButtonContainer>
|
||||||
|
<ActionButton name="Home">
|
||||||
|
<Home size={28} color="#c2c6dc" />
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton name="Home">
|
||||||
|
<Home size={28} color="#c2c6dc" />
|
||||||
|
</ActionButton>
|
||||||
|
</ButtonContainer>
|
||||||
|
</Navbar>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
105
web/src/shared/components/Navbar/Styles.ts
Normal file
105
web/src/shared/components/Navbar/Styles.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import styled, { css } from 'styled-components';
|
||||||
|
|
||||||
|
export const LogoWrapper = styled.div`
|
||||||
|
margin: 20px 0px 20px;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 42px;
|
||||||
|
line-height: 42px;
|
||||||
|
padding-left: 64px;
|
||||||
|
color: rgb(222, 235, 255);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: color 0.1s ease 0s;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Logo = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
left: 19px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LogoTitle = styled.div`
|
||||||
|
position: relative;
|
||||||
|
right: 12px;
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: right 0.1s ease 0s, visibility, opacity, transform 0.25s ease;
|
||||||
|
color: #7367f0;
|
||||||
|
`;
|
||||||
|
export const ActionContainer = styled.div`
|
||||||
|
position: relative;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ActionButtonTitle = styled.span`
|
||||||
|
position: relative;
|
||||||
|
visibility: hidden;
|
||||||
|
left: -5px;
|
||||||
|
opacity: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: left 0.1s ease 0s, visibility, opacity, transform 0.25s ease;
|
||||||
|
|
||||||
|
font-size: 18px;
|
||||||
|
color: #c2c6dc;
|
||||||
|
`;
|
||||||
|
export const IconWrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ActionButtonContainer = styled.div`
|
||||||
|
padding: 0 12px;
|
||||||
|
position: relative;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ActionButtonWrapper = styled.div<{ active?: boolean }>`
|
||||||
|
${props =>
|
||||||
|
props.active &&
|
||||||
|
css`
|
||||||
|
background: rgb(115, 103, 240);
|
||||||
|
box-shadow: 0 0 10px 1px rgba(115, 103, 240, 0.7);
|
||||||
|
`}
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 10px 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
&:hover ${ActionButtonTitle} {
|
||||||
|
transform: translateX(5px);
|
||||||
|
}
|
||||||
|
&:hover ${IconWrapper} {
|
||||||
|
transform: translateX(5px);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Container = styled.aside`
|
||||||
|
z-index: 100;
|
||||||
|
position: fixed;
|
||||||
|
top: 0px;
|
||||||
|
left: 0px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
height: 100vh;
|
||||||
|
width: 80px;
|
||||||
|
transform: translateZ(0px);
|
||||||
|
background: #10163a;
|
||||||
|
transition: all 0.1s ease 0s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
width: 260px;
|
||||||
|
box-shadow: rgba(0, 0, 0, 0.6) 0px 0px 50px 0px;
|
||||||
|
}
|
||||||
|
&:hover ${LogoTitle} {
|
||||||
|
right: 0px;
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
&:hover ${ActionButtonTitle} {
|
||||||
|
left: 15px;
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
`;
|
50
web/src/shared/components/Navbar/index.tsx
Normal file
50
web/src/shared/components/Navbar/index.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Citadel } from 'shared/icons';
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
LogoWrapper,
|
||||||
|
IconWrapper,
|
||||||
|
Logo,
|
||||||
|
LogoTitle,
|
||||||
|
ActionContainer,
|
||||||
|
ActionButtonContainer,
|
||||||
|
ActionButtonWrapper,
|
||||||
|
ActionButtonTitle,
|
||||||
|
} from './Styles';
|
||||||
|
|
||||||
|
type ActionButtonProps = {
|
||||||
|
name: string;
|
||||||
|
active?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ActionButton: React.FC<ActionButtonProps> = ({ name, active, children }) => {
|
||||||
|
return (
|
||||||
|
<ActionButtonWrapper active={active ?? false}>
|
||||||
|
<IconWrapper>{children}</IconWrapper>
|
||||||
|
<ActionButtonTitle>{name}</ActionButtonTitle>
|
||||||
|
</ActionButtonWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ButtonContainer: React.FC = ({ children }) => (
|
||||||
|
<ActionContainer>
|
||||||
|
<ActionButtonContainer>{children}</ActionButtonContainer>
|
||||||
|
</ActionContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PrimaryLogo = () => {
|
||||||
|
return (
|
||||||
|
<LogoWrapper>
|
||||||
|
<Logo>
|
||||||
|
<Citadel size={42} />
|
||||||
|
</Logo>
|
||||||
|
<LogoTitle>Citadel</LogoTitle>
|
||||||
|
</LogoWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Navbar: React.FC = ({ children }) => {
|
||||||
|
return <Container>{children}</Container>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Navbar;
|
31
web/src/shared/components/PopupMenu/LabelEditor.tsx
Normal file
31
web/src/shared/components/PopupMenu/LabelEditor.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import LabelColors from 'shared/constants/labelColors';
|
||||||
|
import { Checkmark } from 'shared/icons';
|
||||||
|
import { SaveButton, DeleteButton, LabelBox, EditLabelForm, FieldLabel, FieldName } from './Styles';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
label: Label;
|
||||||
|
onLabelEdit: (labelId: string, labelName: string, color: string) => void;
|
||||||
|
};
|
||||||
|
const LabelManager = ({ label, onLabelEdit }: Props) => {
|
||||||
|
const [currentLabel, setCurrentLabel] = useState('');
|
||||||
|
return (
|
||||||
|
<EditLabelForm>
|
||||||
|
<FieldLabel>Name</FieldLabel>
|
||||||
|
<FieldName id="labelName" type="text" name="name" value={currentLabel} />
|
||||||
|
<FieldLabel>Select a color</FieldLabel>
|
||||||
|
<div>
|
||||||
|
{Object.values(LabelColors).map(labelColor => (
|
||||||
|
<LabelBox color={labelColor}>
|
||||||
|
<Checkmark color="#fff" size={12} />
|
||||||
|
</LabelBox>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<SaveButton type="submit" value="Save" />
|
||||||
|
<DeleteButton type="submit" value="Delete" />
|
||||||
|
</div>
|
||||||
|
</EditLabelForm>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default LabelManager;
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user