From 18dd18f32362b23674520425f691512eaadb7556 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Wed, 19 Jan 2022 04:57:17 +0000 Subject: [PATCH 01/41] Add several dependencies needed for API routes --- buildmode/buildmode.go | 21 +++ go.mod | 6 + go.sum | 19 ++ longid/id.go | 214 +++++++++++++++++++++ longid/id_test.go | 80 ++++++++ srverr/error.go | 10 + srverr/error_test.go | 19 ++ srverr/errors.go | 65 +++++++ srverr/wrap.go | 48 +++++ validate/devurl.go | 38 ++++ validate/devurl_test.go | 47 +++++ validate/numeric.go | 15 ++ validate/numeric_test.go | 24 +++ validate/struct.go | 285 ++++++++++++++++++++++++++++ validate/struct_test.go | 206 ++++++++++++++++++++ validate/user.go | 37 ++++ validate/user_test.go | 65 +++++++ validate/validator.go | 46 +++++ validate/validator_test.go | 46 +++++ validate/vassert/assert.go | 68 +++++++ validate/vassert/doc.go | 6 + xjson/duration.go | 32 ++++ xjson/duration_test.go | 22 +++ xjson/error.go | 207 +++++++++++++++++++++ xjson/error_page.html | 117 ++++++++++++ xjson/error_test.go | 238 ++++++++++++++++++++++++ xjson/json.go | 372 +++++++++++++++++++++++++++++++++++++ xjson/json_test.go | 241 ++++++++++++++++++++++++ 28 files changed, 2594 insertions(+) create mode 100644 buildmode/buildmode.go create mode 100644 longid/id.go create mode 100644 longid/id_test.go create mode 100644 srverr/error.go create mode 100644 srverr/error_test.go create mode 100644 srverr/errors.go create mode 100644 srverr/wrap.go create mode 100644 validate/devurl.go create mode 100644 validate/devurl_test.go create mode 100644 validate/numeric.go create mode 100644 validate/numeric_test.go create mode 100644 validate/struct.go create mode 100644 validate/struct_test.go create mode 100644 validate/user.go create mode 100644 validate/user_test.go create mode 100644 validate/validator.go create mode 100644 validate/validator_test.go create mode 100644 validate/vassert/assert.go create mode 100644 validate/vassert/doc.go create mode 100644 xjson/duration.go create mode 100644 xjson/duration_test.go create mode 100644 xjson/error.go create mode 100644 xjson/error_page.html create mode 100644 xjson/error_test.go create mode 100644 xjson/json.go create mode 100644 xjson/json_test.go diff --git a/buildmode/buildmode.go b/buildmode/buildmode.go new file mode 100644 index 0000000000000..1636d589cb790 --- /dev/null +++ b/buildmode/buildmode.go @@ -0,0 +1,21 @@ +package buildmode + +import ( + "flag" + "strings" +) + +// BuildMode is injected at build time. +var ( + BuildMode string +) + +// Dev returns true when built to run in a dev deployment. +func Dev() bool { + return strings.HasPrefix(BuildMode, "dev") +} + +// Test returns true when running inside a unit test. +func Test() bool { + return flag.Lookup("test.v") != nil +} diff --git a/go.mod b/go.mod index 577e7278aa438..214e497aa8dd3 100644 --- a/go.mod +++ b/go.mod @@ -42,6 +42,7 @@ require ( github.com/alecthomas/chroma v0.9.1 // indirect github.com/apparentlymart/go-textseg v1.0.0 // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect + github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect github.com/cenkalti/backoff/v4 v4.1.2 // indirect github.com/containerd/continuity v0.1.0 // indirect github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect @@ -53,6 +54,9 @@ require ( github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-units v0.4.0 // indirect github.com/fatih/color v1.13.0 // indirect + github.com/go-playground/locales v0.14.0 // indirect + github.com/go-playground/universal-translator v0.18.0 // indirect + github.com/go-playground/validator/v10 v10.10.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/go-cmp v0.5.6 // indirect @@ -66,6 +70,7 @@ require ( github.com/imdario/mergo v0.3.12 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/justinas/nosurf v1.1.1 // indirect + github.com/leodido/go-urn v1.2.1 // indirect github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.14 // indirect github.com/mitchellh/go-wordwrap v1.0.0 // indirect @@ -107,4 +112,5 @@ require ( google.golang.org/grpc v1.43.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect + k8s.io/utils v0.0.0-20211208161948-7d6a63dca704 // indirect ) diff --git a/go.sum b/go.sum index 009f6283c4c39..3803458b6f3ac 100644 --- a/go.sum +++ b/go.sum @@ -151,6 +151,8 @@ github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ= +github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/aws/aws-sdk-go v1.15.11/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0= github.com/aws/aws-sdk-go v1.15.78/go.mod h1:E3/ieXAlvM0XWO57iftYVDLLvQ824smPP3ATZkfNZeM= github.com/aws/aws-sdk-go v1.17.7 h1:/4+rDPe0W95KBmNGYCG+NUvdL8ssPYBMxL+aSCg6nIA= @@ -468,6 +470,13 @@ github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL9 github.com/go-openapi/spec v0.19.3/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo= github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU= +github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= +github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho= +github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= +github.com/go-playground/validator/v10 v10.10.0 h1:I7mrTYv78z8k8VXa/qJlOlEXn/nBh+BF8dHX5nt/dr0= +github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= @@ -804,6 +813,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= @@ -818,6 +829,8 @@ github.com/kylecarbs/terraform-config-inspect v0.0.0-20211215004401-bbc517866b88 github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= +github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/lib/pq v0.0.0-20180327071824-d34b9ff171c2/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= @@ -1020,6 +1033,7 @@ github.com/pion/webrtc/v3 v3.1.13 h1:2XxgGstOqt03ba8QD5+m9S8DCA3Ez53mULT4If8onOg github.com/pion/webrtc/v3 v3.1.13/go.mod h1:RACpyE1EDYlzonfbdPvXkIGDaqD8+NsHqZJN0yEbRbA= github.com/pkg/browser v0.0.0-20210706143420-7d21f8c997e2/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -1069,6 +1083,8 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= @@ -1265,6 +1281,7 @@ golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= @@ -1889,6 +1906,8 @@ k8s.io/klog/v2 v2.4.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= k8s.io/kube-openapi v0.0.0-20201113171705-d219536bb9fd/go.mod h1:WOJ3KddDSol4tAGcJo0Tvi+dK12EcqSLqcWsryKMpfM= k8s.io/kubernetes v1.13.0/go.mod h1:ocZa8+6APFNC2tX1DZASIbocyYT5jHzqFVsY5aoB7Jk= k8s.io/utils v0.0.0-20201110183641-67b214c5f920/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= +k8s.io/utils v0.0.0-20211208161948-7d6a63dca704 h1:ZKMMxTvduyf5WUtREOqg5LiXaN1KO/+0oOQPRFrClpo= +k8s.io/utils v0.0.0-20211208161948-7d6a63dca704/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= modernc.org/b v1.0.0/go.mod h1:uZWcZfRj1BpYzfN9JTerzlNUnnPsV9O2ZA8JsRcubNg= modernc.org/cc/v3 v3.32.4/go.mod h1:0R6jl1aZlIl2avnYfbfHBS1QB6/f+16mihBObaBC878= modernc.org/ccgo/v3 v3.9.2/go.mod h1:gnJpy6NIVqkETT+L5zPsQFj7L2kkhfPMzOghRNv/CFo= diff --git a/longid/id.go b/longid/id.go new file mode 100644 index 0000000000000..db520409b8924 --- /dev/null +++ b/longid/id.go @@ -0,0 +1,214 @@ +package longid + +import ( + "bytes" + "database/sql" + "database/sql/driver" + "encoding/binary" + "encoding/hex" + "fmt" + "hash/fnv" + "math/rand" + "os" + "sync/atomic" + "time" + + "golang.org/x/xerrors" +) + +// bit counts. +const ( + TimeBits = 32 + IncrementorBits = 10 + + RandomBits = 70 + HostIDBits = 8 + + // amount of random bits in each uint64 + RandomBits1 = 64 - (TimeBits + IncrementorBits) + RandomBits2 = RandomBits - RandomBits1 + + HostMask = 0x00000000000000FF +) + +var ( + inc uint32 + hostID int64 +) + +func init() { + rand.Seed(time.Now().UnixNano()) + + hostname, err := os.Hostname() + if err != nil { + panic(err) + } + hash := fnv.New64a() + _, _ = hash.Write([]byte(hostname)) + hostID = (int64(hash.Sum64())) & HostMask + inc = rand.Uint32() +} + +// HostID returns the host ID for the current machine. +func HostID() int64 { + return hostID +} + +// ID describes a 128 bit ID +type ID [16]byte + +// parse errors +var ( + ErrWrongSize = xerrors.New("id in string form should be exactly 33 bytes") +) + +// FromSlice converts a slice into an ID. +func FromSlice(b []byte) ID { + var l ID + copy(l[:], b) + return l +} + +func part1() int64 { + seconds := time.Now().Unix() + + // place time portion properly + time := seconds << (64 - TimeBits) + + i := atomic.AddUint32(&inc, 1) + + // reset incrementor if it's too big + atomic.CompareAndSwapUint32(&inc, ((1 << IncrementorBits) - 1), 0) + + i <<= (RandomBits1) + + var randBuf [4]byte + _, _ = rand.Read(randBuf[:]) + + rand := (binary.BigEndian.Uint32(randBuf[:]) >> (32 - RandomBits1)) + + return time + int64(i) + int64(rand) +} + +func part2() int64 { + var randBuf [8]byte + _, _ = rand.Read(randBuf[:]) + rand := binary.BigEndian.Uint64(randBuf[:]) << HostIDBits + // fmt.Printf("%x\n", rand) + + return int64(rand) + hostID +} + +// New generates a long ID. +func New() ID { + var id ID + binary.BigEndian.PutUint64(id[:8], uint64(part1())) + binary.BigEndian.PutUint64(id[8:], uint64(part2())) + return id +} + +// Bytes returns a byte slice from l. +func (l ID) Bytes() []byte { + return l[:] +} + +// CreatedAt returns the time the ID was created at. +func (l ID) CreatedAt() time.Time { + epoch := (time.Now().Unix() >> (TimeBits)) << (TimeBits) + + ts := binary.BigEndian.Uint64(l[:8]) >> (64 - TimeBits) + + // fmt.Printf("%064b\n", epoch) + // fmt.Printf("%064b\n", ts) + + return time.Unix(epoch+int64(ts), 0) +} + +// String returns the text representation of l +func (l ID) String() string { + return fmt.Sprintf("%08x-%024x", l[:4], l[4:]) +} + +// MarshalText marshals l +func (l ID) MarshalText() ([]byte, error) { + return []byte(l.String()), nil +} + +// UnmarshalText parses b +func (l *ID) UnmarshalText(b []byte) error { + ll, err := Parse(string(b)) + if err != nil { + return err + } + copy(l[:], ll[:]) + return nil +} + +// MarshalJSON marshals l +func (l ID) MarshalJSON() ([]byte, error) { + return []byte("\"" + l.String() + "\""), nil +} + +// UnmarshalJSON parses b +func (l *ID) UnmarshalJSON(b []byte) error { + return l.UnmarshalText(bytes.Trim(b, "\"")) +} + +var _ = driver.Valuer(New()) +var _ = sql.Scanner(&ID{}) + +func (l ID) Value() (driver.Value, error) { + return l.Bytes(), nil +} + +func (l *ID) Scan(v interface{}) error { + b, ok := v.([]byte) + if !ok { + return xerrors.New("can only scan binary types") + } + if len(b) != 16 { + return xerrors.New("must be 16 bytes") + } + copy(l[:], b) + return nil +} + +// Parse parses the String() representation of a Long +func Parse(l string) (ID, error) { + var ( + id ID + err error + ) + if len(l) != 33 { + return id, ErrWrongSize + } + + p1, err := hex.DecodeString(l[:8]) + if err != nil { + return id, xerrors.Errorf("failed to decode short portion: %w", err) + } + + p2, err := hex.DecodeString(l[9:]) + if err != nil { + return id, xerrors.Errorf("failed to decode rand portion: %w", err) + } + + copy(id[:4], p1) + copy(id[4:], p2) + + return id, nil +} + +// TimeReset the current bounds of +// validity for timestamps extracted from longs +func TimeReset() (last time.Time, next time.Time) { + const lastStr = "00000000-00680e087d8fff20a11d24e6" + const nextStr = "ffffffff-00680e087d8fff20a11d24e6" + l, _ := Parse(lastStr) + last = l.CreatedAt() + + l, _ = Parse(nextStr) + next = l.CreatedAt() + + return +} diff --git a/longid/id_test.go b/longid/id_test.go new file mode 100644 index 0000000000000..ce8df492b2c82 --- /dev/null +++ b/longid/id_test.go @@ -0,0 +1,80 @@ +package longid + +import ( + "fmt" + "math/rand" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestID(t *testing.T) { + last, next := TimeReset() + t.Logf("Long Reset: Last: %v, Next: %v (🔼 %v)\n", last, next, next.Sub(last)) + t.Run("New()", func(t *testing.T) { + for i := 0; i < 5; i++ { + l := New() + fmt.Printf("Long: %v\n", l) + assert.WithinDuration(t, time.Now(), l.CreatedAt(), time.Second) + } + }) + + t.Run("Parse()", func(t *testing.T) { + t.Run("Good", func(t *testing.T) { + want := New() + got, err := Parse(want.String()) + require.Nil(t, err) + require.Equal(t, want, got) + }) + + t.Run("Bad Size", func(t *testing.T) { + _, err := Parse(New().String() + "ab") + require.NotNil(t, err) + }) + + t.Run("Bad Hex", func(t *testing.T) { + str := New().String() + str = "O" + str[1:] + _, err := Parse(str) + require.NotNil(t, err) + }) + }) + + t.Run("FromSlice", func(t *testing.T) { + l := New() + assert.Equal(t, l, FromSlice(l[:])) + }) + + t.Run("Scan", func(t *testing.T) { + var l ID + b := make([]byte, 16) + _, err := rand.Read(b) + require.NoError(t, err) + + require.NoError(t, l.Scan(b)) + assert.Equal(t, b, l.Bytes()) + }) +} + +func TestLongRaces(_ *testing.T) { + var wg sync.WaitGroup + for i := 0; i < 16; i++ { + go func() { + for i := 0; i < 1000; i++ { + New() + } + }() + } + wg.Wait() +} + +func BenchmarkLong(b *testing.B) { + b.Run("New()", func(b *testing.B) { + for i := 0; i < b.N; i++ { + New() + } + }) +} diff --git a/srverr/error.go b/srverr/error.go new file mode 100644 index 0000000000000..022387ca2f28c --- /dev/null +++ b/srverr/error.go @@ -0,0 +1,10 @@ +package srverr + +// Error is an interface for specifying how specific errors should be +// dispatched by the API. The underlying struct is sent under the `details` +// field. +type Error interface { + Status() int + PublicMessage() string + Code() Code +} diff --git a/srverr/error_test.go b/srverr/error_test.go new file mode 100644 index 0000000000000..908df65d56e7d --- /dev/null +++ b/srverr/error_test.go @@ -0,0 +1,19 @@ +package srverr + +import ( + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" +) + +func TestErrorChain(t *testing.T) { + t.Run("wrapping", func(t *testing.T) { + err := xerrors.Errorf("im an error") + err = Upgrade(err, ResourceNotFoundError{}) + err = xerrors.Errorf("wrapped http error: %w", err) + + var herr Error + require.ErrorAs(t, err, &herr, "should find http error details") + }) +} diff --git a/srverr/errors.go b/srverr/errors.go new file mode 100644 index 0000000000000..411ea05e09219 --- /dev/null +++ b/srverr/errors.go @@ -0,0 +1,65 @@ +package srverr + +import ( + "net/http" +) + +// SettableError describes a structured error that can accept an error. This is +// useful to prevent handlers from needing to insert the error into Upgrade +// twice. xjson.HandleError uses this interface set the final error string +// before marshaling. +type VerboseError interface { + SetVerbose(err error) +} + +// Verbose is for reusing the `verbose` field between error types. It +// implements VerboseError so it's not necessary to prefill the struct with the +// verbose error. +type Verbose struct { + Verbose string `json:"verbose"` +} + +func (e *Verbose) SetVerbose(err error) { e.Verbose = err.Error() } + +// Code is a string enum indicating the structure of the details field in an +// error response. Each error type should correspond to a unique Code. +type Code string + +const ( + CodeServerError Code = "server_error" + CodeDatabaseError Code = "database_error" + CodeResourceNotFound Code = "resource_not_found" +) + +var _ VerboseError = &ServerError{} + +// ServerError describes an error of unknown origins. +type ServerError struct { + Verbose +} + +func (*ServerError) Status() int { return http.StatusInternalServerError } +func (*ServerError) PublicMessage() string { return "An internal server error occurred." } +func (*ServerError) Code() Code { return CodeServerError } +func (*ServerError) Error() string { return "internal server error" } + +// DatabaseError describes an unknown error from the database. +type DatabaseError struct { + Verbose +} + +func (*DatabaseError) Status() int { return http.StatusInternalServerError } +func (*DatabaseError) PublicMessage() string { return "A database error occurred." } +func (*DatabaseError) Code() Code { return CodeDatabaseError } +func (*DatabaseError) Error() string { return "database error" } + +// ResourceNotFoundError describes an error when a provided resource ID was not +// found within the database or the user does not have the proper permission to +// view it. +type ResourceNotFoundError struct { +} + +func (ResourceNotFoundError) Status() int { return http.StatusNotFound } +func (e ResourceNotFoundError) PublicMessage() string { return "Resource not found." } +func (ResourceNotFoundError) Code() Code { return CodeResourceNotFound } +func (ResourceNotFoundError) Error() string { return "resource not found" } diff --git a/srverr/wrap.go b/srverr/wrap.go new file mode 100644 index 0000000000000..d87229d0bbb27 --- /dev/null +++ b/srverr/wrap.go @@ -0,0 +1,48 @@ +package srverr + +import ( + "encoding/json" +) + +// Upgrade transparently upgrades any error chain by adding information on how +// the error should be converted into an HTTP response. Since this adds it to +// the chain transparently, there is no indication from the error string that +// it is an upgraded error. You must use xerrors.As to check if an error chain +// contains an upgraded error. +// An error may be upgraded multiple times. The last call to Upgrade will +// always be used. +func Upgrade(err error, herr Error) error { + return wrapped{ + err: err, + herr: herr, + } +} + +var _ VerboseError = wrapped{} + +type wrapped struct { + err error + herr Error +} + +// Make sure the wrapped error still behaves as if it was a regular call to +// xerrors.Errorf. +func (w wrapped) Error() string { return w.err.Error() } +func (w wrapped) Unwrap() error { return w.err } + +// Pass through srverr.Error interface functions from the underlying +// srverr.Error. +func (w wrapped) Status() int { return w.herr.Status() } +func (w wrapped) PublicMessage() string { return w.herr.PublicMessage() } +func (w wrapped) Code() Code { return w.herr.Code() } + +// When a wrapped error is marshaled, we want to make sure it marshals the +// underlying srverr.Error, not the wrapped structure. +func (w wrapped) MarshalJSON() ([]byte, error) { return json.Marshal(w.herr) } + +// If the underlying srverr.Error implements VerboseError, pass through. +func (w wrapped) SetVerbose(err error) { + if v, ok := w.herr.(VerboseError); ok { + v.SetVerbose(err) + } +} diff --git a/validate/devurl.go b/validate/devurl.go new file mode 100644 index 0000000000000..4577f14d9defd --- /dev/null +++ b/validate/devurl.go @@ -0,0 +1,38 @@ +package validate + +import ( + "regexp" + "strings" + + "golang.org/x/xerrors" +) + +// NOTE: disallowing leading and trailing hyphens to avoid semantic confusion with hyphen used as separator. +// Disallowing leading and trailing underscores to avoid potential clashes with mDNS-related stuff. +var devURLValidNameRx = regexp.MustCompile("^[a-zA-Z]([a-zA-Z0-9_-]{0,41}[a-zA-Z0-9])?$") +var devURLInvalidLenMsg = "invalid devurl name %q: names may not be more than 64 characters in length." +var devURLInvalidNameMsg = "invalid devurl name %q: names must begin with a letter, followed by zero or more letters," + + " digits, hyphens, or underscores, and end with a letter or digit." + +const ( + // DevURLDelimiter is the separator for parts of a DevURL. + // eg. kyle--test--name.cdr.co + DevURLDelimiter = "--" +) + +// DevURLName only validates the name of the devurl, not the fully resolved subdomain. +func DevURLName(name string) error { + if len(name) == 0 { + return nil + } + if len(name) > 43 { + return xerrors.Errorf(devURLInvalidLenMsg, name) + } + if name != "" && !devURLValidNameRx.MatchString(name) { + return xerrors.Errorf(devURLInvalidNameMsg, name) + } + if strings.Contains(name, DevURLDelimiter) { + return xerrors.Errorf(devURLInvalidNameMsg, name) + } + return nil +} diff --git a/validate/devurl_test.go b/validate/devurl_test.go new file mode 100644 index 0000000000000..0df6ed88b6622 --- /dev/null +++ b/validate/devurl_test.go @@ -0,0 +1,47 @@ +package validate + +import ( + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/utils/pointer" +) + +func Test_DevURLName(t *testing.T) { + testCases := []struct { + S string + Err *string + }{ + {"", nil}, + {"a", nil}, + {"a1", nil}, + {"a1a", nil}, + {"a-b", nil}, + {"a_b", nil}, + {"a_-b", nil}, + {"a_bc", nil}, + {"a-b-c", nil}, + {"a-b_c", nil}, + {"a_b-c", nil}, + {"a_b_c", nil}, + {"1", pointer.String("names must begin with a letter")}, + {"1a", pointer.String("names must begin with a letter")}, + {"1a1", pointer.String("names must begin with a letter")}, + {"1234", pointer.String("names must begin with a letter")}, + {"-a", pointer.String("names must begin with a letter")}, + {"a-", pointer.String("names must begin with a letter")}, + {"_a", pointer.String("names must begin with a letter")}, + {"a_", pointer.String("names must begin with a letter")}, + {"a--b", pointer.String("names must begin with a letter")}, + {"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", pointer.String("names may not be more than 64 characters in length")}, + } + for _, tc := range testCases { + err := DevURLName(tc.S) + if tc.Err != nil { + require.Errorf(t, err, "expected error for test case %q", tc.S) + require.Containsf(t, err.Error(), *tc.Err, "expected error for test case %q", tc.S) + } else { + require.NoError(t, err, tc.S) + } + } +} diff --git a/validate/numeric.go b/validate/numeric.go new file mode 100644 index 0000000000000..cdabeb2ecbd77 --- /dev/null +++ b/validate/numeric.go @@ -0,0 +1,15 @@ +package validate + +// Numeric returns true if s contains only digits. +// Returns false otherwise. +func Numeric(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} diff --git a/validate/numeric_test.go b/validate/numeric_test.go new file mode 100644 index 0000000000000..b86e13be569c1 --- /dev/null +++ b/validate/numeric_test.go @@ -0,0 +1,24 @@ +package validate + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_Numeric(t *testing.T) { + testCases := []struct { + S string + Expected bool + }{ + {"", false}, + {"a1", false}, + {"1a", false}, + {"1a1", false}, + {"1234", true}, + } + for _, tc := range testCases { + actual := Numeric(tc.S) + require.Equal(t, tc.Expected, actual, tc.S) + } +} diff --git a/validate/struct.go b/validate/struct.go new file mode 100644 index 0000000000000..0449f018eb50c --- /dev/null +++ b/validate/struct.go @@ -0,0 +1,285 @@ +package validate + +import ( + "reflect" + "strings" + + "github.com/go-playground/validator/v10" + "golang.org/x/xerrors" +) + +const ( + validateTag = "validate" // Used by go-playground/validator + jsonTag = "json" // Stdlib json tag +) + +var ErrNotAStruct = xerrors.Errorf("value not a struct") + +// FieldsMissingValidation returns a list of fields that are missing appropriate +// validation tags. Any field that is exported, does not have a "-" json tag +// value, and is not a bool or pointer to a bool will be included in the +// returned list of fields if it lacks a "validate" tag. +// +// This will recursively check nested structs, stopping at fields that are +// unexported, or fields that do not unmarshal from json. `v` should be a +// struct. +// +// Nested struct fields that do not have a "validate" tag will be included in +// the returned list. +func FieldsMissingValidation(v interface{}) ([]reflect.StructField, error) { + _, ok := isStruct(v) + if !ok { + return nil, ErrNotAStruct + } + + fields, err := SelectFields(v, + SelectAll{ + FieldSelectorFunc(IsExported), + NegateSelector(FieldSelectorFunc(IsBool)), + NegateSelector(FieldSelectorFunc(HasSkipJSON)), + NegateSelector(ValidateTagKeyFieldSelector), + }, + SelectAny{ + NegateSelector(FieldSelectorFunc(IsExported)), + FieldSelectorFunc(HasSkipJSON), + FieldSelectorFunc(HasSkipValidate), + }, + ) + if err != nil { + return nil, xerrors.Errorf("select fields: %w", err) + } + + return fields, nil +} + +// FieldsWithValidation returns a list of fields with a "validate" tag. +// +// This will recursively check nested structs, stopping at fields that are +// unexported, or fields that do not unmarshal from json. `v` should be a +// struct. +// +// Nested struct fields that do have a "validate" tag will be included in the +// returned list. +func FieldsWithValidation(v interface{}) ([]reflect.StructField, error) { + _, ok := isStruct(v) + if !ok { + return nil, ErrNotAStruct + } + + fields, err := SelectFields(v, + SelectAll{ + FieldSelectorFunc(IsExported), + NegateSelector(FieldSelectorFunc(IsBool)), + NegateSelector(FieldSelectorFunc(HasSkipJSON)), + ValidateTagKeyFieldSelector, + }, + SelectAny{ + NegateSelector(FieldSelectorFunc(IsExported)), + FieldSelectorFunc(HasSkipJSON), + FieldSelectorFunc(HasSkipValidate), + }, + ) + if err != nil { + return nil, xerrors.Errorf("select fields: %w", err) + } + + return fields, nil +} + +type FieldSelector interface { + Matches(field reflect.StructField) bool +} + +type SelectAny []FieldSelector + +func (fs SelectAny) Matches(field reflect.StructField) bool { + for _, f := range fs { + if f.Matches(field) { + return true + } + } + return false +} + +type SelectAll []FieldSelector + +func (fs SelectAll) Matches(field reflect.StructField) bool { + for _, f := range fs { + if !f.Matches(field) { + return false + } + } + return true +} + +// JSONTagValueFieldSelector selects all fields that has a given json tag value. +type JSONTagValueFieldSelector string + +func (fs JSONTagValueFieldSelector) Matches(field reflect.StructField) bool { + tagVal, ok := field.Tag.Lookup(jsonTag) + if !ok { + return false + } + for _, s := range strings.Split(tagVal, ",") { + if s == string(fs) { + return true + } + } + return false +} + +// TagKeyFieldSelector selects all fields with the given tag key. +type TagKeyFieldSelector string + +const ( + ValidateTagKeyFieldSelector TagKeyFieldSelector = validateTag +) + +func (fs TagKeyFieldSelector) Matches(field reflect.StructField) bool { + _, ok := field.Tag.Lookup(string(fs)) + return ok +} + +type FieldSelectorFunc func(field reflect.StructField) bool + +func (fs FieldSelectorFunc) Matches(field reflect.StructField) bool { + return fs(field) +} + +// IsExported checks if the field is exported. +func IsExported(field reflect.StructField) bool { + // PkgPath is empty for exported fields (noted in doc for PkgPath). + return field.PkgPath == "" +} + +// IsBool checks if the field is a bool, or a *bool. +func IsBool(field reflect.StructField) bool { + // Field is a bool. + if field.Type.Kind() == reflect.Bool { + return true + } + // Field is a *bool. + if field.Type.Kind() == reflect.Ptr && field.Type.Elem().Kind() == reflect.Bool { + return true + } + return false +} + +func HasSkipJSON(field reflect.StructField) bool { + jsonVal, jsonFound := field.Tag.Lookup(jsonTag) + skipJSON := jsonFound && jsonVal == "-" + return skipJSON +} + +func HasSkipValidate(field reflect.StructField) bool { + jsonVal, jsonFound := field.Tag.Lookup(validateTag) + skipJSON := jsonFound && jsonVal == "-" + return skipJSON +} + +func NegateSelector(fs FieldSelector) FieldSelector { + return FieldSelectorFunc(func(field reflect.StructField) bool { + return !fs.Matches(field) + }) +} + +// Field validates struct `v`, returning just the validation error for a +// field that Matches FieldSelector. If the selector matches more than one +// field, only the first will be checked. +func Field(v interface{}, fs FieldSelector) error { + _, ok := isStruct(v) + if !ok { + return ErrNotAStruct + } + + err := Validator().Struct(v) + if err == nil { + return nil + } + + var vErrs validator.ValidationErrors + if xerrors.As(err, &vErrs) { + fields, _ := SelectFields(v, fs, nil) // Can only error if `v` isn't a struct. + for _, field := range fields { + for _, vErr := range vErrs { + if field.Name == vErr.StructField() { + return vErr + } + } + } + // Field selector either matched no fields that failed validation, or + // all matched fields passed validation. + return nil + } + + return xerrors.Errorf("non-validation error when validating: %w", err) +} + +// SelectFields selects all fields from struct `v` that match the field +// selector. +// +// This will recurse through nested structs, stopping at fields that are +// selected with `skipFields`. A value of nil for `skipFields` will continue to +// recurse indiscriminately. Infinite recursion is avoided by detecting if a +// field of a struct has the same type as the struct itself. +func SelectFields(v interface{}, fs FieldSelector, skipFields FieldSelector) ([]reflect.StructField, error) { + return selectFieldsWithVisited(v, fs, skipFields, nil) +} + +type fieldType struct { + pkg string + name string +} + +func selectFieldsWithVisited(v interface{}, fs FieldSelector, skipFields FieldSelector, visited []*fieldType) ([]reflect.StructField, error) { + st, ok := isStruct(v) + if !ok { + return nil, ErrNotAStruct + } + + var fields []reflect.StructField + + // Check to make sure we haven't visited this type yet. If we have, there's + // no need to continue. + for _, ft := range visited { + if st.Name() == ft.name && st.PkgPath() == ft.pkg { + return fields, nil + } + } + visited = append(visited, &fieldType{pkg: st.PkgPath(), name: st.Name()}) + + for i := 0; i < st.NumField(); i++ { + field := st.Field(i) + if fs.Matches(field) { + fields = append(fields, field) + } + + if skipFields != nil && skipFields.Matches(field) { + continue + } + + fv := reflect.Zero(field.Type) + if field.Type.Kind() == reflect.Ptr { + fv = reflect.Zero(field.Type.Elem()) + } + if fv.Kind() == reflect.Struct { + nestedFields, err := selectFieldsWithVisited(fv.Interface(), fs, skipFields, visited) + if err != nil { + return nil, xerrors.Errorf("select fields, field: %s: %w", field.Name, err) + } + fields = append(fields, nestedFields...) + } + } + + return fields, nil +} + +// isStruct checks to make sure `v` is either a struct, or a pointer to a +// struct. +func isStruct(v interface{}) (reflect.Type, bool) { + rv := reflect.ValueOf(v) + if rv.Kind() == reflect.Ptr { + rv = rv.Elem() + } + return rv.Type(), rv.Kind() == reflect.Struct +} diff --git a/validate/struct_test.go b/validate/struct_test.go new file mode 100644 index 0000000000000..606a5213d43a4 --- /dev/null +++ b/validate/struct_test.go @@ -0,0 +1,206 @@ +package validate + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestFieldsValidation(t *testing.T) { + t.Parallel() + + t.Run("AllFieldsValidated", func(t *testing.T) { + type s struct { + Field string `validate:"min=3"` + } + var v s + fs, err := FieldsMissingValidation(v) + require.NoError(t, err, "err") + require.Equal(t, 0, len(fs), "num fields") + fs, err = FieldsWithValidation(v) + require.NoError(t, err, "err") + require.Equal(t, 1, len(fs), "num fields") + }) + + t.Run("Pointer", func(t *testing.T) { + type s struct { + Field string `validate:"min=3"` + } + var v s + fs, err := FieldsMissingValidation(&v) + require.NoError(t, err, "err") + require.Equal(t, 0, len(fs), "num fields") + fs, err = FieldsWithValidation(&v) + require.NoError(t, err, "err") + require.Equal(t, 1, len(fs), "num fields") + }) + + t.Run("MissingValidations", func(t *testing.T) { + type s struct { + Field1 string + Field2 string + } + var v s + fs, err := FieldsMissingValidation(v) + require.NoError(t, err, "err") + require.Equal(t, 2, len(fs), "num fields") + fs, err = FieldsWithValidation(v) + require.NoError(t, err, "err") + require.Equal(t, 0, len(fs), "num fields") + }) + + t.Run("UnexportedFields", func(t *testing.T) { + type s struct { + field string + } + var v = s{field: "string"} + fs, err := FieldsMissingValidation(v) + require.NoError(t, err, "err") + require.Equal(t, 0, len(fs), "num fields") + fs, err = FieldsWithValidation(v) + require.NoError(t, err, "err") + require.Equal(t, 0, len(fs), "num fields") + }) + + t.Run("Bools", func(t *testing.T) { + type s struct { + Field1 *bool + Field2 bool + } + var v s + fs, err := FieldsMissingValidation(v) + require.NoError(t, err, "err") + require.Equal(t, 0, len(fs), "num fields") + fs, err = FieldsWithValidation(v) + require.NoError(t, err, "err") + require.Equal(t, 0, len(fs), "num fields") + }) + + t.Run("Nested", func(t *testing.T) { + type nested struct { + Field string `validate:"min=3"` + } + type s struct { + Nested *nested `validate:"required"` + } + var v s + fs, err := FieldsMissingValidation(v) + require.NoError(t, err, "err") + require.Equal(t, 0, len(fs), "num fields") + fs, err = FieldsWithValidation(v) + require.NoError(t, err, "err") + require.Equal(t, 2, len(fs), "num fields") + }) + + t.Run("NestedUnexported", func(t *testing.T) { + // Specifically using time since it has known unexported fields, and is + // from a different package. + type s struct { + Time time.Time `validate:"required"` + } + var v s + fs, err := FieldsMissingValidation(v) + require.NoError(t, err, "err") + require.Equal(t, 0, len(fs), "num fields") + fs, err = FieldsWithValidation(v) + require.NoError(t, err, "err") + require.Equal(t, 1, len(fs), "num fields") + }) +} + +func TestValidateField(t *testing.T) { + t.Parallel() + + t.Run("NoMatch", func(t *testing.T) { + type s struct { + Field string `validate:"min=3"` + } + err := Field(s{}, JSONTagValueFieldSelector("hello")) + require.NoError(t, err, "validate") + }) + + t.Run("MatchValid", func(t *testing.T) { + type s struct { + Field string `json:"hello" validate:"min=3"` + } + err := Field(s{Field: "world"}, JSONTagValueFieldSelector("hello")) + require.NoError(t, err, "validate") + }) + + t.Run("MatchInvalid", func(t *testing.T) { + type s struct { + Field string `json:"hello" validate:"min=3"` + } + err := Field(s{Field: "hi"}, JSONTagValueFieldSelector("hello")) + require.Error(t, err, "validate") + }) +} + +func TestSelectFields(t *testing.T) { + t.Parallel() + + t.Run("Some", func(t *testing.T) { + type s struct { + Field1 string `bogus:"bogus"` + Field2 string + } + fs := TagKeyFieldSelector("bogus") + fields, err := SelectFields(s{}, fs, nil) + require.NoError(t, err, "select") + require.Equal(t, 1, len(fields), "num fields") + }) + + t.Run("Nested", func(t *testing.T) { + type nested struct { + Field1 string `bogus:"bogus"` + } + type s struct { + Field1 string `bogus:"bogus"` + Nested *nested + } + fs := TagKeyFieldSelector("bogus") + fields, err := SelectFields(s{}, fs, nil) + require.NoError(t, err, "select") + require.Equal(t, 2, len(fields), "num fields") + }) + + t.Run("Embedded", func(t *testing.T) { + type embedded struct { + Field1 string `bogus:"bogus"` + } + type s struct { + embedded + } + fs := TagKeyFieldSelector("bogus") + fields, err := SelectFields(s{}, fs, nil) + require.NoError(t, err, "select") + require.Equal(t, 1, len(fields), "num fields") + }) + + t.Run("InfiniteRecursion", func(t *testing.T) { + type s struct { + Field *s `bogus:"bogus"` + } + fs := TagKeyFieldSelector("bogus") + fields, err := SelectFields(s{}, fs, nil) + require.NoError(t, err, "select") + require.Equal(t, 1, len(fields), "num fields") + }) +} + +func TestValidateStruct(t *testing.T) { + t.Run("Anonymous", func(t *testing.T) { + // Test validate on anonymous fields + type s struct { + json.RawMessage `validate:"min=4"` + } + + // Not enough bytes + v := s{RawMessage: []byte("{}}")} + + err := Validator().Struct(v) + require.Error(t, err, "validate") + }) +} diff --git a/validate/user.go b/validate/user.go new file mode 100644 index 0000000000000..47560756089db --- /dev/null +++ b/validate/user.go @@ -0,0 +1,37 @@ +package validate + +import ( + "regexp" + + "golang.org/x/xerrors" +) + +const ( + // UsernameMaxLength is the maximum length a username can be. + UsernameMaxLength = 32 +) + +// Matches alphanumeric usernames with `-`, but not consecutively. +var usernameRx = regexp.MustCompile("^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$") + +var ErrInvalidUsernameRegex = xerrors.Errorf("username must conform to regex %v", usernameRx.String()) +var ErrUsernameTooLong = xerrors.Errorf("usernames must be a maximum length of %d", UsernameMaxLength) + +// Username validates the string provided to be a valid Coder username. +// Coder usernames follow GitHub's username rules. Here are the rules: +// 1. Must be alphanumeric. +// 2. Minimum length of 1, maximum of 32. +// 3. Cannot start with a hyphen. +// 4. Cannot include consecutive hyphens. +func Username(s string) error { + if len(s) > UsernameMaxLength { + return ErrUsernameTooLong + } + if len(s) < 1 { + return ErrInvalidUsernameRegex + } + if !usernameRx.MatchString(s) { + return ErrInvalidUsernameRegex + } + return nil +} diff --git a/validate/user_test.go b/validate/user_test.go new file mode 100644 index 0000000000000..f94e2e4c7eb76 --- /dev/null +++ b/validate/user_test.go @@ -0,0 +1,65 @@ +package validate + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_Username(t *testing.T) { + t.Parallel() + testCases := []struct { + Input string + Err error + }{ + {"1", nil}, + {"12", nil}, + {"123", nil}, + {"12345678901234567890", nil}, + {"123456789012345678901", nil}, + {"a", nil}, + {"a1", nil}, + {"a1b2", nil}, + {"a1b2c3d4e5f6g7h8i9j0", nil}, + {"a1b2c3d4e5f6g7h8i9j0k", nil}, + {"aa", nil}, + {"abc", nil}, + {"abcdefghijklmnopqrst", nil}, + {"abcdefghijklmnopqrstu", nil}, + {"wow-test", nil}, + + {"", ErrInvalidUsernameRegex}, + {" ", ErrInvalidUsernameRegex}, + {" a", ErrInvalidUsernameRegex}, + {" a ", ErrInvalidUsernameRegex}, + {" 1", ErrInvalidUsernameRegex}, + {"1 ", ErrInvalidUsernameRegex}, + {" aa", ErrInvalidUsernameRegex}, + {"aa ", ErrInvalidUsernameRegex}, + {" 12", ErrInvalidUsernameRegex}, + {"12 ", ErrInvalidUsernameRegex}, + {" a1", ErrInvalidUsernameRegex}, + {"a1 ", ErrInvalidUsernameRegex}, + {" abcdefghijklmnopqrstu", ErrInvalidUsernameRegex}, + {"abcdefghijklmnopqrstu ", ErrInvalidUsernameRegex}, + {" 123456789012345678901", ErrInvalidUsernameRegex}, + {" a1b2c3d4e5f6g7h8i9j0k", ErrInvalidUsernameRegex}, + {"a1b2c3d4e5f6g7h8i9j0k ", ErrInvalidUsernameRegex}, + {"bananas_wow", ErrInvalidUsernameRegex}, + {"test--now", ErrInvalidUsernameRegex}, + + {"123456789012345678901234567890123", ErrUsernameTooLong}, + {"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ErrUsernameTooLong}, + {"123456789012345678901234567890123123456789012345678901234567890123", ErrUsernameTooLong}, + } + for _, testCase := range testCases { + t.Run(testCase.Input, func(t *testing.T) { + if testCase.Err == nil { + require.NoError(t, Username(testCase.Input), fmt.Sprintf("username %q should be valid", testCase.Input)) + } else { + require.Equal(t, Username(testCase.Input), testCase.Err, fmt.Sprintf("username %q should not be valid", testCase.Input)) + } + }) + } +} diff --git a/validate/validator.go b/validate/validator.go new file mode 100644 index 0000000000000..df2034512237a --- /dev/null +++ b/validate/validator.go @@ -0,0 +1,46 @@ +package validate + +import ( + "github.com/go-playground/validator/v10" + "golang.org/x/xerrors" +) + +func init() { + validate = validator.New() + mustRegisterValidation(validate, "longid", validateLongID) +} + +func mustRegisterValidation(v *validator.Validate, tag string, fn validator.Func) { + if err := v.RegisterValidation(tag, fn); err != nil { + panic(xerrors.Errorf("register validation: %w", err)) + } +} + +// Global validation struct +// +// Custom validators should be added to this struct if needed (see +// https://github.com/go-playground/validator/blob/master/_examples/custom-validation/main.go +// for an example). +var validate *validator.Validate + +// Validator returns a copy of the global validator. +func Validator() *validator.Validate { + v := *validate + return &v +} + +// validateLongID validates that a field is a string, and that the string does +// not exceed the max length of a long ID. +// +// Additional formatting checks are omitted as there's a lot of tests that don't +// use actual long IDs when generating test requests, and the system admin's ID +// also does not follow the long ID format. +func validateLongID(fl validator.FieldLevel) bool { + f := fl.Field().Interface() + s, ok := f.(string) + if !ok { + return false + } + const longIDLen = 33 // The format string for a long id is "%08x-%024x" + return len(s) <= longIDLen +} diff --git a/validate/validator_test.go b/validate/validator_test.go new file mode 100644 index 0000000000000..7afb9245be09d --- /dev/null +++ b/validate/validator_test.go @@ -0,0 +1,46 @@ +package validate + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/longid" +) + +func TestValidateLongID(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + type myStruct struct { + ID string `validate:"longid"` + } + v := &myStruct{ + ID: longid.New().String(), + } + err := Validator().Struct(v) + require.NoError(t, err, "validate") + }) + + t.Run("Invalid", func(t *testing.T) { + type myStruct struct { + ID string `validate:"longid"` + } + v := &myStruct{ + ID: longid.New().String() + "hello", + } + err := Validator().Struct(v) + require.Error(t, err, "unexpectedly validated") + }) + + t.Run("WrongType", func(t *testing.T) { + type myStruct struct { + ID int `validate:"longid"` + } + v := &myStruct{ + ID: 123, + } + err := Validator().Struct(v) + require.Error(t, err, "unexpectedly validated") + }) +} diff --git a/validate/vassert/assert.go b/validate/vassert/assert.go new file mode 100644 index 0000000000000..974d9b8c6c063 --- /dev/null +++ b/validate/vassert/assert.go @@ -0,0 +1,68 @@ +package vassert + +import ( + "testing" + + "github.com/coder/coder/validate" +) + +// Tags asserts that most fields on a struct with a "json" tag also have a +// "validate" tag, unless the json tag value is "-". +// +// This will recursively check nested structs. +// +// Boolean values and boolean pointers do not require a validate tag. +// +// `v` should be a struct. +func Tags(t *testing.T, v interface{}) { + t.Helper() + fields, err := validate.FieldsMissingValidation(v) + if err != nil { + t.Fatalf("failed to get missing field validations: %s", err) + } + if len(fields) > 0 { + names := make([]string, len(fields)) + for i, f := range fields { + names[i] = f.Name + } + t.Fatalf("the following fields are missing validations: %v", names) + } +} + +// FieldValid asserts that the field with the correspting `jsonField` +// value validates. +// +// `v` should be a struct. +func FieldValid(t *testing.T, v interface{}, jsonField string) { + t.Helper() + ensureHasJSONField(t, v, jsonField) + err := validate.Field(v, validate.JSONTagValueFieldSelector(jsonField)) + if err != nil { + t.Fatalf("expected field %q to validate: %v", jsonField, err) + } +} + +// FieldInvalid asserts that the field with the correspting `jsonField` +// value does not validate. +// +// `v` should be a struct. +func FieldInvalid(t *testing.T, v interface{}, jsonField string) { + t.Helper() + ensureHasJSONField(t, v, jsonField) + err := validate.Field(v, validate.JSONTagValueFieldSelector(jsonField)) + if err == nil { + t.Fatalf("expected field %q to be invalid", jsonField) + } +} + +func ensureHasJSONField(t *testing.T, v interface{}, jsonField string) { + t.Helper() + fs := validate.JSONTagValueFieldSelector(jsonField) + fields, err := validate.SelectFields(v, fs, nil) + if err != nil { + t.Fatalf("failed to select fields: %v", err) + } + if len(fields) == 0 { + t.Fatalf("%q matches no fields on the struct", jsonField) + } +} diff --git a/validate/vassert/doc.go b/validate/vassert/doc.go new file mode 100644 index 0000000000000..413342a68cc13 --- /dev/null +++ b/validate/vassert/doc.go @@ -0,0 +1,6 @@ +// Package vassert provides testing utilities for asserting validations for +// request bodies. +// +// All functions in this package assume the use of +// https://github.com/go-playground/validator. +package vassert diff --git a/xjson/duration.go b/xjson/duration.go new file mode 100644 index 0000000000000..32f296314d196 --- /dev/null +++ b/xjson/duration.go @@ -0,0 +1,32 @@ +package xjson + +import ( + "encoding/json" + "strconv" + "time" +) + +// Duration is a time.Duration that marshals to millisecond precision. +// Most javascript applications expect durations to be in milliseconds. +// Although this would typically be a time.Duration it was changed to +// an int64 to avoid errors in the swaggo/swag tool we use to auto -generate +// documentation. +type Duration int64 + +// MarshalJSON marshals the duration to millisecond precision. +func (d Duration) MarshalJSON() ([]byte, error) { + du := time.Duration(d) + return json.Marshal(du.Milliseconds()) +} + +// UnmarshalJSON unmarshals a millisecond-precision integer to +// a time.Duration. +func (d *Duration) UnmarshalJSON(b []byte) error { + i, err := strconv.ParseInt(string(b), 10, 64) + if err != nil { + return err + } + + *d = Duration(time.Duration(i) * time.Millisecond) + return nil +} diff --git a/xjson/duration_test.go b/xjson/duration_test.go new file mode 100644 index 0000000000000..e7aff2990eddf --- /dev/null +++ b/xjson/duration_test.go @@ -0,0 +1,22 @@ +package xjson + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestDuration(t *testing.T) { + t.Run("MarshalUnmarshalJSON", func(t *testing.T) { + var dur = Duration(time.Hour) + b, err := json.Marshal(dur) + require.NoError(t, err, "marshal duration") + + var unmarshalDur Duration + err = json.Unmarshal(b, &unmarshalDur) + require.NoError(t, err, "unmarshal duration") + require.Equal(t, dur, unmarshalDur, "Did not parse to milliseconds") + }) +} diff --git a/xjson/error.go b/xjson/error.go new file mode 100644 index 0000000000000..84711cb672b50 --- /dev/null +++ b/xjson/error.go @@ -0,0 +1,207 @@ +package xjson + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/coder/coder/srverr" +) + +const ( + // codeVerbose indicates a details object with a 'verbose' field + // exists in the error response. + codeVerbose srverr.Code = "verbose" + // codeEmpty indicates that no details object exists. + codeEmpty srverr.Code = "empty" + // codeSolution indicates the details field has a payload for the + // error and has a solution to resolve the error. + codeSolution srverr.Code = "solution" +) + +// WriteBadRequestWithCode writes a 400 to the response using a custom code, msg, and json marshaled details +func WriteBadRequestWithCode(w http.ResponseWriter, code srverr.Code, humanMsg string, details interface{}) { + Write(w, http.StatusBadRequest, errorResponse{ + Error: errorPayload{ + Msg: humanMsg, + Code: code, + Details: details, + }, + }) +} + +// WriteBadRequest writes a 400 to the response. +func WriteBadRequest(w http.ResponseWriter, humanMsg string) { + WriteError(w, http.StatusBadRequest, humanMsg, nil) +} + +// WriteUnauthorized writes a 401 to the response. +func WriteUnauthorized(w http.ResponseWriter, humanMsg string) { + WriteError(w, http.StatusUnauthorized, humanMsg, nil) +} + +// WriteForbidden writes a 403 to the response. +func WriteForbidden(w http.ResponseWriter, humanMsg string) { + WriteError(w, http.StatusForbidden, humanMsg, nil) +} + +// WriteConflict writes a 409 to the response. +func WriteConflict(w http.ResponseWriter, humanMsg string) { + WriteError(w, http.StatusConflict, humanMsg, nil) +} + +// WritePreconditionFailed writes a 412 to the response. If the err is non-nil +// a verbose field is written with the contents of the error. +func WritePreconditionFailed(w http.ResponseWriter, humanMsg string, err error) { + WriteError(w, http.StatusPreconditionFailed, humanMsg, err) +} + +func WriteErrorWithSolution(w http.ResponseWriter, statusCode int, humanMsg string, solution string, err error) { + Write(w, statusCode, errorResponse{ + Error: errorPayload{ + Msg: humanMsg, + Code: codeSolution, + Details: detailsPrecondition{ + Message: humanMsg, + Error: err.Error(), + Solution: solution, + Verbose: err.Error(), //nolint:deprecated + }, + }, + }) +} + +// WriteFieldedPreconditionFailed writes a 412 to the response and the +// proper json fielded payload for decoding the error + solution +func WriteFieldedPreconditionFailed(w http.ResponseWriter, humanMsg string, solution string, err error) { + WriteErrorWithSolution(w, http.StatusPreconditionFailed, humanMsg, solution, err) +} + +// WriteNotFound writes a 404 to the response. It returns a generic public +// message such as "Environment not found." using the provided resource. +func WriteNotFound(w http.ResponseWriter, resource string) { + WriteError(w, http.StatusNotFound, fmt.Sprintf("%s not found.", resource), nil) +} + +// WriteCustomNotFound writes a 400 to the response. +func WriteCustomNotFound(w http.ResponseWriter, humanMsg string) { + WriteError(w, http.StatusNotFound, humanMsg, nil) +} + +// WriteInternalServerError writes a 500 to the response. It uses a generic +// message as the public message and writes the error the 'verbose' field +// in 'details' if it is non-nil. +func WriteInternalServerError(w http.ResponseWriter, err error) { + WriteCustomInternalServerError(w, "An internal server error occurred.", err) +} + +// WriteCustomInternalServerError writes a 500 to the response. Instead of the +// generic "An internal server error" occurred, the provided humanMsg is used. +func WriteCustomInternalServerError(w http.ResponseWriter, humanMsg string, err error) { + WriteError(w, http.StatusInternalServerError, humanMsg, err) +} + +// WriteError is a generic endpoint for writing error responses. If err is non-nil +// a 'verbose' field is written to the 'details' object. +func WriteError(w http.ResponseWriter, status int, humanMsg string, err error) { + Write(w, status, defaultErrorParams{ + msg: humanMsg, + verbose: err, + }) +} + +// defaultErrorParams contains common parameters across most error responses. +// Since the nature of the error payload is nested this type exists to allow +// assigning the values to a friendly, flat type. +type defaultErrorParams struct { + msg string + verbose error +} + +// MarshalJSON marshals the default error parameters into our structured error +// response. +func (d defaultErrorParams) MarshalJSON() ([]byte, error) { + payload := errorResponse{ + Error: errorPayload{ + Msg: d.msg, + Code: codeEmpty, + }, + } + if d.verbose != nil { + payload.Error.Code = codeVerbose + payload.Error.Details = detailsVerbose{ + Verbose: d.verbose.Error(), + } + } + + return json.Marshal(payload) +} + +// detailsVerbose is a simple object that can be assigned to the 'details' +// field of an erro response. It contains a more verbose explanation of the +// error. It tends to be the raw output of err.Error(). +type detailsVerbose struct { + Verbose string `json:"verbose,omitempty"` +} + +// detailsPrecondition is a details object that should be paired with 412 status +// codes. It contains the Go error, a human message, and a solution note. +type detailsPrecondition struct { + // Error is err.Error() and from Go + Error string `json:"error"` + // Message is the human readable error message + Message string `json:"message"` + // Solution is a helpful hint on how to solve the error + Solution string `json:"solution"` + + // Verbose is a copy of Error. + // Deprecated: Should remove this field, but the ui expects 'verbose' messages + // still and have not been moved to use the new fields for this error type. + Verbose string `json:"verbose,omitempty"` +} + +// errorResponse is the root of the error payload we send for status codes 400 +// and above. +type errorResponse struct { + Error errorPayload `json:"error"` +} + +// errorPayload contains the contents of an error response. +type errorPayload struct { + // Msg is a human-readable message. + Msg string `json:"msg"` + // Code dictates the structure of the details field. + Code srverr.Code `json:"code"` + // Details is an arbitrary object containing extra information + // on a particular error. Its structure is dictated by Code. + Details interface{} `json:"details,omitempty"` +} + +// HTTPError represents an error from the Coder API. +type HTTPError struct { + *http.Response + // we can't read the body lazily when Error is invoked + // so this must be populated at construction + Body []byte +} + +var _ error = &HTTPError{} + +// Error implements error. +func (e *HTTPError) Error() string { + var msg errorResponse + // Try to decode the payload as an error, if it fails or if there is no error message, + // return the response URL with the status. + if err := json.Unmarshal(e.Body, &msg); err != nil || msg.Error.Msg == "" { + return fmt.Sprintf("%s: %d %s", e.Request.URL, e.StatusCode, e.Status) + } + + // If the payload was a in the expected error format with a message, include it. + return msg.Error.Msg +} + +func BodyError(resp *http.Response) *HTTPError { + body, _ := io.ReadAll(resp.Body) + return &HTTPError{Response: resp, Body: body} +} diff --git a/xjson/error_page.html b/xjson/error_page.html new file mode 100644 index 0000000000000..5a0dc52b3d638 --- /dev/null +++ b/xjson/error_page.html @@ -0,0 +1,117 @@ + + + + + + + + + + +
+ +
+
+ +

{{status .}}

+

{{.Msg}}

+ {{if .DevURL}} + Retry + {{end}} + + {{if .Err}} +

{{.Err}}

+ {{end}} Back to Site +
+
+
+
+ diff --git a/xjson/error_test.go b/xjson/error_test.go new file mode 100644 index 0000000000000..017dd60084655 --- /dev/null +++ b/xjson/error_test.go @@ -0,0 +1,238 @@ +package xjson + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/net/html" + "golang.org/x/xerrors" + + "github.com/coder/coder/srverr" +) + +func TestDefaultErrorParams(t *testing.T) { + t.Parallel() + + t.Run("VerboseDetails", func(t *testing.T) { + var ( + testMsg = "Testing." + testErr = xerrors.Errorf("testing verbose") + ) + p := defaultErrorParams{ + msg: testMsg, + verbose: testErr, + } + + actualResponse, err := p.MarshalJSON() + require.NoError(t, err, "marshal error params") + + expectedResponse, err := json.Marshal(errorResponse{ + Error: errorPayload{ + Msg: testMsg, + Code: codeVerbose, + Details: detailsVerbose{ + Verbose: testErr.Error(), + }, + }, + }) + require.NoError(t, err, "marshal expected response") + require.Equal(t, expectedResponse, actualResponse, "responses differ") + }) + + t.Run("EmptyDetails", func(t *testing.T) { + var ( + testMsg = "Testing." + ) + p := defaultErrorParams{ + msg: testMsg, + } + + actualResponse, err := p.MarshalJSON() + require.NoError(t, err, "marshal error params") + + expectedResponse, err := json.Marshal(errorResponse{ + Error: errorPayload{ + Msg: testMsg, + Code: codeEmpty, + }, + }) + require.NoError(t, err, "marshal expected response") + require.Equal(t, string(expectedResponse), string(actualResponse), "responses differ") + }) +} + +// Checking Xjson errors write the correct code +func Test_JsonErrors(t *testing.T) { + t.Parallel() + const testMessage = "test message" + + vs := []struct { + Name string + Write func(w http.ResponseWriter) + ExpectedStatusCode int + ErrorCode srverr.Code + RespContains string + }{ + { + Name: "CustomBadRequest", + Write: func(w http.ResponseWriter) { + WriteBadRequestWithCode(w, "test", testMessage, nil) + }, + ExpectedStatusCode: http.StatusBadRequest, + RespContains: testMessage, + ErrorCode: "test", + }, + { + Name: "BadRequest", + Write: func(w http.ResponseWriter) { + WriteBadRequest(w, testMessage) + }, + ExpectedStatusCode: http.StatusBadRequest, + RespContains: testMessage, + }, + { + Name: "Unauthorized", + Write: func(w http.ResponseWriter) { + WriteUnauthorized(w, testMessage) + }, + ExpectedStatusCode: http.StatusUnauthorized, + RespContains: testMessage, + }, + { + Name: "Forbidden", + Write: func(w http.ResponseWriter) { + WriteForbidden(w, testMessage) + }, + ExpectedStatusCode: http.StatusForbidden, + RespContains: testMessage, + }, + { + Name: "Conflict", + Write: func(w http.ResponseWriter) { + WriteConflict(w, testMessage) + }, + ExpectedStatusCode: http.StatusConflict, + RespContains: testMessage, + }, + { + Name: "PreconditionFailed", + Write: func(w http.ResponseWriter) { + WritePreconditionFailed(w, testMessage, xerrors.New("random")) + }, + ExpectedStatusCode: http.StatusPreconditionFailed, + RespContains: testMessage, + ErrorCode: codeVerbose, + }, + { + Name: "FieldedPreconditionFailed", + Write: func(w http.ResponseWriter) { + WriteFieldedPreconditionFailed(w, testMessage, "this is a solution", xerrors.New("random")) + }, + ExpectedStatusCode: http.StatusPreconditionFailed, + RespContains: testMessage, + ErrorCode: codeSolution, + }, + { + Name: "NotFound", + Write: func(w http.ResponseWriter) { + WriteNotFound(w, testMessage) + }, + ExpectedStatusCode: http.StatusNotFound, + RespContains: testMessage, + }, + { + Name: "CustomNotFound", + Write: func(w http.ResponseWriter) { + WriteCustomNotFound(w, testMessage) + }, + ExpectedStatusCode: http.StatusNotFound, + RespContains: testMessage, + }, + { + Name: "CustomServerError", + Write: func(w http.ResponseWriter) { + WriteCustomInternalServerError(w, testMessage, xerrors.New("random")) + }, + ExpectedStatusCode: http.StatusInternalServerError, + RespContains: testMessage, + ErrorCode: codeVerbose, + }, + { + Name: "ServerError", + Write: func(w http.ResponseWriter) { + WriteInternalServerError(w, xerrors.New(testMessage)) + }, + ExpectedStatusCode: http.StatusInternalServerError, + RespContains: "server error", + ErrorCode: codeVerbose, + }, + } + + for _, v := range vs { + if v.ErrorCode == "" { + v.ErrorCode = codeEmpty + } + t.Run(v.Name, func(t *testing.T) { + w := httptest.NewRecorder() + v.Write(w) + + resp := w.Result() + require.Equal(t, v.ExpectedStatusCode, resp.StatusCode, "BadRequest") + + respErr := BodyError(resp) + _ = resp.Body.Close() + + // Assure the body is a full json payload + var eResp errorResponse + err := json.Unmarshal(respErr.Body, &eResp) + + require.True(t, strings.Contains(respErr.Error(), v.RespContains), "contains") + + require.NoError(t, err, "body decode") + require.Equal(t, v.ErrorCode, eResp.Error.Code, "correct code") + }) + } +} + +// Test_EmptyHTTPError checks to ensure the error works on empty responses. +// Athought the response is not an error code, the BodyError should still wrap the response data +// corectly. +func Test_EmptyHTTPError(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(srv.Close) + + r, err := http.NewRequestWithContext(context.Background(), "GET", srv.URL, nil) + require.NoError(t, err, "new request") + resp, err := http.DefaultClient.Do(r) + require.NoError(t, err, "GET request") + + e := BodyError(resp) + require.Contains(t, e.Error(), strconv.Itoa(resp.StatusCode), "has status code") +} + +// TestHTMLErrorPage tests that the embedded page is valid HTML. +func TestHTMLErrorPage(t *testing.T) { + t.Parallel() + + recorder := httptest.NewRecorder() + + WriteErrPage(recorder, ErrPage{ + DevURL: "https://*.master.cdr.dev", + AccessURL: "https://master.cdr.dev", + }) + + node, err := html.Parse(recorder.Body) + require.NoError(t, err, "HTML error page does not appear valid") + require.NotNil(t, node, "require the node to be non-nil") + require.Nil(t, node.Parent, "parent should be nil (root element)") + require.Equal(t, html.DocumentNode, node.Type, "node is document") +} diff --git a/xjson/json.go b/xjson/json.go new file mode 100644 index 0000000000000..e3505067a3cf9 --- /dev/null +++ b/xjson/json.go @@ -0,0 +1,372 @@ +package xjson + +import ( + "context" + _ "embed" + "encoding/json" + "fmt" + "html/template" + "io" + "net/http" + "reflect" + "runtime" + "strings" + + "github.com/asaskevich/govalidator" + "github.com/go-playground/validator/v10" + "golang.org/x/xerrors" + + "github.com/coder/coder/buildmode" + "github.com/coder/coder/validate" + + "cdr.dev/slog" +) + +// This contains the raw mark-up for our dynamic server-side error page. +// Reasons for using a string literal: +// +// 1. It's a small/simple amount of markup +// 2. We avoid possible file-path errors from files moving around +// 3. More performant than doing file i/o +// 4. Development will be easier because it becomes hot-swappable +//go:embed error_page.html +var errPageMarkup string + +// m is a a helper struct for marshaling arbitrary json. +type m map[string]interface{} + +// SuccessMsg contains a single 'msg' field +// with an arbitrary value indicating success. +type SuccessMsg struct { + Msg string `json:"msg"` +} // @name SuccessMsg + +// Write writes a json response. +func Write(w http.ResponseWriter, status int, body interface{}) { + if body == nil { + w.WriteHeader(status) + return + } + + if strBody, ok := body.(string); ok { + body = SuccessMsg{ + Msg: strBody, + } + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + + err := encodeBody(w, body) + if err != nil { + // We can't write to hijacked connections. Don't panic in that + // case. + if xerrors.Is(err, http.ErrHijacked) { + return + } + + panic(err) + } +} + +func encodeBody(w io.Writer, body interface{}) error { + enc := json.NewEncoder(w) + // Format the response nicely. + enc.SetIndent("", "\t") + enc.SetEscapeHTML(false) + return enc.Encode(body) +} + +// ErrPage is used for writing error templates +// to the response writer using WriteErrPage. +type ErrPage struct { + DevURL string + AccessURL string + Msg string + Code int + Err error +} + +// WriteErrPage writes error templates to w after dynamically constructing it +// based on the contents of p. +// +// If p.Code == 0: +// The status code will default to http.StatusInternalServerError. +// +// If p.Msg == "": +// The error message will default to the status text of the status code. + +// If p.Err == nil: +// The error will not render. It is optional to provide a value for p.Err since +// p.Msg is rendered as the public-facing error that the user will see. p.Err +// can be used for development debugging purposes. +// +// If p.AccessURL == "": +// The Back to Site button on the page won't work. AccessURL should have it's +// value set to a database.ConfigGeneral.AccessURL.URL. +// +// If p.DevURL == "": +// The retry button linking back to the dev url will not appear on the rendered page. +func WriteErrPage(w http.ResponseWriter, p ErrPage) { + if p.Code == 0 { + p.Code = http.StatusInternalServerError + } + + if p.Msg == "" { + p.Msg = http.StatusText(p.Code) + } + + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(p.Code) + + t, err := template.New("").Funcs( + template.FuncMap{ + "status": func(p ErrPage) string { + return fmt.Sprintf("%d - %s", p.Code, http.StatusText(p.Code)) + }, + }, + ).Parse(errPageMarkup) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Load the contents of p into the template then write the template to w. + if err := t.ExecuteTemplate(w, "", p); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +// ErrUnauthorized is an error returned when a user tries to access a resource without +// having sufficient permissions. +var ErrUnauthorized = xerrors.New("Insufficient permissions to access resource") + +// WriteUnauthorizedError writes out an error formatted in JSON +// about the user not having sufficient permissions to access a resource. +func WriteUnauthorizedError(w http.ResponseWriter) { + WriteUnauthorized(w, ErrUnauthorized.Error()) +} + +// DatabaseError writes 500 with a database error message to the response writer, +// and logs details about the error. +func DatabaseError(ctx context.Context, log slog.Logger, w http.ResponseWriter, err error) { + // To maintain backwards-compatible behavior we do not write the database error to + // the details. + WriteCustomInternalServerError(w, "A database error occurred.", nil) + slog.Helper() + log.Error(ctx, "A database error occurred.", slog.Error(err)) +} + +// ServerError writes a 500 with the message to the response writer, and logs details +// about the error. +func ServerError(ctx context.Context, log slog.Logger, w http.ResponseWriter, err error, msg string) { + WriteInternalServerError(w, nil) + slog.Helper() + log.Error(ctx, "server error", + slog.F("msg", msg), + slog.Error(err), + ) +} + +// validatorErrorMessage constructs a human readable message from a validation error. +func validatorErrorMessage(err govalidator.Error) string { + switch { + case err.Validator == "required": + return fmt.Sprintf("Field %q is required.", err.Name) + default: + return fmt.Sprintf("Field %q is invalid (%v).", err.Name, err.Err.Error()) + } +} + +// convertValidationErrors converts govalidator errors into structured JSON. +// Each entry of the returned []m has at least `msg` set. +func convertValidationErrors(errs govalidator.Errors) []m { + var r []m + + for _, err := range errs { + switch e := err.(type) { // nolint: errorlint + case govalidator.Errors: + // For some reason govalidator nests another Errors sometimes. + // Let's just flatten and append it. + r = append(r, convertValidationErrors(e)...) + case govalidator.Error: + r = append(r, m{ + "msg": validatorErrorMessage(e), + "field": e.Name, + "error": e.Err.Error(), + "validator": e.Validator, + }) + default: + r = append(r, m{ + "msg": err, + // type is provided to aid in debugging. It offers no contract. + "type": reflect.TypeOf(err).String(), + }) + } + } + + return r +} + +// ReadBody reads a json object from the request body. If the read fails, a 400 +// is sent back to the client, and this will return false. +// +// To ensure proper validation during development, this function will fatal if +// the current build mode is "dev", if there's at least one field with the +// "validate" tag, and there's additional fields on the struct that are not +// validated (in accordance to `validate.FieldsMissingValidation`). +func ReadBody(log slog.Logger, w http.ResponseWriter, r *http.Request, v interface{}) bool { + err := json.NewDecoder(r.Body).Decode(v) + if err != nil { + log.Warn(r.Context(), "failed to read body", slog.Error(err)) + WriteError(w, http.StatusBadRequest, "Failed to read body.", err) + return false + } + + if buildmode.Dev() { + mustConsistentlyValidate(r.Context(), log, v) + } + + return Validate(w, v) +} + +func mustConsistentlyValidate(ctx context.Context, log slog.Logger, v interface{}) { + // Only make the logger if we need it. + logger := func() slog.Logger { + // Add caller and object type to know where to find struct that is failing. + _, file, line, _ := runtime.Caller(3) + return log.With( + slog.F("v", v), + slog.F("type", reflect.TypeOf(v).String()), + slog.F("caller", fmt.Sprintf("%s:%d", file, line)), + ) + } + + // Get explicitly tagged fields. + explicit, err := validate.FieldsWithValidation(v) + // Errors only if `v` isn't a struct. + if err != nil { + logger().Debug(ctx, "failed to check for fields with validation", slog.Error(err)) + return + } + if len(explicit) > 0 { + notValidated, err := validate.FieldsMissingValidation(v) + if err != nil { + logger().Debug(ctx, "failed to check for fields missing validation", slog.Error(err)) + return + } + if len(notValidated) > 0 { + logger().Fatal(ctx, "some fields missing validation", + slog.F("explicitly_validated", explicit), + slog.F("not_validated", notValidated), + ) + } + } +} + +func summarizeValidationErrors(subErrors []m) string { + var sb strings.Builder + _, _ = fmt.Fprint(&sb, "Input validation failed.") + for _, v := range subErrors { + _, _ = fmt.Fprint(&sb, "\n", "• ", v["msg"]) + } + return sb.String() +} + +// summarizeFieldErrors formats an error string suitable for displaying directly +// to the user from field validation errors. +func summarizeFieldErrors(errs []validator.FieldError) string { + var sb strings.Builder + for i, err := range errs { + // Error is decent enough. Will produce a string in the form of: + // "Key: '%s' Error:Field validation for '%s' failed on the '%s' tag" + _, _ = sb.WriteString(err.Error()) + if i != len(errs)-1 { + _, _ = sb.WriteString(", ") + } + } + + return sb.String() +} + +// convertFieldErrors converts field errors into structured JSON. Each entry of +// the returned []m has at least `msg` set. +func convertFieldErrors(errs []validator.FieldError) []m { + ms := make([]m, len(errs)) + for i, err := range errs { + ms[i] = m{ + "msg": err.Error(), + } + } + + return ms +} + +// Validate will call `Check` on the provided value, and write an appropriate +// response if validation fails. +func Validate(w http.ResponseWriter, v interface{}) bool { + if err := Check(v); err != nil { + WriteError(w, http.StatusBadRequest, "Request failed to validate.", err) + return false + } + return true +} + +type checkError struct { + Message string `json:"msg"` + Errors []m `json:"validation_errors,omitempty"` +} + +func (e *checkError) Error() string { + return fmt.Sprintf("%s: [%s]", e.Message, summarizeValidationErrors(e.Errors)) +} + +// Check runs validation on fields of a struct. If the type passed in is not a +// struct, no validation will be done. +// +// If a struct field has a "valid" tag, asaskevich/govalidator will be used for +// validation. If a struct field has a "validate" tag, go-playground/validator +// will be used for validation. +func Check(v interface{}) error { + // govalidator returns an error if the type isn't struct or *struct. + rv := reflect.ValueOf(v) + for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface { + rv = rv.Elem() + } + if rv.Kind() != reflect.Struct { + return nil + } + + // Validate using go-playground/validator first. + if err := validate.Validator().Struct(v); err != nil { + var vErrs validator.ValidationErrors + if xerrors.As(err, &vErrs) { + return &checkError{ + Message: summarizeFieldErrors(vErrs), + Errors: convertFieldErrors(vErrs), + } + } + return &checkError{Message: fmt.Sprintf("input validation: %s", err.Error())} + } + + // Validate using asaskevich/govalidator after the above. Eventually this + // should be removed when all validation is switched over. + if ok, err := govalidator.ValidateStruct(v); err != nil { + var gve govalidator.Errors + if xerrors.As(err, &gve) { + verrs := convertValidationErrors(gve) + + return &checkError{ + Message: summarizeValidationErrors(verrs), + Errors: verrs, + } + } + + return &checkError{Message: fmt.Sprintf("input validation: %s", err.Error())} + } else if !ok { + return &checkError{Message: "input validation failed"} + } + + return nil +} diff --git a/xjson/json_test.go b/xjson/json_test.go new file mode 100644 index 0000000000000..3550881931067 --- /dev/null +++ b/xjson/json_test.go @@ -0,0 +1,241 @@ +package xjson + +import ( + "bytes" + "fmt" + "html/template" + "io" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/asaskevich/govalidator" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" +) + +func Test_convertValidationErrors(t *testing.T) { + type args struct { + errs govalidator.Errors + } + tests := []struct { + name string + args args + want []m + }{ + {"none", args{govalidator.Errors{}}, nil}, + // TODO (AB): Add more tests. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := convertValidationErrors(tt.args.errs); !reflect.DeepEqual(got, tt.want) { + t.Errorf("convertValidationErrors() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestWriteErrPage(t *testing.T) { + for _, test := range []struct { + name string + p ErrPage + }{ + { + name: "OK", + p: ErrPage{ + DevURL: "ok-expected-dev-url", + AccessURL: "ok-expected-access-url", + Msg: "ok-expected-msg", + Code: http.StatusBadGateway, + Err: xerrors.New("ok-expected-err"), + }, + }, + { + name: "Code Unset", + p: ErrPage{ + DevURL: "code-unset-expected-dev-url", + AccessURL: "code-unset-expected-access-url", + Msg: "code-unset-expected-msg", + Code: 0, + Err: xerrors.New("code-unset-expected-err"), + }, + }, + { + name: "Msg Unset", + p: ErrPage{ + DevURL: "msg-unset-expected-dev-url", + AccessURL: "msg-unset-expected-access-url", + Msg: "", + Code: http.StatusInternalServerError, + Err: xerrors.New("msg-unset-expected-err"), + }, + }, + { + name: "AccessURL Unset", + p: ErrPage{ + DevURL: "access-url-unset-expected-dev-url", + AccessURL: "", + Msg: "access-url-unset-expected-msg", + Code: http.StatusInternalServerError, + Err: xerrors.New("access-url-unset-expected-err"), + }, + }, + { + name: "DevURL Unset", + p: ErrPage{ + DevURL: "", + AccessURL: "dev-url-unset-expected-access-url", + Msg: "dev-url-unset-expected-msg", + Code: http.StatusInternalServerError, + Err: xerrors.New("dev-url-unset-expected-err"), + }, + }, + { + name: "Nil Err", + p: ErrPage{ + DevURL: "nil-err-expected-dev-url", + AccessURL: "nil-err-expected-access-url", + Msg: "nil-err-expected-msg", + Code: http.StatusInternalServerError, + Err: nil, + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + WriteErrPage(w, test.p) + }, + ) + + s := httptest.NewServer(handler) + defer s.Close() + + req := httptest.NewRequest(http.MethodGet, s.URL, nil) + respRecorder := httptest.NewRecorder() + handler(respRecorder, req) + + resp := respRecorder.Result() + require.NotNil(t, resp, "expected non-nil response") + defer resp.Body.Close() + require.Equal(t, http.StatusInternalServerError, resp.StatusCode, "status codes differ") + + got, err := io.ReadAll(resp.Body) + require.Equal(t, nil, err, "read body") + + switch test.name { + case "OK": + require.Equal(t, expectedOKErrPage(t), got, "OK response data does not match") + case "Code Unset": + require.Equal(t, expectedCodeUnsetErrPage(t), got, "code-unset response data does not match") + case "Msg Unset": + require.Equal(t, expectedMsgUnsetErrPage(t), got, "msg-unset response data does not match") + case "DevURL Unset": + require.Equal(t, expectedDevURLUnsetErrPage(t), got, "dev-url-unset response data does not match") + case "AccessURL Unset": + require.Equal(t, expectedAccessURLUnsetErrPage(t), got, "access-url-unset response data does not match") + case "Nil Err": + require.Equal(t, expectedNilErrPage(t), got, "nil-err response data does not match") + default: + t.Fail() + } + }, + ) + } +} + +func toTemplateData(t *testing.T, ep ErrPage) []byte { + view, err := template.New("").Funcs( + template.FuncMap{ + "status": func(p ErrPage) string { + return fmt.Sprintf("%d - %s", p.Code, http.StatusText(p.Code)) + }, + }, + ).Parse(errPageMarkup) + + require.NoError(t, err) + b := bytes.NewBuffer(nil) + // We can use a bytes.Buffer as replacement for the http.ResponseWriter because + // it implements the io.Writer interface. + // Write the ErrPage to the template then writes the template to the buffer. + require.NoError(t, view.ExecuteTemplate(b, "", ep)) + return b.Bytes() +} + +func expectedOKErrPage(t *testing.T) []byte { + // when this is turned into template data, + // the retry button should be rendered by the handler + // and match accordingly. + return toTemplateData(t, ErrPage{ + DevURL: "code-unset-expected-dev-url", + AccessURL: "code-unset-expected-access-url", + Msg: "code-unset-expected-msg", + Code: http.StatusBadGateway, + Err: xerrors.New("code-unset-expected-err"), + }) +} + +func expectedCodeUnsetErrPage(t *testing.T) []byte { + return toTemplateData(t, ErrPage{ + DevURL: "code-unset-expected-dev-url", + AccessURL: "code-unset-expected-access-url", + Msg: "code-unset-expected-msg", + // If Code is unset, it should default to a 500. + Code: http.StatusInternalServerError, + Err: xerrors.New("code-unset-expected-err"), + }) +} + +func expectedMsgUnsetErrPage(t *testing.T) []byte { + return toTemplateData(t, ErrPage{ + DevURL: "msg-unset-expected-dev-url", + AccessURL: "msg-unset-expected-access-url", + // If Msg was unset, it should default to the status text + // of its status code.. + Msg: http.StatusText(http.StatusInternalServerError), + Code: http.StatusInternalServerError, + Err: xerrors.New("msg-unset-expected-err"), + }) +} + +func expectedAccessURLUnsetErrPage(t *testing.T) []byte { + return toTemplateData(t, ErrPage{ + DevURL: "access-url-unset-expected-dev-url", + // AccessURL's do not get auto-corrected. + // 'Back to Site' button will not work when + // writing an ErrPage with an unset AccessURL + // to a template. + AccessURL: "", + Msg: "access-url-unset-expected-msg", + Code: http.StatusInternalServerError, + Err: xerrors.New("access-url-unset-expected-err"), + }) +} + +func expectedNilErrPage(t *testing.T) []byte { + return toTemplateData(t, ErrPage{ + DevURL: "nil-err-expected-dev-url", + AccessURL: "nil-err-expected-access-url", + Msg: "nil-err-expected-msg", + Code: http.StatusInternalServerError, + Err: nil, + }) +} + +func expectedDevURLUnsetErrPage(t *testing.T) []byte { + return toTemplateData(t, ErrPage{ + // The DevURL remains unadjusted because no logic + // will adjust it. When the err page is turned into + // template data the retry button won't be present. + // The test server handler should do the same and provide + // the expected result. + DevURL: "", + AccessURL: "nil-err-expected-access-url", + Msg: "nil-err-expected-msg", + Code: http.StatusInternalServerError, + Err: nil, + }) +} From 7329cca18e53062024bceaf95aa0649839deb274 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Wed, 19 Jan 2022 04:57:52 +0000 Subject: [PATCH 02/41] Add placeholder API and projectService --- coderd/api.go | 17 +++++++++ coderd/coderd.go | 19 ++++++++++ coderd/projects.go | 91 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 coderd/api.go create mode 100644 coderd/projects.go diff --git a/coderd/api.go b/coderd/api.go new file mode 100644 index 0000000000000..5b01cae67a165 --- /dev/null +++ b/coderd/api.go @@ -0,0 +1,17 @@ +package coderd + +import "context" + +// API offers an HTTP API. Routes are located in routes.go. +type API struct { + // Services. + projectService *projectService +} + +// New returns an instantiated API. +func NewAPI(ctx context.Context) *API { + api := &API{ + projectService: newProjectService(), + } + return api +} diff --git a/coderd/coderd.go b/coderd/coderd.go index e3c7c2b546b9f..4e8296002aeb1 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1,6 +1,7 @@ package coderd import ( + "context" "net/http" "cdr.dev/slog" @@ -16,8 +17,14 @@ type Options struct { Database database.Store } +const ( + provisionerTerraform = "provisioner:terraform" + provisionerBasic = "provisioner:basic" +) + // New constructs the Coder API into an HTTP handler. func New(options *Options) http.Handler { + api := NewAPI(context.Background()) r := chi.NewRouter() r.Route("/api/v2", func(r chi.Router) { r.Get("/", func(w http.ResponseWriter, r *http.Request) { @@ -27,6 +34,18 @@ func New(options *Options) http.Handler { Message: "👋", }) }) + + // Projects endpoint + r.Route("/projects", func(r chi.Router) { + r.Route("/{organization}", func(r chi.Router) { + // TODO: Authentication + // TODO: User extraction + // TODO: Extract organization and add to context + r.Get("/", api.projectService.getProjects) + r.Post("/", api.projectService.createProject) + }) + }) + }) r.NotFound(site.Handler().ServeHTTP) return r diff --git a/coderd/projects.go b/coderd/projects.go new file mode 100644 index 0000000000000..cd057a9cbdc0f --- /dev/null +++ b/coderd/projects.go @@ -0,0 +1,91 @@ +package coderd + +import ( + "net/http" + + "github.com/coder/coder/xjson" +) + +type ProjectParameter struct { + Id string `json:"id" validate:"required"` + Name string `json:"name" validate:"required"` + Description string `json:"description"` + + // Validation Parameters + ValueType string `json:"validation_value_type"` +} + +// Project is a Go representation of the workspaces v2 project, +// defined here: https://www.notion.so/coderhq/Workspaces-v2-e908a8cd54804ddd910367abf03c8d0a#befa328add894231979e6cf8a378d2ec +type Project struct { + Id string `json:"id" validate:"required"` + Name string `json:"name" validate:"required"` + Description string `json:"description" validate:"required"` + ProvisionerType string `json:"provisioner_type" validate:"required"` + + Parameters []ProjectParameter `json:"parameters" validate:"required"` +} + +// Placeholder type of projectService +type projectService struct { +} + +func newProjectService() *projectService { + projectService := &projectService{} + return projectService +} + +func (ps *projectService) getProjects(w http.ResponseWriter, r *http.Request) { + + // Construct a couple hard-coded projects to return the UI + + terraformProject := Project{ + Id: "test_terraform_project_id", + Name: "Terraform", + Description: "Kubernetes on Terraform", + Parameters: []ProjectParameter{ + { + Id: "parameter_cluster_namespace", + Name: "Namespace", + Description: "Kubernetes namespace to host workspace pod", + ValueType: "string", + }, + { + Id: "parameter_cpu", + Name: "CPU", + Description: "CPU Cores to Allocate", + ValueType: "number", + }, + }, + } + + echoProject := Project{ + Id: "test_echo_project_id", + Name: "Echo Project", + Description: "A simple echo provider", + Parameters: []ProjectParameter{ + { + Id: "parameter_echo_string", + Name: "Echo String", + Description: "String that should be echo'd out in build log", + ValueType: "string", + }, + }, + } + + projects := []Project{ + terraformProject, + echoProject, + } + + xjson.Write(w, http.StatusOK, projects) +} + +func (ps *projectService) createProject(w http.ResponseWriter, r *http.Request) { + // TODO: Validate arguments + // Organization context + // User + // Parameter values + // Submit to provisioner + xjson.Write(w, http.StatusOK, nil) +} From 6726f40cf0ddfa7e4ec6bcf876a95d5768343d9c Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Wed, 19 Jan 2022 05:05:39 +0000 Subject: [PATCH 03/41] Create initial workspaces API --- coderd/api.go | 10 ++++----- coderd/coderd.go | 14 +++++++++++-- coderd/projects.go | 7 +++++-- coderd/workspaces.go | 48 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 9 deletions(-) create mode 100644 coderd/workspaces.go diff --git a/coderd/api.go b/coderd/api.go index 5b01cae67a165..1fe4d7140d8e2 100644 --- a/coderd/api.go +++ b/coderd/api.go @@ -1,17 +1,17 @@ package coderd -import "context" - // API offers an HTTP API. Routes are located in routes.go. type API struct { // Services. - projectService *projectService + projectService *projectService + workspaceService *workspaceService } // New returns an instantiated API. -func NewAPI(ctx context.Context) *API { +func NewAPI() *API { api := &API{ - projectService: newProjectService(), + projectService: newProjectService(), + workspaceService: newWorkspaceService(), } return api } diff --git a/coderd/coderd.go b/coderd/coderd.go index 4e8296002aeb1..c57ef3c1372a8 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1,7 +1,6 @@ package coderd import ( - "context" "net/http" "cdr.dev/slog" @@ -24,7 +23,7 @@ const ( // New constructs the Coder API into an HTTP handler. func New(options *Options) http.Handler { - api := NewAPI(context.Background()) + api := NewAPI() r := chi.NewRouter() r.Route("/api/v2", func(r chi.Router) { r.Get("/", func(w http.ResponseWriter, r *http.Request) { @@ -43,6 +42,17 @@ func New(options *Options) http.Handler { // TODO: Extract organization and add to context r.Get("/", api.projectService.getProjects) r.Post("/", api.projectService.createProject) + + r.Get("/{projectId}", api.projectService.getProjectById) + // TODO: Get project by id + }) + }) + + // Workspaces endpoint + r.Route("/workspaces", func(r chi.Router) { + r.Route("/{organization}", func(r chi.Router) { + r.Get("/", api.workspaceService.getWorkspaces) + r.Get("/{projectId}", api.workspaceService.getWorkspaceById) }) }) diff --git a/coderd/projects.go b/coderd/projects.go index cd057a9cbdc0f..81230b42eed3d 100644 --- a/coderd/projects.go +++ b/coderd/projects.go @@ -36,9 +36,7 @@ func newProjectService() *projectService { } func (ps *projectService) getProjects(w http.ResponseWriter, r *http.Request) { - // Construct a couple hard-coded projects to return the UI - terraformProject := Project{ Id: "test_terraform_project_id", Name: "Terraform", @@ -81,6 +79,11 @@ func (ps *projectService) getProjects(w http.ResponseWriter, r *http.Request) { xjson.Write(w, http.StatusOK, projects) } +func (ps *projectService) getProjectById(w http.ResponseWriter, r *http.Request) { + // TODO: Get a project by id + xjson.Write(w, http.StatusNotFound, nil) +} + func (ps *projectService) createProject(w http.ResponseWriter, r *http.Request) { // TODO: Validate arguments // Organization context diff --git a/coderd/workspaces.go b/coderd/workspaces.go new file mode 100644 index 0000000000000..be236ef6b5936 --- /dev/null +++ b/coderd/workspaces.go @@ -0,0 +1,48 @@ +package coderd + +import ( + "net/http" + + "github.com/coder/coder/xjson" +) + +type Workspace struct { + Id string `json:"id" validate:"required"` + Name string `json:"name" validate:"required"` + ProjectId string `json:"project_id" validate:"required"` +} + +// Placeholder type of workspaceService +type workspaceService struct { +} + +func newWorkspaceService() *workspaceService { + workspaceService := &workspaceService{} + return workspaceService +} + +func (ws *workspaceService) getWorkspaces(w http.ResponseWriter, r *http.Request) { + // Dummy workspace to return + workspace := Workspace{ + Id: "test-workspace", + Name: "Test Workspace", + ProjectId: "test-project-id", + } + + workspaces := []Workspace{ + workspace, + } + + xjson.Write(w, http.StatusOK, workspaces) +} + +func (ws *workspaceService) getWorkspaceById(w http.ResponseWriter, r *http.Request) { + // TODO: Read workspace off context + // Dummy workspace to return + workspace := Workspace{ + Id: "test-workspace", + Name: "Test Workspace", + ProjectId: "test-project-id", + } + xjson.Write(w, http.StatusOK, workspace) +} \ No newline at end of file From 3f932c2a69814de4bb79a8295749f9967d377686 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Wed, 19 Jan 2022 05:44:56 +0000 Subject: [PATCH 04/41] Port over SafeHydrate component --- site/pages/_app.tsx | 19 +++++++++++++++---- site/pages/_document.tsx | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 site/pages/_document.tsx diff --git a/site/pages/_app.tsx b/site/pages/_app.tsx index 9cb8010a5dbea..65662289655c6 100644 --- a/site/pages/_app.tsx +++ b/site/pages/_app.tsx @@ -40,16 +40,27 @@ const Contents: React.FC = ({ Component, pageProps }) => { ) } +/** + * SafeHydrate is a component that only allows its children to be rendered + * client-side. This check is performed by querying the existence of the window + * global. + */ +const SafeHydrate: React.FC = ({ children }) => ( +
{typeof window === "undefined" ? null : children}
+) + /** * is the root rendering logic of the application - setting up our router * and any contexts / global state management. */ const MyApp: React.FC = (appProps) => { return ( - - - - + + + + + + ) } diff --git a/site/pages/_document.tsx b/site/pages/_document.tsx new file mode 100644 index 0000000000000..7c866c59f31da --- /dev/null +++ b/site/pages/_document.tsx @@ -0,0 +1,34 @@ +import Document, { DocumentContext, Head, Html, Main, NextScript } from "next/document" +import React from "react" + +class MyDocument extends Document { + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + static async getInitialProps(ctx: DocumentContext) { + const initialProps = await Document.getInitialProps(ctx) + return { ...initialProps } + } + + render(): JSX.Element { + return ( + + + {/* Meta tags */} + + + + + + + + + + +
+ + + + ) + } +} + +export default MyDocument \ No newline at end of file From 6fdb43f9e277774ad28fa02509f7692d439ec047 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Wed, 19 Jan 2022 05:45:28 +0000 Subject: [PATCH 05/41] Stub pages for workspace creation --- site/pages/workspaces/create/[projectId].tsx | 23 ++++++++++++++++++++ site/pages/workspaces/create/index.tsx | 19 ++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 site/pages/workspaces/create/[projectId].tsx create mode 100644 site/pages/workspaces/create/index.tsx diff --git a/site/pages/workspaces/create/[projectId].tsx b/site/pages/workspaces/create/[projectId].tsx new file mode 100644 index 0000000000000..ff9b13800f730 --- /dev/null +++ b/site/pages/workspaces/create/[projectId].tsx @@ -0,0 +1,23 @@ +import React from "react" +import { useRouter } from "next/router" + +const CreateProjectPage: React.FC = () => { + + const router = useRouter() + const { projectId } = router.query + + const createWorkspace = () => { + alert("create") + } + + const button = { + children: "New Workspace", + onClick: createWorkspace, + } + + return ( +
Create Page: {projectId}
+ ) +} + +export default CreateProjectPage \ No newline at end of file diff --git a/site/pages/workspaces/create/index.tsx b/site/pages/workspaces/create/index.tsx new file mode 100644 index 0000000000000..a1d524acb5e3f --- /dev/null +++ b/site/pages/workspaces/create/index.tsx @@ -0,0 +1,19 @@ +import React from "react" + +const CreateSelectProjectPage: React.FC = () => { + + const createWorkspace = () => { + alert("create") + } + + const button = { + children: "New Workspace", + onClick: createWorkspace, + } + + return ( +
Create Page
+ ) +} + +export default CreateSelectProjectPage \ No newline at end of file From 97b23832e80a689657d0c3b1f976e89fc409b111 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Wed, 19 Jan 2022 05:47:26 +0000 Subject: [PATCH 06/41] Add missed favicons --- site/pages/_document.tsx | 6 +++--- site/static/favicon.ico | Bin 0 -> 4286 bytes site/static/favicon.png | Bin 0 -> 571 bytes site/static/favicon.svg | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 site/static/favicon.ico create mode 100644 site/static/favicon.png create mode 100644 site/static/favicon.svg diff --git a/site/pages/_document.tsx b/site/pages/_document.tsx index 7c866c59f31da..58fb1adbdfeb7 100644 --- a/site/pages/_document.tsx +++ b/site/pages/_document.tsx @@ -18,9 +18,9 @@ class MyDocument extends Document { - - - + + +
diff --git a/site/static/favicon.ico b/site/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..2e20e00e1a1dcb5779342ed92cb5bf133a3b041b GIT binary patch literal 4286 zcmeHHJ8l~>5T)Y6MG6TBkTz4MRFOU#=mNfhm=i=dFacjcH;~J8$_==|9zaJ}-9RZ_ z1YzEjIKhBfiIQCf(KFu8aOTZtxwf4f;YY;r>Ap^!d+VHg4a2H!gU%*STM8P?;ttR9lQq-<; z{1OufdIZEumO6okScAITW*<2`1m+IpJ)MH>8%_z4djxgXDr*-e#+-t?@EkR8#4=AE z>A_PFBvq^L>P?I}h3a-1IASB>s`W=SHmuq{2U(*rT7|wpV^a+d*oc_c`+>X-H8`lY z_Wt&}^r#bX2OYK&GPaIBs=?i|52sCQ!b^oT109O~JFmi0=;CLrNK zjnirz?*5&rwR!b-IZu7e8V^e}pz2D?x+$m-I99{_Qj@Tf&x~6g{WYkN>~NnASZaA* zfdZEJf`IKGOF=#hEO)oPsck^XwujX`dr~jpF>qg7!`gUEoa6Vf)YUkz^kMq0=(E6? zJJ(R&ncbyFv7Nodd6mg|79Iy%YjZV!0>M4F24m(NR={)NA$;!q+6?v%1LTeh59cNG z1M2xLJXqeXAT~tao5I6+$^2;QziHEK8(42x?uXQ0|9=>}1dkBw{ylsF6;n7(LCvYP z`}6PhQY=uQ^VPCv4g6-RS9?xf*;f$gi(Wj}5FpUGYKOj0tq=18Mv(W%UOiYtfI#PN z;LmFX*Zlu;18E4`n{jGKH>q_G;Ot%Zl=*WuhJV}rn7Ntz6kfTu{V>`-xpDi;eQcBa Z+@+AZltPy}@}W!L!8V~6xJ|d^xZhhaYyAKK literal 0 HcmV?d00001 diff --git a/site/static/favicon.png b/site/static/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..ebb0c8c8062b5603f44c42a695670b2b48477de6 GIT binary patch literal 571 zcmV-B0>u4^P)@1z3&;l zT<&rqhrq-{7)q^rlL-iF>(jGMzOKcCvQr%nXlj{Lcfs}GUx*Ca&N>uyWkG)iY(zyMjmIu z7cD)D)oAawZ6>Mli{U>OTNNHd+84gyI}>cf7Ri`BWeewKeu;iXO0>5o6}D3|%HVIY zoWhfUM4`K(+>^i&YQ;0={EBfem?0{yH}?HyXfh>WOH3{E8`ojW#u3gWV1r&OIHB11 z?7M) \ No newline at end of file From 086d5332d622f0ceaeb21c46b2630ed4491cf804 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Wed, 19 Jan 2022 05:58:56 +0000 Subject: [PATCH 07/41] Formatting --- site/pages/_document.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/pages/_document.tsx b/site/pages/_document.tsx index 58fb1adbdfeb7..a32706ebf6fe2 100644 --- a/site/pages/_document.tsx +++ b/site/pages/_document.tsx @@ -31,4 +31,4 @@ class MyDocument extends Document { } } -export default MyDocument \ No newline at end of file +export default MyDocument From c305c8de23d6d02641312367be080430476e7a39 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Wed, 19 Jan 2022 15:38:56 +0000 Subject: [PATCH 08/41] Initial select page --- site/components/FullScreenForm/Title.tsx | 35 ++++++++++++++++++++++++ site/components/FullScreenForm/index.ts | 1 + site/pages/workspaces/create/index.tsx | 7 +++-- 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 site/components/FullScreenForm/Title.tsx create mode 100644 site/components/FullScreenForm/index.ts diff --git a/site/components/FullScreenForm/Title.tsx b/site/components/FullScreenForm/Title.tsx new file mode 100644 index 0000000000000..e713875be00c0 --- /dev/null +++ b/site/components/FullScreenForm/Title.tsx @@ -0,0 +1,35 @@ +import { makeStyles } from "@material-ui/core/styles" +import Typography from "@material-ui/core/Typography" +import React from "react" + +export interface TitleProps { + title: string + organization: string +} + +const useStyles = makeStyles((theme) => ({ + title: { + textAlign: "center", + marginBottom: theme.spacing(10), + + [theme.breakpoints.down("sm")]: { + gridColumn: 1, + }, + + "& h3": { + marginBottom: theme.spacing(1), + }, + }, +})) + +export const Title: React.FC = ({ title, organization }) => { + + const styles = useStyles() + + return
+ {title} + + In {organization} organization + +
+} \ No newline at end of file diff --git a/site/components/FullScreenForm/index.ts b/site/components/FullScreenForm/index.ts new file mode 100644 index 0000000000000..ffb1e7c3d5912 --- /dev/null +++ b/site/components/FullScreenForm/index.ts @@ -0,0 +1 @@ +export { Title } from "./Title" \ No newline at end of file diff --git a/site/pages/workspaces/create/index.tsx b/site/pages/workspaces/create/index.tsx index a1d524acb5e3f..d383230d127f5 100644 --- a/site/pages/workspaces/create/index.tsx +++ b/site/pages/workspaces/create/index.tsx @@ -1,5 +1,8 @@ +import Typography from "@material-ui/core/Typography" import React from "react" +import { Title } from "../../../components/FullScreenForm" + const CreateSelectProjectPage: React.FC = () => { const createWorkspace = () => { @@ -7,12 +10,12 @@ const CreateSelectProjectPage: React.FC = () => { } const button = { - children: "New Workspace", + children: "Next", onClick: createWorkspace, } return ( -
Create Page
+ ) } From cc411d0608fa403371307dea42601181d74951dc Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Wed, 19 Jan 2022 15:40:34 +0000 Subject: [PATCH 09/41] Flip to light theme --- site/components/Navbar/index.tsx | 4 +--- site/pages/_app.tsx | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/site/components/Navbar/index.tsx b/site/components/Navbar/index.tsx index 3e3ae6bf8428b..73ba6f630b83f 100644 --- a/site/components/Navbar/index.tsx +++ b/site/components/Navbar/index.tsx @@ -17,9 +17,7 @@ export const Navbar: React.FC = () => { </Button> </Link> </div> - <div className={styles.fullWidth}> - <div className={styles.title}>Coder v2</div> - </div> + <div className={styles.fullWidth} /> <div className={styles.fixed}> <List> <ListSubheader>Manage</ListSubheader> diff --git a/site/pages/_app.tsx b/site/pages/_app.tsx index 65662289655c6..092cb7ae88a82 100644 --- a/site/pages/_app.tsx +++ b/site/pages/_app.tsx @@ -3,7 +3,7 @@ import React from "react" import CssBaseline from "@material-ui/core/CssBaseline" import ThemeProvider from "@material-ui/styles/ThemeProvider" -import { dark } from "../theme" +import { light } from "../theme" import { AppProps } from "next/app" import { makeStyles } from "@material-ui/core" import { Navbar } from "../components/Navbar" @@ -56,7 +56,7 @@ const SafeHydrate: React.FC = ({ children }) => ( const MyApp: React.FC<AppProps> = (appProps) => { return ( <SafeHydrate> - <ThemeProvider theme={dark}> + <ThemeProvider theme={light}> <CssBaseline /> <Contents {...appProps} /> </ThemeProvider> From 907a523275af85c2ab5257180a20cb2bd5f37d19 Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Wed, 19 Jan 2022 17:14:52 +0000 Subject: [PATCH 10/41] Start pulling out AppPage/FormPage --- .../{FullScreenForm => Form}/Title.tsx | 17 ++--- site/components/Form/index.ts | 1 + site/components/FullScreenForm/index.ts | 1 - site/components/Page/AppPage.tsx | 51 ++++++++++++++ site/components/Page/FormPage.tsx | 69 +++++++++++++++++++ site/components/Page/RedirectPage.tsx | 16 +++++ site/components/Page/index.tsx | 3 + site/pages/_app.tsx | 54 +-------------- site/pages/index.tsx | 61 ++-------------- site/pages/workspaces/create/[projectId].tsx | 7 +- site/pages/workspaces/create/index.tsx | 35 +++++++--- site/pages/workspaces/index.tsx | 64 +++++++++++++++++ 12 files changed, 247 insertions(+), 132 deletions(-) rename site/components/{FullScreenForm => Form}/Title.tsx (71%) create mode 100644 site/components/Form/index.ts delete mode 100644 site/components/FullScreenForm/index.ts create mode 100644 site/components/Page/AppPage.tsx create mode 100644 site/components/Page/FormPage.tsx create mode 100644 site/components/Page/RedirectPage.tsx create mode 100644 site/pages/workspaces/index.tsx diff --git a/site/components/FullScreenForm/Title.tsx b/site/components/Form/Title.tsx similarity index 71% rename from site/components/FullScreenForm/Title.tsx rename to site/components/Form/Title.tsx index e713875be00c0..8a5113f0ed4b2 100644 --- a/site/components/FullScreenForm/Title.tsx +++ b/site/components/Form/Title.tsx @@ -23,13 +23,14 @@ const useStyles = makeStyles((theme) => ({ })) export const Title: React.FC<TitleProps> = ({ title, organization }) => { - const styles = useStyles() - return <div className={styles.title} > - <Typography variant="h3">{title}</Typography> - <Typography variant="caption"> - In <strong>{organization}</strong> organization - </Typography> - </div> -} \ No newline at end of file + return ( + <div className={styles.title}> + <Typography variant="h3">{title}</Typography> + <Typography variant="caption"> + In <strong>{organization}</strong> organization + </Typography> + </div> + ) +} diff --git a/site/components/Form/index.ts b/site/components/Form/index.ts new file mode 100644 index 0000000000000..331a3002fb1fc --- /dev/null +++ b/site/components/Form/index.ts @@ -0,0 +1 @@ +export { Title } from "./Title" diff --git a/site/components/FullScreenForm/index.ts b/site/components/FullScreenForm/index.ts deleted file mode 100644 index ffb1e7c3d5912..0000000000000 --- a/site/components/FullScreenForm/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Title } from "./Title" \ No newline at end of file diff --git a/site/components/Page/AppPage.tsx b/site/components/Page/AppPage.tsx new file mode 100644 index 0000000000000..5002b4e6e4262 --- /dev/null +++ b/site/components/Page/AppPage.tsx @@ -0,0 +1,51 @@ +import React from "react" + +import { makeStyles } from "@material-ui/core" +import { Navbar } from "../Navbar" +import { Footer } from "./Footer" + +const useStyles = makeStyles((theme) => ({ + root: { + display: "flex", + flexDirection: "column", + }, + header: { + flex: 0, + }, + body: { + height: "100%", + flex: 1, + }, + footer: { + flex: 0, + }, +})) + +/** + * `AppPage` is a main application page - containing the following common elements: + * - A navbar, with organization dropdown and users + * - A footer + */ +export const AppPage: React.FC = ({ children }) => { + const styles = useStyles() + + const header = ( + <div className={styles.header}> + <Navbar /> + </div> + ) + + const footer = ( + <div className={styles.footer}> + <Footer /> + </div> + ) + + return ( + <div className={styles.root}> + {header} + {children} + {footer} + </div> + ) +} diff --git a/site/components/Page/FormPage.tsx b/site/components/Page/FormPage.tsx new file mode 100644 index 0000000000000..93c886258743d --- /dev/null +++ b/site/components/Page/FormPage.tsx @@ -0,0 +1,69 @@ +import Button from "@material-ui/core/Button" +import Typography from "@material-ui/core/Typography" +import { makeStyles } from "@material-ui/core/styles" +import { ButtonProps } from "@material-ui/core/Button" +import React from "react" + +import { Title } from "./../Form" + +const useStyles = makeStyles(() => ({ + form: { + display: "flex", + flexDirection: "column", + flex: "1 1 auto", + }, + header: { + flex: "0", + marginTop: "1em", + }, + body: { + flex: "1", + overflowY: "auto", + }, + footer: { + display: "flex", + flex: "0", + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + }, + button: { + margin: "1em", + }, +})) + +export interface FormButton { + props: ButtonProps + title: string +} + +export interface FormPageProps { + title: string + organization: string + buttons?: FormButton[] +} + +export const FormPage: React.FC<FormPageProps> = ({ title, organization, children, buttons }) => { + const styles = useStyles() + + const actualButtons = buttons || [] + + return ( + <div className={styles.form}> + <div className={styles.header}> + <Title title={title} organization={organization} /> + </div> + <div className={styles.body}>{children}</div> + <div className={styles.footer}> + {actualButtons.map(({ props, title }) => { + return ( + <Button {...props} className={styles.button}> + {" "} + {title} + </Button> + ) + })} + </div> + </div> + ) +} diff --git a/site/components/Page/RedirectPage.tsx b/site/components/Page/RedirectPage.tsx new file mode 100644 index 0000000000000..56632f1122cab --- /dev/null +++ b/site/components/Page/RedirectPage.tsx @@ -0,0 +1,16 @@ +import React, { useEffect } from "react" +import { useRouter } from "next/router" + +export interface RedirectPageProps { + path: string +} + +export const RedirectPage: React.FC<RedirectPageProps> = ({ path }) => { + const router = useRouter() + + useEffect(() => { + router.push(path) + }) + + return null +} diff --git a/site/components/Page/index.tsx b/site/components/Page/index.tsx index a29a2e5d3927b..6dc33d1ebc31c 100644 --- a/site/components/Page/index.tsx +++ b/site/components/Page/index.tsx @@ -1 +1,4 @@ export * from "./Footer" +export * from "./AppPage" +export * from "./FormPage" +export * from "./RedirectPage" diff --git a/site/pages/_app.tsx b/site/pages/_app.tsx index 092cb7ae88a82..e237a577a2f91 100644 --- a/site/pages/_app.tsx +++ b/site/pages/_app.tsx @@ -6,39 +6,6 @@ import ThemeProvider from "@material-ui/styles/ThemeProvider" import { light } from "../theme" import { AppProps } from "next/app" import { makeStyles } from "@material-ui/core" -import { Navbar } from "../components/Navbar" -import { Footer } from "../components/Page" - -/** - * `Contents` is the wrapper around the core app UI, - * containing common UI elements like the footer and navbar. - * - * This can't be inlined in `MyApp` because it requires styling, - * and `useStyles` needs to be inside a `<ThemeProvider />` - */ -const Contents: React.FC<AppProps> = ({ Component, pageProps }) => { - const styles = useStyles() - - const header = ( - <div className={styles.header}> - <Navbar /> - </div> - ) - - const footer = ( - <div className={styles.footer}> - <Footer /> - </div> - ) - - return ( - <div className={styles.root}> - {header} - <Component {...pageProps} /> - {footer} - </div> - ) -} /** * SafeHydrate is a component that only allows its children to be rendered @@ -53,32 +20,15 @@ const SafeHydrate: React.FC = ({ children }) => ( * <App /> is the root rendering logic of the application - setting up our router * and any contexts / global state management. */ -const MyApp: React.FC<AppProps> = (appProps) => { +const MyApp: React.FC<AppProps> = ({ Component, pageProps }) => { return ( <SafeHydrate> <ThemeProvider theme={light}> <CssBaseline /> - <Contents {...appProps} /> + <Component {...pageProps} /> </ThemeProvider> </SafeHydrate> ) } -const useStyles = makeStyles((theme) => ({ - root: { - display: "flex", - flexDirection: "column", - }, - header: { - flex: 0, - }, - body: { - height: "100%", - flex: 1, - }, - footer: { - flex: 0, - }, -})) - export default MyApp diff --git a/site/pages/index.tsx b/site/pages/index.tsx index 3654e69b52d90..7e60c168315ac 100644 --- a/site/pages/index.tsx +++ b/site/pages/index.tsx @@ -1,61 +1,8 @@ import React from "react" -import { makeStyles, Box, Paper } from "@material-ui/core" -import { AddToQueue as AddWorkspaceIcon } from "@material-ui/icons" +import { RedirectPage } from "./../components/Page" -import { EmptyState, SplitButton } from "../components" - -const WorkspacesPage: React.FC = () => { - const styles = useStyles() - - const createWorkspace = () => { - alert("create") - } - - const button = { - children: "New Workspace", - onClick: createWorkspace, - } - - return ( - <> - <div className={styles.header}> - <SplitButton<string> - color="primary" - onClick={createWorkspace} - options={[ - { - label: "New workspace", - value: "custom", - }, - { - label: "New workspace from template", - value: "template", - }, - ]} - startIcon={<AddWorkspaceIcon />} - textTransform="none" - /> - </div> - - <Paper style={{ maxWidth: "1380px", margin: "1em auto", width: "100%" }}> - <Box pt={4} pb={4}> - <EmptyState message="No workspaces available." button={button} /> - </Box> - </Paper> - </> - ) +export const IndexPage: React.FC = () => { + return <RedirectPage path="/workspaces" /> } -const useStyles = makeStyles((theme) => ({ - header: { - display: "flex", - flexDirection: "row-reverse", - justifyContent: "space-between", - margin: "1em auto", - maxWidth: "1380px", - padding: theme.spacing(2, 6.25, 0), - width: "100%", - }, -})) - -export default WorkspacesPage +export default IndexPage diff --git a/site/pages/workspaces/create/[projectId].tsx b/site/pages/workspaces/create/[projectId].tsx index ff9b13800f730..0c9a2ad1bea62 100644 --- a/site/pages/workspaces/create/[projectId].tsx +++ b/site/pages/workspaces/create/[projectId].tsx @@ -2,7 +2,6 @@ import React from "react" import { useRouter } from "next/router" const CreateProjectPage: React.FC = () => { - const router = useRouter() const { projectId } = router.query @@ -15,9 +14,7 @@ const CreateProjectPage: React.FC = () => { onClick: createWorkspace, } - return ( - <div>Create Page: {projectId}</div> - ) + return <div>Create Page: {projectId}</div> } -export default CreateProjectPage \ No newline at end of file +export default CreateProjectPage diff --git a/site/pages/workspaces/create/index.tsx b/site/pages/workspaces/create/index.tsx index d383230d127f5..739b20bd07ba5 100644 --- a/site/pages/workspaces/create/index.tsx +++ b/site/pages/workspaces/create/index.tsx @@ -1,22 +1,39 @@ -import Typography from "@material-ui/core/Typography" import React from "react" -import { Title } from "../../../components/FullScreenForm" +import { useRouter } from "next/router" +import { FormPage, FormButton } from "../../../components/Page" const CreateSelectProjectPage: React.FC = () => { + const router = useRouter() const createWorkspace = () => { alert("create") } - const button = { - children: "Next", - onClick: createWorkspace, + const cancel = () => { + router.back() } - return ( - <Title title={"Select Project"} organization={"test-org"} /> - ) + const buttons: FormButton[] = [ + { + title: "Cancel", + props: { + variant: "outlined", + onClick: cancel, + }, + }, + { + title: "Next", + props: { + variant: "contained", + color: "primary", + disabled: false, + type: "submit", + }, + }, + ] + + return <FormPage title={"Select Project"} organization={"test-org"} buttons={buttons}></FormPage> } -export default CreateSelectProjectPage \ No newline at end of file +export default CreateSelectProjectPage diff --git a/site/pages/workspaces/index.tsx b/site/pages/workspaces/index.tsx new file mode 100644 index 0000000000000..759ae72df3f22 --- /dev/null +++ b/site/pages/workspaces/index.tsx @@ -0,0 +1,64 @@ +import React from "react" +import { useRouter } from "next/router" +import { makeStyles, Box, Paper } from "@material-ui/core" +import { AddToQueue as AddWorkspaceIcon } from "@material-ui/icons" + +import { EmptyState, SplitButton } from "../../components" +import { AppPage } from "../../components/Page" + +const WorkspacesPage: React.FC = () => { + const styles = useStyles() + const router = useRouter() + + const createWorkspace = () => { + router.push("/workspaces/create") + } + + const button = { + children: "New Workspace", + onClick: createWorkspace, + } + + return ( + <AppPage> + <div className={styles.header}> + <SplitButton<string> + color="primary" + onClick={createWorkspace} + options={[ + { + label: "New workspace", + value: "custom", + }, + { + label: "New workspace from template", + value: "template", + }, + ]} + startIcon={<AddWorkspaceIcon />} + textTransform="none" + /> + </div> + + <Paper style={{ maxWidth: "1380px", margin: "1em auto", width: "100%" }}> + <Box pt={4} pb={4}> + <EmptyState message="No workspaces available." button={button} /> + </Box> + </Paper> + </AppPage> + ) +} + +const useStyles = makeStyles((theme) => ({ + header: { + display: "flex", + flexDirection: "row-reverse", + justifyContent: "space-between", + margin: "1em auto", + maxWidth: "1380px", + padding: theme.spacing(2, 6.25, 0), + width: "100%", + }, +})) + +export default WorkspacesPage From 56f7515edf41bb1387623127c33bbdb9cc17eb6e Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Wed, 19 Jan 2022 17:19:03 +0000 Subject: [PATCH 11/41] Start stubbing out API --- site/api.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 site/api.ts diff --git a/site/api.ts b/site/api.ts new file mode 100644 index 0000000000000..20dc8582fbc38 --- /dev/null +++ b/site/api.ts @@ -0,0 +1,23 @@ +export interface Project { + id: string + name: string + description: string +} + +export namespace Project { + export const get = async (org: string): Promise<Project[]> => { + const project1: Project = { + id: "test-terraform-1", + name: "Terraform Project 1", + description: "Simple terraform project that deploys a kubernetes provider", + } + + const project2: Project = { + id: "test-echo-1", + name: "Echo Project", + description: "Project built on echo provisioner", + } + + return Promise.resolve([project1, project2]) + } +} From 0a5c48a9083bea816a05320a6be4b16b9d1a8f05 Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Wed, 19 Jan 2022 18:37:58 +0000 Subject: [PATCH 12/41] Start scaffolding project selection --- site/api.ts | 5 ++- site/components/Navbar/index.tsx | 6 +-- site/components/Page/FormPage.tsx | 2 +- site/components/Project/ProjectIcon.tsx | 29 +++++++++++++ site/hooks/useRequest.ts | 32 +++++++++++++++ site/pages/workspaces/create/[projectId].tsx | 33 ++++++++++++--- site/pages/workspaces/create/index.tsx | 43 +++++++++++++++++--- 7 files changed, 132 insertions(+), 18 deletions(-) create mode 100644 site/components/Project/ProjectIcon.tsx create mode 100644 site/hooks/useRequest.ts diff --git a/site/api.ts b/site/api.ts index 20dc8582fbc38..920881df7d21e 100644 --- a/site/api.ts +++ b/site/api.ts @@ -1,19 +1,22 @@ export interface Project { id: string + icon: string name: string description: string } export namespace Project { - export const get = async (org: string): Promise<Project[]> => { + export const get = (org: string): Promise<Project[]> => { const project1: Project = { id: "test-terraform-1", + icon: "https://www.datocms-assets.com/2885/1620155117-brandhcterraformverticalcolorwhite.svg", name: "Terraform Project 1", description: "Simple terraform project that deploys a kubernetes provider", } const project2: Project = { id: "test-echo-1", + icon: "https://www.datocms-assets.com/2885/1620155117-brandhcterraformverticalcolorwhite.svg", name: "Echo Project", description: "Project built on echo provisioner", } diff --git a/site/components/Navbar/index.tsx b/site/components/Navbar/index.tsx index 73ba6f630b83f..cb32d4062815c 100644 --- a/site/components/Navbar/index.tsx +++ b/site/components/Navbar/index.tsx @@ -18,11 +18,7 @@ export const Navbar: React.FC = () => { </Link> </div> <div className={styles.fullWidth} /> - <div className={styles.fixed}> - <List> - <ListSubheader>Manage</ListSubheader> - </List> - </div> + <div className={styles.fixed} /> </div> ) } diff --git a/site/components/Page/FormPage.tsx b/site/components/Page/FormPage.tsx index 93c886258743d..96b5b3e1e94ff 100644 --- a/site/components/Page/FormPage.tsx +++ b/site/components/Page/FormPage.tsx @@ -17,6 +17,7 @@ const useStyles = makeStyles(() => ({ marginTop: "1em", }, body: { + padding: "2em", flex: "1", overflowY: "auto", }, @@ -58,7 +59,6 @@ export const FormPage: React.FC<FormPageProps> = ({ title, organization, childre {actualButtons.map(({ props, title }) => { return ( <Button {...props} className={styles.button}> - {" "} {title} </Button> ) diff --git a/site/components/Project/ProjectIcon.tsx b/site/components/Project/ProjectIcon.tsx new file mode 100644 index 0000000000000..c065d9fbb715e --- /dev/null +++ b/site/components/Project/ProjectIcon.tsx @@ -0,0 +1,29 @@ +import React from "react" +import { Box, Typography } from "@material-ui/core" + +export interface ProjectIconProps { + title: string + icon: string + description?: string + onClick: () => void +} + +export const ProjectIcon: React.FC<ProjectIconProps> = ({ title, icon, onClick }) => { + return ( + <Box + css={{ + flex: "0", + margin: "1em", + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + border: "1px solid red", + }} + onClick={onClick} + > + <img src={icon} width={"128px"} height={"128px"} /> + <Typography>{title}</Typography> + </Box> + ) +} diff --git a/site/hooks/useRequest.ts b/site/hooks/useRequest.ts new file mode 100644 index 0000000000000..9dc69f905166e --- /dev/null +++ b/site/hooks/useRequest.ts @@ -0,0 +1,32 @@ +import { useState, useEffect } from "react" + +export type RequestState<TPayload> = + | { + state: "loading" + } + | { + state: "error" + error: Error + } + | { + state: "success" + payload: TPayload + } + +export const useRequestor = <TPayload>(fn: () => Promise<TPayload>) => { + const [requestState, setRequestState] = useState<RequestState<TPayload>>({ state: "loading" }) + + useEffect(() => { + const f = async () => { + try { + const response = await fn() + setRequestState({ state: "success", payload: response }) + } catch (err) { + setRequestState({ state: "error", error: err }) + } + } + f() + }) + + return requestState +} diff --git a/site/pages/workspaces/create/[projectId].tsx b/site/pages/workspaces/create/[projectId].tsx index 0c9a2ad1bea62..f56d96c070ed4 100644 --- a/site/pages/workspaces/create/[projectId].tsx +++ b/site/pages/workspaces/create/[projectId].tsx @@ -1,20 +1,41 @@ import React from "react" import { useRouter } from "next/router" +import { FormPage, FormButton } from "../../../components/Page" + const CreateProjectPage: React.FC = () => { const router = useRouter() const { projectId } = router.query - const createWorkspace = () => { - alert("create") + const cancel = () => { + router.back() } - const button = { - children: "New Workspace", - onClick: createWorkspace, + const submit = () => { + alert("Submitting workspace") } - return <div>Create Page: {projectId}</div> + const buttons: FormButton[] = [ + { + title: "Cancel", + props: { + variant: "outlined", + onClick: cancel, + }, + }, + { + title: "Submit", + props: { + variant: "contained", + color: "primary", + disabled: false, + type: "submit", + onClick: submit, + }, + }, + ] + + return <FormPage title={"Create Project"} organization={"test-org"} buttons={buttons}></FormPage> } export default CreateProjectPage diff --git a/site/pages/workspaces/create/index.tsx b/site/pages/workspaces/create/index.tsx index 739b20bd07ba5..0b079d4393f84 100644 --- a/site/pages/workspaces/create/index.tsx +++ b/site/pages/workspaces/create/index.tsx @@ -2,18 +2,44 @@ import React from "react" import { useRouter } from "next/router" import { FormPage, FormButton } from "../../../components/Page" +import { useRequestor } from "../../../hooks/useRequest" +import * as Api from "./../../../api" +import CircularProgress from "@material-ui/core/CircularProgress" +import { ProjectIcon } from "../../../components/Project/ProjectIcon" +import Box from "@material-ui/core/Box" const CreateSelectProjectPage: React.FC = () => { const router = useRouter() - - const createWorkspace = () => { - alert("create") - } + const requestState = useRequestor(() => Api.Project.get("test-org")) const cancel = () => { router.back() } + const next = (projectId: string) => () => { + router.push(`/workspaces/create/${projectId}`) + } + + let body + + switch (requestState.state) { + case "loading": + body = <CircularProgress /> + break + case "error": + body = <>{requestState.error.toString()}</> + break + case "success": + body = ( + <> + {requestState.payload.map((project) => { + return <ProjectIcon title={project.name} icon={project.icon} onClick={() => alert("clicked")} /> + })} + </> + ) + break + } + const buttons: FormButton[] = [ { title: "Cancel", @@ -29,11 +55,18 @@ const CreateSelectProjectPage: React.FC = () => { color: "primary", disabled: false, type: "submit", + onClick: next("test1"), }, }, ] - return <FormPage title={"Select Project"} organization={"test-org"} buttons={buttons}></FormPage> + return ( + <FormPage title={"Select Project"} organization={"test-org"} buttons={buttons}> + <Box style={{ display: "flex", flexDirection: "row", justifyContent: "center", alignItems: "center" }}> + {body} + </Box> + </FormPage> + ) } export default CreateSelectProjectPage From aea58ae2207e19ad58260ad8bb2afe769831e938 Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Wed, 19 Jan 2022 19:08:36 +0000 Subject: [PATCH 13/41] Add placeholder icon if none available --- site/api.ts | 6 +- site/components/Project/ProjectIcon.tsx | 86 ++++++++++++++++++-- site/pages/workspaces/create/[projectId].tsx | 6 +- site/pages/workspaces/create/index.tsx | 14 +--- 4 files changed, 91 insertions(+), 21 deletions(-) diff --git a/site/api.ts b/site/api.ts index 920881df7d21e..69b5342e30f1d 100644 --- a/site/api.ts +++ b/site/api.ts @@ -1,6 +1,9 @@ +import { SvgIcon } from "@material-ui/core" +import { Logo } from "./components/Icons" + export interface Project { id: string - icon: string + icon?: string name: string description: string } @@ -16,7 +19,6 @@ export namespace Project { const project2: Project = { id: "test-echo-1", - icon: "https://www.datocms-assets.com/2885/1620155117-brandhcterraformverticalcolorwhite.svg", name: "Echo Project", description: "Project built on echo provisioner", } diff --git a/site/components/Project/ProjectIcon.tsx b/site/components/Project/ProjectIcon.tsx index c065d9fbb715e..3733969d09c4f 100644 --- a/site/components/Project/ProjectIcon.tsx +++ b/site/components/Project/ProjectIcon.tsx @@ -1,14 +1,86 @@ import React from "react" -import { Box, Typography } from "@material-ui/core" +import { Box, makeStyles, SvgIcon, Typography } from "@material-ui/core" export interface ProjectIconProps { title: string - icon: string + icon?: string description?: string onClick: () => void } -export const ProjectIcon: React.FC<ProjectIconProps> = ({ title, icon, onClick }) => { +const useStyles = makeStyles((theme) => ({ + container: { + boxShadow: theme.shadows[1], + cursor: "pointer", + transition: "box-shadow 250ms ease-in-out", + backgroundColor: "lightgrey", + "&:hover": { + boxShadow: theme.shadows[8], + }, + }, +})) + +const Circle: React.FC = () => { + return ( + <Box + css={{ + width: "96px", + height: "96px", + borderRadius: "96px", + border: "48px solid white", + }} + /> + ) +} + +const useStyles2 = makeStyles((theme) => ({ + root: { + color: theme.palette.text.secondary, + display: "-webkit-box", // See (1) + marginTop: theme.spacing(0.5), + maxWidth: "110%", + minWidth: 0, + overflow: "hidden", // See (1) + textAlign: "center", + textOverflow: "ellipsis", // See (1) + whiteSpace: "normal", // See (1) + + // (1) - These styles, along with clamping make it so that not only + // can text not overflow horizontally, but there can also only be a + // maximum of 2 line breaks. This is standard behaviour on OS files + // (ex: Windows 10 Desktop application) to prevent excessive vertical + // line wraps. This is important in Generic Applications, as we have no + // control over the application name used in the manifest. + ["-webkit-line-clamp"]: 2, + ["-webkit-box-orient"]: "vertical", + }, +})) + +export const ProjectName: React.FC = ({ children }) => { + const styles = useStyles2() + + return ( + <Typography className={styles.root} noWrap variant="body2"> + {children} + </Typography> + ) +} + +export const ProjectIcon: React.FC<ProjectIconProps> = ({ icon, title, onClick }) => { + const styles = useStyles() + + let iconComponent + + if (typeof icon !== "undefined") { + iconComponent = <img src={icon} width={"128px"} height={"128px"} /> + } else { + iconComponent = ( + <Box width={"128px"} height={"128px"} style={{ display: "flex", justifyContent: "center", alignItems: "center" }}> + <Circle /> + </Box> + ) + } + return ( <Box css={{ @@ -18,12 +90,14 @@ export const ProjectIcon: React.FC<ProjectIconProps> = ({ title, icon, onClick } flexDirection: "column", justifyContent: "center", alignItems: "center", - border: "1px solid red", + border: "1px solid black", + borderRadius: "4px", }} + className={styles.container} onClick={onClick} > - <img src={icon} width={"128px"} height={"128px"} /> - <Typography>{title}</Typography> + {iconComponent} + <ProjectName>{title}</ProjectName> </Box> ) } diff --git a/site/pages/workspaces/create/[projectId].tsx b/site/pages/workspaces/create/[projectId].tsx index f56d96c070ed4..ddf88f9ed375a 100644 --- a/site/pages/workspaces/create/[projectId].tsx +++ b/site/pages/workspaces/create/[projectId].tsx @@ -35,7 +35,11 @@ const CreateProjectPage: React.FC = () => { }, ] - return <FormPage title={"Create Project"} organization={"test-org"} buttons={buttons}></FormPage> + return ( + <FormPage title={"Create Project"} organization={"test-org"} buttons={buttons}> + <div>TODO: Dynamic form fields</div> + </FormPage> + ) } export default CreateProjectPage diff --git a/site/pages/workspaces/create/index.tsx b/site/pages/workspaces/create/index.tsx index 0b079d4393f84..6a43606dc77ae 100644 --- a/site/pages/workspaces/create/index.tsx +++ b/site/pages/workspaces/create/index.tsx @@ -16,7 +16,7 @@ const CreateSelectProjectPage: React.FC = () => { router.back() } - const next = (projectId: string) => () => { + const select = (projectId: string) => () => { router.push(`/workspaces/create/${projectId}`) } @@ -33,7 +33,7 @@ const CreateSelectProjectPage: React.FC = () => { body = ( <> {requestState.payload.map((project) => { - return <ProjectIcon title={project.name} icon={project.icon} onClick={() => alert("clicked")} /> + return <ProjectIcon title={project.name} icon={project.icon} onClick={select(project.id)} /> })} </> ) @@ -48,16 +48,6 @@ const CreateSelectProjectPage: React.FC = () => { onClick: cancel, }, }, - { - title: "Next", - props: { - variant: "contained", - color: "primary", - disabled: false, - type: "submit", - onClick: next("test1"), - }, - }, ] return ( From be4c90f1f2d740aee304f5c32904717243463175 Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Wed, 19 Jan 2022 21:33:19 +0000 Subject: [PATCH 14/41] Revert sum changes --- go.sum | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/go.sum b/go.sum index d5d796a9beac3..65f858c429c8f 100644 --- a/go.sum +++ b/go.sum @@ -151,8 +151,6 @@ github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= -github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ= -github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/aws/aws-sdk-go v1.15.11/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0= github.com/aws/aws-sdk-go v1.15.78/go.mod h1:E3/ieXAlvM0XWO57iftYVDLLvQ824smPP3ATZkfNZeM= github.com/aws/aws-sdk-go v1.17.7 h1:/4+rDPe0W95KBmNGYCG+NUvdL8ssPYBMxL+aSCg6nIA= @@ -470,13 +468,6 @@ github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL9 github.com/go-openapi/spec v0.19.3/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo= github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= -github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU= -github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= -github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho= -github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= -github.com/go-playground/validator/v10 v10.10.0 h1:I7mrTYv78z8k8VXa/qJlOlEXn/nBh+BF8dHX5nt/dr0= -github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= @@ -813,8 +804,6 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= @@ -829,8 +818,6 @@ github.com/kylecarbs/terraform-config-inspect v0.0.0-20211215004401-bbc517866b88 github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= -github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/lib/pq v0.0.0-20180327071824-d34b9ff171c2/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= @@ -1033,7 +1020,6 @@ github.com/pion/webrtc/v3 v3.1.13 h1:2XxgGstOqt03ba8QD5+m9S8DCA3Ez53mULT4If8onOg github.com/pion/webrtc/v3 v3.1.13/go.mod h1:RACpyE1EDYlzonfbdPvXkIGDaqD8+NsHqZJN0yEbRbA= github.com/pkg/browser v0.0.0-20210706143420-7d21f8c997e2/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -1083,8 +1069,6 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= @@ -1282,7 +1266,6 @@ golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= @@ -1907,8 +1890,6 @@ k8s.io/klog/v2 v2.4.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= k8s.io/kube-openapi v0.0.0-20201113171705-d219536bb9fd/go.mod h1:WOJ3KddDSol4tAGcJo0Tvi+dK12EcqSLqcWsryKMpfM= k8s.io/kubernetes v1.13.0/go.mod h1:ocZa8+6APFNC2tX1DZASIbocyYT5jHzqFVsY5aoB7Jk= k8s.io/utils v0.0.0-20201110183641-67b214c5f920/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= -k8s.io/utils v0.0.0-20211208161948-7d6a63dca704 h1:ZKMMxTvduyf5WUtREOqg5LiXaN1KO/+0oOQPRFrClpo= -k8s.io/utils v0.0.0-20211208161948-7d6a63dca704/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= modernc.org/b v1.0.0/go.mod h1:uZWcZfRj1BpYzfN9JTerzlNUnnPsV9O2ZA8JsRcubNg= modernc.org/cc/v3 v3.32.4/go.mod h1:0R6jl1aZlIl2avnYfbfHBS1QB6/f+16mihBObaBC878= modernc.org/ccgo/v3 v3.9.2/go.mod h1:gnJpy6NIVqkETT+L5zPsQFj7L2kkhfPMzOghRNv/CFo= From 308de2b7049afa3745161c8df1f08513cbe0ef98 Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Wed, 19 Jan 2022 21:40:40 +0000 Subject: [PATCH 15/41] Initial create form --- coderd/api.go | 17 ---- coderd/projects.go | 94 ------------------- coderd/workspaces.go | 48 ---------- site/api.ts | 2 +- .../{Page => PageTemplates}/AppPage.tsx | 0 .../{Page => PageTemplates}/Footer.test.tsx | 0 .../{Page => PageTemplates}/Footer.tsx | 0 .../{Page => PageTemplates}/FormPage.tsx | 6 +- .../{Page => PageTemplates}/RedirectPage.tsx | 0 .../{Page => PageTemplates}/index.tsx | 0 site/components/index.tsx | 2 +- site/pages/index.tsx | 2 +- site/pages/workspaces/create/[projectId].tsx | 2 +- site/pages/workspaces/create/index.tsx | 2 +- site/pages/workspaces/index.tsx | 2 +- 15 files changed, 11 insertions(+), 166 deletions(-) delete mode 100644 coderd/api.go delete mode 100644 coderd/projects.go delete mode 100644 coderd/workspaces.go rename site/components/{Page => PageTemplates}/AppPage.tsx (100%) rename site/components/{Page => PageTemplates}/Footer.test.tsx (100%) rename site/components/{Page => PageTemplates}/Footer.tsx (100%) rename site/components/{Page => PageTemplates}/FormPage.tsx (91%) rename site/components/{Page => PageTemplates}/RedirectPage.tsx (100%) rename site/components/{Page => PageTemplates}/index.tsx (100%) diff --git a/coderd/api.go b/coderd/api.go deleted file mode 100644 index 1fe4d7140d8e2..0000000000000 --- a/coderd/api.go +++ /dev/null @@ -1,17 +0,0 @@ -package coderd - -// API offers an HTTP API. Routes are located in routes.go. -type API struct { - // Services. - projectService *projectService - workspaceService *workspaceService -} - -// New returns an instantiated API. -func NewAPI() *API { - api := &API{ - projectService: newProjectService(), - workspaceService: newWorkspaceService(), - } - return api -} diff --git a/coderd/projects.go b/coderd/projects.go deleted file mode 100644 index 81230b42eed3d..0000000000000 --- a/coderd/projects.go +++ /dev/null @@ -1,94 +0,0 @@ -package coderd - -import ( - "net/http" - - "github.com/coder/coder/xjson" -) - -type ProjectParameter struct { - Id string `json:"id" validate:"required"` - Name string `json:"name" validate:"required"` - Description string `json:"description"` - - // Validation Parameters - ValueType string `json:"validation_value_type"` -} - -// Project is a Go representation of the workspaces v2 project, -// defined here: https://www.notion.so/coderhq/Workspaces-v2-e908a8cd54804ddd910367abf03c8d0a#befa328add894231979e6cf8a378d2ec -type Project struct { - Id string `json:"id" validate:"required"` - Name string `json:"name" validate:"required"` - Description string `json:"description" validate:"required"` - ProvisionerType string `json:"provisioner_type" validate:"required"` - - Parameters []ProjectParameter `json:"parameters" validate:"required"` -} - -// Placeholder type of projectService -type projectService struct { -} - -func newProjectService() *projectService { - projectService := &projectService{} - return projectService -} - -func (ps *projectService) getProjects(w http.ResponseWriter, r *http.Request) { - // Construct a couple hard-coded projects to return the UI - terraformProject := Project{ - Id: "test_terraform_project_id", - Name: "Terraform", - Description: "Kubernetes on Terraform", - Parameters: []ProjectParameter{ - { - Id: "parameter_cluster_namespace", - Name: "Namespace", - Description: "Kubernetes namespace to host workspace pod", - ValueType: "string", - }, - { - Id: "parameter_cpu", - Name: "CPU", - Description: "CPU Cores to Allocate", - ValueType: "number", - }, - }, - } - - echoProject := Project{ - Id: "test_echo_project_id", - Name: "Echo Project", - Description: "A simple echo provider", - Parameters: []ProjectParameter{ - { - Id: "parameter_echo_string", - Name: "Echo String", - Description: "String that should be echo'd out in build log", - ValueType: "string", - }, - }, - } - - projects := []Project{ - terraformProject, - echoProject, - } - - xjson.Write(w, http.StatusOK, projects) -} - -func (ps *projectService) getProjectById(w http.ResponseWriter, r *http.Request) { - // TODO: Get a project by id - xjson.Write(w, http.StatusNotFound, nil) -} - -func (ps *projectService) createProject(w http.ResponseWriter, r *http.Request) { - // TODO: Validate arguments - // Organization context - // User - // Parameter values - // Submit to provisioner - xjson.Write(w, http.StatusOK, nil) -} diff --git a/coderd/workspaces.go b/coderd/workspaces.go deleted file mode 100644 index be236ef6b5936..0000000000000 --- a/coderd/workspaces.go +++ /dev/null @@ -1,48 +0,0 @@ -package coderd - -import ( - "net/http" - - "github.com/coder/coder/xjson" -) - -type Workspace struct { - Id string `json:"id" validate:"required"` - Name string `json:"name" validate:"required"` - ProjectId string `json:"project_id" validate:"required"` -} - -// Placeholder type of workspaceService -type workspaceService struct { -} - -func newWorkspaceService() *workspaceService { - workspaceService := &workspaceService{} - return workspaceService -} - -func (ws *workspaceService) getWorkspaces(w http.ResponseWriter, r *http.Request) { - // Dummy workspace to return - workspace := Workspace{ - Id: "test-workspace", - Name: "Test Workspace", - ProjectId: "test-project-id", - } - - workspaces := []Workspace{ - workspace, - } - - xjson.Write(w, http.StatusOK, workspaces) -} - -func (ws *workspaceService) getWorkspaceById(w http.ResponseWriter, r *http.Request) { - // TODO: Read workspace off context - // Dummy workspace to return - workspace := Workspace{ - Id: "test-workspace", - Name: "Test Workspace", - ProjectId: "test-project-id", - } - xjson.Write(w, http.StatusOK, workspace) -} \ No newline at end of file diff --git a/site/api.ts b/site/api.ts index 69b5342e30f1d..b7886293f3c20 100644 --- a/site/api.ts +++ b/site/api.ts @@ -9,7 +9,7 @@ export interface Project { } export namespace Project { - export const get = (org: string): Promise<Project[]> => { + export const get = (_org: string): Promise<Project[]> => { const project1: Project = { id: "test-terraform-1", icon: "https://www.datocms-assets.com/2885/1620155117-brandhcterraformverticalcolorwhite.svg", diff --git a/site/components/Page/AppPage.tsx b/site/components/PageTemplates/AppPage.tsx similarity index 100% rename from site/components/Page/AppPage.tsx rename to site/components/PageTemplates/AppPage.tsx diff --git a/site/components/Page/Footer.test.tsx b/site/components/PageTemplates/Footer.test.tsx similarity index 100% rename from site/components/Page/Footer.test.tsx rename to site/components/PageTemplates/Footer.test.tsx diff --git a/site/components/Page/Footer.tsx b/site/components/PageTemplates/Footer.tsx similarity index 100% rename from site/components/Page/Footer.tsx rename to site/components/PageTemplates/Footer.tsx diff --git a/site/components/Page/FormPage.tsx b/site/components/PageTemplates/FormPage.tsx similarity index 91% rename from site/components/Page/FormPage.tsx rename to site/components/PageTemplates/FormPage.tsx index 96b5b3e1e94ff..a85446514673b 100644 --- a/site/components/Page/FormPage.tsx +++ b/site/components/PageTemplates/FormPage.tsx @@ -4,7 +4,7 @@ import { makeStyles } from "@material-ui/core/styles" import { ButtonProps } from "@material-ui/core/Button" import React from "react" -import { Title } from "./../Form" +import { Title } from "../Form" const useStyles = makeStyles(() => ({ form: { @@ -20,6 +20,10 @@ const useStyles = makeStyles(() => ({ padding: "2em", flex: "1", overflowY: "auto", + display: "flex", + flexDirection: "column", + justifyContent: "flex-start", + alignItems: "center", }, footer: { display: "flex", diff --git a/site/components/Page/RedirectPage.tsx b/site/components/PageTemplates/RedirectPage.tsx similarity index 100% rename from site/components/Page/RedirectPage.tsx rename to site/components/PageTemplates/RedirectPage.tsx diff --git a/site/components/Page/index.tsx b/site/components/PageTemplates/index.tsx similarity index 100% rename from site/components/Page/index.tsx rename to site/components/PageTemplates/index.tsx diff --git a/site/components/index.tsx b/site/components/index.tsx index 5fd2a75122e23..0fd524b316295 100644 --- a/site/components/index.tsx +++ b/site/components/index.tsx @@ -1,3 +1,3 @@ export * from "./Button" export * from "./EmptyState" -export * from "./Page" +export * from "./PageTemplates" diff --git a/site/pages/index.tsx b/site/pages/index.tsx index 7e60c168315ac..94c469197590f 100644 --- a/site/pages/index.tsx +++ b/site/pages/index.tsx @@ -1,5 +1,5 @@ import React from "react" -import { RedirectPage } from "./../components/Page" +import { RedirectPage } from "../components/PageTemplates" export const IndexPage: React.FC = () => { return <RedirectPage path="/workspaces" /> diff --git a/site/pages/workspaces/create/[projectId].tsx b/site/pages/workspaces/create/[projectId].tsx index ddf88f9ed375a..b72f24e7a3bd6 100644 --- a/site/pages/workspaces/create/[projectId].tsx +++ b/site/pages/workspaces/create/[projectId].tsx @@ -1,7 +1,7 @@ import React from "react" import { useRouter } from "next/router" -import { FormPage, FormButton } from "../../../components/Page" +import { FormPage, FormButton } from "../../../components/PageTemplates" const CreateProjectPage: React.FC = () => { const router = useRouter() diff --git a/site/pages/workspaces/create/index.tsx b/site/pages/workspaces/create/index.tsx index 6a43606dc77ae..87d7cd6a89713 100644 --- a/site/pages/workspaces/create/index.tsx +++ b/site/pages/workspaces/create/index.tsx @@ -1,7 +1,7 @@ import React from "react" import { useRouter } from "next/router" -import { FormPage, FormButton } from "../../../components/Page" +import { FormPage, FormButton } from "../../../components/PageTemplates" import { useRequestor } from "../../../hooks/useRequest" import * as Api from "./../../../api" import CircularProgress from "@material-ui/core/CircularProgress" diff --git a/site/pages/workspaces/index.tsx b/site/pages/workspaces/index.tsx index 759ae72df3f22..8559bd6afe0fd 100644 --- a/site/pages/workspaces/index.tsx +++ b/site/pages/workspaces/index.tsx @@ -4,7 +4,7 @@ import { makeStyles, Box, Paper } from "@material-ui/core" import { AddToQueue as AddWorkspaceIcon } from "@material-ui/icons" import { EmptyState, SplitButton } from "../../components" -import { AppPage } from "../../components/Page" +import { AppPage } from "../../components/PageTemplates" const WorkspacesPage: React.FC = () => { const styles = useStyles() From d0b0ef19220c0238803168ab69b387c9463f0852 Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Wed, 19 Jan 2022 21:41:33 +0000 Subject: [PATCH 16/41] Rename project function --- site/api.ts | 2 +- site/pages/workspaces/create/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/site/api.ts b/site/api.ts index b7886293f3c20..7b0491a90d927 100644 --- a/site/api.ts +++ b/site/api.ts @@ -9,7 +9,7 @@ export interface Project { } export namespace Project { - export const get = (_org: string): Promise<Project[]> => { + export const getAllProjectsInOrg = (_org: string): Promise<Project[]> => { const project1: Project = { id: "test-terraform-1", icon: "https://www.datocms-assets.com/2885/1620155117-brandhcterraformverticalcolorwhite.svg", diff --git a/site/pages/workspaces/create/index.tsx b/site/pages/workspaces/create/index.tsx index 87d7cd6a89713..4d33d1c333112 100644 --- a/site/pages/workspaces/create/index.tsx +++ b/site/pages/workspaces/create/index.tsx @@ -10,7 +10,7 @@ import Box from "@material-ui/core/Box" const CreateSelectProjectPage: React.FC = () => { const router = useRouter() - const requestState = useRequestor(() => Api.Project.get("test-org")) + const requestState = useRequestor(() => Api.Project.getAllProjectsInOrg("test-org")) const cancel = () => { router.back() From 797c82b72ac17e617fce421ab895e17913e88f0e Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Wed, 19 Jan 2022 21:43:23 +0000 Subject: [PATCH 17/41] Add formik --- package.json | 1 + yarn.lock | 37 +++++++++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index e93112a385806..a35152707bf50 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@types/react-dom": "17.0.11", "@types/superagent": "4.1.14", "express": "4.17.2", + "formik": "2.2.9", "http-proxy-middleware": "2.0.1", "jest": "27.4.7", "next": "12.0.7", diff --git a/yarn.lock b/yarn.lock index c81a1fda9fd61..96101344e49ff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1775,6 +1775,11 @@ deep-is@~0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== +deepmerge@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-2.2.1.tgz#5d3ff22a01c00f645405a2fbc17d0778a1801170" + integrity sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA== + deepmerge@^4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" @@ -2171,6 +2176,19 @@ form-data@^3.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +formik@2.2.9: + version "2.2.9" + resolved "https://registry.yarnpkg.com/formik/-/formik-2.2.9.tgz#8594ba9c5e2e5cf1f42c5704128e119fc46232d0" + integrity sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA== + dependencies: + deepmerge "^2.1.1" + hoist-non-react-statics "^3.3.0" + lodash "^4.17.21" + lodash-es "^4.17.21" + react-fast-compare "^2.0.1" + tiny-warning "^1.0.2" + tslib "^1.10.0" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -2339,7 +2357,7 @@ hmac-drbg@^1.0.1: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -3300,6 +3318,11 @@ locate-path@^5.0.0: dependencies: p-locate "^4.1.0" +lodash-es@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" + integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== + lodash.memoize@4.x: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" @@ -3310,7 +3333,7 @@ lodash.sortby@^4.7.0: resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= -lodash@^4.7.0: +lodash@^4.17.21, lodash@^4.7.0: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -3925,6 +3948,11 @@ react-dom@17.0.2: object-assign "^4.1.1" scheduler "^0.20.2" +react-fast-compare@^2.0.1: + version "2.0.4" + resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" + integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== + react-is@17.0.2, "react-is@^16.8.0 || ^17.0.0", react-is@^17.0.1: version "17.0.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" @@ -4505,6 +4533,11 @@ ts-node@10.4.0: make-error "^1.1.1" yn "3.1.1" +tslib@^1.10.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" + integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== + tty-browserify@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.1.tgz#3f05251ee17904dfd0677546670db9651682b811" From 5016b9a34632249c25a99dfe0af7f20245a44762 Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Wed, 19 Jan 2022 21:51:23 +0000 Subject: [PATCH 18/41] Stub create workspace API --- site/api.ts | 4 ++++ site/pages/workspaces/create/[projectId].tsx | 18 ++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/site/api.ts b/site/api.ts index 7b0491a90d927..42c25cbd97309 100644 --- a/site/api.ts +++ b/site/api.ts @@ -25,4 +25,8 @@ export namespace Project { return Promise.resolve([project1, project2]) } + + export const createProject = (name: string): Promise<string> => { + return Promise.resolve("test-workspace") + } } diff --git a/site/pages/workspaces/create/[projectId].tsx b/site/pages/workspaces/create/[projectId].tsx index b72f24e7a3bd6..b2f45c40b2959 100644 --- a/site/pages/workspaces/create/[projectId].tsx +++ b/site/pages/workspaces/create/[projectId].tsx @@ -1,5 +1,8 @@ import React from "react" import { useRouter } from "next/router" +import { useFormik } from "formik" + +import * as API from "../../../api" import { FormPage, FormButton } from "../../../components/PageTemplates" @@ -7,12 +10,23 @@ const CreateProjectPage: React.FC = () => { const router = useRouter() const { projectId } = router.query + + const form = useFormik({ + initialValues: { + name: "" + }, + onSubmit: async ({ name }) => { + return API.Project.createProject(name) + }, + }) + const cancel = () => { router.back() } - const submit = () => { - alert("Submitting workspace") + const submit = async () => { + const workspaceId = await form.submitForm() + router.push(`/workspaces/${workspaceId}`) } const buttons: FormButton[] = [ From 8da382bbca2894602880e58c9d7d00f594063c1c Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Thu, 20 Jan 2022 01:51:29 +0000 Subject: [PATCH 19/41] Hook up formik to create form --- site/api.ts | 44 ++++-- site/components/Form/FormSection.tsx | 62 ++++++++ site/components/Form/FormTextField.tsx | 153 +++++++++++++++++++ site/components/Form/PasswordField.tsx | 40 +++++ site/components/Form/Title.tsx | 7 +- site/components/Form/index.ts | 3 +- site/components/Form/types.ts | 16 ++ site/pages/workspaces/create/[projectId].tsx | 42 ++++- site/util/firstOrOnly.ts | 7 + site/util/formik.ts | 125 +++++++++++++++ site/util/index.ts | 1 + 11 files changed, 475 insertions(+), 25 deletions(-) create mode 100644 site/components/Form/FormSection.tsx create mode 100644 site/components/Form/FormTextField.tsx create mode 100644 site/components/Form/PasswordField.tsx create mode 100644 site/components/Form/types.ts create mode 100644 site/util/firstOrOnly.ts create mode 100644 site/util/formik.ts create mode 100644 site/util/index.ts diff --git a/site/api.ts b/site/api.ts index 42c25cbd97309..f75688ffd937e 100644 --- a/site/api.ts +++ b/site/api.ts @@ -9,24 +9,44 @@ export interface Project { } export namespace Project { + const testProject1: Project = { + id: "test-terraform-1", + icon: "https://www.datocms-assets.com/2885/1620155117-brandhcterraformverticalcolorwhite.svg", + name: "Terraform Project 1", + description: "Simple terraform project that deploys a kubernetes provider", + } + + const testProject2: Project = { + id: "test-echo-1", + name: "Echo Project", + description: "Project built on echo provisioner", + } + + const allProjects = [testProject1, testProject2] + export const getAllProjectsInOrg = (_org: string): Promise<Project[]> => { - const project1: Project = { - id: "test-terraform-1", - icon: "https://www.datocms-assets.com/2885/1620155117-brandhcterraformverticalcolorwhite.svg", - name: "Terraform Project 1", - description: "Simple terraform project that deploys a kubernetes provider", - } + return Promise.resolve(allProjects) + } - const project2: Project = { - id: "test-echo-1", - name: "Echo Project", - description: "Project built on echo provisioner", + export const getProject = async (_org: string, projectId: string): Promise<Project> => { + const matchingProjects = allProjects.filter((p) => p.id === projectId) + + if (matchingProjects.length === 0) { + throw new Error(`No project matching ${projectId} found`) } - return Promise.resolve([project1, project2]) + return matchingProjects[0] + } + + export const createWorkspace = (name: string): Promise<string> => { + return Promise.resolve("test-workspace") } +} + +export namespace Workspace { + export type WorkspaceId = string - export const createProject = (name: string): Promise<string> => { + export const createWorkspace = (name: string, projectTemplate: string): Promise<WorkspaceId> => { return Promise.resolve("test-workspace") } } diff --git a/site/components/Form/FormSection.tsx b/site/components/Form/FormSection.tsx new file mode 100644 index 0000000000000..eae2322e4447a --- /dev/null +++ b/site/components/Form/FormSection.tsx @@ -0,0 +1,62 @@ +import FormHelperText from "@material-ui/core/FormHelperText" +import { makeStyles } from "@material-ui/core/styles" +import Typography from "@material-ui/core/Typography" +import { Style } from "@material-ui/icons" +import React from "react" + +export interface FormSectionProps { + title: string + description?: string +} + +export const useStyles = makeStyles((theme) => ({ + root: { + display: "flex", + flexDirection: "row", + // Borrowed from PaperForm styles + maxWidth: "852px", + width: "100%", + borderBottom: `1px solid ${theme.palette.divider}`, + }, + descriptionContainer: { + maxWidth: "200px", + flex: "0 0 200px", + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "flex-start", + marginTop: theme.spacing(2), + marginBottom: theme.spacing(2), + }, + descriptionText: { + fontSize: "0.9em", + lineHeight: "1em", + color: theme.palette.text.secondary, + marginTop: theme.spacing(1), + }, + contents: { + flex: 1, + marginTop: theme.spacing(2), + marginBottom: theme.spacing(2), + }, +})) + +export const FormSection: React.FC<FormSectionProps> = ({ title, description, children }) => { + const styles = useStyles() + + return ( + <div className={styles.root}> + <div className={styles.descriptionContainer}> + <Typography variant="h5" color="textPrimary"> + {title} + </Typography> + {description && ( + <Typography className={styles.descriptionText} variant="body2" color="textSecondary"> + {description} + </Typography> + )} + </div> + <div className={styles.contents}>{children}</div> + </div> + ) +} diff --git a/site/components/Form/FormTextField.tsx b/site/components/Form/FormTextField.tsx new file mode 100644 index 0000000000000..5b2839d7e3584 --- /dev/null +++ b/site/components/Form/FormTextField.tsx @@ -0,0 +1,153 @@ +import TextField, { TextFieldProps } from "@material-ui/core/TextField" +import { FormikLike } from "../../util/formik" +import React from "react" +import { PasswordField } from "./PasswordField" +import { FormFieldProps } from "./types" + +/** + * FormTextFieldProps extends form-related MUI TextFieldProps with Formik + * props. The passed in form is used to compute error states and configure + * change handlers. `formFieldName` represents the key of a Formik value + * that's associated to this component. + */ +export interface FormTextFieldProps<T> + extends Pick< + TextFieldProps, + | "autoComplete" + | "autoFocus" + | "children" + | "className" + | "disabled" + | "fullWidth" + | "helperText" + | "id" + | "InputLabelProps" + | "InputProps" + | "inputProps" + | "label" + | "margin" + | "multiline" + | "onChange" + | "placeholder" + | "required" + | "rows" + | "select" + | "SelectProps" + | "style" + | "type" + | "variant" + >, + FormFieldProps<T> { + /** + * eventTransform is an optional transformer on the event data before it is + * processed by formik. + * + * @example + * <FormTextField + * eventTransformer={(str) => { + * return str.replace(" ", "-") + * }} + * /> + */ + eventTransform?: (value: string) => unknown + /** + * isPassword uses a PasswordField component when `true`; otherwise a + * TextField component is used. + */ + isPassword?: boolean + /** + * displayValueOverride allows displaying a different value in the field + * without changing the actual underlying value. + */ + displayValueOverride?: string +} + +/** + * Factory function for creating a formik TextField + * + * @example + * interface FormValues { + * username: string + * } + * + * // Use the factory to create a FormTextField associated to this form + * const FormTextField = formTextFieldFactory<FormValues>() + * + * const MyComponent: React.FC = () => { + * const form = useFormik<FormValues>() + * + * return ( + * <FormTextField + * form={form} + * formFieldName="username" + * fullWidth + * helperText="A unique name" + * label="Username" + * placeholder="Lorem Ipsum" + * required + * /> + * ) + * } + */ +export const formTextFieldFactory = <T,>(): React.FC<FormTextFieldProps<T>> => { + const component: React.FC<FormTextFieldProps<T>> = ({ + children, + disabled, + displayValueOverride, + eventTransform, + form, + formFieldName, + helperText, + isPassword = false, + InputProps, + onChange, + type, + ...rest + }) => { + const isError = form.touched[formFieldName] && Boolean(form.errors[formFieldName]) + + // Conversion to a string primitive is necessary as formFieldName is an in + // indexable type such as a string, number or enum. + const fieldId = FormikLike.getFieldId<T>(form, String(formFieldName)) + + const Component = isPassword ? PasswordField : TextField + const inputType = isPassword ? undefined : type + + return ( + <Component + {...rest} + disabled={disabled || form.isSubmitting} + error={isError} + helperText={isError ? form.errors[formFieldName] : helperText} + id={fieldId} + InputProps={isPassword ? undefined : InputProps} + name={fieldId} + onBlur={form.handleBlur} + onChange={(e) => { + if (typeof onChange !== "undefined") { + onChange(e) + } + + const event = e + if (typeof eventTransform !== "undefined") { + // TODO(Grey): Asserting the type as a string here is not quite + // right in that when an input is of type="number", the value will + // be a number. Type asserting is better than conversion for this + // reason, but perhaps there's a better way to do this without any + // assertions. + event.target.value = eventTransform(e.target.value) as string + } + form.handleChange(event) + }} + type={inputType} + value={displayValueOverride || form.values[formFieldName]} + > + {children} + </Component> + ) + } + + // Required when using an anonymous factory function + component.displayName = "FormTextField" + return component +} diff --git a/site/components/Form/PasswordField.tsx b/site/components/Form/PasswordField.tsx new file mode 100644 index 0000000000000..462c1643185b0 --- /dev/null +++ b/site/components/Form/PasswordField.tsx @@ -0,0 +1,40 @@ +import IconButton from "@material-ui/core/IconButton" +import InputAdornment from "@material-ui/core/InputAdornment" +import { makeStyles } from "@material-ui/core/styles" +import TextField, { TextFieldProps } from "@material-ui/core/TextField" +import VisibilityOffOutlined from "@material-ui/icons/VisibilityOffOutlined" +import VisibilityOutlined from "@material-ui/icons/VisibilityOutlined" +import React, { useCallback, useState } from "react" + +type PasswordFieldProps = Omit<TextFieldProps, "InputProps" | "type"> + +export const PasswordField: React.FC<PasswordFieldProps> = ({ variant = "outlined", ...rest }) => { + const styles = useStyles() + const [showPassword, setShowPassword] = useState<boolean>(false) + + const handleVisibilityChange = useCallback(() => setShowPassword((showPassword) => !showPassword), []) + const VisibilityIcon = showPassword ? VisibilityOffOutlined : VisibilityOutlined + + return ( + <TextField + {...rest} + type={showPassword ? "text" : "password"} + variant={variant} + InputProps={{ + endAdornment: ( + <InputAdornment position="end"> + <IconButton aria-label="toggle password visibility" onClick={handleVisibilityChange} size="small"> + <VisibilityIcon className={styles.visibilityIcon} /> + </IconButton> + </InputAdornment> + ), + }} + /> + ) +} + +const useStyles = makeStyles({ + visibilityIcon: { + fontSize: 20, + }, +}) diff --git a/site/components/Form/Title.tsx b/site/components/Form/Title.tsx index 8a5113f0ed4b2..8f906ec51e6c2 100644 --- a/site/components/Form/Title.tsx +++ b/site/components/Form/Title.tsx @@ -10,11 +10,8 @@ export interface TitleProps { const useStyles = makeStyles((theme) => ({ title: { textAlign: "center", - marginBottom: theme.spacing(10), - - [theme.breakpoints.down("sm")]: { - gridColumn: 1, - }, + marginTop: theme.spacing(5), + marginBottom: theme.spacing(5), "& h3": { marginBottom: theme.spacing(1), diff --git a/site/components/Form/index.ts b/site/components/Form/index.ts index 331a3002fb1fc..5ae7983ca7923 100644 --- a/site/components/Form/index.ts +++ b/site/components/Form/index.ts @@ -1 +1,2 @@ -export { Title } from "./Title" +export * from "./Title" +export * from "./FormSection" diff --git a/site/components/Form/types.ts b/site/components/Form/types.ts new file mode 100644 index 0000000000000..bc30b42d424e7 --- /dev/null +++ b/site/components/Form/types.ts @@ -0,0 +1,16 @@ +import { FormikLike } from "../../util/formik" + +/** + * FormFieldProps are required props for creating form fields using a factory. + */ +export interface FormFieldProps<T> { + /** + * form is a reference to a form or subform and is used to compute common + * states such as error and helper text + */ + form: FormikLike<T> + /** + * formFieldName is a field name associated with the form schema. + */ + formFieldName: keyof T +} diff --git a/site/pages/workspaces/create/[projectId].tsx b/site/pages/workspaces/create/[projectId].tsx index b2f45c40b2959..fabe3311f4180 100644 --- a/site/pages/workspaces/create/[projectId].tsx +++ b/site/pages/workspaces/create/[projectId].tsx @@ -1,22 +1,40 @@ import React from "react" import { useRouter } from "next/router" import { useFormik } from "formik" +import { firstOrOnly } from "./../../../util" import * as API from "../../../api" import { FormPage, FormButton } from "../../../components/PageTemplates" +import { useRequestor } from "../../../hooks/useRequest" +import { FormSection } from "../../../components/Form" +import { formTextFieldFactory } from "../../../components/Form/FormTextField" + +namespace CreateProjectForm { + export interface Schema { + name: string + parameters: any[] + } + + export const initial: Schema = { + name: "", + parameters: [], + } +} + +const FormTextField = formTextFieldFactory<CreateProjectForm.Schema>() const CreateProjectPage: React.FC = () => { const router = useRouter() - const { projectId } = router.query - + const { projectId: routeProjectId } = router.query + const projectId = firstOrOnly(routeProjectId) + // const projectToLoad = useRequestor(() => API.Project.getProject("test-org", projectId)) const form = useFormik({ - initialValues: { - name: "" - }, + enableReinitialize: true, + initialValues: CreateProjectForm.initial, onSubmit: async ({ name }) => { - return API.Project.createProject(name) + return API.Workspace.createWorkspace(name, projectId) }, }) @@ -51,7 +69,17 @@ const CreateProjectPage: React.FC = () => { return ( <FormPage title={"Create Project"} organization={"test-org"} buttons={buttons}> - <div>TODO: Dynamic form fields</div> + <FormSection title="General"> + <FormTextField + form={form} + formFieldName="name" + fullWidth + helperText="A unique name describing your workspace." + label="Workspace Name" + placeholder="my-dev" + required + /> + </FormSection> </FormPage> ) } diff --git a/site/util/firstOrOnly.ts b/site/util/firstOrOnly.ts new file mode 100644 index 0000000000000..61e26ff679e66 --- /dev/null +++ b/site/util/firstOrOnly.ts @@ -0,0 +1,7 @@ +export const firstOrOnly = <T>(itemOrItems: T | T[]) => { + if (Array.isArray(itemOrItems)) { + return itemOrItems[0] + } else { + return itemOrItems + } +} diff --git a/site/util/formik.ts b/site/util/formik.ts new file mode 100644 index 0000000000000..240b911c834e9 --- /dev/null +++ b/site/util/formik.ts @@ -0,0 +1,125 @@ +import { FormikContextType, FormikErrors, FormikTouched, getIn } from "formik" + +/** + * FormikLike is a thin layer of abstraction over 'Formik' + * + * FormikLike is intended to be compatible with Formik (ie, a subset - a drop-in replacement), + * but adds faculty for handling sub-forms. + */ +export interface FormikLike<T> + extends Pick< + FormikContextType<T>, + // Subset of formik functionality that is supported in subForms + | "errors" + | "handleBlur" + | "handleChange" + | "isSubmitting" + | "setFieldTouched" + | "setFieldValue" + | "submitCount" + | "touched" + | "values" + > { + getFieldId?: (fieldName: string) => string +} + +// Utility functions around the FormikLike interface +export namespace FormikLike { + /** + * getFieldId + * + * getFieldId returns the fully-qualified path for a field. + * For a form with no parents, this is just the field name. + * For a form with parents, this is included the path of the field. + */ + export const getFieldId = <T>(form: FormikLike<T>, fieldName: string | keyof T): string => { + if (typeof form.getFieldId !== "undefined") { + return form.getFieldId(String(fieldName)) + } else { + return String(fieldName) + } + } +} + +/** + * subForm + * + * `subForm` takes a parentForm and a selector, and returns a new form + * that is scoped to just the selector. + * + * For example, consider the schema: + * + * ``` + * type NestedSchema = { + * name: string, + * } + * + * type Schema = { + * nestedForm: NestedSchema, + * } + * ``` + * + * Calling `subForm(parentForm, "nestedForm")` where `parentForm` is a + * `FormikLike<Schema>` will return a `FormikLike<NestedSchema>`. + * + * This is helpful for composing forms - a `FormikLike<NestedSchema>` + * could either be part of a larger parent form, or a stand-alone form - + * the component itself doesn't have to know! + * + * @param parentForm The parent form `FormikLike` + * @param subFormSelector The field containing the nested form + * @returns A `FormikLike` for the nested form + */ +export const subForm = <TParentFormSchema, TSubFormSchema>( + parentForm: FormikLike<TParentFormSchema>, + subFormSelector: string & keyof TParentFormSchema, +): FormikLike<TSubFormSchema> => { + // TODO: It would be nice to have better typing for `getIn` so that we + // don't need the `as` cast. Perhaps future versions of `Formik` will have + // a more strongly typed version of `getIn`? Or, we may have a more type-safe + // utility for this in the future. + const values = getIn(parentForm.values, subFormSelector) as TSubFormSchema + const errors = (getIn(parentForm.errors, subFormSelector) || {}) as FormikErrors<TSubFormSchema> + const touched = (getIn(parentForm.touched, subFormSelector) || {}) as FormikTouched<TSubFormSchema> + + const getFieldId = (fieldName: string): string => { + return FormikLike.getFieldId(parentForm, subFormSelector + "." + fieldName) + } + + return { + values, + errors, + touched, + + // We can pass the parentForm handlerBlur/handleChange directly, + // since they figure out the field ID from the element. + handleBlur: parentForm.handleBlur, + handleChange: parentForm.handleChange, + + // isSubmitting can just pass through - there isn't a difference + // in submitting state between parent forms and subforms + // (only the top-level form handles submission) + isSubmitting: parentForm.isSubmitting, + submitCount: parentForm.submitCount, + + // Wrap setFieldValue & setFieldTouched so we can resolve to the fully-nested ID for setting + setFieldValue: <T extends keyof TSubFormSchema>(fieldName: string | keyof T, value: T[keyof T]): void => { + const fieldNameAsString = String(fieldName) + const resolvedFieldId = getFieldId(fieldNameAsString) + + parentForm.setFieldValue(resolvedFieldId, value) + }, + setFieldTouched: <T extends keyof TSubFormSchema>( + fieldName: string | keyof T, + isTouched: boolean | undefined, + shouldValidate = false, + ): void => { + const fieldNameAsString = String(fieldName) + const resolvedFieldId = getFieldId(fieldNameAsString) + + parentForm.setFieldTouched(resolvedFieldId, isTouched, shouldValidate) + }, + + getFieldId, + } +} diff --git a/site/util/index.ts b/site/util/index.ts new file mode 100644 index 0000000000000..bc33f2ad9f50a --- /dev/null +++ b/site/util/index.ts @@ -0,0 +1 @@ +export * from "./firstOrOnly" From 6fc0a54c597ae470ec89229d4cadbb1426145f69 Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Thu, 20 Jan 2022 02:32:01 +0000 Subject: [PATCH 20/41] Initial shared loading logic --- site/api.ts | 13 ++-- site/components/Form/Title.tsx | 8 +-- site/components/PageTemplates/FormPage.tsx | 6 +- site/components/PageTemplates/LoadingPage.tsx | 41 ++++++++++++ site/hooks/useRequest.ts | 24 +++++-- site/pages/workspaces/create/[projectId].tsx | 41 +++++++----- site/pages/workspaces/create/index.tsx | 63 +++++++++---------- site/util/index.ts | 2 + site/util/promise.ts | 8 +++ 9 files changed, 141 insertions(+), 65 deletions(-) create mode 100644 site/components/PageTemplates/LoadingPage.tsx create mode 100644 site/util/promise.ts diff --git a/site/api.ts b/site/api.ts index f75688ffd937e..bb17562d583b5 100644 --- a/site/api.ts +++ b/site/api.ts @@ -1,5 +1,6 @@ import { SvgIcon } from "@material-ui/core" import { Logo } from "./components/Icons" +import { wait } from "./util" export interface Project { id: string @@ -24,11 +25,14 @@ export namespace Project { const allProjects = [testProject1, testProject2] - export const getAllProjectsInOrg = (_org: string): Promise<Project[]> => { - return Promise.resolve(allProjects) + export const getAllProjectsInOrg = async (_org: string): Promise<Project[]> => { + await wait(250) + return allProjects } export const getProject = async (_org: string, projectId: string): Promise<Project> => { + await wait(250) + const matchingProjects = allProjects.filter((p) => p.id === projectId) if (matchingProjects.length === 0) { @@ -38,8 +42,9 @@ export namespace Project { return matchingProjects[0] } - export const createWorkspace = (name: string): Promise<string> => { - return Promise.resolve("test-workspace") + export const createWorkspace = async (name: string): Promise<string> => { + await wait(250) + return "test-workspace" } } diff --git a/site/components/Form/Title.tsx b/site/components/Form/Title.tsx index 8f906ec51e6c2..8e6275853719c 100644 --- a/site/components/Form/Title.tsx +++ b/site/components/Form/Title.tsx @@ -4,7 +4,7 @@ import React from "react" export interface TitleProps { title: string - organization: string + detail: React.ReactNode } const useStyles = makeStyles((theme) => ({ @@ -19,15 +19,13 @@ const useStyles = makeStyles((theme) => ({ }, })) -export const Title: React.FC<TitleProps> = ({ title, organization }) => { +export const Title: React.FC<TitleProps> = ({ title, detail }) => { const styles = useStyles() return ( <div className={styles.title}> <Typography variant="h3">{title}</Typography> - <Typography variant="caption"> - In <strong>{organization}</strong> organization - </Typography> + <Typography variant="caption">{detail}</Typography> </div> ) } diff --git a/site/components/PageTemplates/FormPage.tsx b/site/components/PageTemplates/FormPage.tsx index a85446514673b..7a8ebc48a480a 100644 --- a/site/components/PageTemplates/FormPage.tsx +++ b/site/components/PageTemplates/FormPage.tsx @@ -44,11 +44,11 @@ export interface FormButton { export interface FormPageProps { title: string - organization: string + detail?: React.ReactNode buttons?: FormButton[] } -export const FormPage: React.FC<FormPageProps> = ({ title, organization, children, buttons }) => { +export const FormPage: React.FC<FormPageProps> = ({ title, detail, children, buttons }) => { const styles = useStyles() const actualButtons = buttons || [] @@ -56,7 +56,7 @@ export const FormPage: React.FC<FormPageProps> = ({ title, organization, childre return ( <div className={styles.form}> <div className={styles.header}> - <Title title={title} organization={organization} /> + <Title title={title} detail={detail} /> </div> <div className={styles.body}>{children}</div> <div className={styles.footer}> diff --git a/site/components/PageTemplates/LoadingPage.tsx b/site/components/PageTemplates/LoadingPage.tsx new file mode 100644 index 0000000000000..0364e7d6b8509 --- /dev/null +++ b/site/components/PageTemplates/LoadingPage.tsx @@ -0,0 +1,41 @@ +import { Box, CircularProgress, makeStyles } from "@material-ui/core" +import React from "react" +import { RequestState } from "../../hooks/useRequest" + +export interface LoadingPageProps<T> { + request: RequestState<T> + children: (state: T) => React.ReactElement<any, any> +} + +const useStyles = makeStyles(() => ({ + fullScreenLoader: { + position: "absolute", + top: "0", + left: "0", + right: "0", + bottom: "0", + display: "flex", + justifyContent: "center", + alignItems: "center", + }, +})) + +export const LoadingPage: React.FC<LoadingPageProps<T>> = <T,>(props: LoadingPageProps<T>) => { + const styles = useStyles() + + const { request, children } = props + const { state } = request + switch (state) { + case "error": + return <div>{request.error.toString()}</div> + case "loading": + return ( + <div className={styles.fullScreenLoader}> + {" "} + <CircularProgress /> + </div> + ) + case "success": + return children(request.payload) + } +} diff --git a/site/hooks/useRequest.ts b/site/hooks/useRequest.ts index 9dc69f905166e..509ef6e0cba82 100644 --- a/site/hooks/useRequest.ts +++ b/site/hooks/useRequest.ts @@ -1,4 +1,5 @@ import { useState, useEffect } from "react" +import isReady from "next/router" export type RequestState<TPayload> = | { @@ -13,20 +14,35 @@ export type RequestState<TPayload> = payload: TPayload } -export const useRequestor = <TPayload>(fn: () => Promise<TPayload>) => { +export const useRequestor = <TPayload>(fn: () => Promise<TPayload>, deps: any[] = []) => { const [requestState, setRequestState] = useState<RequestState<TPayload>>({ state: "loading" }) useEffect(() => { + // Initially, some parameters might not be available - make sure all query parameters are set + // as a courtesy to users of this hook. + if (!isReady) { + return + } + + let cancelled = false const f = async () => { try { const response = await fn() - setRequestState({ state: "success", payload: response }) + if (!cancelled) { + setRequestState({ state: "success", payload: response }) + } } catch (err) { - setRequestState({ state: "error", error: err }) + if (!cancelled) { + setRequestState({ state: "error", error: err }) + } } } f() - }) + + return () => { + cancelled = true + } + }, [isReady, ...deps]) return requestState } diff --git a/site/pages/workspaces/create/[projectId].tsx b/site/pages/workspaces/create/[projectId].tsx index fabe3311f4180..678ee761ba72f 100644 --- a/site/pages/workspaces/create/[projectId].tsx +++ b/site/pages/workspaces/create/[projectId].tsx @@ -9,6 +9,7 @@ import { FormPage, FormButton } from "../../../components/PageTemplates" import { useRequestor } from "../../../hooks/useRequest" import { FormSection } from "../../../components/Form" import { formTextFieldFactory } from "../../../components/Form/FormTextField" +import { LoadingPage } from "../../../components/PageTemplates/LoadingPage" namespace CreateProjectForm { export interface Schema { @@ -27,8 +28,9 @@ const FormTextField = formTextFieldFactory<CreateProjectForm.Schema>() const CreateProjectPage: React.FC = () => { const router = useRouter() const { projectId: routeProjectId } = router.query + console.log(routeProjectId) const projectId = firstOrOnly(routeProjectId) - // const projectToLoad = useRequestor(() => API.Project.getProject("test-org", projectId)) + const projectToLoad = useRequestor(() => API.Project.getProject("test-org", projectId), [projectId]) const form = useFormik({ enableReinitialize: true, @@ -68,19 +70,30 @@ const CreateProjectPage: React.FC = () => { ] return ( - <FormPage title={"Create Project"} organization={"test-org"} buttons={buttons}> - <FormSection title="General"> - <FormTextField - form={form} - formFieldName="name" - fullWidth - helperText="A unique name describing your workspace." - label="Workspace Name" - placeholder="my-dev" - required - /> - </FormSection> - </FormPage> + <LoadingPage request={projectToLoad}> + {(project) => { + const detail = ( + <> + <strong>{project.name}</strong> in <strong> {"test-org"}</strong> organization + </> + ) + return ( + <FormPage title={"Create Project"} detail={detail} buttons={buttons}> + <FormSection title="General"> + <FormTextField + form={form} + formFieldName="name" + fullWidth + helperText="A unique name describing your workspace." + label="Workspace Name" + placeholder={project.id} + required + /> + </FormSection> + </FormPage> + ) + }} + </LoadingPage> ) } diff --git a/site/pages/workspaces/create/index.tsx b/site/pages/workspaces/create/index.tsx index 4d33d1c333112..637f7457bf3c0 100644 --- a/site/pages/workspaces/create/index.tsx +++ b/site/pages/workspaces/create/index.tsx @@ -7,6 +7,7 @@ import * as Api from "./../../../api" import CircularProgress from "@material-ui/core/CircularProgress" import { ProjectIcon } from "../../../components/Project/ProjectIcon" import Box from "@material-ui/core/Box" +import { LoadingPage } from "../../../components/PageTemplates/LoadingPage" const CreateSelectProjectPage: React.FC = () => { const router = useRouter() @@ -20,42 +21,34 @@ const CreateSelectProjectPage: React.FC = () => { router.push(`/workspaces/create/${projectId}`) } - let body - - switch (requestState.state) { - case "loading": - body = <CircularProgress /> - break - case "error": - body = <>{requestState.error.toString()}</> - break - case "success": - body = ( - <> - {requestState.payload.map((project) => { - return <ProjectIcon title={project.name} icon={project.icon} onClick={select(project.id)} /> - })} - </> - ) - break - } - - const buttons: FormButton[] = [ - { - title: "Cancel", - props: { - variant: "outlined", - onClick: cancel, - }, - }, - ] - return ( - <FormPage title={"Select Project"} organization={"test-org"} buttons={buttons}> - <Box style={{ display: "flex", flexDirection: "row", justifyContent: "center", alignItems: "center" }}> - {body} - </Box> - </FormPage> + <LoadingPage request={requestState}> + {(projects) => { + const buttons: FormButton[] = [ + { + title: "Cancel", + props: { + variant: "outlined", + onClick: cancel, + }, + }, + ] + const detail = ( + <> + In <strong> {"test-org"}</strong> organization + </> + ) + return ( + <FormPage title={"Select Project"} detail={detail} buttons={buttons}> + <Box style={{ display: "flex", flexDirection: "row", justifyContent: "center", alignItems: "center" }}> + {projects.map((project: Api.Project) => { + return <ProjectIcon title={project.name} icon={project.icon} onClick={select(project.id)} /> + })} + </Box> + </FormPage> + ) + }} + </LoadingPage> ) } diff --git a/site/util/index.ts b/site/util/index.ts index bc33f2ad9f50a..d792ccafcdd4c 100644 --- a/site/util/index.ts +++ b/site/util/index.ts @@ -1 +1,3 @@ export * from "./firstOrOnly" +export * from "./formik" +export * from "./promise" diff --git a/site/util/promise.ts b/site/util/promise.ts new file mode 100644 index 0000000000000..eb0951f0d85c7 --- /dev/null +++ b/site/util/promise.ts @@ -0,0 +1,8 @@ +/** + * Returns a promise that resolves after a set time + * + * @param time Time to wait in milliseconds + */ +export async function wait(milliseconds: number): Promise<void> { + await new Promise((resolve) => setTimeout(resolve, milliseconds)) +} From e57d1b62b54cd35cd92593be2f478d28b37d088e Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Thu, 20 Jan 2022 05:06:38 +0000 Subject: [PATCH 21/41] Set up dynamic form components --- site/api.ts | 44 ++++++- site/components/Form/FormRow.tsx | 14 +++ site/components/Form/FormSection.tsx | 4 +- .../Form/{Title.tsx => FormTitle.tsx} | 0 site/components/Form/index.ts | 3 +- site/components/PageTemplates/LoadingPage.tsx | 4 +- site/pages/workspaces/create/[projectId].tsx | 111 ++++++++++++------ site/pages/workspaces/create/index.tsx | 2 +- 8 files changed, 138 insertions(+), 44 deletions(-) create mode 100644 site/components/Form/FormRow.tsx rename site/components/Form/{Title.tsx => FormTitle.tsx} (100%) diff --git a/site/api.ts b/site/api.ts index bb17562d583b5..18372cb8b84e3 100644 --- a/site/api.ts +++ b/site/api.ts @@ -2,11 +2,22 @@ import { SvgIcon } from "@material-ui/core" import { Logo } from "./components/Icons" import { wait } from "./util" +export type ProjectParameterType = "string" | "number" + +export interface ProjectParameter { + id: string + name: string + description: string + defaultValue?: string + type: ProjectParameterType +} + export interface Project { id: string icon?: string name: string description: string + parameters: ProjectParameter[] } export namespace Project { @@ -15,12 +26,35 @@ export namespace Project { icon: "https://www.datocms-assets.com/2885/1620155117-brandhcterraformverticalcolorwhite.svg", name: "Terraform Project 1", description: "Simple terraform project that deploys a kubernetes provider", + parameters: [ + { + id: "namespace", + name: "Namespace", + description: "The kubernetes namespace that will own the workspace pod.", + defaultValue: "default", + type: "string", + }, + { + id: "cpu_cores", + name: "CPU Cores", + description: "Number of CPU cores to allocate for pod.", + type: "number", + }, + ], } const testProject2: Project = { id: "test-echo-1", name: "Echo Project", description: "Project built on echo provisioner", + parameters: [ + { + id: "echo_string", + name: "Echo string", + description: "String that will be echoed out during build.", + type: "string", + }, + ], } const allProjects = [testProject1, testProject2] @@ -51,7 +85,15 @@ export namespace Project { export namespace Workspace { export type WorkspaceId = string - export const createWorkspace = (name: string, projectTemplate: string): Promise<WorkspaceId> => { + export const createWorkspace = ( + name: string, + projectTemplate: string, + parameters: Record<string, string>, + ): Promise<WorkspaceId> => { + alert( + `Creating workspace named ${name} for project ${projectTemplate} with parameters: ${JSON.stringify(parameters)}`, + ) + return Promise.resolve("test-workspace") } } diff --git a/site/components/Form/FormRow.tsx b/site/components/Form/FormRow.tsx new file mode 100644 index 0000000000000..84ae37d381a2c --- /dev/null +++ b/site/components/Form/FormRow.tsx @@ -0,0 +1,14 @@ +import { makeStyles } from "@material-ui/core/styles" +import React from "react" + +const useStyles = makeStyles((theme) => ({ + row: { + marginTop: theme.spacing(2), + marginBottom: theme.spacing(2), + }, +})) + +export const FormRow: React.FC = ({ children }) => { + const styles = useStyles() + return <div className={styles.row}>{children}</div> +} diff --git a/site/components/Form/FormSection.tsx b/site/components/Form/FormSection.tsx index eae2322e4447a..6ae2bae4fbf58 100644 --- a/site/components/Form/FormSection.tsx +++ b/site/components/Form/FormSection.tsx @@ -23,9 +23,9 @@ export const useStyles = makeStyles((theme) => ({ flex: "0 0 200px", display: "flex", flexDirection: "column", - justifyContent: "center", + justifyContent: "flex-start", alignItems: "flex-start", - marginTop: theme.spacing(2), + marginTop: theme.spacing(5), marginBottom: theme.spacing(2), }, descriptionText: { diff --git a/site/components/Form/Title.tsx b/site/components/Form/FormTitle.tsx similarity index 100% rename from site/components/Form/Title.tsx rename to site/components/Form/FormTitle.tsx diff --git a/site/components/Form/index.ts b/site/components/Form/index.ts index 5ae7983ca7923..29f7fc92fd34f 100644 --- a/site/components/Form/index.ts +++ b/site/components/Form/index.ts @@ -1,2 +1,3 @@ -export * from "./Title" +export * from "./FormTitle" +export * from "./FormRow" export * from "./FormSection" diff --git a/site/components/PageTemplates/LoadingPage.tsx b/site/components/PageTemplates/LoadingPage.tsx index 0364e7d6b8509..c10a5d1c4c754 100644 --- a/site/components/PageTemplates/LoadingPage.tsx +++ b/site/components/PageTemplates/LoadingPage.tsx @@ -25,13 +25,13 @@ export const LoadingPage: React.FC<LoadingPageProps<T>> = <T,>(props: LoadingPag const { request, children } = props const { state } = request + switch (state) { case "error": - return <div>{request.error.toString()}</div> + return <div className={styles.fullScreenLoader}>{request.error.toString()}</div> case "loading": return ( <div className={styles.fullScreenLoader}> - {" "} <CircularProgress /> </div> ) diff --git a/site/pages/workspaces/create/[projectId].tsx b/site/pages/workspaces/create/[projectId].tsx index 678ee761ba72f..18e88a9db64f4 100644 --- a/site/pages/workspaces/create/[projectId].tsx +++ b/site/pages/workspaces/create/[projectId].tsx @@ -7,41 +7,59 @@ import * as API from "../../../api" import { FormPage, FormButton } from "../../../components/PageTemplates" import { useRequestor } from "../../../hooks/useRequest" -import { FormSection } from "../../../components/Form" +import { FormSection, FormRow } from "../../../components/Form" import { formTextFieldFactory } from "../../../components/Form/FormTextField" import { LoadingPage } from "../../../components/PageTemplates/LoadingPage" namespace CreateProjectForm { export interface Schema { name: string - parameters: any[] + parameters: Record<string, string> } export const initial: Schema = { name: "", - parameters: [], + parameters: {}, } } const FormTextField = formTextFieldFactory<CreateProjectForm.Schema>() +namespace Helpers { + export const projectParametersToValues = (parameters: API.ProjectParameter[]) => { + const parameterValues: Record<string, string> = {} + return parameters.reduce((acc, curr) => { + return { + ...acc, + [curr.id]: curr.defaultValue || "", + } + }, parameterValues) + } +} + const CreateProjectPage: React.FC = () => { const router = useRouter() const { projectId: routeProjectId } = router.query - console.log(routeProjectId) const projectId = firstOrOnly(routeProjectId) + const projectToLoad = useRequestor(() => API.Project.getProject("test-org", projectId), [projectId]) + const parametersWithMetadata = projectToLoad.state === "success" ? projectToLoad.payload.parameters : [] + const parameters = Helpers.projectParametersToValues(parametersWithMetadata) + const form = useFormik({ enableReinitialize: true, - initialValues: CreateProjectForm.initial, - onSubmit: async ({ name }) => { - return API.Workspace.createWorkspace(name, projectId) + initialValues: { + name: "", + parameters, + }, + onSubmit: async ({ name, parameters }) => { + return API.Workspace.createWorkspace(name, projectId, parameters) }, }) const cancel = () => { - router.back() + router.push(`/workspaces/create`) } const submit = async () => { @@ -49,29 +67,29 @@ const CreateProjectPage: React.FC = () => { router.push(`/workspaces/${workspaceId}`) } - const buttons: FormButton[] = [ - { - title: "Cancel", - props: { - variant: "outlined", - onClick: cancel, - }, - }, - { - title: "Submit", - props: { - variant: "contained", - color: "primary", - disabled: false, - type: "submit", - onClick: submit, - }, - }, - ] - return ( <LoadingPage request={projectToLoad}> {(project) => { + const buttons: FormButton[] = [ + { + title: "Back", + props: { + variant: "outlined", + onClick: cancel, + }, + }, + { + title: "Create Workspace", + props: { + variant: "contained", + color: "primary", + disabled: false, + type: "submit", + onClick: submit, + }, + }, + ] + const detail = ( <> <strong>{project.name}</strong> in <strong> {"test-org"}</strong> organization @@ -80,15 +98,34 @@ const CreateProjectPage: React.FC = () => { return ( <FormPage title={"Create Project"} detail={detail} buttons={buttons}> <FormSection title="General"> - <FormTextField - form={form} - formFieldName="name" - fullWidth - helperText="A unique name describing your workspace." - label="Workspace Name" - placeholder={project.id} - required - /> + <FormRow> + <FormTextField + form={form} + formFieldName="name" + fullWidth + helperText="A unique name describing your workspace." + label="Workspace Name" + placeholder={project.id} + required + /> + </FormRow> + </FormSection> + <FormSection title="Parameters"> + {parametersWithMetadata.map((param) => { + return ( + <FormRow> + <FormTextField + form={form} + formFieldName={"parameters." + param.id} + fullWidth + label={param.name} + helperText={param.description} + placeholder={param.defaultValue} + required + /> + </FormRow> + ) + })} </FormSection> </FormPage> ) diff --git a/site/pages/workspaces/create/index.tsx b/site/pages/workspaces/create/index.tsx index 637f7457bf3c0..e0ce5c603dd3d 100644 --- a/site/pages/workspaces/create/index.tsx +++ b/site/pages/workspaces/create/index.tsx @@ -14,7 +14,7 @@ const CreateSelectProjectPage: React.FC = () => { const requestState = useRequestor(() => Api.Project.getAllProjectsInOrg("test-org")) const cancel = () => { - router.back() + router.push(`/workspaces`) } const select = (projectId: string) => () => { From 08010c38892c66ff4d263135a4aca199a7277521 Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Thu, 20 Jan 2022 05:11:09 +0000 Subject: [PATCH 22/41] Remove leftover buildmode pkg --- buildmode/buildmode.go | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 buildmode/buildmode.go diff --git a/buildmode/buildmode.go b/buildmode/buildmode.go deleted file mode 100644 index 1636d589cb790..0000000000000 --- a/buildmode/buildmode.go +++ /dev/null @@ -1,21 +0,0 @@ -package buildmode - -import ( - "flag" - "strings" -) - -// BuildMode is injected at build time. -var ( - BuildMode string -) - -// Dev returns true when built to run in a dev deployment. -func Dev() bool { - return strings.HasPrefix(BuildMode, "dev") -} - -// Test returns true when running inside a unit test. -func Test() bool { - return flag.Lookup("test.v") != nil -} From 88004453f2a8673ac05fe3db7979e25722a865b7 Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Thu, 20 Jan 2022 05:13:01 +0000 Subject: [PATCH 23/41] Add note in api that this is temporary --- site/api.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/site/api.ts b/site/api.ts index 18372cb8b84e3..f3c9b1d44802e 100644 --- a/site/api.ts +++ b/site/api.ts @@ -1,7 +1,12 @@ -import { SvgIcon } from "@material-ui/core" -import { Logo } from "./components/Icons" import { wait } from "./util" +// TEMPORARY +// This is all placeholder / stub code until we have a real API to work with! +// +// The implementations below that are hard-coded will switch to using `fetch` +// once the routes are available. +// TEMPORARY + export type ProjectParameterType = "string" | "number" export interface ProjectParameter { From 496b42ef3a0c38efa80971e2a9a707d5adef8423 Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Thu, 20 Jan 2022 05:19:03 +0000 Subject: [PATCH 24/41] Factor ProjectName to separate file --- site/components/Project/ProjectIcon.tsx | 40 ++++--------------------- site/components/Project/ProjectName.tsx | 35 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 35 deletions(-) create mode 100644 site/components/Project/ProjectName.tsx diff --git a/site/components/Project/ProjectIcon.tsx b/site/components/Project/ProjectIcon.tsx index 3733969d09c4f..1ecc89dea6e30 100644 --- a/site/components/Project/ProjectIcon.tsx +++ b/site/components/Project/ProjectIcon.tsx @@ -1,5 +1,6 @@ import React from "react" -import { Box, makeStyles, SvgIcon, Typography } from "@material-ui/core" +import { Box, makeStyles } from "@material-ui/core" +import { ProjectName } from "./ProjectName" export interface ProjectIconProps { title: string @@ -19,7 +20,9 @@ const useStyles = makeStyles((theme) => ({ }, }, })) - +/** + * <Circle /> is just a placeholder icon for projects that don't have one. + */ const Circle: React.FC = () => { return ( <Box @@ -33,39 +36,6 @@ const Circle: React.FC = () => { ) } -const useStyles2 = makeStyles((theme) => ({ - root: { - color: theme.palette.text.secondary, - display: "-webkit-box", // See (1) - marginTop: theme.spacing(0.5), - maxWidth: "110%", - minWidth: 0, - overflow: "hidden", // See (1) - textAlign: "center", - textOverflow: "ellipsis", // See (1) - whiteSpace: "normal", // See (1) - - // (1) - These styles, along with clamping make it so that not only - // can text not overflow horizontally, but there can also only be a - // maximum of 2 line breaks. This is standard behaviour on OS files - // (ex: Windows 10 Desktop application) to prevent excessive vertical - // line wraps. This is important in Generic Applications, as we have no - // control over the application name used in the manifest. - ["-webkit-line-clamp"]: 2, - ["-webkit-box-orient"]: "vertical", - }, -})) - -export const ProjectName: React.FC = ({ children }) => { - const styles = useStyles2() - - return ( - <Typography className={styles.root} noWrap variant="body2"> - {children} - </Typography> - ) -} - export const ProjectIcon: React.FC<ProjectIconProps> = ({ icon, title, onClick }) => { const styles = useStyles() diff --git a/site/components/Project/ProjectName.tsx b/site/components/Project/ProjectName.tsx new file mode 100644 index 0000000000000..98dcd203c10c6 --- /dev/null +++ b/site/components/Project/ProjectName.tsx @@ -0,0 +1,35 @@ +import React from "react" +import { Box, makeStyles, Typography } from "@material-ui/core" + +const useStyles = makeStyles((theme) => ({ + root: { + color: theme.palette.text.secondary, + display: "-webkit-box", // See (1) + marginTop: theme.spacing(0.5), + maxWidth: "110%", + minWidth: 0, + overflow: "hidden", // See (1) + textAlign: "center", + textOverflow: "ellipsis", // See (1) + whiteSpace: "normal", // See (1) + + // (1) - These styles, along with clamping make it so that not only + // can text not overflow horizontally, but there can also only be a + // maximum of 2 line breaks. This is standard behaviour on OS files + // (ex: Windows 10 Desktop application) to prevent excessive vertical + // line wraps. This is important in Generic Applications, as we have no + // control over the application name used in the manifest. + ["-webkit-line-clamp"]: 2, + ["-webkit-box-orient"]: "vertical", + }, +})) + +export const ProjectName: React.FC = ({ children }) => { + const styles = useStyles2() + + return ( + <Typography className={styles.root} noWrap variant="body2"> + {children} + </Typography> + ) +} From 646206e5739ddc7376db7a231df186f180212f89 Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Thu, 20 Jan 2022 05:24:45 +0000 Subject: [PATCH 25/41] Fix useStyles naming --- site/components/Project/ProjectName.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/components/Project/ProjectName.tsx b/site/components/Project/ProjectName.tsx index 98dcd203c10c6..2421d31c2000c 100644 --- a/site/components/Project/ProjectName.tsx +++ b/site/components/Project/ProjectName.tsx @@ -1,5 +1,5 @@ import React from "react" -import { Box, makeStyles, Typography } from "@material-ui/core" +import { makeStyles, Typography } from "@material-ui/core" const useStyles = makeStyles((theme) => ({ root: { @@ -25,7 +25,7 @@ const useStyles = makeStyles((theme) => ({ })) export const ProjectName: React.FC = ({ children }) => { - const styles = useStyles2() + const styles = useStyles() return ( <Typography className={styles.root} noWrap variant="body2"> From 4b6f1c5785a8291cfe448e31e7b3418e8e3b255c Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Thu, 20 Jan 2022 05:25:14 +0000 Subject: [PATCH 26/41] Remove accidentally duplicated SafeHydrate --- site/pages/_app.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/site/pages/_app.tsx b/site/pages/_app.tsx index 1621a983c4357..77f8836125606 100644 --- a/site/pages/_app.tsx +++ b/site/pages/_app.tsx @@ -5,16 +5,6 @@ import ThemeProvider from "@material-ui/styles/ThemeProvider" import { light } from "../theme" import { AppProps } from "next/app" -import { makeStyles } from "@material-ui/core" - -/** - * SafeHydrate is a component that only allows its children to be rendered - * client-side. This check is performed by querying the existence of the window - * global. - */ -const SafeHydrate: React.FC = ({ children }) => ( - <div suppressHydrationWarning>{typeof window === "undefined" ? null : children}</div> -) /** * ClientRender is a component that only allows its children to be rendered From 961a44bb61581c0926e1708c318762c476f8e21e Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Thu, 20 Jan 2022 05:26:07 +0000 Subject: [PATCH 27/41] Remove addition of srverr package --- srverr/error.go | 10 ------- srverr/error_test.go | 19 ------------- srverr/errors.go | 65 -------------------------------------------- srverr/wrap.go | 48 -------------------------------- 4 files changed, 142 deletions(-) delete mode 100644 srverr/error.go delete mode 100644 srverr/error_test.go delete mode 100644 srverr/errors.go delete mode 100644 srverr/wrap.go diff --git a/srverr/error.go b/srverr/error.go deleted file mode 100644 index 022387ca2f28c..0000000000000 --- a/srverr/error.go +++ /dev/null @@ -1,10 +0,0 @@ -package srverr - -// Error is an interface for specifying how specific errors should be -// dispatched by the API. The underlying struct is sent under the `details` -// field. -type Error interface { - Status() int - PublicMessage() string - Code() Code -} diff --git a/srverr/error_test.go b/srverr/error_test.go deleted file mode 100644 index 908df65d56e7d..0000000000000 --- a/srverr/error_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package srverr - -import ( - "testing" - - "github.com/stretchr/testify/require" - "golang.org/x/xerrors" -) - -func TestErrorChain(t *testing.T) { - t.Run("wrapping", func(t *testing.T) { - err := xerrors.Errorf("im an error") - err = Upgrade(err, ResourceNotFoundError{}) - err = xerrors.Errorf("wrapped http error: %w", err) - - var herr Error - require.ErrorAs(t, err, &herr, "should find http error details") - }) -} diff --git a/srverr/errors.go b/srverr/errors.go deleted file mode 100644 index 411ea05e09219..0000000000000 --- a/srverr/errors.go +++ /dev/null @@ -1,65 +0,0 @@ -package srverr - -import ( - "net/http" -) - -// SettableError describes a structured error that can accept an error. This is -// useful to prevent handlers from needing to insert the error into Upgrade -// twice. xjson.HandleError uses this interface set the final error string -// before marshaling. -type VerboseError interface { - SetVerbose(err error) -} - -// Verbose is for reusing the `verbose` field between error types. It -// implements VerboseError so it's not necessary to prefill the struct with the -// verbose error. -type Verbose struct { - Verbose string `json:"verbose"` -} - -func (e *Verbose) SetVerbose(err error) { e.Verbose = err.Error() } - -// Code is a string enum indicating the structure of the details field in an -// error response. Each error type should correspond to a unique Code. -type Code string - -const ( - CodeServerError Code = "server_error" - CodeDatabaseError Code = "database_error" - CodeResourceNotFound Code = "resource_not_found" -) - -var _ VerboseError = &ServerError{} - -// ServerError describes an error of unknown origins. -type ServerError struct { - Verbose -} - -func (*ServerError) Status() int { return http.StatusInternalServerError } -func (*ServerError) PublicMessage() string { return "An internal server error occurred." } -func (*ServerError) Code() Code { return CodeServerError } -func (*ServerError) Error() string { return "internal server error" } - -// DatabaseError describes an unknown error from the database. -type DatabaseError struct { - Verbose -} - -func (*DatabaseError) Status() int { return http.StatusInternalServerError } -func (*DatabaseError) PublicMessage() string { return "A database error occurred." } -func (*DatabaseError) Code() Code { return CodeDatabaseError } -func (*DatabaseError) Error() string { return "database error" } - -// ResourceNotFoundError describes an error when a provided resource ID was not -// found within the database or the user does not have the proper permission to -// view it. -type ResourceNotFoundError struct { -} - -func (ResourceNotFoundError) Status() int { return http.StatusNotFound } -func (e ResourceNotFoundError) PublicMessage() string { return "Resource not found." } -func (ResourceNotFoundError) Code() Code { return CodeResourceNotFound } -func (ResourceNotFoundError) Error() string { return "resource not found" } diff --git a/srverr/wrap.go b/srverr/wrap.go deleted file mode 100644 index d87229d0bbb27..0000000000000 --- a/srverr/wrap.go +++ /dev/null @@ -1,48 +0,0 @@ -package srverr - -import ( - "encoding/json" -) - -// Upgrade transparently upgrades any error chain by adding information on how -// the error should be converted into an HTTP response. Since this adds it to -// the chain transparently, there is no indication from the error string that -// it is an upgraded error. You must use xerrors.As to check if an error chain -// contains an upgraded error. -// An error may be upgraded multiple times. The last call to Upgrade will -// always be used. -func Upgrade(err error, herr Error) error { - return wrapped{ - err: err, - herr: herr, - } -} - -var _ VerboseError = wrapped{} - -type wrapped struct { - err error - herr Error -} - -// Make sure the wrapped error still behaves as if it was a regular call to -// xerrors.Errorf. -func (w wrapped) Error() string { return w.err.Error() } -func (w wrapped) Unwrap() error { return w.err } - -// Pass through srverr.Error interface functions from the underlying -// srverr.Error. -func (w wrapped) Status() int { return w.herr.Status() } -func (w wrapped) PublicMessage() string { return w.herr.PublicMessage() } -func (w wrapped) Code() Code { return w.herr.Code() } - -// When a wrapped error is marshaled, we want to make sure it marshals the -// underlying srverr.Error, not the wrapped structure. -func (w wrapped) MarshalJSON() ([]byte, error) { return json.Marshal(w.herr) } - -// If the underlying srverr.Error implements VerboseError, pass through. -func (w wrapped) SetVerbose(err error) { - if v, ok := w.herr.(VerboseError); ok { - v.SetVerbose(err) - } -} From eb46f4c56fe28c586cdc1eb7d260abae78344f19 Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Thu, 20 Jan 2022 05:34:02 +0000 Subject: [PATCH 28/41] Fix usage of render prop pattern --- site/components/PageTemplates/LoadingPage.tsx | 15 ++++++++--- site/components/Project/ProjectIcon.tsx | 25 ++++++++----------- site/pages/workspaces/create/[projectId].tsx | 17 ++++++++++--- site/pages/workspaces/create/index.tsx | 7 +++--- 4 files changed, 40 insertions(+), 24 deletions(-) diff --git a/site/components/PageTemplates/LoadingPage.tsx b/site/components/PageTemplates/LoadingPage.tsx index c10a5d1c4c754..9a18f7397937d 100644 --- a/site/components/PageTemplates/LoadingPage.tsx +++ b/site/components/PageTemplates/LoadingPage.tsx @@ -4,7 +4,8 @@ import { RequestState } from "../../hooks/useRequest" export interface LoadingPageProps<T> { request: RequestState<T> - children: (state: T) => React.ReactElement<any, any> + // Render Prop pattern: https://reactjs.org/docs/render-props.html + render: (state: T) => React.ReactElement<any, any> } const useStyles = makeStyles(() => ({ @@ -20,10 +21,18 @@ const useStyles = makeStyles(() => ({ }, })) +/** + * `<LoadingPage />` is a helper component that manages loading state when making requests. + * + * While a request is in-flight, the component will show a loading spinner. + * If a request fails, an error display will show. + * Finally, if the request succeeds, we use the Render Prop pattern to pass it off: + * https://reactjs.org/docs/render-props.html + */ export const LoadingPage: React.FC<LoadingPageProps<T>> = <T,>(props: LoadingPageProps<T>) => { const styles = useStyles() - const { request, children } = props + const { request, render } = props const { state } = request switch (state) { @@ -36,6 +45,6 @@ export const LoadingPage: React.FC<LoadingPageProps<T>> = <T,>(props: LoadingPag </div> ) case "success": - return children(request.payload) + return render(request.payload) } } diff --git a/site/components/Project/ProjectIcon.tsx b/site/components/Project/ProjectIcon.tsx index 1ecc89dea6e30..994906d9036d5 100644 --- a/site/components/Project/ProjectIcon.tsx +++ b/site/components/Project/ProjectIcon.tsx @@ -15,6 +15,14 @@ const useStyles = makeStyles((theme) => ({ cursor: "pointer", transition: "box-shadow 250ms ease-in-out", backgroundColor: "lightgrey", + flex: "0", + margin: "1em", + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + border: `1px solid ${theme.palette.primary.main}`, + borderRadius: "4px", "&:hover": { boxShadow: theme.shadows[8], }, @@ -52,22 +60,9 @@ export const ProjectIcon: React.FC<ProjectIconProps> = ({ icon, title, onClick } } return ( - <Box - css={{ - flex: "0", - margin: "1em", - display: "flex", - flexDirection: "column", - justifyContent: "center", - alignItems: "center", - border: "1px solid black", - borderRadius: "4px", - }} - className={styles.container} - onClick={onClick} - > + <div className={styles.container} onClick={onClick}> {iconComponent} <ProjectName>{title}</ProjectName> - </Box> + </div> ) } diff --git a/site/pages/workspaces/create/[projectId].tsx b/site/pages/workspaces/create/[projectId].tsx index 18e88a9db64f4..ebc95944b5489 100644 --- a/site/pages/workspaces/create/[projectId].tsx +++ b/site/pages/workspaces/create/[projectId].tsx @@ -26,6 +26,12 @@ namespace CreateProjectForm { const FormTextField = formTextFieldFactory<CreateProjectForm.Schema>() namespace Helpers { + /** + * Convert an array for project paremeters to an id -> value dictionary + * + * @param parameters An array of `ProjectParameter` + * @returns A `Record<string, string>`, where the key is a parameter id, and the value is the default value + */ export const projectParametersToValues = (parameters: API.ProjectParameter[]) => { const parameterValues: Record<string, string> = {} return parameters.reduce((acc, curr) => { @@ -38,17 +44,21 @@ namespace Helpers { } const CreateProjectPage: React.FC = () => { + // Grab the `projectId` from a route const router = useRouter() const { projectId: routeProjectId } = router.query + // ...there can be more than one specified, but we don't handle that case. const projectId = firstOrOnly(routeProjectId) const projectToLoad = useRequestor(() => API.Project.getProject("test-org", projectId), [projectId]) + // When the project is loaded, we need to pluck the default parameters out and hand them off to formik const parametersWithMetadata = projectToLoad.state === "success" ? projectToLoad.payload.parameters : [] const parameters = Helpers.projectParametersToValues(parametersWithMetadata) const form = useFormik({ enableReinitialize: true, + // TODO: Set up validation, based on form fields that come from ProjectParameters initialValues: { name: "", parameters, @@ -68,8 +78,9 @@ const CreateProjectPage: React.FC = () => { } return ( - <LoadingPage request={projectToLoad}> - {(project) => { + <LoadingPage + request={projectToLoad} + render={(project) => { const buttons: FormButton[] = [ { title: "Back", @@ -130,7 +141,7 @@ const CreateProjectPage: React.FC = () => { </FormPage> ) }} - </LoadingPage> + /> ) } diff --git a/site/pages/workspaces/create/index.tsx b/site/pages/workspaces/create/index.tsx index e0ce5c603dd3d..4a46e58a62aad 100644 --- a/site/pages/workspaces/create/index.tsx +++ b/site/pages/workspaces/create/index.tsx @@ -22,8 +22,9 @@ const CreateSelectProjectPage: React.FC = () => { } return ( - <LoadingPage request={requestState}> - {(projects) => { + <LoadingPage + request={requestState} + render={(projects) => { const buttons: FormButton[] = [ { title: "Cancel", @@ -48,7 +49,7 @@ const CreateSelectProjectPage: React.FC = () => { </FormPage> ) }} - </LoadingPage> + /> ) } From 840a796288ef7251a41b38feed6a770f3fe4cc74 Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Thu, 20 Jan 2022 05:44:56 +0000 Subject: [PATCH 29/41] Fix some compilation issues --- site/components/Form/FormTextField.tsx | 1 - site/components/PageTemplates/LoadingPage.tsx | 2 +- site/pages/workspaces/create/[projectId].tsx | 13 ++++++++----- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/site/components/Form/FormTextField.tsx b/site/components/Form/FormTextField.tsx index 5b2839d7e3584..f6a6e8f13c3bf 100644 --- a/site/components/Form/FormTextField.tsx +++ b/site/components/Form/FormTextField.tsx @@ -35,7 +35,6 @@ export interface FormTextFieldProps<T> | "SelectProps" | "style" | "type" - | "variant" >, FormFieldProps<T> { /** diff --git a/site/components/PageTemplates/LoadingPage.tsx b/site/components/PageTemplates/LoadingPage.tsx index 9a18f7397937d..61ec1f6d47c1a 100644 --- a/site/components/PageTemplates/LoadingPage.tsx +++ b/site/components/PageTemplates/LoadingPage.tsx @@ -29,7 +29,7 @@ const useStyles = makeStyles(() => ({ * Finally, if the request succeeds, we use the Render Prop pattern to pass it off: * https://reactjs.org/docs/render-props.html */ -export const LoadingPage: React.FC<LoadingPageProps<T>> = <T,>(props: LoadingPageProps<T>) => { +export const LoadingPage = <T,>(props: LoadingPageProps<T>): React.ReactElement<any, any> => { const styles = useStyles() const { request, render } = props diff --git a/site/pages/workspaces/create/[projectId].tsx b/site/pages/workspaces/create/[projectId].tsx index ebc95944b5489..bb281061f056b 100644 --- a/site/pages/workspaces/create/[projectId].tsx +++ b/site/pages/workspaces/create/[projectId].tsx @@ -1,7 +1,7 @@ import React from "react" import { useRouter } from "next/router" import { useFormik } from "formik" -import { firstOrOnly } from "./../../../util" +import { firstOrOnly, subForm, FormikLike } from "./../../../util" import * as API from "../../../api" @@ -24,6 +24,7 @@ namespace CreateProjectForm { } const FormTextField = formTextFieldFactory<CreateProjectForm.Schema>() +const ParameterTextField = formTextFieldFactory<Record<string, string>>() namespace Helpers { /** @@ -68,6 +69,8 @@ const CreateProjectPage: React.FC = () => { }, }) + const parametersForm: FormikLike<Record<string, string>> = subForm(form, "parameters") + const cancel = () => { router.push(`/workspaces/create`) } @@ -78,7 +81,7 @@ const CreateProjectPage: React.FC = () => { } return ( - <LoadingPage + <LoadingPage<API.Project> request={projectToLoad} render={(project) => { const buttons: FormButton[] = [ @@ -125,9 +128,9 @@ const CreateProjectPage: React.FC = () => { {parametersWithMetadata.map((param) => { return ( <FormRow> - <FormTextField - form={form} - formFieldName={"parameters." + param.id} + <ParameterTextField + form={parametersForm} + formFieldName={param.id} fullWidth label={param.name} helperText={param.description} From f20171f3a2cad9d048d875ac85e4fb925325635c Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Thu, 20 Jan 2022 05:45:55 +0000 Subject: [PATCH 30/41] Clean up imports --- site/components/Form/index.ts | 3 ++- site/pages/workspaces/create/[projectId].tsx | 5 +---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/site/components/Form/index.ts b/site/components/Form/index.ts index 29f7fc92fd34f..f9b0464de03ce 100644 --- a/site/components/Form/index.ts +++ b/site/components/Form/index.ts @@ -1,3 +1,4 @@ -export * from "./FormTitle" export * from "./FormRow" export * from "./FormSection" +export * from "./FormTextField" +export * from "./FormTitle" \ No newline at end of file diff --git a/site/pages/workspaces/create/[projectId].tsx b/site/pages/workspaces/create/[projectId].tsx index bb281061f056b..878ee8605c977 100644 --- a/site/pages/workspaces/create/[projectId].tsx +++ b/site/pages/workspaces/create/[projectId].tsx @@ -2,13 +2,10 @@ import React from "react" import { useRouter } from "next/router" import { useFormik } from "formik" import { firstOrOnly, subForm, FormikLike } from "./../../../util" - import * as API from "../../../api" - import { FormPage, FormButton } from "../../../components/PageTemplates" import { useRequestor } from "../../../hooks/useRequest" -import { FormSection, FormRow } from "../../../components/Form" -import { formTextFieldFactory } from "../../../components/Form/FormTextField" +import { FormSection, FormRow, formTextFieldFactory } from "../../../components/Form" import { LoadingPage } from "../../../components/PageTemplates/LoadingPage" namespace CreateProjectForm { From 03236dfc39970735c22c94464641dfadbe812adc Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Thu, 20 Jan 2022 05:49:50 +0000 Subject: [PATCH 31/41] Add some initial form tests --- package.json | 5 +- site/components/Form/FormTextField.test.tsx | 77 +++ site/util/formik.test.tsx | 709 ++++++++++++++++++++ yarn.lock | 132 +++- 4 files changed, 917 insertions(+), 6 deletions(-) create mode 100644 site/components/Form/FormTextField.test.tsx create mode 100644 site/util/formik.test.tsx diff --git a/package.json b/package.json index a35152707bf50..71bae8bfe120c 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "@material-ui/core": "4.9.4", "@material-ui/icons": "4.5.1", "@material-ui/lab": "4.0.0-alpha.42", + "@testing-library/jest-dom": "5.16.1", "@testing-library/react": "12.1.2", + "@testing-library/user-event": "13.5.0", "@types/express": "4.17.13", "@types/jest": "27.4.0", "@types/node": "14.18.4", @@ -35,7 +37,8 @@ "ts-jest": "27.1.2", "ts-loader": "9.2.6", "ts-node": "10.4.0", - "typescript": "4.5.4" + "typescript": "4.5.4", + "yup": "0.32.11" }, "dependencies": {} } diff --git a/site/components/Form/FormTextField.test.tsx b/site/components/Form/FormTextField.test.tsx new file mode 100644 index 0000000000000..87653463c68ae --- /dev/null +++ b/site/components/Form/FormTextField.test.tsx @@ -0,0 +1,77 @@ +import { act, fireEvent, render, screen } from "@testing-library/react" +import { useFormik } from "formik" +import React from "react" +import * as yup from "yup" +import { formTextFieldFactory, FormTextFieldProps } from "./FormTextField" + +namespace Helpers { + export interface FormValues { + name: string + } + + export const requiredValidationMsg = "required" + + const FormTextField = formTextFieldFactory<FormValues>() + + export const Component: React.FC<Omit<FormTextFieldProps<FormValues>, "form" | "formFieldName">> = (props) => { + const form = useFormik<FormValues>({ + initialValues: { + name: "", + }, + onSubmit: (values, helpers) => { + return helpers.setSubmitting(false) + }, + validationSchema: yup.object({ + name: yup.string().required(requiredValidationMsg), + }), + }) + + return <FormTextField {...props} form={form} formFieldName="name" /> + } +} + +describe("FormTextField", () => { + describe("helperText", () => { + it("uses helperText prop when there are no errors", () => { + // Given + const props = { + helperText: "testing", + } + + // When + const { queryByText } = render(<Helpers.Component {...props} />) + + // Then + expect(queryByText(props.helperText)).toBeDefined() + }) + + it("uses validation message when there are errors", () => { + // Given + const props = {} + + // When + const { container } = render(<Helpers.Component {...props} />) + const el = container.firstChild + + // Then + expect(el).toBeDefined() + expect(screen.queryByText(Helpers.requiredValidationMsg)).toBeNull() + + // When + act(() => { + fireEvent.focus(el as Element) + }) + + // Then + expect(screen.queryByText(Helpers.requiredValidationMsg)).toBeNull() + + // When + act(() => { + fireEvent.blur(el as Element) + }) + + // Then + expect(screen.queryByText(Helpers.requiredValidationMsg)).toBeDefined() + }) + }) +}) diff --git a/site/util/formik.test.tsx b/site/util/formik.test.tsx new file mode 100644 index 0000000000000..84bbb4a93a0e0 --- /dev/null +++ b/site/util/formik.test.tsx @@ -0,0 +1,709 @@ +import Checkbox from "@material-ui/core/Checkbox" +import "@testing-library/jest-dom" +import { fireEvent, render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { useFormik } from "formik" +import { formTextFieldFactory } from "../components/Form" +import React from "react" +import * as Yup from "yup" +import { FormikLike, subForm } from "./formik" + +/** + * SubForm + * + * A simple schema component for a small 'subform' that we'll reuse + * across a bunch of different forms. + * + * With the `subForm` API, this can act as either a top-level form, + * or can be composed as sub-forms within other forms. + */ +namespace SubForm { + export type Schema = { + firstName?: string + lastName?: string + } + + export const validator = Yup.object({ + firstName: Yup.string().required().min(3, "First name must be at least characters"), + lastName: Yup.string().required(), + }) + + export interface SubFormProps { + form: FormikLike<Schema> + } + + const FormTextField = formTextFieldFactory<Schema>() + + export const Component: React.FC<SubFormProps> = (props: SubFormProps) => { + const { form } = props + return ( + <> + <div className="firstName-container"> + <FormTextField form={form} formFieldName="firstName" helperText="Your first name" label="First Name" /> + </div> + <div className="lastName-container"> + <FormTextField form={form} formFieldName="lastName" helperText="Your last name" label="Last Name" /> + </div> + </> + ) + } +} + +namespace SubFormUsingSetValue { + export type Schema = { + count: number + } + + export const initialValues = { + count: 0, + } + + export const validator = Yup.object({ + count: Yup.number(), + }) + + export interface SubFormUsingSetValueProps { + form: FormikLike<Schema> + } + + export const Component: React.FC<SubFormUsingSetValueProps> = (props: SubFormUsingSetValueProps) => { + const { form } = props + const currentValue = form.values.count + + const incrementCount = () => { + form.setFieldValue("count", currentValue + 1) + } + + return ( + <> + <div>{"Count: " + currentValue.toString()}</div> + <input type="button" value="Click me to increment count" onClick={incrementCount} /> + </> + ) + } +} + +/** + * FormWithNestedSubForm + * + * This is an example of a form that nests the SubForm. + */ +namespace FormWithNestedSubForm { + export type Schema = { + parentField: string + subForm: SubForm.Schema + } + + export const validator = Yup.object({ + parentField: Yup.string().required(), + subForm: SubForm.validator, + }) + + export interface FormWithNestedSubFormProps { + form: FormikLike<Schema> + } + + export const Component: React.FC<FormWithNestedSubFormProps> = (props: FormWithNestedSubFormProps) => { + const { form } = props + + const nestedForm = subForm<Schema, SubForm.Schema>(form, "subForm") + return ( + <div> + <div id="parentForm"></div> + <div id="subForm"> + <SubForm.Component form={nestedForm} /> + </div> + </div> + ) + } +} + +/** + * FormWithMultipleSubforms + * + * Example of a parent form that has multiple child subforms at the same level. + */ +namespace FormWithMultipleNestedSubForms { + export type Schema = { + parentField: string + subForm1: SubForm.Schema + subForm2: SubForm.Schema + } + + export const schema = Yup.object({ + parentField: Yup.string().required(), + subForm1: SubForm.validator, + subForm2: SubForm.validator, + }) + + export interface FormWithMultipleNestedSubFormProps { + form: FormikLike<Schema> + } + + export const Component: React.FC<FormWithMultipleNestedSubFormProps> = ( + props: FormWithMultipleNestedSubFormProps, + ) => { + const { form } = props + + const nestedForm1 = subForm<Schema, SubForm.Schema>(form, "subForm1") + const nestedForm2 = subForm<Schema, SubForm.Schema>(form, "subForm2") + return ( + <div> + <div id="parentForm"></div> + <div id="subForm1"> + <SubForm.Component form={nestedForm1} /> + </div> + <div id="subForm2"> + <SubForm.Component form={nestedForm2} /> + </div> + </div> + ) + } +} + +/** + * FormWithDynamicSubForms + * + * This is intended to closely replicate the scenario we'll need for EC2 providers - + * a dynamic create-workspace form that will show an advanced section that depends on + * whether the provider is Kubernetes or EC2. + * + * This is one approach to designing a form like this, using a 'fat interface'. + * + * In this, the schema contains both EC2 | Kubernetes, and the validation + * logic switches depending on which is chosen. + */ +namespace FormWithDynamicSubForms { + export type Schema = { + isKubernetes: boolean + kubernetesMetadata: SubForm.Schema + ec2Metadata: SubForm.Schema + } + + export const schema = Yup.object({ + isKubernetes: Yup.boolean().required(), + kubernetesMetadata: Yup.mixed().when("isKubernetes", { + is: true, + then: SubForm.validator, + }), + ec2Metadata: Yup.mixed().when("isKubernetes", { + is: false, + then: SubForm.validator, + }), + }) + + export interface FormWithDynamicSubFormsProps { + form: FormikLike<Schema> + } + + export const Component: React.FC<FormWithDynamicSubFormsProps> = (props: FormWithDynamicSubFormsProps) => { + const { form } = props + + const isKubernetes = form.values.isKubernetes + + const kubernetesForm = subForm<Schema, SubForm.Schema>(form, "kubernetesMetadata") + const ec2Form = subForm<Schema, SubForm.Schema>(form, "ec2Metadata") + return ( + <div> + <div id="parentForm"> + <Checkbox id="isKubernetes" name="isKubernetes" checked={isKubernetes} onChange={form.handleChange} /> + </div> + {isKubernetes ? ( + <div id="kubernetes"> + <SubForm.Component form={kubernetesForm} /> + </div> + ) : ( + <div id="ec2"> + <SubForm.Component form={ec2Form} /> + </div> + )} + </div> + ) + } +} + +describe("formik", () => { + describe("subforms", () => { + // This test is a bit superfluous, but it's here to exhibit the difference in using the sub-form + // as a top-level form, vs some other form's subform + it("binds fields correctly as a top-level form", () => { + // Given + const TestComponent = () => { + // Initialize form + const form = useFormik<SubForm.Schema>({ + initialValues: { + firstName: "first-name", + lastName: "last-name", + }, + validationSchema: SubForm.validator, + onSubmit: () => { + return + }, + }) + + return ( + <div id="container"> + <SubForm.Component form={form} /> + </div> + ) + } + + // When + const rendered = render(<TestComponent />) + + // Then: verify form gets bound correctly + const firstNameElement = rendered.container.querySelector(".firstName-container input") as HTMLInputElement + expect(firstNameElement.value).toBe("first-name") + + const lastNameElement = rendered.container.querySelector(".lastName-container input") as HTMLInputElement + expect(lastNameElement.value).toBe("last-name") + }) + + it("binds fields correctly as a nested form", () => { + // Given + const TestComponent = () => { + const form = useFormik<FormWithNestedSubForm.Schema>({ + initialValues: { + parentField: "parent-test-value", + subForm: { + firstName: "first-name", + lastName: "last-name", + }, + }, + validationSchema: FormWithNestedSubForm.validator, + onSubmit: () => { + return + }, + }) + + return <FormWithNestedSubForm.Component form={form} /> + } + + // When + const rendered = render(<TestComponent />) + + // Then: verify form gets bound correctly + const firstNameElement = rendered.container.querySelector( + "#subForm .firstName-container input", + ) as HTMLInputElement + expect(firstNameElement.value).toBe("first-name") + + const lastNameElement = rendered.container.querySelector("#subForm .lastName-container input") as HTMLInputElement + expect(lastNameElement.value).toBe("last-name") + }) + + it("binds fields correctly with multiple nested forms", () => { + // Given + const TestComponent = () => { + const form = useFormik<FormWithMultipleNestedSubForms.Schema>({ + initialValues: { + parentField: "parent-test-value", + subForm1: { + firstName: "Arthur", + lastName: "Aardvark", + }, + subForm2: { + firstName: "Bartholomew", + lastName: "Bear", + }, + }, + validationSchema: FormWithNestedSubForm.validator, + onSubmit: () => { + return + }, + }) + + return <FormWithMultipleNestedSubForms.Component form={form} /> + } + + // When + const rendered = render(<TestComponent />) + + // Then: verify form gets bound correctly, for the first nested form + const firstNameElement1 = rendered.container.querySelector( + "#subForm1 .firstName-container input", + ) as HTMLInputElement + expect(firstNameElement1.value).toBe("Arthur") + + const lastNameElement1 = rendered.container.querySelector( + "#subForm1 .lastName-container input", + ) as HTMLInputElement + expect(lastNameElement1.value).toBe("Aardvark") + + // Verify form gets bound correctly, for the first nested form + const firstNameElement2 = rendered.container.querySelector( + "#subForm2 .firstName-container input", + ) as HTMLInputElement + expect(firstNameElement2.value).toBe("Bartholomew") + + const lastNameElement2 = rendered.container.querySelector( + "#subForm2 .lastName-container input", + ) as HTMLInputElement + expect(lastNameElement2.value).toBe("Bear") + }) + + it("dynamic subforms work correctly", async () => { + // Given + const TestComponent = () => { + const form = useFormik<FormWithDynamicSubForms.Schema>({ + initialValues: { + isKubernetes: true, + kubernetesMetadata: { + firstName: "Kubernetes", + lastName: "Provider", + }, + ec2Metadata: { + firstName: "Amazon", + lastName: "Provider", + }, + }, + validationSchema: FormWithDynamicSubForms.schema, + onSubmit: () => { + return + }, + }) + + return <FormWithDynamicSubForms.Component form={form} /> + } + + // When + const rendered = render(<TestComponent />) + + const kubernetesNameElement = rendered.container.querySelector( + "#kubernetes .firstName-container input", + ) as HTMLInputElement + expect(kubernetesNameElement.value).toBe("Kubernetes") + + const checkBox = rendered.container.querySelector("#isKubernetes") as HTMLInputElement + + fireEvent.click(checkBox) + // Wait for rendering to complete after clicking the checkbox. + // We know it's done when the 'Amazon' text input displays + await screen.findByDisplayValue("Amazon") + + // Then + // Now, we should be in EC2 mode - validate that value got bound correclty + // We're in kubernetes mode - verify values got bound correctly + const ec2NameElement = rendered.container.querySelector("#ec2 .firstName-container input") as HTMLInputElement + expect(ec2NameElement.value).toBe("Amazon") + }) + + it("nested 'touched' gets updated for subform when field is modified", async () => { + // Given + const TestComponent = () => { + // Initialize form + const form = useFormik<FormWithNestedSubForm.Schema>({ + initialValues: { + parentField: "no-op", + subForm: { + firstName: "", + lastName: "last-name", + }, + }, + validationSchema: FormWithNestedSubForm.validator, + onSubmit: () => { + return + }, + }) + + const nestedForm = subForm<FormWithNestedSubForm.Schema, SubForm.Schema>(form, "subForm") + + return ( + <div id="container"> + <div className="touched-sensor">{"Touched: " + String(!!nestedForm.touched["firstName"])}</div> + <SubForm.Component form={nestedForm} /> + </div> + ) + } + + // When + const rendered = render(<TestComponent />) + const touchSensor = rendered.container.querySelector(".touched-sensor") as HTMLDivElement + expect(touchSensor.textContent).toBe("Touched: false") + + let firstNameElement = rendered.container.querySelector(".firstName-container input") as HTMLInputElement + // ...user types coder, and leaves form field + userEvent.type(firstNameElement, "Coder") + fireEvent.blur(firstNameElement) + + // Then: Verify touched field is updated and everything is up-to-date + await screen.findByText("Touched: true") + + // Then: verify form gets bound correctly + firstNameElement = rendered.container.querySelector(".firstName-container input") as HTMLInputElement + expect(firstNameElement.value).toBe("Coder") + }) + + it.each([ + // The name field is required to be at least 3 characters, so error should be false: + ["Coder", "error: false"], + // The name field is required to be at least 3 characters, so error should be true: + ["C", "error: true"], + ])("Nested 'error' test - Typing %p should result in %p", async (textToType: string, expectedLabel: string) => { + // Given + const TestComponent = () => { + // Initialize form + const form = useFormik<FormWithNestedSubForm.Schema>({ + initialValues: { + parentField: "no-op", + subForm: { + // First name needs to be at least 3 characters + firstName: "", + lastName: "last-name", + }, + }, + validationSchema: FormWithNestedSubForm.validator, + onSubmit: () => { + return + }, + }) + + const nestedForm = subForm<FormWithNestedSubForm.Schema, SubForm.Schema>(form, "subForm") + + return ( + <div id="container"> + <div className="error-sensor">{"error: " + String(!!nestedForm.errors["firstName"])}</div> + <SubForm.Component form={nestedForm} /> + </div> + ) + } + + // When + const rendered = render(<TestComponent />) + + // Then + // ...user types coder, and leaves form field + const firstNameElement = rendered.container.querySelector(".firstName-container input") as HTMLInputElement + userEvent.type(firstNameElement, textToType) + fireEvent.blur(firstNameElement) + + const element = await screen.findByText(expectedLabel) + expect(element.textContent).toBe(expectedLabel) + }) + + it("subforms pass correct values on 'submit'", async () => { + // Given + let hitCount = 0 + let submitResult: FormWithNestedSubForm.Schema | null = null + + const onSubmit = (submitValues: FormWithNestedSubForm.Schema) => { + hitCount++ + submitResult = submitValues + } + + const TestComponent = () => { + // Initialize form + const form = useFormik<FormWithNestedSubForm.Schema>({ + initialValues: { + parentField: "no-op", + subForm: { + // First name needs to be at least 3 characters + firstName: "", + lastName: "", + }, + }, + validationSchema: FormWithNestedSubForm.validator, + + // Submit is always handled by the top-level form + onSubmit, + }) + + const nestedForm = subForm<FormWithNestedSubForm.Schema, SubForm.Schema>(form, "subForm") + + return ( + <div id="container"> + <div className="submit-sensor">{"Submits: " + String(form.submitCount)}</div> + <SubForm.Component form={nestedForm} /> + <input type="button" onClick={form.submitForm} value="Submit" /> + </div> + ) + } + + // When: User types values and submits + const rendered = render(<TestComponent />) + + const firstNameElement = rendered.container.querySelector(".firstName-container input") as HTMLInputElement + userEvent.type(firstNameElement, "Coder") + fireEvent.blur(firstNameElement) + + const lastNameElement = rendered.container.querySelector(".lastName-container input") as HTMLInputElement + userEvent.type(lastNameElement, "Rocks") + fireEvent.blur(lastNameElement) + + const submitButton = await screen.findByText("Submit") + fireEvent.click(submitButton) + + // Wait for submission to percolate through rendering + await screen.findByText("Submits: 1") + + // Then: We should've received a submit callback with correct values + expect(hitCount).toBe(1) + expect(submitResult).toEqual({ + parentField: "no-op", + subForm: { + firstName: "Coder", + lastName: "Rocks", + }, + }) + }) + + it("subforms handle setFieldValue correctly", async () => { + // Given: A form with a subform that uses `setFieldValue` + interface ParentSchema { + subForm: SubFormUsingSetValue.Schema + } + + const TestComponent = () => { + // Initialize form + const form = useFormik<ParentSchema>({ + initialValues: { + subForm: SubFormUsingSetValue.initialValues, + }, + validationSchema: Yup.object({ + subForm: SubFormUsingSetValue.validator, + }), + + // Submit is always handled by the top-level form + onSubmit: () => { + return + }, + }) + + const nestedForm = subForm<ParentSchema, SubFormUsingSetValue.Schema>(form, "subForm") + + return ( + <div id="container"> + <SubFormUsingSetValue.Component form={nestedForm} /> + </div> + ) + } + + render(<TestComponent />) + + // First render: We should find an element with 'Count: 0' (initial value) + await screen.findAllByText("Count: 0") + + // When: User clicks button, which should increment form value + const buttonElement = await screen.findByText("Click me to increment count") + fireEvent.click(buttonElement) + + // Then: The count sensor should be incremented from 0 -> 1 + await screen.findAllByText("Count: 1") + }) + + it("subforms handle setFieldTouched correctly", async () => { + // Given: A form with a subform that uses `set` + interface ParentSchema { + nestedForm: SubForm.Schema + } + + const TestComponent = () => { + // Initialize form + const form = useFormik<ParentSchema>({ + initialValues: { + nestedForm: { + firstName: "first", + lastName: "last", + }, + }, + validationSchema: Yup.object({ + nestedForm: SubForm.validator, + }), + + // Submit is always handled by the top-level form + onSubmit: () => { + return + }, + }) + + const nestedForm = subForm<ParentSchema, SubForm.Schema>(form, "nestedForm") + + const setTouched = () => { + nestedForm.setFieldTouched("firstName", true) + } + + const isFieldTouched = nestedForm.touched["firstName"] + return ( + <div id="container"> + <div className="touch-sensor">{"Touched: " + String(!!isFieldTouched)}</div> + <input type="button" onClick={setTouched} value="Click me to set touched" /> + <SubForm.Component form={nestedForm} /> + </div> + ) + } + + render(<TestComponent />) + + // First render: We should find an element with 'Count: 0' (initial value) + await screen.findAllByText("Touched: false") + + // When: User clicks button, which should increment form value + const buttonElement = await screen.findByText("Click me to set touched") + fireEvent.click(buttonElement) + + // Then: The count sensor should be incremented from 0 -> 1 + await screen.findAllByText("Touched: true") + }) + + it("multiple nesting levels are handled correctly", async () => { + // Given: A form with a subform that uses `set` + interface ParentSchema { + outerNesting: { + innerNesting: { + nestedForm: SubForm.Schema + } + } + } + + const TestComponent = () => { + // Initialize form + const form = useFormik<ParentSchema>({ + initialValues: { + outerNesting: { + innerNesting: { + nestedForm: { + firstName: "double-nested-first", + lastName: "", + }, + }, + }, + }, + validationSchema: Yup.object({ + nestedForm: SubForm.validator, + }), + + // Submit is always handled by the top-level form + onSubmit: () => { + return + }, + }) + + // Peel apart the layers of nesting, so we can validate the binding is correct... + const outerNestedForm = subForm<ParentSchema, { innerNesting: { nestedForm: SubForm.Schema } }>( + form, + "outerNesting", + ) + const innerNestedForm = subForm< + { innerNesting: { nestedForm: SubForm.Schema } }, + { nestedForm: SubForm.Schema } + >(outerNestedForm, "innerNesting") + const nestedForm = subForm<{ nestedForm: SubForm.Schema }, SubForm.Schema>(innerNestedForm, "nestedForm") + + return ( + <div id="container"> + <div data-testid="field-id-sensor">{"FieldId: " + FormikLike.getFieldId(nestedForm, "firstName")}</div> + <SubForm.Component form={nestedForm} /> + </div> + ) + } + + // When + render(<TestComponent />) + + // Then: + // The form element should be bound correctly + const element = await screen.findByTestId("field-id-sensor") + expect(element.textContent).toBe("FieldId: outerNesting.innerNesting.nestedForm.firstName") + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index 96101344e49ff..e66d71fff8ec3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -269,7 +269,7 @@ dependencies: regenerator-runtime "^0.13.4" -"@babel/runtime@^7.12.5", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.7": +"@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": version "7.16.7" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.7.tgz#03ff99f64106588c9c403c6ecb8c3bafbbdff1fa" integrity sha512-9E9FJowqAsytyOY6LG+1KuueckRL+aQW+mKvXRXnuFGyRAyepJPmEo9vgMfXUA6O9u3IeEdv9MAkppFcaQwogQ== @@ -746,6 +746,21 @@ lz-string "^1.4.4" pretty-format "^27.0.2" +"@testing-library/jest-dom@5.16.1": + version "5.16.1" + resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.16.1.tgz#3db7df5ae97596264a7da9696fe14695ba02e51f" + integrity sha512-ajUJdfDIuTCadB79ukO+0l8O+QwN0LiSxDaYUTI4LndbbUsGi6rWU1SCexXzBA2NSjlVB9/vbkasQIL3tmPBjw== + dependencies: + "@babel/runtime" "^7.9.2" + "@types/testing-library__jest-dom" "^5.9.1" + aria-query "^5.0.0" + chalk "^3.0.0" + css "^3.0.0" + css.escape "^1.5.1" + dom-accessibility-api "^0.5.6" + lodash "^4.17.15" + redent "^3.0.0" + "@testing-library/react@12.1.2": version "12.1.2" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.2.tgz#f1bc9a45943461fa2a598bb4597df1ae044cfc76" @@ -754,6 +769,13 @@ "@babel/runtime" "^7.12.5" "@testing-library/dom" "^8.0.0" +"@testing-library/user-event@13.5.0": + version "13.5.0" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-13.5.0.tgz#69d77007f1e124d55314a2b73fd204b333b13295" + integrity sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg== + dependencies: + "@babel/runtime" "^7.12.5" + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" @@ -889,7 +911,7 @@ dependencies: "@types/istanbul-lib-report" "*" -"@types/jest@27.4.0": +"@types/jest@*", "@types/jest@27.4.0": version "27.4.0" resolved "https://registry.yarnpkg.com/@types/jest/-/jest-27.4.0.tgz#037ab8b872067cae842a320841693080f9cb84ed" integrity sha512-gHl8XuC1RZ8H2j5sHv/JqsaxXkDDM9iDOgu0Wp8sjs4u/snb2PVehyWXJPr+ORA0RPpgw231mnutWI1+0hgjIQ== @@ -897,6 +919,11 @@ jest-diff "^27.0.0" pretty-format "^27.0.0" +"@types/lodash@^4.14.175": + version "4.14.178" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.178.tgz#341f6d2247db528d4a13ddbb374bcdc80406f4f8" + integrity sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw== + "@types/mime@^1": version "1.3.2" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" @@ -981,6 +1008,13 @@ "@types/cookiejar" "*" "@types/node" "*" +"@types/testing-library__jest-dom@^5.9.1": + version "5.14.2" + resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.2.tgz#564fb2b2dc827147e937a75b639a05d17ce18b44" + integrity sha512-vehbtyHUShPxIa9SioxDwCvgxukDMH//icJG90sXQBUm5lJOHLT5kNeU9tnivhnA/TkOFMzGIXN2cTc4hY8/kg== + dependencies: + "@types/jest" "*" + "@types/yargs-parser@*": version "20.2.1" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.1.tgz#3b9ce2489919d9e4fea439b76916abc34b2df129" @@ -1137,6 +1171,11 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= +atob@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" + integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== + available-typed-arrays@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7" @@ -1454,6 +1493,14 @@ chalk@4.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chalk@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + chalk@^4.0.0, chalk@^4.1.0: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" @@ -1686,11 +1733,20 @@ css-vendor@^2.0.8: "@babel/runtime" "^7.8.3" is-in-browser "^1.0.2" -css.escape@1.5.1: +css.escape@1.5.1, css.escape@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" integrity sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s= +css@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/css/-/css-3.0.0.tgz#4447a4d58fdd03367c516ca9f64ae365cee4aa5d" + integrity sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ== + dependencies: + inherits "^2.0.4" + source-map "^0.6.1" + source-map-resolve "^0.6.0" + cssnano-preset-simple@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/cssnano-preset-simple/-/cssnano-preset-simple-3.0.0.tgz#e95d0012699ca2c741306e9a3b8eeb495a348dbe" @@ -1765,6 +1821,11 @@ decimal.js@^10.2.1: resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.3.1.tgz#d8c3a444a9c6774ba60ca6ad7261c3a94fd5e783" integrity sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ== +decode-uri-component@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" + integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= + dedent@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" @@ -1839,7 +1900,7 @@ diffie-hellman@^5.0.0: miller-rabin "^4.0.0" randombytes "^2.0.0" -dom-accessibility-api@^0.5.9: +dom-accessibility-api@^0.5.6, dom-accessibility-api@^0.5.9: version "0.5.10" resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.10.tgz#caa6d08f60388d0bb4539dd75fe458a9a1d0014c" integrity sha512-Xu9mD0UjrJisTmv7lmVSDMagQcU9R5hwAbxsaAE/35XPnPLJobbuREfV/rraiSaEj/UOvgrzQs66zyTWTlyd+g== @@ -2489,6 +2550,11 @@ imurmurhash@^0.1.4: resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= +indent-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" + integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== + inflight@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" @@ -3333,7 +3399,7 @@ lodash.sortby@^4.7.0: resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= -lodash@^4.17.21, lodash@^4.7.0: +lodash@^4.17.15, lodash@^4.17.21, lodash@^4.7.0: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -3443,6 +3509,11 @@ mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== +min-indent@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" + integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== + minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" @@ -3480,6 +3551,11 @@ ms@2.1.3: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +nanoclone@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/nanoclone/-/nanoclone-0.2.1.tgz#dd4090f8f1a110d26bb32c49ed2f5b9235209ed4" + integrity sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA== + nanoid@^3.1.23: version "3.1.30" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.30.tgz#63f93cc548d2a113dc5dfbc63bfa09e2b9b64362" @@ -3852,6 +3928,11 @@ prop-types@^15.6.2, prop-types@^15.7.2: object-assign "^4.1.1" react-is "^16.13.1" +property-expr@^2.0.4: + version "2.0.5" + resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.5.tgz#278bdb15308ae16af3e3b9640024524f4dc02cb4" + integrity sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA== + proxy-addr@~2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" @@ -4002,6 +4083,14 @@ readdirp@~3.5.0: dependencies: picomatch "^2.2.1" +redent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" + integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg== + dependencies: + indent-string "^4.0.0" + strip-indent "^3.0.0" + regenerator-runtime@0.13.4: version "0.13.4" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.4.tgz#e96bf612a3362d12bb69f7e8f74ffeab25c7ac91" @@ -4198,6 +4287,14 @@ slash@^3.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== +source-map-resolve@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.6.0.tgz#3d9df87e236b53f16d01e58150fc7711138e5ed2" + integrity sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w== + dependencies: + atob "^2.1.2" + decode-uri-component "^0.2.0" + source-map-support@^0.5.6: version "0.5.21" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" @@ -4339,6 +4436,13 @@ strip-final-newline@^2.0.0: resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== +strip-indent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001" + integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ== + dependencies: + min-indent "^1.0.0" + styled-jsx@5.0.0-beta.3: version "5.0.0-beta.3" resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.0.0-beta.3.tgz#400d16179b5dff10d5954ab8be27a9a1b7780dd2" @@ -4468,6 +4572,11 @@ toidentifier@1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== +toposort@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330" + integrity sha1-riF2gXXRVZ1IvvNUILL0li8JwzA= + tough-cookie@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4" @@ -4843,3 +4952,16 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +yup@0.32.11: + version "0.32.11" + resolved "https://registry.yarnpkg.com/yup/-/yup-0.32.11.tgz#d67fb83eefa4698607982e63f7ca4c5ed3cf18c5" + integrity sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg== + dependencies: + "@babel/runtime" "^7.15.4" + "@types/lodash" "^4.14.175" + lodash "^4.17.21" + lodash-es "^4.17.21" + nanoclone "^0.2.1" + property-expr "^2.0.4" + toposort "^2.0.2" From 61cd37f1b6711716ed0023c932feab6d4e3e8aae Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Thu, 20 Jan 2022 05:55:36 +0000 Subject: [PATCH 32/41] Add smoke test for ProjectIcon --- jest.config.js | 1 + site/components/Form/index.ts | 2 +- site/components/Project/ProjectIcon.test.tsx | 15 +++++++++++++++ site/util/firstOrOnly.ts | 5 +++++ 4 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 site/components/Project/ProjectIcon.test.tsx diff --git a/jest.config.js b/jest.config.js index e48b7b593a558..cf5d63467f0b6 100644 --- a/jest.config.js +++ b/jest.config.js @@ -20,6 +20,7 @@ module.exports = { "<rootDir>/site/**/*.tsx", "!<rootDir>/site/**/*.stories.tsx", "!<rootDir>/site/.next/**/*.*", + "!<rootDir>/site/out/**/*.*", "!<rootDir>/site/dev.ts", "!<rootDir>/site/next-env.d.ts", "!<rootDir>/site/next.config.js", diff --git a/site/components/Form/index.ts b/site/components/Form/index.ts index f9b0464de03ce..21dfa39b91e2b 100644 --- a/site/components/Form/index.ts +++ b/site/components/Form/index.ts @@ -1,4 +1,4 @@ export * from "./FormRow" export * from "./FormSection" export * from "./FormTextField" -export * from "./FormTitle" \ No newline at end of file +export * from "./FormTitle" diff --git a/site/components/Project/ProjectIcon.test.tsx b/site/components/Project/ProjectIcon.test.tsx new file mode 100644 index 0000000000000..abdeea7e9bb31 --- /dev/null +++ b/site/components/Project/ProjectIcon.test.tsx @@ -0,0 +1,15 @@ +import React from "react" +import { screen } from "@testing-library/react" + +import { render } from "../../test_helpers" +import { ProjectIcon } from "./ProjectIcon" + +describe("ProjectIcon", () => { + it("renders content", async () => { + // When + render(<ProjectIcon title="Test Title" onClick={() => { return }} />) + + // Then + await screen.findByText("Test Title", { exact: false }) + }) +}) diff --git a/site/util/firstOrOnly.ts b/site/util/firstOrOnly.ts index 61e26ff679e66..6df68f11f1efc 100644 --- a/site/util/firstOrOnly.ts +++ b/site/util/firstOrOnly.ts @@ -1,3 +1,8 @@ +/** + * `firstOrOnly` handles disambiguation of a value that is either a single item or array. + * + * If an array is passed in, the first item will be returned. + */ export const firstOrOnly = <T>(itemOrItems: T | T[]) => { if (Array.isArray(itemOrItems)) { return itemOrItems[0] From 99d0a9574d4aa7fc71f6447b3941b348306e6043 Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Thu, 20 Jan 2022 05:58:07 +0000 Subject: [PATCH 33/41] Add AppPage smoke test --- .../PageTemplates/{Footer.test.tsx => AppPage.test.tsx} | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) rename site/components/PageTemplates/{Footer.test.tsx => AppPage.test.tsx} (51%) diff --git a/site/components/PageTemplates/Footer.test.tsx b/site/components/PageTemplates/AppPage.test.tsx similarity index 51% rename from site/components/PageTemplates/Footer.test.tsx rename to site/components/PageTemplates/AppPage.test.tsx index 32d710d1ad564..1929f050b0cdc 100644 --- a/site/components/PageTemplates/Footer.test.tsx +++ b/site/components/PageTemplates/AppPage.test.tsx @@ -2,14 +2,17 @@ import React from "react" import { screen } from "@testing-library/react" import { render } from "../../test_helpers" -import { Footer } from "./Footer" +import { AppPage } from "./AppPage" -describe("Footer", () => { +describe("AppPage", () => { it("renders content", async () => { // When - render(<Footer />) + render(<AppPage><div>Hello, World</div>H</AppPage>) // Then + // Content should render + await screen.findByText("Hello, World", { exact: false }) + // Footer should render await screen.findByText("Copyright", { exact: false }) }) }) From d52bd385660780051ae6d92595bf7fc22b6c4464 Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Thu, 20 Jan 2022 06:00:31 +0000 Subject: [PATCH 34/41] Fix up formatting --- site/components/PageTemplates/AppPage.test.tsx | 6 +++++- site/components/PageTemplates/LoadingPage.tsx | 2 +- site/components/Project/ProjectIcon.test.tsx | 9 ++++++++- site/hooks/{useRequest.ts => useRequestor.ts} | 0 site/pages/workspaces/create/[projectId].tsx | 2 +- site/pages/workspaces/create/index.tsx | 2 +- 6 files changed, 16 insertions(+), 5 deletions(-) rename site/hooks/{useRequest.ts => useRequestor.ts} (100%) diff --git a/site/components/PageTemplates/AppPage.test.tsx b/site/components/PageTemplates/AppPage.test.tsx index 1929f050b0cdc..9a1feb1f1cf54 100644 --- a/site/components/PageTemplates/AppPage.test.tsx +++ b/site/components/PageTemplates/AppPage.test.tsx @@ -7,7 +7,11 @@ import { AppPage } from "./AppPage" describe("AppPage", () => { it("renders content", async () => { // When - render(<AppPage><div>Hello, World</div>H</AppPage>) + render( + <AppPage> + <div>Hello, World</div>H + </AppPage>, + ) // Then // Content should render diff --git a/site/components/PageTemplates/LoadingPage.tsx b/site/components/PageTemplates/LoadingPage.tsx index 61ec1f6d47c1a..59fc58d013da7 100644 --- a/site/components/PageTemplates/LoadingPage.tsx +++ b/site/components/PageTemplates/LoadingPage.tsx @@ -1,6 +1,6 @@ import { Box, CircularProgress, makeStyles } from "@material-ui/core" import React from "react" -import { RequestState } from "../../hooks/useRequest" +import { RequestState } from "../../hooks/useRequestor" export interface LoadingPageProps<T> { request: RequestState<T> diff --git a/site/components/Project/ProjectIcon.test.tsx b/site/components/Project/ProjectIcon.test.tsx index abdeea7e9bb31..bc5cfb0a31bfd 100644 --- a/site/components/Project/ProjectIcon.test.tsx +++ b/site/components/Project/ProjectIcon.test.tsx @@ -7,7 +7,14 @@ import { ProjectIcon } from "./ProjectIcon" describe("ProjectIcon", () => { it("renders content", async () => { // When - render(<ProjectIcon title="Test Title" onClick={() => { return }} />) + render( + <ProjectIcon + title="Test Title" + onClick={() => { + return + }} + />, + ) // Then await screen.findByText("Test Title", { exact: false }) diff --git a/site/hooks/useRequest.ts b/site/hooks/useRequestor.ts similarity index 100% rename from site/hooks/useRequest.ts rename to site/hooks/useRequestor.ts diff --git a/site/pages/workspaces/create/[projectId].tsx b/site/pages/workspaces/create/[projectId].tsx index 878ee8605c977..47703559f03b5 100644 --- a/site/pages/workspaces/create/[projectId].tsx +++ b/site/pages/workspaces/create/[projectId].tsx @@ -4,7 +4,7 @@ import { useFormik } from "formik" import { firstOrOnly, subForm, FormikLike } from "./../../../util" import * as API from "../../../api" import { FormPage, FormButton } from "../../../components/PageTemplates" -import { useRequestor } from "../../../hooks/useRequest" +import { useRequestor } from "../../../hooks/useRequestor" import { FormSection, FormRow, formTextFieldFactory } from "../../../components/Form" import { LoadingPage } from "../../../components/PageTemplates/LoadingPage" diff --git a/site/pages/workspaces/create/index.tsx b/site/pages/workspaces/create/index.tsx index 4a46e58a62aad..0cada920638bf 100644 --- a/site/pages/workspaces/create/index.tsx +++ b/site/pages/workspaces/create/index.tsx @@ -2,7 +2,7 @@ import React from "react" import { useRouter } from "next/router" import { FormPage, FormButton } from "../../../components/PageTemplates" -import { useRequestor } from "../../../hooks/useRequest" +import { useRequestor } from "../../../hooks/useRequestor" import * as Api from "./../../../api" import CircularProgress from "@material-ui/core/CircularProgress" import { ProjectIcon } from "../../../components/Project/ProjectIcon" From 76d11d4af25dadc37361c9f9a0a05b52d451a530 Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Thu, 20 Jan 2022 06:03:33 +0000 Subject: [PATCH 35/41] Remove api from collection metrics since it is temporary --- jest.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/jest.config.js b/jest.config.js index cf5d63467f0b6..6ecd40fc0d0c4 100644 --- a/jest.config.js +++ b/jest.config.js @@ -21,6 +21,7 @@ module.exports = { "!<rootDir>/site/**/*.stories.tsx", "!<rootDir>/site/.next/**/*.*", "!<rootDir>/site/out/**/*.*", + "!<rootDir>/site/api.ts", "!<rootDir>/site/dev.ts", "!<rootDir>/site/next-env.d.ts", "!<rootDir>/site/next.config.js", From ffac47566d2173a4235cfe50a759f0e62db21459 Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Thu, 20 Jan 2022 06:03:41 +0000 Subject: [PATCH 36/41] Clean up unused import --- site/pages/workspaces/create/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/site/pages/workspaces/create/index.tsx b/site/pages/workspaces/create/index.tsx index 0cada920638bf..b0db48ac4ca4a 100644 --- a/site/pages/workspaces/create/index.tsx +++ b/site/pages/workspaces/create/index.tsx @@ -4,7 +4,6 @@ import { useRouter } from "next/router" import { FormPage, FormButton } from "../../../components/PageTemplates" import { useRequestor } from "../../../hooks/useRequestor" import * as Api from "./../../../api" -import CircularProgress from "@material-ui/core/CircularProgress" import { ProjectIcon } from "../../../components/Project/ProjectIcon" import Box from "@material-ui/core/Box" import { LoadingPage } from "../../../components/PageTemplates/LoadingPage" From e95d75f5e0b033ff78e33c6f6d9e3ae1986a088d Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Sat, 22 Jan 2022 05:03:31 +0000 Subject: [PATCH 37/41] Remove and consolidate form ty pes --- site/components/Form/FormTextField.tsx | 75 +- site/components/Form/types.ts | 16 - site/pages/workspaces/create/[projectId].tsx | 10 +- site/util/formik.test.tsx | 709 ------------------- site/util/formik.ts | 125 ---- site/util/index.ts | 1 - 6 files changed, 49 insertions(+), 887 deletions(-) delete mode 100644 site/components/Form/types.ts delete mode 100644 site/util/formik.test.tsx delete mode 100644 site/util/formik.ts diff --git a/site/components/Form/FormTextField.tsx b/site/components/Form/FormTextField.tsx index f6a6e8f13c3bf..b8b1add664239 100644 --- a/site/components/Form/FormTextField.tsx +++ b/site/components/Form/FormTextField.tsx @@ -1,8 +1,23 @@ import TextField, { TextFieldProps } from "@material-ui/core/TextField" -import { FormikLike } from "../../util/formik" +import { FormikContextType as FormikLike } from "formik" import React from "react" import { PasswordField } from "./PasswordField" -import { FormFieldProps } from "./types" + +/** + * FormFieldProps are required props for creating form fields using a factory. + */ +export interface FormFieldProps<T> { + /** + * form is a reference to a form or subform and is used to compute common + * states such as error and helper text + */ + form: FormikLike<T> + /** + * formFieldName is a field name associated with the form schema. + */ + formFieldName: keyof T +} + /** * FormTextFieldProps extends form-related MUI TextFieldProps with Formik @@ -12,31 +27,31 @@ import { FormFieldProps } from "./types" */ export interface FormTextFieldProps<T> extends Pick< - TextFieldProps, - | "autoComplete" - | "autoFocus" - | "children" - | "className" - | "disabled" - | "fullWidth" - | "helperText" - | "id" - | "InputLabelProps" - | "InputProps" - | "inputProps" - | "label" - | "margin" - | "multiline" - | "onChange" - | "placeholder" - | "required" - | "rows" - | "select" - | "SelectProps" - | "style" - | "type" - >, - FormFieldProps<T> { + TextFieldProps, + | "autoComplete" + | "autoFocus" + | "children" + | "className" + | "disabled" + | "fullWidth" + | "helperText" + | "id" + | "InputLabelProps" + | "InputProps" + | "inputProps" + | "label" + | "margin" + | "multiline" + | "onChange" + | "placeholder" + | "required" + | "rows" + | "select" + | "SelectProps" + | "style" + | "type" + >, + FormFieldProps<T> { /** * eventTransform is an optional transformer on the event data before it is * processed by formik. @@ -105,9 +120,9 @@ export const formTextFieldFactory = <T,>(): React.FC<FormTextFieldProps<T>> => { }) => { const isError = form.touched[formFieldName] && Boolean(form.errors[formFieldName]) - // Conversion to a string primitive is necessary as formFieldName is an in - // indexable type such as a string, number or enum. - const fieldId = FormikLike.getFieldId<T>(form, String(formFieldName)) + // TODO: When we need to bring back FormikLike / subforms, use + // the `FormikLike.getFieldId` helper. + const fieldId = String(formFieldName) const Component = isPassword ? PasswordField : TextField const inputType = isPassword ? undefined : type diff --git a/site/components/Form/types.ts b/site/components/Form/types.ts deleted file mode 100644 index bc30b42d424e7..0000000000000 --- a/site/components/Form/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { FormikLike } from "../../util/formik" - -/** - * FormFieldProps are required props for creating form fields using a factory. - */ -export interface FormFieldProps<T> { - /** - * form is a reference to a form or subform and is used to compute common - * states such as error and helper text - */ - form: FormikLike<T> - /** - * formFieldName is a field name associated with the form schema. - */ - formFieldName: keyof T -} diff --git a/site/pages/workspaces/create/[projectId].tsx b/site/pages/workspaces/create/[projectId].tsx index 47703559f03b5..35f977aa42739 100644 --- a/site/pages/workspaces/create/[projectId].tsx +++ b/site/pages/workspaces/create/[projectId].tsx @@ -1,7 +1,7 @@ import React from "react" import { useRouter } from "next/router" -import { useFormik } from "formik" -import { firstOrOnly, subForm, FormikLike } from "./../../../util" +import { useFormik, FormikContext } from "formik" +import { firstOrOnly } from "./../../../util" import * as API from "../../../api" import { FormPage, FormButton } from "../../../components/PageTemplates" import { useRequestor } from "../../../hooks/useRequestor" @@ -66,8 +66,6 @@ const CreateProjectPage: React.FC = () => { }, }) - const parametersForm: FormikLike<Record<string, string>> = subForm(form, "parameters") - const cancel = () => { router.push(`/workspaces/create`) } @@ -126,8 +124,8 @@ const CreateProjectPage: React.FC = () => { return ( <FormRow> <ParameterTextField - form={parametersForm} - formFieldName={param.id} + form={form} + formFieldName={"parameters." + param.id} fullWidth label={param.name} helperText={param.description} diff --git a/site/util/formik.test.tsx b/site/util/formik.test.tsx deleted file mode 100644 index 84bbb4a93a0e0..0000000000000 --- a/site/util/formik.test.tsx +++ /dev/null @@ -1,709 +0,0 @@ -import Checkbox from "@material-ui/core/Checkbox" -import "@testing-library/jest-dom" -import { fireEvent, render, screen } from "@testing-library/react" -import userEvent from "@testing-library/user-event" -import { useFormik } from "formik" -import { formTextFieldFactory } from "../components/Form" -import React from "react" -import * as Yup from "yup" -import { FormikLike, subForm } from "./formik" - -/** - * SubForm - * - * A simple schema component for a small 'subform' that we'll reuse - * across a bunch of different forms. - * - * With the `subForm` API, this can act as either a top-level form, - * or can be composed as sub-forms within other forms. - */ -namespace SubForm { - export type Schema = { - firstName?: string - lastName?: string - } - - export const validator = Yup.object({ - firstName: Yup.string().required().min(3, "First name must be at least characters"), - lastName: Yup.string().required(), - }) - - export interface SubFormProps { - form: FormikLike<Schema> - } - - const FormTextField = formTextFieldFactory<Schema>() - - export const Component: React.FC<SubFormProps> = (props: SubFormProps) => { - const { form } = props - return ( - <> - <div className="firstName-container"> - <FormTextField form={form} formFieldName="firstName" helperText="Your first name" label="First Name" /> - </div> - <div className="lastName-container"> - <FormTextField form={form} formFieldName="lastName" helperText="Your last name" label="Last Name" /> - </div> - </> - ) - } -} - -namespace SubFormUsingSetValue { - export type Schema = { - count: number - } - - export const initialValues = { - count: 0, - } - - export const validator = Yup.object({ - count: Yup.number(), - }) - - export interface SubFormUsingSetValueProps { - form: FormikLike<Schema> - } - - export const Component: React.FC<SubFormUsingSetValueProps> = (props: SubFormUsingSetValueProps) => { - const { form } = props - const currentValue = form.values.count - - const incrementCount = () => { - form.setFieldValue("count", currentValue + 1) - } - - return ( - <> - <div>{"Count: " + currentValue.toString()}</div> - <input type="button" value="Click me to increment count" onClick={incrementCount} /> - </> - ) - } -} - -/** - * FormWithNestedSubForm - * - * This is an example of a form that nests the SubForm. - */ -namespace FormWithNestedSubForm { - export type Schema = { - parentField: string - subForm: SubForm.Schema - } - - export const validator = Yup.object({ - parentField: Yup.string().required(), - subForm: SubForm.validator, - }) - - export interface FormWithNestedSubFormProps { - form: FormikLike<Schema> - } - - export const Component: React.FC<FormWithNestedSubFormProps> = (props: FormWithNestedSubFormProps) => { - const { form } = props - - const nestedForm = subForm<Schema, SubForm.Schema>(form, "subForm") - return ( - <div> - <div id="parentForm"></div> - <div id="subForm"> - <SubForm.Component form={nestedForm} /> - </div> - </div> - ) - } -} - -/** - * FormWithMultipleSubforms - * - * Example of a parent form that has multiple child subforms at the same level. - */ -namespace FormWithMultipleNestedSubForms { - export type Schema = { - parentField: string - subForm1: SubForm.Schema - subForm2: SubForm.Schema - } - - export const schema = Yup.object({ - parentField: Yup.string().required(), - subForm1: SubForm.validator, - subForm2: SubForm.validator, - }) - - export interface FormWithMultipleNestedSubFormProps { - form: FormikLike<Schema> - } - - export const Component: React.FC<FormWithMultipleNestedSubFormProps> = ( - props: FormWithMultipleNestedSubFormProps, - ) => { - const { form } = props - - const nestedForm1 = subForm<Schema, SubForm.Schema>(form, "subForm1") - const nestedForm2 = subForm<Schema, SubForm.Schema>(form, "subForm2") - return ( - <div> - <div id="parentForm"></div> - <div id="subForm1"> - <SubForm.Component form={nestedForm1} /> - </div> - <div id="subForm2"> - <SubForm.Component form={nestedForm2} /> - </div> - </div> - ) - } -} - -/** - * FormWithDynamicSubForms - * - * This is intended to closely replicate the scenario we'll need for EC2 providers - - * a dynamic create-workspace form that will show an advanced section that depends on - * whether the provider is Kubernetes or EC2. - * - * This is one approach to designing a form like this, using a 'fat interface'. - * - * In this, the schema contains both EC2 | Kubernetes, and the validation - * logic switches depending on which is chosen. - */ -namespace FormWithDynamicSubForms { - export type Schema = { - isKubernetes: boolean - kubernetesMetadata: SubForm.Schema - ec2Metadata: SubForm.Schema - } - - export const schema = Yup.object({ - isKubernetes: Yup.boolean().required(), - kubernetesMetadata: Yup.mixed().when("isKubernetes", { - is: true, - then: SubForm.validator, - }), - ec2Metadata: Yup.mixed().when("isKubernetes", { - is: false, - then: SubForm.validator, - }), - }) - - export interface FormWithDynamicSubFormsProps { - form: FormikLike<Schema> - } - - export const Component: React.FC<FormWithDynamicSubFormsProps> = (props: FormWithDynamicSubFormsProps) => { - const { form } = props - - const isKubernetes = form.values.isKubernetes - - const kubernetesForm = subForm<Schema, SubForm.Schema>(form, "kubernetesMetadata") - const ec2Form = subForm<Schema, SubForm.Schema>(form, "ec2Metadata") - return ( - <div> - <div id="parentForm"> - <Checkbox id="isKubernetes" name="isKubernetes" checked={isKubernetes} onChange={form.handleChange} /> - </div> - {isKubernetes ? ( - <div id="kubernetes"> - <SubForm.Component form={kubernetesForm} /> - </div> - ) : ( - <div id="ec2"> - <SubForm.Component form={ec2Form} /> - </div> - )} - </div> - ) - } -} - -describe("formik", () => { - describe("subforms", () => { - // This test is a bit superfluous, but it's here to exhibit the difference in using the sub-form - // as a top-level form, vs some other form's subform - it("binds fields correctly as a top-level form", () => { - // Given - const TestComponent = () => { - // Initialize form - const form = useFormik<SubForm.Schema>({ - initialValues: { - firstName: "first-name", - lastName: "last-name", - }, - validationSchema: SubForm.validator, - onSubmit: () => { - return - }, - }) - - return ( - <div id="container"> - <SubForm.Component form={form} /> - </div> - ) - } - - // When - const rendered = render(<TestComponent />) - - // Then: verify form gets bound correctly - const firstNameElement = rendered.container.querySelector(".firstName-container input") as HTMLInputElement - expect(firstNameElement.value).toBe("first-name") - - const lastNameElement = rendered.container.querySelector(".lastName-container input") as HTMLInputElement - expect(lastNameElement.value).toBe("last-name") - }) - - it("binds fields correctly as a nested form", () => { - // Given - const TestComponent = () => { - const form = useFormik<FormWithNestedSubForm.Schema>({ - initialValues: { - parentField: "parent-test-value", - subForm: { - firstName: "first-name", - lastName: "last-name", - }, - }, - validationSchema: FormWithNestedSubForm.validator, - onSubmit: () => { - return - }, - }) - - return <FormWithNestedSubForm.Component form={form} /> - } - - // When - const rendered = render(<TestComponent />) - - // Then: verify form gets bound correctly - const firstNameElement = rendered.container.querySelector( - "#subForm .firstName-container input", - ) as HTMLInputElement - expect(firstNameElement.value).toBe("first-name") - - const lastNameElement = rendered.container.querySelector("#subForm .lastName-container input") as HTMLInputElement - expect(lastNameElement.value).toBe("last-name") - }) - - it("binds fields correctly with multiple nested forms", () => { - // Given - const TestComponent = () => { - const form = useFormik<FormWithMultipleNestedSubForms.Schema>({ - initialValues: { - parentField: "parent-test-value", - subForm1: { - firstName: "Arthur", - lastName: "Aardvark", - }, - subForm2: { - firstName: "Bartholomew", - lastName: "Bear", - }, - }, - validationSchema: FormWithNestedSubForm.validator, - onSubmit: () => { - return - }, - }) - - return <FormWithMultipleNestedSubForms.Component form={form} /> - } - - // When - const rendered = render(<TestComponent />) - - // Then: verify form gets bound correctly, for the first nested form - const firstNameElement1 = rendered.container.querySelector( - "#subForm1 .firstName-container input", - ) as HTMLInputElement - expect(firstNameElement1.value).toBe("Arthur") - - const lastNameElement1 = rendered.container.querySelector( - "#subForm1 .lastName-container input", - ) as HTMLInputElement - expect(lastNameElement1.value).toBe("Aardvark") - - // Verify form gets bound correctly, for the first nested form - const firstNameElement2 = rendered.container.querySelector( - "#subForm2 .firstName-container input", - ) as HTMLInputElement - expect(firstNameElement2.value).toBe("Bartholomew") - - const lastNameElement2 = rendered.container.querySelector( - "#subForm2 .lastName-container input", - ) as HTMLInputElement - expect(lastNameElement2.value).toBe("Bear") - }) - - it("dynamic subforms work correctly", async () => { - // Given - const TestComponent = () => { - const form = useFormik<FormWithDynamicSubForms.Schema>({ - initialValues: { - isKubernetes: true, - kubernetesMetadata: { - firstName: "Kubernetes", - lastName: "Provider", - }, - ec2Metadata: { - firstName: "Amazon", - lastName: "Provider", - }, - }, - validationSchema: FormWithDynamicSubForms.schema, - onSubmit: () => { - return - }, - }) - - return <FormWithDynamicSubForms.Component form={form} /> - } - - // When - const rendered = render(<TestComponent />) - - const kubernetesNameElement = rendered.container.querySelector( - "#kubernetes .firstName-container input", - ) as HTMLInputElement - expect(kubernetesNameElement.value).toBe("Kubernetes") - - const checkBox = rendered.container.querySelector("#isKubernetes") as HTMLInputElement - - fireEvent.click(checkBox) - // Wait for rendering to complete after clicking the checkbox. - // We know it's done when the 'Amazon' text input displays - await screen.findByDisplayValue("Amazon") - - // Then - // Now, we should be in EC2 mode - validate that value got bound correclty - // We're in kubernetes mode - verify values got bound correctly - const ec2NameElement = rendered.container.querySelector("#ec2 .firstName-container input") as HTMLInputElement - expect(ec2NameElement.value).toBe("Amazon") - }) - - it("nested 'touched' gets updated for subform when field is modified", async () => { - // Given - const TestComponent = () => { - // Initialize form - const form = useFormik<FormWithNestedSubForm.Schema>({ - initialValues: { - parentField: "no-op", - subForm: { - firstName: "", - lastName: "last-name", - }, - }, - validationSchema: FormWithNestedSubForm.validator, - onSubmit: () => { - return - }, - }) - - const nestedForm = subForm<FormWithNestedSubForm.Schema, SubForm.Schema>(form, "subForm") - - return ( - <div id="container"> - <div className="touched-sensor">{"Touched: " + String(!!nestedForm.touched["firstName"])}</div> - <SubForm.Component form={nestedForm} /> - </div> - ) - } - - // When - const rendered = render(<TestComponent />) - const touchSensor = rendered.container.querySelector(".touched-sensor") as HTMLDivElement - expect(touchSensor.textContent).toBe("Touched: false") - - let firstNameElement = rendered.container.querySelector(".firstName-container input") as HTMLInputElement - // ...user types coder, and leaves form field - userEvent.type(firstNameElement, "Coder") - fireEvent.blur(firstNameElement) - - // Then: Verify touched field is updated and everything is up-to-date - await screen.findByText("Touched: true") - - // Then: verify form gets bound correctly - firstNameElement = rendered.container.querySelector(".firstName-container input") as HTMLInputElement - expect(firstNameElement.value).toBe("Coder") - }) - - it.each([ - // The name field is required to be at least 3 characters, so error should be false: - ["Coder", "error: false"], - // The name field is required to be at least 3 characters, so error should be true: - ["C", "error: true"], - ])("Nested 'error' test - Typing %p should result in %p", async (textToType: string, expectedLabel: string) => { - // Given - const TestComponent = () => { - // Initialize form - const form = useFormik<FormWithNestedSubForm.Schema>({ - initialValues: { - parentField: "no-op", - subForm: { - // First name needs to be at least 3 characters - firstName: "", - lastName: "last-name", - }, - }, - validationSchema: FormWithNestedSubForm.validator, - onSubmit: () => { - return - }, - }) - - const nestedForm = subForm<FormWithNestedSubForm.Schema, SubForm.Schema>(form, "subForm") - - return ( - <div id="container"> - <div className="error-sensor">{"error: " + String(!!nestedForm.errors["firstName"])}</div> - <SubForm.Component form={nestedForm} /> - </div> - ) - } - - // When - const rendered = render(<TestComponent />) - - // Then - // ...user types coder, and leaves form field - const firstNameElement = rendered.container.querySelector(".firstName-container input") as HTMLInputElement - userEvent.type(firstNameElement, textToType) - fireEvent.blur(firstNameElement) - - const element = await screen.findByText(expectedLabel) - expect(element.textContent).toBe(expectedLabel) - }) - - it("subforms pass correct values on 'submit'", async () => { - // Given - let hitCount = 0 - let submitResult: FormWithNestedSubForm.Schema | null = null - - const onSubmit = (submitValues: FormWithNestedSubForm.Schema) => { - hitCount++ - submitResult = submitValues - } - - const TestComponent = () => { - // Initialize form - const form = useFormik<FormWithNestedSubForm.Schema>({ - initialValues: { - parentField: "no-op", - subForm: { - // First name needs to be at least 3 characters - firstName: "", - lastName: "", - }, - }, - validationSchema: FormWithNestedSubForm.validator, - - // Submit is always handled by the top-level form - onSubmit, - }) - - const nestedForm = subForm<FormWithNestedSubForm.Schema, SubForm.Schema>(form, "subForm") - - return ( - <div id="container"> - <div className="submit-sensor">{"Submits: " + String(form.submitCount)}</div> - <SubForm.Component form={nestedForm} /> - <input type="button" onClick={form.submitForm} value="Submit" /> - </div> - ) - } - - // When: User types values and submits - const rendered = render(<TestComponent />) - - const firstNameElement = rendered.container.querySelector(".firstName-container input") as HTMLInputElement - userEvent.type(firstNameElement, "Coder") - fireEvent.blur(firstNameElement) - - const lastNameElement = rendered.container.querySelector(".lastName-container input") as HTMLInputElement - userEvent.type(lastNameElement, "Rocks") - fireEvent.blur(lastNameElement) - - const submitButton = await screen.findByText("Submit") - fireEvent.click(submitButton) - - // Wait for submission to percolate through rendering - await screen.findByText("Submits: 1") - - // Then: We should've received a submit callback with correct values - expect(hitCount).toBe(1) - expect(submitResult).toEqual({ - parentField: "no-op", - subForm: { - firstName: "Coder", - lastName: "Rocks", - }, - }) - }) - - it("subforms handle setFieldValue correctly", async () => { - // Given: A form with a subform that uses `setFieldValue` - interface ParentSchema { - subForm: SubFormUsingSetValue.Schema - } - - const TestComponent = () => { - // Initialize form - const form = useFormik<ParentSchema>({ - initialValues: { - subForm: SubFormUsingSetValue.initialValues, - }, - validationSchema: Yup.object({ - subForm: SubFormUsingSetValue.validator, - }), - - // Submit is always handled by the top-level form - onSubmit: () => { - return - }, - }) - - const nestedForm = subForm<ParentSchema, SubFormUsingSetValue.Schema>(form, "subForm") - - return ( - <div id="container"> - <SubFormUsingSetValue.Component form={nestedForm} /> - </div> - ) - } - - render(<TestComponent />) - - // First render: We should find an element with 'Count: 0' (initial value) - await screen.findAllByText("Count: 0") - - // When: User clicks button, which should increment form value - const buttonElement = await screen.findByText("Click me to increment count") - fireEvent.click(buttonElement) - - // Then: The count sensor should be incremented from 0 -> 1 - await screen.findAllByText("Count: 1") - }) - - it("subforms handle setFieldTouched correctly", async () => { - // Given: A form with a subform that uses `set` - interface ParentSchema { - nestedForm: SubForm.Schema - } - - const TestComponent = () => { - // Initialize form - const form = useFormik<ParentSchema>({ - initialValues: { - nestedForm: { - firstName: "first", - lastName: "last", - }, - }, - validationSchema: Yup.object({ - nestedForm: SubForm.validator, - }), - - // Submit is always handled by the top-level form - onSubmit: () => { - return - }, - }) - - const nestedForm = subForm<ParentSchema, SubForm.Schema>(form, "nestedForm") - - const setTouched = () => { - nestedForm.setFieldTouched("firstName", true) - } - - const isFieldTouched = nestedForm.touched["firstName"] - return ( - <div id="container"> - <div className="touch-sensor">{"Touched: " + String(!!isFieldTouched)}</div> - <input type="button" onClick={setTouched} value="Click me to set touched" /> - <SubForm.Component form={nestedForm} /> - </div> - ) - } - - render(<TestComponent />) - - // First render: We should find an element with 'Count: 0' (initial value) - await screen.findAllByText("Touched: false") - - // When: User clicks button, which should increment form value - const buttonElement = await screen.findByText("Click me to set touched") - fireEvent.click(buttonElement) - - // Then: The count sensor should be incremented from 0 -> 1 - await screen.findAllByText("Touched: true") - }) - - it("multiple nesting levels are handled correctly", async () => { - // Given: A form with a subform that uses `set` - interface ParentSchema { - outerNesting: { - innerNesting: { - nestedForm: SubForm.Schema - } - } - } - - const TestComponent = () => { - // Initialize form - const form = useFormik<ParentSchema>({ - initialValues: { - outerNesting: { - innerNesting: { - nestedForm: { - firstName: "double-nested-first", - lastName: "", - }, - }, - }, - }, - validationSchema: Yup.object({ - nestedForm: SubForm.validator, - }), - - // Submit is always handled by the top-level form - onSubmit: () => { - return - }, - }) - - // Peel apart the layers of nesting, so we can validate the binding is correct... - const outerNestedForm = subForm<ParentSchema, { innerNesting: { nestedForm: SubForm.Schema } }>( - form, - "outerNesting", - ) - const innerNestedForm = subForm< - { innerNesting: { nestedForm: SubForm.Schema } }, - { nestedForm: SubForm.Schema } - >(outerNestedForm, "innerNesting") - const nestedForm = subForm<{ nestedForm: SubForm.Schema }, SubForm.Schema>(innerNestedForm, "nestedForm") - - return ( - <div id="container"> - <div data-testid="field-id-sensor">{"FieldId: " + FormikLike.getFieldId(nestedForm, "firstName")}</div> - <SubForm.Component form={nestedForm} /> - </div> - ) - } - - // When - render(<TestComponent />) - - // Then: - // The form element should be bound correctly - const element = await screen.findByTestId("field-id-sensor") - expect(element.textContent).toBe("FieldId: outerNesting.innerNesting.nestedForm.firstName") - }) - }) -}) diff --git a/site/util/formik.ts b/site/util/formik.ts deleted file mode 100644 index 240b911c834e9..0000000000000 --- a/site/util/formik.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { FormikContextType, FormikErrors, FormikTouched, getIn } from "formik" - -/** - * FormikLike is a thin layer of abstraction over 'Formik' - * - * FormikLike is intended to be compatible with Formik (ie, a subset - a drop-in replacement), - * but adds faculty for handling sub-forms. - */ -export interface FormikLike<T> - extends Pick< - FormikContextType<T>, - // Subset of formik functionality that is supported in subForms - | "errors" - | "handleBlur" - | "handleChange" - | "isSubmitting" - | "setFieldTouched" - | "setFieldValue" - | "submitCount" - | "touched" - | "values" - > { - getFieldId?: (fieldName: string) => string -} - -// Utility functions around the FormikLike interface -export namespace FormikLike { - /** - * getFieldId - * - * getFieldId returns the fully-qualified path for a field. - * For a form with no parents, this is just the field name. - * For a form with parents, this is included the path of the field. - */ - export const getFieldId = <T>(form: FormikLike<T>, fieldName: string | keyof T): string => { - if (typeof form.getFieldId !== "undefined") { - return form.getFieldId(String(fieldName)) - } else { - return String(fieldName) - } - } -} - -/** - * subForm - * - * `subForm` takes a parentForm and a selector, and returns a new form - * that is scoped to just the selector. - * - * For example, consider the schema: - * - * ``` - * type NestedSchema = { - * name: string, - * } - * - * type Schema = { - * nestedForm: NestedSchema, - * } - * ``` - * - * Calling `subForm(parentForm, "nestedForm")` where `parentForm` is a - * `FormikLike<Schema>` will return a `FormikLike<NestedSchema>`. - * - * This is helpful for composing forms - a `FormikLike<NestedSchema>` - * could either be part of a larger parent form, or a stand-alone form - - * the component itself doesn't have to know! - * - * @param parentForm The parent form `FormikLike` - * @param subFormSelector The field containing the nested form - * @returns A `FormikLike` for the nested form - */ -export const subForm = <TParentFormSchema, TSubFormSchema>( - parentForm: FormikLike<TParentFormSchema>, - subFormSelector: string & keyof TParentFormSchema, -): FormikLike<TSubFormSchema> => { - // TODO: It would be nice to have better typing for `getIn` so that we - // don't need the `as` cast. Perhaps future versions of `Formik` will have - // a more strongly typed version of `getIn`? Or, we may have a more type-safe - // utility for this in the future. - const values = getIn(parentForm.values, subFormSelector) as TSubFormSchema - const errors = (getIn(parentForm.errors, subFormSelector) || {}) as FormikErrors<TSubFormSchema> - const touched = (getIn(parentForm.touched, subFormSelector) || {}) as FormikTouched<TSubFormSchema> - - const getFieldId = (fieldName: string): string => { - return FormikLike.getFieldId(parentForm, subFormSelector + "." + fieldName) - } - - return { - values, - errors, - touched, - - // We can pass the parentForm handlerBlur/handleChange directly, - // since they figure out the field ID from the element. - handleBlur: parentForm.handleBlur, - handleChange: parentForm.handleChange, - - // isSubmitting can just pass through - there isn't a difference - // in submitting state between parent forms and subforms - // (only the top-level form handles submission) - isSubmitting: parentForm.isSubmitting, - submitCount: parentForm.submitCount, - - // Wrap setFieldValue & setFieldTouched so we can resolve to the fully-nested ID for setting - setFieldValue: <T extends keyof TSubFormSchema>(fieldName: string | keyof T, value: T[keyof T]): void => { - const fieldNameAsString = String(fieldName) - const resolvedFieldId = getFieldId(fieldNameAsString) - - parentForm.setFieldValue(resolvedFieldId, value) - }, - setFieldTouched: <T extends keyof TSubFormSchema>( - fieldName: string | keyof T, - isTouched: boolean | undefined, - shouldValidate = false, - ): void => { - const fieldNameAsString = String(fieldName) - const resolvedFieldId = getFieldId(fieldNameAsString) - - parentForm.setFieldTouched(resolvedFieldId, isTouched, shouldValidate) - }, - - getFieldId, - } -} diff --git a/site/util/index.ts b/site/util/index.ts index d792ccafcdd4c..7cca9d6f45eac 100644 --- a/site/util/index.ts +++ b/site/util/index.ts @@ -1,3 +1,2 @@ export * from "./firstOrOnly" -export * from "./formik" export * from "./promise" From 3e8069b078fea1d4b0e07f59915e52585fdbbbf7 Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Sat, 22 Jan 2022 05:03:56 +0000 Subject: [PATCH 38/41] Formatting --- site/components/Form/FormTextField.tsx | 51 +++++++++++++------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/site/components/Form/FormTextField.tsx b/site/components/Form/FormTextField.tsx index b8b1add664239..c02aec46928ae 100644 --- a/site/components/Form/FormTextField.tsx +++ b/site/components/Form/FormTextField.tsx @@ -18,7 +18,6 @@ export interface FormFieldProps<T> { formFieldName: keyof T } - /** * FormTextFieldProps extends form-related MUI TextFieldProps with Formik * props. The passed in form is used to compute error states and configure @@ -27,31 +26,31 @@ export interface FormFieldProps<T> { */ export interface FormTextFieldProps<T> extends Pick< - TextFieldProps, - | "autoComplete" - | "autoFocus" - | "children" - | "className" - | "disabled" - | "fullWidth" - | "helperText" - | "id" - | "InputLabelProps" - | "InputProps" - | "inputProps" - | "label" - | "margin" - | "multiline" - | "onChange" - | "placeholder" - | "required" - | "rows" - | "select" - | "SelectProps" - | "style" - | "type" - >, - FormFieldProps<T> { + TextFieldProps, + | "autoComplete" + | "autoFocus" + | "children" + | "className" + | "disabled" + | "fullWidth" + | "helperText" + | "id" + | "InputLabelProps" + | "InputProps" + | "inputProps" + | "label" + | "margin" + | "multiline" + | "onChange" + | "placeholder" + | "required" + | "rows" + | "select" + | "SelectProps" + | "style" + | "type" + >, + FormFieldProps<T> { /** * eventTransform is an optional transformer on the event data before it is * processed by formik. From e5e02f95617f4925f7e41fa84989a3a087527d39 Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Sat, 22 Jan 2022 05:14:40 +0000 Subject: [PATCH 39/41] Clean-up utilities --- site/api.ts | 7 ++----- site/util/{firstOrOnly.ts => array.ts} | 0 site/util/index.ts | 2 +- 3 files changed, 3 insertions(+), 6 deletions(-) rename site/util/{firstOrOnly.ts => array.ts} (100%) diff --git a/site/api.ts b/site/api.ts index a1fa7f6e0a563..ba6adabab6c2e 100644 --- a/site/api.ts +++ b/site/api.ts @@ -1,4 +1,3 @@ -<<<<<<< HEAD import { wait } from "./util" // TEMPORARY @@ -103,8 +102,7 @@ export namespace Workspace { return Promise.resolve("test-workspace") } } -||||||| 36b7b20 -======= + interface LoginResponse { session_token: string } @@ -127,5 +125,4 @@ export const login = async (email: string, password: string): Promise<LoginRespo } return body -} ->>>>>>> main +} \ No newline at end of file diff --git a/site/util/firstOrOnly.ts b/site/util/array.ts similarity index 100% rename from site/util/firstOrOnly.ts rename to site/util/array.ts diff --git a/site/util/index.ts b/site/util/index.ts index 7cca9d6f45eac..ceb696651876a 100644 --- a/site/util/index.ts +++ b/site/util/index.ts @@ -1,2 +1,2 @@ -export * from "./firstOrOnly" +export * from "./array" export * from "./promise" From 5e4a44f553eaf204ae3e7b975a77bd8ab6330c9c Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Sat, 22 Jan 2022 05:22:25 +0000 Subject: [PATCH 40/41] Revert "Remove and consolidate form ty pes" This reverts commit e95d75f5e0b033ff78e33c6f6d9e3ae1986a088d. --- site/components/Form/FormTextField.tsx | 21 +- site/components/Form/types.ts | 16 + site/pages/workspaces/create/[projectId].tsx | 10 +- site/util/formik.test.tsx | 709 +++++++++++++++++++ site/util/formik.ts | 125 ++++ site/util/index.ts | 1 + 6 files changed, 875 insertions(+), 7 deletions(-) create mode 100644 site/components/Form/types.ts create mode 100644 site/util/formik.test.tsx create mode 100644 site/util/formik.ts diff --git a/site/components/Form/FormTextField.tsx b/site/components/Form/FormTextField.tsx index 767c1e2830ee3..20e5513bedbab 100644 --- a/site/components/Form/FormTextField.tsx +++ b/site/components/Form/FormTextField.tsx @@ -1,7 +1,7 @@ import TextField, { TextFieldProps } from "@material-ui/core/TextField" +import { FormikLike } from "../../util/formik" import React from "react" import { PasswordField } from "./PasswordField" -import { FormikContextType } from "formik" /** * FormFieldProps are required props for creating form fields using a factory. @@ -11,7 +11,22 @@ export interface FormFieldProps<T> { * form is a reference to a form or subform and is used to compute common * states such as error and helper text */ - form: FormikContextType<T> + form: FormikLike<T> + /** + * formFieldName is a field name associated with the form schema. + */ + formFieldName: keyof T +} + +/** + * FormFieldProps are required props for creating form fields using a factory. + */ +export interface FormFieldProps<T> { + /** + * form is a reference to a form or subform and is used to compute common + * states such as error and helper text + */ + form: FormikLike<T> /** * formFieldName is a field name associated with the form schema. */ @@ -124,7 +139,7 @@ export const formTextFieldFactory = <T,>(): React.FC<FormTextFieldProps<T>> => { // Conversion to a string primitive is necessary as formFieldName is an in // indexable type such as a string, number or enum. - const fieldId = String(formFieldName) + const fieldId = FormikLike.getFieldId<T>(form, String(formFieldName)) const Component = isPassword ? PasswordField : TextField const inputType = isPassword ? undefined : type diff --git a/site/components/Form/types.ts b/site/components/Form/types.ts new file mode 100644 index 0000000000000..bc30b42d424e7 --- /dev/null +++ b/site/components/Form/types.ts @@ -0,0 +1,16 @@ +import { FormikLike } from "../../util/formik" + +/** + * FormFieldProps are required props for creating form fields using a factory. + */ +export interface FormFieldProps<T> { + /** + * form is a reference to a form or subform and is used to compute common + * states such as error and helper text + */ + form: FormikLike<T> + /** + * formFieldName is a field name associated with the form schema. + */ + formFieldName: keyof T +} diff --git a/site/pages/workspaces/create/[projectId].tsx b/site/pages/workspaces/create/[projectId].tsx index 35f977aa42739..47703559f03b5 100644 --- a/site/pages/workspaces/create/[projectId].tsx +++ b/site/pages/workspaces/create/[projectId].tsx @@ -1,7 +1,7 @@ import React from "react" import { useRouter } from "next/router" -import { useFormik, FormikContext } from "formik" -import { firstOrOnly } from "./../../../util" +import { useFormik } from "formik" +import { firstOrOnly, subForm, FormikLike } from "./../../../util" import * as API from "../../../api" import { FormPage, FormButton } from "../../../components/PageTemplates" import { useRequestor } from "../../../hooks/useRequestor" @@ -66,6 +66,8 @@ const CreateProjectPage: React.FC = () => { }, }) + const parametersForm: FormikLike<Record<string, string>> = subForm(form, "parameters") + const cancel = () => { router.push(`/workspaces/create`) } @@ -124,8 +126,8 @@ const CreateProjectPage: React.FC = () => { return ( <FormRow> <ParameterTextField - form={form} - formFieldName={"parameters." + param.id} + form={parametersForm} + formFieldName={param.id} fullWidth label={param.name} helperText={param.description} diff --git a/site/util/formik.test.tsx b/site/util/formik.test.tsx new file mode 100644 index 0000000000000..84bbb4a93a0e0 --- /dev/null +++ b/site/util/formik.test.tsx @@ -0,0 +1,709 @@ +import Checkbox from "@material-ui/core/Checkbox" +import "@testing-library/jest-dom" +import { fireEvent, render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { useFormik } from "formik" +import { formTextFieldFactory } from "../components/Form" +import React from "react" +import * as Yup from "yup" +import { FormikLike, subForm } from "./formik" + +/** + * SubForm + * + * A simple schema component for a small 'subform' that we'll reuse + * across a bunch of different forms. + * + * With the `subForm` API, this can act as either a top-level form, + * or can be composed as sub-forms within other forms. + */ +namespace SubForm { + export type Schema = { + firstName?: string + lastName?: string + } + + export const validator = Yup.object({ + firstName: Yup.string().required().min(3, "First name must be at least characters"), + lastName: Yup.string().required(), + }) + + export interface SubFormProps { + form: FormikLike<Schema> + } + + const FormTextField = formTextFieldFactory<Schema>() + + export const Component: React.FC<SubFormProps> = (props: SubFormProps) => { + const { form } = props + return ( + <> + <div className="firstName-container"> + <FormTextField form={form} formFieldName="firstName" helperText="Your first name" label="First Name" /> + </div> + <div className="lastName-container"> + <FormTextField form={form} formFieldName="lastName" helperText="Your last name" label="Last Name" /> + </div> + </> + ) + } +} + +namespace SubFormUsingSetValue { + export type Schema = { + count: number + } + + export const initialValues = { + count: 0, + } + + export const validator = Yup.object({ + count: Yup.number(), + }) + + export interface SubFormUsingSetValueProps { + form: FormikLike<Schema> + } + + export const Component: React.FC<SubFormUsingSetValueProps> = (props: SubFormUsingSetValueProps) => { + const { form } = props + const currentValue = form.values.count + + const incrementCount = () => { + form.setFieldValue("count", currentValue + 1) + } + + return ( + <> + <div>{"Count: " + currentValue.toString()}</div> + <input type="button" value="Click me to increment count" onClick={incrementCount} /> + </> + ) + } +} + +/** + * FormWithNestedSubForm + * + * This is an example of a form that nests the SubForm. + */ +namespace FormWithNestedSubForm { + export type Schema = { + parentField: string + subForm: SubForm.Schema + } + + export const validator = Yup.object({ + parentField: Yup.string().required(), + subForm: SubForm.validator, + }) + + export interface FormWithNestedSubFormProps { + form: FormikLike<Schema> + } + + export const Component: React.FC<FormWithNestedSubFormProps> = (props: FormWithNestedSubFormProps) => { + const { form } = props + + const nestedForm = subForm<Schema, SubForm.Schema>(form, "subForm") + return ( + <div> + <div id="parentForm"></div> + <div id="subForm"> + <SubForm.Component form={nestedForm} /> + </div> + </div> + ) + } +} + +/** + * FormWithMultipleSubforms + * + * Example of a parent form that has multiple child subforms at the same level. + */ +namespace FormWithMultipleNestedSubForms { + export type Schema = { + parentField: string + subForm1: SubForm.Schema + subForm2: SubForm.Schema + } + + export const schema = Yup.object({ + parentField: Yup.string().required(), + subForm1: SubForm.validator, + subForm2: SubForm.validator, + }) + + export interface FormWithMultipleNestedSubFormProps { + form: FormikLike<Schema> + } + + export const Component: React.FC<FormWithMultipleNestedSubFormProps> = ( + props: FormWithMultipleNestedSubFormProps, + ) => { + const { form } = props + + const nestedForm1 = subForm<Schema, SubForm.Schema>(form, "subForm1") + const nestedForm2 = subForm<Schema, SubForm.Schema>(form, "subForm2") + return ( + <div> + <div id="parentForm"></div> + <div id="subForm1"> + <SubForm.Component form={nestedForm1} /> + </div> + <div id="subForm2"> + <SubForm.Component form={nestedForm2} /> + </div> + </div> + ) + } +} + +/** + * FormWithDynamicSubForms + * + * This is intended to closely replicate the scenario we'll need for EC2 providers - + * a dynamic create-workspace form that will show an advanced section that depends on + * whether the provider is Kubernetes or EC2. + * + * This is one approach to designing a form like this, using a 'fat interface'. + * + * In this, the schema contains both EC2 | Kubernetes, and the validation + * logic switches depending on which is chosen. + */ +namespace FormWithDynamicSubForms { + export type Schema = { + isKubernetes: boolean + kubernetesMetadata: SubForm.Schema + ec2Metadata: SubForm.Schema + } + + export const schema = Yup.object({ + isKubernetes: Yup.boolean().required(), + kubernetesMetadata: Yup.mixed().when("isKubernetes", { + is: true, + then: SubForm.validator, + }), + ec2Metadata: Yup.mixed().when("isKubernetes", { + is: false, + then: SubForm.validator, + }), + }) + + export interface FormWithDynamicSubFormsProps { + form: FormikLike<Schema> + } + + export const Component: React.FC<FormWithDynamicSubFormsProps> = (props: FormWithDynamicSubFormsProps) => { + const { form } = props + + const isKubernetes = form.values.isKubernetes + + const kubernetesForm = subForm<Schema, SubForm.Schema>(form, "kubernetesMetadata") + const ec2Form = subForm<Schema, SubForm.Schema>(form, "ec2Metadata") + return ( + <div> + <div id="parentForm"> + <Checkbox id="isKubernetes" name="isKubernetes" checked={isKubernetes} onChange={form.handleChange} /> + </div> + {isKubernetes ? ( + <div id="kubernetes"> + <SubForm.Component form={kubernetesForm} /> + </div> + ) : ( + <div id="ec2"> + <SubForm.Component form={ec2Form} /> + </div> + )} + </div> + ) + } +} + +describe("formik", () => { + describe("subforms", () => { + // This test is a bit superfluous, but it's here to exhibit the difference in using the sub-form + // as a top-level form, vs some other form's subform + it("binds fields correctly as a top-level form", () => { + // Given + const TestComponent = () => { + // Initialize form + const form = useFormik<SubForm.Schema>({ + initialValues: { + firstName: "first-name", + lastName: "last-name", + }, + validationSchema: SubForm.validator, + onSubmit: () => { + return + }, + }) + + return ( + <div id="container"> + <SubForm.Component form={form} /> + </div> + ) + } + + // When + const rendered = render(<TestComponent />) + + // Then: verify form gets bound correctly + const firstNameElement = rendered.container.querySelector(".firstName-container input") as HTMLInputElement + expect(firstNameElement.value).toBe("first-name") + + const lastNameElement = rendered.container.querySelector(".lastName-container input") as HTMLInputElement + expect(lastNameElement.value).toBe("last-name") + }) + + it("binds fields correctly as a nested form", () => { + // Given + const TestComponent = () => { + const form = useFormik<FormWithNestedSubForm.Schema>({ + initialValues: { + parentField: "parent-test-value", + subForm: { + firstName: "first-name", + lastName: "last-name", + }, + }, + validationSchema: FormWithNestedSubForm.validator, + onSubmit: () => { + return + }, + }) + + return <FormWithNestedSubForm.Component form={form} /> + } + + // When + const rendered = render(<TestComponent />) + + // Then: verify form gets bound correctly + const firstNameElement = rendered.container.querySelector( + "#subForm .firstName-container input", + ) as HTMLInputElement + expect(firstNameElement.value).toBe("first-name") + + const lastNameElement = rendered.container.querySelector("#subForm .lastName-container input") as HTMLInputElement + expect(lastNameElement.value).toBe("last-name") + }) + + it("binds fields correctly with multiple nested forms", () => { + // Given + const TestComponent = () => { + const form = useFormik<FormWithMultipleNestedSubForms.Schema>({ + initialValues: { + parentField: "parent-test-value", + subForm1: { + firstName: "Arthur", + lastName: "Aardvark", + }, + subForm2: { + firstName: "Bartholomew", + lastName: "Bear", + }, + }, + validationSchema: FormWithNestedSubForm.validator, + onSubmit: () => { + return + }, + }) + + return <FormWithMultipleNestedSubForms.Component form={form} /> + } + + // When + const rendered = render(<TestComponent />) + + // Then: verify form gets bound correctly, for the first nested form + const firstNameElement1 = rendered.container.querySelector( + "#subForm1 .firstName-container input", + ) as HTMLInputElement + expect(firstNameElement1.value).toBe("Arthur") + + const lastNameElement1 = rendered.container.querySelector( + "#subForm1 .lastName-container input", + ) as HTMLInputElement + expect(lastNameElement1.value).toBe("Aardvark") + + // Verify form gets bound correctly, for the first nested form + const firstNameElement2 = rendered.container.querySelector( + "#subForm2 .firstName-container input", + ) as HTMLInputElement + expect(firstNameElement2.value).toBe("Bartholomew") + + const lastNameElement2 = rendered.container.querySelector( + "#subForm2 .lastName-container input", + ) as HTMLInputElement + expect(lastNameElement2.value).toBe("Bear") + }) + + it("dynamic subforms work correctly", async () => { + // Given + const TestComponent = () => { + const form = useFormik<FormWithDynamicSubForms.Schema>({ + initialValues: { + isKubernetes: true, + kubernetesMetadata: { + firstName: "Kubernetes", + lastName: "Provider", + }, + ec2Metadata: { + firstName: "Amazon", + lastName: "Provider", + }, + }, + validationSchema: FormWithDynamicSubForms.schema, + onSubmit: () => { + return + }, + }) + + return <FormWithDynamicSubForms.Component form={form} /> + } + + // When + const rendered = render(<TestComponent />) + + const kubernetesNameElement = rendered.container.querySelector( + "#kubernetes .firstName-container input", + ) as HTMLInputElement + expect(kubernetesNameElement.value).toBe("Kubernetes") + + const checkBox = rendered.container.querySelector("#isKubernetes") as HTMLInputElement + + fireEvent.click(checkBox) + // Wait for rendering to complete after clicking the checkbox. + // We know it's done when the 'Amazon' text input displays + await screen.findByDisplayValue("Amazon") + + // Then + // Now, we should be in EC2 mode - validate that value got bound correclty + // We're in kubernetes mode - verify values got bound correctly + const ec2NameElement = rendered.container.querySelector("#ec2 .firstName-container input") as HTMLInputElement + expect(ec2NameElement.value).toBe("Amazon") + }) + + it("nested 'touched' gets updated for subform when field is modified", async () => { + // Given + const TestComponent = () => { + // Initialize form + const form = useFormik<FormWithNestedSubForm.Schema>({ + initialValues: { + parentField: "no-op", + subForm: { + firstName: "", + lastName: "last-name", + }, + }, + validationSchema: FormWithNestedSubForm.validator, + onSubmit: () => { + return + }, + }) + + const nestedForm = subForm<FormWithNestedSubForm.Schema, SubForm.Schema>(form, "subForm") + + return ( + <div id="container"> + <div className="touched-sensor">{"Touched: " + String(!!nestedForm.touched["firstName"])}</div> + <SubForm.Component form={nestedForm} /> + </div> + ) + } + + // When + const rendered = render(<TestComponent />) + const touchSensor = rendered.container.querySelector(".touched-sensor") as HTMLDivElement + expect(touchSensor.textContent).toBe("Touched: false") + + let firstNameElement = rendered.container.querySelector(".firstName-container input") as HTMLInputElement + // ...user types coder, and leaves form field + userEvent.type(firstNameElement, "Coder") + fireEvent.blur(firstNameElement) + + // Then: Verify touched field is updated and everything is up-to-date + await screen.findByText("Touched: true") + + // Then: verify form gets bound correctly + firstNameElement = rendered.container.querySelector(".firstName-container input") as HTMLInputElement + expect(firstNameElement.value).toBe("Coder") + }) + + it.each([ + // The name field is required to be at least 3 characters, so error should be false: + ["Coder", "error: false"], + // The name field is required to be at least 3 characters, so error should be true: + ["C", "error: true"], + ])("Nested 'error' test - Typing %p should result in %p", async (textToType: string, expectedLabel: string) => { + // Given + const TestComponent = () => { + // Initialize form + const form = useFormik<FormWithNestedSubForm.Schema>({ + initialValues: { + parentField: "no-op", + subForm: { + // First name needs to be at least 3 characters + firstName: "", + lastName: "last-name", + }, + }, + validationSchema: FormWithNestedSubForm.validator, + onSubmit: () => { + return + }, + }) + + const nestedForm = subForm<FormWithNestedSubForm.Schema, SubForm.Schema>(form, "subForm") + + return ( + <div id="container"> + <div className="error-sensor">{"error: " + String(!!nestedForm.errors["firstName"])}</div> + <SubForm.Component form={nestedForm} /> + </div> + ) + } + + // When + const rendered = render(<TestComponent />) + + // Then + // ...user types coder, and leaves form field + const firstNameElement = rendered.container.querySelector(".firstName-container input") as HTMLInputElement + userEvent.type(firstNameElement, textToType) + fireEvent.blur(firstNameElement) + + const element = await screen.findByText(expectedLabel) + expect(element.textContent).toBe(expectedLabel) + }) + + it("subforms pass correct values on 'submit'", async () => { + // Given + let hitCount = 0 + let submitResult: FormWithNestedSubForm.Schema | null = null + + const onSubmit = (submitValues: FormWithNestedSubForm.Schema) => { + hitCount++ + submitResult = submitValues + } + + const TestComponent = () => { + // Initialize form + const form = useFormik<FormWithNestedSubForm.Schema>({ + initialValues: { + parentField: "no-op", + subForm: { + // First name needs to be at least 3 characters + firstName: "", + lastName: "", + }, + }, + validationSchema: FormWithNestedSubForm.validator, + + // Submit is always handled by the top-level form + onSubmit, + }) + + const nestedForm = subForm<FormWithNestedSubForm.Schema, SubForm.Schema>(form, "subForm") + + return ( + <div id="container"> + <div className="submit-sensor">{"Submits: " + String(form.submitCount)}</div> + <SubForm.Component form={nestedForm} /> + <input type="button" onClick={form.submitForm} value="Submit" /> + </div> + ) + } + + // When: User types values and submits + const rendered = render(<TestComponent />) + + const firstNameElement = rendered.container.querySelector(".firstName-container input") as HTMLInputElement + userEvent.type(firstNameElement, "Coder") + fireEvent.blur(firstNameElement) + + const lastNameElement = rendered.container.querySelector(".lastName-container input") as HTMLInputElement + userEvent.type(lastNameElement, "Rocks") + fireEvent.blur(lastNameElement) + + const submitButton = await screen.findByText("Submit") + fireEvent.click(submitButton) + + // Wait for submission to percolate through rendering + await screen.findByText("Submits: 1") + + // Then: We should've received a submit callback with correct values + expect(hitCount).toBe(1) + expect(submitResult).toEqual({ + parentField: "no-op", + subForm: { + firstName: "Coder", + lastName: "Rocks", + }, + }) + }) + + it("subforms handle setFieldValue correctly", async () => { + // Given: A form with a subform that uses `setFieldValue` + interface ParentSchema { + subForm: SubFormUsingSetValue.Schema + } + + const TestComponent = () => { + // Initialize form + const form = useFormik<ParentSchema>({ + initialValues: { + subForm: SubFormUsingSetValue.initialValues, + }, + validationSchema: Yup.object({ + subForm: SubFormUsingSetValue.validator, + }), + + // Submit is always handled by the top-level form + onSubmit: () => { + return + }, + }) + + const nestedForm = subForm<ParentSchema, SubFormUsingSetValue.Schema>(form, "subForm") + + return ( + <div id="container"> + <SubFormUsingSetValue.Component form={nestedForm} /> + </div> + ) + } + + render(<TestComponent />) + + // First render: We should find an element with 'Count: 0' (initial value) + await screen.findAllByText("Count: 0") + + // When: User clicks button, which should increment form value + const buttonElement = await screen.findByText("Click me to increment count") + fireEvent.click(buttonElement) + + // Then: The count sensor should be incremented from 0 -> 1 + await screen.findAllByText("Count: 1") + }) + + it("subforms handle setFieldTouched correctly", async () => { + // Given: A form with a subform that uses `set` + interface ParentSchema { + nestedForm: SubForm.Schema + } + + const TestComponent = () => { + // Initialize form + const form = useFormik<ParentSchema>({ + initialValues: { + nestedForm: { + firstName: "first", + lastName: "last", + }, + }, + validationSchema: Yup.object({ + nestedForm: SubForm.validator, + }), + + // Submit is always handled by the top-level form + onSubmit: () => { + return + }, + }) + + const nestedForm = subForm<ParentSchema, SubForm.Schema>(form, "nestedForm") + + const setTouched = () => { + nestedForm.setFieldTouched("firstName", true) + } + + const isFieldTouched = nestedForm.touched["firstName"] + return ( + <div id="container"> + <div className="touch-sensor">{"Touched: " + String(!!isFieldTouched)}</div> + <input type="button" onClick={setTouched} value="Click me to set touched" /> + <SubForm.Component form={nestedForm} /> + </div> + ) + } + + render(<TestComponent />) + + // First render: We should find an element with 'Count: 0' (initial value) + await screen.findAllByText("Touched: false") + + // When: User clicks button, which should increment form value + const buttonElement = await screen.findByText("Click me to set touched") + fireEvent.click(buttonElement) + + // Then: The count sensor should be incremented from 0 -> 1 + await screen.findAllByText("Touched: true") + }) + + it("multiple nesting levels are handled correctly", async () => { + // Given: A form with a subform that uses `set` + interface ParentSchema { + outerNesting: { + innerNesting: { + nestedForm: SubForm.Schema + } + } + } + + const TestComponent = () => { + // Initialize form + const form = useFormik<ParentSchema>({ + initialValues: { + outerNesting: { + innerNesting: { + nestedForm: { + firstName: "double-nested-first", + lastName: "", + }, + }, + }, + }, + validationSchema: Yup.object({ + nestedForm: SubForm.validator, + }), + + // Submit is always handled by the top-level form + onSubmit: () => { + return + }, + }) + + // Peel apart the layers of nesting, so we can validate the binding is correct... + const outerNestedForm = subForm<ParentSchema, { innerNesting: { nestedForm: SubForm.Schema } }>( + form, + "outerNesting", + ) + const innerNestedForm = subForm< + { innerNesting: { nestedForm: SubForm.Schema } }, + { nestedForm: SubForm.Schema } + >(outerNestedForm, "innerNesting") + const nestedForm = subForm<{ nestedForm: SubForm.Schema }, SubForm.Schema>(innerNestedForm, "nestedForm") + + return ( + <div id="container"> + <div data-testid="field-id-sensor">{"FieldId: " + FormikLike.getFieldId(nestedForm, "firstName")}</div> + <SubForm.Component form={nestedForm} /> + </div> + ) + } + + // When + render(<TestComponent />) + + // Then: + // The form element should be bound correctly + const element = await screen.findByTestId("field-id-sensor") + expect(element.textContent).toBe("FieldId: outerNesting.innerNesting.nestedForm.firstName") + }) + }) +}) diff --git a/site/util/formik.ts b/site/util/formik.ts new file mode 100644 index 0000000000000..240b911c834e9 --- /dev/null +++ b/site/util/formik.ts @@ -0,0 +1,125 @@ +import { FormikContextType, FormikErrors, FormikTouched, getIn } from "formik" + +/** + * FormikLike is a thin layer of abstraction over 'Formik' + * + * FormikLike is intended to be compatible with Formik (ie, a subset - a drop-in replacement), + * but adds faculty for handling sub-forms. + */ +export interface FormikLike<T> + extends Pick< + FormikContextType<T>, + // Subset of formik functionality that is supported in subForms + | "errors" + | "handleBlur" + | "handleChange" + | "isSubmitting" + | "setFieldTouched" + | "setFieldValue" + | "submitCount" + | "touched" + | "values" + > { + getFieldId?: (fieldName: string) => string +} + +// Utility functions around the FormikLike interface +export namespace FormikLike { + /** + * getFieldId + * + * getFieldId returns the fully-qualified path for a field. + * For a form with no parents, this is just the field name. + * For a form with parents, this is included the path of the field. + */ + export const getFieldId = <T>(form: FormikLike<T>, fieldName: string | keyof T): string => { + if (typeof form.getFieldId !== "undefined") { + return form.getFieldId(String(fieldName)) + } else { + return String(fieldName) + } + } +} + +/** + * subForm + * + * `subForm` takes a parentForm and a selector, and returns a new form + * that is scoped to just the selector. + * + * For example, consider the schema: + * + * ``` + * type NestedSchema = { + * name: string, + * } + * + * type Schema = { + * nestedForm: NestedSchema, + * } + * ``` + * + * Calling `subForm(parentForm, "nestedForm")` where `parentForm` is a + * `FormikLike<Schema>` will return a `FormikLike<NestedSchema>`. + * + * This is helpful for composing forms - a `FormikLike<NestedSchema>` + * could either be part of a larger parent form, or a stand-alone form - + * the component itself doesn't have to know! + * + * @param parentForm The parent form `FormikLike` + * @param subFormSelector The field containing the nested form + * @returns A `FormikLike` for the nested form + */ +export const subForm = <TParentFormSchema, TSubFormSchema>( + parentForm: FormikLike<TParentFormSchema>, + subFormSelector: string & keyof TParentFormSchema, +): FormikLike<TSubFormSchema> => { + // TODO: It would be nice to have better typing for `getIn` so that we + // don't need the `as` cast. Perhaps future versions of `Formik` will have + // a more strongly typed version of `getIn`? Or, we may have a more type-safe + // utility for this in the future. + const values = getIn(parentForm.values, subFormSelector) as TSubFormSchema + const errors = (getIn(parentForm.errors, subFormSelector) || {}) as FormikErrors<TSubFormSchema> + const touched = (getIn(parentForm.touched, subFormSelector) || {}) as FormikTouched<TSubFormSchema> + + const getFieldId = (fieldName: string): string => { + return FormikLike.getFieldId(parentForm, subFormSelector + "." + fieldName) + } + + return { + values, + errors, + touched, + + // We can pass the parentForm handlerBlur/handleChange directly, + // since they figure out the field ID from the element. + handleBlur: parentForm.handleBlur, + handleChange: parentForm.handleChange, + + // isSubmitting can just pass through - there isn't a difference + // in submitting state between parent forms and subforms + // (only the top-level form handles submission) + isSubmitting: parentForm.isSubmitting, + submitCount: parentForm.submitCount, + + // Wrap setFieldValue & setFieldTouched so we can resolve to the fully-nested ID for setting + setFieldValue: <T extends keyof TSubFormSchema>(fieldName: string | keyof T, value: T[keyof T]): void => { + const fieldNameAsString = String(fieldName) + const resolvedFieldId = getFieldId(fieldNameAsString) + + parentForm.setFieldValue(resolvedFieldId, value) + }, + setFieldTouched: <T extends keyof TSubFormSchema>( + fieldName: string | keyof T, + isTouched: boolean | undefined, + shouldValidate = false, + ): void => { + const fieldNameAsString = String(fieldName) + const resolvedFieldId = getFieldId(fieldNameAsString) + + parentForm.setFieldTouched(resolvedFieldId, isTouched, shouldValidate) + }, + + getFieldId, + } +} diff --git a/site/util/index.ts b/site/util/index.ts index ceb696651876a..86c87003579b1 100644 --- a/site/util/index.ts +++ b/site/util/index.ts @@ -1,2 +1,3 @@ export * from "./array" +export * from "./formik" export * from "./promise" From 8343ef030e2e3c25cf3c25881e88efd28ee8de76 Mon Sep 17 00:00:00 2001 From: Bryan Phelps <bryan@coder.com> Date: Sat, 22 Jan 2022 21:50:47 +0000 Subject: [PATCH 41/41] First round of lint failures --- site/api.ts | 2 +- site/components/Form/FormSection.tsx | 2 -- site/components/Form/FormTextField.tsx | 15 --------------- site/components/Form/index.ts | 2 +- site/components/Navbar/index.tsx | 2 -- site/components/PageTemplates/FormPage.tsx | 1 - site/components/PageTemplates/LoadingPage.tsx | 3 ++- .../components/PageTemplates/RedirectPage.tsx | 3 ++- site/components/Project/ProjectIcon.tsx | 3 ++- site/components/Project/ProjectName.tsx | 3 ++- site/hooks/useRequestor.ts | 19 ++++++++++--------- site/pages/workspaces/index.tsx | 6 ++++-- 12 files changed, 24 insertions(+), 37 deletions(-) diff --git a/site/api.ts b/site/api.ts index ba6adabab6c2e..bd3411a9be501 100644 --- a/site/api.ts +++ b/site/api.ts @@ -83,7 +83,7 @@ export namespace Project { export const createWorkspace = async (name: string): Promise<string> => { await wait(250) - return "test-workspace" + return name } } diff --git a/site/components/Form/FormSection.tsx b/site/components/Form/FormSection.tsx index 6ae2bae4fbf58..ff82819163974 100644 --- a/site/components/Form/FormSection.tsx +++ b/site/components/Form/FormSection.tsx @@ -1,7 +1,5 @@ -import FormHelperText from "@material-ui/core/FormHelperText" import { makeStyles } from "@material-ui/core/styles" import Typography from "@material-ui/core/Typography" -import { Style } from "@material-ui/icons" import React from "react" export interface FormSectionProps { diff --git a/site/components/Form/FormTextField.tsx b/site/components/Form/FormTextField.tsx index 20e5513bedbab..f80308f0b001c 100644 --- a/site/components/Form/FormTextField.tsx +++ b/site/components/Form/FormTextField.tsx @@ -18,21 +18,6 @@ export interface FormFieldProps<T> { formFieldName: keyof T } -/** - * FormFieldProps are required props for creating form fields using a factory. - */ -export interface FormFieldProps<T> { - /** - * form is a reference to a form or subform and is used to compute common - * states such as error and helper text - */ - form: FormikLike<T> - /** - * formFieldName is a field name associated with the form schema. - */ - formFieldName: keyof T -} - /** * FormTextFieldProps extends form-related MUI TextFieldProps with Formik * props. The passed in form is used to compute error states and configure diff --git a/site/components/Form/index.ts b/site/components/Form/index.ts index 21dfa39b91e2b..f9b0464de03ce 100644 --- a/site/components/Form/index.ts +++ b/site/components/Form/index.ts @@ -1,4 +1,4 @@ export * from "./FormRow" export * from "./FormSection" export * from "./FormTextField" -export * from "./FormTitle" +export * from "./FormTitle" \ No newline at end of file diff --git a/site/components/Navbar/index.tsx b/site/components/Navbar/index.tsx index dd3827e8b9d25..2a27693e775b2 100644 --- a/site/components/Navbar/index.tsx +++ b/site/components/Navbar/index.tsx @@ -1,7 +1,5 @@ import React from "react" import Button from "@material-ui/core/Button" -import List from "@material-ui/core/List" -import ListSubheader from "@material-ui/core/ListSubheader" import { makeStyles } from "@material-ui/core/styles" import Link from "next/link" diff --git a/site/components/PageTemplates/FormPage.tsx b/site/components/PageTemplates/FormPage.tsx index 7a8ebc48a480a..bc0edaa75002e 100644 --- a/site/components/PageTemplates/FormPage.tsx +++ b/site/components/PageTemplates/FormPage.tsx @@ -1,5 +1,4 @@ import Button from "@material-ui/core/Button" -import Typography from "@material-ui/core/Typography" import { makeStyles } from "@material-ui/core/styles" import { ButtonProps } from "@material-ui/core/Button" import React from "react" diff --git a/site/components/PageTemplates/LoadingPage.tsx b/site/components/PageTemplates/LoadingPage.tsx index 59fc58d013da7..aceed167120a4 100644 --- a/site/components/PageTemplates/LoadingPage.tsx +++ b/site/components/PageTemplates/LoadingPage.tsx @@ -1,4 +1,5 @@ -import { Box, CircularProgress, makeStyles } from "@material-ui/core" +import { makeStyles } from "@material-ui/core/styles" +import CircularProgress from "@material-ui/core/CircularProgress" import React from "react" import { RequestState } from "../../hooks/useRequestor" diff --git a/site/components/PageTemplates/RedirectPage.tsx b/site/components/PageTemplates/RedirectPage.tsx index 56632f1122cab..e529c2d0ce719 100644 --- a/site/components/PageTemplates/RedirectPage.tsx +++ b/site/components/PageTemplates/RedirectPage.tsx @@ -9,7 +9,8 @@ export const RedirectPage: React.FC<RedirectPageProps> = ({ path }) => { const router = useRouter() useEffect(() => { - router.push(path) + // 'void' - we're OK with this promise being fire-and-forget + void router.push(path) }) return null diff --git a/site/components/Project/ProjectIcon.tsx b/site/components/Project/ProjectIcon.tsx index 994906d9036d5..f6ec499ed4a55 100644 --- a/site/components/Project/ProjectIcon.tsx +++ b/site/components/Project/ProjectIcon.tsx @@ -1,5 +1,6 @@ import React from "react" -import { Box, makeStyles } from "@material-ui/core" +import { makeStyles } from "@material-ui/core/styles" +import Box from "@material-ui/core/Box" import { ProjectName } from "./ProjectName" export interface ProjectIconProps { diff --git a/site/components/Project/ProjectName.tsx b/site/components/Project/ProjectName.tsx index 2421d31c2000c..957b86cfe2dc3 100644 --- a/site/components/Project/ProjectName.tsx +++ b/site/components/Project/ProjectName.tsx @@ -1,5 +1,6 @@ import React from "react" -import { makeStyles, Typography } from "@material-ui/core" +import { makeStyles } from "@material-ui/core/styles" +import Typography from "@material-ui/core/Typography" const useStyles = makeStyles((theme) => ({ root: { diff --git a/site/hooks/useRequestor.ts b/site/hooks/useRequestor.ts index 509ef6e0cba82..a84c706db551c 100644 --- a/site/hooks/useRequestor.ts +++ b/site/hooks/useRequestor.ts @@ -3,18 +3,19 @@ import isReady from "next/router" export type RequestState<TPayload> = | { - state: "loading" - } + state: "loading" + } | { - state: "error" - error: Error - } + state: "error" + error: Error + } | { - state: "success" - payload: TPayload - } + state: "success" + payload: TPayload + } -export const useRequestor = <TPayload>(fn: () => Promise<TPayload>, deps: any[] = []) => { +// TODO: Replace with `useSWR` +export const useRequestor = <TPayload>(fn: () => Promise<TPayload>, deps: any[] = []): RequestState<TPayload> => { const [requestState, setRequestState] = useState<RequestState<TPayload>>({ state: "loading" }) useEffect(() => { diff --git a/site/pages/workspaces/index.tsx b/site/pages/workspaces/index.tsx index 8559bd6afe0fd..89c7cebf139d0 100644 --- a/site/pages/workspaces/index.tsx +++ b/site/pages/workspaces/index.tsx @@ -1,7 +1,9 @@ import React from "react" import { useRouter } from "next/router" -import { makeStyles, Box, Paper } from "@material-ui/core" -import { AddToQueue as AddWorkspaceIcon } from "@material-ui/icons" +import { makeStyles } from "@material-ui/core/styles" +import Box from "@material-ui/core/Box" +import Paper from "@material-ui/core/Paper" +import AddWorkspaceIcon from "@material-ui/icons/AddToQueue" import { EmptyState, SplitButton } from "../../components" import { AppPage } from "../../components/PageTemplates"