From 79e96fdb98412926e117a1187341d327bf77cfc9 Mon Sep 17 00:00:00 2001 From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Date: Fri, 2 Dec 2022 08:16:03 +0100 Subject: [PATCH] feat: favorite feature and project (#2582) ## About the changes Add an ability to star a toggle from it's overiew. Co-authored-by: sjaanus --- .../FavoriteIconButton/FavoriteIconButton.tsx | 52 ++++ .../FeatureToggleListTable.tsx | 27 +- .../feature/FeatureView/FeatureView.styles.ts | 2 +- .../feature/FeatureView/FeatureView.tsx | 40 ++- .../src/component/project/Project/Project.tsx | 27 +- .../project/ProjectCard/ProjectCard.styles.ts | 5 +- .../project/ProjectCard/ProjectCard.tsx | 31 ++- .../project/ProjectList/ProjectList.tsx | 233 +++++++++--------- .../useFavoriteFeaturesApi.ts | 7 +- .../useFavoriteProjectsApi.ts | 76 ++++++ .../api/getters/useFeature/emptyFeature.ts | 1 + .../api/getters/useProject/useProject.ts | 1 + frontend/src/interfaces/featureToggle.ts | 2 + frontend/src/interfaces/project.ts | 3 + src/lib/routes/admin-api/index.ts | 6 +- .../__snapshots__/openapi.e2e.test.ts.snap | 104 -------- 16 files changed, 370 insertions(+), 247 deletions(-) create mode 100644 frontend/src/component/common/FavoriteIconButton/FavoriteIconButton.tsx create mode 100644 frontend/src/hooks/api/actions/useFavoriteProjectsApi/useFavoriteProjectsApi.ts diff --git a/frontend/src/component/common/FavoriteIconButton/FavoriteIconButton.tsx b/frontend/src/component/common/FavoriteIconButton/FavoriteIconButton.tsx new file mode 100644 index 0000000000..5cf1cce351 --- /dev/null +++ b/frontend/src/component/common/FavoriteIconButton/FavoriteIconButton.tsx @@ -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 = ({ + onClick, + isFavorite, + size = 'large', +}) => { + return ( + + + size === 'medium' + ? theme.spacing(2) + : theme.spacing(3), + }} + /> + } + elseShow={ + + size === 'medium' + ? theme.spacing(2) + : theme.spacing(3), + }} + /> + } + /> + + ); +}; diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx index fdeb85b329..92e2375f6c 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx @@ -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) => ( - feature?.favorite - ? unfavorite( - feature.project, - feature.name - ) - : favorite( - feature.project, - feature.name - ) - } + onClick={() => onFavorite(feature)} /> ), maxWidth: 50, diff --git a/frontend/src/component/feature/FeatureView/FeatureView.styles.ts b/frontend/src/component/feature/FeatureView/FeatureView.styles.ts index e81b728aaf..524ae5eddc 100644 --- a/frontend/src/component/feature/FeatureView/FeatureView.styles.ts +++ b/frontend/src/component/feature/FeatureView/FeatureView.styles.ts @@ -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', diff --git a/frontend/src/component/feature/FeatureView/FeatureView.tsx b/frontend/src/component/feature/FeatureView/FeatureView.tsx index 7bca7dd32c..917aac0836 100644 --- a/frontend/src/component/feature/FeatureView/FeatureView.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureView.tsx @@ -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 ; } + const onFavorite = async () => { + if (feature?.favorite) { + await unfavorite(projectId, feature.name); + } else { + await favorite(projectId, feature.name); + } + refetchFeature(); + }; + return ( {
+ ( + + )} + />

({ 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 ( { >
+ ( + + )} + />

{projectName} diff --git a/frontend/src/component/project/ProjectCard/ProjectCard.styles.ts b/frontend/src/component/project/ProjectCard/ProjectCard.styles.ts index a4a3f3fafb..38cccac9e5 100644 --- a/frontend/src/component/project/ProjectCard/ProjectCard.styles.ts +++ b/frontend/src/component/project/ProjectCard/ProjectCard.styles.ts @@ -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], diff --git a/frontend/src/component/project/ProjectCard/ProjectCard.tsx b/frontend/src/component/project/ProjectCard/ProjectCard.tsx index 37e916bbd6..157ef1fb9c 100644 --- a/frontend/src/component/project/ProjectCard/ProjectCard.tsx +++ b/frontend/src/component/project/ProjectCard/ProjectCard.tsx @@ -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(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 (
+ ( + + )} + />

{name}

{ const { classes: styles } = useStyles(); const { projects, loading, error, refetch } = useProjects(); const [fetchedProjects, setFetchedProjects] = useState({}); - 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 ( - - handleHover(project.id)} - name={project.name} - memberCount={project.memberCount ?? 0} - health={project.health} - id={project.id} - featureCount={project.featureCount} - /> - - ); - }); - }; - - const renderLoading = () => { - return loadingData.map((project: IProjectCard) => { - return ( - {}} - 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 ( -
- - - - - - } - /> - navigate('/projects/create')} - maxWidth="700px" - permission={CREATE_PROJECT} - disabled={createButtonData.disabled} - tooltipProps={createButtonData.tooltip} - > - New project - - - } - > - - } - /> - - } - > - -
- 0} + condition={!isSmallScreen} show={ - - No projects found matching “ - {searchValue} - ” - - } - elseShow={ - - No projects available. - + <> + + + } /> + navigate('/projects/create')} + maxWidth="700px" + permission={CREATE_PROJECT} + disabled={createButtonData.disabled} + tooltipProps={createButtonData.tooltip} + > + New project + + + } + > + } - elseShow={renderProjects()} /> -
-
-
+ + } + > + +
+ 0} + show={ + + No projects found matching “ + {searchValue} + ” + + } + elseShow={ + + No projects available. + + } + /> + } + elseShow={ + + loadingData.map((project: IProjectCard) => ( + {}} + key={project.id} + name={project.name} + id={project.id} + memberCount={2} + health={95} + featureCount={4} + /> + )) + } + elseShow={() => + filteredProjects.map( + (project: IProjectCard) => ( + + + handleHover(project.id) + } + name={project.name} + memberCount={ + project.memberCount ?? 0 + } + health={project.health} + id={project.id} + featureCount={ + project.featureCount + } + isFavorite={project.favorite} + /> + + ) + ) + } + /> + } + /> +
+ ); }; diff --git a/frontend/src/hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi.ts b/frontend/src/hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi.ts index 501610da23..7355624c60 100644 --- a/frontend/src/hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi.ts +++ b/frontend/src/hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi.ts @@ -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)); } diff --git a/frontend/src/hooks/api/actions/useFavoriteProjectsApi/useFavoriteProjectsApi.ts b/frontend/src/hooks/api/actions/useFavoriteProjectsApi/useFavoriteProjectsApi.ts new file mode 100644 index 0000000000..2fb274ac12 --- /dev/null +++ b/frontend/src/hooks/api/actions/useFavoriteProjectsApi/useFavoriteProjectsApi.ts @@ -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, + }; +}; diff --git a/frontend/src/hooks/api/getters/useFeature/emptyFeature.ts b/frontend/src/hooks/api/getters/useFeature/emptyFeature.ts index f2da62298f..3530baff4b 100644 --- a/frontend/src/hooks/api/getters/useFeature/emptyFeature.ts +++ b/frontend/src/hooks/api/getters/useFeature/emptyFeature.ts @@ -11,5 +11,6 @@ export const emptyFeature: IFeatureToggle = { project: '', variants: [], description: '', + favorite: false, impressionData: false, }; diff --git a/frontend/src/hooks/api/getters/useProject/useProject.ts b/frontend/src/hooks/api/getters/useProject/useProject.ts index a7977e10fe..3228f1156b 100644 --- a/frontend/src/hooks/api/getters/useProject/useProject.ts +++ b/frontend/src/hooks/api/getters/useProject/useProject.ts @@ -11,6 +11,7 @@ const fallbackProject: IProject = { members: 0, version: '1', description: 'Default', + favorite: false, }; const useProject = (id: string, options: SWRConfiguration = {}) => { diff --git a/frontend/src/interfaces/featureToggle.ts b/frontend/src/interfaces/featureToggle.ts index 9d440b6979..ef784e1174 100644 --- a/frontend/src/interfaces/featureToggle.ts +++ b/frontend/src/interfaces/featureToggle.ts @@ -26,6 +26,8 @@ export interface IFeatureToggle { description?: string; environments: IFeatureEnvironment[]; name: string; + + favorite: boolean; project: string; type: string; variants: IFeatureVariant[]; diff --git a/frontend/src/interfaces/project.ts b/frontend/src/interfaces/project.ts index b9e3adba44..17a6840dda 100644 --- a/frontend/src/interfaces/project.ts +++ b/frontend/src/interfaces/project.ts @@ -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[]; } diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts index 811dde570e..cd9c046656 100644 --- a/src/lib/routes/admin-api/index.ts +++ b/src/lib/routes/admin-api/index.ts @@ -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, + ), ); } } diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index 17a592675c..ea382a685d 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -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",