diff --git a/src/ServiceRegistry/components/ServiceRegistryTableView/ServiceRegistryTableView.tsx b/src/ServiceRegistry/components/ServiceRegistryTableView/ServiceRegistryTableView.tsx index 935e3bf8..c5896d70 100644 --- a/src/ServiceRegistry/components/ServiceRegistryTableView/ServiceRegistryTableView.tsx +++ b/src/ServiceRegistry/components/ServiceRegistryTableView/ServiceRegistryTableView.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { @@ -16,12 +16,7 @@ import { PaginationVariant, } from '@patternfly/react-core'; import { Registry, RegistryStatusValue } from '@rhoas/registry-management-sdk'; -import { - useBasename, - useAlert, - AlertVariant, - useAuth, -} from '@rhoas/app-services-ui-shared'; +import { useBasename, useAuth } from '@rhoas/app-services-ui-shared'; import { getFormattedDate, InstanceType } from '@app/utils'; import { MASEmptyState, @@ -37,6 +32,7 @@ import { } from './ServiceRegistryToolbar'; import { add } from 'date-fns'; import { FormatDate } from '@rhoas/app-services-ui-components'; +import { useServiceRegistryStatusAlerts } from './useRegistryStatusAlert'; export type ServiceRegistryTableViewProps = ServiceRegistryToolbarProps & { serviceRegistryItems: Registry[]; @@ -61,23 +57,23 @@ const ServiceRegistryTableView: React.FC = ({ setOrderBy, isDrawerOpen, loggedInUser, - currentUserRegistries, total = 0, page, perPage, handleCreateModal, }) => { - const { addAlert } = useAlert() || {}; const { getBasename } = useBasename() || {}; const basename = getBasename && getBasename(); const { t } = useTranslation(['service-registry', 'common']); const auth = useAuth(); const [activeRow, setActiveRow] = useState(); - const [deletedRegistries, setDeletedRegistries] = useState([]); - const [instances, setInstances] = useState>([]); const [isOrgAdmin, setIsOrgAdmin] = useState(); + useServiceRegistryStatusAlerts( + serviceRegistryItems?.filter((i) => i.owner === loggedInUser) + ); + const tableColumns = [ { title: t('common:name') }, { title: t('common:owner') }, @@ -95,141 +91,6 @@ const ServiceRegistryTableView: React.FC = ({ auth?.isOrgAdmin()?.then((isAdmin) => setIsOrgAdmin(isAdmin)); }, [auth]); - const removeRegistryFromList = useCallback( - (name: string) => { - const index = deletedRegistries.findIndex((r) => r === name); - if (index > -1) { - const newDeletedRegistries = Object.assign([], deletedRegistries); - newDeletedRegistries.splice(index, 1); - setDeletedRegistries(newDeletedRegistries); - } - }, - [deletedRegistries] - ); - - const addAlertAfterSuccessDeletion = useCallback(() => { - if (currentUserRegistries) { - // filter all registry with status as deprovision or deleting - const deprovisonedRegistries: Registry[] = currentUserRegistries.filter( - (r) => - r.status === RegistryStatusValue.Deprovision || - r.status === RegistryStatusValue.Deleting - ); - - // filter all new registry which is not in deleteRegistry state - const notPresentRegistries = deprovisonedRegistries - .filter((r) => deletedRegistries.findIndex((dr) => dr === r.name) < 0) - .map((r) => r.name || ''); - - // create new array by merging old and new registry with status as deprovion - const allDeletedRegistries: string[] = [ - ...deletedRegistries, - ...notPresentRegistries, - ]; - setDeletedRegistries(allDeletedRegistries); - - // add alert for deleted registry which are completely deleted from the response - allDeletedRegistries.forEach((registryName) => { - const registryIndex = currentUserRegistries?.findIndex( - (item) => item.name === registryName - ); - if (registryIndex < 0) { - removeRegistryFromList(registryName); - addAlert && - addAlert({ - title: t('service_registry_successfully_deleted', { - name: registryName, - }), - variant: AlertVariant.success, - }); - } - }); - } - }, [ - addAlert, - currentUserRegistries, - deletedRegistries, - removeRegistryFromList, - t, - ]); - - const addAlertAfterSuccessCreation = useCallback(() => { - const lastItemsState: Registry[] = JSON.parse(JSON.stringify(instances)); - if (instances && instances.length > 0) { - const completedOrFailedItems = Object.assign( - [], - serviceRegistryItems - ).filter( - (item: Registry) => - item.status === RegistryStatusValue.Ready || - item.status === RegistryStatusValue.Failed - ); - lastItemsState.forEach((item: Registry) => { - const filteredInstances: Registry[] = completedOrFailedItems.filter( - (cfItem: Registry) => item.id === cfItem.id - ); - if (filteredInstances && filteredInstances.length > 0) { - const { status, name } = filteredInstances[0]; - - if (status === RegistryStatusValue.Ready) { - addAlert && - addAlert({ - title: t('registry_successfully_created'), - variant: AlertVariant.success, - description: ( - - ), - dataTestId: 'toastCreateRegistry-success', - }); - } else if (status === RegistryStatusValue.Failed) { - addAlert && - addAlert({ - title: t('registry_not_created'), - variant: AlertVariant.danger, - description: ( - - ), - dataTestId: 'toastCreateRegistry-failed', - }); - } - } - }); - } - - const incompleteRegistry = Object.assign( - [], - serviceRegistryItems?.filter( - (r: Registry) => - r.status === RegistryStatusValue.Provisioning || - r.status === RegistryStatusValue.Accepted - ) - ); - setInstances(incompleteRegistry); - }, [addAlert, instances, serviceRegistryItems, t]); - - useEffect(() => { - // handle success alert for deletion - addAlertAfterSuccessDeletion(); - - // handle success alert for creation - addAlertAfterSuccessCreation(); - }, [ - page, - perPage, - serviceRegistryItems, - currentUserRegistries, - addAlertAfterSuccessDeletion, - addAlertAfterSuccessCreation, - ]); - const renderNameLink = (name: string, row: IRowData) => { return ( = ({ page={page} perPage={perPage} titles={{ - paginationTitle: t('full_pagination'), - perPageSuffix: t('per_page_suffix'), - toFirstPage: t('to_first_page'), - toPreviousPage: t('to_previous_page'), - toLastPage: t('to_last_page'), - toNextPage: t('to_next_page'), - optionsToggle: t('options_toggle'), - currPage: t('curr_page'), + paginationTitle: t('common:full_pagination'), + perPageSuffix: t('common:per_page_suffix'), + toFirstPage: t('common:to_first_page'), + toPreviousPage: t('common:to_previous_page'), + toLastPage: t('common:to_last_page'), + toNextPage: t('common:to_next_page'), + optionsToggle: t('common:options_toggle'), + currPage: t('common:curr_page'), }} /> )} diff --git a/src/ServiceRegistry/components/ServiceRegistryTableView/ServiceRegistryToolbar.tsx b/src/ServiceRegistry/components/ServiceRegistryTableView/ServiceRegistryToolbar.tsx index 52061f6d..ca2431e0 100644 --- a/src/ServiceRegistry/components/ServiceRegistryTableView/ServiceRegistryToolbar.tsx +++ b/src/ServiceRegistry/components/ServiceRegistryTableView/ServiceRegistryToolbar.tsx @@ -46,14 +46,14 @@ const ServiceRegistryToolbar: FC = ({ perPage={perPage} isCompact={true} titles={{ - paginationTitle: t('minimal_pagination'), - perPageSuffix: t('per_page_suffix'), - toFirstPage: t('to_first_page'), - toPreviousPage: t('to_previous_page'), - toLastPage: t('to_last_page'), - toNextPage: t('to_next_page'), - optionsToggle: t('options_toggle'), - currPage: t('curr_page'), + paginationTitle: t('common:minimal_pagination'), + perPageSuffix: t('common:per_page_suffix'), + toFirstPage: t('common:to_first_page'), + toPreviousPage: t('common:to_previous_page'), + toLastPage: t('common:to_last_page'), + toNextPage: t('common:to_next_page'), + optionsToggle: t('common:options_toggle'), + currPage: t('common:curr_page'), }} /> ), diff --git a/src/ServiceRegistry/components/ServiceRegistryTableView/useRegistryStatusAlert.tsx b/src/ServiceRegistry/components/ServiceRegistryTableView/useRegistryStatusAlert.tsx new file mode 100644 index 00000000..9f87dbec --- /dev/null +++ b/src/ServiceRegistry/components/ServiceRegistryTableView/useRegistryStatusAlert.tsx @@ -0,0 +1,200 @@ +import { useRef, useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAlert } from '@rhoas/app-services-ui-shared'; +import { AlertVariant } from '@patternfly/react-core'; +import { Registry, RegistryStatusValue } from '@rhoas/registry-management-sdk'; +import { usePageVisibility, useInterval } from '@app/hooks'; + +type AlertableInstance = { name: string; status: RegistryStatusValue }; + +export function useServiceRegistryStatusAlerts( + instances: Registry[] | undefined +): void { + const { t } = useTranslation(['service-registry']); + const { addAlert } = useAlert() || {}; + const { isVisible } = usePageVisibility(); + const previousInstancesRef = useRef(); + const instancesBeingDeletedRef = useRef([]); + const toNotifyRef = useRef([]); + + const notifyReady = useCallback( + (name: string) => { + addAlert({ + title: t('registry_successfully_created'), + variant: AlertVariant.success, + description: ( + + ), + dataTestId: 'toastCreateRegistry-success', + }); + }, + [addAlert, t] + ); + + const notifyDelete = useCallback( + (name: string) => { + addAlert({ + title: t('service_registry_successfully_deleted', { + name, + }), + variant: AlertVariant.success, + }); + }, + [addAlert, t] + ); + + const notifyFailure = useCallback( + (name: string) => { + addAlert({ + title: t('registry_not_created'), + variant: AlertVariant.danger, + description: ( + + ), + dataTestId: 'toastCreateRegistry-failed', + }); + }, + [addAlert, t] + ); + + /** + * Derive from the updated instances which instances have changed state from + * the previous run + */ + useEffect( + function checkForRegistryStatusValueChange() { + if (instances) { + const firstData = previousInstancesRef.current === undefined; + const previousInstances = previousInstancesRef.current || []; + + const previousIdsAndStates = previousInstances.map( + (i) => `${i.id}:${i.status}` + ); + const currentIdsAndStates = instances.map((i) => `${i.id}:${i.status}`); + + // Check for changes between polled data in an unefficent but effective way. + // We don't stringify the whole Registry object since it's massive and + // we care only about an instance id and its status. + if ( + JSON.stringify(previousIdsAndStates) !== + JSON.stringify(currentIdsAndStates) + ) { + // an helper function to get the instances that changed state, but only + // if we got at least one snapshot of the data. We don't want to notify + // again for instances already created. + const filterInstances = ( + instances: Registry[], + desiredStatus: RegistryStatusValue + ) => { + return firstData + ? [] + : instances.filter( + (i) => + i.status === desiredStatus && + !previousInstances.find( + (pi) => pi.id === i.id && i.status === desiredStatus + ) + ); + }; + + // get newly created and failed instances + const ready = filterInstances(instances, RegistryStatusValue.Ready); + const failed = filterInstances(instances, RegistryStatusValue.Failed); + + // since it's possible that an instance that is being deleted will + // simply not be returned the next time we poll for data, we keep track + // of instances that are deprovisoning in a ref. We check if these + // instances are still in the current list of instances. If not, they + // have been deleted and we should notify the user. The others, we keep + // them in the ref + const [deleted, stillBeingDeleted] = + instancesBeingDeletedRef.current.reduce<[Registry[], Registry[]]>( + ([deleted, beingDeleted], instanceBeingDeleted) => { + if ( + instances.find((i) => i.id === instanceBeingDeleted.id) === + undefined + ) { + // this instance has been deleted + return [[...deleted, instanceBeingDeleted], beingDeleted]; + } else { + return [deleted, [...beingDeleted, instanceBeingDeleted]]; + } + }, + [[], []] + ); + + // check also for new instances being deleted + const newBeingDeleted = instances.filter( + (i) => + [ + RegistryStatusValue.Deleting, + RegistryStatusValue.Deprovision, + ].includes(i.status as any) && + stillBeingDeleted.find((s) => s.id === i.id) === undefined + ); + + // recreate the deleted instances ref with the data derived before + instancesBeingDeletedRef.current = [ + ...stillBeingDeleted, + ...newBeingDeleted, + ]; + + // update the ref of instances for which we need to notify the user + toNotifyRef.current = [ + ...toNotifyRef.current, + ...ready.map(instanceToAlertable), + ...failed.map(instanceToAlertable), + ...deleted.map(instanceToAlertable), + ]; + + // snapshot the instances used in this run + previousInstancesRef.current = instances; + } + } + }, + [instances] + ); + + // check every second if the browser is visible, and if so send the queued + // notifications + useInterval( + useCallback( + function sendNotificationCb() { + if (isVisible) { + while (toNotifyRef.current.length > 0) { + const instance = toNotifyRef.current.shift()!; + switch (instance.status) { + case RegistryStatusValue.Ready: + notifyReady(instance.name); + break; + case RegistryStatusValue.Failed: + notifyFailure(instance.name); + break; + case RegistryStatusValue.Deprovision: + case RegistryStatusValue.Deleting: + notifyDelete(instance.name); + break; + } + } + } + }, + [isVisible, notifyDelete, notifyFailure, notifyReady] + ), + 1000 + ); +} + +function instanceToAlertable(instance: Registry): AlertableInstance { + return { + name: instance.name!, + status: instance.status as RegistryStatusValue, + }; +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index acc37bba..12cf9c4e 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1 +1,2 @@ export * from './useInterval'; +export * from './usePageVisibility'; diff --git a/src/hooks/usePageVisibility.ts b/src/hooks/usePageVisibility.ts new file mode 100644 index 00000000..b475dfe7 --- /dev/null +++ b/src/hooks/usePageVisibility.ts @@ -0,0 +1,50 @@ +import { SetStateAction, Dispatch, useEffect, useState } from 'react'; + +type XDocument = Document & { + msHidden: string; + webkitHidden: string; +}; + +export function getBrowserVisibilityProp(): string { + const doc: XDocument = document as XDocument; + if (typeof doc.hidden !== 'undefined') { + // Opera 12.10 and Firefox 18 and later support + return 'visibilitychange'; + } else if (typeof doc.msHidden !== 'undefined') { + return 'msvisibilitychange'; + } else if (typeof doc.webkitHidden !== 'undefined') { + return 'webkitvisibilitychange'; + } + return ''; +} +export function getBrowserDocumentHiddenProp(): keyof XDocument | undefined { + const doc: XDocument = document as XDocument; + if (typeof doc.hidden !== 'undefined') { + return 'hidden'; + } else if (typeof doc.msHidden !== 'undefined') { + return 'msHidden'; + } else if (typeof doc.webkitHidden !== 'undefined') { + return 'webkitHidden'; + } + return undefined; +} +export function getIsDocumentHidden(): boolean { + const hiddenKey = getBrowserDocumentHiddenProp(); + return hiddenKey !== undefined && !(document as XDocument)[hiddenKey]; +} + +export function usePageVisibility(): { + isVisible: boolean; + setIsVisible: Dispatch>; +} { + const [isVisible, setIsVisible] = useState(getIsDocumentHidden()); + const onVisibilityChange = () => setIsVisible(getIsDocumentHidden()); + useEffect(() => { + const visibilityChange = getBrowserVisibilityProp(); + document.addEventListener(visibilityChange, onVisibilityChange, false); + return () => { + document.removeEventListener(visibilityChange, onVisibilityChange); + }; + }, []); + return { isVisible, setIsVisible }; +}