diff --git a/CHANGELOG.md b/CHANGELOG.md index 2896c002..fc439380 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,43 +4,43 @@ ### Features -* **mentor:** sidebar, agent settings layout changes ([3bc2762](https://github.com/iblai/os/commit/3bc276244face45d4dd5db2668c4a4bbba3a20c6)) - -### Bug Fixes - -* **e2e:** fix for the e2e tests ([3733d96](https://github.com/iblai/os/commit/3733d967e2c3093a4e8df10041cb42ba8dc6fa05)) -* **e2e:** fix the explore page test failiures ([ecf8a27](https://github.com/iblai/os/commit/ecf8a27a7711b08326aac17afe58daebbd39a55c)) -* **e2e:** fixing coverage.json for missing components ([c63ca0e](https://github.com/iblai/os/commit/c63ca0eb7794fbe57b9e28d518e726f045f4757e)) -* **e2e:** fixing for the e2e tests ([d44cc81](https://github.com/iblai/os/commit/d44cc8160fa2e7e86b1884a9e797d4c5d7555dbf)) -* **e2e:** fixing for the e2e tests ([08965fd](https://github.com/iblai/os/commit/08965fd81c7dcd2782e438df218c972f03fa5309)) -* **e2e:** more fixes for the tests ([8a40c32](https://github.com/iblai/os/commit/8a40c321f77c2ba446c92fa5a96459ca1d7f0e0a)) -* **e2e:** more fixes for the tests ([3104d2e](https://github.com/iblai/os/commit/3104d2e352201a8281421b708dc51c703bcce161)) -* **mentor:** adding app-sidebar test ([0e5f654](https://github.com/iblai/os/commit/0e5f65411086e8bc433dc0d04983e6dbb76a72d8)) -* **mentor:** adding app-sidebar test ([deaa14c](https://github.com/iblai/os/commit/deaa14ce048be7433f1e8b302dc3a586e011ffb9)) -* **mentor:** adding data state ([9a1a7fb](https://github.com/iblai/os/commit/9a1a7fb5da3d61c8980e1e884db798ad5a98a5dd)) -* **mentor:** adding unit tests coverage ([740a2d3](https://github.com/iblai/os/commit/740a2d34cb95ea23fc1c7b767596c85f8c96f7ac)) -* **mentor:** coverage fixes ([606508b](https://github.com/iblai/os/commit/606508b445a7eb04c0601d726cae1a87f69327ff)) -* **mentor:** fix for the excel ([d49413a](https://github.com/iblai/os/commit/d49413a0510e86b0d8b2e59549556103869bc765)) -* **mentor:** fix for the packages ([420ae98](https://github.com/iblai/os/commit/420ae981689dd3cbc3ea643ed7a6b2ce682b3ee5)) -* **mentor:** fix for the settings tab unit tests ([c4a6d50](https://github.com/iblai/os/commit/c4a6d50dfdcbfd827b11ff92ec35802e5085340f)) -* **mentor:** fixes for the e2e tests and tabs for agent settings ([276df4e](https://github.com/iblai/os/commit/276df4e304d92dce95dc33a39fa60026edb6347f)) -* **mentor:** fixing for the tabs inconsistant height ([d1f3535](https://github.com/iblai/os/commit/d1f35359596c634e835e010eafa0df5b9305b8db)) -* **mentor:** type check issue fix ([87d9277](https://github.com/iblai/os/commit/87d9277e018b2354604b99b1d3c2dd5e39a24c15)) -* **mentor:** unit test coverage ([d8a0555](https://github.com/iblai/os/commit/d8a05551ad4f4ce29be8943c875eb23f7579e20a)) -* **mentor:** unit test fixes for providers ([44336a5](https://github.com/iblai/os/commit/44336a58ae0a0001f5c3508384edda09fee9c0cc)) +- **mentor:** sidebar, agent settings layout changes ([3bc2762](https://github.com/iblai/os/commit/3bc276244face45d4dd5db2668c4a4bbba3a20c6)) + +### Bug Fixes + +- **e2e:** fix for the e2e tests ([3733d96](https://github.com/iblai/os/commit/3733d967e2c3093a4e8df10041cb42ba8dc6fa05)) +- **e2e:** fix the explore page test failiures ([ecf8a27](https://github.com/iblai/os/commit/ecf8a27a7711b08326aac17afe58daebbd39a55c)) +- **e2e:** fixing coverage.json for missing components ([c63ca0e](https://github.com/iblai/os/commit/c63ca0eb7794fbe57b9e28d518e726f045f4757e)) +- **e2e:** fixing for the e2e tests ([d44cc81](https://github.com/iblai/os/commit/d44cc8160fa2e7e86b1884a9e797d4c5d7555dbf)) +- **e2e:** fixing for the e2e tests ([08965fd](https://github.com/iblai/os/commit/08965fd81c7dcd2782e438df218c972f03fa5309)) +- **e2e:** more fixes for the tests ([8a40c32](https://github.com/iblai/os/commit/8a40c321f77c2ba446c92fa5a96459ca1d7f0e0a)) +- **e2e:** more fixes for the tests ([3104d2e](https://github.com/iblai/os/commit/3104d2e352201a8281421b708dc51c703bcce161)) +- **mentor:** adding app-sidebar test ([0e5f654](https://github.com/iblai/os/commit/0e5f65411086e8bc433dc0d04983e6dbb76a72d8)) +- **mentor:** adding app-sidebar test ([deaa14c](https://github.com/iblai/os/commit/deaa14ce048be7433f1e8b302dc3a586e011ffb9)) +- **mentor:** adding data state ([9a1a7fb](https://github.com/iblai/os/commit/9a1a7fb5da3d61c8980e1e884db798ad5a98a5dd)) +- **mentor:** adding unit tests coverage ([740a2d3](https://github.com/iblai/os/commit/740a2d34cb95ea23fc1c7b767596c85f8c96f7ac)) +- **mentor:** coverage fixes ([606508b](https://github.com/iblai/os/commit/606508b445a7eb04c0601d726cae1a87f69327ff)) +- **mentor:** fix for the excel ([d49413a](https://github.com/iblai/os/commit/d49413a0510e86b0d8b2e59549556103869bc765)) +- **mentor:** fix for the packages ([420ae98](https://github.com/iblai/os/commit/420ae981689dd3cbc3ea643ed7a6b2ce682b3ee5)) +- **mentor:** fix for the settings tab unit tests ([c4a6d50](https://github.com/iblai/os/commit/c4a6d50dfdcbfd827b11ff92ec35802e5085340f)) +- **mentor:** fixes for the e2e tests and tabs for agent settings ([276df4e](https://github.com/iblai/os/commit/276df4e304d92dce95dc33a39fa60026edb6347f)) +- **mentor:** fixing for the tabs inconsistant height ([d1f3535](https://github.com/iblai/os/commit/d1f35359596c634e835e010eafa0df5b9305b8db)) +- **mentor:** type check issue fix ([87d9277](https://github.com/iblai/os/commit/87d9277e018b2354604b99b1d3c2dd5e39a24c15)) +- **mentor:** unit test coverage ([d8a0555](https://github.com/iblai/os/commit/d8a05551ad4f4ce29be8943c875eb23f7579e20a)) +- **mentor:** unit test fixes for providers ([44336a5](https://github.com/iblai/os/commit/44336a58ae0a0001f5c3508384edda09fee9c0cc)) ## [0.71.1](https://github.com/iblai/os/compare/v0.71.0...v0.71.1) (2026-06-03) ### Bug Fixes -* **deps:** bump axios >=1.16.0 and minimatch's brace-expansion to 2.0.3 ([5a9b63f](https://github.com/iblai/os/commit/5a9b63f2f18774e1b47fa57a0f762961bc71976a)) +- **deps:** bump axios >=1.16.0 and minimatch's brace-expansion to 2.0.3 ([5a9b63f](https://github.com/iblai/os/commit/5a9b63f2f18774e1b47fa57a0f762961bc71976a)) ### Chores -* change agentAI -> OS ([ec6cc3c](https://github.com/iblai/os/commit/ec6cc3c0a3a07ec2ce5fad28397c7668c9e79470)) -* change IBL.ai to ibl.ai ([df48cc5](https://github.com/iblai/os/commit/df48cc5f518785d45abb2b5374b91a30dfda4488)) -* update app name in AGENTS.md ([0bc8a6e](https://github.com/iblai/os/commit/0bc8a6e4fd54c0921d20f26ac5f5830256f24c88)) -* update documentation ([d802e80](https://github.com/iblai/os/commit/d802e802227135715d19097a5a08d80771d83614)) +- change agentAI -> OS ([ec6cc3c](https://github.com/iblai/os/commit/ec6cc3c0a3a07ec2ce5fad28397c7668c9e79470)) +- change IBL.ai to ibl.ai ([df48cc5](https://github.com/iblai/os/commit/df48cc5f518785d45abb2b5374b91a30dfda4488)) +- update app name in AGENTS.md ([0bc8a6e](https://github.com/iblai/os/commit/0bc8a6e4fd54c0921d20f26ac5f5830256f24c88)) +- update documentation ([d802e80](https://github.com/iblai/os/commit/d802e802227135715d19097a5a08d80771d83614)) ## [0.71.0](https://github.com/iblai/os/compare/v0.70.1...v0.71.0) (2026-06-02) diff --git a/components/modals/edit-mentor-modal/__tests__/embed-icon-persistence-utils.test.ts b/components/modals/edit-mentor-modal/__tests__/embed-icon-persistence-utils.test.ts new file mode 100644 index 00000000..e2e1a272 --- /dev/null +++ b/components/modals/edit-mentor-modal/__tests__/embed-icon-persistence-utils.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect } from 'vitest'; +import { + isDataUrl, + dataUrlToFile, + buildEmbedIconSelectionData, + buildFloatingBubbleConfigFromSettings, + hasCustomIconData, +} from '../utils'; +import type { CustomFloatingBubbleConfig } from '../hooks/useEmbedTab'; + +// A minimal 1x1 transparent PNG data URL. +const PNG_DATA_URL = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + +const baseConfig: CustomFloatingBubbleConfig = { + image: 'https://cdn.example.com/icon.png', + position: 'bottom-right', + size: 'small', + backgroundColor: 'transparent', + textColor: '#ffffff', + subtitleTextColor: '#e5e7eb', + accentColor: '#1d4ed8', + borderRadius: 16, + shadow: false, + title: 'Hello', + subtitle: 'World', + height: 48, + fontSize: 14, + subtitleFontSize: 12, + padding: 12, + imageSize: 32, + strokeColor: '#000', + strokeWidth: 0, +}; + +describe('isDataUrl', () => { + it('returns true for a data URL', () => { + expect(isDataUrl(PNG_DATA_URL)).toBe(true); + expect(isDataUrl('data:text/plain;base64,aGk=')).toBe(true); + }); + + it('returns false for a resolved URL, relative path, null or undefined', () => { + expect(isDataUrl('https://cdn.example.com/icon.png')).toBe(false); + expect(isDataUrl('/message-circle.svg')).toBe(false); + expect(isDataUrl(null)).toBe(false); + expect(isDataUrl(undefined)).toBe(false); + }); +}); + +describe('dataUrlToFile', () => { + it('converts a PNG data URL into a File with the right mime + extension', () => { + const file = dataUrlToFile(PNG_DATA_URL); + expect(file).toBeInstanceOf(File); + expect(file?.type).toBe('image/png'); + expect(file?.name).toBe('embed-custom-image.png'); + expect((file as File).size).toBeGreaterThan(0); + }); + + it('honors a provided filename and keeps an explicit extension', () => { + const file = dataUrlToFile(PNG_DATA_URL, 'launcher.webp'); + expect(file?.name).toBe('launcher.webp'); + }); + + it('derives an extension from a "+suffix" mime type', () => { + const file = dataUrlToFile('data:image/svg+xml;base64,PHN2Zy8+'); + expect(file?.type).toBe('image/svg+xml'); + expect(file?.name).toBe('embed-custom-image.svg'); + }); + + it('falls back to octet-stream when the mime is absent', () => { + const file = dataUrlToFile('data:;base64,aGk='); + expect(file?.type).toBe('application/octet-stream'); + expect(file?.name).toBe('embed-custom-image.octet-stream'); + }); + + it('returns null for a non-data URL', () => { + expect(dataUrlToFile('https://cdn.example.com/icon.png')).toBeNull(); + }); + + it('returns null when the data URL has no payload', () => { + expect(dataUrlToFile('data:image/png;base64,')).toBeNull(); + }); +}); + +describe('buildEmbedIconSelectionData', () => { + it('serializes the config minus the image', () => { + const data = buildEmbedIconSelectionData(baseConfig); + expect(data).not.toHaveProperty('image'); + expect(data.title).toBe('Hello'); + expect(data.position).toBe('bottom-right'); + }); + + it('does not embed an icon_selection key (mode is derived from existence)', () => { + const data = buildEmbedIconSelectionData(baseConfig); + expect(data).not.toHaveProperty('icon_selection'); + }); +}); + +describe('hasCustomIconData', () => { + it('returns true only for a non-empty object', () => { + expect(hasCustomIconData({ title: 'x' })).toBe(true); + }); + + it('returns false for an empty object (the backend cleared state)', () => { + expect(hasCustomIconData({})).toBe(false); + }); + + it('returns false for null, undefined, strings and other non-objects', () => { + expect(hasCustomIconData(null)).toBe(false); + expect(hasCustomIconData(undefined)).toBe(false); + expect(hasCustomIconData('')).toBe(false); + expect(hasCustomIconData('null')).toBe(false); + expect(hasCustomIconData(0)).toBe(false); + }); +}); + +describe('buildFloatingBubbleConfigFromSettings', () => { + it('derives iconSelection "custom" when a non-empty JSON map exists', () => { + const result = buildFloatingBubbleConfigFromSettings( + baseConfig, + { + title: 'Persisted', + position: 'top-left', + }, + 'https://cdn.example.com/saved.png', + ); + expect(result).not.toBeNull(); + expect(result?.iconSelection).toBe('custom'); + expect(result?.config.title).toBe('Persisted'); + expect(result?.config.position).toBe('top-left'); + expect(result?.config.image).toBe('https://cdn.example.com/saved.png'); + // Untouched fields fall back to defaults. + expect(result?.config.borderRadius).toBe(16); + }); + + it('drops a legacy icon_selection key but still derives from existence', () => { + const result = buildFloatingBubbleConfigFromSettings( + baseConfig, + // Older payloads may still carry this key; it is no longer authoritative. + { icon_selection: 'default', title: 'Legacy' }, + null, + ); + expect(result?.iconSelection).toBe('custom'); + expect(result?.config).not.toHaveProperty('icon_selection'); + }); + + it('falls back to the default image when no custom image is provided', () => { + const result = buildFloatingBubbleConfigFromSettings( + baseConfig, + { title: 'NoImage' }, + null, + ); + expect(result?.config.image).toBe(baseConfig.image); + }); + + it('ignores a persisted image key inside the JSON map', () => { + const result = buildFloatingBubbleConfigFromSettings( + baseConfig, + { image: 'data:should-be-ignored', title: 'X' }, + 'https://cdn.example.com/real.png', + ); + expect(result?.config.image).toBe('https://cdn.example.com/real.png'); + }); + + it('hydrates from only a custom image and derives "default" (no JSON map)', () => { + const result = buildFloatingBubbleConfigFromSettings( + baseConfig, + null, + 'https://cdn.example.com/only-image.png', + ); + expect(result).not.toBeNull(); + expect(result?.config.image).toBe('https://cdn.example.com/only-image.png'); + // No JSON map => default mode even though a stale image URL lingers. + expect(result?.iconSelection).toBe('default'); + }); + + it('derives "default" when the JSON map is an empty string (cleared)', () => { + const result = buildFloatingBubbleConfigFromSettings( + baseConfig, + '', + 'https://cdn.example.com/stale.png', + ); + expect(result).not.toBeNull(); + expect(result?.iconSelection).toBe('default'); + }); + + it('derives "default" when the JSON map is an empty object {} (backend cleared state)', () => { + // The backend clears `embed_icon_selection_data` by storing `{}` (verified + // live). An empty object still hydrates the config from defaults, but the + // mode must be 'default'. A lingering image must NOT force 'custom'. + const result = buildFloatingBubbleConfigFromSettings( + baseConfig, + {}, + 'https://cdn.example.com/stale.png', + ); + expect(result).not.toBeNull(); + expect(result?.iconSelection).toBe('default'); + // Config still hydrates (image from the stale URL, rest from defaults). + expect(result?.config.image).toBe('https://cdn.example.com/stale.png'); + expect(result?.config.borderRadius).toBe(16); + }); + + it('returns null when there is neither icon data nor a custom image', () => { + expect( + buildFloatingBubbleConfigFromSettings(baseConfig, null, null), + ).toBeNull(); + expect( + buildFloatingBubbleConfigFromSettings(baseConfig, undefined, undefined), + ).toBeNull(); + }); + + it('treats a non-object icon-selection payload as absent', () => { + expect( + buildFloatingBubbleConfigFromSettings(baseConfig, 'not-an-object', null), + ).toBeNull(); + }); +}); diff --git a/components/modals/edit-mentor-modal/__tests__/get-embed-code.test.ts b/components/modals/edit-mentor-modal/__tests__/get-embed-code.test.ts new file mode 100644 index 00000000..bece2e58 --- /dev/null +++ b/components/modals/edit-mentor-modal/__tests__/get-embed-code.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { getEmbedCode } from '../utils'; +import type { EmbedFormValues } from '../hooks/useEmbedTab'; + +const DM_BASE = 'https://api.iblai.org/dm'; +const AXD_BASE = 'https://api.iblai.org/axd'; + +// The default launcher thumbnail is a DM (manager) endpoint. On the unified API +// gateway axdUrl() resolves to an invalid `/axd` prefix (HTTP 404), so the embed +// code builder must use dmUrl() for the thumbnail. These mocks let us assert the +// exact base used without hitting the network. +vi.mock('@/lib/config', () => ({ + config: { + dmUrl: () => DM_BASE, + axdUrl: () => AXD_BASE, + mentorIframeUrl: () => 'https://mentor.example.com', + authUrl: () => 'https://auth.example.com', + }, +})); + +const settings: EmbedFormValues = { + custom_css: '', + description: '', + website_url: '', + mode: 'default', + allow_anonymous: false, + mentor_visibility: null, + is_context_aware: false, + safety_disclaimer: false, + sso: false, + auto_open: false, + sso_provider: '', + metadata: { + primary_color: '#2467eb', + secondary_color: '#000', + safety_disclaimer: false, + }, + slug: 'my-mentor', + icon_selection: 'default', + embed_show_attachment: true, + embed_show_voice_call: true, + embed_show_voice_record: true, + show_catalogue: true, + starter_prompts: 'guided_prompt', +}; + +describe('getEmbedCode default bubble image (thumbnail uses dm, not axd)', () => { + beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: true }) as Response), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('fetches the thumbnail from the dm base, never /axd/', async () => { + const code = await getEmbedCode('acme', settings, 'redirect-token'); + + const expectedThumbnailUrl = `${DM_BASE}/api/core/orgs/acme/thumbnail/`; + + // fetch was called with the dm thumbnail URL... + expect(fetch).toHaveBeenCalledWith(expectedThumbnailUrl); + + // ...and never with an /axd/ thumbnail URL. + const fetchMock = fetch as unknown as ReturnType; + const fetchedUrls = fetchMock.mock.calls.map((c) => String(c[0])); + expect(fetchedUrls.some((u) => u.includes('/axd/'))).toBe(false); + + // The generated embed snippet wires the bubble image to the dm URL. + expect(code).toContain(expectedThumbnailUrl); + expect(code).not.toContain(`${AXD_BASE}/api/core/orgs/`); + }); + + it('falls back to the main-tenant thumbnail via the dm base on a non-ok primary response', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ ok: false } as Response) + .mockResolvedValueOnce({ ok: true } as Response); + vi.stubGlobal('fetch', fetchMock); + + const code = await getEmbedCode('acme', settings, 'redirect-token'); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + `${DM_BASE}/api/core/orgs/acme/thumbnail/`, + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + `${DM_BASE}/api/core/orgs/main/thumbnail/`, + ); + expect(code).toContain(`${DM_BASE}/api/core/orgs/main/thumbnail/`); + }); +}); diff --git a/components/modals/edit-mentor-modal/hooks/__tests__/useEmbedTab.test.ts b/components/modals/edit-mentor-modal/hooks/__tests__/useEmbedTab.test.ts index 13209d6b..aea16c57 100644 --- a/components/modals/edit-mentor-modal/hooks/__tests__/useEmbedTab.test.ts +++ b/components/modals/edit-mentor-modal/hooks/__tests__/useEmbedTab.test.ts @@ -55,13 +55,22 @@ vi.mock('@/features/utils', () => ({ getUserName: vi.fn(), })); -vi.mock('../../utils', () => ({ - getEmbedCode: vi.fn(), -})); +vi.mock('../../utils', async () => { + // Keep the real pure helpers (isDataUrl, dataUrlToFile, build* serializers) + // so the save/load wiring exercises actual round-trip behavior; only stub the + // async `getEmbedCode` snippet builder. + const actual = + await vi.importActual('../../utils'); + return { + ...actual, + getEmbedCode: vi.fn(), + }; +}); vi.mock('sonner', () => ({ toast: { error: vi.fn(), + success: vi.fn(), }, })); @@ -81,6 +90,7 @@ describe('useEmbedTab', () => { let mockGetUserName: ReturnType; let mockGetEmbedCode: ReturnType; let mockToastError: ReturnType; + let mockToastSuccess: ReturnType; beforeEach(() => { vi.clearAllMocks(); @@ -89,6 +99,7 @@ describe('useEmbedTab', () => { mockGetUserName = vi.mocked(utils.getUserName); mockGetEmbedCode = vi.mocked(embedUtils.getEmbedCode); mockToastError = vi.mocked(toast.error); + mockToastSuccess = vi.mocked(toast.success); // Create fresh mock functions for mutations mockCreateRedirectTokenFn = vi.fn(); @@ -106,6 +117,23 @@ describe('useEmbedTab', () => { // Setup default mock return values mockGetUserName.mockReturnValue('test-user'); mockGetEmbedCode.mockResolvedValue(''); + + // Reset the public-settings query to its default shape so a per-test + // `mockReturnValue` override (used by the hydration tests) doesn't leak into + // subsequent tests — `vi.clearAllMocks` clears call history but not the + // implementation set via `mockReturnValue`. + vi.mocked(dataLayer.useGetMentorPublicSettingsQuery).mockReturnValue({ + data: { + allow_anonymous: false, + mentor_visibility: 'public', + custom_css: '', + embed_show_attachment: true, + embed_show_voice_call: true, + embed_show_voice_record: true, + show_catalogue: true, + mentor_unique_id: 'mentor-123', + }, + } as any); }); describe('initialization', () => { @@ -154,6 +182,88 @@ describe('useEmbedTab', () => { expect(result.current.form.getFieldValue('show_catalogue')).toBe(true); }); + it('initializes icon_selection to "custom" when embed_icon_selection_data exists', () => { + // Regression for #789: the mode must hydrate from the form's reactive + // defaultValues (existence of the persisted JSON map), NOT from an + // imperative setFieldValue that the reactive re-init would clobber. + vi.mocked(dataLayer.useGetMentorPublicSettingsQuery).mockReturnValue({ + data: { + mentor_unique_id: 'mentor-123', + embed_icon_selection_data: { title: 'Persisted' }, + }, + } as any); + + const { result } = renderHook(() => useEmbedTab()); + + expect(result.current.form.getFieldValue('icon_selection')).toBe( + 'custom', + ); + }); + + it('initializes icon_selection to "default" when embed_icon_selection_data is an empty object (cleared state)', () => { + // The backend clears the field by storing `{}` (verified live). An empty + // object has no own keys, so the mode must derive to 'default'. + vi.mocked(dataLayer.useGetMentorPublicSettingsQuery).mockReturnValue({ + data: { + mentor_unique_id: 'mentor-123', + embed_icon_selection_data: {}, + }, + } as any); + + const { result } = renderHook(() => useEmbedTab()); + + expect(result.current.form.getFieldValue('icon_selection')).toBe( + 'default', + ); + }); + + it('initializes icon_selection to "default" for an empty object even when a stale embed_custom_image lingers', () => { + // A leftover image URL after switching to default must NOT force 'custom'; + // the mode depends ONLY on the (empty) JSON map. + vi.mocked(dataLayer.useGetMentorPublicSettingsQuery).mockReturnValue({ + data: { + mentor_unique_id: 'mentor-123', + embed_icon_selection_data: {}, + embed_custom_image: 'https://cdn.example.com/stale-icon.png', + }, + } as any); + + const { result } = renderHook(() => useEmbedTab()); + + expect(result.current.form.getFieldValue('icon_selection')).toBe( + 'default', + ); + }); + + it('initializes icon_selection to "default" when embed_icon_selection_data is an empty string', () => { + vi.mocked(dataLayer.useGetMentorPublicSettingsQuery).mockReturnValue({ + data: { + mentor_unique_id: 'mentor-123', + embed_icon_selection_data: '', + }, + } as any); + + const { result } = renderHook(() => useEmbedTab()); + + expect(result.current.form.getFieldValue('icon_selection')).toBe( + 'default', + ); + }); + + it('initializes icon_selection to "default" when embed_icon_selection_data is absent', () => { + vi.mocked(dataLayer.useGetMentorPublicSettingsQuery).mockReturnValue({ + data: { + mentor_unique_id: 'mentor-123', + }, + } as any); + + const { result } = renderHook(() => useEmbedTab()); + + expect(result.current.form.getFieldValue('icon_selection')).toBe( + 'default', + ); + }); + it('should initialize custom floating bubble config with correct defaults', () => { const { result } = renderHook(() => useEmbedTab()); @@ -424,6 +534,414 @@ describe('useEmbedTab', () => { }); }); + describe('handleSaveSettings', () => { + it('persists settings via updateMentorSettings and shows a success toast without creating a token or embed code', async () => { + mockUpdateMentorSettingsFn.mockResolvedValueOnce({ + data: { success: true }, + }); + + const { result } = renderHook(() => useEmbedTab()); + + // No URL / not anonymous: save must NOT validate the website URL. + await act(async () => { + result.current.form.setFieldValue('allow_anonymous', false); + result.current.form.setFieldValue('website_url', ''); + result.current.form.setFieldValue('show_catalogue', true); + }); + + await act(async () => { + await result.current.handleSaveSettings(); + }); + + expect(mockUpdateMentorSettingsFn).toHaveBeenCalledWith( + expect.objectContaining({ + mentor: 'test-mentor', + org: 'test-tenant', + userId: 'test-user', + formData: expect.objectContaining({ show_catalogue: true }), + }), + ); + expect(mockToastSuccess).toHaveBeenCalledWith('Settings saved'); + // No URL validation error was set despite the empty website_url. + expect(result.current.createTokenError).toBe(''); + // Save must not create a redirect token, generate embed code, or open the + // embed dialog. + expect(mockCreateRedirectTokenFn).not.toHaveBeenCalled(); + expect(mockGetEmbedCode).not.toHaveBeenCalled(); + expect(result.current.embedCode).toBe(''); + }); + + it('does not show the success toast and surfaces an error toast on failure', async () => { + mockUpdateMentorSettingsFn.mockResolvedValueOnce({ + error: { error: { error: 'Save failed' } }, + }); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const { result } = renderHook(() => useEmbedTab()); + + await act(async () => { + await result.current.handleSaveSettings(); + }); + + expect(mockToastError).toHaveBeenCalledWith('Save failed'); + expect(mockToastSuccess).not.toHaveBeenCalled(); + expect(mockCreateRedirectTokenFn).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('toggles isSavingSettings around the save call', async () => { + let resolveSave: (v: unknown) => void = () => {}; + mockUpdateMentorSettingsFn.mockReturnValueOnce( + new Promise((resolve) => { + resolveSave = resolve; + }), + ); + + const { result } = renderHook(() => useEmbedTab()); + + expect(result.current.isSavingSettings).toBe(false); + + let savePromise: Promise; + act(() => { + savePromise = result.current.handleSaveSettings(); + }); + + await waitFor(() => { + expect(result.current.isSavingSettings).toBe(true); + }); + + await act(async () => { + resolveSave({ data: { success: true } }); + await savePromise; + }); + + expect(result.current.isSavingSettings).toBe(false); + }); + }); + + describe('custom launcher icon persistence', () => { + // 1x1 transparent PNG data URL used as a freshly-uploaded image preview. + const dataUrl = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + + it('in custom mode sends embed_icon_selection_data (object) without an icon_selection key', async () => { + mockUpdateMentorSettingsFn.mockResolvedValueOnce({ + data: { success: true }, + }); + + const { result } = renderHook(() => useEmbedTab()); + + await act(async () => { + result.current.form.setFieldValue('allow_anonymous', true); + result.current.form.setFieldValue('icon_selection', 'custom'); + result.current.updateMultipleConfig({ + title: 'Ask me', + subtitle: 'How can I help?', + backgroundColor: '#ff0000', + }); + }); + + await act(async () => { + await result.current.syncEmbedSettings(); + }); + + const payload = mockUpdateMentorSettingsFn.mock.calls[0][0]; + expect(payload.formData.embed_icon_selection_data).toEqual( + expect.objectContaining({ + title: 'Ask me', + subtitle: 'How can I help?', + backgroundColor: '#ff0000', + position: 'bottom-right', + }), + ); + // Mode is derived from existence, so the JSON must NOT carry the key. + expect(payload.formData.embed_icon_selection_data).not.toHaveProperty( + 'icon_selection', + ); + // The raw image binary must NOT be embedded in the JSON map. + expect(payload.formData.embed_icon_selection_data).not.toHaveProperty( + 'image', + ); + }); + + it('in default mode clears embed_icon_selection_data with an empty JSON object and sends no image', async () => { + mockUpdateMentorSettingsFn.mockResolvedValueOnce({ + data: { success: true }, + }); + + const { result } = renderHook(() => useEmbedTab()); + + await act(async () => { + result.current.form.setFieldValue('allow_anonymous', true); + result.current.form.setFieldValue('icon_selection', 'default'); + // Even if a data-URL image lingers in config, default mode must not + // upload it. + result.current.updateMultipleConfig({ image: dataUrl }); + }); + + await act(async () => { + await result.current.syncEmbedSettings(); + }); + + const payload = mockUpdateMentorSettingsFn.mock.calls[0][0]; + // The empty JSON object `{}` is the verified clearing value: live testing + // showed `''` is rejected (HTTP 400) and JSON null is silently ignored by + // DRF's partial update, while `{}` actually sets the field to `{}`. We + // pass a plain object literal; the SDK's getFormData JSON-stringifies it + // to the string "{}". On reload, `{}` has no own keys so the mode derives + // back to 'default'. + expect(payload.formData.embed_icon_selection_data).toEqual({}); + expect(payload.formData).not.toHaveProperty('embed_custom_image'); + }); + + it('converts a newly-uploaded data URL image into a File for embed_custom_image', async () => { + mockUpdateMentorSettingsFn.mockResolvedValueOnce({ + data: { success: true }, + }); + + const { result } = renderHook(() => useEmbedTab()); + + await act(async () => { + result.current.form.setFieldValue('allow_anonymous', true); + result.current.form.setFieldValue('icon_selection', 'custom'); + result.current.updateMultipleConfig({ image: dataUrl }); + }); + + await act(async () => { + await result.current.syncEmbedSettings(); + }); + + const payload = mockUpdateMentorSettingsFn.mock.calls[0][0]; + expect(payload.formData.embed_custom_image).toBeInstanceOf(File); + expect(payload.formData.embed_custom_image.type).toBe('image/png'); + }); + + it('omits embed_custom_image when the image is an unchanged resolved URL', async () => { + mockUpdateMentorSettingsFn.mockResolvedValueOnce({ + data: { success: true }, + }); + + const { result } = renderHook(() => useEmbedTab()); + + await act(async () => { + result.current.form.setFieldValue('allow_anonymous', true); + result.current.form.setFieldValue('icon_selection', 'custom'); + // Default image is a resolved thumbnail URL, not a data URL. + }); + + await act(async () => { + await result.current.syncEmbedSettings(); + }); + + const payload = mockUpdateMentorSettingsFn.mock.calls[0][0]; + expect(payload.formData).not.toHaveProperty('embed_custom_image'); + }); + + it('omits embed_custom_image when the image was removed (null)', async () => { + mockUpdateMentorSettingsFn.mockResolvedValueOnce({ + data: { success: true }, + }); + + const { result } = renderHook(() => useEmbedTab()); + + await act(async () => { + result.current.form.setFieldValue('allow_anonymous', true); + result.current.form.setFieldValue('icon_selection', 'custom'); + result.current.updateMultipleConfig({ image: null }); + }); + + await act(async () => { + await result.current.syncEmbedSettings(); + }); + + const payload = mockUpdateMentorSettingsFn.mock.calls[0][0]; + expect(payload.formData).not.toHaveProperty('embed_custom_image'); + }); + + it('hydrates customFloatingBubbleConfig + icon_selection from persisted settings', () => { + vi.mocked(dataLayer.useGetMentorPublicSettingsQuery).mockReturnValue({ + data: { + allow_anonymous: false, + mentor_visibility: 'public', + custom_css: '', + embed_show_attachment: true, + embed_show_voice_call: true, + embed_show_voice_record: true, + show_catalogue: true, + mentor_unique_id: 'mentor-123', + embed_icon_selection_data: { + icon_selection: 'custom', + title: 'Persisted Title', + backgroundColor: '#abcdef', + position: 'top-left', + }, + embed_custom_image: 'https://cdn.example.com/saved-icon.png', + }, + } as any); + + const { result } = renderHook(() => useEmbedTab()); + + expect(result.current.form.getFieldValue('icon_selection')).toBe( + 'custom', + ); + expect(result.current.customFloatingBubbleConfig.title).toBe( + 'Persisted Title', + ); + expect(result.current.customFloatingBubbleConfig.backgroundColor).toBe( + '#abcdef', + ); + expect(result.current.customFloatingBubbleConfig.position).toBe( + 'top-left', + ); + expect(result.current.customFloatingBubbleConfig.image).toBe( + 'https://cdn.example.com/saved-icon.png', + ); + }); + + it('hydrates using mentorId as the settings key when mentor_unique_id is absent', () => { + vi.mocked(dataLayer.useGetMentorPublicSettingsQuery).mockReturnValue({ + data: { + // no mentor_unique_id -> settings key falls back to mentorId + embed_icon_selection_data: { + icon_selection: 'custom', + title: 'Keyed By MentorId', + }, + embed_custom_image: 'https://cdn.example.com/keyed.png', + }, + } as any); + + const { result } = renderHook(() => useEmbedTab()); + + expect(result.current.customFloatingBubbleConfig.title).toBe( + 'Keyed By MentorId', + ); + expect(result.current.form.getFieldValue('icon_selection')).toBe( + 'custom', + ); + }); + + it('derives icon_selection "custom" from a JSON map even without a stored key', () => { + vi.mocked(dataLayer.useGetMentorPublicSettingsQuery).mockReturnValue({ + data: { + mentor_unique_id: 'mentor-123', + // No icon_selection key — mode is derived purely from existence. + embed_icon_selection_data: { title: 'No Selection' }, + embed_custom_image: 'https://cdn.example.com/x.png', + }, + } as any); + + const { result } = renderHook(() => useEmbedTab()); + + expect(result.current.customFloatingBubbleConfig.title).toBe( + 'No Selection', + ); + // The JSON map exists, so the mode derives to 'custom'. + expect(result.current.form.getFieldValue('icon_selection')).toBe( + 'custom', + ); + }); + + it('derives icon_selection "default" when the JSON is cleared (empty string) even if a stale image lingers', () => { + vi.mocked(dataLayer.useGetMentorPublicSettingsQuery).mockReturnValue({ + data: { + mentor_unique_id: 'mentor-123', + embed_icon_selection_data: '', + embed_custom_image: 'https://cdn.example.com/stale.png', + }, + } as any); + + const { result } = renderHook(() => useEmbedTab()); + + // No JSON map (empty string) => default, regardless of the lingering image. + expect(result.current.form.getFieldValue('icon_selection')).toBe( + 'default', + ); + }); + + it('does not hydrate when there is no persisted icon data or image', () => { + // The default public-settings mock has neither field. + const { result } = renderHook(() => useEmbedTab()); + + expect(result.current.form.getFieldValue('icon_selection')).toBe( + 'default', + ); + expect(result.current.customFloatingBubbleConfig.title).toBe(''); + }); + + it('does not clobber in-progress edits on re-render once already hydrated', () => { + vi.mocked(dataLayer.useGetMentorPublicSettingsQuery).mockReturnValue({ + data: { + mentor_unique_id: 'mentor-123', + embed_icon_selection_data: { + icon_selection: 'custom', + title: 'Persisted Title', + }, + embed_custom_image: 'https://cdn.example.com/saved-icon.png', + }, + } as any); + + const { result, rerender } = renderHook(() => useEmbedTab()); + + // User edits after initial hydration. + act(() => { + result.current.updateMultipleConfig({ title: 'User Edit' }); + }); + expect(result.current.customFloatingBubbleConfig.title).toBe('User Edit'); + + // A re-render with the same settings identity must NOT re-hydrate. + rerender(); + expect(result.current.customFloatingBubbleConfig.title).toBe('User Edit'); + }); + + it('round-trips: a saved icon hydrates back on a fresh mount', async () => { + mockUpdateMentorSettingsFn.mockResolvedValueOnce({ + data: { success: true }, + }); + + // First mount: user uploads + saves. + const first = renderHook(() => useEmbedTab()); + await act(async () => { + first.result.current.form.setFieldValue('allow_anonymous', true); + first.result.current.form.setFieldValue('icon_selection', 'custom'); + first.result.current.updateMultipleConfig({ + image: dataUrl, + title: 'RoundTrip', + }); + }); + await act(async () => { + await first.result.current.syncEmbedSettings(); + }); + + const savedPayload = mockUpdateMentorSettingsFn.mock.calls[0][0].formData; + first.unmount(); + + // Simulate the backend persisting the data and returning it (the image + // becomes a resolved URL after upload). + vi.mocked(dataLayer.useGetMentorPublicSettingsQuery).mockReturnValue({ + data: { + mentor_unique_id: 'mentor-123', + embed_icon_selection_data: savedPayload.embed_icon_selection_data, + embed_custom_image: 'https://cdn.example.com/round-trip.png', + }, + } as any); + + // Second mount: the icon persists. + const second = renderHook(() => useEmbedTab()); + expect(second.result.current.form.getFieldValue('icon_selection')).toBe( + 'custom', + ); + expect(second.result.current.customFloatingBubbleConfig.title).toBe( + 'RoundTrip', + ); + expect(second.result.current.customFloatingBubbleConfig.image).toBe( + 'https://cdn.example.com/round-trip.png', + ); + }); + }); + describe('createTokenHandler', () => { it('should create redirect token with valid URL', async () => { mockCreateRedirectTokenFn.mockResolvedValueOnce({ diff --git a/components/modals/edit-mentor-modal/hooks/useEmbedTab.ts b/components/modals/edit-mentor-modal/hooks/useEmbedTab.ts index f7485d2c..93e357fb 100644 --- a/components/modals/edit-mentor-modal/hooks/useEmbedTab.ts +++ b/components/modals/edit-mentor-modal/hooks/useEmbedTab.ts @@ -9,10 +9,17 @@ import { } from '@iblai/iblai-js/data-layer'; import { useForm } from '@tanstack/react-form'; import { useParams } from 'next/navigation'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { z } from 'zod'; -import { getEmbedCode } from '../utils'; +import { + buildEmbedIconSelectionData, + buildFloatingBubbleConfigFromSettings, + dataUrlToFile, + getEmbedCode, + hasCustomIconData, + isDataUrl, +} from '../utils'; import { toast } from 'sonner'; import type { ChatMode } from '@iblai/iblai-js/web-utils'; import { useNavigate } from '@/hooks/user-navigate'; @@ -120,30 +127,31 @@ const useEmbedTab = () => { { isLoading: isCreateTokenLoading, data: redirectTokenData }, ] = useCreateRedirectTokenMutation(); const [updateMentorSettings] = useEditMentorMutation(); + const defaultFloatingBubbleConfig: CustomFloatingBubbleConfig = { + image: `${config.dmUrl()}/api/core/orgs/${params.tenantKey}/thumbnail/`, + //use_icon: false, + position: 'bottom-right', + //offsetX: 20, + //offsetY: 20, + size: 'small', + backgroundColor: 'transparent', + textColor: '#ffffff', + subtitleTextColor: '#e5e7eb', + accentColor: '#1d4ed8', + borderRadius: 16, + shadow: false, + title: '', + subtitle: '', + height: 48, + fontSize: 14, + subtitleFontSize: 12, + padding: 12, + imageSize: 32, + strokeColor: '#000', + strokeWidth: 0, + }; const [customFloatingBubbleConfig, setCustomFloatingBubbleConfig] = - useState({ - image: `${config.dmUrl()}/api/core/orgs/${params.tenantKey}/thumbnail/`, - //use_icon: false, - position: 'bottom-right', - //offsetX: 20, - //offsetY: 20, - size: 'small', - backgroundColor: 'transparent', - textColor: '#ffffff', - subtitleTextColor: '#e5e7eb', - accentColor: '#1d4ed8', - borderRadius: 16, - shadow: false, - title: '', - subtitle: '', - height: 48, - fontSize: 14, - subtitleFontSize: 12, - padding: 12, - imageSize: 32, - strokeColor: '#000', - strokeWidth: 0, - }); + useState(defaultFloatingBubbleConfig); const updateConfig = ( key: keyof typeof customFloatingBubbleConfig, @@ -174,6 +182,102 @@ const useEmbedTab = () => { }); }; + // Persist the mentor settings (the multipart PUT). Extracted from + // `syncEmbedSettings` so the Save button can reuse it WITHOUT the + // website-URL validation, redirect-token creation, embed-code generation, or + // embed-code dialog. The payload is identical to what Create Embed sends, so + // the icon persistence (custom JSON + image, and the `{}` clear for default + // mode) behaves the same regardless of which button triggered it. + const saveMentorSettings = async (): Promise<{ success: boolean }> => { + const value = form.state.values; + + // Set is_context_aware for advanced mode + const formValues = { ...value }; + if (formValues.mode === 'advanced') { + formValues.is_context_aware = true; + } + + // Update mentor settings + const valid_values = Object.fromEntries( + Object.entries(formValues).filter( + ([key, value]) => value !== '' || key === 'custom_css', + ), + ); + + // Persist the custom launcher icon config so it survives a page refresh. + // The custom-vs-default mode is derived from whether + // `embed_icon_selection_data` is a NON-EMPTY object (see `hasCustomIconData` + // / `buildFloatingBubbleConfigFromSettings`), so: + // - custom mode: persist the JSON map (bubble config minus the raw image + // binary) plus the image. The request helper JSON-stringifies objects + // automatically, so we pass a plain object. + // - default mode: CLEAR the stored JSON by persisting an empty JSON object + // `{}` so a non-empty map unambiguously means custom and an empty map + // means default on the next reload. + // + // Clearing nuance — verified LIVE against the real API for this multipart + // PUT field. `getFormData` JSON-stringifies a passed object, so the literal + // `{}` below is sent as the string `"{}"`. The three candidate clear values + // behave as: + // - `''` (empty string) -> HTTP 400 ("Value must be valid JSON"). + // - `'null'` (JSON null, the old `JSON.stringify(null)` approach) -> HTTP + // 200 but the field is NOT changed: DRF's partial-update silently + // ignores JSON null, so the old custom JSON persists. THIS WAS THE BUG. + // - `'{}'` (empty JSON object) -> HTTP 200 and the field IS set to `{}`. + // Only `{}` actually clears, so default mode sends an empty object literal. + // On hydrate, `{}` has no own keys, so `hasCustomIconData` is false and the + // mode correctly derives back to 'default'. + const isCustomIcon = formValues.icon_selection === 'custom'; + const embed_icon_selection_data: Record = isCustomIcon + ? buildEmbedIconSelectionData(customFloatingBubbleConfig) + : {}; + + // - `embed_custom_image`: when the user just uploaded an image it lives in + // state as a base64 data URL — convert it to a real File so it is sent as + // multipart binary. When it is already a resolved URL (unchanged image) + // we omit it so the previously-saved image isn't clobbered. In default + // mode we never send an image. + const imageValue = customFloatingBubbleConfig.image; + let embed_custom_image: File | undefined; + if (isCustomIcon && isDataUrl(imageValue)) { + embed_custom_image = dataUrlToFile(imageValue) ?? undefined; + } + + const response = await updateMentorSettings({ + mentor: mentorId, + org: params.tenantKey, + // @ts-expect-error - userId is required by the API but not reflected in the type definition + userId: getUserName(), + // `embed_custom_image` is typed as `string` in the SDK request model, but + // the endpoint is multipart and the request helper appends a File/Blob as + // binary, so we send a File for new uploads via a narrow cast. + formData: { + ...valid_values, + metadata: { safety_disclaimer: valid_values.safety_disclaimer }, + embed_icon_selection_data, + // Only include the image key when we have a new File to upload so an + // unchanged (already-persisted) image is left untouched by the backend. + ...(embed_custom_image + ? { embed_custom_image: embed_custom_image as unknown as string } + : {}), + }, + }); + if (response?.error) { + console.error( + `Failed to update mentor settings for mentor (${mentorId}) in org (${params.tenantKey})`, + response.error, + ); + const errorMessage = + (response.error as any)?.error?.error ?? + 'An Unknown error occurred. Please try again'; + setCreateTokenError(errorMessage); + toast.error(errorMessage); + return { success: false }; + } + + return { success: true }; + }; + const syncEmbedSettings = async (): Promise<{ success: boolean; redirectToken?: string; @@ -190,21 +294,15 @@ const useEmbedTab = () => { return { success: false }; } - // Set is_context_aware for advanced mode - const formValues = { ...value }; - if (formValues.mode === 'advanced') { - formValues.is_context_aware = true; - } - let redirectTokenResponse: { data?: { token?: string } } | null = null; // Create redirect token if not anonymous - if (!formValues.allow_anonymous) { + if (!value.allow_anonymous) { try { const response = await createRedirectToken({ org: params.tenantKey, requestBody: { - url: formValues.website_url, + url: value.website_url, mentor_unique_id: mentorPublicSettings?.mentor_unique_id, }, }); @@ -217,49 +315,41 @@ const useEmbedTab = () => { redirectTokenResponse = response; } catch (error) { console.error( - `Failed to create redirect token for website (${formValues.website_url}) in org (${params.tenantKey})`, + `Failed to create redirect token for website (${value.website_url}) in org (${params.tenantKey})`, error, ); setCreateTokenError( - `Failed to create redirect token for website (${formValues.website_url}) in org (${params.tenantKey})`, + `Failed to create redirect token for website (${value.website_url}) in org (${params.tenantKey})`, ); console.error(JSON.stringify({ tenant: tenantKey, error })); return { success: false }; } } - // Update mentor settings - const valid_values = Object.fromEntries( - Object.entries(formValues).filter( - ([key, value]) => value !== '' || key === 'custom_css', - ), - ); - const response = await updateMentorSettings({ - mentor: mentorId, - org: params.tenantKey, - // @ts-expect-error - userId is required by the API but not reflected in the type definition - userId: getUserName(), - formData: { - ...valid_values, - metadata: { safety_disclaimer: valid_values.safety_disclaimer }, - }, - }); - if (response?.error) { - console.error( - `Failed to update mentor settings for mentor (${mentorId}) in org (${params.tenantKey})`, - response.error, - ); - const errorMessage = - (response.error as any)?.error?.error ?? - 'An Unknown error occurred. Please try again'; - setCreateTokenError(errorMessage); - toast.error(errorMessage); + const saveResult = await saveMentorSettings(); + if (!saveResult.success) { return { success: false }; } return { success: true, redirectToken: redirectTokenResponse?.data?.token }; }; + // Save button handler: persist settings only — no URL validation, no + // redirect token, no embed-code generation, no embed dialog. Shows a success + // toast on success; `saveMentorSettings` already toasts/sets error on failure. + const [isSavingSettings, setIsSavingSettings] = useState(false); + const handleSaveSettings = async (): Promise => { + setIsSavingSettings(true); + try { + const result = await saveMentorSettings(); + if (result.success) { + toast.success('Settings saved'); + } + } finally { + setIsSavingSettings(false); + } + }; + const form = useForm({ defaultValues: { ...defaultEmbedFormValues, @@ -279,6 +369,24 @@ const useEmbedTab = () => { show_catalogue: (mentorPublicSettings as { show_catalogue?: boolean } | undefined) ?.show_catalogue ?? true, + // The custom-vs-default launcher icon mode is derived from whether the + // persisted `embed_icon_selection_data` is a NON-EMPTY object (mirroring + // `buildFloatingBubbleConfigFromSettings` via the shared + // `hasCustomIconData` predicate, so both call sites stay in lockstep). A + // cleared field reads back as the empty object `{}`, which must yield + // 'default'. Initialize straight from settings so TanStack Form's reactive + // defaultValues hydrate it the same way the sibling fields above are + // hydrated — instead of an imperative `setFieldValue` in an effect that + // the reactive re-init would clobber. + icon_selection: hasCustomIconData( + ( + mentorPublicSettings as + | { embed_icon_selection_data?: unknown } + | undefined + )?.embed_icon_selection_data, + ) + ? 'custom' + : 'default', starter_prompts: mentorPublicSettings?.starter_prompts === 'suggested_prompt' ? 'suggested_prompt' @@ -301,6 +409,44 @@ const useEmbedTab = () => { setEmbedCode(embed); }, }); + // Hydrate the custom launcher icon config from persisted mentor settings + // once they load. Without this, the config only ever lived in local state, so + // a refresh reverted the icon (and the generated embed snippet) to defaults. + // We guard with a ref keyed on the settings identity so hydration runs on the + // initial load / settings-id change only — never on every render — to avoid + // clobbering in-progress user edits. + const hydratedSettingsKeyRef = useRef(null); + useEffect(() => { + if (!mentorPublicSettings) return; + + const settings = mentorPublicSettings as { + mentor_unique_id?: string; + embed_icon_selection_data?: unknown; + embed_custom_image?: string | null; + }; + const settingsKey = settings.mentor_unique_id ?? mentorId; + if (hydratedSettingsKeyRef.current === settingsKey) return; + hydratedSettingsKeyRef.current = settingsKey; + + const hydrated = buildFloatingBubbleConfigFromSettings( + defaultFloatingBubbleConfig, + settings.embed_icon_selection_data, + settings.embed_custom_image, + ); + if (!hydrated) return; + + setCustomFloatingBubbleConfig(hydrated.config); + // NOTE: the `icon_selection` form field is NOT set here. It hydrates from + // settings via the form's reactive `defaultValues` (see the `useForm` block + // above). Setting it imperatively here would be clobbered by that reactive + // re-init when `mentorPublicSettings` loads, which was the root cause of the + // refresh bug (#789). `buildFloatingBubbleConfigFromSettings` still returns + // `iconSelection` for other consumers/tests; we simply don't apply it here. + // Intentionally keyed on settings identity only — `form` and + // `defaultFloatingBubbleConfig` are recreated each render but the ref guard + // ensures this hydrates once per settings load, not on every render. + }, [mentorPublicSettings, mentorId]); + const { data: integratedSsoProviders, isError: isIntegratedSsoProvidersError = true, @@ -366,6 +512,8 @@ const useEmbedTab = () => { updateConfig, updateMultipleConfig, syncEmbedSettings, + handleSaveSettings, + isSavingSettings, }; }; diff --git a/components/modals/edit-mentor-modal/tabs/__tests__/embed-tab.test.tsx b/components/modals/edit-mentor-modal/tabs/__tests__/embed-tab.test.tsx index 02b3be36..f0a87fda 100644 --- a/components/modals/edit-mentor-modal/tabs/__tests__/embed-tab.test.tsx +++ b/components/modals/edit-mentor-modal/tabs/__tests__/embed-tab.test.tsx @@ -6,6 +6,7 @@ import { fireEvent, waitFor, cleanup, + within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -39,6 +40,7 @@ const mockSetFocusEditCustomFloatingBubble = vi.fn(); const mockUpdateConfig = vi.fn(); const mockUpdateMultipleConfig = vi.fn(); const mockFormHandleSubmit = vi.fn(); +const mockHandleSaveSettings = vi.fn(); // next/navigation vi.mock('next/navigation', () => ({ @@ -195,6 +197,9 @@ vi.mock('@/components/ui/select', () => ({
{React.Children.map(children, (child: any) => @@ -332,6 +337,8 @@ function buildUseEmbedTabReturn(overrides: Partial = {}) { updateConfig: mockUpdateConfig, updateMultipleConfig: mockUpdateMultipleConfig, syncEmbedSettings: mockSyncEmbedSettings, + handleSaveSettings: mockHandleSaveSettings, + isSavingSettings: false, ...overrides, }; } @@ -745,6 +752,28 @@ describe('EmbedTab', () => { expect(mockFormHandleSubmit).toHaveBeenCalled(); }); + it('renders both the Save and Create Embed footer buttons', () => { + renderEmbedTab(); + expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Create Embed' }), + ).toBeInTheDocument(); + }); + + it('persists settings via the Save button without submitting the form', () => { + renderEmbedTab(); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(mockHandleSaveSettings).toHaveBeenCalled(); + expect(mockFormHandleSubmit).not.toHaveBeenCalled(); + }); + + it('shows the saving label and disables the Save button while saving', () => { + renderEmbedTab({ isSavingSettings: true }); + const saveBtn = screen.getByRole('button', { name: 'Saving...' }); + expect(saveBtn).toBeInTheDocument(); + expect(saveBtn).toBeDisabled(); + }); + it('submits the form via the form element onSubmit', () => { const { container } = renderEmbedTab(); const formEl = container.querySelector('form') as HTMLFormElement; @@ -803,6 +832,32 @@ describe('EmbedTab', () => { expect(mockSetFocusEditCustomFloatingBubble).toHaveBeenCalledWith(true); }); + // Regression for issue #789: the Icon Selection Select must be *controlled* + // (bound via `value`, not `defaultValue`) so that when the form field is + // hydrated to 'custom' asynchronously after settings load, the trigger label + // reflects it instead of being stuck on "Default". + it('renders a controlled Icon Selection Select that reflects the field value', () => { + renderEmbedTab({}, { ...defaultFormValues, icon_selection: 'custom' }); + + const iconHeading = screen.getByRole('heading', { name: 'Icon Selection' }); + const iconBlock = iconHeading.parentElement as HTMLElement; + const select = within(iconBlock).getByTestId('select-root'); + + expect(select).toHaveAttribute('data-controlled', 'true'); + expect(select).toHaveAttribute('data-value', 'custom'); + }); + + it('Icon Selection Select reflects the default field value', () => { + renderEmbedTab({}, { ...defaultFormValues, icon_selection: 'default' }); + + const iconHeading = screen.getByRole('heading', { name: 'Icon Selection' }); + const iconBlock = iconHeading.parentElement as HTMLElement; + const select = within(iconBlock).getByTestId('select-root'); + + expect(select).toHaveAttribute('data-controlled', 'true'); + expect(select).toHaveAttribute('data-value', 'default'); + }); + it('renders the floating bubble editor dialog and its tabs', () => { renderEmbedTab({ focusEditCustomFloatingBubble: true }); expect(screen.getByText('Icon Editor')).toBeInTheDocument(); @@ -833,6 +888,34 @@ describe('EmbedTab', () => { expect(mockUpdateMultipleConfig).toHaveBeenCalledWith({ image: null }); }); + it('reads an uploaded icon image as a data URL and stores it in config', async () => { + renderEmbedTab({ focusEditCustomFloatingBubble: true }); + + const fileInput = document.getElementById('iconImage') as HTMLInputElement; + expect(fileInput).toBeTruthy(); + + const file = new File(['icon-bytes'], 'icon.png', { type: 'image/png' }); + fireEvent.change(fileInput, { target: { files: [file] } }); + + // FileReader.readAsDataURL resolves asynchronously. + await waitFor(() => { + expect(mockUpdateMultipleConfig).toHaveBeenCalledWith( + expect.objectContaining({ + image: expect.stringMatching(/^data:image\/png;base64,/), + }), + ); + }); + }); + + it('ignores the icon upload when no file is selected', () => { + renderEmbedTab({ focusEditCustomFloatingBubble: true }); + + const fileInput = document.getElementById('iconImage') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [] } }); + + expect(mockUpdateMultipleConfig).not.toHaveBeenCalled(); + }); + it('renders the generated embed code dialog', () => { renderEmbedTab({ embedCode: '' }); expect(screen.getByText('Embedded Code')).toBeInTheDocument(); diff --git a/components/modals/edit-mentor-modal/tabs/embed-tab.tsx b/components/modals/edit-mentor-modal/tabs/embed-tab.tsx index f86cbfbc..1bce588f 100644 --- a/components/modals/edit-mentor-modal/tabs/embed-tab.tsx +++ b/components/modals/edit-mentor-modal/tabs/embed-tab.tsx @@ -242,6 +242,8 @@ export function EmbedTab() { updateConfig, updateMultipleConfig, syncEmbedSettings, + handleSaveSettings, + isSavingSettings, } = useEmbedTab(); const toast = useToast(); const { data: mentorSettings, isLoading: isLoadingSettings } = @@ -964,7 +966,12 @@ export function EmbedTab() { Icon Selection