diff --git a/api/Makefile b/api/Makefile index 5e550dae2421..0133b322e1fd 100644 --- a/api/Makefile +++ b/api/Makefile @@ -138,16 +138,13 @@ generate-grafana-client-types: .PHONY: integrate-private-tests integrate-private-tests: $(eval WORKFLOW_REVISION := $(shell grep -A 1 "\[tool.poetry.group.workflows.dependencies\]" pyproject.toml | awk -F '"' '{printf $$4}')) - $(eval TASK_PROCESSOR_REVISION := $(shell grep -A 0 "flagsmith-task-processor" pyproject.toml | awk -F '"' '{printf $$4}')) $(eval AUTH_CONTROLLER_REVISION := $(shell grep -A 1 "\[tool.poetry.group.auth-controller.dependencies\]" pyproject.toml | awk -F '"' '{printf $$4}')) git clone https://github.com/flagsmith/flagsmith-saml --depth 1 --branch ${SAML_REVISION} && mv ./flagsmith-saml/tests tests/saml_tests git clone https://github.com/flagsmith/flagsmith-rbac --depth 1 --branch ${RBAC_REVISION} && mv ./flagsmith-rbac/tests tests/rbac_tests git clone https://github.com/flagsmith/flagsmith-workflows --depth 1 --branch ${WORKFLOW_REVISION} && mv ./flagsmith-workflows/tests tests/workflow_tests git clone https://github.com/flagsmith/flagsmith-auth-controller --depth 1 --branch ${AUTH_CONTROLLER_REVISION} && mv ./flagsmith-auth-controller/tests tests/auth_controller_tests - - git clone https://github.com/flagsmith/flagsmith-task-processor --depth 1 --branch ${TASK_PROCESSOR_REVISION} && mv ./flagsmith-task-processor/tests tests/task_processor_tests - rm -rf ./flagsmith-saml ./flagsmith-rbac ./flagsmith-workflows ./flagsmith-auth-controller ./flagsmith-task-processor + rm -rf ./flagsmith-saml ./flagsmith-rbac ./flagsmith-workflows ./flagsmith-auth-controller .PHONY: generate-docs generate-docs: diff --git a/api/integrations/launch_darkly/constants.py b/api/integrations/launch_darkly/constants.py index c6e4535afd4d..1bbb9a0da0e5 100644 --- a/api/integrations/launch_darkly/constants.py +++ b/api/integrations/launch_darkly/constants.py @@ -6,6 +6,7 @@ LAUNCH_DARKLY_IMPORTED_TAG_COLOR = "#3d4db6" LAUNCH_DARKLY_IMPORTED_DEFAULT_TAG_LABEL = "Imported" +LAUNCH_DARKLY_IMPORTED_DEPRECATED_TAG_LABEL = "Deprecated" BACKOFF_MAX_RETRIES = 5 BACKOFF_DEFAULT_RETRY_AFTER_SECONDS = 10 diff --git a/api/integrations/launch_darkly/models.py b/api/integrations/launch_darkly/models.py index d59034eb2799..f80d30ca73cf 100644 --- a/api/integrations/launch_darkly/models.py +++ b/api/integrations/launch_darkly/models.py @@ -17,6 +17,7 @@ class LaunchDarklyImportStatus(TypedDict): requested_environment_count: int requested_flag_count: int + deprecated_flag_count: NotRequired[int] result: NotRequired[LaunchDarklyImportResult] error_messages: list[str] diff --git a/api/integrations/launch_darkly/serializers.py b/api/integrations/launch_darkly/serializers.py index c9f1645b876d..9986db3a6f2a 100644 --- a/api/integrations/launch_darkly/serializers.py +++ b/api/integrations/launch_darkly/serializers.py @@ -11,6 +11,7 @@ class LaunchDarklyImportRequestStatusSerializer(serializers.Serializer): # type: ignore[type-arg] requested_environment_count = serializers.IntegerField(read_only=True) requested_flag_count = serializers.IntegerField(read_only=True) + deprecated_flag_count = serializers.IntegerField(read_only=True, default=0) result = serializers.ChoiceField( get_args(LaunchDarklyImportResult), read_only=True, diff --git a/api/integrations/launch_darkly/services.py b/api/integrations/launch_darkly/services.py index 84de934cdd2d..10b9287e7358 100644 --- a/api/integrations/launch_darkly/services.py +++ b/api/integrations/launch_darkly/services.py @@ -27,6 +27,7 @@ from integrations.launch_darkly.client import LaunchDarklyClient from integrations.launch_darkly.constants import ( LAUNCH_DARKLY_IMPORTED_DEFAULT_TAG_LABEL, + LAUNCH_DARKLY_IMPORTED_DEPRECATED_TAG_LABEL, LAUNCH_DARKLY_IMPORTED_TAG_COLOR, ) from integrations.launch_darkly.exceptions import LaunchDarklyRateLimitError @@ -117,7 +118,11 @@ def _create_tags_from_ld( ) -> dict[str, Tag]: tags_by_ld_tag = {} - for ld_tag in (*ld_tags, LAUNCH_DARKLY_IMPORTED_DEFAULT_TAG_LABEL): + for ld_tag in ( + *ld_tags, + LAUNCH_DARKLY_IMPORTED_DEFAULT_TAG_LABEL, + LAUNCH_DARKLY_IMPORTED_DEPRECATED_TAG_LABEL, + ): tags_by_ld_tag[ld_tag], _ = Tag.objects.update_or_create( label=ld_tag, project_id=project_id, @@ -894,6 +899,8 @@ def _create_feature_from_ld( tags_by_ld_tag[LAUNCH_DARKLY_IMPORTED_DEFAULT_TAG_LABEL], *(tags_by_ld_tag[ld_tag] for ld_tag in ld_flag["tags"]), ] + if ld_flag["deprecated"]: + tags.append(tags_by_ld_tag[LAUNCH_DARKLY_IMPORTED_DEPRECATED_TAG_LABEL]) feature, _ = Feature.objects.update_or_create( project_id=project_id, @@ -902,7 +909,7 @@ def _create_feature_from_ld( "description": ld_flag.get("description"), "default_enabled": False, "type": feature_type, - "is_archived": ld_flag["archived"], + "is_archived": ld_flag["archived"] or ld_flag["deprecated"], }, ) feature.tags.set(tags) @@ -1157,3 +1164,8 @@ def process_import_request( segments_by_ld_key=segments_by_ld_key, project_id=import_request.project_id, ) + + # Count deprecated flags for reporting + import_request.status["deprecated_flag_count"] = sum( + 1 for ld_flag in ld_flags if ld_flag["deprecated"] + ) diff --git a/api/tests/unit/integrations/launch_darkly/client_responses/get_flags.json b/api/tests/unit/integrations/launch_darkly/client_responses/get_flags.json index 22245da752c9..19184591949a 100644 --- a/api/tests/unit/integrations/launch_darkly/client_responses/get_flags.json +++ b/api/tests/unit/integrations/launch_darkly/client_responses/get_flags.json @@ -35,6 +35,7 @@ "offVariation": 1, "onVariation": 0 }, + "deprecated": true, "description": "", "environments": { "production": { @@ -162,6 +163,7 @@ "offVariation": 1, "onVariation": 0 }, + "deprecated": false, "description": "", "environments": { "production": { @@ -289,6 +291,7 @@ "offVariation": 1, "onVariation": 0 }, + "deprecated": false, "description": "", "environments": { "production": { @@ -420,6 +423,7 @@ "offVariation": 1, "onVariation": 0 }, + "deprecated": false, "description": "", "environments": { "production": { @@ -558,6 +562,7 @@ "offVariation": 1, "onVariation": 0 }, + "deprecated": false, "description": "", "environments": { "production": { diff --git a/api/tests/unit/integrations/launch_darkly/snapshots/test_process_import_request__large_segments__correctly_imported__import_request_status.json b/api/tests/unit/integrations/launch_darkly/snapshots/test_process_import_request__large_segments__correctly_imported__import_request_status.json index 4c7e66bb9417..5c8dd5470a34 100644 --- a/api/tests/unit/integrations/launch_darkly/snapshots/test_process_import_request__large_segments__correctly_imported__import_request_status.json +++ b/api/tests/unit/integrations/launch_darkly/snapshots/test_process_import_request__large_segments__correctly_imported__import_request_status.json @@ -1,4 +1,5 @@ { + "deprecated_flag_count": 1, "error_messages": [ "Targeting key 'user-...63a49' exceeds the limit of 1000 characters, skipping for segment 'Large User List (Override for test)'", "Segment condition value '.*1a6...\\.com' for property 'email' exceeds the limit of 1000 characters, skipping for segment 'Large Dynamic List (Override for test)'", diff --git a/api/tests/unit/integrations/launch_darkly/test_services.py b/api/tests/unit/integrations/launch_darkly/test_services.py index a191d5798339..6819870802d2 100644 --- a/api/tests/unit/integrations/launch_darkly/test_services.py +++ b/api/tests/unit/integrations/launch_darkly/test_services.py @@ -141,11 +141,13 @@ def test_process_import_request__success__expected_status( # type: ignore[no-un ("testtag", "#3d4db6"), ("testtag2", "#3d4db6"), ("Imported", "#3d4db6"), + ("Deprecated", "#3d4db6"), } assert set( Feature.objects.filter(project=project).values_list("name", "tags__label") ) == { ("flag1", "Imported"), + ("flag1", "Deprecated"), ("flag2_value", "Imported"), ("flag3_multivalue", "Imported"), ("flag4_multivalue", "Imported"), @@ -158,6 +160,10 @@ def test_process_import_request__success__expected_status( # type: ignore[no-un ("TEST_COMBINED_TARGET", "Imported"), } + # Deprecated flags are archived. + deprecated_feature = Feature.objects.get(project=project, name="flag1") + assert deprecated_feature.is_archived is True + # Standard feature states have expected values. boolean_standard_feature = Feature.objects.get(project=project, name="flag1") boolean_standard_feature_states_by_env_name = { diff --git a/api/tests/unit/integrations/launch_darkly/test_views.py b/api/tests/unit/integrations/launch_darkly/test_views.py index 8c661e8ae252..e901a0332e56 100644 --- a/api/tests/unit/integrations/launch_darkly/test_views.py +++ b/api/tests/unit/integrations/launch_darkly/test_views.py @@ -49,6 +49,7 @@ def test_launch_darkly_import_request_view__list__return_expected( "id": import_request.id, "project": project.id, "status": { + "deprecated_flag_count": 0, "error_messages": [], "requested_environment_count": 2, "requested_flag_count": 9, @@ -92,6 +93,7 @@ def test_launch_darkly_import_request_view__create__return_expected( "id": created_import_request.id, "project": project.id, "status": { + "deprecated_flag_count": 0, "error_messages": [], "requested_environment_count": 2, "requested_flag_count": 9, diff --git a/docs/docs/administration-and-security/data-management/import-from-launchdarkly.md b/docs/docs/administration-and-security/data-management/import-from-launchdarkly.md index 770eb5bfc4f1..e7f3a3c492b3 100644 --- a/docs/docs/administration-and-security/data-management/import-from-launchdarkly.md +++ b/docs/docs/administration-and-security/data-management/import-from-launchdarkly.md @@ -65,3 +65,15 @@ Multivariate LaunchDarkly flags will be imported into Flagsmith as MultiVariate Multivariate values will be taken from the `variations` field of within LaunchDarkly. Values set to serve when targeting is off will be imported as control values. + +#### Archived and deprecated flags + +Archived flags in LaunchDarkly are imported as archived flags in Flagsmith. + +Deprecated flags in LaunchDarkly are also imported as archived flags in Flagsmith. They receive a "Deprecated" tag to distinguish them from flags that were archived in LaunchDarkly. + +:::note + +In both LaunchDarkly and Flagsmith, archived flags continue to evaluate normally for SDKs. Archiving a flag hides it from the dashboard but does not affect evaluation. + +::: diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 98c114653e6d..ffb422b1015b 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -186,6 +186,7 @@ export type LaunchDarklyProjectImport = { status: { requested_environment_count: number requested_flag_count: number + deprecated_flag_count?: number result: string | null error_message: string | null } diff --git a/frontend/web/components/import-export/ImportPage.tsx b/frontend/web/components/import-export/ImportPage.tsx index 2efcda0fdb4f..3d7b2a164fe1 100644 --- a/frontend/web/components/import-export/ImportPage.tsx +++ b/frontend/web/components/import-export/ImportPage.tsx @@ -26,52 +26,45 @@ const ImportPage: FC = ({ projectId, projectName }) => { const history = useHistory() const [LDKey, setLDKey] = useState('') const [importId, setImportId] = useState() + const [importSource, setImportSource] = useState('') const [isLoading, setIsLoading] = useState(false) - const [isAppLoading, setAppIsLoading] = useState(false) const [projects, setProjects] = useState<{ key: string; name: string }[]>([]) const [createLaunchDarklyProjectImport, { data, isSuccess }] = useCreateLaunchDarklyProjectImportMutation() - const { - data: status, - isSuccess: statusLoaded, - isUninitialized, - refetch, - } = useGetLaunchDarklyProjectImportQuery( + const { data: status } = useGetLaunchDarklyProjectImportQuery( { import_id: `${importId}`, project_id: projectId, }, - { skip: !importId }, + { + pollingInterval: importId ? 1000 : 0, + skip: !importId, + }, ) + // Set importId when mutation succeeds useEffect(() => { - const checkImportStatus = async () => { - setAppIsLoading(true) - const intervalId = setInterval(async () => { - await refetch() - - if (statusLoaded && status && status.status.result === 'success') { - clearInterval(intervalId) - setAppIsLoading(false) - window.location.reload() - } - }, 1000) - } - - if (statusLoaded) { - checkImportStatus() + if (isSuccess && data?.id) { + setImportId(data.id) + setImportSource('LaunchDarkly') } - }, [statusLoaded, status, refetch]) + }, [isSuccess, data]) + // Navigate away on import success useEffect(() => { - if (isSuccess && data?.id) { - setImportId(data.id) - if (!isUninitialized) { - refetch() - } + if (status?.status?.result === 'success') { + const params = new URLSearchParams() + params.set('import_success', '1') + params.set('import_source', importSource) + params.set('import_count', String(status.status.requested_flag_count)) + params.set( + 'import_deprecated', + String(status.status.deprecated_flag_count ?? 0), + ) + history.push(`/project/${projectId}?${params.toString()}`) } - }, [isSuccess, data, refetch, isUninitialized]) + }, [status, projectId, history, importSource]) const getProjectList = (LDKey: string) => { setIsLoading(true) @@ -210,11 +203,13 @@ const ImportPage: FC = ({ projectId, projectName }) => { ) + const isImporting = !!importId && status?.status?.result !== 'success' + return ( <> - {isAppLoading && ( + {isImporting && (
-
Importing Project
+
Importing from {importSource}...
)} diff --git a/frontend/web/components/import-export/ImportSuccessBanner.tsx b/frontend/web/components/import-export/ImportSuccessBanner.tsx new file mode 100644 index 000000000000..3232d78fd7f2 --- /dev/null +++ b/frontend/web/components/import-export/ImportSuccessBanner.tsx @@ -0,0 +1,53 @@ +import { FC, useState } from 'react' +import { useHistory, useLocation, Link } from 'react-router-dom' +import SuccessMessage from 'components/messages/SuccessMessage' +import Utils from 'common/utils/utils' + +type ImportSuccessBannerProps = { + projectId: string + environmentId: string +} + +const ImportSuccessBanner: FC = ({ + environmentId, + projectId, +}) => { + const history = useHistory() + const location = useLocation() + const [dismissed, setDismissed] = useState(false) + + const params = Utils.fromParam() + const isImportSuccess = params.import_success === '1' + const source = params.import_source + const count = parseInt(params.import_count, 10) + const deprecated = parseInt(params.import_deprecated, 10) || 0 + + if (!isImportSuccess || !source || !count || dismissed) { + return null + } + + const handleDismiss = () => { + setDismissed(true) + history.replace(location.pathname) + } + + const archivedLink = `/project/${projectId}/environment/${environmentId}/features?is_archived=true` + + return ( +
+ + Imported {count} flag{count !== 1 && 's'} from {source}. + {deprecated > 0 && ( + <> + {' '} + {deprecated} deprecated flag + {deprecated !== 1 ? 's were' : ' was'}{' '} + archived. + + )} + +
+ ) +} + +export default ImportSuccessBanner diff --git a/frontend/web/components/messages/SuccessMessage.tsx b/frontend/web/components/messages/SuccessMessage.tsx index d7379eeab4d2..d2a3a16b5588 100644 --- a/frontend/web/components/messages/SuccessMessage.tsx +++ b/frontend/web/components/messages/SuccessMessage.tsx @@ -36,12 +36,12 @@ const SuccessMessage: React.FC = ({ const titleDescClass = infoMessageClass ? `${infoMessageClass} body mr-2` : '' return ( -
+
-
{title}
+
{title}
{children}
{url && ( @@ -50,8 +50,8 @@ const SuccessMessage: React.FC = ({ )} {isClosable && ( - - + + diff --git a/frontend/web/components/pages/ProjectRedirectPage.tsx b/frontend/web/components/pages/ProjectRedirectPage.tsx index d41e09e67ecd..4bb4c6d55668 100644 --- a/frontend/web/components/pages/ProjectRedirectPage.tsx +++ b/frontend/web/components/pages/ProjectRedirectPage.tsx @@ -1,12 +1,13 @@ import { FC, useEffect } from 'react' import { useGetEnvironmentsQuery } from 'common/services/useEnvironment' -import { useHistory } from 'react-router-dom' +import { useHistory, useLocation } from 'react-router-dom' import Utils from 'common/utils/utils' import ConfigProvider from 'common/providers/ConfigProvider' import { useRouteContext } from 'components/providers/RouteContext' const ProjectRedirectPage: FC = () => { const history = useHistory() + const location = useLocation() const { projectId } = useRouteContext() const { data, error } = useGetEnvironmentsQuery({ @@ -22,12 +23,12 @@ const ProjectRedirectPage: FC = () => { const environment = data?.results?.[0] if (environment) { history.replace( - `/project/${projectId}/environment/${environment.api_key}/features`, + `/project/${projectId}/environment/${environment.api_key}/features${location.search}`, ) } else { history.replace(`/project/${projectId}/environment/create`) } - }, [data, error, history]) + }, [data, error, history, location.search]) return (
diff --git a/frontend/web/components/pages/features/FeaturesPage.tsx b/frontend/web/components/pages/features/FeaturesPage.tsx index 2c414c767c73..f0afc2994230 100644 --- a/frontend/web/components/pages/features/FeaturesPage.tsx +++ b/frontend/web/components/pages/features/FeaturesPage.tsx @@ -19,6 +19,7 @@ import { FeaturesTableFilters, FeaturesSDKIntegration, } from './components' +import ImportSuccessBanner from 'components/import-export/ImportSuccessBanner' import { useFeatureFilters } from './hooks/useFeatureFilters' import { useRemoveFeatureWithToast } from './hooks/useRemoveFeatureWithToast' import { useToggleFeatureWithToast } from './hooks/useToggleFeatureWithToast' @@ -323,6 +324,11 @@ const FeaturesPage: FC = () => { projectId={projectId} /> + +