mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-24 01:18:01 +02:00
fix: reject duplicate segment names (#855)
* fix: reject duplicate segment names * fix: useSegmentValidation now takes into account initial value * refactor: add segments e2e test * refactor: add github action from segments e2e test * refactor: use enterprise edition for all e2e tests * refactor: use enterprise edition for all e2e tests
This commit is contained in:
parent
b23226370a
commit
1132a79f6d
2
frontend/.github/workflows/e2e.feature.yml
vendored
2
frontend/.github/workflows/e2e.feature.yml
vendored
@ -17,7 +17,7 @@ jobs:
|
|||||||
- name: Run Cypress
|
- name: Run Cypress
|
||||||
uses: cypress-io/github-action@v2
|
uses: cypress-io/github-action@v2
|
||||||
with:
|
with:
|
||||||
env: AUTH_USER=test@unleash-e2e.com
|
env: AUTH_USER=admin,AUTH_PASSWORD=unleash4all
|
||||||
config: baseUrl=${{ github.event.deployment_status.target_url }}
|
config: baseUrl=${{ github.event.deployment_status.target_url }}
|
||||||
record: true
|
record: true
|
||||||
spec: cypress/integration/feature/feature.spec.ts
|
spec: cypress/integration/feature/feature.spec.ts
|
||||||
|
25
frontend/.github/workflows/e2e.segments.yml
vendored
Normal file
25
frontend/.github/workflows/e2e.segments.yml
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
name: e2e:segments
|
||||||
|
# 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/segments/segments.spec.ts
|
||||||
|
env:
|
||||||
|
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
77
frontend/cypress/integration/segments/segments.spec.ts
Normal file
77
frontend/cypress/integration/segments/segments.spec.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
/// <reference types="cypress" />
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
||||||
|
const AUTH_USER = Cypress.env('AUTH_USER');
|
||||||
|
const AUTH_PASSWORD = Cypress.env('AUTH_PASSWORD');
|
||||||
|
const randomId = String(Math.random()).split('.')[1];
|
||||||
|
const segmentName = `unleash-e2e-${randomId}`;
|
||||||
|
|
||||||
|
Cypress.config({
|
||||||
|
experimentalSessionSupport: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disable all active splash pages by visiting them.
|
||||||
|
const disableActiveSplashScreens = () => {
|
||||||
|
cy.visit(`/splash/operators`);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('segments', () => {
|
||||||
|
before(() => {
|
||||||
|
disableActiveSplashScreens();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.session(AUTH_USER, () => {
|
||||||
|
cy.visit('/');
|
||||||
|
cy.wait(1000);
|
||||||
|
cy.get("[data-test='LOGIN_EMAIL_ID']").type(AUTH_USER);
|
||||||
|
|
||||||
|
if (AUTH_PASSWORD) {
|
||||||
|
cy.get("[data-test='LOGIN_PASSWORD_ID']").type(AUTH_PASSWORD);
|
||||||
|
}
|
||||||
|
|
||||||
|
cy.get("[data-test='LOGIN_BUTTON']").click();
|
||||||
|
// Wait for the login redirect to complete.
|
||||||
|
cy.get("[data-test='HEADER_USER_AVATAR']");
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.visit('/segments');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can create a segment', () => {
|
||||||
|
if (document.querySelector("[data-test='CLOSE_SPLASH']")) {
|
||||||
|
cy.get("[data-test='CLOSE_SPLASH']").click();
|
||||||
|
}
|
||||||
|
|
||||||
|
cy.get("[data-test='NAVIGATE_TO_CREATE_SEGMENT']").click();
|
||||||
|
|
||||||
|
cy.intercept('POST', '/api/admin/segments').as('createSegment');
|
||||||
|
|
||||||
|
cy.get("[data-test='SEGMENT_NAME_ID']").type(segmentName);
|
||||||
|
cy.get("[data-test='SEGMENT_DESC_ID']").type('hello-world');
|
||||||
|
cy.get("[data-test='SEGMENT_NEXT_BTN_ID']").click();
|
||||||
|
cy.get("[data-test='SEGMENT_CREATE_BTN_ID']").click();
|
||||||
|
cy.wait('@createSegment');
|
||||||
|
cy.contains(segmentName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives an error if a segment exists with the same name', () => {
|
||||||
|
cy.get("[data-test='NAVIGATE_TO_CREATE_SEGMENT']").click();
|
||||||
|
|
||||||
|
cy.get("[data-test='SEGMENT_NAME_ID']").type(segmentName);
|
||||||
|
cy.get("[data-test='SEGMENT_NEXT_BTN_ID']").should('be.disabled');
|
||||||
|
cy.get("[data-test='INPUT_ERROR_TEXT']").contains(
|
||||||
|
'Segment name already exists'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can delete a segment', () => {
|
||||||
|
cy.get(`[data-test='SEGMENT_DELETE_BTN_ID_${segmentName}']`).click();
|
||||||
|
|
||||||
|
cy.get("[data-test='SEGMENT_DIALOG_NAME_ID']").type(segmentName);
|
||||||
|
cy.get("[data-test='DIALOGUE_CONFIRM_ID'").click();
|
||||||
|
|
||||||
|
cy.contains(segmentName).should('not.exist');
|
||||||
|
});
|
||||||
|
});
|
@ -15,6 +15,7 @@ import { feedbackCESContext } from 'component/feedback/FeedbackCESContext/Feedba
|
|||||||
import { segmentsDocsLink } from 'component/segments/SegmentDocs/SegmentDocs';
|
import { segmentsDocsLink } from 'component/segments/SegmentDocs/SegmentDocs';
|
||||||
import { useSegmentValuesCount } from 'component/segments/hooks/useSegmentValuesCount';
|
import { useSegmentValuesCount } from 'component/segments/hooks/useSegmentValuesCount';
|
||||||
import { SEGMENT_VALUES_LIMIT } from 'utils/segmentLimits';
|
import { SEGMENT_VALUES_LIMIT } from 'utils/segmentLimits';
|
||||||
|
import { SEGMENT_CREATE_BTN_ID } from 'utils/testIds';
|
||||||
|
|
||||||
export const CreateSegment = () => {
|
export const CreateSegment = () => {
|
||||||
const { uiConfig } = useUiConfig();
|
const { uiConfig } = useUiConfig();
|
||||||
@ -96,6 +97,7 @@ export const CreateSegment = () => {
|
|||||||
name="segment"
|
name="segment"
|
||||||
permission={CREATE_SEGMENT}
|
permission={CREATE_SEGMENT}
|
||||||
disabled={!hasValidConstraints || atSegmentValuesLimit}
|
disabled={!hasValidConstraints || atSegmentValuesLimit}
|
||||||
|
data-test={SEGMENT_CREATE_BTN_ID}
|
||||||
/>
|
/>
|
||||||
</SegmentForm>
|
</SegmentForm>
|
||||||
</FormTemplate>
|
</FormTemplate>
|
||||||
|
@ -17,6 +17,7 @@ import { UpdateButton } from 'component/common/UpdateButton/UpdateButton';
|
|||||||
import { segmentsDocsLink } from 'component/segments/SegmentDocs/SegmentDocs';
|
import { segmentsDocsLink } from 'component/segments/SegmentDocs/SegmentDocs';
|
||||||
import { useSegmentValuesCount } from 'component/segments/hooks/useSegmentValuesCount';
|
import { useSegmentValuesCount } from 'component/segments/hooks/useSegmentValuesCount';
|
||||||
import { SEGMENT_VALUES_LIMIT } from 'utils/segmentLimits';
|
import { SEGMENT_VALUES_LIMIT } from 'utils/segmentLimits';
|
||||||
|
import { SEGMENT_SAVE_BTN_ID } from 'utils/testIds';
|
||||||
|
|
||||||
export const EditSegment = () => {
|
export const EditSegment = () => {
|
||||||
const segmentId = useRequiredPathParam('segmentId');
|
const segmentId = useRequiredPathParam('segmentId');
|
||||||
@ -98,6 +99,7 @@ export const EditSegment = () => {
|
|||||||
<UpdateButton
|
<UpdateButton
|
||||||
permission={UPDATE_SEGMENT}
|
permission={UPDATE_SEGMENT}
|
||||||
disabled={!hasValidConstraints || atSegmentValuesLimit}
|
disabled={!hasValidConstraints || atSegmentValuesLimit}
|
||||||
|
data-test={SEGMENT_SAVE_BTN_ID}
|
||||||
/>
|
/>
|
||||||
</SegmentForm>
|
</SegmentForm>
|
||||||
</FormTemplate>
|
</FormTemplate>
|
||||||
|
@ -3,6 +3,7 @@ import Dialogue from 'component/common/Dialogue';
|
|||||||
import Input from 'component/common/Input/Input';
|
import Input from 'component/common/Input/Input';
|
||||||
import { useStyles } from './SegmentDeleteConfirm.styles';
|
import { useStyles } from './SegmentDeleteConfirm.styles';
|
||||||
import { ISegment } from 'interfaces/segment';
|
import { ISegment } from 'interfaces/segment';
|
||||||
|
import { SEGMENT_DIALOG_NAME_ID } from 'utils/testIds';
|
||||||
|
|
||||||
interface ISegmentDeleteConfirmProps {
|
interface ISegmentDeleteConfirmProps {
|
||||||
segment: ISegment;
|
segment: ISegment;
|
||||||
@ -54,6 +55,7 @@ export const SegmentDeleteConfirm = ({
|
|||||||
value={confirmName}
|
value={confirmName}
|
||||||
label="Segment name"
|
label="Segment name"
|
||||||
className={styles.deleteInput}
|
className={styles.deleteInput}
|
||||||
|
data-test={SEGMENT_DIALOG_NAME_ID}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</Dialogue>
|
</Dialogue>
|
||||||
|
@ -4,6 +4,11 @@ import React from 'react';
|
|||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { useStyles } from 'component/segments/SegmentFormStepOne/SegmentFormStepOne.styles';
|
import { useStyles } from 'component/segments/SegmentFormStepOne/SegmentFormStepOne.styles';
|
||||||
import { SegmentFormStep } from '../SegmentForm/SegmentForm';
|
import { SegmentFormStep } from '../SegmentForm/SegmentForm';
|
||||||
|
import {
|
||||||
|
SEGMENT_NAME_ID,
|
||||||
|
SEGMENT_DESC_ID,
|
||||||
|
SEGMENT_NEXT_BTN_ID,
|
||||||
|
} from 'utils/testIds';
|
||||||
|
|
||||||
interface ISegmentFormPartOneProps {
|
interface ISegmentFormPartOneProps {
|
||||||
name: string;
|
name: string;
|
||||||
@ -41,9 +46,9 @@ export const SegmentFormStepOne: React.FC<ISegmentFormPartOneProps> = ({
|
|||||||
onChange={e => setName(e.target.value)}
|
onChange={e => setName(e.target.value)}
|
||||||
error={Boolean(errors.name)}
|
error={Boolean(errors.name)}
|
||||||
errorText={errors.name}
|
errorText={errors.name}
|
||||||
onFocus={() => clearErrors()}
|
|
||||||
autoFocus
|
autoFocus
|
||||||
required
|
required
|
||||||
|
data-test={SEGMENT_NAME_ID}
|
||||||
/>
|
/>
|
||||||
<p className={styles.inputDescription}>
|
<p className={styles.inputDescription}>
|
||||||
What is the segment description?
|
What is the segment description?
|
||||||
@ -55,7 +60,7 @@ export const SegmentFormStepOne: React.FC<ISegmentFormPartOneProps> = ({
|
|||||||
onChange={e => setDescription(e.target.value)}
|
onChange={e => setDescription(e.target.value)}
|
||||||
error={Boolean(errors.description)}
|
error={Boolean(errors.description)}
|
||||||
errorText={errors.description}
|
errorText={errors.description}
|
||||||
onFocus={() => clearErrors()}
|
data-test={SEGMENT_DESC_ID}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.buttonContainer}>
|
<div className={styles.buttonContainer}>
|
||||||
@ -64,7 +69,8 @@ export const SegmentFormStepOne: React.FC<ISegmentFormPartOneProps> = ({
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={() => setCurrentStep(2)}
|
onClick={() => setCurrentStep(2)}
|
||||||
disabled={name.length === 0}
|
disabled={name.length === 0 || Boolean(errors.name)}
|
||||||
|
data-test={SEGMENT_NEXT_BTN_ID}
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -28,6 +28,7 @@ import PageContent from 'component/common/PageContent';
|
|||||||
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
|
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
|
||||||
import { SegmentDelete } from '../SegmentDelete/SegmentDelete';
|
import { SegmentDelete } from '../SegmentDelete/SegmentDelete';
|
||||||
import { SegmentDocsWarning } from 'component/segments/SegmentDocs/SegmentDocs';
|
import { SegmentDocsWarning } from 'component/segments/SegmentDocs/SegmentDocs';
|
||||||
|
import { NAVIGATE_TO_CREATE_SEGMENT } from 'utils/testIds';
|
||||||
|
|
||||||
export const SegmentsList = () => {
|
export const SegmentsList = () => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
@ -101,6 +102,7 @@ export const SegmentsList = () => {
|
|||||||
<PermissionButton
|
<PermissionButton
|
||||||
onClick={() => history.push('/segments/create')}
|
onClick={() => history.push('/segments/create')}
|
||||||
permission={CREATE_SEGMENT}
|
permission={CREATE_SEGMENT}
|
||||||
|
data-test={NAVIGATE_TO_CREATE_SEGMENT}
|
||||||
>
|
>
|
||||||
New Segment
|
New Segment
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
|
@ -9,6 +9,7 @@ import PermissionIconButton from 'component/common/PermissionIconButton/Permissi
|
|||||||
import TimeAgo from 'react-timeago';
|
import TimeAgo from 'react-timeago';
|
||||||
import { ISegment } from 'interfaces/segment';
|
import { ISegment } from 'interfaces/segment';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
|
import { SEGMENT_DELETE_BTN_ID } from 'utils/testIds';
|
||||||
|
|
||||||
interface ISegmentListItemProps {
|
interface ISegmentListItemProps {
|
||||||
id: number;
|
id: number;
|
||||||
@ -82,6 +83,7 @@ export const SegmentListItem = ({
|
|||||||
setDelDialog(true);
|
setDelDialog(true);
|
||||||
}}
|
}}
|
||||||
permission={ADMIN}
|
permission={ADMIN}
|
||||||
|
data-test={`${SEGMENT_DELETE_BTN_ID}_${name}`}
|
||||||
>
|
>
|
||||||
<Delete />
|
<Delete />
|
||||||
</PermissionIconButton>
|
</PermissionIconButton>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { IConstraint } from 'interfaces/strategy';
|
import { IConstraint } from 'interfaces/strategy';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSegmentValidation } from 'hooks/api/getters/useSegmentValidation/useSegmentValidation';
|
||||||
|
|
||||||
export const useSegmentForm = (
|
export const useSegmentForm = (
|
||||||
initialName = '',
|
initialName = '',
|
||||||
@ -11,6 +12,7 @@ export const useSegmentForm = (
|
|||||||
const [constraints, setConstraints] =
|
const [constraints, setConstraints] =
|
||||||
useState<IConstraint[]>(initialConstraints);
|
useState<IConstraint[]>(initialConstraints);
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
|
const nameError = useSegmentValidation(name, initialName);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setName(initialName);
|
setName(initialName);
|
||||||
@ -25,6 +27,13 @@ export const useSegmentForm = (
|
|||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
}, [JSON.stringify(initialConstraints)]);
|
}, [JSON.stringify(initialConstraints)]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setErrors(errors => ({
|
||||||
|
...errors,
|
||||||
|
name: nameError,
|
||||||
|
}));
|
||||||
|
}, [nameError]);
|
||||||
|
|
||||||
const getSegmentPayload = () => {
|
const getSegmentPayload = () => {
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
|
@ -0,0 +1,40 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { formatApiPath } from 'utils/formatPath';
|
||||||
|
|
||||||
|
export const useSegmentValidation = (
|
||||||
|
name: string,
|
||||||
|
initialName: string
|
||||||
|
): string | undefined => {
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const nameHasChanged = name !== initialName;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setError(undefined);
|
||||||
|
if (name && nameHasChanged) {
|
||||||
|
fetchNewNameValidation(name)
|
||||||
|
.then(parseValidationResponse)
|
||||||
|
.then(setError)
|
||||||
|
.catch(() => setError(undefined));
|
||||||
|
}
|
||||||
|
}, [name, nameHasChanged]);
|
||||||
|
|
||||||
|
return error;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchNewNameValidation = (name: string): Promise<Response> =>
|
||||||
|
fetch(formatApiPath('api/admin/segments/validate'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const parseValidationResponse = async (
|
||||||
|
res: Response
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
if (res.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
return json.details[0].message;
|
||||||
|
};
|
@ -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_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';
|
||||||
|
|
||||||
/* CREATE FEATURE */
|
/* CREATE FEATURE */
|
||||||
@ -8,6 +9,15 @@ export const CF_TYPE_ID = 'CF_TYPE_ID';
|
|||||||
export const CF_DESC_ID = 'CF_DESC_ID';
|
export const CF_DESC_ID = 'CF_DESC_ID';
|
||||||
export const CF_CREATE_BTN_ID = 'CF_CREATE_BTN_ID';
|
export const CF_CREATE_BTN_ID = 'CF_CREATE_BTN_ID';
|
||||||
|
|
||||||
|
/* SEGMENT */
|
||||||
|
export const SEGMENT_NAME_ID = 'SEGMENT_NAME_ID';
|
||||||
|
export const SEGMENT_DESC_ID = 'SEGMENT_DESC_ID';
|
||||||
|
export const SEGMENT_NEXT_BTN_ID = 'SEGMENT_NEXT_BTN_ID';
|
||||||
|
export const SEGMENT_CREATE_BTN_ID = 'SEGMENT_CREATE_BTN_ID';
|
||||||
|
export const SEGMENT_SAVE_BTN_ID = 'SEGMENT_SAVE_BTN_ID';
|
||||||
|
export const SEGMENT_DELETE_BTN_ID = 'SEGMENT_DELETE_BTN_ID';
|
||||||
|
export const SEGMENT_DIALOG_NAME_ID = 'SEGMENT_DIALOG_NAME_ID';
|
||||||
|
|
||||||
/* LOGIN */
|
/* LOGIN */
|
||||||
export const LOGIN_EMAIL_ID = 'LOGIN_EMAIL_ID';
|
export const LOGIN_EMAIL_ID = 'LOGIN_EMAIL_ID';
|
||||||
export const LOGIN_BUTTON = 'LOGIN_BUTTON';
|
export const LOGIN_BUTTON = 'LOGIN_BUTTON';
|
||||||
|
@ -1,7 +1,16 @@
|
|||||||
{
|
{
|
||||||
"rewrites": [
|
"rewrites": [
|
||||||
{ "source": "/api/:match*", "destination": "https://unleash.herokuapp.com/api/:match*" },
|
{
|
||||||
{ "source": "/logout", "destination": "https://unleash.herokuapp.com/logout" },
|
"source": "/api/:match*",
|
||||||
{ "source": "/auth/:match*", "destination": "https://unleash.herokuapp.com/auth/:match*" }
|
"destination": "https://unleash4.herokuapp.com/api/:match*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/logout",
|
||||||
|
"destination": "https://unleash4.herokuapp.com/logout"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/auth/:match*",
|
||||||
|
"destination": "https://unleash4.herokuapp.com/auth/:match*"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user