Skip to content

Commit 11a0f72

Browse files
committed
Add support for multiple projects
wip rm some logs working loading indicator works correctly project setting self cr cr rebase project name
1 parent 4b7c3a7 commit 11a0f72

File tree

8 files changed

+284
-148
lines changed

8 files changed

+284
-148
lines changed

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

Lines changed: 51 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,59 @@ 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+
const projects = await readProjects({ organisationId });
178+
179+
for (const projectId of projectIds) {
180+
const params = new URLSearchParams({ is_archived: "false" });
181+
if (environmentId) params.set("environment", environmentId);
182+
183+
try {
184+
const project = projects.find((p) => String(p.id) === String(projectId));
185+
186+
if (!project) {
187+
throw new ApiError(`Flagsmith project ${projectId} not found`, 404);
188+
}
189+
190+
const path = route`/projects/${projectId}/features/?${params}`;
191+
const data = (await flagsmithApi(apiKey, path)) as PaginatedModels<Feature>;
192+
const results = await unpaginate(apiKey, data);
193+
194+
const enrichedFeatures = results.map((feature) => ({
195+
...feature,
196+
project_name: project.name,
197+
}));
198+
199+
allFeatures.push(...enrichedFeatures);
200+
} catch (error) {
201+
// If a specific project has no features, we can log it but continue with others
202+
if (error instanceof ApiError && error.code === 404) {
203+
console.warn(`Flagsmith project ${projectId} has no features`);
204+
} else {
205+
throw error; // rethrow unexpected errors
206+
}
207+
}
208+
}
209+
210+
if (allFeatures.length === 0) {
211+
throw new ApiError("Flagsmith projects have no features", 404);
212+
}
213+
214+
return allFeatures;
176215
};
177216

178217
export type ReadEnvironmentFeatureState = (args: {
@@ -188,6 +227,7 @@ export const readEnvironmentFeatureState: ReadEnvironmentFeatureState = async ({
188227
const apiKey = await readApiKey();
189228
checkApiKey(apiKey);
190229
if (!envApiKey) throw new ApiError("Flagsmith environment not connected", 400);
230+
191231
const path = route`/environments/${envApiKey}/featurestates/?feature_name=${featureName}`;
192232
const data = (await flagsmithApi(apiKey, path)) as PaginatedModels<EnvironmentFeatureState>;
193233
const results = await unpaginate(apiKey, data);

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

Lines changed: 87 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,60 @@ 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+
return null;
172+
}
173+
}),
174+
);
175+
176+
const validStates = environmentFeatureStates.filter(
177+
(state): state is EnvironmentFeatureState => !!state,
178+
);
179+
180+
const relevantFeatures = environmentFeatures.filter(
181+
(f) => String(f.project) === String(featureProjectId),
182+
);
183+
184+
return {
185+
featureId,
186+
environments: validStates,
187+
counts: relevantFeatures.map((feature) => ({
166188
variations: feature.multivariate_options.length,
167189
segments: feature.num_segment_overrides ?? 0,
168190
identities: feature.num_identity_overrides ?? 0,
169191
})),
170-
}),
171-
[environments, environmentFeatures],
172-
);
192+
};
193+
}, [environments, environmentFeatures]);
194+
173195
const [state] = usePromise(
174-
async () => (error === undefined ? readFeatureState() : undefined),
196+
async () => {
197+
if (error === undefined) {
198+
return await readFeatureState();
199+
}
200+
return undefined;
201+
},
175202
[error, readFeatureState],
176203
setError,
177204
);
@@ -198,47 +225,63 @@ const IssueFeatureTable = ({
198225

199226
type FeatureTableData = {
200227
featureId: string;
201-
baseFeature: Feature | undefined;
202-
environmentFeatures: Feature[];
228+
baseFeature: Feature;
229+
envFeaturesForThisFeature: Feature[];
230+
matchingEnvironments: Environment[];
203231
};
204232

205233
const buildFeatureTableData = ({
206234
issueFeatureIds,
207235
features,
236+
environments,
208237
environmentsFeatures,
209238
}: {
210239
issueFeatureIds: string[];
211240
features: Feature[];
241+
environments: Environment[];
212242
environmentsFeatures: Feature[][];
213243
}): FeatureTableData[] => {
214-
return issueFeatureIds.map((featureId) => {
215-
const baseFeature = features.find((f) => String(f.id) === featureId);
244+
return issueFeatureIds
245+
.map((featureId) => {
246+
const baseFeature = features.find((f) => String(f.id) === featureId);
247+
if (!baseFeature) {
248+
return null;
249+
}
216250

217-
const envFeaturesForThisFeature = environmentsFeatures
218-
.map((envFeatures) => {
251+
const envFeaturesForThisFeature: Feature[] = [];
252+
const matchingEnvironments: Environment[] = [];
253+
254+
environmentsFeatures.forEach((envFeatures, index) => {
219255
const matchingFeature = envFeatures.find((f) => String(f.id) === featureId);
220-
return matchingFeature;
221-
})
222-
.filter(Boolean) as Feature[];
256+
const environment = environments[index];
257+
if (matchingFeature && environment !== undefined) {
258+
envFeaturesForThisFeature.push(matchingFeature);
259+
matchingEnvironments.push(environment);
260+
}
261+
});
223262

224-
return {
225-
featureId,
226-
baseFeature,
227-
environmentFeatures: envFeaturesForThisFeature,
228-
};
229-
});
263+
if (envFeaturesForThisFeature.length === 0 || matchingEnvironments.length === 0) {
264+
return null;
265+
}
266+
267+
return {
268+
featureId,
269+
baseFeature,
270+
envFeaturesForThisFeature,
271+
matchingEnvironments,
272+
};
273+
})
274+
.filter((data): data is FeatureTableData => !!data);
230275
};
231276

232277
type IssueFeatureTablesProps = {
233-
projectUrl: string;
234278
// environments/environmentsFeatures are assumed to be same length/order
235279
environments: Environment[];
236280
environmentsFeatures: Feature[][];
237281
issueFeatureIds: string[];
238282
};
239283

240284
const IssueFeatureTables = ({
241-
projectUrl,
242285
environments,
243286
environmentsFeatures,
244287
issueFeatureIds,
@@ -254,38 +297,34 @@ const IssueFeatureTables = ({
254297
// iterate features from the first environment to get list of tables
255298
// (id/name/description are the same across environments)
256299
const features = environmentsFeatures[0];
257-
258300
const featureTableData = buildFeatureTableData({
259301
issueFeatureIds,
260302
features,
303+
environments,
261304
environmentsFeatures,
262305
});
263306

264307
return (
265308
<Fragment>
266-
{featureTableData.map(({ featureId, baseFeature, environmentFeatures }) => {
267-
if (!baseFeature) {
268-
return null;
269-
}
270-
return (
309+
{featureTableData.map(
310+
({ featureId, baseFeature, matchingEnvironments, envFeaturesForThisFeature }) => (
271311
<Fragment key={featureId}>
272312
<Box xcss={{ marginTop: "space.300", marginBottom: "space.100" }}>
273313
<Text>
274314
<Strong>
275-
{baseFeature.name}
315+
{baseFeature.name} ({baseFeature.project_name})
276316
{baseFeature.description ? ": " : ""}
277317
</Strong>
278318
{baseFeature.description}
279319
</Text>
280320
</Box>
281321
<IssueFeatureTable
282-
projectUrl={projectUrl}
283-
environments={environments}
284-
environmentFeatures={environmentFeatures}
322+
environments={matchingEnvironments}
323+
environmentFeatures={envFeaturesForThisFeature}
285324
/>
286325
</Fragment>
287-
);
288-
})}
326+
),
327+
)}
289328
</Fragment>
290329
);
291330
};

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)