Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ export type SettingsSectionProps = {
groupId: string;
hasReset?: boolean;
sectionName: string;
sectionTitle?: string;
currentTab?: string;
solo: boolean;
help?: ReactNode;
children?: ReactNode;
};

function SettingsSection({ groupId, hasReset = true, sectionName, currentTab, solo, help, children }: SettingsSectionProps) {
function SettingsSection({ groupId, hasReset = true, sectionTitle, sectionName, currentTab, solo, help, children }: SettingsSectionProps) {
const { t } = useTranslation();

const editableSettings = useEditableSettings(
Expand Down Expand Up @@ -74,7 +75,7 @@ function SettingsSection({ groupId, hasReset = true, sectionName, currentTab, so
<AccordionItem
data-qa-section={sectionName}
noncollapsible={solo || !sectionName}
title={sectionName && t(sectionName as TranslationKey)}
title={sectionTitle || (sectionName && t(sectionName as TranslationKey))}
>
{help && (
<Box is='p' color='hint' fontScale='p2'>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Box } from '@rocket.chat/fuselage';
import { Accordion, Box } from '@rocket.chat/fuselage';
import { PageScrollableContentWithShadow } from '@rocket.chat/ui-client';
import { useRouter } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
import type { MouseEvent } from 'react';
import { Trans } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';

import { useEditableSettingsGroupSections } from '../../EditableSettingsContext';
import SettingsGroupPage from '../SettingsGroupPage';
import Section from '../SettingsSection';

type EnterpriseGroupPageProps = {
_id: string;
Expand All @@ -27,9 +29,11 @@ const useRedirectToRouteLink = (onClick: (event: MouseEvent<HTMLAnchorElement>)
return { href: '#', onClick: handleClick };
};

const EnterpriseGroupPage = ({ _id, i18nLabel, onClickBack, ...props }: EnterpriseGroupPageProps) => {
const EnterpriseGroupPage = ({ _id, i18nLabel, hasReset, currentTab, onClickBack, ...props }: EnterpriseGroupPageProps) => {
const { t } = useTranslation();
const { navigate } = useRouter();
const redirectProps = useRedirectToRouteLink(() => navigate('/admin/subscription'));
const sections = useEditableSettingsGroupSections(_id);

return (
<SettingsGroupPage isCustom _id={_id} i18nLabel={i18nLabel} onClickBack={onClickBack} {...props}>
Expand All @@ -39,6 +43,19 @@ const EnterpriseGroupPage = ({ _id, i18nLabel, onClickBack, ...props }: Enterpri
i18nKey='Workspace_license_is_now_managed_from_the_subscription_page'
components={{ a: <Box is='a' fontScale='p2' color='info' {...redirectProps} /> }}
/>
<Accordion>
{sections.map((sectionName) => (
<Section
key={sectionName || ''}
hasReset={hasReset}
groupId={_id}
sectionName={sectionName}
sectionTitle={t('Manual_license_management_deprecated')}
currentTab={currentTab}
solo={false}
/>
))}
</Accordion>
</Box>
</PageScrollableContentWithShadow>
</SettingsGroupPage>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,22 @@ const LicenseStatus = ({ isValidating, isValid, invalidMessage }: LicenseStatusP

if (isValidating) {
return (
<Callout icon='reload' type='info' title={`${t('Validating_license')}...`}>
<Callout role='status' icon='reload' type='info' title={`${t('Validating_license')}...`}>
<Skeleton width='x320' />
</Callout>
);
}

if (isValid) {
return (
<Callout type='success' title={t('Valid_license')}>
<Callout role='status' type='success' title={t('Valid_license')}>
{t('This_license_is_valid_and_ready_to_apply')}
</Callout>
);
}

return (
<Callout type='danger' title={t('Invalid_license')}>
<Callout role='alert' type='danger' title={t('Invalid_license')}>
{invalidMessage}
</Callout>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import type { BehaviorWithContext } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { composeStories } from '@storybook/react';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';

import ManageLicenseModal from './ManageLicenseModal';
import * as stories from './ManageLicenseModal.stories';
import createDeferredMockFn from '../../../../../../../../tests/mocks/utils/createDeferredMockFn';

// Long enough to pass isPlausibleLicense (>= 100 chars) so validation actually runs.
Expand All @@ -20,50 +23,69 @@ const validationFailure = (fails: BehaviorWithContext[]) => () => Promise.reject
// getByLabelText matches by label association regardless of visibility, so it reaches the display:none input.
const fileInput = () => screen.getByLabelText('Upload_license_file');

it('should render the title, description and a disabled apply button', () => {
render(<ManageLicenseModal enterpriseLicense='' onCancel={jest.fn()} />, { wrapper: mockAppRoot().build() });
const enterLicense = async (text: string) => {
await userEvent.click(screen.getByRole('textbox'));
await userEvent.paste(text);
};

expect(screen.getByRole('heading', { name: 'Manage_license' })).toBeInTheDocument();
expect(screen.getByText('Manage_license_description')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Apply_license' })).toBeDisabled();
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);

test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const { baseElement } = render(<Story />, { wrapper: mockAppRoot().build() });
expect(baseElement).toMatchSnapshot();
});

test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />, { wrapper: mockAppRoot().build() });

const results = await axe(container);
expect(results).toHaveNoViolations();
});

it('should show a success status for a valid license', async () => {
render(<ManageLicenseModal enterpriseLicense={LICENSE} onCancel={jest.fn()} />, {
render(<ManageLicenseModal enterpriseLicense='' onCancel={jest.fn()} />, {
wrapper: mockAppRoot().withEndpoint('POST', '/v1/licenses.validate', validationSuccess).build(),
});

await enterLicense(LICENSE);

expect(await screen.findByText('Valid_license')).toBeInTheDocument();
});

it('should map a validation failure to a specific message', async () => {
render(<ManageLicenseModal enterpriseLicense={LICENSE} onCancel={jest.fn()} />, {
render(<ManageLicenseModal enterpriseLicense='' onCancel={jest.fn()} />, {
wrapper: mockAppRoot()
.withEndpoint('POST', '/v1/licenses.validate', validationFailure(reasons(['invalidate_license', 'period'])))
.build(),
});

await enterLicense(LICENSE);

expect(await screen.findByText('License_error_expired')).toBeInTheDocument();
expect(screen.getByText('Invalid_license')).toBeInTheDocument();
});

it('should show a generic message when validation fails for a non-license reason', async () => {
render(<ManageLicenseModal enterpriseLicense={LICENSE} onCancel={jest.fn()} />, {
render(<ManageLicenseModal enterpriseLicense='' onCancel={jest.fn()} />, {
wrapper: mockAppRoot()
.withEndpoint('POST', '/v1/licenses.validate', () => Promise.reject(new Error('network down')))
.build(),
});

await enterLicense(LICENSE);

expect(await screen.findByText('License_error_generic')).toBeInTheDocument();
});

it('should show a validating status while the request is in flight', async () => {
const { fn, resolve } = createDeferredMockFn<null>();

render(<ManageLicenseModal enterpriseLicense={LICENSE} onCancel={jest.fn()} />, {
render(<ManageLicenseModal enterpriseLicense='' onCancel={jest.fn()} />, {
wrapper: mockAppRoot().withEndpoint('POST', '/v1/licenses.validate', fn).build(),
});

await enterLicense(LICENSE);

expect(await screen.findByText('Validating_license...')).toBeInTheDocument();

act(() => resolve(null));
Expand Down Expand Up @@ -111,6 +133,23 @@ it('should apply a valid license and close the modal', async () => {
await waitFor(() => expect(onCancel).toHaveBeenCalled());
});

it('should keep the apply button disabled after erasing a valid license', async () => {
render(<ManageLicenseModal enterpriseLicense='' onCancel={jest.fn()} />, {
wrapper: mockAppRoot().withEndpoint('POST', '/v1/licenses.validate', validationSuccess).build(),
});

await enterLicense(LICENSE);

expect(await screen.findByText('Valid_license')).toBeInTheDocument();

const applyButton = screen.getByRole('button', { name: 'Apply_license' });
await waitFor(() => expect(applyButton).toBeEnabled());

await userEvent.clear(screen.getByRole('textbox'));

await waitFor(() => expect(applyButton).toBeDisabled());
});

it('should ask for confirmation before removing the current license', async () => {
render(<ManageLicenseModal enterpriseLicense={LICENSE} onCancel={jest.fn()} />, {
wrapper: mockAppRoot().withEndpoint('POST', '/v1/licenses.validate', validationSuccess).build(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { BehaviorWithContext } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { Meta, StoryObj } from '@storybook/react';
import { action } from 'storybook/actions';
import { screen, userEvent, expect } from 'storybook/test';

import ManageLicenseModal from './ManageLicenseModal';

const SAMPLE_LICENSE = 'RCLICENSE-'.padEnd(120, 'x');

const validationSuccess = () => null;
const validationFailure = (reasons: BehaviorWithContext[]) => () => Promise.reject({ reasons });

const enterLicense = async (text: string) => {
await userEvent.click(screen.getByRole('textbox'));
await userEvent.paste(text);
};

export default {
component: ManageLicenseModal,
parameters: {
layout: 'fullscreen',
},
args: {
onCancel: action('onCancel'),
},
decorators: [
(Story) => {
const AppRoot = mockAppRoot().withEndpoint('POST', '/v1/licenses.validate', validationSuccess).build();

return (
<AppRoot>
<Story />
</AppRoot>
);
},
],
} satisfies Meta<typeof ManageLicenseModal>;

type Story = StoryObj<typeof ManageLicenseModal>;

export const Default: Story = {
args: {
enterpriseLicense: '',
},
};

export const WithCurrentLicense: Story = {
args: {
enterpriseLicense: SAMPLE_LICENSE,
},
};

export const InvalidLicense: Story = {
args: {
enterpriseLicense: '',
},
decorators: [
(Story) => {
const AppRoot = mockAppRoot()
.withEndpoint('POST', '/v1/licenses.validate', validationFailure([{ behavior: 'invalidate_license', reason: 'period' }]))
.build();

return (
<AppRoot>
<Story />
</AppRoot>
);
},
],
play: async () => {
await enterLicense(SAMPLE_LICENSE);
await expect(await screen.findByText('Invalid_license')).toBeInTheDocument();
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,16 @@ const ManageLicenseModal = ({ enterpriseLicense, onCancel }: ManageLicenseModalP

const trimmedLicense = license.trim();
const debouncedLicense = useDebouncedValue(trimmedLicense, 500);
const { data: validation, isPending, isError } = useValidateLicense(debouncedLicense);

const isEmpty = trimmedLicense === '';
const isPlausible = isPlausibleLicense(trimmedLicense);
const isValidating = isPlausible && (trimmedLicense !== debouncedLicense || isPending);

const isCurrentLicense = !isEmpty && trimmedLicense === enterpriseLicense.trim();

const isLicenseValid = !fileError && !isError && validation?.valid === true;
const { data: validation, isPending, isError } = useValidateLicense(debouncedLicense, !isCurrentLicense);

const isValidating = isPlausible && !isCurrentLicense && (trimmedLicense !== debouncedLicense || isPending);

const isLicenseValid = isPlausible && !fileError && !isError && validation?.valid === true;

const invalidMessage = (() => {
if (fileError) {
Expand All @@ -65,7 +66,7 @@ const ManageLicenseModal = ({ enterpriseLicense, onCancel }: ManageLicenseModalP
return t(getLicenseInvalidMessage(validation?.reasons ?? []));
})();

const showStatus = isPlausible || Boolean(fileError);
const showStatus = !isCurrentLicense && (isPlausible || Boolean(fileError));

const handleApply = async () => {
try {
Expand Down
Loading
Loading