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

Refactor front end code pt 1 (#8438)

This PR is the first in the front end code refactoring. It moves My
Flags out into a separate file and includes some extra error handling
(such as if the name of the flag causes problems for the API).


![image](https://github.com/user-attachments/assets/5aec8f0c-de79-4b7d-b56b-42297b872ec5)
This commit is contained in:
Thomas Heartman 2024-10-14 11:45:37 +02:00 committed by GitHub
parent d8ddb57818
commit 8c2ed5dc30
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 362 additions and 269 deletions

View File

@ -121,10 +121,12 @@ const useFlagMetrics = (
environment: string | null, environment: string | null,
hoursBack: number, hoursBack: number,
) => { ) => {
const { featureMetrics: metrics = [], loading } = useFeatureMetricsRaw( const {
flagName, featureMetrics: metrics = [],
hoursBack, loading,
); error,
} = useFeatureMetricsRaw(flagName, hoursBack);
const sortedMetrics = useMemo(() => { const sortedMetrics = useMemo(() => {
return [...metrics].sort((metricA, metricB) => { return [...metrics].sort((metricA, metricB) => {
return metricA.timestamp.localeCompare(metricB.timestamp); return metricA.timestamp.localeCompare(metricB.timestamp);
@ -151,7 +153,7 @@ const useFlagMetrics = (
return createBarChartOptions(theme, hoursBack, locationSettings); return createBarChartOptions(theme, hoursBack, locationSettings);
}, [theme, hoursBack, locationSettings]); }, [theme, hoursBack, locationSettings]);
return { data, options, loading }; return { data, options, loading, error };
}; };
const EnvironmentSelect: FC<{ const EnvironmentSelect: FC<{
@ -222,11 +224,22 @@ export const FlagMetricsChart: FC<{
const { environment, setEnvironment, activeEnvironments } = const { environment, setEnvironment, activeEnvironments } =
useMetricsEnvironments(flag.project, flag.name); useMetricsEnvironments(flag.project, flag.name);
const { data, options, loading } = useFlagMetrics( const {
flag.name, data,
environment, options,
hoursBack, loading,
); error: metricsError,
} = useFlagMetrics(flag.name, environment, hoursBack);
if (metricsError) {
return (
<ChartContainer>
<PlaceholderFlagMetricsChart
label={`Couldn't fetch metrics for the current flag. This may be a transient error, or your flag name ("${flag.name}") may be causing issues.`}
/>
</ChartContainer>
);
}
const noData = data.datasets[0].data.length === 0; const noData = data.datasets[0].data.length === 0;

View File

@ -58,7 +58,7 @@ export const FlagGrid = styled(ContentGrid)(
); );
export const GridItem = styled('div', { export const GridItem = styled('div', {
shouldForwardProp: (prop) => !['gridArea', 'sx'].includes(prop.toString()), shouldForwardProp: (prop) => !['gridArea'].includes(prop.toString()),
})<{ gridArea: string }>(({ theme, gridArea }) => ({ })<{ gridArea: string }>(({ theme, gridArea }) => ({
padding: theme.spacing(2, 4), padding: theme.spacing(2, 4),
maxHeight: '100%', maxHeight: '100%',
@ -113,3 +113,20 @@ export const StyledList = styled(List)(({ theme }) => ({
maxHeight: '100%', maxHeight: '100%',
})({ theme }), })({ theme }),
})); }));
export const StyledCardTitle = styled('div')<{ lines?: number }>(
({ theme, lines = 2 }) => ({
fontWeight: theme.typography.fontWeightRegular,
fontSize: theme.typography.body1.fontSize,
lineClamp: `${lines}`,
WebkitLineClamp: lines,
lineHeight: '1.2',
display: '-webkit-box',
boxOrient: 'vertical',
textOverflow: 'ellipsis',
overflow: 'hidden',
alignItems: 'flex-start',
WebkitBoxOrient: 'vertical',
wordBreak: 'break-word',
}),
);

View File

@ -0,0 +1,180 @@
import { type FC, useEffect, useRef } from 'react';
import {
ContentGridContainer,
FlagGrid,
ListItemBox,
SpacedGridItem,
StyledCardTitle,
StyledList,
listItemStyle,
} from './Grid';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { getFeatureTypeIcons } from 'utils/getFeatureTypeIcons';
import {
Alert,
IconButton,
Link,
ListItem,
ListItemButton,
Typography,
styled,
} from '@mui/material';
import LinkIcon from '@mui/icons-material/ArrowForward';
import React from 'react';
import type { PersonalDashboardSchemaFlagsItem } from 'openapi';
const NoActiveFlagsInfo = styled('div')(({ theme }) => ({
display: 'flex',
flexFlow: 'column',
gap: theme.spacing(2),
}));
const FlagListItem: FC<{
flag: { name: string; project: string; type: string };
selected: boolean;
onClick: () => void;
}> = ({ flag, selected, onClick }) => {
const activeFlagRef = useRef<HTMLLIElement>(null);
const { trackEvent } = usePlausibleTracker();
useEffect(() => {
if (activeFlagRef.current) {
activeFlagRef.current.scrollIntoView({
block: 'nearest',
inline: 'start',
});
}
}, []);
const IconComponent = getFeatureTypeIcons(flag.type);
const flagLink = `projects/${flag.project}/features/${flag.name}`;
return (
<ListItem
key={flag.name}
disablePadding={true}
sx={{ mb: 1 }}
ref={selected ? activeFlagRef : null}
>
<ListItemButton
sx={listItemStyle}
selected={selected}
onClick={onClick}
>
<ListItemBox>
<IconComponent color='primary' />
<StyledCardTitle>{flag.name}</StyledCardTitle>
<IconButton
component={Link}
href={flagLink}
onClick={() => {
trackEvent('personal-dashboard', {
props: {
eventType: `Go to flag from list`,
},
});
}}
size='small'
sx={{ ml: 'auto' }}
>
<LinkIcon titleAccess={flagLink} />
</IconButton>
</ListItemBox>
</ListItemButton>
</ListItem>
);
};
type FlagData =
| {
state: 'flags';
flags: PersonalDashboardSchemaFlagsItem[];
activeFlag: PersonalDashboardSchemaFlagsItem;
}
| {
state: 'no flags';
};
type Props = {
hasProjects: boolean;
flagData: FlagData;
setActiveFlag: (flag: PersonalDashboardSchemaFlagsItem) => void;
refetchDashboard: () => void;
};
export const MyFlags: FC<Props> = ({
hasProjects,
flagData,
setActiveFlag,
refetchDashboard,
}) => {
return (
<ContentGridContainer>
<FlagGrid>
<SpacedGridItem gridArea='flags'>
{flagData.state === 'flags' ? (
<StyledList
disablePadding={true}
sx={{
height: '100%',
overflow: 'auto',
}}
>
{flagData.flags.map((flag) => (
<FlagListItem
key={flag.name}
flag={flag}
selected={
flag.name === flagData.activeFlag.name
}
onClick={() => setActiveFlag(flag)}
/>
))}
</StyledList>
) : hasProjects ? (
<NoActiveFlagsInfo>
<Typography>
You have not created or favorited any feature
flags. Once you do, they will show up here.
</Typography>
<Typography>
To create a new flag, go to one of your
projects.
</Typography>
</NoActiveFlagsInfo>
) : (
<Alert severity='info'>
You need to create or join a project to be able to
add a flag, or you must be given the rights by your
admin to add feature flags.
</Alert>
)}
</SpacedGridItem>
<SpacedGridItem gridArea='chart'>
{flagData.state === 'flags' ? (
<FlagMetricsChart
flag={flagData.activeFlag}
onArchive={refetchDashboard}
/>
) : (
<PlaceholderFlagMetricsChart
label={
'Metrics for your feature flags will be shown here'
}
/>
)}
</SpacedGridItem>
</FlagGrid>
</ContentGridContainer>
);
};
const FlagMetricsChart = React.lazy(() =>
import('./FlagMetricsChart').then((module) => ({
default: module.FlagMetricsChart,
})),
);
const PlaceholderFlagMetricsChart = React.lazy(() =>
import('./FlagMetricsChart').then((module) => ({
default: module.PlaceholderFlagMetricsChartWithWrapper,
})),
);

View File

@ -13,7 +13,6 @@ import { ConnectSDK, CreateFlag, ExistingFlag } from './ConnectSDK';
import { LatestProjectEvents } from './LatestProjectEvents'; import { LatestProjectEvents } from './LatestProjectEvents';
import { RoleAndOwnerInfo } from './RoleAndOwnerInfo'; import { RoleAndOwnerInfo } from './RoleAndOwnerInfo';
import { forwardRef, useEffect, useRef, type FC } from 'react'; import { forwardRef, useEffect, useRef, type FC } from 'react';
import { StyledCardTitle } from './PersonalDashboard';
import type { import type {
PersonalDashboardProjectDetailsSchema, PersonalDashboardProjectDetailsSchema,
PersonalDashboardSchemaAdminsItem, PersonalDashboardSchemaAdminsItem,
@ -28,6 +27,7 @@ import {
GridItem, GridItem,
SpacedGridItem, SpacedGridItem,
StyledList, StyledList,
StyledCardTitle,
} from './Grid'; } from './Grid';
import { ContactAdmins, DataError } from './ProjectDetailsError'; import { ContactAdmins, DataError } from './ProjectDetailsError';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker';

View File

@ -3,201 +3,23 @@ import {
Accordion, Accordion,
AccordionDetails, AccordionDetails,
AccordionSummary, AccordionSummary,
Alert,
Button, Button,
IconButton,
Link,
ListItem,
ListItemButton,
styled, styled,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import React, { type FC, useEffect, useRef } from 'react';
import LinkIcon from '@mui/icons-material/ArrowForward';
import { WelcomeDialog } from './WelcomeDialog'; import { WelcomeDialog } from './WelcomeDialog';
import { useLocalStorageState } from 'hooks/useLocalStorageState'; import { useLocalStorageState } from 'hooks/useLocalStorageState';
import { usePersonalDashboard } from 'hooks/api/getters/usePersonalDashboard/usePersonalDashboard'; import { usePersonalDashboard } from 'hooks/api/getters/usePersonalDashboard/usePersonalDashboard';
import { getFeatureTypeIcons } from 'utils/getFeatureTypeIcons';
import type {
PersonalDashboardSchemaFlagsItem,
PersonalDashboardSchemaProjectsItem,
} from '../../openapi';
import { usePersonalDashboardProjectDetails } from 'hooks/api/getters/usePersonalDashboard/usePersonalDashboardProjectDetails'; import { usePersonalDashboardProjectDetails } from 'hooks/api/getters/usePersonalDashboard/usePersonalDashboardProjectDetails';
import useLoading from '../../hooks/useLoading'; import useLoading from '../../hooks/useLoading';
import { MyProjects } from './MyProjects'; import { MyProjects } from './MyProjects';
import {
ContentGridContainer,
FlagGrid,
ListItemBox,
listItemStyle,
SpacedGridItem,
StyledList,
} from './Grid';
import { ContentGridNoProjects } from './ContentGridNoProjects'; import { ContentGridNoProjects } from './ContentGridNoProjects';
import ExpandMore from '@mui/icons-material/ExpandMore'; import ExpandMore from '@mui/icons-material/ExpandMore';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import useSplashApi from 'hooks/api/actions/useSplashApi/useSplashApi'; import useSplashApi from 'hooks/api/actions/useSplashApi/useSplashApi';
import { useAuthSplash } from 'hooks/api/getters/useAuth/useAuthSplash'; import { useAuthSplash } from 'hooks/api/getters/useAuth/useAuthSplash';
import { useDashboardState } from './useDashboardState';
export const StyledCardTitle = styled('div')<{ lines?: number }>( import { MyFlags } from './MyFlags';
({ theme, lines = 2 }) => ({
fontWeight: theme.typography.fontWeightRegular,
fontSize: theme.typography.body1.fontSize,
lineClamp: `${lines}`,
WebkitLineClamp: lines,
lineHeight: '1.2',
display: '-webkit-box',
boxOrient: 'vertical',
textOverflow: 'ellipsis',
overflow: 'hidden',
alignItems: 'flex-start',
WebkitBoxOrient: 'vertical',
wordBreak: 'break-word',
}),
);
const FlagListItem: FC<{
flag: { name: string; project: string; type: string };
selected: boolean;
onClick: () => void;
}> = ({ flag, selected, onClick }) => {
const activeFlagRef = useRef<HTMLLIElement>(null);
const { trackEvent } = usePlausibleTracker();
useEffect(() => {
if (activeFlagRef.current) {
activeFlagRef.current.scrollIntoView({
block: 'nearest',
inline: 'start',
});
}
}, []);
const IconComponent = getFeatureTypeIcons(flag.type);
const flagLink = `projects/${flag.project}/features/${flag.name}`;
return (
<ListItem
key={flag.name}
disablePadding={true}
sx={{ mb: 1 }}
ref={selected ? activeFlagRef : null}
>
<ListItemButton
sx={listItemStyle}
selected={selected}
onClick={onClick}
>
<ListItemBox>
<IconComponent color='primary' />
<StyledCardTitle>{flag.name}</StyledCardTitle>
<IconButton
component={Link}
href={flagLink}
onClick={() => {
trackEvent('personal-dashboard', {
props: {
eventType: `Go to flag from list`,
},
});
}}
size='small'
sx={{ ml: 'auto' }}
>
<LinkIcon titleAccess={flagLink} />
</IconButton>
</ListItemBox>
</ListItemButton>
</ListItem>
);
};
// todo: move into own file
const useDashboardState = (
projects: PersonalDashboardSchemaProjectsItem[],
flags: PersonalDashboardSchemaFlagsItem[],
) => {
type State = {
activeProject: string | undefined;
activeFlag: PersonalDashboardSchemaFlagsItem | undefined;
expandProjects: boolean;
expandFlags: boolean;
};
const defaultState: State = {
activeProject: undefined,
activeFlag: undefined,
expandProjects: true,
expandFlags: true,
};
const [state, setState] = useLocalStorageState<State>(
'personal-dashboard:v1',
defaultState,
);
const updateState = (newState: Partial<State>) =>
setState({ ...defaultState, ...state, ...newState });
useEffect(() => {
const updates: Partial<State> = {};
const setDefaultFlag =
flags.length &&
(!state.activeFlag ||
!flags.some((flag) => flag.name === state.activeFlag?.name));
if (setDefaultFlag) {
updates.activeFlag = flags[0];
}
const setDefaultProject =
projects.length &&
(!state.activeProject ||
!projects.some(
(project) => project.id === state.activeProject,
));
if (setDefaultProject) {
updates.activeProject = projects[0].id;
}
if (Object.keys(updates).length) {
updateState(updates);
}
}, [
JSON.stringify(projects, null, 2),
JSON.stringify(flags, null, 2),
JSON.stringify(state, null, 2),
]);
const { activeFlag, activeProject } = state;
const setActiveFlag = (flag: PersonalDashboardSchemaFlagsItem) => {
updateState({
activeFlag: flag,
});
};
const setActiveProject = (projectId: string) => {
updateState({
activeProject: projectId,
});
};
const toggleSectionState = (section: 'flags' | 'projects') => {
const property = section === 'flags' ? 'expandFlags' : 'expandProjects';
updateState({
[property]: !(state[property] ?? true),
});
};
return {
activeFlag,
setActiveFlag,
activeProject,
setActiveProject,
expandFlags: state.expandFlags ?? true,
expandProjects: state.expandProjects ?? true,
toggleSectionState,
};
};
const WelcomeSection = styled('div')(({ theme }) => ({ const WelcomeSection = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
@ -264,6 +86,28 @@ const NoActiveFlagsInfo = styled('div')(({ theme }) => ({
gap: theme.spacing(2), gap: theme.spacing(2),
})); }));
type DashboardState =
| {
state: 'flags and projects';
// regular state; show everything
activeFlag: any;
activeProject: any;
}
| {
state: 'projects, no flags';
// show projects as normal, tell the user to create a flag
activeProject: any;
}
| {
state: 'no projects, no flags';
// no projects and no flags; show information about admins, project owners, and tell the user to join a project to create a flags
}
| {
state: 'flags, no projects';
// show info about admins + project owners, regular flags
activeFlag: any;
};
export const PersonalDashboard = () => { export const PersonalDashboard = () => {
const { user } = useAuthUser(); const { user } = useAuthUser();
const { trackEvent } = usePlausibleTracker(); const { trackEvent } = usePlausibleTracker();
@ -272,8 +116,11 @@ export const PersonalDashboard = () => {
const name = user?.name; const name = user?.name;
const { personalDashboard, refetch: refetchDashboard } = const {
usePersonalDashboard(); personalDashboard,
refetch: refetchDashboard,
loading: personalDashboardLoading,
} = usePersonalDashboard();
const projects = personalDashboard?.projects || []; const projects = personalDashboard?.projects || [];
@ -385,70 +232,22 @@ export const PersonalDashboard = () => {
</Typography> </Typography>
</StyledAccordionSummary> </StyledAccordionSummary>
<StyledAccordionDetails> <StyledAccordionDetails>
<ContentGridContainer> <MyFlags
<FlagGrid> hasProjects={projects?.length > 0}
<SpacedGridItem gridArea='flags'> flagData={
{personalDashboard && personalDashboard &&
personalDashboard.flags.length > 0 ? ( personalDashboard.flags.length &&
<StyledList activeFlag
disablePadding={true} ? {
sx={{ state: 'flags' as const,
height: '100%', activeFlag,
overflow: 'auto', flags: personalDashboard.flags,
}} }
> : { state: 'no flags' as const }
{personalDashboard.flags.map((flag) => ( }
<FlagListItem setActiveFlag={setActiveFlag}
key={flag.name} refetchDashboard={refetchDashboard}
flag={flag} />
selected={
flag.name ===
activeFlag?.name
}
onClick={() =>
setActiveFlag(flag)
}
/>
))}
</StyledList>
) : activeProject ? (
<NoActiveFlagsInfo>
<Typography>
You have not created or favorited
any feature flags. Once you do, they
will show up here.
</Typography>
<Typography>
To create a new flag, go to one of
your projects.
</Typography>
</NoActiveFlagsInfo>
) : (
<Alert severity='info'>
You need to create or join a project to
be able to add a flag, or you must be
given the rights by your admin to add
feature flags.
</Alert>
)}
</SpacedGridItem>
<SpacedGridItem gridArea='chart'>
{activeFlag ? (
<FlagMetricsChart
flag={activeFlag}
onArchive={refetchDashboard}
/>
) : (
<PlaceholderFlagMetricsChart
label={
'Metrics for your feature flags will be shown here'
}
/>
)}
</SpacedGridItem>
</FlagGrid>
</ContentGridContainer>
</StyledAccordionDetails> </StyledAccordionDetails>
</SectionAccordion> </SectionAccordion>
<WelcomeDialog <WelcomeDialog
@ -461,14 +260,3 @@ export const PersonalDashboard = () => {
</MainContent> </MainContent>
); );
}; };
const FlagMetricsChart = React.lazy(() =>
import('./FlagMetricsChart').then((module) => ({
default: module.FlagMetricsChart,
})),
);
const PlaceholderFlagMetricsChart = React.lazy(() =>
import('./FlagMetricsChart').then((module) => ({
default: module.PlaceholderFlagMetricsChartWithWrapper,
})),
);

View File

@ -0,0 +1,95 @@
import { useLocalStorageState } from 'hooks/useLocalStorageState';
import type {
PersonalDashboardSchemaFlagsItem,
PersonalDashboardSchemaProjectsItem,
} from 'openapi';
import { useEffect } from 'react';
export const useDashboardState = (
projects: PersonalDashboardSchemaProjectsItem[],
flags: PersonalDashboardSchemaFlagsItem[],
) => {
type State = {
activeProject: string | undefined;
activeFlag: PersonalDashboardSchemaFlagsItem | undefined;
expandProjects: boolean;
expandFlags: boolean;
};
const defaultState: State = {
activeProject: undefined,
activeFlag: undefined,
expandProjects: true,
expandFlags: true,
};
const [state, setState] = useLocalStorageState<State>(
'personal-dashboard:v1',
defaultState,
);
const updateState = (newState: Partial<State>) =>
setState({ ...defaultState, ...state, ...newState });
useEffect(() => {
const updates: Partial<State> = {};
const setDefaultFlag =
flags.length &&
(!state.activeFlag ||
!flags.some((flag) => flag.name === state.activeFlag?.name));
if (setDefaultFlag) {
updates.activeFlag = flags[0];
}
const setDefaultProject =
projects.length &&
(!state.activeProject ||
!projects.some(
(project) => project.id === state.activeProject,
));
if (setDefaultProject) {
updates.activeProject = projects[0].id;
}
if (Object.keys(updates).length) {
updateState(updates);
}
}, [
JSON.stringify(projects, null, 2),
JSON.stringify(flags, null, 2),
JSON.stringify(state, null, 2),
]);
const { activeFlag, activeProject } = state;
const setActiveFlag = (flag: PersonalDashboardSchemaFlagsItem) => {
updateState({
activeFlag: flag,
});
};
const setActiveProject = (projectId: string) => {
updateState({
activeProject: projectId,
});
};
const toggleSectionState = (section: 'flags' | 'projects') => {
const property = section === 'flags' ? 'expandFlags' : 'expandProjects';
updateState({
[property]: !(state[property] ?? true),
});
};
return {
activeFlag,
setActiveFlag,
activeProject,
setActiveProject,
expandFlags: state.expandFlags ?? true,
expandProjects: state.expandProjects ?? true,
toggleSectionState,
};
};