From 95e9037eeafab3a3c2abdbe714dd6f5063175072 Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Tue, 17 Sep 2024 15:13:28 +0100 Subject: [PATCH 1/5] Segment override UI --- frontend/web/components/ActionButton.tsx | 6 +- frontend/web/components/Icon.tsx | 1 + .../web/components/SegmentOverrideActions.tsx | 157 +++++ frontend/web/components/SegmentOverrides.js | 579 +++++++++--------- frontend/web/components/modals/CreateFlag.js | 80 ++- frontend/web/components/svg/SegmentsIcon.js | 11 +- .../web/styles/project/_FeaturesPage.scss | 2 +- frontend/web/styles/project/_buttons.scss | 8 + frontend/web/styles/project/_utils.scss | 7 + 9 files changed, 520 insertions(+), 331 deletions(-) create mode 100644 frontend/web/components/SegmentOverrideActions.tsx diff --git a/frontend/web/components/ActionButton.tsx b/frontend/web/components/ActionButton.tsx index 02bf62e91323..96061510272c 100644 --- a/frontend/web/components/ActionButton.tsx +++ b/frontend/web/components/ActionButton.tsx @@ -11,14 +11,16 @@ type ActionButtonType = { const ActionButton: FC = ({ onClick, ...rest }) => { return ( ) } diff --git a/frontend/web/components/Icon.tsx b/frontend/web/components/Icon.tsx index 6f23a68bfd4e..871e00b31561 100644 --- a/frontend/web/components/Icon.tsx +++ b/frontend/web/components/Icon.tsx @@ -21,6 +21,7 @@ export type IconName = | 'arrow-left' | 'file-text' | 'copy' + | 'paste' | 'copy-outlined' | 'trash-2' | 'setting' diff --git a/frontend/web/components/SegmentOverrideActions.tsx b/frontend/web/components/SegmentOverrideActions.tsx new file mode 100644 index 000000000000..48cc85c19c93 --- /dev/null +++ b/frontend/web/components/SegmentOverrideActions.tsx @@ -0,0 +1,157 @@ +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' +import ActionButton from './ActionButton' + +interface SegmentOverrideActionProps { + canRemove: boolean + onRemove: () => void + onEdit: () => void + onCopyValue: () => void + canEdit: boolean + canCopyValue: boolean +} + +type ActionType = 'edit' | 'remove' + +function calculateListPosition( + btnEl: HTMLElement, + listEl: HTMLElement, +): { top: number; left: number } { + const top = btnEl.offsetTop + btnEl.offsetHeight + const left = btnEl.offsetLeft + btnEl.offsetWidth - listEl.offsetWidth + return { left, top } +} + +export const SegmentOverrideAction: FC = ({ + canCopyValue, + canEdit, + canRemove, + onCopyValue, + onEdit, + onRemove, +}) => { + 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 === 'edit') { + onEdit() + } else if (action === 'remove') { + onRemove() + } else if (action === 'copy') { + onCopyValue() + } + close() + }, + [close, onRemove, onCopyValue, onEdit], + ) + + 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]) + + if (!canEdit && !!canRemove) { + return ( + + ) + } + + if (!!canEdit && !canRemove) { + return ( + + ) + } + + if (!canEdit && !canRemove) { + return null + } + + return ( +
+
+ { + setIsOpen(true) + }} + /> +
+ + {isOpen && ( +
{ + e.stopPropagation() + }} + onClick={(e) => e.stopPropagation()} + ref={listRef} + className='feature-action__list' + > + {!!canEdit && ( +
{ + e.stopPropagation() + handleActionClick('edit') + }} + > + + View segment +
+ )} + {!!canCopyValue && ( +
{ + e.stopPropagation() + handleActionClick('copy') + }} + > + + Set value from environment +
+ )} + + {!!canRemove && ( +
{ + e.stopPropagation() + handleActionClick('remove') + }} + > + + Remove Override +
+ )} +
+ )} +
+ ) +} + +export default SegmentOverrideAction diff --git a/frontend/web/components/SegmentOverrides.js b/frontend/web/components/SegmentOverrides.js index 62041d30d62d..263614675f5c 100644 --- a/frontend/web/components/SegmentOverrides.js +++ b/frontend/web/components/SegmentOverrides.js @@ -16,6 +16,9 @@ import Icon from './Icon' import SegmentOverrideLimit from './SegmentOverrideLimit' import { getStore } from 'common/store' import { getEnvironment } from 'common/services/useEnvironment' +import Tooltip from './Tooltip' +import SegmentsIcon from './svg/SegmentsIcon' +import SegmentOverrideActions from './SegmentOverrideActions' const arrayMoveMutate = (array, from, to) => { array.splice(to < 0 ? array.length + to : to, 0, array.splice(from, 1)[0]) @@ -96,10 +99,10 @@ const SegmentOverrideInner = class Override extends React.Component { className={`segment-overrides mb-2${ this.props.id ? '' - : ' panel panel-without-heading panel--draggable p-3' + : ' panel user-select-none panel-without-heading panel--draggable pb-0' }`} > - +
{this.props.id ? ( <> @@ -136,8 +139,16 @@ const SegmentOverrideInner = class Override extends React.Component {
) : ( - - {name || v.segment_name} + + + + {name || v.segment_name} + {v.is_feature_specific && (
Feature-Specific
)} @@ -147,28 +158,6 @@ const SegmentOverrideInner = class Override extends React.Component {
- - - - } - > - Set the Feature state to On or Off for Identities in this - Segment - - { - if (!readOnly) { - this.setState({ changed: true }) - toggle(v) - } - }} - /> - {/* Input to adjust order without drag for E2E */} {E2E && ( - {!v.id || v.is_feature_specific ? ( - - ) : ( - - )} + } else { + window.open( + `${document.location.origin}/project/${this.props.projectId}/segments?id=${v.segment}`, + '_blank', + ) + } + }} + canEdit={permission} + /> , ) } - {!readOnly && ( - - )}
- -
+
+
+
+ + Enabled +
+ } + > + Controls whether the feature is enabled for users belonging to + this segment. + +
+ { + if (!readOnly) { + this.setState({ changed: true }) + toggle(v) + } + }} + /> +
{showValue ? ( <> - - { - this.setState({ changed: true }) - setValue( - Utils.getTypedValue(Utils.safeParseEventValue(e)), - ) - } - } - placeholder="Value e.g. 'big' " - /> +
+ + { + this.setState({ changed: true }) + setValue( + Utils.getTypedValue(Utils.safeParseEventValue(e)), + ) + } + } + placeholder="Value e.g. 'big' " + /> +
) : ( - <> +
- - )} - {!!controlValue && - (!multivariateOptions || !multivariateOptions.length) && ( -
- -
- )} - - {!!multivariateOptions?.length && ( -
- - { - const foundMv = - v.multivariate_options && - v.multivariate_options.find( - (v) => v.multivariate_feature_option === mv.id, - ) - if (foundMv) { - return { - ...mv, - default_percentage_allocation: - foundMv.percentage_allocation, - } - } - return { - ...mv, - default_percentage_allocation: 0, - } - })} - setVariations={(i, e, variationOverrides) => { - setVariations(i, e, variationOverrides) - this.setState({ changed: true }) - }} - setValue={(i, e, variationOverrides) => { - setVariations(i, e, variationOverrides) - this.setState({ changed: true }) - }} - updateVariation={(i, e, variationOverrides) => { - setVariations(i, e, variationOverrides) - this.setState({ changed: true }) - }} - weightTitle='Override Weight %' - /> -
)}
+ {!!multivariateOptions?.length && ( +
+ { + const foundMv = + v.multivariate_options && + v.multivariate_options.find( + (v) => v.multivariate_feature_option === mv.id, + ) + if (foundMv) { + return { + ...mv, + default_percentage_allocation: + foundMv.percentage_allocation, + } + } + return { + ...mv, + default_percentage_allocation: 0, + } + })} + setVariations={(i, e, variationOverrides) => { + setVariations(i, e, variationOverrides) + this.setState({ changed: true }) + }} + setValue={(i, e, variationOverrides) => { + setVariations(i, e, variationOverrides) + this.setState({ changed: true }) + }} + updateVariation={(i, e, variationOverrides) => { + setVariations(i, e, variationOverrides) + this.setState({ changed: true }) + }} + weightTitle='Override Weight %' + /> +
+ )}
) } @@ -581,164 +564,158 @@ class TheComponent extends Component { segmentOverrideLimitAlert.percentage >= 100 return (
- - {({ permission: manageSegments }) => { - return ( -
- {!this.props.id && - !this.props.disableCreate && - !this.props.showCreateSegment && - !this.props.readOnly && ( - - - this.setState({ selectedSegment }, this.addItem) - } - /> - - )} - {!this.props.showCreateSegment && - !this.props.readOnly && - !this.props.disableCreate && ( -
- -
- )} - {this.props.showCreateSegment && !this.state.segmentEditId && ( - )} - {this.props.showCreateSegment && this.state.segmentEditId && ( - { - this.setState({ - segmentEditId: undefined, - }) - this.props.setShowCreateSegment(false) - }} - onCancel={() => { - this.setState({ segmentEditId: undefined }) - this.props.setShowCreateSegment(false) - }} - environmentId={this.props.environmentId} - projectId={this.props.projectId} - /> - )} - {visibleValues && - !!visibleValues.length && - !this.props.showCreateSegment && ( -
- {!this.props.id && ( -
- - Segment overrides override the environment defaults, - prioritise them by dragging it to the top of the - list. Segment overrides will only apply when you - identify via the SDK, any identity overrides will - take priority.{' '} - - Check the Docs for more details - - . - - -
- )} - {value && ( - <> - ({ - ...v, - }))} - setSegmentEditId={this.setSegmentEditId} - onSortEnd={this.onSortEnd} - projectFlag={this.props.projectFlag} - /> -
- -
- - )} + {value && ( + <> + { + const tagName = e.target.tagName.toLowerCase() + + // Check if the clicked element is a button, input, or textarea + if ( + tagName === 'input' || + tagName === 'textarea' || + tagName === 'button' + ) { + return true // Cancel sorting for inputs, buttons, etc. + } + + // Cancel if the clicked element has class 'feature-action__list' + if ( + e.target.closest('.feature-action__item') || // Checks for parent elements with the class + e.target.closest('.hljs') + ) { + return true // Cancel sorting if the target or parent has these classes + } + + // Otherwise, allow sorting + return false + }} // Here we pass the function to prevent sorting in certain cases + disabled={this.props.readOnly} + id={this.props.id} + name={this.props.name} + controlValue={this.props.controlValue} + multivariateOptions={multivariateOptions} + confirmRemove={this.confirmRemove} + setVariations={this.setVariations} + toggle={this.toggle} + setValue={this.setValue} + readOnly={this.props.readOnly} + showEditSegment={this.props.showEditSegment} + environmentId={this.props.environmentId} + projectId={this.props.projectId} + setShowCreateSegment={this.props.setShowCreateSegment} + items={value.map((v) => ({ + ...v, + }))} + setSegmentEditId={this.setSegmentEditId} + onSortEnd={this.onSortEnd} + projectFlag={this.props.projectFlag} + /> +
+
- )} + + )}
- ) - }} - + )} +
) } diff --git a/frontend/web/components/modals/CreateFlag.js b/frontend/web/components/modals/CreateFlag.js index 933eafda85e9..e1fc69b62103 100644 --- a/frontend/web/components/modals/CreateFlag.js +++ b/frontend/web/components/modals/CreateFlag.js @@ -1016,17 +1016,13 @@ const CreateFlag = class extends Component { const onCreateFeature = saveFeatureWithValidation(() => { this.save(createFlag, isSaving) }) + const isLimitReached = false const featureLimitAlert = Utils.calculateRemainingLimitsPercentage( project.total_features, project.max_features_allowed, ) - const showIdentityOverrides = - !identity && - isEdit && - !existingChangeRequest && - !hideIdentityOverridesTab return (
- - - Segment Overrides{' '} - - + +
+ + Segment Overrides{' '} + + + } + place='top' + > + { + Constants.strings + .SEGMENT_OVERRIDES_DESCRIPTION + } + +
+ - { - Constants.strings - .SEGMENT_OVERRIDES_DESCRIPTION + {({ + permission: + manageSegmentOverrides, + }) => + !this.state.showCreateSegment && + !!manageSegmentOverrides && + !this.props.disableCreate && ( +
+ +
+ ) } -
+ {!this.state.showCreateSegment && !noPermissions && (
{showValue ? ( <> -
+
Date: Tue, 17 Sep 2024 15:35:13 +0100 Subject: [PATCH 3/5] Slight spacing improvement --- frontend/web/components/SegmentOverrides.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/web/components/SegmentOverrides.js b/frontend/web/components/SegmentOverrides.js index 2b28625bae95..5a8daa4523bc 100644 --- a/frontend/web/components/SegmentOverrides.js +++ b/frontend/web/components/SegmentOverrides.js @@ -96,7 +96,7 @@ const SegmentOverrideInner = class Override extends React.Component {
{!this.props.id && ( -
+
Segment overrides override the feature's environment default value and enabled state, prioritise them by From 003ed1e5564fd7600f44d09494fc2c167a06b1d8 Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Tue, 1 Oct 2024 16:59:52 +0100 Subject: [PATCH 4/5] collapse id --- frontend/web/components/SegmentOverrides.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/frontend/web/components/SegmentOverrides.js b/frontend/web/components/SegmentOverrides.js index 72bf025afa7a..38d9f2a084e1 100644 --- a/frontend/web/components/SegmentOverrides.js +++ b/frontend/web/components/SegmentOverrides.js @@ -634,13 +634,15 @@ class TheComponent extends Component { !this.props.showCreateSegment && (
{!this.props.id && ( -
- - Segment overrides override the environment defaults, - prioritise them by dragging it to the top of the - list. Segment overrides will only apply when you - identify via the SDK, any identity overrides will - take priority.{' '} +