1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-01 01:18:10 +02:00

feat: Personal flags UI component (#8221)

This commit is contained in:
Mateusz Kwasniewski 2024-09-24 08:42:49 +02:00 committed by GitHub
parent 4e8d9a2319
commit fee2143edf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 152 additions and 19 deletions

View File

@ -22,6 +22,8 @@ import { WelcomeDialog } from './WelcomeDialog';
import { useLocalStorageState } from 'hooks/useLocalStorageState'; import { useLocalStorageState } from 'hooks/useLocalStorageState';
import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview'; import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview';
import { ProjectSetupComplete } from './ProjectSetupComplete'; import { ProjectSetupComplete } from './ProjectSetupComplete';
import { usePersonalDashboard } from 'hooks/api/getters/usePersonalDashboard/usePersonalDashboard';
import { getFeatureTypeIcons } from 'utils/getFeatureTypeIcons';
const ScreenExplanation = styled(Typography)(({ theme }) => ({ const ScreenExplanation = styled(Typography)(({ theme }) => ({
marginTop: theme.spacing(1), marginTop: theme.spacing(1),
@ -136,6 +138,38 @@ const useProjects = () => {
return { projects, activeProject, setActiveProject }; return { projects, activeProject, setActiveProject };
}; };
const FlagListItem: FC<{
flag: { name: string; project: string; type: string };
selected: boolean;
onClick: () => void;
}> = ({ flag, selected, onClick }) => {
const IconComponent = getFeatureTypeIcons(flag.type);
return (
<ListItem key={flag.name} disablePadding={true} sx={{ mb: 1 }}>
<ListItemButton
sx={projectStyle}
selected={selected}
onClick={onClick}
>
<ProjectBox>
<IconComponent color='primary' />
<StyledCardTitle>{flag.name}</StyledCardTitle>
<IconButton
component={Link}
href={`projects/${flag.project}/features/${flag.name}`}
size='small'
sx={{ ml: 'auto' }}
>
<LinkIcon
titleAccess={`projects/${flag.project}/features/${flag.name}`}
/>
</IconButton>
</ProjectBox>
</ListItemButton>
</ListItem>
);
};
export const PersonalDashboard = () => { export const PersonalDashboard = () => {
const { user } = useAuthUser(); const { user } = useAuthUser();
@ -143,6 +177,14 @@ export const PersonalDashboard = () => {
const { projects, activeProject, setActiveProject } = useProjects(); const { projects, activeProject, setActiveProject } = useProjects();
const { personalDashboard } = usePersonalDashboard();
const [activeFlag, setActiveFlag] = useState<string | null>(null);
useEffect(() => {
if (personalDashboard?.flags.length) {
setActiveFlag(personalDashboard.flags[0].name);
}
}, [JSON.stringify(personalDashboard)]);
const { project: activeProjectOverview, loading } = const { project: activeProjectOverview, loading } =
useProjectOverview(activeProject); useProjectOverview(activeProject);
@ -256,11 +298,28 @@ export const PersonalDashboard = () => {
</SpacedGridItem> </SpacedGridItem>
<SpacedGridItem item lg={8} md={1} /> <SpacedGridItem item lg={8} md={1} />
<SpacedGridItem item lg={4} md={1}> <SpacedGridItem item lg={4} md={1}>
{personalDashboard && personalDashboard.flags.length > 0 ? (
<List
disablePadding={true}
sx={{ maxHeight: '400px', overflow: 'auto' }}
>
{personalDashboard.flags.map((flag) => (
<FlagListItem
key={flag.name}
flag={flag}
selected={flag.name === activeFlag}
onClick={() => setActiveFlag(flag.name)}
/>
))}
</List>
) : (
<Typography> <Typography>
You have not created or favorited any feature flags. You have not created or favorited any feature flags.
Once you do, the will show up here. Once you do, they will show up here.
</Typography> </Typography>
)}
</SpacedGridItem> </SpacedGridItem>
<SpacedGridItem item lg={8} md={1}> <SpacedGridItem item lg={8} md={1}>
<Typography sx={{ mb: 4 }}>Feature flag metrics</Typography> <Typography sx={{ mb: 4 }}>Feature flag metrics</Typography>
<PlaceholderFlagMetricsChart /> <PlaceholderFlagMetricsChart />

View File

@ -0,0 +1,31 @@
import useSWR from 'swr';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
import type { PersonalDashboardSchema } from 'openapi';
export interface IPersonalDashboardOutput {
personalDashboard?: PersonalDashboardSchema;
refetch: () => void;
loading: boolean;
error?: Error;
}
export const usePersonalDashboard = (): IPersonalDashboardOutput => {
const { data, error, mutate } = useSWR(
formatApiPath('api/admin/personal-dashboard'),
fetcher,
);
return {
personalDashboard: data,
loading: !error && !data,
refetch: () => mutate(),
error,
};
};
const fetcher = (path: string) => {
return fetch(path)
.then(handleErrorResponses('Personal Dashboard'))
.then((res) => res.json());
};

View File

@ -7,4 +7,6 @@
export type PersonalDashboardSchemaFlagsItem = { export type PersonalDashboardSchemaFlagsItem = {
/** The name of the flag */ /** The name of the flag */
name: string; name: string;
project: string;
type: string;
}; };

View File

@ -1,9 +1,12 @@
import type { IPersonalDashboardReadModel } from './personal-dashboard-read-model-type'; import type {
IPersonalDashboardReadModel,
PersonalFeature,
} from './personal-dashboard-read-model-type';
export class FakePersonalDashboardReadModel export class FakePersonalDashboardReadModel
implements IPersonalDashboardReadModel implements IPersonalDashboardReadModel
{ {
async getPersonalFeatures(userId: number): Promise<{ name: string }[]> { async getPersonalFeatures(userId: number): Promise<PersonalFeature[]> {
return []; return [];
} }
} }

View File

@ -55,9 +55,9 @@ test('should return personal dashboard with own flags and favorited flags', asyn
expect(body).toMatchObject({ expect(body).toMatchObject({
projects: [], projects: [],
flags: [ flags: [
{ name: 'my_feature_c' }, { name: 'my_feature_d', type: 'release', project: 'default' },
{ name: 'my_feature_d' }, { name: 'my_feature_c', type: 'release', project: 'default' },
{ name: 'other_feature_b' }, { name: 'other_feature_b', type: 'release', project: 'default' },
], ],
}); });
}); });

View File

@ -1,3 +1,5 @@
export type PersonalFeature = { name: string; type: string; project: string };
export interface IPersonalDashboardReadModel { export interface IPersonalDashboardReadModel {
getPersonalFeatures(userId: number): Promise<{ name: string }[]>; getPersonalFeatures(userId: number): Promise<PersonalFeature[]>;
} }

View File

@ -1,5 +1,8 @@
import type { Db } from '../../db/db'; import type { Db } from '../../db/db';
import type { IPersonalDashboardReadModel } from './personal-dashboard-read-model-type'; import type {
IPersonalDashboardReadModel,
PersonalFeature,
} from './personal-dashboard-read-model-type';
export class PersonalDashboardReadModel implements IPersonalDashboardReadModel { export class PersonalDashboardReadModel implements IPersonalDashboardReadModel {
private db: Db; private db: Db;
@ -8,15 +11,34 @@ export class PersonalDashboardReadModel implements IPersonalDashboardReadModel {
this.db = db; this.db = db;
} }
getPersonalFeatures(userId: number): Promise<{ name: string }[]> { async getPersonalFeatures(userId: number): Promise<PersonalFeature[]> {
return this.db<{ name: string }>('favorite_features') const result = await this.db<{
name: string;
type: string;
project: string;
}>('favorite_features')
.join('features', 'favorite_features.feature', 'features.name')
.where('favorite_features.user_id', userId) .where('favorite_features.user_id', userId)
.select('feature as name') .whereNull('features.archived_at')
.select(
'features.name as name',
'features.type',
'features.project',
'features.created_at',
)
.union(function () { .union(function () {
this.select('name') this.select('name', 'type', 'project', 'created_at')
.from('features') .from('features')
.where('features.created_by_user_id', userId); .where('features.created_by_user_id', userId)
.whereNull('features.archived_at');
}) })
.orderBy('name', 'asc'); .orderBy('created_at', 'desc')
.limit(100);
return result.map((row) => ({
name: row.name,
type: row.type,
project: row.project,
}));
} }
} }

View File

@ -1,4 +1,7 @@
import type { IPersonalDashboardReadModel } from './personal-dashboard-read-model-type'; import type {
IPersonalDashboardReadModel,
PersonalFeature,
} from './personal-dashboard-read-model-type';
export class PersonalDashboardService { export class PersonalDashboardService {
private personalDashboardReadModel: IPersonalDashboardReadModel; private personalDashboardReadModel: IPersonalDashboardReadModel;
@ -7,7 +10,7 @@ export class PersonalDashboardService {
this.personalDashboardReadModel = personalDashboardReadModel; this.personalDashboardReadModel = personalDashboardReadModel;
} }
getPersonalFeatures(userId: number): Promise<{ name: string }[]> { getPersonalFeatures(userId: number): Promise<PersonalFeature[]> {
return this.personalDashboardReadModel.getPersonalFeatures(userId); return this.personalDashboardReadModel.getPersonalFeatures(userId);
} }
} }

View File

@ -36,6 +36,16 @@ export const personalDashboardSchema = {
example: 'my-flag', example: 'my-flag',
description: 'The name of the flag', description: 'The name of the flag',
}, },
project: {
type: 'string',
example: 'my-project-id',
description: 'The id of the feature project',
},
type: {
type: 'string',
example: 'release',
description: 'The type of the feature flag',
},
}, },
}, },
description: 'A list of flags a user created or favorited', description: 'A list of flags a user created or favorited',

View File

@ -54,6 +54,7 @@ process.nextTick(async () => {
addonUsageMetrics: true, addonUsageMetrics: true,
onboardingMetrics: true, onboardingMetrics: true,
onboardingUI: true, onboardingUI: true,
personalDashboardUI: true,
}, },
}, },
authentication: { authentication: {