From 972f1a364e4774f7a4ce722fe88b4bbee6bb9a11 Mon Sep 17 00:00:00 2001 From: Artem Zaiko <102226752+azaiko-akveo@users.noreply.github.com> Date: Thu, 11 Jan 2024 13:04:19 +0300 Subject: [PATCH] feat: implement feature actions dropdown (#3253) --- frontend/common/useOutsideClick.ts | 19 ++ frontend/e2e/helpers.cafe.ts | 2 + frontend/web/components/FeatureAction.tsx | 169 ++++++++++++++++++ frontend/web/components/FeatureRow.js | 109 ++++------- frontend/web/components/Icon.tsx | 20 +++ .../web/styles/project/_FeaturesPage.scss | 59 ++++++ frontend/web/styles/project/_index.scss | 1 + 7 files changed, 302 insertions(+), 77 deletions(-) create mode 100644 frontend/common/useOutsideClick.ts create mode 100644 frontend/web/components/FeatureAction.tsx create mode 100644 frontend/web/styles/project/_FeaturesPage.scss diff --git a/frontend/common/useOutsideClick.ts b/frontend/common/useOutsideClick.ts new file mode 100644 index 000000000000..3a71d0752e9f --- /dev/null +++ b/frontend/common/useOutsideClick.ts @@ -0,0 +1,19 @@ +import { RefObject, useCallback, useEffect } from 'react' + +const useOutsideClick = (ref: RefObject, handler: () => void) => { + const handleClick = useCallback( + (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) { + handler() + } + }, + [handler, ref], + ) + + useEffect(() => { + document.addEventListener('click', handleClick) + return () => document.removeEventListener('click', handleClick) + }, [ref, handler, handleClick]) +} + +export default useOutsideClick diff --git a/frontend/e2e/helpers.cafe.ts b/frontend/e2e/helpers.cafe.ts index 041af36818cb..ca0557311cd5 100644 --- a/frontend/e2e/helpers.cafe.ts +++ b/frontend/e2e/helpers.cafe.ts @@ -306,6 +306,8 @@ 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 setText('[name="confirm-feature-name"]', name) await click('#confirm-remove-feature-btn') diff --git a/frontend/web/components/FeatureAction.tsx b/frontend/web/components/FeatureAction.tsx new file mode 100644 index 000000000000..c54120d12694 --- /dev/null +++ b/frontend/web/components/FeatureAction.tsx @@ -0,0 +1,169 @@ +import { FC, useCallback, useLayoutEffect, useRef, useState } from 'react' +import classNames from 'classnames' + +import useOutsideClick from 'common/useOutsideClick' +import Utils from 'common/utils/utils' +import Constants from 'common/constants' +import Permission from 'common/providers/Permission' +import Button from './base/forms/Button' +import Icon from './Icon' + +interface FeatureActionProps { + projectId: string + featureIndex: number + readOnly: boolean + isProtected: boolean + hideAudit: boolean + hideRemove: boolean + isCompact?: boolean + onCopyName: () => void + onShowHistory: () => void + onRemove: () => void +} + +type ActionType = 'copy' | '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, + hideRemove, + isCompact, + isProtected, + onCopyName, + onRemove, + onShowHistory, + projectId, + readOnly, +}) => { + const [isOpen, setIsOpen] = useState(false) + + const btnRef = useRef(null) + const listRef = useRef(null) + + const close = useCallback(() => setIsOpen(false), []) + + const handleOutsideClick = useCallback( + () => isOpen && close(), + [close, isOpen], + ) + + const handleActionClick = useCallback( + (action: ActionType) => { + if (action === 'copy') { + onCopyName() + } else if (action === 'history') { + onShowHistory() + } else if (action === 'remove') { + onRemove() + } + + close() + }, + [close, onCopyName, onRemove, onShowHistory], + ) + + useOutsideClick(listRef, handleOutsideClick) + + 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` + }, [isOpen]) + + return ( +
+
+ +
+ + {isOpen && ( +
+
handleActionClick('copy')} + > + + Copy Feature Name +
+ + {!hideAudit && ( +
handleActionClick('history')} + > + + Show History +
+ )} + + {!hideRemove && ( + + {({ permission: removeFeaturePermission }) => + Utils.renderWithPermission( + removeFeaturePermission, + Constants.projectPermissions('Delete Feature'), + handleActionClick('remove')} + > + + Remove feature +
+ } + > + {isProtected && + 'This feature has been tagged as protected, permanent, do not delete, or read only. Please remove the tag before attempting to delete this flag.'} + , + ) + } + + )} +
+ )} + + ) +} + +export default FeatureAction diff --git a/frontend/web/components/FeatureRow.js b/frontend/web/components/FeatureRow.js index 6257fb11d644..c3ea6d8e3be8 100644 --- a/frontend/web/components/FeatureRow.js +++ b/frontend/web/components/FeatureRow.js @@ -4,13 +4,13 @@ import ConfirmToggleFeature from './modals/ConfirmToggleFeature' import ConfirmRemoveFeature from './modals/ConfirmRemoveFeature' import CreateFlagModal from './modals/CreateFlag' import ProjectStore from 'common/stores/project-store' -import Permission from 'common/providers/Permission' import Constants from 'common/constants' import { hasProtectedTag } from 'common/utils/hasProtectedTag' import SegmentsIcon from './svg/SegmentsIcon' import UsersIcon from './svg/UsersIcon' // we need this to make JSX compile import Icon from './Icon' import FeatureValue from './FeatureValue' +import FeatureAction from './FeatureAction' import { getViewMode } from 'common/useViewMode' import classNames from 'classnames' import Tag from './tags/Tag' @@ -370,87 +370,42 @@ class TheComponent extends Component { }} /> +
{ - e.stopPropagation() - }} - > - {AccountStore.getOrganisationRole() === 'ADMIN' && - !this.props.hideAudit && ( - { - if (disableControls) return - this.context.router.history.push( - `/project/${projectId}/environment/${environmentId}/audit-log?env=${environment.id}&search=${projectFlag.name}`, - ) - }} - className='text-center' - data-test={`feature-history-${this.props.index}`} - > - -
- } - > - Feature history - - )} - -
{ e.stopPropagation() }} > - {!this.props.hideRemove && ( - - {({ permission: removeFeaturePermission }) => - Utils.renderWithPermission( - removeFeaturePermission, - Constants.projectPermissions('Delete Feature'), - { - if (disableControls) return - this.confirmRemove(projectFlag, () => { - removeFlag(projectId, projectFlag) - }) - }} - className={classNames('btn btn-with-icon', { - 'btn-sm': isCompact, - })} - data-test={`remove-feature-btn-${this.props.index}`} - > - - - } - > - {isProtected - ? 'This feature has been tagged as protected, permanent, do not delete, or read only. Please remove the tag before attempting to delete this flag.' - : 'Remove feature'} - , - ) - } - - )} + { + if (disableControls) return + this.context.router.history.push( + `/project/${projectId}/environment/${environmentId}/audit-log?env=${environment.id}&search=${projectFlag.name}`, + ) + }} + onRemove={() => { + if (disableControls) return + this.confirmRemove(projectFlag, () => { + removeFlag(projectId, projectFlag) + }) + }} + onCopyName={() => { + navigator.clipboard.writeText(name) + toast('Copied to clipboard') + }} + />
) diff --git a/frontend/web/components/Icon.tsx b/frontend/web/components/Icon.tsx index 33387ea9111e..ccc01c5e7bd7 100644 --- a/frontend/web/components/Icon.tsx +++ b/frontend/web/components/Icon.tsx @@ -50,6 +50,7 @@ export type IconName = | 'timer' | 'request' | 'people' + | 'more-vertical' export type IconType = React.DetailedHTMLProps< React.HTMLAttributes, @@ -1414,6 +1415,25 @@ const Icon: FC = ({ fill, fill2, height, name, width, ...rest }) => { ) } + case 'more-vertical': { + return ( + + + + ) + } default: return null } diff --git a/frontend/web/styles/project/_FeaturesPage.scss b/frontend/web/styles/project/_FeaturesPage.scss new file mode 100644 index 000000000000..b12f24067a33 --- /dev/null +++ b/frontend/web/styles/project/_FeaturesPage.scss @@ -0,0 +1,59 @@ +@import '../new/variables-new'; + +.feature-action { + display: flex; + flex-direction: column; + align-items: end; + + &__list { + position: absolute; + z-index: 10; + margin-top: 6px; + overflow: hidden; + + border-radius: 8px; + background: #ffffff; + box-shadow: 0px 10px 12px 0px rgba(100, 116, 139, 0.15); + } + + &__item { + display: flex; + align-items: center; + column-gap: 0.5rem; + + padding: 0.5rem 1rem; + white-space: nowrap; + + color: #2d3443; + font-size: $font-size-sm; + font-style: normal; + font-weight: 400; + line-height: $line-height-sm; + + user-select: none; + + &_disabled { + opacity: 0.4; + pointer-events: none; + } + + &:hover { + background: $bg-light200; + } + } +} + +.dark { + .feature-action__list { + background: $input-bg-dark; + box-shadow: 0px 8px 12px 0px rgba(0, 0, 0, 0.12); + } + + .feature-action__item { + color: white !important; + + &:hover { + background: $primary; + } + } +} diff --git a/frontend/web/styles/project/_index.scss b/frontend/web/styles/project/_index.scss index 7596b660a021..4f02cf92d48d 100644 --- a/frontend/web/styles/project/_index.scss +++ b/frontend/web/styles/project/_index.scss @@ -8,6 +8,7 @@ @import "modals"; @import "project-nav"; @import "panel"; +@import "FeaturesPage"; @import "PricingPage"; @import "UserPage"; @import "TermsPoliciesPage";