diff --git a/src/api/audiences.ts b/src/api/audiences.ts new file mode 100644 index 000000000..7322d589a --- /dev/null +++ b/src/api/audiences.ts @@ -0,0 +1,16 @@ +import apiClient from './apiClient' +import { buildHeaders } from './common' + +const BASE_URL = '/v1/projects/:project/audiences' + +export const fetchAudiences = async ( + token: string, + project_id: string +) => { + return apiClient.get(`${BASE_URL}`, { + headers: buildHeaders(token), + params: { + project: project_id, + } + }) +} \ No newline at end of file diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 96b8a5270..ff5e7752a 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -9,6 +9,7 @@ export type Variation = z.infer export type Feature = z.infer export type FeatureConfig = z.infer export type Audience = z.infer +export type Target = z.infer export type CreateEnvironmentParams = z.infer export const CreateEnvironmentDto = schemas.CreateEnvironmentDto @@ -42,26 +43,26 @@ export const UpdateTargetDto = schemas.UpdateTargetDto export type AudienceOperatorWithAudienceMatchFilter = z.infer export type Filters = z.infer -type AllFilter = z.infer -export const AllFilter = schemas.AllFilter +export type AllFilter = z.infer +export const AllFilterSchema = schemas.AllFilter -type UserFilter = z.infer -export const UserFilter = schemas.UserFilter +export type UserFilter = z.infer +export const UserFilterSchema = schemas.UserFilter -type UserCountryFilter = z.infer -export const UserCountryFilter = schemas.UserCountryFilter +export type UserCountryFilter = z.infer +export const UserCountryFilterSchema = schemas.UserCountryFilter -type UserAppVersionFilter = z.infer -export const UserAppVersionFilter = schemas.UserAppVersionFilter +export type UserAppVersionFilter = z.infer +export const UserAppVersionFilterSchema = schemas.UserAppVersionFilter -type UserPlatformVersionFilter = z.infer -export const UserPlatformVersionFilter = schemas.UserPlatformVersionFilter +export type UserPlatformVersionFilter = z.infer +export const UserPlatformVersionFilterSchema = schemas.UserPlatformVersionFilter -type UserCustomFilter = z.infer -export const UserCustomFilter = schemas.UserCustomFilter +export type UserCustomFilter = z.infer +export const UserCustomFilterSchema = schemas.UserCustomFilter -type AudienceMatchFilter = z.infer -export const AudienceMatchFilter = schemas.AudienceMatchFilter +export type AudienceMatchFilter = z.infer +export const AudienceMatchFilterSchema = schemas.AudienceMatchFilter export type Filter = | AllFilter diff --git a/src/commands/targeting/disable.ts b/src/commands/targeting/disable.ts index 5684e4551..e40e56591 100644 --- a/src/commands/targeting/disable.ts +++ b/src/commands/targeting/disable.ts @@ -4,6 +4,7 @@ import { Variation } from '../../api/schemas' import Base from '../base' import { renderTargetingTree } from '../../ui/targetingTree' import { getFeatureAndEnvironmentKeyFromArgs } from '../../utils/targeting' +import { fetchAudiences } from '../../api/audiences' export default class DisableTargeting extends Base { static hidden = false @@ -45,10 +46,12 @@ export default class DisableTargeting extends Base { if (flags.headless) { this.writer.showResults(updatedTargeting) } else { + const audiences = await fetchAudiences(this.authToken, this.projectKey) renderTargetingTree( - [updatedTargeting], + [updatedTargeting], [environment], - feature.variations as Variation[] + feature.variations as Variation[], + audiences ) } } diff --git a/src/commands/targeting/enable.ts b/src/commands/targeting/enable.ts index ad42505a2..a46829b75 100644 --- a/src/commands/targeting/enable.ts +++ b/src/commands/targeting/enable.ts @@ -4,6 +4,7 @@ import { Variation } from '../../api/schemas' import { renderTargetingTree } from '../../ui/targetingTree' import Base from '../base' import { getFeatureAndEnvironmentKeyFromArgs } from '../../utils/targeting' +import { fetchAudiences } from '../../api/audiences' export default class EnableTargeting extends Base { static hidden = false @@ -45,10 +46,12 @@ export default class EnableTargeting extends Base { if (flags.headless) { this.writer.showResults(updatedTargeting) } else { + const audiences = await fetchAudiences(this.authToken, this.projectKey) renderTargetingTree( - [updatedTargeting], + [updatedTargeting], [environment], - feature.variations as Variation[] + feature.variations as Variation[], + audiences ) } } diff --git a/src/commands/targeting/get.test.ts b/src/commands/targeting/get.test.ts index 38fc605ee..ac34ecba3 100644 --- a/src/commands/targeting/get.test.ts +++ b/src/commands/targeting/get.test.ts @@ -127,6 +127,10 @@ describe('targeting get', () => { .get(`/v1/projects/${projectKey}/environments`) .reply(200, mockEnvironments) ) + .nock(BASE_URL, (api) => api + .get(`/v1/projects/${projectKey}/audiences`) + .reply(200, []) + ) .stdout() .command(['targeting get', featureKey, ...authFlags]) .it('returns all targeting for a feature', (ctx) => { @@ -146,6 +150,10 @@ describe('targeting get', () => { .get(`/v1/projects/${projectKey}/environments`) .reply(200, mockEnvironments) ) + .nock(BASE_URL, (api) => api + .get(`/v1/projects/${projectKey}/audiences`) + .reply(200, []) + ) .stdout() .command(['targeting get', featureKey, 'development', ...authFlags]) .it('includes environment in query params', @@ -166,6 +174,10 @@ describe('targeting get', () => { .get(`/v1/projects/${projectKey}/environments`) .reply(200, mockEnvironments) ) + .nock(BASE_URL, (api) => api + .get(`/v1/projects/${projectKey}/audiences`) + .reply(200, []) + ) .stub(inquirer, 'prompt', () => { return { feature: { key: 'prompted-feature-id' } } }) diff --git a/src/commands/targeting/get.ts b/src/commands/targeting/get.ts index b42530fdb..34e2ce384 100644 --- a/src/commands/targeting/get.ts +++ b/src/commands/targeting/get.ts @@ -3,10 +3,16 @@ import inquirer from '../../ui/autocomplete' import { fetchEnvironments } from '../../api/environments' import { fetchVariations } from '../../api/variations' import { fetchTargetingForFeature } from '../../api/targeting' -import { environmentPrompt, EnvironmentPromptResult, featurePrompt, FeaturePromptResult } from '../../ui/prompts' +import { + environmentPrompt, + EnvironmentPromptResult, + featurePrompt, + FeaturePromptResult +} from '../../ui/prompts' import { renderTargetingTree } from '../../ui/targetingTree' import Base from '../base' import { Feature, Environment } from '../../api/schemas' +import { fetchAudiences } from '../../api/audiences' type Params = { featureKey?: string, @@ -83,7 +89,13 @@ export default class DetailedTargeting extends Base { [params.environment] : await fetchEnvironments(this.authToken, this.projectKey) const variations = params.feature?.variations || await fetchVariations(this.authToken, this.projectKey, params.featureKey) - renderTargetingTree(targeting, environments, variations) + const audiences = await fetchAudiences(this.authToken, this.projectKey) + renderTargetingTree( + targeting, + environments, + variations, + audiences + ) } } } diff --git a/src/commands/targeting/update.test.ts b/src/commands/targeting/update.test.ts index 3ee5bce8c..f78c89017 100644 --- a/src/commands/targeting/update.test.ts +++ b/src/commands/targeting/update.test.ts @@ -203,6 +203,10 @@ describe('targeting update', () => { .query({ environment: envKey }) .reply(200, mockResponseHeadless) ) + .nock(BASE_URL, (api) => api + .get(`/v1/projects/${projectKey}/audiences`) + .reply(200, []) + ) .stdout() .command([ 'targeting update', @@ -275,6 +279,10 @@ describe('targeting update', () => { .query({ environment: envKey }) .reply(200, mockTargetingRules) ) + .nock(BASE_URL, (api) => api + .get(`/v1/projects/${projectKey}/audiences`) + .reply(200, []) + ) .nock(BASE_URL, (api) => api .patch( `/v1/projects/${projectKey}/features/${featureKey}/configurations`, diff --git a/src/commands/targeting/update.ts b/src/commands/targeting/update.ts index e92f1260f..9c66934e7 100644 --- a/src/commands/targeting/update.ts +++ b/src/commands/targeting/update.ts @@ -22,6 +22,7 @@ import { fetchVariations } from '../../api/variations' import { FeatureConfig, UpdateFeatureConfigDto } from '../../api/schemas' import { targetingStatusPrompt } from '../../ui/prompts/targetingPrompts' import UpdateCommand from '../updateCommand' +import { fetchAudiences } from '../../api/audiences' export default class UpdateTargeting extends UpdateCommand { static hidden = false @@ -86,6 +87,7 @@ export default class UpdateTargeting extends UpdateCommand { const environment = await fetchEnvironmentByKey(this.authToken, this.projectKey, envKey) const variations = await fetchVariations(this.authToken, this.projectKey, featureKey) + const audiences = await fetchAudiences(this.authToken, this.projectKey) const status = this.convertStatusFlagValue(flags.status) let targets: UpdateFeatureConfigDto['targets'] @@ -111,7 +113,12 @@ export default class UpdateTargeting extends UpdateCommand { this.authToken, this.projectKey, featureKey, envKey ) const targetingListPrompt = new TargetingListOptions( - featureTargetingRules.targets, this.writer, this.authToken, this.projectKey, featureKey + featureTargetingRules.targets, + audiences, + this.writer, + this.authToken, + this.projectKey, + featureKey ) targetingListPrompt.variations = variations this.prompts.push(targetingListPrompt.getTargetingListPrompt()) @@ -137,7 +144,8 @@ export default class UpdateTargeting extends UpdateCommand { renderTargetingTree( [result], environment ? [environment] : [], - variations + variations, + audiences ) this.showSuggestedCommand(featureKey, envKey, result) } diff --git a/src/ui/prompts/listPrompts/filterListPrompt.ts b/src/ui/prompts/listPrompts/filterListPrompt.ts index c8932d61f..001e4b32d 100644 --- a/src/ui/prompts/listPrompts/filterListPrompt.ts +++ b/src/ui/prompts/listPrompts/filterListPrompt.ts @@ -14,12 +14,20 @@ import { Prompt } from '../types' import { chooseFields } from '../../../utils/prompts' import { UserSubType } from '../../../api/targeting' import { renderDefinitionTree } from '../../targetingTree' +import { buildAudienceNameMap, replaceAudienceIdInFilter } from '../../../utils/audiences' +import Writer from '../../writer' export class FilterListOptions extends ListOptionsPrompt { itemType = 'Filter' messagePrompt = 'Manage your filters' operator: Audience['filters']['operator'] = 'and' + audiences: Audience[] + + constructor(list: Filter[], audiences: Audience[], writer: Writer) { + super(list, writer) + this.audiences = audiences + } async promptAddItem(): Promise> { const { type } = await inquirer.prompt([filterTypePrompt]) @@ -170,7 +178,7 @@ export class FilterListOptions extends ListOptionsPrompt { return [] } - async printListOptions(list?: ListOption[]) { + printListOptions(list?: ListOption[]) { const listToPrint = list || this.list if (listToPrint.length === 0) { this.writer.infoMessage(`No existing ${this.itemType}s.`) @@ -180,7 +188,7 @@ export class FilterListOptions extends ListOptionsPrompt { this.writer.title(this.messagePrompt) this.writer.infoMessage(`Current ${this.itemType}s:`) const values = listToPrint.map((item) => item.value.item) - renderDefinitionTree(values, this.operator) + renderDefinitionTree(values, this.operator, this.audiences) this.writer.blankLine() } } \ No newline at end of file diff --git a/src/ui/prompts/listPrompts/listOptionsPrompt.ts b/src/ui/prompts/listPrompts/listOptionsPrompt.ts index 231712570..52f5e78f5 100644 --- a/src/ui/prompts/listPrompts/listOptionsPrompt.ts +++ b/src/ui/prompts/listPrompts/listOptionsPrompt.ts @@ -164,7 +164,7 @@ export abstract class ListOptionsPrompt { * Prints the list of human-readable names of the list to the console * @param ListOption[] */ - async printListOptions(list?: ListOption[]) { + printListOptions(list?: ListOption[]) { const listToPrint = list || this.list if (listToPrint.length === 0) { this.writer.infoMessage(`No existing ${this.itemType}s.`) diff --git a/src/ui/prompts/listPrompts/targetingListPrompt.ts b/src/ui/prompts/listPrompts/targetingListPrompt.ts index 45d1faded..a7330df40 100644 --- a/src/ui/prompts/listPrompts/targetingListPrompt.ts +++ b/src/ui/prompts/listPrompts/targetingListPrompt.ts @@ -9,7 +9,7 @@ import { ReorderItemPrompt } from './promptOptions' import { servePrompt } from '../targetingPrompts' -import { UpdateTargetParams, Variation } from '../../../api/schemas' +import { Audience, Filters, UpdateTargetParams, Variation } from '../../../api/schemas' import { FilterListOptions } from './filterListPrompt' import Writer from '../../writer' import { renderRulesTree } from '../../targetingTree' @@ -23,9 +23,18 @@ export class TargetingListOptions extends ListOptionsPrompt featureKey: string variations: Variation[] = [] + audiences: Audience[] - constructor(list: UpdateTargetParams[], writer: Writer, authToken: string, projectKey: string, featureKey: string) { + constructor( + list: UpdateTargetParams[], + audiences: Audience[], + writer: Writer, + authToken: string, + projectKey: string, + featureKey: string + ) { super(list, writer) + this.audiences = audiences this.featureKey = featureKey this.authToken = authToken this.projectKey = projectKey @@ -57,7 +66,7 @@ export class TargetingListOptions extends ListOptionsPrompt featureKey: this.featureKey }) const operator = 'and' - const filterListOptions = new FilterListOptions([], this.writer) + const filterListOptions = new FilterListOptions([], this.audiences, this.writer) filterListOptions.operator = operator const filters = await filterListOptions.prompt() const target = { @@ -103,7 +112,11 @@ export class TargetingListOptions extends ListOptionsPrompt projectKey: this.projectKey, featureKey: this.featureKey }) - const filterListOptions = new FilterListOptions(targetToEdit.audience.filters.filters, this.writer) + const filterListOptions = new FilterListOptions( + targetToEdit.audience.filters.filters, + this.audiences, + this.writer + ) filterListOptions.operator = targetToEdit.audience.filters.operator const filters = await filterListOptions.prompt() const target = { @@ -125,7 +138,7 @@ export class TargetingListOptions extends ListOptionsPrompt })) } - async printListOptions(list?: ListOption[]) { + printListOptions(list?: ListOption[]) { const listToPrint = list || this.list if (listToPrint.length === 0) { this.writer.infoMessage(`No existing ${this.itemType}s.`) @@ -135,7 +148,7 @@ export class TargetingListOptions extends ListOptionsPrompt this.writer.title(this.messagePrompt) this.writer.infoMessage(`Current ${this.itemType}s:`) const values = listToPrint.map((item) => item.value.item) - renderRulesTree(values, this.variations) + renderRulesTree(values, this.variations, this.audiences) this.writer.blankLine() } } \ No newline at end of file diff --git a/src/ui/targetingTree.ts b/src/ui/targetingTree.ts index 38091792d..b05909e8c 100644 --- a/src/ui/targetingTree.ts +++ b/src/ui/targetingTree.ts @@ -1,8 +1,9 @@ import { ux } from '@oclif/core' import { Tree } from '@oclif/core/lib/cli-ux/styled/tree' -import { FeatureConfig, Environment, Variation } from '../api/schemas' +import { FeatureConfig, Environment, Variation, Audience as AudienceSchema } from '../api/schemas' import chalk from 'chalk' import { COLORS } from './constants/colors' +import { buildAudienceNameMap, replaceAudienceIdInFilter } from '../utils/audiences' type Distribution = FeatureConfig['targets'][0]['distribution'] type Audience = FeatureConfig['targets'][0]['audience'] @@ -36,15 +37,15 @@ const comparatorMap = { export const renderTargetingTree = ( featureConfigs: FeatureConfig[], environments: Environment[], - variations: Variation[] + variations: Variation[], + audiences: AudienceSchema[] ) => { const targetingTree = ux.tree() - featureConfigs.forEach((config) => { const environmentTree = ux.tree() insertStatusTree(environmentTree, config.status) - insertRulesTree(environmentTree, config.targets, variations) + insertRulesTree(environmentTree, config.targets, variations, audiences) const environmentName = environments.find((env) => env._id === config._environment)?.name targetingTree.insert(environmentName || config._environment, environmentTree) @@ -54,17 +55,19 @@ export const renderTargetingTree = ( export const renderRulesTree = ( targets: Rule[], - variations: Variation[] + variations: Variation[], + audiences: AudienceSchema[] ) => { - const rulesTree = buildRulesTree(targets, variations) + const rulesTree = buildRulesTree(targets, variations, audiences) rulesTree.display() } export const renderDefinitionTree = ( filters: Filters, - operator: Operator + operator: Operator, + audiences: AudienceSchema[] ) => { - const definitionTree = buildDefinitionTree(filters, operator) + const definitionTree = buildDefinitionTree(filters, operator, audiences) definitionTree.display() } @@ -77,12 +80,12 @@ const insertStatusTree = (rootTree: Tree, status: FeatureConfig['status']) => { rootTree.insert(statusTitle, statusTree) } -const buildRulesTree = (targets: Rule[], variations: Variation[]) => { +const buildRulesTree = (targets: Rule[], variations: Variation[], audiences: AudienceSchema[]) => { const rulesTree = ux.tree() targets.forEach((target, idx) => { const ruleTree = ux.tree() - insertDefinitionTree(ruleTree, target.audience) + insertDefinitionTree(ruleTree, target.audience, audiences) insertServeTree(ruleTree, target.distribution, variations) insertScheduleTree(ruleTree, target.rollout) @@ -91,15 +94,20 @@ const buildRulesTree = (targets: Rule[], variations: Variation[]) => { return rulesTree } -const insertRulesTree = (rootTree: Tree, targets: Rule[], variations: Variation[]) => { +const insertRulesTree = ( + rootTree: Tree, + targets: Rule[], + variations: Variation[], + audiences: AudienceSchema[] +) => { if (!targets.length) return - const rulesTree = buildRulesTree(targets, variations) + const rulesTree = buildRulesTree(targets, variations, audiences) const rulesTitle = coloredTitle('rules') rootTree.insert(rulesTitle, rulesTree) } -const buildDefinitionTree = (filters: Filters, operator: Operator) => { +const buildDefinitionTree = (filters: Filters, operator: Operator, audiences: AudienceSchema[]) => { const definitionTree = ux.tree() const prefixWithOperator = (value: string, index: number) => ( index !== 0 ? `${operator.toUpperCase()} ${value}` : value @@ -120,16 +128,22 @@ const buildDefinitionTree = (filters: Filters, operator: Operator) => { const prefixedProperty = prefixWithOperator(userProperty, index) definitionTree.insert(prefixedProperty, userFilter) - } else { - // TODO handle audienceMatch + } else if (filter.type === 'audienceMatch') { + const audienceFilter = ux.tree() + const audienceTree = ux.tree() + const audienceMap = buildAudienceNameMap(audiences) + const replacedIdFilter = (replaceAudienceIdInFilter(filter, audienceMap) || filter) as typeof filter + replacedIdFilter._audiences?.forEach((audience) => audienceTree.insert(audience)) + audienceFilter.insert(comparatorMap[filter.comparator!], audienceTree) + definitionTree.insert(prefixWithOperator('Audience', index), audienceFilter) } }) return definitionTree } -const insertDefinitionTree = (rootTree: Tree, audience: Audience) => { +const insertDefinitionTree = (rootTree: Tree, audience: Audience, audiences: AudienceSchema[]) => { const { filters, operator } = audience.filters - const definitionTree = buildDefinitionTree(filters, operator) + const definitionTree = buildDefinitionTree(filters, operator, audiences) const definitionTitle = coloredTitle('definition') rootTree.insert(definitionTitle, definitionTree) } diff --git a/src/utils/audiences/index.ts b/src/utils/audiences/index.ts new file mode 100644 index 000000000..c0a04105b --- /dev/null +++ b/src/utils/audiences/index.ts @@ -0,0 +1,21 @@ +import { Audience, AudienceMatchFilter, FeatureConfig, Filter, Target, UpdateTargetParams } from '../../api/schemas' + +export const buildAudienceNameMap = (audiences?: Audience[]) => { + return audiences?.reduce((acc, audience) => { + acc[audience._id] = audience.name || audience.key || audience._id + return acc + }, {} as Record) || {} +} + +export const replaceAudienceIdInFilter = ( + filter: Filter, + audienceNameMap: Record +) => { + const newFilter = structuredClone(filter) as typeof filter + if (newFilter.type === 'audienceMatch') { + const audienceIds = newFilter._audiences! + const audienceNames = audienceIds.map((audienceId) => audienceNameMap[audienceId] || audienceId) + newFilter._audiences = audienceNames + } + return newFilter +} diff --git a/src/utils/audiences/utils.test.ts b/src/utils/audiences/utils.test.ts new file mode 100644 index 000000000..a13b03823 --- /dev/null +++ b/src/utils/audiences/utils.test.ts @@ -0,0 +1,54 @@ +import { expect } from '@oclif/test' +import { buildAudienceNameMap, replaceAudienceIdInFilter } from '.' +import { Audience, FeatureConfig, Filter, AudienceMatchFilter } from '../../api/schemas' + +describe('Audience Utils Test', () => { + + const audiences = [ + { + _id: '1', + name: 'Audience 1' + }, + { + _id: '2', + name: 'Audience 2' + }, + ] + const filters = [ + { + 'type': 'audienceMatch', + '_audiences': [ + '1' + ], + 'comparator': '=' + }, + { + 'type': 'user', + 'subType': 'email', + 'values': [ + 'new@email.com' + ], + 'comparator': '=' + } + ] + const audienceNameMap = buildAudienceNameMap(audiences as Audience[]) + + it('should build a map of audience ids to audience names', () => { + expect(audienceNameMap).to.deep.equal({ + '1': 'Audience 1', + '2': 'Audience 2' + }) + }) + + it('should replace audience ids with audience names', async () => { + const replacedIdFilter = + replaceAudienceIdInFilter(filters[0] as AudienceMatchFilter, audienceNameMap) as AudienceMatchFilter + expect(replacedIdFilter._audiences).to.deep.equal(['Audience 1']) + }) + + it('should not replace audience ids if no audience found', async () => { + const noneReplacedIdFilter = + replaceAudienceIdInFilter(filters[0] as AudienceMatchFilter, {}) as AudienceMatchFilter + expect(noneReplacedIdFilter._audiences).to.deep.equal(['1']) + }) +}) \ No newline at end of file