Merge branch 'master' of github.com:backstage/backstage into feat/scaff-logs
* 'master' of github.com:backstage/backstage: (1292 commits)
chore: Fixing prettier formatting
Create six-experts-destroy.md
Add config schema for Bitbucket scaffolder
bug: use the legacy version of graphiql on master right now until we port to app routes
Revert "build(deps): bump jose from 1.27.1 to 3.2.0"
docs: show how to assign annotations to primitive array item types in config schema
changesets: add cli build extension fix
cli: use same extension for chunks as for the main built file
Create swift-ears-fetch.md
[ImgBot] Optimize images
Add missing IncludeSecret type
Update .changeset/friendly-numbers-accept.md
Use bitbucket as location identifier instead of bitbucket/api
Add documentation for config $include
Use msw for mocking external Bitbucket apis
Remove the dependency from the catalog plugin to techdocs
Added changeset for config $include
Add deprecation notice to $data
Config: Add $include which replaces $data and can include any value
chore: bump to the latest and hope that it works 🤞
...
This commit is contained in:
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
import { createApiRef, DiscoveryApi } from '@backstage/core';
|
||||
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
|
||||
export const scaffolderApiRef = createApiRef<ScaffolderApi>({
|
||||
id: 'plugin.scaffolder.service',
|
||||
@@ -33,20 +32,17 @@ export class ScaffolderApi {
|
||||
* Executes the scaffolding of a component, given a template and its
|
||||
* parameter values.
|
||||
*
|
||||
* @param template Template entity for the scaffolder to use. New project is going to be created out of this template.
|
||||
* @param templateName Template name for the scaffolder to use. New project is going to be created out of this template.
|
||||
* @param values Parameters for the template, e.g. name, description
|
||||
*/
|
||||
async scaffold(
|
||||
template: TemplateEntityV1alpha1,
|
||||
values: Record<string, any>,
|
||||
) {
|
||||
async scaffold(templateName: string, values: Record<string, any>) {
|
||||
const url = `${await this.discoveryApi.getBaseUrl('scaffolder')}/v1/jobs`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ template, values: { ...values } }),
|
||||
body: JSON.stringify({ templateName, values: { ...values } }),
|
||||
});
|
||||
|
||||
if (response.status !== 201) {
|
||||
|
||||
@@ -76,6 +76,18 @@ const useStyles = makeStyles(theme => ({
|
||||
boxShadow: `inset 4px 0px 0px ${theme.palette.success.main}`,
|
||||
},
|
||||
},
|
||||
jobStatusTitle: {
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
type Props = {
|
||||
@@ -124,7 +136,7 @@ export const JobStage = ({ endedAt, startedAt, name, log, status }: Props) => {
|
||||
className: classes.button,
|
||||
}}
|
||||
>
|
||||
<Typography variant="button">
|
||||
<Typography variant="button" className={classes.jobStatusTitle}>
|
||||
{name} {timeElapsed && `(${timeElapsed})`}{' '}
|
||||
{startedAt && !endedAt && <CircularProgress size="1em" />}
|
||||
</Typography>
|
||||
|
||||
@@ -13,49 +13,57 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import { Button } from '@backstage/core';
|
||||
import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog';
|
||||
import {
|
||||
Button as Action,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
LinearProgress,
|
||||
} from '@material-ui/core';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { Job } from '../../types';
|
||||
import { JobStage } from '../JobStage/JobStage';
|
||||
import { useJobPolling } from './useJobPolling';
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
onComplete: (job: Job) => void;
|
||||
jobId: string;
|
||||
entity: TemplateEntityV1alpha1 | null;
|
||||
job: Job | null;
|
||||
toCatalogLink?: string;
|
||||
open: boolean;
|
||||
onModalClose: () => void;
|
||||
};
|
||||
|
||||
export const JobStatusModal = ({
|
||||
onClose,
|
||||
jobId,
|
||||
onComplete,
|
||||
entity,
|
||||
job,
|
||||
toCatalogLink,
|
||||
open,
|
||||
onModalClose,
|
||||
}: Props) => {
|
||||
const job = useJobPolling(jobId);
|
||||
const [dialogTitle, setDialogTitle] = useState('Creating component...');
|
||||
const renderTitle = () => {
|
||||
switch (job?.status) {
|
||||
case 'COMPLETED':
|
||||
return 'Successfully created component';
|
||||
case 'FAILED':
|
||||
return 'Failed to create component';
|
||||
default:
|
||||
return 'Create component';
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (job?.status === 'COMPLETED') {
|
||||
setDialogTitle('Successfully created component');
|
||||
onComplete(job);
|
||||
} else if (job?.status === 'FAILED')
|
||||
setDialogTitle('Failed to create component');
|
||||
}, [job, onComplete, setDialogTitle]);
|
||||
const onClose = useCallback(() => {
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
// Disallow closing modal if the job is in progress.
|
||||
if (job.status === 'COMPLETED' || job.status === 'FAILED') {
|
||||
onModalClose();
|
||||
}
|
||||
}, [job, onModalClose]);
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose} fullWidth>
|
||||
<DialogTitle id="responsive-dialog-title">{dialogTitle}</DialogTitle>
|
||||
<Dialog open={open} onClose={onClose} fullWidth>
|
||||
<DialogTitle id="responsive-dialog-title">{renderTitle()}</DialogTitle>
|
||||
<DialogContent>
|
||||
{!job ? (
|
||||
<LinearProgress />
|
||||
@@ -72,16 +80,14 @@ export const JobStatusModal = ({
|
||||
))
|
||||
)}
|
||||
</DialogContent>
|
||||
{entity && (
|
||||
{job?.status && toCatalogLink && (
|
||||
<DialogActions>
|
||||
<Button
|
||||
to={generatePath(
|
||||
`/catalog/${entityRoute.path}`,
|
||||
entityRouteParams(entity),
|
||||
)}
|
||||
>
|
||||
View in catalog
|
||||
</Button>
|
||||
<Button to={toCatalogLink}>View in catalog</Button>
|
||||
</DialogActions>
|
||||
)}
|
||||
{job?.status === 'FAILED' && (
|
||||
<DialogActions>
|
||||
<Action onClick={onClose}>Close</Action>
|
||||
</DialogActions>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
@@ -69,8 +69,9 @@ export const MultistepJsonForm = ({
|
||||
{steps.map(({ label, schema, ...formProps }) => (
|
||||
<Step key={label}>
|
||||
<StepLabel>{label}</StepLabel>
|
||||
<StepContent>
|
||||
<StepContent key={label}>
|
||||
<Form
|
||||
key={label}
|
||||
noHtml5Validate
|
||||
formData={formData}
|
||||
onChange={onChange}
|
||||
|
||||
@@ -53,10 +53,12 @@ export const ScaffolderPage = () => {
|
||||
|
||||
const { data: templates, isValidating, error } = useStaleWhileRevalidate(
|
||||
'templates/all',
|
||||
async () =>
|
||||
catalogApi.getEntities({ kind: 'Template' }) as Promise<
|
||||
TemplateEntityV1alpha1[]
|
||||
>,
|
||||
async () => {
|
||||
const response = await catalogApi.getEntities({
|
||||
filter: { kind: 'Template' },
|
||||
});
|
||||
return response.items as TemplateEntityV1alpha1[];
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -81,7 +83,7 @@ export const ScaffolderPage = () => {
|
||||
variant="contained"
|
||||
color="primary"
|
||||
component={RouterLink}
|
||||
to="/register-component"
|
||||
to="/catalog-import"
|
||||
>
|
||||
Register Existing Component
|
||||
</Button>
|
||||
|
||||
@@ -13,18 +13,17 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { TemplatePage } from './TemplatePage';
|
||||
import { wrapInTestApp, renderWithEffects } from '@backstage/test-utils';
|
||||
import { ApiRegistry, errorApiRef, ApiProvider } from '@backstage/core';
|
||||
import { scaffolderApiRef, ScaffolderApi } from '../../api';
|
||||
import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog';
|
||||
import { mutate } from 'swr';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { Route, MemoryRouter } from 'react-router';
|
||||
import { rootRoute } from '../../routes';
|
||||
import { ThemeProvider } from '@material-ui/core';
|
||||
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
|
||||
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
|
||||
import { renderInTestApp, renderWithEffects } from '@backstage/test-utils';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import { ThemeProvider } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { MemoryRouter, Route } from 'react-router';
|
||||
import { ScaffolderApi, scaffolderApiRef } from '../../api';
|
||||
import { rootRoute } from '../../routes';
|
||||
import { TemplatePage } from './TemplatePage';
|
||||
|
||||
const templateMock = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
@@ -90,48 +89,43 @@ const apis = ApiRegistry.from([
|
||||
]);
|
||||
|
||||
describe('TemplatePage', () => {
|
||||
afterEach(async () => {
|
||||
// Cleaning up swr's cache
|
||||
await act(async () => {
|
||||
await mutate('templates/test');
|
||||
});
|
||||
});
|
||||
beforeEach(() => jest.resetAllMocks());
|
||||
|
||||
it('renders correctly', async () => {
|
||||
catalogApiMock.getEntities.mockResolvedValueOnce([templateMock]);
|
||||
const rendered = await renderWithEffects(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<TemplatePage />
|
||||
</ApiProvider>,
|
||||
),
|
||||
catalogApiMock.getEntities.mockResolvedValueOnce({ items: [templateMock] });
|
||||
const rendered = await renderInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<TemplatePage />
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
expect(rendered.queryByText('Create a new component')).toBeInTheDocument();
|
||||
expect(rendered.queryByText('React SSR Template')).toBeInTheDocument();
|
||||
// await act(async () => await mutate('templates/test'));
|
||||
});
|
||||
|
||||
it('renders spinner while loading', async () => {
|
||||
let resolve: Function;
|
||||
const promise = new Promise<any>(res => {
|
||||
resolve = res;
|
||||
});
|
||||
catalogApiMock.getEntities.mockResolvedValueOnce(promise);
|
||||
const rendered = await renderWithEffects(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<TemplatePage />
|
||||
</ApiProvider>,
|
||||
),
|
||||
catalogApiMock.getEntities.mockReturnValueOnce(promise);
|
||||
const rendered = await renderInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<TemplatePage />
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
expect(rendered.queryByText('Create a new component')).toBeInTheDocument();
|
||||
expect(rendered.queryByTestId('loading-progress')).toBeInTheDocument();
|
||||
// Need to cleanup the promise or will timeout
|
||||
resolve!();
|
||||
act(() => {
|
||||
resolve!({ items: [] });
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates away if no template was loaded', async () => {
|
||||
catalogApiMock.getEntities.mockResolvedValueOnce([]);
|
||||
catalogApiMock.getEntities.mockResolvedValueOnce({ items: [] });
|
||||
|
||||
const rendered = await renderWithEffects(
|
||||
<ApiProvider apis={apis}>
|
||||
|
||||
@@ -23,32 +23,34 @@ import {
|
||||
Page,
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog';
|
||||
import {
|
||||
catalogApiRef,
|
||||
entityRoute,
|
||||
entityRouteParams,
|
||||
} from '@backstage/plugin-catalog';
|
||||
import { LinearProgress } from '@material-ui/core';
|
||||
import { IChangeEvent } from '@rjsf/core';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { generatePath, Navigate } from 'react-router';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import useStaleWhileRevalidate from 'swr';
|
||||
import { useAsync } from 'react-use';
|
||||
import { scaffolderApiRef } from '../../api';
|
||||
import { JobStatusModal } from '../JobStatusModal';
|
||||
import { Job } from '../../types';
|
||||
import { MultistepJsonForm } from '../MultistepJsonForm';
|
||||
import { Navigate } from 'react-router';
|
||||
import { rootRoute } from '../../routes';
|
||||
import { JobStatusModal } from '../JobStatusModal';
|
||||
import { MultistepJsonForm } from '../MultistepJsonForm';
|
||||
import { useJobPolling } from '../hooks/useJobPolling';
|
||||
|
||||
const useTemplate = (
|
||||
templateName: string,
|
||||
catalogApi: typeof catalogApiRef.T,
|
||||
) => {
|
||||
const { data, error } = useStaleWhileRevalidate(
|
||||
`templates/${templateName}`,
|
||||
async () =>
|
||||
catalogApi.getEntities({
|
||||
kind: 'Template',
|
||||
'metadata.name': templateName,
|
||||
}) as Promise<TemplateEntityV1alpha1[]>,
|
||||
);
|
||||
return { template: data?.[0], loading: !error && !data, error };
|
||||
const { value, loading, error } = useAsync(async () => {
|
||||
const response = await catalogApi.getEntities({
|
||||
filter: { kind: 'Template', 'metadata.name': templateName },
|
||||
});
|
||||
return response.items as TemplateEntityV1alpha1[];
|
||||
});
|
||||
return { template: value?.[0], loading, error };
|
||||
};
|
||||
|
||||
const OWNER_REPO_SCHEMA = {
|
||||
@@ -83,53 +85,53 @@ export const TemplatePage = () => {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const scaffolderApi = useApi(scaffolderApiRef);
|
||||
const { templateName } = useParams();
|
||||
const [catalogLink, setCatalogLink] = useState<string | undefined>();
|
||||
const { template, loading } = useTemplate(templateName, catalogApi);
|
||||
|
||||
const [formState, setFormState] = useState({});
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const handleFormReset = () => setFormState({});
|
||||
const handleChange = (e: IChangeEvent) =>
|
||||
setFormState({ ...formState, ...e.formData });
|
||||
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const handleClose = () => setJobId(null);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const job = await scaffolderApi.scaffold(template!, formState);
|
||||
setJobId(job);
|
||||
} catch (e) {
|
||||
errorApi.post(e);
|
||||
}
|
||||
};
|
||||
|
||||
const [entity, setEntity] = React.useState<TemplateEntityV1alpha1 | null>(
|
||||
null,
|
||||
const handleChange = useCallback(
|
||||
(e: IChangeEvent) => setFormState({ ...formState, ...e.formData }),
|
||||
[setFormState, formState],
|
||||
);
|
||||
|
||||
const handleCreateComplete = async (job: Job) => {
|
||||
const target = job.metadata.remoteUrl?.replace(
|
||||
/\.git$/,
|
||||
// TODO(Rugvip): This is not the location we want. As part of scaffodler v2 we
|
||||
// want this to be more flexible, but before that we might want
|
||||
// to update all templates to use catalog-info.yaml instead.
|
||||
'/blob/master/component-info.yaml',
|
||||
);
|
||||
|
||||
if (!target) {
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const job = useJobPolling(jobId, async job => {
|
||||
if (!job.metadata.catalogInfoUrl) {
|
||||
errorApi.post(
|
||||
new Error(
|
||||
`Failed to find component-info.yaml file in ${job.metadata.remoteUrl}.`,
|
||||
),
|
||||
new Error(`No catalogInfoUrl returned from the scaffolder`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
entities: [createdEntity],
|
||||
} = await catalogApi.addLocation({ target });
|
||||
try {
|
||||
const {
|
||||
entities: [createdEntity],
|
||||
} = await catalogApi.addLocation({ target: job.metadata.catalogInfoUrl });
|
||||
|
||||
setEntity((createdEntity as any) as TemplateEntityV1alpha1);
|
||||
const resolvedPath = generatePath(
|
||||
`/catalog/${entityRoute.path}`,
|
||||
entityRouteParams(createdEntity),
|
||||
);
|
||||
|
||||
setCatalogLink(resolvedPath);
|
||||
} catch (ex) {
|
||||
errorApi.post(
|
||||
new Error(
|
||||
`Something went wrong trying to add the new 'catalog-info.yaml' to the catalog`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const jobId = await scaffolderApi.scaffold(templateName, formState);
|
||||
setJobId(jobId);
|
||||
setModalOpen(true);
|
||||
} catch (e) {
|
||||
errorApi.post(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (!loading && !template) {
|
||||
@@ -159,14 +161,12 @@ export const TemplatePage = () => {
|
||||
/>
|
||||
<Content>
|
||||
{loading && <LinearProgress data-testid="loading-progress" />}
|
||||
{jobId && (
|
||||
<JobStatusModal
|
||||
onComplete={handleCreateComplete}
|
||||
jobId={jobId}
|
||||
onClose={handleClose}
|
||||
entity={entity}
|
||||
/>
|
||||
)}
|
||||
<JobStatusModal
|
||||
job={job}
|
||||
toCatalogLink={catalogLink}
|
||||
open={modalOpen}
|
||||
onModalClose={() => setModalOpen(false)}
|
||||
/>
|
||||
{template && (
|
||||
<InfoCard title={template.metadata.title} noPadding>
|
||||
<MultistepJsonForm
|
||||
|
||||
+30
-30
@@ -13,50 +13,50 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Job } from '../../types';
|
||||
import { useApi } from '@backstage/core';
|
||||
import { scaffolderApiRef } from '../../api';
|
||||
import { useInterval } from 'react-use';
|
||||
|
||||
const DEFAULT_POLLING_INTERVAL = 1000;
|
||||
const poll = (thunk: () => Promise<void>, ms: number) => {
|
||||
let shouldStop = false;
|
||||
(async () => {
|
||||
while (!shouldStop) {
|
||||
await thunk();
|
||||
await new Promise(res => setTimeout(res, ms));
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
shouldStop = true;
|
||||
};
|
||||
};
|
||||
|
||||
export const useJobPolling = (
|
||||
jobId: string | null,
|
||||
onFinish?: (j: Job) => void,
|
||||
pollingInterval = DEFAULT_POLLING_INTERVAL,
|
||||
) => {
|
||||
const scaffolderApi = useApi(scaffolderApiRef);
|
||||
const [job, setJob] = useState<Job | null>(null);
|
||||
const [currentJob, setCurrentJob] = useState<Job | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobId) return () => {};
|
||||
|
||||
const stopPolling = poll(async () => {
|
||||
const nextJobState = await scaffolderApi.getJob(jobId);
|
||||
if (
|
||||
nextJobState.status === 'FAILED' ||
|
||||
nextJobState.status === 'COMPLETED'
|
||||
) {
|
||||
stopPolling();
|
||||
const resetCurrentJob = async () => {
|
||||
if (jobId) {
|
||||
const job = await scaffolderApi.getJob(jobId);
|
||||
setCurrentJob(job);
|
||||
}
|
||||
setJob(nextJobState);
|
||||
}, pollingInterval);
|
||||
return () => {
|
||||
stopPolling();
|
||||
};
|
||||
}, [jobId, setJob, scaffolderApi, pollingInterval]);
|
||||
|
||||
return job;
|
||||
resetCurrentJob();
|
||||
}, [jobId, scaffolderApi]);
|
||||
|
||||
const shouldBeRunningInterval =
|
||||
jobId &&
|
||||
currentJob?.status !== 'COMPLETED' &&
|
||||
currentJob?.status !== 'FAILED';
|
||||
|
||||
useInterval(
|
||||
async () => {
|
||||
if (jobId) {
|
||||
const job = await scaffolderApi.getJob(jobId);
|
||||
if (job?.status === 'COMPLETED' || job?.status === 'FAILED') {
|
||||
onFinish?.(job);
|
||||
}
|
||||
setCurrentJob(job);
|
||||
}
|
||||
},
|
||||
shouldBeRunningInterval ? pollingInterval : null,
|
||||
);
|
||||
|
||||
return currentJob;
|
||||
};
|
||||
@@ -13,14 +13,17 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export type JobStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED';
|
||||
export type Job = {
|
||||
id: string;
|
||||
metadata: {
|
||||
entity: any;
|
||||
values: any;
|
||||
remoteUrl?: string;
|
||||
catalogInfoUrl?: string;
|
||||
};
|
||||
status: 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED';
|
||||
status: JobStatus;
|
||||
stages: Stage[];
|
||||
error?: Error;
|
||||
};
|
||||
@@ -28,7 +31,7 @@ export type Job = {
|
||||
export type Stage = {
|
||||
name: string;
|
||||
log: string[];
|
||||
status: 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED';
|
||||
status: JobStatus;
|
||||
startedAt: string;
|
||||
endedAt?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user