1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01: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 useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview';
import { ProjectSetupComplete } from './ProjectSetupComplete';
import { usePersonalDashboard } from 'hooks/api/getters/usePersonalDashboard/usePersonalDashboard';
import { getFeatureTypeIcons } from 'utils/getFeatureTypeIcons';
const ScreenExplanation = styled(Typography)(({ theme }) => ({
marginTop: theme.spacing(1),
@ -136,6 +138,38 @@ const useProjects = () => {
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 = () => {
const { user } = useAuthUser();
@ -143,6 +177,14 @@ export const PersonalDashboard = () => {
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 } =
useProjectOverview(activeProject);
@ -256,11 +298,28 @@ export const PersonalDashboard = () => {
</SpacedGridItem>
<SpacedGridItem item lg={8} md={1} />
<SpacedGridItem item lg={4} md={1}>
<Typography>
You have not created or favorited any feature flags.
Once you do, the will show up here.
</Typography>
{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>
You have not created or favorited any feature flags.
Once you do, they will show up here.
</Typography>
)}
</SpacedGridItem>
<SpacedGridItem item lg={8} md={1}>
<Typography sx={{ mb: 4 }}>Feature flag metrics</Typography>
<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 = {
/** The name of the flag */
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
implements IPersonalDashboardReadModel
{
async getPersonalFeatures(userId: number): Promise<{ name: string }[]> {
async getPersonalFeatures(userId: number): Promise<PersonalFeature[]> {
return [];
}
}

View File

@ -55,9 +55,9 @@ test('should return personal dashboard with own flags and favorited flags', asyn
expect(body).toMatchObject({
projects: [],
flags: [
{ name: 'my_feature_c' },
{ name: 'my_feature_d' },
{ name: 'other_feature_b' },
{ name: 'my_feature_d', type: 'release', project: 'default' },
{ name: 'my_feature_c', type: 'release', project: 'default' },
{ 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 {
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 { IPersonalDashboardReadModel } from './personal-dashboard-read-model-type';
import type {
IPersonalDashboardReadModel,
PersonalFeature,
} from './personal-dashboard-read-model-type';
export class PersonalDashboardReadModel implements IPersonalDashboardReadModel {
private db: Db;
@ -8,15 +11,34 @@ export class PersonalDashboardReadModel implements IPersonalDashboardReadModel {
this.db = db;
}
getPersonalFeatures(userId: number): Promise<{ name: string }[]> {
return this.db<{ name: string }>('favorite_features')
async getPersonalFeatures(userId: number): Promise<PersonalFeature[]> {
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)
.select('feature as name')
.whereNull('features.archived_at')
.select(
'features.name as name',
'features.type',
'features.project',
'features.created_at',
)
.union(function () {
this.select('name')
this.select('name', 'type', 'project', 'created_at')
.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 {
private personalDashboardReadModel: IPersonalDashboardReadModel;
@ -7,7 +10,7 @@ export class PersonalDashboardService {
this.personalDashboardReadModel = personalDashboardReadModel;
}
getPersonalFeatures(userId: number): Promise<{ name: string }[]> {
getPersonalFeatures(userId: number): Promise<PersonalFeature[]> {
return this.personalDashboardReadModel.getPersonalFeatures(userId);
}
}

View File

@ -36,6 +36,16 @@ export const personalDashboardSchema = {
example: 'my-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',

View File

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