Skip to content

Commit

Permalink
feat(feature flags): use organization flags API in the UI (#18477)
Browse files Browse the repository at this point in the history
Co-authored-by: Neil Kakkar <[email protected]>
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
  • Loading branch information
3 people authored Nov 8, 2023
1 parent 51192be commit 292e3db
Show file tree
Hide file tree
Showing 5 changed files with 148 additions and 23 deletions.
31 changes: 31 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
ExportedAssetType,
FeatureFlagAssociatedRoleType,
FeatureFlagType,
OrganizationFeatureFlags,
OrganizationFeatureFlagsCopyBody,
InsightModel,
IntegrationType,
MediaUploadResponse,
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -674,6 +690,21 @@ const api = {
},
},

organizationFeatureFlags: {
async get(
orgId: OrganizationType['id'] = getCurrentOrganizationId(),
featureFlagKey: FeatureFlagType['key']
): Promise<OrganizationFeatureFlags> {
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<ActionType> {
return await new ApiRequest().actionsDetail(actionId).get()
Expand Down
18 changes: 9 additions & 9 deletions frontend/src/scenes/feature-flags/FeatureFlag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: <FeatureFlagProjects />,
})
}

if (featureFlags[FEATURE_FLAGS.FF_DASHBOARD_TEMPLATES] && featureFlag.key && id) {
tabs.push({
label: (
Expand Down Expand Up @@ -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: <FeatureFlagProjects />,
})
}

return (
<>
<div className="feature-flag">
Expand Down
60 changes: 47 additions & 13 deletions frontend/src/scenes/feature-flags/FeatureFlagProjects.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>> => {
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 (
<Link
className="row-name"
onClick={() => {
updateCurrentTeam(team.id, `/feature_flags/${record.flag_id}`)
}}
>
{linkText}
</Link>
)
},
},
{
title: 'Flag status',
dataIndex: 'active',
render: (dataValue) => {
return dataValue ? 'active' : 'disabled'
return dataValue ? (
<LemonTag type="success" className="uppercase">
Enabled
</LemonTag>
) : (
<LemonTag type="default" className="uppercase">
Disabled
</LemonTag>
)
},
},
]
}

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)

Expand All @@ -56,6 +83,7 @@ export default function FeatureFlagProjects(): JSX.Element {
<div>
<div className="font-semibold leading-6 h-6">Destination project</div>
<LemonSelect
dropdownMatchSelectWidth={false}
value={copyDestinationProject}
onChange={(id) => setCopyDestinationProject(id)}
options={
Expand All @@ -68,14 +96,20 @@ export default function FeatureFlagProjects(): JSX.Element {
</div>
<div>
<div className="h-6" />
<LemonButton type="primary" icon={<IconSync />}>
Copy
<LemonButton
disabledReason={!copyDestinationProject && 'Select destination project'}
loading={featureFlagCopyLoading}
type="primary"
icon={<IconSync />}
onClick={() => copyFlag()}
className="w-28 max-w-28"
>
{projectsWithCurrentFlag.find((p) => Number(p.team_id) === copyDestinationProject)
? 'Update'
: 'Copy'}
</LemonButton>
</div>
</div>
<LemonBanner type="warning" className="mb-6">
By performing the copy, you may overwrite your existing Feature Flag configuration in another project.
</LemonBanner>
<LemonTable
loading={false}
dataSource={projectsWithCurrentFlag}
Expand Down
50 changes: 49 additions & 1 deletion frontend/src/scenes/feature-flags/featureFlagLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@ export interface FeatureFlagLogicProps {
id: number | 'new' | 'link'
}

export type ProjectsWithCurrentFlagResponse = {
flag_id: number
team_id: number
active: boolean
}[]

// KLUDGE: Payloads are returned in a <variant-key>: <payload> 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 <variant-key-index>: <payload>
Expand Down Expand Up @@ -579,7 +585,34 @@ export const featureFlagLogic = kea<featureFlagLogicType>([
projectsWithCurrentFlag: {
__default: [] as Record<string, string>[],
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],
})
}
},
},
})),
Expand Down Expand Up @@ -742,6 +775,21 @@ export const featureFlagLogic = kea<featureFlagLogicType>([
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],
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2251,6 +2251,18 @@ export interface FeatureFlagType extends Omit<FeatureFlagBasicType, 'id' | 'team
has_enriched_analytics?: boolean
}

export interface OrganizationFeatureFlagsCopyBody {
feature_flag_key: FeatureFlagType['key']
from_project: TeamType['id']
target_project_ids: TeamType['id'][]
}

export type OrganizationFeatureFlags = {
flag_id: FeatureFlagType['id']
team_id: TeamType['id']
active: FeatureFlagType['active']
}[]

export interface FeatureFlagRollbackConditions {
threshold: number
threshold_type: string
Expand Down

0 comments on commit 292e3db

Please sign in to comment.