snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { useBreakpoints } from '@rocket.chat/fuselage-hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import FilterByText from '../../../components/FilterByText';
|
||||
import CategoryDropDown from '../components/CategoryFilter/CategoryDropDown';
|
||||
import TagList from '../components/CategoryFilter/TagList';
|
||||
import RadioDropDown from '../components/RadioDropDown/RadioDropDown';
|
||||
import type { CategoryDropDownListProps, CategoryOnSelected, selectedCategoriesList } from '../definitions/CategoryDropdownDefinitions';
|
||||
import type { RadioDropDownGroup, RadioDropDownOnSelected } from '../definitions/RadioDropDownDefinitions';
|
||||
|
||||
export type AppsFiltersProps = {
|
||||
text: string;
|
||||
setText: (text: string) => void;
|
||||
freePaidFilterStructure: RadioDropDownGroup;
|
||||
freePaidFilterOnSelected: RadioDropDownOnSelected;
|
||||
categories: CategoryDropDownListProps['categories'];
|
||||
selectedCategories: selectedCategoriesList;
|
||||
onSelected: CategoryOnSelected;
|
||||
sortFilterStructure: RadioDropDownGroup;
|
||||
sortFilterOnSelected: RadioDropDownOnSelected;
|
||||
categoryTagList: selectedCategoriesList;
|
||||
statusFilterStructure: RadioDropDownGroup;
|
||||
statusFilterOnSelected: RadioDropDownOnSelected;
|
||||
context: string;
|
||||
};
|
||||
|
||||
const AppsFilters = ({
|
||||
text,
|
||||
setText,
|
||||
freePaidFilterStructure,
|
||||
freePaidFilterOnSelected,
|
||||
categories,
|
||||
selectedCategories,
|
||||
onSelected,
|
||||
sortFilterStructure,
|
||||
sortFilterOnSelected,
|
||||
categoryTagList,
|
||||
statusFilterStructure,
|
||||
statusFilterOnSelected,
|
||||
context,
|
||||
}: AppsFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isPrivateAppsPage = context === 'private';
|
||||
const breakpoints = useBreakpoints();
|
||||
|
||||
const appsSearchPlaceholders: { [key: string]: string } = {
|
||||
explore: t('Search_Apps'),
|
||||
enterprise: t('Search_Premium_Apps'),
|
||||
installed: t('Search_Installed_Apps'),
|
||||
requested: t('Search_Requested_Apps'),
|
||||
private: t('Search_Private_apps'),
|
||||
};
|
||||
|
||||
const fixFiltersSize = breakpoints.includes('lg') ? { maxWidth: 'x200', minWidth: 'x200' } : null;
|
||||
|
||||
return (
|
||||
<Box paddingInline={24}>
|
||||
<FilterByText value={text} onChange={(event) => setText(event.target.value)} placeholder={appsSearchPlaceholders[context]}>
|
||||
{!isPrivateAppsPage && (
|
||||
<RadioDropDown group={freePaidFilterStructure} onSelected={freePaidFilterOnSelected} flexGrow={1} {...fixFiltersSize} />
|
||||
)}
|
||||
<RadioDropDown group={statusFilterStructure} onSelected={statusFilterOnSelected} flexGrow={1} {...fixFiltersSize} />
|
||||
{!isPrivateAppsPage && (
|
||||
<CategoryDropDown categories={categories} selectedCategories={selectedCategories} onSelected={onSelected} flexGrow={1} />
|
||||
)}
|
||||
<RadioDropDown group={sortFilterStructure} onSelected={sortFilterOnSelected} flexGrow={1} {...fixFiltersSize} />
|
||||
</FilterByText>
|
||||
<TagList categories={categoryTagList} onClick={onSelected} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppsFilters;
|
||||
13
apps/meteor/client/views/marketplace/AppsPage/AppsPage.tsx
Normal file
13
apps/meteor/client/views/marketplace/AppsPage/AppsPage.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Page } from '@rocket.chat/ui-client';
|
||||
|
||||
import AppsPageContent from './AppsPageContent';
|
||||
|
||||
const AppsPage = () => {
|
||||
return (
|
||||
<Page background='tint'>
|
||||
<AppsPageContent />
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppsPage;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Box, States, StatesIcon, StatesTitle, StatesSubtitle, StatesActions, StatesAction } from '@rocket.chat/fuselage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const AppsPageContentError = ({ onButtonClick }: { onButtonClick: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box marginBlockStart={20}>
|
||||
<States>
|
||||
<StatesIcon variation='danger' name='circle-exclamation' />
|
||||
<StatesTitle>{t('Connection_error')}</StatesTitle>
|
||||
<StatesSubtitle>{t('Marketplace_error')}</StatesSubtitle>
|
||||
<StatesActions>
|
||||
<StatesAction icon='reload' onClick={onButtonClick}>
|
||||
{t('Reload_page')}
|
||||
</StatesAction>
|
||||
</StatesActions>
|
||||
</States>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppsPageContentError;
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
|
||||
import { usePagination } from '@rocket.chat/ui-client';
|
||||
import { useRouteParameter, useRouter } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import AppsFilters from './AppsFilters';
|
||||
import AppsPageConnectionError from './AppsPageConnectionError';
|
||||
import AppsPageContentBody from './AppsPageContentBody';
|
||||
import AppsPageContentSkeleton from './AppsPageContentSkeleton';
|
||||
import NoAppRequestsEmptyState from './NoAppRequestsEmptyState';
|
||||
import NoInstalledAppMatchesEmptyState from './NoInstalledAppMatchesEmptyState';
|
||||
import NoInstalledAppsEmptyState from './NoInstalledAppsEmptyState';
|
||||
import NoMarketplaceOrInstalledAppMatchesEmptyState from './NoMarketplaceOrInstalledAppMatchesEmptyState';
|
||||
import PrivateEmptyState from './PrivateEmptyState';
|
||||
import UnsupportedEmptyState from './UnsupportedEmptyState';
|
||||
import MarketplaceHeader from '../components/MarketplaceHeader';
|
||||
import type { RadioDropDownGroup } from '../definitions/RadioDropDownDefinitions';
|
||||
import { useAppsReload } from '../hooks/useAppsReload';
|
||||
import { useCategories } from '../hooks/useCategories';
|
||||
import { useFilteredApps } from '../hooks/useFilteredApps';
|
||||
import { useRadioToggle } from '../hooks/useRadioToggle';
|
||||
|
||||
type AppsContext = 'explore' | 'installed' | 'premium' | 'private' | 'requested';
|
||||
|
||||
const AppsPageContent = () => {
|
||||
const { t } = useTranslation();
|
||||
const reload = useAppsReload();
|
||||
const [text, setText] = useState('');
|
||||
const debouncedText = useDebouncedValue(text, 500);
|
||||
const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const context = (useRouteParameter('context') as AppsContext | undefined) ?? 'explore';
|
||||
|
||||
const isMarketplace = context === 'explore';
|
||||
const isPremium = context === 'premium';
|
||||
const isRequested = context === 'requested';
|
||||
|
||||
const [freePaidFilterStructure, setFreePaidFilterStructure] = useState({
|
||||
label: t('Filter_By_Price'),
|
||||
items: [
|
||||
{ id: 'all', label: t('All_Prices'), checked: true },
|
||||
{ id: 'free', label: t('Free_Apps'), checked: false },
|
||||
{ id: 'paid', label: t('Paid_Apps'), checked: false },
|
||||
{ id: 'premium', label: t('Premium'), checked: false },
|
||||
],
|
||||
});
|
||||
const freePaidFilterOnSelected = useRadioToggle(setFreePaidFilterStructure);
|
||||
|
||||
const [statusFilterStructure, setStatusFilterStructure] = useState({
|
||||
label: t('Filter_By_Status'),
|
||||
items: [
|
||||
{ id: 'all', label: t('All_statuses'), checked: true },
|
||||
{ id: 'enabled', label: t('Enabled'), checked: false },
|
||||
{ id: 'disabled', label: t('Disabled'), checked: false },
|
||||
],
|
||||
});
|
||||
const statusFilterOnSelected = useRadioToggle(setStatusFilterStructure);
|
||||
|
||||
const baseFilterStructureItems = [
|
||||
{ id: 'az', label: 'A-Z', checked: false },
|
||||
{ id: 'za', label: 'Z-A', checked: false },
|
||||
{ id: 'mru', label: t('Most_recent_updated'), checked: true },
|
||||
{ id: 'lru', label: t('Least_recent_updated'), checked: false },
|
||||
];
|
||||
|
||||
const requestedFilterItems = [
|
||||
{ id: 'urf', label: t('Unread_Requested_First'), checked: false },
|
||||
{ id: 'url', label: t('Unread_Requested_Last'), checked: false },
|
||||
];
|
||||
|
||||
const createFilterStructureItems = () => {
|
||||
return isRequested ? [...requestedFilterItems, ...baseFilterStructureItems] : baseFilterStructureItems;
|
||||
};
|
||||
|
||||
const [sortFilterStructure, setSortFilterStructure] = useState<RadioDropDownGroup>(() => {
|
||||
return {
|
||||
label: t('Sort_By'),
|
||||
items: createFilterStructureItems(),
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setSortFilterStructure({
|
||||
label: t('Sort_By'),
|
||||
items: createFilterStructureItems(),
|
||||
});
|
||||
}, [isRequested]);
|
||||
|
||||
const sortFilterOnSelected = useRadioToggle(setSortFilterStructure);
|
||||
|
||||
const findSort = () => {
|
||||
const possibleSort = sortFilterStructure.items.find(({ checked }) => checked);
|
||||
|
||||
return possibleSort ? possibleSort.id : 'mru';
|
||||
};
|
||||
|
||||
const findPurchaseType = () => {
|
||||
const possiblePurchaseType = freePaidFilterStructure.items.find(({ checked }) => checked);
|
||||
|
||||
return possiblePurchaseType ? possiblePurchaseType.id : 'all';
|
||||
};
|
||||
|
||||
const findStatus = () => {
|
||||
const possibleStatus = statusFilterStructure.items.find(({ checked }) => checked);
|
||||
|
||||
return possibleStatus ? possibleStatus.id : 'all';
|
||||
};
|
||||
|
||||
const [categories, selectedCategories, categoryTagList, onSelected] = useCategories();
|
||||
const { isPending, isError, error, data } = useFilteredApps({
|
||||
text: debouncedText,
|
||||
current,
|
||||
itemsPerPage,
|
||||
categories: useMemo(() => selectedCategories.map(({ label }) => label), [selectedCategories]),
|
||||
purchaseType: useMemo(findPurchaseType, [freePaidFilterStructure]),
|
||||
sortingMethod: useMemo(findSort, [sortFilterStructure]),
|
||||
status: useMemo(findStatus, [statusFilterStructure]),
|
||||
context,
|
||||
});
|
||||
|
||||
const isFiltered =
|
||||
Boolean(text.length) ||
|
||||
freePaidFilterStructure.items.find((item) => item.checked)?.id !== 'all' ||
|
||||
statusFilterStructure.items.find((item) => item.checked)?.id !== 'all' ||
|
||||
sortFilterStructure.items.find((item) => item.checked)?.id !== 'mru' ||
|
||||
selectedCategories.length > 0;
|
||||
|
||||
const handleReturn = () => {
|
||||
router.navigate({
|
||||
name: 'marketplace',
|
||||
params: {
|
||||
context: 'explore',
|
||||
page: 'list',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const toggleInitialSortOption = useCallback((isRequested: boolean) => {
|
||||
setSortFilterStructure((prevState) => {
|
||||
prevState.items.forEach((currentItem) => {
|
||||
if (isRequested && currentItem.id === 'urf') {
|
||||
currentItem.checked = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRequested && currentItem.id === 'mru') {
|
||||
currentItem.checked = true;
|
||||
return;
|
||||
}
|
||||
|
||||
currentItem.checked = false;
|
||||
});
|
||||
|
||||
return { ...prevState };
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
toggleInitialSortOption(isRequested);
|
||||
}, [isMarketplace, isRequested, sortFilterOnSelected, t, toggleInitialSortOption]);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<>
|
||||
<MarketplaceHeader unsupportedVersion={false} title={t(`Apps_context_${context}`)} />
|
||||
<AppsFilters
|
||||
text={text}
|
||||
setText={setText}
|
||||
freePaidFilterStructure={freePaidFilterStructure}
|
||||
freePaidFilterOnSelected={freePaidFilterOnSelected}
|
||||
categories={categories}
|
||||
selectedCategories={selectedCategories}
|
||||
onSelected={onSelected}
|
||||
sortFilterStructure={sortFilterStructure}
|
||||
sortFilterOnSelected={sortFilterOnSelected}
|
||||
categoryTagList={categoryTagList}
|
||||
statusFilterStructure={statusFilterStructure}
|
||||
statusFilterOnSelected={statusFilterOnSelected}
|
||||
context={context}
|
||||
/>
|
||||
<AppsPageContentSkeleton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
// TODO: Introduce error codes, so we can avoid using error message strings for this kind of logic
|
||||
// whenever we change the error message, this code ends up breaking.
|
||||
const unsupportedVersion = error.message === 'Marketplace_Unsupported_Version';
|
||||
|
||||
return (
|
||||
<>
|
||||
<MarketplaceHeader unsupportedVersion={unsupportedVersion} title={t(`Apps_context_${context}`)} />
|
||||
<AppsFilters
|
||||
text={text}
|
||||
setText={setText}
|
||||
freePaidFilterStructure={freePaidFilterStructure}
|
||||
freePaidFilterOnSelected={freePaidFilterOnSelected}
|
||||
categories={categories}
|
||||
selectedCategories={selectedCategories}
|
||||
onSelected={onSelected}
|
||||
sortFilterStructure={sortFilterStructure}
|
||||
sortFilterOnSelected={sortFilterOnSelected}
|
||||
categoryTagList={categoryTagList}
|
||||
statusFilterStructure={statusFilterStructure}
|
||||
statusFilterOnSelected={statusFilterOnSelected}
|
||||
context={context}
|
||||
/>
|
||||
{unsupportedVersion ? <UnsupportedEmptyState /> : <AppsPageConnectionError onButtonClick={reload} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MarketplaceHeader unsupportedVersion={false} title={t(`Apps_context_${context}`)} />
|
||||
<AppsFilters
|
||||
text={text}
|
||||
setText={setText}
|
||||
freePaidFilterStructure={freePaidFilterStructure}
|
||||
freePaidFilterOnSelected={freePaidFilterOnSelected}
|
||||
categories={categories}
|
||||
selectedCategories={selectedCategories}
|
||||
onSelected={onSelected}
|
||||
sortFilterStructure={sortFilterStructure}
|
||||
sortFilterOnSelected={sortFilterOnSelected}
|
||||
categoryTagList={categoryTagList}
|
||||
statusFilterStructure={statusFilterStructure}
|
||||
statusFilterOnSelected={statusFilterOnSelected}
|
||||
context={context}
|
||||
/>
|
||||
{(context === 'requested' && data.count === 0 && <NoAppRequestsEmptyState />) ||
|
||||
((isMarketplace || isPremium) && data.count === 0 && (
|
||||
<NoMarketplaceOrInstalledAppMatchesEmptyState shouldShowSearchText={!!data.shouldShowSearchText} text={text} />
|
||||
)) ||
|
||||
(context === 'installed' && data.totalAppsLength !== 0 && data.count === 0 && (
|
||||
<NoInstalledAppMatchesEmptyState shouldShowSearchText={!!data.shouldShowSearchText} text={text} onButtonClick={handleReturn} />
|
||||
)) ||
|
||||
(context === 'private' && !isMarketplace && data.totalAppsLength === 0 && <PrivateEmptyState />) ||
|
||||
(context !== 'private' && !isMarketplace && data.totalAppsLength === 0 && (
|
||||
<NoInstalledAppsEmptyState onButtonClick={handleReturn} />
|
||||
)) || (
|
||||
<AppsPageContentBody
|
||||
isMarketplace={isMarketplace}
|
||||
isFiltered={isFiltered}
|
||||
appsResult={data}
|
||||
itemsPerPage={itemsPerPage}
|
||||
current={current}
|
||||
onSetItemsPerPage={onSetItemsPerPage}
|
||||
onSetCurrent={onSetCurrent}
|
||||
paginationProps={paginationProps}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppsPageContent;
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { App } from '@rocket.chat/core-typings';
|
||||
import { Box, Pagination } from '@rocket.chat/fuselage';
|
||||
import type { PaginatedResult } from '@rocket.chat/rest-typings';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useId, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import AppsList from '../AppsList';
|
||||
import FeaturedAppsSections from './FeaturedAppsSections';
|
||||
|
||||
export type AppsPageContentBodyProps = {
|
||||
isMarketplace: boolean;
|
||||
isFiltered: boolean;
|
||||
appsResult?: PaginatedResult<{
|
||||
items: App[];
|
||||
shouldShowSearchText: boolean;
|
||||
allApps: App[];
|
||||
totalAppsLength: number;
|
||||
}>;
|
||||
itemsPerPage: 25 | 50 | 100;
|
||||
current: number;
|
||||
onSetItemsPerPage: Dispatch<SetStateAction<25 | 50 | 100>>;
|
||||
onSetCurrent: Dispatch<SetStateAction<number>>;
|
||||
paginationProps: {
|
||||
itemsPerPageLabel: () => string;
|
||||
showingResultsLabel: (context: { count: number; current: number; itemsPerPage: 25 | 50 | 100 }) => string;
|
||||
};
|
||||
};
|
||||
|
||||
const AppsPageContentBody = ({
|
||||
isMarketplace,
|
||||
isFiltered,
|
||||
appsResult,
|
||||
itemsPerPage,
|
||||
current,
|
||||
onSetItemsPerPage,
|
||||
onSetCurrent,
|
||||
paginationProps,
|
||||
}: AppsPageContentBodyProps) => {
|
||||
const { t } = useTranslation();
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
const appsListId = useId();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box display='flex' flexDirection='column' overflow='hidden' height='100%' paddingInline={24}>
|
||||
<Box overflowY='scroll' height='100%' ref={scrollableRef}>
|
||||
{isMarketplace && !isFiltered && <FeaturedAppsSections appsListId={appsListId} appsResult={appsResult?.allApps || []} />}
|
||||
<AppsList appsListId={appsListId} apps={appsResult?.items || []} title={isMarketplace ? t('All_Apps') : undefined} />
|
||||
</Box>
|
||||
</Box>
|
||||
{Boolean(appsResult?.count) && (
|
||||
<Pagination
|
||||
divider
|
||||
current={current}
|
||||
itemsPerPage={itemsPerPage}
|
||||
count={appsResult?.total || 0}
|
||||
onSetItemsPerPage={onSetItemsPerPage}
|
||||
onSetCurrent={(value) => {
|
||||
onSetCurrent(value);
|
||||
scrollableRef.current?.scrollTo(0, 0);
|
||||
}}
|
||||
{...paginationProps}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppsPageContentBody;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Skeleton, Box } from '@rocket.chat/fuselage';
|
||||
|
||||
const AppsPageContentSkeleton = () => {
|
||||
const loadingRows = Array.from({ length: 3 }, (_, i) => <Skeleton key={i} height='x56' marginBlockEnd={8} width='100%' variant='rect' />);
|
||||
return (
|
||||
<Box paddingInline={24}>
|
||||
<Box marginBlockEnd={36}>
|
||||
<Skeleton height='x28' width='x150' marginBlockEnd={20} variant='rect' />
|
||||
{loadingRows}
|
||||
</Box>
|
||||
<Box marginBlockEnd={36}>
|
||||
<Skeleton height='x28' width='x150' marginBlockEnd={20} variant='rect' />
|
||||
{loadingRows}
|
||||
</Box>
|
||||
<Skeleton height='x28' width='x150' marginBlockEnd={20} variant='rect' />
|
||||
{loadingRows}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppsPageContentSkeleton;
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { App } from '@rocket.chat/core-typings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import AppsList from '../AppsList';
|
||||
import normalizeFeaturedApps from '../helpers/normalizeFeaturedApps';
|
||||
import { useFeaturedApps } from '../hooks/useFeaturedApps';
|
||||
|
||||
type FeaturedSectionsProps = {
|
||||
appsResult: App[];
|
||||
appsListId: string;
|
||||
};
|
||||
|
||||
const FeaturedAppsSections = ({ appsResult, appsListId }: FeaturedSectionsProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const featuredApps = useFeaturedApps();
|
||||
|
||||
if (featuredApps.isSuccess) {
|
||||
return (
|
||||
<>
|
||||
{featuredApps.data.sections.map((section, index) => (
|
||||
<AppsList
|
||||
appsListId={`${appsListId + index}`}
|
||||
key={section.slug}
|
||||
apps={normalizeFeaturedApps(section.apps, appsResult)}
|
||||
title={i18n.exists(section.i18nLabel) ? t(section.i18nLabel) : section.i18nLabel}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default FeaturedAppsSections;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Box, States, StatesIcon, StatesSubtitle, StatesTitle } from '@rocket.chat/fuselage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const NoAppRequestsEmptyState = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box marginBlockStart='24px'>
|
||||
<States>
|
||||
<StatesIcon name='cube' />
|
||||
<StatesTitle>{t('No_requested_apps')}</StatesTitle>
|
||||
<StatesSubtitle>{t('Requested_apps_will_appear_here')}</StatesSubtitle>
|
||||
</States>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoAppRequestsEmptyState;
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Box,
|
||||
States,
|
||||
StatesIcon,
|
||||
StatesTitle,
|
||||
StatesSubtitle,
|
||||
StatesSuggestion,
|
||||
StatesSuggestionText,
|
||||
StatesActions,
|
||||
StatesAction,
|
||||
} from '@rocket.chat/fuselage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type NoInstalledAppMatchesEmptyStateProps = {
|
||||
shouldShowSearchText: boolean;
|
||||
text: string;
|
||||
onButtonClick: () => void;
|
||||
};
|
||||
|
||||
const NoInstalledAppMatchesEmptyState = ({ shouldShowSearchText, text, onButtonClick }: NoInstalledAppMatchesEmptyStateProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box marginBlockStart={20}>
|
||||
<States>
|
||||
<StatesIcon name='magnifier' />
|
||||
<StatesTitle>{t('No_installed_app_matches')}</StatesTitle>
|
||||
{shouldShowSearchText && (
|
||||
<StatesSubtitle>
|
||||
<span>
|
||||
{t('No_app_matches_for')} <strong>"{text}"</strong>
|
||||
</span>
|
||||
</StatesSubtitle>
|
||||
)}
|
||||
<StatesSuggestion>
|
||||
<StatesSuggestionText>{t('Try_searching_in_the_marketplace_instead')}</StatesSuggestionText>
|
||||
</StatesSuggestion>
|
||||
<StatesActions>
|
||||
<StatesAction onClick={onButtonClick}>{t('Search_on_marketplace')}</StatesAction>
|
||||
</StatesActions>
|
||||
</States>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoInstalledAppMatchesEmptyState;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Box, States, StatesIcon, StatesTitle, StatesSubtitle, StatesActions, StatesAction } from '@rocket.chat/fuselage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const NoInstalledAppsEmptyState = ({ onButtonClick }: { onButtonClick: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box marginBlockStart={20}>
|
||||
<States>
|
||||
<StatesIcon name='magnifier' />
|
||||
<StatesTitle>{t('No_apps_installed')}</StatesTitle>
|
||||
<StatesSubtitle>{t('Explore_the_marketplace_to_find_awesome_apps')}</StatesSubtitle>
|
||||
<StatesActions>
|
||||
<StatesAction onClick={onButtonClick}>{t('Explore_marketplace')}</StatesAction>
|
||||
</StatesActions>
|
||||
</States>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoInstalledAppsEmptyState;
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Box,
|
||||
States,
|
||||
StatesIcon,
|
||||
StatesSubtitle,
|
||||
StatesSuggestion,
|
||||
StatesSuggestionList,
|
||||
StatesSuggestionListItem,
|
||||
StatesSuggestionText,
|
||||
StatesTitle,
|
||||
} from '@rocket.chat/fuselage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type NoMarketplaceOrInstalledAppMatchesEmptyStateProps = { shouldShowSearchText: boolean; text: string };
|
||||
|
||||
const NoMarketplaceOrInstalledAppMatchesEmptyState = ({
|
||||
shouldShowSearchText,
|
||||
text,
|
||||
}: NoMarketplaceOrInstalledAppMatchesEmptyStateProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box marginBlockStart={20}>
|
||||
<States>
|
||||
<StatesIcon name='magnifier' />
|
||||
<StatesTitle>{t('No_app_matches')}</StatesTitle>
|
||||
{shouldShowSearchText && (
|
||||
<StatesSubtitle>
|
||||
{t('No_marketplace_matches_for')}: <strong>"{text}"</strong>
|
||||
</StatesSubtitle>
|
||||
)}
|
||||
<StatesSuggestion>
|
||||
<StatesSuggestionText>{t('You_can_try_to')}:</StatesSuggestionText>
|
||||
<StatesSuggestionList>
|
||||
<StatesSuggestionListItem>{t('Search_by_category')}</StatesSuggestionListItem>
|
||||
<StatesSuggestionListItem>{t('Search_for_a_more_general_term')}</StatesSuggestionListItem>
|
||||
<StatesSuggestionListItem>{t('Search_for_a_more_specific_term')}</StatesSuggestionListItem>
|
||||
<StatesSuggestionListItem>{t('Check_if_the_spelling_is_correct')}</StatesSuggestionListItem>
|
||||
</StatesSuggestionList>
|
||||
</StatesSuggestion>
|
||||
</States>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoMarketplaceOrInstalledAppMatchesEmptyState;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import PrivateEmptyState from './PrivateEmptyState';
|
||||
|
||||
jest.mock('@rocket.chat/ui-client', () => ({
|
||||
...jest.requireActual('@rocket.chat/ui-client'),
|
||||
useLicense: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('with private apps enabled', () => {
|
||||
beforeEach(async () => {
|
||||
const { useLicense } = await import('@rocket.chat/ui-client');
|
||||
(useLicense as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
limits: {
|
||||
privateApps: {
|
||||
max: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
isPending: false,
|
||||
});
|
||||
});
|
||||
|
||||
const appRoot = mockAppRoot().withTranslations('en', 'core', {
|
||||
Private_apps_upgrade_empty_state_title: 'Upgrade to unlock private apps',
|
||||
No_private_apps_installed: 'No private apps installed',
|
||||
});
|
||||
|
||||
it('should offer to upgrade to unlock private apps', () => {
|
||||
render(<PrivateEmptyState />, { wrapper: appRoot.build() });
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'No private apps installed' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('without private apps enabled', () => {
|
||||
beforeEach(async () => {
|
||||
const { useLicense } = await import('@rocket.chat/ui-client');
|
||||
(useLicense as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
limits: {
|
||||
privateApps: {
|
||||
max: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
isPending: false,
|
||||
});
|
||||
});
|
||||
|
||||
const appRoot = mockAppRoot().withTranslations('en', 'core', {
|
||||
Private_apps_upgrade_empty_state_title: 'Upgrade to unlock private apps',
|
||||
No_private_apps_installed: 'No private apps installed',
|
||||
});
|
||||
|
||||
it('should offer to upgrade to unlock private apps', () => {
|
||||
render(<PrivateEmptyState />, { wrapper: appRoot.build() });
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Upgrade to unlock private apps' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { StoryObj, Meta } from '@storybook/react';
|
||||
|
||||
import PrivateEmptyState from './PrivateEmptyState';
|
||||
|
||||
export default {
|
||||
component: PrivateEmptyState,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
controls: { hideNoControlsWarning: true },
|
||||
},
|
||||
} satisfies Meta<typeof PrivateEmptyState>;
|
||||
|
||||
export const Default: StoryObj<typeof PrivateEmptyState> = {
|
||||
render: () => <PrivateEmptyState />,
|
||||
name: 'PageEmptyPrivateApps',
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
|
||||
import PrivateEmptyStateDefault from './PrivateEmptyStateDefault';
|
||||
import PrivateEmptyStateUpgrade from './PrivateEmptyStateUpgrade';
|
||||
import { usePrivateAppsEnabled } from '../hooks/usePrivateAppsEnabled';
|
||||
|
||||
const PrivateEmptyState = () => {
|
||||
const privateAppsEnabled = usePrivateAppsEnabled();
|
||||
|
||||
return <Box marginBlockStart='24px'>{privateAppsEnabled ? <PrivateEmptyStateDefault /> : <PrivateEmptyStateUpgrade />}</Box>;
|
||||
};
|
||||
|
||||
export default PrivateEmptyState;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { States, StatesIcon, StatesTitle, StatesSubtitle } from '@rocket.chat/fuselage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const PrivateEmptyStateDefault = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<States>
|
||||
<StatesIcon name='lock' />
|
||||
<StatesTitle>{t('No_private_apps_installed')}</StatesTitle>
|
||||
<StatesSubtitle>{t('Private_apps_upgrade_empty_state_description')}</StatesSubtitle>
|
||||
</States>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivateEmptyStateDefault;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { States, StatesIcon, StatesTitle, StatesSubtitle, StatesActions, Button } from '@rocket.chat/fuselage';
|
||||
import { usePermission } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useExternalLink } from '../../../hooks/useExternalLink';
|
||||
import UpgradeButton from '../../admin/subscription/components/UpgradeButton';
|
||||
import { PRICING_LINK } from '../../admin/subscription/utils/links';
|
||||
|
||||
const PrivateEmptyStateUpgrade = () => {
|
||||
const { t } = useTranslation();
|
||||
const isAdmin = usePermission('manage-apps');
|
||||
|
||||
const handleOpenLink = useExternalLink();
|
||||
|
||||
return (
|
||||
<States>
|
||||
<StatesIcon name='lightning' />
|
||||
<StatesTitle>{t('Private_apps_upgrade_empty_state_title')}</StatesTitle>
|
||||
<StatesSubtitle>{t('Private_apps_upgrade_empty_state_description')}</StatesSubtitle>
|
||||
<StatesActions>
|
||||
{isAdmin && (
|
||||
<UpgradeButton primary target='private-apps-header' action='upgrade'>
|
||||
{t('Upgrade')}
|
||||
</UpgradeButton>
|
||||
)}
|
||||
{!isAdmin && (
|
||||
<Button onClick={() => handleOpenLink(PRICING_LINK)} role='link'>
|
||||
{t('Learn_more')}
|
||||
</Button>
|
||||
)}
|
||||
</StatesActions>
|
||||
</States>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivateEmptyStateUpgrade;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import UnsupportedEmptyState from './UnsupportedEmptyState';
|
||||
|
||||
describe('with private apps enabled', () => {
|
||||
const appRoot = mockAppRoot().withTranslations('en', 'core', {
|
||||
Marketplace_unavailable: 'Marketplace unavailable',
|
||||
});
|
||||
|
||||
it('should inform that the marketplace is unavailable due unsupported version', () => {
|
||||
render(<UnsupportedEmptyState />, { wrapper: appRoot.build() });
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Marketplace unavailable' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { StoryObj, Meta } from '@storybook/react';
|
||||
|
||||
import UnsupportedEmptyState from './UnsupportedEmptyState';
|
||||
|
||||
export default {
|
||||
component: UnsupportedEmptyState,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
controls: { hideNoControlsWarning: true },
|
||||
},
|
||||
} as Meta<typeof UnsupportedEmptyState>;
|
||||
|
||||
export const Default: StoryObj<typeof UnsupportedEmptyState> = {
|
||||
render: () => <UnsupportedEmptyState />,
|
||||
name: 'UnsupportedEmptyState',
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Box, States, StatesIcon, StatesTitle, StatesSubtitle, StatesActions, Button } from '@rocket.chat/fuselage';
|
||||
import { usePermission } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { links } from '../../../lib/links';
|
||||
import UpdateRocketChatButton from '../components/UpdateRocketChatButton';
|
||||
|
||||
const UnsupportedEmptyState = () => {
|
||||
const isAdmin = usePermission('manage-apps');
|
||||
const { t } = useTranslation();
|
||||
|
||||
const title = isAdmin ? t('Update_to_access_marketplace') : t('Marketplace_unavailable');
|
||||
const description = isAdmin ? t('Update_to_access_marketplace_description') : t('Marketplace_unavailable_description');
|
||||
|
||||
return (
|
||||
<Box marginBlockStart={64}>
|
||||
<States>
|
||||
<StatesIcon name='warning' />
|
||||
<StatesTitle>{title}</StatesTitle>
|
||||
<StatesSubtitle>{description}</StatesSubtitle>
|
||||
<StatesActions>
|
||||
<Button secondary is='a' href={links.go.supportPrerequisites} external>
|
||||
{t('Learn_more')}
|
||||
</Button>
|
||||
{isAdmin && <UpdateRocketChatButton />}
|
||||
</StatesActions>
|
||||
</States>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnsupportedEmptyState;
|
||||
1
apps/meteor/client/views/marketplace/AppsPage/index.ts
Normal file
1
apps/meteor/client/views/marketplace/AppsPage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './AppsPage';
|
||||
Reference in New Issue
Block a user