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, 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,
|
||||||
|
@ -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',
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -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],
|
||||||
|
@ -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
|
||||||
|
@ -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 “
|
<Search
|
||||||
{searchValue}
|
initialValue={searchValue}
|
||||||
”
|
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 “
|
||||||
|
{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 { 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));
|
||||||
}
|
}
|
||||||
|
@ -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: '',
|
project: '',
|
||||||
variants: [],
|
variants: [],
|
||||||
description: '',
|
description: '',
|
||||||
|
favorite: false,
|
||||||
impressionData: false,
|
impressionData: false,
|
||||||
};
|
};
|
||||||
|
@ -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 = {}) => {
|
||||||
|
@ -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[];
|
||||||
|
@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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",
|
||||||
|
Loading…
Reference in New Issue
Block a user