diff --git a/.changeset/fuzzy-eyes-clean.md b/.changeset/fuzzy-eyes-clean.md new file mode 100644 index 0000000000000..e2d9c0cc2b7d6 --- /dev/null +++ b/.changeset/fuzzy-eyes-clean.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes the save button loading state in app settings, ensuring it resets properly after saving. diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/AppDetailsPage.spec.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/AppDetailsPage.spec.tsx new file mode 100644 index 0000000000000..d6bae87ccc599 --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/AppDetailsPage.spec.tsx @@ -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 }) =>
{children}
, + PageFooter: ({ children, isDirty }: { children: React.ReactNode; isDirty: boolean }) => isDirty &&
{children}
, + }; +}); + +jest.mock('./AppDetailsPageHeader', () => ({ + __esModule: true, + default: () =>
AppDetailsPageHeader
, +})); + +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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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' }, + ]); + }); + }); +}); diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/AppDetailsPage.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/AppDetailsPage.tsx index 45aeef3212a65..f31613ebcd396 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/AppDetailsPage.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/AppDetailsPage.tsx @@ -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({ values: reducedSettings }); + const { + handleSubmit, + reset, + formState: { isDirty, isSubmitting }, + } = methods; + const saveAppSettings = useCallback( async (data: AppDetailsPageFormData) => { try { @@ -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({ values: reducedSettings }); - const { - handleSubmit, - reset, - formState: { isDirty, isSubmitting, isSubmitted }, - } = methods; - return ( @@ -125,7 +125,7 @@ const AppDetailsPage = ({ id }: AppDetailsPageProps): ReactElement => { {installed && isAdminUser && ( - )}