From 904193e4377ed37d6f8125b283994cdb2d815840 Mon Sep 17 00:00:00 2001 From: Gimir Date: Wed, 18 Sep 2024 15:58:21 +0300 Subject: [PATCH] feat(chat): add marketplace my applications tab (Issue #2038) (#2138) Co-authored-by: Magomed-Elbi Dzhukalaev Co-authored-by: Alexander <98586297+Alexander-Kezik@users.noreply.github.com> --- apps/chat/src/components/Chatbar/Chatbar.tsx | 20 +- .../components/Common/FolderContextMenu.tsx | 10 +- .../src/components/Common/ItemContextMenu.tsx | 12 +- .../Marketplace/ApplicationCard.tsx | 78 +++++- .../ApplicationDetails/ApplicationDetails.tsx | 6 +- .../ApplicationDetails/ApplicationFooter.tsx | 15 +- .../src/components/Marketplace/CardsList.tsx | 54 +++++ .../components/Marketplace/Marketplace.tsx | 160 +++---------- .../Marketplace/MarketplaceBanner.tsx | 60 +++++ .../Marketplace/MarketplaceFilterbar.tsx | 118 +++++---- .../components/Marketplace/SearchHeader.tsx | 77 ++++++ .../components/Marketplace/TabRenderer.tsx | 225 ++++++++++++++++++ apps/chat/src/constants/marketplace.ts | 5 + .../store/marketplace/marketplace.reducers.ts | 18 +- .../marketplace/marketplace.selectors.ts | 9 +- apps/chat/src/types/applications.ts | 5 + apps/chat/src/utils/app/publications.ts | 3 - 17 files changed, 655 insertions(+), 220 deletions(-) create mode 100644 apps/chat/src/components/Marketplace/CardsList.tsx create mode 100644 apps/chat/src/components/Marketplace/MarketplaceBanner.tsx create mode 100644 apps/chat/src/components/Marketplace/SearchHeader.tsx create mode 100644 apps/chat/src/components/Marketplace/TabRenderer.tsx diff --git a/apps/chat/src/components/Chatbar/Chatbar.tsx b/apps/chat/src/components/Chatbar/Chatbar.tsx index 4f6db0e54b..75a79b390b 100644 --- a/apps/chat/src/components/Chatbar/Chatbar.tsx +++ b/apps/chat/src/components/Chatbar/Chatbar.tsx @@ -53,6 +53,16 @@ const ChatActionsBlock = () => { return ( <> +
+ +
-
- -
); }; diff --git a/apps/chat/src/components/Common/FolderContextMenu.tsx b/apps/chat/src/components/Common/FolderContextMenu.tsx index bdf66543d5..50399c33e9 100644 --- a/apps/chat/src/components/Common/FolderContextMenu.tsx +++ b/apps/chat/src/components/Common/FolderContextMenu.tsx @@ -19,7 +19,6 @@ import { isEntityNameInvalid, } from '@/src/utils/app/common'; import { getRootId } from '@/src/utils/app/id'; -import { isItemPublic } from '@/src/utils/app/publications'; import { isEntityOrParentsExternal } from '@/src/utils/app/share'; import { FeatureType } from '@/src/types/common'; @@ -30,6 +29,8 @@ import { Translation } from '@/src/types/translation'; import { useAppSelector } from '@/src/store/hooks'; import { SettingsSelectors } from '@/src/store/settings/settings.reducers'; +import { PUBLIC_URL_PREFIX } from '@/src/constants/public'; + import ContextMenu from './ContextMenu'; import UnpublishIcon from '@/public/images/icons/unpublish.svg'; @@ -159,7 +160,12 @@ export const FolderContextMenu = ({ dataQa: 'unpublish', display: isPublishingEnabled && - isItemPublic(folder.id) && + folder.id.startsWith( + getRootId({ + featureType, + bucket: PUBLIC_URL_PREFIX, + }), + ) && !!onUnpublish && isSidePanelFolder, Icon: UnpublishIcon, diff --git a/apps/chat/src/components/Common/ItemContextMenu.tsx b/apps/chat/src/components/Common/ItemContextMenu.tsx index 16ccef5c50..d1375af7fc 100644 --- a/apps/chat/src/components/Common/ItemContextMenu.tsx +++ b/apps/chat/src/components/Common/ItemContextMenu.tsx @@ -26,7 +26,6 @@ import { isEntityNameInvalid, } from '@/src/utils/app/common'; import { getRootId } from '@/src/utils/app/id'; -import { isItemPublic } from '@/src/utils/app/publications'; import { isEntityOrParentsExternal } from '@/src/utils/app/share'; import { FeatureType, ShareEntity } from '@/src/types/common'; @@ -37,6 +36,8 @@ import { Translation } from '@/src/types/translation'; import { useAppSelector } from '@/src/store/hooks'; import { SettingsSelectors } from '@/src/store/settings/settings.reducers'; +import { PUBLIC_URL_PREFIX } from '@/src/constants/public'; + import ContextMenu from './ContextMenu'; import UnpublishIcon from '@/public/images/icons/unpublish.svg'; @@ -293,7 +294,14 @@ export default function ItemContextMenu({ name: t('Unpublish'), dataQa: 'unpublish', display: - isPublishingEnabled && !!onUnpublish && isItemPublic(entity.id), + isPublishingEnabled && + !!onUnpublish && + entity.id.startsWith( + getRootId({ + featureType, + bucket: PUBLIC_URL_PREFIX, + }), + ), Icon: UnpublishIcon, onClick: onUnpublish, disabled: disableAll, diff --git a/apps/chat/src/components/Marketplace/ApplicationCard.tsx b/apps/chat/src/components/Marketplace/ApplicationCard.tsx index 151f00727b..cd312a444d 100644 --- a/apps/chat/src/components/Marketplace/ApplicationCard.tsx +++ b/apps/chat/src/components/Marketplace/ApplicationCard.tsx @@ -1,5 +1,6 @@ import { IconDotsVertical, + IconPencilMinus, IconTrashX, IconWorldShare, TablerIconsProps, @@ -10,8 +11,8 @@ import { useTranslation } from 'next-i18next'; import classNames from 'classnames'; +import { getRootId } from '@/src/utils/app/id'; import { isSmallScreen } from '@/src/utils/app/mobile'; -import { isItemPublic } from '@/src/utils/app/publications'; import { FeatureType } from '@/src/types/common'; import { DisplayMenuItemProps } from '@/src/types/menu'; @@ -19,6 +20,12 @@ import { DialAIEntityModel } from '@/src/types/models'; import { PublishActions } from '@/src/types/publication'; import { Translation } from '@/src/types/translation'; +import { useAppSelector } from '@/src/store/hooks'; +import { MarketplaceSelectors } from '@/src/store/marketplace/marketplace.reducers'; + +import { MarketplaceTabs } from '@/src/constants/marketplace'; +import { PUBLIC_URL_PREFIX } from '@/src/constants/public'; + import { ModelIcon } from '@/src/components/Chatbar/ModelIcon'; import ContextMenu from '@/src/components/Common/ContextMenu'; import { EntityMarkdownDescription } from '@/src/components/Common/MarkdownDescription'; @@ -50,7 +57,10 @@ export const CardFooter = () => { interface ApplicationCardProps { entity: DialAIEntityModel; onClick: (entity: DialAIEntityModel) => void; - onPublish: (entity: DialAIEntityModel, action: PublishActions) => void; + onPublish?: (entity: DialAIEntityModel, action: PublishActions) => void; + onDelete?: (entity: DialAIEntityModel) => void; + onEdit?: (entity: DialAIEntityModel) => void; + onRemove?: (entity: DialAIEntityModel) => void; isMobile?: boolean; selected?: boolean; } @@ -58,47 +68,95 @@ interface ApplicationCardProps { export const ApplicationCard = ({ entity, onClick, + onDelete, + onEdit, + onRemove, isMobile, selected, onPublish, }: ApplicationCardProps) => { const { t } = useTranslation(Translation.Marketplace); - const isPublishedEntity = isItemPublic(entity.id); + const selectedTab = useAppSelector(MarketplaceSelectors.selectSelectedTab); + + const isPublishedEntity = entity.id.startsWith( + getRootId({ + featureType: FeatureType.Application, + bucket: PUBLIC_URL_PREFIX, + }), + ); + const isMyEntity = entity.id.startsWith( + getRootId({ featureType: FeatureType.Application }), + ); const menuItems: DisplayMenuItemProps[] = useMemo( () => [ + { + name: t('Edit'), + dataQa: 'edit', + display: isMyEntity && !!onEdit, + Icon: IconPencilMinus, + onClick: (e: React.MouseEvent) => { + e.stopPropagation(); + onEdit?.(entity); + }, + }, { name: t('Publish'), dataQa: 'publish', - display: !isPublishedEntity, + display: isMyEntity && !!onPublish, Icon: IconWorldShare, onClick: (e: React.MouseEvent) => { e.stopPropagation(); - onPublish(entity, PublishActions.ADD); + onPublish?.(entity, PublishActions.ADD); }, }, { name: t('Unpublish'), dataQa: 'unpublish', - display: isPublishedEntity, + display: isPublishedEntity && !!onPublish, Icon: UnpublishIcon, onClick: (e: React.MouseEvent) => { e.stopPropagation(); - onPublish(entity, PublishActions.DELETE); + onPublish?.(entity, PublishActions.DELETE); }, }, { name: t('Delete'), dataQa: 'delete', - display: !isPublishedEntity, + display: isMyEntity && !!onDelete, + Icon: (props: TablerIconsProps) => ( + + ), + onClick: (e: React.MouseEvent) => { + e.stopPropagation(); + onDelete?.(entity); + }, + }, + { + name: t('Remove'), + dataQa: 'remove', + display: selectedTab === MarketplaceTabs.MY_APPLICATIONS && !!onRemove, Icon: (props: TablerIconsProps) => ( ), - onClick: (e: React.MouseEvent) => e.stopPropagation(), // placeholder + onClick: (e: React.MouseEvent) => { + e.stopPropagation(); + onRemove?.(entity); + }, }, ], - [entity, isPublishedEntity, onPublish, t], + [ + entity, + isPublishedEntity, + onPublish, + t, + selectedTab, + onDelete, + isMyEntity, + onEdit, + onRemove, + ], ); const iconSize = diff --git a/apps/chat/src/components/Marketplace/ApplicationDetails/ApplicationDetails.tsx b/apps/chat/src/components/Marketplace/ApplicationDetails/ApplicationDetails.tsx index 9633e0ec46..61b3675c63 100644 --- a/apps/chat/src/components/Marketplace/ApplicationDetails/ApplicationDetails.tsx +++ b/apps/chat/src/components/Marketplace/ApplicationDetails/ApplicationDetails.tsx @@ -34,6 +34,7 @@ interface Props { entity: DialAIEntityModel; onClose: () => void; onPublish: (entity: DialAIEntityModel, action: PublishActions) => void; + onEdit: (entity: DialAIEntityModel) => void; } const ApplicationDetails = ({ @@ -41,6 +42,7 @@ const ApplicationDetails = ({ isMobileView, onClose, onPublish, + onEdit, }: Props) => { const dispatch = useAppDispatch(); @@ -134,9 +136,7 @@ const ApplicationDetails = ({ modelType={EntityType.Model} entity={selectedVersionEntity} entities={filteredEntities} - onEdit={function (_: DialAIEntityModel): void { - throw new Error('Function not implemented.'); - }} + onEdit={onEdit} /> ); diff --git a/apps/chat/src/components/Marketplace/ApplicationDetails/ApplicationFooter.tsx b/apps/chat/src/components/Marketplace/ApplicationDetails/ApplicationFooter.tsx index 8d2702033e..e95ed66f97 100644 --- a/apps/chat/src/components/Marketplace/ApplicationDetails/ApplicationFooter.tsx +++ b/apps/chat/src/components/Marketplace/ApplicationDetails/ApplicationFooter.tsx @@ -3,13 +3,14 @@ import { IconEdit, IconPlayerPlay, IconWorldShare } from '@tabler/icons-react'; import { useTranslation } from 'next-i18next'; import { getRootId, isApplicationId } from '@/src/utils/app/id'; -import { isItemPublic } from '@/src/utils/app/publications'; import { FeatureType } from '@/src/types/common'; import { DialAIEntityModel } from '@/src/types/models'; import { PublishActions } from '@/src/types/publication'; import { Translation } from '@/src/types/translation'; +import { PUBLIC_URL_PREFIX } from '@/src/constants/public'; + import { ModelVersionSelect } from '../../Chat/ModelVersionSelect'; import Tooltip from '../../Common/Tooltip'; @@ -36,6 +37,12 @@ export const ApplicationDetailsFooter = ({ }: Props) => { const { t } = useTranslation(Translation.Marketplace); + const isPublishedApplication = entity.id.startsWith( + getRootId({ + featureType: FeatureType.Application, + bucket: PUBLIC_URL_PREFIX, + }), + ); const isMyApp = entity.id.startsWith( getRootId({ featureType: FeatureType.Application }), ); @@ -50,13 +57,13 @@ export const ApplicationDetailsFooter = ({ /> */} {isApplicationId(entity.id) && ( - {openedSections[FilterTypes.ENTITY_TYPE] && ( -
- {entityTypes.map((type) => ( - - ))} -
- )} - + {showFilterbar && ( +
+ + {openedSections[FilterTypes.ENTITY_TYPE] && ( +
+ {entityTypes.map((type) => ( + + ))} +
+ )} +
+ )} ); }; diff --git a/apps/chat/src/components/Marketplace/SearchHeader.tsx b/apps/chat/src/components/Marketplace/SearchHeader.tsx new file mode 100644 index 0000000000..85d12247e5 --- /dev/null +++ b/apps/chat/src/components/Marketplace/SearchHeader.tsx @@ -0,0 +1,77 @@ +import { IconPlus, IconSearch } from '@tabler/icons-react'; +import { ChangeEvent } from 'react'; + +import { useTranslation } from 'next-i18next'; + +import { Translation } from '@/src/types/translation'; + +import { useAppDispatch, useAppSelector } from '@/src/store/hooks'; +import { + MarketplaceActions, + MarketplaceSelectors, +} from '@/src/store/marketplace/marketplace.reducers'; + +import { MarketplaceTabs } from '@/src/constants/marketplace'; + +const countLabel = { + [MarketplaceTabs.HOME]: 'Home page', + [MarketplaceTabs.MY_APPLICATIONS]: 'My applications', +}; + +interface SearchHeaderProps { + items: number; + onAddApplication: () => void; +} + +export const SearchHeader = ({ + items, + onAddApplication, +}: SearchHeaderProps) => { + const { t } = useTranslation(Translation.Marketplace); + + const dispatch = useAppDispatch(); + + const searchTerm = useAppSelector(MarketplaceSelectors.selectSearchTerm); + const selectedTab = useAppSelector(MarketplaceSelectors.selectSelectedTab); + + const onSearchChange = (e: ChangeEvent) => { + dispatch(MarketplaceActions.setSearchTerm(e.target.value)); + }; + + return ( +
+
+ {t('{{label}}: {{count}} applications', { + count: items, + label: countLabel[selectedTab], + nsSeparator: '::', + })} +
+
+
+ + +
+ {selectedTab === MarketplaceTabs.MY_APPLICATIONS && ( + + )} +
+
+ ); +}; diff --git a/apps/chat/src/components/Marketplace/TabRenderer.tsx b/apps/chat/src/components/Marketplace/TabRenderer.tsx new file mode 100644 index 0000000000..d172db790a --- /dev/null +++ b/apps/chat/src/components/Marketplace/TabRenderer.tsx @@ -0,0 +1,225 @@ +import { useCallback, useMemo, useState } from 'react'; + +import { getFolderIdFromEntityId } from '@/src/utils/app/folders'; +import { isSmallScreen } from '@/src/utils/app/mobile'; +import { ApiUtils } from '@/src/utils/server/api'; + +import { ApplicationActionType } from '@/src/types/applications'; +import { ShareEntity } from '@/src/types/common'; +import { DialAIEntityModel } from '@/src/types/models'; +import { PublishActions } from '@/src/types/publication'; +import { SharingType } from '@/src/types/share'; + +import { ApplicationActions } from '@/src/store/application/application.reducers'; +import { useAppDispatch, useAppSelector } from '@/src/store/hooks'; +import { MarketplaceSelectors } from '@/src/store/marketplace/marketplace.reducers'; +import { + ModelsActions, + ModelsSelectors, +} from '@/src/store/models/models.reducers'; + +import { MarketplaceTabs } from '@/src/constants/marketplace'; + +import { PublishModal } from '@/src/components/Chat/Publish/PublishWizard'; +import { ApplicationDialog } from '@/src/components/Common/ApplicationDialog'; +import { ConfirmDialog } from '@/src/components/Common/ConfirmDialog'; +import ApplicationDetails from '@/src/components/Marketplace/ApplicationDetails/ApplicationDetails'; +import { CardsList } from '@/src/components/Marketplace/CardsList'; +import { MarketplaceBanner } from '@/src/components/Marketplace/MarketplaceBanner'; +import { SearchHeader } from '@/src/components/Marketplace/SearchHeader'; + +enum DeleteType { + DELETE, + REMOVE, +} + +const deleteConfirmationText = { + [DeleteType.DELETE]: { + heading: 'Confirm deleting application', + description: 'Are you sure you want to delete the application?', + confirmLabel: 'Delete', + }, + [DeleteType.REMOVE]: { + heading: 'Confirm removing application', + description: + 'Are you sure you want to remove the application from your list?', + confirmLabel: 'Remove', + }, +}; + +interface TabRendererProps { + entities: DialAIEntityModel[]; + isMobile?: boolean; +} + +export const TabRenderer = ({ entities, isMobile }: TabRendererProps) => { + const dispatch = useAppDispatch(); + + const installedModels = useAppSelector(ModelsSelectors.selectInstalledModels); + const selectedTab = useAppSelector(MarketplaceSelectors.selectSelectedTab); + + const [applicationModel, setApplicationModel] = useState<{ + action: ApplicationActionType; + entity?: DialAIEntityModel; + }>(); + const [deleteModel, setDeleteModel] = useState<{ + action: DeleteType; + entity: DialAIEntityModel; + }>(); + const [publishModel, setPublishModel] = useState<{ + entity: ShareEntity; + action: PublishActions; + }>(); + const [detailsModel, setDetailsModel] = useState(); + + const handleAddApplication = useCallback(() => { + setApplicationModel({ + action: ApplicationActionType.ADD, + }); + }, []); + + const handleEditApplication = useCallback( + (entity: DialAIEntityModel) => { + dispatch(ApplicationActions.get(entity.id)); + setApplicationModel({ + entity, + action: ApplicationActionType.EDIT, + }); + }, + [dispatch], + ); + + const handleDeleteClose = useCallback( + (confirm: boolean) => { + if (confirm && deleteModel) { + if (deleteModel.action === DeleteType.REMOVE) { + const filteredModels = installedModels.filter( + (model) => deleteModel.entity.id !== model.id, + ); + dispatch(ModelsActions.updateInstalledModels(filteredModels)); + } + if (deleteModel.action === DeleteType.DELETE) { + dispatch(ApplicationActions.delete(deleteModel.entity)); + } + } + setDeleteModel(undefined); + }, + [deleteModel, installedModels, dispatch], + ); + + const handleSetPublishEntity = useCallback( + (entity: DialAIEntityModel, action: PublishActions) => + setPublishModel({ + entity: { + name: entity.name, + id: ApiUtils.decodeApiUrl(entity.id), + folderId: getFolderIdFromEntityId(entity.id), + }, + action, + }), + [], + ); + + const handlePublishClose = useCallback(() => setPublishModel(undefined), []); + + const handleDelete = useCallback( + (entity: DialAIEntityModel) => { + setDeleteModel({ entity, action: DeleteType.DELETE }); + }, + [setDeleteModel], + ); + + const handleRemove = useCallback( + (entity: DialAIEntityModel) => { + setDeleteModel({ entity, action: DeleteType.REMOVE }); + }, + [setDeleteModel], + ); + + const handleCardClick = useCallback( + (entity: DialAIEntityModel) => { + setDetailsModel(entity); + }, + [setDetailsModel], + ); + + const handleCloseApplicationDialog = useCallback( + () => setApplicationModel(undefined), + [setApplicationModel], + ); + + const handleCloseDetailsDialog = useCallback( + () => setDetailsModel(undefined), + [setDetailsModel], + ); + + const filteredModels = useMemo(() => { + if (selectedTab === MarketplaceTabs.MY_APPLICATIONS) { + return entities.filter( + (entity) => !!installedModels.find((model) => model.id === entity.id), + ); + } + return entities; + }, [selectedTab, entities, installedModels]); + + return ( + <> +
+ + +
+ + + + {/* MODALS */} + {!!applicationModel && ( + + )} + {!!deleteModel && ( + + )} + {detailsModel && ( + + )} + {!!(publishModel && publishModel?.entity?.id) && ( + + )} + + ); +}; diff --git a/apps/chat/src/constants/marketplace.ts b/apps/chat/src/constants/marketplace.ts index bac2c322b3..014771d431 100644 --- a/apps/chat/src/constants/marketplace.ts +++ b/apps/chat/src/constants/marketplace.ts @@ -8,3 +8,8 @@ export enum FilterTypes { CAPABILITIES = 'Capabilities', ENVIRONMENT = 'Environment', } + +export enum MarketplaceTabs { + HOME = 'HOME', + MY_APPLICATIONS = 'MY_APPLICATIONS', +} diff --git a/apps/chat/src/store/marketplace/marketplace.reducers.ts b/apps/chat/src/store/marketplace/marketplace.reducers.ts index 144447b33d..e6f5a22d27 100644 --- a/apps/chat/src/store/marketplace/marketplace.reducers.ts +++ b/apps/chat/src/store/marketplace/marketplace.reducers.ts @@ -1,6 +1,6 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit'; -import { FilterTypes } from '@/src/constants/marketplace'; +import { FilterTypes, MarketplaceTabs } from '@/src/constants/marketplace'; import * as MarketplaceSelectors from './marketplace.selectors'; @@ -15,7 +15,8 @@ export interface MarketplaceState { [FilterTypes.CAPABILITIES]: string[]; [FilterTypes.ENVIRONMENT]: string[]; }; - searchQuery: string; + searchTerm: string; + selectedTab: MarketplaceTabs; } const initialState: MarketplaceState = { @@ -25,7 +26,8 @@ const initialState: MarketplaceState = { [FilterTypes.CAPABILITIES]: [], [FilterTypes.ENVIRONMENT]: [], }, - searchQuery: '', + searchTerm: '', + selectedTab: MarketplaceTabs.HOME, }; export const marketplaceSlice = createSlice({ @@ -41,11 +43,11 @@ export const marketplaceSlice = createSlice({ [payload.value], ); }, - setSearchQuery: ( - state, - { payload }: PayloadAction<{ searchQuery: string }>, - ) => { - state.searchQuery = payload.searchQuery; + setSearchTerm: (state, { payload }: PayloadAction) => { + state.searchTerm = payload; + }, + setSelectedTab: (state, { payload }: PayloadAction) => { + state.selectedTab = payload; }, }, }); diff --git a/apps/chat/src/store/marketplace/marketplace.selectors.ts b/apps/chat/src/store/marketplace/marketplace.selectors.ts index 7926f84d99..ebaa6f54ba 100644 --- a/apps/chat/src/store/marketplace/marketplace.selectors.ts +++ b/apps/chat/src/store/marketplace/marketplace.selectors.ts @@ -10,7 +10,12 @@ export const selectSelectedFilters = createSelector( (state) => state.selectedFilters, ); -export const selectSearchQuery = createSelector( +export const selectSearchTerm = createSelector( [rootSelector], - (state) => state.searchQuery, + (state) => state.searchTerm, +); + +export const selectSelectedTab = createSelector( + [rootSelector], + (state) => state.selectedTab, ); diff --git a/apps/chat/src/types/applications.ts b/apps/chat/src/types/applications.ts index 3001bdc380..a88f6fec6a 100644 --- a/apps/chat/src/types/applications.ts +++ b/apps/chat/src/types/applications.ts @@ -10,3 +10,8 @@ export interface CustomApplicationModel completionUrl: string; version: string; } + +export enum ApplicationActionType { + ADD = 'ADD', + EDIT = 'EDIT', +} diff --git a/apps/chat/src/utils/app/publications.ts b/apps/chat/src/utils/app/publications.ts index fe2062a69c..2d70036fde 100644 --- a/apps/chat/src/utils/app/publications.ts +++ b/apps/chat/src/utils/app/publications.ts @@ -22,9 +22,6 @@ import { getFolderIdFromEntityId, splitEntityId } from './folders'; import { isRootId } from './id'; import { EnumMapper } from './mappers'; -export const isItemPublic = (id: string) => - id.split('/')[1] === PUBLIC_URL_PREFIX; - export const createTargetUrl = ( featureType: FeatureType, publicPath: string,