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 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,

View File

@ -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',

View File

@ -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

View File

@ -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>

View File

@ -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],

View File

@ -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

View File

@ -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 &ldquo;
{searchValue}
&rdquo;
</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 &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 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));
}

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: '',
variants: [],
description: '',
favorite: false,
impressionData: false,
};

View File

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

View File

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

View File

@ -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[];
}

View File

@ -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,
),
);
}
}

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": {
"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",