[NEW] Marketplace apps page new list view layout (#26181)

pull/26413/head
Henrique Guimarães Ribeiro 3 years ago committed by GitHub
parent f2c84629ee
commit 6d4f021a9a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      apps/meteor/app/apps/client/orchestrator.ts
  2. 14
      apps/meteor/client/components/FilterByText.tsx
  3. 6
      apps/meteor/client/components/Page/PageHeader.tsx
  4. 2
      apps/meteor/client/components/avatar/AppAvatar.tsx
  5. 2
      apps/meteor/client/views/admin/apps/AppDetailsHeader.tsx
  6. 23
      apps/meteor/client/views/admin/apps/AppMenu.js
  7. 122
      apps/meteor/client/views/admin/apps/AppRow.tsx
  8. 52
      apps/meteor/client/views/admin/apps/AppStatus.js
  9. 79
      apps/meteor/client/views/admin/apps/AppsFilters.tsx
  10. 167
      apps/meteor/client/views/admin/apps/AppsList.tsx
  11. 68
      apps/meteor/client/views/admin/apps/AppsListMain.tsx
  12. 11
      apps/meteor/client/views/admin/apps/AppsPage.tsx
  13. 15
      apps/meteor/client/views/admin/apps/BundleChips.tsx
  14. 115
      apps/meteor/client/views/admin/apps/MarketplaceRow.tsx
  15. 2
      apps/meteor/client/views/admin/apps/components/CategoryFilter/CategoryDropDown.tsx
  16. 9
      apps/meteor/client/views/admin/apps/components/CategoryFilter/CategoryDropDownAnchor.tsx
  17. 2
      apps/meteor/client/views/admin/apps/components/RadioDropDown/RadioDropDown.tsx
  18. 12
      apps/meteor/client/views/admin/apps/definitions/CategoryDropdownDefinitions.ts
  19. 7
      apps/meteor/client/views/admin/apps/helpers/filterAppsByDisabled.ts
  20. 5
      apps/meteor/client/views/admin/apps/helpers/filterAppsByEnabled.ts
  21. 15
      apps/meteor/client/views/admin/apps/hooks/useCategories.ts
  22. 19
      apps/meteor/client/views/admin/apps/hooks/useFilteredApps.ts
  23. 9
      apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json

@ -107,7 +107,7 @@ class AppClientOrchestrator {
}
return (result as App[]).map((app: App) => {
const { latest, price, pricingPlans, purchaseType, isEnterpriseOnly, modifiedAt } = app;
const { latest, price, pricingPlans, purchaseType, isEnterpriseOnly, modifiedAt, bundledIn } = app;
return {
...latest,
price,
@ -115,6 +115,7 @@ class AppClientOrchestrator {
purchaseType,
isEnterpriseOnly,
modifiedAt,
bundledIn,
};
});
}

@ -6,6 +6,7 @@ type FilterByTextCommonProps = {
children?: ReactNode | undefined;
placeholder?: string;
inputRef?: () => void;
shouldFiltersStack?: boolean;
onChange: (filter: { text: string }) => void;
};
@ -20,7 +21,14 @@ type FilterByTextProps = FilterByTextCommonProps | FilterByTextPropsWithButton;
const isFilterByTextPropsWithButton = (props: any): props is FilterByTextPropsWithButton =>
'displayButton' in props && props.displayButton === true;
const FilterByText = ({ placeholder, onChange: setFilter, inputRef, children, ...props }: FilterByTextProps): ReactElement => {
const FilterByText = ({
placeholder,
onChange: setFilter,
inputRef,
children,
shouldFiltersStack,
...props
}: FilterByTextProps): ReactElement => {
const t = useTranslation();
const [text, setText] = useState('');
@ -38,7 +46,7 @@ const FilterByText = ({ placeholder, onChange: setFilter, inputRef, children, ..
}, []);
return (
<Box mb='x16' is='form' onSubmit={handleFormSubmit} display='flex' flexDirection='row' {...props}>
<Box mb='x16' is='form' onSubmit={handleFormSubmit} display='flex' flexDirection={shouldFiltersStack ? 'column' : 'row'} {...props}>
<TextInput
placeholder={placeholder ?? t('Search')}
ref={inputRef}
@ -52,7 +60,7 @@ const FilterByText = ({ placeholder, onChange: setFilter, inputRef, children, ..
</Button>
) : (
children && (
<Box mis='x8' display='flex' flexDirection='row'>
<Box mis={shouldFiltersStack ? '' : 'x8'} display='flex' flexDirection={shouldFiltersStack ? 'column' : 'row'}>
{children}
</Box>
)

@ -1,6 +1,6 @@
import { Box, IconButton } from '@rocket.chat/fuselage';
import { useLayout, useTranslation } from '@rocket.chat/ui-contexts';
import React, { useContext, FC, ReactNode } from 'react';
import React, { useContext, FC, ComponentProps, ReactNode } from 'react';
import BurgerMenu from '../BurgerMenu';
import TemplateHeader from '../Header';
@ -10,7 +10,7 @@ type PageHeaderProps = {
title: ReactNode;
onClickBack?: () => void;
borderBlockEndColor?: string;
};
} & Omit<ComponentProps<typeof Box>, 'title'>;
const PageHeader: FC<PageHeaderProps> = ({ children = undefined, title, onClickBack, borderBlockEndColor, ...props }) => {
const t = useTranslation();
@ -18,7 +18,7 @@ const PageHeader: FC<PageHeaderProps> = ({ children = undefined, title, onClickB
const { isMobile } = useLayout();
return (
<Box borderBlockEndWidth='x2' borderBlockEndColor={borderBlockEndColor ?? border ? 'neutral-200' : 'transparent'}>
<Box borderBlockEndWidth='x2' borderBlockEndColor={borderBlockEndColor ?? border ? 'neutral-200' : 'transparent'} {...props}>
<Box
marginBlock='x16'
marginInline='x24'

@ -9,7 +9,7 @@ type AppAvatarProps = {
/* @deprecated */
size: 'x36' | 'x28' | 'x16' | 'x40' | 'x124';
/* @deprecated */
mie?: 'x80' | 'x20' | 'x8';
mie?: 'x80' | 'x20' | 'x16' | 'x8';
/* @deprecated */
alignSelf?: 'center';

@ -27,7 +27,7 @@ const AppDetailsHeader = ({ app }: { app: App }): ReactElement => {
<Box mbe='x16'>{description}</Box>
<Box display='flex' flexDirection='row' alignItems='center' mbe='x16'>
<Box display='flex' flexDirection='row' alignItems='center'>
<AppStatus app={app} installed={installed} isAppDetailsPage={true} mie='x8' />
<AppStatus app={app} installed={installed} isAppDetailsPage={true} mie='x8' isSubscribed={isSubscribed} />
</Box>
{(installed || isSubscribed) && <AppMenu app={app} />}
</Box>

@ -130,17 +130,18 @@ function AppMenu({ app, ...props }) {
action: handleSubscription,
},
}),
...(context !== 'details' && {
viewLogs: {
label: (
<Box>
<Icon name='list-alt' size='x16' marginInlineEnd='x4' />
{t('View_Logs')}
</Box>
),
action: handleViewLogs,
},
}),
...(context !== 'details' &&
app.installed && {
viewLogs: {
label: (
<Box>
<Icon name='list-alt' size='x16' marginInlineEnd='x4' />
{t('View_Logs')}
</Box>
),
action: handleViewLogs,
},
}),
...(app.installed &&
isAppEnabled && {
disable: {

@ -1,38 +1,39 @@
import { Box, Table, Tag } from '@rocket.chat/fuselage';
import { useRoute, useTranslation } from '@rocket.chat/ui-contexts';
import React, { FC, useState, memo, KeyboardEvent, MouseEvent } from 'react';
import { css } from '@rocket.chat/css-in-js';
import { Box } from '@rocket.chat/fuselage';
import { useMediaQueries } from '@rocket.chat/fuselage-hooks';
import colors from '@rocket.chat/fuselage-tokens/colors';
import { useRoute } from '@rocket.chat/ui-contexts';
import React, { FC, memo, KeyboardEvent, MouseEvent } from 'react';
import AppAvatar from '../../../components/avatar/AppAvatar';
import AppMenu from './AppMenu';
import AppStatus from './AppStatus';
import BundleChips from './BundleChips';
import { App } from './types';
type AppRowProps = App & {
medium: boolean;
large: boolean;
};
const AppRow: FC<AppRowProps> = ({ medium, ...props }) => {
const {
author: { name: authorName },
name,
id,
description,
categories,
iconFileData,
marketplaceVersion,
iconFileContent,
installed,
} = props;
const t = useTranslation();
const AppRow: FC<App & { isMarketplace: boolean }> = (props) => {
const { name, id, description, iconFileData, marketplaceVersion, iconFileContent, installed, isSubscribed, isMarketplace, bundledIn } =
props;
const [isFocused, setFocused] = useState(false);
const [isHovered, setHovered] = useState(false);
const isStatusVisible = isFocused || isHovered;
const [isAppNameTruncated, isBundleTextVisible, isDescriptionVisible] = useMediaQueries(
'(max-width: 510px)',
'(max-width: 887px)',
'(min-width: 1200px)',
);
const appsRoute = useRoute('admin-apps');
const marketplaceRoute = useRoute('admin-marketplace');
const handleClick = (): void => {
if (isMarketplace) {
marketplaceRoute.push({
context: 'details',
version: marketplaceVersion,
id,
});
return;
}
appsRoute.push({
context: 'details',
version: marketplaceVersion,
@ -52,53 +53,58 @@ const AppRow: FC<AppRowProps> = ({ medium, ...props }) => {
e.stopPropagation();
};
const hover = css`
&:hover,
&:focus {
cursor: pointer;
outline: 0;
background-color: ${colors.n200} !important;
}
`;
return (
<Table.Row
<Box
key={id}
role='link'
action
tabIndex={0}
onClick={handleClick}
onKeyDown={handleKeyDown}
onFocus={(): void => setFocused(true)}
onBlur={(): void => setFocused(false)}
onMouseEnter={(): void => setHovered(true)}
onMouseLeave={(): void => setHovered(false)}
display='flex'
flexDirection='row'
justifyContent='space-between'
alignItems='center'
bg='surface'
mbe='x8'
pb='x8'
pis='x16'
pie='x4'
className={hover}
>
<Table.Cell withTruncatedText display='flex' flexDirection='row'>
<AppAvatar size='x40' mie='x8' alignSelf='center' iconFileContent={iconFileContent} iconFileData={iconFileData} />
<Box display='flex' flexDirection='column' alignSelf='flex-start'>
<Box color='default' fontScale='p2m'>
<Box display='flex' flexDirection='row' width='80%'>
<AppAvatar size='x40' mie='x16' alignSelf='center' iconFileContent={iconFileContent} iconFileData={iconFileData} />
<Box display='flex' alignItems='center' color='default' fontScale='p2m' mie='x16' style={{ whiteSpace: 'nowrap' }}>
<Box is='span' withTruncatedText={isAppNameTruncated}>
{name}
</Box>
<Box color='default' fontScale='p2m'>{`${t('By')} ${authorName}`}</Box>
</Box>
</Table.Cell>
{medium && (
<Table.Cell>
<Box display='flex' flexDirection='column'>
<Box color='default' withTruncatedText>
<Box display='flex' mie='x16' alignItems='center' color='default'>
{bundledIn && Boolean(bundledIn.length) && (
<Box display='flex' alignItems='center' color='default' mie='x16'>
<BundleChips bundledIn={bundledIn} isIconOnly={isBundleTextVisible} />
</Box>
)}
{isDescriptionVisible && (
<Box is='span' withTruncatedText width='x369'>
{description}
</Box>
{categories && (
<Box color='hint' display='flex' flex-direction='row' withTruncatedText>
{categories.map((current) => (
<Box mie='x4' key={current}>
<Tag disabled>{current}</Tag>
</Box>
))}
</Box>
)}
</Box>
</Table.Cell>
)}
<Table.Cell withTruncatedText>
<Box display='flex' flexDirection='row' alignItems='center' marginInline='neg-x8' onClick={preventClickPropagation}>
<AppStatus app={props} showStatus={isStatusVisible} isAppDetailsPage={false} mis='x4' />
{installed && <AppMenu app={props} invisible={!isStatusVisible} mis='x4' />}
)}
</Box>
</Table.Cell>
</Table.Row>
</Box>
<Box display='flex' flexDirection='row' alignItems='center' justifyContent='flex-end' onClick={preventClickPropagation} width='20%'>
<AppStatus app={props} isSubscribed={isSubscribed} isAppDetailsPage={false} mis='x4' />
{(installed || isSubscribed) && <AppMenu app={props} mis='x4' />}
</Box>
</Box>
);
};

@ -1,8 +1,8 @@
import { Box, Button, Icon, Throbber } from '@rocket.chat/fuselage';
import { Box, Button, Icon, Throbber, Tooltip, PositionAnimated, AnimatedVisibility } from '@rocket.chat/fuselage';
import { useSafely } from '@rocket.chat/fuselage-hooks';
import colors from '@rocket.chat/fuselage-tokens/colors.json';
import { useSetModal, useMethod, useTranslation } from '@rocket.chat/ui-contexts';
import React, { useCallback, useState, memo } from 'react';
import React, { useCallback, useState, useRef, memo } from 'react';
import { Apps } from '../../../../app/apps/client/orchestrator';
import AppPermissionsReviewModal from './AppPermissionsReviewModal';
@ -33,11 +33,13 @@ const actions = {
},
};
const AppStatus = ({ app, showStatus = true, isAppDetailsPage, installed = false, ...props }) => {
const AppStatus = ({ app, showStatus = true, isAppDetailsPage, isSubscribed, installed = false, ...props }) => {
const t = useTranslation();
const [loading, setLoading] = useSafely(useState());
const [isAppPurchased, setPurchased] = useSafely(useState(app?.isPurchased));
const [isHovered, setIsHovered] = useState(false);
const setModal = useSetModal();
const statusRef = useRef();
const { price, purchaseType, pricingPlans } = app;
@ -117,7 +119,7 @@ const AppStatus = ({ app, showStatus = true, isAppDetailsPage, installed = false
<Box {...props}>
{button && (
<Box
bg={colors.p100}
bg={isAppDetailsPage ? colors.p100 : 'transparent'}
display='flex'
flexDirection='row'
alignItems='center'
@ -125,13 +127,24 @@ const AppStatus = ({ app, showStatus = true, isAppDetailsPage, installed = false
borderRadius='x2'
invisible={!showStatus && !loading}
>
<Button primary disabled={loading} minHeight='x40' onClick={handleClick}>
<Button
secondary={button.label !== 'Update'}
primary={button.label === 'Update'}
fontSize='x12'
fontWeight={700}
disabled={loading}
onClick={handleClick}
mie={isAppDetailsPage || isSubscribed ? '0' : 'x32'}
pi='x8'
pb='x6'
lineHeight='x12'
>
{loading ? (
<Throbber inheritColor />
) : (
<>
{button.icon && <Icon name={button.icon} mie='x8' />}
{t(button.label)}
{t(button.label.replace(' ', '_'))}
</>
)}
</Button>
@ -145,10 +158,29 @@ const AppStatus = ({ app, showStatus = true, isAppDetailsPage, installed = false
</Box>
)}
{status && (
<Box display='flex' alignItems='center' pi='x14' pb='x8' bg={AppStatusStyle.bg} color={AppStatusStyle.color}>
<Icon size='x20' name={status.icon} mie='x4' />
{t(status.label)}
</Box>
<>
<Box
ref={statusRef}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
display='flex'
alignItems='center'
pi='x8'
pb='x8'
bg={AppStatusStyle.bg}
color={AppStatusStyle.color}
>
<Icon size='x20' name={status.icon} mie='x4' />
</Box>
<PositionAnimated
anchor={statusRef}
placement='top-middle'
margin={8}
visible={isHovered ? AnimatedVisibility.VISIBLE : AnimatedVisibility.HIDDEN}
>
<Tooltip bg={colors.n900} color={colors.white}>{`App ${status.label}`}</Tooltip>
</PositionAnimated>
</>
)}
</Box>
);

@ -0,0 +1,79 @@
import { useMediaQuery } from '@rocket.chat/fuselage-hooks';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React, { ReactElement } from 'react';
import FilterByText from '../../../components/FilterByText';
import CategoryDropDown from './components/CategoryFilter/CategoryDropDown';
import TagList from './components/CategoryFilter/TagList';
import RadioDropDown from './components/RadioDropDown/RadioDropDown';
import { CategoryDropdownItem, CategoryOnSelected, selectedCategoriesList } from './definitions/CategoryDropdownDefinitions';
import { RadioDropDownGroup, RadioDropDownOnSelected } from './definitions/RadioDropDownDefinitions';
type AppsFiltersProps = {
setText: React.Dispatch<React.SetStateAction<string>> & {
flush: () => void;
cancel: () => void;
};
freePaidFilterStructure: RadioDropDownGroup;
freePaidFilterOnSelected: RadioDropDownOnSelected;
categories: {
label?: string | undefined;
items: CategoryDropdownItem[];
}[];
selectedCategories: selectedCategoriesList;
onSelected: CategoryOnSelected;
sortFilterStructure: RadioDropDownGroup;
sortFilterOnSelected: RadioDropDownOnSelected;
categoryTagList: selectedCategoriesList;
statusFilterStructure: RadioDropDownGroup;
statusFilterOnSelected: RadioDropDownOnSelected;
};
const AppsFilters = ({
setText,
freePaidFilterStructure,
freePaidFilterOnSelected,
categories,
selectedCategories,
onSelected,
sortFilterStructure,
sortFilterOnSelected,
categoryTagList,
statusFilterStructure,
statusFilterOnSelected,
}: AppsFiltersProps): ReactElement => {
const t = useTranslation();
const shouldFiltersStack = useMediaQuery('(max-width: 1060px)');
const hasFilterStackMargin = shouldFiltersStack ? '' : 'x8';
const hasNotFilterStackMargin = shouldFiltersStack ? 'x8' : '';
return (
<>
<FilterByText placeholder={t('Search_Apps')} onChange={({ text }): void => setText(text)} shouldFiltersStack={shouldFiltersStack}>
<RadioDropDown
group={freePaidFilterStructure}
onSelected={freePaidFilterOnSelected}
mie={hasFilterStackMargin}
mb={hasNotFilterStackMargin}
/>
<RadioDropDown
group={statusFilterStructure}
onSelected={statusFilterOnSelected}
mie={hasFilterStackMargin}
mbe={hasNotFilterStackMargin}
/>
<CategoryDropDown data={categories} selectedCategories={selectedCategories} onSelected={onSelected} />
<RadioDropDown
group={sortFilterStructure}
onSelected={sortFilterOnSelected}
mis={hasFilterStackMargin}
mbs={hasNotFilterStackMargin}
/>
</FilterByText>
<TagList categories={categoryTagList} onClick={onSelected} />
</>
);
};
export default AppsFilters;

@ -10,73 +10,55 @@ import {
StatesSuggestionListItem,
StatesSuggestionText,
StatesTitle,
Pagination,
Icon,
} from '@rocket.chat/fuselage';
import { useDebouncedState } from '@rocket.chat/fuselage-hooks';
import { useRoute, useTranslation } from '@rocket.chat/ui-contexts';
import React, { FC, useMemo, useState } from 'react';
import FilterByText from '../../../components/FilterByText';
import {
GenericTable,
GenericTableBody,
GenericTableHeader,
GenericTableHeaderCell,
GenericTableLoadingTable,
} from '../../../components/GenericTable';
import { usePagination } from '../../../components/GenericTable/hooks/usePagination';
import { useResizeInlineBreakpoint } from '../../../hooks/useResizeInlineBreakpoint';
import { AsyncStatePhase } from '../../../lib/asyncState';
import AppRow from './AppRow';
import { useAppsReload, useAppsResult } from './AppsContext';
import MarketplaceRow from './MarketplaceRow';
import CategoryDropDown from './components/CategoryFilter/CategoryDropDown';
import TagList from './components/CategoryFilter/TagList';
import RadioDropDown from './components/RadioDropDown/RadioDropDown';
import AppsFilters from './AppsFilters';
import AppsListMain from './AppsListMain';
import { RadioDropDownGroup } from './definitions/RadioDropDownDefinitions';
import { useCategories } from './hooks/useCategories';
import { useFilteredApps } from './hooks/useFilteredApps';
import { useRadioToggle } from './hooks/useRadioToggle';
const AppsTable: FC<{
const AppsList: FC<{
isMarketplace: boolean;
}> = ({ isMarketplace }) => {
const t = useTranslation();
const [ref, onLargeBreakpoint, onMediumBreakpoint] = useResizeInlineBreakpoint([800, 600], 200) as [
React.RefObject<HTMLElement>,
boolean,
boolean,
];
const { marketplaceApps, installedApps } = useAppsResult();
const marketplaceRoute = useRoute('admin-marketplace');
const Row = isMarketplace ? MarketplaceRow : AppRow;
const [text, setText] = useDebouncedState('', 500);
const reload = useAppsReload();
const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination();
const [freePaidFilterStructure, setFreePaidFilterStructure] = useState<RadioDropDownGroup>({
const marketplaceRoute = useRoute('admin-marketplace');
const [freePaidFilterStructure, setFreePaidFilterStructure] = useState({
label: t('Filter_By_Price'),
items: [
{ id: 'all', label: t('All_Apps'), checked: true },
{ id: 'all', label: t('All_Prices'), checked: true },
{ id: 'free', label: t('Free_Apps'), checked: false },
{ id: 'paid', label: t('Paid_Apps'), checked: false },
],
});
const freePaidFilterOnSelected = useRadioToggle(setFreePaidFilterStructure);
const [categories, selectedCategories, categoryTagList, onSelected] = useCategories();
const [statusFilterStructure, setStatusFilterStructure] = useState({
label: t('Filter_By_Status'),
items: [
{ id: 'all', label: t('All_status'), checked: true },
{ id: 'enabled', label: t('Enabled'), checked: false },
{ id: 'disabled', label: t('Disabled'), checked: false },
],
});
const statusFilterOnSelected = useRadioToggle(setStatusFilterStructure);
const [sortFilterStructure, setSortFilterStructure] = useState<RadioDropDownGroup>({
label: 'Sort by',
label: t('Sort_By'),
items: [
{ id: 'az', label: 'A-Z', checked: true },
{ id: 'za', label: 'Z-A', checked: false },
@ -84,9 +66,9 @@ const AppsTable: FC<{
{ id: 'lru', label: t('Least_recent_updated'), checked: false },
],
});
const sortFilterOnSelected = useRadioToggle(setSortFilterStructure);
const [categories, selectedCategories, categoryTagList, onSelected] = useCategories();
const appsResult = useFilteredApps({
appsData: isMarketplace ? marketplaceApps : installedApps,
text,
@ -95,57 +77,53 @@ const AppsTable: FC<{
categories: useMemo(() => selectedCategories.map(({ label }) => label), [selectedCategories]),
purchaseType: useMemo(() => freePaidFilterStructure.items.find(({ checked }) => checked)?.id, [freePaidFilterStructure]),
sortingMethod: useMemo(() => sortFilterStructure.items.find(({ checked }) => checked)?.id, [sortFilterStructure]),
status: useMemo(() => statusFilterStructure.items.find(({ checked }) => checked)?.id, [statusFilterStructure]),
});
const isAppListReadyOrLoading =
appsResult.phase === AsyncStatePhase.LOADING || (appsResult.phase === AsyncStatePhase.RESOLVED && Boolean(appsResult.value.count));
const noInstalledAppsFound = appsResult.phase === AsyncStatePhase.RESOLVED && !isMarketplace && appsResult.value.total === 0;
const noMarketplaceOrInstalledAppMatches = appsResult.phase === AsyncStatePhase.RESOLVED && isMarketplace && appsResult.value.count === 0;
const noInstalledAppMatches =
appsResult.phase === AsyncStatePhase.RESOLVED && !isMarketplace && appsResult.value.total !== 0 && appsResult.value.count === 0;
return (
<>
{/* TODO Divide into two components: Filters and AppsTable */}
<FilterByText placeholder={t('Search_Apps')} onChange={({ text }): void => setText(text)}>
<RadioDropDown group={freePaidFilterStructure} onSelected={freePaidFilterOnSelected} mie='x8' />
<CategoryDropDown data={categories} selectedCategories={selectedCategories} onSelected={onSelected} />
<RadioDropDown group={sortFilterStructure} onSelected={sortFilterOnSelected} mis='x8' />
</FilterByText>
<TagList categories={categoryTagList} onClick={onSelected} />
{(appsResult.phase === AsyncStatePhase.LOADING ||
(appsResult.phase === AsyncStatePhase.RESOLVED && Boolean(appsResult.value.count))) && (
<>
<GenericTable ref={ref}>
<GenericTableHeader>
<GenericTableHeaderCell width={onMediumBreakpoint ? 'x240' : 'x180'}>{t('Name')}</GenericTableHeaderCell>
{onMediumBreakpoint && <GenericTableHeaderCell>{t('Details')}</GenericTableHeaderCell>}
{isMarketplace && <GenericTableHeaderCell>{t('Price')}</GenericTableHeaderCell>}
<GenericTableHeaderCell width='x160'>{t('Status')}</GenericTableHeaderCell>
</GenericTableHeader>
<GenericTableBody>
{appsResult.phase === AsyncStatePhase.LOADING && (
<GenericTableLoadingTable
// eslint-disable-next-line no-nested-ternary
headerCells={onMediumBreakpoint ? (isMarketplace ? 4 : 3) : 2}
/>
)}
{appsResult.phase === AsyncStatePhase.RESOLVED &&
appsResult.value.items.map((app) => <Row key={app.id} large={onLargeBreakpoint} medium={onMediumBreakpoint} {...app} />)}
</GenericTableBody>
</GenericTable>
{appsResult.phase === AsyncStatePhase.RESOLVED && (
<Pagination
current={current}
itemsPerPage={itemsPerPage}
count={appsResult.value.total}
onSetItemsPerPage={onSetItemsPerPage}
onSetCurrent={onSetCurrent}
{...paginationProps}
/>
)}
</>
<AppsFilters
setText={setText}
freePaidFilterStructure={freePaidFilterStructure}
freePaidFilterOnSelected={freePaidFilterOnSelected}
categories={categories}
selectedCategories={selectedCategories}
onSelected={onSelected}
sortFilterStructure={sortFilterStructure}
sortFilterOnSelected={sortFilterOnSelected}
categoryTagList={categoryTagList}
statusFilterStructure={statusFilterStructure}
statusFilterOnSelected={statusFilterOnSelected}
/>
{isAppListReadyOrLoading && (
<AppsListMain
appsResult={appsResult}
current={current}
itemsPerPage={itemsPerPage}
onSetItemsPerPage={onSetItemsPerPage}
onSetCurrent={onSetCurrent}
paginationProps={paginationProps}
isMarketplace={isMarketplace}
/>
)}
{appsResult.phase === AsyncStatePhase.RESOLVED && isMarketplace && appsResult.value.count === 0 && (
{noMarketplaceOrInstalledAppMatches && (
<Box mbs='x20'>
<States>
<StatesIcon name='magnifier' />
<StatesTitle>{t('No_app_matches')}</StatesTitle>
{appsResult.value.shouldShowSearchText ? (
{appsResult?.value?.shouldShowSearchText ? (
<StatesSubtitle>
{t('No_marketplace_matches_for')}: <strong>"{text}"</strong>
</StatesSubtitle>
@ -164,24 +142,13 @@ const AppsTable: FC<{
</States>
</Box>
)}
{appsResult.phase === AsyncStatePhase.RESOLVED && !isMarketplace && appsResult.value.total === 0 && (
<Box mbs='x20'>
<States>
<StatesIcon name='magnifier' />
<StatesTitle>{t('No_apps_installed')}</StatesTitle>
<StatesSubtitle>{t('Explore_the_marketplace_to_find_awesome_apps')}</StatesSubtitle>
<StatesActions>
<StatesAction onClick={(): void => marketplaceRoute.push({ context: '' })}>{t('Explore_marketplace')}</StatesAction>
</StatesActions>
</States>
</Box>
)}
{appsResult.phase === AsyncStatePhase.RESOLVED && !isMarketplace && appsResult.value.total !== 0 && appsResult.value.count === 0 && (
{noInstalledAppMatches && (
<Box mbs='x20'>
<States>
<StatesIcon name='magnifier' />
<StatesTitle>{t('No_installed_app_matches')}</StatesTitle>
{appsResult.value.shouldShowSearchText ? (
{appsResult?.value?.shouldShowSearchText ? (
<StatesSubtitle>
<span>
{t('No_app_matches_for')} <strong>"{text}"</strong>
@ -199,6 +166,20 @@ const AppsTable: FC<{
</States>
</Box>
)}
{noInstalledAppsFound && (
<Box mbs='x20'>
<States>
<StatesIcon name='magnifier' />
<StatesTitle>{t('No_apps_installed')}</StatesTitle>
<StatesSubtitle>{t('Explore_the_marketplace_to_find_awesome_apps')}</StatesSubtitle>
<StatesActions>
<StatesAction onClick={(): void => marketplaceRoute.push({ context: '' })}>{t('Explore_marketplace')}</StatesAction>
</StatesActions>
</States>
</Box>
)}
{appsResult.phase === AsyncStatePhase.REJECTED && (
<Box mbs='x20'>
<States>
@ -218,4 +199,4 @@ const AppsTable: FC<{
);
};
export default AppsTable;
export default AppsList;

@ -0,0 +1,68 @@
import { App } from '@rocket.chat/core-typings';
import { Box, Pagination, Skeleton } from '@rocket.chat/fuselage';
import colors from '@rocket.chat/fuselage-tokens/colors';
import React, { ReactElement } from 'react';
import { AsyncState, AsyncStatePhase } from '../../../lib/asyncState';
import AppRow from './AppRow';
type itemsPerPage = 25 | 50 | 100;
type AppsListMainProps = {
appsResult: AsyncState<
{
items: App[];
} & {
shouldShowSearchText: boolean;
} & {
count: number;
offset: number;
total: number;
}
>;
current: number;
itemsPerPage: itemsPerPage;
onSetItemsPerPage: React.Dispatch<React.SetStateAction<itemsPerPage>>;
onSetCurrent: React.Dispatch<React.SetStateAction<number>>;
paginationProps: {
itemsPerPageLabel: () => string;
showingResultsLabel: (context: { count: number; current: number; itemsPerPage: itemsPerPage }) => string;
};
isMarketplace: boolean;
};
const AppsListMain = ({
appsResult,
current,
itemsPerPage,
onSetItemsPerPage,
onSetCurrent,
paginationProps,
isMarketplace,
}: AppsListMainProps): ReactElement => {
const loadingRows = Array.from({ length: 8 }, (_, i) => <Skeleton key={i} height='x56' mbe='x8' width='100%' variant='rect' />);
return (
<>
<Box overflowY='auto' height='100%'>
{appsResult.phase === AsyncStatePhase.LOADING
? loadingRows
: appsResult.phase === AsyncStatePhase.RESOLVED &&
appsResult.value.items.map((app) => <AppRow key={app.id} isMarketplace={isMarketplace} {...app} />)}
</Box>
{appsResult.phase === AsyncStatePhase.RESOLVED && (
<Pagination
current={current}
itemsPerPage={itemsPerPage}
count={appsResult.value.total}
onSetItemsPerPage={onSetItemsPerPage}
onSetCurrent={onSetCurrent}
borderBlockStart={`2px solid ${colors.n300}`}
{...paginationProps}
/>
)}
</>
);
};
export default AppsListMain;

@ -1,9 +1,10 @@
import { Button, ButtonGroup, Icon, Skeleton, Tabs } from '@rocket.chat/fuselage';
import colors from '@rocket.chat/fuselage-tokens/colors';
import { useRoute, useSetting, useMethod, useTranslation } from '@rocket.chat/ui-contexts';
import React, { useEffect, useState, ReactElement } from 'react';
import Page from '../../../components/Page';
import AppsTable from './AppsTable';
import AppsList from './AppsList';
type AppsPageProps = {
isMarketplace: boolean;
@ -37,7 +38,7 @@ const AppsPage = ({ isMarketplace }: AppsPageProps): ReactElement => {
return (
<Page>
<Page.Header title={t('Apps')}>
<Page.Header title={t('Apps')} bg={colors.n100}>
<ButtonGroup>
{isMarketplace && !isLoggedInCloud && (
<Button disabled={isLoggedInCloud === undefined} onClick={handleLoginButtonClick}>
@ -57,7 +58,7 @@ const AppsPage = ({ isMarketplace }: AppsPageProps): ReactElement => {
)}
</ButtonGroup>
</Page.Header>
<Tabs>
<Tabs bg={colors.n100}>
<Tabs.Item onClick={(): void => marketplaceRoute.push({ context: '' })} selected={isMarketplace}>
{t('Marketplace')}
</Tabs.Item>
@ -65,8 +66,8 @@ const AppsPage = ({ isMarketplace }: AppsPageProps): ReactElement => {
{t('Installed')}
</Tabs.Item>
</Tabs>
<Page.Content>
<AppsTable isMarketplace={isMarketplace} />
<Page.Content bg={colors.n100}>
<AppsList isMarketplace={isMarketplace} />
</Page.Content>
</Page>
);

@ -10,9 +10,10 @@ type BundleChipsProps = {
bundleName: string;
apps: App[];
}[];
isIconOnly?: boolean;
};
const BundleChips = ({ bundledIn }: BundleChipsProps): ReactElement => {
const BundleChips = ({ bundledIn, isIconOnly }: BundleChipsProps): ReactElement => {
const t = useTranslation();
const bundleRef = useRef<Element>();
@ -36,11 +37,13 @@ const BundleChips = ({ bundledIn }: BundleChipsProps): ReactElement => {
onMouseLeave={(): void => setIsHovered(false)}
>
<Icon name='bag' size='x20' />
<Box fontWeight='700' fontSize='x12' color='info'>
{t('bundle_chip_title', {
bundleName: bundle.bundleName,
})}
</Box>
{!isIconOnly && (
<Box fontWeight='700' fontSize='x12' color='info' style={{ whiteSpace: 'nowrap' }}>
{t('bundle_chip_title', {
bundleName: bundle.bundleName,
})}
</Box>
)}
</Box>
<PositionAnimated
anchor={bundleRef as RefObject<Element>}

@ -1,115 +0,0 @@
import { Box, Table, Tag } from '@rocket.chat/fuselage';
import { useRoute, useTranslation } from '@rocket.chat/ui-contexts';
import React, { useState, memo, FC, KeyboardEvent, MouseEvent } from 'react';
import AppAvatar from '../../../components/avatar/AppAvatar';
import AppMenu from './AppMenu';
import AppStatus from './AppStatus';
import PriceDisplay from './PriceDisplay';
import { App } from './types';
type MarketplaceRowProps = {
medium?: boolean;
large?: boolean;
} & App;
const MarketplaceRow: FC<MarketplaceRowProps> = ({ medium, large, ...props }) => {
const {
author: { name: authorName },
name,
id,
description,
categories,
purchaseType,
pricingPlans,
price,
iconFileData,
marketplaceVersion,
iconFileContent,
installed,
isSubscribed,
} = props;
const t = useTranslation();
const [isFocused, setFocused] = useState(false);
const [isHovered, setHovered] = useState(false);
const isStatusVisible = isFocused || isHovered;
const marketplaceRoute = useRoute('admin-marketplace');
const handleClick = (): void => {
marketplaceRoute.push({
context: 'details',
version: marketplaceVersion,
id,
});
};
const handleKeyDown = (e: KeyboardEvent<HTMLOrSVGElement>): void => {
if (!['Enter', 'Space'].includes(e.nativeEvent.code)) {
return;
}
handleClick();
};
const preventClickPropagation = (e: MouseEvent<HTMLOrSVGElement>): void => {
e.stopPropagation();
};
return (
<Table.Row
key={id}
role='link'
action
tabIndex={0}
onClick={handleClick}
onKeyDown={handleKeyDown}
onFocus={(): void => setFocused(true)}
onBlur={(): void => setFocused(false)}
onMouseEnter={(): void => setHovered(true)}
onMouseLeave={(): void => setHovered(false)}
>
<Table.Cell withTruncatedText display='flex' flexDirection='row'>
<AppAvatar size='x40' mie='x8' alignSelf='center' iconFileContent={iconFileContent} iconFileData={iconFileData} />
<Box display='flex' flexDirection='column' alignSelf='flex-start'>
<Box color='default' fontScale='p2m'>
{name}
</Box>
<Box color='default' fontScale='p2m'>{`${t('By')} ${authorName}`}</Box>
</Box>
</Table.Cell>
{large && (
<Table.Cell>
<Box display='flex' flexDirection='column'>
<Box color='default' withTruncatedText>
{description}
</Box>
{categories && (
<Box color='hint' display='flex' flex-direction='row' withTruncatedText>
{categories.map((current) => (
<Box key={current} mie='x4'>
<Tag disabled>{current}</Tag>
</Box>
))}
</Box>
)}
</Box>
</Table.Cell>
)}
{medium && (
<Table.Cell>
<PriceDisplay {...{ purchaseType, pricingPlans, price }} />
</Table.Cell>
)}
<Table.Cell withTruncatedText>
<Box display='flex' flexDirection='row' alignItems='center' marginInline='neg-x8' onClick={preventClickPropagation}>
<AppStatus app={props} showStatus={isStatusVisible} isAppDetailsPage={false} mis='x4' />
{(installed || isSubscribed) && <AppMenu app={props} invisible={!isStatusVisible} mis='x4' />}
</Box>
</Table.Cell>
</Table.Row>
);
};
export default memo(MarketplaceRow);

@ -24,8 +24,6 @@ const CategoryDropDown: FC<{
}
onMouseEventPreventSideEffects(e);
return false;
},
[toggleCollapsed],
);

@ -35,7 +35,14 @@ const CategoryDropDownAnchor = forwardRef<HTMLInputElement, Partial<ComponentPro
{props.selectedCategoriesCount}
</Box>
)}
<Box is='span' fontWeight='500' fontSize='p2b' color={props.selectedCategoriesCount ? 'alternative' : 'hint'} mi='x4'>
<Box
is='span'
display='flex'
flexGrow={1}
fontWeight={400}
fontSize='p2b'
color={props.selectedCategoriesCount ? 'alternative' : 'hint'}
>
{props.selectedCategoriesCount > 0 ? t('Categories') : t('All_categories')}
</Box>
<Box mi='x4' display='flex' alignItems='center' justifyContent='center'>

@ -29,7 +29,7 @@ const RadioDropDown: FC<RadioDropDownProps & Partial<ComponentProps<typeof Selec
return (
<>
<RadioDropDownAnchor ref={reference} onClick={toggleCollapsed as any} group={group} {...props} />
<RadioDropDownAnchor ref={reference} group={group} onClick={toggleCollapsed as any} {...props} />
{collapsed && (
<DropDownListWrapper ref={reference} onClose={onClose}>
<RadioButtonList group={group} onSelected={onSelected} />

@ -4,14 +4,18 @@ export type CategoryDropdownItem = {
checked?: boolean;
};
export type CategoryDropDownGroups = {
label?: string;
items: CategoryDropdownItem[];
}[];
export type CategoryDropDownListProps = {
groups: {
label?: string;
items: CategoryDropdownItem[];
}[];
groups: CategoryDropDownGroups;
width?: number;
onSelected: CategoryOnSelected;
};
export type selectedCategoriesList = (CategoryDropdownItem & { checked: true })[];
export type CategoryOnSelected = (item: CategoryDropdownItem) => void;
export type CategoryOnRemoved = (category: CategoryDropdownItem) => void;

@ -0,0 +1,7 @@
import { appStatusSpanProps } from '../helpers';
import { App } from '../types';
export const filterAppsByDisabled = (app: App): boolean =>
appStatusSpanProps(app)?.label === 'Disabled' ||
appStatusSpanProps(app)?.label === 'Config Needed' ||
appStatusSpanProps(app)?.label === 'Failed';

@ -0,0 +1,5 @@
import { appStatusSpanProps } from '../helpers';
import { App } from '../types';
export const filterAppsByEnabled = (app: App): boolean =>
appStatusSpanProps(app)?.label === 'Enabled' || appStatusSpanProps(app)?.label === 'Trial period';

@ -2,17 +2,18 @@ import { useTranslation } from '@rocket.chat/ui-contexts';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Apps } from '../../../../../app/apps/client/orchestrator';
import { CategoryDropdownItem, CategoryDropDownListProps } from '../definitions/CategoryDropdownDefinitions';
import {
CategoryDropDownGroups,
CategoryDropdownItem,
CategoryDropDownListProps,
CategoryOnSelected,
selectedCategoriesList,
} from '../definitions/CategoryDropdownDefinitions';
import { handleAPIError } from '../helpers';
import { useCategoryFlatList } from './useCategoryFlatList';
import { useCategoryToggle } from './useCategoryToggle';
export const useCategories = (): [
CategoryDropDownListProps['groups'],
(CategoryDropdownItem & { checked: true })[],
(CategoryDropdownItem & { checked: true })[],
CategoryDropDownListProps['onSelected'],
] => {
export const useCategories = (): [CategoryDropDownGroups, selectedCategoriesList, selectedCategoriesList, CategoryOnSelected] => {
const t = useTranslation();
const [categories, setCategories] = useState<CategoryDropDownListProps['groups']>([]);

@ -4,6 +4,8 @@ import { useMemo, ContextType } from 'react';
import { AsyncState, AsyncStatePhase } from '../../../../lib/asyncState';
import type { AppsContext } from '../AppsContext';
import { filterAppsByCategories } from '../helpers/filterAppsByCategories';
import { filterAppsByDisabled } from '../helpers/filterAppsByDisabled';
import { filterAppsByEnabled } from '../helpers/filterAppsByEnabled';
import { filterAppsByFree } from '../helpers/filterAppsByFree';
import { filterAppsByPaid } from '../helpers/filterAppsByPaid';
import { filterAppsByText } from '../helpers/filterAppsByText';
@ -21,6 +23,7 @@ export const useFilteredApps = ({
categories = [],
purchaseType,
sortingMethod,
status,
}: {
appsData: appsDataType;
text: string;
@ -29,6 +32,7 @@ export const useFilteredApps = ({
categories?: string[];
purchaseType?: string;
sortingMethod?: string;
status?: string;
}): AsyncState<{ items: App[] } & { shouldShowSearchText: boolean } & PaginatedResult> => {
const value = useMemo(() => {
if (appsData.value === undefined) {
@ -54,12 +58,15 @@ export const useFilteredApps = ({
}
if (purchaseType && purchaseType !== 'all') {
filtered =
purchaseType === 'paid' ? filtered.filter((app) => filterAppsByPaid(app)) : filtered.filter((app) => filterAppsByFree(app));
filtered = purchaseType === 'paid' ? filtered.filter(filterAppsByPaid) : filtered.filter(filterAppsByFree);
if (!filtered.length) {
shouldShowSearchText = false;
}
if (!filtered.length) shouldShowSearchText = false;
}
if (status && status !== 'all') {
filtered = status === 'enabled' ? filtered.filter(filterAppsByEnabled) : filtered.filter(filterAppsByDisabled);
if (!filtered.length) shouldShowSearchText = false;
}
if (Boolean(categories.length) && Boolean(text)) {
@ -83,7 +90,7 @@ export const useFilteredApps = ({
const slice = filtered.slice(offset, end);
return { items: slice, offset, total: apps.length, count: slice.length, shouldShowSearchText };
}, [appsData.value, sortingMethod, purchaseType, categories, text, current, itemsPerPage]);
}, [appsData.value, sortingMethod, purchaseType, status, categories, text, current, itemsPerPage]);
if (appsData.phase === AsyncStatePhase.RESOLVED) {
if (!value) {

@ -334,6 +334,8 @@
"All_closed_chats_have_been_removed": "All closed chats have been removed",
"All_logs": "All logs",
"All_messages": "All messages",
"All_Prices": "All prices",
"All_status": "All status",
"All_users": "All users",
"All_users_in_the_channel_can_write_new_messages": "All users in the channel can write new messages",
"Allow_collect_and_store_HTTP_header_informations": "Allow to collect and store HTTP header informations",
@ -737,6 +739,7 @@
"Business_hours_updated": "Business hours updated",
"busy": "busy",
"Busy": "Busy",
"Buy": "Buy",
"By": "By",
"by": "by",
"By_author": "By __author__",
@ -2138,7 +2141,8 @@
"FileUpload_Webdav_Username": "WebDAV Username",
"Filter": "Filter",
"Filter_by_category": "Filter by Category",
"Filter_By_Price": "Filter By Price",
"Filter_By_Price": "Filter by price",
"Filter_By_Status": "Filter by status",
"Filters": "Filters",
"Filters_applied": "Filters applied",
"Financial_Services": "Financial Services",
@ -4036,6 +4040,7 @@
"seconds": "seconds",
"Secret_token": "Secret Token",
"Security": "Security",
"See_Pricing": "See Pricing",
"Powered_by_RocketChat": "Powered by Rocket.Chat",
"See_full_profile": "See full profile",
"See_history": "See history",
@ -4316,6 +4321,7 @@
"Style": "Style",
"Subject": "Subject",
"Submit": "Submit",
"Subscribe": "Subscribe",
"Success": "Success",
"Success_message": "Success message",
"Successfully_downloaded_file_from_external_URL_should_start_preparing_soon": "Successfully downloaded file from external URL, should start preparing soon",
@ -4605,6 +4611,7 @@
"Troubleshoot_Disable_Workspace_Sync": "Disable Workspace Sync",
"Troubleshoot_Disable_Workspace_Sync_Alert": "This setting stops the sync of this server with Rocket.Chat's cloud and may cause issues with marketplace and enteprise licenses!",
"True": "True",
"Try_now": "Try now",
"Try_searching_in_the_marketplace_instead": "Try searching in the Marketplace instead",
"Tuesday": "Tuesday",
"Turn_OFF": "Turn OFF",

Loading…
Cancel
Save