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 (
+
+ );
+}
+```
+
+### 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();
+ } );
+ } );
+} );