diff --git a/.github/workflows/ai-flag-cleanup-pr.yml b/.github/workflows/ai-flag-cleanup-pr.yml
index 31229d5c7720..c36192f38f04 100644
--- a/.github/workflows/ai-flag-cleanup-pr.yml
+++ b/.github/workflows/ai-flag-cleanup-pr.yml
@@ -57,7 +57,7 @@ jobs:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
ref: ${{ github.ref }}
token: ${{ steps.app_token.outputs.token }}
@@ -140,7 +140,7 @@ jobs:
return branchName;
- name: Check out new branch
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
ref: ${{ steps.create_branch.outputs.result }}
token: ${{ steps.app_token.outputs.token }}
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index db27215d04d6..d65676b8d226 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -38,9 +38,9 @@ jobs:
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Use Node.js 20
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
node-version: 22.x
cache: 'yarn'
@@ -55,4 +55,4 @@ jobs:
env:
CI: true
TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
- DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
+ DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
diff --git a/.github/workflows/build_coverage.yaml b/.github/workflows/build_coverage.yaml
index 2883066769c5..9f2145c4257d 100644
--- a/.github/workflows/build_coverage.yaml
+++ b/.github/workflows/build_coverage.yaml
@@ -31,9 +31,9 @@ jobs:
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Use Node.js 22
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
node-version: 22.x
cache: 'yarn'
diff --git a/.github/workflows/build_doc_prs.yaml b/.github/workflows/build_doc_prs.yaml
index 3f848a6243db..547926ce5d54 100644
--- a/.github/workflows/build_doc_prs.yaml
+++ b/.github/workflows/build_doc_prs.yaml
@@ -15,7 +15,7 @@ jobs:
name: build # temporary solution to trick branch protection rules
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Build docs
env:
UNLEASH_FEEDBACK_TARGET_URL: ${{ secrets.DOCS_FEEDBACK_TARGET_URL }}
diff --git a/.github/workflows/build_frontend_prs.yml b/.github/workflows/build_frontend_prs.yml
index 9b0b046a8caa..27d887f86643 100644
--- a/.github/workflows/build_frontend_prs.yml
+++ b/.github/workflows/build_frontend_prs.yml
@@ -16,9 +16,9 @@ jobs:
working-directory: frontend
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Use Node.js 20.x
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
node-version: 22.x
- name: Enable corepack
diff --git a/.github/workflows/build_prs_jest_report.yaml b/.github/workflows/build_prs_jest_report.yaml
index 128eda3c238f..e819590e69cf 100644
--- a/.github/workflows/build_prs_jest_report.yaml
+++ b/.github/workflows/build_prs_jest_report.yaml
@@ -34,9 +34,9 @@ jobs:
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Use Node.js 22.x
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
node-version: 22.x
cache: 'yarn'
diff --git a/.github/workflows/check_links.yaml b/.github/workflows/check_links.yaml
index 6715e5feb805..bd6d6b8af387 100644
--- a/.github/workflows/check_links.yaml
+++ b/.github/workflows/check_links.yaml
@@ -15,7 +15,7 @@ jobs:
issue-lookup-label: automated-link-issue
issue-content: ./lychee-out.md
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Restore lychee cache
uses: actions/cache@v4
with:
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 7ac710e5f443..21a437bd50fa 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -38,7 +38,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml
index 1f11a2ebba1f..1d46767dcf90 100644
--- a/.github/workflows/dependency-review.yml
+++ b/.github/workflows/dependency-review.yml
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Dependency review
uses: actions/dependency-review-action@v4
with:
diff --git a/.github/workflows/docker_publish.yaml b/.github/workflows/docker_publish.yaml
index 584d98887db9..9800a3d5e670 100644
--- a/.github/workflows/docker_publish.yaml
+++ b/.github/workflows/docker_publish.yaml
@@ -28,16 +28,16 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- version: [22.17-alpine3.22]
+ version: [22.21-alpine3.23]
steps:
- name: Checkout tag v${{ inputs.version }}
if: ${{ inputs.version != '' }}
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
ref: v${{ inputs.version }} # tag that should be created by the caller workflow
- name: Checkout
if: ${{ inputs.version == '' }}
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
diff --git a/.github/workflows/e2e.frontend.yaml b/.github/workflows/e2e.frontend.yaml
index 79782680ef65..42eee8e4f0e0 100644
--- a/.github/workflows/e2e.frontend.yaml
+++ b/.github/workflows/e2e.frontend.yaml
@@ -26,7 +26,7 @@ jobs:
run: |
echo "$GITHUB_CONTEXT"
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Build static frontend
run: |
cd frontend
diff --git a/.github/workflows/gitar-duet-action.yml b/.github/workflows/gitar-duet-action.yml
index 884abffd5104..e0eba94ad479 100644
--- a/.github/workflows/gitar-duet-action.yml
+++ b/.github/workflows/gitar-duet-action.yml
@@ -16,13 +16,13 @@ jobs:
steps:
- run: |
echo '${{ toJSON(github.event) }}'
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
ref: ${{ github.head_ref }}
fetch-depth: 0
- name: Use Node.js 20
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
node-version: 22.x
cache: 'yarn'
diff --git a/.github/workflows/gradual-strict-null-checks.yml b/.github/workflows/gradual-strict-null-checks.yml
index a5104795de8e..f956f2b32818 100644
--- a/.github/workflows/gradual-strict-null-checks.yml
+++ b/.github/workflows/gradual-strict-null-checks.yml
@@ -19,16 +19,16 @@ jobs:
steps:
- name: Checkout current branch
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
path: current
- name: Checkout main branch
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
ref: ${{ env.MAIN_BRANCH }}
path: main
- name: Use Node.js ${{ matrix.node-version }}
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
node-version: 22.x
cache: 'yarn'
diff --git a/.github/workflows/hypermod.yml b/.github/workflows/hypermod.yml
index 88f52437f7c9..f175cc2ac908 100644
--- a/.github/workflows/hypermod.yml
+++ b/.github/workflows/hypermod.yml
@@ -12,11 +12,11 @@ jobs:
permissions: write-all
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Run Hypermod CLI
uses: hypermod-io/action@v1
with:
deploymentId: ${{ inputs.deploymentId }}
deploymentKey: ${{ inputs.deploymentKey }}
env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/notify_enterprise.yaml b/.github/workflows/notify_enterprise.yaml
index bbdcf26263ae..357fc42a43da 100644
--- a/.github/workflows/notify_enterprise.yaml
+++ b/.github/workflows/notify_enterprise.yaml
@@ -13,9 +13,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Use Node.js 20
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
node-version: 22.x
cache: 'yarn'
diff --git a/.github/workflows/openapi-diff.yaml b/.github/workflows/openapi-diff.yaml
index 426b7938c379..32b2965e5df5 100644
--- a/.github/workflows/openapi-diff.yaml
+++ b/.github/workflows/openapi-diff.yaml
@@ -11,28 +11,66 @@ on:
- .github/workflows/openapi-diff.yaml
workflow_dispatch:
inputs:
- stable_version:
- description: 'Stable Unleash version to compare against (e.g. unleash-server:6.9.3 or unleash-enterprise:6.10.0)'
- required: false
- default: 'unleash-server:latest'
+ baseline_version:
+ description: 'Stable Unleash version or commit SHA to compare against (e.g. v6.9.3, or a commit SHA).'
+ required: true
jobs:
generate-openapi-stable:
name: Generate OpenAPI (stable)
runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres
+ env:
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_INITDB_ARGS: "--no-sync"
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ - name: Determine baseline commit
+ run: |
+ if [ "${{ github.event_name }}" = "pull_request" ]; then
+ git fetch origin "${{ github.event.pull_request.base.ref }}"
+ BASE_SHA=$(git merge-base "origin/${{ github.event.pull_request.base.ref }}" "${{ github.sha }}")
+ else
+ # workflow_dispatch: baseline_version is required
+ git fetch --tags origin
+ BASE_SHA=$(git rev-parse "${{ github.event.inputs.baseline_version }}")
+ fi
+ echo "BASE_SHA=$BASE_SHA" >> $GITHUB_ENV
+ - name: Checkout baseline commit
+ run: git checkout "${BASE_SHA}"
+ - name: Install node
+ uses: actions/setup-node@v6
+ with:
+ node-version: 22.x
+ - name: Install dependencies
+ run: |
+ yarn install --immutable
- name: Start Unleash test instance
run: |
- docker compose -f .github/docker-compose.test.yml up -d --wait -t 90
+ # fake frontend build
+ mkdir frontend/build
+ touch frontend/build/index.html
+ touch frontend/build/favicon.ico
+ # end fake frontend build
+
+ # start unleash in background
+ NODE_ENV=openapi yarn dev:backend &
env:
- FRONTEND_TEST_LICENSE: ${{ secrets.FRONTEND_TEST_LICENSE }}
- UNLEASH_VERSION: ${{ github.event.inputs.stable_version || 'unleash-server:main-edge' }}
+ DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
+ DATABASE_SSL: 'false'
CHECK_VERSION: 'false'
- name: Wait for Unleash to be ready
run: |
for i in {1..30}; do
- if curl -sf http://localhost:3000/health; then
+ if curl -sf http://localhost:4242/health; then
echo "Unleash is up!";
exit 0
fi
@@ -41,8 +79,8 @@ jobs:
done
echo "Unleash did not become ready in time."
exit 1
- - name: Download OpenAPI spec from (${{ github.event.inputs.stable_version || 'tip of main' }})
- run: curl -sSL -o openapi-stable.json "localhost:3000/docs/openapi.json"
+ - name: Download OpenAPI spec from baseline
+ run: curl -sSL -o openapi-stable.json "localhost:4242/docs/openapi.json"
- name: Upload openapi-stable.json
uses: actions/upload-artifact@v4
with:
@@ -67,9 +105,9 @@ jobs:
options: >-
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Install node
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
node-version: 22.x
- name: Install dependencies
diff --git a/.github/workflows/publish-new-version.yaml b/.github/workflows/publish-new-version.yaml
index e0417baa8ee7..84e81b3beafb 100644
--- a/.github/workflows/publish-new-version.yaml
+++ b/.github/workflows/publish-new-version.yaml
@@ -49,7 +49,7 @@ jobs:
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GH_PUSH_TOKEN }}
@@ -60,7 +60,7 @@ jobs:
git config user.name "Github Actions Bot"
git config user.email "<>"
- name: Use Node.js ${{ matrix.node-version }}
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
cache: 'yarn'
@@ -114,7 +114,7 @@ jobs:
steps:
- name: checkout main branch
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
@@ -124,7 +124,7 @@ jobs:
git config user.name "GitHub Actions Bot"
git config user.email "<>"
- name: Use Node js 20
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
node-version: '22.x'
cache: 'yarn'
diff --git a/.github/workflows/reset_heroku.yml b/.github/workflows/reset_heroku.yml
index bdad988ae444..6e1a4d37b1c8 100644
--- a/.github/workflows/reset_heroku.yml
+++ b/.github/workflows/reset_heroku.yml
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Install Heroku CLI
run: |
diff --git a/.github/workflows/update_contributors.yaml b/.github/workflows/update_contributors.yaml
index 248ddd03c563..38661ac6ca45 100644
--- a/.github/workflows/update_contributors.yaml
+++ b/.github/workflows/update_contributors.yaml
@@ -9,9 +9,9 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Use Node.js 20.x
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
node-version: 22.x
- uses: actions/checkout@master
diff --git a/.github/workflows/validate-migrations.yaml b/.github/workflows/validate-migrations.yaml
index deaa79f45e45..26141145cfcd 100644
--- a/.github/workflows/validate-migrations.yaml
+++ b/.github/workflows/validate-migrations.yaml
@@ -15,9 +15,9 @@ jobs:
test-migrations:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Use Node.js 20.x
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
node-version: 22.x
cache: 'yarn'
diff --git a/Dockerfile b/Dockerfile
index 81494c59fb8c..8e7ee9a568b8 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-ARG NODE_VERSION=22.17-alpine3.22
+ARG NODE_VERSION=22.21-alpine3.23
FROM node:$NODE_VERSION AS builder
diff --git a/README.md b/README.md
index c8181ff26a16..09e869443ef0 100644
--- a/README.md
+++ b/README.md
@@ -50,7 +50,7 @@ Then point your browser to `localhost:4242` and log in using:
- username: `admin`
- password: `unleash4all`
-If you'd rather run the source code in this repo directly via Node.js, see the [step-by-step instructions to get up and running in the contributing guide](./CONTRIBUTING.md#how-to-run-the-project).
+If you'd rather run the source code in this repo directly via Node.js, see the [step-by-step instructions to get up and running in the contributing guide](./contributing/CONTRIBUTING.md#how-to-run-the-project).
### Connect your SDK
@@ -130,7 +130,7 @@ We know that learning a new tool can be hard and time-consuming. We have a growi
Unleash is the largest [open-source feature flag solution](https://www.getunleash.io/) on GitHub. Building Unleash is a collaborative effort, and we owe a lot of gratitude to many smart and talented individuals. Building it together with the community ensures that we build a product that solves real problems for real people. We'd love to have your help too: Please feel free to open issues or provide pull requests.
-Check out [the CONTRIBUTING.md file](./CONTRIBUTING.md) for contribution guidelines and the [Unleash developer guide](./website/docs/contributing/developer-guide.md) for tips on environment setup, running the tests, and running Unleash from source.
+Check out [the CONTRIBUTING.md file](./contributing/CONTRIBUTING.md) for contribution guidelines and the [Unleash developer guide](./website/docs/contributing/developer-guide.md) for tips on environment setup, running the tests, and running Unleash from source.
### Contributors
diff --git a/contributing/ADRs/ADRs.md b/contributing/ADRs/ADRs.md
new file mode 100644
index 000000000000..2471b2eea34f
--- /dev/null
+++ b/contributing/ADRs/ADRs.md
@@ -0,0 +1,49 @@
+---
+title: ADR Overview
+---
+
+## Introduction
+
+Architectural decision records are a record of design decisions we have made in the past because we belived they would help our code quality over time. Any ADR can be challenged, but two conditions must be met to change an ADR:
+1. The proposed solution must provide a tangible benefit in terms of code quality.
+2. The benefits of the proposed solution must outweigh the effort of retroactively changing the entire codebase.
+One such example is the decision to re-write Unleash to TypeScript.
+
+## Overarching ADRs
+
+These ADRs describe decisions that concern the entire codebase. They apply to back-end code, front-end code, and code that doesn't neatly fit into either of those categories.
+
+* [Domain language](/contributing/ADRs/overarching/domain-language)
+* [Separation of request and response schemas](/contributing/ADRs/overarching/separation-request-response-schemas)
+* [Error Logging stack traces](/contributing/ADRs/overarching/logging)
+* [Logging levels](/contributing/ADRs/overarching/logging-levels)
+
+## Back-end ADRs
+
+We are in the process of defining ADRs for the back end. At the time of writing we have created the following ADRS:
+
+* [Naming](/contributing/ADRs/back-end/naming)
+* [Preferred export](/contributing/ADRs/back-end/preferred-export)
+* [Breaking DB changes](/contributing/ADRs/back-end/breaking-db-changes)
+* [POST/PUT API payload](/contributing/ADRs/back-end/POST-PUT-api-payload)
+* [Specificity in database column references](/contributing/ADRs/back-end/specificity-db-columns)
+* [Write model vs Read models](/contributing/ADRs/back-end/write-model-vs-read-models)
+* [Frontend API Design](/contributing/ADRs/back-end/frontend-api-design)
+* [Correct type dependencies](/contributing/ADRs/back-end/correct-type-dependencies)
+
+## Front-end ADRs
+
+We have created a set of ADRs to help guide the development of the front end:
+
+* [Component naming](/contributing/ADRs/front-end/component-naming)
+* [Interface naming](/contributing/ADRs/front-end/interface-naming)
+* [Preferred component props usage](/contributing/ADRs/front-end/preferred-component-props-usage)
+* [Preferred export](/contributing/ADRs/front-end/preferred-export)
+* [Preferred function type](/contributing/ADRs/front-end/preferred-function-type)
+* [Preferred styling method](/contributing/ADRs/front-end/preferred-styling-method)
+* [Preferred data mutation method](/contributing/ADRs/front-end/preferred-data-mutation-method)
+* [Preferred data fetching method](/contributing/ADRs/front-end/preferred-data-fetching-method)
+* [Preferred folder structure](/contributing/ADRs/front-end/preferred-folder-structure)
+* [Preferred form architecture](/contributing/ADRs/front-end/preferred-form-architecture)
+* [OpenAPI SDK generator](/contributing/ADRs/front-end/sdk-generator)
+* [Use of conditionals in JSX (refactor of <ConditionallyRender />)](/contributing/ADRs/front-end/jsx-conditionals)
diff --git a/contributing/ADRs/back-end/POST-PUT-api-payload.md b/contributing/ADRs/back-end/POST-PUT-api-payload.md
new file mode 100644
index 000000000000..f0edf86c8d8e
--- /dev/null
+++ b/contributing/ADRs/back-end/POST-PUT-api-payload.md
@@ -0,0 +1,53 @@
+---
+title: "ADR: POST/PUT API payload"
+---
+
+## Background
+
+Whenever we receive a payload in our backend for POST or PUT requests we need to take into account backwards compatibility. When we add a new field to an existing API payload, clients using the previous version of the payload will not know about that new field. This means that we need to make sure that the new field is optional. If we make the field required, clients using the previous version of the payload will override the value of the new field with an empty value or null.
+
+### Example: adding new setting field to project settings
+
+Project settings on Unleash 5.3:
+```shell
+curl --location --request PUT 'http://localhost:4242/api/admin/projects/default' \
+--header 'Authorization: INSERT_API_KEY' \
+--header 'Content-Type: application/json' \
+--data-raw '{
+ "id": "default",
+ "name": "Default",
+ "description": "Default project",
+ "defaultStickiness": "default",
+ "mode": "open"
+}'
+```
+
+New version of project settings (Unleash 5.6):
+
+```shell
+curl --location --request PUT 'http://localhost:4242/api/admin/projects/default' \
+--header 'Authorization: INSERT_API_KEY' \
+--header 'Content-Type: application/json' \
+--data-raw '{
+ "id": "default",
+ "name": "Default",
+ "description": "Default project",
+ "defaultStickiness": "default",
+ "featureLimit": 2
+}'
+```
+
+Pay attention to the new field feature limit. If a customer updates Unleash to 5.6 but their integration still does not send that field, it may result in the unwanted behavior of setting that field to empty in the database, in case the server assumes that not sending the field means setting it to empty / `null`.
+
+This bug can easily be an oversight but can be prevented by following some rules when designing the API payload.
+
+## Decision
+
+When receiving a body from a request we need to take into account 3 possible cases:
+1. The field has a value
+2. The field is `undefined` or not part of the payload
+3. The field is `null`
+
+- If the field has a value, we need to update or set that value in the DB.
+- If the field is `undefined` or not part of the payload, we need to leave the value in the DB as it is.
+- If the field is `null`, we need to remove the value from the DB (set it as `null` on the DB).
diff --git a/contributing/ADRs/back-end/breaking-db-changes.md b/contributing/ADRs/back-end/breaking-db-changes.md
new file mode 100644
index 000000000000..d556dcda5d4f
--- /dev/null
+++ b/contributing/ADRs/back-end/breaking-db-changes.md
@@ -0,0 +1,41 @@
+---
+title: "ADR: Breaking DB changes"
+---
+
+## Background
+
+During the evolution of a feature different clients may use different version of code e.g. behind a feature flag.
+If the code relies on breaking DB changes (column delete, table rename, deleting DB entries etc.) it may lead to errors.
+
+The very same problem occurs when you apply a breaking migration just before the new version of the application starts e.g. during a zero-downtime deployment (whatever strategy you use).
+The code is still running against the old schema as the migration takes a few seconds to apply.
+
+## Decision
+To address these challenges, follow these guidelines:
+
+### Avoid Breaking DB Changes
+- **Prioritize avoiding breaking changes** in the DB schema whenever possible.
+
+### Use the "Expand/Contract" Pattern
+If breaking changes are inevitable, use the "expand/contract" pattern:
+
+#### Expand Phase
+- **Maintain both old and new DB schemas** in parallel.
+- Ensure **code compatibility with both schemas**.
+- Keep dual compatibility for **at least 2 minor releases**, allowing client upgrades.
+- This approach also supports **downgrading within this version range** without reverting migrations.
+
+#### Contract Phase
+- **Remove the old DB schema** once no clients use the old version.
+
+### Code Reviewer Responsibilities
+- **Action for a Code Reviewer:** When you spot a migration with `ALTER TABLE DROP COLUMN` or `ALTER TABLE RENAME TO`, please raise a flag if the "expand phase" was missed.
+
+
+### Separate Migrations as Distinct PRs
+- Carry out all migrations in **separate pull requests (PRs)** and closely monitor them during deployment. Monitoring should be performed using Grafana, observing any failing requests or errors in the logs.
+
+### Primary Key Requirement for New Tables
+- All new tables must have a primary key to ensure data integrity, improve query efficiency, and establish foreign key relationships. Primary keys also address migration issues in replicated databases without PostgreSQL replica identities. Exceptions require strong justification.
+
+Following these guidelines reduces the risk of errors and compatibility issues during DB schema changes, enhancing stability and reliability in software development.
diff --git a/contributing/ADRs/back-end/correct-type-dependencies.md b/contributing/ADRs/back-end/correct-type-dependencies.md
new file mode 100644
index 000000000000..1e03f088ff63
--- /dev/null
+++ b/contributing/ADRs/back-end/correct-type-dependencies.md
@@ -0,0 +1,38 @@
+---
+title: "ADR: Domain Code Must Not Depend on Infrastructure Types (e.g., OpenAPI)"
+---
+
+## Background
+
+We’ve identified an architectural issue in our backend code: domain-layer code (especially store interfaces) sometimes directly references infrastructure-layer types, such as OpenAPI schemas.
+This breaks a foundational principle of layered architecture: "Domain code should not depend on infrastructure, but infrastructure can depend on domain."
+
+## Decision
+
+All domain code—including store interfaces and business logic—must operate on domain types, not infrastructure types such as OpenAPI schemas.
+Any mapping between API types and domain types must occur at the boundary (e.g., in controllers), not in the core logic.
+
+## Consequences
+
+* Clear architectural boundaries: Domain is isolated from infrastructure concerns like transport and serialization.
+* Improved modularity: Domain logic can move between OSS and Enterprise without dragging along OpenAPI types or other infrastructure.
+* Greater flexibility: We can evolve domain types rapidly and experimentally without worrying about breaking public API contracts.
+* Increased mapping boilerplate: Conversion logic between domain and infrastructure types must be written explicitly at the boundaries.
+
+## Example
+
+Before
+```typescript
+store.createUser(user: CreateUserSchema): Promise;
+```
+
+After
+```typescript
+// Domain-layer store uses a domain type
+store.createUser(user: DomainUser): Promise;
+
+// API-layer controller maps OpenAPI to domain
+const domainUser = mapFromApiType(apiUser);
+await store.createUser(domainUser);
+```
+
diff --git a/contributing/ADRs/back-end/frontend-api-design.md b/contributing/ADRs/back-end/frontend-api-design.md
new file mode 100644
index 000000000000..53a4598b0e4b
--- /dev/null
+++ b/contributing/ADRs/back-end/frontend-api-design.md
@@ -0,0 +1,34 @@
+---
+title: "ADR: Frontend API design"
+---
+
+## Background
+
+Previous version of the Frontend API (known as proxy API) had memory and I/O issues when a large number of tokens was used.
+
+To better understand how it worked you need to know that Frontend API is Unleash Node SDK exposed over the API.
+Node SDK allows to plug in a custom repository so that it can fetch the toggles from a database instead of making remote HTTP calls.
+
+Every time a new Frontend API token was used we created a new SDK client with its own proxy repository. Proxy repository was fetching
+the flags for a project and environment extracted from a token.
+Every time there was a revision ID change (e.g. after some flag changed the status) the repository was updating itself.
+Since every token had a dedicated repository with 10 tokens we were making 10 DB calls on each change.
+What's more each repository kept its own copy of the flags. What it means in practice is that two different tokens with the same
+project and environments would store the same flags twice.
+
+
+
+## Decision
+
+To address these challenges, we came up with a new design:
+
+
+
+We decided to swap ProxyRepository with a drop-in replacement FrontendApiRepository. FrontendApiRepository doesn't store any flags on its own but always filters
+the flags that we keep in a GlobalFrontendApiCache. The cache stores all the flags and updates on every revision ID change.
+
+Consequences:
+* memory improvements: for a large number of tokens the memory footprint is reduced since we only store one copy of each flag in the cache and every repository
+filters the data that it needs for a given project and environment obtained from the token
+* I/O improvements: for a large number of tokens the number of DB calls is reduced to one per revision ID update since only the cache needs to be updated
+
diff --git a/contributing/ADRs/back-end/naming.md b/contributing/ADRs/back-end/naming.md
new file mode 100644
index 000000000000..e3ca8d43e900
--- /dev/null
+++ b/contributing/ADRs/back-end/naming.md
@@ -0,0 +1,21 @@
+---
+title: "ADR: Naming"
+---
+
+## Background
+
+In the codebase, we have found a need to have a common way of naming things in order to ensure consistency. It's important that files are named after the contents of the file to ensure that it's easy to search for files. You should be able to find the file you need in the command line without the help of advanced IDEs. This can easily be solved by proper naming. It's also crucial that the naming is consistent across the project, if we are using different naming conventions in different places, it will be hard to navigate the codebase.
+
+## Decision
+
+We have decided to use a naming convention where the files are named after the main class that it contains. Example:
+
+```js
+feature-toggle-service.ts
+
+class FeatureToggleService {
+ ...
+}
+```
+
+The reason for this decision is to remove mental clutter and free up capacity to easily navigate the codebase. Knowing that a file is named after the class that it contains allows you to quickly scan a file without watching for a context where the class is used in order to understand what it is.
diff --git a/contributing/ADRs/back-end/preferred-export.md b/contributing/ADRs/back-end/preferred-export.md
new file mode 100644
index 000000000000..5408c21deef7
--- /dev/null
+++ b/contributing/ADRs/back-end/preferred-export.md
@@ -0,0 +1,11 @@
+---
+title: "ADR: Preferred export"
+---
+
+## Background
+
+In the codebase, we have discovered that default exports create multiple problems. One is that you can rename the component when importing it, which can cause confusion. Another is that it is harder to find the component when you are looking for it, as you have to look for the file name instead of the component name (solved by ADR for naming, but still relevant).
+
+## Decision
+
+We have decided to use named exports. This will allow us to eliminate the possiblity of exporting a component and renaming it in another file. It also allows us easy access to advanced refactors across the project, because renaming in one place will propagate to all the other places where that import is referenced. This resolves the issues described in the background without any significant downsides.
diff --git a/contributing/ADRs/back-end/specificity-db-columns.md b/contributing/ADRs/back-end/specificity-db-columns.md
new file mode 100644
index 000000000000..5acc243db7b7
--- /dev/null
+++ b/contributing/ADRs/back-end/specificity-db-columns.md
@@ -0,0 +1,75 @@
+---
+title: "ADR: Specificity in database column references"
+---
+
+## Background
+
+We recently experienced an issue where a database migration that introduced a new column resulted in ambiguity errors in our application, which highlighted the need for clearer SQL query standards. Currently, our queries often reference columns without specifying their parent tables, leading to potential ambiguity in complex queries that join multiple tables. This issue becomes more pronounced during database schema changes and migrations, where ambiguity in column references can lead to hard-to-anticipate runtime errors.
+
+## Decision
+
+To mitigate these risks, we will adopt a standard of explicitly specifying the full table name or alias for each column in our SQL queries. This standard is not just about improving readability, but is crucial for avoiding ambiguity in queries, especially when performing joins between tables. The decision to use the full table name or an alias will be left to the discretion of the developer, with a focus on maximizing clarity and maintainability.
+
+### Example: Preferred vs. discouraged syntax
+
+To clarify the standards set out in the ADR, here's a quick comparison of the preferred syntax against the discouraged syntax, using hypothetical tables and columns:
+
+**1. Preferred syntax: Explicit table naming**
+
+```ts
+const rows = await this.db
+ .select(
+ 'u.id',
+ 'u.name',
+ 'u.email',
+ 'o.description',
+ )
+ .from('users as u')
+ .join('orders as o', 'o.user_id', 'u.id')
+ .where('o.status', 'active')
+ .orderBy('o.created_at', 'desc')
+```
+
+**Why preferred**: Clearly indicates that `id`, `name`, and `email` are columns from the `users` table (aliased as `u`), and that `description`, `status` and `created_at` are from the `orders` table (aliased as `o`). This prevents ambiguity, especially useful in JOINs.
+
+**Note**: Aliases (`u` for `users`, `o` for `orders`) are used here for brevity and readability, but they are optional. The key aspect is specifying the table for each column.
+
+**2. Discouraged syntax: Implicit table naming**
+
+```ts
+const rows = await this.db
+ .select(
+ 'id',
+ 'name',
+ 'email',
+ 'description',
+ )
+ .from('users')
+ .join('orders', 'orders.user_id', 'users.id')
+ .where('status', 'active')
+ .orderBy('created_at', 'desc')
+```
+
+**Why discouraged**: Without specifying the table for each column, it becomes unclear which table each column belongs to. This ambiguity, especially in tables with identically named columns, can lead to runtime errors that are difficult to anticipate, particularly in queries involving joins.
+
+### Advantages
+
+The primary benefits of this approach are:
+
+1. **Clarity and reduced ambiguity**: By explicitly specifying table names for each column, we eliminate ambiguity about which table a column belongs to. This is particularly beneficial in complex queries involving multiple tables and joins. This also reduces the risk of ambiguity errors during database migrations or schema changes.
+
+2. **Ease of maintenance and adaptability to changes**: During database migrations or schema changes, specifically referenced columns make it easier to identify and update relevant queries. This reduces the risk of overlooking changes that might affect query behavior.
+
+3. **Improved readability in complex queries**: In queries involving multiple tables and joins, explicitly specifying table names makes the query more readable and understandable, facilitating easier debugging and review, while ensuring consistency across the codebase.
+
+### Concerns
+
+The adoption of this practice comes with minimal concerns:
+
+1. **Slight increase in verbosity**: The queries will be slightly longer due to the addition of table names in columns. However, the benefits in clarity and maintainability far outweigh this minor increase in verbosity.
+
+2. **Initial adaptation curve**: There might be a brief period of adjustment as developers adapt to this new standard. However, given its straightforward nature, this learning curve is expected to be minimal.
+
+## Conclusion
+
+The adoption of explicit table name specification in our SQL queries is a strategic decision aimed at improving the clarity, maintainability, and reliability of our database interactions. This practice will ensure that our queries remain robust and clear, especially in the context of evolving database schemas and complex query scenarios. We will gradually implement this standard in our existing codebase by following the Girl Scout Rule. This change aligns with our commitment to writing clean, understandable, and maintainable code, thereby enhancing the overall quality of our software development processes.
diff --git a/contributing/ADRs/back-end/write-model-vs-read-models.md b/contributing/ADRs/back-end/write-model-vs-read-models.md
new file mode 100644
index 000000000000..6dd984b5d50f
--- /dev/null
+++ b/contributing/ADRs/back-end/write-model-vs-read-models.md
@@ -0,0 +1,80 @@
+---
+title: "ADR: Write Model vs Read Models"
+---
+
+## Background
+
+We keep solving 3 problems in each of the business modules:
+* changing the state of the system through actions
+* displaying UI
+* asking other modules for information
+
+In the past we had one pattern of `service + store` for handling all 3 of those. Store was responsible for changing state and returning UI data. Cross module collaboration
+happened by calling services in other modules. While it was convenient and reduced the number of building blocks to learn, it also led to coupling between
+services and overloaded stores - responsible for writing data, reading complex UI data and answering questions from other modules.
+
+## Decision
+
+To address these challenges categorize your problem as one of 3 models:
+
+
+
+We have 3 types of models serving 3 different purposes:
+* I need to take action that modifies a state of the system (Write Model)
+
+Solution: Go through the Command stack aka the *Write Model*. *Application logic* with the use case/workflow/steps resides in the *Application Service*. Application service can delegate
+ business logic handling to the *Domain Model* if needed (only for complex subdomains like Change Requests). Simple domain logic (very few business if statements)
+ can be handled together with the application logic. Application service calls the store that is responsible for the *generic CRUD operations* serving the write model.
+
+* I need to read data for UI display (External Read Model)
+
+Solution: Expose *External Read Model* aka *View Model* that returns all the data in one query so that frontend doesn't have to ask multiple write models for data.
+ View model will typically join data across a few DB tables since most UI screens required more than one table of data. If we keep the *complex queries* for UI
+ in the external read model our stores can be generic and focused on the main table operations.
+
+* I need information from another module to proceed with my module application logic (Internal Read Model)
+
+Solution: Cross module queries can be handled with the *Internal Read Model*. Internal means it's used internally between our modules and not exposed to the UI.
+ Internal read models are typically narrowly focused on answering one question and usually require *simple queries* compared to external read models. By introducing internal
+ read model other business modules are not coupled to our module's service/store/write model and can't call write model methods from another module by accident.
+
+## Example
+
+Before (one multi-purpose class)
+```typescript
+class SegmentStore {
+ // used to perform actions on segment
+ create(segment: Segment): Promise {}
+ get(id: number): Promise {}
+ update(id: number, segment: Segment): Promise {}
+ delete(id: number): Promise {}
+
+ // used by UI
+ getAll(): Promise {}
+
+ // used by another module checking existing names
+ getSegmentNames(): Promise {}
+}
+```
+
+After (3 role-based classes so clients depends only on method they use)
+```typescript
+// used to perform actions on segment
+class SegmentStore {
+ create(segment: Segment): Promise {}
+ get(id: number): Promise {}
+ update(id: number, segment: Segment): Promise {}
+ delete(id: number): Promise {}
+}
+
+// used by UI
+class SegmentViewModel {
+ getAll(): Promise {}
+}
+
+// used by another module checking existing names
+class SegmentReadModel {
+ getSegmentNames(): Promise {}
+}
+```
+
diff --git a/contributing/ADRs/front-end/component-naming.md b/contributing/ADRs/front-end/component-naming.md
new file mode 100644
index 000000000000..f683d8308bc4
--- /dev/null
+++ b/contributing/ADRs/front-end/component-naming.md
@@ -0,0 +1,29 @@
+---
+title: "ADR: Component naming and file naming"
+---
+
+## Background
+
+In the codebase, we have found a need to have a common way of naming components so that components can be (a) easily searched for, (b) easily identified as react components and (c) be descriptive in what they do in the codebase.
+
+## Decision
+
+We have decided to use a naming convention for components that uppercases the first letter of the component. This also extends to the filename of the component. The two should always be the same:
+
+```jsx
+// Do:
+// MyComponent.ts
+
+const MyComponent = () => {};
+
+// Don't:
+// someRandomName.ts
+
+const MyComponent = () => {};
+```
+
+The reason for this decision is to remove mental clutter and free up capacity to easily navigate the codebase. Knowing that a component name has the same name as the filename will remove any doubts about the file contents quickly and in the same way follow the React standard of uppercase component names.
+
+### Deviations
+
+In some instances, for simplicity we might want to create internal components or child components for a larger component. If these child components are small enough in size and it makes sense to keep them in the same file as the parent (AND they are used in no other external components) it's fine to keep in the same file as the parent component.
diff --git a/contributing/ADRs/front-end/deprecated/preferred-styles-import-placement.md b/contributing/ADRs/front-end/deprecated/preferred-styles-import-placement.md
new file mode 100644
index 000000000000..0ef019b72864
--- /dev/null
+++ b/contributing/ADRs/front-end/deprecated/preferred-styles-import-placement.md
@@ -0,0 +1,52 @@
+---
+title: "ADR: preferred styles import placement"
+---
+
+## Background
+
+SUPERSEDED BY [ADR: Preferred styling method](/contributing/ADRs/front-end/preferred-styling-method)
+
+In the codebase, we have found a need to standardise where to locate the styles import. When using CSS modules, the styles import placement matters for the priority of the styles if you are passing through styles to other components. IE:
+
+```
+// import order matters, because the useStyles in MyComponent now
+// is after the useStyles import it will not take precedence if it has
+// a styling conflict.
+import useStyles from './SecondComponent.styles.ts';
+import MyComponent from '../MyComponent/MyComponent.tsx';
+
+const SecondComponent = () => {
+ const styles = useStyles();
+
+ return
+}
+```
+
+## Decision
+
+We have decided to always place style imports as the last import in the file, so that any components that the file may use can safely be overriden with styles from the parent component.
+
+```tsx
+// Do:
+import MyComponent from '../MyComponent/MyComponent.tsx';
+
+import useStyles from './SecondComponent.styles.ts';
+
+const SecondComponent = () => {
+ const styles = useStyles();
+
+ return ;
+};
+
+// Don't:
+import useStyles from './SecondComponent.styles.ts';
+import MyComponent from '../MyComponent/MyComponent.tsx';
+
+const SecondComponent = () => {
+ const styles = useStyles();
+
+ return ;
+};
+```
+
+The reason for this decision is to remove the posibillity for hard to find bugs, that are not obvious to detect and that might be time consuming to find a solution to.
diff --git a/contributing/ADRs/front-end/handling-tables.md b/contributing/ADRs/front-end/handling-tables.md
new file mode 100644
index 000000000000..c81391b0a186
--- /dev/null
+++ b/contributing/ADRs/front-end/handling-tables.md
@@ -0,0 +1,43 @@
+---
+title: "ADR: Handling tables"
+---
+
+## Background
+
+We need to handle table state on different pages. Some pages do client side table handling while some other need to offload some work to the server.
+Two most critical pages that we migrate to server handling are the Feature Flags page and Project Overview page.
+
+[Table handling options](/img/handling-tables-adr.png)
+
+Table handling consists of 4 parts:
+* API call and **server side data handling**
+* persistent table state in URL and localStorage (handled by the usePerisistentTableState hook)
+* column definitions and **client side data handling** (handled either by react-table or custom code)
+* Material-UI rendering components
+
+Data handling consists of:
+* sorting (either server or client side)
+* pagination (either server or client side)
+* searching (either server or client side)
+* filtering (either server or client side)
+* column visibility (only client side)
+* row selection (only client side)
+
+### Options
+
+For pages with no server data handling we need `react-table` for client side data handling.
+For pages with server data handling we considered two options that we implemented in a spike:
+* not using `react-table` and writing minimal custom code for column visibility, data mapping and row selection.
+Not much else is required since server side is doing sorting/pagination/searching/filtering
+* using `react-table` with the extra cost of the library magic and writing connectors from backend data to `react-table` structures
+
+The tradeoff is between simplicity of the pages that support server side data handling and the consistency
+between the definitions of the client side and server side powered tables.
+
+
+## Decision
+
+We have decided to **favor consistency over one-off simplicity**.
+Using `react-table` comes at a cost but allows to change between client and server side data handling with lesser effort. It allows to revert decisions to client side and makes the migration
+to server side data handling easier.
+
diff --git a/contributing/ADRs/front-end/interface-naming.md b/contributing/ADRs/front-end/interface-naming.md
new file mode 100644
index 000000000000..c13ed8d706b9
--- /dev/null
+++ b/contributing/ADRs/front-end/interface-naming.md
@@ -0,0 +1,23 @@
+---
+title: "ADR: Interface naming"
+---
+
+## Background
+
+In the codebase, we have found a need to have a common way of naming interfaces in order to ensure consistency.
+
+## Decision
+
+We have decided to use a naming convention of appending the letter `I` in front of interfaces to signify that we are in fact using an interface. For props, we use `IComponentNameProps`.
+
+```jsx
+// Do:
+interface IMyInterface {}
+interface IMyComponentNameProps {}
+
+// Don't:
+interface MyInterface {}
+interface MyComponentName {}
+```
+
+The reason for this decision is to remove mental clutter and free up capacity to easily navigate the codebase. Knowing that an interface is prefixed with `I` allows you to quickly scan a file without watching for a context where the interface is used in order to understand what it is.
diff --git a/contributing/ADRs/front-end/jsx-conditionals.md b/contributing/ADRs/front-end/jsx-conditionals.md
new file mode 100644
index 000000000000..b17f0cc51cac
--- /dev/null
+++ b/contributing/ADRs/front-end/jsx-conditionals.md
@@ -0,0 +1,131 @@
+---
+title: "ADR: Use of conditionals in JSX (deprecation of ``)"
+---
+
+## Background
+
+Using the `&&` operator in React can lead to unexpected rendering behavior when dealing with certain falsy values. In our codebase, the `` component has been used to render React elements based on a boolean condition. However, it has certain drawbacks, which is why we would like to replace it with the ternary operator.
+
+### Pitfalls of `&&` operator
+
+While most truthy and falsy values behave as expected with the `&&` operator, certain falsy values can produce unintended outcomes:
+
+```tsx
+{NaN && ❔
} // will render `NaN`
+{0 && ❔
} // will render `0`
+{arr?.length && ❔
} // can render `0`
+```
+
+These issues can cause bugs in components that conditionally render UI elements based on numeric values or other potentially falsy conditions. For this reason, we use a wrapper.
+
+```tsx
+❔
}
+/>
+```
+
+### What's wrong with ``
+
+While this solves leaking render issues, it has some drawbacks.
+
+#### Poor TypeScript support
+```tsx
+import type { FC } from 'react';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+
+const SubComponent: FC<{ text: string }> = ({ text }) => <>{text}>;
+
+export const Test: FC<{ maybeString?: string }> = ({ maybeString }) => (
+ }
+ // ❌ TS Error: Type 'string | undefined' is not assignable to type 'string'
+ // you have to use `maybeString!` here
+ />
+);
+```
+
+#### Obfuscation of code smells and code cruft
+Nested ternaries are easier to spot than nested `` elements.
+
+```tsx
+
+ This is bad}
+ />
+ )}
+ elseShow={'Should be refactored'}
+ />
+
+```
+
+Nested operator does not look like other JSX components.
+
+```tsx
+
+ {a ? (
+ b ?
This is bad
: null
+ ) : 'Should be refactored'}
+
+```
+
+## Options considered
+
+To avoid these issues, safer alternatives to the `&&` operator can be used:
+
+### **Convert to boolean**
+We could try to explicitly convert the condition to a boolean, ensuring that only `true` or `false` determine the rendering.
+
+```tsx
+{Boolean(NaN) && ❔
} // Won't render anything
+{!!0 && ❔
} // Also safe
+```
+
+**Unfortunately** Biome (the linter we use) does not include rules to automatically enforce a safer usage of the `&&` operator, as ESLint did.
+
+### Ternary Operator
+The ternary operator is a more explicit and safer approach. This covers cases where we need to return `null` or `undefined`.
+
+``` tsx
+{NaN ? 👍
: null} // Won't render anything
+```
+
+It also plays nicely with TypeScript.
+
+```tsx
+export const Test: FC<{ maybeString?: string }> = ({ maybeString }) =>
+ maybeString ? : null;
+```
+
+This is what we will use from now onwards.
+
+## Consequences
+Positive: The codebase will become more type-safe and easier to understand.
+
+Negative: The `` component is imported in nearly 400 files. Significant refactoring effort is required.
+
+Performance: There was no measurable performance difference between code with and without this component. This was tested on production bundle, on the features search (table) and projects list pages.
+
+## Migration plan
+
+1. Mark `` as deprecated in the codebase with a clear JSDoc comment.
+
+2. Automated refactoring with AST (Abstract Syntax Tree):
+There already is a script developed that can convert files between `ConditionallyRender` and ternary syntax. It uses jscodeshift, a library. It will be put in `frontend/scripts/transform.js`.
+
+3. Each change will have to be reviewed. The order of refactoring should be:
+ 1. New features that are behind feature flags.
+ 2. Non-critical or not in demand pages, like new signals or feedback component.
+ 3. Less complex pages, for example in `/src/component/admin`.
+ 4. More complex and critical pages, like strategy editing.
+ 5. Utilities and components used in many places (`/src/component/common`).
+
+4. Once all instances of `` have been refactored, remove the component from the codebase.
diff --git a/contributing/ADRs/front-end/preferred-component-props-usage.md b/contributing/ADRs/front-end/preferred-component-props-usage.md
new file mode 100644
index 000000000000..049131178b0c
--- /dev/null
+++ b/contributing/ADRs/front-end/preferred-component-props-usage.md
@@ -0,0 +1,37 @@
+---
+title: "ADR: Preferred component props usage"
+---
+
+## Background
+
+In the codebase, we have found a need to standardise how to use props, in order to easily be able to figure out what a component is doing and what properties it is given without having to look up the interface.
+
+## Decision
+
+We have decided to use props destructuring inline in components in order to quickly display what properties a component is using.
+
+```tsx
+// Do:
+const MyComponent = ({ name, age, occupation }: IComponentProps) => {
+ return (
+
+
{age}
+
{name}
+
{occupation}
+ >
+ )
+};
+
+// Don't:
+function MyComponent(props) {
+ return (
+
+
{props.age}
+
{props.name}
+
{props.occupation}
+ >
+ )
+}
+```
+
+The reason for this decision is to remove mental clutter and free up capacity to easily navigate the codebase. In addition, when components grow, the ability to look at the signature and instantly know what dependencies this component uses gives you an advantage when scanning the codebase.
diff --git a/contributing/ADRs/front-end/preferred-data-fetching-method.md b/contributing/ADRs/front-end/preferred-data-fetching-method.md
new file mode 100644
index 000000000000..6d693fa34eff
--- /dev/null
+++ b/contributing/ADRs/front-end/preferred-data-fetching-method.md
@@ -0,0 +1,83 @@
+---
+title: "ADR: Preferred data fetching method"
+---
+
+## Background
+
+We have found a need to standardise how we fetch data from APIs, in order to reduce complexity and simplify the data fetching process.
+
+## Decision
+
+We have decided to remove redux from our application and fetch all of our data via a third party library called `useSWR` (SWR stands for stale-while-revalidate and is a common cache strategy).
+
+```tsx
+// Do:
+// useSegments.ts
+
+import useSWR from 'swr';
+import { useCallback } from 'react';
+import { formatApiPath } from 'utils/formatPath';
+import handleErrorResponses from '../httpErrorResponseHandler';
+import { ISegment } from 'interfaces/segment';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import { IFlags } from 'interfaces/uiConfig';
+
+export interface UseSegmentsOutput {
+ segments?: ISegment[];
+ refetchSegments: () => void;
+ loading: boolean;
+ error?: Error;
+}
+
+export const useSegments = (strategyId?: string): UseSegmentsOutput => {
+ const { uiConfig } = useUiConfig();
+
+ const { data, error, mutate } = useSWR(
+ [strategyId, uiConfig.flags],
+ fetchSegments
+ );
+
+ const refetchSegments = useCallback(() => {
+ mutate().catch(console.warn);
+ }, [mutate]);
+
+ return {
+ segments: data,
+ refetchSegments,
+ loading: !error && !data,
+ error,
+ };
+};
+
+export const fetchSegments = async (
+ strategyId?: string,
+ flags?: IFlags
+): Promise
=> {
+ if (!flags?.SE) {
+ return [];
+ }
+
+ return fetch(formatSegmentsPath(strategyId))
+ .then(handleErrorResponses('Segments'))
+ .then(res => res.json())
+ .then(res => res.segments);
+};
+
+const formatSegmentsPath = (strategyId?: string): string => {
+ return strategyId
+ ? formatApiPath(`api/admin/segments/strategies/${strategyId}`)
+ : formatApiPath('api/admin/segments');
+};
+
+// Don't:
+const MyComponent = () => {
+ useEffect(() => {
+ const getData = () => {
+ fetch(API_URL)
+ .then(res => res.json())
+ .then(setData);
+ };
+ getData();
+ }, []);
+};
+```
diff --git a/contributing/ADRs/front-end/preferred-data-mutation-method.md b/contributing/ADRs/front-end/preferred-data-mutation-method.md
new file mode 100644
index 000000000000..90186e795e30
--- /dev/null
+++ b/contributing/ADRs/front-end/preferred-data-mutation-method.md
@@ -0,0 +1,90 @@
+---
+title: "ADR: Preferred data mutation method"
+---
+
+## Background
+
+Because our product is open-core, we have complexities and needs for our SaaS platform that are not compatible with the needs of our open-source product. We have found a need to standardise how we fetch data from APIs, in order to reduce complexity and simplify the data fetching process.
+
+## Decision
+
+We have decided to standardise data-fetching and error handling by implementing a top level `useAPI` hook that will take care of formatting the
+request in the correct way adding the basePath if unleash is hosted on a subpath, wrap with error handlers and return the data in a consistent way.
+
+Example:
+
+```tsx
+import { ITagPayload } from 'interfaces/tags';
+import useAPI from '../useApi/useApi';
+
+export const useTagTypesApi = () => {
+ const { makeRequest, createRequest, errors, loading } = useAPI({
+ propagateErrors: true,
+ });
+
+ const createTag = async (payload: ITagPayload) => {
+ const path = `api/admin/tag-types`;
+ const req = createRequest(path, {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ });
+
+ try {
+ const res = await makeRequest(req.caller, req.id);
+
+ return res;
+ } catch (e) {
+ throw e;
+ }
+ };
+
+ const validateTagName = async (name: string) => {
+ const path = `api/admin/tag-types/validate`;
+ const req = createRequest(path, {
+ method: 'POST',
+ body: JSON.stringify({ name }),
+ });
+ try {
+ const res = await makeRequest(req.caller, req.id);
+ return res;
+ } catch (e) {
+ throw e;
+ }
+ };
+ const updateTagType = async (tagName: string, payload: ITagPayload) => {
+ const path = `api/admin/tag-types/${tagName}`;
+ const req = createRequest(path, {
+ method: 'PUT',
+ body: JSON.stringify(payload),
+ });
+
+ try {
+ const res = await makeRequest(req.caller, req.id);
+ return res;
+ } catch (e) {
+ throw e;
+ }
+ };
+
+ const deleteTagType = async (tagName: string) => {
+ const path = `api/admin/tag-types/${tagName}`;
+ const req = createRequest(path, { method: 'DELETE' });
+
+ try {
+ const res = await makeRequest(req.caller, req.id);
+ return res;
+ } catch (e) {
+ throw e;
+ }
+ };
+
+ return {
+ createTag,
+ validateTagName,
+ updateTagType,
+ deleteTagType,
+ errors,
+ loading,
+ };
+};
+```
diff --git a/contributing/ADRs/front-end/preferred-export.md b/contributing/ADRs/front-end/preferred-export.md
new file mode 100644
index 000000000000..adafde39df10
--- /dev/null
+++ b/contributing/ADRs/front-end/preferred-export.md
@@ -0,0 +1,39 @@
+---
+title: "ADR: Preferred export"
+---
+
+## Background
+
+We have seen a need to standardize how to export from files in the project, in order to achieve consistency and avoid situations where we can have a component default exported as one name and renamed as something else in a different file. For example:
+
+```
+// Problem example
+// File A
+
+const MyComponent = () => {
+
+}
+
+export default MyComponent;
+
+// File B
+import NewName from '../components/MyComponent/MyComponent.tsx';
+```
+
+The above can cause massive confusion and make it hard to navigate the codebase.
+
+## Decision
+
+We have decided to standardise exports on named exports. This will allow us to eliminate the possiblity of exporting a component and renaming it in another file.
+
+```jsx
+// Do:
+export const MyComponent = () => {};
+
+// Don't:
+const MyComponent = () => {};
+
+export default MyComponent;
+```
+
+The reason for this decision is to remove mental clutter and free up capacity to easily navigate the codebase. If you can always deduce that the component is named as it is defined, then finding that component becomes a lot easier. This will ensure that we remove unnecessary hurdles to understand and work within the codebase.
diff --git a/contributing/ADRs/front-end/preferred-folder-structure.md b/contributing/ADRs/front-end/preferred-folder-structure.md
new file mode 100644
index 000000000000..ffc776d40ce4
--- /dev/null
+++ b/contributing/ADRs/front-end/preferred-folder-structure.md
@@ -0,0 +1,32 @@
+---
+title: "ADR: Preferred folder structure"
+---
+
+## Background
+
+Folder structure is important in how easy it is to navigate and reason about the codebase. It's important to have a clear structure that is easy to understand and follow, while grouping related files together in such a way that is easy to find and remove.
+
+## Decision
+
+We have decided to create tree-like folder structure that mimics as closely as possible the relationship of the React components in the project. This has a number of benefits:
+
+* If you are looking for a component, you can easily find it by looking at the folder structure.
+* If you need to delete a component, you can be sure that all of the files connected to that component will be deleted if you delete the folder. This is supremely important, because it allows us to get rid of dead code easily and without having to worry about the consequences of deleting a file and worrying about whether it's used somewhere else.
+
+## Folder structure example:
+
+```
+ProfilePage
+ ProfilePage.tsx
+ ProfilePage.styles.ts
+ ProfileSettings
+ ProfileSettings.tsx
+ ProfileSettings.styles.ts
+ ProfilePicture
+ ProfilePicture.tsx
+ ProfilePicture.styles.ts
+```
+
+Now you can clearly see that if you need to delete the `ProfilePage` component, you can simply delete the `ProfilePage` folder and all of the files connected to that component will be deleted.
+
+If you experience that you need to create a component that is used in multiple places, the component should be moved to the closest possible ancestor. If this is not possible, the component should be moved to the `common` folder.
diff --git a/contributing/ADRs/front-end/preferred-form-architecture.md b/contributing/ADRs/front-end/preferred-form-architecture.md
new file mode 100644
index 000000000000..6352c6dd71f8
--- /dev/null
+++ b/contributing/ADRs/front-end/preferred-form-architecture.md
@@ -0,0 +1,17 @@
+---
+title: "ADR: Preferred form architecture"
+---
+
+## Background
+
+Forms can be tricky. In software, we often want to write DRY components, repeating as little as possible. Yet we also want a clear separation of concerns. Forms represent a challenge in this way because you have to choose which principle is the most important. You can't both have it DRY and completely separated.
+
+## Decision
+
+We have decided to architecture our forms in the following way:
+
+* Create a hook that contains all the logic for the form. This hook will return a form object that contains all the form state and functions to update the state.
+* Create a reusable form component that does not contain any logic
+* Create separate Create and Edit components that use the form component and the form hook to create the form and implements it's own logic for submitting the form.
+
+In this way, we keep as much of the form as possible DRY, but we avoid passing state internally in the form so the form doesn't need to know whether it is in create or edit mode. This allows us to keep one thing in mind when working, and not have to worry about dual states of the component.
diff --git a/contributing/ADRs/front-end/preferred-function-type.md b/contributing/ADRs/front-end/preferred-function-type.md
new file mode 100644
index 000000000000..9472f12d9fe3
--- /dev/null
+++ b/contributing/ADRs/front-end/preferred-function-type.md
@@ -0,0 +1,23 @@
+---
+title: "ADR: Preferred function type"
+---
+
+## Background
+
+In the codebase, we have found a need to standardise function types in order to keep the codebase recognizible across different sections, and to encourage / discourage certain patterns.
+
+## Decision
+
+We have decided to use arrow functions across the board in the project. Both for helper functions and for react components.
+
+```jsx
+// Do:
+const myFunction = () => {};
+const MyComponent = () => {};
+
+// Don't:
+function myFunction() {}
+function MyComponent() {}
+```
+
+The reason for this decision is to remove mental clutter and free up capacity to easily navigate the codebase. In addition, using arrow functions allows you to avoid the complexity of losing the scope of `this` for nested functions, and keeps `this` stable without any huge drawbacks. Losing hoisting is an acceptable compromise.
diff --git a/contributing/ADRs/front-end/preferred-styling-method.md b/contributing/ADRs/front-end/preferred-styling-method.md
new file mode 100644
index 000000000000..af32e3a31c31
--- /dev/null
+++ b/contributing/ADRs/front-end/preferred-styling-method.md
@@ -0,0 +1,49 @@
+---
+title: "ADR: preferred styling method"
+---
+
+This document supersedes [ADR: preferred styles import placement](/contributing/ADRs/front-end/deprecated/preferred-styles-import-placement)
+
+
+## Background
+
+In the codebase, we need to have a uniform way of performing style updates.
+
+## Decision
+
+We have decided to move away from using makeStyles as it's currently deprecated from @material/ui, and kept alive with an
+external interop package to maintain compatability with the latest version. The preferred path forward is to use styled components which is
+supported natively in @material/ui and sparingly use the sx prop available on all mui components.
+
+### When to use `sx` vs `styled`
+
+As with everything else, whether to use styled components or the `sx` prop depends on the context.
+
+Styled components have better performance characteristics, but it's fairly minor (refer to Material UI's [performance tradeoffs](https://mui.com/system/getting-started/usage/#performance-tradeoffs) doc for more information). So unless you're rendering something a lot of times, it's not really a big deal. But when in doubt: Use styled components. And when using a styled component feels like too much overhead, consider using the `sx` prop.
+
+### Consequences: code sharing
+
+With makeStyles it was common to reuse CSS fragments via library utilities.
+In the styled components approach we use themeable functions and object literals.
+
+```ts
+import { Theme } from '@mui/material';
+
+export const focusable = (theme: Theme) => ({
+ color: theme.palette.primary.main,
+});
+
+export const flexRow = {
+ display: 'flex',
+ alignItems: 'center',
+};
+```
+
+Usage:
+```ts
+const StyledLink = styled(Link)(({ theme }) => ({
+ ...focusable(theme),
+}));
+
+
+```
diff --git a/contributing/ADRs/front-end/sdk-generator.md b/contributing/ADRs/front-end/sdk-generator.md
new file mode 100644
index 000000000000..e39653b92656
--- /dev/null
+++ b/contributing/ADRs/front-end/sdk-generator.md
@@ -0,0 +1,33 @@
+---
+title: "ADR: OpenAPI SDK Generator"
+---
+
+## Background
+
+In our current frontend setup we have a lot of code that can be generated out of the OpenAPI schema. Types have not been updated in a while, and even some new features rely on hard-coded types instead of auto-generated files in `src/openapi`. Fetchers and actions in the frontend involve custom-built code that is grouped in the `src/hooks/api` folder. There is a separation between getters and actions. Getters use the SWR library. API actions (POST/PUT/DELETE) are grouped by feature and are exposed to components reliant on `useAPI` hook.
+
+## Decisions
+
+- We will use the [Orval](https://orval.dev/) package to generate the typescript types for our SDK.
+- We will consider using Orval to generate the HTTP getters and actions for our SDK in the future, but will first carefully test this approach in new features under development to weed out edge cases.
+- We will deprecate `src/interfaces` related to API calls and use `src/openapi` models instead.
+
+### Advantages
+
+SDK generated out of it will be better than what we have right now. It will help reduce the risk of duplication and inconsistencies in our SDK, as it will be generated from a single source of truth.
+
+- We retain the flexibility of previous solution, because we can implement our own _fetcher_ function, and substitute _response_ and _error_ type generics. See https://orval.dev/guides/custom-client
+- It supports `anyOf` and `oneOf` schema, which the previous generator did not support.
+- If we decide to use Orval to generate the HTTP getters and actions for our SDK, it will reduce the amount of boilerplate code required when working with the new APIs.
+
+### Concerns
+
+- We will need to ensure that we keep our OpenAPI specification up-to-date, as any changes in the specification will be reflected in the generated SDK. We need an enterprise version with all experimental endpoints enabled to get complete output.
+- Orval is well-maintained, but it appears to have just 1 core contributor. SDK is a very important thing and should be reliable.
+- We can revert to writing some API calls by hand if this approach is not flexible enough, but this can cause issues negating the benefits of code generation.
+
+## Alternative packages considered
+
+- `@openapitools/openapi-generator` - does not offer as many customization options as Orval. It struggles with `anyOf` and `oneOf` types. It fails
+- `rapini`: This package is less flexible and less actively maintained than Orval, and we therefore decided against it.
+- `@openapi-codegen` - does not generate SWR hooks
diff --git a/contributing/ADRs/overarching/domain-language.md b/contributing/ADRs/overarching/domain-language.md
new file mode 100644
index 000000000000..cb116b1a616a
--- /dev/null
+++ b/contributing/ADRs/overarching/domain-language.md
@@ -0,0 +1,24 @@
+---
+title: "ADR: Domain language"
+---
+
+## Background
+
+In the codebase, we have seen the need to define a domain language that we use to refer to features, methods to keep it consistent across the codebase. This ADR will contain a growing list of domain language used to keep the consistency across the codebase.
+
+## Decision
+
+We have decided to use the same domain language for the features we develop. Each feature will have it's own domain language to keep it consistent across the codebase.
+
+## Change requests domain language
+
+* *Change request*: An entity referring to the overarching data structure of a change request. A change request contains changes, and can be approved or rejected.
+* *Change*: A term referring to a single change within a change request
+* *Changes*: A term referring to a group of changes within a change request
+* *Discard*: A term used for deleting a single change of a change request, or discarding an entire change request.
+* *Pending*: A *pending* change request is one that has not yet been applied or discarded. In other words, it is in one of these three states:
+ 1. `Draft`
+ 2. `In review`
+ 3. `Approved`
+* *Closed*: A *closed* change request has either been applied or cancelled and can no longer be changed. Change requests that are either `Applied` or `Cancelled` are considered closed.
+
diff --git a/contributing/ADRs/overarching/logging-levels.md b/contributing/ADRs/overarching/logging-levels.md
new file mode 100644
index 000000000000..ee817fb0f176
--- /dev/null
+++ b/contributing/ADRs/overarching/logging-levels.md
@@ -0,0 +1,75 @@
+---
+title: "ADR: Logging levels"
+---
+## Date: 2025-03-20
+
+## Background
+
+Our log levels carry semantic information.
+Log lines logged at the error level triggers SRE alerts if they exceed more than 1 per hour. Though we are pretty good at not excessively logging at ERROR, we do have cases where SRE alerts gets triggered, but by the time SRE can log on and check the deployment, everything is fine again. This means we never had an ERROR, we should have had a WARN message.
+
+This ADR aims to solidify an understanding that levels are important to use correctly to avoid mental load and on-call alerts for things we can't do anything about.
+
+## Decision
+
+We should agree on the semantic information carried in each level, and which levels are ok to ignore while scanning logs from running applications.
+
+Current suggestion
+
+| Log level | Frequency in healthy application | Standard Availability | Configurable |
+|-----------|----------------------------------|-------------------------------------------|-----------------------------|
+| ERROR | 0 | All environments | NO |
+| WARN | 1-10 | All environments | NO |
+| INFO | 10-100 | Default deploy config sets LOG_LEVEL=info | YES |
+| DEBUG | 100 - 1000 | Local development | YES (specific deployments) |
+| TRACE | 1000 - 10000 | NO | YES (specific deployments) |
+
+
+
+
+### Change
+
+Previously we might've logged an ERROR for a self-healable issue, this should change to WARN, and not be an ERROR.
+
+The only things that should be logged at ERROR are exceptional behaviour that we need to fix immediately,
+everything else should be downgraded to WARN. In order to reduce WARN cardinality, this might mean that some messages at WARN today should be downgraded to INFO.
+
+
+### Examples
+[traffic-data-service in enterprise](https://github.com/bricks-software/unleash-enterprise/blob/293304a5d67231d584c3fa4c28589af23fb395e3/src/traffic-data/traffic-data-usage-service.ts#L69)
+#### Previous
+```typescript
+await Promise.all(promises)
+ .then(() => {
+ this.logger.debug('Traffic data usage saved');
+ })
+ .catch((err) => {
+ this.logger.error('Failed to save traffic data usage', err);
+ });
+```
+
+#### Recommended
+Since there's nothing for an SRE to do here if it fails to save, this is an excellent candidate for downgrading to WARN
+```typescript
+await Promise.all(promises)
+ .then(() => {
+ this.logger.debug('Traffic data usage saved');
+ })
+ .catch((err) => {
+ this.logger.warn('Failed to save traffic data usage', err);
+ });
+```
+[markSeen#account-store in unleash](https://github.com/Unleash/unleash/blob/038c10f6125c4cce200a0bf49f38c7bddada7093/src/lib/db/account-store.ts#L164)
+```typescript
+async markSeenAt(secrets: string[]): Promise {
+ const now = new Date();
+ try {
+ await this.db('personal_access_tokens')
+ .whereIn('secret', secrets)
+ .update({ seen_at: now });
+ } catch (err) {
+ this.logger.error('Could not update lastSeen, error: ', err);
+ }
+ }
+```
+Not being able to update lastSeen is not something we can do anything about as on-calls, so this is also a good candidate for downgrading to WARN.
diff --git a/contributing/ADRs/overarching/logging.md b/contributing/ADRs/overarching/logging.md
new file mode 100644
index 000000000000..e4da4bbbcb60
--- /dev/null
+++ b/contributing/ADRs/overarching/logging.md
@@ -0,0 +1,40 @@
+---
+title: "ADR: Logging errors"
+---
+
+## Background
+
+After debugging multiple errors over the last few years, we've consistently found that when something goes wrong, we
+would like as much context as possible to debug faster.
+
+## Decision
+
+When we log at the error level, we should give the person debugging as much information as possible.
+As such, please include the error as a second argument to `logger.error`. This will include the stacktrace in the log
+message and make it a lot easier to figure out where the error is coming from
+
+### Change
+
+#### Previously
+
+```typescript
+function errors() {
+ try {
+ } catch (e) {
+ this.logger.error(`Something went wrong {$e}`);
+ }
+}
+```
+
+to
+
+#### Now (Recommended)
+
+```typescript
+function errors() {
+ try {
+ } catch (e) {
+ this.logger.error('Something went wrong', e);
+ }
+}
+```
diff --git a/contributing/ADRs/overarching/separation-request-response-schemas.md b/contributing/ADRs/overarching/separation-request-response-schemas.md
new file mode 100644
index 000000000000..5f053e513f5d
--- /dev/null
+++ b/contributing/ADRs/overarching/separation-request-response-schemas.md
@@ -0,0 +1,42 @@
+---
+title: "ADR: Separation of request and response schemas"
+---
+
+## Background
+
+During the updating of our OpenAPI documentation, we have encountered issues related to the scope and strictness of our schemas for requests and responses. Currently, we are reusing the same schema for both request and response, which has led to situations where the schemas are either too broad for a response or too strict for a request. This has caused difficulties in accurately defining the expected data structures for API interactions.
+
+## Decision
+
+After careful consideration and discussion, it has been decided to separate the request and response schemas to address the challenges we have encountered. By creating distinct schemas for requests and responses, we aim to improve the flexibility and precision of our API documentation.
+
+### Advantages
+
+Separating the schemas will allow us to establish more precise and constrained response types while enabling more forgiving request types. This approach will facilitate better alignment between the expected data structures and the actual data transmitted in API interactions.
+
+The separation of request and response schemas will provide the following benefits:
+
+1. **Enhanced clarity and correctness**: With dedicated schemas for requests and responses, we can define the precise structure and constraints for each interaction. This will help prevent situations where the schemas are overly permissive or restrictive, reducing ambiguity and ensuring that the code handling the requests and responses is more reliable and easier to understand. By separating the schemas, we can define specific and precise structures for requests and responses, minimizing the use of undefined values and improving the overall correctness of the codebase and API implementation. The client knows exactly what data to send in the requests, and the server knows what to expect in the responses, ensuring that both parties are aligned in terms of data structures and expectations.
+
+2. **Improved maintainability**: By avoiding the reuse of schemas between requests and responses, we can modify and update them independently. This decoupling of schemas will simplify maintenance efforts and minimize the risk of unintended side effects caused by changes in one context affecting the other.
+
+3. **Flexibility for future enhancements**: Separating request and response schemas lays the foundation for introducing additional validation or transformation logic specific to each type of interaction. This modularity will enable us to incorporate future enhancements, such as custom validation rules or middleware, with ease.
+
+### Concerns
+
+While this decision brings several benefits, we acknowledge the following concerns that may arise from the separation of request and response schemas:
+
+1. **Increased schema maintenance**: By having separate schemas for requests and responses, there will be a need to maintain and update two sets of schemas instead of a single shared schema. This could potentially increase the maintenance overhead and introduce the possibility of inconsistencies between the two schemas.
+
+2. **Data duplication and redundancy**: With the separation of schemas, there might be instances where certain data fields or structures are duplicated between the request and response schemas. This redundancy could lead to code duplication and increase the risk of inconsistencies if changes are not carefully synchronized between the two schemas.
+
+
+## Conclusion
+
+By implementing the separation of request and response schemas, we aim to improve the robustness and maintainability of our API documentation. This decision will empower developers to build more reliable integrations by providing clearer guidelines for both request and response data structures.
+
+Furthermore, this separation of schemas brings valuable benefits to our internal development process. It allows us to write more robust code and reduces the need for extensive manipulation of incoming requests to fit the correct shapes. By clearly defining the structure and constraints for each interaction, we minimize the likelihood of bugs and make the code significantly easier to reason about and work with.
+
+While a big bang migration replacing all schemas at once is not feasible, we will follow the Boy Scout Rule and aim to complete the migration to separated schemas by the release of version 6.0. This means that as developers make changes or additions to the code, they will incorporate the separation of schemas, gradually updating the existing codebase over time. This approach ensures a smooth transition to the separated schemas, allowing us to continually improve the code handling requests and responses, reducing reliance on undefined values and promoting clarity, correctness, and maintainability throughout the development process.
+
+Overall, this approach will facilitate better communication, reduce confusion, and enhance the overall developer experience when interacting with our APIs, while providing the aforementioned benefits of more robust code, correctness and improved maintainability.
diff --git a/CONTRIBUTING.md b/contributing/CONTRIBUTING.md
similarity index 100%
rename from CONTRIBUTING.md
rename to contributing/CONTRIBUTING.md
diff --git a/contributing/backend/overview.md b/contributing/backend/overview.md
new file mode 100644
index 000000000000..37e815676563
--- /dev/null
+++ b/contributing/backend/overview.md
@@ -0,0 +1,144 @@
+---
+title: Back end
+---
+
+The backend is written in nodejs/typescript. It's written as a REST API following a CSR (controller, service, repository/store) pattern. The following ADRs are defined for the backend:
+
+## ADRs
+
+We have created a set of ADRs to help guide the development of the backend:
+
+* [Naming](/contributing/ADRs/back-end/naming)
+* [Preferred export](/contributing/ADRs/back-end/preferred-export)
+
+## Requirements
+
+Before developing on this project you will need two things:
+
+- PostgreSQL 14.0+
+- Node.js v22.0+
+
+```sh
+corepack enable
+yarn install
+yarn dev
+```
+
+## PostgreSQL
+
+To run and develop Unleash, you need to have PostgreSQL 14.0+ locally.
+
+Unleash currently also works with PostgreSQL v14.0+, but this might change in a future feature release, and we have stopped running automatic integration tests below PostgreSQL 14. The current recommendation is to use a role with Owner privileges since Unleash uses Postgres functions to simplify our database usage.
+
+### Create a local Unleash database in Postgres
+
+Start the ready-to-use Postgres container (first run builds a small image that
+executes the required SQL automatically):
+
+```bash
+$ docker compose -f docker-compose.postgres.yml up -d
+```
+
+The container exposes Postgres on `localhost:5432` with the expected role and
+databases already created. Stop it with `docker compose -f docker-compose.postgres.yml down`.
+
+If you prefer to run the SQL manually outside of Docker, you can execute:
+
+```bash
+$ psql postgres < yarn run db-migrate create YOUR-MIGRATION-NAME
+```
+
+All migrations require one `up` and one `down` method. There are some migrations that will maintain the database integrity, but not the data integrity and may not be safe to run on a production database.
+
+Example of a typical migration:
+
+```js
+/* eslint camelcase: "off" */
+'use strict';
+
+exports.up = function(db, cb) {
+ db.createTable(
+ 'examples',
+ {
+ id: { type: 'int', primaryKey: true, notNull: true },
+ created_at: { type: 'timestamp', defaultValue: 'now()' },
+ },
+ cb,
+ );
+};
+
+exports.down = function(db, cb) {
+ return db.dropTable('examples', cb);
+};
+```
+
+Test your migrations:
+
+```bash
+> yarn run db-migrate up
+> yarn run db-migrate down
+```
+
+## Publishing / Releasing new packages
+
+Please run `yarn test` checks before publishing.
+
+Run `npm run publish` to start the publishing process.
+
+`npm run publish:dry`
diff --git a/contributing/client-specification.md b/contributing/client-specification.md
new file mode 100644
index 000000000000..8c8e57d9a779
--- /dev/null
+++ b/contributing/client-specification.md
@@ -0,0 +1,116 @@
+---
+id: client-specification
+title: Client Specification
+---
+
+# Client Specification 1.0
+
+This document attempts to guide developers in implementing an Unleash Client SDK.
+
+## System Overview
+
+Unleash is composed of three parts:
+
+- **Unleash API** - The service holding all feature flags and their configurations. Configurations declare which activation strategies to use and which parameters they should get.
+- **Unleash UI** - The dashboard used to manage feature flags, define new strategies, look at metrics, etc.
+- **Unleash SDK** - Used by clients to check if a feature is enabled or disabled. The SDK also collects metrics and sends them to the Unleash API. Activation Strategies are also implemented in the SDK. Unleash currently provides official SDKs for Java and Node.js
+
+
+
+To be super fast, the client SDK caches all feature flags and their current configuration in memory. The activation strategies are also implemented in the SDK. This makes it really fast to check if a flag is on or off because it is just a simple function operating on local state, without the need to poll data from the database.
+
+## The Basics
+
+All client implementations should strive to have a consistent and straightforward user API. It should be a simple method, called isEnabled, to check if a feature flag is enabled or not. The method should return a `boolean` value, true or false.
+
+```javascript
+unleash.isEnabled('myAwesomeFlag');
+```
+
+The basic `isEnabled` method should also accept a default value. This should be used if the client does not know anything about a particular flag. If the user does not specify a default value, false should be returned for unknown feature flags.
+
+**Calling unleash with default value:**
+
+```javascript
+boolean value = unleash.isEnabled("unknownFeatureFlag", false);
+//value==false because default value was used.
+```
+
+### Implementation of isEnabled
+
+A feature flag is defined as:
+
+```json
+{
+ "name": "Feature.B",
+ "description": "lorem ipsum",
+ "enabled": true,
+ "strategies": [
+ {
+ "name": "ActiveForUserWithId",
+ "parameters": {
+ "userIdList": "123,221,998"
+ }
+ },
+ {
+ "name": "GradualRolloutRandom",
+ "parameters": {
+ "percentage": "10"
+ }
+ }
+ ],
+ "strategy": "ActiveForUserWithId",
+ "parameters": {
+ "userIdList": "123,221,998"
+ }
+}
+```
+
+A simple demo of the `isEnabled` function in JavaScript style (most of the implementation will likely be more functional):
+
+```javascript
+function isEnabled(name, unleashContext = {}, defaultValue = false) {
+ const flag = toggleRepository.get(name);
+ let enabled = false;
+
+ if (!flag) {
+ return defaultValue;
+ } else if (!flag.isEnabled) {
+ return false;
+ } else {
+ for (let i = 0; i < flag.strategies.length; i++) {
+ let strategyDef = flag.strategies[i];
+ let strategyImpl = strategyImplRepository.get(strategyDef.name);
+ if (strategyImpl.isEnabled(flag.parameters, unleashContext)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
+```
+
+## Activation Strategies
+
+Activation strategies are defined and configured in the unleash-service. It is up to the client to provide the actual implementation of each activation strategy.
+
+Unleash also ships with a few built-in strategies, and expects client SDK's to implement these. Read more about these [activation strategies](/concepts/activation-strategies). For the built-in strategies to work as expected the client should also allow the user to define an [unleash-context](/concepts/unleash-context). The context should be possible to pass in as part of the `isEnabled` call.
+
+### Extension points
+
+Client implementation should also provide a defined interface to make it easier for the user to implement their own activation strategies, and register those in the Unleash client.
+
+## Fetching feature flags (polling)
+
+The client implementation should fetch flags in the background as regular polling. In a thread-based environment, such as Java, this needs to be done in a separate thread. The default poll interval should be **15 seconds**, and it should also be configurable.
+
+## Client registration
+On start-up, the clients should register with the Unleash server. The registration request must include the required fields specified in the [API documentation](/api/register-client-application).
+
+## Metrics
+
+Clients are expected to send metrics back to Unleash API at regular intervals. The metrics are a list of used flags and how many times they evaluated to yes or no in at the time of requesting the metrics. Read more about how to send metrics in the [Metrics API](/api/register-client-metrics) documentation.
+
+## Backup Feature Flags
+
+The SDK also persists the latest known state to a local file on the instance where the client is running. It will store a local copy every time the client receives changes from the API. Having a local backup of the latest known state minimises the consequences of clients not being able to talk to the Unleash API on startup. This is necessary due to network unreliability.
diff --git a/contributing/developer-guide.md b/contributing/developer-guide.md
new file mode 100644
index 000000000000..576887140ffa
--- /dev/null
+++ b/contributing/developer-guide.md
@@ -0,0 +1,34 @@
+---
+title: Developer guide
+pagination_next: contributing/client-specification
+---
+
+## Introduction
+
+This repository consists of two main parts: the backend and frontend of Unleash. The backend is a Node.js application built with TypeScript, while the frontend is a React application also built with TypeScript. You can find code specific to the backend in the `src` lib folder and code specific to the frontend in the `frontend` folder.
+
+## Development philosophy
+
+The development philosophy at Unleash is centered on delivering high-quality software. We achieve this by following a set of principles that we believe will also make the software easy to maintain and extend, serving as our guide.
+
+We believe that the following principles are essential in achieving our goal:
+
+* We test our code always
+
+Software is difficult. Being a software engineer is about acknowledging our limits, and taking every precaution necessary to avoid introducing bugs. We believe that testing is the best way to achieve this. We test our code always, and prefer automation over manual testing.
+
+* We strive to write code that is easy to understand and maintain
+
+We believe code is a language. Written code is a way to communicate intent. It's about explaining to the reader what this code does, in the shortest amount of time possible. As such, writing clean code is supremely important to us. We believe that this contributes to keeping our codebase maintainable, and helps us maintain speed in the long run.
+
+* We think about solutions before committing
+
+We don't jump to implementation immediately. We think about the problem at hand, and try to examine the impact that this solution may have in a multitude of scenarios. As our product core is open source, we need to balance the solutions and avoid implementations that may be cumbersome for our community. The need to improve our paid offering must never come at the cost of our open source offering.
+
+### Required reading
+
+The following resources should be read before contributing to the project:
+
+* [Clean code javascript](https://github.com/ryanmcdermott/clean-code-javascript)
+* [frontend overview](/contributing/frontend/overview)
+* [backend overview](/contributing/backend/overview)
diff --git a/contributing/frontend/overview.md b/contributing/frontend/overview.md
new file mode 100644
index 000000000000..3e418550c450
--- /dev/null
+++ b/contributing/frontend/overview.md
@@ -0,0 +1,23 @@
+---
+title: Front end
+---
+
+## Frontend overview
+
+The frontend is written in react/typescript. It's a single page application that communicates with the backend via a REST API. The frontend is built using vite and served by the backend.
+
+## ADRs
+
+We have created a set of ADRs to help guide the development of the frontend:
+
+* [Component naming](/contributing/ADRs/front-end/component-naming)
+* [Interface naming](/contributing/ADRs/front-end/interface-naming)
+* [Preferred component props usage](/contributing/ADRs/front-end/preferred-component-props-usage)
+* [Preferred export](/contributing/ADRs/front-end/preferred-export)
+* [Preferred function type](/contributing/ADRs/front-end/preferred-function-type)
+* [Preferred styling method](/contributing/ADRs/front-end/preferred-styling-method)
+* [Preferred data mutation method](/contributing/ADRs/front-end/preferred-data-mutation-method)
+* [Preferred data fetching method](/contributing/ADRs/front-end/preferred-data-fetching-method)
+* [Preferred folder structure](/contributing/ADRs/front-end/preferred-folder-structure)
+* [Preferred form architecture](/contributing/ADRs/front-end/preferred-form-architecture)
+* [OpenAPI SDK generator](/contributing/ADRs/front-end/sdk-generator)
diff --git a/frontend/src/component/admin/AdminHome.tsx b/frontend/src/component/admin/AdminHome.tsx
index 28a43eafafc5..8b0fbc272c9a 100644
--- a/frontend/src/component/admin/AdminHome.tsx
+++ b/frontend/src/component/admin/AdminHome.tsx
@@ -11,9 +11,10 @@ import {
import { useInstanceStats } from 'hooks/api/getters/useInstanceStats/useInstanceStats';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import ArrowOutwardIcon from '@mui/icons-material/ArrowOutward';
+import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
import { formatAssetPath } from 'utils/formatPath';
import easyToDeploy from 'assets/img/easyToDeploy.png';
-import { useNavigate } from 'react-router-dom';
+import { Link, useNavigate } from 'react-router-dom';
import { EnterpriseEdgeDismissibleAlert } from './enterprise-edge/EnterpriseEdgeDismissibleAlert.tsx';
const UI_SWITCH_WIDGET_RATIO_BREAKPOINT = 1505;
@@ -25,6 +26,7 @@ const StyledContainer = styled(Grid)(({ theme }) => ({
}));
const StyledInstanceWidget = styled(Paper)(({ theme }) => ({
+ position: 'relative',
height: theme.spacing(44),
padding: theme.spacing(3),
borderRadius: `${theme.shape.borderRadiusLarge}px`,
@@ -33,10 +35,11 @@ const StyledInstanceWidget = styled(Paper)(({ theme }) => ({
backgroundColor: theme.palette.web.main,
overflow: 'hidden',
[theme.breakpoints.down(UI_SWITCH_WIDGET_RATIO_BREAKPOINT)]: {
- height: theme.spacing(24),
+ height: theme.spacing(28),
+ paddingBottom: theme.spacing(6),
},
[theme.breakpoints.down(800)]: {
- height: theme.spacing(30),
+ height: theme.spacing(34),
},
}));
@@ -61,7 +64,7 @@ const StyledHeader = styled(Typography)(({ theme }) => ({
const StyledLicenseSection = styled('div')(({ theme }) => ({
marginBottom: theme.spacing(6),
[theme.breakpoints.down(UI_SWITCH_WIDGET_RATIO_BREAKPOINT)]: {
- marginBottom: theme.spacing(2),
+ marginBottom: theme.spacing(),
},
}));
@@ -139,6 +142,25 @@ const StyledLinkContainer = styled('div')(({ theme }) => ({
marginTop: theme.spacing(3),
}));
+const StyledHelpContainer = styled('div')(({ theme }) => ({
+ position: 'absolute',
+ left: theme.spacing(2.5),
+ bottom: theme.spacing(3),
+ [theme.breakpoints.down(UI_SWITCH_WIDGET_RATIO_BREAKPOINT)]: {
+ position: 'static',
+ marginTop: theme.spacing(2),
+ },
+}));
+
+const StyledHelpLink = styled(Link)(({ theme }) => ({
+ color: theme.palette.common.white,
+ textDecoration: 'underline !important',
+ fontSize: theme.typography.body2.fontSize,
+ display: 'flex',
+ alignItems: 'center',
+ gap: theme.spacing(1),
+}));
+
const ImageContainer = styled('div')(({ theme }) => ({
display: 'flex',
justifyContent: 'flex-end',
@@ -147,6 +169,10 @@ const ImageContainer = styled('div')(({ theme }) => ({
[theme.breakpoints.down(UI_SWITCH_WIDGET_RATIO_BREAKPOINT)]: {
marginTop: theme.spacing(-22),
marginRight: theme.spacing(-12),
+ paddingTop: theme.spacing(1),
+ },
+ [theme.breakpoints.down(800)]: {
+ paddingTop: 0,
},
}));
@@ -181,6 +207,18 @@ const InstanceWidget = ({
Unleash version
{version}
+
+
+ theme.palette.common.white }}
+ />
+ Learn about versioning
+
+
diff --git a/frontend/src/component/admin/instance-admin/InstanceStats/InstanceStats.tsx b/frontend/src/component/admin/instance-admin/InstanceStats/InstanceStats.tsx
index ba028b891b76..ed11475e3306 100644
--- a/frontend/src/component/admin/instance-admin/InstanceStats/InstanceStats.tsx
+++ b/frontend/src/component/admin/instance-admin/InstanceStats/InstanceStats.tsx
@@ -13,9 +13,15 @@ import { useInstanceStats } from 'hooks/api/getters/useInstanceStats/useInstance
import { formatApiPath } from '../../../../utils/formatPath.ts';
import { PageContent } from '../../../common/PageContent/PageContent.tsx';
import { PageHeader } from '../../../common/PageHeader/PageHeader.tsx';
+import { useUiFlag } from 'hooks/useUiFlag.ts';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig.ts';
export const InstanceStats: FC = () => {
const { stats } = useInstanceStats();
+ const {
+ uiConfig: { resourceLimits },
+ } = useUiConfig();
+ const readOnlyUsersUIEnabled = useUiFlag('readOnlyUsersUI');
let versionTitle: string;
let version: string | undefined;
@@ -72,6 +78,13 @@ export const InstanceStats: FC = () => {
{ title: 'SAML enabled', value: stats?.SAMLenabled ? 'Yes' : 'No' },
{ title: 'OIDC enabled', value: stats?.OIDCenabled ? 'Yes' : 'No' },
);
+
+ if (readOnlyUsersUIEnabled && resourceLimits.readOnlyUsers) {
+ rows.push({
+ title: 'ReadOnly users',
+ value: stats?.readOnlyUsers,
+ });
+ }
}
return (
diff --git a/frontend/src/component/admin/users/UsersList/UsersList.tsx b/frontend/src/component/admin/users/UsersList/UsersList.tsx
index 68e1ed68cf40..17ffbbd4f607 100644
--- a/frontend/src/component/admin/users/UsersList/UsersList.tsx
+++ b/frontend/src/component/admin/users/UsersList/UsersList.tsx
@@ -44,7 +44,11 @@ import { UpgradeSSO } from './UpgradeSSO.tsx';
const UsersList = () => {
const navigate = useNavigate();
- const { isEnterprise, isOss } = useUiConfig();
+ const {
+ isEnterprise,
+ isOss,
+ uiConfig: { resourceLimits },
+ } = useUiConfig();
const { users, roles, refetch, loading } = useUsers();
const { setToastData, setToastApiError } = useToast();
const { removeUser, userLoading, userApiErrors } = useAdminUsersApi();
@@ -61,6 +65,9 @@ const UsersList = () => {
const showUserDeviceCount = useUiFlag('showUserDeviceCount');
const showSSOUpgrade = isOss() && users.length > 3;
+ const showSeatTypes =
+ useUiFlag('readOnlyUsersUI') && resourceLimits.readOnlyUsers;
+
const {
settings: { enabled: scimEnabled },
} = useScimSettings();
@@ -200,6 +207,14 @@ const UsersList = () => {
),
sortType: 'boolean',
},
+ {
+ id: 'seatType',
+ Header: 'Seat type',
+ accessor: 'seatType',
+ maxWidth: 100,
+ sortType: 'boolean',
+ Cell: TextCell,
+ },
{
Header: '',
id: 'Actions',
@@ -239,17 +254,20 @@ const UsersList = () => {
searchable: true,
},
],
- [roles, navigate, isBillingUsers],
+ [roles, navigate, isBillingUsers, showSeatTypes],
);
const initialState = useMemo(() => {
return {
sortBy: [{ id: 'createdAt', desc: true }],
- hiddenColumns: isBillingUsers
- ? ['username', 'email']
- : ['type', 'username', 'email'],
+ hiddenColumns: [
+ 'username',
+ 'email',
+ ...(isBillingUsers ? [] : ['type']),
+ ...(showSeatTypes ? [] : ['seatType']),
+ ],
};
- }, [isBillingUsers]);
+ }, [isBillingUsers, showSeatTypes]);
const { data, getSearchText } = useSearch(
columns,
@@ -281,6 +299,10 @@ const UsersList = () => {
condition: !isBillingUsers || isSmallScreen,
columns: ['type'],
},
+ {
+ condition: !showSeatTypes || isSmallScreen,
+ columns: ['seatType'],
+ },
{
condition: isExtraSmallScreen,
columns: ['imageUrl', 'role'],
diff --git a/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.test.tsx b/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.test.tsx
index 262c77e6a3ca..13b1d1f72f1c 100644
--- a/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.test.tsx
+++ b/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.test.tsx
@@ -2,6 +2,9 @@ import { render } from 'utils/testRenderer';
import { Route, Routes } from 'react-router-dom';
import BreadcrumbNav from './BreadcrumbNav.tsx';
import { screen } from '@testing-library/react';
+import { testServerRoute, testServerSetup } from 'utils/testServer';
+
+const server = testServerSetup();
test('decode URI encoded path in breadcrumbs nav', async () => {
render(
@@ -20,3 +23,22 @@ test('decode URI encoded path in breadcrumbs nav', async () => {
await screen.findByText('my app');
await screen.findByText('my instance');
});
+
+test('use project name when in a project path', async () => {
+ testServerRoute(server, '/api/admin/projects/my-project/overview', {
+ name: 'My Test Project',
+ onboardingStatus: { status: 'onboarded' },
+ version: '1.0.0',
+ });
+
+ render(
+
+ } />
+ ,
+ {
+ route: '/projects/my-project',
+ },
+ );
+
+ await screen.findByText('My Test Project');
+});
diff --git a/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx b/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx
index c2a7142e13ca..c60777ffe8b9 100644
--- a/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx
+++ b/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx
@@ -4,7 +4,9 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
import AccessContext from 'contexts/AccessContext';
import { useContext } from 'react';
import { styled } from '@mui/material';
-import { textTruncated } from 'themes/themeStyles';
+import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview';
+import { useOptionalPathParam } from 'hooks/useOptionalPathParam';
+import { Truncator } from '../Truncator/Truncator';
const StyledBreadcrumbContainer = styled('div')(({ theme }) => ({
height: theme.spacing(2.5),
@@ -21,8 +23,9 @@ const StyledBreadcrumbs = styled(Breadcrumbs)({
});
const StyledCurrentPage = styled('span')(({ theme }) => ({
- ...textTruncated,
fontWeight: theme.typography.fontWeightBold,
+ maxWidth: theme.spacing(25),
+ display: 'block',
}));
const StyledLink = styled(Link)(({ theme }) => ({
@@ -35,6 +38,9 @@ const BreadcrumbNav = () => {
const { isAdmin } = useContext(AccessContext);
const location = useLocation();
+ const projectId = useOptionalPathParam('projectId');
+ const { project } = useProjectOverview(projectId || '');
+
let paths = location.pathname
.split('/')
.filter((item) => item)
@@ -82,11 +88,27 @@ const BreadcrumbNav = () => {
show={
{paths.map((path, index) => {
+ const isProjectPath =
+ path === projectId &&
+ index === 1 &&
+ project.name !== '';
+ const pathName = isProjectPath
+ ? project.name
+ : path;
const lastItem = index === paths.length - 1;
+ const tooltipTitle =
+ isProjectPath && pathName.length > 25
+ ? pathName
+ : undefined;
if (lastItem) {
return (
- {path}
+
+ {pathName}
+
);
}
@@ -103,7 +125,12 @@ const BreadcrumbNav = () => {
return (
- {path}
+
+ {pathName}
+
);
})}
diff --git a/frontend/src/component/common/DropdownMenu/DropdownButton/DropdownButton.tsx b/frontend/src/component/common/DropdownMenu/DropdownButton/DropdownButton.tsx
index 3fccec5e3977..d0be7949125c 100644
--- a/frontend/src/component/common/DropdownMenu/DropdownButton/DropdownButton.tsx
+++ b/frontend/src/component/common/DropdownMenu/DropdownButton/DropdownButton.tsx
@@ -2,7 +2,7 @@ import type { ReactNode, VFC } from 'react';
import { Button, type ButtonProps, Icon } from '@mui/material';
interface IDropdownButtonProps {
- label: string;
+ label: string | ReactNode;
id?: string;
title?: ButtonProps['title'];
className?: string;
diff --git a/frontend/src/component/common/DropdownMenu/DropdownMenu.tsx b/frontend/src/component/common/DropdownMenu/DropdownMenu.tsx
index c48cfc7dc782..76d3c313b87f 100644
--- a/frontend/src/component/common/DropdownMenu/DropdownMenu.tsx
+++ b/frontend/src/component/common/DropdownMenu/DropdownMenu.tsx
@@ -1,34 +1,42 @@
import {
type CSSProperties,
+ type FC,
type MouseEventHandler,
type ReactNode,
useState,
- type VFC,
} from 'react';
-import { Menu } from '@mui/material';
+import { Menu, type SxProps, type Theme } from '@mui/material';
import ArrowDropDown from '@mui/icons-material/ArrowDropDown';
import { DropdownButton } from './DropdownButton/DropdownButton.tsx';
export interface IDropdownMenuProps {
- renderOptions: () => ReactNode;
+ renderOptions: () => ReactNode | ReactNode[];
id: string;
title?: string;
callback?: MouseEventHandler;
icon?: ReactNode;
label: string;
startIcon?: ReactNode;
- style?: CSSProperties;
+ selected?: ReactNode;
+ layout?: DropdownMenuLayout;
}
-const DropdownMenu: VFC = ({
+type DropdownMenuLayout = {
+ width?: number | string;
+ button?: React.CSSProperties;
+ menu?: SxProps;
+};
+
+export const DropdownMenu: FC = ({
renderOptions,
id,
title,
callback,
icon = ,
label,
- style,
startIcon,
+ selected,
+ layout,
...rest
}) => {
const [anchor, setAnchor] = useState(null);
@@ -49,14 +57,17 @@ const DropdownMenu: VFC = ({
<>
>
);
};
-
-export default DropdownMenu;
diff --git a/frontend/src/component/common/GeneralSelect/GeneralSelect.tsx b/frontend/src/component/common/GeneralSelect/GeneralSelect.tsx
index b8f7a1a77d4a..61e896f01bac 100644
--- a/frontend/src/component/common/GeneralSelect/GeneralSelect.tsx
+++ b/frontend/src/component/common/GeneralSelect/GeneralSelect.tsx
@@ -6,6 +6,7 @@ import {
type SelectProps,
type SelectChangeEvent,
styled,
+ ListSubheader,
} from '@mui/material';
import { SELECT_ITEM_ID } from 'utils/testIds';
import KeyboardArrowDownOutlined from '@mui/icons-material/KeyboardArrowDownOutlined';
@@ -21,12 +22,24 @@ export interface ISelectOption {
sx?: SxProps;
}
+export type SelectOptionGroup = {
+ groupHeader: string;
+ options: ISelectOption[];
+};
+
+const isSelectOptionGroup = (
+ options: ISelectOption[] | SelectOptionGroup[],
+): options is SelectOptionGroup[] => {
+ const firstElement = options[0];
+ return firstElement && 'groupHeader' in firstElement;
+};
+
export interface IGeneralSelectProps
extends Omit {
name?: string;
value?: T;
label?: string;
- options: ISelectOption[];
+ options: ISelectOption[] | SelectOptionGroup[];
onChange: (key: T) => void;
disabled?: boolean;
fullWidth?: boolean;
@@ -40,6 +53,19 @@ const StyledFormControl = styled(FormControl)({
maxWidth: '100%',
});
+const toMenuItem = (option: ISelectOption) => (
+
+);
+
function GeneralSelect({
variant = 'outlined',
name,
@@ -85,29 +111,20 @@ function GeneralSelect({
label={visuallyHideLabel ? '' : label}
id={id}
value={value ?? ''}
- MenuProps={{
- sx: {
- '.MuiPopover-paper.MuiMenu-paper': {
- width: 'min-content',
- },
- },
- }}
+ autoWidth
IconComponent={KeyboardArrowDownOutlined}
labelId={labelId}
{...rest}
>
- {options.map((option) => (
-
- ))}
+ {isSelectOptionGroup(options)
+ ? options.flatMap((group) => {
+ return [
+
+ {group.groupHeader}
+ ,
+ ].concat(group.options.map(toMenuItem));
+ })
+ : options.map(toMenuItem)}
);
diff --git a/frontend/src/component/common/LifecycleFilters/FlagsCountBadge.tsx b/frontend/src/component/common/LifecycleFilters/FlagsCountBadge.tsx
new file mode 100644
index 000000000000..96fbd718de08
--- /dev/null
+++ b/frontend/src/component/common/LifecycleFilters/FlagsCountBadge.tsx
@@ -0,0 +1,31 @@
+import { Box, styled } from '@mui/material';
+
+const CountBadge = styled(Box)(({ theme }) => ({
+ backgroundColor: theme.palette.background.elevation1,
+ border: `1px solid ${theme.palette.divider}`,
+ borderRadius: theme.shape.borderRadius,
+ color: theme.palette.text.secondary,
+ padding: theme.spacing(0, 0.5),
+ height: theme.spacing(2.5),
+ textAlign: 'center',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ variant: 'outlined',
+ marginLeft: theme.spacing(1),
+ marginRight: theme.spacing(-0.5),
+ fontWeight: 'bold',
+ '&[data-selected="true"]': {
+ backgroundColor: theme.palette.secondary.light,
+ borderColor: theme.palette.primary.main,
+ color: theme.palette.primary.main,
+ },
+}));
+
+export const FlagsCountBadge = ({
+ count,
+ isActive,
+}: {
+ count: number;
+ isActive?: boolean;
+}) => {count};
diff --git a/frontend/src/component/common/LifecycleFilters/LifecycleChip.tsx b/frontend/src/component/common/LifecycleFilters/LifecycleChip.tsx
new file mode 100644
index 000000000000..e968f15cbefb
--- /dev/null
+++ b/frontend/src/component/common/LifecycleFilters/LifecycleChip.tsx
@@ -0,0 +1,118 @@
+import { Box, Chip, styled } from '@mui/material';
+import type { LifecycleStage } from 'component/feature/FeatureView/FeatureOverview/FeatureLifecycle/LifecycleStage';
+import { FeatureLifecycleStageIcon } from '../FeatureLifecycle/FeatureLifecycleStageIcon';
+import { FlagsCountBadge } from './FlagsCountBadge';
+
+interface LifecycleChipProps {
+ label: string;
+ value: LifecycleStage['name'] | null;
+ isActive: boolean;
+ flagsCount?: number;
+ onClick: () => void;
+}
+
+const StyledChip = styled(Chip, {
+ shouldForwardProp: (prop) => prop !== 'isActive',
+})<{
+ isActive?: boolean;
+}>(({ theme }) => ({
+ borderRadius: 0,
+ padding: theme.spacing(1, 0),
+ fontSize: theme.typography.body2.fontSize,
+ height: 'auto',
+ '&[data-selected="true"]': {
+ backgroundColor: theme.palette.secondary.light,
+ fontWeight: 'bold',
+ borderColor: theme.palette.primary.main,
+ color: theme.palette.primary.main,
+ },
+ ':focus-visible': {
+ outline: `1px solid ${theme.palette.primary.main}`,
+ borderColor: theme.palette.primary.main,
+ },
+ '&:first-of-type': {
+ borderTopLeftRadius: theme.shape.borderRadius,
+ borderBottomLeftRadius: theme.shape.borderRadius,
+ },
+ '&:last-of-type': {
+ borderTopRightRadius: theme.shape.borderRadius,
+ borderBottomRightRadius: theme.shape.borderRadius,
+ },
+ '&:not(&[data-selected="true"], :last-of-type)': {
+ borderRightWidth: 0,
+ },
+ '[data-selected="true"] + &': {
+ borderLeftWidth: 0,
+ },
+ [theme.breakpoints.down('md')]: {
+ width: '100%',
+ justifyContent: 'flex-start',
+ borderLeft: 0,
+ borderRight: 0,
+ borderBottom: 0,
+ borderTop: `1px solid ${theme.palette.divider}`,
+
+ '& .MuiChip-icon': {
+ marginLeft: theme.spacing(1),
+ marginRight: theme.spacing(1),
+ },
+
+ '& .MuiChip-label': {
+ paddingLeft: theme.spacing(1),
+ paddingRight: theme.spacing(1),
+ },
+
+ '&[data-selected="true"]': {
+ borderTop: `1px solid ${theme.palette.divider}`,
+ borderBottom: `1px solid ${theme.palette.divider}`,
+ backgroundColor: theme.palette.background.default,
+ },
+
+ '&[data-selected="true"] + &': {
+ borderTop: 0,
+ },
+ '&:not(:has(.MuiChip-icon)) .MuiChip-label': {
+ paddingLeft: theme.spacing(8),
+ },
+ '&:first-of-type': {
+ borderTopLeftRadius: 0,
+ borderBottomLeftRadius: 0,
+ borderTop: 0,
+ },
+ '&:last-of-type': {
+ borderTopRightRadius: 0,
+ borderBottomRightRadius: 0,
+ },
+ },
+
+ '& .MuiChip-label': {
+ position: 'relative',
+ textAlign: 'center',
+ paddingLeft: theme.spacing(1),
+ },
+}));
+
+export const LifecycleChip = ({
+ label,
+ value,
+ isActive,
+ flagsCount,
+ onClick,
+}: LifecycleChipProps) => (
+
+ {label}
+
+
+ }
+ variant='outlined'
+ onClick={onClick}
+ data-selected={isActive}
+ icon={
+ value ? (
+
+ ) : undefined
+ }
+ />
+);
diff --git a/frontend/src/component/common/LifecycleFilters/LifecycleFilters.tsx b/frontend/src/component/common/LifecycleFilters/LifecycleFilters.tsx
index 78e10d3edd86..7c7e69fc4905 100644
--- a/frontend/src/component/common/LifecycleFilters/LifecycleFilters.tsx
+++ b/frontend/src/component/common/LifecycleFilters/LifecycleFilters.tsx
@@ -1,35 +1,17 @@
-import { Box, Chip, styled } from '@mui/material';
+import { Box, styled, useMediaQuery, useTheme } from '@mui/material';
import type { SxProps, Theme } from '@mui/material';
import type { ReactNode } from 'react';
import type { FilterItemParamHolder } from '../../filter/Filters/Filters.tsx';
import type { LifecycleStage } from '../../feature/FeatureView/FeatureOverview/FeatureLifecycle/LifecycleStage.tsx';
-
-const StyledChip = styled(Chip, {
- shouldForwardProp: (prop) => prop !== 'isActive',
-})<{
- isActive?: boolean;
-}>(({ theme, isActive = false }) => ({
- borderRadius: `${theme.shape.borderRadius}px`,
- padding: theme.spacing(0.5),
- fontSize: theme.typography.body2.fontSize,
- height: 'auto',
- fontWeight: theme.typography.fontWeightMedium,
- ...(isActive && {
- backgroundColor: theme.palette.secondary.light,
- fontWeight: 'bold',
- borderColor: theme.palette.primary.main,
- color: theme.palette.primary.main,
- }),
- ':focus-visible': {
- outline: `1px solid ${theme.palette.primary.main}`,
- borderColor: theme.palette.primary.main,
- },
-}));
+import { FeatureLifecycleStageIcon } from '../FeatureLifecycle/FeatureLifecycleStageIcon.tsx';
+import { DropdownMenu } from '../DropdownMenu/DropdownMenu.tsx';
+import KeyboardArrowDownOutlined from '@mui/icons-material/KeyboardArrowDownOutlined';
+import { LifecycleChip } from './LifecycleChip.tsx';
+import { FlagsCountBadge } from './FlagsCountBadge.tsx';
interface ILifecycleFiltersBaseProps {
state: FilterItemParamHolder;
onChange: (value: FilterItemParamHolder) => void;
- total?: number;
children?: ReactNode;
countData?: Record;
sx?: SxProps;
@@ -45,72 +27,166 @@ const StyledContainer = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
- gap: theme.spacing(1),
+}));
+
+const StyledMinimalChipContainer = styled(Box)(({ theme }) => ({
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'flex-start',
+ gap: theme.spacing(0.5),
+ marginRight: theme.spacing(4),
+ width: '100%',
}));
const lifecycleOptions: {
label: string;
value: LifecycleStage['name'] | null;
}[] = [
- { label: 'All flags', value: null },
+ { label: 'All lifecycles', value: null },
{ label: 'Develop', value: 'pre-live' },
{ label: 'Rollout production', value: 'live' },
{ label: 'Cleanup', value: 'completed' },
];
+const MinimalChip = ({
+ label,
+ value,
+ count,
+}: {
+ label: string;
+ value: LifecycleStage['name'] | null;
+ count?: number;
+}) => (
+
+ {value ? (
+
+ ) : (
+ ({
+ marginLeft: theme.spacing(0.5),
+ })}
+ />
+ )}
+ {label}
+
+
+);
+
export const LifecycleFilters = ({
state,
onChange,
- total,
children,
countData,
}: ILifecycleFiltersBaseProps) => {
- const current = state.lifecycle?.values ?? [];
+ const theme = useTheme();
+ const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
+ const selectedLifecycle = state.lifecycle?.values?.[0] ?? null;
+
+ const isActive = (value: LifecycleStage['name'] | null) => {
+ return value === selectedLifecycle;
+ };
+
const allFlagsCount = Object.entries(countData ?? {}).reduce(
(acc, [key, count]) => (key !== 'archived' ? acc + count : acc),
0,
);
+ const getCount = (value: LifecycleStage['name'] | null) =>
+ value !== null ? countData?.[value] : allFlagsCount || undefined;
+
+ const applyFilter = (value: LifecycleStage['name'] | null) => {
+ onChange(
+ value === null
+ ? { lifecycle: null }
+ : { lifecycle: { operator: 'IS', values: [value] } },
+ );
+ };
+
+ const selectedOption =
+ lifecycleOptions.find((option) => option.value === selectedLifecycle) ??
+ lifecycleOptions[0];
+
+ const renderChips = () =>
+ lifecycleOptions.map(({ label, value }) => {
+ const count =
+ value !== null
+ ? countData?.[value]
+ : allFlagsCount || undefined;
+
+ return (
+ applyFilter(value)}
+ value={value}
+ isActive={isActive(value)}
+ />
+ );
+ });
+
return (
-
-
- {lifecycleOptions.map(({ label, value }) => {
- const isActive =
- value === null
- ? !state.lifecycle
- : current.includes(value);
- const count = value
- ? countData?.[value]
- : allFlagsCount || undefined;
- const dynamicLabel =
- isActive && Number.isInteger(total)
- ? `${label} (${total === (count ?? 0) ? total : `${total} of ${count}`})`
- : `${label}${count !== undefined ? ` (${count})` : ''}`;
-
- const handleClick = () =>
- onChange(
- value === null
- ? { lifecycle: null }
- : {
- lifecycle: {
- operator: 'IS',
- values: [value],
- },
- },
- );
-
- return (
-
+ {isSmallScreen ? (
+
- );
- })}
-
- {children}
-
+ }
+ icon={}
+ renderOptions={() => (
+
+ {lifecycleOptions.map(({ label, value }) => {
+ const count =
+ value !== null
+ ? countData?.[value]
+ : allFlagsCount || undefined;
+
+ return (
+ applyFilter(value)}
+ value={value}
+ isActive={isActive(value)}
+ />
+ );
+ })}
+ {children}
+
+ )}
+ layout={{
+ width: theme.spacing(36),
+ button: {
+ border: `1px solid ${theme.palette.divider}`,
+ paddingRight: theme.spacing(2),
+ },
+ menu: {
+ '& .MuiMenu-list': {
+ padding: 0,
+ },
+ },
+ }}
+ />
+ ) : (
+
+ {renderChips()}
+ {children}
+
+ )}
+ >
);
};
diff --git a/frontend/src/component/common/ReleaseTemplatesBanner/ReleaseTemplatesBanner.tsx b/frontend/src/component/common/ReleaseTemplatesBanner/ReleaseTemplatesBanner.tsx
index 8759b24a0ce0..7fc2f938fe92 100644
--- a/frontend/src/component/common/ReleaseTemplatesBanner/ReleaseTemplatesBanner.tsx
+++ b/frontend/src/component/common/ReleaseTemplatesBanner/ReleaseTemplatesBanner.tsx
@@ -13,6 +13,7 @@ import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import bannerProgressionSvg from 'assets/img/banner-progression.svg';
+import { formatAssetPath } from 'utils/formatPath';
const StyledContainer = styled(Box)(({ theme }) => ({
display: 'flex',
@@ -132,7 +133,10 @@ export const ReleaseTemplatesBanner: FC = () => {
-
+
diff --git a/frontend/src/component/common/Table/cells/LinkCell/LinkCell.tsx b/frontend/src/component/common/Table/cells/LinkCell/LinkCell.tsx
index e59cfcc94ec2..e0b2538f9632 100644
--- a/frontend/src/component/common/Table/cells/LinkCell/LinkCell.tsx
+++ b/frontend/src/component/common/Table/cells/LinkCell/LinkCell.tsx
@@ -14,6 +14,7 @@ import {
interface ILinkCellProps {
title?: string;
+ disableTooltip?: boolean;
to?: string;
onClick?: () => void;
subtitle?: string;
@@ -22,6 +23,7 @@ interface ILinkCellProps {
export const LinkCell: React.FC = ({
title,
+ disableTooltip,
to,
onClick,
subtitle,
@@ -29,17 +31,26 @@ export const LinkCell: React.FC = ({
}) => {
const { searchQuery } = useSearchHighlightContext();
+ const subtitleContent = (
+
+ {subtitle}
+
+ );
const renderSubtitle = (
40)}
show={
-
-
-
- {subtitle}
-
-
-
+ !disableTooltip ? (
+
+ {subtitleContent}
+
+ ) : (
+ subtitleContent
+ )
}
elseShow={
diff --git a/frontend/src/component/common/Truncator/Truncator.tsx b/frontend/src/component/common/Truncator/Truncator.tsx
index 683522b5fbbe..a8532170a3ea 100644
--- a/frontend/src/component/common/Truncator/Truncator.tsx
+++ b/frontend/src/component/common/Truncator/Truncator.tsx
@@ -51,7 +51,10 @@ export const Truncator = ({
const checkTruncation = () => {
if (ref.current) {
- setIsTruncated(ref.current.scrollHeight > ref.current.offsetHeight);
+ setIsTruncated(
+ ref.current.scrollHeight > ref.current.offsetHeight ||
+ ref.current.scrollWidth > ref.current.offsetWidth,
+ );
}
};
useEffect(() => {
diff --git a/frontend/src/component/context/ContextList/AddContextButton.tsx b/frontend/src/component/context/ContextList/AddContextButton.tsx
index 9bbb8921e130..748059483a9e 100644
--- a/frontend/src/component/context/ContextList/AddContextButton.tsx
+++ b/frontend/src/component/context/ContextList/AddContextButton.tsx
@@ -8,7 +8,7 @@ import type { FC } from 'react';
import { useOptionalPathParam } from 'hooks/useOptionalPathParam.ts';
import {
CREATE_CONTEXT_FIELD,
- UPDATE_PROJECT,
+ UPDATE_PROJECT_CONTEXT,
} from '@server/types/permissions.ts';
type IAddContextButtonProps = {};
@@ -27,7 +27,7 @@ export const AddContextButton: FC = () => {
condition={smallScreen}
show={
navigate(createLocation)}
size='large'
@@ -38,7 +38,7 @@ export const AddContextButton: FC = () => {
}
elseShow={
navigate(createLocation)}
color='primary'
diff --git a/frontend/src/component/context/ContextList/ContextActionsCell.tsx b/frontend/src/component/context/ContextList/ContextActionsCell.tsx
index 08468faa7229..6b121883a8f7 100644
--- a/frontend/src/component/context/ContextList/ContextActionsCell.tsx
+++ b/frontend/src/component/context/ContextList/ContextActionsCell.tsx
@@ -8,7 +8,7 @@ import { useOptionalPathParam } from 'hooks/useOptionalPathParam.ts';
import {
DELETE_CONTEXT_FIELD,
UPDATE_CONTEXT_FIELD,
- UPDATE_PROJECT,
+ UPDATE_PROJECT_CONTEXT,
} from '@server/types/permissions.ts';
interface IContextActionsCellProps {
@@ -31,7 +31,7 @@ export const ContextActionsCell: FC = ({
return (
navigate(updateLocation)}
data-loading
@@ -43,7 +43,7 @@ export const ContextActionsCell: FC = ({
void;
@@ -105,7 +105,7 @@ export const CreateUnleashContext = ({
>
diff --git a/frontend/src/component/context/EditContext/EditContext.tsx b/frontend/src/component/context/EditContext/EditContext.tsx
index d12da33ea1c4..6bb011db625b 100644
--- a/frontend/src/component/context/EditContext/EditContext.tsx
+++ b/frontend/src/component/context/EditContext/EditContext.tsx
@@ -14,7 +14,7 @@ import { useContextForm } from '../hooks/useContextForm.ts';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { GO_BACK } from 'constants/navigate';
import { useOptionalPathParam } from 'hooks/useOptionalPathParam.ts';
-import { UPDATE_PROJECT } from '@server/types/permissions.ts';
+import { UPDATE_PROJECT_CONTEXT } from '@server/types/permissions.ts';
type EditContextProps = {
modal?: boolean;
@@ -117,7 +117,7 @@ export const EditContext: FC = ({ modal }) => {
clearErrors={clearErrors}
>
diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/EditableConstraint.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/EditableConstraint.tsx
index 73670578e1b9..84df2cbc8e0b 100644
--- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/EditableConstraint.tsx
+++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/EditableConstraint.tsx
@@ -31,6 +31,8 @@ import {
import type { ConstraintValidationResult } from './useEditableConstraint/constraint-validator.ts';
import { useEffectiveProjectContext } from 'hooks/api/getters/useUnleashContext/useEffectiveProjectContext.ts';
import { useOptionalPathParam } from 'hooks/useOptionalPathParam.ts';
+import { useUiFlag } from 'hooks/useUiFlag.ts';
+import { createContextFieldOptions } from './createContextFieldOptions.ts';
const Container = styled('article')(({ theme }) => ({
'--padding': theme.spacing(2),
@@ -219,6 +221,7 @@ export const EditableConstraint: FC = ({
constraint,
onUpdate,
}) => {
+ const groupContextFieldOptionsByType = useUiFlag('projectContextFields');
const {
constraint: localConstraint,
updateConstraint,
@@ -263,20 +266,12 @@ export const EditableConstraint: FC = ({
return null;
}
- const extantContextFieldNames = context.map((context) => context.name);
- const contextFieldHasBeenDeleted = !extantContextFieldNames.includes(
- localConstraint.contextName,
+ const contextFieldOptions = createContextFieldOptions(
+ localConstraint,
+ context,
+ { groupOptions: groupContextFieldOptionsByType },
);
- const availableContextFieldNames = contextFieldHasBeenDeleted
- ? [...extantContextFieldNames, localConstraint.contextName].toSorted()
- : extantContextFieldNames;
-
- const contextFieldOptions = availableContextFieldNames.map((option) => ({
- key: option,
- label: option,
- }));
-
return (
diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/createContextFieldOptions.test.ts b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/createContextFieldOptions.test.ts
new file mode 100644
index 000000000000..5358caa96e0b
--- /dev/null
+++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/createContextFieldOptions.test.ts
@@ -0,0 +1,125 @@
+import { IN } from 'constants/operators';
+import { createContextFieldOptions } from './createContextFieldOptions';
+
+const contextField = (name: string, project?: string) => ({
+ name,
+ description: '',
+ createdAt: '',
+ sortOrder: 0,
+ stickiness: false,
+ project,
+});
+
+it('groups options by project and global', () => {
+ const localConstraint = {
+ contextName: 'a',
+ operator: IN,
+ values: new Set(),
+ };
+ const context = [
+ contextField('a'),
+ contextField('b'),
+ contextField('c', 'project1'),
+ contextField('d', 'project1'),
+ ];
+
+ const options = createContextFieldOptions(localConstraint, context, {
+ groupOptions: true,
+ });
+
+ expect(options).toEqual([
+ {
+ groupHeader: 'Project context fields',
+ options: [
+ { key: 'c', label: 'c' },
+ { key: 'd', label: 'd' },
+ ],
+ },
+ {
+ groupHeader: 'Global context fields',
+ options: [
+ { key: 'a', label: 'a' },
+ { key: 'b', label: 'b' },
+ ],
+ },
+ ]);
+});
+
+it('does not include empty groups', () => {
+ const localConstraint = {
+ contextName: 'a',
+ operator: IN,
+ values: new Set(),
+ };
+ const onlyGlobalContext = [contextField('a')];
+ const onlyProjectContext = [contextField('a', 'project1')];
+
+ const onlyGlobalOptions = createContextFieldOptions(
+ localConstraint,
+ onlyGlobalContext,
+ { groupOptions: true },
+ );
+
+ expect(onlyGlobalOptions).toEqual([
+ {
+ groupHeader: 'Global context fields',
+ options: [{ key: 'a', label: 'a' }],
+ },
+ ]);
+
+ const onlyProjectOptions = createContextFieldOptions(
+ localConstraint,
+ onlyProjectContext,
+ { groupOptions: true },
+ );
+
+ expect(onlyProjectOptions).toEqual([
+ {
+ groupHeader: 'Project context fields',
+ options: [{ key: 'a', label: 'a' }],
+ },
+ ]);
+});
+
+it('puts deleted context fields in its own group', () => {
+ const localConstraint = {
+ contextName: 'a',
+ operator: IN,
+ values: new Set(),
+ };
+ const onlyGlobalContext = [contextField('b')];
+
+ const options = createContextFieldOptions(
+ localConstraint,
+ onlyGlobalContext,
+ { groupOptions: true },
+ );
+
+ expect(options).toEqual([
+ {
+ groupHeader: 'Global context fields',
+ options: [{ key: 'b', label: 'b' }],
+ },
+ {
+ groupHeader: 'Deleted context fields',
+ options: [{ key: 'a', label: 'a' }],
+ },
+ ]);
+});
+it('groups options only when asked', () => {
+ const localConstraint = {
+ contextName: 'a',
+ operator: IN,
+ values: new Set(),
+ };
+ const context = [contextField('a'), contextField('b', 'project1')];
+
+ const options = createContextFieldOptions(localConstraint, context, {
+ groupOptions: false,
+ });
+
+ expect(options).toEqual([
+ { key: 'a', label: 'a' },
+ { key: 'b', label: 'b' },
+ ]);
+});
diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/createContextFieldOptions.ts b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/createContextFieldOptions.ts
new file mode 100644
index 000000000000..28f181ab4dbd
--- /dev/null
+++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/createContextFieldOptions.ts
@@ -0,0 +1,66 @@
+import type { IUnleashContextDefinition } from 'interfaces/context.ts';
+import type { EditableConstraint } from './useEditableConstraint/editable-constraint-type';
+import type { SelectOptionGroup } from 'component/common/GeneralSelect/GeneralSelect';
+
+export const createContextFieldOptions = (
+ localConstraint: EditableConstraint,
+ context: IUnleashContextDefinition[],
+ { groupOptions }: { groupOptions: boolean },
+) => {
+ const existingContextFieldNames = context.map((context) => context.name);
+ const contextFieldHasBeenDeleted = !existingContextFieldNames.includes(
+ localConstraint.contextName,
+ );
+
+ if (!groupOptions) {
+ const availableContextFieldNames = contextFieldHasBeenDeleted
+ ? [
+ ...existingContextFieldNames,
+ localConstraint.contextName,
+ ].toSorted()
+ : existingContextFieldNames;
+
+ return availableContextFieldNames.map((option) => ({
+ key: option,
+ label: option,
+ }));
+ }
+
+ const fields = context.reduce(
+ ({ project, global }, next) => {
+ if (next.project) {
+ project.push(next);
+ } else {
+ global.push(next);
+ }
+ return { project: project, global: global };
+ },
+ {
+ project: [] as IUnleashContextDefinition[],
+ global: [] as IUnleashContextDefinition[],
+ },
+ );
+
+ const optList = (opts: { name: string }[]) =>
+ opts
+ .toSorted((a, b) => a.name.localeCompare(b.name))
+ .map((option) => ({
+ key: option.name,
+ label: option.name,
+ }));
+
+ return [
+ fields.project.length > 0 && {
+ groupHeader: 'Project context fields',
+ options: optList(fields.project),
+ },
+ fields.global.length > 0 && {
+ groupHeader: 'Global context fields',
+ options: optList(fields.global),
+ },
+ contextFieldHasBeenDeleted && {
+ groupHeader: 'Deleted context fields',
+ options: optList([{ name: localConstraint.contextName }]),
+ },
+ ].filter(Boolean) as SelectOptionGroup[];
+};
diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/ReleaseTemplatesFeedback/ReleaseTemplatesFeedback.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/ReleaseTemplatesFeedback/ReleaseTemplatesFeedback.tsx
deleted file mode 100644
index 6444b65e52a6..000000000000
--- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/ReleaseTemplatesFeedback/ReleaseTemplatesFeedback.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import type { FC } from 'react';
-import { styled, Link } from '@mui/material';
-import type { Link as RouterLink } from 'react-router-dom';
-import { RELEASE_TEMPLATE_FEEDBACK } from 'constants/links';
-import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
-
-const StyledLink = styled(Link)(({ theme }) => ({
- display: 'flex',
- alignItems: 'center',
- gap: theme.spacing(1),
- color: theme.palette.links,
- fontWeight: theme.typography.fontWeightMedium,
- textDecoration: 'none',
- marginRight: 'auto',
-}));
-
-export const ReleaseTemplatesFeedback: FC = () => {
- const { isEnterprise } = useUiConfig();
-
- if (!isEnterprise()) {
- return null;
- }
-
- return (
-
- Give feedback to release templates
-
- );
-};
diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.test.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.test.tsx
index 10a724c5b4b3..488a01dc7c1e 100644
--- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.test.tsx
+++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.test.tsx
@@ -96,7 +96,7 @@ test('Filter table by project', async () => {
await screen.findByPlaceholderText(/Search/);
await screen.getByRole('button', {
- name: 'Filter',
+ name: 'Add filter',
});
await Promise.all(
@@ -109,7 +109,7 @@ test('Filter table by project', async () => {
setupNoFeaturesReturned();
- const addFilterButton = screen.getByText('Filter');
+ const addFilterButton = screen.getByText('Add filter');
addFilterButton.click();
const projectItem = await screen.findByRole('menuitem', {
diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx
index 11dc987067ef..54e3f20209a9 100644
--- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx
+++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx
@@ -68,6 +68,7 @@ export const FeatureToggleListTable: FC = () => {
.map((env) => env.name);
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg'));
+ const isLargeScreen = useMediaQuery(theme.breakpoints.down('xl'));
const [showExportDialog, setShowExportDialog] = useState(false);
const { setToastApiError } = useToast();
@@ -301,9 +302,8 @@ export const FeatureToggleListTable: FC = () => {
- {!isSmallScreen ? (
+ {!isLargeScreen ? (
{
onChange={setTableState}
state={filterState}
/>
- {isSmallScreen ? (
+ {isLargeScreen ? (
({ padding: theme.spacing(0, 3, 3) })}>
{
,
);
- expect(getByText('All flags')).toBeInTheDocument();
+ expect(getByText('All lifecycles')).toBeInTheDocument();
expect(getByText('Develop')).toBeInTheDocument();
expect(getByText('Rollout production')).toBeInTheDocument();
expect(getByText('Cleanup')).toBeInTheDocument();
@@ -47,34 +47,14 @@ describe('LifecycleFilters', () => {
,
);
- expect(getByText('All flags (10)')).toBeInTheDocument();
- expect(getByText('Develop (2)')).toBeInTheDocument();
- expect(getByText('Rollout production (3)')).toBeInTheDocument();
- expect(getByText('Cleanup (4)')).toBeInTheDocument();
- });
-
- it('renders dynamic label when total matches count', () => {
- const total = 3;
- const { getByText } = render(
- ,
- );
- expect(getByText('Rollout production (3)')).toBeInTheDocument();
- });
-
- it('renders dynamic label when total does not match count', () => {
- const total = 2;
- const { getByText } = render(
- ,
- );
- expect(getByText('Rollout production (2 of 3)')).toBeInTheDocument();
+ expect(getByText('All lifecycles')).toBeInTheDocument();
+ expect(getByText('10')).toBeInTheDocument();
+ expect(getByText('Develop')).toBeInTheDocument();
+ expect(getByText('2')).toBeInTheDocument();
+ expect(getByText('Rollout production')).toBeInTheDocument();
+ expect(getByText('3')).toBeInTheDocument();
+ expect(getByText('Cleanup')).toBeInTheDocument();
+ expect(getByText('4')).toBeInTheDocument();
});
it('will apply a correct filter for each stage', async () => {
@@ -83,22 +63,22 @@ describe('LifecycleFilters', () => {
,
);
- await userEvent.click(getByText('Develop (2)'));
+ await userEvent.click(getByText('Develop'));
expect(onChange).toHaveBeenCalledWith({
lifecycle: { operator: 'IS', values: ['pre-live'] },
});
- await userEvent.click(getByText('Rollout production (3)'));
+ await userEvent.click(getByText('Rollout production'));
expect(onChange).toHaveBeenCalledWith({
lifecycle: { operator: 'IS', values: ['live'] },
});
- await userEvent.click(getByText('Cleanup (4)'));
+ await userEvent.click(getByText('Cleanup'));
expect(onChange).toHaveBeenCalledWith({
lifecycle: { operator: 'IS', values: ['completed'] },
});
- await userEvent.click(getByText('All flags (10)'));
+ await userEvent.click(getByText('All lifecycles'));
expect(onChange).toHaveBeenCalledWith({ lifecycle: null });
});
});
diff --git a/frontend/src/component/feature/FeatureToggleList/FeaturesOverviewLifecycleFilters/FeaturesOverviewLifecycleFilters.tsx b/frontend/src/component/feature/FeatureToggleList/FeaturesOverviewLifecycleFilters/FeaturesOverviewLifecycleFilters.tsx
index b0cef112751f..5e55630a4d0e 100644
--- a/frontend/src/component/feature/FeatureToggleList/FeaturesOverviewLifecycleFilters/FeaturesOverviewLifecycleFilters.tsx
+++ b/frontend/src/component/feature/FeatureToggleList/FeaturesOverviewLifecycleFilters/FeaturesOverviewLifecycleFilters.tsx
@@ -7,13 +7,12 @@ import { LifecycleFilters } from 'component/common/LifecycleFilters/LifecycleFil
type FeaturesOverviewLifecycleFiltersProps = {
state: FilterItemParamHolder;
onChange: (value: FilterItemParamHolder) => void;
- total?: number;
children?: ReactNode;
};
export const FeaturesOverviewLifecycleFilters: FC<
FeaturesOverviewLifecycleFiltersProps
-> = ({ state, onChange, total, children }) => {
+> = ({ state, onChange, children }) => {
const { lifecycleCount } = useLifecycleCount();
const countData = Object.entries(lifecycleCount || {}).reduce(
(acc, [key, value]) => {
@@ -28,7 +27,6 @@ export const FeaturesOverviewLifecycleFilters: FC<
{children}
diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx
index 6fe0306615b5..f689898bfa1b 100644
--- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx
+++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx
@@ -18,7 +18,6 @@ import { useState } from 'react';
import type { IReleasePlan } from 'interfaces/releasePlans';
import { EnvironmentAccordionBody } from './EnvironmentAccordionBody/EnvironmentAccordionBody.tsx';
import { Box } from '@mui/material';
-import { ReleaseTemplatesFeedback } from 'component/feature/FeatureStrategy/FeatureStrategyMenu/ReleaseTemplatesFeedback/ReleaseTemplatesFeedback';
const StyledFeatureOverviewEnvironment = styled('div')(({ theme }) => ({
borderRadius: theme.shape.borderRadiusLarge,
@@ -131,7 +130,6 @@ export const FeatureOverviewEnvironment = ({
-
{
return (
diff --git a/frontend/src/component/filter/AddFilterButton.tsx b/frontend/src/component/filter/AddFilterButton.tsx
index b58cb98214ed..b779ebc7b2a0 100644
--- a/frontend/src/component/filter/AddFilterButton.tsx
+++ b/frontend/src/component/filter/AddFilterButton.tsx
@@ -57,7 +57,7 @@ export const AddFilterButton = ({
return (
}>
- Filter
+ Add filter