diff --git a/frontend/Routes.ts b/frontend/Routes.ts index b36f105fa5..eb7c694368 100644 --- a/frontend/Routes.ts +++ b/frontend/Routes.ts @@ -212,7 +212,7 @@ export const RouteObj = { NamespaceDetails: `${hubRoutePrefix}/namespaces/:id`, CreateNamespace: `${hubRoutePrefix}/namespaces/create`, EditNamespace: `${hubRoutePrefix}/namespaces/:id/edit`, - + EditNamespaceMetadataDetails: `${hubRoutePrefix}/namespaces/:id/edit-details`, Approvals: `${hubRoutePrefix}/approvals`, ApprovalDetails: `${hubRoutePrefix}/approvals/details/:id`, diff --git a/frontend/hub/HubRouter.tsx b/frontend/hub/HubRouter.tsx index cc96f69e16..e42a63b80a 100644 --- a/frontend/hub/HubRouter.tsx +++ b/frontend/hub/HubRouter.tsx @@ -9,6 +9,7 @@ import { HubDashboard } from './dashboard/Dashboard'; import { ExecutionEnvironments } from './execution-environments/ExecutionEnvironments'; import { NamespaceDetails } from './namespaces/HubNamespaceDetails'; import { CreateHubNamespace, EditHubNamespace } from './namespaces/HubNamespaceForm'; +import { EditHubNamespaceMetadata } from './namespaces/HubNamespaceMetadataForm'; import { CreateRemote } from './remotes/RemoteForm'; import { Namespaces } from './namespaces/HubNamespaces'; import { RemoteRegistries } from './remote-registries/RemoteRegistries'; @@ -34,6 +35,10 @@ export function HubRouter() { } /> } /> } /> + } + /> } /> } /> diff --git a/frontend/hub/api.tsx b/frontend/hub/api.tsx new file mode 100644 index 0000000000..634021e6da --- /dev/null +++ b/frontend/hub/api.tsx @@ -0,0 +1,103 @@ +import { AutomationServerType } from '../automation-servers/AutomationServer'; +import { activeAutomationServer } from '../automation-servers/AutomationServersProvider'; + +function apiTag(strings: TemplateStringsArray, ...values: string[]) { + if (strings[0]?.[0] !== '/') { + throw new Error('Invalid URL'); + } + + let url = ''; + strings.forEach((fragment, index) => { + url += fragment; + if (index !== strings.length - 1) { + url += encodeURIComponent(`${values.shift() ?? ''}`); + } + }); + + return url; +} + +export function hubAPI(strings: TemplateStringsArray, ...values: string[]) { + let base = process.env.HUB_API_BASE_PATH; + if (!base) { + if (activeAutomationServer?.type === AutomationServerType.Galaxy) { + base = '/api/galaxy'; + } else { + base = '/api/automation-hub'; + } + } + return base + apiTag(strings, ...values); +} + +export function pulpAPI(strings: TemplateStringsArray, ...values: string[]) { + let base = process.env.HUB_API_BASE_PATH; + if (!base) { + if (activeAutomationServer?.type === AutomationServerType.Galaxy) { + base = '/api/galaxy'; + } else { + base = '/api/automation-hub'; + } + } + return base + '/pulp/api/v3' + apiTag(strings, ...values); +} + +export type QueryParams = { + [key: string]: string | undefined; +}; + +export function getQueryString(queryParams: QueryParams) { + return Object.entries(queryParams) + .map(([key, value = '']) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join('&'); +} + +const UUIDRegEx = /\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b/i; + +export function parsePulpIDFromURL(url: string): string | null { + for (const section of url.split('/')) { + if (section.match(UUIDRegEx)) { + return section; + } + } + + return null; +} + +// pulp next links currently include full url - with the wrong server +// "http://localhost:5001/api/page/next?what#ever" -> "/api/page/next?what#ever" +// also has to handle hub links (starting with /api/) and undefined +export function serverlessURL(url?: string) { + if (!url || url.startsWith('/')) { + return url; + } + + const { pathname, search, hash } = new URL(url); + return `${pathname}${search}${hash}`; +} + +export function pulpIdKeyFn(item: { pulp_id: string }) { + return item.pulp_id; +} + +export function pulpHrefKeyFn(item: { pulp_href: string }) { + return item.pulp_href; +} + +export function nameKeyFn(item: { name: string }) { + return item.name; +} + +export function idKeyFn(item: { id: number | string }) { + return item.id; +} + +export function collectionKeyFn(item: { + collection_version: { pulp_href: string }; + repository: { name: string }; +}) { + return item.collection_version.pulp_href + '_' + item.repository.name; +} + +export function appendTrailingSlash(url: string) { + return url.endsWith('/') ? url : url + '/'; +} diff --git a/frontend/hub/collections/CollectionDetails.tsx b/frontend/hub/collections/CollectionDetails.tsx index 5eb6ba7d5c..12a5b17cc2 100644 --- a/frontend/hub/collections/CollectionDetails.tsx +++ b/frontend/hub/collections/CollectionDetails.tsx @@ -52,7 +52,7 @@ import { StatusCell } from '../../common/Status'; import { useGet } from '../../common/crud/useGet'; import { hubAPI } from '../api/utils'; import { HubItemsResponse } from '../useHubView'; -import { CollectionVersionSearch } from './Collection'; +import { Collection } from './Collection'; import { useCollectionActions } from './hooks/useCollectionActions'; import { useCollectionColumns } from './hooks/useCollectionColumns'; @@ -64,7 +64,7 @@ export function CollectionDetails() { params.namespace || '' }&repository=${params.repository || ''} }` ); - let collection: CollectionVersionSearch | undefined = undefined; + let collection: Collection | undefined = undefined; if (data && data.data && data.data.length > 0) { collection = data.data[0]; } @@ -72,10 +72,10 @@ export function CollectionDetails() { return ( @@ -109,7 +109,7 @@ export function CollectionDetails() { ); } -function CollectionDetailsTab(props: { collection?: CollectionVersionSearch }) { +function CollectionDetailsTab(props: { collection?: Collection }) { const { collection } = props; const tableColumns = useCollectionColumns(); return ; diff --git a/frontend/hub/collections/hooks/useCollectionColumns.tsx b/frontend/hub/collections/hooks/useCollectionColumns.tsx index 11e8139301..e8d2da02bd 100644 --- a/frontend/hub/collections/hooks/useCollectionColumns.tsx +++ b/frontend/hub/collections/hooks/useCollectionColumns.tsx @@ -8,27 +8,19 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { ITableColumn, TextCell } from '../../../../framework'; import { RouteObj } from '../../../Routes'; -import { CollectionVersionSearch } from '../Collection'; +import { Collection } from '../Collection'; export function useCollectionColumns(_options?: { disableSort?: boolean; disableLinks?: boolean }) { const { t } = useTranslation(); - return useMemo[]>( + return useMemo[]>( () => [ { header: t('Name'), - value: (collection) => collection.collection_version.name, + value: (collection) => collection.name, cell: (collection) => ( ), card: 'name', @@ -44,13 +36,12 @@ export function useCollectionColumns(_options?: { disableSort?: boolean; disable { header: t('Namespace'), type: 'text', - value: (collection) => collection.collection_version.namespace, - sort: 'namespace', + value: (collection) => collection.namespace.name, }, { header: t('Description'), type: 'description', - value: (collection) => collection.collection_version.description, + value: (collection) => collection.latest_version.metadata.description, card: 'description', list: 'description', }, @@ -58,19 +49,39 @@ export function useCollectionColumns(_options?: { disableSort?: boolean; disable header: t('Modules'), type: 'count', value: (collection) => - collection.collection_version.contents.filter((c) => c.content_type === 'module').length, + collection.latest_version.metadata.contents.filter((c) => c.content_type === 'module') + .length, + }, + { + header: t('Roles'), + type: 'count', + value: (collection) => + collection.latest_version.metadata.contents.filter((c) => c.content_type === 'TODO') + .length, + }, + { + header: t('Plugins'), + type: 'count', + value: (collection) => + collection.latest_version.metadata.contents.filter((c) => c.content_type === 'TODO') + .length, + }, + { + header: t('Dependencies'), + type: 'count', + value: (collection) => Object.keys(collection.latest_version.metadata.dependencies).length, }, { header: t('Updated'), type: 'datetime', - value: (collection) => collection.collection_version.pulp_created, + value: (collection) => collection.latest_version.created_at, card: 'hidden', list: 'secondary', }, { header: t('Version'), type: 'text', - value: (collection) => collection.collection_version.version, + value: (collection) => collection.latest_version.version, card: 'hidden', list: 'secondary', sort: 'version', @@ -78,14 +89,14 @@ export function useCollectionColumns(_options?: { disableSort?: boolean; disable { header: t('Signed state'), cell: (collection) => { - switch (collection.is_signed) { - case true: + switch (collection.latest_version.sign_state) { + case 'signed': return ( ); - case false: + case 'unsigned': return ( ); } -function HubNamespaceInputs() { +function HubNamespaceInputs(props: { isReadOnly?: boolean }) { const { t } = useTranslation(); + const { isReadOnly } = props; return ( <> @@ -97,21 +105,7 @@ function HubNamespaceInputs() { label={t('Name')} placeholder={t('Enter name')} isRequired - /> - - name="description" - label={t('Description')} - placeholder={t('Enter description')} - /> - - name="company" - label={t('Company')} - placeholder={t('Enter company')} - /> - - name="avatar_url" - label={t('Logo URL')} - placeholder={t('Enter logo URL')} + isReadOnly={isReadOnly} /> ); diff --git a/frontend/hub/namespaces/HubNamespaceMetadataForm.tsx b/frontend/hub/namespaces/HubNamespaceMetadataForm.tsx new file mode 100644 index 0000000000..1db3574ef9 --- /dev/null +++ b/frontend/hub/namespaces/HubNamespaceMetadataForm.tsx @@ -0,0 +1,94 @@ +import { useTranslation } from 'react-i18next'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + PageForm, + PageFormSubmitHandler, + PageFormTextInput, + PageHeader, + PageLayout, +} from '../../../framework'; +import { RouteObj } from '../../Routes'; +import { useGet } from '../../common/crud/useGet'; +import { usePatchRequest } from '../../common/crud/usePatchRequest'; +import { HubNamespaceMetadataType } from './HubNamespaceMetadataType'; +import { hubAPI } from '../api'; +import { ItemsResponse } from '../../common/crud/Data'; + +export function EditHubNamespaceMetadata() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const params = useParams<{ id?: string }>(); + const name = params.id; + const { data: namespacesResponse } = useGet>( + hubAPI`/v3/plugin/ansible/search/namespace-metadata/?name=${name ?? ''}` + ); + const patchRequest = usePatchRequest(); + const onSubmit: PageFormSubmitHandler = async (namespace) => { + await patchRequest(hubAPI`/v3/plugin/ansible/search/namespace-metadata/`, namespace); + navigate(-1); + }; + if (!namespacesResponse || namespacesResponse.results.length === 0) { + return ( + + + + ); + } + return ( + + + + + submitText={t('Save namespace')} + onSubmit={onSubmit} + onCancel={() => navigate(-1)} + defaultValue={namespacesResponse.results[0]} + > + + + + ); +} + +function HubNamespaceMetadataInputs() { + const { t } = useTranslation(); + return ( + <> + + name="metadata.name" + label={t('Name')} + placeholder={t('Enter name')} + isRequired + isReadOnly + /> + + name="metadata.description" + label={t('Description')} + placeholder={t('Enter description')} + /> + + name="metadata.company" + label={t('Company')} + placeholder={t('Enter company')} + /> + + name="metadata.avatar_url" + label={t('Logo')} + placeholder={t('Enter logo url')} + /> + + ); +} diff --git a/frontend/hub/namespaces/HubNamespaceMetadataType.tsx b/frontend/hub/namespaces/HubNamespaceMetadataType.tsx new file mode 100644 index 0000000000..c83120eac9 --- /dev/null +++ b/frontend/hub/namespaces/HubNamespaceMetadataType.tsx @@ -0,0 +1,33 @@ +export interface Metadata { + pulp_href: string; + name: string; + company?: string; + email?: string; + description?: string; + resources?: string; + links: []; + avatar_sha256: null | string; + avatar_url: null | string; + metadata_sha256: string; + groups: []; + task: string | null; +} + +export interface Repository { + pulp_href: string; + pulp_created: string; + versions_href: string; + pulp_labels: { pipeline: string }; + latest_version_href: string; + name: string; + description: string; + retain_repo_versions: number; + remote: null | string; +} + +export interface HubNamespaceMetadataType { + metadata: Metadata; + repository: Repository; + in_latest_repo_version: boolean; + in_old_repo_version: boolean; +} diff --git a/frontend/hub/namespaces/HubNamespaces.tsx b/frontend/hub/namespaces/HubNamespaces.tsx index 05d1bbbb96..88503be640 100644 --- a/frontend/hub/namespaces/HubNamespaces.tsx +++ b/frontend/hub/namespaces/HubNamespaces.tsx @@ -2,17 +2,18 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { PageHeader, PageLayout, PageTab, PageTable, PageTabs } from '../../../framework'; import { RouteObj } from '../../Routes'; -import { useHubView } from '../useHubView'; +import { usePulpView } from '../usePulpView'; import { HubNamespace } from './HubNamespace'; import { useHubNamespaceActions } from './hooks/useHubNamespaceActions'; import { useHubNamespaceFilters } from './hooks/useHubNamespaceFilters'; import { useHubNamespaceToolbarActions } from './hooks/useHubNamespaceToolbarActions'; import { useHubNamespacesColumns } from './hooks/useHubNamespacesColumns'; -import { hubAPI } from '../api/utils'; -import { idKeyFn } from '../../common/utils/nameKeyFn'; +import { nameKeyFn } from '../../common/utils/nameKeyFn'; +import { pulpAPI } from '../api/utils'; export function Namespaces() { const { t } = useTranslation(); + return ( ; + return ; } export function MyNamespaces() { - return ; + return ( + + ); } -export function CommonNamespaces({ url }: { url: string }) { +export function CommonNamespaces({ + url, + queryParams, +}: { + url: string; + queryParams: + | { + [key: string]: string; + } + | undefined; +}) { const { t } = useTranslation(); const navigate = useNavigate(); const toolbarFilters = useHubNamespaceFilters(); const tableColumns = useHubNamespacesColumns(); const toolbarActions = useHubNamespaceToolbarActions(); const rowActions = useHubNamespaceActions(); - const view = useHubView({ url, keyFn: idKeyFn, toolbarFilters, tableColumns }); + const view = usePulpView({ + url, + keyFn: nameKeyFn, + toolbarFilters, + tableColumns, + queryParams, + }); return ( toolbarFilters={toolbarFilters} diff --git a/frontend/hub/namespaces/hooks/useDeleteHubNamespaces.tsx b/frontend/hub/namespaces/hooks/useDeleteHubNamespaces.tsx index 1598d34ce7..86b9f8483b 100644 --- a/frontend/hub/namespaces/hooks/useDeleteHubNamespaces.tsx +++ b/frontend/hub/namespaces/hooks/useDeleteHubNamespaces.tsx @@ -3,7 +3,7 @@ import { compareStrings, useBulkConfirmation } from '../../../../framework'; import { requestDelete } from '../../../common/crud/Data'; import { HubNamespace } from '../HubNamespace'; import { useHubNamespacesColumns } from './useHubNamespacesColumns'; -import { hubAPI } from '../../api/utils'; +import { pulpAPI } from '../../api/utils'; import { nameKeyFn } from '../../../common/utils/nameKeyFn'; export function useDeleteHubNamespaces(onComplete: (namespaces: HubNamespace[]) => void) { @@ -26,7 +26,7 @@ export function useDeleteHubNamespaces(onComplete: (namespaces: HubNamespace[]) onComplete, alertPrompts: [t('Deleting a namespace will delete all collections in the namespace.')], actionFn: (namespace: HubNamespace) => - requestDelete(hubAPI`/_ui/v1/namespaces/${namespace.name}/`), + requestDelete(pulpAPI`/pulp_ansible/namespaces/${namespace.name}`), }); }; return deleteHubNamespaces; diff --git a/frontend/hub/namespaces/hooks/useHubNamespaceDetailsFilters.tsx b/frontend/hub/namespaces/hooks/useHubNamespaceDetailsFilters.tsx new file mode 100644 index 0000000000..8f834b7cb1 --- /dev/null +++ b/frontend/hub/namespaces/hooks/useHubNamespaceDetailsFilters.tsx @@ -0,0 +1,20 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { IToolbarFilter, ToolbarFilterType } from '../../../../framework'; + +export function useHubNamespaceDetailsFilters() { + const { t } = useTranslation(); + const toolbarFilters = useMemo( + () => [ + { + key: 'repository_name__icontains', + label: t('Repository'), + type: ToolbarFilterType.Text, + query: 'repository_name__icontains', + comparison: 'contains', + }, + ], + [t] + ); + return toolbarFilters; +} diff --git a/frontend/hub/namespaces/hooks/useHubNamespaceDetailsToolbarActions.tsx b/frontend/hub/namespaces/hooks/useHubNamespaceDetailsToolbarActions.tsx new file mode 100644 index 0000000000..ee15a96a2e --- /dev/null +++ b/frontend/hub/namespaces/hooks/useHubNamespaceDetailsToolbarActions.tsx @@ -0,0 +1,25 @@ +import { TrashIcon } from '@patternfly/react-icons'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { IPageAction, PageActionSelection, PageActionType } from '../../../../framework'; +import { HubNamespaceMetadataType } from '../HubNamespaceMetadataType'; +import { useDeleteHubNamespaces } from './useDeleteHubNamespaces'; + +export function useHubNamespaceDetailsToolbarActions() { + const { t } = useTranslation(); + const deleteHubNamespaces = useDeleteHubNamespaces(() => null); + + return useMemo[]>( + () => [ + { + type: PageActionType.Button, + selection: PageActionSelection.Multiple, + icon: TrashIcon, + label: t('Delete selected namesapces'), + onClick: deleteHubNamespaces, + isDanger: true, + }, + ], + [deleteHubNamespaces, t] + ); +} diff --git a/frontend/hub/namespaces/hooks/useHubNamespaceFilters.tsx b/frontend/hub/namespaces/hooks/useHubNamespaceFilters.tsx index 6ed6c1be6e..8c6d594e14 100644 --- a/frontend/hub/namespaces/hooks/useHubNamespaceFilters.tsx +++ b/frontend/hub/namespaces/hooks/useHubNamespaceFilters.tsx @@ -7,10 +7,10 @@ export function useHubNamespaceFilters() { const toolbarFilters = useMemo( () => [ { - key: 'keywords', + key: 'name__icontains', label: t('Name'), type: ToolbarFilterType.Text, - query: 'keywords', + query: 'name__icontains', comparison: 'contains', }, ], diff --git a/frontend/hub/namespaces/hooks/useHubNamespaceMetadataActions.tsx b/frontend/hub/namespaces/hooks/useHubNamespaceMetadataActions.tsx new file mode 100644 index 0000000000..6a2c5ada2c --- /dev/null +++ b/frontend/hub/namespaces/hooks/useHubNamespaceMetadataActions.tsx @@ -0,0 +1,40 @@ +import { ButtonVariant } from '@patternfly/react-core'; +import { EditIcon, TrashIcon } from '@patternfly/react-icons'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { IPageAction, PageActionSelection, PageActionType } from '../../../../framework'; +import { RouteObj } from '../../../Routes'; +import { HubNamespaceMetadataType } from '../HubNamespaceMetadataType'; +import { useDeleteHubNamespaces } from './useDeleteHubNamespaces'; + +export function useHubNamespaceMetadataActions() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const deleteHubNamespaces = useDeleteHubNamespaces(() => null); + return useMemo(() => { + const actions: IPageAction[] = [ + { + type: PageActionType.Button, + selection: PageActionSelection.Single, + variant: ButtonVariant.primary, + isPinned: true, + icon: EditIcon, + label: t('Edit namespace details'), + onClick: (namespace) => { + navigate(RouteObj.EditNamespaceDetails.replace(':id', namespace.metadata.name)); + }, + }, + { type: PageActionType.Seperator }, + { + type: PageActionType.Button, + selection: PageActionSelection.Single, + icon: TrashIcon, + label: t('Delete namespace'), + onClick: (namespace) => deleteHubNamespaces([namespace]), + isDanger: true, + }, + ]; + return actions; + }, [deleteHubNamespaces, navigate, t]); +} diff --git a/frontend/hub/namespaces/hooks/useHubNamespaceMetadataColumns.tsx b/frontend/hub/namespaces/hooks/useHubNamespaceMetadataColumns.tsx new file mode 100644 index 0000000000..ce375da5c2 --- /dev/null +++ b/frontend/hub/namespaces/hooks/useHubNamespaceMetadataColumns.tsx @@ -0,0 +1,51 @@ +import { RedhatIcon } from '@patternfly/react-icons'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ITableColumn, TextCell } from '../../../../framework'; +// routeobj will be used to direct to the edit ns metadata form. +// import { RouteObj } from '../../../Routes'; +import { HubNamespaceMetadataType } from '../HubNamespaceMetadataType'; +export function useHubNamespaceMetadataColumns(_options?: { + disableSort?: boolean; + disableLinks?: boolean; +}) { + const { t } = useTranslation(); + const tableColumns = useMemo[]>( + () => [ + { + header: t('Name'), + cell: (namespace) => ( + + ), + value: (namespace) => namespace.metadata.name, + sort: 'name', + card: 'name', + list: 'name', + icon: () => , + }, + { + header: t('Description'), + type: 'description', + value: (namespace) => namespace.metadata.description ?? undefined, + card: 'description', + list: 'description', + }, + { + header: t('Repository'), + type: 'text', + value: (namespace) => namespace.repository.name ?? undefined, + }, + { + header: t('Links'), + type: 'text', + value: (namespace) => namespace.metadata.links ?? undefined, + }, + ], + [t] + ); + return tableColumns; +} diff --git a/frontend/hub/namespaces/hooks/useHubNamespaces.tsx b/frontend/hub/namespaces/hooks/useHubNamespaces.tsx index d50f5ec6a5..8acbe279e6 100644 --- a/frontend/hub/namespaces/hooks/useHubNamespaces.tsx +++ b/frontend/hub/namespaces/hooks/useHubNamespaces.tsx @@ -1,9 +1,9 @@ import { useGet } from '../../../common/crud/useGet'; -import { hubAPI } from '../../api/utils'; import { HubItemsResponse } from '../../useHubView'; import { HubNamespace } from '../HubNamespace'; +import { pulpAPI } from '../../api/utils'; export function useHubNamespaces() { - const t = useGet>(hubAPI`/_ui/v1/namespaces/`); - return t.data?.data; + const t = useGet>(pulpAPI`/pulp_ansible/namespaces/`); + return t.data?.results; } diff --git a/frontend/hub/useHubView.tsx b/frontend/hub/useHubView.tsx index c6f792b071..c3dc078a92 100644 --- a/frontend/hub/useHubView.tsx +++ b/frontend/hub/useHubView.tsx @@ -22,6 +22,18 @@ export interface HubItemsResponse { }; } +export interface HubItemsResponse2 { + count: number; + next?: string | null; + previous?: string | null; + results: T[]; +} + +export interface HubNamespaceResponse { + count: number; + results: T[]; +} + export type IHubView = IView & ISelected & { itemCount: number | undefined; @@ -148,22 +160,3 @@ export function useHubView({ }; }, [data?.data, error, refresh, selection, unselectItemsAndRefresh, view]); } - -export async function getAwxError(err: unknown) { - if (err instanceof HTTPError) { - try { - const response = (await err.response.json()) as { __all__?: string[] }; - if ('__all__' in response && Array.isArray(response.__all__)) { - return JSON.stringify(response.__all__[0]); - } else { - return JSON.stringify(response); - } - } catch { - return err.message; - } - } else if (err instanceof Error) { - return err.message; - } else { - return 'unknown error'; - } -} diff --git a/frontend/hub/usePulpSearchView.tsx b/frontend/hub/usePulpSearchView.tsx new file mode 100644 index 0000000000..dd5b6e5162 --- /dev/null +++ b/frontend/hub/usePulpSearchView.tsx @@ -0,0 +1,145 @@ +import { HTTPError } from 'ky'; +import { useCallback, useMemo, useRef } from 'react'; +import useSWR from 'swr'; +import { + ISelected, + ITableColumn, + IToolbarFilter, + IView, + useSelected, + useView, +} from '../../framework'; +import { useFetcher } from '../common/crud/Data'; +import { QueryParams, getQueryString, serverlessURL } from './api'; + +export interface PulpSearchItemsResponse { + count: number; + next?: string | null; + previous?: string | null; + results: T[]; +} + +export type IHubView = IView & + ISelected & { + itemCount: number | undefined; + pageItems: T[] | undefined; + refresh: () => Promise | undefined>; + unselectItemsAndRefresh: (items: T[]) => void; + }; + +export function usePulpSearchView({ + url, + keyFn, + toolbarFilters, + tableColumns, + disableQueryString, + queryParams, + sortKey, + defaultFilters, +}: { + url: string; + keyFn: (item: T) => string | number; + toolbarFilters?: IToolbarFilter[]; + tableColumns?: ITableColumn[]; + disableQueryString?: boolean; + queryParams?: QueryParams; + sortKey?: string; + defaultFilters?: Record; +}): IHubView { + const view = useView({ + defaultValues: { + sort: tableColumns && tableColumns.length ? tableColumns[0].sort : undefined, + filterState: defaultFilters, + }, + disableQueryString, + }); + const itemCountRef = useRef<{ itemCount: number | undefined }>({ itemCount: undefined }); + + const { page, perPage, sort, sortDirection, filterState } = view; + + let queryString = queryParams ? `?${getQueryString(queryParams)}` : ''; + + if (filterState) { + for (const key in filterState) { + const toolbarFilter = toolbarFilters?.find((filter) => filter.key === key); + if (toolbarFilter) { + const values = filterState[key]; + if (values.length > 0) { + queryString ? (queryString += '&') : (queryString += '?'); + if (values.length > 1) { + queryString += values.map((value) => `or__${toolbarFilter.query}=${value}`).join('&'); + } else { + queryString += `${toolbarFilter.query}=${values.join(',')}`; + } + } + } + } + } + + if (sort) { + if (!sortKey) { + sortKey = 'sort'; + } + queryString ? (queryString += '&') : (queryString += '?'); + if (sortDirection === 'desc') { + queryString += `${sortKey}=-${sort}`; + } else { + queryString += `${sortKey}=${sort}`; + } + } + + queryString ? (queryString += '&') : (queryString += '?'); + queryString += `offset=${(page - 1) * perPage}`; + + queryString ? (queryString += '&') : (queryString += '?'); + queryString += `limit=${perPage}`; + + url += queryString; + const fetcher = useFetcher(); + const response = useSWR>(url, fetcher, { + dedupingInterval: 0, + refreshInterval: 30000, + }); + const { data, mutate } = response; + const refresh = useCallback(() => mutate(), [mutate]); + + const nextPage = serverlessURL(data?.next); + useSWR>(nextPage, fetcher, { + dedupingInterval: 0, + }); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + let error: Error | undefined = response.error; + if (error instanceof HTTPError) { + if (error.response.status === 404 && view.page > 1) { + view.setPage(1); + error = undefined; + } + } + + const selection = useSelected(data?.results ?? [], keyFn); + + if (data?.count !== undefined) { + itemCountRef.current.itemCount = data?.count; + } + + const unselectItemsAndRefresh = useCallback( + (items: T[]) => { + selection.unselectItems(items); + void refresh(); + }, + [refresh, selection] + ); + + return useMemo(() => { + return { + refresh, + itemCount: itemCountRef.current.itemCount, + pageItems: data?.results, + error, + ...view, + ...selection, + unselectItemsAndRefresh, + }; + }, [data?.results, error, refresh, selection, unselectItemsAndRefresh, view]); +}