1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-04 13:48:56 +02: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:
andreas-unleash 2022-08-04 15:17:27 +03:00
commit 4157de0230
69 changed files with 1153 additions and 556 deletions

View 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 }}

View File

@ -110,7 +110,7 @@ describe('feature', () => {
expect(req.body.name).to.equal('flexibleRollout'); expect(req.body.name).to.equal('flexibleRollout');
expect(req.body.parameters.groupId).to.equal(featureToggleName); expect(req.body.parameters.groupId).to.equal(featureToggleName);
expect(req.body.parameters.stickiness).to.equal('default'); 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) { if (ENTERPRISE) {
expect(req.body.constraints.length).to.equal(1); expect(req.body.constraints.length).to.equal(1);
@ -151,7 +151,7 @@ describe('feature', () => {
req => { req => {
expect(req.body.parameters.groupId).to.equal('new-group-id'); expect(req.body.parameters.groupId).to.equal('new-group-id');
expect(req.body.parameters.stickiness).to.equal('sessionId'); 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) { if (ENTERPRISE) {
expect(req.body.constraints.length).to.equal(1); expect(req.body.constraints.length).to.equal(1);

View 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');
});
});

View File

@ -33,6 +33,7 @@ import {
StyledButtonSection, StyledButtonSection,
} from './AddonForm.styles'; } from './AddonForm.styles';
import { useTheme } from '@mui/system'; import { useTheme } from '@mui/system';
import { GO_BACK } from 'constants/navigate';
interface IAddonFormProps { interface IAddonFormProps {
provider?: IAddonProvider; provider?: IAddonProvider;
@ -168,7 +169,7 @@ export const AddonForm: VFC<IAddonFormProps> = ({
}; };
const onCancel = () => { const onCancel = () => {
navigate(-1); navigate(GO_BACK);
}; };
const onSubmit: FormEventHandler<HTMLFormElement> = async event => { const onSubmit: FormEventHandler<HTMLFormElement> = async event => {

View File

@ -12,6 +12,7 @@ import { useState } from 'react';
import { scrollToTop } from 'component/common/util'; import { scrollToTop } from 'component/common/util';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { usePageTitle } from 'hooks/usePageTitle'; import { usePageTitle } from 'hooks/usePageTitle';
import { GO_BACK } from 'constants/navigate';
const pageTitle = 'Create API token'; const pageTitle = 'Create API token';
@ -75,7 +76,7 @@ export const CreateApiToken = () => {
}; };
const handleCancel = () => { const handleCancel = () => {
navigate(-1); navigate(GO_BACK);
}; };
return ( return (

View File

@ -9,6 +9,7 @@ import { formatUnknownError } from 'utils/formatUnknownError';
import { UG_CREATE_BTN_ID } from 'utils/testIds'; import { UG_CREATE_BTN_ID } from 'utils/testIds';
import { Button } from '@mui/material'; import { Button } from '@mui/material';
import { CREATE } from 'constants/misc'; import { CREATE } from 'constants/misc';
import { GO_BACK } from 'constants/navigate';
export const CreateGroup = () => { export const CreateGroup = () => {
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
@ -58,7 +59,7 @@ export const CreateGroup = () => {
}; };
const handleCancel = () => { const handleCancel = () => {
navigate(-1); navigate(GO_BACK);
}; };
return ( return (

View File

@ -10,6 +10,8 @@ import { Button } from '@mui/material';
import { EDIT } from 'constants/misc'; import { EDIT } from 'constants/misc';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useGroup } from 'hooks/api/getters/useGroup/useGroup'; 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 = () => { export const EditGroup = () => {
const groupId = Number(useRequiredPathParam('groupId')); const groupId = Number(useRequiredPathParam('groupId'));
@ -40,7 +42,7 @@ export const EditGroup = () => {
try { try {
await updateGroup(groupId, payload); await updateGroup(groupId, payload);
refetchGroup(); refetchGroup();
navigate(-1); navigate(GO_BACK);
setToastData({ setToastData({
title: 'Group updated successfully', title: 'Group updated successfully',
type: 'success', type: 'success',
@ -60,7 +62,7 @@ export const EditGroup = () => {
}; };
const handleCancel = () => { const handleCancel = () => {
navigate(-1); navigate(GO_BACK);
}; };
return ( return (
@ -85,7 +87,12 @@ export const EditGroup = () => {
mode={EDIT} mode={EDIT}
clearErrors={clearErrors} clearErrors={clearErrors}
> >
<Button type="submit" variant="contained" color="primary"> <Button
type="submit"
variant="contained"
color="primary"
data-testid={UG_SAVE_BTN_ID}
>
Save Save
</Button> </Button>
</GroupForm> </GroupForm>

View File

@ -10,6 +10,7 @@ import { FC, FormEvent, useEffect, useMemo, useState } from 'react';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { GroupFormUsersSelect } from 'component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect'; import { GroupFormUsersSelect } from 'component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect';
import { GroupFormUsersTable } from 'component/admin/groups/GroupForm/GroupFormUsersTable/GroupFormUsersTable'; import { GroupFormUsersTable } from 'component/admin/groups/GroupForm/GroupFormUsersTable/GroupFormUsersTable';
import { UG_SAVE_BTN_ID } from 'utils/testIds';
const StyledForm = styled('form')(() => ({ const StyledForm = styled('form')(() => ({
display: 'flex', display: 'flex',
@ -142,6 +143,7 @@ export const AddGroupUser: FC<IAddGroupUserProps> = ({
type="submit" type="submit"
variant="contained" variant="contained"
color="primary" color="primary"
data-testid={UG_SAVE_BTN_ID}
> >
Save Save
</Button> </Button>

View File

@ -8,6 +8,7 @@ import useToast from 'hooks/useToast';
import { IGroup, IGroupUser, Role } from 'interfaces/group'; import { IGroup, IGroupUser, Role } from 'interfaces/group';
import { FC, FormEvent, useEffect, useMemo, useState } from 'react'; import { FC, FormEvent, useEffect, useMemo, useState } from 'react';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { UG_SAVE_BTN_ID, UG_USERS_ROLE_ID } from 'utils/testIds';
const StyledForm = styled('form')(() => ({ const StyledForm = styled('form')(() => ({
display: 'flex', display: 'flex',
@ -143,6 +144,7 @@ export const EditGroupUser: FC<IEditGroupUserProps> = ({
Assign the role the user should have in this group Assign the role the user should have in this group
</StyledInputDescription> </StyledInputDescription>
<StyledSelect <StyledSelect
data-testid={UG_USERS_ROLE_ID}
size="small" size="small"
value={role} value={role}
onChange={event => onChange={event =>
@ -159,6 +161,7 @@ export const EditGroupUser: FC<IEditGroupUserProps> = ({
<StyledButtonContainer> <StyledButtonContainer>
<Button <Button
data-testid={UG_SAVE_BTN_ID}
type="submit" type="submit"
variant="contained" variant="contained"
color="primary" color="primary"

View File

@ -37,6 +37,13 @@ import { AddGroupUser } from './AddGroupUser/AddGroupUser';
import { EditGroupUser } from './EditGroupUser/EditGroupUser'; import { EditGroupUser } from './EditGroupUser/EditGroupUser';
import { RemoveGroupUser } from './RemoveGroupUser/RemoveGroupUser'; import { RemoveGroupUser } from './RemoveGroupUser/RemoveGroupUser';
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar'; 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 }) => ({ const StyledEdit = styled(Edit)(({ theme }) => ({
fontSize: theme.fontSizes.mainHeader, fontSize: theme.fontSizes.mainHeader,
@ -134,6 +141,7 @@ export const Group: VFC = () => {
<ActionCell> <ActionCell>
<Tooltip title="Edit user" arrow describeChild> <Tooltip title="Edit user" arrow describeChild>
<IconButton <IconButton
data-testid={`${UG_EDIT_USER_BTN_ID}-${rowUser.id}`}
onClick={() => { onClick={() => {
setSelectedUser(rowUser); setSelectedUser(rowUser);
setEditUserOpen(true); setEditUserOpen(true);
@ -148,6 +156,7 @@ export const Group: VFC = () => {
describeChild describeChild
> >
<IconButton <IconButton
data-testid={`${UG_REMOVE_USER_BTN_ID}-${rowUser.id}`}
onClick={() => { onClick={() => {
setSelectedUser(rowUser); setSelectedUser(rowUser);
setRemoveUserOpen(true); setRemoveUserOpen(true);
@ -240,6 +249,7 @@ export const Group: VFC = () => {
actions={ actions={
<> <>
<PermissionIconButton <PermissionIconButton
data-testid={UG_EDIT_BTN_ID}
to={`/admin/groups/${groupId}/edit`} to={`/admin/groups/${groupId}/edit`}
component={Link} component={Link}
data-loading data-loading
@ -251,6 +261,7 @@ export const Group: VFC = () => {
<StyledEdit /> <StyledEdit />
</PermissionIconButton> </PermissionIconButton>
<PermissionIconButton <PermissionIconButton
data-testid={UG_DELETE_BTN_ID}
data-loading data-loading
onClick={() => setRemoveOpen(true)} onClick={() => setRemoveOpen(true)}
permission={ADMIN} permission={ADMIN}
@ -296,6 +307,7 @@ export const Group: VFC = () => {
} }
/> />
<Button <Button
data-testid={UG_ADD_USER_BTN_ID}
variant="contained" variant="contained"
color="primary" color="primary"
onClick={() => { onClick={() => {

View File

@ -11,6 +11,7 @@ import { IUser } from 'interfaces/user';
import { useMemo, useState, VFC } from 'react'; import { useMemo, useState, VFC } from 'react';
import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
import { IGroupUser, Role } from 'interfaces/group'; import { IGroupUser, Role } from 'interfaces/group';
import { UG_USERS_ADD_ID, UG_USERS_ID } from 'utils/testIds';
const StyledOption = styled('div')(({ theme }) => ({ const StyledOption = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
@ -83,6 +84,7 @@ export const GroupFormUsersSelect: VFC<IGroupFormUsersSelectProps> = ({
return ( return (
<StyledGroupFormUsersSelect> <StyledGroupFormUsersSelect>
<Autocomplete <Autocomplete
data-testid={UG_USERS_ID}
size="small" size="small"
multiple multiple
limitTags={10} limitTags={10}
@ -113,7 +115,11 @@ export const GroupFormUsersSelect: VFC<IGroupFormUsersSelectProps> = ({
<TextField {...params} label="Select users" /> <TextField {...params} label="Select users" />
)} )}
/> />
<Button variant="outlined" onClick={onAdd}> <Button
variant="outlined"
onClick={onAdd}
data-testid={UG_USERS_ADD_ID}
>
Add Add
</Button> </Button>
</StyledGroupFormUsersSelect> </StyledGroupFormUsersSelect>

View File

@ -4,6 +4,7 @@ import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { Role } from 'interfaces/group'; import { Role } from 'interfaces/group';
import { Badge } from 'component/common/Badge/Badge'; import { Badge } from 'component/common/Badge/Badge';
import { StarRounded } from '@mui/icons-material'; import { StarRounded } from '@mui/icons-material';
import { UG_USERS_TABLE_ROLE_ID } from 'utils/testIds';
const StyledPopupStar = styled(StarRounded)(({ theme }) => ({ const StyledPopupStar = styled(StarRounded)(({ theme }) => ({
color: theme.palette.warning.main, color: theme.palette.warning.main,
@ -36,6 +37,7 @@ export const GroupUserRoleCell = ({
condition={Boolean(onChange)} condition={Boolean(onChange)}
show={ show={
<Select <Select
data-testid={UG_USERS_TABLE_ROLE_ID}
size="small" size="small"
value={value} value={value}
onChange={event => onChange={event =>

View File

@ -12,6 +12,7 @@ import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightC
import { TablePlaceholder } from 'component/common/Table'; import { TablePlaceholder } from 'component/common/Table';
import { GroupCard } from './GroupCard/GroupCard'; import { GroupCard } from './GroupCard/GroupCard';
import { GroupEmpty } from './GroupEmpty/GroupEmpty'; import { GroupEmpty } from './GroupEmpty/GroupEmpty';
import { NAVIGATE_TO_CREATE_GROUP } from 'utils/testIds';
type PageQueryType = Partial<Record<'search', string>>; type PageQueryType = Partial<Record<'search', string>>;
@ -85,6 +86,7 @@ export const GroupsList: VFC = () => {
component={Link} component={Link}
variant="contained" variant="contained"
color="primary" color="primary"
data-testid={NAVIGATE_TO_CREATE_GROUP}
> >
New group New group
</Button> </Button>

View File

@ -8,6 +8,7 @@ import useToast from 'hooks/useToast';
import { CreateButton } from 'component/common/CreateButton/CreateButton'; import { CreateButton } from 'component/common/CreateButton/CreateButton';
import { ADMIN } from 'component/providers/AccessProvider/permissions'; import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { GO_BACK } from 'constants/navigate';
const CreateProjectRole = () => { const CreateProjectRole = () => {
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
@ -66,7 +67,7 @@ const CreateProjectRole = () => {
}; };
const handleCancel = () => { const handleCancel = () => {
navigate(-1); navigate(GO_BACK);
}; };
return ( return (

View File

@ -12,6 +12,7 @@ import useProjectRoleForm from '../hooks/useProjectRoleForm';
import ProjectRoleForm from '../ProjectRoleForm/ProjectRoleForm'; import ProjectRoleForm from '../ProjectRoleForm/ProjectRoleForm';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { GO_BACK } from 'constants/navigate';
const EditProjectRole = () => { const EditProjectRole = () => {
const { uiConfig } = useUiConfig(); const { uiConfig } = useUiConfig();
@ -94,7 +95,7 @@ const EditProjectRole = () => {
}; };
const handleCancel = () => { const handleCancel = () => {
navigate(-1); navigate(GO_BACK);
}; };
return ( return (

View File

@ -11,6 +11,7 @@ import { scrollToTop } from 'component/common/util';
import { CreateButton } from 'component/common/CreateButton/CreateButton'; import { CreateButton } from 'component/common/CreateButton/CreateButton';
import { ADMIN } from 'component/providers/AccessProvider/permissions'; import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { GO_BACK } from 'constants/navigate';
const CreateUser = () => { const CreateUser = () => {
const { setToastApiError } = useToast(); const { setToastApiError } = useToast();
@ -72,7 +73,7 @@ const CreateUser = () => {
}; };
const handleCancel = () => { const handleCancel = () => {
navigate(-1); navigate(GO_BACK);
}; };
return ( return (

View File

@ -13,6 +13,7 @@ import useUserInfo from 'hooks/api/getters/useUserInfo/useUserInfo';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { GO_BACK } from 'constants/navigate';
const EditUser = () => { const EditUser = () => {
useEffect(() => { useEffect(() => {
@ -69,7 +70,7 @@ const EditUser = () => {
}; };
const handleCancel = () => { const handleCancel = () => {
navigate(-1); navigate(GO_BACK);
}; };
return ( return (

View 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>
);
};

View File

@ -4,6 +4,7 @@ import { useNavigate } from 'react-router';
import { ReactComponent as LogoIcon } from 'assets/icons/logoBg.svg'; import { ReactComponent as LogoIcon } from 'assets/icons/logoBg.svg';
import { useStyles } from './NotFound.styles'; import { useStyles } from './NotFound.styles';
import { GO_BACK } from 'constants/navigate';
const NotFound = () => { const NotFound = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -14,7 +15,7 @@ const NotFound = () => {
}; };
const onClickBack = () => { const onClickBack = () => {
navigate(-1); navigate(GO_BACK);
}; };
return ( return (

View File

@ -7,6 +7,7 @@ import UIContext from 'contexts/UIContext';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import Close from '@mui/icons-material/Close'; import Close from '@mui/icons-material/Close';
import { IToast } from 'interfaces/toast'; import { IToast } from 'interfaces/toast';
import { TOAST_TEXT } from 'utils/testIds';
const Toast = ({ title, text, type, confetti }: IToast) => { const Toast = ({ title, text, type, confetti }: IToast) => {
const { setToast } = useContext(UIContext); const { setToast } = useContext(UIContext);
@ -72,7 +73,9 @@ const Toast = ({ title, text, type, confetti }: IToast) => {
<ConditionallyRender <ConditionallyRender
condition={Boolean(text)} condition={Boolean(text)}
show={<p>{text}</p>} show={
<p data-testid={TOAST_TEXT}>{text}</p>
}
/> />
</div> </div>
</div> </div>

View File

@ -1,5 +1,6 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { CreateUnleashContext } from 'component/context/CreateUnleashContext/CreateUnleashContext'; import { CreateUnleashContext } from 'component/context/CreateUnleashContext/CreateUnleashContext';
import { GO_BACK } from 'constants/navigate';
export const CreateUnleashContextPage = () => { export const CreateUnleashContextPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -7,7 +8,7 @@ export const CreateUnleashContextPage = () => {
return ( return (
<CreateUnleashContext <CreateUnleashContext
onSubmit={() => navigate('/context')} onSubmit={() => navigate('/context')}
onCancel={() => navigate(-1)} onCancel={() => navigate(GO_BACK)}
/> />
); );
}; };

View File

@ -12,6 +12,7 @@ import { formatUnknownError } from 'utils/formatUnknownError';
import { ContextForm } from '../ContextForm/ContextForm'; import { ContextForm } from '../ContextForm/ContextForm';
import { useContextForm } from '../hooks/useContextForm'; import { useContextForm } from '../hooks/useContextForm';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { GO_BACK } from 'constants/navigate';
export const EditContext = () => { export const EditContext = () => {
useEffect(() => { useEffect(() => {
@ -71,7 +72,7 @@ export const EditContext = () => {
}; };
const onCancel = () => { const onCancel = () => {
navigate(-1); navigate(GO_BACK);
}; };
return ( return (

View File

@ -15,6 +15,7 @@ import { PageContent } from 'component/common/PageContent/PageContent';
import { ADMIN } from 'component/providers/AccessProvider/permissions'; import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { PageHeader } from 'component/common/PageHeader/PageHeader'; import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { GO_BACK } from 'constants/navigate';
const CreateEnvironment = () => { const CreateEnvironment = () => {
const { setToastApiError, setToastData } = useToast(); const { setToastApiError, setToastData } = useToast();
@ -66,7 +67,7 @@ const CreateEnvironment = () => {
}; };
const handleCancel = () => { const handleCancel = () => {
navigate(-1); navigate(GO_BACK);
}; };
return ( return (

View File

@ -11,6 +11,7 @@ import EnvironmentForm from '../EnvironmentForm/EnvironmentForm';
import useEnvironmentForm from '../hooks/useEnvironmentForm'; import useEnvironmentForm from '../hooks/useEnvironmentForm';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { GO_BACK } from 'constants/navigate';
const EditEnvironment = () => { const EditEnvironment = () => {
const { uiConfig } = useUiConfig(); const { uiConfig } = useUiConfig();
@ -56,7 +57,7 @@ const EditEnvironment = () => {
}; };
const handleCancel = () => { const handleCancel = () => {
navigate(-1); navigate(GO_BACK);
}; };
return ( return (

View File

@ -11,6 +11,7 @@ import { CreateButton } from 'component/common/CreateButton/CreateButton';
import UIContext from 'contexts/UIContext'; import UIContext from 'contexts/UIContext';
import { CF_CREATE_BTN_ID } from 'utils/testIds'; import { CF_CREATE_BTN_ID } from 'utils/testIds';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { GO_BACK } from 'constants/navigate';
const CreateFeature = () => { const CreateFeature = () => {
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
@ -70,7 +71,7 @@ const CreateFeature = () => {
}; };
const handleCancel = () => { const handleCancel = () => {
navigate(-1); navigate(GO_BACK);
}; };
return ( return (

View File

@ -11,6 +11,7 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { GO_BACK } from 'constants/navigate';
const EditFeature = () => { const EditFeature = () => {
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
@ -74,7 +75,7 @@ const EditFeature = () => {
}; };
const handleCancel = () => { const handleCancel = () => {
navigate(-1); navigate(GO_BACK);
}; };
return ( return (

View File

@ -16,13 +16,14 @@ import {
createStrategyPayload, createStrategyPayload,
featureStrategyDocsLinkLabel, featureStrategyDocsLinkLabel,
} from '../FeatureStrategyEdit/FeatureStrategyEdit'; } from '../FeatureStrategyEdit/FeatureStrategyEdit';
import { getStrategyObject } from 'utils/getStrategyObject';
import { useStrategies } from 'hooks/api/getters/useStrategies/useStrategies'; import { useStrategies } from 'hooks/api/getters/useStrategies/useStrategies';
import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions'; import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import { ISegment } from 'interfaces/segment'; import { ISegment } from 'interfaces/segment';
import { useSegmentsApi } from 'hooks/api/actions/useSegmentsApi/useSegmentsApi'; import { useSegmentsApi } from 'hooks/api/actions/useSegmentsApi/useSegmentsApi';
import { formatStrategyName } from 'utils/strategyNames'; import { formatStrategyName } from 'utils/strategyNames';
import { useFeatureImmutable } from 'hooks/api/getters/useFeature/useFeatureImmutable'; import { useFeatureImmutable } from 'hooks/api/getters/useFeature/useFeatureImmutable';
import { useFormErrors } from 'hooks/useFormErrors';
import { createFeatureStrategy } from 'utils/createFeatureStrategy';
export const FeatureStrategyCreate = () => { export const FeatureStrategyCreate = () => {
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
@ -32,6 +33,7 @@ export const FeatureStrategyCreate = () => {
const [strategy, setStrategy] = useState<Partial<IFeatureStrategy>>({}); const [strategy, setStrategy] = useState<Partial<IFeatureStrategy>>({});
const [segments, setSegments] = useState<ISegment[]>([]); const [segments, setSegments] = useState<ISegment[]>([]);
const { strategies } = useStrategies(); const { strategies } = useStrategies();
const errors = useFormErrors();
const { addStrategyToFeature, loading } = useFeatureStrategyApi(); const { addStrategyToFeature, loading } = useFeatureStrategyApi();
const { setStrategySegments } = useSegmentsApi(); const { setStrategySegments } = useSegmentsApi();
@ -45,10 +47,15 @@ export const FeatureStrategyCreate = () => {
featureId featureId
); );
const strategyDefinition = strategies.find(strategy => {
return strategy.name === strategyName;
});
useEffect(() => { useEffect(() => {
// Fill in the default values once the strategies have been fetched. if (strategyDefinition) {
setStrategy(getStrategyObject(strategies, strategyName, featureId)); setStrategy(createFeatureStrategy(featureId, strategyDefinition));
}, [strategies, strategyName, featureId]); }
}, [featureId, strategyDefinition]);
const onSubmit = async () => { const onSubmit = async () => {
try { try {
@ -105,6 +112,7 @@ export const FeatureStrategyCreate = () => {
onSubmit={onSubmit} onSubmit={onSubmit}
loading={loading} loading={loading}
permission={CREATE_FEATURE_STRATEGY} permission={CREATE_FEATURE_STRATEGY}
errors={errors}
/> />
</FormTemplate> </FormTemplate>
); );

View File

@ -15,6 +15,7 @@ import { useSegmentsApi } from 'hooks/api/actions/useSegmentsApi/useSegmentsApi'
import { useSegments } from 'hooks/api/getters/useSegments/useSegments'; import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import { formatStrategyName } from 'utils/strategyNames'; import { formatStrategyName } from 'utils/strategyNames';
import { useFeatureImmutable } from 'hooks/api/getters/useFeature/useFeatureImmutable'; import { useFeatureImmutable } from 'hooks/api/getters/useFeature/useFeatureImmutable';
import { useFormErrors } from 'hooks/useFormErrors';
export const FeatureStrategyEdit = () => { export const FeatureStrategyEdit = () => {
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
@ -27,6 +28,7 @@ export const FeatureStrategyEdit = () => {
const { updateStrategyOnFeature, loading } = useFeatureStrategyApi(); const { updateStrategyOnFeature, loading } = useFeatureStrategyApi();
const { setStrategySegments } = useSegmentsApi(); const { setStrategySegments } = useSegmentsApi();
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
const errors = useFormErrors();
const { uiConfig } = useUiConfig(); const { uiConfig } = useUiConfig();
const { unleashUrl } = uiConfig; const { unleashUrl } = uiConfig;
const navigate = useNavigate(); const navigate = useNavigate();
@ -115,6 +117,7 @@ export const FeatureStrategyEdit = () => {
onSubmit={onSubmit} onSubmit={onSubmit}
loading={loading} loading={loading}
permission={UPDATE_FEATURE_STRATEGY} permission={UPDATE_FEATURE_STRATEGY}
errors={errors}
/> />
</FormTemplate> </FormTemplate>
); );

View File

@ -1,5 +1,9 @@
import React, { useState, useContext } from 'react'; import React, { useState, useContext } from 'react';
import { IFeatureStrategy } from 'interfaces/strategy'; import {
IFeatureStrategy,
IFeatureStrategyParameters,
IStrategyParameter,
} from 'interfaces/strategy';
import { FeatureStrategyType } from '../FeatureStrategyType/FeatureStrategyType'; import { FeatureStrategyType } from '../FeatureStrategyType/FeatureStrategyType';
import { FeatureStrategyEnabled } from '../FeatureStrategyEnabled/FeatureStrategyEnabled'; import { FeatureStrategyEnabled } from '../FeatureStrategyEnabled/FeatureStrategyEnabled';
import { FeatureStrategyConstraints } from '../FeatureStrategyConstraints/FeatureStrategyConstraints'; import { FeatureStrategyConstraints } from '../FeatureStrategyConstraints/FeatureStrategyConstraints';
@ -20,6 +24,9 @@ import AccessContext from 'contexts/AccessContext';
import PermissionButton from 'component/common/PermissionButton/PermissionButton'; import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import { FeatureStrategySegment } from 'component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegment'; import { FeatureStrategySegment } from 'component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegment';
import { ISegment } from 'interfaces/segment'; 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 { interface IFeatureStrategyFormProps {
feature: IFeatureToggle; feature: IFeatureToggle;
@ -33,6 +40,7 @@ interface IFeatureStrategyFormProps {
>; >;
segments: ISegment[]; segments: ISegment[];
setSegments: React.Dispatch<React.SetStateAction<ISegment[]>>; setSegments: React.Dispatch<React.SetStateAction<ISegment[]>>;
errors: IFormErrors;
} }
export const FeatureStrategyForm = ({ export const FeatureStrategyForm = ({
@ -45,44 +53,81 @@ export const FeatureStrategyForm = ({
setStrategy, setStrategy,
segments, segments,
setSegments, setSegments,
errors,
}: IFeatureStrategyFormProps) => { }: IFeatureStrategyFormProps) => {
const { classes: styles } = useStyles(); const { classes: styles } = useStyles();
const [showProdGuard, setShowProdGuard] = useState(false); const [showProdGuard, setShowProdGuard] = useState(false);
const hasValidConstraints = useConstraintsValidation(strategy.constraints); const hasValidConstraints = useConstraintsValidation(strategy.constraints);
const enableProdGuard = useFeatureStrategyProdGuard(feature, environmentId); const enableProdGuard = useFeatureStrategyProdGuard(feature, environmentId);
const { hasAccess } = useContext(AccessContext); const { hasAccess } = useContext(AccessContext);
const { strategies } = useStrategies();
const navigate = useNavigate(); const navigate = useNavigate();
const strategyDefinition = strategies.find(definition => {
return definition.name === strategy.name;
});
const { const {
uiConfig, uiConfig,
error: uiConfigError, error: uiConfigError,
loading: uiConfigLoading, loading: uiConfigLoading,
} = useUiConfig(); } = 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 = () => { const onCancel = () => {
navigate(formatFeaturePath(feature.project, feature.name)); navigate(formatFeaturePath(feature.project, feature.name));
}; };
const onSubmitOrProdGuard = async (event: React.FormEvent) => { const onSubmitWithValidation = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
if (enableProdGuard) { if (!validateAllParameters()) {
return;
} else if (enableProdGuard) {
setShowProdGuard(true); setShowProdGuard(true);
} else { } else {
onSubmit(); onSubmit();
} }
}; };
if (uiConfigError) {
throw uiConfigError;
}
// Wait for uiConfig to load to get the correct flags.
if (uiConfigLoading) {
return null;
}
return ( return (
<form className={styles.form} onSubmit={onSubmitOrProdGuard}> <form className={styles.form} onSubmit={onSubmitWithValidation}>
<div> <div>
<FeatureStrategyEnabled <FeatureStrategyEnabled
feature={feature} feature={feature}
@ -118,7 +163,10 @@ export const FeatureStrategyForm = ({
/> />
<FeatureStrategyType <FeatureStrategyType
strategy={strategy} strategy={strategy}
strategyDefinition={strategyDefinition}
setStrategy={setStrategy} setStrategy={setStrategy}
validateParameter={validateParameter}
errors={errors}
hasAccess={hasAccess( hasAccess={hasAccess(
permission, permission,
feature.project, feature.project,
@ -134,7 +182,11 @@ export const FeatureStrategyForm = ({
variant="contained" variant="contained"
color="primary" color="primary"
type="submit" type="submit"
disabled={loading || !hasValidConstraints} disabled={
loading ||
!hasValidConstraints ||
errors.hasFormErrors()
}
data-testid={STRATEGY_FORM_SUBMIT_ID} data-testid={STRATEGY_FORM_SUBMIT_ID}
> >
Save strategy Save strategy
@ -147,7 +199,6 @@ export const FeatureStrategyForm = ({
> >
Cancel Cancel
</Button> </Button>
<FeatureStrategyProdGuard <FeatureStrategyProdGuard
open={showProdGuard} open={showProdGuard}
onClose={() => setShowProdGuard(false)} onClose={() => setShowProdGuard(false)}

View File

@ -16,7 +16,7 @@ export const FeatureStrategyIcons = ({
return ( return (
<StyledList aria-label="Feature strategies"> <StyledList aria-label="Feature strategies">
{strategies.map(strategy => ( {strategies.map(strategy => (
<StyledListItem key={strategy.name}> <StyledListItem key={strategy.id}>
<FeatureStrategyIcon strategyName={strategy.name} /> <FeatureStrategyIcon strategyName={strategy.name} />
</StyledListItem> </StyledListItem>
))} ))}

View File

@ -1,46 +1,44 @@
import { IFeatureStrategy } from 'interfaces/strategy'; import { IFeatureStrategy, IStrategy } from 'interfaces/strategy';
import DefaultStrategy from 'component/feature/StrategyTypes/DefaultStrategy/DefaultStrategy'; import DefaultStrategy from 'component/feature/StrategyTypes/DefaultStrategy/DefaultStrategy';
import FlexibleStrategy from 'component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy'; import FlexibleStrategy from 'component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy';
import UserWithIdStrategy from 'component/feature/StrategyTypes/UserWithIdStrategy/UserWithId'; import UserWithIdStrategy from 'component/feature/StrategyTypes/UserWithIdStrategy/UserWithId';
import GeneralStrategy from 'component/feature/StrategyTypes/GeneralStrategy/GeneralStrategy'; 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 useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
import produce from 'immer'; import produce from 'immer';
import React from 'react'; import React from 'react';
import { IFormErrors } from 'hooks/useFormErrors';
interface IFeatureStrategyTypeProps { interface IFeatureStrategyTypeProps {
hasAccess: boolean; hasAccess: boolean;
strategy: Partial<IFeatureStrategy>; strategy: Partial<IFeatureStrategy>;
strategyDefinition: IStrategy;
setStrategy: React.Dispatch< setStrategy: React.Dispatch<
React.SetStateAction<Partial<IFeatureStrategy>> React.SetStateAction<Partial<IFeatureStrategy>>
>; >;
validateParameter: (name: string, value: string) => boolean;
errors: IFormErrors;
} }
export const FeatureStrategyType = ({ export const FeatureStrategyType = ({
hasAccess, hasAccess,
strategy, strategy,
strategyDefinition,
setStrategy, setStrategy,
validateParameter,
errors,
}: IFeatureStrategyTypeProps) => { }: IFeatureStrategyTypeProps) => {
const { strategies } = useStrategies();
const { context } = useUnleashContext(); const { context } = useUnleashContext();
const strategyDefinition = strategies.find(definition => { const updateParameter = (name: string, value: string) => {
return definition.name === strategy.name;
});
const updateParameter = (field: string, value: string) => {
setStrategy( setStrategy(
produce(draft => { produce(draft => {
draft.parameters = draft.parameters ?? {}; draft.parameters = draft.parameters ?? {};
draft.parameters[field] = value; draft.parameters[name] = value;
}) })
); );
validateParameter(name, value);
}; };
if (!strategyDefinition) {
return null;
}
switch (strategy.name) { switch (strategy.name) {
case 'default': case 'default':
return <DefaultStrategy strategyDefinition={strategyDefinition} />; return <DefaultStrategy strategyDefinition={strategyDefinition} />;
@ -59,6 +57,7 @@ export const FeatureStrategyType = ({
parameters={strategy.parameters ?? {}} parameters={strategy.parameters ?? {}}
updateParameter={updateParameter} updateParameter={updateParameter}
editable={hasAccess} editable={hasAccess}
errors={errors}
/> />
); );
default: default:
@ -68,6 +67,7 @@ export const FeatureStrategyType = ({
parameters={strategy.parameters ?? {}} parameters={strategy.parameters ?? {}}
updateParameter={updateParameter} updateParameter={updateParameter}
editable={hasAccess} editable={hasAccess}
errors={errors}
/> />
); );
} }

View File

@ -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),
},
}));

View File

@ -1,190 +1,47 @@
import React from 'react'; 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 { IStrategy, IFeatureStrategyParameters } from 'interfaces/strategy';
import { useStyles } from './GeneralStrategy.styles'; import { styled } from '@mui/system';
import { import { StrategyParameter } from 'component/feature/StrategyTypes/StrategyParameter/StrategyParameter';
parseParameterNumber, import { IFormErrors } from 'hooks/useFormErrors';
parseParameterStrings,
parseParameterString,
} from 'utils/parseParameter';
interface IGeneralStrategyProps { interface IGeneralStrategyProps {
parameters: IFeatureStrategyParameters; parameters: IFeatureStrategyParameters;
strategyDefinition: IStrategy; strategyDefinition: IStrategy;
updateParameter: (field: string, value: string) => void; updateParameter: (field: string, value: string) => void;
editable: boolean; editable: boolean;
errors: IFormErrors;
} }
const StyledContainer = styled('div')(({ theme }) => ({
display: 'grid',
gap: theme.spacing(4),
}));
const GeneralStrategy = ({ const GeneralStrategy = ({
parameters, parameters,
strategyDefinition, strategyDefinition,
updateParameter, updateParameter,
editable, editable,
errors,
}: IGeneralStrategyProps) => { }: 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) { if (!strategyDefinition || strategyDefinition.parameters.length === 0) {
return null; return null;
} }
return ( return (
<div className={styles.container}> <StyledContainer>
{strategyDefinition.parameters.map( {strategyDefinition.parameters.map((definition, index) => (
({ name, type, description, required }) => { <div key={index}>
if (type === 'percentage') { <StrategyParameter
const value = parseParameterNumber(parameters[name]); definition={definition}
return ( parameters={parameters}
<div key={name}> updateParameter={updateParameter}
<RolloutSlider editable={editable}
name={name} errors={errors}
onChange={onChangePercentage.bind( />
this, </div>
name ))}
)} </StyledContainer>
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>
); );
}; };

View File

@ -87,7 +87,6 @@ const RolloutSlider = ({
> >
{name} {name}
</Typography> </Typography>
<br />
<StyledSlider <StyledSlider
min={0} min={0}
max={100} max={100}

View File

@ -11,12 +11,14 @@ import { Add } from '@mui/icons-material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { ADD_TO_STRATEGY_INPUT_LIST, STRATEGY_INPUT_LIST } from 'utils/testIds'; import { ADD_TO_STRATEGY_INPUT_LIST, STRATEGY_INPUT_LIST } from 'utils/testIds';
import StringTruncator from 'component/common/StringTruncator/StringTruncator'; import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { IFormErrors } from 'hooks/useFormErrors';
interface IStrategyInputList { interface IStrategyInputList {
name: string; name: string;
list: string[]; list: string[];
setConfig: (field: string, value: string) => void; setConfig: (field: string, value: string) => void;
disabled: boolean; disabled: boolean;
errors: IFormErrors;
} }
const Container = styled('div')(({ theme }) => ({ const Container = styled('div')(({ theme }) => ({
@ -32,6 +34,7 @@ const ChipsList = styled('div')(({ theme }) => ({
const InputContainer = styled('div')(({ theme }) => ({ const InputContainer = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
gap: theme.spacing(1), gap: theme.spacing(1),
alignItems: 'start',
})); }));
const StrategyInputList = ({ const StrategyInputList = ({
@ -39,6 +42,7 @@ const StrategyInputList = ({
list, list,
setConfig, setConfig,
disabled, disabled,
errors,
}: IStrategyInputList) => { }: IStrategyInputList) => {
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const ENTERKEY = 'Enter'; const ENTERKEY = 'Enter';
@ -120,6 +124,8 @@ const StrategyInputList = ({
show={ show={
<InputContainer> <InputContainer>
<TextField <TextField
error={Boolean(errors.getFormError(name))}
helperText={errors.getFormError(name)}
name={`input_field`} name={`input_field`}
variant="outlined" variant="outlined"
label="Add items" label="Add items"

View File

@ -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>
);
};

View File

@ -1,17 +1,20 @@
import { IFeatureStrategyParameters } from 'interfaces/strategy'; import { IFeatureStrategyParameters } from 'interfaces/strategy';
import StrategyInputList from '../StrategyInputList/StrategyInputList'; import StrategyInputList from '../StrategyInputList/StrategyInputList';
import { parseParameterStrings } from 'utils/parseParameter'; import { parseParameterStrings } from 'utils/parseParameter';
import { IFormErrors } from 'hooks/useFormErrors';
interface IUserWithIdStrategyProps { interface IUserWithIdStrategyProps {
parameters: IFeatureStrategyParameters; parameters: IFeatureStrategyParameters;
updateParameter: (field: string, value: string) => void; updateParameter: (field: string, value: string) => void;
editable: boolean; editable: boolean;
errors: IFormErrors;
} }
const UserWithIdStrategy = ({ const UserWithIdStrategy = ({
editable, editable,
parameters, parameters,
updateParameter, updateParameter,
errors,
}: IUserWithIdStrategyProps) => { }: IUserWithIdStrategyProps) => {
return ( return (
<div> <div>
@ -20,6 +23,7 @@ const UserWithIdStrategy = ({
list={parseParameterStrings(parameters.userIds)} list={parseParameterStrings(parameters.userIds)}
disabled={!editable} disabled={!editable}
setConfig={updateParameter} setConfig={updateParameter}
errors={errors}
/> />
</div> </div>
); );

View File

@ -80,16 +80,7 @@ exports[`returns all baseRoutes 1`] = `
"flag": "P", "flag": "P",
"menu": {}, "menu": {},
"parent": "/projects", "parent": "/projects",
"path": "/projects/:projectId/:activeTab", "path": "/projects/:projectId/*",
"title": ":projectId",
"type": "protected",
},
{
"component": [Function],
"flag": "P",
"menu": {},
"parent": "/projects",
"path": "/projects/:projectId",
"title": ":projectId", "title": ":projectId",
"type": "protected", "type": "protected",
}, },

View File

@ -136,16 +136,7 @@ export const routes: IRoute[] = [
menu: {}, menu: {},
}, },
{ {
path: '/projects/:projectId/:activeTab', path: '/projects/:projectId/*',
parent: '/projects',
title: ':projectId',
component: Project,
flag: P,
type: 'protected',
menu: {},
},
{
path: '/projects/:projectId',
parent: '/projects', parent: '/projects',
title: ':projectId', title: ':projectId',
component: Project, component: Project,

View File

@ -9,6 +9,7 @@ import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { GO_BACK } from 'constants/navigate';
const CreateProject = () => { const CreateProject = () => {
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
@ -65,7 +66,7 @@ const CreateProject = () => {
}; };
const handleCancel = () => { const handleCancel = () => {
navigate(-1); navigate(GO_BACK);
}; };
return ( return (

View File

@ -13,6 +13,7 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useContext } from 'react'; import { useContext } from 'react';
import AccessContext from 'contexts/AccessContext'; import AccessContext from 'contexts/AccessContext';
import { Alert } from '@mui/material'; import { Alert } from '@mui/material';
import { GO_BACK } from 'constants/navigate';
const EditProject = () => { const EditProject = () => {
const { uiConfig } = useUiConfig(); const { uiConfig } = useUiConfig();
@ -70,7 +71,7 @@ const EditProject = () => {
}; };
const handleCancel = () => { const handleCancel = () => {
navigate(-1); navigate(GO_BACK);
}; };
const accessDeniedAlert = !hasAccess(UPDATE_PROJECT, projectId) && ( const accessDeniedAlert = !hasAccess(UPDATE_PROJECT, projectId) && (

View File

@ -16,80 +16,54 @@ import ProjectOverview from './ProjectOverview';
import ProjectHealth from './ProjectHealth/ProjectHealth'; import ProjectHealth from './ProjectHealth/ProjectHealth';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions'; import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions';
import { TabPanel } from 'component/common/TabNav/TabPanel/TabPanel';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useOptionalPathParam } from 'hooks/useOptionalPathParam';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { Routes, Route, useLocation } from 'react-router-dom';
const Project = () => { const Project = () => {
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const activeTab = useOptionalPathParam('activeTab');
const params = useQueryParams(); const params = useQueryParams();
const { project, error, loading, refetch } = useProject(projectId); const { project, error, loading, refetch } = useProject(projectId);
const ref = useLoading(loading); const ref = useLoading(loading);
const { setToastData } = useToast(); const { setToastData } = useToast();
const { classes: styles } = useStyles(); const { classes: styles } = useStyles();
const navigate = useNavigate(); const navigate = useNavigate();
const { pathname } = useLocation();
const { isOss } = useUiConfig(); const { isOss } = useUiConfig();
const basePath = `/projects/${projectId}`; const basePath = `/projects/${projectId}`;
const projectName = project?.name || projectId; const projectName = project?.name || projectId;
const tabData = [
const tabs = [
{ {
title: 'Overview', title: 'Overview',
component: (
<ProjectOverview
projectId={projectId}
projectName={projectName}
/>
),
path: basePath, path: basePath,
name: 'overview', name: 'overview',
}, },
{ {
title: 'Health', title: 'Health',
component: (
<ProjectHealth
projectId={projectId}
projectName={projectName}
/>
),
path: `${basePath}/health`, path: `${basePath}/health`,
name: 'health', name: 'health',
}, },
{ {
title: 'Access', title: 'Access',
component: <ProjectAccess projectName={projectName} />,
path: `${basePath}/access`, path: `${basePath}/access`,
name: 'access', name: 'access',
}, },
{ {
title: 'Environments', title: 'Environments',
component: (
<ProjectEnvironment
projectId={projectId}
projectName={projectName}
/>
),
path: `${basePath}/environments`, path: `${basePath}/environments`,
name: 'environments', name: 'environments',
}, },
{ {
title: 'Archive', title: 'Archive',
component: (
<ProjectFeaturesArchive
projectId={projectId}
projectName={projectName}
/>
),
path: `${basePath}/archive`, path: `${basePath}/archive`,
name: 'archive', name: 'archive',
}, },
]; ];
const activeTabIdx = activeTab const activeTab = [...tabs]
? tabData.findIndex(tab => tab.name === activeTab) .reverse()
: 0; .find(tab => pathname.startsWith(tab.path));
useEffect(() => { useEffect(() => {
const created = params.get('created'); const created = params.get('created');
@ -107,13 +81,13 @@ const Project = () => {
}, []); }, []);
const renderTabs = () => { const renderTabs = () => {
return tabData.map((tab, index) => { return tabs.map(tab => {
return ( return (
<Tab <Tab
data-loading
key={tab.title} key={tab.title}
id={`tab-${index}`}
aria-controls={`tabpanel-${index}`}
label={tab.title} label={tab.title}
value={tab.path}
onClick={() => navigate(tab.path)} onClick={() => navigate(tab.path)}
className={styles.tabButton} 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 ( return (
<div ref={ref}> <div ref={ref}>
<div className={styles.header}> <div className={styles.header}>
@ -167,7 +131,7 @@ const Project = () => {
<div className={styles.separator} /> <div className={styles.separator} />
<div className={styles.tabContainer}> <div className={styles.tabContainer}>
<Tabs <Tabs
value={activeTabIdx} value={activeTab?.path}
indicatorColor="primary" indicatorColor="primary"
textColor="primary" textColor="primary"
> >
@ -175,7 +139,13 @@ const Project = () => {
</Tabs> </Tabs>
</div> </div>
</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> </div>
); );
}; };

View File

@ -1,15 +1,11 @@
import { ProjectFeaturesArchiveTable } from 'component/archive/ProjectFeaturesArchiveTable'; import { ProjectFeaturesArchiveTable } from 'component/archive/ProjectFeaturesArchiveTable';
import { usePageTitle } from 'hooks/usePageTitle'; import { usePageTitle } from 'hooks/usePageTitle';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useProjectNameOrId } from 'hooks/api/getters/useProject/useProject';
interface IProjectFeaturesArchiveProps { export const ProjectFeaturesArchive = () => {
projectId: string; const projectId = useRequiredPathParam('projectId');
projectName: string; const projectName = useProjectNameOrId(projectId);
}
export const ProjectFeaturesArchive = ({
projectId,
projectName,
}: IProjectFeaturesArchiveProps) => {
usePageTitle(`Project archive ${projectName}`); usePageTitle(`Project archive ${projectName}`);
return <ProjectFeaturesArchiveTable projectId={projectId} />; return <ProjectFeaturesArchiveTable projectId={projectId} />;

View File

@ -4,13 +4,12 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
import { usePageTitle } from 'hooks/usePageTitle'; import { usePageTitle } from 'hooks/usePageTitle';
import { ReportCard } from './ReportTable/ReportCard/ReportCard'; import { ReportCard } from './ReportTable/ReportCard/ReportCard';
import { ReportTable } from './ReportTable/ReportTable'; import { ReportTable } from './ReportTable/ReportTable';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useProjectNameOrId } from 'hooks/api/getters/useProject/useProject';
interface IProjectHealthProps { const ProjectHealth = () => {
projectId: string; const projectId = useRequiredPathParam('projectId');
projectName: string; const projectName = useProjectNameOrId(projectId);
}
const ProjectHealth = ({ projectId, projectName }: IProjectHealthProps) => {
usePageTitle(`Project health ${projectName}`); usePageTitle(`Project health ${projectName}`);
const { healthReport, refetchHealthReport, error } = useHealthReport( const { healthReport, refetchHealthReport, error } = useHealthReport(

View File

@ -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 { ProjectFeatureToggles } from './ProjectFeatureToggles/ProjectFeatureToggles';
import ProjectInfo from './ProjectInfo/ProjectInfo'; import ProjectInfo from './ProjectInfo/ProjectInfo';
import { useStyles } from './Project.styles'; import { useStyles } from './Project.styles';
import { usePageTitle } from 'hooks/usePageTitle'; import { usePageTitle } from 'hooks/usePageTitle';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
interface IProjectOverviewProps { const refreshInterval = 15 * 1000;
projectName: string;
projectId: string;
}
const ProjectOverview = ({ projectId, projectName }: IProjectOverviewProps) => { const ProjectOverview = () => {
const { project, loading } = useProject(projectId, { const projectId = useRequiredPathParam('projectId');
refreshInterval: 15 * 1000, // ms const projectName = useProjectNameOrId(projectId);
}); const { project, loading } = useProject(projectId, { refreshInterval });
const { members, features, health, description, environments } = project; const { members, features, health, description, environments } = project;
const { classes: styles } = useStyles(); const { classes: styles } = useStyles();
usePageTitle(`Project overview ${projectName}`); usePageTitle(`Project overview ${projectName}`);

View File

@ -1,4 +1,4 @@
import { useContext, VFC } from 'react'; import { useContext } from 'react';
import { PageContent } from 'component/common/PageContent/PageContent'; import { PageContent } from 'component/common/PageContent/PageContent';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { Alert } from '@mui/material'; import { Alert } from '@mui/material';
@ -8,14 +8,11 @@ import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { usePageTitle } from 'hooks/usePageTitle'; import { usePageTitle } from 'hooks/usePageTitle';
import { ProjectAccessTable } from 'component/project/ProjectAccess/ProjectAccessTable/ProjectAccessTable'; import { ProjectAccessTable } from 'component/project/ProjectAccess/ProjectAccessTable/ProjectAccessTable';
import { useProjectNameOrId } from 'hooks/api/getters/useProject/useProject';
interface IProjectAccess { export const ProjectAccess = () => {
projectName: string;
}
export const ProjectAccess: VFC<IProjectAccess> = ({ projectName }) => {
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const projectName = useProjectNameOrId(projectId);
const { hasAccess } = useContext(AccessContext); const { hasAccess } = useContext(AccessContext);
const { isOss } = useUiConfig(); const { isOss } = useUiConfig();
usePageTitle(`Project access ${projectName}`); usePageTitle(`Project access ${projectName}`);

View File

@ -1,4 +1,4 @@
import React, { FormEvent, useEffect, useMemo, useState } from 'react'; import React, { FormEvent, useState } from 'react';
import { import {
Autocomplete, Autocomplete,
Button, Button,
@ -25,7 +25,8 @@ import { IUser } from 'interfaces/user';
import { IGroup } from 'interfaces/group'; import { IGroup } from 'interfaces/group';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { ProjectRoleDescription } from './ProjectRoleDescription/ProjectRoleDescription'; 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')(() => ({ const StyledForm = styled('form')(() => ({
display: 'flex', display: 'flex',
@ -88,96 +89,83 @@ interface IAccessOption {
} }
interface IProjectAccessAssignProps { interface IProjectAccessAssignProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
selected?: IProjectAccess; selected?: IProjectAccess;
accesses: IProjectAccess[]; accesses: IProjectAccess[];
users: IUser[];
groups: IGroup[];
roles: IProjectRole[]; roles: IProjectRole[];
entityType: string;
} }
export const ProjectAccessAssign = ({ export const ProjectAccessAssign = ({
open,
setOpen,
selected, selected,
accesses, accesses,
users,
groups,
roles, roles,
entityType,
}: IProjectAccessAssignProps) => { }: IProjectAccessAssignProps) => {
const { uiConfig } = useUiConfig();
const { flags } = uiConfig;
const entityType = flags.UG ? 'user / group' : 'user';
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const { refetchProjectAccess } = useProjectAccess(projectId); const { refetchProjectAccess } = useProjectAccess(projectId);
const { addAccessToProject, changeUserRole, changeGroupRole, loading } = const { addAccessToProject, changeUserRole, changeGroupRole, loading } =
useProjectApi(); useProjectApi();
const { users, groups } = useAccess();
const edit = Boolean(selected); const edit = Boolean(selected);
const { setToastData, setToastApiError } = useToast(); 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>( const [role, setRole] = useState<IProjectRole | null>(
roles.find(({ id }) => id === selected?.entity.roleId) ?? null roles.find(({ id }) => id === selected?.entity.roleId) ?? null
); );
useEffect(() => { const payload = {
setRole(roles.find(({ id }) => id === selected?.entity.roleId) ?? null); users: selectedOptions
}, [roles, selected]); ?.filter(({ type }) => type === ENTITY_TYPE.USER)
.map(({ id }) => ({ id })),
const payload = useMemo( groups: selectedOptions
() => ({ ?.filter(({ type }) => type === ENTITY_TYPE.GROUP)
users: selectedOptions .map(({ id }) => ({ id })),
?.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 handleSubmit = async (e: FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
@ -193,7 +181,7 @@ export const ProjectAccessAssign = ({
await changeGroupRole(projectId, role.id, selected.entity.id); await changeGroupRole(projectId, role.id, selected.entity.id);
} }
refetchProjectAccess(); refetchProjectAccess();
setOpen(false); navigate(GO_BACK);
setToastData({ setToastData({
title: `${selectedOptions.length} ${ title: `${selectedOptions.length} ${
selectedOptions.length === 1 ? 'access' : 'accesses' selectedOptions.length === 1 ? 'access' : 'accesses'
@ -277,10 +265,8 @@ export const ProjectAccessAssign = ({
return ( return (
<SidebarModal <SidebarModal
open={open} open
onClose={() => { onClose={() => navigate(GO_BACK)}
setOpen(false);
}}
label={`${!edit ? 'Assign' : 'Edit'} ${entityType} access`} label={`${!edit ? 'Assign' : 'Edit'} ${entityType} access`}
> >
<FormTemplate <FormTemplate
@ -373,11 +359,7 @@ export const ProjectAccessAssign = ({
> >
Assign {entityType} Assign {entityType}
</Button> </Button>
<StyledCancelButton <StyledCancelButton onClick={() => navigate(GO_BACK)}>
onClick={() => {
setOpen(false);
}}
>
Cancel Cancel
</StyledCancelButton> </StyledCancelButton>
</StyledButtonContainer> </StyledButtonContainer>

View File

@ -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}
/>
);
};

View File

@ -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}
/>
);
};

View File

@ -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}
/>
);
};

View File

@ -15,14 +15,19 @@ import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useSearch } from 'hooks/useSearch'; 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 { createLocalStorage } from 'utils/createLocalStorage';
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell'; import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell'; import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell';
import { PageContent } from 'component/common/PageContent/PageContent'; import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader'; import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { Search } from 'component/common/Search/Search'; import { Search } from 'component/common/Search/Search';
import { ProjectAccessAssign } from 'component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign';
import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi'; import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import { Dialogue } from 'component/common/Dialogue/Dialogue'; import { Dialogue } from 'component/common/Dialogue/Dialogue';
@ -33,6 +38,9 @@ import { IUser } from 'interfaces/user';
import { IGroup } from 'interfaces/group'; import { IGroup } from 'interfaces/group';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell'; import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar'; 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< export type PageQueryType = Partial<
Record<'sort' | 'order' | 'search', string> Record<'sort' | 'order' | 'search', string>
@ -52,44 +60,17 @@ export const ProjectAccessTable: VFC = () => {
const { flags } = uiConfig; const { flags } = uiConfig;
const entityType = flags.UG ? 'user / group' : 'user'; const entityType = flags.UG ? 'user / group' : 'user';
const navigate = useNavigate();
const theme = useTheme(); const theme = useTheme();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const { setToastData } = useToast(); const { setToastData } = useToast();
const { access, refetchProjectAccess } = useProjectAccess(projectId); const { access, refetchProjectAccess } = useProjectAccess(projectId);
const { removeUserFromRole, removeGroupFromRole } = useProjectApi(); const { removeUserFromRole, removeGroupFromRole } = useProjectApi();
const [assignOpen, setAssignOpen] = useState(false);
const [removeOpen, setRemoveOpen] = useState(false); const [removeOpen, setRemoveOpen] = useState(false);
const [groupOpen, setGroupOpen] = useState(false); const [groupOpen, setGroupOpen] = useState(false);
const [selectedRow, setSelectedRow] = useState<IProjectAccess>(); 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( const columns = useMemo(
() => [ () => [
{ {
@ -145,7 +126,8 @@ export const ProjectAccessTable: VFC = () => {
{ {
Header: 'Role', Header: 'Role',
accessor: (row: IProjectAccess) => accessor: (row: IProjectAccess) =>
roles.find(({ id }) => id === row.entity.roleId)?.name, access?.roles.find(({ id }) => id === row.entity.roleId)
?.name,
minWidth: 120, minWidth: 120,
filterName: 'role', filterName: 'role',
}, },
@ -187,19 +169,23 @@ export const ProjectAccessTable: VFC = () => {
disableSortBy: true, disableSortBy: true,
align: 'center', align: 'center',
maxWidth: 200, maxWidth: 200,
Cell: ({ row: { original: row } }: any) => ( Cell: ({
row: { original: row },
}: {
row: { original: IProjectAccess };
}) => (
<ActionCell> <ActionCell>
<PermissionIconButton <PermissionIconButton
component={Link}
permission={UPDATE_PROJECT} permission={UPDATE_PROJECT}
projectId={projectId} projectId={projectId}
onClick={() => { to={`edit/${
setSelectedRow(row); row.type === ENTITY_TYPE.USER ? 'user' : 'group'
setAssignOpen(true); }/${row.entity.id}`}
}} disabled={access?.rows.length === 1}
disabled={mappedData.length === 1}
tooltipProps={{ tooltipProps={{
title: title:
mappedData.length === 1 access?.rows.length === 1
? 'Cannot edit access. A project must have at least one owner' ? 'Cannot edit access. A project must have at least one owner'
: 'Edit access', : 'Edit access',
}} }}
@ -213,10 +199,10 @@ export const ProjectAccessTable: VFC = () => {
setSelectedRow(row); setSelectedRow(row);
setRemoveOpen(true); setRemoveOpen(true);
}} }}
disabled={mappedData.length === 1} disabled={access?.rows.length === 1}
tooltipProps={{ tooltipProps={{
title: title:
mappedData.length === 1 access?.rows.length === 1
? 'Cannot remove access. A project must have at least one owner' ? 'Cannot remove access. A project must have at least one owner'
: 'Remove access', : 'Remove access',
}} }}
@ -227,7 +213,7 @@ export const ProjectAccessTable: VFC = () => {
), ),
}, },
], ],
[roles, mappedData.length, projectId] [access, projectId]
); );
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@ -247,7 +233,7 @@ export const ProjectAccessTable: VFC = () => {
const { data, getSearchText, getSearchContext } = useSearch( const { data, getSearchText, getSearchContext } = useSearch(
columns, columns,
searchValue, searchValue,
mappedData ?? [] access?.rows ?? []
); );
const { const {
@ -319,7 +305,6 @@ export const ProjectAccessTable: VFC = () => {
}); });
} }
setRemoveOpen(false); setRemoveOpen(false);
setSelectedRow(undefined);
}; };
return ( return (
<PageContent <PageContent
@ -348,9 +333,10 @@ export const ProjectAccessTable: VFC = () => {
} }
/> />
<Button <Button
component={Link}
to={`create`}
variant="contained" variant="contained"
color="primary" color="primary"
onClick={() => setAssignOpen(true)}
> >
Assign {entityType} Assign {entityType}
</Button> </Button>
@ -399,19 +385,21 @@ export const ProjectAccessTable: VFC = () => {
/> />
} }
/> />
<ProjectAccessAssign <Routes>
open={assignOpen} <Route path="create" element={<ProjectAccessCreate />} />
setOpen={setAssignOpen} <Route
selected={selectedRow} path="edit/group/:groupId"
accesses={mappedData} element={<ProjectAccessEditGroup />}
roles={roles} />
entityType={entityType} <Route
/> path="edit/user/:userId"
element={<ProjectAccessEditUser />}
/>
</Routes>
<Dialogue <Dialogue
open={removeOpen} open={removeOpen}
onClick={() => removeAccess(selectedRow)} onClick={() => removeAccess(selectedRow)}
onClose={() => { onClose={() => {
setSelectedRow(undefined);
setRemoveOpen(false); setRemoveOpen(false);
}} }}
title={`Really remove ${entityType} from this project?`} title={`Really remove ${entityType} from this project?`}
@ -422,12 +410,12 @@ export const ProjectAccessTable: VFC = () => {
group={selectedRow?.entity as IGroup} group={selectedRow?.entity as IGroup}
projectId={projectId} projectId={projectId}
subtitle={`Role: ${ subtitle={`Role: ${
roles.find(({ id }) => id === selectedRow?.entity.roleId) access?.roles.find(
?.name ({ id }) => id === selectedRow?.entity.roleId
)?.name
}`} }`}
onEdit={() => { onEdit={() => {
setAssignOpen(true); navigate(`edit/group/${selectedRow?.entity.id}`);
console.log('Assign Open true');
}} }}
onRemove={() => { onRemove={() => {
setGroupOpen(false); setGroupOpen(false);

View File

@ -8,7 +8,9 @@ import ApiError from 'component/common/ApiError/ApiError';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments'; 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 { FormControlLabel, FormGroup, Alert } from '@mui/material';
import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi'; import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
import EnvironmentDisableConfirm from './EnvironmentDisableConfirm/EnvironmentDisableConfirm'; import EnvironmentDisableConfirm from './EnvironmentDisableConfirm/EnvironmentDisableConfirm';
@ -19,17 +21,13 @@ import { getEnabledEnvs } from './helpers';
import StringTruncator from 'component/common/StringTruncator/StringTruncator'; import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { useThemeStyles } from 'themes/themeStyles'; import { useThemeStyles } from 'themes/themeStyles';
import { usePageTitle } from 'hooks/usePageTitle'; import { usePageTitle } from 'hooks/usePageTitle';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
interface IProjectEnvironmentListProps { const ProjectEnvironmentList = () => {
projectId: string; const projectId = useRequiredPathParam('projectId');
projectName: string; const projectName = useProjectNameOrId(projectId);
}
const ProjectEnvironmentList = ({
projectId,
projectName,
}: IProjectEnvironmentListProps) => {
usePageTitle(`Project environments ${projectName}`); usePageTitle(`Project environments ${projectName}`);
// api state // api state
const [envs, setEnvs] = useState<IProjectEnvironment[]>([]); const [envs, setEnvs] = useState<IProjectEnvironment[]>([]);
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();

View File

@ -9,6 +9,7 @@ import useStrategiesApi from 'hooks/api/actions/useStrategiesApi/useStrategiesAp
import { useStrategies } from 'hooks/api/getters/useStrategies/useStrategies'; import { useStrategies } from 'hooks/api/getters/useStrategies/useStrategies';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { CreateButton } from 'component/common/CreateButton/CreateButton'; import { CreateButton } from 'component/common/CreateButton/CreateButton';
import { GO_BACK } from 'constants/navigate';
export const CreateStrategy = () => { export const CreateStrategy = () => {
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
@ -64,7 +65,7 @@ export const CreateStrategy = () => {
}; };
const handleCancel = () => { const handleCancel = () => {
navigate(-1); navigate(GO_BACK);
}; };
return ( return (

View File

@ -11,6 +11,7 @@ import { formatUnknownError } from 'utils/formatUnknownError';
import useStrategy from 'hooks/api/getters/useStrategy/useStrategy'; import useStrategy from 'hooks/api/getters/useStrategy/useStrategy';
import { UpdateButton } from 'component/common/UpdateButton/UpdateButton'; import { UpdateButton } from 'component/common/UpdateButton/UpdateButton';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { GO_BACK } from 'constants/navigate';
export const EditStrategy = () => { export const EditStrategy = () => {
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
@ -68,7 +69,7 @@ export const EditStrategy = () => {
}; };
const handleCancel = () => { const handleCancel = () => {
navigate(-1); navigate(GO_BACK);
}; };
return ( return (

View File

@ -3,10 +3,11 @@ import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({ export const useStyles = makeStyles()(theme => ({
paramsContainer: { paramsContainer: {
maxWidth: '400px', maxWidth: '400px',
margin: '1rem 0',
}, },
divider: { divider: {
borderStyle: 'dashed', borderStyle: 'dashed',
marginBottom: '1rem !important', margin: '1rem 0 1.5rem 0',
borderColor: theme.palette.grey[500], borderColor: theme.palette.grey[500],
}, },
nameContainer: { nameContainer: {
@ -18,13 +19,17 @@ export const useStyles = makeStyles()(theme => ({
minWidth: '365px', minWidth: '365px',
width: '100%', width: '100%',
}, },
input: { minWidth: '365px', width: '100%', marginBottom: '1rem' }, input: {
minWidth: '365px',
width: '100%',
marginBottom: '1rem',
},
description: { description: {
minWidth: '365px', minWidth: '365px',
marginBottom: '1rem', marginBottom: '1rem',
}, },
checkboxLabel: { checkboxLabel: {
marginBottom: '1rem', marginTop: '-0.5rem',
}, },
inputDescription: { inputDescription: {
marginBottom: '0.5rem', marginBottom: '0.5rem',

View File

@ -8,6 +8,7 @@ import useTagTypesApi from 'hooks/api/actions/useTagTypesApi/useTagTypesApi';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { GO_BACK } from 'constants/navigate';
const CreateTagType = () => { const CreateTagType = () => {
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
@ -55,7 +56,7 @@ const CreateTagType = () => {
}; };
const handleCancel = () => { const handleCancel = () => {
navigate(-1); navigate(GO_BACK);
}; };
return ( return (

View File

@ -10,6 +10,7 @@ import useToast from 'hooks/useToast';
import FormTemplate from 'component/common/FormTemplate/FormTemplate'; import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { GO_BACK } from 'constants/navigate';
const EditTagType = () => { const EditTagType = () => {
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
@ -54,7 +55,7 @@ const EditTagType = () => {
}; };
const handleCancel = () => { const handleCancel = () => {
navigate(-1); navigate(GO_BACK);
}; };
return ( return (

View File

@ -0,0 +1 @@
export const GO_BACK = -1;

View File

@ -1,24 +1,30 @@
import useSWR from 'swr'; import useSWR from 'swr';
import { useMemo } from 'react';
import { formatApiPath } from 'utils/formatPath'; import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler'; 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( const { data, error, mutate } = useSWR(
formatApiPath(`api/admin/user-admin/access`), formatApiPath(`api/admin/user-admin/access`),
fetcher fetcher
); );
return useMemo( return {
() => ({ users: data?.users,
users: data?.users ?? [], groups: data?.groups,
groups: data?.groups ?? [], loading: !error && !data,
loading: !error && !data, refetch: () => mutate(),
refetch: () => mutate(), error,
error, };
}),
[data, error, mutate]
);
}; };
const fetcher = (path: string) => { const fetcher = (path: string) => {

View File

@ -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; export default useProject;

View File

@ -1,5 +1,5 @@
import useSWR, { mutate, SWRConfiguration } from 'swr'; import useSWR, { mutate, SWRConfiguration } from 'swr';
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { formatApiPath } from 'utils/formatPath'; import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler'; import handleErrorResponses from '../httpErrorResponseHandler';
import { IProjectRole } from 'interfaces/role'; import { IProjectRole } from 'interfaces/role';
@ -29,6 +29,7 @@ export interface IProjectAccessOutput {
users: IProjectAccessUser[]; users: IProjectAccessUser[];
groups: IProjectAccessGroup[]; groups: IProjectAccessGroup[];
roles: IProjectRole[]; roles: IProjectRole[];
rows: IProjectAccess[];
} }
const useProjectAccess = ( const useProjectAccess = (
@ -58,23 +59,44 @@ const useProjectAccess = (
setLoading(!error && !data); setLoading(!error && !data);
}, [data, error]); }, [data, error]);
let access: IProjectAccessOutput = data const access: IProjectAccessOutput | undefined = useMemo(() => {
? { if (data) {
roles: data.roles, return formatAccessData({
users: data.users, roles: data.roles,
groups: users: data.users,
data?.groups.map((group: any) => ({ groups:
...group, data?.groups.map((group: any) => ({
users: mapGroupUsers(group.users ?? []), ...group,
})) ?? [], users: mapGroupUsers(group.users ?? []),
} })) ?? [],
: { roles: [], users: [], groups: [] }; });
}
}, [data]);
return { return {
access: access, access,
error, error,
loading, loading,
refetchProjectAccess, 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; export default useProjectAccess;

View 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,
};
};

View 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",
},
}
`);
});

View 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 '';
};

View File

@ -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: [] };
};

View File

@ -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 '';
}
};

View File

@ -1,5 +1,6 @@
/* NAVIGATION */ /* NAVIGATION */
export const NAVIGATE_TO_CREATE_FEATURE = 'NAVIGATE_TO_CREATE_FEATURE'; 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 NAVIGATE_TO_CREATE_SEGMENT = 'NAVIGATE_TO_CREATE_SEGMENT';
export const CREATE_API_TOKEN_BUTTON = 'CREATE_API_TOKEN_BUTTON'; 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 */ /* CREATE GROUP */
export const UG_NAME_ID = 'UG_NAME_ID'; export const UG_NAME_ID = 'UG_NAME_ID';
export const UG_DESC_ID = 'UG_DESC_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_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 */ /* SEGMENT */
export const SEGMENT_NAME_ID = 'SEGMENT_NAME_ID'; 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 AUTH_PAGE_ID = 'AUTH_PAGE_ID';
export const ANNOUNCER_ELEMENT_TEST_ID = 'ANNOUNCER_ELEMENT_TEST_ID'; export const ANNOUNCER_ELEMENT_TEST_ID = 'ANNOUNCER_ELEMENT_TEST_ID';
export const INSTANCE_STATUS_BAR_ID = 'INSTANCE_STATUS_BAR_ID'; export const INSTANCE_STATUS_BAR_ID = 'INSTANCE_STATUS_BAR_ID';
export const TOAST_TEXT = 'TOAST_TEXT';

View 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();
});

View 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));
};