From 2d75765cdbb029ad2b526c2c3f6b5a5175d3446f Mon Sep 17 00:00:00 2001 From: Thorben Denzer Date: Wed, 15 Apr 2026 17:14:58 +0200 Subject: [PATCH] feat(ui): require organization selection before entering pages Before this commit, the "Content", "Execution Environment" and "Environments" pages would load incorrectly, if the user had selected "Any Organization". Management of these objects across organizations is currently not supported. To ensure the user has selected an organization, a new component, "ForceTaxonomy", is introduced, which requires the user to select an organization before proceeding. The ForceTaxonomy component is generalized and supports enforcement of: - Organization selection - Loction selection - Organization and Location selection Now, to access the "Content", "Execution Environments" and "Environments" pages, the user has to have a concrete organization selected. --- .../AnsibleContentPageWrapper.tsx | 5 +- .../AnsibleEnvironmentsPageWrapper.tsx | 5 +- .../AnsibleExecutionEnvPageWrapper.tsx | 5 +- webpack/components/common/ForceTaxonomy.tsx | 248 ++++++++++++++++++ .../common/components/TaxonSelector.tsx | 81 ++++++ webpack/types/common.d.ts | 11 + .../Root/Context/ForemanContext.d.ts | 10 +- 7 files changed, 355 insertions(+), 10 deletions(-) create mode 100644 webpack/components/common/ForceTaxonomy.tsx create mode 100644 webpack/components/common/components/TaxonSelector.tsx create mode 100644 webpack/types/common.d.ts diff --git a/webpack/components/ansible_content/AnsibleContentPageWrapper.tsx b/webpack/components/ansible_content/AnsibleContentPageWrapper.tsx index 1e45508b..a09785bb 100644 --- a/webpack/components/ansible_content/AnsibleContentPageWrapper.tsx +++ b/webpack/components/ansible_content/AnsibleContentPageWrapper.tsx @@ -2,11 +2,14 @@ import React from 'react'; import Permitted from 'foremanReact/components/Permitted'; import { AdPermissions } from '../../constants/foremanAnsibleDirectorPermissions'; import AnsibleContentPage from './AnsibleContentPage'; +import { ForceTaxonomy } from '../common/ForceTaxonomy'; // TODO: This wrapper will be used for permission management const AnsibleContentPageWrapper: React.FC = () => ( - + + + ); diff --git a/webpack/components/ansible_environments/AnsibleEnvironmentsPageWrapper.tsx b/webpack/components/ansible_environments/AnsibleEnvironmentsPageWrapper.tsx index 71657043..c2ad63dd 100644 --- a/webpack/components/ansible_environments/AnsibleEnvironmentsPageWrapper.tsx +++ b/webpack/components/ansible_environments/AnsibleEnvironmentsPageWrapper.tsx @@ -3,10 +3,13 @@ import Permitted from 'foremanReact/components/Permitted'; import AnsibleEnvironmentsPage from './AnsibleEnvironmentsPage'; import { AdPermissions } from '../../constants/foremanAnsibleDirectorPermissions'; +import { ForceTaxonomy } from '../common/ForceTaxonomy'; const AnsibleEnvironmentsPageWrapper: React.FC = () => ( - + + + ); diff --git a/webpack/components/ansible_execution_environments/AnsibleExecutionEnvPageWrapper.tsx b/webpack/components/ansible_execution_environments/AnsibleExecutionEnvPageWrapper.tsx index 80c97837..56298d65 100644 --- a/webpack/components/ansible_execution_environments/AnsibleExecutionEnvPageWrapper.tsx +++ b/webpack/components/ansible_execution_environments/AnsibleExecutionEnvPageWrapper.tsx @@ -2,10 +2,13 @@ import React from 'react'; import Permitted from 'foremanReact/components/Permitted'; import AnsibleExecutionEnvPage from './AnsibleExecutionEnvPage'; import { AdPermissions } from '../../constants/foremanAnsibleDirectorPermissions'; +import { ForceTaxonomy } from '../common/ForceTaxonomy'; const AnsibleExecutionEnvPageWrapper: React.FC = () => ( - + + + ); diff --git a/webpack/components/common/ForceTaxonomy.tsx b/webpack/components/common/ForceTaxonomy.tsx new file mode 100644 index 00000000..92add2bd --- /dev/null +++ b/webpack/components/common/ForceTaxonomy.tsx @@ -0,0 +1,248 @@ +import React, { ReactElement, useEffect } from 'react'; +import { + useForemanOrganization, + useForemanLocation, +} from 'foremanReact/Root/Context/ForemanContext'; +import { translate as _, sprintf as __ } from 'foremanReact/common/I18n'; + +import { + Card, + CardTitle, + CardBody, + CardFooter, + Bullseye, + ActionList, + ActionListItem, + Button, + Form, + TextVariants, + TextContent, + Text, +} from '@patternfly/react-core'; + +import axios, { AxiosResponse } from 'axios'; +import { foremanUrl } from 'foremanReact/common/helpers'; +import { addToast } from 'foremanReact/components/ToastsList'; +import { useDispatch } from 'react-redux'; + +import { TaxonSelector } from './components/TaxonSelector'; + +interface ForceTaxonomyProps { + organization?: boolean; + location?: boolean; + children: React.ReactNode; +} + +export const ForceTaxonomy = ({ + organization, + location = false, + children = false, +}: ForceTaxonomyProps): ReactElement => { + const [selectedOrganization, setSelectedOrganization] = React.useState< + string + >(''); + const [selectedLocation, setSelectedLocation] = React.useState(''); + + const currentOrganization = useForemanOrganization(); + const currentLocation = useForemanLocation(); + + const organizationUpdateRequired = + organization && currentOrganization === undefined; + const locationUpdateRequired = location && currentLocation === undefined; + + useEffect(() => { + if (organizationUpdateRequired && locationUpdateRequired) { + document.title = _('Organization/Location required!'); + } else if (locationUpdateRequired) { + document.title = _('Location required!'); + } else if (organizationUpdateRequired) { + document.title = _('Organization required!'); + } + }, [organizationUpdateRequired, locationUpdateRequired]); + + const dispatch = useDispatch(); + + const card = (content: ReactElement): ReactElement => ( + + + {content} + + + ); + + const updateTaxonomy = async (): Promise => { + const updateTaxon = async ({ + type, + endpoint, + }: { + type: 'organization' | 'location'; + endpoint: string; + }): Promise => { + try { + await axios.get(foremanUrl(endpoint)); + dispatch( + addToast({ + type: 'success', + key: `UPDATE_CURRENT_${type.toUpperCase()}_SUCC`, + message: __(_('Successfully updated the current %(taxonType)s!'), { + taxonType: type, + }), + sticky: false, + }) + ); + } catch (e) { + dispatch( + addToast({ + type: 'danger', + key: `UPDATE_CURRENT_${type.toUpperCase()}_ERR`, + message: __( + _( + 'Updating the current %(taxonType)s failed with error code "%(error)s".' + ), + { + taxonType: type, + error: (e as { + response: AxiosResponse; + }).response.status, + } + ), + sticky: false, + }) + ); + } + }; + + await Promise.all([ + organizationUpdateRequired && + updateTaxon({ + type: 'organization', + endpoint: `/organizations/${selectedOrganization}/select`, + }), + locationUpdateRequired && + updateTaxon({ + type: 'location', + endpoint: `/locations/${selectedLocation}/select`, + }), + ]); + + // Unfortunately, the context is hydrated during the initial mounting of the root React App. + // There is currently no mechanism to refresh it out of tree. Therefore, a reload is needed here. + window.location.reload(); + }; + + if (organizationUpdateRequired && locationUpdateRequired) { + return card( + <> + + + + {_('Select an organization and a location')} + + + + +
+ setSelectedOrganization(taxonId)} + /> + setSelectedLocation(taxonId)} + /> + +
+ + + + + + + + + ); + } else if (locationUpdateRequired) { + return card( + <> + + + {_('Select a location')} + + + +
+ { + setSelectedLocation(taxonId); + }} + /> + +
+ + + + + + + + + ); + } else if (organizationUpdateRequired) { + return card( + <> + + + + {_('Select an organization')} + + + + +
+ setSelectedOrganization(taxonId)} + /> + +
+ + + + + + + + + ); + } + + return <>{children}; +}; diff --git a/webpack/components/common/components/TaxonSelector.tsx b/webpack/components/common/components/TaxonSelector.tsx new file mode 100644 index 00000000..271df933 --- /dev/null +++ b/webpack/components/common/components/TaxonSelector.tsx @@ -0,0 +1,81 @@ +import React, { ReactElement } from 'react'; + +import { useAPI } from 'foremanReact/common/hooks/API/APIHooks'; +import { foremanUrl } from 'foremanReact/common/helpers'; +import { translate as _, sprintf as __ } from 'foremanReact/common/I18n'; +import { + EmptyState, + EmptyStateHeader, + EmptyStateIcon, + FormGroup, + FormSelect, + FormSelectOption, + Spinner, +} from '@patternfly/react-core'; + +import { Taxon } from '../../../types/common'; + +interface TaxonSelectorProps { + type: 'organization' | 'location'; + selected: string; + onSelect: (taxonId: string) => void; +} + +export const TaxonSelector = ({ + type, + selected, + onSelect, +}: TaxonSelectorProps): ReactElement => { + const taxonResponse = useAPI<{ results: Taxon[] }>( + 'get', + foremanUrl(`/api/v2/${type}s`) + ); + + if (taxonResponse.status === 'ERROR') { + // TODO: Handle error + } else if (taxonResponse.status === 'RESOLVED') { + return ( + + { + onSelect(value); + }} + > + {[ + , + ...taxonResponse.response.results.map(taxon => ( + + )), + ]} + + + ); + } + + return ( + + } + /> + + ); +}; diff --git a/webpack/types/common.d.ts b/webpack/types/common.d.ts new file mode 100644 index 00000000..fcd365dd --- /dev/null +++ b/webpack/types/common.d.ts @@ -0,0 +1,11 @@ +export interface Organization { + id: number; + title: string; +} + +export interface Location { + id: number; + title: string; +} + +export interface Taxon extends Organization, Location {} diff --git a/webpack/types/foremanReact/Root/Context/ForemanContext.d.ts b/webpack/types/foremanReact/Root/Context/ForemanContext.d.ts index c1e6db5b..88d940bb 100644 --- a/webpack/types/foremanReact/Root/Context/ForemanContext.d.ts +++ b/webpack/types/foremanReact/Root/Context/ForemanContext.d.ts @@ -1,9 +1,5 @@ -interface Organization { - id: number; - title: string; -} - declare module 'foremanReact/Root/Context/ForemanContext' { + import { Organization, Location } from '../../../common'; // export declare function forceSingleton(key: string, createFn: () => T): T | null; // export declare const getForemanContext: ( @@ -22,9 +18,9 @@ declare module 'foremanReact/Root/Context/ForemanContext' { // export declare const useForemanDocUrl: () => string | undefined; - export declare const useForemanOrganization: () => Organization | null; + export declare const useForemanOrganization: () => Organization | undefined; - // export declare const useForemanLocation: () => string | undefined; + export declare const useForemanLocation: () => Location | undefined; // export declare const useForemanUser: () => any;