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:
parent
a2321192fc
commit
79e96fdb98
@ -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>
|
||||
);
|
||||
};
|
@ -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 as RouterLink, useSearchParams } from 'react-router-dom';
|
||||
import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table';
|
||||
@ -50,7 +50,7 @@ export const FeatureToggleListTable: VFC = () => {
|
||||
const theme = useTheme();
|
||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg'));
|
||||
const { features = [], loading } = useFeatures();
|
||||
const { features = [], loading, refetchFeatures } = useFeatures();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [initialState] = useState(() => ({
|
||||
sortBy: [
|
||||
@ -73,6 +73,17 @@ export const FeatureToggleListTable: VFC = () => {
|
||||
const [searchValue, setSearchValue] = useState(initialState.globalFilter);
|
||||
const { favorite, unfavorite } = useFavoriteFeaturesApi();
|
||||
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(
|
||||
() => [
|
||||
@ -89,17 +100,7 @@ export const FeatureToggleListTable: VFC = () => {
|
||||
Cell: ({ row: { original: feature } }: any) => (
|
||||
<FavoriteIconCell
|
||||
value={feature?.favorite}
|
||||
onClick={() =>
|
||||
feature?.favorite
|
||||
? unfavorite(
|
||||
feature.project,
|
||||
feature.name
|
||||
)
|
||||
: favorite(
|
||||
feature.project,
|
||||
feature.name
|
||||
)
|
||||
}
|
||||
onClick={() => onFavorite(feature)}
|
||||
/>
|
||||
),
|
||||
maxWidth: 50,
|
||||
|
@ -20,7 +20,7 @@ export const useStyles = makeStyles()(theme => ({
|
||||
display: 'flex',
|
||||
},
|
||||
innerContainer: {
|
||||
padding: '1rem 2rem',
|
||||
padding: theme.spacing(2, 4, 2, 2),
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
|
@ -1,6 +1,13 @@
|
||||
import { Tab, Tabs, useMediaQuery } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import { Archive, FileCopy, Label, WatchLater } from '@mui/icons-material';
|
||||
import { IconButton, Tab, Tabs, useMediaQuery } from '@mui/material';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import {
|
||||
Archive,
|
||||
FileCopy,
|
||||
Label,
|
||||
WatchLater,
|
||||
Star as StarIcon,
|
||||
StarBorder as StarBorderIcon,
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
Link,
|
||||
Route,
|
||||
@ -29,18 +36,23 @@ import AddTagDialog from './FeatureOverview/AddTagDialog/AddTagDialog';
|
||||
import { FeatureStatusChip } from 'component/common/FeatureStatusChip/FeatureStatusChip';
|
||||
import { FeatureNotFound } from 'component/feature/FeatureView/FeatureNotFound/FeatureNotFound';
|
||||
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 { MainLayout } from 'component/layout/MainLayout/MainLayout';
|
||||
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 = () => {
|
||||
const projectId = useRequiredPathParam('projectId');
|
||||
const featureId = useRequiredPathParam('featureId');
|
||||
const { refetch: projectRefetch } = useProject(projectId);
|
||||
const { favorite, unfavorite } = useFavoriteFeaturesApi();
|
||||
const { refetchFeature } = useFeature(projectId, featureId);
|
||||
const { isChangeRequestConfiguredInAnyEnv } =
|
||||
useChangeRequestsEnabled(projectId);
|
||||
const { uiConfig } = useUiConfig();
|
||||
|
||||
const [openTagDialog, setOpenTagDialog] = useState(false);
|
||||
const [showDelDialog, setShowDelDialog] = useState(false);
|
||||
@ -85,6 +97,15 @@ export const FeatureView = () => {
|
||||
return <FeatureNotFound />;
|
||||
}
|
||||
|
||||
const onFavorite = async () => {
|
||||
if (feature?.favorite) {
|
||||
await unfavorite(projectId, feature.name);
|
||||
} else {
|
||||
await favorite(projectId, feature.name);
|
||||
}
|
||||
refetchFeature();
|
||||
};
|
||||
|
||||
return (
|
||||
<MainLayout
|
||||
ref={ref}
|
||||
@ -101,6 +122,17 @@ export const FeatureView = () => {
|
||||
<div className={styles.header}>
|
||||
<div className={styles.innerContainer}>
|
||||
<div className={styles.toggleInfoContainer}>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(
|
||||
uiConfig?.flags?.favorites
|
||||
)}
|
||||
show={() => (
|
||||
<FavoriteIconButton
|
||||
onClick={onFavorite}
|
||||
isFavorite={feature?.favorite}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<h1
|
||||
className={styles.featureViewHeader}
|
||||
data-loading
|
||||
|
@ -7,7 +7,7 @@ import { styled, Tab, Tabs } from '@mui/material';
|
||||
import { Delete, Edit } from '@mui/icons-material';
|
||||
import useToast from 'hooks/useToast';
|
||||
import useQueryParams from 'hooks/useQueryParams';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { ProjectAccess } from '../ProjectAccess/ProjectAccess';
|
||||
import ProjectEnvironment from '../ProjectEnvironment/ProjectEnvironment';
|
||||
import { ProjectFeaturesArchive } from './ProjectFeaturesArchive/ProjectFeaturesArchive';
|
||||
@ -29,6 +29,8 @@ import { MainLayout } from 'component/layout/MainLayout/MainLayout';
|
||||
import { ProjectChangeRequests } from '../../changeRequest/ProjectChangeRequests/ProjectChangeRequests';
|
||||
import { ProjectSettings } from './ProjectSettings/ProjectSettings';
|
||||
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
|
||||
import { FavoriteIconButton } from '../../common/FavoriteIconButton/FavoriteIconButton';
|
||||
import { useFavoriteProjectsApi } from '../../../hooks/api/actions/useFavoriteProjectsApi/useFavoriteProjectsApi';
|
||||
|
||||
const StyledDiv = styled('div')(() => ({
|
||||
display: 'flex',
|
||||
@ -52,17 +54,18 @@ const StyledText = styled(StyledTitle)(({ theme }) => ({
|
||||
const Project = () => {
|
||||
const projectId = useRequiredPathParam('projectId');
|
||||
const params = useQueryParams();
|
||||
const { project, loading } = useProject(projectId);
|
||||
const { project, loading, refetch } = useProject(projectId);
|
||||
const ref = useLoading(loading);
|
||||
const { setToastData } = useToast();
|
||||
const { classes: styles } = useStyles();
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const { isOss } = useUiConfig();
|
||||
const { isOss, uiConfig } = useUiConfig();
|
||||
const basePath = `/projects/${projectId}`;
|
||||
const projectName = project?.name || projectId;
|
||||
const { isChangeRequestConfiguredInAnyEnv, isChangeRequestFlagEnabled } =
|
||||
useChangeRequestsEnabled(projectId);
|
||||
const { favorite, unfavorite } = useFavoriteProjectsApi();
|
||||
|
||||
const [showDelDialog, setShowDelDialog] = useState(false);
|
||||
|
||||
@ -144,6 +147,15 @@ const Project = () => {
|
||||
/* eslint-disable-next-line */
|
||||
}, []);
|
||||
|
||||
const onFavorite = async () => {
|
||||
if (project?.favorite) {
|
||||
await unfavorite(projectId);
|
||||
} else {
|
||||
await favorite(projectId);
|
||||
}
|
||||
refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<MainLayout
|
||||
ref={ref}
|
||||
@ -155,6 +167,15 @@ const Project = () => {
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.innerContainer}>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(uiConfig?.flags?.favorites)}
|
||||
show={() => (
|
||||
<FavoriteIconButton
|
||||
onClick={onFavorite}
|
||||
isFavorite={project?.favorite}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<h2 className={styles.title}>
|
||||
<div>
|
||||
<StyledName data-loading>{projectName}</StyledName>
|
||||
|
@ -2,7 +2,7 @@ import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
export const useStyles = makeStyles()(theme => ({
|
||||
projectCard: {
|
||||
padding: '1rem',
|
||||
padding: theme.spacing(1, 2, 2, 2),
|
||||
width: '220px',
|
||||
height: '204px',
|
||||
display: 'flex',
|
||||
@ -22,7 +22,6 @@ export const useStyles = makeStyles()(theme => ({
|
||||
header: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
title: {
|
||||
fontWeight: 'normal',
|
||||
@ -54,6 +53,8 @@ export const useStyles = makeStyles()(theme => ({
|
||||
},
|
||||
actionsBtn: {
|
||||
transform: 'translateX(15px)',
|
||||
marginLeft: 'auto',
|
||||
marginRight: theme.spacing(1),
|
||||
},
|
||||
icon: {
|
||||
color: theme.palette.grey[700],
|
||||
|
@ -14,8 +14,11 @@ import {
|
||||
import AccessContext from 'contexts/AccessContext';
|
||||
import { DEFAULT_PROJECT_ID } from 'hooks/api/getters/useDefaultProject/useDefaultProjectId';
|
||||
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 { ConditionallyRender } from '../../common/ConditionallyRender/ConditionallyRender';
|
||||
|
||||
interface IProjectCardProps {
|
||||
name: string;
|
||||
@ -24,6 +27,7 @@ interface IProjectCardProps {
|
||||
memberCount: number;
|
||||
id: string;
|
||||
onHover: () => void;
|
||||
isFavorite?: boolean;
|
||||
}
|
||||
|
||||
export const ProjectCard = ({
|
||||
@ -33,13 +37,16 @@ export const ProjectCard = ({
|
||||
memberCount,
|
||||
onHover,
|
||||
id,
|
||||
isFavorite = false,
|
||||
}: IProjectCardProps) => {
|
||||
const { classes } = useStyles();
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
const { isOss } = useUiConfig();
|
||||
const { isOss, uiConfig } = useUiConfig();
|
||||
const [anchorEl, setAnchorEl] = useState<Element | null>(null);
|
||||
const [showDelDialog, setShowDelDialog] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { favorite, unfavorite } = useFavoriteProjectsApi();
|
||||
const { refetch } = useProjects();
|
||||
|
||||
const handleClick = (event: React.SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
@ -49,9 +56,29 @@ export const ProjectCard = ({
|
||||
const canDeleteProject =
|
||||
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 (
|
||||
<Card className={classes.projectCard} onMouseEnter={onHover}>
|
||||
<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>
|
||||
|
||||
<PermissionIconButton
|
||||
|
@ -77,7 +77,6 @@ export const ProjectListNew = () => {
|
||||
const { classes: styles } = useStyles();
|
||||
const { projects, loading, error, refetch } = useProjects();
|
||||
const [fetchedProjects, setFetchedProjects] = useState<projectMap>({});
|
||||
const ref = useLoading(loading);
|
||||
const { isOss } = useUiConfig();
|
||||
|
||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||
@ -99,9 +98,19 @@ export const ProjectListNew = () => {
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
const regExp = new RegExp(searchValue, 'i');
|
||||
return searchValue
|
||||
? projects.filter(project => regExp.test(project.name))
|
||||
: projects;
|
||||
return (
|
||||
searchValue
|
||||
? 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]);
|
||||
|
||||
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 =
|
||||
filteredProjects.length < projects.length
|
||||
? `${filteredProjects.length} of ${projects.length}`
|
||||
: projects.length;
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<PageContent
|
||||
header={
|
||||
<PageHeader
|
||||
title={`Projects (${projectCount})`}
|
||||
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={
|
||||
<PageContent
|
||||
isLoading={loading}
|
||||
header={
|
||||
<PageHeader
|
||||
title={`Projects (${projectCount})`}
|
||||
actions={
|
||||
<>
|
||||
<ConditionallyRender
|
||||
condition={searchValue?.length > 0}
|
||||
condition={!isSmallScreen}
|
||||
show={
|
||||
<TablePlaceholder>
|
||||
No projects found matching “
|
||||
{searchValue}
|
||||
”
|
||||
</TablePlaceholder>
|
||||
}
|
||||
elseShow={
|
||||
<TablePlaceholder>
|
||||
No projects available.
|
||||
</TablePlaceholder>
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
}
|
||||
elseShow={renderProjects()}
|
||||
/>
|
||||
</div>
|
||||
</PageContent>
|
||||
</div>
|
||||
</PageHeader>
|
||||
}
|
||||
>
|
||||
<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 “
|
||||
{searchValue}
|
||||
”
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
@ -1,17 +1,14 @@
|
||||
import { useCallback } from 'react';
|
||||
import useToast from 'hooks/useToast';
|
||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||
import { useFeatures } from 'hooks/api/getters/useFeatures/useFeatures';
|
||||
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
||||
import useAPI from '../useApi/useApi';
|
||||
import useProject from 'hooks/api/getters/useProject/useProject';
|
||||
import { usePlausibleTracker } from '../../../usePlausibleTracker';
|
||||
|
||||
export const useFavoriteFeaturesApi = () => {
|
||||
const { makeRequest, createRequest, errors, loading } = useAPI({
|
||||
propagateErrors: true,
|
||||
});
|
||||
const { setToastData, setToastApiError } = useToast();
|
||||
const { refetchFeatures } = useFeatures();
|
||||
const { trackEvent } = usePlausibleTracker();
|
||||
|
||||
const favorite = useCallback(
|
||||
@ -35,7 +32,6 @@ export const useFavoriteFeaturesApi = () => {
|
||||
eventType: `feature favorited`,
|
||||
},
|
||||
});
|
||||
refetchFeatures();
|
||||
} catch (error) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
}
|
||||
@ -64,7 +60,6 @@ export const useFavoriteFeaturesApi = () => {
|
||||
eventType: `feature unfavorited`,
|
||||
},
|
||||
});
|
||||
refetchFeatures();
|
||||
} catch (error) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
@ -11,5 +11,6 @@ export const emptyFeature: IFeatureToggle = {
|
||||
project: '',
|
||||
variants: [],
|
||||
description: '',
|
||||
favorite: false,
|
||||
impressionData: false,
|
||||
};
|
||||
|
@ -11,6 +11,7 @@ const fallbackProject: IProject = {
|
||||
members: 0,
|
||||
version: '1',
|
||||
description: 'Default',
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
const useProject = (id: string, options: SWRConfiguration = {}) => {
|
||||
|
@ -26,6 +26,8 @@ export interface IFeatureToggle {
|
||||
description?: string;
|
||||
environments: IFeatureEnvironment[];
|
||||
name: string;
|
||||
|
||||
favorite: boolean;
|
||||
project: string;
|
||||
type: string;
|
||||
variants: IFeatureVariant[];
|
||||
|
@ -8,6 +8,7 @@ export interface IProjectCard {
|
||||
description: string;
|
||||
featureCount: number;
|
||||
memberCount?: number;
|
||||
favorite?: boolean;
|
||||
}
|
||||
|
||||
export interface IProject {
|
||||
@ -18,6 +19,8 @@ export interface IProject {
|
||||
description?: string;
|
||||
environments: string[];
|
||||
health: number;
|
||||
|
||||
favorite: boolean;
|
||||
features: IFeatureToggleListItem[];
|
||||
}
|
||||
|
||||
|
@ -27,6 +27,7 @@ import PatController from './user/pat';
|
||||
import { PublicSignupController } from './public-signup';
|
||||
import InstanceAdminController from './instance-admin';
|
||||
import FavoritesController from './favorites';
|
||||
import { conditionalMiddleware } from '../../middleware';
|
||||
|
||||
class AdminApi extends Controller {
|
||||
constructor(config: IUnleashConfig, services: IUnleashServices) {
|
||||
@ -118,7 +119,10 @@ class AdminApi extends Controller {
|
||||
);
|
||||
this.app.use(
|
||||
`/projects`,
|
||||
new FavoritesController(config, services).router,
|
||||
conditionalMiddleware(
|
||||
() => config.flagResolver.isEnabled('favorites'),
|
||||
new FavoritesController(config, services).router,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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": {
|
||||
"get": {
|
||||
"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": {
|
||||
"get": {
|
||||
"operationId": "getFeatureVariants",
|
||||
|
Loading…
Reference in New Issue
Block a user