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