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:
commit
4157de0230
25
frontend/.github/workflows/e2e.groups.yml
vendored
Normal file
25
frontend/.github/workflows/e2e.groups.yml
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
name: e2e:groups
|
||||||
|
# https://docs.github.com/en/actions/reference/events-that-trigger-workflows
|
||||||
|
on: [deployment_status]
|
||||||
|
jobs:
|
||||||
|
e2e:
|
||||||
|
# only runs this job on successful deploy
|
||||||
|
if: github.event_name == 'deployment_status' && github.event.deployment_status.state == 'success'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Dump GitHub context
|
||||||
|
env:
|
||||||
|
GITHUB_CONTEXT: ${{ toJson(github) }}
|
||||||
|
run: |
|
||||||
|
echo "$GITHUB_CONTEXT"
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
- name: Run Cypress
|
||||||
|
uses: cypress-io/github-action@v2
|
||||||
|
with:
|
||||||
|
env: AUTH_USER=admin,AUTH_PASSWORD=unleash4all
|
||||||
|
config: baseUrl=${{ github.event.deployment_status.target_url }}
|
||||||
|
record: true
|
||||||
|
spec: cypress/integration/groups/groups.spec.ts
|
||||||
|
env:
|
||||||
|
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
@ -110,7 +110,7 @@ describe('feature', () => {
|
|||||||
expect(req.body.name).to.equal('flexibleRollout');
|
expect(req.body.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);
|
||||||
|
160
frontend/cypress/integration/groups/groups.spec.ts
Normal file
160
frontend/cypress/integration/groups/groups.spec.ts
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
/// <reference types="cypress" />
|
||||||
|
|
||||||
|
export {};
|
||||||
|
const baseUrl = Cypress.config().baseUrl;
|
||||||
|
const randomId = String(Math.random()).split('.')[1];
|
||||||
|
const groupName = `unleash-e2e-${randomId}`;
|
||||||
|
const userIds: any[] = [];
|
||||||
|
|
||||||
|
// Disable all active splash pages by visiting them.
|
||||||
|
const disableActiveSplashScreens = () => {
|
||||||
|
cy.visit(`/splash/operators`);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('groups', () => {
|
||||||
|
before(() => {
|
||||||
|
disableActiveSplashScreens();
|
||||||
|
cy.login();
|
||||||
|
for (let i = 1; i <= 2; i++) {
|
||||||
|
cy.request('POST', `${baseUrl}/api/admin/user-admin`, {
|
||||||
|
name: `unleash-e2e-user${i}-${randomId}`,
|
||||||
|
email: `unleash-e2e-user${i}-${randomId}@test.com`,
|
||||||
|
sendEmail: false,
|
||||||
|
rootRole: 3,
|
||||||
|
}).then(response => userIds.push(response.body.id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
userIds.forEach(id =>
|
||||||
|
cy.request('DELETE', `${baseUrl}/api/admin/user-admin/${id}`)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.login();
|
||||||
|
cy.visit('/admin/groups');
|
||||||
|
if (document.querySelector("[data-testid='CLOSE_SPLASH']")) {
|
||||||
|
cy.get("[data-testid='CLOSE_SPLASH']").click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives an error if a group does not have an owner', () => {
|
||||||
|
cy.get("[data-testid='NAVIGATE_TO_CREATE_GROUP']").click();
|
||||||
|
|
||||||
|
cy.intercept('POST', '/api/admin/groups').as('createGroup');
|
||||||
|
|
||||||
|
cy.get("[data-testid='UG_NAME_ID']").type(groupName);
|
||||||
|
cy.get("[data-testid='UG_DESC_ID']").type('hello-world');
|
||||||
|
cy.get("[data-testid='UG_USERS_ID']").click();
|
||||||
|
cy.contains(`unleash-e2e-user1-${randomId}`).click();
|
||||||
|
cy.get("[data-testid='UG_USERS_ADD_ID']").click();
|
||||||
|
|
||||||
|
cy.get("[data-testid='UG_CREATE_BTN_ID']").click();
|
||||||
|
cy.get("[data-testid='TOAST_TEXT']").contains(
|
||||||
|
'Group needs to have at least one Owner'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can create a group', () => {
|
||||||
|
cy.get("[data-testid='NAVIGATE_TO_CREATE_GROUP']").click();
|
||||||
|
|
||||||
|
cy.intercept('POST', '/api/admin/groups').as('createGroup');
|
||||||
|
|
||||||
|
cy.get("[data-testid='UG_NAME_ID']").type(groupName);
|
||||||
|
cy.get("[data-testid='UG_DESC_ID']").type('hello-world');
|
||||||
|
cy.get("[data-testid='UG_USERS_ID']").click();
|
||||||
|
cy.contains(`unleash-e2e-user1-${randomId}`).click();
|
||||||
|
cy.get("[data-testid='UG_USERS_ADD_ID']").click();
|
||||||
|
cy.get("[data-testid='UG_USERS_TABLE_ROLE_ID']").click();
|
||||||
|
cy.contains('Owner').click();
|
||||||
|
|
||||||
|
cy.get("[data-testid='UG_CREATE_BTN_ID']").click();
|
||||||
|
cy.wait('@createGroup');
|
||||||
|
cy.contains(groupName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives an error if a group exists with the same name', () => {
|
||||||
|
cy.get("[data-testid='NAVIGATE_TO_CREATE_GROUP']").click();
|
||||||
|
|
||||||
|
cy.intercept('POST', '/api/admin/groups').as('createGroup');
|
||||||
|
|
||||||
|
cy.get("[data-testid='UG_NAME_ID']").type(groupName);
|
||||||
|
cy.get("[data-testid='UG_DESC_ID']").type('hello-world');
|
||||||
|
cy.get("[data-testid='UG_USERS_ID']").click();
|
||||||
|
cy.contains(`unleash-e2e-user1-${randomId}`).click();
|
||||||
|
cy.get("[data-testid='UG_USERS_ADD_ID']").click();
|
||||||
|
cy.get("[data-testid='UG_USERS_TABLE_ROLE_ID']").click();
|
||||||
|
cy.contains('Owner').click();
|
||||||
|
|
||||||
|
cy.get("[data-testid='UG_CREATE_BTN_ID']").click();
|
||||||
|
cy.get("[data-testid='TOAST_TEXT']").contains(
|
||||||
|
'Group name already exists'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can edit a group', () => {
|
||||||
|
cy.contains(groupName).click();
|
||||||
|
|
||||||
|
cy.get("[data-testid='UG_EDIT_BTN_ID']").click();
|
||||||
|
|
||||||
|
cy.get("[data-testid='UG_DESC_ID']").type('-my edited description');
|
||||||
|
|
||||||
|
cy.get("[data-testid='UG_SAVE_BTN_ID']").click();
|
||||||
|
|
||||||
|
cy.contains('hello-world-my edited description');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can add user to a group', () => {
|
||||||
|
cy.contains(groupName).click();
|
||||||
|
|
||||||
|
cy.get("[data-testid='UG_ADD_USER_BTN_ID']").click();
|
||||||
|
|
||||||
|
cy.get("[data-testid='UG_USERS_ID']").click();
|
||||||
|
cy.contains(`unleash-e2e-user2-${randomId}`).click();
|
||||||
|
cy.get("[data-testid='UG_USERS_ADD_ID']").click();
|
||||||
|
|
||||||
|
cy.get("[data-testid='UG_SAVE_BTN_ID']").click();
|
||||||
|
|
||||||
|
cy.contains(`unleash-e2e-user1-${randomId}`);
|
||||||
|
cy.contains(`unleash-e2e-user2-${randomId}`);
|
||||||
|
cy.get("td span:contains('Owner')").should('have.length', 1);
|
||||||
|
cy.get("td span:contains('Member')").should('have.length', 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can edit user role in a group', () => {
|
||||||
|
cy.contains(groupName).click();
|
||||||
|
|
||||||
|
cy.get(`[data-testid='UG_EDIT_USER_BTN_ID-${userIds[1]}']`).click();
|
||||||
|
|
||||||
|
cy.get("[data-testid='UG_USERS_ROLE_ID']").click();
|
||||||
|
cy.get("li[data-value='Owner']").click();
|
||||||
|
|
||||||
|
cy.get("[data-testid='UG_SAVE_BTN_ID']").click();
|
||||||
|
|
||||||
|
cy.contains(`unleash-e2e-user1-${randomId}`);
|
||||||
|
cy.contains(`unleash-e2e-user2-${randomId}`);
|
||||||
|
cy.get("td span:contains('Owner')").should('have.length', 2);
|
||||||
|
cy.contains('Member').should('not.exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can remove user from a group', () => {
|
||||||
|
cy.contains(groupName).click();
|
||||||
|
|
||||||
|
cy.get(`[data-testid='UG_REMOVE_USER_BTN_ID-${userIds[1]}']`).click();
|
||||||
|
|
||||||
|
cy.get("[data-testid='DIALOGUE_CONFIRM_ID'").click();
|
||||||
|
|
||||||
|
cy.contains(`unleash-e2e-user1-${randomId}`);
|
||||||
|
cy.contains(`unleash-e2e-user2-${randomId}`).should('not.exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can delete a group', () => {
|
||||||
|
cy.contains(groupName).click();
|
||||||
|
|
||||||
|
cy.get("[data-testid='UG_DELETE_BTN_ID']").click();
|
||||||
|
cy.get("[data-testid='DIALOGUE_CONFIRM_ID'").click();
|
||||||
|
|
||||||
|
cy.contains(groupName).should('not.exist');
|
||||||
|
});
|
||||||
|
});
|
@ -33,6 +33,7 @@ import {
|
|||||||
StyledButtonSection,
|
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 => {
|
||||||
|
@ -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 (
|
||||||
|
@ -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 (
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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"
|
||||||
|
@ -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={() => {
|
||||||
|
@ -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>
|
||||||
|
@ -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 =>
|
||||||
|
@ -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>
|
||||||
|
@ -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 (
|
||||||
|
@ -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 (
|
||||||
|
@ -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 (
|
||||||
|
@ -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 (
|
||||||
|
23
frontend/src/component/common/InputCaption/InputCaption.tsx
Normal file
23
frontend/src/component/common/InputCaption/InputCaption.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Box } from '@mui/material';
|
||||||
|
|
||||||
|
export interface IInputCaptionProps {
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InputCaption = ({ text }: IInputCaptionProps) => {
|
||||||
|
if (!text) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={theme => ({
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
fontSize: theme.fontSizes.smallerBody,
|
||||||
|
marginTop: theme.spacing(1),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
@ -4,6 +4,7 @@ import { useNavigate } from 'react-router';
|
|||||||
import { ReactComponent as LogoIcon } from 'assets/icons/logoBg.svg';
|
import { 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 (
|
||||||
|
@ -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>
|
||||||
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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 (
|
||||||
|
@ -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 (
|
||||||
|
@ -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 (
|
||||||
|
@ -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 (
|
||||||
|
@ -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 (
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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)}
|
||||||
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,15 +0,0 @@
|
|||||||
import { makeStyles } from 'tss-react/mui';
|
|
||||||
|
|
||||||
export const useStyles = makeStyles()(theme => ({
|
|
||||||
container: {
|
|
||||||
display: 'grid',
|
|
||||||
gap: theme.spacing(4),
|
|
||||||
},
|
|
||||||
helpText: {
|
|
||||||
color: theme.palette.text.secondary,
|
|
||||||
fontSize: theme.fontSizes.smallerBody,
|
|
||||||
lineHeight: '14px',
|
|
||||||
margin: 0,
|
|
||||||
marginTop: theme.spacing(1),
|
|
||||||
},
|
|
||||||
}));
|
|
@ -1,190 +1,47 @@
|
|||||||
import React from 'react';
|
import 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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -87,7 +87,6 @@ const RolloutSlider = ({
|
|||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</Typography>
|
</Typography>
|
||||||
<br />
|
|
||||||
<StyledSlider
|
<StyledSlider
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
|
@ -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"
|
||||||
|
@ -0,0 +1,140 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FormControlLabel, Switch, TextField } from '@mui/material';
|
||||||
|
import StrategyInputList from '../StrategyInputList/StrategyInputList';
|
||||||
|
import RolloutSlider from '../RolloutSlider/RolloutSlider';
|
||||||
|
import {
|
||||||
|
IFeatureStrategyParameters,
|
||||||
|
IStrategyParameter,
|
||||||
|
} from 'interfaces/strategy';
|
||||||
|
import {
|
||||||
|
parseParameterNumber,
|
||||||
|
parseParameterStrings,
|
||||||
|
parseParameterString,
|
||||||
|
} from 'utils/parseParameter';
|
||||||
|
import { InputCaption } from 'component/common/InputCaption/InputCaption';
|
||||||
|
import { IFormErrors } from 'hooks/useFormErrors';
|
||||||
|
|
||||||
|
interface IStrategyParameterProps {
|
||||||
|
definition: IStrategyParameter;
|
||||||
|
parameters: IFeatureStrategyParameters;
|
||||||
|
updateParameter: (field: string, value: string) => void;
|
||||||
|
editable: boolean;
|
||||||
|
errors: IFormErrors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StrategyParameter = ({
|
||||||
|
definition,
|
||||||
|
parameters,
|
||||||
|
updateParameter,
|
||||||
|
editable,
|
||||||
|
errors,
|
||||||
|
}: IStrategyParameterProps) => {
|
||||||
|
const { type, name, description, required } = definition;
|
||||||
|
const value = parameters[name];
|
||||||
|
const error = errors.getFormError(name);
|
||||||
|
const label = required ? `${name} * ` : name;
|
||||||
|
|
||||||
|
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
updateParameter(name, event.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChangePercentage = (event: Event, next: number | number[]) => {
|
||||||
|
updateParameter(name, next.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChangeBoolean = (event: React.ChangeEvent, checked: boolean) => {
|
||||||
|
updateParameter(name, String(checked));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type === 'percentage') {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<RolloutSlider
|
||||||
|
name={name}
|
||||||
|
onChange={onChangePercentage}
|
||||||
|
disabled={!editable}
|
||||||
|
value={parseParameterNumber(parameters[name])}
|
||||||
|
minLabel="off"
|
||||||
|
maxLabel="on"
|
||||||
|
/>
|
||||||
|
<InputCaption text={description} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'list') {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<StrategyInputList
|
||||||
|
name={name}
|
||||||
|
list={parseParameterStrings(parameters[name])}
|
||||||
|
disabled={!editable}
|
||||||
|
setConfig={updateParameter}
|
||||||
|
errors={errors}
|
||||||
|
/>
|
||||||
|
<InputCaption text={description} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'number') {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<TextField
|
||||||
|
error={Boolean(error)}
|
||||||
|
helperText={error}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
aria-required={required}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
disabled={!editable}
|
||||||
|
label={label}
|
||||||
|
onChange={onChange}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
<InputCaption text={description} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'boolean') {
|
||||||
|
const value = parseParameterString(parameters[name]);
|
||||||
|
const checked = value === 'true';
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FormControlLabel
|
||||||
|
label={name}
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
name={name}
|
||||||
|
onChange={onChangeBoolean}
|
||||||
|
checked={checked}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputCaption text={description} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<TextField
|
||||||
|
rows={1}
|
||||||
|
placeholder=""
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
aria-required={required}
|
||||||
|
disabled={!editable}
|
||||||
|
error={Boolean(error)}
|
||||||
|
helperText={error}
|
||||||
|
name={name}
|
||||||
|
label={label}
|
||||||
|
onChange={onChange}
|
||||||
|
value={parseParameterString(parameters[name])}
|
||||||
|
/>
|
||||||
|
<InputCaption text={description} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -1,17 +1,20 @@
|
|||||||
import { IFeatureStrategyParameters } from 'interfaces/strategy';
|
import { 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>
|
||||||
);
|
);
|
||||||
|
@ -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",
|
||||||
},
|
},
|
||||||
|
@ -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,
|
||||||
|
@ -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 (
|
||||||
|
@ -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) && (
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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} />;
|
||||||
|
@ -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(
|
||||||
|
@ -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}`);
|
||||||
|
@ -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}`);
|
||||||
|
@ -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>
|
||||||
|
@ -0,0 +1,24 @@
|
|||||||
|
import { ProjectAccessAssign } from '../ProjectAccessAssign/ProjectAccessAssign';
|
||||||
|
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
|
import useProjectAccess from 'hooks/api/getters/useProjectAccess/useProjectAccess';
|
||||||
|
import { useAccess } from 'hooks/api/getters/useAccess/useAccess';
|
||||||
|
|
||||||
|
export const ProjectAccessCreate = () => {
|
||||||
|
const projectId = useRequiredPathParam('projectId');
|
||||||
|
|
||||||
|
const { access } = useProjectAccess(projectId);
|
||||||
|
const { users, groups } = useAccess();
|
||||||
|
|
||||||
|
if (!access || !users || !groups) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectAccessAssign
|
||||||
|
accesses={access.rows}
|
||||||
|
users={users}
|
||||||
|
groups={groups}
|
||||||
|
roles={access.roles}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,33 @@
|
|||||||
|
import { ProjectAccessAssign } from '../ProjectAccessAssign/ProjectAccessAssign';
|
||||||
|
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
|
import useProjectAccess, {
|
||||||
|
ENTITY_TYPE,
|
||||||
|
} from 'hooks/api/getters/useProjectAccess/useProjectAccess';
|
||||||
|
import { useAccess } from 'hooks/api/getters/useAccess/useAccess';
|
||||||
|
|
||||||
|
export const ProjectAccessEditGroup = () => {
|
||||||
|
const projectId = useRequiredPathParam('projectId');
|
||||||
|
const groupId = useRequiredPathParam('groupId');
|
||||||
|
|
||||||
|
const { access } = useProjectAccess(projectId);
|
||||||
|
const { users, groups } = useAccess();
|
||||||
|
|
||||||
|
if (!access || !users || !groups) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = access.rows.find(
|
||||||
|
row =>
|
||||||
|
row.entity.id === Number(groupId) && row.type === ENTITY_TYPE.GROUP
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectAccessAssign
|
||||||
|
accesses={access.rows}
|
||||||
|
selected={group}
|
||||||
|
users={users}
|
||||||
|
groups={groups}
|
||||||
|
roles={access.roles}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,32 @@
|
|||||||
|
import { ProjectAccessAssign } from '../ProjectAccessAssign/ProjectAccessAssign';
|
||||||
|
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
|
import useProjectAccess, {
|
||||||
|
ENTITY_TYPE,
|
||||||
|
} from 'hooks/api/getters/useProjectAccess/useProjectAccess';
|
||||||
|
import { useAccess } from 'hooks/api/getters/useAccess/useAccess';
|
||||||
|
|
||||||
|
export const ProjectAccessEditUser = () => {
|
||||||
|
const projectId = useRequiredPathParam('projectId');
|
||||||
|
const userId = useRequiredPathParam('userId');
|
||||||
|
|
||||||
|
const { access } = useProjectAccess(projectId);
|
||||||
|
const { users, groups } = useAccess();
|
||||||
|
|
||||||
|
if (!access || !users || !groups) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = access.rows.find(
|
||||||
|
row => row.entity.id === Number(userId) && row.type === ENTITY_TYPE.USER
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectAccessAssign
|
||||||
|
accesses={access.rows}
|
||||||
|
selected={user}
|
||||||
|
users={users}
|
||||||
|
groups={groups}
|
||||||
|
roles={access.roles}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -15,14 +15,19 @@ import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
|
|||||||
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
import { 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);
|
||||||
|
@ -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();
|
||||||
|
@ -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 (
|
||||||
|
@ -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 (
|
||||||
|
@ -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',
|
||||||
|
@ -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 (
|
||||||
|
@ -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 (
|
||||||
|
1
frontend/src/constants/navigate.ts
Normal file
1
frontend/src/constants/navigate.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const GO_BACK = -1;
|
@ -1,24 +1,30 @@
|
|||||||
import useSWR from 'swr';
|
import 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) => {
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
59
frontend/src/hooks/useFormErrors.ts
Normal file
59
frontend/src/hooks/useFormErrors.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import produce from 'immer';
|
||||||
|
|
||||||
|
export interface IFormErrors {
|
||||||
|
// Get the error message for a field name, if any.
|
||||||
|
getFormError(field: string): string | undefined;
|
||||||
|
|
||||||
|
// Set an error message for a field name.
|
||||||
|
setFormError(field: string, message: string): void;
|
||||||
|
|
||||||
|
// Remove an existing error for a field name.
|
||||||
|
removeFormError(field: string): void;
|
||||||
|
|
||||||
|
// Check if there are any errors.
|
||||||
|
hasFormErrors(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFormErrors = (): IFormErrors => {
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const getFormError = useCallback(
|
||||||
|
(field: string): string | undefined => errors[field],
|
||||||
|
[errors]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setFormError = useCallback(
|
||||||
|
(field: string, message: string): void => {
|
||||||
|
setErrors(
|
||||||
|
produce(draft => {
|
||||||
|
draft[field] = message;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setErrors]
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeFormError = useCallback(
|
||||||
|
(field: string): void => {
|
||||||
|
setErrors(
|
||||||
|
produce(draft => {
|
||||||
|
delete draft[field];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setErrors]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasFormErrors = useCallback(
|
||||||
|
(): boolean => Object.values(errors).some(Boolean),
|
||||||
|
[errors]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
getFormError,
|
||||||
|
setFormError,
|
||||||
|
removeFormError,
|
||||||
|
hasFormErrors,
|
||||||
|
};
|
||||||
|
};
|
76
frontend/src/utils/createFeatureStrategy.test.ts
Normal file
76
frontend/src/utils/createFeatureStrategy.test.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { createFeatureStrategy } from 'utils/createFeatureStrategy';
|
||||||
|
|
||||||
|
test('createFeatureStrategy', () => {
|
||||||
|
expect(
|
||||||
|
createFeatureStrategy('a', {
|
||||||
|
name: 'b',
|
||||||
|
displayName: 'c',
|
||||||
|
editable: true,
|
||||||
|
deprecated: false,
|
||||||
|
description: 'd',
|
||||||
|
parameters: [],
|
||||||
|
})
|
||||||
|
).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"constraints": [],
|
||||||
|
"name": "b",
|
||||||
|
"parameters": {},
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createFeatureStrategy with parameters', () => {
|
||||||
|
expect(
|
||||||
|
createFeatureStrategy('a', {
|
||||||
|
name: 'b',
|
||||||
|
displayName: 'c',
|
||||||
|
editable: true,
|
||||||
|
deprecated: false,
|
||||||
|
description: 'd',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'groupId',
|
||||||
|
type: 'string',
|
||||||
|
description: 'a',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'stickiness',
|
||||||
|
type: 'string',
|
||||||
|
description: 'a',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rollout',
|
||||||
|
type: 'percentage',
|
||||||
|
description: 'a',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 's',
|
||||||
|
type: 'string',
|
||||||
|
description: 's',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'b',
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'b',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"constraints": [],
|
||||||
|
"name": "b",
|
||||||
|
"parameters": {
|
||||||
|
"b": "false",
|
||||||
|
"groupId": "a",
|
||||||
|
"rollout": "50",
|
||||||
|
"s": "",
|
||||||
|
"stickiness": "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
55
frontend/src/utils/createFeatureStrategy.ts
Normal file
55
frontend/src/utils/createFeatureStrategy.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
IStrategy,
|
||||||
|
IFeatureStrategy,
|
||||||
|
IFeatureStrategyParameters,
|
||||||
|
IStrategyParameter,
|
||||||
|
} from 'interfaces/strategy';
|
||||||
|
|
||||||
|
// Create a new feature strategy with default values from a strategy definition.
|
||||||
|
export const createFeatureStrategy = (
|
||||||
|
featureId: string,
|
||||||
|
strategyDefinition: IStrategy
|
||||||
|
): Omit<IFeatureStrategy, 'id'> => {
|
||||||
|
const parameters: IFeatureStrategyParameters = {};
|
||||||
|
|
||||||
|
strategyDefinition.parameters.forEach((parameter: IStrategyParameter) => {
|
||||||
|
parameters[parameter.name] = createFeatureStrategyParameterValue(
|
||||||
|
featureId,
|
||||||
|
parameter
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: strategyDefinition.name,
|
||||||
|
constraints: [],
|
||||||
|
parameters,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create default feature strategy parameter values from a strategy definition.
|
||||||
|
const createFeatureStrategyParameterValue = (
|
||||||
|
featureId: string,
|
||||||
|
parameter: IStrategyParameter
|
||||||
|
): string => {
|
||||||
|
if (
|
||||||
|
parameter.name === 'rollout' ||
|
||||||
|
parameter.name === 'percentage' ||
|
||||||
|
parameter.type === 'percentage'
|
||||||
|
) {
|
||||||
|
return '50';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parameter.name === 'stickiness') {
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parameter.name === 'groupId') {
|
||||||
|
return featureId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parameter.type === 'boolean') {
|
||||||
|
return 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
};
|
@ -1,24 +0,0 @@
|
|||||||
import {
|
|
||||||
IStrategy,
|
|
||||||
IStrategyParameter,
|
|
||||||
IFeatureStrategyParameters,
|
|
||||||
} from 'interfaces/strategy';
|
|
||||||
import { resolveDefaultParamValue } from 'utils/resolveDefaultParamValue';
|
|
||||||
|
|
||||||
export const getStrategyObject = (
|
|
||||||
selectableStrategies: IStrategy[],
|
|
||||||
name: string,
|
|
||||||
featureId: string
|
|
||||||
) => {
|
|
||||||
const selectedStrategy = selectableStrategies.find(
|
|
||||||
strategy => strategy.name === name
|
|
||||||
);
|
|
||||||
|
|
||||||
const parameters: IFeatureStrategyParameters = {};
|
|
||||||
|
|
||||||
selectedStrategy?.parameters.forEach(({ name }: IStrategyParameter) => {
|
|
||||||
parameters[name] = resolveDefaultParamValue(name, featureId);
|
|
||||||
});
|
|
||||||
|
|
||||||
return { name, parameters, constraints: [] };
|
|
||||||
};
|
|
@ -1,16 +0,0 @@
|
|||||||
export const resolveDefaultParamValue = (
|
|
||||||
name: string,
|
|
||||||
featureToggleName: string
|
|
||||||
): string => {
|
|
||||||
switch (name) {
|
|
||||||
case 'percentage':
|
|
||||||
case 'rollout':
|
|
||||||
return '100';
|
|
||||||
case 'stickiness':
|
|
||||||
return 'default';
|
|
||||||
case 'groupId':
|
|
||||||
return featureToggleName;
|
|
||||||
default:
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
};
|
|
@ -1,5 +1,6 @@
|
|||||||
/* NAVIGATION */
|
/* 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';
|
||||||
|
55
frontend/src/utils/validateParameterValue.test.ts
Normal file
55
frontend/src/utils/validateParameterValue.test.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { validateParameterValue } from 'utils/validateParameterValue';
|
||||||
|
|
||||||
|
test('validateParameterValue string', () => {
|
||||||
|
expect(
|
||||||
|
validateParameterValue(
|
||||||
|
{ type: 'string', name: 'a', description: 'b', required: false },
|
||||||
|
''
|
||||||
|
)
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
validateParameterValue(
|
||||||
|
{ type: 'string', name: 'a', description: 'b', required: false },
|
||||||
|
'a'
|
||||||
|
)
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
validateParameterValue(
|
||||||
|
{ type: 'string', name: 'a', description: 'b', required: true },
|
||||||
|
''
|
||||||
|
)
|
||||||
|
).not.toBeUndefined();
|
||||||
|
expect(
|
||||||
|
validateParameterValue(
|
||||||
|
{ type: 'string', name: 'a', description: 'b', required: true },
|
||||||
|
'b'
|
||||||
|
)
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateParameterValue number', () => {
|
||||||
|
expect(
|
||||||
|
validateParameterValue(
|
||||||
|
{ type: 'number', name: 'a', description: 'b', required: false },
|
||||||
|
''
|
||||||
|
)
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
validateParameterValue(
|
||||||
|
{ type: 'number', name: 'a', description: 'b', required: false },
|
||||||
|
'a'
|
||||||
|
)
|
||||||
|
).not.toBeUndefined();
|
||||||
|
expect(
|
||||||
|
validateParameterValue(
|
||||||
|
{ type: 'number', name: 'a', description: 'b', required: true },
|
||||||
|
''
|
||||||
|
)
|
||||||
|
).not.toBeUndefined();
|
||||||
|
expect(
|
||||||
|
validateParameterValue(
|
||||||
|
{ type: 'number', name: 'a', description: 'b', required: true },
|
||||||
|
'1'
|
||||||
|
)
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
23
frontend/src/utils/validateParameterValue.ts
Normal file
23
frontend/src/utils/validateParameterValue.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import {
|
||||||
|
IStrategyParameter,
|
||||||
|
IFeatureStrategyParameters,
|
||||||
|
} from 'interfaces/strategy';
|
||||||
|
|
||||||
|
export const validateParameterValue = (
|
||||||
|
definition: IStrategyParameter,
|
||||||
|
value: IFeatureStrategyParameters[string]
|
||||||
|
): string | undefined => {
|
||||||
|
const { type, required } = definition;
|
||||||
|
|
||||||
|
if (required && value === '') {
|
||||||
|
return 'Field is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'number' && !isValidNumberOrEmpty(value)) {
|
||||||
|
return 'Not a valid number.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidNumberOrEmpty = (value: string | number | undefined): boolean => {
|
||||||
|
return value === '' || /^\d+$/.test(String(value));
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user