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

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

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

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { default } from './AppsPage';