diff --git a/CHANGES/2833.feature b/CHANGES/2833.feature new file mode 100644 index 0000000000..049b1295e7 --- /dev/null +++ b/CHANGES/2833.feature @@ -0,0 +1 @@ +Add an import button for import/reimport roles in the UI diff --git a/src/api/index.ts b/src/api/index.ts index 58ee5da6a1..e604811d60 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -19,6 +19,7 @@ export { GenericPulpAPI } from './generic-pulp'; export { GroupAPI } from './group'; export { GroupRoleAPI } from './group-role'; export { ImportAPI } from './import'; +export { LegacyImportAPI } from './legacy-import'; export { LegacyNamespaceAPI } from './legacy-namespace'; export { LegacyRoleAPI } from './legacy-role'; export { MyDistributionAPI } from './my-distribution'; diff --git a/src/api/legacy-import.ts b/src/api/legacy-import.ts new file mode 100644 index 0000000000..c2d9bddc22 --- /dev/null +++ b/src/api/legacy-import.ts @@ -0,0 +1,14 @@ +import { LegacyAPI } from './legacy'; + +export class API extends LegacyAPI { + apiPath = 'v1/imports/'; + sortParam = 'order_by'; + + // list(params?) + + import(data) { + return this.http.post(this.apiPath, data); + } +} + +export const LegacyImportAPI = new API(); diff --git a/src/components/index.ts b/src/components/index.ts index 885d96108a..8aba5c113e 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -43,6 +43,7 @@ export { CollectionHeader } from './headers/collection-header'; export { PartnerHeader } from './headers/partner-header'; export { HelperText } from './helper-text/helper-text'; export { ImportModal } from './import-modal/import-modal'; +export { RoleImportForm } from './legacy-role/role-import-form'; export { ListItemActions } from './list-item-actions/list-item-actions'; export { LoadingPageSpinner } from './loading/loading-page-spinner'; export { LoadingPageWithHeader } from './loading/loading-with-header'; diff --git a/src/components/legacy-role/role-import-form.tsx b/src/components/legacy-role/role-import-form.tsx new file mode 100644 index 0000000000..2716d39e78 --- /dev/null +++ b/src/components/legacy-role/role-import-form.tsx @@ -0,0 +1,137 @@ +import { Trans, t } from '@lingui/macro'; +import { ActionGroup, Button } from '@patternfly/react-core'; +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { LegacyImportAPI } from 'src/api'; +import { AlertType, DataForm, ExternalLink } from 'src/components'; +import { useContext } from 'src/loaders/app-context'; +import { Paths, formatPath } from 'src/paths'; +import { ErrorMessagesType, handleHttpError, taskAlert } from 'src/utilities'; + +interface IProps { + addAlert: (alert: AlertType) => void; +} + +export const RoleImportForm = ({ addAlert }: IProps) => { + const { queueAlert, user } = useContext(); + const [data, setData] = useState<{ + alternate_role_name?: string; + github_repo?: string; + github_user?: string; + namespace_id?: string; + }>(user.is_superuser ? {} : { github_user: user.username }); + const [errors, setErrors] = useState(null); + const navigate = useNavigate(); + + // TODO user will have their namespace, superuser needs to create+assign + // curl -X POST '{"name": "foobar"}" /api/v1/namespaces/ + + const formFields = [ + { id: 'github_user', title: t`GitHub user` }, + { + id: 'github_repo', + title: t`GitHub repository`, + helper: + !data.github_repo || + data.github_repo.startsWith('ansible-role-') || + 'ansible-role-'.startsWith(data.github_repo) + ? null + : { + variant: 'warning' as const, + text: ( + <> + {t`Did you mean ${`ansible-role-${data.github_repo}`}?`}{' '} + + + ), + }, + }, + { + id: 'github_reference', + title: t`GitHub ref (a commit, branch or tag)`, + placeholder: t`Automatic`, + }, + { + id: 'alternate_role_name', + title: t`Role name`, + placeholder: t`Only used when a role doesn't have galaxy_info.role_name metadata, and doesn't follow the ansible-role-$name naming convention.`, + }, + ]; + + const requiredFields = ['github_user', 'github_repo']; + + const nonempty = (o) => + Object.fromEntries(Object.entries(o).filter(([_k, v]) => v)); + + const onCancel = () => navigate(formatPath(Paths.standaloneRoles)); + const onSaved = ({ + data: { + results: [{ pulp_id }], + }, + }) => { + // the role import_log tab is not available before the role gets imported, go to list + // TODO .. but we could waitForTask, and go to role on success + queueAlert(taskAlert(pulp_id, t`Import started`)); + navigate(formatPath(Paths.standaloneRoles)); + }; + + const onSave = () => + LegacyImportAPI.import(nonempty(data)) + .then(onSaved) + .catch(handleHttpError(t`Failed to import role`, null, addAlert)); + + const link = + data.github_user && + data.github_repo && + `https://github.com/${data.github_user}/${data.github_repo}`; + + const anyErrors = !!errors || requiredFields.some((k) => !data[k]); + + const formSuffix = ( + <> + {link ? ( +
+ + Will clone {link} + +
+ ) : null} + + + + + + ); + + const updateField = (k, v) => { + setData((data) => ({ ...data, [k]: v })); + + if (requiredFields.includes(k) && !v) { + setErrors((errors) => ({ ...errors, [k]: t`Field is required.` })); + } + }; + + return ( + updateField(e.target.id, v)} + onSave={onSave} + /> + ); +}; diff --git a/src/components/shared/data-form.tsx b/src/components/shared/data-form.tsx index 419a3e0c0f..c47681c8a6 100644 --- a/src/components/shared/data-form.tsx +++ b/src/components/shared/data-form.tsx @@ -1,10 +1,13 @@ import { Form, FormGroup, + HelperText, + HelperTextItem, + HelperTextItemProps, TextInput, TextInputTypes, } from '@patternfly/react-core'; -import React from 'react'; +import React, { ReactNode } from 'react'; import { ErrorMessagesType } from 'src/utilities'; interface IProps { @@ -15,10 +18,14 @@ interface IProps { placeholder?: string; title: string; type?: string; + helper?: { + variant: HelperTextItemProps['variant']; // "default" | "error" | "success" | "warning" | "indeterminate" + text: ReactNode; + }; }[]; - formPrefix?: React.ReactNode; - formSuffix?: React.ReactNode; - isReadonly: boolean; + formPrefix?: ReactNode; + formSuffix?: ReactNode; + isReadonly?: boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any model: Record; requiredFields: string[]; @@ -70,6 +77,13 @@ export class DataForm extends React.Component { {...(field.type === 'password' ? { autoComplete: 'off' } : {})} /> )} + {field.helper ? ( + + + {field.helper.text} + + + ) : null} ); }); diff --git a/src/containers/ansible-role/role-import.tsx b/src/containers/ansible-role/role-import.tsx new file mode 100644 index 0000000000..dab5df813b --- /dev/null +++ b/src/containers/ansible-role/role-import.tsx @@ -0,0 +1,54 @@ +import { t } from '@lingui/macro'; +import React from 'react'; +import { + AlertList, + AlertType, + BaseHeader, + Main, + RoleImportForm, + closeAlertMixin, +} from 'src/components'; +import { RouteProps, withRouter } from 'src/utilities'; + +interface RoleState { + alerts: AlertType[]; +} + +class AnsibleRoleImport extends React.Component { + constructor(props) { + super(props); + this.state = { + alerts: [], + }; + } + + private addAlert(alert: AlertType) { + this.setState({ + alerts: [...this.state.alerts, alert], + }); + } + + private get closeAlert() { + return closeAlertMixin('alerts'); + } + + render() { + const { alerts } = this.state; + const addAlert = (alert) => this.addAlert(alert); + const closeAlert = (i) => this.closeAlert(i); + + return ( + <> + + +
+
+ +
+
+ + ); + } +} + +export default withRouter(AnsibleRoleImport); diff --git a/src/containers/ansible-role/role-list.tsx b/src/containers/ansible-role/role-list.tsx index b073e9df76..1062fcf72d 100644 --- a/src/containers/ansible-role/role-list.tsx +++ b/src/containers/ansible-role/role-list.tsx @@ -1,5 +1,5 @@ import { t } from '@lingui/macro'; -import { DataList } from '@patternfly/react-core'; +import { Button, DataList } from '@patternfly/react-core'; import React from 'react'; import { LegacyRoleAPI, LegacyRoleListType, TagAPI } from 'src/api'; import { @@ -14,6 +14,8 @@ import { Pagination, closeAlertMixin, } from 'src/components'; +import { AppContext } from 'src/loaders/app-context'; +import { Paths, formatPath } from 'src/paths'; import { ParamHelper, RouteProps, @@ -35,6 +37,8 @@ interface RolesState { } class AnsibleRoleList extends React.Component { + static contextType = AppContext; + constructor(props) { super(props); @@ -58,6 +62,9 @@ class AnsibleRoleList extends React.Component { } componentDidMount() { + this.setState({ alerts: this.context.alerts || [] }); + this.context.setAlerts([]); + this.query(this.state.params); } @@ -163,6 +170,14 @@ class AnsibleRoleList extends React.Component { ) : (
+ this.props.navigate(formatPath(Paths.standaloneRoleImport)) + } + >{t`Import role`}, + ]} count={count} filterConfig={filterConfig} ignoredParams={['page', 'page_size', 'sort']} @@ -182,7 +197,7 @@ class AnsibleRoleList extends React.Component { ))} diff --git a/src/containers/index.ts b/src/containers/index.ts index 6cf0d9e0fe..19880974a7 100644 --- a/src/containers/index.ts +++ b/src/containers/index.ts @@ -7,6 +7,7 @@ export { default as AnsibleRepositoryList } from './ansible-repository/list'; export { default as AnsibleRoleNamespaceDetail } from './ansible-role/namespace-detail'; export { default as AnsibleRoleNamespaceList } from './ansible-role/namespace-list'; export { default as AnsibleRoleDetail } from './ansible-role/role-detail'; +export { default as AnsibleRoleImport } from './ansible-role/role-import'; export { default as AnsibleRoleList } from './ansible-role/role-list'; export { default as CertificationDashboard } from './certification-dashboard/certification-dashboard'; export { default as CollectionContent } from './collection-detail/collection-content'; diff --git a/src/loaders/standalone/routes.tsx b/src/loaders/standalone/routes.tsx index 04099cce02..407531b8ce 100644 --- a/src/loaders/standalone/routes.tsx +++ b/src/loaders/standalone/routes.tsx @@ -10,6 +10,7 @@ import { AnsibleRepositoryEdit, AnsibleRepositoryList, AnsibleRoleDetail, + AnsibleRoleImport, AnsibleRoleList, AnsibleRoleNamespaceDetail, AnsibleRoleNamespaceList, @@ -216,6 +217,7 @@ export class StandaloneRoutes extends React.Component { }, { component: AnsibleRoleNamespaceList, path: Paths.standaloneNamespaces }, { component: AnsibleRoleDetail, path: Paths.standaloneRole }, + { component: AnsibleRoleImport, path: Paths.standaloneRoleImport }, { component: AnsibleRoleList, path: Paths.standaloneRoles }, { component: TaskListView, diff --git a/src/paths.ts b/src/paths.ts index fd3407fa19..3060648486 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -112,6 +112,7 @@ export enum Paths { logout = '/logout', landingPage = '/', standaloneRole = '/standalone/roles/:namespace/:name/:tab?', + standaloneRoleImport = '/standalone/roles/import', standaloneRoles = '/standalone/roles', standaloneNamespace = '/standalone/namespaces/:namespaceid', standaloneNamespaces = '/standalone/namespaces',