diff --git a/projects/packages/forms/changelog/update-use-config-forms b/projects/packages/forms/changelog/update-use-config-forms new file mode 100644 index 0000000000000..d4fdc0fa8ff12 --- /dev/null +++ b/projects/packages/forms/changelog/update-use-config-forms @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Forms: add new useConfigValue hook and start using it on the dashboard diff --git a/projects/packages/forms/src/blocks/contact-form/components/jetpack-integrations-modal/integration-card/plugin-action-button.tsx b/projects/packages/forms/src/blocks/contact-form/components/jetpack-integrations-modal/integration-card/plugin-action-button.tsx index 760bee10cd499..48a86aed5182d 100644 --- a/projects/packages/forms/src/blocks/contact-form/components/jetpack-integrations-modal/integration-card/plugin-action-button.tsx +++ b/projects/packages/forms/src/blocks/contact-form/components/jetpack-integrations-modal/integration-card/plugin-action-button.tsx @@ -7,7 +7,7 @@ import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import useFormsConfig from '../../../../../hooks/use-forms-config'; +import useConfigValue from '../../../../../hooks/use-config-value'; import { usePluginInstallation } from '../hooks/use-plugin-installation'; type PluginActionButtonProps = { @@ -34,9 +34,9 @@ const PluginActionButton = ( { trackEventName ); - const config = useFormsConfig(); - const canUserInstallPlugins = Boolean( config?.canInstallPlugins ); - const canUserActivatePlugins = Boolean( config?.canActivatePlugins ); + // Permissions from consolidated Forms config (shared across editor and dashboard) + const canUserInstallPlugins = useConfigValue( 'canInstallPlugins' ); + const canUserActivatePlugins = useConfigValue( 'canActivatePlugins' ); const canPerformAction = isInstalled ? canUserActivatePlugins : canUserInstallPlugins; const [ isReconcilingStatus, setIsReconcilingStatus ] = useState( false ); diff --git a/projects/packages/forms/src/blocks/contact-form/edit.tsx b/projects/packages/forms/src/blocks/contact-form/edit.tsx index 02c778c16ff29..34447f4cca5f2 100644 --- a/projects/packages/forms/src/blocks/contact-form/edit.tsx +++ b/projects/packages/forms/src/blocks/contact-form/edit.tsx @@ -33,7 +33,7 @@ import clsx from 'clsx'; /* * Internal dependencies */ -import useFormsConfig from '../../hooks/use-forms-config'; +import useConfigValue from '../../hooks/use-config-value'; import { store as singleStepStore } from '../../store/form-step-preview'; import { PREVIOUS_BUTTON_TEMPLATE, @@ -168,8 +168,7 @@ function JetpackContactFormEdit( { disableSummary, notificationRecipients, } = attributes; - const formsConfig = useFormsConfig(); - const showFormIntegrations = Boolean( formsConfig?.isIntegrationsEnabled ); + const showFormIntegrations = useConfigValue( 'isIntegrationsEnabled' ); const instanceId = useInstanceId( JetpackContactFormEdit ); // Backward compatibility for the deprecated customThankyou attribute. diff --git a/projects/packages/forms/src/dashboard/about/index.tsx b/projects/packages/forms/src/dashboard/about/index.tsx index 8a6d30eedb490..e666771f513e5 100644 --- a/projects/packages/forms/src/dashboard/about/index.tsx +++ b/projects/packages/forms/src/dashboard/about/index.tsx @@ -9,13 +9,13 @@ import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ +import useConfigValue from '../../hooks/use-config-value'; import AkismetIcon from '../../icons/akismet'; import CreativeMailIcon from '../../icons/creative-mail'; import GoogleSheetsIcon from '../../icons/google-sheets'; import SalesforceIcon from '../../icons/salesforce'; import CreateFormButton from '../components/create-form-button'; import Details from '../components/details'; -import { config } from '../index'; import PatternCard from './pattern-card'; import CheckSVG from './svg/check-svg'; import CloseSVG from './svg/close-svg'; @@ -32,7 +32,7 @@ import './style.scss'; import type { Pattern } from '../../types'; const About = () => { - const ASSETS_URL = useMemo( () => config( 'pluginAssetsURL' ), [] ); + const ASSETS_URL = useConfigValue( 'pluginAssetsURL' ); // Ensure config is loaded. const patterns: Pattern[] = useMemo( () => [ diff --git a/projects/packages/forms/src/dashboard/class-dashboard.php b/projects/packages/forms/src/dashboard/class-dashboard.php index 4bf18080707ed..caeac7c85c90c 100644 --- a/projects/packages/forms/src/dashboard/class-dashboard.php +++ b/projects/packages/forms/src/dashboard/class-dashboard.php @@ -11,9 +11,6 @@ use Automattic\Jetpack\Assets; use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State; use Automattic\Jetpack\Forms\ContactForm\Contact_Form_Plugin; -use Automattic\Jetpack\Forms\Jetpack_Forms; -use Automattic\Jetpack\Redirect; -use Automattic\Jetpack\Status; use Automattic\Jetpack\Tracking; if ( ! defined( 'ABSPATH' ) ) { @@ -151,29 +148,8 @@ public function add_new_admin_submenu() { * Render the dashboard. */ public function render_dashboard() { - if ( ! class_exists( 'Jetpack_AI_Helper' ) ) { - require_once JETPACK__PLUGIN_DIR . '_inc/lib/class-jetpack-ai-helper.php'; - } - - $ai_feature = \Jetpack_AI_Helper::get_ai_assistance_feature(); - $has_ai = ! is_wp_error( $ai_feature ) ? $ai_feature['has-feature'] : false; - - $config = array( - 'blogId' => get_current_blog_id(), - 'exportNonce' => wp_create_nonce( 'feedback_export' ), - 'newFormNonce' => wp_create_nonce( 'create_new_form' ), - 'gdriveConnectSupportURL' => esc_url( Redirect::get_url( 'jetpack-support-contact-form-export' ) ), - 'checkForSpamNonce' => wp_create_nonce( 'grunion_recheck_queue' ), - 'pluginAssetsURL' => Jetpack_Forms::assets_url(), - 'siteURL' => ( new Status() )->get_site_suffix(), - 'hasFeedback' => $this->has_feedback(), - 'hasAI' => $has_ai, - 'dashboardURL' => self::get_forms_admin_url(), - 'isMailpoetEnabled' => Jetpack_Forms::is_mailpoet_enabled(), - ); - ?> -
+
{ const location = useLocation(); const navigate = useNavigate(); const [ isSm ] = useBreakpointMatch( 'sm' ); - const formsConfig = useFormsConfig(); - const enableIntegrationsTab = Boolean( formsConfig?.isIntegrationsEnabled ); + const enableIntegrationsTab = useConfigValue( 'isIntegrationsEnabled' ); + const hasFeedback = useConfigValue( 'hasFeedback' ); + const isLoadingConfig = enableIntegrationsTab === undefined; const { currentStatus } = useSelect( select => ( { @@ -72,15 +72,15 @@ const Layout = () => { return path; } - return config( 'hasFeedback' ) ? 'responses' : 'about'; - }, [ location.pathname, tabs ] ); + return hasFeedback ? 'responses' : 'about'; + }, [ location.pathname, tabs, hasFeedback ] ); const isResponsesTab = getCurrentTab() === 'responses'; const handleTabSelect = useCallback( ( tabName: string ) => { if ( ! tabName ) { - tabName = config( 'hasFeedback' ) ? 'responses' : 'about'; + tabName = hasFeedback ? 'responses' : 'about'; } const currentTab = getCurrentTab(); @@ -98,7 +98,7 @@ const Layout = () => { search: tabName === 'responses' ? location.search : '', } ); }, - [ navigate, location.search, isSm, getCurrentTab ] + [ navigate, location.search, isSm, getCurrentTab, hasFeedback ] ); return ( @@ -124,15 +124,17 @@ const Layout = () => { ) } - - { () => } - + { ! isLoadingConfig && ( + + { () => } + + ) } ); }; diff --git a/projects/packages/forms/src/dashboard/components/response-view/body.tsx b/projects/packages/forms/src/dashboard/components/response-view/body.tsx index 7b33e8a4778ef..d9968f124b217 100644 --- a/projects/packages/forms/src/dashboard/components/response-view/body.tsx +++ b/projects/packages/forms/src/dashboard/components/response-view/body.tsx @@ -24,11 +24,11 @@ import clsx from 'clsx'; /** * Internal dependencies */ -import useFormsConfig from '../../../hooks/use-forms-config'; +import useConfigValue from '../../../hooks/use-config-value'; +import CopyClipboardButton from '../../components/copy-clipboard-button'; +import Gravatar from '../../components/gravatar'; import { useMarkAsSpam } from '../../hooks/use-mark-as-spam'; import { getPath, updateMenuCounter, updateMenuCounterOptimistically } from '../../inbox/utils'; -import CopyClipboardButton from '../copy-clipboard-button'; -import Gravatar from '../gravatar'; import type { FormResponse } from '../../../types'; const getDisplayName = response => { @@ -203,8 +203,7 @@ const ResponseViewBody = ( { const { editEntityRecord } = useDispatch( 'core' ); - const formsConfig = useFormsConfig(); - const emptyTrashDays = formsConfig?.emptyTrashDays ?? 0; + const emptyTrashDays = useConfigValue( 'emptyTrashDays' ) ?? 0; // When opening a "Mark as spam" link from the email, the ResponseViewBody component is rendered, so we use a hook here to handle it. const { isConfirmDialogOpen, onConfirmMarkAsSpam, onCancelMarkAsSpam } = useMarkAsSpam( diff --git a/projects/packages/forms/src/dashboard/hooks/use-create-form.ts b/projects/packages/forms/src/dashboard/hooks/use-create-form.ts index c7f3785ac5fa8..d1f0b80076f4c 100644 --- a/projects/packages/forms/src/dashboard/hooks/use-create-form.ts +++ b/projects/packages/forms/src/dashboard/hooks/use-create-form.ts @@ -5,7 +5,7 @@ import { useCallback } from '@wordpress/element'; /** * Internal dependencies */ -import { config } from '../index'; +import useConfigValue from '../../hooks/use-config-value'; type ClickHandlerProps = { formPattern?: string; @@ -24,30 +24,34 @@ type CreateFormReturn = { * @return {CreateFormReturn} The createForm and openNewForm functions. */ export default function useCreateForm(): CreateFormReturn { - const createForm = useCallback( async ( formPattern: string ) => { - const data = new FormData(); + const newFormNonce = useConfigValue( 'newFormNonce' ); + const createForm = useCallback( + async ( formPattern: string ) => { + const data = new FormData(); - data.append( 'action', 'create_new_form' ); - data.append( 'newFormNonce', config( 'newFormNonce' ) ); + data.append( 'action', 'create_new_form' ); + data.append( 'newFormNonce', newFormNonce ); - if ( formPattern ) { - data.append( 'pattern', formPattern ); - } + if ( formPattern ) { + data.append( 'pattern', formPattern ); + } - const response = await fetch( window.ajaxurl, { method: 'POST', body: data } ); + const response = await fetch( window.ajaxurl, { method: 'POST', body: data } ); - const { - success, - post_url: postUrl, - data: message, - }: { success?: boolean; data?: string; post_url?: string } = await response.json(); + const { + success, + post_url: postUrl, + data: message, + }: { success?: boolean; data?: string; post_url?: string } = await response.json(); - if ( success === false ) { - throw new Error( message ); - } + if ( success === false ) { + throw new Error( message ); + } - return postUrl; - }, [] ); + return postUrl; + }, + [ newFormNonce ] + ); const openNewForm = useCallback( async ( { formPattern, showPatterns, analyticsEvent }: ClickHandlerProps ) => { diff --git a/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts b/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts index 4bf38e269b73f..471515b565aaa 100644 --- a/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts +++ b/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts @@ -11,7 +11,7 @@ import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import { config } from '..'; +import useConfigValue from '../../hooks/use-config-value'; import { store as dashboardStore } from '../store'; type ExportHookReturn = { @@ -78,11 +78,13 @@ export default function useExportResponses(): ExportHookReturn { return { selected: getSelectedResponsesFromCurrentDataset(), currentQuery: getCurrentQuery() }; }, [] ); + const exportNonce = useConfigValue( 'exportNonce' ); + const onExport = useCallback( ( action: string, nonceName: string ) => { const data = new FormData(); data.append( 'action', action ); - data.append( nonceName, config( 'exportNonce' ) ); + data.append( nonceName, exportNonce ); selected.forEach( ( id: string ) => data.append( 'selected[]', id ) ); data.append( 'post', currentQuery.parent || 'all' ); data.append( 'search', currentQuery.search || '' ); @@ -95,7 +97,7 @@ export default function useExportResponses(): ExportHookReturn { return fetch( window.ajaxurl, { method: 'POST', body: data } ); }, - [ currentQuery, selected ] + [ currentQuery, selected, exportNonce ] ); useEffect( () => { diff --git a/projects/packages/forms/src/dashboard/inbox/empty-responses.tsx b/projects/packages/forms/src/dashboard/inbox/empty-responses.tsx index dfc2369d97da9..25c6a35b69036 100644 --- a/projects/packages/forms/src/dashboard/inbox/empty-responses.tsx +++ b/projects/packages/forms/src/dashboard/inbox/empty-responses.tsx @@ -3,7 +3,7 @@ import { __experimentalVStack as VStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis } from '@wordpress/components'; import { __, _n, sprintf } from '@wordpress/i18n'; -import useFormsConfig from '../../hooks/use-forms-config'; +import useConfigValue from '../../hooks/use-config-value'; const EmptyWrapper = ( { heading = '', body = '' } ) => ( @@ -22,8 +22,7 @@ type EmptyResponsesProps = { }; const EmptyResponses = ( { status, isSearch }: EmptyResponsesProps ) => { - const formsConfig = useFormsConfig(); - const emptyTrashDays = formsConfig?.emptyTrashDays ?? 0; + const emptyTrashDays = useConfigValue( 'emptyTrashDays' ) ?? 0; const searchHeading = __( 'No results found', 'jetpack-forms' ); const searchMessage = __( diff --git a/projects/packages/forms/src/dashboard/inbox/export-responses/google-drive.tsx b/projects/packages/forms/src/dashboard/inbox/export-responses/google-drive.tsx index a5cbc0299b5b4..275e9d811931b 100644 --- a/projects/packages/forms/src/dashboard/inbox/export-responses/google-drive.tsx +++ b/projects/packages/forms/src/dashboard/inbox/export-responses/google-drive.tsx @@ -13,7 +13,7 @@ import clsx from 'clsx'; /** * Internal dependencies */ -import { config } from '../..'; +import useConfigValue from '../../../hooks/use-config-value'; import { INTEGRATIONS_STORE } from '../../../store/integrations'; import { PARTIAL_RESPONSES_PATH } from '../../../util/get-preferred-responses-view'; /** @@ -31,6 +31,7 @@ const GoogleDriveExport = ( { onExport, autoConnect = false } ) => { }, [] ) as { integration?: Integration }; const { refreshIntegrations } = useDispatch( INTEGRATIONS_STORE ) as IntegrationsDispatch; const isConnectedToGoogleDrive = !! integration?.isConnected; + const gdriveConnectSupportURL = useConfigValue( 'gdriveConnectSupportURL' ); const { tracks } = useAnalytics(); const autoConnectOpened = useRef( false ); const [ isTogglingConnection, setIsTogglingConnection ] = useState( false ); @@ -110,7 +111,7 @@ const GoogleDriveExport = ( { onExport, autoConnect = false } ) => { <>   { const navigate = useNavigate(); + const hasFeedback = useConfigValue( 'hasFeedback' ); + // If a user has no responses yet, redirect them to the landing page. useEffect( () => { - if ( config( 'hasFeedback' ) ) { + if ( hasFeedback !== false ) { return; } - navigate( '/landing' ); - }, [ navigate ] ); + navigate( '/about' ); + }, [ navigate, hasFeedback ] ); + + if ( hasFeedback === undefined ) { + return null; // or a loading spinner, if you prefer + } return ; }; diff --git a/projects/packages/forms/src/dashboard/index.tsx b/projects/packages/forms/src/dashboard/index.tsx index 03b2e9f03e16f..6ed88bb051605 100644 --- a/projects/packages/forms/src/dashboard/index.tsx +++ b/projects/packages/forms/src/dashboard/index.tsx @@ -3,7 +3,7 @@ */ import { ThemeProvider } from '@automattic/jetpack-components'; import { createRoot } from '@wordpress/element'; -import { createHashRouter, Navigate } from 'react-router'; +import { createHashRouter } from 'react-router'; import { RouterProvider } from 'react-router/dom'; /** * Internal dependencies @@ -15,16 +15,9 @@ import Integrations from './integrations'; import DashboardNotices from './notices-list'; import './style.scss'; -let settings = {}; - -export const config = ( key: string ) => settings?.[ key ]; - window.addEventListener( 'load', () => { const container = document.getElementById( 'jp-forms-dashboard' ); - settings = JSON.parse( decodeURIComponent( container.dataset.config ) ); - delete container.dataset.config; - const router = createHashRouter( [ { path: '/', @@ -32,7 +25,7 @@ window.addEventListener( 'load', () => { children: [ { index: true, - element: , + element: , }, { path: 'responses', diff --git a/projects/packages/forms/src/hooks/use-config-value.ts b/projects/packages/forms/src/hooks/use-config-value.ts new file mode 100644 index 0000000000000..729b9c0fbbf54 --- /dev/null +++ b/projects/packages/forms/src/hooks/use-config-value.ts @@ -0,0 +1,31 @@ +import { useSelect } from '@wordpress/data'; +import { CONFIG_STORE } from '../store/config'; +import type { ConfigSelectors } from '../store/config/types'; +import type { FormsConfigData } from '../types'; + +/** + * Hook to get a specific config value from the forms config store. + * Automatically fetches config from /wp/v2/feedback/config if not already loaded. + * Config data is cached and won't refetch unless invalidated. + * + * @param key - The config key to retrieve + * @return The config value, or undefined if not yet loaded or if the key doesn't exist + * + * @example + * const isMailPoetEnabled = useConfigValue( 'isMailPoetEnabled' ); + * const hasAI = useConfigValue( 'hasAI' ); + */ +export default function useConfigValue< K extends keyof FormsConfigData >( + key: K +): FormsConfigData[ K ] | undefined { + return useSelect( + select => { + const configSelect = select( CONFIG_STORE ) as ConfigSelectors; + // Trigger getConfig resolver (which fetches all config data) + const config = configSelect.getConfig(); + // Return the specific key value + return config?.[ key ]; + }, + [ key ] + ); +} diff --git a/projects/packages/forms/src/store/config/README.md b/projects/packages/forms/src/store/config/README.md new file mode 100644 index 0000000000000..5d55785ee9a13 --- /dev/null +++ b/projects/packages/forms/src/store/config/README.md @@ -0,0 +1,376 @@ +# Forms Config Store + +A Redux-style store for managing Jetpack Forms configuration data. The store automatically fetches and caches data from the `/wp/v2/feedback/config` REST API endpoint. + +## Overview + +The config store provides a centralized way to access Forms configuration data across your application. It handles async fetching, caching, loading states, and errors automatically. + +## Quick Start + +The simplest way to use the config store is with the `useConfigValue` hook: + +```typescript +import useConfigValue from '../hooks/use-config-value'; + +function MyComponent() { + const isMailPoetEnabled = useConfigValue('isMailPoetEnabled'); + const hasAI = useConfigValue('hasAI'); + const blogId = useConfigValue('blogId'); + + if (isMailPoetEnabled === undefined) { + return
Loading...
; + } + + return
MailPoet is {isMailPoetEnabled ? 'enabled' : 'disabled'}
; +} +``` + +## Available Config Keys + +The store provides access to the following configuration values (see `FormsConfigData` type): + +- `isMailPoetEnabled` - Whether MailPoet integration is enabled +- `isIntegrationsEnabled` - Whether integrations UI is enabled +- `canInstallPlugins` - Whether the current user can install plugins +- `canActivatePlugins` - Whether the current user can activate plugins +- `hasFeedback` - Whether there are any form responses on the site +- `hasAI` - Whether AI Assist features are available +- `formsResponsesUrl` - URL of the Forms responses list in wp-admin +- `blogId` - Current site blog ID +- `gdriveConnectSupportURL` - Support URL for Google Drive connect guidance +- `pluginAssetsURL` - Base URL to static/assets for the Forms package +- `siteURL` - The site suffix/fragment for building admin links +- `dashboardURL` - The dashboard URL with migration acknowledgement parameter +- `exportNonce` - Nonce for exporting feedback responses +- `newFormNonce` - Nonce for creating a new form +- `emptyTrashDays` - Number of days before WordPress permanently deletes trash + +## Usage Examples + +### Basic Hook Usage + +```typescript +import useConfigValue from '../hooks/use-config-value'; + +function ExampleComponent() { + const hasAI = useConfigValue('hasAI'); + + return hasAI ? : ; +} +``` + +### Using Multiple Config Values + +```typescript +import useConfigValue from '../hooks/use-config-value'; + +function DashboardSettings() { + const canInstall = useConfigValue('canInstallPlugins'); + const canActivate = useConfigValue('canActivatePlugins'); + const responsesUrl = useConfigValue('formsResponsesUrl'); + + return ( +
+ View Responses + {canInstall && } + {canActivate && } +
+ ); +} +``` + +### Advanced: Direct Store Access + +For more control, you can use the store directly with `@wordpress/data`: + +```typescript +import { useSelect, useDispatch } from '@wordpress/data'; +import { CONFIG_STORE } from '../store/config'; + +function AdvancedComponent() { + // Get the entire config object + const config = useSelect( + select => select(CONFIG_STORE).getConfig(), + [] + ); + + // Get a specific value + const hasAI = useSelect( + select => select(CONFIG_STORE).getConfigValue('hasAI'), + [] + ); + + // Check loading state + const isLoading = useSelect( + select => select(CONFIG_STORE).isConfigLoading(), + [] + ); + + // Get error state + const error = useSelect( + select => select(CONFIG_STORE).getConfigError(), + [] + ); + + // Get dispatch actions + const { refreshConfig, invalidateConfig } = useDispatch(CONFIG_STORE); + + if (isLoading) return ; + if (error) return ; + + return ( +
+
{JSON.stringify(config, null, 2)}
+ +
+ ); +} +``` + +### Handling Loading States + +```typescript +import useConfigValue from '../hooks/use-config-value'; + +function ComponentWithLoading() { + const blogId = useConfigValue('blogId'); + + // Value is undefined while loading or if it doesn't exist + if (blogId === undefined) { + return ; + } + + return
Blog ID: {blogId}
; +} +``` + +### Force Refresh Config + +```typescript +import { useDispatch } from '@wordpress/data'; +import { CONFIG_STORE } from '../store/config'; + +function RefreshButton() { + const { refreshConfig } = useDispatch(CONFIG_STORE); + + const handleRefresh = async () => { + await refreshConfig(); + console.log('Config refreshed!'); + }; + + return ; +} +``` + +### Invalidate Cache + +```typescript +import { useDispatch } from '@wordpress/data'; +import { CONFIG_STORE } from '../store/config'; + +function ResetButton() { + const { invalidateConfig } = useDispatch(CONFIG_STORE); + + // Invalidating will cause the next access to re-fetch + return ; +} +``` + +## Store API Reference + +### Selectors + +- `getConfig()` - Returns the entire config object or null if not loaded +- `getConfigValue(key)` - Returns the value for a specific config key +- `isConfigLoading()` - Returns true if config is currently being fetched +- `getConfigError()` - Returns error message if fetch failed, null otherwise + +### Actions + +- `refreshConfig()` - Force re-fetch the config from the API +- `invalidateConfig()` - Clear the cached config (next access will re-fetch) +- `receiveConfig(config)` - Manually set the config data +- `receiveConfigValue(key, value)` - Manually set a single config value +- `setConfigLoading(isLoading)` - Set the loading state +- `setConfigError(error)` - Set the error state + +## How It Works + +1. **Automatic Fetching**: The first time you access config data, the store automatically fetches it from `/wp/v2/feedback/config` +2. **Caching**: Once fetched, the config is cached in the Redux store and won't be re-fetched unless you explicitly invalidate it +3. **Request Deduplication**: Multiple simultaneous calls to `useConfigValue()` with different keys trigger only ONE API request +4. **Resolvers**: The store uses WordPress data resolvers to handle async fetching automatically +5. **Type Safety**: Full TypeScript support ensures you only access valid config keys + +### Resolver Behavior + +The config store has one resolver: `getConfig` + +**How `useConfigValue` works:** + +When you call `useConfigValue('hasAI')`: +1. The hook internally calls `getConfig()` selector to fetch the entire config object +2. WordPress automatically triggers the `getConfig` resolver if config isn't loaded +3. The resolver checks if config is already loaded or currently loading via `isFulfilled` +4. If not loaded, it fetches from `/wp/v2/feedback/config` +5. Once loaded, it returns the value for the specific key (`config.hasAI`) +6. Subsequent calls to `useConfigValue()` with any key use the cached config + +**Request Deduplication:** + +Multiple components calling different config values simultaneously: +```typescript +// Component A +const hasAI = useConfigValue('hasAI'); + +// Component B +const blogId = useConfigValue('blogId'); + +// Component C +const canInstall = useConfigValue('canInstallPlugins'); +``` + +All three calls trigger the same `getConfig` resolver, but the resolver's `isFulfilled` check ensures only ONE API request is made. All components receive their respective values from the same fetched config object. + +## Benefits + +- **Performance**: Config is fetched once and cached, avoiding redundant API calls +- **Request Deduplication**: Multiple simultaneous calls with different keys result in only one API request +- **Consistency**: All components get the same config data from a single source +- **Type Safety**: TypeScript autocomplete helps you use the correct config keys +- **Async by Default**: All config fetching happens asynchronously without blocking UI +- **Error Handling**: Built-in loading and error states make it easy to handle failures +- **Standard Pattern**: Follows WordPress data API best practices + +## Implementation Details + +### isFulfilled Check + +The resolver uses an `isFulfilled` function to prevent duplicate requests: + +```typescript +isFulfilled: (state: ConfigState) => { + // Consider fulfilled if config exists or is currently loading + return state.config !== null || state.isLoading; +} +``` + +This ensures that: +- If config is already loaded, no fetch occurs +- If a fetch is in progress (`isLoading: true`), subsequent calls wait for the same fetch +- Only the first call actually triggers the API request + +### Store Structure + +```typescript +type ConfigState = { + config: Partial | null; + isLoading: boolean; + error: string | null; +}; +``` + +## Adding a New Config Key + +To add a new configuration value to the config store, follow these steps: + +### 1. Update the PHP Endpoint + +First, add your new config value to the REST API endpoint response. In the Forms package, this is typically done in the endpoint handler: + +```php +// src/contact-form/class-contact-form-endpoint.php +public function get_config() { + return array( + 'isMailPoetEnabled' => $this->is_mailpoet_enabled(), + 'hasAI' => $this->has_ai(), + // Add your new key here + 'myNewFeature' => $this->check_my_new_feature(), + ); +} +``` + +### 2. Update the TypeScript Type Definition + +Add the new key to the `FormsConfigData` interface in `src/types/index.ts`: + +```typescript +export interface FormsConfigData { + /** Whether MailPoet integration is enabled across contexts. */ + isMailPoetEnabled?: boolean; + + /** Whether AI Assist features are available for the site/user. */ + hasAI?: boolean; + + /** Whether my new feature is enabled. */ + myNewFeature?: boolean; // Add your new key with proper JSDoc + + // ... other keys +} +``` + +**Important Notes:** +- Add JSDoc comments to document what the config value represents +- Use optional properties (`?`) since not all config values may be present in all contexts +- Use appropriate TypeScript types (`boolean`, `string`, `number`, etc.) + +### 3. Update the README + +Add your new config key to the "Available Config Keys" section above: + +```markdown +- `myNewFeature` - Whether my new feature is enabled +``` + +### 4. Use the New Config Value + +Now you can use your new config value in any component: + +```typescript +import useConfigValue from '../hooks/use-config-value'; + +function MyComponent() { + const myNewFeature = useConfigValue('myNewFeature'); + + if (myNewFeature === undefined) { + return ; + } + + return myNewFeature ? : ; +} +``` + +### Complete Example + +Here's a complete example of adding a new `showBetaFeatures` config: + +**1. PHP (endpoint):** +```php +return array( + 'showBetaFeatures' => current_user_can('manage_options') && get_option('jetpack_forms_beta_features', false), +); +``` + +**2. TypeScript (`src/types/index.ts`):** +```typescript +export interface FormsConfigData { + /** Whether beta features should be shown to the current user. */ + showBetaFeatures?: boolean; + // ... other keys +} +``` + +**3. README:** +```markdown +- `showBetaFeatures` - Whether beta features should be shown to the current user +``` + +**4. Usage:** +```typescript +function BetaFeatureToggle() { + const showBeta = useConfigValue('showBetaFeatures'); + + return showBeta && ; +} +``` diff --git a/projects/packages/forms/src/store/config/action-types.ts b/projects/packages/forms/src/store/config/action-types.ts new file mode 100644 index 0000000000000..c721c47afee87 --- /dev/null +++ b/projects/packages/forms/src/store/config/action-types.ts @@ -0,0 +1,5 @@ +export const RECEIVE_CONFIG = 'RECEIVE_CONFIG'; +export const RECEIVE_CONFIG_VALUE = 'RECEIVE_CONFIG_VALUE'; +export const INVALIDATE_CONFIG = 'INVALIDATE_CONFIG'; +export const SET_CONFIG_LOADING = 'SET_CONFIG_LOADING'; +export const SET_CONFIG_ERROR = 'SET_CONFIG_ERROR'; diff --git a/projects/packages/forms/src/store/config/actions.ts b/projects/packages/forms/src/store/config/actions.ts new file mode 100644 index 0000000000000..dfa76efc7b37c --- /dev/null +++ b/projects/packages/forms/src/store/config/actions.ts @@ -0,0 +1,40 @@ +import { + RECEIVE_CONFIG, + RECEIVE_CONFIG_VALUE, + INVALIDATE_CONFIG, + SET_CONFIG_LOADING, + SET_CONFIG_ERROR, +} from './action-types'; +import { getConfig } from './resolvers'; +import type { FormsConfigData } from '../../types'; + +export const receiveConfig = ( config: Partial< FormsConfigData > ) => ( { + type: RECEIVE_CONFIG, + config, +} ); + +export const receiveConfigValue = < K extends keyof FormsConfigData >( + key: K, + value: FormsConfigData[ K ] +) => ( { + type: RECEIVE_CONFIG_VALUE, + key, + value, +} ); + +export const invalidateConfig = () => ( { + type: INVALIDATE_CONFIG, +} ); + +export const setConfigLoading = ( isLoading: boolean ) => ( { + type: SET_CONFIG_LOADING, + isLoading, +} ); + +export const setConfigError = ( error: string | null ) => ( { + type: SET_CONFIG_ERROR, + error, +} ); + +// Thunk-like action to immediately refresh from the endpoint +export const refreshConfig = () => getConfig(); diff --git a/projects/packages/forms/src/store/config/index.ts b/projects/packages/forms/src/store/config/index.ts new file mode 100644 index 0000000000000..da230581cb370 --- /dev/null +++ b/projects/packages/forms/src/store/config/index.ts @@ -0,0 +1,20 @@ +import { createReduxStore, register } from '@wordpress/data'; +import * as actions from './actions'; +import reducer from './reducer'; +import * as resolvers from './resolvers'; +import * as selectors from './selectors'; + +export const CONFIG_STORE = 'jetpack/forms/config'; + +export const store = createReduxStore( CONFIG_STORE, { + reducer, + actions, + selectors, + resolvers, +} ); + +register( store ); + +export * from './actions'; +export * from './selectors'; +export * from './types'; diff --git a/projects/packages/forms/src/store/config/reducer.ts b/projects/packages/forms/src/store/config/reducer.ts new file mode 100644 index 0000000000000..9d0a24f14ad9a --- /dev/null +++ b/projects/packages/forms/src/store/config/reducer.ts @@ -0,0 +1,65 @@ +import { UNKNOWN_ERROR_MESSAGE } from '../constants'; +import { + RECEIVE_CONFIG, + RECEIVE_CONFIG_VALUE, + INVALIDATE_CONFIG, + SET_CONFIG_LOADING, + SET_CONFIG_ERROR, +} from './action-types'; +import type { ConfigState, ConfigAction } from './types'; + +const DEFAULT_STATE: ConfigState = { + config: null, + isLoading: false, + error: null, +}; + +/** + * Config store reducer. + * + * @param state - Current state + * @param action - Dispatched action + * @return Updated state + */ +export default function reducer( + state: ConfigState = DEFAULT_STATE, + action: ConfigAction +): ConfigState { + switch ( action.type ) { + case SET_CONFIG_LOADING: + return { + ...state, + isLoading: !! action.isLoading, + error: action.isLoading ? null : state.error, + }; + case SET_CONFIG_ERROR: + return { + ...state, + isLoading: false, + error: action.error ?? UNKNOWN_ERROR_MESSAGE, + }; + case RECEIVE_CONFIG: + return { + ...state, + config: action.config ?? null, + isLoading: false, + error: null, + }; + case RECEIVE_CONFIG_VALUE: + return { + ...state, + config: { + ...( state.config ?? {} ), + [ action.key as string ]: action.value, + }, + }; + case INVALIDATE_CONFIG: + return { + ...state, + config: null, + isLoading: false, + }; + default: + return state; + } +} diff --git a/projects/packages/forms/src/store/config/resolvers.ts b/projects/packages/forms/src/store/config/resolvers.ts new file mode 100644 index 0000000000000..e20e7fe75fd33 --- /dev/null +++ b/projects/packages/forms/src/store/config/resolvers.ts @@ -0,0 +1,41 @@ +import apiFetch from '@wordpress/api-fetch'; +import { UNKNOWN_ERROR_MESSAGE } from '../constants'; +import { INVALIDATE_CONFIG } from './action-types'; +import { receiveConfig, setConfigError, setConfigLoading } from './actions'; +import type { ConfigAction, ConfigState } from './types'; +import type { FormsConfigData } from '../../types'; + +const fetchConfigData = async ( dispatch: ( action: ConfigAction ) => void ) => { + dispatch( setConfigLoading( true ) ); + try { + const result = await apiFetch< FormsConfigData >( { + path: '/wp/v2/feedback/config', + } ); + dispatch( receiveConfig( result ) ); + } catch ( e ) { + const message = e instanceof Error ? e.message : UNKNOWN_ERROR_MESSAGE; + dispatch( setConfigError( message ) ); + } finally { + dispatch( setConfigLoading( false ) ); + } +}; + +/** + * Resolver to fetch config data. + * + * @return {Function} The resolver function. + */ +export function getConfig() { + return async ( { dispatch }: { dispatch: ( action: ConfigAction ) => void } ) => { + await fetchConfigData( dispatch ); + }; +} + +getConfig.isFulfilled = ( state: ConfigState ) => { + // Consider fulfilled if config exists or is currently loading + return state.config !== null || state.isLoading; +}; + +getConfig.shouldInvalidate = ( action: ConfigAction ) => { + return action.type === INVALIDATE_CONFIG; +}; diff --git a/projects/packages/forms/src/store/config/selectors.ts b/projects/packages/forms/src/store/config/selectors.ts new file mode 100644 index 0000000000000..eed0fc314c3dc --- /dev/null +++ b/projects/packages/forms/src/store/config/selectors.ts @@ -0,0 +1,13 @@ +import type { ConfigState } from './types'; +import type { FormsConfigData } from '../../types'; + +export const getConfig = ( state: ConfigState ): Partial< FormsConfigData > | null => state.config; + +export const getConfigValue = < K extends keyof FormsConfigData >( + state: ConfigState, + key: K +): FormsConfigData[ K ] | undefined => state.config?.[ key ]; + +export const isConfigLoading = ( state: ConfigState ): boolean => state.isLoading; + +export const getConfigError = ( state: ConfigState ): string | null => state.error; diff --git a/projects/packages/forms/src/store/config/types.ts b/projects/packages/forms/src/store/config/types.ts new file mode 100644 index 0000000000000..824d941777dc7 --- /dev/null +++ b/projects/packages/forms/src/store/config/types.ts @@ -0,0 +1,31 @@ +import { CONFIG_STORE } from '.'; +import type { FormsConfigData } from '../../types'; + +export type ConfigState = { + config: Partial< FormsConfigData > | null; + isLoading: boolean; + error: string | null; +}; + +export type ConfigAction = { + type: string; + config?: Partial< FormsConfigData >; + key?: keyof FormsConfigData; + value?: unknown; + isLoading?: boolean; + error?: string | null; +}; + +export type ConfigSelectors = { + getConfig: () => Partial< FormsConfigData > | null; + getConfigValue: < K extends keyof FormsConfigData >( key: K ) => FormsConfigData[ K ] | undefined; + isConfigLoading: () => boolean; + getConfigError: () => string | null; +}; + +export type ConfigDispatch = { + refreshConfig: () => Promise< void >; + invalidateConfig: () => void; +}; + +export type SelectConfig = ( store: typeof CONFIG_STORE ) => ConfigSelectors; diff --git a/projects/packages/forms/src/store/constants.ts b/projects/packages/forms/src/store/constants.ts new file mode 100644 index 0000000000000..9b43fbfabd6ab --- /dev/null +++ b/projects/packages/forms/src/store/constants.ts @@ -0,0 +1,4 @@ +import { __ } from '@wordpress/i18n'; + +const UNKNOWN_ERROR_MESSAGE = __( 'Unknown error', 'jetpack-forms' ); +export { UNKNOWN_ERROR_MESSAGE }; diff --git a/projects/packages/forms/src/store/integrations/reducer.ts b/projects/packages/forms/src/store/integrations/reducer.ts index 66107d801b580..08cc6290a4578 100644 --- a/projects/packages/forms/src/store/integrations/reducer.ts +++ b/projects/packages/forms/src/store/integrations/reducer.ts @@ -1,4 +1,4 @@ -import { __ } from '@wordpress/i18n'; +import { UNKNOWN_ERROR_MESSAGE } from '../constants'; import { RECEIVE_INTEGRATIONS, INVALIDATE_INTEGRATIONS, @@ -35,7 +35,7 @@ export default function reducer( return { ...state, isLoading: false, - error: action.error ?? __( 'Unknown error', 'jetpack-forms' ), + error: action.error ?? UNKNOWN_ERROR_MESSAGE, }; case RECEIVE_INTEGRATIONS: return { diff --git a/projects/packages/forms/src/store/integrations/resolvers.ts b/projects/packages/forms/src/store/integrations/resolvers.ts index 7174fb97da59f..aec39c61c77fd 100644 --- a/projects/packages/forms/src/store/integrations/resolvers.ts +++ b/projects/packages/forms/src/store/integrations/resolvers.ts @@ -1,6 +1,6 @@ import apiFetch from '@wordpress/api-fetch'; -import { __ } from '@wordpress/i18n'; import { addQueryArgs } from '@wordpress/url'; +import { UNKNOWN_ERROR_MESSAGE } from '../constants'; import { INVALIDATE_INTEGRATIONS } from './action-types'; import { receiveIntegrations, setIntegrationsError, setIntegrationsLoading } from './actions'; import type { IntegrationsAction } from './types'; @@ -15,7 +15,7 @@ export const getIntegrations = const result = await apiFetch< Integration[] >( { path } ); dispatch( receiveIntegrations( result ) ); } catch ( e ) { - const message = e instanceof Error ? e.message : __( 'Unknown error', 'jetpack-forms' ); + const message = e instanceof Error ? e.message : UNKNOWN_ERROR_MESSAGE; dispatch( setIntegrationsError( message ) ); } finally { dispatch( setIntegrationsLoading( false ) ); diff --git a/projects/packages/forms/tests/js/hooks/use-config-value.test.js b/projects/packages/forms/tests/js/hooks/use-config-value.test.js new file mode 100644 index 0000000000000..d682cc7841d5e --- /dev/null +++ b/projects/packages/forms/tests/js/hooks/use-config-value.test.js @@ -0,0 +1,194 @@ +/** + * External dependencies + */ +import { renderHook, act } from '@testing-library/react'; +import { createRegistry, RegistryProvider } from '@wordpress/data'; +/** + * Internal dependencies + */ +import useConfigValue from '../../../src/hooks/use-config-value'; +import { store as configStore, CONFIG_STORE } from '../../../src/store/config'; + +const mockConfigData = { + isMailPoetEnabled: true, + isIntegrationsEnabled: true, + canInstallPlugins: false, + canActivatePlugins: true, + hasFeedback: true, + hasAI: false, + formsResponsesUrl: 'https://example.com/wp-admin/edit.php?post_type=feedback', + blogId: 12345, + gdriveConnectSupportURL: 'https://example.com/support', + pluginAssetsURL: 'https://example.com/assets', + siteURL: 'example.com', + dashboardURL: 'https://example.com/dashboard', + exportNonce: 'export123', + newFormNonce: 'form456', + emptyTrashDays: 30, +}; + +describe( 'useConfigValue', () => { + let registry; + let wrapper; + + beforeEach( () => { + // Create a fresh registry for each test + registry = createRegistry(); + registry.register( configStore ); + + // Create wrapper component that provides the registry + wrapper = ( { children } ) => ( + { children } + ); + } ); + + it( 'returns undefined when config is not loaded', () => { + const { result } = renderHook( () => useConfigValue( 'hasAI' ), { wrapper } ); + + expect( result.current ).toBeUndefined(); + } ); + + it( 'returns the correct value for a config key', () => { + // Populate the store with config data + registry.dispatch( CONFIG_STORE ).receiveConfig( mockConfigData ); + + const { result } = renderHook( () => useConfigValue( 'hasAI' ), { wrapper } ); + + expect( result.current ).toBe( false ); + } ); + + it( 'returns boolean values correctly', () => { + registry.dispatch( CONFIG_STORE ).receiveConfig( mockConfigData ); + + const { result: mailpoetResult } = renderHook( () => useConfigValue( 'isMailPoetEnabled' ), { + wrapper, + } ); + const { result: aiResult } = renderHook( () => useConfigValue( 'hasAI' ), { wrapper } ); + const { result: integrationsResult } = renderHook( + () => useConfigValue( 'isIntegrationsEnabled' ), + { wrapper } + ); + + expect( mailpoetResult.current ).toBe( true ); + expect( aiResult.current ).toBe( false ); + expect( integrationsResult.current ).toBe( true ); + } ); + + it( 'returns string values correctly', () => { + registry.dispatch( CONFIG_STORE ).receiveConfig( mockConfigData ); + + const { result: urlResult } = renderHook( () => useConfigValue( 'formsResponsesUrl' ), { + wrapper, + } ); + const { result: siteResult } = renderHook( () => useConfigValue( 'siteURL' ), { wrapper } ); + + expect( urlResult.current ).toBe( 'https://example.com/wp-admin/edit.php?post_type=feedback' ); + expect( siteResult.current ).toBe( 'example.com' ); + } ); + + it( 'returns number values correctly', () => { + registry.dispatch( CONFIG_STORE ).receiveConfig( mockConfigData ); + + const { result: blogIdResult } = renderHook( () => useConfigValue( 'blogId' ), { wrapper } ); + const { result: trashResult } = renderHook( () => useConfigValue( 'emptyTrashDays' ), { + wrapper, + } ); + + expect( blogIdResult.current ).toBe( 12345 ); + expect( trashResult.current ).toBe( 30 ); + } ); + + it( 'returns undefined for non-existent keys', () => { + registry.dispatch( CONFIG_STORE ).receiveConfig( { isMailPoetEnabled: true } ); + + const { result } = renderHook( () => useConfigValue( 'hasAI' ), { wrapper } ); + + expect( result.current ).toBeUndefined(); + } ); + + it( 'updates when config value changes', () => { + registry.dispatch( CONFIG_STORE ).receiveConfig( { hasAI: false } ); + + const { result, rerender } = renderHook( () => useConfigValue( 'hasAI' ), { wrapper } ); + + expect( result.current ).toBe( false ); + + // Update the config + act( () => { + registry.dispatch( CONFIG_STORE ).receiveConfigValue( 'hasAI', true ); + } ); + rerender(); + + expect( result.current ).toBe( true ); + } ); + + it( 'multiple hooks can read different config values', () => { + registry.dispatch( CONFIG_STORE ).receiveConfig( mockConfigData ); + + const { result: hasAI } = renderHook( () => useConfigValue( 'hasAI' ), { wrapper } ); + const { result: blogId } = renderHook( () => useConfigValue( 'blogId' ), { wrapper } ); + const { result: isMailPoet } = renderHook( () => useConfigValue( 'isMailPoetEnabled' ), { + wrapper, + } ); + + expect( hasAI.current ).toBe( false ); + expect( blogId.current ).toBe( 12345 ); + expect( isMailPoet.current ).toBe( true ); + } ); + + it( 'returns undefined when config is invalidated', () => { + registry.dispatch( CONFIG_STORE ).receiveConfig( mockConfigData ); + + const { result, rerender } = renderHook( () => useConfigValue( 'hasAI' ), { wrapper } ); + + expect( result.current ).toBe( false ); + + // Invalidate the config + act( () => { + registry.dispatch( CONFIG_STORE ).invalidateConfig(); + } ); + rerender(); + + expect( result.current ).toBeUndefined(); + } ); + + it( 'handles partial config objects', () => { + // Only set a few config values + registry.dispatch( CONFIG_STORE ).receiveConfig( { + isMailPoetEnabled: true, + hasAI: false, + } ); + + const { result: mailpoetResult } = renderHook( () => useConfigValue( 'isMailPoetEnabled' ), { + wrapper, + } ); + const { result: aiResult } = renderHook( () => useConfigValue( 'hasAI' ), { wrapper } ); + const { result: blogIdResult } = renderHook( () => useConfigValue( 'blogId' ), { wrapper } ); + + expect( mailpoetResult.current ).toBe( true ); + expect( aiResult.current ).toBe( false ); + expect( blogIdResult.current ).toBeUndefined(); + } ); + + it( 'works with different config keys in the same component', () => { + registry.dispatch( CONFIG_STORE ).receiveConfig( mockConfigData ); + + const { result: result1 } = renderHook( () => useConfigValue( 'hasAI' ), { wrapper } ); + const { result: result2 } = renderHook( () => useConfigValue( 'blogId' ), { wrapper } ); + const { result: result3 } = renderHook( () => useConfigValue( 'canInstallPlugins' ), { + wrapper, + } ); + + expect( result1.current ).toBe( false ); + expect( result2.current ).toBe( 12345 ); + expect( result3.current ).toBe( false ); + } ); + + it( 'handles empty config object', () => { + registry.dispatch( CONFIG_STORE ).receiveConfig( {} ); + + const { result } = renderHook( () => useConfigValue( 'hasAI' ), { wrapper } ); + + expect( result.current ).toBeUndefined(); + } ); +} ); diff --git a/projects/packages/forms/tests/js/store/config.test.js b/projects/packages/forms/tests/js/store/config.test.js new file mode 100644 index 0000000000000..7d5d5b5a96a6d --- /dev/null +++ b/projects/packages/forms/tests/js/store/config.test.js @@ -0,0 +1,388 @@ +import apiFetch from '@wordpress/api-fetch'; +import { createRegistry } from '@wordpress/data'; +/** + * Internal dependencies + */ +import { store, CONFIG_STORE } from '../../../src/store/config'; +import * as actions from '../../../src/store/config/actions'; +import reducer from '../../../src/store/config/reducer'; +import * as selectors from '../../../src/store/config/selectors'; + +// Mock apiFetch +jest.mock( '@wordpress/api-fetch' ); + +const createRegistryWithStores = () => { + const registry = createRegistry(); + registry.register( store ); + return registry; +}; + +const mockConfigData = { + isMailPoetEnabled: true, + isIntegrationsEnabled: true, + canInstallPlugins: true, + canActivatePlugins: true, + hasFeedback: true, + hasAI: false, + formsResponsesUrl: 'https://example.com/wp-admin/edit.php?post_type=feedback', + blogId: 12345, + gdriveConnectSupportURL: 'https://example.com/support', + pluginAssetsURL: 'https://example.com/assets', + siteURL: 'example.com', + dashboardURL: 'https://example.com/dashboard', + exportNonce: 'export123', + newFormNonce: 'form456', + emptyTrashDays: 30, +}; + +describe( 'Config Store', () => { + describe( 'actions', () => { + it( 'receiveConfig', () => { + const config = { isMailPoetEnabled: true, hasAI: false }; + const action = actions.receiveConfig( config ); + + expect( action ).toEqual( { + type: 'RECEIVE_CONFIG', + config, + } ); + } ); + + it( 'receiveConfigValue', () => { + const action = actions.receiveConfigValue( 'hasAI', true ); + + expect( action ).toEqual( { + type: 'RECEIVE_CONFIG_VALUE', + key: 'hasAI', + value: true, + } ); + } ); + + it( 'invalidateConfig', () => { + const action = actions.invalidateConfig(); + + expect( action ).toEqual( { + type: 'INVALIDATE_CONFIG', + } ); + } ); + + it( 'setConfigLoading', () => { + const action = actions.setConfigLoading( true ); + + expect( action ).toEqual( { + type: 'SET_CONFIG_LOADING', + isLoading: true, + } ); + } ); + + it( 'setConfigError', () => { + const error = 'Something went wrong'; + const action = actions.setConfigError( error ); + + expect( action ).toEqual( { + type: 'SET_CONFIG_ERROR', + error, + } ); + } ); + } ); + + describe( 'reducer', () => { + const DEFAULT_STATE = { + config: null, + isLoading: false, + error: null, + }; + + it( 'should return default state', () => { + const state = reducer( undefined, {} ); + expect( state ).toEqual( DEFAULT_STATE ); + } ); + + it( 'should handle RECEIVE_CONFIG', () => { + const config = { isMailPoetEnabled: true, hasAI: false }; + const state = reducer( DEFAULT_STATE, { + type: 'RECEIVE_CONFIG', + config, + } ); + + expect( state ).toEqual( { + config, + isLoading: false, + error: null, + } ); + } ); + + it( 'should handle RECEIVE_CONFIG_VALUE', () => { + const initialState = { + config: { isMailPoetEnabled: true }, + isLoading: false, + error: null, + }; + + const state = reducer( initialState, { + type: 'RECEIVE_CONFIG_VALUE', + key: 'hasAI', + value: true, + } ); + + expect( state.config ).toEqual( { + isMailPoetEnabled: true, + hasAI: true, + } ); + } ); + + it( 'should handle SET_CONFIG_LOADING', () => { + const state = reducer( DEFAULT_STATE, { + type: 'SET_CONFIG_LOADING', + isLoading: true, + } ); + + expect( state ).toEqual( { + config: null, + isLoading: true, + error: null, + } ); + } ); + + it( 'should clear error when loading starts', () => { + const initialState = { + config: null, + isLoading: false, + error: 'Previous error', + }; + + const state = reducer( initialState, { + type: 'SET_CONFIG_LOADING', + isLoading: true, + } ); + + expect( state.error ).toBeNull(); + } ); + + it( 'should handle SET_CONFIG_ERROR', () => { + const error = 'Network error'; + const state = reducer( DEFAULT_STATE, { + type: 'SET_CONFIG_ERROR', + error, + } ); + + expect( state ).toEqual( { + config: null, + isLoading: false, + error, + } ); + } ); + + it( 'should handle INVALIDATE_CONFIG', () => { + const initialState = { + config: { isMailPoetEnabled: true }, + isLoading: false, + error: null, + }; + + const state = reducer( initialState, { + type: 'INVALIDATE_CONFIG', + } ); + + expect( state ).toEqual( { + config: null, + isLoading: false, + error: null, + } ); + } ); + } ); + + describe( 'selectors', () => { + it( 'getConfig returns config', () => { + const state = { + config: mockConfigData, + isLoading: false, + error: null, + }; + + expect( selectors.getConfig( state ) ).toEqual( mockConfigData ); + } ); + + it( 'getConfig returns null when no config', () => { + const state = { + config: null, + isLoading: false, + error: null, + }; + + expect( selectors.getConfig( state ) ).toBeNull(); + } ); + + it( 'getConfigValue returns specific value', () => { + const state = { + config: mockConfigData, + isLoading: false, + error: null, + }; + + expect( selectors.getConfigValue( state, 'hasAI' ) ).toBe( false ); + expect( selectors.getConfigValue( state, 'blogId' ) ).toBe( 12345 ); + expect( selectors.getConfigValue( state, 'isMailPoetEnabled' ) ).toBe( true ); + } ); + + it( 'getConfigValue returns undefined when key does not exist', () => { + const state = { + config: { isMailPoetEnabled: true }, + isLoading: false, + error: null, + }; + + expect( selectors.getConfigValue( state, 'hasAI' ) ).toBeUndefined(); + } ); + + it( 'getConfigValue returns undefined when config is null', () => { + const state = { + config: null, + isLoading: false, + error: null, + }; + + expect( selectors.getConfigValue( state, 'hasAI' ) ).toBeUndefined(); + } ); + + it( 'isConfigLoading returns loading state', () => { + const loadingState = { + config: null, + isLoading: true, + error: null, + }; + + const notLoadingState = { + config: mockConfigData, + isLoading: false, + error: null, + }; + + expect( selectors.isConfigLoading( loadingState ) ).toBe( true ); + expect( selectors.isConfigLoading( notLoadingState ) ).toBe( false ); + } ); + + it( 'getConfigError returns error state', () => { + const errorState = { + config: null, + isLoading: false, + error: 'Failed to fetch', + }; + + const noErrorState = { + config: mockConfigData, + isLoading: false, + error: null, + }; + + expect( selectors.getConfigError( errorState ) ).toBe( 'Failed to fetch' ); + expect( selectors.getConfigError( noErrorState ) ).toBeNull(); + } ); + } ); + + describe( 'integration tests', () => { + let registry; + + beforeEach( () => { + registry = createRegistryWithStores(); + apiFetch.mockClear(); + } ); + + it( 'should fetch config on first access', async () => { + apiFetch.mockResolvedValue( mockConfigData ); + + // Trigger resolver by selecting config + const promise = registry.select( CONFIG_STORE ).getConfig(); + + // Initially returns null + expect( promise ).toBeNull(); + + // Wait for the resolver to complete + await new Promise( resolve => setTimeout( resolve, 0 ) ); + + // Now should have the config + const config = registry.select( CONFIG_STORE ).getConfig(); + expect( config ).toEqual( mockConfigData ); + expect( apiFetch ).toHaveBeenCalledWith( { path: '/wp/v2/feedback/config' } ); + } ); + + it( 'should handle fetch errors', async () => { + const errorMessage = 'Network error'; + apiFetch.mockRejectedValue( new Error( errorMessage ) ); + + // Trigger resolver + registry.select( CONFIG_STORE ).getConfig(); + + // Wait for the resolver to complete + await new Promise( resolve => setTimeout( resolve, 0 ) ); + + const error = registry.select( CONFIG_STORE ).getConfigError(); + expect( error ).toBe( errorMessage ); + } ); + + it( 'should allow manual config update', () => { + const config = { isMailPoetEnabled: true, hasAI: false }; + registry.dispatch( CONFIG_STORE ).receiveConfig( config ); + + expect( registry.select( CONFIG_STORE ).getConfig() ).toEqual( config ); + } ); + + it( 'should allow updating individual config values', () => { + const initialConfig = { isMailPoetEnabled: true, hasAI: false }; + registry.dispatch( CONFIG_STORE ).receiveConfig( initialConfig ); + + registry.dispatch( CONFIG_STORE ).receiveConfigValue( 'hasAI', true ); + + const config = registry.select( CONFIG_STORE ).getConfig(); + expect( config ).toEqual( { + isMailPoetEnabled: true, + hasAI: true, + } ); + } ); + + it( 'should invalidate config', () => { + const config = { isMailPoetEnabled: true, hasAI: false }; + registry.dispatch( CONFIG_STORE ).receiveConfig( config ); + + expect( registry.select( CONFIG_STORE ).getConfig() ).toEqual( config ); + + registry.dispatch( CONFIG_STORE ).invalidateConfig(); + + expect( registry.select( CONFIG_STORE ).getConfig() ).toBeNull(); + } ); + + it( 'should fetch config data and make it available', async () => { + apiFetch.mockResolvedValue( mockConfigData ); + + // Trigger resolver + registry.select( CONFIG_STORE ).getConfig(); + + // Wait for resolver to complete + await new Promise( resolve => setTimeout( resolve, 0 ) ); + + // Config should be available + const config = registry.select( CONFIG_STORE ).getConfig(); + expect( config ).toEqual( mockConfigData ); + + // Second access should use cached data (no additional API call) + const config2 = registry.select( CONFIG_STORE ).getConfig(); + expect( config2 ).toEqual( mockConfigData ); + + // Should not make additional calls after config is loaded + expect( apiFetch ).toHaveBeenCalledWith( { path: '/wp/v2/feedback/config' } ); + } ); + + it( 'should get specific config values', () => { + registry.dispatch( CONFIG_STORE ).receiveConfig( mockConfigData ); + + expect( registry.select( CONFIG_STORE ).getConfigValue( 'blogId' ) ).toBe( 12345 ); + expect( registry.select( CONFIG_STORE ).getConfigValue( 'hasAI' ) ).toBe( false ); + expect( registry.select( CONFIG_STORE ).getConfigValue( 'isMailPoetEnabled' ) ).toBe( true ); + } ); + + it( 'should return undefined for non-existent config keys', () => { + registry.dispatch( CONFIG_STORE ).receiveConfig( { isMailPoetEnabled: true } ); + + expect( registry.select( CONFIG_STORE ).getConfigValue( 'hasAI' ) ).toBeUndefined(); + } ); + } ); +} );