From 1e817d928572520ae889378b6664d82df7e11605 Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 24 Apr 2025 10:15:27 +0200 Subject: [PATCH 1/8] feat: implemented-clone-segment --- frontend/common/services/useSegment.ts | 11 ++ frontend/common/types/requests.ts | 5 + .../common/utils/calculateListPosition.ts | 12 ++ frontend/common/utils/utils.tsx | 2 - frontend/web/components/FeatureAction.tsx | 92 ++++------ .../components/modals/ConfirmCloneSegment.tsx | 73 ++++++++ .../modals/CreateSegmentRulesTabForm.tsx | 1 - .../web/components/pages/SegmentsPage.tsx | 87 ++-------- .../segments/SegmentRow/SegmentRow.tsx | 160 ++++++++++++++++++ .../SegmentRow/components/SegmentAction.tsx | 78 +++++++++ frontend/web/components/shared/ActionItem.tsx | 41 +++++ .../web/styles/project/_FeaturesPage.scss | 1 + 12 files changed, 432 insertions(+), 131 deletions(-) create mode 100644 frontend/common/utils/calculateListPosition.ts create mode 100644 frontend/web/components/modals/ConfirmCloneSegment.tsx create mode 100644 frontend/web/components/segments/SegmentRow/SegmentRow.tsx create mode 100644 frontend/web/components/segments/SegmentRow/components/SegmentAction.tsx create mode 100644 frontend/web/components/shared/ActionItem.tsx diff --git a/frontend/common/services/useSegment.ts b/frontend/common/services/useSegment.ts index e4b9f3de7d83..4ebd0a128c4f 100644 --- a/frontend/common/services/useSegment.ts +++ b/frontend/common/services/useSegment.ts @@ -8,6 +8,16 @@ export const segmentService = service .enhanceEndpoints({ addTagTypes: ['Segment'] }) .injectEndpoints({ endpoints: (builder) => ({ + cloneSegment: builder.mutation({ + invalidatesTags: (q, e, arg) => [ + { id: `LIST${arg.projectId}`, type: 'Segment' }, + ], + query: (query: Req['cloneSegment']) => ({ + body: { name: query.name }, + method: 'POST', + url: `projects/${query.projectId}/segments/${query.segmentId}/clone/`, + }), + }), createSegment: builder.mutation({ invalidatesTags: (q, e, arg) => [ { id: `LIST${arg.projectId}`, type: 'Segment' }, @@ -118,6 +128,7 @@ export async function getSegment( // END OF FUNCTION_EXPORTS export const { + useCloneSegmentMutation, useCreateSegmentMutation, useDeleteSegmentMutation, useGetSegmentQuery, diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index db54fff4ecce..d66425ae9817 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -79,6 +79,11 @@ export type Req = { projectId: number | string segment: Omit } + cloneSegment: { + projectId: number | string + segmentId: number + name: string + } getAuditLogs: PagedRequest<{ search?: string project: string diff --git a/frontend/common/utils/calculateListPosition.ts b/frontend/common/utils/calculateListPosition.ts new file mode 100644 index 000000000000..0233a7956800 --- /dev/null +++ b/frontend/common/utils/calculateListPosition.ts @@ -0,0 +1,12 @@ +export function calculateListPosition( + btnEl: HTMLElement, + listEl: HTMLElement, +): { top: number; left: number } { + const listPosition = listEl.getBoundingClientRect() + const btnPosition = btnEl.getBoundingClientRect() + const pageTop = window.visualViewport?.pageTop ?? 0 + return { + left: btnPosition.right - listPosition.width, + top: pageTop + btnPosition.bottom, + } +} diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index 5654a39e24fa..15ebb77b9818 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -14,7 +14,6 @@ import { ProjectFlag, SegmentCondition, Tag, - User, PConfidence, } from 'common/types/responses' import flagsmith from 'flagsmith' @@ -27,7 +26,6 @@ import { defaultFlags } from 'common/stores/default-flags' import Color from 'color' import { selectBuildVersion } from 'common/services/useBuildVersion' import { getStore } from 'common/store' -import format from './format' const semver = require('semver') diff --git a/frontend/web/components/FeatureAction.tsx b/frontend/web/components/FeatureAction.tsx index 8dcbff94d281..c79f2340c8ff 100644 --- a/frontend/web/components/FeatureAction.tsx +++ b/frontend/web/components/FeatureAction.tsx @@ -10,6 +10,8 @@ import { Tag } from 'common/types/responses' import color from 'color' import { getTagColor } from './tags/Tag' import ActionButton from './ActionButton' +import ActionItem from './shared/ActionItem' +import { calculateListPosition } from 'common/utils/calculateListPosition' interface FeatureActionProps { projectId: string @@ -29,19 +31,6 @@ interface FeatureActionProps { type ActionType = 'copy' | 'audit' | 'history' | 'remove' -function calculateListPosition( - btnEl: HTMLElement, - listEl: HTMLElement, -): { top: number; left: number } { - const listPosition = listEl.getBoundingClientRect() - const btnPosition = btnEl.getBoundingClientRect() - const pageTop = window.visualViewport?.pageTop ?? 0 - return { - left: btnPosition.right - listPosition.width, - top: pageTop + btnPosition.bottom, - } -} - export const FeatureAction: FC = ({ featureIndex, hideAudit, @@ -90,9 +79,9 @@ export const FeatureAction: FC = ({ useLayoutEffect(() => { if (!isOpen || !listRef.current || !btnRef.current) return - const listPosition = calculateListPosition(btnRef.current, listRef.current) - listRef.current.style.top = `${listPosition.top}px` - listRef.current.style.left = `${listPosition.left}px` + const { left, top } = calculateListPosition(btnRef.current, listRef.current) + listRef.current.style.top = `${top}px` + listRef.current.style.left = `${left}px` }, [isOpen]) const isProtected = !!protectedTags?.length @@ -107,44 +96,37 @@ export const FeatureAction: FC = ({ {isOpen && (
-
{ - e.stopPropagation() + } + label='Copy Feature Name' + handleActionClick={() => { handleActionClick('copy') }} - > - - Copy Feature Name -
+ entity='feature' + index={featureIndex} + /> {!hideAudit && ( -
{ - e.stopPropagation() + } + label='Show Audit Logs' + handleActionClick={() => { handleActionClick('audit') }} - > - - Show Audit Logs -
+ entity='feature' + index={featureIndex} + /> )} - {!hideHistory && ( -
{ - e.stopPropagation() + } + label='Show History' + handleActionClick={() => { handleActionClick('history') }} - > - - Show History -
+ entity='feature' + index={featureIndex} + /> )} - {!hideRemove && ( = ({ Constants.projectPermissions('Delete Feature'), { - e.stopPropagation() + } + label='Remove feature' + handleActionClick={() => { handleActionClick('remove') }} - > - - Remove feature -
+ entity='feature' + index={featureIndex} + disabled={ + !removeFeaturePermission || readOnly || isProtected + } + /> } > {isProtected && diff --git a/frontend/web/components/modals/ConfirmCloneSegment.tsx b/frontend/web/components/modals/ConfirmCloneSegment.tsx new file mode 100644 index 000000000000..aa2477184b40 --- /dev/null +++ b/frontend/web/components/modals/ConfirmCloneSegment.tsx @@ -0,0 +1,73 @@ +import React, { FC, FormEvent, useState } from 'react' +import { Segment } from 'common/types/responses' +import InputGroup from 'components/base/forms/InputGroup' +import Utils from 'common/utils/utils' // we need this to make JSX compile +import Button from 'components/base/forms/Button' +import ModalHR from './ModalHR' +import Format from 'common/utils/format' + +type ConfirmCloneSegmentType = { + cb: (name: string) => void + isLoading?: boolean + segment: Segment +} + +const ConfirmCloneSegment: FC = ({ + cb, + isLoading, + segment, +}) => { + const [segmentCloneName, setSegmentCloneName] = useState('') + + const submit = (e: FormEvent) => { + e.preventDefault() + if (!!segmentCloneName && segmentCloneName !== segment.name) { + closeModal() + cb(segmentCloneName) + } + } + + return ( +
+
+ { + setSegmentCloneName( + Format.enumeration + .set(Utils.safeParseEventValue(e)) + .toLowerCase(), + ) + }} + /> +
+ +
+ + +
+ + ) +} + +export default ConfirmCloneSegment diff --git a/frontend/web/components/modals/CreateSegmentRulesTabForm.tsx b/frontend/web/components/modals/CreateSegmentRulesTabForm.tsx index 7733724d51cd..284a71c30fdd 100644 --- a/frontend/web/components/modals/CreateSegmentRulesTabForm.tsx +++ b/frontend/web/components/modals/CreateSegmentRulesTabForm.tsx @@ -61,7 +61,6 @@ const CreateSegmentRulesTabForm: React.FC = ({ setValueChanged, showDescriptions, }) => { - const SEGMENT_ID_MAXLENGTH = Constants.forms.maxLength.SEGMENT_ID return (
diff --git a/frontend/web/components/pages/SegmentsPage.tsx b/frontend/web/components/pages/SegmentsPage.tsx index 62e3499e1907..5b45bc465863 100644 --- a/frontend/web/components/pages/SegmentsPage.tsx +++ b/frontend/web/components/pages/SegmentsPage.tsx @@ -1,10 +1,10 @@ -import React, { FC, ReactNode, useEffect, useRef, useState } from 'react' // we need this to make JSX compile +import React, { FC, ReactNode, useEffect, useState } from 'react' // we need this to make JSX compile import { RouterChildContext } from 'react-router' -import { find, sortBy } from 'lodash' +import { sortBy } from 'lodash' import Constants from 'common/constants' import useSearchThrottle from 'common/useSearchThrottle' -import { Environment, Segment } from 'common/types/responses' +import { Environment } from 'common/types/responses' import { useDeleteSegmentMutation, useGetSegmentsQuery, @@ -12,14 +12,12 @@ import { import { useHasPermission } from 'common/providers/Permission' import API from 'project/api' import Button from 'components/base/forms/Button' -import ConfirmRemoveSegment from 'components/modals/ConfirmRemoveSegment' import CreateSegmentModal from 'components/modals/CreateSegment' import PanelSearch from 'components/PanelSearch' import JSONReference from 'components/JSONReference' import ConfigProvider from 'common/providers/ConfigProvider' import Utils from 'common/utils/utils' import ProjectStore from 'common/stores/project-store' -import Icon from 'components/Icon' import PageTitle from 'components/PageTitle' import Switch from 'components/Switch' import { setModalTitle } from 'components/modals/base/ModalDefault' @@ -28,6 +26,7 @@ import InfoMessage from 'components/InfoMessage' import { withRouter } from 'react-router-dom' import CodeHelp from 'components/CodeHelp' +import { SegmentRow } from 'components/segments/SegmentRow/SegmentRow' type SegmentsPageType = { router: RouterChildContext['router'] match: { @@ -58,6 +57,7 @@ const SegmentsPage: FC = (props) => { closeModal() } }, [id]) + const { data, error, isLoading, refetch } = useGetSegmentsQuery({ include_feature_specific: showFeatureSpecific, page, @@ -106,13 +106,6 @@ const SegmentsPage: FC = (props) => { 'side-modal create-new-segment-modal', ) } - const confirmRemove = (segment: Segment, cb: () => void) => { - openModal( - 'Remove Segment', - , - 'p-0', - ) - } const { permission: manageSegmentsPermission } = useHasPermission({ id: projectId, @@ -151,6 +144,7 @@ const SegmentsPage: FC = (props) => { }, ) } + const renderWithPermission = ( permission: boolean, name: string, @@ -164,6 +158,7 @@ const SegmentsPage: FC = (props) => { ) } + const segments = data?.results return (
= (props) => { items={sortBy(segments, (v) => { return `${v.feature ? 'a' : 'z'}${v.name}` })} - renderRow={({ description, feature, id, name }, i) => { + renderRow={(segment, index) => { return renderWithPermission( manageSegmentsPermission, 'Manage segments', - - - props.router.history.push( - `${ - document.location.pathname - }?${Utils.toParam({ - ...Utils.fromParam(), - id, - })}`, - ) - : undefined - } - > - - {name} - {feature && ( -
- Feature-Specific -
- )} -
-
- {description || 'No description'} -
-
-
- -
-
, + , ) }} paging={data} diff --git a/frontend/web/components/segments/SegmentRow/SegmentRow.tsx b/frontend/web/components/segments/SegmentRow/SegmentRow.tsx new file mode 100644 index 000000000000..f53e2ef3ea44 --- /dev/null +++ b/frontend/web/components/segments/SegmentRow/SegmentRow.tsx @@ -0,0 +1,160 @@ +import { FC } from 'react' +import { RouterChildContext } from 'react-router' + +import { useHasPermission } from 'common/providers/Permission' + +import Utils from 'common/utils/utils' +import Icon from 'components/Icon' +import ConfirmRemoveSegment from 'components/modals/ConfirmRemoveSegment' + +import { Segment } from 'common/types/responses' +import { MutationDefinition } from '@reduxjs/toolkit/dist/query/endpointDefinitions' +import { MutationTrigger } from '@reduxjs/toolkit/dist/query/react/buildHooks' +import SegmentAction from './components/SegmentAction' +import ConfirmCloneSegment from 'components/modals/ConfirmCloneSegment' +import { useCloneSegmentMutation } from 'common/services/useSegment' + +interface SegmentRowProps { + segment: Segment + index: number + projectId: string + router: RouterChildContext['router'] + removeSegment: MutationTrigger< + MutationDefinition< + { + projectId: number | string + id: number + }, + any, + 'Segment', + Segment, + 'service' + > + > +} + +export const SegmentRow: FC = ({ + index, + projectId, + removeSegment, + router, + segment, +}) => { + const { description, feature, id, name } = segment + + const { permission: manageSegmentsPermission } = useHasPermission({ + id: projectId, + level: 'project', + permission: 'MANAGE_SEGMENTS', + }) + + const [cloneSegment, { isLoading: isCloning }] = useCloneSegmentMutation() + + const isCloningEnabled = Utils.getFlagsmithHasFeature('clone_segment') + + const removeSegmentCallback = async () => { + try { + await removeSegment({ id, projectId }) + toast( +
+ Removed Segment: {segment.name} +
, + ) + } catch (error) { + toast( +
+ Error removing segment: {segment.name} +
, + 'danger', + ) + } + } + + const cloneSegmentCallback = async (name: string) => { + try { + await cloneSegment({ name, projectId, segmentId: segment.id }).unwrap() + toast( +
+ Cloned Segment: {segment.name} into{' '} + {name} +
, + ) + } catch (error) { + toast( +
+ Error cloning segment: {segment.name} +
, + 'danger', + ) + } + } + + const handleRemoveSegment = () => { + openModal( + 'Remove Segment', + , + 'p-0', + ) + } + + const handleCloneSegment = () => { + openModal( + 'Clone Segment', + , + 'p-0', + ) + } + + return ( + + + router.history.push( + `${document.location.pathname}?${Utils.toParam({ + ...Utils.fromParam(), + id, + })}`, + ) + : undefined + } + > + + {name} + {feature && ( +
Feature-Specific
+ )} +
+
+ {description || 'No description'} +
+
+
+ {isCloningEnabled ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/frontend/web/components/segments/SegmentRow/components/SegmentAction.tsx b/frontend/web/components/segments/SegmentRow/components/SegmentAction.tsx new file mode 100644 index 000000000000..75a45d63edd0 --- /dev/null +++ b/frontend/web/components/segments/SegmentRow/components/SegmentAction.tsx @@ -0,0 +1,78 @@ +import { FC, useCallback, useLayoutEffect, useRef, useState } from 'react' +import ActionButton from 'components/ActionButton' +import ActionItem from 'components/shared/ActionItem' +import Icon from 'components/Icon' +import useOutsideClick from 'common/useOutsideClick' +import { calculateListPosition } from 'common/utils/calculateListPosition' + +interface SegmentActionProps { + index: number + isRemoveDisabled: boolean + isCloneDisabled: boolean + onRemove: () => void + onClone: () => void +} + +const SegmentAction: FC = ({ + index, + isCloneDisabled = true, + isRemoveDisabled, + onClone, + onRemove, +}) => { + const [isOpen, setIsOpen] = useState(false) + const btnRef = useRef(null) + const listRef = useRef(null) + + const handleOutsideClick = useCallback( + () => isOpen && setIsOpen(false), + [isOpen], + ) + useOutsideClick(listRef, handleOutsideClick) + + useLayoutEffect(() => { + if (!isOpen || !listRef.current || !btnRef.current) return + const { left, top } = calculateListPosition(btnRef.current, listRef.current) + listRef.current.style.top = `${top}px` + listRef.current.style.left = `${left}px` + }, [isOpen]) + + return ( +
+
+ setIsOpen(true)} + data-test={`segment-action-${index}`} + /> +
+ {isOpen && ( +
+ {!isCloneDisabled && ( + } + label='Clone Segment' + handleActionClick={() => { + onClone() + }} + entity='segment' + index={index} + disabled={isCloneDisabled} + /> + )} + } + label='Remove Segment' + handleActionClick={() => { + onRemove() + }} + entity='segment' + index={index} + disabled={isRemoveDisabled} + /> +
+ )} +
+ ) +} + +export default SegmentAction diff --git a/frontend/web/components/shared/ActionItem.tsx b/frontend/web/components/shared/ActionItem.tsx new file mode 100644 index 000000000000..3bb3c8038270 --- /dev/null +++ b/frontend/web/components/shared/ActionItem.tsx @@ -0,0 +1,41 @@ +import { FC, ReactNode } from 'react' +import classNames from 'classnames' + +interface ActionRowProps { + handleActionClick: () => void + index: number + entity: 'feature' | 'segment' + icon: ReactNode + label: string + disabled?: boolean +} + +const ActionItem: FC = ({ + disabled, + entity, + handleActionClick, + icon, + index, + label, +}) => { + return ( +
{ + if (disabled) { + return + } + e.stopPropagation() + handleActionClick() + }} + > + {!!icon && icon} + {label} +
+ ) +} + +export default ActionItem diff --git a/frontend/web/styles/project/_FeaturesPage.scss b/frontend/web/styles/project/_FeaturesPage.scss index 223b06efe5f8..12ea61f4fd47 100644 --- a/frontend/web/styles/project/_FeaturesPage.scss +++ b/frontend/web/styles/project/_FeaturesPage.scss @@ -35,6 +35,7 @@ &_disabled { opacity: 0.4; pointer-events: none; + cursor: not-allowed; } &:hover { From 31626a2130a6ae2dc11378dfe168c69b4455d16d Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 25 Apr 2025 15:08:49 +0200 Subject: [PATCH 2/8] feat: added-clone-segments-e2e --- frontend/e2e/helpers.cafe.ts | 153 +++++++++++------- frontend/e2e/tests/project-permission-test.ts | 8 +- frontend/e2e/tests/segment-test.ts | 16 +- frontend/web/components/FeatureAction.tsx | 5 +- .../components/modals/ConfirmCloneSegment.tsx | 2 +- .../SegmentRow/components/SegmentAction.tsx | 2 + frontend/web/components/shared/ActionItem.tsx | 4 +- 7 files changed, 123 insertions(+), 67 deletions(-) diff --git a/frontend/e2e/helpers.cafe.ts b/frontend/e2e/helpers.cafe.ts index 93d8b386bd80..ed1b2b77079e 100644 --- a/frontend/e2e/helpers.cafe.ts +++ b/frontend/e2e/helpers.cafe.ts @@ -1,5 +1,5 @@ import { RequestLogger, Selector, t } from 'testcafe' -import { FlagsmithValue } from '../common/types/responses'; +import { FlagsmithValue } from '../common/types/responses' export const LONG_TIMEOUT = 40000 @@ -13,6 +13,12 @@ export type Rule = { value: string | number | boolean ors?: Rule[] } + +// Allows to check if an element is present - can be used to identify active feature flag state +export const isElementExists = async (selector: string) => { + return Selector(byId(selector)).exists +} + export const setText = async (selector: string, text: string) => { logUsingLastSection(`Set text ${selector} : ${text}`) if (text) { @@ -40,8 +46,7 @@ export const waitForElementNotClickable = async (selector: string) => { await t .expect(Selector(selector).visible) .ok(`waitForElementVisible(${selector})`, { timeout: LONG_TIMEOUT }) - await t - .expect(Selector(selector).hasAttribute('disabled')).ok() + await t.expect(Selector(selector).hasAttribute('disabled')).ok() } export const waitForElementClickable = async (selector: string) => { @@ -49,8 +54,7 @@ export const waitForElementClickable = async (selector: string) => { await t .expect(Selector(selector).visible) .ok(`waitForElementVisible(${selector})`, { timeout: LONG_TIMEOUT }) - await t - .expect(Selector(selector).hasAttribute('disabled')).notOk() + await t.expect(Selector(selector).hasAttribute('disabled')).notOk() } export const logResults = async (requests: LoggedRequest[], t) => { @@ -106,15 +110,15 @@ export const click = async (selector: string) => { .click(selector) } -export const clickByText = async (text:string, element = 'button') => { +export const clickByText = async (text: string, element = 'button') => { logUsingLastSection(`Click by text ${text} ${element}`) - const selector = Selector(element).withText(text); + const selector = Selector(element).withText(text) await t - .scrollIntoView(selector) - .expect(Selector(selector).hasAttribute('disabled')) - .notOk('ready for testing', { timeout: 5000 }) - .hover(selector) - .click(selector) + .scrollIntoView(selector) + .expect(Selector(selector).hasAttribute('disabled')) + .notOk('ready for testing', { timeout: 5000 }) + .hover(selector) + .click(selector) } export const gotoSegments = async () => { @@ -131,7 +135,11 @@ export const getLogger = () => stringifyResponseBody: true, }) -export const createRole = async (roleName: string, index: number, users: number[]) => { +export const createRole = async ( + roleName: string, + index: number, + users: number[], +) => { await click(byId('tab-item-roles')) await click(byId('create-role')) await setText(byId('role-name'), roleName) @@ -145,8 +153,7 @@ export const createRole = async (roleName: string, index: number, users: number[ await closeModal() } - -export const editRoleMembers = async (index:number)=>{ +export const editRoleMembers = async (index: number) => { await click(byId('tab-item-roles')) await click(byId('create-role')) await setText(byId('role-name'), roleName) @@ -270,10 +277,12 @@ export const saveFeatureSegments = async () => { await waitForElementNotExist('#create-feature-modal') } -export const createEnvironment = async (name:string) => { +export const createEnvironment = async (name: string) => { await setText('[name="envName"]', name) await click('#create-env-btn') - await waitForElementVisible(byId(`switch-environment-${name.toLowerCase()}-active`)) + await waitForElementVisible( + byId(`switch-environment-${name.toLowerCase()}-active`), + ) } export const goToUser = async (index: number) => { @@ -301,8 +310,25 @@ export const assertTextContentContains = (selector: string, v: string) => t.expect(Selector(selector).textContent).contains(v) export const getText = (selector: string) => Selector(selector).innerText -export const deleteSegment = async (index: number, name: string) => { - await click(byId(`remove-segment-btn-${index}`)) +export const cloneSegment = async (index: number, name: string) => { + await click(byId(`segment-action-${index}`)) + await click(byId(`segment-clone-${index}`)) + await setText('[name="clone-segment-name"]', name) + await click('#confirm-clone-segment-btn') + await waitForElementVisible(byId(`segment-${index + 1}-name`)) +} + +export const deleteSegment = async ( + index: number, + name: string, + legacyDelete = true, +) => { + if (legacyDelete) { + await click(byId(`remove-segment-btn-${index}`)) + } else { + await click(byId(`segment-action-${index}`)) + await click(byId(`segment-remove-${index}`)) + } await setText('[name="confirm-segment-name"]', name) await click('#confirm-remove-segment-btn') await waitForElementNotExist(`remove-segment-btn-${index}`) @@ -320,41 +346,44 @@ export const logout = async () => { await waitForElementVisible('#login-page') } -export const goToFeatureVersions = async (featureIndex:number) =>{ +export const goToFeatureVersions = async (featureIndex: number) => { await gotoFeature(featureIndex) await click(byId('change-history')) } export const compareVersion = async ( - featureIndex:number, - versionIndex:number, - compareOption: 'LIVE'|'PREVIOUS'|null, - oldEnabled:boolean, - newEnabled:boolean, - oldValue?:FlagsmithValue, - newValue?:FlagsmithValue -) =>{ + featureIndex: number, + versionIndex: number, + compareOption: 'LIVE' | 'PREVIOUS' | null, + oldEnabled: boolean, + newEnabled: boolean, + oldValue?: FlagsmithValue, + newValue?: FlagsmithValue, +) => { await goToFeatureVersions(featureIndex) await click(byId(`history-item-${versionIndex}-compare`)) - if(compareOption==='LIVE') { + if (compareOption === 'LIVE') { await click(byId(`history-item-${versionIndex}-compare-live`)) - } else if(compareOption==='PREVIOUS') { + } else if (compareOption === 'PREVIOUS') { await click(byId(`history-item-${versionIndex}-compare-previous`)) } await assertTextContent(byId(`old-enabled`), `${oldEnabled}`) await assertTextContent(byId(`new-enabled`), `${newEnabled}`) - if(oldValue) { + if (oldValue) { await assertTextContent(byId(`old-value`), `${oldValue}`) } - if(newValue) { + if (newValue) { await assertTextContent(byId(`old-value`), `${oldValue}`) } await closeModal() } -export const assertNumberOfVersions = async (index:number, versions:number) =>{ +export const assertNumberOfVersions = async ( + index: number, + versions: number, +) => { await goToFeatureVersions(index) - await waitForElementVisible(byId(`history-item-${versions-2}-compare`)) + await waitForElementVisible(byId(`history-item-${versions - 2}-compare`)) await closeModal() } @@ -389,7 +418,10 @@ export const createRemoteConfig = async ( await closeModal() } -export const createOrganisationAndProject = async (organisationName:string,projectName:string) =>{ +export const createOrganisationAndProject = async ( + organisationName: string, + projectName: string, +) => { log('Create Organisation') await click(byId('home-link')) await click(byId('create-organisation-btn')) @@ -418,12 +450,12 @@ export const editRemoteConfig = async ( await click(byId('toggle-feature-button')) } await Promise.all( - mvs.map(async (v, i) => { - await setText(byId(`featureVariationWeight${v.value}`), `${v.weight}`) - }), + mvs.map(async (v, i) => { + await setText(byId(`featureVariationWeight${v.value}`), `${v.weight}`) + }), ) await click(byId('update-feature-btn')) - if(value) { + if (value) { await waitForElementVisible(byId(`feature-value-${index}`)) await assertTextContent(byId(`feature-value-${index}`), expectedValue) } @@ -455,11 +487,11 @@ export const createFeature = async ( export const deleteFeature = async (index: number, name: string) => { await click(byId(`feature-action-${index}`)) - await waitForElementVisible(byId(`remove-feature-btn-${index}`)) - await click(byId(`remove-feature-btn-${index}`)) + await waitForElementVisible(byId(`feature-remove-${index}`)) + await click(byId(`feature-remove-${index}`)) await setText('[name="confirm-feature-name"]', name) await click('#confirm-remove-feature-btn') - await waitForElementNotExist(`remove-feature-btn-${index}`) + await waitForElementNotExist(`feature-remove-${index}`) } export const toggleFeature = async (index: number, toValue: boolean) => { @@ -531,14 +563,18 @@ export const waitAndRefresh = async (waitFor = 3000) => { await t.eval(() => location.reload()) } -export const refreshUntilElementVisible = async (selector: string, maxRetries=20) => { - const element = Selector(selector); - const isElementVisible = async () => await element.exists && await element.visible; - let retries = 0; +export const refreshUntilElementVisible = async ( + selector: string, + maxRetries = 20, +) => { + const element = Selector(selector) + const isElementVisible = async () => + (await element.exists) && (await element.visible) + let retries = 0 while (retries < maxRetries && !(await isElementVisible())) { - await t.eval(() => location.reload()); // Reload the page - await t.wait(3000); - retries++; + await t.eval(() => location.reload()) // Reload the page + await t.wait(3000) + retries++ } return t.scrollIntoView(element) } @@ -561,21 +597,26 @@ const permissionsMap = { 'VIEW_IDENTITIES': 'environment', 'MANAGE_SEGMENT_OVERRIDES': 'environment', 'MANAGE_TAGS': 'project', -} as const; - - -export const setUserPermission = async (email: string, permission: keyof typeof permissionsMap | 'ADMIN', entityName:string|null, entityLevel?: 'project'|'environment'|'organisation', parentName?: string) => { +} as const + +export const setUserPermission = async ( + email: string, + permission: keyof typeof permissionsMap | 'ADMIN', + entityName: string | null, + entityLevel?: 'project' | 'environment' | 'organisation', + parentName?: string, +) => { await click(byId('users-and-permissions')) await click(byId(`user-${email}`)) const level = permissionsMap[permission] || entityLevel await click(byId(`${level}-permissions-tab`)) - if(parentName) { + if (parentName) { await clickByText(parentName, 'a') } - if(entityName) { + if (entityName) { await click(byId(`permissions-${entityName.toLowerCase()}`)) } - if(permission==='ADMIN') { + if (permission === 'ADMIN') { await click(byId(`admin-switch-${level}`)) } else { await click(byId(`permission-switch-${permission}`)) diff --git a/frontend/e2e/tests/project-permission-test.ts b/frontend/e2e/tests/project-permission-test.ts index 8f68d67df317..f6c83e84233e 100644 --- a/frontend/e2e/tests/project-permission-test.ts +++ b/frontend/e2e/tests/project-permission-test.ts @@ -88,8 +88,8 @@ export default async function () { await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) await click('#project-select-0') await click(byId('feature-action-0')) - await waitForElementVisible(byId('remove-feature-btn-0')) - await Selector(byId('remove-feature-btn-0')).hasClass( + await waitForElementVisible(byId('feature-remove-0')) + await Selector(byId('feature-remove-0')).hasClass( 'feature-action__item_disabled', ) await logout() @@ -101,8 +101,8 @@ export default async function () { await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) await click('#project-select-0') await click(byId('feature-action-0')) - await waitForElementVisible(byId('remove-feature-btn-0')) - await t.expect(Selector(byId('remove-feature-btn-0')).hasClass('feature-action__item_disabled')).notOk(); + await waitForElementVisible(byId('feature-remove-0')) + await t.expect(Selector(byId('feature-remove-0')).hasClass('feature-action__item_disabled')).notOk(); await logout() log('User without MANAGE_SEGMENTS permissions cannot Manage Segments') diff --git a/frontend/e2e/tests/segment-test.ts b/frontend/e2e/tests/segment-test.ts index 2505dc86647f..2a3b0112b4f6 100644 --- a/frontend/e2e/tests/segment-test.ts +++ b/frontend/e2e/tests/segment-test.ts @@ -25,15 +25,15 @@ import { viewFeature, waitAndRefresh, waitForElementVisible, - createOrganisationAndProject, -} from '../helpers.cafe'; + cloneSegment, + isElementExists, +} from '../helpers.cafe' import { E2E_USER, PASSWORD } from '../config' export const testSegment1 = async () => { log('Login') await login(E2E_USER, PASSWORD) await click('#project-select-1') - log('Create Feature') await createRemoteConfig(0, 'mv_flag', 'big', null, null, [ @@ -125,9 +125,17 @@ export const testSegment1 = async () => { await waitAndRefresh() await assertTextContent(byId('user-feature-value-0'), '"medium"') + const isCloneSegmentEnabled = await isElementExists('segment-action-0') + if (isCloneSegmentEnabled) { + log('Clone segment') + await gotoSegments() + await cloneSegment(0, '0cloned-segment') + await deleteSegment(0, '0cloned-segment', !isCloneSegmentEnabled) + } + log('Delete segment') await gotoSegments() - await deleteSegment(0, '18_or_19') + await deleteSegment(0, '18_or_19', !isCloneSegmentEnabled) await gotoFeatures() await deleteFeature(0, 'mv_flag') } diff --git a/frontend/web/components/FeatureAction.tsx b/frontend/web/components/FeatureAction.tsx index c79f2340c8ff..7755102d257f 100644 --- a/frontend/web/components/FeatureAction.tsx +++ b/frontend/web/components/FeatureAction.tsx @@ -1,5 +1,4 @@ import { FC, useCallback, useLayoutEffect, useRef, useState } from 'react' -import classNames from 'classnames' import useOutsideClick from 'common/useOutsideClick' import Utils from 'common/utils/utils' @@ -104,6 +103,7 @@ export const FeatureAction: FC = ({ }} entity='feature' index={featureIndex} + action='copy' /> {!hideAudit && ( = ({ }} entity='feature' index={featureIndex} + action='audit' /> )} {!hideHistory && ( @@ -125,6 +126,7 @@ export const FeatureAction: FC = ({ }} entity='feature' index={featureIndex} + action='history' /> )} {!hideRemove && ( @@ -146,6 +148,7 @@ export const FeatureAction: FC = ({ handleActionClick={() => { handleActionClick('remove') }} + action='remove' entity='feature' index={featureIndex} disabled={ diff --git a/frontend/web/components/modals/ConfirmCloneSegment.tsx b/frontend/web/components/modals/ConfirmCloneSegment.tsx index aa2477184b40..7ebc16a68764 100644 --- a/frontend/web/components/modals/ConfirmCloneSegment.tsx +++ b/frontend/web/components/modals/ConfirmCloneSegment.tsx @@ -34,7 +34,7 @@ const ConfirmCloneSegment: FC = ({ className='mb-0' inputProps={{ className: 'full-width', - name: 'confirm-segment-name', + name: 'clone-segment-name', }} value={segmentCloneName} title='New Segment Name*' diff --git a/frontend/web/components/segments/SegmentRow/components/SegmentAction.tsx b/frontend/web/components/segments/SegmentRow/components/SegmentAction.tsx index 75a45d63edd0..d315d8761875 100644 --- a/frontend/web/components/segments/SegmentRow/components/SegmentAction.tsx +++ b/frontend/web/components/segments/SegmentRow/components/SegmentAction.tsx @@ -57,6 +57,7 @@ const SegmentAction: FC = ({ entity='segment' index={index} disabled={isCloneDisabled} + action='clone' /> )} = ({ entity='segment' index={index} disabled={isRemoveDisabled} + action='remove' />
)} diff --git a/frontend/web/components/shared/ActionItem.tsx b/frontend/web/components/shared/ActionItem.tsx index 3bb3c8038270..e084dd62a459 100644 --- a/frontend/web/components/shared/ActionItem.tsx +++ b/frontend/web/components/shared/ActionItem.tsx @@ -8,9 +8,11 @@ interface ActionRowProps { icon: ReactNode label: string disabled?: boolean + action: 'remove' | 'copy' | 'audit' | 'history' | 'clone' } const ActionItem: FC = ({ + action, disabled, entity, handleActionClick, @@ -23,7 +25,7 @@ const ActionItem: FC = ({ className={classNames('feature-action__item', { 'feature-action__item_disabled': disabled, })} - data-test={`${entity}-action-${index}`} + data-test={`${entity}-${action}-${index}`} onClick={(e) => { if (disabled) { return From fa9666d432ea6c6a93e1fc83471ee667dc316ba0 Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 1 May 2025 17:41:25 +0200 Subject: [PATCH 3/8] feat: added-description-comments --- frontend/common/utils/calculateListPosition.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/common/utils/calculateListPosition.ts b/frontend/common/utils/calculateListPosition.ts index 0233a7956800..c39af3825d24 100644 --- a/frontend/common/utils/calculateListPosition.ts +++ b/frontend/common/utils/calculateListPosition.ts @@ -1,3 +1,4 @@ +// This function is used to calculate the position of a dropdown menu relative to his trigger button element export function calculateListPosition( btnEl: HTMLElement, listEl: HTMLElement, From fc0655d7b37c074ef5f17addfe53e21256e8f73e Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 1 May 2025 17:41:54 +0200 Subject: [PATCH 4/8] feat: use-req-delete-segment-typing --- frontend/web/components/segments/SegmentRow/SegmentRow.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/frontend/web/components/segments/SegmentRow/SegmentRow.tsx b/frontend/web/components/segments/SegmentRow/SegmentRow.tsx index f53e2ef3ea44..50411b4082b5 100644 --- a/frontend/web/components/segments/SegmentRow/SegmentRow.tsx +++ b/frontend/web/components/segments/SegmentRow/SegmentRow.tsx @@ -13,6 +13,7 @@ import { MutationTrigger } from '@reduxjs/toolkit/dist/query/react/buildHooks' import SegmentAction from './components/SegmentAction' import ConfirmCloneSegment from 'components/modals/ConfirmCloneSegment' import { useCloneSegmentMutation } from 'common/services/useSegment' +import { Req } from 'common/types/requests' interface SegmentRowProps { segment: Segment @@ -20,11 +21,7 @@ interface SegmentRowProps { projectId: string router: RouterChildContext['router'] removeSegment: MutationTrigger< - MutationDefinition< - { - projectId: number | string - id: number - }, + MutationDefinition Date: Thu, 1 May 2025 17:42:55 +0200 Subject: [PATCH 5/8] feat: use-flagsmith-isomorphic-for-flag-evaluation --- frontend/e2e/init.cafe.js | 116 +++++++++++++++-------------- frontend/e2e/tests/segment-test.ts | 5 +- 2 files changed, 63 insertions(+), 58 deletions(-) diff --git a/frontend/e2e/init.cafe.js b/frontend/e2e/init.cafe.js index 7c0e6a0cdbdf..9c75c376c582 100644 --- a/frontend/e2e/init.cafe.js +++ b/frontend/e2e/init.cafe.js @@ -14,7 +14,7 @@ import versioningTests from './tests/versioning-tests' import organisationPermissionTest from './tests/organisation-permission-test' import projectPermissionTest from './tests/project-permission-test' import environmentPermissionTest from './tests/environment-permission-test' -import rolesTest from './tests/roles-test' +import flagsmith from 'flagsmith/isomorphic'; require('dotenv').config() @@ -30,10 +30,16 @@ console.log( '\n', ) + fixture`E2E Tests`.requestHooks(logger).before(async () => { const token = process.env.E2E_TEST_TOKEN ? process.env.E2E_TEST_TOKEN : process.env[`E2E_TEST_TOKEN_${Project.env.toUpperCase()}`] + await flagsmith.init({ + api:Project.flagsmithClientAPI, + environmentID:Project.flagsmith, + fetch, + }) if (token) { await fetch(e2eTestApi, { @@ -86,60 +92,60 @@ fixture`E2E Tests`.requestHooks(logger).before(async () => { }) test('Segment-part-1', async () => { - await testSegment1() - await logout() -}) - -test('Segment-part-2', async () => { - await testSegment2() + await testSegment1(flagsmith) await logout() }) -test('Segment-part-3', async () => { - await testSegment3() - await logout() -}) - -test('Flag', async () => { - await flagTests() - await logout() -}) - -test('Signup', async () => { - await initialiseTests() - await logout() -}) - -test('Invite', async () => { - await inviteTest() -}) - -test('Environment', async () => { - await environmentTest() - await logout() -}) - -test('Project', async () => { - await projectTest() - await logout() -}) - -test('Versioning', async () => { - await versioningTests() - await logout() -}) - -test('Organisation-permission', async () => { - await organisationPermissionTest() - await logout() -}) - -test('Project-permission', async () => { - await projectPermissionTest() - await logout() -}) - -test('Environment-permission', async () => { - await environmentPermissionTest() - await logout() -}) +// test('Segment-part-2', async () => { +// await testSegment2() +// await logout() +// }) + +// test('Segment-part-3', async () => { +// await testSegment3() +// await logout() +// }) + +// test('Flag', async () => { +// await flagTests() +// await logout() +// }) + +// test('Signup', async () => { +// await initialiseTests() +// await logout() +// }) + +// test('Invite', async () => { +// await inviteTest() +// }) + +// test('Environment', async () => { +// await environmentTest() +// await logout() +// }) + +// test('Project', async () => { +// await projectTest() +// await logout() +// }) + +// test('Versioning', async () => { +// await versioningTests() +// await logout() +// }) + +// test('Organisation-permission', async () => { +// await organisationPermissionTest() +// await logout() +// }) + +// test('Project-permission', async () => { +// await projectPermissionTest() +// await logout() +// }) + +// test('Environment-permission', async () => { +// await environmentPermissionTest() +// await logout() +// }) diff --git a/frontend/e2e/tests/segment-test.ts b/frontend/e2e/tests/segment-test.ts index 2a3b0112b4f6..26db73a87adb 100644 --- a/frontend/e2e/tests/segment-test.ts +++ b/frontend/e2e/tests/segment-test.ts @@ -26,11 +26,10 @@ import { waitAndRefresh, waitForElementVisible, cloneSegment, - isElementExists, } from '../helpers.cafe' import { E2E_USER, PASSWORD } from '../config' -export const testSegment1 = async () => { +export const testSegment1 = async (flagsmith: any) => { log('Login') await login(E2E_USER, PASSWORD) await click('#project-select-1') @@ -125,7 +124,7 @@ export const testSegment1 = async () => { await waitAndRefresh() await assertTextContent(byId('user-feature-value-0'), '"medium"') - const isCloneSegmentEnabled = await isElementExists('segment-action-0') + const isCloneSegmentEnabled = await flagsmith.hasFeature('clone_segment') if (isCloneSegmentEnabled) { log('Clone segment') await gotoSegments() From afbafe70a2895026c8c6ad54e55a1745e82f4e84 Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 1 May 2025 17:44:34 +0200 Subject: [PATCH 6/8] feat: use-flagsmith-isomorphic-for-flag-evaluation --- frontend/e2e/init.cafe.js | 106 +++++++++++++++++++------------------- 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/frontend/e2e/init.cafe.js b/frontend/e2e/init.cafe.js index 9c75c376c582..53d3ed61cce4 100644 --- a/frontend/e2e/init.cafe.js +++ b/frontend/e2e/init.cafe.js @@ -96,56 +96,56 @@ test('Segment-part-1', async () => { await logout() }) -// test('Segment-part-2', async () => { -// await testSegment2() -// await logout() -// }) - -// test('Segment-part-3', async () => { -// await testSegment3() -// await logout() -// }) - -// test('Flag', async () => { -// await flagTests() -// await logout() -// }) - -// test('Signup', async () => { -// await initialiseTests() -// await logout() -// }) - -// test('Invite', async () => { -// await inviteTest() -// }) - -// test('Environment', async () => { -// await environmentTest() -// await logout() -// }) - -// test('Project', async () => { -// await projectTest() -// await logout() -// }) - -// test('Versioning', async () => { -// await versioningTests() -// await logout() -// }) - -// test('Organisation-permission', async () => { -// await organisationPermissionTest() -// await logout() -// }) - -// test('Project-permission', async () => { -// await projectPermissionTest() -// await logout() -// }) - -// test('Environment-permission', async () => { -// await environmentPermissionTest() -// await logout() -// }) +test('Segment-part-2', async () => { + await testSegment2() + await logout() +}) + +test('Segment-part-3', async () => { + await testSegment3() + await logout() +}) + +test('Flag', async () => { + await flagTests() + await logout() +}) + +test('Signup', async () => { + await initialiseTests() + await logout() +}) + +test('Invite', async () => { + await inviteTest() +}) + +test('Environment', async () => { + await environmentTest() + await logout() +}) + +test('Project', async () => { + await projectTest() + await logout() +}) + +test('Versioning', async () => { + await versioningTests() + await logout() +}) + +test('Organisation-permission', async () => { + await organisationPermissionTest() + await logout() +}) + +test('Project-permission', async () => { + await projectPermissionTest() + await logout() +}) + +test('Environment-permission', async () => { + await environmentPermissionTest() + await logout() +}) From 1faeabecbb6a66279f86557d987758d3c8a4e3ad Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 26 May 2025 10:38:07 +0200 Subject: [PATCH 7/8] feat: re-import-roles-tests --- frontend/e2e/init.cafe.js | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/e2e/init.cafe.js b/frontend/e2e/init.cafe.js index 97ba0aad9706..002078d53984 100644 --- a/frontend/e2e/init.cafe.js +++ b/frontend/e2e/init.cafe.js @@ -15,6 +15,7 @@ import organisationPermissionTest from './tests/organisation-permission-test' import projectPermissionTest from './tests/project-permission-test' import environmentPermissionTest from './tests/environment-permission-test' import flagsmith from 'flagsmith/isomorphic'; +import rolesTest from './tests/roles-test' require('dotenv').config() From 1399066c8bdd8aa79b90e7e0c04fad136d365309 Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 26 May 2025 17:30:15 +0200 Subject: [PATCH 8/8] feat: wrapped-segment-row-in-with-router --- frontend/web/components/pages/SegmentsPage.tsx | 2 +- frontend/web/components/segments/SegmentRow/SegmentRow.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/web/components/pages/SegmentsPage.tsx b/frontend/web/components/pages/SegmentsPage.tsx index 72a7165ef743..46327e7bb7f9 100644 --- a/frontend/web/components/pages/SegmentsPage.tsx +++ b/frontend/web/components/pages/SegmentsPage.tsx @@ -1,4 +1,4 @@ -// TODO: Migrate to new router +// TODO: migrate to useRouter with next version import React, { FC, ReactNode, useEffect, useState } from 'react' // we need this to make JSX compile import { RouterChildContext } from 'react-router' import { sortBy } from 'lodash' diff --git a/frontend/web/components/segments/SegmentRow/SegmentRow.tsx b/frontend/web/components/segments/SegmentRow/SegmentRow.tsx index 69454e60d373..778aee6b088d 100644 --- a/frontend/web/components/segments/SegmentRow/SegmentRow.tsx +++ b/frontend/web/components/segments/SegmentRow/SegmentRow.tsx @@ -1,6 +1,6 @@ -// TODO: Migrate to new router +// TODO: migrate to useRouter with next version import { FC } from 'react' -import { RouterChildContext } from 'react-router' +import { RouterChildContext, withRouter } from 'react-router' import { useHasPermission } from 'common/providers/Permission' @@ -151,3 +151,5 @@ export const SegmentRow: FC = ({ ) } + +export default withRouter(SegmentRow as any)