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 c205d9e3..65734996 100644 --- a/src/Creator.tsx +++ b/src/Creator.tsx @@ -22,10 +22,7 @@ import useSuspenseLoader, { import fetchFilters from './utils/fetchFilters'; import { ExtendedQuickstart } from './utils/fetchQuickstarts'; import useFilterMap from './hooks/useFilterMap'; - -const BASE_METADATA = { - name: 'test-quickstart', -}; +import { useFlag } from '@unleash/proxy-client-react'; function makeDemoQuickStart( kind: ItemKind | null, @@ -37,7 +34,6 @@ function makeDemoQuickStart( ...baseQuickStart, metadata: { ...baseQuickStart.metadata, - name: 'test-quickstart', ...(kindMeta?.extraMetadata ?? {}), }, }; @@ -51,6 +47,9 @@ 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 }); @@ -89,6 +88,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 +148,8 @@ const CreatorInternal = ({ }); }); updates.metadata = { + name: old.metadata.name, tags: allTags, - ...BASE_METADATA, ...meta.extraMetadata, }; @@ -151,10 +160,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 @@ -233,6 +259,9 @@ const CreatorInternal = ({ updateSpec(() => spec); }} onChangeMetadataTags={updateMetadataTags} + onChangeMetadataName={ + showGitService ? updateMetadataName : undefined + } filterData={filterData} onChangeBundles={setBundles} onChangeCurrentStage={setCurrentStage} 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 && ( + } + > + Confirm + + )} + {prError && ( + + Retry + + )} + + {prResult ? 'Close' : 'Cancel'} + + + + ); +}; + +export default CreatePRModal; 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 a2c35216..7ef22edc 100644 --- a/src/components/creator/CreatorWizard.tsx +++ b/src/components/creator/CreatorWizard.tsx @@ -14,6 +14,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, @@ -42,6 +43,7 @@ import { NAME_DESCRIPTION, NAME_DURATION, NAME_KIND, + NAME_METADATA_NAME, NAME_PANEL_INTRODUCTION, NAME_PREREQUISITES, NAME_TAGS, @@ -56,6 +58,10 @@ import { CreatorFiles } from './types'; import { FilterData } from '../../utils/FiltersCategoryInterface'; import TagsSelector from './TagsSelector'; 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; @@ -67,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[] }; @@ -84,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[] = ['']; @@ -112,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 ?? []); @@ -124,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]; @@ -214,8 +230,42 @@ const PropUpdater = ({ }; const FileDownload = () => { + const showGitService = useFlag( + 'platform.learning-resources.quickstarts.git-service' + ); 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); + + 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 = @@ -239,31 +289,73 @@ const FileDownload = () => { - - Download these files and use them to create the learning resource PR - in the{' '} - - {' '} - correct repo - - . - + {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 + + . + + )} - } - onClick={() => files.forEach((file) => doDownload(file))} - > - Download all ({files.length}) files - + {showGitService ? ( + + + } + onClick={() => files.forEach((file) => doDownload(file))} + > + Download all ({files.length}) files + + + + } + onClick={handleOpenPRModal} + isDisabled={!canCreatePR} + > + Create PR + + + + ) : ( + } + onClick={() => files.forEach((file) => doDownload(file))} + > + Download all ({files.length}) files + + )} + {showGitService && quickstartName && ( + + )} + {files.map((file) => ( { 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), + [chrome, filterData, showGitService] + ); const availableBundles = useMemo(() => chrome.getAvailableBundles(), []); // [viewMode] only, including props like quickStart, currentKind, etc would recompute on @@ -390,6 +489,7 @@ const CreatorWizard = ({ 'lr-task-title-preview': TaskTitlePreview, 'lr-string-array': StringArrayInput, 'lr-tag-filter-selector': TagsSelector, + 'lr-source-selector': SourceSelector, }; return ( @@ -444,6 +544,7 @@ const CreatorWizard = ({ onChangeTags={onChangeTags} onChangeBundles={onChangeBundles} onChangeQuickStartSpec={onChangeQuickStartSpec} + onChangeMetadataName={onChangeMetadataName} /> )} 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.test.tsx b/src/components/creator/CreatorYAMLView.test.tsx index 93b1fc43..3ce956cd 100644 --- a/src/components/creator/CreatorYAMLView.test.tsx +++ b/src/components/creator/CreatorYAMLView.test.tsx @@ -11,6 +11,11 @@ import { CreatorWizardContext } from './context'; import { CreatorFiles } from './types'; import { DEFAULT_QUICKSTART_YAML } from '../../data/quickstart-templates'; import { ExtendedQuickstart } from '../../utils/fetchQuickstarts'; +import { + createQuickstartPR, + getRepoQuickstartContent, + listRepoQuickstarts, +} from '../../utils/createQuickstartPR'; // Mock downloadFile from frontend-components-utilities const mockDownloadFile = jest.fn(); @@ -21,6 +26,28 @@ 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 }, + }), +})); + +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 = ({ @@ -40,6 +67,25 @@ jest.mock('@monaco-editor/react', () => { return { __esModule: true, default: MockEditor }; }); +jest.mock('../../utils/createQuickstartPR', () => ({ + createQuickstartPR: jest.fn(), + listRepoQuickstarts: jest.fn().mockResolvedValue([]), + getRepoQuickstartContent: jest + .fn() + .mockResolvedValue({ name: '', files: [] }), +})); + +const mockedCreatePR = createQuickstartPR as jest.MockedFunction< + typeof createQuickstartPR +>; +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' }, { name: 'test.yaml', content: 'spec:\n displayName: Test\n' }, @@ -632,4 +678,266 @@ spec: ); }); }); + + describe('Create PR button', () => { + beforeEach(() => { + mockGitServiceFlag = true; + }); + afterEach(() => { + mockGitServiceFlag = false; + }); + 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.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') + ).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( + screen.getByRole('button', { name: /confirm/i }) + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /confirm/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.getByRole('button', { name: /confirm/i }) + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /confirm/i })); + + await waitFor(() => { + expect(screen.getByText(/Network error/)).toBeInTheDocument(); + }); + expect( + screen.getByRole('button', { name: /retry/i }) + ).toBeInTheDocument(); + }); + }); + + describe('Mode detection label', () => { + beforeEach(() => { + mockGitServiceFlag = true; + }); + afterEach(() => { + mockGitServiceFlag = false; + }); + it('shows "Editing" label when quickstart name is set', async () => { + 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(/Editing: brand-new-qs/)).toBeInTheDocument(); + }); + }); + }); + + describe('Load from Repo', () => { + beforeEach(() => { + mockGitServiceFlag = true; + }); + afterEach(() => { + mockGitServiceFlag = false; + }); + 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'); + 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 9ba77030..f0e7f3a6 100644 --- a/src/components/creator/CreatorYAMLView.tsx +++ b/src/components/creator/CreatorYAMLView.tsx @@ -9,34 +9,55 @@ 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 { + RepoQuickstartEntry, + getRepoQuickstartContent, + listRepoQuickstarts, +} 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 { ExtendedQuickstart } from '../../utils/fetchQuickstarts'; 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 CreatePRModal from './CreatePRModal'; import './CreatorYAMLView.scss'; import { DEFAULT_QUICKSTART_YAML } from '../../data/quickstart-templates'; @@ -204,9 +225,6 @@ function serializeToYaml( ...(quickStart.spec.description ? { description: quickStart.spec.description } : {}), - ...(quickStart.spec.durationMinutes !== undefined - ? { durationMinutes: quickStart.spec.durationMinutes } - : {}), ...(quickStart.spec.type ? { type: { @@ -215,6 +233,9 @@ function serializeToYaml( }, } : {}), + ...(quickStart.spec.durationMinutes !== undefined + ? { durationMinutes: quickStart.spec.durationMinutes } + : {}), ...(quickStart.spec.link ? { link: { @@ -419,6 +440,41 @@ const CreatorYAMLView: React.FC = ({ }) => { const { files } = useContext(CreatorWizardContext); + const showCreatePR = useFlag( + 'platform.learning-resources.quickstarts.git-service' + ); + + const [parsedName, setParsedName] = useState(null); + const { + prLoading, + prResult, + prError, + canCreatePR, + handleCreatePR, + setPrResult, + 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( + [] + ); + 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. const getInitialYaml = (): string => { @@ -457,6 +513,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 = { @@ -510,6 +575,11 @@ const CreatorYAMLView: React.FC = ({ // Update state setParseError(null); + const name = metadata.name || null; + if (name !== parsedName) { + setParsedName(name); + } + // Detect and propagate kind from spec.type const detectedKind = detectKind(spec); if (onChangeKind) { @@ -645,6 +715,62 @@ const CreatorYAMLView: React.FC = ({ }); }; + 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) { + 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' + ); + } + }; + + 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. @@ -756,22 +882,41 @@ const CreatorYAMLView: React.FC = ({ data-testid="yaml-file-input" /> - - + {!showCreatePR && ( + + + } + onClick={handleDownload} + size="sm" + isDisabled={!canDownload} + > + Download Files ({files.length}) + + + + )} + {showCreatePR && ( + } - onClick={handleDownload} + variant="secondary" + icon={} + onClick={handleOpenRepoModal} size="sm" - isDisabled={!canDownload} > - Download Files ({files.length}) + Load from Repo - - + + )} + {showCreatePR && parsedName && parsedName !== 'untitled-quickstart' && ( + + Editing: {parsedName} + + )} {hasMetadataSelectors && ( = ({ }} /> + {showCreatePR && ( + + + + } + onClick={handleDownload} + size="sm" + isDisabled={!canDownload} + > + Download Files ({files.length}) + + + + + + } + onClick={handleOpenPRModal} + size="sm" + isDisabled={!canCreatePR || !canDownload} + > + Create PR + + + + + )} + {showCreatePR && parsedName && ( + + )} + {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) => ( + + + + handleLoadFromRepo(qs.name)} + > + {qs.displayName || qs.name} + + , + ]} + /> + + + ))} + {filteredRepoQuickstarts.length === 0 && ( + + + + No quickstarts found + , + ]} + /> + + + )} + + )} + + + setRepoModalOpen(false)}> + Cancel + + + + )} ); }; diff --git a/src/components/creator/SourceSelector.tsx b/src/components/creator/SourceSelector.tsx new file mode 100644 index 00000000..da76d7b8 --- /dev/null +++ b/src/components/creator/SourceSelector.tsx @@ -0,0 +1,334 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + Alert, + Content, + DataList, + DataListCell, + DataListItem, + DataListItemCells, + DataListItemRow, + SearchInput, + Spinner, +} from '@patternfly/react-core'; +import { + UseFieldApiConfig, + useFieldApi, + useFormApi, +} from '@data-driven-forms/react-form-renderer'; +import YAML from 'yaml'; +import { + RepoQuickstartEntry, + getRepoQuickstartContent, + listRepoQuickstarts, +} from '../../utils/createQuickstartPR'; +import { + NAME_BUNDLES, + NAME_DESCRIPTION, + NAME_DURATION, + NAME_KIND, + NAME_METADATA_NAME, + NAME_PANEL_INTRODUCTION, + NAME_PREREQUISITES, + NAME_TAGS, + NAME_TASKS_ARRAY, + NAME_TASK_TITLES, + NAME_TITLE, + NAME_URL, +} 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(() => { + const needle = search.toLowerCase(); + return quickstarts.filter( + (qs) => + (qs.name ?? '').toLowerCase().includes(needle) || + (qs.displayName ?? '').toLowerCase().includes(needle) + ); + }, [quickstarts, search]); + + const clearQuickstartFields = () => { + 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 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( + (f) => + (f.name.endsWith('.yml') || f.name.endsWith('.yaml')) && + f.name !== 'metadata.yaml' + ); + if (!yamlFile) { + setError(`No quickstart YAML file found in "${name}".`); + return; + } + + const parsed = YAML.parse(yamlFile.content); + if (!parsed) { + setError(`The quickstart YAML in "${name}" is empty or not valid.`); + 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. + + + + + Select source + + {' '} + * + + + + setSearch(value)} + onClear={() => setSearch('')} + className="pf-v6-u-mb-sm" + /> + + {loading && } + + {error && ( + + {error} + + )} + + {!loading && !error && ( + + + + + + + Start from scratch + + , + ]} + /> + + + {filtered.map((qs) => ( + + + + handleSelectRepo(qs.name)} + disabled={loadingName !== null} + style={{ + fontWeight: selected === qs.name ? 700 : 400, + }} + > + {qs.displayName || qs.name} + {loadingName === qs.name && ( + + )} + + , + ]} + /> + + + ))} + {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..04107831 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' }; @@ -89,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): Schema { +export function makeSchema( + chrome: ChromeAPI, + filterData: FilterData, + showGitService = false +): Schema { const bundles = chrome.getAvailableBundles(); const taskSteps = []; @@ -113,6 +119,7 @@ export function makeSchema(chrome: ChromeAPI, filterData: FilterData): Schema { isDynamic: true, crossroads: [NAME_KIND, NAME_TASK_TITLES], fields: [ + ...(showGitService ? [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/components/creator/useCreatePR.ts b/src/components/creator/useCreatePR.ts new file mode 100644 index 00000000..06cd85fe --- /dev/null +++ b/src/components/creator/useCreatePR.ts @@ -0,0 +1,89 @@ +import { useContext, useState } from 'react'; +import { useChrome } from '@redhat-cloud-services/frontend-components/useChrome'; +import { + PRResponse, + createQuickstartPR, + listRepoQuickstarts, +} 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 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 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(); + 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}-${safeName}-${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, + canCreatePR, + handleCreatePR, + setPrResult, + setPrError, + }; +} diff --git a/src/utils/createQuickstartPR.test.ts b/src/utils/createQuickstartPR.test.ts new file mode 100644 index 00000000..1e26ce81 --- /dev/null +++ b/src/utils/createQuickstartPR.test.ts @@ -0,0 +1,193 @@ +import axios from 'axios'; +import { + PRFile, + PRMetadata, + createQuickstartPR, + getRepoQuickstartContent, + listRepoQuickstarts, +} 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..9e89647d --- /dev/null +++ b/src/utils/createQuickstartPR.ts @@ -0,0 +1,71 @@ +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; + const { data } = await axios.get<{ data: { content: unknown }[] }>( + `${API_BASE}/quickstarts`, + { params: { name, limit: 1 } } + ); + return data.data.length > 0; +}; + +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; +};