diff --git a/frontend/.github/workflows/e2e.feature.yml b/frontend/.github/workflows/e2e.feature.yml index 19d3860ef4..ad89346f22 100644 --- a/frontend/.github/workflows/e2e.feature.yml +++ b/frontend/.github/workflows/e2e.feature.yml @@ -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 diff --git a/frontend/.github/workflows/e2e.segments.yml b/frontend/.github/workflows/e2e.segments.yml new file mode 100644 index 0000000000..b8111b2e32 --- /dev/null +++ b/frontend/.github/workflows/e2e.segments.yml @@ -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 }} diff --git a/frontend/cypress/integration/segments/segments.spec.ts b/frontend/cypress/integration/segments/segments.spec.ts new file mode 100644 index 0000000000..e9bb58f9ed --- /dev/null +++ b/frontend/cypress/integration/segments/segments.spec.ts @@ -0,0 +1,77 @@ +/// + +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'); + }); +}); diff --git a/frontend/src/component/segments/CreateSegment/CreateSegment.tsx b/frontend/src/component/segments/CreateSegment/CreateSegment.tsx index 521c704ec6..207c2f4296 100644 --- a/frontend/src/component/segments/CreateSegment/CreateSegment.tsx +++ b/frontend/src/component/segments/CreateSegment/CreateSegment.tsx @@ -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} /> diff --git a/frontend/src/component/segments/EditSegment/EditSegment.tsx b/frontend/src/component/segments/EditSegment/EditSegment.tsx index 7d0b1dd615..f6f9583249 100644 --- a/frontend/src/component/segments/EditSegment/EditSegment.tsx +++ b/frontend/src/component/segments/EditSegment/EditSegment.tsx @@ -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 = () => { diff --git a/frontend/src/component/segments/SegmentDelete/SegmentDeleteConfirm/SegmentDeleteConfirm.tsx b/frontend/src/component/segments/SegmentDelete/SegmentDeleteConfirm/SegmentDeleteConfirm.tsx index 6f3605f0ed..ad91af165d 100644 --- a/frontend/src/component/segments/SegmentDelete/SegmentDeleteConfirm/SegmentDeleteConfirm.tsx +++ b/frontend/src/component/segments/SegmentDelete/SegmentDeleteConfirm/SegmentDeleteConfirm.tsx @@ -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} /> diff --git a/frontend/src/component/segments/SegmentFormStepOne/SegmentFormStepOne.tsx b/frontend/src/component/segments/SegmentFormStepOne/SegmentFormStepOne.tsx index 21180f1b6e..f40a434890 100644 --- a/frontend/src/component/segments/SegmentFormStepOne/SegmentFormStepOne.tsx +++ b/frontend/src/component/segments/SegmentFormStepOne/SegmentFormStepOne.tsx @@ -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 = ({ onChange={e => setName(e.target.value)} error={Boolean(errors.name)} errorText={errors.name} - onFocus={() => clearErrors()} autoFocus required + data-test={SEGMENT_NAME_ID} />

What is the segment description? @@ -55,7 +60,7 @@ export const SegmentFormStepOne: React.FC = ({ onChange={e => setDescription(e.target.value)} error={Boolean(errors.description)} errorText={errors.description} - onFocus={() => clearErrors()} + data-test={SEGMENT_DESC_ID} />

@@ -64,7 +69,8 @@ export const SegmentFormStepOne: React.FC = ({ 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 diff --git a/frontend/src/component/segments/SegmentList/SegmentList.tsx b/frontend/src/component/segments/SegmentList/SegmentList.tsx index 2f3e763c91..281dea24f3 100644 --- a/frontend/src/component/segments/SegmentList/SegmentList.tsx +++ b/frontend/src/component/segments/SegmentList/SegmentList.tsx @@ -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 = () => { history.push('/segments/create')} permission={CREATE_SEGMENT} + data-test={NAVIGATE_TO_CREATE_SEGMENT} > New Segment diff --git a/frontend/src/component/segments/SegmentList/SegmentListItem/SegmentListItem.tsx b/frontend/src/component/segments/SegmentList/SegmentListItem/SegmentListItem.tsx index 1d0289ab0c..f948cf851e 100644 --- a/frontend/src/component/segments/SegmentList/SegmentListItem/SegmentListItem.tsx +++ b/frontend/src/component/segments/SegmentList/SegmentListItem/SegmentListItem.tsx @@ -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}`} > diff --git a/frontend/src/component/segments/hooks/useSegmentForm.ts b/frontend/src/component/segments/hooks/useSegmentForm.ts index 2e81eb0c32..c01caa26e9 100644 --- a/frontend/src/component/segments/hooks/useSegmentForm.ts +++ b/frontend/src/component/segments/hooks/useSegmentForm.ts @@ -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(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, diff --git a/frontend/src/hooks/api/getters/useSegmentValidation/useSegmentValidation.ts b/frontend/src/hooks/api/getters/useSegmentValidation/useSegmentValidation.ts new file mode 100644 index 0000000000..9b865a5f4d --- /dev/null +++ b/frontend/src/hooks/api/getters/useSegmentValidation/useSegmentValidation.ts @@ -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(); + 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 => + fetch(formatApiPath('api/admin/segments/validate'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + +const parseValidationResponse = async ( + res: Response +): Promise => { + if (res.ok) { + return; + } + + const json = await res.json(); + return json.details[0].message; +}; diff --git a/frontend/src/utils/testIds.ts b/frontend/src/utils/testIds.ts index ec8b5f8515..7d0e9c539d 100644 --- a/frontend/src/utils/testIds.ts +++ b/frontend/src/utils/testIds.ts @@ -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'; diff --git a/frontend/vercel.json b/frontend/vercel.json index d924b1a1e0..e2941effb6 100644 --- a/frontend/vercel.json +++ b/frontend/vercel.json @@ -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*" + } ] }