feat: add i18n support for catalog-import plugin
Signed-off-by: JounQin <admin@1stg.me>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-import': patch
|
||||
---
|
||||
|
||||
Add i18n support for catalog-import plugin
|
||||
@@ -16,8 +16,69 @@ import { TranslationRef } from '@backstage/core-plugin-api/alpha';
|
||||
export const catalogImportTranslationRef: TranslationRef<
|
||||
'catalog-import',
|
||||
{
|
||||
readonly pageTitle: 'Register an existing component';
|
||||
readonly 'buttons.back': 'Back';
|
||||
readonly 'defaultImportPage.headerTitle': 'Register an existing component';
|
||||
readonly 'defaultImportPage.contentHeaderTitle': 'Start tracking your component in {{appTitle}}';
|
||||
readonly 'defaultImportPage.supportTitle': 'Start tracking your component in {{appTitle}} by adding it to the software catalog.';
|
||||
readonly 'importInfoCard.title': 'Register an existing component';
|
||||
readonly 'importInfoCard.deepLinkTitle': 'Learn more about the Software Catalog';
|
||||
readonly 'importInfoCard.linkDescription': 'Enter the URL to your source code repository to add it to {{appTitle}}.';
|
||||
readonly 'importInfoCard.fileLinkTitle': 'Link to an existing entity file';
|
||||
readonly 'importInfoCard.examplePrefix': 'Example: ';
|
||||
readonly 'importInfoCard.fileLinkDescription': 'The wizard analyzes the file, previews the entities, and adds them to the {{appTitle}} catalog.';
|
||||
readonly 'importInfoCard.githubIntegration.label': 'GitHub only';
|
||||
readonly 'importInfoCard.githubIntegration.title': 'Link to a repository';
|
||||
readonly 'importStepper.finish.title': 'Finish';
|
||||
readonly 'importStepper.noLocation.title': 'Create Pull Request';
|
||||
readonly 'importStepper.noLocation.createPr.ownerLabel': 'Entity Owner';
|
||||
readonly 'importStepper.noLocation.createPr.detailsTitle': 'Pull Request Details';
|
||||
readonly 'importStepper.noLocation.createPr.titleLabel': 'Pull Request Title';
|
||||
readonly 'importStepper.noLocation.createPr.titlePlaceholder': 'Add Backstage catalog entity descriptor files';
|
||||
readonly 'importStepper.noLocation.createPr.bodyLabel': 'Pull Request Body';
|
||||
readonly 'importStepper.noLocation.createPr.bodyPlaceholder': 'A describing text with Markdown support';
|
||||
readonly 'importStepper.noLocation.createPr.configurationTitle': 'Entity Configuration';
|
||||
readonly 'importStepper.noLocation.createPr.componentNameLabel': 'Name of the created component';
|
||||
readonly 'importStepper.noLocation.createPr.componentNamePlaceholder': 'my-component';
|
||||
readonly 'importStepper.noLocation.createPr.ownerLoadingText': 'Loading groups…';
|
||||
readonly 'importStepper.noLocation.createPr.ownerHelperText': 'Select an owner from the list or enter a reference to a Group or a User';
|
||||
readonly 'importStepper.noLocation.createPr.ownerErrorHelperText': 'required value';
|
||||
readonly 'importStepper.noLocation.createPr.ownerPlaceholder': 'my-group';
|
||||
readonly 'importStepper.noLocation.createPr.codeownersHelperText': 'WARNING: This may fail if no CODEOWNERS file is found at the target location.';
|
||||
readonly 'importStepper.singleLocation.title': 'Select Locations';
|
||||
readonly 'importStepper.singleLocation.description': 'Discovered Locations: 1';
|
||||
readonly 'importStepper.multipleLocations.title': 'Select Locations';
|
||||
readonly 'importStepper.multipleLocations.description': 'Discovered Locations: {{length, number}}';
|
||||
readonly 'importStepper.analyze.title': 'Select URL';
|
||||
readonly 'importStepper.prepare.title': 'Import Actions';
|
||||
readonly 'importStepper.prepare.description': 'Optional';
|
||||
readonly 'importStepper.review.title': 'Review';
|
||||
readonly 'stepFinishImportLocation.repository.title': 'The following Pull Request has been opened: ';
|
||||
readonly 'stepFinishImportLocation.repository.description': 'Your entities will be imported as soon as the Pull Request is merged.';
|
||||
readonly 'stepFinishImportLocation.backButtonText': 'Register another';
|
||||
readonly 'stepFinishImportLocation.locations.new': 'The following entities have been added to the catalog:';
|
||||
readonly 'stepFinishImportLocation.locations.backButtonText': 'Register another';
|
||||
readonly 'stepFinishImportLocation.locations.existing': 'A refresh was triggered for the following locations:';
|
||||
readonly 'stepFinishImportLocation.locations.viewButtonText': 'View Component';
|
||||
readonly 'stepInitAnalyzeUrl.error.default': 'Received unknown analysis result of type {{type}}. Please contact the support team.';
|
||||
readonly 'stepInitAnalyzeUrl.error.url': 'Must start with http:// or https://.';
|
||||
readonly 'stepInitAnalyzeUrl.error.repository': "Couldn't generate entities for your repository";
|
||||
readonly 'stepInitAnalyzeUrl.error.locations': 'There are no entities at this location';
|
||||
readonly 'stepInitAnalyzeUrl.urlHelperText': 'Enter the full path to your entity file to start tracking your component';
|
||||
readonly 'stepInitAnalyzeUrl.nextButtonText': 'Analyze';
|
||||
readonly 'stepPrepareCreatePullRequest.nextButtonText': 'Create PR';
|
||||
readonly 'stepPrepareCreatePullRequest.previewPr.title': 'Preview Pull Request';
|
||||
readonly 'stepPrepareCreatePullRequest.previewPr.subheader': 'Create a new Pull Request';
|
||||
readonly 'stepPrepareCreatePullRequest.previewCatalogInfo.title': 'Preview Entities';
|
||||
readonly 'stepPrepareSelectLocations.locations.description': 'Select one or more locations that are present in your git repository:';
|
||||
readonly 'stepPrepareSelectLocations.locations.selectAll': 'Select All';
|
||||
readonly 'stepPrepareSelectLocations.nextButtonText': 'Review';
|
||||
readonly 'stepPrepareSelectLocations.existingLocations.description': 'These locations already exist in the catalog:';
|
||||
readonly 'stepReviewLocation.refresh': 'Refresh';
|
||||
readonly 'stepReviewLocation.import': 'Import';
|
||||
readonly 'stepReviewLocation.catalog.new': 'The following entities will be added to the catalog:';
|
||||
readonly 'stepReviewLocation.catalog.exists': 'The following locations already exist in the catalog:';
|
||||
readonly 'stepReviewLocation.prepareResult.title': 'The following Pull Request has been opened: ';
|
||||
readonly 'stepReviewLocation.prepareResult.description': 'You can already import the location and {{appTitle}} will fetch the entities as soon as the Pull Request is merged.';
|
||||
}
|
||||
>;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { ApiRef } from '@backstage/core-plugin-api';
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { catalogImportTranslationRef } from '@backstage/plugin-catalog-import/alpha';
|
||||
import { ComponentProps } from 'react';
|
||||
import { CompoundEntityRef } from '@backstage/catalog-model';
|
||||
import { ConfigApi } from '@backstage/core-plugin-api';
|
||||
@@ -24,6 +25,7 @@ import { ScmAuthApi } from '@backstage/integration-react';
|
||||
import { ScmIntegrationRegistry } from '@backstage/integration';
|
||||
import { SubmitHandler } from 'react-hook-form';
|
||||
import { TextFieldProps } from '@material-ui/core/TextField/TextField';
|
||||
import { TranslationFunction } from '@backstage/core-plugin-api/alpha';
|
||||
import { UseFormProps } from 'react-hook-form';
|
||||
import { UseFormReturn } from 'react-hook-form';
|
||||
|
||||
@@ -145,6 +147,7 @@ export { catalogImportPlugin as plugin };
|
||||
export function defaultGenerateStepper(
|
||||
flow: ImportFlows,
|
||||
defaults: StepperProvider,
|
||||
t: TranslationFunction<typeof catalogImportTranslationRef.T>,
|
||||
): StepperProvider;
|
||||
|
||||
// @public
|
||||
|
||||
@@ -92,3 +92,5 @@ export default createFrontendPlugin({
|
||||
importPage: convertLegacyRouteRef(rootRouteRef),
|
||||
},
|
||||
});
|
||||
|
||||
export { catalogImportTranslationRef } from './translation';
|
||||
|
||||
@@ -154,7 +154,7 @@ function mockPrEndpoint() {
|
||||
describe('RepoApiClient', () => {
|
||||
const server = setupServer();
|
||||
registerMswTestHooks(server);
|
||||
const testToken = new Date().toString();
|
||||
const testToken = new Date().toLocaleString('en-US');
|
||||
const sut = new RepoApiClient({
|
||||
project: 'project',
|
||||
tenantUrl: 'https://dev.azure.com/acme',
|
||||
@@ -316,7 +316,7 @@ describe('createAzurePullRequest', () => {
|
||||
});
|
||||
|
||||
it('should create a new Pull request', async () => {
|
||||
const testToken = new Date().getTime().toString();
|
||||
const testToken = new Date().toLocaleString('en-US');
|
||||
const options: AzurePrOptions = {
|
||||
tenantUrl: 'https://dev.azure.com/acme',
|
||||
repository: 'test',
|
||||
|
||||
@@ -15,11 +15,14 @@
|
||||
*/
|
||||
|
||||
import { LinkButton } from '@backstage/core-components';
|
||||
import { useTranslationRef } from '@backstage/frontend-plugin-api';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import CircularProgress from '@material-ui/core/CircularProgress';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
import { catalogImportTranslationRef } from '../../translation';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
wrapper: {
|
||||
marginTop: theme.spacing(1),
|
||||
@@ -62,11 +65,12 @@ export const NextButton = (
|
||||
};
|
||||
|
||||
export const BackButton = (props: ComponentProps<typeof Button>) => {
|
||||
const { t } = useTranslationRef(catalogImportTranslationRef);
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Button variant="outlined" className={classes.button} {...props}>
|
||||
{props.children || 'Back'}
|
||||
{props.children || t('buttons.back')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,13 +22,14 @@ import {
|
||||
SupportButton,
|
||||
} from '@backstage/core-components';
|
||||
import { configApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { useTranslationRef } from '@backstage/frontend-plugin-api';
|
||||
import Grid from '@material-ui/core/Grid';
|
||||
import useMediaQuery from '@material-ui/core/useMediaQuery';
|
||||
import { useTheme } from '@material-ui/core/styles';
|
||||
import useMediaQuery from '@material-ui/core/useMediaQuery';
|
||||
|
||||
import { catalogImportTranslationRef } from '../../translation';
|
||||
import { ImportInfoCard } from '../ImportInfoCard';
|
||||
import { ImportStepper } from '../ImportStepper';
|
||||
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
|
||||
import { catalogImportTranslationRef } from '../../translation';
|
||||
|
||||
/**
|
||||
* The default catalog import page.
|
||||
@@ -42,8 +43,6 @@ export const DefaultImportPage = () => {
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const appTitle = configApi.getOptionalString('app.title') || 'Backstage';
|
||||
|
||||
const supportTitle = `Start tracking your component in ${appTitle} by adding it to the software catalog.`;
|
||||
|
||||
const contentItems = [
|
||||
<Grid key={0} item xs={12} md={4} lg={6} xl={8}>
|
||||
<ImportInfoCard />
|
||||
@@ -56,10 +55,14 @@ export const DefaultImportPage = () => {
|
||||
|
||||
return (
|
||||
<Page themeId="home">
|
||||
<Header title={t('pageTitle')} />
|
||||
<Header title={t('defaultImportPage.headerTitle')} />
|
||||
<Content>
|
||||
<ContentHeader title={`Start tracking your component in ${appTitle}`}>
|
||||
<SupportButton>{supportTitle}</SupportButton>
|
||||
<ContentHeader
|
||||
title={t('defaultImportPage.contentHeaderTitle', { appTitle })}
|
||||
>
|
||||
<SupportButton>
|
||||
{t('defaultImportPage.supportTitle', { appTitle })}
|
||||
</SupportButton>
|
||||
</ContentHeader>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
|
||||
@@ -16,11 +16,12 @@
|
||||
|
||||
import { InfoCard } from '@backstage/core-components';
|
||||
import { configApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { useTranslationRef } from '@backstage/frontend-plugin-api';
|
||||
import Chip from '@material-ui/core/Chip';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
|
||||
import { catalogImportApiRef } from '../../api';
|
||||
import { useCatalogFilename } from '../../hooks';
|
||||
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
|
||||
import { catalogImportTranslationRef } from '../../translation';
|
||||
|
||||
/**
|
||||
@@ -58,31 +59,37 @@ export const ImportInfoCard = (props: ImportInfoCardProps) => {
|
||||
title={t('importInfoCard.title')}
|
||||
titleTypographyProps={{ component: 'h3' }}
|
||||
deepLink={{
|
||||
title: 'Learn more about the Software Catalog',
|
||||
title: t('importInfoCard.deepLinkTitle'),
|
||||
link: 'https://backstage.io/docs/features/software-catalog/',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" paragraph>
|
||||
Enter the URL to your source code repository to add it to {appTitle}.
|
||||
{t('importInfoCard.linkDescription', { appTitle })}
|
||||
</Typography>
|
||||
<Typography component="h4" variant="h6">
|
||||
Link to an existing entity file
|
||||
{t('importInfoCard.fileLinkTitle')}
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" color="textSecondary" paragraph>
|
||||
Example: <code>{exampleLocationUrl}</code>
|
||||
{t('importInfoCard.examplePrefix')}
|
||||
<code>{exampleLocationUrl}</code>
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph>
|
||||
The wizard analyzes the file, previews the entities, and adds them to
|
||||
the {appTitle} catalog.
|
||||
{t('importInfoCard.fileLinkDescription', { appTitle })}
|
||||
</Typography>
|
||||
{hasGithubIntegration && (
|
||||
<>
|
||||
<Typography component="h4" variant="h6">
|
||||
Link to a repository{' '}
|
||||
<Chip label="GitHub only" variant="outlined" size="small" />
|
||||
{t('importInfoCard.githubIntegration.title')}
|
||||
<Chip
|
||||
label={t('importInfoCard.githubIntegration.label')}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
style={{ marginLeft: 8, marginBottom: 0 }}
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" color="textSecondary" paragraph>
|
||||
Example: <code>{exampleRepositoryUrl}</code>
|
||||
{t('importInfoCard.examplePrefix')}
|
||||
<code>{exampleRepositoryUrl}</code>
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph>
|
||||
The wizard discovers all <code>{catalogFilename}</code> files in the
|
||||
|
||||
@@ -16,12 +16,15 @@
|
||||
|
||||
import { InfoCard, InfoCardVariants } from '@backstage/core-components';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { useTranslationRef } from '@backstage/frontend-plugin-api';
|
||||
import Step from '@material-ui/core/Step';
|
||||
import StepContent from '@material-ui/core/StepContent';
|
||||
import Stepper from '@material-ui/core/Stepper';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { catalogImportApiRef } from '../../api';
|
||||
import { catalogImportTranslationRef } from '../../translation';
|
||||
import { ImportFlows, ImportState, useImportState } from '../useImportState';
|
||||
import {
|
||||
defaultGenerateStepper,
|
||||
@@ -56,6 +59,7 @@ export interface ImportStepperProps {
|
||||
* @public
|
||||
*/
|
||||
export const ImportStepper = (props: ImportStepperProps) => {
|
||||
const { t } = useTranslationRef(catalogImportTranslationRef);
|
||||
const {
|
||||
initialUrl,
|
||||
generateStepper = defaultGenerateStepper,
|
||||
@@ -67,8 +71,8 @@ export const ImportStepper = (props: ImportStepperProps) => {
|
||||
const state = useImportState({ initialUrl });
|
||||
|
||||
const states = useMemo<StepperProvider>(
|
||||
() => generateStepper(state.activeFlow, defaultStepper),
|
||||
[generateStepper, state.activeFlow],
|
||||
() => generateStepper(state.activeFlow, defaultStepper, t),
|
||||
[generateStepper, state.activeFlow, t],
|
||||
);
|
||||
|
||||
const render = (step: StepConfiguration) => {
|
||||
@@ -90,25 +94,25 @@ export const ImportStepper = (props: ImportStepperProps) => {
|
||||
{render(
|
||||
states.analyze(
|
||||
state as Extract<ImportState, { activeState: 'analyze' }>,
|
||||
{ apis: { catalogImportApi } },
|
||||
{ apis: { catalogImportApi }, t },
|
||||
),
|
||||
)}
|
||||
{render(
|
||||
states.prepare(
|
||||
state as Extract<ImportState, { activeState: 'prepare' }>,
|
||||
{ apis: { catalogImportApi } },
|
||||
{ apis: { catalogImportApi }, t },
|
||||
),
|
||||
)}
|
||||
{render(
|
||||
states.review(
|
||||
state as Extract<ImportState, { activeState: 'review' }>,
|
||||
{ apis: { catalogImportApi } },
|
||||
{ apis: { catalogImportApi }, t },
|
||||
),
|
||||
)}
|
||||
{render(
|
||||
states.finish(
|
||||
state as Extract<ImportState, { activeState: 'finish' }>,
|
||||
{ apis: { catalogImportApi } },
|
||||
{ apis: { catalogImportApi }, t },
|
||||
),
|
||||
)}
|
||||
</Stepper>
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { TranslationFunction } from '@backstage/core-plugin-api/alpha';
|
||||
import { catalogImportTranslationRef } from '@backstage/plugin-catalog-import/alpha';
|
||||
import Box from '@material-ui/core/Box';
|
||||
import Checkbox from '@material-ui/core/Checkbox';
|
||||
import FormControlLabel from '@material-ui/core/FormControlLabel';
|
||||
@@ -48,19 +50,31 @@ export type StepConfiguration = {
|
||||
export interface StepperProvider {
|
||||
analyze: (
|
||||
s: Extract<ImportState, { activeState: 'analyze' }>,
|
||||
opts: { apis: StepperApis },
|
||||
opts: {
|
||||
apis: StepperApis;
|
||||
t: TranslationFunction<typeof catalogImportTranslationRef.T>;
|
||||
},
|
||||
) => StepConfiguration;
|
||||
prepare: (
|
||||
s: Extract<ImportState, { activeState: 'prepare' }>,
|
||||
opts: { apis: StepperApis },
|
||||
opts: {
|
||||
apis: StepperApis;
|
||||
t: TranslationFunction<typeof catalogImportTranslationRef.T>;
|
||||
},
|
||||
) => StepConfiguration;
|
||||
review: (
|
||||
s: Extract<ImportState, { activeState: 'review' }>,
|
||||
opts: { apis: StepperApis },
|
||||
opts: {
|
||||
apis: StepperApis;
|
||||
t: TranslationFunction<typeof catalogImportTranslationRef.T>;
|
||||
},
|
||||
) => StepConfiguration;
|
||||
finish: (
|
||||
s: Extract<ImportState, { activeState: 'finish' }>,
|
||||
opts: { apis: StepperApis },
|
||||
opts: {
|
||||
apis: StepperApis;
|
||||
t: TranslationFunction<typeof catalogImportTranslationRef.T>;
|
||||
},
|
||||
) => StepConfiguration;
|
||||
}
|
||||
|
||||
@@ -77,6 +91,7 @@ export interface StepperProvider {
|
||||
export function defaultGenerateStepper(
|
||||
flow: ImportFlows,
|
||||
defaults: StepperProvider,
|
||||
t: TranslationFunction<typeof catalogImportTranslationRef.T>,
|
||||
): StepperProvider {
|
||||
switch (flow) {
|
||||
// the prepare step is skipped but the label of the step is updated
|
||||
@@ -88,11 +103,11 @@ export function defaultGenerateStepper(
|
||||
<StepLabel
|
||||
optional={
|
||||
<Typography variant="caption">
|
||||
Discovered Locations: 1
|
||||
{t('importStepper.singleLocation.description')}
|
||||
</Typography>
|
||||
}
|
||||
>
|
||||
Select Locations
|
||||
{t('importStepper.singleLocation.title')}
|
||||
</StepLabel>
|
||||
),
|
||||
content: <></>,
|
||||
@@ -113,11 +128,13 @@ export function defaultGenerateStepper(
|
||||
<StepLabel
|
||||
optional={
|
||||
<Typography variant="caption">
|
||||
Discovered Locations: {state.analyzeResult.locations.length}
|
||||
{t('importStepper.multipleLocations.description', {
|
||||
length: state.analyzeResult.locations.length,
|
||||
})}
|
||||
</Typography>
|
||||
}
|
||||
>
|
||||
Select Locations
|
||||
{t('importStepper.multipleLocations.title')}
|
||||
</StepLabel>
|
||||
),
|
||||
content: (
|
||||
@@ -141,7 +158,9 @@ export function defaultGenerateStepper(
|
||||
}
|
||||
|
||||
return {
|
||||
stepLabel: <StepLabel>Create Pull Request</StepLabel>,
|
||||
stepLabel: (
|
||||
<StepLabel>{t('importStepper.noLocation.title')}</StepLabel>
|
||||
),
|
||||
content: (
|
||||
<StepPrepareCreatePullRequest
|
||||
analyzeResult={state.analyzeResult}
|
||||
@@ -157,7 +176,9 @@ export function defaultGenerateStepper(
|
||||
}) => (
|
||||
<>
|
||||
<Box marginTop={2}>
|
||||
<Typography variant="h6">Pull Request Details</Typography>
|
||||
<Typography variant="h6">
|
||||
{t('importStepper.noLocation.createPr.detailsTitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
@@ -166,8 +187,10 @@ export function defaultGenerateStepper(
|
||||
required: true,
|
||||
}),
|
||||
)}
|
||||
label="Pull Request Title"
|
||||
placeholder="Add Backstage catalog entity descriptor files"
|
||||
label={t('importStepper.noLocation.createPr.titleLabel')}
|
||||
placeholder={t(
|
||||
'importStepper.noLocation.createPr.titlePlaceholder',
|
||||
)}
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
@@ -181,8 +204,10 @@ export function defaultGenerateStepper(
|
||||
required: true,
|
||||
}),
|
||||
)}
|
||||
label="Pull Request Body"
|
||||
placeholder="A describing text with Markdown support"
|
||||
label={t('importStepper.noLocation.createPr.bodyLabel')}
|
||||
placeholder={t(
|
||||
'importStepper.noLocation.createPr.bodyPlaceholder',
|
||||
)}
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
@@ -192,15 +217,23 @@ export function defaultGenerateStepper(
|
||||
/>
|
||||
|
||||
<Box marginTop={2}>
|
||||
<Typography variant="h6">Entity Configuration</Typography>
|
||||
<Typography variant="h6">
|
||||
{t(
|
||||
'importStepper.noLocation.createPr.configurationTitle',
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
{...asInputRef(
|
||||
register('componentName', { required: true }),
|
||||
)}
|
||||
label="Name of the created component"
|
||||
placeholder="my-component"
|
||||
label={t(
|
||||
'importStepper.noLocation.createPr.componentNameLabel',
|
||||
)}
|
||||
placeholder={t(
|
||||
'importStepper.noLocation.createPr.componentNamePlaceholder',
|
||||
)}
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
@@ -214,12 +247,22 @@ export function defaultGenerateStepper(
|
||||
errors={formState.errors}
|
||||
options={groups || []}
|
||||
loading={groupsLoading}
|
||||
loadingText="Loading groups…"
|
||||
helperText="Select an owner from the list or enter a reference to a Group or a User"
|
||||
errorHelperText="required value"
|
||||
loadingText={t(
|
||||
'importStepper.noLocation.createPr.ownerLoadingText',
|
||||
)}
|
||||
helperText={t(
|
||||
'importStepper.noLocation.createPr.ownerHelperText',
|
||||
)}
|
||||
errorHelperText={t(
|
||||
'importStepper.noLocation.createPr.ownerErrorHelperText',
|
||||
)}
|
||||
textFieldProps={{
|
||||
label: 'Entity Owner',
|
||||
placeholder: 'my-group',
|
||||
label: t(
|
||||
'importStepper.noLocation.createPr.ownerLabel',
|
||||
),
|
||||
placeholder: t(
|
||||
'importStepper.noLocation.createPr.ownerPlaceholder',
|
||||
),
|
||||
}}
|
||||
rules={{ required: true }}
|
||||
required
|
||||
@@ -244,8 +287,9 @@ export function defaultGenerateStepper(
|
||||
}
|
||||
/>
|
||||
<FormHelperText>
|
||||
WARNING: This may fail if no CODEOWNERS file is found at
|
||||
the target location.
|
||||
{t(
|
||||
'importStepper.noLocation.createPr.codeownersHelperText',
|
||||
)}
|
||||
</FormHelperText>
|
||||
</>
|
||||
)}
|
||||
@@ -261,8 +305,8 @@ export function defaultGenerateStepper(
|
||||
}
|
||||
|
||||
export const defaultStepper: StepperProvider = {
|
||||
analyze: (state, { apis }) => ({
|
||||
stepLabel: <StepLabel>Select URL</StepLabel>,
|
||||
analyze: (state, { apis, t }) => ({
|
||||
stepLabel: <StepLabel>{t('importStepper.analyze.title')}</StepLabel>,
|
||||
content: (
|
||||
<StepInitAnalyzeUrl
|
||||
key="analyze"
|
||||
@@ -273,17 +317,23 @@ export const defaultStepper: StepperProvider = {
|
||||
),
|
||||
}),
|
||||
|
||||
prepare: state => ({
|
||||
prepare: (state, { t }) => ({
|
||||
stepLabel: (
|
||||
<StepLabel optional={<Typography variant="caption">Optional</Typography>}>
|
||||
Import Actions
|
||||
<StepLabel
|
||||
optional={
|
||||
<Typography variant="caption">
|
||||
{t('importStepper.prepare.description')}
|
||||
</Typography>
|
||||
}
|
||||
>
|
||||
{t('importStepper.prepare.title')}
|
||||
</StepLabel>
|
||||
),
|
||||
content: <BackButton onClick={state.onGoBack} />,
|
||||
}),
|
||||
|
||||
review: state => ({
|
||||
stepLabel: <StepLabel>Review</StepLabel>,
|
||||
review: (state, { t }) => ({
|
||||
stepLabel: <StepLabel>{t('importStepper.review.title')}</StepLabel>,
|
||||
content: (
|
||||
<StepReviewLocation
|
||||
prepareResult={state.prepareResult}
|
||||
@@ -293,8 +343,8 @@ export const defaultStepper: StepperProvider = {
|
||||
),
|
||||
}),
|
||||
|
||||
finish: state => ({
|
||||
stepLabel: <StepLabel>Finish</StepLabel>,
|
||||
finish: (state, { t }) => ({
|
||||
stepLabel: <StepLabel>{t('importStepper.finish.title')}</StepLabel>,
|
||||
content: (
|
||||
<StepFinishImportLocation
|
||||
prepareResult={state.prepareResult}
|
||||
|
||||
+20
-14
@@ -14,17 +14,20 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { CompoundEntityRef, DEFAULT_NAMESPACE } from '@backstage/catalog-model';
|
||||
import { Link } from '@backstage/core-components';
|
||||
import { useRouteRef } from '@backstage/core-plugin-api';
|
||||
import { useTranslationRef } from '@backstage/frontend-plugin-api';
|
||||
import { entityRouteRef } from '@backstage/plugin-catalog-react';
|
||||
import Grid from '@material-ui/core/Grid';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import LocationOnIcon from '@material-ui/icons/LocationOn';
|
||||
import partition from 'lodash/partition';
|
||||
|
||||
import { catalogImportTranslationRef } from '../../translation';
|
||||
import { BackButton, ViewComponentButton } from '../Buttons';
|
||||
import { EntityListComponent } from '../EntityListComponent';
|
||||
import { PrepareResult } from '../useImportState';
|
||||
import { Link } from '@backstage/core-components';
|
||||
import partition from 'lodash/partition';
|
||||
import { CompoundEntityRef, DEFAULT_NAMESPACE } from '@backstage/catalog-model';
|
||||
import { entityRouteRef } from '@backstage/plugin-catalog-react';
|
||||
import { useRouteRef } from '@backstage/core-plugin-api';
|
||||
|
||||
type Props = {
|
||||
prepareResult: PrepareResult;
|
||||
@@ -60,13 +63,14 @@ const filterComponentEntity = (
|
||||
};
|
||||
|
||||
export const StepFinishImportLocation = ({ prepareResult, onReset }: Props) => {
|
||||
const { t } = useTranslationRef(catalogImportTranslationRef);
|
||||
const entityRoute = useRouteRef(entityRouteRef);
|
||||
|
||||
if (prepareResult.type === 'repository') {
|
||||
return (
|
||||
<>
|
||||
<Typography paragraph>
|
||||
The following Pull Request has been opened:{' '}
|
||||
{t('stepFinishImportLocation.repository.title')}
|
||||
<Link
|
||||
to={prepareResult.pullRequest.url}
|
||||
target="_blank"
|
||||
@@ -76,10 +80,12 @@ export const StepFinishImportLocation = ({ prepareResult, onReset }: Props) => {
|
||||
</Link>
|
||||
</Typography>
|
||||
<Typography paragraph>
|
||||
Your entities will be imported as soon as the Pull Request is merged.
|
||||
{t('stepFinishImportLocation.repository.description')}
|
||||
</Typography>
|
||||
<Grid container spacing={0}>
|
||||
<BackButton onClick={onReset}>Register another</BackButton>
|
||||
<BackButton onClick={onReset}>
|
||||
{t('stepFinishImportLocation.backButtonText')}
|
||||
</BackButton>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
@@ -94,9 +100,7 @@ export const StepFinishImportLocation = ({ prepareResult, onReset }: Props) => {
|
||||
<>
|
||||
{newLocations.length > 0 && (
|
||||
<>
|
||||
<Typography>
|
||||
The following entities have been added to the catalog:
|
||||
</Typography>
|
||||
<Typography>{t('stepFinishImportLocation.locations.new')}</Typography>
|
||||
|
||||
<EntityListComponent
|
||||
locations={newLocations}
|
||||
@@ -108,7 +112,7 @@ export const StepFinishImportLocation = ({ prepareResult, onReset }: Props) => {
|
||||
{existingLocations.length > 0 && (
|
||||
<>
|
||||
<Typography>
|
||||
A refresh was triggered for the following locations:
|
||||
{t('stepFinishImportLocation.locations.existing')}
|
||||
</Typography>
|
||||
|
||||
<EntityListComponent
|
||||
@@ -121,10 +125,12 @@ export const StepFinishImportLocation = ({ prepareResult, onReset }: Props) => {
|
||||
<Grid container spacing={0}>
|
||||
{newComponentEntity && (
|
||||
<ViewComponentButton to={entityRoute(newComponentEntity)}>
|
||||
View Component
|
||||
{t('stepFinishImportLocation.locations.viewButtonText')}
|
||||
</ViewComponentButton>
|
||||
)}
|
||||
<BackButton onClick={onReset}>Register another</BackButton>
|
||||
<BackButton onClick={onReset}>
|
||||
{t('stepFinishImportLocation.backButtonText')}
|
||||
</BackButton>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
|
||||
+58
-42
@@ -15,8 +15,8 @@
|
||||
*/
|
||||
|
||||
import { errorApiRef } from '@backstage/core-plugin-api';
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import { act, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ReactNode } from 'react';
|
||||
import { AnalyzeResult, catalogImportApiRef } from '../../api/';
|
||||
@@ -60,23 +60,24 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
});
|
||||
|
||||
it('renders without exploding', async () => {
|
||||
render(<StepInitAnalyzeUrl onAnalysis={() => undefined} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<StepInitAnalyzeUrl onAnalysis={() => undefined} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('textbox', { name: /URL/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox', { name: /URL/i })).toHaveValue('');
|
||||
});
|
||||
|
||||
it('should use default analysis url', async () => {
|
||||
render(
|
||||
<StepInitAnalyzeUrl
|
||||
onAnalysis={() => undefined}
|
||||
analysisUrl="https://default"
|
||||
/>,
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<StepInitAnalyzeUrl
|
||||
onAnalysis={() => undefined}
|
||||
analysisUrl="https://default"
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('textbox', { name: /URL/i })).toBeInTheDocument();
|
||||
@@ -88,9 +89,11 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
it('should not analyze without url', async () => {
|
||||
const onAnalysisFn = jest.fn();
|
||||
|
||||
render(<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
@@ -108,9 +111,11 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
it('should not analyze invalid value', async () => {
|
||||
const onAnalysisFn = jest.fn();
|
||||
|
||||
render(<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.type(
|
||||
@@ -136,9 +141,11 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
locations: [location],
|
||||
} as AnalyzeResult;
|
||||
|
||||
render(<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
catalogImportApi.analyzeUrl.mockReturnValueOnce(
|
||||
Promise.resolve(analyzeResult),
|
||||
@@ -170,9 +177,11 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
locations: [location, location],
|
||||
} as AnalyzeResult;
|
||||
|
||||
render(<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
catalogImportApi.analyzeUrl.mockReturnValueOnce(
|
||||
Promise.resolve(analyzeResult),
|
||||
@@ -203,9 +212,11 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
locations: [],
|
||||
} as AnalyzeResult;
|
||||
|
||||
render(<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
catalogImportApi.analyzeUrl.mockReturnValueOnce(
|
||||
Promise.resolve(analyzeResult),
|
||||
@@ -244,9 +255,11 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
],
|
||||
} as AnalyzeResult;
|
||||
|
||||
render(<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
catalogImportApi.analyzeUrl.mockReturnValueOnce(
|
||||
Promise.resolve(analyzeResult),
|
||||
@@ -279,9 +292,11 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
generatedEntities: [],
|
||||
} as AnalyzeResult;
|
||||
|
||||
render(<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
catalogImportApi.analyzeUrl.mockReturnValueOnce(
|
||||
Promise.resolve(analyzeResult),
|
||||
@@ -320,11 +335,10 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
],
|
||||
} as AnalyzeResult;
|
||||
|
||||
render(
|
||||
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} disablePullRequest />,
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} disablePullRequest />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
catalogImportApi.analyzeUrl.mockReturnValueOnce(
|
||||
@@ -349,9 +363,11 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
it('should report unknown type to the errorapi', async () => {
|
||||
const onAnalysisFn = jest.fn();
|
||||
|
||||
render(<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
catalogImportApi.analyzeUrl.mockReturnValueOnce(
|
||||
Promise.resolve({ type: 'unknown' } as any as AnalyzeResult),
|
||||
|
||||
@@ -15,12 +15,15 @@
|
||||
*/
|
||||
|
||||
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { useTranslationRef } from '@backstage/frontend-plugin-api';
|
||||
import FormHelperText from '@material-ui/core/FormHelperText';
|
||||
import Grid from '@material-ui/core/Grid';
|
||||
import TextField from '@material-ui/core/TextField';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { AnalyzeResult, catalogImportApiRef } from '../../api';
|
||||
import { catalogImportTranslationRef } from '../../translation';
|
||||
import { NextButton } from '../Buttons';
|
||||
import { asInputRef } from '../helpers';
|
||||
import { ImportFlows, PrepareResult } from '../useImportState';
|
||||
@@ -55,6 +58,7 @@ export interface StepInitAnalyzeUrlProps {
|
||||
* @public
|
||||
*/
|
||||
export const StepInitAnalyzeUrl = (props: StepInitAnalyzeUrlProps) => {
|
||||
const { t } = useTranslationRef(catalogImportTranslationRef);
|
||||
const {
|
||||
onAnalysis,
|
||||
analysisUrl = '',
|
||||
@@ -95,7 +99,7 @@ export const StepInitAnalyzeUrl = (props: StepInitAnalyzeUrlProps) => {
|
||||
) {
|
||||
onAnalysis('no-location', url, analysisResult);
|
||||
} else {
|
||||
setError("Couldn't generate entities for your repository");
|
||||
setError(t('stepInitAnalyzeUrl.error.repository'));
|
||||
setSubmitted(false);
|
||||
}
|
||||
break;
|
||||
@@ -108,16 +112,16 @@ export const StepInitAnalyzeUrl = (props: StepInitAnalyzeUrlProps) => {
|
||||
} else if (analysisResult.locations.length > 1) {
|
||||
onAnalysis('multiple-locations', url, analysisResult);
|
||||
} else {
|
||||
setError('There are no entities at this location');
|
||||
setError(t('stepInitAnalyzeUrl.error.locations'));
|
||||
setSubmitted(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
const err = `Received unknown analysis result of type ${
|
||||
(analysisResult as any).type
|
||||
}. Please contact the support team.`;
|
||||
const err = t('stepInitAnalyzeUrl.error.default', {
|
||||
type: (analysisResult as any).type,
|
||||
});
|
||||
setError(err);
|
||||
setSubmitted(false);
|
||||
|
||||
@@ -130,7 +134,7 @@ export const StepInitAnalyzeUrl = (props: StepInitAnalyzeUrlProps) => {
|
||||
setSubmitted(false);
|
||||
}
|
||||
},
|
||||
[catalogImportApi, disablePullRequest, errorApi, onAnalysis],
|
||||
[catalogImportApi, disablePullRequest, errorApi, onAnalysis, t],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -143,7 +147,7 @@ export const StepInitAnalyzeUrl = (props: StepInitAnalyzeUrlProps) => {
|
||||
httpsValidator: (value: any) =>
|
||||
(typeof value === 'string' &&
|
||||
value.match(/^http[s]?:\/\//) !== null) ||
|
||||
'Must start with http:// or https://.',
|
||||
t('stepInitAnalyzeUrl.error.url'),
|
||||
},
|
||||
}),
|
||||
)}
|
||||
@@ -151,7 +155,7 @@ export const StepInitAnalyzeUrl = (props: StepInitAnalyzeUrlProps) => {
|
||||
id="url"
|
||||
label="URL"
|
||||
placeholder={exampleLocationUrl}
|
||||
helperText="Enter the full path to your entity file to start tracking your component"
|
||||
helperText={t('stepInitAnalyzeUrl.urlHelperText')}
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
error={Boolean(errors.url)}
|
||||
@@ -167,7 +171,7 @@ export const StepInitAnalyzeUrl = (props: StepInitAnalyzeUrlProps) => {
|
||||
loading={submitted}
|
||||
type="submit"
|
||||
>
|
||||
Analyze
|
||||
{t('stepInitAnalyzeUrl.nextButtonText')}
|
||||
</NextButton>
|
||||
</Grid>
|
||||
</form>
|
||||
|
||||
+13
-6
@@ -14,8 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { PreviewPullRequestComponent } from './PreviewPullRequestComponent';
|
||||
|
||||
@@ -27,7 +28,7 @@ const useStyles = makeStyles({
|
||||
|
||||
describe('<PreviewPullRequestComponent />', () => {
|
||||
it('renders without exploding', async () => {
|
||||
render(
|
||||
await renderInTestApp(
|
||||
<PreviewPullRequestComponent
|
||||
title="My Title"
|
||||
description="My **description**"
|
||||
@@ -45,7 +46,7 @@ describe('<PreviewPullRequestComponent />', () => {
|
||||
it('renders card with custom styles', async () => {
|
||||
const { result } = renderHook(() => useStyles());
|
||||
|
||||
render(
|
||||
await renderInTestApp(
|
||||
<PreviewPullRequestComponent
|
||||
title="My Title"
|
||||
description="My **description**"
|
||||
@@ -56,15 +57,21 @@ describe('<PreviewPullRequestComponent />', () => {
|
||||
const title = screen.getByText('My Title');
|
||||
const description = screen.getByText('description', { selector: 'strong' });
|
||||
expect(title).toBeInTheDocument();
|
||||
expect(title).not.toBeVisible();
|
||||
expect(description).toBeInTheDocument();
|
||||
expect(description).not.toBeVisible();
|
||||
|
||||
// FIXME: https://github.com/testing-library/jest-dom/issues/444
|
||||
// expect(title).not.toBeVisible();
|
||||
// expect(description).not.toBeVisible();
|
||||
|
||||
const card = title.closest(`.${result.current.displayNone}`);
|
||||
expect(card).toBeInTheDocument();
|
||||
expect(description.closest(`.${result.current.displayNone}`)).toBe(card);
|
||||
});
|
||||
|
||||
it('renders with custom styles', async () => {
|
||||
const { result } = renderHook(() => useStyles());
|
||||
|
||||
render(
|
||||
await renderInTestApp(
|
||||
<PreviewPullRequestComponent
|
||||
title="My Title"
|
||||
description="My **description**"
|
||||
|
||||
+9
-2
@@ -14,10 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { MarkdownContent } from '@backstage/core-components';
|
||||
import { useTranslationRef } from '@backstage/frontend-plugin-api';
|
||||
import Card from '@material-ui/core/Card';
|
||||
import CardContent from '@material-ui/core/CardContent';
|
||||
import CardHeader from '@material-ui/core/CardHeader';
|
||||
import { MarkdownContent } from '@backstage/core-components';
|
||||
|
||||
import { catalogImportTranslationRef } from '../../translation';
|
||||
|
||||
/**
|
||||
* Props for {@link PreviewPullRequestComponent}.
|
||||
@@ -39,9 +42,13 @@ export const PreviewPullRequestComponent = (
|
||||
props: PreviewPullRequestComponentProps,
|
||||
) => {
|
||||
const { title, description, classes } = props;
|
||||
const { t } = useTranslationRef(catalogImportTranslationRef);
|
||||
return (
|
||||
<Card variant="outlined" className={classes?.card}>
|
||||
<CardHeader title={title} subheader="Create a new Pull Request" />
|
||||
<CardHeader
|
||||
title={title}
|
||||
subheader={t('stepPrepareCreatePullRequest.previewPr.subheader')}
|
||||
/>
|
||||
<CardContent className={classes?.cardContent}>
|
||||
<MarkdownContent content={description} />
|
||||
</CardContent>
|
||||
|
||||
+74
-74
@@ -16,9 +16,14 @@
|
||||
|
||||
import { configApiRef, errorApiRef } from '@backstage/core-plugin-api';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { TestApiProvider, mockApis } from '@backstage/test-utils';
|
||||
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
|
||||
import {
|
||||
mockApis,
|
||||
renderInTestApp,
|
||||
TestApiProvider,
|
||||
} from '@backstage/test-utils';
|
||||
import TextField from '@material-ui/core/TextField';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ReactNode } from 'react';
|
||||
import { AnalyzeResult, catalogImportApiRef } from '../../api';
|
||||
@@ -27,7 +32,6 @@ import {
|
||||
generateEntities,
|
||||
StepPrepareCreatePullRequest,
|
||||
} from './StepPrepareCreatePullRequest';
|
||||
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
|
||||
|
||||
describe('<StepPrepareCreatePullRequest />', () => {
|
||||
const catalogImportApi: jest.Mocked<typeof catalogImportApiRef.T> = {
|
||||
@@ -90,24 +94,23 @@ describe('<StepPrepareCreatePullRequest />', () => {
|
||||
it('renders without exploding', async () => {
|
||||
catalogApi.getEntities.mockReturnValue(Promise.resolve({ items: [] }));
|
||||
|
||||
render(
|
||||
<StepPrepareCreatePullRequest
|
||||
analyzeResult={analyzeResult}
|
||||
onPrepare={onPrepareFn}
|
||||
renderFormFields={({ register }) => {
|
||||
return (
|
||||
<>
|
||||
<TextField {...asInputRef(register('title'))} />
|
||||
<TextField {...asInputRef(register('body'))} />
|
||||
<TextField {...asInputRef(register('componentName'))} />
|
||||
<TextField {...asInputRef(register('owner'))} />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<StepPrepareCreatePullRequest
|
||||
analyzeResult={analyzeResult}
|
||||
onPrepare={onPrepareFn}
|
||||
renderFormFields={({ register }) => {
|
||||
return (
|
||||
<>
|
||||
<TextField {...asInputRef(register('title'))} />
|
||||
<TextField {...asInputRef(register('body'))} />
|
||||
<TextField {...asInputRef(register('componentName'))} />
|
||||
<TextField {...asInputRef(register('owner'))} />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
const title = await screen.findByText('My title');
|
||||
@@ -129,32 +132,31 @@ describe('<StepPrepareCreatePullRequest />', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<StepPrepareCreatePullRequest
|
||||
analyzeResult={analyzeResult}
|
||||
onPrepare={onPrepareFn}
|
||||
renderFormFields={({ register }) => {
|
||||
return (
|
||||
<>
|
||||
<TextField {...asInputRef(register('title'))} />
|
||||
<TextField {...asInputRef(register('body'))} />
|
||||
<TextField
|
||||
{...asInputRef(register('componentName'))}
|
||||
id="name"
|
||||
label="name"
|
||||
/>
|
||||
<TextField
|
||||
{...asInputRef(register('owner'))}
|
||||
id="owner"
|
||||
label="owner"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<StepPrepareCreatePullRequest
|
||||
analyzeResult={analyzeResult}
|
||||
onPrepare={onPrepareFn}
|
||||
renderFormFields={({ register }) => {
|
||||
return (
|
||||
<>
|
||||
<TextField {...asInputRef(register('title'))} />
|
||||
<TextField {...asInputRef(register('body'))} />
|
||||
<TextField
|
||||
{...asInputRef(register('componentName'))}
|
||||
id="name"
|
||||
label="name"
|
||||
/>
|
||||
<TextField
|
||||
{...asInputRef(register('owner'))}
|
||||
id="owner"
|
||||
label="owner"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await userEvent.type(await screen.findByLabelText('name'), '-changed');
|
||||
@@ -211,24 +213,23 @@ spec:
|
||||
new Error('some error'),
|
||||
);
|
||||
|
||||
render(
|
||||
<StepPrepareCreatePullRequest
|
||||
analyzeResult={analyzeResult}
|
||||
onPrepare={onPrepareFn}
|
||||
renderFormFields={({ register }) => {
|
||||
return (
|
||||
<>
|
||||
<TextField {...asInputRef(register('title'))} />
|
||||
<TextField {...asInputRef(register('body'))} />
|
||||
<TextField {...asInputRef(register('componentName'))} />
|
||||
<TextField {...asInputRef(register('owner'))} />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<StepPrepareCreatePullRequest
|
||||
analyzeResult={analyzeResult}
|
||||
onPrepare={onPrepareFn}
|
||||
renderFormFields={({ register }) => {
|
||||
return (
|
||||
<>
|
||||
<TextField {...asInputRef(register('title'))} />
|
||||
<TextField {...asInputRef(register('body'))} />
|
||||
<TextField {...asInputRef(register('componentName'))} />
|
||||
<TextField {...asInputRef(register('owner'))} />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
@@ -256,15 +257,14 @@ spec:
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<StepPrepareCreatePullRequest
|
||||
analyzeResult={analyzeResult}
|
||||
onPrepare={onPrepareFn}
|
||||
renderFormFields={renderFormFieldsFn}
|
||||
/>,
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<StepPrepareCreatePullRequest
|
||||
analyzeResult={analyzeResult}
|
||||
onPrepare={onPrepareFn}
|
||||
renderFormFields={renderFormFieldsFn}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
+11
-3
@@ -17,6 +17,7 @@
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { assertError } from '@backstage/errors';
|
||||
import { useTranslationRef } from '@backstage/frontend-plugin-api';
|
||||
import {
|
||||
catalogApiRef,
|
||||
humanizeEntityRef,
|
||||
@@ -30,8 +31,10 @@ import { ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import { NestedValue, UseFormReturn } from 'react-hook-form';
|
||||
import useAsync from 'react-use/esm/useAsync';
|
||||
import YAML from 'yaml';
|
||||
|
||||
import { AnalyzeResult, catalogImportApiRef } from '../../api';
|
||||
import { useCatalogFilename } from '../../hooks';
|
||||
import { catalogImportTranslationRef } from '../../translation';
|
||||
import { PartialEntity } from '../../types';
|
||||
import { BackButton, NextButton } from '../Buttons';
|
||||
import { PrepareResult } from '../useImportState';
|
||||
@@ -127,6 +130,7 @@ export const StepPrepareCreatePullRequest = (
|
||||
) => {
|
||||
const { analyzeResult, onPrepare, onGoBack, renderFormFields } = props;
|
||||
|
||||
const { t } = useTranslationRef(catalogImportTranslationRef);
|
||||
const classes = useStyles();
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const catalogImportApi = useApi(catalogImportApiRef);
|
||||
@@ -252,7 +256,9 @@ export const StepPrepareCreatePullRequest = (
|
||||
})}
|
||||
|
||||
<Box marginTop={2}>
|
||||
<Typography variant="h6">Preview Pull Request</Typography>
|
||||
<Typography variant="h6">
|
||||
{t('stepPrepareCreatePullRequest.previewPr.title')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<PreviewPullRequestComponent
|
||||
@@ -265,7 +271,9 @@ export const StepPrepareCreatePullRequest = (
|
||||
/>
|
||||
|
||||
<Box marginTop={2} marginBottom={1}>
|
||||
<Typography variant="h6">Preview Entities</Typography>
|
||||
<Typography variant="h6">
|
||||
{t('stepPrepareCreatePullRequest.previewCatalogInfo.title')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<PreviewCatalogInfoComponent
|
||||
@@ -296,7 +304,7 @@ export const StepPrepareCreatePullRequest = (
|
||||
)}
|
||||
loading={submitted}
|
||||
>
|
||||
Create PR
|
||||
{t('stepPrepareCreatePullRequest.nextButtonText')}
|
||||
</NextButton>
|
||||
</Grid>
|
||||
</>
|
||||
|
||||
+14
-6
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useTranslationRef } from '@backstage/frontend-plugin-api';
|
||||
import Checkbox from '@material-ui/core/Checkbox';
|
||||
import Grid from '@material-ui/core/Grid';
|
||||
import ListItem from '@material-ui/core/ListItem';
|
||||
@@ -21,12 +22,14 @@ import ListItemIcon from '@material-ui/core/ListItemIcon';
|
||||
import ListItemText from '@material-ui/core/ListItemText';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import LocationOnIcon from '@material-ui/icons/LocationOn';
|
||||
import partition from 'lodash/partition';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { AnalyzeResult } from '../../api';
|
||||
import { catalogImportTranslationRef } from '../../translation';
|
||||
import { BackButton, NextButton } from '../Buttons';
|
||||
import { EntityListComponent } from '../EntityListComponent';
|
||||
import { PrepareResult } from '../useImportState';
|
||||
import partition from 'lodash/partition';
|
||||
|
||||
type Props = {
|
||||
analyzeResult: Extract<AnalyzeResult, { type: 'locations' }>;
|
||||
@@ -49,6 +52,8 @@ export const StepPrepareSelectLocations = ({
|
||||
onPrepare,
|
||||
onGoBack,
|
||||
}: Props) => {
|
||||
const { t } = useTranslationRef(catalogImportTranslationRef);
|
||||
|
||||
const [selectedUrls, setSelectedUrls] = useState<string[]>(
|
||||
prepareResult?.locations.map(l => l.target) || [],
|
||||
);
|
||||
@@ -82,8 +87,7 @@ export const StepPrepareSelectLocations = ({
|
||||
{locations.length > 0 && (
|
||||
<>
|
||||
<Typography>
|
||||
Select one or more locations that are present in your git
|
||||
repository:
|
||||
{t('stepPrepareSelectLocations.locations.description')}
|
||||
</Typography>
|
||||
<EntityListComponent
|
||||
firstListItem={
|
||||
@@ -100,7 +104,9 @@ export const StepPrepareSelectLocations = ({
|
||||
disableRipple
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Select All" />
|
||||
<ListItemText
|
||||
primary={t('stepPrepareSelectLocations.locations.selectAll')}
|
||||
/>
|
||||
</ListItem>
|
||||
}
|
||||
onItemClick={onItemClick}
|
||||
@@ -120,7 +126,9 @@ export const StepPrepareSelectLocations = ({
|
||||
|
||||
{existingLocations.length > 0 && (
|
||||
<>
|
||||
<Typography>These locations already exist in the catalog:</Typography>
|
||||
<Typography>
|
||||
{t('stepPrepareSelectLocations.existingLocations.description')}
|
||||
</Typography>
|
||||
<EntityListComponent
|
||||
locations={existingLocations}
|
||||
locationListItemIcon={() => <LocationOnIcon />}
|
||||
@@ -133,7 +141,7 @@ export const StepPrepareSelectLocations = ({
|
||||
<Grid container spacing={0}>
|
||||
{onGoBack && <BackButton onClick={onGoBack} />}
|
||||
<NextButton disabled={selectedUrls.length === 0} onClick={handleResult}>
|
||||
Review
|
||||
{t('stepPrepareSelectLocations.nextButtonText')}
|
||||
</NextButton>
|
||||
</Grid>
|
||||
</>
|
||||
|
||||
@@ -14,20 +14,22 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { Link } from '@backstage/core-components';
|
||||
import { configApiRef, useAnalytics, useApi } from '@backstage/core-plugin-api';
|
||||
import { assertError } from '@backstage/errors';
|
||||
import { useTranslationRef } from '@backstage/frontend-plugin-api';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import FormHelperText from '@material-ui/core/FormHelperText';
|
||||
import Grid from '@material-ui/core/Grid';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import LocationOnIcon from '@material-ui/icons/LocationOn';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { BackButton, NextButton } from '../Buttons';
|
||||
import { EntityListComponent } from '../EntityListComponent';
|
||||
import { PrepareResult, ReviewResult } from '../useImportState';
|
||||
|
||||
import { configApiRef, useAnalytics, useApi } from '@backstage/core-plugin-api';
|
||||
import { Link } from '@backstage/core-components';
|
||||
import { stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { assertError } from '@backstage/errors';
|
||||
import { catalogImportTranslationRef } from '../../translation';
|
||||
|
||||
type Props = {
|
||||
prepareResult: PrepareResult;
|
||||
@@ -40,6 +42,7 @@ export const StepReviewLocation = ({
|
||||
onReview,
|
||||
onGoBack,
|
||||
}: Props) => {
|
||||
const { t } = useTranslationRef(catalogImportTranslationRef);
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const configApi = useApi(configApiRef);
|
||||
const analytics = useAnalytics();
|
||||
@@ -119,7 +122,7 @@ export const StepReviewLocation = ({
|
||||
{prepareResult.type === 'repository' && (
|
||||
<>
|
||||
<Typography paragraph>
|
||||
The following Pull Request has been opened:{' '}
|
||||
{t('stepReviewLocation.prepareResult.title')}
|
||||
<Link
|
||||
to={prepareResult.pullRequest.url}
|
||||
target="_blank"
|
||||
@@ -130,16 +133,15 @@ export const StepReviewLocation = ({
|
||||
</Typography>
|
||||
|
||||
<Typography paragraph>
|
||||
You can already import the location and {appTitle} will fetch the
|
||||
entities as soon as the Pull Request is merged.
|
||||
{t('stepReviewLocation.prepareResult.description', { appTitle })}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Typography>
|
||||
{exists
|
||||
? 'The following locations already exist in the catalog:'
|
||||
: 'The following entities will be added to the catalog:'}
|
||||
? t('stepReviewLocation.catalog.exists')
|
||||
: t('stepReviewLocation.catalog.new')}
|
||||
</Typography>
|
||||
|
||||
<EntityListComponent
|
||||
@@ -156,7 +158,9 @@ export const StepReviewLocation = ({
|
||||
loading={submitted}
|
||||
onClick={() => handleClick()}
|
||||
>
|
||||
{exists ? 'Refresh' : 'Import'}
|
||||
{exists
|
||||
? t('stepReviewLocation.refresh')
|
||||
: t('stepReviewLocation.import')}
|
||||
</NextButton>
|
||||
</Grid>
|
||||
</>
|
||||
|
||||
@@ -20,9 +20,132 @@ import { createTranslationRef } from '@backstage/core-plugin-api/alpha';
|
||||
export const catalogImportTranslationRef = createTranslationRef({
|
||||
id: 'catalog-import',
|
||||
messages: {
|
||||
pageTitle: 'Register an existing component',
|
||||
buttons: {
|
||||
back: 'Back',
|
||||
},
|
||||
defaultImportPage: {
|
||||
headerTitle: 'Register an existing component',
|
||||
contentHeaderTitle: 'Start tracking your component in {{appTitle}}',
|
||||
supportTitle:
|
||||
'Start tracking your component in {{appTitle}} by adding it to the software catalog.',
|
||||
},
|
||||
importInfoCard: {
|
||||
title: 'Register an existing component',
|
||||
deepLinkTitle: 'Learn more about the Software Catalog',
|
||||
linkDescription:
|
||||
'Enter the URL to your source code repository to add it to {{appTitle}}.',
|
||||
fileLinkTitle: 'Link to an existing entity file',
|
||||
examplePrefix: 'Example: ',
|
||||
fileLinkDescription:
|
||||
'The wizard analyzes the file, previews the entities, and adds them to the {{appTitle}} catalog.',
|
||||
githubIntegration: {
|
||||
title: 'Link to a repository',
|
||||
label: 'GitHub only',
|
||||
},
|
||||
},
|
||||
importStepper: {
|
||||
singleLocation: {
|
||||
title: 'Select Locations',
|
||||
description: 'Discovered Locations: 1',
|
||||
},
|
||||
multipleLocations: {
|
||||
title: 'Select Locations',
|
||||
description: 'Discovered Locations: {{length, number}}',
|
||||
},
|
||||
noLocation: {
|
||||
title: 'Create Pull Request',
|
||||
createPr: {
|
||||
detailsTitle: 'Pull Request Details',
|
||||
titleLabel: 'Pull Request Title',
|
||||
titlePlaceholder: 'Add Backstage catalog entity descriptor files',
|
||||
bodyLabel: 'Pull Request Body',
|
||||
bodyPlaceholder: 'A describing text with Markdown support',
|
||||
configurationTitle: 'Entity Configuration',
|
||||
componentNameLabel: 'Name of the created component',
|
||||
componentNamePlaceholder: 'my-component',
|
||||
ownerLoadingText: 'Loading groups…',
|
||||
ownerHelperText:
|
||||
'Select an owner from the list or enter a reference to a Group or a User',
|
||||
ownerErrorHelperText: 'required value',
|
||||
ownerLabel: 'Entity Owner',
|
||||
ownerPlaceholder: 'my-group',
|
||||
codeownersHelperText:
|
||||
'WARNING: This may fail if no CODEOWNERS file is found at the target location.',
|
||||
},
|
||||
},
|
||||
analyze: {
|
||||
title: 'Select URL',
|
||||
},
|
||||
prepare: {
|
||||
title: 'Import Actions',
|
||||
description: 'Optional',
|
||||
},
|
||||
review: {
|
||||
title: 'Review',
|
||||
},
|
||||
finish: {
|
||||
title: 'Finish',
|
||||
},
|
||||
},
|
||||
stepFinishImportLocation: {
|
||||
backButtonText: 'Register another',
|
||||
repository: {
|
||||
title: 'The following Pull Request has been opened: ',
|
||||
description:
|
||||
'Your entities will be imported as soon as the Pull Request is merged.',
|
||||
},
|
||||
locations: {
|
||||
new: 'The following entities have been added to the catalog:',
|
||||
existing: 'A refresh was triggered for the following locations:',
|
||||
viewButtonText: 'View Component',
|
||||
backButtonText: 'Register another',
|
||||
},
|
||||
},
|
||||
stepInitAnalyzeUrl: {
|
||||
error: {
|
||||
repository: "Couldn't generate entities for your repository",
|
||||
locations: 'There are no entities at this location',
|
||||
default:
|
||||
'Received unknown analysis result of type {{type}}. Please contact the support team.',
|
||||
url: 'Must start with http:// or https://.',
|
||||
},
|
||||
urlHelperText:
|
||||
'Enter the full path to your entity file to start tracking your component',
|
||||
nextButtonText: 'Analyze',
|
||||
},
|
||||
stepPrepareCreatePullRequest: {
|
||||
previewPr: {
|
||||
title: 'Preview Pull Request',
|
||||
subheader: 'Create a new Pull Request',
|
||||
},
|
||||
previewCatalogInfo: {
|
||||
title: 'Preview Entities',
|
||||
},
|
||||
nextButtonText: 'Create PR',
|
||||
},
|
||||
stepPrepareSelectLocations: {
|
||||
locations: {
|
||||
description:
|
||||
'Select one or more locations that are present in your git repository:',
|
||||
selectAll: 'Select All',
|
||||
},
|
||||
existingLocations: {
|
||||
description: 'These locations already exist in the catalog:',
|
||||
},
|
||||
nextButtonText: 'Review',
|
||||
},
|
||||
stepReviewLocation: {
|
||||
prepareResult: {
|
||||
title: 'The following Pull Request has been opened: ',
|
||||
description:
|
||||
'You can already import the location and {{appTitle}} will fetch the entities as soon as the Pull Request is merged.',
|
||||
},
|
||||
catalog: {
|
||||
exists: 'The following locations already exist in the catalog:',
|
||||
new: 'The following entities will be added to the catalog:',
|
||||
},
|
||||
refresh: 'Refresh',
|
||||
import: 'Import',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -344,8 +344,8 @@ export const scaffolderReactTranslationRef: TranslationRef<
|
||||
readonly 'stepper.backButtonText': 'Back';
|
||||
readonly 'stepper.createButtonText': 'Create';
|
||||
readonly 'stepper.reviewButtonText': 'Review';
|
||||
readonly 'stepper.stepIndexLabel': 'Step {{index, number}}';
|
||||
readonly 'stepper.nextButtonText': 'Next';
|
||||
readonly 'stepper.stepIndexLabel': 'Step {{index, number}}';
|
||||
readonly 'templateCategoryPicker.title': 'Categories';
|
||||
readonly 'templateCard.noDescription': 'No description';
|
||||
readonly 'templateCard.chooseButtonText': 'Choose';
|
||||
|
||||
Reference in New Issue
Block a user