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/stale.yml b/frontend/.github/stale.yml
new file mode 100644
index 0000000000..0d0b1c994d
--- /dev/null
+++ b/frontend/.github/stale.yml
@@ -0,0 +1 @@
+_extends: .github
diff --git a/frontend/.github/workflows/add-to-project.yml b/frontend/.github/workflows/add-to-project.yml
new file mode 100644
index 0000000000..ce57699355
--- /dev/null
+++ b/frontend/.github/workflows/add-to-project.yml
@@ -0,0 +1,14 @@
+name: Add new item to project board
+
+on:
+ issues:
+ types:
+ - opened
+ pull_request_target:
+ types:
+ - opened
+
+jobs:
+ add-to-project:
+ uses: unleash/.github/.github/workflows/add-item-to-project.yml@main
+ secrets: inherit
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/.github/workflows/node.js.yml b/frontend/.github/workflows/node.js.yml
new file mode 100644
index 0000000000..dc37eb3254
--- /dev/null
+++ b/frontend/.github/workflows/node.js.yml
@@ -0,0 +1,23 @@
+name: Node.js CI
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ 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 install --frozen-lockfile
+ - run: yarn run test
+ - run: yarn run fmt:check
diff --git a/frontend/.github/workflows/release.yml b/frontend/.github/workflows/release.yml
new file mode 100644
index 0000000000..0680a0d661
--- /dev/null
+++ b/frontend/.github/workflows/release.yml
@@ -0,0 +1,40 @@
+name: 'Release unleash-frontend'
+on:
+ push:
+ tags:
+ - 'v*'
+
+jobs:
+ build:
+
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ node-version: [14.x]
+
+ steps:
+ - uses: actions/checkout@v3
+ - name: Publish to npm
+ uses: actions/setup-node@v3
+ with:
+ node-version: ${{ matrix.node-version }}
+ registry-url: 'https://registry.npmjs.org'
+ - run: |
+ yarn install --frozen-lockfile
+ - run: |
+ TAG=$(echo $GITHUB_REF_NAME | grep -oP '^v\d+\.\d+\.\d+-?\K(\w+)?')
+ 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 build/ s3://getunleash-static/unleash/${{ steps.get_version.outputs.VERSION }} --recursive
diff --git a/frontend/.github/workflows/release_changelog.yml b/frontend/.github/workflows/release_changelog.yml
new file mode 100644
index 0000000000..52d02d0151
--- /dev/null
+++ b/frontend/.github/workflows/release_changelog.yml
@@ -0,0 +1,27 @@
+name: 'Release changelog'
+on:
+ push:
+ tags:
+ - 'v*'
+
+jobs:
+ release:
+ if: startsWith(github.ref, 'refs/tags/')
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v3
+ - name: Build changelog
+ id: github_release
+ uses: metcalfc/changelog-generator@v3.0.0
+ with:
+ myToken: ${{ secrets.GITHUB_TOKEN }}
+ - name: Create release
+ uses: actions/create-release@v1
+ with:
+ tag_name: ${{ github.ref }}
+ release_name: ${{ github.ref }}
+ body: ${{ steps.github_release.outputs.changelog }}
+ prerelease: ${{ contains(github.ref, 'beta') || contains(github.ref, 'alpha') }}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN}}
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/CHANGELOG.md b/frontend/CHANGELOG.md
new file mode 100644
index 0000000000..9fa79ab5f1
--- /dev/null
+++ b/frontend/CHANGELOG.md
@@ -0,0 +1,670 @@
+# Change Log
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](http://keepachangelog.com/)
+and this project adheres to [Semantic Versioning](http://semver.org/).
+
+The latest version of this document is always available in
+[releases](https://github.com/Unleash/unleash-frontend/releases).
+
+# 4.2.13
+- fix: mobile percentagecircle (#502)
+- fix: status chip (#501)
+- Feat/new toggle overview (#497)
+- chore(deps): update dependency @types/node to v14.17.33
+- chore(deps): pin dependencies
+- fix: support new event format with diff will be done in th
+e UI (#496)
+- Merge pull request #495 from Unleash/fix/revive-archived-f
+eature
+- update snapshots
+- fix: disable revive feature when project is deleted
+- use useProjects to check if project deleted or not
+- fix: add correct path for create first toggle button
+- fix: remove typo from UI
+- fix: rename isProject
+Deleted to projectExists and add PermissionIconButton
+# 4.2.2
+- fix: toast text
+- Fix/strategy sidepanel (#479)
+- fix: guard for disabling envs (#4
+92)
+- fix: handle undefined project wit
+h default (#486)
+- chore(deps): update dependency cs
+s-loader to v6.5.1
+- chore(deps): update dependency @types/react-dom to v17.0.11
+- chore(deps): update dependency @types/react to v17.0.34
+- Merge pull request #483 from Unleash/fix/add-highlight-row
+
+# 4.2.1
+- Feat/toggle view (#389)
+- feat: created project header (#388)
+- feat: e2e tests and mobile views (#348)
+- fix: missing-toggle link should include name-param once
+- feat: project environments configuration (#365)
+- fix: use renovater github config
+- fix: renovate should be allowed to automerge all packages
+- fix: allow renovate bot to auto-merge
+- task: remove display name from environment (#367)
+# 4.2.0
+- Feat/environment strategies (#339)
+- fix: not set env if undefined
+- Add renovate.json (#340)
+- Add project and environment scoping to API keys (#336)
+- Fix/strategy permissions (#337)
+- fix: header zIndex
+- Feat/environment crud (#335)
+# 4.1.1
+- fix: header zindex
+# 4.1.0
+- fix: sync (#334)
+- Fix/create feature (#332)
+- Fix/texture (#330)
+- fix: update constraint text field
+- Fix/minor 41 bugs (#329)
+- fix: clean up footer a bit
+- fix: content-wrapper should not take 100%
+- fix: should not show deprecated strategies
+- fix: do not filter parent routes for main nav
+- fix: add feature toggles and projects to mobile navigation (#328)
+- fix: change prepublish to prepare
+# 4.1.0-beta.4
+- Feat/feature routes
+- add enableSingleSignOut for OIDC
+- (feat/tests) feat: SSO auto-create users with default role
+# 4.1.0-beta.3
+- Fix/routing
+# 4.1.0-beta.2
+- Fix/frontend projects changes
+- chore(deps): bump path-parse from 1.0.6 to 1.0.7
+- Add switch for deciding whether to send email.
+- Fix/make sure stickiness exists
+- Load name from url
+- chore(deps): bump dns-packet from 1.3.1 to 1.3.4
+- chore(deps): bump ws from 6.2.1 to 6.2.2
+- chore(deps): bump tar from 6.1.0 to 6.1.5
+- fix: variant stickiness should not revert to default when updating
+- fix: add logout as an explicit call
+- Feat/new navigation
+# 4.0.5 fix: run use effect when value changes, not object (#315)
+- fix: run use effect when value changes, not object (#315)
+- fix: add flex wrap
+
+# 4.0.5-beta.2
+- fix/pagination
+
+# 4.0.5-beta.1
+- Feat/group by projects
+- fix: add missing icons
+- Offline mode
+
+# 4.0.4
+
+- fix: update feedback url
+
+# 4.0.3
+
+- fix: add null check for dueDate
+
+# 4.0.2
+
+- chore/update-changelog
+- feat/pnps
+- fix/customer journey patches (#304)
+- fix: add check for obscure error (#305)
+- fix: passwordchecker
+
+# 4.0.1
+
+- fix: Project actions need to checkAccess based on projectId
+- fix: INLINE_RUNTIME_CHUNK
+
+# 4.0.0
+
+- feat: update color scheme and logo (#301)
+- feat: Add admin-invoice section (#299)
+- fix: reset border radius on mobile
+- fix: standalone pages (#300)
+
+# 4.0.0-beta.5
+
+- chore(deps): bump hosted-git-info from 2.8.8 to 2.8.9 (#291)
+- fix: remove unused components
+- Fix/customer journey (#297)
+- feat: simple project view (#295)
+- fix: use router match util (#298)
+- fix: typo
+- fix: import correct component container (#296)
+
+# 4.0.0-beta.4
+
+- fix: footer
+
+# 4.0.0-beta.3
+
+- fix: include invite link in email (#294)
+- fix: add link to manage access to edit project
+- fix: hosted auth should not need to load initial data
+
+# 4.0.0-beta.2
+
+- Fix: feedback on create (#292)
+- fix: proper error handling for auth-settings being stored (#293)
+- fix: api path for auth-config
+- fix: improve password auth extra options
+
+# 4.0.0-beta.1
+
+- fix: support custom stickiness for flexible strategies
+- fix: add members to project use correct uri
+- Fix/console warn (#290)
+- Fix/strategy constraints (#289)
+- Set .nvmrc to 14 to reflect new requirement of node
+
+# 4.0.0-beta.0
+
+- feat: upgrade to node.js v14
+- fix: tiny margin for feature toggle list item
+- fix: should be allowed to create toggles without errors
+- Fix: jumping screen (#288)
+- Fix/minor changes (#285)
+- Fix/v4 corrections (#287)
+- fix: use correct baseUriPath with localStorage
+- fix: link to docs for empty apps
+
+# 4.0.0-alpha.14
+
+- fix: all global event log requires admin
+- fix: constraints array can be undefined
+
+# 4.0.0-alpha.13
+
+- feat: bootstrap endpoint for initial data (#281)
+- fix: allow permissions to be checked without project being defined (#282)
+- fix/strategy constraints (#283)
+- fix: logout should only be called once
+- fix: handle generic errors better
+
+
+# 4.0.0-alpha.12
+
+- fix: add datadog logo for addons
+
+# 4.0.0-alpha.11
+- Feat/auth hosted section (#280)
+- fix: only get legalValues if definition exists
+- Fix/variants (#278)
+- Fix/bugfixes (#279)
+- fix/locale (#277)
+- fix: added teams logo
+- Fix/cleanup (#276)
+- fix: password
+
+# 4.0.0-alpha.10
+- fix: password
+
+# 4.0.0-alpha.9
+- fix: optimizations
+- feat: user profile
+
+# 4.0.0-alpha.8
+- chore(deps): bump ssri from 6.0.1 to 6.0.2 (#270)
+- fix: lint
+- fix: minor tuning on auth
+- feat: add new user (#273)
+
+# 4.0.0-alpha.7
+- feat: add support for demo sign-in
+
+# 4.0.0-alpha.5
+- fix: require ADMIN role to manage users
+- fix: add permissions for tag-types and project
+
+# 4.0.0-alpha.4
+- fix: overall bugs
+- feat: user flow
+- fix: small description for toggles
+- fix: make admin pages fork for OSS and enterprise
+
+# 4.0.0-alpha.3
+- fix: logout redirect logic
+- fix: redirect from login page if authorized
+- fix: material UI cleanup (#264)
+
+
+# 4.0.0-alpha.2
+- feat: admin users (#266)
+- fix: remove editableStrategies from useEffect deps
+- fix: Migrate to create-react-app and react-scripts (#263)
+
+# 4.0.0-alpha.1
+
+- fix: delete strategy
+
+# 4.0.0-alpha.0
+
+- feat: Switch to material-ui
+
+# 3.15.0
+
+- feat: Adapt API keys to new endpoint (#259)
+- chore(deps): bump elliptic from 6.5.3 to 6.5.4 (#253)
+- chore(deps): bump yargs-parser from 5.0.0 to 5.0.1 (#256)
+- chore(deps): bump y18n from 3.2.1 to 3.2.2 (#261)
+- fix: add ascending sorting (#260)
+- chore: changelog
+- fix: encode URI value when deleting tag
+- Merge pull request #257 from Unleash/fix/encode-tag-values
+- fix: encode tag value
+
+# 3.14.1
+
+- fix: uriencode tag.value when deleting a tag
+
+# 3.14.0
+
+- fix: should fetch projects once to make sure we know about projects
+- feat/rbac: edit access for projects. (#251)
+
+# 3.13.5
+
+- fix: check that strategies exists before calling includes
+
+# 3.13.4
+
+- fix: metrics invalid date
+
+# 3.13.3
+
+- fix: content-min-height
+
+# 3.13.2
+
+- feat: stale dashboard
+
+# 3.13.1
+
+- fix: fix update-variant-test
+- fix: unsecure => insecure
+- fix: upgrade uglifyjs-webpack-plugin to version 2.2.0
+- fix: one and only one front (#244)
+
+# 3.13.0
+
+- fix: minor visual for dropdowns
+- feat: add oss/enterprise version to footer (#245)
+
+# 3.12.0
+
+- feat: allow custom context fields to define stickiness. (#241)
+- fix: filter duplicates
+
+# 3.11.4
+
+- fix: should not register duplicate HTML5 backends
+
+# 3.11.3
+
+- fix: use findIndex when using predicate.
+
+# 3.11.2
+
+- fix: Add UI for showing 'create tag' errors
+- fix: UX should not eagerly store strategy updates! (#240)
+- fix: upgraded jest to version 26.6.3
+
+# 3.11.1
+
+- fix: make sure we also bundle SVG in public
+
+# 3.11.0
+
+- feat: Addon support from UI (#236)
+- fix: Use type and value from action to remove tag (#238)
+- fix: add missing space (#239)
+- fix: error in snapshot
+
+# 3.10.0
+
+- feat: Can now deprecate and reactivate strategies (#235)
+
+# 3.9.1
+
+- fix: Tags viewable on archived features (#233)
+
+# 3.9.0
+
+- feat: Tags for feature toggles (#232)
+- feat: Tag-types (#232)
+
+# 3.8.4
+
+- fix: update canisue-lite
+- fix: move all api calls to store folders
+- fix: move feature-metrics store to its own folder
+- fix: move history to folder
+- fix: move feature-toggle store into folder
+- fix: move error store into folder
+- fix: remove unused client-instance concept
+- fix: archive store in folder
+- fix: remove use of input stores
+
+# 3.8.3
+
+- feat: Add last seen at timestamp
+- fix: add last seen as sort option
+
+# 3.8.2
+
+- fix: new feature toggle gets default strategy
+
+# 3.8.1
+
+- fix: minor CSS improvement for strategy configs
+- fix: minor strategy configure update
+
+# 3.8.0
+
+- feat: Should update activation strategies immediately (#229)
+
+# 3.7.0
+
+- fix: remove deprecated badges
+- fix: filter for projects
+- chore(deps-dev): bump node-fetch from 2.6.0 to 2.6.1
+- feat: add technical support for projects in UI
+
+# 3.6.5
+
+- fix: should be possible to remove all variants.
+
+# 3.6.4
+
+- fix: minur ux tweaks
+
+# 3.6.3
+
+- fix: hide content if showing authentication modal
+- fix: add security wanring to the console
+- fix: typo description => descriptionn
+
+# 3.6.2
+
+- fix: show notification when app updates
+- fix: add created date for applications
+
+# 3.6.1
+
+- fix: minor css tweaks for mobile
+- fix: should support 409 responses as well
+
+# 3.6.0
+
+- feat: add search for applications
+- feat: Should be possible to remove applications
+- fix: make sure application is updated on edit
+- fix: list parameters should be trimmed
+- fix: cleanup edit application a bit
+- fix: use https url for local->heroku proxy
+- fix: upgrade whatwg-fetch to version 3.4.1
+
+# 3.5.1
+
+- fix: add link to all client SDKs
+- fix: use Rect.memo to increase performance
+
+# [3.5.0]
+
+- feat: added time-ago to toggle-list
+- feat: Add stale marking of feature toggles
+- feat: add support for toggle type (#220)
+- feat: sort by stale
+- fix: improve type-chip color
+- fix: some ux cleanup for toggle types
+
+# [3.4.0]
+
+- Feat: (VariantCustomization) Allow user to customize variant weights (#216)
+- bump elliptic from 6.5.2 to 6.5.3 (#218)
+- chore(deps): bump websocket-extensions from 0.1.3 to 0.1.4 (#217)
+- chore(deps-dev): bump lodash from 4.17.15 to 4.17.19 (#214)
+- fix: upgrade react-dnd to version 11.1.3
+- fix: Update react-dnd to the latest version 🚀 (#213)
+- fix: read unleash version from ui-config (#219)
+- fix: flag initial context fields
+
+# [3.3.5]
+
+- fix: should handle zero variants
+- fix: modal for variants
+
+## [3.3.4]
+
+- fix: allow overflow for strategy card
+- fix: add common component input-list-field
+
+## [3.3.3]
+
+- fix: improve on variant ui
+- fix: should not clear all stores on update user profile
+- fix: convert variant-view-component to function
+- fix: tune css a little
+
+## [3.3.2]
+
+- fix: reset stores on login/logout (#212)
+- fix: password login should prefer login options
+- fix: Transform username/password login response to json (#211)
+
+## [3.3.1]
+
+- feat: add support for username/password login
+- feat: locale select should be dropdown menu
+- feat: support internal routes
+- fix: adjust colors of dialog
+
+## [3.2.21]
+
+- fix: upgrade fetch-mock to version 9.4.0
+- fix: upgrade redux to version 4.0.5
+- fix: upgrade babel dependencies
+- fix: upgrade react-router to version 5.1.2
+- fix: upgrade react to version 16.13.1
+- fix: rename use of legacy react lifecyle methods
+- fix: upgrade react-dnd to version 10.0.2"
+
+## [3.2.20]
+
+- fix: logout should be real request and not just XHR
+
+## [3.2.19]
+
+- fix: default groupId never set for strategies (only in ui)
+
+## [3.2.18]
+
+- fix: clean up history view a bit
+
+## [3.2.17]
+
+- fix: feature search should use debounce
+- fix: footer should be on bottom
+
+## [3.2.16]
+
+- fix: minor improvement on context UI
+
+## [3.2.15]
+
+- fix: strategy config not maintainted in create toggle
+- fix: missing feature toggle should pre-fill name
+
+## [3.2.14]
+
+- fix: upgrade react-mdl to version 2.1.0
+
+## [3.2.13]
+
+- fix: Should be possible to clone even if strategy does not have groupId
+
+## [3.2.12]
+
+- feat: clone feature toggle configuration (#201)
+
+## [3.2.11]
+
+- fix: clean up variants view
+- fix: Cannot remove all variants in Admin UI
+- fix: update fetch-mock to version 8.0.0 (#199)
+- fix: update mini-css-extract-plugin to version 0.9.0
+
+## [3.2.10]
+
+- fix: missing strategy makes the toggle-configure crash
+
+## [3.2.9]
+
+- fix: Update feature toggle description. (#198)
+- fix: Update feature toggle description. (#196)
+- feat: Filter on all values in toogle data
+- feat: Add option for custom ui links (#195)
+- fix: Ensure chips are wrapped (#194)
+
+## [3.2.8]
+
+- fix: auto-fill groupId paramters
+- feat: Add support for flexible rollout strategy. (#193)
+
+## [3.2.7]
+
+- fix: upgrade react to 16.10.2
+- fix: upgrade eslint to version 6.5.1
+- fix: upgrade style-loader to version 1.0.0
+- fix: Build with node-10
+- chore: update yarn.lock
+- fix: babel-preset-env (#190)
+- fix: Added plugin to remove dist folder automatically (#191)
+- fix: Prevent text highlighting overlap between chips (#188)
+- chore: Added official sdk in the footer (#189)
+
+## [3.2.6]
+
+- fix: Add new locales: cz, de
+
+## [3.2.5]
+
+- feat: boolean strategy paramters
+
+## [3.2.4]
+
+- fix: Clean up the UI with empty states
+- feat: Support a few more locales
+
+## [3.2.3]
+
+- fix: Cleanup logut flow
+- chore: remove unleash.beta.variants flag
+
+## [3.2.2]
+
+- fix: Use toggle/on/off endoints to ensure correct state
+- feat: Customisable UI via config
+- chore: Update css-loader to version 2.1.1
+- chore: Update debug to version 4.1.1
+- chore: Update enzyme to latest versions
+- chore: Update redux\* to latest versions
+
+## [3.2.1]
+
+- fix: Fixed bug in history view preventing toggle-view.
+- feat: Add all official client SDKs to footer
+
+## [3.2.0]
+
+- feat: Initial beta support for variants
+- feature: Show tooltips and featuretoggle names in event view
+
+## [3.1.4]
+
+- feat: Add UI support for permission.
+
+## [3.1.3]
+
+- fix(webpack): Strip all comments in css/js bundles.
+
+## [3.1.2]
+
+- chore(package): update webpack to version 4.17.1
+- chore(package): move all dependencies to devDependencies as they are not used outside this module.
+
+## [3.1.1]
+
+- fix(strategy-create): Should be able to open the create strategy view.
+- chore(package): Upgrade redux to version 4.0.0
+
+## [3.1.0]
+
+- fix(react-router): Upgrade to react-router v4.
+- fix(feature-create): Default strategy should be chosen if strategy list is empty.
+- fix(feature-update): Do not change route after feature toggle update.
+
+## [3.0.1]
+
+- fix(feature): Create feature form inside a Card to align UI
+- feat(archive): Improve archive view, UI, search, toggle details
+- fix(navigation): signout more visible
+- fix(signout): make signout works with proxy
+- chore(package): Upgrade react to version 16.2.0
+- chore(package): update sass-loader to version 7.0.1
+
+## [3.0.0]
+
+- Nothing new, just locking down the version.
+
+## [3.0.0-alpha.8]
+
+- feat(timestamps): Make formatting of timestamps configurable.
+- fix(package): Update react-mdl to version 1.11.0
+- fix(package): update normalize.css to version 8.0.0
+
+## [3.0.0-alpha.7]
+
+- Move metrics poller to seperate class
+- Bugfix: CreatedAt set when creating new toggle
+- chore(lint): Added propTypes to all components
+
+## [3.0.0-alpha.6]
+
+- Bugfix: actions should always throw errors
+- Bugfix: filter regex should never throw.
+
+## [3.0.0-alpha.5]
+
+- Add support for simple builtin authentication provider
+- Add support for custom authentication provider (aka Oauth2, etc)
+
+## [3.0.0-alpha.4]
+
+- Added unleash-version details in footer.
+- Some house-keeping
+
+## [3.0.0-alpha.2]
+
+- show sdk version as part of instances details.
+- Bugfix: multiple strategies with list-inputs should work.
+
+## [3.0.0-alpha.1] - 2017-06-28
+
+- updated paths to use new admin api paths
+
+## [2.2.0] - 2017-01-20
+
+- clean filter/sorting and fabbutton #61
+- nicer fallback image for metric progress
+- fix switch width issue
+
+## [2.1.0] - 2017-01-20
+
+- Adjust header #51 #52
diff --git a/frontend/LICENSE b/frontend/LICENSE
new file mode 100644
index 0000000000..daac024875
--- /dev/null
+++ b/frontend/LICENSE
@@ -0,0 +1,202 @@
+Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "{}"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2020 Bricks Software AS
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000000..cdddbbc674
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,68 @@
+# unleash-frontend
+
+This repo 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 unleash-frontend dev server:
+
+```
+cd ~/unleash-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 ~/unleash-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..a14a4f6371
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,145 @@
+{
+ "name": "unleash-frontend",
+ "description": "unleash your features",
+ "version": "4.14.8",
+ "keywords": [
+ "unleash",
+ "feature toggle",
+ "feature",
+ "toggle"
+ ],
+ "files": [
+ "index.js",
+ "build/"
+ ],
+ "repository": {
+ "type": "git",
+ "url": "ssh://git@github.com:Unleash/unleash-frontend.git"
+ },
+ "bugs": {
+ "url": "https://github.com/Unleash/unleash-frontend"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "license": "Apache-2.0",
+ "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",
+ "prepare": "yarn run build",
+ "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",
+ "isready": "yarn lint && yarn fmt && yarn prepare"
+ },
+ "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/renovate.json b/frontend/renovate.json
new file mode 100644
index 0000000000..86c0697753
--- /dev/null
+++ b/frontend/renovate.json
@@ -0,0 +1,10 @@
+{
+ "extends": ["config:base"],
+ "packageRules": [
+ {
+ "matchUpdateTypes": ["minor", "patch", "pin", "digest"],
+ "matchPackagePatterns": ["*"],
+ "automerge": true
+ }
+ ]
+}
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 (
+
+
+
+
+
+
+ }
+ label={formValues.enabled ? 'Enabled' : 'Disabled'}
+ />
+
+
+
+ What is your addon description?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {submitText}
+
+
+ Cancel
+
+
+
+
+
+ );
+};
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 (
+
+ );
+ case 'jira-comment':
+ return (
+
+ );
+ case 'webhook':
+ return (
+
+ );
+ case 'teams':
+ return (
+
+ );
+ case 'datadog':
+ return (
+
+ );
+ 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 (
+
+ );
+};
+
+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
+
+
+ {/*consider these from API or constants. */}
+ Viewer
+ Editor
+ Admin
+
+
+
+
+
+
+ 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
+
+
+
+
+
+ );
+};
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
+
+
+
+
+
+ );
+};
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 (
+
+
+
+ );
+};
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
+
+
+
+
+
+ );
+};
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 (
+
+ );
+};
+
+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 (
+
+
+
+ Create group
+
+
+
+ );
+};
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 (
+
+
+
+ Save
+
+
+
+ );
+};
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
+
+
+
+
+
+
+
+ Save
+
+ {
+ 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();
+ }}
+ >
+
+
+
+
+
+
+
+ );
+};
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.
+
+
+ Create your first 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 (
+
+ );
+};
+
+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 (
+
+
+
+
+ navigate('/admin/create-project-role')
+ }
+ >
+ New project role
+
+ >
+ }
+ />
+ }
+ >
+
+
+
+
+ {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 (
+
+ );
+};
+
+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 (
+
+
+
+ );
+};
+
+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 (
+
+
+
+ navigate('/admin/create-user')}
+ >
+ New user
+
+ >
+ }
+ />
+ }
+ >
+
+
+
+
+ {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 (
+
+ );
+ } 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}
+
+
+
+
+ );
+};
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 (
+
+ );
+};
+
+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}
+
+
+
+
+ Save
+
+ {
+ setAction(CANCEL);
+ triggerTransition();
+ }}
+ className={styles.rightButton}
+ >
+ Cancel
+
+
+
+ >
+ );
+};
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}
+ />
+
+
addValues()}
+ >
+ Add values
+
+
+
+
+
+
+ );
+};
+
+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
+
+
+
+
+
+
+
+ Add custom constraint
+
+
+ }
+ />
+
+ );
+ }
+);
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
+ setOpen(true)}
+ onClose={() => setOpen(false)}
+ onChange={onSelectChange}
+ renderValue={renderValue}
+ >
+ {options.map(operator => (
+
+
+
{operator}
+
+ {formatOperatorDescription(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
+}) => (
+ {icon}}>
+ {label}
+
+);
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 (
+
+ {description}
+
+
+
+ {children}
+
+ );
+};
+
+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}
+
+ {renderSelectItems()}
+
+
+ );
+};
+
+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`] = `
+
+
+
+
+
+
+
+ Warning!
+
+ Your free
+ Pro
+ trial has expired.
+
+ Upgrade trial
+
+ otherwise your
+
+
+ account will be deleted.
+
+
+
+`;
+
+exports[`InstanceStatusBar should warn when the trial has expired 1`] = `
+
+
+
+
+
+
+
+ Warning!
+
+ Your free
+ Pro
+ trial has expired.
+
+ Upgrade trial
+
+ otherwise your
+
+
+ account will be deleted.
+
+
+
+`;
+
+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`] = `
+
+
+
+
+
+
+
+ Warning!
+
+ Your free
+ Pro
+ trial has expired.
+
+ Upgrade trial
+
+ otherwise your
+
+
+ account will be deleted.
+
+
+
+`;
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 (
+
+ );
+};
+
+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.
+
+
+
+
+ Go back
+
+
+ Go home
+
+
+
+
+ );
+};
+
+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={
+ <>
+ {
+ prevPage();
+ if (start > 0) {
+ setLimit(prev => prev - 1);
+ setStart(prev => prev - 1);
+ }
+ }}
+ >
+
+
+ {
+ setPageIndex(0);
+ if (start > 0) {
+ setLimit(STARTLIMIT);
+ setStart(0);
+ }
+ }}
+ >
+
+
+ >
+ }
+ />
+
+ {pages
+ .map((page, idx) => {
+ const active = pageIndex === idx;
+ return (
+ {
+ setPageIndex(idx);
+ }}
+ >
+ {idx + 1}
+
+ );
+ })
+ .slice(start, limit)}
+
+ {
+ nextPage();
+ if (limit < pages.length) {
+ setLimit(prev => prev + 1);
+ setStart(prev => prev + 1);
+ }
+ }}
+ className={classnames(
+ styles.idxBtn,
+ styles.idxBtnRight
+ )}
+ >
+
+
+ {
+ setPageIndex(pages.length - 1);
+ setLimit(pages.length);
+ setStart(pages.length - STARTLIMIT);
+ }}
+ >
+
+
+ >
+ }
+ />
+
+
+ }
+ />
+ );
+};
+
+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 (
+
+
+ }
+ />
+ }
+ >
+ {children}
+
+
+
+ );
+};
+
+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) => (
+
+ {value === index && children}
+
+);
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 (
+
+
+
+
+
+ {children}
+
+
+
+
+
+ {children}
+
+
+
+
+
+ }
+ 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 (
+
+ );
+};
+
+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,
+}) => (
+
+
+ {submitText}
+
+
+
+ Cancel
+
+
+);
+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}
+
+ {renderSelectItems()}
+
+
+ );
+};
+
+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 ;
+};
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 (
+
+ );
+};
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.
+
+
+
+
+ Go back
+
+
+ >
+ }
+ />
+ );
+};
+
+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 (
+
+ );
+};
+
+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 (
+
+ );
+};
+
+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.
+
+
+
+
+ );
+};
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={
+
+ New feature toggle
+
+ }
+ />
+ );
+};
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 (
+
+ );
+};
+
+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(event.currentTarget);
+ }}
+ disabled={!enabled}
+ variant="outlined"
+ >
+ Copy from another 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 (
+
+ );
+};
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}
+
+
+
+ {togglePreviewIcon}
+
+
+
+
+
+
+
+
+ );
+};
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
+
+
+
+
+
+ 4 years ago
+
+
+
+
+
+ another's description
+
+
+
+
+
+
+
+
+
+ default
+
+
+
+
+ ,
+
,
+]
+`;
+
+exports[`renders correctly with one feature without permission 1`] = `
+[
+
+
+
+
+ ⊕
+
+
+
+
+
+
+
+
+
+
+
+ Another
+
+
+
+
+
+ 4 years ago
+
+
+
+
+
+ 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`] = `
+[
+
+
+
+
+
+
+
+ Feature toggles
+
+
+
+
+
+
+ Sorted by:
+
+
+
+ By Name
+
+
+
+
+
+ Toggle
+
+
+
+
+
+
+
+
+
+
+
+
+
+
,
+
,
+]
+`;
+
+exports[`renders correctly with one feature without permissions 1`] = `
+[
+
+
+
+
+
+
+
+ Feature toggles
+
+
+
+
+
+
+ Sorted by:
+
+
+
+ By Name
+
+
+
+
+
+ Toggle
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
,
+
+ 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
+
+
+ >
+
+ >
+ );
+};
+
+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 (
+
+ );
+};
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:
+
+
+ }
+ 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 (
+
+ );
+ case 'jira':
+ return (
+
+ );
+ case 'webhook':
+ return (
+
+ );
+ default:
+ return ;
+ }
+ } else {
+ return {typeName[0].toUpperCase()} ;
+ }
+ };
+
+ const renderTag = (t: ITag) => (
+
+ }
+ onDelete={
+ canDeleteTag
+ ? () => {
+ setShowDelDialog(true);
+ setSelectedTag({ type: t.type, value: t.value });
+ }
+ : undefined
+ }
+ />
+ );
+
+ return (
+
+
{
+ setShowDelDialog(false);
+ setSelectedTag({ type: '', value: '' });
+ }}
+ onClick={() => {
+ setShowDelDialog(false);
+ handleDelete();
+ setSelectedTag({ type: '', value: '' });
+ }}
+ title="Are you sure you want to delete this tag?"
+ />
+
+
+
0}
+ show={tags.map(renderTag)}
+ elseShow={No tags to display
}
+ />
+
+
+ );
+};
+
+export default FeatureOverviewTags;
diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSegment/FeatureOverviewSegment.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSegment/FeatureOverviewSegment.tsx
new file mode 100644
index 0000000000..e6bce9fd9d
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSegment/FeatureOverviewSegment.tsx
@@ -0,0 +1,33 @@
+import { Fragment } from 'react';
+import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator';
+import { SegmentItem } from '../../../../common/SegmentItem/SegmentItem';
+
+interface IFeatureOverviewSegmentProps {
+ strategyId: string;
+}
+
+export const FeatureOverviewSegment = ({
+ strategyId,
+}: IFeatureOverviewSegmentProps) => {
+ const { segments } = useSegments(strategyId);
+
+ if (!segments || segments.length === 0) {
+ return null;
+ }
+
+ return (
+ <>
+ {segments.map((segment, index) => (
+
+ 0}
+ show={ }
+ />
+
+
+ ))}
+ >
+ );
+};
diff --git a/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettings.styles.ts b/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettings.styles.ts
new file mode 100644
index 0000000000..6413100062
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettings.styles.ts
@@ -0,0 +1,30 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ innerContainer: {
+ display: 'flex',
+ },
+ bodyContainer: {
+ padding: 0,
+ },
+ listContainer: {
+ width: '20%',
+ borderRight: `1px solid ${theme.palette.grey[300]}`,
+ padding: '1rem 0',
+ [theme.breakpoints.down('md')]: {
+ width: '35%',
+ },
+ },
+ listItem: {
+ padding: '0.75rem 2rem',
+ },
+ innerBodyContainer: {
+ padding: '2rem',
+ display: 'flex',
+ flexDirection: 'column',
+ width: 400,
+ ['& > *']: {
+ margin: '0.5rem 0',
+ },
+ },
+}));
diff --git a/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettings.tsx b/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettings.tsx
new file mode 100644
index 0000000000..82b04f0661
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettings.tsx
@@ -0,0 +1,65 @@
+import { useState } from 'react';
+import { PageContent } from 'component/common/PageContent/PageContent';
+import { useStyles } from './FeatureSettings.styles';
+import { List, ListItem } from '@mui/material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import FeatureSettingsProject from './FeatureSettingsProject/FeatureSettingsProject';
+import { FeatureSettingsInformation } from './FeatureSettingsInformation/FeatureSettingsInformation';
+import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+
+const METADATA = 'metadata';
+const PROJECT = 'project';
+
+export const FeatureSettings = () => {
+ const { classes: styles } = useStyles();
+ const projectId = useRequiredPathParam('projectId');
+ const featureId = useRequiredPathParam('featureId');
+ const [settings, setSettings] = useState(METADATA);
+ const { uiConfig } = useUiConfig();
+
+ return (
+
+
+
+
+ setSettings(METADATA)}
+ selected={settings === METADATA}
+ >
+ Metadata
+
+ setSettings(PROJECT)}
+ selected={settings === PROJECT}
+ hidden={!uiConfig.flags.P}
+ >
+ Project
+
+
+
+
+
+ }
+ />
+ }
+ />
+
+
+
+ );
+};
diff --git a/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettingsInformation/FeatureSettingsInformation.style.ts b/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettingsInformation/FeatureSettingsInformation.style.ts
new file mode 100644
index 0000000000..3b2247ce0d
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettingsInformation/FeatureSettingsInformation.style.ts
@@ -0,0 +1,11 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ container: {
+ display: 'flex',
+ alignItems: 'center',
+ },
+ header: {
+ fontSize: theme.fontSizes.mainHeader,
+ },
+}));
diff --git a/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettingsInformation/FeatureSettingsInformation.tsx b/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettingsInformation/FeatureSettingsInformation.tsx
new file mode 100644
index 0000000000..8b8a32e343
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettingsInformation/FeatureSettingsInformation.tsx
@@ -0,0 +1,64 @@
+import { Typography } from '@mui/material';
+import { Edit } from '@mui/icons-material';
+import { useNavigate } from 'react-router-dom';
+import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
+import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
+import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions';
+import { useStyles } from './FeatureSettingsInformation.style';
+
+interface IFeatureSettingsInformationProps {
+ projectId: string;
+ featureId: string;
+}
+
+export const FeatureSettingsInformation = ({
+ projectId,
+ featureId,
+}: IFeatureSettingsInformationProps) => {
+ const { classes: styles } = useStyles();
+ const { feature } = useFeature(projectId, featureId);
+ const navigate = useNavigate();
+
+ const onEdit = () => {
+ navigate(`/projects/${projectId}/features/${featureId}/edit`);
+ };
+
+ return (
+ <>
+
+
+ Feature information
+
+
+
+
+
+
+ Name: {feature.name}
+
+
+ Description:{' '}
+
+ {!feature.description?.length
+ ? 'no description'
+ : feature.description}
+
+
+
+ Type: {feature.type}
+
+
+ Impression Data:{' '}
+
+ {feature.impressionData ? 'enabled' : 'disabled'}
+
+
+ >
+ );
+};
diff --git a/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettingsMetadata/FeatureTypeSelect/FeatureTypeSelect.tsx b/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettingsMetadata/FeatureTypeSelect/FeatureTypeSelect.tsx
new file mode 100644
index 0000000000..b23a7a4e32
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettingsMetadata/FeatureTypeSelect/FeatureTypeSelect.tsx
@@ -0,0 +1,48 @@
+import useFeatureTypes from 'hooks/api/getters/useFeatureTypes/useFeatureTypes';
+import GeneralSelect, {
+ ISelectOption,
+ IGeneralSelectProps,
+} from 'component/common/GeneralSelect/GeneralSelect';
+
+interface IFeatureTypeSelectProps
+ extends Omit {
+ value: string;
+ editable: boolean;
+}
+
+const FeatureTypeSelect = ({
+ editable,
+ value,
+ id,
+ label,
+ onChange,
+ ...rest
+}: IFeatureTypeSelectProps) => {
+ const { featureTypes } = useFeatureTypes();
+
+ const options: ISelectOption[] = featureTypes.map(t => ({
+ key: t.id,
+ label: t.name,
+ title: t.description,
+ }));
+
+ if (!options.some(o => o.key === value)) {
+ options.push({ key: value, label: value });
+ }
+
+ return (
+ <>
+
+ >
+ );
+};
+
+export default FeatureTypeSelect;
diff --git a/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettingsProject/FeatureProjectSelect/FeatureProjectSelect.tsx b/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettingsProject/FeatureProjectSelect/FeatureProjectSelect.tsx
new file mode 100644
index 0000000000..59cdf17916
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettingsProject/FeatureProjectSelect/FeatureProjectSelect.tsx
@@ -0,0 +1,62 @@
+import useProjects from 'hooks/api/getters/useProjects/useProjects';
+import { IProjectCard } from 'interfaces/project';
+import GeneralSelect, {
+ ISelectOption,
+ IGeneralSelectProps,
+} from 'component/common/GeneralSelect/GeneralSelect';
+import React from 'react';
+
+interface IFeatureProjectSelectProps
+ extends Omit {
+ enabled: boolean;
+ value: string;
+ filter: (projectId: string) => boolean;
+}
+
+const FeatureProjectSelect = ({
+ enabled,
+ value,
+ onChange,
+ filter,
+ ...rest
+}: IFeatureProjectSelectProps) => {
+ const { projects } = useProjects();
+
+ if (!enabled) {
+ return null;
+ }
+
+ const formatOption = (project: IProjectCard) => {
+ return {
+ key: project.id,
+ label: project.name,
+ title: project.description,
+ };
+ };
+
+ let options: ISelectOption[];
+
+ if (filter) {
+ options = projects
+ .filter(project => filter(project.id))
+ .map(formatOption);
+ } else {
+ options = projects.map(formatOption);
+ }
+
+ if (value && !options.find(o => o.key === value)) {
+ options.push({ key: value, label: value });
+ }
+
+ return (
+
+ );
+};
+
+export default FeatureProjectSelect;
diff --git a/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettingsProject/FeatureSettingsProject.tsx b/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettingsProject/FeatureSettingsProject.tsx
new file mode 100644
index 0000000000..b4cee80b8f
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettingsProject/FeatureSettingsProject.tsx
@@ -0,0 +1,82 @@
+import { useContext, useState, useMemo } from 'react';
+import { useNavigate } from 'react-router';
+import AccessContext from 'contexts/AccessContext';
+import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
+import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
+import useToast from 'hooks/useToast';
+import { MOVE_FEATURE_TOGGLE } from 'component/providers/AccessProvider/permissions';
+import PermissionButton from 'component/common/PermissionButton/PermissionButton';
+import FeatureProjectSelect from './FeatureProjectSelect/FeatureProjectSelect';
+import FeatureSettingsProjectConfirm from './FeatureSettingsProjectConfirm/FeatureSettingsProjectConfirm';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
+import useProjects from 'hooks/api/getters/useProjects/useProjects';
+
+const FeatureSettingsProject = () => {
+ const { hasAccess } = useContext(AccessContext);
+ const projectId = useRequiredPathParam('projectId');
+ const featureId = useRequiredPathParam('featureId');
+ const { feature, refetchFeature } = useFeature(projectId, featureId);
+ const [showConfirmDialog, setShowConfirmDialog] = useState(false);
+ const { changeFeatureProject } = useFeatureApi();
+ const { setToastData, setToastApiError } = useToast();
+ const [project, setProject] = useState(projectId);
+ const { projects } = useProjects();
+ const navigate = useNavigate();
+
+ const onConfirm = async () => {
+ try {
+ if (project) {
+ await changeFeatureProject(projectId, featureId, project);
+ refetchFeature();
+ setToastData({ title: 'Project changed', type: 'success' });
+ setShowConfirmDialog(false);
+ navigate(
+ `/projects/${project}/features/${featureId}/settings`,
+ { replace: true }
+ );
+ }
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ };
+
+ const targetProjectIds = useMemo(() => {
+ return projects
+ .map(project => project.id)
+ .filter(projectId => hasAccess(MOVE_FEATURE_TOGGLE, projectId));
+ }, [projects, hasAccess]);
+
+ if (targetProjectIds.length === 0) {
+ return null;
+ }
+
+ return (
+ <>
+ targetProjectIds.includes(projectId)}
+ enabled
+ />
+ setShowConfirmDialog(true)}
+ disabled={project === projectId}
+ projectId={projectId}
+ >
+ Save
+
+ setShowConfirmDialog(false)}
+ onClick={onConfirm}
+ />
+ >
+ );
+};
+
+export default FeatureSettingsProject;
diff --git a/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettingsProject/FeatureSettingsProjectConfirm/FeatureSettingsProjectConfirm.styles.ts b/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettingsProject/FeatureSettingsProjectConfirm/FeatureSettingsProjectConfirm.styles.ts
new file mode 100644
index 0000000000..6525f8ecf5
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettingsProject/FeatureSettingsProjectConfirm/FeatureSettingsProjectConfirm.styles.ts
@@ -0,0 +1,9 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ container: {
+ display: 'grid',
+ gap: theme.spacing(2),
+ paddingTop: theme.spacing(2),
+ },
+}));
diff --git a/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettingsProject/FeatureSettingsProjectConfirm/FeatureSettingsProjectConfirm.tsx b/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettingsProject/FeatureSettingsProjectConfirm/FeatureSettingsProjectConfirm.tsx
new file mode 100644
index 0000000000..4ab38c3096
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureSettings/FeatureSettingsProject/FeatureSettingsProjectConfirm/FeatureSettingsProjectConfirm.tsx
@@ -0,0 +1,82 @@
+import { useMemo } from 'react';
+import useProject from 'hooks/api/getters/useProject/useProject';
+import { IFeatureToggle } from 'interfaces/featureToggle';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+import { useStyles } from './FeatureSettingsProjectConfirm.styles';
+import { arraysHaveSameItems } from 'utils/arraysHaveSameItems';
+import { Alert } from '@mui/material';
+
+interface IFeatureSettingsProjectConfirm {
+ projectId: string;
+ open: boolean;
+ onClose: () => void;
+ onClick: (args: any) => void;
+ feature: IFeatureToggle;
+}
+
+const FeatureSettingsProjectConfirm = ({
+ projectId,
+ open,
+ onClose,
+ onClick,
+ feature,
+}: IFeatureSettingsProjectConfirm) => {
+ const { project } = useProject(projectId);
+ const { classes: styles } = useStyles();
+
+ const hasSameEnvironments: boolean = useMemo(() => {
+ return arraysHaveSameItems(
+ feature.environments.map(env => env.name),
+ project.environments
+ );
+ }, [feature, project]);
+
+ return (
+
+
+
+ This feature toggle is compatible with the new
+ project.
+
+
+ Are you sure you want to change the project for this
+ toggle?
+
+
+
+ }
+ elseShow={
+
+
+
+ Incompatible project environments
+
+
+ In order to move a feature toggle between two
+ projects, both projects must have the exact same
+ environments enabled.
+
+
+
+ }
+ />
+ );
+};
+
+export default FeatureSettingsProjectConfirm;
diff --git a/frontend/src/component/feature/FeatureView/FeatureStatus/FeatureStatus.styles.ts b/frontend/src/component/feature/FeatureView/FeatureStatus/FeatureStatus.styles.ts
new file mode 100644
index 0000000000..2d29ff8027
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureStatus/FeatureStatus.styles.ts
@@ -0,0 +1,16 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ container: {
+ width: '42px',
+ height: '42px',
+ fontSize: '0.7em',
+ background: 'gray',
+ borderRadius: theme.shape.borderRadius,
+ textAlign: 'center',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: '13px 10px',
+ },
+}));
diff --git a/frontend/src/component/feature/FeatureView/FeatureStatus/FeatureStatus.tsx b/frontend/src/component/feature/FeatureView/FeatureStatus/FeatureStatus.tsx
new file mode 100644
index 0000000000..6767e9657d
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureStatus/FeatureStatus.tsx
@@ -0,0 +1,114 @@
+import { useStyles } from './FeatureStatus.styles';
+import TimeAgo from 'react-timeago';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { Tooltip, TooltipProps } from '@mui/material';
+import React from 'react';
+
+function generateUnit(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 '';
+ }
+}
+
+function getColor(unit?: string): string {
+ switch (unit) {
+ case 'second':
+ return '#98E3AF';
+ case 'minute':
+ return '#98E3AF';
+ case 'hour':
+ return '#98E3AF';
+ case 'day':
+ return '#98E3AF';
+ case 'week':
+ return '#ECD875';
+ case 'month':
+ return '#F5A69A';
+ case 'year':
+ return '#F5A69A';
+ default:
+ return '#EDF0F1';
+ }
+}
+
+interface IFeatureStatusProps {
+ lastSeenAt?: string | Date | null;
+ tooltipPlacement?: TooltipProps['placement'];
+}
+
+const FeatureStatus = ({
+ lastSeenAt,
+ tooltipPlacement,
+}: IFeatureStatusProps) => {
+ const { classes: styles } = useStyles();
+
+ const Wrapper = (
+ props: React.PropsWithChildren<{ color: string; toolTip: string }>
+ ) => {
+ return (
+
+
+ {props.children}
+
+
+ );
+ };
+
+ return (
+ (
+ {
+ return (
+
+ {value}
+ {generateUnit(unit)}
+
+ );
+ }}
+ />
+ )}
+ elseShow={
+
+ ⊕
+
+ }
+ />
+ );
+};
+
+export default FeatureStatus;
diff --git a/frontend/src/component/feature/FeatureView/FeatureType/FeatureType.styles.ts b/frontend/src/component/feature/FeatureView/FeatureType/FeatureType.styles.ts
new file mode 100644
index 0000000000..2611881121
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureType/FeatureType.styles.ts
@@ -0,0 +1,7 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ icon: {
+ color: theme.palette.inactiveIcon,
+ },
+}));
diff --git a/frontend/src/component/feature/FeatureView/FeatureType/FeatureType.tsx b/frontend/src/component/feature/FeatureView/FeatureType/FeatureType.tsx
new file mode 100644
index 0000000000..161d2f9659
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureType/FeatureType.tsx
@@ -0,0 +1,25 @@
+import { useStyles } from './FeatureType.styles';
+import { Tooltip } from '@mui/material';
+import { getFeatureTypeIcons } from 'utils/getFeatureTypeIcons';
+import useFeatureTypes from 'hooks/api/getters/useFeatureTypes/useFeatureTypes';
+
+interface IFeatureTypeProps {
+ type: string;
+}
+
+const FeatureStatus = ({ type }: IFeatureTypeProps) => {
+ const { classes: styles } = useStyles();
+ const { featureTypes } = useFeatureTypes();
+ const IconComponent = getFeatureTypeIcons(type);
+
+ const typeName = featureTypes.filter(t => t.id === type).map(t => t.name);
+ const title = `"${typeName || type}" toggle`;
+
+ return (
+
+
+
+ );
+};
+
+export default FeatureStatus;
diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariants.styles.ts b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariants.styles.ts
new file mode 100644
index 0000000000..fd3ed38125
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariants.styles.ts
@@ -0,0 +1,9 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ container: {
+ borderRadius: '12.5px',
+ backgroundColor: '#fff',
+ padding: '2rem',
+ },
+}));
diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariants.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariants.tsx
new file mode 100644
index 0000000000..cc842ca49d
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariants.tsx
@@ -0,0 +1,10 @@
+import { FeatureVariantsList } from './FeatureVariantsList/FeatureVariantsList';
+import { usePageTitle } from 'hooks/usePageTitle';
+
+const FeatureVariants = () => {
+ usePageTitle('Variants');
+
+ return ;
+};
+
+export default FeatureVariants;
diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/AddFeatureVariant.styles.ts b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/AddFeatureVariant.styles.ts
new file mode 100644
index 0000000000..c02a3587c2
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/AddFeatureVariant.styles.ts
@@ -0,0 +1,34 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ error: {
+ color: theme.palette.error.main,
+ fontSize: theme.fontSizes.smallBody,
+ position: 'relative',
+ },
+ input: {
+ maxWidth: 350,
+ width: '100%',
+ },
+ grid: {
+ marginBottom: '0.5rem',
+ },
+ weightInput: {
+ marginRight: '0.8rem',
+ },
+ label: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '1ch',
+ marginBottom: '1rem',
+ },
+ info: {
+ width: '18.5px',
+ height: '18.5px',
+ color: 'grey',
+ },
+ select: {
+ minWidth: '100px',
+ width: '100%',
+ },
+}));
diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/AddFeatureVariant.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/AddFeatureVariant.tsx
new file mode 100644
index 0000000000..8c45b00137
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/AddFeatureVariant.tsx
@@ -0,0 +1,376 @@
+import React, { useEffect, useState } from 'react';
+import {
+ Button,
+ FormControl,
+ FormControlLabel,
+ Grid,
+ InputAdornment,
+} from '@mui/material';
+import { weightTypes } from './enums';
+import { OverrideConfig } from './OverrideConfig/OverrideConfig';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { useThemeStyles } from 'themes/themeStyles';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+import { modalStyles, trim } from 'component/common/util';
+import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch';
+import { UPDATE_FEATURE_VARIANTS } from 'component/providers/AccessProvider/permissions';
+import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
+import { IFeatureVariant } from 'interfaces/featureToggle';
+import cloneDeep from 'lodash.clonedeep';
+import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
+import { useStyles } from './AddFeatureVariant.styles';
+import Input from 'component/common/Input/Input';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
+import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
+import { useOverrides } from './useOverrides';
+import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
+
+const payloadOptions = [
+ { key: 'string', label: 'string' },
+ { key: 'json', label: 'json' },
+ { key: 'csv', label: 'csv' },
+];
+
+const EMPTY_PAYLOAD = { type: 'string', value: '' };
+
+interface IAddVariantProps {
+ showDialog: boolean;
+ closeDialog: () => void;
+ save: (variantToSave: IFeatureVariant) => Promise;
+ editVariant: IFeatureVariant;
+ validateName: (value: string) => Record | undefined;
+ validateWeight: (value: string) => Record | undefined;
+ title: string;
+ editing: boolean;
+}
+
+export const AddVariant = ({
+ showDialog,
+ closeDialog,
+ save,
+ editVariant,
+ validateName,
+ validateWeight,
+ title,
+ editing,
+}: IAddVariantProps) => {
+ const { classes: styles } = useStyles();
+ const [data, setData] = useState>({});
+ const [payload, setPayload] = useState(EMPTY_PAYLOAD);
+ const [overrides, overridesDispatch] = useOverrides([]);
+ const [error, setError] = useState>({});
+ const { classes: themeStyles } = useThemeStyles();
+ const projectId = useRequiredPathParam('projectId');
+ const featureId = useRequiredPathParam('featureId');
+ const { feature } = useFeature(projectId, featureId);
+ const [variants, setVariants] = useState([]);
+ const { context } = useUnleashContext();
+
+ const isValidJSON = (input: string): boolean => {
+ try {
+ JSON.parse(input);
+ return true;
+ } catch (e: unknown) {
+ setError({
+ payload: 'Invalid JSON',
+ });
+ return false;
+ }
+ };
+
+ const clear = () => {
+ if (editVariant) {
+ setData({
+ name: editVariant.name,
+ weight: String(editVariant.weight / 10),
+ weightType: editVariant.weightType || weightTypes.VARIABLE,
+ stickiness: editVariant.stickiness,
+ });
+ if (editVariant.payload) {
+ setPayload(editVariant.payload);
+ } else {
+ setPayload(EMPTY_PAYLOAD);
+ }
+ if (editVariant.overrides) {
+ overridesDispatch({
+ type: 'SET',
+ payload: editVariant.overrides,
+ });
+ } else {
+ overridesDispatch({ type: 'CLEAR' });
+ }
+ } else {
+ setData({});
+ setPayload(EMPTY_PAYLOAD);
+ overridesDispatch({ type: 'CLEAR' });
+ }
+ setError({});
+ };
+
+ const setClonedVariants = (clonedVariants: IFeatureVariant[]) =>
+ setVariants(cloneDeep(clonedVariants));
+
+ useEffect(() => {
+ if (feature) {
+ setClonedVariants(feature.variants);
+ }
+ }, [feature.variants]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ useEffect(() => {
+ clear();
+ }, [editVariant]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const setVariantValue = (
+ e: React.ChangeEvent
+ ) => {
+ const { name, value } = e.target;
+ setData({
+ ...data,
+ [name]: trim(value),
+ });
+ };
+
+ const setVariantWeightType = (e: React.ChangeEvent) => {
+ const { checked, name } = e.target;
+ const weightType = checked ? weightTypes.FIX : weightTypes.VARIABLE;
+ setData({
+ ...data,
+ [name]: weightType,
+ });
+ };
+
+ const submit = async (e: React.FormEvent) => {
+ setError({});
+ e.preventDefault();
+
+ const nameValidation = validateName(data.name);
+ if (nameValidation) {
+ setError(nameValidation);
+ return;
+ }
+ const weightValidation = validateWeight(data.weight);
+ if (weightValidation) {
+ setError(weightValidation);
+ return;
+ }
+ const validJSON =
+ payload.type === 'json' && !isValidJSON(payload.value);
+ if (validJSON) {
+ return;
+ }
+
+ try {
+ const variant: IFeatureVariant = {
+ name: data.name,
+ weight: Number(data.weight) * 10,
+ weightType: data.weightType,
+ stickiness: data.stickiness,
+ payload: payload.value ? payload : undefined,
+ overrides: overrides
+ .map(o => ({
+ contextName: o.contextName,
+ values: o.values,
+ }))
+ .filter(o => o.values && o.values.length > 0),
+ };
+ await save(variant);
+ clear();
+ closeDialog();
+ } catch (e: unknown) {
+ const error = formatUnknownError(e);
+ if (error.includes('duplicate value')) {
+ setError({ name: 'A variant with that name already exists.' });
+ } else if (error.includes('must be a number')) {
+ setError({ weight: 'Weight must be a number' });
+ } else {
+ const msg = error || 'Could not add variant';
+ setError({ general: msg });
+ }
+ }
+ };
+
+ const onPayload = (name: string) => (value: string) => {
+ setError({ payload: '' });
+ setPayload({ ...payload, [name]: value });
+ };
+
+ const onCancel = (e: React.SyntheticEvent) => {
+ e.preventDefault();
+ clear();
+ closeDialog();
+ };
+
+ const onAddOverride = () => {
+ if (context.length > 0) {
+ overridesDispatch({
+ type: 'ADD',
+ payload: { contextName: context[0].name, values: [] },
+ });
+ }
+ };
+
+ const isFixWeight = data.weightType === weightTypes.FIX;
+
+ const formId = 'add-feature-variant-form';
+
+ return (
+
+
+
+ );
+};
diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/OverrideConfig/OverrideConfig.styles.ts b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/OverrideConfig/OverrideConfig.styles.ts
new file mode 100644
index 0000000000..7c0281c5db
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/OverrideConfig/OverrideConfig.styles.ts
@@ -0,0 +1,7 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ contextFieldSelect: {
+ marginRight: '8px',
+ },
+}));
diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/OverrideConfig/OverrideConfig.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/OverrideConfig/OverrideConfig.tsx
new file mode 100644
index 0000000000..e38188b089
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/OverrideConfig/OverrideConfig.tsx
@@ -0,0 +1,154 @@
+import { ChangeEvent, VFC } from 'react';
+import classnames from 'classnames';
+import { Grid, IconButton, TextField, Tooltip } from '@mui/material';
+import { Delete } from '@mui/icons-material';
+import { useStyles } from './OverrideConfig.styles';
+import { Autocomplete } from '@mui/material';
+import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
+import { useThemeStyles } from 'themes/themeStyles';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { InputListField } from 'component/common/InputListField/InputListField';
+import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
+import { IOverride } from 'interfaces/featureToggle';
+import { OverridesDispatchType } from '../useOverrides';
+
+interface IOverrideConfigProps {
+ overrides: IOverride[];
+ overridesDispatch: OverridesDispatchType;
+}
+
+export const OverrideConfig: VFC = ({
+ overrides,
+ overridesDispatch,
+}) => {
+ const { classes: styles } = useStyles();
+ const { classes: themeStyles } = useThemeStyles();
+
+ const { context } = useUnleashContext();
+ const contextNames = context.map(({ name }) => ({
+ key: name,
+ label: name,
+ }));
+
+ const updateValues = (index: number) => (values: string[]) => {
+ overridesDispatch({
+ type: 'UPDATE_VALUES_AT',
+ payload: [index, values],
+ });
+ };
+
+ const updateSelectValues =
+ (index: number) => (event: ChangeEvent, options: string[]) => {
+ event?.preventDefault();
+ overridesDispatch({
+ type: 'UPDATE_VALUES_AT',
+ payload: [index, options ? options : []],
+ });
+ };
+
+ return (
+ <>
+ {overrides.map((override, index) => {
+ const definition = context.find(
+ c => c.name === override.contextName
+ );
+ const legalValues =
+ definition?.legalValues?.map(({ value }) => value) || [];
+ const filteredValues = override.values.filter(value =>
+ legalValues.includes(value)
+ );
+
+ return (
+
+
+ {
+ overridesDispatch({
+ type: 'UPDATE_TYPE_AT',
+ payload: [index, value],
+ });
+ }}
+ />
+
+
+ 0
+ )}
+ show={
+ {
+ return option === value;
+ }}
+ options={legalValues}
+ onChange={updateSelectValues(index)}
+ getOptionLabel={option => option}
+ value={filteredValues}
+ style={{ width: '100%' }}
+ filterSelectedOptions
+ size="small"
+ renderInput={params => (
+
+ )}
+ />
+ }
+ elseShow={
+
+ }
+ />
+
+
+
+ {
+ event.preventDefault();
+ overridesDispatch({
+ type: 'REMOVE',
+ payload: index,
+ });
+ }}
+ size="large"
+ >
+
+
+
+
+
+ );
+ })}
+ >
+ );
+};
diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/enums.ts b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/enums.ts
new file mode 100644
index 0000000000..bf588779cf
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/enums.ts
@@ -0,0 +1,4 @@
+export const weightTypes = {
+ FIX: 'fix',
+ VARIABLE: 'variable',
+};
diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/useOverrides.test.ts b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/useOverrides.test.ts
new file mode 100644
index 0000000000..1723768782
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/useOverrides.test.ts
@@ -0,0 +1,154 @@
+import { renderHook, act } from '@testing-library/react-hooks';
+import { useOverrides } from './useOverrides';
+
+describe('useOverrides', () => {
+ it('should return initial value', () => {
+ const { result } = renderHook(() =>
+ useOverrides([{ contextName: 'context', values: ['a', 'b'] }])
+ );
+
+ expect(result.current[0]).toEqual([
+ { contextName: 'context', values: ['a', 'b'] },
+ ]);
+ });
+
+ it('should set value with an action', () => {
+ const { result } = renderHook(() =>
+ useOverrides([
+ { contextName: 'X', values: ['a', 'b'] },
+ { contextName: 'Y', values: ['a', 'b', 'c'] },
+ ])
+ );
+
+ const [, dispatch] = result.current;
+ act(() => {
+ dispatch({
+ type: 'SET',
+ payload: [{ contextName: 'Z', values: ['d'] }],
+ });
+ });
+
+ expect(result.current[0]).toEqual([
+ { contextName: 'Z', values: ['d'] },
+ ]);
+ });
+
+ it('should clear all overrides with an action', () => {
+ const { result } = renderHook(() =>
+ useOverrides([
+ { contextName: 'X', values: ['a', 'b'] },
+ { contextName: 'Y', values: ['a', 'b', 'c'] },
+ ])
+ );
+
+ const [, dispatch] = result.current;
+ act(() => {
+ dispatch({
+ type: 'CLEAR',
+ });
+ });
+
+ expect(result.current[0]).toEqual([]);
+ });
+
+ it('should add value with an action', () => {
+ const { result } = renderHook(() =>
+ useOverrides([
+ { contextName: 'X', values: ['a'] },
+ { contextName: 'Y', values: ['b'] },
+ ])
+ );
+
+ const [, dispatch] = result.current;
+ act(() => {
+ dispatch({
+ type: 'ADD',
+ payload: { contextName: 'Z', values: ['c'] },
+ });
+ });
+
+ expect(result.current[0]).toEqual([
+ { contextName: 'X', values: ['a'] },
+ { contextName: 'Y', values: ['b'] },
+ { contextName: 'Z', values: ['c'] },
+ ]);
+ });
+
+ it('should remove override at index with an action', () => {
+ const { result } = renderHook(() =>
+ useOverrides([
+ { contextName: '1', values: [] },
+ { contextName: '2', values: ['a'] },
+ { contextName: '3', values: ['b'] },
+ { contextName: '4', values: ['c'] },
+ ])
+ );
+
+ const [, dispatch] = result.current;
+
+ act(() => {
+ dispatch({ type: 'REMOVE', payload: 1 });
+ });
+ expect(result.current[0]).toEqual([
+ { contextName: '1', values: [] },
+ { contextName: '3', values: ['b'] },
+ { contextName: '4', values: ['c'] },
+ ]);
+
+ act(() => {
+ dispatch({ type: 'REMOVE', payload: 2 });
+ });
+ expect(result.current[0]).toEqual([
+ { contextName: '1', values: [] },
+ { contextName: '3', values: ['b'] },
+ ]);
+ });
+
+ it('should update at index with an action', () => {
+ const { result } = renderHook(() =>
+ useOverrides([
+ { contextName: '1', values: [] },
+ { contextName: '2', values: ['a'] },
+ { contextName: '3', values: ['b'] },
+ ])
+ );
+
+ const [, dispatch] = result.current;
+
+ act(() => {
+ dispatch({
+ type: 'UPDATE_VALUES_AT',
+ payload: [1, ['x', 'y', 'z']],
+ });
+ });
+ expect(result.current[0]).toEqual([
+ { contextName: '1', values: [] },
+ { contextName: '2', values: ['x', 'y', 'z'] },
+ { contextName: '3', values: ['b'] },
+ ]);
+ });
+
+ it('should change context at index with an action', () => {
+ const { result } = renderHook(() =>
+ useOverrides([
+ { contextName: '1', values: ['x'] },
+ { contextName: '2', values: ['y'] },
+ { contextName: '3', values: ['z'] },
+ ])
+ );
+
+ const [, dispatch] = result.current;
+
+ act(() => {
+ dispatch({
+ type: 'UPDATE_TYPE_AT',
+ payload: [1, 'NewContext'],
+ });
+ });
+ expect(result.current[0]).toEqual([
+ { contextName: '1', values: ['x'] },
+ { contextName: 'NewContext', values: ['y'] },
+ { contextName: '3', values: ['z'] },
+ ]);
+ });
+});
diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/useOverrides.ts b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/useOverrides.ts
new file mode 100644
index 0000000000..7d0988d0dd
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/useOverrides.ts
@@ -0,0 +1,41 @@
+import { useReducer } from 'react';
+import { IOverride } from 'interfaces/featureToggle';
+
+type OverridesReducerAction =
+ | { type: 'SET'; payload: IOverride[] }
+ | { type: 'CLEAR' }
+ | { type: 'ADD'; payload: IOverride }
+ | { type: 'REMOVE'; payload: number }
+ | { type: 'UPDATE_VALUES_AT'; payload: [number, IOverride['values']] }
+ | { type: 'UPDATE_TYPE_AT'; payload: [number, IOverride['contextName']] };
+
+const overridesReducer = (
+ state: IOverride[],
+ action: OverridesReducerAction
+) => {
+ switch (action.type) {
+ case 'SET':
+ return action.payload;
+ case 'CLEAR':
+ return [];
+ case 'ADD':
+ return [...state, action.payload];
+ case 'REMOVE':
+ return state.filter((_, index) => index !== action.payload);
+ case 'UPDATE_VALUES_AT':
+ const [index1, values] = action.payload;
+ return state.map((item, i) =>
+ i === index1 ? { ...item, values } : item
+ );
+ case 'UPDATE_TYPE_AT':
+ const [index2, contextName] = action.payload;
+ return state.map((item, i) =>
+ i === index2 ? { ...item, contextName } : item
+ );
+ }
+};
+
+export const useOverrides = (initialValue: IOverride[] = []) =>
+ useReducer(overridesReducer, initialValue);
+
+export type OverridesDispatchType = ReturnType[1];
diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/FeatureVariantsList.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/FeatureVariantsList.tsx
new file mode 100644
index 0000000000..807367591a
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/FeatureVariantsList.tsx
@@ -0,0 +1,483 @@
+import * as jsonpatch from 'fast-json-patch';
+
+import {
+ Alert,
+ Table,
+ TableBody,
+ TableCell,
+ TableRow,
+ useMediaQuery,
+} from '@mui/material';
+import { AddVariant } from './AddFeatureVariant/AddFeatureVariant';
+import { useContext, useEffect, useState, useMemo, useCallback } from 'react';
+import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
+import AccessContext from 'contexts/AccessContext';
+import { UPDATE_FEATURE_VARIANTS } from 'component/providers/AccessProvider/permissions';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
+import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
+import { IFeatureVariant } from 'interfaces/featureToggle';
+import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
+import useToast from 'hooks/useToast';
+import { calculateVariantWeight, updateWeight } from 'component/common/util';
+import cloneDeep from 'lodash.clonedeep';
+import useDeleteVariantMarkup from './useDeleteVariantMarkup';
+import PermissionButton from 'component/common/PermissionButton/PermissionButton';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
+import { useTable, useSortBy, useGlobalFilter } from 'react-table';
+import { PageContent } from 'component/common/PageContent/PageContent';
+import { PageHeader } from 'component/common/PageHeader/PageHeader';
+import { SortableTableHeader, TablePlaceholder } from 'component/common/Table';
+import { sortTypes } from 'utils/sortTypes';
+import { PayloadOverridesCell } from './PayloadOverridesCell/PayloadOverridesCell';
+import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
+import theme from 'themes/theme';
+import { VariantsActionCell } from './VariantsActionsCell/VariantsActionsCell';
+
+export const FeatureVariantsList = () => {
+ const { hasAccess } = useContext(AccessContext);
+ const projectId = useRequiredPathParam('projectId');
+ const featureId = useRequiredPathParam('featureId');
+ const { feature, refetchFeature, loading } = useFeature(
+ projectId,
+ featureId
+ );
+ const [variants, setVariants] = useState([]);
+ const [editing, setEditing] = useState(false);
+ const { context } = useUnleashContext();
+ const { setToastData, setToastApiError } = useToast();
+ const { patchFeatureVariants } = useFeatureApi();
+ const [variantToEdit, setVariantToEdit] = useState({});
+ const [showAddVariant, setShowAddVariant] = useState(false);
+ const [stickinessOptions, setStickinessOptions] = useState([]);
+ const [delDialog, setDelDialog] = useState({ name: '', show: false });
+
+ const isMediumScreen = useMediaQuery(theme.breakpoints.down('md'));
+ const isLargeScreen = useMediaQuery(theme.breakpoints.down('lg'));
+
+ useEffect(() => {
+ if (feature) {
+ setClonedVariants(feature.variants);
+ }
+ /* eslint-disable-next-line react-hooks/exhaustive-deps */
+ }, [feature.variants]);
+
+ useEffect(() => {
+ const options = [
+ 'default',
+ ...context.filter(c => c.stickiness).map(c => c.name),
+ ];
+
+ setStickinessOptions(options);
+ }, [context]);
+
+ const editable = hasAccess(UPDATE_FEATURE_VARIANTS, projectId);
+
+ const data = useMemo(() => {
+ if (loading) {
+ return Array(5).fill({
+ name: 'Context name',
+ description: 'Context description when loading',
+ });
+ }
+
+ return feature.variants;
+ }, [feature.variants, loading]);
+
+ const editVariant = useCallback(
+ (name: string) => {
+ const variant = {
+ ...variants.find(variant => variant.name === name),
+ };
+ setVariantToEdit(variant);
+ setEditing(true);
+ setShowAddVariant(true);
+ },
+ [variants, setVariantToEdit, setEditing, setShowAddVariant]
+ );
+
+ const columns = useMemo(
+ () => [
+ {
+ Header: 'Name',
+ accessor: 'name',
+ width: '25%',
+ Cell: ({
+ row: {
+ original: { name },
+ },
+ }: any) => {
+ return {name} ;
+ },
+ sortType: 'alphanumeric',
+ },
+ {
+ Header: 'Payload/Overrides',
+ accessor: 'data',
+ Cell: ({
+ row: {
+ original: { overrides, payload },
+ },
+ }: any) => {
+ return (
+
+ );
+ },
+ disableSortBy: true,
+ },
+ {
+ Header: 'Weight',
+ accessor: 'weight',
+ width: '20%',
+ Cell: ({
+ row: {
+ original: { name, weight },
+ },
+ }: any) => {
+ return (
+
+ {calculateVariantWeight(weight)} %
+
+ );
+ },
+ sortType: 'number',
+ },
+ {
+ Header: 'Type',
+ accessor: 'weightType',
+ width: '20%',
+ Cell: ({
+ row: {
+ original: { weightType },
+ },
+ }: any) => {
+ return {weightType} ;
+ },
+ sortType: 'alphanumeric',
+ },
+ {
+ Header: 'Actions',
+ id: 'Actions',
+ align: 'right',
+ Cell: ({ row: { original } }: any) => (
+
+ ),
+ width: 150,
+ disableSortBy: true,
+ },
+ ],
+ [projectId, editVariant]
+ );
+
+ const initialState = useMemo(
+ () => ({
+ sortBy: [{ id: 'name', desc: false }],
+ }),
+ []
+ );
+
+ const {
+ getTableProps,
+ getTableBodyProps,
+ headerGroups,
+ rows,
+ prepareRow,
+ setHiddenColumns,
+ } = useTable(
+ {
+ columns: columns as any[],
+ data: data as any[],
+ initialState,
+ sortTypes,
+ autoResetGlobalFilter: false,
+ autoResetSortBy: false,
+ disableSortRemove: true,
+ },
+ useGlobalFilter,
+ useSortBy
+ );
+
+ useEffect(() => {
+ const hiddenColumns = [];
+ if (isLargeScreen) {
+ hiddenColumns.push('weightType');
+ }
+ if (isMediumScreen) {
+ hiddenColumns.push('data');
+ }
+ setHiddenColumns(hiddenColumns);
+ }, [setHiddenColumns, isMediumScreen, isLargeScreen]);
+
+ // @ts-expect-error
+ const setClonedVariants = clonedVariants =>
+ setVariants(cloneDeep(clonedVariants));
+
+ const handleCloseAddVariant = () => {
+ setShowAddVariant(false);
+ setEditing(false);
+ setVariantToEdit({});
+ };
+
+ const renderStickiness = () => {
+ if (!variants || variants.length < 2) {
+ return null;
+ }
+
+ const value = variants[0].stickiness || 'default';
+ const options = stickinessOptions.map(c => ({ key: c, label: c }));
+
+ // guard on stickiness being disabled for context field.
+ if (!stickinessOptions.includes(value)) {
+ options.push({ key: value, label: value });
+ }
+
+ const onChange = (value: string) => {
+ updateStickiness(value).catch(console.warn);
+ };
+
+ return (
+
+
+
+
+ By overriding the stickiness you can control which parameter
+ is used to ensure consistent traffic allocation across
+ variants.{' '}
+
+ Read more
+
+
+
+ );
+ };
+
+ const updateStickiness = async (stickiness: string) => {
+ const newVariants = [...variants].map(variant => {
+ return { ...variant, stickiness };
+ });
+
+ const patch = createPatch(newVariants);
+
+ if (patch.length === 0) return;
+
+ try {
+ await patchFeatureVariants(projectId, featureId, patch);
+ refetchFeature();
+ setToastData({
+ title: 'Updated variant',
+ confetti: true,
+ type: 'success',
+ text: 'Successfully updated variant stickiness',
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ };
+
+ const removeVariant = async (name: string) => {
+ let updatedVariants = variants.filter(value => value.name !== name);
+ try {
+ await updateVariants(
+ updatedVariants,
+ 'Successfully removed variant'
+ );
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ };
+ const updateVariant = async (variant: IFeatureVariant) => {
+ const updatedVariants = cloneDeep(variants);
+ const variantIdxToUpdate = updatedVariants.findIndex(
+ (v: IFeatureVariant) => v.name === variant.name
+ );
+ updatedVariants[variantIdxToUpdate] = variant;
+ await updateVariants(updatedVariants, 'Successfully updated variant');
+ };
+
+ const saveNewVariant = async (variant: IFeatureVariant) => {
+ let stickiness = 'default';
+ if (variants?.length > 0) {
+ stickiness = variants[0].stickiness || 'default';
+ }
+ variant.stickiness = stickiness;
+
+ await updateVariants(
+ [...variants, variant],
+ 'Successfully added a variant'
+ );
+ };
+
+ const updateVariants = async (
+ variants: IFeatureVariant[],
+ successText: string
+ ) => {
+ const newVariants = updateWeight(variants, 1000);
+ const patch = createPatch(newVariants);
+
+ if (patch.length === 0) return;
+ try {
+ await patchFeatureVariants(projectId, featureId, patch);
+ refetchFeature();
+ setToastData({
+ title: 'Updated variant',
+ type: 'success',
+ text: successText,
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ };
+
+ const validateName = (name: string) => {
+ if (!name) {
+ return { name: 'Name is required' };
+ }
+ };
+
+ const validateWeight = (weight: string) => {
+ const weightValue = parseInt(weight);
+ if (weightValue > 100 || weightValue < 0) {
+ return { weight: 'weight must be between 0 and 100' };
+ }
+ };
+
+ const delDialogueMarkup = useDeleteVariantMarkup({
+ show: delDialog.show,
+ onClick: () => {
+ removeVariant(delDialog.name);
+ setDelDialog({ name: '', show: false });
+ setToastData({
+ title: 'Deleted variant',
+ type: 'success',
+ text: `Successfully deleted variant`,
+ });
+ },
+ onClose: () => setDelDialog({ show: false, name: '' }),
+ });
+
+ const createPatch = (newVariants: IFeatureVariant[]) => {
+ return jsonpatch.compare(feature.variants, newVariants);
+ };
+
+ const addVariant = () => {
+ setEditing(false);
+ if (variants.length === 0) {
+ setVariantToEdit({ weight: 1000 });
+ } else {
+ setVariantToEdit({
+ weightType: 'variable',
+ });
+ }
+ setShowAddVariant(true);
+ };
+
+ return (
+
+
+ New variant
+
+ >
+ }
+ />
+ }
+ >
+
+ Variants allows you to return a variant object if the feature
+ toggle is considered enabled for the current request. When using
+ variants you should use the{' '}
+ getVariant() method
+ in the Client SDK.
+
+
+
+
+ {rows.map(row => {
+ prepareRow(row);
+ return (
+
+ {row.cells.map(cell => (
+
+ {cell.render('Cell')}
+
+ ))}
+
+ );
+ })}
+
+
+
+ No variants available. Get started by adding one.
+
+ }
+ />
+
+
+
+
+
+
+
+ {
+ if (!editing) {
+ return saveNewVariant(variantToSave);
+ } else {
+ return updateVariant(variantToSave);
+ }
+ }}
+ editing={editing}
+ validateName={validateName}
+ validateWeight={validateWeight}
+ // @ts-expect-error
+ editVariant={variantToEdit}
+ title={editing ? 'Edit variant' : 'Add variant'}
+ />
+
+ {delDialogueMarkup}
+
+ );
+};
diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/PayloadOverridesCell/PayloadOverridesCell.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/PayloadOverridesCell/PayloadOverridesCell.tsx
new file mode 100644
index 0000000000..bdf447a8a7
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/PayloadOverridesCell/PayloadOverridesCell.tsx
@@ -0,0 +1,26 @@
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
+import { IOverride, IPayload } from 'interfaces/featureToggle';
+
+interface IPayloadOverridesCellProps {
+ payload: IPayload;
+ overrides: IOverride[];
+}
+
+export const PayloadOverridesCell = ({
+ payload,
+ overrides,
+}: IPayloadOverridesCellProps) => {
+ return (
+ <>
+ Payload}
+ />
+ 0}
+ show={Overrides }
+ />
+ >
+ );
+};
diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/VariantsActionsCell/VariantsActionsCell.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/VariantsActionsCell/VariantsActionsCell.tsx
new file mode 100644
index 0000000000..4fa0c4a516
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/VariantsActionsCell/VariantsActionsCell.tsx
@@ -0,0 +1,55 @@
+import { Edit, Delete } from '@mui/icons-material';
+import { Box } from '@mui/material';
+import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
+import { UPDATE_FEATURE_VARIANTS } from 'component/providers/AccessProvider/permissions';
+import { IFeatureVariant } from 'interfaces/featureToggle';
+
+interface IVarintsActionCellProps {
+ projectId: string;
+ editVariant: (name: string) => void;
+ setDelDialog: React.Dispatch<
+ React.SetStateAction<{
+ name: string;
+ show: boolean;
+ }>
+ >;
+ variant: IFeatureVariant;
+}
+
+export const VariantsActionCell = ({
+ projectId,
+ setDelDialog,
+ variant,
+ editVariant,
+}: IVarintsActionCellProps) => {
+ return (
+
+ editVariant(variant.name)}
+ >
+
+
+
+ setDelDialog({
+ show: true,
+ name: variant.name,
+ })
+ }
+ >
+
+
+
+ );
+};
diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/useDeleteVariantMarkup.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/useDeleteVariantMarkup.tsx
new file mode 100644
index 0000000000..294546285d
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/useDeleteVariantMarkup.tsx
@@ -0,0 +1,31 @@
+import { Alert } from '@mui/material';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+
+interface IUseDeleteVariantMarkupProps {
+ show: boolean;
+ onClick: () => void;
+ onClose: () => void;
+}
+
+const useDeleteVariantMarkup = ({
+ show,
+ onClick,
+ onClose,
+}: IUseDeleteVariantMarkupProps) => {
+ return (
+
+
+ Deleting this variant will change which variant users receive.
+
+
+ );
+};
+
+export default useDeleteVariantMarkup;
diff --git a/frontend/src/component/feature/FeatureView/FeatureView.styles.ts b/frontend/src/component/feature/FeatureView/FeatureView.styles.ts
new file mode 100644
index 0000000000..e81b728aaf
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureView.styles.ts
@@ -0,0 +1,58 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ container: {
+ borderRadius: theme.shape.borderRadiusLarge,
+ boxShadow: 'none',
+ display: 'flex',
+ },
+ header: {
+ backgroundColor: theme.palette.background.paper,
+ borderRadius: theme.shape.borderRadiusLarge,
+ marginBottom: '1rem',
+ },
+ toggleInfoContainer: {
+ display: 'flex',
+ alignItems: 'center',
+ },
+ toolbarContainer: {
+ flexShrink: 0,
+ display: 'flex',
+ },
+ innerContainer: {
+ padding: '1rem 2rem',
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ [theme.breakpoints.down(500)]: {
+ flexDirection: 'column',
+ },
+ },
+ separator: {
+ width: '100%',
+ backgroundColor: theme.palette.grey[200],
+ height: '1px',
+ },
+ tabContainer: {
+ padding: '0 2rem',
+ },
+ tabButton: {
+ textTransform: 'none',
+ width: 'auto',
+ fontSize: '1rem',
+ padding: '0 !important',
+ [theme.breakpoints.up('md')]: {
+ minWidth: 160,
+ },
+ },
+ featureViewHeader: {
+ fontSize: theme.fontSizes.mainHeader,
+ fontWeight: 'normal',
+ display: 'flex',
+ alignItems: 'center',
+ wordBreak: 'break-all',
+ },
+ statusContainer: {
+ marginLeft: '0.5rem',
+ },
+}));
diff --git a/frontend/src/component/feature/FeatureView/FeatureView.tsx b/frontend/src/component/feature/FeatureView/FeatureView.tsx
new file mode 100644
index 0000000000..eb691c5fd1
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureView.tsx
@@ -0,0 +1,203 @@
+import { Tab, Tabs, useMediaQuery } from '@mui/material';
+import React, { useState } from 'react';
+import { Archive, FileCopy, Label, WatchLater } from '@mui/icons-material';
+import {
+ Link,
+ Route,
+ useNavigate,
+ Routes,
+ useLocation,
+} from 'react-router-dom';
+import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
+import useProject from 'hooks/api/getters/useProject/useProject';
+import {
+ CREATE_FEATURE,
+ DELETE_FEATURE,
+ UPDATE_FEATURE,
+} from 'component/providers/AccessProvider/permissions';
+import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
+import FeatureLog from './FeatureLog/FeatureLog';
+import FeatureOverview from './FeatureOverview/FeatureOverview';
+import FeatureVariants from './FeatureVariants/FeatureVariants';
+import { FeatureMetrics } from './FeatureMetrics/FeatureMetrics';
+import { useStyles } from './FeatureView.styles';
+import { FeatureSettings } from './FeatureSettings/FeatureSettings';
+import useLoading from 'hooks/useLoading';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { FeatureStaleDialog } from 'component/common/FeatureStaleDialog/FeatureStaleDialog';
+import AddTagDialog from './FeatureOverview/AddTagDialog/AddTagDialog';
+import StatusChip from 'component/common/StatusChip/StatusChip';
+import { FeatureNotFound } from 'component/feature/FeatureView/FeatureNotFound/FeatureNotFound';
+import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
+import { FeatureArchiveDialog } from '../../common/FeatureArchiveDialog/FeatureArchiveDialog';
+
+export const FeatureView = () => {
+ const projectId = useRequiredPathParam('projectId');
+ const featureId = useRequiredPathParam('featureId');
+ const { refetch: projectRefetch } = useProject(projectId);
+ const { refetchFeature } = useFeature(projectId, featureId);
+
+ const [openTagDialog, setOpenTagDialog] = useState(false);
+ const [showDelDialog, setShowDelDialog] = useState(false);
+ const [openStaleDialog, setOpenStaleDialog] = useState(false);
+ const smallScreen = useMediaQuery(`(max-width:${500}px)`);
+
+ const { feature, loading, error, status } = useFeature(
+ projectId,
+ featureId
+ );
+
+ const { classes: styles } = useStyles();
+ const navigate = useNavigate();
+ const { pathname } = useLocation();
+ const ref = useLoading(loading);
+
+ const basePath = `/projects/${projectId}/features/${featureId}`;
+
+ const tabData = [
+ {
+ title: 'Overview',
+ path: `${basePath}`,
+ name: 'overview',
+ },
+ {
+ title: 'Metrics',
+ path: `${basePath}/metrics`,
+ name: 'Metrics',
+ },
+ { title: 'Variants', path: `${basePath}/variants`, name: 'Variants' },
+ { title: 'Settings', path: `${basePath}/settings`, name: 'Settings' },
+ {
+ title: 'Event log',
+ path: `${basePath}/logs`,
+ name: 'Event log',
+ },
+ ];
+
+ const activeTab = tabData.find(tab => tab.path === pathname) ?? tabData[0];
+
+ if (status === 404) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+ {feature.name}{' '}
+
+ }
+ />
+
+
+
+
+
+
+
setShowDelDialog(true)}
+ >
+
+
+
setOpenStaleDialog(true)}
+ permission={UPDATE_FEATURE}
+ projectId={projectId}
+ tooltipProps={{
+ title: 'Toggle stale state',
+ }}
+ data-loading
+ >
+
+
+
setOpenTagDialog(true)}
+ permission={UPDATE_FEATURE}
+ projectId={projectId}
+ tooltipProps={{ title: 'Add tag' }}
+ data-loading
+ >
+
+
+
+
+
+
+
+ {tabData.map(tab => (
+ navigate(tab.path)}
+ className={styles.tabButton}
+ />
+ ))}
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ {
+ projectRefetch();
+ navigate(`/projects/${projectId}`);
+ }}
+ onClose={() => setShowDelDialog(false)}
+ projectId={projectId}
+ featureId={featureId}
+ />
+ {
+ setOpenStaleDialog(false);
+ refetchFeature();
+ }}
+ featureId={featureId}
+ projectId={projectId}
+ />
+
+
+ }
+ />
+ );
+};
diff --git a/frontend/src/component/feature/RedirectFeatureView/RedirectFeatureView.tsx b/frontend/src/component/feature/RedirectFeatureView/RedirectFeatureView.tsx
new file mode 100644
index 0000000000..88a41c2f5d
--- /dev/null
+++ b/frontend/src/component/feature/RedirectFeatureView/RedirectFeatureView.tsx
@@ -0,0 +1,33 @@
+import { useEffect, useState } from 'react';
+import { Navigate } from 'react-router-dom';
+import { useFeatures } from 'hooks/api/getters/useFeatures/useFeatures';
+import { getTogglePath } from 'utils/routePathHelpers';
+import { FeatureSchema } from 'openapi';
+import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
+
+const RedirectFeatureView = () => {
+ const featureId = useRequiredPathParam('featureId');
+ const { features = [] } = useFeatures();
+ const [featureToggle, setFeatureToggle] = useState();
+
+ useEffect(() => {
+ const toggle = features.find(
+ (toggle: FeatureSchema) => toggle.name === featureId
+ );
+
+ setFeatureToggle(toggle);
+ }, [features, featureId]);
+
+ if (!featureToggle?.project) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+export default RedirectFeatureView;
diff --git a/frontend/src/component/feature/StrategyTypes/DefaultStrategy/DefaultStrategy.tsx b/frontend/src/component/feature/StrategyTypes/DefaultStrategy/DefaultStrategy.tsx
new file mode 100644
index 0000000000..dd75dcee75
--- /dev/null
+++ b/frontend/src/component/feature/StrategyTypes/DefaultStrategy/DefaultStrategy.tsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import { IStrategy } from 'interfaces/strategy';
+
+interface IDefaultStrategyProps {
+ strategyDefinition: IStrategy;
+}
+
+const DefaultStrategy = ({ strategyDefinition }: IDefaultStrategyProps) => {
+ return {strategyDefinition?.description}
;
+};
+
+export default DefaultStrategy;
diff --git a/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy.tsx b/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy.tsx
new file mode 100644
index 0000000000..9f10c53c8e
--- /dev/null
+++ b/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy.tsx
@@ -0,0 +1,126 @@
+import { Typography } from '@mui/material';
+import { IFeatureStrategyParameters } from 'interfaces/strategy';
+import RolloutSlider from '../RolloutSlider/RolloutSlider';
+import Select from 'component/common/select';
+import React from 'react';
+import Input from 'component/common/Input/Input';
+import {
+ FLEXIBLE_STRATEGY_GROUP_ID,
+ FLEXIBLE_STRATEGY_STICKINESS_ID,
+} from 'utils/testIds';
+import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
+import {
+ parseParameterNumber,
+ parseParameterString,
+} from 'utils/parseParameter';
+
+const builtInStickinessOptions = [
+ { key: 'default', label: 'default' },
+ { key: 'userId', label: 'userId' },
+ { key: 'sessionId', label: 'sessionId' },
+ { key: 'random', label: 'random' },
+];
+
+interface IFlexibleStrategyProps {
+ parameters: IFeatureStrategyParameters;
+ updateParameter: (field: string, value: string) => void;
+ context: any;
+ editable: boolean;
+}
+
+const FlexibleStrategy = ({
+ updateParameter,
+ parameters,
+ context,
+ editable = true,
+}: IFlexibleStrategyProps) => {
+ const onUpdate = (field: string) => (newValue: string) => {
+ updateParameter(field, newValue);
+ };
+
+ const updateRollout = (e: Event, value: number | number[]) => {
+ updateParameter('rollout', value.toString());
+ };
+
+ const resolveStickiness = () =>
+ builtInStickinessOptions.concat(
+ context
+ // @ts-expect-error
+ .filter(c => c.stickiness)
+ .filter(
+ // @ts-expect-error
+ c => !builtInStickinessOptions.find(s => s.key === c.name)
+ )
+ // @ts-expect-error
+ .map(c => ({ key: c.name, label: c.name }))
+ );
+
+ const stickinessOptions = resolveStickiness();
+
+ const rollout =
+ parameters.rollout !== undefined
+ ? parseParameterNumber(parameters.rollout)
+ : 100;
+
+ return (
+
+
+
+
+
+
+ Stickiness
+
+
+ onUpdate('stickiness')(e.target.value)}
+ />
+
+
+
+
+ GroupId
+
+
+ onUpdate('groupId')(e.target.value)}
+ data-testid={FLEXIBLE_STRATEGY_GROUP_ID}
+ />
+
+
+ );
+};
+
+export default FlexibleStrategy;
diff --git a/frontend/src/component/feature/StrategyTypes/GeneralStrategy/GeneralStrategy.tsx b/frontend/src/component/feature/StrategyTypes/GeneralStrategy/GeneralStrategy.tsx
new file mode 100644
index 0000000000..417c0f97dd
--- /dev/null
+++ b/frontend/src/component/feature/StrategyTypes/GeneralStrategy/GeneralStrategy.tsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import { IStrategy, IFeatureStrategyParameters } from 'interfaces/strategy';
+import { styled } from '@mui/system';
+import { StrategyParameter } from 'component/feature/StrategyTypes/StrategyParameter/StrategyParameter';
+import { IFormErrors } from 'hooks/useFormErrors';
+
+interface IGeneralStrategyProps {
+ parameters: IFeatureStrategyParameters;
+ strategyDefinition: IStrategy;
+ updateParameter: (field: string, value: string) => void;
+ editable: boolean;
+ errors: IFormErrors;
+}
+
+const StyledContainer = styled('div')(({ theme }) => ({
+ display: 'grid',
+ gap: theme.spacing(4),
+}));
+
+const GeneralStrategy = ({
+ parameters,
+ strategyDefinition,
+ updateParameter,
+ editable,
+ errors,
+}: IGeneralStrategyProps) => {
+ if (!strategyDefinition || strategyDefinition.parameters.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {strategyDefinition.parameters.map((definition, index) => (
+
+
+
+ ))}
+
+ );
+};
+
+export default GeneralStrategy;
diff --git a/frontend/src/component/feature/StrategyTypes/RolloutSlider/RolloutSlider.tsx b/frontend/src/component/feature/StrategyTypes/RolloutSlider/RolloutSlider.tsx
new file mode 100644
index 0000000000..959950c7b2
--- /dev/null
+++ b/frontend/src/component/feature/StrategyTypes/RolloutSlider/RolloutSlider.tsx
@@ -0,0 +1,107 @@
+import { makeStyles, withStyles } from 'tss-react/mui';
+import { Slider, Typography } from '@mui/material';
+import { ROLLOUT_SLIDER_ID } from 'utils/testIds';
+import React from 'react';
+
+const StyledSlider = withStyles(Slider, theme => ({
+ root: {
+ height: 8,
+ },
+ thumb: {
+ height: 24,
+ width: 24,
+ backgroundColor: '#fff',
+ border: '2px solid currentColor',
+ },
+ active: {},
+ valueLabel: {},
+ track: {
+ height: 8,
+ borderRadius: theme.shape.borderRadius,
+ },
+ rail: {
+ height: 8,
+ borderRadius: theme.shape.borderRadius,
+ },
+}));
+
+const useStyles = makeStyles()(theme => ({
+ slider: {
+ width: '100%',
+ maxWidth: '100%',
+ },
+ margin: {
+ height: theme.spacing(3),
+ },
+}));
+
+const marks = [
+ {
+ value: 0,
+ label: '0%',
+ },
+ {
+ value: 25,
+ label: '25%',
+ },
+ {
+ value: 50,
+ label: '50%',
+ },
+ {
+ value: 75,
+ label: '75%',
+ },
+ {
+ value: 100,
+ label: '100%',
+ },
+];
+
+interface IRolloutSliderProps {
+ name: string;
+ minLabel?: string;
+ maxLabel?: string;
+ value: number;
+ onChange: (e: Event, newValue: number | number[]) => void;
+ disabled?: boolean;
+}
+
+const RolloutSlider = ({
+ name,
+ value,
+ onChange,
+ disabled = false,
+}: IRolloutSliderProps) => {
+ const { classes } = useStyles();
+
+ const valuetext = (value: number) => `${value}%`;
+
+ return (
+
+
+ {name}
+
+
+
+ );
+};
+
+export default RolloutSlider;
diff --git a/frontend/src/component/feature/StrategyTypes/StrategyInputList/StrategyInputList.tsx b/frontend/src/component/feature/StrategyTypes/StrategyInputList/StrategyInputList.tsx
new file mode 100644
index 0000000000..4257304903
--- /dev/null
+++ b/frontend/src/component/feature/StrategyTypes/StrategyInputList/StrategyInputList.tsx
@@ -0,0 +1,160 @@
+import React, { ChangeEvent, useState } from 'react';
+import {
+ Button,
+ Chip,
+ TextField,
+ Typography,
+ styled,
+ TextFieldProps,
+} from '@mui/material';
+import { Add } from '@mui/icons-material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { ADD_TO_STRATEGY_INPUT_LIST, STRATEGY_INPUT_LIST } from 'utils/testIds';
+import StringTruncator from 'component/common/StringTruncator/StringTruncator';
+import { IFormErrors } from 'hooks/useFormErrors';
+
+interface IStrategyInputList {
+ name: string;
+ list: string[];
+ setConfig: (field: string, value: string) => void;
+ disabled: boolean;
+ errors: IFormErrors;
+}
+
+const Container = styled('div')(({ theme }) => ({
+ display: 'grid',
+ gap: theme.spacing(1),
+}));
+
+const ChipsList = styled('div')(({ theme }) => ({
+ display: 'flex',
+ gap: theme.spacing(1),
+ flexWrap: 'wrap',
+}));
+
+const InputContainer = styled('div')(({ theme }) => ({
+ display: 'flex',
+ gap: theme.spacing(1),
+ alignItems: 'start',
+}));
+
+const StrategyInputList = ({
+ name,
+ list,
+ setConfig,
+ disabled,
+ errors,
+}: IStrategyInputList) => {
+ const [input, setInput] = useState('');
+ const ENTERKEY = 'Enter';
+
+ const onBlur = (e: ChangeEvent) => {
+ setValue(e);
+ };
+
+ const onKeyDown = (e: ChangeEvent) => {
+ // @ts-expect-error
+ if (e?.key === ENTERKEY) {
+ setValue(e);
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ };
+
+ const setValue = (evt: ChangeEvent) => {
+ evt.preventDefault();
+ // @ts-expect-error
+ const value = evt.target.value;
+
+ if (value) {
+ const newValues = value
+ .split(/,\s*/)
+ // @ts-expect-error
+ .filter(a => !list.includes(a));
+ if (newValues.length > 0) {
+ const newList = list.concat(newValues).filter(a => a);
+ setConfig(name, newList.join(','));
+ }
+ setInput('');
+ }
+ };
+
+ const onClose = (index: number) => {
+ // @ts-expect-error
+ list[index] = null;
+ setConfig(
+ name,
+ list.length === 1 ? '' : list.filter(Boolean).join(',')
+ );
+ };
+
+ const onChange: TextFieldProps['onChange'] = event => {
+ setInput(event.currentTarget.value);
+ };
+
+ return (
+
+
+ List of {name}
+
+ 0}
+ show={
+
+ {list.map((entryValue, index) => (
+
+ }
+ onDelete={
+ disabled ? undefined : () => onClose(index)
+ }
+ title="Remove value"
+ />
+ ))}
+
+ }
+ />
+
+
+ {/* @ts-expect-error */}
+ }
+ >
+ Add
+
+
+ }
+ />
+
+ );
+};
+
+export default StrategyInputList;
diff --git a/frontend/src/component/feature/StrategyTypes/StrategyParameter/StrategyParameter.tsx b/frontend/src/component/feature/StrategyTypes/StrategyParameter/StrategyParameter.tsx
new file mode 100644
index 0000000000..318c7f6cef
--- /dev/null
+++ b/frontend/src/component/feature/StrategyTypes/StrategyParameter/StrategyParameter.tsx
@@ -0,0 +1,140 @@
+import React from 'react';
+import { FormControlLabel, Switch, TextField } from '@mui/material';
+import StrategyInputList from '../StrategyInputList/StrategyInputList';
+import RolloutSlider from '../RolloutSlider/RolloutSlider';
+import {
+ IFeatureStrategyParameters,
+ IStrategyParameter,
+} from 'interfaces/strategy';
+import {
+ parseParameterNumber,
+ parseParameterStrings,
+ parseParameterString,
+} from 'utils/parseParameter';
+import { InputCaption } from 'component/common/InputCaption/InputCaption';
+import { IFormErrors } from 'hooks/useFormErrors';
+
+interface IStrategyParameterProps {
+ definition: IStrategyParameter;
+ parameters: IFeatureStrategyParameters;
+ updateParameter: (field: string, value: string) => void;
+ editable: boolean;
+ errors: IFormErrors;
+}
+
+export const StrategyParameter = ({
+ definition,
+ parameters,
+ updateParameter,
+ editable,
+ errors,
+}: IStrategyParameterProps) => {
+ const { type, name, description, required } = definition;
+ const value = parameters[name];
+ const error = errors.getFormError(name);
+ const label = required ? `${name} * ` : name;
+
+ const onChange = (event: React.ChangeEvent) => {
+ updateParameter(name, event.target.value);
+ };
+
+ const onChangePercentage = (event: Event, next: number | number[]) => {
+ updateParameter(name, next.toString());
+ };
+
+ const onChangeBoolean = (event: React.ChangeEvent, checked: boolean) => {
+ updateParameter(name, String(checked));
+ };
+
+ if (type === 'percentage') {
+ return (
+
+
+
+
+ );
+ }
+
+ if (type === 'list') {
+ return (
+
+
+
+
+ );
+ }
+
+ if (type === 'number') {
+ return (
+
+
+
+
+ );
+ }
+
+ if (type === 'boolean') {
+ const value = parseParameterString(parameters[name]);
+ const checked = value === 'true';
+ return (
+
+
+ }
+ />
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/feature/StrategyTypes/UserWithIdStrategy/UserWithId.tsx b/frontend/src/component/feature/StrategyTypes/UserWithIdStrategy/UserWithId.tsx
new file mode 100644
index 0000000000..aab8651a20
--- /dev/null
+++ b/frontend/src/component/feature/StrategyTypes/UserWithIdStrategy/UserWithId.tsx
@@ -0,0 +1,32 @@
+import { IFeatureStrategyParameters } from 'interfaces/strategy';
+import StrategyInputList from '../StrategyInputList/StrategyInputList';
+import { parseParameterStrings } from 'utils/parseParameter';
+import { IFormErrors } from 'hooks/useFormErrors';
+
+interface IUserWithIdStrategyProps {
+ parameters: IFeatureStrategyParameters;
+ updateParameter: (field: string, value: string) => void;
+ editable: boolean;
+ errors: IFormErrors;
+}
+
+const UserWithIdStrategy = ({
+ editable,
+ parameters,
+ updateParameter,
+ errors,
+}: IUserWithIdStrategyProps) => {
+ return (
+
+
+
+ );
+};
+
+export default UserWithIdStrategy;
diff --git a/frontend/src/component/feature/hooks/useFeatureForm.ts b/frontend/src/component/feature/hooks/useFeatureForm.ts
new file mode 100644
index 0000000000..d6271a3c3a
--- /dev/null
+++ b/frontend/src/component/feature/hooks/useFeatureForm.ts
@@ -0,0 +1,95 @@
+import { useEffect, useState } from 'react';
+import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
+import useQueryParams from 'hooks/useQueryParams';
+import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
+import { formatUnknownError } from 'utils/formatUnknownError';
+
+const useFeatureForm = (
+ initialName = '',
+ initialType = 'release',
+ initialProject = 'default',
+ initialDescription = '',
+ initialImpressionData = false
+) => {
+ const projectId = useRequiredPathParam('projectId');
+ const params = useQueryParams();
+ const { validateFeatureToggleName } = useFeatureApi();
+ const toggleQueryName = params.get('name');
+ const [type, setType] = useState(initialType);
+ const [name, setName] = useState(toggleQueryName || initialName);
+ const [project, setProject] = useState(projectId || initialProject);
+ const [description, setDescription] = useState(initialDescription);
+ const [impressionData, setImpressionData] = useState(
+ initialImpressionData
+ );
+ const [errors, setErrors] = useState({});
+
+ useEffect(() => {
+ setType(initialType);
+ }, [initialType]);
+
+ useEffect(() => {
+ if (!name) {
+ setName(toggleQueryName || initialName);
+ }
+ }, [name, initialName, toggleQueryName]);
+
+ useEffect(() => {
+ if (!projectId) setProject(initialProject);
+ else setProject(projectId);
+ }, [initialProject, projectId]);
+
+ useEffect(() => {
+ setDescription(initialDescription);
+ }, [initialDescription]);
+
+ useEffect(() => {
+ setImpressionData(initialImpressionData);
+ }, [initialImpressionData]);
+
+ const getTogglePayload = () => {
+ return {
+ type,
+ name,
+ description,
+ impressionData,
+ };
+ };
+
+ const validateToggleName = async () => {
+ if (name.length === 0) {
+ setErrors(prev => ({ ...prev, name: 'Name can not be empty.' }));
+ return false;
+ }
+ try {
+ await validateFeatureToggleName(name);
+ return true;
+ } catch (error: unknown) {
+ setErrors(prev => ({ ...prev, name: formatUnknownError(error) }));
+ return false;
+ }
+ };
+
+ const clearErrors = () => {
+ setErrors({});
+ };
+
+ return {
+ type,
+ setType,
+ name,
+ setName,
+ project,
+ setProject,
+ description,
+ setDescription,
+ impressionData,
+ setImpressionData,
+ getTogglePayload,
+ validateToggleName,
+ clearErrors,
+ errors,
+ };
+};
+
+export default useFeatureForm;
diff --git a/frontend/src/component/feedback/FeedbackCES/FeedbackCES.styles.ts b/frontend/src/component/feedback/FeedbackCES/FeedbackCES.styles.ts
new file mode 100644
index 0000000000..ee11de71ad
--- /dev/null
+++ b/frontend/src/component/feedback/FeedbackCES/FeedbackCES.styles.ts
@@ -0,0 +1,35 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ overlay: {
+ pointerEvents: 'none',
+ display: 'grid',
+ padding: '1rem',
+ overflowY: 'auto',
+ alignItems: 'center',
+ justifyContent: 'center',
+ height: '100vh',
+ width: '100vw',
+ },
+ modal: {
+ pointerEvents: 'auto',
+ position: 'relative',
+ padding: '4rem',
+ background: theme.palette.background.paper,
+ boxShadow: '0 0 1rem rgba(0, 0, 0, 0.25)',
+ borderRadius: '1rem',
+ [theme.breakpoints.down('md')]: {
+ padding: '2rem',
+ },
+ },
+ close: {
+ all: 'unset',
+ position: 'absolute',
+ top: 0,
+ right: 0,
+ },
+ closeIcon: {
+ fontSize: '1.5rem',
+ color: theme.palette.inactiveIcon,
+ },
+}));
diff --git a/frontend/src/component/feedback/FeedbackCES/FeedbackCES.tsx b/frontend/src/component/feedback/FeedbackCES/FeedbackCES.tsx
new file mode 100644
index 0000000000..f6d851bd24
--- /dev/null
+++ b/frontend/src/component/feedback/FeedbackCES/FeedbackCES.tsx
@@ -0,0 +1,44 @@
+import { IconButton, Modal } from '@mui/material';
+import React, { useContext } from 'react';
+import {
+ feedbackCESContext,
+ IFeedbackCESState,
+} from 'component/feedback/FeedbackCESContext/FeedbackCESContext';
+import { FeedbackCESForm } from 'component/feedback/FeedbackCES/FeedbackCESForm';
+import { useStyles } from 'component/feedback/FeedbackCES/FeedbackCES.styles';
+import { CloseOutlined } from '@mui/icons-material';
+
+export interface IFeedbackCESProps {
+ state?: IFeedbackCESState;
+}
+
+export const FeedbackCES = ({ state }: IFeedbackCESProps) => {
+ const { hideFeedbackCES } = useContext(feedbackCESContext);
+ const { classes: styles } = useStyles();
+
+ const modalContent = state && (
+
+ );
+
+ return (
+
+
+
+
+
+
+
+
+ {modalContent}
+
+
+
+ );
+};
diff --git a/frontend/src/component/feedback/FeedbackCES/FeedbackCESForm.styles.ts b/frontend/src/component/feedback/FeedbackCES/FeedbackCESForm.styles.ts
new file mode 100644
index 0000000000..7b5972ab4d
--- /dev/null
+++ b/frontend/src/component/feedback/FeedbackCES/FeedbackCESForm.styles.ts
@@ -0,0 +1,36 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ container: {
+ fontWeight: theme.fontWeight.thin,
+ },
+ form: {
+ display: 'grid',
+ gap: '3rem',
+ gridTemplateColumns: 'minmax(auto, 40rem)',
+ justifyContent: 'center',
+ },
+ title: {
+ all: 'unset',
+ display: 'block',
+ textAlign: 'center',
+ color: theme.palette.text.secondary,
+ },
+ subtitle: {
+ all: 'unset',
+ display: 'block',
+ marginTop: '2.5rem',
+ fontSize: '1.5rem',
+ textAlign: 'center',
+ },
+ textLabel: {
+ display: 'block',
+ marginBottom: '0.5rem',
+ },
+ buttons: {
+ textAlign: 'center',
+ },
+ button: {
+ minWidth: '15rem',
+ },
+}));
diff --git a/frontend/src/component/feedback/FeedbackCES/FeedbackCESForm.test.tsx b/frontend/src/component/feedback/FeedbackCES/FeedbackCESForm.test.tsx
new file mode 100644
index 0000000000..145ed250f2
--- /dev/null
+++ b/frontend/src/component/feedback/FeedbackCES/FeedbackCESForm.test.tsx
@@ -0,0 +1,20 @@
+import { FeedbackCESForm } from './FeedbackCESForm';
+import { ThemeProvider } from 'themes/ThemeProvider';
+import { render } from 'utils/testRenderer';
+
+test('FeedbackCESForm', () => {
+ const onClose = () => {
+ throw new Error('Unexpected onClose call.');
+ };
+
+ render(
+
+
+
+ );
+
+ expect(document.body).toMatchSnapshot();
+});
diff --git a/frontend/src/component/feedback/FeedbackCES/FeedbackCESForm.tsx b/frontend/src/component/feedback/FeedbackCES/FeedbackCESForm.tsx
new file mode 100644
index 0000000000..3422428111
--- /dev/null
+++ b/frontend/src/component/feedback/FeedbackCES/FeedbackCESForm.tsx
@@ -0,0 +1,96 @@
+import { useStyles } from 'component/feedback/FeedbackCES/FeedbackCESForm.styles';
+import { Button, TextField } from '@mui/material';
+import React, { useState } from 'react';
+import produce from 'immer';
+import useToast from 'hooks/useToast';
+import { IFeedbackCESState } from 'component/feedback/FeedbackCESContext/FeedbackCESContext';
+import { FeedbackCESScore } from 'component/feedback/FeedbackCES/FeedbackCESScore';
+import { sendFeedbackInput } from 'component/feedback/FeedbackCES/sendFeedbackInput';
+
+export interface IFeedbackCESFormProps {
+ state: IFeedbackCESState;
+ onClose: () => void;
+}
+
+export interface IFeedbackCESForm {
+ score: number;
+ comment: string;
+ path: string;
+}
+
+export const FeedbackCESForm = ({ state, onClose }: IFeedbackCESFormProps) => {
+ const [loading, setLoading] = useState(false);
+ const { setToastData } = useToast();
+ const { classes: styles } = useStyles();
+
+ const [form, setForm] = useState>({
+ path: state.path,
+ });
+
+ const onCommentChange = (event: React.ChangeEvent) => {
+ setForm(
+ produce(draft => {
+ draft.comment = event.target.value;
+ })
+ );
+ };
+
+ const onSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+
+ if (loading) {
+ return;
+ }
+
+ try {
+ setLoading(true);
+ await sendFeedbackInput(form);
+ setToastData({
+ type: 'success',
+ title: 'Feedback sent. Thank you!',
+ confetti: true,
+ });
+ onClose();
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
Please help us improve
+
+
+ );
+};
diff --git a/frontend/src/component/feedback/FeedbackCES/FeedbackCESScore.styles.ts b/frontend/src/component/feedback/FeedbackCES/FeedbackCESScore.styles.ts
new file mode 100644
index 0000000000..78d348269a
--- /dev/null
+++ b/frontend/src/component/feedback/FeedbackCES/FeedbackCESScore.styles.ts
@@ -0,0 +1,55 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ scoreInput: {
+ display: 'flex',
+ gap: '1rem',
+ alignItems: 'center',
+ margin: '0 auto',
+ },
+ scoreHelp: {
+ width: '6.25rem',
+ whiteSpace: 'nowrap',
+ color: theme.palette.text.secondary,
+ '&:first-of-type': {
+ textAlign: 'right',
+ },
+ [theme.breakpoints.down('sm')]: {
+ display: 'none',
+ },
+ },
+ scoreValue: {
+ '& input': {
+ clip: 'rect(0 0 0 0)',
+ clipPath: 'inset(50%)',
+ overflow: 'hidden',
+ position: 'absolute',
+ whiteSpace: 'nowrap',
+ width: 1,
+ height: 1,
+ },
+ '& span': {
+ display: 'grid',
+ justifyContent: 'center',
+ alignItems: 'center',
+ background: theme.palette.grey[300],
+ width: '3rem',
+ height: '3rem',
+ borderRadius: '10rem',
+ fontSize: '1.25rem',
+ paddingBottom: 2,
+ userSelect: 'none',
+ cursor: 'pointer',
+ },
+ '& input:checked + span': {
+ fontWeight: theme.fontWeight.bold,
+ background: theme.palette.primary.main,
+ color: 'white',
+ },
+ '& input:focus-visible + span': {
+ outline: '2px solid',
+ outlineOffset: 2,
+ outlineColor: theme.palette.primary.main,
+ },
+ },
+}));
diff --git a/frontend/src/component/feedback/FeedbackCES/FeedbackCESScore.tsx b/frontend/src/component/feedback/FeedbackCES/FeedbackCESScore.tsx
new file mode 100644
index 0000000000..029be6c28a
--- /dev/null
+++ b/frontend/src/component/feedback/FeedbackCES/FeedbackCESScore.tsx
@@ -0,0 +1,40 @@
+import React from 'react';
+import produce from 'immer';
+import { useStyles } from 'component/feedback/FeedbackCES/FeedbackCESScore.styles';
+import { IFeedbackCESForm } from 'component/feedback/FeedbackCES/FeedbackCESForm';
+
+interface IFeedbackCESScoreProps {
+ form: Partial;
+ setForm: React.Dispatch>>;
+}
+
+export const FeedbackCESScore = ({ form, setForm }: IFeedbackCESScoreProps) => {
+ const { classes: styles } = useStyles();
+
+ const onScoreChange = (event: React.ChangeEvent) => {
+ setForm(
+ produce(draft => {
+ draft.score = Number(event.target.value);
+ })
+ );
+ };
+
+ return (
+
+ Very difficult
+ {[1, 2, 3, 4, 5, 6, 7].map(score => (
+
+
+ {score}
+
+ ))}
+ Very easy
+
+ );
+};
diff --git a/frontend/src/component/feedback/FeedbackCES/__snapshots__/FeedbackCESForm.test.tsx.snap b/frontend/src/component/feedback/FeedbackCES/__snapshots__/FeedbackCESForm.test.tsx.snap
new file mode 100644
index 0000000000..83670e63ee
--- /dev/null
+++ b/frontend/src/component/feedback/FeedbackCES/__snapshots__/FeedbackCESForm.test.tsx.snap
@@ -0,0 +1,191 @@
+// Vitest Snapshot v1
+
+exports[`FeedbackCESForm 1`] = `
+
+
+
+
+ Please help us improve
+
+
+
+
+
+
+`;
diff --git a/frontend/src/component/feedback/FeedbackCES/sendFeedbackInput.ts b/frontend/src/component/feedback/FeedbackCES/sendFeedbackInput.ts
new file mode 100644
index 0000000000..70fb8ee782
--- /dev/null
+++ b/frontend/src/component/feedback/FeedbackCES/sendFeedbackInput.ts
@@ -0,0 +1,40 @@
+import { IFeedbackCESForm } from 'component/feedback/FeedbackCES/FeedbackCESForm';
+
+interface IFeedbackEndpointRequestBody {
+ source: 'app' | 'app:segments';
+ data: {
+ score: number;
+ comment?: string;
+ customerType?: 'open source' | 'paying';
+ openedManually?: boolean;
+ currentPage?: string;
+ };
+}
+
+export const sendFeedbackInput = async (
+ form: Partial
+): Promise => {
+ if (!form.score) {
+ return;
+ }
+
+ const body: IFeedbackEndpointRequestBody = {
+ source: 'app:segments',
+ data: {
+ score: form.score,
+ comment: form.comment,
+ currentPage: form.path,
+ openedManually: false,
+ customerType: 'paying',
+ },
+ };
+
+ await fetch(
+ 'https://europe-west3-metrics-304612.cloudfunctions.net/docs-app-feedback',
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ }
+ );
+};
diff --git a/frontend/src/component/feedback/FeedbackCESContext/FeedbackCESContext.ts b/frontend/src/component/feedback/FeedbackCESContext/FeedbackCESContext.ts
new file mode 100644
index 0000000000..3750c9e0bc
--- /dev/null
+++ b/frontend/src/component/feedback/FeedbackCESContext/FeedbackCESContext.ts
@@ -0,0 +1,29 @@
+import React, { createContext } from 'react';
+
+export type ShowFeedbackCES = React.Dispatch<
+ React.SetStateAction
+>;
+
+export interface IFeedbackCESContext {
+ showFeedbackCES: ShowFeedbackCES;
+ hideFeedbackCES: () => void;
+}
+
+export interface IFeedbackCESState {
+ path: `/${string}`;
+ title: string;
+ text: string;
+}
+
+const showFeedbackCESPlaceholder = () => {
+ throw new Error('showFeedbackCES called outside feedbackCESContext');
+};
+
+const hideFeedbackCESPlaceholder = () => {
+ throw new Error('hideFeedbackCES called outside feedbackCESContext');
+};
+
+export const feedbackCESContext = createContext({
+ showFeedbackCES: showFeedbackCESPlaceholder,
+ hideFeedbackCES: hideFeedbackCESPlaceholder,
+});
diff --git a/frontend/src/component/feedback/FeedbackCESContext/FeedbackCESProvider.tsx b/frontend/src/component/feedback/FeedbackCESContext/FeedbackCESProvider.tsx
new file mode 100644
index 0000000000..7a90954478
--- /dev/null
+++ b/frontend/src/component/feedback/FeedbackCESContext/FeedbackCESProvider.tsx
@@ -0,0 +1,60 @@
+import React, {
+ useState,
+ ReactNode,
+ useMemo,
+ useCallback,
+ useEffect,
+} from 'react';
+import { FeedbackCES } from 'component/feedback/FeedbackCES/FeedbackCES';
+import {
+ feedbackCESContext,
+ ShowFeedbackCES,
+ IFeedbackCESContext,
+ IFeedbackCESState,
+} from 'component/feedback/FeedbackCESContext/FeedbackCESContext';
+import { useFeedbackCESSeen } from 'component/feedback/FeedbackCESContext/useFeedbackCESSeen';
+import { useFeedbackCESEnabled } from 'component/feedback/FeedbackCESContext/useFeedbackCESEnabled';
+
+interface IFeedbackProviderProps {
+ children: ReactNode;
+}
+
+export const FeedbackCESProvider = ({ children }: IFeedbackProviderProps) => {
+ const [state, setState] = useState();
+ const { isSeen, setSeen } = useFeedbackCESSeen();
+ const enabled = useFeedbackCESEnabled();
+
+ useEffect(() => {
+ state && setSeen(state);
+ }, [state, setSeen]);
+
+ // Set a new feedback state iff the path is unseen and CES is enabled.
+ const showFeedbackCES: ShowFeedbackCES = useCallback(
+ value => {
+ setState(prev => {
+ const next = value instanceof Function ? value(prev) : value;
+ return !enabled || !next || isSeen(next) ? undefined : next;
+ });
+ },
+ [enabled, isSeen]
+ );
+
+ const hideFeedbackCES = useCallback(() => {
+ setState(undefined);
+ }, [setState]);
+
+ const value: IFeedbackCESContext = useMemo(
+ () => ({
+ showFeedbackCES: showFeedbackCES,
+ hideFeedbackCES: hideFeedbackCES,
+ }),
+ [showFeedbackCES, hideFeedbackCES]
+ );
+
+ return (
+
+ {children}
+
+
+ );
+};
diff --git a/frontend/src/component/feedback/FeedbackCESContext/useFeedbackCESEnabled.ts b/frontend/src/component/feedback/FeedbackCESContext/useFeedbackCESEnabled.ts
new file mode 100644
index 0000000000..ca128fa5fd
--- /dev/null
+++ b/frontend/src/component/feedback/FeedbackCESContext/useFeedbackCESEnabled.ts
@@ -0,0 +1,9 @@
+import {
+ isLocalhostDomain,
+ isUnleashDomain,
+ isVercelBranchDomain,
+} from 'utils/env';
+
+export const useFeedbackCESEnabled = (): boolean => {
+ return isUnleashDomain() || isVercelBranchDomain() || isLocalhostDomain();
+};
diff --git a/frontend/src/component/feedback/FeedbackCESContext/useFeedbackCESSeen.ts b/frontend/src/component/feedback/FeedbackCESContext/useFeedbackCESSeen.ts
new file mode 100644
index 0000000000..7c0b906f03
--- /dev/null
+++ b/frontend/src/component/feedback/FeedbackCESContext/useFeedbackCESSeen.ts
@@ -0,0 +1,36 @@
+import { useAuthFeedback } from 'hooks/api/getters/useAuth/useAuthFeedback';
+import { useAuthFeedbackApi } from 'hooks/api/actions/useAuthFeedbackApi/useAuthFeedbackApi';
+import { IFeedbackCESState } from 'component/feedback/FeedbackCESContext/FeedbackCESContext';
+import { useCallback } from 'react';
+
+interface IUseFeedbackCESSeen {
+ setSeen: (state: IFeedbackCESState) => void;
+ isSeen: (state: IFeedbackCESState) => boolean;
+}
+
+export const useFeedbackCESSeen = (): IUseFeedbackCESSeen => {
+ const { createFeedback } = useAuthFeedbackApi();
+ const { feedback } = useAuthFeedback();
+
+ const isSeen = useCallback(
+ (state: IFeedbackCESState) =>
+ !!feedback &&
+ feedback.some(f => f.feedbackId === formatFeedbackCESId(state)),
+ [feedback]
+ );
+
+ const setSeen = useCallback(
+ (state: IFeedbackCESState) =>
+ createFeedback({ feedbackId: formatFeedbackCESId(state) }),
+ [createFeedback]
+ );
+
+ return {
+ isSeen,
+ setSeen,
+ };
+};
+
+const formatFeedbackCESId = (state: IFeedbackCESState): string => {
+ return `ces${state.path}`;
+};
diff --git a/frontend/src/component/feedback/FeedbackNPS/FeedbackNPS.styles.ts b/frontend/src/component/feedback/FeedbackNPS/FeedbackNPS.styles.ts
new file mode 100644
index 0000000000..1562137a86
--- /dev/null
+++ b/frontend/src/component/feedback/FeedbackNPS/FeedbackNPS.styles.ts
@@ -0,0 +1,37 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ feedback: {
+ borderRadius: '12.5px',
+ backgroundColor: theme.palette.background.paper,
+ zIndex: 9999,
+ boxShadow: '2px 2px 4px 4px rgba(143,143,143, 0.25)',
+ padding: '1.5rem',
+ maxWidth: '400px',
+ },
+ animateContainer: {
+ zIndex: 9999,
+ },
+ container: {
+ display: 'flex',
+ flexDirection: 'column',
+ position: 'relative',
+ },
+ close: {
+ position: 'absolute',
+ right: '-38px',
+ top: '-47px',
+ backgroundColor: theme.palette.background.paper,
+ boxShadow: '2px 2px 4px 4px rgba(143,143,143, 0.25)',
+ ['&:hover']: {
+ backgroundColor: '#fff',
+ },
+ },
+ logo: {
+ width: '25px',
+ height: '25px',
+ },
+ cancel: {
+ marginLeft: '1rem',
+ },
+}));
diff --git a/frontend/src/component/feedback/FeedbackNPS/FeedbackNPS.tsx b/frontend/src/component/feedback/FeedbackNPS/FeedbackNPS.tsx
new file mode 100644
index 0000000000..706cccf0d8
--- /dev/null
+++ b/frontend/src/component/feedback/FeedbackNPS/FeedbackNPS.tsx
@@ -0,0 +1,136 @@
+import { useContext, useState } from 'react';
+import { Button, IconButton, Tooltip } from '@mui/material';
+import classnames from 'classnames';
+import CloseIcon from '@mui/icons-material/Close';
+import { ReactComponent as Logo } from 'assets/icons/logoPlain.svg';
+import { useStyles } from 'component/feedback/FeedbackNPS/FeedbackNPS.styles';
+import AnimateOnMount from 'component/common/AnimateOnMount/AnimateOnMount';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { useThemeStyles } from 'themes/themeStyles';
+import UIContext from 'contexts/UIContext';
+import {
+ PNPS_FEEDBACK_ID,
+ showNPSFeedback,
+} from 'component/feedback/FeedbackNPS/showNPSFeedback';
+import { useAuthFeedback } from 'hooks/api/getters/useAuth/useAuthFeedback';
+import { useAuthFeedbackApi } from 'hooks/api/actions/useAuthFeedbackApi/useAuthFeedbackApi';
+
+interface IFeedbackNPSProps {
+ openUrl: string;
+}
+
+export const FeedbackNPS = ({ openUrl }: IFeedbackNPSProps) => {
+ const { showFeedback, setShowFeedback } = useContext(UIContext);
+ const { createFeedback, updateFeedback } = useAuthFeedbackApi();
+ const { feedback } = useAuthFeedback();
+ const [answeredNotNow, setAnsweredNotNow] = useState(false);
+ const { classes: styles } = useStyles();
+ const { classes: themeStyles } = useThemeStyles();
+ const feedbackId = PNPS_FEEDBACK_ID;
+
+ const onConfirm = async () => {
+ try {
+ await createFeedback({ feedbackId });
+ } catch (err) {
+ console.warn(err);
+ setShowFeedback(false);
+ }
+ // Await api call to register confirmation
+ window.open(openUrl, '_blank');
+ setTimeout(() => {
+ setShowFeedback(false);
+ }, 200);
+ };
+
+ const onDontShowAgain = async () => {
+ try {
+ await updateFeedback({ feedbackId, neverShow: true });
+ } catch (err) {
+ console.warn(err);
+ setShowFeedback(false);
+ }
+ setTimeout(() => {
+ setShowFeedback(false);
+ }, 100);
+ };
+
+ if (!showNPSFeedback(feedback)) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ setShowFeedback(false)}
+ size="large"
+ >
+
+
+
+
+
+ Alright, apologies for the disruption. Have a
+ nice day!
+
+ }
+ elseShow={
+
+ Hi. Do you have 2 minutes to help us improve
+ Unleash?{' '}
+
+ }
+ />
+
+
+
+ Don't show again
+
+ }
+ elseShow={
+ <>
+
+ Yes, no problem
+
+ setAnsweredNotNow(true)}
+ >
+ Not now
+
+ >
+ }
+ />
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/feedback/FeedbackNPS/showNPSFeedback.ts b/frontend/src/component/feedback/FeedbackNPS/showNPSFeedback.ts
new file mode 100644
index 0000000000..50b0cb2475
--- /dev/null
+++ b/frontend/src/component/feedback/FeedbackNPS/showNPSFeedback.ts
@@ -0,0 +1,37 @@
+import differenceInDays from 'date-fns/differenceInDays';
+import { IAuthFeedback } from 'hooks/api/getters/useAuth/useAuthEndpoint';
+
+export const PNPS_FEEDBACK_ID = 'pnps';
+
+export const showNPSFeedback = (
+ feedbackList: IAuthFeedback[] | undefined
+): boolean => {
+ if (!feedbackList) {
+ return false;
+ }
+
+ if (feedbackList.length === 0) {
+ return true;
+ }
+
+ const feedback = feedbackList.find(
+ feedback => feedback.feedbackId === PNPS_FEEDBACK_ID
+ );
+
+ if (!feedback) {
+ return true;
+ }
+
+ if (feedback.neverShow) {
+ return false;
+ }
+
+ if (feedback.given) {
+ const SIX_MONTHS_IN_DAYS = 182;
+ const now = new Date();
+ const difference = differenceInDays(now, new Date(feedback.given));
+ return difference > SIX_MONTHS_IN_DAYS;
+ }
+
+ return false;
+};
diff --git a/frontend/src/component/layout/LayoutPicker/LayoutPicker.tsx b/frontend/src/component/layout/LayoutPicker/LayoutPicker.tsx
new file mode 100644
index 0000000000..01704a1970
--- /dev/null
+++ b/frontend/src/component/layout/LayoutPicker/LayoutPicker.tsx
@@ -0,0 +1,37 @@
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { matchPath } from 'react-router';
+import { useLocation } from 'react-router-dom';
+import { MainLayout } from '../MainLayout/MainLayout';
+import { ReactNode } from 'react';
+
+interface ILayoutPickerProps {
+ children: ReactNode;
+}
+
+export const LayoutPicker = ({ children }: ILayoutPickerProps) => {
+ const { pathname } = useLocation();
+
+ return (
+ {children}}
+ />
+ );
+};
+
+const isStandalonePage = (pathname: string): boolean => {
+ return standalonePagePatterns.some(pattern => {
+ return matchPath(pattern, pathname);
+ });
+};
+
+const standalonePagePatterns = [
+ '/login',
+ '/new-user',
+ '/reset-password',
+ '/reset-password-success',
+ '/forgotten-password',
+ '/splash/:splashId',
+ '/404',
+];
diff --git a/frontend/src/component/layout/MainLayout/MainLayout.tsx b/frontend/src/component/layout/MainLayout/MainLayout.tsx
new file mode 100644
index 0000000000..abf6149ad6
--- /dev/null
+++ b/frontend/src/component/layout/MainLayout/MainLayout.tsx
@@ -0,0 +1,76 @@
+import React, { ReactNode } from 'react';
+import classnames from 'classnames';
+import { makeStyles } from 'tss-react/mui';
+import { Grid } from '@mui/material';
+import { useStyles as useAppStyles } from 'component/App.styles';
+import Header from 'component/menu/Header/Header';
+import Footer from 'component/menu/Footer/Footer';
+import Proclamation from 'component/common/Proclamation/Proclamation';
+import BreadcrumbNav from 'component/common/BreadcrumbNav/BreadcrumbNav';
+import textureImage from 'assets/img/texture.png';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import { SkipNavLink } from 'component/common/SkipNav/SkipNavLink';
+import { SkipNavTarget } from 'component/common/SkipNav/SkipNavTarget';
+import { formatAssetPath } from 'utils/formatPath';
+
+const useStyles = makeStyles()(theme => ({
+ container: {
+ height: '100%',
+ justifyContent: 'space-between',
+ },
+ contentContainer: {
+ height: '100%',
+ padding: '3.25rem 0',
+ position: 'relative',
+ [theme.breakpoints.down('md')]: {
+ padding: '3.25rem 0.75rem',
+ },
+ },
+}));
+
+interface IMainLayoutProps {
+ children: ReactNode;
+}
+
+export const MainLayout = ({ children }: IMainLayoutProps) => {
+ const { classes } = useStyles();
+ const { classes: styles } = useAppStyles();
+ const { uiConfig } = useUiConfig();
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/frontend/src/component/menu/Footer/ApiDetails/ApiDetails.test.tsx b/frontend/src/component/menu/Footer/ApiDetails/ApiDetails.test.tsx
new file mode 100644
index 0000000000..d758d70121
--- /dev/null
+++ b/frontend/src/component/menu/Footer/ApiDetails/ApiDetails.test.tsx
@@ -0,0 +1,53 @@
+import React from 'react';
+import { ApiDetails } from 'component/menu/Footer/ApiDetails/ApiDetails';
+import { render } from 'utils/testRenderer';
+
+test('renders correctly with empty version', () => {
+ const uiConfig = {
+ name: 'Unleash',
+ slogan: 'We are the best!',
+ environment: 'test',
+ version: '',
+ };
+
+ render( );
+ expect(document.body).toMatchSnapshot();
+});
+
+test('renders correctly with ui-config', () => {
+ const uiConfig = {
+ name: 'Unleash',
+ slogan: 'We are the best!',
+ environment: 'test',
+ version: '1.1.0',
+ };
+
+ render( );
+ expect(document.body).toMatchSnapshot();
+});
+
+test('renders correctly without uiConfig', () => {
+ const uiConfig = {
+ name: 'Unleash',
+ version: '1.1.0',
+ };
+
+ render( );
+ expect(document.body).toMatchSnapshot();
+});
+
+test('renders correctly with versionInfo', () => {
+ const uiConfig = {
+ name: 'Unleash',
+ version: '1.2.3',
+ versionInfo: {
+ instanceId: '1',
+ isLatest: false,
+ current: { enterprise: '1.2.3', oss: '1.2.3' },
+ latest: { enterprise: '1.2.4', oss: '1.2.4' },
+ },
+ };
+
+ render( );
+ expect(document.body).toMatchSnapshot();
+});
diff --git a/frontend/src/component/menu/Footer/ApiDetails/ApiDetails.tsx b/frontend/src/component/menu/Footer/ApiDetails/ApiDetails.tsx
new file mode 100644
index 0000000000..f839957c9b
--- /dev/null
+++ b/frontend/src/component/menu/Footer/ApiDetails/ApiDetails.tsx
@@ -0,0 +1,47 @@
+import { ReactElement } from 'react';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import {
+ formatCurrentVersion,
+ formatUpdateNotification,
+ IPartialUiConfig,
+} from './apidetails.helpers';
+import { FooterTitle } from 'component/menu/Footer/FooterTitle';
+
+interface IApiDetailsProps {
+ uiConfig: IPartialUiConfig;
+}
+
+export const ApiDetails = (props: IApiDetailsProps): ReactElement => {
+ const instanceId = props.uiConfig.versionInfo?.instanceId;
+ const currentVersion = formatCurrentVersion(props.uiConfig);
+ const environment = props.uiConfig.environment;
+ const updateNotification = formatUpdateNotification(props.uiConfig);
+
+ return (
+
+
+ {currentVersion}{' '}
+ ({environment})}
+ />
+
+
+ {updateNotification}
+
+
+ }
+ />
+
+ {props.uiConfig.slogan}
+
+ {`${instanceId}`}}
+ />
+
+ );
+};
diff --git a/frontend/src/component/menu/Footer/ApiDetails/__snapshots__/ApiDetails.test.tsx.snap b/frontend/src/component/menu/Footer/ApiDetails/__snapshots__/ApiDetails.test.tsx.snap
new file mode 100644
index 0000000000..2029c37678
--- /dev/null
+++ b/frontend/src/component/menu/Footer/ApiDetails/__snapshots__/ApiDetails.test.tsx.snap
@@ -0,0 +1,130 @@
+// Vitest Snapshot v1
+
+exports[`renders correctly with empty version 1`] = `
+
+
+
+
+ Unleash
+
+
+ (
+ test
+ )
+
+
+
+
+ We are the best!
+
+
+
+
+
+
+`;
+
+exports[`renders correctly with ui-config 1`] = `
+
+
+
+
+ Unleash 1.1.0
+
+
+ (
+ test
+ )
+
+
+
+
+ We are the best!
+
+
+
+
+
+
+`;
+
+exports[`renders correctly with versionInfo 1`] = `
+
+
+
+
+ Unleash 1.2.3
+
+
+
+ Upgrade available - Latest Enterprise release: 1.2.4
+
+
+
+
+
+
+ 1
+
+
+
+
+
+`;
+
+exports[`renders correctly without uiConfig 1`] = `
+
+
+
+
+ Unleash 1.1.0
+
+
+
+
+
+
+
+
+
+`;
diff --git a/frontend/src/component/menu/Footer/ApiDetails/apidetails.helpers.tsx b/frontend/src/component/menu/Footer/ApiDetails/apidetails.helpers.tsx
new file mode 100644
index 0000000000..6ccaa333df
--- /dev/null
+++ b/frontend/src/component/menu/Footer/ApiDetails/apidetails.helpers.tsx
@@ -0,0 +1,38 @@
+import { IVersionInfo } from 'interfaces/uiConfig';
+
+export interface IPartialUiConfig {
+ name: string;
+ version: string;
+ slogan?: string;
+ environment?: string;
+ versionInfo?: IVersionInfo;
+}
+
+export const formatCurrentVersion = (uiConfig: IPartialUiConfig): string => {
+ const current = uiConfig.versionInfo?.current;
+
+ if (current?.enterprise) {
+ return `${uiConfig.name} ${current.enterprise}`;
+ }
+
+ if (current?.oss) {
+ return `${uiConfig.name} ${current.oss}`;
+ }
+
+ return `${uiConfig.name} ${uiConfig.version}`;
+};
+
+export const formatUpdateNotification = (
+ uiConfig: IPartialUiConfig
+): string | undefined => {
+ const latest = uiConfig.versionInfo?.latest;
+ const isLatest = uiConfig.versionInfo?.isLatest;
+
+ if (latest?.enterprise && !isLatest) {
+ return `Upgrade available - Latest Enterprise release: ${latest.enterprise}`;
+ }
+
+ if (latest?.oss && !isLatest) {
+ return `Upgrade available - Latest OSS release: ${latest.oss}`;
+ }
+};
diff --git a/frontend/src/component/menu/Footer/Footer.styles.ts b/frontend/src/component/menu/Footer/Footer.styles.ts
new file mode 100644
index 0000000000..481c797b58
--- /dev/null
+++ b/frontend/src/component/menu/Footer/Footer.styles.ts
@@ -0,0 +1,23 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ footer: {
+ padding: '2rem 4rem',
+ width: '100%',
+ flexGrow: 1,
+ zIndex: 100,
+ backgroundColor: theme.palette.footerBackground,
+ },
+ list: {
+ padding: 0,
+ margin: 0,
+ },
+ listItem: {
+ padding: 0,
+ margin: 0,
+ '& a': {
+ textDecoration: 'none',
+ color: theme.palette.text.primary,
+ },
+ },
+}));
diff --git a/frontend/src/component/menu/Footer/Footer.test.tsx b/frontend/src/component/menu/Footer/Footer.test.tsx
new file mode 100644
index 0000000000..1d877a8f20
--- /dev/null
+++ b/frontend/src/component/menu/Footer/Footer.test.tsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import { MemoryRouter } from 'react-router-dom';
+import Footer from './Footer';
+import { ThemeProvider } from 'themes/ThemeProvider';
+import { AnnouncerProvider } from 'component/common/Announcer/AnnouncerProvider/AnnouncerProvider';
+
+test('should render DrawerMenu', () => {
+ const tree = renderer.create(
+
+
+
+
+
+
+
+ );
+
+ expect(tree).toMatchSnapshot();
+});
+
+test('should render DrawerMenu with "features" selected', () => {
+ const tree = renderer.create(
+
+
+
+
+
+
+
+ );
+
+ expect(tree).toMatchSnapshot();
+});
diff --git a/frontend/src/component/menu/Footer/Footer.tsx b/frontend/src/component/menu/Footer/Footer.tsx
new file mode 100644
index 0000000000..2fed152523
--- /dev/null
+++ b/frontend/src/component/menu/Footer/Footer.tsx
@@ -0,0 +1,271 @@
+/* eslint-disable react/jsx-no-target-blank */
+
+import { VFC } from 'react';
+import { List, ListItem, ListItemText, Grid } from '@mui/material';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import { ApiDetails } from './ApiDetails/ApiDetails';
+import { useStyles } from './Footer.styles';
+import { FooterTitle } from './FooterTitle';
+
+export const Footer: VFC = () => {
+ const { classes: styles } = useStyles();
+ const { uiConfig } = useUiConfig();
+
+ return (
+
+
+
+
+
+
+
+
+
+ Server SDKs
+
+
+
+ Node.js
+
+ }
+ />
+
+
+
+ Java
+
+ }
+ />
+
+
+
+ Go
+
+ }
+ />
+ {' '}
+
+
+ Ruby
+
+ }
+ />
+ {' '}
+
+
+ Python
+
+ }
+ />
+
+
+
+ .NET
+
+ }
+ />
+
+
+
+ PHP
+
+ }
+ />
+
+
+
+ All SDKs
+
+ }
+ />
+
+
+
+
+
+
+ Frontend SDKs
+
+
+
+ Unleash Proxy
+
+ }
+ />
+
+
+
+ JavaScript SDK
+
+ }
+ />
+
+
+
+ React SDK
+
+ }
+ />
+
+
+
+ iOS SDK
+
+ }
+ />
+
+
+
+ Android SDK
+
+ }
+ />
+
+
+
+
+
+
+ About
+
+
+
+ getunleash.io
+
+ }
+ />
+
+
+
+ Twitter
+
+ }
+ />
+
+
+
+ LinkedIn
+
+ }
+ />
+
+
+
+ GitHub
+
+ }
+ />
+
+
+
+ Slack Community
+
+ }
+ />
+
+
+
+
+
+
+
+
+ );
+};
+
+export default Footer;
diff --git a/frontend/src/component/menu/Footer/FooterTitle.styles.ts b/frontend/src/component/menu/Footer/FooterTitle.styles.ts
new file mode 100644
index 0000000000..b67cf10a2a
--- /dev/null
+++ b/frontend/src/component/menu/Footer/FooterTitle.styles.ts
@@ -0,0 +1,11 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ title: {
+ all: 'unset',
+ display: 'block',
+ margin: '1rem 0',
+ fontSize: '1rem',
+ fontWeight: theme.fontWeight.bold,
+ },
+}));
diff --git a/frontend/src/component/menu/Footer/FooterTitle.tsx b/frontend/src/component/menu/Footer/FooterTitle.tsx
new file mode 100644
index 0000000000..c8b25f9ac1
--- /dev/null
+++ b/frontend/src/component/menu/Footer/FooterTitle.tsx
@@ -0,0 +1,12 @@
+import { ReactNode } from 'react';
+import { useStyles } from 'component/menu/Footer/FooterTitle.styles';
+
+interface IFooterTitleProps {
+ children: ReactNode;
+}
+
+export const FooterTitle = ({ children }: IFooterTitleProps) => {
+ const { classes: styles } = useStyles();
+
+ return {children} ;
+};
diff --git a/frontend/src/component/menu/Footer/__snapshots__/Footer.test.tsx.snap b/frontend/src/component/menu/Footer/__snapshots__/Footer.test.tsx.snap
new file mode 100644
index 0000000000..2673ce57d9
--- /dev/null
+++ b/frontend/src/component/menu/Footer/__snapshots__/Footer.test.tsx.snap
@@ -0,0 +1,891 @@
+// Vitest Snapshot v1
+
+exports[`should render DrawerMenu 1`] = `
+[
+ ,
+
,
+]
+`;
+
+exports[`should render DrawerMenu with "features" selected 1`] = `
+[
+ ,
+
,
+]
+`;
diff --git a/frontend/src/component/menu/Header/DrawerMenu/DrawerMenu.module.scss b/frontend/src/component/menu/Header/DrawerMenu/DrawerMenu.module.scss
new file mode 100644
index 0000000000..1f1ae94ce3
--- /dev/null
+++ b/frontend/src/component/menu/Header/DrawerMenu/DrawerMenu.module.scss
@@ -0,0 +1,65 @@
+.drawer {
+ box-shadow: none;
+ border: 0;
+}
+
+.drawerTitle {
+ display: flex;
+ align-items: center;
+ color: inherit;
+ text-decoration: none;
+}
+
+.drawerContainer {
+ width: var(--drawer-width);
+}
+
+.drawerTitleLogo {
+ width: 65px;
+ height: 65px;
+}
+
+.drawerTitleText {
+ font-size: 1.1rem;
+ margin-left: 0.25rem;
+}
+
+.drawerList,
+.iconLinkList {
+ display: flex;
+ flex-direction: column;
+ align-items: centre;
+ padding: 0.5rem;
+}
+
+.iconLink {
+ display: flex;
+ padding: 0.8rem;
+ text-decoration: none;
+ color: inherit;
+}
+
+.navigationLink {
+ text-decoration: none;
+ color: var(--drawer-link-inactive);
+ padding: var(--drawer-padding);
+ display: flex;
+ align-items: centre;
+}
+
+.navigationLinkActive {
+ background-color: var(--drawer-link-active-bg);
+ color: var(--drawer-link-active);
+ width: 100%;
+}
+
+.navigationIcon {
+ margin-right: 16px;
+ fill: #635dc5;
+}
+
+.iconGitHub {
+ width: 24px;
+ height: 24px;
+ background: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHN0eWxlPSJ3aWR0aDoyNHB4O2hlaWdodDoyNHB4IiB2aWV3Qm94PSIwIDAgMjQgMjQiPgogICAgPHBhdGggZmlsbD0iIzc1NzU3NSIgZD0iTTEyLDJBMTAsMTAgMCAwLDAgMiwxMkMyLDE2LjQyIDQuODcsMjAuMTcgOC44NCwyMS41QzkuMzQsMjEuNTggOS41LDIxLjI3IDkuNSwyMUM5LjUsMjAuNzcgOS41LDIwLjE0IDkuNSwxOS4zMUM2LjczLDE5LjkxIDYuMTQsMTcuOTcgNi4xNCwxNy45N0M1LjY4LDE2LjgxIDUuMDMsMTYuNSA1LjAzLDE2LjVDNC4xMiwxNS44OCA1LjEsMTUuOSA1LjEsMTUuOUM2LjEsMTUuOTcgNi42MywxNi45MyA2LjYzLDE2LjkzQzcuNSwxOC40NSA4Ljk3LDE4IDkuNTQsMTcuNzZDOS42MywxNy4xMSA5Ljg5LDE2LjY3IDEwLjE3LDE2LjQyQzcuOTUsMTYuMTcgNS42MiwxNS4zMSA1LjYyLDExLjVDNS42MiwxMC4zOSA2LDkuNSA2LjY1LDguNzlDNi41NSw4LjU0IDYuMiw3LjUgNi43NSw2LjE1QzYuNzUsNi4xNSA3LjU5LDUuODggOS41LDcuMTdDMTAuMjksNi45NSAxMS4xNSw2Ljg0IDEyLDYuODRDMTIuODUsNi44NCAxMy43MSw2Ljk1IDE0LjUsNy4xN0MxNi40MSw1Ljg4IDE3LjI1LDYuMTUgMTcuMjUsNi4xNUMxNy44LDcuNSAxNy40NSw4LjU0IDE3LjM1LDguNzlDMTgsOS41IDE4LjM4LDEwLjM5IDE4LjM4LDExLjVDMTguMzgsMTUuMzIgMTYuMDQsMTYuMTYgMTMuODEsMTYuNDFDMTQuMTcsMTYuNzIgMTQuNSwxNy4zMyAxNC41LDE4LjI2QzE0LjUsMTkuNiAxNC41LDIwLjY4IDE0LjUsMjFDMTQuNSwyMS4yNyAxNC42NiwyMS41OSAxNS4xNywyMS41QzE5LjE0LDIwLjE2IDIyLDE2LjQyIDIyLDEyQTEwLDEwIDAgMCwwIDEyLDJaIiAvPgo8L3N2Zz4=);
+}
diff --git a/frontend/src/component/menu/Header/DrawerMenu/DrawerMenu.tsx b/frontend/src/component/menu/Header/DrawerMenu/DrawerMenu.tsx
new file mode 100644
index 0000000000..ebb1683171
--- /dev/null
+++ b/frontend/src/component/menu/Header/DrawerMenu/DrawerMenu.tsx
@@ -0,0 +1,130 @@
+import React, { ReactNode, VFC } from 'react';
+import { Link } from 'react-router-dom';
+import { Divider, Drawer, List } from '@mui/material';
+import GitHubIcon from '@mui/icons-material/GitHub';
+import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
+import ExitToApp from '@mui/icons-material/ExitToApp';
+import { ReactComponent as LogoIcon } from 'assets/icons/logoBg.svg';
+import NavigationLink from '../NavigationLink/NavigationLink';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { basePath } from 'utils/formatPath';
+import { IFlags } from 'interfaces/uiConfig';
+import { IRoute } from 'interfaces/route';
+import styles from './DrawerMenu.module.scss'; // FIXME: useStyle - theme
+
+interface IDrawerMenuProps {
+ title?: string;
+ open?: boolean;
+ toggleDrawer: () => void;
+ admin?: boolean;
+ links: Array<{
+ value: string;
+ icon: ReactNode;
+ href: string;
+ title: string;
+ }>;
+ flags?: IFlags;
+ routes: {
+ mainNavRoutes: IRoute[];
+ mobileRoutes: IRoute[];
+ adminRoutes: IRoute[];
+ };
+}
+
+export const DrawerMenu: VFC = ({
+ links = [],
+ title = 'Unleash',
+ flags = {},
+ open = false,
+ toggleDrawer,
+ admin = false,
+ routes,
+}) => {
+ const renderLinks = () => {
+ return links.map(link => {
+ let icon = null;
+ if (link.value === 'GitHub') {
+ icon = ;
+ } else if (link.value === 'Documentation') {
+ icon = ;
+ }
+
+ return (
+
+ {icon}
+ {link.value}
+
+ );
+ });
+ };
+
+ return (
+
+
+
+ );
+};
diff --git a/frontend/src/component/menu/Header/Header.styles.ts b/frontend/src/component/menu/Header/Header.styles.ts
new file mode 100644
index 0000000000..5c04ad56e1
--- /dev/null
+++ b/frontend/src/component/menu/Header/Header.styles.ts
@@ -0,0 +1,89 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ header: {
+ backgroundColor: theme.palette.headerBackground,
+ padding: '0.5rem',
+ boxShadow: 'none',
+ position: 'relative',
+ zIndex: 300,
+ },
+ links: {
+ display: 'flex',
+ justifyContent: 'center',
+ marginLeft: '1.5rem',
+ '& a': {
+ textDecoration: 'none',
+ color: 'inherit',
+ marginRight: '1.5rem',
+ display: 'flex',
+ alignItems: 'center',
+ },
+ },
+ container: {
+ display: 'flex',
+ alignItems: 'center',
+ maxWidth: 1280,
+ [theme.breakpoints.down('md')]: {
+ padding: '0',
+ },
+ },
+ nav: {
+ display: 'flex',
+ alignItems: 'center',
+ flexGrow: 1,
+ },
+ drawerButton: {
+ color: '#000',
+ },
+ advancedNavButton: {
+ border: 'none',
+ background: 'transparent',
+ height: '100%',
+ display: 'flex',
+ fontSize: '1rem',
+ fontFamily: theme.typography.fontFamily,
+ alignItems: 'center',
+ color: 'inherit',
+ cursor: 'pointer',
+ },
+ headerTitle: {
+ fontSize: '1.4rem',
+ },
+ userContainer: {
+ marginLeft: 'auto',
+ display: 'flex',
+ alignItems: 'center',
+ },
+ logoOnly: {
+ width: '60px',
+ },
+ logo: {
+ width: '150px',
+ },
+ popover: {
+ top: '25px',
+ },
+ menuItem: {
+ minWidth: '150px',
+ },
+ menuItemBox: {
+ width: '12.5px',
+ height: '12.5px',
+ display: 'block',
+ backgroundColor: theme.palette.primary.main,
+ marginRight: '1rem',
+ borderRadius: '2px',
+ },
+ navMenuLink: {
+ textDecoration: 'none',
+ alignItems: 'center',
+ display: 'flex',
+ },
+ icon: {
+ color: theme.palette.grey[700],
+ },
+ wideButton: {
+ borderRadius: 100,
+ },
+}));
diff --git a/frontend/src/component/menu/Header/Header.tsx b/frontend/src/component/menu/Header/Header.tsx
new file mode 100644
index 0000000000..c12341e408
--- /dev/null
+++ b/frontend/src/component/menu/Header/Header.tsx
@@ -0,0 +1,242 @@
+import { useEffect, useState, VFC } from 'react';
+import useMediaQuery from '@mui/material/useMediaQuery';
+import { useTheme } from '@mui/material/styles';
+import { Link } from 'react-router-dom';
+import {
+ AppBar,
+ Container,
+ FormControlLabel,
+ IconButton,
+ Tooltip,
+ Switch,
+} from '@mui/material';
+import MenuIcon from '@mui/icons-material/Menu';
+import SettingsIcon from '@mui/icons-material/Settings';
+import UserProfile from 'component/user/UserProfile';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import MenuBookIcon from '@mui/icons-material/MenuBook';
+import { ReactComponent as UnleashLogo } from 'assets/img/logoDarkWithText.svg';
+import { ReactComponent as UnleashLogoWhite } from 'assets/img/logoWithWhiteText.svg';
+
+import { DrawerMenu } from './DrawerMenu/DrawerMenu';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import { useThemeStyles } from 'themes/themeStyles';
+import { ADMIN } from 'component/providers/AccessProvider/permissions';
+import { IPermission } from 'interfaces/user';
+import { NavigationMenu } from './NavigationMenu/NavigationMenu';
+import { getRoutes } from 'component/menu/routes';
+import { KeyboardArrowDown } from '@mui/icons-material';
+import { filterByConfig } from 'component/common/util';
+import { useAuthPermissions } from 'hooks/api/getters/useAuth/useAuthPermissions';
+import { useStyles } from './Header.styles';
+import classNames from 'classnames';
+import { useId } from 'hooks/useId';
+import { IRoute } from 'interfaces/route';
+import { ThemeMode } from 'component/common/ThemeMode/ThemeMode';
+import { useThemeMode } from 'hooks/useThemeMode';
+
+const Header: VFC = () => {
+ const { onSetThemeMode, themeMode } = useThemeMode();
+ const theme = useTheme();
+ const adminId = useId();
+ const configId = useId();
+ const [adminRef, setAdminRef] = useState(null);
+ const [configRef, setConfigRef] = useState(null);
+
+ const [admin, setAdmin] = useState(false);
+ const { permissions } = useAuthPermissions();
+ const { uiConfig, isOss } = useUiConfig();
+ const smallScreen = useMediaQuery(theme.breakpoints.down('md'));
+ const { classes: styles } = useStyles();
+ const { classes: themeStyles } = useThemeStyles();
+ const [openDrawer, setOpenDrawer] = useState(false);
+
+ const toggleDrawer = () => setOpenDrawer(prev => !prev);
+ const onAdminClose = () => setAdminRef(null);
+ const onConfigureClose = () => setConfigRef(null);
+
+ useEffect(() => {
+ const admin = permissions?.find(
+ (element: IPermission) => element.permission === ADMIN
+ );
+
+ if (admin) {
+ setAdmin(true);
+ }
+ }, [permissions]);
+
+ const routes = getRoutes();
+
+ const filterByEnterprise = (route: IRoute): boolean => {
+ return !route.menu.isEnterprise || !isOss();
+ };
+
+ const filteredMainRoutes = {
+ mainNavRoutes: routes.mainNavRoutes.filter(filterByConfig(uiConfig)),
+ mobileRoutes: routes.mobileRoutes.filter(filterByConfig(uiConfig)),
+ adminRoutes: routes.adminRoutes
+ .filter(filterByConfig(uiConfig))
+ .filter(filterByEnterprise),
+ };
+
+ if (smallScreen) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ }
+ lightmode={
+
+ }
+ />
+
+
+
+
+ Projects
+
+
+ Feature toggles
+
+
+ Playground
+
+ setConfigRef(e.currentTarget)}
+ aria-controls={configRef ? configId : undefined}
+ aria-expanded={Boolean(configRef)}
+ >
+ Configure
+
+
+
+
+
+
+ }
+ label="darkmode"
+ />
+ }
+ />
+
+
+
+
+
+
+
+ setAdminRef(e.currentTarget)
+ }
+ className={classNames(
+ styles.wideButton,
+ themeStyles.focusable
+ )}
+ aria-controls={
+ adminRef ? adminId : undefined
+ }
+ aria-expanded={Boolean(adminRef)}
+ size="large"
+ disableRipple
+ >
+
+
+
+
+ }
+ />
+ {' '}
+
+
+
+
+
+ );
+};
+
+export default Header;
diff --git a/frontend/src/component/menu/Header/NavigationLink/NavigationLink.styles.ts b/frontend/src/component/menu/Header/NavigationLink/NavigationLink.styles.ts
new file mode 100644
index 0000000000..f122b84488
--- /dev/null
+++ b/frontend/src/component/menu/Header/NavigationLink/NavigationLink.styles.ts
@@ -0,0 +1,31 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ menuItem: {
+ minWidth: '150px',
+ height: '100%',
+ width: '100%',
+ margin: '0',
+ padding: '0',
+ },
+ menuItemBox: {
+ width: '12.5px',
+ height: '12.5px',
+ display: 'block',
+ backgroundColor: theme.palette.primary.main,
+ marginRight: '1rem',
+ borderRadius: '2px',
+ },
+ navMenuLink: {
+ textDecoration: 'none',
+ alignItems: 'center',
+ display: 'flex',
+ color: 'inherit',
+ height: '100%',
+ width: '100%',
+ '&&': {
+ // Override MenuItem's built-in padding.
+ padding: '0.5rem 1rem',
+ },
+ },
+}));
diff --git a/frontend/src/component/menu/Header/NavigationLink/NavigationLink.tsx b/frontend/src/component/menu/Header/NavigationLink/NavigationLink.tsx
new file mode 100644
index 0000000000..c9ed93d55b
--- /dev/null
+++ b/frontend/src/component/menu/Header/NavigationLink/NavigationLink.tsx
@@ -0,0 +1,36 @@
+import { ListItem, Link } from '@mui/material';
+import { Link as RouterLink } from 'react-router-dom';
+
+import { useStyles } from './NavigationLink.styles';
+
+interface INavigationLinkProps {
+ path: string;
+ text: string;
+ handleClose: () => void;
+}
+
+const NavigationLink = ({ path, text, handleClose }: INavigationLinkProps) => {
+ const { classes: styles } = useStyles();
+
+ return (
+ {
+ handleClose();
+ }}
+ >
+
+
+ {text}
+
+
+ );
+};
+
+export default NavigationLink;
diff --git a/frontend/src/component/menu/Header/NavigationMenu/NavigationMenu.tsx b/frontend/src/component/menu/Header/NavigationMenu/NavigationMenu.tsx
new file mode 100644
index 0000000000..2c3080e958
--- /dev/null
+++ b/frontend/src/component/menu/Header/NavigationMenu/NavigationMenu.tsx
@@ -0,0 +1,46 @@
+import { Menu, MenuItem } from '@mui/material';
+import { Link } from 'react-router-dom';
+import { useStyles } from '../NavigationLink/NavigationLink.styles';
+
+interface INavigationMenuProps {
+ options: any[];
+ id: string;
+ anchorEl: any;
+ handleClose: () => void;
+ style: Object;
+}
+
+export const NavigationMenu = ({
+ options,
+ id,
+ handleClose,
+ anchorEl,
+ style,
+}: INavigationMenuProps) => {
+ const { classes: styles } = useStyles();
+
+ return (
+
+ {options.map(option => {
+ return (
+
+
+ {option.title}
+
+ );
+ })}
+
+ );
+};
diff --git a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap
new file mode 100644
index 0000000000..b4632cd766
--- /dev/null
+++ b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap
@@ -0,0 +1,489 @@
+// Vitest Snapshot v1
+
+exports[`returns all baseRoutes 1`] = `
+[
+ {
+ "component": [Function],
+ "menu": {},
+ "path": "/splash/:splashId",
+ "title": "Unleash",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "enterprise": true,
+ "menu": {},
+ "parent": "/projects",
+ "path": "/projects/create",
+ "title": "Create",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "enterprise": true,
+ "menu": {},
+ "parent": "/projects",
+ "path": "/projects/:projectId/edit",
+ "title": ":projectId",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {},
+ "parent": "/archive",
+ "path": "/projects/:projectId/archived",
+ "title": ":projectId",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {},
+ "parent": "/projects/:projectId/features/:featureId/:activeTab",
+ "path": "/projects/:projectId/features/:featureId/:activeTab/copy",
+ "title": "Copy",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {},
+ "parent": "/projects",
+ "path": "/projects/:projectId/features/:featureId/edit",
+ "title": "Edit feature",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {},
+ "parent": "/projects",
+ "path": "/projects/:projectId/features/:featureId/*",
+ "title": "FeatureView",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {},
+ "parent": "/projects/:projectId/features",
+ "path": "/projects/:projectId/create-toggle",
+ "title": "Create feature toggle",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {},
+ "parent": "/features",
+ "path": "/projects/:projectId/features2/:featureId",
+ "title": ":featureId",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "flag": "P",
+ "menu": {},
+ "parent": "/projects",
+ "path": "/projects/:projectId/*",
+ "title": ":projectId",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {
+ "mobile": true,
+ },
+ "path": "/projects",
+ "title": "Projects",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {},
+ "parent": "/features",
+ "path": "/features/:activeTab/:featureId",
+ "title": ":featureId",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {
+ "mobile": true,
+ },
+ "path": "/features",
+ "title": "Feature toggles",
+ "type": "protected",
+ },
+ {
+ "component": {
+ "$$typeof": Symbol(react.lazy),
+ "_init": [Function],
+ "_payload": {
+ "_result": [Function],
+ "_status": -1,
+ },
+ },
+ "hidden": false,
+ "menu": {
+ "mobile": true,
+ },
+ "path": "/playground",
+ "title": "Playground",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {},
+ "parent": "/applications",
+ "path": "/applications/:name",
+ "title": ":name",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {
+ "advanced": true,
+ "mobile": true,
+ },
+ "path": "/applications",
+ "title": "Applications",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "flag": "C",
+ "menu": {},
+ "parent": "/context",
+ "path": "/context/create",
+ "title": "Create",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "flag": "C",
+ "menu": {},
+ "parent": "/context",
+ "path": "/context/edit/:name",
+ "title": ":name",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "flag": "C",
+ "menu": {
+ "advanced": true,
+ "mobile": true,
+ },
+ "path": "/context",
+ "title": "Context fields",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {},
+ "parent": "/strategies",
+ "path": "/strategies/create",
+ "title": "Create",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {},
+ "parent": "/strategies",
+ "path": "/strategies/:name/edit",
+ "title": ":name",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {},
+ "parent": "/strategies",
+ "path": "/strategies/:name",
+ "title": ":name",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {
+ "advanced": true,
+ "mobile": true,
+ },
+ "path": "/strategies",
+ "title": "Strategies",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {},
+ "parent": "/environments",
+ "path": "/environments/create",
+ "title": "Environments",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {},
+ "path": "/environments/:id",
+ "title": "Edit",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "flag": "EEA",
+ "menu": {
+ "advanced": true,
+ "mobile": true,
+ },
+ "path": "/environments",
+ "title": "Environments",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {},
+ "parent": "/tag-types",
+ "path": "/tag-types/create",
+ "title": "Create",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {},
+ "parent": "/tag-types",
+ "path": "/tag-types/edit/:name",
+ "title": ":name",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {
+ "advanced": true,
+ "mobile": true,
+ },
+ "path": "/tag-types",
+ "title": "Tag types",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {},
+ "parent": "/addons",
+ "path": "/addons/create/:providerId",
+ "title": "Create",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {},
+ "parent": "/addons",
+ "path": "/addons/edit/:addonId",
+ "title": "Edit",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "hidden": false,
+ "menu": {
+ "advanced": true,
+ "mobile": true,
+ },
+ "path": "/addons",
+ "title": "Addons",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "flag": "SE",
+ "hidden": false,
+ "layout": "main",
+ "menu": {},
+ "path": "/segments/create",
+ "title": "Segments",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "flag": "SE",
+ "hidden": false,
+ "layout": "main",
+ "menu": {},
+ "path": "/segments/edit/:segmentId",
+ "title": "Segments",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "flag": "SE",
+ "hidden": false,
+ "menu": {
+ "advanced": true,
+ "mobile": true,
+ },
+ "path": "/segments",
+ "title": "Segments",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {
+ "adminSettings": true,
+ },
+ "path": "/history",
+ "title": "Event log",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {},
+ "path": "/archive",
+ "title": "Archived toggles",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {},
+ "parent": "/admin",
+ "path": "/admin/api/create-token",
+ "title": "API access",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "flag": "RE",
+ "menu": {},
+ "path": "/admin/create-project-role",
+ "title": "Create",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "flag": "RE",
+ "menu": {},
+ "path": "/admin/roles/:id/edit",
+ "title": "Edit",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {
+ "advanced": true,
+ "mobile": true,
+ },
+ "parent": "/admin",
+ "path": "/admin/api",
+ "title": "API access",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {
+ "adminSettings": true,
+ },
+ "parent": "/admin",
+ "path": "/admin/users",
+ "title": "Users",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {},
+ "parent": "/admin",
+ "path": "/admin/create-user",
+ "title": "Users",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "flag": "UG",
+ "menu": {
+ "adminSettings": true,
+ },
+ "parent": "/admin",
+ "path": "/admin/groups",
+ "title": "Groups",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "flag": "UG",
+ "menu": {},
+ "parent": "/admin",
+ "path": "/admin/groups/:groupId",
+ "title": ":groupId",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "flag": "UG",
+ "menu": {},
+ "parent": "/admin/groups",
+ "path": "/admin/groups/create-group",
+ "title": "Create group",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "flag": "UG",
+ "menu": {},
+ "parent": "/admin/groups",
+ "path": "/admin/groups/:groupId/edit",
+ "title": "Edit group",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "flag": "RE",
+ "menu": {
+ "adminSettings": true,
+ },
+ "parent": "/admin",
+ "path": "/admin/roles",
+ "title": "Project roles",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {
+ "adminSettings": true,
+ },
+ "parent": "/admin",
+ "path": "/admin/auth",
+ "title": "Single sign-on",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "configFlag": "embedProxy",
+ "menu": {
+ "adminSettings": true,
+ },
+ "parent": "/admin",
+ "path": "/admin/cors",
+ "title": "CORS origins",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {},
+ "parent": "/admin",
+ "path": "/admin/billing",
+ "title": "Billing",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "menu": {
+ "adminSettings": true,
+ "isEnterprise": true,
+ },
+ "parent": "/admin",
+ "path": "/admin-invoices",
+ "title": "Billing & invoices",
+ "type": "protected",
+ },
+ {
+ "component": [Function],
+ "hidden": false,
+ "menu": {},
+ "path": "/admin",
+ "title": "Admin",
+ "type": "protected",
+ },
+]
+`;
diff --git a/frontend/src/component/menu/__tests__/routes.test.tsx b/frontend/src/component/menu/__tests__/routes.test.tsx
new file mode 100644
index 0000000000..f0d7f11330
--- /dev/null
+++ b/frontend/src/component/menu/__tests__/routes.test.tsx
@@ -0,0 +1,10 @@
+import { baseRoutes, getRoute } from '../routes';
+
+test('returns all baseRoutes', () => {
+ expect(baseRoutes).toMatchSnapshot();
+});
+
+test('getRoute() returns named route', () => {
+ const featuresRoute = getRoute('/features');
+ expect(featuresRoute?.path).toEqual('/features');
+});
diff --git a/frontend/src/component/menu/routes.ts b/frontend/src/component/menu/routes.ts
new file mode 100644
index 0000000000..77efed3d9e
--- /dev/null
+++ b/frontend/src/component/menu/routes.ts
@@ -0,0 +1,585 @@
+import { FeatureToggleListTable } from 'component/feature/FeatureToggleList/FeatureToggleListTable';
+import { StrategyView } from 'component/strategies/StrategyView/StrategyView';
+import { StrategiesList } from 'component/strategies/StrategiesList/StrategiesList';
+import { TagTypeList } from 'component/tags/TagTypeList/TagTypeList';
+import { AddonList } from 'component/addons/AddonList/AddonList';
+import Admin from 'component/admin';
+import AdminApi from 'component/admin/api';
+import AdminUsers from 'component/admin/users/UsersAdmin';
+import { GroupsAdmin } from 'component/admin/groups/GroupsAdmin';
+import { AuthSettings } from 'component/admin/auth/AuthSettings';
+import Login from 'component/user/Login/Login';
+import { C, EEA, P, RE, SE, UG } from 'component/common/flags';
+import { NewUser } from 'component/user/NewUser/NewUser';
+import ResetPassword from 'component/user/ResetPassword/ResetPassword';
+import ForgottenPassword from 'component/user/ForgottenPassword/ForgottenPassword';
+import { ProjectListNew } from 'component/project/ProjectList/ProjectList';
+import Project from 'component/project/Project/Project';
+import RedirectArchive from 'component/archive/RedirectArchive';
+import { FeatureView } from 'component/feature/FeatureView/FeatureView';
+import ProjectRoles from 'component/admin/projectRoles/ProjectRoles/ProjectRoles';
+import CreateProjectRole from 'component/admin/projectRoles/CreateProjectRole/CreateProjectRole';
+import EditProjectRole from 'component/admin/projectRoles/EditProjectRole/EditProjectRole';
+import CreateUser from 'component/admin/users/CreateUser/CreateUser';
+import EditUser from 'component/admin/users/EditUser/EditUser';
+import { CreateApiToken } from 'component/admin/apiToken/CreateApiToken/CreateApiToken';
+import CreateEnvironment from 'component/environments/CreateEnvironment/CreateEnvironment';
+import EditEnvironment from 'component/environments/EditEnvironment/EditEnvironment';
+import { EditContext } from 'component/context/EditContext/EditContext';
+import EditTagType from 'component/tags/EditTagType/EditTagType';
+import CreateTagType from 'component/tags/CreateTagType/CreateTagType';
+import EditProject from 'component/project/Project/EditProject/EditProject';
+import CreateProject from 'component/project/Project/CreateProject/CreateProject';
+import CreateFeature from 'component/feature/CreateFeature/CreateFeature';
+import EditFeature from 'component/feature/EditFeature/EditFeature';
+import { ApplicationEdit } from 'component/application/ApplicationEdit/ApplicationEdit';
+import { ApplicationList } from 'component/application/ApplicationList/ApplicationList';
+import ContextList from 'component/context/ContextList/ContextList';
+import RedirectFeatureView from 'component/feature/RedirectFeatureView/RedirectFeatureView';
+import { CreateAddon } from 'component/addons/CreateAddon/CreateAddon';
+import { EditAddon } from 'component/addons/EditAddon/EditAddon';
+import { CopyFeatureToggle } from 'component/feature/CopyFeature/CopyFeature';
+import { EventPage } from 'component/events/EventPage/EventPage';
+import { CreateStrategy } from 'component/strategies/CreateStrategy/CreateStrategy';
+import { EditStrategy } from 'component/strategies/EditStrategy/EditStrategy';
+import { SplashPage } from 'component/splash/SplashPage/SplashPage';
+import { CreateUnleashContextPage } from 'component/context/CreateUnleashContext/CreateUnleashContextPage';
+import { CreateSegment } from 'component/segments/CreateSegment/CreateSegment';
+import { EditSegment } from 'component/segments/EditSegment/EditSegment';
+import { IRoute } from 'interfaces/route';
+import { EnvironmentTable } from 'component/environments/EnvironmentTable/EnvironmentTable';
+import { SegmentTable } from 'component/segments/SegmentTable/SegmentTable';
+import FlaggedBillingRedirect from 'component/admin/billing/FlaggedBillingRedirect/FlaggedBillingRedirect';
+import { FeaturesArchiveTable } from '../archive/FeaturesArchiveTable';
+import { Billing } from 'component/admin/billing/Billing';
+import { Group } from 'component/admin/groups/Group/Group';
+import { CreateGroup } from 'component/admin/groups/CreateGroup/CreateGroup';
+import { EditGroup } from 'component/admin/groups/EditGroup/EditGroup';
+import { LazyPlayground } from 'component/playground/Playground/LazyPlayground';
+import { CorsAdmin } from 'component/admin/cors';
+
+export const routes: IRoute[] = [
+ // Splash
+ {
+ path: '/splash/:splashId',
+ title: 'Unleash',
+ component: SplashPage,
+ type: 'protected',
+ menu: {},
+ },
+
+ // Project
+ {
+ path: '/projects/create',
+ parent: '/projects',
+ title: 'Create',
+ component: CreateProject,
+ type: 'protected',
+ enterprise: true,
+ menu: {},
+ },
+ {
+ path: '/projects/:projectId/edit',
+ parent: '/projects',
+ title: ':projectId',
+ component: EditProject,
+ type: 'protected',
+ enterprise: true,
+ menu: {},
+ },
+ {
+ path: '/projects/:projectId/archived',
+ title: ':projectId',
+ parent: '/archive',
+ component: RedirectArchive,
+ type: 'protected',
+ menu: {},
+ },
+ {
+ path: '/projects/:projectId/features/:featureId/:activeTab/copy',
+ parent: '/projects/:projectId/features/:featureId/:activeTab',
+ title: 'Copy',
+ component: CopyFeatureToggle,
+ type: 'protected',
+ menu: {},
+ },
+ {
+ path: '/projects/:projectId/features/:featureId/edit',
+ parent: '/projects',
+ title: 'Edit feature',
+ component: EditFeature,
+ type: 'protected',
+ menu: {},
+ },
+ {
+ path: '/projects/:projectId/features/:featureId/*',
+ parent: '/projects',
+ title: 'FeatureView',
+ component: FeatureView,
+ type: 'protected',
+ menu: {},
+ },
+ {
+ path: '/projects/:projectId/create-toggle',
+ parent: '/projects/:projectId/features',
+ title: 'Create feature toggle',
+ component: CreateFeature,
+ type: 'protected',
+ menu: {},
+ },
+ {
+ path: '/projects/:projectId/features2/:featureId',
+ parent: '/features',
+ title: ':featureId',
+ component: RedirectFeatureView,
+ type: 'protected',
+ menu: {},
+ },
+ {
+ path: '/projects/:projectId/*',
+ parent: '/projects',
+ title: ':projectId',
+ component: Project,
+ flag: P,
+ type: 'protected',
+ menu: {},
+ },
+ {
+ path: '/projects',
+ title: 'Projects',
+ component: ProjectListNew,
+ type: 'protected',
+ menu: { mobile: true },
+ },
+
+ // Features
+ {
+ path: '/features/:activeTab/:featureId',
+ parent: '/features',
+ title: ':featureId',
+ component: RedirectFeatureView,
+ type: 'protected',
+ menu: {},
+ },
+ {
+ path: '/features',
+ title: 'Feature toggles',
+ component: FeatureToggleListTable,
+ type: 'protected',
+ menu: { mobile: true },
+ },
+
+ // Playground
+ {
+ path: '/playground',
+ title: 'Playground',
+ component: LazyPlayground,
+ hidden: false,
+ type: 'protected',
+ menu: { mobile: true },
+ },
+
+ // Applications
+ {
+ path: '/applications/:name',
+ title: ':name',
+ parent: '/applications',
+ component: ApplicationEdit,
+ type: 'protected',
+ menu: {},
+ },
+ {
+ path: '/applications',
+ title: 'Applications',
+ component: ApplicationList,
+ type: 'protected',
+ menu: { mobile: true, advanced: true },
+ },
+
+ // Context
+ {
+ path: '/context/create',
+ parent: '/context',
+ title: 'Create',
+ component: CreateUnleashContextPage,
+ type: 'protected',
+ flag: C,
+ menu: {},
+ },
+ {
+ path: '/context/edit/:name',
+ parent: '/context',
+ title: ':name',
+ component: EditContext,
+ type: 'protected',
+ flag: C,
+ menu: {},
+ },
+ {
+ path: '/context',
+ title: 'Context fields',
+ component: ContextList,
+ type: 'protected',
+ flag: C,
+ menu: { mobile: true, advanced: true },
+ },
+
+ // Strategies
+ {
+ path: '/strategies/create',
+ title: 'Create',
+ parent: '/strategies',
+ component: CreateStrategy,
+ type: 'protected',
+ menu: {},
+ },
+ {
+ path: '/strategies/:name/edit',
+ title: ':name',
+ parent: '/strategies',
+ component: EditStrategy,
+ type: 'protected',
+ menu: {},
+ },
+ {
+ path: '/strategies/:name',
+ title: ':name',
+ parent: '/strategies',
+ component: StrategyView,
+ type: 'protected',
+ menu: {},
+ },
+ {
+ path: '/strategies',
+ title: 'Strategies',
+ component: StrategiesList,
+ type: 'protected',
+ menu: { mobile: true, advanced: true },
+ },
+ {
+ path: '/environments/create',
+ title: 'Environments',
+ component: CreateEnvironment,
+ parent: '/environments',
+ type: 'protected',
+ menu: {},
+ },
+ {
+ path: '/environments/:id',
+ title: 'Edit',
+ component: EditEnvironment,
+ type: 'protected',
+ menu: {},
+ },
+ {
+ path: '/environments',
+ title: 'Environments',
+ component: EnvironmentTable,
+ type: 'protected',
+ flag: EEA,
+ menu: { mobile: true, advanced: true },
+ },
+
+ // Tags
+ {
+ path: '/tag-types/create',
+ parent: '/tag-types',
+ title: 'Create',
+ component: CreateTagType,
+ type: 'protected',
+ menu: {},
+ },
+ {
+ path: '/tag-types/edit/:name',
+ parent: '/tag-types',
+ title: ':name',
+ component: EditTagType,
+ type: 'protected',
+ menu: {},
+ },
+ {
+ path: '/tag-types',
+ title: 'Tag types',
+ component: TagTypeList,
+ type: 'protected',
+ menu: { mobile: true, advanced: true },
+ },
+
+ // Addons
+ {
+ path: '/addons/create/:providerId',
+ parent: '/addons',
+ title: 'Create',
+ component: CreateAddon,
+ type: 'protected',
+ menu: {},
+ },
+ {
+ path: '/addons/edit/:addonId',
+ parent: '/addons',
+ title: 'Edit',
+ component: EditAddon,
+ type: 'protected',
+ menu: {},
+ },
+ {
+ path: '/addons',
+ title: 'Addons',
+ component: AddonList,
+ hidden: false,
+ type: 'protected',
+ menu: { mobile: true, advanced: true },
+ },
+
+ // Segments
+ {
+ path: '/segments/create',
+ title: 'Segments',
+ component: CreateSegment,
+ hidden: false,
+ type: 'protected',
+ layout: 'main',
+ menu: {},
+ flag: SE,
+ },
+ {
+ path: '/segments/edit/:segmentId',
+ title: 'Segments',
+ component: EditSegment,
+ hidden: false,
+ type: 'protected',
+ layout: 'main',
+ menu: {},
+ flag: SE,
+ },
+ {
+ path: '/segments',
+ title: 'Segments',
+ component: SegmentTable,
+ hidden: false,
+ type: 'protected',
+ menu: { mobile: true, advanced: true },
+ flag: SE,
+ },
+
+ // History
+ {
+ path: '/history',
+ title: 'Event log',
+ component: EventPage,
+ type: 'protected',
+ menu: { adminSettings: true },
+ },
+
+ // Archive
+ {
+ path: '/archive',
+ title: 'Archived toggles',
+ component: FeaturesArchiveTable,
+ type: 'protected',
+ menu: {},
+ },
+
+ // Admin
+ {
+ path: '/admin/api/create-token',
+ parent: '/admin',
+ title: 'API access',
+ component: CreateApiToken,
+ type: 'protected',
+ menu: {},
+ },
+ {
+ path: '/admin/create-project-role',
+ title: 'Create',
+ component: CreateProjectRole,
+ type: 'protected',
+ menu: {},
+ flag: RE,
+ },
+ {
+ path: '/admin/roles/:id/edit',
+ title: 'Edit',
+ component: EditProjectRole,
+ type: 'protected',
+ menu: {},
+ flag: RE,
+ },
+ {
+ path: '/admin/users/:id/edit',
+ title: 'Edit',
+ component: EditUser,
+ type: 'protected',
+ menu: {},
+ hidden: true,
+ },
+ {
+ path: '/admin/api',
+ parent: '/admin',
+ title: 'API access',
+ component: AdminApi,
+ type: 'protected',
+ menu: { mobile: true, advanced: true },
+ },
+ {
+ path: '/admin/users',
+ parent: '/admin',
+ title: 'Users',
+ component: AdminUsers,
+ type: 'protected',
+ menu: { adminSettings: true },
+ },
+ {
+ path: '/admin/create-user',
+ parent: '/admin',
+ title: 'Users',
+ component: CreateUser,
+ type: 'protected',
+ menu: {},
+ },
+ {
+ path: '/admin/groups',
+ parent: '/admin',
+ title: 'Groups',
+ component: GroupsAdmin,
+ type: 'protected',
+ menu: { adminSettings: true },
+ flag: UG,
+ },
+ {
+ path: '/admin/groups/:groupId',
+ parent: '/admin',
+ title: ':groupId',
+ component: Group,
+ type: 'protected',
+ menu: {},
+ flag: UG,
+ },
+ {
+ path: '/admin/groups/create-group',
+ parent: '/admin/groups',
+ title: 'Create group',
+ component: CreateGroup,
+ type: 'protected',
+ menu: {},
+ flag: UG,
+ },
+ {
+ path: '/admin/groups/:groupId/edit',
+ parent: '/admin/groups',
+ title: 'Edit group',
+ component: EditGroup,
+ type: 'protected',
+ menu: {},
+ flag: UG,
+ },
+ {
+ path: '/admin/roles',
+ parent: '/admin',
+ title: 'Project roles',
+ component: ProjectRoles,
+ type: 'protected',
+ flag: RE,
+ menu: { adminSettings: true },
+ },
+ {
+ path: '/admin/auth',
+ parent: '/admin',
+ title: 'Single sign-on',
+ component: AuthSettings,
+ type: 'protected',
+ menu: { adminSettings: true },
+ },
+ {
+ path: '/admin/cors',
+ parent: '/admin',
+ title: 'CORS origins',
+ component: CorsAdmin,
+ type: 'protected',
+ configFlag: 'embedProxy',
+ menu: { adminSettings: true },
+ },
+ {
+ path: '/admin/billing',
+ parent: '/admin',
+ title: 'Billing',
+ component: Billing,
+ type: 'protected',
+ menu: {},
+ },
+ {
+ path: '/admin-invoices',
+ parent: '/admin',
+ title: 'Billing & invoices',
+ component: FlaggedBillingRedirect,
+ type: 'protected',
+ menu: { adminSettings: true, isEnterprise: true },
+ },
+ {
+ path: '/admin',
+ title: 'Admin',
+ component: Admin,
+ hidden: false,
+ type: 'protected',
+ menu: {},
+ },
+
+ /* If you update this route path, make sure you update the path in SWRProvider.tsx */
+ {
+ path: '/login',
+ title: 'Log in',
+ component: Login,
+ type: 'unprotected',
+ hidden: true,
+ menu: {},
+ },
+ /* If you update this route path, make sure you update the path in SWRProvider.tsx */
+ {
+ path: '/new-user',
+ title: 'New user',
+ hidden: true,
+ component: NewUser,
+ type: 'unprotected',
+ menu: {},
+ },
+ /* If you update this route path, make sure you update the path in SWRProvider.tsx */
+ {
+ path: '/reset-password',
+ title: 'Reset password',
+ hidden: true,
+ component: ResetPassword,
+ type: 'unprotected',
+ menu: {},
+ },
+ /* If you update this route path, make sure you update the path in SWRProvider.tsx */
+ {
+ path: '/forgotten-password',
+ title: 'Forgotten password',
+ hidden: true,
+ component: ForgottenPassword,
+ type: 'unprotected',
+ menu: {},
+ },
+];
+
+export const getRoute = (path: string) =>
+ routes.find(route => route.path === path);
+
+export const baseRoutes = routes.filter(route => !route.hidden);
+
+const computeRoutes = () => {
+ const mainNavRoutes = baseRoutes.filter(route => route.menu.advanced);
+ const adminRoutes = routes.filter(route => route.menu.adminSettings);
+ const mobileRoutes = routes.filter(route => route.menu.mobile);
+
+ const computedRoutes = {
+ mainNavRoutes,
+ adminRoutes,
+ mobileRoutes,
+ };
+ return () => {
+ return computedRoutes;
+ };
+};
+
+export const getRoutes = computeRoutes();
diff --git a/frontend/src/component/playground/Playground/LazyPlayground.tsx b/frontend/src/component/playground/Playground/LazyPlayground.tsx
new file mode 100644
index 0000000000..41ea00f4b3
--- /dev/null
+++ b/frontend/src/component/playground/Playground/LazyPlayground.tsx
@@ -0,0 +1,3 @@
+import { lazy } from 'react';
+
+export const LazyPlayground = lazy(() => import('./Playground'));
diff --git a/frontend/src/component/playground/Playground/Playground.tsx b/frontend/src/component/playground/Playground/Playground.tsx
new file mode 100644
index 0000000000..9277298596
--- /dev/null
+++ b/frontend/src/component/playground/Playground/Playground.tsx
@@ -0,0 +1,223 @@
+import { FormEventHandler, useEffect, useState, VFC } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import { Box, Paper, useMediaQuery, useTheme } from '@mui/material';
+import { PageContent } from 'component/common/PageContent/PageContent';
+import { PageHeader } from 'component/common/PageHeader/PageHeader';
+import useToast from 'hooks/useToast';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import { PlaygroundResultsTable } from './PlaygroundResultsTable/PlaygroundResultsTable';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { usePlaygroundApi } from 'hooks/api/actions/usePlayground/usePlayground';
+import { PlaygroundResponseSchema } from 'component/playground/Playground/interfaces/playground.model';
+import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
+import { PlaygroundForm } from './PlaygroundForm/PlaygroundForm';
+import {
+ resolveDefaultEnvironment,
+ resolveProjects,
+ resolveResultsWidth,
+} from './playground.utils';
+import { PlaygroundGuidance } from './PlaygroundGuidance/PlaygroundGuidance';
+import { PlaygroundGuidancePopper } from './PlaygroundGuidancePopper/PlaygroundGuidancePopper';
+import Loader from '../../common/Loader/Loader';
+
+export const Playground: VFC<{}> = () => {
+ const { environments } = useEnvironments();
+ const theme = useTheme();
+ const matches = useMediaQuery(theme.breakpoints.down('lg'));
+
+ const [environment, setEnvironment] = useState('');
+ const [projects, setProjects] = useState([]);
+ const [context, setContext] = useState();
+ const [results, setResults] = useState<
+ PlaygroundResponseSchema | undefined
+ >();
+ const { setToastData } = useToast();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const { evaluatePlayground, loading } = usePlaygroundApi();
+
+ useEffect(() => {
+ setEnvironment(resolveDefaultEnvironment(environments));
+ }, [environments]);
+
+ useEffect(() => {
+ // Load initial values from URL
+ try {
+ const environmentFromUrl = searchParams.get('environment');
+ if (environmentFromUrl) {
+ setEnvironment(environmentFromUrl);
+ }
+
+ let projectsArray: string[];
+ let projectsFromUrl = searchParams.get('projects');
+ if (projectsFromUrl) {
+ projectsArray = projectsFromUrl.split(',');
+ setProjects(projectsArray);
+ }
+
+ let contextFromUrl = searchParams.get('context');
+ if (contextFromUrl) {
+ contextFromUrl = decodeURI(contextFromUrl);
+ setContext(contextFromUrl);
+ }
+
+ const makePlaygroundRequest = async () => {
+ if (environmentFromUrl && contextFromUrl) {
+ await evaluatePlaygroundContext(
+ environmentFromUrl,
+ projectsArray || '*',
+ contextFromUrl
+ );
+ }
+ };
+
+ makePlaygroundRequest();
+ } catch (error) {
+ setToastData({
+ type: 'error',
+ title: `Failed to parse URL parameters: ${formatUnknownError(
+ error
+ )}`,
+ });
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const evaluatePlaygroundContext = async (
+ environment: string,
+ projects: string[] | string,
+ context: string | undefined,
+ action?: () => void
+ ) => {
+ try {
+ const parsedContext = JSON.parse(context || '{}');
+ const response = await evaluatePlayground({
+ environment,
+ projects: resolveProjects(projects),
+ context: {
+ appName: 'playground',
+ ...parsedContext,
+ },
+ });
+
+ if (action && typeof action === 'function') {
+ action();
+ }
+ setResults(response);
+ } catch (error: unknown) {
+ setToastData({
+ type: 'error',
+ title: `Error parsing context: ${formatUnknownError(error)}`,
+ });
+ }
+ };
+
+ const onSubmit: FormEventHandler = async event => {
+ event.preventDefault();
+
+ await evaluatePlaygroundContext(
+ environment,
+ projects,
+ context,
+ setURLParameters
+ );
+ };
+
+ const setURLParameters = () => {
+ searchParams.set('context', encodeURI(context || '')); // always set because of native validation
+ searchParams.set('environment', environment);
+ if (
+ Array.isArray(projects) &&
+ projects.length > 0 &&
+ !(projects.length === 1 && projects[0] === '*')
+ ) {
+ searchParams.set('projects', projects.join(','));
+ } else {
+ searchParams.delete('projects');
+ }
+ setSearchParams(searchParams);
+ };
+
+ const formWidth = results && !matches ? '35%' : 'auto';
+ const resultsWidth = resolveResultsWidth(matches, results);
+
+ return (
+ }
+ />
+ }
+ disableLoading
+ bodyClass={'no-padding'}
+ >
+
+
+
+
+
+
+ ({
+ width: resultsWidth,
+ transition: 'width 0.4s ease',
+ padding: theme.spacing(4, 2),
+ })}
+ >
+ }
+ elseShow={
+
+ }
+ elseShow={ }
+ />
+ }
+ />
+
+
+
+ );
+};
+
+export default Playground;
diff --git a/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundCodeFieldset/PlaygroundCodeFieldset.tsx b/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundCodeFieldset/PlaygroundCodeFieldset.tsx
new file mode 100644
index 0000000000..2912dc803b
--- /dev/null
+++ b/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundCodeFieldset/PlaygroundCodeFieldset.tsx
@@ -0,0 +1,237 @@
+import {
+ Dispatch,
+ FormEvent,
+ SetStateAction,
+ useEffect,
+ useMemo,
+ useState,
+ VFC,
+} from 'react';
+import {
+ Box,
+ Button,
+ FormControl,
+ InputLabel,
+ MenuItem,
+ Select,
+ TextField,
+ Typography,
+ useTheme,
+ Autocomplete,
+} from '@mui/material';
+
+import { debounce } from 'debounce';
+import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import useToast from 'hooks/useToast';
+import { PlaygroundEditor } from './PlaygroundEditor/PlaygroundEditor';
+import { GuidanceIndicator } from 'component/common/GuidanceIndicator/GuidanceIndicator';
+import { parseDateValue, parseValidDate } from 'component/common/util';
+interface IPlaygroundCodeFieldsetProps {
+ context: string | undefined;
+ setContext: Dispatch>;
+}
+
+export const PlaygroundCodeFieldset: VFC = ({
+ context,
+ setContext,
+}) => {
+ const theme = useTheme();
+ const { setToastData } = useToast();
+ const { context: contextData } = useUnleashContext();
+ const contextOptions = contextData
+ .sort((a, b) => a.sortOrder - b.sortOrder)
+ .map(({ name }) => name);
+ const [error, setError] = useState();
+ const [fieldExist, setFieldExist] = useState(false);
+ const [contextField, setContextField] = useState('');
+ const [contextValue, setContextValue] = useState('');
+ const debounceJsonParsing = useMemo(
+ () =>
+ debounce((input?: string) => {
+ if (!input) {
+ return setError(undefined);
+ }
+
+ try {
+ const contextValue = JSON.parse(input);
+
+ setFieldExist(contextValue[contextField] !== undefined);
+ } catch (error: unknown) {
+ return setError(formatUnknownError(error));
+ }
+
+ return setError(undefined);
+ }, 250),
+ [setError, contextField, setFieldExist]
+ );
+
+ useEffect(() => {
+ debounceJsonParsing(context);
+ }, [debounceJsonParsing, context]);
+
+ const onAddField = () => {
+ try {
+ const currentValue = JSON.parse(context || '{}');
+ setContext(
+ JSON.stringify(
+ {
+ ...currentValue,
+ [contextField]: contextValue,
+ },
+ null,
+ 2
+ )
+ );
+
+ const foundContext = contextData.find(
+ context => context.name === contextField
+ );
+
+ if (
+ (foundContext?.legalValues &&
+ foundContext.legalValues.length > 0) ||
+ contextField === 'currentTime'
+ )
+ return;
+ setContextValue('');
+ } catch (error) {
+ setToastData({
+ type: 'error',
+ title: `Error parsing context: ${formatUnknownError(error)}`,
+ });
+ }
+ };
+
+ const resolveInput = () => {
+ if (contextField === 'currentTime') {
+ const validDate = parseValidDate(contextValue);
+ const now = new Date();
+
+ const value = validDate
+ ? parseDateValue(validDate.toISOString())
+ : parseDateValue(now.toISOString());
+
+ return (
+ {
+ const parsedDate = parseValidDate(e.target.value);
+ const dateString = parsedDate?.toISOString();
+ dateString && setContextValue(dateString);
+ }}
+ InputLabelProps={{
+ shrink: true,
+ }}
+ required
+ />
+ );
+ }
+ const foundField = contextData.find(
+ contextData => contextData.name === contextField
+ );
+ if (
+ foundField &&
+ foundField.legalValues &&
+ foundField.legalValues.length > 0
+ ) {
+ const options = foundField.legalValues.map(({ value }) => value);
+ return (
+ {
+ if (typeof newValue === 'string') {
+ return setContextValue(newValue);
+ }
+ }}
+ options={options}
+ sx={{ width: 200, maxWidth: '100%' }}
+ renderInput={(params: any) => (
+
+ )}
+ />
+ );
+ }
+
+ return (
+ setContextValue(event.target.value || '')}
+ />
+ );
+ };
+
+ return (
+
+
+ 2
+
+ Unleash context
+
+
+
+
+
+
+ Context field
+
+ {
+ setContextField(event.target.value || '');
+
+ if (event.target.value === 'currentTime') {
+ return setContextValue(
+ new Date().toISOString()
+ );
+ }
+ setContextValue('');
+ }}
+ variant="outlined"
+ size="small"
+ sx={{ width: 200, maxWidth: '100%' }}
+ >
+ {contextOptions.map(option => (
+
+ {option}
+
+ ))}
+
+
+ {resolveInput()}
+
+ {`${!fieldExist ? 'Add' : 'Replace'} `}
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundCodeFieldset/PlaygroundEditor/PlaygroundEditor.tsx b/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundCodeFieldset/PlaygroundEditor/PlaygroundEditor.tsx
new file mode 100644
index 0000000000..61db181346
--- /dev/null
+++ b/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundCodeFieldset/PlaygroundEditor/PlaygroundEditor.tsx
@@ -0,0 +1,138 @@
+import CodeMirror from '@uiw/react-codemirror';
+import { useContext } from 'react';
+import { json } from '@codemirror/lang-json';
+import { Dispatch, SetStateAction, VFC, useCallback } from 'react';
+import { styled, useTheme, Box } from '@mui/material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { duotoneDark, duotoneLight } from '@uiw/codemirror-theme-duotone';
+import Check from '@mui/icons-material/Check';
+import { Error } from '@mui/icons-material';
+import UIContext from 'contexts/UIContext';
+
+interface IPlaygroundEditorProps {
+ context: string | undefined;
+ setContext: Dispatch>;
+ error: string | undefined;
+}
+
+const StyledEditorHeader = styled('aside')(({ theme }) => ({
+ height: '50px',
+ backgroundColor: theme.palette.neutral.light,
+ borderTopRightRadius: theme.shape.borderRadiusMedium,
+ borderTopLeftRadius: theme.shape.borderRadiusMedium,
+ padding: theme.spacing(1, 2),
+ color: theme.palette.text.primary,
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ border: `1px solid ${theme.palette.lightBorder}`,
+ borderBottom: 'none',
+}));
+
+const StyledEditorStatusContainer = styled('div')(({ theme, style }) => ({
+ width: '28px',
+ height: '28px',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ transition: `background-color 0.5s ease-in-out`,
+ borderRadius: '50%',
+ opacity: 0.8,
+ ...style,
+}));
+
+const StyledErrorSpan = styled('div')(({ theme }) => ({
+ fontSize: '0.9rem',
+ color: theme.palette.error.main,
+ marginRight: theme.spacing(1),
+}));
+
+const EditorStatusOk = () => {
+ const theme = useTheme();
+ return (
+
+
+
+ );
+};
+
+const EditorStatusError = () => {
+ const theme = useTheme();
+
+ return (
+
+
+
+ );
+};
+
+export const PlaygroundEditor: VFC = ({
+ context,
+ setContext,
+ error,
+}) => {
+ const { themeMode } = useContext(UIContext);
+ const theme = useTheme();
+ const onCodeFieldChange = useCallback(
+ context => {
+ setContext(context);
+ },
+ [setContext]
+ );
+
+ return (
+
+
+ JSON
+ ({
+ display: 'flex',
+ alignItems: 'center',
+ })}
+ >
+ {error}
+
+
+ }
+ elseShow={ }
+ />
+
+
+
+ );
+};
diff --git a/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.tsx b/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.tsx
new file mode 100644
index 0000000000..c364bfecb4
--- /dev/null
+++ b/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.tsx
@@ -0,0 +1,124 @@
+import { ComponentProps, VFC } from 'react';
+import {
+ Autocomplete,
+ Box,
+ TextField,
+ Typography,
+ useTheme,
+} from '@mui/material';
+import useProjects from 'hooks/api/getters/useProjects/useProjects';
+import { GuidanceIndicator } from 'component/common/GuidanceIndicator/GuidanceIndicator';
+
+interface IPlaygroundConnectionFieldsetProps {
+ environment: string;
+ projects: string[];
+ setProjects: (projects: string[]) => void;
+ setEnvironment: (environment: string) => void;
+ environmentOptions: string[];
+}
+
+interface IOption {
+ label: string;
+ id: string;
+}
+
+const allOption: IOption = { label: 'ALL', id: '*' };
+
+export const PlaygroundConnectionFieldset: VFC<
+ IPlaygroundConnectionFieldsetProps
+> = ({
+ environment,
+ projects,
+ setProjects,
+ setEnvironment,
+ environmentOptions,
+}) => {
+ const theme = useTheme();
+
+ const { projects: availableProjects = [] } = useProjects();
+ const projectsOptions = [
+ allOption,
+ ...availableProjects.map(({ name: label, id }) => ({
+ label,
+ id,
+ })),
+ ];
+
+ const onProjectsChange: ComponentProps['onChange'] = (
+ event,
+ value,
+ reason
+ ) => {
+ const newProjects = value as IOption | IOption[];
+ if (reason === 'clear' || newProjects === null) {
+ return setProjects([allOption.id]);
+ }
+ if (Array.isArray(newProjects)) {
+ if (newProjects.length === 0) {
+ return setProjects([allOption.id]);
+ }
+ if (
+ newProjects.find(({ id }) => id === allOption.id) !== undefined
+ ) {
+ return setProjects([allOption.id]);
+ }
+ return setProjects(newProjects.map(({ id }) => id));
+ }
+ if (newProjects.id === allOption.id) {
+ return setProjects([allOption.id]);
+ }
+
+ return setProjects([newProjects.id]);
+ };
+
+ const isAllProjects =
+ projects.length === 0 || (projects.length === 1 && projects[0] === '*');
+
+ return (
+
+
+ 1
+
+ Access configuration
+
+
+
+ (
+
+ )}
+ value={environment}
+ onChange={(event, value) => setEnvironment(value || '')}
+ size="small"
+ />
+ (
+
+ )}
+ size="small"
+ value={
+ isAllProjects
+ ? allOption
+ : projectsOptions.filter(({ id }) =>
+ projects.includes(id)
+ )
+ }
+ onChange={onProjectsChange}
+ />
+
+
+ );
+};
diff --git a/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundForm.tsx b/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundForm.tsx
new file mode 100644
index 0000000000..ba410d06d5
--- /dev/null
+++ b/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundForm.tsx
@@ -0,0 +1,86 @@
+import { Box, Button, Divider, useTheme } from '@mui/material';
+import { GuidanceIndicator } from 'component/common/GuidanceIndicator/GuidanceIndicator';
+import { IEnvironment } from 'interfaces/environments';
+import { FormEvent, VFC } from 'react';
+import { getEnvironmentOptions } from '../playground.utils';
+import { PlaygroundCodeFieldset } from './PlaygroundCodeFieldset/PlaygroundCodeFieldset';
+import { PlaygroundConnectionFieldset } from './PlaygroundConnectionFieldset/PlaygroundConnectionFieldset';
+
+interface IPlaygroundFormProps {
+ environments: IEnvironment[];
+ onSubmit: (event: FormEvent) => void;
+ environment: string;
+ projects: string[];
+ setProjects: React.Dispatch>;
+ setEnvironment: React.Dispatch>;
+ context: string | undefined;
+ setContext: React.Dispatch>;
+}
+
+export const PlaygroundForm: VFC = ({
+ environments,
+ environment,
+ onSubmit,
+ projects,
+ setProjects,
+ setEnvironment,
+ context,
+ setContext,
+}) => {
+ const theme = useTheme();
+
+ return (
+
+
+
+
+
+
+
+ 3
+
+
+ Try configuration
+
+
+
+ );
+};
diff --git a/frontend/src/component/playground/Playground/PlaygroundGuidance/PlaygroundGuidance.tsx b/frontend/src/component/playground/Playground/PlaygroundGuidance/PlaygroundGuidance.tsx
new file mode 100644
index 0000000000..ed1e36a7f7
--- /dev/null
+++ b/frontend/src/component/playground/Playground/PlaygroundGuidance/PlaygroundGuidance.tsx
@@ -0,0 +1,40 @@
+import { Typography, Box, Divider } from '@mui/material';
+import { PlaygroundGuidanceSection } from './PlaygroundGuidanceSection/PlaygroundGuidanceSection';
+
+export const PlaygroundGuidance = () => {
+ return (
+
+
+ Unleash playground is for helping you to undestand how unleash
+ works, how feature toggles are evaluated and for you to easily
+ debug your feature toggles.
+
+
+
+
+
+ What you need to do is:
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/playground/Playground/PlaygroundGuidance/PlaygroundGuidanceSection/PlaygroundGuidanceSection.tsx b/frontend/src/component/playground/Playground/PlaygroundGuidance/PlaygroundGuidanceSection/PlaygroundGuidanceSection.tsx
new file mode 100644
index 0000000000..b2e13cca05
--- /dev/null
+++ b/frontend/src/component/playground/Playground/PlaygroundGuidance/PlaygroundGuidanceSection/PlaygroundGuidanceSection.tsx
@@ -0,0 +1,44 @@
+import { Box, Typography } from '@mui/material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { GuidanceIndicator } from 'component/common/GuidanceIndicator/GuidanceIndicator';
+import { VFC } from 'react';
+
+interface IPlaygroundGuidanceSectionProps {
+ headerText: string;
+ bodyText?: string;
+ sectionNumber: string;
+}
+
+export const PlaygroundGuidanceSection: VFC<
+ IPlaygroundGuidanceSectionProps
+> = ({ headerText, bodyText, sectionNumber }) => {
+ return (
+
+
+
+ {sectionNumber}
+
+
+
+ {headerText}
+
+
+ {bodyText}
+
+ }
+ />
+
+
+
+ );
+};
diff --git a/frontend/src/component/playground/Playground/PlaygroundGuidancePopper/PlaygroundGuidancePopper.tsx b/frontend/src/component/playground/Playground/PlaygroundGuidancePopper/PlaygroundGuidancePopper.tsx
new file mode 100644
index 0000000000..9971cc7f61
--- /dev/null
+++ b/frontend/src/component/playground/Playground/PlaygroundGuidancePopper/PlaygroundGuidancePopper.tsx
@@ -0,0 +1,49 @@
+import { useState } from 'react';
+
+import { Close, Help } from '@mui/icons-material';
+import { Box, IconButton, Popper, Paper } from '@mui/material';
+import { PlaygroundGuidance } from '../PlaygroundGuidance/PlaygroundGuidance';
+
+export const PlaygroundGuidancePopper = () => {
+ const [anchor, setAnchorEl] = useState(null);
+
+ const onOpen = (event: React.FormEvent) =>
+ setAnchorEl(event.currentTarget);
+
+ const onClose = () => setAnchorEl(null);
+
+ const open = Boolean(anchor);
+
+ const id = 'playground-guidance-popper';
+
+ return (
+
+
+