mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-15 01:16:22 +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
|
||||
uses: cypress-io/github-action@v2
|
||||
with:
|
||||
env: AUTH_USER=test@unleash-e2e.com
|
||||
env: AUTH_USER=admin,AUTH_PASSWORD=unleash4all
|
||||
config: baseUrl=${{ github.event.deployment_status.target_url }}
|
||||
record: true
|
||||
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 { useSegmentValuesCount } from 'component/segments/hooks/useSegmentValuesCount';
|
||||
import { SEGMENT_VALUES_LIMIT } from 'utils/segmentLimits';
|
||||
import { SEGMENT_CREATE_BTN_ID } from 'utils/testIds';
|
||||
|
||||
export const CreateSegment = () => {
|
||||
const { uiConfig } = useUiConfig();
|
||||
@ -96,6 +97,7 @@ export const CreateSegment = () => {
|
||||
name="segment"
|
||||
permission={CREATE_SEGMENT}
|
||||
disabled={!hasValidConstraints || atSegmentValuesLimit}
|
||||
data-test={SEGMENT_CREATE_BTN_ID}
|
||||
/>
|
||||
</SegmentForm>
|
||||
</FormTemplate>
|
||||
|
@ -17,6 +17,7 @@ import { UpdateButton } from 'component/common/UpdateButton/UpdateButton';
|
||||
import { segmentsDocsLink } from 'component/segments/SegmentDocs/SegmentDocs';
|
||||
import { useSegmentValuesCount } from 'component/segments/hooks/useSegmentValuesCount';
|
||||
import { SEGMENT_VALUES_LIMIT } from 'utils/segmentLimits';
|
||||
import { SEGMENT_SAVE_BTN_ID } from 'utils/testIds';
|
||||
|
||||
export const EditSegment = () => {
|
||||
const segmentId = useRequiredPathParam('segmentId');
|
||||
@ -98,6 +99,7 @@ export const EditSegment = () => {
|
||||
<UpdateButton
|
||||
permission={UPDATE_SEGMENT}
|
||||
disabled={!hasValidConstraints || atSegmentValuesLimit}
|
||||
data-test={SEGMENT_SAVE_BTN_ID}
|
||||
/>
|
||||
</SegmentForm>
|
||||
</FormTemplate>
|
||||
|
@ -3,6 +3,7 @@ import Dialogue from 'component/common/Dialogue';
|
||||
import Input from 'component/common/Input/Input';
|
||||
import { useStyles } from './SegmentDeleteConfirm.styles';
|
||||
import { ISegment } from 'interfaces/segment';
|
||||
import { SEGMENT_DIALOG_NAME_ID } from 'utils/testIds';
|
||||
|
||||
interface ISegmentDeleteConfirmProps {
|
||||
segment: ISegment;
|
||||
@ -54,6 +55,7 @@ export const SegmentDeleteConfirm = ({
|
||||
value={confirmName}
|
||||
label="Segment name"
|
||||
className={styles.deleteInput}
|
||||
data-test={SEGMENT_DIALOG_NAME_ID}
|
||||
/>
|
||||
</form>
|
||||
</Dialogue>
|
||||
|
@ -4,6 +4,11 @@ import React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useStyles } from 'component/segments/SegmentFormStepOne/SegmentFormStepOne.styles';
|
||||
import { SegmentFormStep } from '../SegmentForm/SegmentForm';
|
||||
import {
|
||||
SEGMENT_NAME_ID,
|
||||
SEGMENT_DESC_ID,
|
||||
SEGMENT_NEXT_BTN_ID,
|
||||
} from 'utils/testIds';
|
||||
|
||||
interface ISegmentFormPartOneProps {
|
||||
name: string;
|
||||
@ -41,9 +46,9 @@ export const SegmentFormStepOne: React.FC<ISegmentFormPartOneProps> = ({
|
||||
onChange={e => setName(e.target.value)}
|
||||
error={Boolean(errors.name)}
|
||||
errorText={errors.name}
|
||||
onFocus={() => clearErrors()}
|
||||
autoFocus
|
||||
required
|
||||
data-test={SEGMENT_NAME_ID}
|
||||
/>
|
||||
<p className={styles.inputDescription}>
|
||||
What is the segment description?
|
||||
@ -55,7 +60,7 @@ export const SegmentFormStepOne: React.FC<ISegmentFormPartOneProps> = ({
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
error={Boolean(errors.description)}
|
||||
errorText={errors.description}
|
||||
onFocus={() => clearErrors()}
|
||||
data-test={SEGMENT_DESC_ID}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.buttonContainer}>
|
||||
@ -64,7 +69,8 @@ export const SegmentFormStepOne: React.FC<ISegmentFormPartOneProps> = ({
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => setCurrentStep(2)}
|
||||
disabled={name.length === 0}
|
||||
disabled={name.length === 0 || Boolean(errors.name)}
|
||||
data-test={SEGMENT_NEXT_BTN_ID}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
|
@ -28,6 +28,7 @@ import PageContent from 'component/common/PageContent';
|
||||
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
|
||||
import { SegmentDelete } from '../SegmentDelete/SegmentDelete';
|
||||
import { SegmentDocsWarning } from 'component/segments/SegmentDocs/SegmentDocs';
|
||||
import { NAVIGATE_TO_CREATE_SEGMENT } from 'utils/testIds';
|
||||
|
||||
export const SegmentsList = () => {
|
||||
const history = useHistory();
|
||||
@ -101,6 +102,7 @@ export const SegmentsList = () => {
|
||||
<PermissionButton
|
||||
onClick={() => history.push('/segments/create')}
|
||||
permission={CREATE_SEGMENT}
|
||||
data-test={NAVIGATE_TO_CREATE_SEGMENT}
|
||||
>
|
||||
New Segment
|
||||
</PermissionButton>
|
||||
|
@ -9,6 +9,7 @@ import PermissionIconButton from 'component/common/PermissionIconButton/Permissi
|
||||
import TimeAgo from 'react-timeago';
|
||||
import { ISegment } from 'interfaces/segment';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { SEGMENT_DELETE_BTN_ID } from 'utils/testIds';
|
||||
|
||||
interface ISegmentListItemProps {
|
||||
id: number;
|
||||
@ -82,6 +83,7 @@ export const SegmentListItem = ({
|
||||
setDelDialog(true);
|
||||
}}
|
||||
permission={ADMIN}
|
||||
data-test={`${SEGMENT_DELETE_BTN_ID}_${name}`}
|
||||
>
|
||||
<Delete />
|
||||
</PermissionIconButton>
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { IConstraint } from 'interfaces/strategy';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSegmentValidation } from 'hooks/api/getters/useSegmentValidation/useSegmentValidation';
|
||||
|
||||
export const useSegmentForm = (
|
||||
initialName = '',
|
||||
@ -11,6 +12,7 @@ export const useSegmentForm = (
|
||||
const [constraints, setConstraints] =
|
||||
useState<IConstraint[]>(initialConstraints);
|
||||
const [errors, setErrors] = useState({});
|
||||
const nameError = useSegmentValidation(name, initialName);
|
||||
|
||||
useEffect(() => {
|
||||
setName(initialName);
|
||||
@ -25,6 +27,13 @@ export const useSegmentForm = (
|
||||
// eslint-disable-next-line
|
||||
}, [JSON.stringify(initialConstraints)]);
|
||||
|
||||
useEffect(() => {
|
||||
setErrors(errors => ({
|
||||
...errors,
|
||||
name: nameError,
|
||||
}));
|
||||
}, [nameError]);
|
||||
|
||||
const getSegmentPayload = () => {
|
||||
return {
|
||||
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 */
|
||||
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';
|
||||
|
||||
/* 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_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 */
|
||||
export const LOGIN_EMAIL_ID = 'LOGIN_EMAIL_ID';
|
||||
export const LOGIN_BUTTON = 'LOGIN_BUTTON';
|
||||
|
@ -1,7 +1,16 @@
|
||||
{
|
||||
"rewrites": [
|
||||
{ "source": "/api/:match*", "destination": "https://unleash.herokuapp.com/api/:match*" },
|
||||
{ "source": "/logout", "destination": "https://unleash.herokuapp.com/logout" },
|
||||
{ "source": "/auth/:match*", "destination": "https://unleash.herokuapp.com/auth/:match*" }
|
||||
{
|
||||
"source": "/api/: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