diff --git a/src/api/base.ts b/src/api/base.ts index 04363cbbda..a6857d72d9 100644 --- a/src/api/base.ts +++ b/src/api/base.ts @@ -67,7 +67,7 @@ export class BaseAPI { return this.http.patch(this.getPath(apiPath) + id + '/', data); } - getPath(apiPath: string) { + getPath(apiPath?: string) { return apiPath || this.apiPath; } diff --git a/src/api/index.ts b/src/api/index.ts index d35f5b28cc..753c56372e 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -85,6 +85,7 @@ export { SettingsAPI } from './settings'; export { SignCollectionAPI } from './sign-collections'; export { SignContainersAPI } from './sign-containers'; export { SigningServiceAPI, SigningServiceType } from './signing-service'; +export { TagAPI } from './tag'; export { TaskAPI } from './task'; export { TaskManagementAPI } from './task-management'; export { UserAPI } from './user'; diff --git a/src/api/tag.ts b/src/api/tag.ts new file mode 100644 index 0000000000..6323bfc2e7 --- /dev/null +++ b/src/api/tag.ts @@ -0,0 +1,15 @@ +import { HubAPI } from './hub'; + +export class API extends HubAPI { + apiPath = this.getUIPath('tags/'); + + listCollections(params) { + return this.list(params, this.getPath() + 'collections/'); + } + + listRoles(params) { + return this.list(params, this.getPath() + 'roles/'); + } +} + +export const TagAPI = new API(); diff --git a/src/components/collection-list/collection-filter.tsx b/src/components/collection-list/collection-filter.tsx index 6cda4ace62..7136011ea5 100644 --- a/src/components/collection-list/collection-filter.tsx +++ b/src/components/collection-list/collection-filter.tsx @@ -7,9 +7,8 @@ import { } from '@patternfly/react-core'; import React from 'react'; import { useEffect, useState } from 'react'; -import { AnsibleRepositoryAPI } from 'src/api'; +import { AnsibleRepositoryAPI, TagAPI } from 'src/api'; import { AppliedFilters, CompoundFilter } from 'src/components'; -import { Constants } from 'src/constants'; import { useContext } from 'src/loaders/app-context'; import './collection-filter.scss'; @@ -28,27 +27,51 @@ interface IProps { export const CollectionFilter = (props: IProps) => { const context = useContext(); + const { ignoredParams, params, updateParams } = props; + const { display_signatures, display_repositories } = context.featureFlags; + const displayTags = ignoredParams.includes('tags') === false; + const displayRepos = + ignoredParams.includes('repository_name') === false && display_repositories; + const displayNamespaces = ignoredParams.includes('namespace') === false; + const [repositories, setRepositories] = useState([]); const [inputText, setInputText] = useState(''); const [selectedFilter, setSelectedFilter] = useState(null); + const [tags, setTags] = useState([]); const loadRepos = () => { AnsibleRepositoryAPI.list({ name__icontains: inputText, pulp_label_select: '!hide_from_search', - }).then((res) => { - const repos = res.data.results.map(({ name }) => ({ - id: name, - title: name, - })); - setRepositories(repos); - }); + }).then(({ data: { results } }) => + setRepositories( + results.map(({ name }) => ({ + id: name, + title: name, + })), + ), + ); + }; + + const loadTags = () => { + TagAPI.listCollections({ name__icontains: inputText, sort: '-count' }).then( + ({ data: { data } }) => + setTags( + data.map(({ name, count }) => ({ + id: name, + title: count === undefined ? name : t`${name} (${count})`, + })), + ), + ); }; useEffect(() => { if (selectedFilter === 'repository_name') { loadRepos(); } + if (selectedFilter === 'tags' && displayTags) { + loadTags(); + } }, [selectedFilter]); useEffect(() => { @@ -65,12 +88,11 @@ export const CollectionFilter = (props: IProps) => { } }, [inputText]); - const { ignoredParams, params, updateParams } = props; - const { display_signatures, display_repositories } = context.featureFlags; - const displayTags = ignoredParams.includes('tags') === false; - const displayRepos = - ignoredParams.includes('repository_name') === false && display_repositories; - const displayNamespaces = ignoredParams.includes('namespace') === false; + useEffect(() => { + if (inputText != '' && selectedFilter === 'tags' && displayTags) { + loadTags(); + } + }, [inputText]); const filterConfig = [ { @@ -90,11 +112,8 @@ export const CollectionFilter = (props: IProps) => { displayTags && { id: 'tags', title: t`Tag`, - inputType: 'multiple' as const, - options: Constants.COLLECTION_FILTER_TAGS.map((tag) => ({ - id: tag, - title: tag, - })), + inputType: 'typeahead' as const, + options: tags, }, display_signatures && { id: 'is_signed', @@ -118,9 +137,7 @@ export const CollectionFilter = (props: IProps) => { updateParams={updateParams} params={params} filterConfig={filterConfig} - selectFilter={(selected) => { - setSelectedFilter(selected); - }} + selectFilter={setSelectedFilter} /> Promise<{ id: string; title: string }[]> + >; updateParams: (p) => void; } +function useTypeaheads(typeaheads, { inputText, selectedFilter }) { + const [options, setOptions] = useState({}); + const loader = typeaheads[selectedFilter]; + const setter = (value) => + setOptions((options) => ({ ...options, [selectedFilter]: value })); + + useEffect(() => { + if (selectedFilter && loader) { + loader('').then(setter); + } + }, [selectedFilter]); + + useEffect(() => { + if (inputText && loader) { + loader(inputText).then(setter); + } + }, [inputText]); + + return options; +} + // FIXME: missing Buttons & CardListSwitcher to be usable everywhere export function HubListToolbar({ + count, + filterConfig, ignoredParams, params, - updateParams, - filterConfig, sortOptions, - count, + typeaheads, + updateParams, }: IProps) { const [inputText, setInputText] = useState(''); + const [selectedFilter, setSelectedFilter] = useState(null); + const typeaheadOptions = useTypeaheads(typeaheads || {}, { + inputText, + selectedFilter, + }); const niceNames = Object.fromEntries( filterConfig.map(({ id, title }) => [id, title]), ); + const filterWithOptions = filterConfig.map((item) => + item.inputType !== 'typeahead' + ? item + : { ...item, options: item.options || typeaheadOptions[item.id] || [] }, + ); + return ( @@ -51,10 +88,11 @@ export function HubListToolbar({ > diff --git a/src/constants.tsx b/src/constants.tsx index 4a1e9dd7ca..6ee4b5ed40 100644 --- a/src/constants.tsx +++ b/src/constants.tsx @@ -32,22 +32,6 @@ export class Constants { 'rejected', ]; - // FIXME: replace with API call or free form input - static COLLECTION_FILTER_TAGS = [ - 'application', - 'cloud', - 'database', - 'eda', - 'infrastructure', - 'linux', - 'monitoring', - 'networking', - 'security', - 'storage', - 'tools', - 'windows', - ]; - static TASK_NAMES = { 'galaxy_ng.app.tasks.curate_all_synclist_repository': msg`Curate all synclist repository`, 'galaxy_ng.app.tasks.curate_synclist_repository': msg`Curate synclist repository`, diff --git a/src/containers/ansible-role/namespace-detail.tsx b/src/containers/ansible-role/namespace-detail.tsx index 4e394edf33..1494da7e0b 100644 --- a/src/containers/ansible-role/namespace-detail.tsx +++ b/src/containers/ansible-role/namespace-detail.tsx @@ -14,6 +14,7 @@ import { LegacyNamespaceListType, LegacyRoleAPI, LegacyRoleListType, + TagAPI, } from 'src/api'; import { AlertList, @@ -119,11 +120,24 @@ class NamespaceRoles extends React.Component< ); } + loadTags(inputText) { + return TagAPI.listRoles({ name__icontains: inputText, sort: '-count' }) + .then(({ data: { data } }) => + data.map(({ name, count }) => ({ + id: name, + title: count === undefined ? name : t`${name} (${count})`, + })), + ) + .catch(() => []); + } + private get updateParams() { return ParamHelper.updateParamsMixin(); } render() { + const { count, loading, params, roles } = this.state; + const updateParams = (params) => this.updateParams(params, () => this.query(params)); @@ -135,6 +149,8 @@ class NamespaceRoles extends React.Component< { id: 'tags', title: t`Tags`, + inputType: 'typeahead' as const, + // options handled by `typeaheads` }, ]; @@ -152,8 +168,6 @@ class NamespaceRoles extends React.Component< }, ]; - const { count, loading, params, roles } = this.state; - const noData = count === 0 && !filterIsSet( @@ -178,6 +192,7 @@ class NamespaceRoles extends React.Component< ignoredParams={['page', 'page_size', 'sort']} params={params} sortOptions={sortOptions} + typeaheads={{ tags: this.loadTags }} updateParams={updateParams} /> {!count ? ( diff --git a/src/containers/ansible-role/role-list.tsx b/src/containers/ansible-role/role-list.tsx index c968774994..b073e9df76 100644 --- a/src/containers/ansible-role/role-list.tsx +++ b/src/containers/ansible-role/role-list.tsx @@ -1,7 +1,7 @@ import { t } from '@lingui/macro'; import { DataList } from '@patternfly/react-core'; import React from 'react'; -import { LegacyRoleAPI, LegacyRoleListType } from 'src/api'; +import { LegacyRoleAPI, LegacyRoleListType, TagAPI } from 'src/api'; import { AlertList, AlertType, @@ -80,6 +80,17 @@ class AnsibleRoleList extends React.Component { ); } + loadTags(inputText) { + return TagAPI.listRoles({ name__icontains: inputText, sort: '-count' }) + .then(({ data: { data } }) => + data.map(({ name, count }) => ({ + id: name, + title: count === undefined ? name : t`${name} (${count})`, + })), + ) + .catch(() => []); + } + private get updateParams() { return ParamHelper.updateParamsMixin(); } @@ -95,6 +106,8 @@ class AnsibleRoleList extends React.Component { } render() { + const { alerts, count, loading, params, roles } = this.state; + const updateParams = (params) => this.updateParams(params, () => this.query(params)); @@ -110,6 +123,8 @@ class AnsibleRoleList extends React.Component { { id: 'tags', title: t`Tags`, + inputType: 'typeahead' as const, + // options handled by `typeaheads` }, ]; @@ -127,8 +142,6 @@ class AnsibleRoleList extends React.Component { }, ]; - const { alerts, count, loading, params, roles } = this.state; - const noData = count === 0 && !filterIsSet( @@ -155,6 +168,7 @@ class AnsibleRoleList extends React.Component { ignoredParams={['page', 'page_size', 'sort']} params={params} sortOptions={sortOptions} + typeaheads={{ tags: this.loadTags }} updateParams={updateParams} />