Merge pull request #13112 from dagda1/step-layout
Provide API to extend scaffolder form layout
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder': minor
|
||||
---
|
||||
|
||||
Ability to modify the layout of the step form
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
ScaffolderPage,
|
||||
NextScaffolderPage,
|
||||
scaffolderPlugin,
|
||||
ScaffolderLayouts,
|
||||
} from '@backstage/plugin-scaffolder';
|
||||
import { SearchPage } from '@backstage/plugin-search';
|
||||
import { TechRadarPage } from '@backstage/plugin-tech-radar';
|
||||
@@ -104,6 +105,7 @@ import { ApacheAirflowPage } from '@backstage/plugin-apache-airflow';
|
||||
import { RequirePermission } from '@backstage/plugin-permission-react';
|
||||
import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common';
|
||||
import { PlaylistIndexPage } from '@backstage/plugin-playlist';
|
||||
import { TwoColumnLayout } from './components/scaffolder/customScaffolderLayouts';
|
||||
|
||||
const app = createApp({
|
||||
apis,
|
||||
@@ -218,6 +220,9 @@ const routes = (
|
||||
<ScaffolderFieldExtensions>
|
||||
<LowerCaseValuePickerFieldExtension />
|
||||
</ScaffolderFieldExtensions>
|
||||
<ScaffolderLayouts>
|
||||
<TwoColumnLayout />
|
||||
</ScaffolderLayouts>
|
||||
</Route>
|
||||
<Route
|
||||
path="/create/next"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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 React from 'react';
|
||||
import {
|
||||
createScaffolderLayout,
|
||||
LayoutTemplate,
|
||||
scaffolderPlugin,
|
||||
} from '@backstage/plugin-scaffolder';
|
||||
import { Grid } from '@material-ui/core';
|
||||
|
||||
const TwoColumn: LayoutTemplate = ({ properties, description, title }) => {
|
||||
const mid = Math.ceil(properties.length / 2);
|
||||
const left = properties.slice(0, mid);
|
||||
const right = properties.slice(mid);
|
||||
return (
|
||||
<>
|
||||
<h1>{title}</h1>
|
||||
<h2>In two column layout!!</h2>
|
||||
<Grid container justifyContent="flex-end">
|
||||
{left.map(prop => (
|
||||
<Grid item xs={6} key={prop.content.key}>
|
||||
{prop.content}
|
||||
</Grid>
|
||||
))}
|
||||
{right.map(prop => (
|
||||
<Grid item xs={6} key={prop.content.key}>
|
||||
{prop.content}
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
{description}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const TwoColumnLayout = scaffolderPlugin.provide(
|
||||
createScaffolderLayout({
|
||||
name: 'TwoColumn',
|
||||
component: TwoColumn,
|
||||
}),
|
||||
);
|
||||
@@ -17,6 +17,7 @@
|
||||
export const defaultPreviewTemplate = `# Edit the template parameters below to see how they will render in the scaffolder form UI
|
||||
parameters:
|
||||
- title: Fill in some steps
|
||||
ui:ObjectFieldTemplate: TwoColumn
|
||||
required:
|
||||
- name
|
||||
properties:
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ExternalRouteRef } from '@backstage/core-plugin-api';
|
||||
import { FetchApi } from '@backstage/core-plugin-api';
|
||||
import { FieldProps } from '@rjsf/core';
|
||||
import { FieldValidation } from '@rjsf/core';
|
||||
import type { FormProps } from '@rjsf/core';
|
||||
import { IdentityApi } from '@backstage/core-plugin-api';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { JSONSchema7 } from 'json-schema';
|
||||
@@ -37,6 +38,11 @@ export function createScaffolderFieldExtension<
|
||||
options: FieldExtensionOptions<TReturnValue, TInputProps>,
|
||||
): Extension<FieldExtensionComponent<TReturnValue, TInputProps>>;
|
||||
|
||||
// @public
|
||||
export function createScaffolderLayout<TInputProps = unknown>(
|
||||
options: LayoutOptions,
|
||||
): Extension<LayoutComponent<TInputProps>>;
|
||||
|
||||
// @public
|
||||
export type CustomFieldValidator<TFieldReturnValue> = (
|
||||
data: TFieldReturnValue,
|
||||
@@ -110,6 +116,20 @@ export type FieldExtensionOptions<
|
||||
validation?: CustomFieldValidator<TFieldReturnValue>;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type LayoutComponent<_TInputProps> = () => null;
|
||||
|
||||
// @public
|
||||
export interface LayoutOptions<P = any> {
|
||||
// (undocumented)
|
||||
component: LayoutTemplate<P>;
|
||||
// (undocumented)
|
||||
name: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type LayoutTemplate<T = any> = FormProps<T>['ObjectFieldTemplate'];
|
||||
|
||||
// @public
|
||||
export type ListActionsResponse = Array<{
|
||||
id: string;
|
||||
@@ -349,6 +369,9 @@ export interface ScaffolderGetIntegrationsListResponse {
|
||||
}[];
|
||||
}
|
||||
|
||||
// @public
|
||||
export const ScaffolderLayouts: React.ComponentType;
|
||||
|
||||
// @public (undocumented)
|
||||
export type ScaffolderOutputLink = {
|
||||
title?: string;
|
||||
|
||||
@@ -36,6 +36,7 @@ import { transformSchemaToProps } from './schema';
|
||||
import { Content, StructuredMetadataTable } from '@backstage/core-components';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import * as fieldOverrides from './FieldOverrides';
|
||||
import { LayoutOptions } from '../../layouts';
|
||||
|
||||
const Form = withTheme(MuiTheme);
|
||||
type Step = {
|
||||
@@ -55,6 +56,7 @@ type Props = {
|
||||
widgets?: FormProps<any>['widgets'];
|
||||
fields?: FormProps<any>['fields'];
|
||||
finishButtonLabel?: string;
|
||||
layouts: LayoutOptions[];
|
||||
};
|
||||
|
||||
export function getUiSchemasFromSteps(steps: Step[]): UiSchema[] {
|
||||
@@ -119,6 +121,7 @@ export const MultistepJsonForm = (props: Props) => {
|
||||
fields,
|
||||
widgets,
|
||||
finishButtonLabel,
|
||||
layouts,
|
||||
} = props;
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [disableButtons, setDisableButtons] = useState(false);
|
||||
@@ -214,7 +217,7 @@ export const MultistepJsonForm = (props: Props) => {
|
||||
if (e.errors.length === 0) handleNext();
|
||||
}}
|
||||
{...formProps}
|
||||
{...transformSchemaToProps(schema)}
|
||||
{...transformSchemaToProps(schema, layouts)}
|
||||
>
|
||||
<Button disabled={activeStep === 0} onClick={handleBack}>
|
||||
Back
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
*/
|
||||
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { FormProps } from '@rjsf/core';
|
||||
import { FormProps, UiSchema } from '@rjsf/core';
|
||||
import { LayoutOptions } from '../../layouts';
|
||||
|
||||
function isObject(value: unknown): value is JsonObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
@@ -99,14 +100,29 @@ function extractUiSchema(schema: JsonObject, uiSchema: JsonObject) {
|
||||
}
|
||||
}
|
||||
|
||||
export function transformSchemaToProps(inputSchema: JsonObject): {
|
||||
export function transformSchemaToProps(
|
||||
inputSchema: JsonObject,
|
||||
layouts: LayoutOptions[] = [],
|
||||
): {
|
||||
schema: FormProps<any>['schema'];
|
||||
uiSchema: FormProps<any>['uiSchema'];
|
||||
} {
|
||||
const customLayoutName = inputSchema['ui:ObjectFieldTemplate'];
|
||||
inputSchema.type = inputSchema.type || 'object';
|
||||
const schema = JSON.parse(JSON.stringify(inputSchema));
|
||||
delete schema.title; // Rendered separately
|
||||
const uiSchema = {};
|
||||
const uiSchema: UiSchema = {};
|
||||
extractUiSchema(schema, uiSchema);
|
||||
|
||||
if (customLayoutName) {
|
||||
const Layout = layouts.find(
|
||||
layout => layout.name === customLayoutName,
|
||||
)?.component;
|
||||
|
||||
if (Layout) {
|
||||
uiSchema['ui:ObjectFieldTemplate'] = Layout;
|
||||
}
|
||||
}
|
||||
|
||||
return { schema, uiSchema };
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
selectedTemplateRouteRef,
|
||||
} from '../routes';
|
||||
import { ListTasksPage } from './ListTasksPage';
|
||||
import { LayoutOptions, LAYOUTS_KEY, LAYOUTS_WRAPPER_KEY } from '../layouts';
|
||||
|
||||
/**
|
||||
* The props for the entrypoint `ScaffolderPage` component the plugin.
|
||||
@@ -105,6 +106,17 @@ export const Router = (props: RouterProps) => {
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
const customLayouts = useElementFilter(outlet, elements =>
|
||||
elements
|
||||
.selectByComponentData({
|
||||
key: LAYOUTS_WRAPPER_KEY,
|
||||
})
|
||||
.findComponentData<LayoutOptions>({
|
||||
key: LAYOUTS_KEY,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* This component can be deleted once the older routes have been deprecated.
|
||||
*/
|
||||
@@ -142,7 +154,10 @@ export const Router = (props: RouterProps) => {
|
||||
path={selectedTemplateRouteRef.path}
|
||||
element={
|
||||
<SecretsContextProvider>
|
||||
<TemplatePage customFieldExtensions={fieldExtensions} />
|
||||
<TemplatePage
|
||||
customFieldExtensions={fieldExtensions}
|
||||
layouts={customLayouts}
|
||||
/>
|
||||
</SecretsContextProvider>
|
||||
}
|
||||
/>
|
||||
@@ -159,6 +174,7 @@ export const Router = (props: RouterProps) => {
|
||||
<TemplateEditorPage
|
||||
defaultPreviewTemplate={defaultPreviewTemplate}
|
||||
customFieldExtensions={fieldExtensions}
|
||||
layouts={customLayouts}
|
||||
/>
|
||||
</SecretsContextProvider>
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
import React, { useState } from 'react';
|
||||
import { FieldExtensionOptions } from '../../extensions';
|
||||
import type { LayoutOptions } from '../../layouts';
|
||||
import { TemplateDirectoryAccess } from '../../lib/filesystem';
|
||||
import { DirectoryEditorProvider } from './DirectoryEditorContext';
|
||||
import { DryRunProvider } from './DryRunContext';
|
||||
@@ -57,6 +58,7 @@ const useStyles = makeStyles({
|
||||
export const TemplateEditor = (props: {
|
||||
directory: TemplateDirectoryAccess;
|
||||
fieldExtensions?: FieldExtensionOptions<any, any>[];
|
||||
layouts?: LayoutOptions[];
|
||||
onClose?: () => void;
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
@@ -77,6 +79,7 @@ export const TemplateEditor = (props: {
|
||||
<TemplateEditorForm.DirectoryEditorDryRun
|
||||
setErrorText={setErrorText}
|
||||
fieldExtensions={props.fieldExtensions}
|
||||
layouts={props.layouts}
|
||||
/>
|
||||
</section>
|
||||
<section className={classes.results}>
|
||||
|
||||
@@ -20,6 +20,7 @@ import React, { Component, ReactNode, useMemo, useState } from 'react';
|
||||
import useDebounce from 'react-use/lib/useDebounce';
|
||||
import yaml from 'yaml';
|
||||
import { FieldExtensionOptions } from '../../extensions';
|
||||
import { LayoutOptions } from '../../layouts';
|
||||
import { TemplateParameterSchema } from '../../types';
|
||||
import { MultistepJsonForm } from '../MultistepJsonForm';
|
||||
import { createValidator } from '../TemplatePage';
|
||||
@@ -83,6 +84,7 @@ interface TemplateEditorFormProps {
|
||||
|
||||
onDryRun?: (data: JsonObject) => Promise<void>;
|
||||
fieldExtensions?: FieldExtensionOptions<any, any>[];
|
||||
layouts?: LayoutOptions[];
|
||||
}
|
||||
|
||||
function isJsonObject(value: JsonValue | undefined): value is JsonObject {
|
||||
@@ -99,6 +101,7 @@ export function TemplateEditorForm(props: TemplateEditorFormProps) {
|
||||
onDryRun,
|
||||
setErrorText,
|
||||
fieldExtensions = [],
|
||||
layouts = [],
|
||||
} = props;
|
||||
const classes = useStyles();
|
||||
const apiHolder = useApiHolder();
|
||||
@@ -138,6 +141,7 @@ export function TemplateEditorForm(props: TemplateEditorFormProps) {
|
||||
}
|
||||
|
||||
const { parameters } = rootObj;
|
||||
|
||||
if (!Array.isArray(parameters)) {
|
||||
setErrorText('Template parameters must be an array');
|
||||
setSteps(undefined);
|
||||
@@ -188,6 +192,7 @@ export function TemplateEditorForm(props: TemplateEditorFormProps) {
|
||||
onReset={() => onUpdate({})}
|
||||
finishButtonLabel={onDryRun && 'Try It'}
|
||||
onFinish={onDryRun && (() => onDryRun(data))}
|
||||
layouts={layouts}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
@@ -197,7 +202,10 @@ export function TemplateEditorForm(props: TemplateEditorFormProps) {
|
||||
|
||||
/** A version of the TemplateEditorForm that is connected to the DirectoryEditor and DryRun contexts */
|
||||
export function TemplateEditorFormDirectoryEditorDryRun(
|
||||
props: Pick<TemplateEditorFormProps, 'setErrorText' | 'fieldExtensions'>,
|
||||
props: Pick<
|
||||
TemplateEditorFormProps,
|
||||
'setErrorText' | 'fieldExtensions' | 'layouts'
|
||||
>,
|
||||
) {
|
||||
const { setErrorText, fieldExtensions = [] } = props;
|
||||
const dryRun = useDryRun();
|
||||
|
||||
@@ -23,6 +23,7 @@ import { TemplateEditorIntro } from './TemplateEditorIntro';
|
||||
import { TemplateEditor } from './TemplateEditor';
|
||||
import { TemplateFormPreviewer } from './TemplateFormPreviewer';
|
||||
import { FieldExtensionOptions } from '../../extensions';
|
||||
import type { LayoutOptions } from '../../layouts';
|
||||
|
||||
type Selection =
|
||||
| {
|
||||
@@ -36,6 +37,7 @@ type Selection =
|
||||
interface TemplateEditorPageProps {
|
||||
defaultPreviewTemplate?: string;
|
||||
customFieldExtensions?: FieldExtensionOptions<any, any>[];
|
||||
layouts?: LayoutOptions[];
|
||||
}
|
||||
|
||||
export function TemplateEditorPage(props: TemplateEditorPageProps) {
|
||||
@@ -48,6 +50,7 @@ export function TemplateEditorPage(props: TemplateEditorPageProps) {
|
||||
directory={selection.directory}
|
||||
fieldExtensions={props.customFieldExtensions}
|
||||
onClose={() => setSelection(undefined)}
|
||||
layouts={props.layouts}
|
||||
/>
|
||||
);
|
||||
} else if (selection?.type === 'form') {
|
||||
@@ -56,6 +59,7 @@ export function TemplateEditorPage(props: TemplateEditorPageProps) {
|
||||
defaultPreviewTemplate={props.defaultPreviewTemplate}
|
||||
customFieldExtensions={props.customFieldExtensions}
|
||||
onClose={() => setSelection(undefined)}
|
||||
layouts={props.layouts}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -33,6 +33,7 @@ import React, { useCallback, useState } from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import yaml from 'yaml';
|
||||
import { FieldExtensionOptions } from '../../extensions';
|
||||
import { LayoutOptions } from '../../layouts';
|
||||
import { TemplateEditorForm } from './TemplateEditorForm';
|
||||
import { TemplateEditorTextArea } from './TemplateEditorTextArea';
|
||||
|
||||
@@ -110,10 +111,12 @@ export const TemplateFormPreviewer = ({
|
||||
defaultPreviewTemplate = EXAMPLE_TEMPLATE_PARAMS_YAML,
|
||||
customFieldExtensions = [],
|
||||
onClose,
|
||||
layouts = [],
|
||||
}: {
|
||||
defaultPreviewTemplate?: string;
|
||||
customFieldExtensions?: FieldExtensionOptions<any, any>[];
|
||||
onClose?: () => void;
|
||||
layouts?: LayoutOptions[];
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
const alertApi = useApi(alertApiRef);
|
||||
@@ -208,6 +211,7 @@ export const TemplateFormPreviewer = ({
|
||||
data={formState}
|
||||
onUpdate={setFormState}
|
||||
setErrorText={setErrorText}
|
||||
layouts={layouts}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
useRouteRefParams,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { LayoutOptions } from '../../layouts';
|
||||
|
||||
const useTemplateParameterSchema = (templateRef: string) => {
|
||||
const scaffolderApi = useApi(scaffolderApiRef);
|
||||
@@ -51,8 +52,10 @@ const useTemplateParameterSchema = (templateRef: string) => {
|
||||
|
||||
export const TemplatePage = ({
|
||||
customFieldExtensions = [],
|
||||
layouts = [],
|
||||
}: {
|
||||
customFieldExtensions?: FieldExtensionOptions<any, any>[];
|
||||
layouts?: LayoutOptions[];
|
||||
}) => {
|
||||
const apiHolder = useApiHolder();
|
||||
const secretsContext = useContext(SecretsContext);
|
||||
@@ -146,6 +149,7 @@ export const TemplatePage = ({
|
||||
onChange={handleChange}
|
||||
onReset={handleFormReset}
|
||||
onFinish={handleCreate}
|
||||
layouts={layouts}
|
||||
steps={schema.steps.map(step => {
|
||||
return {
|
||||
...step,
|
||||
|
||||
@@ -48,6 +48,8 @@ export type {
|
||||
FieldExtensionComponentProps,
|
||||
FieldExtensionComponent,
|
||||
} from './extensions';
|
||||
export { createScaffolderLayout, ScaffolderLayouts } from './layouts';
|
||||
export type { LayoutOptions, LayoutTemplate, LayoutComponent } from './layouts';
|
||||
export {
|
||||
EntityPickerFieldExtension,
|
||||
EntityNamePickerFieldExtension,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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 { attachComponentData, Extension } from '@backstage/core-plugin-api';
|
||||
import type { LayoutOptions } from './types';
|
||||
|
||||
export const LAYOUTS_KEY = 'scaffolder.layout.v1';
|
||||
export const LAYOUTS_WRAPPER_KEY = 'scaffolder.layouts.wrapper.v1';
|
||||
|
||||
/**
|
||||
* The type used to wrap up the Layout and embed the input props
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type LayoutComponent<_TInputProps> = () => null;
|
||||
|
||||
/**
|
||||
* Method for creating custom Layouts that can be used in the scaffolder frontend form
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function createScaffolderLayout<TInputProps = unknown>(
|
||||
options: LayoutOptions,
|
||||
): Extension<LayoutComponent<TInputProps>> {
|
||||
return {
|
||||
expose() {
|
||||
const LayoutDataHolder: any = () => null;
|
||||
|
||||
attachComponentData(LayoutDataHolder, LAYOUTS_KEY, options);
|
||||
|
||||
return LayoutDataHolder;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The wrapping component for defining scaffolder layouts as children
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const ScaffolderLayouts: React.ComponentType = (): JSX.Element | null =>
|
||||
null;
|
||||
|
||||
attachComponentData(ScaffolderLayouts, LAYOUTS_WRAPPER_KEY, true);
|
||||
|
||||
export type { LayoutOptions, LayoutTemplate } from './types';
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 type { FormProps } from '@rjsf/core';
|
||||
|
||||
/**
|
||||
* The field template from \@rjsf/core which is a react component that gets passed \@rjsf/core field related props.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type LayoutTemplate<T = any> = FormProps<T>['ObjectFieldTemplate'];
|
||||
|
||||
/**
|
||||
* The type of layouts that is passed to the TemplateForms
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface LayoutOptions<P = any> {
|
||||
name: string;
|
||||
component: LayoutTemplate<P>;
|
||||
}
|
||||
Reference in New Issue
Block a user