Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/project-creation-wizard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@openchoreo/backstage-plugin': patch
'@openchoreo/backstage-plugin-common': patch
'@openchoreo/backstage-plugin-catalog-backend-module': patch
'@openchoreo/backstage-plugin-scaffolder-backend-module': patch
---

Add a per-ProjectType "Create Project" wizard, mirroring the Resource creation flow.

Each `ProjectType` / `ClusterProjectType` now generates a scaffolder Template via `PtdToTemplateConverter`, surfaced under a new `?view=projects` browse view with a dedicated "Project" landing card. Selecting a type opens a wizard whose parameters step is driven by the type's `spec.parameters.openAPIV3Schema`, then creates the Project with `spec.type` and `spec.parameters` set via the extended `openchoreo:project:create` action (it falls back to the OpenChoreo API default when these are omitted, keeping the legacy path working). The catalog provider emits these templates during full sync and the event-delta path keeps them current. Replaces the static `create-openchoreo-project` template.
7 changes: 3 additions & 4 deletions app-config.production.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,9 @@ catalog:
target: /app/catalog-entities/org.yaml
rules:
- allow: [Group]
- type: file
target: /app/templates/create-openchoreo-project/template.yaml
rules:
- allow: [Template]
# Per-ProjectType "Create Project" wizards are generated by the
# OpenChoreoEntityProvider (PtdToTemplateConverter), so no static
# create-openchoreo-project Location is registered here.
- type: file
target: /app/templates/create-openchoreo-componenttype/template.yaml
rules:
Expand Down
8 changes: 3 additions & 5 deletions app-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -271,11 +271,9 @@ catalog:
target: ../../catalog-entities/org.yaml
rules:
- allow: [Group]
# Local example template
- type: file
target: ../../templates/create-openchoreo-project/template.yaml
rules:
- allow: [Template]
# Per-ProjectType "Create Project" wizards are generated by the
# OpenChoreoEntityProvider (PtdToTemplateConverter), so no static
# create-openchoreo-project Location is registered here.
- type: file
target: ../../templates/create-openchoreo-componenttype/template.yaml
rules:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,7 @@ export function useKindCreateConfig(): KindCreateConfig | null {
};
case 'system':
return {
createPath: templateRoute({
namespace: 'default',
templateName: 'create-openchoreo-project',
}),
createPath: `${scaffolderRoot}?view=projects`,
buttonLabel: 'Create Project',
canCreate: projectPerm.canCreate,
loading: projectPerm.loading,
Expand Down
128 changes: 117 additions & 11 deletions packages/app/src/components/scaffolder/CustomTemplateListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import FilterListIcon from '@material-ui/icons/FilterList';
import ArrowBackIcon from '@material-ui/icons/ArrowBack';
import WidgetsOutlinedIcon from '@material-ui/icons/WidgetsOutlined';
import StorageOutlinedIcon from '@material-ui/icons/StorageOutlined';
import AccountTreeOutlinedIcon from '@material-ui/icons/AccountTreeOutlined';
import { useRouteRef, useApp } from '@backstage/core-plugin-api';
import { DocsIcon, Page, Header, Content } from '@backstage/core-components';
import {
Expand Down Expand Up @@ -72,11 +73,11 @@ import { CustomTemplateCard } from './CustomTemplateCard';
import { TemplateCardSkeletons } from './TemplateCardSkeleton';
import { useStyles } from './styles';

// 'Resource' templates are presented as a meta-card on the landing view
// (mirrors 'Component') and the per-type templates are listed under
// /create?view=resources. Hide them from the landing application-templates
// grid to avoid duplicate "Resource" cards.
const APPLICATION_TYPES = ['System (Project)'];
// 'Project', 'Component' and 'Resource' are all presented as meta-cards on the
// landing view; their per-type templates are listed under
// /create?view=projects|components|resources. None of them appear as real
// application-template cards, so APPLICATION_TYPES is empty.
const APPLICATION_TYPES: string[] = [];
// Order is significant — this list controls the on-screen order of the
// Platform Resources cards (see platformTemplates sort below). Foundation-
// first to mirror Application Resources (Project → Component → Resource),
Expand All @@ -98,11 +99,13 @@ const PLATFORM_TYPES = [
'ClusterWorkflow',
'Workflow',
];
// 'Resource' is intentionally in KNOWN_CARD_TYPES but NOT in APPLICATION_TYPES
// — per-type Resource templates are rendered under /create?view=resources,
// not in the landing grid or the "Other Templates" catch-all.
// 'Project'/'Component'/'Resource' are in KNOWN_CARD_TYPES but NOT in
// APPLICATION_TYPES — their per-type templates are rendered under the
// respective ?view= pages, not in the landing grid or the "Other Templates"
// catch-all.
const KNOWN_CARD_TYPES = [
...APPLICATION_TYPES,
'Project',
'Component',
'Resource',
...PLATFORM_TYPES,
Expand Down Expand Up @@ -142,6 +145,7 @@ const TemplateListContent = (props: TemplateListPageProps) => {
const [searchParams, setSearchParams] = useSearchParams();
const isComponentsView = searchParams.get('view') === 'components';
const isResourcesView = searchParams.get('view') === 'resources';
const isProjectsView = searchParams.get('view') === 'projects';

const { templateFilter, headerOptions } = props;

Expand Down Expand Up @@ -184,7 +188,7 @@ const TemplateListContent = (props: TemplateListPageProps) => {
const isTemplateDisabled = useCallback(
(specType: string): boolean => {
switch (specType) {
case 'System (Project)':
case 'Project':
return !projectPerm.loading && !projectPerm.canCreate;
case 'Component':
return !componentPerm.loading && !componentPerm.canCreate;
Expand Down Expand Up @@ -365,6 +369,19 @@ const TemplateListContent = (props: TemplateListPageProps) => {
},
];

// Project Templates view shows the per-type wizards generated by
// PtdToTemplateConverter from (Cluster)ProjectType entities. The
// PTD_GENERATED annotation distinguishes them from any standalone
// hand-written Project template.
const projectGroups = [
{
title: 'Project Templates',
filter: (e: any) =>
e.spec?.type === 'Project' &&
e.metadata?.annotations?.[CHOREO_ANNOTATIONS.PTD_GENERATED] === 'true',
},
];

const resourceTemplates = useMemo(
() =>
templates.filter(
Expand Down Expand Up @@ -489,6 +506,10 @@ const TemplateListContent = (props: TemplateListPageProps) => {
setSearchParams({ view: 'resources' });
}, [setSearchParams]);

const navigateToProjectsView = useCallback(() => {
setSearchParams({ view: 'projects' });
}, [setSearchParams]);

const navigateBackToLanding = useCallback(() => {
setSearchParams({});
}, [setSearchParams]);
Expand Down Expand Up @@ -525,7 +546,7 @@ const TemplateListContent = (props: TemplateListPageProps) => {
<Box className={classes.filterRow}>
<ScaffolderSearchBar />
<Box className={classes.categoryFilter}>
{isComponentsView || isResourcesView ? (
{isComponentsView || isResourcesView || isProjectsView ? (
<ScaffolderNamespacePicker />
) : (
<ScaffolderCategoryPicker />
Expand All @@ -552,7 +573,7 @@ const TemplateListContent = (props: TemplateListPageProps) => {
<ScaffolderSearchBar />
</Box>
<Box className={classes.filterItem}>
{isComponentsView || isResourcesView ? (
{isComponentsView || isResourcesView || isProjectsView ? (
<ScaffolderNamespacePicker />
) : (
<ScaffolderCategoryPicker />
Expand All @@ -572,6 +593,37 @@ const TemplateListContent = (props: TemplateListPageProps) => {
);

const renderLandingView = () => {
const projectDisabled = isTemplateDisabled('Project');
const projectCard = (
<Box
className={`${classes.cardBase} ${classes.resourceCard} ${
projectDisabled ? classes.cardDisabled : ''
}`}
onClick={projectDisabled ? undefined : navigateToProjectsView}
onKeyDown={
projectDisabled
? undefined
: e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
navigateToProjectsView();
}
}
}
role="button"
tabIndex={projectDisabled ? -1 : 0}
aria-disabled={projectDisabled || undefined}
>
<Box className={classes.resourceCardIcon}>
<AccountTreeOutlinedIcon fontSize="inherit" />
</Box>
<Typography className={classes.resourceCardTitle}>Project</Typography>
<Typography className={classes.resourceCardDescription}>
Browse project templates
</Typography>
</Box>
);

const componentDisabled = isTemplateDisabled('Component');
const componentCard = (
<Box
Expand Down Expand Up @@ -652,6 +704,16 @@ const TemplateListContent = (props: TemplateListPageProps) => {
) : (
renderTemplateCards(applicationTemplates)
)}
{/* Project — navigation card, opens the per-type templates list */}
<Grid item xs={12} sm={6} md={3}>
{projectDisabled ? (
<Tooltip title={projectPerm.createDeniedTooltip}>
<Box className={classes.cardDisabledWrapper}>{projectCard}</Box>
</Tooltip>
) : (
projectCard
)}
</Grid>
{/* Component — navigation card, no single backing template */}
<Grid item xs={12} sm={6} md={3}>
{componentDisabled ? (
Expand Down Expand Up @@ -745,6 +807,27 @@ const TemplateListContent = (props: TemplateListPageProps) => {
return Card;
}, [getResourceTemplateCardState]);

// Project create permission is namespace-scoped and generic (there is no
// per-projectType create permission), so the card is gated by the single
// project:create decision rather than a per-type context check.
const ProjectTemplateCard = useMemo(() => {
const projectDisabled = !projectPerm.loading && !projectPerm.canCreate;
const Card = (cardProps: {
template: TemplateEntityV1beta3;
onSelected?: (template: TemplateEntityV1beta3) => void;
}) => (
<CustomTemplateCard
{...cardProps}
disabled={projectDisabled}
disabledReason={
projectDisabled ? projectPerm.createDeniedTooltip : undefined
}
/>
);
Card.displayName = 'ProjectTemplateCard';
return Card;
}, [projectPerm]);

const renderComponentsView = () => (
<>
<Box
Expand Down Expand Up @@ -789,6 +872,28 @@ const TemplateListContent = (props: TemplateListPageProps) => {
</>
);

const renderProjectsView = () => (
<>
<Box
component="button"
className={classes.backButton}
onClick={navigateBackToLanding}
>
<ArrowBackIcon fontSize="small" />
Back to Resources
</Box>
<Box className={classes.contentArea}>
<TemplateGroups
groups={projectGroups}
templateFilter={templateFilter}
TemplateCardComponent={ProjectTemplateCard}
onTemplateSelected={onTemplateSelected}
additionalLinksForEntity={additionalLinksForEntity}
/>
</Box>
</>
);

return (
<Page themeId="home">
<Header
Expand Down Expand Up @@ -824,6 +929,7 @@ const TemplateListContent = (props: TemplateListPageProps) => {
{(() => {
if (isComponentsView) return renderComponentsView();
if (isResourcesView) return renderResourcesView();
if (isProjectsView) return renderProjectsView();
return renderLandingView();
})()}
</Box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { ResourceTypeYamlEditorFieldExtension } from '../../scaffolder/ResourceT
import { ClusterProjectTypeYamlEditorFieldExtension } from '../../scaffolder/ClusterProjectTypeYamlEditor';
import { ProjectTypeYamlEditorFieldExtension } from '../../scaffolder/ProjectTypeYamlEditor';
import { ResourceParametersFieldExtension } from '../../scaffolder/ResourceParametersField';
import { ProjectParametersFieldExtension } from '../../scaffolder/ProjectParametersField';
import { ClusterTraitYamlEditorFieldExtension } from '../../scaffolder/ClusterTraitYamlEditor';
import { ComponentWorkflowYamlEditorFieldExtension } from '../../scaffolder/ComponentWorkflowYamlEditor';
import { ClusterWorkflowYamlEditorFieldExtension } from '../../scaffolder/ClusterWorkflowYamlEditor';
Expand Down Expand Up @@ -80,6 +81,7 @@ export function OpenChoreoScaffolderPage() {
<ClusterProjectTypeYamlEditorFieldExtension />
<ProjectTypeYamlEditorFieldExtension />
<ResourceParametersFieldExtension />
<ProjectParametersFieldExtension />
<ClusterTraitYamlEditorFieldExtension />
<ComponentWorkflowYamlEditorFieldExtension />
<ClusterWorkflowYamlEditorFieldExtension />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { render, screen } from '@testing-library/react';
import type { ReviewStepProps } from '@backstage/plugin-scaffolder-react';
import { CustomReviewStep } from './CustomReviewStep';

// Stub StructuredMetadataTable so we can read exactly what each review
// section forwards as its `metadata` prop.
jest.mock('@backstage/core-components', () => ({
StructuredMetadataTable: (props: { metadata: Record<string, unknown> }) => (
<div data-testid="smt" data-metadata={JSON.stringify(props.metadata)} />
),
}));

jest.mock('./styles', () => ({
useStyles: () => ({
reviewContent: 'reviewContent',
sectionTitle: 'sectionTitle',
footer: 'footer',
promotionPathRow: 'promotionPathRow',
envBox: 'envBox',
arrow: 'arrow',
}),
}));

const makeProps = (formData: Record<string, unknown>): ReviewStepProps =>
({
formData,
steps: [],
handleBack: jest.fn(),
handleCreate: jest.fn(),
disableButtons: false,
} as unknown as ReviewStepProps);

const tables = () =>
screen
.getAllByTestId('smt')
.map(el => JSON.parse(el.getAttribute('data-metadata') || '{}'));

describe('CustomReviewStep — Project (per-ProjectType) templates', () => {
it('renders Project Metadata and Parameters sections from a project template', () => {
render(
<CustomReviewStep
{...makeProps({
namespace_name: 'domain:default/default',
project_name: 'web-app-demo',
displayName: 'Web App Demo',
description: 'A demo project',
deployment_pipeline: 'default',
parameters: { appName: 'my-app', replicas: 3 },
})}
/>,
);

expect(screen.getByText('Project Metadata')).toBeInTheDocument();
expect(screen.getByText('Parameters')).toBeInTheDocument();

const all = tables();
expect(all).toHaveLength(2);

// Metadata table: namespace ref is shortened to its name.
const meta = JSON.stringify(all[0]);
expect(meta).toContain('default');
expect(meta).toContain('web-app-demo');
expect(meta).toContain('Web App Demo');

// Parameters table: schema-driven values are flattened in.
expect(JSON.stringify(all[1])).toContain('my-app');
});

it('omits the Parameters section for a type with no parameters', () => {
render(
<CustomReviewStep
{...makeProps({
namespace_name: 'domain:default/default',
project_name: 'minimal-demo',
deployment_pipeline: 'default',
})}
/>,
);

expect(screen.getByText('Project Metadata')).toBeInTheDocument();
expect(screen.queryByText('Parameters')).not.toBeInTheDocument();
expect(tables()).toHaveLength(1);
});
});
Loading
Loading