1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +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.parameters.groupId).to.equal(featureToggleName);
expect(req.body.parameters.stickiness).to.equal('default');
expect(req.body.parameters.rollout).to.equal('100');
expect(req.body.parameters.rollout).to.equal('50');
if (ENTERPRISE) {
expect(req.body.constraints.length).to.equal(1);
@ -151,7 +151,7 @@ describe('feature', () => {
req => {
expect(req.body.parameters.groupId).to.equal('new-group-id');
expect(req.body.parameters.stickiness).to.equal('sessionId');
expect(req.body.parameters.rollout).to.equal('100');
expect(req.body.parameters.rollout).to.equal('50');
if (ENTERPRISE) {
expect(req.body.constraints.length).to.equal(1);

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 { useStyles } from './NotFound.styles';
import { GO_BACK } from 'constants/navigate';
const NotFound = () => {
const navigate = useNavigate();
@ -14,7 +15,7 @@ const NotFound = () => {
};
const onClickBack = () => {
navigate(-1);
navigate(GO_BACK);
};
return (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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 { FormControlLabel, Switch, TextField, Tooltip } from '@mui/material';
import StrategyInputList from '../StrategyInputList/StrategyInputList';
import RolloutSlider from '../RolloutSlider/RolloutSlider';
import { IStrategy, IFeatureStrategyParameters } from 'interfaces/strategy';
import { useStyles } from './GeneralStrategy.styles';
import {
parseParameterNumber,
parseParameterStrings,
parseParameterString,
} from 'utils/parseParameter';
import { styled } from '@mui/system';
import { StrategyParameter } from 'component/feature/StrategyTypes/StrategyParameter/StrategyParameter';
import { IFormErrors } from 'hooks/useFormErrors';
interface IGeneralStrategyProps {
parameters: IFeatureStrategyParameters;
strategyDefinition: IStrategy;
updateParameter: (field: string, value: string) => void;
editable: boolean;
errors: IFormErrors;
}
const StyledContainer = styled('div')(({ theme }) => ({
display: 'grid',
gap: theme.spacing(4),
}));
const GeneralStrategy = ({
parameters,
strategyDefinition,
updateParameter,
editable,
errors,
}: IGeneralStrategyProps) => {
const { classes: styles } = useStyles();
const onChangeTextField = (
field: string,
evt: React.ChangeEvent<HTMLInputElement>
) => {
const { value } = evt.currentTarget;
evt.preventDefault();
updateParameter(field, value);
};
const onChangePercentage = (
field: string,
evt: Event,
newValue: number | number[]
) => {
evt.preventDefault();
updateParameter(field, newValue.toString());
};
const handleSwitchChange = (field: string, currentValue: any) => {
const value = currentValue === 'true' ? 'false' : 'true';
updateParameter(field, value);
};
if (!strategyDefinition || strategyDefinition.parameters.length === 0) {
return null;
}
return (
<div className={styles.container}>
{strategyDefinition.parameters.map(
({ name, type, description, required }) => {
if (type === 'percentage') {
const value = parseParameterNumber(parameters[name]);
return (
<div key={name}>
<RolloutSlider
name={name}
onChange={onChangePercentage.bind(
this,
name
)}
disabled={!editable}
value={value}
minLabel="off"
maxLabel="on"
/>
{description && (
<p className={styles.helpText}>
{description}
</p>
)}
</div>
);
} else if (type === 'list') {
const values = parseParameterStrings(parameters[name]);
return (
<div key={name}>
<StrategyInputList
name={name}
list={values}
disabled={!editable}
setConfig={updateParameter}
/>
{description && (
<p className={styles.helpText}>
{description}
</p>
)}
</div>
);
} else if (type === 'number') {
const regex = new RegExp('^\\d+$');
const value = parseParameterString(parameters[name]);
const error =
value.length > 0 ? !regex.test(value) : false;
return (
<div key={name}>
<TextField
error={error}
helperText={
error && `${name} is not a number!`
}
variant="outlined"
size="small"
required={required}
style={{ width: '100%' }}
disabled={!editable}
name={name}
label={name}
onChange={onChangeTextField.bind(
this,
name
)}
value={value}
/>
{description && (
<p className={styles.helpText}>
{description}
</p>
)}
</div>
);
} else if (type === 'boolean') {
const value = parseParameterString(parameters[name]);
return (
<div key={name}>
<Tooltip
title={description}
placement="right-end"
arrow
>
<FormControlLabel
label={name}
control={
<Switch
name={name}
onChange={handleSwitchChange.bind(
this,
name,
value
)}
checked={value === 'true'}
/>
}
/>
</Tooltip>
</div>
);
} else {
const value = parseParameterString(parameters[name]);
return (
<div key={name}>
<TextField
rows={1}
placeholder=""
variant="outlined"
size="small"
style={{ width: '100%' }}
required={required}
disabled={!editable}
name={name}
label={name}
onChange={onChangeTextField.bind(
this,
name
)}
value={value}
/>
{description && (
<p className={styles.helpText}>
{description}
</p>
)}
</div>
);
}
}
)}
</div>
<StyledContainer>
{strategyDefinition.parameters.map((definition, index) => (
<div key={index}>
<StrategyParameter
definition={definition}
parameters={parameters}
updateParameter={updateParameter}
editable={editable}
errors={errors}
/>
</div>
))}
</StyledContainer>
);
};

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,24 +1,30 @@
import useSWR from 'swr';
import { useMemo } from 'react';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
import { IGroup } from 'interfaces/group';
import { IUser } from 'interfaces/user';
export const useAccess = () => {
export interface IUseAccessOutput {
users?: IUser[];
groups?: IGroup[];
loading: boolean;
refetch: () => void;
error?: Error;
}
export const useAccess = (): IUseAccessOutput => {
const { data, error, mutate } = useSWR(
formatApiPath(`api/admin/user-admin/access`),
fetcher
);
return useMemo(
() => ({
users: data?.users ?? [],
groups: data?.groups ?? [],
loading: !error && !data,
refetch: () => mutate(),
error,
}),
[data, error, mutate]
);
return {
users: data?.users,
groups: data?.groups,
loading: !error && !data,
refetch: () => mutate(),
error,
};
};
const fetcher = (path: string) => {

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;

View File

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

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 */
export const NAVIGATE_TO_CREATE_FEATURE = 'NAVIGATE_TO_CREATE_FEATURE';
export const NAVIGATE_TO_CREATE_GROUP = 'NAVIGATE_TO_CREATE_GROUP';
export const NAVIGATE_TO_CREATE_SEGMENT = 'NAVIGATE_TO_CREATE_SEGMENT';
export const CREATE_API_TOKEN_BUTTON = 'CREATE_API_TOKEN_BUTTON';
@ -12,7 +13,17 @@ export const CF_CREATE_BTN_ID = 'CF_CREATE_BTN_ID';
/* CREATE GROUP */
export const UG_NAME_ID = 'UG_NAME_ID';
export const UG_DESC_ID = 'UG_DESC_ID';
export const UG_USERS_ID = 'UG_USERS_ID';
export const UG_USERS_ADD_ID = 'UG_USERS_ADD_ID';
export const UG_USERS_TABLE_ROLE_ID = 'UG_USERS_TABLE_ROLE_ID';
export const UG_CREATE_BTN_ID = 'UG_CREATE_BTN_ID';
export const UG_SAVE_BTN_ID = 'UG_SAVE_BTN_ID';
export const UG_EDIT_BTN_ID = 'UG_EDIT_BTN_ID';
export const UG_DELETE_BTN_ID = 'UG_DELETE_BTN_ID';
export const UG_ADD_USER_BTN_ID = 'UG_ADD_USER_BTN_ID';
export const UG_EDIT_USER_BTN_ID = 'UG_EDIT_USER_BTN_ID';
export const UG_REMOVE_USER_BTN_ID = 'UG_REMOVE_USER_BTN_ID';
export const UG_USERS_ROLE_ID = 'UG_USERS_ROLE_ID';
/* SEGMENT */
export const SEGMENT_NAME_ID = 'SEGMENT_NAME_ID';
@ -55,3 +66,4 @@ export const SIDEBAR_MODAL_ID = 'SIDEBAR_MODAL_ID';
export const AUTH_PAGE_ID = 'AUTH_PAGE_ID';
export const ANNOUNCER_ELEMENT_TEST_ID = 'ANNOUNCER_ELEMENT_TEST_ID';
export const INSTANCE_STATUS_BAR_ID = 'INSTANCE_STATUS_BAR_ID';
export const TOAST_TEXT = 'TOAST_TEXT';

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