mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	Merge remote-tracking branch 'origin/task/Add_strategy_information_to_playground_results' into task/Add_strategy_information_to_playground_results
This commit is contained in:
		
						commit
						4157de0230
					
				
							
								
								
									
										25
									
								
								frontend/.github/workflows/e2e.groups.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								frontend/.github/workflows/e2e.groups.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -0,0 +1,25 @@
 | 
			
		||||
name: e2e:groups
 | 
			
		||||
# https://docs.github.com/en/actions/reference/events-that-trigger-workflows
 | 
			
		||||
on: [deployment_status]
 | 
			
		||||
jobs:
 | 
			
		||||
  e2e:
 | 
			
		||||
    # only runs this job on successful deploy
 | 
			
		||||
    if: github.event_name == 'deployment_status' && github.event.deployment_status.state == 'success'
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Dump GitHub context
 | 
			
		||||
        env:
 | 
			
		||||
          GITHUB_CONTEXT: ${{ toJson(github) }}
 | 
			
		||||
        run: |
 | 
			
		||||
          echo "$GITHUB_CONTEXT"
 | 
			
		||||
      - name: Checkout
 | 
			
		||||
        uses: actions/checkout@v3
 | 
			
		||||
      - name: Run Cypress
 | 
			
		||||
        uses: cypress-io/github-action@v2
 | 
			
		||||
        with:
 | 
			
		||||
          env: AUTH_USER=admin,AUTH_PASSWORD=unleash4all
 | 
			
		||||
          config: baseUrl=${{ github.event.deployment_status.target_url }}
 | 
			
		||||
          record: true
 | 
			
		||||
          spec: cypress/integration/groups/groups.spec.ts
 | 
			
		||||
        env:
 | 
			
		||||
          CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
 | 
			
		||||
@ -110,7 +110,7 @@ describe('feature', () => {
 | 
			
		||||
                expect(req.body.name).to.equal('flexibleRollout');
 | 
			
		||||
                expect(req.body.parameters.groupId).to.equal(featureToggleName);
 | 
			
		||||
                expect(req.body.parameters.stickiness).to.equal('default');
 | 
			
		||||
                expect(req.body.parameters.rollout).to.equal('100');
 | 
			
		||||
                expect(req.body.parameters.rollout).to.equal('50');
 | 
			
		||||
 | 
			
		||||
                if (ENTERPRISE) {
 | 
			
		||||
                    expect(req.body.constraints.length).to.equal(1);
 | 
			
		||||
@ -151,7 +151,7 @@ describe('feature', () => {
 | 
			
		||||
            req => {
 | 
			
		||||
                expect(req.body.parameters.groupId).to.equal('new-group-id');
 | 
			
		||||
                expect(req.body.parameters.stickiness).to.equal('sessionId');
 | 
			
		||||
                expect(req.body.parameters.rollout).to.equal('100');
 | 
			
		||||
                expect(req.body.parameters.rollout).to.equal('50');
 | 
			
		||||
 | 
			
		||||
                if (ENTERPRISE) {
 | 
			
		||||
                    expect(req.body.constraints.length).to.equal(1);
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										160
									
								
								frontend/cypress/integration/groups/groups.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										160
									
								
								frontend/cypress/integration/groups/groups.spec.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,160 @@
 | 
			
		||||
/// <reference types="cypress" />
 | 
			
		||||
 | 
			
		||||
export {};
 | 
			
		||||
const baseUrl = Cypress.config().baseUrl;
 | 
			
		||||
const randomId = String(Math.random()).split('.')[1];
 | 
			
		||||
const groupName = `unleash-e2e-${randomId}`;
 | 
			
		||||
const userIds: any[] = [];
 | 
			
		||||
 | 
			
		||||
// Disable all active splash pages by visiting them.
 | 
			
		||||
const disableActiveSplashScreens = () => {
 | 
			
		||||
    cy.visit(`/splash/operators`);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
describe('groups', () => {
 | 
			
		||||
    before(() => {
 | 
			
		||||
        disableActiveSplashScreens();
 | 
			
		||||
        cy.login();
 | 
			
		||||
        for (let i = 1; i <= 2; i++) {
 | 
			
		||||
            cy.request('POST', `${baseUrl}/api/admin/user-admin`, {
 | 
			
		||||
                name: `unleash-e2e-user${i}-${randomId}`,
 | 
			
		||||
                email: `unleash-e2e-user${i}-${randomId}@test.com`,
 | 
			
		||||
                sendEmail: false,
 | 
			
		||||
                rootRole: 3,
 | 
			
		||||
            }).then(response => userIds.push(response.body.id));
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    after(() => {
 | 
			
		||||
        userIds.forEach(id =>
 | 
			
		||||
            cy.request('DELETE', `${baseUrl}/api/admin/user-admin/${id}`)
 | 
			
		||||
        );
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    beforeEach(() => {
 | 
			
		||||
        cy.login();
 | 
			
		||||
        cy.visit('/admin/groups');
 | 
			
		||||
        if (document.querySelector("[data-testid='CLOSE_SPLASH']")) {
 | 
			
		||||
            cy.get("[data-testid='CLOSE_SPLASH']").click();
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('gives an error if a group does not have an owner', () => {
 | 
			
		||||
        cy.get("[data-testid='NAVIGATE_TO_CREATE_GROUP']").click();
 | 
			
		||||
 | 
			
		||||
        cy.intercept('POST', '/api/admin/groups').as('createGroup');
 | 
			
		||||
 | 
			
		||||
        cy.get("[data-testid='UG_NAME_ID']").type(groupName);
 | 
			
		||||
        cy.get("[data-testid='UG_DESC_ID']").type('hello-world');
 | 
			
		||||
        cy.get("[data-testid='UG_USERS_ID']").click();
 | 
			
		||||
        cy.contains(`unleash-e2e-user1-${randomId}`).click();
 | 
			
		||||
        cy.get("[data-testid='UG_USERS_ADD_ID']").click();
 | 
			
		||||
 | 
			
		||||
        cy.get("[data-testid='UG_CREATE_BTN_ID']").click();
 | 
			
		||||
        cy.get("[data-testid='TOAST_TEXT']").contains(
 | 
			
		||||
            'Group needs to have at least one Owner'
 | 
			
		||||
        );
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('can create a group', () => {
 | 
			
		||||
        cy.get("[data-testid='NAVIGATE_TO_CREATE_GROUP']").click();
 | 
			
		||||
 | 
			
		||||
        cy.intercept('POST', '/api/admin/groups').as('createGroup');
 | 
			
		||||
 | 
			
		||||
        cy.get("[data-testid='UG_NAME_ID']").type(groupName);
 | 
			
		||||
        cy.get("[data-testid='UG_DESC_ID']").type('hello-world');
 | 
			
		||||
        cy.get("[data-testid='UG_USERS_ID']").click();
 | 
			
		||||
        cy.contains(`unleash-e2e-user1-${randomId}`).click();
 | 
			
		||||
        cy.get("[data-testid='UG_USERS_ADD_ID']").click();
 | 
			
		||||
        cy.get("[data-testid='UG_USERS_TABLE_ROLE_ID']").click();
 | 
			
		||||
        cy.contains('Owner').click();
 | 
			
		||||
 | 
			
		||||
        cy.get("[data-testid='UG_CREATE_BTN_ID']").click();
 | 
			
		||||
        cy.wait('@createGroup');
 | 
			
		||||
        cy.contains(groupName);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('gives an error if a group exists with the same name', () => {
 | 
			
		||||
        cy.get("[data-testid='NAVIGATE_TO_CREATE_GROUP']").click();
 | 
			
		||||
 | 
			
		||||
        cy.intercept('POST', '/api/admin/groups').as('createGroup');
 | 
			
		||||
 | 
			
		||||
        cy.get("[data-testid='UG_NAME_ID']").type(groupName);
 | 
			
		||||
        cy.get("[data-testid='UG_DESC_ID']").type('hello-world');
 | 
			
		||||
        cy.get("[data-testid='UG_USERS_ID']").click();
 | 
			
		||||
        cy.contains(`unleash-e2e-user1-${randomId}`).click();
 | 
			
		||||
        cy.get("[data-testid='UG_USERS_ADD_ID']").click();
 | 
			
		||||
        cy.get("[data-testid='UG_USERS_TABLE_ROLE_ID']").click();
 | 
			
		||||
        cy.contains('Owner').click();
 | 
			
		||||
 | 
			
		||||
        cy.get("[data-testid='UG_CREATE_BTN_ID']").click();
 | 
			
		||||
        cy.get("[data-testid='TOAST_TEXT']").contains(
 | 
			
		||||
            'Group name already exists'
 | 
			
		||||
        );
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('can edit a group', () => {
 | 
			
		||||
        cy.contains(groupName).click();
 | 
			
		||||
 | 
			
		||||
        cy.get("[data-testid='UG_EDIT_BTN_ID']").click();
 | 
			
		||||
 | 
			
		||||
        cy.get("[data-testid='UG_DESC_ID']").type('-my edited description');
 | 
			
		||||
 | 
			
		||||
        cy.get("[data-testid='UG_SAVE_BTN_ID']").click();
 | 
			
		||||
 | 
			
		||||
        cy.contains('hello-world-my edited description');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('can add user to a group', () => {
 | 
			
		||||
        cy.contains(groupName).click();
 | 
			
		||||
 | 
			
		||||
        cy.get("[data-testid='UG_ADD_USER_BTN_ID']").click();
 | 
			
		||||
 | 
			
		||||
        cy.get("[data-testid='UG_USERS_ID']").click();
 | 
			
		||||
        cy.contains(`unleash-e2e-user2-${randomId}`).click();
 | 
			
		||||
        cy.get("[data-testid='UG_USERS_ADD_ID']").click();
 | 
			
		||||
 | 
			
		||||
        cy.get("[data-testid='UG_SAVE_BTN_ID']").click();
 | 
			
		||||
 | 
			
		||||
        cy.contains(`unleash-e2e-user1-${randomId}`);
 | 
			
		||||
        cy.contains(`unleash-e2e-user2-${randomId}`);
 | 
			
		||||
        cy.get("td span:contains('Owner')").should('have.length', 1);
 | 
			
		||||
        cy.get("td span:contains('Member')").should('have.length', 1);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('can edit user role in a group', () => {
 | 
			
		||||
        cy.contains(groupName).click();
 | 
			
		||||
 | 
			
		||||
        cy.get(`[data-testid='UG_EDIT_USER_BTN_ID-${userIds[1]}']`).click();
 | 
			
		||||
 | 
			
		||||
        cy.get("[data-testid='UG_USERS_ROLE_ID']").click();
 | 
			
		||||
        cy.get("li[data-value='Owner']").click();
 | 
			
		||||
 | 
			
		||||
        cy.get("[data-testid='UG_SAVE_BTN_ID']").click();
 | 
			
		||||
 | 
			
		||||
        cy.contains(`unleash-e2e-user1-${randomId}`);
 | 
			
		||||
        cy.contains(`unleash-e2e-user2-${randomId}`);
 | 
			
		||||
        cy.get("td span:contains('Owner')").should('have.length', 2);
 | 
			
		||||
        cy.contains('Member').should('not.exist');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('can remove user from a group', () => {
 | 
			
		||||
        cy.contains(groupName).click();
 | 
			
		||||
 | 
			
		||||
        cy.get(`[data-testid='UG_REMOVE_USER_BTN_ID-${userIds[1]}']`).click();
 | 
			
		||||
 | 
			
		||||
        cy.get("[data-testid='DIALOGUE_CONFIRM_ID'").click();
 | 
			
		||||
 | 
			
		||||
        cy.contains(`unleash-e2e-user1-${randomId}`);
 | 
			
		||||
        cy.contains(`unleash-e2e-user2-${randomId}`).should('not.exist');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('can delete a group', () => {
 | 
			
		||||
        cy.contains(groupName).click();
 | 
			
		||||
 | 
			
		||||
        cy.get("[data-testid='UG_DELETE_BTN_ID']").click();
 | 
			
		||||
        cy.get("[data-testid='DIALOGUE_CONFIRM_ID'").click();
 | 
			
		||||
 | 
			
		||||
        cy.contains(groupName).should('not.exist');
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
@ -33,6 +33,7 @@ import {
 | 
			
		||||
    StyledButtonSection,
 | 
			
		||||
} from './AddonForm.styles';
 | 
			
		||||
import { useTheme } from '@mui/system';
 | 
			
		||||
import { GO_BACK } from 'constants/navigate';
 | 
			
		||||
 | 
			
		||||
interface IAddonFormProps {
 | 
			
		||||
    provider?: IAddonProvider;
 | 
			
		||||
@ -168,7 +169,7 @@ export const AddonForm: VFC<IAddonFormProps> = ({
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const onCancel = () => {
 | 
			
		||||
        navigate(-1);
 | 
			
		||||
        navigate(GO_BACK);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const onSubmit: FormEventHandler<HTMLFormElement> = async event => {
 | 
			
		||||
 | 
			
		||||
@ -12,6 +12,7 @@ import { useState } from 'react';
 | 
			
		||||
import { scrollToTop } from 'component/common/util';
 | 
			
		||||
import { formatUnknownError } from 'utils/formatUnknownError';
 | 
			
		||||
import { usePageTitle } from 'hooks/usePageTitle';
 | 
			
		||||
import { GO_BACK } from 'constants/navigate';
 | 
			
		||||
 | 
			
		||||
const pageTitle = 'Create API token';
 | 
			
		||||
 | 
			
		||||
@ -75,7 +76,7 @@ export const CreateApiToken = () => {
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleCancel = () => {
 | 
			
		||||
        navigate(-1);
 | 
			
		||||
        navigate(GO_BACK);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
 | 
			
		||||
@ -9,6 +9,7 @@ import { formatUnknownError } from 'utils/formatUnknownError';
 | 
			
		||||
import { UG_CREATE_BTN_ID } from 'utils/testIds';
 | 
			
		||||
import { Button } from '@mui/material';
 | 
			
		||||
import { CREATE } from 'constants/misc';
 | 
			
		||||
import { GO_BACK } from 'constants/navigate';
 | 
			
		||||
 | 
			
		||||
export const CreateGroup = () => {
 | 
			
		||||
    const { setToastData, setToastApiError } = useToast();
 | 
			
		||||
@ -58,7 +59,7 @@ export const CreateGroup = () => {
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleCancel = () => {
 | 
			
		||||
        navigate(-1);
 | 
			
		||||
        navigate(GO_BACK);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
 | 
			
		||||
@ -10,6 +10,8 @@ import { Button } from '@mui/material';
 | 
			
		||||
import { EDIT } from 'constants/misc';
 | 
			
		||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
 | 
			
		||||
import { useGroup } from 'hooks/api/getters/useGroup/useGroup';
 | 
			
		||||
import { UG_SAVE_BTN_ID } from 'utils/testIds';
 | 
			
		||||
import { GO_BACK } from 'constants/navigate';
 | 
			
		||||
 | 
			
		||||
export const EditGroup = () => {
 | 
			
		||||
    const groupId = Number(useRequiredPathParam('groupId'));
 | 
			
		||||
@ -40,7 +42,7 @@ export const EditGroup = () => {
 | 
			
		||||
        try {
 | 
			
		||||
            await updateGroup(groupId, payload);
 | 
			
		||||
            refetchGroup();
 | 
			
		||||
            navigate(-1);
 | 
			
		||||
            navigate(GO_BACK);
 | 
			
		||||
            setToastData({
 | 
			
		||||
                title: 'Group updated successfully',
 | 
			
		||||
                type: 'success',
 | 
			
		||||
@ -60,7 +62,7 @@ export const EditGroup = () => {
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleCancel = () => {
 | 
			
		||||
        navigate(-1);
 | 
			
		||||
        navigate(GO_BACK);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
@ -85,7 +87,12 @@ export const EditGroup = () => {
 | 
			
		||||
                mode={EDIT}
 | 
			
		||||
                clearErrors={clearErrors}
 | 
			
		||||
            >
 | 
			
		||||
                <Button type="submit" variant="contained" color="primary">
 | 
			
		||||
                <Button
 | 
			
		||||
                    type="submit"
 | 
			
		||||
                    variant="contained"
 | 
			
		||||
                    color="primary"
 | 
			
		||||
                    data-testid={UG_SAVE_BTN_ID}
 | 
			
		||||
                >
 | 
			
		||||
                    Save
 | 
			
		||||
                </Button>
 | 
			
		||||
            </GroupForm>
 | 
			
		||||
 | 
			
		||||
@ -10,6 +10,7 @@ import { FC, FormEvent, useEffect, useMemo, useState } from 'react';
 | 
			
		||||
import { formatUnknownError } from 'utils/formatUnknownError';
 | 
			
		||||
import { GroupFormUsersSelect } from 'component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect';
 | 
			
		||||
import { GroupFormUsersTable } from 'component/admin/groups/GroupForm/GroupFormUsersTable/GroupFormUsersTable';
 | 
			
		||||
import { UG_SAVE_BTN_ID } from 'utils/testIds';
 | 
			
		||||
 | 
			
		||||
const StyledForm = styled('form')(() => ({
 | 
			
		||||
    display: 'flex',
 | 
			
		||||
@ -142,6 +143,7 @@ export const AddGroupUser: FC<IAddGroupUserProps> = ({
 | 
			
		||||
                            type="submit"
 | 
			
		||||
                            variant="contained"
 | 
			
		||||
                            color="primary"
 | 
			
		||||
                            data-testid={UG_SAVE_BTN_ID}
 | 
			
		||||
                        >
 | 
			
		||||
                            Save
 | 
			
		||||
                        </Button>
 | 
			
		||||
 | 
			
		||||
@ -8,6 +8,7 @@ import useToast from 'hooks/useToast';
 | 
			
		||||
import { IGroup, IGroupUser, Role } from 'interfaces/group';
 | 
			
		||||
import { FC, FormEvent, useEffect, useMemo, useState } from 'react';
 | 
			
		||||
import { formatUnknownError } from 'utils/formatUnknownError';
 | 
			
		||||
import { UG_SAVE_BTN_ID, UG_USERS_ROLE_ID } from 'utils/testIds';
 | 
			
		||||
 | 
			
		||||
const StyledForm = styled('form')(() => ({
 | 
			
		||||
    display: 'flex',
 | 
			
		||||
@ -143,6 +144,7 @@ export const EditGroupUser: FC<IEditGroupUserProps> = ({
 | 
			
		||||
                            Assign the role the user should have in this group
 | 
			
		||||
                        </StyledInputDescription>
 | 
			
		||||
                        <StyledSelect
 | 
			
		||||
                            data-testid={UG_USERS_ROLE_ID}
 | 
			
		||||
                            size="small"
 | 
			
		||||
                            value={role}
 | 
			
		||||
                            onChange={event =>
 | 
			
		||||
@ -159,6 +161,7 @@ export const EditGroupUser: FC<IEditGroupUserProps> = ({
 | 
			
		||||
 | 
			
		||||
                    <StyledButtonContainer>
 | 
			
		||||
                        <Button
 | 
			
		||||
                            data-testid={UG_SAVE_BTN_ID}
 | 
			
		||||
                            type="submit"
 | 
			
		||||
                            variant="contained"
 | 
			
		||||
                            color="primary"
 | 
			
		||||
 | 
			
		||||
@ -37,6 +37,13 @@ import { AddGroupUser } from './AddGroupUser/AddGroupUser';
 | 
			
		||||
import { EditGroupUser } from './EditGroupUser/EditGroupUser';
 | 
			
		||||
import { RemoveGroupUser } from './RemoveGroupUser/RemoveGroupUser';
 | 
			
		||||
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
 | 
			
		||||
import {
 | 
			
		||||
    UG_EDIT_BTN_ID,
 | 
			
		||||
    UG_DELETE_BTN_ID,
 | 
			
		||||
    UG_ADD_USER_BTN_ID,
 | 
			
		||||
    UG_EDIT_USER_BTN_ID,
 | 
			
		||||
    UG_REMOVE_USER_BTN_ID,
 | 
			
		||||
} from 'utils/testIds';
 | 
			
		||||
 | 
			
		||||
const StyledEdit = styled(Edit)(({ theme }) => ({
 | 
			
		||||
    fontSize: theme.fontSizes.mainHeader,
 | 
			
		||||
@ -134,6 +141,7 @@ export const Group: VFC = () => {
 | 
			
		||||
                    <ActionCell>
 | 
			
		||||
                        <Tooltip title="Edit user" arrow describeChild>
 | 
			
		||||
                            <IconButton
 | 
			
		||||
                                data-testid={`${UG_EDIT_USER_BTN_ID}-${rowUser.id}`}
 | 
			
		||||
                                onClick={() => {
 | 
			
		||||
                                    setSelectedUser(rowUser);
 | 
			
		||||
                                    setEditUserOpen(true);
 | 
			
		||||
@ -148,6 +156,7 @@ export const Group: VFC = () => {
 | 
			
		||||
                            describeChild
 | 
			
		||||
                        >
 | 
			
		||||
                            <IconButton
 | 
			
		||||
                                data-testid={`${UG_REMOVE_USER_BTN_ID}-${rowUser.id}`}
 | 
			
		||||
                                onClick={() => {
 | 
			
		||||
                                    setSelectedUser(rowUser);
 | 
			
		||||
                                    setRemoveUserOpen(true);
 | 
			
		||||
@ -240,6 +249,7 @@ export const Group: VFC = () => {
 | 
			
		||||
                        actions={
 | 
			
		||||
                            <>
 | 
			
		||||
                                <PermissionIconButton
 | 
			
		||||
                                    data-testid={UG_EDIT_BTN_ID}
 | 
			
		||||
                                    to={`/admin/groups/${groupId}/edit`}
 | 
			
		||||
                                    component={Link}
 | 
			
		||||
                                    data-loading
 | 
			
		||||
@ -251,6 +261,7 @@ export const Group: VFC = () => {
 | 
			
		||||
                                    <StyledEdit />
 | 
			
		||||
                                </PermissionIconButton>
 | 
			
		||||
                                <PermissionIconButton
 | 
			
		||||
                                    data-testid={UG_DELETE_BTN_ID}
 | 
			
		||||
                                    data-loading
 | 
			
		||||
                                    onClick={() => setRemoveOpen(true)}
 | 
			
		||||
                                    permission={ADMIN}
 | 
			
		||||
@ -296,6 +307,7 @@ export const Group: VFC = () => {
 | 
			
		||||
                                            }
 | 
			
		||||
                                        />
 | 
			
		||||
                                        <Button
 | 
			
		||||
                                            data-testid={UG_ADD_USER_BTN_ID}
 | 
			
		||||
                                            variant="contained"
 | 
			
		||||
                                            color="primary"
 | 
			
		||||
                                            onClick={() => {
 | 
			
		||||
 | 
			
		||||
@ -11,6 +11,7 @@ import { IUser } from 'interfaces/user';
 | 
			
		||||
import { useMemo, useState, VFC } from 'react';
 | 
			
		||||
import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
 | 
			
		||||
import { IGroupUser, Role } from 'interfaces/group';
 | 
			
		||||
import { UG_USERS_ADD_ID, UG_USERS_ID } from 'utils/testIds';
 | 
			
		||||
 | 
			
		||||
const StyledOption = styled('div')(({ theme }) => ({
 | 
			
		||||
    display: 'flex',
 | 
			
		||||
@ -83,6 +84,7 @@ export const GroupFormUsersSelect: VFC<IGroupFormUsersSelectProps> = ({
 | 
			
		||||
    return (
 | 
			
		||||
        <StyledGroupFormUsersSelect>
 | 
			
		||||
            <Autocomplete
 | 
			
		||||
                data-testid={UG_USERS_ID}
 | 
			
		||||
                size="small"
 | 
			
		||||
                multiple
 | 
			
		||||
                limitTags={10}
 | 
			
		||||
@ -113,7 +115,11 @@ export const GroupFormUsersSelect: VFC<IGroupFormUsersSelectProps> = ({
 | 
			
		||||
                    <TextField {...params} label="Select users" />
 | 
			
		||||
                )}
 | 
			
		||||
            />
 | 
			
		||||
            <Button variant="outlined" onClick={onAdd}>
 | 
			
		||||
            <Button
 | 
			
		||||
                variant="outlined"
 | 
			
		||||
                onClick={onAdd}
 | 
			
		||||
                data-testid={UG_USERS_ADD_ID}
 | 
			
		||||
            >
 | 
			
		||||
                Add
 | 
			
		||||
            </Button>
 | 
			
		||||
        </StyledGroupFormUsersSelect>
 | 
			
		||||
 | 
			
		||||
@ -4,6 +4,7 @@ import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
 | 
			
		||||
import { Role } from 'interfaces/group';
 | 
			
		||||
import { Badge } from 'component/common/Badge/Badge';
 | 
			
		||||
import { StarRounded } from '@mui/icons-material';
 | 
			
		||||
import { UG_USERS_TABLE_ROLE_ID } from 'utils/testIds';
 | 
			
		||||
 | 
			
		||||
const StyledPopupStar = styled(StarRounded)(({ theme }) => ({
 | 
			
		||||
    color: theme.palette.warning.main,
 | 
			
		||||
@ -36,6 +37,7 @@ export const GroupUserRoleCell = ({
 | 
			
		||||
                condition={Boolean(onChange)}
 | 
			
		||||
                show={
 | 
			
		||||
                    <Select
 | 
			
		||||
                        data-testid={UG_USERS_TABLE_ROLE_ID}
 | 
			
		||||
                        size="small"
 | 
			
		||||
                        value={value}
 | 
			
		||||
                        onChange={event =>
 | 
			
		||||
 | 
			
		||||
@ -12,6 +12,7 @@ import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightC
 | 
			
		||||
import { TablePlaceholder } from 'component/common/Table';
 | 
			
		||||
import { GroupCard } from './GroupCard/GroupCard';
 | 
			
		||||
import { GroupEmpty } from './GroupEmpty/GroupEmpty';
 | 
			
		||||
import { NAVIGATE_TO_CREATE_GROUP } from 'utils/testIds';
 | 
			
		||||
 | 
			
		||||
type PageQueryType = Partial<Record<'search', string>>;
 | 
			
		||||
 | 
			
		||||
@ -85,6 +86,7 @@ export const GroupsList: VFC = () => {
 | 
			
		||||
                                component={Link}
 | 
			
		||||
                                variant="contained"
 | 
			
		||||
                                color="primary"
 | 
			
		||||
                                data-testid={NAVIGATE_TO_CREATE_GROUP}
 | 
			
		||||
                            >
 | 
			
		||||
                                New group
 | 
			
		||||
                            </Button>
 | 
			
		||||
 | 
			
		||||
@ -8,6 +8,7 @@ import useToast from 'hooks/useToast';
 | 
			
		||||
import { CreateButton } from 'component/common/CreateButton/CreateButton';
 | 
			
		||||
import { ADMIN } from 'component/providers/AccessProvider/permissions';
 | 
			
		||||
import { formatUnknownError } from 'utils/formatUnknownError';
 | 
			
		||||
import { GO_BACK } from 'constants/navigate';
 | 
			
		||||
 | 
			
		||||
const CreateProjectRole = () => {
 | 
			
		||||
    const { setToastData, setToastApiError } = useToast();
 | 
			
		||||
@ -66,7 +67,7 @@ const CreateProjectRole = () => {
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleCancel = () => {
 | 
			
		||||
        navigate(-1);
 | 
			
		||||
        navigate(GO_BACK);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
 | 
			
		||||
@ -12,6 +12,7 @@ import useProjectRoleForm from '../hooks/useProjectRoleForm';
 | 
			
		||||
import ProjectRoleForm from '../ProjectRoleForm/ProjectRoleForm';
 | 
			
		||||
import { formatUnknownError } from 'utils/formatUnknownError';
 | 
			
		||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
 | 
			
		||||
import { GO_BACK } from 'constants/navigate';
 | 
			
		||||
 | 
			
		||||
const EditProjectRole = () => {
 | 
			
		||||
    const { uiConfig } = useUiConfig();
 | 
			
		||||
@ -94,7 +95,7 @@ const EditProjectRole = () => {
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleCancel = () => {
 | 
			
		||||
        navigate(-1);
 | 
			
		||||
        navigate(GO_BACK);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
 | 
			
		||||
@ -11,6 +11,7 @@ import { scrollToTop } from 'component/common/util';
 | 
			
		||||
import { CreateButton } from 'component/common/CreateButton/CreateButton';
 | 
			
		||||
import { ADMIN } from 'component/providers/AccessProvider/permissions';
 | 
			
		||||
import { formatUnknownError } from 'utils/formatUnknownError';
 | 
			
		||||
import { GO_BACK } from 'constants/navigate';
 | 
			
		||||
 | 
			
		||||
const CreateUser = () => {
 | 
			
		||||
    const { setToastApiError } = useToast();
 | 
			
		||||
@ -72,7 +73,7 @@ const CreateUser = () => {
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleCancel = () => {
 | 
			
		||||
        navigate(-1);
 | 
			
		||||
        navigate(GO_BACK);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
 | 
			
		||||
@ -13,6 +13,7 @@ import useUserInfo from 'hooks/api/getters/useUserInfo/useUserInfo';
 | 
			
		||||
import useToast from 'hooks/useToast';
 | 
			
		||||
import { formatUnknownError } from 'utils/formatUnknownError';
 | 
			
		||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
 | 
			
		||||
import { GO_BACK } from 'constants/navigate';
 | 
			
		||||
 | 
			
		||||
const EditUser = () => {
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
@ -69,7 +70,7 @@ const EditUser = () => {
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleCancel = () => {
 | 
			
		||||
        navigate(-1);
 | 
			
		||||
        navigate(GO_BACK);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										23
									
								
								frontend/src/component/common/InputCaption/InputCaption.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								frontend/src/component/common/InputCaption/InputCaption.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,23 @@
 | 
			
		||||
import { Box } from '@mui/material';
 | 
			
		||||
 | 
			
		||||
export interface IInputCaptionProps {
 | 
			
		||||
    text?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const InputCaption = ({ text }: IInputCaptionProps) => {
 | 
			
		||||
    if (!text) {
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <Box
 | 
			
		||||
            sx={theme => ({
 | 
			
		||||
                color: theme.palette.text.secondary,
 | 
			
		||||
                fontSize: theme.fontSizes.smallerBody,
 | 
			
		||||
                marginTop: theme.spacing(1),
 | 
			
		||||
            })}
 | 
			
		||||
        >
 | 
			
		||||
            {text}
 | 
			
		||||
        </Box>
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
@ -4,6 +4,7 @@ import { useNavigate } from 'react-router';
 | 
			
		||||
import { ReactComponent as LogoIcon } from 'assets/icons/logoBg.svg';
 | 
			
		||||
 | 
			
		||||
import { useStyles } from './NotFound.styles';
 | 
			
		||||
import { GO_BACK } from 'constants/navigate';
 | 
			
		||||
 | 
			
		||||
const NotFound = () => {
 | 
			
		||||
    const navigate = useNavigate();
 | 
			
		||||
@ -14,7 +15,7 @@ const NotFound = () => {
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const onClickBack = () => {
 | 
			
		||||
        navigate(-1);
 | 
			
		||||
        navigate(GO_BACK);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
 | 
			
		||||
@ -7,6 +7,7 @@ import UIContext from 'contexts/UIContext';
 | 
			
		||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
 | 
			
		||||
import Close from '@mui/icons-material/Close';
 | 
			
		||||
import { IToast } from 'interfaces/toast';
 | 
			
		||||
import { TOAST_TEXT } from 'utils/testIds';
 | 
			
		||||
 | 
			
		||||
const Toast = ({ title, text, type, confetti }: IToast) => {
 | 
			
		||||
    const { setToast } = useContext(UIContext);
 | 
			
		||||
@ -72,7 +73,9 @@ const Toast = ({ title, text, type, confetti }: IToast) => {
 | 
			
		||||
 | 
			
		||||
                                <ConditionallyRender
 | 
			
		||||
                                    condition={Boolean(text)}
 | 
			
		||||
                                    show={<p>{text}</p>}
 | 
			
		||||
                                    show={
 | 
			
		||||
                                        <p data-testid={TOAST_TEXT}>{text}</p>
 | 
			
		||||
                                    }
 | 
			
		||||
                                />
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,6 @@
 | 
			
		||||
import { useNavigate } from 'react-router-dom';
 | 
			
		||||
import { CreateUnleashContext } from 'component/context/CreateUnleashContext/CreateUnleashContext';
 | 
			
		||||
import { GO_BACK } from 'constants/navigate';
 | 
			
		||||
 | 
			
		||||
export const CreateUnleashContextPage = () => {
 | 
			
		||||
    const navigate = useNavigate();
 | 
			
		||||
@ -7,7 +8,7 @@ export const CreateUnleashContextPage = () => {
 | 
			
		||||
    return (
 | 
			
		||||
        <CreateUnleashContext
 | 
			
		||||
            onSubmit={() => navigate('/context')}
 | 
			
		||||
            onCancel={() => navigate(-1)}
 | 
			
		||||
            onCancel={() => navigate(GO_BACK)}
 | 
			
		||||
        />
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -12,6 +12,7 @@ import { formatUnknownError } from 'utils/formatUnknownError';
 | 
			
		||||
import { ContextForm } from '../ContextForm/ContextForm';
 | 
			
		||||
import { useContextForm } from '../hooks/useContextForm';
 | 
			
		||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
 | 
			
		||||
import { GO_BACK } from 'constants/navigate';
 | 
			
		||||
 | 
			
		||||
export const EditContext = () => {
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
@ -71,7 +72,7 @@ export const EditContext = () => {
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const onCancel = () => {
 | 
			
		||||
        navigate(-1);
 | 
			
		||||
        navigate(GO_BACK);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
 | 
			
		||||
@ -15,6 +15,7 @@ import { PageContent } from 'component/common/PageContent/PageContent';
 | 
			
		||||
import { ADMIN } from 'component/providers/AccessProvider/permissions';
 | 
			
		||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
 | 
			
		||||
import { formatUnknownError } from 'utils/formatUnknownError';
 | 
			
		||||
import { GO_BACK } from 'constants/navigate';
 | 
			
		||||
 | 
			
		||||
const CreateEnvironment = () => {
 | 
			
		||||
    const { setToastApiError, setToastData } = useToast();
 | 
			
		||||
@ -66,7 +67,7 @@ const CreateEnvironment = () => {
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleCancel = () => {
 | 
			
		||||
        navigate(-1);
 | 
			
		||||
        navigate(GO_BACK);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
 | 
			
		||||
@ -11,6 +11,7 @@ import EnvironmentForm from '../EnvironmentForm/EnvironmentForm';
 | 
			
		||||
import useEnvironmentForm from '../hooks/useEnvironmentForm';
 | 
			
		||||
import { formatUnknownError } from 'utils/formatUnknownError';
 | 
			
		||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
 | 
			
		||||
import { GO_BACK } from 'constants/navigate';
 | 
			
		||||
 | 
			
		||||
const EditEnvironment = () => {
 | 
			
		||||
    const { uiConfig } = useUiConfig();
 | 
			
		||||
@ -56,7 +57,7 @@ const EditEnvironment = () => {
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleCancel = () => {
 | 
			
		||||
        navigate(-1);
 | 
			
		||||
        navigate(GO_BACK);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
 | 
			
		||||
@ -11,6 +11,7 @@ import { CreateButton } from 'component/common/CreateButton/CreateButton';
 | 
			
		||||
import UIContext from 'contexts/UIContext';
 | 
			
		||||
import { CF_CREATE_BTN_ID } from 'utils/testIds';
 | 
			
		||||
import { formatUnknownError } from 'utils/formatUnknownError';
 | 
			
		||||
import { GO_BACK } from 'constants/navigate';
 | 
			
		||||
 | 
			
		||||
const CreateFeature = () => {
 | 
			
		||||
    const { setToastData, setToastApiError } = useToast();
 | 
			
		||||
@ -70,7 +71,7 @@ const CreateFeature = () => {
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleCancel = () => {
 | 
			
		||||
        navigate(-1);
 | 
			
		||||
        navigate(GO_BACK);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
 | 
			
		||||
@ -11,6 +11,7 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
 | 
			
		||||
import useToast from 'hooks/useToast';
 | 
			
		||||
import { formatUnknownError } from 'utils/formatUnknownError';
 | 
			
		||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
 | 
			
		||||
import { GO_BACK } from 'constants/navigate';
 | 
			
		||||
 | 
			
		||||
const EditFeature = () => {
 | 
			
		||||
    const projectId = useRequiredPathParam('projectId');
 | 
			
		||||
@ -74,7 +75,7 @@ const EditFeature = () => {
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleCancel = () => {
 | 
			
		||||
        navigate(-1);
 | 
			
		||||
        navigate(GO_BACK);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
 | 
			
		||||
@ -16,13 +16,14 @@ import {
 | 
			
		||||
    createStrategyPayload,
 | 
			
		||||
    featureStrategyDocsLinkLabel,
 | 
			
		||||
} from '../FeatureStrategyEdit/FeatureStrategyEdit';
 | 
			
		||||
import { getStrategyObject } from 'utils/getStrategyObject';
 | 
			
		||||
import { useStrategies } from 'hooks/api/getters/useStrategies/useStrategies';
 | 
			
		||||
import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
 | 
			
		||||
import { ISegment } from 'interfaces/segment';
 | 
			
		||||
import { useSegmentsApi } from 'hooks/api/actions/useSegmentsApi/useSegmentsApi';
 | 
			
		||||
import { formatStrategyName } from 'utils/strategyNames';
 | 
			
		||||
import { useFeatureImmutable } from 'hooks/api/getters/useFeature/useFeatureImmutable';
 | 
			
		||||
import { useFormErrors } from 'hooks/useFormErrors';
 | 
			
		||||
import { createFeatureStrategy } from 'utils/createFeatureStrategy';
 | 
			
		||||
 | 
			
		||||
export const FeatureStrategyCreate = () => {
 | 
			
		||||
    const projectId = useRequiredPathParam('projectId');
 | 
			
		||||
@ -32,6 +33,7 @@ export const FeatureStrategyCreate = () => {
 | 
			
		||||
    const [strategy, setStrategy] = useState<Partial<IFeatureStrategy>>({});
 | 
			
		||||
    const [segments, setSegments] = useState<ISegment[]>([]);
 | 
			
		||||
    const { strategies } = useStrategies();
 | 
			
		||||
    const errors = useFormErrors();
 | 
			
		||||
 | 
			
		||||
    const { addStrategyToFeature, loading } = useFeatureStrategyApi();
 | 
			
		||||
    const { setStrategySegments } = useSegmentsApi();
 | 
			
		||||
@ -45,10 +47,15 @@ export const FeatureStrategyCreate = () => {
 | 
			
		||||
        featureId
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const strategyDefinition = strategies.find(strategy => {
 | 
			
		||||
        return strategy.name === strategyName;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        // Fill in the default values once the strategies have been fetched.
 | 
			
		||||
        setStrategy(getStrategyObject(strategies, strategyName, featureId));
 | 
			
		||||
    }, [strategies, strategyName, featureId]);
 | 
			
		||||
        if (strategyDefinition) {
 | 
			
		||||
            setStrategy(createFeatureStrategy(featureId, strategyDefinition));
 | 
			
		||||
        }
 | 
			
		||||
    }, [featureId, strategyDefinition]);
 | 
			
		||||
 | 
			
		||||
    const onSubmit = async () => {
 | 
			
		||||
        try {
 | 
			
		||||
@ -105,6 +112,7 @@ export const FeatureStrategyCreate = () => {
 | 
			
		||||
                onSubmit={onSubmit}
 | 
			
		||||
                loading={loading}
 | 
			
		||||
                permission={CREATE_FEATURE_STRATEGY}
 | 
			
		||||
                errors={errors}
 | 
			
		||||
            />
 | 
			
		||||
        </FormTemplate>
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
@ -15,6 +15,7 @@ import { useSegmentsApi } from 'hooks/api/actions/useSegmentsApi/useSegmentsApi'
 | 
			
		||||
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
 | 
			
		||||
import { formatStrategyName } from 'utils/strategyNames';
 | 
			
		||||
import { useFeatureImmutable } from 'hooks/api/getters/useFeature/useFeatureImmutable';
 | 
			
		||||
import { useFormErrors } from 'hooks/useFormErrors';
 | 
			
		||||
 | 
			
		||||
export const FeatureStrategyEdit = () => {
 | 
			
		||||
    const projectId = useRequiredPathParam('projectId');
 | 
			
		||||
@ -27,6 +28,7 @@ export const FeatureStrategyEdit = () => {
 | 
			
		||||
    const { updateStrategyOnFeature, loading } = useFeatureStrategyApi();
 | 
			
		||||
    const { setStrategySegments } = useSegmentsApi();
 | 
			
		||||
    const { setToastData, setToastApiError } = useToast();
 | 
			
		||||
    const errors = useFormErrors();
 | 
			
		||||
    const { uiConfig } = useUiConfig();
 | 
			
		||||
    const { unleashUrl } = uiConfig;
 | 
			
		||||
    const navigate = useNavigate();
 | 
			
		||||
@ -115,6 +117,7 @@ export const FeatureStrategyEdit = () => {
 | 
			
		||||
                onSubmit={onSubmit}
 | 
			
		||||
                loading={loading}
 | 
			
		||||
                permission={UPDATE_FEATURE_STRATEGY}
 | 
			
		||||
                errors={errors}
 | 
			
		||||
            />
 | 
			
		||||
        </FormTemplate>
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,9 @@
 | 
			
		||||
import React, { useState, useContext } from 'react';
 | 
			
		||||
import { IFeatureStrategy } from 'interfaces/strategy';
 | 
			
		||||
import {
 | 
			
		||||
    IFeatureStrategy,
 | 
			
		||||
    IFeatureStrategyParameters,
 | 
			
		||||
    IStrategyParameter,
 | 
			
		||||
} from 'interfaces/strategy';
 | 
			
		||||
import { FeatureStrategyType } from '../FeatureStrategyType/FeatureStrategyType';
 | 
			
		||||
import { FeatureStrategyEnabled } from '../FeatureStrategyEnabled/FeatureStrategyEnabled';
 | 
			
		||||
import { FeatureStrategyConstraints } from '../FeatureStrategyConstraints/FeatureStrategyConstraints';
 | 
			
		||||
@ -20,6 +24,9 @@ import AccessContext from 'contexts/AccessContext';
 | 
			
		||||
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
 | 
			
		||||
import { FeatureStrategySegment } from 'component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegment';
 | 
			
		||||
import { ISegment } from 'interfaces/segment';
 | 
			
		||||
import { IFormErrors } from 'hooks/useFormErrors';
 | 
			
		||||
import { useStrategies } from 'hooks/api/getters/useStrategies/useStrategies';
 | 
			
		||||
import { validateParameterValue } from 'utils/validateParameterValue';
 | 
			
		||||
 | 
			
		||||
interface IFeatureStrategyFormProps {
 | 
			
		||||
    feature: IFeatureToggle;
 | 
			
		||||
@ -33,6 +40,7 @@ interface IFeatureStrategyFormProps {
 | 
			
		||||
    >;
 | 
			
		||||
    segments: ISegment[];
 | 
			
		||||
    setSegments: React.Dispatch<React.SetStateAction<ISegment[]>>;
 | 
			
		||||
    errors: IFormErrors;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const FeatureStrategyForm = ({
 | 
			
		||||
@ -45,44 +53,81 @@ export const FeatureStrategyForm = ({
 | 
			
		||||
    setStrategy,
 | 
			
		||||
    segments,
 | 
			
		||||
    setSegments,
 | 
			
		||||
    errors,
 | 
			
		||||
}: IFeatureStrategyFormProps) => {
 | 
			
		||||
    const { classes: styles } = useStyles();
 | 
			
		||||
    const [showProdGuard, setShowProdGuard] = useState(false);
 | 
			
		||||
    const hasValidConstraints = useConstraintsValidation(strategy.constraints);
 | 
			
		||||
    const enableProdGuard = useFeatureStrategyProdGuard(feature, environmentId);
 | 
			
		||||
    const { hasAccess } = useContext(AccessContext);
 | 
			
		||||
    const { strategies } = useStrategies();
 | 
			
		||||
    const navigate = useNavigate();
 | 
			
		||||
 | 
			
		||||
    const strategyDefinition = strategies.find(definition => {
 | 
			
		||||
        return definition.name === strategy.name;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const {
 | 
			
		||||
        uiConfig,
 | 
			
		||||
        error: uiConfigError,
 | 
			
		||||
        loading: uiConfigLoading,
 | 
			
		||||
    } = useUiConfig();
 | 
			
		||||
 | 
			
		||||
    if (uiConfigError) {
 | 
			
		||||
        throw uiConfigError;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (uiConfigLoading || !strategyDefinition) {
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const findParameterDefinition = (name: string): IStrategyParameter => {
 | 
			
		||||
        return strategyDefinition.parameters.find(parameterDefinition => {
 | 
			
		||||
            return parameterDefinition.name === name;
 | 
			
		||||
        })!;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const validateParameter = (
 | 
			
		||||
        name: string,
 | 
			
		||||
        value: IFeatureStrategyParameters[string]
 | 
			
		||||
    ): boolean => {
 | 
			
		||||
        const parameterValueError = validateParameterValue(
 | 
			
		||||
            findParameterDefinition(name),
 | 
			
		||||
            value
 | 
			
		||||
        );
 | 
			
		||||
        if (parameterValueError) {
 | 
			
		||||
            errors.setFormError(name, parameterValueError);
 | 
			
		||||
            return false;
 | 
			
		||||
        } else {
 | 
			
		||||
            errors.removeFormError(name);
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const validateAllParameters = (): boolean => {
 | 
			
		||||
        return strategyDefinition.parameters
 | 
			
		||||
            .map(parameter => parameter.name)
 | 
			
		||||
            .map(name => validateParameter(name, strategy.parameters?.[name]))
 | 
			
		||||
            .every(Boolean);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const onCancel = () => {
 | 
			
		||||
        navigate(formatFeaturePath(feature.project, feature.name));
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const onSubmitOrProdGuard = async (event: React.FormEvent) => {
 | 
			
		||||
    const onSubmitWithValidation = async (event: React.FormEvent) => {
 | 
			
		||||
        event.preventDefault();
 | 
			
		||||
        if (enableProdGuard) {
 | 
			
		||||
        if (!validateAllParameters()) {
 | 
			
		||||
            return;
 | 
			
		||||
        } else if (enableProdGuard) {
 | 
			
		||||
            setShowProdGuard(true);
 | 
			
		||||
        } else {
 | 
			
		||||
            onSubmit();
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    if (uiConfigError) {
 | 
			
		||||
        throw uiConfigError;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Wait for uiConfig to load to get the correct flags.
 | 
			
		||||
    if (uiConfigLoading) {
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <form className={styles.form} onSubmit={onSubmitOrProdGuard}>
 | 
			
		||||
        <form className={styles.form} onSubmit={onSubmitWithValidation}>
 | 
			
		||||
            <div>
 | 
			
		||||
                <FeatureStrategyEnabled
 | 
			
		||||
                    feature={feature}
 | 
			
		||||
@ -118,7 +163,10 @@ export const FeatureStrategyForm = ({
 | 
			
		||||
            />
 | 
			
		||||
            <FeatureStrategyType
 | 
			
		||||
                strategy={strategy}
 | 
			
		||||
                strategyDefinition={strategyDefinition}
 | 
			
		||||
                setStrategy={setStrategy}
 | 
			
		||||
                validateParameter={validateParameter}
 | 
			
		||||
                errors={errors}
 | 
			
		||||
                hasAccess={hasAccess(
 | 
			
		||||
                    permission,
 | 
			
		||||
                    feature.project,
 | 
			
		||||
@ -134,7 +182,11 @@ export const FeatureStrategyForm = ({
 | 
			
		||||
                    variant="contained"
 | 
			
		||||
                    color="primary"
 | 
			
		||||
                    type="submit"
 | 
			
		||||
                    disabled={loading || !hasValidConstraints}
 | 
			
		||||
                    disabled={
 | 
			
		||||
                        loading ||
 | 
			
		||||
                        !hasValidConstraints ||
 | 
			
		||||
                        errors.hasFormErrors()
 | 
			
		||||
                    }
 | 
			
		||||
                    data-testid={STRATEGY_FORM_SUBMIT_ID}
 | 
			
		||||
                >
 | 
			
		||||
                    Save strategy
 | 
			
		||||
@ -147,7 +199,6 @@ export const FeatureStrategyForm = ({
 | 
			
		||||
                >
 | 
			
		||||
                    Cancel
 | 
			
		||||
                </Button>
 | 
			
		||||
 | 
			
		||||
                <FeatureStrategyProdGuard
 | 
			
		||||
                    open={showProdGuard}
 | 
			
		||||
                    onClose={() => setShowProdGuard(false)}
 | 
			
		||||
 | 
			
		||||
@ -16,7 +16,7 @@ export const FeatureStrategyIcons = ({
 | 
			
		||||
    return (
 | 
			
		||||
        <StyledList aria-label="Feature strategies">
 | 
			
		||||
            {strategies.map(strategy => (
 | 
			
		||||
                <StyledListItem key={strategy.name}>
 | 
			
		||||
                <StyledListItem key={strategy.id}>
 | 
			
		||||
                    <FeatureStrategyIcon strategyName={strategy.name} />
 | 
			
		||||
                </StyledListItem>
 | 
			
		||||
            ))}
 | 
			
		||||
 | 
			
		||||
@ -1,46 +1,44 @@
 | 
			
		||||
import { IFeatureStrategy } from 'interfaces/strategy';
 | 
			
		||||
import { IFeatureStrategy, IStrategy } from 'interfaces/strategy';
 | 
			
		||||
import DefaultStrategy from 'component/feature/StrategyTypes/DefaultStrategy/DefaultStrategy';
 | 
			
		||||
import FlexibleStrategy from 'component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy';
 | 
			
		||||
import UserWithIdStrategy from 'component/feature/StrategyTypes/UserWithIdStrategy/UserWithId';
 | 
			
		||||
import GeneralStrategy from 'component/feature/StrategyTypes/GeneralStrategy/GeneralStrategy';
 | 
			
		||||
import { useStrategies } from 'hooks/api/getters/useStrategies/useStrategies';
 | 
			
		||||
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
 | 
			
		||||
import produce from 'immer';
 | 
			
		||||
import React from 'react';
 | 
			
		||||
import { IFormErrors } from 'hooks/useFormErrors';
 | 
			
		||||
 | 
			
		||||
interface IFeatureStrategyTypeProps {
 | 
			
		||||
    hasAccess: boolean;
 | 
			
		||||
    strategy: Partial<IFeatureStrategy>;
 | 
			
		||||
    strategyDefinition: IStrategy;
 | 
			
		||||
    setStrategy: React.Dispatch<
 | 
			
		||||
        React.SetStateAction<Partial<IFeatureStrategy>>
 | 
			
		||||
    >;
 | 
			
		||||
    validateParameter: (name: string, value: string) => boolean;
 | 
			
		||||
    errors: IFormErrors;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const FeatureStrategyType = ({
 | 
			
		||||
    hasAccess,
 | 
			
		||||
    strategy,
 | 
			
		||||
    strategyDefinition,
 | 
			
		||||
    setStrategy,
 | 
			
		||||
    validateParameter,
 | 
			
		||||
    errors,
 | 
			
		||||
}: IFeatureStrategyTypeProps) => {
 | 
			
		||||
    const { strategies } = useStrategies();
 | 
			
		||||
    const { context } = useUnleashContext();
 | 
			
		||||
 | 
			
		||||
    const strategyDefinition = strategies.find(definition => {
 | 
			
		||||
        return definition.name === strategy.name;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const updateParameter = (field: string, value: string) => {
 | 
			
		||||
    const updateParameter = (name: string, value: string) => {
 | 
			
		||||
        setStrategy(
 | 
			
		||||
            produce(draft => {
 | 
			
		||||
                draft.parameters = draft.parameters ?? {};
 | 
			
		||||
                draft.parameters[field] = value;
 | 
			
		||||
                draft.parameters[name] = value;
 | 
			
		||||
            })
 | 
			
		||||
        );
 | 
			
		||||
        validateParameter(name, value);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    if (!strategyDefinition) {
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    switch (strategy.name) {
 | 
			
		||||
        case 'default':
 | 
			
		||||
            return <DefaultStrategy strategyDefinition={strategyDefinition} />;
 | 
			
		||||
@ -59,6 +57,7 @@ export const FeatureStrategyType = ({
 | 
			
		||||
                    parameters={strategy.parameters ?? {}}
 | 
			
		||||
                    updateParameter={updateParameter}
 | 
			
		||||
                    editable={hasAccess}
 | 
			
		||||
                    errors={errors}
 | 
			
		||||
                />
 | 
			
		||||
            );
 | 
			
		||||
        default:
 | 
			
		||||
@ -68,6 +67,7 @@ export const FeatureStrategyType = ({
 | 
			
		||||
                    parameters={strategy.parameters ?? {}}
 | 
			
		||||
                    updateParameter={updateParameter}
 | 
			
		||||
                    editable={hasAccess}
 | 
			
		||||
                    errors={errors}
 | 
			
		||||
                />
 | 
			
		||||
            );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -1,15 +0,0 @@
 | 
			
		||||
import { makeStyles } from 'tss-react/mui';
 | 
			
		||||
 | 
			
		||||
export const useStyles = makeStyles()(theme => ({
 | 
			
		||||
    container: {
 | 
			
		||||
        display: 'grid',
 | 
			
		||||
        gap: theme.spacing(4),
 | 
			
		||||
    },
 | 
			
		||||
    helpText: {
 | 
			
		||||
        color: theme.palette.text.secondary,
 | 
			
		||||
        fontSize: theme.fontSizes.smallerBody,
 | 
			
		||||
        lineHeight: '14px',
 | 
			
		||||
        margin: 0,
 | 
			
		||||
        marginTop: theme.spacing(1),
 | 
			
		||||
    },
 | 
			
		||||
}));
 | 
			
		||||
@ -1,190 +1,47 @@
 | 
			
		||||
import React from 'react';
 | 
			
		||||
import { FormControlLabel, Switch, TextField, Tooltip } from '@mui/material';
 | 
			
		||||
import StrategyInputList from '../StrategyInputList/StrategyInputList';
 | 
			
		||||
import RolloutSlider from '../RolloutSlider/RolloutSlider';
 | 
			
		||||
import { IStrategy, IFeatureStrategyParameters } from 'interfaces/strategy';
 | 
			
		||||
import { useStyles } from './GeneralStrategy.styles';
 | 
			
		||||
import {
 | 
			
		||||
    parseParameterNumber,
 | 
			
		||||
    parseParameterStrings,
 | 
			
		||||
    parseParameterString,
 | 
			
		||||
} from 'utils/parseParameter';
 | 
			
		||||
import { styled } from '@mui/system';
 | 
			
		||||
import { StrategyParameter } from 'component/feature/StrategyTypes/StrategyParameter/StrategyParameter';
 | 
			
		||||
import { IFormErrors } from 'hooks/useFormErrors';
 | 
			
		||||
 | 
			
		||||
interface IGeneralStrategyProps {
 | 
			
		||||
    parameters: IFeatureStrategyParameters;
 | 
			
		||||
    strategyDefinition: IStrategy;
 | 
			
		||||
    updateParameter: (field: string, value: string) => void;
 | 
			
		||||
    editable: boolean;
 | 
			
		||||
    errors: IFormErrors;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const StyledContainer = styled('div')(({ theme }) => ({
 | 
			
		||||
    display: 'grid',
 | 
			
		||||
    gap: theme.spacing(4),
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
const GeneralStrategy = ({
 | 
			
		||||
    parameters,
 | 
			
		||||
    strategyDefinition,
 | 
			
		||||
    updateParameter,
 | 
			
		||||
    editable,
 | 
			
		||||
    errors,
 | 
			
		||||
}: IGeneralStrategyProps) => {
 | 
			
		||||
    const { classes: styles } = useStyles();
 | 
			
		||||
    const onChangeTextField = (
 | 
			
		||||
        field: string,
 | 
			
		||||
        evt: React.ChangeEvent<HTMLInputElement>
 | 
			
		||||
    ) => {
 | 
			
		||||
        const { value } = evt.currentTarget;
 | 
			
		||||
 | 
			
		||||
        evt.preventDefault();
 | 
			
		||||
        updateParameter(field, value);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const onChangePercentage = (
 | 
			
		||||
        field: string,
 | 
			
		||||
        evt: Event,
 | 
			
		||||
        newValue: number | number[]
 | 
			
		||||
    ) => {
 | 
			
		||||
        evt.preventDefault();
 | 
			
		||||
        updateParameter(field, newValue.toString());
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleSwitchChange = (field: string, currentValue: any) => {
 | 
			
		||||
        const value = currentValue === 'true' ? 'false' : 'true';
 | 
			
		||||
        updateParameter(field, value);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    if (!strategyDefinition || strategyDefinition.parameters.length === 0) {
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <div className={styles.container}>
 | 
			
		||||
            {strategyDefinition.parameters.map(
 | 
			
		||||
                ({ name, type, description, required }) => {
 | 
			
		||||
                    if (type === 'percentage') {
 | 
			
		||||
                        const value = parseParameterNumber(parameters[name]);
 | 
			
		||||
                        return (
 | 
			
		||||
                            <div key={name}>
 | 
			
		||||
                                <RolloutSlider
 | 
			
		||||
                                    name={name}
 | 
			
		||||
                                    onChange={onChangePercentage.bind(
 | 
			
		||||
                                        this,
 | 
			
		||||
                                        name
 | 
			
		||||
                                    )}
 | 
			
		||||
                                    disabled={!editable}
 | 
			
		||||
                                    value={value}
 | 
			
		||||
                                    minLabel="off"
 | 
			
		||||
                                    maxLabel="on"
 | 
			
		||||
                                />
 | 
			
		||||
                                {description && (
 | 
			
		||||
                                    <p className={styles.helpText}>
 | 
			
		||||
                                        {description}
 | 
			
		||||
                                    </p>
 | 
			
		||||
                                )}
 | 
			
		||||
                            </div>
 | 
			
		||||
                        );
 | 
			
		||||
                    } else if (type === 'list') {
 | 
			
		||||
                        const values = parseParameterStrings(parameters[name]);
 | 
			
		||||
                        return (
 | 
			
		||||
                            <div key={name}>
 | 
			
		||||
                                <StrategyInputList
 | 
			
		||||
                                    name={name}
 | 
			
		||||
                                    list={values}
 | 
			
		||||
                                    disabled={!editable}
 | 
			
		||||
                                    setConfig={updateParameter}
 | 
			
		||||
                                />
 | 
			
		||||
                                {description && (
 | 
			
		||||
                                    <p className={styles.helpText}>
 | 
			
		||||
                                        {description}
 | 
			
		||||
                                    </p>
 | 
			
		||||
                                )}
 | 
			
		||||
                            </div>
 | 
			
		||||
                        );
 | 
			
		||||
                    } else if (type === 'number') {
 | 
			
		||||
                        const regex = new RegExp('^\\d+$');
 | 
			
		||||
                        const value = parseParameterString(parameters[name]);
 | 
			
		||||
                        const error =
 | 
			
		||||
                            value.length > 0 ? !regex.test(value) : false;
 | 
			
		||||
 | 
			
		||||
                        return (
 | 
			
		||||
                            <div key={name}>
 | 
			
		||||
                                <TextField
 | 
			
		||||
                                    error={error}
 | 
			
		||||
                                    helperText={
 | 
			
		||||
                                        error && `${name} is not a number!`
 | 
			
		||||
                                    }
 | 
			
		||||
                                    variant="outlined"
 | 
			
		||||
                                    size="small"
 | 
			
		||||
                                    required={required}
 | 
			
		||||
                                    style={{ width: '100%' }}
 | 
			
		||||
                                    disabled={!editable}
 | 
			
		||||
                                    name={name}
 | 
			
		||||
                                    label={name}
 | 
			
		||||
                                    onChange={onChangeTextField.bind(
 | 
			
		||||
                                        this,
 | 
			
		||||
                                        name
 | 
			
		||||
                                    )}
 | 
			
		||||
                                    value={value}
 | 
			
		||||
                                />
 | 
			
		||||
                                {description && (
 | 
			
		||||
                                    <p className={styles.helpText}>
 | 
			
		||||
                                        {description}
 | 
			
		||||
                                    </p>
 | 
			
		||||
                                )}
 | 
			
		||||
                            </div>
 | 
			
		||||
                        );
 | 
			
		||||
                    } else if (type === 'boolean') {
 | 
			
		||||
                        const value = parseParameterString(parameters[name]);
 | 
			
		||||
                        return (
 | 
			
		||||
                            <div key={name}>
 | 
			
		||||
                                <Tooltip
 | 
			
		||||
                                    title={description}
 | 
			
		||||
                                    placement="right-end"
 | 
			
		||||
                                    arrow
 | 
			
		||||
                                >
 | 
			
		||||
                                    <FormControlLabel
 | 
			
		||||
                                        label={name}
 | 
			
		||||
                                        control={
 | 
			
		||||
                                            <Switch
 | 
			
		||||
                                                name={name}
 | 
			
		||||
                                                onChange={handleSwitchChange.bind(
 | 
			
		||||
                                                    this,
 | 
			
		||||
                                                    name,
 | 
			
		||||
                                                    value
 | 
			
		||||
                                                )}
 | 
			
		||||
                                                checked={value === 'true'}
 | 
			
		||||
                                            />
 | 
			
		||||
                                        }
 | 
			
		||||
                                    />
 | 
			
		||||
                                </Tooltip>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        );
 | 
			
		||||
                    } else {
 | 
			
		||||
                        const value = parseParameterString(parameters[name]);
 | 
			
		||||
                        return (
 | 
			
		||||
                            <div key={name}>
 | 
			
		||||
                                <TextField
 | 
			
		||||
                                    rows={1}
 | 
			
		||||
                                    placeholder=""
 | 
			
		||||
                                    variant="outlined"
 | 
			
		||||
                                    size="small"
 | 
			
		||||
                                    style={{ width: '100%' }}
 | 
			
		||||
                                    required={required}
 | 
			
		||||
                                    disabled={!editable}
 | 
			
		||||
                                    name={name}
 | 
			
		||||
                                    label={name}
 | 
			
		||||
                                    onChange={onChangeTextField.bind(
 | 
			
		||||
                                        this,
 | 
			
		||||
                                        name
 | 
			
		||||
                                    )}
 | 
			
		||||
                                    value={value}
 | 
			
		||||
                                />
 | 
			
		||||
                                {description && (
 | 
			
		||||
                                    <p className={styles.helpText}>
 | 
			
		||||
                                        {description}
 | 
			
		||||
                                    </p>
 | 
			
		||||
                                )}
 | 
			
		||||
                            </div>
 | 
			
		||||
                        );
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            )}
 | 
			
		||||
        </div>
 | 
			
		||||
        <StyledContainer>
 | 
			
		||||
            {strategyDefinition.parameters.map((definition, index) => (
 | 
			
		||||
                <div key={index}>
 | 
			
		||||
                    <StrategyParameter
 | 
			
		||||
                        definition={definition}
 | 
			
		||||
                        parameters={parameters}
 | 
			
		||||
                        updateParameter={updateParameter}
 | 
			
		||||
                        editable={editable}
 | 
			
		||||
                        errors={errors}
 | 
			
		||||
                    />
 | 
			
		||||
                </div>
 | 
			
		||||
            ))}
 | 
			
		||||
        </StyledContainer>
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -87,7 +87,6 @@ const RolloutSlider = ({
 | 
			
		||||
            >
 | 
			
		||||
                {name}
 | 
			
		||||
            </Typography>
 | 
			
		||||
            <br />
 | 
			
		||||
            <StyledSlider
 | 
			
		||||
                min={0}
 | 
			
		||||
                max={100}
 | 
			
		||||
 | 
			
		||||
@ -11,12 +11,14 @@ import { Add } from '@mui/icons-material';
 | 
			
		||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
 | 
			
		||||
import { ADD_TO_STRATEGY_INPUT_LIST, STRATEGY_INPUT_LIST } from 'utils/testIds';
 | 
			
		||||
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
 | 
			
		||||
import { IFormErrors } from 'hooks/useFormErrors';
 | 
			
		||||
 | 
			
		||||
interface IStrategyInputList {
 | 
			
		||||
    name: string;
 | 
			
		||||
    list: string[];
 | 
			
		||||
    setConfig: (field: string, value: string) => void;
 | 
			
		||||
    disabled: boolean;
 | 
			
		||||
    errors: IFormErrors;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const Container = styled('div')(({ theme }) => ({
 | 
			
		||||
@ -32,6 +34,7 @@ const ChipsList = styled('div')(({ theme }) => ({
 | 
			
		||||
const InputContainer = styled('div')(({ theme }) => ({
 | 
			
		||||
    display: 'flex',
 | 
			
		||||
    gap: theme.spacing(1),
 | 
			
		||||
    alignItems: 'start',
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
const StrategyInputList = ({
 | 
			
		||||
@ -39,6 +42,7 @@ const StrategyInputList = ({
 | 
			
		||||
    list,
 | 
			
		||||
    setConfig,
 | 
			
		||||
    disabled,
 | 
			
		||||
    errors,
 | 
			
		||||
}: IStrategyInputList) => {
 | 
			
		||||
    const [input, setInput] = useState('');
 | 
			
		||||
    const ENTERKEY = 'Enter';
 | 
			
		||||
@ -120,6 +124,8 @@ const StrategyInputList = ({
 | 
			
		||||
                show={
 | 
			
		||||
                    <InputContainer>
 | 
			
		||||
                        <TextField
 | 
			
		||||
                            error={Boolean(errors.getFormError(name))}
 | 
			
		||||
                            helperText={errors.getFormError(name)}
 | 
			
		||||
                            name={`input_field`}
 | 
			
		||||
                            variant="outlined"
 | 
			
		||||
                            label="Add items"
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,140 @@
 | 
			
		||||
import React from 'react';
 | 
			
		||||
import { FormControlLabel, Switch, TextField } from '@mui/material';
 | 
			
		||||
import StrategyInputList from '../StrategyInputList/StrategyInputList';
 | 
			
		||||
import RolloutSlider from '../RolloutSlider/RolloutSlider';
 | 
			
		||||
import {
 | 
			
		||||
    IFeatureStrategyParameters,
 | 
			
		||||
    IStrategyParameter,
 | 
			
		||||
} from 'interfaces/strategy';
 | 
			
		||||
import {
 | 
			
		||||
    parseParameterNumber,
 | 
			
		||||
    parseParameterStrings,
 | 
			
		||||
    parseParameterString,
 | 
			
		||||
} from 'utils/parseParameter';
 | 
			
		||||
import { InputCaption } from 'component/common/InputCaption/InputCaption';
 | 
			
		||||
import { IFormErrors } from 'hooks/useFormErrors';
 | 
			
		||||
 | 
			
		||||
interface IStrategyParameterProps {
 | 
			
		||||
    definition: IStrategyParameter;
 | 
			
		||||
    parameters: IFeatureStrategyParameters;
 | 
			
		||||
    updateParameter: (field: string, value: string) => void;
 | 
			
		||||
    editable: boolean;
 | 
			
		||||
    errors: IFormErrors;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const StrategyParameter = ({
 | 
			
		||||
    definition,
 | 
			
		||||
    parameters,
 | 
			
		||||
    updateParameter,
 | 
			
		||||
    editable,
 | 
			
		||||
    errors,
 | 
			
		||||
}: IStrategyParameterProps) => {
 | 
			
		||||
    const { type, name, description, required } = definition;
 | 
			
		||||
    const value = parameters[name];
 | 
			
		||||
    const error = errors.getFormError(name);
 | 
			
		||||
    const label = required ? `${name} * ` : name;
 | 
			
		||||
 | 
			
		||||
    const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
 | 
			
		||||
        updateParameter(name, event.target.value);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const onChangePercentage = (event: Event, next: number | number[]) => {
 | 
			
		||||
        updateParameter(name, next.toString());
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const onChangeBoolean = (event: React.ChangeEvent, checked: boolean) => {
 | 
			
		||||
        updateParameter(name, String(checked));
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    if (type === 'percentage') {
 | 
			
		||||
        return (
 | 
			
		||||
            <div>
 | 
			
		||||
                <RolloutSlider
 | 
			
		||||
                    name={name}
 | 
			
		||||
                    onChange={onChangePercentage}
 | 
			
		||||
                    disabled={!editable}
 | 
			
		||||
                    value={parseParameterNumber(parameters[name])}
 | 
			
		||||
                    minLabel="off"
 | 
			
		||||
                    maxLabel="on"
 | 
			
		||||
                />
 | 
			
		||||
                <InputCaption text={description} />
 | 
			
		||||
            </div>
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (type === 'list') {
 | 
			
		||||
        return (
 | 
			
		||||
            <div>
 | 
			
		||||
                <StrategyInputList
 | 
			
		||||
                    name={name}
 | 
			
		||||
                    list={parseParameterStrings(parameters[name])}
 | 
			
		||||
                    disabled={!editable}
 | 
			
		||||
                    setConfig={updateParameter}
 | 
			
		||||
                    errors={errors}
 | 
			
		||||
                />
 | 
			
		||||
                <InputCaption text={description} />
 | 
			
		||||
            </div>
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (type === 'number') {
 | 
			
		||||
        return (
 | 
			
		||||
            <div>
 | 
			
		||||
                <TextField
 | 
			
		||||
                    error={Boolean(error)}
 | 
			
		||||
                    helperText={error}
 | 
			
		||||
                    variant="outlined"
 | 
			
		||||
                    size="small"
 | 
			
		||||
                    aria-required={required}
 | 
			
		||||
                    style={{ width: '100%' }}
 | 
			
		||||
                    disabled={!editable}
 | 
			
		||||
                    label={label}
 | 
			
		||||
                    onChange={onChange}
 | 
			
		||||
                    value={value}
 | 
			
		||||
                />
 | 
			
		||||
                <InputCaption text={description} />
 | 
			
		||||
            </div>
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (type === 'boolean') {
 | 
			
		||||
        const value = parseParameterString(parameters[name]);
 | 
			
		||||
        const checked = value === 'true';
 | 
			
		||||
        return (
 | 
			
		||||
            <div>
 | 
			
		||||
                <FormControlLabel
 | 
			
		||||
                    label={name}
 | 
			
		||||
                    control={
 | 
			
		||||
                        <Switch
 | 
			
		||||
                            name={name}
 | 
			
		||||
                            onChange={onChangeBoolean}
 | 
			
		||||
                            checked={checked}
 | 
			
		||||
                        />
 | 
			
		||||
                    }
 | 
			
		||||
                />
 | 
			
		||||
                <InputCaption text={description} />
 | 
			
		||||
            </div>
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <div>
 | 
			
		||||
            <TextField
 | 
			
		||||
                rows={1}
 | 
			
		||||
                placeholder=""
 | 
			
		||||
                variant="outlined"
 | 
			
		||||
                size="small"
 | 
			
		||||
                style={{ width: '100%' }}
 | 
			
		||||
                aria-required={required}
 | 
			
		||||
                disabled={!editable}
 | 
			
		||||
                error={Boolean(error)}
 | 
			
		||||
                helperText={error}
 | 
			
		||||
                name={name}
 | 
			
		||||
                label={label}
 | 
			
		||||
                onChange={onChange}
 | 
			
		||||
                value={parseParameterString(parameters[name])}
 | 
			
		||||
            />
 | 
			
		||||
            <InputCaption text={description} />
 | 
			
		||||
        </div>
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
@ -1,17 +1,20 @@
 | 
			
		||||
import { IFeatureStrategyParameters } from 'interfaces/strategy';
 | 
			
		||||
import StrategyInputList from '../StrategyInputList/StrategyInputList';
 | 
			
		||||
import { parseParameterStrings } from 'utils/parseParameter';
 | 
			
		||||
import { IFormErrors } from 'hooks/useFormErrors';
 | 
			
		||||
 | 
			
		||||
interface IUserWithIdStrategyProps {
 | 
			
		||||
    parameters: IFeatureStrategyParameters;
 | 
			
		||||
    updateParameter: (field: string, value: string) => void;
 | 
			
		||||
    editable: boolean;
 | 
			
		||||
    errors: IFormErrors;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const UserWithIdStrategy = ({
 | 
			
		||||
    editable,
 | 
			
		||||
    parameters,
 | 
			
		||||
    updateParameter,
 | 
			
		||||
    errors,
 | 
			
		||||
}: IUserWithIdStrategyProps) => {
 | 
			
		||||
    return (
 | 
			
		||||
        <div>
 | 
			
		||||
@ -20,6 +23,7 @@ const UserWithIdStrategy = ({
 | 
			
		||||
                list={parseParameterStrings(parameters.userIds)}
 | 
			
		||||
                disabled={!editable}
 | 
			
		||||
                setConfig={updateParameter}
 | 
			
		||||
                errors={errors}
 | 
			
		||||
            />
 | 
			
		||||
        </div>
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
@ -80,16 +80,7 @@ exports[`returns all baseRoutes 1`] = `
 | 
			
		||||
    "flag": "P",
 | 
			
		||||
    "menu": {},
 | 
			
		||||
    "parent": "/projects",
 | 
			
		||||
    "path": "/projects/:projectId/:activeTab",
 | 
			
		||||
    "title": ":projectId",
 | 
			
		||||
    "type": "protected",
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "component": [Function],
 | 
			
		||||
    "flag": "P",
 | 
			
		||||
    "menu": {},
 | 
			
		||||
    "parent": "/projects",
 | 
			
		||||
    "path": "/projects/:projectId",
 | 
			
		||||
    "path": "/projects/:projectId/*",
 | 
			
		||||
    "title": ":projectId",
 | 
			
		||||
    "type": "protected",
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
@ -136,16 +136,7 @@ export const routes: IRoute[] = [
 | 
			
		||||
        menu: {},
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        path: '/projects/:projectId/:activeTab',
 | 
			
		||||
        parent: '/projects',
 | 
			
		||||
        title: ':projectId',
 | 
			
		||||
        component: Project,
 | 
			
		||||
        flag: P,
 | 
			
		||||
        type: 'protected',
 | 
			
		||||
        menu: {},
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        path: '/projects/:projectId',
 | 
			
		||||
        path: '/projects/:projectId/*',
 | 
			
		||||
        parent: '/projects',
 | 
			
		||||
        title: ':projectId',
 | 
			
		||||
        component: Project,
 | 
			
		||||
 | 
			
		||||
@ -9,6 +9,7 @@ import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
 | 
			
		||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
 | 
			
		||||
import useToast from 'hooks/useToast';
 | 
			
		||||
import { formatUnknownError } from 'utils/formatUnknownError';
 | 
			
		||||
import { GO_BACK } from 'constants/navigate';
 | 
			
		||||
 | 
			
		||||
const CreateProject = () => {
 | 
			
		||||
    const { setToastData, setToastApiError } = useToast();
 | 
			
		||||
@ -65,7 +66,7 @@ const CreateProject = () => {
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleCancel = () => {
 | 
			
		||||
        navigate(-1);
 | 
			
		||||
        navigate(GO_BACK);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
 | 
			
		||||
@ -13,6 +13,7 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
 | 
			
		||||
import { useContext } from 'react';
 | 
			
		||||
import AccessContext from 'contexts/AccessContext';
 | 
			
		||||
import { Alert } from '@mui/material';
 | 
			
		||||
import { GO_BACK } from 'constants/navigate';
 | 
			
		||||
 | 
			
		||||
const EditProject = () => {
 | 
			
		||||
    const { uiConfig } = useUiConfig();
 | 
			
		||||
@ -70,7 +71,7 @@ const EditProject = () => {
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleCancel = () => {
 | 
			
		||||
        navigate(-1);
 | 
			
		||||
        navigate(GO_BACK);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const accessDeniedAlert = !hasAccess(UPDATE_PROJECT, projectId) && (
 | 
			
		||||
 | 
			
		||||
@ -16,80 +16,54 @@ import ProjectOverview from './ProjectOverview';
 | 
			
		||||
import ProjectHealth from './ProjectHealth/ProjectHealth';
 | 
			
		||||
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
 | 
			
		||||
import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions';
 | 
			
		||||
import { TabPanel } from 'component/common/TabNav/TabPanel/TabPanel';
 | 
			
		||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
 | 
			
		||||
import { useOptionalPathParam } from 'hooks/useOptionalPathParam';
 | 
			
		||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
 | 
			
		||||
import { Routes, Route, useLocation } from 'react-router-dom';
 | 
			
		||||
 | 
			
		||||
const Project = () => {
 | 
			
		||||
    const projectId = useRequiredPathParam('projectId');
 | 
			
		||||
    const activeTab = useOptionalPathParam('activeTab');
 | 
			
		||||
    const params = useQueryParams();
 | 
			
		||||
    const { project, error, loading, refetch } = useProject(projectId);
 | 
			
		||||
    const ref = useLoading(loading);
 | 
			
		||||
    const { setToastData } = useToast();
 | 
			
		||||
    const { classes: styles } = useStyles();
 | 
			
		||||
    const navigate = useNavigate();
 | 
			
		||||
    const { pathname } = useLocation();
 | 
			
		||||
    const { isOss } = useUiConfig();
 | 
			
		||||
 | 
			
		||||
    const basePath = `/projects/${projectId}`;
 | 
			
		||||
    const projectName = project?.name || projectId;
 | 
			
		||||
    const tabData = [
 | 
			
		||||
 | 
			
		||||
    const tabs = [
 | 
			
		||||
        {
 | 
			
		||||
            title: 'Overview',
 | 
			
		||||
            component: (
 | 
			
		||||
                <ProjectOverview
 | 
			
		||||
                    projectId={projectId}
 | 
			
		||||
                    projectName={projectName}
 | 
			
		||||
                />
 | 
			
		||||
            ),
 | 
			
		||||
            path: basePath,
 | 
			
		||||
            name: 'overview',
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            title: 'Health',
 | 
			
		||||
            component: (
 | 
			
		||||
                <ProjectHealth
 | 
			
		||||
                    projectId={projectId}
 | 
			
		||||
                    projectName={projectName}
 | 
			
		||||
                />
 | 
			
		||||
            ),
 | 
			
		||||
            path: `${basePath}/health`,
 | 
			
		||||
            name: 'health',
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            title: 'Access',
 | 
			
		||||
            component: <ProjectAccess projectName={projectName} />,
 | 
			
		||||
            path: `${basePath}/access`,
 | 
			
		||||
            name: 'access',
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            title: 'Environments',
 | 
			
		||||
            component: (
 | 
			
		||||
                <ProjectEnvironment
 | 
			
		||||
                    projectId={projectId}
 | 
			
		||||
                    projectName={projectName}
 | 
			
		||||
                />
 | 
			
		||||
            ),
 | 
			
		||||
            path: `${basePath}/environments`,
 | 
			
		||||
            name: 'environments',
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            title: 'Archive',
 | 
			
		||||
            component: (
 | 
			
		||||
                <ProjectFeaturesArchive
 | 
			
		||||
                    projectId={projectId}
 | 
			
		||||
                    projectName={projectName}
 | 
			
		||||
                />
 | 
			
		||||
            ),
 | 
			
		||||
            path: `${basePath}/archive`,
 | 
			
		||||
            name: 'archive',
 | 
			
		||||
        },
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    const activeTabIdx = activeTab
 | 
			
		||||
        ? tabData.findIndex(tab => tab.name === activeTab)
 | 
			
		||||
        : 0;
 | 
			
		||||
    const activeTab = [...tabs]
 | 
			
		||||
        .reverse()
 | 
			
		||||
        .find(tab => pathname.startsWith(tab.path));
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        const created = params.get('created');
 | 
			
		||||
@ -107,13 +81,13 @@ const Project = () => {
 | 
			
		||||
    }, []);
 | 
			
		||||
 | 
			
		||||
    const renderTabs = () => {
 | 
			
		||||
        return tabData.map((tab, index) => {
 | 
			
		||||
        return tabs.map(tab => {
 | 
			
		||||
            return (
 | 
			
		||||
                <Tab
 | 
			
		||||
                    data-loading
 | 
			
		||||
                    key={tab.title}
 | 
			
		||||
                    id={`tab-${index}`}
 | 
			
		||||
                    aria-controls={`tabpanel-${index}`}
 | 
			
		||||
                    label={tab.title}
 | 
			
		||||
                    value={tab.path}
 | 
			
		||||
                    onClick={() => navigate(tab.path)}
 | 
			
		||||
                    className={styles.tabButton}
 | 
			
		||||
                />
 | 
			
		||||
@ -121,16 +95,6 @@ const Project = () => {
 | 
			
		||||
        });
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const renderTabContent = () => {
 | 
			
		||||
        return tabData.map((tab, index) => {
 | 
			
		||||
            return (
 | 
			
		||||
                <TabPanel value={activeTabIdx} index={index} key={tab.path}>
 | 
			
		||||
                    {tab.component}
 | 
			
		||||
                </TabPanel>
 | 
			
		||||
            );
 | 
			
		||||
        });
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <div ref={ref}>
 | 
			
		||||
            <div className={styles.header}>
 | 
			
		||||
@ -167,7 +131,7 @@ const Project = () => {
 | 
			
		||||
                <div className={styles.separator} />
 | 
			
		||||
                <div className={styles.tabContainer}>
 | 
			
		||||
                    <Tabs
 | 
			
		||||
                        value={activeTabIdx}
 | 
			
		||||
                        value={activeTab?.path}
 | 
			
		||||
                        indicatorColor="primary"
 | 
			
		||||
                        textColor="primary"
 | 
			
		||||
                    >
 | 
			
		||||
@ -175,7 +139,13 @@ const Project = () => {
 | 
			
		||||
                    </Tabs>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            {renderTabContent()}
 | 
			
		||||
            <Routes>
 | 
			
		||||
                <Route path="health" element={<ProjectHealth />} />
 | 
			
		||||
                <Route path="access/*" element={<ProjectAccess />} />
 | 
			
		||||
                <Route path="environments" element={<ProjectEnvironment />} />
 | 
			
		||||
                <Route path="archive" element={<ProjectFeaturesArchive />} />
 | 
			
		||||
                <Route path="*" element={<ProjectOverview />} />
 | 
			
		||||
            </Routes>
 | 
			
		||||
        </div>
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -1,15 +1,11 @@
 | 
			
		||||
import { ProjectFeaturesArchiveTable } from 'component/archive/ProjectFeaturesArchiveTable';
 | 
			
		||||
import { usePageTitle } from 'hooks/usePageTitle';
 | 
			
		||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
 | 
			
		||||
import { useProjectNameOrId } from 'hooks/api/getters/useProject/useProject';
 | 
			
		||||
 | 
			
		||||
interface IProjectFeaturesArchiveProps {
 | 
			
		||||
    projectId: string;
 | 
			
		||||
    projectName: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const ProjectFeaturesArchive = ({
 | 
			
		||||
    projectId,
 | 
			
		||||
    projectName,
 | 
			
		||||
}: IProjectFeaturesArchiveProps) => {
 | 
			
		||||
export const ProjectFeaturesArchive = () => {
 | 
			
		||||
    const projectId = useRequiredPathParam('projectId');
 | 
			
		||||
    const projectName = useProjectNameOrId(projectId);
 | 
			
		||||
    usePageTitle(`Project archive – ${projectName}`);
 | 
			
		||||
 | 
			
		||||
    return <ProjectFeaturesArchiveTable projectId={projectId} />;
 | 
			
		||||
 | 
			
		||||
@ -4,13 +4,12 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
 | 
			
		||||
import { usePageTitle } from 'hooks/usePageTitle';
 | 
			
		||||
import { ReportCard } from './ReportTable/ReportCard/ReportCard';
 | 
			
		||||
import { ReportTable } from './ReportTable/ReportTable';
 | 
			
		||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
 | 
			
		||||
import { useProjectNameOrId } from 'hooks/api/getters/useProject/useProject';
 | 
			
		||||
 | 
			
		||||
interface IProjectHealthProps {
 | 
			
		||||
    projectId: string;
 | 
			
		||||
    projectName: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const ProjectHealth = ({ projectId, projectName }: IProjectHealthProps) => {
 | 
			
		||||
const ProjectHealth = () => {
 | 
			
		||||
    const projectId = useRequiredPathParam('projectId');
 | 
			
		||||
    const projectName = useProjectNameOrId(projectId);
 | 
			
		||||
    usePageTitle(`Project health – ${projectName}`);
 | 
			
		||||
 | 
			
		||||
    const { healthReport, refetchHealthReport, error } = useHealthReport(
 | 
			
		||||
 | 
			
		||||
@ -1,18 +1,18 @@
 | 
			
		||||
import useProject from 'hooks/api/getters/useProject/useProject';
 | 
			
		||||
import useProject, {
 | 
			
		||||
    useProjectNameOrId,
 | 
			
		||||
} from 'hooks/api/getters/useProject/useProject';
 | 
			
		||||
import { ProjectFeatureToggles } from './ProjectFeatureToggles/ProjectFeatureToggles';
 | 
			
		||||
import ProjectInfo from './ProjectInfo/ProjectInfo';
 | 
			
		||||
import { useStyles } from './Project.styles';
 | 
			
		||||
import { usePageTitle } from 'hooks/usePageTitle';
 | 
			
		||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
 | 
			
		||||
 | 
			
		||||
interface IProjectOverviewProps {
 | 
			
		||||
    projectName: string;
 | 
			
		||||
    projectId: string;
 | 
			
		||||
}
 | 
			
		||||
const refreshInterval = 15 * 1000;
 | 
			
		||||
 | 
			
		||||
const ProjectOverview = ({ projectId, projectName }: IProjectOverviewProps) => {
 | 
			
		||||
    const { project, loading } = useProject(projectId, {
 | 
			
		||||
        refreshInterval: 15 * 1000, // ms
 | 
			
		||||
    });
 | 
			
		||||
const ProjectOverview = () => {
 | 
			
		||||
    const projectId = useRequiredPathParam('projectId');
 | 
			
		||||
    const projectName = useProjectNameOrId(projectId);
 | 
			
		||||
    const { project, loading } = useProject(projectId, { refreshInterval });
 | 
			
		||||
    const { members, features, health, description, environments } = project;
 | 
			
		||||
    const { classes: styles } = useStyles();
 | 
			
		||||
    usePageTitle(`Project overview – ${projectName}`);
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
import { useContext, VFC } from 'react';
 | 
			
		||||
import { useContext } from 'react';
 | 
			
		||||
import { PageContent } from 'component/common/PageContent/PageContent';
 | 
			
		||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
 | 
			
		||||
import { Alert } from '@mui/material';
 | 
			
		||||
@ -8,14 +8,11 @@ import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions';
 | 
			
		||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
 | 
			
		||||
import { usePageTitle } from 'hooks/usePageTitle';
 | 
			
		||||
import { ProjectAccessTable } from 'component/project/ProjectAccess/ProjectAccessTable/ProjectAccessTable';
 | 
			
		||||
import { useProjectNameOrId } from 'hooks/api/getters/useProject/useProject';
 | 
			
		||||
 | 
			
		||||
interface IProjectAccess {
 | 
			
		||||
    projectName: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const ProjectAccess: VFC<IProjectAccess> = ({ projectName }) => {
 | 
			
		||||
export const ProjectAccess = () => {
 | 
			
		||||
    const projectId = useRequiredPathParam('projectId');
 | 
			
		||||
 | 
			
		||||
    const projectName = useProjectNameOrId(projectId);
 | 
			
		||||
    const { hasAccess } = useContext(AccessContext);
 | 
			
		||||
    const { isOss } = useUiConfig();
 | 
			
		||||
    usePageTitle(`Project access – ${projectName}`);
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
import React, { FormEvent, useEffect, useMemo, useState } from 'react';
 | 
			
		||||
import React, { FormEvent, useState } from 'react';
 | 
			
		||||
import {
 | 
			
		||||
    Autocomplete,
 | 
			
		||||
    Button,
 | 
			
		||||
@ -25,7 +25,8 @@ import { IUser } from 'interfaces/user';
 | 
			
		||||
import { IGroup } from 'interfaces/group';
 | 
			
		||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
 | 
			
		||||
import { ProjectRoleDescription } from './ProjectRoleDescription/ProjectRoleDescription';
 | 
			
		||||
import { useAccess } from '../../../../hooks/api/getters/useAccess/useAccess';
 | 
			
		||||
import { useNavigate } from 'react-router-dom';
 | 
			
		||||
import { GO_BACK } from 'constants/navigate';
 | 
			
		||||
 | 
			
		||||
const StyledForm = styled('form')(() => ({
 | 
			
		||||
    display: 'flex',
 | 
			
		||||
@ -88,96 +89,83 @@ interface IAccessOption {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface IProjectAccessAssignProps {
 | 
			
		||||
    open: boolean;
 | 
			
		||||
    setOpen: React.Dispatch<React.SetStateAction<boolean>>;
 | 
			
		||||
    selected?: IProjectAccess;
 | 
			
		||||
    accesses: IProjectAccess[];
 | 
			
		||||
    users: IUser[];
 | 
			
		||||
    groups: IGroup[];
 | 
			
		||||
    roles: IProjectRole[];
 | 
			
		||||
    entityType: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const ProjectAccessAssign = ({
 | 
			
		||||
    open,
 | 
			
		||||
    setOpen,
 | 
			
		||||
    selected,
 | 
			
		||||
    accesses,
 | 
			
		||||
    users,
 | 
			
		||||
    groups,
 | 
			
		||||
    roles,
 | 
			
		||||
    entityType,
 | 
			
		||||
}: IProjectAccessAssignProps) => {
 | 
			
		||||
    const { uiConfig } = useUiConfig();
 | 
			
		||||
    const { flags } = uiConfig;
 | 
			
		||||
    const entityType = flags.UG ? 'user / group' : 'user';
 | 
			
		||||
 | 
			
		||||
    const projectId = useRequiredPathParam('projectId');
 | 
			
		||||
    const { refetchProjectAccess } = useProjectAccess(projectId);
 | 
			
		||||
    const { addAccessToProject, changeUserRole, changeGroupRole, loading } =
 | 
			
		||||
        useProjectApi();
 | 
			
		||||
    const { users, groups } = useAccess();
 | 
			
		||||
    const edit = Boolean(selected);
 | 
			
		||||
 | 
			
		||||
    const { setToastData, setToastApiError } = useToast();
 | 
			
		||||
    const { uiConfig } = useUiConfig();
 | 
			
		||||
    const navigate = useNavigate();
 | 
			
		||||
 | 
			
		||||
    const [selectedOptions, setSelectedOptions] = useState<IAccessOption[]>([]);
 | 
			
		||||
    const options = [
 | 
			
		||||
        ...groups
 | 
			
		||||
            .filter(
 | 
			
		||||
                (group: IGroup) =>
 | 
			
		||||
                    edit ||
 | 
			
		||||
                    !accesses.some(
 | 
			
		||||
                        ({ entity: { id }, type }) =>
 | 
			
		||||
                            group.id === id && type === ENTITY_TYPE.GROUP
 | 
			
		||||
                    )
 | 
			
		||||
            )
 | 
			
		||||
            .map((group: IGroup) => ({
 | 
			
		||||
                id: group.id,
 | 
			
		||||
                entity: group,
 | 
			
		||||
                type: ENTITY_TYPE.GROUP,
 | 
			
		||||
            })),
 | 
			
		||||
        ...users
 | 
			
		||||
            .filter(
 | 
			
		||||
                (user: IUser) =>
 | 
			
		||||
                    edit ||
 | 
			
		||||
                    !accesses.some(
 | 
			
		||||
                        ({ entity: { id }, type }) =>
 | 
			
		||||
                            user.id === id && type === ENTITY_TYPE.USER
 | 
			
		||||
                    )
 | 
			
		||||
            )
 | 
			
		||||
            .map((user: IUser) => ({
 | 
			
		||||
                id: user.id,
 | 
			
		||||
                entity: user,
 | 
			
		||||
                type: ENTITY_TYPE.USER,
 | 
			
		||||
            })),
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    const [selectedOptions, setSelectedOptions] = useState<IAccessOption[]>(
 | 
			
		||||
        () =>
 | 
			
		||||
            options.filter(
 | 
			
		||||
                ({ id, type }) =>
 | 
			
		||||
                    id === selected?.entity.id && type === selected?.type
 | 
			
		||||
            )
 | 
			
		||||
    );
 | 
			
		||||
    const [role, setRole] = useState<IProjectRole | null>(
 | 
			
		||||
        roles.find(({ id }) => id === selected?.entity.roleId) ?? null
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        setRole(roles.find(({ id }) => id === selected?.entity.roleId) ?? null);
 | 
			
		||||
    }, [roles, selected]);
 | 
			
		||||
 | 
			
		||||
    const payload = useMemo(
 | 
			
		||||
        () => ({
 | 
			
		||||
            users: selectedOptions
 | 
			
		||||
                ?.filter(({ type }) => type === ENTITY_TYPE.USER)
 | 
			
		||||
                .map(({ id }) => ({ id })),
 | 
			
		||||
            groups: selectedOptions
 | 
			
		||||
                ?.filter(({ type }) => type === ENTITY_TYPE.GROUP)
 | 
			
		||||
                .map(({ id }) => ({ id })),
 | 
			
		||||
        }),
 | 
			
		||||
        [selectedOptions]
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const options = useMemo(
 | 
			
		||||
        () => [
 | 
			
		||||
            ...groups
 | 
			
		||||
                .filter(
 | 
			
		||||
                    (group: IGroup) =>
 | 
			
		||||
                        edit ||
 | 
			
		||||
                        !accesses.some(
 | 
			
		||||
                            ({ entity: { id }, type }) =>
 | 
			
		||||
                                group.id === id && type === ENTITY_TYPE.GROUP
 | 
			
		||||
                        )
 | 
			
		||||
                )
 | 
			
		||||
                .map((group: IGroup) => ({
 | 
			
		||||
                    id: group.id,
 | 
			
		||||
                    entity: group,
 | 
			
		||||
                    type: ENTITY_TYPE.GROUP,
 | 
			
		||||
                })),
 | 
			
		||||
            ...users
 | 
			
		||||
                .filter(
 | 
			
		||||
                    (user: IUser) =>
 | 
			
		||||
                        edit ||
 | 
			
		||||
                        !accesses.some(
 | 
			
		||||
                            ({ entity: { id }, type }) =>
 | 
			
		||||
                                user.id === id && type === ENTITY_TYPE.USER
 | 
			
		||||
                        )
 | 
			
		||||
                )
 | 
			
		||||
                .map((user: IUser) => ({
 | 
			
		||||
                    id: user.id,
 | 
			
		||||
                    entity: user,
 | 
			
		||||
                    type: ENTITY_TYPE.USER,
 | 
			
		||||
                })),
 | 
			
		||||
        ],
 | 
			
		||||
        [users, accesses, edit, groups]
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        const selectedOption =
 | 
			
		||||
            options.filter(
 | 
			
		||||
                ({ id, type }) =>
 | 
			
		||||
                    id === selected?.entity.id && type === selected?.type
 | 
			
		||||
            ) || [];
 | 
			
		||||
        setSelectedOptions(selectedOption);
 | 
			
		||||
        setRole(roles.find(({ id }) => id === selected?.entity.roleId) || null);
 | 
			
		||||
    }, [open, selected, options, roles]);
 | 
			
		||||
    const payload = {
 | 
			
		||||
        users: selectedOptions
 | 
			
		||||
            ?.filter(({ type }) => type === ENTITY_TYPE.USER)
 | 
			
		||||
            .map(({ id }) => ({ id })),
 | 
			
		||||
        groups: selectedOptions
 | 
			
		||||
            ?.filter(({ type }) => type === ENTITY_TYPE.GROUP)
 | 
			
		||||
            .map(({ id }) => ({ id })),
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
@ -193,7 +181,7 @@ export const ProjectAccessAssign = ({
 | 
			
		||||
                await changeGroupRole(projectId, role.id, selected.entity.id);
 | 
			
		||||
            }
 | 
			
		||||
            refetchProjectAccess();
 | 
			
		||||
            setOpen(false);
 | 
			
		||||
            navigate(GO_BACK);
 | 
			
		||||
            setToastData({
 | 
			
		||||
                title: `${selectedOptions.length} ${
 | 
			
		||||
                    selectedOptions.length === 1 ? 'access' : 'accesses'
 | 
			
		||||
@ -277,10 +265,8 @@ export const ProjectAccessAssign = ({
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <SidebarModal
 | 
			
		||||
            open={open}
 | 
			
		||||
            onClose={() => {
 | 
			
		||||
                setOpen(false);
 | 
			
		||||
            }}
 | 
			
		||||
            open
 | 
			
		||||
            onClose={() => navigate(GO_BACK)}
 | 
			
		||||
            label={`${!edit ? 'Assign' : 'Edit'} ${entityType} access`}
 | 
			
		||||
        >
 | 
			
		||||
            <FormTemplate
 | 
			
		||||
@ -373,11 +359,7 @@ export const ProjectAccessAssign = ({
 | 
			
		||||
                        >
 | 
			
		||||
                            Assign {entityType}
 | 
			
		||||
                        </Button>
 | 
			
		||||
                        <StyledCancelButton
 | 
			
		||||
                            onClick={() => {
 | 
			
		||||
                                setOpen(false);
 | 
			
		||||
                            }}
 | 
			
		||||
                        >
 | 
			
		||||
                        <StyledCancelButton onClick={() => navigate(GO_BACK)}>
 | 
			
		||||
                            Cancel
 | 
			
		||||
                        </StyledCancelButton>
 | 
			
		||||
                    </StyledButtonContainer>
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,24 @@
 | 
			
		||||
import { ProjectAccessAssign } from '../ProjectAccessAssign/ProjectAccessAssign';
 | 
			
		||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
 | 
			
		||||
import useProjectAccess from 'hooks/api/getters/useProjectAccess/useProjectAccess';
 | 
			
		||||
import { useAccess } from 'hooks/api/getters/useAccess/useAccess';
 | 
			
		||||
 | 
			
		||||
export const ProjectAccessCreate = () => {
 | 
			
		||||
    const projectId = useRequiredPathParam('projectId');
 | 
			
		||||
 | 
			
		||||
    const { access } = useProjectAccess(projectId);
 | 
			
		||||
    const { users, groups } = useAccess();
 | 
			
		||||
 | 
			
		||||
    if (!access || !users || !groups) {
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <ProjectAccessAssign
 | 
			
		||||
            accesses={access.rows}
 | 
			
		||||
            users={users}
 | 
			
		||||
            groups={groups}
 | 
			
		||||
            roles={access.roles}
 | 
			
		||||
        />
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
@ -0,0 +1,33 @@
 | 
			
		||||
import { ProjectAccessAssign } from '../ProjectAccessAssign/ProjectAccessAssign';
 | 
			
		||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
 | 
			
		||||
import useProjectAccess, {
 | 
			
		||||
    ENTITY_TYPE,
 | 
			
		||||
} from 'hooks/api/getters/useProjectAccess/useProjectAccess';
 | 
			
		||||
import { useAccess } from 'hooks/api/getters/useAccess/useAccess';
 | 
			
		||||
 | 
			
		||||
export const ProjectAccessEditGroup = () => {
 | 
			
		||||
    const projectId = useRequiredPathParam('projectId');
 | 
			
		||||
    const groupId = useRequiredPathParam('groupId');
 | 
			
		||||
 | 
			
		||||
    const { access } = useProjectAccess(projectId);
 | 
			
		||||
    const { users, groups } = useAccess();
 | 
			
		||||
 | 
			
		||||
    if (!access || !users || !groups) {
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const group = access.rows.find(
 | 
			
		||||
        row =>
 | 
			
		||||
            row.entity.id === Number(groupId) && row.type === ENTITY_TYPE.GROUP
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <ProjectAccessAssign
 | 
			
		||||
            accesses={access.rows}
 | 
			
		||||
            selected={group}
 | 
			
		||||
            users={users}
 | 
			
		||||
            groups={groups}
 | 
			
		||||
            roles={access.roles}
 | 
			
		||||
        />
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
@ -0,0 +1,32 @@
 | 
			
		||||
import { ProjectAccessAssign } from '../ProjectAccessAssign/ProjectAccessAssign';
 | 
			
		||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
 | 
			
		||||
import useProjectAccess, {
 | 
			
		||||
    ENTITY_TYPE,
 | 
			
		||||
} from 'hooks/api/getters/useProjectAccess/useProjectAccess';
 | 
			
		||||
import { useAccess } from 'hooks/api/getters/useAccess/useAccess';
 | 
			
		||||
 | 
			
		||||
export const ProjectAccessEditUser = () => {
 | 
			
		||||
    const projectId = useRequiredPathParam('projectId');
 | 
			
		||||
    const userId = useRequiredPathParam('userId');
 | 
			
		||||
 | 
			
		||||
    const { access } = useProjectAccess(projectId);
 | 
			
		||||
    const { users, groups } = useAccess();
 | 
			
		||||
 | 
			
		||||
    if (!access || !users || !groups) {
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const user = access.rows.find(
 | 
			
		||||
        row => row.entity.id === Number(userId) && row.type === ENTITY_TYPE.USER
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <ProjectAccessAssign
 | 
			
		||||
            accesses={access.rows}
 | 
			
		||||
            selected={user}
 | 
			
		||||
            users={users}
 | 
			
		||||
            groups={groups}
 | 
			
		||||
            roles={access.roles}
 | 
			
		||||
        />
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
@ -15,14 +15,19 @@ import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
 | 
			
		||||
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
 | 
			
		||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
 | 
			
		||||
import { useSearch } from 'hooks/useSearch';
 | 
			
		||||
import { useSearchParams } from 'react-router-dom';
 | 
			
		||||
import {
 | 
			
		||||
    Link,
 | 
			
		||||
    Route,
 | 
			
		||||
    Routes,
 | 
			
		||||
    useNavigate,
 | 
			
		||||
    useSearchParams,
 | 
			
		||||
} from 'react-router-dom';
 | 
			
		||||
import { createLocalStorage } from 'utils/createLocalStorage';
 | 
			
		||||
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
 | 
			
		||||
import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell';
 | 
			
		||||
import { PageContent } from 'component/common/PageContent/PageContent';
 | 
			
		||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
 | 
			
		||||
import { Search } from 'component/common/Search/Search';
 | 
			
		||||
import { ProjectAccessAssign } from 'component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign';
 | 
			
		||||
import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
 | 
			
		||||
import useToast from 'hooks/useToast';
 | 
			
		||||
import { Dialogue } from 'component/common/Dialogue/Dialogue';
 | 
			
		||||
@ -33,6 +38,9 @@ import { IUser } from 'interfaces/user';
 | 
			
		||||
import { IGroup } from 'interfaces/group';
 | 
			
		||||
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
 | 
			
		||||
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
 | 
			
		||||
import { ProjectAccessCreate } from 'component/project/ProjectAccess/ProjectAccessCreate/ProjectAccessCreate';
 | 
			
		||||
import { ProjectAccessEditUser } from 'component/project/ProjectAccess/ProjectAccessEditUser/ProjectAccessEditUser';
 | 
			
		||||
import { ProjectAccessEditGroup } from 'component/project/ProjectAccess/ProjectAccessEditGroup/ProjectAccessEditGroup';
 | 
			
		||||
 | 
			
		||||
export type PageQueryType = Partial<
 | 
			
		||||
    Record<'sort' | 'order' | 'search', string>
 | 
			
		||||
@ -52,44 +60,17 @@ export const ProjectAccessTable: VFC = () => {
 | 
			
		||||
    const { flags } = uiConfig;
 | 
			
		||||
    const entityType = flags.UG ? 'user / group' : 'user';
 | 
			
		||||
 | 
			
		||||
    const navigate = useNavigate();
 | 
			
		||||
    const theme = useTheme();
 | 
			
		||||
    const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
 | 
			
		||||
    const { setToastData } = useToast();
 | 
			
		||||
 | 
			
		||||
    const { access, refetchProjectAccess } = useProjectAccess(projectId);
 | 
			
		||||
    const { removeUserFromRole, removeGroupFromRole } = useProjectApi();
 | 
			
		||||
    const [assignOpen, setAssignOpen] = useState(false);
 | 
			
		||||
    const [removeOpen, setRemoveOpen] = useState(false);
 | 
			
		||||
    const [groupOpen, setGroupOpen] = useState(false);
 | 
			
		||||
    const [selectedRow, setSelectedRow] = useState<IProjectAccess>();
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        if (!assignOpen && !groupOpen) {
 | 
			
		||||
            setSelectedRow(undefined);
 | 
			
		||||
        }
 | 
			
		||||
    }, [assignOpen, groupOpen]);
 | 
			
		||||
 | 
			
		||||
    const roles = useMemo(
 | 
			
		||||
        () => access.roles || [],
 | 
			
		||||
        // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
			
		||||
        [JSON.stringify(access.roles)]
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const mappedData: IProjectAccess[] = useMemo(() => {
 | 
			
		||||
        const users = access.users || [];
 | 
			
		||||
        const groups = access.groups || [];
 | 
			
		||||
        return [
 | 
			
		||||
            ...users.map(user => ({
 | 
			
		||||
                entity: user,
 | 
			
		||||
                type: ENTITY_TYPE.USER,
 | 
			
		||||
            })),
 | 
			
		||||
            ...groups.map(group => ({
 | 
			
		||||
                entity: group,
 | 
			
		||||
                type: ENTITY_TYPE.GROUP,
 | 
			
		||||
            })),
 | 
			
		||||
        ];
 | 
			
		||||
    }, [access]);
 | 
			
		||||
 | 
			
		||||
    const columns = useMemo(
 | 
			
		||||
        () => [
 | 
			
		||||
            {
 | 
			
		||||
@ -145,7 +126,8 @@ export const ProjectAccessTable: VFC = () => {
 | 
			
		||||
            {
 | 
			
		||||
                Header: 'Role',
 | 
			
		||||
                accessor: (row: IProjectAccess) =>
 | 
			
		||||
                    roles.find(({ id }) => id === row.entity.roleId)?.name,
 | 
			
		||||
                    access?.roles.find(({ id }) => id === row.entity.roleId)
 | 
			
		||||
                        ?.name,
 | 
			
		||||
                minWidth: 120,
 | 
			
		||||
                filterName: 'role',
 | 
			
		||||
            },
 | 
			
		||||
@ -187,19 +169,23 @@ export const ProjectAccessTable: VFC = () => {
 | 
			
		||||
                disableSortBy: true,
 | 
			
		||||
                align: 'center',
 | 
			
		||||
                maxWidth: 200,
 | 
			
		||||
                Cell: ({ row: { original: row } }: any) => (
 | 
			
		||||
                Cell: ({
 | 
			
		||||
                    row: { original: row },
 | 
			
		||||
                }: {
 | 
			
		||||
                    row: { original: IProjectAccess };
 | 
			
		||||
                }) => (
 | 
			
		||||
                    <ActionCell>
 | 
			
		||||
                        <PermissionIconButton
 | 
			
		||||
                            component={Link}
 | 
			
		||||
                            permission={UPDATE_PROJECT}
 | 
			
		||||
                            projectId={projectId}
 | 
			
		||||
                            onClick={() => {
 | 
			
		||||
                                setSelectedRow(row);
 | 
			
		||||
                                setAssignOpen(true);
 | 
			
		||||
                            }}
 | 
			
		||||
                            disabled={mappedData.length === 1}
 | 
			
		||||
                            to={`edit/${
 | 
			
		||||
                                row.type === ENTITY_TYPE.USER ? 'user' : 'group'
 | 
			
		||||
                            }/${row.entity.id}`}
 | 
			
		||||
                            disabled={access?.rows.length === 1}
 | 
			
		||||
                            tooltipProps={{
 | 
			
		||||
                                title:
 | 
			
		||||
                                    mappedData.length === 1
 | 
			
		||||
                                    access?.rows.length === 1
 | 
			
		||||
                                        ? 'Cannot edit access. A project must have at least one owner'
 | 
			
		||||
                                        : 'Edit access',
 | 
			
		||||
                            }}
 | 
			
		||||
@ -213,10 +199,10 @@ export const ProjectAccessTable: VFC = () => {
 | 
			
		||||
                                setSelectedRow(row);
 | 
			
		||||
                                setRemoveOpen(true);
 | 
			
		||||
                            }}
 | 
			
		||||
                            disabled={mappedData.length === 1}
 | 
			
		||||
                            disabled={access?.rows.length === 1}
 | 
			
		||||
                            tooltipProps={{
 | 
			
		||||
                                title:
 | 
			
		||||
                                    mappedData.length === 1
 | 
			
		||||
                                    access?.rows.length === 1
 | 
			
		||||
                                        ? 'Cannot remove access. A project must have at least one owner'
 | 
			
		||||
                                        : 'Remove access',
 | 
			
		||||
                            }}
 | 
			
		||||
@ -227,7 +213,7 @@ export const ProjectAccessTable: VFC = () => {
 | 
			
		||||
                ),
 | 
			
		||||
            },
 | 
			
		||||
        ],
 | 
			
		||||
        [roles, mappedData.length, projectId]
 | 
			
		||||
        [access, projectId]
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const [searchParams, setSearchParams] = useSearchParams();
 | 
			
		||||
@ -247,7 +233,7 @@ export const ProjectAccessTable: VFC = () => {
 | 
			
		||||
    const { data, getSearchText, getSearchContext } = useSearch(
 | 
			
		||||
        columns,
 | 
			
		||||
        searchValue,
 | 
			
		||||
        mappedData ?? []
 | 
			
		||||
        access?.rows ?? []
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const {
 | 
			
		||||
@ -319,7 +305,6 @@ export const ProjectAccessTable: VFC = () => {
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
        setRemoveOpen(false);
 | 
			
		||||
        setSelectedRow(undefined);
 | 
			
		||||
    };
 | 
			
		||||
    return (
 | 
			
		||||
        <PageContent
 | 
			
		||||
@ -348,9 +333,10 @@ export const ProjectAccessTable: VFC = () => {
 | 
			
		||||
                                }
 | 
			
		||||
                            />
 | 
			
		||||
                            <Button
 | 
			
		||||
                                component={Link}
 | 
			
		||||
                                to={`create`}
 | 
			
		||||
                                variant="contained"
 | 
			
		||||
                                color="primary"
 | 
			
		||||
                                onClick={() => setAssignOpen(true)}
 | 
			
		||||
                            >
 | 
			
		||||
                                Assign {entityType}
 | 
			
		||||
                            </Button>
 | 
			
		||||
@ -399,19 +385,21 @@ export const ProjectAccessTable: VFC = () => {
 | 
			
		||||
                    />
 | 
			
		||||
                }
 | 
			
		||||
            />
 | 
			
		||||
            <ProjectAccessAssign
 | 
			
		||||
                open={assignOpen}
 | 
			
		||||
                setOpen={setAssignOpen}
 | 
			
		||||
                selected={selectedRow}
 | 
			
		||||
                accesses={mappedData}
 | 
			
		||||
                roles={roles}
 | 
			
		||||
                entityType={entityType}
 | 
			
		||||
            />
 | 
			
		||||
            <Routes>
 | 
			
		||||
                <Route path="create" element={<ProjectAccessCreate />} />
 | 
			
		||||
                <Route
 | 
			
		||||
                    path="edit/group/:groupId"
 | 
			
		||||
                    element={<ProjectAccessEditGroup />}
 | 
			
		||||
                />
 | 
			
		||||
                <Route
 | 
			
		||||
                    path="edit/user/:userId"
 | 
			
		||||
                    element={<ProjectAccessEditUser />}
 | 
			
		||||
                />
 | 
			
		||||
            </Routes>
 | 
			
		||||
            <Dialogue
 | 
			
		||||
                open={removeOpen}
 | 
			
		||||
                onClick={() => removeAccess(selectedRow)}
 | 
			
		||||
                onClose={() => {
 | 
			
		||||
                    setSelectedRow(undefined);
 | 
			
		||||
                    setRemoveOpen(false);
 | 
			
		||||
                }}
 | 
			
		||||
                title={`Really remove ${entityType} from this project?`}
 | 
			
		||||
@ -422,12 +410,12 @@ export const ProjectAccessTable: VFC = () => {
 | 
			
		||||
                group={selectedRow?.entity as IGroup}
 | 
			
		||||
                projectId={projectId}
 | 
			
		||||
                subtitle={`Role: ${
 | 
			
		||||
                    roles.find(({ id }) => id === selectedRow?.entity.roleId)
 | 
			
		||||
                        ?.name
 | 
			
		||||
                    access?.roles.find(
 | 
			
		||||
                        ({ id }) => id === selectedRow?.entity.roleId
 | 
			
		||||
                    )?.name
 | 
			
		||||
                }`}
 | 
			
		||||
                onEdit={() => {
 | 
			
		||||
                    setAssignOpen(true);
 | 
			
		||||
                    console.log('Assign Open true');
 | 
			
		||||
                    navigate(`edit/group/${selectedRow?.entity.id}`);
 | 
			
		||||
                }}
 | 
			
		||||
                onRemove={() => {
 | 
			
		||||
                    setGroupOpen(false);
 | 
			
		||||
 | 
			
		||||
@ -8,7 +8,9 @@ import ApiError from 'component/common/ApiError/ApiError';
 | 
			
		||||
import useToast from 'hooks/useToast';
 | 
			
		||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
 | 
			
		||||
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
 | 
			
		||||
import useProject from 'hooks/api/getters/useProject/useProject';
 | 
			
		||||
import useProject, {
 | 
			
		||||
    useProjectNameOrId,
 | 
			
		||||
} from 'hooks/api/getters/useProject/useProject';
 | 
			
		||||
import { FormControlLabel, FormGroup, Alert } from '@mui/material';
 | 
			
		||||
import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
 | 
			
		||||
import EnvironmentDisableConfirm from './EnvironmentDisableConfirm/EnvironmentDisableConfirm';
 | 
			
		||||
@ -19,17 +21,13 @@ import { getEnabledEnvs } from './helpers';
 | 
			
		||||
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
 | 
			
		||||
import { useThemeStyles } from 'themes/themeStyles';
 | 
			
		||||
import { usePageTitle } from 'hooks/usePageTitle';
 | 
			
		||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
 | 
			
		||||
 | 
			
		||||
interface IProjectEnvironmentListProps {
 | 
			
		||||
    projectId: string;
 | 
			
		||||
    projectName: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const ProjectEnvironmentList = ({
 | 
			
		||||
    projectId,
 | 
			
		||||
    projectName,
 | 
			
		||||
}: IProjectEnvironmentListProps) => {
 | 
			
		||||
const ProjectEnvironmentList = () => {
 | 
			
		||||
    const projectId = useRequiredPathParam('projectId');
 | 
			
		||||
    const projectName = useProjectNameOrId(projectId);
 | 
			
		||||
    usePageTitle(`Project environments – ${projectName}`);
 | 
			
		||||
 | 
			
		||||
    // api state
 | 
			
		||||
    const [envs, setEnvs] = useState<IProjectEnvironment[]>([]);
 | 
			
		||||
    const { setToastData, setToastApiError } = useToast();
 | 
			
		||||
 | 
			
		||||
@ -9,6 +9,7 @@ import useStrategiesApi from 'hooks/api/actions/useStrategiesApi/useStrategiesAp
 | 
			
		||||
import { useStrategies } from 'hooks/api/getters/useStrategies/useStrategies';
 | 
			
		||||
import { formatUnknownError } from 'utils/formatUnknownError';
 | 
			
		||||
import { CreateButton } from 'component/common/CreateButton/CreateButton';
 | 
			
		||||
import { GO_BACK } from 'constants/navigate';
 | 
			
		||||
 | 
			
		||||
export const CreateStrategy = () => {
 | 
			
		||||
    const { setToastData, setToastApiError } = useToast();
 | 
			
		||||
@ -64,7 +65,7 @@ export const CreateStrategy = () => {
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleCancel = () => {
 | 
			
		||||
        navigate(-1);
 | 
			
		||||
        navigate(GO_BACK);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
 | 
			
		||||
@ -11,6 +11,7 @@ import { formatUnknownError } from 'utils/formatUnknownError';
 | 
			
		||||
import useStrategy from 'hooks/api/getters/useStrategy/useStrategy';
 | 
			
		||||
import { UpdateButton } from 'component/common/UpdateButton/UpdateButton';
 | 
			
		||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
 | 
			
		||||
import { GO_BACK } from 'constants/navigate';
 | 
			
		||||
 | 
			
		||||
export const EditStrategy = () => {
 | 
			
		||||
    const { setToastData, setToastApiError } = useToast();
 | 
			
		||||
@ -68,7 +69,7 @@ export const EditStrategy = () => {
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleCancel = () => {
 | 
			
		||||
        navigate(-1);
 | 
			
		||||
        navigate(GO_BACK);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
 | 
			
		||||
@ -3,10 +3,11 @@ import { makeStyles } from 'tss-react/mui';
 | 
			
		||||
export const useStyles = makeStyles()(theme => ({
 | 
			
		||||
    paramsContainer: {
 | 
			
		||||
        maxWidth: '400px',
 | 
			
		||||
        margin: '1rem 0',
 | 
			
		||||
    },
 | 
			
		||||
    divider: {
 | 
			
		||||
        borderStyle: 'dashed',
 | 
			
		||||
        marginBottom: '1rem !important',
 | 
			
		||||
        margin: '1rem 0 1.5rem 0',
 | 
			
		||||
        borderColor: theme.palette.grey[500],
 | 
			
		||||
    },
 | 
			
		||||
    nameContainer: {
 | 
			
		||||
@ -18,13 +19,17 @@ export const useStyles = makeStyles()(theme => ({
 | 
			
		||||
        minWidth: '365px',
 | 
			
		||||
        width: '100%',
 | 
			
		||||
    },
 | 
			
		||||
    input: { minWidth: '365px', width: '100%', marginBottom: '1rem' },
 | 
			
		||||
    input: {
 | 
			
		||||
        minWidth: '365px',
 | 
			
		||||
        width: '100%',
 | 
			
		||||
        marginBottom: '1rem',
 | 
			
		||||
    },
 | 
			
		||||
    description: {
 | 
			
		||||
        minWidth: '365px',
 | 
			
		||||
        marginBottom: '1rem',
 | 
			
		||||
    },
 | 
			
		||||
    checkboxLabel: {
 | 
			
		||||
        marginBottom: '1rem',
 | 
			
		||||
        marginTop: '-0.5rem',
 | 
			
		||||
    },
 | 
			
		||||
    inputDescription: {
 | 
			
		||||
        marginBottom: '0.5rem',
 | 
			
		||||
 | 
			
		||||
@ -8,6 +8,7 @@ import useTagTypesApi from 'hooks/api/actions/useTagTypesApi/useTagTypesApi';
 | 
			
		||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
 | 
			
		||||
import useToast from 'hooks/useToast';
 | 
			
		||||
import { formatUnknownError } from 'utils/formatUnknownError';
 | 
			
		||||
import { GO_BACK } from 'constants/navigate';
 | 
			
		||||
 | 
			
		||||
const CreateTagType = () => {
 | 
			
		||||
    const { setToastData, setToastApiError } = useToast();
 | 
			
		||||
@ -55,7 +56,7 @@ const CreateTagType = () => {
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleCancel = () => {
 | 
			
		||||
        navigate(-1);
 | 
			
		||||
        navigate(GO_BACK);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
 | 
			
		||||
@ -10,6 +10,7 @@ import useToast from 'hooks/useToast';
 | 
			
		||||
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
 | 
			
		||||
import { formatUnknownError } from 'utils/formatUnknownError';
 | 
			
		||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
 | 
			
		||||
import { GO_BACK } from 'constants/navigate';
 | 
			
		||||
 | 
			
		||||
const EditTagType = () => {
 | 
			
		||||
    const { setToastData, setToastApiError } = useToast();
 | 
			
		||||
@ -54,7 +55,7 @@ const EditTagType = () => {
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleCancel = () => {
 | 
			
		||||
        navigate(-1);
 | 
			
		||||
        navigate(GO_BACK);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1
									
								
								frontend/src/constants/navigate.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/src/constants/navigate.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1 @@
 | 
			
		||||
export const GO_BACK = -1;
 | 
			
		||||
@ -1,24 +1,30 @@
 | 
			
		||||
import useSWR from 'swr';
 | 
			
		||||
import { useMemo } from 'react';
 | 
			
		||||
import { formatApiPath } from 'utils/formatPath';
 | 
			
		||||
import handleErrorResponses from '../httpErrorResponseHandler';
 | 
			
		||||
import { IGroup } from 'interfaces/group';
 | 
			
		||||
import { IUser } from 'interfaces/user';
 | 
			
		||||
 | 
			
		||||
export const useAccess = () => {
 | 
			
		||||
export interface IUseAccessOutput {
 | 
			
		||||
    users?: IUser[];
 | 
			
		||||
    groups?: IGroup[];
 | 
			
		||||
    loading: boolean;
 | 
			
		||||
    refetch: () => void;
 | 
			
		||||
    error?: Error;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const useAccess = (): IUseAccessOutput => {
 | 
			
		||||
    const { data, error, mutate } = useSWR(
 | 
			
		||||
        formatApiPath(`api/admin/user-admin/access`),
 | 
			
		||||
        fetcher
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return useMemo(
 | 
			
		||||
        () => ({
 | 
			
		||||
            users: data?.users ?? [],
 | 
			
		||||
            groups: data?.groups ?? [],
 | 
			
		||||
            loading: !error && !data,
 | 
			
		||||
            refetch: () => mutate(),
 | 
			
		||||
            error,
 | 
			
		||||
        }),
 | 
			
		||||
        [data, error, mutate]
 | 
			
		||||
    );
 | 
			
		||||
    return {
 | 
			
		||||
        users: data?.users,
 | 
			
		||||
        groups: data?.groups,
 | 
			
		||||
        loading: !error && !data,
 | 
			
		||||
        refetch: () => mutate(),
 | 
			
		||||
        error,
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const fetcher = (path: string) => {
 | 
			
		||||
 | 
			
		||||
@ -29,4 +29,8 @@ const useProject = (id: string, options: SWRConfiguration = {}) => {
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useProjectNameOrId = (id: string): string => {
 | 
			
		||||
    return useProject(id).project.name || id;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default useProject;
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
import useSWR, { mutate, SWRConfiguration } from 'swr';
 | 
			
		||||
import { useState, useEffect } from 'react';
 | 
			
		||||
import { useState, useEffect, useMemo } from 'react';
 | 
			
		||||
import { formatApiPath } from 'utils/formatPath';
 | 
			
		||||
import handleErrorResponses from '../httpErrorResponseHandler';
 | 
			
		||||
import { IProjectRole } from 'interfaces/role';
 | 
			
		||||
@ -29,6 +29,7 @@ export interface IProjectAccessOutput {
 | 
			
		||||
    users: IProjectAccessUser[];
 | 
			
		||||
    groups: IProjectAccessGroup[];
 | 
			
		||||
    roles: IProjectRole[];
 | 
			
		||||
    rows: IProjectAccess[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const useProjectAccess = (
 | 
			
		||||
@ -58,23 +59,44 @@ const useProjectAccess = (
 | 
			
		||||
        setLoading(!error && !data);
 | 
			
		||||
    }, [data, error]);
 | 
			
		||||
 | 
			
		||||
    let access: IProjectAccessOutput = data
 | 
			
		||||
        ? {
 | 
			
		||||
              roles: data.roles,
 | 
			
		||||
              users: data.users,
 | 
			
		||||
              groups:
 | 
			
		||||
                  data?.groups.map((group: any) => ({
 | 
			
		||||
                      ...group,
 | 
			
		||||
                      users: mapGroupUsers(group.users ?? []),
 | 
			
		||||
                  })) ?? [],
 | 
			
		||||
          }
 | 
			
		||||
        : { roles: [], users: [], groups: [] };
 | 
			
		||||
    const access: IProjectAccessOutput | undefined = useMemo(() => {
 | 
			
		||||
        if (data) {
 | 
			
		||||
            return formatAccessData({
 | 
			
		||||
                roles: data.roles,
 | 
			
		||||
                users: data.users,
 | 
			
		||||
                groups:
 | 
			
		||||
                    data?.groups.map((group: any) => ({
 | 
			
		||||
                        ...group,
 | 
			
		||||
                        users: mapGroupUsers(group.users ?? []),
 | 
			
		||||
                    })) ?? [],
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }, [data]);
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        access: access,
 | 
			
		||||
        access,
 | 
			
		||||
        error,
 | 
			
		||||
        loading,
 | 
			
		||||
        refetchProjectAccess,
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const formatAccessData = (access: any): IProjectAccessOutput => {
 | 
			
		||||
    const users = access.users || [];
 | 
			
		||||
    const groups = access.groups || [];
 | 
			
		||||
    return {
 | 
			
		||||
        ...access,
 | 
			
		||||
        rows: [
 | 
			
		||||
            ...users.map((user: any) => ({
 | 
			
		||||
                entity: user,
 | 
			
		||||
                type: ENTITY_TYPE.USER,
 | 
			
		||||
            })),
 | 
			
		||||
            ...groups.map((group: any) => ({
 | 
			
		||||
                entity: group,
 | 
			
		||||
                type: ENTITY_TYPE.GROUP,
 | 
			
		||||
            })),
 | 
			
		||||
        ],
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default useProjectAccess;
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										59
									
								
								frontend/src/hooks/useFormErrors.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								frontend/src/hooks/useFormErrors.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,59 @@
 | 
			
		||||
import { useState, useCallback } from 'react';
 | 
			
		||||
import produce from 'immer';
 | 
			
		||||
 | 
			
		||||
export interface IFormErrors {
 | 
			
		||||
    // Get the error message for a field name, if any.
 | 
			
		||||
    getFormError(field: string): string | undefined;
 | 
			
		||||
 | 
			
		||||
    // Set an error message for a field name.
 | 
			
		||||
    setFormError(field: string, message: string): void;
 | 
			
		||||
 | 
			
		||||
    // Remove an existing error for a field name.
 | 
			
		||||
    removeFormError(field: string): void;
 | 
			
		||||
 | 
			
		||||
    // Check if there are any errors.
 | 
			
		||||
    hasFormErrors(): boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const useFormErrors = (): IFormErrors => {
 | 
			
		||||
    const [errors, setErrors] = useState<Record<string, string>>({});
 | 
			
		||||
 | 
			
		||||
    const getFormError = useCallback(
 | 
			
		||||
        (field: string): string | undefined => errors[field],
 | 
			
		||||
        [errors]
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const setFormError = useCallback(
 | 
			
		||||
        (field: string, message: string): void => {
 | 
			
		||||
            setErrors(
 | 
			
		||||
                produce(draft => {
 | 
			
		||||
                    draft[field] = message;
 | 
			
		||||
                })
 | 
			
		||||
            );
 | 
			
		||||
        },
 | 
			
		||||
        [setErrors]
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const removeFormError = useCallback(
 | 
			
		||||
        (field: string): void => {
 | 
			
		||||
            setErrors(
 | 
			
		||||
                produce(draft => {
 | 
			
		||||
                    delete draft[field];
 | 
			
		||||
                })
 | 
			
		||||
            );
 | 
			
		||||
        },
 | 
			
		||||
        [setErrors]
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const hasFormErrors = useCallback(
 | 
			
		||||
        (): boolean => Object.values(errors).some(Boolean),
 | 
			
		||||
        [errors]
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        getFormError,
 | 
			
		||||
        setFormError,
 | 
			
		||||
        removeFormError,
 | 
			
		||||
        hasFormErrors,
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										76
									
								
								frontend/src/utils/createFeatureStrategy.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								frontend/src/utils/createFeatureStrategy.test.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,76 @@
 | 
			
		||||
import { createFeatureStrategy } from 'utils/createFeatureStrategy';
 | 
			
		||||
 | 
			
		||||
test('createFeatureStrategy', () => {
 | 
			
		||||
    expect(
 | 
			
		||||
        createFeatureStrategy('a', {
 | 
			
		||||
            name: 'b',
 | 
			
		||||
            displayName: 'c',
 | 
			
		||||
            editable: true,
 | 
			
		||||
            deprecated: false,
 | 
			
		||||
            description: 'd',
 | 
			
		||||
            parameters: [],
 | 
			
		||||
        })
 | 
			
		||||
    ).toMatchInlineSnapshot(`
 | 
			
		||||
      {
 | 
			
		||||
        "constraints": [],
 | 
			
		||||
        "name": "b",
 | 
			
		||||
        "parameters": {},
 | 
			
		||||
      }
 | 
			
		||||
    `);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('createFeatureStrategy with parameters', () => {
 | 
			
		||||
    expect(
 | 
			
		||||
        createFeatureStrategy('a', {
 | 
			
		||||
            name: 'b',
 | 
			
		||||
            displayName: 'c',
 | 
			
		||||
            editable: true,
 | 
			
		||||
            deprecated: false,
 | 
			
		||||
            description: 'd',
 | 
			
		||||
            parameters: [
 | 
			
		||||
                {
 | 
			
		||||
                    name: 'groupId',
 | 
			
		||||
                    type: 'string',
 | 
			
		||||
                    description: 'a',
 | 
			
		||||
                    required: true,
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    name: 'stickiness',
 | 
			
		||||
                    type: 'string',
 | 
			
		||||
                    description: 'a',
 | 
			
		||||
                    required: true,
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    name: 'rollout',
 | 
			
		||||
                    type: 'percentage',
 | 
			
		||||
                    description: 'a',
 | 
			
		||||
                    required: true,
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    name: 's',
 | 
			
		||||
                    type: 'string',
 | 
			
		||||
                    description: 's',
 | 
			
		||||
                    required: true,
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    name: 'b',
 | 
			
		||||
                    type: 'boolean',
 | 
			
		||||
                    description: 'b',
 | 
			
		||||
                    required: true,
 | 
			
		||||
                },
 | 
			
		||||
            ],
 | 
			
		||||
        })
 | 
			
		||||
    ).toMatchInlineSnapshot(`
 | 
			
		||||
      {
 | 
			
		||||
        "constraints": [],
 | 
			
		||||
        "name": "b",
 | 
			
		||||
        "parameters": {
 | 
			
		||||
          "b": "false",
 | 
			
		||||
          "groupId": "a",
 | 
			
		||||
          "rollout": "50",
 | 
			
		||||
          "s": "",
 | 
			
		||||
          "stickiness": "default",
 | 
			
		||||
        },
 | 
			
		||||
      }
 | 
			
		||||
    `);
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										55
									
								
								frontend/src/utils/createFeatureStrategy.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								frontend/src/utils/createFeatureStrategy.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,55 @@
 | 
			
		||||
import {
 | 
			
		||||
    IStrategy,
 | 
			
		||||
    IFeatureStrategy,
 | 
			
		||||
    IFeatureStrategyParameters,
 | 
			
		||||
    IStrategyParameter,
 | 
			
		||||
} from 'interfaces/strategy';
 | 
			
		||||
 | 
			
		||||
// Create a new feature strategy with default values from a strategy definition.
 | 
			
		||||
export const createFeatureStrategy = (
 | 
			
		||||
    featureId: string,
 | 
			
		||||
    strategyDefinition: IStrategy
 | 
			
		||||
): Omit<IFeatureStrategy, 'id'> => {
 | 
			
		||||
    const parameters: IFeatureStrategyParameters = {};
 | 
			
		||||
 | 
			
		||||
    strategyDefinition.parameters.forEach((parameter: IStrategyParameter) => {
 | 
			
		||||
        parameters[parameter.name] = createFeatureStrategyParameterValue(
 | 
			
		||||
            featureId,
 | 
			
		||||
            parameter
 | 
			
		||||
        );
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        name: strategyDefinition.name,
 | 
			
		||||
        constraints: [],
 | 
			
		||||
        parameters,
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Create default feature strategy parameter values from a strategy definition.
 | 
			
		||||
const createFeatureStrategyParameterValue = (
 | 
			
		||||
    featureId: string,
 | 
			
		||||
    parameter: IStrategyParameter
 | 
			
		||||
): string => {
 | 
			
		||||
    if (
 | 
			
		||||
        parameter.name === 'rollout' ||
 | 
			
		||||
        parameter.name === 'percentage' ||
 | 
			
		||||
        parameter.type === 'percentage'
 | 
			
		||||
    ) {
 | 
			
		||||
        return '50';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (parameter.name === 'stickiness') {
 | 
			
		||||
        return 'default';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (parameter.name === 'groupId') {
 | 
			
		||||
        return featureId;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (parameter.type === 'boolean') {
 | 
			
		||||
        return 'false';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return '';
 | 
			
		||||
};
 | 
			
		||||
@ -1,24 +0,0 @@
 | 
			
		||||
import {
 | 
			
		||||
    IStrategy,
 | 
			
		||||
    IStrategyParameter,
 | 
			
		||||
    IFeatureStrategyParameters,
 | 
			
		||||
} from 'interfaces/strategy';
 | 
			
		||||
import { resolveDefaultParamValue } from 'utils/resolveDefaultParamValue';
 | 
			
		||||
 | 
			
		||||
export const getStrategyObject = (
 | 
			
		||||
    selectableStrategies: IStrategy[],
 | 
			
		||||
    name: string,
 | 
			
		||||
    featureId: string
 | 
			
		||||
) => {
 | 
			
		||||
    const selectedStrategy = selectableStrategies.find(
 | 
			
		||||
        strategy => strategy.name === name
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const parameters: IFeatureStrategyParameters = {};
 | 
			
		||||
 | 
			
		||||
    selectedStrategy?.parameters.forEach(({ name }: IStrategyParameter) => {
 | 
			
		||||
        parameters[name] = resolveDefaultParamValue(name, featureId);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return { name, parameters, constraints: [] };
 | 
			
		||||
};
 | 
			
		||||
@ -1,16 +0,0 @@
 | 
			
		||||
export const resolveDefaultParamValue = (
 | 
			
		||||
    name: string,
 | 
			
		||||
    featureToggleName: string
 | 
			
		||||
): string => {
 | 
			
		||||
    switch (name) {
 | 
			
		||||
        case 'percentage':
 | 
			
		||||
        case 'rollout':
 | 
			
		||||
            return '100';
 | 
			
		||||
        case 'stickiness':
 | 
			
		||||
            return 'default';
 | 
			
		||||
        case 'groupId':
 | 
			
		||||
            return featureToggleName;
 | 
			
		||||
        default:
 | 
			
		||||
            return '';
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
@ -1,5 +1,6 @@
 | 
			
		||||
/* NAVIGATION */
 | 
			
		||||
export const NAVIGATE_TO_CREATE_FEATURE = 'NAVIGATE_TO_CREATE_FEATURE';
 | 
			
		||||
export const NAVIGATE_TO_CREATE_GROUP = 'NAVIGATE_TO_CREATE_GROUP';
 | 
			
		||||
export const NAVIGATE_TO_CREATE_SEGMENT = 'NAVIGATE_TO_CREATE_SEGMENT';
 | 
			
		||||
export const CREATE_API_TOKEN_BUTTON = 'CREATE_API_TOKEN_BUTTON';
 | 
			
		||||
 | 
			
		||||
@ -12,7 +13,17 @@ export const CF_CREATE_BTN_ID = 'CF_CREATE_BTN_ID';
 | 
			
		||||
/* CREATE GROUP */
 | 
			
		||||
export const UG_NAME_ID = 'UG_NAME_ID';
 | 
			
		||||
export const UG_DESC_ID = 'UG_DESC_ID';
 | 
			
		||||
export const UG_USERS_ID = 'UG_USERS_ID';
 | 
			
		||||
export const UG_USERS_ADD_ID = 'UG_USERS_ADD_ID';
 | 
			
		||||
export const UG_USERS_TABLE_ROLE_ID = 'UG_USERS_TABLE_ROLE_ID';
 | 
			
		||||
export const UG_CREATE_BTN_ID = 'UG_CREATE_BTN_ID';
 | 
			
		||||
export const UG_SAVE_BTN_ID = 'UG_SAVE_BTN_ID';
 | 
			
		||||
export const UG_EDIT_BTN_ID = 'UG_EDIT_BTN_ID';
 | 
			
		||||
export const UG_DELETE_BTN_ID = 'UG_DELETE_BTN_ID';
 | 
			
		||||
export const UG_ADD_USER_BTN_ID = 'UG_ADD_USER_BTN_ID';
 | 
			
		||||
export const UG_EDIT_USER_BTN_ID = 'UG_EDIT_USER_BTN_ID';
 | 
			
		||||
export const UG_REMOVE_USER_BTN_ID = 'UG_REMOVE_USER_BTN_ID';
 | 
			
		||||
export const UG_USERS_ROLE_ID = 'UG_USERS_ROLE_ID';
 | 
			
		||||
 | 
			
		||||
/* SEGMENT */
 | 
			
		||||
export const SEGMENT_NAME_ID = 'SEGMENT_NAME_ID';
 | 
			
		||||
@ -55,3 +66,4 @@ export const SIDEBAR_MODAL_ID = 'SIDEBAR_MODAL_ID';
 | 
			
		||||
export const AUTH_PAGE_ID = 'AUTH_PAGE_ID';
 | 
			
		||||
export const ANNOUNCER_ELEMENT_TEST_ID = 'ANNOUNCER_ELEMENT_TEST_ID';
 | 
			
		||||
export const INSTANCE_STATUS_BAR_ID = 'INSTANCE_STATUS_BAR_ID';
 | 
			
		||||
export const TOAST_TEXT = 'TOAST_TEXT';
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										55
									
								
								frontend/src/utils/validateParameterValue.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								frontend/src/utils/validateParameterValue.test.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,55 @@
 | 
			
		||||
import { validateParameterValue } from 'utils/validateParameterValue';
 | 
			
		||||
 | 
			
		||||
test('validateParameterValue string', () => {
 | 
			
		||||
    expect(
 | 
			
		||||
        validateParameterValue(
 | 
			
		||||
            { type: 'string', name: 'a', description: 'b', required: false },
 | 
			
		||||
            ''
 | 
			
		||||
        )
 | 
			
		||||
    ).toBeUndefined();
 | 
			
		||||
    expect(
 | 
			
		||||
        validateParameterValue(
 | 
			
		||||
            { type: 'string', name: 'a', description: 'b', required: false },
 | 
			
		||||
            'a'
 | 
			
		||||
        )
 | 
			
		||||
    ).toBeUndefined();
 | 
			
		||||
    expect(
 | 
			
		||||
        validateParameterValue(
 | 
			
		||||
            { type: 'string', name: 'a', description: 'b', required: true },
 | 
			
		||||
            ''
 | 
			
		||||
        )
 | 
			
		||||
    ).not.toBeUndefined();
 | 
			
		||||
    expect(
 | 
			
		||||
        validateParameterValue(
 | 
			
		||||
            { type: 'string', name: 'a', description: 'b', required: true },
 | 
			
		||||
            'b'
 | 
			
		||||
        )
 | 
			
		||||
    ).toBeUndefined();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('validateParameterValue number', () => {
 | 
			
		||||
    expect(
 | 
			
		||||
        validateParameterValue(
 | 
			
		||||
            { type: 'number', name: 'a', description: 'b', required: false },
 | 
			
		||||
            ''
 | 
			
		||||
        )
 | 
			
		||||
    ).toBeUndefined();
 | 
			
		||||
    expect(
 | 
			
		||||
        validateParameterValue(
 | 
			
		||||
            { type: 'number', name: 'a', description: 'b', required: false },
 | 
			
		||||
            'a'
 | 
			
		||||
        )
 | 
			
		||||
    ).not.toBeUndefined();
 | 
			
		||||
    expect(
 | 
			
		||||
        validateParameterValue(
 | 
			
		||||
            { type: 'number', name: 'a', description: 'b', required: true },
 | 
			
		||||
            ''
 | 
			
		||||
        )
 | 
			
		||||
    ).not.toBeUndefined();
 | 
			
		||||
    expect(
 | 
			
		||||
        validateParameterValue(
 | 
			
		||||
            { type: 'number', name: 'a', description: 'b', required: true },
 | 
			
		||||
            '1'
 | 
			
		||||
        )
 | 
			
		||||
    ).toBeUndefined();
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										23
									
								
								frontend/src/utils/validateParameterValue.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								frontend/src/utils/validateParameterValue.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,23 @@
 | 
			
		||||
import {
 | 
			
		||||
    IStrategyParameter,
 | 
			
		||||
    IFeatureStrategyParameters,
 | 
			
		||||
} from 'interfaces/strategy';
 | 
			
		||||
 | 
			
		||||
export const validateParameterValue = (
 | 
			
		||||
    definition: IStrategyParameter,
 | 
			
		||||
    value: IFeatureStrategyParameters[string]
 | 
			
		||||
): string | undefined => {
 | 
			
		||||
    const { type, required } = definition;
 | 
			
		||||
 | 
			
		||||
    if (required && value === '') {
 | 
			
		||||
        return 'Field is required';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (type === 'number' && !isValidNumberOrEmpty(value)) {
 | 
			
		||||
        return 'Not a valid number.';
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const isValidNumberOrEmpty = (value: string | number | undefined): boolean => {
 | 
			
		||||
    return value === '' || /^\d+$/.test(String(value));
 | 
			
		||||
};
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user