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

feat: favorite feature and project (#2582)

## About the changes
Add an ability to star a toggle from it's overiew.

Co-authored-by: sjaanus <sellinjaanus@gmail.com>
This commit is contained in:
Tymoteusz Czech 2022-12-02 08:16:03 +01:00 committed by GitHub
parent a2321192fc
commit 79e96fdb98
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 370 additions and 247 deletions

View File

@ -0,0 +1,52 @@
import React, { VFC } from 'react';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { IconButton } from '@mui/material';
import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender';
import {
Star as StarIcon,
StarBorder as StarBorderIcon,
} from '@mui/icons-material';
interface IFavoriteIconButtonProps {
onClick: (event?: any) => void;
isFavorite: boolean;
size?: 'medium' | 'large';
}
export const FavoriteIconButton: VFC<IFavoriteIconButtonProps> = ({
onClick,
isFavorite,
size = 'large',
}) => {
return (
<IconButton size={size} data-loading sx={{ mr: 1 }} onClick={onClick}>
<ConditionallyRender
condition={isFavorite}
show={
<StarIcon
color="primary"
sx={{
fontSize: theme =>
size === 'medium'
? theme.spacing(2)
: theme.spacing(3),
}}
/>
}
elseShow={
<StarBorderIcon
sx={{
fontSize: theme =>
size === 'medium'
? theme.spacing(2)
: theme.spacing(3),
}}
/>
}
/>
</IconButton>
);
};

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, VFC } from 'react'; import { useCallback, useEffect, useMemo, useState, VFC } from 'react';
import { Link, useMediaQuery, useTheme } from '@mui/material'; import { Link, useMediaQuery, useTheme } from '@mui/material';
import { Link as RouterLink, useSearchParams } from 'react-router-dom'; import { Link as RouterLink, useSearchParams } from 'react-router-dom';
import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table'; import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table';
@ -50,7 +50,7 @@ export const FeatureToggleListTable: VFC = () => {
const theme = useTheme(); const theme = useTheme();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg')); const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg'));
const { features = [], loading } = useFeatures(); const { features = [], loading, refetchFeatures } = useFeatures();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [initialState] = useState(() => ({ const [initialState] = useState(() => ({
sortBy: [ sortBy: [
@ -73,6 +73,17 @@ export const FeatureToggleListTable: VFC = () => {
const [searchValue, setSearchValue] = useState(initialState.globalFilter); const [searchValue, setSearchValue] = useState(initialState.globalFilter);
const { favorite, unfavorite } = useFavoriteFeaturesApi(); const { favorite, unfavorite } = useFavoriteFeaturesApi();
const { uiConfig } = useUiConfig(); const { uiConfig } = useUiConfig();
const onFavorite = useCallback(
async (feature: any) => {
if (feature?.favorite) {
await unfavorite(feature.project, feature.name);
} else {
await favorite(feature.project, feature.name);
}
refetchFeatures();
},
[favorite, refetchFeatures, unfavorite]
);
const columns = useMemo( const columns = useMemo(
() => [ () => [
@ -89,17 +100,7 @@ export const FeatureToggleListTable: VFC = () => {
Cell: ({ row: { original: feature } }: any) => ( Cell: ({ row: { original: feature } }: any) => (
<FavoriteIconCell <FavoriteIconCell
value={feature?.favorite} value={feature?.favorite}
onClick={() => onClick={() => onFavorite(feature)}
feature?.favorite
? unfavorite(
feature.project,
feature.name
)
: favorite(
feature.project,
feature.name
)
}
/> />
), ),
maxWidth: 50, maxWidth: 50,

View File

@ -20,7 +20,7 @@ export const useStyles = makeStyles()(theme => ({
display: 'flex', display: 'flex',
}, },
innerContainer: { innerContainer: {
padding: '1rem 2rem', padding: theme.spacing(2, 4, 2, 2),
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',

View File

@ -1,6 +1,13 @@
import { Tab, Tabs, useMediaQuery } from '@mui/material'; import { IconButton, Tab, Tabs, useMediaQuery } from '@mui/material';
import React, { useState } from 'react'; import React, { useCallback, useState } from 'react';
import { Archive, FileCopy, Label, WatchLater } from '@mui/icons-material'; import {
Archive,
FileCopy,
Label,
WatchLater,
Star as StarIcon,
StarBorder as StarBorderIcon,
} from '@mui/icons-material';
import { import {
Link, Link,
Route, Route,
@ -29,18 +36,23 @@ import AddTagDialog from './FeatureOverview/AddTagDialog/AddTagDialog';
import { FeatureStatusChip } from 'component/common/FeatureStatusChip/FeatureStatusChip'; import { FeatureStatusChip } from 'component/common/FeatureStatusChip/FeatureStatusChip';
import { FeatureNotFound } from 'component/feature/FeatureView/FeatureNotFound/FeatureNotFound'; import { FeatureNotFound } from 'component/feature/FeatureView/FeatureNotFound/FeatureNotFound';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { FeatureArchiveDialog } from '../../common/FeatureArchiveDialog/FeatureArchiveDialog'; import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog';
import { DraftBanner } from 'component/changeRequest/DraftBanner/DraftBanner'; import { DraftBanner } from 'component/changeRequest/DraftBanner/DraftBanner';
import { MainLayout } from 'component/layout/MainLayout/MainLayout'; import { MainLayout } from 'component/layout/MainLayout/MainLayout';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { FavoriteIconButton } from 'component/common/FavoriteIconButton/FavoriteIconButton';
export const FeatureView = () => { export const FeatureView = () => {
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId'); const featureId = useRequiredPathParam('featureId');
const { refetch: projectRefetch } = useProject(projectId); const { refetch: projectRefetch } = useProject(projectId);
const { favorite, unfavorite } = useFavoriteFeaturesApi();
const { refetchFeature } = useFeature(projectId, featureId); const { refetchFeature } = useFeature(projectId, featureId);
const { isChangeRequestConfiguredInAnyEnv } = const { isChangeRequestConfiguredInAnyEnv } =
useChangeRequestsEnabled(projectId); useChangeRequestsEnabled(projectId);
const { uiConfig } = useUiConfig();
const [openTagDialog, setOpenTagDialog] = useState(false); const [openTagDialog, setOpenTagDialog] = useState(false);
const [showDelDialog, setShowDelDialog] = useState(false); const [showDelDialog, setShowDelDialog] = useState(false);
@ -85,6 +97,15 @@ export const FeatureView = () => {
return <FeatureNotFound />; return <FeatureNotFound />;
} }
const onFavorite = async () => {
if (feature?.favorite) {
await unfavorite(projectId, feature.name);
} else {
await favorite(projectId, feature.name);
}
refetchFeature();
};
return ( return (
<MainLayout <MainLayout
ref={ref} ref={ref}
@ -101,6 +122,17 @@ export const FeatureView = () => {
<div className={styles.header}> <div className={styles.header}>
<div className={styles.innerContainer}> <div className={styles.innerContainer}>
<div className={styles.toggleInfoContainer}> <div className={styles.toggleInfoContainer}>
<ConditionallyRender
condition={Boolean(
uiConfig?.flags?.favorites
)}
show={() => (
<FavoriteIconButton
onClick={onFavorite}
isFavorite={feature?.favorite}
/>
)}
/>
<h1 <h1
className={styles.featureViewHeader} className={styles.featureViewHeader}
data-loading data-loading

View File

@ -7,7 +7,7 @@ import { styled, Tab, Tabs } from '@mui/material';
import { Delete, Edit } from '@mui/icons-material'; import { Delete, Edit } from '@mui/icons-material';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import useQueryParams from 'hooks/useQueryParams'; import useQueryParams from 'hooks/useQueryParams';
import { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { ProjectAccess } from '../ProjectAccess/ProjectAccess'; import { ProjectAccess } from '../ProjectAccess/ProjectAccess';
import ProjectEnvironment from '../ProjectEnvironment/ProjectEnvironment'; import ProjectEnvironment from '../ProjectEnvironment/ProjectEnvironment';
import { ProjectFeaturesArchive } from './ProjectFeaturesArchive/ProjectFeaturesArchive'; import { ProjectFeaturesArchive } from './ProjectFeaturesArchive/ProjectFeaturesArchive';
@ -29,6 +29,8 @@ import { MainLayout } from 'component/layout/MainLayout/MainLayout';
import { ProjectChangeRequests } from '../../changeRequest/ProjectChangeRequests/ProjectChangeRequests'; import { ProjectChangeRequests } from '../../changeRequest/ProjectChangeRequests/ProjectChangeRequests';
import { ProjectSettings } from './ProjectSettings/ProjectSettings'; import { ProjectSettings } from './ProjectSettings/ProjectSettings';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { FavoriteIconButton } from '../../common/FavoriteIconButton/FavoriteIconButton';
import { useFavoriteProjectsApi } from '../../../hooks/api/actions/useFavoriteProjectsApi/useFavoriteProjectsApi';
const StyledDiv = styled('div')(() => ({ const StyledDiv = styled('div')(() => ({
display: 'flex', display: 'flex',
@ -52,17 +54,18 @@ const StyledText = styled(StyledTitle)(({ theme }) => ({
const Project = () => { const Project = () => {
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const params = useQueryParams(); const params = useQueryParams();
const { project, loading } = useProject(projectId); const { project, loading, refetch } = useProject(projectId);
const ref = useLoading(loading); const ref = useLoading(loading);
const { setToastData } = useToast(); const { setToastData } = useToast();
const { classes: styles } = useStyles(); const { classes: styles } = useStyles();
const navigate = useNavigate(); const navigate = useNavigate();
const { pathname } = useLocation(); const { pathname } = useLocation();
const { isOss } = useUiConfig(); const { isOss, uiConfig } = useUiConfig();
const basePath = `/projects/${projectId}`; const basePath = `/projects/${projectId}`;
const projectName = project?.name || projectId; const projectName = project?.name || projectId;
const { isChangeRequestConfiguredInAnyEnv, isChangeRequestFlagEnabled } = const { isChangeRequestConfiguredInAnyEnv, isChangeRequestFlagEnabled } =
useChangeRequestsEnabled(projectId); useChangeRequestsEnabled(projectId);
const { favorite, unfavorite } = useFavoriteProjectsApi();
const [showDelDialog, setShowDelDialog] = useState(false); const [showDelDialog, setShowDelDialog] = useState(false);
@ -144,6 +147,15 @@ const Project = () => {
/* eslint-disable-next-line */ /* eslint-disable-next-line */
}, []); }, []);
const onFavorite = async () => {
if (project?.favorite) {
await unfavorite(projectId);
} else {
await favorite(projectId);
}
refetch();
};
return ( return (
<MainLayout <MainLayout
ref={ref} ref={ref}
@ -155,6 +167,15 @@ const Project = () => {
> >
<div className={styles.header}> <div className={styles.header}>
<div className={styles.innerContainer}> <div className={styles.innerContainer}>
<ConditionallyRender
condition={Boolean(uiConfig?.flags?.favorites)}
show={() => (
<FavoriteIconButton
onClick={onFavorite}
isFavorite={project?.favorite}
/>
)}
/>
<h2 className={styles.title}> <h2 className={styles.title}>
<div> <div>
<StyledName data-loading>{projectName}</StyledName> <StyledName data-loading>{projectName}</StyledName>

View File

@ -2,7 +2,7 @@ import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({ export const useStyles = makeStyles()(theme => ({
projectCard: { projectCard: {
padding: '1rem', padding: theme.spacing(1, 2, 2, 2),
width: '220px', width: '220px',
height: '204px', height: '204px',
display: 'flex', display: 'flex',
@ -22,7 +22,6 @@ export const useStyles = makeStyles()(theme => ({
header: { header: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between',
}, },
title: { title: {
fontWeight: 'normal', fontWeight: 'normal',
@ -54,6 +53,8 @@ export const useStyles = makeStyles()(theme => ({
}, },
actionsBtn: { actionsBtn: {
transform: 'translateX(15px)', transform: 'translateX(15px)',
marginLeft: 'auto',
marginRight: theme.spacing(1),
}, },
icon: { icon: {
color: theme.palette.grey[700], color: theme.palette.grey[700],

View File

@ -14,8 +14,11 @@ import {
import AccessContext from 'contexts/AccessContext'; import AccessContext from 'contexts/AccessContext';
import { DEFAULT_PROJECT_ID } from 'hooks/api/getters/useDefaultProject/useDefaultProjectId'; import { DEFAULT_PROJECT_ID } from 'hooks/api/getters/useDefaultProject/useDefaultProjectId';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import useProjects from 'hooks/api/getters/useProjects/useProjects';
import { useFavoriteProjectsApi } from 'hooks/api/actions/useFavoriteProjectsApi/useFavoriteProjectsApi';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { FavoriteIconButton } from 'component/common/FavoriteIconButton/FavoriteIconButton';
import { DeleteProjectDialogue } from '../Project/DeleteProject/DeleteProjectDialogue'; import { DeleteProjectDialogue } from '../Project/DeleteProject/DeleteProjectDialogue';
import { ConditionallyRender } from '../../common/ConditionallyRender/ConditionallyRender';
interface IProjectCardProps { interface IProjectCardProps {
name: string; name: string;
@ -24,6 +27,7 @@ interface IProjectCardProps {
memberCount: number; memberCount: number;
id: string; id: string;
onHover: () => void; onHover: () => void;
isFavorite?: boolean;
} }
export const ProjectCard = ({ export const ProjectCard = ({
@ -33,13 +37,16 @@ export const ProjectCard = ({
memberCount, memberCount,
onHover, onHover,
id, id,
isFavorite = false,
}: IProjectCardProps) => { }: IProjectCardProps) => {
const { classes } = useStyles(); const { classes } = useStyles();
const { hasAccess } = useContext(AccessContext); const { hasAccess } = useContext(AccessContext);
const { isOss } = useUiConfig(); const { isOss, uiConfig } = useUiConfig();
const [anchorEl, setAnchorEl] = useState<Element | null>(null); const [anchorEl, setAnchorEl] = useState<Element | null>(null);
const [showDelDialog, setShowDelDialog] = useState(false); const [showDelDialog, setShowDelDialog] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const { favorite, unfavorite } = useFavoriteProjectsApi();
const { refetch } = useProjects();
const handleClick = (event: React.SyntheticEvent) => { const handleClick = (event: React.SyntheticEvent) => {
event.preventDefault(); event.preventDefault();
@ -49,9 +56,29 @@ export const ProjectCard = ({
const canDeleteProject = const canDeleteProject =
hasAccess(DELETE_PROJECT, id) && id !== DEFAULT_PROJECT_ID; hasAccess(DELETE_PROJECT, id) && id !== DEFAULT_PROJECT_ID;
const onFavorite = async (e: Event) => {
e.preventDefault();
if (isFavorite) {
await unfavorite(id);
} else {
await favorite(id);
}
refetch();
};
return ( return (
<Card className={classes.projectCard} onMouseEnter={onHover}> <Card className={classes.projectCard} onMouseEnter={onHover}>
<div className={classes.header} data-loading> <div className={classes.header} data-loading>
<ConditionallyRender
condition={Boolean(uiConfig?.flags?.favorites)}
show={() => (
<FavoriteIconButton
onClick={onFavorite}
isFavorite={isFavorite}
size="medium"
/>
)}
/>
<h2 className={classes.title}>{name}</h2> <h2 className={classes.title}>{name}</h2>
<PermissionIconButton <PermissionIconButton

View File

@ -77,7 +77,6 @@ export const ProjectListNew = () => {
const { classes: styles } = useStyles(); const { classes: styles } = useStyles();
const { projects, loading, error, refetch } = useProjects(); const { projects, loading, error, refetch } = useProjects();
const [fetchedProjects, setFetchedProjects] = useState<projectMap>({}); const [fetchedProjects, setFetchedProjects] = useState<projectMap>({});
const ref = useLoading(loading);
const { isOss } = useUiConfig(); const { isOss } = useUiConfig();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
@ -99,9 +98,19 @@ export const ProjectListNew = () => {
const filteredProjects = useMemo(() => { const filteredProjects = useMemo(() => {
const regExp = new RegExp(searchValue, 'i'); const regExp = new RegExp(searchValue, 'i');
return searchValue return (
? projects.filter(project => regExp.test(project.name)) searchValue
: projects; ? projects.filter(project => regExp.test(project.name))
: projects
).sort((a, b) => {
if (a?.favorite && !b?.favorite) {
return -1;
}
if (!a?.favorite && b?.favorite) {
return 1;
}
return 0;
});
}, [projects, searchValue]); }, [projects, searchValue]);
const handleHover = (projectId: string) => { const handleHover = (projectId: string) => {
@ -129,124 +138,126 @@ export const ProjectListNew = () => {
); );
}; };
const renderProjects = () => {
if (loading) {
return renderLoading();
}
return filteredProjects.map((project: IProjectCard) => {
return (
<Link
key={project.id}
to={`/projects/${project.id}`}
className={styles.cardLink}
>
<ProjectCard
onHover={() => handleHover(project.id)}
name={project.name}
memberCount={project.memberCount ?? 0}
health={project.health}
id={project.id}
featureCount={project.featureCount}
/>
</Link>
);
});
};
const renderLoading = () => {
return loadingData.map((project: IProjectCard) => {
return (
<ProjectCard
data-loading
onHover={() => {}}
key={project.id}
name={project.name}
id={project.id}
memberCount={2}
health={95}
featureCount={4}
/>
);
});
};
let projectCount = let projectCount =
filteredProjects.length < projects.length filteredProjects.length < projects.length
? `${filteredProjects.length} of ${projects.length}` ? `${filteredProjects.length} of ${projects.length}`
: projects.length; : projects.length;
return ( return (
<div ref={ref}> <PageContent
<PageContent isLoading={loading}
header={ header={
<PageHeader <PageHeader
title={`Projects (${projectCount})`} title={`Projects (${projectCount})`}
actions={ actions={
<> <>
<ConditionallyRender
condition={!isSmallScreen}
show={
<>
<Search
initialValue={searchValue}
onChange={setSearchValue}
/>
<PageHeader.Divider />
</>
}
/>
<ResponsiveButton
Icon={Add}
endIcon={createButtonData.endIcon}
onClick={() => navigate('/projects/create')}
maxWidth="700px"
permission={CREATE_PROJECT}
disabled={createButtonData.disabled}
tooltipProps={createButtonData.tooltip}
>
New project
</ResponsiveButton>
</>
}
>
<ConditionallyRender
condition={isSmallScreen}
show={
<Search
initialValue={searchValue}
onChange={setSearchValue}
/>
}
/>
</PageHeader>
}
>
<ConditionallyRender condition={error} show={renderError()} />
<div className={styles.container}>
<ConditionallyRender
condition={filteredProjects.length < 1 && !loading}
show={
<ConditionallyRender <ConditionallyRender
condition={searchValue?.length > 0} condition={!isSmallScreen}
show={ show={
<TablePlaceholder> <>
No projects found matching &ldquo; <Search
{searchValue} initialValue={searchValue}
&rdquo; onChange={setSearchValue}
</TablePlaceholder> />
} <PageHeader.Divider />
elseShow={ </>
<TablePlaceholder>
No projects available.
</TablePlaceholder>
} }
/> />
<ResponsiveButton
Icon={Add}
endIcon={createButtonData.endIcon}
onClick={() => navigate('/projects/create')}
maxWidth="700px"
permission={CREATE_PROJECT}
disabled={createButtonData.disabled}
tooltipProps={createButtonData.tooltip}
>
New project
</ResponsiveButton>
</>
}
>
<ConditionallyRender
condition={isSmallScreen}
show={
<Search
initialValue={searchValue}
onChange={setSearchValue}
/>
} }
elseShow={renderProjects()}
/> />
</div> </PageHeader>
</PageContent> }
</div> >
<ConditionallyRender condition={error} show={renderError()} />
<div className={styles.container}>
<ConditionallyRender
condition={filteredProjects.length < 1 && !loading}
show={
<ConditionallyRender
condition={searchValue?.length > 0}
show={
<TablePlaceholder>
No projects found matching &ldquo;
{searchValue}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No projects available.
</TablePlaceholder>
}
/>
}
elseShow={
<ConditionallyRender
condition={loading}
show={() =>
loadingData.map((project: IProjectCard) => (
<ProjectCard
data-loading
onHover={() => {}}
key={project.id}
name={project.name}
id={project.id}
memberCount={2}
health={95}
featureCount={4}
/>
))
}
elseShow={() =>
filteredProjects.map(
(project: IProjectCard) => (
<Link
key={project.id}
to={`/projects/${project.id}`}
className={styles.cardLink}
>
<ProjectCard
onHover={() =>
handleHover(project.id)
}
name={project.name}
memberCount={
project.memberCount ?? 0
}
health={project.health}
id={project.id}
featureCount={
project.featureCount
}
isFavorite={project.favorite}
/>
</Link>
)
)
}
/>
}
/>
</div>
</PageContent>
); );
}; };

View File

@ -1,17 +1,14 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { useFeatures } from 'hooks/api/getters/useFeatures/useFeatures'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import useAPI from '../useApi/useApi'; import useAPI from '../useApi/useApi';
import useProject from 'hooks/api/getters/useProject/useProject';
import { usePlausibleTracker } from '../../../usePlausibleTracker';
export const useFavoriteFeaturesApi = () => { export const useFavoriteFeaturesApi = () => {
const { makeRequest, createRequest, errors, loading } = useAPI({ const { makeRequest, createRequest, errors, loading } = useAPI({
propagateErrors: true, propagateErrors: true,
}); });
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
const { refetchFeatures } = useFeatures();
const { trackEvent } = usePlausibleTracker(); const { trackEvent } = usePlausibleTracker();
const favorite = useCallback( const favorite = useCallback(
@ -35,7 +32,6 @@ export const useFavoriteFeaturesApi = () => {
eventType: `feature favorited`, eventType: `feature favorited`,
}, },
}); });
refetchFeatures();
} catch (error) { } catch (error) {
setToastApiError(formatUnknownError(error)); setToastApiError(formatUnknownError(error));
} }
@ -64,7 +60,6 @@ export const useFavoriteFeaturesApi = () => {
eventType: `feature unfavorited`, eventType: `feature unfavorited`,
}, },
}); });
refetchFeatures();
} catch (error) { } catch (error) {
setToastApiError(formatUnknownError(error)); setToastApiError(formatUnknownError(error));
} }

View File

@ -0,0 +1,76 @@
import { useCallback } from 'react';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import useAPI from '../useApi/useApi';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
export const useFavoriteProjectsApi = () => {
const { makeRequest, createRequest, errors, loading } = useAPI({
propagateErrors: true,
});
const { setToastData, setToastApiError } = useToast();
const { trackEvent } = usePlausibleTracker();
const favorite = useCallback(
async (projectId: string) => {
const path = `api/admin/projects/${projectId}/favorites`;
const req = createRequest(
path,
{ method: 'POST' },
'addFavoriteProject'
);
try {
await makeRequest(req.caller, req.id);
setToastData({
title: 'Project added to favorites',
type: 'success',
});
trackEvent('favorite', {
props: {
eventType: `project favorited`,
},
});
} catch (error) {
setToastApiError(formatUnknownError(error));
}
},
[createRequest, makeRequest]
);
const unfavorite = useCallback(
async (projectId: string) => {
const path = `api/admin/projects/${projectId}/favorites`;
const req = createRequest(
path,
{ method: 'DELETE' },
'removeFavoriteProject'
);
try {
await makeRequest(req.caller, req.id);
setToastData({
title: 'Project removed from favorites',
type: 'success',
});
trackEvent('favorite', {
props: {
eventType: `project unfavorited`,
},
});
} catch (error) {
setToastApiError(formatUnknownError(error));
}
},
[createRequest, makeRequest]
);
return {
favorite,
unfavorite,
errors,
loading,
};
};

View File

@ -11,5 +11,6 @@ export const emptyFeature: IFeatureToggle = {
project: '', project: '',
variants: [], variants: [],
description: '', description: '',
favorite: false,
impressionData: false, impressionData: false,
}; };

View File

@ -11,6 +11,7 @@ const fallbackProject: IProject = {
members: 0, members: 0,
version: '1', version: '1',
description: 'Default', description: 'Default',
favorite: false,
}; };
const useProject = (id: string, options: SWRConfiguration = {}) => { const useProject = (id: string, options: SWRConfiguration = {}) => {

View File

@ -26,6 +26,8 @@ export interface IFeatureToggle {
description?: string; description?: string;
environments: IFeatureEnvironment[]; environments: IFeatureEnvironment[];
name: string; name: string;
favorite: boolean;
project: string; project: string;
type: string; type: string;
variants: IFeatureVariant[]; variants: IFeatureVariant[];

View File

@ -8,6 +8,7 @@ export interface IProjectCard {
description: string; description: string;
featureCount: number; featureCount: number;
memberCount?: number; memberCount?: number;
favorite?: boolean;
} }
export interface IProject { export interface IProject {
@ -18,6 +19,8 @@ export interface IProject {
description?: string; description?: string;
environments: string[]; environments: string[];
health: number; health: number;
favorite: boolean;
features: IFeatureToggleListItem[]; features: IFeatureToggleListItem[];
} }

View File

@ -27,6 +27,7 @@ import PatController from './user/pat';
import { PublicSignupController } from './public-signup'; import { PublicSignupController } from './public-signup';
import InstanceAdminController from './instance-admin'; import InstanceAdminController from './instance-admin';
import FavoritesController from './favorites'; import FavoritesController from './favorites';
import { conditionalMiddleware } from '../../middleware';
class AdminApi extends Controller { class AdminApi extends Controller {
constructor(config: IUnleashConfig, services: IUnleashServices) { constructor(config: IUnleashConfig, services: IUnleashServices) {
@ -118,7 +119,10 @@ class AdminApi extends Controller {
); );
this.app.use( this.app.use(
`/projects`, `/projects`,
new FavoritesController(config, services).router, conditionalMiddleware(
() => config.flagResolver.isEnabled('favorites'),
new FavoritesController(config, services).router,
),
); );
} }
} }

View File

@ -5169,50 +5169,6 @@ If the provided project does not exist, the list of events will be empty.",
], ],
}, },
}, },
"/api/admin/projects/{projectId}/favorites": {
"delete": {
"operationId": "removeFavoriteProject",
"parameters": [
{
"in": "path",
"name": "projectId",
"required": true,
"schema": {
"type": "string",
},
},
],
"responses": {
"200": {
"description": "This response has no body.",
},
},
"tags": [
"Features",
],
},
"post": {
"operationId": "addFavoriteProject",
"parameters": [
{
"in": "path",
"name": "projectId",
"required": true,
"schema": {
"type": "string",
},
},
],
"responses": {
"200": {
"description": "This response has no body.",
},
},
"tags": [
"Features",
],
},
},
"/api/admin/projects/{projectId}/features": { "/api/admin/projects/{projectId}/features": {
"get": { "get": {
"operationId": "getFeatures", "operationId": "getFeatures",
@ -6184,66 +6140,6 @@ If the provided project does not exist, the list of events will be empty.",
], ],
}, },
}, },
"/api/admin/projects/{projectId}/features/{featureName}/favorites": {
"delete": {
"operationId": "removeFavoriteFeature",
"parameters": [
{
"in": "path",
"name": "projectId",
"required": true,
"schema": {
"type": "string",
},
},
{
"in": "path",
"name": "featureName",
"required": true,
"schema": {
"type": "string",
},
},
],
"responses": {
"200": {
"description": "This response has no body.",
},
},
"tags": [
"Features",
],
},
"post": {
"operationId": "addFavoriteFeature",
"parameters": [
{
"in": "path",
"name": "projectId",
"required": true,
"schema": {
"type": "string",
},
},
{
"in": "path",
"name": "featureName",
"required": true,
"schema": {
"type": "string",
},
},
],
"responses": {
"200": {
"description": "This response has no body.",
},
},
"tags": [
"Features",
],
},
},
"/api/admin/projects/{projectId}/features/{featureName}/variants": { "/api/admin/projects/{projectId}/features/{featureName}/variants": {
"get": { "get": {
"operationId": "getFeatureVariants", "operationId": "getFeatureVariants",