diff --git a/frontend/.github/workflows/e2e.groups.yml b/frontend/.github/workflows/e2e.groups.yml
new file mode 100644
index 0000000000..7eb5e056e6
--- /dev/null
+++ b/frontend/.github/workflows/e2e.groups.yml
@@ -0,0 +1,25 @@
+name: e2e:groups
+# https://docs.github.com/en/actions/reference/events-that-trigger-workflows
+on: [deployment_status]
+jobs:
+ e2e:
+ # only runs this job on successful deploy
+ if: github.event_name == 'deployment_status' && github.event.deployment_status.state == 'success'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Dump GitHub context
+ env:
+ GITHUB_CONTEXT: ${{ toJson(github) }}
+ run: |
+ echo "$GITHUB_CONTEXT"
+ - name: Checkout
+ uses: actions/checkout@v3
+ - name: Run Cypress
+ uses: cypress-io/github-action@v2
+ with:
+ env: AUTH_USER=admin,AUTH_PASSWORD=unleash4all
+ config: baseUrl=${{ github.event.deployment_status.target_url }}
+ record: true
+ spec: cypress/integration/groups/groups.spec.ts
+ env:
+ CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
diff --git a/frontend/cypress/integration/feature/feature.spec.ts b/frontend/cypress/integration/feature/feature.spec.ts
index 2ce8628fa9..f2651b5f5c 100644
--- a/frontend/cypress/integration/feature/feature.spec.ts
+++ b/frontend/cypress/integration/feature/feature.spec.ts
@@ -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);
diff --git a/frontend/cypress/integration/groups/groups.spec.ts b/frontend/cypress/integration/groups/groups.spec.ts
new file mode 100644
index 0000000000..6977641946
--- /dev/null
+++ b/frontend/cypress/integration/groups/groups.spec.ts
@@ -0,0 +1,160 @@
+///
+
+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');
+ });
+});
diff --git a/frontend/src/component/addons/AddonForm/AddonForm.tsx b/frontend/src/component/addons/AddonForm/AddonForm.tsx
index f7bf2a49e2..3a19477149 100644
--- a/frontend/src/component/addons/AddonForm/AddonForm.tsx
+++ b/frontend/src/component/addons/AddonForm/AddonForm.tsx
@@ -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 = ({
};
const onCancel = () => {
- navigate(-1);
+ navigate(GO_BACK);
};
const onSubmit: FormEventHandler = async event => {
diff --git a/frontend/src/component/admin/apiToken/CreateApiToken/CreateApiToken.tsx b/frontend/src/component/admin/apiToken/CreateApiToken/CreateApiToken.tsx
index 07b9c0eb09..203f1ac1fb 100644
--- a/frontend/src/component/admin/apiToken/CreateApiToken/CreateApiToken.tsx
+++ b/frontend/src/component/admin/apiToken/CreateApiToken/CreateApiToken.tsx
@@ -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 (
diff --git a/frontend/src/component/admin/groups/CreateGroup/CreateGroup.tsx b/frontend/src/component/admin/groups/CreateGroup/CreateGroup.tsx
index 8b6bcec4d7..8cb9dec9cb 100644
--- a/frontend/src/component/admin/groups/CreateGroup/CreateGroup.tsx
+++ b/frontend/src/component/admin/groups/CreateGroup/CreateGroup.tsx
@@ -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 (
diff --git a/frontend/src/component/admin/groups/EditGroup/EditGroup.tsx b/frontend/src/component/admin/groups/EditGroup/EditGroup.tsx
index ab0f3a1972..2efac8018e 100644
--- a/frontend/src/component/admin/groups/EditGroup/EditGroup.tsx
+++ b/frontend/src/component/admin/groups/EditGroup/EditGroup.tsx
@@ -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}
>
-
diff --git a/frontend/src/component/admin/groups/Group/EditGroupUser/EditGroupUser.tsx b/frontend/src/component/admin/groups/Group/EditGroupUser/EditGroupUser.tsx
index bab86186d3..cdf1214340 100644
--- a/frontend/src/component/admin/groups/Group/EditGroupUser/EditGroupUser.tsx
+++ b/frontend/src/component/admin/groups/Group/EditGroupUser/EditGroupUser.tsx
@@ -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 = ({
Assign the role the user should have in this group
@@ -159,6 +161,7 @@ export const EditGroupUser: FC = ({
({
fontSize: theme.fontSizes.mainHeader,
@@ -134,6 +141,7 @@ export const Group: VFC = () => {
{
setSelectedUser(rowUser);
setEditUserOpen(true);
@@ -148,6 +156,7 @@ export const Group: VFC = () => {
describeChild
>
{
setSelectedUser(rowUser);
setRemoveUserOpen(true);
@@ -240,6 +249,7 @@ export const Group: VFC = () => {
actions={
<>
{
setRemoveOpen(true)}
permission={ADMIN}
@@ -296,6 +307,7 @@ export const Group: VFC = () => {
}
/>
{
diff --git a/frontend/src/component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect.tsx b/frontend/src/component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect.tsx
index 60f5f70fa9..584cfaa867 100644
--- a/frontend/src/component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect.tsx
+++ b/frontend/src/component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect.tsx
@@ -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 = ({
return (
= ({
)}
/>
-
+
Add
diff --git a/frontend/src/component/admin/groups/GroupUserRoleCell/GroupUserRoleCell.tsx b/frontend/src/component/admin/groups/GroupUserRoleCell/GroupUserRoleCell.tsx
index 6382e56dc7..64186f2cbe 100644
--- a/frontend/src/component/admin/groups/GroupUserRoleCell/GroupUserRoleCell.tsx
+++ b/frontend/src/component/admin/groups/GroupUserRoleCell/GroupUserRoleCell.tsx
@@ -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={
diff --git a/frontend/src/component/admin/projectRoles/CreateProjectRole/CreateProjectRole.tsx b/frontend/src/component/admin/projectRoles/CreateProjectRole/CreateProjectRole.tsx
index 80a0a2e38c..cb0b547901 100644
--- a/frontend/src/component/admin/projectRoles/CreateProjectRole/CreateProjectRole.tsx
+++ b/frontend/src/component/admin/projectRoles/CreateProjectRole/CreateProjectRole.tsx
@@ -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 (
diff --git a/frontend/src/component/admin/projectRoles/EditProjectRole/EditProjectRole.tsx b/frontend/src/component/admin/projectRoles/EditProjectRole/EditProjectRole.tsx
index 0ef6667a3c..f739274204 100644
--- a/frontend/src/component/admin/projectRoles/EditProjectRole/EditProjectRole.tsx
+++ b/frontend/src/component/admin/projectRoles/EditProjectRole/EditProjectRole.tsx
@@ -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 (
diff --git a/frontend/src/component/admin/users/CreateUser/CreateUser.tsx b/frontend/src/component/admin/users/CreateUser/CreateUser.tsx
index 0756df9a68..41ce786603 100644
--- a/frontend/src/component/admin/users/CreateUser/CreateUser.tsx
+++ b/frontend/src/component/admin/users/CreateUser/CreateUser.tsx
@@ -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 (
diff --git a/frontend/src/component/admin/users/EditUser/EditUser.tsx b/frontend/src/component/admin/users/EditUser/EditUser.tsx
index d307511d22..482f6bfe24 100644
--- a/frontend/src/component/admin/users/EditUser/EditUser.tsx
+++ b/frontend/src/component/admin/users/EditUser/EditUser.tsx
@@ -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 (
diff --git a/frontend/src/component/common/InputCaption/InputCaption.tsx b/frontend/src/component/common/InputCaption/InputCaption.tsx
new file mode 100644
index 0000000000..ecf6fcb0c6
--- /dev/null
+++ b/frontend/src/component/common/InputCaption/InputCaption.tsx
@@ -0,0 +1,23 @@
+import { Box } from '@mui/material';
+
+export interface IInputCaptionProps {
+ text?: string;
+}
+
+export const InputCaption = ({ text }: IInputCaptionProps) => {
+ if (!text) {
+ return null;
+ }
+
+ return (
+ ({
+ color: theme.palette.text.secondary,
+ fontSize: theme.fontSizes.smallerBody,
+ marginTop: theme.spacing(1),
+ })}
+ >
+ {text}
+
+ );
+};
diff --git a/frontend/src/component/common/NotFound/NotFound.tsx b/frontend/src/component/common/NotFound/NotFound.tsx
index 3155dfb92f..e20fd10beb 100644
--- a/frontend/src/component/common/NotFound/NotFound.tsx
+++ b/frontend/src/component/common/NotFound/NotFound.tsx
@@ -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 (
diff --git a/frontend/src/component/common/ToastRenderer/Toast/Toast.tsx b/frontend/src/component/common/ToastRenderer/Toast/Toast.tsx
index 40f59958b5..9d5bc5bb30 100644
--- a/frontend/src/component/common/ToastRenderer/Toast/Toast.tsx
+++ b/frontend/src/component/common/ToastRenderer/Toast/Toast.tsx
@@ -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) => {
{text}
}
+ show={
+ {text}
+ }
/>
diff --git a/frontend/src/component/context/CreateUnleashContext/CreateUnleashContextPage.tsx b/frontend/src/component/context/CreateUnleashContext/CreateUnleashContextPage.tsx
index c05275dcf8..fddaedd78b 100644
--- a/frontend/src/component/context/CreateUnleashContext/CreateUnleashContextPage.tsx
+++ b/frontend/src/component/context/CreateUnleashContext/CreateUnleashContextPage.tsx
@@ -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 (
navigate('/context')}
- onCancel={() => navigate(-1)}
+ onCancel={() => navigate(GO_BACK)}
/>
);
};
diff --git a/frontend/src/component/context/EditContext/EditContext.tsx b/frontend/src/component/context/EditContext/EditContext.tsx
index 67883a9603..fd0b65eb77 100644
--- a/frontend/src/component/context/EditContext/EditContext.tsx
+++ b/frontend/src/component/context/EditContext/EditContext.tsx
@@ -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 (
diff --git a/frontend/src/component/environments/CreateEnvironment/CreateEnvironment.tsx b/frontend/src/component/environments/CreateEnvironment/CreateEnvironment.tsx
index f7bd0e789b..31901bcc9b 100644
--- a/frontend/src/component/environments/CreateEnvironment/CreateEnvironment.tsx
+++ b/frontend/src/component/environments/CreateEnvironment/CreateEnvironment.tsx
@@ -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 (
diff --git a/frontend/src/component/environments/EditEnvironment/EditEnvironment.tsx b/frontend/src/component/environments/EditEnvironment/EditEnvironment.tsx
index 54dee41050..10ad7f5afd 100644
--- a/frontend/src/component/environments/EditEnvironment/EditEnvironment.tsx
+++ b/frontend/src/component/environments/EditEnvironment/EditEnvironment.tsx
@@ -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 (
diff --git a/frontend/src/component/feature/CreateFeature/CreateFeature.tsx b/frontend/src/component/feature/CreateFeature/CreateFeature.tsx
index f80033c857..5f52865d21 100644
--- a/frontend/src/component/feature/CreateFeature/CreateFeature.tsx
+++ b/frontend/src/component/feature/CreateFeature/CreateFeature.tsx
@@ -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 (
diff --git a/frontend/src/component/feature/EditFeature/EditFeature.tsx b/frontend/src/component/feature/EditFeature/EditFeature.tsx
index a36b296709..b4c5804715 100644
--- a/frontend/src/component/feature/EditFeature/EditFeature.tsx
+++ b/frontend/src/component/feature/EditFeature/EditFeature.tsx
@@ -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 (
diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx
index 15340a0859..811e259c3a 100644
--- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx
+++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx
@@ -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>({});
const [segments, setSegments] = useState([]);
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}
/>
);
diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx
index f684c6eb7c..338db01bf3 100644
--- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx
+++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx
@@ -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}
/>
);
diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx
index dabb3cfceb..a96b024a7d 100644
--- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx
+++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx
@@ -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>;
+ 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 (
-