diff --git a/README.md b/README.md index b18e07f..2eba007 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,7 @@ In order to access the https://[env].foo.redhat.com in your browser, you have to To setup the hosts file run following command: -```bash -npm run patch:hosts -``` - -If this command throws an error run it as a `sudo`: - -```bash -sudo npm run patch:hosts -``` - -The commands adds these entries to `/etc/hosts` +Add the below to your `/etc/hosts` file: ``` 127.0.0.1 prod.foo.redhat.com @@ -25,6 +15,13 @@ The commands adds these entries to `/etc/hosts` 127.0.0.1 ci.foo.redhat.com ``` +## Install react developer tools + +A recommended tool to install is react developer tools, which is installed as a plugin for your +favourite browser. + +- [React Developer Tools](https://react.dev/learn/react-developer-tools). + ## Setup and run chrome-service-backend Clone repositories and use Alejandro's branch: diff --git a/docs/04-context.md b/docs/04-context.md index 7eb1bd4..dac4df9 100644 --- a/docs/04-context.md +++ b/docs/04-context.md @@ -7,6 +7,9 @@ creation of a context for the micro-frontend. towards the ListDomains. - Provide the same data information for the different impacted objects. +> Avoid to use context if it evoke a performance because an excess of the +> render on the frontend. + ## Defining the context For using a context, we need to define the context (in this case it is defined @@ -17,78 +20,88 @@ it were required). import { createContext } from 'react'; import { Domain } from './Api'; -export interface IAppContext { +export interface AppContextType { domains: Domain[]; setDomains: (domains: Domain[]) => void; } -export const AppContext = createContext({ - domains: [], - setDomains: (domains: Domain[]) => { - throw new Error('Function "setDomains" not implemented: domains=' + domains); - }, -}); +export const AppContext = createContext(undefined); + +export const AppContextProvider: React.FC = ({ children }) => { + const [getter, setter] = useState(undefined); + + return ( + + {children} + + ); +} ``` Let's see what we have here: -- IAppContext define an interface with properties to be shared and some +- `AppContextType` define an interface with properties to be shared and some callbacks to update the properties above. -- The callbacks will use some state associated to a root component, so - when we change the state from a deeper component, the view is properly - rendered. - -We will define the callbacks at `src/AppEntry.tsx`: +- Create the context for the type `AppContextType`. +- Define the `AppContextProvider` which map getter and setter, and extend with + the `children`. -## Referencing the context in the root component +## Wrap your application by the context provider ```typescript -import React, { useState } from 'react'; +import React from 'react'; // ... const AppEntry = () => { - // Declare the state - const [domains, setDomains] = useState([]); - const cbSetDomains = (domains: Domain[]) => { - setDomains(domains); - }; return ( - + - + ); }; ``` -As we see the callback is invoking the setter for the state, which raise the -render mechanism for the view. - -See that we inject the domains value and the setter callback into the `value` property -for the `AppContext.Provider` tag. +Now we only reference the `AppContextProvider` wrapping our application. The +setter and getter are configured inside the AppContextProvider, which provide +a cleaner code. ## Use the context form other inner components Using the context from any component nested in our App would be kind of the below: ```typescript -const appContext = useContext(AppContext); +// we get the reference to the context +const appContext = useContext(AppContext); -const domains = appContext.domains; // Retrieving the value from the context +// We use a property +const domains = appContext?.domains; +// We use a setter which will fire a render cycle with the change const myEvent = () => { - appContext.setDomains(newDomain); // This will fire from the root component the render + appContext?.setDomains(newDomains); }; ``` -What is happening here? -- I get the reference to the context. -- I read a value from the context. -- I set a value into the context by using the injected callback when we - used the context into the root component; recall that it is calling a setDomain state, - which is the one that trigger the render of the view. +> Remember we mapped the set... function returned by useState hook +> for this callback, so we are calling a change of the state for +> the context. So it is calling a setDomain state, which is the one +> that trigger the render of the view. + +## Final considerations +Similar approach could be followed when we want to define a context closer to some component, +such as the wizard page (not using this approach currently), but instead of wrapping the application +we would wrap an inner component. diff --git a/src/AppContext.tsx b/src/AppContext.tsx index 7e9291d..68f742e 100644 --- a/src/AppContext.tsx +++ b/src/AppContext.tsx @@ -1,30 +1,44 @@ -import { createContext } from 'react'; +import { ReactNode, createContext, useState } from 'react'; import { Domain } from './Api'; import { VerifyState } from './Routes/WizardPage/Components/VerifyRegistry/VerifyRegistry'; +import React from 'react'; /** * It represents the application context so common events and properties * are shared for many components, making their values accessible. * @public */ -export interface IAppContext { +export interface AppContextType { /** Represent the current list of domains to be displayed in the listDomains view. */ domains: Domain[]; /** Callback to set the value of `domains`. */ setDomains: (domains: Domain[]) => void; + /** Update an existing domain in domains */ + updateDomain: (domain: Domain) => void; + /** Delete the domain identified by id */ + deleteDomain: (id: string) => void; + /** Get the domain identified by id */ + getDomain: (id: string) => Domain | undefined; + /** The current editing domain */ + editing?: Domain; + /** Set the current editing domain */ + setEditing: (value?: Domain) => void; + /** Encapsulates the context related with the wizard. */ wizard: { /** Retrieve the current token, required to register a domain. */ - getToken: () => string; + token: string; /** Set the value of the token. */ setToken: (value: string) => void; + /** Retrieve the value of the registered status which is updated once * the user has registered the domain by using ipa-hcc tool. */ - getRegisteredStatus: () => VerifyState; + registeredStatus: VerifyState; /** Setter for the registered status. */ setRegisteredStatus: (value: VerifyState) => void; + /** Get the ephemeral domain state that manage the wizard. */ - getDomain: () => Domain; + domain: Domain; /** Set the ephemeral domain information. */ setDomain: (value: Domain) => void; }; @@ -34,29 +48,98 @@ export interface IAppContext { * Represent the application context. * @public */ -export const AppContext = createContext({ - domains: [], - setDomains: (domains: Domain[]) => { - throw new Error('Function "setDomains" not implemented: domains=' + domains); - }, - wizard: { - getToken: (): string => { - return ''; - }, - setToken: (value: string) => { - throw new Error('Function "setToken" not implemented: value=' + value); - }, - getRegisteredStatus: (): VerifyState => { - return 'initial'; - }, - setRegisteredStatus: (value: VerifyState) => { - throw new Error('Function "setRegisteredStatus" not implemented: value=' + value); - }, - getDomain: (): Domain => { - return {} as Domain; - }, - setDomain: (value: Domain) => { - throw new Error('Function "setDomain" not implemented: value=' + value); - }, - }, -}); +export const AppContext = createContext(undefined); + +/** + * The properties accepted by the AppContextProvider. + */ +interface AppContextProviderProps { + /** The children components. */ + children: ReactNode; +} + +/** + * Define the provider for the application context. + * @param param0 The children components. + * @returns the application context. + */ +export const AppContextProvider: React.FC = ({ children }) => { + const [domains, _setDomains] = useState([]); + const [editing, _setEditing] = useState(); + + const [wizardToken, _setWizardSetToken] = useState(); + const [wizardRegisteredStatus, _setWizardRegisteredStatus] = useState('initial'); + const [wizardDomain, _setWizardDomain] = useState(); + + /** + * Update a domain into the list of domains kept into the application context + * if it exists. + * @param domain The domain to be updated into the context. + */ + const _updateDomain = (domain: Domain) => { + const newDomains: Domain[] = {} as Domain[]; + for (const idx in domains) { + if (domains[idx].domain_id === domain.domain_id) { + newDomains[idx] = domain; + } else { + newDomains[idx] = domains[idx]; + } + } + _setDomains(newDomains); + }; + + /** + * Delete a domain from the application context if it exists, which is + * identified by the its id. + * @param id the domain identifier. + */ + const _deleteDomain = (id: string) => { + const newDomains: Domain[] = {} as Domain[]; + for (const idx in domains) { + if (domains[idx].domain_id !== id) { + newDomains[idx] = domains[idx]; + } + } + _setDomains(newDomains); + }; + + /** + * Retrieve a domain from the application context if it exists. + * @param id the domain identifier. + * @returns The domain that exists into the application context + * or undefined if it is not found. + */ + const _getDomain = (id: string): Domain | undefined => { + if (id === '') return undefined; + for (const idx in domains) { + if (domains[idx].domain_id === id) { + return domains[idx]; + } + } + return undefined; + }; + + return ( + + {children} + + ); +}; diff --git a/src/AppEntry.tsx b/src/AppEntry.tsx index 557347b..dba22de 100644 --- a/src/AppEntry.tsx +++ b/src/AppEntry.tsx @@ -1,61 +1,19 @@ -import React, { useContext, useState } from 'react'; +import React from 'react'; import { BrowserRouter as Router } from 'react-router-dom'; import { Provider } from 'react-redux'; import { init } from './store'; import App from './App'; import { getBaseName } from '@redhat-cloud-services/frontend-components-utilities/helpers'; import logger from 'redux-logger'; -import { AppContext } from './AppContext'; -import { Domain } from './Api'; -import { VerifyState } from './Routes/WizardPage/Components/VerifyRegistry/VerifyRegistry'; +import { AppContextProvider } from './AppContext'; const AppEntry = () => { - const appContext = useContext(AppContext); - const [domains, setDomains] = useState([]); - const [wizardToken, setWizardToken] = useState(''); - const [wizardDomain, setWizardDomain] = useState({} as Domain); - const [wizardRegisterStatus, setWizardRegisterStatus] = useState('initial'); - const cbSetDomains = (domains: Domain[]) => { - appContext.domains = domains; - setDomains(domains); - }; - const cbGetWizardToken = (): string => { - return wizardToken; - }; - const cbSetWizardToken = (value: string) => { - setWizardToken(value); - }; - const cbGetWizardDomain = (): Domain => { - return wizardDomain; - }; - const cbSetWizardDomain = (value: Domain) => { - setWizardDomain(value); - }; - const cbGetRegisterStatus = (): VerifyState => { - return wizardRegisterStatus; - }; - const cbSetRegisterStatus = (value: VerifyState) => { - setWizardRegisterStatus(value); - }; return ( - + - + ); diff --git a/src/Components/DomainList/DomainList.tsx b/src/Components/DomainList/DomainList.tsx index 67a19f2..5eecdbc 100644 --- a/src/Components/DomainList/DomainList.tsx +++ b/src/Components/DomainList/DomainList.tsx @@ -4,8 +4,9 @@ import { Fragment, useContext, useState } from 'react'; import React from 'react'; import { Domain, DomainType, ResourcesApiFactory } from '../../Api/api'; -import { Link } from 'react-router-dom'; -import { AppContext, IAppContext } from '../../AppContext'; +import { useNavigate } from 'react-router-dom'; +import { AppContext, AppContextType } from '../../AppContext'; +import { Button } from '@patternfly/react-core'; export interface IColumnType { key: string; @@ -94,7 +95,8 @@ export const DomainList = () => { const base_url = '/api/idmsvc/v1'; const resources_api = ResourcesApiFactory(undefined, base_url, undefined); - const context = useContext(AppContext); + const context = useContext(AppContext); + const navigate = useNavigate(); // Index of the currently sorted column // Note: if you intend to make columns reorderable, you may instead want to use a non-numeric key @@ -104,7 +106,7 @@ export const DomainList = () => { // Sort direction of the currently sorted column const [activeSortDirection, setActiveSortDirection] = React.useState<'asc' | 'desc'>('asc'); - const [domains, setDomains] = useState(context.domains); + const [domains, setDomains] = useState(context?.domains || ([] as Domain[])); const enabledText = 'Enabled'; const disabledText = 'Disabled'; @@ -200,6 +202,13 @@ export const DomainList = () => { // We shouldn't store the list of data in state because we don't want to have to sync that with props. activeSortIndex !== null && domains.sort(createCompareRows(activeSortIndex, activeSortDirection)); + const onShowDetails = (domain: Domain | undefined) => { + if (domain !== undefined) { + context?.setEditing(domain); + navigate('/details/' + domain?.domain_id); + } + }; + return ( <> @@ -223,7 +232,14 @@ export const DomainList = () => { <> - {domain.title} + diff --git a/src/Routes.tsx b/src/Routes.tsx index 18672a4..b5c2b94 100644 --- a/src/Routes.tsx +++ b/src/Routes.tsx @@ -1,8 +1,8 @@ -import React, { Suspense, lazy } from 'react'; +import React, { lazy } from 'react'; import { Navigate, Route, Routes } from 'react-router-dom'; -import { Bullseye, Spinner } from '@patternfly/react-core'; import WizardPage from './Routes/WizardPage/WizardPage'; +import DetailPage from './Routes/DetailPage/DetailPage'; const DefaultPage = lazy(() => import(/* webpackChunkName: "DefaultPage" */ './Routes/DefaultPage/DefaultPage')); const OopsPage = lazy(() => import(/* webpackChunkName: "OopsPage" */ './Routes/OopsPage/OopsPage')); @@ -17,22 +17,15 @@ const NoPermissionsPage = lazy(() => import(/* webpackChunkName: "NoPermissionsP * component - component to be rendered when a route has been chosen. */ const DomainRegistryRoutes = () => ( - - - - } - > - - - - - - {/* Finally, catch all unmatched routes */} - } /> - - + + + + + + + {/* Finally, catch all unmatched routes */} + } /> + ); export default DomainRegistryRoutes; diff --git a/src/Routes/DefaultPage/DefaultPage.tsx b/src/Routes/DefaultPage/DefaultPage.tsx index 67b7347..cc4e84f 100644 --- a/src/Routes/DefaultPage/DefaultPage.tsx +++ b/src/Routes/DefaultPage/DefaultPage.tsx @@ -10,6 +10,7 @@ import { CardBody, EmptyState, EmptyStateBody, + EmptyStateIcon, EmptyStateVariant, Flex, FlexItem, @@ -27,24 +28,22 @@ import './DefaultPage.scss'; import Section from '@redhat-cloud-services/frontend-components/Section'; import { Domain, ResourcesApiFactory } from '../../Api/api'; import { DomainList } from '../../Components/DomainList/DomainList'; -import { AppContext, IAppContext } from '../../AppContext'; +import { AppContext, AppContextType } from '../../AppContext'; const Header = () => { const linkLearnMoreAbout = 'https://access.redhat.com/articles/1586893'; const title = 'Directory and Domain Services'; return ( - <> - - -

- Manage registered identity domains to leverage host access controls from your existing identity and access management.{' '} - -

-
- + + +

+ Manage registered identity domains to leverage host access controls from your existing identity and access management.{' '} + +

+
); }; @@ -52,13 +51,15 @@ const EmptyContent = () => { // FIXME Update this link in the future const linkLearnMoreAbout = 'https://access.redhat.com/articles/1586893'; const navigate = useNavigate(); - const appContext = useContext(AppContext); + const appContext = useContext(AppContext); const handleOpenWizard = () => { - appContext.wizard.setDomain({ domain_id: '', title: '', description: '' } as Domain); - appContext.wizard.setToken(''); - appContext.wizard.setRegisteredStatus('initial'); - navigate('/domains/wizard', { replace: true }); + if (appContext !== undefined) { + appContext.wizard.setDomain({ domain_id: '', title: '', description: '' } as Domain); + appContext.wizard.setToken(''); + appContext.wizard.setRegisteredStatus('initial'); + navigate('/domains/wizard', { replace: true }); + } }; return ( @@ -66,7 +67,7 @@ const EmptyContent = () => {
- + No identity domains registered @@ -117,8 +118,7 @@ const ListContent = () => { // TODO Extract this code in a hook useEffect(() => { - // eslint-disable-next-line prefer-const - let local_domains: Domain[] = []; + const local_domains: Domain[] = []; resources_api .listDomains(undefined, offset, perPage, undefined) .then((res) => { @@ -129,7 +129,7 @@ const ListContent = () => { .then((res_domain) => { local_domains[count++] = res_domain.data; if (res.data.data.length == local_domains.length) { - appContext.setDomains(local_domains); + appContext?.setDomains(local_domains); const newOffset = Math.floor((offset + perPage - 1) / perPage) * perPage; const newPage = newOffset / perPage; setItemCount(res.data.meta.count); @@ -152,10 +152,12 @@ const ListContent = () => { }, [page, perPage, offset]); const handleOpenWizard = () => { - appContext.wizard.setDomain({ domain_id: '', title: '', description: '' } as Domain); - appContext.wizard.setRegisteredStatus('initial'); - appContext.wizard.setToken(''); - navigate('/domains/wizard', { replace: true }); + if (appContext !== undefined) { + appContext.wizard.setDomain({ domain_id: '', title: '', description: '' } as Domain); + appContext.wizard.setRegisteredStatus('initial'); + appContext.wizard.setToken(''); + navigate('/domains/wizard', { replace: true }); + } }; const onSetPage = (_event: React.MouseEvent | React.KeyboardEvent | MouseEvent, newPage: number) => { @@ -220,7 +222,7 @@ const DefaultPage = () => { // States const [page, setPage] = useState(0); - const [itemCount, setItemCount] = useState(0); + const [itemCount, setItemCount] = useState(appContext?.domains.length || -1); const [perPage] = useState(10); const [offset, setOffset] = useState(0); @@ -228,8 +230,7 @@ const DefaultPage = () => { // TODO Extract in a hook useEffect(() => { - // eslint-disable-next-line prefer-const - let local_domains: Domain[] = []; + const local_domains: Domain[] = []; resources_api .listDomains(undefined, offset, perPage, undefined) .then((res) => { @@ -240,7 +241,7 @@ const DefaultPage = () => { .then((res_domain) => { local_domains[count++] = res_domain.data; if (res.data.data.length == local_domains.length) { - appContext.setDomains(local_domains); + appContext?.setDomains(local_domains); const newOffset = Math.floor((offset + perPage - 1) / perPage) * perPage; const newPage = newOffset / perPage; setItemCount(res.data.meta.count); @@ -260,7 +261,7 @@ const DefaultPage = () => { return () => { // Finalizer }; - }, [page, perPage, offset]); + }, []); const listContent = ( <> @@ -276,7 +277,8 @@ const DefaultPage = () => { ); - const content = itemCount == 0 ? emptyContent : listContent; + // TODO Use similar logic to display a Spinner icon + const content = itemCount <= 0 ? emptyContent : listContent; return content; }; diff --git a/src/Routes/DetailPage/Components/DetailGeneral/DetailGeneral.test.tsx b/src/Routes/DetailPage/Components/DetailGeneral/DetailGeneral.test.tsx new file mode 100644 index 0000000..5cf9745 --- /dev/null +++ b/src/Routes/DetailPage/Components/DetailGeneral/DetailGeneral.test.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +// import { DetailGeneral } from './DetailGeneral'; +// import { AppContextProvider } from '../../../../AppContext'; + +test('expect sample-component to render children', () => { + render(<>); + // render( + // + // + // + // ); + // expect(screen.getByRole('heading')).toHaveTextContent('Name'); + // expect(screen.getByRole('heading')).toHaveTextContent('Location'); +}); diff --git a/src/Routes/DetailPage/Components/DetailGeneral/DetailGeneral.tsx b/src/Routes/DetailPage/Components/DetailGeneral/DetailGeneral.tsx new file mode 100644 index 0000000..4c7e357 --- /dev/null +++ b/src/Routes/DetailPage/Components/DetailGeneral/DetailGeneral.tsx @@ -0,0 +1,323 @@ +import { + Button, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Icon, + Modal, + ModalVariant, + Switch, + Text, + TextArea, + TextInput, + Tooltip, +} from '@patternfly/react-core'; +import React from 'react'; +import { useState } from 'react'; +import { Domain, ResourcesApiFactory } from '../../../../Api'; +import OutlinedQuestionCircleIcon from '@patternfly/react-icons/dist/esm/icons/outlined-question-circle-icon'; +import PencilAltIcon from '@patternfly/react-icons/dist/esm/icons/pencil-alt-icon'; +import DownloadIcon from '@patternfly/react-icons/dist/esm/icons/download-icon'; + +interface DetailGeneralProps { + domain?: Domain; + onShowServerTab?: () => void; + onChange?: (domain: Domain) => void; +} + +export const DetailGeneral = (props: DetailGeneralProps) => { + const domain = props.domain; + if (domain === undefined) { + return <>; + } + + const base_url = '/api/idmsvc/v1'; + const resources_api = ResourcesApiFactory(undefined, base_url, undefined); + + // States + const [autoJoin, setAutoJoin] = useState(domain.auto_enrollment_enabled); + const [title, setTitle] = useState(domain.title || ''); + const [description, setDescription] = useState(domain.description || ''); + + const [editTitle, setEditTitle] = useState(domain.title || ''); + const [editDescription, setEditDescription] = useState(domain.description || ''); + + const [isTitleModalOpen, setIsTitleModalOpen] = useState(false); + const [isDescriptionModalOpen, setIsDescriptionModalOpen] = useState(false); + + // Control handlers + const handleSaveTitleButton = () => { + console.log('Save Title button pressed'); + if (domain.domain_id) { + resources_api + .updateDomainUser(domain.domain_id, { + title: editTitle, + }) + .then((response) => { + if (response.status == 200) { + setTitle(response.data.title || ''); + if (props.onChange !== undefined) props.onChange({ ...domain, title: response.data.title }); + } else { + // TODO show-up notification with error message + } + }) + .catch((error) => { + // TODO show-up notification with error message + console.log('error at handleSaveTitleButton: ' + error); + }); + } + setIsTitleModalOpen(false); + }; + + const handleCancelTitleButton = () => { + console.log('Cancel Title button pressed'); + setIsTitleModalOpen(false); + }; + + const handleSaveDescriptionButton = () => { + console.log('Save Description button pressed'); + if (domain.domain_id) { + resources_api + .updateDomainUser(domain.domain_id, { + description: editDescription, + }) + .then((response) => { + if (response.status == 200) { + setDescription(response.data.description || ''); + if (props.onChange !== undefined) props.onChange({ ...domain, description: response.data.description }); + } else { + // TODO show-up notification with error message + } + }) + .catch((error) => { + // TODO show-up notification with error message + console.log('error at handleSaveDescriptionButton: ' + error); + }); + } + setIsDescriptionModalOpen(false); + }; + + const handleCancelDescriptionButton = () => { + console.log('Cancel Description button pressed'); + setIsDescriptionModalOpen(false); + }; + + const handleAutoJoin = (checked: boolean, event: React.FormEvent) => { + console.log('toggled auto-join enable/disable'); + if (domain.domain_id) { + resources_api + .updateDomainUser(domain.domain_id, { + auto_enrollment_enabled: !autoJoin, + }) + .then((response) => { + if (response.status == 200) { + setAutoJoin(response.data.auto_enrollment_enabled); + if (props.onChange !== undefined) props.onChange({ ...domain, auto_enrollment_enabled: response.data.auto_enrollment_enabled }); + } else { + // TODO show-up notification with error message + } + }) + .catch((error) => { + // TODO show-up notification with error message + console.log('error onClose: ' + error); + }); + } + }; + + return ( + <> + + + + Identity domain type + + + + + + + Red Hat IdM + + + + Kerberos realm + + + + + + + {domain?.['rhel-idm']?.realm_name} + + + + Display name + + + + + + + + {title}{' '} + + + + + + + Description + + + + + + + + + {description} + + + + + + Red Hat IdM servers + + + + + + + + + + + + + UUID + + + + + + + {domain?.domain_id} + + + + Domain auto-join on launch + + + + + + + + + + + + + Certificate Authority + + + + + + + + + + + + + Save + , + , + ]} + > + setEditTitle(value)} /> + + + Save + , + , + ]} + > +