Skip to content

Commit 5122006

Browse files
authored
Merge pull request #28 from Flagsmith/26-support-multiple-projects
Add support for multiple projects
2 parents 293e670 + bae9697 commit 5122006

File tree

8 files changed

+296
-148
lines changed

8 files changed

+296
-148
lines changed

flagsmith-jira-app/src/backend/flagsmith.ts

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ type PaginatedModels<TModel extends Model> = {
1717

1818
export type Organisation = Model;
1919
export type Project = Model;
20-
export type Environment = Model & { api_key: string };
20+
export type Environment = Model & { api_key: string; project: number };
2121

2222
export type Feature = Model & {
2323
id: number | string;
@@ -27,6 +27,8 @@ export type Feature = Model & {
2727
multivariate_options: unknown[];
2828
num_segment_overrides: number | null;
2929
num_identity_overrides: number | null;
30+
project: number;
31+
project_name: string;
3032
};
3133

3234
export type EnvironmentFeatureState = Model & {
@@ -157,22 +159,63 @@ export const readEnvironments: ReadEnvironments = async ({ projectId }) => {
157159
};
158160

159161
export type ReadFeatures = (args: {
160-
projectId?: string;
162+
projectIds?: string[];
161163
environmentId?: string;
162164
}) => Promise<Feature[]>;
163165

164166
/** Read Flagsmith Features for stored API Key, given Project ID and optional Environment ID */
165-
export const readFeatures: ReadFeatures = async ({ projectId, environmentId }) => {
167+
export const readFeatures: ReadFeatures = async ({ projectIds, environmentId }) => {
166168
const apiKey = await readApiKey();
167169
checkApiKey(apiKey);
168-
if (!projectId) throw new ApiError("Flagsmith project not connected", 400);
169-
const params = new URLSearchParams({ is_archived: "false" });
170-
if (environmentId) params.set("environment", environmentId);
171-
const path = route`/projects/${projectId}/features/?${params}`;
172-
const data = (await flagsmithApi(apiKey, path)) as PaginatedModels<Feature>;
173-
const results = await unpaginate(apiKey, data);
174-
if (results.length === 0) throw new ApiError("Flagsmith project has no features", 404);
175-
return results;
170+
171+
if (!projectIds || projectIds.length === 0) {
172+
throw new ApiError("Flagsmith project not connected", 400);
173+
}
174+
175+
const allFeatures: Feature[] = [];
176+
const organisationId = await readOrganisationId();
177+
178+
// TODO: consider ways to avoid multiple API calls
179+
// relates to https://github.com/Flagsmith/flagsmith-jira-integration/issues/22
180+
// When an RBAC API key is in use, there is no need for the backend to retrieve projects for filtering as any API call to a project ID that isn't permitted will cause an auth error.
181+
const projects = await readProjects({ organisationId });
182+
183+
for (const projectId of projectIds) {
184+
const params = new URLSearchParams({ is_archived: "false" });
185+
if (environmentId) params.set("environment", environmentId);
186+
187+
try {
188+
const project = projects.find((p) => String(p.id) === String(projectId));
189+
190+
if (!project) {
191+
throw new ApiError(`Flagsmith project ${projectId} not found`, 404);
192+
}
193+
194+
const path = route`/projects/${projectId}/features/?${params}`;
195+
const data = (await flagsmithApi(apiKey, path)) as PaginatedModels<Feature>;
196+
const results = await unpaginate(apiKey, data);
197+
198+
const enrichedFeatures = results.map((feature) => ({
199+
...feature,
200+
project_name: project.name,
201+
}));
202+
203+
allFeatures.push(...enrichedFeatures);
204+
} catch (error) {
205+
// If a specific project has no features, we can log it but continue with others
206+
if (error instanceof ApiError && error.code === 404) {
207+
console.warn(`Flagsmith project ${projectId} has no features`);
208+
} else {
209+
throw error; // rethrow unexpected errors
210+
}
211+
}
212+
}
213+
214+
if (allFeatures.length === 0) {
215+
throw new ApiError("Flagsmith projects have no features", 404);
216+
}
217+
218+
return allFeatures;
176219
};
177220

178221
export type ReadEnvironmentFeatureState = (args: {
@@ -188,6 +231,7 @@ export const readEnvironmentFeatureState: ReadEnvironmentFeatureState = async ({
188231
const apiKey = await readApiKey();
189232
checkApiKey(apiKey);
190233
if (!envApiKey) throw new ApiError("Flagsmith environment not connected", 400);
234+
191235
const path = route`/environments/${envApiKey}/featurestates/?feature_name=${featureName}`;
192236
const data = (await flagsmithApi(apiKey, path)) as PaginatedModels<EnvironmentFeatureState>;
193237
const results = await unpaginate(apiKey, data);

flagsmith-jira-app/src/frontend/components/IssueFeatureTables.tsx

Lines changed: 95 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
} from "@forge/react";
1414
import { Fragment, useCallback, useState } from "react";
1515

16-
import { usePromise } from "../../common";
16+
import { FLAGSMITH_APP, usePromise } from "../../common";
1717
import {
1818
Environment,
1919
EnvironmentFeatureState,
@@ -130,14 +130,12 @@ const makeRows = (projectUrl: string, state: FeatureState) => {
130130
};
131131

132132
type IssueFeatureTableProps = {
133-
projectUrl: string;
134133
environments: Environment[]; // must be non-empty
135134
// list of same feature in the context of each environment
136135
environmentFeatures: Feature[];
137136
};
138137

139138
const IssueFeatureTable = ({
140-
projectUrl,
141139
environments,
142140
environmentFeatures,
143141
}: IssueFeatureTableProps): JSX.Element => {
@@ -147,31 +145,68 @@ const IssueFeatureTable = ({
147145
// get id and name from first feature - assume non-empty
148146
const featureId = environmentFeatures[0]!.id;
149147
const featureName = environmentFeatures[0]!.name;
148+
const featureProjectId = environmentFeatures[0]!.project;
149+
const projectUrl = `${FLAGSMITH_APP}/project/${featureProjectId}`;
150150

151151
/** Read feature state for each environment */
152-
const readFeatureState = useCallback(
153-
async (): Promise<FeatureState> => ({
154-
featureId,
155-
environments: await Promise.all(
156-
environments.map(async (environment) => ({
157-
...(await readEnvironmentFeatureState({
152+
const readFeatureState = useCallback(async (): Promise<FeatureState> => {
153+
const matchingEnvs = environments.filter(
154+
(env) => String(env.project) === String(featureProjectId),
155+
);
156+
157+
const environmentFeatureStates = await Promise.all(
158+
matchingEnvs.map(async (environment) => {
159+
try {
160+
const state = await readEnvironmentFeatureState({
158161
envApiKey: String(environment.api_key),
159162
featureName,
160-
})),
161-
name: environment.name,
162-
api_key: String(environment.api_key),
163-
})),
164-
),
165-
counts: environmentFeatures.map((feature) => ({
163+
});
164+
165+
return {
166+
...state,
167+
name: environment.name,
168+
api_key: String(environment.api_key),
169+
};
170+
} catch (err) {
171+
console.warn(
172+
`Failed to read feature state for environment "${environment.name}" (${environment.id})`,
173+
{
174+
featureName,
175+
apiKey: environment.api_key,
176+
error: err instanceof Error ? err.message : String(err),
177+
},
178+
);
179+
return null;
180+
}
181+
}),
182+
);
183+
184+
const validStates = environmentFeatureStates.filter(
185+
(state): state is EnvironmentFeatureState => !!state,
186+
);
187+
188+
const relevantFeatures = environmentFeatures.filter(
189+
(f) => String(f.project) === String(featureProjectId),
190+
);
191+
192+
return {
193+
featureId,
194+
environments: validStates,
195+
counts: relevantFeatures.map((feature) => ({
166196
variations: feature.multivariate_options.length,
167197
segments: feature.num_segment_overrides ?? 0,
168198
identities: feature.num_identity_overrides ?? 0,
169199
})),
170-
}),
171-
[environments, environmentFeatures],
172-
);
200+
};
201+
}, [environments, environmentFeatures]);
202+
173203
const [state] = usePromise(
174-
async () => (error === undefined ? readFeatureState() : undefined),
204+
async () => {
205+
if (error === undefined) {
206+
return await readFeatureState();
207+
}
208+
return undefined;
209+
},
175210
[error, readFeatureState],
176211
setError,
177212
);
@@ -198,47 +233,63 @@ const IssueFeatureTable = ({
198233

199234
type FeatureTableData = {
200235
featureId: string;
201-
baseFeature: Feature | undefined;
202-
environmentFeatures: Feature[];
236+
baseFeature: Feature;
237+
envFeaturesForThisFeature: Feature[];
238+
matchingEnvironments: Environment[];
203239
};
204240

205241
const buildFeatureTableData = ({
206242
issueFeatureIds,
207243
features,
244+
environments,
208245
environmentsFeatures,
209246
}: {
210247
issueFeatureIds: string[];
211248
features: Feature[];
249+
environments: Environment[];
212250
environmentsFeatures: Feature[][];
213251
}): FeatureTableData[] => {
214-
return issueFeatureIds.map((featureId) => {
215-
const baseFeature = features.find((f) => String(f.id) === featureId);
252+
return issueFeatureIds
253+
.map((featureId) => {
254+
const baseFeature = features.find((f) => String(f.id) === featureId);
255+
if (!baseFeature) {
256+
return null;
257+
}
216258

217-
const envFeaturesForThisFeature = environmentsFeatures
218-
.map((envFeatures) => {
259+
const envFeaturesForThisFeature: Feature[] = [];
260+
const matchingEnvironments: Environment[] = [];
261+
262+
environmentsFeatures.forEach((envFeatures, index) => {
219263
const matchingFeature = envFeatures.find((f) => String(f.id) === featureId);
220-
return matchingFeature;
221-
})
222-
.filter(Boolean) as Feature[];
264+
const environment = environments[index];
265+
if (matchingFeature && environment !== undefined) {
266+
envFeaturesForThisFeature.push(matchingFeature);
267+
matchingEnvironments.push(environment);
268+
}
269+
});
223270

224-
return {
225-
featureId,
226-
baseFeature,
227-
environmentFeatures: envFeaturesForThisFeature,
228-
};
229-
});
271+
if (envFeaturesForThisFeature.length === 0 || matchingEnvironments.length === 0) {
272+
return null;
273+
}
274+
275+
return {
276+
featureId,
277+
baseFeature,
278+
envFeaturesForThisFeature,
279+
matchingEnvironments,
280+
};
281+
})
282+
.filter((data): data is FeatureTableData => !!data);
230283
};
231284

232285
type IssueFeatureTablesProps = {
233-
projectUrl: string;
234286
// environments/environmentsFeatures are assumed to be same length/order
235287
environments: Environment[];
236288
environmentsFeatures: Feature[][];
237289
issueFeatureIds: string[];
238290
};
239291

240292
const IssueFeatureTables = ({
241-
projectUrl,
242293
environments,
243294
environmentsFeatures,
244295
issueFeatureIds,
@@ -254,38 +305,34 @@ const IssueFeatureTables = ({
254305
// iterate features from the first environment to get list of tables
255306
// (id/name/description are the same across environments)
256307
const features = environmentsFeatures[0];
257-
258308
const featureTableData = buildFeatureTableData({
259309
issueFeatureIds,
260310
features,
311+
environments,
261312
environmentsFeatures,
262313
});
263314

264315
return (
265316
<Fragment>
266-
{featureTableData.map(({ featureId, baseFeature, environmentFeatures }) => {
267-
if (!baseFeature) {
268-
return null;
269-
}
270-
return (
317+
{featureTableData.map(
318+
({ featureId, baseFeature, matchingEnvironments, envFeaturesForThisFeature }) => (
271319
<Fragment key={featureId}>
272320
<Box xcss={{ marginTop: "space.300", marginBottom: "space.100" }}>
273321
<Text>
274322
<Strong>
275-
{baseFeature.name}
323+
{baseFeature.name} ({baseFeature.project_name})
276324
{baseFeature.description ? ": " : ""}
277325
</Strong>
278326
{baseFeature.description}
279327
</Text>
280328
</Box>
281329
<IssueFeatureTable
282-
projectUrl={projectUrl}
283-
environments={environments}
284-
environmentFeatures={environmentFeatures}
330+
environments={matchingEnvironments}
331+
environmentFeatures={envFeaturesForThisFeature}
285332
/>
286333
</Fragment>
287-
);
288-
})}
334+
),
335+
)}
289336
</Fragment>
290337
);
291338
};

flagsmith-jira-app/src/frontend/components/IssueFeaturesForm.tsx

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,32 @@ const IssueFeaturesForm = ({
2727
}, [props.issueFeatureIds]);
2828

2929
const featureInputId = useId();
30-
const featureOptions = useMemo(
31-
() =>
32-
features.map((each) => ({
33-
label: each.name,
34-
value: String(each.id),
35-
})),
36-
[features],
37-
);
38-
const featureValue = featureOptions.filter((option) => featureIds.includes(option.value)) ?? null;
30+
const featureOptions = useMemo(() => {
31+
const grouped = features.reduce(
32+
(acc, feature) => {
33+
const projectGroup = acc.find((group) => group.label === feature.project_name);
34+
const option = {
35+
label: feature.name,
36+
value: String(feature.id),
37+
};
38+
if (projectGroup) {
39+
projectGroup.options.push(option);
40+
} else {
41+
acc.push({
42+
label: feature.project_name,
43+
options: [option],
44+
});
45+
}
46+
return acc;
47+
},
48+
[] as { label: string; options: { label: string; value: string }[] }[],
49+
);
50+
return grouped;
51+
}, [features]);
52+
const featureValue =
53+
featureOptions
54+
.flatMap((group) => group.options)
55+
.filter((option) => featureIds.includes(option.value)) ?? null;
3956

4057
const onFeatureChange = async (options: { value: string }[]) => {
4158
const featureIds = options.map((option) => option.value);

0 commit comments

Comments
 (0)