Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4c41d8d
fix: Save button remains in loading state after saving app settings
sushen123 Mar 4, 2025
503b6f7
Add changeset for save button loading state fix
sushen123 Mar 4, 2025
ff32d91
Update changeset for save button
sushen123 Mar 4, 2025
9be6db7
fix: Hide footer after saving app settings
sushen123 Mar 5, 2025
b46f1d8
Add Unit test for AppDetailsPage.tsx
sushen123 Mar 7, 2025
2a8368e
Merge branch 'develop' into fix/save-button-loading-state
sushen123 Mar 7, 2025
478f114
tests
abhinavkrin Mar 7, 2025
b0990d0
Merge branch 'develop' into fix/save-button-loading-state
scuciatto Mar 19, 2025
731ffad
Merge branch 'develop' into fix/save-button-loading-state
d-gubert Mar 20, 2025
e403b1a
Merge branch 'develop' into fix/save-button-loading-state
scuciatto Mar 20, 2025
07276ba
Merge branch 'develop' into fix/save-button-loading-state
kodiakhq[bot] Mar 20, 2025
e89d573
Merge branch 'develop' into fix/save-button-loading-state
kodiakhq[bot] Mar 20, 2025
08681f4
Merge branch 'develop' into fix/save-button-loading-state
kodiakhq[bot] Mar 20, 2025
2046323
Merge branch 'develop' into fix/save-button-loading-state
d-gubert Mar 20, 2025
24e8f75
Merge branch 'develop' into fix/save-button-loading-state
abhinavkrin Mar 25, 2025
d9436e0
Merge branch 'develop' into fix/save-button-loading-state
kodiakhq[bot] Mar 25, 2025
65efa6d
Merge branch 'develop' into fix/save-button-loading-state
abhinavkrin Mar 25, 2025
1db7ad8
Merge branch 'develop' into fix/save-button-loading-state
kodiakhq[bot] Mar 25, 2025
6b7e6e1
Merge branch 'develop' into fix/save-button-loading-state
kodiakhq[bot] Mar 26, 2025
dc30baf
Merge branch 'develop' into fix/save-button-loading-state
kodiakhq[bot] Mar 26, 2025
c3a7441
Merge branch 'develop' into fix/save-button-loading-state
kodiakhq[bot] Mar 26, 2025
7a67296
Merge branch 'develop' into fix/save-button-loading-state
kodiakhq[bot] Mar 26, 2025
cac46b3
Merge branch 'develop' into fix/save-button-loading-state
kodiakhq[bot] Mar 26, 2025
a09d078
Merge branch 'develop' into fix/save-button-loading-state
kodiakhq[bot] Mar 26, 2025
de922b4
Merge branch 'develop' into fix/save-button-loading-state
kodiakhq[bot] Mar 26, 2025
ef85018
Merge branch 'develop' into fix/save-button-loading-state
kodiakhq[bot] Mar 26, 2025
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
5 changes: 5 additions & 0 deletions .changeset/fuzzy-eyes-clean.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Fixes the save button loading state in app settings, ensuring it resets properly after saving.
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import AppDetailsPage from './AppDetailsPage';
import { AppClientOrchestratorInstance } from '../../../apps/orchestrator';
import { useAppInfo } from '../hooks/useAppInfo';

jest.mock('../hooks/useAppInfo', () => ({
useAppInfo: jest.fn(),
}));

jest.mock('@rocket.chat/ui-contexts', () => {
const originalModule = jest.requireActual('@rocket.chat/ui-contexts');
return {
...originalModule,
useRouter: () => ({ navigate: jest.fn() }),
useToastMessageDispatch: () => jest.fn(),
usePermission: () => true,
useRouteParameter: () => 'settings',
};
});

jest.mock('../../../components/Page', () => {
const originalModule = jest.requireActual('../../../components/Page');
return {
...originalModule,
PageHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PageFooter: ({ children, isDirty }: { children: React.ReactNode; isDirty: boolean }) => isDirty && <div>{children}</div>,
};
});

jest.mock('./AppDetailsPageHeader', () => ({
__esModule: true,
default: () => <div>AppDetailsPageHeader</div>,
}));

jest.mock('../../../apps/orchestrator', () => ({
AppClientOrchestratorInstance: {
setAppSettings: jest.fn(),
},
}));

const wrapper = mockAppRoot().withTranslations('en', 'core', { Save_changes: 'Save changes' });
describe('AppDetailsPage', () => {
beforeEach(() => {
(useAppInfo as jest.Mock).mockReturnValue({
id: 'app123',
name: 'Test App',
installed: true,
settings: {
setting1: { id: 'setting1', value: 'old-value', packageValue: 'default-value', type: 'string' },
},
privacyPolicySummary: '',
permissions: [],
tosLink: '',
privacyLink: '',
});
(AppClientOrchestratorInstance.setAppSettings as jest.Mock).mockReset();
});

it('should not display the Save button initially', async () => {
render(<AppDetailsPage id='app123' />, {
wrapper: wrapper.build(),
legacyRoot: true,
});

await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument();
});
});

it('should display the Save button when a setting is changed', async () => {
render(<AppDetailsPage id='app123' />, {
wrapper: wrapper.build(),
legacyRoot: true,
});

const settingInput = screen.getByLabelText('setting1');
await userEvent.clear(settingInput);
await userEvent.type(settingInput, 'new-value');

await waitFor(() => {
expect(screen.getByRole('button', { name: 'Save changes' })).toBeVisible();
});
});

it('should disable the Save button during submission', async () => {
(AppClientOrchestratorInstance.setAppSettings as jest.Mock).mockResolvedValueOnce(new Promise((resolve) => setTimeout(resolve, 500)));

render(<AppDetailsPage id='app123' />, {
wrapper: wrapper.build(),
legacyRoot: true,
});

const settingInput = screen.getByLabelText('setting1');
await userEvent.clear(settingInput);
await userEvent.type(settingInput, 'new-value');

const saveButton = screen.getByRole('button', { name: 'Save changes' });

await userEvent.click(saveButton);

await waitFor(() => {
expect(saveButton).toBeDisabled();
});
});

it('should hide the Save button after successful save', async () => {
(AppClientOrchestratorInstance.setAppSettings as jest.Mock).mockResolvedValueOnce(new Promise((resolve) => setTimeout(resolve, 500)));

render(<AppDetailsPage id='app123' />, {
wrapper: wrapper.build(),
legacyRoot: true,
});

const settingInput = screen.getByLabelText('setting1');
await userEvent.clear(settingInput);
await userEvent.type(settingInput, 'new-value');

const saveButton = screen.getByRole('button', { name: 'Save changes' });
await userEvent.click(saveButton);

await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument();
});
});

it('should call setAppSettings with updated setting value', async () => {
(AppClientOrchestratorInstance.setAppSettings as jest.Mock).mockResolvedValueOnce(new Promise((resolve) => setTimeout(resolve, 500)));

render(<AppDetailsPage id='app123' />, {
wrapper: wrapper.build(),
legacyRoot: true,
});

const settingInput = screen.getByLabelText('setting1');
await userEvent.clear(settingInput);
await userEvent.type(settingInput, 'new-value');

const saveButton = screen.getByRole('button', { name: 'Save changes' });
await userEvent.click(saveButton);

await waitFor(() => {
expect(AppClientOrchestratorInstance.setAppSettings as jest.Mock).toHaveBeenCalledWith('app123', [
{ id: 'setting1', packageValue: 'default-value', type: 'string', value: 'new-value' },
]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,20 @@ const AppDetailsPage = ({ id }: AppDetailsPageProps): ReactElement => {
const { installed, settings, privacyPolicySummary, permissions, tosLink, privacyLink, name } = appData || {};
const isSecurityVisible = Boolean(privacyPolicySummary || permissions || tosLink || privacyLink);

const reducedSettings = useMemo((): AppDetailsPageFormData => {
return Object.values(settings || {}).reduce(
(ret: AppDetailsPageFormData, { id, value, packageValue }) => ({ ...ret, [id]: value ?? packageValue }),
{},
);
}, [settings]);

const methods = useForm<AppDetailsPageFormData>({ values: reducedSettings });
const {
handleSubmit,
reset,
formState: { isDirty, isSubmitting },
} = methods;

const saveAppSettings = useCallback(
async (data: AppDetailsPageFormData) => {
try {
Expand All @@ -61,29 +75,15 @@ const AppDetailsPage = ({ id }: AppDetailsPageProps): ReactElement => {
value: data[setting.id],
})),
);

reset(data);
dispatchToastMessage({ type: 'success', message: `${name} settings saved succesfully` });
} catch (e: any) {
handleAPIError(e);
}
},
[dispatchToastMessage, id, name, settings],
[dispatchToastMessage, id, name, settings, reset],
);

const reducedSettings = useMemo((): AppDetailsPageFormData => {
return Object.values(settings || {}).reduce(
(ret: AppDetailsPageFormData, { id, value, packageValue }) => ({ ...ret, [id]: value ?? packageValue }),
{},
);
}, [settings]);

const methods = useForm<AppDetailsPageFormData>({ values: reducedSettings });
const {
handleSubmit,
reset,
formState: { isDirty, isSubmitting, isSubmitted },
} = methods;

return (
<Page flexDirection='column' h='full'>
<PageHeader title={t('App_Info')} onClickBack={handleReturn} />
Expand Down Expand Up @@ -125,7 +125,7 @@ const AppDetailsPage = ({ id }: AppDetailsPageProps): ReactElement => {
<ButtonGroup>
<Button onClick={() => reset()}>{t('Cancel')}</Button>
{installed && isAdminUser && (
<Button primary loading={isSubmitting || isSubmitted} onClick={handleSubmit(saveAppSettings)}>
<Button primary loading={isSubmitting} onClick={handleSubmit(saveAppSettings)}>
{t('Save_changes')}
</Button>
)}
Expand Down
Loading