diff --git a/.dockerignore b/.dockerignore index ce4738ec2e..8c611ae669 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,3 +9,5 @@ !CHANGELOG.md !LICENSE !README.md +!frontend +frontend/node_modules diff --git a/.eslintignore b/.eslintignore index 62ea7713bb..a5d4f44329 100644 --- a/.eslintignore +++ b/.eslintignore @@ -10,3 +10,4 @@ website/core website/pages website setupJest.js +frontend diff --git a/.github/workflows/build_coverage.yaml b/.github/workflows/build_coverage.yaml index a785212973..90d2066ee6 100644 --- a/.github/workflows/build_coverage.yaml +++ b/.github/workflows/build_coverage.yaml @@ -6,6 +6,7 @@ on: - main paths-ignore: - website/** + - frontend/** - coverage/** jobs: diff --git a/.github/workflows/build_frontend_prs.yml b/.github/workflows/build_frontend_prs.yml new file mode 100644 index 0000000000..caf5ca3610 --- /dev/null +++ b/.github/workflows/build_frontend_prs.yml @@ -0,0 +1,25 @@ +name: PR -> Frontend Build & Test + +on: + pull_request: + paths: + - frontend/** + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + strategy: + matrix: + node-version: [14.x] + steps: + - uses: actions/checkout@v3 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + - run: yarn --frozen-lockfile + - run: yarn run test + - run: yarn run fmt:check diff --git a/.github/workflows/build_prs.yaml b/.github/workflows/build_prs.yaml index 39f18db44a..24b108ad93 100644 --- a/.github/workflows/build_prs.yaml +++ b/.github/workflows/build_prs.yaml @@ -17,6 +17,6 @@ jobs: with: node-version: ${{ matrix.node-version }} cache: 'yarn' - - run: yarn + - run: yarn install --frozen-lockfile --ignore-scripts - run: yarn lint - run: yarn build diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index afea456143..3e2c3b8986 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -28,3 +28,14 @@ jobs: npm publish --tag ${TAG:-latest} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_DEFAULT_REGION }} + - name: Get the version + id: get_version + run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} + - name: Publish static assets to S3 + run: | + aws s3 cp frontend/build s3://getunleash-static/unleash/${{ steps.get_version.outputs.VERSION }} --recursive diff --git a/.gitignore b/.gitignore index 81bc76805d..4ab27b404f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,10 +23,6 @@ coverage/lcov-report # Compiled binary addons (http://nodejs.org/api/addons.html) build/Release -# webpack output -packages/unleash-frontend/public/bundle.js -packages/unleash-frontend/public/bundle.js.map - # liquibase stuff /sql unleash-db.jar diff --git a/Dockerfile b/Dockerfile index f875b773c0..5a322f517c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ WORKDIR /unleash COPY . /unleash -RUN yarn install --frozen-lockfile --ignore-scripts && yarn run build && yarn run local:package +RUN yarn install --frozen-lockfile && yarn run local:package WORKDIR /unleash/docker diff --git a/docker/package.json b/docker/package.json index 7d55dcf9c1..24a9cd7e3a 100644 --- a/docker/package.json +++ b/docker/package.json @@ -17,7 +17,7 @@ "@passport-next/passport-google-oauth2": "^1.0.0", "basic-auth": "^2.0.1", "passport": "^0.6.0", - "unleash-server": "file:./../build/" + "unleash-server": "file:../build" }, "resolutions": { "async": "^3.2.3", diff --git a/docker/yarn.lock b/docker/yarn.lock index 04769d0833..9fed7f479a 100644 --- a/docker/yarn.lock +++ b/docker/yarn.lock @@ -52,9 +52,9 @@ integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw== "@hapi/hoek@^9.0.0": - version "9.2.1" - resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.2.1.tgz#9551142a1980503752536b5050fd99f4a7f13b17" - integrity sha512-gfta+H8aziZsm8pZa0vj04KO6biEiisppNgA1kbJvFrrWu9Vm7eaUEy76DIxsuTaWvti5fkJVhllWc6ZTE+Mdw== + version "9.3.0" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb" + integrity sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ== "@hapi/topo@^5.0.0": version "5.1.0" @@ -69,17 +69,17 @@ integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg== "@npmcli/fs@^2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@npmcli/fs/-/fs-2.1.0.tgz#f2a21c28386e299d1a9fae8051d35ad180e33109" - integrity sha512-DmfBvNXGaetMxj9LTp8NAN9vEidXURrf5ZTslQzEAi/6GbW+4yjaLFQc6Tue5cpZ9Frlk4OBo/Snf1Bh/S7qTQ== + version "2.1.2" + resolved "https://registry.yarnpkg.com/@npmcli/fs/-/fs-2.1.2.tgz#a9e2541a4a2fec2e69c29b35e6060973da79b865" + integrity sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ== dependencies: "@gar/promisify" "^1.1.3" semver "^7.3.5" "@npmcli/move-file@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@npmcli/move-file/-/move-file-2.0.0.tgz#417f585016081a0184cef3e38902cd917a9bbd02" - integrity sha512-UR6D5f4KEGWJV6BGPH3Qb2EtgH+t+1XQ1Tt85c7qicN6cezzuHPdZwwAxqZr4JLtnQu0LZsTza/5gmNmSl8XLg== + version "2.0.1" + resolved "https://registry.yarnpkg.com/@npmcli/move-file/-/move-file-2.0.1.tgz#26f6bdc379d87f75e55739bab89db525b06100e4" + integrity sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ== dependencies: mkdirp "^1.0.4" rimraf "^3.0.2" @@ -227,7 +227,7 @@ ansi-styles@^4.0.0: append-field@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56" - integrity sha1-HjRA6RXwsSA9I3SOeO3XubW0PlY= + integrity sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw== argparse@^2.0.1: version "2.0.1" @@ -237,12 +237,12 @@ argparse@^2.0.1: arr-union@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" - integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ= + integrity sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q== array-flatten@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" - integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI= + integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== array-flatten@3.0.0: version "3.0.0" @@ -256,7 +256,7 @@ asn1@^0.2.4: dependencies: safer-buffer "~2.1.0" -async@^3.2.3, async@~0.9.0, async@~1.0.0: +async@3.2.3, async@^3.2.3, async@^3.2.4: version "3.2.3" resolved "https://registry.yarnpkg.com/async/-/async-3.2.3.tgz#ac53dafd3f4720ee9e8a160628f18ea91df196c9" integrity sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g== @@ -281,19 +281,19 @@ basic-auth@^2.0.1: bcrypt-pbkdf@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" - integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4= + integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w== dependencies: tweetnacl "^0.14.3" bcryptjs@^2.4.3: version "2.4.3" resolved "https://registry.yarnpkg.com/bcryptjs/-/bcryptjs-2.4.3.tgz#9ab5627b93e60621ff7cdac5da9733027df1d0cb" - integrity sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms= + integrity sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ== -bintrees@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/bintrees/-/bintrees-1.0.1.tgz#0e655c9b9c2435eaab68bf4027226d2b55a34524" - integrity sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ= +bintrees@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bintrees/-/bintrees-1.0.2.tgz#49f896d6e858a4a499df85c38fb399b9aff840f8" + integrity sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw== bluebird@^3.1.1, bluebird@^3.7.2: version "3.7.2" @@ -305,21 +305,23 @@ blueimp-md5@^2.10.0: resolved "https://registry.yarnpkg.com/blueimp-md5/-/blueimp-md5-2.19.0.tgz#b53feea5498dcb53dc6ec4b823adb84b729c4af0" integrity sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w== -body-parser@1.19.2: - version "1.19.2" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.2.tgz#4714ccd9c157d44797b8b5607d72c0b89952f26e" - integrity sha512-SAAwOxgoCKMGs9uUAUFHygfLAyaniaoun6I8mFY9pRAJL9+Kec34aU+oIjDhTycub1jozEfEwx1W1IuOYxVSFw== +body-parser@1.20.0: + version "1.20.0" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.0.tgz#3de69bd89011c11573d7bfee6a64f11b6bd27cc5" + integrity sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg== dependencies: bytes "3.1.2" content-type "~1.0.4" debug "2.6.9" - depd "~1.1.2" - http-errors "1.8.1" + depd "2.0.0" + destroy "1.2.0" + http-errors "2.0.0" iconv-lite "0.4.24" - on-finished "~2.3.0" - qs "6.9.7" - raw-body "2.4.3" + on-finished "2.4.1" + qs "6.10.3" + raw-body "2.5.1" type-is "~1.6.18" + unpipe "1.0.0" brace-expansion@^1.1.7: version "1.1.11" @@ -361,17 +363,17 @@ busboy@^1.0.0: bytes@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" - integrity sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg= + integrity sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw== bytes@3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== -cacache@^16.0.2: - version "16.0.7" - resolved "https://registry.yarnpkg.com/cacache/-/cacache-16.0.7.tgz#74a5d9bc4c17b4c0b373c1f5d42dadf5dc06638d" - integrity sha512-a4zfQpp5vm4Ipdvbj+ZrPonikRhm6WBEd4zT1Yc1DXsmAxrPgDwWBLF/u/wTVXSFPIgOJ1U3ghSa2Xm4s3h28w== +cacache@^16.1.0: + version "16.1.3" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-16.1.3.tgz#a02b9f34ecfaf9a78c9f4bc16fceb94d5d67a38e" + integrity sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ== dependencies: "@npmcli/fs" "^2.1.0" "@npmcli/move-file" "^2.0.0" @@ -390,12 +392,20 @@ cacache@^16.0.2: rimraf "^3.0.2" ssri "^9.0.0" tar "^6.1.11" - unique-filename "^1.1.1" + unique-filename "^2.0.0" + +call-bind@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" + integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA== + dependencies: + function-bind "^1.1.1" + get-intrinsic "^1.0.2" call-me-maybe@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b" - integrity sha1-JtII6onje1y95gJQoV8DHBak1ms= + integrity sha512-wCyFsDQkKPwwF8BDwOiWNx/9K45L/hvggQiDbve+viMNMQnWhrlYIuBk09offfwCRtCO9P6XwUttufzU11WCVw== camelcase@^5.0.0: version "5.3.1" @@ -424,7 +434,7 @@ cliui@^6.0.0: clone-deep@^0.2.4: version "0.2.4" resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-0.2.4.tgz#4e73dd09e9fb971cc38670c5dced9c1896481cc6" - integrity sha1-TnPdCen7lxzDhnDF3O2cGJZIHMY= + integrity sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg== dependencies: for-own "^0.1.3" is-plain-object "^2.0.1" @@ -444,30 +454,25 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -colorette@2.0.16: - version "2.0.16" - resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.16.tgz#713b9af84fdb000139f04546bd4a93f62a5085da" - integrity sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g== +colorette@2.0.19: + version "2.0.19" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.19.tgz#cdf044f47ad41a0f4b56b3a0d5b4e6e1a2d5a798" + integrity sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ== colors@1.0.x: version "1.0.3" resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" - integrity sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs= + integrity sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw== commander@^2.20.3: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== -commander@^7.1.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" - integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== - commander@^9.1.0: - version "9.2.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-9.2.0.tgz#6e21014b2ed90d8b7c9647230d8b7a94a4a419a9" - integrity sha512-e2i4wANQiSXgnrBlIatyHtP1odfUp0BbV5Y5nEGbxtIrStkEOAAzCUirvLBNXHLr7kwLvJl6V+4V3XV9x7Wd9w== + version "9.4.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-9.4.0.tgz#bc4a40918fefe52e22450c111ecd6b7acce6f11c" + integrity sha512-sRPT+umqkz90UA8M1yqYfnHlZA7fF6nSphDtxeywPZ49ysjxDQybzk13CL+mXekDRG92skbcqCLVovuCusNmFw== compressible@~2.0.16: version "2.0.18" @@ -492,7 +497,7 @@ compression@^1.7.4: concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== concat-stream@^1.5.2: version "1.6.2" @@ -504,13 +509,13 @@ concat-stream@^1.5.2: readable-stream "^2.2.2" typedarray "^0.0.6" -connect-session-knex@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/connect-session-knex/-/connect-session-knex-2.1.1.tgz#3543417a0c2bb6700b219e3c88f4e1b4b304f09a" - integrity sha512-gIOqwoU4mWe9uwkWsnBI9KsBr2sYp0IyXX6NJG7oGW6wJjy5CpWufB3FoJPEYb2OqNPMmshr07vS12pcMfok2g== +connect-session-knex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/connect-session-knex/-/connect-session-knex-3.0.0.tgz#23fb0fe086cdfbf2c1db9aac56fc3b0c30530810" + integrity sha512-2XmV0ELjLvtda1MhRQhTfdjLhZ3My6Ebz7TCO718O2MEo0+l6Vn3UQVpvu9wLu/dNFUnfE3UgsJIk2ZJ/OR6pA== dependencies: bluebird "^3.7.2" - knex "^0.95.6" + knex "^2.0.0" content-disposition@0.5.4: version "0.5.4" @@ -545,7 +550,7 @@ cookie-session@^2.0.0-rc.1: cookie-signature@1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" - integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw= + integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== cookie@0.4.1: version "0.4.1" @@ -557,6 +562,11 @@ cookie@0.4.2: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== +cookie@0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" + integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== + cookies@0.8.0: version "0.8.0" resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.8.0.tgz#1293ce4b391740a8406e3c9870e828c4b54f3f90" @@ -589,7 +599,7 @@ cpu-features@~0.0.4: cycle@1.0.x: version "1.0.3" resolved "https://registry.yarnpkg.com/cycle/-/cycle-1.0.3.tgz#21e80b2be8580f98b468f379430662b046c34ad2" - integrity sha1-IegLK+hYD5i0aPN5QwZisEbDStI= + integrity sha512-TVF6svNzeQCOpjCqsy0/CSy8VgObG3wXusJ73xW2GbG5rGx7lC8zxDSURicsXI2UsGdi2L0QNRCi745/wUDvsA== d@1, d@^1.0.1: version "1.0.1" @@ -600,14 +610,14 @@ d@1, d@^1.0.1: type "^1.0.1" date-fns@^2.25.0: - version "2.28.0" - resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.28.0.tgz#9570d656f5fc13143e50c975a3b6bbeb46cd08b2" - integrity sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw== + version "2.29.2" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.2.tgz#0d4b3d0f3dff0f920820a070920f0d9662c51931" + integrity sha512-0VNbwmWJDS/G3ySwFSJA3ayhbURMTJLtwM2DTxf9CWondCnh6DTNlO9JgRSq6ibf4eD0lfMJNBxUdEAHHix+bA== -date-format@^4.0.6: - version "4.0.6" - resolved "https://registry.yarnpkg.com/date-format/-/date-format-4.0.6.tgz#f6138b8f17968df9815b3d101fc06b0523f066c5" - integrity sha512-B9vvg5rHuQ8cbUXE/RMWMyX2YA5TecT3jKF5fLtGNlzPlU7zblSPmAm2OImDbWL+LDOQ6pUm+4LOFz+ywS41Zw== +date-format@^4.0.13: + version "4.0.13" + resolved "https://registry.yarnpkg.com/date-format/-/date-format-4.0.13.tgz#87c3aab3a4f6f37582c5f5f63692d2956fa67890" + integrity sha512-bnYCwf8Emc3pTD8pXnre+wfnjGtfi5ncMDKy7+cWZXbmRAsdWkOQHrfC1yz/KiwP5thDp2kCHWYWKBX4HP1hoQ== db-migrate-base@^2.3.0: version "2.3.1" @@ -673,17 +683,10 @@ debug@4, debug@4.3.4, debug@^4.1.0, debug@^4.3.3, debug@^4.3.4: dependencies: ms "2.1.2" -debug@4.3.2: - version "4.3.2" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b" - integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw== - dependencies: - ms "2.1.2" - decamelize@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" - integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= + integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== deep-extend@^0.6.0: version "0.6.0" @@ -703,18 +706,13 @@ depd@2.0.0, depd@~2.0.0: depd@^1.1.2, depd@~1.1.0, depd@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" - integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= + integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== destroy@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== -destroy@~1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" - integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA= - dotenv@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-5.0.1.tgz#a5317459bd3d79ab88cff6e44057a6a3fbb1fcef" @@ -723,7 +721,7 @@ dotenv@^5.0.1: ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" - integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== emoji-regex@^8.0.0: version "8.0.0" @@ -733,7 +731,7 @@ emoji-regex@^8.0.0: encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" - integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= + integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== encoding@^0.1.13: version "0.1.13" @@ -756,9 +754,9 @@ errorhandler@^1.5.1: escape-html "~1.0.3" es5-ext@^0.10.35, es5-ext@^0.10.46, es5-ext@^0.10.50, es5-ext@^0.10.53, es5-ext@~0.10.14, es5-ext@~0.10.2, es5-ext@~0.10.46: - version "0.10.59" - resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.59.tgz#71038939730eb6f4f165f1421308fb60be363bc6" - integrity sha512-cOgyhW0tIJyQY1Kfw6Kr0viu9ZlUctVchRMZ7R0HiH3dxTSp5zJDLecwxUqPUrGKMsgBI1wd1FL+d9Jxfi4cLw== + version "0.10.62" + resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.62.tgz#5e6adc19a6da524bf3d1e02bbc8960e5eb49a9a5" + integrity sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA== dependencies: es6-iterator "^2.0.3" es6-symbol "^3.1.3" @@ -767,7 +765,7 @@ es5-ext@^0.10.35, es5-ext@^0.10.46, es5-ext@^0.10.50, es5-ext@^0.10.53, es5-ext@ es6-iterator@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7" - integrity sha1-p96IkUGgWpSwhUQDstCg+/qY87c= + integrity sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g== dependencies: d "1" es5-ext "^0.10.35" @@ -799,7 +797,7 @@ escalade@^3.1.1: escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" - integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== esm@^3.2.25: version "3.2.25" @@ -809,22 +807,22 @@ esm@^3.2.25: etag@~1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" - integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== event-emitter@^0.3.5: version "0.3.5" resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39" - integrity sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk= + integrity sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA== dependencies: d "1" es5-ext "~0.10.14" express-session@^1.17.1: - version "1.17.2" - resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.17.2.tgz#397020374f9bf7997f891b85ea338767b30d0efd" - integrity sha512-mPcYcLA0lvh7D4Oqr5aNJFMtBMKPLl++OKKxkHzZ0U0oDq1rpKBnkR5f5vCHR26VeArlTOEF9td4x5IjICksRQ== + version "1.17.3" + resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.17.3.tgz#14b997a15ed43e5949cb1d073725675dd2777f36" + integrity sha512-4+otWXlShYlG1Ma+2Jnn+xgKUZTMJ5QD3YvfilX3AcocOAbIkVylSWEklzALe/+Pu4qV6TYBj5GwOBFfdKqLBw== dependencies: - cookie "0.4.1" + cookie "0.4.2" cookie-signature "1.0.6" debug "2.6.9" depd "~2.0.0" @@ -834,37 +832,38 @@ express-session@^1.17.1: uid-safe "~2.1.5" express@^4.17.1: - version "4.17.3" - resolved "https://registry.yarnpkg.com/express/-/express-4.17.3.tgz#f6c7302194a4fb54271b73a1fe7a06478c8f85a1" - integrity sha512-yuSQpz5I+Ch7gFrPCk4/c+dIBKlQUxtgwqzph132bsT6qhuzss6I8cLJQz7B3rFblzd6wtcI0ZbGltH/C4LjUg== + version "4.18.1" + resolved "https://registry.yarnpkg.com/express/-/express-4.18.1.tgz#7797de8b9c72c857b9cd0e14a5eea80666267caf" + integrity sha512-zZBcOX9TfehHQhtupq57OF8lFZ3UZi08Y97dwFCkD8p9d/d2Y3M+ykKcwaMDEL+4qyUolgBDX6AblpR3fL212Q== dependencies: accepts "~1.3.8" array-flatten "1.1.1" - body-parser "1.19.2" + body-parser "1.20.0" content-disposition "0.5.4" content-type "~1.0.4" - cookie "0.4.2" + cookie "0.5.0" cookie-signature "1.0.6" debug "2.6.9" - depd "~1.1.2" + depd "2.0.0" encodeurl "~1.0.2" escape-html "~1.0.3" etag "~1.8.1" - finalhandler "~1.1.2" + finalhandler "1.2.0" fresh "0.5.2" + http-errors "2.0.0" merge-descriptors "1.0.1" methods "~1.1.2" - on-finished "~2.3.0" + on-finished "2.4.1" parseurl "~1.3.3" path-to-regexp "0.1.7" proxy-addr "~2.0.7" - qs "6.9.7" + qs "6.10.3" range-parser "~1.2.1" safe-buffer "5.2.1" - send "0.17.2" - serve-static "1.14.2" + send "0.18.0" + serve-static "1.15.0" setprototypeof "1.2.0" - statuses "~1.5.0" + statuses "2.0.1" type-is "~1.6.18" utils-merge "1.0.1" vary "~1.1.2" @@ -879,7 +878,7 @@ ext@^1.1.2: eyes@0.1.x: version "0.1.8" resolved "https://registry.yarnpkg.com/eyes/-/eyes-0.1.8.tgz#62cf120234c683785d902348a800ef3e0cc20bc0" - integrity sha1-Ys8SAjTGg3hdkCNIqADvPgzCC8A= + integrity sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ== fast-deep-equal@^3.1.1: version "3.1.3" @@ -899,22 +898,22 @@ fast-json-stable-stringify@^2.0.0: final-fs@^1.6.0: version "1.6.1" resolved "https://registry.yarnpkg.com/final-fs/-/final-fs-1.6.1.tgz#d6dcd92ef6fe4fe8c07abd568c7135610ede3236" - integrity sha1-1tzZLvb+T+jAer1WjHE1YQ7eMjY= + integrity sha512-r5dgz23H8qh1LxKVJK84zet2PhWSWkIOgbLVUd5PlNFAULD/kCDBH9JEMwJt9dpdTnLsSD4rEqS56p2MH7Wbvw== dependencies: node-fs "~0.1.5" when "~2.0.1" -finalhandler@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" - integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA== +finalhandler@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" + integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg== dependencies: debug "2.6.9" encodeurl "~1.0.2" escape-html "~1.0.3" - on-finished "~2.3.0" + on-finished "2.4.1" parseurl "~1.3.3" - statuses "~1.5.0" + statuses "2.0.1" unpipe "~1.0.0" find-up@^4.1.0: @@ -925,25 +924,25 @@ find-up@^4.1.0: locate-path "^5.0.0" path-exists "^4.0.0" -flatted@^3.2.5: - version "3.2.5" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.5.tgz#76c8584f4fc843db64702a6bd04ab7a8bd666da3" - integrity sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg== +flatted@^3.2.6: + version "3.2.7" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787" + integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== for-in@^0.1.3: version "0.1.8" resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.8.tgz#d8773908e31256109952b1fdb9b3fa867d2775e1" - integrity sha1-2Hc5COMSVhCZUrH9ubP6hn0ndeE= + integrity sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g== for-in@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" - integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= + integrity sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ== for-own@^0.1.3: version "0.1.5" resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce" - integrity sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4= + integrity sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw== dependencies: for-in "^1.0.1" @@ -955,16 +954,16 @@ forwarded@0.2.0: fresh@0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" - integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= + integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== -fs-extra@^10.0.1: - version "10.0.1" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.0.1.tgz#27de43b4320e833f6867cc044bfce29fdf0ef3b8" - integrity sha512-NbdoVMZso2Lsrn/QwLXOy6rm0ufY2zEOKCDzJR/0kBsb0E6qed0P3iYK+Ath3BfvXEeu4JhEtXLgILx5psUfag== +fs-extra@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" + integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== dependencies: graceful-fs "^4.2.0" - jsonfile "^6.0.1" - universalify "^2.0.0" + jsonfile "^4.0.0" + universalify "^0.1.0" fs-minipass@^2.0.0, fs-minipass@^2.1.0: version "2.1.0" @@ -976,7 +975,7 @@ fs-minipass@^2.0.0, fs-minipass@^2.1.0: fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== function-bind@^1.1.1: version "1.1.1" @@ -988,44 +987,47 @@ get-caller-file@^2.0.1: resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== +get-intrinsic@^1.0.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.2.tgz#336975123e05ad0b7ba41f152ee4aadbea6cf598" + integrity sha512-Jfm3OyCxHh9DJyc28qGk+JmfkpO41A4XkneDSujN9MDXrm4oDKdHvndhZ2dN94+ERNfkYJWDclW6k2L/ZGHjXA== + dependencies: + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.3" + get-package-type@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== -getopts@2.2.5: - version "2.2.5" - resolved "https://registry.yarnpkg.com/getopts/-/getopts-2.2.5.tgz#67a0fe471cacb9c687d817cab6450b96dde8313b" - integrity sha512-9jb7AW5p3in+IiJWhQiZmmwkpLaR/ccTWdWQCtZM66HJcHHLegowh4q4tSD7gouUyeNvFWRavfK9GXosQHDpFA== - getopts@2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/getopts/-/getopts-2.3.0.tgz#71e5593284807e03e2427449d4f6712a268666f4" integrity sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA== glob@^7.1.3: - version "7.2.0" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" - integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== dependencies: fs.realpath "^1.0.0" inflight "^1.0.4" inherits "2" - minimatch "^3.0.4" + minimatch "^3.1.1" once "^1.3.0" path-is-absolute "^1.0.0" glob@^8.0.1: - version "8.0.1" - resolved "https://registry.yarnpkg.com/glob/-/glob-8.0.1.tgz#00308f5c035aa0b2a447cd37ead267ddff1577d3" - integrity sha512-cF7FYZZ47YzmCu7dDy50xSRRfO3ErRfrXuLZcNIuyiJEco0XSrGtuilG19L5xp3NcwTx7Gn+X6Tv3fmsUPTbow== + version "8.0.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-8.0.3.tgz#415c6eb2deed9e502c68fa44a272e6da6eeca42e" + integrity sha512-ull455NHSHI/Y1FqGaaYFaLGkNMMJbavMrEGFXG/PGrg6y7sutWHUHrz6gy6WEBH6akM1M414dWKCNs+IhKdiQ== dependencies: fs.realpath "^1.0.0" inflight "^1.0.4" inherits "2" minimatch "^5.0.1" once "^1.3.0" - path-is-absolute "^1.0.0" graceful-fs@^4.1.6, graceful-fs@^4.2.0: version "4.2.10" @@ -1040,6 +1042,11 @@ gravatar-url@^3.1.0: md5-hex "^3.0.1" type-fest "^0.8.1" +has-symbols@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" + integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== + has@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" @@ -1048,26 +1055,15 @@ has@^1.0.3: function-bind "^1.1.1" helmet@^5.0.0: - version "5.0.2" - resolved "https://registry.yarnpkg.com/helmet/-/helmet-5.0.2.tgz#3264ec6bab96c82deaf65e3403c369424cb2366c" - integrity sha512-QWlwUZZ8BtlvwYVTSDTBChGf8EOcQ2LkGMnQJxSzD1mUu8CCjXJZq/BXP8eWw4kikRnzlhtYo3lCk0ucmYA3Vg== + version "5.1.1" + resolved "https://registry.yarnpkg.com/helmet/-/helmet-5.1.1.tgz#609823c5c2e78aea62dd9afc8f544ca409da5e85" + integrity sha512-/yX0oVZBggA9cLJh8aw3PPCfedBnbd7J2aowjzsaWwZh7/UFY0nccn/aHAggIgWUFfnykX8GKd3a1pSbrmlcVQ== http-cache-semantics@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ== -http-errors@1.8.1, http-errors@^1.7.3: - version "1.8.1" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c" - integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g== - dependencies: - depd "~1.1.2" - inherits "2.0.4" - setprototypeof "1.2.0" - statuses ">= 1.5.0 < 2" - toidentifier "1.0.1" - http-errors@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" @@ -1079,6 +1075,17 @@ http-errors@2.0.0: statuses "2.0.1" toidentifier "1.0.1" +http-errors@^1.7.3: + version "1.8.1" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c" + integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g== + dependencies: + depd "~1.1.2" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses ">= 1.5.0 < 2" + toidentifier "1.0.1" + http-proxy-agent@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" @@ -1099,7 +1106,7 @@ https-proxy-agent@^5.0.0: humanize-ms@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" - integrity sha1-xG4xWaKT9riW2ikxbYtv6Lt5u+0= + integrity sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ== dependencies: ms "^2.0.0" @@ -1120,7 +1127,7 @@ iconv-lite@^0.6.2: imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" - integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== indent-string@^4.0.0: version "4.0.0" @@ -1140,7 +1147,7 @@ inflection@^1.10.0: inflight@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== dependencies: once "^1.3.0" wrappy "1" @@ -1160,10 +1167,15 @@ interpret@^2.2.0: resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9" integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw== -ip@^1.1.5: - version "1.1.5" - resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" - integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo= +ip@^1.1.5, ip@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.8.tgz#ae05948f6b075435ed3307acce04629da8cdbf48" + integrity sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg== + +ip@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ip/-/ip-2.0.0.tgz#4cf4ab182fee2314c75ede1276f8c80b479936da" + integrity sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ== ipaddr.js@1.9.1: version "1.9.1" @@ -1175,17 +1187,17 @@ is-buffer@^1.0.2, is-buffer@^1.1.5: resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== -is-core-module@^2.8.1: - version "2.8.1" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.8.1.tgz#f59fdfca701d5879d0a6b100a40aa1560ce27211" - integrity sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA== +is-core-module@^2.9.0: + version "2.10.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.10.0.tgz#9012ede0a91c69587e647514e1d5277019e728ed" + integrity sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg== dependencies: has "^1.0.3" is-extendable@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" - integrity sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik= + integrity sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw== is-fullwidth-code-point@^3.0.0: version "3.0.0" @@ -1195,7 +1207,7 @@ is-fullwidth-code-point@^3.0.0: is-lambda@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5" - integrity sha1-PZh3iZ5qU+/AFgUEzeFfgubwYdU= + integrity sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ== is-plain-object@^2.0.1, is-plain-object@^2.0.4: version "2.0.4" @@ -1217,17 +1229,17 @@ is-promise@^2.2.2: isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" - integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= + integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== isobject@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" - integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= + integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== isstream@0.1.x: version "0.1.2" resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" - integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= + integrity sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g== joi@^17.3.0: version "17.6.0" @@ -1247,10 +1259,10 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" -json-schema-to-ts@^2.5.3: - version "2.5.3" - resolved "https://registry.yarnpkg.com/json-schema-to-ts/-/json-schema-to-ts-2.5.3.tgz#10a1ad27a3cc6117ae9c652cc583a9e0ed10f0c8" - integrity sha512-2vABI+1IZNkChaPfLu7PG192ZY9gvRY00RbuN3VGlNNZkvYRpIECdBZPBVMe41r3wX0sl9emjRyhHT3gTm7HIg== +json-schema-to-ts@2.5.5: + version "2.5.5" + resolved "https://registry.yarnpkg.com/json-schema-to-ts/-/json-schema-to-ts-2.5.5.tgz#09022355466dc07451b0d1235d4056ae67bceaa1" + integrity sha512-GFD5t0fUnX/B0gE9xbHjxv2BwFXRJND2+OKoLoMElJ3XRJ7dOBlLT7KXpg96aETeZ0RJbAZOfqHALBf5k4aIIA== dependencies: "@types/json-schema" "^7.0.9" ts-algebra "^1.1.1" @@ -1271,12 +1283,10 @@ json-schema@^0.4.0: resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== -jsonfile@^6.0.1: - version "6.1.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" - integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== - dependencies: - universalify "^2.0.0" +jsonfile@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" + integrity sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg== optionalDependencies: graceful-fs "^4.1.6" @@ -1290,14 +1300,14 @@ keygrip@~1.1.0: kind-of@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-2.0.1.tgz#018ec7a4ce7e3a86cb9141be519d24c8faa981b5" - integrity sha1-AY7HpM5+OobLkUG+UZ0kyPqpgbU= + integrity sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg== dependencies: is-buffer "^1.0.2" kind-of@^3.0.2: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" - integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ= + integrity sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ== dependencies: is-buffer "^1.1.5" @@ -1306,31 +1316,12 @@ kind-of@^6.0.3: resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== -knex@^0.95.6: - version "0.95.15" - resolved "https://registry.yarnpkg.com/knex/-/knex-0.95.15.tgz#39d7e7110a6e2ad7de5d673d2dea94143015e0e7" - integrity sha512-Loq6WgHaWlmL2bfZGWPsy4l8xw4pOE+tmLGkPG0auBppxpI0UcK+GYCycJcqz9W54f2LiGewkCVLBm3Wq4ur/w== - dependencies: - colorette "2.0.16" - commander "^7.1.0" - debug "4.3.2" - escalade "^3.1.1" - esm "^3.2.25" - getopts "2.2.5" - interpret "^2.2.0" - lodash "^4.17.21" - pg-connection-string "2.5.0" - rechoir "0.7.0" - resolve-from "^5.0.0" - tarn "^3.0.1" - tildify "2.0.0" - knex@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/knex/-/knex-2.0.0.tgz#84296ced7ef27e0f3b954302ac7d9da67c28b5d3" - integrity sha512-LchC8/GLfreMz8d4kCwh/ymXttsoJG8zO1O0AJBjnxdyr2oT/k2ik77hP1PpZkZH9mDQrq6WsQcIu18Pnqppzg== + version "2.2.0" + resolved "https://registry.yarnpkg.com/knex/-/knex-2.2.0.tgz#86a3176924d37303b3f9ff7f70087418c263ce7a" + integrity sha512-yhm1Qe9Ok0TeXBq3nNHqZYJPrQ4Iw2tq9k/HxjrZ/EWec2ifOjJlkNHr26v8cQrWtk5iG3iwfUazTIWy+VKG5g== dependencies: - colorette "2.0.16" + colorette "2.0.19" commander "^9.1.0" debug "4.3.4" escalade "^3.1.1" @@ -1348,12 +1339,12 @@ knex@^2.0.0: lazy-cache@^0.2.3: version "0.2.7" resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-0.2.7.tgz#7feddf2dcb6edb77d11ef1d117ab5ffdf0ab1b65" - integrity sha1-f+3fLctu23fRHvHRF6tf/fCrG2U= + integrity sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ== lazy-cache@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e" - integrity sha1-odePw6UEdMuAhF07O24dpJpEbo4= + integrity sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ== locate-path@^5.0.0: version "5.0.0" @@ -1365,17 +1356,17 @@ locate-path@^5.0.0: lodash.defaults@^4.1.0: version "4.2.0" resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" - integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw= + integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ== lodash.get@^4.4.2: version "4.4.2" resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" - integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= + integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== lodash.isequal@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" - integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA= + integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ== lodash@^4.17.11, lodash@^4.17.21: version "4.17.21" @@ -1383,40 +1374,42 @@ lodash@^4.17.11, lodash@^4.17.21: integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== log4js@^6.0.0: - version "6.4.4" - resolved "https://registry.yarnpkg.com/log4js/-/log4js-6.4.4.tgz#c9bc75569f3f40bba22fe1bd0677afa7a6a13bac" - integrity sha512-ncaWPsuw9Vl1CKA406hVnJLGQKy1OHx6buk8J4rE2lVW+NW5Y82G5/DIloO7NkqLOUtNPEANaWC1kZYVjXssPw== + version "6.6.1" + resolved "https://registry.yarnpkg.com/log4js/-/log4js-6.6.1.tgz#48f23de8a87d2f5ffd3d913f24ca9ce77895272f" + integrity sha512-J8VYFH2UQq/xucdNu71io4Fo+purYYudyErgBbswWKO0MC6QVOERRomt5su/z6d3RJSmLyTGmXl3Q/XjKCf+/A== dependencies: - date-format "^4.0.6" + date-format "^4.0.13" debug "^4.3.4" - flatted "^3.2.5" + flatted "^3.2.6" rfdc "^1.3.0" - streamroller "^3.0.6" + streamroller "^3.1.2" -lru-cache@^7.4.0: - version "7.7.3" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.7.3.tgz#98cd19eef89ce6a4a3c4502c17c833888677c252" - integrity sha512-WY9wjJNQt9+PZilnLbuFKM+SwDull9+6IAguOrarOMoOHTcJ9GnXSO11+Gw6c7xtDkBkthR57OZMtZKYr+1CEw== +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" lru-cache@^7.7.1: - version "7.9.0" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.9.0.tgz#29c2a989b6c10f32ceccc66ff44059e1490af3e1" - integrity sha512-lkcNMUKqdJk96TuIXUidxaPuEg5sJo/+ZyVE2BDFnuZGzwXem7d8582eG8vbu4todLfT14snP6iHriCHXXi5Rw== + version "7.14.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.14.0.tgz#21be64954a4680e303a09e9468f880b98a0b3c7f" + integrity sha512-EIRtP1GrSJny0dqb50QXRUNBxHJhcpxHC++M5tD7RYbvLLn5KVWKsbyswSSqDuU15UFi3bgTQIY8nhDMeF6aDQ== lru-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3" - integrity sha1-Jzi9nw089PhEkMVzbEhpmsYyzaM= + integrity sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ== dependencies: es5-ext "~0.10.2" -make-fetch-happen@^10.1.2: - version "10.1.2" - resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-10.1.2.tgz#acffef43f86250602b932eecc0ad3acc992ae233" - integrity sha512-GWMGiZsKVeJACQGJ1P3Z+iNec7pLsU6YW1q11eaPn3RR8nRXHppFWfP7Eu0//55JK3hSjrAQRl8sDa5uXpq1Ew== +make-fetch-happen@^10.0.0, make-fetch-happen@^10.1.2: + version "10.2.1" + resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz#f5e3835c5e9817b617f2770870d9492d28678164" + integrity sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w== dependencies: agentkeepalive "^4.2.1" - cacache "^16.0.2" + cacache "^16.1.0" http-cache-semantics "^4.1.0" http-proxy-agent "^5.0.0" https-proxy-agent "^5.0.0" @@ -1429,7 +1422,7 @@ make-fetch-happen@^10.1.2: minipass-pipeline "^1.2.4" negotiator "^0.6.3" promise-retry "^2.0.1" - socks-proxy-agent "^6.1.1" + socks-proxy-agent "^7.0.0" ssri "^9.0.0" md5-hex@^3.0.1: @@ -1442,7 +1435,7 @@ md5-hex@^3.0.1: media-typer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" - integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== memoizee@^0.4.15: version "0.4.15" @@ -1470,12 +1463,12 @@ merge-deep@^3.0.2: merge-descriptors@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" - integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E= + integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" - integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= + integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== mime-db@1.52.0, "mime-db@>= 1.43.0 < 2": version "1.52.0" @@ -1499,7 +1492,7 @@ mime@^3.0.0: resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7" integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A== -minimatch@^3.0.4: +minimatch@^3.1.1: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== @@ -1507,9 +1500,9 @@ minimatch@^3.0.4: brace-expansion "^1.1.7" minimatch@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.0.1.tgz#fb9022f7528125187c92bd9e9b6366be1cf3415b" - integrity sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g== + version "5.1.0" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.0.tgz#1717b464f4971b144f6aabe8f2d0b8e4511e09c7" + integrity sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg== dependencies: brace-expansion "^2.0.1" @@ -1526,9 +1519,9 @@ minipass-collect@^1.0.2: minipass "^3.0.0" minipass-fetch@^2.0.3: - version "2.1.0" - resolved "https://registry.yarnpkg.com/minipass-fetch/-/minipass-fetch-2.1.0.tgz#ca1754a5f857a3be99a9271277246ac0b44c3ff8" - integrity sha512-H9U4UVBGXEyyWJnqYDCLp1PwD8XIkJ4akNHp1aGVI+2Ym7wQMlxDKi4IB4JbmyU+pl9pEs/cVrK6cOuvmbK4Sg== + version "2.1.2" + resolved "https://registry.yarnpkg.com/minipass-fetch/-/minipass-fetch-2.1.2.tgz#95560b50c472d81a3bc76f20ede80eaed76d8add" + integrity sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA== dependencies: minipass "^3.1.6" minipass-sized "^1.0.3" @@ -1558,9 +1551,9 @@ minipass-sized@^1.0.3: minipass "^3.0.0" minipass@^3.0.0, minipass@^3.1.1, minipass@^3.1.6: - version "3.1.6" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.6.tgz#3b8150aa688a711a1521af5e8779c1d3bb4f45ee" - integrity sha512-rty5kpw9/z8SX9dmxblFA6edItUmwJgMeYDZRrwlIVN27i8gysGbznJwUggw2V/FVqFSDdWy040ZPS811DYAqQ== + version "3.3.4" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.4.tgz#ca99f95dd77c43c7a76bf51e6d200025eee0ffae" + integrity sha512-I9WPbWHCGu8W+6k1ZiGpPu0GkoKBeorkfKNuAFBNS1HNFJvke82sxvI5bzcCNpWPorkOO5QQ+zomzzwRxejXiw== dependencies: yallist "^4.0.0" @@ -1575,7 +1568,7 @@ minizlib@^2.1.1, minizlib@^2.1.2: mixin-object@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/mixin-object/-/mixin-object-2.0.1.tgz#4fb949441dab182540f1fe035ba60e1947a5e57e" - integrity sha1-T7lJRB2rGCVA8f4DW6YOGUel5X4= + integrity sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA== dependencies: for-in "^0.1.3" is-extendable "^0.1.1" @@ -1595,12 +1588,12 @@ mkdirp@^1.0.3, mkdirp@^1.0.4: "mongodb-uri@>= 0.9.7": version "0.9.7" resolved "https://registry.yarnpkg.com/mongodb-uri/-/mongodb-uri-0.9.7.tgz#0f771ad16f483ae65f4287969428e9fbc4aa6181" - integrity sha1-D3ca0W9IOuZfQoeWlCjp+8SqYYE= + integrity sha512-s6BdnqNoEYfViPJgkH85X5Nw5NpzxN8hoflKLweNa7vBxt2V7kaS06d74pAtqDxde8fn4r9h4dNdLiFGoNV0KA== ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== ms@2.1.1: version "2.1.1" @@ -1630,6 +1623,11 @@ multer@^1.4.5-lts.1: type-is "^1.6.4" xtend "^4.0.0" +murmurhash3js@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/murmurhash3js/-/murmurhash3js-3.0.1.tgz#3e983e5b47c2a06f43a713174e7e435ca044b998" + integrity sha512-KL8QYUaxq7kUbcl0Yto51rMcYt7E/4N4BG3/c96Iqw1PQrTRspu8Cpx4TZ4Nunib1d4bEkIH3gjCYlP2RLBdow== + mustache@^4.1.0: version "4.2.0" resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64" @@ -1641,9 +1639,9 @@ mute-stream@~0.0.4: integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== nan@^2.15.0: - version "2.15.0" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee" - integrity sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ== + version "2.16.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.16.0.tgz#664f43e45460fb98faf00edca0bb0d7b8dce7916" + integrity sha512-UdAqHyFngu7TfQKsCBgAA6pWDkT8MAO7d0jyOecVhN5354xbLqdn8mV9Tat9gepAupm0bt2DbeaSC8vS52MuFA== negotiator@0.6.3, negotiator@^0.6.3: version "0.6.3" @@ -1658,12 +1656,12 @@ next-tick@1, next-tick@^1.1.0: node-fs@~0.1.5: version "0.1.7" resolved "https://registry.yarnpkg.com/node-fs/-/node-fs-0.1.7.tgz#32323cccb46c9fbf0fc11812d45021cc31d325bb" - integrity sha1-MjI8zLRsn78PwRgS1FAhzDHTJbs= + integrity sha512-XqDBlmUKgDGe76+lZ/0sRBF3XW2vVcK07+ZPvdpUTK8jrvtPahUd0aBqJ9+ZjB01ANjZLuvK3O/eoMVmz62rpA== nodemailer@^6.5.0: - version "6.7.3" - resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.7.3.tgz#b73f9a81b9c8fa8acb4ea14b608f5e725ea8e018" - integrity sha512-KUdDsspqx89sD4UUyUKzdlUOper3hRkDVkrKh/89G+d9WKsU5ox51NWS4tB1XR5dPUdR4SP0E3molyEfOvSa3g== + version "6.7.8" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.7.8.tgz#9f1af9911314960c0b889079e1754e8d9e3f740a" + integrity sha512-2zaTFGqZixVmTxpJRCFC+Vk5eGRd/fYtvIR+dl5u9QXLTQWGIf48x/JXvo58g9sa0bU6To04XUv554Paykum3g== oauth@0.9.x: version "0.9.15" @@ -1673,7 +1671,12 @@ oauth@0.9.x: object-assign@^4, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" - integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +object-inspect@^1.9.0: + version "1.12.2" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea" + integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ== on-finished@2.4.1: version "2.4.1" @@ -1682,13 +1685,6 @@ on-finished@2.4.1: dependencies: ee-first "1.1.1" -on-finished@~2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" - integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc= - dependencies: - ee-first "1.1.1" - on-headers@~1.0.1, on-headers@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" @@ -1697,7 +1693,7 @@ on-headers@~1.0.1, on-headers@~1.0.2: once@^1.3.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== dependencies: wrappy "1" @@ -1709,7 +1705,7 @@ openapi-types@^12.0.0: owasp-password-strength-test@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/owasp-password-strength-test/-/owasp-password-strength-test-1.3.0.tgz#4f629e42903e8f6d279b230d657ab61e58e44b12" - integrity sha1-T2KeQpA+j20nmyMNZXq2HljkSxI= + integrity sha512-33/Z+vyjlFaVZsT7aAFe3SkQZdU6su59XNkYdU5o2Fssz0D9dt6uiFaMm62M7dFQSKogULq8UYvdKnHkeqNB2w== p-limit@^2.2.0: version "2.3.0" @@ -1745,7 +1741,7 @@ packet-reader@1.0.0: parse-database-url@^0.3.0, parse-database-url@~0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/parse-database-url/-/parse-database-url-0.3.0.tgz#369666321e927c9ade63cdfc1aaaf6fb37453d0d" - integrity sha1-NpZmMh6SfJreY838Gqr2+zdFPQ0= + integrity sha512-YRxDoVBAUk3ksGF9pud+aqWwXmThZzhX9Z1PPxKU03BB3/gu2RcgyMA4rktMYhkIJ9KxwW7lIj00U+TSNz80wg== dependencies: mongodb-uri ">= 0.9.7" @@ -1787,7 +1783,7 @@ path-exists@^4.0.0: path-is-absolute@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== path-parse@^1.0.7: version "1.0.7" @@ -1797,7 +1793,7 @@ path-parse@^1.0.7: path-to-regexp@0.1.7: version "0.1.7" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" - integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= + integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== path-to-regexp@^2.4.0: version "2.4.0" @@ -1819,10 +1815,10 @@ pg-int8@1.0.1: resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== -pg-pool@^3.5.1: - version "3.5.1" - resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.5.1.tgz#f499ce76f9bf5097488b3b83b19861f28e4ed905" - integrity sha512-6iCR0wVrro6OOHFsyavV+i6KYL4lVNyYAB9RD18w66xSzN+d8b66HiwuP30Gp1SH5O9T82fckkzsRjlrhD0ioQ== +pg-pool@^3.5.2: + version "3.5.2" + resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.5.2.tgz#ed1bed1fb8d79f1c6fd5fb1c99e990fbf9ddf178" + integrity sha512-His3Fh17Z4eg7oANLob6ZvH8xIVen3phEZh2QuyrIl4dQSDVEabNducv6ysROKpDNPSD+12tONZVWfSgMvDD9w== pg-protocol@^1.5.0: version "1.5.0" @@ -1841,14 +1837,14 @@ pg-types@^2.1.0: postgres-interval "^1.1.0" pg@^8.0.3, pg@^8.7.3: - version "8.7.3" - resolved "https://registry.yarnpkg.com/pg/-/pg-8.7.3.tgz#8a5bdd664ca4fda4db7997ec634c6e5455b27c44" - integrity sha512-HPmH4GH4H3AOprDJOazoIcpI49XFsHCe8xlrjHkWiapdbHK+HLtbm/GQzXYAZwmPju/kzKhjaSfMACG+8cgJcw== + version "8.8.0" + resolved "https://registry.yarnpkg.com/pg/-/pg-8.8.0.tgz#a77f41f9d9ede7009abfca54667c775a240da686" + integrity sha512-UXYN0ziKj+AeNNP7VDMwrehpACThH7LUl/p8TDFpEUuSejCUIwGSfxpHsPvtM6/WXFy6SU4E5RG4IJV/TZAGjw== dependencies: buffer-writer "2.0.0" packet-reader "1.0.0" pg-connection-string "^2.5.0" - pg-pool "^3.5.1" + pg-pool "^3.5.2" pg-protocol "^1.5.0" pg-types "^2.1.0" pgpass "1.x" @@ -1863,7 +1859,7 @@ pgpass@1.x: pkginfo@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/pkginfo/-/pkginfo-0.4.1.tgz#b5418ef0439de5425fc4995042dced14fb2a84ff" - integrity sha1-tUGO8EOd5UJfxJlQQtztFPsqhP8= + integrity sha512-8xCNE/aT/EXKenuMDZ+xTVwkT8gsoHN2z/Q29l80u0ppGEXVvsKRzNMbtKhg8LS8k1tJLAHHylf6p4VFmP6XUQ== postgres-array@~2.0.0: version "2.0.0" @@ -1873,7 +1869,7 @@ postgres-array@~2.0.0: postgres-bytea@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-1.0.0.tgz#027b533c0aa890e26d172d47cf9ccecc521acd35" - integrity sha1-AntTPAqokOJtFy1Hz5zOzFIazTU= + integrity sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w== postgres-date@~1.0.4: version "1.0.7" @@ -1893,16 +1889,16 @@ process-nextick-args@~2.0.0: integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== prom-client@^14.0.0: - version "14.0.1" - resolved "https://registry.yarnpkg.com/prom-client/-/prom-client-14.0.1.tgz#bdd9583e02ec95429677c0e013712d42ef1f86a8" - integrity sha512-HxTArb6fkOntQHoRGvv4qd/BkorjliiuO2uSWC2KC17MUTKYttWdDoXX/vxOhQdkoECEM9BBH0pj2l8G8kev6w== + version "14.1.0" + resolved "https://registry.yarnpkg.com/prom-client/-/prom-client-14.1.0.tgz#049609859483d900844924df740722c76ed1fdbb" + integrity sha512-iFWCchQmi4170omLpFXbzz62SQTmPhtBL35v0qGEVRHKcqIeiexaoYeP0vfZTujxEq3tA87iqOdRbC9svS1B9A== dependencies: tdigest "^0.1.1" promise-inflight@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" - integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= + integrity sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g== promise-retry@^2.0.1: version "2.0.1" @@ -1913,12 +1909,12 @@ promise-retry@^2.0.1: retry "^0.12.0" prompt@^1.0.0: - version "1.2.2" - resolved "https://registry.yarnpkg.com/prompt/-/prompt-1.2.2.tgz#b624fcf53aa6c8c5637e009c193ef69eee45dbe0" - integrity sha512-XNXhNv3PUHJDcDkISpCwSJxtw9Bor4FZnlMUDW64N/KCPdxhfVlpD5+YUXI/Z8a9QWmOhs9KSiVtR8nzPS0BYA== + version "1.3.0" + resolved "https://registry.yarnpkg.com/prompt/-/prompt-1.3.0.tgz#b1f6d47cb1b6beed4f0660b470f5d3ec157ad7ce" + integrity sha512-ZkaRWtaLBZl7KKAKndKYUL8WqNT+cQHKRZnT4RYYms48jQkFw3rrBL+/N5K/KtdEveHkxs982MX2BkDKub2ZMg== dependencies: "@colors/colors" "1.5.0" - async "~0.9.0" + async "3.2.3" read "1.0.x" revalidator "0.1.x" winston "2.x" @@ -1936,28 +1932,30 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== -qs@6.9.7: - version "6.9.7" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.7.tgz#4610846871485e1e048f44ae3b94033f0e675afe" - integrity sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw== +qs@6.10.3: + version "6.10.3" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.3.tgz#d6cde1b2ffca87b5aa57889816c5f81535e22e8e" + integrity sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ== + dependencies: + side-channel "^1.0.4" random-bytes@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b" - integrity sha1-T2ih3Arli9P7lYSMMDJNt11kNgs= + integrity sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ== range-parser@~1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== -raw-body@2.4.3: - version "2.4.3" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.3.tgz#8f80305d11c2a0a545c2d9d89d7a0286fcead43c" - integrity sha512-UlTNLIcu0uzb4D2f4WltY6cVjLi+/jEN4lgEUj3E04tpMDpUlkBo/eSn6zou9hum2VMNpCCUone0O0WeJim07g== +raw-body@2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" + integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig== dependencies: bytes "3.1.2" - http-errors "1.8.1" + http-errors "2.0.0" iconv-lite "0.4.24" unpipe "1.0.0" @@ -1974,7 +1972,7 @@ rc@^1.2.8: read@1.0.x: version "1.0.7" resolved "https://registry.yarnpkg.com/read/-/read-1.0.7.tgz#b3da19bd052431a97671d44a42634adf710b40c4" - integrity sha1-s9oZvQUkMal2cdRKQmNK33ELQMQ= + integrity sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ== dependencies: mute-stream "~0.0.4" @@ -1991,13 +1989,6 @@ readable-stream@^2.2.2: string_decoder "~1.1.1" util-deprecate "~1.0.1" -rechoir@0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.7.0.tgz#32650fd52c21ab252aa5d65b19310441c7e03aca" - integrity sha512-ADsDEH2bvbjltXEP+hTIAmeFekTFK0V2BTxMkok6qILyAJEXV0AFfoWcAq4yfll5VdIMd/RVXq0lR+wQi5ZU3Q== - dependencies: - resolve "^1.9.0" - rechoir@^0.8.0: version "0.8.0" resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.8.0.tgz#49f866e0d32146142da3ad8f0eff352b3215ff22" @@ -2008,7 +1999,7 @@ rechoir@^0.8.0: require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" - integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== require-from-string@^2.0.2: version "2.0.2" @@ -2025,19 +2016,19 @@ resolve-from@^5.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== -resolve@^1.1.6, resolve@^1.20.0, resolve@^1.9.0: - version "1.22.0" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.0.tgz#5e0b8c67c15df57a89bdbabe603a002f21731198" - integrity sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw== +resolve@^1.1.6, resolve@^1.20.0: + version "1.22.1" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177" + integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw== dependencies: - is-core-module "^2.8.1" + is-core-module "^2.9.0" path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" response-time@^2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/response-time/-/response-time-2.3.2.tgz#ffa71bab952d62f7c1d49b7434355fbc68dffc5a" - integrity sha1-/6cbq5UtYvfB1Jt0NDVfvGjf/Fo= + integrity sha512-MUIDaDQf+CVqflfTdQ5yam+aYCkXj1PY8fjlPDQ6ppxJlmgZb864pHtA750mayywNg8tx4rS7qH9JXd/OF+3gw== dependencies: depd "~1.1.0" on-headers "~1.0.1" @@ -2045,12 +2036,12 @@ response-time@^2.3.2: retry@^0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" - integrity sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs= + integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== revalidator@0.1.x: version "0.1.8" resolved "https://registry.yarnpkg.com/revalidator/-/revalidator-0.1.8.tgz#fece61bfa0c1b52a206bd6b18198184bdd523a3b" - integrity sha1-/s5hv6DBtSoga9axgZgYS91SOjs= + integrity sha512-xcBILK2pA9oh4SiinPEZfhP8HfrB/ha+a2fTMyl7Om2WjlDVrOQy99N2MXXlUHqGJz4qEu2duXxHJjDWuK/0xg== rfdc@^1.3.0: version "1.3.0" @@ -2103,30 +2094,11 @@ semver@^5.0.3, semver@^5.3.0: integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== semver@^7.3.5: - version "7.3.6" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.6.tgz#5d73886fb9c0c6602e79440b97165c29581cbb2b" - integrity sha512-HZWqcgwLsjaX1HBD31msI/rXktuIhS+lWvdE4kN9z+8IVT4Itc7vqU2WvYsyD6/sjYCt4dEKH/m1M3dwI9CC5w== + version "7.3.7" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f" + integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g== dependencies: - lru-cache "^7.4.0" - -send@0.17.2: - version "0.17.2" - resolved "https://registry.yarnpkg.com/send/-/send-0.17.2.tgz#926622f76601c41808012c8bf1688fe3906f7820" - integrity sha512-UJYB6wFSJE3G00nEivR5rgWp8c2xXvJ3OPWPhmuteU0IKj8nKbG3DrjiOmLwpnHGYWAVwA69zmTm++YG0Hmwww== - dependencies: - debug "2.6.9" - depd "~1.1.2" - destroy "~1.0.4" - encodeurl "~1.0.2" - escape-html "~1.0.3" - etag "~1.8.1" - fresh "0.5.2" - http-errors "1.8.1" - mime "1.6.0" - ms "2.1.3" - on-finished "~2.3.0" - range-parser "~1.2.1" - statuses "~1.5.0" + lru-cache "^6.0.0" send@0.18.0: version "0.18.0" @@ -2150,7 +2122,7 @@ send@0.18.0: serve-favicon@^2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/serve-favicon/-/serve-favicon-2.5.0.tgz#935d240cdfe0f5805307fdfe967d88942a2cbcf0" - integrity sha1-k10kDN/g9YBTB/3+ln2IlCosvPA= + integrity sha512-FMW2RvqNr03x+C0WxTyu6sOv21oOjkq5j8tjquWccwa6ScNyGFOGJVpuS1NmTVGBAHS07xnSKotgf2ehQmf9iA== dependencies: etag "~1.8.1" fresh "0.5.2" @@ -2158,17 +2130,7 @@ serve-favicon@^2.5.0: parseurl "~1.3.2" safe-buffer "5.1.1" -serve-static@1.14.2: - version "1.14.2" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.2.tgz#722d6294b1d62626d41b43a013ece4598d292bfa" - integrity sha512-+TMNA9AFxUEGuC0z2mevogSnn9MXKb4fa7ngeRMJaaGv8vTwnIEkKi+QGvPt33HSnf8pRS+WGM0EbMtCJLKMBQ== - dependencies: - encodeurl "~1.0.2" - escape-html "~1.0.3" - parseurl "~1.3.3" - send "0.17.2" - -serve-static@^1.13.2: +serve-static@1.15.0, serve-static@^1.13.2: version "1.15.0" resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540" integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g== @@ -2181,7 +2143,7 @@ serve-static@^1.13.2: set-blocking@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" - integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= + integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== set-value@^4.0.1: version "4.1.0" @@ -2199,33 +2161,42 @@ setprototypeof@1.2.0: shallow-clone@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-0.1.2.tgz#5909e874ba77106d73ac414cfec1ffca87d97060" - integrity sha1-WQnodLp3EG1zrEFM/sH/yofZcGA= + integrity sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw== dependencies: is-extendable "^0.1.1" kind-of "^2.0.1" lazy-cache "^0.2.3" mixin-object "^2.0.1" +side-channel@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" + integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== + dependencies: + call-bind "^1.0.0" + get-intrinsic "^1.0.2" + object-inspect "^1.9.0" + smart-buffer@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== -socks-proxy-agent@^6.1.1: - version "6.2.0" - resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-6.2.0.tgz#f6b5229cc0cbd6f2f202d9695f09d871e951c85e" - integrity sha512-wWqJhjb32Q6GsrUqzuFkukxb/zzide5quXYcMVpIjxalDBBYy2nqKCFQ/9+Ie4dvOYSQdOk3hUlZSdzZOd3zMQ== +socks-proxy-agent@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz#dc069ecf34436621acb41e3efa66ca1b5fed15b6" + integrity sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww== dependencies: agent-base "^6.0.2" debug "^4.3.3" socks "^2.6.2" socks@^2.6.2: - version "2.6.2" - resolved "https://registry.yarnpkg.com/socks/-/socks-2.6.2.tgz#ec042d7960073d40d94268ff3bb727dc685f111a" - integrity sha512-zDZhHhZRY9PxRruRMR7kMhnf3I8hDs4S3f9RecfnGxvcBHQcKcIH/oUcEWffsfl1XxdYlA7nnlGbbTvPz9D8gA== + version "2.7.0" + resolved "https://registry.yarnpkg.com/socks/-/socks-2.7.0.tgz#f9225acdb841e874dca25f870e9130990f3913d0" + integrity sha512-scnOe9y4VuiNUULJN72GrM26BNOjVsfPXI+j+98PkyEfsIXroa5ofyjT+FzGvn/xHs73U2JtoBYAVx9Hl4quSA== dependencies: - ip "^1.1.5" + ip "^2.0.0" smart-buffer "^4.2.0" split2@^4.1.0: @@ -2245,40 +2216,40 @@ ssh2@1.4.0, ssh2@^1.4.0: nan "^2.15.0" ssri@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/ssri/-/ssri-9.0.0.tgz#70ad90e339eb910f1a7ff1dcf4afc268326c4547" - integrity sha512-Y1Z6J8UYnexKFN1R/hxUaYoY2LVdKEzziPmVAFKiKX8fiwvCJTVzn/xYE9TEWod5OVyNfIHHuVfIEuBClL/uJQ== + version "9.0.1" + resolved "https://registry.yarnpkg.com/ssri/-/ssri-9.0.1.tgz#544d4c357a8d7b71a19700074b6883fcb4eae057" + integrity sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q== dependencies: minipass "^3.1.1" stack-trace@0.0.x: version "0.0.10" resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" - integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA= + integrity sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg== statuses@2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== -"statuses@>= 1.5.0 < 2", statuses@~1.5.0: +"statuses@>= 1.5.0 < 2": version "1.5.0" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" - integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= + integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== stoppable@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/stoppable/-/stoppable-1.1.0.tgz#32da568e83ea488b08e4d7ea2c3bcc9d75015d5b" integrity sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw== -streamroller@^3.0.6: - version "3.0.6" - resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-3.0.6.tgz#52823415800ded79a49aa3f7712f50a422b97493" - integrity sha512-Qz32plKq/MZywYyhEatxyYc8vs994Gz0Hu2MSYXXLD233UyPeIeRBZARIIGwFer4Mdb8r3Y2UqKkgyDghM6QCg== +streamroller@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-3.1.2.tgz#abd444560768b340f696307cf84d3f46e86c0e63" + integrity sha512-wZswqzbgGGsXYIrBYhOE0yP+nQ6XRk7xDcYwuQAGTYXdyAUmvgVFE0YU1g5pvQT0m7GBaQfYcSnlHbapuK0H0A== dependencies: - date-format "^4.0.6" + date-format "^4.0.13" debug "^4.3.4" - fs-extra "^10.0.1" + fs-extra "^8.1.0" streamsearch@^1.1.0: version "1.1.0" @@ -2311,7 +2282,7 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1: strip-json-comments@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" - integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= + integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== supports-preserve-symlinks-flag@^1.0.0: version "1.0.0" @@ -2326,9 +2297,9 @@ swagger-parser@^10.0.3: "@apidevtools/swagger-parser" "10.0.3" swagger-ui-dist@^4.10.3: - version "4.10.3" - resolved "https://registry.yarnpkg.com/swagger-ui-dist/-/swagger-ui-dist-4.10.3.tgz#d67066716ce20cb6afb23474c9ca2727633a9eb3" - integrity sha512-eR4vsd7sYo0Sx7ZKRP5Z04yij7JkNmIlUQfrDQgC+xO5ABYx+waabzN+nDsQTLAJ4Z04bjkRd8xqkJtbxr3G7w== + version "4.14.0" + resolved "https://registry.yarnpkg.com/swagger-ui-dist/-/swagger-ui-dist-4.14.0.tgz#e34d807464eb84578c43902e393084a1a6fbda52" + integrity sha512-TBzhheU15s+o54Cgk9qxuYcZMiqSm/SkvKnapoGHOF66kz0Y5aGjpzj5BT/vpBbn6rTPJ9tUYXQxuDWfsjiGMw== tar@^6.1.11: version "6.1.11" @@ -2342,17 +2313,17 @@ tar@^6.1.11: mkdirp "^1.0.3" yallist "^4.0.0" -tarn@^3.0.1, tarn@^3.0.2: +tarn@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/tarn/-/tarn-3.0.2.tgz#73b6140fbb881b71559c4f8bfde3d9a4b3d27693" integrity sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ== tdigest@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/tdigest/-/tdigest-0.1.1.tgz#2e3cb2c39ea449e55d1e6cd91117accca4588021" - integrity sha1-Ljyyw56kSeVdHmzZEReszKRYgCE= + version "0.1.2" + resolved "https://registry.yarnpkg.com/tdigest/-/tdigest-0.1.2.tgz#96c64bac4ff10746b910b0e23b515794e12faced" + integrity sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA== dependencies: - bintrees "1.0.1" + bintrees "1.0.2" tildify@2.0.0: version "2.0.0" @@ -2401,7 +2372,7 @@ tunnel-ssh@^4.0.0: tweetnacl@^0.14.3: version "0.14.5" resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" - integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= + integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA== type-fest@^0.8.1: version "0.8.1" @@ -2422,14 +2393,14 @@ type@^1.0.1: integrity sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg== type@^2.5.0: - version "2.6.0" - resolved "https://registry.yarnpkg.com/type/-/type-2.6.0.tgz#3ca6099af5981d36ca86b78442973694278a219f" - integrity sha512-eiDBDOmkih5pMbo9OqsqPRGMljLodLcwd5XD5JbtNB0o89xZAwynY9EdCDsJU7LtcVCClu9DvM7/0Ep1hYX3EQ== + version "2.7.2" + resolved "https://registry.yarnpkg.com/type/-/type-2.7.2.tgz#2376a15a3a28b1efa0f5350dcf72d24df6ef98d0" + integrity sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw== typedarray@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" - integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= + integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== uid-safe@~2.1.5: version "2.1.5" @@ -2443,40 +2414,45 @@ uid2@0.0.x: resolved "https://registry.yarnpkg.com/uid2/-/uid2-0.0.4.tgz#033f3b1d5d32505f5ce5f888b9f3b667123c0a44" integrity sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA== -unique-filename@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230" - integrity sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ== +unique-filename@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-2.0.0.tgz#c844c84c3b22e92038b0c53950f9dc34d2b55490" + integrity sha512-tpzoz2RpZ//6Zt4GPpOFTyrnfZuSvjIfe8lvx6Thp4yTQwJtAFwPlssEBE62VhGA2We5/COyNpcIu+OABu3/Yg== dependencies: - unique-slug "^2.0.0" + unique-slug "^2.0.2" -unique-slug@^2.0.0: +unique-slug@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-2.0.2.tgz#baabce91083fc64e945b0f3ad613e264f7cd4e6c" integrity sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w== dependencies: imurmurhash "^0.1.4" -universalify@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" - integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== +universalify@^0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" + integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== -unleash-frontend@4.14.0-beta.0: - version "4.14.0-beta.0" - resolved "https://registry.yarnpkg.com/unleash-frontend/-/unleash-frontend-4.14.0-beta.0.tgz#c68335f92f92494bdd25eb3aeb5f2dd9ce7950de" - integrity sha512-RIBkNR2S/uayMFwc88xlUwluYix6GH+7Cf1DqVV1fD/s0MMFQHQzL7qpE3XUmiUw5rn4BqXs95V1bmjh00+ACg== +unleash-client@3.15.0: + version "3.15.0" + resolved "https://registry.yarnpkg.com/unleash-client/-/unleash-client-3.15.0.tgz#6ba4d917a0d8d628e73267ae8114d261d210a1a9" + integrity sha512-pNfzJa7QWhtSMTGNhmanpgqjg3xIJK4gJgQiZdkJlUY6GPDXit8p4fGs94jC8zM/xzpa1ji9+sSx6GC9YDeCiQ== + dependencies: + ip "^1.1.5" + make-fetch-happen "^10.0.0" + murmurhash3js "^3.0.1" + semver "^7.3.5" "unleash-server@file:../build": - version "4.14.0-beta.0" + version "4.15.0-beta.2" dependencies: "@unleash/express-openapi" "^0.2.0" ajv "^8.11.0" ajv-formats "^2.1.1" - async "^3.2.3" + async "^3.2.4" bcryptjs "^2.4.3" compression "^1.7.4" - connect-session-knex "^2.1.0" + connect-session-knex "^3.0.0" cookie-parser "^1.4.5" cookie-session "^2.0.0-rc.1" cors "^2.8.5" @@ -2491,15 +2467,17 @@ unleash-frontend@4.14.0-beta.0: fast-json-patch "^3.1.0" gravatar-url "^3.1.0" helmet "^5.0.0" + ip "^1.1.8" joi "^17.3.0" js-yaml "^4.1.0" - json-schema-to-ts "^2.5.3" + json-schema-to-ts "2.5.5" knex "^2.0.0" log4js "^6.0.0" make-fetch-happen "^10.1.2" memoizee "^0.4.15" mime "^3.0.0" multer "^1.4.5-lts.1" + murmurhash3js "^3.0.1" mustache "^4.1.0" nodemailer "^6.5.0" openapi-types "^12.0.0" @@ -2513,14 +2491,15 @@ unleash-frontend@4.14.0-beta.0: semver "^7.3.5" serve-favicon "^2.5.0" stoppable "^1.1.0" + ts-toolbelt "^9.6.0" type-is "^1.6.18" - unleash-frontend "4.14.0-beta.0" + unleash-client "3.15.0" uuid "^8.3.2" unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" - integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== uri-js@^4.2.2: version "4.4.1" @@ -2532,7 +2511,7 @@ uri-js@^4.2.2: util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" - integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== utils-merge@1.0.1, utils-merge@1.x.x, utils-merge@^1.0.1: version "1.0.1" @@ -2552,24 +2531,24 @@ validator@^13.7.0: vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" - integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== when@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/when/-/when-2.0.1.tgz#8d872fe15e68424c91b4b724e848e0807dab6642" - integrity sha1-jYcv4V5oQkyRtLck6EjggH2rZkI= + integrity sha512-h0l57vFJ4YQe1/U+C+oqBfAoopxXABUm6VqWM0x2gg4pARru4IUWo/PAxyawWgbGtndXrZbA41EzsfxacZVEXQ== which-module@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" - integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= + integrity sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q== winston@2.x: - version "2.4.5" - resolved "https://registry.yarnpkg.com/winston/-/winston-2.4.5.tgz#f2e431d56154c4ea765545fc1003bd340c95b59a" - integrity sha512-TWoamHt5yYvsMarGlGEQE59SbJHqGsZV8/lwC+iCcGeAe0vUaOh+Lv6SYM17ouzC/a/LB1/hz/7sxFBtlu1l4A== + version "2.4.6" + resolved "https://registry.yarnpkg.com/winston/-/winston-2.4.6.tgz#da616f332928f70aac482f59b43d62228f29e478" + integrity sha512-J5Zu4p0tojLde8mIOyDSsmLmcP8I3Z6wtwpTDHx1+hGcdhxcJaAmG4CFtagkb+NiN1M9Ek4b42pzMWqfc9jm8w== dependencies: - async "~1.0.0" + async "^3.2.3" colors "1.0.x" cycle "1.0.x" eyes "0.1.x" @@ -2588,7 +2567,7 @@ wrap-ansi@^6.2.0: wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== xtend@^4.0.0: version "4.0.2" diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 0000000000..afff24bb76 --- /dev/null +++ b/frontend/.editorconfig @@ -0,0 +1,16 @@ +# editorconfig.org +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.json] +indent_size = 2 diff --git a/frontend/.github/workflows/e2e.feature.yml b/frontend/.github/workflows/e2e.feature.yml new file mode 100644 index 0000000000..ad89346f22 --- /dev/null +++ b/frontend/.github/workflows/e2e.feature.yml @@ -0,0 +1,25 @@ +name: e2e:feature +# https://docs.github.com/en/actions/reference/events-that-trigger-workflows +on: [deployment_status] +jobs: + e2e: + # only runs this job on successful deploy + if: github.event_name == 'deployment_status' && github.event.deployment_status.state == 'success' + runs-on: ubuntu-latest + steps: + - name: Dump GitHub context + env: + GITHUB_CONTEXT: ${{ toJson(github) }} + run: | + echo "$GITHUB_CONTEXT" + - name: Checkout + uses: actions/checkout@v3 + - name: Run Cypress + uses: cypress-io/github-action@v2 + with: + env: AUTH_USER=admin,AUTH_PASSWORD=unleash4all + config: baseUrl=${{ github.event.deployment_status.target_url }} + record: true + spec: cypress/integration/feature/feature.spec.ts + env: + CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} diff --git a/frontend/.github/workflows/e2e.groups.yml b/frontend/.github/workflows/e2e.groups.yml new file mode 100644 index 0000000000..7eb5e056e6 --- /dev/null +++ b/frontend/.github/workflows/e2e.groups.yml @@ -0,0 +1,25 @@ +name: e2e:groups +# https://docs.github.com/en/actions/reference/events-that-trigger-workflows +on: [deployment_status] +jobs: + e2e: + # only runs this job on successful deploy + if: github.event_name == 'deployment_status' && github.event.deployment_status.state == 'success' + runs-on: ubuntu-latest + steps: + - name: Dump GitHub context + env: + GITHUB_CONTEXT: ${{ toJson(github) }} + run: | + echo "$GITHUB_CONTEXT" + - name: Checkout + uses: actions/checkout@v3 + - name: Run Cypress + uses: cypress-io/github-action@v2 + with: + env: AUTH_USER=admin,AUTH_PASSWORD=unleash4all + config: baseUrl=${{ github.event.deployment_status.target_url }} + record: true + spec: cypress/integration/groups/groups.spec.ts + env: + CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} diff --git a/frontend/.github/workflows/e2e.project-access.yml b/frontend/.github/workflows/e2e.project-access.yml new file mode 100644 index 0000000000..f2dfff64df --- /dev/null +++ b/frontend/.github/workflows/e2e.project-access.yml @@ -0,0 +1,25 @@ +name: e2e:project-access +# https://docs.github.com/en/actions/reference/events-that-trigger-workflows +on: [deployment_status] +jobs: + e2e: + # only runs this job on successful deploy + if: github.event_name == 'deployment_status' && github.event.deployment_status.state == 'success' + runs-on: ubuntu-latest + steps: + - name: Dump GitHub context + env: + GITHUB_CONTEXT: ${{ toJson(github) }} + run: | + echo "$GITHUB_CONTEXT" + - name: Checkout + uses: actions/checkout@v3 + - name: Run Cypress + uses: cypress-io/github-action@v2 + with: + env: AUTH_USER=admin,AUTH_PASSWORD=unleash4all + config: baseUrl=${{ github.event.deployment_status.target_url }} + record: true + spec: cypress/integration/projects/access/project-access.spec.ts + env: + CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} diff --git a/frontend/.github/workflows/e2e.segments.yml b/frontend/.github/workflows/e2e.segments.yml new file mode 100644 index 0000000000..b8111b2e32 --- /dev/null +++ b/frontend/.github/workflows/e2e.segments.yml @@ -0,0 +1,25 @@ +name: e2e:segments +# https://docs.github.com/en/actions/reference/events-that-trigger-workflows +on: [deployment_status] +jobs: + e2e: + # only runs this job on successful deploy + if: github.event_name == 'deployment_status' && github.event.deployment_status.state == 'success' + runs-on: ubuntu-latest + steps: + - name: Dump GitHub context + env: + GITHUB_CONTEXT: ${{ toJson(github) }} + run: | + echo "$GITHUB_CONTEXT" + - name: Checkout + uses: actions/checkout@v3 + - name: Run Cypress + uses: cypress-io/github-action@v2 + with: + env: AUTH_USER=admin,AUTH_PASSWORD=unleash4all + config: baseUrl=${{ github.event.deployment_status.target_url }} + record: true + spec: cypress/integration/segments/segments.spec.ts + env: + CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000000..10bbc1c1fa --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,56 @@ +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules +jspm_packages +package-lock.json + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +typings/ + +# Built +dist +build + +# IDE +.idea/ +.vscode/ + +.DS_Store + +cypress/downloads/* +cypress/videos/* +cypress/downloads/* +cypress/screenshots/* +.env.local diff --git a/frontend/.nvmrc b/frontend/.nvmrc new file mode 100644 index 0000000000..8bb247c432 --- /dev/null +++ b/frontend/.nvmrc @@ -0,0 +1 @@ +14.20 diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000000..a06b11e1a7 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,3 @@ +.github/* +/src/openapi +CHANGELOG.md diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000000..552578130e --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "avoid", + "printWidth": 80 +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000000..e93e8f0e44 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,68 @@ +# frontend + +This directory contains the Unleash Admin UI frontend app. + +## Run with a local instance of the unleash-api + +First, start the unleash-api backend on port 4242. +Then, start the frontend dev server: + +``` +cd ~/frontend +yarn install +yarn run start +``` + +## Run with a heroku-hosted instance of unleash-api + +Alternatively, instead of running unleash-api on localhost, use a remote instance: + +``` +cd ~/frontend +yarn install +yarn run start:heroku +``` + +## Running end-to-end tests + +We have a set of Cypress tests that run on the build before a PR can be merged +so it's important that you check these yourself before submitting a PR. +On the server the tests will run against the deployed Heroku app so this is what you probably want to test against: + +``` +yarn run start:heroku +``` + +In a different shell, you can run the tests themselves: + +``` +yarn run e2e:heroku +``` + +If you need to test against patches against a local server instance, +you'll need to run that, and then run the end to end tests using: + +``` +yarn run e2e +``` + +You may also need to test that a feature works against the enterprise version of unleash. +Assuming the Heroku instance is still running, this can be done by: + +``` +yarn run start:enterprise +yarn run e2e +``` + +## Generating the OpenAPI client + +The frontend uses an OpenAPI client generated from the backend's OpenAPI spec. +Whenever there are changes to the backend API, the client should be regenerated: + +``` +./scripts/generate-openapi.sh +``` + +This script assumes that you have a running instance of the enterprise backend at `http://localhost:4242`. +The new OpenAPI client will be generated from the runtime schema of this instance. +The target URL can be changed by setting the `UNLEASH_OPENAPI_URL` env var. diff --git a/frontend/cypress.json b/frontend/cypress.json new file mode 100644 index 0000000000..ea78e63c8d --- /dev/null +++ b/frontend/cypress.json @@ -0,0 +1,7 @@ +{ + "projectId": "tc2qff", + "defaultCommandTimeout": 12000, + "screenshotOnRunFailure": false, + "video": false, + "experimentalSessionAndOrigin": true +} diff --git a/frontend/cypress/fixtures/example.json b/frontend/cypress/fixtures/example.json new file mode 100644 index 0000000000..02e4254378 --- /dev/null +++ b/frontend/cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/frontend/cypress/integration/feature/feature.spec.ts b/frontend/cypress/integration/feature/feature.spec.ts new file mode 100644 index 0000000000..f2651b5f5c --- /dev/null +++ b/frontend/cypress/integration/feature/feature.spec.ts @@ -0,0 +1,331 @@ +/// + +export {}; + +const ENTERPRISE = Boolean(Cypress.env('ENTERPRISE')); +const randomId = String(Math.random()).split('.')[1]; +const featureToggleName = `unleash-e2e-${randomId}`; +const baseUrl = Cypress.config().baseUrl; +const variant1 = 'variant1'; +const variant2 = 'variant2'; +let strategyId = ''; + +// Disable the prod guard modal by marking it as seen. +const disableFeatureStrategiesProdGuard = () => { + localStorage.setItem( + 'useFeatureStrategyProdGuardSettings:v2', + JSON.stringify({ hide: true }) + ); +}; + +// Disable all active splash pages by visiting them. +const disableActiveSplashScreens = () => { + cy.visit(`/splash/operators`); +}; + +describe('feature', () => { + before(() => { + disableFeatureStrategiesProdGuard(); + disableActiveSplashScreens(); + }); + + after(() => { + cy.request({ + method: 'DELETE', + url: `${baseUrl}/api/admin/features/${featureToggleName}`, + }); + cy.request({ + method: 'DELETE', + url: `${baseUrl}/api/admin/archive/${featureToggleName}`, + }); + }); + + beforeEach(() => { + cy.login(); + cy.visit('/'); + }); + + it('can create a feature toggle', () => { + if (document.querySelector("[data-testid='CLOSE_SPLASH']")) { + cy.get("[data-testid='CLOSE_SPLASH']").click(); + } + + cy.get('[data-testid=NAVIGATE_TO_CREATE_FEATURE').click(); + + cy.intercept('POST', '/api/admin/projects/default/features').as( + 'createFeature' + ); + + cy.get("[data-testid='CF_NAME_ID'").type(featureToggleName); + cy.get("[data-testid='CF_DESC_ID'").type('hello-world'); + cy.get("[data-testid='CF_CREATE_BTN_ID']").click(); + cy.wait('@createFeature'); + cy.url().should('include', featureToggleName); + }); + + it('gives an error if a toggle exists with the same name', () => { + cy.get('[data-testid=NAVIGATE_TO_CREATE_FEATURE').click(); + + cy.intercept('POST', '/api/admin/projects/default/features').as( + 'createFeature' + ); + + cy.get("[data-testid='CF_NAME_ID'").type(featureToggleName); + cy.get("[data-testid='CF_DESC_ID'").type('hello-world'); + cy.get("[data-testid='CF_CREATE_BTN_ID']").click(); + cy.get("[data-testid='INPUT_ERROR_TEXT']").contains( + 'A toggle with that name already exists' + ); + }); + + it('gives an error if a toggle name is url unsafe', () => { + cy.get('[data-testid=NAVIGATE_TO_CREATE_FEATURE').click(); + + cy.intercept('POST', '/api/admin/projects/default/features').as( + 'createFeature' + ); + + cy.get("[data-testid='CF_NAME_ID'").type('featureToggleUnsafe####$#//'); + cy.get("[data-testid='CF_DESC_ID'").type('hello-world'); + cy.get("[data-testid='CF_CREATE_BTN_ID']").click(); + cy.get("[data-testid='INPUT_ERROR_TEXT']").contains( + `"name" must be URL friendly` + ); + }); + + it('can add a gradual rollout strategy to the development environment', () => { + cy.visit( + `/projects/default/features/${featureToggleName}/strategies/create?environmentId=development&strategyName=flexibleRollout` + ); + + if (ENTERPRISE) { + cy.get('[data-testid=ADD_CONSTRAINT_ID]').click(); + cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click(); + } + + cy.intercept( + 'POST', + `/api/admin/projects/default/features/${featureToggleName}/environments/*/strategies`, + req => { + expect(req.body.name).to.equal('flexibleRollout'); + expect(req.body.parameters.groupId).to.equal(featureToggleName); + expect(req.body.parameters.stickiness).to.equal('default'); + expect(req.body.parameters.rollout).to.equal('50'); + + if (ENTERPRISE) { + expect(req.body.constraints.length).to.equal(1); + } else { + expect(req.body.constraints.length).to.equal(0); + } + + req.continue(res => { + strategyId = res.body.id; + }); + } + ).as('addStrategyToFeature'); + + cy.get(`[data-testid=STRATEGY_FORM_SUBMIT_ID]`).first().click(); + cy.wait('@addStrategyToFeature'); + }); + + it('can update a strategy in the development environment', () => { + cy.visit( + `/projects/default/features/${featureToggleName}/strategies/edit?environmentId=development&strategyId=${strategyId}` + ); + + cy.get('[data-testid=FLEXIBLE_STRATEGY_STICKINESS_ID]') + .first() + .click() + .get('[data-testid=SELECT_ITEM_ID-sessionId') + .first() + .click(); + + cy.get('[data-testid=FLEXIBLE_STRATEGY_GROUP_ID]') + .first() + .clear() + .type('new-group-id'); + + cy.intercept( + 'PUT', + `/api/admin/projects/default/features/${featureToggleName}/environments/*/strategies/${strategyId}`, + req => { + expect(req.body.parameters.groupId).to.equal('new-group-id'); + expect(req.body.parameters.stickiness).to.equal('sessionId'); + expect(req.body.parameters.rollout).to.equal('50'); + + if (ENTERPRISE) { + expect(req.body.constraints.length).to.equal(1); + } else { + expect(req.body.constraints.length).to.equal(0); + } + + req.continue(res => { + expect(res.statusCode).to.equal(200); + }); + } + ).as('updateStrategy'); + + cy.get(`[data-testid=STRATEGY_FORM_SUBMIT_ID]`).first().click(); + cy.wait('@updateStrategy'); + }); + + it('can delete a strategy in the development environment', () => { + cy.visit(`/projects/default/features/${featureToggleName}`); + + cy.intercept( + 'DELETE', + `/api/admin/projects/default/features/${featureToggleName}/environments/*/strategies/${strategyId}`, + req => { + req.continue(res => { + expect(res.statusCode).to.equal(200); + }); + } + ).as('deleteStrategy'); + + cy.get( + '[data-testid=FEATURE_ENVIRONMENT_ACCORDION_development]' + ).click(); + cy.get('[data-testid=STRATEGY_FORM_REMOVE_ID]').click(); + cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click(); + cy.wait('@deleteStrategy'); + }); + + it('can add a userId strategy to the development environment', () => { + cy.visit( + `/projects/default/features/${featureToggleName}/strategies/create?environmentId=development&strategyName=userWithId` + ); + + if (ENTERPRISE) { + cy.get('[data-testid=ADD_CONSTRAINT_ID]').click(); + cy.get('[data-testid=CONSTRAINT_AUTOCOMPLETE_ID]') + .type('{downArrow}'.repeat(1)) + .type('{enter}'); + cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click(); + } + + cy.get('[data-testid=STRATEGY_INPUT_LIST]') + .type('user1') + .type('{enter}') + .type('user2') + .type('{enter}'); + cy.get('[data-testid=ADD_TO_STRATEGY_INPUT_LIST]').click(); + + cy.intercept( + 'POST', + `/api/admin/projects/default/features/${featureToggleName}/environments/*/strategies`, + req => { + expect(req.body.name).to.equal('userWithId'); + + expect(req.body.parameters.userIds.length).to.equal(11); + + if (ENTERPRISE) { + expect(req.body.constraints.length).to.equal(1); + } else { + expect(req.body.constraints.length).to.equal(0); + } + + req.continue(res => { + strategyId = res.body.id; + }); + } + ).as('addStrategyToFeature'); + + cy.get(`[data-testid=STRATEGY_FORM_SUBMIT_ID]`).first().click(); + cy.wait('@addStrategyToFeature'); + }); + + it('can add two variant to the feature', () => { + cy.visit(`/projects/default/features/${featureToggleName}/variants`); + + cy.intercept( + 'PATCH', + `/api/admin/projects/default/features/${featureToggleName}/variants`, + req => { + if (req.body.length === 1) { + expect(req.body[0].op).to.equal('add'); + expect(req.body[0].path).to.match(/\//); + expect(req.body[0].value.name).to.equal(variant1); + } else if (req.body.length === 2) { + expect(req.body[0].op).to.equal('replace'); + expect(req.body[0].path).to.match(/weight/); + expect(req.body[0].value).to.equal(500); + expect(req.body[1].op).to.equal('add'); + expect(req.body[1].path).to.match(/\//); + expect(req.body[1].value.name).to.equal(variant2); + } + } + ).as('variantCreation'); + + cy.get('[data-testid=ADD_VARIANT_BUTTON]').click(); + cy.wait(1000); + cy.get('[data-testid=VARIANT_NAME_INPUT]').type(variant1); + cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click(); + cy.wait('@variantCreation'); + cy.get('[data-testid=ADD_VARIANT_BUTTON]').click(); + cy.wait(1000); + cy.get('[data-testid=VARIANT_NAME_INPUT]').type(variant2); + cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click(); + cy.wait('@variantCreation'); + }); + + it('can set weight to fixed value for one of the variants', () => { + cy.visit(`/projects/default/features/${featureToggleName}/variants`); + + cy.get(`[data-testid=VARIANT_EDIT_BUTTON_${variant1}]`).click(); + cy.wait(1000); + cy.get('[data-testid=VARIANT_NAME_INPUT]') + .children() + .find('input') + .should('have.attr', 'disabled'); + cy.get('[data-testid=VARIANT_WEIGHT_CHECK]').find('input').check(); + cy.get('[data-testid=VARIANT_WEIGHT_INPUT]').clear().type('15'); + + cy.intercept( + 'PATCH', + `/api/admin/projects/default/features/${featureToggleName}/variants`, + req => { + expect(req.body[0].op).to.equal('replace'); + expect(req.body[0].path).to.match(/weight/); + expect(req.body[0].value).to.equal(850); + expect(req.body[1].op).to.equal('replace'); + expect(req.body[1].path).to.match(/weightType/); + expect(req.body[1].value).to.equal('fix'); + expect(req.body[2].op).to.equal('replace'); + expect(req.body[2].path).to.match(/weight/); + expect(req.body[2].value).to.equal(150); + } + ).as('variantUpdate'); + + cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click(); + cy.wait('@variantUpdate'); + cy.get(`[data-testid=VARIANT_WEIGHT_${variant1}]`).should( + 'have.text', + '15 %' + ); + }); + + it('can delete variant', () => { + const variantName = 'to-be-deleted'; + + cy.visit(`/projects/default/features/${featureToggleName}/variants`); + cy.get('[data-testid=ADD_VARIANT_BUTTON]').click(); + cy.wait(1000); + cy.get('[data-testid=VARIANT_NAME_INPUT]').type(variantName); + cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click(); + + cy.intercept( + 'PATCH', + `/api/admin/projects/default/features/${featureToggleName}/variants`, + req => { + const patch = req.body.find( + (patch: Record) => patch.op === 'remove' + ); + expect(patch.path).to.match(/\//); + } + ).as('delete'); + + cy.get(`[data-testid=VARIANT_DELETE_BUTTON_${variantName}]`).click(); + cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click(); + cy.wait('@delete'); + }); +}); diff --git a/frontend/cypress/integration/groups/groups.spec.ts b/frontend/cypress/integration/groups/groups.spec.ts new file mode 100644 index 0000000000..efc9931f1c --- /dev/null +++ b/frontend/cypress/integration/groups/groups.spec.ts @@ -0,0 +1,113 @@ +/// + +export {}; +const baseUrl = Cypress.config().baseUrl; +const randomId = String(Math.random()).split('.')[1]; +const groupName = `unleash-e2e-${randomId}`; +const userIds: any[] = []; + +// Disable all active splash pages by visiting them. +const disableActiveSplashScreens = () => { + cy.visit(`/splash/operators`); +}; + +describe('groups', () => { + before(() => { + disableActiveSplashScreens(); + cy.login(); + for (let i = 1; i <= 2; i++) { + cy.request('POST', `${baseUrl}/api/admin/user-admin`, { + name: `unleash-e2e-user${i}-${randomId}`, + email: `unleash-e2e-user${i}-${randomId}@test.com`, + sendEmail: false, + rootRole: 3, + }).then(response => userIds.push(response.body.id)); + } + }); + + after(() => { + userIds.forEach(id => + cy.request('DELETE', `${baseUrl}/api/admin/user-admin/${id}`) + ); + }); + + beforeEach(() => { + cy.login(); + cy.visit('/admin/groups'); + if (document.querySelector("[data-testid='CLOSE_SPLASH']")) { + cy.get("[data-testid='CLOSE_SPLASH']").click(); + } + }); + + it('can create a group', () => { + cy.get("[data-testid='NAVIGATE_TO_CREATE_GROUP']").click(); + + cy.intercept('POST', '/api/admin/groups').as('createGroup'); + + cy.get("[data-testid='UG_NAME_ID']").type(groupName); + cy.get("[data-testid='UG_DESC_ID']").type('hello-world'); + cy.get("[data-testid='UG_USERS_ID']").click(); + cy.contains(`unleash-e2e-user1-${randomId}`).click(); + + cy.get("[data-testid='UG_CREATE_BTN_ID']").click(); + cy.wait('@createGroup'); + cy.contains(groupName); + }); + + it('gives an error if a group exists with the same name', () => { + cy.get("[data-testid='NAVIGATE_TO_CREATE_GROUP']").click(); + + cy.intercept('POST', '/api/admin/groups').as('createGroup'); + + cy.get("[data-testid='UG_NAME_ID']").type(groupName); + cy.get("[data-testid='INPUT_ERROR_TEXT'").contains( + 'A group with that name already exists.' + ); + }); + + it('can edit a group', () => { + cy.contains(groupName).click(); + + cy.get("[data-testid='UG_EDIT_BTN_ID']").click(); + + cy.get("[data-testid='UG_DESC_ID']").type('-my edited description'); + + cy.get("[data-testid='UG_SAVE_BTN_ID']").click(); + + cy.contains('hello-world-my edited description'); + }); + + it('can add user to a group', () => { + cy.contains(groupName).click(); + + cy.get("[data-testid='UG_EDIT_USERS_BTN_ID']").click(); + + cy.get("[data-testid='UG_USERS_ID']").click(); + cy.contains(`unleash-e2e-user2-${randomId}`).click(); + + cy.get("[data-testid='UG_SAVE_BTN_ID']").click(); + + cy.contains(`unleash-e2e-user1-${randomId}`); + cy.contains(`unleash-e2e-user2-${randomId}`); + }); + + it('can remove user from a group', () => { + cy.contains(groupName).click(); + + cy.get(`[data-testid='UG_REMOVE_USER_BTN_ID-${userIds[1]}']`).click(); + + cy.get("[data-testid='DIALOGUE_CONFIRM_ID'").click(); + + cy.contains(`unleash-e2e-user1-${randomId}`); + cy.contains(`unleash-e2e-user2-${randomId}`).should('not.exist'); + }); + + it('can delete a group', () => { + cy.contains(groupName).click(); + + cy.get("[data-testid='UG_DELETE_BTN_ID']").click(); + cy.get("[data-testid='DIALOGUE_CONFIRM_ID'").click(); + + cy.contains(groupName).should('not.exist'); + }); +}); diff --git a/frontend/cypress/integration/projects/access/project-access.spec.ts b/frontend/cypress/integration/projects/access/project-access.spec.ts new file mode 100644 index 0000000000..cd9039449e --- /dev/null +++ b/frontend/cypress/integration/projects/access/project-access.spec.ts @@ -0,0 +1,147 @@ +/// + +import { + PA_ASSIGN_BUTTON_ID, + PA_ASSIGN_CREATE_ID, + PA_EDIT_BUTTON_ID, + PA_REMOVE_BUTTON_ID, + PA_ROLE_ID, + PA_USERS_GROUPS_ID, + PA_USERS_GROUPS_TITLE_ID, +} from '../../../../src/utils/testIds'; + +export {}; +const baseUrl = Cypress.config().baseUrl; +const randomId = String(Math.random()).split('.')[1]; +const groupAndProjectName = `group-e2e-${randomId}`; +const userName = `user-e2e-${randomId}`; +const groupIds: any[] = []; +const userIds: any[] = []; + +// Disable all active splash pages by visiting them. +const disableActiveSplashScreens = () => { + cy.visit(`/splash/operators`); +}; + +describe('project-access', () => { + before(() => { + disableActiveSplashScreens(); + cy.login(); + for (let i = 1; i <= 2; i++) { + const name = `${i}-${userName}`; + cy.request('POST', `${baseUrl}/api/admin/user-admin`, { + name: name, + email: `${name}@test.com`, + sendEmail: false, + rootRole: 3, + }) + .as(name) + .then(response => { + const id = response.body.id; + userIds.push(id); + cy.request('POST', `${baseUrl}/api/admin/groups`, { + name: `${i}-${groupAndProjectName}`, + users: [{ user: { id: id } }], + }).then(response => { + const id = response.body.id; + groupIds.push(id); + }); + }); + } + cy.request('POST', `${baseUrl}/api/admin/projects`, { + id: groupAndProjectName, + name: groupAndProjectName, + }); + }); + + after(() => { + userIds.forEach(id => + cy.request('DELETE', `${baseUrl}/api/admin/user-admin/${id}`) + ); + groupIds.forEach(id => + cy.request('DELETE', `${baseUrl}/api/admin/groups/${id}`) + ); + + cy.request( + 'DELETE', + `${baseUrl}/api/admin/projects/${groupAndProjectName}` + ); + }); + + beforeEach(() => { + cy.login(); + cy.visit(`/projects/${groupAndProjectName}/access`); + if (document.querySelector("[data-testid='CLOSE_SPLASH']")) { + cy.get("[data-testid='CLOSE_SPLASH']").click(); + } + }); + + it('can assign permissions to user', () => { + cy.get(`[data-testid='${PA_ASSIGN_BUTTON_ID}']`).click(); + + cy.intercept( + 'POST', + `/api/admin/projects/${groupAndProjectName}/role/4/access` + ).as('assignAccess'); + + cy.get(`[data-testid='${PA_USERS_GROUPS_ID}']`).click(); + cy.contains(`1-${userName}`).click(); + cy.get(`[data-testid='${PA_USERS_GROUPS_TITLE_ID}']`).click(); + cy.get(`[data-testid='${PA_ROLE_ID}']`).click(); + cy.contains('full control over the project').click({ force: true }); + + cy.get(`[data-testid='${PA_ASSIGN_CREATE_ID}']`).click(); + cy.wait('@assignAccess'); + cy.contains(`1-${userName}`); + }); + + it('can assign permissions to group', () => { + cy.get(`[data-testid='${PA_ASSIGN_BUTTON_ID}']`).click(); + + cy.intercept( + 'POST', + `/api/admin/projects/${groupAndProjectName}/role/4/access` + ).as('assignAccess'); + + cy.get(`[data-testid='${PA_USERS_GROUPS_ID}']`).click(); + cy.contains(`1-${groupAndProjectName}`).click({ force: true }); + cy.get(`[data-testid='${PA_USERS_GROUPS_TITLE_ID}']`).click(); + cy.get(`[data-testid='${PA_ROLE_ID}']`).click(); + cy.contains('full control over the project').click({ force: true }); + + cy.get(`[data-testid='${PA_ASSIGN_CREATE_ID}']`).click(); + cy.wait('@assignAccess'); + cy.contains(`1-${groupAndProjectName}`); + }); + + it('can edit role', () => { + cy.get(`[data-testid='${PA_EDIT_BUTTON_ID}']`).first().click(); + + cy.intercept( + 'PUT', + `/api/admin/projects/${groupAndProjectName}/groups/${groupIds[0]}/roles/5` + ).as('editAccess'); + + cy.get(`[data-testid='${PA_ROLE_ID}']`).click(); + cy.contains('within a project are allowed').click({ force: true }); + + cy.get(`[data-testid='${PA_ASSIGN_CREATE_ID}']`).click(); + cy.wait('@editAccess'); + cy.get("td span:contains('Owner')").should('have.length', 2); + cy.get("td span:contains('Member')").should('have.length', 1); + }); + + it('can remove access', () => { + cy.get(`[data-testid='${PA_REMOVE_BUTTON_ID}']`).first().click(); + + cy.intercept( + 'DELETE', + `/api/admin/projects/${groupAndProjectName}/groups/${groupIds[0]}/roles/5` + ).as('removeAccess'); + + cy.contains("Yes, I'm sure").click(); + + cy.wait('@removeAccess'); + cy.contains(`1-${groupAndProjectName} has been removed from project`); + }); +}); diff --git a/frontend/cypress/integration/segments/segments.spec.ts b/frontend/cypress/integration/segments/segments.spec.ts new file mode 100644 index 0000000000..7dc3a54d3f --- /dev/null +++ b/frontend/cypress/integration/segments/segments.spec.ts @@ -0,0 +1,57 @@ +/// + +export {}; +const randomId = String(Math.random()).split('.')[1]; +const segmentName = `unleash-e2e-${randomId}`; + +// Disable all active splash pages by visiting them. +const disableActiveSplashScreens = () => { + cy.visit(`/splash/operators`); +}; + +describe('segments', () => { + before(() => { + disableActiveSplashScreens(); + }); + + beforeEach(() => { + cy.login(); + cy.visit('/segments'); + }); + + it('can create a segment', () => { + if (document.querySelector("[data-testid='CLOSE_SPLASH']")) { + cy.get("[data-testid='CLOSE_SPLASH']").click(); + } + + cy.get("[data-testid='NAVIGATE_TO_CREATE_SEGMENT']").click(); + + cy.intercept('POST', '/api/admin/segments').as('createSegment'); + + cy.get("[data-testid='SEGMENT_NAME_ID']").type(segmentName); + cy.get("[data-testid='SEGMENT_DESC_ID']").type('hello-world'); + cy.get("[data-testid='SEGMENT_NEXT_BTN_ID']").click(); + cy.get("[data-testid='SEGMENT_CREATE_BTN_ID']").click(); + cy.wait('@createSegment'); + cy.contains(segmentName); + }); + + it('gives an error if a segment exists with the same name', () => { + cy.get("[data-testid='NAVIGATE_TO_CREATE_SEGMENT']").click(); + + cy.get("[data-testid='SEGMENT_NAME_ID']").type(segmentName); + cy.get("[data-testid='SEGMENT_NEXT_BTN_ID']").should('be.disabled'); + cy.get("[data-testid='INPUT_ERROR_TEXT']").contains( + 'Segment name already exists' + ); + }); + + it('can delete a segment', () => { + cy.get(`[data-testid='SEGMENT_DELETE_BTN_ID_${segmentName}']`).click(); + + cy.get("[data-testid='SEGMENT_DIALOG_NAME_ID']").type(segmentName); + cy.get("[data-testid='DIALOGUE_CONFIRM_ID'").click(); + + cy.contains(segmentName).should('not.exist'); + }); +}); diff --git a/frontend/cypress/plugins/index.ts b/frontend/cypress/plugins/index.ts new file mode 100644 index 0000000000..59b2bab6e4 --- /dev/null +++ b/frontend/cypress/plugins/index.ts @@ -0,0 +1,22 @@ +/// +// *********************************************************** +// This example plugins/index.js can be used to load plugins +// +// You can change the location of this file or turn off loading +// the plugins file with the 'pluginsFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/plugins-guide +// *********************************************************** + +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) + +/** + * @type {Cypress.PluginConfig} + */ +// eslint-disable-next-line no-unused-vars +module.exports = (on, config) => { + // `on` is used to hook into various events Cypress emits + // `config` is the resolved Cypress config +} diff --git a/frontend/cypress/support/commands.ts b/frontend/cypress/support/commands.ts new file mode 100644 index 0000000000..fdbea23436 --- /dev/null +++ b/frontend/cypress/support/commands.ts @@ -0,0 +1,45 @@ +// *********************************************** +// This example commands.js shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add('login', (email, password) => { ... }) +// +// +// -- This is a child command -- +// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This will overwrite an existing command -- +// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) + +const AUTH_USER = Cypress.env('AUTH_USER'); +const AUTH_PASSWORD = Cypress.env('AUTH_PASSWORD'); + +Cypress.Commands.add('login', (user = AUTH_USER, password = AUTH_PASSWORD) => + cy.session(user, () => { + cy.visit('/'); + cy.wait(1000); + cy.get("[data-testid='LOGIN_EMAIL_ID']").type(user); + + if (AUTH_PASSWORD) { + cy.get("[data-testid='LOGIN_PASSWORD_ID']").type(password); + } + + cy.get("[data-testid='LOGIN_BUTTON']").click(); + + // Wait for the login redirect to complete. + cy.get("[data-testid='HEADER_USER_AVATAR']"); + }) +); diff --git a/frontend/cypress/support/index.ts b/frontend/cypress/support/index.ts new file mode 100644 index 0000000000..1f43f103c0 --- /dev/null +++ b/frontend/cypress/support/index.ts @@ -0,0 +1,28 @@ +// *********************************************************** +// This example support/index.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands'; + +// Alternatively you can use CommonJS syntax: +// require('./commands') + +declare global { + namespace Cypress { + interface Chainable { + login(user?: string, password?: string): Chainable; + } + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000000..a7fa6b0b73 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,23 @@ + + + + + + + + + + + Unleash + + + + + +
+ + + diff --git a/frontend/index.js b/frontend/index.js new file mode 100644 index 0000000000..1eff7e6952 --- /dev/null +++ b/frontend/index.js @@ -0,0 +1,9 @@ +require('pkginfo')(module, 'version'); +const path = require('path'); + +const { version } = module.exports; + +module.exports = { + publicFolder: path.join(__dirname, 'build'), + version +}; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000000..5600892fec --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,131 @@ +{ + "name": "unleash-frontend-local", + "version": "0.0.0", + "private": true, + "files": [ + "index.js", + "build/" + ], + "engines": { + "node": ">=14" + }, + "scripts": { + "build": "tsc && vite build", + "lint": "eslint src --max-warnings 0", + "start": "vite", + "start:heroku": "UNLEASH_API=https://unleash.herokuapp.com yarn run start", + "start:enterprise": "UNLEASH_API=https://unleash4.herokuapp.com yarn run start", + "start:demo": "UNLEASH_BASE_PATH=/demo/ yarn start", + "test": "vitest run", + "test:watch": "vitest watch", + "fmt": "prettier src --write --loglevel warn", + "fmt:check": "prettier src --check", + "e2e": "yarn run cypress open --config baseUrl='http://localhost:3000' --env AUTH_USER=admin,AUTH_PASSWORD=unleash4all", + "e2e:heroku": "yarn run cypress open --config baseUrl='http://localhost:3000' --env AUTH_USER=example@example.com", + "prepare": "yarn run build" + }, + "devDependencies": { + "@codemirror/lang-json": "6.0.0", + "@emotion/react": "11.9.3", + "@emotion/styled": "11.9.3", + "@mui/icons-material": "5.8.4", + "@mui/lab": "5.0.0-alpha.95", + "@mui/material": "5.10.1", + "@openapitools/openapi-generator-cli": "2.5.1", + "@testing-library/dom": "8.17.1", + "@testing-library/jest-dom": "5.16.5", + "@testing-library/react": "12.1.5", + "@testing-library/react-hooks": "7.0.2", + "@testing-library/user-event": "14.4.3", + "@types/debounce": "1.2.1", + "@types/deep-diff": "1.0.1", + "@types/jest": "28.1.7", + "@types/lodash.clonedeep": "4.5.7", + "@types/node": "17.0.18", + "@types/react": "17.0.48", + "@types/react-dom": "17.0.17", + "@types/react-router-dom": "5.3.3", + "@types/react-table": "7.7.12", + "@types/react-test-renderer": "17.0.2", + "@types/react-timeago": "4.1.3", + "@types/semver": "7.3.12", + "@uiw/react-codemirror": "4.11.5", + "@vitejs/plugin-react": "1.3.2", + "chart.js": "3.9.1", + "chartjs-adapter-date-fns": "2.0.0", + "classnames": "2.3.1", + "copy-to-clipboard": "3.3.2", + "cypress": "9.7.0", + "date-fns": "2.29.2", + "debounce": "1.2.1", + "deep-diff": "1.0.2", + "eslint": "8.22.0", + "eslint-config-react-app": "7.0.1", + "fast-json-patch": "3.1.1", + "http-proxy-middleware": "2.0.6", + "immer": "9.0.15", + "jsdom": "20.0.0", + "lodash.clonedeep": "4.5.0", + "msw": "0.45.0", + "pkginfo": "0.4.1", + "plausible-tracker": "0.3.8", + "prettier": "2.7.1", + "prop-types": "15.8.1", + "react": "17.0.2", + "react-chartjs-2": "4.3.1", + "react-dom": "17.0.2", + "react-hooks-global-state": "2.0.0", + "react-router-dom": "6.3.0", + "react-table": "7.8.0", + "react-test-renderer": "17.0.2", + "react-timeago": "7.1.0", + "sass": "1.54.5", + "semver": "7.3.7", + "swr": "1.3.0", + "tss-react": "3.7.1", + "typescript": "4.7.4", + "vite": "2.9.15", + "vite-plugin-env-compatible": "1.1.1", + "vite-plugin-svgr": "2.2.1", + "vite-tsconfig-paths": "3.5.0", + "vitest": "0.22.1", + "whatwg-fetch": "3.6.2", + "@uiw/codemirror-theme-duotone": "4.11.5" + }, + "jest": { + "moduleNameMapper": { + "\\.(jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/src/__mocks__/fileMock.js", + "\\.svg": "/src/__mocks__/svgMock.js", + "\\.(css|scss)$": "identity-obj-proxy" + } + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ], + "parserOptions": { + "warnOnUnsupportedTypeScriptVersion": false + }, + "rules": { + "no-restricted-globals": "off", + "no-useless-computed-key": "off", + "import/no-anonymous-default-export": "off" + }, + "ignorePatterns": [ + "cypress" + ] + } +} diff --git a/frontend/public/cs_CZ.png b/frontend/public/cs_CZ.png new file mode 100644 index 0000000000..4fc3adb599 Binary files /dev/null and b/frontend/public/cs_CZ.png differ diff --git a/frontend/public/da-DK.png b/frontend/public/da-DK.png new file mode 100644 index 0000000000..e3471d34e9 Binary files /dev/null and b/frontend/public/da-DK.png differ diff --git a/frontend/public/datadog.svg b/frontend/public/datadog.svg new file mode 100644 index 0000000000..49e6f8473f --- /dev/null +++ b/frontend/public/datadog.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/de_DE.png b/frontend/public/de_DE.png new file mode 100644 index 0000000000..eea2e58b4f Binary files /dev/null and b/frontend/public/de_DE.png differ diff --git a/frontend/public/en-GB.png b/frontend/public/en-GB.png new file mode 100644 index 0000000000..f1e0e12640 Binary files /dev/null and b/frontend/public/en-GB.png differ diff --git a/frontend/public/en-IN.png b/frontend/public/en-IN.png new file mode 100644 index 0000000000..2f06567b84 Binary files /dev/null and b/frontend/public/en-IN.png differ diff --git a/frontend/public/en-US.png b/frontend/public/en-US.png new file mode 100644 index 0000000000..5b96ff2407 Binary files /dev/null and b/frontend/public/en-US.png differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000000..551d158eee Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/favicon_old.ico b/frontend/public/favicon_old.ico new file mode 100644 index 0000000000..fed2443a05 Binary files /dev/null and b/frontend/public/favicon_old.ico differ diff --git a/frontend/public/flags-normal/ad.png b/frontend/public/flags-normal/ad.png new file mode 100644 index 0000000000..886752f6ce Binary files /dev/null and b/frontend/public/flags-normal/ad.png differ diff --git a/frontend/public/flags-normal/ae.png b/frontend/public/flags-normal/ae.png new file mode 100644 index 0000000000..a253cd2db5 Binary files /dev/null and b/frontend/public/flags-normal/ae.png differ diff --git a/frontend/public/flags-normal/af.png b/frontend/public/flags-normal/af.png new file mode 100644 index 0000000000..6ae08810c5 Binary files /dev/null and b/frontend/public/flags-normal/af.png differ diff --git a/frontend/public/flags-normal/ag.png b/frontend/public/flags-normal/ag.png new file mode 100644 index 0000000000..ee529d2bc2 Binary files /dev/null and b/frontend/public/flags-normal/ag.png differ diff --git a/frontend/public/flags-normal/al.png b/frontend/public/flags-normal/al.png new file mode 100644 index 0000000000..4b59dfbdb4 Binary files /dev/null and b/frontend/public/flags-normal/al.png differ diff --git a/frontend/public/flags-normal/am.png b/frontend/public/flags-normal/am.png new file mode 100644 index 0000000000..41b497a368 Binary files /dev/null and b/frontend/public/flags-normal/am.png differ diff --git a/frontend/public/flags-normal/ao.png b/frontend/public/flags-normal/ao.png new file mode 100644 index 0000000000..f5ff237487 Binary files /dev/null and b/frontend/public/flags-normal/ao.png differ diff --git a/frontend/public/flags-normal/ar.png b/frontend/public/flags-normal/ar.png new file mode 100644 index 0000000000..0b25d9cb71 Binary files /dev/null and b/frontend/public/flags-normal/ar.png differ diff --git a/frontend/public/flags-normal/at.png b/frontend/public/flags-normal/at.png new file mode 100644 index 0000000000..75646bab58 Binary files /dev/null and b/frontend/public/flags-normal/at.png differ diff --git a/frontend/public/flags-normal/au.png b/frontend/public/flags-normal/au.png new file mode 100644 index 0000000000..f2572d728d Binary files /dev/null and b/frontend/public/flags-normal/au.png differ diff --git a/frontend/public/flags-normal/az.png b/frontend/public/flags-normal/az.png new file mode 100644 index 0000000000..f639aefd61 Binary files /dev/null and b/frontend/public/flags-normal/az.png differ diff --git a/frontend/public/flags-normal/ba.png b/frontend/public/flags-normal/ba.png new file mode 100644 index 0000000000..a4ac356d60 Binary files /dev/null and b/frontend/public/flags-normal/ba.png differ diff --git a/frontend/public/flags-normal/bb.png b/frontend/public/flags-normal/bb.png new file mode 100644 index 0000000000..2bf58e69e6 Binary files /dev/null and b/frontend/public/flags-normal/bb.png differ diff --git a/frontend/public/flags-normal/bd.png b/frontend/public/flags-normal/bd.png new file mode 100644 index 0000000000..e9872d1542 Binary files /dev/null and b/frontend/public/flags-normal/bd.png differ diff --git a/frontend/public/flags-normal/be.png b/frontend/public/flags-normal/be.png new file mode 100644 index 0000000000..5d1b8325dd Binary files /dev/null and b/frontend/public/flags-normal/be.png differ diff --git a/frontend/public/flags-normal/bf.png b/frontend/public/flags-normal/bf.png new file mode 100644 index 0000000000..5172dbfb31 Binary files /dev/null and b/frontend/public/flags-normal/bf.png differ diff --git a/frontend/public/flags-normal/bg.png b/frontend/public/flags-normal/bg.png new file mode 100644 index 0000000000..d78308df87 Binary files /dev/null and b/frontend/public/flags-normal/bg.png differ diff --git a/frontend/public/flags-normal/bh.png b/frontend/public/flags-normal/bh.png new file mode 100644 index 0000000000..5e247e7aa4 Binary files /dev/null and b/frontend/public/flags-normal/bh.png differ diff --git a/frontend/public/flags-normal/bi.png b/frontend/public/flags-normal/bi.png new file mode 100644 index 0000000000..26186437c3 Binary files /dev/null and b/frontend/public/flags-normal/bi.png differ diff --git a/frontend/public/flags-normal/bj.png b/frontend/public/flags-normal/bj.png new file mode 100644 index 0000000000..20e281f272 Binary files /dev/null and b/frontend/public/flags-normal/bj.png differ diff --git a/frontend/public/flags-normal/bn.png b/frontend/public/flags-normal/bn.png new file mode 100644 index 0000000000..b4a3e60e65 Binary files /dev/null and b/frontend/public/flags-normal/bn.png differ diff --git a/frontend/public/flags-normal/bo.png b/frontend/public/flags-normal/bo.png new file mode 100644 index 0000000000..342267cbbc Binary files /dev/null and b/frontend/public/flags-normal/bo.png differ diff --git a/frontend/public/flags-normal/br.png b/frontend/public/flags-normal/br.png new file mode 100644 index 0000000000..43725657c0 Binary files /dev/null and b/frontend/public/flags-normal/br.png differ diff --git a/frontend/public/flags-normal/bs.png b/frontend/public/flags-normal/bs.png new file mode 100644 index 0000000000..1bbb1d8f61 Binary files /dev/null and b/frontend/public/flags-normal/bs.png differ diff --git a/frontend/public/flags-normal/bt.png b/frontend/public/flags-normal/bt.png new file mode 100644 index 0000000000..cd4c85391f Binary files /dev/null and b/frontend/public/flags-normal/bt.png differ diff --git a/frontend/public/flags-normal/bw.png b/frontend/public/flags-normal/bw.png new file mode 100644 index 0000000000..555d80b35f Binary files /dev/null and b/frontend/public/flags-normal/bw.png differ diff --git a/frontend/public/flags-normal/by.png b/frontend/public/flags-normal/by.png new file mode 100644 index 0000000000..0dc3102025 Binary files /dev/null and b/frontend/public/flags-normal/by.png differ diff --git a/frontend/public/flags-normal/bz.png b/frontend/public/flags-normal/bz.png new file mode 100644 index 0000000000..3b6c39e67e Binary files /dev/null and b/frontend/public/flags-normal/bz.png differ diff --git a/frontend/public/flags-normal/ca.png b/frontend/public/flags-normal/ca.png new file mode 100644 index 0000000000..c939b041da Binary files /dev/null and b/frontend/public/flags-normal/ca.png differ diff --git a/frontend/public/flags-normal/cd.png b/frontend/public/flags-normal/cd.png new file mode 100644 index 0000000000..44043fac2f Binary files /dev/null and b/frontend/public/flags-normal/cd.png differ diff --git a/frontend/public/flags-normal/cf.png b/frontend/public/flags-normal/cf.png new file mode 100644 index 0000000000..5b7cb2252e Binary files /dev/null and b/frontend/public/flags-normal/cf.png differ diff --git a/frontend/public/flags-normal/cg.png b/frontend/public/flags-normal/cg.png new file mode 100644 index 0000000000..2d7ce4c027 Binary files /dev/null and b/frontend/public/flags-normal/cg.png differ diff --git a/frontend/public/flags-normal/ch.png b/frontend/public/flags-normal/ch.png new file mode 100644 index 0000000000..5fe151ca63 Binary files /dev/null and b/frontend/public/flags-normal/ch.png differ diff --git a/frontend/public/flags-normal/ci.png b/frontend/public/flags-normal/ci.png new file mode 100644 index 0000000000..0534124cb5 Binary files /dev/null and b/frontend/public/flags-normal/ci.png differ diff --git a/frontend/public/flags-normal/cl.png b/frontend/public/flags-normal/cl.png new file mode 100644 index 0000000000..af74ffc934 Binary files /dev/null and b/frontend/public/flags-normal/cl.png differ diff --git a/frontend/public/flags-normal/cm.png b/frontend/public/flags-normal/cm.png new file mode 100644 index 0000000000..b33c811544 Binary files /dev/null and b/frontend/public/flags-normal/cm.png differ diff --git a/frontend/public/flags-normal/cn.png b/frontend/public/flags-normal/cn.png new file mode 100644 index 0000000000..d31bab7106 Binary files /dev/null and b/frontend/public/flags-normal/cn.png differ diff --git a/frontend/public/flags-normal/co.png b/frontend/public/flags-normal/co.png new file mode 100644 index 0000000000..b6aae55c71 Binary files /dev/null and b/frontend/public/flags-normal/co.png differ diff --git a/frontend/public/flags-normal/cr.png b/frontend/public/flags-normal/cr.png new file mode 100644 index 0000000000..9c92f6de0c Binary files /dev/null and b/frontend/public/flags-normal/cr.png differ diff --git a/frontend/public/flags-normal/cu.png b/frontend/public/flags-normal/cu.png new file mode 100644 index 0000000000..f21090e273 Binary files /dev/null and b/frontend/public/flags-normal/cu.png differ diff --git a/frontend/public/flags-normal/cv.png b/frontend/public/flags-normal/cv.png new file mode 100644 index 0000000000..6eeae62b98 Binary files /dev/null and b/frontend/public/flags-normal/cv.png differ diff --git a/frontend/public/flags-normal/cy.png b/frontend/public/flags-normal/cy.png new file mode 100644 index 0000000000..554460413a Binary files /dev/null and b/frontend/public/flags-normal/cy.png differ diff --git a/frontend/public/flags-normal/cz.png b/frontend/public/flags-normal/cz.png new file mode 100644 index 0000000000..4fc3adb599 Binary files /dev/null and b/frontend/public/flags-normal/cz.png differ diff --git a/frontend/public/flags-normal/de.png b/frontend/public/flags-normal/de.png new file mode 100644 index 0000000000..eea2e58b4f Binary files /dev/null and b/frontend/public/flags-normal/de.png differ diff --git a/frontend/public/flags-normal/dj.png b/frontend/public/flags-normal/dj.png new file mode 100644 index 0000000000..dbc95d77e3 Binary files /dev/null and b/frontend/public/flags-normal/dj.png differ diff --git a/frontend/public/flags-normal/dk.png b/frontend/public/flags-normal/dk.png new file mode 100644 index 0000000000..e3471d34e9 Binary files /dev/null and b/frontend/public/flags-normal/dk.png differ diff --git a/frontend/public/flags-normal/dm.png b/frontend/public/flags-normal/dm.png new file mode 100644 index 0000000000..a158c88fde Binary files /dev/null and b/frontend/public/flags-normal/dm.png differ diff --git a/frontend/public/flags-normal/do.png b/frontend/public/flags-normal/do.png new file mode 100644 index 0000000000..81fa5e8bbb Binary files /dev/null and b/frontend/public/flags-normal/do.png differ diff --git a/frontend/public/flags-normal/dz.png b/frontend/public/flags-normal/dz.png new file mode 100644 index 0000000000..b2768bcccf Binary files /dev/null and b/frontend/public/flags-normal/dz.png differ diff --git a/frontend/public/flags-normal/ec.png b/frontend/public/flags-normal/ec.png new file mode 100644 index 0000000000..27fe811525 Binary files /dev/null and b/frontend/public/flags-normal/ec.png differ diff --git a/frontend/public/flags-normal/ee.png b/frontend/public/flags-normal/ee.png new file mode 100644 index 0000000000..21b4b72de8 Binary files /dev/null and b/frontend/public/flags-normal/ee.png differ diff --git a/frontend/public/flags-normal/eg.png b/frontend/public/flags-normal/eg.png new file mode 100644 index 0000000000..d98e5d3aa9 Binary files /dev/null and b/frontend/public/flags-normal/eg.png differ diff --git a/frontend/public/flags-normal/eh.png b/frontend/public/flags-normal/eh.png new file mode 100644 index 0000000000..cf451799aa Binary files /dev/null and b/frontend/public/flags-normal/eh.png differ diff --git a/frontend/public/flags-normal/er.png b/frontend/public/flags-normal/er.png new file mode 100644 index 0000000000..3f88fc52a2 Binary files /dev/null and b/frontend/public/flags-normal/er.png differ diff --git a/frontend/public/flags-normal/es.png b/frontend/public/flags-normal/es.png new file mode 100644 index 0000000000..f589a83520 Binary files /dev/null and b/frontend/public/flags-normal/es.png differ diff --git a/frontend/public/flags-normal/et.png b/frontend/public/flags-normal/et.png new file mode 100644 index 0000000000..d759c2fd13 Binary files /dev/null and b/frontend/public/flags-normal/et.png differ diff --git a/frontend/public/flags-normal/fi.png b/frontend/public/flags-normal/fi.png new file mode 100644 index 0000000000..2bcb6a5534 Binary files /dev/null and b/frontend/public/flags-normal/fi.png differ diff --git a/frontend/public/flags-normal/fj.png b/frontend/public/flags-normal/fj.png new file mode 100644 index 0000000000..7aef415f91 Binary files /dev/null and b/frontend/public/flags-normal/fj.png differ diff --git a/frontend/public/flags-normal/fm.png b/frontend/public/flags-normal/fm.png new file mode 100644 index 0000000000..1dfbdffe8b Binary files /dev/null and b/frontend/public/flags-normal/fm.png differ diff --git a/frontend/public/flags-normal/fr.png b/frontend/public/flags-normal/fr.png new file mode 100644 index 0000000000..fcfa7caf02 Binary files /dev/null and b/frontend/public/flags-normal/fr.png differ diff --git a/frontend/public/flags-normal/ga.png b/frontend/public/flags-normal/ga.png new file mode 100644 index 0000000000..2dc5f0fcf8 Binary files /dev/null and b/frontend/public/flags-normal/ga.png differ diff --git a/frontend/public/flags-normal/gb.png b/frontend/public/flags-normal/gb.png new file mode 100644 index 0000000000..f1e0e12640 Binary files /dev/null and b/frontend/public/flags-normal/gb.png differ diff --git a/frontend/public/flags-normal/gd.png b/frontend/public/flags-normal/gd.png new file mode 100644 index 0000000000..5e3ed13b73 Binary files /dev/null and b/frontend/public/flags-normal/gd.png differ diff --git a/frontend/public/flags-normal/ge.png b/frontend/public/flags-normal/ge.png new file mode 100644 index 0000000000..cd5b75deb7 Binary files /dev/null and b/frontend/public/flags-normal/ge.png differ diff --git a/frontend/public/flags-normal/gh.png b/frontend/public/flags-normal/gh.png new file mode 100644 index 0000000000..a7b60ce7a2 Binary files /dev/null and b/frontend/public/flags-normal/gh.png differ diff --git a/frontend/public/flags-normal/gm.png b/frontend/public/flags-normal/gm.png new file mode 100644 index 0000000000..ca440bb63d Binary files /dev/null and b/frontend/public/flags-normal/gm.png differ diff --git a/frontend/public/flags-normal/gn.png b/frontend/public/flags-normal/gn.png new file mode 100644 index 0000000000..0740a3fc35 Binary files /dev/null and b/frontend/public/flags-normal/gn.png differ diff --git a/frontend/public/flags-normal/gq.png b/frontend/public/flags-normal/gq.png new file mode 100644 index 0000000000..bc9c8c46b7 Binary files /dev/null and b/frontend/public/flags-normal/gq.png differ diff --git a/frontend/public/flags-normal/gr.png b/frontend/public/flags-normal/gr.png new file mode 100644 index 0000000000..ec65864a50 Binary files /dev/null and b/frontend/public/flags-normal/gr.png differ diff --git a/frontend/public/flags-normal/gt.png b/frontend/public/flags-normal/gt.png new file mode 100644 index 0000000000..3c7cee7dfa Binary files /dev/null and b/frontend/public/flags-normal/gt.png differ diff --git a/frontend/public/flags-normal/gw.png b/frontend/public/flags-normal/gw.png new file mode 100644 index 0000000000..515d457f7f Binary files /dev/null and b/frontend/public/flags-normal/gw.png differ diff --git a/frontend/public/flags-normal/gy.png b/frontend/public/flags-normal/gy.png new file mode 100644 index 0000000000..6c3e673302 Binary files /dev/null and b/frontend/public/flags-normal/gy.png differ diff --git a/frontend/public/flags-normal/hn.png b/frontend/public/flags-normal/hn.png new file mode 100644 index 0000000000..ee1d10289a Binary files /dev/null and b/frontend/public/flags-normal/hn.png differ diff --git a/frontend/public/flags-normal/hr.png b/frontend/public/flags-normal/hr.png new file mode 100644 index 0000000000..2dae8a8aff Binary files /dev/null and b/frontend/public/flags-normal/hr.png differ diff --git a/frontend/public/flags-normal/ht.png b/frontend/public/flags-normal/ht.png new file mode 100644 index 0000000000..2e15f89987 Binary files /dev/null and b/frontend/public/flags-normal/ht.png differ diff --git a/frontend/public/flags-normal/hu.png b/frontend/public/flags-normal/hu.png new file mode 100644 index 0000000000..c1c028ec2e Binary files /dev/null and b/frontend/public/flags-normal/hu.png differ diff --git a/frontend/public/flags-normal/id.png b/frontend/public/flags-normal/id.png new file mode 100644 index 0000000000..619215dacc Binary files /dev/null and b/frontend/public/flags-normal/id.png differ diff --git a/frontend/public/flags-normal/ie.png b/frontend/public/flags-normal/ie.png new file mode 100644 index 0000000000..3881ba3402 Binary files /dev/null and b/frontend/public/flags-normal/ie.png differ diff --git a/frontend/public/flags-normal/il.png b/frontend/public/flags-normal/il.png new file mode 100644 index 0000000000..33fc90c2ca Binary files /dev/null and b/frontend/public/flags-normal/il.png differ diff --git a/frontend/public/flags-normal/in.png b/frontend/public/flags-normal/in.png new file mode 100644 index 0000000000..2f06567b84 Binary files /dev/null and b/frontend/public/flags-normal/in.png differ diff --git a/frontend/public/flags-normal/iq.png b/frontend/public/flags-normal/iq.png new file mode 100644 index 0000000000..6b5eb22a03 Binary files /dev/null and b/frontend/public/flags-normal/iq.png differ diff --git a/frontend/public/flags-normal/ir.png b/frontend/public/flags-normal/ir.png new file mode 100644 index 0000000000..36f7ec834e Binary files /dev/null and b/frontend/public/flags-normal/ir.png differ diff --git a/frontend/public/flags-normal/is.png b/frontend/public/flags-normal/is.png new file mode 100644 index 0000000000..74fef41dff Binary files /dev/null and b/frontend/public/flags-normal/is.png differ diff --git a/frontend/public/flags-normal/it.png b/frontend/public/flags-normal/it.png new file mode 100644 index 0000000000..ff7ed31740 Binary files /dev/null and b/frontend/public/flags-normal/it.png differ diff --git a/frontend/public/flags-normal/jm.png b/frontend/public/flags-normal/jm.png new file mode 100644 index 0000000000..68e58fee3d Binary files /dev/null and b/frontend/public/flags-normal/jm.png differ diff --git a/frontend/public/flags-normal/jo.png b/frontend/public/flags-normal/jo.png new file mode 100644 index 0000000000..57bd76a6dc Binary files /dev/null and b/frontend/public/flags-normal/jo.png differ diff --git a/frontend/public/flags-normal/jp.png b/frontend/public/flags-normal/jp.png new file mode 100644 index 0000000000..33f3a75784 Binary files /dev/null and b/frontend/public/flags-normal/jp.png differ diff --git a/frontend/public/flags-normal/ke.png b/frontend/public/flags-normal/ke.png new file mode 100644 index 0000000000..9e8373fd49 Binary files /dev/null and b/frontend/public/flags-normal/ke.png differ diff --git a/frontend/public/flags-normal/kg.png b/frontend/public/flags-normal/kg.png new file mode 100644 index 0000000000..3e7d661122 Binary files /dev/null and b/frontend/public/flags-normal/kg.png differ diff --git a/frontend/public/flags-normal/kh.png b/frontend/public/flags-normal/kh.png new file mode 100644 index 0000000000..cf76786bf4 Binary files /dev/null and b/frontend/public/flags-normal/kh.png differ diff --git a/frontend/public/flags-normal/ki.png b/frontend/public/flags-normal/ki.png new file mode 100644 index 0000000000..ff8e470df2 Binary files /dev/null and b/frontend/public/flags-normal/ki.png differ diff --git a/frontend/public/flags-normal/km.png b/frontend/public/flags-normal/km.png new file mode 100644 index 0000000000..cbd5e1b5d0 Binary files /dev/null and b/frontend/public/flags-normal/km.png differ diff --git a/frontend/public/flags-normal/kn.png b/frontend/public/flags-normal/kn.png new file mode 100644 index 0000000000..fed64fc00b Binary files /dev/null and b/frontend/public/flags-normal/kn.png differ diff --git a/frontend/public/flags-normal/kp.png b/frontend/public/flags-normal/kp.png new file mode 100644 index 0000000000..b25aadc337 Binary files /dev/null and b/frontend/public/flags-normal/kp.png differ diff --git a/frontend/public/flags-normal/kr.png b/frontend/public/flags-normal/kr.png new file mode 100644 index 0000000000..d035cab9b5 Binary files /dev/null and b/frontend/public/flags-normal/kr.png differ diff --git a/frontend/public/flags-normal/ks.png b/frontend/public/flags-normal/ks.png new file mode 100644 index 0000000000..942e1b5800 Binary files /dev/null and b/frontend/public/flags-normal/ks.png differ diff --git a/frontend/public/flags-normal/kw.png b/frontend/public/flags-normal/kw.png new file mode 100644 index 0000000000..8c01668df4 Binary files /dev/null and b/frontend/public/flags-normal/kw.png differ diff --git a/frontend/public/flags-normal/kz.png b/frontend/public/flags-normal/kz.png new file mode 100644 index 0000000000..436ac8a1e8 Binary files /dev/null and b/frontend/public/flags-normal/kz.png differ diff --git a/frontend/public/flags-normal/la.png b/frontend/public/flags-normal/la.png new file mode 100644 index 0000000000..87d7fb3c88 Binary files /dev/null and b/frontend/public/flags-normal/la.png differ diff --git a/frontend/public/flags-normal/lb.png b/frontend/public/flags-normal/lb.png new file mode 100644 index 0000000000..7d3659ab07 Binary files /dev/null and b/frontend/public/flags-normal/lb.png differ diff --git a/frontend/public/flags-normal/lc.png b/frontend/public/flags-normal/lc.png new file mode 100644 index 0000000000..4bb0487ca1 Binary files /dev/null and b/frontend/public/flags-normal/lc.png differ diff --git a/frontend/public/flags-normal/li.png b/frontend/public/flags-normal/li.png new file mode 100644 index 0000000000..b68b433a88 Binary files /dev/null and b/frontend/public/flags-normal/li.png differ diff --git a/frontend/public/flags-normal/lk.png b/frontend/public/flags-normal/lk.png new file mode 100644 index 0000000000..15e45c8197 Binary files /dev/null and b/frontend/public/flags-normal/lk.png differ diff --git a/frontend/public/flags-normal/lr.png b/frontend/public/flags-normal/lr.png new file mode 100644 index 0000000000..36948eef99 Binary files /dev/null and b/frontend/public/flags-normal/lr.png differ diff --git a/frontend/public/flags-normal/ls.png b/frontend/public/flags-normal/ls.png new file mode 100644 index 0000000000..70cab72308 Binary files /dev/null and b/frontend/public/flags-normal/ls.png differ diff --git a/frontend/public/flags-normal/lt.png b/frontend/public/flags-normal/lt.png new file mode 100644 index 0000000000..80bc580526 Binary files /dev/null and b/frontend/public/flags-normal/lt.png differ diff --git a/frontend/public/flags-normal/lu.png b/frontend/public/flags-normal/lu.png new file mode 100644 index 0000000000..c5c2246c5b Binary files /dev/null and b/frontend/public/flags-normal/lu.png differ diff --git a/frontend/public/flags-normal/lv.png b/frontend/public/flags-normal/lv.png new file mode 100644 index 0000000000..75431d1965 Binary files /dev/null and b/frontend/public/flags-normal/lv.png differ diff --git a/frontend/public/flags-normal/ly.png b/frontend/public/flags-normal/ly.png new file mode 100644 index 0000000000..2914da2986 Binary files /dev/null and b/frontend/public/flags-normal/ly.png differ diff --git a/frontend/public/flags-normal/ma.png b/frontend/public/flags-normal/ma.png new file mode 100644 index 0000000000..0f751a1c5a Binary files /dev/null and b/frontend/public/flags-normal/ma.png differ diff --git a/frontend/public/flags-normal/mc.png b/frontend/public/flags-normal/mc.png new file mode 100644 index 0000000000..3f8311b28d Binary files /dev/null and b/frontend/public/flags-normal/mc.png differ diff --git a/frontend/public/flags-normal/md.png b/frontend/public/flags-normal/md.png new file mode 100644 index 0000000000..4645ae109e Binary files /dev/null and b/frontend/public/flags-normal/md.png differ diff --git a/frontend/public/flags-normal/me.png b/frontend/public/flags-normal/me.png new file mode 100644 index 0000000000..941d51d414 Binary files /dev/null and b/frontend/public/flags-normal/me.png differ diff --git a/frontend/public/flags-normal/mg.png b/frontend/public/flags-normal/mg.png new file mode 100644 index 0000000000..43922054d4 Binary files /dev/null and b/frontend/public/flags-normal/mg.png differ diff --git a/frontend/public/flags-normal/mh.png b/frontend/public/flags-normal/mh.png new file mode 100644 index 0000000000..8438bfa32f Binary files /dev/null and b/frontend/public/flags-normal/mh.png differ diff --git a/frontend/public/flags-normal/mk.png b/frontend/public/flags-normal/mk.png new file mode 100644 index 0000000000..3c08615b9b Binary files /dev/null and b/frontend/public/flags-normal/mk.png differ diff --git a/frontend/public/flags-normal/ml.png b/frontend/public/flags-normal/ml.png new file mode 100644 index 0000000000..ce81958a2f Binary files /dev/null and b/frontend/public/flags-normal/ml.png differ diff --git a/frontend/public/flags-normal/mm.png b/frontend/public/flags-normal/mm.png new file mode 100644 index 0000000000..3c1c085685 Binary files /dev/null and b/frontend/public/flags-normal/mm.png differ diff --git a/frontend/public/flags-normal/mn.png b/frontend/public/flags-normal/mn.png new file mode 100644 index 0000000000..2771b270b7 Binary files /dev/null and b/frontend/public/flags-normal/mn.png differ diff --git a/frontend/public/flags-normal/mr.png b/frontend/public/flags-normal/mr.png new file mode 100644 index 0000000000..f4dcf1d2f9 Binary files /dev/null and b/frontend/public/flags-normal/mr.png differ diff --git a/frontend/public/flags-normal/mt.png b/frontend/public/flags-normal/mt.png new file mode 100644 index 0000000000..950502ab63 Binary files /dev/null and b/frontend/public/flags-normal/mt.png differ diff --git a/frontend/public/flags-normal/mu.png b/frontend/public/flags-normal/mu.png new file mode 100644 index 0000000000..a6349637cd Binary files /dev/null and b/frontend/public/flags-normal/mu.png differ diff --git a/frontend/public/flags-normal/mv.png b/frontend/public/flags-normal/mv.png new file mode 100644 index 0000000000..565a40831e Binary files /dev/null and b/frontend/public/flags-normal/mv.png differ diff --git a/frontend/public/flags-normal/mw.png b/frontend/public/flags-normal/mw.png new file mode 100644 index 0000000000..442dbc58de Binary files /dev/null and b/frontend/public/flags-normal/mw.png differ diff --git a/frontend/public/flags-normal/mx.png b/frontend/public/flags-normal/mx.png new file mode 100644 index 0000000000..666424d181 Binary files /dev/null and b/frontend/public/flags-normal/mx.png differ diff --git a/frontend/public/flags-normal/my.png b/frontend/public/flags-normal/my.png new file mode 100644 index 0000000000..215448cdd3 Binary files /dev/null and b/frontend/public/flags-normal/my.png differ diff --git a/frontend/public/flags-normal/mz.png b/frontend/public/flags-normal/mz.png new file mode 100644 index 0000000000..18e2a94999 Binary files /dev/null and b/frontend/public/flags-normal/mz.png differ diff --git a/frontend/public/flags-normal/na.png b/frontend/public/flags-normal/na.png new file mode 100644 index 0000000000..ca31b5d22f Binary files /dev/null and b/frontend/public/flags-normal/na.png differ diff --git a/frontend/public/flags-normal/ne.png b/frontend/public/flags-normal/ne.png new file mode 100644 index 0000000000..e0097297b8 Binary files /dev/null and b/frontend/public/flags-normal/ne.png differ diff --git a/frontend/public/flags-normal/ng.png b/frontend/public/flags-normal/ng.png new file mode 100644 index 0000000000..ee5775a8dd Binary files /dev/null and b/frontend/public/flags-normal/ng.png differ diff --git a/frontend/public/flags-normal/ni.png b/frontend/public/flags-normal/ni.png new file mode 100644 index 0000000000..2ebe882abc Binary files /dev/null and b/frontend/public/flags-normal/ni.png differ diff --git a/frontend/public/flags-normal/nl.png b/frontend/public/flags-normal/nl.png new file mode 100644 index 0000000000..0386cc3e80 Binary files /dev/null and b/frontend/public/flags-normal/nl.png differ diff --git a/frontend/public/flags-normal/no.png b/frontend/public/flags-normal/no.png new file mode 100644 index 0000000000..bb2f806b51 Binary files /dev/null and b/frontend/public/flags-normal/no.png differ diff --git a/frontend/public/flags-normal/np.png b/frontend/public/flags-normal/np.png new file mode 100644 index 0000000000..726500cc63 Binary files /dev/null and b/frontend/public/flags-normal/np.png differ diff --git a/frontend/public/flags-normal/nr.png b/frontend/public/flags-normal/nr.png new file mode 100644 index 0000000000..65b58110d7 Binary files /dev/null and b/frontend/public/flags-normal/nr.png differ diff --git a/frontend/public/flags-normal/nz.png b/frontend/public/flags-normal/nz.png new file mode 100644 index 0000000000..abe4acf673 Binary files /dev/null and b/frontend/public/flags-normal/nz.png differ diff --git a/frontend/public/flags-normal/om.png b/frontend/public/flags-normal/om.png new file mode 100644 index 0000000000..8681267655 Binary files /dev/null and b/frontend/public/flags-normal/om.png differ diff --git a/frontend/public/flags-normal/pa.png b/frontend/public/flags-normal/pa.png new file mode 100644 index 0000000000..e821dee88a Binary files /dev/null and b/frontend/public/flags-normal/pa.png differ diff --git a/frontend/public/flags-normal/pe.png b/frontend/public/flags-normal/pe.png new file mode 100644 index 0000000000..5af51ad757 Binary files /dev/null and b/frontend/public/flags-normal/pe.png differ diff --git a/frontend/public/flags-normal/pg.png b/frontend/public/flags-normal/pg.png new file mode 100644 index 0000000000..14818457ec Binary files /dev/null and b/frontend/public/flags-normal/pg.png differ diff --git a/frontend/public/flags-normal/ph.png b/frontend/public/flags-normal/ph.png new file mode 100644 index 0000000000..ffa33a921e Binary files /dev/null and b/frontend/public/flags-normal/ph.png differ diff --git a/frontend/public/flags-normal/pk.png b/frontend/public/flags-normal/pk.png new file mode 100644 index 0000000000..645971c5d4 Binary files /dev/null and b/frontend/public/flags-normal/pk.png differ diff --git a/frontend/public/flags-normal/pl.png b/frontend/public/flags-normal/pl.png new file mode 100644 index 0000000000..9d4e692506 Binary files /dev/null and b/frontend/public/flags-normal/pl.png differ diff --git a/frontend/public/flags-normal/pt.png b/frontend/public/flags-normal/pt.png new file mode 100644 index 0000000000..6526f8c1b3 Binary files /dev/null and b/frontend/public/flags-normal/pt.png differ diff --git a/frontend/public/flags-normal/pw.png b/frontend/public/flags-normal/pw.png new file mode 100644 index 0000000000..0a91ea566c Binary files /dev/null and b/frontend/public/flags-normal/pw.png differ diff --git a/frontend/public/flags-normal/py.png b/frontend/public/flags-normal/py.png new file mode 100644 index 0000000000..40dffa497b Binary files /dev/null and b/frontend/public/flags-normal/py.png differ diff --git a/frontend/public/flags-normal/qa.png b/frontend/public/flags-normal/qa.png new file mode 100644 index 0000000000..9cf0068305 Binary files /dev/null and b/frontend/public/flags-normal/qa.png differ diff --git a/frontend/public/flags-normal/ro.png b/frontend/public/flags-normal/ro.png new file mode 100644 index 0000000000..0bee8d1a9c Binary files /dev/null and b/frontend/public/flags-normal/ro.png differ diff --git a/frontend/public/flags-normal/rs.png b/frontend/public/flags-normal/rs.png new file mode 100644 index 0000000000..19fd38a653 Binary files /dev/null and b/frontend/public/flags-normal/rs.png differ diff --git a/frontend/public/flags-normal/ru.png b/frontend/public/flags-normal/ru.png new file mode 100644 index 0000000000..66741a4d43 Binary files /dev/null and b/frontend/public/flags-normal/ru.png differ diff --git a/frontend/public/flags-normal/rw.png b/frontend/public/flags-normal/rw.png new file mode 100644 index 0000000000..24080d6dad Binary files /dev/null and b/frontend/public/flags-normal/rw.png differ diff --git a/frontend/public/flags-normal/sa.png b/frontend/public/flags-normal/sa.png new file mode 100644 index 0000000000..66dadb5b5f Binary files /dev/null and b/frontend/public/flags-normal/sa.png differ diff --git a/frontend/public/flags-normal/sb.png b/frontend/public/flags-normal/sb.png new file mode 100644 index 0000000000..97e0fc7c1d Binary files /dev/null and b/frontend/public/flags-normal/sb.png differ diff --git a/frontend/public/flags-normal/sc.png b/frontend/public/flags-normal/sc.png new file mode 100644 index 0000000000..7686373597 Binary files /dev/null and b/frontend/public/flags-normal/sc.png differ diff --git a/frontend/public/flags-normal/sd.png b/frontend/public/flags-normal/sd.png new file mode 100644 index 0000000000..9a6f886d73 Binary files /dev/null and b/frontend/public/flags-normal/sd.png differ diff --git a/frontend/public/flags-normal/se.png b/frontend/public/flags-normal/se.png new file mode 100644 index 0000000000..59595199b3 Binary files /dev/null and b/frontend/public/flags-normal/se.png differ diff --git a/frontend/public/flags-normal/sg.png b/frontend/public/flags-normal/sg.png new file mode 100644 index 0000000000..8ba42209d7 Binary files /dev/null and b/frontend/public/flags-normal/sg.png differ diff --git a/frontend/public/flags-normal/si.png b/frontend/public/flags-normal/si.png new file mode 100644 index 0000000000..3b751344c8 Binary files /dev/null and b/frontend/public/flags-normal/si.png differ diff --git a/frontend/public/flags-normal/sk.png b/frontend/public/flags-normal/sk.png new file mode 100644 index 0000000000..0769397a95 Binary files /dev/null and b/frontend/public/flags-normal/sk.png differ diff --git a/frontend/public/flags-normal/sl.png b/frontend/public/flags-normal/sl.png new file mode 100644 index 0000000000..96cddd4f40 Binary files /dev/null and b/frontend/public/flags-normal/sl.png differ diff --git a/frontend/public/flags-normal/sm.png b/frontend/public/flags-normal/sm.png new file mode 100644 index 0000000000..4ee071c298 Binary files /dev/null and b/frontend/public/flags-normal/sm.png differ diff --git a/frontend/public/flags-normal/sn.png b/frontend/public/flags-normal/sn.png new file mode 100644 index 0000000000..9415c60ef5 Binary files /dev/null and b/frontend/public/flags-normal/sn.png differ diff --git a/frontend/public/flags-normal/so.png b/frontend/public/flags-normal/so.png new file mode 100644 index 0000000000..93a7fdc9c6 Binary files /dev/null and b/frontend/public/flags-normal/so.png differ diff --git a/frontend/public/flags-normal/sr.png b/frontend/public/flags-normal/sr.png new file mode 100644 index 0000000000..47092d9e55 Binary files /dev/null and b/frontend/public/flags-normal/sr.png differ diff --git a/frontend/public/flags-normal/st.png b/frontend/public/flags-normal/st.png new file mode 100644 index 0000000000..85f7d3860d Binary files /dev/null and b/frontend/public/flags-normal/st.png differ diff --git a/frontend/public/flags-normal/sv.png b/frontend/public/flags-normal/sv.png new file mode 100644 index 0000000000..977957299f Binary files /dev/null and b/frontend/public/flags-normal/sv.png differ diff --git a/frontend/public/flags-normal/sy.png b/frontend/public/flags-normal/sy.png new file mode 100644 index 0000000000..a80b6b1117 Binary files /dev/null and b/frontend/public/flags-normal/sy.png differ diff --git a/frontend/public/flags-normal/sz.png b/frontend/public/flags-normal/sz.png new file mode 100644 index 0000000000..893376774e Binary files /dev/null and b/frontend/public/flags-normal/sz.png differ diff --git a/frontend/public/flags-normal/td.png b/frontend/public/flags-normal/td.png new file mode 100644 index 0000000000..41f123b5bf Binary files /dev/null and b/frontend/public/flags-normal/td.png differ diff --git a/frontend/public/flags-normal/tg.png b/frontend/public/flags-normal/tg.png new file mode 100644 index 0000000000..a4a1d9f9ae Binary files /dev/null and b/frontend/public/flags-normal/tg.png differ diff --git a/frontend/public/flags-normal/th.png b/frontend/public/flags-normal/th.png new file mode 100644 index 0000000000..f0f7207d8f Binary files /dev/null and b/frontend/public/flags-normal/th.png differ diff --git a/frontend/public/flags-normal/tj.png b/frontend/public/flags-normal/tj.png new file mode 100644 index 0000000000..682b5e0f04 Binary files /dev/null and b/frontend/public/flags-normal/tj.png differ diff --git a/frontend/public/flags-normal/tl.png b/frontend/public/flags-normal/tl.png new file mode 100644 index 0000000000..8a98e900ea Binary files /dev/null and b/frontend/public/flags-normal/tl.png differ diff --git a/frontend/public/flags-normal/tm.png b/frontend/public/flags-normal/tm.png new file mode 100644 index 0000000000..58567c8145 Binary files /dev/null and b/frontend/public/flags-normal/tm.png differ diff --git a/frontend/public/flags-normal/tn.png b/frontend/public/flags-normal/tn.png new file mode 100644 index 0000000000..db4951a6ae Binary files /dev/null and b/frontend/public/flags-normal/tn.png differ diff --git a/frontend/public/flags-normal/to.png b/frontend/public/flags-normal/to.png new file mode 100644 index 0000000000..95b78ce263 Binary files /dev/null and b/frontend/public/flags-normal/to.png differ diff --git a/frontend/public/flags-normal/tr.png b/frontend/public/flags-normal/tr.png new file mode 100644 index 0000000000..95d0c8710e Binary files /dev/null and b/frontend/public/flags-normal/tr.png differ diff --git a/frontend/public/flags-normal/tt.png b/frontend/public/flags-normal/tt.png new file mode 100644 index 0000000000..39a4af4229 Binary files /dev/null and b/frontend/public/flags-normal/tt.png differ diff --git a/frontend/public/flags-normal/tv.png b/frontend/public/flags-normal/tv.png new file mode 100644 index 0000000000..6bfe412e99 Binary files /dev/null and b/frontend/public/flags-normal/tv.png differ diff --git a/frontend/public/flags-normal/tw.png b/frontend/public/flags-normal/tw.png new file mode 100644 index 0000000000..80e07d8144 Binary files /dev/null and b/frontend/public/flags-normal/tw.png differ diff --git a/frontend/public/flags-normal/tz.png b/frontend/public/flags-normal/tz.png new file mode 100644 index 0000000000..446ecb4fa4 Binary files /dev/null and b/frontend/public/flags-normal/tz.png differ diff --git a/frontend/public/flags-normal/ua.png b/frontend/public/flags-normal/ua.png new file mode 100644 index 0000000000..0023479404 Binary files /dev/null and b/frontend/public/flags-normal/ua.png differ diff --git a/frontend/public/flags-normal/ug.png b/frontend/public/flags-normal/ug.png new file mode 100644 index 0000000000..cdcab6a1bd Binary files /dev/null and b/frontend/public/flags-normal/ug.png differ diff --git a/frontend/public/flags-normal/us.png b/frontend/public/flags-normal/us.png new file mode 100644 index 0000000000..5b96ff2407 Binary files /dev/null and b/frontend/public/flags-normal/us.png differ diff --git a/frontend/public/flags-normal/uy.png b/frontend/public/flags-normal/uy.png new file mode 100644 index 0000000000..219ef44a12 Binary files /dev/null and b/frontend/public/flags-normal/uy.png differ diff --git a/frontend/public/flags-normal/uz.png b/frontend/public/flags-normal/uz.png new file mode 100644 index 0000000000..80e0a44670 Binary files /dev/null and b/frontend/public/flags-normal/uz.png differ diff --git a/frontend/public/flags-normal/va.png b/frontend/public/flags-normal/va.png new file mode 100644 index 0000000000..c94c81ddec Binary files /dev/null and b/frontend/public/flags-normal/va.png differ diff --git a/frontend/public/flags-normal/vc.png b/frontend/public/flags-normal/vc.png new file mode 100644 index 0000000000..77196ed9de Binary files /dev/null and b/frontend/public/flags-normal/vc.png differ diff --git a/frontend/public/flags-normal/ve.png b/frontend/public/flags-normal/ve.png new file mode 100644 index 0000000000..40ae68ebd8 Binary files /dev/null and b/frontend/public/flags-normal/ve.png differ diff --git a/frontend/public/flags-normal/vn.png b/frontend/public/flags-normal/vn.png new file mode 100644 index 0000000000..d683852312 Binary files /dev/null and b/frontend/public/flags-normal/vn.png differ diff --git a/frontend/public/flags-normal/vu.png b/frontend/public/flags-normal/vu.png new file mode 100644 index 0000000000..e1ad764e13 Binary files /dev/null and b/frontend/public/flags-normal/vu.png differ diff --git a/frontend/public/flags-normal/ws.png b/frontend/public/flags-normal/ws.png new file mode 100644 index 0000000000..71db01fa40 Binary files /dev/null and b/frontend/public/flags-normal/ws.png differ diff --git a/frontend/public/flags-normal/ye.png b/frontend/public/flags-normal/ye.png new file mode 100644 index 0000000000..3a2e0a2b0c Binary files /dev/null and b/frontend/public/flags-normal/ye.png differ diff --git a/frontend/public/flags-normal/za.png b/frontend/public/flags-normal/za.png new file mode 100644 index 0000000000..535fe71092 Binary files /dev/null and b/frontend/public/flags-normal/za.png differ diff --git a/frontend/public/flags-normal/zm.png b/frontend/public/flags-normal/zm.png new file mode 100644 index 0000000000..7b0246a85f Binary files /dev/null and b/frontend/public/flags-normal/zm.png differ diff --git a/frontend/public/flags-normal/zw.png b/frontend/public/flags-normal/zw.png new file mode 100644 index 0000000000..abf138692f Binary files /dev/null and b/frontend/public/flags-normal/zw.png differ diff --git a/frontend/public/fr-FR.png b/frontend/public/fr-FR.png new file mode 100644 index 0000000000..fcfa7caf02 Binary files /dev/null and b/frontend/public/fr-FR.png differ diff --git a/frontend/public/jira.svg b/frontend/public/jira.svg new file mode 100644 index 0000000000..4ace5cc84a --- /dev/null +++ b/frontend/public/jira.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/logo-filled.png b/frontend/public/logo-filled.png new file mode 100644 index 0000000000..ceef07e1d5 Binary files /dev/null and b/frontend/public/logo-filled.png differ diff --git a/frontend/public/logo-inverted.png b/frontend/public/logo-inverted.png new file mode 100644 index 0000000000..8f45ebbf26 Binary files /dev/null and b/frontend/public/logo-inverted.png differ diff --git a/frontend/public/logo.png b/frontend/public/logo.png new file mode 100644 index 0000000000..27ff43307c Binary files /dev/null and b/frontend/public/logo.png differ diff --git a/frontend/public/logo_old.png b/frontend/public/logo_old.png new file mode 100644 index 0000000000..97e9ef9e8c Binary files /dev/null and b/frontend/public/logo_old.png differ diff --git a/frontend/public/nb-NO.png b/frontend/public/nb-NO.png new file mode 100644 index 0000000000..bb2f806b51 Binary files /dev/null and b/frontend/public/nb-NO.png differ diff --git a/frontend/public/pt_BR.png b/frontend/public/pt_BR.png new file mode 100644 index 0000000000..43725657c0 Binary files /dev/null and b/frontend/public/pt_BR.png differ diff --git a/frontend/public/slack.svg b/frontend/public/slack.svg new file mode 100644 index 0000000000..69a4eb6a21 --- /dev/null +++ b/frontend/public/slack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/sv-SE.png b/frontend/public/sv-SE.png new file mode 100644 index 0000000000..59595199b3 Binary files /dev/null and b/frontend/public/sv-SE.png differ diff --git a/frontend/public/switches.svg b/frontend/public/switches.svg new file mode 100644 index 0000000000..aad9bd6f54 --- /dev/null +++ b/frontend/public/switches.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/public/teams.svg b/frontend/public/teams.svg new file mode 100644 index 0000000000..90b3444b60 --- /dev/null +++ b/frontend/public/teams.svg @@ -0,0 +1,59 @@ + + + + + + + + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/unknown-locale.png b/frontend/public/unknown-locale.png new file mode 100644 index 0000000000..00b618ad5a Binary files /dev/null and b/frontend/public/unknown-locale.png differ diff --git a/frontend/public/webhooks.svg b/frontend/public/webhooks.svg new file mode 100644 index 0000000000..ec5cddf369 --- /dev/null +++ b/frontend/public/webhooks.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/scripts/generate-openapi.sh b/frontend/scripts/generate-openapi.sh new file mode 100755 index 0000000000..19d68b94d8 --- /dev/null +++ b/frontend/scripts/generate-openapi.sh @@ -0,0 +1,24 @@ +#!/bin/sh + +# Generate OpenAPI bindings for the Unleash API. +# https://openapi-generator.tech/docs/generators/typescript-fetch + +set -feux +cd "$(dirname "$0")" + +# URL to the generated open API spec. +# Set the UNLEASH_OPENAPI_URL environment variable to override. +UNLEASH_OPENAPI_URL="${UNLEASH_OPENAPI_URL:-http://localhost:4242/docs/openapi.json}" + +rm -rf "../src/openapi" +mkdir "../src/openapi" + +npx @openapitools/openapi-generator-cli generate \ + -g "typescript-fetch" \ + -i "$UNLEASH_OPENAPI_URL" \ + -o "../src/openapi" + +# Remove unused files. +rm "openapitools.json" +rm "../src/openapi/.openapi-generator-ignore" +rm -r "../src/openapi/.openapi-generator" diff --git a/frontend/src/__mocks__/fileMock.js b/frontend/src/__mocks__/fileMock.js new file mode 100644 index 0000000000..86059f3629 --- /dev/null +++ b/frontend/src/__mocks__/fileMock.js @@ -0,0 +1 @@ +module.exports = 'test-file-stub'; diff --git a/frontend/src/__mocks__/svgMock.js b/frontend/src/__mocks__/svgMock.js new file mode 100644 index 0000000000..ffe2050a02 --- /dev/null +++ b/frontend/src/__mocks__/svgMock.js @@ -0,0 +1,2 @@ +export default 'SvgrURL'; +export const ReactComponent = 'div'; diff --git a/frontend/src/assets/fonts/roboto300.ttf b/frontend/src/assets/fonts/roboto300.ttf new file mode 100644 index 0000000000..0e977514ff Binary files /dev/null and b/frontend/src/assets/fonts/roboto300.ttf differ diff --git a/frontend/src/assets/fonts/roboto400.ttf b/frontend/src/assets/fonts/roboto400.ttf new file mode 100644 index 0000000000..3d6861b423 Binary files /dev/null and b/frontend/src/assets/fonts/roboto400.ttf differ diff --git a/frontend/src/assets/fonts/roboto500.ttf b/frontend/src/assets/fonts/roboto500.ttf new file mode 100644 index 0000000000..e89b0b79a2 Binary files /dev/null and b/frontend/src/assets/fonts/roboto500.ttf differ diff --git a/frontend/src/assets/fonts/roboto700.ttf b/frontend/src/assets/fonts/roboto700.ttf new file mode 100644 index 0000000000..3742457900 Binary files /dev/null and b/frontend/src/assets/fonts/roboto700.ttf differ diff --git a/frontend/src/assets/fonts/senBold.ttf b/frontend/src/assets/fonts/senBold.ttf new file mode 100644 index 0000000000..bd184b83cb Binary files /dev/null and b/frontend/src/assets/fonts/senBold.ttf differ diff --git a/frontend/src/assets/fonts/senExtraBold.ttf b/frontend/src/assets/fonts/senExtraBold.ttf new file mode 100644 index 0000000000..13b3be3618 Binary files /dev/null and b/frontend/src/assets/fonts/senExtraBold.ttf differ diff --git a/frontend/src/assets/fonts/senRegular.ttf b/frontend/src/assets/fonts/senRegular.ttf new file mode 100644 index 0000000000..8b08770a02 Binary files /dev/null and b/frontend/src/assets/fonts/senRegular.ttf differ diff --git a/frontend/src/assets/icons/24_Negator off.svg b/frontend/src/assets/icons/24_Negator off.svg new file mode 100644 index 0000000000..a3dababecf --- /dev/null +++ b/frontend/src/assets/icons/24_Negator off.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/icons/24_Negator.svg b/frontend/src/assets/icons/24_Negator.svg new file mode 100644 index 0000000000..84e1638591 --- /dev/null +++ b/frontend/src/assets/icons/24_Negator.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/icons/24_Text format off.svg b/frontend/src/assets/icons/24_Text format off.svg new file mode 100644 index 0000000000..c4e0102f8a --- /dev/null +++ b/frontend/src/assets/icons/24_Text format off.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/icons/24_Text format.svg b/frontend/src/assets/icons/24_Text format.svg new file mode 100644 index 0000000000..ea8ae72501 --- /dev/null +++ b/frontend/src/assets/icons/24_Text format.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/icons/Icecream.svg b/frontend/src/assets/icons/Icecream.svg new file mode 100644 index 0000000000..016aa0fd6c --- /dev/null +++ b/frontend/src/assets/icons/Icecream.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/src/assets/icons/addfiles.svg b/frontend/src/assets/icons/addfiles.svg new file mode 100644 index 0000000000..b8952ba5af --- /dev/null +++ b/frontend/src/assets/icons/addfiles.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/datadog.svg b/frontend/src/assets/icons/datadog.svg new file mode 100644 index 0000000000..49e6f8473f --- /dev/null +++ b/frontend/src/assets/icons/datadog.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/dots.svg b/frontend/src/assets/icons/dots.svg new file mode 100644 index 0000000000..1bec57b922 --- /dev/null +++ b/frontend/src/assets/icons/dots.svg @@ -0,0 +1,16 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/email.svg b/frontend/src/assets/icons/email.svg new file mode 100644 index 0000000000..ec1e08b84d --- /dev/null +++ b/frontend/src/assets/icons/email.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/icons/google.svg b/frontend/src/assets/icons/google.svg new file mode 100644 index 0000000000..f9c6f88d2b --- /dev/null +++ b/frontend/src/assets/icons/google.svg @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/gradual.svg b/frontend/src/assets/icons/gradual.svg new file mode 100644 index 0000000000..78f94fc766 --- /dev/null +++ b/frontend/src/assets/icons/gradual.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/icons/isenabled-false.svg b/frontend/src/assets/icons/isenabled-false.svg new file mode 100644 index 0000000000..51edee56cc --- /dev/null +++ b/frontend/src/assets/icons/isenabled-false.svg @@ -0,0 +1,5 @@ + + + diff --git a/frontend/src/assets/icons/isenabled-true.svg b/frontend/src/assets/icons/isenabled-true.svg new file mode 100644 index 0000000000..52e560d6d7 --- /dev/null +++ b/frontend/src/assets/icons/isenabled-true.svg @@ -0,0 +1,5 @@ + + + diff --git a/frontend/src/assets/icons/jira.svg b/frontend/src/assets/icons/jira.svg new file mode 100644 index 0000000000..4ace5cc84a --- /dev/null +++ b/frontend/src/assets/icons/jira.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/logoBg.svg b/frontend/src/assets/icons/logoBg.svg new file mode 100644 index 0000000000..b79f917031 --- /dev/null +++ b/frontend/src/assets/icons/logoBg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/logoInverted.svg b/frontend/src/assets/icons/logoInverted.svg new file mode 100644 index 0000000000..32da7185f1 --- /dev/null +++ b/frontend/src/assets/icons/logoInverted.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/icons/logoPlain.svg b/frontend/src/assets/icons/logoPlain.svg new file mode 100644 index 0000000000..8cf6ae5438 --- /dev/null +++ b/frontend/src/assets/icons/logoPlain.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/assets/icons/logoWhiteBg.svg b/frontend/src/assets/icons/logoWhiteBg.svg new file mode 100644 index 0000000000..f888d4a288 --- /dev/null +++ b/frontend/src/assets/icons/logoWhiteBg.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/src/assets/icons/projectIcon.svg b/frontend/src/assets/icons/projectIcon.svg new file mode 100644 index 0000000000..d4604c184d --- /dev/null +++ b/frontend/src/assets/icons/projectIcon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/assets/icons/reorder.svg b/frontend/src/assets/icons/reorder.svg new file mode 100644 index 0000000000..99f4cb517f --- /dev/null +++ b/frontend/src/assets/icons/reorder.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/assets/icons/rollout.svg b/frontend/src/assets/icons/rollout.svg new file mode 100644 index 0000000000..0626d7b34b --- /dev/null +++ b/frontend/src/assets/icons/rollout.svg @@ -0,0 +1,4 @@ + + + diff --git a/frontend/src/assets/icons/slack.svg b/frontend/src/assets/icons/slack.svg new file mode 100644 index 0000000000..69a4eb6a21 --- /dev/null +++ b/frontend/src/assets/icons/slack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/star.svg b/frontend/src/assets/icons/star.svg new file mode 100644 index 0000000000..ff00f4f52d --- /dev/null +++ b/frontend/src/assets/icons/star.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/assets/icons/switches.svg b/frontend/src/assets/icons/switches.svg new file mode 100644 index 0000000000..aad9bd6f54 --- /dev/null +++ b/frontend/src/assets/icons/switches.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/assets/icons/teams.svg b/frontend/src/assets/icons/teams.svg new file mode 100644 index 0000000000..d005badd9f --- /dev/null +++ b/frontend/src/assets/icons/teams.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/icons/toggleLeft.svg b/frontend/src/assets/icons/toggleLeft.svg new file mode 100644 index 0000000000..d4e9d2ae49 --- /dev/null +++ b/frontend/src/assets/icons/toggleLeft.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/icons/toggleRight.svg b/frontend/src/assets/icons/toggleRight.svg new file mode 100644 index 0000000000..f6da7f12e6 --- /dev/null +++ b/frontend/src/assets/icons/toggleRight.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/icons/unknownUser.png b/frontend/src/assets/icons/unknownUser.png new file mode 100644 index 0000000000..47214107be Binary files /dev/null and b/frontend/src/assets/icons/unknownUser.png differ diff --git a/frontend/src/assets/icons/webhooks.svg b/frontend/src/assets/icons/webhooks.svg new file mode 100644 index 0000000000..ec5cddf369 --- /dev/null +++ b/frontend/src/assets/icons/webhooks.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/img/logo.png b/frontend/src/assets/img/logo.png new file mode 100644 index 0000000000..27ff43307c Binary files /dev/null and b/frontend/src/assets/img/logo.png differ diff --git a/frontend/src/assets/img/logo.svg b/frontend/src/assets/img/logo.svg new file mode 100644 index 0000000000..843669fff4 --- /dev/null +++ b/frontend/src/assets/img/logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/src/assets/img/logoDark.svg b/frontend/src/assets/img/logoDark.svg new file mode 100644 index 0000000000..b79f917031 --- /dev/null +++ b/frontend/src/assets/img/logoDark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/img/logoDarkWithText.svg b/frontend/src/assets/img/logoDarkWithText.svg new file mode 100644 index 0000000000..51862398e8 --- /dev/null +++ b/frontend/src/assets/img/logoDarkWithText.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/img/logoWhiteTransparentHorizontal.svg b/frontend/src/assets/img/logoWhiteTransparentHorizontal.svg new file mode 100644 index 0000000000..8f78bf75a2 --- /dev/null +++ b/frontend/src/assets/img/logoWhiteTransparentHorizontal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/img/logoWithName.svg b/frontend/src/assets/img/logoWithName.svg new file mode 100644 index 0000000000..ed650f953d --- /dev/null +++ b/frontend/src/assets/img/logoWithName.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/assets/img/logoWithWhiteText.svg b/frontend/src/assets/img/logoWithWhiteText.svg new file mode 100644 index 0000000000..c966754f9e --- /dev/null +++ b/frontend/src/assets/img/logoWithWhiteText.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/img/mobileGuidanceBg.svg b/frontend/src/assets/img/mobileGuidanceBg.svg new file mode 100644 index 0000000000..20093fdcf3 --- /dev/null +++ b/frontend/src/assets/img/mobileGuidanceBg.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/img/texture.png b/frontend/src/assets/img/texture.png new file mode 100644 index 0000000000..4769ce29a2 Binary files /dev/null and b/frontend/src/assets/img/texture.png differ diff --git a/frontend/src/assets/img/unleashLogoIconDarkAlpha.gif b/frontend/src/assets/img/unleashLogoIconDarkAlpha.gif new file mode 100644 index 0000000000..077b62d726 Binary files /dev/null and b/frontend/src/assets/img/unleashLogoIconDarkAlpha.gif differ diff --git a/frontend/src/component/App.styles.ts b/frontend/src/component/App.styles.ts new file mode 100644 index 0000000000..c6b04a0447 --- /dev/null +++ b/frontend/src/component/App.styles.ts @@ -0,0 +1,80 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + '& ul': { + margin: 0, + }, + }, + primaryBreadcrumb: { + color: 'white', + }, + headerTitleLink: { + color: 'white', + textDecoration: 'none', + }, + contentWrapper: { + margin: '0 auto', + flex: 1, + width: '100%', + backgroundColor: theme.palette.contentWrapper, + }, + content: { + width: '1250px', + margin: '16px auto', + [theme.breakpoints.down('lg')]: { + width: '1024px', + }, + [theme.breakpoints.down(1024)]: { + width: '100%', + marginLeft: 0, + marginRight: 0, + }, + [theme.breakpoints.down('sm')]: { + minWidth: '100%', + }, + }, + contentContainer: { + padding: '2rem 0', + height: '100%', + }, + drawerTitle: { + lineHeight: '1 !important', + paddingTop: '16px', + paddingBottom: '16px', + paddingLeft: '24px !important', + [theme.breakpoints.down(1024)]: { + paddingTop: '12px', + paddingBottom: '12px', + paddingLeft: '16px !important', + }, + }, + drawerTitleLogo: { + paddingRight: '16px', + }, + drawerTitleText: { + display: 'inline-block', + verticalAlign: 'middle', + fontSize: theme.fontSizes.smallerBody, + }, + navigation: { + padding: '8px 5px 8px 0 !important', + }, + navigationLink: { + padding: '12px 20px !important', + borderRadius: '0 50px 50px 0', + textDecoration: 'none', + [theme.breakpoints.down(1024)]: { + padding: '12px 16px !important', + }, + }, + navigationIcon: { + marginRight: '16px', + }, + iconGitHub: { + width: '24px', + height: '24px', + background: + 'url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHN0eWxlPSJ3aWR0aDoyNHB4O2hlaWdodDoyNHB4IiB2aWV3Qm94PSIwIDAgMjQgMjQiPgogICAgPHBhdGggZmlsbD0iIzc1NzU3NSIgZD0iTTEyLDJBMTAsMTAgMCAwLDAgMiwxMkMyLDE2LjQyIDQuODcsMjAuMTcgOC44NCwyMS41QzkuMzQsMjEuNTggOS41LDIxLjI3IDkuNSwyMUM5LjUsMjAuNzcgOS41LDIwLjE0IDkuNSwxOS4zMUM2LjczLDE5LjkxIDYuMTQsMTcuOTcgNi4xNCwxNy45N0M1LjY4LDE2LjgxIDUuMDMsMTYuNSA1LjAzLDE2LjVDNC4xMiwxNS44OCA1LjEsMTUuOSA1LjEsMTUuOUM2LjEsMTUuOTcgNi42MywxNi45MyA2LjYzLDE2LjkzQzcuNSwxOC40NSA4Ljk3LDE4IDkuNTQsMTcuNzZDOS42MywxNy4xMSA5Ljg5LDE2LjY3IDEwLjE3LDE2LjQyQzcuOTUsMTYuMTcgNS42MiwxNS4zMSA1LjYyLDExLjVDNS42MiwxMC4zOSA2LDkuNSA2LjY1LDguNzlDNi41NSw4LjU0IDYuMiw3LjUgNi43NSw2LjE1QzYuNzUsNi4xNSA3LjU5LDUuODggOS41LDcuMTdDMTAuMjksNi45NSAxMS4xNSw2Ljg0IDEyLDYuODRDMTIuODUsNi44NCAxMy43MSw2Ljk1IDE0LjUsNy4xN0MxNi40MSw1Ljg4IDE3LjI1LDYuMTUgMTcuMjUsNi4xNUMxNy44LDcuNSAxNy40NSw4LjU0IDE3LjM1LDguNzlDMTgsOS41IDE4LjM4LDEwLjM5IDE4LjM4LDExLjVDMTguMzgsMTUuMzIgMTYuMDQsMTYuMTYgMTMuODEsMTYuNDFDMTQuMTcsMTYuNzIgMTQuNSwxNy4zMyAxNC41LDE4LjI2QzE0LjUsMTkuNiAxNC41LDIwLjY4IDE0LjUsMjFDMTQuNSwyMS4yNyAxNC42NiwyMS41OSAxNS4xNywyMS41QzE5LjE0LDIwLjE2IDIyLDE2LjQyIDIyLDEyQTEwLDEwIDAgMCwwIDEyLDJaIiAvPgo8L3N2Zz4=)', + }, +})); diff --git a/frontend/src/component/App.tsx b/frontend/src/component/App.tsx new file mode 100644 index 0000000000..62c4d55233 --- /dev/null +++ b/frontend/src/component/App.tsx @@ -0,0 +1,69 @@ +import { Navigate, Route, Routes } from 'react-router-dom'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { FeedbackNPS } from 'component/feedback/FeedbackNPS/FeedbackNPS'; +import { LayoutPicker } from 'component/layout/LayoutPicker/LayoutPicker'; +import Loader from 'component/common/Loader/Loader'; +import NotFound from 'component/common/NotFound/NotFound'; +import { ProtectedRoute } from 'component/common/ProtectedRoute/ProtectedRoute'; +import SWRProvider from 'component/providers/SWRProvider/SWRProvider'; +import ToastRenderer from 'component/common/ToastRenderer/ToastRenderer'; +import { routes } from 'component/menu/routes'; +import { useAuthDetails } from 'hooks/api/getters/useAuth/useAuthDetails'; +import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser'; +import { SplashPageRedirect } from 'component/splash/SplashPageRedirect/SplashPageRedirect'; +import { useStyles } from './App.styles'; +import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { Suspense } from 'react'; + +export const App = () => { + const { classes: styles } = useStyles(); + const { authDetails } = useAuthDetails(); + const { user } = useAuthUser(); + const { isOss } = useUiConfig(); + const isLoggedIn = Boolean(user?.id); + const hasFetchedAuth = Boolean(authDetails || user); + usePlausibleTracker(); + + const availableRoutes = isOss() + ? routes.filter(route => !route.enterprise) + : routes; + + return ( + + }> + } + elseShow={ +
+ + + + {availableRoutes.map(route => ( + + } + /> + ))} + + } + /> + } /> + + + + +
+ } + /> +
+
+ ); +}; diff --git a/frontend/src/component/addons/AddonForm/AddonForm.styles.tsx b/frontend/src/component/addons/AddonForm/AddonForm.styles.tsx new file mode 100644 index 0000000000..4ae328f0bc --- /dev/null +++ b/frontend/src/component/addons/AddonForm/AddonForm.styles.tsx @@ -0,0 +1,52 @@ +import { styled } from '@mui/system'; +import { FormControlLabel, TextField } from '@mui/material'; + +export const StyledForm = styled('form')({ + display: 'flex', + flexDirection: 'column', + height: '100%', + gap: '1rem', +}); + +export const StyledFormSection = styled('section')({ + marginBottom: '36px', +}); + +export const StyledHelpText = styled('p')({ + marginBottom: '0.5rem', +}); + +export const StyledContainer = styled('div')({ + maxWidth: '600px', +}); + +export const StyledButtonContainer = styled('div')({ + marginTop: 'auto', + display: 'flex', + justifyContent: 'flex-end', +}); + +export const StyledButtonSection = styled('section')(({ theme }) => ({ + 'padding-top': '16px', + '& > *': { + marginRight: theme.spacing(1), + }, +})); + +export const StyledTextField = styled(TextField)({ + width: '100%', + marginBottom: '1rem', + marginTop: '0px', +}); + +export const StyledSelectAllFormControlLabel = styled(FormControlLabel)({ + paddingBottom: '16px', +}); + +export const StyledTitle = styled('h4')({ + marginBottom: '8px', +}); + +export const StyledAddonParameterContainer = styled('div')({ + marginTop: '25px', +}); diff --git a/frontend/src/component/addons/AddonForm/AddonForm.tsx b/frontend/src/component/addons/AddonForm/AddonForm.tsx new file mode 100644 index 0000000000..3a19477149 --- /dev/null +++ b/frontend/src/component/addons/AddonForm/AddonForm.tsx @@ -0,0 +1,348 @@ +import React, { + ChangeEventHandler, + FormEventHandler, + MouseEventHandler, + useEffect, + useState, + VFC, +} from 'react'; +import { Button, Divider, FormControlLabel, Switch } from '@mui/material'; +import produce from 'immer'; +import { trim } from 'component/common/util'; +import { IAddon, IAddonProvider } from 'interfaces/addons'; +import { AddonParameters } from './AddonParameters/AddonParameters'; +import cloneDeep from 'lodash.clonedeep'; +import { useNavigate } from 'react-router-dom'; +import useAddonsApi from 'hooks/api/actions/useAddonsApi/useAddonsApi'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import useProjects from '../../../hooks/api/getters/useProjects/useProjects'; +import { useEnvironments } from '../../../hooks/api/getters/useEnvironments/useEnvironments'; +import { AddonMultiSelector } from './AddonMultiSelector/AddonMultiSelector'; +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig'; +import PermissionButton from '../../common/PermissionButton/PermissionButton'; +import { ADMIN } from '../../providers/AccessProvider/permissions'; +import { + StyledForm, + StyledFormSection, + StyledHelpText, + StyledTextField, + StyledContainer, + StyledButtonContainer, + StyledButtonSection, +} from './AddonForm.styles'; +import { useTheme } from '@mui/system'; +import { GO_BACK } from 'constants/navigate'; + +interface IAddonFormProps { + provider?: IAddonProvider; + addon: IAddon; + fetch: () => void; + editMode: boolean; +} + +export const AddonForm: VFC = ({ + editMode, + provider, + addon: initialValues, + fetch, +}) => { + const { createAddon, updateAddon } = useAddonsApi(); + const { setToastData, setToastApiError } = useToast(); + const navigate = useNavigate(); + const theme = useTheme(); + const { projects: availableProjects } = useProjects(); + const selectableProjects = availableProjects.map(project => ({ + value: project.id, + label: project.name, + })); + const { environments: availableEnvironments } = useEnvironments(); + const selectableEnvironments = availableEnvironments.map(environment => ({ + value: environment.name, + label: environment.name, + })); + const selectableEvents = provider?.events.map(event => ({ + value: event, + label: event, + })); + const { uiConfig } = useUiConfig(); + const [formValues, setFormValues] = useState(initialValues); + const [errors, setErrors] = useState<{ + containsErrors: boolean; + parameters: Record; + events?: string; + projects?: string; + environments?: string; + general?: string; + description?: string; + }>({ + containsErrors: false, + parameters: {}, + }); + const submitText = editMode ? 'Update' : 'Create'; + let url = `${uiConfig.unleashUrl}/api/admin/addons${ + editMode ? `/${formValues.id}` : `` + }`; + + const formatApiCode = () => { + return `curl --location --request ${ + editMode ? 'PUT' : 'POST' + } '${url}' \\ + --header 'Authorization: INSERT_API_KEY' \\ + --header 'Content-Type: application/json' \\ + --data-raw '${JSON.stringify(formValues, undefined, 2)}'`; + }; + + useEffect(() => { + if (!provider) { + fetch(); + } + }, [fetch, provider]); // empty array => fetch only first time + + useEffect(() => { + setFormValues({ ...initialValues }); + /* eslint-disable-next-line */ + }, [initialValues.description, initialValues.provider]); + + useEffect(() => { + if (provider && !formValues.provider) { + setFormValues({ ...initialValues, provider: provider.name }); + } + }, [provider, initialValues, formValues.provider]); + + const setFieldValue = + (field: string): ChangeEventHandler => + event => { + event.preventDefault(); + setFormValues({ ...formValues, [field]: event.target.value }); + }; + + const onEnabled: MouseEventHandler = event => { + event.preventDefault(); + setFormValues(({ enabled }) => ({ ...formValues, enabled: !enabled })); + }; + + const setParameterValue = + (param: string): ChangeEventHandler => + event => { + event.preventDefault(); + setFormValues( + produce(draft => { + draft.parameters[param] = event.target.value; + }) + ); + }; + + const setEventValues = (events: string[]) => { + setFormValues( + produce(draft => { + draft.events = events; + }) + ); + setErrors(prev => ({ + ...prev, + events: undefined, + })); + }; + const setProjects = (projects: string[]) => { + setFormValues( + produce(draft => { + draft.projects = projects; + }) + ); + setErrors(prev => ({ + ...prev, + projects: undefined, + })); + }; + const setEnvironments = (environments: string[]) => { + setFormValues( + produce(draft => { + draft.environments = environments; + }) + ); + setErrors(prev => ({ + ...prev, + environments: undefined, + })); + }; + + const onCancel = () => { + navigate(GO_BACK); + }; + + const onSubmit: FormEventHandler = async event => { + event.preventDefault(); + if (!provider) { + return; + } + + const updatedErrors = cloneDeep(errors); + updatedErrors.containsErrors = false; + + // Validations + if (formValues.events.length === 0) { + updatedErrors.events = 'You must listen to at least one event'; + updatedErrors.containsErrors = true; + } + + provider.parameters.forEach(parameterConfig => { + const value = trim(formValues.parameters[parameterConfig.name]); + if (parameterConfig.required && !value) { + updatedErrors.parameters[parameterConfig.name] = + 'This field is required'; + updatedErrors.containsErrors = true; + } + }); + + if (updatedErrors.containsErrors) { + setErrors(updatedErrors); + return; + } + + try { + if (editMode) { + await updateAddon(formValues); + navigate('/addons'); + setToastData({ + type: 'success', + title: 'Addon updated successfully', + }); + } else { + await createAddon(formValues); + navigate('/addons'); + setToastData({ + type: 'success', + confetti: true, + title: 'Addon created successfully', + }); + } + } catch (error) { + const message = formatUnknownError(error); + setToastApiError(message); + setErrors({ + parameters: {}, + general: message, + containsErrors: true, + }); + } + }; + + const { + name, + description, + documentationUrl = 'https://unleash.github.io/docs/addons', + } = provider ? provider : ({} as Partial); + + return ( + + + + + + + + What is your addon description? + + + + + + + + + + + + + + + + + + + + + + + {submitText} + + + + + + + ); +}; diff --git a/frontend/src/component/addons/AddonForm/AddonMultiSelector/AddonMultiSelector.test.tsx b/frontend/src/component/addons/AddonForm/AddonMultiSelector/AddonMultiSelector.test.tsx new file mode 100644 index 0000000000..186520ea37 --- /dev/null +++ b/frontend/src/component/addons/AddonForm/AddonMultiSelector/AddonMultiSelector.test.tsx @@ -0,0 +1,143 @@ +import { vi } from 'vitest'; +import React from 'react'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from 'utils/testRenderer'; +import { + IAddonMultiSelectorProps, + AddonMultiSelector, +} from './AddonMultiSelector'; + +const onChange = vi.fn(); +const onFocus = vi.fn(); + +const mockProps: IAddonMultiSelectorProps = { + options: [ + { label: 'Project1', value: 'project1' }, + { label: 'Project2', value: 'project2' }, + { label: 'Project3', value: 'project3' }, + ], + selectedItems: [], + onChange, + onFocus, + selectAllEnabled: true, + entityName: 'project', +}; + +describe('AddonMultiSelector', () => { + beforeEach(() => { + onChange.mockClear(); + onFocus.mockClear(); + }); + + it('renders with default state', () => { + render(); + + const checkbox = screen.getByLabelText( + /all current and future projects/i + ); + expect(checkbox).toBeChecked(); + + const selectInputContainer = screen.getByTestId('select-project-input'); + const input = within(selectInputContainer).getByRole('combobox'); + expect(input).toBeDisabled(); + }); + + it('can toggle "ALL" checkbox', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('select-all-projects')); + + expect( + screen.getByLabelText(/all current and future projects/i) + ).not.toBeChecked(); + + expect(screen.getByLabelText('Projects')).toBeEnabled(); + + await user.click(screen.getByTestId('select-all-projects')); + + expect( + screen.getByLabelText(/all current and future projects/i) + ).toBeChecked(); + + expect(screen.getByLabelText('Projects')).toBeDisabled(); + }); + + it('renders with autocomplete enabled if default value is not a wildcard', () => { + render( + + ); + + const checkbox = screen.getByLabelText( + /all current and future projects/i + ); + expect(checkbox).not.toBeChecked(); + + const selectInputContainer = screen.getByTestId('select-project-input'); + const input = within(selectInputContainer).getByRole('combobox'); + expect(input).toBeEnabled(); + }); + + describe('Select/Deselect projects in dropdown', () => { + it("doesn't show up for less than 3 options", async () => { + const user = userEvent.setup(); + render( + + ); + await user.click(screen.getByLabelText('Projects')); + + const button = screen.queryByRole('button', { + name: /select all/i, + }); + expect(button).not.toBeInTheDocument(); + }); + }); + + it('can filter options', async () => { + const user = userEvent.setup(); + render( + + ); + const input = await screen.findByLabelText('Projects'); + await user.type(input, 'alp'); + + await waitFor(() => { + expect(screen.getByText('Alpha')).toBeVisible(); + }); + await waitFor(() => { + expect(screen.queryByText('Bravo')).not.toBeInTheDocument(); + }); + await waitFor(() => { + expect(screen.queryByText('Charlie')).not.toBeInTheDocument(); + }); + await waitFor(() => { + expect(screen.getByText('Alpaca')).toBeVisible(); + }); + + await user.clear(input); + await user.type(input, 'bravo'); + await waitFor(() => { + expect(screen.getByText('Bravo')).toBeVisible(); + }); + await waitFor(() => { + expect(screen.queryByText('Alpha')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/component/addons/AddonForm/AddonMultiSelector/AddonMultiSelector.tsx b/frontend/src/component/addons/AddonForm/AddonMultiSelector/AddonMultiSelector.tsx new file mode 100644 index 0000000000..e7c29889d5 --- /dev/null +++ b/frontend/src/component/addons/AddonForm/AddonMultiSelector/AddonMultiSelector.tsx @@ -0,0 +1,189 @@ +import React, { ChangeEvent, Fragment, useState, VFC } from 'react'; +import { IAutocompleteBoxOption } from '../../../common/AutocompleteBox/AutocompleteBox'; +import { styles as themeStyles } from 'component/common'; +import { + AutocompleteRenderGroupParams, + AutocompleteRenderInputParams, + AutocompleteRenderOptionState, +} from '@mui/material/Autocomplete'; +import { styled } from '@mui/system'; +import { + capitalize, + Checkbox, + Paper, + TextField, + Autocomplete, +} from '@mui/material'; +import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'; +import CheckBoxIcon from '@mui/icons-material/CheckBox'; +import { ConditionallyRender } from '../../../common/ConditionallyRender/ConditionallyRender'; +import { SelectAllButton } from '../../../admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton'; +import { + StyledHelpText, + StyledSelectAllFormControlLabel, + StyledTitle, +} from '../AddonForm.styles'; + +export interface IAddonMultiSelectorProps { + options: IAutocompleteBoxOption[]; + selectedItems: string[]; + onChange: (value: string[]) => void; + error?: string; + onFocus?: () => void; + entityName: string; + selectAllEnabled: boolean; + description?: string; +} + +const ALL_OPTIONS = '*'; + +const StyledCheckbox = styled(Checkbox)(() => ({ + marginRight: '0.2em', +})); + +const CustomPaper = ({ ...props }) => ; + +export const AddonMultiSelector: VFC = ({ + options, + selectedItems, + onChange, + error, + onFocus, + entityName, + selectAllEnabled = true, + description, +}) => { + const [isWildcardSelected, selectWildcard] = useState( + selectedItems.includes(ALL_OPTIONS) + ); + const renderInput = (params: AutocompleteRenderInputParams) => ( + + ); + + const isAllSelected = + selectedItems.length > 0 && + selectedItems.length === options.length && + selectedItems[0] !== ALL_OPTIONS; + + const onAllItemsChange = ( + e: ChangeEvent, + checked: boolean + ) => { + if (checked) { + selectWildcard(true); + onChange([ALL_OPTIONS]); + } else { + selectWildcard(false); + onChange(selectedItems.includes(ALL_OPTIONS) ? [] : selectedItems); + } + }; + + const onSelectAllClick = () => { + const newItems = isAllSelected ? [] : options.map(({ value }) => value); + onChange(newItems); + }; + const renderOption = ( + props: object, + option: IAutocompleteBoxOption, + { selected }: AutocompleteRenderOptionState + ) => { + return ( +
  • + } + checkedIcon={} + checked={selected} + /> + {option.label} +
  • + ); + }; + const renderGroup = ({ key, children }: AutocompleteRenderGroupParams) => ( + + 2 && selectAllEnabled} + show={ + + } + /> + {children} + + ); + const SelectAllFormControl = () => ( + + } + label={`ALL current and future ${entityName}s`} + /> + ); + + const DefaultHelpText = () => ( + + Selecting {entityName}(s) here will filter events so that your addon + will only receive events that are tagged with one of your{' '} + {entityName}s. + + ); + + return ( + + {capitalize(entityName)}s + {description}} + /> + } + /> + {error} + } + /> + label} + fullWidth + groupBy={() => 'Select/Deselect all'} + renderGroup={renderGroup} + PaperComponent={CustomPaper} + renderOption={renderOption} + renderInput={renderInput} + value={ + isWildcardSelected + ? options + : options.filter(option => + selectedItems.includes(option.value) + ) + } + onChange={(_, input) => { + const state = input.map(({ value }) => value); + onChange(state); + }} + /> + + ); +}; diff --git a/frontend/src/component/addons/AddonForm/AddonParameters/AddonParameter/AddonParameter.tsx b/frontend/src/component/addons/AddonForm/AddonParameters/AddonParameter/AddonParameter.tsx new file mode 100644 index 0000000000..9a1f3b20f8 --- /dev/null +++ b/frontend/src/component/addons/AddonForm/AddonParameters/AddonParameter/AddonParameter.tsx @@ -0,0 +1,57 @@ +import { TextField } from '@mui/material'; +import { IAddonConfig, IAddonProviderParams } from 'interfaces/addons'; +import { ChangeEventHandler } from 'react'; +import { StyledAddonParameterContainer } from '../../AddonForm.styles'; + +const resolveType = ({ type = 'text', sensitive = false }, value: string) => { + if (sensitive && value === MASKED_VALUE) { + return 'text'; + } + if (type === 'textfield') { + return 'text'; + } + return type; +}; + +const MASKED_VALUE = '*****'; + +export interface IAddonParameterProps { + parametersErrors: Record; + definition: IAddonProviderParams; + setParameterValue: (param: string) => ChangeEventHandler; + config: IAddonConfig; +} + +export const AddonParameter = ({ + definition, + config, + parametersErrors, + setParameterValue, +}: IAddonParameterProps) => { + const value = config.parameters[definition.name] || ''; + const type = resolveType(definition, value); + const error = parametersErrors[definition.name]; + + return ( + + + + ); +}; diff --git a/frontend/src/component/addons/AddonForm/AddonParameters/AddonParameters.tsx b/frontend/src/component/addons/AddonForm/AddonParameters/AddonParameters.tsx new file mode 100644 index 0000000000..fb62c6894f --- /dev/null +++ b/frontend/src/component/addons/AddonForm/AddonParameters/AddonParameters.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { IAddonProvider } from 'interfaces/addons'; +import { + AddonParameter, + IAddonParameterProps, +} from './AddonParameter/AddonParameter'; +import { StyledTitle } from '../AddonForm.styles'; + +interface IAddonParametersProps { + provider?: IAddonProvider; + parametersErrors: IAddonParameterProps['parametersErrors']; + editMode: boolean; + setParameterValue: IAddonParameterProps['setParameterValue']; + config: IAddonParameterProps['config']; +} + +export const AddonParameters = ({ + provider, + config, + parametersErrors, + setParameterValue, + editMode, +}: IAddonParametersProps) => { + if (!provider) return null; + return ( + + Parameters + {editMode ? ( +

    + Sensitive parameters will be masked with value "***** + ". If you don't change the value they will not be updated + when saving. +

    + ) : null} + {provider.parameters.map(parameter => ( + + ))} +
    + ); +}; diff --git a/frontend/src/component/addons/AddonList/AddonIcon/AddonIcon.tsx b/frontend/src/component/addons/AddonList/AddonIcon/AddonIcon.tsx new file mode 100644 index 0000000000..2fb1ccfd5e --- /dev/null +++ b/frontend/src/component/addons/AddonList/AddonIcon/AddonIcon.tsx @@ -0,0 +1,70 @@ +import { Avatar } from '@mui/material'; +import { DeviceHub } from '@mui/icons-material'; +import { formatAssetPath } from 'utils/formatPath'; + +import slackIcon from 'assets/icons/slack.svg'; +import jiraIcon from 'assets/icons/jira.svg'; +import webhooksIcon from 'assets/icons/webhooks.svg'; +import teamsIcon from 'assets/icons/teams.svg'; +import dataDogIcon from 'assets/icons/datadog.svg'; + +const style: React.CSSProperties = { + width: '32.5px', + height: '32.5px', + marginRight: '16px', +}; + +interface IAddonIconProps { + name: string; +} + +export const AddonIcon = ({ name }: IAddonIconProps) => { + switch (name) { + case 'slack': + return ( + Slack logo + ); + case 'jira-comment': + return ( + JIRA logo + ); + case 'webhook': + return ( + Generic Webhook logo + ); + case 'teams': + return ( + Microsoft Teams logo + ); + case 'datadog': + return ( + Datadog logo + ); + default: + return ( + + + + ); + } +}; diff --git a/frontend/src/component/addons/AddonList/AddonList.tsx b/frontend/src/component/addons/AddonList/AddonList.tsx new file mode 100644 index 0000000000..5d4de0e8c8 --- /dev/null +++ b/frontend/src/component/addons/AddonList/AddonList.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { ConfiguredAddons } from './ConfiguredAddons/ConfiguredAddons'; +import { AvailableAddons } from './AvailableAddons/AvailableAddons'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import useAddons from 'hooks/api/getters/useAddons/useAddons'; + +export const AddonList = () => { + const { providers, addons, loading } = useAddons(); + + return ( + <> + 0} + show={} + /> + + + ); +}; diff --git a/frontend/src/component/addons/AddonList/AvailableAddons/AvailableAddons.tsx b/frontend/src/component/addons/AddonList/AvailableAddons/AvailableAddons.tsx new file mode 100644 index 0000000000..ed8d5486a4 --- /dev/null +++ b/frontend/src/component/addons/AddonList/AvailableAddons/AvailableAddons.tsx @@ -0,0 +1,181 @@ +import { useMemo } from 'react'; +import { PageContent } from 'component/common/PageContent/PageContent'; + +import { + Table, + SortableTableHeader, + TableBody, + TableCell, + TableRow, + TablePlaceholder, +} from 'component/common/Table'; + +import { useTable, useSortBy } from 'react-table'; +import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { sortTypes } from 'utils/sortTypes'; +import { IconCell } from 'component/common/Table/cells/IconCell/IconCell'; +import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell'; +import { ConfigureAddonButton } from './ConfigureAddonButton/ConfigureAddonButton'; +import { AddonIcon } from '../AddonIcon/AddonIcon'; + +interface IProvider { + name: string; + displayName: string; + description: string; + documentationUrl: string; + parameters: object[]; + events: string[]; +} + +interface IAvailableAddonsProps { + providers: IProvider[]; + loading: boolean; +} + +export const AvailableAddons = ({ + providers, + loading, +}: IAvailableAddonsProps) => { + const data = useMemo(() => { + if (loading) { + return Array(5).fill({ + name: 'Provider name', + description: 'Provider description when loading', + }); + } + + return providers.map(({ name, displayName, description }) => ({ + name, + displayName, + description, + })); + }, [providers, loading]); + + const columns = useMemo( + () => [ + { + id: 'Icon', + Cell: ({ + row: { + original: { name }, + }, + }: any) => { + return ( + } /> + ); + }, + }, + { + Header: 'Name', + accessor: 'name', + width: '90%', + Cell: ({ + row: { + original: { name, description }, + }, + }: any) => { + return ( + + ); + }, + sortType: 'alphanumeric', + }, + { + id: 'Actions', + align: 'center', + Cell: ({ row: { original } }: any) => ( + + + + ), + width: 150, + disableSortBy: true, + }, + { + accessor: 'description', + disableSortBy: true, + }, + ], + [] + ); + + const initialState = useMemo( + () => ({ + sortBy: [{ id: 'name', desc: false }], + hiddenColumns: ['description'], + }), + [] + ); + + const { + getTableProps, + getTableBodyProps, + headerGroups, + rows, + prepareRow, + state: { globalFilter }, + } = useTable( + { + columns: columns as any[], // TODO: fix after `react-table` v8 update + data, + initialState, + sortTypes, + autoResetGlobalFilter: false, + autoResetSortBy: false, + disableSortRemove: true, + }, + useSortBy + ); + + return ( + } + > + + + + {rows.map(row => { + prepareRow(row); + return ( + + {row.cells.map(cell => ( + + {cell.render('Cell')} + + ))} + + ); + })} + +
    + + 0} + show={ + + No providers found matching “ + {globalFilter} + ” + + } + elseShow={ + + No providers available. + + } + /> + } + /> +
    + ); +}; diff --git a/frontend/src/component/addons/AddonList/AvailableAddons/ConfigureAddonButton/ConfigureAddonButton.tsx b/frontend/src/component/addons/AddonList/AvailableAddons/ConfigureAddonButton/ConfigureAddonButton.tsx new file mode 100644 index 0000000000..10de7b0a56 --- /dev/null +++ b/frontend/src/component/addons/AddonList/AvailableAddons/ConfigureAddonButton/ConfigureAddonButton.tsx @@ -0,0 +1,21 @@ +import PermissionButton from 'component/common/PermissionButton/PermissionButton'; +import { CREATE_ADDON } from 'component/providers/AccessProvider/permissions'; +import { useNavigate } from 'react-router-dom'; + +interface IConfigureAddonButtonProps { + name: string; +} + +export const ConfigureAddonButton = ({ name }: IConfigureAddonButtonProps) => { + const navigate = useNavigate(); + + return ( + navigate(`/addons/create/${name}`)} + > + Configure + + ); +}; diff --git a/frontend/src/component/addons/AddonList/ConfiguredAddons/ConfiguredAddons.tsx b/frontend/src/component/addons/AddonList/ConfiguredAddons/ConfiguredAddons.tsx new file mode 100644 index 0000000000..263d7d9d8f --- /dev/null +++ b/frontend/src/component/addons/AddonList/ConfiguredAddons/ConfiguredAddons.tsx @@ -0,0 +1,228 @@ +import { Table, TableBody, TableCell, TableRow } from 'component/common/Table'; +import { useMemo, useState, useCallback } from 'react'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import useAddons from 'hooks/api/getters/useAddons/useAddons'; +import useToast from 'hooks/useToast'; +import useAddonsApi from 'hooks/api/actions/useAddonsApi/useAddonsApi'; +import { IAddon } from 'interfaces/addons'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell'; +import { sortTypes } from 'utils/sortTypes'; +import { useTable, useSortBy } from 'react-table'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { SortableTableHeader, TablePlaceholder } from 'component/common/Table'; +import { IconCell } from 'component/common/Table/cells/IconCell/IconCell'; +import { AddonIcon } from '../AddonIcon/AddonIcon'; +import { ConfiguredAddonsActionsCell } from './ConfiguredAddonsActionCell/ConfiguredAddonsActionsCell'; + +export const ConfiguredAddons = () => { + const { refetchAddons, addons, loading } = useAddons(); + const { updateAddon, removeAddon } = useAddonsApi(); + const { setToastData, setToastApiError } = useToast(); + const [showDelete, setShowDelete] = useState(false); + const [deletedAddon, setDeletedAddon] = useState({ + id: 0, + provider: '', + description: '', + enabled: false, + events: [], + parameters: {}, + }); + + const data = useMemo(() => { + if (loading) { + return Array(5).fill({ + name: 'Addon name', + description: 'Addon description when loading', + }); + } + + return addons.map(addon => ({ + ...addon, + })); + }, [addons, loading]); + + const toggleAddon = useCallback( + async (addon: IAddon) => { + try { + await updateAddon({ ...addon, enabled: !addon.enabled }); + refetchAddons(); + setToastData({ + type: 'success', + title: 'Success', + text: !addon.enabled + ? 'Addon is now enabled' + : 'Addon is now disabled', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + throw error; // caught by optimistic update + } + }, + [setToastApiError, refetchAddons, setToastData, updateAddon] + ); + + const columns = useMemo( + () => [ + { + id: 'Icon', + Cell: ({ + row: { + original: { provider }, + }, + }: any) => ( + } /> + ), + }, + { + Header: 'Name', + accessor: 'provider', + width: '90%', + Cell: ({ + row: { + original: { provider, description }, + }, + }: any) => { + return ( + + ); + }, + sortType: 'alphanumeric', + }, + { + Header: 'Actions', + id: 'Actions', + align: 'center', + Cell: ({ + row: { original }, + }: { + row: { original: IAddon }; + }) => ( + + ), + width: 150, + disableSortBy: true, + }, + { + accessor: 'description', + disableSortBy: true, + }, + ], + [toggleAddon] + ); + + const initialState = useMemo( + () => ({ + sortBy: [{ id: 'provider', desc: false }], + hiddenColumns: ['description'], + }), + [] + ); + + const { + getTableProps, + getTableBodyProps, + headerGroups, + rows, + prepareRow, + state: { globalFilter }, + } = useTable( + { + columns: columns as any[], // TODO: fix after `react-table` v8 update + data, + initialState, + sortTypes, + autoResetGlobalFilter: false, + autoResetSortBy: false, + disableSortRemove: true, + }, + useSortBy + ); + + const onRemoveAddon = async (addon: IAddon) => { + try { + await removeAddon(addon.id); + refetchAddons(); + setToastData({ + type: 'success', + title: 'Success', + text: 'Deleted addon successfully', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + return ( + } + sx={theme => ({ marginBottom: theme.spacing(2) })} + > + + + + {rows.map(row => { + prepareRow(row); + return ( + + {row.cells.map(cell => ( + + {cell.render('Cell')} + + ))} + + ); + })} + +
    + 0} + show={ + + No addons found matching “ + {globalFilter} + ” + + } + elseShow={ + + No addons configured + + } + /> + } + /> + { + onRemoveAddon(deletedAddon); + setShowDelete(false); + }} + onClose={() => { + setShowDelete(false); + }} + title="Confirm deletion" + > +
    Are you sure you want to delete this Addon?
    +
    +
    + ); +}; diff --git a/frontend/src/component/addons/AddonList/ConfiguredAddons/ConfiguredAddonsActionCell/ConfiguredAddonsActionsCell.tsx b/frontend/src/component/addons/AddonList/ConfiguredAddons/ConfiguredAddonsActionCell/ConfiguredAddonsActionsCell.tsx new file mode 100644 index 0000000000..f2a9e21136 --- /dev/null +++ b/frontend/src/component/addons/AddonList/ConfiguredAddons/ConfiguredAddonsActionCell/ConfiguredAddonsActionsCell.tsx @@ -0,0 +1,73 @@ +import { Edit, Delete } from '@mui/icons-material'; +import { Tooltip } from '@mui/material'; +import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; +import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch'; +import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell'; +import { useOptimisticUpdate } from 'component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/hooks/useOptimisticUpdate'; +import { + UPDATE_ADDON, + DELETE_ADDON, +} from 'component/providers/AccessProvider/permissions'; +import { IAddon } from 'interfaces/addons'; +import { useNavigate } from 'react-router-dom'; + +interface IConfiguredAddonsActionsCellProps { + toggleAddon: (addon: IAddon) => Promise; + original: IAddon; + setShowDelete: React.Dispatch>; + setDeletedAddon: React.Dispatch>; +} + +export const ConfiguredAddonsActionsCell = ({ + toggleAddon, + setShowDelete, + setDeletedAddon, + original, +}: IConfiguredAddonsActionsCellProps) => { + const navigate = useNavigate(); + const [isEnabled, setIsEnabled, rollbackIsChecked] = + useOptimisticUpdate(original.enabled); + + const onClick = () => { + setIsEnabled(!isEnabled); + toggleAddon(original).catch(rollbackIsChecked); + }; + + return ( + + + + + + navigate(`/addons/edit/${original.id}`)} + > + + + { + setDeletedAddon(original); + setShowDelete(true); + }} + > + + + + ); +}; diff --git a/frontend/src/component/addons/CreateAddon/CreateAddon.tsx b/frontend/src/component/addons/CreateAddon/CreateAddon.tsx new file mode 100644 index 0000000000..30548882f1 --- /dev/null +++ b/frontend/src/component/addons/CreateAddon/CreateAddon.tsx @@ -0,0 +1,39 @@ +import useAddons from 'hooks/api/getters/useAddons/useAddons'; +import { AddonForm } from '../AddonForm/AddonForm'; +import cloneDeep from 'lodash.clonedeep'; +import { IAddon } from 'interfaces/addons'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; + +export const DEFAULT_DATA = { + provider: '', + description: '', + enabled: true, + parameters: {}, + events: [], + projects: [], + environments: [], +} as unknown as IAddon; // TODO: improve type + +export const CreateAddon = () => { + const providerId = useRequiredPathParam('providerId'); + const { providers, refetchAddons } = useAddons(); + + const editMode = false; + const provider = providers.find( + (providerItem: any) => providerItem.name === providerId + ); + + const defaultAddon = { + ...cloneDeep(DEFAULT_DATA), + provider: provider ? provider.name : '', + }; + + return ( + + ); +}; diff --git a/frontend/src/component/addons/EditAddon/EditAddon.tsx b/frontend/src/component/addons/EditAddon/EditAddon.tsx new file mode 100644 index 0000000000..3fa6cfd4ce --- /dev/null +++ b/frontend/src/component/addons/EditAddon/EditAddon.tsx @@ -0,0 +1,28 @@ +import useAddons from 'hooks/api/getters/useAddons/useAddons'; +import { AddonForm } from '../AddonForm/AddonForm'; +import cloneDeep from 'lodash.clonedeep'; +import { IAddon } from 'interfaces/addons'; +import { DEFAULT_DATA } from '../CreateAddon/CreateAddon'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; + +export const EditAddon = () => { + const addonId = useRequiredPathParam('addonId'); + const { providers, addons, refetchAddons } = useAddons(); + + const editMode = true; + const addon = addons.find( + (addon: IAddon) => addon.id === Number(addonId) + ) || { ...cloneDeep(DEFAULT_DATA) }; + const provider = addon + ? providers.find(provider => provider.name === addon.provider) + : undefined; + + return ( + + ); +}; diff --git a/frontend/src/component/admin/api/index.tsx b/frontend/src/component/admin/api/index.tsx new file mode 100644 index 0000000000..057133471e --- /dev/null +++ b/frontend/src/component/admin/api/index.tsx @@ -0,0 +1,21 @@ +import { useLocation } from 'react-router-dom'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { ApiTokenPage } from 'component/admin/apiToken/ApiTokenPage/ApiTokenPage'; +import AdminMenu from '../menu/AdminMenu'; + +const ApiPage = () => { + const { pathname } = useLocation(); + const showAdminMenu = pathname.includes('/admin/'); + + return ( +
    + } + /> + +
    + ); +}; + +export default ApiPage; diff --git a/frontend/src/component/admin/apiToken/ApiTokenDocs/ApiTokenDocs.tsx b/frontend/src/component/admin/apiToken/ApiTokenDocs/ApiTokenDocs.tsx new file mode 100644 index 0000000000..f33c9a7282 --- /dev/null +++ b/frontend/src/component/admin/apiToken/ApiTokenDocs/ApiTokenDocs.tsx @@ -0,0 +1,27 @@ +import { Alert } from '@mui/material'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; + +export const ApiTokenDocs = () => { + const { uiConfig } = useUiConfig(); + + return ( + +

    + Read the{' '} + + SDK overview + {' '} + to connect Unleash to your application. Please note it can take + up to 1 minute before a new API key is + activated. +

    +
    + API URL: {' '} +
    {uiConfig.unleashUrl}/api/
    +
    + ); +}; diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.styles.ts b/frontend/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.styles.ts new file mode 100644 index 0000000000..e54129a369 --- /dev/null +++ b/frontend/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.styles.ts @@ -0,0 +1,61 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + maxWidth: '400px', + }, + form: { + display: 'flex', + flexDirection: 'column', + height: '100%', + }, + input: { width: '100%', marginBottom: '1rem' }, + selectInput: { + marginBottom: '1rem', + minWidth: '400px', + [theme.breakpoints.down(600)]: { + minWidth: '379px', + }, + }, + radioGroup: { + marginBottom: theme.spacing(2), + }, + radioItem: { + marginBottom: theme.spacing(1), + }, + radio: { + marginLeft: theme.spacing(1.5), + }, + label: { + minWidth: '300px', + [theme.breakpoints.down(600)]: { + minWidth: 'auto', + }, + }, + buttonContainer: { + marginTop: 'auto', + display: 'flex', + justifyContent: 'flex-end', + }, + cancelButton: { + marginLeft: '1.5rem', + }, + inputDescription: { + marginBottom: '0.5rem', + }, + permissionErrorContainer: { + position: 'relative', + }, + errorMessage: { + fontSize: theme.fontSizes.smallBody, + color: theme.palette.error.main, + position: 'absolute', + top: '-8px', + }, + selectOptionsLink: { + cursor: 'pointer', + }, + selectOptionCheckbox: { + marginRight: '0.2rem', + }, +})); diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.tsx b/frontend/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.tsx new file mode 100644 index 0000000000..5cce172e92 --- /dev/null +++ b/frontend/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.tsx @@ -0,0 +1,190 @@ +import { + Button, + FormControl, + FormControlLabel, + Radio, + RadioGroup, + Typography, + Box, +} from '@mui/material'; +import { KeyboardArrowDownOutlined } from '@mui/icons-material'; +import React from 'react'; +import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments'; +import useProjects from 'hooks/api/getters/useProjects/useProjects'; +import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect'; +import Input from 'component/common/Input/Input'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { SelectProjectInput } from './SelectProjectInput/SelectProjectInput'; +import { ApiTokenFormErrorType } from './useApiTokenForm'; +import { useStyles } from './ApiTokenForm.styles'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { TokenType } from 'interfaces/token'; +import { CorsTokenAlert } from 'component/admin/cors/CorsTokenAlert'; + +interface IApiTokenFormProps { + username: string; + type: string; + projects: string[]; + environment?: string; + setTokenType: (value: string) => void; + setUsername: React.Dispatch>; + setProjects: React.Dispatch>; + setEnvironment: React.Dispatch>; + handleSubmit: (e: any) => void; + handleCancel: () => void; + errors: { [key: string]: string }; + mode: 'Create' | 'Edit'; + clearErrors: (error?: ApiTokenFormErrorType) => void; +} + +const ApiTokenForm: React.FC = ({ + children, + username, + type, + projects, + environment, + setUsername, + setTokenType, + setProjects, + setEnvironment, + handleSubmit, + handleCancel, + errors, + clearErrors, +}) => { + const { uiConfig } = useUiConfig(); + const { classes: styles } = useStyles(); + const { environments } = useEnvironments(); + const { projects: availableProjects } = useProjects(); + + const selectableTypes = [ + { + key: TokenType.CLIENT, + label: `Server-side SDK (${TokenType.CLIENT})`, + title: 'Connect server-side SDK or Unleash Proxy', + }, + { + key: TokenType.ADMIN, + label: TokenType.ADMIN, + title: 'Full access for managing Unleash', + }, + ]; + + if (uiConfig.embedProxy) { + selectableTypes.splice(1, 0, { + key: TokenType.FRONTEND, + label: `Client-side SDK (${TokenType.FRONTEND})`, + title: 'Connect web and mobile SDK directly to Unleash', + }); + } + + const selectableProjects = availableProjects.map(project => ({ + value: project.id, + label: project.name, + })); + + const selectableEnvs = + type === TokenType.ADMIN + ? [{ key: '*', label: 'ALL' }] + : environments.map(environment => ({ + key: environment.name, + label: environment.name, + title: environment.name, + disabled: !environment.enabled, + })); + + return ( +
    +
    +

    + Who are you generating the token for? +

    + setUsername(e.target.value)} + label="Username" + error={errors.username !== undefined} + errorText={errors.username} + onFocus={() => clearErrors('username')} + autoFocus + /> + + + setTokenType(value)} + > + {selectableTypes.map(({ key, label, title }) => ( + } + label={ + <> + {label} + + {title} + + + } + /> + ))} + + +

    + Which project do you want to give access to? +

    + clearErrors('projects')} + /> +

    + Which environment should the token have access to? +

    + +
    +
    + {children} + +
    + + + + } + /> + + ); +}; + +export default ApiTokenForm; diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton.styles.ts b/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton.styles.ts new file mode 100644 index 0000000000..5c1e706282 --- /dev/null +++ b/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton.styles.ts @@ -0,0 +1,8 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + selectOptionsLink: { + cursor: 'pointer', + fontSize: theme.fontSizes.bodySize, + }, +})); diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton.tsx b/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton.tsx new file mode 100644 index 0000000000..10d8165223 --- /dev/null +++ b/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton.tsx @@ -0,0 +1,28 @@ +import React, { FC } from 'react'; +import { Box, Link } from '@mui/material'; +import { useStyles } from './SelectAllButton.styles'; + +type SelectAllButtonProps = { + isAllSelected: boolean; + onClick: () => void; +}; + +export const SelectAllButton: FC = ({ + isAllSelected, + onClick, +}) => { + const { classes: styles } = useStyles(); + + return ( + + + {isAllSelected ? 'Deselect all' : 'Select all'} + + + ); +}; diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.styles.ts b/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.styles.ts new file mode 100644 index 0000000000..7c29c9bd3c --- /dev/null +++ b/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.styles.ts @@ -0,0 +1,7 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + selectOptionCheckbox: { + marginRight: '0.2rem', + }, +})); diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.test.tsx b/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.test.tsx new file mode 100644 index 0000000000..97ccfa906c --- /dev/null +++ b/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.test.tsx @@ -0,0 +1,166 @@ +import { vi } from 'vitest'; +import React from 'react'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from 'utils/testRenderer'; +import { + ISelectProjectInputProps, + SelectProjectInput, +} from './SelectProjectInput'; + +const onChange = vi.fn(); +const onFocus = vi.fn(); + +const mockProps: ISelectProjectInputProps = { + options: [ + { label: 'Project1', value: 'project1' }, + { label: 'Project2', value: 'project2' }, + { label: 'Project3', value: 'project3' }, + ], + defaultValue: ['*'], + onChange, + onFocus, +}; + +describe('SelectProjectInput', () => { + beforeEach(() => { + onChange.mockClear(); + onFocus.mockClear(); + }); + + it('renders with default state', () => { + render(); + + const checkbox = screen.getByLabelText( + /all current and future projects/i + ); + expect(checkbox).toBeChecked(); + + const selectInputContainer = screen.getByTestId('select-input'); + const input = within(selectInputContainer).getByRole('combobox'); + expect(input).toBeDisabled(); + }); + + it('can toggle "ALL" checkbox', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('select-all-projects')); + + expect( + screen.getByLabelText(/all current and future projects/i) + ).not.toBeChecked(); + + expect(screen.getByLabelText('Projects')).toBeEnabled(); + + await user.click(screen.getByTestId('select-all-projects')); + + expect( + screen.getByLabelText(/all current and future projects/i) + ).toBeChecked(); + + expect(screen.getByLabelText('Projects')).toBeDisabled(); + }); + + it('renders with autocomplete enabled if default value is not a wildcard', () => { + render( + + ); + + const checkbox = screen.getByLabelText( + /all current and future projects/i + ); + expect(checkbox).not.toBeChecked(); + + const selectInputContainer = screen.getByTestId('select-input'); + const input = within(selectInputContainer).getByRole('combobox'); + expect(input).toBeEnabled(); + }); + + describe('Select/Deselect projects in dropdown', () => { + it('selects and deselects all options', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByLabelText('Projects')); + + let button = screen.getByRole('button', { + name: /select all/i, + }); + expect(button).toBeInTheDocument(); + await user.click(button); + + expect(onChange).toHaveBeenCalledWith([ + 'project1', + 'project2', + 'project3', + ]); + + button = screen.getByRole('button', { + name: /deselect all/i, + }); + expect(button).toBeInTheDocument(); + await user.click(button); + expect(onChange).toHaveBeenCalledWith([]); + }); + + it("doesn't show up for less than 3 options", async () => { + const user = userEvent.setup(); + render( + + ); + await user.click(screen.getByLabelText('Projects')); + + const button = screen.queryByRole('button', { + name: /select all/i, + }); + expect(button).not.toBeInTheDocument(); + }); + }); + + it('can filter options', async () => { + const user = userEvent.setup(); + render( + + ); + const input = await screen.findByLabelText('Projects'); + await user.type(input, 'alp'); + + await waitFor(() => { + expect(screen.getByText('Alpha')).toBeVisible(); + }); + await waitFor(() => { + expect(screen.queryByText('Bravo')).not.toBeInTheDocument(); + }); + await waitFor(() => { + expect(screen.queryByText('Charlie')).not.toBeInTheDocument(); + }); + await waitFor(() => { + expect(screen.getByText('Alpaca')).toBeVisible(); + }); + + await user.clear(input); + await user.type(input, 'bravo'); + await waitFor(() => { + expect(screen.getByText('Bravo')).toBeVisible(); + }); + await waitFor(() => { + expect(screen.queryByText('Alpha')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.tsx b/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.tsx new file mode 100644 index 0000000000..37e2c3c4ca --- /dev/null +++ b/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.tsx @@ -0,0 +1,166 @@ +import React, { Fragment, useState, ChangeEvent, VFC } from 'react'; +import { + Checkbox, + FormControlLabel, + TextField, + Box, + Paper, +} from '@mui/material'; +import { Autocomplete } from '@mui/material'; + +import { + AutocompleteRenderGroupParams, + AutocompleteRenderInputParams, + AutocompleteRenderOptionState, +} from '@mui/material/Autocomplete'; + +import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'; +import CheckBoxIcon from '@mui/icons-material/CheckBox'; +import { IAutocompleteBoxOption } from 'component/common/AutocompleteBox/AutocompleteBox'; +import { useStyles } from '../ApiTokenForm.styles'; +import { SelectAllButton } from './SelectAllButton/SelectAllButton'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; + +const ALL_PROJECTS = '*'; + +// Fix for shadow under Autocomplete - match with Select input +const CustomPaper = ({ ...props }) => ; + +export interface ISelectProjectInputProps { + disabled?: boolean; + options: IAutocompleteBoxOption[]; + defaultValue: string[]; + onChange: (value: string[]) => void; + onFocus?: () => void; + error?: string; +} + +export const SelectProjectInput: VFC = ({ + options, + defaultValue = [ALL_PROJECTS], + onChange, + disabled, + error, + onFocus, +}) => { + const { classes: styles } = useStyles(); + const [projects, setProjects] = useState( + typeof defaultValue === 'string' ? [defaultValue] : defaultValue + ); + const [isWildcardSelected, selectWildcard] = useState( + typeof defaultValue === 'string' || defaultValue.includes(ALL_PROJECTS) + ); + const isAllSelected = + projects.length > 0 && + projects.length === options.length && + projects[0] !== ALL_PROJECTS; + + const onAllProjectsChange = ( + e: ChangeEvent, + checked: boolean + ) => { + if (checked) { + selectWildcard(true); + onChange([ALL_PROJECTS]); + } else { + selectWildcard(false); + onChange(projects.includes(ALL_PROJECTS) ? [] : projects); + } + }; + + const onSelectAllClick = () => { + const newProjects = isAllSelected + ? [] + : options.map(({ value }) => value); + setProjects(newProjects); + onChange(newProjects); + }; + + const renderOption = ( + props: object, + option: IAutocompleteBoxOption, + { selected }: AutocompleteRenderOptionState + ) => ( +
  • + } + checkedIcon={} + checked={selected} + className={styles.selectOptionCheckbox} + /> + {option.label} +
  • + ); + + const renderGroup = ({ key, children }: AutocompleteRenderGroupParams) => ( + + 2} + show={ + + } + /> + {children} + + ); + + const renderInput = (params: AutocompleteRenderInputParams) => ( + + ); + + return ( + + + + } + label="ALL current and future projects" + /> + + label} + groupBy={() => 'Select/Deselect all'} + renderGroup={renderGroup} + fullWidth + PaperComponent={CustomPaper} + renderOption={renderOption} + renderInput={renderInput} + value={ + isWildcardSelected || disabled + ? options + : options.filter(option => + projects.includes(option.value) + ) + } + onChange={(_, input) => { + const state = input.map(({ value }) => value); + setProjects(state); + onChange(state); + }} + /> + + ); +}; diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/useApiTokenForm.ts b/frontend/src/component/admin/apiToken/ApiTokenForm/useApiTokenForm.ts new file mode 100644 index 0000000000..7c28a3416c --- /dev/null +++ b/frontend/src/component/admin/apiToken/ApiTokenForm/useApiTokenForm.ts @@ -0,0 +1,82 @@ +import { useEffect, useState } from 'react'; +import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments'; +import { IApiTokenCreate } from 'hooks/api/actions/useApiTokensApi/useApiTokensApi'; + +export type ApiTokenFormErrorType = 'username' | 'projects'; + +export const useApiTokenForm = () => { + const { environments } = useEnvironments(); + const initialEnvironment = environments?.find(e => e.enabled)?.name; + + const [username, setUsername] = useState(''); + const [type, setType] = useState('CLIENT'); + const [projects, setProjects] = useState(['*']); + const [memorizedProjects, setMemorizedProjects] = + useState(projects); + const [environment, setEnvironment] = useState(); + const [errors, setErrors] = useState< + Partial> + >({}); + + useEffect(() => { + setEnvironment(type === 'ADMIN' ? '*' : initialEnvironment); + }, [type, initialEnvironment]); + + const setTokenType = (value: string) => { + if (value === 'ADMIN') { + setType(value); + setMemorizedProjects(projects); + setProjects(['*']); + setEnvironment('*'); + } else { + setType(value); + setProjects(memorizedProjects); + setEnvironment(initialEnvironment); + } + }; + + const getApiTokenPayload = (): IApiTokenCreate => ({ + username, + type, + environment, + projects, + }); + + const isValid = () => { + const newErrors: Partial> = {}; + if (!username) { + newErrors['username'] = 'Username is required'; + } + if (projects.length === 0) { + newErrors['projects'] = 'At least one project is required'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const clearErrors = (error?: ApiTokenFormErrorType) => { + if (error) { + const newErrors = { ...errors }; + delete newErrors[error]; + setErrors(newErrors); + } else { + setErrors({}); + } + }; + + return { + username, + type, + projects, + environment, + setUsername, + setTokenType, + setProjects, + setEnvironment, + getApiTokenPayload, + isValid, + clearErrors, + errors, + }; +}; diff --git a/frontend/src/component/admin/apiToken/ApiTokenPage/ApiTokenPage.tsx b/frontend/src/component/admin/apiToken/ApiTokenPage/ApiTokenPage.tsx new file mode 100644 index 0000000000..f5d20b261d --- /dev/null +++ b/frontend/src/component/admin/apiToken/ApiTokenPage/ApiTokenPage.tsx @@ -0,0 +1,18 @@ +import { useContext } from 'react'; +import AccessContext from 'contexts/AccessContext'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { READ_API_TOKEN } from 'component/providers/AccessProvider/permissions'; +import { AdminAlert } from 'component/common/AdminAlert/AdminAlert'; +import { ApiTokenTable } from 'component/admin/apiToken/ApiTokenTable/ApiTokenTable'; + +export const ApiTokenPage = () => { + const { hasAccess } = useContext(AccessContext); + + return ( + } + elseShow={() => } + /> + ); +}; diff --git a/frontend/src/component/admin/apiToken/ApiTokenTable/ApiTokenTable.tsx b/frontend/src/component/admin/apiToken/ApiTokenTable/ApiTokenTable.tsx new file mode 100644 index 0000000000..811b3316c8 --- /dev/null +++ b/frontend/src/component/admin/apiToken/ApiTokenTable/ApiTokenTable.tsx @@ -0,0 +1,223 @@ +import { useApiTokens } from 'hooks/api/getters/useApiTokens/useApiTokens'; +import { useTable, useGlobalFilter, useSortBy } from 'react-table'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { + SortableTableHeader, + TableCell, + TablePlaceholder, +} from 'component/common/Table'; +import { Table, TableBody, Box, TableRow, useMediaQuery } from '@mui/material'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { ApiTokenDocs } from 'component/admin/apiToken/ApiTokenDocs/ApiTokenDocs'; +import { CreateApiTokenButton } from 'component/admin/apiToken/CreateApiTokenButton/CreateApiTokenButton'; +import { IconCell } from 'component/common/Table/cells/IconCell/IconCell'; +import { Key } from '@mui/icons-material'; +import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell'; +import { CopyApiTokenButton } from 'component/admin/apiToken/CopyApiTokenButton/CopyApiTokenButton'; +import { RemoveApiTokenButton } from 'component/admin/apiToken/RemoveApiTokenButton/RemoveApiTokenButton'; +import { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; +import { sortTypes } from 'utils/sortTypes'; +import { useMemo } from 'react'; +import theme from 'themes/theme'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { ProjectsList } from 'component/admin/apiToken/ProjectsList/ProjectsList'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell'; +import { Search } from 'component/common/Search/Search'; +import useHiddenColumns from 'hooks/useHiddenColumns'; + +const hiddenColumnsSmall = ['Icon', 'createdAt']; +const hiddenColumnsFlagE = ['projects', 'environment']; + +export const ApiTokenTable = () => { + const { tokens, loading } = useApiTokens(); + const initialState = useMemo(() => ({ sortBy: [{ id: 'createdAt' }] }), []); + const { uiConfig } = useUiConfig(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + + const { + getTableProps, + getTableBodyProps, + headerGroups, + rows, + prepareRow, + state: { globalFilter }, + setGlobalFilter, + setHiddenColumns, + } = useTable( + { + columns: COLUMNS as any, + data: tokens as any, + initialState, + sortTypes, + disableSortRemove: true, + }, + useGlobalFilter, + useSortBy + ); + + useHiddenColumns(setHiddenColumns, hiddenColumnsSmall, isSmallScreen); + useHiddenColumns(setHiddenColumns, hiddenColumnsFlagE, !uiConfig.flags.E); + + return ( + + + + + + } + /> + } + > + 0} + show={ + + + + } + /> + + + + + + {rows.map(row => { + prepareRow(row); + return ( + + {row.cells.map(cell => ( + + {cell.render('Cell')} + + ))} + + ); + })} + +
    +
    +
    + 0} + show={ + + No tokens found matching “ + {globalFilter} + ” + + } + elseShow={ + + + {'No tokens available. Read '} + + API How-to guides + {' '} + {' to learn more.'} + + + } + /> + } + /> +
    + ); +}; + +const tokenDescriptions = { + client: { + label: 'CLIENT', + title: 'Connect server-side SDK or Unleash Proxy', + }, + frontend: { + label: 'FRONTEND', + title: 'Connect web and mobile SDK', + }, + admin: { + label: 'ADMIN', + title: 'Full access for managing Unleash', + }, +}; + +const COLUMNS = [ + { + id: 'Icon', + width: '1%', + Cell: () => } />, + disableSortBy: true, + disableGlobalFilter: true, + }, + { + Header: 'Username', + accessor: 'username', + Cell: HighlightCell, + }, + { + Header: 'Type', + accessor: 'type', + Cell: ({ value }: { value: 'admin' | 'client' | 'frontend' }) => ( + + ), + minWidth: 280, + }, + { + Header: 'Project', + accessor: 'project', + Cell: (props: any) => ( + + ), + minWidth: 120, + }, + { + Header: 'Environment', + accessor: 'environment', + Cell: HighlightCell, + minWidth: 120, + }, + { + Header: 'Created', + accessor: 'createdAt', + Cell: DateCell, + minWidth: 150, + disableGlobalFilter: true, + }, + { + Header: 'Actions', + id: 'Actions', + align: 'center', + width: '1%', + disableSortBy: true, + disableGlobalFilter: true, + Cell: (props: any) => ( + + + + + ), + }, +]; diff --git a/frontend/src/component/admin/apiToken/ConfirmToken/ConfirmToken.tsx b/frontend/src/component/admin/apiToken/ConfirmToken/ConfirmToken.tsx new file mode 100644 index 0000000000..7526b9dffc --- /dev/null +++ b/frontend/src/component/admin/apiToken/ConfirmToken/ConfirmToken.tsx @@ -0,0 +1,34 @@ +import { Typography } from '@mui/material'; +import { useThemeStyles } from 'themes/themeStyles'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import { UserToken } from './UserToken/UserToken'; + +interface IConfirmUserLink { + open: boolean; + closeConfirm: () => void; + token: string; +} + +export const ConfirmToken = ({ + open, + closeConfirm, + token, +}: IConfirmUserLink) => { + const { classes: themeStyles } = useThemeStyles(); + + return ( + +
    + + Your new token has been created successfully. + + +
    +
    + ); +}; diff --git a/frontend/src/component/admin/apiToken/ConfirmToken/UserToken/UserToken.tsx b/frontend/src/component/admin/apiToken/ConfirmToken/UserToken/UserToken.tsx new file mode 100644 index 0000000000..2125bf8684 --- /dev/null +++ b/frontend/src/component/admin/apiToken/ConfirmToken/UserToken/UserToken.tsx @@ -0,0 +1,47 @@ +import { IconButton, Tooltip } from '@mui/material'; +import CopyIcon from '@mui/icons-material/FileCopy'; +import copy from 'copy-to-clipboard'; +import useToast from 'hooks/useToast'; + +interface IUserTokenProps { + token: string; +} + +export const UserToken = ({ token }: IUserTokenProps) => { + const { setToastData } = useToast(); + + const copyToken = () => { + if (copy(token)) { + setToastData({ + type: 'success', + title: 'Token copied to clipboard', + }); + } else + setToastData({ + type: 'error', + title: 'Could not copy token', + }); + }; + + return ( +
    + {token} + + + + + +
    + ); +}; diff --git a/frontend/src/component/admin/apiToken/CopyApiTokenButton/CopyApiTokenButton.tsx b/frontend/src/component/admin/apiToken/CopyApiTokenButton/CopyApiTokenButton.tsx new file mode 100644 index 0000000000..e34e3501fb --- /dev/null +++ b/frontend/src/component/admin/apiToken/CopyApiTokenButton/CopyApiTokenButton.tsx @@ -0,0 +1,30 @@ +import { IconButton, Tooltip } from '@mui/material'; +import { IApiToken } from 'hooks/api/getters/useApiTokens/useApiTokens'; +import useToast from 'hooks/useToast'; +import copy from 'copy-to-clipboard'; +import { FileCopy } from '@mui/icons-material'; + +interface ICopyApiTokenButtonProps { + token: IApiToken; +} + +export const CopyApiTokenButton = ({ token }: ICopyApiTokenButtonProps) => { + const { setToastData } = useToast(); + + const copyToken = (value: string) => { + if (copy(value)) { + setToastData({ + type: 'success', + title: `Token copied to clipboard`, + }); + } + }; + + return ( + + copyToken(token.secret)} size="large"> + + + + ); +}; diff --git a/frontend/src/component/admin/apiToken/CreateApiToken/CreateApiToken.tsx b/frontend/src/component/admin/apiToken/CreateApiToken/CreateApiToken.tsx new file mode 100644 index 0000000000..203f1ac1fb --- /dev/null +++ b/frontend/src/component/admin/apiToken/CreateApiToken/CreateApiToken.tsx @@ -0,0 +1,115 @@ +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import { useNavigate } from 'react-router-dom'; +import ApiTokenForm from '../ApiTokenForm/ApiTokenForm'; +import { CreateButton } from 'component/common/CreateButton/CreateButton'; +import useApiTokensApi from 'hooks/api/actions/useApiTokensApi/useApiTokensApi'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import useToast from 'hooks/useToast'; +import { useApiTokenForm } from 'component/admin/apiToken/ApiTokenForm/useApiTokenForm'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import { ConfirmToken } from '../ConfirmToken/ConfirmToken'; +import { useState } from 'react'; +import { scrollToTop } from 'component/common/util'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { usePageTitle } from 'hooks/usePageTitle'; +import { GO_BACK } from 'constants/navigate'; + +const pageTitle = 'Create API token'; + +export const CreateApiToken = () => { + const { setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + const navigate = useNavigate(); + const [showConfirm, setShowConfirm] = useState(false); + const [token, setToken] = useState(''); + + const { + getApiTokenPayload, + username, + type, + projects, + environment, + setUsername, + setTokenType, + setProjects, + setEnvironment, + isValid, + errors, + clearErrors, + } = useApiTokenForm(); + + const { createToken, loading } = useApiTokensApi(); + + usePageTitle(pageTitle); + + const handleSubmit = async (e: Event) => { + e.preventDefault(); + if (!isValid()) { + return; + } + try { + const payload = getApiTokenPayload(); + await createToken(payload) + .then(res => res.json()) + .then(api => { + scrollToTop(); + setToken(api.secret); + setShowConfirm(true); + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const closeConfirm = () => { + setShowConfirm(false); + navigate('/admin/api'); + }; + + const formatApiCode = () => { + return `curl --location --request POST '${ + uiConfig.unleashUrl + }/api/admin/api-tokens' \\ +--header 'Authorization: INSERT_API_KEY' \\ +--header 'Content-Type: application/json' \\ +--data-raw '${JSON.stringify(getApiTokenPayload(), undefined, 2)}'`; + }; + + const handleCancel = () => { + navigate(GO_BACK); + }; + + return ( + + + + + + + ); +}; diff --git a/frontend/src/component/admin/apiToken/CreateApiTokenButton/CreateApiTokenButton.tsx b/frontend/src/component/admin/apiToken/CreateApiTokenButton/CreateApiTokenButton.tsx new file mode 100644 index 0000000000..f1236fec3b --- /dev/null +++ b/frontend/src/component/admin/apiToken/CreateApiTokenButton/CreateApiTokenButton.tsx @@ -0,0 +1,21 @@ +import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton'; +import { CREATE_API_TOKEN } from 'component/providers/AccessProvider/permissions'; +import { CREATE_API_TOKEN_BUTTON } from 'utils/testIds'; +import { useNavigate } from 'react-router-dom'; +import { Add } from '@mui/icons-material'; + +export const CreateApiTokenButton = () => { + const navigate = useNavigate(); + + return ( + navigate('/admin/api/create-token')} + data-testid={CREATE_API_TOKEN_BUTTON} + permission={CREATE_API_TOKEN} + maxWidth="700px" + > + New API token + + ); +}; diff --git a/frontend/src/component/admin/apiToken/ProjectsList/ProjectsList.test.tsx b/frontend/src/component/admin/apiToken/ProjectsList/ProjectsList.test.tsx new file mode 100644 index 0000000000..5341609dc7 --- /dev/null +++ b/frontend/src/component/admin/apiToken/ProjectsList/ProjectsList.test.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { render } from 'utils/testRenderer'; +import { screen } from '@testing-library/react'; +import { ProjectsList } from 'component/admin/apiToken/ProjectsList/ProjectsList'; + +describe('ProjectsList', () => { + it('should prioritize new "projects" array over deprecated "project"', async () => { + render( + + ); + + const links = await screen.findAllByRole('link'); + expect(links).toHaveLength(2); + expect(links[0]).toHaveTextContent('project1'); + expect(links[1]).toHaveTextContent('project2'); + expect(links[0]).toHaveAttribute('href', '/projects/project1'); + expect(links[1]).toHaveAttribute('href', '/projects/project2'); + }); + + it('should render correctly with single "project"', async () => { + render(); + + const links = await screen.findAllByRole('link'); + expect(links).toHaveLength(1); + expect(links[0]).toHaveTextContent('project'); + }); + + it('should have comma between project links', async () => { + const { container } = render(); + + expect(container.textContent).toContain(', '); + }); + + it('should render asterisk if no projects are passed', async () => { + const { container } = render(); + + expect(container.textContent).toEqual('*'); + }); + + it('should render asterisk if empty projects array is passed', async () => { + const { container } = render(); + + expect(container.textContent).toEqual('*'); + }); +}); diff --git a/frontend/src/component/admin/apiToken/ProjectsList/ProjectsList.tsx b/frontend/src/component/admin/apiToken/ProjectsList/ProjectsList.tsx new file mode 100644 index 0000000000..c18918e88f --- /dev/null +++ b/frontend/src/component/admin/apiToken/ProjectsList/ProjectsList.tsx @@ -0,0 +1,59 @@ +import { styled } from '@mui/material'; +import { Highlighter } from 'component/common/Highlighter/Highlighter'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; +import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { Fragment, VFC } from 'react'; +import { Link } from 'react-router-dom'; + +const StyledLink = styled(Link)(() => ({ + textDecoration: 'none', + '&:hover, &:focus': { + textDecoration: 'underline', + }, +})); + +interface IProjectsListProps { + project?: string; + projects?: string | string[]; +} + +export const ProjectsList: VFC = ({ + projects, + project, +}) => { + const { searchQuery } = useSearchHighlightContext(); + + let fields: string[] = + projects && Array.isArray(projects) + ? projects + : project + ? [project] + : []; + + if (fields.length === 0) { + return ( + + * + + ); + } + + return ( + + {fields.map((item, index) => ( + + {index > 0 && ', '} + {!item || item === '*' ? ( + * + ) : ( + + + {item} + + + )} + + ))} + + ); +}; diff --git a/frontend/src/component/admin/apiToken/RemoveApiTokenButton/RemoveApiTokenButton.tsx b/frontend/src/component/admin/apiToken/RemoveApiTokenButton/RemoveApiTokenButton.tsx new file mode 100644 index 0000000000..38d47099b6 --- /dev/null +++ b/frontend/src/component/admin/apiToken/RemoveApiTokenButton/RemoveApiTokenButton.tsx @@ -0,0 +1,70 @@ +import { DELETE_API_TOKEN } from 'component/providers/AccessProvider/permissions'; +import { Delete } from '@mui/icons-material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { IconButton, Tooltip } from '@mui/material'; +import { + IApiToken, + useApiTokens, +} from 'hooks/api/getters/useApiTokens/useApiTokens'; +import AccessContext from 'contexts/AccessContext'; +import { useContext, useState } from 'react'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import useToast from 'hooks/useToast'; +import useApiTokensApi from 'hooks/api/actions/useApiTokensApi/useApiTokensApi'; + +interface IRemoveApiTokenButtonProps { + token: IApiToken; +} + +export const RemoveApiTokenButton = ({ token }: IRemoveApiTokenButtonProps) => { + const { hasAccess } = useContext(AccessContext); + const { deleteToken } = useApiTokensApi(); + const [open, setOpen] = useState(false); + const { setToastData } = useToast(); + const { refetch } = useApiTokens(); + + const onRemove = async () => { + await deleteToken(token.secret); + setOpen(false); + refetch(); + setToastData({ + type: 'success', + title: 'API token removed', + }); + }; + + return ( + <> + + setOpen(true)} size="large"> + + + + } + /> + setOpen(false)} + title="Confirm deletion" + > +
    + Are you sure you want to delete the following API token? +
    +
      +
    • + username:{' '} + {token.username} +
    • +
    • + type: {token.type} +
    • +
    +
    +
    + + ); +}; diff --git a/frontend/src/component/admin/auth/AuthSettings.tsx b/frontend/src/component/admin/auth/AuthSettings.tsx new file mode 100644 index 0000000000..e95d9e8f6b --- /dev/null +++ b/frontend/src/component/admin/auth/AuthSettings.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import AdminMenu from '../menu/AdminMenu'; +import { Alert } from '@mui/material'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { OidcAuth } from './OidcAuth/OidcAuth'; +import { SamlAuth } from './SamlAuth/SamlAuth'; +import { PasswordAuth } from './PasswordAuth/PasswordAuth'; +import { GoogleAuth } from './GoogleAuth/GoogleAuth'; +import { TabNav } from 'component/common/TabNav/TabNav/TabNav'; + +export const AuthSettings = () => { + const { authenticationType } = useUiConfig().uiConfig; + + const tabs = [ + { + label: 'OpenID Connect', + component: , + }, + { + label: 'SAML 2.0', + component: , + }, + { + label: 'Password', + component: , + }, + { + label: 'Google', + component: , + }, + ]; + + return ( +
    + + + } + /> + + You are running the open-source version of Unleash. + You have to use the Enterprise edition in order + configure Single Sign-on. + + } + /> + + You are running Unleash in demo mode. You have to + use the Enterprise edition in order configure Single + Sign-on. + + } + /> + + You have decided to use custom authentication type. + You have to use the Enterprise edition in order + configure Single Sign-on from the user interface. + + } + /> + + Your Unleash instance is managed by the Unleash + team. + + } + /> + +
    + ); +}; diff --git a/frontend/src/component/admin/auth/AutoCreateForm/AutoCreateForm.tsx b/frontend/src/component/admin/auth/AutoCreateForm/AutoCreateForm.tsx new file mode 100644 index 0000000000..bc89340ad6 --- /dev/null +++ b/frontend/src/component/admin/auth/AutoCreateForm/AutoCreateForm.tsx @@ -0,0 +1,119 @@ +import React, { ChangeEvent, Fragment } from 'react'; +import { + FormControl, + FormControlLabel, + Grid, + InputLabel, + MenuItem, + Select, + Switch, + TextField, + SelectChangeEvent, +} from '@mui/material'; + +interface IAutoCreateFormProps { + data?: { + enabled: boolean; + autoCreate: boolean; + defaultRootRole?: string; + emailDomains?: string; + }; + setValue: (name: string, value: string | boolean) => void; +} + +export const AutoCreateForm = ({ + data = { enabled: false, autoCreate: false }, + setValue, +}: IAutoCreateFormProps) => { + const updateAutoCreate = () => { + setValue('autoCreate', !data.autoCreate); + }; + + const updateDefaultRootRole = (evt: SelectChangeEvent) => { + setValue('defaultRootRole', evt.target.value); + }; + + const updateField = (e: ChangeEvent) => { + setValue(e.target.name, e.target.value); + }; + + return ( + + + + Auto-create users +

    + Enable automatic creation of new users when signing in. +

    +
    + + + } + label="Auto-create users" + /> + +
    + + + Default Root Role +

    + Choose which root role the user should get when no + explicit role mapping exists. +

    +
    + + + + Default Role + + + + +
    + + + Email domains +

    + Comma separated list of email domains that should be + allowed to sign in. +

    +
    + + + +
    +
    + ); +}; diff --git a/frontend/src/component/admin/auth/GoogleAuth/GoogleAuth.tsx b/frontend/src/component/admin/auth/GoogleAuth/GoogleAuth.tsx new file mode 100644 index 0000000000..fb23b99aa3 --- /dev/null +++ b/frontend/src/component/admin/auth/GoogleAuth/GoogleAuth.tsx @@ -0,0 +1,248 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { + Button, + FormControlLabel, + Grid, + Switch, + TextField, +} from '@mui/material'; +import { Alert } from '@mui/material'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import AccessContext from 'contexts/AccessContext'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import useAuthSettings from 'hooks/api/getters/useAuthSettings/useAuthSettings'; +import useAuthSettingsApi from 'hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { removeEmptyStringFields } from 'utils/removeEmptyStringFields'; + +const initialState = { + enabled: false, + autoCreate: false, + unleashHostname: location.hostname, + clientId: '', + clientSecret: '', + emailDomains: '', +}; + +export const GoogleAuth = () => { + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + const [data, setData] = useState(initialState); + const { hasAccess } = useContext(AccessContext); + const { config } = useAuthSettings('google'); + const { updateSettings, errors, loading } = useAuthSettingsApi('google'); + + useEffect(() => { + if (config.clientId) { + setData(config); + } + }, [config]); + + if (!hasAccess(ADMIN)) { + return You need admin privileges to access this section.; + } + + const updateField = (event: React.ChangeEvent) => { + setData({ + ...data, + [event.target.name]: event.target.value, + }); + }; + + const updateEnabled = () => { + setData({ ...data, enabled: !data.enabled }); + }; + + const updateAutoCreate = () => { + setData({ ...data, autoCreate: !data.autoCreate }); + }; + + const onSubmit = async (event: React.SyntheticEvent) => { + event.preventDefault(); + + try { + await updateSettings(removeEmptyStringFields(data)); + setToastData({ + title: 'Settings stored', + type: 'success', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + return ( + + + + + Please read the{' '} + + documentation + {' '} + to learn how to integrate with Google OAuth 2.0.
    + Callback URL:{' '} + {uiConfig.unleashUrl}/auth/google/callback +
    +
    +
    +
    + + + Enable +

    + Enable Google users to login. Value is ignored if + Client ID and Client Secret are not defined. +

    +
    + + + } + label={data.enabled ? 'Enabled' : 'Disabled'} + /> + +
    + + + Client ID +

    + (Required) The Client ID provided by Google when + registering the application. +

    +
    + + + +
    + + + Client Secret +

    + (Required) Client Secret provided by Google when + registering the application. +

    +
    + + + +
    + + + Unleash hostname +

    + (Required) The hostname you are running Unleash on + that Google should send the user back to. The final + callback URL will be{' '} + + + https://[unleash.hostname.com]/auth/google/callback + + +

    +
    + + + +
    + + + Auto-create users +

    + Enable automatic creation of new users when signing + in with Google. +

    +
    + + + +
    + + + Email domains +

    + (Optional) Comma separated list of email domains + that should be allowed to sign in. +

    +
    + + + +
    + + + {' '} +

    + + {errors?.message} + +

    +
    +
    +
    +
    + ); +}; diff --git a/frontend/src/component/admin/auth/OidcAuth/OidcAuth.tsx b/frontend/src/component/admin/auth/OidcAuth/OidcAuth.tsx new file mode 100644 index 0000000000..721f65beff --- /dev/null +++ b/frontend/src/component/admin/auth/OidcAuth/OidcAuth.tsx @@ -0,0 +1,261 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { + Button, + FormControlLabel, + Grid, + Switch, + TextField, +} from '@mui/material'; +import { Alert } from '@mui/material'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import AccessContext from 'contexts/AccessContext'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import { AutoCreateForm } from '../AutoCreateForm/AutoCreateForm'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import useAuthSettingsApi from 'hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi'; +import useAuthSettings from 'hooks/api/getters/useAuthSettings/useAuthSettings'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { removeEmptyStringFields } from 'utils/removeEmptyStringFields'; + +const initialState = { + enabled: false, + enableSingleSignOut: false, + autoCreate: false, + unleashHostname: location.hostname, + clientId: '', + discoverUrl: '', + secret: '', + acrValues: '', +}; + +export const OidcAuth = () => { + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + const [data, setData] = useState(initialState); + const { hasAccess } = useContext(AccessContext); + const { config } = useAuthSettings('oidc'); + const { updateSettings, errors, loading } = useAuthSettingsApi('oidc'); + + useEffect(() => { + if (config.discoverUrl) { + setData(config); + } + }, [config]); + + if (!hasAccess(ADMIN)) { + return ( + + You need to be a root admin to access this section. + + ); + } + + const updateField = (event: React.ChangeEvent) => { + setValue(event.target.name, event.target.value); + }; + + const updateEnabled = () => { + setData({ ...data, enabled: !data.enabled }); + }; + + const updateSingleSignOut = () => { + setData({ ...data, enableSingleSignOut: !data.enableSingleSignOut }); + }; + + const setValue = (name: string, value: string | boolean) => { + setData({ + ...data, + [name]: value, + }); + }; + + const onSubmit = async (event: React.SyntheticEvent) => { + event.preventDefault(); + + try { + await updateSettings(removeEmptyStringFields(data)); + setToastData({ + title: 'Settings stored', + type: 'success', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + return ( + + + + + Please read the{' '} + + documentation + {' '} + to learn how to integrate with specific Open Id Connect + providers (Okta, Keycloak, Google, etc).
    + Callback URL:{' '} + {uiConfig.unleashUrl}/auth/oidc/callback +
    +
    +
    +
    + + + Enable +

    Enable Open Id Connect Authentication.

    +
    + + + } + label={data.enabled ? 'Enabled' : 'Disabled'} + /> + +
    + + + Discover URL +

    (Required) Issuer discover metadata URL

    +
    + + + +
    + + + Client ID +

    (Required) Client ID of your OpenID application

    +
    + + + +
    + + + Client secret +

    + (Required) Client secret of your OpenID application.{' '} +

    +
    + + + +
    +

    Optional Configuration

    + + + Enable Single Sign-Out +

    + If you enable Single Sign-Out Unleash will redirect + the user to the IDP as part of the Sign-out process. +

    +
    + + + } + label={ + data.enableSingleSignOut + ? 'Enabled' + : 'Disabled' + } + /> + +
    + + + ACR Values +

    + Requested Authentication Context Class Reference + values. If multiple values are specified they should + be "space" separated. Will be sent as "acr_values" + as part of the authentication request. Unleash will + validate the acr value in the id token claims + against the list of acr values. +

    +
    + + + +
    + + + + + + {' '} +

    + + {errors?.message} + +

    +
    +
    + +
    + ); +}; diff --git a/frontend/src/component/admin/auth/PasswordAuth/PasswordAuth.tsx b/frontend/src/component/admin/auth/PasswordAuth/PasswordAuth.tsx new file mode 100644 index 0000000000..2aef86c5cd --- /dev/null +++ b/frontend/src/component/admin/auth/PasswordAuth/PasswordAuth.tsx @@ -0,0 +1,103 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { Button, FormControlLabel, Grid, Switch } from '@mui/material'; +import { Alert } from '@mui/material'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import AccessContext from 'contexts/AccessContext'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import useAuthSettings from 'hooks/api/getters/useAuthSettings/useAuthSettings'; +import useAuthSettingsApi, { + ISimpleAuthSettings, +} from 'hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; + +export const PasswordAuth = () => { + const { setToastData, setToastApiError } = useToast(); + const { config } = useAuthSettings('simple'); + const [disablePasswordAuth, setDisablePasswordAuth] = + useState(false); + const { updateSettings, errors, loading } = + useAuthSettingsApi('simple'); + const { hasAccess } = useContext(AccessContext); + + useEffect(() => { + setDisablePasswordAuth(!!config.disabled); + }, [config.disabled]); + + if (!hasAccess(ADMIN)) { + return ( + + You need to be a root admin to access this section. + + ); + } + + const updateDisabled = () => { + setDisablePasswordAuth(!disablePasswordAuth); + }; + + const onSubmit = async (event: React.SyntheticEvent) => { + event.preventDefault(); + + try { + const settings: ISimpleAuthSettings = { + disabled: disablePasswordAuth, + }; + await updateSettings(settings); + setToastData({ + title: 'Successfully saved', + text: 'Password authentication settings stored.', + autoHideDuration: 4000, + type: 'success', + show: true, + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + setDisablePasswordAuth(config.disabled); + } + }; + return ( + +
    + + + Password based login +

    Allow users to login with username & password

    +
    + + + } + label={ + !disablePasswordAuth ? 'Enabled' : 'Disabled' + } + /> + +
    + + + {' '} +

    + + {errors?.message} + +

    +
    +
    +
    +
    + ); +}; diff --git a/frontend/src/component/admin/auth/SamlAuth/SamlAuth.tsx b/frontend/src/component/admin/auth/SamlAuth/SamlAuth.tsx new file mode 100644 index 0000000000..5e1da6f385 --- /dev/null +++ b/frontend/src/component/admin/auth/SamlAuth/SamlAuth.tsx @@ -0,0 +1,270 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { + Button, + FormControlLabel, + Grid, + Switch, + TextField, +} from '@mui/material'; +import { Alert } from '@mui/material'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import AccessContext from 'contexts/AccessContext'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import { AutoCreateForm } from '../AutoCreateForm/AutoCreateForm'; +import useToast from 'hooks/useToast'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import useAuthSettings from 'hooks/api/getters/useAuthSettings/useAuthSettings'; +import useAuthSettingsApi from 'hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { removeEmptyStringFields } from 'utils/removeEmptyStringFields'; + +const initialState = { + enabled: false, + autoCreate: false, + unleashHostname: location.hostname, + entityId: '', + signOnUrl: '', + certificate: '', + signOutUrl: '', + spCertificate: '', +}; + +export const SamlAuth = () => { + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + const [data, setData] = useState(initialState); + const { hasAccess } = useContext(AccessContext); + const { config } = useAuthSettings('saml'); + const { updateSettings, errors, loading } = useAuthSettingsApi('saml'); + + useEffect(() => { + if (config.entityId) { + setData(config); + } + }, [config]); + + if (!hasAccess(ADMIN)) { + return ( + + You need to be a root admin to access this section. + + ); + } + + const updateField = (event: React.ChangeEvent) => { + setValue(event.target.name, event.target.value); + }; + + const updateEnabled = () => { + setData({ ...data, enabled: !data.enabled }); + }; + + const setValue = (name: string, value: string | boolean) => { + setData({ + ...data, + [name]: value, + }); + }; + + const onSubmit = async (event: React.SyntheticEvent) => { + event.preventDefault(); + + try { + await updateSettings(removeEmptyStringFields(data)); + setToastData({ + title: 'Settings stored', + type: 'success', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + return ( + + + + + Please read the{' '} + + documentation + {' '} + to learn how to integrate with specific SAML 2.0 + providers (Okta, Keycloak, etc).
    + Callback URL:{' '} + {uiConfig.unleashUrl}/auth/saml/callback +
    +
    +
    +
    + + + Enable +

    Enable SAML 2.0 Authentication.

    +
    + + + } + label={data.enabled ? 'Enabled' : 'Disabled'} + /> + +
    + + + Entity ID +

    (Required) The Entity Identity provider issuer.

    +
    + + + +
    + + + Single Sign-On URL +

    + (Required) The url to redirect the user to for + signing in. +

    +
    + + + +
    + + + X.509 Certificate +

    + (Required) The certificate used to sign the SAML 2.0 + request. +

    +
    + + + +
    +

    Optional Configuration

    + + + Single Sign-out URL +

    + (Optional) The url to redirect the user to for + signing out of the IDP. +

    +
    + + + +
    + + + Service Provider X.509 Certificate +

    + (Optional) The private certificate used by the + Service Provider used to sign the SAML 2.0 request + towards the IDP. E.g. used to sign single logout + requests (SLO). +

    +
    + + + +
    + + + + {' '} +

    + + {errors?.message} + +

    +
    +
    + +
    + ); +}; diff --git a/frontend/src/component/admin/billing/Billing.tsx b/frontend/src/component/admin/billing/Billing.tsx new file mode 100644 index 0000000000..2e82f5d8aa --- /dev/null +++ b/frontend/src/component/admin/billing/Billing.tsx @@ -0,0 +1,62 @@ +import AdminMenu from '../menu/AdminMenu'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { useContext, useEffect } from 'react'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import AccessContext from 'contexts/AccessContext'; +import { AdminAlert } from 'component/common/AdminAlert/AdminAlert'; +import { useInstanceStatus } from 'hooks/api/getters/useInstanceStatus/useInstanceStatus'; +import { Alert } from '@mui/material'; +import { BillingDashboard } from './BillingDashboard/BillingDashboard'; +import { BillingHistory } from './BillingHistory/BillingHistory'; +import useInvoices from 'hooks/api/getters/useInvoices/useInvoices'; + +export const Billing = () => { + const { + instanceStatus, + isBilling, + refetchInstanceStatus, + refresh, + loading, + } = useInstanceStatus(); + const { invoices } = useInvoices(); + const { hasAccess } = useContext(AccessContext); + + useEffect(() => { + const hardRefresh = async () => { + await refresh(); + refetchInstanceStatus(); + }; + hardRefresh(); + }, [refetchInstanceStatus, refresh]); + + return ( +
    + + + ( + <> + + + + )} + elseShow={() => } + /> + } + elseShow={ + + Billing is not enabled for this instance. + + } + /> + +
    + ); +}; diff --git a/frontend/src/component/admin/billing/BillingDashboard/BillingDashboard.tsx b/frontend/src/component/admin/billing/BillingDashboard/BillingDashboard.tsx new file mode 100644 index 0000000000..179f1d2bb3 --- /dev/null +++ b/frontend/src/component/admin/billing/BillingDashboard/BillingDashboard.tsx @@ -0,0 +1,20 @@ +import { Grid } from '@mui/material'; +import { IInstanceStatus } from 'interfaces/instance'; +import { VFC } from 'react'; +import { BillingInformation } from './BillingInformation/BillingInformation'; +import { BillingPlan } from './BillingPlan/BillingPlan'; + +interface IBillingDashboardProps { + instanceStatus: IInstanceStatus; +} + +export const BillingDashboard: VFC = ({ + instanceStatus, +}) => { + return ( + + + + + ); +}; diff --git a/frontend/src/component/admin/billing/BillingDashboard/BillingInformation/BillingInformation.tsx b/frontend/src/component/admin/billing/BillingDashboard/BillingInformation/BillingInformation.tsx new file mode 100644 index 0000000000..6564441ad1 --- /dev/null +++ b/frontend/src/component/admin/billing/BillingDashboard/BillingInformation/BillingInformation.tsx @@ -0,0 +1,69 @@ +import { FC } from 'react'; +import { Alert, Divider, Grid, styled, Typography } from '@mui/material'; +import { BillingInformationButton } from './BillingInformationButton/BillingInformationButton'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { IInstanceStatus, InstanceState } from 'interfaces/instance'; + +const StyledInfoBox = styled('aside')(({ theme }) => ({ + padding: theme.spacing(4), + height: '100%', + borderRadius: theme.shape.borderRadiusLarge, + backgroundColor: theme.palette.secondaryContainer, +})); + +const StyledTitle = styled(Typography)(({ theme }) => ({ + marginBottom: theme.spacing(4), +})); + +const StyledAlert = styled(Alert)(({ theme }) => ({ + marginBottom: theme.spacing(4), +})); + +const StyledInfoLabel = styled(Typography)(({ theme }) => ({ + fontSize: theme.fontSizes.smallBody, + color: theme.palette.text.secondary, +})); + +const StyledDivider = styled(Divider)(({ theme }) => ({ + margin: `${theme.spacing(2.5)} 0`, + borderColor: theme.palette.dividerAlternative, +})); +interface IBillingInformationProps { + instanceStatus: IInstanceStatus; +} + +export const BillingInformation: FC = ({ + instanceStatus, +}) => { + const inactive = instanceStatus.state !== InstanceState.ACTIVE; + + return ( + + + Billing information + + In order to Upgrade trial you need + to provide us your billing information. + + } + /> + + + {inactive + ? 'Once we have received your billing information we will upgrade your trial within 1 business day' + : 'Update your credit card and business information and change which email address we send invoices to'} + + + + + Get in touch with us + {' '} + for any clarification + + + + ); +}; diff --git a/frontend/src/component/admin/billing/BillingDashboard/BillingInformation/BillingInformationButton/BillingInformationButton.tsx b/frontend/src/component/admin/billing/BillingDashboard/BillingInformation/BillingInformationButton/BillingInformationButton.tsx new file mode 100644 index 0000000000..dd70f6a04d --- /dev/null +++ b/frontend/src/component/admin/billing/BillingDashboard/BillingInformation/BillingInformationButton/BillingInformationButton.tsx @@ -0,0 +1,25 @@ +import { Button, styled } from '@mui/material'; +import { VFC } from 'react'; +import { formatApiPath } from 'utils/formatPath'; + +const PORTAL_URL = formatApiPath('api/admin/invoices'); + +const StyledButton = styled(Button)(({ theme }) => ({ + width: '100%', + marginBottom: theme.spacing(1.5), +})); + +interface IBillingInformationButtonProps { + update?: boolean; +} + +export const BillingInformationButton: VFC = ({ + update, +}) => ( + + {update ? 'Update billing information' : 'Add billing information'} + +); diff --git a/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingPlan.tsx b/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingPlan.tsx new file mode 100644 index 0000000000..e32726c912 --- /dev/null +++ b/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingPlan.tsx @@ -0,0 +1,241 @@ +import { FC } from 'react'; +import { Alert, Divider, Grid, styled, Typography } from '@mui/material'; +import { Link } from 'react-router-dom'; +import CheckIcon from '@mui/icons-material/Check'; +import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { + IInstanceStatus, + InstanceState, + InstancePlan, +} from 'interfaces/instance'; +import { trialHasExpired, isTrialInstance } from 'utils/instanceTrial'; +import { GridRow } from 'component/common/GridRow/GridRow'; +import { GridCol } from 'component/common/GridCol/GridCol'; +import { GridColLink } from './GridColLink/GridColLink'; +import { STRIPE } from 'component/admin/billing/flags'; +import { Badge } from 'component/common/Badge/Badge'; + +const StyledPlanBox = styled('aside')(({ theme }) => ({ + padding: theme.spacing(2.5), + height: '100%', + borderRadius: theme.shape.borderRadiusLarge, + boxShadow: theme.boxShadows.elevated, + [theme.breakpoints.up('md')]: { + padding: theme.spacing(6.5), + }, +})); + +const StyledInfoLabel = styled(Typography)(({ theme }) => ({ + fontSize: theme.fontSizes.smallBody, + color: theme.palette.text.secondary, +})); + +const StyledPlanSpan = styled('span')(({ theme }) => ({ + fontSize: '3.25rem', + lineHeight: 1, + color: theme.palette.primary.main, + fontWeight: 800, +})); + +const StyledTrialSpan = styled('span')(({ theme }) => ({ + marginLeft: theme.spacing(1.5), + fontWeight: theme.fontWeight.bold, +})); + +const StyledPriceSpan = styled('span')(({ theme }) => ({ + color: theme.palette.primary.main, + fontSize: theme.fontSizes.mainHeader, + fontWeight: theme.fontWeight.bold, +})); + +const StyledAlert = styled(Alert)(({ theme }) => ({ + fontSize: theme.fontSizes.smallerBody, + marginBottom: theme.spacing(3), + marginTop: theme.spacing(-1.5), + [theme.breakpoints.up('md')]: { + marginTop: theme.spacing(-4.5), + }, +})); + +const StyledCheckIcon = styled(CheckIcon)(({ theme }) => ({ + fontSize: '1rem', + marginRight: theme.spacing(1), +})); + +const StyledDivider = styled(Divider)(({ theme }) => ({ + margin: `${theme.spacing(3)} 0`, +})); + +interface IBillingPlanProps { + instanceStatus: IInstanceStatus; +} + +export const BillingPlan: FC = ({ instanceStatus }) => { + const { users } = useUsers(); + const expired = trialHasExpired(instanceStatus); + + const price = { + [InstancePlan.PRO]: 80, + [InstancePlan.COMPANY]: 0, + [InstancePlan.TEAM]: 0, + [InstancePlan.UNKNOWN]: 0, + user: 15, + }; + + const planPrice = price[instanceStatus.plan]; + const seats = instanceStatus.seats ?? 5; + const freeAssigned = Math.min(users.length, seats); + const paidAssigned = users.length - freeAssigned; + const paidAssignedPrice = price.user * paidAssigned; + const finalPrice = planPrice + paidAssignedPrice; + const inactive = instanceStatus.state !== InstanceState.ACTIVE; + + return ( + + + + After you have sent your billing information, your + instance will be upgraded - you don't have to do + anything.{' '} + + Get in touch with us + {' '} + for any clarification + + } + /> + Current plan + + ({ marginBottom: theme.spacing(3) })}> + + + {instanceStatus.plan} + + ({ + color: expired + ? theme.palette.error.dark + : theme.palette.warning.dark, + })} + > + {expired + ? 'Trial expired' + : instanceStatus.trialExtended + ? 'Extended Trial' + : 'Trial'} + + } + /> + + + 0} + show={ + + ${planPrice.toFixed(2)} + + } + /> + + + + + + ({ + marginBottom: theme.spacing(1.5), + })} + > + + + {seats} team + members + + + {freeAssigned} assigned + + + + + + + + included + + + + + + + Paid members + + + {paidAssigned} assigned + + + + + Add up to 15 extra paid members - $ + {price.user} + /month per member + + + + ({ + fontSize: + theme.fontSizes.mainHeader, + })} + > + ${paidAssignedPrice.toFixed(2)} + + + + + + + + + ({ + fontWeight: + theme.fontWeight.bold, + fontSize: + theme.fontSizes.mainHeader, + })} + > + Total per month + + + + ({ + fontWeight: + theme.fontWeight.bold, + fontSize: '2rem', + })} + > + ${finalPrice.toFixed(2)} + + + + + + } + /> + + + ); +}; diff --git a/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/GridColLink/GridColLink.tsx b/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/GridColLink/GridColLink.tsx new file mode 100644 index 0000000000..d077a58c8b --- /dev/null +++ b/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/GridColLink/GridColLink.tsx @@ -0,0 +1,11 @@ +import { styled } from '@mui/material'; +import { FC } from 'react'; + +const StyledSpan = styled('span')(({ theme }) => ({ + fontSize: theme.fontSizes.smallBody, + marginLeft: theme.spacing(1), +})); + +export const GridColLink: FC = ({ children }) => { + return ({children}); +}; diff --git a/frontend/src/component/admin/billing/BillingHistory/BillingHistory.tsx b/frontend/src/component/admin/billing/BillingHistory/BillingHistory.tsx new file mode 100644 index 0000000000..373f8cf4c6 --- /dev/null +++ b/frontend/src/component/admin/billing/BillingHistory/BillingHistory.tsx @@ -0,0 +1,120 @@ +import { + Table, + SortableTableHeader, + TableBody, + TableCell, + TableRow, + TablePlaceholder, +} from 'component/common/Table'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; +import { useMemo, VFC } from 'react'; +import { useGlobalFilter, useSortBy, useTable } from 'react-table'; +import { sortTypes } from 'utils/sortTypes'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { Box, IconButton, styled, Typography } from '@mui/material'; +import FileDownload from '@mui/icons-material/FileDownload'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; + +const StyledTitle = styled(Typography)(({ theme }) => ({ + marginTop: theme.spacing(6), + marginBottom: theme.spacing(2.5), + fontSize: theme.fontSizes.mainHeader, +})); +interface IBillingHistoryProps { + data: Record[]; + isLoading?: boolean; +} + +const columns = [ + { + Header: 'Amount', + accessor: 'amountFormatted', + }, + { + Header: 'Status', + accessor: 'status', + disableGlobalFilter: true, + }, + { + Header: 'Due date', + accessor: 'dueDate', + Cell: DateCell, + sortType: 'date', + disableGlobalFilter: true, + }, + { + Header: 'Download', + accessor: 'invoicePDF', + align: 'center', + Cell: ({ value }: { value: string }) => ( + + + + + + ), + width: 100, + disableGlobalFilter: true, + disableSortBy: true, + }, +]; + +export const BillingHistory: VFC = ({ + data, + isLoading = false, +}) => { + const initialState = useMemo( + () => ({ + sortBy: [{ id: 'dueDate' }], + }), + [] + ); + + const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = + useTable( + { + columns, + data, + initialState, + sortTypes, + autoResetGlobalFilter: false, + disableSortRemove: true, + defaultColumn: { + Cell: TextCell, + }, + }, + useGlobalFilter, + useSortBy + ); + + return ( + + Payment history + + + + {rows.map(row => { + prepareRow(row); + return ( + + {row.cells.map(cell => ( + + {cell.render('Cell')} + + ))} + + ); + })} + +
    + No invoices to show.} + /> +
    + ); +}; diff --git a/frontend/src/component/admin/billing/FlaggedBillingRedirect/FlaggedBillingRedirect.tsx b/frontend/src/component/admin/billing/FlaggedBillingRedirect/FlaggedBillingRedirect.tsx new file mode 100644 index 0000000000..0a9d17683e --- /dev/null +++ b/frontend/src/component/admin/billing/FlaggedBillingRedirect/FlaggedBillingRedirect.tsx @@ -0,0 +1,19 @@ +import { Navigate } from 'react-router-dom'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import InvoiceAdminPage from 'component/admin/invoice/InvoiceAdminPage'; + +const FlaggedBillingRedirect = () => { + const { uiConfig, loading } = useUiConfig(); + + if (loading) { + return null; + } + + if (!uiConfig.flags.UNLEASH_CLOUD) { + return ; + } + + return ; +}; + +export default FlaggedBillingRedirect; diff --git a/frontend/src/component/admin/billing/flags.ts b/frontend/src/component/admin/billing/flags.ts new file mode 100644 index 0000000000..3ae06e3aea --- /dev/null +++ b/frontend/src/component/admin/billing/flags.ts @@ -0,0 +1 @@ +export const STRIPE = false; diff --git a/frontend/src/component/admin/cors/CorsForm.test.tsx b/frontend/src/component/admin/cors/CorsForm.test.tsx new file mode 100644 index 0000000000..93b48176e8 --- /dev/null +++ b/frontend/src/component/admin/cors/CorsForm.test.tsx @@ -0,0 +1,23 @@ +import { + parseInputValue, + formatInputValue, +} from 'component/admin/cors/CorsForm'; + +test('parseInputValue', () => { + const fn = parseInputValue; + expect(fn('')).toEqual([]); + expect(fn('a')).toEqual(['a']); + expect(fn('a\nb,,c,d,')).toEqual(['a', 'b', 'c', 'd']); + expect(fn('http://localhost:8080')).toEqual(['http://localhost:8080']); + expect(fn('https://example.com')).toEqual(['https://example.com']); + expect(fn('https://example.com/')).toEqual(['https://example.com']); + expect(fn('https://example.com/')).toEqual(['https://example.com']); +}); + +test('formatInputValue', () => { + const fn = formatInputValue; + expect(fn(undefined)).toEqual(''); + expect(fn([])).toEqual(''); + expect(fn(['a'])).toEqual('a'); + expect(fn(['a', 'b', 'c', 'd'])).toEqual('a\nb\nc\nd'); +}); diff --git a/frontend/src/component/admin/cors/CorsForm.tsx b/frontend/src/component/admin/cors/CorsForm.tsx new file mode 100644 index 0000000000..10dfd80459 --- /dev/null +++ b/frontend/src/component/admin/cors/CorsForm.tsx @@ -0,0 +1,74 @@ +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import React, { useState } from 'react'; +import { TextField, Box } from '@mui/material'; +import { UpdateButton } from 'component/common/UpdateButton/UpdateButton'; +import { useUiConfigApi } from 'hooks/api/actions/useUiConfigApi/useUiConfigApi'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { useId } from 'hooks/useId'; + +interface ICorsFormProps { + frontendApiOrigins: string[] | undefined; +} + +export const CorsForm = ({ frontendApiOrigins }: ICorsFormProps) => { + const { setFrontendSettings } = useUiConfigApi(); + const { setToastData, setToastApiError } = useToast(); + const [value, setValue] = useState(formatInputValue(frontendApiOrigins)); + const inputFieldId = useId(); + const helpTextId = useId(); + + const onSubmit = async (event: React.FormEvent) => { + try { + const split = parseInputValue(value); + event.preventDefault(); + await setFrontendSettings(split); + setValue(formatInputValue(split)); + setToastData({ title: 'Settings saved', type: 'success' }); + } catch (error) { + setToastApiError(formatUnknownError(error)); + } + }; + + return ( +
    + + + setValue(event.target.value)} + multiline + rows={12} + variant="outlined" + fullWidth + InputProps={{ + style: { fontFamily: 'monospace', fontSize: '0.8em' }, + }} + /> + + +
    + ); +}; + +export const parseInputValue = (value: string): string[] => { + return value + .split(/[,\n\s]+/) // Split by commas/newlines/spaces. + .map(value => value.replace(/\/$/, '')) // Remove trailing slashes. + .filter(Boolean); // Remove empty values from (e.g.) double newlines. +}; + +export const formatInputValue = (values: string[] | undefined): string => { + return values?.join('\n') ?? ''; +}; + +const textareaDomainsPlaceholder = [ + 'https://example.com', + 'https://example.org', +].join('\n'); diff --git a/frontend/src/component/admin/cors/CorsHelpAlert.tsx b/frontend/src/component/admin/cors/CorsHelpAlert.tsx new file mode 100644 index 0000000000..05c3623ba1 --- /dev/null +++ b/frontend/src/component/admin/cors/CorsHelpAlert.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Alert } from '@mui/material'; + +export const CorsHelpAlert = () => { + return ( + +

    + Use this page to configure allowed CORS origins for the Frontend + API (/api/frontend). +

    +

    + This configuration will not affect the Admin API ( + /api/admin) nor the Client API ( + /api/client). +

    +

    + An asterisk (*) may be used to allow API calls from + any origin. +

    +
    + ); +}; diff --git a/frontend/src/component/admin/cors/CorsTokenAlert.tsx b/frontend/src/component/admin/cors/CorsTokenAlert.tsx new file mode 100644 index 0000000000..bac1ebfba8 --- /dev/null +++ b/frontend/src/component/admin/cors/CorsTokenAlert.tsx @@ -0,0 +1,17 @@ +import { TokenType } from 'interfaces/token'; +import { Link } from 'react-router-dom'; +import { Alert } from '@mui/material'; + +export const CorsTokenAlert = () => { + return ( + + By default, all {TokenType.FRONTEND} tokens may be used from any + CORS origin. If you'd like to configure a strict set of origins, + please use the{' '} + + CORS origins configuration page + + . + + ); +}; diff --git a/frontend/src/component/admin/cors/index.tsx b/frontend/src/component/admin/cors/index.tsx new file mode 100644 index 0000000000..802696b383 --- /dev/null +++ b/frontend/src/component/admin/cors/index.tsx @@ -0,0 +1,50 @@ +import { useLocation } from 'react-router-dom'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import AdminMenu from '../menu/AdminMenu'; +import { AdminAlert } from 'component/common/AdminAlert/AdminAlert'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import AccessContext from 'contexts/AccessContext'; +import React, { useContext } from 'react'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { Box } from '@mui/material'; +import { CorsHelpAlert } from 'component/admin/cors/CorsHelpAlert'; +import { CorsForm } from 'component/admin/cors/CorsForm'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; + +export const CorsAdmin = () => { + const { pathname } = useLocation(); + const showAdminMenu = pathname.includes('/admin/'); + const { hasAccess } = useContext(AccessContext); + + return ( +
    + } + /> + } + elseShow={} + /> +
    + ); +}; + +const CorsPage = () => { + const { uiConfig, loading } = useUiConfig(); + + if (loading) { + return null; + } + + return ( + }> + + + + + + ); +}; diff --git a/frontend/src/component/admin/groups/CreateGroup/CreateGroup.tsx b/frontend/src/component/admin/groups/CreateGroup/CreateGroup.tsx new file mode 100644 index 0000000000..12661a7d51 --- /dev/null +++ b/frontend/src/component/admin/groups/CreateGroup/CreateGroup.tsx @@ -0,0 +1,116 @@ +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import { useNavigate } from 'react-router-dom'; +import { GroupForm } from '../GroupForm/GroupForm'; +import { useGroupForm } from '../hooks/useGroupForm'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import useToast from 'hooks/useToast'; +import { useGroupApi } from 'hooks/api/actions/useGroupApi/useGroupApi'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { UG_CREATE_BTN_ID } from 'utils/testIds'; +import { Button } from '@mui/material'; +import { CREATE } from 'constants/misc'; +import { GO_BACK } from 'constants/navigate'; +import { useGroups } from 'hooks/api/getters/useGroups/useGroups'; + +export const CreateGroup = () => { + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + const navigate = useNavigate(); + + const { + name, + setName, + description, + setDescription, + users, + setUsers, + getGroupPayload, + clearErrors, + errors, + setErrors, + } = useGroupForm(); + + const { groups } = useGroups(); + const { createGroup, loading } = useGroupApi(); + + const handleSubmit = async (e: Event) => { + e.preventDefault(); + clearErrors(); + + if (!isValid) return; + + const payload = getGroupPayload(); + try { + const group = await createGroup(payload); + navigate(`/admin/groups/${group.id}`); + setToastData({ + title: 'Group created successfully', + text: 'Now you can start using your group.', + confetti: true, + type: 'success', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const formatApiCode = () => { + return `curl --location --request POST '${ + uiConfig.unleashUrl + }/api/admin/groups' \\ + --header 'Authorization: INSERT_API_KEY' \\ + --header 'Content-Type: application/json' \\ + --data-raw '${JSON.stringify(getGroupPayload(), undefined, 2)}'`; + }; + + const handleCancel = () => { + navigate(GO_BACK); + }; + + const isNameEmpty = (name: string) => name.length; + const isNameUnique = (name: string) => + !groups?.filter(group => group.name === name).length; + const isValid = isNameEmpty(name) && isNameUnique(name); + + const onSetName = (name: string) => { + clearErrors(); + if (!isNameUnique(name)) { + setErrors({ name: 'A group with that name already exists.' }); + } + setName(name); + }; + + return ( + + + + + + ); +}; diff --git a/frontend/src/component/admin/groups/EditGroup/EditGroup.tsx b/frontend/src/component/admin/groups/EditGroup/EditGroup.tsx new file mode 100644 index 0000000000..a7b5285305 --- /dev/null +++ b/frontend/src/component/admin/groups/EditGroup/EditGroup.tsx @@ -0,0 +1,120 @@ +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import { useNavigate } from 'react-router-dom'; +import { GroupForm } from '../GroupForm/GroupForm'; +import { useGroupForm } from '../hooks/useGroupForm'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import useToast from 'hooks/useToast'; +import { useGroupApi } from 'hooks/api/actions/useGroupApi/useGroupApi'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { Button } from '@mui/material'; +import { EDIT } from 'constants/misc'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { useGroup } from 'hooks/api/getters/useGroup/useGroup'; +import { UG_SAVE_BTN_ID } from 'utils/testIds'; +import { GO_BACK } from 'constants/navigate'; +import { useGroups } from 'hooks/api/getters/useGroups/useGroups'; + +export const EditGroup = () => { + const groupId = Number(useRequiredPathParam('groupId')); + const { group, refetchGroup } = useGroup(groupId); + const { refetchGroups } = useGroups(); + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + const navigate = useNavigate(); + + const { + name, + setName, + description, + setDescription, + users, + setUsers, + getGroupPayload, + clearErrors, + errors, + setErrors, + } = useGroupForm(group?.name, group?.description, group?.users); + + const { groups } = useGroups(); + const { updateGroup, loading } = useGroupApi(); + + const handleSubmit = async (e: Event) => { + e.preventDefault(); + clearErrors(); + + const payload = getGroupPayload(); + try { + await updateGroup(groupId, payload); + refetchGroup(); + refetchGroups(); + navigate(GO_BACK); + setToastData({ + title: 'Group updated successfully', + type: 'success', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const formatApiCode = () => { + return `curl --location --request PUT '${ + uiConfig.unleashUrl + }/api/admin/groups/${groupId}' \\ + --header 'Authorization: INSERT_API_KEY' \\ + --header 'Content-Type: application/json' \\ + --data-raw '${JSON.stringify(getGroupPayload(), undefined, 2)}'`; + }; + + const handleCancel = () => { + navigate(GO_BACK); + }; + + const isNameEmpty = (name: string) => name.length; + const isNameUnique = (name: string) => + !groups?.filter(group => group.name === name && group.id !== groupId) + .length; + const isValid = isNameEmpty(name) && isNameUnique(name); + + const onSetName = (name: string) => { + clearErrors(); + if (!isNameUnique(name)) { + setErrors({ name: 'A group with that name already exists.' }); + } + setName(name); + }; + + return ( + + + + + + ); +}; diff --git a/frontend/src/component/admin/groups/Group/EditGroupUsers/EditGroupUsers.tsx b/frontend/src/component/admin/groups/Group/EditGroupUsers/EditGroupUsers.tsx new file mode 100644 index 0000000000..7fbdb57ac5 --- /dev/null +++ b/frontend/src/component/admin/groups/Group/EditGroupUsers/EditGroupUsers.tsx @@ -0,0 +1,144 @@ +import { Button, styled } from '@mui/material'; +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; +import { useGroupApi } from 'hooks/api/actions/useGroupApi/useGroupApi'; +import { useGroup } from 'hooks/api/getters/useGroup/useGroup'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import useToast from 'hooks/useToast'; +import { IGroup } from 'interfaces/group'; +import { FC, FormEvent, useEffect } from 'react'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { GroupFormUsersSelect } from 'component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect'; +import { GroupFormUsersTable } from 'component/admin/groups/GroupForm/GroupFormUsersTable/GroupFormUsersTable'; +import { UG_SAVE_BTN_ID } from 'utils/testIds'; +import { useGroupForm } from 'component/admin/groups/hooks/useGroupForm'; +import { useGroups } from 'hooks/api/getters/useGroups/useGroups'; + +const StyledForm = styled('form')(() => ({ + display: 'flex', + flexDirection: 'column', + height: '100%', +})); + +const StyledInputDescription = styled('p')(({ theme }) => ({ + color: theme.palette.text.secondary, + marginBottom: theme.spacing(1), +})); + +const StyledButtonContainer = styled('div')(() => ({ + marginTop: 'auto', + display: 'flex', + justifyContent: 'flex-end', +})); + +const StyledCancelButton = styled(Button)(({ theme }) => ({ + marginLeft: theme.spacing(3), +})); + +interface IEditGroupUsersProps { + open: boolean; + setOpen: React.Dispatch>; + group: IGroup; +} + +export const EditGroupUsers: FC = ({ + open, + setOpen, + group, +}) => { + const { refetchGroup } = useGroup(group.id); + const { refetchGroups } = useGroups(); + const { updateGroup, loading } = useGroupApi(); + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + + const { users, setUsers, getGroupPayload } = useGroupForm( + group.name, + group.description, + group.users + ); + + useEffect(() => { + setUsers(group.users); + }, [group.users, open, setUsers]); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + try { + await updateGroup(group.id, getGroupPayload()); + refetchGroup(); + refetchGroups(); + setOpen(false); + setToastData({ + title: 'Group users saved successfully', + type: 'success', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const formatApiCode = () => { + return `curl --location --request PUT '${ + uiConfig.unleashUrl + }/api/admin/groups/${group.id}' \\ + --header 'Authorization: INSERT_API_KEY' \\ + --header 'Content-Type: application/json' \\ + --data-raw '${JSON.stringify(getGroupPayload(), undefined, 2)}'`; + }; + + return ( + { + setOpen(false); + }} + label="Edit users" + > + + +
    + + Edit users in this group + + + +
    + + + + { + setOpen(false); + }} + > + Cancel + + +
    +
    +
    + ); +}; diff --git a/frontend/src/component/admin/groups/Group/Group.tsx b/frontend/src/component/admin/groups/Group/Group.tsx new file mode 100644 index 0000000000..231fca285c --- /dev/null +++ b/frontend/src/component/admin/groups/Group/Group.tsx @@ -0,0 +1,366 @@ +import { useEffect, useMemo, useState, VFC } from 'react'; +import { + IconButton, + styled, + Tooltip, + useMediaQuery, + useTheme, +} from '@mui/material'; +import { useSearchParams, Link } from 'react-router-dom'; +import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table'; +import { TablePlaceholder, VirtualizedTable } from 'component/common/Table'; +import { useGroup } from 'hooks/api/getters/useGroup/useGroup'; +import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { sortTypes } from 'utils/sortTypes'; +import { createLocalStorage } from 'utils/createLocalStorage'; +import { IGroupUser } from 'interfaces/group'; +import { useSearch } from 'hooks/useSearch'; +import { Search } from 'component/common/Search/Search'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; +import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell'; +import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell'; +import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; +import { Add, Delete, Edit } from '@mui/icons-material'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import { MainHeader } from 'component/common/MainHeader/MainHeader'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { RemoveGroup } from 'component/admin/groups/RemoveGroup/RemoveGroup'; +import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell'; +import { EditGroupUsers } from './EditGroupUsers/EditGroupUsers'; +import { RemoveGroupUser } from './RemoveGroupUser/RemoveGroupUser'; +import { UserAvatar } from 'component/common/UserAvatar/UserAvatar'; +import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton'; +import { + UG_EDIT_BTN_ID, + UG_DELETE_BTN_ID, + UG_EDIT_USERS_BTN_ID, + UG_REMOVE_USER_BTN_ID, +} from 'utils/testIds'; + +const StyledEdit = styled(Edit)(({ theme }) => ({ + fontSize: theme.fontSizes.mainHeader, +})); + +const StyledDelete = styled(Delete)(({ theme }) => ({ + fontSize: theme.fontSizes.mainHeader, +})); + +export const groupUsersPlaceholder: IGroupUser[] = Array(15).fill({ + name: 'Name of the user', + username: 'Username of the user', +}); + +export type PageQueryType = Partial< + Record<'sort' | 'order' | 'search', string> +>; + +const defaultSort: SortingRule = { id: 'joinedAt' }; + +const { value: storedParams, setValue: setStoredParams } = createLocalStorage( + 'Group:v1', + defaultSort +); + +export const Group: VFC = () => { + const groupId = Number(useRequiredPathParam('groupId')); + const theme = useTheme(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + const { group, loading } = useGroup(groupId); + const [removeOpen, setRemoveOpen] = useState(false); + const [editUsersOpen, setEditUsersOpen] = useState(false); + const [removeUserOpen, setRemoveUserOpen] = useState(false); + const [selectedUser, setSelectedUser] = useState(); + + const columns = useMemo( + () => [ + { + Header: 'Avatar', + accessor: 'imageUrl', + Cell: ({ row: { original: user } }: any) => ( + + + + ), + maxWidth: 85, + disableSortBy: true, + }, + { + id: 'name', + Header: 'Name', + accessor: (row: IGroupUser) => row.name || '', + Cell: HighlightCell, + minWidth: 100, + searchable: true, + }, + { + id: 'username', + Header: 'Username', + accessor: (row: IGroupUser) => row.username || row.email, + Cell: HighlightCell, + minWidth: 100, + searchable: true, + }, + { + Header: 'Joined', + accessor: 'joinedAt', + Cell: DateCell, + sortType: 'date', + maxWidth: 150, + }, + { + Header: 'Last login', + accessor: (row: IGroupUser) => row.seenAt || '', + Cell: ({ row: { original: user } }: any) => ( + + ), + sortType: 'date', + maxWidth: 150, + }, + { + Header: 'Actions', + id: 'Actions', + align: 'center', + Cell: ({ row: { original: rowUser } }: any) => ( + + + + { + setSelectedUser(rowUser); + setRemoveUserOpen(true); + }} + > + + + + + + ), + maxWidth: 100, + disableSortBy: true, + }, + ], + [setSelectedUser, setRemoveUserOpen] + ); + + const [searchParams, setSearchParams] = useSearchParams(); + const [initialState] = useState(() => ({ + sortBy: [ + { + id: searchParams.get('sort') || storedParams.id, + desc: searchParams.has('order') + ? searchParams.get('order') === 'desc' + : storedParams.desc, + }, + ], + hiddenColumns: ['description'], + globalFilter: searchParams.get('search') || '', + })); + const [searchValue, setSearchValue] = useState(initialState.globalFilter); + + const { + data: searchedData, + getSearchText, + getSearchContext, + } = useSearch(columns, searchValue, group?.users ?? []); + + const data = useMemo( + () => + searchedData?.length === 0 && loading + ? groupUsersPlaceholder + : searchedData, + [searchedData, loading] + ); + + const { + headerGroups, + rows, + prepareRow, + state: { sortBy }, + } = useTable( + { + columns: columns as any[], + data, + initialState, + sortTypes, + autoResetSortBy: false, + disableSortRemove: true, + disableMultiSort: true, + }, + useSortBy, + useFlexLayout + ); + + useEffect(() => { + const tableState: PageQueryType = {}; + tableState.sort = sortBy[0].id; + if (sortBy[0].desc) { + tableState.order = 'desc'; + } + if (searchValue) { + tableState.search = searchValue; + } + + setSearchParams(tableState, { + replace: true, + }); + setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false }); + }, [sortBy, searchValue, setSearchParams]); + + return ( + + + + + + setRemoveOpen(true)} + permission={ADMIN} + tooltipProps={{ + title: 'Delete group', + }} + > + + + + } + /> + + + + + + } + /> + { + setEditUsersOpen(true); + }} + maxWidth="700px" + Icon={Add} + permission={ADMIN} + > + Edit users + + + } + > + + } + /> + + } + > + + + + 0} + show={ + + No users found matching “ + {searchValue} + ” in this group. + + } + elseShow={ + + This group is empty. Get started by + adding a user to the group. + + } + /> + } + /> + + + + + + } + /> + ); +}; diff --git a/frontend/src/component/admin/groups/Group/RemoveGroupUser/RemoveGroupUser.tsx b/frontend/src/component/admin/groups/Group/RemoveGroupUser/RemoveGroupUser.tsx new file mode 100644 index 0000000000..1a970e8dd5 --- /dev/null +++ b/frontend/src/component/admin/groups/Group/RemoveGroupUser/RemoveGroupUser.tsx @@ -0,0 +1,68 @@ +import { Typography } from '@mui/material'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import { useGroupApi } from 'hooks/api/actions/useGroupApi/useGroupApi'; +import { useGroup } from 'hooks/api/getters/useGroup/useGroup'; +import useToast from 'hooks/useToast'; +import { IGroup, IGroupUser } from 'interfaces/group'; +import { FC } from 'react'; +import { formatUnknownError } from 'utils/formatUnknownError'; + +interface IRemoveGroupUserProps { + open: boolean; + setOpen: React.Dispatch>; + user?: IGroupUser; + group: IGroup; +} + +export const RemoveGroupUser: FC = ({ + open, + setOpen, + user, + group, +}) => { + const { refetchGroup } = useGroup(group.id); + const { updateGroup } = useGroupApi(); + const { setToastData, setToastApiError } = useToast(); + + const onRemoveClick = async () => { + try { + const groupPayload = { + ...group, + users: group.users + .filter(({ id }) => id !== user?.id) + .map(({ id }) => ({ + user: { id }, + })), + }; + await updateGroup(group.id, groupPayload); + refetchGroup(); + setOpen(false); + setToastData({ + title: 'User removed from group successfully', + type: 'success', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const userName = user?.name || user?.username || user?.email; + return ( + { + setOpen(false); + }} + title="Remove user from group?" + > + + Do you really want to remove {userName} from{' '} + {group.name}? {userName} will + lose all access rights granted by this group. + + + ); +}; diff --git a/frontend/src/component/admin/groups/GroupForm/GroupForm.tsx b/frontend/src/component/admin/groups/GroupForm/GroupForm.tsx new file mode 100644 index 0000000000..704583770b --- /dev/null +++ b/frontend/src/component/admin/groups/GroupForm/GroupForm.tsx @@ -0,0 +1,124 @@ +import { FC } from 'react'; +import { Button, styled } from '@mui/material'; +import { UG_DESC_ID, UG_NAME_ID } from 'utils/testIds'; +import Input from 'component/common/Input/Input'; +import { IGroupUser } from 'interfaces/group'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { GroupFormUsersSelect } from './GroupFormUsersSelect/GroupFormUsersSelect'; +import { GroupFormUsersTable } from './GroupFormUsersTable/GroupFormUsersTable'; + +const StyledForm = styled('form')(() => ({ + display: 'flex', + flexDirection: 'column', + height: '100%', +})); + +const StyledInputDescription = styled('p')(({ theme }) => ({ + color: theme.palette.text.secondary, + marginBottom: theme.spacing(1), +})); + +const StyledInput = styled(Input)(({ theme }) => ({ + width: '100%', + maxWidth: theme.spacing(50), + marginBottom: theme.spacing(2), +})); + +const StyledGroupFormUsersTableWrapper = styled('div')(({ theme }) => ({ + marginBottom: theme.spacing(6), +})); + +const StyledButtonContainer = styled('div')(() => ({ + marginTop: 'auto', + display: 'flex', + justifyContent: 'flex-end', +})); + +const StyledCancelButton = styled(Button)(({ theme }) => ({ + marginLeft: theme.spacing(3), +})); + +interface IGroupForm { + name: string; + description: string; + users: IGroupUser[]; + setName: (name: string) => void; + setDescription: React.Dispatch>; + setUsers: React.Dispatch>; + handleSubmit: (e: any) => void; + handleCancel: () => void; + errors: { [key: string]: string }; + mode: 'Create' | 'Edit'; +} + +export const GroupForm: FC = ({ + name, + description, + users, + setName, + setDescription, + setUsers, + handleSubmit, + handleCancel, + errors, + mode, + children, +}) => ( + +
    + + What would you like to call your group? + + setName(e.target.value)} + data-testid={UG_NAME_ID} + required + /> + + How would you describe your group? + + setDescription(e.target.value)} + data-testid={UG_DESC_ID} + /> + + + Add users to this group + + + + + + + } + /> +
    + + + {children} + + Cancel + + +
    +); diff --git a/frontend/src/component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect.tsx b/frontend/src/component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect.tsx new file mode 100644 index 0000000000..e17f558b4e --- /dev/null +++ b/frontend/src/component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect.tsx @@ -0,0 +1,109 @@ +import { Autocomplete, Checkbox, styled, TextField } from '@mui/material'; +import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'; +import CheckBoxIcon from '@mui/icons-material/CheckBox'; +import { IUser } from 'interfaces/user'; +import { VFC } from 'react'; +import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; +import { IGroupUser } from 'interfaces/group'; +import { UG_USERS_ID } from 'utils/testIds'; + +const StyledOption = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + '& > span:first-of-type': { + color: theme.palette.text.secondary, + }, +})); + +const StyledTags = styled('div')(({ theme }) => ({ + paddingLeft: theme.spacing(1), +})); + +const StyledGroupFormUsersSelect = styled('div')(({ theme }) => ({ + display: 'flex', + marginBottom: theme.spacing(3), + '& > div:first-of-type': { + width: '100%', + maxWidth: theme.spacing(50), + marginRight: theme.spacing(1), + }, +})); + +const renderOption = ( + props: React.HTMLAttributes, + option: IUser, + selected: boolean +) => ( +
  • + } + checkedIcon={} + style={{ marginRight: 8 }} + checked={selected} + /> + + {option.name || option.username} + {option.email} + +
  • +); + +const renderTags = (value: IGroupUser[]) => ( + + {value.length > 1 + ? `${value.length} users selected` + : value[0].name || value[0].username || value[0].email} + +); + +interface IGroupFormUsersSelectProps { + users: IGroupUser[]; + setUsers: React.Dispatch>; +} + +export const GroupFormUsersSelect: VFC = ({ + users, + setUsers, +}) => { + const { users: usersAll } = useUsers(); + + return ( + + { + if ( + event.type === 'keydown' && + (event as React.KeyboardEvent).key === 'Backspace' && + reason === 'removeOption' + ) { + return; + } + setUsers(newValue); + }} + options={[...usersAll].sort((a, b) => { + const aName = a.name || a.username || ''; + const bName = b.name || b.username || ''; + return aName.localeCompare(bName); + })} + renderOption={(props, option, { selected }) => + renderOption(props, option as IUser, selected) + } + isOptionEqualToValue={(option, value) => option.id === value.id} + getOptionLabel={(option: IUser) => + option.email || option.name || option.username || '' + } + renderInput={params => ( + + )} + renderTags={value => renderTags(value)} + /> + + ); +}; diff --git a/frontend/src/component/admin/groups/GroupForm/GroupFormUsersTable/GroupFormUsersTable.tsx b/frontend/src/component/admin/groups/GroupForm/GroupFormUsersTable/GroupFormUsersTable.tsx new file mode 100644 index 0000000000..64aff7d552 --- /dev/null +++ b/frontend/src/component/admin/groups/GroupForm/GroupFormUsersTable/GroupFormUsersTable.tsx @@ -0,0 +1,117 @@ +import { useMemo, VFC } from 'react'; +import { IconButton, Tooltip, useMediaQuery } from '@mui/material'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; +import { IGroupUser } from 'interfaces/group'; +import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell'; +import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell'; +import { Delete } from '@mui/icons-material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { VirtualizedTable } from 'component/common/Table'; +import { useFlexLayout, useSortBy, useTable } from 'react-table'; +import { sortTypes } from 'utils/sortTypes'; +import { UserAvatar } from 'component/common/UserAvatar/UserAvatar'; +import theme from 'themes/theme'; +import useHiddenColumns from 'hooks/useHiddenColumns'; + +const hiddenColumnsSmall = ['imageUrl', 'name']; + +interface IGroupFormUsersTableProps { + users: IGroupUser[]; + setUsers: React.Dispatch>; +} + +export const GroupFormUsersTable: VFC = ({ + users, + setUsers, +}) => { + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + + const columns = useMemo( + () => [ + { + Header: 'Avatar', + accessor: 'imageUrl', + Cell: ({ row: { original: user } }: any) => ( + + + + ), + maxWidth: 85, + disableSortBy: true, + }, + { + id: 'name', + Header: 'Name', + accessor: (row: IGroupUser) => row.name || '', + Cell: HighlightCell, + minWidth: 100, + searchable: true, + }, + { + id: 'username', + Header: 'Username', + accessor: (row: IGroupUser) => row.username || row.email, + Cell: HighlightCell, + minWidth: 100, + searchable: true, + }, + { + Header: 'Action', + id: 'Action', + align: 'center', + Cell: ({ row: { original: rowUser } }: any) => ( + + + + setUsers((users: IGroupUser[]) => + users.filter( + user => user.id !== rowUser.id + ) + ) + } + > + + + + + ), + maxWidth: 100, + disableSortBy: true, + }, + ], + [setUsers] + ); + + const { headerGroups, rows, prepareRow, setHiddenColumns } = useTable( + { + columns: columns as any[], + data: users as any[], + sortTypes, + autoResetSortBy: false, + disableSortRemove: true, + disableMultiSort: true, + }, + useSortBy, + useFlexLayout + ); + + useHiddenColumns(setHiddenColumns, hiddenColumnsSmall, isSmallScreen); + + return ( + 0} + show={ + + } + /> + ); +}; diff --git a/frontend/src/component/admin/groups/GroupsAdmin.tsx b/frontend/src/component/admin/groups/GroupsAdmin.tsx new file mode 100644 index 0000000000..ffe8b39814 --- /dev/null +++ b/frontend/src/component/admin/groups/GroupsAdmin.tsx @@ -0,0 +1,11 @@ +import { GroupsList } from './GroupsList/GroupsList'; +import AdminMenu from '../menu/AdminMenu'; + +export const GroupsAdmin = () => { + return ( +
    + + +
    + ); +}; diff --git a/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCard.tsx b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCard.tsx new file mode 100644 index 0000000000..b9f0cc5d13 --- /dev/null +++ b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCard.tsx @@ -0,0 +1,165 @@ +import { styled, Tooltip } from '@mui/material'; +import { IGroup } from 'interfaces/group'; +import { Link, useNavigate } from 'react-router-dom'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { GroupCardAvatars } from './GroupCardAvatars/GroupCardAvatars'; +import { Badge } from 'component/common/Badge/Badge'; +import { GroupCardActions } from './GroupCardActions/GroupCardActions'; +import { RemoveGroup } from 'component/admin/groups/RemoveGroup/RemoveGroup'; +import { useState } from 'react'; +import TopicOutlinedIcon from '@mui/icons-material/TopicOutlined'; +import { EditGroupUsers } from 'component/admin/groups/Group/EditGroupUsers/EditGroupUsers'; + +const StyledLink = styled(Link)(({ theme }) => ({ + textDecoration: 'none', + color: theme.palette.text.primary, +})); + +const StyledGroupCard = styled('aside')(({ theme }) => ({ + padding: theme.spacing(2.5), + height: '100%', + border: `1px solid ${theme.palette.dividerAlternative}`, + borderRadius: theme.shape.borderRadiusLarge, + boxShadow: theme.boxShadows.card, + display: 'flex', + flexDirection: 'column', + [theme.breakpoints.up('md')]: { + padding: theme.spacing(4), + }, + '&:hover': { + transition: 'background-color 0.2s ease-in-out', + backgroundColor: theme.palette.neutral.light, + }, +})); + +const StyledRow = styled('div')(() => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', +})); + +const StyledTitleRow = styled(StyledRow)(() => ({ + alignItems: 'flex-start', +})); + +const StyledBottomRow = styled(StyledRow)(() => ({ + marginTop: 'auto', +})); + +const StyledHeaderTitle = styled('h2')(({ theme }) => ({ + fontSize: theme.fontSizes.mainHeader, + fontWeight: theme.fontWeight.medium, +})); + +const StyledHeaderActions = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + color: theme.palette.text.secondary, + fontSize: theme.fontSizes.smallBody, +})); + +const StyledDescription = styled('p')(({ theme }) => ({ + color: theme.palette.text.secondary, + fontSize: theme.fontSizes.smallBody, + marginTop: theme.spacing(1), + marginBottom: theme.spacing(4), +})); + +const StyledCounterDescription = styled('span')(({ theme }) => ({ + color: theme.palette.text.secondary, + marginLeft: theme.spacing(1), +})); + +const ProjectBadgeContainer = styled('div')(() => ({ + maxWidth: '50%', +})); + +const StyledBadge = styled(Badge)(() => ({ + marginRight: 0.5, +})); + +interface IGroupCardProps { + group: IGroup; +} + +export const GroupCard = ({ group }: IGroupCardProps) => { + const [editUsersOpen, setEditUsersOpen] = useState(false); + const [removeOpen, setRemoveOpen] = useState(false); + const navigate = useNavigate(); + return ( + <> + + + + {group.name} + + setEditUsersOpen(true)} + onRemove={() => setRemoveOpen(true)} + /> + + + {group.description} + + 0} + show={} + elseShow={ + + This group has no users. + + } + /> + + 0} + show={group.projects.map(project => ( + + { + e.preventDefault(); + navigate( + `/projects/${project}/access` + ); + }} + color="secondary" + icon={} + > + {project} + + + ))} + elseShow={ + + Not used + + } + /> + + + + + + + + ); +}; diff --git a/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardActions/GroupCardActions.tsx b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardActions/GroupCardActions.tsx new file mode 100644 index 0000000000..c4a277bb54 --- /dev/null +++ b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardActions/GroupCardActions.tsx @@ -0,0 +1,125 @@ +import { FC, useState } from 'react'; +import { + IconButton, + ListItemIcon, + ListItemText, + MenuItem, + MenuList, + Popover, + styled, + Tooltip, + Typography, +} from '@mui/material'; +import { Delete, Edit, GroupRounded, MoreVert } from '@mui/icons-material'; +import { Link } from 'react-router-dom'; + +const StyledActions = styled('div')(({ theme }) => ({ + display: 'flex', + justifyContent: 'center', +})); + +const StyledPopover = styled(Popover)(({ theme }) => ({ + borderRadius: theme.shape.borderRadiusLarge, + padding: theme.spacing(1, 1.5), +})); + +interface IGroupCardActions { + groupId: number; + onEditUsers: () => void; + onRemove: () => void; +} + +export const GroupCardActions: FC = ({ + groupId, + onEditUsers, + onRemove, +}) => { + const [anchorEl, setAnchorEl] = useState(null); + + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + + const id = `feature-${groupId}-actions`; + const menuId = `${id}-menu`; + + return ( + { + e.preventDefault(); + e.stopPropagation(); + }} + > + + + + + + + + + + + + + Edit group + + + { + onEditUsers(); + handleClose(); + }} + > + + + + + + Edit group users + + + + { + onRemove(); + handleClose(); + }} + > + + + + + + Delete group + + + + + + + ); +}; diff --git a/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/GroupCardAvatars.tsx b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/GroupCardAvatars.tsx new file mode 100644 index 0000000000..3b13b4c190 --- /dev/null +++ b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/GroupCardAvatars.tsx @@ -0,0 +1,78 @@ +import { styled } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { IGroupUser } from 'interfaces/group'; +import React, { useMemo, useState } from 'react'; +import { GroupPopover } from './GroupPopover/GroupPopover'; +import { UserAvatar } from 'component/common/UserAvatar/UserAvatar'; + +const StyledAvatars = styled('div')(({ theme }) => ({ + display: 'inline-flex', + alignItems: 'center', + flexWrap: 'wrap', + marginLeft: theme.spacing(1), +})); + +const StyledAvatar = styled(UserAvatar)(({ theme }) => ({ + outline: `${theme.spacing(0.25)} solid ${theme.palette.background.paper}`, + marginLeft: theme.spacing(-1), + '&:hover': { + outlineColor: theme.palette.primary.main, + }, +})); + +interface IGroupCardAvatarsProps { + users: IGroupUser[]; +} + +export const GroupCardAvatars = ({ users }: IGroupCardAvatarsProps) => { + const shownUsers = useMemo( + () => + users + .sort((a, b) => b?.joinedAt!.getTime() - a?.joinedAt!.getTime()) + .slice(0, 9), + [users] + ); + + const [anchorEl, setAnchorEl] = useState(null); + const [popupUser, setPopupUser] = useState(); + + const onPopoverOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const onPopoverClose = () => { + setAnchorEl(null); + }; + + const avatarOpen = Boolean(anchorEl); + + return ( + + {shownUsers.map(user => ( + { + onPopoverOpen(event); + setPopupUser(user); + }} + onMouseLeave={onPopoverClose} + /> + ))} + 9} + show={ + + +{users.length - shownUsers.length} + + } + /> + + + ); +}; diff --git a/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/GroupPopover/GroupPopover.tsx b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/GroupPopover/GroupPopover.tsx new file mode 100644 index 0000000000..b9a50d1442 --- /dev/null +++ b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/GroupPopover/GroupPopover.tsx @@ -0,0 +1,50 @@ +import { Popover, styled } from '@mui/material'; +import { IGroupUser } from 'interfaces/group'; + +const StyledPopover = styled(Popover)(({ theme }) => ({ + pointerEvents: 'none', + '.MuiPaper-root': { + padding: theme.spacing(2), + }, +})); + +const StyledName = styled('div')(({ theme }) => ({ + color: theme.palette.text.secondary, + fontSize: theme.fontSizes.smallBody, + marginTop: theme.spacing(1), +})); + +interface IGroupPopoverProps { + user: IGroupUser | undefined; + + open: boolean; + anchorEl: HTMLElement | null; + + onPopoverClose(event: React.MouseEvent): void; +} + +export const GroupPopover = ({ + user, + open, + anchorEl, + onPopoverClose, +}: IGroupPopoverProps) => { + return ( + + {user?.name || user?.username} +
    {user?.email}
    +
    + ); +}; diff --git a/frontend/src/component/admin/groups/GroupsList/GroupEmpty/GroupEmpty.tsx b/frontend/src/component/admin/groups/GroupsList/GroupEmpty/GroupEmpty.tsx new file mode 100644 index 0000000000..768127474d --- /dev/null +++ b/frontend/src/component/admin/groups/GroupsList/GroupEmpty/GroupEmpty.tsx @@ -0,0 +1,35 @@ +import { Button, styled, Typography } from '@mui/material'; +import { Link } from 'react-router-dom'; + +export const GroupEmpty = () => { + const StyledContainerDiv = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + margin: theme.spacing(6), + marginLeft: 'auto', + marginRight: 'auto', + })); + + const StyledTitle = styled(Typography)(({ theme }) => ({ + fontSize: theme.fontSizes.bodySize, + marginBottom: theme.spacing(2.5), + })); + + return ( + + + No groups available. Get started by adding a new group. + + + + ); +}; diff --git a/frontend/src/component/admin/groups/GroupsList/GroupsList.tsx b/frontend/src/component/admin/groups/GroupsList/GroupsList.tsx new file mode 100644 index 0000000000..d47238784f --- /dev/null +++ b/frontend/src/component/admin/groups/GroupsList/GroupsList.tsx @@ -0,0 +1,141 @@ +import { useEffect, useMemo, useState, VFC } from 'react'; +import { useGroups } from 'hooks/api/getters/useGroups/useGroups'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { IGroup } from 'interfaces/group'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { Search } from 'component/common/Search/Search'; +import { Grid, useMediaQuery } from '@mui/material'; +import theme from 'themes/theme'; +import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { TablePlaceholder } from 'component/common/Table'; +import { GroupCard } from './GroupCard/GroupCard'; +import { GroupEmpty } from './GroupEmpty/GroupEmpty'; +import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import { Add } from '@mui/icons-material'; +import { NAVIGATE_TO_CREATE_GROUP } from 'utils/testIds'; + +type PageQueryType = Partial>; + +const groupsSearch = (group: IGroup, searchValue: string) => { + const search = searchValue.toLowerCase(); + const users = { + names: group.users?.map(user => user.name?.toLowerCase() || ''), + usernames: group.users?.map(user => user.username?.toLowerCase() || ''), + emails: group.users?.map(user => user.email?.toLowerCase() || ''), + }; + return ( + group.name.toLowerCase().includes(search) || + group.description.toLowerCase().includes(search) || + users.names?.some(name => name.includes(search)) || + users.usernames?.some(username => username.includes(search)) || + users.emails?.some(email => email.includes(search)) + ); +}; + +export const GroupsList: VFC = () => { + const navigate = useNavigate(); + const { groups = [], loading } = useGroups(); + const [searchParams, setSearchParams] = useSearchParams(); + const [searchValue, setSearchValue] = useState( + searchParams.get('search') || '' + ); + + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + + useEffect(() => { + const tableState: PageQueryType = {}; + if (searchValue) { + tableState.search = searchValue; + } + + setSearchParams(tableState, { + replace: true, + }); + }, [searchValue, setSearchParams]); + + const data = useMemo(() => { + const sortedGroups = groups.sort((a, b) => + a.name.localeCompare(b.name) + ); + return searchValue + ? sortedGroups.filter(group => groupsSearch(group, searchValue)) + : sortedGroups; + }, [groups, searchValue]); + + return ( + + + + + + } + /> + + navigate('/admin/groups/create-group') + } + maxWidth="700px" + Icon={Add} + permission={ADMIN} + data-testid={NAVIGATE_TO_CREATE_GROUP} + > + New group + + + } + > + + } + /> + + } + > + + + {data.map(group => ( + + + + ))} + + + 0} + show={ + + No groups found matching “ + {searchValue} + ” + + } + elseShow={} + /> + } + /> + + ); +}; diff --git a/frontend/src/component/admin/groups/RemoveGroup/RemoveGroup.tsx b/frontend/src/component/admin/groups/RemoveGroup/RemoveGroup.tsx new file mode 100644 index 0000000000..d6f1544697 --- /dev/null +++ b/frontend/src/component/admin/groups/RemoveGroup/RemoveGroup.tsx @@ -0,0 +1,60 @@ +import { Typography } from '@mui/material'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import { useGroupApi } from 'hooks/api/actions/useGroupApi/useGroupApi'; +import { useGroups } from 'hooks/api/getters/useGroups/useGroups'; +import useToast from 'hooks/useToast'; +import { IGroup } from 'interfaces/group'; +import { FC } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { formatUnknownError } from 'utils/formatUnknownError'; + +interface IRemoveGroupProps { + open: boolean; + setOpen: React.Dispatch>; + group: IGroup; +} + +export const RemoveGroup: FC = ({ + open, + setOpen, + group, +}) => { + const { refetchGroups } = useGroups(); + const { removeGroup } = useGroupApi(); + const { setToastData, setToastApiError } = useToast(); + const navigate = useNavigate(); + + const onRemoveClick = async () => { + try { + await removeGroup(group.id); + refetchGroups(); + setOpen(false); + navigate('/admin/groups'); + setToastData({ + title: 'Group removed successfully', + type: 'success', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + return ( + { + setOpen(false); + }} + title="Delete group?" + > + + Do you really want to delete {group.name}? + Users who are granted access to projects only via this group + will lose access to those projects. + + + ); +}; diff --git a/frontend/src/component/admin/groups/hooks/useGroupForm.ts b/frontend/src/component/admin/groups/hooks/useGroupForm.ts new file mode 100644 index 0000000000..da4b5c4adf --- /dev/null +++ b/frontend/src/component/admin/groups/hooks/useGroupForm.ts @@ -0,0 +1,43 @@ +import { useState } from 'react'; +import useQueryParams from 'hooks/useQueryParams'; +import { IGroupUser } from 'interfaces/group'; + +export const useGroupForm = ( + initialName = '', + initialDescription = '', + initialUsers: IGroupUser[] = [] +) => { + const params = useQueryParams(); + const groupQueryName = params.get('name'); + const [name, setName] = useState(groupQueryName || initialName); + const [description, setDescription] = useState(initialDescription); + const [users, setUsers] = useState(initialUsers); + const [errors, setErrors] = useState({}); + + const getGroupPayload = () => { + return { + name, + description, + users: users.map(({ id }) => ({ + user: { id }, + })), + }; + }; + + const clearErrors = () => { + setErrors({}); + }; + + return { + name, + setName, + description, + setDescription, + users, + setUsers, + getGroupPayload, + clearErrors, + errors, + setErrors, + }; +}; diff --git a/frontend/src/component/admin/index.tsx b/frontend/src/component/admin/index.tsx new file mode 100644 index 0000000000..6605f52f9f --- /dev/null +++ b/frontend/src/component/admin/index.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Navigate } from 'react-router-dom'; + +const render = () => ; + +render.propTypes = { + match: PropTypes.object.isRequired, + history: PropTypes.object.isRequired, +}; + +export default render; diff --git a/frontend/src/component/admin/invoice/InvoiceAdminPage.tsx b/frontend/src/component/admin/invoice/InvoiceAdminPage.tsx new file mode 100644 index 0000000000..f9197c0df6 --- /dev/null +++ b/frontend/src/component/admin/invoice/InvoiceAdminPage.tsx @@ -0,0 +1,25 @@ +import { useContext } from 'react'; +import InvoiceList from './InvoiceList'; +import AccessContext from 'contexts/AccessContext'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { Alert } from '@mui/material'; + +const InvoiceAdminPage = () => { + const { hasAccess } = useContext(AccessContext); + return ( +
    + } + elseShow={ + + You need to be instance admin to access this section. + + } + /> +
    + ); +}; + +export default InvoiceAdminPage; diff --git a/frontend/src/component/admin/invoice/InvoiceList.tsx b/frontend/src/component/admin/invoice/InvoiceList.tsx new file mode 100644 index 0000000000..136706ca32 --- /dev/null +++ b/frontend/src/component/admin/invoice/InvoiceList.tsx @@ -0,0 +1,122 @@ +import { useEffect, useState } from 'react'; +import { + Table, + TableHead, + TableBody, + TableRow, + TableCell, + Button, +} from '@mui/material'; +import OpenInNew from '@mui/icons-material/OpenInNew'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { formatApiPath } from 'utils/formatPath'; +import useInvoices from 'hooks/api/getters/useInvoices/useInvoices'; +import { IInvoice } from 'interfaces/invoice'; +import { useLocationSettings } from 'hooks/useLocationSettings'; +import { formatDateYMD } from 'utils/formatDate'; + +const PORTAL_URL = formatApiPath('api/admin/invoices/portal'); + +const InvoiceList = () => { + const { refetchInvoices, invoices } = useInvoices(); + const [isLoaded, setLoaded] = useState(false); + const { locationSettings } = useLocationSettings(); + + useEffect(() => { + refetchInvoices(); + setLoaded(true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + 0} + show={ + } + > + Billing portal + + } + /> + } + > +
    + + + + Amount + Status + Due date + PDF + Link + + + + {invoices.map((item: IInvoice) => ( + + + {item.amountFormatted} + + + {item.status} + + + {item.dueDate && + formatDateYMD( + item.dueDate, + locationSettings.locale + )} + + + PDF + + + + Payment link + + + + ))} + +
    +
    +
    + } + elseShow={
    {isLoaded && 'No invoices to show.'}
    } + /> + ); +}; +export default InvoiceList; diff --git a/frontend/src/component/admin/menu/AdminMenu.tsx b/frontend/src/component/admin/menu/AdminMenu.tsx new file mode 100644 index 0000000000..f85102eb9c --- /dev/null +++ b/frontend/src/component/admin/menu/AdminMenu.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { NavLink, useLocation } from 'react-router-dom'; +import { Paper, Tab, Tabs } from '@mui/material'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { useInstanceStatus } from 'hooks/api/getters/useInstanceStatus/useInstanceStatus'; + +const navLinkStyle = { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + width: '100%', + textDecoration: 'none', + color: 'inherit', + padding: '0.8rem 1.5rem', +}; + +const activeNavLinkStyle: React.CSSProperties = { + fontWeight: 'bold', + borderRadius: '3px', + padding: '0.8rem 1.5rem', +}; + +const createNavLinkStyle = (props: { + isActive: boolean; +}): React.CSSProperties => { + return props.isActive + ? { ...navLinkStyle, ...activeNavLinkStyle } + : navLinkStyle; +}; + +function AdminMenu() { + const { uiConfig } = useUiConfig(); + const { pathname } = useLocation(); + const { isBilling } = useInstanceStatus(); + const { flags } = uiConfig; + + return ( + + + + Users + + } + /> + {flags.UG && ( + + Groups + + } + /> + )} + {flags.RE && ( + + Project roles + + } + /> + )} + + API access + + } + /> + {uiConfig.embedProxy && ( + + CORS origins + + } + /> + )} + + Single sign-on + + } + /> + {isBilling && ( + + Billing + + } + /> + )} + + + ); +} + +export default AdminMenu; diff --git a/frontend/src/component/admin/projectRoles/CreateProjectRole/CreateProjectRole.tsx b/frontend/src/component/admin/projectRoles/CreateProjectRole/CreateProjectRole.tsx new file mode 100644 index 0000000000..cb0b547901 --- /dev/null +++ b/frontend/src/component/admin/projectRoles/CreateProjectRole/CreateProjectRole.tsx @@ -0,0 +1,107 @@ +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import useProjectRolesApi from 'hooks/api/actions/useProjectRolesApi/useProjectRolesApi'; +import { useNavigate } from 'react-router-dom'; +import ProjectRoleForm from '../ProjectRoleForm/ProjectRoleForm'; +import useProjectRoleForm from '../hooks/useProjectRoleForm'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import useToast from 'hooks/useToast'; +import { CreateButton } from 'component/common/CreateButton/CreateButton'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { GO_BACK } from 'constants/navigate'; + +const CreateProjectRole = () => { + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + const navigate = useNavigate(); + const { + roleName, + roleDesc, + setRoleName, + setRoleDesc, + checkedPermissions, + handlePermissionChange, + checkAllProjectPermissions, + checkAllEnvironmentPermissions, + getProjectRolePayload, + validatePermissions, + validateName, + validateNameUniqueness, + errors, + clearErrors, + getRoleKey, + } = useProjectRoleForm(); + + const { createRole, loading } = useProjectRolesApi(); + + const handleSubmit = async (e: Event) => { + e.preventDefault(); + clearErrors(); + const validName = validateName(); + const validPermissions = validatePermissions(); + + if (validName && validPermissions) { + const payload = getProjectRolePayload(); + try { + await createRole(payload); + navigate('/admin/roles'); + setToastData({ + title: 'Project role created', + text: 'Now you can start assigning your project roles to project members.', + confetti: true, + type: 'success', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + } + }; + + const formatApiCode = () => { + return `curl --location --request POST '${ + uiConfig.unleashUrl + }/api/admin/roles' \\ +--header 'Authorization: INSERT_API_KEY' \\ +--header 'Content-Type: application/json' \\ +--data-raw '${JSON.stringify(getProjectRolePayload(), undefined, 2)}'`; + }; + + const handleCancel = () => { + navigate(GO_BACK); + }; + + return ( + + + + + + ); +}; + +export default CreateProjectRole; diff --git a/frontend/src/component/admin/projectRoles/EditProjectRole/EditProjectRole.tsx b/frontend/src/component/admin/projectRoles/EditProjectRole/EditProjectRole.tsx new file mode 100644 index 0000000000..f00d947fe7 --- /dev/null +++ b/frontend/src/component/admin/projectRoles/EditProjectRole/EditProjectRole.tsx @@ -0,0 +1,133 @@ +import { useEffect } from 'react'; +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import { UpdateButton } from 'component/common/UpdateButton/UpdateButton'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import useProjectRolesApi from 'hooks/api/actions/useProjectRolesApi/useProjectRolesApi'; +import useProjectRole from 'hooks/api/getters/useProjectRole/useProjectRole'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import useToast from 'hooks/useToast'; +import { IPermission } from 'interfaces/project'; +import { useNavigate } from 'react-router-dom'; +import useProjectRoleForm from '../hooks/useProjectRoleForm'; +import ProjectRoleForm from '../ProjectRoleForm/ProjectRoleForm'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { GO_BACK } from 'constants/navigate'; + +const EditProjectRole = () => { + const { uiConfig } = useUiConfig(); + const { setToastData, setToastApiError } = useToast(); + const projectId = useRequiredPathParam('id'); + const { role } = useProjectRole(projectId); + + const navigate = useNavigate(); + const { + roleName, + roleDesc, + setRoleName, + setRoleDesc, + checkedPermissions, + handlePermissionChange, + checkAllProjectPermissions, + checkAllEnvironmentPermissions, + getProjectRolePayload, + validatePermissions, + validateName, + errors, + clearErrors, + getRoleKey, + handleInitialCheckedPermissions, + permissions, + } = useProjectRoleForm(role.name, role.description); + + useEffect(() => { + const initialCheckedPermissions = role?.permissions?.reduce( + (acc: { [key: string]: IPermission }, curr: IPermission) => { + acc[getRoleKey(curr)] = curr; + return acc; + }, + {} + ); + + handleInitialCheckedPermissions(initialCheckedPermissions || {}); + /* eslint-disable-next-line */ + }, [ + role?.permissions?.length, + permissions?.project?.length, + permissions?.environments?.length, + ]); + + const formatApiCode = () => { + return `curl --location --request PUT '${ + uiConfig.unleashUrl + }/api/admin/roles/${role.id}' \\ +--header 'Authorization: INSERT_API_KEY' \\ +--header 'Content-Type: application/json' \\ +--data-raw '${JSON.stringify(getProjectRolePayload(), undefined, 2)}'`; + }; + + const { refetch } = useProjectRole(projectId); + const { editRole, loading } = useProjectRolesApi(); + + const handleSubmit = async (e: Event) => { + e.preventDefault(); + const payload = getProjectRolePayload(); + + const validName = validateName(); + const validPermissions = validatePermissions(); + + if (validName && validPermissions) { + try { + await editRole(projectId, payload); + refetch(); + navigate('/admin/roles'); + setToastData({ + type: 'success', + title: 'Project role updated', + text: 'Your role changes will automatically be applied to the users with this role.', + confetti: true, + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + } + }; + + const handleCancel = () => { + navigate(GO_BACK); + }; + + return ( + + + + + + ); +}; + +export default EditProjectRole; diff --git a/frontend/src/component/admin/projectRoles/ProjectRoleForm/EnvironmentPermissionAccordion/EnvironmentPermissionAccordion.styles.ts b/frontend/src/component/admin/projectRoles/ProjectRoleForm/EnvironmentPermissionAccordion/EnvironmentPermissionAccordion.styles.ts new file mode 100644 index 0000000000..eaac54cee8 --- /dev/null +++ b/frontend/src/component/admin/projectRoles/ProjectRoleForm/EnvironmentPermissionAccordion/EnvironmentPermissionAccordion.styles.ts @@ -0,0 +1,35 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + environmentPermissionContainer: { + marginBottom: '1.25rem', + }, + accordionSummary: { + boxShadow: 'none', + padding: '0', + }, + label: { + minWidth: '300px', + [theme.breakpoints.down(600)]: { + minWidth: 'auto', + }, + }, + accordionHeader: { + display: 'flex', + alignItems: 'center', + [theme.breakpoints.down(500)]: { + flexDirection: 'column', + alignItems: 'flex-start', + }, + }, + accordionBody: { + padding: '0', + flexWrap: 'wrap', + }, + header: { + color: theme.palette.primary.main, + }, + icon: { + fill: theme.palette.primary.main, + }, +})); diff --git a/frontend/src/component/admin/projectRoles/ProjectRoleForm/EnvironmentPermissionAccordion/EnvironmentPermissionAccordion.tsx b/frontend/src/component/admin/projectRoles/ProjectRoleForm/EnvironmentPermissionAccordion/EnvironmentPermissionAccordion.tsx new file mode 100644 index 0000000000..95f715952b --- /dev/null +++ b/frontend/src/component/admin/projectRoles/ProjectRoleForm/EnvironmentPermissionAccordion/EnvironmentPermissionAccordion.tsx @@ -0,0 +1,153 @@ +import { + Accordion, + AccordionDetails, + AccordionSummary, + Checkbox, + FormControlLabel, +} from '@mui/material'; +import { ExpandMore } from '@mui/icons-material'; +import { useEffect, useState } from 'react'; +import { + IPermission, + IProjectEnvironmentPermissions, +} from 'interfaces/project'; +import StringTruncator from 'component/common/StringTruncator/StringTruncator'; +import { ICheckedPermission } from 'component/admin/projectRoles/hooks/useProjectRoleForm'; +import { useStyles } from './EnvironmentPermissionAccordion.styles'; + +type PermissionMap = { [key: string]: boolean }; + +interface IEnvironmentPermissionAccordionProps { + environment: IProjectEnvironmentPermissions; + handlePermissionChange: (permission: IPermission, type: string) => void; + checkAllEnvironmentPermissions: (envName: string) => void; + checkedPermissions: ICheckedPermission; + getRoleKey: (permission: { id: number; environment?: string }) => string; +} + +const EnvironmentPermissionAccordion = ({ + environment, + handlePermissionChange, + checkAllEnvironmentPermissions, + checkedPermissions, + getRoleKey, +}: IEnvironmentPermissionAccordionProps) => { + const [permissionMap, setPermissionMap] = useState({}); + const [permissionCount, setPermissionCount] = useState(0); + const { classes: styles } = useStyles(); + + useEffect(() => { + const permissionMap = environment?.permissions?.reduce( + (acc: PermissionMap, curr: IPermission) => { + acc[getRoleKey(curr)] = true; + return acc; + }, + {} + ); + + setPermissionMap(permissionMap); + /* eslint-disable-next-line */ + }, [environment?.permissions?.length]); + + useEffect(() => { + let count = 0; + Object.keys(checkedPermissions).forEach(key => { + if (permissionMap[key]) { + count = count + 1; + } + }); + + setPermissionCount(count); + /* eslint-disable-next-line */ + }, [checkedPermissions]); + + const renderPermissions = () => { + const envPermissions = environment?.permissions?.map( + (permission: IPermission) => { + return ( + + handlePermissionChange( + permission, + environment.name + ) + } + color="primary" + /> + } + label={permission.displayName} + /> + ); + } + ); + + envPermissions.push( + + checkAllEnvironmentPermissions(environment?.name) + } + color="primary" + /> + } + label={'Select all permissions for this env'} + /> + ); + + return envPermissions; + }; + + return ( +
    + + + } + > +
    + +   +

    + ({permissionCount} /{' '} + {environment?.permissions?.length} permissions) +

    +
    +
    + + {renderPermissions()} + +
    +
    + ); +}; + +export default EnvironmentPermissionAccordion; diff --git a/frontend/src/component/admin/projectRoles/ProjectRoleForm/ProjectRoleForm.styles.ts b/frontend/src/component/admin/projectRoles/ProjectRoleForm/ProjectRoleForm.styles.ts new file mode 100644 index 0000000000..55eca794a7 --- /dev/null +++ b/frontend/src/component/admin/projectRoles/ProjectRoleForm/ProjectRoleForm.styles.ts @@ -0,0 +1,41 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + maxWidth: '400px', + }, + input: { width: '100%', marginBottom: '1rem' }, + label: { + minWidth: '300px', + [theme.breakpoints.down(600)]: { + minWidth: 'auto', + }, + }, + buttonContainer: { + marginTop: 'auto', + display: 'flex', + justifyContent: 'flex-end', + }, + cancelButton: { + marginLeft: '1.5rem', + }, + inputDescription: { + marginBottom: '0.5rem', + }, + formHeader: { + fontWeight: 'normal', + marginTop: '0', + }, + header: { + fontWeight: 'normal', + }, + permissionErrorContainer: { + position: 'relative', + }, + errorMessage: { + fontSize: theme.fontSizes.smallBody, + color: theme.palette.error.main, + position: 'absolute', + top: '-8px', + }, +})); diff --git a/frontend/src/component/admin/projectRoles/ProjectRoleForm/ProjectRoleForm.tsx b/frontend/src/component/admin/projectRoles/ProjectRoleForm/ProjectRoleForm.tsx new file mode 100644 index 0000000000..c1244c38fa --- /dev/null +++ b/frontend/src/component/admin/projectRoles/ProjectRoleForm/ProjectRoleForm.tsx @@ -0,0 +1,179 @@ +import Input from 'component/common/Input/Input'; +import EnvironmentPermissionAccordion from './EnvironmentPermissionAccordion/EnvironmentPermissionAccordion'; +import { Button, Checkbox, FormControlLabel, TextField } from '@mui/material'; +import useProjectRolePermissions from 'hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions'; + +import { useStyles } from './ProjectRoleForm.styles'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import React, { ReactNode } from 'react'; +import { IPermission } from 'interfaces/project'; +import { + ICheckedPermission, + PROJECT_CHECK_ALL_KEY, +} from '../hooks/useProjectRoleForm'; + +interface IProjectRoleForm { + roleName: string; + roleDesc: string; + setRoleName: React.Dispatch>; + setRoleDesc: React.Dispatch>; + checkedPermissions: ICheckedPermission; + handlePermissionChange: (permission: IPermission, type: string) => void; + checkAllProjectPermissions: () => void; + checkAllEnvironmentPermissions: (envName: string) => void; + handleSubmit: (e: any) => void; + handleCancel: () => void; + errors: { [key: string]: string }; + mode?: string; + clearErrors: () => void; + validateNameUniqueness?: () => void; + getRoleKey: (permission: { id: number; environment?: string }) => string; + children: ReactNode; +} + +const ProjectRoleForm: React.FC = ({ + children, + handleSubmit, + handleCancel, + roleName, + roleDesc, + setRoleName, + setRoleDesc, + checkedPermissions, + handlePermissionChange, + checkAllProjectPermissions, + checkAllEnvironmentPermissions, + errors, + mode, + validateNameUniqueness, + clearErrors, + getRoleKey, +}: IProjectRoleForm) => { + const { classes: styles } = useStyles(); + const { permissions } = useProjectRolePermissions({ + revalidateIfStale: false, + revalidateOnReconnect: false, + revalidateOnFocus: false, + }); + + const { project, environments } = permissions; + + const renderProjectPermissions = () => { + const projectPermissions = project.map(permission => { + return ( + + handlePermissionChange(permission, 'project') + } + color="primary" + /> + } + label={permission.displayName} + /> + ); + }); + + projectPermissions.push( + checkAllProjectPermissions()} + color="primary" + /> + } + label={'Select all project permissions'} + /> + ); + + return projectPermissions; + }; + + const renderEnvironmentPermissions = () => { + return environments.map(environment => { + return ( + + ); + }); + }; + + return ( +
    +
    +

    + What is your role name? +

    + setRoleName(e.target.value)} + error={Boolean(errors.name)} + errorText={errors.name} + onFocus={() => clearErrors()} + onBlur={validateNameUniqueness} + autoFocus + /> + +

    + What is this role for? +

    + setRoleDesc(e.target.value)} + /> +
    +
    + + You must select at least one permission for a role. + + } + /> +
    +

    Project permissions

    +
    {renderProjectPermissions()}
    +

    Environment permissions

    +
    {renderEnvironmentPermissions()}
    +
    + {children} + +
    +
    + ); +}; + +export default ProjectRoleForm; diff --git a/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm.styles.ts b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm.styles.ts new file mode 100644 index 0000000000..7937593eb6 --- /dev/null +++ b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm.styles.ts @@ -0,0 +1,10 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + deleteParagraph: { + marginTop: '2rem', + }, + roleDeleteInput: { + marginTop: '1rem', + }, +})); diff --git a/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm.tsx b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm.tsx new file mode 100644 index 0000000000..ed719e68f0 --- /dev/null +++ b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm.tsx @@ -0,0 +1,70 @@ +import { Alert } from '@mui/material'; +import React from 'react'; +import { IProjectRole } from 'interfaces/role'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import Input from 'component/common/Input/Input'; +import { useStyles } from './ProjectRoleDeleteConfirm.styles'; + +interface IProjectRoleDeleteConfirmProps { + role: IProjectRole; + open: boolean; + setDeldialogue: React.Dispatch>; + handleDeleteRole: (id: number) => Promise; + confirmName: string; + setConfirmName: React.Dispatch>; +} + +const ProjectRoleDeleteConfirm = ({ + role, + open, + setDeldialogue, + handleDeleteRole, + confirmName, + setConfirmName, +}: IProjectRoleDeleteConfirmProps) => { + const { classes: styles } = useStyles(); + + const handleChange = (e: React.ChangeEvent) => + setConfirmName(e.currentTarget.value); + + const handleCancel = () => { + setDeldialogue(false); + setConfirmName(''); + }; + const formId = 'delete-project-role-confirmation-form'; + return ( + handleDeleteRole(role.id)} + disabledPrimaryButton={role?.name !== confirmName} + onClose={handleCancel} + formId={formId} + > + + Danger. Deleting this role will result in removing all + permissions that are active in this environment across all + feature toggles. + + +

    + In order to delete this role, please enter the name of the role + in the textfield below: {role?.name} +

    + +
    + +
    +
    + ); +}; + +export default ProjectRoleDeleteConfirm; diff --git a/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleList/ProjectRoleList.tsx b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleList/ProjectRoleList.tsx new file mode 100644 index 0000000000..bf088cdb8d --- /dev/null +++ b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleList/ProjectRoleList.tsx @@ -0,0 +1,265 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Table, + SortableTableHeader, + TableBody, + TableCell, + TableRow, + TablePlaceholder, +} from 'component/common/Table'; +import { useTable, useGlobalFilter, useSortBy } from 'react-table'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import useProjectRoles from 'hooks/api/getters/useProjectRoles/useProjectRoles'; +import IRole, { IProjectRole } from 'interfaces/role'; +import useProjectRolesApi from 'hooks/api/actions/useProjectRolesApi/useProjectRolesApi'; +import useToast from 'hooks/useToast'; +import ProjectRoleDeleteConfirm from '../ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { Box, Button, useMediaQuery } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; +import { Delete, Edit, SupervisedUserCircle } from '@mui/icons-material'; +import { useNavigate } from 'react-router-dom'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { sortTypes } from 'utils/sortTypes'; +import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell'; +import theme from 'themes/theme'; +import { IconCell } from 'component/common/Table/cells/IconCell/IconCell'; +import { Search } from 'component/common/Search/Search'; + +const ROOTROLE = 'root'; +const BUILTIN_ROLE_TYPE = 'project'; + +const ProjectRoleList = () => { + const navigate = useNavigate(); + const { roles, refetch, loading } = useProjectRoles(); + + const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); + + const paginationFilter = (role: IRole) => role?.type !== ROOTROLE; + const data = roles.filter(paginationFilter); + + const { deleteRole } = useProjectRolesApi(); + const [currentRole, setCurrentRole] = useState(null); + const [delDialog, setDelDialog] = useState(false); + const [confirmName, setConfirmName] = useState(''); + const { setToastData, setToastApiError } = useToast(); + + const deleteProjectRole = async () => { + if (!currentRole?.id) return; + try { + await deleteRole(currentRole?.id); + refetch(); + setToastData({ + type: 'success', + title: 'Successfully deleted role', + text: 'Your role is now deleted', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + setDelDialog(false); + setConfirmName(''); + }; + + const columns = useMemo( + () => [ + { + id: 'Icon', + Cell: () => ( + } + /> + ), + disableGlobalFilter: true, + }, + { + Header: 'Project role', + accessor: 'name', + }, + { + Header: 'Description', + accessor: 'description', + width: '90%', + }, + { + Header: 'Actions', + id: 'Actions', + align: 'center', + Cell: ({ + row: { + original: { id, type, name, description }, + }, + }: any) => ( + + { + navigate(`/admin/roles/${id}/edit`); + }} + permission={ADMIN} + tooltipProps={{ + title: + type === BUILTIN_ROLE_TYPE + ? 'You cannot edit role' + : 'Edit role', + }} + > + + + { + setCurrentRole({ + id, + name, + description, + } as IProjectRole); + setDelDialog(true); + }} + permission={ADMIN} + tooltipProps={{ + title: + type === BUILTIN_ROLE_TYPE + ? 'You cannot remove role' + : 'Remove role', + }} + > + + + + ), + width: 100, + disableGlobalFilter: true, + disableSortBy: true, + }, + ], + [navigate] + ); + + const initialState = useMemo( + () => ({ + sortBy: [{ id: 'name', desc: false }], + }), + [] + ); + + const { + getTableProps, + getTableBodyProps, + headerGroups, + rows, + prepareRow, + state: { globalFilter }, + setGlobalFilter, + setHiddenColumns, + } = useTable( + { + columns: columns as any[], // TODO: fix after `react-table` v8 update + data, + initialState, + sortTypes, + autoResetGlobalFilter: false, + autoResetSortBy: false, + disableSortRemove: true, + defaultColumn: { + Cell: HighlightCell, + }, + }, + useGlobalFilter, + useSortBy + ); + + useEffect(() => { + const hiddenColumns = []; + if (isExtraSmallScreen) { + hiddenColumns.push('Icon'); + } + setHiddenColumns(hiddenColumns); + }, [setHiddenColumns, isExtraSmallScreen]); + + return ( + + + + + + } + /> + } + > + + + + + {rows.map(row => { + prepareRow(row); + return ( + + {row.cells.map(cell => ( + + {cell.render('Cell')} + + ))} + + ); + })} + +
    +
    + 0} + show={ + + No project roles found matching “ + {globalFilter} + ” + + } + elseShow={ + + No project roles available. Get started by + adding one. + + } + /> + } + /> + + +
    + ); +}; + +export default ProjectRoleList; diff --git a/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoles.styles.ts b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoles.styles.ts new file mode 100644 index 0000000000..fb25df6238 --- /dev/null +++ b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoles.styles.ts @@ -0,0 +1,10 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + rolesListBody: { + padding: theme.spacing(4), + paddingBottom: '4rem', + minHeight: '50vh', + position: 'relative', + }, +})); diff --git a/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoles.tsx b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoles.tsx new file mode 100644 index 0000000000..8b538acac8 --- /dev/null +++ b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoles.tsx @@ -0,0 +1,24 @@ +import { useContext } from 'react'; +import AccessContext from 'contexts/AccessContext'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import AdminMenu from 'component/admin/menu/AdminMenu'; +import ProjectRoleList from './ProjectRoleList/ProjectRoleList'; +import { AdminAlert } from 'component/common/AdminAlert/AdminAlert'; + +const ProjectRoles = () => { + const { hasAccess } = useContext(AccessContext); + + return ( +
    + + } + elseShow={} + /> +
    + ); +}; + +export default ProjectRoles; diff --git a/frontend/src/component/admin/projectRoles/hooks/useProjectRoleForm.ts b/frontend/src/component/admin/projectRoles/hooks/useProjectRoleForm.ts new file mode 100644 index 0000000000..3b364813dc --- /dev/null +++ b/frontend/src/component/admin/projectRoles/hooks/useProjectRoleForm.ts @@ -0,0 +1,271 @@ +import { useEffect, useState } from 'react'; +import { IPermission } from 'interfaces/project'; +import cloneDeep from 'lodash.clonedeep'; +import useProjectRolePermissions from 'hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions'; +import useProjectRolesApi from 'hooks/api/actions/useProjectRolesApi/useProjectRolesApi'; +import { formatUnknownError } from 'utils/formatUnknownError'; + +export interface ICheckedPermission { + [key: string]: IPermission; +} + +export const PROJECT_CHECK_ALL_KEY = 'check-all-project'; +export const ENVIRONMENT_CHECK_ALL_KEY = 'check-all-environment'; + +const useProjectRoleForm = ( + initialRoleName = '', + initialRoleDesc = '', + initialCheckedPermissions = {} +) => { + const { permissions } = useProjectRolePermissions({ + revalidateIfStale: false, + revalidateOnReconnect: false, + revalidateOnFocus: false, + }); + + const [roleName, setRoleName] = useState(initialRoleName); + const [roleDesc, setRoleDesc] = useState(initialRoleDesc); + const [checkedPermissions, setCheckedPermissions] = + useState(initialCheckedPermissions); + const [errors, setErrors] = useState({}); + + const { validateRole } = useProjectRolesApi(); + + useEffect(() => { + setRoleName(initialRoleName); + }, [initialRoleName]); + + useEffect(() => { + setRoleDesc(initialRoleDesc); + }, [initialRoleDesc]); + + const handleInitialCheckedPermissions = ( + initialCheckedPermissions: ICheckedPermission + ) => { + const formattedInitialCheckedPermissions = + isAllEnvironmentPermissionsChecked( + // @ts-expect-error + isAllProjectPermissionsChecked(initialCheckedPermissions) + ); + + setCheckedPermissions(formattedInitialCheckedPermissions || {}); + }; + + const isAllProjectPermissionsChecked = ( + initialCheckedPermissions: ICheckedPermission + ) => { + const { project } = permissions; + if (!project || project.length === 0) return; + const isAllChecked = project.every((permission: IPermission) => { + return initialCheckedPermissions[getRoleKey(permission)]; + }); + + if (isAllChecked) { + // @ts-expect-error + initialCheckedPermissions[PROJECT_CHECK_ALL_KEY] = true; + } else { + delete initialCheckedPermissions[PROJECT_CHECK_ALL_KEY]; + } + + return initialCheckedPermissions; + }; + + const isAllEnvironmentPermissionsChecked = ( + initialCheckedPermissions: ICheckedPermission + ) => { + const { environments } = permissions; + if (!environments || environments.length === 0) return; + environments.forEach(env => { + const isAllChecked = env.permissions.every( + (permission: IPermission) => { + return initialCheckedPermissions[getRoleKey(permission)]; + } + ); + + const key = `${ENVIRONMENT_CHECK_ALL_KEY}-${env.name}`; + + if (isAllChecked) { + // @ts-expect-error + initialCheckedPermissions[key] = true; + } else { + delete initialCheckedPermissions[key]; + } + }); + return initialCheckedPermissions; + }; + + const getCheckAllKeys = () => { + const { environments } = permissions; + const envKeys = environments.map(env => { + return `${ENVIRONMENT_CHECK_ALL_KEY}-${env.name}`; + }); + + return [...envKeys, PROJECT_CHECK_ALL_KEY]; + }; + + const handlePermissionChange = (permission: IPermission, type: string) => { + let checkedPermissionsCopy = cloneDeep(checkedPermissions); + + if (checkedPermissionsCopy[getRoleKey(permission)]) { + delete checkedPermissionsCopy[getRoleKey(permission)]; + } else { + checkedPermissionsCopy[getRoleKey(permission)] = { ...permission }; + } + + if (type === 'project') { + // @ts-expect-error + checkedPermissionsCopy = isAllProjectPermissionsChecked( + checkedPermissionsCopy + ); + } else { + // @ts-expect-error + checkedPermissionsCopy = isAllEnvironmentPermissionsChecked( + checkedPermissionsCopy + ); + } + + setCheckedPermissions(checkedPermissionsCopy); + }; + + const checkAllProjectPermissions = () => { + const { project } = permissions; + const checkedPermissionsCopy = cloneDeep(checkedPermissions); + const checkedAll = checkedPermissionsCopy[PROJECT_CHECK_ALL_KEY]; + project.forEach((permission: IPermission, index: number) => { + const lastItem = project.length - 1 === index; + if (checkedAll) { + if (checkedPermissionsCopy[getRoleKey(permission)]) { + delete checkedPermissionsCopy[getRoleKey(permission)]; + } + + if (lastItem) { + delete checkedPermissionsCopy[PROJECT_CHECK_ALL_KEY]; + } + } else { + checkedPermissionsCopy[getRoleKey(permission)] = { + ...permission, + }; + + if (lastItem) { + // @ts-expect-error + checkedPermissionsCopy[PROJECT_CHECK_ALL_KEY] = true; + } + } + }); + + setCheckedPermissions(checkedPermissionsCopy); + }; + + const checkAllEnvironmentPermissions = (envName: string) => { + const { environments } = permissions; + const checkedPermissionsCopy = cloneDeep(checkedPermissions); + const environmentCheckAllKey = `${ENVIRONMENT_CHECK_ALL_KEY}-${envName}`; + const env = environments.find(env => env.name === envName); + if (!env) return; + const checkedAll = checkedPermissionsCopy[environmentCheckAllKey]; + + env.permissions.forEach((permission: IPermission, index: number) => { + const lastItem = env.permissions.length - 1 === index; + if (checkedAll) { + if (checkedPermissionsCopy[getRoleKey(permission)]) { + delete checkedPermissionsCopy[getRoleKey(permission)]; + } + + if (lastItem) { + delete checkedPermissionsCopy[environmentCheckAllKey]; + } + } else { + checkedPermissionsCopy[getRoleKey(permission)] = { + ...permission, + }; + + if (lastItem) { + // @ts-expect-error + checkedPermissionsCopy[environmentCheckAllKey] = true; + } + } + }); + + setCheckedPermissions(checkedPermissionsCopy); + }; + + const getProjectRolePayload = () => { + const checkAllKeys = getCheckAllKeys(); + const permissions = Object.keys(checkedPermissions) + .filter(key => { + return !checkAllKeys.includes(key); + }) + .map(permission => { + return checkedPermissions[permission]; + }); + return { + name: roleName, + description: roleDesc, + permissions, + }; + }; + + const validateNameUniqueness = async () => { + const payload = getProjectRolePayload(); + + try { + await validateRole(payload); + } catch (error: unknown) { + setErrors(prev => ({ ...prev, name: formatUnknownError(error) })); + } + }; + + const validateName = () => { + if (roleName.length === 0) { + setErrors(prev => ({ ...prev, name: 'Name can not be empty.' })); + return false; + } + return true; + }; + + const validatePermissions = () => { + if (Object.keys(checkedPermissions).length === 0) { + setErrors(prev => ({ + ...prev, + permissions: 'You must include at least one permission.', + })); + return false; + } + return true; + }; + + const clearErrors = () => { + setErrors({}); + }; + + const getRoleKey = (permission: { + id: number; + environment?: string; + }): string => { + return permission.environment + ? `${permission.id}-${permission.environment}` + : `${permission.id}`; + }; + + return { + roleName, + roleDesc, + setRoleName, + setRoleDesc, + handlePermissionChange, + checkAllProjectPermissions, + checkAllEnvironmentPermissions, + checkedPermissions, + getProjectRolePayload, + validatePermissions, + validateName, + handleInitialCheckedPermissions, + clearErrors, + validateNameUniqueness, + errors, + getRoleKey, + permissions, + }; +}; + +export default useProjectRoleForm; diff --git a/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserAdded.tsx b/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserAdded.tsx new file mode 100644 index 0000000000..5142eb3a12 --- /dev/null +++ b/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserAdded.tsx @@ -0,0 +1,36 @@ +import ConfirmUserEmail from './ConfirmUserEmail/ConfirmUserEmail'; +import ConfirmUserLink from './ConfirmUserLink/ConfirmUserLink'; + +interface IConfirmUserAddedProps { + open: boolean; + closeConfirm: () => void; + inviteLink: string; + emailSent: boolean; +} + +const ConfirmUserAdded = ({ + open, + closeConfirm, + emailSent, + inviteLink, +}: IConfirmUserAddedProps) => { + if (emailSent) { + return ( + + ); + } + + return ( + + ); +}; + +export default ConfirmUserAdded; diff --git a/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserEmail/ConfirmUserEmail.styles.ts b/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserEmail/ConfirmUserEmail.styles.ts new file mode 100644 index 0000000000..5245049494 --- /dev/null +++ b/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserEmail/ConfirmUserEmail.styles.ts @@ -0,0 +1,11 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()({ + iconContainer: { + width: '100%', + textAlign: 'center', + }, + emailIcon: { + margin: '2rem auto', + }, +}); diff --git a/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserEmail/ConfirmUserEmail.tsx b/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserEmail/ConfirmUserEmail.tsx new file mode 100644 index 0000000000..0bae5c0514 --- /dev/null +++ b/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserEmail/ConfirmUserEmail.tsx @@ -0,0 +1,46 @@ +import { Typography } from '@mui/material'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; + +import { ReactComponent as EmailIcon } from 'assets/icons/email.svg'; +import { useStyles } from './ConfirmUserEmail.styles'; +import UserInviteLink from '../ConfirmUserLink/UserInviteLink/UserInviteLink'; + +interface IConfirmUserEmailProps { + open: boolean; + closeConfirm: () => void; + inviteLink: string; +} + +const ConfirmUserEmail = ({ + open, + closeConfirm, + inviteLink, +}: IConfirmUserEmailProps) => { + const { classes: styles } = useStyles(); + return ( + + + A new team member has been added. We’ve sent an email on your + behalf to inform them of their new account and role. No further + steps are required. + +
    + +
    + + In a rush? + + + You may also copy the invite link and send it to the user. + + +
    + ); +}; + +export default ConfirmUserEmail; diff --git a/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserLink/ConfirmUserLink.tsx b/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserLink/ConfirmUserLink.tsx new file mode 100644 index 0000000000..7471d056a9 --- /dev/null +++ b/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserLink/ConfirmUserLink.tsx @@ -0,0 +1,58 @@ +import { Typography } from '@mui/material'; +import { Alert } from '@mui/material'; +import { useThemeStyles } from 'themes/themeStyles'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import UserInviteLink from './UserInviteLink/UserInviteLink'; + +interface IConfirmUserLink { + open: boolean; + closeConfirm: () => void; + inviteLink: string; +} + +const ConfirmUserLink = ({ + open, + closeConfirm, + inviteLink, +}: IConfirmUserLink) => { + const { classes: themeStyles } = useThemeStyles(); + return ( + +
    + + A new team member has been added. Please provide them with + the following link to get started: + + + + + Copy the link and send it to the user. This will allow them + to set up their password and get started with their Unleash + account. + + + + Want to avoid this step in the future?{' '} + {/* TODO - ADD LINK HERE ONCE IT EXISTS*/} + + If you configure an email server for Unleash + {' '} + we'll automatically send informational getting started + emails to new users once you add them. + + +
    +
    + ); +}; + +export default ConfirmUserLink; diff --git a/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserLink/UserInviteLink/UserInviteLink.tsx b/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserLink/UserInviteLink/UserInviteLink.tsx new file mode 100644 index 0000000000..391a436da1 --- /dev/null +++ b/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserLink/UserInviteLink/UserInviteLink.tsx @@ -0,0 +1,59 @@ +import { IconButton, Tooltip } from '@mui/material'; +import CopyIcon from '@mui/icons-material/FileCopy'; +import useToast from 'hooks/useToast'; + +interface IInviteLinkProps { + inviteLink: string; +} + +const UserInviteLink = ({ inviteLink }: IInviteLinkProps) => { + const { setToastData } = useToast(); + + const handleCopy = () => { + try { + return navigator.clipboard + .writeText(inviteLink) + .then(() => { + setToastData({ + type: 'success', + title: 'Successfully copied invite link.', + }); + }) + .catch(() => { + setError(); + }); + } catch (e) { + setError(); + } + }; + + const setError = () => + setToastData({ + type: 'error', + title: 'Could not copy invite link.', + }); + + return ( +
    + {inviteLink} + + + + + +
    + ); +}; + +export default UserInviteLink; diff --git a/frontend/src/component/admin/users/CreateUser/CreateUser.tsx b/frontend/src/component/admin/users/CreateUser/CreateUser.tsx new file mode 100644 index 0000000000..41ce786603 --- /dev/null +++ b/frontend/src/component/admin/users/CreateUser/CreateUser.tsx @@ -0,0 +1,115 @@ +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import { useNavigate } from 'react-router-dom'; +import UserForm from '../UserForm/UserForm'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import useAdminUsersApi from 'hooks/api/actions/useAdminUsersApi/useAdminUsersApi'; +import useToast from 'hooks/useToast'; +import useAddUserForm from '../hooks/useAddUserForm'; +import ConfirmUserAdded from '../ConfirmUserAdded/ConfirmUserAdded'; +import { useState } from 'react'; +import { scrollToTop } from 'component/common/util'; +import { CreateButton } from 'component/common/CreateButton/CreateButton'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { GO_BACK } from 'constants/navigate'; + +const CreateUser = () => { + const { setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + const navigate = useNavigate(); + const { + name, + setName, + email, + setEmail, + sendEmail, + setSendEmail, + rootRole, + setRootRole, + getAddUserPayload, + validateName, + validateEmail, + errors, + clearErrors, + } = useAddUserForm(); + const [showConfirm, setShowConfirm] = useState(false); + const [inviteLink, setInviteLink] = useState(''); + + const { addUser, userLoading: loading } = useAdminUsersApi(); + + const handleSubmit = async (e: Event) => { + e.preventDefault(); + clearErrors(); + const validName = validateName(); + const validEmail = validateEmail(); + + if (validName && validEmail) { + const payload = getAddUserPayload(); + try { + await addUser(payload) + .then(res => res.json()) + .then(user => { + scrollToTop(); + setInviteLink(user.inviteLink); + setShowConfirm(true); + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + } + }; + const closeConfirm = () => { + setShowConfirm(false); + navigate('/admin/users'); + }; + + const formatApiCode = () => { + return `curl --location --request POST '${ + uiConfig.unleashUrl + }/api/admin/user-admin' \\ +--header 'Authorization: INSERT_API_KEY' \\ +--header 'Content-Type: application/json' \\ +--data-raw '${JSON.stringify(getAddUserPayload(), undefined, 2)}'`; + }; + + const handleCancel = () => { + navigate(GO_BACK); + }; + + return ( + + + + + + + ); +}; + +export default CreateUser; diff --git a/frontend/src/component/admin/users/EditUser/EditUser.tsx b/frontend/src/component/admin/users/EditUser/EditUser.tsx new file mode 100644 index 0000000000..482f6bfe24 --- /dev/null +++ b/frontend/src/component/admin/users/EditUser/EditUser.tsx @@ -0,0 +1,107 @@ +import { useNavigate } from 'react-router-dom'; +import UserForm from '../UserForm/UserForm'; +import useAddUserForm from '../hooks/useAddUserForm'; +import { scrollToTop } from 'component/common/util'; +import { useEffect } from 'react'; +import { UpdateButton } from 'component/common/UpdateButton/UpdateButton'; +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import { EDIT } from 'constants/misc'; +import useAdminUsersApi from 'hooks/api/actions/useAdminUsersApi/useAdminUsersApi'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import useUserInfo from 'hooks/api/getters/useUserInfo/useUserInfo'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { GO_BACK } from 'constants/navigate'; + +const EditUser = () => { + useEffect(() => { + scrollToTop(); + }, []); + const { uiConfig } = useUiConfig(); + const { setToastData, setToastApiError } = useToast(); + const id = useRequiredPathParam('id'); + const { user, refetch } = useUserInfo(id); + const { updateUser, userLoading: loading } = useAdminUsersApi(); + const navigate = useNavigate(); + const { + name, + setName, + email, + setEmail, + sendEmail, + setSendEmail, + rootRole, + setRootRole, + getAddUserPayload, + validateName, + errors, + clearErrors, + } = useAddUserForm(user?.name, user?.email, user?.rootRole); + + const formatApiCode = () => { + return `curl --location --request PUT '${ + uiConfig.unleashUrl + }/api/admin/user-admin/${id}' \\ +--header 'Authorization: INSERT_API_KEY' \\ +--header 'Content-Type: application/json' \\ +--data-raw '${JSON.stringify(getAddUserPayload(), undefined, 2)}'`; + }; + + const handleSubmit = async (e: Event) => { + e.preventDefault(); + const payload = getAddUserPayload(); + const validName = validateName(); + + if (validName) { + try { + await updateUser({ ...payload, id }); + refetch(); + navigate('/admin/users'); + setToastData({ + title: 'User information updated', + type: 'success', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + } + }; + + const handleCancel = () => { + navigate(GO_BACK); + }; + + return ( + + + + + + ); +}; + +export default EditUser; diff --git a/frontend/src/component/admin/users/UserForm/UserForm.styles.ts b/frontend/src/component/admin/users/UserForm/UserForm.styles.ts new file mode 100644 index 0000000000..2b4106a89d --- /dev/null +++ b/frontend/src/component/admin/users/UserForm/UserForm.styles.ts @@ -0,0 +1,60 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + maxWidth: '400px', + }, + form: { + display: 'flex', + flexDirection: 'column', + height: '100%', + }, + input: { width: '100%', marginBottom: '1rem' }, + label: { + minWidth: '300px', + [theme.breakpoints.down(600)]: { + minWidth: 'auto', + }, + }, + buttonContainer: { + marginTop: 'auto', + display: 'flex', + justifyContent: 'flex-end', + }, + cancelButton: { + marginLeft: '1.5rem', + }, + inputDescription: { + marginBottom: '0.5rem', + }, + permissionErrorContainer: { + position: 'relative', + }, + errorMessage: { + fontSize: theme.fontSizes.smallBody, + color: theme.palette.error.main, + position: 'absolute', + top: '-8px', + }, + roleBox: { + margin: '3px 0', + border: '1px solid #EFEFEF', + padding: '1rem', + }, + userInfoContainer: { + margin: '-20px 0', + }, + roleRadio: { + marginRight: '15px', + }, + roleSubtitle: { + margin: '0.5rem 0', + }, + errorAlert: { + marginBottom: '1rem', + }, + flexRow: { + display: 'flex', + alignItems: 'center', + }, +})); diff --git a/frontend/src/component/admin/users/UserForm/UserForm.tsx b/frontend/src/component/admin/users/UserForm/UserForm.tsx new file mode 100644 index 0000000000..f3678f489d --- /dev/null +++ b/frontend/src/component/admin/users/UserForm/UserForm.tsx @@ -0,0 +1,164 @@ +import Input from 'component/common/Input/Input'; +import { + FormControlLabel, + Button, + RadioGroup, + FormControl, + Typography, + Radio, + Switch, +} from '@mui/material'; +import { useStyles } from './UserForm.styles'; +import React from 'react'; +import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { EDIT } from 'constants/misc'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; + +interface IUserForm { + email: string; + name: string; + rootRole: number; + sendEmail: boolean; + setEmail: React.Dispatch>; + setName: React.Dispatch>; + setSendEmail: React.Dispatch>; + setRootRole: React.Dispatch>; + handleSubmit: (e: any) => void; + handleCancel: () => void; + errors: { [key: string]: string }; + clearErrors: () => void; + mode?: string; +} + +const UserForm: React.FC = ({ + children, + email, + name, + rootRole, + sendEmail, + setEmail, + setName, + setSendEmail, + setRootRole, + handleSubmit, + handleCancel, + errors, + clearErrors, + mode, +}) => { + const { classes: styles } = useStyles(); + const { roles } = useUsers(); + const { uiConfig } = useUiConfig(); + + // @ts-expect-error + const sortRoles = (a, b) => { + if (b.name[0] < a.name[0]) { + return 1; + } else if (a.name[0] < b.name[0]) { + return -1; + } + return 0; + }; + + return ( +
    +
    +

    + Who is the new Unleash user? +

    + setName(e.target.value)} + error={Boolean(errors.name)} + errorText={errors.name} + onFocus={() => clearErrors()} + autoFocus + /> + setEmail(e.target.value)} + error={Boolean(errors.email)} + errorText={errors.email} + onFocus={() => clearErrors()} + /> + + + What is your team member allowed to do? + + setRootRole(+e.target.value)} + data-loading + > + {/* @ts-expect-error */} + {roles.sort(sortRoles).map(role => ( + + {role.name} + + {role.description} + +
    + } + control={ + + } + value={role.id} + /> + ))} + + + + + Should we send an email to your new team member + +
    + setSendEmail(!sendEmail)} + checked={sendEmail} + /> + + {sendEmail ? 'Yes' : 'No'} + +
    + + } + /> + +
    + {children} + +
    + + ); +}; + +export default UserForm; diff --git a/frontend/src/component/admin/users/UsersAdmin.tsx b/frontend/src/component/admin/users/UsersAdmin.tsx new file mode 100644 index 0000000000..e34f238537 --- /dev/null +++ b/frontend/src/component/admin/users/UsersAdmin.tsx @@ -0,0 +1,24 @@ +import { useContext } from 'react'; +import UsersList from './UsersList/UsersList'; +import AdminMenu from '../menu/AdminMenu'; +import AccessContext from 'contexts/AccessContext'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import { AdminAlert } from 'component/common/AdminAlert/AdminAlert'; + +const UsersAdmin = () => { + const { hasAccess } = useContext(AccessContext); + + return ( +
    + + } + elseShow={} + /> +
    + ); +}; + +export default UsersAdmin; diff --git a/frontend/src/component/admin/users/UsersList/ChangePassword/ChangePassword.tsx b/frontend/src/component/admin/users/UsersList/ChangePassword/ChangePassword.tsx new file mode 100644 index 0000000000..251e9f7d43 --- /dev/null +++ b/frontend/src/component/admin/users/UsersList/ChangePassword/ChangePassword.tsx @@ -0,0 +1,136 @@ +import React, { useState } from 'react'; +import classnames from 'classnames'; +import { styled, TextField, Typography } from '@mui/material'; +import { trim } from 'component/common/util'; +import { modalStyles } from 'component/admin/users/util'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import PasswordChecker, { + PASSWORD_FORMAT_MESSAGE, +} from 'component/user/common/ResetPasswordForm/PasswordChecker/PasswordChecker'; +import { useThemeStyles } from 'themes/themeStyles'; +import PasswordMatcher from 'component/user/common/ResetPasswordForm/PasswordMatcher/PasswordMatcher'; +import { IUser } from 'interfaces/user'; +import useAdminUsersApi from 'hooks/api/actions/useAdminUsersApi/useAdminUsersApi'; +import { UserAvatar } from 'component/common/UserAvatar/UserAvatar'; + +const StyledUserAvatar = styled(UserAvatar)(({ theme }) => ({ + width: theme.spacing(5), + height: theme.spacing(5), + margin: 0, +})); + +interface IChangePasswordProps { + showDialog: boolean; + closeDialog: () => void; + user: IUser; +} + +const ChangePassword = ({ + showDialog, + closeDialog, + user, +}: IChangePasswordProps) => { + const [data, setData] = useState>({}); + const [error, setError] = useState(); + const [validPassword, setValidPassword] = useState(false); + const { classes: themeStyles } = useThemeStyles(); + const { changePassword } = useAdminUsersApi(); + + const updateField: React.ChangeEventHandler = event => { + setError(undefined); + setData({ ...data, [event.target.name]: trim(event.target.value) }); + }; + + const submit = async (event: React.SyntheticEvent) => { + event.preventDefault(); + + if (data.password !== data.confirm) { + return; + } + + if (!validPassword) { + setError(PASSWORD_FORMAT_MESSAGE); + return; + } + + try { + await changePassword(user.id, data.password); + setData({}); + closeDialog(); + } catch (error: unknown) { + console.warn(error); + setError(PASSWORD_FORMAT_MESSAGE); + } + }; + + const onCancel = (event: React.SyntheticEvent) => { + event.preventDefault(); + setData({}); + setError(undefined); + closeDialog(); + }; + + return ( + +
    + + Changing password for user + +
    + + + {user.username || user.email} + +
    + + + + + +
    + ); +}; + +export default ChangePassword; diff --git a/frontend/src/component/admin/users/UsersList/DeleteUser/DeleteUser.tsx b/frontend/src/component/admin/users/UsersList/DeleteUser/DeleteUser.tsx new file mode 100644 index 0000000000..783d81f764 --- /dev/null +++ b/frontend/src/component/admin/users/UsersList/DeleteUser/DeleteUser.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { REMOVE_USER_ERROR } from 'hooks/api/actions/useAdminUsersApi/useAdminUsersApi'; +import { Alert, styled } from '@mui/material'; +import useLoading from 'hooks/useLoading'; +import { Typography } from '@mui/material'; +import { useThemeStyles } from 'themes/themeStyles'; +import { IUser } from 'interfaces/user'; +import { UserAvatar } from 'component/common/UserAvatar/UserAvatar'; + +const StyledUserAvatar = styled(UserAvatar)(({ theme }) => ({ + width: theme.spacing(5), + height: theme.spacing(5), + margin: 0, +})); + +interface IDeleteUserProps { + showDialog: boolean; + closeDialog: () => void; + user: IUser; + userLoading: boolean; + removeUser: () => void; + userApiErrors: Record; +} + +const DeleteUser = ({ + showDialog, + closeDialog, + user, + userLoading, + removeUser, + userApiErrors, +}: IDeleteUserProps) => { + const ref = useLoading(userLoading); + const { classes: themeStyles } = useThemeStyles(); + + return ( + +
    + + {userApiErrors[REMOVE_USER_ERROR]} + + } + /> +
    + + + {user.username || user.email} + +
    + + Are you sure you want to delete{' '} + {user + ? `${user.name || 'user'} (${ + user.email || user.username + })` + : ''} + ? + +
    +
    + ); +}; + +export default DeleteUser; diff --git a/frontend/src/component/admin/users/UsersList/UserTypeCell/UserTypeCell.tsx b/frontend/src/component/admin/users/UsersList/UserTypeCell/UserTypeCell.tsx new file mode 100644 index 0000000000..d27d5b205d --- /dev/null +++ b/frontend/src/component/admin/users/UsersList/UserTypeCell/UserTypeCell.tsx @@ -0,0 +1,29 @@ +import { MonetizationOn } from '@mui/icons-material'; +import { styled, Tooltip } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; + +const StyledMonetizationOn = styled(MonetizationOn)(({ theme }) => ({ + color: theme.palette.primary.light, + fontSize: '1.75rem', +})); + +interface IUserTypeCellProps { + value: boolean; +} + +export const UserTypeCell = ({ value }: IUserTypeCellProps) => { + return ( + + + + + } + elseShow="Free" + /> + + ); +}; diff --git a/frontend/src/component/admin/users/UsersList/UsersActionsCell/UsersActionsCell.tsx b/frontend/src/component/admin/users/UsersList/UsersActionsCell/UsersActionsCell.tsx new file mode 100644 index 0000000000..720b2679ea --- /dev/null +++ b/frontend/src/component/admin/users/UsersList/UsersActionsCell/UsersActionsCell.tsx @@ -0,0 +1,57 @@ +import { Delete, Edit, Lock } from '@mui/icons-material'; +import { Box, styled } from '@mui/material'; +import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import { VFC } from 'react'; + +const StyledBox = styled(Box)(() => ({ + display: 'flex', + justifyContent: 'center', +})); + +interface IUsersActionsCellProps { + onEdit: (event: React.SyntheticEvent) => void; + onChangePassword: (event: React.SyntheticEvent) => void; + onDelete: (event: React.SyntheticEvent) => void; +} + +export const UsersActionsCell: VFC = ({ + onEdit, + onChangePassword, + onDelete, +}) => { + return ( + + + + + + + + + + + + ); +}; diff --git a/frontend/src/component/admin/users/UsersList/UsersList.tsx b/frontend/src/component/admin/users/UsersList/UsersList.tsx new file mode 100644 index 0000000000..7876760811 --- /dev/null +++ b/frontend/src/component/admin/users/UsersList/UsersList.tsx @@ -0,0 +1,330 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { + Table, + SortableTableHeader, + TableBody, + TableCell, + TableRow, + TablePlaceholder, +} from 'component/common/Table'; +import ChangePassword from './ChangePassword/ChangePassword'; +import DeleteUser from './DeleteUser/DeleteUser'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import ConfirmUserAdded from '../ConfirmUserAdded/ConfirmUserAdded'; +import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; +import useAdminUsersApi from 'hooks/api/actions/useAdminUsersApi/useAdminUsersApi'; +import { IUser } from 'interfaces/user'; +import IRole from 'interfaces/role'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { useUsersPlan } from 'hooks/useUsersPlan'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { Button, useMediaQuery } from '@mui/material'; +import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { UserTypeCell } from './UserTypeCell/UserTypeCell'; +import { useGlobalFilter, useSortBy, useTable } from 'react-table'; +import { sortTypes } from 'utils/sortTypes'; +import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; +import { useNavigate } from 'react-router-dom'; +import { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; +import theme from 'themes/theme'; +import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell'; +import { UsersActionsCell } from './UsersActionsCell/UsersActionsCell'; +import { Search } from 'component/common/Search/Search'; +import { UserAvatar } from 'component/common/UserAvatar/UserAvatar'; + +const UsersList = () => { + const navigate = useNavigate(); + const { users, roles, refetch, loading } = useUsers(); + const { setToastData, setToastApiError } = useToast(); + const { removeUser, userLoading, userApiErrors } = useAdminUsersApi(); + const [pwDialog, setPwDialog] = useState<{ open: boolean; user?: IUser }>({ + open: false, + }); + const [delDialog, setDelDialog] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + const [emailSent, setEmailSent] = useState(false); + const [inviteLink, setInviteLink] = useState(''); + const [delUser, setDelUser] = useState(); + const { planUsers, isBillingUsers } = useUsersPlan(users); + + const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + + const closeDelDialog = () => { + setDelDialog(false); + setDelUser(undefined); + }; + + const openDelDialog = + (user: IUser) => (e: React.SyntheticEvent) => { + e.preventDefault(); + setDelDialog(true); + setDelUser(user); + }; + const openPwDialog = + (user: IUser) => (e: React.SyntheticEvent) => { + e.preventDefault(); + setPwDialog({ open: true, user }); + }; + + const closePwDialog = () => { + setPwDialog({ open: false }); + }; + + const onDeleteUser = async (user: IUser) => { + try { + await removeUser(user.id); + setToastData({ + title: `${user.name} has been deleted`, + type: 'success', + }); + refetch(); + closeDelDialog(); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const closeConfirm = () => { + setShowConfirm(false); + setEmailSent(false); + setInviteLink(''); + }; + + const columns = useMemo( + () => [ + { + id: 'type', + Header: 'Type', + accessor: 'paid', + Cell: ({ row: { original: user } }: any) => ( + + ), + disableGlobalFilter: true, + sortType: 'boolean', + }, + { + Header: 'Created', + accessor: 'createdAt', + Cell: DateCell, + disableGlobalFilter: true, + sortType: 'date', + minWidth: 120, + }, + { + Header: 'Avatar', + accessor: 'imageUrl', + Cell: ({ row: { original: user } }: any) => ( + + + + ), + disableGlobalFilter: true, + disableSortBy: true, + }, + { + id: 'name', + Header: 'Name', + accessor: (row: any) => row.name || '', + width: '40%', + Cell: HighlightCell, + }, + { + id: 'username', + Header: 'Username', + accessor: (row: any) => row.username || row.email, + width: '40%', + Cell: HighlightCell, + }, + { + id: 'role', + Header: 'Role', + accessor: (row: any) => + roles.find((role: IRole) => role.id === row.rootRole) + ?.name || '', + disableGlobalFilter: true, + }, + { + id: 'last-login', + Header: 'Last login', + accessor: (row: any) => row.seenAt || '', + Cell: ({ row: { original: user } }: any) => ( + + ), + disableGlobalFilter: true, + sortType: 'date', + minWidth: 150, + }, + { + Header: 'Actions', + id: 'Actions', + align: 'center', + Cell: ({ row: { original: user } }: any) => ( + { + navigate(`/admin/users/${user.id}/edit`); + }} + onChangePassword={openPwDialog(user)} + onDelete={openDelDialog(user)} + /> + ), + width: 100, + disableGlobalFilter: true, + disableSortBy: true, + }, + ], + [roles, navigate, isBillingUsers] + ); + + const initialState = useMemo(() => { + return { + sortBy: [{ id: 'createdAt' }], + hiddenColumns: isBillingUsers ? [] : ['type'], + }; + }, [isBillingUsers]); + + const data = isBillingUsers ? planUsers : users; + + const { + getTableProps, + getTableBodyProps, + headerGroups, + rows, + prepareRow, + state: { globalFilter }, + setGlobalFilter, + setHiddenColumns, + } = useTable( + { + columns: columns, + data, + initialState, + sortTypes, + autoResetGlobalFilter: false, + autoResetSortBy: false, + disableSortRemove: true, + defaultColumn: { + Cell: TextCell, + }, + }, + useGlobalFilter, + useSortBy + ); + + useEffect(() => { + const hiddenColumns = []; + if (!isBillingUsers || isSmallScreen) { + hiddenColumns.push('type'); + } + if (isSmallScreen) { + hiddenColumns.push('createdAt', 'username'); + } + if (isExtraSmallScreen) { + hiddenColumns.push('imageUrl', 'role', 'last-login'); + } + setHiddenColumns(hiddenColumns); + }, [setHiddenColumns, isExtraSmallScreen, isSmallScreen, isBillingUsers]); + + return ( + + + + + + } + /> + } + > + + + + + {rows.map(row => { + prepareRow(row); + return ( + + {row.cells.map(cell => ( + + {cell.render('Cell')} + + ))} + + ); + })} + +
    +
    + 0} + show={ + + No users found matching “ + {globalFilter} + ” + + } + elseShow={ + + No users available. Get started by adding one. + + } + /> + } + /> + + + + ( + + )} + /> + onDeleteUser(delUser!)} + userLoading={userLoading} + userApiErrors={userApiErrors} + /> + } + /> +
    + ); +}; + +export default UsersList; diff --git a/frontend/src/component/admin/users/hooks/useAddUserForm.ts b/frontend/src/component/admin/users/hooks/useAddUserForm.ts new file mode 100644 index 0000000000..607f1e30e1 --- /dev/null +++ b/frontend/src/component/admin/users/hooks/useAddUserForm.ts @@ -0,0 +1,86 @@ +import { useEffect, useState } from 'react'; +import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; + +const useCreateUserForm = ( + initialName = '', + initialEmail = '', + initialRootRole = 1 +) => { + const { uiConfig } = useUiConfig(); + const [name, setName] = useState(initialName); + const [email, setEmail] = useState(initialEmail); + const [sendEmail, setSendEmail] = useState(false); + const [rootRole, setRootRole] = useState(initialRootRole); + const [errors, setErrors] = useState({}); + + const { users } = useUsers(); + + useEffect(() => { + setName(initialName); + }, [initialName]); + + useEffect(() => { + setEmail(initialEmail); + }, [initialEmail]); + + useEffect(() => { + setSendEmail(uiConfig?.emailEnabled || false); + }, [uiConfig?.emailEnabled]); + + useEffect(() => { + setRootRole(initialRootRole); + }, [initialRootRole]); + + const getAddUserPayload = () => { + return { + name: name, + email: email, + sendEmail: sendEmail, + rootRole: rootRole, + }; + }; + + const validateName = () => { + if (name.length === 0) { + setErrors(prev => ({ ...prev, name: 'Name can not be empty.' })); + return false; + } + if (email.length === 0) { + setErrors(prev => ({ ...prev, email: 'Email can not be empty.' })); + return false; + } + return true; + }; + + const validateEmail = () => { + // @ts-expect-error + if (users.some(user => user['email'] === email)) { + setErrors(prev => ({ ...prev, email: 'Email already exists' })); + return false; + } + return true; + }; + + const clearErrors = () => { + setErrors({}); + }; + + return { + name, + setName, + email, + setEmail, + sendEmail, + setSendEmail, + rootRole, + setRootRole, + getAddUserPayload, + validateName, + validateEmail, + clearErrors, + errors, + }; +}; + +export default useCreateUserForm; diff --git a/frontend/src/component/admin/users/util.ts b/frontend/src/component/admin/users/util.ts new file mode 100644 index 0000000000..027e93284e --- /dev/null +++ b/frontend/src/component/admin/users/util.ts @@ -0,0 +1,21 @@ +export const modalStyles = { + overlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.25)', + zIndex: 5, + }, + content: { + width: '500px', + maxWidth: '90%', + margin: '0', + top: '50%', + left: '50%', + right: 'auto', + bottom: 'auto', + transform: 'translate(-50%, -50%)', + }, +}; diff --git a/frontend/src/component/application/ApplicationEdit/ApplicationEdit.tsx b/frontend/src/component/application/ApplicationEdit/ApplicationEdit.tsx new file mode 100644 index 0000000000..0bbacd33d7 --- /dev/null +++ b/frontend/src/component/application/ApplicationEdit/ApplicationEdit.tsx @@ -0,0 +1,155 @@ +/* eslint react/no-multi-comp:off */ +import React, { useContext, useState } from 'react'; +import { + Avatar, + Icon, + IconButton, + LinearProgress, + Link, + Typography, +} from '@mui/material'; +import { Link as LinkIcon } from '@mui/icons-material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { UPDATE_APPLICATION } from 'component/providers/AccessProvider/permissions'; +import { ApplicationView } from '../ApplicationView/ApplicationView'; +import { ApplicationUpdate } from '../ApplicationUpdate/ApplicationUpdate'; +import { TabNav } from 'component/common/TabNav/TabNav/TabNav'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import AccessContext from 'contexts/AccessContext'; +import useApplicationsApi from 'hooks/api/actions/useApplicationsApi/useApplicationsApi'; +import useApplication from 'hooks/api/getters/useApplication/useApplication'; +import { useNavigate } from 'react-router-dom'; +import { useLocationSettings } from 'hooks/useLocationSettings'; +import useToast from 'hooks/useToast'; +import PermissionButton from 'component/common/PermissionButton/PermissionButton'; +import { formatDateYMD } from 'utils/formatDate'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; + +export const ApplicationEdit = () => { + const navigate = useNavigate(); + const name = useRequiredPathParam('name'); + const { application, loading } = useApplication(name); + const { appName, url, description, icon = 'apps', createdAt } = application; + const { hasAccess } = useContext(AccessContext); + const { deleteApplication } = useApplicationsApi(); + const { locationSettings } = useLocationSettings(); + const { setToastData, setToastApiError } = useToast(); + + const [showDialog, setShowDialog] = useState(false); + + const toggleModal = () => { + setShowDialog(!showDialog); + }; + + const formatDate = (v: string) => formatDateYMD(v, locationSettings.locale); + + const onDeleteApplication = async (evt: React.SyntheticEvent) => { + evt.preventDefault(); + try { + await deleteApplication(appName); + setToastData({ + title: 'Deleted Successfully', + text: 'Application deleted successfully', + type: 'success', + }); + navigate('/applications'); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const renderModal = () => ( + + ); + const tabData = [ + { + label: 'Application overview', + component: , + }, + { + label: 'Edit application', + component: , + }, + ]; + + if (loading) { + return ( +
    +

    Loading...

    + +
    + ); + } else if (!application) { + return

    Application ({appName}) not found

    ; + } + return ( + + + {icon || 'apps'} + + {appName} + + } + title={appName} + actions={ + <> + + + + } + /> + + + Delete + + + } + /> + } + > +
    + {description || ''} + + Created: {formatDate(createdAt)} + +
    + + {renderModal()} + + + } + /> +
    + ); +}; diff --git a/frontend/src/component/application/ApplicationList/ApplicationList.tsx b/frontend/src/component/application/ApplicationList/ApplicationList.tsx new file mode 100644 index 0000000000..983a9aaf09 --- /dev/null +++ b/frontend/src/component/application/ApplicationList/ApplicationList.tsx @@ -0,0 +1,93 @@ +import { useEffect, useMemo, useState } from 'react'; +import { CircularProgress } from '@mui/material'; +import { Warning } from '@mui/icons-material'; +import { AppsLinkList, styles as themeStyles } from 'component/common'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import useApplications from 'hooks/api/getters/useApplications/useApplications'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { useSearchParams } from 'react-router-dom'; +import { Search } from 'component/common/Search/Search'; + +type PageQueryType = Partial>; + +export const ApplicationList = () => { + const { applications, loading } = useApplications(); + const [searchParams, setSearchParams] = useSearchParams(); + const [searchValue, setSearchValue] = useState( + searchParams.get('search') || '' + ); + + useEffect(() => { + const tableState: PageQueryType = {}; + if (searchValue) { + tableState.search = searchValue; + } + + setSearchParams(tableState, { + replace: true, + }); + }, [searchValue, setSearchParams]); + + const filteredApplications = useMemo(() => { + const regExp = new RegExp(searchValue, 'i'); + return searchValue + ? applications?.filter(a => regExp.test(a.appName)) + : applications; + }, [applications, searchValue]); + + const renderNoApplications = () => ( + <> +
    +
    +
    + Oh snap, it does not seem like you have connected any + applications. To connect your application to Unleash you will + require a Client SDK. +
    +
    + You can read more about how to use Unleash in your application + in the{' '} + + documentation. + +
    + + ); + + if (!filteredApplications) { + return ; + } + + return ( + <> + + } + /> + } + > +
    + 0} + show={} + elseShow={ + ...loading
    } + elseShow={renderNoApplications()} + /> + } + /> + +
    + + ); +}; diff --git a/frontend/src/component/application/ApplicationUpdate/ApplicationUpdate.tsx b/frontend/src/component/application/ApplicationUpdate/ApplicationUpdate.tsx new file mode 100644 index 0000000000..da4793dbc9 --- /dev/null +++ b/frontend/src/component/application/ApplicationUpdate/ApplicationUpdate.tsx @@ -0,0 +1,85 @@ +import { ChangeEvent, useState } from 'react'; +import { Grid, TextField } from '@mui/material'; +import { useThemeStyles } from 'themes/themeStyles'; +import icons from 'component/application/iconNames'; +import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect'; +import useApplicationsApi from 'hooks/api/actions/useApplicationsApi/useApplicationsApi'; +import useToast from 'hooks/useToast'; +import { IApplication } from 'interfaces/application'; +import useApplication from 'hooks/api/getters/useApplication/useApplication'; +import { formatUnknownError } from 'utils/formatUnknownError'; + +interface IApplicationUpdateProps { + application: IApplication; +} + +export const ApplicationUpdate = ({ application }: IApplicationUpdateProps) => { + const { storeApplicationMetaData } = useApplicationsApi(); + const { appName, icon, url, description } = application; + const { refetchApplication } = useApplication(appName); + const [localUrl, setLocalUrl] = useState(url || ''); + const [localDescription, setLocalDescription] = useState(description || ''); + const { setToastData, setToastApiError } = useToast(); + const { classes: themeStyles } = useThemeStyles(); + + const onChange = async ( + field: string, + value: string, + event?: ChangeEvent + ) => { + event?.preventDefault(); + try { + await storeApplicationMetaData(appName, field, value); + refetchApplication(); + setToastData({ + type: 'success', + title: 'Updated Successfully', + text: `${field} successfully updated`, + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + return ( + + + + ({ key: v, label: v }))} + value={icon || 'apps'} + onChange={key => onChange('icon', key)} + /> + + + setLocalUrl(e.target.value)} + label="Application URL" + placeholder="https://example.com" + type="url" + variant="outlined" + size="small" + onBlur={e => onChange('url', localUrl, e)} + /> + + + setLocalDescription(e.target.value)} + onBlur={e => + onChange('description', localDescription, e) + } + /> + + + + ); +}; diff --git a/frontend/src/component/application/ApplicationView/ApplicationView.tsx b/frontend/src/component/application/ApplicationView/ApplicationView.tsx new file mode 100644 index 0000000000..b6c014aa2f --- /dev/null +++ b/frontend/src/component/application/ApplicationView/ApplicationView.tsx @@ -0,0 +1,217 @@ +import { useContext } from 'react'; +import { Link } from 'react-router-dom'; +import { + Grid, + List, + ListItem, + ListItemAvatar, + ListItemText, + Typography, + Divider, +} from '@mui/material'; +import { + Extension, + FlagRounded, + Report, + SvgIconComponent, + Timeline, +} from '@mui/icons-material'; +import { + CREATE_FEATURE, + CREATE_STRATEGY, +} from 'component/providers/AccessProvider/permissions'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { getTogglePath } from 'utils/routePathHelpers'; +import useApplication from 'hooks/api/getters/useApplication/useApplication'; +import AccessContext from 'contexts/AccessContext'; +import { formatDateYMDHMS } from 'utils/formatDate'; +import { useLocationSettings } from 'hooks/useLocationSettings'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; + +export const ApplicationView = () => { + const { hasAccess } = useContext(AccessContext); + const name = useRequiredPathParam('name'); + const { application } = useApplication(name); + const { locationSettings } = useLocationSettings(); + const { instances, strategies, seenToggles } = application; + + const notFoundListItem = ({ + createUrl, + name, + permission, + }: { + createUrl: string; + name: string; + permission: string; + }) => ( + + + + + {name}} + secondary={'Missing, want to create?'} + /> + + } + elseShow={ + + + + + + + } + /> + ); + + const foundListItem = ({ + viewUrl, + name, + description, + Icon, + i, + }: { + viewUrl: string; + name: string; + description: string; + Icon: SvgIconComponent; + i: number; + }) => ( + + + + + + {name} + + } + secondary={description} + /> + + ); + return ( + + + + Toggles + + + + {seenToggles.map( + ({ name, description, notFound, project }, i) => ( + + ) + )} + + + + + Implemented strategies + + + + {strategies.map( + ({ name, description, notFound }, i: number) => ( + + ) + )} + + + + + {instances.length} Instances registered + + + + {instances.map( + ({ + instanceId, + clientIp, + lastSeen, + sdkVersion, + }: { + instanceId: string; + clientIp: string; + lastSeen: string; + sdkVersion: string; + }) => ( + + + + + + {instanceId} {sdkVersion} + + } + elseShow={{instanceId}} + /> + } + secondary={ + + {clientIp} last seen at{' '} + + {formatDateYMDHMS( + lastSeen, + locationSettings.locale + )} + + + } + /> + + ) + )} + + + + ); +}; diff --git a/frontend/src/component/application/iconNames.ts b/frontend/src/component/application/iconNames.ts new file mode 100644 index 0000000000..90d5ac720e --- /dev/null +++ b/frontend/src/component/application/iconNames.ts @@ -0,0 +1,934 @@ +export default [ + '3d_rotation', + 'ac_unit', + 'access_alarm', + 'access_alarms', + 'access_time', + 'accessibility', + 'accessible', + 'account_balance', + 'account_balance_wallet', + 'account_box', + 'account_circle', + 'adb', + 'add', + 'add_a_photo', + 'add_alarm', + 'add_alert', + 'add_box', + 'add_circle', + 'add_circle_outline', + 'add_location', + 'add_shopping_cart', + 'add_to_photos', + 'add_to_queue', + 'adjust', + 'airline_seat_flat', + 'airline_seat_flat_angled', + 'airline_seat_individual_suite', + 'airline_seat_legroom_extra', + 'airline_seat_legroom_normal', + 'airline_seat_legroom_reduced', + 'airline_seat_recline_extra', + 'airline_seat_recline_normal', + 'airplanemode_active', + 'airplanemode_inactive', + 'airplay', + 'airport_shuttle', + 'alarm', + 'alarm_add', + 'alarm_off', + 'alarm_on', + 'album', + 'all_inclusive', + 'all_out', + 'android', + 'announcement', + 'apps', + 'archive', + 'arrow_back', + 'arrow_downward', + 'arrow_drop_down', + 'arrow_drop_down_circle', + 'arrow_drop_up', + 'arrow_forward', + 'arrow_upward', + 'art_track', + 'aspect_ratio', + 'assessment', + 'assignment', + 'assignment_ind', + 'assignment_late', + 'assignment_return', + 'assignment_returned', + 'assignment_turned_in', + 'assistant', + 'assistant_photo', + 'attach_file', + 'attach_money', + 'attachment', + 'audiotrack', + 'autorenew', + 'av_timer', + 'backspace', + 'backup', + 'battery_alert', + 'battery_charging_full', + 'battery_full', + 'battery_std', + 'battery_unknown', + 'beach_access', + 'beenhere', + 'block', + 'bluetooth', + 'bluetooth_audio', + 'bluetooth_connected', + 'bluetooth_disabled', + 'bluetooth_searching', + 'blur_circular', + 'blur_linear', + 'blur_off', + 'blur_on', + 'book', + 'bookmark', + 'bookmark_border', + 'border_all', + 'border_bottom', + 'border_clear', + 'border_color', + 'border_horizontal', + 'border_inner', + 'border_left', + 'border_outer', + 'border_right', + 'border_style', + 'border_top', + 'border_vertical', + 'branding_watermark', + 'brightness_1', + 'brightness_2', + 'brightness_3', + 'brightness_4', + 'brightness_5', + 'brightness_6', + 'brightness_7', + 'brightness_auto', + 'brightness_high', + 'brightness_low', + 'brightness_medium', + 'broken_image', + 'brush', + 'bubble_chart', + 'bug_report', + 'build', + 'burst_mode', + 'business', + 'business_center', + 'cached', + 'cake', + 'call', + 'call_end', + 'call_made', + 'call_merge', + 'call_missed', + 'call_missed_outgoing', + 'call_received', + 'call_split', + 'call_to_action', + 'camera', + 'camera_alt', + 'camera_enhance', + 'camera_front', + 'camera_rear', + 'camera_roll', + 'cancel', + 'card_giftcard', + 'card_membership', + 'card_travel', + 'casino', + 'cast', + 'cast_connected', + 'center_focus_strong', + 'center_focus_weak', + 'change_history', + 'chat', + 'chat_bubble', + 'chat_bubble_outline', + 'check', + 'check_box', + 'check_box_outline_blank', + 'check_circle', + 'chevron_left', + 'chevron_right', + 'child_care', + 'child_friendly', + 'chrome_reader_mode', + 'class', + 'clear', + 'clear_all', + 'close', + 'closed_caption', + 'cloud', + 'cloud_circle', + 'cloud_done', + 'cloud_download', + 'cloud_off', + 'cloud_queue', + 'cloud_upload', + 'code', + 'collections', + 'collections_bookmark', + 'color_lens', + 'colorize', + 'comment', + 'compare', + 'compare_arrows', + 'computer', + 'confirmation_number', + 'contact_mail', + 'contact_phone', + 'contacts', + 'content_copy', + 'content_cut', + 'content_paste', + 'control_point', + 'control_point_duplicate', + 'copyright', + 'create', + 'create_new_folder', + 'credit_card', + 'crop', + 'crop_16_9', + 'crop_3_2', + 'crop_5_4', + 'crop_7_5', + 'crop_din', + 'crop_free', + 'crop_landscape', + 'crop_original', + 'crop_portrait', + 'crop_rotate', + 'crop_square', + 'dashboard', + 'data_usage', + 'date_range', + 'dehaze', + 'delete', + 'delete_forever', + 'delete_sweep', + 'description', + 'desktop_mac', + 'desktop_windows', + 'details', + 'developer_board', + 'developer_mode', + 'device_hub', + 'devices', + 'devices_other', + 'dialer_sip', + 'dialpad', + 'directions', + 'directions_bike', + 'directions_boat', + 'directions_bus', + 'directions_car', + 'directions_railway', + 'directions_run', + 'directions_subway', + 'directions_transit', + 'directions_walk', + 'disc_full', + 'dns', + 'do_not_disturb', + 'do_not_disturb_alt', + 'do_not_disturb_off', + 'do_not_disturb_on', + 'dock', + 'domain', + 'done', + 'done_all', + 'donut_large', + 'donut_small', + 'drafts', + 'drag_handle', + 'drive_eta', + 'dvr', + 'edit', + 'edit_location', + 'eject', + 'email', + 'enhanced_encryption', + 'equalizer', + 'error', + 'error_outline', + 'euro_symbol', + 'ev_station', + 'event', + 'event_available', + 'event_busy', + 'event_note', + 'event_seat', + 'exit_to_app', + 'expand_less', + 'expand_more', + 'explicit', + 'explore', + 'exposure', + 'exposure_neg_1', + 'exposure_neg_2', + 'exposure_plus_1', + 'exposure_plus_2', + 'exposure_zero', + 'extension', + 'face', + 'fast_forward', + 'fast_rewind', + 'favorite', + 'favorite_border', + 'featured_play_list', + 'featured_video', + 'feedback', + 'fiber_dvr', + 'fiber_manual_record', + 'fiber_new', + 'fiber_pin', + 'fiber_smart_record', + 'file_download', + 'file_upload', + 'filter', + 'filter_1', + 'filter_2', + 'filter_3', + 'filter_4', + 'filter_5', + 'filter_6', + 'filter_7', + 'filter_8', + 'filter_9', + 'filter_9_plus', + 'filter_b_and_w', + 'filter_center_focus', + 'filter_drama', + 'filter_frames', + 'filter_hdr', + 'filter_list', + 'filter_none', + 'filter_tilt_shift', + 'filter_vintage', + 'find_in_page', + 'find_replace', + 'fingerprint', + 'first_page', + 'fitness_center', + 'flag', + 'flare', + 'flash_auto', + 'flash_off', + 'flash_on', + 'flight', + 'flight_land', + 'flight_takeoff', + 'flip', + 'flip_to_back', + 'flip_to_front', + 'folder', + 'folder_open', + 'folder_shared', + 'folder_special', + 'font_download', + 'format_align_center', + 'format_align_justify', + 'format_align_left', + 'format_align_right', + 'format_bold', + 'format_clear', + 'format_color_fill', + 'format_color_reset', + 'format_color_text', + 'format_indent_decrease', + 'format_indent_increase', + 'format_italic', + 'format_line_spacing', + 'format_list_bulleted', + 'format_list_numbered', + 'format_paint', + 'format_quote', + 'format_shapes', + 'format_size', + 'format_strikethrough', + 'format_textdirection_l_to_r', + 'format_textdirection_r_to_l', + 'format_underlined', + 'forum', + 'forward', + 'forward_10', + 'forward_30', + 'forward_5', + 'free_breakfast', + 'fullscreen', + 'fullscreen_exit', + 'functions', + 'g_translate', + 'gamepad', + 'games', + 'gavel', + 'gesture', + 'get_app', + 'gif', + 'golf_course', + 'gps_fixed', + 'gps_not_fixed', + 'gps_off', + 'grade', + 'gradient', + 'grain', + 'graphic_eq', + 'grid_off', + 'grid_on', + 'group', + 'group_add', + 'group_work', + 'hd', + 'hdr_off', + 'hdr_on', + 'hdr_strong', + 'hdr_weak', + 'headset', + 'headset_mic', + 'healing', + 'hearing', + 'help', + 'help_outline', + 'high_quality', + 'highlight', + 'highlight_off', + 'history', + 'home', + 'hot_tub', + 'hotel', + 'hourglass_empty', + 'hourglass_full', + 'http', + 'https', + 'image', + 'image_aspect_ratio', + 'import_contacts', + 'import_export', + 'important_devices', + 'inbox', + 'indeterminate_check_box', + 'info', + 'info_outline', + 'input', + 'insert_chart', + 'insert_comment', + 'insert_drive_file', + 'insert_emoticon', + 'insert_invitation', + 'insert_link', + 'insert_photo', + 'invert_colors', + 'invert_colors_off', + 'iso', + 'keyboard', + 'keyboard_arrow_down', + 'keyboard_arrow_left', + 'keyboard_arrow_right', + 'keyboard_arrow_up', + 'keyboard_backspace', + 'keyboard_capslock', + 'keyboard_hide', + 'keyboard_return', + 'keyboard_tab', + 'keyboard_voice', + 'kitchen', + 'label', + 'label_outline', + 'landscape', + 'language', + 'laptop', + 'laptop_chromebook', + 'laptop_mac', + 'laptop_windows', + 'last_page', + 'launch', + 'layers', + 'layers_clear', + 'leak_add', + 'leak_remove', + 'lens', + 'library_add', + 'library_books', + 'library_music', + 'lightbulb_outline', + 'line_style', + 'line_weight', + 'linear_scale', + 'link', + 'linked_camera', + 'list', + 'live_help', + 'live_tv', + 'local_activity', + 'local_airport', + 'local_atm', + 'local_bar', + 'local_cafe', + 'local_car_wash', + 'local_convenience_store', + 'local_dining', + 'local_drink', + 'local_florist', + 'local_gas_station', + 'local_grocery_store', + 'local_hospital', + 'local_hotel', + 'local_laundry_service', + 'local_library', + 'local_mall', + 'local_movies', + 'local_offer', + 'local_parking', + 'local_pharmacy', + 'local_phone', + 'local_pizza', + 'local_play', + 'local_post_office', + 'local_printshop', + 'local_see', + 'local_shipping', + 'local_taxi', + 'location_city', + 'location_disabled', + 'location_off', + 'location_on', + 'location_searching', + 'lock', + 'lock_open', + 'lock_outline', + 'looks', + 'looks_3', + 'looks_4', + 'looks_5', + 'looks_6', + 'looks_one', + 'looks_two', + 'loop', + 'loupe', + 'low_priority', + 'loyalty', + 'mail', + 'mail_outline', + 'map', + 'markunread', + 'markunread_mailbox', + 'memory', + 'menu', + 'merge_type', + 'message', + 'mic', + 'mic_none', + 'mic_off', + 'mms', + 'mode_comment', + 'mode_edit', + 'monetization_on', + 'money_off', + 'monochrome_photos', + 'mood', + 'mood_bad', + 'more', + 'more_horiz', + 'more_vert', + 'motorcycle', + 'mouse', + 'move_to_inbox', + 'movie', + 'movie_creation', + 'movie_filter', + 'multiline_chart', + 'music_note', + 'music_video', + 'my_location', + 'nature', + 'nature_people', + 'navigate_before', + 'navigate_next', + 'navigation', + 'near_me', + 'network_cell', + 'network_check', + 'network_locked', + 'network_wifi', + 'new_releases', + 'next_week', + 'nfc', + 'no_encryption', + 'no_sim', + 'not_interested', + 'note', + 'note_add', + 'notifications', + 'notifications_active', + 'notifications_none', + 'notifications_off', + 'notifications_paused', + 'offline_pin', + 'ondemand_video', + 'opacity', + 'open_in_browser', + 'open_in_new', + 'open_with', + 'pages', + 'pageview', + 'palette', + 'pan_tool', + 'panorama', + 'panorama_fish_eye', + 'panorama_horizontal', + 'panorama_vertical', + 'panorama_wide_angle', + 'party_mode', + 'pause', + 'pause_circle_filled', + 'pause_circle_outline', + 'payment', + 'people', + 'people_outline', + 'perm_camera_mic', + 'perm_contact_calendar', + 'perm_data_setting', + 'perm_device_information', + 'perm_identity', + 'perm_media', + 'perm_phone_msg', + 'perm_scan_wifi', + 'person', + 'person_add', + 'person_outline', + 'person_pin', + 'person_pin_circle', + 'personal_video', + 'pets', + 'phone', + 'phone_android', + 'phone_bluetooth_speaker', + 'phone_forwarded', + 'phone_in_talk', + 'phone_iphone', + 'phone_locked', + 'phone_missed', + 'phone_paused', + 'phonelink', + 'phonelink_erase', + 'phonelink_lock', + 'phonelink_off', + 'phonelink_ring', + 'phonelink_setup', + 'photo', + 'photo_album', + 'photo_camera', + 'photo_filter', + 'photo_library', + 'photo_size_select_actual', + 'photo_size_select_large', + 'photo_size_select_small', + 'picture_as_pdf', + 'picture_in_picture', + 'picture_in_picture_alt', + 'pie_chart', + 'pie_chart_outlined', + 'pin_drop', + 'place', + 'play_arrow', + 'play_circle_filled', + 'play_circle_outline', + 'play_for_work', + 'playlist_add', + 'playlist_add_check', + 'playlist_play', + 'plus_one', + 'poll', + 'polymer', + 'pool', + 'portable_wifi_off', + 'portrait', + 'power', + 'power_input', + 'power_settings_new', + 'pregnant_woman', + 'present_to_all', + 'print', + 'priority_high', + 'public', + 'publish', + 'query_builder', + 'question_answer', + 'queue', + 'queue_music', + 'queue_play_next', + 'radio', + 'radio_button_checked', + 'radio_button_unchecked', + 'rate_review', + 'receipt', + 'recent_actors', + 'record_voice_over', + 'redeem', + 'redo', + 'refresh', + 'remove', + 'remove_circle', + 'remove_circle_outline', + 'remove_from_queue', + 'remove_red_eye', + 'remove_shopping_cart', + 'reorder', + 'repeat', + 'repeat_one', + 'replay', + 'replay_10', + 'replay_30', + 'replay_5', + 'reply', + 'reply_all', + 'report', + 'report_problem', + 'restaurant', + 'restaurant_menu', + 'restore', + 'restore_page', + 'ring_volume', + 'room', + 'room_service', + 'rotate_90_degrees_ccw', + 'rotate_left', + 'rotate_right', + 'rounded_corner', + 'router', + 'rowing', + 'rss_feed', + 'rv_hookup', + 'satellite', + 'save', + 'scanner', + 'schedule', + 'school', + 'screen_lock_landscape', + 'screen_lock_portrait', + 'screen_lock_rotation', + 'screen_rotation', + 'screen_share', + 'sd_card', + 'sd_storage', + 'search', + 'security', + 'select_all', + 'send', + 'sentiment_dissatisfied', + 'sentiment_neutral', + 'sentiment_satisfied', + 'sentiment_very_dissatisfied', + 'sentiment_very_satisfied', + 'settings', + 'settings_applications', + 'settings_backup_restore', + 'settings_bluetooth', + 'settings_brightness', + 'settings_cell', + 'settings_ethernet', + 'settings_input_antenna', + 'settings_input_component', + 'settings_input_composite', + 'settings_input_hdmi', + 'settings_input_svideo', + 'settings_overscan', + 'settings_phone', + 'settings_power', + 'settings_remote', + 'settings_system_daydream', + 'settings_voice', + 'share', + 'shop', + 'shop_two', + 'shopping_basket', + 'shopping_cart', + 'short_text', + 'show_chart', + 'shuffle', + 'signal_cellular_4_bar', + 'signal_cellular_connected_no_internet_4_bar', + 'signal_cellular_no_sim', + 'signal_cellular_null', + 'signal_cellular_off', + 'signal_wifi_4_bar', + 'signal_wifi_4_bar_lock', + 'signal_wifi_off', + 'sim_card', + 'sim_card_alert', + 'skip_next', + 'skip_previous', + 'slideshow', + 'slow_motion_video', + 'smartphone', + 'smoke_free', + 'smoking_rooms', + 'sms', + 'sms_failed', + 'snooze', + 'sort', + 'sort_by_alpha', + 'spa', + 'space_bar', + 'speaker', + 'speaker_group', + 'speaker_notes', + 'speaker_notes_off', + 'speaker_phone', + 'spellcheck', + 'star', + 'star_border', + 'star_half', + 'stars', + 'stay_current_landscape', + 'stay_current_portrait', + 'stay_primary_landscape', + 'stay_primary_portrait', + 'stop', + 'stop_screen_share', + 'storage', + 'store', + 'store_mall_directory', + 'straighten', + 'streetview', + 'strikethrough_s', + 'style', + 'subdirectory_arrow_left', + 'subdirectory_arrow_right', + 'subject', + 'subscriptions', + 'subtitles', + 'subway', + 'supervisor_account', + 'surround_sound', + 'swap_calls', + 'swap_horiz', + 'swap_vert', + 'swap_vertical_circle', + 'switch_camera', + 'switch_video', + 'sync', + 'sync_disabled', + 'sync_problem', + 'system_update', + 'system_update_alt', + 'tab', + 'tab_unselected', + 'tablet', + 'tablet_android', + 'tablet_mac', + 'tag_faces', + 'tap_and_play', + 'terrain', + 'text_fields', + 'text_format', + 'textsms', + 'texture', + 'theaters', + 'thumb_down', + 'thumb_up', + 'thumbs_up_down', + 'time_to_leave', + 'timelapse', + 'timeline', + 'timer', + 'timer_10', + 'timer_3', + 'timer_off', + 'title', + 'toc', + 'today', + 'toll', + 'tonality', + 'touch_app', + 'toys', + 'track_changes', + 'traffic', + 'train', + 'tram', + 'transfer_within_a_station', + 'transform', + 'translate', + 'trending_down', + 'trending_flat', + 'trending_up', + 'tune', + 'turned_in', + 'turned_in_not', + 'tv', + 'unarchive', + 'undo', + 'unfold_less', + 'unfold_more', + 'update', + 'usb', + 'verified_user', + 'vertical_align_bottom', + 'vertical_align_center', + 'vertical_align_top', + 'vibration', + 'video_call', + 'video_label', + 'video_library', + 'videocam', + 'videocam_off', + 'videogame_asset', + 'view_agenda', + 'view_array', + 'view_carousel', + 'view_column', + 'view_comfy', + 'view_compact', + 'view_day', + 'view_headline', + 'view_list', + 'view_module', + 'view_quilt', + 'view_stream', + 'view_week', + 'vignette', + 'visibility', + 'visibility_off', + 'voice_chat', + 'voicemail', + 'volume_down', + 'volume_mute', + 'volume_off', + 'volume_up', + 'vpn_key', + 'vpn_lock', + 'wallpaper', + 'warning', + 'watch', + 'watch_later', + 'wb_auto', + 'wb_cloudy', + 'wb_incandescent', + 'wb_iridescent', + 'wb_sunny', + 'wc', + 'web', + 'web_asset', + 'weekend', + 'whatshot', + 'widgets', + 'wifi', + 'wifi_lock', + 'wifi_tethering', + 'work', + 'wrap_text', + 'youtube_searched_for', + 'zoom_in', + 'zoom_out', + 'zoom_out_map', +]; diff --git a/frontend/src/component/archive/ArchiveTable/ArchiveTable.tsx b/frontend/src/component/archive/ArchiveTable/ArchiveTable.tsx new file mode 100644 index 0000000000..714e799804 --- /dev/null +++ b/frontend/src/component/archive/ArchiveTable/ArchiveTable.tsx @@ -0,0 +1,310 @@ +import { PageContent } from 'component/common/PageContent/PageContent'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { TablePlaceholder, VirtualizedTable } from 'component/common/Table'; +import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table'; +import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { useMediaQuery } from '@mui/material'; +import { sortTypes } from 'utils/sortTypes'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell'; +import { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { Search } from 'component/common/Search/Search'; +import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell'; +import { FeatureSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureSeenCell'; +import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell'; +import { FeatureStaleCell } from 'component/feature/FeatureToggleList/FeatureStaleCell/FeatureStaleCell'; +import { ArchivedFeatureActionCell } from 'component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureActionCell'; +import { featuresPlaceholder } from 'component/feature/FeatureToggleList/FeatureToggleListTable'; +import theme from 'themes/theme'; +import { FeatureSchema } from 'openapi'; +import { useFeatureArchiveApi } from 'hooks/api/actions/useFeatureArchiveApi/useReviveFeatureApi'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { useSearch } from 'hooks/useSearch'; +import { FeatureArchivedCell } from './FeatureArchivedCell/FeatureArchivedCell'; +import { useSearchParams } from 'react-router-dom'; +import { ArchivedFeatureDeleteConfirm } from './ArchivedFeatureActionCell/ArchivedFeatureDeleteConfirm/ArchivedFeatureDeleteConfirm'; +import { IFeatureToggle } from 'interfaces/featureToggle'; + +export interface IFeaturesArchiveTableProps { + archivedFeatures: FeatureSchema[]; + title: string; + refetch: () => void; + loading: boolean; + storedParams: SortingRule; + setStoredParams: ( + newValue: + | SortingRule + | ((prev: SortingRule) => SortingRule) + ) => SortingRule; + projectId?: string; +} + +export const ArchiveTable = ({ + archivedFeatures = [], + loading, + refetch, + storedParams, + setStoredParams, + title, + projectId, +}: IFeaturesArchiveTableProps) => { + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg')); + const { setToastData, setToastApiError } = useToast(); + + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [deletedFeature, setDeletedFeature] = useState(); + + const [searchParams, setSearchParams] = useSearchParams(); + const { reviveFeature } = useFeatureArchiveApi(); + + const [searchValue, setSearchValue] = useState( + searchParams.get('search') || '' + ); + + const onRevive = useCallback( + async (feature: string) => { + try { + await reviveFeature(feature); + await refetch(); + setToastData({ + type: 'success', + title: "And we're back!", + text: 'The feature toggle has been revived.', + }); + } catch (e: unknown) { + setToastApiError(formatUnknownError(e)); + } + }, + [refetch, reviveFeature, setToastApiError, setToastData] + ); + + const columns = useMemo( + () => [ + { + id: 'Seen', + Header: 'Seen', + width: 85, + canSort: true, + Cell: FeatureSeenCell, + accessor: 'lastSeenAt', + align: 'center', + }, + { + Header: 'Type', + accessor: 'type', + width: 85, + canSort: true, + Cell: FeatureTypeCell, + align: 'center', + }, + { + Header: 'Name', + accessor: 'name', + searchable: true, + minWidth: 100, + Cell: ({ value, row: { original } }: any) => ( + + ), + sortType: 'alphanumeric', + }, + { + Header: 'Created', + accessor: 'createdAt', + width: 150, + Cell: DateCell, + sortType: 'date', + }, + { + Header: 'Archived', + accessor: 'archivedAt', + width: 150, + Cell: FeatureArchivedCell, + sortType: 'date', + }, + ...(!projectId + ? [ + { + Header: 'Project ID', + accessor: 'project', + sortType: 'alphanumeric', + filterName: 'project', + searchable: true, + maxWidth: 170, + Cell: ({ value }: any) => ( + + ), + }, + ] + : []), + { + Header: 'State', + accessor: 'stale', + Cell: FeatureStaleCell, + sortType: 'boolean', + maxWidth: 120, + filterName: 'state', + filterParsing: (value: any) => (value ? 'stale' : 'active'), + }, + { + Header: 'Actions', + id: 'Actions', + align: 'center', + maxWidth: 120, + canSort: false, + Cell: ({ row: { original: feature } }: any) => ( + onRevive(feature.name)} + onDelete={() => { + setDeletedFeature(feature); + setDeleteModalOpen(true); + }} + /> + ), + }, + // Always hidden -- for search + { + accessor: 'description', + }, + ], + //eslint-disable-next-line + [projectId] + ); + + const { + data: searchedData, + getSearchText, + getSearchContext, + } = useSearch(columns, searchValue, archivedFeatures); + + const data = useMemo( + () => (loading ? featuresPlaceholder : searchedData), + [searchedData, loading] + ); + + const [initialState] = useState(() => ({ + sortBy: [ + { + id: searchParams.get('sort') || storedParams.id, + desc: searchParams.has('order') + ? searchParams.get('order') === 'desc' + : storedParams.desc, + }, + ], + hiddenColumns: ['description'], + })); + + const { + headerGroups, + rows, + state: { sortBy }, + prepareRow, + setHiddenColumns, + } = useTable( + { + columns: columns as any[], // TODO: fix after `react-table` v8 update + data, + initialState, + sortTypes, + disableSortRemove: true, + autoResetSortBy: false, + }, + useFlexLayout, + useSortBy + ); + + useEffect(() => { + const hiddenColumns = ['description']; + if (isMediumScreen) { + hiddenColumns.push('lastSeenAt', 'status'); + } + if (isSmallScreen) { + hiddenColumns.push('type', 'createdAt'); + } + setHiddenColumns(hiddenColumns); + }, [setHiddenColumns, isSmallScreen, isMediumScreen]); + + useEffect(() => { + if (loading) { + return; + } + const tableState: Record = {}; + tableState.sort = sortBy[0].id; + if (sortBy[0].desc) { + tableState.order = 'desc'; + } + if (searchValue) { + tableState.search = searchValue; + } + + setSearchParams(tableState, { + replace: true, + }); + setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false }); + }, [loading, sortBy, searchValue]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + } + /> + } + > + + + + ( + 0} + show={ + + No feature toggles found matching “ + {searchValue}” + + } + elseShow={ + + None of the feature toggles were archived yet. + + } + /> + )} + /> + + + ); +}; diff --git a/frontend/src/component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureActionCell.tsx b/frontend/src/component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureActionCell.tsx new file mode 100644 index 0000000000..f536af806f --- /dev/null +++ b/frontend/src/component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureActionCell.tsx @@ -0,0 +1,41 @@ +import { VFC } from 'react'; +import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell'; +import { Delete, Undo } from '@mui/icons-material'; +import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; +import { + DELETE_FEATURE, + UPDATE_FEATURE, +} from 'component/providers/AccessProvider/permissions'; + +interface IReviveArchivedFeatureCell { + onRevive: () => void; + onDelete: () => void; + project: string; +} + +export const ArchivedFeatureActionCell: VFC = ({ + onRevive, + onDelete, + project, +}) => { + return ( + + + + + + + + + ); +}; diff --git a/frontend/src/component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureDeleteConfirm/ArchivedFeatureDeleteConfirm.tsx b/frontend/src/component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureDeleteConfirm/ArchivedFeatureDeleteConfirm.tsx new file mode 100644 index 0000000000..0ab88b915f --- /dev/null +++ b/frontend/src/component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureDeleteConfirm/ArchivedFeatureDeleteConfirm.tsx @@ -0,0 +1,100 @@ +import { Alert, styled } from '@mui/material'; +import React, { useState } from 'react'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import Input from 'component/common/Input/Input'; +import { IFeatureToggle } from 'interfaces/featureToggle'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { useFeatureArchiveApi } from 'hooks/api/actions/useFeatureArchiveApi/useReviveFeatureApi'; +import useToast from 'hooks/useToast'; + +interface IArchivedFeatureDeleteConfirmProps { + deletedFeature?: IFeatureToggle; + open: boolean; + setOpen: React.Dispatch>; + refetch: () => void; +} + +const StyledDeleteParagraph = styled('p')(({ theme }) => ({ + marginTop: theme.spacing(4), +})); + +const StyledFormInput = styled(Input)(({ theme }) => ({ + marginTop: theme.spacing(2), + width: '100%', +})); + +export const ArchivedFeatureDeleteConfirm = ({ + deletedFeature, + open, + setOpen, + refetch, +}: IArchivedFeatureDeleteConfirmProps) => { + const [confirmName, setConfirmName] = useState(''); + const { setToastData, setToastApiError } = useToast(); + const { deleteFeature } = useFeatureArchiveApi(); + + const onDeleteFeatureToggle = async () => { + try { + if (!deletedFeature) { + return; + } + await deleteFeature(deletedFeature.name); + await refetch(); + setToastData({ + type: 'success', + title: 'Feature toggle deleted', + text: `You have successfully deleted the ${deletedFeature.name} feature toggle.`, + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } finally { + clearModal(); + } + }; + + const clearModal = () => { + setOpen(false); + setConfirmName(''); + }; + + const formId = 'delete-feature-toggle-confirmation-form'; + + return ( + + + Warning! Before you delete a feature toggle, make sure + all in-code references to that feature toggle have been removed. + Otherwise, a new feature toggle with the same name could + activate the old code paths. + + + + In order to delete this feature toggle, please enter its name in + the text field below: +
    + {deletedFeature?.name} +
    + +
    + ) => { + setConfirmName(e.currentTarget.value); + }} + value={confirmName} + placeholder="" + label="Feature toggle name" + /> + +
    + ); +}; diff --git a/frontend/src/component/archive/ArchiveTable/FeatureArchivedCell/FeatureArchivedCell.tsx b/frontend/src/component/archive/ArchiveTable/FeatureArchivedCell/FeatureArchivedCell.tsx new file mode 100644 index 0000000000..7cd1177199 --- /dev/null +++ b/frontend/src/component/archive/ArchiveTable/FeatureArchivedCell/FeatureArchivedCell.tsx @@ -0,0 +1,49 @@ +import { VFC } from 'react'; +import TimeAgo from 'react-timeago'; +import { Tooltip, Typography, useTheme } from '@mui/material'; +import { formatDateYMD } from 'utils/formatDate'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; +import { useLocationSettings } from 'hooks/useLocationSettings'; + +interface IFeatureArchivedCellProps { + value?: string | Date | null; +} + +export const FeatureArchivedCell: VFC = ({ + value: archivedAt, +}) => { + const { locationSettings } = useLocationSettings(); + const theme = useTheme(); + + if (!archivedAt) + return ( + + + not available + + + ); + + return ( + + + + + + + + ); +}; diff --git a/frontend/src/component/archive/FeaturesArchiveTable.tsx b/frontend/src/component/archive/FeaturesArchiveTable.tsx new file mode 100644 index 0000000000..1fde18d557 --- /dev/null +++ b/frontend/src/component/archive/FeaturesArchiveTable.tsx @@ -0,0 +1,32 @@ +import { useFeaturesArchive } from 'hooks/api/getters/useFeaturesArchive/useFeaturesArchive'; +import { ArchiveTable } from './ArchiveTable/ArchiveTable'; +import { SortingRule } from 'react-table'; +import { usePageTitle } from 'hooks/usePageTitle'; +import { createLocalStorage } from 'utils/createLocalStorage'; + +const defaultSort: SortingRule = { id: 'createdAt' }; +const { value, setValue } = createLocalStorage( + 'FeaturesArchiveTable:v1', + defaultSort +); + +export const FeaturesArchiveTable = () => { + usePageTitle('Archive'); + + const { + archivedFeatures = [], + loading, + refetchArchived, + } = useFeaturesArchive(); + + return ( + + ); +}; diff --git a/frontend/src/component/archive/ProjectFeaturesArchiveTable.tsx b/frontend/src/component/archive/ProjectFeaturesArchiveTable.tsx new file mode 100644 index 0000000000..5f200dfa00 --- /dev/null +++ b/frontend/src/component/archive/ProjectFeaturesArchiveTable.tsx @@ -0,0 +1,37 @@ +import { ArchiveTable } from './ArchiveTable/ArchiveTable'; +import { SortingRule } from 'react-table'; +import { useProjectFeaturesArchive } from 'hooks/api/getters/useProjectFeaturesArchive/useProjectFeaturesArchive'; +import { createLocalStorage } from 'utils/createLocalStorage'; + +const defaultSort: SortingRule = { id: 'archivedAt' }; + +interface IProjectFeaturesTable { + projectId: string; +} + +export const ProjectFeaturesArchiveTable = ({ + projectId, +}: IProjectFeaturesTable) => { + const { + archivedFeatures = [], + refetchArchived, + loading, + } = useProjectFeaturesArchive(projectId); + + const { value, setValue } = createLocalStorage( + `${projectId}:ProjectFeaturesArchiveTable`, + defaultSort + ); + + return ( + + ); +}; diff --git a/frontend/src/component/archive/RedirectArchive.tsx b/frontend/src/component/archive/RedirectArchive.tsx new file mode 100644 index 0000000000..e0cbfe0f31 --- /dev/null +++ b/frontend/src/component/archive/RedirectArchive.tsx @@ -0,0 +1,7 @@ +import { Navigate } from 'react-router-dom'; + +const RedirectArchive = () => { + return ; +}; + +export default RedirectArchive; diff --git a/frontend/src/component/common/AdminAlert/AdminAlert.tsx b/frontend/src/component/common/AdminAlert/AdminAlert.tsx new file mode 100644 index 0000000000..71a5b84253 --- /dev/null +++ b/frontend/src/component/common/AdminAlert/AdminAlert.tsx @@ -0,0 +1,9 @@ +import { Alert } from '@mui/material'; + +export const AdminAlert = () => { + return ( + + You need instance admin to access this section. + + ); +}; diff --git a/frontend/src/component/common/AnimateOnMount/AnimateOnMount.tsx b/frontend/src/component/common/AnimateOnMount/AnimateOnMount.tsx new file mode 100644 index 0000000000..e05f212a43 --- /dev/null +++ b/frontend/src/component/common/AnimateOnMount/AnimateOnMount.tsx @@ -0,0 +1,72 @@ +import React, { useEffect, useState, useRef, FC } from 'react'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; + +interface IAnimateOnMountProps { + mounted: boolean; + enter: string; + start: string; + leave: string; + container?: string; + style?: React.CSSProperties; + onStart?: () => void; + onEnd?: () => void; +} + +const AnimateOnMount: FC = ({ + mounted, + enter, + start, + leave, + container, + children, + style, + onStart, + onEnd, +}) => { + const [show, setShow] = useState(mounted); + const [styles, setStyles] = useState(''); + const mountedRef = useRef(null); + + useEffect(() => { + if (mountedRef.current !== mounted || mountedRef === null) { + if (mounted) { + setShow(true); + onStart?.(); + setTimeout(() => { + setStyles(enter); + }, 50); + } else { + if (!leave) { + setShow(false); + } + setStyles(leave); + } + } + }, [mounted, enter, onStart, leave]); + + const onTransitionEnd = () => { + if (!mounted) { + setShow(false); + onEnd?.(); + } + }; + + return ( + + {children} + + } + /> + ); +}; + +export default AnimateOnMount; diff --git a/frontend/src/component/common/Announcer/AnnouncerContext/AnnouncerContext.test.tsx b/frontend/src/component/common/Announcer/AnnouncerContext/AnnouncerContext.test.tsx new file mode 100644 index 0000000000..97851c3547 --- /dev/null +++ b/frontend/src/component/common/Announcer/AnnouncerContext/AnnouncerContext.test.tsx @@ -0,0 +1,24 @@ +import { render } from 'utils/testRenderer'; +import { AnnouncerContext } from 'component/common/Announcer/AnnouncerContext/AnnouncerContext'; +import { useContext, useEffect } from 'react'; +import { screen } from '@testing-library/react'; +import { ANNOUNCER_ELEMENT_TEST_ID } from 'utils/testIds'; + +test('AnnouncerContext', async () => { + const TestComponent = () => { + const { setAnnouncement } = useContext(AnnouncerContext); + + useEffect(() => { + setAnnouncement('Foo'); + setAnnouncement('Bar'); + }, [setAnnouncement]); + + return null; + }; + + render(); + + const el = screen.getByTestId(ANNOUNCER_ELEMENT_TEST_ID); + expect(el).not.toHaveTextContent('Foo'); + expect(el).toHaveTextContent('Bar'); +}); diff --git a/frontend/src/component/common/Announcer/AnnouncerContext/AnnouncerContext.tsx b/frontend/src/component/common/Announcer/AnnouncerContext/AnnouncerContext.tsx new file mode 100644 index 0000000000..5d8e4f429a --- /dev/null +++ b/frontend/src/component/common/Announcer/AnnouncerContext/AnnouncerContext.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +export interface IAnnouncerContext { + setAnnouncement: React.Dispatch>; +} + +const setAnnouncementPlaceholder = () => { + throw new Error('setAnnouncement called outside AnnouncerContext'); +}; + +// AnnouncerContext announces messages to screen readers through a live region. +// Call setAnnouncement to broadcast a new message to the screen reader. +export const AnnouncerContext = React.createContext({ + setAnnouncement: setAnnouncementPlaceholder, +}); diff --git a/frontend/src/component/common/Announcer/AnnouncerElement/AnnouncerElement.styles.ts b/frontend/src/component/common/Announcer/AnnouncerElement/AnnouncerElement.styles.ts new file mode 100644 index 0000000000..5e55220bdb --- /dev/null +++ b/frontend/src/component/common/Announcer/AnnouncerElement/AnnouncerElement.styles.ts @@ -0,0 +1,14 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()({ + container: { + clip: 'rect(0 0 0 0)', + clipPath: 'inset(50%)', + zIndex: -1, + width: 1, + height: 1, + margin: -1, + padding: 0, + overflow: 'hidden', + }, +}); diff --git a/frontend/src/component/common/Announcer/AnnouncerElement/AnnouncerElement.tsx b/frontend/src/component/common/Announcer/AnnouncerElement/AnnouncerElement.tsx new file mode 100644 index 0000000000..b58321b075 --- /dev/null +++ b/frontend/src/component/common/Announcer/AnnouncerElement/AnnouncerElement.tsx @@ -0,0 +1,25 @@ +import React, { ReactElement } from 'react'; +import { useStyles } from 'component/common/Announcer/AnnouncerElement/AnnouncerElement.styles'; +import { ANNOUNCER_ELEMENT_TEST_ID } from 'utils/testIds'; + +interface IAnnouncerElementProps { + announcement?: string; +} + +export const AnnouncerElement = ({ + announcement, +}: IAnnouncerElementProps): ReactElement => { + const { classes: styles } = useStyles(); + + return ( +
    + {announcement} +
    + ); +}; diff --git a/frontend/src/component/common/Announcer/AnnouncerProvider/AnnouncerProvider.tsx b/frontend/src/component/common/Announcer/AnnouncerProvider/AnnouncerProvider.tsx new file mode 100644 index 0000000000..a2fdc63e4f --- /dev/null +++ b/frontend/src/component/common/Announcer/AnnouncerProvider/AnnouncerProvider.tsx @@ -0,0 +1,27 @@ +import React, { ReactElement, useMemo, useState, ReactNode } from 'react'; +import { AnnouncerContext } from '../AnnouncerContext/AnnouncerContext'; +import { AnnouncerElement } from 'component/common/Announcer/AnnouncerElement/AnnouncerElement'; + +interface IAnnouncerProviderProps { + children: ReactNode; +} + +export const AnnouncerProvider = ({ + children, +}: IAnnouncerProviderProps): ReactElement => { + const [announcement, setAnnouncement] = useState(); + + const value = useMemo( + () => ({ + setAnnouncement, + }), + [setAnnouncement] + ); + + return ( + + {children} + + + ); +}; diff --git a/frontend/src/component/common/ApiError/ApiError.tsx b/frontend/src/component/common/ApiError/ApiError.tsx new file mode 100644 index 0000000000..379b0172d6 --- /dev/null +++ b/frontend/src/component/common/ApiError/ApiError.tsx @@ -0,0 +1,34 @@ +import { Button } from '@mui/material'; +import { Alert } from '@mui/material'; +import React from 'react'; + +interface IApiErrorProps { + className?: string; + onClick: () => void; + text: string; + style?: React.CSSProperties; +} + +const ApiError: React.FC = ({ + className, + onClick, + text, + ...rest +}) => { + return ( + + TRY AGAIN + + } + severity="error" + {...rest} + > + {text} + + ); +}; + +export default ApiError; diff --git a/frontend/src/component/common/AutocompleteBox/AutocompleteBox.styles.ts b/frontend/src/component/common/AutocompleteBox/AutocompleteBox.styles.ts new file mode 100644 index 0000000000..d7b20bf80e --- /dev/null +++ b/frontend/src/component/common/AutocompleteBox/AutocompleteBox.styles.ts @@ -0,0 +1,44 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + display: 'flex', + alignItems: 'center', + borderRadius: '1rem', + '& .MuiInputLabel-root[data-shrink="false"]': { + top: 3, + }, + }, + icon: { + background: theme.palette.featureSegmentSearchBackground, + height: 48, + width: 48, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + paddingLeft: 6, + borderTopLeftRadius: 40, + borderBottomLeftRadius: 40, + color: '#fff', + }, + iconDisabled: { + background: theme.palette.primary.light, + }, + autocomplete: { + flex: 1, + }, + inputRoot: { + height: 48, + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + borderTopRightRadius: 50, + borderBottomRightRadius: 50, + '& fieldset': { + borderColor: theme.palette.grey[300], + borderLeftColor: 'transparent', + }, + '&.Mui-focused .MuiOutlinedInput-notchedOutline': { + borderWidth: 1, + }, + }, +})); diff --git a/frontend/src/component/common/AutocompleteBox/AutocompleteBox.tsx b/frontend/src/component/common/AutocompleteBox/AutocompleteBox.tsx new file mode 100644 index 0000000000..e43e4123a4 --- /dev/null +++ b/frontend/src/component/common/AutocompleteBox/AutocompleteBox.tsx @@ -0,0 +1,60 @@ +import { useStyles } from 'component/common/AutocompleteBox/AutocompleteBox.styles'; +import { Search, ArrowDropDown } from '@mui/icons-material'; +import { Autocomplete } from '@mui/material'; +import { AutocompleteRenderInputParams } from '@mui/material/Autocomplete'; +import { TextField } from '@mui/material'; +import classNames from 'classnames'; + +interface IAutocompleteBoxProps { + label: string; + options: IAutocompleteBoxOption[]; + value?: IAutocompleteBoxOption[]; + onChange: (value: IAutocompleteBoxOption[]) => void; + disabled?: boolean; +} + +export interface IAutocompleteBoxOption { + value: string; + label: string; +} + +export const AutocompleteBox = ({ + label, + options, + value = [], + onChange, + disabled, +}: IAutocompleteBoxProps) => { + const { classes: styles } = useStyles(); + + const renderInput = (params: AutocompleteRenderInputParams) => { + return ; + }; + + return ( +
    +
    + +
    + } + onChange={(event, value) => onChange(value || [])} + renderInput={renderInput} + getOptionLabel={value => value.label} + disabled={disabled} + size="small" + multiple + /> +
    + ); +}; diff --git a/frontend/src/component/common/Badge/Badge.tsx b/frontend/src/component/common/Badge/Badge.tsx new file mode 100644 index 0000000000..b944542e25 --- /dev/null +++ b/frontend/src/component/common/Badge/Badge.tsx @@ -0,0 +1,90 @@ +import { styled, SxProps, Theme } from '@mui/material'; +import React, { + cloneElement, + FC, + ForwardedRef, + forwardRef, + ReactElement, + ReactNode, +} from 'react'; +import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender'; + +type Color = 'info' | 'success' | 'warning' | 'error' | 'secondary' | 'neutral'; + +interface IBadgeProps { + color?: Color; + icon?: ReactElement; + className?: string; + sx?: SxProps; + children?: ReactNode; + title?: string; + onClick?: (event: React.SyntheticEvent) => void; +} + +interface IBadgeIconProps { + color?: Color; +} + +const StyledBadge = styled('div')( + ({ theme, color = 'neutral', icon }) => ({ + display: 'inline-flex', + alignItems: 'center', + padding: theme.spacing(icon ? 0.375 : 0.625, 1), + borderRadius: theme.shape.borderRadius, + fontSize: theme.fontSizes.smallerBody, + fontWeight: theme.fontWeight.bold, + lineHeight: 1, + backgroundColor: theme.palette[color].light, + color: theme.palette[color].dark, + border: `1px solid ${theme.palette[color].border}`, + }) +); + +const StyledBadgeIcon = styled('div')( + ({ theme, color = 'neutral' }) => ({ + display: 'flex', + color: theme.palette[color].main, + marginRight: theme.spacing(0.5), + }) +); + +export const Badge: FC = forwardRef( + ( + { + color = 'neutral', + icon, + className, + sx, + children, + ...props + }: IBadgeProps, + ref: ForwardedRef + ) => ( + + + + cloneElement(icon!, { + sx: { fontSize: '16px' }, + }) + } + /> + + } + /> + {children} + + ) +); diff --git a/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.styles.ts b/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.styles.ts new file mode 100644 index 0000000000..9706b056fd --- /dev/null +++ b/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.styles.ts @@ -0,0 +1,21 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + breadcrumbNav: { + position: 'absolute', + top: '4px', + }, + breadcrumbNavParagraph: { + color: 'inherit', + '& > *': { + verticalAlign: 'middle', + }, + }, + breadcrumbLink: { + textDecoration: 'none', + '& > *': { + verticalAlign: 'middle', + }, + color: theme.palette.primary.main, + }, +})); diff --git a/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx b/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx new file mode 100644 index 0000000000..4f598bd2a1 --- /dev/null +++ b/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx @@ -0,0 +1,97 @@ +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import { Link, useLocation } from 'react-router-dom'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { useStyles } from './BreadcrumbNav.styles'; +import AccessContext from 'contexts/AccessContext'; +import { useContext } from 'react'; +import StringTruncator from '../StringTruncator/StringTruncator'; + +const BreadcrumbNav = () => { + const { isAdmin } = useContext(AccessContext); + const { classes: styles } = useStyles(); + const location = useLocation(); + + const paths = location.pathname + .split('/') + .filter(item => item) + .filter( + item => + item !== 'create' && + item !== 'edit' && + item !== 'view' && + item !== 'variants' && + item !== 'logs' && + item !== 'metrics' && + item !== 'copy' && + item !== 'features' && + item !== 'features2' && + item !== 'create-toggle' && + item !== 'settings' + ); + + return ( + 1} + show={ + + {paths.map((path, index) => { + const lastItem = index === paths.length - 1; + if (lastItem) { + return ( +

    + +

    + ); + } + + let link = '/'; + + paths.forEach((path, i) => { + if (i !== index && i < index) { + link += path + '/'; + } else if (i === index) { + link += path; + } + }); + + return ( + + + + ); + })} +
    + } + /> + } + /> + ); +}; + +export default BreadcrumbNav; diff --git a/frontend/src/component/common/CheckmarkBadge/CheckMarkBadge.styles.ts b/frontend/src/component/common/CheckmarkBadge/CheckMarkBadge.styles.ts new file mode 100644 index 0000000000..33e9bdf2be --- /dev/null +++ b/frontend/src/component/common/CheckmarkBadge/CheckMarkBadge.styles.ts @@ -0,0 +1,22 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + badge: { + backgroundColor: theme.palette.checkmarkBadge, + width: '75px', + height: '75px', + borderRadius: '50px', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + [theme.breakpoints.down('sm')]: { + width: '50px', + height: '50px', + }, + }, + check: { + color: '#fff', + width: '35px', + height: '35px', + }, +})); diff --git a/frontend/src/component/common/CheckmarkBadge/CheckMarkBadge.tsx b/frontend/src/component/common/CheckmarkBadge/CheckMarkBadge.tsx new file mode 100644 index 0000000000..deb6c7dc1d --- /dev/null +++ b/frontend/src/component/common/CheckmarkBadge/CheckMarkBadge.tsx @@ -0,0 +1,23 @@ +import { Check, Close } from '@mui/icons-material'; +import { useStyles } from './CheckMarkBadge.styles'; +import classnames from 'classnames'; + +interface ICheckMarkBadgeProps { + className: string; + type?: string; +} + +const CheckMarkBadge = ({ type, className }: ICheckMarkBadgeProps) => { + const { classes: styles } = useStyles(); + return ( +
    + {type === 'error' ? ( + + ) : ( + + )} +
    + ); +}; + +export default CheckMarkBadge; diff --git a/frontend/src/component/common/Codebox/Codebox.styles.ts b/frontend/src/component/common/Codebox/Codebox.styles.ts new file mode 100644 index 0000000000..f1bbb2e8a2 --- /dev/null +++ b/frontend/src/component/common/Codebox/Codebox.styles.ts @@ -0,0 +1,27 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + backgroundColor: theme.palette.codebox, + padding: '1rem', + borderRadius: theme.shape.borderRadiusMedium, + position: 'relative', + maxHeight: '500px', + overflow: 'auto', + }, + code: { + margin: 0, + wordBreak: 'break-all', + whiteSpace: 'pre-wrap', + color: theme.palette.formSidebarTextColor, + fontSize: 14, + }, + icon: { + fill: '#fff', + }, + iconButton: { + position: 'absolute', + bottom: '10px', + right: '20px', + }, +})); diff --git a/frontend/src/component/common/Codebox/Codebox.tsx b/frontend/src/component/common/Codebox/Codebox.tsx new file mode 100644 index 0000000000..76257cfb0e --- /dev/null +++ b/frontend/src/component/common/Codebox/Codebox.tsx @@ -0,0 +1,17 @@ +import { useStyles } from './Codebox.styles'; + +interface ICodeboxProps { + text: string; +} + +const Codebox = ({ text }: ICodeboxProps) => { + const { classes: styles } = useStyles(); + + return ( +
    +
    {text}
    +
    + ); +}; + +export default Codebox; diff --git a/frontend/src/component/common/ConditionallyRender/ConditionallyRender.tsx b/frontend/src/component/common/ConditionallyRender/ConditionallyRender.tsx new file mode 100644 index 0000000000..b87028d087 --- /dev/null +++ b/frontend/src/component/common/ConditionallyRender/ConditionallyRender.tsx @@ -0,0 +1,53 @@ +import { ReactNode } from 'react'; + +interface IConditionallyRenderProps { + condition: boolean; + show: TargetElement; + elseShow?: TargetElement; +} + +type TargetElement = + | JSX.Element + | JSX.Element[] + | RenderFunc + | ReactNode + | null; + +type RenderFunc = () => JSX.Element; + +export const ConditionallyRender = ({ + condition, + show, + elseShow, +}: IConditionallyRenderProps): JSX.Element | null => { + const handleFunction = (renderFunc: RenderFunc): JSX.Element | null => { + const result = renderFunc(); + if (!result) { + /* eslint-disable-next-line */ + console.warn( + 'Nothing was returned from your render function. Verify that you are returning a valid react component' + ); + return null; + } + return result; + }; + + const isFunc = (param: TargetElement): boolean => { + return typeof param === 'function'; + }; + + if (condition) { + if (isFunc(show)) { + return handleFunction(show as RenderFunc); + } + + return show as JSX.Element; + } + if (!condition && elseShow) { + if (isFunc(elseShow)) { + return handleFunction(elseShow as RenderFunc); + } + return elseShow as JSX.Element; + } + return null; +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordion.styles.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordion.styles.ts new file mode 100644 index 0000000000..af577bd40e --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordion.styles.ts @@ -0,0 +1,166 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + constraintIconContainer: { + backgroundColor: theme.palette.background.paper, + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginRight: theme.spacing(1), + [theme.breakpoints.down(650)]: { + marginBottom: '1rem', + marginRight: 0, + }, + }, + constraintIcon: { + fill: '#fff', + }, + accordion: { + border: `1px solid ${theme.palette.dividerAlternative}`, + borderRadius: theme.shape.borderRadiusMedium, + backgroundColor: theme.palette.constraintAccordion.background, + boxShadow: 'none', + margin: 0, + }, + accordionRoot: { + '&:before': { + opacity: '0 !important', + }, + }, + accordionEdit: { + backgroundColor: theme.palette.constraintAccordion.editBackground, + }, + headerMetaInfo: { + display: 'flex', + alignItems: 'stretch', + marginLeft: theme.spacing(1), + [theme.breakpoints.down(710)]: { + marginLeft: 0, + flexDirection: 'column', + alignItems: 'center', + width: '100%', + }, + }, + headerContainer: { + display: 'flex', + alignItems: 'center', + width: '100%', + [theme.breakpoints.down(710)]: { + flexDirection: 'column', + alignItems: 'center', + position: 'relative', + }, + }, + headerValuesContainerWrapper: { + display: 'flex', + alignItems: 'stretch', + margin: 'auto 0', + }, + headerValuesContainer: { + display: 'flex', + justifyContent: 'stretch', + margin: 'auto 0', + flexDirection: 'column', + marginLeft: theme.spacing(1), + [theme.breakpoints.down(710)]: { + marginLeft: 0, + }, + }, + headerValues: { + fontSize: theme.fontSizes.smallBody, + }, + headerValuesExpand: { + fontSize: theme.fontSizes.smallBody, + marginTop: '4px', + color: theme.palette.primary.dark, + [theme.breakpoints.down(710)]: { + textAlign: 'center', + }, + }, + headerConstraintContainer: { + minWidth: '152px', + position: 'relative', + [theme.breakpoints.down(710)]: { + paddingRight: 0, + }, + }, + editingBadge: { + borderRadius: theme.shape.borderRadiusExtraLarge, + padding: '0.25rem 0.5rem', + backgroundColor: '#635DC5', + color: '#fff', + marginLeft: 'auto', + fontSize: '0.9rem', + [theme.breakpoints.down(650)]: { + position: 'absolute', + right: 0, + top: '-10px', + }, + }, + headerText: { + maxWidth: '400px', + fontSize: theme.fontSizes.smallBody, + [theme.breakpoints.down('xl')]: { + display: 'none', + }, + }, + selectContainer: { + display: 'flex', + alignItems: 'center', + [theme.breakpoints.down(770)]: { + flexDirection: 'column', + }, + }, + bottomSelect: { + [theme.breakpoints.down(770)]: { + marginTop: '1rem', + }, + display: 'inline-flex', + }, + headerSelect: { + marginRight: '1rem', + width: '200px', + [theme.breakpoints.between(1101, 1365)]: { + width: '170px', + marginRight: '8px', + }, + }, + chip: { + margin: '0 0.5rem 0.5rem 0', + }, + chipValue: { + whiteSpace: 'pre', + }, + headerActions: { + marginLeft: 'auto', + whiteSpace: 'nowrap', + [theme.breakpoints.down(710)]: { + display: 'none', + }, + }, + accordionDetails: { + borderTop: `1px dashed ${theme.palette.grey[300]}`, + display: 'flex', + flexDirection: 'column', + }, + valuesContainer: { + padding: '1rem 0rem', + maxHeight: '400px', + overflowY: 'auto', + }, + summary: { + border: 'none', + padding: theme.spacing(0.5, 3), + '&:hover .valuesExpandLabel': { + textDecoration: 'underline', + }, + }, + settingsIcon: { + height: '32.5px', + width: '32.5px', + marginRight: '0.5rem', + fill: theme.palette.inactiveIcon, + }, + form: { padding: 0, margin: 0, width: '100%' }, +})); diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordion.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordion.tsx new file mode 100644 index 0000000000..ffb54cc4a1 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordion.tsx @@ -0,0 +1,49 @@ +import { IConstraint } from 'interfaces/strategy'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; + +import { ConstraintAccordionEdit } from './ConstraintAccordionEdit/ConstraintAccordionEdit'; +import { ConstraintAccordionView } from './ConstraintAccordionView/ConstraintAccordionView'; + +interface IConstraintAccordionProps { + compact: boolean; + editing: boolean; + constraint: IConstraint; + onCancel: () => void; + onEdit?: () => void; + onDelete?: () => void; + onSave?: (constraint: IConstraint) => void; +} + +export const ConstraintAccordion = ({ + constraint, + compact = false, + editing, + onEdit, + onCancel, + onDelete, + onSave, +}: IConstraintAccordionProps) => { + if (!constraint) return null; + + return ( + + } + elseShow={ + + } + /> + ); +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEdit.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEdit.tsx new file mode 100644 index 0000000000..73b30eb261 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEdit.tsx @@ -0,0 +1,241 @@ +import { useCallback, useEffect, useState } from 'react'; +import classnames from 'classnames'; +import { IConstraint } from 'interfaces/strategy'; +import { useStyles } from '../ConstraintAccordion.styles'; +import { ConstraintAccordionEditBody } from './ConstraintAccordionEditBody/ConstraintAccordionEditBody'; +import { ConstraintAccordionEditHeader } from './ConstraintAccordionEditHeader/ConstraintAccordionEditHeader'; +import { Accordion, AccordionDetails, AccordionSummary } from '@mui/material'; +import { cleanConstraint } from 'utils/cleanConstraint'; +import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; +import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { IUnleashContextDefinition } from 'interfaces/context'; +import { useConstraintInput } from './ConstraintAccordionEditBody/useConstraintInput/useConstraintInput'; +import { Operator } from 'constants/operators'; +import { ResolveInput } from './ConstraintAccordionEditBody/ResolveInput/ResolveInput'; + +interface IConstraintAccordionEditProps { + constraint: IConstraint; + onCancel: () => void; + onSave: (constraint: IConstraint) => void; + compact: boolean; + onDelete?: () => void; +} + +export const CANCEL = 'cancel'; +export const SAVE = 'save'; + +const resolveContextDefinition = ( + context: IUnleashContextDefinition[], + contextName: string +): IUnleashContextDefinition => { + const definition = context.find( + contextDef => contextDef.name === contextName + ); + + return ( + definition || { + name: '', + description: '', + createdAt: '', + sortOrder: 1, + stickiness: false, + } + ); +}; + +export const ConstraintAccordionEdit = ({ + constraint, + compact, + onCancel, + onSave, + onDelete, +}: IConstraintAccordionEditProps) => { + const [localConstraint, setLocalConstraint] = useState( + cleanConstraint(constraint) + ); + + const { context } = useUnleashContext(); + const [contextDefinition, setContextDefinition] = useState( + resolveContextDefinition(context, localConstraint.contextName) + ); + const { validateConstraint } = useFeatureApi(); + const [expanded, setExpanded] = useState(false); + const [action, setAction] = useState(''); + const { classes: styles } = useStyles(); + + useEffect(() => { + // Setting expanded to true on mount will cause the accordion + // animation to take effect and transition the expanded accordion in + setExpanded(true); + }, []); + + useEffect(() => { + setContextDefinition( + resolveContextDefinition(context, localConstraint.contextName) + ); + }, [localConstraint.contextName, context]); + + const setContextName = useCallback((contextName: string) => { + setLocalConstraint(prev => ({ + ...prev, + contextName, + values: [], + value: '', + })); + }, []); + + const setOperator = useCallback((operator: Operator) => { + setLocalConstraint(prev => ({ + ...prev, + operator, + values: [], + value: '', + })); + }, []); + + const setValues = useCallback((values: string[]) => { + setLocalConstraint(prev => ({ + ...prev, + values, + })); + }, []); + + const setValue = useCallback((value: string) => { + setLocalConstraint(prev => ({ ...prev, value })); + }, []); + + const setInvertedOperator = () => { + setLocalConstraint(prev => ({ ...prev, inverted: !prev.inverted })); + }; + + const setCaseInsensitive = useCallback(() => { + setLocalConstraint(prev => ({ + ...prev, + caseInsensitive: !prev.caseInsensitive, + })); + }, []); + + const removeValue = useCallback( + (index: number) => { + const valueCopy = [...localConstraint.values!]; + valueCopy.splice(index, 1); + + setValues(valueCopy); + }, + [localConstraint, setValues] + ); + + const triggerTransition = () => { + setExpanded(false); + }; + + const validateConstraintValues = () => { + const hasValues = + Array.isArray(localConstraint.values) && + Boolean(localConstraint.values.length > 0); + const hasValue = Boolean(localConstraint.value); + + if (hasValues || hasValue) { + setError(''); + return true; + } + setError('You must provide a value for the constraint'); + return false; + }; + + const onSubmit = async () => { + const hasValues = validateConstraintValues(); + if (!hasValues) return; + const [typeValidatorResult, err] = validator(); + + if (!typeValidatorResult) { + setError(err); + } + + if (typeValidatorResult) { + try { + await validateConstraint(localConstraint); + setError(''); + setAction(SAVE); + triggerTransition(); + return; + } catch (error: unknown) { + setError(formatUnknownError(error)); + } + } + }; + + const { input, validator, setError, error } = useConstraintInput({ + contextDefinition, + localConstraint, + }); + + useEffect(() => { + setError(''); + setLocalConstraint(localConstraint => cleanConstraint(localConstraint)); + }, [localConstraint.operator, localConstraint.contextName, setError]); + + return ( +
    + { + if (action === CANCEL) { + setAction(''); + onCancel(); + } else if (action === SAVE) { + setAction(''); + onSave(localConstraint); + } + }, + }} + > + + + + + + + + + + +
    + ); +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintAccordionEditBody.styles.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintAccordionEditBody.styles.ts new file mode 100644 index 0000000000..82c2efa82a --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintAccordionEditBody.styles.ts @@ -0,0 +1,27 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + inputContainer: { + padding: '1rem', + backgroundColor: theme.palette.neutral.light, + }, + buttonContainer: { + display: 'flex', + alignItems: 'center', + marginTop: '1rem', + borderTop: `1px solid ${theme.palette.grey[300]}`, + width: '100%', + padding: '1rem', + }, + innerButtonContainer: { + marginLeft: 'auto', + }, + leftButton: { + marginRight: '0.5rem', + minWidth: '125px', + }, + rightButton: { + marginLeft: '0.5rem', + minWidth: '125px', + }, +})); diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintAccordionEditBody.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintAccordionEditBody.tsx new file mode 100644 index 0000000000..ebfcb4d3a9 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintAccordionEditBody.tsx @@ -0,0 +1,59 @@ +import { Button } from '@mui/material'; +import { IConstraint } from 'interfaces/strategy'; +import { CANCEL } from '../ConstraintAccordionEdit'; + +import { useStyles } from './ConstraintAccordionEditBody.styles'; +import React from 'react'; +import { newOperators } from 'constants/operators'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { oneOf } from 'utils/oneOf'; +import { OperatorUpgradeAlert } from 'component/common/OperatorUpgradeAlert/OperatorUpgradeAlert'; + +interface IConstraintAccordionBody { + localConstraint: IConstraint; + setValues: (values: string[]) => void; + triggerTransition: () => void; + setValue: (value: string) => void; + setAction: React.Dispatch>; + onSubmit: () => void; +} + +export const ConstraintAccordionEditBody: React.FC< + IConstraintAccordionBody +> = ({ localConstraint, children, triggerTransition, setAction, onSubmit }) => { + const { classes: styles } = useStyles(); + + return ( + <> +
    + } + /> + {children} +
    +
    +
    + + +
    +
    + + ); +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintFormHeader/ConstraintFormHeader.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintFormHeader/ConstraintFormHeader.tsx new file mode 100644 index 0000000000..be2ab9b64c --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintFormHeader/ConstraintFormHeader.tsx @@ -0,0 +1,22 @@ +import { makeStyles } from 'tss-react/mui'; +import React from 'react'; + +const useStyles = makeStyles()(theme => ({ + header: { + fontSize: theme.fontSizes.bodySize, + fontWeight: 'normal', + marginTop: '1rem', + marginBottom: '0.25rem', + }, +})); + +export const ConstraintFormHeader: React.FC< + React.HTMLAttributes +> = ({ children, ...rest }) => { + const { classes: styles } = useStyles(); + return ( +

    + {children} +

    + ); +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/DateSingleValue.test.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/DateSingleValue.test.tsx new file mode 100644 index 0000000000..ed6f46a166 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/DateSingleValue.test.tsx @@ -0,0 +1,18 @@ +import { parseDateValue } from 'component/common/util'; + +test(`Date component is able to parse midnight when it's 00`, () => { + let f = parseDateValue('2022-03-15T12:27'); + let midnight = parseDateValue('2022-03-15T00:27'); + expect(f).toEqual('2022-03-15T12:27'); + expect(midnight).toEqual('2022-03-15T00:27'); +}); + +test(`Date component - snapshot matching`, () => { + let midnight = '2022-03-15T00:00'; + let midday = '2022-03-15T12:00'; + let obj = { + midnight: parseDateValue(midnight), + midday: parseDateValue(midday), + }; + expect(obj).toMatchSnapshot(); +}); diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/DateSingleValue.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/DateSingleValue.tsx new file mode 100644 index 0000000000..39d1f900c0 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/DateSingleValue.tsx @@ -0,0 +1,42 @@ +import { ConstraintFormHeader } from '../ConstraintFormHeader/ConstraintFormHeader'; +import Input from 'component/common/Input/Input'; +import { parseDateValue, parseValidDate } from 'component/common/util'; +interface IDateSingleValueProps { + setValue: (value: string) => void; + value?: string; + error: string; + setError: React.Dispatch>; +} + +export const DateSingleValue = ({ + setValue, + value, + error, + setError, +}: IDateSingleValueProps) => { + if (!value) return null; + + return ( + <> + Select a date + { + setError(''); + const parsedDate = parseValidDate(e.target.value); + const dateString = parsedDate?.toISOString(); + dateString && setValue(dateString); + }} + InputLabelProps={{ + shrink: true, + }} + error={Boolean(error)} + errorText={error} + required + /> + + ); +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/__snapshots__/DateSingleValue.test.tsx.snap b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/__snapshots__/DateSingleValue.test.tsx.snap new file mode 100644 index 0000000000..b7338e170a --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/__snapshots__/DateSingleValue.test.tsx.snap @@ -0,0 +1,8 @@ +// Vitest Snapshot v1 + +exports[`Date component - snapshot matching 1`] = ` +{ + "midday": "2022-03-15T12:00", + "midnight": "2022-03-15T00:00", +} +`; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/FreeTextInput/FreeTextInput.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/FreeTextInput/FreeTextInput.tsx new file mode 100644 index 0000000000..91c9e2b190 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/FreeTextInput/FreeTextInput.tsx @@ -0,0 +1,167 @@ +import { Button, Chip } from '@mui/material'; +import { makeStyles } from 'tss-react/mui'; +import Input from 'component/common/Input/Input'; +import StringTruncator from 'component/common/StringTruncator/StringTruncator'; +import React, { useState } from 'react'; +import { ConstraintFormHeader } from '../ConstraintFormHeader/ConstraintFormHeader'; +import { parseParameterStrings } from 'utils/parseParameter'; + +interface IFreeTextInputProps { + values: string[]; + removeValue: (index: number) => void; + setValues: (values: string[]) => void; + beforeValues?: JSX.Element; + error: string; + setError: React.Dispatch>; +} + +const useStyles = makeStyles()(theme => ({ + valueChip: { + margin: '0 0.5rem 0.5rem 0', + }, + chipValue: { + whiteSpace: 'pre', + }, + inputContainer: { + display: 'flex', + alignItems: 'center', + [theme.breakpoints.down(700)]: { + flexDirection: 'column', + alignItems: 'flex-start', + }, + }, + inputInnerContainer: { + minWidth: '300px', + [theme.breakpoints.down(700)]: { + minWidth: '100%', + }, + }, + input: { + width: '100%', + margin: '1rem 0', + }, + button: { + marginLeft: '1rem', + [theme.breakpoints.down(700)]: { + marginLeft: 0, + marginBottom: '0.5rem', + }, + }, + valuesContainer: { marginTop: '1rem' }, +})); + +const ENTER = 'Enter'; + +export const FreeTextInput = ({ + values, + removeValue, + setValues, + error, + setError, +}: IFreeTextInputProps) => { + const [inputValues, setInputValues] = useState(''); + const { classes: styles } = useStyles(); + + const onKeyPress = (event: React.KeyboardEvent) => { + if (event.key === ENTER) { + event.preventDefault(); + addValues(); + } + }; + + const addValues = () => { + const newValues = uniqueValues([ + ...values, + ...parseParameterStrings(inputValues), + ]); + + if (newValues.length === 0) { + setError('values cannot be empty'); + } else if (newValues.some(v => v.length > 100)) { + setError('values cannot be longer than 100 characters'); + } else { + setError(''); + setInputValues(''); + setValues(newValues); + } + }; + + return ( +
    + + Set values (maximum 100 char length per value) + +
    +
    + { + setError(''); + }} + onChange={e => setInputValues(e.target.value)} + placeholder="value1, value2, value3..." + className={styles.input} + error={Boolean(error)} + errorText={error} + /> +
    + +
    +
    + +
    +
    + ); +}; + +interface IConstraintValueChipsProps { + values: string[]; + removeValue: (index: number) => void; +} + +const ConstraintValueChips = ({ + values, + removeValue, +}: IConstraintValueChipsProps) => { + const { classes: styles } = useStyles(); + return ( + <> + {values.map((value, index) => { + // Key is not ideal, but we don't have anything guaranteed to + // be unique here. + return ( + + } + key={`${value}-${index}`} + onDelete={() => removeValue(index)} + className={styles.valueChip} + /> + ); + })} + + ); +}; + +const uniqueValues = (values: T[]): T[] => { + return Array.from(new Set(values)); +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/LegalValueLabel/LegalValueLabel.styles.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/LegalValueLabel/LegalValueLabel.styles.ts new file mode 100644 index 0000000000..890e09c7b2 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/LegalValueLabel/LegalValueLabel.styles.ts @@ -0,0 +1,17 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + display: 'inline-block', + wordBreak: 'break-word', + }, + value: { + lineHeight: 1.33, + fontSize: theme.fontSizes.smallBody, + }, + description: { + lineHeight: 1.33, + fontSize: theme.fontSizes.smallerBody, + color: theme.palette.grey[700], + }, +})); diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/LegalValueLabel/LegalValueLabel.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/LegalValueLabel/LegalValueLabel.tsx new file mode 100644 index 0000000000..0c637119a8 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/LegalValueLabel/LegalValueLabel.tsx @@ -0,0 +1,39 @@ +import { ILegalValue } from 'interfaces/context'; +import { useStyles } from './LegalValueLabel.styles'; +import React from 'react'; +import { FormControlLabel } from '@mui/material'; + +interface ILegalValueTextProps { + legal: ILegalValue; + control: React.ReactElement; +} + +export const LegalValueLabel = ({ legal, control }: ILegalValueTextProps) => { + const { classes: styles } = useStyles(); + + return ( +
    + +
    {legal.value}
    +
    + {legal.description} +
    + + } + /> +
    + ); +}; + +export const filterLegalValues = ( + legalValues: ILegalValue[], + filter: string +): ILegalValue[] => { + return legalValues.filter(legalValue => { + return legalValue.value.includes(filter); + }); +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ResolveInput/ResolveInput.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ResolveInput/ResolveInput.tsx new file mode 100644 index 0000000000..a957d1df4e --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ResolveInput/ResolveInput.tsx @@ -0,0 +1,152 @@ +import { IUnleashContextDefinition } from 'interfaces/context'; +import { IConstraint } from 'interfaces/strategy'; +import { DateSingleValue } from '../DateSingleValue/DateSingleValue'; +import { FreeTextInput } from '../FreeTextInput/FreeTextInput'; +import { RestrictiveLegalValues } from '../RestrictiveLegalValues/RestrictiveLegalValues'; +import { SingleLegalValue } from '../SingleLegalValue/SingleLegalValue'; +import { SingleValue } from '../SingleValue/SingleValue'; +import { + IN_OPERATORS_LEGAL_VALUES, + STRING_OPERATORS_FREETEXT, + STRING_OPERATORS_LEGAL_VALUES, + SEMVER_OPERATORS_SINGLE_VALUE, + NUM_OPERATORS_LEGAL_VALUES, + NUM_OPERATORS_SINGLE_VALUE, + SEMVER_OPERATORS_LEGAL_VALUES, + DATE_OPERATORS_SINGLE_VALUE, + IN_OPERATORS_FREETEXT, + Input, +} from '../useConstraintInput/useConstraintInput'; +import React from 'react'; + +interface IResolveInputProps { + contextDefinition: IUnleashContextDefinition; + localConstraint: IConstraint; + setValue: (value: string) => void; + setValues: (values: string[]) => void; + setError: React.Dispatch>; + removeValue: (index: number) => void; + input: Input; + error: string; +} + +export const ResolveInput = ({ + input, + contextDefinition, + localConstraint, + setValue, + setValues, + setError, + removeValue, + error, +}: IResolveInputProps) => { + const resolveInput = () => { + switch (input) { + case IN_OPERATORS_LEGAL_VALUES: + return ( + + ); + case STRING_OPERATORS_LEGAL_VALUES: + return ( + <> + + + ); + case NUM_OPERATORS_LEGAL_VALUES: + return ( + <> + Number(legalValue.value) + ) || [] + } + error={error} + setError={setError} + /> + + ); + case SEMVER_OPERATORS_LEGAL_VALUES: + return ( + <> + + + ); + case DATE_OPERATORS_SINGLE_VALUE: + return ( + + ); + case IN_OPERATORS_FREETEXT: + return ( + + ); + case STRING_OPERATORS_FREETEXT: + return ( + <> + + + ); + case NUM_OPERATORS_SINGLE_VALUE: + return ( + + ); + case SEMVER_OPERATORS_SINGLE_VALUE: + return ( + + ); + } + }; + + return <>{resolveInput()}; +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/RestrictiveLegalValues/RestrictiveLegalValues.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/RestrictiveLegalValues/RestrictiveLegalValues.tsx new file mode 100644 index 0000000000..930db206da --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/RestrictiveLegalValues/RestrictiveLegalValues.tsx @@ -0,0 +1,100 @@ +import { useEffect, useState } from 'react'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { Checkbox } from '@mui/material'; +import { useThemeStyles } from 'themes/themeStyles'; +import { ConstraintValueSearch } from 'component/common/ConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch'; +import { ConstraintFormHeader } from '../ConstraintFormHeader/ConstraintFormHeader'; +import { ILegalValue } from 'interfaces/context'; +import { + LegalValueLabel, + filterLegalValues, +} from '../LegalValueLabel/LegalValueLabel'; + +interface IRestrictiveLegalValuesProps { + legalValues: ILegalValue[]; + values: string[]; + setValues: (values: string[]) => void; + beforeValues?: JSX.Element; + error: string; + setError: React.Dispatch>; +} + +interface IValuesMap { + [key: string]: boolean; +} + +const createValuesMap = (values: string[]): IValuesMap => { + return values.reduce((result: IValuesMap, currentValue: string) => { + if (!result[currentValue]) { + result[currentValue] = true; + } + return result; + }, {}); +}; + +export const RestrictiveLegalValues = ({ + legalValues, + values, + setValues, + error, + setError, +}: IRestrictiveLegalValuesProps) => { + const [filter, setFilter] = useState(''); + const filteredValues = filterLegalValues(legalValues, filter); + + // Lazily initialise the values because there might be a lot of them. + const [valuesMap, setValuesMap] = useState(() => createValuesMap(values)); + const { classes: styles } = useThemeStyles(); + + useEffect(() => { + setValuesMap(createValuesMap(values)); + }, [values, setValuesMap]); + + const onChange = (legalValue: string) => { + setError(''); + if (valuesMap[legalValue]) { + const index = values.findIndex(value => value === legalValue); + const newValues = [...values]; + newValues.splice(index, 1); + setValues(newValues); + return; + } + + setValues([...values, legalValue]); + }; + + return ( + <> + + Select values from a predefined set + + 100} + show={ + + } + /> + {filteredValues.map(match => ( + onChange(match.value)} + name={match.value} + color="primary" + /> + } + /> + ))} + {error}

    } + /> + + ); +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleLegalValue/SingleLegalValue.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleLegalValue/SingleLegalValue.tsx new file mode 100644 index 0000000000..b83db6fa1e --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleLegalValue/SingleLegalValue.tsx @@ -0,0 +1,81 @@ +import React, { useState } from 'react'; +import { ConstraintFormHeader } from '../ConstraintFormHeader/ConstraintFormHeader'; +import { FormControl, RadioGroup, Radio } from '@mui/material'; +import { ConstraintValueSearch } from 'component/common/ConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { useThemeStyles } from 'themes/themeStyles'; +import { ILegalValue } from 'interfaces/context'; +import { + LegalValueLabel, + filterLegalValues, +} from '../LegalValueLabel/LegalValueLabel'; + +interface ISingleLegalValueProps { + setValue: (value: string) => void; + value?: string; + type: string; + legalValues: ILegalValue[]; + error: string; + setError: React.Dispatch>; +} + +export const SingleLegalValue = ({ + setValue, + value, + type, + legalValues, + error, + setError, +}: ISingleLegalValueProps) => { + const [filter, setFilter] = useState(''); + const { classes: styles } = useThemeStyles(); + const filteredValues = filterLegalValues(legalValues, filter); + + return ( + <> + + Add a single {type.toLowerCase()} value + + 100)} + show={ + + } + /> + + { + setError(''); + setValue(e.target.value); + }} + > + {filteredValues.map(match => ( + } + /> + ))} + + + } + elseShow={ +

    No valid legal values available for this operator.

    + } + /> + {error}

    } + /> + + ); +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleValue/SingleValue.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleValue/SingleValue.tsx new file mode 100644 index 0000000000..38573b7dd5 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleValue/SingleValue.tsx @@ -0,0 +1,52 @@ +import Input from 'component/common/Input/Input'; +import { makeStyles } from 'tss-react/mui'; +import { ConstraintFormHeader } from '../ConstraintFormHeader/ConstraintFormHeader'; + +interface ISingleValueProps { + setValue: (value: string) => void; + value?: string; + type: string; + error: string; + setError: React.Dispatch>; +} + +const useStyles = makeStyles()(theme => ({ + singleValueContainer: { maxWidth: '300px', marginTop: '-1rem' }, + singleValueInput: { + width: '100%', + margin: '1rem 0', + }, +})); + +export const SingleValue = ({ + setValue, + value, + type, + error, + setError, +}: ISingleValueProps) => { + const { classes: styles } = useStyles(); + return ( + <> + + Add a single {type.toLowerCase()} value + +
    + { + setError(''); + setValue(e.target.value.trim()); + }} + onFocus={() => setError('')} + placeholder={`Enter a single ${type} value`} + className={styles.singleValueInput} + error={Boolean(error)} + errorText={error} + /> +
    + + ); +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/constraintValidators.test.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/constraintValidators.test.ts new file mode 100644 index 0000000000..0dcd5870f0 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/constraintValidators.test.ts @@ -0,0 +1,110 @@ +import { + numberValidatorGenerator, + semVerValidatorGenerator, + dateValidatorGenerator, + stringValidatorGenerator, +} from './constraintValidators'; + +test('numbervalidator should accept 0', () => { + const numValidator = numberValidatorGenerator(0); + const [result, err] = numValidator(); + + expect(result).toBe(true); + expect(err).toBe(''); +}); + +test('number validator should reject value that cannot be parsed to number', () => { + const numValidator = numberValidatorGenerator('testa31'); + const [result, err] = numValidator(); + + expect(result).toBe(false); + expect(err).toBe('Value must be a number'); +}); + +test('number validator should reject NaN', () => { + const numValidator = numberValidatorGenerator(NaN); + const [result, err] = numValidator(); + + expect(result).toBe(false); + expect(err).toBe('Value must be a number'); +}); + +test('number validator should accept value that can be parsed to number', () => { + const numValidator = numberValidatorGenerator('31'); + const [result, err] = numValidator(); + + expect(result).toBe(true); + expect(err).toBe(''); +}); + +test('number validator should accept float values', () => { + const numValidator = numberValidatorGenerator('31.12'); + const [result, err] = numValidator(); + + expect(result).toBe(true); + expect(err).toBe(''); +}); + +test('semver validator should reject prefixed values', () => { + const semVerValidator = semVerValidatorGenerator('v1.4.2'); + const [result, err] = semVerValidator(); + + expect(result).toBe(false); + expect(err).toBe('Value is not a valid semver. For example 1.2.4'); +}); + +test('semver validator should reject partial semver values', () => { + const semVerValidator = semVerValidatorGenerator('4.2'); + const [result, err] = semVerValidator(); + + expect(result).toBe(false); + expect(err).toBe('Value is not a valid semver. For example 1.2.4'); +}); + +test('semver validator should accept semver complient values', () => { + const semVerValidator = semVerValidatorGenerator('1.4.2'); + const [result, err] = semVerValidator(); + + expect(result).toBe(true); + expect(err).toBe(''); +}); + +test('date validator should reject invalid date', () => { + const dateValidator = dateValidatorGenerator('114mydate2005'); + const [result, err] = dateValidator(); + + expect(result).toBe(false); + expect(err).toBe('Value must be a valid date matching RFC3339'); +}); + +test('date validator should accept valid date', () => { + const dateValidator = dateValidatorGenerator('2022-03-03T10:15:23.262Z'); + const [result, err] = dateValidator(); + + expect(result).toBe(true); + expect(err).toBe(''); +}); + +test('string validator should accept a list of strings', () => { + const stringValidator = stringValidatorGenerator(['1234', '4121']); + const [result, err] = stringValidator(); + + expect(result).toBe(true); + expect(err).toBe(''); +}); + +test('string validator should reject values that are not arrays', () => { + const stringValidator = stringValidatorGenerator(4); + const [result, err] = stringValidator(); + + expect(result).toBe(false); + expect(err).toBe('Values must be a list of strings'); +}); + +test('string validator should reject arrays that are not arrays of strings', () => { + const stringValidator = stringValidatorGenerator(['test', NaN, 5]); + const [result, err] = stringValidator(); + + expect(result).toBe(false); + expect(err).toBe('Values must be a list of strings'); +}); diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/constraintValidators.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/constraintValidators.ts new file mode 100644 index 0000000000..cf23d3e420 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/constraintValidators.ts @@ -0,0 +1,55 @@ +import { isValid, parseISO } from 'date-fns'; +import semver from 'semver'; + +export type ConstraintValidatorOutput = [boolean, string]; + +export const numberValidatorGenerator = (value: unknown) => { + return (): ConstraintValidatorOutput => { + const converted = Number(value); + + if (typeof converted !== 'number' || Number.isNaN(converted)) { + return [false, 'Value must be a number']; + } + + return [true, '']; + }; +}; + +export const stringValidatorGenerator = (values: unknown) => { + return (): ConstraintValidatorOutput => { + const error: ConstraintValidatorOutput = [ + false, + 'Values must be a list of strings', + ]; + if (!Array.isArray(values)) { + return error; + } + + if (!values.every(value => typeof value === 'string')) { + return error; + } + + return [true, '']; + }; +}; + +export const semVerValidatorGenerator = (value: string) => { + return (): ConstraintValidatorOutput => { + const isCleanValue = semver.clean(value) === value; + + if (!semver.valid(value) || !isCleanValue) { + return [false, 'Value is not a valid semver. For example 1.2.4']; + } + + return [true, '']; + }; +}; + +export const dateValidatorGenerator = (value: string) => { + return (): ConstraintValidatorOutput => { + if (!isValid(parseISO(value))) { + return [false, 'Value must be a valid date matching RFC3339']; + } + return [true, '']; + }; +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/useConstraintInput.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/useConstraintInput.tsx new file mode 100644 index 0000000000..3503c69ce5 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/useConstraintInput.tsx @@ -0,0 +1,158 @@ +import { + inOperators, + stringOperators, + numOperators, + semVerOperators, + dateOperators, +} from 'constants/operators'; +import { IUnleashContextDefinition } from 'interfaces/context'; +import { IConstraint } from 'interfaces/strategy'; +import React, { useCallback, useEffect, useState } from 'react'; +import { oneOf } from 'utils/oneOf'; + +import { + numberValidatorGenerator, + stringValidatorGenerator, + semVerValidatorGenerator, + dateValidatorGenerator, + ConstraintValidatorOutput, +} from './constraintValidators'; +import { nonEmptyArray } from 'utils/nonEmptyArray'; + +interface IUseConstraintInputProps { + contextDefinition: IUnleashContextDefinition; + localConstraint: IConstraint; +} + +interface IUseConstraintOutput { + input: Input; + error: string; + validator: () => ConstraintValidatorOutput; + setError: React.Dispatch>; +} + +export const IN_OPERATORS_LEGAL_VALUES = 'IN_OPERATORS_LEGAL_VALUES'; +export const STRING_OPERATORS_LEGAL_VALUES = 'STRING_OPERATORS_LEGAL_VALUES'; +export const NUM_OPERATORS_LEGAL_VALUES = 'NUM_OPERATORS_LEGAL_VALUES'; +export const SEMVER_OPERATORS_LEGAL_VALUES = 'SEMVER_OPERATORS_LEGAL_VALUES'; +export const DATE_OPERATORS_SINGLE_VALUE = 'DATE_OPERATORS_SINGLE_VALUE'; +export const IN_OPERATORS_FREETEXT = 'IN_OPERATORS_FREETEXT'; +export const STRING_OPERATORS_FREETEXT = 'STRING_OPERATORS_FREETEXT'; +export const NUM_OPERATORS_SINGLE_VALUE = 'NUM_OPERATORS_SINGLE_VALUE'; +export const SEMVER_OPERATORS_SINGLE_VALUE = 'SEMVER_OPERATORS_SINGLE_VALUE'; + +export type Input = + | 'IN_OPERATORS_LEGAL_VALUES' + | 'STRING_OPERATORS_LEGAL_VALUES' + | 'NUM_OPERATORS_LEGAL_VALUES' + | 'SEMVER_OPERATORS_LEGAL_VALUES' + | 'DATE_OPERATORS_SINGLE_VALUE' + | 'IN_OPERATORS_FREETEXT' + | 'STRING_OPERATORS_FREETEXT' + | 'NUM_OPERATORS_SINGLE_VALUE' + | 'SEMVER_OPERATORS_SINGLE_VALUE'; + +const NUMBER_VALIDATOR = 'NUMBER_VALIDATOR'; +const SEMVER_VALIDATOR = 'SEMVER_VALIDATOR'; +const STRING_ARRAY_VALIDATOR = 'STRING_ARRAY_VALIDATOR'; +const DATE_VALIDATOR = 'DATE_VALIDATOR'; + +type Validator = + | 'NUMBER_VALIDATOR' + | 'SEMVER_VALIDATOR' + | 'STRING_ARRAY_VALIDATOR' + | 'DATE_VALIDATOR'; + +export const useConstraintInput = ({ + contextDefinition, + localConstraint, +}: IUseConstraintInputProps): IUseConstraintOutput => { + const [input, setInput] = useState(IN_OPERATORS_LEGAL_VALUES); + const [validator, setValidator] = useState( + STRING_ARRAY_VALIDATOR + ); + const [error, setError] = useState(''); + + const resolveInputType = useCallback(() => { + if ( + nonEmptyArray(contextDefinition.legalValues) && + oneOf(inOperators, localConstraint.operator) + ) { + setInput(IN_OPERATORS_LEGAL_VALUES); + } else if ( + nonEmptyArray(contextDefinition.legalValues) && + oneOf(stringOperators, localConstraint.operator) + ) { + setInput(STRING_OPERATORS_LEGAL_VALUES); + } else if ( + nonEmptyArray(contextDefinition.legalValues) && + oneOf(numOperators, localConstraint.operator) + ) { + setInput(NUM_OPERATORS_LEGAL_VALUES); + } else if ( + nonEmptyArray(contextDefinition.legalValues) && + oneOf(semVerOperators, localConstraint.operator) + ) { + setInput(SEMVER_OPERATORS_LEGAL_VALUES); + } else if (oneOf(dateOperators, localConstraint.operator)) { + setInput(DATE_OPERATORS_SINGLE_VALUE); + } else if (oneOf(inOperators, localConstraint.operator)) { + setInput(IN_OPERATORS_FREETEXT); + } else if (oneOf(stringOperators, localConstraint.operator)) { + setInput(STRING_OPERATORS_FREETEXT); + } else if (oneOf(numOperators, localConstraint.operator)) { + setInput(NUM_OPERATORS_SINGLE_VALUE); + } else if (oneOf(semVerOperators, localConstraint.operator)) { + setInput(SEMVER_OPERATORS_SINGLE_VALUE); + } + }, [localConstraint, contextDefinition]); + + const resolveValidator = () => { + switch (validator) { + case NUMBER_VALIDATOR: + return numberValidatorGenerator(localConstraint.value); + case STRING_ARRAY_VALIDATOR: + return stringValidatorGenerator(localConstraint.values || []); + case SEMVER_VALIDATOR: + return semVerValidatorGenerator(localConstraint.value || ''); + case DATE_VALIDATOR: + return dateValidatorGenerator(localConstraint.value || ''); + } + }; + + const resolveValidatorType = useCallback( + (operator: string) => { + if (oneOf(numOperators, operator)) { + setValidator(NUMBER_VALIDATOR); + } + + if (oneOf([...stringOperators, ...inOperators], operator)) { + setValidator(STRING_ARRAY_VALIDATOR); + } + + if (oneOf(semVerOperators, operator)) { + setValidator(SEMVER_VALIDATOR); + } + + if (oneOf(dateOperators, operator)) { + setValidator(DATE_VALIDATOR); + } + }, + [setValidator] + ); + + useEffect(() => { + resolveValidatorType(localConstraint.operator); + }, [ + localConstraint.operator, + localConstraint.value, + localConstraint.values, + resolveValidatorType, + ]); + + useEffect(() => { + resolveInputType(); + }, [contextDefinition, localConstraint, resolveInputType]); + + return { input, error, validator: resolveValidator(), setError }; +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditHeader/ConstraintAccordionEditHeader.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditHeader/ConstraintAccordionEditHeader.tsx new file mode 100644 index 0000000000..3e474a34f9 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditHeader/ConstraintAccordionEditHeader.tsx @@ -0,0 +1,153 @@ +import { IConstraint } from 'interfaces/strategy'; + +import { useStyles } from 'component/common/ConstraintAccordion/ConstraintAccordion.styles'; +import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext'; +import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect'; +import { ConstraintIcon } from 'component/common/ConstraintAccordion/ConstraintIcon'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { + dateOperators, + DATE_AFTER, + IN, + stringOperators, +} from 'constants/operators'; +import { resolveText } from './helpers'; +import { oneOf } from 'utils/oneOf'; +import React, { useEffect, useState } from 'react'; +import { Operator } from 'constants/operators'; +import { ConstraintOperatorSelect } from 'component/common/ConstraintAccordion/ConstraintOperatorSelect/ConstraintOperatorSelect'; +import { + operatorsForContext, + CURRENT_TIME_CONTEXT_FIELD, +} from 'utils/operatorsForContext'; +import { InvertedOperatorButton } from '../StyledToggleButton/InvertedOperatorButton/InvertedOperatorButton'; +import { CaseSensitiveButton } from '../StyledToggleButton/CaseSensitiveButton/CaseSensitiveButton'; +import { ConstraintAccordionHeaderActions } from '../../ConstraintAccordionHeaderActions/ConstraintAccordionHeaderActions'; + +interface IConstraintAccordionViewHeader { + localConstraint: IConstraint; + setContextName: (contextName: string) => void; + setOperator: (operator: Operator) => void; + setLocalConstraint: React.Dispatch>; + action: string; + compact: boolean; + onDelete?: () => void; + setInvertedOperator: () => void; + setCaseInsensitive: () => void; +} + +export const ConstraintAccordionEditHeader = ({ + compact, + localConstraint, + setLocalConstraint, + setContextName, + setOperator, + onDelete, + setInvertedOperator, + setCaseInsensitive, +}: IConstraintAccordionViewHeader) => { + const { classes: styles } = useStyles(); + const { context } = useUnleashContext(); + const { contextName, operator } = localConstraint; + const [showCaseSensitiveButton, setShowCaseSensitiveButton] = + useState(false); + + /* We need a special case to handle the currenTime context field. Since + this field will be the only one to allow DATE_BEFORE and DATE_AFTER operators + this will check if the context field is the current time context field AND check + if it is not already using one of the date operators (to not overwrite if there is existing + data). */ + useEffect(() => { + if ( + contextName === CURRENT_TIME_CONTEXT_FIELD && + !oneOf(dateOperators, operator) + ) { + setLocalConstraint(prev => ({ + ...prev, + operator: DATE_AFTER, + value: new Date().toISOString(), + })); + } else if ( + contextName !== CURRENT_TIME_CONTEXT_FIELD && + oneOf(dateOperators, operator) + ) { + setOperator(IN); + } else if (oneOf(stringOperators, operator)) { + setShowCaseSensitiveButton(true); + } + }, [contextName, setOperator, operator, setLocalConstraint]); + + if (!context) { + return null; + } + + const constraintNameOptions = context.map(context => { + return { key: context.name, label: context.name }; + }); + + const onOperatorChange = (operator: Operator) => { + if (oneOf(dateOperators, operator)) { + setLocalConstraint(prev => ({ + ...prev, + operator: operator, + value: new Date().toISOString(), + })); + } else { + if (oneOf(stringOperators, operator)) { + setShowCaseSensitiveButton(true); + } + setOperator(operator); + } + }; + + return ( +
    + +
    +
    + +
    +
    + +
    + +
    + + } + /> +
    +
    + + {resolveText(operator, contextName)} +

    + } + /> + +
    + ); +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditHeader/helpers.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditHeader/helpers.ts new file mode 100644 index 0000000000..f0d2b13384 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditHeader/helpers.ts @@ -0,0 +1,82 @@ +import { + DATE_BEFORE, + DATE_AFTER, + IN, + NOT_IN, + NUM_EQ, + NUM_GT, + NUM_GTE, + NUM_LT, + NUM_LTE, + STR_CONTAINS, + STR_ENDS_WITH, + STR_STARTS_WITH, + SEMVER_EQ, + SEMVER_GT, + SEMVER_LT, + Operator, +} from 'constants/operators'; + +export const resolveText = (operator: Operator, contextName: string) => { + const base = `To satisfy this constraint, values passed into the SDK as ${contextName} must`; + + if (operator === IN) { + return `${base} include:`; + } + + if (operator === NOT_IN) { + return `${base} not include:`; + } + + if (operator === STR_ENDS_WITH) { + return `${base} end with:`; + } + + if (operator === STR_STARTS_WITH) { + return `${base} start with:`; + } + + if (operator === STR_CONTAINS) { + return `${base} contain:`; + } + + if (operator === NUM_EQ) { + return `${base} match:`; + } + + if (operator === NUM_GT) { + return `${base} be greater than:`; + } + + if (operator === NUM_GTE) { + return `${base} be greater than or equal to:`; + } + + if (operator === NUM_LT) { + return `${base} be less than:`; + } + + if (operator === NUM_LTE) { + return `${base} be less than or equal to:`; + } + + if (operator === DATE_AFTER) { + return `${base} be after the following date`; + } + + if (operator === DATE_BEFORE) { + return `${base} be before the following date:`; + } + + if (operator === SEMVER_EQ) { + return `${base} match the following version:`; + } + + if (operator === SEMVER_GT) { + return `${base} be greater than the following version:`; + } + + if (operator === SEMVER_LT) { + return `${base} be less than the following version:`; + } +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/CaseSensitiveButton/CaseSensitiveButton.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/CaseSensitiveButton/CaseSensitiveButton.tsx new file mode 100644 index 0000000000..8a82b7ef5c --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/CaseSensitiveButton/CaseSensitiveButton.tsx @@ -0,0 +1,51 @@ +import { Tooltip, Box } from '@mui/material'; +import { ReactComponent as CaseSensitive } from 'assets/icons/24_Text format.svg'; +import { ReactComponent as CaseSensitiveOff } from 'assets/icons/24_Text format off.svg'; +import React from 'react'; +import { + StyledToggleButtonOff, + StyledToggleButtonOn, +} from '../StyledToggleButton'; +import { ConditionallyRender } from '../../../../ConditionallyRender/ConditionallyRender'; +import { IConstraint } from 'interfaces/strategy'; + +interface CaseSensitiveButtonProps { + localConstraint: IConstraint; + setCaseInsensitive: () => void; +} + +export const CaseSensitiveButton = ({ + localConstraint, + setCaseInsensitive, +}: CaseSensitiveButtonProps) => ( + + + + + + } + elseShow={ + + + + } + /> + + +); diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/InvertedOperatorButton/InvertedOperatorButton.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/InvertedOperatorButton/InvertedOperatorButton.tsx new file mode 100644 index 0000000000..28dad13936 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/InvertedOperatorButton/InvertedOperatorButton.tsx @@ -0,0 +1,75 @@ +import { Box, Tooltip, useTheme } from '@mui/material'; +import { ReactComponent as NegatedIcon } from 'assets/icons/24_Negator.svg'; +import { ReactComponent as NegatedIconOff } from 'assets/icons/24_Negator off.svg'; +import { IConstraint } from 'interfaces/strategy'; +import { + StyledToggleButtonOff, + StyledToggleButtonOn, +} from '../StyledToggleButton'; +import { ConditionallyRender } from '../../../../ConditionallyRender/ConditionallyRender'; +import { ThemeMode } from 'component/common/ThemeMode/ThemeMode'; + +interface InvertedOperatorButtonProps { + localConstraint: IConstraint; + setInvertedOperator: () => void; +} + +export const InvertedOperatorButton = ({ + localConstraint, + setInvertedOperator, +}: InvertedOperatorButtonProps) => { + const theme = useTheme(); + + return ( + + + + + } + lightmode={} + /> + + } + elseShow={ + + + } + lightmode={} + /> + + } + /> + + + ); +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/StyledToggleButton.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/StyledToggleButton.tsx new file mode 100644 index 0000000000..e4cef7dae5 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/StyledToggleButton.tsx @@ -0,0 +1,39 @@ +import { styled } from '@mui/system'; +import { IconButton } from '@mui/material'; + +export const StyledToggleButtonOff = styled(IconButton)(({ theme }) => ({ + width: '28px', + minWidth: '28px', + maxWidth: '28px', + height: 'auto', + backgroundColor: theme.palette.tertiary.background, + borderRadius: theme.shape.borderRadius, + padding: '0 1px 0', + marginRight: '1rem', + '&:hover': { + background: theme.palette.tertiary.contrast[300], + }, + [theme.breakpoints.between(1101, 1365)]: { + marginRight: '0.5rem', + alignItems: 'center', + }, +})); + +export const StyledToggleButtonOn = styled(IconButton)(({ theme }) => ({ + width: '28px', + minWidth: '28px', + maxWidth: '28px', + color: theme.palette.primary.contrastText, + backgroundColor: theme.palette.primary.main, + borderRadius: theme.shape.borderRadius, + marginRight: '1rem', + padding: '0 1px 0', + '&:hover': { + color: theme.palette.primary.contrastText, + backgroundColor: theme.palette.primary.main, + }, + [theme.breakpoints.between(1101, 1365)]: { + marginRight: '0.5rem', + alignItems: 'center', + }, +})); diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionHeaderActions/ConstraintAccordionHeaderActions.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionHeaderActions/ConstraintAccordionHeaderActions.tsx new file mode 100644 index 0000000000..810a3b9e63 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionHeaderActions/ConstraintAccordionHeaderActions.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { IconButton, Tooltip } from '@mui/material'; +import { Delete, Edit } from '@mui/icons-material'; +import { useStyles } from '../ConstraintAccordion.styles'; +import { ConditionallyRender } from '../../ConditionallyRender/ConditionallyRender'; + +interface ConstraintAccordionHeaderActionsProps { + onDelete?: () => void; + onEdit?: () => void; + disableEdit?: boolean; + disableDelete?: boolean; +} + +export const ConstraintAccordionHeaderActions = ({ + onEdit, + onDelete, + disableDelete = false, + disableEdit = false, +}: ConstraintAccordionHeaderActionsProps) => { + const { classes: styles } = useStyles(); + const onEditClick = + onEdit && + ((event: React.SyntheticEvent) => { + event.stopPropagation(); + onEdit(); + }); + + const onDeleteClick = + onDelete && + ((event: React.SyntheticEvent) => { + event.stopPropagation(); + onDelete(); + }); + + return ( +
    + + + + + + } + /> + + + + + + } + /> +
    + ); +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList.styles.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList.styles.ts new file mode 100644 index 0000000000..571a8c663b --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList.styles.ts @@ -0,0 +1,29 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + width: '100%', + display: 'flex', + flexDirection: 'column', + }, + help: { + fill: theme.palette.grey[600], + [theme.breakpoints.down(860)]: { + display: 'none', + }, + }, + helpWrapper: { + marginLeft: '12px', + height: '24px', + }, + addCustomLabel: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'start', + margin: '0.75rem 0 ', + }, + customConstraintLabel: { + marginBottom: theme.spacing(1), + color: theme.palette.text.secondary, + }, +})); diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList.tsx new file mode 100644 index 0000000000..85b3c33772 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList.tsx @@ -0,0 +1,175 @@ +import React, { forwardRef, Fragment, useImperativeHandle } from 'react'; +import { Button, Tooltip } from '@mui/material'; +import { HelpOutline } from '@mui/icons-material'; +import { IConstraint } from 'interfaces/strategy'; +import { ConstraintAccordion } from 'component/common/ConstraintAccordion/ConstraintAccordion'; +import produce from 'immer'; +import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext'; +import { useWeakMap } from 'hooks/useWeakMap'; +import { objectId } from 'utils/objectId'; +import { useStyles } from './ConstraintAccordionList.styles'; +import { createEmptyConstraint } from 'component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator'; + +interface IConstraintAccordionListProps { + constraints: IConstraint[]; + setConstraints?: React.Dispatch>; + showCreateButton?: boolean; + /* Add "Custom constraints" title on the top - default `true` */ + showLabel?: boolean; +} + +// Ref methods exposed by this component. +export interface IConstraintAccordionListRef { + addConstraint?: (contextName: string) => void; +} + +// Extra form state for each constraint. +interface IConstraintAccordionListItemState { + // Is the constraint new (never been saved)? + new?: boolean; + // Is the constraint currently being edited? + editing?: boolean; +} + +export const constraintAccordionListId = 'constraintAccordionListId'; + +export const ConstraintAccordionList = forwardRef< + IConstraintAccordionListRef | undefined, + IConstraintAccordionListProps +>( + ( + { constraints, setConstraints, showCreateButton, showLabel = true }, + ref + ) => { + const state = useWeakMap< + IConstraint, + IConstraintAccordionListItemState + >(); + const { context } = useUnleashContext(); + const { classes: styles } = useStyles(); + + const addConstraint = + setConstraints && + ((contextName: string) => { + const constraint = createEmptyConstraint(contextName); + state.set(constraint, { editing: true, new: true }); + setConstraints(prev => [...prev, constraint]); + }); + + useImperativeHandle(ref, () => ({ + addConstraint, + })); + + const onAdd = + addConstraint && + (() => { + addConstraint(context[0].name); + }); + + const onEdit = + setConstraints && + ((constraint: IConstraint) => { + state.set(constraint, { editing: true }); + }); + + const onRemove = + setConstraints && + ((index: number) => { + const constraint = constraints[index]; + state.set(constraint, {}); + setConstraints( + produce(draft => { + draft.splice(index, 1); + }) + ); + }); + + const onSave = + setConstraints && + ((index: number, constraint: IConstraint) => { + state.set(constraint, {}); + setConstraints( + produce(draft => { + draft[index] = constraint; + }) + ); + }); + + const onCancel = (index: number) => { + const constraint = constraints[index]; + state.get(constraint)?.new && onRemove?.(index); + state.set(constraint, {}); + }; + + if (context.length === 0) { + return null; + } + + return ( +
    + 0 && showLabel + } + show={ +

    + Custom constraints +

    + } + /> + {constraints.map((constraint, index) => ( + + 0} + show={} + /> + + + ))} + +
    +

    Add any number of custom constraints

    + + + + + +
    + +
    + } + /> + + ); + } +); diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint.ts new file mode 100644 index 0000000000..5ad8f8edfa --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint.ts @@ -0,0 +1,21 @@ +import { dateOperators } from 'constants/operators'; +import { IConstraint } from 'interfaces/strategy'; +import { oneOf } from 'utils/oneOf'; +import { operatorsForContext } from 'utils/operatorsForContext'; + +export const createEmptyConstraint = (contextName: string): IConstraint => { + const operator = operatorsForContext(contextName)[0]; + + const value = oneOf(dateOperators, operator) + ? new Date().toISOString() + : ''; + + return { + contextName, + operator, + value, + values: [], + caseInsensitive: false, + inverted: false, + }; +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView.tsx new file mode 100644 index 0000000000..382aea7266 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView.tsx @@ -0,0 +1,94 @@ +import { useState } from 'react'; +import { + Accordion, + AccordionSummary, + AccordionDetails, + SxProps, + Theme, +} from '@mui/material'; +import { IConstraint } from 'interfaces/strategy'; +import { ConstraintAccordionViewBody } from './ConstraintAccordionViewBody/ConstraintAccordionViewBody'; +import { ConstraintAccordionViewHeader } from './ConstraintAccordionViewHeader/ConstraintAccordionViewHeader'; +import { oneOf } from 'utils/oneOf'; +import { + dateOperators, + numOperators, + semVerOperators, +} from 'constants/operators'; +import { useStyles } from '../ConstraintAccordion.styles'; + +interface IConstraintAccordionViewProps { + constraint: IConstraint; + onDelete?: () => void; + onEdit?: () => void; + sx?: SxProps; + compact?: boolean; + renderAfter?: JSX.Element; +} + +export const ConstraintAccordionView = ({ + constraint, + onEdit, + onDelete, + sx = undefined, + compact = false, + renderAfter, +}: IConstraintAccordionViewProps) => { + const { classes: styles } = useStyles(); + const [expandable, setExpandable] = useState(true); + const [expanded, setExpanded] = useState(false); + + const singleValue = oneOf( + [...semVerOperators, ...numOperators, ...dateOperators], + constraint.operator + ); + const handleClick = () => { + if (expandable) { + setExpanded(!expanded); + } + }; + + return ( + + +
    + + {renderAfter} +
    +
    + + + + +
    + ); +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/ConstraintAccordionViewBody.style.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/ConstraintAccordionViewBody.style.ts new file mode 100644 index 0000000000..7d0cc2d8b8 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/ConstraintAccordionViewBody.style.ts @@ -0,0 +1,27 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + chip: { + margin: '0 0.5rem 0.5rem 0', + }, + chipValue: { + whiteSpace: 'pre', + }, + singleValueView: { + display: 'flex', + alignItems: 'center', + [theme.breakpoints.down(600)]: { flexDirection: 'column' }, + }, + singleValueText: { + marginRight: '0.75rem', + [theme.breakpoints.down(600)]: { + marginBottom: '0.75rem', + marginRight: 0, + }, + }, + settingsParagraph: { + display: 'flex', + alignItems: 'center', + padding: '0.5rem 0', + }, +})); diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/ConstraintAccordionViewBody.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/ConstraintAccordionViewBody.tsx new file mode 100644 index 0000000000..703f4eb746 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/ConstraintAccordionViewBody.tsx @@ -0,0 +1,29 @@ +import { IConstraint } from 'interfaces/strategy'; +import { useStyles } from 'component/common/ConstraintAccordion/ConstraintAccordion.styles'; +import { formatConstraintValue } from 'utils/formatConstraintValue'; +import { useLocationSettings } from 'hooks/useLocationSettings'; +import { MultipleValues } from './MultipleValues/MultipleValues'; +import { SingleValue } from './SingleValue/SingleValue'; + +interface IConstraintAccordionViewBodyProps { + constraint: IConstraint; +} + +export const ConstraintAccordionViewBody = ({ + constraint, +}: IConstraintAccordionViewBodyProps) => { + const { classes: styles } = useStyles(); + const { locationSettings } = useLocationSettings(); + + return ( +
    +
    + + +
    +
    + ); +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/MultipleValues/MultipleValues.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/MultipleValues/MultipleValues.tsx new file mode 100644 index 0000000000..cdcb925720 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/MultipleValues/MultipleValues.tsx @@ -0,0 +1,47 @@ +import { useState } from 'react'; +import { Chip } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import StringTruncator from 'component/common/StringTruncator/StringTruncator'; +import { ConstraintValueSearch } from '../../../ConstraintValueSearch/ConstraintValueSearch'; +import { useStyles } from '../ConstraintAccordionViewBody.style'; + +interface IMultipleValuesProps { + values: string[] | undefined; +} + +export const MultipleValues = ({ values }: IMultipleValuesProps) => { + const [filter, setFilter] = useState(''); + const { classes: styles } = useStyles(); + + if (!values || values.length === 0) return null; + + return ( + <> + 20} + show={ + + } + /> + {values + .filter(value => value.includes(filter)) + .map((value, index) => ( + + } + className={styles.chip} + /> + ))} + + ); +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/SingleValue/SingleValue.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/SingleValue/SingleValue.tsx new file mode 100644 index 0000000000..f34ef16d46 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/SingleValue/SingleValue.tsx @@ -0,0 +1,29 @@ +import { Chip } from '@mui/material'; +import StringTruncator from 'component/common/StringTruncator/StringTruncator'; +import { useStyles } from '../ConstraintAccordionViewBody.style'; + +interface ISingleValueProps { + value: string | undefined; + operator: string; +} + +export const SingleValue = ({ value, operator }: ISingleValueProps) => { + const { classes: styles } = useStyles(); + if (!value) return null; + + return ( +
    +

    Value must be {operator}

    {' '} + + } + className={styles.chip} + /> +
    + ); +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeader.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeader.tsx new file mode 100644 index 0000000000..bf09761625 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeader.tsx @@ -0,0 +1,43 @@ +import { ConstraintIcon } from 'component/common/ConstraintAccordion/ConstraintIcon'; +import { IConstraint } from 'interfaces/strategy'; +import { ConstraintAccordionViewHeaderInfo } from './ConstraintAccordionViewHeaderInfo/ConstraintAccordionViewHeaderInfo'; +import { ConstraintAccordionHeaderActions } from '../../ConstraintAccordionHeaderActions/ConstraintAccordionHeaderActions'; +import { useStyles } from 'component/common/ConstraintAccordion/ConstraintAccordion.styles'; + +interface IConstraintAccordionViewHeaderProps { + constraint: IConstraint; + onDelete?: () => void; + onEdit?: () => void; + singleValue: boolean; + expanded: boolean; + allowExpand: (shouldExpand: boolean) => void; + compact?: boolean; +} + +export const ConstraintAccordionViewHeader = ({ + constraint, + onEdit, + onDelete, + singleValue, + allowExpand, + expanded, + compact, +}: IConstraintAccordionViewHeaderProps) => { + const { classes: styles } = useStyles(); + + return ( +
    + + + +
    + ); +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeaderInfo/ConstraintAccordionViewHeaderInfo.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeaderInfo/ConstraintAccordionViewHeaderInfo.tsx new file mode 100644 index 0000000000..f8298b0fa5 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeaderInfo/ConstraintAccordionViewHeaderInfo.tsx @@ -0,0 +1,83 @@ +import { styled, Tooltip } from '@mui/material'; +import { ConstraintViewHeaderOperator } from '../ConstraintViewHeaderOperator/ConstraintViewHeaderOperator'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { ConstraintAccordionViewHeaderSingleValue } from '../ContraintAccordionViewHeaderSingleValue/ConstraintAccordionViewHeaderSingleValue'; +import { ConstraintAccordionViewHeaderMultipleValues } from '../ContraintAccordionViewHeaderMultipleValues/ConstraintAccordionViewHeaderMultipleValues'; +import React from 'react'; +import { IConstraint } from 'interfaces/strategy'; +import { useStyles } from '../../../ConstraintAccordion.styles'; + +const StyledHeaderText = styled('span')(({ theme }) => ({ + display: '-webkit-box', + WebkitLineClamp: 3, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', + maxWidth: '100px', + minWidth: '100px', + marginRight: '10px', + marginTop: 'auto', + marginBottom: 'auto', + wordBreak: 'break-word', + fontSize: theme.fontSizes.smallBody, + [theme.breakpoints.down(710)]: { + textAlign: 'center', + padding: theme.spacing(1, 0), + marginRight: 'inherit', + maxWidth: 'inherit', + }, +})); + +const StyledHeaderWrapper = styled('div')(({ theme }) => ({ + display: 'flex', + width: '100%', + justifyContent: 'space-between', + borderRadius: theme.spacing(1), +})); + +interface ConstraintAccordionViewHeaderMetaInfoProps { + constraint: IConstraint; + singleValue: boolean; + expanded: boolean; + allowExpand: (shouldExpand: boolean) => void; + maxLength?: number; +} + +export const ConstraintAccordionViewHeaderInfo = ({ + constraint, + singleValue, + allowExpand, + expanded, + maxLength = 112, //The max number of characters in the values text for NOT allowing expansion +}: ConstraintAccordionViewHeaderMetaInfoProps) => { + const { classes: styles } = useStyles(); + + return ( + +
    + + + {constraint.contextName} + + + + + } + elseShow={ + + } + /> +
    +
    + ); +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintViewHeaderOperator/ConstraintViewHeaderOperator.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintViewHeaderOperator/ConstraintViewHeaderOperator.tsx new file mode 100644 index 0000000000..5c254e12b5 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintViewHeaderOperator/ConstraintViewHeaderOperator.tsx @@ -0,0 +1,56 @@ +import { IConstraint } from 'interfaces/strategy'; +import { ConditionallyRender } from '../../../../ConditionallyRender/ConditionallyRender'; +import { Tooltip, Box } from '@mui/material'; +import { stringOperators } from 'constants/operators'; +import { ReactComponent as NegatedIcon } from 'assets/icons/24_Negator.svg'; +import { ConstraintOperator } from '../../../ConstraintOperator/ConstraintOperator'; +import { useStyles } from '../../../ConstraintAccordion.styles'; +import { StyledIconWrapper } from '../StyledIconWrapper/StyledIconWrapper'; +import { ReactComponent as CaseSensitive } from 'assets/icons/24_Text format.svg'; +import { oneOf } from 'utils/oneOf'; + +interface ConstraintViewHeaderOperatorProps { + constraint: IConstraint; +} + +export const ConstraintViewHeaderOperator = ({ + constraint, +}: ConstraintViewHeaderOperatorProps) => { + const { classes: styles } = useStyles(); + + return ( +
    + + + + + + + + } + /> +
    + +
    + + + + + + } + /> +
    + ); +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ContraintAccordionViewHeaderMultipleValues/ConstraintAccordionViewHeaderMultipleValues.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ContraintAccordionViewHeaderMultipleValues/ConstraintAccordionViewHeaderMultipleValues.tsx new file mode 100644 index 0000000000..d3aff894fb --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ContraintAccordionViewHeaderMultipleValues/ConstraintAccordionViewHeaderMultipleValues.tsx @@ -0,0 +1,72 @@ +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { styled } from '@mui/material'; +import React, { useEffect, useMemo, useState } from 'react'; +import classnames from 'classnames'; +import { IConstraint } from 'interfaces/strategy'; +import { useStyles } from '../../../ConstraintAccordion.styles'; + +const StyledValuesSpan = styled('span')(({ theme }) => ({ + display: '-webkit-box', + WebkitLineClamp: 2, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', + wordBreak: 'break-word', + fontSize: theme.fontSizes.smallBody, + margin: 'auto 0', + [theme.breakpoints.down(710)]: { + margin: theme.spacing(1, 0), + textAlign: 'center', + }, +})); + +interface ConstraintSingleValueProps { + constraint: IConstraint; + expanded: boolean; + maxLength: number; + allowExpand: (shouldExpand: boolean) => void; +} + +export const ConstraintAccordionViewHeaderMultipleValues = ({ + constraint, + expanded, + allowExpand, + maxLength, +}: ConstraintSingleValueProps) => { + const { classes: styles } = useStyles(); + + const [expandable, setExpandable] = useState(false); + + const text = useMemo(() => { + return constraint?.values?.map(value => value).join(', '); + }, [constraint]); + + useEffect(() => { + if (text) { + allowExpand((text?.length ?? 0) > maxLength); + setExpandable((text?.length ?? 0) > maxLength); + } + }, [text, maxLength, allowExpand, setExpandable]); + + return ( +
    +
    + {text} + + {!expanded + ? `View all (${constraint?.values?.length})` + : 'View less'} +

    + } + /> +
    +
    + ); +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ContraintAccordionViewHeaderSingleValue/ConstraintAccordionViewHeaderSingleValue.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ContraintAccordionViewHeaderSingleValue/ConstraintAccordionViewHeaderSingleValue.tsx new file mode 100644 index 0000000000..64a3cab927 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ContraintAccordionViewHeaderSingleValue/ConstraintAccordionViewHeaderSingleValue.tsx @@ -0,0 +1,39 @@ +import React, { useEffect } from 'react'; +import { Chip, styled } from '@mui/material'; +import { formatConstraintValue } from 'utils/formatConstraintValue'; +import { useStyles } from '../../../ConstraintAccordion.styles'; +import { IConstraint } from 'interfaces/strategy'; +import { useLocationSettings } from 'hooks/useLocationSettings'; + +const StyledSingleValueChip = styled(Chip)(({ theme }) => ({ + margin: 'auto 0', + marginLeft: theme.spacing(1), + [theme.breakpoints.down(710)]: { + margin: theme.spacing(1, 0), + }, +})); + +interface ConstraintSingleValueProps { + constraint: IConstraint; + allowExpand: (shouldExpand: boolean) => void; +} + +export const ConstraintAccordionViewHeaderSingleValue = ({ + constraint, + allowExpand, +}: ConstraintSingleValueProps) => { + const { locationSettings } = useLocationSettings(); + const { classes: styles } = useStyles(); + + useEffect(() => { + allowExpand(false); + }, [allowExpand]); + + return ( +
    + +
    + ); +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/StyledIconWrapper/StyledIconWrapper.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/StyledIconWrapper/StyledIconWrapper.tsx new file mode 100644 index 0000000000..f12acf3f2f --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/StyledIconWrapper/StyledIconWrapper.tsx @@ -0,0 +1,34 @@ +import { forwardRef, ReactNode } from 'react'; +import { styled } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; + +export const StyledIconWrapperBase = styled('div')<{ + prefix?: boolean; +}>(({ theme }) => ({ + backgroundColor: theme.palette.grey[200], + width: 24, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + alignSelf: 'stretch', + color: theme.palette.primary.main, + marginLeft: theme.spacing(1), + borderRadius: theme.shape.borderRadius, +})); + +const StyledPrefixIconWrapper = styled(StyledIconWrapperBase)(() => ({ + marginLeft: 0, + borderTopRightRadius: 0, + borderBottomRightRadius: 0, +})); + +export const StyledIconWrapper = forwardRef< + HTMLDivElement, + { isPrefix?: boolean; children?: ReactNode } +>(({ isPrefix, ...props }, ref) => ( + } + elseShow={() => } + /> +)); diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintIcon.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintIcon.tsx new file mode 100644 index 0000000000..b941342d4c --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintIcon.tsx @@ -0,0 +1,29 @@ +import { VFC } from 'react'; +import { Box } from '@mui/material'; +import { TrackChanges } from '@mui/icons-material'; + +interface IConstraintIconProps { + compact?: boolean; +} + +export const ConstraintIcon: VFC = ({ compact }) => ( + + + +); diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintOperator/ConstraintOperator.styles.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintOperator/ConstraintOperator.styles.ts new file mode 100644 index 0000000000..4d74d7c893 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintOperator/ConstraintOperator.styles.ts @@ -0,0 +1,31 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + padding: theme.spacing(0.5, 1.5), + borderRadius: theme.shape.borderRadius, + backgroundColor: theme.palette.constraintAccordion.operatorBackground, + lineHeight: 1.25, + }, + name: { + fontSize: theme.fontSizes.smallBody, + lineHeight: 17 / 14, + }, + text: { + fontSize: theme.fontSizes.smallerBody, + color: theme.palette.grey[700], + }, + not: { + display: 'block', + margin: '-1rem 0 0.25rem 0', + height: '1rem', + '& > span': { + display: 'inline-block', + padding: '0 0.25rem', + borderRadius: theme.shape.borderRadius, + fontSize: theme.fontSizes.smallerBody, + backgroundColor: theme.palette.primary.light, + color: 'white', + }, + }, +})); diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintOperator/ConstraintOperator.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintOperator/ConstraintOperator.tsx new file mode 100644 index 0000000000..427f98afa0 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintOperator/ConstraintOperator.tsx @@ -0,0 +1,33 @@ +import { IConstraint } from 'interfaces/strategy'; +import { formatOperatorDescription } from 'component/common/ConstraintAccordion/ConstraintOperator/formatOperatorDescription'; +import { useStyles } from 'component/common/ConstraintAccordion/ConstraintOperator/ConstraintOperator.styles'; +import React from 'react'; + +interface IConstraintOperatorProps { + constraint: IConstraint; + hasPrefix?: boolean; +} + +export const ConstraintOperator = ({ + constraint, + hasPrefix, +}: IConstraintOperatorProps) => { + const { classes: styles } = useStyles(); + + const operatorName = constraint.operator; + const operatorText = formatOperatorDescription(constraint.operator); + + return ( +
    +
    {operatorName}
    +
    {operatorText}
    +
    + ); +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintOperator/formatOperatorDescription.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintOperator/formatOperatorDescription.ts new file mode 100644 index 0000000000..ef4a76118e --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintOperator/formatOperatorDescription.ts @@ -0,0 +1,23 @@ +import { Operator } from 'constants/operators'; + +export const formatOperatorDescription = (operator: Operator): string => { + return constraintOperatorDescriptions[operator]; +}; + +const constraintOperatorDescriptions = { + IN: 'is one of', + NOT_IN: 'is not one of', + STR_CONTAINS: 'is a string that contains', + STR_STARTS_WITH: 'is a string that starts with', + STR_ENDS_WITH: 'is a string that ends with', + NUM_EQ: 'is a number equal to', + NUM_GT: 'is a number greater than', + NUM_GTE: 'is a number greater than or equal to', + NUM_LT: 'is a number less than', + NUM_LTE: 'is a number less than or equal to', + DATE_BEFORE: 'is a date before', + DATE_AFTER: 'is a date after', + SEMVER_EQ: 'is a SemVer equal to', + SEMVER_GT: 'is a SemVer greater than', + SEMVER_LT: 'is a SemVer less than', +}; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintOperatorSelect/ConstraintOperatorSelect.styles.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintOperatorSelect/ConstraintOperatorSelect.styles.ts new file mode 100644 index 0000000000..3c2d139bc9 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintOperatorSelect/ConstraintOperatorSelect.styles.ts @@ -0,0 +1,43 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + valueContainer: { + lineHeight: 1.1, + marginTop: -2, + marginBottom: -10, + }, + optionContainer: { + lineHeight: 1.2, + }, + label: { + fontSize: theme.fontSizes.smallBody, + }, + description: { + fontSize: theme.fontSizes.smallerBody, + color: theme.palette.grey[700], + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + }, + separator: { + position: 'relative', + overflow: 'visible', + marginTop: '1rem', + '&:before': { + content: '""', + display: 'block', + position: 'absolute', + top: '-0.5rem', + left: 0, + right: 0, + borderTop: '1px solid', + borderTopColor: theme.palette.grey[300], + }, + }, + formInput: { + [theme.breakpoints.between(1101, 1365)]: { + width: '170px', + marginRight: '8px', + }, + }, +})); diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintOperatorSelect/ConstraintOperatorSelect.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintOperatorSelect/ConstraintOperatorSelect.tsx new file mode 100644 index 0000000000..82e3c23d29 --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintOperatorSelect/ConstraintOperatorSelect.tsx @@ -0,0 +1,107 @@ +import { + Select, + MenuItem, + FormControl, + InputLabel, + SelectChangeEvent, +} from '@mui/material'; +import { + Operator, + stringOperators, + semVerOperators, + dateOperators, + numOperators, + inOperators, +} from 'constants/operators'; +import React, { useState } from 'react'; +import { formatOperatorDescription } from 'component/common/ConstraintAccordion/ConstraintOperator/formatOperatorDescription'; +import { useStyles } from 'component/common/ConstraintAccordion/ConstraintOperatorSelect/ConstraintOperatorSelect.styles'; +import classNames from 'classnames'; + +interface IConstraintOperatorSelectProps { + options: Operator[]; + value: Operator; + onChange: (value: Operator) => void; +} + +export const ConstraintOperatorSelect = ({ + options, + value, + onChange, +}: IConstraintOperatorSelectProps) => { + const { classes: styles } = useStyles(); + const [open, setOpen] = useState(false); + + const onSelectChange = (event: SelectChangeEvent) => { + onChange(event.target.value as Operator); + }; + + const renderValue = () => { + return ( +
    +
    {value}
    +
    + {formatOperatorDescription(value)} +
    +
    + ); + }; + + return ( + + Operator + + + ); +}; + +const needSeparatorAbove = (options: Operator[], option: Operator): boolean => { + if (option === options[0]) { + return false; + } + + return operatorGroups.some(group => { + return group[0] === option; + }); +}; + +const operatorGroups = [ + inOperators, + stringOperators, + numOperators, + dateOperators, + semVerOperators, +]; diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch.tsx new file mode 100644 index 0000000000..22f153cb6a --- /dev/null +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch.tsx @@ -0,0 +1,50 @@ +import { TextField, InputAdornment, Chip } from '@mui/material'; +import { Search } from '@mui/icons-material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; + +interface IConstraintValueSearchProps { + filter: string; + setFilter: React.Dispatch>; +} + +export const ConstraintValueSearch = ({ + filter, + setFilter, +}: IConstraintValueSearchProps) => { + return ( +
    +
    + setFilter(e.target.value)} + placeholder="Filter values" + style={{ + width: '100%', + margin: '1rem 0', + }} + variant="outlined" + size="small" + InputProps={{ + startAdornment: ( + + + + ), + }} + /> +
    + setFilter('')} + /> + } + /> +
    + ); +}; diff --git a/frontend/src/component/common/CreateButton/CreateButton.tsx b/frontend/src/component/common/CreateButton/CreateButton.tsx new file mode 100644 index 0000000000..05d9306af2 --- /dev/null +++ b/frontend/src/component/common/CreateButton/CreateButton.tsx @@ -0,0 +1,15 @@ +import PermissionButton, { + IPermissionButtonProps, +} from '../PermissionButton/PermissionButton'; + +interface ICreateButtonProps extends IPermissionButtonProps { + name: string; +} + +export const CreateButton = ({ name, ...rest }: ICreateButtonProps) => { + return ( + + Create {name} + + ); +}; diff --git a/frontend/src/component/common/Dialogue/Dialogue.styles.ts b/frontend/src/component/common/Dialogue/Dialogue.styles.ts new file mode 100644 index 0000000000..d127fb3e82 --- /dev/null +++ b/frontend/src/component/common/Dialogue/Dialogue.styles.ts @@ -0,0 +1,14 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + dialogTitle: { + backgroundColor: theme.palette.dialogHeaderBackground, + color: theme.palette.dialogHeaderText, + height: '150px', + padding: '2rem 3rem', + clipPath: ' ellipse(130% 115px at 120% 20%)', + }, + dialogContentPadding: { + padding: '2rem 3rem', + }, +})); diff --git a/frontend/src/component/common/Dialogue/Dialogue.tsx b/frontend/src/component/common/Dialogue/Dialogue.tsx new file mode 100644 index 0000000000..e4436a67f4 --- /dev/null +++ b/frontend/src/component/common/Dialogue/Dialogue.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, +} from '@mui/material'; + +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { useStyles } from './Dialogue.styles'; +import { DIALOGUE_CONFIRM_ID } from 'utils/testIds'; + +interface IDialogue { + primaryButtonText?: string; + secondaryButtonText?: string; + open: boolean; + onClick?: (e: React.SyntheticEvent) => void; + onClose?: (e: React.SyntheticEvent, reason?: string) => void; + style?: object; + title: string; + fullWidth?: boolean; + maxWidth?: 'lg' | 'sm' | 'xs' | 'md' | 'xl'; + disabledPrimaryButton?: boolean; + formId?: string; + permissionButton?: JSX.Element; +} + +export const Dialogue: React.FC = ({ + children, + open, + onClick, + onClose, + title, + primaryButtonText, + disabledPrimaryButton = false, + secondaryButtonText, + maxWidth = 'sm', + fullWidth = false, + formId, + permissionButton, +}) => { + const { classes: styles } = useStyles(); + const handleClick = formId + ? (e: React.SyntheticEvent) => { + e.preventDefault(); + if (onClick) { + onClick(e); + } + } + : onClick; + return ( + + {title} + + {children} + + } + /> + + + + {primaryButtonText || "Yes, I'm sure"} + + } + /> + } + /> + + + {secondaryButtonText || 'No, take me back'} + + } + /> + + + ); +}; diff --git a/frontend/src/component/common/DividerText/DividerText.styles.ts b/frontend/src/component/common/DividerText/DividerText.styles.ts new file mode 100644 index 0000000000..58023603e0 --- /dev/null +++ b/frontend/src/component/common/DividerText/DividerText.styles.ts @@ -0,0 +1,21 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + margin: '1rem auto', + }, + wing: { + width: '80px', + height: '3px', + backgroundColor: theme.palette.divider, + borderRadius: theme.shape.borderRadius, + }, + text: { + textAlign: 'center', + display: 'block', + margin: '0 1rem', + }, +})); diff --git a/frontend/src/component/common/DividerText/DividerText.tsx b/frontend/src/component/common/DividerText/DividerText.tsx new file mode 100644 index 0000000000..13eda22945 --- /dev/null +++ b/frontend/src/component/common/DividerText/DividerText.tsx @@ -0,0 +1,22 @@ +import { Typography } from '@mui/material'; +import { useStyles } from 'component/common/DividerText/DividerText.styles'; + +interface IDividerTextProps { + text: string; +} + +const DividerText = ({ text, ...rest }: IDividerTextProps) => { + const { classes: styles } = useStyles(); + + return ( +
    + + + {text} + + +
    + ); +}; + +export default DividerText; diff --git a/frontend/src/component/common/DropdownMenu/DropdownButton/DropdownButton.tsx b/frontend/src/component/common/DropdownMenu/DropdownButton/DropdownButton.tsx new file mode 100644 index 0000000000..a492668899 --- /dev/null +++ b/frontend/src/component/common/DropdownMenu/DropdownButton/DropdownButton.tsx @@ -0,0 +1,23 @@ +import { ReactNode, VFC } from 'react'; +import { Button, ButtonProps, Icon } from '@mui/material'; + +interface IDropdownButtonProps { + label: string; + id?: string; + title?: ButtonProps['title']; + className?: string; + icon?: ReactNode; + startIcon?: ButtonProps['startIcon']; + style?: ButtonProps['style']; + onClick: ButtonProps['onClick']; +} + +export const DropdownButton: VFC = ({ + label, + icon, + ...rest +}) => ( + +); diff --git a/frontend/src/component/common/DropdownMenu/DropdownMenu.tsx b/frontend/src/component/common/DropdownMenu/DropdownMenu.tsx new file mode 100644 index 0000000000..32f773ff4d --- /dev/null +++ b/frontend/src/component/common/DropdownMenu/DropdownMenu.tsx @@ -0,0 +1,74 @@ +import { + CSSProperties, + MouseEventHandler, + ReactNode, + useState, + VFC, +} from 'react'; +import { Menu } from '@mui/material'; +import { ArrowDropDown } from '@mui/icons-material'; +import { DropdownButton } from './DropdownButton/DropdownButton'; + +export interface IDropdownMenuProps { + renderOptions: () => ReactNode; + id: string; + title?: string; + callback?: MouseEventHandler; + icon?: ReactNode; + label: string; + startIcon?: ReactNode; + style?: CSSProperties; +} + +const DropdownMenu: VFC = ({ + renderOptions, + id, + title, + callback, + icon = , + label, + style, + startIcon, + ...rest +}) => { + const [anchor, setAnchor] = useState(null); + + const handleOpen: MouseEventHandler = e => { + setAnchor(e.currentTarget); + }; + + const handleClose: MouseEventHandler = e => { + if (callback && typeof callback === 'function') { + callback(e); + } + + setAnchor(null); + }; + + return ( + <> + + + {renderOptions()} + + + ); +}; + +export default DropdownMenu; diff --git a/frontend/src/component/common/EnvironmentIcon/EnvironmentIcon.tsx b/frontend/src/component/common/EnvironmentIcon/EnvironmentIcon.tsx new file mode 100644 index 0000000000..1d7e251b48 --- /dev/null +++ b/frontend/src/component/common/EnvironmentIcon/EnvironmentIcon.tsx @@ -0,0 +1,41 @@ +import { useTheme } from '@mui/material/styles'; +import { Cloud } from '@mui/icons-material'; + +interface IEnvironmentIcon { + enabled: boolean; + className?: string; +} + +const EnvironmentIcon = ({ enabled, className }: IEnvironmentIcon) => { + const theme = useTheme(); + + const title = enabled ? 'Environment enabled' : 'Environment disabled'; + + const container = { + backgroundColor: enabled + ? theme.palette.primary.light + : theme.palette.neutral.border, + borderRadius: '50%', + width: '28px', + height: '28px', + minWidth: '28px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginRight: theme.spacing(1), + }; + + const icon = { + fill: '#fff', + width: '16px', + height: '16px', + }; + + return ( +
    + +
    + ); +}; + +export default EnvironmentIcon; diff --git a/frontend/src/component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog.styles.ts b/frontend/src/component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog.styles.ts new file mode 100644 index 0000000000..7de67a83af --- /dev/null +++ b/frontend/src/component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog.styles.ts @@ -0,0 +1,8 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + infoText: { + marginBottom: '10px', + fontSize: theme.fontSizes.bodySize, + }, +})); diff --git a/frontend/src/component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog.tsx b/frontend/src/component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog.tsx new file mode 100644 index 0000000000..19d1ee44a2 --- /dev/null +++ b/frontend/src/component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog.tsx @@ -0,0 +1,69 @@ +import { useNavigate } from 'react-router-dom'; +import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import PermissionButton from '../PermissionButton/PermissionButton'; +import { useStyles } from './EnvironmentStrategyDialog.styles'; +import { formatCreateStrategyPath } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate'; + +interface IEnvironmentStrategyDialogProps { + open: boolean; + featureId: string; + projectId: string; + onClose: () => void; + environmentName: string; +} +const EnvironmentStrategyDialog = ({ + open, + environmentName, + featureId, + projectId, + onClose, +}: IEnvironmentStrategyDialogProps) => { + const { classes: styles } = useStyles(); + const navigate = useNavigate(); + + const createStrategyPath = formatCreateStrategyPath( + projectId, + featureId, + environmentName, + 'default' + ); + + const onClick = () => { + onClose(); + navigate(createStrategyPath); + }; + + return ( + onClose()} + title="You need to add a strategy to your toggle" + primaryButtonText="Take me directly to add strategy" + permissionButton={ + + Take me directly to add strategy + + } + secondaryButtonText="Cancel" + > +

    + Before you can enable the toggle in the environment, you need to + add an activation strategy. +

    +

    + You can add the activation strategy by selecting the toggle, + open the environment accordion and add the activation strategy. +

    +
    + ); +}; + +export default EnvironmentStrategyDialog; diff --git a/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.tsx b/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.tsx new file mode 100644 index 0000000000..25e3c5c972 --- /dev/null +++ b/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.tsx @@ -0,0 +1,53 @@ +import { VFC } from 'react'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; + +interface IFeatureArchiveDialogProps { + isOpen: boolean; + onConfirm: () => void; + onClose: () => void; + projectId: string; + featureId: string; +} + +export const FeatureArchiveDialog: VFC = ({ + isOpen, + onClose, + onConfirm, + projectId, + featureId, +}) => { + const { archiveFeatureToggle } = useFeatureApi(); + const { setToastData, setToastApiError } = useToast(); + + const archiveToggle = async () => { + try { + await archiveFeatureToggle(projectId, featureId); + setToastData({ + text: 'Your feature toggle has been archived', + type: 'success', + title: 'Feature archived', + }); + onConfirm(); + onClose(); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + onClose(); + } + }; + + return ( + archiveToggle()} + open={isOpen} + onClose={onClose} + primaryButtonText="Archive toggle" + secondaryButtonText="Cancel" + title="Archive feature toggle" + > + Are you sure you want to archive this feature toggle? + + ); +}; diff --git a/frontend/src/component/common/FeatureStaleDialog/FeatureStaleDialog.tsx b/frontend/src/component/common/FeatureStaleDialog/FeatureStaleDialog.tsx new file mode 100644 index 0000000000..e73b71747e --- /dev/null +++ b/frontend/src/component/common/FeatureStaleDialog/FeatureStaleDialog.tsx @@ -0,0 +1,81 @@ +import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; +import { Typography } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import React from 'react'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; + +interface IFeatureStaleDialogProps { + isStale: boolean; + isOpen: boolean; + projectId: string; + featureId: string; + onClose: () => void; +} + +export const FeatureStaleDialog = ({ + isStale, + isOpen, + projectId, + featureId, + onClose, +}: IFeatureStaleDialogProps) => { + const { setToastData, setToastApiError } = useToast(); + const { patchFeatureToggle } = useFeatureApi(); + + const toggleToStaleContent = ( + Setting a toggle to stale marks it for cleanup + ); + + const toggleToActiveContent = ( + + Setting a toggle to active marks it as in active use + + ); + + const toggleActionText = isStale ? 'active' : 'stale'; + + const onSubmit = async (event: React.SyntheticEvent) => { + event.stopPropagation(); + + try { + const patch = [{ op: 'replace', path: '/stale', value: !isStale }]; + await patchFeatureToggle(projectId, featureId, patch); + onClose(); + } catch (err: unknown) { + setToastApiError(formatUnknownError(err)); + } + + if (isStale) { + setToastData({ + type: 'success', + title: "And we're back!", + text: 'The toggle is no longer marked as stale.', + }); + } else { + setToastData({ + type: 'success', + title: 'A job well done.', + text: 'The toggle has been marked as stale.', + }); + } + }; + + return ( + + + + ); +}; diff --git a/frontend/src/component/common/FormTemplate/FormTemplate.styles.ts b/frontend/src/component/common/FormTemplate/FormTemplate.styles.ts new file mode 100644 index 0000000000..24b34cc50d --- /dev/null +++ b/frontend/src/component/common/FormTemplate/FormTemplate.styles.ts @@ -0,0 +1,109 @@ +import { makeStyles } from 'tss-react/mui'; + +export const formTemplateSidebarWidth = '27.5rem'; + +export const useStyles = makeStyles()(theme => ({ + container: { + minHeight: '80vh', + width: '100%', + display: 'flex', + margin: '0 auto', + borderRadius: '1rem', + overflow: 'hidden', + [theme.breakpoints.down(1100)]: { + flexDirection: 'column', + minHeight: 0, + }, + }, + modal: { + minHeight: '100vh', + borderRadius: 0, + }, + sidebar: { + backgroundColor: theme.palette.formSidebar, + padding: '2rem', + flexGrow: 0, + flexShrink: 0, + width: formTemplateSidebarWidth, + [theme.breakpoints.down(1100)]: { + width: '100%', + color: 'red', + }, + [theme.breakpoints.down(500)]: { + padding: '2rem 1rem', + }, + }, + sidebarDivider: { + opacity: 0.3, + marginBottom: '8px', + }, + title: { + marginBottom: '1.5rem', + fontWeight: 'normal', + }, + subtitle: { + color: theme.palette.formSidebarTextColor, + marginBottom: '1rem', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + fontWeight: theme.fontWeight.bold, + fontSize: theme.fontSizes.bodySize, + }, + description: { + color: theme.palette.formSidebarTextColor, + zIndex: 1, + position: 'relative', + }, + linkContainer: { + margin: '1.5rem 0', + display: 'flex', + alignItems: 'center', + }, + linkIcon: { + marginRight: '0.5rem', + color: '#fff', + }, + documentationLink: { + color: '#fff', + display: 'block', + '&:hover': { + textDecoration: 'none', + }, + }, + formContent: { + backgroundColor: theme.palette.formBackground, + display: 'flex', + flexDirection: 'column', + padding: '3rem', + flexGrow: 1, + [theme.breakpoints.down(1200)]: { + padding: '2rem', + }, + [theme.breakpoints.down(1100)]: { + width: '100%', + }, + [theme.breakpoints.down(500)]: { + padding: '2rem 1rem', + }, + }, + icon: { fill: '#fff' }, + mobileGuidanceBgContainer: { + zIndex: 1, + position: 'absolute', + right: -3, + top: -3, + }, + mobileGuidanceBackground: { + width: '75px', + height: '75px', + }, + mobileGuidanceButton: { + position: 'absolute', + zIndex: 400, + right: 0, + }, + infoIcon: { + fill: '#fff', + }, +})); diff --git a/frontend/src/component/common/FormTemplate/FormTemplate.tsx b/frontend/src/component/common/FormTemplate/FormTemplate.tsx new file mode 100644 index 0000000000..c20cf92be4 --- /dev/null +++ b/frontend/src/component/common/FormTemplate/FormTemplate.tsx @@ -0,0 +1,193 @@ +import { useStyles } from './FormTemplate.styles'; +import MenuBookIcon from '@mui/icons-material/MenuBook'; +import Codebox from '../Codebox/Codebox'; +import { + Collapse, + IconButton, + useMediaQuery, + Tooltip, + Divider, +} from '@mui/material'; +import { FileCopy, Info } from '@mui/icons-material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import Loader from '../Loader/Loader'; +import copy from 'copy-to-clipboard'; +import useToast from 'hooks/useToast'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { ReactComponent as MobileGuidanceBG } from 'assets/img/mobileGuidanceBg.svg'; +import { useThemeStyles } from 'themes/themeStyles'; + +interface ICreateProps { + title: string; + description: string; + documentationLink: string; + documentationLinkLabel: string; + loading?: boolean; + modal?: boolean; + formatApiCode: () => string; +} + +const FormTemplate: React.FC = ({ + title, + description, + children, + documentationLink, + documentationLinkLabel, + loading, + modal, + formatApiCode, +}) => { + const { setToastData } = useToast(); + const { classes: styles } = useStyles(); + const { classes: themeStyles } = useThemeStyles(); + const smallScreen = useMediaQuery(`(max-width:${1099}px)`); + + const copyCommand = () => { + if (copy(formatApiCode())) { + setToastData({ + title: 'Successfully copied the command', + text: 'The command should now be automatically copied to your clipboard', + autoHideDuration: 6000, + type: 'success', + show: true, + }); + } else { + setToastData({ + title: 'Could not copy the command', + text: 'Sorry, but we could not copy the command.', + autoHideDuration: 6000, + type: 'error', + show: true, + }); + } + }; + + return ( +
    + + + + } + /> +
    + } + elseShow={ + <> +

    {title}

    + {children} + + } + />{' '} +
    + + +

    + API Command{' '} + + + + + +

    + + + } + /> +
    + ); +}; + +interface IMobileGuidance { + description: string; + documentationLink: string; + documentationLinkLabel?: string; +} + +const MobileGuidance = ({ + description, + documentationLink, + documentationLinkLabel, +}: IMobileGuidance) => { + const [open, setOpen] = useState(false); + const { classes: styles } = useStyles(); + + return ( + <> +
    + +
    + + setOpen(prev => !prev)} + size="large" + > + + + + + + + + ); +}; + +interface IGuidanceProps { + description: string; + documentationLink: string; + documentationLinkLabel?: string; +} + +const Guidance: React.FC = ({ + description, + children, + documentationLink, + documentationLinkLabel = 'Learn more', +}) => { + const { classes: styles } = useStyles(); + + return ( + + ); +}; + +export default FormTemplate; diff --git a/frontend/src/component/common/GeneralSelect/GeneralSelect.tsx b/frontend/src/component/common/GeneralSelect/GeneralSelect.tsx new file mode 100644 index 0000000000..90dca9cc59 --- /dev/null +++ b/frontend/src/component/common/GeneralSelect/GeneralSelect.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { + FormControl, + InputLabel, + MenuItem, + Select, + SelectProps, + SelectChangeEvent, +} from '@mui/material'; +import { SELECT_ITEM_ID } from 'utils/testIds'; +import { KeyboardArrowDownOutlined } from '@mui/icons-material'; + +export interface ISelectOption { + key: string; + title?: string; + label?: string; + disabled?: boolean; +} + +export interface IGeneralSelectProps extends Omit { + name?: string; + value?: string; + label?: string; + options: ISelectOption[]; + onChange: (key: string) => void; + disabled?: boolean; + fullWidth?: boolean; + classes?: any; + defaultValue?: string; +} + +const GeneralSelect: React.FC = ({ + name, + value = '', + label = '', + options, + onChange, + id, + disabled = false, + className, + classes, + fullWidth, + ...rest +}) => { + const renderSelectItems = () => + options.map(option => ( + + {option.label} + + )); + + const onSelectChange = (event: SelectChangeEvent) => { + event.preventDefault(); + onChange(String(event.target.value)); + }; + + return ( + + {label} + + + ); +}; + +export default GeneralSelect; diff --git a/frontend/src/component/common/Gradient/Gradient.tsx b/frontend/src/component/common/Gradient/Gradient.tsx new file mode 100644 index 0000000000..4de131d677 --- /dev/null +++ b/frontend/src/component/common/Gradient/Gradient.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +interface IGradientProps { + from: string; + to: string; + style?: object; + className?: string; +} + +const Gradient: React.FC = ({ + children, + from, + to, + style, + ...rest +}) => { + return ( +
    + {children} +
    + ); +}; + +export default Gradient; diff --git a/frontend/src/component/common/GridCol/GridCol.tsx b/frontend/src/component/common/GridCol/GridCol.tsx new file mode 100644 index 0000000000..95b56c7be7 --- /dev/null +++ b/frontend/src/component/common/GridCol/GridCol.tsx @@ -0,0 +1,19 @@ +import { Grid } from '@mui/material'; +import { FC } from 'react'; + +export const GridCol: FC<{ vertical?: boolean }> = ({ + children, + vertical = false, +}) => { + return ( + + {children} + + ); +}; diff --git a/frontend/src/component/common/GridRow/GridRow.tsx b/frontend/src/component/common/GridRow/GridRow.tsx new file mode 100644 index 0000000000..54110f4cb3 --- /dev/null +++ b/frontend/src/component/common/GridRow/GridRow.tsx @@ -0,0 +1,21 @@ +import { Grid, styled, SxProps, Theme } from '@mui/material'; +import { FC } from 'react'; + +const StyledGrid = styled(Grid)(({ theme }) => ({ + flexWrap: 'nowrap', + gap: theme.spacing(1), +})); + +export const GridRow: FC<{ sx?: SxProps }> = ({ sx, children }) => { + return ( + + {children} + + ); +}; diff --git a/frontend/src/component/common/GuidanceIndicator/GuidanceIndicator.tsx b/frontend/src/component/common/GuidanceIndicator/GuidanceIndicator.tsx new file mode 100644 index 0000000000..167fdef936 --- /dev/null +++ b/frontend/src/component/common/GuidanceIndicator/GuidanceIndicator.tsx @@ -0,0 +1,40 @@ +import { styled, useTheme } from '@mui/material'; +import { FC } from 'react'; + +const StyledIndicator = styled('div')(({ style, theme }) => ({ + width: '25px', + height: '25px', + borderRadius: '50%', + color: theme.palette.text.tertiaryContrast, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontWeight: 'bold', + ...style, +})); + +interface IGuidanceIndicatorProps { + style?: React.CSSProperties; + type?: guidanceIndicatorType; +} + +type guidanceIndicatorType = 'primary' | 'secondary'; + +export const GuidanceIndicator: FC = ({ + style, + children, + type, +}) => { + const theme = useTheme(); + + const defaults = { backgroundColor: theme.palette.primary.main }; + if (type === 'secondary') { + defaults.backgroundColor = theme.palette.tertiary.dark; + } + + return ( + + {children} + + ); +}; diff --git a/frontend/src/component/common/HelpIcon/HelpIcon.styles.ts b/frontend/src/component/common/HelpIcon/HelpIcon.styles.ts new file mode 100644 index 0000000000..a017afcda9 --- /dev/null +++ b/frontend/src/component/common/HelpIcon/HelpIcon.styles.ts @@ -0,0 +1,22 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + display: 'inline-grid', + alignItems: 'center', + outline: 0, + + '&:is(:focus-visible, :active) > *, &:hover > *': { + outlineStyle: 'solid', + outlineWidth: 2, + outlineOffset: 0, + outlineColor: theme.palette.primary.main, + borderRadius: '100%', + color: theme.palette.primary.main, + }, + }, + icon: { + fontSize: '1rem', + color: theme.palette.inactiveIcon, + }, +})); diff --git a/frontend/src/component/common/HelpIcon/HelpIcon.tsx b/frontend/src/component/common/HelpIcon/HelpIcon.tsx new file mode 100644 index 0000000000..09b12df9e8 --- /dev/null +++ b/frontend/src/component/common/HelpIcon/HelpIcon.tsx @@ -0,0 +1,21 @@ +import { Tooltip, TooltipProps } from '@mui/material'; +import { Info } from '@mui/icons-material'; +import { useStyles } from 'component/common/HelpIcon/HelpIcon.styles'; +import React from 'react'; + +interface IHelpIconProps { + tooltip: string; + placement?: TooltipProps['placement']; +} + +export const HelpIcon = ({ tooltip, placement }: IHelpIconProps) => { + const { classes: styles } = useStyles(); + + return ( + + + + + + ); +}; diff --git a/frontend/src/component/common/Highlighter/Highlighter.styles.ts b/frontend/src/component/common/Highlighter/Highlighter.styles.ts new file mode 100644 index 0000000000..970aa4835f --- /dev/null +++ b/frontend/src/component/common/Highlighter/Highlighter.styles.ts @@ -0,0 +1,9 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + highlighter: { + '&>mark': { + backgroundColor: theme.palette.highlight, + }, + }, +})); diff --git a/frontend/src/component/common/Highlighter/Highlighter.tsx b/frontend/src/component/common/Highlighter/Highlighter.tsx new file mode 100644 index 0000000000..fa5f47815f --- /dev/null +++ b/frontend/src/component/common/Highlighter/Highlighter.tsx @@ -0,0 +1,36 @@ +import { VFC } from 'react'; +import { useStyles } from './Highlighter.styles'; + +interface IHighlighterProps { + search?: string; + children?: string; + caseSensitive?: boolean; +} + +const escapeRegex = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +export const Highlighter: VFC = ({ + search, + children, + caseSensitive, +}) => { + const { classes } = useStyles(); + if (!children) { + return null; + } + + if (!search) { + return <>{children}; + } + + const regex = new RegExp(escapeRegex(search), caseSensitive ? 'g' : 'gi'); + + return ( + $&') || '', + }} + /> + ); +}; diff --git a/frontend/src/component/common/Input/Input.styles.ts b/frontend/src/component/common/Input/Input.styles.ts new file mode 100644 index 0000000000..12295699d6 --- /dev/null +++ b/frontend/src/component/common/Input/Input.styles.ts @@ -0,0 +1,12 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + helperText: { + position: 'absolute', + top: '35px', + }, + inputContainer: { + width: '100%', + position: 'relative', + }, +})); diff --git a/frontend/src/component/common/Input/Input.tsx b/frontend/src/component/common/Input/Input.tsx new file mode 100644 index 0000000000..59ccf9cfc2 --- /dev/null +++ b/frontend/src/component/common/Input/Input.tsx @@ -0,0 +1,58 @@ +import { INPUT_ERROR_TEXT } from 'utils/testIds'; +import { useStyles } from './Input.styles'; +import React from 'react'; +import { TextField, OutlinedTextFieldProps } from '@mui/material'; + +interface IInputProps extends Omit { + label: string; + error?: boolean; + errorText?: string; + style?: Object; + className?: string; + value: string; + onChange: (e: any) => any; + onFocus?: (e: any) => any; + onBlur?: (e: any) => any; + multiline?: boolean; + rows?: number; +} + +const Input = ({ + label, + placeholder, + error, + errorText, + style, + className, + value, + onChange, + InputProps, + ...rest +}: IInputProps) => { + const { classes: styles } = useStyles(); + return ( +
    + +
    + ); +}; + +export default Input; diff --git a/frontend/src/component/common/InputCaption/InputCaption.tsx b/frontend/src/component/common/InputCaption/InputCaption.tsx new file mode 100644 index 0000000000..ecf6fcb0c6 --- /dev/null +++ b/frontend/src/component/common/InputCaption/InputCaption.tsx @@ -0,0 +1,23 @@ +import { Box } from '@mui/material'; + +export interface IInputCaptionProps { + text?: string; +} + +export const InputCaption = ({ text }: IInputCaptionProps) => { + if (!text) { + return null; + } + + return ( + ({ + color: theme.palette.text.secondary, + fontSize: theme.fontSizes.smallerBody, + marginTop: theme.spacing(1), + })} + > + {text} + + ); +}; diff --git a/frontend/src/component/common/InputListField/InputListField.tsx b/frontend/src/component/common/InputListField/InputListField.tsx new file mode 100644 index 0000000000..4d4a95dce2 --- /dev/null +++ b/frontend/src/component/common/InputListField/InputListField.tsx @@ -0,0 +1,53 @@ +import { VFC } from 'react'; +import { TextField, TextFieldProps } from '@mui/material'; + +interface IInputListFieldProps { + label: string; + values?: any[]; + error?: boolean; + placeholder?: string; + name: string; + updateValues: (values: string[]) => void; + onBlur?: TextFieldProps['onBlur']; + helperText?: TextFieldProps['helperText']; + FormHelperTextProps?: TextFieldProps['FormHelperTextProps']; +} + +export const InputListField: VFC = ({ + values = [], + updateValues, + placeholder = '', + error, + ...rest +}) => { + const handleChange: TextFieldProps['onChange'] = event => { + const values = event.target.value.split(/,\s?/); + const trimmedValues = values.map(v => v.trim()); + updateValues(trimmedValues); + }; + + const handleKeyDown: TextFieldProps['onKeyDown'] = event => { + if (event.key === 'Backspace') { + const currentValue = (event.target as HTMLInputElement).value; + if (currentValue.endsWith(', ')) { + event.preventDefault(); + const value = currentValue.slice(0, -2); + updateValues(value.split(/,\s*/)); + } + } + }; + + return ( + + ); +}; diff --git a/frontend/src/component/common/InstanceStatus/InstanceStatus.tsx b/frontend/src/component/common/InstanceStatus/InstanceStatus.tsx new file mode 100644 index 0000000000..2af9b6edfd --- /dev/null +++ b/frontend/src/component/common/InstanceStatus/InstanceStatus.tsx @@ -0,0 +1,131 @@ +import { useInstanceStatus } from 'hooks/api/getters/useInstanceStatus/useInstanceStatus'; +import React, { FC, VFC, useEffect, useState, useContext } from 'react'; +import { InstanceStatusBar } from 'component/common/InstanceStatus/InstanceStatusBar'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import { Typography, useTheme } from '@mui/material'; +import { useNavigate } from 'react-router-dom'; +import { IInstanceStatus } from 'interfaces/instance'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import AccessContext from 'contexts/AccessContext'; +import useInstanceStatusApi from 'hooks/api/actions/useInstanceStatusApi/useInstanceStatusApi'; +import { trialHasExpired, canExtendTrial } from 'utils/instanceTrial'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; + +interface ITrialDialogProps { + instanceStatus: IInstanceStatus; + onExtendTrial: () => Promise; +} + +const TrialDialog: VFC = ({ + instanceStatus, + onExtendTrial, +}) => { + const { hasAccess } = useContext(AccessContext); + const navigate = useNavigate(); + const expired = trialHasExpired(instanceStatus); + const [dialogOpen, setDialogOpen] = useState(expired); + + const onClose = (event: React.SyntheticEvent, muiCloseReason?: string) => { + if (!muiCloseReason) { + setDialogOpen(false); + if (canExtendTrial(instanceStatus)) { + onExtendTrial().catch(console.error); + } + } + }; + + useEffect(() => { + setDialogOpen(expired); + const interval = setInterval(() => { + setDialogOpen(expired); + }, 60000); + return () => clearInterval(interval); + }, [expired]); + + if (hasAccess(ADMIN)) { + return ( + { + navigate('/admin/billing'); + setDialogOpen(false); + }} + onClose={onClose} + title={`Your free ${instanceStatus.plan} trial has expired!`} + > + + Upgrade trial otherwise your{' '} + account will be deleted. + + + ); + } + + return ( + { + setDialogOpen(false); + }} + title={`Your free ${instanceStatus.plan} trial has expired!`} + > + + Please inform your admin to Upgrade trial or + your account will be deleted. + + + ); +}; + +export const InstanceStatus: FC = ({ children }) => { + const { instanceStatus, refetchInstanceStatus, isBilling } = + useInstanceStatus(); + const { extendTrial } = useInstanceStatusApi(); + const { setToastApiError } = useToast(); + const theme = useTheme(); + + const onExtendTrial = async () => { + try { + await extendTrial(); + await refetchInstanceStatus(); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + return ( +
    + ( + <> + + + + )} + /> + {children} +
    + ); +}; + +const InstanceStatusBarMemo = React.memo(InstanceStatusBar); diff --git a/frontend/src/component/common/InstanceStatus/InstanceStatusBar.test.tsx b/frontend/src/component/common/InstanceStatus/InstanceStatusBar.test.tsx new file mode 100644 index 0000000000..8791740e68 --- /dev/null +++ b/frontend/src/component/common/InstanceStatus/InstanceStatusBar.test.tsx @@ -0,0 +1,103 @@ +import { InstanceStatusBar } from 'component/common/InstanceStatus/InstanceStatusBar'; +import { InstancePlan, InstanceState } from 'interfaces/instance'; +import { render } from 'utils/testRenderer'; +import { screen } from '@testing-library/react'; +import { addDays, subDays } from 'date-fns'; +import { INSTANCE_STATUS_BAR_ID } from 'utils/testIds'; +import { UNKNOWN_INSTANCE_STATUS } from 'hooks/api/getters/useInstanceStatus/useInstanceStatus'; + +test('InstanceStatusBar should be hidden by default', async () => { + render(); + + expect( + screen.queryByTestId(INSTANCE_STATUS_BAR_ID) + ).not.toBeInTheDocument(); +}); + +test('InstanceStatusBar should be hidden when state is active', async () => { + render( + + ); + + expect( + screen.queryByTestId(INSTANCE_STATUS_BAR_ID) + ).not.toBeInTheDocument(); +}); + +test('InstanceStatusBar should warn when the trial is far from expired', async () => { + render( + + ); + + expect(screen.getByTestId(INSTANCE_STATUS_BAR_ID)).toBeInTheDocument(); + expect(await screen.findByTestId(INSTANCE_STATUS_BAR_ID)).toMatchSnapshot(); +}); + +test('InstanceStatusBar should warn when the trial is about to expire', async () => { + render( + + ); + + expect(screen.getByTestId(INSTANCE_STATUS_BAR_ID)).toBeInTheDocument(); + expect(await screen.findByTestId(INSTANCE_STATUS_BAR_ID)).toMatchSnapshot(); +}); + +test('InstanceStatusBar should warn when trialExpiry has passed', async () => { + render( + + ); + + expect(screen.getByTestId(INSTANCE_STATUS_BAR_ID)).toBeInTheDocument(); + expect(await screen.findByTestId(INSTANCE_STATUS_BAR_ID)).toMatchSnapshot(); +}); + +test('InstanceStatusBar should warn when the trial has expired', async () => { + render( + + ); + + expect(screen.getByTestId(INSTANCE_STATUS_BAR_ID)).toBeInTheDocument(); + expect(await screen.findByTestId(INSTANCE_STATUS_BAR_ID)).toMatchSnapshot(); +}); + +test('InstanceStatusBar should warn when the trial has churned', async () => { + render( + + ); + + expect(screen.getByTestId(INSTANCE_STATUS_BAR_ID)).toBeInTheDocument(); + expect(await screen.findByTestId(INSTANCE_STATUS_BAR_ID)).toMatchSnapshot(); +}); diff --git a/frontend/src/component/common/InstanceStatus/InstanceStatusBar.tsx b/frontend/src/component/common/InstanceStatus/InstanceStatusBar.tsx new file mode 100644 index 0000000000..089c225800 --- /dev/null +++ b/frontend/src/component/common/InstanceStatus/InstanceStatusBar.tsx @@ -0,0 +1,142 @@ +import { styled, Button, Typography } from '@mui/material'; +import { IInstanceStatus } from 'interfaces/instance'; +import { INSTANCE_STATUS_BAR_ID } from 'utils/testIds'; +import { InfoOutlined, WarningAmber } from '@mui/icons-material'; +import { useNavigate } from 'react-router-dom'; +import { useContext } from 'react'; +import AccessContext from 'contexts/AccessContext'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import { + trialHasExpired, + trialExpiresSoon, + isTrialInstance, +} from 'utils/instanceTrial'; +import { formatDistanceToNowStrict, parseISO } from 'date-fns'; + +const StyledWarningBar = styled('aside')(({ theme }) => ({ + position: 'relative', + zIndex: 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: theme.spacing(1), + gap: theme.spacing(1), + borderBottom: '1px solid', + borderColor: theme.palette.warning.border, + background: theme.palette.warning.light, + color: theme.palette.warning.dark, +})); + +const StyledInfoBar = styled('aside')(({ theme }) => ({ + position: 'relative', + zIndex: 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: theme.spacing(1), + gap: theme.spacing(1), + borderBottom: '1px solid', + borderColor: theme.palette.info.border, + background: theme.palette.info.light, + color: theme.palette.info.dark, +})); + +const StyledButton = styled(Button)(({ theme }) => ({ + whiteSpace: 'nowrap', + minWidth: '8rem', + marginLeft: theme.spacing(2), +})); + +const StyledWarningIcon = styled(WarningAmber)(({ theme }) => ({ + color: theme.palette.warning.main, +})); + +const StyledInfoIcon = styled(InfoOutlined)(({ theme }) => ({ + color: theme.palette.info.main, +})); + +interface IInstanceStatusBarProps { + instanceStatus: IInstanceStatus; +} + +export const InstanceStatusBar = ({ + instanceStatus, +}: IInstanceStatusBarProps) => { + if (trialHasExpired(instanceStatus)) { + return ; + } + + if (trialExpiresSoon(instanceStatus)) { + return ; + } + + if (isTrialInstance(instanceStatus)) { + return ; + } + + return null; +}; + +const StatusBarExpired = ({ instanceStatus }: IInstanceStatusBarProps) => { + return ( + + + ({ fontSize: theme.fontSizes.smallBody })}> + Warning! Your free {instanceStatus.plan} trial + has expired. Upgrade trial otherwise your{' '} + account will be deleted. + + + + ); +}; + +const StatusBarExpiresSoon = ({ instanceStatus }: IInstanceStatusBarProps) => { + const timeRemaining = formatDistanceToNowStrict( + parseISO(instanceStatus.trialExpiry!), + { roundingMethod: 'floor' } + ); + + return ( + + + ({ fontSize: theme.fontSizes.smallBody })}> + Heads up! You have{' '} + {timeRemaining} left of your free{' '} + {instanceStatus.plan} trial. + + + + ); +}; + +const StatusBarExpiresLater = ({ instanceStatus }: IInstanceStatusBarProps) => { + return ( + + + ({ fontSize: theme.fontSizes.smallBody })}> + Heads up! You're currently on a free{' '} + {instanceStatus.plan} trial account. + + + + ); +}; + +const BillingLink = () => { + const { hasAccess } = useContext(AccessContext); + const navigate = useNavigate(); + + if (!hasAccess(ADMIN)) { + return null; + } + + return ( + navigate('/admin/billing')} + variant="outlined" + > + Upgrade trial + + ); +}; diff --git a/frontend/src/component/common/InstanceStatus/__snapshots__/InstanceStatusBar.test.tsx.snap b/frontend/src/component/common/InstanceStatus/__snapshots__/InstanceStatusBar.test.tsx.snap new file mode 100644 index 0000000000..24c0071941 --- /dev/null +++ b/frontend/src/component/common/InstanceStatus/__snapshots__/InstanceStatusBar.test.tsx.snap @@ -0,0 +1,186 @@ +// Vitest Snapshot v1 + +exports[`InstanceStatusBar should warn when the trial has churned 1`] = ` + +`; + +exports[`InstanceStatusBar should warn when the trial has expired 1`] = ` + +`; + +exports[`InstanceStatusBar should warn when the trial is about to expire 1`] = ` + +`; + +exports[`InstanceStatusBar should warn when the trial is far from expired 1`] = ` + +`; + +exports[`InstanceStatusBar should warn when trialExpiry has passed 1`] = ` + +`; diff --git a/frontend/src/component/common/Loader/Loader.styles.ts b/frontend/src/component/common/Loader/Loader.styles.ts new file mode 100644 index 0000000000..7284ea586f --- /dev/null +++ b/frontend/src/component/common/Loader/Loader.styles.ts @@ -0,0 +1,15 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + loader: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + height: '100%', + backgroundColor: theme.palette.background.paper, + }, + img: { + width: '100px', + height: '100px', + }, +})); diff --git a/frontend/src/component/common/Loader/Loader.tsx b/frontend/src/component/common/Loader/Loader.tsx new file mode 100644 index 0000000000..73a8bf2d17 --- /dev/null +++ b/frontend/src/component/common/Loader/Loader.tsx @@ -0,0 +1,15 @@ +import logo from 'assets/img/unleashLogoIconDarkAlpha.gif'; +import { formatAssetPath } from 'utils/formatPath'; +import { useStyles } from './Loader.styles'; + +const Loader = () => { + const { classes: styles } = useStyles(); + + return ( +
    + +
    + ); +}; + +export default Loader; diff --git a/frontend/src/component/common/LoginRedirect/LoginRedirect.tsx b/frontend/src/component/common/LoginRedirect/LoginRedirect.tsx new file mode 100644 index 0000000000..38a9bcbea1 --- /dev/null +++ b/frontend/src/component/common/LoginRedirect/LoginRedirect.tsx @@ -0,0 +1,10 @@ +import { useLocation, Navigate } from 'react-router-dom'; + +export const LoginRedirect = () => { + const { pathname, search } = useLocation(); + + const redirect = encodeURIComponent(pathname + search); + const loginLink = `/login?redirect=${redirect}`; + + return ; +}; diff --git a/frontend/src/component/common/MainHeader/MainHeader.tsx b/frontend/src/component/common/MainHeader/MainHeader.tsx new file mode 100644 index 0000000000..9c5464bfdb --- /dev/null +++ b/frontend/src/component/common/MainHeader/MainHeader.tsx @@ -0,0 +1,64 @@ +import { Paper, styled } from '@mui/material'; +import { usePageTitle } from 'hooks/usePageTitle'; +import { ReactNode } from 'react'; +import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender'; + +const StyledMainHeader = styled(Paper)(({ theme }) => ({ + borderRadius: theme.shape.borderRadiusLarge, + padding: theme.spacing(2.5, 4), + boxShadow: 'none', + marginBottom: theme.spacing(2), + fontSize: theme.fontSizes.smallBody, +})); + +const StyledTitleHeader = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'space-between', +})); + +const StyledTitle = styled('h1')(({ theme }) => ({ + fontSize: theme.fontSizes.mainHeader, +})); + +const StyledActions = styled('div')(({ theme }) => ({ + display: 'flex', +})); + +const StyledDescription = styled('span')(({ theme }) => ({ + color: theme.palette.text.secondary, + fontSize: theme.fontSizes.smallBody, + marginLeft: theme.spacing(1), +})); + +interface IMainHeaderProps { + title?: string; + description?: string; + actions?: ReactNode; +} + +export const MainHeader = ({ + title, + description, + actions, +}: IMainHeaderProps) => { + usePageTitle(title); + + return ( + + + {title} + {actions} + + + Description: + {description} + + } + /> + + ); +}; diff --git a/frontend/src/component/common/NoItems/NoItems.styles.ts b/frontend/src/component/common/NoItems/NoItems.styles.ts new file mode 100644 index 0000000000..bc041bf0cf --- /dev/null +++ b/frontend/src/component/common/NoItems/NoItems.styles.ts @@ -0,0 +1,35 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + display: 'flex', + width: '80%', + margin: '0 auto', + [theme.breakpoints.down(700)]: { + flexDirection: 'column', + alignItems: 'center', + }, + }, + textContainer: { + width: '50%', + [theme.breakpoints.down(700)]: { + width: '100%', + }, + }, + iconContainer: { + width: '50%', + [theme.breakpoints.down(700)]: { + width: '100%', + }, + }, + icon: { + width: '300px', + height: '200px', + [theme.breakpoints.down(700)]: { + marginTop: '2rem', + }, + [theme.breakpoints.down(500)]: { + display: 'none', + }, + }, +})); diff --git a/frontend/src/component/common/NoItems/NoItems.tsx b/frontend/src/component/common/NoItems/NoItems.tsx new file mode 100644 index 0000000000..77d547e3b5 --- /dev/null +++ b/frontend/src/component/common/NoItems/NoItems.tsx @@ -0,0 +1,17 @@ +import { ReactComponent as NoItemsIcon } from 'assets/icons/addfiles.svg'; +import { useStyles } from './NoItems.styles'; +import React from 'react'; + +const NoItems: React.FC = ({ children }) => { + const { classes: styles } = useStyles(); + return ( +
    +
    {children}
    +
    + +
    +
    + ); +}; + +export default NoItems; diff --git a/frontend/src/component/common/NotFound/NotFound.styles.ts b/frontend/src/component/common/NotFound/NotFound.styles.ts new file mode 100644 index 0000000000..2f6fa50efe --- /dev/null +++ b/frontend/src/component/common/NotFound/NotFound.styles.ts @@ -0,0 +1,35 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()({ + container: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + minHeight: '100vh', + padding: '2rem', + position: 'fixed', + inset: 0, + backgroundColor: '#fff', + width: '100%', + }, + logo: { + height: '80px', + }, + content: { + display: 'flex', + position: 'relative', + }, + buttonContainer: { + marginTop: '2rem', + }, + homeButton: { + marginLeft: '1rem', + }, + icon: { + height: '150px', + width: '150px', + position: 'absolute', + right: 100, + top: 45, + }, +}); diff --git a/frontend/src/component/common/NotFound/NotFound.tsx b/frontend/src/component/common/NotFound/NotFound.tsx new file mode 100644 index 0000000000..e20fd10beb --- /dev/null +++ b/frontend/src/component/common/NotFound/NotFound.tsx @@ -0,0 +1,47 @@ +import { Button, Typography } from '@mui/material'; +import { useNavigate } from 'react-router'; + +import { ReactComponent as LogoIcon } from 'assets/icons/logoBg.svg'; + +import { useStyles } from './NotFound.styles'; +import { GO_BACK } from 'constants/navigate'; + +const NotFound = () => { + const navigate = useNavigate(); + const { classes: styles } = useStyles(); + + const onClickHome = () => { + navigate('/'); + }; + + const onClickBack = () => { + navigate(GO_BACK); + }; + + return ( +
    +
    + +
    + + Ooops. That's a page we haven't toggled on yet. + +
    +
    + + +
    +
    +
    + ); +}; + +export default NotFound; diff --git a/frontend/src/component/common/OperatorUpgradeAlert/OperatorUpgradeAlert.tsx b/frontend/src/component/common/OperatorUpgradeAlert/OperatorUpgradeAlert.tsx new file mode 100644 index 0000000000..2927f4a04a --- /dev/null +++ b/frontend/src/component/common/OperatorUpgradeAlert/OperatorUpgradeAlert.tsx @@ -0,0 +1,23 @@ +import { Alert } from '@mui/material'; + +export const OperatorUpgradeAlert = () => { + return ( + + Remember to update your Unleash client! New operators require new + SDK versions. . + + ); +}; + +const OperatorDocsLink = () => { + return ( + + Read more + + ); +}; diff --git a/frontend/src/component/common/PageContent/PageContent.styles.ts b/frontend/src/component/common/PageContent/PageContent.styles.ts new file mode 100644 index 0000000000..d9ead5943b --- /dev/null +++ b/frontend/src/component/common/PageContent/PageContent.styles.ts @@ -0,0 +1,29 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + borderRadius: theme.shape.borderRadiusLarge, + boxShadow: 'none', + }, + headerContainer: { + padding: theme.spacing(2, 4), + borderBottomStyle: 'solid', + borderBottomWidth: 1, + borderBottomColor: theme.palette.divider, + [theme.breakpoints.down('md')]: { + padding: '1.5rem 1rem', + }, + }, + bodyContainer: { + padding: theme.spacing(4), + [theme.breakpoints.down('md')]: { + padding: theme.spacing(2), + }, + }, + paddingDisabled: { + padding: '0', + }, + borderDisabled: { + border: 'none', + }, +})); diff --git a/frontend/src/component/common/PageContent/PageContent.tsx b/frontend/src/component/common/PageContent/PageContent.tsx new file mode 100644 index 0000000000..e96973eea2 --- /dev/null +++ b/frontend/src/component/common/PageContent/PageContent.tsx @@ -0,0 +1,95 @@ +import React, { FC, ReactNode } from 'react'; +import classnames from 'classnames'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { Paper, PaperProps } from '@mui/material'; +import { useStyles } from './PageContent.styles'; +import useLoading from 'hooks/useLoading'; +import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender'; + +interface IPageContentProps extends PaperProps { + header?: ReactNode; + isLoading?: boolean; + /** + * @deprecated fix feature event log and remove + */ + disablePadding?: boolean; + /** + * @deprecated fix feature event log and remove + */ + disableBorder?: boolean; + disableLoading?: boolean; + bodyClass?: string; +} + +const PageContentLoading: FC<{ isLoading: boolean }> = ({ + children, + isLoading, +}) => { + const ref = useLoading(isLoading); + + return ( +
    + {children} +
    + ); +}; + +export const PageContent: FC = ({ + children, + header, + disablePadding = false, + disableBorder = false, + bodyClass = '', + isLoading = false, + disableLoading = false, + className, + ...rest +}) => { + const { classes: styles } = useStyles(); + + const headerClasses = classnames('header', styles.headerContainer, { + [styles.paddingDisabled]: disablePadding, + [styles.borderDisabled]: disableBorder, + }); + + const bodyClasses = classnames( + 'body', + bodyClass ? bodyClass : styles.bodyContainer, + { + [styles.paddingDisabled]: disablePadding, + [styles.borderDisabled]: disableBorder, + } + ); + + const paperProps = disableBorder ? { elevation: 0 } : {}; + + const content = ( + + + } + elseShow={header} + /> + + } + /> +
    {children}
    +
    + ); + + if (disableLoading) { + return content; + } + + return ( + {content} + ); +}; diff --git a/frontend/src/component/common/PageHeader/PageHeader.styles.ts b/frontend/src/component/common/PageHeader/PageHeader.styles.ts new file mode 100644 index 0000000000..e13fba7db2 --- /dev/null +++ b/frontend/src/component/common/PageHeader/PageHeader.styles.ts @@ -0,0 +1,31 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + headerContainer: { + display: 'flex', + flexDirection: 'column', + }, + topContainer: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + position: 'relative', + }, + header: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + marginRight: theme.spacing(2), + }, + headerTitle: { + fontSize: theme.fontSizes.mainHeader, + fontWeight: 'normal', + }, + headerActions: { + display: 'flex', + flexGrow: 1, + justifyContent: 'flex-end', + alignItems: 'center', + gap: theme.spacing(1), + }, +})); diff --git a/frontend/src/component/common/PageHeader/PageHeader.tsx b/frontend/src/component/common/PageHeader/PageHeader.tsx new file mode 100644 index 0000000000..d60a5544c6 --- /dev/null +++ b/frontend/src/component/common/PageHeader/PageHeader.tsx @@ -0,0 +1,88 @@ +import { ReactNode, FC, VFC } from 'react'; +import classnames from 'classnames'; + +import { + Divider, + styled, + SxProps, + Theme, + Typography, + TypographyProps, +} from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; + +import { useStyles } from './PageHeader.styles'; +import { usePageTitle } from 'hooks/usePageTitle'; + +const StyledDivider = styled(Divider)(({ theme }) => ({ + height: '100%', + borderColor: theme.palette.dividerAlternative, + width: '1px', + display: 'inline-block', + marginLeft: theme.spacing(2), + marginRight: theme.spacing(2), + padding: '10px 0', + verticalAlign: 'middle', +})); + +interface IPageHeaderProps { + title?: string; + titleElement?: ReactNode; + subtitle?: string; + variant?: TypographyProps['variant']; + loading?: boolean; + actions?: ReactNode; + className?: string; + secondary?: boolean; +} + +const PageHeaderComponent: FC & { + Divider: typeof PageHeaderDivider; +} = ({ + title, + titleElement, + actions, + subtitle, + variant, + loading, + className = '', + secondary, + children, +}) => { + const { classes: styles } = useStyles(); + const headerClasses = classnames({ skeleton: loading }); + + usePageTitle(secondary ? '' : title); + + return ( +
    +
    +
    + + {titleElement || title} + + {subtitle && {subtitle}} +
    + {actions}
    } + /> +
    + {children} + + ); +}; + +const PageHeaderDivider: VFC<{ sx?: SxProps }> = ({ sx }) => { + return ; +}; + +PageHeaderComponent.Divider = PageHeaderDivider; + +export const PageHeader = PageHeaderComponent; diff --git a/frontend/src/component/common/PaginateUI/PaginateUI.tsx b/frontend/src/component/common/PaginateUI/PaginateUI.tsx new file mode 100644 index 0000000000..26718b0ca1 --- /dev/null +++ b/frontend/src/component/common/PaginateUI/PaginateUI.tsx @@ -0,0 +1,171 @@ +import React, { useEffect, useState } from 'react'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import classnames from 'classnames'; +import { useStyles } from './PaginationUI.styles'; + +import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos'; +import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; + +import DoubleArrowIcon from '@mui/icons-material/DoubleArrow'; +import { useMediaQuery, useTheme } from '@mui/material'; + +interface IPaginateUIProps { + pages: any[]; + pageIndex: number; + prevPage: () => void; + setPageIndex: (idx: number) => void; + nextPage: () => void; + style?: React.CSSProperties; +} + +/** + * @deprecated + */ +const PaginateUI = ({ + pages, + pageIndex, + prevPage, + setPageIndex, + nextPage, + ...rest +}: IPaginateUIProps) => { + const STARTLIMIT = 6; + const theme = useTheme(); + const { classes: styles } = useStyles(); + const [limit, setLimit] = useState(STARTLIMIT); + const [start, setStart] = useState(0); + const matches = useMediaQuery(theme.breakpoints.down('md')); + + useEffect(() => { + if (matches) { + setLimit(4); + } + }, [matches]); + + useEffect(() => { + if (pageIndex === 0 && start !== 0) { + setStart(0); + setLimit(STARTLIMIT); + } + }, [pageIndex, start]); + + return ( + 1} + show={ +
    +
    + 0} + show={ + <> + + + + } + /> + + {pages + .map((page, idx) => { + const active = pageIndex === idx; + return ( + + ); + }) + .slice(start, limit)} + + + + + } + /> +
    +
    + } + /> + ); +}; + +export default PaginateUI; diff --git a/frontend/src/component/common/PaginateUI/PaginationUI.styles.ts b/frontend/src/component/common/PaginateUI/PaginationUI.styles.ts new file mode 100644 index 0000000000..727320f814 --- /dev/null +++ b/frontend/src/component/common/PaginateUI/PaginationUI.styles.ts @@ -0,0 +1,58 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + pagination: { + margin: '1rem 0 0 0', + display: 'flex', + justifyContent: ' center', + position: 'absolute', + bottom: '25px', + right: '0', + left: '0', + }, + paginationInnerContainer: { + position: 'relative', + }, + paginationButton: { + border: 'none', + cursor: 'pointer', + backgroundColor: 'efefef', + margin: '0 0.2rem', + width: '31px', + borderRadius: theme.shape.borderRadius, + padding: '0.25rem 0.5rem', + }, + paginationButtonActive: { + backgroundColor: '#635DC5', + color: '#fff', + transition: 'backgroundColor 0.3s ease', + }, + idxBtn: { + border: 'none', + borderRadius: theme.shape.borderRadius, + background: 'transparent', + position: 'absolute', + height: '23px', + cursor: 'pointer', + }, + doubleArrowBtnLeft: { + left: '-55px', + }, + doubleArrowBtnRight: { + right: '-55px', + }, + arrowIcon: { height: '15px', width: '15px' }, + arrowIconLeft: { + transform: 'rotate(180deg)', + }, + idxBtnIcon: { + height: '15px', + width: '15px', + }, + idxBtnLeft: { + left: '-30px', + }, + idxBtnRight: { + right: '-30px', + }, +})); diff --git a/frontend/src/component/common/PasswordField/PasswordField.tsx b/frontend/src/component/common/PasswordField/PasswordField.tsx new file mode 100644 index 0000000000..49b7df5a12 --- /dev/null +++ b/frontend/src/component/common/PasswordField/PasswordField.tsx @@ -0,0 +1,52 @@ +import { + IconButton, + InputAdornment, + TextField, + TextFieldProps, +} from '@mui/material'; +import { Visibility, VisibilityOff } from '@mui/icons-material'; +import React, { useState, VFC } from 'react'; + +const PasswordField: VFC = ({ ...rest }) => { + const [showPassword, setShowPassword] = useState(false); + + const handleClickShowPassword = () => { + setShowPassword(!showPassword); + }; + + const handleMouseDownPassword = ( + e: React.MouseEvent + ) => { + e.preventDefault(); + }; + + const IconComponent = showPassword ? Visibility : VisibilityOff; + const iconTitle = 'Toggle password visibility'; + + return ( + + + + + + ), + }} + {...rest} + /> + ); +}; + +export default PasswordField; diff --git a/frontend/src/component/common/PercentageCircle/PercentageCircle.tsx b/frontend/src/component/common/PercentageCircle/PercentageCircle.tsx new file mode 100644 index 0000000000..60b7ea86bf --- /dev/null +++ b/frontend/src/component/common/PercentageCircle/PercentageCircle.tsx @@ -0,0 +1,45 @@ +import { useTheme } from '@mui/material'; +import { CSSProperties } from 'react'; + +interface IPercentageCircleProps { + percentage: number; + size?: `${number}rem`; +} + +const PercentageCircle = ({ + percentage, + size = '4rem', +}: IPercentageCircleProps) => { + const theme = useTheme(); + + const style: CSSProperties = { + display: 'block', + borderRadius: '100%', + transform: 'rotate(-90deg)', + height: size, + width: size, + background: theme.palette.grey[200], + }; + + // The percentage circle used to be drawn by CSS with a conic-gradient, + // but the result was either jagged or blurry. SVG seems to look better. + // See https://stackoverflow.com/a/70659532. + const r = 100 / (2 * Math.PI); + const d = 2 * r; + + return ( + + + + ); +}; + +export default PercentageCircle; diff --git a/frontend/src/component/common/PermissionButton/PermissionButton.tsx b/frontend/src/component/common/PermissionButton/PermissionButton.tsx new file mode 100644 index 0000000000..0882d40a7b --- /dev/null +++ b/frontend/src/component/common/PermissionButton/PermissionButton.tsx @@ -0,0 +1,93 @@ +import { Button, ButtonProps } from '@mui/material'; +import { Lock } from '@mui/icons-material'; +import AccessContext from 'contexts/AccessContext'; +import React, { useContext } from 'react'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { + TooltipResolver, + ITooltipResolverProps, +} from 'component/common/TooltipResolver/TooltipResolver'; +import { formatAccessText } from 'utils/formatAccessText'; +import { useId } from 'hooks/useId'; + +export interface IPermissionButtonProps extends Omit { + permission: string | string[]; + onClick?: (e: any) => void; + disabled?: boolean; + projectId?: string; + environmentId?: string; + tooltipProps?: Omit; +} + +const PermissionButton: React.FC = ({ + permission, + variant = 'contained', + color = 'primary', + onClick, + children, + disabled, + projectId, + environmentId, + tooltipProps, + ...rest +}) => { + const { hasAccess } = useContext(AccessContext); + const id = useId(); + let access; + + const handleAccess = () => { + let access; + if (Array.isArray(permission)) { + access = permission.some(permission => { + if (projectId && environmentId) { + return hasAccess(permission, projectId, environmentId); + } else if (projectId) { + return hasAccess(permission, projectId); + } else { + return hasAccess(permission); + } + }); + } else { + if (projectId && environmentId) { + access = hasAccess(permission, projectId, environmentId); + } else if (projectId) { + access = hasAccess(permission, projectId); + } else { + access = hasAccess(permission); + } + } + + return access; + }; + + access = handleAccess(); + + return ( + + + + + + ); +}; + +export default PermissionButton; diff --git a/frontend/src/component/common/PermissionHOC/PermissionHOC.tsx b/frontend/src/component/common/PermissionHOC/PermissionHOC.tsx new file mode 100644 index 0000000000..25a833b0cc --- /dev/null +++ b/frontend/src/component/common/PermissionHOC/PermissionHOC.tsx @@ -0,0 +1,45 @@ +import { useContext, FC, ReactElement } from 'react'; +import AccessContext from 'contexts/AccessContext'; +import { + ITooltipResolverProps, + TooltipResolver, +} from 'component/common/TooltipResolver/TooltipResolver'; +import { formatAccessText } from 'utils/formatAccessText'; + +type IPermissionHOCProps = { + permission: string; + projectId?: string; + environmentId?: string; + tooltip?: string; + tooltipProps?: Omit; + children: ({ hasAccess }: { hasAccess?: boolean }) => ReactElement; +}; + +export const PermissionHOC: FC = ({ + permission, + projectId, + children, + environmentId, + tooltip, + tooltipProps, +}) => { + const { hasAccess } = useContext(AccessContext); + let access; + + if (projectId && environmentId) { + access = hasAccess(permission, projectId, environmentId); + } else if (projectId) { + access = hasAccess(permission, projectId); + } else { + access = hasAccess(permission); + } + + return ( + + {children({ hasAccess: access })} + + ); +}; diff --git a/frontend/src/component/common/PermissionIconButton/PermissionIconButton.tsx b/frontend/src/component/common/PermissionIconButton/PermissionIconButton.tsx new file mode 100644 index 0000000000..aa3c44c3c0 --- /dev/null +++ b/frontend/src/component/common/PermissionIconButton/PermissionIconButton.tsx @@ -0,0 +1,78 @@ +import { IconButton, IconButtonProps } from '@mui/material'; +import React, { useContext, ReactNode } from 'react'; +import AccessContext from 'contexts/AccessContext'; +import { Link } from 'react-router-dom'; +import { + TooltipResolver, + ITooltipResolverProps, +} from 'component/common/TooltipResolver/TooltipResolver'; +import { formatAccessText } from 'utils/formatAccessText'; +import { useId } from 'hooks/useId'; + +interface IPermissionIconButtonProps { + permission: string; + projectId?: string; + environmentId?: string; + className?: string; + children?: ReactNode; + disabled?: boolean; + hidden?: boolean; + type?: 'button'; + edge?: IconButtonProps['edge']; + tooltipProps?: Omit; + sx?: IconButtonProps['sx']; + size?: string; +} + +interface IButtonProps extends IPermissionIconButtonProps { + onClick: (event: React.SyntheticEvent) => void; +} + +interface ILinkProps extends IPermissionIconButtonProps { + component: typeof Link; + to: string; +} + +const PermissionIconButton = ({ + permission, + projectId, + children, + environmentId, + tooltipProps, + disabled, + ...rest +}: IButtonProps | ILinkProps) => { + const { hasAccess } = useContext(AccessContext); + const id = useId(); + let access; + + if (projectId && environmentId) { + access = hasAccess(permission, projectId, environmentId); + } else if (projectId) { + access = hasAccess(permission, projectId); + } else { + access = hasAccess(permission); + } + + return ( + e.preventDefault()} + > +
    + + {children} + +
    +
    + ); +}; + +export default PermissionIconButton; diff --git a/frontend/src/component/common/PermissionSwitch/PermissionSwitch.tsx b/frontend/src/component/common/PermissionSwitch/PermissionSwitch.tsx new file mode 100644 index 0000000000..3d99a7af94 --- /dev/null +++ b/frontend/src/component/common/PermissionSwitch/PermissionSwitch.tsx @@ -0,0 +1,58 @@ +import { Switch, SwitchProps } from '@mui/material'; +import AccessContext from 'contexts/AccessContext'; +import React, { useContext } from 'react'; +import { formatAccessText } from 'utils/formatAccessText'; +import { TooltipResolver } from 'component/common/TooltipResolver/TooltipResolver'; + +interface IPermissionSwitchProps extends SwitchProps { + permission: string; + tooltip?: string; + onChange?: (e: React.ChangeEvent) => void; + disabled?: boolean; + projectId?: string; + environmentId?: string; + checked: boolean; +} + +const PermissionSwitch = React.forwardRef< + HTMLButtonElement, + IPermissionSwitchProps +>((props, ref) => { + const { + permission, + tooltip, + disabled, + projectId, + environmentId, + checked, + onChange, + ...rest + } = props; + + const { hasAccess } = useContext(AccessContext); + + let access; + if (projectId && environmentId) { + access = hasAccess(permission, projectId, environmentId); + } else if (projectId) { + access = hasAccess(permission, projectId); + } else { + access = hasAccess(permission); + } + + return ( + + + + + + ); +}); + +export default PermissionSwitch; diff --git a/frontend/src/component/common/Proclamation/Proclamation.styles.ts b/frontend/src/component/common/Proclamation/Proclamation.styles.ts new file mode 100644 index 0000000000..3997888d0c --- /dev/null +++ b/frontend/src/component/common/Proclamation/Proclamation.styles.ts @@ -0,0 +1,15 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()({ + proclamation: { + marginBottom: '1rem', + }, + content: { + maxWidth: '800px', + }, + link: { + display: 'block', + marginTop: '0.5rem', + width: '100px', + }, +}); diff --git a/frontend/src/component/common/Proclamation/Proclamation.tsx b/frontend/src/component/common/Proclamation/Proclamation.tsx new file mode 100644 index 0000000000..df0fe67ee1 --- /dev/null +++ b/frontend/src/component/common/Proclamation/Proclamation.tsx @@ -0,0 +1,66 @@ +import { useState, useEffect } from 'react'; +import { Alert } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { Typography } from '@mui/material'; +import { useStyles } from './Proclamation.styles'; +import { IProclamationToast } from 'interfaces/uiConfig'; + +interface IProclamationProps { + toast?: IProclamationToast; +} + +const renderProclamation = (id: string) => { + if (!id) return false; + if (localStorage) { + const value = localStorage.getItem(id); + if (value) { + return false; + } + } + return true; +}; + +const Proclamation = ({ toast }: IProclamationProps) => { + const [show, setShow] = useState(false); + const { classes: styles } = useStyles(); + + useEffect(() => { + setShow(renderProclamation(toast?.id || '')); + }, [toast?.id]); + + const onClose = () => { + if (localStorage) { + localStorage.setItem(toast?.id || '', 'seen'); + } + setShow(false); + }; + + if (!toast) return null; + + return ( + + + {toast.message} + + + View more + + + } + /> + ); +}; + +export default Proclamation; diff --git a/frontend/src/component/common/ProjectSelect/ProjectSelect.tsx b/frontend/src/component/common/ProjectSelect/ProjectSelect.tsx new file mode 100644 index 0000000000..9f8daeab7a --- /dev/null +++ b/frontend/src/component/common/ProjectSelect/ProjectSelect.tsx @@ -0,0 +1,74 @@ +import React, { MouseEventHandler, useMemo, VFC } from 'react'; +import { MenuItem, Typography } from '@mui/material'; +import DropdownMenu, { IDropdownMenuProps } from '../DropdownMenu/DropdownMenu'; +import useProjects from 'hooks/api/getters/useProjects/useProjects'; +import { IProjectCard } from 'interfaces/project'; + +const ALL_PROJECTS = { id: '*', name: '> All projects' }; + +interface IProjectSelectProps { + currentProjectId: string; + updateCurrentProject: (id: string) => void; +} + +const ProjectSelect: VFC> = ({ + currentProjectId, + updateCurrentProject, + ...rest +}) => { + const { projects } = useProjects(); + + const setProject = (value?: string | null) => { + const id = value && typeof value === 'string' ? value.trim() : '*'; + updateCurrentProject(id); + }; + + const currentProject = useMemo(() => { + const project = projects.find(i => i.id === currentProjectId); + return project || ALL_PROJECTS; + }, [currentProjectId, projects]); + + const handleChangeProject: MouseEventHandler = event => { + const target = (event.target as Element).getAttribute('data-target'); + setProject(target); + }; + + const renderProjectItem = (selectedId: string, item: IProjectCard) => ( + + {item.name} + + ); + + const renderProjectOptions = () => [ + + {ALL_PROJECTS.name} + , + ...projects.map(project => + renderProjectItem(currentProjectId, project) + ), + ]; + + return ( + + + + ); +}; + +export default ProjectSelect; diff --git a/frontend/src/component/common/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/component/common/ProtectedRoute/ProtectedRoute.tsx new file mode 100644 index 0000000000..ca1ff60804 --- /dev/null +++ b/frontend/src/component/common/ProtectedRoute/ProtectedRoute.tsx @@ -0,0 +1,18 @@ +import { IRoute } from 'interfaces/route'; +import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser'; +import { LoginRedirect } from 'component/common/LoginRedirect/LoginRedirect'; + +interface IProtectedRouteProps { + route: IRoute; +} + +export const ProtectedRoute = ({ route }: IProtectedRouteProps) => { + const { user } = useAuthUser(); + const isLoggedIn = Boolean(user?.id); + + if (!isLoggedIn && route.type === 'protected') { + return ; + } + + return ; +}; diff --git a/frontend/src/component/common/ResponsiveButton/ResponsiveButton.tsx b/frontend/src/component/common/ResponsiveButton/ResponsiveButton.tsx new file mode 100644 index 0000000000..128c60ddaf --- /dev/null +++ b/frontend/src/component/common/ResponsiveButton/ResponsiveButton.tsx @@ -0,0 +1,66 @@ +import { useMediaQuery } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import PermissionButton from '../PermissionButton/PermissionButton'; +import PermissionIconButton from '../PermissionIconButton/PermissionIconButton'; +import React from 'react'; + +interface IResponsiveButtonProps { + Icon: React.ElementType; + onClick: () => void; + disabled?: boolean; + permission: string; + projectId?: string; + environmentId?: string; + maxWidth: string; + className?: string; +} + +const ResponsiveButton: React.FC = ({ + Icon, + onClick, + maxWidth, + disabled = false, + children, + permission, + environmentId, + projectId, + ...rest +}) => { + const smallScreen = useMediaQuery(`(max-width:${maxWidth})`); + + return ( + + + + } + elseShow={ + + {children} + + } + /> + ); +}; + +export default ResponsiveButton; diff --git a/frontend/src/component/common/ScrollTop/ScrollTop.tsx b/frontend/src/component/common/ScrollTop/ScrollTop.tsx new file mode 100644 index 0000000000..3e70b83e3b --- /dev/null +++ b/frontend/src/component/common/ScrollTop/ScrollTop.tsx @@ -0,0 +1,21 @@ +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; + +export const ScrollTop = (): null => { + const { pathname } = useLocation(); + + useEffect(() => { + if (!noScrollPaths.some(noScroll => pathname.includes(noScroll))) { + window.scrollTo(0, 0); + } + }, [pathname]); + + return null; +}; + +const noScrollPaths = [ + '/admin/api', + '/admin/users', + '/admin/auth', + '/admin/roles', +]; diff --git a/frontend/src/component/common/Search/Search.styles.ts b/frontend/src/component/common/Search/Search.styles.ts new file mode 100644 index 0000000000..09d8bc076f --- /dev/null +++ b/frontend/src/component/common/Search/Search.styles.ts @@ -0,0 +1,47 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + display: 'flex', + flexGrow: 1, + alignItems: 'center', + position: 'relative', + backgroundColor: theme.palette.background.paper, + maxWidth: '400px', + [theme.breakpoints.down('md')]: { + marginTop: theme.spacing(1), + maxWidth: '100%', + }, + }, + search: { + display: 'flex', + alignItems: 'center', + backgroundColor: theme.palette.background.paper, + border: `1px solid ${theme.palette.grey[500]}`, + borderRadius: theme.shape.borderRadiusExtraLarge, + padding: '3px 5px 3px 12px', + width: '100%', + zIndex: 3, + '&.search-container:focus-within': { + borderColor: theme.palette.primary.light, + boxShadow: theme.boxShadows.main, + }, + }, + searchIcon: { + marginRight: 8, + color: theme.palette.inactiveIcon, + }, + clearContainer: { + width: '30px', + '& > button': { + padding: '7px', + }, + }, + clearIcon: { + color: theme.palette.grey[700], + fontSize: '18px', + }, + inputRoot: { + width: '100%', + }, +})); diff --git a/frontend/src/component/common/Search/Search.tsx b/frontend/src/component/common/Search/Search.tsx new file mode 100644 index 0000000000..6733984e7b --- /dev/null +++ b/frontend/src/component/common/Search/Search.tsx @@ -0,0 +1,121 @@ +import React, { useRef, useState } from 'react'; +import { IconButton, InputBase, Tooltip } from '@mui/material'; +import { Search as SearchIcon, Close } from '@mui/icons-material'; +import classnames from 'classnames'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { useStyles } from './Search.styles'; +import { SearchSuggestions } from './SearchSuggestions/SearchSuggestions'; +import { IGetSearchContextOutput } from 'hooks/useSearch'; +import { useKeyboardShortcut } from 'hooks/useKeyboardShortcut'; +import { useAsyncDebounce } from 'react-table'; + +interface ISearchProps { + initialValue?: string; + onChange: (value: string) => void; + className?: string; + placeholder?: string; + hasFilters?: boolean; + disabled?: boolean; + getSearchContext?: () => IGetSearchContextOutput; + containerStyles?: React.CSSProperties; + debounceTime?: number; +} + +export const Search = ({ + initialValue = '', + onChange, + className, + placeholder: customPlaceholder, + hasFilters, + disabled, + getSearchContext, + containerStyles, + debounceTime = 200, +}: ISearchProps) => { + const ref = useRef(); + const { classes: styles } = useStyles(); + const [showSuggestions, setShowSuggestions] = useState(false); + + const [value, setValue] = useState(initialValue); + const debouncedOnChange = useAsyncDebounce(onChange, debounceTime); + + const onSearchChange = (value: string) => { + debouncedOnChange(value); + setValue(value); + }; + + const hotkey = useKeyboardShortcut( + { modifiers: ['ctrl'], key: 'k', preventDefault: true }, + () => { + if (document.activeElement === ref.current) { + ref.current?.blur(); + } else { + ref.current?.focus(); + } + } + ); + useKeyboardShortcut({ key: 'Escape' }, () => { + if (document.activeElement === ref.current) { + ref.current?.blur(); + } + }); + const placeholder = `${customPlaceholder ?? 'Search'} (${hotkey})`; + + return ( +
    +
    + + onSearchChange(e.target.value)} + onFocus={() => setShowSuggestions(true)} + onBlur={() => setShowSuggestions(false)} + disabled={disabled} + /> +
    + + { + onSearchChange(''); + ref.current?.focus(); + }} + > + + + + } + /> +
    +
    + + } + /> +
    + ); +}; diff --git a/frontend/src/component/common/Search/SearchSuggestions/SearchDescription/SearchDescription.tsx b/frontend/src/component/common/Search/SearchSuggestions/SearchDescription/SearchDescription.tsx new file mode 100644 index 0000000000..66396db282 --- /dev/null +++ b/frontend/src/component/common/Search/SearchSuggestions/SearchDescription/SearchDescription.tsx @@ -0,0 +1,72 @@ +import { styled } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { + getSearchTextGenerator, + IGetSearchContextOutput, +} from 'hooks/useSearch'; +import { VFC } from 'react'; + +const StyledHeader = styled('span')(({ theme }) => ({ + fontSize: theme.fontSizes.smallBody, + color: theme.palette.text.primary, +})); + +const StyledCode = styled('span')(({ theme }) => ({ + backgroundColor: theme.palette.secondaryContainer, + color: theme.palette.text.primary, + padding: theme.spacing(0, 0.5), + borderRadius: theme.spacing(0.5), +})); + +interface ISearchDescriptionProps { + filters: any[]; + getSearchContext: () => IGetSearchContextOutput; + searchableColumnsString: string; +} + +export const SearchDescription: VFC = ({ + filters, + getSearchContext, + searchableColumnsString, +}) => { + const searchContext = getSearchContext(); + const getSearchText = getSearchTextGenerator(searchContext.columns); + const searchText = getSearchText(searchContext.searchValue); + const searchFilters = filters.filter(filter => filter.values.length > 0); + + return ( + <> + + Searching for: +

    + {searchText}{' '} + {searchableColumnsString + ? ` in ${searchableColumnsString}` + : ''} +

    + + } + /> + 0} + show={ + <> + Filtering by: + {searchFilters.map(filter => ( +

    + + {filter.values.join(',')} + {' '} + in {filter.header}. Options:{' '} + {filter.options.join(', ')} +

    + ))} + + } + /> + + ); +}; diff --git a/frontend/src/component/common/Search/SearchSuggestions/SearchInstructions/SearchInstructions.tsx b/frontend/src/component/common/Search/SearchSuggestions/SearchInstructions/SearchInstructions.tsx new file mode 100644 index 0000000000..bfaf3a557e --- /dev/null +++ b/frontend/src/component/common/Search/SearchSuggestions/SearchInstructions/SearchInstructions.tsx @@ -0,0 +1,62 @@ +import { styled } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { IGetSearchContextOutput } from 'hooks/useSearch'; +import { VFC } from 'react'; + +const StyledHeader = styled('span')(({ theme }) => ({ + fontSize: theme.fontSizes.smallBody, + color: theme.palette.text.primary, +})); + +const StyledCode = styled('span')(({ theme }) => ({ + backgroundColor: theme.palette.secondaryContainer, + color: theme.palette.text.primary, + padding: theme.spacing(0, 0.5), + borderRadius: theme.spacing(0.5), +})); + +interface ISearchInstructionsProps { + filters: any[]; + getSearchContext: () => IGetSearchContextOutput; + searchableColumnsString: string; +} + +export const SearchInstructions: VFC = ({ + filters, + getSearchContext, + searchableColumnsString, +}) => { + return ( + <> + + {filters.length > 0 + ? 'Filter your search with operators like:' + : `Start typing to search${ + searchableColumnsString + ? ` in ${searchableColumnsString}` + : '...' + }`} + + {filters.map(filter => ( +

    + Filter by {filter.header}:{' '} + + {filter.name}:{filter.options[0]} + + 1} + show={ + <> + {' or '} + + {filter.name}: + {filter.options.slice(0, 2).join(',')} + + + } + /> +

    + ))} + + ); +}; diff --git a/frontend/src/component/common/Search/SearchSuggestions/SearchSuggestions.tsx b/frontend/src/component/common/Search/SearchSuggestions/SearchSuggestions.tsx new file mode 100644 index 0000000000..3ba355c9df --- /dev/null +++ b/frontend/src/component/common/Search/SearchSuggestions/SearchSuggestions.tsx @@ -0,0 +1,150 @@ +import { FilterList } from '@mui/icons-material'; +import { Box, Divider, Paper, styled } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { + getColumnValues, + getFilterableColumns, + getFilterValues, + IGetSearchContextOutput, +} from 'hooks/useSearch'; +import { useMemo, VFC } from 'react'; +import { SearchDescription } from './SearchDescription/SearchDescription'; +import { SearchInstructions } from './SearchInstructions/SearchInstructions'; + +const randomIndex = (arr: any[]) => Math.floor(Math.random() * arr.length); + +const StyledPaper = styled(Paper)(({ theme }) => ({ + position: 'absolute', + width: '100%', + left: 0, + top: '20px', + zIndex: 2, + padding: theme.spacing(4, 1.5, 1.5), + borderBottomLeftRadius: theme.spacing(1), + borderBottomRightRadius: theme.spacing(1), + boxShadow: '0px 8px 20px rgba(33, 33, 33, 0.15)', + fontSize: theme.fontSizes.smallBody, + color: theme.palette.text.secondary, + wordBreak: 'break-word', +})); + +const StyledBox = styled(Box)(({ theme }) => ({ + display: 'flex', + gap: theme.spacing(2), +})); + +const StyledFilterList = styled(FilterList)(({ theme }) => ({ + color: theme.palette.text.secondary, +})); + +const StyledDivider = styled(Divider)(({ theme }) => ({ + border: `1px dashed ${theme.palette.dividerAlternative}`, + margin: theme.spacing(1.5, 0), +})); + +const StyledCode = styled('span')(({ theme }) => ({ + backgroundColor: theme.palette.secondaryContainer, + color: theme.palette.text.primary, + padding: theme.spacing(0, 0.5), + borderRadius: theme.spacing(0.5), +})); + +interface SearchSuggestionsProps { + getSearchContext: () => IGetSearchContextOutput; +} + +export const SearchSuggestions: VFC = ({ + getSearchContext, +}) => { + const searchContext = getSearchContext(); + + const randomRow = useMemo( + () => randomIndex(searchContext.data), + [searchContext.data] + ); + + const filters = getFilterableColumns(searchContext.columns) + .map(column => { + const filterOptions = searchContext.data.map(row => + getColumnValues(column, row) + ); + + return { + name: column.filterName, + header: column.Header ?? column.filterName, + options: [...new Set(filterOptions)].sort((a, b) => + a.localeCompare(b) + ), + suggestedOption: + filterOptions[randomRow] ?? `example-${column.filterName}`, + values: getFilterValues( + column.filterName, + searchContext.searchValue + ), + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + + const searchableColumns = searchContext.columns.filter( + column => column.searchable && column.accessor + ); + + const searchableColumnsString = searchableColumns + .map(column => column.Header ?? column.accessor) + .join(', '); + + const suggestedTextSearch = + searchContext.data.length && searchableColumns.length + ? getColumnValues( + searchableColumns[0], + searchContext.data[randomRow] + ) + : 'example-search-text'; + + return ( + + + + + + } + elseShow={ + + } + /> + + + + 0} + show="Combine filters and search." + /> +

    + Example:{' '} + + {filters.map(filter => ( + + {filter.name}:{filter.suggestedOption}{' '} + + ))} + {suggestedTextSearch} + +

    +
    + ); +}; diff --git a/frontend/src/component/common/SearchField/SearchField.tsx b/frontend/src/component/common/SearchField/SearchField.tsx new file mode 100644 index 0000000000..ac0798d6a7 --- /dev/null +++ b/frontend/src/component/common/SearchField/SearchField.tsx @@ -0,0 +1,78 @@ +import React, { useState, VFC } from 'react'; +import classnames from 'classnames'; +import { debounce } from 'debounce'; +import { InputBase, Chip } from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; +import { useStyles } from 'component/common/SearchField/styles'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; + +interface ISearchFieldProps { + updateValue: (value: string) => void; + initialValue?: string; + className?: string; + showValueChip?: boolean; +} + +/** + * @deprecated use `Search` instead. + */ +export const SearchField: VFC = ({ + updateValue, + initialValue = '', + className = '', + showValueChip, +}) => { + const { classes: styles } = useStyles(); + const [localValue, setLocalValue] = useState(initialValue); + const debounceUpdateValue = debounce(updateValue, 500); + + const handleChange = (event: React.ChangeEvent) => { + event.preventDefault(); + const value = event.target.value || ''; + setLocalValue(value); + debounceUpdateValue(value); + }; + + const handleKeyPress = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + updateValue(localValue); + } + }; + + const updateNow = () => { + updateValue(localValue); + }; + + const onDelete = () => { + setLocalValue(''); + updateValue(''); + }; + + return ( +
    +
    + + +
    + + } + /> + + ); +}; diff --git a/frontend/src/component/common/SearchField/styles.ts b/frontend/src/component/common/SearchField/styles.ts new file mode 100644 index 0000000000..3317521aac --- /dev/null +++ b/frontend/src/component/common/SearchField/styles.ts @@ -0,0 +1,28 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + gap: '1rem', + }, + search: { + display: 'flex', + alignItems: 'center', + backgroundColor: theme.palette.background.default, + borderRadius: theme.shape.borderRadiusExtraLarge, + padding: '0.25rem 0.5rem', + maxWidth: '450px', + [theme.breakpoints.down('sm')]: { + width: '100%', + }, + }, + searchIcon: { + marginRight: 8, + color: theme.palette.inactiveIcon, + }, + inputRoot: { + width: '100%', + }, +})); diff --git a/frontend/src/component/common/SegmentItem/SegmentItem.styles.ts b/frontend/src/component/common/SegmentItem/SegmentItem.styles.ts new file mode 100644 index 0000000000..729a715fd0 --- /dev/null +++ b/frontend/src/component/common/SegmentItem/SegmentItem.styles.ts @@ -0,0 +1,45 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + width: '100%', + padding: theme.spacing(2, 3), + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-start', + fontSize: theme.fontSizes.smallBody, + border: `1px solid ${theme.palette.dividerAlternative}`, + position: 'relative', + borderRadius: '5px', + }, + link: { + textDecoration: 'none', + marginLeft: theme.spacing(1), + '&:hover': { + textDecoration: 'underline', + }, + }, + accordion: { + border: `1px solid ${theme.palette.dividerAlternative}`, + borderRadius: theme.shape.borderRadiusMedium, + backgroundColor: '#fff', + boxShadow: 'none', + margin: 0, + }, + accordionRoot: { + transition: 'all 0.1s ease', + }, + accordionExpanded: { + backgroundColor: theme.palette.neutral.light, + }, + previewButton: { + paddingTop: 0, + paddingBottom: 0, + marginLeft: 'auto', + fontSize: theme.fontSizes.smallBody, + }, + summary: { + fontSize: theme.fontSizes.smallBody, + margin: theme.spacing(0.5, 0), + }, +})); diff --git a/frontend/src/component/common/SegmentItem/SegmentItem.tsx b/frontend/src/component/common/SegmentItem/SegmentItem.tsx new file mode 100644 index 0000000000..79a5ced0a7 --- /dev/null +++ b/frontend/src/component/common/SegmentItem/SegmentItem.tsx @@ -0,0 +1,95 @@ +import { useState, VFC } from 'react'; +import { Link } from 'react-router-dom'; +import { DonutLarge } from '@mui/icons-material'; +import { ISegment } from 'interfaces/segment'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Button, + Typography, +} from '@mui/material'; +import { ConstraintAccordionList } from '../ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList'; +import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender'; +import { useStyles } from './SegmentItem.styles'; + +interface ISegmentItemProps { + segment: Partial; + isExpanded?: boolean; + constraintList?: JSX.Element; + headerContent?: JSX.Element; +} + +export const SegmentItem: VFC = ({ + segment, + isExpanded, + headerContent, + constraintList, +}) => { + const { classes } = useStyles(); + const [isOpen, setIsOpen] = useState(isExpanded || false); + + return ( + + + + Segment: + + {segment.name} + + + setIsOpen(value => !value)} + className={classes.previewButton} + > + {isOpen ? 'Close preview' : 'Preview'} + + } + /> + + + 0} + show={ + + } + elseShow={ + + This segment has no constraints. + + } + /> + } + /> + + + ); +}; diff --git a/frontend/src/component/common/SidebarModal/SidebarModal.styles.ts b/frontend/src/component/common/SidebarModal/SidebarModal.styles.ts new file mode 100644 index 0000000000..c63fb8c13c --- /dev/null +++ b/frontend/src/component/common/SidebarModal/SidebarModal.styles.ts @@ -0,0 +1,15 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(() => ({ + modal: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + height: '100vh', + maxWidth: '98vw', + width: 1300, + overflow: 'auto', + boxShadow: '0 0 1rem rgba(0, 0, 0, 0.25)', + }, +})); diff --git a/frontend/src/component/common/SidebarModal/SidebarModal.tsx b/frontend/src/component/common/SidebarModal/SidebarModal.tsx new file mode 100644 index 0000000000..8c27e629d4 --- /dev/null +++ b/frontend/src/component/common/SidebarModal/SidebarModal.tsx @@ -0,0 +1,39 @@ +import { ReactNode } from 'react'; +import { Modal, Backdrop } from '@mui/material'; +import Fade from '@mui/material/Fade'; +import { useStyles } from 'component/common/SidebarModal/SidebarModal.styles'; +import { SIDEBAR_MODAL_ID } from 'utils/testIds'; + +interface ISidebarModalProps { + open: boolean; + onClose: () => void; + label: string; + children: ReactNode; +} + +const TRANSITION_DURATION = 250; + +export const SidebarModal = ({ + open, + onClose, + label, + children, +}: ISidebarModalProps) => { + const { classes: styles } = useStyles(); + + return ( + + +
    {children}
    +
    +
    + ); +}; diff --git a/frontend/src/component/common/SkipNav/SkipNavLink.styles.ts b/frontend/src/component/common/SkipNav/SkipNavLink.styles.ts new file mode 100644 index 0000000000..a4fa96d4ce --- /dev/null +++ b/frontend/src/component/common/SkipNav/SkipNavLink.styles.ts @@ -0,0 +1,33 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + link: { + position: 'fixed', + overflow: 'hidden', + zIndex: 1000, + top: '1.125rem', + left: '1.125rem', + padding: '0.5rem 1rem', + whiteSpace: 'nowrap', + textDecoration: 'none', + background: theme.palette.primary.dark, + color: theme.palette.primary.contrastText, + borderRadius: theme.shape.borderRadius, + fontSize: theme.fontSizes.smallBody, + + [theme.breakpoints.down(960)]: { + top: '0.8rem', + left: '0.8rem', + }, + + '&:not(:focus):not(:active)': { + clip: 'rect(0 0 0 0)', + clipPath: 'inset(50%)', + zIndex: -1, + width: 1, + height: 1, + margin: -1, + padding: 0, + }, + }, +})); diff --git a/frontend/src/component/common/SkipNav/SkipNavLink.tsx b/frontend/src/component/common/SkipNav/SkipNavLink.tsx new file mode 100644 index 0000000000..635dbbb352 --- /dev/null +++ b/frontend/src/component/common/SkipNav/SkipNavLink.tsx @@ -0,0 +1,12 @@ +import { SKIP_NAV_TARGET_ID } from 'component/common/SkipNav/SkipNavTarget'; +import { useStyles } from 'component/common/SkipNav/SkipNavLink.styles'; + +export const SkipNavLink = () => { + const { classes: styles } = useStyles(); + + return ( + + Skip to content + + ); +}; diff --git a/frontend/src/component/common/SkipNav/SkipNavTarget.tsx b/frontend/src/component/common/SkipNav/SkipNavTarget.tsx new file mode 100644 index 0000000000..feeff614fe --- /dev/null +++ b/frontend/src/component/common/SkipNav/SkipNavTarget.tsx @@ -0,0 +1,5 @@ +export const SKIP_NAV_TARGET_ID = 'skip-nav-target-id'; + +export const SkipNavTarget = () => { + return
    ; +}; diff --git a/frontend/src/component/common/StatusChip/StatusChip.styles.ts b/frontend/src/component/common/StatusChip/StatusChip.styles.ts new file mode 100644 index 0000000000..d939efd67f --- /dev/null +++ b/frontend/src/component/common/StatusChip/StatusChip.styles.ts @@ -0,0 +1,9 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + chip: { + background: 'transparent', + border: `1px solid ${theme.palette.primary.main}`, + color: theme.palette.primary.main, + }, +})); diff --git a/frontend/src/component/common/StatusChip/StatusChip.tsx b/frontend/src/component/common/StatusChip/StatusChip.tsx new file mode 100644 index 0000000000..7e30c3f0ca --- /dev/null +++ b/frontend/src/component/common/StatusChip/StatusChip.tsx @@ -0,0 +1,34 @@ +import { Chip } from '@mui/material'; +import { useStyles } from './StatusChip.styles'; + +interface IStatusChip { + stale: boolean; + showActive?: boolean; +} + +const StatusChip = ({ stale, showActive = true }: IStatusChip) => { + const { classes: styles } = useStyles(); + + if (!stale && !showActive) { + return null; + } + + const title = stale + ? 'Feature toggle is deprecated.' + : 'Feature toggle is active.'; + const value = stale ? 'Stale' : 'Active'; + + return ( +
    + +
    + ); +}; + +export default StatusChip; diff --git a/frontend/src/component/common/StrategyItemContainer/StrategyItemContainer.styles.ts b/frontend/src/component/common/StrategyItemContainer/StrategyItemContainer.styles.ts new file mode 100644 index 0000000000..701ed5f7e4 --- /dev/null +++ b/frontend/src/component/common/StrategyItemContainer/StrategyItemContainer.styles.ts @@ -0,0 +1,39 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + borderRadius: theme.shape.borderRadiusMedium, + border: `1px solid ${theme.palette.divider}`, + '& + &': { + marginTop: theme.spacing(2), + }, + background: theme.palette.background.paper, + }, + header: { + padding: theme.spacing(0.5, 2), + display: 'flex', + gap: theme.spacing(1), + alignItems: 'center', + borderBottom: `1px solid ${theme.palette.divider}`, + fontWeight: theme.typography.fontWeightMedium, + }, + headerDraggable: { + paddingLeft: theme.spacing(1), + }, + icon: { + fill: theme.palette.inactiveIcon, + }, + actions: { + marginLeft: 'auto', + display: 'flex', + minHeight: theme.spacing(6), + alignItems: 'center', + }, + resultChip: { + marginLeft: 'auto', + }, + body: { + padding: theme.spacing(2), + justifyItems: 'center', + }, +})); diff --git a/frontend/src/component/common/StrategyItemContainer/StrategyItemContainer.tsx b/frontend/src/component/common/StrategyItemContainer/StrategyItemContainer.tsx new file mode 100644 index 0000000000..fc3efe8e61 --- /dev/null +++ b/frontend/src/component/common/StrategyItemContainer/StrategyItemContainer.tsx @@ -0,0 +1,96 @@ +import { DragEventHandler, FC, ReactNode } from 'react'; +import { DragIndicator } from '@mui/icons-material'; +import { styled, IconButton, Box } from '@mui/material'; +import classNames from 'classnames'; +import { IFeatureStrategy } from 'interfaces/strategy'; +import { + getFeatureStrategyIcon, + formatStrategyName, +} from 'utils/strategyNames'; +import StringTruncator from 'component/common/StringTruncator/StringTruncator'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { useStyles } from './StrategyItemContainer.styles'; + +interface IStrategyItemContainerProps { + strategy: IFeatureStrategy; + onDragStart?: DragEventHandler; + onDragEnd?: DragEventHandler; + actions?: ReactNode; + orderNumber?: number; + className?: string; +} + +const DragIcon = styled(IconButton)(({ theme }) => ({ + padding: 0, + cursor: 'inherit', + transition: 'color 0.2s ease-in-out', +})); + +const StyledIndexLabel = styled('div')(({ theme }) => ({ + fontSize: theme.typography.fontSize, + color: theme.palette.text.secondary, + position: 'absolute', + display: 'none', + right: 'calc(100% + 6px)', + top: theme.spacing(2.5), + [theme.breakpoints.up('md')]: { + display: 'block', + }, +})); + +export const StrategyItemContainer: FC = ({ + strategy, + onDragStart, + onDragEnd, + actions, + children, + orderNumber, + className, +}) => { + const { classes: styles } = useStyles(); + const Icon = getFeatureStrategyIcon(strategy.name); + + return ( + + {orderNumber}} + /> + +
    + ( + + + + )} + /> + + +
    {actions}
    +
    +
    {children}
    +
    +
    + ); +}; diff --git a/frontend/src/component/common/StrategySeparator/StrategySeparator.tsx b/frontend/src/component/common/StrategySeparator/StrategySeparator.tsx new file mode 100644 index 0000000000..19e0896483 --- /dev/null +++ b/frontend/src/component/common/StrategySeparator/StrategySeparator.tsx @@ -0,0 +1,52 @@ +import { Box, styled, useTheme } from '@mui/material'; +import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender'; + +interface IStrategySeparatorProps { + text: 'AND' | 'OR'; +} + +const StyledContent = styled('div')(({ theme }) => ({ + padding: theme.spacing(0.75, 1), + color: theme.palette.text.primary, + fontSize: theme.fontSizes.smallerBody, + backgroundColor: theme.palette.secondaryContainer, + borderRadius: theme.shape.borderRadius, + position: 'absolute', + zIndex: theme.zIndex.fab, + top: '50%', + left: theme.spacing(2), + transform: 'translateY(-50%)', + lineHeight: 1, +})); + +const StyledCenteredContent = styled(StyledContent)(({ theme }) => ({ + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + backgroundColor: theme.palette.activityIndicators.primary, + border: `1px solid ${theme.palette.primary.border}`, + borderRadius: theme.shape.borderRadiusLarge, + padding: theme.spacing(0.75, 1.5), +})); + +export const StrategySeparator = ({ text }: IStrategySeparatorProps) => { + const theme = useTheme(); + + return ( + + {text}} + elseShow={() => ( + {text} + )} + /> + + ); +}; diff --git a/frontend/src/component/common/StringTruncator/StringTruncator.tsx b/frontend/src/component/common/StringTruncator/StringTruncator.tsx new file mode 100644 index 0000000000..74205701f8 --- /dev/null +++ b/frontend/src/component/common/StringTruncator/StringTruncator.tsx @@ -0,0 +1,45 @@ +import { Tooltip } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; + +interface IStringTruncatorProps { + text: string; + maxWidth: string; + className?: string; + maxLength: number; +} + +const StringTruncator = ({ + text, + maxWidth, + maxLength, + className, + ...rest +}: IStringTruncatorProps) => { + return ( + maxLength} + show={ + + + {text} + + + } + elseShow={{text}} + /> + ); +}; + +export default StringTruncator; diff --git a/frontend/src/component/common/TabNav/TabNav/TabNav.styles.ts b/frontend/src/component/common/TabNav/TabNav/TabNav.styles.ts new file mode 100644 index 0000000000..856db96277 --- /dev/null +++ b/frontend/src/component/common/TabNav/TabNav/TabNav.styles.ts @@ -0,0 +1,15 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + tabNav: { + backgroundColor: theme.palette.background.paper, + borderBottom: '1px solid', + borderBottomColor: theme.palette.grey[300], + borderRadius: 0, + }, + tab: { + [theme.breakpoints.up('lg')]: { + minWidth: 160, + }, + }, +})); diff --git a/frontend/src/component/common/TabNav/TabNav/TabNav.tsx b/frontend/src/component/common/TabNav/TabNav/TabNav.tsx new file mode 100644 index 0000000000..b3c1122456 --- /dev/null +++ b/frontend/src/component/common/TabNav/TabNav/TabNav.tsx @@ -0,0 +1,67 @@ +import React, { useState, ReactNode } from 'react'; +import classnames from 'classnames'; +import { Tabs, Tab, Paper } from '@mui/material'; +import { useStyles } from 'component/common/TabNav/TabNav/TabNav.styles'; +import { TabPanel } from 'component/common/TabNav/TabPanel/TabPanel'; + +interface ITabNavProps { + tabData: ITabData[]; + className?: string; + navClass?: string; + startingTab?: number; +} + +interface ITabData { + label: string; + component: ReactNode; +} + +export const TabNav = ({ + tabData, + className = '', + navClass = '', + startingTab = 0, +}: ITabNavProps) => { + const { classes: styles } = useStyles(); + const [activeTab, setActiveTab] = useState(startingTab); + + const renderTabs = () => + tabData.map((tab, index) => ( + + )); + + const renderTabPanels = () => + tabData.map((tab, index) => ( + + {tab.component} + + )); + + return ( + <> + + { + setActiveTab(tabId); + }} + indicatorColor="primary" + textColor="primary" + centered + > + {renderTabs()} + + +
    {renderTabPanels()}
    + + ); +}; diff --git a/frontend/src/component/common/TabNav/TabPanel/TabPanel.tsx b/frontend/src/component/common/TabNav/TabPanel/TabPanel.tsx new file mode 100644 index 0000000000..3c2fc65a39 --- /dev/null +++ b/frontend/src/component/common/TabNav/TabPanel/TabPanel.tsx @@ -0,0 +1,18 @@ +import React, { ReactNode } from 'react'; + +interface ITabPanelProps { + value: number; + index: number; + children: ReactNode; +} + +export const TabPanel = ({ children, value, index }: ITabPanelProps) => ( + +); diff --git a/frontend/src/component/common/Table/SearchHighlightContext/SearchHighlightContext.tsx b/frontend/src/component/common/Table/SearchHighlightContext/SearchHighlightContext.tsx new file mode 100644 index 0000000000..2a2e34a58c --- /dev/null +++ b/frontend/src/component/common/Table/SearchHighlightContext/SearchHighlightContext.tsx @@ -0,0 +1,9 @@ +import { createContext, useContext } from 'react'; + +const SearchHighlightContext = createContext(''); + +export const SearchHighlightProvider = SearchHighlightContext.Provider; + +export const useSearchHighlightContext = (): { searchQuery: string } => { + return { searchQuery: useContext(SearchHighlightContext) }; +}; diff --git a/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.styles.ts b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.styles.ts new file mode 100644 index 0000000000..3a7ffa4823 --- /dev/null +++ b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.styles.ts @@ -0,0 +1,99 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + header: { + position: 'relative', + fontWeight: theme.fontWeight.medium, + }, + flex: { + justifyContent: 'stretch', + alignItems: 'center', + display: 'flex', + flexShrink: 0, + '& > *': { + flexGrow: 1, + }, + }, + flexGrow: { + flexGrow: 1, + }, + sortable: { + padding: 0, + '&:hover, &:focus': { + backgroundColor: theme.palette.tableHeaderHover, + '& svg': { + color: 'inherit', + }, + }, + }, + sortButton: { + all: 'unset', + whiteSpace: 'nowrap', + width: '100%', + position: 'relative', + zIndex: 1, + ':hover, :focus, &:focus-visible, &:active': { + outline: 'revert', + '.hover-only': { + display: 'inline-block', + }, + }, + display: 'flex', + boxSizing: 'inherit', + cursor: 'pointer', + }, + sortedButton: { + fontWeight: theme.fontWeight.bold, + }, + label: { + display: 'flex', + flexDirection: 'column', + flexShrink: 1, + minWidth: 0, + '::after': { + fontWeight: 'bold', + display: 'inline-block', + height: 0, + content: 'attr(data-text)', + visibility: 'hidden', + overflow: 'hidden', + }, + }, + alignLeft: { + justifyContent: 'flex-start', + textAlign: 'left', + }, + alignRight: { + justifyContent: 'flex-end', + textAlign: 'right', + }, + alignCenter: { + justifyContent: 'center', + textAlign: 'center', + }, + hiddenMeasurementLayer: { + padding: theme.spacing(2), + visibility: 'hidden', + display: 'flex', + alignItems: 'center', + width: '100%', + }, + visibleAbsoluteLayer: { + padding: theme.spacing(2), + position: 'absolute', + display: 'flex', + alignItems: 'center', + width: '100%', + height: '100%', + '.hover-only': { + display: 'none', + }, + '& > span': { + minWidth: 0, + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + overflowX: 'hidden', + overflowY: 'visible', + }, + }, +})); diff --git a/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.tsx b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.tsx new file mode 100644 index 0000000000..bef033443d --- /dev/null +++ b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.tsx @@ -0,0 +1,161 @@ +import { + FC, + MouseEventHandler, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { TableCell, Tooltip } from '@mui/material'; +import classnames from 'classnames'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { useStyles } from './CellSortable.styles'; +import { AnnouncerContext } from 'component/common/Announcer/AnnouncerContext/AnnouncerContext'; +import { SortArrow } from './SortArrow/SortArrow'; + +interface ICellSortableProps { + isSortable?: boolean; + isSorted?: boolean; + isDescending?: boolean; + ariaTitle?: string; + width?: number | string; + minWidth?: number | string; + maxWidth?: number | string; + align?: 'left' | 'center' | 'right'; + isFlex?: boolean; + isFlexGrow?: boolean; + onClick?: MouseEventHandler; +} + +export const CellSortable: FC = ({ + children, + isSortable = true, + isSorted = false, + isDescending, + width, + minWidth, + maxWidth, + align, + ariaTitle, + isFlex, + isFlexGrow, + onClick = () => {}, +}) => { + const { setAnnouncement } = useContext(AnnouncerContext); + const [title, setTitle] = useState(''); + const ref = useRef(null); + const { classes: styles } = useStyles(); + + const ariaSort = isSorted + ? isDescending + ? 'descending' + : 'ascending' + : undefined; + + const onSortClick: MouseEventHandler = event => { + onClick(event); + setAnnouncement( + `Sorted${ariaTitle ? ` by ${ariaTitle} ` : ''}, ${ + isDescending ? 'ascending' : 'descending' + }` + ); + }; + + const alignClass = useMemo(() => { + switch (align) { + case 'left': + return styles.alignLeft; + case 'center': + return styles.alignCenter; + case 'right': + return styles.alignRight; + default: + return undefined; + } + }, [align, styles]); + + useEffect(() => { + const updateTitle = () => { + const newTitle = + ariaTitle && + ref.current && + ref?.current?.offsetWidth < ref?.current?.scrollWidth + ? `${children}` + : ''; + + if (newTitle !== title) { + setTitle(newTitle); + } + }; + + updateTitle(); + }, [setTitle, ariaTitle]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + + + + } + elseShow={
    {children}
    } + /> +
    + ); +}; diff --git a/frontend/src/component/common/Table/SortableTableHeader/CellSortable/SortArrow/SortArrow.styles.ts b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/SortArrow/SortArrow.styles.ts new file mode 100644 index 0000000000..2cfd400e7b --- /dev/null +++ b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/SortArrow/SortArrow.styles.ts @@ -0,0 +1,14 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + icon: { + marginLeft: theme.spacing(0.25), + marginRight: theme.spacing(-0.5), + color: theme.palette.grey[700], + fontSize: theme.fontSizes.mainHeader, + verticalAlign: 'middle', + }, + sorted: { + color: theme.palette.grey[900], + }, +})); diff --git a/frontend/src/component/common/Table/SortableTableHeader/CellSortable/SortArrow/SortArrow.tsx b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/SortArrow/SortArrow.tsx new file mode 100644 index 0000000000..5ddf22dd74 --- /dev/null +++ b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/SortArrow/SortArrow.tsx @@ -0,0 +1,60 @@ +import { VFC } from 'react'; +import { + KeyboardArrowDown, + KeyboardArrowUp, + UnfoldMoreOutlined, +} from '@mui/icons-material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { useStyles } from './SortArrow.styles'; +import classnames from 'classnames'; + +interface ISortArrowProps { + isSorted?: boolean; + isDesc?: boolean; + className?: string; +} + +export const SortArrow: VFC = ({ + isSorted: sorted, + isDesc: desc = false, + className, +}) => { + const { classes: styles } = useStyles(); + + return ( + + } + elseShow={ + + } + /> + } + elseShow={ + + } + /> + ); +}; diff --git a/frontend/src/component/common/Table/SortableTableHeader/SortableTableHeader.styles.ts b/frontend/src/component/common/Table/SortableTableHeader/SortableTableHeader.styles.ts new file mode 100644 index 0000000000..e1904e1818 --- /dev/null +++ b/frontend/src/component/common/Table/SortableTableHeader/SortableTableHeader.styles.ts @@ -0,0 +1,20 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + tableHeader: { + '& > th': { + height: theme.shape.tableRowHeightCompact, + border: 0, + backgroundColor: theme.palette.tableHeaderBackground, + color: theme.palette.tableHeaderColor, + '&:first-of-type': { + borderTopLeftRadius: theme.shape.borderRadiusMedium, + borderBottomLeftRadius: theme.shape.borderRadiusMedium, + }, + '&:last-of-type': { + borderTopRightRadius: theme.shape.borderRadiusMedium, + borderBottomRightRadius: theme.shape.borderRadiusMedium, + }, + }, + }, +})); diff --git a/frontend/src/component/common/Table/SortableTableHeader/SortableTableHeader.tsx b/frontend/src/component/common/Table/SortableTableHeader/SortableTableHeader.tsx new file mode 100644 index 0000000000..9eb020fda6 --- /dev/null +++ b/frontend/src/component/common/Table/SortableTableHeader/SortableTableHeader.tsx @@ -0,0 +1,61 @@ +import { VFC } from 'react'; +import { TableHead, TableRow } from '@mui/material'; +import { HeaderGroup } from 'react-table'; +import { useStyles } from './SortableTableHeader.styles'; +import { CellSortable } from './CellSortable/CellSortable'; + +interface ISortableTableHeaderProps { + headerGroups: HeaderGroup[]; + className?: string; + flex?: boolean; +} + +export const SortableTableHeader: VFC = ({ + headerGroups, + className, + flex, +}) => { + const { classes: styles } = useStyles(); + + return ( + + {headerGroups.map(headerGroup => ( + + {headerGroup.headers.map((column: HeaderGroup) => { + const content = column.render('Header'); + + return ( + + {content} + + ); + })} + + ))} + + ); +}; diff --git a/frontend/src/component/common/Table/Table/Table.styles.ts b/frontend/src/component/common/Table/Table/Table.styles.ts new file mode 100644 index 0000000000..b006579729 --- /dev/null +++ b/frontend/src/component/common/Table/Table/Table.styles.ts @@ -0,0 +1,19 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles<{ + rowHeight: 'auto' | 'standard' | 'dense' | 'compact' | number; +}>()((theme, { rowHeight }) => ({ + table: { + position: 'relative', + + '& tbody tr': { + height: + { + auto: 'auto', + standard: theme.shape.tableRowHeight, + compact: theme.shape.tableRowHeightCompact, + dense: theme.shape.tableRowHeightDense, + }[rowHeight] ?? rowHeight, + }, + }, +})); diff --git a/frontend/src/component/common/Table/Table/Table.tsx b/frontend/src/component/common/Table/Table/Table.tsx new file mode 100644 index 0000000000..86ae22607b --- /dev/null +++ b/frontend/src/component/common/Table/Table/Table.tsx @@ -0,0 +1,16 @@ +import { FC } from 'react'; +import classnames from 'classnames'; +import { Table as MUITable, TableProps } from '@mui/material'; +import { useStyles } from './Table.styles'; + +export const Table: FC< + TableProps & { + rowHeight?: 'auto' | 'dense' | 'standard' | 'compact' | number; + } +> = ({ rowHeight = 'auto', className, ...props }) => { + const { classes } = useStyles({ rowHeight }); + + return ( + + ); +}; diff --git a/frontend/src/component/common/Table/TableCell/TableCell.styles.ts b/frontend/src/component/common/Table/TableCell/TableCell.styles.ts new file mode 100644 index 0000000000..ac18919c58 --- /dev/null +++ b/frontend/src/component/common/Table/TableCell/TableCell.styles.ts @@ -0,0 +1,7 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + tableCell: { + padding: 0, + }, +})); diff --git a/frontend/src/component/common/Table/TableCell/TableCell.tsx b/frontend/src/component/common/Table/TableCell/TableCell.tsx new file mode 100644 index 0000000000..f43275d934 --- /dev/null +++ b/frontend/src/component/common/Table/TableCell/TableCell.tsx @@ -0,0 +1,15 @@ +import { FC } from 'react'; +import classnames from 'classnames'; +import { TableCell as MUITableCell, TableCellProps } from '@mui/material'; +import { useStyles } from './TableCell.styles'; + +export const TableCell: FC = ({ className, ...props }) => { + const { classes: styles } = useStyles(); + + return ( + + ); +}; diff --git a/frontend/src/component/common/Table/TablePlaceholder/TablePlaceholder.styles.ts b/frontend/src/component/common/Table/TablePlaceholder/TablePlaceholder.styles.ts new file mode 100644 index 0000000000..4df809bbce --- /dev/null +++ b/frontend/src/component/common/Table/TablePlaceholder/TablePlaceholder.styles.ts @@ -0,0 +1,14 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + emptyStateListItem: { + border: `2px dashed ${theme.palette.neutral.light}`, + padding: '0.8rem', + textAlign: 'center', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + marginTop: theme.spacing(2), + width: '100%', + }, +})); diff --git a/frontend/src/component/common/Table/TablePlaceholder/TablePlaceholder.tsx b/frontend/src/component/common/Table/TablePlaceholder/TablePlaceholder.tsx new file mode 100644 index 0000000000..456c7cf715 --- /dev/null +++ b/frontend/src/component/common/Table/TablePlaceholder/TablePlaceholder.tsx @@ -0,0 +1,9 @@ +import { FC } from 'react'; +import { Box } from '@mui/material'; +import { useStyles } from 'component/common/Table/TablePlaceholder/TablePlaceholder.styles'; + +export const TablePlaceholder: FC = ({ children }) => { + const { classes: styles } = useStyles(); + + return {children}; +}; diff --git a/frontend/src/component/common/Table/VirtualizedTable/VirtualizedTable.styles.ts b/frontend/src/component/common/Table/VirtualizedTable/VirtualizedTable.styles.ts new file mode 100644 index 0000000000..d368e0efaa --- /dev/null +++ b/frontend/src/component/common/Table/VirtualizedTable/VirtualizedTable.styles.ts @@ -0,0 +1,16 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(() => ({ + row: { + position: 'absolute', + width: '100%', + }, + cell: { + alignItems: 'center', + display: 'flex', + flexShrink: 0, + '& > *': { + flexGrow: 1, + }, + }, +})); diff --git a/frontend/src/component/common/Table/VirtualizedTable/VirtualizedTable.tsx b/frontend/src/component/common/Table/VirtualizedTable/VirtualizedTable.tsx new file mode 100644 index 0000000000..1fb82e2c88 --- /dev/null +++ b/frontend/src/component/common/Table/VirtualizedTable/VirtualizedTable.tsx @@ -0,0 +1,101 @@ +import { useMemo, VFC } from 'react'; +import { useTheme } from '@mui/material'; +import { + SortableTableHeader, + Table, + TableCell, + TableBody, + TableRow, +} from 'component/common/Table'; +import { useVirtualizedRange } from 'hooks/useVirtualizedRange'; +import { useStyles } from './VirtualizedTable.styles'; +import { HeaderGroup, Row } from 'react-table'; + +interface IVirtualizedTableProps { + rowHeight?: number; + headerGroups: HeaderGroup[]; + rows: Row[]; + prepareRow: (row: Row) => void; +} + +/** + * READ BEFORE USE + * + * Virtualized tables require some setup. + * With this component all but one columns are fixed width, and one fills remaining space. + * Add `maxWidth` to columns that will be static in width, and `minWidth` to the one that should grow. + * + * Remember to add `useFlexLayout` to `useTable` + * (more at: https://react-table-v7.tanstack.com/docs/api/useFlexLayout) + */ +export const VirtualizedTable: VFC = ({ + rowHeight: rowHeightOverride, + headerGroups, + rows, + prepareRow, +}) => { + const { classes } = useStyles(); + const theme = useTheme(); + const rowHeight = useMemo( + () => rowHeightOverride || theme.shape.tableRowHeight, + [rowHeightOverride, theme.shape.tableRowHeight] + ); + + const [firstRenderedIndex, lastRenderedIndex] = + useVirtualizedRange(rowHeight); + + const tableHeight = useMemo( + () => rowHeight * rows.length + theme.shape.tableRowHeightCompact, + [rowHeight, rows.length, theme.shape.tableRowHeightCompact] + ); + + return ( + + + + {rows.map((row, index) => { + const top = + index * rowHeight + theme.shape.tableRowHeightCompact; + + const isVirtual = + index < firstRenderedIndex || index > lastRenderedIndex; + + if (isVirtual) { + return null; + } + + prepareRow(row); + + return ( + + {row.cells.map(cell => ( + + {cell.render('Cell')} + + ))} + + ); + })} + +
    + ); +}; diff --git a/frontend/src/component/common/Table/cells/ActionCell/ActionCell.styles.ts b/frontend/src/component/common/Table/cells/ActionCell/ActionCell.styles.ts new file mode 100644 index 0000000000..5f00808831 --- /dev/null +++ b/frontend/src/component/common/Table/cells/ActionCell/ActionCell.styles.ts @@ -0,0 +1,15 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + padding: theme.spacing(0, 1.5), + }, + divider: { + borderColor: theme.palette.dividerAlternative, + height: theme.spacing(3), + margin: theme.spacing(0, 2), + }, +})); diff --git a/frontend/src/component/common/Table/cells/ActionCell/ActionCell.tsx b/frontend/src/component/common/Table/cells/ActionCell/ActionCell.tsx new file mode 100644 index 0000000000..30637e2c72 --- /dev/null +++ b/frontend/src/component/common/Table/cells/ActionCell/ActionCell.tsx @@ -0,0 +1,26 @@ +import { Box, Divider } from '@mui/material'; +import { FC, VFC } from 'react'; +import { useStyles } from './ActionCell.styles'; + +const ActionCellDivider: VFC = () => { + const { classes } = useStyles(); + return ( + + ); +}; + +const ActionCellComponent: FC & { + Divider: typeof ActionCellDivider; +} = ({ children }) => { + const { classes } = useStyles(); + + return {children}; +}; + +ActionCellComponent.Divider = ActionCellDivider; + +export const ActionCell = ActionCellComponent; diff --git a/frontend/src/component/common/Table/cells/DateCell/DateCell.tsx b/frontend/src/component/common/Table/cells/DateCell/DateCell.tsx new file mode 100644 index 0000000000..388eed8f41 --- /dev/null +++ b/frontend/src/component/common/Table/cells/DateCell/DateCell.tsx @@ -0,0 +1,21 @@ +import { VFC } from 'react'; +import { useLocationSettings } from 'hooks/useLocationSettings'; +import { formatDateYMD } from 'utils/formatDate'; +import { parseISO } from 'date-fns'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; + +interface IDateCellProps { + value?: Date | string | null; +} + +export const DateCell: VFC = ({ value }) => { + const { locationSettings } = useLocationSettings(); + + const date = value + ? value instanceof Date + ? formatDateYMD(value, locationSettings.locale) + : formatDateYMD(parseISO(value), locationSettings.locale) + : undefined; + + return {date}; +}; diff --git a/frontend/src/component/common/Table/cells/FeatureNameCell/FeatureNameCell.tsx b/frontend/src/component/common/Table/cells/FeatureNameCell/FeatureNameCell.tsx new file mode 100644 index 0000000000..13f0128a17 --- /dev/null +++ b/frontend/src/component/common/Table/cells/FeatureNameCell/FeatureNameCell.tsx @@ -0,0 +1,20 @@ +import { VFC } from 'react'; +import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell'; + +interface IFeatureNameCellProps { + row: { + original: { + name: string; + description: string; + project: string; + }; + }; +} + +export const FeatureNameCell: VFC = ({ row }) => ( + +); diff --git a/frontend/src/component/common/Table/cells/FeatureSeenCell/FeatureSeenCell.styles.ts b/frontend/src/component/common/Table/cells/FeatureSeenCell/FeatureSeenCell.styles.ts new file mode 100644 index 0000000000..27cb60bfc1 --- /dev/null +++ b/frontend/src/component/common/Table/cells/FeatureSeenCell/FeatureSeenCell.styles.ts @@ -0,0 +1,20 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + display: 'flex', + padding: theme.spacing(1.5), + }, + box: { + width: '38px', + height: '38px', + background: 'gray', + borderRadius: '4px', + textAlign: 'center', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: theme.fontSizes.smallerBody, + margin: '0 auto', + }, +})); diff --git a/frontend/src/component/common/Table/cells/FeatureSeenCell/FeatureSeenCell.tsx b/frontend/src/component/common/Table/cells/FeatureSeenCell/FeatureSeenCell.tsx new file mode 100644 index 0000000000..9c8f83abc5 --- /dev/null +++ b/frontend/src/component/common/Table/cells/FeatureSeenCell/FeatureSeenCell.tsx @@ -0,0 +1,117 @@ +import React, { FC, VFC } from 'react'; +import TimeAgo from 'react-timeago'; +import { Tooltip, useTheme } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { useStyles } from './FeatureSeenCell.styles'; + +function shortenUnitName(unit?: string): string { + switch (unit) { + case 'second': + return 's'; + case 'minute': + return 'm'; + case 'hour': + return 'h'; + case 'day': + return 'D'; + case 'week': + return 'W'; + case 'month': + return 'M'; + case 'year': + return 'Y'; + default: + return ''; + } +} + +const useFeatureColor = () => { + const theme = useTheme(); + + return (unit?: string): string => { + switch (unit) { + case 'second': + return theme.palette.activityIndicators.recent; + case 'minute': + return theme.palette.activityIndicators.recent; + case 'hour': + return theme.palette.activityIndicators.recent; + case 'day': + return theme.palette.activityIndicators.recent; + case 'week': + return theme.palette.activityIndicators.inactive; + case 'month': + return theme.palette.activityIndicators.abandoned; + case 'year': + return theme.palette.activityIndicators.abandoned; + default: + return theme.palette.activityIndicators.unknown; + } + }; +}; + +interface IFeatureSeenCellProps { + value?: string | Date | null; +} + +const Wrapper: FC<{ unit?: string; tooltip: string }> = ({ + unit, + tooltip, + children, +}) => { + const { classes: styles } = useStyles(); + const getColor = useFeatureColor(); + + return ( +
    + +
    + {children} +
    +
    +
    + ); +}; + +export const FeatureSeenCell: VFC = ({ + value: lastSeenAt, +}) => { + return ( + { + return ( + + {value} + {shortenUnitName(unit)} + + ); + }} + /> + } + elseShow={ + + – + + } + /> + ); +}; diff --git a/frontend/src/component/common/Table/cells/FeatureTypeCell/FeatureTypeCell.styles.ts b/frontend/src/component/common/Table/cells/FeatureTypeCell/FeatureTypeCell.styles.ts new file mode 100644 index 0000000000..2cbed1538b --- /dev/null +++ b/frontend/src/component/common/Table/cells/FeatureTypeCell/FeatureTypeCell.styles.ts @@ -0,0 +1,13 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + padding: theme.spacing(1.5), + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }, + icon: { + color: theme.palette.inactiveIcon, + }, +})); diff --git a/frontend/src/component/common/Table/cells/FeatureTypeCell/FeatureTypeCell.tsx b/frontend/src/component/common/Table/cells/FeatureTypeCell/FeatureTypeCell.tsx new file mode 100644 index 0000000000..617d2d3260 --- /dev/null +++ b/frontend/src/component/common/Table/cells/FeatureTypeCell/FeatureTypeCell.tsx @@ -0,0 +1,29 @@ +import { VFC } from 'react'; +import { Tooltip } from '@mui/material'; +import { getFeatureTypeIcons } from 'utils/getFeatureTypeIcons'; +import useFeatureTypes from 'hooks/api/getters/useFeatureTypes/useFeatureTypes'; +import { useStyles } from './FeatureTypeCell.styles'; + +interface IFeatureTypeProps { + value?: string; +} + +export const FeatureTypeCell: VFC = ({ value }) => { + const { classes: styles } = useStyles(); + const { featureTypes } = useFeatureTypes(); + const IconComponent = getFeatureTypeIcons(value); + + const typeName = featureTypes + .filter(type => type.id === value) + .map(type => type.name); + + const title = `This is a "${typeName || value}" toggle`; + + return ( +
    + + + +
    + ); +}; diff --git a/frontend/src/component/common/Table/cells/HighlightCell/HighlightCell.styles.ts b/frontend/src/component/common/Table/cells/HighlightCell/HighlightCell.styles.ts new file mode 100644 index 0000000000..3b47620556 --- /dev/null +++ b/frontend/src/component/common/Table/cells/HighlightCell/HighlightCell.styles.ts @@ -0,0 +1,29 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + wordBreak: 'break-word', + padding: theme.spacing(1, 2), + }, + title: { + overflow: 'hidden', + textOverflow: 'ellipsis', + display: '-webkit-box', + WebkitBoxOrient: 'vertical', + WebkitLineClamp: '1', + lineClamp: '1', + }, + subtitle: { + color: theme.palette.text.secondary, + overflow: 'hidden', + textOverflow: 'ellipsis', + fontSize: 'inherit', + WebkitLineClamp: '1', + lineClamp: '1', + display: '-webkit-box', + WebkitBoxOrient: 'vertical', + }, +})); diff --git a/frontend/src/component/common/Table/cells/HighlightCell/HighlightCell.tsx b/frontend/src/component/common/Table/cells/HighlightCell/HighlightCell.tsx new file mode 100644 index 0000000000..9690312ee0 --- /dev/null +++ b/frontend/src/component/common/Table/cells/HighlightCell/HighlightCell.tsx @@ -0,0 +1,48 @@ +import { VFC } from 'react'; +import { Highlighter } from 'component/common/Highlighter/Highlighter'; +import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { Box, Typography } from '@mui/material'; +import { useStyles } from './HighlightCell.styles'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; + +interface IHighlightCellProps { + value: string; + subtitle?: string; +} + +export const HighlightCell: VFC = ({ + value, + subtitle, +}) => { + const { searchQuery } = useSearchHighlightContext(); + const { classes } = useStyles(); + + return ( + + + {value} + + ( + + + {subtitle} + + + )} + /> + + ); +}; diff --git a/frontend/src/component/common/Table/cells/IconCell/IconCell.tsx b/frontend/src/component/common/Table/cells/IconCell/IconCell.tsx new file mode 100644 index 0000000000..2ad4f6a8db --- /dev/null +++ b/frontend/src/component/common/Table/cells/IconCell/IconCell.tsx @@ -0,0 +1,21 @@ +import { Box } from '@mui/material'; +import React, { ReactNode } from 'react'; + +interface IIconCellProps { + icon: ReactNode; +} + +export const IconCell = ({ icon }: IIconCellProps) => { + return ( + + {icon} + + ); +}; diff --git a/frontend/src/component/common/Table/cells/LinkCell/LinkCell.styles.ts b/frontend/src/component/common/Table/cells/LinkCell/LinkCell.styles.ts new file mode 100644 index 0000000000..4e900c3329 --- /dev/null +++ b/frontend/src/component/common/Table/cells/LinkCell/LinkCell.styles.ts @@ -0,0 +1,44 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + wrapper: { + paddingTop: theme.spacing(1.5), + paddingBottom: theme.spacing(1.5), + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), + display: 'flex', + alignItems: 'center', + minHeight: '62px', + }, + link: { + '&:hover, &:focus': { + textDecoration: 'none', + '& > div > span:first-of-type': { + textDecoration: 'underline', + }, + }, + }, + container: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + wordBreak: 'break-all', + }, + title: { + overflow: 'hidden', + textOverflow: 'ellipsis', + display: '-webkit-box', + WebkitBoxOrient: 'vertical', + }, + description: { + color: theme.palette.text.secondary, + textDecoration: 'none', + fontSize: 'inherit', + WebkitLineClamp: 1, + lineClamp: 1, + overflow: 'hidden', + textOverflow: 'ellipsis', + display: '-webkit-box', + WebkitBoxOrient: 'vertical', + }, +})); diff --git a/frontend/src/component/common/Table/cells/LinkCell/LinkCell.tsx b/frontend/src/component/common/Table/cells/LinkCell/LinkCell.tsx new file mode 100644 index 0000000000..3a3b9d91a7 --- /dev/null +++ b/frontend/src/component/common/Table/cells/LinkCell/LinkCell.tsx @@ -0,0 +1,79 @@ +import { FC } from 'react'; +import { Link, Typography } from '@mui/material'; +import { Link as RouterLink } from 'react-router-dom'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { useStyles } from './LinkCell.styles'; +import { Highlighter } from 'component/common/Highlighter/Highlighter'; +import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import classnames from 'classnames'; + +interface ILinkCellProps { + title?: string; + to?: string; + onClick?: () => void; + subtitle?: string; +} + +export const LinkCell: FC = ({ + title, + to, + onClick, + subtitle, + children, +}) => { + const { classes: styles } = useStyles(); + const { searchQuery } = useSearchHighlightContext(); + + const content = ( +
    + + {title} + {children} + + + + + {subtitle} + + + + } + /> +
    + ); + + return to ? ( + + {content} + + ) : onClick ? ( + + {content} + + ) : ( + {content} + ); +}; diff --git a/frontend/src/component/common/Table/cells/TextCell/TextCell.styles.ts b/frontend/src/component/common/Table/cells/TextCell/TextCell.styles.ts new file mode 100644 index 0000000000..e052a9b64f --- /dev/null +++ b/frontend/src/component/common/Table/cells/TextCell/TextCell.styles.ts @@ -0,0 +1,14 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles<{ lineClamp?: number }>()( + (theme, { lineClamp }) => ({ + wrapper: { + padding: theme.spacing(1, 2), + display: '-webkit-box', + overflow: lineClamp ? 'hidden' : 'auto', + WebkitLineClamp: lineClamp ? lineClamp : 'none', + WebkitBoxOrient: 'vertical', + wordBreak: 'break-all', + }, + }) +); diff --git a/frontend/src/component/common/Table/cells/TextCell/TextCell.tsx b/frontend/src/component/common/Table/cells/TextCell/TextCell.tsx new file mode 100644 index 0000000000..10335a76de --- /dev/null +++ b/frontend/src/component/common/Table/cells/TextCell/TextCell.tsx @@ -0,0 +1,26 @@ +import { FC } from 'react'; +import { Box } from '@mui/material'; +import { useStyles } from './TextCell.styles'; + +interface ITextCellProps { + value?: string | null; + lineClamp?: number; + 'data-testid'?: string; +} + +export const TextCell: FC = ({ + value, + children, + lineClamp, + 'data-testid': testid, +}) => { + const { classes } = useStyles({ lineClamp }); + + return ( + + + {children ?? value} + + + ); +}; diff --git a/frontend/src/component/common/Table/cells/TimeAgoCell/TimeAgoCell.tsx b/frontend/src/component/common/Table/cells/TimeAgoCell/TimeAgoCell.tsx new file mode 100644 index 0000000000..484974e42d --- /dev/null +++ b/frontend/src/component/common/Table/cells/TimeAgoCell/TimeAgoCell.tsx @@ -0,0 +1,38 @@ +import { Tooltip, Typography } from '@mui/material'; +import { useLocationSettings } from 'hooks/useLocationSettings'; +import { VFC } from 'react'; +import { formatDateYMD } from 'utils/formatDate'; +import { TextCell } from '../TextCell/TextCell'; +import TimeAgo from 'react-timeago'; + +interface ITimeAgoCellProps { + value?: string | number | Date; + live?: boolean; + emptyText?: string; +} + +export const TimeAgoCell: VFC = ({ + value, + live = false, + emptyText, +}) => { + const { locationSettings } = useLocationSettings(); + + if (!value) return {emptyText}; + + return ( + + + + + + + + ); +}; diff --git a/frontend/src/component/common/Table/index.ts b/frontend/src/component/common/Table/index.ts new file mode 100644 index 0000000000..3bab70111a --- /dev/null +++ b/frontend/src/component/common/Table/index.ts @@ -0,0 +1,6 @@ +export { SortableTableHeader } from './SortableTableHeader/SortableTableHeader'; +export { TableBody, TableRow } from '@mui/material'; +export { Table } from './Table/Table'; +export { TableCell } from './TableCell/TableCell'; +export { TablePlaceholder } from './TablePlaceholder/TablePlaceholder'; +export { VirtualizedTable } from './VirtualizedTable/VirtualizedTable'; diff --git a/frontend/src/component/common/TagSelect/TagSelect.tsx b/frontend/src/component/common/TagSelect/TagSelect.tsx new file mode 100644 index 0000000000..13260d3c22 --- /dev/null +++ b/frontend/src/component/common/TagSelect/TagSelect.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import GeneralSelect, { + IGeneralSelectProps, +} from '../GeneralSelect/GeneralSelect'; +import useTagTypes from 'hooks/api/getters/useTagTypes/useTagTypes'; + +interface ITagSelect { + name: string; + value: string; + onChange: IGeneralSelectProps['onChange']; + autoFocus?: boolean; +} + +const TagSelect = ({ value, onChange, ...rest }: ITagSelect) => { + const { tagTypes } = useTagTypes(); + + const options = tagTypes.map(tagType => ({ + key: tagType.name, + label: tagType.name, + title: tagType.name, + })); + + return ( + <> + + + ); +}; + +export default TagSelect; diff --git a/frontend/src/component/common/ThemeMode/ThemeMode.tsx b/frontend/src/component/common/ThemeMode/ThemeMode.tsx new file mode 100644 index 0000000000..67a6d3e582 --- /dev/null +++ b/frontend/src/component/common/ThemeMode/ThemeMode.tsx @@ -0,0 +1,20 @@ +import UIContext from 'contexts/UIContext'; +import { useContext } from 'react'; +import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender'; + +interface IThemeModeProps { + darkmode: JSX.Element; + lightmode: JSX.Element; +} + +export const ThemeMode = ({ darkmode, lightmode }: IThemeModeProps) => { + const { themeMode } = useContext(UIContext); + + return ( + + ); +}; diff --git a/frontend/src/component/common/ToastRenderer/Toast/Toast.styles.ts b/frontend/src/component/common/ToastRenderer/Toast/Toast.styles.ts new file mode 100644 index 0000000000..4b3aa28151 --- /dev/null +++ b/frontend/src/component/common/ToastRenderer/Toast/Toast.styles.ts @@ -0,0 +1,67 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + maxWidth: '450px', + background: theme.palette.background.paper, + boxShadow: '2px 2px 4px rgba(0,0,0,0.4)', + zIndex: 500, + margin: '0 0.8rem', + borderRadius: '12.5px', + padding: '2rem', + }, + innerContainer: { + position: 'relative', + }, + starting: { + opacity: 0, + }, + headerContainer: { + display: 'flex', + alignItems: 'center', + }, + confettiContainer: { + position: 'relative', + maxWidth: '600px', + margin: '0 auto', + display: 'flex', + }, + textContainer: { + marginLeft: '1rem', + wordBreak: 'break-word', + }, + headerStyles: { + fontWeight: 'normal', + margin: 0, + marginBottom: '0.5rem', + }, + createdContainer: { + display: 'flex', + alignItems: 'center', + flexDirection: 'column', + }, + anim: { + animation: `$drop 10s 3s`, + }, + checkMark: { + width: '65px', + height: '65px', + }, + buttonStyle: { + position: 'absolute', + top: '-33px', + right: '-33px', + }, + '@keyframes drop': { + '0%': { + opacity: '0%', + top: '0%', + }, + '10%': { + opacity: '100%', + }, + '100%': { + top: '100%', + }, + }, +})); diff --git a/frontend/src/component/common/ToastRenderer/Toast/Toast.tsx b/frontend/src/component/common/ToastRenderer/Toast/Toast.tsx new file mode 100644 index 0000000000..9d5bc5bb30 --- /dev/null +++ b/frontend/src/component/common/ToastRenderer/Toast/Toast.tsx @@ -0,0 +1,98 @@ +import { useStyles } from './Toast.styles'; +import classnames from 'classnames'; +import { useContext } from 'react'; +import { IconButton, Tooltip } from '@mui/material'; +import CheckMarkBadge from 'component/common/CheckmarkBadge/CheckMarkBadge'; +import UIContext from 'contexts/UIContext'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import Close from '@mui/icons-material/Close'; +import { IToast } from 'interfaces/toast'; +import { TOAST_TEXT } from 'utils/testIds'; + +const Toast = ({ title, text, type, confetti }: IToast) => { + const { setToast } = useContext(UIContext); + + const { classes: styles } = useStyles(); + const confettiColors = ['#d13447', '#ffbf00', '#263672']; + const confettiAmount = 200; + + const getRandomNumber = (input: number) => { + return Math.floor(Math.random() * input) + 1; + }; + + const renderConfetti = () => { + const elements = new Array(confettiAmount).fill(1); + + const styledElements = elements.map((el, index) => { + const width = getRandomNumber(8); + const length = getRandomNumber(100); + + const style = { + position: 'absolute' as 'absolute', + width: `${width}px`, + height: `${width * 0.4}px`, + backgroundColor: confettiColors[getRandomNumber(2)], + left: `${length}%`, + transform: `rotate(${getRandomNumber(101)}deg)`, + animationDelay: `${getRandomNumber(5)}s`, + animationDuration: `${getRandomNumber(3)}s`, + animationEase: `${getRandomNumber(2)}s`, + }; + + return ( +
    + ); + }); + + return styledElements; + }; + + const hide = () => { + setToast((prev: IToast) => ({ ...prev, show: false })); + }; + + return ( +
    +
    +
    + {confetti && renderConfetti()} +
    +
    +
    + +
    +
    +

    {title}

    + + {text}

    + } + /> +
    +
    + + + + + +
    +
    +
    +
    + ); +}; + +export default Toast; diff --git a/frontend/src/component/common/ToastRenderer/ToastRenderer.styles.ts b/frontend/src/component/common/ToastRenderer/ToastRenderer.styles.ts new file mode 100644 index 0000000000..6e3bf1e15f --- /dev/null +++ b/frontend/src/component/common/ToastRenderer/ToastRenderer.styles.ts @@ -0,0 +1,10 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + toastWrapper: { + right: 0, + left: 0, + margin: '0 auto', + maxWidth: '450px', + }, +})); diff --git a/frontend/src/component/common/ToastRenderer/ToastRenderer.tsx b/frontend/src/component/common/ToastRenderer/ToastRenderer.tsx new file mode 100644 index 0000000000..345ffc6b0f --- /dev/null +++ b/frontend/src/component/common/ToastRenderer/ToastRenderer.tsx @@ -0,0 +1,46 @@ +import { Portal } from '@mui/material'; +import { useContext, useEffect } from 'react'; +import { useThemeStyles } from 'themes/themeStyles'; +import UIContext from 'contexts/UIContext'; +import { useStyles } from './ToastRenderer.styles'; +import AnimateOnMount from '../AnimateOnMount/AnimateOnMount'; +import Toast from './Toast/Toast'; +import { IToast } from 'interfaces/toast'; + +const ToastRenderer = () => { + const { toastData, setToast } = useContext(UIContext); + const { classes: themeStyles } = useThemeStyles(); + const { classes: styles } = useStyles(); + + const hide = () => { + setToast((prev: IToast) => ({ ...prev, show: false })); + }; + + useEffect(() => { + if (!toastData.autoHideDuration) return; + let timeout = setTimeout(() => { + hide(); + }, toastData.autoHideDuration); + + return () => { + clearTimeout(timeout); + }; + /* eslint-disable-next-line */ + }, [toastData?.show]); + + return ( + + + + + + ); +}; + +export default ToastRenderer; diff --git a/frontend/src/component/common/TooltipResolver/TooltipResolver.tsx b/frontend/src/component/common/TooltipResolver/TooltipResolver.tsx new file mode 100644 index 0000000000..237bb45ee5 --- /dev/null +++ b/frontend/src/component/common/TooltipResolver/TooltipResolver.tsx @@ -0,0 +1,21 @@ +import { Tooltip, TooltipProps } from '@mui/material'; + +export interface ITooltipResolverProps extends Omit { + title: string | undefined; +} + +export const TooltipResolver = ({ + title, + children, + ...rest +}: ITooltipResolverProps) => { + if (!title) { + return children; + } + + return ( + + {children} + + ); +}; diff --git a/frontend/src/component/common/UpdateButton/UpdateButton.tsx b/frontend/src/component/common/UpdateButton/UpdateButton.tsx new file mode 100644 index 0000000000..d0e01f5c27 --- /dev/null +++ b/frontend/src/component/common/UpdateButton/UpdateButton.tsx @@ -0,0 +1,11 @@ +import PermissionButton, { + IPermissionButtonProps, +} from '../PermissionButton/PermissionButton'; + +export const UpdateButton = ({ ...rest }: IPermissionButtonProps) => { + return ( + + Save + + ); +}; diff --git a/frontend/src/component/common/UserAvatar/UserAvatar.tsx b/frontend/src/component/common/UserAvatar/UserAvatar.tsx new file mode 100644 index 0000000000..08150a331c --- /dev/null +++ b/frontend/src/component/common/UserAvatar/UserAvatar.tsx @@ -0,0 +1,78 @@ +import { Avatar, AvatarProps, styled, SxProps, Theme } from '@mui/material'; +import { IUser } from 'interfaces/user'; +import { FC } from 'react'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; + +const StyledAvatar = styled(Avatar)(({ theme }) => ({ + width: theme.spacing(3.5), + height: theme.spacing(3.5), + margin: 'auto', + backgroundColor: theme.palette.secondary.light, + color: theme.palette.text.primary, + fontSize: theme.fontSizes.smallerBody, + fontWeight: theme.fontWeight.bold, +})); + +interface IUserAvatarProps extends AvatarProps { + user?: IUser; + src?: string; + title?: string; + onMouseEnter?: (event: any) => void; + onMouseLeave?: () => void; + className?: string; + sx?: SxProps; +} + +export const UserAvatar: FC = ({ + user, + src, + title, + onMouseEnter, + onMouseLeave, + className, + sx, + children, + ...props +}) => { + if (!title && !onMouseEnter && user) { + title = `${user?.name || user?.email || user?.username} (id: ${ + user?.id + })`; + } + + if (!src && user) { + src = user?.imageUrl; + } + + let fallback; + if (!children && user) { + fallback = user?.name || user?.email || user?.username; + if (fallback && fallback.includes(' ')) { + fallback = `${fallback.split(' ')[0][0]}${ + fallback.split(' ')[1][0] + }`.toUpperCase(); + } else if (fallback) { + fallback = fallback[0].toUpperCase(); + } + } + + return ( + + + + ); +}; diff --git a/frontend/src/component/common/common.module.scss b/frontend/src/component/common/common.module.scss new file mode 100644 index 0000000000..24c955c562 --- /dev/null +++ b/frontend/src/component/common/common.module.scss @@ -0,0 +1,132 @@ +/** Select **/ + +.truncate { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.fullwidth { + width: 100%; +} + +.sectionPadding { + padding: 0 16px; +} + +.horisontalScroll { + overflow-x: scroll; + -webkit-overflow-scrolling: touch; +} + +.horisontalScroll::-webkit-scrollbar { + display: none; +} + +.listLink { + color: #212121; + text-decoration: none; + font-weight: normal; + display: block; +} + +.listLink:hover { + color: #000; +} + +@media (max-width: 920px) { + .hideLt920 { + display: none; + } +} + +@media (max-width: 600px) { + .hideLt600 { + display: none; + } +} + +.dataTableHeader { + display: flex; + justify-content: space-between; + align-items: center; + + .titleText { + margin: 0; + font-size: 20px; + line-height: 24px; + } + + .actions { + flex-shrink: 0; + } +} + +.switchWithLabel { + display: flex; + + .label { + padding-right: 16px; + line-height: 24px; + } + .switch { + display: inline-block; + padding-right: 8px; + } +} + +.emptyState { + display: flex; + align-items: center; + flex-direction: column; + justify-content: center; + min-height: 200px; +} + +.toggleName { + color: #37474f !important; + font-weight: 700; +} + +.toolbar { + position: relative; + padding: 0 24px 16px 24px; + text-align: center; +} + +.toolbarButton { + position: absolute; + top: 56px; + right: 24px; + z-index: 2; +} + +.error { + color: #d50000; +} + +.headerTitle { + font-size: var(--h1-size); + margin: 0; +} + +.listItem { + padding: 0; +} + +.section { + padding: 8px 16px 8px 16px; +} + +.contentPadding { + padding: var(--card-padding); +} + +.contentSpacing > * { + margin: 0.5rem 0; +} + +.searchField { + margin-bottom: 2rem; + max-width: 400px; +} diff --git a/frontend/src/component/common/flags.ts b/frontend/src/component/common/flags.ts new file mode 100644 index 0000000000..d11ef76c12 --- /dev/null +++ b/frontend/src/component/common/flags.ts @@ -0,0 +1,7 @@ +export const P = 'P'; +export const C = 'C'; +export const E = 'E'; +export const EEA = 'EEA'; +export const RE = 'RE'; +export const SE = 'SE'; +export const UG = 'UG'; diff --git a/frontend/src/component/common/index.jsx b/frontend/src/component/common/index.jsx new file mode 100644 index 0000000000..900a0231de --- /dev/null +++ b/frontend/src/component/common/index.jsx @@ -0,0 +1,168 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; +import { + List, + MenuItem, + Icon, + ListItem, + ListItemText, + ListItemAvatar, + Button, + Avatar, + Typography, +} from '@mui/material'; +import { Apps } from '@mui/icons-material'; + +import styles from './common.module.scss'; +import { ConditionallyRender } from './ConditionallyRender/ConditionallyRender'; + +export { styles }; + +export const AppsLinkList = ({ apps }) => ( + + 0} + show={apps.map(({ appName, description, icon }) => ( + + + + {icon}} + elseShow={} + /> + + + + {appName} + + } + secondary={description || 'No description'} + /> + + ))} + /> + +); +AppsLinkList.propTypes = { + apps: PropTypes.array.isRequired, +}; + +export const DataTableHeader = ({ title, actions }) => ( +
    +
    + + {title} + +
    + {actions &&
    {actions}
    } +
    +); +DataTableHeader.propTypes = { + title: PropTypes.string, + actions: PropTypes.any, +}; + +export const FormButtons = ({ + submitText = 'Create', + onCancel, + primaryButtonTestId, +}) => ( +
    + +   + +
    +); +FormButtons.propTypes = { + submitText: PropTypes.string, + onCancel: PropTypes.func.isRequired, + primaryButtonTestId: PropTypes.string, +}; + +export const IconLink = ({ url, icon: IconComponent }) => ( + + + +); +IconLink.propTypes = { + url: PropTypes.string, + icon: PropTypes.object, +}; + +export const MenuItemWithIcon = React.forwardRef( + ({ icon: IconComponent, label, disabled, ...menuItemProps }, ref) => ( + + + {label} + + ) +); +MenuItemWithIcon.propTypes = { + icon: PropTypes.object, + label: PropTypes.string, + disabled: PropTypes.bool, +}; + +const badNumbers = [NaN, Infinity, -Infinity]; +export function calc(value, total, decimal) { + if ( + typeof value !== 'number' || + typeof total !== 'number' || + typeof decimal !== 'number' + ) { + return null; + } + + if (total === 0) { + return 0; + } + + badNumbers.forEach(number => { + if ([value, total, decimal].indexOf(number) > -1) { + return number; + } + }); + + return ((value / total) * 100).toFixed(decimal); +} + +export const selectStyles = { + control: provided => ({ + ...provided, + border: '1px solid #607d8b', + boxShadow: '0', + ':hover': { + borderColor: '#607d8b', + boxShadow: '0 0 0 1px #607d8b', + }, + }), +}; diff --git a/frontend/src/component/common/select.tsx b/frontend/src/component/common/select.tsx new file mode 100644 index 0000000000..053ad4fdc6 --- /dev/null +++ b/frontend/src/component/common/select.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { + FormControl, + InputLabel, + MenuItem, + Select, + SelectChangeEvent, +} from '@mui/material'; +import { SELECT_ITEM_ID } from 'utils/testIds'; + +export interface ISelectOption { + key: string; + title?: string; + label?: string; +} +export interface ISelectMenuProps { + name: string; + id: string; + value?: string; + label?: string; + options: ISelectOption[]; + style?: object; + onChange?: (event: SelectChangeEvent, child: React.ReactNode) => void; + disabled?: boolean; + className?: string; + classes?: any; +} + +const SelectMenu: React.FC = ({ + name, + value = '', + label = '', + options, + onChange, + id, + disabled = false, + className, + classes, + ...rest +}) => { + const renderSelectItems = () => + options.map(option => ( + + {option.label} + + )); + + return ( + + {label} + + + ); +}; + +export default SelectMenu; diff --git a/frontend/src/component/common/util.ts b/frontend/src/component/common/util.ts new file mode 100644 index 0000000000..5dd05b01fc --- /dev/null +++ b/frontend/src/component/common/util.ts @@ -0,0 +1,112 @@ +import { weightTypes } from '../feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/enums'; +import { IUiConfig } from 'interfaces/uiConfig'; +import { IRoute } from 'interfaces/route'; +import { IFeatureVariant } from 'interfaces/featureToggle'; +import { format, isValid } from 'date-fns'; + +export const filterByConfig = (config: IUiConfig) => (r: IRoute) => { + if (r.flag) { + // Check if the route's `flag` is enabled in IUiConfig.flags. + const flags = config.flags as unknown as Record; + return Boolean(flags[r.flag]); + } + + if (r.configFlag) { + // Check if the route's `configFlag` is enabled in IUiConfig. + return Boolean(config[r.configFlag]); + } + + return true; +}; + +export const scrollToTop = () => { + window.scrollTo(0, 0); +}; + +export const trim = (value: string): string => { + if (value && value.trim) { + return value.trim(); + } else { + return value; + } +}; + +export const parseDateValue = (value: string) => { + const date = new Date(value); + return format(date, 'yyyy-MM-dd') + 'T' + format(date, 'HH:mm'); +}; + +export const parseValidDate = (value: string): Date | undefined => { + const parsed = new Date(value); + + if (isValid(parsed)) { + return parsed; + } +}; + +export const calculateVariantWeight = (weight: number) => { + return weight / 10.0; +}; + +export function updateWeight(variants: IFeatureVariant[], totalWeight: number) { + if (variants.length === 0) { + return []; + } + const variantMetadata = variants.reduce( + ({ remainingPercentage, variableVariantCount }, variant) => { + if (variant.weight && variant.weightType === weightTypes.FIX) { + remainingPercentage -= Number(variant.weight); + } else { + variableVariantCount += 1; + } + return { + remainingPercentage, + variableVariantCount, + }; + }, + { remainingPercentage: totalWeight, variableVariantCount: 0 } + ); + + const { remainingPercentage, variableVariantCount } = variantMetadata; + + if (remainingPercentage < 0) { + throw new Error('The traffic distribution total must equal 100%'); + } + + if (!variableVariantCount) { + throw new Error('There must be at least one variable variant'); + } + + const percentage = parseInt( + String(remainingPercentage / variableVariantCount) + ); + + return variants.map(variant => { + if (variant.weightType !== weightTypes.FIX) { + variant.weight = percentage; + } + return variant; + }); +} + +export const modalStyles = { + overlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.25)', + zIndex: 5, + }, + content: { + width: '500px', + maxWidth: '90%', + margin: '0', + top: '50%', + left: '50%', + right: 'auto', + bottom: 'auto', + transform: 'translate(-50%, -50%)', + }, +}; diff --git a/frontend/src/component/context/ContectFormChip/ContextFormChip.styles.ts b/frontend/src/component/context/ContectFormChip/ContextFormChip.styles.ts new file mode 100644 index 0000000000..5781a55deb --- /dev/null +++ b/frontend/src/component/context/ContectFormChip/ContextFormChip.styles.ts @@ -0,0 +1,37 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + display: 'grid', + lineHeight: 1.25, + gridTemplateColumns: '1fr auto', + alignSelf: 'start', + alignItems: 'start', + gap: '0.5rem', + padding: '0.5rem', + background: theme.palette.grey[200], + borderRadius: theme.shape.borderRadius, + wordBreak: 'break-word', + }, + label: { + fontSize: theme.fontSizes.smallBody, + }, + description: { + fontSize: theme.fontSizes.smallerBody, + color: theme.palette.grey[700], + }, + button: { + all: 'unset', + lineHeight: 0.1, + paddingTop: 1, + display: 'block', + cursor: 'pointer', + '& svg': { + fontSize: '1rem', + opacity: 0.5, + }, + '&:hover svg, &:focus-visible svg': { + opacity: 0.75, + }, + }, +})); diff --git a/frontend/src/component/context/ContectFormChip/ContextFormChip.tsx b/frontend/src/component/context/ContectFormChip/ContextFormChip.tsx new file mode 100644 index 0000000000..778e294cb0 --- /dev/null +++ b/frontend/src/component/context/ContectFormChip/ContextFormChip.tsx @@ -0,0 +1,34 @@ +import { useStyles } from 'component/context/ContectFormChip/ContextFormChip.styles'; +import { Cancel } from '@mui/icons-material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; + +interface IContextFormChipProps { + label: string; + description?: string; + onRemove: () => void; +} + +export const ContextFormChip = ({ + label, + description, + onRemove, +}: IContextFormChipProps) => { + const { classes: styles } = useStyles(); + + return ( +
  • +
    +
    {label}
    + ( +
    {description}
    + )} + /> +
    + +
  • + ); +}; diff --git a/frontend/src/component/context/ContectFormChip/ContextFormChipList.styles.ts b/frontend/src/component/context/ContectFormChip/ContextFormChipList.styles.ts new file mode 100644 index 0000000000..00ca3b6eb7 --- /dev/null +++ b/frontend/src/component/context/ContectFormChip/ContextFormChipList.styles.ts @@ -0,0 +1,13 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + listStyleType: 'none', + display: 'flex', + flexWrap: 'wrap', + gap: '0.5rem', + padding: 0, + margin: 0, + marginBottom: '1rem !important', + }, +})); diff --git a/frontend/src/component/context/ContectFormChip/ContextFormChipList.tsx b/frontend/src/component/context/ContectFormChip/ContextFormChipList.tsx new file mode 100644 index 0000000000..639a5f45ae --- /dev/null +++ b/frontend/src/component/context/ContectFormChip/ContextFormChipList.tsx @@ -0,0 +1,8 @@ +import { useStyles } from 'component/context/ContectFormChip/ContextFormChipList.styles'; +import React from 'react'; + +export const ContextFormChipList: React.FC = ({ children }) => { + const { classes: styles } = useStyles(); + + return
      {children}
    ; +}; diff --git a/frontend/src/component/context/ContextForm/ContextForm.styles.ts b/frontend/src/component/context/ContextForm/ContextForm.styles.ts new file mode 100644 index 0000000000..c731b59f8b --- /dev/null +++ b/frontend/src/component/context/ContextForm/ContextForm.styles.ts @@ -0,0 +1,59 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + maxWidth: '470px', + }, + form: { + display: 'flex', + flexDirection: 'column', + height: '100%', + }, + input: { width: '100%', marginBottom: '1rem' }, + inputHeader: { + marginBottom: '0.3rem', + }, + label: { + minWidth: '300px', + [theme.breakpoints.down(600)]: { + minWidth: 'auto', + }, + }, + tagContainer: { + display: 'grid', + gridTemplateColumns: '1fr auto', + gap: '0.5rem', + marginBottom: '1rem', + }, + tagInput: { + gridColumn: 1, + }, + tagButton: { + gridColumn: 2, + }, + buttonContainer: { + marginTop: 'auto', + display: 'flex', + justifyContent: 'flex-end', + }, + cancelButton: { + marginLeft: '1.5rem', + }, + inputDescription: { + marginBottom: '0.5rem', + }, + permissionErrorContainer: { + position: 'relative', + }, + errorMessage: { + fontSize: theme.fontSizes.smallBody, + color: theme.palette.error.main, + position: 'absolute', + top: '-8px', + }, + switchContainer: { + display: 'flex', + alignItems: 'center', + marginLeft: '-9px', + }, +})); diff --git a/frontend/src/component/context/ContextForm/ContextForm.tsx b/frontend/src/component/context/ContextForm/ContextForm.tsx new file mode 100644 index 0000000000..d489028f99 --- /dev/null +++ b/frontend/src/component/context/ContextForm/ContextForm.tsx @@ -0,0 +1,223 @@ +import Input from 'component/common/Input/Input'; +import { TextField, Button, Switch, Typography } from '@mui/material'; +import { useStyles } from './ContextForm.styles'; +import React, { useState, useEffect } from 'react'; +import { Add } from '@mui/icons-material'; +import { ILegalValue } from 'interfaces/context'; +import { ContextFormChip } from 'component/context/ContectFormChip/ContextFormChip'; +import { ContextFormChipList } from 'component/context/ContectFormChip/ContextFormChipList'; + +interface IContextForm { + contextName: string; + contextDesc: string; + legalValues: ILegalValue[]; + stickiness: boolean; + setContextName: React.Dispatch>; + setContextDesc: React.Dispatch>; + setStickiness: React.Dispatch>; + setLegalValues: React.Dispatch>; + handleSubmit: (e: any) => void; + onCancel: () => void; + errors: { [key: string]: string }; + mode: 'Create' | 'Edit'; + clearErrors: (key?: string) => void; + validateContext?: () => void; + setErrors: React.Dispatch>; +} + +const ENTER = 'Enter'; + +export const ContextForm: React.FC = ({ + children, + handleSubmit, + onCancel, + contextName, + contextDesc, + legalValues, + stickiness, + setContextName, + setContextDesc, + setLegalValues, + setStickiness, + errors, + mode, + validateContext, + setErrors, + clearErrors, +}) => { + const { classes: styles } = useStyles(); + const [value, setValue] = useState(''); + const [valueDesc, setValueDesc] = useState(''); + const [valueFocused, setValueFocused] = useState(false); + + const isMissingValue = valueDesc.trim() && !value.trim(); + + const isDuplicateValue = legalValues.some(legalValue => { + return legalValue.value.trim() === value.trim(); + }); + + useEffect(() => { + setErrors(prev => ({ + ...prev, + tag: isMissingValue + ? 'Value cannot be empty' + : isDuplicateValue + ? 'Duplicate value' + : undefined, + })); + }, [setErrors, isMissingValue, isDuplicateValue]); + + const onSubmit = (event: React.SyntheticEvent) => { + event.preventDefault(); + handleSubmit(event); + }; + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === ENTER) { + event.preventDefault(); + if (valueFocused) { + addLegalValue(); + } else { + handleSubmit(event); + } + } + }; + + const sortLegalValues = (a: ILegalValue, b: ILegalValue) => { + return a.value.toLowerCase().localeCompare(b.value.toLowerCase()); + }; + + const addLegalValue = () => { + const next: ILegalValue = { + value: value.trim(), + description: valueDesc.trim(), + }; + if (next.value && !isDuplicateValue) { + setValue(''); + setValueDesc(''); + setLegalValues(prev => [...prev, next].sort(sortLegalValues)); + } + }; + + const removeLegalValue = (value: ILegalValue) => { + setLegalValues(prev => prev.filter(p => p.value !== value.value)); + }; + + return ( +
    +
    +

    + What is your context name? +

    + setContextName(e.target.value.trim())} + error={Boolean(errors.name)} + errorText={errors.name} + onFocus={() => clearErrors('name')} + onBlur={validateContext} + autoFocus + /> +

    + What is this context for? +

    + setContextDesc(e.target.value)} + /> +

    + Which values do you want to allow? +

    +
    + setValue(e.target.value)} + onKeyPress={e => onKeyDown(e)} + onBlur={() => setValueFocused(false)} + onFocus={() => setValueFocused(true)} + inputProps={{ maxLength: 100 }} + /> + setValueDesc(e.target.value)} + onKeyPress={e => onKeyDown(e)} + onBlur={() => setValueFocused(false)} + onFocus={() => setValueFocused(true)} + inputProps={{ maxLength: 100 }} + /> + +
    + + {legalValues.map(legalValue => { + return ( + removeLegalValue(legalValue)} + /> + ); + })} + +

    Custom stickiness

    +

    + By enabling stickiness on this context field you can use it + together with the flexible-rollout strategy. This will + guarantee a consistent behavior for specific values of this + context field. PS! Not all client SDK's support this feature + yet!{' '} + + Read more + +

    +
    + setStickiness(!stickiness)} + /> + {stickiness ? 'On' : 'Off'} +
    +
    +
    + {children} + +
    +
    + ); +}; diff --git a/frontend/src/component/context/ContextList/AddContextButton/AddContextButton.tsx b/frontend/src/component/context/ContextList/AddContextButton/AddContextButton.tsx new file mode 100644 index 0000000000..721c6b8a78 --- /dev/null +++ b/frontend/src/component/context/ContextList/AddContextButton/AddContextButton.tsx @@ -0,0 +1,41 @@ +import { VFC } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useMediaQuery } from '@mui/material'; +import { Add } from '@mui/icons-material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { CREATE_CONTEXT_FIELD } from 'component/providers/AccessProvider/permissions'; +import PermissionButton from 'component/common/PermissionButton/PermissionButton'; +import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; + +interface IAddContextButtonProps {} + +export const AddContextButton: VFC = () => { + const smallScreen = useMediaQuery('(max-width:700px)'); + const navigate = useNavigate(); + + return ( + navigate('/context/create')} + size="large" + tooltipProps={{ title: 'Add context type' }} + > + + + } + elseShow={ + navigate('/context/create')} + permission={CREATE_CONTEXT_FIELD} + color="primary" + variant="contained" + > + New context field + + } + /> + ); +}; diff --git a/frontend/src/component/context/ContextList/ContextActionsCell/ContextActionsCell.tsx b/frontend/src/component/context/ContextList/ContextActionsCell/ContextActionsCell.tsx new file mode 100644 index 0000000000..d41e0319ae --- /dev/null +++ b/frontend/src/component/context/ContextList/ContextActionsCell/ContextActionsCell.tsx @@ -0,0 +1,48 @@ +import { VFC } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Delete, Edit } from '@mui/icons-material'; +import { + DELETE_CONTEXT_FIELD, + UPDATE_CONTEXT_FIELD, +} from 'component/providers/AccessProvider/permissions'; +import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; +import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell'; + +interface IContextActionsCellProps { + name: string; + onDelete: () => void; +} + +export const ContextActionsCell: VFC = ({ + name, + onDelete, +}) => { + const navigate = useNavigate(); + + return ( + + navigate(`/context/edit/${name}`)} + data-loading + aria-label="edit" + tooltipProps={{ + title: 'Edit context field', + }} + > + + + + + + + ); +}; diff --git a/frontend/src/component/context/ContextList/ContextList.tsx b/frontend/src/component/context/ContextList/ContextList.tsx new file mode 100644 index 0000000000..7a263e6b21 --- /dev/null +++ b/frontend/src/component/context/ContextList/ContextList.tsx @@ -0,0 +1,231 @@ +import { useMemo, useState, VFC } from 'react'; +import { useGlobalFilter, useSortBy, useTable } from 'react-table'; +import { + Table, + SortableTableHeader, + TableBody, + TableCell, + TableRow, + TablePlaceholder, +} from 'component/common/Table'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { Dialogue as ConfirmDialogue } from 'component/common/Dialogue/Dialogue'; +import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext'; +import useContextsApi from 'hooks/api/actions/useContextsApi/useContextsApi'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { AddContextButton } from './AddContextButton/AddContextButton'; +import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { sortTypes } from 'utils/sortTypes'; +import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell'; +import { ContextActionsCell } from './ContextActionsCell/ContextActionsCell'; +import { Adjust } from '@mui/icons-material'; +import { IconCell } from 'component/common/Table/cells/IconCell/IconCell'; +import { Search } from 'component/common/Search/Search'; + +const ContextList: VFC = () => { + const [showDelDialogue, setShowDelDialogue] = useState(false); + const [name, setName] = useState(); + const { context, refetchUnleashContext, loading } = useUnleashContext(); + const { removeContext } = useContextsApi(); + const { setToastData, setToastApiError } = useToast(); + + const data = useMemo(() => { + if (loading) { + return Array(5).fill({ + name: 'Context name', + description: 'Context description when loading', + }); + } + + return context + .map(({ name, description, sortOrder }) => ({ + name, + description, + sortOrder, + })) + .sort((a, b) => a.sortOrder - b.sortOrder); + }, [context, loading]); + + const columns = useMemo( + () => [ + { + id: 'Icon', + Cell: () => } />, + disableGlobalFilter: true, + }, + { + Header: 'Name', + accessor: 'name', + width: '90%', + Cell: ({ + row: { + original: { name, description }, + }, + }: any) => ( + + ), + sortType: 'alphanumeric', + }, + { + Header: 'Actions', + id: 'Actions', + align: 'center', + Cell: ({ + row: { + original: { name }, + }, + }: any) => ( + { + setName(name); + setShowDelDialogue(true); + }} + /> + ), + width: 150, + disableGlobalFilter: true, + disableSortBy: true, + }, + { + accessor: 'description', + disableSortBy: true, + }, + { + accessor: 'sortOrder', + disableGlobalFilter: true, + sortType: 'number', + }, + ], + [] + ); + + const initialState = useMemo( + () => ({ + sortBy: [{ id: 'name', desc: false }], + hiddenColumns: ['description', 'sortOrder'], + }), + [] + ); + + const onDeleteContext = async () => { + try { + if (name === undefined) { + throw new Error(); + } + await removeContext(name); + refetchUnleashContext(); + setToastData({ + type: 'success', + title: 'Successfully deleted context', + text: 'Your context is now deleted', + }); + } catch (error) { + setToastApiError(formatUnknownError(error)); + } + setName(undefined); + setShowDelDialogue(false); + }; + + const { + getTableProps, + getTableBodyProps, + headerGroups, + rows, + prepareRow, + state: { globalFilter }, + setGlobalFilter, + } = useTable( + { + columns: columns as any[], // TODO: fix after `react-table` v8 update + data, + initialState, + sortTypes, + autoResetGlobalFilter: false, + autoResetSortBy: false, + disableSortRemove: true, + }, + useGlobalFilter, + useSortBy + ); + + return ( + + + + + + } + /> + } + > + + + + + {rows.map(row => { + prepareRow(row); + return ( + + {row.cells.map(cell => ( + + {cell.render('Cell')} + + ))} + + ); + })} + +
    +
    + 0} + show={ + + No contexts found matching “ + {globalFilter} + ” + + } + elseShow={ + + No contexts available. Get started by adding + one. + + } + /> + } + /> + { + setName(undefined); + setShowDelDialogue(false); + }} + title="Really delete context field" + /> +
    + ); +}; + +export default ContextList; diff --git a/frontend/src/component/context/CreateUnleashContext/CreateUnleashContext.tsx b/frontend/src/component/context/CreateUnleashContext/CreateUnleashContext.tsx new file mode 100644 index 0000000000..91df0464f9 --- /dev/null +++ b/frontend/src/component/context/CreateUnleashContext/CreateUnleashContext.tsx @@ -0,0 +1,109 @@ +import { CreateButton } from 'component/common/CreateButton/CreateButton'; +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import { useContextForm } from '../hooks/useContextForm'; +import { ContextForm } from '../ContextForm/ContextForm'; +import { CREATE_CONTEXT_FIELD } from 'component/providers/AccessProvider/permissions'; +import useContextsApi from 'hooks/api/actions/useContextsApi/useContextsApi'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; + +interface ICreateContextProps { + onSubmit: () => void; + onCancel: () => void; + modal?: boolean; +} + +export const CreateUnleashContext = ({ + onSubmit, + onCancel, + modal, +}: ICreateContextProps) => { + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + const { + contextName, + contextDesc, + legalValues, + stickiness, + setContextName, + setContextDesc, + setLegalValues, + setStickiness, + getContextPayload, + validateContext, + clearErrors, + setErrors, + errors, + } = useContextForm(); + const { createContext, loading } = useContextsApi(); + const { refetchUnleashContext } = useUnleashContext(); + + const handleSubmit = async (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + const validName = await validateContext(); + + if (validName) { + const payload = getContextPayload(); + try { + await createContext(payload); + refetchUnleashContext(); + setToastData({ + title: 'Context created', + confetti: true, + type: 'success', + }); + onSubmit(); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + } + }; + + const formatApiCode = () => { + return `curl --location --request POST '${ + uiConfig.unleashUrl + }/api/admin/context' \\ +--header 'Authorization: INSERT_API_KEY' \\ +--header 'Content-Type: application/json' \\ +--data-raw '${JSON.stringify(getContextPayload(), undefined, 2)}'`; + }; + + return ( + + + + + + ); +}; diff --git a/frontend/src/component/context/CreateUnleashContext/CreateUnleashContextPage.tsx b/frontend/src/component/context/CreateUnleashContext/CreateUnleashContextPage.tsx new file mode 100644 index 0000000000..fddaedd78b --- /dev/null +++ b/frontend/src/component/context/CreateUnleashContext/CreateUnleashContextPage.tsx @@ -0,0 +1,14 @@ +import { useNavigate } from 'react-router-dom'; +import { CreateUnleashContext } from 'component/context/CreateUnleashContext/CreateUnleashContext'; +import { GO_BACK } from 'constants/navigate'; + +export const CreateUnleashContextPage = () => { + const navigate = useNavigate(); + + return ( + navigate('/context')} + onCancel={() => navigate(GO_BACK)} + /> + ); +}; diff --git a/frontend/src/component/context/EditContext/EditContext.tsx b/frontend/src/component/context/EditContext/EditContext.tsx new file mode 100644 index 0000000000..fd0b65eb77 --- /dev/null +++ b/frontend/src/component/context/EditContext/EditContext.tsx @@ -0,0 +1,108 @@ +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import { UpdateButton } from 'component/common/UpdateButton/UpdateButton'; +import { UPDATE_CONTEXT_FIELD } from 'component/providers/AccessProvider/permissions'; +import useContextsApi from 'hooks/api/actions/useContextsApi/useContextsApi'; +import useContext from 'hooks/api/getters/useContext/useContext'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import useToast from 'hooks/useToast'; +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { scrollToTop } from 'component/common/util'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { ContextForm } from '../ContextForm/ContextForm'; +import { useContextForm } from '../hooks/useContextForm'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { GO_BACK } from 'constants/navigate'; + +export const EditContext = () => { + useEffect(() => { + scrollToTop(); + }, []); + + const { uiConfig } = useUiConfig(); + const { setToastData, setToastApiError } = useToast(); + const name = useRequiredPathParam('name'); + const { context, refetch } = useContext(name); + const { updateContext, loading } = useContextsApi(); + const navigate = useNavigate(); + const { + contextName, + contextDesc, + legalValues, + stickiness, + setContextName, + setContextDesc, + setLegalValues, + setStickiness, + getContextPayload, + clearErrors, + setErrors, + errors, + } = useContextForm( + context?.name, + context?.description, + context?.legalValues, + context?.stickiness + ); + + const formatApiCode = () => { + return `curl --location --request PUT '${ + uiConfig.unleashUrl + }/api/admin/context/${name}' \\ +--header 'Authorization: INSERT_API_KEY' \\ +--header 'Content-Type: application/json' \\ +--data-raw '${JSON.stringify(getContextPayload(), undefined, 2)}'`; + }; + + const handleSubmit = async (e: Event) => { + e.preventDefault(); + const payload = getContextPayload(); + + try { + await updateContext(payload); + refetch(); + navigate('/context'); + setToastData({ + title: 'Context information updated', + type: 'success', + }); + } catch (e: unknown) { + setToastApiError(formatUnknownError(e)); + } + }; + + const onCancel = () => { + navigate(GO_BACK); + }; + + return ( + + + + + + ); +}; diff --git a/frontend/src/component/context/hooks/useContextForm.ts b/frontend/src/component/context/hooks/useContextForm.ts new file mode 100644 index 0000000000..faa310af2b --- /dev/null +++ b/frontend/src/component/context/hooks/useContextForm.ts @@ -0,0 +1,82 @@ +import { useEffect, useState } from 'react'; +import useContextsApi from 'hooks/api/actions/useContextsApi/useContextsApi'; +import { ILegalValue } from 'interfaces/context'; +import { formatUnknownError } from 'utils/formatUnknownError'; + +export const useContextForm = ( + initialContextName = '', + initialContextDesc = '', + initialLegalValues = [] as ILegalValue[], + initialStickiness = false +) => { + const [contextName, setContextName] = useState(initialContextName); + const [contextDesc, setContextDesc] = useState(initialContextDesc); + const [legalValues, setLegalValues] = useState(initialLegalValues); + const [stickiness, setStickiness] = useState(initialStickiness); + const [errors, setErrors] = useState({}); + const { validateContextName } = useContextsApi(); + + useEffect(() => { + setContextName(initialContextName); + }, [initialContextName]); + + useEffect(() => { + setContextDesc(initialContextDesc); + }, [initialContextDesc]); + + useEffect(() => { + setLegalValues(initialLegalValues); + // eslint-disable-next-line + }, [initialLegalValues.length]); + + useEffect(() => { + setStickiness(initialStickiness); + }, [initialStickiness]); + + const getContextPayload = () => { + return { + name: contextName, + description: contextDesc, + legalValues, + stickiness, + }; + }; + + const validateContext = async () => { + if (contextName.length === 0) { + setErrors(prev => ({ ...prev, name: 'Name can not be empty.' })); + return false; + } + try { + await validateContextName(contextName); + return true; + } catch (error: unknown) { + setErrors(prev => ({ ...prev, name: formatUnknownError(error) })); + return false; + } + }; + + const clearErrors = (key?: string) => { + if (key) { + setErrors(prev => ({ ...prev, [key]: undefined })); + } else { + setErrors({}); + } + }; + + return { + contextName, + contextDesc, + legalValues, + stickiness, + setContextName, + setContextDesc, + setLegalValues, + setStickiness, + getContextPayload, + validateContext, + setErrors, + clearErrors, + errors, + }; +}; diff --git a/frontend/src/component/environments/CreateEnvironment/CreateEnvironment.tsx b/frontend/src/component/environments/CreateEnvironment/CreateEnvironment.tsx new file mode 100644 index 0000000000..31901bcc9b --- /dev/null +++ b/frontend/src/component/environments/CreateEnvironment/CreateEnvironment.tsx @@ -0,0 +1,136 @@ +import { useNavigate } from 'react-router-dom'; +import useEnvironmentForm from '../hooks/useEnvironmentForm'; +import EnvironmentForm from '../EnvironmentForm/EnvironmentForm'; +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import { Alert } from '@mui/material'; +import { Button } from '@mui/material'; +import { CreateButton } from 'component/common/CreateButton/CreateButton'; +import useEnvironmentApi from 'hooks/api/actions/useEnvironmentApi/useEnvironmentApi'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import useToast from 'hooks/useToast'; +import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments'; +import useProjectRolePermissions from 'hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { GO_BACK } from 'constants/navigate'; + +const CreateEnvironment = () => { + const { setToastApiError, setToastData } = useToast(); + const { uiConfig } = useUiConfig(); + const navigate = useNavigate(); + const { environments } = useEnvironments(); + const canCreateMoreEnvs = environments.length < 7; + const { createEnvironment, loading } = useEnvironmentApi(); + const { refetch } = useProjectRolePermissions(); + const { + name, + setName, + type, + setType, + getEnvPayload, + validateEnvironmentName, + clearErrors, + errors, + } = useEnvironmentForm(); + + const handleSubmit = async (e: Event) => { + e.preventDefault(); + clearErrors(); + const validName = await validateEnvironmentName(); + if (validName) { + const payload = getEnvPayload(); + try { + await createEnvironment(payload); + refetch(); + setToastData({ + title: 'Environment created', + type: 'success', + confetti: true, + }); + navigate('/environments'); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + } + }; + + const formatApiCode = () => { + return `curl --location --request POST '${ + uiConfig.unleashUrl + }/api/admin/environments' \\ +--header 'Authorization: INSERT_API_KEY' \\ +--header 'Content-Type: application/json' \\ +--data-raw '${JSON.stringify(getEnvPayload(), undefined, 2)}'`; + }; + + const handleCancel = () => { + navigate(GO_BACK); + }; + + return ( + + + + + + } + elseShow={ + <> + } + > + +

    + Currently Unleash does not support more than 7 + environments. If you need more please reach out. +

    +
    +
    + +
    + + } + /> + ); +}; + +export default CreateEnvironment; diff --git a/frontend/src/component/environments/CreateEnvironmentButton/CreateEnvironmentButton.tsx b/frontend/src/component/environments/CreateEnvironmentButton/CreateEnvironmentButton.tsx new file mode 100644 index 0000000000..c82273033c --- /dev/null +++ b/frontend/src/component/environments/CreateEnvironmentButton/CreateEnvironmentButton.tsx @@ -0,0 +1,22 @@ +import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton'; +import { Add } from '@mui/icons-material'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { useNavigate } from 'react-router-dom'; + +export const CreateEnvironmentButton = () => { + const { uiConfig } = useUiConfig(); + const navigate = useNavigate(); + + return ( + navigate('/environments/create')} + maxWidth="700px" + Icon={Add} + permission={ADMIN} + disabled={!Boolean(uiConfig.flags.EEA)} + > + New environment + + ); +}; diff --git a/frontend/src/component/environments/EditEnvironment/EditEnvironment.tsx b/frontend/src/component/environments/EditEnvironment/EditEnvironment.tsx new file mode 100644 index 0000000000..10ad7f5afd --- /dev/null +++ b/frontend/src/component/environments/EditEnvironment/EditEnvironment.tsx @@ -0,0 +1,97 @@ +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import { UpdateButton } from 'component/common/UpdateButton/UpdateButton'; +import useEnvironmentApi from 'hooks/api/actions/useEnvironmentApi/useEnvironmentApi'; +import useEnvironment from 'hooks/api/getters/useEnvironment/useEnvironment'; +import useProjectRolePermissions from 'hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import useToast from 'hooks/useToast'; +import { useNavigate } from 'react-router-dom'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import EnvironmentForm from '../EnvironmentForm/EnvironmentForm'; +import useEnvironmentForm from '../hooks/useEnvironmentForm'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { GO_BACK } from 'constants/navigate'; + +const EditEnvironment = () => { + const { uiConfig } = useUiConfig(); + const { setToastData, setToastApiError } = useToast(); + const id = useRequiredPathParam('id'); + const { environment } = useEnvironment(id); + const { updateEnvironment } = useEnvironmentApi(); + + const navigate = useNavigate(); + const { name, type, setName, setType, errors, clearErrors } = + useEnvironmentForm(environment.name, environment.type); + const { refetch } = useProjectRolePermissions(); + + const editPayload = () => { + return { + type, + sortOrder: environment.sortOrder, + }; + }; + + const formatApiCode = () => { + return `curl --location --request PUT '${ + uiConfig.unleashUrl + }/api/admin/environments/update/${id}' \\ +--header 'Authorization: INSERT_API_KEY' \\ +--header 'Content-Type: application/json' \\ +--data-raw '${JSON.stringify(editPayload(), undefined, 2)}'`; + }; + + const handleSubmit = async (e: Event) => { + e.preventDefault(); + try { + await updateEnvironment(id, editPayload()); + refetch(); + navigate('/environments'); + setToastData({ + type: 'success', + title: 'Successfully updated environment.', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const handleCancel = () => { + navigate(GO_BACK); + }; + + return ( + + + + + + ); +}; + +export default EditEnvironment; diff --git a/frontend/src/component/environments/EnvironmentCard/EnvironmentCard.styles.ts b/frontend/src/component/environments/EnvironmentCard/EnvironmentCard.styles.ts new file mode 100644 index 0000000000..eeb57d6092 --- /dev/null +++ b/frontend/src/component/environments/EnvironmentCard/EnvironmentCard.styles.ts @@ -0,0 +1,31 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + display: 'flex', + flexDirection: 'column', + border: `1px solid ${theme.palette.grey[200]}`, + padding: '1.5rem', + borderRadius: '5px', + margin: '1.5rem 0', + minWidth: '450px', + }, + icon: { + fill: theme.palette.inactiveIcon, + marginRight: '0.5rem', + }, + header: { + display: 'flex', + alignItems: 'center', + marginBottom: '0.25rem', + }, + infoContainer: { + marginTop: '1rem', + display: 'flex', + justifyContent: 'space-around', + }, + infoInnerContainer: { + textAlign: 'center', + }, + infoTitle: { fontWeight: 'bold', marginBottom: '0.25rem' }, +})); diff --git a/frontend/src/component/environments/EnvironmentCard/EnvironmentCard.tsx b/frontend/src/component/environments/EnvironmentCard/EnvironmentCard.tsx new file mode 100644 index 0000000000..8ec5fad578 --- /dev/null +++ b/frontend/src/component/environments/EnvironmentCard/EnvironmentCard.tsx @@ -0,0 +1,38 @@ +import { CloudCircle } from '@mui/icons-material'; +import StringTruncator from 'component/common/StringTruncator/StringTruncator'; +import { useStyles } from 'component/environments/EnvironmentCard/EnvironmentCard.styles'; + +interface IEnvironmentProps { + name: string; + type: string; +} + +const EnvironmentCard = ({ name, type }: IEnvironmentProps) => { + const { classes: styles } = useStyles(); + return ( +
    +
    + Environment +
    + +
    +
    +
    Id
    +
    + +
    +
    +
    +
    Type
    +
    {type}
    +
    +
    +
    + ); +}; + +export default EnvironmentCard; diff --git a/frontend/src/component/environments/EnvironmentDeleteConfirm/EnvironmentDeleteConfirm.styles.ts b/frontend/src/component/environments/EnvironmentDeleteConfirm/EnvironmentDeleteConfirm.styles.ts new file mode 100644 index 0000000000..75e0fdb3ea --- /dev/null +++ b/frontend/src/component/environments/EnvironmentDeleteConfirm/EnvironmentDeleteConfirm.styles.ts @@ -0,0 +1,10 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + deleteParagraph: { + marginTop: '2rem', + }, + environmentDeleteInput: { + marginTop: '1rem', + }, +})); diff --git a/frontend/src/component/environments/EnvironmentDeleteConfirm/EnvironmentDeleteConfirm.tsx b/frontend/src/component/environments/EnvironmentDeleteConfirm/EnvironmentDeleteConfirm.tsx new file mode 100644 index 0000000000..3c8ef945c0 --- /dev/null +++ b/frontend/src/component/environments/EnvironmentDeleteConfirm/EnvironmentDeleteConfirm.tsx @@ -0,0 +1,74 @@ +import { Alert } from '@mui/material'; +import React from 'react'; +import { IEnvironment } from 'interfaces/environments'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import Input from 'component/common/Input/Input'; +import EnvironmentCard from 'component/environments/EnvironmentCard/EnvironmentCard'; +import { useStyles } from 'component/environments/EnvironmentDeleteConfirm/EnvironmentDeleteConfirm.styles'; + +interface IEnviromentDeleteConfirmProps { + env: IEnvironment; + open: boolean; + setDeldialogue: React.Dispatch>; + handleDeleteEnvironment: () => Promise; + confirmName: string; + setConfirmName: React.Dispatch>; +} + +const EnvironmentDeleteConfirm = ({ + env, + open, + setDeldialogue, + handleDeleteEnvironment, + confirmName, + setConfirmName, +}: IEnviromentDeleteConfirmProps) => { + const { classes: styles } = useStyles(); + + const handleChange = (e: React.ChangeEvent) => + setConfirmName(e.currentTarget.value); + + const handleCancel = () => { + setDeldialogue(false); + setConfirmName(''); + }; + + const formId = 'delete-environment-confirmation-form'; + + return ( + + + Danger. Deleting this environment will result in removing all + strategies that are active in this environment across all + feature toggles. + + + +

    + In order to delete this environment, please enter the id of the + environment in the textfield below: {env?.name} +

    + +
    + +
    +
    + ); +}; + +export default EnvironmentDeleteConfirm; diff --git a/frontend/src/component/environments/EnvironmentForm/EnvironmentForm.styles.ts b/frontend/src/component/environments/EnvironmentForm/EnvironmentForm.styles.ts new file mode 100644 index 0000000000..e3f65cb555 --- /dev/null +++ b/frontend/src/component/environments/EnvironmentForm/EnvironmentForm.styles.ts @@ -0,0 +1,46 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + maxWidth: '440px', + }, + form: { + display: 'flex', + flexDirection: 'column', + height: '100%', + }, + input: { width: '100%', marginBottom: '1rem' }, + label: { + minWidth: '30px', + [theme.breakpoints.down(600)]: { + minWidth: 'auto', + }, + }, + buttonContainer: { + marginTop: 'auto', + display: 'flex', + justifyContent: 'flex-end', + }, + cancelButton: { + marginLeft: '1.5rem', + }, + inputDescription: { + marginBottom: '0.5rem', + }, + formHeader: { + fontWeight: 'normal', + marginTop: '0', + }, + header: { + fontWeight: 'normal', + }, + permissionErrorContainer: { + position: 'relative', + }, + errorMessage: { + fontSize: theme.fontSizes.smallBody, + color: theme.palette.error.main, + position: 'absolute', + top: '-8px', + }, +})); diff --git a/frontend/src/component/environments/EnvironmentForm/EnvironmentForm.tsx b/frontend/src/component/environments/EnvironmentForm/EnvironmentForm.tsx new file mode 100644 index 0000000000..5de5557e35 --- /dev/null +++ b/frontend/src/component/environments/EnvironmentForm/EnvironmentForm.tsx @@ -0,0 +1,75 @@ +import { Button } from '@mui/material'; +import { useStyles } from './EnvironmentForm.styles'; +import React from 'react'; +import Input from 'component/common/Input/Input'; +import EnvironmentTypeSelector from './EnvironmentTypeSelector/EnvironmentTypeSelector'; +import { trim } from 'component/common/util'; + +interface IEnvironmentForm { + name: string; + type: string; + setName: React.Dispatch>; + setType: React.Dispatch>; + validateEnvironmentName?: (e: any) => void; + handleSubmit: (e: any) => void; + handleCancel: () => void; + errors: { [key: string]: string }; + mode: 'Create' | 'Edit'; + clearErrors: () => void; +} + +const EnvironmentForm: React.FC = ({ + children, + handleSubmit, + handleCancel, + name, + type, + setName, + setType, + validateEnvironmentName, + errors, + mode, + clearErrors, +}) => { + const { classes: styles } = useStyles(); + + return ( +
    +

    Environment information

    + +
    +

    + What is your environment name? (Can't be changed later) +

    + setName(trim(e.target.value))} + error={Boolean(errors.name)} + errorText={errors.name} + onFocus={() => clearErrors()} + onBlur={validateEnvironmentName} + disabled={mode === 'Edit'} + autoFocus + /> + +

    + What type of environment do you want to create? +

    + setType(e.currentTarget.value)} + value={type} + /> +
    +
    + {children} + +
    +
    + ); +}; + +export default EnvironmentForm; diff --git a/frontend/src/component/environments/EnvironmentForm/EnvironmentTypeSelector/EnvironmentTypeSelector.styles.ts b/frontend/src/component/environments/EnvironmentForm/EnvironmentTypeSelector/EnvironmentTypeSelector.styles.ts new file mode 100644 index 0000000000..d34580f22a --- /dev/null +++ b/frontend/src/component/environments/EnvironmentForm/EnvironmentTypeSelector/EnvironmentTypeSelector.styles.ts @@ -0,0 +1,14 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + radioGroup: { + flexDirection: 'row', + }, + formHeader: { + fontWeight: 'bold', + fontSize: theme.fontSizes.bodySize, + marginTop: '1.5rem', + marginBottom: '0.5rem', + }, + radioBtnGroup: { display: 'flex', flexDirection: 'column' }, +})); diff --git a/frontend/src/component/environments/EnvironmentForm/EnvironmentTypeSelector/EnvironmentTypeSelector.tsx b/frontend/src/component/environments/EnvironmentForm/EnvironmentTypeSelector/EnvironmentTypeSelector.tsx new file mode 100644 index 0000000000..3206ca450e --- /dev/null +++ b/frontend/src/component/environments/EnvironmentForm/EnvironmentTypeSelector/EnvironmentTypeSelector.tsx @@ -0,0 +1,57 @@ +import { + FormControl, + FormControlLabel, + Radio, + RadioGroup, +} from '@mui/material'; +import { useStyles } from './EnvironmentTypeSelector.styles'; +import React from 'react'; + +interface IEnvironmentTypeSelectorProps { + onChange: (event: React.FormEvent) => void; + value: string; +} + +const EnvironmentTypeSelector = ({ + onChange, + value, +}: IEnvironmentTypeSelectorProps) => { + const { classes: styles } = useStyles(); + return ( + + +
    + } + /> + } + /> +
    +
    + } + /> + } + /> +
    +
    +
    + ); +}; + +export default EnvironmentTypeSelector; diff --git a/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentActionCell.tsx b/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentActionCell.tsx new file mode 100644 index 0000000000..2a2dab69b9 --- /dev/null +++ b/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentActionCell.tsx @@ -0,0 +1,148 @@ +import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell'; +import { + DELETE_ENVIRONMENT, + UPDATE_ENVIRONMENT, +} from 'component/providers/AccessProvider/permissions'; +import { Edit, Delete } from '@mui/icons-material'; +import { useNavigate } from 'react-router-dom'; +import { useState } from 'react'; +import { IEnvironment } from 'interfaces/environments'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import EnvironmentToggleConfirm from '../../EnvironmentToggleConfirm/EnvironmentToggleConfirm'; +import EnvironmentDeleteConfirm from '../../EnvironmentDeleteConfirm/EnvironmentDeleteConfirm'; +import useEnvironmentApi from 'hooks/api/actions/useEnvironmentApi/useEnvironmentApi'; +import useProjectRolePermissions from 'hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions'; +import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments'; +import useToast from 'hooks/useToast'; +import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch'; +import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; + +interface IEnvironmentTableActionsProps { + environment: IEnvironment; +} + +export const EnvironmentActionCell = ({ + environment, +}: IEnvironmentTableActionsProps) => { + const navigate = useNavigate(); + const { setToastApiError, setToastData } = useToast(); + const { refetchEnvironments } = useEnvironments(); + const { refetch: refetchPermissions } = useProjectRolePermissions(); + const { deleteEnvironment, toggleEnvironmentOn, toggleEnvironmentOff } = + useEnvironmentApi(); + + const [deleteModal, setDeleteModal] = useState(false); + const [toggleModal, setToggleModal] = useState(false); + const [confirmName, setConfirmName] = useState(''); + + const handleDeleteEnvironment = async () => { + try { + await deleteEnvironment(environment.name); + refetchPermissions(); + setToastData({ + type: 'success', + title: 'Environment deleted', + text: `You have successfully deleted the ${environment.name} environment.`, + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } finally { + setDeleteModal(false); + setConfirmName(''); + await refetchEnvironments(); + } + }; + + const handleConfirmToggleEnvironment = () => { + return environment.enabled + ? handleToggleEnvironmentOff() + : handleToggleEnvironmentOn(); + }; + + const handleToggleEnvironmentOn = async () => { + try { + setToggleModal(false); + await toggleEnvironmentOn(environment.name); + setToastData({ + type: 'success', + title: 'Project environment enabled', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } finally { + await refetchEnvironments(); + } + }; + + const handleToggleEnvironmentOff = async () => { + try { + setToggleModal(false); + await toggleEnvironmentOff(environment.name); + setToastData({ + type: 'success', + title: 'Project environment disabled', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } finally { + await refetchEnvironments(); + } + }; + + return ( + + setToggleModal(true)} + /> + + navigate(`/environments/${environment.name}`)} + > + + + setDeleteModal(true)} + > + + + + + + ); +}; diff --git a/frontend/src/component/environments/EnvironmentTable/EnvironmentIconCell/EnvironmentIconCell.tsx b/frontend/src/component/environments/EnvironmentTable/EnvironmentIconCell/EnvironmentIconCell.tsx new file mode 100644 index 0000000000..66c3b2dd8a --- /dev/null +++ b/frontend/src/component/environments/EnvironmentTable/EnvironmentIconCell/EnvironmentIconCell.tsx @@ -0,0 +1,42 @@ +import { useContext, VFC } from 'react'; +import { styled } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { Box, IconButton } from '@mui/material'; +import { CloudCircle, DragIndicator } from '@mui/icons-material'; +import { UPDATE_ENVIRONMENT } from 'component/providers/AccessProvider/permissions'; +import AccessContext from 'contexts/AccessContext'; + +const DragIcon = styled(IconButton)( + ({ theme }) => ` + padding: ${theme.spacing(0, 1, 0, 0)}; + cursor: inherit; + transition: color 0.2s ease-in-out; + ` +); + +export const EnvironmentIconCell: VFC = () => { + const { hasAccess } = useContext(AccessContext); + const updatePermission = hasAccess(UPDATE_ENVIRONMENT); + const { searchQuery } = useSearchHighlightContext(); + + // Allow drag and drop if the user is permitted to reorder environments. + // Disable drag and drop while searching since some rows may be hidden. + const enableDragAndDrop = updatePermission && !searchQuery; + return ( + + + + + } + /> + + + ); +}; diff --git a/frontend/src/component/environments/EnvironmentTable/EnvironmentNameCell/EnvironmentNameCell.tsx b/frontend/src/component/environments/EnvironmentTable/EnvironmentNameCell/EnvironmentNameCell.tsx new file mode 100644 index 0000000000..d66ed118dd --- /dev/null +++ b/frontend/src/component/environments/EnvironmentTable/EnvironmentNameCell/EnvironmentNameCell.tsx @@ -0,0 +1,35 @@ +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; +import { IEnvironment } from 'interfaces/environments'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { Badge } from 'component/common/Badge/Badge'; +import { Highlighter } from 'component/common/Highlighter/Highlighter'; +import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { styled } from '@mui/material'; + +const StyledBadge = styled(Badge)(({ theme }) => ({ + marginLeft: theme.spacing(1), +})); + +interface IEnvironmentNameCellProps { + environment: IEnvironment; +} + +export const EnvironmentNameCell = ({ + environment, +}: IEnvironmentNameCellProps) => { + const { searchQuery } = useSearchHighlightContext(); + + return ( + + {environment.name} + Disabled} + /> + Predefined} + /> + + ); +}; diff --git a/frontend/src/component/environments/EnvironmentTable/EnvironmentRow/EnvironmentRow.tsx b/frontend/src/component/environments/EnvironmentTable/EnvironmentRow/EnvironmentRow.tsx new file mode 100644 index 0000000000..e5f16f07b7 --- /dev/null +++ b/frontend/src/component/environments/EnvironmentTable/EnvironmentRow/EnvironmentRow.tsx @@ -0,0 +1,30 @@ +import { useDragItem, MoveListItem } from 'hooks/useDragItem'; +import { Row } from 'react-table'; +import { TableRow } from '@mui/material'; +import { TableCell } from 'component/common/Table'; +import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { UPDATE_ENVIRONMENT } from 'component/providers/AccessProvider/permissions'; +import AccessContext from 'contexts/AccessContext'; +import { useContext } from 'react'; + +interface IEnvironmentRowProps { + row: Row; + moveListItem: MoveListItem; +} + +export const EnvironmentRow = ({ row, moveListItem }: IEnvironmentRowProps) => { + const { hasAccess } = useContext(AccessContext); + const dragItemRef = useDragItem(row.index, moveListItem); + const { searchQuery } = useSearchHighlightContext(); + const draggable = !searchQuery && hasAccess(UPDATE_ENVIRONMENT); + + return ( + + {row.cells.map((cell: any) => ( + + {cell.render('Cell')} + + ))} + + ); +}; diff --git a/frontend/src/component/environments/EnvironmentTable/EnvironmentTable.tsx b/frontend/src/component/environments/EnvironmentTable/EnvironmentTable.tsx new file mode 100644 index 0000000000..b9d8dde01c --- /dev/null +++ b/frontend/src/component/environments/EnvironmentTable/EnvironmentTable.tsx @@ -0,0 +1,159 @@ +import { PageContent } from 'component/common/PageContent/PageContent'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments'; +import { CreateEnvironmentButton } from 'component/environments/CreateEnvironmentButton/CreateEnvironmentButton'; +import { useTable, useGlobalFilter } from 'react-table'; +import { + SortableTableHeader, + Table, + TablePlaceholder, +} from 'component/common/Table'; +import { useCallback } from 'react'; +import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { Alert, styled, TableBody } from '@mui/material'; +import { MoveListItem } from 'hooks/useDragItem'; +import useToast from 'hooks/useToast'; +import useEnvironmentApi, { + createSortOrderPayload, +} from 'hooks/api/actions/useEnvironmentApi/useEnvironmentApi'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { EnvironmentRow } from './EnvironmentRow/EnvironmentRow'; +import { EnvironmentNameCell } from './EnvironmentNameCell/EnvironmentNameCell'; +import { EnvironmentActionCell } from './EnvironmentActionCell/EnvironmentActionCell'; +import { EnvironmentIconCell } from './EnvironmentIconCell/EnvironmentIconCell'; +import { Search } from 'component/common/Search/Search'; + +const StyledAlert = styled(Alert)(({ theme }) => ({ + marginBottom: theme.spacing(4), +})); + +export const EnvironmentTable = () => { + const { changeSortOrder } = useEnvironmentApi(); + const { setToastApiError } = useToast(); + const { environments, mutateEnvironments } = useEnvironments(); + + const moveListItem: MoveListItem = useCallback( + async (dragIndex: number, dropIndex: number, save = false) => { + const copy = [...environments]; + const tmp = copy[dragIndex]; + copy.splice(dragIndex, 1); + copy.splice(dropIndex, 0, tmp); + await mutateEnvironments(copy); + + if (save) { + try { + await changeSortOrder(createSortOrderPayload(copy)); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + } + }, + [changeSortOrder, environments, mutateEnvironments, setToastApiError] + ); + + const { + getTableProps, + getTableBodyProps, + headerGroups, + rows, + prepareRow, + state: { globalFilter }, + setGlobalFilter, + } = useTable( + { + columns: COLUMNS as any, + data: environments, + disableSortBy: true, + }, + useGlobalFilter + ); + + const headerSearch = ( + + ); + + const headerActions = ( + <> + {headerSearch} + + + + ); + + const header = ; + + return ( + + + This is the order of environments that you have today in each + feature toggle. Rearranging them here will change also the order + inside each feature toggle. + + + + + + {rows.map(row => { + prepareRow(row); + return ( + + ); + })} + +
    +
    + 0} + show={ + + No environments found matching “ + {globalFilter} + ” + + } + elseShow={ + + No environments available. Get started by adding + one. + + } + /> + } + /> +
    + ); +}; + +const COLUMNS = [ + { + id: 'Icon', + width: '1%', + Cell: () => , + disableGlobalFilter: true, + }, + { + Header: 'Name', + accessor: 'name', + Cell: ({ row: { original } }: any) => ( + + ), + }, + { + Header: 'Actions', + id: 'Actions', + align: 'center', + width: '1%', + Cell: ({ row: { original } }: any) => ( + + ), + disableGlobalFilter: true, + }, +]; diff --git a/frontend/src/component/environments/EnvironmentToggleConfirm/EnvironmentToggleConfirm.tsx b/frontend/src/component/environments/EnvironmentToggleConfirm/EnvironmentToggleConfirm.tsx new file mode 100644 index 0000000000..e47cc73c76 --- /dev/null +++ b/frontend/src/component/environments/EnvironmentToggleConfirm/EnvironmentToggleConfirm.tsx @@ -0,0 +1,60 @@ +import { capitalize } from '@mui/material'; +import { Alert } from '@mui/material'; +import React from 'react'; +import { IEnvironment } from 'interfaces/environments'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import EnvironmentCard from 'component/environments/EnvironmentCard/EnvironmentCard'; + +interface IEnvironmentToggleConfirmProps { + env: IEnvironment; + open: boolean; + setToggleDialog: React.Dispatch>; + handleConfirmToggleEnvironment: () => void; +} + +const EnvironmentToggleConfirm = ({ + env, + open, + setToggleDialog, + handleConfirmToggleEnvironment, +}: IEnvironmentToggleConfirmProps) => { + let text = env.enabled ? 'disable' : 'enable'; + + const handleCancel = () => { + setToggleDialog(false); + }; + + return ( + + + Disabling an environment will not effect any strategies + that already exist in that environment, but it will make + it unavailable as a selection option for new activation + strategies. + + } + elseShow={ + + Enabling an environment will allow you to add new + activation strategies to this environment. + + } + /> + + + + ); +}; + +export default EnvironmentToggleConfirm; diff --git a/frontend/src/component/environments/hooks/useEnvironmentForm.ts b/frontend/src/component/environments/hooks/useEnvironmentForm.ts new file mode 100644 index 0000000000..c8869e2123 --- /dev/null +++ b/frontend/src/component/environments/hooks/useEnvironmentForm.ts @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react'; +import useEnvironmentApi from 'hooks/api/actions/useEnvironmentApi/useEnvironmentApi'; +import { formatUnknownError } from 'utils/formatUnknownError'; + +const useEnvironmentForm = (initialName = '', initialType = 'development') => { + const [name, setName] = useState(initialName); + const [type, setType] = useState(initialType); + const [errors, setErrors] = useState({}); + + useEffect(() => { + setName(initialName); + }, [initialName]); + + useEffect(() => { + setType(initialType); + }, [initialType]); + + const { validateEnvName } = useEnvironmentApi(); + + const getEnvPayload = () => { + return { + name, + type, + }; + }; + + const validateEnvironmentName = async () => { + if (name.length === 0) { + setErrors(prev => ({ + ...prev, + name: 'Environment name can not be empty', + })); + return false; + } + + try { + await validateEnvName(name); + return true; + } catch (error: unknown) { + setErrors(prev => ({ ...prev, name: formatUnknownError(error) })); + return false; + } + }; + + const clearErrors = () => { + setErrors({}); + }; + + return { + name, + setName, + type, + setType, + getEnvPayload, + validateEnvironmentName, + clearErrors, + errors, + }; +}; + +export default useEnvironmentForm; diff --git a/frontend/src/component/events/EventCard/EventCard.tsx b/frontend/src/component/events/EventCard/EventCard.tsx new file mode 100644 index 0000000000..ed63fcd66b --- /dev/null +++ b/frontend/src/component/events/EventCard/EventCard.tsx @@ -0,0 +1,130 @@ +import EventDiff from 'component/events/EventDiff/EventDiff'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { IEvent } from 'interfaces/event'; +import { useLocationSettings } from 'hooks/useLocationSettings'; +import { formatDateYMDHMS } from 'utils/formatDate'; +import { Link } from 'react-router-dom'; +import { styled } from '@mui/material'; + +interface IEventCardProps { + entry: IEvent; +} + +const StyledDefinitionTerm = styled('dt')(({ theme }) => ({ + color: theme.palette.text.secondary, +})); + +const StyledChangesTitle = styled('strong')(({ theme }) => ({ + fontWeight: 'inherit', + color: theme.palette.text.secondary, +})); + +const StyledContainerListItem = styled('li')(({ theme }) => ({ + display: 'grid', + backgroundColor: theme.palette.neutral.light, + borderRadius: theme.shape.borderRadiusLarge, + padding: theme.spacing(0.5), + [theme.breakpoints.up('md')]: { + gridTemplateColumns: 'auto minmax(0, 1fr)', + }, + + '& dl': { + display: 'grid', + gridTemplateColumns: 'auto 1fr', + alignContent: 'start', + gap: theme.spacing(1), + padding: theme.spacing(2), + [theme.breakpoints.up('md')]: { + padding: theme.spacing(4), + }, + }, +})); + +const StyledCodeSection = styled('div')(({ theme }) => ({ + backgroundColor: 'white', + overflowX: 'auto', + padding: theme.spacing(2), + borderBottomLeftRadius: theme.shape.borderRadiusLarge, + borderBottomRightRadius: theme.shape.borderRadiusLarge, + [theme.breakpoints.up('md')]: { + padding: theme.spacing(4), + borderRadius: 0, + borderTopRightRadius: theme.shape.borderRadiusLarge, + borderBottomRightRadius: theme.shape.borderRadiusLarge, + }, + + '& code': { + wordWrap: 'break-word', + whiteSpace: 'pre-wrap', + fontFamily: 'monospace', + lineHeight: 1.5, + fontSize: theme.fontSizes.smallBody, + }, +})); + +const EventCard = ({ entry }: IEventCardProps) => { + const { locationSettings } = useLocationSettings(); + + const createdAtFormatted = formatDateYMDHMS( + entry.createdAt, + locationSettings.locale + ); + + return ( + +
    + Event id: +
    {entry.id}
    + Changed at: +
    {createdAtFormatted}
    + Event: +
    {entry.type}
    + Changed by: +
    {entry.createdBy}
    + + + Project: + +
    + + {entry.project} + +
    + + } + /> + + + Feature: + +
    + + {entry.featureName} + +
    + + } + /> +
    + + Changes: + + + } + /> +
    + ); +}; + +export default EventCard; diff --git a/frontend/src/component/events/EventDiff/EventDiff.tsx b/frontend/src/component/events/EventDiff/EventDiff.tsx new file mode 100644 index 0000000000..81f48258f3 --- /dev/null +++ b/frontend/src/component/events/EventDiff/EventDiff.tsx @@ -0,0 +1,101 @@ +import { diff } from 'deep-diff'; +import { IEvent } from 'interfaces/event'; +import { useTheme } from '@mui/system'; +import { CSSProperties } from 'react'; + +const DIFF_PREFIXES: Record = { + A: ' ', + E: ' ', + D: '-', + N: '+', +}; + +interface IEventDiffProps { + entry: IEvent; +} + +const EventDiff = ({ entry }: IEventDiffProps) => { + const theme = useTheme(); + + const styles: Record = { + A: { color: theme.palette.code.edited }, // array edited + E: { color: theme.palette.code.edited }, // edited + D: { color: theme.palette.code.diffSub }, // deleted + N: { color: theme.palette.code.diffAdd }, // added + }; + + const diffs = + entry.data && entry.preData + ? diff(entry.preData, entry.data) + : undefined; + + const buildItemDiff = (diff: any, key: string) => { + let change; + if (diff.lhs !== undefined) { + change = ( +
    + - {key}: {JSON.stringify(diff.lhs)} +
    + ); + } else if (diff.rhs !== undefined) { + change = ( +
    + + {key}: {JSON.stringify(diff.rhs)} +
    + ); + } + + return change; + }; + + const buildDiff = (diff: any, idx: number) => { + let change; + const key = diff.path.join('.'); + + if (diff.item) { + change = buildItemDiff(diff.item, key); + } else if (diff.lhs !== undefined && diff.rhs !== undefined) { + change = ( +
    +
    + - {key}: {JSON.stringify(diff.lhs)} +
    +
    + + {key}: {JSON.stringify(diff.rhs)} +
    +
    + ); + } else { + change = ( +
    + {DIFF_PREFIXES[diff.kind]} {key}:{' '} + {JSON.stringify(diff.rhs || diff.item)} +
    + ); + } + + return
    {change}
    ; + }; + + let changes; + + if (diffs) { + changes = diffs.map(buildDiff); + } else { + // Just show the data if there is no diff yet. + const data = entry.data || entry.preData; + changes = [ +
    + {JSON.stringify(data, null, 2)} +
    , + ]; + } + + return ( +
    +            {changes.length === 0 ? '(no changes)' : changes}
    +        
    + ); +}; + +export default EventDiff; diff --git a/frontend/src/component/events/EventJson/EventJson.tsx b/frontend/src/component/events/EventJson/EventJson.tsx new file mode 100644 index 0000000000..eabeef49f2 --- /dev/null +++ b/frontend/src/component/events/EventJson/EventJson.tsx @@ -0,0 +1,39 @@ +import { IEvent } from 'interfaces/event'; +import { styled } from '@mui/material'; + +interface IEventJsonProps { + entry: IEvent; +} + +export const StyledJsonListItem = styled('li')(({ theme }) => ({ + padding: theme.spacing(4), + backgroundColor: theme.palette.neutral.light, + borderRadius: theme.shape.borderRadiusLarge, + fontSize: theme.fontSizes.smallBody, + + '& code': { + wordWrap: 'break-word', + whiteSpace: 'pre-wrap', + fontFamily: 'monospace', + lineHeight: '100%', + }, +})); + +const EventJson = ({ entry }: IEventJsonProps) => { + const localEventData = JSON.parse(JSON.stringify(entry)); + delete localEventData.description; + delete localEventData.name; + delete localEventData.diffs; + + const prettyPrinted = JSON.stringify(localEventData, null, 2); + + return ( + +
    + {prettyPrinted} +
    +
    + ); +}; + +export default EventJson; diff --git a/frontend/src/component/events/EventLog/EventLog.tsx b/frontend/src/component/events/EventLog/EventLog.tsx new file mode 100644 index 0000000000..288f9ade28 --- /dev/null +++ b/frontend/src/component/events/EventLog/EventLog.tsx @@ -0,0 +1,108 @@ +import { Switch, FormControlLabel, useMediaQuery, Box } from '@mui/material'; +import EventJson from 'component/events/EventJson/EventJson'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import EventCard from 'component/events/EventCard/EventCard'; +import { useEventSettings } from 'hooks/useEventSettings'; +import React, { useState, useEffect } from 'react'; +import { Search } from 'component/common/Search/Search'; +import theme from 'themes/theme'; +import { useEventSearch } from 'hooks/api/getters/useEventSearch/useEventSearch'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { useOnVisible } from 'hooks/useOnVisible'; +import { IEvent } from 'interfaces/event'; +import { styled } from '@mui/system'; + +interface IEventLogProps { + title: string; + project?: string; + feature?: string; + displayInline?: boolean; +} + +const StyledEventsList = styled('ul')(({ theme }) => ({ + listStyleType: 'none', + margin: 0, + padding: 0, + display: 'grid', + gap: theme.spacing(2), +})); + +export const EventLog = ({ + title, + project, + feature, + displayInline, +}: IEventLogProps) => { + const [query, setQuery] = useState(''); + const { events, fetchNextPage } = useEventSearch(project, feature, query); + const fetchNextPageRef = useOnVisible(fetchNextPage); + const { eventSettings, setEventSettings } = useEventSettings(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + + // Cache the previous search results so that we can show those while + // fetching new results for a new search query in the background. + const [cache, setCache] = useState(); + useEffect(() => events && setCache(events), [events]); + + const onShowData = () => { + setEventSettings(prev => ({ showData: !prev.showData })); + }; + + const searchInputField = ; + + const showDataSwitch = ( + + } + /> + ); + + return ( + + {showDataSwitch} + {!isSmallScreen && searchInputField} + + } + > + {isSmallScreen && searchInputField} +
    + } + > + {displayInline && } +

    No events found.

    } + /> + 0)} + show={() => ( + + {cache?.map(entry => ( + } + elseShow={() => } + /> + ))} + + )} + /> +
    + + ); +}; diff --git a/frontend/src/component/events/EventPage/EventPage.tsx b/frontend/src/component/events/EventPage/EventPage.tsx new file mode 100644 index 0000000000..9acc8d1557 --- /dev/null +++ b/frontend/src/component/events/EventPage/EventPage.tsx @@ -0,0 +1,18 @@ +import React, { useContext } from 'react'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import AccessContext from 'contexts/AccessContext'; +import { AdminAlert } from 'component/common/AdminAlert/AdminAlert'; +import { EventLog } from 'component/events/EventLog/EventLog'; + +export const EventPage = () => { + const { hasAccess } = useContext(AccessContext); + + return ( + } + elseShow={} + /> + ); +}; diff --git a/frontend/src/component/feature/CopyFeature/CopyFeature.module.scss b/frontend/src/component/feature/CopyFeature/CopyFeature.module.scss new file mode 100644 index 0000000000..66c823bd5d --- /dev/null +++ b/frontend/src/component/feature/CopyFeature/CopyFeature.module.scss @@ -0,0 +1,27 @@ +.header { + padding: var(--card-header-padding); + border: var(--default-border); +} + +.header h1 { + font-size: var(--h1-size); +} + +.content { + padding: var(--card-padding); +} + +.content form { + display: flex; + flex-direction: column; + max-width: 400px; +} + +.content > *, +.content form > * { + margin: 1rem 0; +} + +.text { + max-width: 400px; +} diff --git a/frontend/src/component/feature/CopyFeature/CopyFeature.tsx b/frontend/src/component/feature/CopyFeature/CopyFeature.tsx new file mode 100644 index 0000000000..a590dce438 --- /dev/null +++ b/frontend/src/component/feature/CopyFeature/CopyFeature.tsx @@ -0,0 +1,130 @@ +import { useState, FormEventHandler, ChangeEventHandler } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { + Button, + TextField, + Switch, + Paper, + FormControlLabel, + Alert, +} from '@mui/material'; +import { FileCopy } from '@mui/icons-material'; +import { styles as themeStyles } from 'component/common'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import styles from './CopyFeature.module.scss'; +import { trim } from 'component/common/util'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { getTogglePath } from 'utils/routePathHelpers'; +import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; + +export const CopyFeatureToggle = () => { + const [replaceGroupId, setReplaceGroupId] = useState(true); + const [apiError, setApiError] = useState(''); + const [nameError, setNameError] = useState(); + const [newToggleName, setNewToggleName] = useState(); + const { cloneFeatureToggle, validateFeatureToggleName } = useFeatureApi(); + const featureId = useRequiredPathParam('featureId'); + const projectId = useRequiredPathParam('projectId'); + const { feature } = useFeature(projectId, featureId); + const navigate = useNavigate(); + + const setValue: ChangeEventHandler = event => { + const value = trim(event.target.value); + setNewToggleName(value); + }; + + const toggleReplaceGroupId = () => { + setReplaceGroupId(prev => !prev); + }; + + const onValidateName = async () => { + try { + await validateFeatureToggleName(newToggleName); + setNameError(undefined); + return true; + } catch (error) { + setNameError(formatUnknownError(error)); + } + return false; + }; + + const onSubmit: FormEventHandler = async event => { + event.preventDefault(); + + const isValidName = await onValidateName(); + + if (!isValidName) { + return; + } + + try { + await cloneFeatureToggle(projectId, featureId, { + name: newToggleName as string, + replaceGroupId, + }); + navigate(getTogglePath(projectId, newToggleName as string)); + } catch (error) { + setApiError(formatUnknownError(error)); + } + }; + + if (!feature || !feature.name) return Toggle not found; + + return ( + +
    +

    Copy {featureId}

    +
    + {apiError}} + /> +
    +

    + You are about to create a new feature toggle by cloning the + configuration of feature toggle  + + {featureId} + + . You must give the new feature toggle a unique name before + you can proceed. +

    +
    + + + } + label="Replace groupId" + /> + + + +
    +
    + ); +}; diff --git a/frontend/src/component/feature/CreateFeature/CreateFeature.tsx b/frontend/src/component/feature/CreateFeature/CreateFeature.tsx new file mode 100644 index 0000000000..5f52865d21 --- /dev/null +++ b/frontend/src/component/feature/CreateFeature/CreateFeature.tsx @@ -0,0 +1,116 @@ +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import { useNavigate } from 'react-router-dom'; +import FeatureForm from '../FeatureForm/FeatureForm'; +import useFeatureForm from '../hooks/useFeatureForm'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import useToast from 'hooks/useToast'; +import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; +import { CREATE_FEATURE } from 'component/providers/AccessProvider/permissions'; +import { useContext } from 'react'; +import { CreateButton } from 'component/common/CreateButton/CreateButton'; +import UIContext from 'contexts/UIContext'; +import { CF_CREATE_BTN_ID } from 'utils/testIds'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { GO_BACK } from 'constants/navigate'; + +const CreateFeature = () => { + const { setToastData, setToastApiError } = useToast(); + const { setShowFeedback } = useContext(UIContext); + const { uiConfig } = useUiConfig(); + const navigate = useNavigate(); + + const { + type, + setType, + name, + setName, + project, + setProject, + description, + setDescription, + validateToggleName, + impressionData, + setImpressionData, + getTogglePayload, + clearErrors, + errors, + } = useFeatureForm(); + + const { createFeatureToggle, loading } = useFeatureApi(); + + const handleSubmit = async (e: Event) => { + e.preventDefault(); + clearErrors(); + const validToggleName = await validateToggleName(); + + if (validToggleName) { + const payload = getTogglePayload(); + try { + await createFeatureToggle(project, payload); + navigate(`/projects/${project}/features/${name}`); + setToastData({ + title: 'Toggle created successfully', + text: 'Now you can start using your toggle.', + confetti: true, + type: 'success', + }); + setShowFeedback(true); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + } + }; + + const formatApiCode = () => { + return `curl --location --request POST '${ + uiConfig.unleashUrl + }/api/admin/projects/${project}/features' \\ + --header 'Authorization: INSERT_API_KEY' \\ + --header 'Content-Type: application/json' \\ + --data-raw '${JSON.stringify(getTogglePayload(), undefined, 2)}'`; + }; + + const handleCancel = () => { + navigate(GO_BACK); + }; + + return ( + + + + + + ); +}; + +export default CreateFeature; diff --git a/frontend/src/component/feature/CreateFeatureButton/CreateFeatureButton.tsx b/frontend/src/component/feature/CreateFeatureButton/CreateFeatureButton.tsx new file mode 100644 index 0000000000..ff5c999a5d --- /dev/null +++ b/frontend/src/component/feature/CreateFeatureButton/CreateFeatureButton.tsx @@ -0,0 +1,58 @@ +import classnames from 'classnames'; +import { Link } from 'react-router-dom'; +import { Button, IconButton, Tooltip } from '@mui/material'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { Add } from '@mui/icons-material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { NAVIGATE_TO_CREATE_FEATURE } from 'utils/testIds'; +import { IFeaturesFilter } from 'hooks/useFeaturesFilter'; +import { useCreateFeaturePath } from 'component/feature/CreateFeatureButton/useCreateFeaturePath'; + +interface ICreateFeatureButtonProps { + loading: boolean; + filter: IFeaturesFilter; +} + +export const CreateFeatureButton = ({ + loading, + filter, +}: ICreateFeatureButtonProps) => { + const smallScreen = useMediaQuery('(max-width:800px)'); + const createFeature = useCreateFeaturePath(filter); + + if (!createFeature) { + return null; + } + + return ( + + + + + + } + elseShow={ + + } + /> + ); +}; diff --git a/frontend/src/component/feature/CreateFeatureButton/useCreateFeaturePath.ts b/frontend/src/component/feature/CreateFeatureButton/useCreateFeaturePath.ts new file mode 100644 index 0000000000..7a5b571eb8 --- /dev/null +++ b/frontend/src/component/feature/CreateFeatureButton/useCreateFeaturePath.ts @@ -0,0 +1,34 @@ +import { useDefaultProjectId } from 'hooks/api/getters/useDefaultProject/useDefaultProjectId'; +import { IFeaturesFilter } from 'hooks/useFeaturesFilter'; +import { getCreateTogglePath } from 'utils/routePathHelpers'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { CREATE_FEATURE } from 'component/providers/AccessProvider/permissions'; +import AccessContext from 'contexts/AccessContext'; +import { useContext } from 'react'; + +interface IUseCreateFeaturePathOutput { + path: string; + access: boolean; +} + +export const useCreateFeaturePath = ( + filter: IFeaturesFilter +): IUseCreateFeaturePathOutput | undefined => { + const { hasAccess } = useContext(AccessContext); + const defaultProjectId = useDefaultProjectId(); + const { uiConfig } = useUiConfig(); + + const selectedProjectId = + filter.project === '*' || !filter.project + ? defaultProjectId + : filter.project; + + if (!selectedProjectId) { + return; + } + + return { + path: getCreateTogglePath(selectedProjectId, uiConfig.flags.E), + access: hasAccess(CREATE_FEATURE, selectedProjectId), + }; +}; diff --git a/frontend/src/component/feature/EditFeature/EditFeature.tsx b/frontend/src/component/feature/EditFeature/EditFeature.tsx new file mode 100644 index 0000000000..b4c5804715 --- /dev/null +++ b/frontend/src/component/feature/EditFeature/EditFeature.tsx @@ -0,0 +1,114 @@ +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import { useNavigate } from 'react-router-dom'; +import FeatureForm from '../FeatureForm/FeatureForm'; +import useFeatureForm from '../hooks/useFeatureForm'; +import * as jsonpatch from 'fast-json-patch'; +import { UpdateButton } from 'component/common/UpdateButton/UpdateButton'; +import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions'; +import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { GO_BACK } from 'constants/navigate'; + +const EditFeature = () => { + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + const navigate = useNavigate(); + const { patchFeatureToggle, loading } = useFeatureApi(); + const { feature } = useFeature(projectId, featureId); + + const { + type, + setType, + name, + setName, + project, + setProject, + description, + setDescription, + impressionData, + setImpressionData, + clearErrors, + errors, + } = useFeatureForm( + feature?.name, + feature?.type, + feature?.project, + feature?.description, + feature?.impressionData + ); + + const createPatch = () => { + const comparison = { ...feature, type, description, impressionData }; + const patch = jsonpatch.compare(feature, comparison); + return patch; + }; + + const handleSubmit = async (e: Event) => { + e.preventDefault(); + clearErrors(); + const patch = createPatch(); + try { + await patchFeatureToggle(project, featureId, patch); + navigate(`/projects/${project}/features/${name}`); + setToastData({ + title: 'Toggle updated successfully', + type: 'success', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const formatApiCode = () => { + return `curl --location --request PATCH '${ + uiConfig.unleashUrl + }/api/admin/projects/${projectId}/features/${featureId}' \\ + --header 'Authorization: INSERT_API_KEY' \\ + --header 'Content-Type: application/json' \\ + --data-raw '${JSON.stringify(createPatch(), undefined, 2)}'`; + }; + + const handleCancel = () => { + navigate(GO_BACK); + }; + + return ( + + + + + + ); +}; + +export default EditFeature; diff --git a/frontend/src/component/feature/FeatureForm/FeatureForm.styles.ts b/frontend/src/component/feature/FeatureForm/FeatureForm.styles.ts new file mode 100644 index 0000000000..19e697f00e --- /dev/null +++ b/frontend/src/component/feature/FeatureForm/FeatureForm.styles.ts @@ -0,0 +1,68 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + maxWidth: '400px', + }, + form: { + display: 'flex', + flexDirection: 'column', + height: '100%', + }, + input: { width: '100%', marginBottom: '1rem' }, + selectInput: { + marginBottom: '1rem', + minWidth: '400px', + [theme.breakpoints.down(600)]: { + minWidth: '379px', + }, + }, + label: { + minWidth: '300px', + [theme.breakpoints.down(600)]: { + minWidth: 'auto', + }, + }, + buttonContainer: { + marginTop: 'auto', + display: 'flex', + justifyContent: 'flex-end', + }, + cancelButton: { + marginLeft: '1.5rem', + }, + inputDescription: { + marginBottom: '0.5rem', + color: theme.palette.text.secondary, + }, + typeDescription: { + fontSize: theme.fontSizes.smallBody, + color: theme.palette.text.secondary, + top: '-13px', + position: 'relative', + }, + formHeader: { + fontWeight: 'normal', + marginTop: '0', + }, + header: { + fontWeight: 'normal', + }, + permissionErrorContainer: { + position: 'relative', + }, + errorMessage: { + fontSize: theme.fontSizes.smallBody, + color: theme.palette.error.main, + position: 'absolute', + top: '-8px', + }, + roleSubtitle: { + margin: '0.5rem 0', + }, + flexRow: { + display: 'flex', + alignItems: 'center', + marginTop: '0.5rem', + }, +})); diff --git a/frontend/src/component/feature/FeatureForm/FeatureForm.tsx b/frontend/src/component/feature/FeatureForm/FeatureForm.tsx new file mode 100644 index 0000000000..451507bc60 --- /dev/null +++ b/frontend/src/component/feature/FeatureForm/FeatureForm.tsx @@ -0,0 +1,192 @@ +import { + Button, + FormControl, + FormControlLabel, + Switch, + Typography, +} from '@mui/material'; +import { useStyles } from './FeatureForm.styles'; +import FeatureTypeSelect from '../FeatureView/FeatureSettings/FeatureSettingsMetadata/FeatureTypeSelect/FeatureTypeSelect'; +import { CF_DESC_ID, CF_NAME_ID, CF_TYPE_ID } from 'utils/testIds'; +import useFeatureTypes from 'hooks/api/getters/useFeatureTypes/useFeatureTypes'; +import { KeyboardArrowDownOutlined } from '@mui/icons-material'; +import { projectFilterGenerator } from 'utils/projectFilterGenerator'; +import FeatureProjectSelect from '../FeatureView/FeatureSettings/FeatureSettingsProject/FeatureProjectSelect/FeatureProjectSelect'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { trim } from 'component/common/util'; +import Input from 'component/common/Input/Input'; +import { CREATE_FEATURE } from 'component/providers/AccessProvider/permissions'; +import { useNavigate } from 'react-router-dom'; +import React from 'react'; +import { useAuthPermissions } from 'hooks/api/getters/useAuth/useAuthPermissions'; + +interface IFeatureToggleForm { + type: string; + name: string; + description: string; + project: string; + impressionData: boolean; + setType: React.Dispatch>; + setName: React.Dispatch>; + setDescription: React.Dispatch>; + setProject: React.Dispatch>; + setImpressionData: React.Dispatch>; + validateToggleName?: () => void; + handleSubmit: (e: any) => void; + handleCancel: () => void; + errors: { [key: string]: string }; + mode: 'Create' | 'Edit'; + clearErrors: () => void; +} + +const FeatureForm: React.FC = ({ + children, + type, + name, + description, + project, + setType, + setName, + setDescription, + setProject, + validateToggleName, + setImpressionData, + impressionData, + handleSubmit, + handleCancel, + errors, + mode, + clearErrors, +}) => { + const { classes: styles } = useStyles(); + const { featureTypes } = useFeatureTypes(); + const navigate = useNavigate(); + const { permissions } = useAuthPermissions(); + const editable = mode !== 'Edit'; + + const renderToggleDescription = () => { + return featureTypes.find(toggle => toggle.id === type)?.description; + }; + + return ( +
    +
    +

    + What would you like to call your toggle? +

    + clearErrors()} + value={name} + onChange={e => setName(trim(e.target.value))} + data-testid={CF_NAME_ID} + onBlur={validateToggleName} + /> +

    + What kind of feature toggle do you want? +

    + +

    + {renderToggleDescription()} +

    + + In which project do you want to save the toggle? +

    + } + /> + { + setProject(projectId); + navigate(`/projects/${projectId}/create-toggle`, { + replace: true, + }); + }} + enabled={editable} + filter={projectFilterGenerator(permissions, CREATE_FEATURE)} + IconComponent={KeyboardArrowDownOutlined} + className={styles.selectInput} + /> + +

    + How would you describe your feature toggle? +

    + setDescription(e.target.value)} + /> + + + Impression Data + +

    + When you enable impression data for a feature toggle, + your client SDKs will emit events you can listen for + every time this toggle gets triggered. Learn more in{' '} + + the impression data documentation + +

    +
    + + setImpressionData(!impressionData) + } + checked={impressionData} + /> + } + label="Enable impression data" + /> +
    +
    +
    + +
    + {children} + +
    +
    + ); +}; + +export default FeatureForm; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/FeatureStrategyConstraints.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/FeatureStrategyConstraints.tsx new file mode 100644 index 0000000000..9a34120aa0 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/FeatureStrategyConstraints.tsx @@ -0,0 +1,57 @@ +import { IConstraint, IFeatureStrategy } from 'interfaces/strategy'; +import React, { useMemo, useContext } from 'react'; +import { ConstraintAccordionList } from 'component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList'; +import AccessContext from 'contexts/AccessContext'; +import { + UPDATE_FEATURE_STRATEGY, + CREATE_FEATURE_STRATEGY, +} from 'component/providers/AccessProvider/permissions'; + +interface IFeatureStrategyConstraintsProps { + projectId: string; + environmentId: string; + strategy: Partial; + setStrategy: React.Dispatch< + React.SetStateAction> + >; +} + +export const FeatureStrategyConstraints = ({ + projectId, + environmentId, + strategy, + setStrategy, +}: IFeatureStrategyConstraintsProps) => { + const { hasAccess } = useContext(AccessContext); + + const constraints = useMemo(() => { + return strategy.constraints ?? []; + }, [strategy]); + + const setConstraints = (value: React.SetStateAction) => { + setStrategy(prev => ({ + ...prev, + constraints: value instanceof Function ? value(constraints) : value, + })); + }; + + const showCreateButton = hasAccess( + CREATE_FEATURE_STRATEGY, + projectId, + environmentId + ); + + const allowEditAndDelete = hasAccess( + UPDATE_FEATURE_STRATEGY, + projectId, + environmentId + ); + + return ( + + ); +}; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.test.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.test.tsx new file mode 100644 index 0000000000..42458dc599 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.test.tsx @@ -0,0 +1,20 @@ +import { formatAddStrategyApiCode } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate'; + +test('formatAddStrategyApiCode', () => { + expect( + formatAddStrategyApiCode( + 'projectId', + 'featureId', + 'environmentId', + { id: 'strategyId' }, + 'unleashUrl' + ) + ).toMatchInlineSnapshot(` + "curl --location --request POST 'unleashUrl/api/admin/projects/projectId/features/featureId/environments/environmentId/strategies' \\\\ + --header 'Authorization: INSERT_API_KEY' \\\\ + --header 'Content-Type: application/json' \\\\ + --data-raw '{ + \\"id\\": \\"strategyId\\" + }'" + `); +}); diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx new file mode 100644 index 0000000000..39072d93b8 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx @@ -0,0 +1,146 @@ +import React, { useEffect, useState } from 'react'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { useRequiredQueryParam } from 'hooks/useRequiredQueryParam'; +import { FeatureStrategyForm } from 'component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm'; +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import useFeatureStrategyApi from 'hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { useNavigate } from 'react-router-dom'; +import useToast from 'hooks/useToast'; +import { IFeatureStrategy } from 'interfaces/strategy'; +import { + featureStrategyDocsLink, + featureStrategyHelp, + formatFeaturePath, + createStrategyPayload, + featureStrategyDocsLinkLabel, +} from '../FeatureStrategyEdit/FeatureStrategyEdit'; +import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions'; +import { ISegment } from 'interfaces/segment'; +import { useSegmentsApi } from 'hooks/api/actions/useSegmentsApi/useSegmentsApi'; +import { formatStrategyName } from 'utils/strategyNames'; +import { useFeatureImmutable } from 'hooks/api/getters/useFeature/useFeatureImmutable'; +import { useFormErrors } from 'hooks/useFormErrors'; +import { createFeatureStrategy } from 'utils/createFeatureStrategy'; +import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy'; + +export const FeatureStrategyCreate = () => { + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + const environmentId = useRequiredQueryParam('environmentId'); + const strategyName = useRequiredQueryParam('strategyName'); + const [strategy, setStrategy] = useState>({}); + const [segments, setSegments] = useState([]); + const { strategyDefinition } = useStrategy(strategyName); + const errors = useFormErrors(); + + const { addStrategyToFeature, loading } = useFeatureStrategyApi(); + const { setStrategySegments } = useSegmentsApi(); + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + const { unleashUrl } = uiConfig; + const navigate = useNavigate(); + + const { feature, refetchFeature } = useFeatureImmutable( + projectId, + featureId + ); + + useEffect(() => { + if (strategyDefinition) { + setStrategy(createFeatureStrategy(featureId, strategyDefinition)); + } + }, [featureId, strategyDefinition]); + + const onSubmit = async () => { + try { + const created = await addStrategyToFeature( + projectId, + featureId, + environmentId, + createStrategyPayload(strategy) + ); + if (uiConfig.flags.SE) { + await setStrategySegments({ + environmentId, + projectId, + strategyId: created.id, + segmentIds: segments.map(s => s.id), + }); + } + setToastData({ + title: 'Strategy created', + type: 'success', + confetti: true, + }); + refetchFeature(); + navigate(formatFeaturePath(projectId, featureId)); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + return ( + + formatAddStrategyApiCode( + projectId, + featureId, + environmentId, + strategy, + unleashUrl + ) + } + > + + + ); +}; + +export const formatCreateStrategyPath = ( + projectId: string, + featureId: string, + environmentId: string, + strategyName: string +): string => { + const params = new URLSearchParams({ environmentId, strategyName }); + + return `/projects/${projectId}/features/${featureId}/strategies/create?${params}`; +}; + +export const formatAddStrategyApiCode = ( + projectId: string, + featureId: string, + environmentId: string, + strategy: Partial, + unleashUrl?: string +): string => { + if (!unleashUrl) { + return ''; + } + + const url = `${unleashUrl}/api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/strategies`; + const payload = JSON.stringify(strategy, undefined, 2); + + return `curl --location --request POST '${url}' \\ + --header 'Authorization: INSERT_API_KEY' \\ + --header 'Content-Type: application/json' \\ + --data-raw '${payload}'`; +}; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.test.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.test.tsx new file mode 100644 index 0000000000..b35a1fc096 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.test.tsx @@ -0,0 +1,53 @@ +import { formatUpdateStrategyApiCode } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit'; +import { IFeatureStrategy, IStrategy } from 'interfaces/strategy'; + +test('formatUpdateStrategyApiCode', () => { + const strategy: IFeatureStrategy = { + id: 'a', + name: 'b', + parameters: { + c: 1, + b: 2, + a: 3, + }, + constraints: [], + }; + + const strategyDefinition: IStrategy = { + name: 'c', + displayName: 'd', + description: 'e', + editable: false, + deprecated: false, + parameters: [ + { name: 'a', description: '', type: '', required: false }, + { name: 'b', description: '', type: '', required: false }, + { name: 'c', description: '', type: '', required: false }, + ], + }; + + expect( + formatUpdateStrategyApiCode( + 'projectId', + 'featureId', + 'environmentId', + strategy, + strategyDefinition, + 'unleashUrl' + ) + ).toMatchInlineSnapshot(` + "curl --location --request PUT 'unleashUrl/api/admin/projects/projectId/features/featureId/environments/environmentId/strategies/a' \\\\ + --header 'Authorization: INSERT_API_KEY' \\\\ + --header 'Content-Type: application/json' \\\\ + --data-raw '{ + \\"id\\": \\"a\\", + \\"name\\": \\"b\\", + \\"parameters\\": { + \\"a\\": 3, + \\"b\\": 2, + \\"c\\": 1 + }, + \\"constraints\\": [] + }'" + `); +}); diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx new file mode 100644 index 0000000000..6a452c78a1 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx @@ -0,0 +1,200 @@ +import React, { useEffect, useState } from 'react'; +import { FeatureStrategyForm } from 'component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm'; +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { useRequiredQueryParam } from 'hooks/useRequiredQueryParam'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import useFeatureStrategyApi from 'hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { useNavigate } from 'react-router-dom'; +import useToast from 'hooks/useToast'; +import { + IFeatureStrategy, + IFeatureStrategyPayload, + IStrategy, +} from 'interfaces/strategy'; +import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions'; +import { ISegment } from 'interfaces/segment'; +import { useSegmentsApi } from 'hooks/api/actions/useSegmentsApi/useSegmentsApi'; +import { useSegments } from 'hooks/api/getters/useSegments/useSegments'; +import { formatStrategyName } from 'utils/strategyNames'; +import { useFeatureImmutable } from 'hooks/api/getters/useFeature/useFeatureImmutable'; +import { useFormErrors } from 'hooks/useFormErrors'; +import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy'; +import { sortStrategyParameters } from 'utils/sortStrategyParameters'; + +export const FeatureStrategyEdit = () => { + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + const environmentId = useRequiredQueryParam('environmentId'); + const strategyId = useRequiredQueryParam('strategyId'); + + const [strategy, setStrategy] = useState>({}); + const [segments, setSegments] = useState([]); + const { updateStrategyOnFeature, loading } = useFeatureStrategyApi(); + const { setStrategySegments } = useSegmentsApi(); + const { strategyDefinition } = useStrategy(strategy.name); + const { setToastData, setToastApiError } = useToast(); + const errors = useFormErrors(); + const { uiConfig } = useUiConfig(); + const { unleashUrl } = uiConfig; + const navigate = useNavigate(); + + const { feature, refetchFeature } = useFeatureImmutable( + projectId, + featureId + ); + + const { + segments: savedStrategySegments, + refetchSegments: refetchSavedStrategySegments, + } = useSegments(strategyId); + + useEffect(() => { + const savedStrategy = feature.environments + .flatMap(environment => environment.strategies) + .find(strategy => strategy.id === strategyId); + setStrategy(prev => ({ ...prev, ...savedStrategy })); + }, [strategyId, feature]); + + useEffect(() => { + // Fill in the selected segments once they've been fetched. + savedStrategySegments && setSegments(savedStrategySegments); + }, [savedStrategySegments]); + + const onSubmit = async () => { + try { + await updateStrategyOnFeature( + projectId, + featureId, + environmentId, + strategyId, + createStrategyPayload(strategy) + ); + if (uiConfig.flags.SE) { + await setStrategySegments({ + environmentId, + projectId, + strategyId, + segmentIds: segments.map(s => s.id), + }); + await refetchSavedStrategySegments(); + } + setToastData({ + title: 'Strategy updated', + type: 'success', + confetti: true, + }); + refetchFeature(); + navigate(formatFeaturePath(projectId, featureId)); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + if (!strategy.id || !strategyDefinition) { + return null; + } + + return ( + + formatUpdateStrategyApiCode( + projectId, + featureId, + environmentId, + strategy, + strategyDefinition, + unleashUrl + ) + } + > + + + ); +}; + +export const createStrategyPayload = ( + strategy: Partial +): IFeatureStrategyPayload => { + return { + name: strategy.name, + constraints: strategy.constraints ?? [], + parameters: strategy.parameters ?? {}, + }; +}; + +export const formatFeaturePath = ( + projectId: string, + featureId: string +): string => { + return `/projects/${projectId}/features/${featureId}`; +}; + +export const formatEditStrategyPath = ( + projectId: string, + featureId: string, + environmentId: string, + strategyId: string +): string => { + const params = new URLSearchParams({ environmentId, strategyId }); + + return `/projects/${projectId}/features/${featureId}/strategies/edit?${params}`; +}; + +export const formatUpdateStrategyApiCode = ( + projectId: string, + featureId: string, + environmentId: string, + strategy: Partial, + strategyDefinition: IStrategy, + unleashUrl?: string +): string => { + if (!unleashUrl) { + return ''; + } + + // Sort the strategy parameters payload so that they match + // the order of the input fields in the form, for usability. + const sortedStrategy = { + ...strategy, + parameters: sortStrategyParameters( + strategy.parameters ?? {}, + strategyDefinition + ), + }; + + const url = `${unleashUrl}/api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/strategies/${strategy.id}`; + const payload = JSON.stringify(sortedStrategy, undefined, 2); + + return `curl --location --request PUT '${url}' \\ + --header 'Authorization: INSERT_API_KEY' \\ + --header 'Content-Type: application/json' \\ + --data-raw '${payload}'`; +}; + +export const featureStrategyHelp = ` + An activation strategy will only run when a feature toggle is enabled and provides a way to control who will get access to the feature. + If any of a feature toggle's activation strategies returns true, the user will get access. +`; + +export const featureStrategyDocsLink = + 'https://docs.getunleash.io/user_guide/activation_strategy'; + +export const featureStrategyDocsLinkLabel = 'Strategies documentation'; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEmpty/CopyButton/CopyButton.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEmpty/CopyButton/CopyButton.tsx new file mode 100644 index 0000000000..e8c0037482 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEmpty/CopyButton/CopyButton.tsx @@ -0,0 +1,106 @@ +import { MouseEvent, useContext, useState, VFC } from 'react'; +import { + Button, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Tooltip, +} from '@mui/material'; +import { Lock } from '@mui/icons-material'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { IFeatureEnvironment } from 'interfaces/featureToggle'; +import AccessContext from 'contexts/AccessContext'; +import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; + +interface ICopyButtonProps { + environmentId: IFeatureEnvironment['name']; + environments: IFeatureEnvironment['name'][]; + onClick: (environmentId: string) => void; +} + +export const CopyButton: VFC = ({ + environmentId, + environments, + onClick, +}) => { + const projectId = useRequiredPathParam('projectId'); + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + const { hasAccess } = useContext(AccessContext); + const enabled = environments.some(environment => + hasAccess(CREATE_FEATURE_STRATEGY, projectId, environment) + ); + + return ( +
    + +
    + +
    +
    + { + setAnchorEl(null); + }} + MenuListProps={{ + 'aria-labelledby': `copy-all-strategies-${environmentId}`, + }} + > + {environments.map(environment => { + const access = hasAccess( + CREATE_FEATURE_STRATEGY, + projectId, + environment + ); + + return ( + +
    + onClick(environment)} + disabled={!access} + > + + + + } + /> + + Copy from {environment} + + +
    +
    + ); + })} +
    +
    + ); +}; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEmpty/FeatureStrategyEmpty.styles.ts b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEmpty/FeatureStrategyEmpty.styles.ts new file mode 100644 index 0000000000..5f7fc7bf8c --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEmpty/FeatureStrategyEmpty.styles.ts @@ -0,0 +1,23 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + paddingTop: theme.spacing(2), + }, + title: { + fontSize: theme.fontSizes.bodySize, + textAlign: 'center', + color: theme.palette.text.primary, + marginBottom: theme.spacing(1), + }, + description: { + color: theme.palette.text.secondary, + fontSize: theme.fontSizes.smallBody, + textAlign: 'center', + marginBottom: theme.spacing(3), + }, +})); diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEmpty/FeatureStrategyEmpty.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEmpty/FeatureStrategyEmpty.tsx new file mode 100644 index 0000000000..c604d24b4b --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEmpty/FeatureStrategyEmpty.tsx @@ -0,0 +1,189 @@ +import { Link } from 'react-router-dom'; +import { Box } from '@mui/material'; +import { SectionSeparator } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/SectionSeparator/SectionSeparator'; +import useFeatureStrategyApi from 'hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi'; +import useToast from 'hooks/useToast'; +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import { FeatureStrategyMenu } from '../FeatureStrategyMenu/FeatureStrategyMenu'; +import { PresetCard } from './PresetCard/PresetCard'; +import { useStyles } from './FeatureStrategyEmpty.styles'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { useFeatureImmutable } from 'hooks/api/getters/useFeature/useFeatureImmutable'; +import { getFeatureStrategyIcon } from 'utils/strategyNames'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { CopyButton } from './CopyButton/CopyButton'; + +interface IFeatureStrategyEmptyProps { + projectId: string; + featureId: string; + environmentId: string; +} + +export const FeatureStrategyEmpty = ({ + projectId, + featureId, + environmentId, +}: IFeatureStrategyEmptyProps) => { + const { classes: styles } = useStyles(); + const { addStrategyToFeature } = useFeatureStrategyApi(); + const { setToastData, setToastApiError } = useToast(); + const { refetchFeature } = useFeature(projectId, featureId); + const { refetchFeature: refetchFeatureImmutable } = useFeatureImmutable( + projectId, + featureId + ); + const { feature } = useFeature(projectId, featureId); + const otherAvailableEnvironments = feature?.environments.filter( + environment => + environment.name !== environmentId && + environment.strategies && + environment.strategies.length > 0 + ); + + const onAfterAddStrategy = (multiple = false) => { + refetchFeature(); + refetchFeatureImmutable(); + + setToastData({ + title: multiple ? 'Strategies created' : 'Strategy created', + text: multiple + ? 'Successfully copied from another environment' + : 'Successfully created strategy', + type: 'success', + }); + }; + + const onCopyStrategies = async (fromEnvironmentName: string) => { + const strategies = + otherAvailableEnvironments?.find( + environment => environment.name === fromEnvironmentName + )?.strategies || []; + + try { + await Promise.all( + strategies.map(strategy => { + const { id, ...strategyCopy } = { + ...strategy, + environment: environmentId, + }; + + return addStrategyToFeature( + projectId, + featureId, + environmentId, + strategyCopy + ); + }) + ); + onAfterAddStrategy(true); + } catch (error) { + setToastApiError(formatUnknownError(error)); + } + }; + + const onAddSimpleStrategy = async () => { + try { + await addStrategyToFeature(projectId, featureId, environmentId, { + name: 'default', + parameters: {}, + constraints: [], + }); + onAfterAddStrategy(); + } catch (error) { + setToastApiError(formatUnknownError(error)); + } + }; + + const onAddGradualRolloutStrategy = async () => { + try { + await addStrategyToFeature(projectId, featureId, environmentId, { + name: 'flexibleRollout', + parameters: { + rollout: '50', + stickiness: 'default', + }, + constraints: [], + }); + onAfterAddStrategy(); + } catch (error) { + setToastApiError(formatUnknownError(error)); + } + }; + + return ( +
    +
    + You have not defined any strategies yet. +
    +

    + Strategies added in this environment will only be executed if + the SDK is using an{' '} + API key configured for this + environment. +

    + + + 0 + } + show={ + environment.name + )} + onClick={onCopyStrategies} + /> + } + /> + + + Or use a strategy template + + + + The standard strategy is strictly on/off for your entire + userbase. + + + Roll out to a percentage of your userbase. + + +
    + ); +}; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEmpty/PresetCard/PresetCard.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEmpty/PresetCard/PresetCard.tsx new file mode 100644 index 0000000000..75ffa587a3 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEmpty/PresetCard/PresetCard.tsx @@ -0,0 +1,64 @@ +import { ElementType, FC } from 'react'; +import { Card, CardContent, Typography, styled, Box } from '@mui/material'; +import PermissionButton from 'component/common/PermissionButton/PermissionButton'; +import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions'; + +interface IPresetCardProps { + title: string; + projectId: string; + environmentId: string; + onClick: () => void; + Icon: ElementType; +} + +const StyledCard = styled(Card)(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + borderRadius: theme.shape.borderRadiusMedium, +})); + +export const PresetCard: FC = ({ + title, + children, + Icon, + projectId, + environmentId, + onClick, +}) => ( + + + + {title} + + + {children} + + + + + Use template + + + + +); diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEnabled/FeatureStrategyEnabled.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEnabled/FeatureStrategyEnabled.tsx new file mode 100644 index 0000000000..fbfd976336 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEnabled/FeatureStrategyEnabled.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { Alert } from '@mui/material'; +import { IFeatureToggle } from 'interfaces/featureToggle'; +import { formatFeaturePath } from '../FeatureStrategyEdit/FeatureStrategyEdit'; + +interface IFeatureStrategyEnabledProps { + feature: IFeatureToggle; + environmentId: string; +} + +export const FeatureStrategyEnabled = ({ + feature, + environmentId, +}: IFeatureStrategyEnabledProps) => { + const featurePagePath = formatFeaturePath(feature.project, feature.name); + + const featurePageLink = ( + + feature toggle page + + ); + + return ( + + This feature toggle is currently enabled in the{' '} + {environmentId} environment. Any changes + made here will be available to users as soon as you hit{' '} + save. + + } + elseShow={ + + This feature toggle is currently disabled in the{' '} + {environmentId} environment. Any changes + made here will not take effect until the toggle has been + enabled on the {featurePageLink}. + + } + /> + ); +}; + +const isFeatureEnabledInEnvironment = ( + feature: IFeatureToggle, + environmentId: string +): boolean => { + const environment = feature.environments.find(environment => { + return environment.name === environmentId; + }); + + if (!environment) { + return false; + } + + return environment.enabled; +}; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.styles.ts b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.styles.ts new file mode 100644 index 0000000000..b5eaca13c7 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.styles.ts @@ -0,0 +1,37 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + form: { + display: 'grid', + gap: '1rem', + }, + hr: { + width: '100%', + height: 1, + margin: '1rem 0', + border: 'none', + background: theme.palette.grey[200], + }, + title: { + display: 'grid', + gridTemplateColumns: 'auto 1fr', + gridGap: '.5rem', + fontSize: theme.fontSizes.bodySize, + }, + icon: { + color: theme.palette.primary.main, + }, + name: { + display: 'block', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + fontWeight: theme.fontWeight.thin, + }, + buttons: { + display: 'flex', + justifyContent: 'end', + gap: '1rem', + paddingBottom: '5rem', + }, +})); diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx new file mode 100644 index 0000000000..0b9eb7b6c2 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx @@ -0,0 +1,208 @@ +import React, { useState, useContext } from 'react'; +import { + IFeatureStrategy, + IFeatureStrategyParameters, + IStrategyParameter, +} from 'interfaces/strategy'; +import { FeatureStrategyType } from '../FeatureStrategyType/FeatureStrategyType'; +import { FeatureStrategyEnabled } from '../FeatureStrategyEnabled/FeatureStrategyEnabled'; +import { FeatureStrategyConstraints } from '../FeatureStrategyConstraints/FeatureStrategyConstraints'; +import { Button } from '@mui/material'; +import { + FeatureStrategyProdGuard, + useFeatureStrategyProdGuard, +} from '../FeatureStrategyProdGuard/FeatureStrategyProdGuard'; +import { IFeatureToggle } from 'interfaces/featureToggle'; +import { useStyles } from './FeatureStrategyForm.styles'; +import { formatFeaturePath } from '../FeatureStrategyEdit/FeatureStrategyEdit'; +import { useNavigate } from 'react-router-dom'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { STRATEGY_FORM_SUBMIT_ID } from 'utils/testIds'; +import { useConstraintsValidation } from 'hooks/api/getters/useConstraintsValidation/useConstraintsValidation'; +import AccessContext from 'contexts/AccessContext'; +import PermissionButton from 'component/common/PermissionButton/PermissionButton'; +import { FeatureStrategySegment } from 'component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegment'; +import { ISegment } from 'interfaces/segment'; +import { IFormErrors } from 'hooks/useFormErrors'; +import { validateParameterValue } from 'utils/validateParameterValue'; +import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy'; + +interface IFeatureStrategyFormProps { + feature: IFeatureToggle; + environmentId: string; + permission: string; + onSubmit: () => void; + loading: boolean; + strategy: Partial; + setStrategy: React.Dispatch< + React.SetStateAction> + >; + segments: ISegment[]; + setSegments: React.Dispatch>; + errors: IFormErrors; +} + +export const FeatureStrategyForm = ({ + feature, + environmentId, + permission, + onSubmit, + loading, + strategy, + setStrategy, + segments, + setSegments, + errors, +}: IFeatureStrategyFormProps) => { + const { classes: styles } = useStyles(); + const [showProdGuard, setShowProdGuard] = useState(false); + const hasValidConstraints = useConstraintsValidation(strategy.constraints); + const enableProdGuard = useFeatureStrategyProdGuard(feature, environmentId); + const { hasAccess } = useContext(AccessContext); + const { strategyDefinition } = useStrategy(strategy?.name); + const navigate = useNavigate(); + + const { + uiConfig, + error: uiConfigError, + loading: uiConfigLoading, + } = useUiConfig(); + + if (uiConfigError) { + throw uiConfigError; + } + + if (uiConfigLoading || !strategyDefinition) { + return null; + } + + const findParameterDefinition = (name: string): IStrategyParameter => { + return strategyDefinition.parameters.find(parameterDefinition => { + return parameterDefinition.name === name; + })!; + }; + + const validateParameter = ( + name: string, + value: IFeatureStrategyParameters[string] + ): boolean => { + const parameterValueError = validateParameterValue( + findParameterDefinition(name), + value + ); + if (parameterValueError) { + errors.setFormError(name, parameterValueError); + return false; + } else { + errors.removeFormError(name); + return true; + } + }; + + const validateAllParameters = (): boolean => { + return strategyDefinition.parameters + .map(parameter => parameter.name) + .map(name => validateParameter(name, strategy.parameters?.[name])) + .every(Boolean); + }; + + const onCancel = () => { + navigate(formatFeaturePath(feature.project, feature.name)); + }; + + const onSubmitWithValidation = async (event: React.FormEvent) => { + event.preventDefault(); + if (!validateAllParameters()) { + return; + } else if (enableProdGuard) { + setShowProdGuard(true); + } else { + onSubmit(); + } + }; + + return ( +
    +
    + +
    +
    + + } + /> + + } + /> + } + /> + +
    +
    + + Save strategy + + + setShowProdGuard(false)} + onClick={onSubmit} + loading={loading} + label="Save strategy" + /> +
    + + ); +}; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyIcon/FeatureStrategyIcon.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyIcon/FeatureStrategyIcon.tsx new file mode 100644 index 0000000000..390266797b --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyIcon/FeatureStrategyIcon.tsx @@ -0,0 +1,35 @@ +import { + getFeatureStrategyIcon, + formatStrategyName, +} from 'utils/strategyNames'; +import { styled, Tooltip } from '@mui/material'; + +interface IFeatureStrategyIconProps { + strategyName: string; +} + +export const FeatureStrategyIcon = ({ + strategyName, +}: IFeatureStrategyIconProps) => { + const Icon = getFeatureStrategyIcon(strategyName); + + return ( + + + + + + ); +}; + +const StyledIcon = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: theme.palette.inactiveIcon, + + '& svg': { + width: theme.spacing(2.5), + height: theme.spacing(2.5), + }, +})); diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyIcons/FeatureStrategyIcons.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyIcons/FeatureStrategyIcons.tsx new file mode 100644 index 0000000000..63e011a374 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyIcons/FeatureStrategyIcons.tsx @@ -0,0 +1,38 @@ +import { IFeatureStrategy } from 'interfaces/strategy'; +import { FeatureStrategyIcon } from 'component/feature/FeatureStrategy/FeatureStrategyIcon/FeatureStrategyIcon'; +import { styled } from '@mui/material'; + +interface IFeatureStrategyIconsProps { + strategies: IFeatureStrategy[] | undefined; +} + +export const FeatureStrategyIcons = ({ + strategies, +}: IFeatureStrategyIconsProps) => { + if (!strategies?.length) { + return null; + } + + return ( + + {strategies.map(strategy => ( + + + + ))} + + ); +}; + +const StyledList = styled('ul')(() => ({ + all: 'unset', + display: 'flex', + alignItems: 'center', + alignContent: 'center', +})); + +const StyledListItem = styled('li')(() => ({ + all: 'unset', + minWidth: 30, + textAlign: 'center', +})); diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu.tsx new file mode 100644 index 0000000000..481925b40b --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu.tsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; +import PermissionButton, { + IPermissionButtonProps, +} from 'component/common/PermissionButton/PermissionButton'; +import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions'; +import { Popover } from '@mui/material'; +import { FeatureStrategyMenuCards } from './FeatureStrategyMenuCards/FeatureStrategyMenuCards'; + +interface IFeatureStrategyMenuProps { + label: string; + projectId: string; + featureId: string; + environmentId: string; + variant?: IPermissionButtonProps['variant']; +} + +export const FeatureStrategyMenu = ({ + label, + projectId, + featureId, + environmentId, + variant, +}: IFeatureStrategyMenuProps) => { + const [anchor, setAnchor] = useState(); + const isPopoverOpen = Boolean(anchor); + const popoverId = isPopoverOpen ? 'FeatureStrategyMenuPopover' : undefined; + + const onClose = () => { + setAnchor(undefined); + }; + + const onClick = (event: React.SyntheticEvent) => { + setAnchor(event.currentTarget); + }; + + return ( +
    event.stopPropagation()}> + + {label} + + + + +
    + ); +}; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenuCard/FeatureStrategyMenuCard.styles.ts b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenuCard/FeatureStrategyMenuCard.styles.ts new file mode 100644 index 0000000000..aaf4e3c187 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenuCard/FeatureStrategyMenuCard.styles.ts @@ -0,0 +1,40 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + card: { + display: 'grid', + gridTemplateColumns: '3rem 1fr', + width: '20rem', + padding: '1rem', + color: 'inherit', + textDecoration: 'inherit', + lineHeight: 1.25, + borderWidth: 1, + borderStyle: 'solid', + borderColor: theme.palette.grey[400], + borderRadius: theme.spacing(1), + '&:hover, &:focus': { + borderColor: theme.palette.primary.main, + }, + }, + icon: { + width: '2rem', + height: 'auto', + '& > svg': { + // Styling for SVG icons. + fill: theme.palette.primary.main, + }, + '& > div': { + // Styling for the Rollout icon. + height: '1rem', + marginLeft: '-.75rem', + color: theme.palette.primary.main, + }, + }, + name: { + fontWeight: theme.fontWeight.bold, + }, + description: { + fontSize: theme.fontSizes.smallBody, + }, +})); diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenuCard/FeatureStrategyMenuCard.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenuCard/FeatureStrategyMenuCard.tsx new file mode 100644 index 0000000000..8bfbe95be1 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenuCard/FeatureStrategyMenuCard.tsx @@ -0,0 +1,51 @@ +import { IStrategy } from 'interfaces/strategy'; +import { Link } from 'react-router-dom'; +import { useStyles } from './FeatureStrategyMenuCard.styles'; +import { + getFeatureStrategyIcon, + formatStrategyName, +} from 'utils/strategyNames'; +import { formatCreateStrategyPath } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate'; +import StringTruncator from 'component/common/StringTruncator/StringTruncator'; + +interface IFeatureStrategyMenuCardProps { + projectId: string; + featureId: string; + environmentId: string; + strategy: IStrategy; +} + +export const FeatureStrategyMenuCard = ({ + projectId, + featureId, + environmentId, + strategy, +}: IFeatureStrategyMenuCardProps) => { + const { classes: styles } = useStyles(); + const StrategyIcon = getFeatureStrategyIcon(strategy.name); + const strategyName = formatStrategyName(strategy.name); + + const createStrategyPath = formatCreateStrategyPath( + projectId, + featureId, + environmentId, + strategy.name + ); + + return ( + +
    + +
    +
    + +
    {strategy.description}
    +
    + + ); +}; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenuCards/FeatureStrategyMenuCards.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenuCards/FeatureStrategyMenuCards.tsx new file mode 100644 index 0000000000..e58fc6d838 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenuCards/FeatureStrategyMenuCards.tsx @@ -0,0 +1,37 @@ +import { useMemo } from 'react'; +import { List, ListItem } from '@mui/material'; +import { useStrategies } from 'hooks/api/getters/useStrategies/useStrategies'; +import { FeatureStrategyMenuCard } from '../FeatureStrategyMenuCard/FeatureStrategyMenuCard'; + +interface IFeatureStrategyMenuCardsProps { + projectId: string; + featureId: string; + environmentId: string; +} + +export const FeatureStrategyMenuCards = ({ + projectId, + featureId, + environmentId, +}: IFeatureStrategyMenuCardsProps) => { + const { strategies } = useStrategies(); + + const availableStrategies = useMemo(() => { + return strategies.filter(strategy => !strategy.deprecated); + }, [strategies]); + + return ( + + {availableStrategies.map(strategy => ( + + + + ))} + + ); +}; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyProdGuard/FeatureStrategyProdGuard.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyProdGuard/FeatureStrategyProdGuard.tsx new file mode 100644 index 0000000000..96131364ee --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyProdGuard/FeatureStrategyProdGuard.tsx @@ -0,0 +1,88 @@ +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import { Alert } from '@mui/material'; +import { Checkbox, FormControlLabel } from '@mui/material'; +import { PRODUCTION } from 'constants/environmentTypes'; +import { IFeatureToggle } from 'interfaces/featureToggle'; +import { createPersistentGlobalStateHook } from 'hooks/usePersistentGlobalState'; + +interface IFeatureStrategyProdGuardProps { + open: boolean; + onClick: () => void; + onClose: () => void; + label: string; + loading: boolean; +} + +interface IFeatureStrategyProdGuardSettings { + hide: boolean; +} + +export const FeatureStrategyProdGuard = ({ + open, + onClose, + onClick, + label, + loading, +}: IFeatureStrategyProdGuardProps) => { + const [settings, setSettings] = useFeatureStrategyProdGuardSettings(); + + const toggleHideSetting = () => { + setSettings(prev => ({ hide: !prev.hide })); + }; + + return ( + + + WARNING. You are about to make changes to a production + environment. These changes will affect your customers. + +

    + Are you sure you want to proceed? +

    + + } + /> +
    + ); +}; + +// Check if the prod guard dialog should be enabled. +export const useFeatureStrategyProdGuard = ( + feature: IFeatureToggle, + environmentId: string +): boolean => { + const [settings] = useFeatureStrategyProdGuardSettings(); + + const environment = feature.environments.find(environment => { + return environment.name === environmentId; + }); + + if (settings.hide) { + return false; + } + + return environment?.type === PRODUCTION; +}; + +// Store the "always hide" prod guard dialog setting in localStorage. +const localStorageKey = 'useFeatureStrategyProdGuardSettings:v2'; + +const useFeatureStrategyProdGuardSettings = + createPersistentGlobalStateHook( + localStorageKey, + { hide: false } + ); diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyRemove/FeatureStrategyRemove.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyRemove/FeatureStrategyRemove.tsx new file mode 100644 index 0000000000..5c9e7c5955 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyRemove/FeatureStrategyRemove.tsx @@ -0,0 +1,109 @@ +import React, { useState } from 'react'; +import useFeatureStrategyApi from 'hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { useNavigate } from 'react-router-dom'; +import useToast from 'hooks/useToast'; +import { formatFeaturePath } from '../FeatureStrategyEdit/FeatureStrategyEdit'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import { Alert } from '@mui/material'; +import PermissionButton from 'component/common/PermissionButton/PermissionButton'; +import { DELETE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions'; +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import { STRATEGY_FORM_REMOVE_ID } from 'utils/testIds'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; +import { Delete } from '@mui/icons-material'; + +interface IFeatureStrategyRemoveProps { + projectId: string; + featureId: string; + environmentId: string; + strategyId: string; + disabled?: boolean; + icon?: boolean; +} + +export const FeatureStrategyRemove = ({ + projectId, + featureId, + environmentId, + strategyId, + disabled, + icon, +}: IFeatureStrategyRemoveProps) => { + const [openDialogue, setOpenDialogue] = useState(false); + const { deleteStrategyFromFeature } = useFeatureStrategyApi(); + const { refetchFeature } = useFeature(projectId, featureId); + const { setToastData, setToastApiError } = useToast(); + const navigate = useNavigate(); + + const onRemove = async (event: React.FormEvent) => { + try { + event.preventDefault(); + await deleteStrategyFromFeature( + projectId, + featureId, + environmentId, + strategyId + ); + setToastData({ + title: 'Strategy deleted', + type: 'success', + }); + refetchFeature(); + navigate(formatFeaturePath(projectId, featureId)); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + return ( + <> + setOpenDialogue(true)} + projectId={projectId} + environmentId={environmentId} + disabled={disabled} + permission={DELETE_FEATURE_STRATEGY} + data-testid={STRATEGY_FORM_REMOVE_ID} + tooltipProps={{ title: 'Remove strategy' }} + type="button" + > + + + } + elseShow={ + setOpenDialogue(true)} + projectId={projectId} + environmentId={environmentId} + disabled={disabled} + permission={DELETE_FEATURE_STRATEGY} + data-testid={STRATEGY_FORM_REMOVE_ID} + color="secondary" + variant="text" + type="button" + > + Remove strategy + + } + /> + setOpenDialogue(false)} + > + + Removing the strategy will change which users receive access + to the feature. + + + + ); +}; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegment.styles.ts b/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegment.styles.ts new file mode 100644 index 0000000000..4759618392 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegment.styles.ts @@ -0,0 +1,9 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + divider: { + border: `1px dashed ${theme.palette.divider}`, + marginTop: theme.spacing(1), + marginBottom: theme.spacing(2), + }, +})); diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegment.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegment.tsx new file mode 100644 index 0000000000..6320b7238a --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegment.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { useSegments } from 'hooks/api/getters/useSegments/useSegments'; +import { ISegment } from 'interfaces/segment'; +import { + AutocompleteBox, + IAutocompleteBoxOption, +} from 'component/common/AutocompleteBox/AutocompleteBox'; +import { FeatureStrategySegmentList } from 'component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentList'; +import { useStyles } from 'component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegment.styles'; +import { SegmentDocsStrategyWarning } from 'component/segments/SegmentDocs/SegmentDocs'; +import { useSegmentLimits } from 'hooks/api/getters/useSegmentLimits/useSegmentLimits'; +import { Divider, Typography } from '@mui/material'; + +interface IFeatureStrategySegmentProps { + segments: ISegment[]; + setSegments: React.Dispatch>; +} + +export const FeatureStrategySegment = ({ + segments: selectedSegments, + setSegments: setSelectedSegments, +}: IFeatureStrategySegmentProps) => { + const { segments: allSegments } = useSegments(); + const { classes: styles } = useStyles(); + const { strategySegmentsLimit } = useSegmentLimits(); + + const atStrategySegmentsLimit: boolean = Boolean( + strategySegmentsLimit && + selectedSegments.length >= strategySegmentsLimit + ); + + if (!allSegments || allSegments.length === 0) { + return null; + } + + const unusedSegments = allSegments.filter(segment => { + return !selectedSegments.find(selected => selected.id === segment.id); + }); + + const autocompleteOptions = unusedSegments.map(segment => ({ + value: String(segment.id), + label: segment.name, + })); + + const onChange = ([option]: IAutocompleteBoxOption[]) => { + const selectedSegment = allSegments.find(segment => { + return String(segment.id) === option.value; + }); + if (selectedSegment) { + setSelectedSegments(prev => [...prev, selectedSegment]); + } + }; + + return ( + <> + + Segmentation + + {atStrategySegmentsLimit && } +

    Add a predefined segment to constrain this feature toggle:

    + + + + + ); +}; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentChip.styles.ts b/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentChip.styles.ts new file mode 100644 index 0000000000..76c1437905 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentChip.styles.ts @@ -0,0 +1,29 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + chip: { + display: 'flex', + alignItems: 'center', + gap: '0.25rem', + paddingInlineStart: '1rem', + paddingInlineEnd: '0.5rem', + paddingBlockStart: 4, + paddingBlockEnd: 4, + borderRadius: '100rem', + background: theme.palette.featureStrategySegmentChipBackground, + color: 'white', + }, + link: { + marginRight: '.5rem', + color: 'inherit', + textDecoration: 'none', + }, + button: { + all: 'unset', + height: '1rem', + cursor: 'pointer', + }, + icon: { + fontSize: '1rem', + }, +})); diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentChip.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentChip.tsx new file mode 100644 index 0000000000..5ebece812a --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentChip.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { ISegment } from 'interfaces/segment'; +import { Clear, VisibilityOff, Visibility } from '@mui/icons-material'; +import { useStyles } from './FeatureStrategySegmentChip.styles'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { constraintAccordionListId } from 'component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList'; +import { Tooltip } from '@mui/material'; + +interface IFeatureStrategySegmentListProps { + segment: ISegment; + setSegments: React.Dispatch>; + preview?: ISegment; + setPreview: React.Dispatch>; +} + +export const FeatureStrategySegmentChip = ({ + segment, + setSegments, + preview, + setPreview, +}: IFeatureStrategySegmentListProps) => { + const { classes: styles } = useStyles(); + + const onRemove = () => { + setSegments(prev => { + return prev.filter(s => s.id !== segment.id); + }); + setPreview(prev => { + return prev === segment ? undefined : prev; + }); + }; + + const onTogglePreview = () => { + setPreview(prev => { + return prev === segment ? undefined : segment; + }); + }; + + const togglePreviewIcon = ( + } + elseShow={} + /> + ); + + const previewIconTooltip = + segment === preview + ? 'Hide segment constraints' + : 'Preview segment constraints'; + + return ( + + + {segment.name} + + + + + + + + + ); +}; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentList.styles.ts b/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentList.styles.ts new file mode 100644 index 0000000000..daba34f3a4 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentList.styles.ts @@ -0,0 +1,29 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + title: { + margin: 0, + fontSize: theme.fontSizes.bodySize, + fontWeight: theme.fontWeight.thin, + }, + list: { + display: 'flex', + flexWrap: 'wrap', + gap: '0.5rem', + }, + and: { + fontSize: theme.fontSizes.smallerBody, + padding: theme.spacing(0.75, 1), + display: 'block', + marginTop: 'auto', + marginBottom: 'auto', + alignItems: 'center', + borderRadius: theme.shape.borderRadius, + lineHeight: 1, + color: theme.palette.text.primary, + backgroundColor: theme.palette.secondaryContainer, + }, + selectedSegmentsLabel: { + color: theme.palette.text.secondary, + }, +})); diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentList.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentList.tsx new file mode 100644 index 0000000000..ac04eaf5b2 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentList.tsx @@ -0,0 +1,57 @@ +import React, { Fragment, useState } from 'react'; +import { ISegment } from 'interfaces/segment'; +import { useStyles } from 'component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentList.styles'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { FeatureStrategySegmentChip } from 'component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentChip'; +import { SegmentItem } from 'component/common/SegmentItem/SegmentItem'; + +interface IFeatureStrategySegmentListProps { + segments: ISegment[]; + setSegments: React.Dispatch>; +} + +export const FeatureStrategySegmentList = ({ + segments, + setSegments, +}: IFeatureStrategySegmentListProps) => { + const { classes: styles } = useStyles(); + const [preview, setPreview] = useState(); + const lastSegmentIndex = segments.length - 1; + + if (segments.length === 0) { + return null; + } + + return ( + <> + 0} + show={ +

    + Selected Segments +

    + } + /> +
    + {segments.map((segment, i) => ( + + + AND} + /> + + ))} +
    + } + /> + + ); +}; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyType/FeatureStrategyType.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyType/FeatureStrategyType.tsx new file mode 100644 index 0000000000..3ea0fc8c94 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyType/FeatureStrategyType.tsx @@ -0,0 +1,74 @@ +import { IFeatureStrategy, IStrategy } from 'interfaces/strategy'; +import DefaultStrategy from 'component/feature/StrategyTypes/DefaultStrategy/DefaultStrategy'; +import FlexibleStrategy from 'component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy'; +import UserWithIdStrategy from 'component/feature/StrategyTypes/UserWithIdStrategy/UserWithId'; +import GeneralStrategy from 'component/feature/StrategyTypes/GeneralStrategy/GeneralStrategy'; +import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext'; +import produce from 'immer'; +import React from 'react'; +import { IFormErrors } from 'hooks/useFormErrors'; + +interface IFeatureStrategyTypeProps { + hasAccess: boolean; + strategy: Partial; + strategyDefinition: IStrategy; + setStrategy: React.Dispatch< + React.SetStateAction> + >; + validateParameter: (name: string, value: string) => boolean; + errors: IFormErrors; +} + +export const FeatureStrategyType = ({ + hasAccess, + strategy, + strategyDefinition, + setStrategy, + validateParameter, + errors, +}: IFeatureStrategyTypeProps) => { + const { context } = useUnleashContext(); + + const updateParameter = (name: string, value: string) => { + setStrategy( + produce(draft => { + draft.parameters = draft.parameters ?? {}; + draft.parameters[name] = value; + }) + ); + validateParameter(name, value); + }; + + switch (strategy.name) { + case 'default': + return ; + case 'flexibleRollout': + return ( + + ); + case 'userWithId': + return ( + + ); + default: + return ( + + ); + } +}; diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureStaleCell/FeatureStaleCell.styles.ts b/frontend/src/component/feature/FeatureToggleList/FeatureStaleCell/FeatureStaleCell.styles.ts new file mode 100644 index 0000000000..c4e36378e1 --- /dev/null +++ b/frontend/src/component/feature/FeatureToggleList/FeatureStaleCell/FeatureStaleCell.styles.ts @@ -0,0 +1,11 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + status: { + color: theme.palette.success.dark, + fontSize: 'inherit', + }, + stale: { + color: theme.palette.error.dark, + }, +})); diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureStaleCell/FeatureStaleCell.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureStaleCell/FeatureStaleCell.tsx new file mode 100644 index 0000000000..2913caa96f --- /dev/null +++ b/frontend/src/component/feature/FeatureToggleList/FeatureStaleCell/FeatureStaleCell.tsx @@ -0,0 +1,23 @@ +import { VFC } from 'react'; +import { Box, Typography } from '@mui/material'; +import { useStyles } from './FeatureStaleCell.styles'; +import classnames from 'classnames'; + +interface IFeatureStaleCellProps { + value?: boolean; +} + +export const FeatureStaleCell: VFC = ({ value }) => { + const { classes: styles } = useStyles(); + return ( + + + {value ? 'Stale' : 'Active'} + + + ); +}; diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/FeatureToggleListItem.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/FeatureToggleListItem.tsx new file mode 100644 index 0000000000..b2e6bbf20d --- /dev/null +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/FeatureToggleListItem.tsx @@ -0,0 +1,208 @@ +import { memo } from 'react'; +import classnames from 'classnames'; +import { Link } from 'react-router-dom'; +import { Chip, ListItem, Tooltip } from '@mui/material'; +import { Undo } from '@mui/icons-material'; +import TimeAgo from 'react-timeago'; +import { IAccessContext } from 'contexts/AccessContext'; +import StatusChip from 'component/common/StatusChip/StatusChip'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions'; +import { IFlags } from 'interfaces/uiConfig'; +import { getTogglePath } from 'utils/routePathHelpers'; +import FeatureStatus from 'component/feature/FeatureView/FeatureStatus/FeatureStatus'; +import FeatureType from 'component/feature/FeatureView/FeatureType/FeatureType'; +import useProjects from 'hooks/api/getters/useProjects/useProjects'; +import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; +import { FeatureSchema } from 'openapi'; +import { styles as themeStyles } from 'component/common'; +import { useStyles } from './styles'; + +interface IFeatureToggleListItemProps { + feature: FeatureSchema; + onRevive?: (id: string) => void; + hasAccess: IAccessContext['hasAccess']; + flags?: IFlags; + inProject?: boolean; + className?: string; +} + +/** + * @deprecated + */ +export const FeatureToggleListItem = memo( + ({ + feature, + onRevive, + hasAccess, + flags = {}, + inProject, + className, + ...rest + }) => { + const { classes: styles } = useStyles(); + + const { projects } = useProjects(); + const isArchive = Boolean(onRevive); + + const { + name, + description, + type, + stale, + createdAt, + project, + lastSeenAt, + } = feature; + + const projectExists = () => { + let projectExist = projects.find(proj => proj.id === project); + if (projectExist) { + return true; + } + return false; + }; + + const reviveFeature = () => { + if (projectExists() && onRevive) { + onRevive(feature.name); + } + }; + + return ( + + + + + + + + + + + + {name}  + + + {/* */} + + + ( + + )} + /> + +
    + + {description} + +
    + + } + elseShow={ + <> + + + {name} {' '} + + + {/* */} + + + ( + + )} + /> + +
    + + {description} + +
    + + } + /> +
    + + + + + + } + /> + + + + + } + /> +
    + ); + } +); diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/__snapshots__/FeatureToggleListItem.test.jsx.snap b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/__snapshots__/FeatureToggleListItem.test.jsx.snap new file mode 100644 index 0000000000..421a37748f --- /dev/null +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/__snapshots__/FeatureToggleListItem.test.jsx.snap @@ -0,0 +1,291 @@ +// Vitest Snapshot v1 + +exports[`renders correctly with one feature 1`] = ` +[ +
  • + +
    + + ⊕ + +
    +
    + + + + + + + + + Another +   + + + + + +
    + + + another's description + + +
    +
    +
    + + +
    + + default + +
    +
    +
    +
  • , +
    , +] +`; + +exports[`renders correctly with one feature without permission 1`] = ` +[ +
  • + +
    + + ⊕ + +
    +
    + + + + + + + + + Another +   + + + + + +
    + + + another's description + + +
    +
    +
    + + +
    + +
    +
    +
    +
  • , +
    , +] +`; diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/styles.ts b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/styles.ts new file mode 100644 index 0000000000..25015eee0f --- /dev/null +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/styles.ts @@ -0,0 +1,37 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + listItem: { + padding: '0', + margin: '1rem 0', + '&:hover': { + backgroundColor: theme.palette.grey[200], + }, + }, + listItemMetric: { + width: '40px', + marginRight: '0.25rem', + flexShrink: 0, + }, + listItemType: { + width: '40px', + textAlign: 'center', + marginRight: '0', + flexShrink: 0, + }, + listItemSvg: { + fill: theme.palette.grey[300], + }, + listItemLink: { + marginLeft: '0.25rem', + minWidth: '0', + }, + listItemStrategies: { + marginLeft: 'auto', + display: 'flex', + alignItems: 'center', + }, + disabledLink: { + pointerEvents: 'none', + }, +})); diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx new file mode 100644 index 0000000000..fb42b64590 --- /dev/null +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx @@ -0,0 +1,266 @@ +import { useEffect, useMemo, useState, VFC } from 'react'; +import { Link, useMediaQuery, useTheme } from '@mui/material'; +import { Link as RouterLink, useSearchParams } from 'react-router-dom'; +import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table'; +import { TablePlaceholder, VirtualizedTable } from 'component/common/Table'; +import { useFeatures } from 'hooks/api/getters/useFeatures/useFeatures'; +import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; +import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell'; +import { FeatureSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureSeenCell'; +import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell'; +import { FeatureNameCell } from 'component/common/Table/cells/FeatureNameCell/FeatureNameCell'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { sortTypes } from 'utils/sortTypes'; +import { createLocalStorage } from 'utils/createLocalStorage'; +import { FeatureSchema } from 'openapi'; +import { CreateFeatureButton } from '../CreateFeatureButton/CreateFeatureButton'; +import { FeatureStaleCell } from './FeatureStaleCell/FeatureStaleCell'; +import { useSearch } from 'hooks/useSearch'; +import { Search } from 'component/common/Search/Search'; + +export const featuresPlaceholder: FeatureSchema[] = Array(15).fill({ + name: 'Name of the feature', + description: 'Short description of the feature', + type: '-', + createdAt: new Date(2022, 1, 1), + project: 'projectID', +}); + +export type PageQueryType = Partial< + Record<'sort' | 'order' | 'search', string> +>; + +const columns = [ + { + Header: 'Seen', + accessor: 'lastSeenAt', + Cell: FeatureSeenCell, + sortType: 'date', + align: 'center', + maxWidth: 85, + }, + { + Header: 'Type', + accessor: 'type', + Cell: FeatureTypeCell, + align: 'center', + maxWidth: 85, + }, + { + Header: 'Name', + accessor: 'name', + minWidth: 150, + Cell: FeatureNameCell, + sortType: 'alphanumeric', + searchable: true, + }, + { + Header: 'Created', + accessor: 'createdAt', + Cell: DateCell, + sortType: 'date', + maxWidth: 150, + }, + { + Header: 'Project ID', + accessor: 'project', + Cell: ({ value }: { value: string }) => ( + + ), + sortType: 'alphanumeric', + maxWidth: 150, + filterName: 'project', + searchable: true, + }, + { + Header: 'State', + accessor: 'stale', + Cell: FeatureStaleCell, + sortType: 'boolean', + maxWidth: 120, + filterName: 'state', + filterParsing: (value: any) => (value ? 'stale' : 'active'), + }, + // Always hidden -- for search + { + accessor: 'description', + }, +]; + +const defaultSort: SortingRule = { id: 'createdAt' }; + +const { value: storedParams, setValue: setStoredParams } = createLocalStorage( + 'FeatureToggleListTable:v1', + defaultSort +); + +export const FeatureToggleListTable: VFC = () => { + const theme = useTheme(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg')); + const { features = [], loading } = useFeatures(); + const [searchParams, setSearchParams] = useSearchParams(); + const [initialState] = useState(() => ({ + sortBy: [ + { + id: searchParams.get('sort') || storedParams.id, + desc: searchParams.has('order') + ? searchParams.get('order') === 'desc' + : storedParams.desc, + }, + ], + hiddenColumns: ['description'], + globalFilter: searchParams.get('search') || '', + })); + const [searchValue, setSearchValue] = useState(initialState.globalFilter); + + const { + data: searchedData, + getSearchText, + getSearchContext, + } = useSearch(columns, searchValue, features); + + const data = useMemo( + () => + searchedData?.length === 0 && loading + ? featuresPlaceholder + : searchedData, + [searchedData, loading] + ); + + const { + headerGroups, + rows, + prepareRow, + state: { sortBy }, + setHiddenColumns, + } = useTable( + { + columns, + data, + initialState, + sortTypes, + autoResetSortBy: false, + disableSortRemove: true, + disableMultiSort: true, + }, + useSortBy, + useFlexLayout + ); + + useEffect(() => { + const hiddenColumns = ['description']; + if (isMediumScreen) { + hiddenColumns.push('lastSeenAt', 'stale'); + } + if (isSmallScreen) { + hiddenColumns.push('type', 'createdAt'); + } + setHiddenColumns(hiddenColumns); + }, [setHiddenColumns, isSmallScreen, isMediumScreen]); + + useEffect(() => { + const tableState: PageQueryType = {}; + tableState.sort = sortBy[0].id; + if (sortBy[0].desc) { + tableState.order = 'desc'; + } + if (searchValue) { + tableState.search = searchValue; + } + + setSearchParams(tableState, { + replace: true, + }); + setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false }); + }, [sortBy, searchValue, setSearchParams]); + + return ( + + + + + + } + /> + + View archive + + + + } + > + + } + /> + + } + > + + + + 0} + show={ + + No feature toggles found matching “ + {searchValue} + ” + + } + elseShow={ + + No feature toggles available. Get started by + adding a new feature toggle. + + } + /> + } + /> + + ); +}; diff --git a/frontend/src/component/feature/FeatureToggleList/__snapshots__/FeatureToggleList.test.jsx.snap b/frontend/src/component/feature/FeatureToggleList/__snapshots__/FeatureToggleList.test.jsx.snap new file mode 100644 index 0000000000..1b3a7d58b5 --- /dev/null +++ b/frontend/src/component/feature/FeatureToggleList/__snapshots__/FeatureToggleList.test.jsx.snap @@ -0,0 +1,368 @@ +// Vitest Snapshot v1 + +exports[`renders correctly with one feature 1`] = ` +[ +
    +
    +
    +
    + + + +
    + +
    +
    +
    + + Archive + +
    +
    +
    +
    +
    +

    + Feature toggles +

    +
    +
    +
    +
    +

    + Sorted by: +

    + +
    +
    +
    +
    +
    +
    +
      + +
    +
    +
    +
    , +
    , +] +`; + +exports[`renders correctly with one feature without permissions 1`] = ` +[ +
    +
    +
    +
    + + + +
    + +
    +
    +
    + + Archive + +
    +
    +
    +
    +
    +

    + Feature toggles +

    +
    +
    +
    +
    +

    + Sorted by: +

    + +
    +
    +
    +
    +
    +
    +
      + +
    +
    +
    +
    , +
    + Navigated to Feature toggles +
    , +] +`; diff --git a/frontend/src/component/feature/FeatureView/FeatureLog/FeatureLog.styles.ts b/frontend/src/component/feature/FeatureView/FeatureLog/FeatureLog.styles.ts new file mode 100644 index 0000000000..b2313f433d --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureLog/FeatureLog.styles.ts @@ -0,0 +1,9 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + borderRadius: '12.5px', + backgroundColor: theme.palette.background.paper, + padding: '2rem', + }, +})); diff --git a/frontend/src/component/feature/FeatureView/FeatureLog/FeatureLog.tsx b/frontend/src/component/feature/FeatureView/FeatureLog/FeatureLog.tsx new file mode 100644 index 0000000000..0bd399841b --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureLog/FeatureLog.tsx @@ -0,0 +1,28 @@ +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import { useStyles } from './FeatureLog.styles'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { EventLog } from 'component/events/EventLog/EventLog'; + +const FeatureLog = () => { + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + const { classes: styles } = useStyles(); + const { feature } = useFeature(projectId, featureId); + + if (!feature.name) { + return null; + } + + return ( +
    + +
    + ); +}; + +export default FeatureLog; diff --git a/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetrics.styles.ts b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetrics.styles.ts new file mode 100644 index 0000000000..3ee30c8b52 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetrics.styles.ts @@ -0,0 +1,9 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + mobileMarginTop: { + [theme.breakpoints.down('md')]: { + marginTop: theme.spacing(2), + }, + }, +})); diff --git a/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetrics.tsx b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetrics.tsx new file mode 100644 index 0000000000..cc478ac6db --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetrics.tsx @@ -0,0 +1,138 @@ +import { useFeatureMetricsRaw } from 'hooks/api/getters/useFeatureMetricsRaw/useFeatureMetricsRaw'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { useEffect, useMemo, useState } from 'react'; +import { + FEATURE_METRIC_HOURS_BACK_MAX, + FeatureMetricsHours, +} from './FeatureMetricsHours/FeatureMetricsHours'; +import { IFeatureMetricsRaw } from 'interfaces/featureToggle'; +import { Grid } from '@mui/material'; +import { FeatureMetricsContent } from './FeatureMetricsContent/FeatureMetricsContent'; +import { useQueryStringNumberState } from 'hooks/useQueryStringNumberState'; +import { useQueryStringState } from 'hooks/useQueryStringState'; +import { FeatureMetricsChips } from './FeatureMetricsChips/FeatureMetricsChips'; +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { useStyles } from './FeatureMetrics.styles'; +import { usePageTitle } from 'hooks/usePageTitle'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; + +export const FeatureMetrics = () => { + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + const environments = useFeatureMetricsEnvironments(projectId, featureId); + const applications = useFeatureMetricsApplications(featureId); + const { classes: styles } = useStyles(); + usePageTitle('Metrics'); + + const [hoursBack = FEATURE_METRIC_HOURS_BACK_MAX, setHoursBack] = + useQueryStringNumberState('hoursBack'); + const { featureMetrics } = useFeatureMetricsRaw(featureId, hoursBack); + + // Keep a cache of the fetched metrics so that we can + // show the cached result while fetching new metrics. + const [cachedMetrics, setCachedMetrics] = useState< + Readonly | undefined + >(featureMetrics); + + useEffect(() => { + featureMetrics && setCachedMetrics(featureMetrics); + }, [featureMetrics]); + + const defaultEnvironment = Array.from(environments)[0]; + const defaultApplication = Array.from(applications)[0]; + const [environment = defaultEnvironment, setEnvironment] = + useQueryStringState('environment'); + const [application = defaultApplication, setApplication] = + useQueryStringState('application'); + + const filteredMetrics = useMemo(() => { + return cachedMetrics + ?.filter(metric => metric.environment === environment) + .filter(metric => metric.appName === application); + }, [cachedMetrics, environment, application]); + + if (!filteredMetrics) { + return null; + } + + return ( + + + + 0} + show={ + + } + /> + + + 0} + show={ + + } + /> + + +
    + +
    +
    +
    + +
    + ); +}; + +// Get all the environment names for a feature, +// not just the one's we have metrics for. +const useFeatureMetricsEnvironments = ( + projectId: string, + featureId: string +): Set => { + const { feature } = useFeature(projectId, featureId); + + const environments = feature.environments.map(environment => { + return environment.name; + }); + + return new Set(environments); +}; + +// Get all application names for a feature. Fetch apps for the max time range +// so that the list of apps doesn't change when selecting a shorter range. +const useFeatureMetricsApplications = (featureId: string): Set => { + const { featureMetrics = [] } = useFeatureMetricsRaw( + featureId, + FEATURE_METRIC_HOURS_BACK_MAX + ); + + const applications = featureMetrics.map(metric => { + return metric.appName; + }); + + return new Set(applications); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsChart/FeatureMetricsChart.tsx b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsChart/FeatureMetricsChart.tsx new file mode 100644 index 0000000000..db8b7ab320 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsChart/FeatureMetricsChart.tsx @@ -0,0 +1,72 @@ +import { IFeatureMetricsRaw } from 'interfaces/featureToggle'; +import React, { useMemo } from 'react'; +import { Line } from 'react-chartjs-2'; +import { + CategoryScale, + Chart as ChartJS, + Legend, + LinearScale, + LineElement, + PointElement, + TimeScale, + Title, + Tooltip, +} from 'chart.js'; +import { useLocationSettings } from 'hooks/useLocationSettings'; +import 'chartjs-adapter-date-fns'; +import { createChartData } from './createChartData'; +import { createChartOptions } from './createChartOptions'; + +interface IFeatureMetricsChartProps { + metrics: IFeatureMetricsRaw[]; + hoursBack: number; + statsSectionId: string; +} + +export const FeatureMetricsChart = ({ + metrics, + hoursBack, + statsSectionId, +}: IFeatureMetricsChartProps) => { + const { locationSettings } = useLocationSettings(); + + const sortedMetrics = useMemo(() => { + return [...metrics].sort((metricA, metricB) => { + return metricA.timestamp.localeCompare(metricB.timestamp); + }); + }, [metrics]); + + const options = useMemo(() => { + return createChartOptions(sortedMetrics, hoursBack, locationSettings); + }, [sortedMetrics, hoursBack, locationSettings]); + + const data = useMemo(() => { + return createChartData(sortedMetrics, locationSettings); + }, [sortedMetrics, locationSettings]); + + return ( +
    + +
    + ); +}; + +// Register dependencies that we need to draw the chart. +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + TimeScale, + Legend, + Tooltip, + Title +); + +// Use a default export to lazy-load the charting library. +export default FeatureMetricsChart; diff --git a/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsChart/createChartData.ts b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsChart/createChartData.ts new file mode 100644 index 0000000000..907e9018ce --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsChart/createChartData.ts @@ -0,0 +1,71 @@ +import { IFeatureMetricsRaw } from 'interfaces/featureToggle'; +import { ChartData } from 'chart.js'; +import { ILocationSettings } from 'hooks/useLocationSettings'; +import theme from 'themes/theme'; +import 'chartjs-adapter-date-fns'; + +interface IPoint { + x: string; + y: number; +} + +export const createChartData = ( + metrics: IFeatureMetricsRaw[], + locationSettings: ILocationSettings +): ChartData<'line', IPoint[], string> => { + const requestsSeries = { + label: 'total requests', + borderColor: theme.palette.primary.main, + backgroundColor: theme.palette.primary.main, + data: createChartPoints(metrics, locationSettings, m => m.yes + m.no), + elements: { + point: { + radius: 6, + pointStyle: 'circle', + }, + line: { + borderDash: [8, 4], + }, + }, + }; + + const yesSeries = { + label: 'exposed', + borderColor: theme.palette.success.main, + backgroundColor: theme.palette.success.main, + data: createChartPoints(metrics, locationSettings, m => m.yes), + elements: { + point: { + radius: 6, + pointStyle: 'triangle', + }, + }, + }; + + const noSeries = { + label: 'not exposed', + borderColor: theme.palette.error.main, + backgroundColor: theme.palette.error.main, + data: createChartPoints(metrics, locationSettings, m => m.no), + elements: { + point: { + radius: 6, + pointStyle: 'triangle', + pointRotation: 180, + }, + }, + }; + + return { datasets: [yesSeries, noSeries, requestsSeries] }; +}; + +const createChartPoints = ( + metrics: IFeatureMetricsRaw[], + locationSettings: ILocationSettings, + y: (m: IFeatureMetricsRaw) => number +): IPoint[] => { + return metrics.map(metric => ({ + x: metric.timestamp, + y: y(metric), + })); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsChart/createChartOptions.ts b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsChart/createChartOptions.ts new file mode 100644 index 0000000000..abc1e00a4c --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsChart/createChartOptions.ts @@ -0,0 +1,95 @@ +import { ILocationSettings } from 'hooks/useLocationSettings'; +import 'chartjs-adapter-date-fns'; +import { ChartOptions, defaults } from 'chart.js'; +import { IFeatureMetricsRaw } from 'interfaces/featureToggle'; +import theme from 'themes/theme'; +import { formatDateHM } from 'utils/formatDate'; + +export const createChartOptions = ( + metrics: IFeatureMetricsRaw[], + hoursBack: number, + locationSettings: ILocationSettings +): ChartOptions<'line'> => { + return { + locale: locationSettings.locale, + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: 'index', + intersect: false, + }, + plugins: { + tooltip: { + backgroundColor: 'white', + bodyColor: theme.palette.text.primary, + titleColor: theme.palette.grey[700], + borderColor: theme.palette.primary.main, + borderWidth: 1, + padding: 10, + boxPadding: 5, + usePointStyle: true, + callbacks: { + title: items => + formatDateHM( + items[0].parsed.x, + locationSettings.locale + ), + }, + // Sort tooltip items in the same order as the lines in the chart. + itemSort: (a, b) => b.parsed.y - a.parsed.y, + }, + legend: { + position: 'top', + align: 'end', + labels: { + boxWidth: 10, + boxHeight: 10, + usePointStyle: true, + }, + }, + title: { + text: formatChartLabel(hoursBack), + position: 'top', + align: 'start', + display: true, + font: { + size: 16, + weight: '400', + }, + }, + }, + scales: { + y: { + type: 'linear', + title: { + display: true, + text: 'Number of requests', + }, + ticks: { precision: 0 }, + }, + x: { + type: 'time', + time: { unit: 'hour' }, + grid: { display: false }, + ticks: { + callback: (_, i, data) => + formatDateHM(data[i].value, locationSettings.locale), + }, + }, + }, + }; +}; + +const formatChartLabel = (hoursBack: number): string => { + return hoursBack === 1 + ? 'Requests in the last hour' + : `Requests in the last ${hoursBack} hours`; +}; + +// Set the default font for ticks, legends, tooltips, etc. +defaults.font = { + ...defaults.font, + family: 'Sen', + size: 13, + weight: '400', +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsChips/FeatureMetricsChips.styles.ts b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsChips/FeatureMetricsChips.styles.ts new file mode 100644 index 0000000000..54a14c49c6 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsChips/FeatureMetricsChips.styles.ts @@ -0,0 +1,25 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + title: { + margin: 0, + marginBottom: '.5rem', + fontSize: theme.fontSizes.smallBody, + fontWeight: theme.fontWeight.thin, + color: theme.palette.grey[800], + }, + list: { + display: 'flex', + flexWrap: 'wrap', + gap: '.5rem', + listStyleType: 'none', + padding: 0, + minHeight: '100%', + }, + item: { + '& > [aria-pressed=true]': { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + }, + }, +})); diff --git a/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsChips/FeatureMetricsChips.tsx b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsChips/FeatureMetricsChips.tsx new file mode 100644 index 0000000000..ff7721c003 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsChips/FeatureMetricsChips.tsx @@ -0,0 +1,51 @@ +import { Chip } from '@mui/material'; +import { useMemo } from 'react'; +import { useStyles } from './FeatureMetricsChips.styles'; +import { useThemeStyles } from 'themes/themeStyles'; + +interface IFeatureMetricsChipsProps { + title: string; + values: Set; + value?: string; + setValue: (value: string) => void; +} + +export const FeatureMetricsChips = ({ + title, + values, + value, + setValue, +}: IFeatureMetricsChipsProps) => { + const { classes: themeStyles } = useThemeStyles(); + const { classes: styles } = useStyles(); + + const onClick = (value: string) => () => { + if (values.has(value)) { + setValue(value); + } + }; + + const sortedValues = useMemo(() => { + return Array.from(values).sort((valueA, valueB) => { + return valueA.localeCompare(valueB); + }); + }, [values]); + + return ( +
    +

    {title}

    +
      + {sortedValues.map(val => ( +
    • + +
    • + ))} +
    +
    + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsContent/FeatureMetricsContent.tsx b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsContent/FeatureMetricsContent.tsx new file mode 100644 index 0000000000..2c3ffe64ee --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsContent/FeatureMetricsContent.tsx @@ -0,0 +1,70 @@ +import { FeatureMetricsTable } from '../FeatureMetricsTable/FeatureMetricsTable'; +import { IFeatureMetricsRaw } from 'interfaces/featureToggle'; +import { FeatureMetricsStatsRaw } from '../FeatureMetricsStats/FeatureMetricsStatsRaw'; +import { Box, Typography } from '@mui/material'; +import theme from 'themes/theme'; +import { useId } from 'hooks/useId'; +import React, { Suspense } from 'react'; + +interface IFeatureMetricsContentProps { + metrics: IFeatureMetricsRaw[]; + hoursBack: number; +} + +export const FeatureMetricsContent = ({ + metrics, + hoursBack, +}: IFeatureMetricsContentProps) => { + const statsSectionId = useId(); + const tableSectionId = useId(); + + if (metrics.length === 0) { + return ( + + + We have yet to receive any metrics for this feature toggle + in the selected time period. + + + Please note that, since the SDKs send metrics on an + interval, it might take some time before metrics appear. + + + ); + } + + return ( + + + + + + + + + + + + ); +}; + +const LazyFeatureMetricsChart = React.lazy( + () => import('../FeatureMetricsChart/FeatureMetricsChart') +); diff --git a/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsHours/FeatureMetricsHours.tsx b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsHours/FeatureMetricsHours.tsx new file mode 100644 index 0000000000..36ae7dbd66 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsHours/FeatureMetricsHours.tsx @@ -0,0 +1,57 @@ +import GeneralSelect, { + IGeneralSelectProps, +} from 'component/common/GeneralSelect/GeneralSelect'; + +interface IFeatureMetricsHoursProps { + hoursBack: number; + setHoursBack: (value: number) => void; +} + +export const FEATURE_METRIC_HOURS_BACK_MAX = 48; + +export const FeatureMetricsHours = ({ + hoursBack, + setHoursBack, +}: IFeatureMetricsHoursProps) => { + const onChange: IGeneralSelectProps['onChange'] = key => { + setHoursBack(parseFeatureMetricsHour(key)); + }; + + return ( + + ); +}; + +const parseFeatureMetricsHour = (value: unknown) => { + switch (value) { + case '1': + return 1; + case '24': + return 24; + default: + return FEATURE_METRIC_HOURS_BACK_MAX; + } +}; + +const hourOptions: { key: `${number}`; label: string }[] = [ + { + key: '1', + label: 'Last hour', + }, + { + key: '24', + label: 'Last 24 hours', + }, + { + key: `${FEATURE_METRIC_HOURS_BACK_MAX}`, + label: `Last ${FEATURE_METRIC_HOURS_BACK_MAX} hours`, + }, +]; diff --git a/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsStats/FeatureMetricsStats.styles.ts b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsStats/FeatureMetricsStats.styles.ts new file mode 100644 index 0000000000..09be2d3fee --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsStats/FeatureMetricsStats.styles.ts @@ -0,0 +1,32 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + item: { + padding: theme.spacing(2), + background: theme.palette.featureMetricsBackground, + borderRadius: theme.spacing(2), + textAlign: 'center', + [theme.breakpoints.up('md')]: { + padding: theme.spacing(4), + }, + }, + title: { + margin: 0, + fontSize: theme.fontSizes.bodySize, + fontWeight: theme.fontWeight.thin, + }, + value: { + fontSize: '2.25rem', + fontWeight: theme.fontWeight.bold, + color: theme.palette.primary.main, + }, + text: { + margin: '.5rem 0 0 0', + padding: '1rem 0 0 0', + borderTopWidth: 1, + borderTopStyle: 'solid', + borderTopColor: theme.palette.grey[300], + fontSize: theme.fontSizes.smallerBody, + color: theme.palette.grey[800], + }, +})); diff --git a/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsStats/FeatureMetricsStats.tsx b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsStats/FeatureMetricsStats.tsx new file mode 100644 index 0000000000..03e49fa426 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsStats/FeatureMetricsStats.tsx @@ -0,0 +1,68 @@ +import { calculatePercentage } from 'utils/calculatePercentage'; +import { useStyles } from './FeatureMetricsStats.styles'; +import { Grid } from '@mui/material'; + +export interface IFeatureMetricsStatsProps { + totalYes: number; + totalNo: number; + hoursBack: number; + statsSectionId?: string; + tableSectionId?: string; +} + +export const FeatureMetricsStats = ({ + totalYes, + totalNo, + hoursBack, + statsSectionId, + tableSectionId, +}: IFeatureMetricsStatsProps) => { + const { classes: styles } = useStyles(); + + const hoursSuffix = + hoursBack === 1 ? 'in the last hour' : `in the last ${hoursBack} hours`; + + return ( + + +
    +

    Exposure

    +

    {totalYes}

    +

    + Total exposure of the feature in the environment{' '} + {hoursSuffix}. +

    +
    +
    + +
    +

    Exposure %

    +

    + {calculatePercentage(totalYes + totalNo, totalYes)}% +

    +

    + % total exposure of the feature in the environment{' '} + {hoursSuffix}. +

    +
    +
    + +
    +

    Requests

    +

    {totalYes + totalNo}

    +

    + Total requests for the feature in the environment{' '} + {hoursSuffix}. +

    +
    +
    +
    + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsStats/FeatureMetricsStatsRaw.tsx b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsStats/FeatureMetricsStatsRaw.tsx new file mode 100644 index 0000000000..4e78000939 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsStats/FeatureMetricsStatsRaw.tsx @@ -0,0 +1,28 @@ +import { IFeatureMetricsRaw } from 'interfaces/featureToggle'; +import { useMemo } from 'react'; +import { + FeatureMetricsStats, + IFeatureMetricsStatsProps, +} from './FeatureMetricsStats'; + +interface IFeatureMetricsStatsRawProps + extends Omit { + metrics: IFeatureMetricsRaw[]; +} + +export const FeatureMetricsStatsRaw = ({ + metrics, + ...rest +}: IFeatureMetricsStatsRawProps) => { + const totalYes = useMemo(() => { + return metrics.reduce((acc, m) => acc + m.yes, 0); + }, [metrics]); + + const totalNo = useMemo(() => { + return metrics.reduce((acc, m) => acc + m.no, 0); + }, [metrics]); + + return ( + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsTable/FeatureMetricsTable.tsx b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsTable/FeatureMetricsTable.tsx new file mode 100644 index 0000000000..912a7cc7ca --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsTable/FeatureMetricsTable.tsx @@ -0,0 +1,106 @@ +import { IFeatureMetricsRaw } from 'interfaces/featureToggle'; +import { TableBody, TableRow, useMediaQuery } from '@mui/material'; +import { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; +import { useTable, useGlobalFilter, useSortBy } from 'react-table'; +import { SortableTableHeader, TableCell, Table } from 'component/common/Table'; +import { IconCell } from 'component/common/Table/cells/IconCell/IconCell'; +import { Assessment } from '@mui/icons-material'; +import { useMemo, useEffect } from 'react'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; +import theme from 'themes/theme'; + +interface IFeatureMetricsTableProps { + metrics: IFeatureMetricsRaw[]; + tableSectionId?: string; +} + +export const FeatureMetricsTable = ({ + metrics, + tableSectionId, +}: IFeatureMetricsTableProps) => { + const isMediumScreen = useMediaQuery(theme.breakpoints.down('md')); + + const initialState = useMemo(() => ({ sortBy: [{ id: 'timestamp' }] }), []); + + const { + getTableProps, + getTableBodyProps, + headerGroups, + rows, + prepareRow, + setHiddenColumns, + } = useTable( + { + initialState, + columns: COLUMNS as any, + data: metrics as any, + disableSortRemove: true, + defaultColumn: { Cell: TextCell }, + }, + useGlobalFilter, + useSortBy + ); + + useEffect(() => { + const hiddenColumns = []; + if (isMediumScreen) { + hiddenColumns.push('appName', 'environment'); + } + setHiddenColumns(hiddenColumns); + }, [setHiddenColumns, isMediumScreen]); + + if (metrics.length === 0) { + return null; + } + + return ( + + + + {rows.map(row => { + prepareRow(row); + return ( + + {row.cells.map(cell => ( + + {cell.render('Cell')} + + ))} + + ); + })} + +
    + ); +}; + +const COLUMNS = [ + { + id: 'Icon', + width: '1%', + disableSortBy: true, + Cell: () => } />, + }, + { + Header: 'Time', + accessor: 'timestamp', + Cell: (props: any) => , + }, + { + Header: 'Application', + accessor: 'appName', + }, + { + Header: 'Environment', + accessor: 'environment', + }, + { + id: 'requested', + Header: 'Requested', + accessor: (original: any) => original.yes + original.no, + }, + { + Header: 'Exposed', + accessor: 'yes', + }, +]; diff --git a/frontend/src/component/feature/FeatureView/FeatureNotFound/FeatureNotFound.styles.ts b/frontend/src/component/feature/FeatureView/FeatureNotFound/FeatureNotFound.styles.ts new file mode 100644 index 0000000000..990694cc8c --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureNotFound/FeatureNotFound.styles.ts @@ -0,0 +1,7 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + featureId: { + wordBreak: 'break-all', + }, +})); diff --git a/frontend/src/component/feature/FeatureView/FeatureNotFound/FeatureNotFound.tsx b/frontend/src/component/feature/FeatureView/FeatureNotFound/FeatureNotFound.tsx new file mode 100644 index 0000000000..2aef95d623 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureNotFound/FeatureNotFound.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { getCreateTogglePath } from 'utils/routePathHelpers'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { useStyles } from 'component/feature/FeatureView/FeatureNotFound/FeatureNotFound.styles'; +import { useFeaturesArchive } from 'hooks/api/getters/useFeaturesArchive/useFeaturesArchive'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; + +export const FeatureNotFound = () => { + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + const { archivedFeatures } = useFeaturesArchive(); + const { classes: styles } = useStyles(); + const { uiConfig } = useUiConfig(); + + const createFeatureTogglePath = getCreateTogglePath( + projectId, + uiConfig.flags.E, + { name: featureId } + ); + + if (!archivedFeatures) { + return null; + } + + const isArchived = archivedFeatures.some(archivedFeature => { + return archivedFeature.name === featureId; + }); + + if (isArchived) { + return ( +

    + The feature{' '} + {featureId} has + been archived. You can find it on the{' '} + archive page. +

    + ); + } + + return ( +

    + The feature{' '} + {featureId} does not + exist. Would you like to{' '} + create it? +

    + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/AddTagDialog.styles.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/AddTagDialog.styles.ts new file mode 100644 index 0000000000..c125eab8e1 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/AddTagDialog.styles.ts @@ -0,0 +1,9 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + dialogFormContent: { + ['& > *']: { + margin: '0.5rem 0', + }, + }, +})); diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/AddTagDialog.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/AddTagDialog.tsx new file mode 100644 index 0000000000..a11ed40a90 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/AddTagDialog.tsx @@ -0,0 +1,117 @@ +import { Typography } from '@mui/material'; +import React, { useState } from 'react'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import Input from 'component/common/Input/Input'; +import { useStyles } from './AddTagDialog.styles'; +import { trim } from 'component/common/util'; +import TagSelect from 'component/common/TagSelect/TagSelect'; +import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; +import useTags from 'hooks/api/getters/useTags/useTags'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; + +interface IAddTagDialogProps { + open: boolean; + setOpen: React.Dispatch>; +} + +interface IDefaultTag { + type: string; + value: string; + + [index: string]: string; +} + +const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => { + const DEFAULT_TAG: IDefaultTag = { type: 'simple', value: '' }; + const { classes: styles } = useStyles(); + const featureId = useRequiredPathParam('featureId'); + const { addTagToFeature, loading } = useFeatureApi(); + const { refetch } = useTags(featureId); + const [errors, setErrors] = useState({ tagError: '' }); + const { setToastData } = useToast(); + const [tag, setTag] = useState(DEFAULT_TAG); + + const onCancel = () => { + setOpen(false); + setErrors({ tagError: '' }); + setTag(DEFAULT_TAG); + }; + + const setValue = (field: string, value: string) => { + const newTag = { ...tag }; + newTag[field] = trim(value); + setTag(newTag); + }; + + const onSubmit = async (evt: React.SyntheticEvent) => { + evt.preventDefault(); + if (!tag.type) { + tag.type = 'simple'; + } + try { + await addTagToFeature(featureId, tag); + + setOpen(false); + setTag(DEFAULT_TAG); + refetch(); + setToastData({ + type: 'success', + title: 'Added tag to toggle', + text: 'We successfully added a tag to your toggle', + confetti: true, + }); + } catch (error: unknown) { + const message = formatUnknownError(error); + setErrors({ tagError: message }); + } + }; + + const formId = 'add-tag-form'; + + return ( + <> + + <> + + Tags allow you to group features together + +
    +
    + setValue('type', type)} + /> +
    + + setValue('value', e.target.value) + } + /> +
    +
    + +
    + + ); +}; + +export default AddTagDialog; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.styles.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.styles.ts new file mode 100644 index 0000000000..863a55d8d1 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.styles.ts @@ -0,0 +1,26 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + display: 'flex', + width: '100%', + [theme.breakpoints.down(1000)]: { + flexDirection: 'column', + }, + }, + mainContent: { + display: 'flex', + flexDirection: 'column', + width: `calc(100% - (350px + 1rem))`, + [theme.breakpoints.down(1000)]: { + width: '100%', + }, + }, + trafficContainer: { + display: 'flex', + flexWrap: 'wrap', + [theme.breakpoints.down(1000)]: { + marginTop: '1rem', + }, + }, +})); diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx new file mode 100644 index 0000000000..347d03619d --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx @@ -0,0 +1,63 @@ +import FeatureOverviewMetaData from './FeatureOverviewMetaData/FeatureOverviewMetaData'; +import { useStyles } from './FeatureOverview.styles'; +import FeatureOverviewEnvironments from './FeatureOverviewEnvironments/FeatureOverviewEnvironments'; +import FeatureOverviewEnvSwitches from './FeatureOverviewEnvSwitches/FeatureOverviewEnvSwitches'; +import { Routes, Route, useNavigate } from 'react-router-dom'; +import { FeatureStrategyCreate } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate'; +import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; +import { + FeatureStrategyEdit, + formatFeaturePath, +} from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { usePageTitle } from 'hooks/usePageTitle'; + +const FeatureOverview = () => { + const { classes: styles } = useStyles(); + const navigate = useNavigate(); + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + const featurePath = formatFeaturePath(projectId, featureId); + const onSidebarClose = () => navigate(featurePath); + usePageTitle(featureId); + + return ( +
    +
    + + +
    +
    + +
    + + + + + } + /> + + + + } + /> + +
    + ); +}; + +export default FeatureOverview; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvSwitches/FeatureOverviewEnvSwitch/FeatureOverviewEnvSwitch.styles.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvSwitches/FeatureOverviewEnvSwitch/FeatureOverviewEnvSwitch.styles.ts new file mode 100644 index 0000000000..8c92b9067c --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvSwitches/FeatureOverviewEnvSwitch/FeatureOverviewEnvSwitch.styles.ts @@ -0,0 +1,9 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + label: { + display: 'inline-flex', + alignItems: 'center', + cursor: 'pointer', + }, +})); diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvSwitches/FeatureOverviewEnvSwitch/FeatureOverviewEnvSwitch.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvSwitches/FeatureOverviewEnvSwitch/FeatureOverviewEnvSwitch.tsx new file mode 100644 index 0000000000..d4603f2014 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvSwitches/FeatureOverviewEnvSwitch/FeatureOverviewEnvSwitch.tsx @@ -0,0 +1,111 @@ +import { ENVIRONMENT_STRATEGY_ERROR } from 'constants/apiErrors'; +import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import useToast from 'hooks/useToast'; +import { IFeatureEnvironment } from 'interfaces/featureToggle'; +import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch'; +import StringTruncator from 'component/common/StringTruncator/StringTruncator'; +import { UPDATE_FEATURE_ENVIRONMENT } from 'component/providers/AccessProvider/permissions'; +import React from 'react'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { useStyles } from './FeatureOverviewEnvSwitch.styles'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; + +interface IFeatureOverviewEnvSwitchProps { + env: IFeatureEnvironment; + callback?: () => void; + text?: string; + showInfoBox: () => void; +} + +const FeatureOverviewEnvSwitch = ({ + env, + callback, + text, + showInfoBox, +}: IFeatureOverviewEnvSwitchProps) => { + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + const { toggleFeatureEnvironmentOn, toggleFeatureEnvironmentOff } = + useFeatureApi(); + const { refetchFeature } = useFeature(projectId, featureId); + const { setToastData, setToastApiError } = useToast(); + const { classes: styles } = useStyles(); + + const handleToggleEnvironmentOn = async () => { + try { + await toggleFeatureEnvironmentOn(projectId, featureId, env.name); + setToastData({ + type: 'success', + title: `Available in ${env.name}`, + text: `${featureId} is now available in ${env.name} based on its defined strategies.`, + }); + refetchFeature(); + if (callback) { + callback(); + } + } catch (error: unknown) { + if ( + error instanceof Error && + error.message === ENVIRONMENT_STRATEGY_ERROR + ) { + showInfoBox(); + } else { + setToastApiError(formatUnknownError(error)); + } + } + }; + + const handleToggleEnvironmentOff = async () => { + try { + await toggleFeatureEnvironmentOff(projectId, featureId, env.name); + setToastData({ + type: 'success', + title: `Unavailable in ${env.name}`, + text: `${featureId} is unavailable in ${env.name} and its strategies will no longer have any effect.`, + }); + refetchFeature(); + if (callback) { + callback(); + } + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const toggleEnvironment = async (e: React.ChangeEvent) => { + if (env.enabled) { + await handleToggleEnvironmentOff(); + return; + } + await handleToggleEnvironmentOn(); + }; + + let content = text ? ( + text + ) : ( + <> + {' '} + {env.enabled ? 'enabled' : 'disabled'} in +   + + + ); + + return ( +
    + +
    + ); +}; + +export default FeatureOverviewEnvSwitch; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvSwitches/FeatureOverviewEnvSwitches.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvSwitches/FeatureOverviewEnvSwitches.tsx new file mode 100644 index 0000000000..84fa25d88b --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvSwitches/FeatureOverviewEnvSwitches.tsx @@ -0,0 +1,93 @@ +import { useState } from 'react'; +import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import EnvironmentStrategyDialog from 'component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog'; +import FeatureOverviewEnvSwitch from './FeatureOverviewEnvSwitch/FeatureOverviewEnvSwitch'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { HelpIcon } from 'component/common/HelpIcon/HelpIcon'; +import { styled } from '@mui/material'; + +const StyledContainer = styled('div')(({ theme }) => ({ + borderRadius: theme.shape.borderRadiusLarge, + backgroundColor: theme.palette.background.paper, + display: 'flex', + flexDirection: 'column', + padding: '1.5rem', + maxWidth: '350px', + minWidth: '350px', + marginRight: '1rem', + marginTop: '1rem', + [theme.breakpoints.down(1000)]: { + marginBottom: '1rem', + width: '100%', + maxWidth: 'none', + minWidth: 'auto', + }, +})); + +const StyledHeader = styled('h3')(({ theme }) => ({ + display: 'flex', + gap: theme.spacing(1), + alignItems: 'center', + fontSize: theme.fontSizes.bodySize, + fontWeight: 'normal', + margin: 0, + marginBottom: '0.5rem', + + // Make the help icon align with the text. + '& > :last-child': { + position: 'relative', + top: 1, + }, +})); + +const FeatureOverviewEnvSwitches = () => { + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + const { feature } = useFeature(projectId, featureId); + useFeatureApi(); + + const [showInfoBox, setShowInfoBox] = useState(false); + const [environmentName, setEnvironmentName] = useState(''); + + const closeInfoBox = () => { + setShowInfoBox(false); + }; + + const renderEnvironmentSwitches = () => { + return feature?.environments.map(env => { + return ( + { + setEnvironmentName(env.name); + setShowInfoBox(true); + }} + /> + ); + }); + }; + + return ( + + + Feature toggle status + + + {renderEnvironmentSwitches()} + + + ); +}; + +export default FeatureOverviewEnvSwitches; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/EnvironmentAccordionBody.styles.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/EnvironmentAccordionBody.styles.ts new file mode 100644 index 0000000000..01e1765bca --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/EnvironmentAccordionBody.styles.ts @@ -0,0 +1,14 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + accordionBodyInnerContainer: { + [theme.breakpoints.down(400)]: { + padding: '0.5rem', + }, + }, + accordionBody: { + width: '100%', + position: 'relative', + paddingBottom: '1rem', + }, +})); diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/EnvironmentAccordionBody.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/EnvironmentAccordionBody.tsx new file mode 100644 index 0000000000..4f738d3406 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/EnvironmentAccordionBody.tsx @@ -0,0 +1,174 @@ +import { DragEventHandler, RefObject, useEffect, useState } from 'react'; +import { Alert } from '@mui/material'; +import useFeatureStrategyApi from 'hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import useToast from 'hooks/useToast'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { StrategyDraggableItem } from './StrategyDraggableItem/StrategyDraggableItem'; +import { IFeatureEnvironment } from 'interfaces/featureToggle'; +import { FeatureStrategyEmpty } from 'component/feature/FeatureStrategy/FeatureStrategyEmpty/FeatureStrategyEmpty'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { useStyles } from './EnvironmentAccordionBody.styles'; +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; + +interface IEnvironmentAccordionBodyProps { + isDisabled: boolean; + featureEnvironment?: IFeatureEnvironment; + otherEnvironments?: IFeatureEnvironment['name'][]; +} + +const EnvironmentAccordionBody = ({ + featureEnvironment, + isDisabled, + otherEnvironments, +}: IEnvironmentAccordionBodyProps) => { + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + const { setStrategiesSortOrder } = useFeatureStrategyApi(); + const { setToastData, setToastApiError } = useToast(); + const { refetchFeature } = useFeature(projectId, featureId); + const [strategies, setStrategies] = useState( + featureEnvironment?.strategies || [] + ); + const [dragItem, setDragItem] = useState<{ + id: string; + index: number; + height: number; + } | null>(null); + const { classes: styles } = useStyles(); + useEffect(() => { + // Use state to enable drag and drop, but switch to API output when it arrives + setStrategies(featureEnvironment?.strategies || []); + }, [featureEnvironment?.strategies]); + + if (!featureEnvironment) { + return null; + } + + const onReorder = async (payload: { id: string; sortOrder: number }[]) => { + try { + await setStrategiesSortOrder( + projectId, + featureId, + featureEnvironment.name, + payload + ); + refetchFeature(); + setToastData({ + title: 'Order of strategies updated', + type: 'success', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const onDragStartRef = + ( + ref: RefObject, + index: number + ): DragEventHandler => + event => { + setDragItem({ + id: strategies[index].id, + index, + height: ref.current?.offsetHeight || 0, + }); + + if (ref?.current) { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/html', ref.current.outerHTML); + event.dataTransfer.setDragImage(ref.current, 20, 20); + } + }; + + const onDragOver = + (targetId: string) => + ( + ref: RefObject, + targetIndex: number + ): DragEventHandler => + event => { + if (dragItem === null || ref.current === null) return; + if (dragItem.index === targetIndex || targetId === dragItem.id) + return; + + const { top, bottom } = ref.current.getBoundingClientRect(); + const overTargetTop = event.clientY - top < dragItem.height; + const overTargetBottom = bottom - event.clientY < dragItem.height; + const draggingUp = dragItem.index > targetIndex; + + // prevent oscillating by only reordering if there is sufficient space + if ( + (overTargetTop && draggingUp) || + (overTargetBottom && !draggingUp) + ) { + const newStrategies = [...strategies]; + const movedStrategy = newStrategies.splice( + dragItem.index, + 1 + )[0]; + newStrategies.splice(targetIndex, 0, movedStrategy); + setStrategies(newStrategies); + setDragItem({ + ...dragItem, + index: targetIndex, + }); + } + }; + + const onDragEnd = () => { + setDragItem(null); + onReorder( + strategies.map((strategy, sortOrder) => ({ + id: strategy.id, + sortOrder, + })) + ); + }; + + return ( +
    +
    + 0 && isDisabled} + show={() => ( + + This environment is disabled, which means that none + of your strategies are executing. + + )} + /> + 0} + show={ + <> + {strategies.map((strategy, index) => ( + + ))} + + } + elseShow={ + + } + /> +
    +
    + ); +}; + +export default EnvironmentAccordionBody; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyDraggableItem.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyDraggableItem.tsx new file mode 100644 index 0000000000..7fab824cb0 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyDraggableItem.tsx @@ -0,0 +1,59 @@ +import { DragEventHandler, RefObject, useRef } from 'react'; +import { Box } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator'; +import { IFeatureEnvironment } from 'interfaces/featureToggle'; +import { IFeatureStrategy } from 'interfaces/strategy'; +import { StrategyItem } from './StrategyItem/StrategyItem'; + +interface IStrategyDraggableItemProps { + strategy: IFeatureStrategy; + environmentName: string; + index: number; + otherEnvironments?: IFeatureEnvironment['name'][]; + isDragging?: boolean; + onDragStartRef: ( + ref: RefObject, + index: number + ) => DragEventHandler; + onDragOver: ( + ref: RefObject, + index: number + ) => DragEventHandler; + onDragEnd: () => void; +} +export const StrategyDraggableItem = ({ + strategy, + index, + environmentName, + otherEnvironments, + isDragging, + onDragStartRef, + onDragOver, + onDragEnd, +}: IStrategyDraggableItemProps) => { + const ref = useRef(null); + + return ( + + 0} + show={} + /> + + + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/CopyStrategyIconMenu/CopyStrategyIconMenu.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/CopyStrategyIconMenu/CopyStrategyIconMenu.tsx new file mode 100644 index 0000000000..e16482a8c1 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/CopyStrategyIconMenu/CopyStrategyIconMenu.tsx @@ -0,0 +1,149 @@ +import { MouseEvent, useContext, useState, VFC } from 'react'; +import { + IconButton, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Tooltip, +} from '@mui/material'; +import { AddToPhotos as CopyIcon, Lock } from '@mui/icons-material'; +import { IFeatureStrategy } from 'interfaces/strategy'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { IFeatureEnvironment } from 'interfaces/featureToggle'; +import AccessContext from 'contexts/AccessContext'; +import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import useFeatureStrategyApi from 'hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi'; +import useToast from 'hooks/useToast'; +import { useFeatureImmutable } from 'hooks/api/getters/useFeature/useFeatureImmutable'; +import { formatUnknownError } from 'utils/formatUnknownError'; + +interface ICopyStrategyIconMenuProps { + environments: IFeatureEnvironment['name'][]; + strategy: IFeatureStrategy; +} + +export const CopyStrategyIconMenu: VFC = ({ + environments, + strategy, +}) => { + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + const { addStrategyToFeature } = useFeatureStrategyApi(); + const { setToastData, setToastApiError } = useToast(); + const { refetchFeature } = useFeature(projectId, featureId); + const { refetchFeature: refetchFeatureImmutable } = useFeatureImmutable( + projectId, + featureId + ); + const onClose = () => { + setAnchorEl(null); + }; + const { hasAccess } = useContext(AccessContext); + const onClick = async (environmentId: string) => { + const { id, ...strategyCopy } = { + ...strategy, + environment: environmentId, + }; + + try { + await addStrategyToFeature( + projectId, + featureId, + environmentId, + strategyCopy + ); + refetchFeature(); + refetchFeatureImmutable(); + setToastData({ + title: `Strategy created`, + text: `Successfully copied a strategy to ${environmentId}`, + type: 'success', + }); + } catch (error) { + setToastApiError(formatUnknownError(error)); + } + onClose(); + }; + + const enabled = environments.some(environment => + hasAccess(CREATE_FEATURE_STRATEGY, projectId, environment) + ); + + return ( +
    + +
    + ) => { + setAnchorEl(event.currentTarget); + }} + disabled={!enabled} + > + + +
    +
    + + {environments.map(environment => { + const access = hasAccess( + CREATE_FEATURE_STRATEGY, + projectId, + environment + ); + + return ( + +
    + onClick(environment)} + disabled={!access} + > + + + + } + /> + + Copy to {environment} + + +
    +
    + ); + })} +
    +
    + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/ConstraintItem/ConstraintItem.styles.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/ConstraintItem/ConstraintItem.styles.ts new file mode 100644 index 0000000000..dc31d5fc97 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/ConstraintItem/ConstraintItem.styles.ts @@ -0,0 +1,20 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + width: '100%', + padding: theme.spacing(2, 3), + borderRadius: theme.shape.borderRadiusMedium, + border: `1px solid ${theme.palette.divider}`, + }, + chip: { + margin: '0.25rem', + }, + paragraph: { + display: 'inline', + margin: '0.25rem 0', + maxWidth: '95%', + textAlign: 'center', + wordBreak: 'break-word', + }, +})); diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/ConstraintItem/ConstraintItem.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/ConstraintItem/ConstraintItem.tsx new file mode 100644 index 0000000000..727d9ce42d --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/ConstraintItem/ConstraintItem.tsx @@ -0,0 +1,43 @@ +import { Chip } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { useStyles } from './ConstraintItem.styles'; +import StringTruncator from 'component/common/StringTruncator/StringTruncator'; + +interface IConstraintItemProps { + value: string[]; + text: string; +} + +export const ConstraintItem = ({ value, text }: IConstraintItemProps) => { + const { classes: styles } = useStyles(); + return ( +
    + No {text}s added yet.

    } + elseShow={ +
    +

    + {value.length}{' '} + {value.length > 1 ? `${text}s` : text} will get + access. +

    + {value.map((v: string) => ( + + } + className={styles.chip} + /> + ))} +
    + } + /> +
    + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/StrategyExecution.styles.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/StrategyExecution.styles.ts new file mode 100644 index 0000000000..a570236fbd --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/StrategyExecution.styles.ts @@ -0,0 +1,12 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + valueContainer: { + padding: theme.spacing(2, 3), + border: `1px solid ${theme.palette.dividerAlternative}`, + borderRadius: theme.shape.borderRadiusMedium, + }, + valueSeparator: { + color: theme.palette.grey[700], + }, +})); diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/StrategyExecution.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/StrategyExecution.tsx new file mode 100644 index 0000000000..f47ab515e5 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/StrategyExecution.tsx @@ -0,0 +1,292 @@ +import { Fragment, useMemo, VFC } from 'react'; +import { Box, Chip } from '@mui/material'; +import { IFeatureStrategy } from 'interfaces/strategy'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import PercentageCircle from 'component/common/PercentageCircle/PercentageCircle'; +import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator'; +import { ConstraintItem } from './ConstraintItem/ConstraintItem'; +import { useStrategies } from 'hooks/api/getters/useStrategies/useStrategies'; +import { useSegments } from 'hooks/api/getters/useSegments/useSegments'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { FeatureOverviewSegment } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSegment/FeatureOverviewSegment'; +import { ConstraintAccordionList } from 'component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList'; +import { useStyles } from './StrategyExecution.styles'; +import { + parseParameterNumber, + parseParameterString, + parseParameterStrings, +} from 'utils/parseParameter'; +import StringTruncator from 'component/common/StringTruncator/StringTruncator'; + +interface IStrategyExecutionProps { + strategy: IFeatureStrategy; +} + +const NoItems: VFC = () => ( + + This strategy does not have constraints or parameters. + +); + +export const StrategyExecution: VFC = ({ + strategy, +}) => { + const { parameters, constraints = [] } = strategy; + const { classes: styles } = useStyles(); + const { strategies } = useStrategies(); + const { uiConfig } = useUiConfig(); + const { segments } = useSegments(strategy.id); + + const definition = strategies.find(strategyDefinition => { + return strategyDefinition.name === strategy.name; + }); + + const parametersList = useMemo(() => { + if (!parameters || definition?.editable) return null; + + return Object.keys(parameters).map(key => { + switch (key) { + case 'rollout': + case 'Rollout': + const percentage = parseParameterNumber(parameters[key]); + + return ( + + + + +
    + {' '} + of your base{' '} + {constraints.length > 0 + ? 'who match constraints' + : ''}{' '} + is included. +
    +
    + ); + case 'userIds': + case 'UserIds': + const users = parseParameterStrings(parameters[key]); + return ( + + ); + case 'hostNames': + case 'HostNames': + const hosts = parseParameterStrings(parameters[key]); + return ( + + ); + case 'IPs': + const IPs = parseParameterStrings(parameters[key]); + return ; + case 'stickiness': + case 'groupId': + return null; + default: + return null; + } + }); + }, [parameters, definition, constraints, styles]); + + const customStrategyList = useMemo(() => { + if (!parameters || !definition?.editable) return null; + const isSetTo = ( + {' is set to '} + ); + + return definition?.parameters.map(param => { + const { type, name } = { ...param }; + if (!type || !name || parameters[name] === undefined) { + return null; + } + const nameItem = ( + + ); + + switch (param?.type) { + case 'list': + const values = parseParameterStrings(parameters[name]); + + return values.length > 0 ? ( +
    + {nameItem}{' '} + + has {values.length}{' '} + {values.length > 1 ? `items` : 'item'}:{' '} + {values.map((item: string) => ( + + } + sx={{ mr: 0.5 }} + /> + ))} + +
    + ) : null; + + case 'percentage': + const percentage = parseParameterNumber(parameters[name]); + return parameters[name] !== '' ? ( + + + + +
    + {nameItem} + {isSetTo} + +
    +
    + ) : null; + + case 'boolean': + return parameters[name] === 'true' || + parameters[name] === 'false' ? ( +
    + + {isSetTo} + +
    + ) : null; + + case 'string': + const value = parseParameterString(parameters[name]); + return typeof parameters[name] !== 'undefined' ? ( +
    + {nameItem} + + {' is an empty string'} + + } + elseShow={ + <> + {isSetTo} + + + } + /> +
    + ) : null; + + case 'number': + const number = parseParameterNumber(parameters[name]); + return parameters[name] !== '' && number !== undefined ? ( +
    + {nameItem} + {isSetTo} + +
    + ) : null; + case 'default': + return null; + } + + return null; + }); + }, [parameters, definition, styles]); + + if (!parameters) { + return ; + } + + const listItems = [ + Boolean(uiConfig.flags.SE) && segments && segments.length > 0 && ( + + ), + constraints.length > 0 && ( + + ), + strategy.name === 'default' && ( + <> + + The standard strategy is{' '} + {' '} + for all users. + + + ), + ...(parametersList ?? []), + ...(customStrategyList ?? []), + ].filter(Boolean); + + return ( + 0} + show={ + <> + {listItems.map((item, index) => ( + + 0} + show={} + /> + {item} + + ))} + + } + elseShow={} + /> + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyItem.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyItem.tsx new file mode 100644 index 0000000000..ad3d49e4d5 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyItem.tsx @@ -0,0 +1,85 @@ +import { DragEventHandler, VFC } from 'react'; +import { Edit } from '@mui/icons-material'; +import { Link } from 'react-router-dom'; +import { IFeatureEnvironment } from 'interfaces/featureToggle'; +import { IFeatureStrategy } from 'interfaces/strategy'; +import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; +import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions'; +import { formatEditStrategyPath } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit'; +import { FeatureStrategyRemove } from 'component/feature/FeatureStrategy/FeatureStrategyRemove/FeatureStrategyRemove'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { StrategyExecution } from './StrategyExecution/StrategyExecution'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { CopyStrategyIconMenu } from './CopyStrategyIconMenu/CopyStrategyIconMenu'; +import { StrategyItemContainer } from 'component/common/StrategyItemContainer/StrategyItemContainer'; + +interface IStrategyItemProps { + environmentId: string; + strategy: IFeatureStrategy; + onDragStart?: DragEventHandler; + onDragEnd?: DragEventHandler; + otherEnvironments?: IFeatureEnvironment['name'][]; + orderNumber?: number; +} + +export const StrategyItem: VFC = ({ + environmentId, + strategy, + onDragStart, + onDragEnd, + otherEnvironments, + orderNumber, +}) => { + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + + const editStrategyPath = formatEditStrategyPath( + projectId, + featureId, + environmentId, + strategy.id + ); + + return ( + + 0 + )} + show={() => ( + + )} + /> + + + + + + } + > + + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentFooter/EnvironmentFooter.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentFooter/EnvironmentFooter.tsx new file mode 100644 index 0000000000..7c2ea3285e --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentFooter/EnvironmentFooter.tsx @@ -0,0 +1,29 @@ +import { IFeatureEnvironmentMetrics } from 'interfaces/featureToggle'; +import { FeatureMetricsStats } from 'component/feature/FeatureView/FeatureMetrics/FeatureMetricsStats/FeatureMetricsStats'; +import { SectionSeparator } from '../SectionSeparator/SectionSeparator'; + +interface IEnvironmentFooterProps { + environmentMetric?: IFeatureEnvironmentMetrics; +} + +export const EnvironmentFooter = ({ + environmentMetric, +}: IEnvironmentFooterProps) => { + if (!environmentMetric) { + return null; + } + + return ( + <> + Feature toggle exposure + +
    + +
    + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.styles.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.styles.ts new file mode 100644 index 0000000000..f4ea62eaa3 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.styles.ts @@ -0,0 +1,99 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + featureOverviewEnvironment: { + borderRadius: theme.shape.borderRadiusLarge, + marginBottom: theme.spacing(2), + padding: '0.2rem', + backgroundColor: theme.palette.background.paper, + }, + accordion: { + boxShadow: 'none', + background: 'none', + }, + accordionHeader: { + boxShadow: 'none', + padding: '1rem 2rem', + [theme.breakpoints.down(400)]: { + padding: '0.5rem 1rem', + }, + }, + accordionBodyInnerContainer: { + [theme.breakpoints.down(400)]: { + padding: '0.5rem', + }, + }, + accordionDetails: { + padding: theme.spacing(3), + background: theme.palette.secondaryContainer, + borderBottomLeftRadius: theme.shape.borderRadiusLarge, + borderBottomRightRadius: theme.shape.borderRadiusLarge, + borderBottom: `4px solid ${theme.palette.primary.light}`, + + [theme.breakpoints.down('md')]: { + padding: theme.spacing(2, 1), + }, + }, + accordionDetailsDisabled: { + borderBottom: `4px solid ${theme.palette.neutral.border}`, + }, + accordionBody: { + width: '100%', + position: 'relative', + paddingBottom: theme.spacing(2), + }, + header: { + display: 'flex', + justifyContent: 'center', + flexDirection: 'column', + }, + headerTitle: { + display: 'flex', + alignItems: 'center', + [theme.breakpoints.down(560)]: { + flexDirection: 'column', + textAlign: 'center', + }, + }, + headerIcon: { + [theme.breakpoints.down(560)]: { + marginBottom: '0.5rem', + }, + }, + iconContainer: { + backgroundColor: theme.palette.primary.light, + borderRadius: '50%', + width: '28px', + height: '28px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginRight: '0.5rem', + }, + icon: { + fill: '#fff', + width: '17px', + height: '17px', + }, + linkContainer: { + display: 'flex', + justifyContent: 'flex-end', + marginBottom: '1rem', + }, + truncator: { + fontSize: theme.fontSizes.bodySize, + fontWeight: theme.typography.fontWeightMedium, + [theme.breakpoints.down(560)]: { + textAlign: 'center', + }, + }, + container: { + display: 'flex', + alignItems: 'center', + marginLeft: '1.8rem', + [theme.breakpoints.down(560)]: { + flexDirection: 'column', + marginLeft: '0', + }, + }, +})); diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx new file mode 100644 index 0000000000..0441e4016f --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx @@ -0,0 +1,165 @@ +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Chip, + useTheme, +} from '@mui/material'; +import classNames from 'classnames'; +import { ExpandMore } from '@mui/icons-material'; +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import useFeatureMetrics from 'hooks/api/getters/useFeatureMetrics/useFeatureMetrics'; +import { IFeatureEnvironment } from 'interfaces/featureToggle'; +import { getFeatureMetrics } from 'utils/getFeatureMetrics'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import EnvironmentIcon from 'component/common/EnvironmentIcon/EnvironmentIcon'; +import StringTruncator from 'component/common/StringTruncator/StringTruncator'; +import { useStyles } from './FeatureOverviewEnvironment.styles'; +import EnvironmentAccordionBody from './EnvironmentAccordionBody/EnvironmentAccordionBody'; +import { EnvironmentFooter } from './EnvironmentFooter/EnvironmentFooter'; +import FeatureOverviewEnvironmentMetrics from './FeatureOverviewEnvironmentMetrics/FeatureOverviewEnvironmentMetrics'; +import { FeatureStrategyMenu } from 'component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu'; +import { FEATURE_ENVIRONMENT_ACCORDION } from 'utils/testIds'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { FeatureStrategyIcons } from 'component/feature/FeatureStrategy/FeatureStrategyIcons/FeatureStrategyIcons'; +// import { Badge } from 'component/common/Badge/Badge'; + +interface IFeatureOverviewEnvironmentProps { + env: IFeatureEnvironment; +} + +const FeatureOverviewEnvironment = ({ + env, +}: IFeatureOverviewEnvironmentProps) => { + const { classes: styles } = useStyles(); + const theme = useTheme(); + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + const { metrics } = useFeatureMetrics(projectId, featureId); + const { feature } = useFeature(projectId, featureId); + + const featureMetrics = getFeatureMetrics(feature?.environments, metrics); + const environmentMetric = featureMetrics.find( + featureMetric => featureMetric.environment === env.name + ); + const featureEnvironment = feature?.environments.find( + featureEnvironment => featureEnvironment.name === env.name + ); + + return ( +
    + + } + > +
    +
    + +
    + +
    + + } + /> +
    +
    + + +
    +
    + + +
    + + + name) + .filter(name => name !== env.name)} + /> + 0 + } + show={ + <> + + + + + + } + /> + +
    +
    + ); +}; + +export default FeatureOverviewEnvironment; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironmentMetrics/FeatureOverviewEnvironmentMetrics.styles.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironmentMetrics/FeatureOverviewEnvironmentMetrics.styles.ts new file mode 100644 index 0000000000..1b4e6c6918 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironmentMetrics/FeatureOverviewEnvironmentMetrics.styles.ts @@ -0,0 +1,42 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + marginLeft: 'auto', + display: 'flex', + alignItems: 'center', + }, + info: { + marginRight: '0.5rem', + display: 'flex', + flexDirection: 'column', + }, + icon: { + fill: theme.palette.grey[300], + height: '75px', + width: '75px', + [theme.breakpoints.down(500)]: { + display: 'none', + }, + }, + infoParagraph: { + maxWidth: '270px', + marginTop: '0.25rem', + fontSize: theme.fontSizes.smallBody, + textAlign: 'right', + [theme.breakpoints.down(700)]: { + display: 'none', + }, + }, + percentage: { + color: theme.palette.primary.main, + textAlign: 'right', + fontSize: theme.fontSizes.bodySize, + }, + percentageCircle: { + margin: '0 1rem', + [theme.breakpoints.down(500)]: { + display: 'none', + }, + }, +})); diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironmentMetrics/FeatureOverviewEnvironmentMetrics.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironmentMetrics/FeatureOverviewEnvironmentMetrics.tsx new file mode 100644 index 0000000000..3e542e350a --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironmentMetrics/FeatureOverviewEnvironmentMetrics.tsx @@ -0,0 +1,83 @@ +import { FiberManualRecord } from '@mui/icons-material'; +import { useTheme } from '@mui/system'; +import { IFeatureEnvironmentMetrics } from 'interfaces/featureToggle'; +import { calculatePercentage } from 'utils/calculatePercentage'; +import PercentageCircle from 'component/common/PercentageCircle/PercentageCircle'; +import { useStyles } from './FeatureOverviewEnvironmentMetrics.styles'; + +interface IFeatureOverviewEnvironmentMetrics { + environmentMetric?: IFeatureEnvironmentMetrics; + disabled?: boolean; +} + +const FeatureOverviewEnvironmentMetrics = ({ + environmentMetric, + disabled = false, +}: IFeatureOverviewEnvironmentMetrics) => { + const { classes: styles } = useStyles(); + const theme = useTheme(); + + if (!environmentMetric) return null; + + const total = environmentMetric.yes + environmentMetric.no; + const percentage = calculatePercentage(total, environmentMetric?.yes); + + if ( + !environmentMetric || + (environmentMetric.yes === 0 && environmentMetric.no === 0) + ) { + return ( +
    +
    +

    + {percentage}% +

    +

    + The feature has been requested 0 times and + exposed 0 times in the last hour +

    +
    + +
    + ); + } + + return ( +
    +
    +

    {percentage}%

    +

    + The feature has been requested{' '} + {environmentMetric.yes + environmentMetric.no} times{' '} + and exposed {environmentMetric.yes} times in the last + hour +

    +
    +
    + +
    +
    + ); +}; + +export default FeatureOverviewEnvironmentMetrics; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/SectionSeparator/SectionSeparator.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/SectionSeparator/SectionSeparator.tsx new file mode 100644 index 0000000000..ef525e8529 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/SectionSeparator/SectionSeparator.tsx @@ -0,0 +1,35 @@ +import { FC } from 'react'; +import { styled } from '@mui/material'; + +const SeparatorContainer = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + margin: '1rem 0', + position: 'relative', + '&:before': { + content: '""', + position: 'absolute', + top: '50%', + transform: 'translateY(-50%)', + height: 2, + width: '100%', + backgroundColor: theme.palette.dividerAlternative, + }, +})); + +const SeparatorContent = styled('span')(({ theme }) => ({ + fontSize: theme.fontSizes.bodySize, + textAlign: 'center', + padding: '0 1rem', + background: theme.palette.secondaryContainer, + position: 'relative', + maxWidth: '80%', + color: theme.palette.text.primary, +})); + +export const SectionSeparator: FC = ({ children }) => ( + + {children} + +); diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironments.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironments.tsx new file mode 100644 index 0000000000..1742a78cbc --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironments.tsx @@ -0,0 +1,23 @@ +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import FeatureOverviewEnvironment from './FeatureOverviewEnvironment/FeatureOverviewEnvironment'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; + +const FeatureOverviewEnvironments = () => { + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + const { feature } = useFeature(projectId, featureId); + + if (!feature) return null; + + const { environments } = feature; + + return ( + <> + {environments?.map(env => ( + + ))} + + ); +}; + +export default FeatureOverviewEnvironments; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetaData.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetaData.tsx new file mode 100644 index 0000000000..a98b29091c --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetaData.tsx @@ -0,0 +1,92 @@ +import { capitalize } from '@mui/material'; +import classnames from 'classnames'; +import { Link } from 'react-router-dom'; +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import { getFeatureTypeIcons } from 'utils/getFeatureTypeIcons'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { useStyles } from './FeatureOverviewMetadata.styles'; +import { Edit } from '@mui/icons-material'; +import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; +import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions'; +import useTags from 'hooks/api/getters/useTags/useTags'; +import FeatureOverviewTags from './FeatureOverviewTags/FeatureOverviewTags'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; + +const FeatureOverviewMetaData = () => { + const { classes: styles } = useStyles(); + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + const { tags } = useTags(featureId); + const { feature } = useFeature(projectId, featureId); + const { project, description, type } = feature; + + const IconComponent = getFeatureTypeIcons(type); + + return ( +
    +
    +
    + {' '} +

    + {capitalize(type || '')} toggle +

    +
    +
    + + Project: {project} + + +
    Description:
    +
    +

    {description}

    + + + +
    + + } + elseShow={ + +
    + No description.{' '} + + + +
    +
    + } + /> +
    +
    + 0} + show={ +
    + +
    + } + /> +
    + ); +}; + +export default FeatureOverviewMetaData; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetadata.styles.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetadata.styles.ts new file mode 100644 index 0000000000..6923d7c385 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetadata.styles.ts @@ -0,0 +1,59 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + borderRadius: theme.shape.borderRadiusLarge, + color: '#fff', + backgroundColor: theme.palette.featureMetaData, + display: 'flex', + flexDirection: 'column', + maxWidth: '350px', + minWidth: '350px', + marginRight: '1rem', + [theme.breakpoints.down(1000)]: { + width: '100%', + maxWidth: 'none', + minWidth: 'auto', + }, + }, + paddingContainerTop: { + padding: '1.5rem 1.5rem 0 1.5rem', + }, + paddingContainerBottom: { + padding: '0 1.5rem 1.5rem 1.5rem', + borderTop: `1px solid ${theme.palette.grey[300]}`, + }, + metaDataHeader: { + display: 'flex', + alignItems: 'center', + }, + header: { + fontSize: theme.fontSizes.bodySize, + fontWeight: 'normal', + margin: 0, + }, + body: { + margin: '1rem 0', + display: 'flex', + flexDirection: 'column', + }, + bodyItem: { + margin: '0.5rem 0', + fontSize: theme.fontSizes.bodySize, + wordBreak: 'break-all', + }, + headerIcon: { + marginRight: '1rem', + height: '40px', + width: '40px', + fill: '#fff', + }, + descriptionContainer: { + display: 'flex', + alignItems: 'center', + color: '#fff', + }, + editIcon: { + color: '#fff', + }, +})); diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewTags/FeatureOverviewTags.styles.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewTags/FeatureOverviewTags.styles.ts new file mode 100644 index 0000000000..6e2a4c6af2 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewTags/FeatureOverviewTags.styles.ts @@ -0,0 +1,38 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + borderRadius: theme.shape.borderRadiusLarge, + backgroundColor: theme.palette.primary.main, + display: 'flex', + flexDirection: 'column', + marginRight: '1rem', + marginTop: '1rem', + [theme.breakpoints.down(800)]: { + width: '100%', + maxWidth: 'none', + }, + }, + tagHeader: { + display: 'flex', + alignItems: 'center', + }, + tag: { + height: '40px', + width: '40px', + fill: theme.palette.primary.main, + marginRight: '0.8rem', + }, + tagChip: { + marginRight: '0.25rem', + marginTop: '0.5rem', + backgroundColor: '#fff', + fontSize: theme.fontSizes.smallBody, + }, + closeIcon: { + color: theme.palette.primary.light, + '&:hover': { + color: theme.palette.primary.light, + }, + }, +})); diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewTags/FeatureOverviewTags.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewTags/FeatureOverviewTags.tsx new file mode 100644 index 0000000000..ad5e890a63 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewTags/FeatureOverviewTags.tsx @@ -0,0 +1,148 @@ +import React, { useContext, useState } from 'react'; +import { Chip } from '@mui/material'; +import { Close, Label } from '@mui/icons-material'; +import useTags from 'hooks/api/getters/useTags/useTags'; +import { useStyles } from './FeatureOverviewTags.styles'; +import slackIcon from 'assets/icons/slack.svg'; +import jiraIcon from 'assets/icons/jira.svg'; +import webhookIcon from 'assets/icons/webhooks.svg'; +import { formatAssetPath } from 'utils/formatPath'; +import useTagTypes from 'hooks/api/getters/useTagTypes/useTagTypes'; +import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import { ITag } from 'interfaces/tags'; +import useToast from 'hooks/useToast'; +import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import AccessContext from 'contexts/AccessContext'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; + +interface IFeatureOverviewTagsProps extends React.HTMLProps { + projectId: string; +} + +const FeatureOverviewTags: React.FC = ({ + projectId, + ...rest +}) => { + const [showDelDialog, setShowDelDialog] = useState(false); + const [selectedTag, setSelectedTag] = useState({ + value: '', + type: '', + }); + const { classes: styles } = useStyles(); + const featureId = useRequiredPathParam('featureId'); + const { tags, refetch } = useTags(featureId); + const { tagTypes } = useTagTypes(); + const { deleteTagFromFeature } = useFeatureApi(); + const { setToastData, setToastApiError } = useToast(); + const { hasAccess } = useContext(AccessContext); + const canDeleteTag = hasAccess(UPDATE_FEATURE, projectId); + + const handleDelete = async () => { + try { + await deleteTagFromFeature( + featureId, + selectedTag.type, + selectedTag.value + ); + refetch(); + setToastData({ + type: 'success', + title: 'Tag deleted', + text: 'Successfully deleted tag', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const tagIcon = (typeName: string) => { + let tagType = tagTypes.find(type => type.name === typeName); + + const style = { width: '20px', height: '20px', marginRight: '5px' }; + + if (tagType && tagType.icon) { + switch (tagType.name) { + case 'slack': + return ( + Slack + ); + case 'jira': + return ( + JIRA + ); + case 'webhook': + return ( + Webhook + ); + default: + return