Skip to content

Commit bd95141

Browse files
committed
feat(ui): segment flag usage and configurable loading
- Add listFlagsForSegment and getSegmentFlagCounts RTK endpoints (existing REST APIs) - Segment page: Used in flags; segments list: per-segment flag counts when enabled - Preferences: Segment flag usage toggle (default off); persist with theme/timezone/sidebar - Add Sidebar enum to types; fix accidental conflict markers in store listener Signed-off-by: aparimeet <aparimeet@gmail.com>
1 parent 219eadb commit bd95141

7 files changed

Lines changed: 203 additions & 53 deletions

File tree

ui/src/app/preferences/Preferences.tsx

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ export default function Preferences() {
2020
const loadSegmentFlagReferences = useSelector(
2121
selectLoadSegmentFlagReferences
2222
);
23-
2423
const dispatch = useDispatch();
2524

2625
const initialValues = {
@@ -33,19 +32,21 @@ export default function Preferences() {
3332

3433
return (
3534
<Formik initialValues={initialValues} onSubmit={() => {}}>
36-
<div className="my-10 divide-y divide-gray-200">
35+
<div className="divide-border my-10 divide-y">
3736
<div className="space-y-1">
38-
<h3 className="text-xl font-semibold text-gray-700">Preferences</h3>
39-
<p className="mt-2 text-sm text-gray-500">
37+
<h3 className="text-secondary-foreground text-xl font-semibold">
38+
Preferences
39+
</h3>
40+
<p className="text-muted-foreground mt-2 text-sm">
4041
Manage how information is displayed in the UI
4142
</p>
4243
</div>
4344
<div className="mt-6 max-w-4xl">
44-
<div className="divide-y divide-gray-200">
45+
<div className="divide-border divide-y">
4546
<div className="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:pt-5">
4647
<label
4748
htmlFor="location"
48-
className="text-sm font-bold text-gray-500"
49+
className="text-muted-foreground text-sm font-bold"
4950
>
5051
Theme
5152
</label>
@@ -66,9 +67,6 @@ export default function Preferences() {
6667
</div>
6768
<div className="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:pt-5">
6869
<span
69-
<<<<<<< HEAD
70-
className="text-sm font-bold text-gray-500"
71-
=======
7270
className="text-muted-foreground text-sm font-bold"
7371
id="label-switch-segment-flags"
7472
>
@@ -96,7 +94,6 @@ export default function Preferences() {
9694
<div className="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:pt-5">
9795
<span
9896
className="text-muted-foreground text-sm font-bold"
99-
>>>>>>> f5591c11 (Make segment flag usage configurable with UI preference)
10097
id="label-switch-tmz"
10198
>
10299
UTC Timezone

ui/src/app/preferences/preferencesSlice.ts

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,24 @@
11
/* eslint-disable @typescript-eslint/no-use-before-define */
2-
import { createSlice } from '@reduxjs/toolkit';
2+
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
33
import { RootState } from '~/store';
4-
import { Theme, Timezone } from '~/types/Preferences';
4+
import { Theme, Timezone, Sidebar } from '~/types/Preferences';
55
import { fetchInfoAsync } from '~/app/meta/metaSlice';
66

77
export const preferencesKey = 'preferences';
88

99
interface IPreferencesState {
1010
theme: Theme;
1111
timezone: Timezone;
12-
<<<<<<< HEAD
13-
=======
1412
sidebar: Sidebar;
1513
/** When true, the UI loads which flags reference each segment (extra API calls). */
1614
loadSegmentFlagReferences?: boolean;
17-
>>>>>>> f5591c11 (Make segment flag usage configurable with UI preference)
1815
}
1916

2017
const initialState: IPreferencesState = {
2118
theme: Theme.SYSTEM,
22-
<<<<<<< HEAD
23-
timezone: Timezone.LOCAL
24-
=======
2519
timezone: Timezone.LOCAL,
2620
sidebar: Sidebar.OPEN,
2721
loadSegmentFlagReferences: false
28-
>>>>>>> f5591c11 (Make segment flag usage configurable with UI preference)
2922
};
3023

3124
export const preferencesSlice = createSlice({
@@ -37,8 +30,6 @@ export const preferencesSlice = createSlice({
3730
},
3831
timezoneChanged: (state, action) => {
3932
state.timezone = action.payload;
40-
<<<<<<< HEAD
41-
=======
4233
},
4334
sidebarChanged: (state, action: PayloadAction<boolean>) => {
4435
state.sidebar = action.payload ? Sidebar.OPEN : Sidebar.CLOSE;
@@ -48,7 +39,6 @@ export const preferencesSlice = createSlice({
4839
action: PayloadAction<boolean>
4940
) => {
5041
state.loadSegmentFlagReferences = action.payload;
51-
>>>>>>> f5591c11 (Make segment flag usage configurable with UI preference)
5242
}
5343
},
5444
extraReducers(builder) {
@@ -64,16 +54,23 @@ export const preferencesSlice = createSlice({
6454
if (!currentPreference.timezone) {
6555
state.timezone = Timezone.LOCAL;
6656
}
57+
let sidebar = Sidebar.OPEN;
58+
if (
59+
currentPreference.sidebar &&
60+
Object.values(Sidebar).includes(currentPreference.sidebar)
61+
) {
62+
sidebar = currentPreference.sidebar;
63+
}
64+
state.sidebar = sidebar;
65+
66+
if (typeof currentPreference.loadSegmentFlagReferences === 'boolean') {
67+
state.loadSegmentFlagReferences =
68+
currentPreference.loadSegmentFlagReferences;
69+
}
6770
});
6871
}
6972
});
7073

71-
<<<<<<< HEAD
72-
export const { themeChanged, timezoneChanged } = preferencesSlice.actions;
73-
74-
export const selectTheme = (state: RootState) => state.preferences.theme;
75-
export const selectTimezone = (state: RootState) => state.preferences.timezone;
76-
=======
7774
export const {
7875
themeChanged,
7976
timezoneChanged,
@@ -88,6 +85,5 @@ export const selectSidebar = (state: RootState) =>
8885
/** Off by default; must be explicitly enabled in Preferences. */
8986
export const selectLoadSegmentFlagReferences = (state: RootState) =>
9087
state.preferences.loadSegmentFlagReferences === true;
91-
>>>>>>> f5591c11 (Make segment flag usage configurable with UI preference)
9288

9389
export default preferencesSlice.reducer;

ui/src/app/segments/Segment.tsx

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { CalendarIcon, FilesIcon, Trash2Icon } from 'lucide-react';
1+
import { CalendarIcon, FlagIcon, FilesIcon, Trash2Icon } from 'lucide-react';
22
import { formatDistanceToNowStrict, parseISO } from 'date-fns';
33
import { useEffect, useMemo, useRef, useState } from 'react';
44
import { useSelector } from 'react-redux';
5-
import { useNavigate, useParams } from 'react-router';
5+
import { Link, useNavigate, useParams } from 'react-router';
66
import { selectReadonly } from '~/app/meta/metaSlice';
77
import {
88
selectCurrentNamespace,
@@ -13,7 +13,8 @@ import {
1313
useCopySegmentMutation,
1414
useDeleteConstraintMutation,
1515
useDeleteSegmentMutation,
16-
useGetSegmentQuery
16+
useGetSegmentQuery,
17+
useListFlagsForSegmentQuery
1718
} from '~/app/segments/segmentsApi';
1819
import Chips from '~/components/Chips';
1920
import EmptyState from '~/components/EmptyState';
@@ -37,6 +38,7 @@ import {
3738
IConstraint
3839
} from '~/types/Constraint';
3940
import { PageHeader } from '~/components/ui/page';
41+
import { Badge } from '~/components/Badge';
4042

4143
function ConstraintArrayValue({ value }: { value: string | undefined }) {
4244
const items: string[] | number[] = useMemo(() => {
@@ -105,8 +107,6 @@ export default function Segment() {
105107
segmentKey: segmentKey || ''
106108
});
107109

108-
<<<<<<< HEAD
109-
=======
110110
const { data: flagsForSegment } = useListFlagsForSegmentQuery(
111111
{
112112
namespaceKey: namespace.key,
@@ -118,7 +118,6 @@ export default function Segment() {
118118
}
119119
);
120120

121-
>>>>>>> f5591c11 (Make segment flag usage configurable with UI preference)
122121
const [deleteSegment] = useDeleteSegmentMutation();
123122
const [deleteSegmentConstraint] = useDeleteConstraintMutation();
124123
const [copySegment] = useCopySegmentMutation();
@@ -273,8 +272,6 @@ export default function Segment() {
273272
<SegmentForm segment={segment} />
274273
</div>
275274

276-
<<<<<<< HEAD
277-
=======
278275
{loadSegmentFlagReferences && (
279276
<div className="mb-8">
280277
<h3 className="text-secondary-foreground leading-6 font-medium">
@@ -285,7 +282,7 @@ export default function Segment() {
285282
constraints may affect how these flags evaluate.
286283
</p>
287284
{!flagsForSegment ? (
288-
<Loading size="sm" variant="start" />
285+
<p className="text-muted-foreground text-sm">Loading…</p>
289286
) : flagsForSegment.flags.length === 0 ? (
290287
<p className="text-secondary-foreground/80 text-sm">
291288
This segment is not used by any flags.
@@ -315,7 +312,6 @@ export default function Segment() {
315312
</div>
316313
)}
317314

318-
>>>>>>> f5591c11 (Make segment flag usage configurable with UI preference)
319315
<div>
320316
<div className="sm:flex sm:items-center">
321317
<div className="sm:flex-auto">

ui/src/app/segments/segmentsApi.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,37 @@ import { SortingState } from '@tanstack/react-table';
44
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
55
import { RootState } from '~/store';
66
import { IConstraintBase } from '~/types/Constraint';
7+
import { IFlagList } from '~/types/Flag';
8+
import { IRolloutList } from '~/types/Rollout';
9+
import { IRule, IRuleList } from '~/types/Rule';
710
import { ISegment, ISegmentBase, ISegmentList } from '~/types/Segment';
811
import { baseQuery } from '~/utils/redux-rtk';
912

13+
export interface IFlagReference {
14+
key: string;
15+
name: string;
16+
}
17+
18+
export function ruleReferencesSegment(
19+
rule: IRule,
20+
segmentKey: string
21+
): boolean {
22+
if (rule.segmentKey === segmentKey) return true;
23+
if (rule.segmentKeys?.includes(segmentKey)) return true;
24+
return false;
25+
}
26+
27+
export function rolloutReferencesSegment(
28+
rollout: { segment?: { segmentKey?: string; segmentKeys?: string[] } },
29+
segmentKey: string
30+
): boolean {
31+
const seg = rollout.segment;
32+
if (!seg) return false;
33+
if (seg.segmentKey === segmentKey) return true;
34+
if (seg.segmentKeys?.includes(segmentKey)) return true;
35+
return false;
36+
}
37+
1038
const initialTableState: {
1139
sorting: SortingState;
1240
} = {
@@ -211,13 +239,131 @@ export const segmentsApi = createApi({
211239
invalidatesTags: (_result, _error, { namespaceKey, segmentKey }) => [
212240
{ type: 'Segment', id: namespaceKey + '/' + segmentKey }
213241
]
242+
}),
243+
244+
listFlagsForSegment: builder.query<
245+
{ flags: IFlagReference[] },
246+
{ namespaceKey: string; segmentKey: string }
247+
>({
248+
queryFn: async (
249+
{ namespaceKey, segmentKey },
250+
_api,
251+
_extraOptions,
252+
baseQueryFn
253+
) => {
254+
const flagsResp = await baseQueryFn({
255+
url: `/namespaces/${namespaceKey}/flags`,
256+
method: 'GET'
257+
});
258+
if (flagsResp.error) {
259+
return { error: flagsResp.error };
260+
}
261+
const flags = (flagsResp.data as IFlagList).flags ?? [];
262+
const refs: IFlagReference[] = [];
263+
264+
await Promise.all(
265+
flags.map(async (flag) => {
266+
const [rulesResp, rolloutsResp] = await Promise.all([
267+
baseQueryFn({
268+
url: `/namespaces/${namespaceKey}/flags/${flag.key}/rules`,
269+
method: 'GET'
270+
}),
271+
baseQueryFn({
272+
url: `/namespaces/${namespaceKey}/flags/${flag.key}/rollouts`,
273+
method: 'GET'
274+
})
275+
]);
276+
const rules = (rulesResp.data as IRuleList)?.rules ?? [];
277+
const rollouts = (rolloutsResp.data as IRolloutList)?.rules ?? [];
278+
const used =
279+
rules.some((r) => ruleReferencesSegment(r, segmentKey)) ||
280+
rollouts.some((r) => rolloutReferencesSegment(r, segmentKey));
281+
if (used) {
282+
refs.push({ key: flag.key, name: flag.name });
283+
}
284+
})
285+
);
286+
287+
return {
288+
data: {
289+
flags: refs.sort((a, b) => a.name.localeCompare(b.name))
290+
}
291+
};
292+
},
293+
providesTags: (_result, _error, { namespaceKey, segmentKey }) => [
294+
{ type: 'Segment', id: namespaceKey + '/' + segmentKey }
295+
]
296+
}),
297+
298+
getSegmentFlagCounts: builder.query<
299+
Record<string, number>,
300+
{ namespaceKey: string }
301+
>({
302+
queryFn: async ({ namespaceKey }, _api, _extraOptions, baseQueryFn) => {
303+
const flagsResp = await baseQueryFn({
304+
url: `/namespaces/${namespaceKey}/flags`,
305+
method: 'GET'
306+
});
307+
if (flagsResp.error) {
308+
return { error: flagsResp.error };
309+
}
310+
const flags = (flagsResp.data as IFlagList).flags ?? [];
311+
const countBySegment: Record<string, Set<string>> = {};
312+
313+
const addFlagToSegments = (segmentKeys: string[], flagKey: string) => {
314+
for (const sk of segmentKeys) {
315+
if (!countBySegment[sk]) countBySegment[sk] = new Set();
316+
countBySegment[sk].add(flagKey);
317+
}
318+
};
319+
320+
await Promise.all(
321+
flags.map(async (flag) => {
322+
const [rulesResp, rolloutsResp] = await Promise.all([
323+
baseQueryFn({
324+
url: `/namespaces/${namespaceKey}/flags/${flag.key}/rules`,
325+
method: 'GET'
326+
}),
327+
baseQueryFn({
328+
url: `/namespaces/${namespaceKey}/flags/${flag.key}/rollouts`,
329+
method: 'GET'
330+
})
331+
]);
332+
const rules = (rulesResp.data as IRuleList)?.rules ?? [];
333+
const rollouts = (rolloutsResp.data as IRolloutList)?.rules ?? [];
334+
for (const r of rules) {
335+
if (r.segmentKey) addFlagToSegments([r.segmentKey], flag.key);
336+
if (r.segmentKeys?.length)
337+
addFlagToSegments(r.segmentKeys, flag.key);
338+
}
339+
for (const r of rollouts) {
340+
const seg = r.segment;
341+
if (seg?.segmentKey)
342+
addFlagToSegments([seg.segmentKey], flag.key);
343+
if (seg?.segmentKeys?.length)
344+
addFlagToSegments(seg.segmentKeys, flag.key);
345+
}
346+
})
347+
);
348+
349+
const result: Record<string, number> = {};
350+
for (const [sk, set] of Object.entries(countBySegment)) {
351+
result[sk] = set.size;
352+
}
353+
return { data: result };
354+
},
355+
providesTags: (_result, _error, { namespaceKey }) => [
356+
segmentTag(namespaceKey)
357+
]
214358
})
215359
})
216360
});
217361

218362
export const {
219363
useListSegmentsQuery,
220364
useGetSegmentQuery,
365+
useListFlagsForSegmentQuery,
366+
useGetSegmentFlagCountsQuery,
221367
useCreateSegmentMutation,
222368
useDeleteSegmentMutation,
223369
useUpdateSegmentMutation,

0 commit comments

Comments
 (0)