From 519d6b74c5dc4f5c99a18ea5a6a7117503dc8098 Mon Sep 17 00:00:00 2001 From: Hossam Farid Date: Tue, 21 Jul 2026 16:48:52 -0400 Subject: [PATCH 1/8] feat(git-service): adding initial frontend mockup --- .../creator/CreatorYAMLView.test.tsx | 231 ++++++++++++++++ src/components/creator/CreatorYAMLView.tsx | 255 ++++++++++++++++++ src/utils/createQuickstartPR.test.ts | 181 +++++++++++++ src/utils/createQuickstartPR.ts | 75 ++++++ 4 files changed, 742 insertions(+) create mode 100644 src/utils/createQuickstartPR.test.ts create mode 100644 src/utils/createQuickstartPR.ts diff --git a/src/components/creator/CreatorYAMLView.test.tsx b/src/components/creator/CreatorYAMLView.test.tsx index 93b1fc43..e116befe 100644 --- a/src/components/creator/CreatorYAMLView.test.tsx +++ b/src/components/creator/CreatorYAMLView.test.tsx @@ -11,6 +11,12 @@ import { CreatorWizardContext } from './context'; import { CreatorFiles } from './types'; import { DEFAULT_QUICKSTART_YAML } from '../../data/quickstart-templates'; import { ExtendedQuickstart } from '../../utils/fetchQuickstarts'; +import { + createQuickstartPR, + quickstartExists, + listRepoQuickstarts, + getRepoQuickstartContent, +} from '../../utils/createQuickstartPR'; // Mock downloadFile from frontend-components-utilities const mockDownloadFile = jest.fn(); @@ -21,6 +27,23 @@ jest.mock( }) ); +// Mock useChrome for SSO user email (co-authored-by) +const mockGetUser = jest.fn().mockResolvedValue({ + identity: { + user: { + email: 'testuser@redhat.com', + first_name: 'Test', + last_name: 'User', + }, + }, +}); +jest.mock('@redhat-cloud-services/frontend-components/useChrome', () => ({ + __esModule: true, + useChrome: () => ({ + auth: { getUser: mockGetUser }, + }), +})); + // Mock Monaco Editor — render a simple textarea that mirrors onChange behavior jest.mock('@monaco-editor/react', () => { const MockEditor = ({ @@ -40,6 +63,18 @@ jest.mock('@monaco-editor/react', () => { return { __esModule: true, default: MockEditor }; }); +jest.mock('../../utils/createQuickstartPR', () => ({ + createQuickstartPR: jest.fn(), + quickstartExists: jest.fn().mockResolvedValue(false), + listRepoQuickstarts: jest.fn().mockResolvedValue([]), + getRepoQuickstartContent: jest.fn().mockResolvedValue({ name: '', files: [] }), +})); + +const mockedCreatePR = createQuickstartPR as jest.MockedFunction; +const mockedQuickstartExists = quickstartExists as jest.MockedFunction; +const mockedListRepoQuickstarts = listRepoQuickstarts as jest.MockedFunction; +const mockedGetRepoQuickstartContent = getRepoQuickstartContent as jest.MockedFunction; + const MOCK_FILES: CreatorFiles = [ { name: 'metadata.yaml', content: 'kind: QuickStarts\nname: test\n' }, { name: 'test.yaml', content: 'spec:\n displayName: Test\n' }, @@ -632,4 +667,200 @@ spec: ); }); }); + + describe('Create PR button', () => { + it('renders Create PR button', () => { + renderWithContext(); + expect( + screen.getByRole('button', { name: /create pr/i }) + ).toBeInTheDocument(); + }); + + it('disables Create PR button when YAML has parse error', () => { + renderWithContext(); + + const editor = screen.getByTestId('mock-monaco-editor'); + fireEvent.change(editor, { target: { value: 'invalid: [unclosed' } }); + act(() => { jest.advanceTimersByTime(200); }); + + const prBtn = screen.getByRole('button', { name: /create pr/i }); + expect(prBtn).toBeDisabled(); + }); + + it('shows success alert with PR link on success', async () => { + mockedCreatePR.mockResolvedValueOnce({ + prUrl: 'https://github.com/org/repo/pull/99', + branchName: 'qs-create-my-qs-123', + commitSha: 'abc123', + status: 'created', + }); + + renderWithContext(); + + const editor = screen.getByTestId('mock-monaco-editor'); + fireEvent.change(editor, { + target: { value: 'metadata:\n name: my-qs\nspec:\n displayName: My QS\n description: Desc\n' }, + }); + act(() => { jest.advanceTimersByTime(200); }); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /create pr/i })).not.toBeDisabled(); + }); + + fireEvent.click(screen.getByRole('button', { name: /create pr/i })); + + await waitFor(() => { + expect(screen.getByText('https://github.com/org/repo/pull/99')).toBeInTheDocument(); + }); + }); + + it('includes co-authored-by from SSO user in commit message', async () => { + mockedCreatePR.mockResolvedValueOnce({ + prUrl: 'https://github.com/org/repo/pull/99', + branchName: 'qs-create-my-qs-123', + commitSha: 'abc123', + status: 'created', + }); + + renderWithContext(); + + const editor = screen.getByTestId('mock-monaco-editor'); + fireEvent.change(editor, { + target: { value: 'metadata:\n name: my-qs\nspec:\n displayName: My QS\n description: Desc\n' }, + }); + act(() => { jest.advanceTimersByTime(200); }); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /create pr/i })).not.toBeDisabled(); + }); + + fireEvent.click(screen.getByRole('button', { name: /create pr/i })); + + await waitFor(() => { + expect(mockedCreatePR).toHaveBeenCalled(); + }); + + const metadata = mockedCreatePR.mock.calls[0][1]; + expect(metadata.commitMessage).toContain('Co-authored-by: Test User '); + }); + + it('shows error alert with retry on failure', async () => { + mockedCreatePR.mockRejectedValueOnce(new Error('Network error')); + + renderWithContext(); + + const editor = screen.getByTestId('mock-monaco-editor'); + fireEvent.change(editor, { + target: { value: 'metadata:\n name: my-qs\nspec:\n displayName: My QS\n description: Desc\n' }, + }); + act(() => { jest.advanceTimersByTime(200); }); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /create pr/i })).not.toBeDisabled(); + }); + + fireEvent.click(screen.getByRole('button', { name: /create pr/i })); + + await waitFor(() => { + expect(screen.getByText(/Network error/)).toBeInTheDocument(); + }); + expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument(); + }); + }); + + describe('Mode detection label', () => { + it('shows "Creating" label for new quickstarts', async () => { + mockedQuickstartExists.mockResolvedValue(false); + + renderWithContext(); + const editor = screen.getByTestId('mock-monaco-editor'); + fireEvent.change(editor, { + target: { value: 'metadata:\n name: brand-new-qs\nspec:\n displayName: New\n' }, + }); + act(() => { jest.advanceTimersByTime(200); }); + + await waitFor(() => { + expect(screen.getByText(/Creating: brand-new-qs/)).toBeInTheDocument(); + }); + }); + + it('shows "Updating" label for existing quickstarts', async () => { + mockedQuickstartExists.mockResolvedValue(true); + + renderWithContext(); + const editor = screen.getByTestId('mock-monaco-editor'); + fireEvent.change(editor, { + target: { value: 'metadata:\n name: existing-qs\nspec:\n displayName: Existing\n' }, + }); + act(() => { jest.advanceTimersByTime(200); }); + + await waitFor(() => { + expect(screen.getByText(/Updating: existing-qs/)).toBeInTheDocument(); + }); + }); + }); + + describe('Load from Repo', () => { + it('renders Load from Repo button', () => { + renderWithContext(); + expect( + screen.getByRole('button', { name: /load from repo/i }) + ).toBeInTheDocument(); + }); + + it('opens modal and shows quickstarts list', async () => { + mockedListRepoQuickstarts.mockResolvedValueOnce([ + { name: 'getting-started', displayName: 'Getting Started' }, + { name: 'cost-mgmt', displayName: 'Cost Management' }, + ]); + + renderWithContext(); + fireEvent.click(screen.getByRole('button', { name: /load from repo/i })); + + await waitFor(() => { + expect(screen.getByText('Getting Started')).toBeInTheDocument(); + expect(screen.getByText('Cost Management')).toBeInTheDocument(); + }); + }); + + it('loads quickstart content into editor on selection', async () => { + jest.useRealTimers(); + + mockedListRepoQuickstarts.mockResolvedValueOnce([ + { name: 'getting-started', displayName: 'Getting Started' }, + ]); + const yamlContent = 'metadata:\n name: getting-started\nspec:\n displayName: GS\n'; + mockedGetRepoQuickstartContent.mockResolvedValueOnce({ + name: 'getting-started', + files: [ + { name: 'metadata.yaml', content: 'kind: QuickStarts\n' }, + { name: 'getting-started.yml', content: yamlContent }, + ], + }); + + renderWithContext(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /load from repo/i })); + }); + + await waitFor(() => { + expect(screen.getByText('Getting Started')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Getting Started' })); + + // Flush the async handleLoadFromRepo + React state updates + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(mockedGetRepoQuickstartContent).toHaveBeenCalledWith('getting-started'); + const editor = screen.getByTestId('mock-monaco-editor'); + expect((editor as HTMLTextAreaElement).value).toBe(yamlContent); + }); + }); }); diff --git a/src/components/creator/CreatorYAMLView.tsx b/src/components/creator/CreatorYAMLView.tsx index 9ba77030..a46b26e8 100644 --- a/src/components/creator/CreatorYAMLView.tsx +++ b/src/components/creator/CreatorYAMLView.tsx @@ -9,30 +9,52 @@ import React, { import { Alert, Button, + DataList, + DataListCell, + DataListItem, + DataListItemCells, + DataListItemRow, Divider, Flex, FlexItem, FormGroup, + Label, List, ListItem, MenuToggle, MenuToggleElement, + Modal, + ModalBody, + ModalFooter, + ModalHeader, PageSection, + SearchInput, Select, SelectGroup, SelectList, SelectOption, + Spinner, Tooltip, } from '@patternfly/react-core'; import { + CodeBranchIcon, DownloadIcon, FileImportIcon, UploadIcon, } from '@patternfly/react-icons'; +import { + createQuickstartPR, + getRepoQuickstartContent, + listRepoQuickstarts, + quickstartExists, + PRResponse, + RepoQuickstartEntry, +} from '../../utils/createQuickstartPR'; import Editor from '@monaco-editor/react'; import YAML from 'yaml'; import { QuickStartSpec } from '@patternfly/quickstarts'; import { downloadFile } from '@redhat-cloud-services/frontend-components-utilities/helpers'; +import { useChrome } from '@redhat-cloud-services/frontend-components/useChrome'; import { ExtendedQuickstart } from '../../utils/fetchQuickstarts'; import { CreatorWizardContext } from './context'; import { ALL_KIND_ENTRIES, ItemKind } from './meta'; @@ -418,6 +440,22 @@ const CreatorYAMLView: React.FC = ({ bundles: bundleOptions, }) => { const { files } = useContext(CreatorWizardContext); + const chrome = useChrome(); + + // Hardcoded to true for local dev — revert to useFlag before opening PR: + const showCreatePR = useFlag('platform.learning-resources.quickstarts.create-pr'); + // const showCreatePR = true; + + const [prLoading, setPrLoading] = useState(false); + const [prResult, setPrResult] = useState(null); + const [prError, setPrError] = useState(null); + const [isUpdate, setIsUpdate] = useState(false); + const [parsedName, setParsedName] = useState(null); + const [repoModalOpen, setRepoModalOpen] = useState(false); + const [repoQuickstarts, setRepoQuickstarts] = useState([]); + const [repoLoading, setRepoLoading] = useState(false); + const [repoSearch, setRepoSearch] = useState(''); + const [repoError, setRepoError] = useState(null); // On mount, serialize current state to YAML if we have data from the wizard. // This enables switching wizard → YAML without losing data. @@ -510,6 +548,17 @@ const CreatorYAMLView: React.FC = ({ // Update state setParseError(null); + // Track parsed name for mode detection + const name = metadata.name || null; + if (name !== parsedName) { + setParsedName(name); + if (name && name !== 'untitled-quickstart') { + quickstartExists(name).then(setIsUpdate); + } else { + setIsUpdate(false); + } + } + // Detect and propagate kind from spec.type const detectedKind = detectKind(spec); if (onChangeKind) { @@ -645,6 +694,77 @@ const CreatorYAMLView: React.FC = ({ }); }; + const handleCreatePR = async () => { + if (!parsedName || prLoading) return; + setPrLoading(true); + setPrResult(null); + setPrError(null); + try { + const timestamp = Date.now(); + const prefix = isUpdate ? 'update' : 'create'; + let commitMessage = `feat(quickstarts): ${prefix} ${parsedName}`; + const user = await chrome.auth.getUser(); + const identity = user?.identity?.user; + if (identity?.email) { + const name = [identity.first_name, identity.last_name].filter(Boolean).join(' ') || identity.email; + commitMessage += `\n\nCo-authored-by: ${name} <${identity.email}>`; + } + const result = await createQuickstartPR(files, { + branchName: `qs-${prefix}-${parsedName}-${timestamp}`, + commitMessage, + prTitle: `feat(quickstarts): ${prefix} ${parsedName}`, + prBody: `${isUpdate ? 'Updating' : 'Adding new'} quickstart via the Quickstarts Creator tool.\n\nDirectory: docs/quickstarts/${parsedName}/`, + isUpdate, + directoryName: parsedName, + ...(isUpdate ? { existingPath: `docs/quickstarts/${parsedName}/` } : {}), + }); + setPrResult(result); + } catch (err) { + setPrError(err instanceof Error ? err.message : 'Failed to create PR'); + } finally { + setPrLoading(false); + } + }; + + const handleOpenRepoModal = async () => { + setRepoModalOpen(true); + setRepoLoading(true); + setRepoError(null); + setRepoSearch(''); + try { + const entries = await listRepoQuickstarts(); + setRepoQuickstarts(entries); + } catch (err) { + setRepoError(err instanceof Error ? err.message : 'Failed to load quickstarts'); + } finally { + setRepoLoading(false); + } + }; + + const handleLoadFromRepo = async (name: string) => { + setRepoModalOpen(false); + try { + const content = await getRepoQuickstartContent(name); + const yamlFile = content.files.find( + (f) => + (f.name.endsWith('.yml') || f.name.endsWith('.yaml')) && + f.name !== 'metadata.yaml' + ); + if (yamlFile) { + setYamlContent(yamlFile.content); + parseAndUpdateQuickstart(yamlFile.content); + } + } catch (err) { + setParseError(err instanceof Error ? err.message : 'Failed to load quickstart'); + } + }; + + const filteredRepoQuickstarts = repoQuickstarts.filter( + (qs) => + qs.name.toLowerCase().includes(repoSearch.toLowerCase()) || + qs.displayName.toLowerCase().includes(repoSearch.toLowerCase()) + ); + /** * Update the metadata.tags section inside the current YAML editor content * when bundles or tags are changed via the UI selectors. @@ -696,6 +816,9 @@ const CreatorYAMLView: React.FC = ({ const canDownload = isUserContent(yamlContent) && !parseError && files.length > 0; + const canCreatePR = + canDownload && !!parsedName && parsedName !== 'untitled-quickstart'; + return ( {parseError && ( @@ -724,9 +847,37 @@ const CreatorYAMLView: React.FC = ({ )} + {prResult && ( + setPrResult(null)}>✕} + > + + {prResult.prUrl} + + + )} + {prError && ( + setPrError(null)}>✕} + > + {prError}{' '} + + + )} + {showCreatePR && ( + <> + + + + + + + + + + )} + {showCreatePR && parsedName && parsedName !== 'untitled-quickstart' && ( + + + + )} {hasMetadataSelectors && ( = ({ }} /> + {showCreatePR && ( + setRepoModalOpen(false)} + aria-label="Load from Repository" + variant="medium" + > + + + setRepoSearch(value)} + onClear={() => setRepoSearch('')} + className="pf-v6-u-mb-md" + /> + {repoLoading && } + {repoError && ( + + {repoError} + + )} + {!repoLoading && !repoError && ( + + {filteredRepoQuickstarts.map((qs) => ( + + + + + , + ]} + /> + + + ))} + {filteredRepoQuickstarts.length === 0 && ( + + + + No quickstarts found + , + ]} + /> + + + )} + + )} + + + + + + )} ); }; diff --git a/src/utils/createQuickstartPR.test.ts b/src/utils/createQuickstartPR.test.ts new file mode 100644 index 00000000..0ce9da01 --- /dev/null +++ b/src/utils/createQuickstartPR.test.ts @@ -0,0 +1,181 @@ +import axios from 'axios'; +import { + createQuickstartPR, + getRepoQuickstartContent, + listRepoQuickstarts, + PRFile, + PRMetadata, +} from './createQuickstartPR'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +const MOCK_FILES: PRFile[] = [ + { name: 'metadata.yaml', content: 'kind: QuickStarts\nmetadata:\n name: my-qs\n' }, + { name: 'my-qs.yaml', content: 'spec:\n displayName: My QS\n' }, +]; + +const MOCK_METADATA: PRMetadata = { + branchName: 'qs-create-my-qs-1234567890', + commitMessage: 'feat(quickstarts): add my-qs', + prTitle: 'feat(quickstarts): add my-qs', + prBody: 'Adding new quickstart via the Quickstarts Creator tool.\n\nDirectory: docs/quickstarts/my-qs/', + isUpdate: false, + directoryName: 'my-qs', +}; + +const MOCK_RESPONSE = { + prUrl: 'https://github.com/org/repo/pull/42', + branchName: 'qs-create-my-qs-1234567890', + commitSha: 'abc123def456', + status: 'created', +}; + +describe('createQuickstartPR', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('POSTs to /api/quickstarts/v1/pull-request with files and metadata', async () => { + mockedAxios.post.mockResolvedValueOnce({ data: { data: MOCK_RESPONSE } }); + + const result = await createQuickstartPR(MOCK_FILES, MOCK_METADATA); + + expect(mockedAxios.post).toHaveBeenCalledWith( + '/api/quickstarts/v1/pull-request', + { files: MOCK_FILES, metadata: MOCK_METADATA } + ); + expect(result).toEqual(MOCK_RESPONSE); + }); + + it('returns the PR URL, branchName, commitSha, and status from the response', async () => { + mockedAxios.post.mockResolvedValueOnce({ data: { data: MOCK_RESPONSE } }); + + const result = await createQuickstartPR(MOCK_FILES, MOCK_METADATA); + + expect(result.prUrl).toBe('https://github.com/org/repo/pull/42'); + expect(result.branchName).toBe('qs-create-my-qs-1234567890'); + expect(result.commitSha).toBe('abc123def456'); + expect(result.status).toBe('created'); + }); + + it('propagates network errors', async () => { + mockedAxios.post.mockRejectedValueOnce(new Error('Network error')); + + await expect(createQuickstartPR(MOCK_FILES, MOCK_METADATA)).rejects.toThrow('Network error'); + }); + + it('propagates 4xx/5xx errors from the API', async () => { + const apiError = { response: { status: 502, data: { msg: 'git-service unreachable' } } }; + mockedAxios.post.mockRejectedValueOnce(apiError); + + await expect(createQuickstartPR(MOCK_FILES, MOCK_METADATA)).rejects.toEqual(apiError); + }); + + it('sends isUpdate: false for new quickstarts', async () => { + mockedAxios.post.mockResolvedValueOnce({ data: { data: MOCK_RESPONSE } }); + + await createQuickstartPR(MOCK_FILES, { ...MOCK_METADATA, isUpdate: false }); + + const body = mockedAxios.post.mock.calls[0][1] as { metadata: PRMetadata }; + expect(body.metadata.isUpdate).toBe(false); + expect(body.metadata.existingPath).toBeUndefined(); + }); + + it('forwards existingPath and isUpdate: true for updates (48694 path)', async () => { + mockedAxios.post.mockResolvedValueOnce({ data: { data: { ...MOCK_RESPONSE, status: 'updated' } } }); + + const updateMetadata: PRMetadata = { + ...MOCK_METADATA, + isUpdate: true, + existingPath: 'docs/quickstarts/my-qs/', + }; + + const result = await createQuickstartPR(MOCK_FILES, updateMetadata); + + const body = mockedAxios.post.mock.calls[0][1] as { metadata: PRMetadata }; + expect(body.metadata.isUpdate).toBe(true); + expect(body.metadata.existingPath).toBe('docs/quickstarts/my-qs/'); + expect(result.status).toBe('updated'); + }); +}); + +describe('listRepoQuickstarts', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('GETs /api/quickstarts/v1/repo-quickstarts and returns quickstarts array', async () => { + const mockQuickstarts = [ + { name: 'getting-started', displayName: 'Getting Started' }, + { name: 'cost-management', displayName: 'Cost Management' }, + ]; + mockedAxios.get.mockResolvedValueOnce({ + data: { data: { quickstarts: mockQuickstarts } }, + }); + + const result = await listRepoQuickstarts(); + + expect(mockedAxios.get).toHaveBeenCalledWith( + '/api/quickstarts/v1/repo-quickstarts' + ); + expect(result).toEqual(mockQuickstarts); + expect(result).toHaveLength(2); + }); + + it('propagates errors', async () => { + mockedAxios.get.mockRejectedValueOnce(new Error('Network error')); + + await expect(listRepoQuickstarts()).rejects.toThrow('Network error'); + }); +}); + +describe('getRepoQuickstartContent', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('GETs /api/quickstarts/v1/repo-quickstarts/{name} and returns content', async () => { + const mockContent = { + name: 'getting-started', + files: [ + { name: 'metadata.yaml', content: 'kind: QuickStarts\n' }, + { name: 'getting-started.yml', content: 'spec:\n displayName: GS\n' }, + ], + }; + mockedAxios.get.mockResolvedValueOnce({ + data: { data: mockContent }, + }); + + const result = await getRepoQuickstartContent('getting-started'); + + expect(mockedAxios.get).toHaveBeenCalledWith( + '/api/quickstarts/v1/repo-quickstarts/getting-started' + ); + expect(result.name).toBe('getting-started'); + expect(result.files).toHaveLength(2); + }); + + it('encodes the quickstart name in the URL', async () => { + mockedAxios.get.mockResolvedValueOnce({ + data: { data: { name: 'my qs', files: [] } }, + }); + + await getRepoQuickstartContent('my qs'); + + expect(mockedAxios.get).toHaveBeenCalledWith( + '/api/quickstarts/v1/repo-quickstarts/my%20qs' + ); + }); + + it('propagates 404 errors', async () => { + const apiError = { + response: { status: 404, data: { msg: 'quickstart not found' } }, + }; + mockedAxios.get.mockRejectedValueOnce(apiError); + + await expect(getRepoQuickstartContent('nonexistent')).rejects.toEqual( + apiError + ); + }); +}); diff --git a/src/utils/createQuickstartPR.ts b/src/utils/createQuickstartPR.ts new file mode 100644 index 00000000..25f693de --- /dev/null +++ b/src/utils/createQuickstartPR.ts @@ -0,0 +1,75 @@ +import axios from 'axios'; + +export const API_BASE = '/api/quickstarts/v1'; + +export interface PRFile { + name: string; + content: string; +} + +export interface PRMetadata { + branchName: string; + commitMessage: string; + prTitle: string; + prBody: string; + isUpdate: boolean; + existingPath?: string; + directoryName?: string; +} + +export interface PRResponse { + prUrl: string; + branchName: string; + commitSha: string; + status: string; +} + +export const quickstartExists = async (name: string): Promise => { + if (!name || name === 'untitled-quickstart') return false; + try { + const { data } = await axios.get<{ data: { content: unknown }[] }>( + `${API_BASE}/quickstarts`, + { params: { name, limit: 1 } } + ); + return data.data.length > 0; + } catch { + return false; + } +}; + +export const createQuickstartPR = async ( + files: PRFile[], + metadata: PRMetadata +): Promise => { + const { data } = await axios.post<{ data: PRResponse }>( + `${API_BASE}/pull-request`, + { files, metadata } + ); + return data.data; +}; + +export interface RepoQuickstartEntry { + name: string; + displayName: string; +} + +export interface RepoQuickstartContent { + name: string; + files: PRFile[]; +} + +export const listRepoQuickstarts = async (): Promise => { + const { data } = await axios.get<{ + data: { quickstarts: RepoQuickstartEntry[] }; + }>(`${API_BASE}/repo-quickstarts`); + return data.data.quickstarts; +}; + +export const getRepoQuickstartContent = async ( + name: string +): Promise => { + const { data } = await axios.get<{ data: RepoQuickstartContent }>( + `${API_BASE}/repo-quickstarts/${encodeURIComponent(name)}` + ); + return data.data; +}; From 78b73661d96395611ca2193fd05fece20930c49b Mon Sep 17 00:00:00 2001 From: Hossam Farid Date: Tue, 21 Jul 2026 17:21:30 -0400 Subject: [PATCH 2/8] feat(git-service): restructure creator tab --- src/components/creator/CreatorYAMLView.scss | 1 + src/components/creator/CreatorYAMLView.tsx | 148 ++++++++++---------- 2 files changed, 78 insertions(+), 71 deletions(-) diff --git a/src/components/creator/CreatorYAMLView.scss b/src/components/creator/CreatorYAMLView.scss index bf42c4a9..713a1cb2 100644 --- a/src/components/creator/CreatorYAMLView.scss +++ b/src/components/creator/CreatorYAMLView.scss @@ -11,6 +11,7 @@ height: 600px; max-height: 70vh; overflow: hidden; + margin-bottom: var(--pf-t--global--spacer--md); } } diff --git a/src/components/creator/CreatorYAMLView.tsx b/src/components/creator/CreatorYAMLView.tsx index a46b26e8..da7bcf98 100644 --- a/src/components/creator/CreatorYAMLView.tsx +++ b/src/components/creator/CreatorYAMLView.tsx @@ -443,8 +443,8 @@ const CreatorYAMLView: React.FC = ({ const chrome = useChrome(); // Hardcoded to true for local dev — revert to useFlag before opening PR: - const showCreatePR = useFlag('platform.learning-resources.quickstarts.create-pr'); - // const showCreatePR = true; + // const showCreatePR = useFlag('platform.learning-resources.quickstarts.create-pr'); + const showCreatePR = true; const [prLoading, setPrLoading] = useState(false); const [prResult, setPrResult] = useState(null); @@ -847,33 +847,6 @@ const CreatorYAMLView: React.FC = ({ )} - {prResult && ( - setPrResult(null)}>✕} - > - - {prResult.prUrl} - - - )} - {prError && ( - setPrError(null)}>✕} - > - {prError}{' '} - - - )} = ({ data-testid="yaml-file-input" /> - - + {showCreatePR && ( + - - - {showCreatePR && ( - <> - - - - - - - - - + )} {showCreatePR && parsedName && parsedName !== 'untitled-quickstart' && ( @@ -1008,6 +946,74 @@ const CreatorYAMLView: React.FC = ({ }} /> + {prResult && ( + setPrResult(null)}>✕} + > + + {prResult.prUrl} + + + )} + {prError && ( + setPrError(null)}>✕} + > + {prError}{' '} + + + )} + + + + + + + {showCreatePR && ( + + + + + + )} + {showCreatePR && ( Date: Tue, 21 Jul 2026 18:11:44 -0400 Subject: [PATCH 3/8] feat(git-service): add PR button to wizard --- src/components/creator/CreatorWizard.tsx | 114 +++++++++++++++++---- src/components/creator/CreatorYAMLView.tsx | 64 +++--------- src/components/creator/useCreatePR.ts | 81 +++++++++++++++ 3 files changed, 190 insertions(+), 69 deletions(-) create mode 100644 src/components/creator/useCreatePR.ts diff --git a/src/components/creator/CreatorWizard.tsx b/src/components/creator/CreatorWizard.tsx index a2c35216..986aa474 100644 --- a/src/components/creator/CreatorWizard.tsx +++ b/src/components/creator/CreatorWizard.tsx @@ -1,4 +1,5 @@ import { + Alert, Banner, Button, ClipboardCopy, @@ -6,6 +7,7 @@ import { Content, Flex, FlexItem, + Spinner, Stack, StackItem, Tab, @@ -14,6 +16,7 @@ import { Title, } from '@patternfly/react-core'; import CheckCircleIcon from '@patternfly/react-icons/dist/dynamic/icons/check-circle-icon'; +import CodeBranchIcon from '@patternfly/react-icons/dist/dynamic/icons/code-branch-icon'; import DownloadIcon from '@patternfly/react-icons/dist/dynamic/icons/download-icon'; import React, { Fragment, @@ -56,6 +59,7 @@ import { CreatorFiles } from './types'; import { FilterData } from '../../utils/FiltersCategoryInterface'; import TagsSelector from './TagsSelector'; import CreatorYAMLView from './CreatorYAMLView'; +import { useCreatePR } from './useCreatePR'; export type CreatorWizardProps = { onChangeKind: (newKind: ItemKind | null) => void; @@ -216,6 +220,25 @@ const PropUpdater = ({ const FileDownload = () => { const { files } = useContext(CreatorWizardContext); + const quickstartName = useMemo(() => { + const yamlFile = files.find( + (f) => f.name !== 'metadata.yaml' && f.name.endsWith('.yaml') + ); + if (!yamlFile) return null; + const name = yamlFile.name.replace(/\.yaml$/, ''); + return name || null; + }, [files]); + + const { + prLoading, + prResult, + prError, + canCreatePR, + handleCreatePR, + setPrResult, + setPrError, + } = useCreatePR(quickstartName); + function doDownload(file: { content: string; name: string }) { const dotIndex = file.name.lastIndexOf('.'); const baseName = @@ -240,30 +263,85 @@ const FileDownload = () => { - Download these files and use them to create the learning resource PR - in the{' '} - - {' '} - correct repo - - . + Download these files or submit them directly as a pull request. - + + + + + + + + + {prResult && ( + + setPrResult(null)} + > + ✕ + + } + > + + {prResult.prUrl} + + + + )} + {prError && ( + + setPrError(null)} + > + ✕ + + } + > + {prError}{' '} + + + + )} + {files.map((file) => ( = ({ bundles: bundleOptions, }) => { const { files } = useContext(CreatorWizardContext); - const chrome = useChrome(); // Hardcoded to true for local dev — revert to useFlag before opening PR: // const showCreatePR = useFlag('platform.learning-resources.quickstarts.create-pr'); const showCreatePR = true; - const [prLoading, setPrLoading] = useState(false); - const [prResult, setPrResult] = useState(null); - const [prError, setPrError] = useState(null); - const [isUpdate, setIsUpdate] = useState(false); const [parsedName, setParsedName] = useState(null); + const { + prLoading, + prResult, + prError, + isUpdate, + canCreatePR, + handleCreatePR, + setPrResult, + setPrError, + } = useCreatePR(parsedName); + const [repoModalOpen, setRepoModalOpen] = useState(false); const [repoQuickstarts, setRepoQuickstarts] = useState([]); const [repoLoading, setRepoLoading] = useState(false); @@ -548,15 +551,9 @@ const CreatorYAMLView: React.FC = ({ // Update state setParseError(null); - // Track parsed name for mode detection const name = metadata.name || null; if (name !== parsedName) { setParsedName(name); - if (name && name !== 'untitled-quickstart') { - quickstartExists(name).then(setIsUpdate); - } else { - setIsUpdate(false); - } } // Detect and propagate kind from spec.type @@ -694,38 +691,6 @@ const CreatorYAMLView: React.FC = ({ }); }; - const handleCreatePR = async () => { - if (!parsedName || prLoading) return; - setPrLoading(true); - setPrResult(null); - setPrError(null); - try { - const timestamp = Date.now(); - const prefix = isUpdate ? 'update' : 'create'; - let commitMessage = `feat(quickstarts): ${prefix} ${parsedName}`; - const user = await chrome.auth.getUser(); - const identity = user?.identity?.user; - if (identity?.email) { - const name = [identity.first_name, identity.last_name].filter(Boolean).join(' ') || identity.email; - commitMessage += `\n\nCo-authored-by: ${name} <${identity.email}>`; - } - const result = await createQuickstartPR(files, { - branchName: `qs-${prefix}-${parsedName}-${timestamp}`, - commitMessage, - prTitle: `feat(quickstarts): ${prefix} ${parsedName}`, - prBody: `${isUpdate ? 'Updating' : 'Adding new'} quickstart via the Quickstarts Creator tool.\n\nDirectory: docs/quickstarts/${parsedName}/`, - isUpdate, - directoryName: parsedName, - ...(isUpdate ? { existingPath: `docs/quickstarts/${parsedName}/` } : {}), - }); - setPrResult(result); - } catch (err) { - setPrError(err instanceof Error ? err.message : 'Failed to create PR'); - } finally { - setPrLoading(false); - } - }; - const handleOpenRepoModal = async () => { setRepoModalOpen(true); setRepoLoading(true); @@ -816,9 +781,6 @@ const CreatorYAMLView: React.FC = ({ const canDownload = isUserContent(yamlContent) && !parseError && files.length > 0; - const canCreatePR = - canDownload && !!parsedName && parsedName !== 'untitled-quickstart'; - return ( {parseError && ( @@ -1005,7 +967,7 @@ const CreatorYAMLView: React.FC = ({ icon={prLoading ? : } onClick={handleCreatePR} size="sm" - isDisabled={!canCreatePR || prLoading} + isDisabled={!canCreatePR || !canDownload || prLoading} isLoading={prLoading} > {prLoading ? 'Creating PR...' : 'Create PR'} diff --git a/src/components/creator/useCreatePR.ts b/src/components/creator/useCreatePR.ts new file mode 100644 index 00000000..bf44d089 --- /dev/null +++ b/src/components/creator/useCreatePR.ts @@ -0,0 +1,81 @@ +import { useContext, useEffect, useState } from 'react'; +import { useChrome } from '@redhat-cloud-services/frontend-components/useChrome'; +import { + createQuickstartPR, + PRResponse, + quickstartExists, +} from '../../utils/createQuickstartPR'; +import { CreatorWizardContext } from './context'; + +export function useCreatePR(quickstartName: string | null) { + const { files } = useContext(CreatorWizardContext); + const chrome = useChrome(); + + const [prLoading, setPrLoading] = useState(false); + const [prResult, setPrResult] = useState(null); + const [prError, setPrError] = useState(null); + const [isUpdate, setIsUpdate] = useState(false); + + useEffect(() => { + if (quickstartName && quickstartName !== 'untitled-quickstart') { + quickstartExists(quickstartName).then(setIsUpdate); + } else { + setIsUpdate(false); + } + }, [quickstartName]); + + const canCreatePR = + files.length > 0 && + !!quickstartName && + quickstartName !== 'untitled-quickstart'; + + const handleCreatePR = async () => { + if (!quickstartName || prLoading) return; + setPrLoading(true); + setPrResult(null); + setPrError(null); + try { + const timestamp = Date.now(); + const prefix = isUpdate ? 'update' : 'create'; + let commitMessage = `feat(quickstarts): ${prefix} ${quickstartName}`; + const user = await chrome.auth.getUser(); + const identity = user?.identity?.user; + if (identity?.email) { + const name = + [identity.first_name, identity.last_name] + .filter(Boolean) + .join(' ') || identity.email; + commitMessage += `\n\nCo-authored-by: ${name} <${identity.email}>`; + } + const result = await createQuickstartPR(files, { + branchName: `qs-${prefix}-${quickstartName}-${timestamp}`, + commitMessage, + prTitle: `feat(quickstarts): ${prefix} ${quickstartName}`, + prBody: `${ + isUpdate ? 'Updating' : 'Adding new' + } quickstart via the Quickstarts Creator tool.\n\nDirectory: docs/quickstarts/${quickstartName}/`, + isUpdate, + directoryName: quickstartName, + ...(isUpdate + ? { existingPath: `docs/quickstarts/${quickstartName}/` } + : {}), + }); + setPrResult(result); + } catch (err) { + setPrError(err instanceof Error ? err.message : 'Failed to create PR'); + } finally { + setPrLoading(false); + } + }; + + return { + prLoading, + prResult, + prError, + isUpdate, + canCreatePR, + handleCreatePR, + setPrResult, + setPrError, + }; +} From f7c53943c29c5313a98321c2859a05670124f7bf Mon Sep 17 00:00:00 2001 From: Hossam Farid Date: Fri, 24 Jul 2026 13:10:16 -0400 Subject: [PATCH 4/8] feat(git-service): add new step for the wizard and clean up structure --- src/Creator.tsx | 18 +- src/components/creator/CreatorWizard.tsx | 15 + .../creator/CreatorYAMLView.test.tsx | 5 +- src/components/creator/CreatorYAMLView.tsx | 32 +- src/components/creator/SourceSelector.tsx | 312 ++++++++++++++++++ src/components/creator/meta.ts | 2 +- src/components/creator/schema.tsx | 5 +- src/components/creator/steps/common.ts | 1 + src/components/creator/steps/kind.tsx | 2 +- src/components/creator/steps/source.tsx | 25 ++ src/utils/createQuickstartPR.test.ts | 28 +- src/utils/createQuickstartPR.ts | 21 +- 12 files changed, 427 insertions(+), 39 deletions(-) create mode 100644 src/components/creator/SourceSelector.tsx create mode 100644 src/components/creator/steps/source.tsx diff --git a/src/Creator.tsx b/src/Creator.tsx index c205d9e3..74b07f2c 100644 --- a/src/Creator.tsx +++ b/src/Creator.tsx @@ -23,10 +23,6 @@ import fetchFilters from './utils/fetchFilters'; import { ExtendedQuickstart } from './utils/fetchQuickstarts'; import useFilterMap from './hooks/useFilterMap'; -const BASE_METADATA = { - name: 'test-quickstart', -}; - function makeDemoQuickStart( kind: ItemKind | null, baseQuickStart: ExtendedQuickstart @@ -37,7 +33,6 @@ function makeDemoQuickStart( ...baseQuickStart, metadata: { ...baseQuickStart.metadata, - name: 'test-quickstart', ...(kindMeta?.extraMetadata ?? {}), }, }; @@ -89,6 +84,16 @@ const CreatorInternal = ({ })); }; + const updateMetadataName = (name: string) => { + setRawQuickStart((old) => ({ + ...old, + metadata: { + ...old.metadata, + name, + }, + })); + }; + const updateMetadataTags = (tags: Array<{ kind: string; value: string }>) => { setRawQuickStart((old) => ({ ...old, @@ -139,8 +144,8 @@ const CreatorInternal = ({ }); }); updates.metadata = { + name: old.metadata.name, tags: allTags, - ...BASE_METADATA, ...meta.extraMetadata, }; @@ -233,6 +238,7 @@ const CreatorInternal = ({ updateSpec(() => spec); }} onChangeMetadataTags={updateMetadataTags} + onChangeMetadataName={updateMetadataName} filterData={filterData} onChangeBundles={setBundles} onChangeCurrentStage={setCurrentStage} diff --git a/src/components/creator/CreatorWizard.tsx b/src/components/creator/CreatorWizard.tsx index 986aa474..510382ad 100644 --- a/src/components/creator/CreatorWizard.tsx +++ b/src/components/creator/CreatorWizard.tsx @@ -45,6 +45,7 @@ import { NAME_DESCRIPTION, NAME_DURATION, NAME_KIND, + NAME_METADATA_NAME, NAME_PANEL_INTRODUCTION, NAME_PREREQUISITES, NAME_TAGS, @@ -60,6 +61,7 @@ import { FilterData } from '../../utils/FiltersCategoryInterface'; import TagsSelector from './TagsSelector'; import CreatorYAMLView from './CreatorYAMLView'; import { useCreatePR } from './useCreatePR'; +import SourceSelector from './SourceSelector'; export type CreatorWizardProps = { onChangeKind: (newKind: ItemKind | null) => void; @@ -71,6 +73,7 @@ export type CreatorWizardProps = { filterData: FilterData; onChangeTags: (tags: { [kind: string]: string[] }) => void; onChangeMetadataTags: (tags: Array<{ kind: string; value: string }>) => void; + onChangeMetadataName?: (name: string) => void; quickStart?: ExtendedQuickstart; currentBundles?: string[]; currentTags?: { [kind: string]: string[] }; @@ -88,6 +91,7 @@ type UpdaterProps = { onChangeBundles: (bundles: string[]) => void; onChangeQuickStartSpec: (newValue: QuickStartSpec) => void; onChangeTags: CreatorWizardProps['onChangeTags']; + onChangeMetadataName?: (name: string) => void; }; const DEFAULT_TASK_TITLES: string[] = ['']; @@ -116,9 +120,11 @@ const PropUpdater = ({ onChangeTags, onChangeBundles, onChangeQuickStartSpec, + onChangeMetadataName, }: UpdaterProps) => { const bundles = values[NAME_BUNDLES]; const tags = values[NAME_TAGS]; + const metadataName: string | undefined = values[NAME_METADATA_NAME]; useEffect(() => { onChangeBundles(bundles ?? []); @@ -128,6 +134,12 @@ const PropUpdater = ({ onChangeTags(tags ?? {}); }, [tags]); + useEffect(() => { + if (metadataName && onChangeMetadataName) { + onChangeMetadataName(metadataName); + } + }, [metadataName]); + const rawKind: string | undefined = values[NAME_KIND]; const title: string | undefined = values[NAME_TITLE]; const description: string | undefined = values[NAME_DESCRIPTION]; @@ -403,6 +415,7 @@ const CreatorWizard = ({ resetCreator, onChangeTags, onChangeMetadataTags, + onChangeMetadataName, files, filterData, quickStart, @@ -468,6 +481,7 @@ const CreatorWizard = ({ 'lr-task-title-preview': TaskTitlePreview, 'lr-string-array': StringArrayInput, 'lr-tag-filter-selector': TagsSelector, + 'lr-source-selector': SourceSelector, }; return ( @@ -522,6 +536,7 @@ const CreatorWizard = ({ onChangeTags={onChangeTags} onChangeBundles={onChangeBundles} onChangeQuickStartSpec={onChangeQuickStartSpec} + onChangeMetadataName={onChangeMetadataName} /> )} diff --git a/src/components/creator/CreatorYAMLView.test.tsx b/src/components/creator/CreatorYAMLView.test.tsx index e116befe..626b896f 100644 --- a/src/components/creator/CreatorYAMLView.test.tsx +++ b/src/components/creator/CreatorYAMLView.test.tsx @@ -860,7 +860,10 @@ spec: expect(mockedGetRepoQuickstartContent).toHaveBeenCalledWith('getting-started'); const editor = screen.getByTestId('mock-monaco-editor'); - expect((editor as HTMLTextAreaElement).value).toBe(yamlContent); + const editorValue = (editor as HTMLTextAreaElement).value; + expect(editorValue).toContain('kind: QuickStarts'); + expect(editorValue).toContain('name: getting-started'); + expect(editorValue).toContain('displayName: GS'); }); }); }); diff --git a/src/components/creator/CreatorYAMLView.tsx b/src/components/creator/CreatorYAMLView.tsx index 16797da0..471fdc4e 100644 --- a/src/components/creator/CreatorYAMLView.tsx +++ b/src/components/creator/CreatorYAMLView.tsx @@ -223,9 +223,6 @@ function serializeToYaml( ...(quickStart.spec.description ? { description: quickStart.spec.description } : {}), - ...(quickStart.spec.durationMinutes !== undefined - ? { durationMinutes: quickStart.spec.durationMinutes } - : {}), ...(quickStart.spec.type ? { type: { @@ -234,6 +231,9 @@ function serializeToYaml( }, } : {}), + ...(quickStart.spec.durationMinutes !== undefined + ? { durationMinutes: quickStart.spec.durationMinutes } + : {}), ...(quickStart.spec.link ? { link: { @@ -498,6 +498,15 @@ const CreatorYAMLView: React.FC = ({ yamlContentRef.current = yamlContent; }, [yamlContent]); + // Parse initial YAML on mount so parsedName is set when wizard data is present + const initialYamlRef = useRef(true); + useEffect(() => { + if (initialYamlRef.current && isUserContent(yamlContent)) { + parseAndUpdateQuickstart(yamlContent); + } + initialYamlRef.current = false; + }, []); + const configureMonacoEnvironment = () => { // Disable Monaco workers to prevent CDN fetching in CI environments self.MonacoEnvironment = { @@ -716,8 +725,21 @@ const CreatorYAMLView: React.FC = ({ f.name !== 'metadata.yaml' ); if (yamlFile) { - setYamlContent(yamlFile.content); - parseAndUpdateQuickstart(yamlFile.content); + let finalContent = yamlFile.content; + try { + const parsed = YAML.parse(finalContent); + if (parsed && !parsed.kind) { + const { metadata, spec, ...rest } = parsed; + finalContent = YAML.stringify( + { kind: 'QuickStarts', metadata, spec, ...rest }, + { lineWidth: 0 } + ); + } + } catch { + // use raw content if parse fails + } + setYamlContent(finalContent); + parseAndUpdateQuickstart(finalContent); } } catch (err) { setParseError(err instanceof Error ? err.message : 'Failed to load quickstart'); diff --git a/src/components/creator/SourceSelector.tsx b/src/components/creator/SourceSelector.tsx new file mode 100644 index 00000000..b3e9949e --- /dev/null +++ b/src/components/creator/SourceSelector.tsx @@ -0,0 +1,312 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + Alert, + DataList, + DataListCell, + DataListItem, + DataListItemCells, + DataListItemRow, + SearchInput, + Spinner, + Content, +} from '@patternfly/react-core'; +import { + UseFieldApiConfig, + useFieldApi, + useFormApi, +} from '@data-driven-forms/react-form-renderer'; +import YAML from 'yaml'; +import { + listRepoQuickstarts, + getRepoQuickstartContent, + RepoQuickstartEntry, +} from '../../utils/createQuickstartPR'; +import { + NAME_KIND, + NAME_METADATA_NAME, + NAME_TITLE, + NAME_DESCRIPTION, + NAME_DURATION, + NAME_URL, + NAME_BUNDLES, + NAME_TAGS, + NAME_PANEL_INTRODUCTION, + NAME_PREREQUISITES, + NAME_TASK_TITLES, + NAME_TASKS_ARRAY, +} from './steps/common'; +import { ALL_KIND_ENTRIES, ItemKind } from './meta'; + +const SOURCE_SCRATCH = '__scratch__'; + +const normalizeKindLabel = (value: string) => + value.toLowerCase().replace(/\s+/g, ''); + +function detectKindFromSpec( + spec: Record | undefined +): ItemKind | null { + const typeObj = spec?.type as Record | undefined; + const typeText = typeObj?.text; + if (typeof typeText !== 'string') return null; + for (const [kind, meta] of ALL_KIND_ENTRIES) { + if (normalizeKindLabel(meta.displayName) === normalizeKindLabel(typeText)) { + return kind; + } + } + return null; +} + +const SourceSelector = (props: UseFieldApiConfig) => { + const { input } = useFieldApi(props); + const formApi = useFormApi(); + + const [quickstarts, setQuickstarts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(''); + const [loadingName, setLoadingName] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const entries = await listRepoQuickstarts(); + if (!cancelled) setQuickstarts(entries); + } catch (err) { + if (!cancelled) + setError( + err instanceof Error ? err.message : 'Failed to load quickstarts' + ); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const filtered = useMemo( + () => + quickstarts.filter( + (qs) => + qs.name.toLowerCase().includes(search.toLowerCase()) || + qs.displayName.toLowerCase().includes(search.toLowerCase()) + ), + [quickstarts, search] + ); + + const handleSelectScratch = () => { + input.onChange(SOURCE_SCRATCH); + formApi.change(NAME_KIND, undefined); + formApi.change(NAME_METADATA_NAME, undefined); + formApi.change(NAME_TITLE, undefined); + formApi.change(NAME_DESCRIPTION, undefined); + formApi.change(NAME_DURATION, undefined); + formApi.change(NAME_URL, undefined); + formApi.change(NAME_BUNDLES, undefined); + formApi.change(NAME_TAGS, undefined); + formApi.change(NAME_PREREQUISITES, undefined); + formApi.change(NAME_PANEL_INTRODUCTION, undefined); + formApi.change(NAME_TASK_TITLES, undefined); + formApi.change(NAME_TASKS_ARRAY, undefined); + }; + + const handleSelectRepo = async (name: string) => { + setLoadingName(name); + input.onChange(name); + try { + const content = await getRepoQuickstartContent(name); + const yamlFile = content.files.find( + (f) => + (f.name.endsWith('.yml') || f.name.endsWith('.yaml')) && + f.name !== 'metadata.yaml' + ); + if (!yamlFile) return; + + const parsed = YAML.parse(yamlFile.content); + if (!parsed) return; + + const spec = parsed.spec || {}; + const metadata = parsed.metadata || {}; + + if (metadata.name) formApi.change(NAME_METADATA_NAME, metadata.name); + + const detectedKind = detectKindFromSpec(spec); + if (detectedKind) { + formApi.change(NAME_KIND, detectedKind); + } + + if (spec.displayName) formApi.change(NAME_TITLE, spec.displayName); + if (spec.description) formApi.change(NAME_DESCRIPTION, spec.description); + if (spec.durationMinutes !== undefined) + formApi.change(NAME_DURATION, spec.durationMinutes); + if (spec.link?.href) formApi.change(NAME_URL, spec.link.href); + if (spec.prerequisites) + formApi.change(NAME_PREREQUISITES, spec.prerequisites); + if (spec.introduction) + formApi.change(NAME_PANEL_INTRODUCTION, spec.introduction); + + if (spec.tasks && Array.isArray(spec.tasks)) { + formApi.change( + NAME_TASK_TITLES, + spec.tasks.map((t: { title?: string }) => t.title || '') + ); + formApi.change( + NAME_TASKS_ARRAY, + spec.tasks.map( + (t: { + description?: string; + review?: { + instructions?: string; + failedTaskHelp?: string; + }; + }) => ({ + description: t.description, + enable_work_check: !!t.review, + work_check_instructions: t.review?.instructions, + work_check_help: t.review?.failedTaskHelp, + }) + ) + ); + } + + const bundles: string[] = []; + const tagsByKind: { [kind: string]: string[] } = {}; + if (Array.isArray(metadata.tags)) { + metadata.tags.forEach((tag: { kind?: string; value?: string }) => { + if (tag.kind === 'bundle' && tag.value) { + bundles.push(tag.value); + } else if (tag.kind && tag.value) { + if (!tagsByKind[tag.kind]) tagsByKind[tag.kind] = []; + tagsByKind[tag.kind].push(tag.value); + } + }); + } + if (bundles.length > 0) formApi.change(NAME_BUNDLES, bundles); + if (Object.keys(tagsByKind).length > 0) + formApi.change(NAME_TAGS, tagsByKind); + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to load quickstart' + ); + } finally { + setLoadingName(null); + } + }; + + const selected = input.value; + + return ( +
+
+ + Start from scratch or load an existing quickstart from the repository. + +
+
+ +
+ + setSearch(value)} + onClear={() => setSearch('')} + className="pf-v6-u-mb-sm" + /> + + {loading && } + + {error && ( + + {error} + + )} + + {!loading && !error && ( +
+ + + + + + , + ]} + /> + + + {filtered.map((qs) => ( + + + + + , + ]} + /> + + + ))} + {filtered.length === 0 && quickstarts.length > 0 && ( + + + + No quickstarts found + , + ]} + /> + + + )} + +
+ )} + +
+
+
+ ); +}; + +export default SourceSelector; diff --git a/src/components/creator/meta.ts b/src/components/creator/meta.ts index 1aea013f..5d299d6e 100644 --- a/src/components/creator/meta.ts +++ b/src/components/creator/meta.ts @@ -12,7 +12,7 @@ const rawItemKindMeta = Object.freeze({ }, }, quickstart: { - displayName: 'Quickstart', + displayName: 'Quick start', tagColor: 'green', hasDuration: true, fields: { diff --git a/src/components/creator/schema.tsx b/src/components/creator/schema.tsx index 55dfd960..8caca297 100644 --- a/src/components/creator/schema.tsx +++ b/src/components/creator/schema.tsx @@ -18,6 +18,7 @@ import { makePanelOverviewStep, } from './steps/panel-overview'; import { isKindStep, makeKindStep } from './steps/kind'; +import { isSourceStep, makeSourceStep } from './steps/source'; import { STEP_DOWNLOAD, isDownloadStep, @@ -68,7 +69,8 @@ const CustomButtons = (props: WizardButtonsProps) => { const STEP_TITLE_PANEL_PARENT = 'Create panel'; export function stageFromStepName(name: string): CreatorWizardStage { - if (isKindStep(name) || isDetailsStep(name)) return { type: 'card' }; + if (isSourceStep(name) || isKindStep(name) || isDetailsStep(name)) + return { type: 'card' }; if (isPanelOverviewStep(name)) return { type: 'panel-overview' }; @@ -113,6 +115,7 @@ export function makeSchema(chrome: ChromeAPI, filterData: FilterData): Schema { isDynamic: true, crossroads: [NAME_KIND, NAME_TASK_TITLES], fields: [ + makeSourceStep(), makeKindStep(), ...ALL_ITEM_KINDS.map((kind) => makeDetailsStep({ diff --git a/src/components/creator/steps/common.ts b/src/components/creator/steps/common.ts index bcecd7c4..f5dde6a8 100644 --- a/src/components/creator/steps/common.ts +++ b/src/components/creator/steps/common.ts @@ -5,6 +5,7 @@ export const REQUIRED = { } as const; export const NAME_KIND = 'kind'; +export const NAME_METADATA_NAME = 'metadata-name'; export const NAME_TAGS = 'tags'; export const NAME_TITLE = 'title'; export const NAME_BUNDLES = 'bundles'; diff --git a/src/components/creator/steps/kind.tsx b/src/components/creator/steps/kind.tsx index 52669994..e1d605bb 100644 --- a/src/components/creator/steps/kind.tsx +++ b/src/components/creator/steps/kind.tsx @@ -3,7 +3,7 @@ import { ALL_ITEM_KINDS, ALL_KIND_ENTRIES } from '../meta'; import { NAME_KIND, REQUIRED } from './common'; import { detailsStepName } from './details'; -const STEP_KIND = 'step-kind'; +export const STEP_KIND = 'step-kind'; export function isKindStep(name: string): boolean { return name === STEP_KIND; diff --git a/src/components/creator/steps/source.tsx b/src/components/creator/steps/source.tsx new file mode 100644 index 00000000..db22b799 --- /dev/null +++ b/src/components/creator/steps/source.tsx @@ -0,0 +1,25 @@ +import { STEP_KIND } from './kind'; +import { REQUIRED } from './common'; + +export const STEP_SOURCE = 'step-source'; +export const NAME_SOURCE = 'source'; + +export function isSourceStep(name: string): boolean { + return name === STEP_SOURCE; +} + +export function makeSourceStep() { + return { + name: STEP_SOURCE, + title: 'Select source', + fields: [ + { + component: 'lr-source-selector', + name: NAME_SOURCE, + isRequired: true, + validate: [REQUIRED], + }, + ], + nextStep: STEP_KIND, + }; +} diff --git a/src/utils/createQuickstartPR.test.ts b/src/utils/createQuickstartPR.test.ts index 0ce9da01..c12023a7 100644 --- a/src/utils/createQuickstartPR.test.ts +++ b/src/utils/createQuickstartPR.test.ts @@ -36,20 +36,20 @@ describe('createQuickstartPR', () => { jest.clearAllMocks(); }); - it('POSTs to /api/quickstarts/v1/pull-request with files and metadata', async () => { - mockedAxios.post.mockResolvedValueOnce({ data: { data: MOCK_RESPONSE } }); + it('POSTs to /api/v1/submit-pr with files and metadata', async () => { + mockedAxios.post.mockResolvedValueOnce({ data: MOCK_RESPONSE }); const result = await createQuickstartPR(MOCK_FILES, MOCK_METADATA); expect(mockedAxios.post).toHaveBeenCalledWith( - '/api/quickstarts/v1/pull-request', + '/api/v1/submit-pr', { files: MOCK_FILES, metadata: MOCK_METADATA } ); expect(result).toEqual(MOCK_RESPONSE); }); it('returns the PR URL, branchName, commitSha, and status from the response', async () => { - mockedAxios.post.mockResolvedValueOnce({ data: { data: MOCK_RESPONSE } }); + mockedAxios.post.mockResolvedValueOnce({ data: MOCK_RESPONSE }); const result = await createQuickstartPR(MOCK_FILES, MOCK_METADATA); @@ -73,7 +73,7 @@ describe('createQuickstartPR', () => { }); it('sends isUpdate: false for new quickstarts', async () => { - mockedAxios.post.mockResolvedValueOnce({ data: { data: MOCK_RESPONSE } }); + mockedAxios.post.mockResolvedValueOnce({ data: MOCK_RESPONSE }); await createQuickstartPR(MOCK_FILES, { ...MOCK_METADATA, isUpdate: false }); @@ -83,7 +83,7 @@ describe('createQuickstartPR', () => { }); it('forwards existingPath and isUpdate: true for updates (48694 path)', async () => { - mockedAxios.post.mockResolvedValueOnce({ data: { data: { ...MOCK_RESPONSE, status: 'updated' } } }); + mockedAxios.post.mockResolvedValueOnce({ data: { ...MOCK_RESPONSE, status: 'updated' } }); const updateMetadata: PRMetadata = { ...MOCK_METADATA, @@ -105,19 +105,19 @@ describe('listRepoQuickstarts', () => { jest.clearAllMocks(); }); - it('GETs /api/quickstarts/v1/repo-quickstarts and returns quickstarts array', async () => { + it('GETs /api/v1/list-quickstarts and returns quickstarts array', async () => { const mockQuickstarts = [ { name: 'getting-started', displayName: 'Getting Started' }, { name: 'cost-management', displayName: 'Cost Management' }, ]; mockedAxios.get.mockResolvedValueOnce({ - data: { data: { quickstarts: mockQuickstarts } }, + data: { quickstarts: mockQuickstarts }, }); const result = await listRepoQuickstarts(); expect(mockedAxios.get).toHaveBeenCalledWith( - '/api/quickstarts/v1/repo-quickstarts' + '/api/v1/list-quickstarts' ); expect(result).toEqual(mockQuickstarts); expect(result).toHaveLength(2); @@ -135,7 +135,7 @@ describe('getRepoQuickstartContent', () => { jest.clearAllMocks(); }); - it('GETs /api/quickstarts/v1/repo-quickstarts/{name} and returns content', async () => { + it('GETs /api/v1/quickstart-content/{name} and returns content', async () => { const mockContent = { name: 'getting-started', files: [ @@ -144,13 +144,13 @@ describe('getRepoQuickstartContent', () => { ], }; mockedAxios.get.mockResolvedValueOnce({ - data: { data: mockContent }, + data: mockContent, }); const result = await getRepoQuickstartContent('getting-started'); expect(mockedAxios.get).toHaveBeenCalledWith( - '/api/quickstarts/v1/repo-quickstarts/getting-started' + '/api/v1/quickstart-content/getting-started' ); expect(result.name).toBe('getting-started'); expect(result.files).toHaveLength(2); @@ -158,13 +158,13 @@ describe('getRepoQuickstartContent', () => { it('encodes the quickstart name in the URL', async () => { mockedAxios.get.mockResolvedValueOnce({ - data: { data: { name: 'my qs', files: [] } }, + data: { name: 'my qs', files: [] }, }); await getRepoQuickstartContent('my qs'); expect(mockedAxios.get).toHaveBeenCalledWith( - '/api/quickstarts/v1/repo-quickstarts/my%20qs' + '/api/v1/quickstart-content/my%20qs' ); }); diff --git a/src/utils/createQuickstartPR.ts b/src/utils/createQuickstartPR.ts index 25f693de..e846b45e 100644 --- a/src/utils/createQuickstartPR.ts +++ b/src/utils/createQuickstartPR.ts @@ -1,6 +1,7 @@ import axios from 'axios'; export const API_BASE = '/api/quickstarts/v1'; +export const GIT_API_BASE = '/api/v1'; export interface PRFile { name: string; @@ -41,11 +42,11 @@ export const createQuickstartPR = async ( files: PRFile[], metadata: PRMetadata ): Promise => { - const { data } = await axios.post<{ data: PRResponse }>( - `${API_BASE}/pull-request`, + const { data } = await axios.post( + `${GIT_API_BASE}/submit-pr`, { files, metadata } ); - return data.data; + return data; }; export interface RepoQuickstartEntry { @@ -59,17 +60,17 @@ export interface RepoQuickstartContent { } export const listRepoQuickstarts = async (): Promise => { - const { data } = await axios.get<{ - data: { quickstarts: RepoQuickstartEntry[] }; - }>(`${API_BASE}/repo-quickstarts`); - return data.data.quickstarts; + const { data } = await axios.get<{ quickstarts: RepoQuickstartEntry[] }>( + `${GIT_API_BASE}/list-quickstarts` + ); + return data.quickstarts; }; export const getRepoQuickstartContent = async ( name: string ): Promise => { - const { data } = await axios.get<{ data: RepoQuickstartContent }>( - `${API_BASE}/repo-quickstarts/${encodeURIComponent(name)}` + const { data } = await axios.get( + `${GIT_API_BASE}/quickstart-content/${encodeURIComponent(name)}` ); - return data.data; + return data; }; From 33ceb47fd55810ea57572445d88bf1870d5e7729 Mon Sep 17 00:00:00 2001 From: Hossam Farid Date: Mon, 27 Jul 2026 14:20:44 -0400 Subject: [PATCH 5/8] feat(git-service): gate all changes and fix PSK endpoints --- src/Creator.tsx | 29 ++++-- src/components/creator/CreatorWizard.test.tsx | 4 + src/components/creator/CreatorWizard.tsx | 90 ++++++++++++------- .../creator/CreatorYAMLView.test.tsx | 23 +++++ src/components/creator/CreatorYAMLView.tsx | 76 +++++++++------- src/components/creator/schema.tsx | 4 +- src/utils/createQuickstartPR.test.ts | 28 +++--- src/utils/createQuickstartPR.ts | 21 +++-- 8 files changed, 182 insertions(+), 93 deletions(-) diff --git a/src/Creator.tsx b/src/Creator.tsx index 74b07f2c..a1367925 100644 --- a/src/Creator.tsx +++ b/src/Creator.tsx @@ -22,6 +22,7 @@ import useSuspenseLoader, { import fetchFilters from './utils/fetchFilters'; import { ExtendedQuickstart } from './utils/fetchQuickstarts'; import useFilterMap from './hooks/useFilterMap'; +import { useFlag } from '@unleash/proxy-client-react'; function makeDemoQuickStart( kind: ItemKind | null, @@ -46,6 +47,7 @@ const CreatorInternal = ({ filterLoader: UnwrappedLoader; }) => { const { data: filterData } = filterLoader(); + const showGitService = useFlag('platform.learning-resources.quickstarts.git-service'); const [rawKind, setRawKind] = useState(null); const filterMap = useFilterMap({ data: filterData }); @@ -156,10 +158,27 @@ const CreatorInternal = ({ setRawKind(newKind); }; - const quickStart = useMemo( - () => makeDemoQuickStart(rawKind, rawQuickStart), - [rawKind, rawQuickStart] - ); + const quickStart = useMemo(() => { + const demo = makeDemoQuickStart(rawKind, rawQuickStart); + if (!showGitService) return demo; + + const allTags = bundles.toSorted().map((bundle) => ({ + kind: 'bundle', + value: bundle, + })); + Object.entries(tags).forEach(([kind, values]) => { + values.forEach((value) => { + allTags.push({ kind, value }); + }); + }); + return { + ...demo, + metadata: { + ...demo.metadata, + tags: allTags, + }, + }; + }, [rawKind, rawQuickStart, bundles, tags, showGitService]); const files = useMemo(() => { const effectiveName = quickStart.spec.displayName @@ -238,7 +257,7 @@ const CreatorInternal = ({ updateSpec(() => spec); }} onChangeMetadataTags={updateMetadataTags} - onChangeMetadataName={updateMetadataName} + onChangeMetadataName={showGitService ? updateMetadataName : undefined} filterData={filterData} onChangeBundles={setBundles} onChangeCurrentStage={setCurrentStage} diff --git a/src/components/creator/CreatorWizard.test.tsx b/src/components/creator/CreatorWizard.test.tsx index 09aefff5..93ae7763 100644 --- a/src/components/creator/CreatorWizard.test.tsx +++ b/src/components/creator/CreatorWizard.test.tsx @@ -3,6 +3,10 @@ import { act, render, screen, waitFor } from '@testing-library/react'; import CreatorWizard, { CreatorWizardProps } from './CreatorWizard'; import { ExtendedQuickstart } from '../../utils/fetchQuickstarts'; +jest.mock('@unleash/proxy-client-react', () => ({ + useFlag: () => false, +})); + jest.mock('@redhat-cloud-services/frontend-components/useChrome', () => ({ __esModule: true, useChrome: () => ({ diff --git a/src/components/creator/CreatorWizard.tsx b/src/components/creator/CreatorWizard.tsx index 510382ad..2d80f384 100644 --- a/src/components/creator/CreatorWizard.tsx +++ b/src/components/creator/CreatorWizard.tsx @@ -7,7 +7,6 @@ import { Content, Flex, FlexItem, - Spinner, Stack, StackItem, Tab, @@ -62,6 +61,7 @@ import TagsSelector from './TagsSelector'; import CreatorYAMLView from './CreatorYAMLView'; import { useCreatePR } from './useCreatePR'; import SourceSelector from './SourceSelector'; +import { useFlag } from '@unleash/proxy-client-react'; export type CreatorWizardProps = { onChangeKind: (newKind: ItemKind | null) => void; @@ -230,6 +230,7 @@ const PropUpdater = ({ }; const FileDownload = () => { + const showGitService = useFlag('platform.learning-resources.quickstarts.git-service'); const { files } = useContext(CreatorWizardContext); const quickstartName = useMemo(() => { @@ -274,39 +275,65 @@ const FileDownload = () => { - - Download these files or submit them directly as a pull request. - + {showGitService ? ( + + Download these files or submit them directly as a pull request. + + ) : ( + + Download these files and use them to create the learning resource PR + in the{' '} + + {' '} + correct repo + + . + + )} - - - - - - - - + {showGitService ? ( + + + + + + + + + ) : ( + + )} - {prResult && ( + {showGitService && prResult && ( { )} - {prError && ( + {showGitService && prError && ( { const chrome = useChrome(); + const showGitService = useFlag('platform.learning-resources.quickstarts.git-service'); const [viewMode, setViewMode] = useState('wizard'); - const schema = useMemo(() => makeSchema(chrome, filterData), []); + const schema = useMemo(() => makeSchema(chrome, filterData, showGitService), [showGitService]); const availableBundles = useMemo(() => chrome.getAvailableBundles(), []); // [viewMode] only, including props like quickStart, currentKind, etc would recompute on diff --git a/src/components/creator/CreatorYAMLView.test.tsx b/src/components/creator/CreatorYAMLView.test.tsx index 626b896f..751e2046 100644 --- a/src/components/creator/CreatorYAMLView.test.tsx +++ b/src/components/creator/CreatorYAMLView.test.tsx @@ -44,6 +44,11 @@ jest.mock('@redhat-cloud-services/frontend-components/useChrome', () => ({ }), })); +let mockGitServiceFlag = false; +jest.mock('@unleash/proxy-client-react', () => ({ + useFlag: () => mockGitServiceFlag, +})); + // Mock Monaco Editor — render a simple textarea that mirrors onChange behavior jest.mock('@monaco-editor/react', () => { const MockEditor = ({ @@ -669,6 +674,12 @@ spec: }); describe('Create PR button', () => { + beforeEach(() => { + mockGitServiceFlag = true; + }); + afterEach(() => { + mockGitServiceFlag = false; + }); it('renders Create PR button', () => { renderWithContext(); expect( @@ -769,6 +780,12 @@ spec: }); describe('Mode detection label', () => { + beforeEach(() => { + mockGitServiceFlag = true; + }); + afterEach(() => { + mockGitServiceFlag = false; + }); it('shows "Creating" label for new quickstarts', async () => { mockedQuickstartExists.mockResolvedValue(false); @@ -801,6 +818,12 @@ spec: }); describe('Load from Repo', () => { + beforeEach(() => { + mockGitServiceFlag = true; + }); + afterEach(() => { + mockGitServiceFlag = false; + }); it('renders Load from Repo button', () => { renderWithContext(); expect( diff --git a/src/components/creator/CreatorYAMLView.tsx b/src/components/creator/CreatorYAMLView.tsx index 471fdc4e..82c1f135 100644 --- a/src/components/creator/CreatorYAMLView.tsx +++ b/src/components/creator/CreatorYAMLView.tsx @@ -56,6 +56,7 @@ import { CreatorWizardContext } from './context'; import { useCreatePR } from './useCreatePR'; import { ALL_KIND_ENTRIES, ItemKind } from './meta'; import { FilterData } from '../../utils/FiltersCategoryInterface'; +import { useFlag } from '@unleash/proxy-client-react'; import './CreatorYAMLView.scss'; import { DEFAULT_QUICKSTART_YAML } from '../../data/quickstart-templates'; @@ -438,9 +439,7 @@ const CreatorYAMLView: React.FC = ({ }) => { const { files } = useContext(CreatorWizardContext); - // Hardcoded to true for local dev — revert to useFlag before opening PR: - // const showCreatePR = useFlag('platform.learning-resources.quickstarts.create-pr'); - const showCreatePR = true; + const showCreatePR = useFlag('platform.learning-resources.quickstarts.git-service'); const [parsedName, setParsedName] = useState(null); const { @@ -834,7 +833,6 @@ const CreatorYAMLView: React.FC = ({ + + + )} {showCreatePR && ( )} - - - - - - - {showCreatePR && ( + + +
= ({ > - )} -
+ + )} {showCreatePR && ( makeDetailsStep({ diff --git a/src/utils/createQuickstartPR.test.ts b/src/utils/createQuickstartPR.test.ts index c12023a7..0ce9da01 100644 --- a/src/utils/createQuickstartPR.test.ts +++ b/src/utils/createQuickstartPR.test.ts @@ -36,20 +36,20 @@ describe('createQuickstartPR', () => { jest.clearAllMocks(); }); - it('POSTs to /api/v1/submit-pr with files and metadata', async () => { - mockedAxios.post.mockResolvedValueOnce({ data: MOCK_RESPONSE }); + it('POSTs to /api/quickstarts/v1/pull-request with files and metadata', async () => { + mockedAxios.post.mockResolvedValueOnce({ data: { data: MOCK_RESPONSE } }); const result = await createQuickstartPR(MOCK_FILES, MOCK_METADATA); expect(mockedAxios.post).toHaveBeenCalledWith( - '/api/v1/submit-pr', + '/api/quickstarts/v1/pull-request', { files: MOCK_FILES, metadata: MOCK_METADATA } ); expect(result).toEqual(MOCK_RESPONSE); }); it('returns the PR URL, branchName, commitSha, and status from the response', async () => { - mockedAxios.post.mockResolvedValueOnce({ data: MOCK_RESPONSE }); + mockedAxios.post.mockResolvedValueOnce({ data: { data: MOCK_RESPONSE } }); const result = await createQuickstartPR(MOCK_FILES, MOCK_METADATA); @@ -73,7 +73,7 @@ describe('createQuickstartPR', () => { }); it('sends isUpdate: false for new quickstarts', async () => { - mockedAxios.post.mockResolvedValueOnce({ data: MOCK_RESPONSE }); + mockedAxios.post.mockResolvedValueOnce({ data: { data: MOCK_RESPONSE } }); await createQuickstartPR(MOCK_FILES, { ...MOCK_METADATA, isUpdate: false }); @@ -83,7 +83,7 @@ describe('createQuickstartPR', () => { }); it('forwards existingPath and isUpdate: true for updates (48694 path)', async () => { - mockedAxios.post.mockResolvedValueOnce({ data: { ...MOCK_RESPONSE, status: 'updated' } }); + mockedAxios.post.mockResolvedValueOnce({ data: { data: { ...MOCK_RESPONSE, status: 'updated' } } }); const updateMetadata: PRMetadata = { ...MOCK_METADATA, @@ -105,19 +105,19 @@ describe('listRepoQuickstarts', () => { jest.clearAllMocks(); }); - it('GETs /api/v1/list-quickstarts and returns quickstarts array', async () => { + it('GETs /api/quickstarts/v1/repo-quickstarts and returns quickstarts array', async () => { const mockQuickstarts = [ { name: 'getting-started', displayName: 'Getting Started' }, { name: 'cost-management', displayName: 'Cost Management' }, ]; mockedAxios.get.mockResolvedValueOnce({ - data: { quickstarts: mockQuickstarts }, + data: { data: { quickstarts: mockQuickstarts } }, }); const result = await listRepoQuickstarts(); expect(mockedAxios.get).toHaveBeenCalledWith( - '/api/v1/list-quickstarts' + '/api/quickstarts/v1/repo-quickstarts' ); expect(result).toEqual(mockQuickstarts); expect(result).toHaveLength(2); @@ -135,7 +135,7 @@ describe('getRepoQuickstartContent', () => { jest.clearAllMocks(); }); - it('GETs /api/v1/quickstart-content/{name} and returns content', async () => { + it('GETs /api/quickstarts/v1/repo-quickstarts/{name} and returns content', async () => { const mockContent = { name: 'getting-started', files: [ @@ -144,13 +144,13 @@ describe('getRepoQuickstartContent', () => { ], }; mockedAxios.get.mockResolvedValueOnce({ - data: mockContent, + data: { data: mockContent }, }); const result = await getRepoQuickstartContent('getting-started'); expect(mockedAxios.get).toHaveBeenCalledWith( - '/api/v1/quickstart-content/getting-started' + '/api/quickstarts/v1/repo-quickstarts/getting-started' ); expect(result.name).toBe('getting-started'); expect(result.files).toHaveLength(2); @@ -158,13 +158,13 @@ describe('getRepoQuickstartContent', () => { it('encodes the quickstart name in the URL', async () => { mockedAxios.get.mockResolvedValueOnce({ - data: { name: 'my qs', files: [] }, + data: { data: { name: 'my qs', files: [] } }, }); await getRepoQuickstartContent('my qs'); expect(mockedAxios.get).toHaveBeenCalledWith( - '/api/v1/quickstart-content/my%20qs' + '/api/quickstarts/v1/repo-quickstarts/my%20qs' ); }); diff --git a/src/utils/createQuickstartPR.ts b/src/utils/createQuickstartPR.ts index e846b45e..25f693de 100644 --- a/src/utils/createQuickstartPR.ts +++ b/src/utils/createQuickstartPR.ts @@ -1,7 +1,6 @@ import axios from 'axios'; export const API_BASE = '/api/quickstarts/v1'; -export const GIT_API_BASE = '/api/v1'; export interface PRFile { name: string; @@ -42,11 +41,11 @@ export const createQuickstartPR = async ( files: PRFile[], metadata: PRMetadata ): Promise => { - const { data } = await axios.post( - `${GIT_API_BASE}/submit-pr`, + const { data } = await axios.post<{ data: PRResponse }>( + `${API_BASE}/pull-request`, { files, metadata } ); - return data; + return data.data; }; export interface RepoQuickstartEntry { @@ -60,17 +59,17 @@ export interface RepoQuickstartContent { } export const listRepoQuickstarts = async (): Promise => { - const { data } = await axios.get<{ quickstarts: RepoQuickstartEntry[] }>( - `${GIT_API_BASE}/list-quickstarts` - ); - return data.quickstarts; + const { data } = await axios.get<{ + data: { quickstarts: RepoQuickstartEntry[] }; + }>(`${API_BASE}/repo-quickstarts`); + return data.data.quickstarts; }; export const getRepoQuickstartContent = async ( name: string ): Promise => { - const { data } = await axios.get( - `${GIT_API_BASE}/quickstart-content/${encodeURIComponent(name)}` + const { data } = await axios.get<{ data: RepoQuickstartContent }>( + `${API_BASE}/repo-quickstarts/${encodeURIComponent(name)}` ); - return data; + return data.data; }; From dbbcba384269bc730ac8734ea47b94dc0b28bbe4 Mon Sep 17 00:00:00 2001 From: Hossam Farid Date: Fri, 7 Aug 2026 10:01:27 -0400 Subject: [PATCH 6/8] fix(git-service): update cypress test file and fix linter issues --- cypress/component/CreatorYAMLView.cy.tsx | 49 +++- src/Creator.tsx | 8 +- src/components/creator/CreatorWizard.tsx | 31 ++- .../creator/CreatorYAMLView.test.tsx | 108 ++++++--- src/components/creator/CreatorYAMLView.tsx | 30 ++- src/components/creator/SourceSelector.tsx | 211 +++++++++--------- src/components/creator/schema.tsx | 6 +- src/components/creator/useCreatePR.ts | 7 +- src/utils/createQuickstartPR.test.ts | 28 ++- 9 files changed, 302 insertions(+), 176 deletions(-) diff --git a/cypress/component/CreatorYAMLView.cy.tsx b/cypress/component/CreatorYAMLView.cy.tsx index b5863db5..f3646204 100644 --- a/cypress/component/CreatorYAMLView.cy.tsx +++ b/cypress/component/CreatorYAMLView.cy.tsx @@ -1,6 +1,27 @@ import React from 'react'; +import { FlagProvider, IConfig } from '@unleash/proxy-client-react'; import CreatorYAMLView from '../../src/components/creator/CreatorYAMLView'; +const unleashConfig: IConfig = { + appName: 'test-app', + url: 'https://unleash.example.com/api/', + clientKey: 'test', + refreshInterval: 0, + disableRefresh: true, + bootstrap: [ + { + name: 'platform.learning-resources.quickstarts.git-service', + enabled: false, + impressionData: false, + variant: { name: 'disabled', enabled: false }, + }, + ], +}; + +const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +); + const setMonacoValue = (value: string) => { // Retry until Monaco is ready and models are available cy.window().should((win: any) => { @@ -35,7 +56,7 @@ const getMonacoValue = (): Cypress.Chainable => { describe('CreatorYAMLView', () => { beforeEach(() => { // Mount the component before each test - cy.mount(); + cy.mount(); // This single wait ensures the editor is ready for all tests cy.get('.lr-c-creator-yaml-view__editor', { timeout: 10000 }).should('be.visible'); @@ -97,11 +118,13 @@ describe('CreatorYAMLView', () => { const onChangeTags = cy.stub().as('onChangeTags'); cy.mount( - + + + ); // Wait for editor to initialize @@ -132,7 +155,9 @@ spec: const onChangeQuickStartSpec = cy.stub().as('onChangeQuickStartSpec'); cy.mount( - + + + ); cy.get('.monaco-editor textarea', { timeout: 10000 }).should('exist'); @@ -160,7 +185,7 @@ spec: }); it('should show error alert for invalid YAML', () => { - cy.mount(); + cy.mount(); // Wait for editor to be ready cy.get('.monaco-editor textarea', { timeout: 10000 }).should('exist'); @@ -185,7 +210,9 @@ spec: const onChangeQuickStartSpec = cy.stub().as('onChangeQuickStartSpec'); cy.mount( - + + + ); // Wait for editor to initialize @@ -221,7 +248,7 @@ spec: }); it('should recover from error when valid YAML is entered', () => { - cy.mount(); + cy.mount(); // Wait for editor to be ready cy.get('.monaco-editor textarea', { timeout: 10000 }).should('exist'); @@ -253,7 +280,7 @@ spec: }); it('should update editor content via Monaco API', () => { - cy.mount(); + cy.mount(); // Wait for editor to be ready cy.get('.monaco-editor textarea', { timeout: 10000 }).should('exist'); diff --git a/src/Creator.tsx b/src/Creator.tsx index a1367925..65734996 100644 --- a/src/Creator.tsx +++ b/src/Creator.tsx @@ -47,7 +47,9 @@ const CreatorInternal = ({ filterLoader: UnwrappedLoader; }) => { const { data: filterData } = filterLoader(); - const showGitService = useFlag('platform.learning-resources.quickstarts.git-service'); + const showGitService = useFlag( + 'platform.learning-resources.quickstarts.git-service' + ); const [rawKind, setRawKind] = useState(null); const filterMap = useFilterMap({ data: filterData }); @@ -257,7 +259,9 @@ const CreatorInternal = ({ updateSpec(() => spec); }} onChangeMetadataTags={updateMetadataTags} - onChangeMetadataName={showGitService ? updateMetadataName : undefined} + onChangeMetadataName={ + showGitService ? updateMetadataName : undefined + } filterData={filterData} onChangeBundles={setBundles} onChangeCurrentStage={setCurrentStage} diff --git a/src/components/creator/CreatorWizard.tsx b/src/components/creator/CreatorWizard.tsx index 2d80f384..97285550 100644 --- a/src/components/creator/CreatorWizard.tsx +++ b/src/components/creator/CreatorWizard.tsx @@ -230,7 +230,9 @@ const PropUpdater = ({ }; const FileDownload = () => { - const showGitService = useFlag('platform.learning-resources.quickstarts.git-service'); + const showGitService = useFlag( + 'platform.learning-resources.quickstarts.git-service' + ); const { files } = useContext(CreatorWizardContext); const quickstartName = useMemo(() => { @@ -281,8 +283,8 @@ const FileDownload = () => { ) : ( - Download these files and use them to create the learning resource PR - in the{' '} + Download these files and use them to create the learning resource + PR in the{' '} { } @@ -365,10 +362,7 @@ const FileDownload = () => { title="Failed to Create PR" isInline actionClose={ - } @@ -452,9 +446,14 @@ const CreatorWizard = ({ onChangeKindDirect, }: CreatorWizardProps) => { const chrome = useChrome(); - const showGitService = useFlag('platform.learning-resources.quickstarts.git-service'); + const showGitService = useFlag( + 'platform.learning-resources.quickstarts.git-service' + ); const [viewMode, setViewMode] = useState('wizard'); - const schema = useMemo(() => makeSchema(chrome, filterData, showGitService), [showGitService]); + const schema = useMemo( + () => makeSchema(chrome, filterData, showGitService), + [showGitService] + ); const availableBundles = useMemo(() => chrome.getAvailableBundles(), []); // [viewMode] only, including props like quickStart, currentKind, etc would recompute on diff --git a/src/components/creator/CreatorYAMLView.test.tsx b/src/components/creator/CreatorYAMLView.test.tsx index 751e2046..5eadd2cf 100644 --- a/src/components/creator/CreatorYAMLView.test.tsx +++ b/src/components/creator/CreatorYAMLView.test.tsx @@ -13,9 +13,9 @@ import { DEFAULT_QUICKSTART_YAML } from '../../data/quickstart-templates'; import { ExtendedQuickstart } from '../../utils/fetchQuickstarts'; import { createQuickstartPR, - quickstartExists, - listRepoQuickstarts, getRepoQuickstartContent, + listRepoQuickstarts, + quickstartExists, } from '../../utils/createQuickstartPR'; // Mock downloadFile from frontend-components-utilities @@ -72,13 +72,24 @@ jest.mock('../../utils/createQuickstartPR', () => ({ createQuickstartPR: jest.fn(), quickstartExists: jest.fn().mockResolvedValue(false), listRepoQuickstarts: jest.fn().mockResolvedValue([]), - getRepoQuickstartContent: jest.fn().mockResolvedValue({ name: '', files: [] }), + getRepoQuickstartContent: jest + .fn() + .mockResolvedValue({ name: '', files: [] }), })); -const mockedCreatePR = createQuickstartPR as jest.MockedFunction; -const mockedQuickstartExists = quickstartExists as jest.MockedFunction; -const mockedListRepoQuickstarts = listRepoQuickstarts as jest.MockedFunction; -const mockedGetRepoQuickstartContent = getRepoQuickstartContent as jest.MockedFunction; +const mockedCreatePR = createQuickstartPR as jest.MockedFunction< + typeof createQuickstartPR +>; +const mockedQuickstartExists = quickstartExists as jest.MockedFunction< + typeof quickstartExists +>; +const mockedListRepoQuickstarts = listRepoQuickstarts as jest.MockedFunction< + typeof listRepoQuickstarts +>; +const mockedGetRepoQuickstartContent = + getRepoQuickstartContent as jest.MockedFunction< + typeof getRepoQuickstartContent + >; const MOCK_FILES: CreatorFiles = [ { name: 'metadata.yaml', content: 'kind: QuickStarts\nname: test\n' }, @@ -692,7 +703,9 @@ spec: const editor = screen.getByTestId('mock-monaco-editor'); fireEvent.change(editor, { target: { value: 'invalid: [unclosed' } }); - act(() => { jest.advanceTimersByTime(200); }); + act(() => { + jest.advanceTimersByTime(200); + }); const prBtn = screen.getByRole('button', { name: /create pr/i }); expect(prBtn).toBeDisabled(); @@ -710,18 +723,27 @@ spec: const editor = screen.getByTestId('mock-monaco-editor'); fireEvent.change(editor, { - target: { value: 'metadata:\n name: my-qs\nspec:\n displayName: My QS\n description: Desc\n' }, + target: { + value: + 'metadata:\n name: my-qs\nspec:\n displayName: My QS\n description: Desc\n', + }, + }); + act(() => { + jest.advanceTimersByTime(200); }); - act(() => { jest.advanceTimersByTime(200); }); await waitFor(() => { - expect(screen.getByRole('button', { name: /create pr/i })).not.toBeDisabled(); + expect( + screen.getByRole('button', { name: /create pr/i }) + ).not.toBeDisabled(); }); fireEvent.click(screen.getByRole('button', { name: /create pr/i })); await waitFor(() => { - expect(screen.getByText('https://github.com/org/repo/pull/99')).toBeInTheDocument(); + expect( + screen.getByText('https://github.com/org/repo/pull/99') + ).toBeInTheDocument(); }); }); @@ -737,12 +759,19 @@ spec: const editor = screen.getByTestId('mock-monaco-editor'); fireEvent.change(editor, { - target: { value: 'metadata:\n name: my-qs\nspec:\n displayName: My QS\n description: Desc\n' }, + target: { + value: + 'metadata:\n name: my-qs\nspec:\n displayName: My QS\n description: Desc\n', + }, + }); + act(() => { + jest.advanceTimersByTime(200); }); - act(() => { jest.advanceTimersByTime(200); }); await waitFor(() => { - expect(screen.getByRole('button', { name: /create pr/i })).not.toBeDisabled(); + expect( + screen.getByRole('button', { name: /create pr/i }) + ).not.toBeDisabled(); }); fireEvent.click(screen.getByRole('button', { name: /create pr/i })); @@ -752,7 +781,9 @@ spec: }); const metadata = mockedCreatePR.mock.calls[0][1]; - expect(metadata.commitMessage).toContain('Co-authored-by: Test User '); + expect(metadata.commitMessage).toContain( + 'Co-authored-by: Test User ' + ); }); it('shows error alert with retry on failure', async () => { @@ -762,12 +793,19 @@ spec: const editor = screen.getByTestId('mock-monaco-editor'); fireEvent.change(editor, { - target: { value: 'metadata:\n name: my-qs\nspec:\n displayName: My QS\n description: Desc\n' }, + target: { + value: + 'metadata:\n name: my-qs\nspec:\n displayName: My QS\n description: Desc\n', + }, + }); + act(() => { + jest.advanceTimersByTime(200); }); - act(() => { jest.advanceTimersByTime(200); }); await waitFor(() => { - expect(screen.getByRole('button', { name: /create pr/i })).not.toBeDisabled(); + expect( + screen.getByRole('button', { name: /create pr/i }) + ).not.toBeDisabled(); }); fireEvent.click(screen.getByRole('button', { name: /create pr/i })); @@ -775,7 +813,9 @@ spec: await waitFor(() => { expect(screen.getByText(/Network error/)).toBeInTheDocument(); }); - expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /retry/i }) + ).toBeInTheDocument(); }); }); @@ -792,9 +832,13 @@ spec: renderWithContext(); const editor = screen.getByTestId('mock-monaco-editor'); fireEvent.change(editor, { - target: { value: 'metadata:\n name: brand-new-qs\nspec:\n displayName: New\n' }, + target: { + value: 'metadata:\n name: brand-new-qs\nspec:\n displayName: New\n', + }, + }); + act(() => { + jest.advanceTimersByTime(200); }); - act(() => { jest.advanceTimersByTime(200); }); await waitFor(() => { expect(screen.getByText(/Creating: brand-new-qs/)).toBeInTheDocument(); @@ -807,9 +851,14 @@ spec: renderWithContext(); const editor = screen.getByTestId('mock-monaco-editor'); fireEvent.change(editor, { - target: { value: 'metadata:\n name: existing-qs\nspec:\n displayName: Existing\n' }, + target: { + value: + 'metadata:\n name: existing-qs\nspec:\n displayName: Existing\n', + }, + }); + act(() => { + jest.advanceTimersByTime(200); }); - act(() => { jest.advanceTimersByTime(200); }); await waitFor(() => { expect(screen.getByText(/Updating: existing-qs/)).toBeInTheDocument(); @@ -852,7 +901,8 @@ spec: mockedListRepoQuickstarts.mockResolvedValueOnce([ { name: 'getting-started', displayName: 'Getting Started' }, ]); - const yamlContent = 'metadata:\n name: getting-started\nspec:\n displayName: GS\n'; + const yamlContent = + 'metadata:\n name: getting-started\nspec:\n displayName: GS\n'; mockedGetRepoQuickstartContent.mockResolvedValueOnce({ name: 'getting-started', files: [ @@ -864,7 +914,9 @@ spec: renderWithContext(); await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /load from repo/i })); + fireEvent.click( + screen.getByRole('button', { name: /load from repo/i }) + ); }); await waitFor(() => { @@ -881,7 +933,9 @@ spec: await new Promise((r) => setTimeout(r, 0)); }); - expect(mockedGetRepoQuickstartContent).toHaveBeenCalledWith('getting-started'); + expect(mockedGetRepoQuickstartContent).toHaveBeenCalledWith( + 'getting-started' + ); const editor = screen.getByTestId('mock-monaco-editor'); const editorValue = (editor as HTMLTextAreaElement).value; expect(editorValue).toContain('kind: QuickStarts'); diff --git a/src/components/creator/CreatorYAMLView.tsx b/src/components/creator/CreatorYAMLView.tsx index 82c1f135..32df9bb5 100644 --- a/src/components/creator/CreatorYAMLView.tsx +++ b/src/components/creator/CreatorYAMLView.tsx @@ -43,9 +43,9 @@ import { UploadIcon, } from '@patternfly/react-icons'; import { + RepoQuickstartEntry, getRepoQuickstartContent, listRepoQuickstarts, - RepoQuickstartEntry, } from '../../utils/createQuickstartPR'; import Editor from '@monaco-editor/react'; import YAML from 'yaml'; @@ -439,7 +439,9 @@ const CreatorYAMLView: React.FC = ({ }) => { const { files } = useContext(CreatorWizardContext); - const showCreatePR = useFlag('platform.learning-resources.quickstarts.git-service'); + const showCreatePR = useFlag( + 'platform.learning-resources.quickstarts.git-service' + ); const [parsedName, setParsedName] = useState(null); const { @@ -454,7 +456,9 @@ const CreatorYAMLView: React.FC = ({ } = useCreatePR(parsedName); const [repoModalOpen, setRepoModalOpen] = useState(false); - const [repoQuickstarts, setRepoQuickstarts] = useState([]); + const [repoQuickstarts, setRepoQuickstarts] = useState( + [] + ); const [repoLoading, setRepoLoading] = useState(false); const [repoSearch, setRepoSearch] = useState(''); const [repoError, setRepoError] = useState(null); @@ -708,7 +712,9 @@ const CreatorYAMLView: React.FC = ({ const entries = await listRepoQuickstarts(); setRepoQuickstarts(entries); } catch (err) { - setRepoError(err instanceof Error ? err.message : 'Failed to load quickstarts'); + setRepoError( + err instanceof Error ? err.message : 'Failed to load quickstarts' + ); } finally { setRepoLoading(false); } @@ -741,7 +747,9 @@ const CreatorYAMLView: React.FC = ({ parseAndUpdateQuickstart(finalContent); } } catch (err) { - setParseError(err instanceof Error ? err.message : 'Failed to load quickstart'); + setParseError( + err instanceof Error ? err.message : 'Failed to load quickstart' + ); } }; @@ -952,7 +960,11 @@ const CreatorYAMLView: React.FC = ({ title="Pull Request Created" className="pf-v6-u-mb-md" isInline - actionClose={} + actionClose={ + + } > {prResult.prUrl} @@ -965,7 +977,11 @@ const CreatorYAMLView: React.FC = ({ title="Failed to Create PR" className="pf-v6-u-mb-md" isInline - actionClose={} + actionClose={ + + } > {prError}{' '} - , - ]} - /> - - - {filtered.map((qs) => ( - - - - - , - ]} - /> - - - ))} - {filtered.length === 0 && quickstarts.length > 0 && ( - - - - No quickstarts found - , - ]} - /> - - - )} - - - )} + {error} + + )} + {!loading && !error && ( +
+ + + + + + , + ]} + /> + + + {filtered.map((qs) => ( + + + + + , + ]} + /> + + + ))} + {filtered.length === 0 && quickstarts.length > 0 && ( + + + + No quickstarts found + , + ]} + /> + + + )} + +
+ )} diff --git a/src/components/creator/schema.tsx b/src/components/creator/schema.tsx index 533327ee..04107831 100644 --- a/src/components/creator/schema.tsx +++ b/src/components/creator/schema.tsx @@ -91,7 +91,11 @@ export function stageFromStepName(name: string): CreatorWizardStage { throw new Error('unable to parse step name: ' + name); } -export function makeSchema(chrome: ChromeAPI, filterData: FilterData, showGitService = false): Schema { +export function makeSchema( + chrome: ChromeAPI, + filterData: FilterData, + showGitService = false +): Schema { const bundles = chrome.getAvailableBundles(); const taskSteps = []; diff --git a/src/components/creator/useCreatePR.ts b/src/components/creator/useCreatePR.ts index bf44d089..e665fbcb 100644 --- a/src/components/creator/useCreatePR.ts +++ b/src/components/creator/useCreatePR.ts @@ -1,8 +1,8 @@ import { useContext, useEffect, useState } from 'react'; import { useChrome } from '@redhat-cloud-services/frontend-components/useChrome'; import { - createQuickstartPR, PRResponse, + createQuickstartPR, quickstartExists, } from '../../utils/createQuickstartPR'; import { CreatorWizardContext } from './context'; @@ -42,9 +42,8 @@ export function useCreatePR(quickstartName: string | null) { const identity = user?.identity?.user; if (identity?.email) { const name = - [identity.first_name, identity.last_name] - .filter(Boolean) - .join(' ') || identity.email; + [identity.first_name, identity.last_name].filter(Boolean).join(' ') || + identity.email; commitMessage += `\n\nCo-authored-by: ${name} <${identity.email}>`; } const result = await createQuickstartPR(files, { diff --git a/src/utils/createQuickstartPR.test.ts b/src/utils/createQuickstartPR.test.ts index 0ce9da01..1e26ce81 100644 --- a/src/utils/createQuickstartPR.test.ts +++ b/src/utils/createQuickstartPR.test.ts @@ -1,17 +1,20 @@ import axios from 'axios'; import { + PRFile, + PRMetadata, createQuickstartPR, getRepoQuickstartContent, listRepoQuickstarts, - PRFile, - PRMetadata, } from './createQuickstartPR'; jest.mock('axios'); const mockedAxios = axios as jest.Mocked; const MOCK_FILES: PRFile[] = [ - { name: 'metadata.yaml', content: 'kind: QuickStarts\nmetadata:\n name: my-qs\n' }, + { + name: 'metadata.yaml', + content: 'kind: QuickStarts\nmetadata:\n name: my-qs\n', + }, { name: 'my-qs.yaml', content: 'spec:\n displayName: My QS\n' }, ]; @@ -19,7 +22,8 @@ const MOCK_METADATA: PRMetadata = { branchName: 'qs-create-my-qs-1234567890', commitMessage: 'feat(quickstarts): add my-qs', prTitle: 'feat(quickstarts): add my-qs', - prBody: 'Adding new quickstart via the Quickstarts Creator tool.\n\nDirectory: docs/quickstarts/my-qs/', + prBody: + 'Adding new quickstart via the Quickstarts Creator tool.\n\nDirectory: docs/quickstarts/my-qs/', isUpdate: false, directoryName: 'my-qs', }; @@ -62,14 +66,20 @@ describe('createQuickstartPR', () => { it('propagates network errors', async () => { mockedAxios.post.mockRejectedValueOnce(new Error('Network error')); - await expect(createQuickstartPR(MOCK_FILES, MOCK_METADATA)).rejects.toThrow('Network error'); + await expect(createQuickstartPR(MOCK_FILES, MOCK_METADATA)).rejects.toThrow( + 'Network error' + ); }); it('propagates 4xx/5xx errors from the API', async () => { - const apiError = { response: { status: 502, data: { msg: 'git-service unreachable' } } }; + const apiError = { + response: { status: 502, data: { msg: 'git-service unreachable' } }, + }; mockedAxios.post.mockRejectedValueOnce(apiError); - await expect(createQuickstartPR(MOCK_FILES, MOCK_METADATA)).rejects.toEqual(apiError); + await expect(createQuickstartPR(MOCK_FILES, MOCK_METADATA)).rejects.toEqual( + apiError + ); }); it('sends isUpdate: false for new quickstarts', async () => { @@ -83,7 +93,9 @@ describe('createQuickstartPR', () => { }); it('forwards existingPath and isUpdate: true for updates (48694 path)', async () => { - mockedAxios.post.mockResolvedValueOnce({ data: { data: { ...MOCK_RESPONSE, status: 'updated' } } }); + mockedAxios.post.mockResolvedValueOnce({ + data: { data: { ...MOCK_RESPONSE, status: 'updated' } }, + }); const updateMetadata: PRMetadata = { ...MOCK_METADATA, From fe69231e6bd240ca374139287df91585aee06a6a Mon Sep 17 00:00:00 2001 From: Hossam Farid Date: Fri, 7 Aug 2026 14:09:35 -0400 Subject: [PATCH 7/8] fix(git-service): implement code rabbit suggestions --- src/components/creator/CreatorWizard.tsx | 13 +++---- .../creator/CreatorYAMLView.test.tsx | 31 +--------------- src/components/creator/CreatorYAMLView.tsx | 5 +-- src/components/creator/SourceSelector.tsx | 37 ++++++++++++------- src/components/creator/useCreatePR.ts | 35 +++++++++++------- src/utils/createQuickstartPR.ts | 16 +++----- 6 files changed, 60 insertions(+), 77 deletions(-) diff --git a/src/components/creator/CreatorWizard.tsx b/src/components/creator/CreatorWizard.tsx index 97285550..1e909255 100644 --- a/src/components/creator/CreatorWizard.tsx +++ b/src/components/creator/CreatorWizard.tsx @@ -1,5 +1,6 @@ import { Alert, + AlertActionCloseButton, Banner, Button, ClipboardCopy, @@ -318,7 +319,7 @@ const FileDownload = () => { isDisabled={!canCreatePR || prLoading} isLoading={prLoading} > - {prLoading ? 'Creating PR...' : 'Create PR'} + {prLoading ? 'Submitting PR...' : 'Create PR'} @@ -340,9 +341,7 @@ const FileDownload = () => { title="Pull Request Created" isInline actionClose={ - + setPrResult(null)} /> } >
{ title="Failed to Create PR" isInline actionClose={ - + setPrError(null)} /> } > {prError}{' '} @@ -452,7 +449,7 @@ const CreatorWizard = ({ const [viewMode, setViewMode] = useState('wizard'); const schema = useMemo( () => makeSchema(chrome, filterData, showGitService), - [showGitService] + [chrome, filterData, showGitService] ); const availableBundles = useMemo(() => chrome.getAvailableBundles(), []); diff --git a/src/components/creator/CreatorYAMLView.test.tsx b/src/components/creator/CreatorYAMLView.test.tsx index 5eadd2cf..93984b0b 100644 --- a/src/components/creator/CreatorYAMLView.test.tsx +++ b/src/components/creator/CreatorYAMLView.test.tsx @@ -15,7 +15,6 @@ import { createQuickstartPR, getRepoQuickstartContent, listRepoQuickstarts, - quickstartExists, } from '../../utils/createQuickstartPR'; // Mock downloadFile from frontend-components-utilities @@ -70,7 +69,6 @@ jest.mock('@monaco-editor/react', () => { jest.mock('../../utils/createQuickstartPR', () => ({ createQuickstartPR: jest.fn(), - quickstartExists: jest.fn().mockResolvedValue(false), listRepoQuickstarts: jest.fn().mockResolvedValue([]), getRepoQuickstartContent: jest .fn() @@ -80,9 +78,6 @@ jest.mock('../../utils/createQuickstartPR', () => ({ const mockedCreatePR = createQuickstartPR as jest.MockedFunction< typeof createQuickstartPR >; -const mockedQuickstartExists = quickstartExists as jest.MockedFunction< - typeof quickstartExists ->; const mockedListRepoQuickstarts = listRepoQuickstarts as jest.MockedFunction< typeof listRepoQuickstarts >; @@ -826,9 +821,7 @@ spec: afterEach(() => { mockGitServiceFlag = false; }); - it('shows "Creating" label for new quickstarts', async () => { - mockedQuickstartExists.mockResolvedValue(false); - + it('shows "Editing" label when quickstart name is set', async () => { renderWithContext(); const editor = screen.getByTestId('mock-monaco-editor'); fireEvent.change(editor, { @@ -841,27 +834,7 @@ spec: }); await waitFor(() => { - expect(screen.getByText(/Creating: brand-new-qs/)).toBeInTheDocument(); - }); - }); - - it('shows "Updating" label for existing quickstarts', async () => { - mockedQuickstartExists.mockResolvedValue(true); - - renderWithContext(); - const editor = screen.getByTestId('mock-monaco-editor'); - fireEvent.change(editor, { - target: { - value: - 'metadata:\n name: existing-qs\nspec:\n displayName: Existing\n', - }, - }); - act(() => { - jest.advanceTimersByTime(200); - }); - - await waitFor(() => { - expect(screen.getByText(/Updating: existing-qs/)).toBeInTheDocument(); + expect(screen.getByText(/Editing: brand-new-qs/)).toBeInTheDocument(); }); }); }); diff --git a/src/components/creator/CreatorYAMLView.tsx b/src/components/creator/CreatorYAMLView.tsx index 32df9bb5..bb792a33 100644 --- a/src/components/creator/CreatorYAMLView.tsx +++ b/src/components/creator/CreatorYAMLView.tsx @@ -448,7 +448,6 @@ const CreatorYAMLView: React.FC = ({ prLoading, prResult, prError, - isUpdate, canCreatePR, handleCreatePR, setPrResult, @@ -902,9 +901,7 @@ const CreatorYAMLView: React.FC = ({ )} {showCreatePR && parsedName && parsedName !== 'untitled-quickstart' && ( - + )} diff --git a/src/components/creator/SourceSelector.tsx b/src/components/creator/SourceSelector.tsx index 1fd69cde..da76d7b8 100644 --- a/src/components/creator/SourceSelector.tsx +++ b/src/components/creator/SourceSelector.tsx @@ -86,18 +86,16 @@ const SourceSelector = (props: UseFieldApiConfig) => { }; }, []); - const filtered = useMemo( - () => - quickstarts.filter( - (qs) => - qs.name.toLowerCase().includes(search.toLowerCase()) || - qs.displayName.toLowerCase().includes(search.toLowerCase()) - ), - [quickstarts, search] - ); + const filtered = useMemo(() => { + const needle = search.toLowerCase(); + return quickstarts.filter( + (qs) => + (qs.name ?? '').toLowerCase().includes(needle) || + (qs.displayName ?? '').toLowerCase().includes(needle) + ); + }, [quickstarts, search]); - const handleSelectScratch = () => { - input.onChange(SOURCE_SCRATCH); + const clearQuickstartFields = () => { formApi.change(NAME_KIND, undefined); formApi.change(NAME_METADATA_NAME, undefined); formApi.change(NAME_TITLE, undefined); @@ -112,9 +110,16 @@ const SourceSelector = (props: UseFieldApiConfig) => { formApi.change(NAME_TASKS_ARRAY, undefined); }; + const handleSelectScratch = () => { + input.onChange(SOURCE_SCRATCH); + clearQuickstartFields(); + }; + const handleSelectRepo = async (name: string) => { setLoadingName(name); input.onChange(name); + setError(null); + clearQuickstartFields(); try { const content = await getRepoQuickstartContent(name); const yamlFile = content.files.find( @@ -122,10 +127,16 @@ const SourceSelector = (props: UseFieldApiConfig) => { (f.name.endsWith('.yml') || f.name.endsWith('.yaml')) && f.name !== 'metadata.yaml' ); - if (!yamlFile) return; + if (!yamlFile) { + setError(`No quickstart YAML file found in "${name}".`); + return; + } const parsed = YAML.parse(yamlFile.content); - if (!parsed) return; + if (!parsed) { + setError(`The quickstart YAML in "${name}" is empty or not valid.`); + return; + } const spec = parsed.spec || {}; const metadata = parsed.metadata || {}; diff --git a/src/components/creator/useCreatePR.ts b/src/components/creator/useCreatePR.ts index e665fbcb..06cd85fe 100644 --- a/src/components/creator/useCreatePR.ts +++ b/src/components/creator/useCreatePR.ts @@ -1,9 +1,9 @@ -import { useContext, useEffect, useState } from 'react'; +import { useContext, useState } from 'react'; import { useChrome } from '@redhat-cloud-services/frontend-components/useChrome'; import { PRResponse, createQuickstartPR, - quickstartExists, + listRepoQuickstarts, } from '../../utils/createQuickstartPR'; import { CreatorWizardContext } from './context'; @@ -14,15 +14,6 @@ export function useCreatePR(quickstartName: string | null) { const [prLoading, setPrLoading] = useState(false); const [prResult, setPrResult] = useState(null); const [prError, setPrError] = useState(null); - const [isUpdate, setIsUpdate] = useState(false); - - useEffect(() => { - if (quickstartName && quickstartName !== 'untitled-quickstart') { - quickstartExists(quickstartName).then(setIsUpdate); - } else { - setIsUpdate(false); - } - }, [quickstartName]); const canCreatePR = files.length > 0 && @@ -36,6 +27,25 @@ export function useCreatePR(quickstartName: string | null) { setPrError(null); try { const timestamp = Date.now(); + const safeName = quickstartName + .toLowerCase() + .replace(/[^a-z0-9._-]/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^[-.]+|[-.]+$/g, ''); + if (!safeName) { + setPrError('Quickstart name is not valid for a branch name.'); + setPrLoading(false); + return; + } + + let isUpdate = false; + try { + const repoEntries = await listRepoQuickstarts(); + isUpdate = repoEntries.some((e) => e.name === quickstartName); + } catch { + // If repo check fails, default to create + } + const prefix = isUpdate ? 'update' : 'create'; let commitMessage = `feat(quickstarts): ${prefix} ${quickstartName}`; const user = await chrome.auth.getUser(); @@ -47,7 +57,7 @@ export function useCreatePR(quickstartName: string | null) { commitMessage += `\n\nCo-authored-by: ${name} <${identity.email}>`; } const result = await createQuickstartPR(files, { - branchName: `qs-${prefix}-${quickstartName}-${timestamp}`, + branchName: `qs-${prefix}-${safeName}-${timestamp}`, commitMessage, prTitle: `feat(quickstarts): ${prefix} ${quickstartName}`, prBody: `${ @@ -71,7 +81,6 @@ export function useCreatePR(quickstartName: string | null) { prLoading, prResult, prError, - isUpdate, canCreatePR, handleCreatePR, setPrResult, diff --git a/src/utils/createQuickstartPR.ts b/src/utils/createQuickstartPR.ts index 25f693de..9e89647d 100644 --- a/src/utils/createQuickstartPR.ts +++ b/src/utils/createQuickstartPR.ts @@ -26,15 +26,11 @@ export interface PRResponse { export const quickstartExists = async (name: string): Promise => { if (!name || name === 'untitled-quickstart') return false; - try { - const { data } = await axios.get<{ data: { content: unknown }[] }>( - `${API_BASE}/quickstarts`, - { params: { name, limit: 1 } } - ); - return data.data.length > 0; - } catch { - return false; - } + const { data } = await axios.get<{ data: { content: unknown }[] }>( + `${API_BASE}/quickstarts`, + { params: { name, limit: 1 } } + ); + return data.data.length > 0; }; export const createQuickstartPR = async ( @@ -62,7 +58,7 @@ export const listRepoQuickstarts = async (): Promise => { const { data } = await axios.get<{ data: { quickstarts: RepoQuickstartEntry[] }; }>(`${API_BASE}/repo-quickstarts`); - return data.data.quickstarts; + return data.data?.quickstarts ?? []; }; export const getRepoQuickstartContent = async ( From f388f53919d3f86e1b9aec71f05bb444d89f7d12 Mon Sep 17 00:00:00 2001 From: Hossam Farid Date: Fri, 7 Aug 2026 15:58:44 -0400 Subject: [PATCH 8/8] feat(git-service): add a confirmation modal to make a PR, and some code review updates --- src/components/creator/CreatePRModal.tsx | 105 ++++++++++++++++++ src/components/creator/CreatorWizard.tsx | 70 +++++------- .../creator/CreatorYAMLView.test.tsx | 24 ++++ src/components/creator/CreatorYAMLView.tsx | 68 +++++------- 4 files changed, 184 insertions(+), 83 deletions(-) create mode 100644 src/components/creator/CreatePRModal.tsx diff --git a/src/components/creator/CreatePRModal.tsx b/src/components/creator/CreatePRModal.tsx new file mode 100644 index 00000000..8edba18a --- /dev/null +++ b/src/components/creator/CreatePRModal.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { + Alert, + Button, + Content, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + Spinner, +} from '@patternfly/react-core'; +import CodeBranchIcon from '@patternfly/react-icons/dist/dynamic/icons/code-branch-icon'; +import { PRResponse } from '../../utils/createQuickstartPR'; + +type CreatePRModalProps = { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + quickstartName: string; + prLoading: boolean; + prResult: PRResponse | null; + prError: string | null; +}; + +const CreatePRModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + quickstartName, + prLoading, + prResult, + prError, +}) => { + const showConfirm = !prLoading && !prResult && !prError; + + return ( + + + + {showConfirm && ( + + This will submit a pull request for{' '} + {quickstartName} to the quickstarts repository. The + PR will be checked against existing quickstarts to determine if this + is a new submission or an update. + + )} + {prLoading && ( +
+ + + Submitting pull request... + +
+ )} + {prResult && ( + +
+ {prResult.prUrl} + + + )} + {prError && ( + + {prError} + + )} + + + {showConfirm && ( + + )} + {prError && ( + + )} + + +
+ ); +}; + +export default CreatePRModal; diff --git a/src/components/creator/CreatorWizard.tsx b/src/components/creator/CreatorWizard.tsx index 1e909255..7ef22edc 100644 --- a/src/components/creator/CreatorWizard.tsx +++ b/src/components/creator/CreatorWizard.tsx @@ -1,6 +1,4 @@ import { - Alert, - AlertActionCloseButton, Banner, Button, ClipboardCopy, @@ -63,6 +61,7 @@ import CreatorYAMLView from './CreatorYAMLView'; import { useCreatePR } from './useCreatePR'; import SourceSelector from './SourceSelector'; import { useFlag } from '@unleash/proxy-client-react'; +import CreatePRModal from './CreatePRModal'; export type CreatorWizardProps = { onChangeKind: (newKind: ItemKind | null) => void; @@ -255,6 +254,18 @@ const FileDownload = () => { setPrError, } = useCreatePR(quickstartName); + const [prModalOpen, setPrModalOpen] = useState(false); + + const handleOpenPRModal = () => { + setPrResult(null); + setPrError(null); + setPrModalOpen(true); + }; + + const handleClosePRModal = () => { + setPrModalOpen(false); + }; + function doDownload(file: { content: string; name: string }) { const dotIndex = file.name.lastIndexOf('.'); const baseName = @@ -314,12 +325,11 @@ const FileDownload = () => { @@ -334,42 +344,16 @@ const FileDownload = () => { )} - {showGitService && prResult && ( - - setPrResult(null)} /> - } - > - - {prResult.prUrl} - - - - )} - {showGitService && prError && ( - - setPrError(null)} /> - } - > - {prError}{' '} - - - + {showGitService && quickstartName && ( + )} {files.map((file) => ( diff --git a/src/components/creator/CreatorYAMLView.test.tsx b/src/components/creator/CreatorYAMLView.test.tsx index 93984b0b..3ce956cd 100644 --- a/src/components/creator/CreatorYAMLView.test.tsx +++ b/src/components/creator/CreatorYAMLView.test.tsx @@ -735,6 +735,14 @@ spec: fireEvent.click(screen.getByRole('button', { name: /create pr/i })); + await waitFor(() => { + expect( + screen.getByRole('button', { name: /confirm/i }) + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /confirm/i })); + await waitFor(() => { expect( screen.getByText('https://github.com/org/repo/pull/99') @@ -771,6 +779,14 @@ spec: fireEvent.click(screen.getByRole('button', { name: /create pr/i })); + await waitFor(() => { + expect( + screen.getByRole('button', { name: /confirm/i }) + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /confirm/i })); + await waitFor(() => { expect(mockedCreatePR).toHaveBeenCalled(); }); @@ -805,6 +821,14 @@ spec: fireEvent.click(screen.getByRole('button', { name: /create pr/i })); + await waitFor(() => { + expect( + screen.getByRole('button', { name: /confirm/i }) + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /confirm/i })); + await waitFor(() => { expect(screen.getByText(/Network error/)).toBeInTheDocument(); }); diff --git a/src/components/creator/CreatorYAMLView.tsx b/src/components/creator/CreatorYAMLView.tsx index bb792a33..f0e7f3a6 100644 --- a/src/components/creator/CreatorYAMLView.tsx +++ b/src/components/creator/CreatorYAMLView.tsx @@ -57,6 +57,7 @@ import { useCreatePR } from './useCreatePR'; import { ALL_KIND_ENTRIES, ItemKind } from './meta'; import { FilterData } from '../../utils/FiltersCategoryInterface'; import { useFlag } from '@unleash/proxy-client-react'; +import CreatePRModal from './CreatePRModal'; import './CreatorYAMLView.scss'; import { DEFAULT_QUICKSTART_YAML } from '../../data/quickstart-templates'; @@ -454,6 +455,18 @@ const CreatorYAMLView: React.FC = ({ setPrError, } = useCreatePR(parsedName); + const [prModalOpen, setPrModalOpen] = useState(false); + + const handleOpenPRModal = () => { + setPrResult(null); + setPrError(null); + setPrModalOpen(true); + }; + + const handleClosePRModal = () => { + setPrModalOpen(false); + }; + const [repoModalOpen, setRepoModalOpen] = useState(false); const [repoQuickstarts, setRepoQuickstarts] = useState( [] @@ -951,41 +964,6 @@ const CreatorYAMLView: React.FC = ({ }} /> - {showCreatePR && prResult && ( - setPrResult(null)}> - ✕ - - } - > - - {prResult.prUrl} - - - )} - {showCreatePR && prError && ( - setPrError(null)}> - ✕ - - } - > - {prError}{' '} - - - )} {showCreatePR && ( = ({ > )} + {showCreatePR && parsedName && ( + + )} {showCreatePR && (