feat(scaffolder): config-driven template groups and swappable TemplateCard (#34147)
* feat(scaffolder): config-driven template groups and swappable TemplateCard Signed-off-by: benjdlambert <ben@blam.sh> * refactor(scaffolder): keep createGroupsWithOther internal Signed-off-by: benjdlambert <ben@blam.sh> * docs(scaffolder): fix sub-page extension ID in changeset Signed-off-by: benjdlambert <ben@blam.sh> * address PR review feedback Signed-off-by: benjdlambert <ben@blam.sh> * split TemplateCard swappable contract from legacy props Signed-off-by: benjdlambert <ben@blam.sh> * address review feedback: dedupe tags, defensive groups copy, doc clarifications Signed-off-by: benjdlambert <ben@blam.sh> * regenerate api reports Signed-off-by: benjdlambert <ben@blam.sh> * align docs and changeset with actual default group titles Signed-off-by: benjdlambert <ben@blam.sh> * regen api reports after rebase Signed-off-by: benjdlambert <ben@blam.sh> --------- Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
@@ -32,6 +32,7 @@ import { ScaffolderStep } from '@backstage/plugin-scaffolder-react';
|
||||
import { ScaffolderTaskOutput } from '@backstage/plugin-scaffolder-react';
|
||||
import { SetStateAction } from 'react';
|
||||
import { StyleRules } from '@material-ui/core/styles/withStyles';
|
||||
import { SwappableComponentRef } from '@backstage/frontend-plugin-api';
|
||||
import { TaskStep } from '@backstage/plugin-scaffolder-common';
|
||||
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
import { TemplateGroupFilter } from '@backstage/plugin-scaffolder-react';
|
||||
@@ -389,7 +390,27 @@ export interface TaskStepsProps {
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export const TemplateCard: (props: TemplateCardProps) => JSX_2.Element;
|
||||
export const TemplateCard: {
|
||||
(props: TemplateCardComponentProps): JSX.Element | null;
|
||||
ref: SwappableComponentRef<
|
||||
TemplateCardComponentProps,
|
||||
TemplateCardComponentProps
|
||||
>;
|
||||
};
|
||||
|
||||
// @alpha
|
||||
export interface TemplateCardComponentProps {
|
||||
// (undocumented)
|
||||
additionalLinks?: {
|
||||
icon: IconComponent;
|
||||
text: string;
|
||||
url: string;
|
||||
}[];
|
||||
// (undocumented)
|
||||
onSelected?: () => void;
|
||||
// (undocumented)
|
||||
template: TemplateEntityV1beta3;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface TemplateCardProps {
|
||||
|
||||
@@ -405,7 +405,7 @@ describe('TemplateCard', () => {
|
||||
|
||||
fireEvent.click(getByRole('button', { name: 'Choose' }));
|
||||
|
||||
expect(mockOnSelected).toHaveBeenCalledWith(mockTemplate);
|
||||
expect(mockOnSelected).toHaveBeenCalledWith();
|
||||
});
|
||||
it('should not render the choose button when user has insufficient permissions', async () => {
|
||||
const mockTemplate: TemplateEntityV1beta3 = {
|
||||
|
||||
@@ -14,95 +14,23 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { RELATION_OWNED_BY } from '@backstage/catalog-model';
|
||||
import { IconComponent, useAnalytics } from '@backstage/core-plugin-api';
|
||||
import { getEntityRelations } from '@backstage/plugin-catalog-react';
|
||||
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
import Card from '@material-ui/core/Card';
|
||||
import CardActions from '@material-ui/core/CardActions';
|
||||
import CardContent from '@material-ui/core/CardContent';
|
||||
import Divider from '@material-ui/core/Divider';
|
||||
import Grid from '@material-ui/core/Grid';
|
||||
import { makeStyles, Theme } from '@material-ui/core/styles';
|
||||
import { useCallback } from 'react';
|
||||
import { CardHeader } from './CardHeader';
|
||||
import { usePermission } from '@backstage/plugin-permission-react';
|
||||
import { taskCreatePermission } from '@backstage/plugin-scaffolder-common/alpha';
|
||||
import { TemplateCardContent } from './TemplateCardContent';
|
||||
import { TemplateCardTags } from './TemplateCardTags';
|
||||
import { TemplateCardLinks } from './TemplateCardLinks';
|
||||
import { TemplateCardActions } from './TemplateCardActions';
|
||||
import { createSwappableComponent } from '@backstage/frontend-plugin-api';
|
||||
import type { TemplateCardComponentProps } from './TemplateCardImpl';
|
||||
|
||||
const useStyles = makeStyles<Theme>(() => ({
|
||||
actionContainer: { padding: '16px', flex: 1, alignItems: 'flex-end' },
|
||||
}));
|
||||
export type {
|
||||
TemplateCardProps,
|
||||
TemplateCardComponentProps,
|
||||
} from './TemplateCardImpl';
|
||||
|
||||
/**
|
||||
* The Props for the {@link TemplateCard} component
|
||||
* The `TemplateCard` component that is rendered in a list for each template.
|
||||
* Apps using the new frontend system can replace it by registering a
|
||||
* `SwappableComponentBlueprint` that targets `TemplateCard`.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface TemplateCardProps {
|
||||
template: TemplateEntityV1beta3;
|
||||
additionalLinks?: {
|
||||
icon: IconComponent;
|
||||
text: string;
|
||||
url: string;
|
||||
}[];
|
||||
onSelected?: (template: TemplateEntityV1beta3) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `TemplateCard` component that is rendered in a list for each template
|
||||
* @alpha
|
||||
*/
|
||||
export const TemplateCard = (props: TemplateCardProps) => {
|
||||
const { additionalLinks, onSelected, template } = props;
|
||||
const styles = useStyles();
|
||||
const analytics = useAnalytics();
|
||||
const ownedByRelations = getEntityRelations(template, RELATION_OWNED_BY);
|
||||
const hasTags = !!template.metadata.tags?.length;
|
||||
const hasLinks =
|
||||
!!additionalLinks?.length || !!template.metadata.links?.length;
|
||||
const displayDefaultDivider = !hasTags && !hasLinks;
|
||||
|
||||
const { allowed: canCreateTask } = usePermission({
|
||||
permission: taskCreatePermission,
|
||||
export const TemplateCard =
|
||||
createSwappableComponent<TemplateCardComponentProps>({
|
||||
id: 'scaffolder.templateCard',
|
||||
loader: () => import('./TemplateCardImpl').then(m => m.TemplateCardImpl),
|
||||
});
|
||||
const handleChoose = useCallback(() => {
|
||||
analytics.captureEvent('click', `Template has been opened`);
|
||||
onSelected?.(template);
|
||||
}, [analytics, onSelected, template]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader template={template} data-testid="template-card-header" />
|
||||
<CardContent>
|
||||
<Grid container spacing={2} data-testid="template-card-content">
|
||||
<TemplateCardContent template={template} />
|
||||
{displayDefaultDivider && (
|
||||
<Grid item xs={12}>
|
||||
<Divider data-testid="template-card-separator" />
|
||||
</Grid>
|
||||
)}
|
||||
{hasTags && <TemplateCardTags template={template} />}
|
||||
{hasLinks && (
|
||||
<TemplateCardLinks
|
||||
template={template}
|
||||
additionalLinks={additionalLinks}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
</CardContent>
|
||||
<CardActions
|
||||
className={styles.actionContainer}
|
||||
data-testid="template-card-actions"
|
||||
>
|
||||
<TemplateCardActions
|
||||
canCreateTask={canCreateTask}
|
||||
handleChoose={handleChoose}
|
||||
ownedByRelations={ownedByRelations}
|
||||
/>
|
||||
</CardActions>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { RELATION_OWNED_BY } from '@backstage/catalog-model';
|
||||
import { IconComponent, useAnalytics } from '@backstage/core-plugin-api';
|
||||
import { getEntityRelations } from '@backstage/plugin-catalog-react';
|
||||
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
import Card from '@material-ui/core/Card';
|
||||
import CardActions from '@material-ui/core/CardActions';
|
||||
import CardContent from '@material-ui/core/CardContent';
|
||||
import Divider from '@material-ui/core/Divider';
|
||||
import Grid from '@material-ui/core/Grid';
|
||||
import { makeStyles, Theme } from '@material-ui/core/styles';
|
||||
import { useCallback } from 'react';
|
||||
import { CardHeader } from './CardHeader';
|
||||
import { usePermission } from '@backstage/plugin-permission-react';
|
||||
import { taskCreatePermission } from '@backstage/plugin-scaffolder-common/alpha';
|
||||
import { TemplateCardContent } from './TemplateCardContent';
|
||||
import { TemplateCardTags } from './TemplateCardTags';
|
||||
import { TemplateCardLinks } from './TemplateCardLinks';
|
||||
import { TemplateCardActions } from './TemplateCardActions';
|
||||
|
||||
const useStyles = makeStyles<Theme>(() => ({
|
||||
actionContainer: { padding: '16px', flex: 1, alignItems: 'flex-end' },
|
||||
}));
|
||||
|
||||
/**
|
||||
* The legacy Props for the `CardComponent` slot in {@link TemplateGroupsProps}.
|
||||
* @alpha
|
||||
*/
|
||||
export interface TemplateCardProps {
|
||||
template: TemplateEntityV1beta3;
|
||||
additionalLinks?: {
|
||||
icon: IconComponent;
|
||||
text: string;
|
||||
url: string;
|
||||
}[];
|
||||
onSelected?: (template: TemplateEntityV1beta3) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Props for components used as the swappable {@link TemplateCard}. The
|
||||
* surrounding list takes care of binding the template to `onSelected`, so
|
||||
* implementations only need to invoke it without arguments.
|
||||
* @alpha
|
||||
*/
|
||||
export interface TemplateCardComponentProps {
|
||||
template: TemplateEntityV1beta3;
|
||||
additionalLinks?: {
|
||||
icon: IconComponent;
|
||||
text: string;
|
||||
url: string;
|
||||
}[];
|
||||
onSelected?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation of the `TemplateCard`. The exported `TemplateCard`
|
||||
* is a swappable wrapper around this component.
|
||||
*/
|
||||
export const TemplateCardImpl = (props: TemplateCardComponentProps) => {
|
||||
const { additionalLinks, onSelected, template } = props;
|
||||
const styles = useStyles();
|
||||
const analytics = useAnalytics();
|
||||
const ownedByRelations = getEntityRelations(template, RELATION_OWNED_BY);
|
||||
const hasTags = !!template.metadata.tags?.length;
|
||||
const hasLinks =
|
||||
!!additionalLinks?.length || !!template.metadata.links?.length;
|
||||
const displayDefaultDivider = !hasTags && !hasLinks;
|
||||
|
||||
const { allowed: canCreateTask } = usePermission({
|
||||
permission: taskCreatePermission,
|
||||
});
|
||||
const handleChoose = useCallback(() => {
|
||||
analytics.captureEvent('click', 'Template has been opened');
|
||||
onSelected?.();
|
||||
}, [analytics, onSelected]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader template={template} data-testid="template-card-header" />
|
||||
<CardContent>
|
||||
<Grid container spacing={2} data-testid="template-card-content">
|
||||
<TemplateCardContent template={template} />
|
||||
{displayDefaultDivider && (
|
||||
<Grid item xs={12}>
|
||||
<Divider data-testid="template-card-separator" />
|
||||
</Grid>
|
||||
)}
|
||||
{hasTags && <TemplateCardTags template={template} />}
|
||||
{hasLinks && (
|
||||
<TemplateCardLinks
|
||||
template={template}
|
||||
additionalLinks={additionalLinks}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
</CardContent>
|
||||
<CardActions
|
||||
className={styles.actionContainer}
|
||||
data-testid="template-card-actions"
|
||||
>
|
||||
<TemplateCardActions
|
||||
canCreateTask={canCreateTask}
|
||||
handleChoose={handleChoose}
|
||||
ownedByRelations={ownedByRelations}
|
||||
/>
|
||||
</CardActions>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -13,4 +13,8 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { TemplateCard, type TemplateCardProps } from './TemplateCard';
|
||||
export {
|
||||
TemplateCard,
|
||||
type TemplateCardProps,
|
||||
type TemplateCardComponentProps,
|
||||
} from './TemplateCard';
|
||||
|
||||
@@ -62,10 +62,16 @@ describe('TemplateGroup', () => {
|
||||
|
||||
for (const { template } of mockTemplates) {
|
||||
expect(TemplateCard).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ template, onSelected: mockOnSelected }),
|
||||
expect.objectContaining({ template, onSelected: expect.any(Function) }),
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
const lastCall = jest.mocked(TemplateCard).mock.calls.at(-1)![0];
|
||||
lastCall.onSelected!();
|
||||
expect(mockOnSelected).toHaveBeenCalledWith(
|
||||
mockTemplates[mockTemplates.length - 1].template,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use the passed in TemplateCard prop to render the template card', () => {
|
||||
|
||||
@@ -62,8 +62,6 @@ export const TemplateGroup = (props: TemplateGroupProps) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const Card = CardComponent || TemplateCard;
|
||||
|
||||
return (
|
||||
<Content>
|
||||
{titleComponent}
|
||||
@@ -75,11 +73,19 @@ export const TemplateGroup = (props: TemplateGroupProps) => {
|
||||
}}
|
||||
key={stringifyEntityRef(template)}
|
||||
>
|
||||
<Card
|
||||
additionalLinks={additionalLinks}
|
||||
template={template}
|
||||
onSelected={onSelected}
|
||||
/>
|
||||
{CardComponent ? (
|
||||
<CardComponent
|
||||
additionalLinks={additionalLinks}
|
||||
template={template}
|
||||
onSelected={onSelected}
|
||||
/>
|
||||
) : (
|
||||
<TemplateCard
|
||||
additionalLinks={additionalLinks}
|
||||
template={template}
|
||||
onSelected={() => onSelected(template)}
|
||||
/>
|
||||
)}
|
||||
</AnalyticsContext>
|
||||
))}
|
||||
</ItemCardGrid>
|
||||
|
||||
Reference in New Issue
Block a user