diff --git a/src/applications/_mock-form-ae-design-patterns/hooks/useLocalStorage.js b/src/applications/_mock-form-ae-design-patterns/hooks/useLocalStorage.js index 810859636ea8..fd86dbbfa505 100644 --- a/src/applications/_mock-form-ae-design-patterns/hooks/useLocalStorage.js +++ b/src/applications/_mock-form-ae-design-patterns/hooks/useLocalStorage.js @@ -1,5 +1,4 @@ import { useState, useEffect } from 'react'; - /** * useLocalStorage is a hook that provides a way to store and retrieve values from localStorage * @param {string} key - The key to store the value under @@ -11,10 +10,17 @@ export const useLocalStorage = (key, defaultValue) => { let currentValue; try { - currentValue = JSON.parse( - localStorage.getItem(key) || String(defaultValue), - ); + const item = localStorage.getItem(key); + if (item === null) { + currentValue = defaultValue; + } else if (item.startsWith('{') || item.startsWith('[')) { + currentValue = JSON.parse(item); + } else { + currentValue = item; + } } catch (error) { + // eslint-disable-next-line no-console + console.error('Error getting value from localStorage', error); currentValue = defaultValue; } @@ -30,6 +36,7 @@ export const useLocalStorage = (key, defaultValue) => { const clearValue = () => { localStorage.removeItem(key); + setValue(defaultValue); }; return [value, setValue, clearValue]; diff --git a/src/applications/_mock-form-ae-design-patterns/hooks/useMockedLogin.js b/src/applications/_mock-form-ae-design-patterns/hooks/useMockedLogin.js index db016ed8a8e9..6c0c6cfff355 100644 --- a/src/applications/_mock-form-ae-design-patterns/hooks/useMockedLogin.js +++ b/src/applications/_mock-form-ae-design-patterns/hooks/useMockedLogin.js @@ -1,37 +1,51 @@ -import { useEffect } from 'react'; -import { useDispatch } from 'react-redux'; +import { useEffect, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { teardownProfileSession } from 'platform/user/profile/utilities'; import { updateLoggedInStatus } from 'platform/user/authentication/actions'; import { useLocalStorage } from './useLocalStorage'; export const useMockedLogin = () => { - const [, setHasSession] = useLocalStorage('hasSession', ''); + const [ + localHasSession, + setLocalHasSession, + clearLocalHasSession, + ] = useLocalStorage('hasSession', ''); + + const loggedInFromState = useSelector( + state => state?.user?.login?.currentlyLoggedIn, + ); + + const loggedIn = useMemo( + () => localHasSession === 'true' || loggedInFromState, + [localHasSession, loggedInFromState], + ); + const dispatch = useDispatch(); const logIn = () => { - setHasSession('true'); + setLocalHasSession('true'); dispatch(updateLoggedInStatus(true)); }; const logOut = () => { teardownProfileSession(); dispatch(updateLoggedInStatus(false)); - setHasSession(''); + clearLocalHasSession(); }; const useLoggedInQuery = location => { useEffect( () => { if (location?.query?.loggedIn === 'true') { - setHasSession('true'); + setLocalHasSession('true'); dispatch(updateLoggedInStatus(true)); } if (location?.query?.loggedIn === 'false') { teardownProfileSession(); dispatch(updateLoggedInStatus(false)); - setHasSession(''); + clearLocalHasSession(); } // having the pollTimeout present triggers some api calls to be made locally and in codespaces @@ -43,5 +57,5 @@ export const useMockedLogin = () => { ); }; - return { logIn, logOut, useLoggedInQuery }; + return { logIn, logOut, useLoggedInQuery, loggedIn }; }; diff --git a/src/applications/_mock-form-ae-design-patterns/mocks/endpoints/in-progress-forms/22-1990.js b/src/applications/_mock-form-ae-design-patterns/mocks/endpoints/in-progress-forms/22-1990.js index 3ba89f4f564f..ed27973d8926 100644 --- a/src/applications/_mock-form-ae-design-patterns/mocks/endpoints/in-progress-forms/22-1990.js +++ b/src/applications/_mock-form-ae-design-patterns/mocks/endpoints/in-progress-forms/22-1990.js @@ -18,7 +18,7 @@ const response = { street: USER.MAILING_ADDRESS.ADDRESS_LINE1, city: USER.MAILING_ADDRESS.CITY, state: USER.MAILING_ADDRESS.STATE_CODE, - country: USER.MAILING_ADDRESS.COUNTRY_CODE_ISO2, + country: USER.MAILING_ADDRESS.COUNTRY_CODE_ISO3, postalCode: USER.MAILING_ADDRESS.ZIP_CODE, isMilitary: false, }, diff --git a/src/applications/_mock-form-ae-design-patterns/mocks/endpoints/in-progress-forms/26-1880.js b/src/applications/_mock-form-ae-design-patterns/mocks/endpoints/in-progress-forms/26-1880.js index 51d26fb2e097..e9f49c9e0356 100644 --- a/src/applications/_mock-form-ae-design-patterns/mocks/endpoints/in-progress-forms/26-1880.js +++ b/src/applications/_mock-form-ae-design-patterns/mocks/endpoints/in-progress-forms/26-1880.js @@ -8,7 +8,7 @@ const response = { street: USER.MAILING_ADDRESS.ADDRESS_LINE1, city: USER.MAILING_ADDRESS.CITY, state: USER.MAILING_ADDRESS.STATE_CODE, - country: USER.MAILING_ADDRESS.COUNTRY_CODE_ISO2, + country: USER.MAILING_ADDRESS.COUNTRY_CODE_ISO3, postalCode: USER.MAILING_ADDRESS.ZIP_CODE, }, contactPhone: `${USER.HOME_PHONE.AREA_CODE}${USER.HOME_PHONE.PHONE_NUMBER}`, diff --git a/src/applications/_mock-form-ae-design-patterns/mocks/server.js b/src/applications/_mock-form-ae-design-patterns/mocks/server.js index 673b05df5270..d2f138bfe2bc 100644 --- a/src/applications/_mock-form-ae-design-patterns/mocks/server.js +++ b/src/applications/_mock-form-ae-design-patterns/mocks/server.js @@ -47,8 +47,8 @@ const responses = { res.json( generateFeatureToggles({ aedpVADX: true, - coeAccess: true, - profileUseExperimental: true, + coeAccess: false, + profileUseExperimental: false, }), ), secondsOfDelay, diff --git a/src/applications/_mock-form-ae-design-patterns/patterns/pattern2/TaskBlue/ProfileInformationEditView.jsx b/src/applications/_mock-form-ae-design-patterns/patterns/pattern2/TaskBlue/ProfileInformationEditView.jsx index 88b5281d5a73..edfa1bc9a2c4 100644 --- a/src/applications/_mock-form-ae-design-patterns/patterns/pattern2/TaskBlue/ProfileInformationEditView.jsx +++ b/src/applications/_mock-form-ae-design-patterns/patterns/pattern2/TaskBlue/ProfileInformationEditView.jsx @@ -346,7 +346,7 @@ export class ProfileInformationEditView extends Component { diff --git a/src/applications/_mock-form-ae-design-patterns/patterns/pattern2/TaskGray/shared/components/WIP.jsx b/src/applications/_mock-form-ae-design-patterns/patterns/pattern2/TaskGray/shared/components/WIP.jsx index 3ed42a296e1c..daec3c6743c6 100644 --- a/src/applications/_mock-form-ae-design-patterns/patterns/pattern2/TaskGray/shared/components/WIP.jsx +++ b/src/applications/_mock-form-ae-design-patterns/patterns/pattern2/TaskGray/shared/components/WIP.jsx @@ -1,7 +1,7 @@ import React from 'react'; export const WIP = () => ( -
+

diff --git a/src/applications/_mock-form-ae-design-patterns/shared/components/VADXPlugin.jsx b/src/applications/_mock-form-ae-design-patterns/shared/components/VADXPlugin.jsx index 4340475aac92..6fd29e4105bb 100644 --- a/src/applications/_mock-form-ae-design-patterns/shared/components/VADXPlugin.jsx +++ b/src/applications/_mock-form-ae-design-patterns/shared/components/VADXPlugin.jsx @@ -4,13 +4,42 @@ import { Link } from 'react-router'; export const VADXPlugin = () => { return (
-

VADX Plugin

-

- Pattern 1 -

-

- Pattern 2 -

+

VADX Plugin Example

+
+ Pattern 1 +
    +
  • + Task Green +
  • +
  • + + Task Yellow + +
  • +
  • + + Task Purple + +
  • +
+
+
+ Pattern 2 +
    +
  • + Task Orange +
  • +
  • + Task Gray +
  • +
  • + Task Blue +
  • +
  • + Post Study +
  • +
+
); }; diff --git a/src/applications/_mock-form-ae-design-patterns/vadx/context/vadx.js b/src/applications/_mock-form-ae-design-patterns/vadx/context/vadx.js index ddf30b708062..98bffcc69624 100644 --- a/src/applications/_mock-form-ae-design-patterns/vadx/context/vadx.js +++ b/src/applications/_mock-form-ae-design-patterns/vadx/context/vadx.js @@ -7,7 +7,7 @@ import React, { } from 'react'; import PropTypes from 'prop-types'; -import { debounce, isEqual } from 'lodash'; +import { debounce } from 'lodash'; import { useDispatch, useSelector } from 'react-redux'; import { useMockedLogin } from '../../hooks/useMockedLogin'; import { vadxPreferencesStorage } from '../utils/StorageAdapter'; @@ -22,10 +22,9 @@ import { setVadxToggles } from '../actions/toggles'; export const VADXContext = createContext(null); /** - * @param {Object} props + * @component VADXProvider * @param {React.ReactNode} props.children * @returns {React.ReactNode} - * @component */ export const VADXProvider = ({ children }) => { const [preferences, setPreferences] = useState({}); @@ -40,7 +39,7 @@ export const VADXProvider = ({ children }) => { ); // mock login functions - const { logIn, logOut } = useMockedLogin(); + const { logIn, logOut, loggedIn } = useMockedLogin(); useEffect( () => { @@ -120,10 +119,14 @@ export const VADXProvider = ({ children }) => { // update local toggles const updateLocalToggles = useCallback( - toggles => { - setSyncedData({ ...preferences, localToggles: toggles }); + async toggles => { + await setSyncedData({ + ...preferences, + localToggles: { ...toggles }, + }); + dispatch(setVadxToggles(toggles)); }, - [preferences, setSyncedData], + [preferences, setSyncedData, dispatch], ); // clear local toggles @@ -142,30 +145,12 @@ export const VADXProvider = ({ children }) => { const togglesState = useSelector(state => state?.featureToggles); - const localTogglesAreEmpty = useMemo( - () => isEqual(preferences?.localToggles, {}), - [preferences?.localToggles], - ); - - // check if the local toggles are not empty and not equal to the toggles state - const customLocalToggles = - !localTogglesAreEmpty && !isEqual(preferences?.localToggles, togglesState); - - // if the custom local toggles are true, then update the redux state for the toggles - useEffect( - () => { - if (customLocalToggles) { - dispatch(setVadxToggles(preferences?.localToggles)); - } - }, - [customLocalToggles, preferences?.localToggles, dispatch], - ); - return ( { + useEffect( + () => { + const handleKeyPress = event => { + // Check for Ctrl/Cmd + Shift + / + if ( + (event.ctrlKey || event.metaKey) && + event.shiftKey && + event.key === '\\' + ) { + event.preventDefault(); + setShowVADX(!showVADX); + } + }; + + document.addEventListener('keydown', handleKeyPress); + return () => document.removeEventListener('keydown', handleKeyPress); + }, + [showVADX, setShowVADX], + ); + return ( setShowVADX(!showVADX)} type="button"> diff --git a/src/applications/_mock-form-ae-design-patterns/vadx/panel/Panel.jsx b/src/applications/_mock-form-ae-design-patterns/vadx/panel/Panel.jsx index f5c6b45c66aa..f55bacd241bc 100644 --- a/src/applications/_mock-form-ae-design-patterns/vadx/panel/Panel.jsx +++ b/src/applications/_mock-form-ae-design-patterns/vadx/panel/Panel.jsx @@ -1,9 +1,9 @@ -import React, { useContext } from 'react'; +import React, { useCallback, useContext } from 'react'; import { PluginContext } from '../context/plugin'; import { VADXContext } from '../context/vadx'; -import Tabs from './Tabs'; +import Tabs from './tabs/Tabs'; import { FloatingButton } from './FloatingButton'; const VADXContainer = () => { @@ -12,9 +12,12 @@ const VADXContainer = () => { const showVADX = !!preferences?.showVADX; - const handleShowVADX = () => { - updateShowVADX(!showVADX); - }; + const handleShowVADX = useCallback( + () => { + updateShowVADX(!showVADX); + }, + [showVADX, updateShowVADX], + ); return ( <> diff --git a/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/FormTab.jsx b/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/FormTab.jsx deleted file mode 100644 index abb146c88791..000000000000 --- a/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/FormTab.jsx +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react'; -import { Link, withRouter } from 'react-router'; - -const getFormInfo = router => { - const childRoutes = router?.routes.map?.(route => { - if (route.childRoutes && route.childRoutes.length > 0) { - return route.childRoutes?.[0]; - } - return null; - }); - - // we are assuming the first route with config is the same for all routes - const firstRouteWithConfig = childRoutes.find(route => { - return route?.formConfig; - }, {}); - - const pageListFormatted = firstRouteWithConfig?.pageList?.map(page => { - return { - path: page?.path, - title: page?.title, - pageKey: page?.pageKey, - isActive: router?.location?.pathname === page?.path, - }; - }); - - return { - pageList: pageListFormatted, - formConfig: firstRouteWithConfig?.formConfig, - }; -}; - -const FormTabBase = props => { - const formInfo = getFormInfo(props.router); - - return ( -
    - {formInfo?.pageList ? ( - formInfo.pageList.map(page => ( -
  • - - {page.title || page.pageKey || page.path} - -
  • - )) - ) : ( -
  • No form system pages found
  • - )} -
- ); -}; - -export const FormTab = withRouter(FormTabBase); diff --git a/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/OtherTab.jsx b/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/OtherTab.jsx deleted file mode 100644 index dc303f130005..000000000000 --- a/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/OtherTab.jsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; - -import { useFocusedElement } from '../../hooks/useFocusedElement'; -import { HeadingHierarchyInspector } from '../HeadingHierarchyInspector'; - -export const OtherTab = () => { - const { displayString, onMouseEnter, onMouseLeave } = useFocusedElement(); - return ( -
-

- - {displayString || 'No element focused'} -

- - -
- ); -}; diff --git a/src/applications/_mock-form-ae-design-patterns/vadx/panel/Tabs.jsx b/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/Tabs.jsx similarity index 91% rename from src/applications/_mock-form-ae-design-patterns/vadx/panel/Tabs.jsx rename to src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/Tabs.jsx index 681ea5106bac..a5bf58709ea0 100644 --- a/src/applications/_mock-form-ae-design-patterns/vadx/panel/Tabs.jsx +++ b/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/Tabs.jsx @@ -1,10 +1,10 @@ import React, { useContext } from 'react'; import { VaButton } from '@department-of-veterans-affairs/component-library/dist/react-bindings'; import styled from 'styled-components'; -import { TogglesTab } from './tabs/TogglesTab'; -import { FormTab } from './tabs/FormTab'; -import { OtherTab } from './tabs/OtherTab'; -import { VADXContext } from '../context/vadx'; +import { TogglesTab } from './toggles/TogglesTab'; +import { FormTab } from './form/FormTab'; +import { OtherTab } from './other/OtherTab'; +import { VADXContext } from '../../context/vadx'; const VADXPanelDiv = styled.div` background-color: var(--vads-color-white); diff --git a/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/TogglesTab.jsx b/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/TogglesTab.jsx deleted file mode 100644 index adcd9a240747..000000000000 --- a/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/TogglesTab.jsx +++ /dev/null @@ -1,127 +0,0 @@ -import { - VaButton, - VaCheckbox, - VaSearchInput, -} from '@department-of-veterans-affairs/component-library/dist/react-bindings'; -import React, { useContext } from 'react'; -import environment from '~/platform/utilities/environment'; -import LoadingButton from 'platform/site-wide/loading-button/LoadingButton'; -import { VADXContext } from '../../context/vadx'; - -export const TogglesTab = () => { - const { - preferences, - updateDevLoading, - updateLocalToggles, - updateClearLocalToggles, - debouncedSetSearchQuery, - togglesLoading, - togglesState, - } = useContext(VADXContext); - - const { searchQuery, isDevLoading } = preferences; - - if (togglesLoading) { - return
Loading...
; - } - - const fetchDevToggles = async () => { - try { - updateDevLoading(true); - - const response = await fetch( - 'https://staging-api.va.gov/v0/feature_toggles', - ); - const { - data: { features }, - } = await response.json(); - - const formattedToggles = features - .filter(toggle => toggle.name.includes('_')) - .reduce((acc, toggle) => { - acc[toggle.name] = toggle.value; - return acc; - }, {}); - - updateLocalToggles(formattedToggles); - - updateDevLoading(false); - } catch (error) { - // eslint-disable-next-line no-console - console.error('Error fetching dev toggles:', error); - } - }; - - const filteredToggles = Object.keys(togglesState).filter(toggle => { - const toggleName = toggle?.toLowerCase?.() || ''; - const searchQueryLower = searchQuery?.toLowerCase?.() || ''; - return toggleName.includes(searchQueryLower) && toggle !== 'loading'; - }); - - return ( - <> - debouncedSetSearchQuery(e.target.value)} - onSubmit={e => debouncedSetSearchQuery(e.target.value)} - small - value={searchQuery} - /> -
- { - updateClearLocalToggles(); - window.location.reload(); - }} - text="Reset Toggles" - primary - /> - - {isDevLoading ? ( - - ) : ( - - )} - - {filteredToggles?.length} toggles - -
- - {Object.keys(togglesState).length < 2 && ( -
-

- - No toggles found. -

-

- Is your api running at {environment.API_URL}? -

-
- )} - - {filteredToggles.map(toggle => { - return ( - - { - const updatedToggles = { - ...togglesState, - [toggle]: e.target.checked, - }; - updateLocalToggles(updatedToggles); - }} - /> - - ); - })} - - ); -}; diff --git a/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/form/ChapterAnalyzer.jsx b/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/form/ChapterAnalyzer.jsx new file mode 100644 index 000000000000..8bab909996b5 --- /dev/null +++ b/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/form/ChapterAnalyzer.jsx @@ -0,0 +1,139 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router'; +import { useSelector } from 'react-redux'; + +const isReactComponent = component => { + return ( + component && + (typeof component === 'function' || + (component.$$typeof && + component.$$typeof.toString().includes('Symbol(react'))) + ); +}; + +const WarningBadge = ({ children }) => ( + + {children} + +); + +const PageAnalysis = ({ page, pageName, urlPrefix }) => { + const formData = useSelector(state => state?.form?.data || {}); + const hasCustomPage = isReactComponent(page?.CustomPage); + const hasCustomPageReview = isReactComponent(page?.CustomPageReview); + const hasUiSchema = page?.uiSchema && Object.keys(page?.uiSchema).length > 0; + const showWarning = (hasCustomPage || hasCustomPageReview) && hasUiSchema; + + const depends = page?.depends; + const hasDepends = !!depends; + const isVisible = !!( + depends && + typeof depends === 'function' && + depends(formData) + ); + + const path = `${urlPrefix}${page.path}`; + + return ( +
  • + + + + {page.title ? page.title : `${pageName}: no title`} + + + + {hasDepends && + (isVisible ? ( + + ) : ( + + ))} + {(hasCustomPage || hasCustomPageReview || showWarning) && ( + <> + {hasCustomPage && ( + + [CustomPage + {hasCustomPageReview && '+Review'}] + + )} + + {showWarning && ( + Warning: uiSchema may be ignored + )} + + )} + +
  • + ); +}; + +const ChapterAnalyzer = ({ formConfig, urlPrefix }) => { + if (!formConfig?.chapters) { + return ( +
    + No chapters or form config found +
    + ); + } + + return ( +
    +

    Form Structure

    +
    + {Object.entries(formConfig.chapters).map(([chapterKey, chapter]) => ( +
    +

    + {chapter?.title || chapterKey} +

    +
      + {chapter?.pages && + Object.entries(chapter.pages).map(([pageKey, page]) => ( + + ))} +
    +
    + ))} +
    +
    + ); +}; + +ChapterAnalyzer.propTypes = { + formConfig: PropTypes.shape({ + chapters: PropTypes.object.isRequired, + }), + urlPrefix: PropTypes.string, +}; + +PageAnalysis.propTypes = { + page: PropTypes.shape({ + title: PropTypes.string, + path: PropTypes.string, + depends: PropTypes.func, + CustomPage: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + CustomPageReview: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + uiSchema: PropTypes.object, + }).isRequired, + pageName: PropTypes.string.isRequired, + urlPrefix: PropTypes.string, +}; + +WarningBadge.propTypes = { + children: PropTypes.node.isRequired, +}; + +export default ChapterAnalyzer; diff --git a/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/form/ChapterAnalyzer.unit.spec.js b/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/form/ChapterAnalyzer.unit.spec.js new file mode 100644 index 000000000000..b1152846df5f --- /dev/null +++ b/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/form/ChapterAnalyzer.unit.spec.js @@ -0,0 +1,197 @@ +import React from 'react'; +import { expect } from 'chai'; +import { renderWithStoreAndRouter } from 'platform/testing/unit/react-testing-library-helpers'; +import ChapterAnalyzer from './ChapterAnalyzer'; + +const initialState = { + form: { + data: { + someField: 'value', + }, + }, +}; + +const path = '/form/name-birth'; + +const reducers = { + form: (state = initialState) => state, +}; + +describe('ChapterAnalyzer', () => { + describe('when no chapters exist on formConfig', () => { + it('should display no chapters found message', () => { + const { getByText } = renderWithStoreAndRouter( + , + { initialState, path, history: null, reducers }, + ); + + expect(getByText('No chapters or form config found')).to.exist; + }); + }); + + describe('with valid form config', () => { + let formConfig; + + beforeEach(() => { + formConfig = { + chapters: { + personalInfo: { + title: 'Personal Information', + pages: { + nameAndBirth: { + title: 'Name and Birth Date', + path: 'name-birth', + depends: () => true, + }, + contactInfo: { + title: 'Contact Information', + path: 'contact', + CustomPage: () => null, + uiSchema: { 'ui:title': 'Contact' }, + }, + }, + }, + }, + }; + }); + + it('should render chapter titles', () => { + const { getByText } = renderWithStoreAndRouter( + , + { initialState, path, history: null, reducers }, + ); + + expect(getByText('Personal Information')).to.exist; + }); + + it('should render page links', () => { + const { getByText } = renderWithStoreAndRouter( + , + { initialState, path, history: null, reducers }, + ); + + const pageTitleLink = getByText('Name and Birth Date').closest('a'); + + expect(pageTitleLink, 'Link element exists').to.not.be.null; + expect(pageTitleLink, 'Link is an anchor tag').to.have.property( + 'tagName', + 'A', + ); + }); + + it('should show warning badge when CustomPage and uiSchema exist together', () => { + const { getByText } = renderWithStoreAndRouter( + , + { initialState, path, history: null, reducers }, + ); + + expect(getByText('Warning: uiSchema may be ignored')).to.exist; + }); + + it('should show visibility indicator for pages with depends function', () => { + const { container } = renderWithStoreAndRouter( + , + { initialState, path, history: null, reducers }, + ); + + // Check for visibility icon + // have to use container.querySelector because + // visibility icon is web component + const visibilityIcon = container.querySelector( + 'va-icon[icon="visibility"]', + ); + expect(visibilityIcon).to.exist; + }); + + it('should show visibility_off indicator for pages with depends function that return false', () => { + formConfig.chapters.personalInfo.pages.nameAndBirth.depends = () => false; + + const { container } = renderWithStoreAndRouter( + , + { initialState, path, history: null, reducers }, + ); + + const visibilityOffIcon = container.querySelector( + 'va-icon[icon="visibility_off"]', + ); + expect(visibilityOffIcon).to.exist; + }); + + it('should handle pages without titles', () => { + const configWithoutTitle = { + chapters: { + chapter1: { + pages: { + page1: { + path: '/path', + }, + }, + }, + }, + }; + + const { getByText } = renderWithStoreAndRouter( + , + { initialState, path, history: null, reducers }, + ); + + expect(getByText('page1: no title')).to.exist; + }); + + it('should show warning badge when CustomPage and uiSchema exist together', () => { + const { getByText } = renderWithStoreAndRouter( + , + { initialState, path, history: null, reducers }, + ); + + expect(getByText('Warning: uiSchema may be ignored')).to.exist; + }); + }); + + describe('CustomPage indicators', () => { + it('should show CustomPage indicator', () => { + const config = { + chapters: { + chapter1: { + pages: { + page1: { + title: 'Test Page', + CustomPage: () => null, + }, + }, + }, + }, + }; + + const { getByText } = renderWithStoreAndRouter( + , + { initialState, path, history: null, reducers }, + ); + + expect(getByText('[CustomPage]')).to.exist; + }); + + it('should show CustomPage+Review indicator when both exist', () => { + const config = { + chapters: { + chapter1: { + pages: { + page1: { + title: 'Test Page', + CustomPage: () => null, + CustomPageReview: () => null, + }, + }, + }, + }, + }; + + const { getByText } = renderWithStoreAndRouter( + , + { initialState, path, history: null, reducers }, + ); + + expect(getByText('[CustomPage+Review]')).to.exist; + }); + }); +}); diff --git a/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/form/FormDataViewer.jsx b/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/form/FormDataViewer.jsx new file mode 100644 index 000000000000..936d32102c96 --- /dev/null +++ b/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/form/FormDataViewer.jsx @@ -0,0 +1,115 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +/** + * @component FormDataViewer + * @description Displays form data values in a read-only format. + * Skips objects where all nested values are undefined. + * + * @param {Object} props + * @param {Object} props.data - The form data object to display + */ +export const FormDataViewer = ({ data }) => { + if (!data || typeof data !== 'object') { + return

    No data available

    ; + } + + // Recursively check if an object has any defined values + const hasDefinedValues = obj => { + if (!obj || typeof obj !== 'object') { + return obj !== undefined; + } + + return Object.values(obj).some(value => { + if (value && typeof value === 'object') { + return hasDefinedValues(value); + } + return value !== undefined; + }); + }; + + const renderValue = (key, value) => { + // Skip undefined values + if (value === undefined) { + return null; + } + + // Skip objects with no defined values + if (value && typeof value === 'object' && !hasDefinedValues(value)) { + return null; + } + + switch (typeof value) { + case 'boolean': + return ( +
    + + {key}: + + {value ? 'Yes' : 'No'} +
    + ); + + case 'string': + case 'number': + if (!value && value !== 0) return null; + return ( +
    + + {key}: + + {value} +
    + ); + + case 'object': + if (value === null) { + return null; + } + + // Only render object if it has defined values + if (!hasDefinedValues(value)) { + return null; + } + + return ( +
    +

    + {key} +

    + +
    + ); + + default: + return null; + } + }; + + // If no defined values exist at all, show empty message + if (!hasDefinedValues(data)) { + return ( +
    +

    + No form data entered yet +

    +
    + ); + } + + return ( +
    + {Object.entries(data).map(([key, value]) => { + const rendered = renderValue(key, value); + return rendered ?
    {rendered}
    : null; + })} +
    + ); +}; + +FormDataViewer.propTypes = { + data: PropTypes.object, +}; diff --git a/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/form/FormTab.jsx b/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/form/FormTab.jsx new file mode 100644 index 000000000000..9830ee426e71 --- /dev/null +++ b/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/form/FormTab.jsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { Link, withRouter } from 'react-router'; +import { useSelector } from 'react-redux'; +import ChapterAnalyzer from './ChapterAnalyzer'; +import { FormDataViewer } from './FormDataViewer'; + +const getFormInfo = router => { + const childRoutes = router?.routes.map?.(route => { + if (route.childRoutes && route.childRoutes.length > 0) { + return route.childRoutes?.[0]; + } + return null; + }); + + // we are assuming the first route with config is the same for all routes + const firstRouteWithConfig = childRoutes.find(route => { + return route?.formConfig; + }, {}); + + const pageListFormatted = firstRouteWithConfig?.pageList?.map(page => { + return { + path: page?.path, + title: page?.title, + pageKey: page?.pageKey, + isActive: router?.location?.pathname === page?.path, + }; + }); + + return { + pageList: pageListFormatted, + formConfig: firstRouteWithConfig?.formConfig, + }; +}; + +const FormTabBase = props => { + const formInfo = getFormInfo(props.router); + const specialPages = formInfo?.pageList?.filter( + page => + page.path?.includes('/introduction') || + page.path?.includes('/review-and-submit'), + ); + + const formData = useSelector(state => state?.form?.data); + + return ( +
    +
    + {specialPages?.length > 0 + ? specialPages.map((page, index) => ( + + {page.title || page.pageKey || page.path} + + )) + : null} +
    + + +
    + ); +}; + +export const FormTab = withRouter(FormTabBase); diff --git a/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/other/ActiveElement.jsx b/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/other/ActiveElement.jsx new file mode 100644 index 000000000000..afee1eb40a8f --- /dev/null +++ b/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/other/ActiveElement.jsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { useFocusedElement } from '../../../hooks/useFocusedElement'; + +export const ActiveElement = () => { + const { displayString, onMouseEnter, onMouseLeave } = useFocusedElement(); + return ( +
    +

    Active element:

    +

    + + + {displayString || 'No element focused'} + +

    +
    + ); +}; diff --git a/src/applications/_mock-form-ae-design-patterns/vadx/panel/HeadingHierarchyInspector.jsx b/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/other/HeadingHierarchyInspector.jsx similarity index 91% rename from src/applications/_mock-form-ae-design-patterns/vadx/panel/HeadingHierarchyInspector.jsx rename to src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/other/HeadingHierarchyInspector.jsx index d9590c2acd49..b9b2cd795ef5 100644 --- a/src/applications/_mock-form-ae-design-patterns/vadx/panel/HeadingHierarchyInspector.jsx +++ b/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/other/HeadingHierarchyInspector.jsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import styled from 'styled-components'; import { withRouter } from 'react-router'; import PropTypes from 'prop-types'; -import HeadingHierarchyAnalyzer from '../utils/HeadingHierarchyAnalyzer'; +import HeadingHierarchyAnalyzer from '../../../utils/HeadingHierarchyAnalyzer'; const PreXs = styled.pre` font-size: 0.75rem; @@ -51,10 +51,11 @@ const HeadingHierarchyInspectorBase = ({ location }) => { ) : (
    -

    +

    + Heading Hierarchy {analysis?.issues?.length > 0 ? ( - - Heading Issues Found ({analysis.issues.length}) + + Issues Found ({analysis.issues.length}) ) : ( @@ -94,9 +95,6 @@ const HeadingHierarchyInspectorBase = ({ location }) => { )}

    -

    - Heading Hierarchy -

    {analysis && new HeadingHierarchyAnalyzer().generateTreeText( diff --git a/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/other/LoggedInState.jsx b/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/other/LoggedInState.jsx new file mode 100644 index 000000000000..c18ec30fe326 --- /dev/null +++ b/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/other/LoggedInState.jsx @@ -0,0 +1,19 @@ +import React, { useContext } from 'react'; +import { VADXContext } from '../../../context/vadx'; + +export const LoggedInState = () => { + const { logIn, logOut, loggedIn } = useContext(VADXContext); + + return ( +
    + + Logged In Status: {loggedIn ? 'true' : 'false'} + + +
    + ); +}; diff --git a/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/other/MemoryUsage.jsx b/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/other/MemoryUsage.jsx new file mode 100644 index 000000000000..29534cbd0ad8 --- /dev/null +++ b/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/other/MemoryUsage.jsx @@ -0,0 +1,36 @@ +import React, { useState, useEffect } from 'react'; + +export const MemoryUsage = () => { + const [memoryUsage, setMemoryUsage] = useState(null); + + useEffect(() => { + // Function to get browser tab's memory usage + const updateMemoryUsage = () => { + if (performance && performance.memory) { + const used = Math.round( + performance.memory.usedJSHeapSize / 1024 / 1024, + ); + const total = Math.round( + performance.memory.totalJSHeapSize / 1024 / 1024, + ); + setMemoryUsage({ used, total }); + } + }; + + updateMemoryUsage(); + + const interval = setInterval(updateMemoryUsage, 2000); + + return () => clearInterval(interval); + }, []); + + return memoryUsage ? ( +
    + + Memory Usage: {memoryUsage.used} + MB / {memoryUsage.total} + MB + +
    + ) : null; +}; diff --git a/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/other/OtherTab.jsx b/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/other/OtherTab.jsx new file mode 100644 index 000000000000..f1cf24d1cc2d --- /dev/null +++ b/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/other/OtherTab.jsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { HeadingHierarchyInspector } from './HeadingHierarchyInspector'; +import { MemoryUsage } from './MemoryUsage'; +import { ActiveElement } from './ActiveElement'; +import { LoggedInState } from './LoggedInState'; + +export const OtherTab = () => { + return ( +
    + + + + +
    + ); +}; diff --git a/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/toggles/TogglesTab.jsx b/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/toggles/TogglesTab.jsx new file mode 100644 index 000000000000..a6b2e96b269e --- /dev/null +++ b/src/applications/_mock-form-ae-design-patterns/vadx/panel/tabs/toggles/TogglesTab.jsx @@ -0,0 +1,180 @@ +import { + VaButton, + VaCheckbox, + VaSearchInput, +} from '@department-of-veterans-affairs/component-library/dist/react-bindings'; +import { snakeCase } from 'lodash'; +import React, { useCallback, useContext, useEffect, useMemo } from 'react'; +import FEATURE_FLAG_NAMES from 'platform/utilities/feature-toggles/featureFlagNames'; +import { useDispatch } from 'react-redux'; +import { connectFeatureToggle } from 'platform/utilities/feature-toggles'; +import { TOGGLE_VALUES_RESET } from 'platform/site-wide/feature-toggles/actionTypes'; +import { VADXContext } from '../../../context/vadx'; + +/** + * @component TogglesTab + * + * @description A tab component that manages and displays feature toggles + * This component provides functionality to: + * - Search through feature toggles + * - Reset toggles to their default values + * - Fetch toggles from the staging environment + * - Toggle individual features on/off + * + * @requires VADXContext - Context providing toggle state management and other VADX utilities + * + * @requires Redux - For global state management of feature toggles + * + * @note + * - Only displays toggles with underscore naming convention + * - Excludes toggles containing 'vadx' in their names + * - Maintains both camelCase and snake_case versions of toggle names for compatibility within state + * + */ +export const TogglesTab = () => { + const dispatch = useDispatch(); + const { + preferences, + updateDevLoading, + updateLocalToggles, + updateClearLocalToggles, + debouncedSetSearchQuery, + togglesLoading, + togglesState, + } = useContext(VADXContext); + + const handleResetToggles = useCallback( + () => { + updateDevLoading(true); + updateClearLocalToggles(); + dispatch({ type: TOGGLE_VALUES_RESET }); + dispatch(connectFeatureToggle); + updateDevLoading(false); + }, + [dispatch, updateDevLoading, updateClearLocalToggles], + ); + + const { searchQuery, isDevLoading } = preferences; + + useEffect(() => { + if (preferences?.localToggles) { + updateLocalToggles(preferences.localToggles); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const fetchDevToggles = useCallback( + async () => { + try { + updateDevLoading(true); + + const response = await fetch( + 'https://staging-api.va.gov/v0/feature_toggles', + ); + const { + data: { features }, + } = await response.json(); + + const formattedToggles = features + .filter(toggle => { + return toggle.name.includes('_') && !toggle.name.includes('vadx'); + }) + .reduce( + (acc, toggle) => { + acc[toggle.name] = toggle.value; + return acc; + }, + { + [FEATURE_FLAG_NAMES.aedpVADX]: true, + }, + ); + + updateLocalToggles(formattedToggles); + + updateDevLoading(false); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error fetching dev toggles:', error); + } + }, + [updateDevLoading, updateLocalToggles], + ); + + const filteredToggles = useMemo( + () => { + return Object.keys(togglesState).filter(toggle => { + const toggleName = toggle.toLowerCase(); + const searchQueryLower = searchQuery?.toLowerCase() || ''; + + return ( + toggle.includes('_') && + toggleName.includes(searchQueryLower) && + !toggleName.includes('vadx') && + toggle !== 'loading' + ); + }); + }, + [togglesState, searchQuery], + ); + + if (togglesLoading || isDevLoading) { + return
    Loading...
    ; + } + + return ( + <> + debouncedSetSearchQuery(e.target.value)} + onSubmit={e => debouncedSetSearchQuery(e.target.value)} + small + value={searchQuery} + /> +
    + + + + + + {filteredToggles?.length} toggles + +
    + + {Object.keys(togglesState).length < 2 && ( +
    +

    + + No toggles found. +

    +
    + )} + + {filteredToggles.map(toggle => { + const snakeCaseToggle = snakeCase(toggle); + return ( + + { + const updatedToggles = { + ...togglesState, + [toggle]: e.target.checked, + [snakeCaseToggle]: e.target.checked, + }; + updateLocalToggles(updatedToggles); + }} + /> + + ); + })} + + ); +}; diff --git a/src/platform/site-wide/feature-toggles/actionTypes.js b/src/platform/site-wide/feature-toggles/actionTypes.js index ec3011c3b06b..acfe63493426 100644 --- a/src/platform/site-wide/feature-toggles/actionTypes.js +++ b/src/platform/site-wide/feature-toggles/actionTypes.js @@ -1,3 +1,4 @@ export const FETCH_TOGGLE_VALUES_STARTED = 'FETCH_TOGGLE_VALUES_STARTED'; export const FETCH_TOGGLE_VALUES_SUCCEEDED = 'FETCH_TOGGLE_VALUES_SUCCEEDED'; export const TOGGLE_VALUES_SET = 'TOGGLE_VALUES_SET'; +export const TOGGLE_VALUES_RESET = 'TOGGLE_VALUES_RESET'; diff --git a/src/platform/site-wide/feature-toggles/reducers/index.js b/src/platform/site-wide/feature-toggles/reducers/index.js index b9042eeead10..bebd4ecf8724 100644 --- a/src/platform/site-wide/feature-toggles/reducers/index.js +++ b/src/platform/site-wide/feature-toggles/reducers/index.js @@ -2,6 +2,7 @@ import { TOGGLE_VALUES_SET, FETCH_TOGGLE_VALUES_STARTED, FETCH_TOGGLE_VALUES_SUCCEEDED, + TOGGLE_VALUES_RESET, } from '../actionTypes'; const INITIAL_STATE = {}; @@ -24,6 +25,8 @@ export const FeatureToggleReducer = (state = INITIAL_STATE, action) => { ...state, ...action.newToggleValues, }; + case TOGGLE_VALUES_RESET: + return INITIAL_STATE; default: return state; }