diff --git a/web/app/components/plugins/plugin-page/index.tsx b/web/app/components/plugins/plugin-page/index.tsx index 361f562352..047d1fb8cf 100644 --- a/web/app/components/plugins/plugin-page/index.tsx +++ b/web/app/components/plugins/plugin-page/index.tsx @@ -86,15 +86,13 @@ const PluginPage = ({ return } if (bundleInfo) { - // bundleInfo is a JSON string from URL, needs parsing try { - const parsedBundleInfo = typeof bundleInfo === 'string' ? JSON.parse(bundleInfo) : bundleInfo - const { data } = await fetchBundleInfoFromMarketPlace(parsedBundleInfo) + const { data } = await fetchBundleInfoFromMarketPlace(bundleInfo) setDependencies(data.version.dependencies) showInstallFromMarketplace() } - catch (e) { - console.error('Failed to parse bundle info:', e) + catch (error) { + console.error('Failed to load bundle info:', error) } } })() diff --git a/web/hooks/use-query-params.spec.tsx b/web/hooks/use-query-params.spec.tsx index 2ce8f63156..68692030b3 100644 --- a/web/hooks/use-query-params.spec.tsx +++ b/web/hooks/use-query-params.spec.tsx @@ -474,9 +474,10 @@ describe('useQueryParams hooks', () => { describe('usePluginInstallation', () => { it('should parse package ids from JSON arrays', () => { // Arrange + const bundleInfo = { org: 'org', name: 'bundle', version: '1.0.0' } const { result } = renderWithAdapter( () => usePluginInstallation(), - '?package-ids=%5B%22org%2Fplugin%22%5D&bundle-info=bundle', + `?package-ids=%5B%22org%2Fplugin%22%5D&bundle-info=${encodeURIComponent(JSON.stringify(bundleInfo))}`, ) // Act @@ -484,7 +485,7 @@ describe('useQueryParams hooks', () => { // Assert expect(state.packageId).toBe('org/plugin') - expect(state.bundleInfo).toBe('bundle') + expect(state.bundleInfo).toEqual(bundleInfo) }) it('should return raw package id when JSON parsing fails', () => { @@ -532,24 +533,26 @@ describe('useQueryParams hooks', () => { it('should set bundle info when provided', async () => { // Arrange + const bundleInfo = { org: 'org', name: 'bundle', version: '1.0.0' } const { result, onUrlUpdate } = renderWithAdapter(() => usePluginInstallation()) // Act act(() => { - result.current[1]({ bundleInfo: 'bundle' }) + result.current[1]({ bundleInfo }) }) // Assert await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.searchParams.get('bundle-info')).toBe('bundle') + expect(update.searchParams.get('bundle-info')).toBe(JSON.stringify(bundleInfo)) }) it('should clear installation params when state is null', async () => { // Arrange + const bundleInfo = { org: 'org', name: 'bundle', version: '1.0.0' } const { result, onUrlUpdate } = renderWithAdapter( () => usePluginInstallation(), - '?package-ids=%5B%22org%2Fplugin%22%5D&bundle-info=bundle', + `?package-ids=%5B%22org%2Fplugin%22%5D&bundle-info=${encodeURIComponent(JSON.stringify(bundleInfo))}`, ) // Act @@ -566,9 +569,10 @@ describe('useQueryParams hooks', () => { it('should preserve bundle info when only packageId is updated', async () => { // Arrange + const bundleInfo = { org: 'org', name: 'bundle', version: '1.0.0' } const { result, onUrlUpdate } = renderWithAdapter( () => usePluginInstallation(), - '?bundle-info=bundle', + `?bundle-info=${encodeURIComponent(JSON.stringify(bundleInfo))}`, ) // Act @@ -579,7 +583,7 @@ describe('useQueryParams hooks', () => { // Assert await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.searchParams.get('bundle-info')).toBe('bundle') + expect(update.searchParams.get('bundle-info')).toBe(JSON.stringify(bundleInfo)) }) }) }) diff --git a/web/hooks/use-query-params.ts b/web/hooks/use-query-params.ts index de984c8512..534d2032cd 100644 --- a/web/hooks/use-query-params.ts +++ b/web/hooks/use-query-params.ts @@ -141,6 +141,47 @@ export function useMarketplaceFilters() { */ const PACKAGE_IDS_PARAM = 'package-ids' const BUNDLE_INFO_PARAM = 'bundle-info' +type BundleInfoQuery = { + org: string + name: string + version: string +} + +const parseAsPackageId = createParser({ + parse: (value) => { + try { + const parsed = JSON.parse(value) + if (Array.isArray(parsed)) { + const first = parsed[0] + return typeof first === 'string' ? first : null + } + return value + } + catch { + return value + } + }, + serialize: value => JSON.stringify([value]), +}) + +const parseAsBundleInfo = createParser({ + parse: (value) => { + try { + const parsed = JSON.parse(value) as Partial + if (parsed + && typeof parsed.org === 'string' + && typeof parsed.name === 'string' + && typeof parsed.version === 'string') { + return { org: parsed.org, name: parsed.name, version: parsed.version } + } + } + catch { + return null + } + return null + }, + serialize: value => JSON.stringify(value), +}) /** * Hook to manage plugin installation state via URL @@ -149,53 +190,45 @@ const BUNDLE_INFO_PARAM = 'bundle-info' * @example * const [installState, setInstallState] = usePluginInstallation() * setInstallState({ packageId: 'org/plugin' }) // Sets ?package-ids=["org/plugin"] + * setInstallState({ bundleInfo: { org: 'org', name: 'bundle', version: '1.0.0' } }) // Sets ?bundle-info=... * setInstallState(null) // Clears installation params */ export function usePluginInstallation() { - const [packageIds, setPackageIds] = useQueryState( - PACKAGE_IDS_PARAM, - parseAsString, - ) - const [bundleInfo, setBundleInfo] = useQueryState( - BUNDLE_INFO_PARAM, - parseAsString, + const [installState, setInstallStateState] = useQueryStates( + { + packageId: parseAsPackageId, + bundleInfo: parseAsBundleInfo, + }, + { + urlKeys: { + packageId: PACKAGE_IDS_PARAM, + bundleInfo: BUNDLE_INFO_PARAM, + }, + }, ) const setInstallState = useCallback( - (state: { packageId?: string, bundleInfo?: string } | null) => { + (state: { packageId?: string, bundleInfo?: BundleInfoQuery } | null) => { if (!state) { - setPackageIds(null) - setBundleInfo(null) + setInstallStateState(null) return } - if (state.packageId) { - // Store as JSON array for consistency with existing code - setPackageIds(JSON.stringify([state.packageId])) - } - if (state.bundleInfo) { - setBundleInfo(state.bundleInfo) - } + const patch: { packageId?: string, bundleInfo?: BundleInfoQuery } = {} + if (state.packageId) + patch.packageId = state.packageId + if (state.bundleInfo) + patch.bundleInfo = state.bundleInfo + if (Object.keys(patch).length === 0) + return + setInstallStateState(patch) }, - [setBundleInfo, setPackageIds], + [setInstallStateState], ) - // Parse packageIds from JSON array - const currentPackageId = packageIds - ? (() => { - try { - const parsed = JSON.parse(packageIds) - return Array.isArray(parsed) ? parsed[0] : packageIds - } - catch { - return packageIds - } - })() - : null - return [ { - packageId: currentPackageId, - bundleInfo, + packageId: installState.packageId, + bundleInfo: installState.bundleInfo, }, setInstallState, ] as const