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 && (
-