diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 63e4d08fd2d8f..805c8c3262ca5 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -19,6 +19,8 @@ import { ExportedAssetType, FeatureFlagAssociatedRoleType, FeatureFlagType, + OrganizationFeatureFlags, + OrganizationFeatureFlagsCopyBody, InsightModel, IntegrationType, MediaUploadResponse, @@ -178,6 +180,20 @@ class ApiRequest { return this.organizationResourceAccess().addPathComponent(id) } + public organizationFeatureFlags(orgId: OrganizationType['id'], featureFlagKey: FeatureFlagType['key']): ApiRequest { + return this.organizations() + .addPathComponent(orgId) + .addPathComponent('feature_flags') + .addPathComponent(featureFlagKey) + } + + public copyOrganizationFeatureFlags(orgId: OrganizationType['id']): ApiRequest { + return this.organizations() + .addPathComponent(orgId) + .addPathComponent('feature_flags') + .addPathComponent('copy_flags') + } + // # Projects public projects(): ApiRequest { return this.addPathComponent('projects') @@ -674,6 +690,21 @@ const api = { }, }, + organizationFeatureFlags: { + async get( + orgId: OrganizationType['id'] = getCurrentOrganizationId(), + featureFlagKey: FeatureFlagType['key'] + ): Promise { + return await new ApiRequest().organizationFeatureFlags(orgId, featureFlagKey).get() + }, + async copy( + orgId: OrganizationType['id'] = getCurrentOrganizationId(), + data: OrganizationFeatureFlagsCopyBody + ): Promise<{ success: FeatureFlagType[]; failed: any }> { + return await new ApiRequest().copyOrganizationFeatureFlags(orgId).create({ data }) + }, + }, + actions: { async get(actionId: ActionType['id']): Promise { return await new ApiRequest().actionsDetail(actionId).get() diff --git a/frontend/src/scenes/feature-flags/FeatureFlag.tsx b/frontend/src/scenes/feature-flags/FeatureFlag.tsx index 18805d37a2fbc..8e5bf5fc6da14 100644 --- a/frontend/src/scenes/feature-flags/FeatureFlag.tsx +++ b/frontend/src/scenes/feature-flags/FeatureFlag.tsx @@ -156,6 +156,15 @@ export function FeatureFlag({ id }: { id?: string } = {}): JSX.Element { }) } + const hasMultipleProjects = (currentOrganization?.teams?.length ?? 0) > 1 + if (featureFlags[FEATURE_FLAGS.MULTI_PROJECT_FEATURE_FLAGS] && hasMultipleProjects) { + tabs.push({ + label: 'Projects', + key: FeatureFlagsTab.PROJECTS, + content: , + }) + } + if (featureFlags[FEATURE_FLAGS.FF_DASHBOARD_TEMPLATES] && featureFlag.key && id) { tabs.push({ label: ( @@ -206,15 +215,6 @@ export function FeatureFlag({ id }: { id?: string } = {}): JSX.Element { }) } - const hasMultipleProjects = (currentOrganization?.teams?.length ?? 0) > 1 - if (featureFlags[FEATURE_FLAGS.MULTI_PROJECT_FEATURE_FLAGS] && hasMultipleProjects) { - tabs.push({ - label: 'Projects', - key: FeatureFlagsTab.PROJECTS, - content: , - }) - } - return ( <>
diff --git a/frontend/src/scenes/feature-flags/FeatureFlagProjects.tsx b/frontend/src/scenes/feature-flags/FeatureFlagProjects.tsx index cdf57d7e06aaa..d7410b7f20e84 100644 --- a/frontend/src/scenes/feature-flags/FeatureFlagProjects.tsx +++ b/frontend/src/scenes/feature-flags/FeatureFlagProjects.tsx @@ -1,36 +1,63 @@ import { LemonTable, LemonTableColumns } from 'lib/lemon-ui/LemonTable' -import { LemonButton, LemonSelect } from '@posthog/lemon-ui' +import { LemonButton, LemonSelect, LemonTag, Link } from '@posthog/lemon-ui' import { IconArrowRight, IconSync } from 'lib/lemon-ui/icons' import { useActions, useValues } from 'kea' -import { LemonBanner } from 'lib/lemon-ui/LemonBanner' import { featureFlagLogic } from './featureFlagLogic' import { organizationLogic } from '../organizationLogic' import { teamLogic } from 'scenes/teamLogic' +import { userLogic } from 'scenes/userLogic' import { useEffect } from 'react' const getColumns = (): LemonTableColumns> => { const { currentTeamId } = useValues(teamLogic) + const { currentOrganization } = useValues(organizationLogic) + const { updateCurrentTeam } = useActions(userLogic) return [ { title: 'Project', - dataIndex: 'project_name', - render: (dataValue, record) => - Number(record.project_id) === currentTeamId ? `${dataValue} (current)` : dataValue, + dataIndex: 'team_id', + render: (dataValue, record) => { + const team = currentOrganization?.teams?.find((t) => t.id === Number(dataValue)) + if (!team) { + return '(project does not exist)' + } + const linkText = team.id === currentTeamId ? `${team.name} (current)` : team.name + + return ( + { + updateCurrentTeam(team.id, `/feature_flags/${record.flag_id}`) + }} + > + {linkText} + + ) + }, }, { title: 'Flag status', dataIndex: 'active', render: (dataValue) => { - return dataValue ? 'active' : 'disabled' + return dataValue ? ( + + Enabled + + ) : ( + + Disabled + + ) }, }, ] } export default function FeatureFlagProjects(): JSX.Element { - const { featureFlag, copyDestinationProject, projectsWithCurrentFlag } = useValues(featureFlagLogic) - const { setCopyDestinationProject, loadProjectsWithCurrentFlag } = useActions(featureFlagLogic) + const { featureFlag, copyDestinationProject, projectsWithCurrentFlag, featureFlagCopyLoading } = + useValues(featureFlagLogic) + const { setCopyDestinationProject, loadProjectsWithCurrentFlag, copyFlag } = useActions(featureFlagLogic) const { currentOrganization } = useValues(organizationLogic) const { currentTeam } = useValues(teamLogic) @@ -56,6 +83,7 @@ export default function FeatureFlagProjects(): JSX.Element {
Destination project
setCopyDestinationProject(id)} options={ @@ -68,14 +96,20 @@ export default function FeatureFlagProjects(): JSX.Element {
- }> - Copy + } + onClick={() => copyFlag()} + className="w-28 max-w-28" + > + {projectsWithCurrentFlag.find((p) => Number(p.team_id) === copyDestinationProject) + ? 'Update' + : 'Copy'}
- - By performing the copy, you may overwrite your existing Feature Flag configuration in another project. - : mapping. // This doesn't work for forms because variant-keys can be updated too which would invalidate the dictionary entry. // If a multivariant flag is returned, the payload dictionary will be transformed to be : @@ -579,7 +585,34 @@ export const featureFlagLogic = kea([ projectsWithCurrentFlag: { __default: [] as Record[], loadProjectsWithCurrentFlag: async () => { - return [] + const orgId = values.currentOrganization?.id + const flagKey = values.featureFlag.key + + const projects = await api.organizationFeatureFlags.get(orgId, flagKey) + + // Put current project first + const currentProjectIdx = projects.findIndex((p) => p.team_id === values.currentTeamId) + if (currentProjectIdx) { + const [currentProject] = projects.splice(currentProjectIdx, 1) + const sortedProjects = [currentProject, ...projects] + return sortedProjects + } + return projects + }, + }, + featureFlagCopy: { + copyFlag: async () => { + const orgId = values.currentOrganization?.id + const featureFlagKey = values.featureFlag.key + const { copyDestinationProject, currentTeamId } = values + + if (currentTeamId && copyDestinationProject) { + return await api.organizationFeatureFlags.copy(orgId, { + feature_flag_key: featureFlagKey, + from_project: currentTeamId, + target_project_ids: [copyDestinationProject], + }) + } }, }, })), @@ -742,6 +775,21 @@ export const featureFlagLogic = kea([ featureFlagsLogic.findMounted()?.actions.updateFlag(updatedFlag) } }, + copyFlagSuccess: ({ featureFlagCopy }) => { + if (featureFlagCopy?.success.length) { + const operation = values.projectsWithCurrentFlag.find( + (p) => Number(p.team_id) === values.copyDestinationProject + ) + ? 'updated' + : 'copied' + lemonToast.success(`Feature flag ${operation} successfully!`) + } else { + lemonToast.error(`Error while saving feature flag: ${featureFlagCopy?.failed || featureFlagCopy}`) + } + + actions.loadProjectsWithCurrentFlag() + actions.setCopyDestinationProject(null) + }, })), selectors({ sentryErrorCount: [(s) => [s.sentryStats], (stats) => stats.total_count], diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c515f2e22af3d..fa7bcc592299c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -2251,6 +2251,18 @@ export interface FeatureFlagType extends Omit