Skip to content

Commit 3f000d7

Browse files
MartinSchoelerd-guberttassoevan
authored
feat: new App instances page & mixed app status (#36359)
Co-authored-by: Douglas Gubert <[email protected]> Co-authored-by: Tasso Evangelista <[email protected]>
1 parent 78ff5df commit 3f000d7

File tree

21 files changed

+419
-94
lines changed

21 files changed

+419
-94
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@rocket.chat/meteor": minor
3+
"@rocket.chat/i18n": minor
4+
---
5+
6+
Adds a new tab on App Details page to see all instances and it's statuses.
7+
Adds a new variant to the status tag, mixed status.

apps/meteor/client/apps/orchestrator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ class AppClientOrchestrator {
4747
}
4848

4949
public async getInstalledApps(): Promise<App[]> {
50-
const result = await sdk.rest.get<'/apps/installed'>('/apps/installed');
50+
const result = await sdk.rest.get<'/apps/installed'>('/apps/installed', { includeClusterStatus: 'true' });
5151

5252
if ('apps' in result) {
5353
// TODO: chapter day: multiple results are returned, but we only need one

apps/meteor/client/providers/AppsProvider/storeQueryFunction.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export function storeQueryFunction(
3030
...app,
3131
...(installedApp && {
3232
private: installedApp.private,
33+
clusterStatus: installedApp.clusterStatus,
3334
installed: true,
3435
status: installedApp.status,
3536
version: installedApp.version,

apps/meteor/client/views/marketplace/AppDetailsPage/AppDetailsPage.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import AppDetailsPageTabs from './AppDetailsPageTabs';
1313
import { handleAPIError } from '../helpers/handleAPIError';
1414
import { useAppInfo } from '../hooks/useAppInfo';
1515
import AppDetails from './tabs/AppDetails';
16+
import AppInstances from './tabs/AppInstances';
1617
import AppLogs from './tabs/AppLogs';
1718
import { AppLogsFilterContextualBar } from './tabs/AppLogs/Filters/AppLogsFilterContextualBar';
1819
import { useAppLogsFilterForm } from './tabs/AppLogs/useAppLogsFilterForm';
@@ -121,6 +122,7 @@ const AppDetailsPage = ({ id }: AppDetailsPageProps): ReactElement => {
121122
isSecurityVisible={isSecurityVisible}
122123
settings={settings}
123124
tab={tab}
125+
hasCluster={!!appData.clusterStatus}
124126
/>
125127
{Boolean(!tab || tab === 'details') && <AppDetails app={appData} />}
126128
{tab === 'requests' && <AppRequests id={id} isAdminUser={isAdminUser} />}
@@ -143,6 +145,7 @@ const AppDetailsPage = ({ id }: AppDetailsPageProps): ReactElement => {
143145
<AppLogs id={id} />
144146
</FormProvider>
145147
)}
148+
{tab === 'instances' && <AppInstances id={id} />}
146149
</>
147150
)}
148151
</Box>

apps/meteor/client/views/marketplace/AppDetailsPage/AppDetailsPageTabs.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,23 @@ type AppDetailsPageTabsProps = {
1111
isSecurityVisible: boolean;
1212
settings: ISettings | undefined;
1313
tab: string | undefined;
14+
hasCluster: boolean;
1415
};
1516

16-
const AppDetailsPageTabs = ({ context, installed, isSecurityVisible, settings, tab }: AppDetailsPageTabsProps): ReactElement => {
17+
const AppDetailsPageTabs = ({
18+
context,
19+
installed = false,
20+
isSecurityVisible,
21+
settings,
22+
tab,
23+
hasCluster = false,
24+
}: AppDetailsPageTabsProps): ReactElement => {
1725
const { t } = useTranslation();
1826
const isAdminUser = usePermission('manage-apps');
1927

2028
const router = useRouter();
2129

22-
const handleTabClick = (tab: 'details' | 'security' | 'releases' | 'settings' | 'logs' | 'requests') => {
30+
const handleTabClick = (tab: 'details' | 'security' | 'releases' | 'settings' | 'logs' | 'requests' | 'instances') => {
2331
router.navigate(
2432
{
2533
name: 'marketplace',
@@ -49,16 +57,21 @@ const AppDetailsPageTabs = ({ context, installed, isSecurityVisible, settings, t
4957
{t('Releases')}
5058
</Tabs.Item>
5159
)}
52-
{Boolean(installed && settings && Object.values(settings).length) && isAdminUser && (
60+
{installed && Boolean(settings && Object.values(settings).length) && isAdminUser && (
5361
<Tabs.Item onClick={() => handleTabClick('settings')} selected={tab === 'settings'}>
5462
{t('Settings')}
5563
</Tabs.Item>
5664
)}
57-
{Boolean(installed) && isAdminUser && isAdminUser && (
65+
{installed && isAdminUser && (
5866
<Tabs.Item onClick={() => handleTabClick('logs')} selected={tab === 'logs'}>
5967
{t('Logs')}
6068
</Tabs.Item>
6169
)}
70+
{hasCluster && installed && isAdminUser && (
71+
<Tabs.Item onClick={() => handleTabClick('instances')} selected={tab === 'instances'}>
72+
{t('Instances')}
73+
</Tabs.Item>
74+
)}
6275
</Tabs>
6376
);
6477
};
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { mockAppRoot } from '@rocket.chat/mock-providers';
2+
import { composeStories } from '@storybook/react';
3+
import { render } from '@testing-library/react';
4+
import { axe } from 'jest-axe';
5+
6+
import * as stories from './AppInstances.stories';
7+
8+
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
9+
10+
test.each(testCases)(`renders AppInstances without crashing`, async (_storyname, Story) => {
11+
const view = render(<Story />, { wrapper: mockAppRoot().build() });
12+
13+
expect(view.baseElement).toMatchSnapshot();
14+
});
15+
16+
test.each(testCases)('AppInstances should have no a11y violations', async (_storyname, Story) => {
17+
const { container } = render(<Story />, { wrapper: mockAppRoot().build() });
18+
19+
// Today we do not have exactly a pattern to handle menu cells that don't have a header
20+
const results = await axe(container, { rules: { 'empty-table-header': { enabled: false } } });
21+
22+
expect(results).toHaveNoViolations();
23+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { mockAppRoot } from '@rocket.chat/mock-providers';
2+
import type { Meta, StoryObj } from '@storybook/react';
3+
4+
import AppInstances from './AppInstances';
5+
6+
const statusPossibleStates = [
7+
'unknown',
8+
'constructed',
9+
'initialized',
10+
'auto_enabled',
11+
'manually_enabled',
12+
'compiler_error_disabled',
13+
'invalid_license_disabled',
14+
'invalid_installation_disabled',
15+
'error_disabled',
16+
'manually_disabled',
17+
'invalid_settings_disabled',
18+
'disabled',
19+
];
20+
21+
const statusTranslations = {
22+
App_status_auto_enabled: 'Enabled',
23+
App_status_constructed: 'Constructed',
24+
App_status_disabled: 'Disabled',
25+
App_status_error_disabled: 'Disabled: Uncaught Error',
26+
App_status_initialized: 'Initialized',
27+
App_status_invalid_license_disabled: 'Disabled: Invalid License',
28+
App_status_invalid_settings_disabled: 'Disabled: Configuration Needed',
29+
App_status_manually_disabled: 'Disabled: Manually',
30+
App_status_manually_enabled: 'Enabled',
31+
App_status_unknown: 'Unknown',
32+
App_status_invalid_installation_disabled: 'Disabled: Invalid Installation',
33+
Workspace_instance: 'Workspace instance',
34+
};
35+
36+
export default {
37+
title: 'Components/AppInstances',
38+
component: AppInstances,
39+
} satisfies Meta<typeof AppInstances>;
40+
41+
type Story = StoryObj<typeof AppInstances>;
42+
43+
export const Default: Story = {
44+
decorators: [
45+
mockAppRoot()
46+
.withEndpoint('GET', '/apps/:id/status', () => ({
47+
status: 'disabled',
48+
// Using any since the actual values from the AppStatus Enum cannot be used because it was exported as a type
49+
clusterStatus: statusPossibleStates.map((status, i) => ({ instanceId: `instance-id-${i}`, status })) as any,
50+
}))
51+
.withTranslations('en', 'core', statusTranslations)
52+
.buildStoryDecorator(),
53+
],
54+
args: {
55+
id: 'app-id',
56+
},
57+
};
58+
59+
export const NoResults: Story = {
60+
decorators: [
61+
mockAppRoot()
62+
.withEndpoint('GET', '/apps/:id/status', () => ({
63+
status: 'disabled',
64+
clusterStatus: [],
65+
}))
66+
.buildStoryDecorator(),
67+
],
68+
args: {
69+
id: 'app-id',
70+
},
71+
};
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type { AppStatus } from '@rocket.chat/apps';
2+
import { AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus';
3+
import { Box, Palette, Tag } from '@rocket.chat/fuselage';
4+
import { GenericMenu } from '@rocket.chat/ui-client';
5+
import { useRouter } from '@rocket.chat/ui-contexts';
6+
import type { ReactElement } from 'react';
7+
import { useTranslation } from 'react-i18next';
8+
9+
import { CustomScrollbars } from '../../../../../components/CustomScrollbars';
10+
import GenericNoResults from '../../../../../components/GenericNoResults';
11+
import {
12+
GenericTable,
13+
GenericTableBody,
14+
GenericTableCell,
15+
GenericTableHeader,
16+
GenericTableHeaderCell,
17+
GenericTableRow,
18+
} from '../../../../../components/GenericTable';
19+
import AccordionLoading from '../../../components/AccordionLoading';
20+
import { useAppInstances } from '../../../hooks/useAppInstances';
21+
22+
type AppInstanceProps = {
23+
id: string;
24+
};
25+
26+
const AppInstances = ({ id }: AppInstanceProps): ReactElement => {
27+
const { t } = useTranslation();
28+
const { data, isSuccess, isError, isLoading } = useAppInstances({ appId: id });
29+
30+
const getStatusColor = (status: AppStatus) => {
31+
if (AppStatusUtils.isDisabled(status) || AppStatusUtils.isError(status)) {
32+
return Palette.text['font-danger'].toString();
33+
}
34+
35+
return Palette.text['font-default'].toString();
36+
};
37+
38+
const router = useRouter();
39+
40+
const handleSelectLogs = () => {
41+
router.navigate(
42+
{
43+
name: 'marketplace',
44+
params: { ...router.getRouteParameters(), tab: 'logs' },
45+
},
46+
{ replace: true },
47+
);
48+
};
49+
50+
return (
51+
<Box h='full' w='full' marginInline='auto' color='default' pbs={24}>
52+
{isLoading && <AccordionLoading />}
53+
{isError && (
54+
<Box maxWidth='x600' alignSelf='center'>
55+
{t('App_not_found')}
56+
</Box>
57+
)}
58+
{isSuccess && data.clusterStatus && data.clusterStatus.length > 0 && (
59+
<CustomScrollbars>
60+
<GenericTable w='full'>
61+
<GenericTableHeader>
62+
<GenericTableHeaderCell key='instanceId'>{t('Workspace_instance')}</GenericTableHeaderCell>
63+
<GenericTableHeaderCell key='status'>{t('Status')}</GenericTableHeaderCell>
64+
<GenericTableHeaderCell key='actions' width={64} />
65+
</GenericTableHeader>
66+
<GenericTableBody>
67+
{data?.clusterStatus?.map((instance) => (
68+
<GenericTableRow key={instance.instanceId}>
69+
<GenericTableCell>{instance.instanceId}</GenericTableCell>
70+
<GenericTableCell>
71+
<Box justifyContent='flex-start' display='flex'>
72+
<Tag medium color={getStatusColor(instance.status)}>
73+
{t(`App_status_${instance.status}`)}
74+
</Tag>
75+
</Box>
76+
</GenericTableCell>
77+
<GenericTableCell>
78+
<GenericMenu
79+
title='Actions'
80+
items={[
81+
{
82+
content: t('View_Logs'),
83+
onClick: handleSelectLogs,
84+
id: 'view-logs',
85+
icon: 'desktop-text',
86+
},
87+
]}
88+
/>
89+
</GenericTableCell>
90+
</GenericTableRow>
91+
))}
92+
</GenericTableBody>
93+
</GenericTable>
94+
</CustomScrollbars>
95+
)}
96+
{isSuccess && (!data.clusterStatus || data.clusterStatus.length === 0) && (
97+
<CustomScrollbars>
98+
<GenericNoResults />
99+
</CustomScrollbars>
100+
)}
101+
</Box>
102+
);
103+
};
104+
105+
export default AppInstances;
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2+
3+
exports[`renders AppInstances without crashing 1`] = `
4+
<body>
5+
<div>
6+
<div
7+
class="rcx-box rcx-box--full rcx-css-vkstbu"
8+
>
9+
<span
10+
class="rcx-skeleton rcx-skeleton--rect rcx-css-jti8fm"
11+
/>
12+
<span
13+
class="rcx-skeleton rcx-skeleton--rect rcx-css-jti8fm"
14+
/>
15+
<span
16+
class="rcx-skeleton rcx-skeleton--rect rcx-css-jti8fm"
17+
/>
18+
</div>
19+
</div>
20+
</body>
21+
`;
22+
23+
exports[`renders AppInstances without crashing 2`] = `
24+
<body>
25+
<div>
26+
<div
27+
class="rcx-box rcx-box--full rcx-css-vkstbu"
28+
>
29+
<span
30+
class="rcx-skeleton rcx-skeleton--rect rcx-css-jti8fm"
31+
/>
32+
<span
33+
class="rcx-skeleton rcx-skeleton--rect rcx-css-jti8fm"
34+
/>
35+
<span
36+
class="rcx-skeleton rcx-skeleton--rect rcx-css-jti8fm"
37+
/>
38+
</div>
39+
</div>
40+
</body>
41+
`;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from './AppInstances';

0 commit comments

Comments
 (0)