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:
parent
4e8d9a2319
commit
fee2143edf
@ -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 />
|
||||||
|
@ -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());
|
||||||
|
};
|
@ -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;
|
||||||
};
|
};
|
||||||
|
@ -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 [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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[]>;
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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',
|
||||||
|
@ -54,6 +54,7 @@ process.nextTick(async () => {
|
|||||||
addonUsageMetrics: true,
|
addonUsageMetrics: true,
|
||||||
onboardingMetrics: true,
|
onboardingMetrics: true,
|
||||||
onboardingUI: true,
|
onboardingUI: true,
|
||||||
|
personalDashboardUI: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
authentication: {
|
authentication: {
|
||||||
|
Loading…
Reference in New Issue
Block a user