From 0f1dcd684739ec0ce8c1b3ac05511faf15f7ebcf Mon Sep 17 00:00:00 2001 From: Joseph Bannerman Date: Fri, 2 Oct 2026 16:45:08 -0400 Subject: [PATCH] fix(hydra-migration): migrate help panel support cases to GraphQL --- src/Messages.ts | 4 + .../HelpPanel/HelpPanelContent.stories.tsx | 9 +- .../HelpPanelTabs/SupportPanel.stories.tsx | 30 ++-- .../HelpPanelTabs/SupportPanel.test.tsx | 53 +++++++ .../HelpPanel/HelpPanelTabs/SupportPanel.tsx | 92 +++++------- .../_shared/helpPanelJourneyHelpers.ts | 30 ++-- .../_shared/supportCasesResponse.ts | 31 ++++ src/utils/fetchSupportCases.test.ts | 120 ++++++++++++++++ src/utils/fetchSupportCases.ts | 134 ++++++++++++++++++ 9 files changed, 408 insertions(+), 95 deletions(-) create mode 100644 src/components/HelpPanel/HelpPanelTabs/SupportPanel.test.tsx create mode 100644 src/user-journeys/_shared/supportCasesResponse.ts create mode 100644 src/utils/fetchSupportCases.test.ts create mode 100644 src/utils/fetchSupportCases.ts diff --git a/src/Messages.ts b/src/Messages.ts index f5b1a360..f06064e9 100644 --- a/src/Messages.ts +++ b/src/Messages.ts @@ -151,6 +151,10 @@ const messages = defineMessages({ id: 'helpPanel.support.customerPortalLink', defaultMessage: 'Customer Portal', }, + supportCasesLoadError: { + id: 'supportCasesLoadError', + defaultMessage: 'Unable to load support cases. Please try again later.', + }, supportCasesTableTitle: { id: 'helpPanel.support.casesTableTitle', defaultMessage: 'My open support cases', diff --git a/src/components/HelpPanel/HelpPanelContent.stories.tsx b/src/components/HelpPanel/HelpPanelContent.stories.tsx index 13d9330f..41de6dd6 100644 --- a/src/components/HelpPanel/HelpPanelContent.stories.tsx +++ b/src/components/HelpPanel/HelpPanelContent.stories.tsx @@ -1,3 +1,4 @@ +import { supportCasesResponse } from '../../user-journeys/_shared/supportCasesResponse'; import type { Meta, StoryObj } from '@storybook/react-webpack5'; import React, { useState } from 'react'; import { MemoryRouter } from 'react-router-dom'; @@ -130,11 +131,11 @@ const mockHelpPanelHandlers = [ return HttpResponse.json({ success: true }); }), // Support cases API (empty state) - prevents "Failed to fetch" errors - http.post('https://api.access.redhat.com/support/v1/cases/filter', () => - HttpResponse.json({ cases: [] }) + http.post('https://graphql.redhat.com', () => + HttpResponse.json(supportCasesResponse([])) ), - http.post('https://api.access.stage.redhat.com/support/v1/cases/filter', () => - HttpResponse.json({ cases: [] }) + http.post('https://graphql.stage.redhat.com', () => + HttpResponse.json(supportCasesResponse([])) ), ]; diff --git a/src/components/HelpPanel/HelpPanelTabs/SupportPanel.stories.tsx b/src/components/HelpPanel/HelpPanelTabs/SupportPanel.stories.tsx index 0bad834f..5798b27c 100644 --- a/src/components/HelpPanel/HelpPanelTabs/SupportPanel.stories.tsx +++ b/src/components/HelpPanel/HelpPanelTabs/SupportPanel.stories.tsx @@ -1,3 +1,4 @@ +import { supportCasesResponse } from '../../../user-journeys/_shared/supportCasesResponse'; import type { Meta, StoryObj } from '@storybook/react-webpack5'; import React from 'react'; import { IntlProvider } from 'react-intl'; @@ -9,8 +10,7 @@ import { supportPanelMswHandlersWithCases, } from '../../../user-journeys/_shared/helpPanelJourneyHelpers'; -const supportCasesFilterUrlStage = - 'https://api.access.stage.redhat.com/support/v1/cases/filter'; +const supportCasesFilterUrlStage = 'https://graphql.stage.redhat.com'; /** * Wrapper to provide IntlProvider (component uses useIntl and Messages). @@ -73,7 +73,7 @@ export const Loading: Story = { handlers: [ http.post(supportCasesFilterUrlStage, async () => { await delay(2000); - return HttpResponse.json({ cases: [] }); + return HttpResponse.json(supportCasesResponse([])); }), ], }, @@ -156,7 +156,7 @@ export const WithCasesPagination: Story = { msw: { handlers: [ http.post(supportCasesFilterUrlStage, () => - HttpResponse.json({ cases: manyCases }) + HttpResponse.json(supportCasesResponse(manyCases)) ), ], }, @@ -189,34 +189,26 @@ export const WithCasesPagination: Story = { }; /** - * API error: component shows empty state after failed fetch. + * API error: component distinguishes a failure from an empty case list. */ export const ApiError: Story = { parameters: { + testRunner: { ignoreConsoleErrors: true }, msw: { handlers: [ http.post(supportCasesFilterUrlStage, () => - HttpResponse.json({ error: 'Server error' }, { status: 500 }) + HttpResponse.json({ errors: [{ message: 'Missing CRM contact' }] }) ), ], }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - - await waitFor( - () => { - const emptyState = document.querySelector( - '[data-ouia-component-id="help-panel-support-empty-state"]' - ); - expect(emptyState).toBeInTheDocument(); - }, - { timeout: 5000 } - ); - - expect(canvas.getByText('No open support cases')).toBeInTheDocument(); expect( - canvas.getByRole('button', { name: /open a support case/i }) + await canvas.findByText( + 'Unable to load support cases. Please try again later.' + ) ).toBeInTheDocument(); + expect(canvas.queryByText('No open support cases')).not.toBeInTheDocument(); }, }; diff --git a/src/components/HelpPanel/HelpPanelTabs/SupportPanel.test.tsx b/src/components/HelpPanel/HelpPanelTabs/SupportPanel.test.tsx new file mode 100644 index 00000000..f80b64ee --- /dev/null +++ b/src/components/HelpPanel/HelpPanelTabs/SupportPanel.test.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { IntlProvider } from 'react-intl'; +import SupportPanel from './SupportPanel'; +import { fetchSupportCases } from '../../../utils/fetchSupportCases'; + +jest.mock('../../../utils/fetchSupportCases'); +jest.mock('@redhat-cloud-services/frontend-components/useChrome', () => ({ + __esModule: true, + default: () => ({ auth: {}, getEnvironment: () => 'stage' }), +})); +const fetchCases = fetchSupportCases as jest.MockedFunction< + typeof fetchSupportCases +>; +const renderPanel = () => + render( + + + + ); +afterEach(() => jest.restoreAllMocks()); +it('shows new status values and changes the visible page while preserving the total', async () => { + fetchCases.mockResolvedValue( + Array.from({ length: 25 }, (_, i) => ({ + id: String(i), + caseNumber: `000${i}`, + summary: `Case title ${i}`, + lastModifiedById: '', + lastModifiedDate: '', + severity: '3 (Medium)', + status: 'Waiting on Engineering', + })) + ); + renderPanel(); + expect(await screen.findByText('Case title 0')).toBeInTheDocument(); + expect(screen.getAllByText('Waiting on Engineering')).toHaveLength(20); + expect(screen.queryByText('Case title 20')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /go to next page/i })); + expect(await screen.findByText('Case title 20')).toBeInTheDocument(); + expect(screen.queryByText('Case title 0')).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /25/ })).toBeInTheDocument(); +}); +it('renders an error rather than an empty state when access fails', async () => { + jest.spyOn(console, 'error').mockImplementation(() => undefined); + fetchCases.mockRejectedValue(new Error('GraphQL failed')); + renderPanel(); + expect( + await screen.findByText( + 'Unable to load support cases. Please try again later.' + ) + ).toBeInTheDocument(); + expect(screen.queryByText('No open support cases')).not.toBeInTheDocument(); +}); diff --git a/src/components/HelpPanel/HelpPanelTabs/SupportPanel.tsx b/src/components/HelpPanel/HelpPanelTabs/SupportPanel.tsx index fa9b34e1..c04c2400 100644 --- a/src/components/HelpPanel/HelpPanelTabs/SupportPanel.tsx +++ b/src/components/HelpPanel/HelpPanelTabs/SupportPanel.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from 'react'; import { + Alert, Button, Content, ContentVariants, @@ -21,18 +22,14 @@ import InProgressIcon from '@patternfly/react-icons/dist/dynamic/icons/in-progre import { Table, TableVariant, Tbody, Td, Tr } from '@patternfly/react-table'; import { useIntl } from 'react-intl'; import messages from '../../../Messages'; +import { + SupportCase, + fetchSupportCases, +} from '../../../utils/fetchSupportCases'; const SUPPORT_CASE_URL = 'https://access.redhat.com/support/cases/#/case/new/get-support?caseCreate=true'; -type Case = { - id: string; - caseNumber: string; - summary: string; - lastModifiedDate: string; - status: string; -}; - const columnNames = { summary: 'Title', status: 'Status', @@ -74,13 +71,14 @@ export const statusIcons = (status: string) => { ), }; - return statusMapper[status] ?? ''; + return statusMapper[status] ?? status; }; const SupportPanel: React.FunctionComponent = () => { const intl = useIntl(); - const [cases, setCases] = useState([]); + const [cases, setCases] = useState([]); const chrome = useChrome(); - const [isLoading, setIsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(20); @@ -101,59 +99,39 @@ const SupportPanel: React.FunctionComponent = () => { setPage(newPage); }; - const getUrl = (env: string) => - `https://api.access${ - env === 'stage' || env === 'frhStage' ? '.stage' : '' - }.redhat.com/support/v1/cases/filter`; - - const fetchSupportCases = async () => { - const token = await chrome.auth.getToken(); - const user = await chrome.auth.getUser(); - const options = { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify({ - createdBySSOName: `${user?.identity.user?.username}`, - }), - }; - - try { - const response = await fetch(getUrl(chrome.getEnvironment()), options); - const data = await response.json(); - const { cases } = data; - - if (cases && Array.isArray(cases)) { - cases.sort( - (a: { lastModifiedDate: number }, b: { lastModifiedDate: number }) => - new Date(b.lastModifiedDate).getTime() - - new Date(a.lastModifiedDate).getTime() - ); - setCases(cases); - } else { - setCases([]); - } - - setIsLoading(false); - } catch (error) { - console.error('Unable to fetch support cases', error); - setCases([]); - setIsLoading(false); - } - }; - useEffect(() => { + const controller = new AbortController(); setIsLoading(true); - fetchSupportCases(); + setHasError(false); + fetchSupportCases(chrome.auth, chrome.getEnvironment(), controller.signal) + .then((data) => { + if (!controller.signal.aborted) { + setCases(data); + setPage(1); + } + }) + .catch((error) => { + if (!controller.signal.aborted) { + console.error('Unable to fetch support cases', error); + setHasError(true); + } + }) + .finally(() => { + if (!controller.signal.aborted) setIsLoading(false); + }); + return () => controller.abort(); }, []); return ( <> {isLoading ? ( + ) : hasError ? ( + ) : cases.length === 0 ? ( { data-ouia-component-id="help-panel-support-cases-table" > - {cases.map((c) => ( + {cases.slice((page - 1) * perPage, page * perPage).map((c) => ( - HttpResponse.json({ cases: [] }) + http.post('https://graphql.redhat.com', () => + HttpResponse.json(supportCasesResponse([])) ), - http.post('https://api.access.stage.redhat.com/support/v1/cases/filter', () => - HttpResponse.json({ cases: [] }) + http.post('https://graphql.stage.redhat.com', () => + HttpResponse.json(supportCasesResponse([])) ), ]; @@ -511,11 +512,11 @@ export const searchPanelJourneyMswHandlers = [ return HttpResponse.json({ success: true }); }), // Support cases API (empty state) - prevents "Failed to fetch" errors - http.post('https://api.access.redhat.com/support/v1/cases/filter', () => - HttpResponse.json({ cases: [] }) + http.post('https://graphql.redhat.com', () => + HttpResponse.json(supportCasesResponse([])) ), - http.post('https://api.access.stage.redhat.com/support/v1/cases/filter', () => - HttpResponse.json({ cases: [] }) + http.post('https://graphql.stage.redhat.com', () => + HttpResponse.json(supportCasesResponse([])) ), // Base handlers last (overridden routes above take priority in MSW) ...helpPanelMswHandlers, @@ -744,12 +745,11 @@ export const mockApiBundles = [ }, ]; -const supportCasesFilterUrlProd = - 'https://api.access.redhat.com/support/v1/cases/filter'; -const supportCasesFilterUrlStage = - 'https://api.access.stage.redhat.com/support/v1/cases/filter'; +const supportCasesFilterUrlProd = 'https://graphql.redhat.com'; +const supportCasesFilterUrlStage = 'https://graphql.stage.redhat.com'; -const emptySupportCasesResponse = () => HttpResponse.json({ cases: [] }); +const emptySupportCasesResponse = () => + HttpResponse.json(supportCasesResponse([])); /** * MSW handlers for Support Panel - empty state (no open support cases). @@ -929,10 +929,10 @@ const mockSupportCases = [ */ export const supportPanelMswHandlersWithCases = [ http.post(supportCasesFilterUrlProd, () => - HttpResponse.json({ cases: mockSupportCases }) + HttpResponse.json(supportCasesResponse(mockSupportCases)) ), http.post(supportCasesFilterUrlStage, () => - HttpResponse.json({ cases: mockSupportCases }) + HttpResponse.json(supportCasesResponse(mockSupportCases)) ), ]; diff --git a/src/user-journeys/_shared/supportCasesResponse.ts b/src/user-journeys/_shared/supportCasesResponse.ts new file mode 100644 index 00000000..b5f374da --- /dev/null +++ b/src/user-journeys/_shared/supportCasesResponse.ts @@ -0,0 +1,31 @@ +interface MockSupportCase { + id: string; + caseNumber: string; + summary: string; + status: string; + lastModifiedDate: string; +} +export function supportCasesResponse(cases: MockSupportCase[]) { + return { + data: { + redhat_support_uiapi: { + query: { + RedHatSupportCase: { + edges: cases.map((c) => ({ + node: { + Id: c.id, + CaseNumber__c: { value: c.caseNumber }, + Subject: { value: c.summary }, + Status: { value: c.status }, + Priority: { value: '3 (Medium)' }, + LastModifiedDate: { value: c.lastModifiedDate }, + LastModifiedBy: null, + }, + })), + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + }; +} diff --git a/src/utils/fetchSupportCases.test.ts b/src/utils/fetchSupportCases.test.ts new file mode 100644 index 00000000..6e893b12 --- /dev/null +++ b/src/utils/fetchSupportCases.test.ts @@ -0,0 +1,120 @@ +import { fetchSupportCases, getSupportCasesUrl } from './fetchSupportCases'; +import type { ChromeAPI } from '@redhat-cloud-services/types'; + +const getUser = jest.fn(); +const getToken = jest.fn(); +const auth = { getUser, getToken } as unknown as ChromeAPI['auth']; +const mockFetch = jest.fn(); +const originalFetch = global.fetch; +const node = { + Id: 'record-id', + CaseNumber__c: { value: '00012345' }, + Subject: { value: 'R&D case' }, + Status: { value: 'Waiting on Engineering' }, + Priority: { value: '3 (Medium)' }, + LastModifiedDate: { value: '2026-10-01T00:00:00Z' }, + LastModifiedBy: { Name: { value: 'Case editor' } }, +}; +const page = ( + nodes = [node], + hasNextPage = false, + endCursor: string | null = null +) => ({ + ok: true, + status: 200, + json: async () => ({ + data: { + redhat_support_uiapi: { + query: { + RedHatSupportCase: { + edges: nodes.map((node) => ({ node })), + pageInfo: { hasNextPage, endCursor }, + }, + }, + }, + }, + }), +}); +beforeEach(() => { + jest.clearAllMocks(); + global.fetch = mockFetch; + getUser.mockResolvedValue({ identity: { user: { username: 'test-user' } } }); + getToken.mockResolvedValue('test-token'); +}); +afterAll(() => { + global.fetch = originalFetch; +}); + +it('fetches all cursor pages and preserves raw field values and leading zeroes', async () => { + mockFetch + .mockResolvedValueOnce(page([node], true, 'next')) + .mockResolvedValueOnce(page([{ ...node, Id: 'second' }])); + const cases = await fetchSupportCases(auth, 'stage'); + expect(cases).toHaveLength(2); + expect(cases[0]).toEqual({ + id: 'record-id', + caseNumber: '00012345', + summary: 'R&D case', + status: 'Waiting on Engineering', + severity: '3 (Medium)', + lastModifiedById: 'Case editor', + lastModifiedDate: '2026-10-01T00:00:00Z', + }); + const [url, request] = mockFetch.mock.calls[0]; + expect(url).toBe('https://graphql.stage.redhat.com'); + expect(request.headers.Authorization).toBe('Bearer test-token'); + expect(request.credentials).toBe('omit'); + expect(JSON.parse(request.body).variables).toEqual({ + after: null, + where: { CreatedBy: { FederationIdentifier: { eq: 'test-user' } } }, + }); + expect(JSON.parse(mockFetch.mock.calls[1][1].body).variables.after).toBe( + 'next' + ); +}); +it('treats GraphQL errors with HTTP 200 as failures, even with partial data', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + ...(await page().json()), + errors: [{ message: 'Missing CRM contact' }], + }), + }); + await expect(fetchSupportCases(auth, 'stage')).rejects.toThrow('GraphQL'); +}); +it('rejects HTTP failures', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 403 }); + await expect(fetchSupportCases(auth, 'stage')).rejects.toThrow('403'); +}); +it('distinguishes empty cases from malformed responses', async () => { + mockFetch.mockResolvedValueOnce(page([])); + await expect(fetchSupportCases(auth, 'stage')).resolves.toEqual([]); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: null }), + }); + await expect(fetchSupportCases(auth, 'stage')).rejects.toThrow('Invalid'); +}); +it('does not send an unscoped request without a username', async () => { + getUser.mockResolvedValue(undefined); + await expect(fetchSupportCases(auth, 'stage')).rejects.toThrow('signed-in'); + expect(mockFetch).not.toHaveBeenCalled(); +}); +it('does not request cases without a token', async () => { + getToken.mockResolvedValue(undefined); + await expect(fetchSupportCases(auth, 'stage')).rejects.toThrow( + 'access token' + ); + expect(mockFetch).not.toHaveBeenCalled(); +}); +it('rejects repeated pagination cursors rather than looping', async () => { + mockFetch.mockResolvedValue(page([node], true, 'same')); + await expect(fetchSupportCases(auth, 'stage')).rejects.toThrow('pagination'); + expect(mockFetch).toHaveBeenCalledTimes(2); +}); +it('preserves environment selection', () => { + expect(getSupportCasesUrl('frhStage')).toBe( + 'https://graphql.stage.redhat.com' + ); + expect(getSupportCasesUrl('prod')).toBe('https://graphql.redhat.com'); +}); diff --git a/src/utils/fetchSupportCases.ts b/src/utils/fetchSupportCases.ts new file mode 100644 index 00000000..a772a6b1 --- /dev/null +++ b/src/utils/fetchSupportCases.ts @@ -0,0 +1,134 @@ +import type { ChromeAPI } from '@redhat-cloud-services/types'; + +export interface SupportCase { + id: string; + caseNumber: string; + summary: string; + lastModifiedById: string; + lastModifiedDate: string; + severity: string; + status: string; +} + +type Value = { value: string | null } | null; +interface CaseNode { + Id: string; + CaseNumber__c: Value; + Subject: Value; + Status: Value; + Priority: Value; + LastModifiedDate: Value; + LastModifiedBy: { Name: Value } | null; +} +interface CaseResponse { + errors?: { message: string }[]; + data?: { + redhat_support_uiapi?: { + query?: { + RedHatSupportCase?: { + edges: { node: CaseNode }[]; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + }; + }; + }; + }; +} + +export const supportCasesQuery = `query ConsoleSupportCases( + $after: String, $where: RedHatSupportCase_Filter +) { + redhat_support_uiapi { + query { + RedHatSupportCase(first: 100, after: $after, where: $where, + orderBy: {LastModifiedDate: {order: DESC}}) { + edges { node { + Id + CaseNumber__c { value } + Subject { value } + Status { value } + Priority { value } + LastModifiedDate { value } + LastModifiedBy { Name { value } } + } } + pageInfo { hasNextPage endCursor } + } + } + } +}`; + +export function getSupportCasesUrl(environment: string) { + // Preserve the existing stage/frhStage environment selection. + return environment === 'stage' || environment === 'frhStage' + ? 'https://graphql.stage.redhat.com' + : 'https://graphql.redhat.com'; +} + +export async function fetchSupportCases( + auth: ChromeAPI['auth'], + environment: string, + signal?: AbortSignal +): Promise { + const user = await auth.getUser(); + const username = user?.identity.user?.username; + if (!username) throw new Error('Support cases require a signed-in user'); + const cases: SupportCase[] = []; + const cursors = new Set(); + let after: string | null = null; + // Fetch every page before applying local filters or showing a total count. + do { + signal?.throwIfAborted(); + const token = await auth.getToken(); + if (!token) throw new Error('Support cases require an access token'); + const response = await fetch(getSupportCasesUrl(environment), { + method: 'POST', + signal, + credentials: 'omit', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'apollographql-client-name': 'learning-resources', + 'apollographql-client-version': '1.1.0', + 'X-Chatter-Entity-Encoding': 'false', + }, + body: JSON.stringify({ + query: supportCasesQuery, + variables: { + after, + where: { CreatedBy: { FederationIdentifier: { eq: username } } }, + }, + }), + }); + if (!response.ok) + throw new Error(`Support cases request failed (${response.status})`); + const payload: CaseResponse = await response.json(); + // A GraphQL failure can arrive with HTTP 200 and partial data. + if (payload.errors?.length) + throw new Error('Support cases GraphQL request failed'); + const connection = + payload.data?.redhat_support_uiapi?.query?.RedHatSupportCase; + if ( + !connection || + !Array.isArray(connection.edges) || + !connection.pageInfo + ) { + throw new Error('Invalid support cases response'); + } + cases.push( + ...connection.edges.map(({ node }) => ({ + id: node.Id, + caseNumber: node.CaseNumber__c?.value ?? '', + summary: node.Subject?.value ?? '', + lastModifiedById: node.LastModifiedBy?.Name?.value ?? '', + lastModifiedDate: node.LastModifiedDate?.value ?? '', + severity: node.Priority?.value ?? '', + status: node.Status?.value ?? '', + })) + ); + if (!connection.pageInfo.hasNextPage) return cases; + after = connection.pageInfo.endCursor; + if (!after || cursors.has(after)) + throw new Error('Invalid support cases pagination cursor'); + cursors.add(after); + } while (after); + return cases; +}