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..c39af3825d24 --- /dev/null +++ b/frontend/common/utils/calculateListPosition.ts @@ -0,0 +1,13 @@ +// 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, +): { 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/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/init.cafe.js b/frontend/e2e/init.cafe.js index 7c0e6a0cdbdf..53d3ed61cce4 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,7 +92,7 @@ fixture`E2E Tests`.requestHooks(logger).before(async () => { }) test('Segment-part-1', async () => { - await testSegment1() + await testSegment1(flagsmith) await logout() }) 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..26db73a87adb 100644 --- a/frontend/e2e/tests/segment-test.ts +++ b/frontend/e2e/tests/segment-test.ts @@ -25,15 +25,14 @@ import { viewFeature, waitAndRefresh, waitForElementVisible, - createOrganisationAndProject, -} from '../helpers.cafe'; + cloneSegment, +} 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') - log('Create Feature') await createRemoteConfig(0, 'mv_flag', 'big', null, null, [ @@ -125,9 +124,17 @@ export const testSegment1 = async () => { await waitAndRefresh() await assertTextContent(byId('user-feature-value-0'), '"medium"') + const isCloneSegmentEnabled = await flagsmith.hasFeature('clone_segment') + 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 8dcbff94d281..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' @@ -10,6 +9,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 +30,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 +78,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 +95,40 @@ export const FeatureAction: FC = ({ {isOpen && (
-
{ - e.stopPropagation() + } + label='Copy Feature Name' + handleActionClick={() => { handleActionClick('copy') }} - > - - Copy Feature Name -
+ entity='feature' + index={featureIndex} + action='copy' + /> {!hideAudit && ( -
{ - e.stopPropagation() + } + label='Show Audit Logs' + handleActionClick={() => { handleActionClick('audit') }} - > - - Show Audit Logs -
+ entity='feature' + index={featureIndex} + action='audit' + /> )} - {!hideHistory && ( -
{ - e.stopPropagation() + } + label='Show History' + handleActionClick={() => { handleActionClick('history') }} - > - - Show History -
+ entity='feature' + index={featureIndex} + action='history' + /> )} - {!hideRemove && ( = ({ Constants.projectPermissions('Delete Feature'), { - e.stopPropagation() + } + label='Remove feature' + handleActionClick={() => { handleActionClick('remove') }} - > - - Remove feature -
+ action='remove' + 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..7ebc16a68764 --- /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..50411b4082b5 --- /dev/null +++ b/frontend/web/components/segments/SegmentRow/SegmentRow.tsx @@ -0,0 +1,157 @@ +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' +import { Req } from 'common/types/requests' + +interface SegmentRowProps { + segment: Segment + index: number + projectId: string + router: RouterChildContext['router'] + removeSegment: MutationTrigger< + MutationDefinition + > +} + +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..d315d8761875 --- /dev/null +++ b/frontend/web/components/segments/SegmentRow/components/SegmentAction.tsx @@ -0,0 +1,80 @@ +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} + action='clone' + /> + )} + } + label='Remove Segment' + handleActionClick={() => { + onRemove() + }} + entity='segment' + index={index} + disabled={isRemoveDisabled} + action='remove' + /> +
+ )} +
+ ) +} + +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..e084dd62a459 --- /dev/null +++ b/frontend/web/components/shared/ActionItem.tsx @@ -0,0 +1,43 @@ +import { FC, ReactNode } from 'react' +import classNames from 'classnames' + +interface ActionRowProps { + handleActionClick: () => void + index: number + entity: 'feature' | 'segment' + icon: ReactNode + label: string + disabled?: boolean + action: 'remove' | 'copy' | 'audit' | 'history' | 'clone' +} + +const ActionItem: FC = ({ + action, + 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 {