Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement feature actions dropdown #3253

Merged
merged 4 commits into from
Jan 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions frontend/common/useOutsideClick.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { RefObject, useCallback, useEffect } from 'react'

const useOutsideClick = (ref: RefObject<HTMLElement>, 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
2 changes: 2 additions & 0 deletions frontend/e2e/helpers.cafe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
169 changes: 169 additions & 0 deletions frontend/web/components/FeatureAction.tsx
Original file line number Diff line number Diff line change
@@ -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<FeatureActionProps> = ({
featureIndex,
hideAudit,
hideRemove,
isCompact,
isProtected,
onCopyName,
onRemove,
onShowHistory,
projectId,
readOnly,
}) => {
const [isOpen, setIsOpen] = useState<boolean>(false)

const btnRef = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLDivElement>(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 (
<div className='feature-action'>
<div ref={btnRef}>
<Button
style={{
lineHeight: 0,
padding: isCompact ? '0.625rem' : '0.875rem',
}}
className={classNames('btn btn-with-icon', {
'btn-sm': isCompact,
})}
data-test={`feature-action-${featureIndex}`}
onClick={() => setIsOpen(true)}
>
<Icon
name='more-vertical'
width={isCompact ? 16 : 18}
fill='#6837FC'
/>
</Button>
</div>

{isOpen && (
<div ref={listRef} className='feature-action__list'>
<div
className='feature-action__item'
onClick={() => handleActionClick('copy')}
>
<Icon name='copy' width={18} fill='#9DA4AE' />
<span>Copy Feature Name</span>
</div>

{!hideAudit && (
<div
className='feature-action__item'
data-test={`feature-history-${featureIndex}`}
onClick={() => handleActionClick('history')}
>
<Icon name='clock' width={18} fill='#9DA4AE' />
<span>Show History</span>
</div>
)}

{!hideRemove && (
<Permission
level='project'
permission='DELETE_FEATURE'
id={projectId}
>
{({ permission: removeFeaturePermission }) =>
Utils.renderWithPermission(
removeFeaturePermission,
Constants.projectPermissions('Delete Feature'),
<Tooltip
html
title={
<div
className={classNames('feature-action__item', {
'feature-action__item_disabled':
!removeFeaturePermission || readOnly || isProtected,
})}
data-test={`remove-feature-btn-${featureIndex}`}
onClick={() => handleActionClick('remove')}
>
<Icon name='trash-2' width={18} fill='#9DA4AE' />
<span>Remove feature</span>
</div>
}
>
{isProtected &&
'<span>This feature has been tagged as <bold>protected</bold>, <bold>permanent</bold>, <bold>do not delete</bold>, or <bold>read only</bold>. Please remove the tag before attempting to delete this flag.</span>'}
</Tooltip>,
)
}
</Permission>
)}
</div>
)}
</div>
)
}

export default FeatureAction
109 changes: 32 additions & 77 deletions frontend/web/components/FeatureRow.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -370,87 +370,42 @@ class TheComponent extends Component {
}}
/>
</div>

<div
className='table-column text-right'
style={{ width: width[2] }}
onClick={(e) => {
e.stopPropagation()
}}
>
{AccountStore.getOrganisationRole() === 'ADMIN' &&
!this.props.hideAudit && (
<Tooltip
html
title={
<div
onClick={() => {
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}`}
>
<Icon name='clock' width={24} fill='#9DA4AE' />
</div>
}
>
Feature history
</Tooltip>
)}
</div>
<div
className='table-column text-right'
style={{ width: width[3] }}
className='table-column'
style={{ width: isCompact ? width[2] : width[3] }}
onClick={(e) => {
e.stopPropagation()
}}
>
{!this.props.hideRemove && (
<Permission
level='project'
permission='DELETE_FEATURE'
id={projectId}
>
{({ permission: removeFeaturePermission }) =>
Utils.renderWithPermission(
removeFeaturePermission,
Constants.projectPermissions('Delete Feature'),
<Tooltip
html
title={
<Button
disabled={
!removeFeaturePermission || readOnly || isProtected
}
onClick={() => {
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}`}
>
<Icon
name='trash-2'
width={isCompact ? 16 : 20}
fill='#656D7B'
/>
</Button>
}
>
{isProtected
? '<span>This feature has been tagged as <bold>protected</bold>, <bold>permanent</bold>, <bold>do not delete</bold>, or <bold>read only</bold>. Please remove the tag before attempting to delete this flag.</span>'
: 'Remove feature'}
</Tooltip>,
)
}
</Permission>
)}
<FeatureAction
projectId={projectId}
featureIndex={this.props.index}
readOnly={readOnly}
isProtected={isProtected}
isCompact={isCompact}
hideAudit={
AccountStore.getOrganisationRole() !== 'ADMIN' ||
this.props.hideAudit
}
hideRemove={this.props.hideRemove}
onShowHistory={() => {
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')
}}
/>
</div>
</Row>
)
Expand Down
20 changes: 20 additions & 0 deletions frontend/web/components/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export type IconName =
| 'timer'
| 'request'
| 'people'
| 'more-vertical'

export type IconType = React.DetailedHTMLProps<
React.HTMLAttributes<SVGSVGElement>,
Expand Down Expand Up @@ -1414,6 +1415,25 @@ const Icon: FC<IconType> = ({ fill, fill2, height, name, width, ...rest }) => {
</svg>
)
}
case 'more-vertical': {
return (
<svg
width={width || '24'}
height={width || '24'}
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
id='Filled/more-vertical'
fillRule='evenodd'
clipRule='evenodd'
d='M12 7C13.104 7 14 6.104 14 5C14 3.896 13.104 3 12 3C10.896 3 10 3.896 10 5C10 6.104 10.896 7 12 7ZM12 10C10.896 10 10 10.896 10 12C10 13.104 10.896 14 12 14C13.104 14 14 13.104 14 12C14 10.896 13.104 10 12 10ZM10 19C10 17.896 10.896 17 12 17C13.104 17 14 17.896 14 19C14 20.104 13.104 21 12 21C10.896 21 10 20.104 10 19Z'
fill={fill || '#1A2634'}
/>
</svg>
)
}
default:
return null
}
Expand Down
Loading