diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 000000000000..6ba5c2ea65a4 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,6 @@ +# Initial Mypy '# type: ignore' comments dump +# https://github.com/Flagsmith/flagsmith/pull/5119 +5de0b425b0ee16b16bfb0fb33b067fa314d040fb +# Linting fixes for frontend +# https://github.com/Flagsmith/flagsmith/pull/5123 +1f7083636d3b163588fe9a8b45af289bd40b1a8d diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b1fc9a20a2db..693504b35445 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: exclude: migrations - repo: https://github.com/pycqa/flake8 - rev: 7.1.1 + rev: 7.1.2 hooks: - id: flake8 name: flake8 @@ -46,7 +46,7 @@ repos: types: [python] - repo: https://github.com/python-poetry/poetry - rev: 2.0.1 + rev: 2.1.1 hooks: - id: poetry-check args: ["-C", "api"] diff --git a/api/audit/signals.py b/api/audit/signals.py index 5a832e4fe294..0f909a8ab96d 100644 --- a/api/audit/signals.py +++ b/api/audit/signals.py @@ -1,4 +1,5 @@ import logging +import typing from django.conf import settings from django.db.models.signals import post_save @@ -18,6 +19,33 @@ logger = logging.getLogger(__name__) +AuditLogIntegrationAttrName = typing.Literal[ + "data_dog_config", + "dynatrace_config", + "grafana_config", + "new_relic_config", + "slack_config", +] + + +def _get_integration_config( + instance: AuditLog, + integration_name: AuditLogIntegrationAttrName, +) -> IntegrationsModel | None: + for relation_name in ("project", "environment", "organisation"): + if hasattr( + related_object := getattr(instance, relation_name), + integration_name, + ): + integration_config: IntegrationsModel = getattr( + related_object, + integration_name, + ) + if not integration_config.deleted: + return integration_config + return None + + @receiver(post_save, sender=AuditLog) def call_webhooks(sender, instance, **kwargs): # type: ignore[no-untyped-def] if settings.DISABLE_WEBHOOKS: @@ -42,18 +70,6 @@ def call_webhooks(sender, instance, **kwargs): # type: ignore[no-untyped-def] ) -def _get_integration_config( - instance: AuditLog, integration_name: str -) -> IntegrationsModel | None: - if hasattr(project := instance.project, integration_name): - return getattr(project, integration_name) # type: ignore[no-any-return] - if hasattr(environment := instance.environment, integration_name): - return getattr(environment, integration_name) # type: ignore[no-any-return] - if hasattr(organisation := instance.organisation, integration_name): - return getattr(organisation, integration_name) # type: ignore[no-any-return] - return None - - def track_only_feature_related_events(signal_function): # type: ignore[no-untyped-def] def signal_wrapper(sender, instance, **kwargs): # type: ignore[no-untyped-def] # Only handle Feature related changes diff --git a/api/tests/unit/audit/test_unit_audit_signals.py b/api/tests/unit/audit/test_unit_audit_signals.py index 3d28ed77f1bd..bbd15ad42d51 100644 --- a/api/tests/unit/audit/test_unit_audit_signals.py +++ b/api/tests/unit/audit/test_unit_audit_signals.py @@ -168,6 +168,32 @@ def test_send_audit_log_event_to_grafana__organisation_grafana_config__calls_exp ) +def test_send_audit_log_event_to_grafana__organisation_grafana_config__deleted__doesnt_call( + mocker: MockerFixture, + organisation: Organisation, + project: Project, +) -> None: + # Given + audit_log_record = AuditLog.objects.create( + project=project, + related_object_type=RelatedObjectType.FEATURE.name, + ) + grafana_wrapper_mock = mocker.patch("audit.signals.GrafanaWrapper", autospec=True) + + grafana_config = GrafanaOrganisationConfiguration.objects.create( + organisation=organisation, + base_url="test.com", + api_key="test", + ) + grafana_config.delete() + + # When + send_audit_log_event_to_grafana(AuditLog, audit_log_record) + + # Then + grafana_wrapper_mock.assert_not_called() + + @responses.activate def test_send_environment_feature_version_audit_log_event_to_grafana( tagged_feature: Feature, diff --git a/frontend/common/constants.ts b/frontend/common/constants.ts index 90671de8605a..ca05197fbc26 100644 --- a/frontend/common/constants.ts +++ b/frontend/common/constants.ts @@ -363,7 +363,7 @@ const Constants = { }, }, exampleAuditWebhook: `{ - "created_date": "2020-02-23T17:30:57.006318Z", + "created_date": "2020-02-23T17:30:57.006318Z", "log": "New Flag / Remote Config created: my_feature", "author": { "id": 3, @@ -435,6 +435,15 @@ const Constants = { }, "event_type": "FLAG_UPDATED" }`, + featurePanelTabs: { + ANALYTICS: 'analytics', + HISTORY: 'history', + IDENTITY_OVERRIDES: 'identity-overrides', + LINKS: 'links', + SEGMENT_OVERRIDES: 'segment-overrides', + SETTINGS: 'settings', + VALUE: 'value', + }, forms: { maxLength: { 'FEATURE_ID': 150, diff --git a/frontend/common/dispatcher/action-constants.js b/frontend/common/dispatcher/action-constants.js index 1ebbf9d487bf..6f2f09d9df58 100644 --- a/frontend/common/dispatcher/action-constants.js +++ b/frontend/common/dispatcher/action-constants.js @@ -30,7 +30,6 @@ const Actions = Object.assign({}, require('./base/_action-constants'), { 'GET_FEATURE_USAGE': 'GET_FEATURE_USAGE', 'GET_FLAGS': 'GET_FLAGS', 'GET_IDENTITY': 'GET_IDENTITY', - 'GET_IDENTITY_SEGMENTS': 'GET_IDENTITY_SEGMENTS', 'GET_ORGANISATION': 'GET_ORGANISATION', 'GET_PROJECT': 'GET_PROJECT', 'INVALIDATE_INVITE_LINK': 'INVALIDATE_INVITE_LINK', diff --git a/frontend/common/dispatcher/app-actions.js b/frontend/common/dispatcher/app-actions.js index 67aa617027b2..c31fe0e39729 100644 --- a/frontend/common/dispatcher/app-actions.js +++ b/frontend/common/dispatcher/app-actions.js @@ -246,13 +246,6 @@ const AppActions = Object.assign({}, require('./base/_app-actions'), { id, }) }, - getIdentitySegments(projectId, id) { - Dispatcher.handleViewAction({ - actionType: Actions.GET_IDENTITY_SEGMENTS, - id, - projectId, - }) - }, getOrganisation(organisationId) { Dispatcher.handleViewAction({ actionType: Actions.GET_ORGANISATION, diff --git a/frontend/common/providers/IdentitySegmentsProvider.js b/frontend/common/providers/IdentitySegmentsProvider.js deleted file mode 100644 index 97dd67786403..000000000000 --- a/frontend/common/providers/IdentitySegmentsProvider.js +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react' -import IdentitySegmentsStore from 'common/stores/identity-segments-store' - -const IdentitySegmentsProvider = class extends React.Component { - static displayName = 'IdentitySegmentsProvider' - - constructor(props, context) { - super(props, context) - this.state = { - isLoading: IdentitySegmentsStore.isLoading, - segments: IdentitySegmentsStore.model[props.id], - segmentsPaging: IdentitySegmentsStore.paging, - } - ES6Component(this) - } - - componentDidMount() { - if (this.props.fetch) { - AppActions.getIdentitySegments(this.props.projectId, this.props.id) - } - this.listenTo(IdentitySegmentsStore, 'change', () => { - this.setState({ - isLoading: IdentitySegmentsStore.isLoading, - segments: IdentitySegmentsStore.model[this.props.id], - segmentsPaging: IdentitySegmentsStore.paging, - }) - }) - } - - render() { - return this.props.children({ ...this.state }, {}) - } -} - -IdentitySegmentsProvider.propTypes = { - children: OptionalFunc, - fetch: OptionalBool, - id: RequiredString, - projectId: RequiredString, -} - -export default IdentitySegmentsProvider diff --git a/frontend/common/services/useIdentitySegment.ts b/frontend/common/services/useIdentitySegment.ts new file mode 100644 index 000000000000..01a2c927d1e8 --- /dev/null +++ b/frontend/common/services/useIdentitySegment.ts @@ -0,0 +1,59 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' +import Utils from 'common/utils/utils' +import transformCorePaging from 'common/transformCorePaging' +import { sortBy } from 'lodash' + +export const identitySegmentService = service + .enhanceEndpoints({ addTagTypes: ['IdentitySegment'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + getIdentitySegments: builder.query< + Res['identitySegments'], + Req['getIdentitySegments'] + >({ + providesTags: (res) => [{ id: res?.id, type: 'IdentitySegment' }], + query: ({ projectId, ...query }: Req['getIdentitySegments']) => ({ + url: `/projects/${projectId}/segments/?${Utils.toParam(query)}`, + }), + transformResponse: ( + res: Res['identitySegments'], + _, + req: Req['getIdentitySegments'], + ) => + transformCorePaging(req, { + ...res, + results: sortBy(res.results, 'name'), + }), + }), + // END OF ENDPOINTS + }), + }) + +export async function getIdentitySegments( + store: any, + data: Req['getIdentitySegments'], + options?: Parameters< + typeof identitySegmentService.endpoints.getIdentitySegments.initiate + >[1], +) { + return store.dispatch( + identitySegmentService.endpoints.getIdentitySegments.initiate( + data, + options, + ), + ) +} +// END OF FUNCTION_EXPORTS + +export const { + useGetIdentitySegmentsQuery, + // END OF EXPORTS +} = identitySegmentService + +/* Usage examples: +const { data, isLoading } = useGetIdentitySegmentsQuery({ id: 2 }, {}) //get hook +const [createIdentitySegments, { isLoading, data, isSuccess }] = useCreateIdentitySegmentsMutation() //create hook +identitySegmentService.endpoints.getIdentitySegments.select({id: 2})(store.getState()) //access data from any function +*/ diff --git a/frontend/common/stores/feature-list-store.ts b/frontend/common/stores/feature-list-store.ts index 7718e3d9ae02..90ab661130da 100644 --- a/frontend/common/stores/feature-list-store.ts +++ b/frontend/common/stores/feature-list-store.ts @@ -670,12 +670,17 @@ const controller = { ) } }) + return updatedChangeRequest }, ) }) - Promise.all([prom]).then(() => { - store.saved({ changeRequest: true, isCreate: true }) + Promise.all([prom]).then(([updatedChangeRequest]) => { + store.saved({ + changeRequest: true, + isCreate: true, + updatedChangeRequest, + }) }) } catch (e) { API.ajaxHandler(store, e) @@ -975,7 +980,7 @@ const controller = { const store = Object.assign({}, BaseStore, { getEnvironmentFlags() { - return store.model && store.model.keyedEnvironmentFeatures + return store?.model?.keyedEnvironmentFeatures }, getFeatureUsage() { return store.model && store.model.usageData diff --git a/frontend/common/stores/identity-segments-store.js b/frontend/common/stores/identity-segments-store.js deleted file mode 100644 index 79fe7dbf3739..000000000000 --- a/frontend/common/stores/identity-segments-store.js +++ /dev/null @@ -1,46 +0,0 @@ -const Dispatcher = require('../dispatcher/dispatcher') -const BaseStore = require('./base/_store') -const data = require('../data/base/_data') - -const controller = { - getIdentitySegments: (projectId, id, page) => { - store.loading() - const endpoint = - page || `${Project.api}projects/${projectId}/segments/?identity=${id}` - return data - .get(endpoint) - .then((res) => { - store.model[id] = res.results && _.sortBy(res.results, (r) => r.name) - store.paging.next = res.next - store.paging.count = res.count - store.paging.previous = res.previous - store.paging.currentPage = - endpoint.indexOf('?page=') !== -1 - ? parseInt(endpoint.substr(endpoint.indexOf('?page=') + 6)) - : 1 - store.loaded() - }) - .catch((e) => API.ajaxHandler(store, e)) - }, -} - -const store = Object.assign({}, BaseStore, { - id: 'identity-segments', - model: {}, - paging: {}, -}) - -store.dispatcherIndex = Dispatcher.register(store, (payload) => { - const action = payload.action // this is our action from handleViewAction - switch (action.actionType) { - case Actions.GET_IDENTITY_SEGMENTS: - controller.getIdentitySegments(action.projectId, action.id) - break - case Actions.GET_IDENTITY_SEGMENTS_PAGE: - controller.getIdentitySegments(null, null, action.page) - break - default: - } -}) -controller.store = store -module.exports = controller.store diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index c53869a8fdba..7fd493edbc99 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -593,5 +593,10 @@ export type Req = { use_edge_identities: boolean data: Omit } + getIdentitySegments: PagedRequest<{ + q?: string + identity: string + projectId: string + }> // END OF TYPES } diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 915b1d863d6f..3a9446bdbd0d 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -456,18 +456,17 @@ export type ProjectFlag = { export type FeatureListProviderData = { projectFlags: ProjectFlag[] | null - environmentFlags: FeatureState[] | null + environmentFlags: Record | undefined error: boolean isLoading: boolean } export type FeatureListProviderActions = { toggleFlag: ( - index: number, - environments: Environment[], - comment: string | null, - environmentFlags: FeatureState[], - projectFlags: ProjectFlag[], + projectId: string, + environmentId: string, + projectFlag: ProjectFlag, + environmentFlags: FeatureState | undefined, ) => void removeFlag: (projectId: string, projectFlag: ProjectFlag) => void } @@ -802,6 +801,7 @@ export type Res = { metadata_xml: string } samlAttributeMapping: PagedResponse + identitySegments: PagedResponse organisationWebhooks: PagedResponse identityTrait: { id: string } identityTraits: IdentityTrait[] diff --git a/frontend/common/utils/getProtectedTags.ts b/frontend/common/utils/getProtectedTags.ts deleted file mode 100644 index 807fb0c05852..000000000000 --- a/frontend/common/utils/getProtectedTags.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { getStore } from 'common/store' -import { ProjectFlag, Tag } from 'common/types/responses' -import { tagService } from 'common/services/useTag' - -export const getProtectedTags = ( - projectFlag: ProjectFlag, - projectId: string, -) => { - const store = getStore() - const tags: Tag[] = - tagService.endpoints.getTags.select({ projectId: `${projectId}` })( - store.getState(), - ).data || [] - return projectFlag.tags - ?.filter((id) => { - const tag = tags.find((tag) => tag.id === id) - return tag?.is_permanent - }) - .map((id) => { - return tags.find((tag) => tag.id === id) - }) -} diff --git a/frontend/common/utils/useProtectedTags.ts b/frontend/common/utils/useProtectedTags.ts new file mode 100644 index 000000000000..e49a48ae12e8 --- /dev/null +++ b/frontend/common/utils/useProtectedTags.ts @@ -0,0 +1,27 @@ +import { ProjectFlag, Tag } from 'common/types/responses' +import { useGetTagsQuery } from 'common/services/useTag' + +export const useProtectedTags = ( + projectFlag: ProjectFlag, + projectId: string, + skip?: boolean, +): Tag[] | undefined => { + const { data: tags } = useGetTagsQuery( + { projectId }, + { skip: skip || !projectId }, + ) + + if (!tags) { + return undefined + } + + const protectedFlags = projectFlag.tags.reduce((acc, id) => { + const tag = tags?.find((t) => t.id === id) + if (tag?.is_permanent) { + acc.push(tag) + } + return acc + }, []) + + return protectedFlags +} diff --git a/frontend/global.d.ts b/frontend/global.d.ts index b7b2ec2cae16..90d076c54381 100644 --- a/frontend/global.d.ts +++ b/frontend/global.d.ts @@ -27,7 +27,12 @@ declare global { ) => void const openConfirm: (data: OpenConfirm) => void const Row: typeof Component - const toast: (value: ReactNode, theme?: string, expiry?: number) => void + const toast: ( + value: ReactNode, + theme?: string, + expiry?: number, + action?: { buttonText: string; onClick: () => void }, + ) => void const Flex: typeof Component const isMobile: boolean const FormGroup: typeof Component diff --git a/frontend/web/components/CompareEnvironments.js b/frontend/web/components/CompareEnvironments.js index cfcb9fabd7a6..b2a416f70b59 100644 --- a/frontend/web/components/CompareEnvironments.js +++ b/frontend/web/components/CompareEnvironments.js @@ -255,6 +255,7 @@ class CompareEnvironments extends Component { condensed isCompareEnv fadeEnabled={fadeEnabled} + history={this.context.router.history} fadeValue={fadeValue} environmentFlags={this.state.environmentLeftFlags} projectFlags={this.state.projectFlagsLeft} @@ -285,6 +286,7 @@ class CompareEnvironments extends Component { isCompareEnv fadeEnabled={fadeEnabled} fadeValue={fadeValue} + history={this.context.router.history} environmentFlags={this.state.environmentRightFlags} projectFlags={this.state.projectFlagsRight} permission={permission} diff --git a/frontend/web/components/CompareFeatures.js b/frontend/web/components/CompareFeatures.js index 5bf1c1082b06..49417c515f99 100644 --- a/frontend/web/components/CompareFeatures.js +++ b/frontend/web/components/CompareFeatures.js @@ -141,6 +141,7 @@ class CompareEnvironments extends Component { permission={permission} environmentId={data.api_key} projectId={this.props.projectId} + history={this.context.router.history} index={i} canDelete={permission} toggleFlag={toggleFlag} diff --git a/frontend/web/components/CondensedFeatureRow.tsx b/frontend/web/components/CondensedFeatureRow.tsx new file mode 100644 index 000000000000..920d4e6419ce --- /dev/null +++ b/frontend/web/components/CondensedFeatureRow.tsx @@ -0,0 +1,121 @@ +import classNames from 'classnames' +import Switch from './Switch' +import { + FeatureListProviderData, + FeatureState, + ProjectFlag, +} from 'common/types/responses' +import FeatureValue from './FeatureValue' +import SegmentOverridesIcon from './SegmentOverridesIcon' +import IdentityOverridesIcon from './IdentityOverridesIcon' +import Constants from 'common/constants' + +export interface CondensedFeatureRowProps { + disableControls?: boolean + readOnly: boolean + projectFlag: ProjectFlag + environmentFlags: FeatureListProviderData['environmentFlags'] + permission?: boolean + editFeature: ( + projectFlag: ProjectFlag, + environmentFlag?: FeatureState, + tab?: string, + ) => void + onChange: () => void + style?: React.CSSProperties + className?: string + isCompact?: boolean + fadeEnabled?: boolean + fadeValue?: boolean + index: number +} + +const CondensedFeatureRow: React.FC = ({ + className, + disableControls, + editFeature, + environmentFlags, + fadeEnabled, + fadeValue, + index, + isCompact, + onChange, + permission, + projectFlag, + readOnly, + style, +}) => { + const { id } = projectFlag + const showPlusIndicator = + projectFlag?.is_num_identity_overrides_complete === false + + return ( + { + if (disableControls) return + !readOnly && editFeature(projectFlag, environmentFlags?.[id]) + }} + style={{ ...style }} + className={classNames('flex-row', { 'fs-small': isCompact }, className)} + > +
+ + + +
+ + +
+ permission && + !readOnly && + editFeature(projectFlag, environmentFlags?.[id]) + } + className={`flex-fill ${fadeValue ? 'faded' : ''}`} + > + +
+ + { + e.stopPropagation() + editFeature( + projectFlag, + environmentFlags?.[id], + Constants.featurePanelTabs.SEGMENT_OVERRIDES, + ) + }} + count={projectFlag.num_segment_overrides} + /> + { + e.stopPropagation() + editFeature( + projectFlag, + environmentFlags?.[id], + Constants.featurePanelTabs.IDENTITY_OVERRIDES, + ) + }} + count={projectFlag.num_identity_overrides} + showPlusIndicator={showPlusIndicator} + /> +
+
+
+ ) +} + +export default CondensedFeatureRow diff --git a/frontend/web/components/EnvironmentSelect.tsx b/frontend/web/components/EnvironmentSelect.tsx index 38cd03ec6b06..4075b8617bcc 100644 --- a/frontend/web/components/EnvironmentSelect.tsx +++ b/frontend/web/components/EnvironmentSelect.tsx @@ -2,7 +2,7 @@ import React, { FC, useMemo } from 'react' import { useGetEnvironmentsQuery } from 'common/services/useEnvironment' import { Props } from 'react-select/lib/Select' -export type EnvironmentSelectType = Partial & { +export type EnvironmentSelectType = Partial> & { projectId: string value?: string label?: string diff --git a/frontend/web/components/ExampleFeatureRow.tsx b/frontend/web/components/ExampleFeatureRow.tsx deleted file mode 100644 index 184309823f98..000000000000 --- a/frontend/web/components/ExampleFeatureRow.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React, { FC } from 'react' -import { FeatureState, ProjectFlag } from 'common/types/responses' -import { useGetTagsQuery } from 'common/services/useTag' -import FeatureRow from './FeatureRow' -import { flag } from 'ionicons/icons' - -type ExampleFeatureRowType = {} - -const ExampleFeatureRow: FC = ({}) => { - const flag: ProjectFlag = { - created_date: new Date().toISOString(), - default_enabled: true, - description: 'Feature description', - id: 1, - initial_value: 'Blue', - is_archived: false, - is_server_key_only: false, - multivariate_options: [], - name: 'example_feature', - num_identity_overrides: 1, - num_segment_overrides: 1, - owner_groups: [], - owners: [], - project: 1, - tags: [], - type: 'STANDARD', - uuid: '1', - } - const flag2: ProjectFlag = { - created_date: new Date().toISOString(), - default_enabled: true, - description: 'Feature description', - id: 1, - initial_value: `2`, - is_archived: false, - is_server_key_only: false, - multivariate_options: [], - name: 'example_feature2', - num_identity_overrides: 1, - num_segment_overrides: 1, - owner_groups: [], - owners: [], - project: 1, - tags: [], - type: 'STANDARD', - uuid: '1', - } - const featureState: FeatureState = { - created_at: '', - enabled: false, - environment: 1, - feature: flag.id, - feature_state_value: flag.initial_value, - id: 1, - multivariate_feature_state_values: [], - updated_at: '', - uuid: '', - } - return ( -
-
-
- {}} - removeFlag={() => {}} - projectFlag={flag} - /> - {}} - removeFlag={() => {}} - projectFlag={flag} - /> -
-
-
- ) -} - -export default ExampleFeatureRow diff --git a/frontend/web/components/ExistingChangeRequestAlert.tsx b/frontend/web/components/ExistingChangeRequestAlert.tsx index 46968290bf7e..6a630e5c7795 100644 --- a/frontend/web/components/ExistingChangeRequestAlert.tsx +++ b/frontend/web/components/ExistingChangeRequestAlert.tsx @@ -1,54 +1,84 @@ -import { FC, useRef } from 'react' -import { useGetChangeRequestsQuery } from 'common/services/useChangeRequest' -import WarningMessage from './WarningMessage' -import moment from 'moment' +import { FC } from 'react' +import InfoMessage from './InfoMessage' +import { RouterChildContext } from 'react-router-dom' +import Button from './base/forms/Button' +import { ChangeRequestSummary } from 'common/types/responses' type ExistingChangeRequestAlertType = { + changeRequests: ChangeRequestSummary[] + editingChangeRequest?: ChangeRequestSummary + scheduledChangeRequests: ChangeRequestSummary[] + projectId: number | string environmentId: string - featureId: number className?: string + history: RouterChildContext['router']['history'] } const ExistingChangeRequestAlert: FC = ({ + changeRequests, className, + editingChangeRequest, environmentId, - featureId, + history, + projectId, + scheduledChangeRequests, }) => { - const { data } = useGetChangeRequestsQuery({ - committed: false, - environmentId, - feature_id: featureId, - }) - const date = useRef(moment().toISOString()) - const { data: scheduledChangeRequests } = useGetChangeRequestsQuery({ - environmentId, - feature_id: featureId, - live_from_after: date.current, - }) - - if (scheduledChangeRequests?.results?.length) { - return ( -
- -
+ const handleNavigate = () => { + const changes = scheduledChangeRequests?.length + ? scheduledChangeRequests + : changeRequests + const latestChangeRequest = !editingChangeRequest?.id + ? changes?.at(-1)?.id + : editingChangeRequest?.id + closeModal() + history.push( + `/project/${projectId}/environment/${environmentId}/change-requests/${latestChangeRequest}`, ) } - if (data?.results?.length) { - return ( -
- -
- ) + + const getRequestChangeInfoText = ( + hasScheduledChangeRequests: boolean, + hasChangeRequests: boolean, + ) => { + if (hasScheduledChangeRequests) { + return [ + 'You have scheduled changes upcoming for this feature.', + 'to view your scheduled changes.', + ] + } + + if (hasChangeRequests) { + return [ + 'You have open change requests for this feature.', + 'to view your requested changes.', + ] + } + } + + const requestChangeInfoText = getRequestChangeInfoText( + !!scheduledChangeRequests?.length, + !!changeRequests?.length, + ) + + console.log({ requestChangeInfoText }) + + if (!requestChangeInfoText?.length) { + return null } - return null + + return ( +
+ + + {requestChangeInfoText[0]} Click{' '} + {' '} + {requestChangeInfoText[1]} + + +
+ ) } export default ExistingChangeRequestAlert diff --git a/frontend/web/components/FeatureRow.js b/frontend/web/components/FeatureRow.js deleted file mode 100644 index 53fb6f4da3e7..000000000000 --- a/frontend/web/components/FeatureRow.js +++ /dev/null @@ -1,447 +0,0 @@ -import React, { Component } from 'react' -import TagValues from './tags/TagValues' -import ConfirmToggleFeature from './modals/ConfirmToggleFeature' -import ConfirmRemoveFeature from './modals/ConfirmRemoveFeature' -import CreateFlagModal from './modals/CreateFlag' -import ProjectStore from 'common/stores/project-store' -import Constants from 'common/constants' -import { getProtectedTags } from 'common/utils/getProtectedTags' -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' -import Button from './base/forms/Button' -import SegmentOverridesIcon from './SegmentOverridesIcon' -import IdentityOverridesIcon from './IdentityOverridesIcon' -import StaleFlagWarning from './StaleFlagWarning' -import UnhealthyFlagWarning from './UnhealthyFlagWarning' - -export const width = [200, 70, 55, 70, 450] - -export const TABS = { - ANALYTICS: 'analytics', - HISTORY: 'history', - IDENTITY_OVERRIDES: 'identity-overrides', - LINKS: 'links', - SEGMENT_OVERRIDES: 'segment-overrides', - SETTINGS: 'settings', - VALUE: 'value', -} - -class TheComponent extends Component { - static contextTypes = { - router: propTypes.object.isRequired, - } - - constructor(props, context) { - super(props, context) - - this.state = { - unhealthyTagId: undefined, - } - } - - confirmToggle = () => { - const { - environmentFlags, - environmentId, - projectFlag, - projectId, - toggleFlag, - } = this.props - const { id } = projectFlag - openModal( - 'Toggle Feature', - { - toggleFlag( - projectId, - environmentId, - projectFlag, - environmentFlags[id], - ) - }} - />, - 'p-0', - ) - } - - componentDidMount() { - const { environmentFlags, projectFlag } = this.props - const { feature } = Utils.fromParam() - const { id } = projectFlag - if (`${id}` === feature) { - this.editFeature(projectFlag, environmentFlags[id]) - } - } - - copyFeature = (e) => { - const { projectFlag } = this.props - e?.stopPropagation()?.() - e?.currentTarget?.blur?.() - Utils.copyToClipboard(projectFlag.name) - } - confirmRemove = (projectFlag, cb) => { - openModal2( - 'Remove Feature', - , - 'p-0', - ) - } - - editFeature = (projectFlag, environmentFlag, tab) => { - if (this.props.disableControls) { - return - } - API.trackEvent(Constants.events.VIEW_FEATURE) - history.replaceState( - {}, - null, - `${document.location.pathname}?feature=${projectFlag.id}&tab=${ - tab || Utils.fromParam().tab || 'value' - }`, - ) - openModal( - - {this.props.permission ? 'Edit Feature' : 'Feature'}: {projectFlag.name} - - , - , - 'side-modal create-feature-modal', - () => { - history.replaceState({}, null, `${document.location.pathname}`) - }, - ) - } - - render() { - const { - disableControls, - environmentFlags, - environmentId, - permission, - projectFlag, - projectId, - removeFlag, - } = this.props - const { created_date, description, id, name } = this.props.projectFlag - const readOnly = - this.props.readOnly || Utils.getFlagsmithHasFeature('read_only_mode') - const protectedTags = getProtectedTags(projectFlag, projectId) - const environment = ProjectStore.getEnvironment(environmentId) - const changeRequestsEnabled = Utils.changeRequestsEnabled( - environment && environment.minimum_change_request_approvals, - ) - const showPlusIndicator = - projectFlag?.is_num_identity_overrides_complete === false - - const onChange = () => { - if (disableControls) { - return - } - if ( - projectFlag?.multivariate_options?.length || - Utils.changeRequestsEnabled( - environment.minimum_change_request_approvals, - ) - ) { - this.editFeature(projectFlag, environmentFlags[id]) - return - } - this.confirmToggle() - } - const isCompact = getViewMode() === 'compact' - if (this.props.condensed) { - return ( - { - if (disableControls) return - !readOnly && this.editFeature(projectFlag, environmentFlags[id]) - }} - style={{ - ...(this.props.style || {}), - }} - className={classNames( - 'flex-row', - { 'fs-small': isCompact }, - this.props.className, - )} - > -
- - - -
- - -
- permission && - !readOnly && - this.editFeature(projectFlag, environmentFlags[id]) - } - className={`flex-fill ${this.props.fadeValue ? 'faded' : ''}`} - > - -
- - { - e.stopPropagation() - this.editFeature( - projectFlag, - environmentFlags[id], - TABS.SEGMENT_OVERRIDES, - ) - }} - count={projectFlag.num_segment_overrides} - /> - { - e.stopPropagation() - this.editFeature( - projectFlag, - environmentFlags[id], - TABS.IDENTITY_OVERRIDES, - ) - }} - count={projectFlag.num_identity_overrides} - showPlusIndicator={showPlusIndicator} - /> -
-
-
- ) - } - - const isFeatureHealthEnabled = - Utils.getFlagsmithHasFeature('feature_health') - - return ( - - !readOnly && this.editFeature(projectFlag, environmentFlags[id]) - } - > - - - - - - - {created_date ? ( - {name}}> - {isCompact && description ? `${description}` : null} - - ) : ( - name - )} - - - - { - e.stopPropagation() - this.editFeature( - projectFlag, - environmentFlags[id], - TABS.SEGMENT_OVERRIDES, - ) - }} - count={projectFlag.num_segment_overrides} - /> - { - e.stopPropagation() - this.editFeature( - projectFlag, - environmentFlags[id], - TABS.IDENTITY_OVERRIDES, - ) - }} - count={projectFlag.num_identity_overrides} - showPlusIndicator={showPlusIndicator} - /> - {projectFlag.is_server_key_only && ( - - {'Server-side only'} - - } - place='top' - > - { - 'Prevent this feature from being accessed with client-side SDKs.' - } - - )} - - {projectFlag.is_archived && ( - - )} - - {!!isCompact && } - {isFeatureHealthEnabled && !!isCompact && ( - - )} - - {!isCompact && } - {isFeatureHealthEnabled && !isCompact && ( - - )} - {description && !isCompact && ( -
- {description} -
- )} -
-
-
-
- - !readOnly && this.editFeature(projectFlag, environmentFlags[id]) - } - value={ - environmentFlags[id] && environmentFlags[id].feature_state_value - } - data-test={`feature-value-${this.props.index}`} - /> -
-
{ - e.stopPropagation() - }} - > - -
- -
{ - e.stopPropagation() - }} - > - { - if (disableControls) return - this.editFeature(projectFlag, environmentFlags[id], TABS.HISTORY) - }} - onShowAudit={() => { - if (disableControls) return - this.context.router.history.push( - `/project/${projectId}/audit-log?env=${environment.id}&search=${projectFlag.name}`, - ) - }} - onRemove={() => { - if (disableControls) return - this.confirmRemove(projectFlag, () => { - removeFlag(projectId, projectFlag) - }) - }} - onCopyName={this.copyFeature} - /> -
-
- ) - } -} - -export default TheComponent diff --git a/frontend/web/components/FeatureRow.tsx b/frontend/web/components/FeatureRow.tsx new file mode 100644 index 000000000000..ccffd86c2693 --- /dev/null +++ b/frontend/web/components/FeatureRow.tsx @@ -0,0 +1,398 @@ +import React, { FC, useEffect } from 'react' +import TagValues from './tags/TagValues' +import ConfirmToggleFeature from './modals/ConfirmToggleFeature' +import ConfirmRemoveFeature from './modals/ConfirmRemoveFeature' +import CreateFlagModal from './modals/CreateFlag' +import ProjectStore from 'common/stores/project-store' +import Constants from 'common/constants' +import { useProtectedTags } from 'common/utils/useProtectedTags' +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' +import Button from './base/forms/Button' +import SegmentOverridesIcon from './SegmentOverridesIcon' +import IdentityOverridesIcon from './IdentityOverridesIcon' +import StaleFlagWarning from './StaleFlagWarning' +import UnhealthyFlagWarning from './UnhealthyFlagWarning' +import { + Environment, + FeatureListProviderActions, + FeatureListProviderData, + FeatureState, + ProjectFlag, +} from 'common/types/responses' +import Utils from 'common/utils/utils' +import API from 'project/api' +import Switch from './Switch' +import AccountStore from 'common/stores/account-store' +import CondensedFeatureRow from './CondensedFeatureRow' +import { RouterChildContext } from 'react-router' + +interface FeatureRowProps { + disableControls?: boolean + environmentFlags: FeatureListProviderData['environmentFlags'] + environmentId: string + permission?: boolean + projectFlag: ProjectFlag + projectId: string + removeFlag?: FeatureListProviderActions['removeFlag'] + toggleFlag?: FeatureListProviderActions['toggleFlag'] + index: number + readOnly?: boolean + condensed?: boolean + className?: string + style?: React.CSSProperties + fadeEnabled?: boolean + fadeValue?: boolean + hideAudit?: boolean + hideRemove?: boolean + history?: RouterChildContext['router']['history'] +} + +const width = [200, 70, 55, 70, 450] + +const FeatureRow: FC = ({ + className, + condensed = false, + disableControls, + environmentFlags, + environmentId, + fadeEnabled, + fadeValue, + hideAudit = false, + hideRemove = false, + history, + index, + permission, + projectFlag, + projectId, + readOnly = false, + removeFlag, + style, + toggleFlag, +}) => { + const protectedTags = useProtectedTags(projectFlag, projectId) + + useEffect(() => { + const { feature } = Utils.fromParam() + const { id } = projectFlag + + if (`${id}` === feature) { + editFeature(projectFlag, environmentFlags?.[id]) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [environmentFlags, projectFlag]) + + const copyFeature = () => { + Utils.copyToClipboard(projectFlag.name) + } + + const confirmRemove = (projectFlag: ProjectFlag, cb: () => void) => { + openModal2( + 'Remove Feature', + , + 'p-0', + ) + } + + const confirmToggle = () => { + const { id } = projectFlag + openModal( + 'Toggle Feature', + { + toggleFlag?.( + projectId, + environmentId, + projectFlag, + environmentFlags?.[id], + ) + }} + />, + 'p-0', + ) + } + + const onChange = () => { + if (disableControls) { + return + } + if ( + projectFlag?.multivariate_options?.length || + Utils.changeRequestsEnabled(environment?.minimum_change_request_approvals) + ) { + editFeature(projectFlag, environmentFlags?.[id]) + return + } + confirmToggle() + } + + const editFeature = ( + projectFlag: ProjectFlag, + environmentFlag?: FeatureState, + tab?: string, + ) => { + if (disableControls) { + return + } + + API.trackEvent(Constants.events.VIEW_FEATURE) + const tabValue = tab || Utils.fromParam().tab || 'value' + + history.replace( + {}, + '', + `${document.location.pathname}?feature=${projectFlag.id}&tab=${tabValue}`, + ) + + openModal( + + {permission ? 'Edit Feature' : 'Feature'}: {projectFlag.name} + + , + , + 'side-modal create-feature-modal', + () => { + history.replace({}, '', `${document.location.pathname}`) + }, + ) + } + + const isReadOnly = readOnly || Utils.getFlagsmithHasFeature('read_only_mode') + const isFeatureHealthEnabled = Utils.getFlagsmithHasFeature('feature_health') + + const { created_date, description, id, name } = projectFlag + const environment = ProjectStore.getEnvironment( + environmentId, + ) as Environment | null + + const isCompact = getViewMode() === 'compact' + const showPlusIndicator = + projectFlag?.is_num_identity_overrides_complete === false + + if (condensed) { + return ( + + ) + } + + return ( + + !isReadOnly && editFeature(projectFlag, environmentFlags?.[id]) + } + > + + + + + + + {created_date ? ( + + {isCompact && description ? `${description}` : ''} + + ) : ( + name + )} + + + + { + e.stopPropagation() + editFeature( + projectFlag, + environmentFlags?.[id], + Constants.featurePanelTabs.SEGMENT_OVERRIDES, + ) + }} + count={projectFlag.num_segment_overrides} + /> + { + e.stopPropagation() + editFeature( + projectFlag, + environmentFlags?.[id], + Constants.featurePanelTabs.IDENTITY_OVERRIDES, + ) + }} + count={projectFlag.num_identity_overrides} + showPlusIndicator={showPlusIndicator} + /> + {projectFlag.is_server_key_only && ( + + {'Server-side only'} + + } + place='top' + > + { + 'Prevent this feature from being accessed with client-side SDKs.' + } + + )} + + {projectFlag.is_archived && ( + + )} + + {!!isCompact && } + {isFeatureHealthEnabled && !!isCompact && ( + + )} + + {!isCompact && } + {isFeatureHealthEnabled && !isCompact && ( + + )} + {description && !isCompact && ( +
+ {description} +
+ )} +
+
+
+
+ + !isReadOnly && editFeature(projectFlag, environmentFlags?.[id]) + } + value={environmentFlags?.[id]?.feature_state_value ?? null} + data-test={`feature-value-${index}`} + /> +
+
{ + e.stopPropagation() + }} + > + +
+ +
{ + e.stopPropagation() + }} + > + { + if (disableControls) return + editFeature( + projectFlag, + environmentFlags?.[id], + Constants.featurePanelTabs.HISTORY, + ) + }} + onShowAudit={() => { + if (disableControls) return + history.push( + `/project/${projectId}/audit-log?env=${environment?.id}&search=${projectFlag.name}`, + '', + ) + }} + onRemove={() => { + if (disableControls) return + confirmRemove(projectFlag, () => { + removeFlag?.(projectId, projectFlag) + }) + }} + onCopyName={copyFeature} + /> +
+
+ ) +} + +export default FeatureRow diff --git a/frontend/web/components/StaleFlagWarning.tsx b/frontend/web/components/StaleFlagWarning.tsx index 2cb576f7a301..3acf786a43d1 100644 --- a/frontend/web/components/StaleFlagWarning.tsx +++ b/frontend/web/components/StaleFlagWarning.tsx @@ -2,7 +2,7 @@ import { FC } from 'react' import Constants from 'common/constants' import moment from 'moment' import { Project, ProjectFlag } from 'common/types/responses' -import { getProtectedTags } from 'common/utils/getProtectedTags' +import { useProtectedTags } from 'common/utils/useProtectedTags' import { IonIcon } from '@ionic/react' import { warning } from 'ionicons/icons' import Tooltip from './Tooltip' @@ -14,8 +14,20 @@ type StaleFlagWarningType = { } const StaleFlagWarning: FC = ({ projectFlag }) => { - if (!Utils.getFlagsmithHasFeature('feature_versioning')) return null - const protectedTags = getProtectedTags(projectFlag, `${projectFlag.project}`) + const isFeatureVersioningEnabled = + Utils.getFlagsmithHasFeature('feature_versioning') + const protectedTags = useProtectedTags( + projectFlag, + `${projectFlag.project}`, + !isFeatureVersioningEnabled, + ) + + if (!isFeatureVersioningEnabled) return null + + if (protectedTags === undefined) { + return null + } + if (protectedTags?.length) { return null } diff --git a/frontend/web/components/import-export/FeatureExport.tsx b/frontend/web/components/import-export/FeatureExport.tsx index 988711d234d7..71d62bb01d50 100644 --- a/frontend/web/components/import-export/FeatureExport.tsx +++ b/frontend/web/components/import-export/FeatureExport.tsx @@ -9,7 +9,12 @@ import FeatureListStore from 'common/stores/feature-list-store' import FeatureListProvider from 'common/providers/FeatureListProvider' import AppActions from 'common/dispatcher/app-actions' import FeatureRow from 'components/FeatureRow' -import { FeatureState, ProjectFlag, TagStrategy } from 'common/types/responses' +import { + FeatureListProviderData, + FeatureState, + ProjectFlag, + TagStrategy, +} from 'common/types/responses' import ProjectStore from 'common/stores/project-store' import Utils from 'common/utils/utils' import Button from 'components/base/forms/Button' @@ -125,8 +130,8 @@ const FeatureExport: FC = ({ projectId }) => { environmentFlags, projectFlags, }: { - environmentFlags?: FeatureState[] - projectFlags: ProjectFlag[] + environmentFlags?: FeatureListProviderData['environmentFlags'] + projectFlags: FeatureListProviderData['projectFlags'] }) => { const isLoading = !FeatureListStore.hasLoaded @@ -159,11 +164,7 @@ const FeatureExport: FC = ({ projectId }) => { readOnly hideRemove hideAudit - descriptionInTooltip - hideActions - size='sm' environmentFlags={environmentFlags} - projectFlags={projectFlags} environmentId={environment} projectId={projectId} index={i} diff --git a/frontend/web/components/import-export/FeatureImport.tsx b/frontend/web/components/import-export/FeatureImport.tsx index f3cff83b460b..3dd6bc262e0a 100644 --- a/frontend/web/components/import-export/FeatureImport.tsx +++ b/frontend/web/components/import-export/FeatureImport.tsx @@ -134,16 +134,10 @@ const FeatureExport: FC = ({ projectId }) => { tags: tags?.length ? tags : undefined, }) }, [projectId, tags]) - const { - featureStates, - projectFlags, - }: { - projectFlags: ProjectFlag[] | null - featureStates: FeatureState[] | null - } = useMemo(() => { + const { featureStates, projectFlags } = useMemo(() => { if (fileData) { const createdDate = new Date().toISOString() - const existingFlags: ProjectFlag[] = + const existingFlags = !!fileData && !!currentFeatureStates && currentProjectflags?.map((projectFlag, i) => { @@ -426,9 +420,7 @@ const FeatureExport: FC = ({ projectId }) => { !hideTags.includes(tag)) || [], } @@ -201,6 +205,9 @@ const CreateFlag = class extends Component { }) } + this.fetchChangeRequests() + this.fetchScheduledChangeRequests() + getGithubIntegration(getStore(), { organisation_id: AccountStore.getOrganisation().id, }).then((res) => { @@ -543,6 +550,43 @@ const CreateFlag = class extends Component { this.forceUpdate() } + fetchChangeRequests = (forceRefetch) => { + const { environmentId, projectFlag } = this.props + if (!projectFlag?.id) return + + getChangeRequests( + getStore(), + { + committed: false, + environmentId, + feature_id: projectFlag?.id, + }, + { forceRefetch }, + ).then((res) => { + this.setState({ changeRequests: res.data?.results }) + }) + } + + fetchScheduledChangeRequests = (forceRefetch) => { + const { environmentId, projectFlag } = this.props + if (!projectFlag?.id) return + + const date = moment().toISOString() + + console.log('data', date) + getChangeRequests( + getStore(), + { + environmentId, + feature_id: projectFlag.id, + live_from_after: date, + }, + { forceRefetch }, + ).then((res) => { + this.setState({ scheduledChangeRequests: res.data?.results }) + }) + } + render() { const { default_enabled, @@ -579,6 +623,9 @@ const CreateFlag = class extends Component { let regexValid = true const metadataEnable = Utils.getPlansPermission('METADATA') + const { changeRequests, scheduledChangeRequests } = this.state + console.log({ changeRequests, out: true, scheduledChangeRequests }) + try { if (!isEdit && name && regex) { regexValid = name.match(new RegExp(regex)) @@ -745,13 +792,18 @@ const CreateFlag = class extends Component { const Value = (error, projectAdmin, createFeature, hideValue) => { const { featureError, featureWarning } = this.parseError(error) + const { changeRequests, scheduledChangeRequests } = this.state return ( <> - {!!isEdit && ( + {!!isEdit && !identity && ( )} {!isEdit && ( @@ -886,6 +938,11 @@ const CreateFlag = class extends Component { this.props.projectId, this.props.environmentId, ) + + if (is4Eyes && !identity) { + this.fetchChangeRequests(true) + this.fetchScheduledChangeRequests(true) + } }} > {( @@ -2070,7 +2127,13 @@ const FeatureProvider = (WrappedComponent) => { this.listenTo( FeatureListStore, 'saved', - ({ changeRequest, createdFlag, error, isCreate } = {}) => { + ({ + changeRequest, + createdFlag, + error, + isCreate, + updatedChangeRequest, + } = {}) => { if (error?.data?.metadata) { error.data.metadata?.forEach((m) => { if (Object.keys(m).length > 0) { @@ -2081,11 +2144,23 @@ const FeatureProvider = (WrappedComponent) => { toast('Error updating the Flag', 'danger') return } else { - toast( - `${createdFlag || isCreate ? 'Created' : 'Updated'} ${ - changeRequest ? 'Change Request' : 'Feature' - }`, - ) + const operation = createdFlag || isCreate ? 'Created' : 'Updated' + const type = changeRequest ? 'Change Request' : 'Feature' + + const toastText = `${operation} ${type}` + const toastAction = changeRequest + ? { + buttonText: 'Open', + onClick: () => { + closeModal() + this.props.history.push( + `/project/${this.props.projectId}/environment/${this.props.environmentId}/change-requests/${updatedChangeRequest?.id}`, + ) + }, + } + : undefined + + toast(toastText, 'success', undefined, toastAction) } const envFlags = FeatureListStore.getEnvironmentFlags() diff --git a/frontend/web/components/modals/CreateSegmentUsersTabContent.tsx b/frontend/web/components/modals/CreateSegmentUsersTabContent.tsx index 5b10da5e69a8..5cf2657d7660 100644 --- a/frontend/web/components/modals/CreateSegmentUsersTabContent.tsx +++ b/frontend/web/components/modals/CreateSegmentUsersTabContent.tsx @@ -1,13 +1,16 @@ -import React from 'react' +import React, { FC } from 'react' import EnvironmentSelect from 'components/EnvironmentSelect' -import IdentitySegmentsProvider from 'common/providers/IdentitySegmentsProvider' import PanelSearch from 'components/PanelSearch' import InfoMessage from 'components/InfoMessage' import InputGroup from 'components/base/forms/InputGroup' import Utils from 'common/utils/utils' -import { Res, Segment } from 'common/types/responses' +import { Res } from 'common/types/responses' import Icon from 'components/Icon' -import AppActions from 'common/dispatcher/app-actions' +import { + identitySegmentService, + useGetIdentitySegmentsQuery, +} from 'common/services/useIdentitySegment' +import { getStore } from 'common/store' interface CreateSegmentUsersTabContentProps { projectId: string | number @@ -22,6 +25,55 @@ interface CreateSegmentUsersTabContentProps { setSearchInput: (input: string) => void } +type UserRowType = { + id: string + identifier: string + segmentName: string + projectId: string + index: number +} + +const UserRow: FC = ({ + id, + identifier, + index, + projectId, + segmentName, +}) => { + const { data: segments } = useGetIdentitySegmentsQuery({ + identity: id, + projectId, + }) + let inSegment = false + if (segments?.results.find((v) => v.name === segmentName)) { + inSegment = true + } + return ( + + +
{identifier}
+ + {inSegment ? ( + <> + + User in segment + + ) : ( + <> + + Not in segment + + )} + +
+
+ ) +} + const CreateSegmentUsersTabContent: React.FC< CreateSegmentUsersTabContentProps > = ({ @@ -96,8 +148,10 @@ const CreateSegmentUsersTabContent: React.FC< onRefresh={ environmentId ? () => - identities?.results.forEach((identity) => - AppActions.getIdentitySegments(projectId, identity.id), + getStore().dispatch( + identitySegmentService.util.invalidateTags([ + 'IdentitySegment', + ]), ) : undefined } @@ -105,51 +159,13 @@ const CreateSegmentUsersTabContent: React.FC< { id, identifier }: { id: string; identifier: string }, index: number, ) => ( - - - {({ segments }: { segments?: Segment[] }) => { - let inSegment = false - if (segments?.find((v) => v.name === name)) { - inSegment = true - } - return ( - -
{identifier}
- - {inSegment ? ( - <> - - User in segment - - ) : ( - <> - - Not in segment - - )} - -
- ) - }} -
-
+ )} filterRow={() => true} search={searchInput} diff --git a/frontend/web/components/pages/FeaturesPage.js b/frontend/web/components/pages/FeaturesPage.js index 61f36b7680a8..6b4a42fb77e4 100644 --- a/frontend/web/components/pages/FeaturesPage.js +++ b/frontend/web/components/pages/FeaturesPage.js @@ -576,6 +576,7 @@ const FeaturesPage = class extends Component { environmentFlags={environmentFlags} projectFlags={projectFlags} permission={permission} + history={this.context.router.history} environmentId={environmentId} projectId={projectId} index={i} diff --git a/frontend/web/components/pages/UserPage.tsx b/frontend/web/components/pages/UserPage.tsx index 67375dffc6d7..067482abf970 100644 --- a/frontend/web/components/pages/UserPage.tsx +++ b/frontend/web/components/pages/UserPage.tsx @@ -29,7 +29,6 @@ import Format from 'common/utils/format' import Icon from 'components/Icon' import IdentifierString from 'components/IdentifierString' import IdentityProvider from 'common/providers/IdentityProvider' -import IdentitySegmentsProvider from 'common/providers/IdentitySegmentsProvider' import InfoMessage from 'components/InfoMessage' import JSONReference from 'components/JSONReference' import PageTitle from 'components/PageTitle' @@ -58,6 +57,8 @@ import ClearFilters from 'components/ClearFilters' import SegmentsIcon from 'components/svg/SegmentsIcon' import UsersIcon from 'components/svg/UsersIcon' import IdentityTraits from 'components/IdentityTraits' +import { useGetIdentitySegmentsQuery } from 'common/services/useIdentitySegment' +import useSearchThrottle from 'common/useSearchThrottle' const width = [200, 48, 78] @@ -133,7 +134,23 @@ const UserPage: FC = (props) => { const [actualFlags, setActualFlags] = useState>() const [preselect, setPreselect] = useState(Utils.fromParam().flag) - + const [segmentsPage, setSegmentsPage] = useState(1) + const { + search, + searchInput: segmentSearchInput, + setSearchInput: setSegmentSearchInput, + } = useSearchThrottle('') + const { + data: segments, + isFetching: isFetchingSegments, + refetch: refetchIdentitySegments, + } = useGetIdentitySegmentsQuery({ + identity: id, + page: segmentsPage, + page_size: 10, + projectId, + q: search, + }) const getFilter = useCallback( (filter) => ({ ...filter, @@ -166,7 +183,6 @@ const UserPage: FC = (props) => { useEffect(() => { AppActions.getIdentity(environmentId, id) - AppActions.getIdentitySegments(projectId, id) getTags(getStore(), { projectId: `${projectId}` }) getActualFlags() API.trackPage(Constants.pages.USER) @@ -914,93 +930,96 @@ const UserPage: FC = (props) => { identityId={id} identityName={identity} /> - )} - - {({ segments }: any) => - !segments ? ( -
- -
- ) : ( - - - - Name - - - Description - - - } - items={segments || []} - renderRow={( - { created_date, description, name }: any, - i: number, - ) => ( - editSegment(segments[i])} + )}{' '} + {!segments?.results ? ( +
+ +
+ ) : ( + + { + setSegmentSearchInput( + Utils.safeParseEventValue(e), + ) + }} + itemHeight={70} + paging={segments} + nextPage={() => setSegmentsPage(segmentsPage + 1)} + prevPage={() => setSegmentsPage(segmentsPage - 1)} + goToPage={setSegmentsPage} + header={ + + + Name + + + Description + + + } + items={segments.results} + renderRow={( + { created_date, description, name }: any, + i: number, + ) => ( + editSegment(segments.results[i])} + > + +
+ editSegment(segments.results[i]) + } > - -
editSegment(segments[i])} - > - - {name} - -
-
- Created{' '} - {moment(created_date).format( - 'DD/MMM/YYYY', - )} -
-
- - {description &&
{description}
} -
+ {name} + +
+
+ Created{' '} + {moment(created_date).format('DD/MMM/YYYY')} +
+
+ + {description &&
{description}
} +
+
+ )} + renderNoResults={ + +
+ + This user is not a member of any segments. - )} - renderNoResults={ - -
- - This user is not a member of any - segments. - -
-
- } - filterRow={( - { name }: any, - searchString: string, - ) => - name - .toLowerCase() - .indexOf(searchString.toLowerCase()) > -1 - } - /> - - ) - } - +
+
+ } + filterRow={({ name }: any, searchString: string) => + name + .toLowerCase() + .indexOf(searchString.toLowerCase()) > -1 + } + /> +
+ )}
diff --git a/frontend/web/components/pages/WidgetPage.tsx b/frontend/web/components/pages/WidgetPage.tsx index 37b7ef875f8a..6a48405586e2 100644 --- a/frontend/web/components/pages/WidgetPage.tsx +++ b/frontend/web/components/pages/WidgetPage.tsx @@ -425,11 +425,8 @@ const FeatureList = class extends Component { - - {this.props.children} - - - - - - -
- ) - } -} - -Message.defaultProps = { - expiry: 5000, - theme: 'success', -} - -Message.propTypes = { - children: OptionalNode, - content: OptionalNode, - expiry: OptionalNumber, - isRemoving: OptionalBool, - remove: RequiredFunc, - theme: OptionalString, -} - -module.exports = Message - -const Toast = class extends React.Component { - static displayName = 'ToastMessages' - - constructor(props, context) { - super(props, context) - this.state = { messages: [] } - window.toast = this.toast - } - - toast = (content, theme, expiry) => { - const { messages } = this.state - - // Ignore duplicate messages - if (messages[0]?.content === content) { - return - } - const id = Utils.GUID() - messages.unshift({ content, expiry: E2E ? 1000 : expiry, id, theme }) - this.setState({ messages }) - } - - remove = (id) => { - const index = _.findIndex(this.state.messages, { id }) - const messages = this.state.messages - - if (index > -1) { - messages[index].isRemoving = true - setTimeout(() => { - const index = _.findIndex(this.state.messages, { id }) - const messages = this.state.messages - messages.splice(index, 1) - this.setState({ messages }) - }, 500) - this.setState({ messages }) - } - } - - render() { - return ( -
- {this.state.messages.map((message) => ( - this.remove(message.id)} - expiry={message.expiry} - theme={message.theme} - > - {message.content} - - ))} -
- ) - } -} -module.exports = Toast diff --git a/frontend/web/project/toast.tsx b/frontend/web/project/toast.tsx new file mode 100644 index 000000000000..f2cd888f0f6c --- /dev/null +++ b/frontend/web/project/toast.tsx @@ -0,0 +1,160 @@ +import React, { FC, useEffect, useState } from 'react' +import cn from 'classnames' +import { close } from 'ionicons/icons' +import { IonIcon } from '@ionic/react' + +import Utils from 'common/utils/utils' +import Button from 'components/base/forms/Button' + +export type ThemeType = 'danger' | 'info' | 'success' | 'warning' + +const themeClassNames: Record = { + danger: 'alert-danger', + info: 'alert-info', + success: 'alert', + warning: 'alert-warning', +} + +export interface MessageProps { + action?: { buttonText: string; onClick: () => void } + remove: () => void + expiry?: number + isRemoving?: boolean + theme?: ThemeType + children?: React.ReactNode +} + +const Message: FC = ({ + action, + children, + expiry = 5000, + isRemoving = false, + remove, + theme = 'success', +}) => { + useEffect(() => { + const timeout = setTimeout(remove, expiry) + return () => clearTimeout(timeout) + }, [remove, expiry]) + + const className = cn( + { + 'alert': true, + 'removing-out': isRemoving, + 'show': !isRemoving, + 'toast-message': true, + }, + themeClassNames[theme], + ) + + const hasAction = action?.onClick && action?.buttonText + + const closeButton = ( + + + + + + ) + + return ( +
+ +
{children}
+ {!hasAction && closeButton} + {hasAction && ( + + + {closeButton} + + )} +
+
+ ) +} + +export interface Message { + action?: { buttonText: string; onClick: () => void } + id: string + content: React.ReactNode + expiry?: number + theme?: ThemeType + isRemoving?: boolean +} + +const ToastMessages: FC<{}> = () => { + const [messages, setMessages] = useState([]) + + const toast = ( + content: React.ReactNode, + theme?: ThemeType, + expiry?: number, + action?: { buttonText: string; onClick: () => void }, + ) => { + setMessages((prevMessages) => { + // Ignore duplicate messages + if (prevMessages[0]?.content === content) { + return prevMessages + } + + const id = Utils.GUID() + return [ + { + action, + content, + expiry: E2E ? 1000 : expiry, + id, + theme, + }, + ...prevMessages, + ] + }) + } + + const remove = (id: string) => { + setMessages((prevMessages) => { + const index = prevMessages.findIndex((msg) => msg.id === id) + if (index === -1) return prevMessages + + const newMessages = [...prevMessages] + newMessages[index] = { ...newMessages[index], isRemoving: true } + + setTimeout(() => { + setMessages((prev) => prev.filter((msg) => msg.id !== id)) + }, 500) + + return newMessages + }) + } + + // Attach toast to window for global access + React.useEffect(() => { + ;(window as any).toast = toast + }, []) + + return ( +
+ {messages.map((message) => ( + remove(message.id)} + expiry={message.expiry} + theme={message.theme} + > + {message.content} + + ))} +
+ ) +} + +export default ToastMessages diff --git a/frontend/web/styles/components/_toast.scss b/frontend/web/styles/components/_toast.scss index c9acd6d45f33..6cb0b5eaee23 100644 --- a/frontend/web/styles/components/_toast.scss +++ b/frontend/web/styles/components/_toast.scss @@ -1,23 +1,72 @@ @import '../variables'; +@keyframes toast-in { + from { + transform: translateY(100%); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes toast-out { + from { + transform: translateY(0); + opacity: 1; + } + + to { + transform: translateY(100%); + opacity: 0; + } +} + #toast { display: inline-block; position: fixed; - right: 15px; - top: 15px; + bottom: 20px; + left: 50%; + transform: translateX(-50%); z-index: 1000000000; + a, button { box-shadow: none; - color: black; + } + + a { + color: $text-icon-grey; + } + + button { + box-shadow: none; + line-height: 16px; + color: $primary800; + height: 'auto'; + margin-right: 0.2rem; } } .toast-message { - top: 0; - right: 15px; - width: 250px; - background: $success-solid-alert; - font-weight: 500; - border: 1px solid $success; -} + width: 250px; + min-height: 50px; + background: $success-solid-alert; + font-weight: 500; + border: 1px solid $success; + animation: toast-in 0.3s ease-out; + + &.show { + display: flex !important; + } + + &.removing-out { + animation: toast-out 0.3s ease-out forwards; + } + + @media screen and (min-width: 768px) { + width: 320px; + } +} \ No newline at end of file diff --git a/frontend/web/styles/project/_modals.scss b/frontend/web/styles/project/_modals.scss index 9cca000670b1..628c4636e18e 100644 --- a/frontend/web/styles/project/_modals.scss +++ b/frontend/web/styles/project/_modals.scss @@ -271,6 +271,11 @@ $side-width: 750px; opacity: 0; pointer-events: none; } + + #toast { + transform: translate(-50%); + } + } .table-filter-list { overflow-y: auto; diff --git a/frontend/web/styles/project/_project-nav.scss b/frontend/web/styles/project/_project-nav.scss index 4d271a326eda..9cc8a8625921 100644 --- a/frontend/web/styles/project/_project-nav.scss +++ b/frontend/web/styles/project/_project-nav.scss @@ -13,10 +13,16 @@ nav a { border-radius: $border-radius; &:hover { - color: $body-color; background-color: $primary-alfa-8; } } + &:hover{ + svg { + path { + fill: $link-hover-color; + } + } + } svg { width: 14px; height: 14px; @@ -122,15 +128,22 @@ nav a { font-size: $font-size-base; } } - .collapsible-title { - line-height: 32px; - cursor: pointer; - border-radius: $border-radius; - &:hover { - color: $primary; - background-color: $primary-alfa-8; +.aside-nav a:hover { + svg { + path { + fill: $primary; } } +} +.collapsible-title { + line-height: 32px; + cursor: pointer; + border-radius: $border-radius; + &:hover { + color: $primary; + background-color: $primary-alfa-8; + } +} .collapsible.active { .collapsible-title { pointer-events: none; @@ -157,4 +170,17 @@ nav a { opacity: 0.3; } } + .aside-nav a { + &.active { + background-color: $primary-alfa-16; + } + } + .aside-nav a:hover { + background-color: $primary-alfa-8; + svg { + path { + fill: $body-color-dark; + } + } + } }