diff --git a/docs/manifest.json b/docs/manifest.json
index a4ac61dba37797..fa42ee55097a00 100644
--- a/docs/manifest.json
+++ b/docs/manifest.json
@@ -1427,6 +1427,12 @@
"markdown_source": "../packages/a11y/README.md",
"parent": "packages"
},
+ {
+ "title": "@wordpress/abilities",
+ "slug": "packages-abilities",
+ "markdown_source": "../packages/abilities/README.md",
+ "parent": "packages"
+ },
{
"title": "@wordpress/admin-ui",
"slug": "packages-admin-ui",
diff --git a/lib/experimental/workflow-palette.php b/lib/experimental/workflow-palette.php
new file mode 100644
index 00000000000000..79e70ac475fd38
--- /dev/null
+++ b/lib/experimental/workflow-palette.php
@@ -0,0 +1,16 @@
+ __( 'Enables the Workflow Palette for running workflows composed of abilities, from a unified interface.', 'gutenberg' ),
+ 'id' => 'gutenberg-workflow-palette',
+ )
+ );
+
register_setting(
'gutenberg-experiments',
'gutenberg-experiments'
diff --git a/lib/load.php b/lib/load.php
index af4ea1f2409e93..b8d91fdcf57a85 100644
--- a/lib/load.php
+++ b/lib/load.php
@@ -110,6 +110,10 @@ function gutenberg_is_experiment_enabled( $name ) {
require __DIR__ . '/experimental/pages/gutenberg-boot.php';
require __DIR__ . '/experimental/posts/load.php';
+if ( gutenberg_is_experiment_enabled( 'gutenberg-workflow-palette' ) ) {
+ require __DIR__ . '/experimental/workflow-palette.php';
+}
+
if ( gutenberg_is_experiment_enabled( 'gutenberg-no-tinymce' ) ) {
require __DIR__ . '/experimental/disable-tinymce.php';
}
diff --git a/package-lock.json b/package-lock.json
index 4f7798c093f4e5..ae043293b4f590 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -72,7 +72,7 @@
"@types/sprintf-js": "1.1.2",
"@types/uuid": "8.3.1",
"@wordpress/build": "file:./packages/wp-build",
- "ajv": "8.7.1",
+ "ajv": "8.17.1",
"appium": "2.0.0",
"babel-jest": "29.7.0",
"babel-loader": "9.2.1",
@@ -9532,6 +9532,333 @@
}
}
},
+ "node_modules/@radix-ui/react-dialog": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
+ "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
+ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
+ "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
+ "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
+ "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-id": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
+ "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
+ "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-effect-event": "0.0.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
+ "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/react-remove-scroll": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
+ "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==",
+ "license": "MIT",
+ "dependencies": {
+ "react-remove-scroll-bar": "^2.3.7",
+ "react-style-singleton": "^2.2.3",
+ "tslib": "^2.1.0",
+ "use-callback-ref": "^1.3.3",
+ "use-sidecar": "^1.1.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dismissable-layer": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
+ "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-escape-keydown": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/primitive": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
+ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
+ "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-callback-ref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
+ "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-escape-keydown": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
+ "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz",
@@ -9550,6 +9877,102 @@
}
}
},
+ "node_modules/@radix-ui/react-focus-scope": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
+ "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
+ "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-use-callback-ref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
+ "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-id": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz",
@@ -9569,6 +9992,155 @@
}
}
},
+ "node_modules/@radix-ui/react-portal": {
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
+ "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
+ "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
+ "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-presence": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
+ "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-presence/node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
+ "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-presence/node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
+ "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-primitive": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz",
@@ -9649,6 +10221,39 @@
}
}
},
+ "node_modules/@radix-ui/react-use-effect-event": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
+ "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-effect-event/node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
+ "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz",
@@ -17209,6 +17814,10 @@
"resolved": "packages/a11y",
"link": true
},
+ "node_modules/@wordpress/abilities": {
+ "resolved": "packages/abilities",
+ "link": true
+ },
"node_modules/@wordpress/admin-ui": {
"resolved": "packages/admin-ui",
"link": true
@@ -17677,6 +18286,10 @@
"resolved": "packages/wordcount",
"link": true
},
+ "node_modules/@wordpress/workflow": {
+ "resolved": "packages/workflow",
+ "link": true
+ },
"node_modules/@xmldom/xmldom": {
"version": "0.8.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
@@ -17856,20 +18469,35 @@
}
},
"node_modules/ajv": {
- "version": "8.7.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.7.1.tgz",
- "integrity": "sha512-gPpOObTO1QjbnN1sVMjJcp1TF9nggMfO4MBR5uQl6ZVTOaEPq5i4oq/6R9q2alMMPB3eg53wFv1RuJBLuxf3Hw==",
+ "version": "8.17.1",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
+ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "license": "MIT",
"dependencies": {
- "fast-deep-equal": "^3.1.1",
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
- "require-from-string": "^2.0.2",
- "uri-js": "^4.2.2"
+ "require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/ajv-draft-04": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz",
+ "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "ajv": "^8.5.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
"node_modules/ajv-errors": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz",
@@ -18673,9 +19301,10 @@
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="
},
"node_modules/aria-hidden": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.3.tgz",
- "integrity": "sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==",
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
+ "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
+ "license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
@@ -19168,22 +19797,6 @@
"webpack": ">=5"
}
},
- "node_modules/babel-loader/node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
- "license": "MIT",
- "dependencies": {
- "fast-deep-equal": "^3.1.3",
- "fast-uri": "^3.0.1",
- "json-schema-traverse": "^1.0.0",
- "require-from-string": "^2.0.2"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/epoberezkin"
- }
- },
"node_modules/babel-loader/node_modules/ajv-keywords": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
@@ -19225,11 +19838,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/babel-loader/node_modules/json-schema-traverse": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
- "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
- },
"node_modules/babel-loader/node_modules/locate-path": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz",
@@ -21355,6 +21963,111 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
+ "node_modules/cmdk": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
+ "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "^1.1.1",
+ "@radix-ui/react-dialog": "^1.1.6",
+ "@radix-ui/react-id": "^1.1.0",
+ "@radix-ui/react-primitive": "^2.0.2"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19 || ^19.0.0-rc",
+ "react-dom": "^18 || ^19 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/cmdk/node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
+ "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/cmdk/node_modules/@radix-ui/react-id": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
+ "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/cmdk/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
+ "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/cmdk/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
+ "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/cmdk/node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
+ "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -22339,22 +23052,6 @@
"webpack": "^5.1.0"
}
},
- "node_modules/copy-webpack-plugin/node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
- "license": "MIT",
- "dependencies": {
- "fast-deep-equal": "^3.1.3",
- "fast-uri": "^3.0.1",
- "json-schema-traverse": "^1.0.0",
- "require-from-string": "^2.0.2"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/epoberezkin"
- }
- },
"node_modules/copy-webpack-plugin/node_modules/ajv-keywords": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
@@ -22407,11 +23104,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/copy-webpack-plugin/node_modules/json-schema-traverse": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
- "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
- },
"node_modules/copy-webpack-plugin/node_modules/schema-utils": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz",
@@ -36094,22 +36786,6 @@
"webpack": "^5.0.0"
}
},
- "node_modules/mini-css-extract-plugin/node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
- "license": "MIT",
- "dependencies": {
- "fast-deep-equal": "^3.1.3",
- "fast-uri": "^3.0.1",
- "json-schema-traverse": "^1.0.0",
- "require-from-string": "^2.0.2"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/epoberezkin"
- }
- },
"node_modules/mini-css-extract-plugin/node_modules/ajv-keywords": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
@@ -36122,12 +36798,6 @@
"ajv": "^8.8.2"
}
},
- "node_modules/mini-css-extract-plugin/node_modules/json-schema-traverse": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
- "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
- "license": "MIT"
- },
"node_modules/mini-css-extract-plugin/node_modules/schema-utils": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz",
@@ -42035,19 +42705,20 @@
}
},
"node_modules/react-remove-scroll-bar": {
- "version": "2.3.6",
- "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz",
- "integrity": "sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==",
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
+ "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
+ "license": "MIT",
"dependencies": {
- "react-style-singleton": "^2.2.1",
+ "react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
- "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -42239,20 +42910,20 @@
}
},
"node_modules/react-style-singleton": {
- "version": "2.2.1",
- "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz",
- "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==",
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
+ "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
+ "license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
- "invariant": "^2.2.4",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
- "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -48572,9 +49243,10 @@
}
},
"node_modules/use-callback-ref": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.0.tgz",
- "integrity": "sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==",
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
+ "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
+ "license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
@@ -48582,8 +49254,8 @@
"node": ">=10"
},
"peerDependencies": {
- "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -48609,9 +49281,10 @@
}
},
"node_modules/use-sidecar": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz",
- "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==",
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
+ "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
+ "license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
@@ -48620,8 +49293,8 @@
"node": ">=10"
},
"peerDependencies": {
- "@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0",
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -50142,22 +50815,6 @@
}
}
},
- "node_modules/webpack-dev-middleware/node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
- "dev": true,
- "dependencies": {
- "fast-deep-equal": "^3.1.3",
- "fast-uri": "^3.0.1",
- "json-schema-traverse": "^1.0.0",
- "require-from-string": "^2.0.2"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/epoberezkin"
- }
- },
"node_modules/webpack-dev-middleware/node_modules/ajv-keywords": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
@@ -50176,12 +50833,6 @@
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"dev": true
},
- "node_modules/webpack-dev-middleware/node_modules/json-schema-traverse": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
- "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
- "dev": true
- },
"node_modules/webpack-dev-middleware/node_modules/schema-utils": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz",
@@ -50260,22 +50911,6 @@
}
}
},
- "node_modules/webpack-dev-server/node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
- "license": "MIT",
- "dependencies": {
- "fast-deep-equal": "^3.1.3",
- "fast-uri": "^3.0.1",
- "json-schema-traverse": "^1.0.0",
- "require-from-string": "^2.0.2"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/epoberezkin"
- }
- },
"node_modules/webpack-dev-server/node_modules/ajv-keywords": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
@@ -50372,12 +51007,6 @@
"node": ">=8"
}
},
- "node_modules/webpack-dev-server/node_modules/json-schema-traverse": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
- "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
- "license": "MIT"
- },
"node_modules/webpack-dev-server/node_modules/open": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz",
@@ -51646,6 +52275,42 @@
"npm": ">=8.19.2"
}
},
+ "packages/abilities": {
+ "name": "@wordpress/abilities",
+ "version": "0.1.0",
+ "license": "GPL-2.0-or-later",
+ "dependencies": {
+ "@wordpress/api-fetch": "file:../api-fetch",
+ "@wordpress/core-data": "file:../core-data",
+ "@wordpress/data": "file:../data",
+ "@wordpress/i18n": "file:../i18n",
+ "@wordpress/url": "file:../url",
+ "ajv": "^8.17.1",
+ "ajv-draft-04": "^1.0.0",
+ "ajv-formats": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=18.12.0",
+ "npm": ">=8.19.2"
+ }
+ },
+ "packages/abilities/node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
"packages/admin-ui": {
"name": "@wordpress/admin-ui",
"version": "1.3.0",
@@ -54875,22 +55540,6 @@
}
}
},
- "packages/scripts/node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
- "license": "MIT",
- "dependencies": {
- "fast-deep-equal": "^3.1.3",
- "fast-uri": "^3.0.1",
- "json-schema-traverse": "^1.0.0",
- "require-from-string": "^2.0.2"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/epoberezkin"
- }
- },
"packages/scripts/node_modules/ajv-keywords": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
@@ -54903,12 +55552,6 @@
"ajv": "^8.8.2"
}
},
- "packages/scripts/node_modules/json-schema-traverse": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
- "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
- "license": "MIT"
- },
"packages/scripts/node_modules/json2php": {
"version": "0.0.9",
"resolved": "https://registry.npmjs.org/json2php/-/json2php-0.0.9.tgz",
@@ -55306,6 +55949,32 @@
"npm": ">=8.19.2"
}
},
+ "packages/workflow": {
+ "name": "@wordpress/workflow",
+ "version": "0.1.0",
+ "license": "GPL-2.0-or-later",
+ "dependencies": {
+ "@wordpress/abilities": "file:../abilities",
+ "@wordpress/base-styles": "file:../base-styles",
+ "@wordpress/components": "file:../components",
+ "@wordpress/data": "file:../data",
+ "@wordpress/element": "file:../element",
+ "@wordpress/i18n": "file:../i18n",
+ "@wordpress/icons": "file:../icons",
+ "@wordpress/keyboard-shortcuts": "file:../keyboard-shortcuts",
+ "@wordpress/private-apis": "file:../private-apis",
+ "clsx": "^2.1.1",
+ "cmdk": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=18.12.0",
+ "npm": ">=8.19.2"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
+ }
+ },
"packages/wp-build": {
"name": "@wordpress/build",
"version": "0.3.0",
diff --git a/package.json b/package.json
index 63602ca1e9b2aa..42a6ef43d53f20 100644
--- a/package.json
+++ b/package.json
@@ -93,7 +93,7 @@
"@types/sprintf-js": "1.1.2",
"@types/uuid": "8.3.1",
"@wordpress/build": "file:./packages/wp-build",
- "ajv": "8.7.1",
+ "ajv": "8.17.1",
"appium": "2.0.0",
"babel-jest": "29.7.0",
"babel-loader": "9.2.1",
diff --git a/packages/abilities/CHANGELOG.md b/packages/abilities/CHANGELOG.md
new file mode 100644
index 00000000000000..6c7dfefbba188c
--- /dev/null
+++ b/packages/abilities/CHANGELOG.md
@@ -0,0 +1,9 @@
+
+
+## Unreleased
+
+## 0.1.0 (Unreleased)
+
+### New Features
+
+- Initial release of the WordPress Abilities API client library.
diff --git a/packages/abilities/README.md b/packages/abilities/README.md
new file mode 100644
index 00000000000000..58dc08ca862d6e
--- /dev/null
+++ b/packages/abilities/README.md
@@ -0,0 +1,302 @@
+# WordPress Abilities API Client
+
+Client library for the WordPress Abilities API, providing a standardized way to discover and execute WordPress capabilities.
+
+## Table of Contents
+
+- [Installation](#installation)
+- [Usage](#usage)
+- [API Reference](#api-reference)
+- [Development and Testing](#development-and-testing)
+
+## Installation
+
+The client is currently available as a part of the Composer package.
+
+### As a WordPress Script
+
+When the Abilities API is installed, the client is automatically registered and enqueue in the admin.
+
+## Usage
+
+```javascript
+// In your WordPress plugin or theme JavaScript
+const { getAbilities, getAbility, executeAbility } = wp.abilities;
+// or import { getAbilities, getAbility, executeAbility } from '@wordpress/abilities'; depending on your setup
+
+// Get all abilities
+const abilities = await getAbilities();
+
+// Get a specific ability
+const ability = await getAbility( 'my-plugin/my-ability' );
+
+// Execute an ability
+const result = await executeAbility( 'my-plugin/my-ability', {
+ param1: 'value1',
+ param2: 'value2',
+} );
+```
+
+### Using with React and WordPress Data
+
+The client includes a data store that integrates with `@wordpress/data` for use in React components:
+
+```javascript
+import { useSelect } from '@wordpress/data';
+import { store as abilitiesStore } from '@wordpress/abilities';
+
+function MyComponent() {
+ const abilities = useSelect(
+ ( select ) => select( abilitiesStore ).getAbilities(),
+ []
+ );
+
+ const specificAbility = useSelect(
+ ( select ) =>
+ select( abilitiesStore ).getAbility( 'my-plugin/my-ability' ),
+ []
+ );
+
+ return (
+
+
All Abilities
+
+ { abilities.map( ( ability ) => (
+ -
+ { ability.label }:{ ' ' }
+ { ability.description }
+
+ ) ) }
+
+
+ );
+}
+```
+
+## API Reference
+
+### Functions
+
+#### `getAbilities( args: AbilitiesQueryArgs = {} ): Promise`
+
+Returns all registered abilities. Optionally filter by category slug. Automatically handles pagination to fetch all abilities across multiple pages if needed.
+
+```javascript
+// Get all abilities
+const abilities = await getAbilities();
+console.log( `Found ${ abilities.length } abilities` );
+
+// Get abilities in a specific category
+const dataAbilities = await getAbilities( { category: 'data-retrieval' } );
+console.log( `Found ${ dataAbilities.length } data retrieval abilities` );
+```
+
+#### `getAbility( name: string ): Promise`
+
+Returns a specific ability by name, or null if not found.
+
+```javascript
+const ability = await getAbility( 'my-plugin/create-post' );
+if ( ability ) {
+ console.log( `Found ability: ${ ability.label }` );
+}
+```
+
+#### `getAbilityCategories(): Promise`
+
+Returns all registered ability categories. Categories are used to organize abilities into logical groups.
+
+```javascript
+const categories = await getAbilityCategories();
+console.log( `Found ${ categories.length } categories` );
+
+categories.forEach( ( category ) => {
+ console.log( `${ category.label }: ${ category.description }` );
+} );
+```
+
+#### `getAbilityCategory( slug: string ): Promise`
+
+Returns a specific ability category by slug, or null if not found.
+
+```javascript
+const category = await getAbilityCategory( 'data-retrieval' );
+if ( category ) {
+ console.log( `Found category: ${ category.label }` );
+ console.log( `Description: ${ category.description }` );
+}
+```
+
+#### `registerAbility( ability: Ability ): Promise`
+
+Registers a client-side ability. Client abilities are executed locally in the browser and must include a callback function and a valid category.
+
+```javascript
+import { registerAbility } from '@wordpress/abilities';
+
+await registerAbility( {
+ name: 'my-plugin/navigate',
+ label: 'Navigate to URL',
+ description: 'Navigates to a URL within WordPress admin',
+ category: 'navigation',
+ input_schema: {
+ type: 'object',
+ properties: {
+ url: { type: 'string' },
+ },
+ required: [ 'url' ],
+ },
+ callback: async ( { url } ) => {
+ window.location.href = url;
+ return { success: true };
+ },
+} );
+```
+
+#### `unregisterAbility( name: string ): void`
+
+Unregisters a client-side ability from the store.
+
+```javascript
+import { unregisterAbility } from '@wordpress/abilities';
+
+unregisterAbility( 'my-plugin/navigate' );
+```
+
+#### `registerAbilityCategory( slug: string, args: AbilityCategoryArgs ): Promise`
+
+Registers a client-side ability category. This is useful when registering client-side abilities that introduce new categories not defined by the server.
+
+```javascript
+import { registerAbilityCategory } from '@wordpress/abilities';
+
+// Register a new category
+await registerAbilityCategory( 'block-editor', {
+ label: 'Block Editor',
+ description: 'Abilities for interacting with the WordPress block editor',
+} );
+
+// Register a category with optional metadata
+await registerAbilityCategory( 'custom-category', {
+ label: 'Custom Category',
+ description: 'A category for custom abilities',
+ meta: {
+ color: '#ff0000',
+ },
+} );
+
+// Then register abilities using the new category
+await registerAbility( {
+ name: 'my-plugin/insert-block',
+ label: 'Insert Block',
+ description: 'Inserts a block into the editor',
+ category: 'block-editor', // Uses the client-registered category
+ callback: async ( { blockType } ) => {
+ // Implementation
+ return { success: true };
+ },
+} );
+```
+
+#### `unregisterAbilityCategory( slug: string ): void`
+
+Unregisters an ability category from the store.
+
+```javascript
+import { unregisterAbilityCategory } from '@wordpress/abilities';
+
+unregisterAbilityCategory( 'block-editor' );
+```
+
+#### `executeAbility( name: string, input?: Record ): Promise`
+
+Executes an ability with optional input parameters. The HTTP method is automatically determined based on the ability's annotations:
+
+- `readonly` abilities use GET (read-only operations)
+- regular abilities use POST (write operations)
+
+```javascript
+// Execute a read-only ability (GET)
+const data = await executeAbility( 'my-plugin/get-data', {
+ id: 123,
+} );
+
+// Execute a regular ability (POST)
+const result = await executeAbility( 'my-plugin/create-item', {
+ title: 'New Item',
+ content: 'Item content',
+} );
+```
+
+### Store Selectors
+
+When using with `@wordpress/data`:
+
+- `getAbilities( args: AbilitiesQueryArgs = {} )` - Returns all abilities from the store, optionally filtered by query arguments
+- `getAbility( name: string )` - Returns a specific ability from the store
+- `getAbilityCategories()` - Returns all categories from the store
+- `getAbilityCategory( slug: string )` - Returns a specific category from the store
+
+```javascript
+import { useSelect } from '@wordpress/data';
+import { store as abilitiesStore } from '@wordpress/abilities';
+
+function MyComponent() {
+ // Get all abilities
+ const allAbilities = useSelect(
+ ( select ) => select( abilitiesStore ).getAbilities(),
+ []
+ );
+
+ // Get all categories
+ const categories = useSelect(
+ ( select ) => select( abilitiesStore ).getAbilityCategories(),
+ []
+ );
+
+ // Get abilities in a specific category
+ const dataAbilities = useSelect(
+ ( select ) =>
+ select( abilitiesStore ).getAbilities( {
+ category: 'data-retrieval',
+ } ),
+ []
+ );
+
+ // Get a specific category
+ const dataCategory = useSelect(
+ ( select ) =>
+ select( abilitiesStore ).getAbilityCategory( 'data-retrieval' ),
+ []
+ );
+
+ return (
+
+
All Abilities ({ allAbilities.length })
+
Categories ({ categories.length })
+
+ { categories.map( ( category ) => (
+ -
+ { category.label }:{ ' ' }
+ { category.description }
+
+ ) ) }
+
+
{ dataCategory?.label } Abilities
+
+ { dataAbilities.map( ( ability ) => (
+ - { ability.label }
+ ) ) }
+
+
+ );
+}
+```
+
+## Contributing to this package
+
+This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects.
+
+To find out more about contributing to this package or Gutenberg as a whole, please read the project's main [contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md).
+
+

diff --git a/packages/abilities/package.json b/packages/abilities/package.json
new file mode 100644
index 00000000000000..fdb8fb9731da1b
--- /dev/null
+++ b/packages/abilities/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "@wordpress/abilities",
+ "version": "0.1.0",
+ "description": "JavaScript client for WordPress Abilities API.",
+ "author": "The WordPress Contributors",
+ "license": "GPL-2.0-or-later",
+ "keywords": [
+ "wordpress",
+ "abilities",
+ "api",
+ "ai"
+ ],
+ "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/abilities/README.md",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/WordPress/gutenberg.git",
+ "directory": "packages/abilities"
+ },
+ "bugs": {
+ "url": "https://github.com/WordPress/gutenberg/issues"
+ },
+ "engines": {
+ "node": ">=18.12.0",
+ "npm": ">=8.19.2"
+ },
+ "files": [
+ "build",
+ "build-module",
+ "build-types",
+ "src",
+ "*.md"
+ ],
+ "main": "build/index.js",
+ "module": "build-module/index.js",
+ "react-native": "src/index",
+ "wpScript": true,
+ "types": "build-types",
+ "dependencies": {
+ "@wordpress/api-fetch": "file:../api-fetch",
+ "@wordpress/core-data": "file:../core-data",
+ "@wordpress/data": "file:../data",
+ "@wordpress/i18n": "file:../i18n",
+ "@wordpress/url": "file:../url",
+ "ajv": "^8.17.1",
+ "ajv-draft-04": "^1.0.0",
+ "ajv-formats": "^3.0.1"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/abilities/src/api.ts b/packages/abilities/src/api.ts
new file mode 100644
index 00000000000000..e7b6dd749bc3db
--- /dev/null
+++ b/packages/abilities/src/api.ts
@@ -0,0 +1,339 @@
+/**
+ * WordPress dependencies
+ */
+import { dispatch, resolveSelect } from '@wordpress/data';
+import apiFetch from '@wordpress/api-fetch';
+import { addQueryArgs } from '@wordpress/url';
+import { sprintf } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import { store } from './store';
+import type {
+ Ability,
+ AbilityCategory,
+ AbilityCategoryArgs,
+ AbilitiesQueryArgs,
+ AbilityInput,
+ AbilityOutput,
+} from './types';
+import { validateValueFromSchema } from './validation';
+
+/**
+ * Get all available abilities with optional filtering.
+ *
+ * @param args Optional query arguments to filter. Defaults to empty object.
+ * @return Promise resolving to array of abilities.
+ */
+export async function getAbilities(
+ args: AbilitiesQueryArgs = {}
+): Promise< Ability[] > {
+ return await resolveSelect( store ).getAbilities( args );
+}
+
+/**
+ * Get a specific ability by name.
+ *
+ * @param name The ability name.
+ * @return Promise resolving to the ability or null if not found.
+ */
+export async function getAbility( name: string ): Promise< Ability | null > {
+ return await resolveSelect( store ).getAbility( name );
+}
+
+/**
+ * Get all available ability categories.
+ *
+ * @return Promise resolving to array of categories.
+ */
+export async function getAbilityCategories(): Promise< AbilityCategory[] > {
+ return await resolveSelect( store ).getAbilityCategories();
+}
+
+/**
+ * Get a specific ability category by slug.
+ *
+ * @param slug The category slug.
+ * @return Promise resolving to the category or null if not found.
+ */
+export async function getAbilityCategory(
+ slug: string
+): Promise< AbilityCategory | null > {
+ return await resolveSelect( store ).getAbilityCategory( slug );
+}
+
+/**
+ * Register a client-side ability.
+ *
+ * Client abilities are executed locally in the browser and must include
+ * a callback function. The ability will be validated by the store action,
+ * and an error will be thrown if validation fails.
+ *
+ * Categories will be automatically fetched from the REST API if they
+ * haven't been loaded yet, so you don't need to call getAbilityCategories()
+ * before registering abilities.
+ *
+ * @param ability The ability definition including callback.
+ * @return Promise that resolves when registration is complete.
+ * @throws {Error} If the ability fails validation.
+ *
+ * @example
+ * ```js
+ * await registerAbility({
+ * name: 'my-plugin/navigate',
+ * label: 'Navigate to URL',
+ * description: 'Navigates to a URL within WordPress admin',
+ * category: 'navigation',
+ * input_schema: {
+ * type: 'object',
+ * properties: {
+ * url: { type: 'string' }
+ * },
+ * required: ['url']
+ * },
+ * callback: async ({ url }) => {
+ * window.location.href = url;
+ * return { success: true };
+ * }
+ * });
+ * ```
+ */
+export async function registerAbility( ability: Ability ): Promise< void > {
+ await dispatch( store ).registerAbility( ability );
+}
+
+/**
+ * Unregister an ability from the store.
+ *
+ * Remove a client-side ability from the store.
+ * Note: This will return an error for server-side abilities.
+ *
+ * @param name The ability name to unregister.
+ */
+export function unregisterAbility( name: string ): void {
+ dispatch( store ).unregisterAbility( name );
+}
+
+/**
+ * Register a client-side ability category.
+ *
+ * Categories registered on the client are stored alongside server-side categories
+ * in the same store and can be used when registering client side abilities.
+ * This is useful when registering client-side abilities that introduce new
+ * categories not defined by the server.
+ *
+ * Categories will be automatically fetched from the REST API if they haven't been
+ * loaded yet to check for duplicates against server-side categories.
+ *
+ * @param slug Category slug (lowercase alphanumeric with dashes only).
+ * @param args Category arguments (label, description, optional meta).
+ * @return Promise that resolves when registration is complete.
+ * @throws {Error} If the category fails validation.
+ *
+ * @example
+ * ```js
+ * // Register a new category for block editor abilities
+ * await registerAbilityCategory('block-editor', {
+ * label: 'Block Editor',
+ * description: 'Abilities for interacting with the WordPress block editor'
+ * });
+ *
+ * // Then register abilities using this category
+ * await registerAbility({
+ * name: 'my-plugin/insert-block',
+ * label: 'Insert Block',
+ * description: 'Inserts a block into the editor',
+ * category: 'block-editor',
+ * callback: async ({ blockType }) => {
+ * // Implementation
+ * return { success: true };
+ * }
+ * });
+ * ```
+ */
+export async function registerAbilityCategory(
+ slug: string,
+ args: AbilityCategoryArgs
+): Promise< void > {
+ await dispatch( store ).registerAbilityCategory( slug, args );
+}
+
+/**
+ * Unregister an ability category.
+ *
+ * Removes a category from the store.
+ *
+ * @param slug The category slug to unregister.
+ *
+ * @example
+ * ```js
+ * unregisterAbilityCategory('block-editor');
+ * ```
+ */
+export function unregisterAbilityCategory( slug: string ): void {
+ dispatch( store ).unregisterAbilityCategory( slug );
+}
+
+/**
+ * Execute a client-side ability.
+ *
+ * @param ability The ability to execute.
+ * @param input Input parameters for the ability.
+ * @return Promise resolving to the ability execution result.
+ * @throws Error if validation fails or execution errors.
+ */
+async function executeClientAbility(
+ ability: Ability,
+ input: AbilityInput
+): Promise< AbilityOutput > {
+ if ( ! ability.callback ) {
+ throw new Error(
+ sprintf(
+ 'Client ability %s is missing callback function',
+ ability.name
+ )
+ );
+ }
+
+ // Check permission callback if defined
+ if ( ability.permissionCallback ) {
+ const hasPermission = await ability.permissionCallback( input );
+ if ( ! hasPermission ) {
+ const error = new Error(
+ sprintf( 'Permission denied for ability: %s', ability.name )
+ );
+ ( error as any ).code = 'ability_permission_denied';
+ throw error;
+ }
+ }
+
+ if ( ability.input_schema ) {
+ const inputValidation = validateValueFromSchema(
+ input,
+ ability.input_schema,
+ 'input'
+ );
+ if ( inputValidation !== true ) {
+ const error = new Error(
+ sprintf(
+ 'Ability "%1$s" has invalid input. Reason: %2$s',
+ ability.name,
+ inputValidation
+ )
+ );
+ ( error as any ).code = 'ability_invalid_input';
+ throw error;
+ }
+ }
+
+ let result: AbilityOutput;
+ try {
+ result = await ability.callback( input );
+ } catch ( error ) {
+ // eslint-disable-next-line no-console
+ console.error(
+ `Error executing client ability ${ ability.name }:`,
+ error
+ );
+ throw error;
+ }
+
+ if ( ability.output_schema ) {
+ const outputValidation = validateValueFromSchema(
+ result,
+ ability.output_schema,
+ 'output'
+ );
+ if ( outputValidation !== true ) {
+ const error = new Error(
+ sprintf(
+ 'Ability "%1$s" has invalid output. Reason: %2$s',
+ ability.name,
+ outputValidation
+ )
+ );
+ ( error as any ).code = 'ability_invalid_output';
+ throw error;
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Execute a server-side ability.
+ *
+ * @param ability The ability to execute.
+ * @param input Input parameters for the ability.
+ * @return Promise resolving to the ability execution result.
+ * @throws Error if the API call fails.
+ */
+async function executeServerAbility(
+ ability: Ability,
+ input: AbilityInput
+): Promise< AbilityOutput > {
+ let method = 'POST';
+ if ( !! ability.meta?.annotations?.readonly ) {
+ method = 'GET';
+ } else if (
+ !! ability.meta?.annotations?.destructive &&
+ !! ability.meta?.annotations?.idempotent
+ ) {
+ method = 'DELETE';
+ }
+
+ let path = `/wp-abilities/v1/abilities/${ ability.name }/run`;
+ const options: {
+ method: string;
+ data?: { input: AbilityInput };
+ } = {
+ method,
+ };
+
+ if ( [ 'GET', 'DELETE' ].includes( method ) && input !== null ) {
+ // For GET and DELETE requests, pass the input directly.
+ path = addQueryArgs( path, { input } );
+ } else if ( method === 'POST' && input !== null ) {
+ options.data = { input };
+ }
+
+ // Note: Input and output validation happens on the server side for these abilities.
+ try {
+ return await apiFetch< AbilityOutput >( {
+ path,
+ ...options,
+ } );
+ } catch ( error ) {
+ // eslint-disable-next-line no-console
+ console.error( `Error executing ability ${ ability.name }:`, error );
+ throw error;
+ }
+}
+
+/**
+ * Execute an ability.
+ *
+ * Determines whether to execute locally (client abilities) or remotely (server abilities)
+ * based on whether the ability has a callback function.
+ *
+ * @param name The ability name.
+ * @param input Optional input parameters for the ability.
+ * @return Promise resolving to the ability execution result.
+ * @throws Error if the ability is not found or execution fails.
+ */
+export async function executeAbility(
+ name: string,
+ input?: AbilityInput
+): Promise< AbilityOutput > {
+ const ability = await getAbility( name );
+ if ( ! ability ) {
+ throw new Error( sprintf( 'Ability not found: %s', name ) );
+ }
+
+ if ( ability.callback ) {
+ return executeClientAbility( ability, input );
+ }
+
+ return executeServerAbility( ability, input );
+}
diff --git a/packages/abilities/src/index.ts b/packages/abilities/src/index.ts
new file mode 100644
index 00000000000000..c3bcac425b4456
--- /dev/null
+++ b/packages/abilities/src/index.ts
@@ -0,0 +1,65 @@
+/**
+ * WordPress Abilities API Client
+ *
+ * This package provides a client for interacting with the
+ * WordPress Abilities API, allowing you to list, retrieve, and execute
+ * abilities from client-side code.
+ *
+ * @package
+ */
+
+/**
+ * Public API functions
+ */
+export {
+ getAbilities,
+ getAbility,
+ getAbilityCategories,
+ getAbilityCategory,
+ executeAbility,
+ registerAbility,
+ unregisterAbility,
+ registerAbilityCategory,
+ unregisterAbilityCategory,
+} from './api';
+
+/**
+ * The store can be used directly with @wordpress/data via selectors
+ * in React components with useSelect.
+ *
+ * @example
+ * ```js
+ * import { useSelect } from '@wordpress/data';
+ * import { store as abilitiesStore } from '@wordpress/abilities';
+ *
+ * function MyComponent() {
+ * const abilities = useSelect(
+ * (select) => select(abilitiesStore).getAbilities(),
+ * []
+ * );
+ * // Use abilities...
+ * }
+ * ```
+ */
+export { store } from './store';
+
+/**
+ * Type definitions
+ */
+export type {
+ Ability,
+ AbilityCategory,
+ AbilityCategoryArgs,
+ AbilitiesState,
+ AbilitiesQueryArgs,
+ AbilityCallback,
+ PermissionCallback,
+ AbilityInput,
+ AbilityOutput,
+ ValidationError,
+} from './types';
+
+/**
+ * Validation utilities
+ */
+export { validateValueFromSchema } from './validation';
diff --git a/packages/abilities/src/store/actions.ts b/packages/abilities/src/store/actions.ts
new file mode 100644
index 00000000000000..4e5e4f1dbc7f2b
--- /dev/null
+++ b/packages/abilities/src/store/actions.ts
@@ -0,0 +1,247 @@
+/**
+ * WordPress dependencies
+ */
+import { sprintf } from '@wordpress/i18n';
+import { resolveSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import type { Ability, AbilityCategory, AbilityCategoryArgs } from '../types';
+import {
+ RECEIVE_ABILITIES,
+ REGISTER_ABILITY,
+ UNREGISTER_ABILITY,
+ RECEIVE_CATEGORIES,
+ REGISTER_ABILITY_CATEGORY,
+ UNREGISTER_ABILITY_CATEGORY,
+ STORE_NAME,
+} from './constants';
+
+/**
+ * Returns an action object used to receive abilities into the store.
+ *
+ * @param abilities Array of abilities to store.
+ * @return Action object.
+ */
+export function receiveAbilities( abilities: Ability[] ) {
+ return {
+ type: RECEIVE_ABILITIES,
+ abilities,
+ };
+}
+
+/**
+ * Returns an action object used to receive categories into the store.
+ *
+ * @param categories Array of categories to store.
+ * @return Action object.
+ */
+export function receiveCategories( categories: AbilityCategory[] ) {
+ return {
+ type: RECEIVE_CATEGORIES,
+ categories,
+ };
+}
+
+/**
+ * Registers an ability in the store.
+ *
+ * This action validates the ability before registration. If validation fails,
+ * an error will be thrown. Categories will be automatically fetched from the
+ * REST API if they haven't been loaded yet.
+ *
+ * @param ability The ability to register.
+ * @return Action object or function.
+ * @throws {Error} If validation fails.
+ */
+export function registerAbility( ability: Ability ) {
+ // @ts-expect-error - registry types are not yet available
+ return async ( { select, dispatch } ) => {
+ if ( ! ability.name ) {
+ throw new Error( 'Ability name is required' );
+ }
+
+ // Validate name format matches server implementation
+ if ( ! /^[a-z0-9-]+\/[a-z0-9-]+$/.test( ability.name ) ) {
+ throw new Error(
+ 'Ability name must be a string containing a namespace prefix, i.e. "my-plugin/my-ability". It can only contain lowercase alphanumeric characters, dashes and the forward slash.'
+ );
+ }
+
+ if ( ! ability.label ) {
+ throw new Error(
+ sprintf( 'Ability "%s" must have a label', ability.name )
+ );
+ }
+
+ if ( ! ability.description ) {
+ throw new Error(
+ sprintf( 'Ability "%s" must have a description', ability.name )
+ );
+ }
+
+ if ( ! ability.category ) {
+ throw new Error(
+ sprintf( 'Ability "%s" must have a category', ability.name )
+ );
+ }
+
+ // Validate category format
+ if ( ! /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test( ability.category ) ) {
+ throw new Error(
+ sprintf(
+ 'Ability "%1$s" has an invalid category. Category must be lowercase alphanumeric with dashes only Got: "%2$s"',
+ ability.name,
+ ability.category
+ )
+ );
+ }
+
+ // Ensure categories are loaded before validating
+ const categories =
+ await resolveSelect( STORE_NAME ).getAbilityCategories();
+ const existingCategory = categories.find(
+ ( cat: AbilityCategory ) => cat.slug === ability.category
+ );
+ if ( ! existingCategory ) {
+ throw new Error(
+ sprintf(
+ 'Ability "%1$s" references non-existent category "%2$s". Please register the category first.',
+ ability.name,
+ ability.category
+ )
+ );
+ }
+
+ // Client-side abilities must have a callback
+ if ( ability.callback && typeof ability.callback !== 'function' ) {
+ throw new Error(
+ sprintf(
+ 'Ability "%s" has an invalid callback. Callback must be a function',
+ ability.name
+ )
+ );
+ }
+
+ // Check if ability is already registered
+ const existingAbility = select.getAbility( ability.name );
+ if ( existingAbility ) {
+ throw new Error(
+ sprintf( 'Ability "%s" is already registered', ability.name )
+ );
+ }
+
+ // All validation passed, dispatch the registration action
+ dispatch( {
+ type: REGISTER_ABILITY,
+ ability,
+ } );
+ };
+}
+
+/**
+ * Returns an action object used to unregister a client-side ability.
+ *
+ * @param name The name of the ability to unregister.
+ * @return Action object.
+ */
+export function unregisterAbility( name: string ) {
+ return {
+ type: UNREGISTER_ABILITY,
+ name,
+ };
+}
+
+/**
+ * Registers a client-side ability category in the store.
+ *
+ * This action validates the category before registration. If validation fails,
+ * an error will be thrown. Categories will be automatically fetched from the
+ * REST API if they haven't been loaded yet to check for duplicates.
+ *
+ * @param slug The unique category slug identifier.
+ * @param args Category arguments (label, description, optional meta).
+ * @return Action object or function.
+ * @throws {Error} If validation fails.
+ */
+export function registerAbilityCategory(
+ slug: string,
+ args: AbilityCategoryArgs
+) {
+ // @ts-expect-error - registry types are not yet available
+ return async ( { select, dispatch } ) => {
+ if ( ! slug ) {
+ throw new Error( 'Category slug is required' );
+ }
+
+ // Validate slug format matches server implementation
+ if ( ! /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test( slug ) ) {
+ throw new Error(
+ 'Category slug must contain only lowercase alphanumeric characters and dashes.'
+ );
+ }
+
+ // Ensure categories are loaded before checking for duplicates
+ await resolveSelect( STORE_NAME ).getAbilityCategories();
+ const existingCategory = select.getAbilityCategory( slug );
+ if ( existingCategory ) {
+ throw new Error(
+ sprintf( 'Category "%s" is already registered.', slug )
+ );
+ }
+
+ // Validate label presence and type (matches PHP empty() + is_string())
+ if ( ! args.label || typeof args.label !== 'string' ) {
+ throw new Error(
+ 'The category properties must contain a `label` string.'
+ );
+ }
+
+ // Validate description presence and type (matches PHP empty() + is_string())
+ if ( ! args.description || typeof args.description !== 'string' ) {
+ throw new Error(
+ 'The category properties must contain a `description` string.'
+ );
+ }
+
+ if (
+ args.meta !== undefined &&
+ ( typeof args.meta !== 'object' || Array.isArray( args.meta ) )
+ ) {
+ throw new Error(
+ 'The category properties should provide a valid `meta` object.'
+ );
+ }
+
+ const category: AbilityCategory = {
+ slug,
+ label: args.label,
+ description: args.description,
+ meta: {
+ ...( args.meta || {} ),
+ // Internal implementation note: Client-registered categories will have `meta._clientRegistered` set to `true` to differentiate them from server-fetched categories.
+ // This is used internally by the resolver to determine whether to fetch categories from the server.
+ _clientRegistered: true,
+ },
+ };
+
+ dispatch( {
+ type: REGISTER_ABILITY_CATEGORY,
+ category,
+ } );
+ };
+}
+
+/**
+ * Returns an action object used to unregister a client-side ability category.
+ *
+ * @param slug The slug of the category to unregister.
+ * @return Action object.
+ */
+export function unregisterAbilityCategory( slug: string ) {
+ return {
+ type: UNREGISTER_ABILITY_CATEGORY,
+ slug,
+ };
+}
diff --git a/packages/abilities/src/store/constants.ts b/packages/abilities/src/store/constants.ts
new file mode 100644
index 00000000000000..bd9ae5cedd3912
--- /dev/null
+++ b/packages/abilities/src/store/constants.ts
@@ -0,0 +1,15 @@
+/**
+ * Store constants
+ */
+export const STORE_NAME = 'core/abilities';
+export const ENTITY_KIND = 'root';
+export const ENTITY_NAME = 'abilities';
+export const ENTITY_NAME_CATEGORIES = 'ability-categories';
+
+// Action types
+export const RECEIVE_ABILITIES = 'RECEIVE_ABILITIES';
+export const REGISTER_ABILITY = 'REGISTER_ABILITY';
+export const UNREGISTER_ABILITY = 'UNREGISTER_ABILITY';
+export const RECEIVE_CATEGORIES = 'RECEIVE_CATEGORIES';
+export const REGISTER_ABILITY_CATEGORY = 'REGISTER_ABILITY_CATEGORY';
+export const UNREGISTER_ABILITY_CATEGORY = 'UNREGISTER_ABILITY_CATEGORY';
diff --git a/packages/abilities/src/store/index.ts b/packages/abilities/src/store/index.ts
new file mode 100644
index 00000000000000..422670e77c9554
--- /dev/null
+++ b/packages/abilities/src/store/index.ts
@@ -0,0 +1,55 @@
+/**
+ * WordPress dependencies
+ */
+import { createReduxStore, register, dispatch } from '@wordpress/data';
+import { store as coreStore } from '@wordpress/core-data';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import reducer from './reducer';
+import * as actions from './actions';
+import * as selectors from './selectors';
+import * as resolvers from './resolvers';
+import {
+ STORE_NAME,
+ ENTITY_KIND,
+ ENTITY_NAME,
+ ENTITY_NAME_CATEGORIES,
+} from './constants';
+
+/**
+ * The abilities store definition.
+ */
+export const store = createReduxStore( STORE_NAME, {
+ reducer,
+ actions,
+ selectors,
+ resolvers,
+} );
+
+register( store );
+
+dispatch( coreStore ).addEntities( [
+ {
+ name: ENTITY_NAME,
+ kind: ENTITY_KIND,
+ key: 'name',
+ baseURL: '/wp-abilities/v1/abilities',
+ baseURLParams: { context: 'edit' },
+ plural: 'abilities',
+ label: __( 'Abilities' ),
+ supportsPagination: true,
+ },
+ {
+ name: ENTITY_NAME_CATEGORIES,
+ kind: ENTITY_KIND,
+ key: 'slug',
+ baseURL: '/wp-abilities/v1/categories',
+ baseURLParams: { context: 'edit' },
+ plural: 'ability-categories',
+ label: __( 'Ability Categories' ),
+ supportsPagination: true,
+ },
+] );
diff --git a/packages/abilities/src/store/reducer.ts b/packages/abilities/src/store/reducer.ts
new file mode 100644
index 00000000000000..2aec93d14dc768
--- /dev/null
+++ b/packages/abilities/src/store/reducer.ts
@@ -0,0 +1,184 @@
+/**
+ * WordPress dependencies
+ */
+import { combineReducers } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import type { Ability, AbilityCategory } from '../types';
+import {
+ RECEIVE_ABILITIES,
+ REGISTER_ABILITY,
+ UNREGISTER_ABILITY,
+ RECEIVE_CATEGORIES,
+ REGISTER_ABILITY_CATEGORY,
+ UNREGISTER_ABILITY_CATEGORY,
+} from './constants';
+
+/**
+ * Valid keys for an Ability object.
+ * Used to filter out non-standard properties from server responses.
+ */
+const ABILITY_KEYS = [
+ 'name',
+ 'label',
+ 'description',
+ 'category',
+ 'input_schema',
+ 'output_schema',
+ 'meta',
+ 'callback',
+ 'permissionCallback',
+] as const;
+
+/**
+ * Valid keys for an AbilityCategory object.
+ * Used to filter out non-standard properties from server responses.
+ */
+const CATEGORY_KEYS = [ 'slug', 'label', 'description', 'meta' ] as const;
+
+/**
+ * Sanitizes an ability object to only include valid properties.
+ * This ensures consistent shape regardless of source (server/client).
+ *
+ * @param ability Raw ability object that may contain extra properties.
+ * @return Sanitized ability with only valid properties.
+ */
+function sanitizeAbility( ability: any ): Ability {
+ return Object.keys( ability )
+ .filter(
+ ( key ) =>
+ ABILITY_KEYS.includes( key as any ) &&
+ ability[ key ] !== undefined
+ )
+ .reduce(
+ ( obj, key ) => ( { ...obj, [ key ]: ability[ key ] } ),
+ {} as Ability
+ );
+}
+
+/**
+ * Sanitizes a category object to only include valid properties.
+ * This ensures consistent shape regardless of source.
+ *
+ * @param category Raw category object that may contain extra properties.
+ * @return Sanitized category with only valid properties.
+ */
+function sanitizeCategory( category: any ): AbilityCategory {
+ return Object.keys( category )
+ .filter(
+ ( key ) =>
+ CATEGORY_KEYS.includes( key as any ) &&
+ category[ key ] !== undefined
+ )
+ .reduce(
+ ( obj, key ) => ( { ...obj, [ key ]: category[ key ] } ),
+ {} as AbilityCategory
+ );
+}
+
+interface AbilitiesAction {
+ type: string;
+ abilities?: Ability[];
+ ability?: Ability;
+ categories?: AbilityCategory[];
+ category?: AbilityCategory;
+ name?: string;
+ slug?: string;
+}
+
+const DEFAULT_STATE: Record< string, Ability > = {};
+
+/**
+ * Reducer managing the abilities by name.
+ *
+ * @param state Current state.
+ * @param action Dispatched action.
+ * @return New state.
+ */
+function abilitiesByName(
+ state: Record< string, Ability > = DEFAULT_STATE,
+ action: AbilitiesAction
+): Record< string, Ability > {
+ switch ( action.type ) {
+ case RECEIVE_ABILITIES: {
+ if ( ! action.abilities ) {
+ return state;
+ }
+ const newState: Record< string, Ability > = {};
+ action.abilities.forEach( ( ability ) => {
+ newState[ ability.name ] = sanitizeAbility( ability );
+ } );
+ return newState;
+ }
+ case REGISTER_ABILITY: {
+ if ( ! action.ability ) {
+ return state;
+ }
+ return {
+ ...state,
+ [ action.ability.name ]: sanitizeAbility( action.ability ),
+ };
+ }
+ case UNREGISTER_ABILITY: {
+ if ( ! action.name || ! state[ action.name ] ) {
+ return state;
+ }
+ const { [ action.name ]: _, ...newState } = state;
+ return newState;
+ }
+ default:
+ return state;
+ }
+}
+
+const DEFAULT_CATEGORIES_STATE: Record< string, AbilityCategory > = {};
+
+/**
+ * Reducer managing the categories by slug.
+ *
+ * @param state Current state.
+ * @param action Dispatched action.
+ * @return New state.
+ */
+function categoriesBySlug(
+ state: Record< string, AbilityCategory > = DEFAULT_CATEGORIES_STATE,
+ action: AbilitiesAction
+): Record< string, AbilityCategory > {
+ switch ( action.type ) {
+ case RECEIVE_CATEGORIES: {
+ if ( ! action.categories ) {
+ return state;
+ }
+ const newState: Record< string, AbilityCategory > = {};
+ action.categories.forEach( ( category ) => {
+ newState[ category.slug ] = sanitizeCategory( category );
+ } );
+ return newState;
+ }
+ case REGISTER_ABILITY_CATEGORY: {
+ if ( ! action.category ) {
+ return state;
+ }
+ return {
+ ...state,
+ [ action.category.slug ]: sanitizeCategory( action.category ),
+ };
+ }
+ case UNREGISTER_ABILITY_CATEGORY: {
+ if ( ! action.slug || ! state[ action.slug ] ) {
+ return state;
+ }
+ const { [ action.slug ]: _, ...newState } = state;
+ return newState;
+ }
+ default:
+ return state;
+ }
+}
+
+export default combineReducers( {
+ abilitiesByName,
+ categoriesBySlug,
+} );
diff --git a/packages/abilities/src/store/resolvers.ts b/packages/abilities/src/store/resolvers.ts
new file mode 100644
index 00000000000000..64643cefed34cd
--- /dev/null
+++ b/packages/abilities/src/store/resolvers.ts
@@ -0,0 +1,151 @@
+/**
+ * WordPress dependencies
+ */
+import { store as coreStore } from '@wordpress/core-data';
+
+/**
+ * Internal dependencies
+ */
+import type { Ability, AbilityCategory } from '../types';
+import { ENTITY_KIND, ENTITY_NAME, ENTITY_NAME_CATEGORIES } from './constants';
+import {
+ receiveAbilities,
+ receiveCategories,
+ registerAbility,
+ registerAbilityCategory,
+} from './actions';
+
+/**
+ * Resolver for getAbilities selector.
+ * Fetches all abilities from the server.
+ *
+ * The resolver only fetches once (without query args filter) and stores all abilities.
+ * Query args filtering handled client-side by the selector for better performance
+ * and to avoid multiple API requests when filtering by different categories.
+ */
+export function getAbilities() {
+ // @ts-expect-error - registry types are not yet available
+ return async ( { dispatch, registry, select } ) => {
+ const existingAbilities = select.getAbilities();
+
+ // Check if we have any server-side abilities (abilities without callbacks)
+ // Client abilities have callbacks and are registered immediately on page load
+ // We only want to skip fetching if we've already fetched server abilities
+ const hasServerAbilities = existingAbilities.some(
+ ( ability: Ability ) => ! ability.callback
+ );
+
+ if ( hasServerAbilities ) {
+ return;
+ }
+
+ const abilities = await registry
+ .resolveSelect( coreStore )
+ .getEntityRecords( ENTITY_KIND, ENTITY_NAME, {
+ per_page: -1,
+ } );
+
+ dispatch( receiveAbilities( abilities || [] ) );
+ };
+}
+
+/**
+ * Resolver for getAbility selector.
+ * Fetches a specific ability from the server if not already in store.
+ *
+ * @param name Ability name.
+ */
+export function getAbility( name: string ) {
+ // @ts-expect-error - registry types are not yet available
+ return async ( { dispatch, registry, select } ) => {
+ // Check if ability already exists in store (i.e. client ability or already fetched)
+ const existingAbility = select.getAbility( name );
+ if ( existingAbility ) {
+ return;
+ }
+
+ try {
+ const ability = await registry
+ .resolveSelect( coreStore )
+ .getEntityRecord( ENTITY_KIND, ENTITY_NAME, name );
+
+ if ( ability ) {
+ await dispatch( registerAbility( ability ) );
+ }
+ } catch ( error ) {
+ // If ability doesn't exist or error, we'll return undefined from the selector
+ // eslint-disable-next-line no-console
+ console.debug( `Ability not found: ${ name }` );
+ }
+ };
+}
+
+/**
+ * Resolver for getAbilityCategories selector.
+ * Fetches all categories from the server.
+ *
+ * The resolver only fetches once and stores all categories.
+ */
+export function getAbilityCategories() {
+ // @ts-expect-error - registry types are not yet available
+ return async ( { dispatch, registry, select } ) => {
+ const existingCategories = select.getAbilityCategories();
+
+ // Check if we have any server-side categories (categories without meta._clientRegistered flag)
+ // Client categories have meta._clientRegistered=true and might be registered immediately
+ // We only want to skip fetching if we've already fetched server categories
+ const hasServerCategories = existingCategories.some(
+ ( category: AbilityCategory ) => ! category.meta?._clientRegistered
+ );
+
+ if ( hasServerCategories ) {
+ return;
+ }
+
+ const categories = await registry
+ .resolveSelect( coreStore )
+ .getEntityRecords( ENTITY_KIND, ENTITY_NAME_CATEGORIES, {
+ per_page: -1,
+ } );
+
+ dispatch( receiveCategories( categories || [] ) );
+ };
+}
+
+/**
+ * Resolver for getAbilityCategory selector.
+ * Fetches a specific category from the server if not already in store.
+ *
+ * @param slug Category slug.
+ */
+export function getAbilityCategory( slug: string ) {
+ // @ts-expect-error - registry types are not yet available
+ return async ( { dispatch, registry, select } ) => {
+ // Check if category already exists in store (either client-registered or server-fetched).
+ // This prevents unnecessary network requests while allowing client-side categories
+ // to be retrieved immediately without hitting the API.
+ const existingCategory = select.getAbilityCategory( slug );
+ if ( existingCategory ) {
+ return;
+ }
+
+ try {
+ const category = await registry
+ .resolveSelect( coreStore )
+ .getEntityRecord( ENTITY_KIND, ENTITY_NAME_CATEGORIES, slug );
+
+ if ( category ) {
+ await dispatch(
+ registerAbilityCategory( category.slug, {
+ label: category.label,
+ description: category.description,
+ meta: category.meta,
+ } )
+ );
+ }
+ } catch ( error ) {
+ // eslint-disable-next-line no-console
+ console.debug( `Category not found: ${ slug }` );
+ }
+ };
+}
diff --git a/packages/abilities/src/store/selectors.ts b/packages/abilities/src/store/selectors.ts
new file mode 100644
index 00000000000000..5b468ec086f893
--- /dev/null
+++ b/packages/abilities/src/store/selectors.ts
@@ -0,0 +1,82 @@
+/**
+ * WordPress dependencies
+ */
+import { createSelector } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import type {
+ Ability,
+ AbilityCategory,
+ AbilitiesQueryArgs,
+ AbilitiesState,
+} from '../types';
+
+/**
+ * Returns all registered abilities.
+ * Optionally filters by query arguments.
+ *
+ * @param state Store state.
+ * @param args Optional query arguments to filter. Defaults to empty object.
+ * @return Array of abilities.
+ */
+export const getAbilities = createSelector(
+ (
+ state: AbilitiesState,
+ { category }: AbilitiesQueryArgs = {}
+ ): Ability[] => {
+ const abilities = Object.values( state.abilitiesByName );
+ if ( category ) {
+ return abilities.filter(
+ ( ability ) => ability.category === category
+ );
+ }
+ return abilities;
+ },
+ ( state: AbilitiesState, category?: string ) => [
+ state.abilitiesByName,
+ category,
+ ]
+);
+
+/**
+ * Returns a specific ability by name.
+ *
+ * @param state Store state.
+ * @param name Ability name.
+ * @return Ability object or undefined if not found.
+ */
+export function getAbility(
+ state: AbilitiesState,
+ name: string
+): Ability | undefined {
+ return state.abilitiesByName[ name ];
+}
+
+/**
+ * Returns all registered ability categories.
+ *
+ * @param state Store state.
+ * @return Array of categories.
+ */
+export const getAbilityCategories = createSelector(
+ ( state: AbilitiesState ): AbilityCategory[] => {
+ return Object.values( state.categoriesBySlug );
+ },
+ ( state: AbilitiesState ) => [ state.categoriesBySlug ]
+);
+
+/**
+ * Returns a specific ability category by slug.
+ *
+ * @param state Store state.
+ * @param slug Category slug.
+ * @return Category object or undefined if not found.
+ */
+export function getAbilityCategory(
+ state: AbilitiesState,
+ slug: string
+): AbilityCategory | undefined {
+ return state.categoriesBySlug[ slug ];
+}
diff --git a/packages/abilities/src/store/tests/actions.test.ts b/packages/abilities/src/store/tests/actions.test.ts
new file mode 100644
index 00000000000000..6e32a57f3ffde7
--- /dev/null
+++ b/packages/abilities/src/store/tests/actions.test.ts
@@ -0,0 +1,950 @@
+/**
+ * Tests for store actions.
+ */
+
+/**
+ * WordPress dependencies
+ */
+import { resolveSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import {
+ receiveAbilities,
+ registerAbility,
+ unregisterAbility,
+ receiveCategories,
+ registerAbilityCategory,
+ unregisterAbilityCategory,
+} from '../actions';
+import {
+ RECEIVE_ABILITIES,
+ REGISTER_ABILITY,
+ UNREGISTER_ABILITY,
+ RECEIVE_CATEGORIES,
+ REGISTER_ABILITY_CATEGORY,
+ UNREGISTER_ABILITY_CATEGORY,
+} from '../constants';
+import type {
+ Ability,
+ AbilityCategory,
+ AbilityCategoryArgs,
+} from '../../types';
+
+// Mock the WordPress data store
+jest.mock( '@wordpress/data', () => ( {
+ resolveSelect: jest.fn(),
+} ) );
+
+describe( 'Store Actions', () => {
+ describe( 'receiveAbilities', () => {
+ it( 'should create an action to receive abilities', () => {
+ const abilities: Ability[] = [
+ {
+ name: 'test/ability1',
+ label: 'Test Ability 1',
+ description: 'First test ability',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ },
+ {
+ name: 'test/ability2',
+ label: 'Test Ability 2',
+ description: 'Second test ability',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ },
+ ];
+
+ const action = receiveAbilities( abilities );
+
+ expect( action ).toEqual( {
+ type: RECEIVE_ABILITIES,
+ abilities,
+ } );
+ } );
+
+ it( 'should handle empty abilities array', () => {
+ const abilities: Ability[] = [];
+ const action = receiveAbilities( abilities );
+
+ expect( action ).toEqual( {
+ type: RECEIVE_ABILITIES,
+ abilities: [],
+ } );
+ } );
+ } );
+
+ describe( 'registerAbility', () => {
+ let mockSelect: any;
+ let mockDispatch: jest.Mock;
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+ const defaultCategories = [
+ {
+ slug: 'test-category',
+ label: 'Test Category',
+ description: 'Test category for testing',
+ },
+ {
+ slug: 'data-retrieval',
+ label: 'Data Retrieval',
+ description: 'Abilities that retrieve data',
+ },
+ ];
+
+ mockSelect = {
+ getAbility: jest.fn().mockReturnValue( null ),
+ getAbilityCategories: jest
+ .fn()
+ .mockReturnValue( defaultCategories ),
+ getAbilityCategory: jest.fn().mockImplementation( ( slug ) => {
+ const categories: Record< string, any > = {
+ 'test-category': {
+ slug: 'test-category',
+ label: 'Test Category',
+ description: 'Test category for testing',
+ },
+ 'data-retrieval': {
+ slug: 'data-retrieval',
+ label: 'Data Retrieval',
+ description: 'Abilities that retrieve data',
+ },
+ };
+ return categories[ slug ] || null;
+ } ),
+ };
+ mockDispatch = jest.fn();
+
+ // Mock resolveSelect to return categories
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbilityCategories: jest
+ .fn()
+ .mockResolvedValue( defaultCategories ),
+ } );
+ } );
+
+ it( 'should register a valid client ability', async () => {
+ const ability: Ability = {
+ name: 'test/ability',
+ label: 'Test Ability',
+ description: 'Test ability description',
+ category: 'test-category',
+ input_schema: {
+ type: 'object',
+ properties: {
+ message: { type: 'string' },
+ },
+ },
+ output_schema: {
+ type: 'object',
+ properties: {
+ success: { type: 'boolean' },
+ },
+ },
+ callback: jest.fn(),
+ };
+
+ const action = registerAbility( ability );
+ await action( { select: mockSelect, dispatch: mockDispatch } );
+
+ expect( mockDispatch ).toHaveBeenCalledWith( {
+ type: REGISTER_ABILITY,
+ ability,
+ } );
+ } );
+
+ it( 'should register server-side abilities', async () => {
+ const ability: Ability = {
+ name: 'test/server-ability',
+ label: 'Server Ability',
+ description: 'Server-side ability',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ };
+
+ const action = registerAbility( ability );
+ await action( { select: mockSelect, dispatch: mockDispatch } );
+
+ expect( mockDispatch ).toHaveBeenCalledWith( {
+ type: REGISTER_ABILITY,
+ ability,
+ } );
+ } );
+
+ it( 'should validate and reject ability without name', async () => {
+ const ability: Ability = {
+ name: '',
+ label: 'Test Ability',
+ description: 'Test description',
+ category: 'test-category',
+ callback: jest.fn(),
+ };
+
+ const action = registerAbility( ability );
+
+ await expect(
+ action( { select: mockSelect, dispatch: mockDispatch } )
+ ).rejects.toThrow( 'Ability name is required' );
+ expect( mockDispatch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should validate and reject ability with invalid name format', async () => {
+ const testCases = [
+ 'invalid', // No namespace
+ 'my-plugin/feature/action', // Multiple slashes
+ 'My-Plugin/feature', // Uppercase letters
+ 'my_plugin/feature', // Underscores not allowed
+ 'my-plugin/feature!', // Special characters not allowed
+ 'my plugin/feature', // Spaces not allowed
+ ];
+
+ for ( const invalidName of testCases ) {
+ const ability: Ability = {
+ name: invalidName,
+ label: 'Test Ability',
+ description: 'Test description',
+ category: 'test-category',
+ callback: jest.fn(),
+ };
+
+ const action = registerAbility( ability );
+
+ await expect(
+ action( { select: mockSelect, dispatch: mockDispatch } )
+ ).rejects.toThrow(
+ 'Ability name must be a string containing a namespace prefix'
+ );
+ expect( mockDispatch ).not.toHaveBeenCalled();
+ mockDispatch.mockClear();
+ }
+ } );
+
+ it( 'should validate and reject ability without label', async () => {
+ const ability: Ability = {
+ name: 'test/ability',
+ label: '',
+ description: 'Test description',
+ category: 'test-category',
+ callback: jest.fn(),
+ };
+
+ const action = registerAbility( ability );
+
+ await expect(
+ action( { select: mockSelect, dispatch: mockDispatch } )
+ ).rejects.toThrow( 'Ability "test/ability" must have a label' );
+ expect( mockDispatch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should validate and reject ability without description', async () => {
+ const ability: Ability = {
+ name: 'test/ability',
+ label: 'Test Ability',
+ description: '',
+ category: 'test-category',
+ callback: jest.fn(),
+ };
+
+ const action = registerAbility( ability );
+
+ await expect(
+ action( { select: mockSelect, dispatch: mockDispatch } )
+ ).rejects.toThrow(
+ 'Ability "test/ability" must have a description'
+ );
+ expect( mockDispatch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should validate and reject ability without category', async () => {
+ const ability: Ability = {
+ name: 'test/ability',
+ label: 'Test Ability',
+ description: 'Test description',
+ category: '',
+ callback: jest.fn(),
+ };
+
+ const action = registerAbility( ability );
+
+ await expect(
+ action( { select: mockSelect, dispatch: mockDispatch } )
+ ).rejects.toThrow( 'Ability "test/ability" must have a category' );
+ expect( mockDispatch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should validate and reject ability with invalid category format', async () => {
+ const testCases = [
+ 'Data-Retrieval', // Uppercase letters
+ 'data_retrieval', // Underscores not allowed
+ 'data.retrieval', // Dots not allowed
+ 'data/retrieval', // Slashes not allowed
+ '-data-retrieval', // Leading dash
+ 'data-retrieval-', // Trailing dash
+ 'data--retrieval', // Double dash
+ ];
+
+ for ( const invalidCategory of testCases ) {
+ const ability: Ability = {
+ name: 'test/ability',
+ label: 'Test Ability',
+ description: 'Test description',
+ category: invalidCategory,
+ callback: jest.fn(),
+ };
+
+ const action = registerAbility( ability );
+
+ await expect(
+ action( { select: mockSelect, dispatch: mockDispatch } )
+ ).rejects.toThrow(
+ 'Ability "test/ability" has an invalid category. Category must be lowercase alphanumeric with dashes only'
+ );
+ expect( mockDispatch ).not.toHaveBeenCalled();
+ mockDispatch.mockClear();
+ }
+ } );
+
+ it( 'should accept ability with valid category format', async () => {
+ const validCategories = [
+ 'data-retrieval',
+ 'user-management',
+ 'analytics-123',
+ 'ecommerce',
+ ];
+
+ for ( const validCategory of validCategories ) {
+ const ability: Ability = {
+ name: 'test/ability-' + validCategory,
+ label: 'Test Ability',
+ description: 'Test description',
+ category: validCategory,
+ callback: jest.fn(),
+ };
+
+ const categoriesForTest = [
+ {
+ slug: 'test-category',
+ label: 'Test Category',
+ description: 'Test category for testing',
+ },
+ {
+ slug: 'data-retrieval',
+ label: 'Data Retrieval',
+ description: 'Abilities that retrieve data',
+ },
+ {
+ slug: validCategory,
+ label: 'Test Category',
+ description: 'Test',
+ },
+ ];
+
+ // Mock both select and resolveSelect
+ mockSelect.getAbilityCategories.mockReturnValue(
+ categoriesForTest
+ );
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbilityCategories: jest
+ .fn()
+ .mockResolvedValue( categoriesForTest ),
+ } );
+
+ mockSelect.getAbility.mockReturnValue( null );
+ mockDispatch.mockClear();
+
+ const action = registerAbility( ability );
+ await action( { select: mockSelect, dispatch: mockDispatch } );
+
+ expect( mockDispatch ).toHaveBeenCalledWith( {
+ type: REGISTER_ABILITY,
+ ability,
+ } );
+ }
+ } );
+
+ it( 'should validate and reject ability with non-existent category', async () => {
+ mockSelect.getAbilityCategories.mockReturnValue( [
+ {
+ slug: 'test-category',
+ label: 'Test Category',
+ description: 'Test category for testing',
+ },
+ {
+ slug: 'data-retrieval',
+ label: 'Data Retrieval',
+ description: 'Abilities that retrieve data',
+ },
+ ] );
+
+ const ability: Ability = {
+ name: 'test/ability',
+ label: 'Test Ability',
+ description: 'Test description',
+ category: 'non-existent-category',
+ callback: jest.fn(),
+ };
+
+ const action = registerAbility( ability );
+
+ await expect(
+ action( { select: mockSelect, dispatch: mockDispatch } )
+ ).rejects.toThrow(
+ 'Ability "test/ability" references non-existent category "non-existent-category". Please register the category first.'
+ );
+ expect( mockDispatch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should accept ability with existing category', async () => {
+ const categoriesForTest = [
+ {
+ slug: 'test-category',
+ label: 'Test Category',
+ description: 'Test category for testing',
+ },
+ {
+ slug: 'data-retrieval',
+ label: 'Data Retrieval',
+ description: 'Abilities that retrieve data',
+ },
+ ];
+
+ mockSelect.getAbilityCategories.mockReturnValue(
+ categoriesForTest
+ );
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbilityCategories: jest
+ .fn()
+ .mockResolvedValue( categoriesForTest ),
+ } );
+
+ const ability: Ability = {
+ name: 'test/ability',
+ label: 'Test Ability',
+ description: 'Test description',
+ category: 'data-retrieval',
+ callback: jest.fn(),
+ };
+
+ const action = registerAbility( ability );
+ await action( { select: mockSelect, dispatch: mockDispatch } );
+
+ // resolveSelect should have been called to load categories
+ expect( resolveSelect ).toHaveBeenCalledWith( 'core/abilities' );
+ expect( mockDispatch ).toHaveBeenCalledWith( {
+ type: REGISTER_ABILITY,
+ ability,
+ } );
+ } );
+
+ it( 'should validate and reject ability with invalid callback', async () => {
+ const ability: Ability = {
+ name: 'test/ability',
+ label: 'Test Ability',
+ description: 'Test description',
+ category: 'test-category',
+ callback: 'not a function' as any,
+ };
+
+ const action = registerAbility( ability );
+
+ await expect(
+ action( { select: mockSelect, dispatch: mockDispatch } )
+ ).rejects.toThrow(
+ 'Ability "test/ability" has an invalid callback. Callback must be a function'
+ );
+ expect( mockDispatch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should validate and reject already registered ability', async () => {
+ const existingAbility: Ability = {
+ name: 'test/ability',
+ label: 'Existing Ability',
+ description: 'Already registered',
+ category: 'test-category',
+ };
+
+ mockSelect.getAbility.mockReturnValue( existingAbility );
+
+ const ability: Ability = {
+ name: 'test/ability',
+ label: 'Test Ability',
+ description: 'Test description',
+ category: 'test-category',
+ callback: jest.fn(),
+ };
+
+ const action = registerAbility( ability );
+
+ await expect(
+ action( { select: mockSelect, dispatch: mockDispatch } )
+ ).rejects.toThrow( 'Ability "test/ability" is already registered' );
+ expect( mockDispatch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should load categories using resolveSelect before validation', async () => {
+ const categories = [
+ {
+ slug: 'test-category',
+ label: 'Test Category',
+ description: 'Test category',
+ },
+ ];
+
+ // Mock resolveSelect to return categories directly
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbilityCategories: jest.fn().mockResolvedValue( categories ),
+ } );
+
+ const ability: Ability = {
+ name: 'test/ability',
+ label: 'Test Ability',
+ description: 'Test description',
+ category: 'test-category',
+ callback: jest.fn(),
+ };
+
+ const action = registerAbility( ability );
+ await action( { select: mockSelect, dispatch: mockDispatch } );
+
+ // Should have called resolveSelect to load categories
+ expect( resolveSelect ).toHaveBeenCalledWith( 'core/abilities' );
+ // Should have successfully registered
+ expect( mockDispatch ).toHaveBeenCalledWith( {
+ type: REGISTER_ABILITY,
+ ability,
+ } );
+ } );
+ } );
+
+ describe( 'unregisterAbility', () => {
+ it( 'should create an action to unregister an ability', () => {
+ const abilityName = 'test/ability';
+ const action = unregisterAbility( abilityName );
+
+ expect( action ).toEqual( {
+ type: UNREGISTER_ABILITY,
+ name: abilityName,
+ } );
+ } );
+
+ it( 'should handle valid namespaced ability names', () => {
+ const abilityName = 'my-plugin/feature-action';
+ const action = unregisterAbility( abilityName );
+
+ expect( action ).toEqual( {
+ type: UNREGISTER_ABILITY,
+ name: abilityName,
+ } );
+ } );
+ } );
+
+ describe( 'receiveCategories', () => {
+ it( 'should create an action to receive categories', () => {
+ const categories: AbilityCategory[] = [
+ {
+ slug: 'data-retrieval',
+ label: 'Data Retrieval',
+ description: 'Abilities that retrieve data',
+ },
+ {
+ slug: 'user-management',
+ label: 'User Management',
+ description: 'Abilities for managing users',
+ },
+ ];
+
+ const action = receiveCategories( categories );
+
+ expect( action ).toEqual( {
+ type: RECEIVE_CATEGORIES,
+ categories,
+ } );
+ } );
+
+ it( 'should handle empty categories array', () => {
+ const categories: AbilityCategory[] = [];
+ const action = receiveCategories( categories );
+
+ expect( action ).toEqual( {
+ type: RECEIVE_CATEGORIES,
+ categories: [],
+ } );
+ } );
+ } );
+
+ describe( 'registerAbilityCategory', () => {
+ let mockSelect: any;
+ let mockDispatch: jest.Mock;
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+ mockSelect = {
+ getAbilityCategory: jest.fn().mockReturnValue( null ),
+ getAbilityCategories: jest.fn().mockReturnValue( [] ),
+ };
+ mockDispatch = jest.fn();
+
+ // Mock resolveSelect to return a mock that resolves the getAbilityCategories call
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbilityCategories: jest.fn().mockResolvedValue( undefined ),
+ } );
+ } );
+
+ it( 'should register a valid category', async () => {
+ const slug = 'test-category';
+ const args: AbilityCategoryArgs = {
+ label: 'Test Category',
+ description: 'A test category for testing',
+ };
+
+ const action = registerAbilityCategory( slug, args );
+ await action( { select: mockSelect, dispatch: mockDispatch } );
+
+ expect( mockDispatch ).toHaveBeenCalledWith( {
+ type: REGISTER_ABILITY_CATEGORY,
+ category: {
+ slug,
+ label: args.label,
+ description: args.description,
+ meta: {
+ _clientRegistered: true,
+ },
+ },
+ } );
+ } );
+
+ it( 'should register a category with meta', async () => {
+ const slug = 'test-category';
+ const args: AbilityCategoryArgs = {
+ label: 'Test Category',
+ description: 'A test category',
+ meta: { foo: 'bar', nested: { key: 'value' } },
+ };
+
+ const action = registerAbilityCategory( slug, args );
+ await action( { select: mockSelect, dispatch: mockDispatch } );
+
+ expect( mockDispatch ).toHaveBeenCalledWith( {
+ type: REGISTER_ABILITY_CATEGORY,
+ category: {
+ slug,
+ label: args.label,
+ description: args.description,
+ meta: {
+ ...args.meta,
+ _clientRegistered: true,
+ },
+ },
+ } );
+ } );
+
+ it( 'should validate and reject empty slug', async () => {
+ const args: AbilityCategoryArgs = {
+ label: 'Test',
+ description: 'Test',
+ };
+
+ const action = registerAbilityCategory( '', args );
+
+ await expect(
+ action( { select: mockSelect, dispatch: mockDispatch } )
+ ).rejects.toThrow( 'Category slug is required' );
+ expect( mockDispatch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should validate and reject invalid slug formats', async () => {
+ const testCases = [
+ 'Data-Retrieval', // Uppercase
+ 'data_retrieval', // Underscores
+ 'data.retrieval', // Dots
+ 'data/retrieval', // Slashes
+ '-data-retrieval', // Leading dash
+ 'data-retrieval-', // Trailing dash
+ 'data--retrieval', // Double dash
+ 'data retrieval', // Spaces
+ 'data!retrieval', // Special characters
+ ];
+
+ const args: AbilityCategoryArgs = {
+ label: 'Test',
+ description: 'Test',
+ };
+
+ for ( const invalidSlug of testCases ) {
+ const action = registerAbilityCategory( invalidSlug, args );
+
+ await expect(
+ action( { select: mockSelect, dispatch: mockDispatch } )
+ ).rejects.toThrow(
+ 'Category slug must contain only lowercase alphanumeric characters and dashes'
+ );
+ expect( mockDispatch ).not.toHaveBeenCalled();
+ mockDispatch.mockClear();
+ }
+ } );
+
+ it( 'should accept valid slug formats', async () => {
+ const validSlugs = [
+ 'data-retrieval',
+ 'user-management',
+ 'analytics-123',
+ 'ecommerce',
+ 'a',
+ '123',
+ 'test-multiple-words-with-dashes',
+ ];
+
+ const args: AbilityCategoryArgs = {
+ label: 'Test Category',
+ description: 'Test description',
+ };
+
+ for ( const validSlug of validSlugs ) {
+ mockSelect.getAbilityCategory.mockReturnValue( null );
+ mockDispatch.mockClear();
+
+ const action = registerAbilityCategory( validSlug, args );
+ await action( { select: mockSelect, dispatch: mockDispatch } );
+
+ expect( mockDispatch ).toHaveBeenCalledWith( {
+ type: REGISTER_ABILITY_CATEGORY,
+ category: {
+ slug: validSlug,
+ label: args.label,
+ description: args.description,
+ meta: {
+ _clientRegistered: true,
+ },
+ },
+ } );
+ }
+ } );
+
+ it( 'should validate and reject missing label', async () => {
+ const slug = 'test-category';
+ const args = {
+ label: '',
+ description: 'Test',
+ } as AbilityCategoryArgs;
+
+ const action = registerAbilityCategory( slug, args );
+
+ await expect(
+ action( { select: mockSelect, dispatch: mockDispatch } )
+ ).rejects.toThrow(
+ 'The category properties must contain a `label` string.'
+ );
+ expect( mockDispatch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should validate and reject non-string label', async () => {
+ const slug = 'test-category';
+ const args = {
+ label: 123 as any,
+ description: 'Test',
+ };
+
+ const action = registerAbilityCategory( slug, args );
+
+ await expect(
+ action( { select: mockSelect, dispatch: mockDispatch } )
+ ).rejects.toThrow(
+ 'The category properties must contain a `label` string.'
+ );
+ expect( mockDispatch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should validate and reject missing description', async () => {
+ const slug = 'test-category';
+ const args = {
+ label: 'Test',
+ description: '',
+ } as AbilityCategoryArgs;
+
+ const action = registerAbilityCategory( slug, args );
+
+ await expect(
+ action( { select: mockSelect, dispatch: mockDispatch } )
+ ).rejects.toThrow(
+ 'The category properties must contain a `description` string.'
+ );
+ expect( mockDispatch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should validate and reject non-string description', async () => {
+ const slug = 'test-category';
+ const args = {
+ label: 'Test',
+ description: 123 as any,
+ };
+
+ const action = registerAbilityCategory( slug, args );
+
+ await expect(
+ action( { select: mockSelect, dispatch: mockDispatch } )
+ ).rejects.toThrow(
+ 'The category properties must contain a `description` string.'
+ );
+ expect( mockDispatch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should validate and reject non-object meta', async () => {
+ const slug = 'test-category';
+ const args = {
+ label: 'Test',
+ description: 'Test',
+ meta: 'invalid' as any,
+ };
+
+ const action = registerAbilityCategory( slug, args );
+
+ await expect(
+ action( { select: mockSelect, dispatch: mockDispatch } )
+ ).rejects.toThrow(
+ 'The category properties should provide a valid `meta` object.'
+ );
+ expect( mockDispatch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should validate and reject array as meta', async () => {
+ const slug = 'test-category';
+ const args = {
+ label: 'Test',
+ description: 'Test',
+ meta: [ 'invalid' ] as any,
+ };
+
+ const action = registerAbilityCategory( slug, args );
+
+ await expect(
+ action( { select: mockSelect, dispatch: mockDispatch } )
+ ).rejects.toThrow(
+ 'The category properties should provide a valid `meta` object.'
+ );
+ expect( mockDispatch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should validate and reject already registered category', async () => {
+ const existingCategory: AbilityCategory = {
+ slug: 'test-category',
+ label: 'Existing Category',
+ description: 'Already registered',
+ };
+
+ mockSelect.getAbilityCategory.mockReturnValue( existingCategory );
+
+ const args: AbilityCategoryArgs = {
+ label: 'Test',
+ description: 'Test',
+ };
+
+ const action = registerAbilityCategory( 'test-category', args );
+
+ await expect(
+ action( { select: mockSelect, dispatch: mockDispatch } )
+ ).rejects.toThrow(
+ 'Category "test-category" is already registered.'
+ );
+ expect( mockDispatch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should allow registering ability after registering category', async () => {
+ // First register a category
+ const categorySlug = 'new-category';
+ const categoryArgs: AbilityCategoryArgs = {
+ label: 'New Category',
+ description: 'A newly registered category',
+ };
+
+ const categoryAction = registerAbilityCategory(
+ categorySlug,
+ categoryArgs
+ );
+ await categoryAction( {
+ select: mockSelect,
+ dispatch: mockDispatch,
+ } );
+
+ // Verify category was registered
+ expect( mockDispatch ).toHaveBeenCalledWith( {
+ type: REGISTER_ABILITY_CATEGORY,
+ category: {
+ slug: categorySlug,
+ label: categoryArgs.label,
+ description: categoryArgs.description,
+ meta: {
+ _clientRegistered: true,
+ },
+ },
+ } );
+
+ // Now mock that the category exists for ability registration
+ const categoriesWithNew = [
+ {
+ slug: categorySlug,
+ label: categoryArgs.label,
+ description: categoryArgs.description,
+ },
+ ];
+ mockSelect.getAbilityCategories = jest
+ .fn()
+ .mockReturnValue( categoriesWithNew );
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbilityCategories: jest
+ .fn()
+ .mockResolvedValue( categoriesWithNew ),
+ } );
+ mockSelect.getAbility = jest.fn().mockReturnValue( null );
+ mockDispatch.mockClear();
+
+ // Register an ability using the new category
+ const ability: Ability = {
+ name: 'test/ability',
+ label: 'Test Ability',
+ description: 'Test description',
+ category: categorySlug,
+ callback: jest.fn(),
+ };
+
+ const abilityAction = registerAbility( ability );
+ await abilityAction( {
+ select: mockSelect,
+ dispatch: mockDispatch,
+ } );
+
+ // Should successfully register with the new category
+ expect( mockDispatch ).toHaveBeenCalledWith( {
+ type: REGISTER_ABILITY,
+ ability,
+ } );
+ } );
+ } );
+
+ describe( 'unregisterAbilityCategory', () => {
+ it( 'should create an action to unregister a category', () => {
+ const slug = 'test-category';
+ const action = unregisterAbilityCategory( slug );
+
+ expect( action ).toEqual( {
+ type: UNREGISTER_ABILITY_CATEGORY,
+ slug,
+ } );
+ } );
+
+ it( 'should handle valid category slugs', () => {
+ const slug = 'data-retrieval';
+ const action = unregisterAbilityCategory( slug );
+
+ expect( action ).toEqual( {
+ type: UNREGISTER_ABILITY_CATEGORY,
+ slug,
+ } );
+ } );
+ } );
+} );
diff --git a/packages/abilities/src/store/tests/reducer.test.ts b/packages/abilities/src/store/tests/reducer.test.ts
new file mode 100644
index 00000000000000..1063d87c914304
--- /dev/null
+++ b/packages/abilities/src/store/tests/reducer.test.ts
@@ -0,0 +1,861 @@
+/**
+ * Tests for store reducer.
+ */
+
+/**
+ * Internal dependencies
+ */
+import reducer from '../reducer';
+import {
+ RECEIVE_ABILITIES,
+ REGISTER_ABILITY,
+ UNREGISTER_ABILITY,
+ RECEIVE_CATEGORIES,
+ REGISTER_ABILITY_CATEGORY,
+ UNREGISTER_ABILITY_CATEGORY,
+} from '../constants';
+
+describe( 'Store Reducer', () => {
+ describe( 'abilitiesByName', () => {
+ const defaultState = {};
+
+ describe( 'RECEIVE_ABILITIES', () => {
+ it( 'should add abilities to the state', () => {
+ const abilities = [
+ {
+ name: 'test/ability1',
+ label: 'Test Ability 1',
+ description: 'First test ability',
+ input_schema: { type: 'object' },
+ },
+ {
+ name: 'test/ability2',
+ label: 'Test Ability 2',
+ description: 'Second test ability',
+ output_schema: { type: 'object' },
+ },
+ ];
+
+ const action = {
+ type: RECEIVE_ABILITIES,
+ abilities,
+ };
+
+ const state = reducer(
+ { abilitiesByName: defaultState },
+ action
+ );
+
+ expect( state.abilitiesByName ).toHaveProperty(
+ 'test/ability1'
+ );
+ expect( state.abilitiesByName ).toHaveProperty(
+ 'test/ability2'
+ );
+ expect( state.abilitiesByName[ 'test/ability1' ].label ).toBe(
+ 'Test Ability 1'
+ );
+ expect( state.abilitiesByName[ 'test/ability2' ].label ).toBe(
+ 'Test Ability 2'
+ );
+ } );
+
+ it( 'should filter out _links from server responses', () => {
+ const abilities = [
+ {
+ name: 'test/ability',
+ label: 'Test Ability',
+ description: 'Test ability with links',
+ _links: {
+ self: {
+ href: '/wp-abilities/v1/abilities/test/ability',
+ },
+ collection: { href: '/wp-abilities/v1/abilities' },
+ },
+ },
+ ];
+
+ const action = {
+ type: RECEIVE_ABILITIES,
+ abilities,
+ };
+
+ const state = reducer(
+ { abilitiesByName: defaultState },
+ action
+ );
+
+ expect(
+ state.abilitiesByName[ 'test/ability' ]
+ ).not.toHaveProperty( '_links' );
+ expect( state.abilitiesByName[ 'test/ability' ].name ).toBe(
+ 'test/ability'
+ );
+ expect( state.abilitiesByName[ 'test/ability' ].label ).toBe(
+ 'Test Ability'
+ );
+ } );
+
+ it( 'should filter out _embedded from server responses', () => {
+ const abilities = [
+ {
+ name: 'test/ability',
+ label: 'Test Ability',
+ description: 'Test ability with embedded',
+ _embedded: {
+ author: { id: 1, name: 'Admin' },
+ },
+ },
+ ];
+
+ const action = {
+ type: RECEIVE_ABILITIES,
+ abilities,
+ };
+
+ const state = reducer(
+ { abilitiesByName: defaultState },
+ action
+ );
+
+ expect(
+ state.abilitiesByName[ 'test/ability' ]
+ ).not.toHaveProperty( '_embedded' );
+ } );
+
+ it( 'should preserve all valid ability properties', () => {
+ const abilities = [
+ {
+ name: 'test/ability',
+ label: 'Test Ability',
+ description: 'Full test ability.',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ meta: {
+ category: 'test',
+ },
+ callback: () => Promise.resolve( {} ),
+ permissionCallback: () => true,
+ // Extra properties that should be filtered out
+ _links: { self: { href: '/test' } },
+ _embedded: { test: 'value' },
+ extra_field: 'should be removed',
+ },
+ ];
+
+ const action = {
+ type: RECEIVE_ABILITIES,
+ abilities,
+ };
+
+ const state = reducer(
+ { abilitiesByName: defaultState },
+ action
+ );
+ const ability = state.abilitiesByName[ 'test/ability' ];
+
+ // Should have valid properties
+ expect( ability.name ).toBe( 'test/ability' );
+ expect( ability.label ).toBe( 'Test Ability' );
+ expect( ability.description ).toBe( 'Full test ability.' );
+ expect( ability.input_schema ).toEqual( { type: 'object' } );
+ expect( ability.output_schema ).toEqual( { type: 'object' } );
+ expect( ability.meta ).toEqual( { category: 'test' } );
+ expect( ability.callback ).toBeDefined();
+ expect( ability.permissionCallback ).toBeDefined();
+
+ // Should NOT have invalid properties
+ expect( ability ).not.toHaveProperty( '_links' );
+ expect( ability ).not.toHaveProperty( '_embedded' );
+ expect( ability ).not.toHaveProperty( 'extra_field' );
+ } );
+ } );
+
+ describe( 'REGISTER_ABILITY', () => {
+ it( 'should add ability to the state', () => {
+ const ability = {
+ name: 'test/ability',
+ label: 'Test Ability',
+ description: 'Test ability',
+ callback: () => Promise.resolve( {} ),
+ };
+
+ const action = {
+ type: REGISTER_ABILITY,
+ ability,
+ };
+
+ const state = reducer(
+ { abilitiesByName: defaultState },
+ action
+ );
+
+ expect( state.abilitiesByName ).toHaveProperty(
+ 'test/ability'
+ );
+ expect( state.abilitiesByName[ 'test/ability' ].label ).toBe(
+ 'Test Ability'
+ );
+ } );
+
+ it( 'should filter out extra properties when registering', () => {
+ const ability = {
+ name: 'test/ability',
+ label: 'Test Ability',
+ description: 'Test ability',
+ callback: () => Promise.resolve( {} ),
+ // Extra properties that should be filtered out
+ _links: { self: { href: '/test' } },
+ extra_field: 'should be removed',
+ };
+
+ const action = {
+ type: REGISTER_ABILITY,
+ ability,
+ };
+
+ const state = reducer(
+ { abilitiesByName: defaultState },
+ action
+ );
+ const registeredAbility =
+ state.abilitiesByName[ 'test/ability' ];
+
+ // Should have valid properties
+ expect( registeredAbility.name ).toBe( 'test/ability' );
+ expect( registeredAbility.label ).toBe( 'Test Ability' );
+ expect( registeredAbility.description ).toBe( 'Test ability' );
+ expect( registeredAbility.callback ).toBeDefined();
+
+ // Should NOT have invalid properties
+ expect( registeredAbility ).not.toHaveProperty( '_links' );
+ expect( registeredAbility ).not.toHaveProperty( 'extra_field' );
+ } );
+
+ it( 'should replace existing ability', () => {
+ const initialState = {
+ 'test/ability': {
+ name: 'test/ability',
+ label: 'Old Label',
+ description: 'Old description',
+ },
+ };
+
+ const ability = {
+ name: 'test/ability',
+ label: 'New Label',
+ description: 'New description',
+ input_schema: { type: 'string' },
+ };
+
+ const action = {
+ type: REGISTER_ABILITY,
+ ability,
+ };
+
+ const state = reducer(
+ { abilitiesByName: initialState },
+ action
+ );
+
+ expect( state.abilitiesByName[ 'test/ability' ].label ).toBe(
+ 'New Label'
+ );
+ expect(
+ state.abilitiesByName[ 'test/ability' ].description
+ ).toBe( 'New description' );
+ expect(
+ state.abilitiesByName[ 'test/ability' ].input_schema
+ ).toEqual( { type: 'string' } );
+ } );
+ } );
+
+ describe( 'UNREGISTER_ABILITY', () => {
+ it( 'should remove ability from the state', () => {
+ const initialState = {
+ 'test/ability1': {
+ name: 'test/ability1',
+ label: 'Test Ability 1',
+ description: 'First test ability',
+ },
+ 'test/ability2': {
+ name: 'test/ability2',
+ label: 'Test Ability 2',
+ description: 'Second test ability',
+ },
+ };
+
+ const action = {
+ type: UNREGISTER_ABILITY,
+ name: 'test/ability1',
+ };
+
+ const state = reducer(
+ { abilitiesByName: initialState },
+ action
+ );
+
+ expect( state.abilitiesByName ).not.toHaveProperty(
+ 'test/ability1'
+ );
+ expect( state.abilitiesByName ).toHaveProperty(
+ 'test/ability2'
+ );
+ } );
+ } );
+
+ describe( 'Edge cases', () => {
+ it( 'should handle unregistering non-existent ability', () => {
+ const initialState = {
+ 'test/ability': {
+ name: 'test/ability',
+ label: 'Test Ability',
+ description: 'Test ability',
+ },
+ };
+
+ const action = {
+ type: UNREGISTER_ABILITY,
+ name: 'test/non-existent',
+ };
+
+ const state = reducer(
+ { abilitiesByName: initialState },
+ action
+ );
+
+ expect( state.abilitiesByName ).toEqual( initialState );
+ } );
+
+ it( 'should handle undefined abilities in RECEIVE_ABILITIES', () => {
+ const action = {
+ type: RECEIVE_ABILITIES,
+ abilities: undefined,
+ };
+
+ const state = reducer(
+ { abilitiesByName: defaultState },
+ action
+ );
+
+ expect( state.abilitiesByName ).toEqual( defaultState );
+ } );
+
+ it( 'should handle undefined ability in REGISTER_ABILITY', () => {
+ const action = {
+ type: REGISTER_ABILITY,
+ ability: undefined,
+ };
+
+ const state = reducer(
+ { abilitiesByName: defaultState },
+ action
+ );
+
+ expect( state.abilitiesByName ).toEqual( defaultState );
+ } );
+
+ it( 'should handle undefined properties gracefully', () => {
+ const abilities = [
+ {
+ name: 'test/minimal',
+ label: 'Minimal',
+ description:
+ 'Minimal ability with undefined properties',
+ input_schema: undefined,
+ output_schema: undefined,
+ meta: undefined,
+ callback: undefined,
+ permissionCallback: undefined,
+ },
+ ];
+
+ const action = {
+ type: RECEIVE_ABILITIES,
+ abilities,
+ };
+
+ const state = reducer(
+ { abilitiesByName: defaultState },
+ action
+ );
+ const ability = state.abilitiesByName[ 'test/minimal' ];
+
+ expect( ability.name ).toBe( 'test/minimal' );
+ expect( ability.label ).toBe( 'Minimal' );
+ expect( ability.description ).toBe(
+ 'Minimal ability with undefined properties'
+ );
+ // Undefined properties should not be present
+ expect( ability ).not.toHaveProperty( 'input_schema' );
+ expect( ability ).not.toHaveProperty( 'output_schema' );
+ expect( ability ).not.toHaveProperty( 'meta' );
+ expect( ability ).not.toHaveProperty( 'callback' );
+ expect( ability ).not.toHaveProperty( 'permissionCallback' );
+ } );
+ } );
+ } );
+
+ describe( 'categoriesBySlug', () => {
+ const defaultState = {};
+
+ describe( 'RECEIVE_CATEGORIES', () => {
+ it( 'should add categories to the state', () => {
+ const categories = [
+ {
+ slug: 'data-retrieval',
+ label: 'Data Retrieval',
+ description: 'Abilities that retrieve data',
+ },
+ {
+ slug: 'user-management',
+ label: 'User Management',
+ description: 'Abilities for managing users',
+ },
+ ];
+
+ const action = {
+ type: RECEIVE_CATEGORIES,
+ categories,
+ };
+
+ const state = reducer(
+ { categoriesBySlug: defaultState, abilitiesByName: {} },
+ action
+ );
+
+ expect( state.categoriesBySlug ).toHaveProperty(
+ 'data-retrieval'
+ );
+ expect( state.categoriesBySlug ).toHaveProperty(
+ 'user-management'
+ );
+ expect( state.categoriesBySlug[ 'data-retrieval' ].label ).toBe(
+ 'Data Retrieval'
+ );
+ expect(
+ state.categoriesBySlug[ 'user-management' ].label
+ ).toBe( 'User Management' );
+ } );
+
+ it( 'should filter out _links from server responses', () => {
+ const categories = [
+ {
+ slug: 'data-retrieval',
+ label: 'Data Retrieval',
+ description: 'Test category with links',
+ _links: {
+ self: {
+ href: '/wp-abilities/v1/categories/data-retrieval',
+ },
+ collection: {
+ href: '/wp-abilities/v1/categories',
+ },
+ },
+ },
+ ];
+
+ const action = {
+ type: RECEIVE_CATEGORIES,
+ categories,
+ };
+
+ const state = reducer(
+ { categoriesBySlug: defaultState, abilitiesByName: {} },
+ action
+ );
+
+ expect(
+ state.categoriesBySlug[ 'data-retrieval' ]
+ ).not.toHaveProperty( '_links' );
+ expect( state.categoriesBySlug[ 'data-retrieval' ].slug ).toBe(
+ 'data-retrieval'
+ );
+ expect( state.categoriesBySlug[ 'data-retrieval' ].label ).toBe(
+ 'Data Retrieval'
+ );
+ } );
+
+ it( 'should filter out _embedded from server responses', () => {
+ const categories = [
+ {
+ slug: 'data-retrieval',
+ label: 'Data Retrieval',
+ description: 'Test category with embedded',
+ _embedded: {
+ author: { id: 1, name: 'Admin' },
+ },
+ },
+ ];
+
+ const action = {
+ type: RECEIVE_CATEGORIES,
+ categories,
+ };
+
+ const state = reducer(
+ { categoriesBySlug: defaultState, abilitiesByName: {} },
+ action
+ );
+
+ expect(
+ state.categoriesBySlug[ 'data-retrieval' ]
+ ).not.toHaveProperty( '_embedded' );
+ } );
+
+ it( 'should preserve all valid category properties', () => {
+ const categories = [
+ {
+ slug: 'data-retrieval',
+ label: 'Data Retrieval',
+ description: 'Full test category.',
+ meta: {
+ priority: 'high',
+ color: 'blue',
+ },
+ // Extra properties that should be filtered out
+ _links: { self: { href: '/test' } },
+ _embedded: { test: 'value' },
+ extra_field: 'should be removed',
+ },
+ ];
+
+ const action = {
+ type: RECEIVE_CATEGORIES,
+ categories,
+ };
+
+ const state = reducer(
+ { categoriesBySlug: defaultState, abilitiesByName: {} },
+ action
+ );
+ const category = state.categoriesBySlug[ 'data-retrieval' ];
+
+ // Should have valid properties
+ expect( category.slug ).toBe( 'data-retrieval' );
+ expect( category.label ).toBe( 'Data Retrieval' );
+ expect( category.description ).toBe( 'Full test category.' );
+ expect( category.meta ).toEqual( {
+ priority: 'high',
+ color: 'blue',
+ } );
+
+ // Should NOT have invalid properties
+ expect( category ).not.toHaveProperty( '_links' );
+ expect( category ).not.toHaveProperty( '_embedded' );
+ expect( category ).not.toHaveProperty( 'extra_field' );
+ } );
+
+ it( 'should overwrite existing categories', () => {
+ const initialState = {
+ 'existing-category': {
+ slug: 'existing-category',
+ label: 'Existing Category',
+ description: 'Already in store',
+ },
+ };
+
+ const categories = [
+ {
+ slug: 'data-retrieval',
+ label: 'Data Retrieval',
+ description: 'New category',
+ },
+ ];
+
+ const action = {
+ type: RECEIVE_CATEGORIES,
+ categories,
+ };
+
+ const state = reducer(
+ { categoriesBySlug: initialState, abilitiesByName: {} },
+ action
+ );
+
+ // Should only have new categories, old ones are replaced
+ expect( state.categoriesBySlug ).not.toHaveProperty(
+ 'existing-category'
+ );
+ expect( state.categoriesBySlug ).toHaveProperty(
+ 'data-retrieval'
+ );
+ } );
+ } );
+
+ describe( 'Edge cases', () => {
+ it( 'should handle undefined categories in RECEIVE_CATEGORIES', () => {
+ const action = {
+ type: RECEIVE_CATEGORIES,
+ categories: undefined,
+ };
+
+ const state = reducer(
+ { categoriesBySlug: defaultState, abilitiesByName: {} },
+ action
+ );
+
+ expect( state.categoriesBySlug ).toEqual( defaultState );
+ } );
+
+ it( 'should handle empty categories array', () => {
+ const action = {
+ type: RECEIVE_CATEGORIES,
+ categories: [],
+ };
+
+ const state = reducer(
+ { categoriesBySlug: defaultState, abilitiesByName: {} },
+ action
+ );
+
+ expect( state.categoriesBySlug ).toEqual( defaultState );
+ } );
+
+ it( 'should handle undefined properties gracefully', () => {
+ const categories = [
+ {
+ slug: 'minimal',
+ label: 'Minimal',
+ description: 'Minimal category with undefined meta',
+ meta: undefined,
+ },
+ ];
+
+ const action = {
+ type: RECEIVE_CATEGORIES,
+ categories,
+ };
+
+ const state = reducer(
+ { categoriesBySlug: defaultState, abilitiesByName: {} },
+ action
+ );
+ const category = state.categoriesBySlug.minimal;
+
+ expect( category.slug ).toBe( 'minimal' );
+ expect( category.label ).toBe( 'Minimal' );
+ expect( category.description ).toBe(
+ 'Minimal category with undefined meta'
+ );
+ // Undefined properties should not be present
+ expect( category ).not.toHaveProperty( 'meta' );
+ } );
+ } );
+
+ describe( 'REGISTER_ABILITY_CATEGORY', () => {
+ it( 'should add category to the state', () => {
+ const category = {
+ slug: 'test-category',
+ label: 'Test Category',
+ description: 'A test category',
+ };
+
+ const action = {
+ type: REGISTER_ABILITY_CATEGORY,
+ category,
+ };
+
+ const state = reducer(
+ { categoriesBySlug: defaultState, abilitiesByName: {} },
+ action
+ );
+
+ expect( state.categoriesBySlug ).toHaveProperty(
+ 'test-category'
+ );
+ expect( state.categoriesBySlug[ 'test-category' ].label ).toBe(
+ 'Test Category'
+ );
+ } );
+
+ it( 'should add category with meta to the state', () => {
+ const category = {
+ slug: 'test-category',
+ label: 'Test Category',
+ description: 'A test category',
+ meta: { color: 'blue', priority: 'high' },
+ };
+
+ const action = {
+ type: REGISTER_ABILITY_CATEGORY,
+ category,
+ };
+
+ const state = reducer(
+ { categoriesBySlug: defaultState, abilitiesByName: {} },
+ action
+ );
+
+ expect(
+ state.categoriesBySlug[ 'test-category' ].meta
+ ).toEqual( { color: 'blue', priority: 'high' } );
+ } );
+
+ it( 'should filter out extra properties when registering', () => {
+ const category = {
+ slug: 'test-category',
+ label: 'Test Category',
+ description: 'A test category',
+ // Extra properties that should be filtered out
+ _links: { self: { href: '/test' } },
+ _embedded: { author: { id: 1 } },
+ extra_field: 'should be removed',
+ };
+
+ const action = {
+ type: REGISTER_ABILITY_CATEGORY,
+ category,
+ };
+
+ const state = reducer(
+ { categoriesBySlug: defaultState, abilitiesByName: {} },
+ action
+ );
+ const registeredCategory =
+ state.categoriesBySlug[ 'test-category' ];
+
+ // Should have valid properties
+ expect( registeredCategory.slug ).toBe( 'test-category' );
+ expect( registeredCategory.label ).toBe( 'Test Category' );
+ expect( registeredCategory.description ).toBe(
+ 'A test category'
+ );
+
+ // Should NOT have invalid properties
+ expect( registeredCategory ).not.toHaveProperty( '_links' );
+ expect( registeredCategory ).not.toHaveProperty( '_embedded' );
+ expect( registeredCategory ).not.toHaveProperty(
+ 'extra_field'
+ );
+ } );
+
+ it( 'should replace existing category', () => {
+ const initialState = {
+ 'test-category': {
+ slug: 'test-category',
+ label: 'Old Label',
+ description: 'Old description',
+ },
+ };
+
+ const category = {
+ slug: 'test-category',
+ label: 'New Label',
+ description: 'New description',
+ meta: { color: 'red' },
+ };
+
+ const action = {
+ type: REGISTER_ABILITY_CATEGORY,
+ category,
+ };
+
+ const state = reducer(
+ { categoriesBySlug: initialState, abilitiesByName: {} },
+ action
+ );
+
+ expect( state.categoriesBySlug[ 'test-category' ].label ).toBe(
+ 'New Label'
+ );
+ expect(
+ state.categoriesBySlug[ 'test-category' ].description
+ ).toBe( 'New description' );
+ expect(
+ state.categoriesBySlug[ 'test-category' ].meta
+ ).toEqual( { color: 'red' } );
+ } );
+
+ it( 'should handle undefined category', () => {
+ const action = {
+ type: REGISTER_ABILITY_CATEGORY,
+ category: undefined,
+ };
+
+ const state = reducer(
+ { categoriesBySlug: defaultState, abilitiesByName: {} },
+ action
+ );
+
+ expect( state.categoriesBySlug ).toEqual( defaultState );
+ } );
+ } );
+
+ describe( 'UNREGISTER_ABILITY_CATEGORY', () => {
+ it( 'should remove category from the state', () => {
+ const initialState = {
+ category1: {
+ slug: 'category1',
+ label: 'Category 1',
+ description: 'First category',
+ },
+ category2: {
+ slug: 'category2',
+ label: 'Category 2',
+ description: 'Second category',
+ },
+ };
+
+ const action = {
+ type: UNREGISTER_ABILITY_CATEGORY,
+ slug: 'category1',
+ };
+
+ const state = reducer(
+ { categoriesBySlug: initialState, abilitiesByName: {} },
+ action
+ );
+
+ expect( state.categoriesBySlug ).not.toHaveProperty(
+ 'category1'
+ );
+ expect( state.categoriesBySlug ).toHaveProperty( 'category2' );
+ } );
+
+ it( 'should handle unregistering non-existent category', () => {
+ const initialState = {
+ 'test-category': {
+ slug: 'test-category',
+ label: 'Test Category',
+ description: 'A test category',
+ },
+ };
+
+ const action = {
+ type: UNREGISTER_ABILITY_CATEGORY,
+ slug: 'non-existent',
+ };
+
+ const state = reducer(
+ { categoriesBySlug: initialState, abilitiesByName: {} },
+ action
+ );
+
+ expect( state.categoriesBySlug ).toEqual( initialState );
+ } );
+
+ it( 'should handle undefined slug', () => {
+ const initialState = {
+ 'test-category': {
+ slug: 'test-category',
+ label: 'Test Category',
+ description: 'A test category',
+ },
+ };
+
+ const action = {
+ type: UNREGISTER_ABILITY_CATEGORY,
+ slug: undefined,
+ };
+
+ const state = reducer(
+ { categoriesBySlug: initialState, abilitiesByName: {} },
+ action
+ );
+
+ expect( state.categoriesBySlug ).toEqual( initialState );
+ } );
+ } );
+ } );
+} );
diff --git a/packages/abilities/src/store/tests/resolvers.test.ts b/packages/abilities/src/store/tests/resolvers.test.ts
new file mode 100644
index 00000000000000..6ed9d19a958e67
--- /dev/null
+++ b/packages/abilities/src/store/tests/resolvers.test.ts
@@ -0,0 +1,642 @@
+/**
+ * Tests for store resolvers.
+ */
+
+/**
+ * WordPress dependencies
+ */
+import { store as coreStore } from '@wordpress/core-data';
+
+/**
+ * Internal dependencies
+ */
+import {
+ getAbilities,
+ getAbility,
+ getAbilityCategories,
+ getAbilityCategory,
+} from '../resolvers';
+import { receiveAbilities, receiveCategories } from '../actions';
+import { ENTITY_KIND, ENTITY_NAME, ENTITY_NAME_CATEGORIES } from '../constants';
+import type { Ability, AbilityCategory } from '../../types';
+
+// Mock the WordPress core data store
+jest.mock( '@wordpress/core-data', () => ( {
+ store: 'core',
+} ) );
+
+describe( 'Store Resolvers', () => {
+ let mockDispatch: jest.Mock;
+ let mockRegistry: any;
+ let mockSelect: any;
+
+ beforeEach( () => {
+ mockDispatch = jest.fn();
+ mockSelect = jest.fn();
+ mockRegistry = {
+ resolveSelect: jest.fn(),
+ select: jest.fn(),
+ };
+ } );
+
+ describe( 'getAbilities', () => {
+ it( 'should fetch and dispatch abilities from the server', async () => {
+ const mockAbilities: Ability[] = [
+ {
+ name: 'test/ability1',
+ label: 'Test Ability 1',
+ description: 'First test ability',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ },
+ ];
+
+ const mockResolveSelect = {
+ getEntityRecords: jest.fn().mockResolvedValue( mockAbilities ),
+ };
+
+ const mockSelectInstance = {
+ getAbilities: jest.fn().mockReturnValue( [] ), // Store is empty
+ };
+
+ mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect );
+
+ const resolver = getAbilities();
+ await resolver( {
+ dispatch: mockDispatch,
+ registry: mockRegistry,
+ select: mockSelectInstance,
+ } );
+
+ expect( mockRegistry.resolveSelect ).toHaveBeenCalledWith(
+ coreStore
+ );
+ expect( mockResolveSelect.getEntityRecords ).toHaveBeenCalledWith(
+ ENTITY_KIND,
+ ENTITY_NAME,
+ { per_page: -1 }
+ );
+ expect( mockDispatch ).toHaveBeenCalledWith(
+ receiveAbilities( mockAbilities )
+ );
+ } );
+
+ it( 'should not fetch if store already has abilities', async () => {
+ const existingAbilities: Ability[] = [
+ {
+ name: 'test/ability1',
+ label: 'Test Ability 1',
+ description: 'First test ability',
+ category: 'data-retrieval',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ },
+ ];
+
+ const mockResolveSelect = {
+ getEntityRecords: jest.fn(),
+ };
+
+ const mockSelectInstance = {
+ getAbilities: jest.fn().mockReturnValue( existingAbilities ), // Store has data
+ };
+
+ mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect );
+
+ const resolver = getAbilities( { category: 'data-retrieval' } );
+ await resolver( {
+ dispatch: mockDispatch,
+ registry: mockRegistry,
+ select: mockSelectInstance,
+ } );
+
+ // Should not fetch since store already has abilities
+ expect( mockResolveSelect.getEntityRecords ).not.toHaveBeenCalled();
+ expect( mockDispatch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should handle empty abilities', async () => {
+ const mockResolveSelect = {
+ getEntityRecords: jest.fn().mockResolvedValue( [] ),
+ };
+
+ const mockSelectInstance = {
+ getAbilities: jest.fn().mockReturnValue( [] ), // Store is empty
+ };
+
+ mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect );
+
+ const resolver = getAbilities();
+ await resolver( {
+ dispatch: mockDispatch,
+ registry: mockRegistry,
+ select: mockSelectInstance,
+ } );
+
+ expect( mockDispatch ).toHaveBeenCalledWith(
+ receiveAbilities( [] )
+ );
+ } );
+
+ it( 'should handle null response', async () => {
+ const mockResolveSelect = {
+ getEntityRecords: jest.fn().mockResolvedValue( null ),
+ };
+
+ const mockSelectInstance = {
+ getAbilities: jest.fn().mockReturnValue( [] ), // Store is empty
+ };
+
+ mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect );
+
+ const resolver = getAbilities();
+ await resolver( {
+ dispatch: mockDispatch,
+ registry: mockRegistry,
+ select: mockSelectInstance,
+ } );
+
+ expect( mockDispatch ).toHaveBeenCalledWith(
+ receiveAbilities( [] )
+ );
+ } );
+
+ it( 'should fetch all abilities in a single request', async () => {
+ const allAbilities: Ability[] = [
+ {
+ name: 'test/ability1',
+ label: 'Test Ability 1',
+ description: 'First test ability',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ },
+ {
+ name: 'test/ability2',
+ label: 'Test Ability 2',
+ description: 'Second test ability',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ },
+ {
+ name: 'test/ability3',
+ label: 'Test Ability 3',
+ description: 'Third test ability',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ },
+ ];
+
+ const mockResolveSelect = {
+ getEntityRecords: jest.fn().mockResolvedValue( allAbilities ),
+ };
+
+ const mockSelectInstance = {
+ getAbilities: jest.fn().mockReturnValue( [] ), // Store is empty
+ };
+
+ mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect );
+
+ const resolver = getAbilities();
+ await resolver( {
+ dispatch: mockDispatch,
+ registry: mockRegistry,
+ select: mockSelectInstance,
+ } );
+
+ // Should fetch all abilities in one request with per_page: -1
+ expect( mockResolveSelect.getEntityRecords ).toHaveBeenCalledTimes(
+ 1
+ );
+ expect(
+ mockResolveSelect.getEntityRecords
+ ).toHaveBeenNthCalledWith( 1, ENTITY_KIND, ENTITY_NAME, {
+ per_page: -1,
+ } );
+
+ // Should dispatch all abilities
+ expect( mockDispatch ).toHaveBeenCalledWith(
+ receiveAbilities( allAbilities )
+ );
+ } );
+ } );
+
+ describe( 'getAbility', () => {
+ it( 'should fetch and dispatch a specific ability', async () => {
+ const mockAbility: Ability = {
+ name: 'test/ability',
+ label: 'Test Ability',
+ description: 'Test ability description',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ };
+
+ const mockResolveSelect = {
+ getEntityRecord: jest.fn().mockResolvedValue( mockAbility ),
+ };
+
+ mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect );
+ mockSelect.getAbility = jest.fn().mockReturnValue( null ); // Ability not in store
+
+ const resolver = getAbility( 'test/ability' );
+ await resolver( {
+ dispatch: mockDispatch,
+ registry: mockRegistry,
+ select: mockSelect,
+ } );
+
+ expect( mockSelect.getAbility ).toHaveBeenCalledWith(
+ 'test/ability'
+ );
+ expect( mockRegistry.resolveSelect ).toHaveBeenCalledWith(
+ coreStore
+ );
+ expect( mockResolveSelect.getEntityRecord ).toHaveBeenCalledWith(
+ ENTITY_KIND,
+ ENTITY_NAME,
+ 'test/ability'
+ );
+ // registerAbility returns a thunk, so we just verify dispatch was called
+ expect( mockDispatch ).toHaveBeenCalled();
+ expect( typeof mockDispatch.mock.calls[ 0 ][ 0 ] ).toBe(
+ 'function'
+ );
+ } );
+
+ it( 'should not fetch if ability already exists in store', async () => {
+ const existingAbility: Ability = {
+ name: 'test/ability',
+ label: 'Test Ability',
+ description: 'Already in store',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ callback: jest.fn(),
+ };
+
+ mockSelect.getAbility = jest
+ .fn()
+ .mockReturnValue( existingAbility );
+
+ const resolver = getAbility( 'test/ability' );
+ await resolver( {
+ dispatch: mockDispatch,
+ registry: mockRegistry,
+ select: mockSelect,
+ } );
+
+ expect( mockSelect.getAbility ).toHaveBeenCalledWith(
+ 'test/ability'
+ );
+ expect( mockRegistry.resolveSelect ).not.toHaveBeenCalled();
+ expect( mockDispatch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should handle non-existent abilities', async () => {
+ const mockResolveSelect = {
+ getEntityRecord: jest.fn().mockResolvedValue( null ),
+ };
+
+ mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect );
+ mockSelect.getAbility = jest.fn().mockReturnValue( null );
+
+ const resolver = getAbility( 'non-existent' );
+ await resolver( {
+ dispatch: mockDispatch,
+ registry: mockRegistry,
+ select: mockSelect,
+ } );
+
+ expect( mockResolveSelect.getEntityRecord ).toHaveBeenCalledWith(
+ ENTITY_KIND,
+ ENTITY_NAME,
+ 'non-existent'
+ );
+ expect( mockDispatch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should handle valid namespaced ability names', async () => {
+ const mockAbility: Ability = {
+ name: 'my-plugin/feature-action',
+ label: 'Namespaced Action',
+ description: 'Namespaced ability',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ };
+
+ const mockResolveSelect = {
+ getEntityRecord: jest.fn().mockResolvedValue( mockAbility ),
+ };
+
+ mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect );
+ mockSelect.getAbility = jest.fn().mockReturnValue( null );
+
+ const resolver = getAbility( 'my-plugin/feature-action' );
+ await resolver( {
+ dispatch: mockDispatch,
+ registry: mockRegistry,
+ select: mockSelect,
+ } );
+
+ expect( mockResolveSelect.getEntityRecord ).toHaveBeenCalledWith(
+ ENTITY_KIND,
+ ENTITY_NAME,
+ 'my-plugin/feature-action'
+ );
+ // registerAbility returns a thunk, so we just verify dispatch was called
+ expect( mockDispatch ).toHaveBeenCalled();
+ expect( typeof mockDispatch.mock.calls[ 0 ][ 0 ] ).toBe(
+ 'function'
+ );
+ } );
+ } );
+
+ describe( 'getAbilityCategories', () => {
+ it( 'should fetch and dispatch categories from the server', async () => {
+ const mockCategories: AbilityCategory[] = [
+ {
+ slug: 'data-retrieval',
+ label: 'Data Retrieval',
+ description: 'Abilities that retrieve data',
+ },
+ {
+ slug: 'user-management',
+ label: 'User Management',
+ description: 'Abilities for managing users',
+ },
+ ];
+
+ const mockResolveSelect = {
+ getEntityRecords: jest.fn().mockResolvedValue( mockCategories ),
+ };
+
+ const mockSelectInstance = {
+ getAbilityCategories: jest.fn().mockReturnValue( [] ),
+ };
+
+ mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect );
+
+ const resolver = getAbilityCategories();
+ await resolver( {
+ dispatch: mockDispatch,
+ registry: mockRegistry,
+ select: mockSelectInstance,
+ } );
+
+ expect( mockRegistry.resolveSelect ).toHaveBeenCalledWith(
+ coreStore
+ );
+ expect( mockResolveSelect.getEntityRecords ).toHaveBeenCalledWith(
+ ENTITY_KIND,
+ ENTITY_NAME_CATEGORIES,
+ { per_page: -1 }
+ );
+ expect( mockDispatch ).toHaveBeenCalledWith(
+ receiveCategories( mockCategories )
+ );
+ } );
+
+ it( 'should not fetch if store already has categories', async () => {
+ const existingCategories: AbilityCategory[] = [
+ {
+ slug: 'data-retrieval',
+ label: 'Data Retrieval',
+ description: 'Abilities that retrieve data',
+ },
+ ];
+
+ const mockResolveSelect = {
+ getEntityRecords: jest.fn(),
+ };
+
+ const mockSelectInstance = {
+ getAbilityCategories: jest
+ .fn()
+ .mockReturnValue( existingCategories ),
+ };
+
+ mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect );
+
+ const resolver = getAbilityCategories();
+ await resolver( {
+ dispatch: mockDispatch,
+ registry: mockRegistry,
+ select: mockSelectInstance,
+ } );
+
+ // Should not fetch since store already has categories
+ expect( mockResolveSelect.getEntityRecords ).not.toHaveBeenCalled();
+ expect( mockDispatch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should fetch from server even when only client-registered categories exist', async () => {
+ // This tests the scenario where a client category is registered first
+ // The resolver should still fetch server categories
+ const clientOnlyCategories: AbilityCategory[] = [
+ {
+ slug: 'client-category',
+ label: 'Client Category',
+ description: 'A category registered on the client',
+ meta: {
+ _clientRegistered: true,
+ },
+ },
+ ];
+
+ const serverCategories: AbilityCategory[] = [
+ {
+ slug: 'data-retrieval',
+ label: 'Data Retrieval',
+ description: 'Server category',
+ },
+ {
+ slug: 'user-management',
+ label: 'User Management',
+ description: 'Another server category',
+ },
+ ];
+
+ const mockResolveSelect = {
+ getEntityRecords: jest
+ .fn()
+ .mockResolvedValue( serverCategories ),
+ };
+
+ const mockSelectInstance = {
+ getAbilityCategories: jest
+ .fn()
+ .mockReturnValue( clientOnlyCategories ),
+ };
+
+ mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect );
+
+ const resolver = getAbilityCategories();
+ await resolver( {
+ dispatch: mockDispatch,
+ registry: mockRegistry,
+ select: mockSelectInstance,
+ } );
+
+ // Should fetch from server because only client categories exist
+ expect( mockRegistry.resolveSelect ).toHaveBeenCalledWith(
+ coreStore
+ );
+ expect( mockResolveSelect.getEntityRecords ).toHaveBeenCalledWith(
+ ENTITY_KIND,
+ ENTITY_NAME_CATEGORIES,
+ { per_page: -1 }
+ );
+ expect( mockDispatch ).toHaveBeenCalledWith(
+ receiveCategories( serverCategories )
+ );
+ } );
+
+ it( 'should handle null response', async () => {
+ const mockResolveSelect = {
+ getEntityRecords: jest.fn().mockResolvedValue( null ),
+ };
+
+ const mockSelectInstance = {
+ getAbilityCategories: jest.fn().mockReturnValue( [] ),
+ };
+
+ mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect );
+
+ const resolver = getAbilityCategories();
+ await resolver( {
+ dispatch: mockDispatch,
+ registry: mockRegistry,
+ select: mockSelectInstance,
+ } );
+
+ expect( mockDispatch ).toHaveBeenCalledWith(
+ receiveCategories( [] )
+ );
+ } );
+ } );
+
+ describe( 'getAbilityCategory', () => {
+ it( 'should fetch and dispatch a specific category', async () => {
+ const mockCategory: AbilityCategory = {
+ slug: 'data-retrieval',
+ label: 'Data Retrieval',
+ description: 'Abilities that retrieve data',
+ };
+
+ const mockResolveSelect = {
+ getEntityRecord: jest.fn().mockResolvedValue( mockCategory ),
+ };
+
+ mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect );
+ mockSelect.getAbilityCategory = jest.fn().mockReturnValue( null );
+
+ const resolver = getAbilityCategory( 'data-retrieval' );
+ await resolver( {
+ dispatch: mockDispatch,
+ registry: mockRegistry,
+ select: mockSelect,
+ } );
+
+ expect( mockSelect.getAbilityCategory ).toHaveBeenCalledWith(
+ 'data-retrieval'
+ );
+ expect( mockRegistry.resolveSelect ).toHaveBeenCalledWith(
+ coreStore
+ );
+ expect( mockResolveSelect.getEntityRecord ).toHaveBeenCalledWith(
+ ENTITY_KIND,
+ ENTITY_NAME_CATEGORIES,
+ 'data-retrieval'
+ );
+ // registerAbilityCategory returns a thunk, so we just verify dispatch was called
+ expect( mockDispatch ).toHaveBeenCalled();
+ expect( typeof mockDispatch.mock.calls[ 0 ][ 0 ] ).toBe(
+ 'function'
+ );
+ } );
+
+ it( 'should not fetch if category already exists in store', async () => {
+ const existingCategory: AbilityCategory = {
+ slug: 'data-retrieval',
+ label: 'Data Retrieval',
+ description: 'Already in store',
+ };
+
+ mockSelect.getAbilityCategory = jest
+ .fn()
+ .mockReturnValue( existingCategory );
+
+ const resolver = getAbilityCategory( 'data-retrieval' );
+ await resolver( {
+ dispatch: mockDispatch,
+ registry: mockRegistry,
+ select: mockSelect,
+ } );
+
+ expect( mockSelect.getAbilityCategory ).toHaveBeenCalledWith(
+ 'data-retrieval'
+ );
+ expect( mockRegistry.resolveSelect ).not.toHaveBeenCalled();
+ expect( mockDispatch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should handle non-existent categories', async () => {
+ const mockResolveSelect = {
+ getEntityRecord: jest.fn().mockResolvedValue( null ),
+ };
+
+ mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect );
+ mockSelect.getAbilityCategory = jest.fn().mockReturnValue( null );
+
+ const resolver = getAbilityCategory( 'non-existent' );
+ await resolver( {
+ dispatch: mockDispatch,
+ registry: mockRegistry,
+ select: mockSelect,
+ } );
+
+ expect( mockResolveSelect.getEntityRecord ).toHaveBeenCalledWith(
+ ENTITY_KIND,
+ ENTITY_NAME_CATEGORIES,
+ 'non-existent'
+ );
+ expect( mockDispatch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should handle categories with meta', async () => {
+ const mockCategory: AbilityCategory = {
+ slug: 'user-management',
+ label: 'User Management',
+ description: 'Abilities for managing users',
+ meta: {
+ priority: 'high',
+ },
+ };
+
+ const mockResolveSelect = {
+ getEntityRecord: jest.fn().mockResolvedValue( mockCategory ),
+ };
+
+ mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect );
+ mockSelect.getAbilityCategory = jest.fn().mockReturnValue( null );
+
+ const resolver = getAbilityCategory( 'user-management' );
+ await resolver( {
+ dispatch: mockDispatch,
+ registry: mockRegistry,
+ select: mockSelect,
+ } );
+
+ // registerAbilityCategory returns a thunk, so we just verify dispatch was called
+ expect( mockDispatch ).toHaveBeenCalled();
+ expect( typeof mockDispatch.mock.calls[ 0 ][ 0 ] ).toBe(
+ 'function'
+ );
+ } );
+ } );
+} );
diff --git a/packages/abilities/src/store/tests/selectors.test.ts b/packages/abilities/src/store/tests/selectors.test.ts
new file mode 100644
index 00000000000000..58a8fba9d7b1cc
--- /dev/null
+++ b/packages/abilities/src/store/tests/selectors.test.ts
@@ -0,0 +1,412 @@
+/**
+ * Tests for store selectors.
+ */
+
+/**
+ * Internal dependencies
+ */
+import {
+ getAbilities,
+ getAbility,
+ getAbilityCategories,
+ getAbilityCategory,
+} from '../selectors';
+import type { AbilitiesState } from '../../types';
+
+describe( 'Store Selectors', () => {
+ describe( 'getAbilities', () => {
+ it( 'should return all abilities as an array', () => {
+ const state: AbilitiesState = {
+ abilitiesByName: {
+ 'test/ability1': {
+ name: 'test/ability1',
+ label: 'Test Ability 1',
+ description: 'First test ability',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ },
+ 'test/ability2': {
+ name: 'test/ability2',
+ label: 'Test Ability 2',
+ description: 'Second test ability',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ callback: jest.fn(),
+ },
+ },
+ categoriesBySlug: {},
+ };
+
+ const abilities = getAbilities( state );
+
+ expect( abilities ).toHaveLength( 2 );
+ expect( abilities ).toContainEqual(
+ state.abilitiesByName[ 'test/ability1' ]
+ );
+ expect( abilities ).toContainEqual(
+ state.abilitiesByName[ 'test/ability2' ]
+ );
+ } );
+
+ it( 'should return empty array when no abilities exist', () => {
+ const state: AbilitiesState = {
+ abilitiesByName: {},
+ categoriesBySlug: {},
+ };
+
+ const abilities = getAbilities( state );
+
+ expect( abilities ).toEqual( [] );
+ } );
+
+ it( 'should memoize results when state unchanged', () => {
+ const state: AbilitiesState = {
+ abilitiesByName: {
+ 'test/ability': {
+ name: 'test/ability',
+ label: 'Test Ability',
+ description: 'Test ability',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ },
+ },
+ categoriesBySlug: {},
+ };
+
+ const result1 = getAbilities( state );
+ const result2 = getAbilities( state );
+
+ // Should return the same reference when state unchanged
+ expect( result1 ).toBe( result2 );
+ } );
+
+ it( 'should return new array reference when state changes', () => {
+ const state1: AbilitiesState = {
+ abilitiesByName: {
+ 'test/ability1': {
+ name: 'test/ability1',
+ label: 'Test Ability 1',
+ description: 'Test ability',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ },
+ },
+ categoriesBySlug: {},
+ };
+
+ const state2: AbilitiesState = {
+ abilitiesByName: {
+ ...state1.abilitiesByName,
+ 'test/ability2': {
+ name: 'test/ability2',
+ label: 'Test Ability 2',
+ description: 'Another test ability',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ },
+ },
+ categoriesBySlug: {},
+ };
+
+ const result1 = getAbilities( state1 );
+ const result2 = getAbilities( state2 );
+
+ // Should return different references when state changes
+ expect( result1 ).not.toBe( result2 );
+ expect( result1 ).toHaveLength( 1 );
+ expect( result2 ).toHaveLength( 2 );
+ } );
+
+ it( 'should filter abilities by category when category is provided', () => {
+ const state: AbilitiesState = {
+ abilitiesByName: {
+ 'test/ability1': {
+ name: 'test/ability1',
+ label: 'Test Ability 1',
+ description: 'First test ability',
+ category: 'data-retrieval',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ },
+ 'test/ability2': {
+ name: 'test/ability2',
+ label: 'Test Ability 2',
+ description: 'Second test ability',
+ category: 'data-retrieval',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ },
+ 'test/ability3': {
+ name: 'test/ability3',
+ label: 'Test Ability 3',
+ description: 'Third test ability',
+ category: 'user-management',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ },
+ },
+ categoriesBySlug: {},
+ };
+
+ const result = getAbilities( state, {
+ category: 'data-retrieval',
+ } );
+
+ expect( result ).toHaveLength( 2 );
+ expect( result ).toContainEqual(
+ expect.objectContaining( { name: 'test/ability1' } )
+ );
+ expect( result ).toContainEqual(
+ expect.objectContaining( { name: 'test/ability2' } )
+ );
+ expect( result ).not.toContainEqual(
+ expect.objectContaining( { name: 'test/ability3' } )
+ );
+ } );
+
+ it( 'should return empty array when no abilities match category', () => {
+ const state: AbilitiesState = {
+ abilitiesByName: {
+ 'test/ability1': {
+ name: 'test/ability1',
+ label: 'Test Ability 1',
+ description: 'First test ability',
+ category: 'data-retrieval',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ },
+ },
+ categoriesBySlug: {},
+ };
+
+ const result = getAbilities( state, {
+ category: 'non-existent-category',
+ } );
+
+ expect( result ).toEqual( [] );
+ } );
+ } );
+
+ describe( 'getAbility', () => {
+ const state: AbilitiesState = {
+ abilitiesByName: {
+ 'test/ability1': {
+ name: 'test/ability1',
+ label: 'Test Ability 1',
+ description: 'First test ability',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ },
+ 'test/ability2': {
+ name: 'test/ability2',
+ label: 'Test Ability 2',
+ description: 'Second test ability',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ callback: jest.fn(),
+ },
+ },
+ categoriesBySlug: {},
+ };
+
+ it( 'should return a specific ability by name', () => {
+ const ability = getAbility( state, 'test/ability1' );
+
+ expect( ability ).toEqual(
+ state.abilitiesByName[ 'test/ability1' ]
+ );
+ } );
+
+ it( 'should return null if ability not found', () => {
+ const ability = getAbility( state, 'non-existent' );
+
+ expect( ability ).toBeUndefined();
+ } );
+
+ it( 'should handle empty state', () => {
+ const emptyState: AbilitiesState = {
+ abilitiesByName: {},
+ categoriesBySlug: {},
+ };
+
+ const ability = getAbility( emptyState, 'test/ability' );
+
+ expect( ability ).toBeUndefined();
+ } );
+
+ it( 'should return client abilities with callbacks', () => {
+ const ability = getAbility( state, 'test/ability2' );
+
+ expect( ability ).toEqual(
+ state.abilitiesByName[ 'test/ability2' ]
+ );
+ expect( ability?.callback ).toBeDefined();
+ } );
+
+ it( 'should handle valid namespaced ability names correctly', () => {
+ const stateWithNamespaced: AbilitiesState = {
+ abilitiesByName: {
+ 'my-plugin/feature-action': {
+ name: 'my-plugin/feature-action',
+ label: 'Namespaced Action',
+ description: 'Namespaced ability',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ },
+ },
+ categoriesBySlug: {},
+ };
+
+ const ability = getAbility(
+ stateWithNamespaced,
+ 'my-plugin/feature-action'
+ );
+
+ expect( ability ).toEqual(
+ stateWithNamespaced.abilitiesByName[
+ 'my-plugin/feature-action'
+ ]
+ );
+ } );
+ } );
+
+ describe( 'getAbilityCategories', () => {
+ it( 'should return all categories as an array', () => {
+ const state: AbilitiesState = {
+ abilitiesByName: {},
+ categoriesBySlug: {
+ 'data-retrieval': {
+ slug: 'data-retrieval',
+ label: 'Data Retrieval',
+ description: 'Abilities that retrieve data',
+ },
+ 'user-management': {
+ slug: 'user-management',
+ label: 'User Management',
+ description: 'Abilities for managing users',
+ },
+ },
+ };
+
+ const categories = getAbilityCategories( state );
+
+ expect( categories ).toHaveLength( 2 );
+ expect( categories ).toContainEqual(
+ state.categoriesBySlug[ 'data-retrieval' ]
+ );
+ expect( categories ).toContainEqual(
+ state.categoriesBySlug[ 'user-management' ]
+ );
+ } );
+
+ it( 'should return empty array when no categories exist', () => {
+ const state: AbilitiesState = {
+ abilitiesByName: {},
+ categoriesBySlug: {},
+ };
+
+ const categories = getAbilityCategories( state );
+
+ expect( categories ).toEqual( [] );
+ } );
+ } );
+
+ describe( 'getAbilityCategory', () => {
+ const state: AbilitiesState = {
+ abilitiesByName: {},
+ categoriesBySlug: {
+ 'data-retrieval': {
+ slug: 'data-retrieval',
+ label: 'Data Retrieval',
+ description: 'Abilities that retrieve data',
+ },
+ 'user-management': {
+ slug: 'user-management',
+ label: 'User Management',
+ description: 'Abilities for managing users',
+ meta: {
+ priority: 'high',
+ },
+ },
+ },
+ };
+
+ it( 'should return a specific category by slug', () => {
+ const category = getAbilityCategory( state, 'data-retrieval' );
+
+ expect( category ).toEqual(
+ state.categoriesBySlug[ 'data-retrieval' ]
+ );
+ } );
+
+ it( 'should return null if category not found', () => {
+ const category = getAbilityCategory( state, 'non-existent' );
+
+ expect( category ).toBeUndefined();
+ } );
+
+ it( 'should handle empty state', () => {
+ const emptyState: AbilitiesState = {
+ abilitiesByName: {},
+ categoriesBySlug: {},
+ };
+
+ const category = getAbilityCategory( emptyState, 'data-retrieval' );
+
+ expect( category ).toBeUndefined();
+ } );
+
+ it( 'should return categories with meta', () => {
+ const category = getAbilityCategory( state, 'user-management' );
+
+ expect( category ).toEqual(
+ state.categoriesBySlug[ 'user-management' ]
+ );
+ expect( category?.meta ).toBeDefined();
+ expect( category?.meta?.priority ).toBe( 'high' );
+ } );
+
+ it( 'should handle valid category slug formats', () => {
+ const stateWithVariousSlugs: AbilitiesState = {
+ abilitiesByName: {},
+ categoriesBySlug: {
+ simple: {
+ slug: 'simple',
+ label: 'Simple',
+ description: 'Simple slug',
+ },
+ 'with-dashes': {
+ slug: 'with-dashes',
+ label: 'With Dashes',
+ description: 'Slug with dashes',
+ },
+ with123: {
+ slug: 'with123',
+ label: 'With Numbers',
+ description: 'Slug with numbers',
+ },
+ },
+ };
+
+ expect(
+ getAbilityCategory( stateWithVariousSlugs, 'simple' )
+ ).toEqual( stateWithVariousSlugs.categoriesBySlug.simple );
+ expect(
+ getAbilityCategory( stateWithVariousSlugs, 'with-dashes' )
+ ).toEqual(
+ stateWithVariousSlugs.categoriesBySlug[ 'with-dashes' ]
+ );
+ expect(
+ getAbilityCategory( stateWithVariousSlugs, 'with123' )
+ ).toEqual( stateWithVariousSlugs.categoriesBySlug.with123 );
+ } );
+ } );
+} );
diff --git a/packages/abilities/src/tests/api.test.ts b/packages/abilities/src/tests/api.test.ts
new file mode 100644
index 00000000000000..9671e513729e32
--- /dev/null
+++ b/packages/abilities/src/tests/api.test.ts
@@ -0,0 +1,655 @@
+/**
+ * Tests for API functions.
+ */
+
+/**
+ * WordPress dependencies
+ */
+import { dispatch, select, resolveSelect } from '@wordpress/data';
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Internal dependencies
+ */
+import {
+ getAbilities,
+ getAbility,
+ getAbilityCategories,
+ getAbilityCategory,
+ registerAbility,
+ unregisterAbility,
+ executeAbility,
+} from '../api';
+import { store } from '../store';
+import type { Ability, AbilityCategory } from '../types';
+
+// Mock WordPress dependencies
+jest.mock( '@wordpress/data', () => ( {
+ dispatch: jest.fn(),
+ select: jest.fn(),
+ resolveSelect: jest.fn(),
+} ) );
+
+jest.mock( '@wordpress/api-fetch' );
+
+jest.mock( '../store', () => ( {
+ store: 'abilities-api/store',
+} ) );
+
+describe( 'API functions', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ describe( 'getAbilities', () => {
+ it( 'should resolve and return all abilities from the store', async () => {
+ const mockAbilities: Ability[] = [
+ {
+ name: 'test/ability1',
+ label: 'Test Ability 1',
+ description: 'First test ability',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ },
+ {
+ name: 'test/ability2',
+ label: 'Test Ability 2',
+ description: 'Second test ability',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ },
+ ];
+
+ const mockGetAbilities = jest
+ .fn()
+ .mockResolvedValue( mockAbilities );
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbilities: mockGetAbilities,
+ } );
+
+ const result = await getAbilities();
+
+ expect( resolveSelect ).toHaveBeenCalledWith( store );
+ expect( mockGetAbilities ).toHaveBeenCalled();
+ expect( result ).toEqual( mockAbilities );
+ } );
+
+ it( 'should pass category parameter to store when filtering', async () => {
+ const mockAbilities: Ability[] = [
+ {
+ name: 'test/ability1',
+ label: 'Test Ability 1',
+ description: 'First test ability',
+ category: 'data-retrieval',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ },
+ {
+ name: 'test/ability2',
+ label: 'Test Ability 2',
+ description: 'Second test ability',
+ category: 'data-retrieval',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ },
+ ];
+
+ const mockGetAbilities = jest
+ .fn()
+ .mockResolvedValue( mockAbilities );
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbilities: mockGetAbilities,
+ } );
+
+ const result = await getAbilities( { category: 'data-retrieval' } );
+
+ expect( resolveSelect ).toHaveBeenCalledWith( store );
+ expect( mockGetAbilities ).toHaveBeenCalledWith( {
+ category: 'data-retrieval',
+ } );
+ expect( result ).toEqual( mockAbilities );
+ } );
+ } );
+
+ describe( 'getAbility', () => {
+ it( 'should return a specific ability by name', async () => {
+ const mockAbility: Ability = {
+ name: 'test/ability',
+ label: 'Test Ability',
+ description: 'Test ability description',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ };
+
+ const mockGetAbility = jest.fn().mockResolvedValue( mockAbility );
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbility: mockGetAbility,
+ } );
+
+ const result = await getAbility( 'test/ability' );
+
+ expect( resolveSelect ).toHaveBeenCalledWith( store );
+ expect( mockGetAbility ).toHaveBeenCalledWith( 'test/ability' );
+ expect( result ).toEqual( mockAbility );
+ } );
+
+ it( 'should return null if ability not found', async () => {
+ const mockGetAbility = jest.fn().mockResolvedValue( null );
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbility: mockGetAbility,
+ } );
+
+ const result = await getAbility( 'non-existent' );
+
+ expect( mockGetAbility ).toHaveBeenCalledWith( 'non-existent' );
+ expect( result ).toBeNull();
+ } );
+ } );
+
+ describe( 'registerAbility', () => {
+ it( 'should register a client-side ability with a callback', () => {
+ const mockRegisterAbility = jest.fn();
+ ( dispatch as jest.Mock ).mockReturnValue( {
+ registerAbility: mockRegisterAbility,
+ } );
+
+ // Mock select to return no existing ability
+ ( select as jest.Mock ).mockReturnValue( {
+ getAbility: jest.fn().mockReturnValue( null ),
+ } );
+
+ const ability = {
+ name: 'test/client-ability',
+ label: 'Client Ability',
+ description: 'Test client ability',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ callback: jest.fn(),
+ };
+
+ registerAbility( ability );
+
+ expect( dispatch ).toHaveBeenCalledWith( store );
+ expect( mockRegisterAbility ).toHaveBeenCalledWith( ability );
+ } );
+ } );
+
+ describe( 'unregisterAbility', () => {
+ it( 'should unregister an ability', () => {
+ const mockUnregisterAbility = jest.fn();
+ ( dispatch as jest.Mock ).mockReturnValue( {
+ unregisterAbility: mockUnregisterAbility,
+ } );
+
+ unregisterAbility( 'test/ability' );
+
+ expect( dispatch ).toHaveBeenCalledWith( store );
+ expect( mockUnregisterAbility ).toHaveBeenCalledWith(
+ 'test/ability'
+ );
+ } );
+ } );
+
+ describe( 'executeAbility', () => {
+ it( 'should execute a server-side ability via API', async () => {
+ const mockAbility: Ability = {
+ name: 'test/server-ability',
+ label: 'Server Ability',
+ description: 'Test server ability',
+ category: 'test-category',
+ input_schema: {
+ type: 'object',
+ properties: {
+ message: { type: 'string' },
+ },
+ required: [ 'message' ],
+ },
+ output_schema: { type: 'object' },
+ };
+
+ const mockGetAbility = jest.fn().mockResolvedValue( mockAbility );
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbility: mockGetAbility,
+ } );
+
+ const mockResponse = { success: true, result: 'test' };
+ ( apiFetch as unknown as jest.Mock ).mockResolvedValue(
+ mockResponse
+ );
+
+ const input = { message: 'Hello' };
+ const result = await executeAbility( 'test/server-ability', input );
+
+ expect( mockGetAbility ).toHaveBeenCalledWith(
+ 'test/server-ability'
+ );
+ expect( apiFetch ).toHaveBeenCalledWith( {
+ path: '/wp-abilities/v1/abilities/test/server-ability/run',
+ method: 'POST',
+ data: { input },
+ } );
+ expect( result ).toEqual( mockResponse );
+ } );
+
+ it( 'should execute a client-side ability locally', async () => {
+ const mockCallback = jest
+ .fn()
+ .mockResolvedValue( { success: true } );
+ const mockAbility: Ability = {
+ name: 'test/client-ability',
+ label: 'Client Ability',
+ description: 'Test client ability',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ callback: mockCallback,
+ };
+
+ const mockGetAbility = jest.fn().mockResolvedValue( mockAbility );
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbility: mockGetAbility,
+ } );
+
+ const input = { test: 'data' };
+ const result = await executeAbility( 'test/client-ability', input );
+
+ expect( mockGetAbility ).toHaveBeenCalledWith(
+ 'test/client-ability'
+ );
+ expect( mockCallback ).toHaveBeenCalledWith( input );
+ expect( apiFetch ).not.toHaveBeenCalled();
+ expect( result ).toEqual( { success: true } );
+ } );
+
+ it( 'should throw error if ability not found', async () => {
+ const mockGetAbility = jest.fn().mockResolvedValue( null );
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbility: mockGetAbility,
+ } );
+
+ await expect(
+ executeAbility( 'non-existent', {} )
+ ).rejects.toThrow( 'Ability not found: non-existent' );
+ } );
+
+ it( 'should validate input for client abilities', async () => {
+ const mockCallback = jest.fn();
+ const mockAbility: Ability = {
+ name: 'test/client-ability',
+ label: 'Client Ability',
+ description: 'Test client ability',
+ category: 'test-category',
+ input_schema: {
+ type: 'object',
+ properties: {
+ message: { type: 'string' },
+ },
+ required: [ 'message' ],
+ },
+ output_schema: { type: 'object' },
+ callback: mockCallback,
+ };
+
+ const mockGetAbility = jest.fn().mockResolvedValue( mockAbility );
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbility: mockGetAbility,
+ } );
+
+ await expect(
+ executeAbility( 'test/client-ability', {} )
+ ).rejects.toThrow( 'invalid input' );
+ } );
+
+ it( 'should execute a read-only ability via GET', async () => {
+ const mockAbility: Ability = {
+ name: 'test/read-only',
+ label: 'Read-only Ability',
+ description: 'Test read-only ability.',
+ category: 'test-category',
+ input_schema: {
+ type: 'object',
+ properties: {
+ id: { type: 'string' },
+ format: { type: 'string' },
+ },
+ },
+ output_schema: { type: 'object' },
+ meta: {
+ annotations: { readonly: true },
+ },
+ };
+
+ const mockGetAbility = jest.fn().mockResolvedValue( mockAbility );
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbility: mockGetAbility,
+ } );
+
+ const mockResponse = { data: 'read-only data' };
+ ( apiFetch as unknown as jest.Mock ).mockResolvedValue(
+ mockResponse
+ );
+
+ const input = { id: '123', format: 'json' };
+ const result = await executeAbility( 'test/read-only', input );
+
+ expect( apiFetch ).toHaveBeenCalledWith( {
+ path: '/wp-abilities/v1/abilities/test/read-only/run?input%5Bid%5D=123&input%5Bformat%5D=json',
+ method: 'GET',
+ } );
+ expect( result ).toEqual( mockResponse );
+ } );
+
+ it( 'should execute a read-only ability with empty input', async () => {
+ const mockAbility: Ability = {
+ name: 'test/read-only',
+ label: 'Read-only Ability',
+ description: 'Test read-only ability.',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ meta: {
+ annotations: { readonly: true },
+ },
+ };
+
+ const mockGetAbility = jest.fn().mockResolvedValue( mockAbility );
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbility: mockGetAbility,
+ } );
+
+ const mockResponse = { data: 'read-only data' };
+ ( apiFetch as unknown as jest.Mock ).mockResolvedValue(
+ mockResponse
+ );
+
+ const result = await executeAbility( 'test/read-only', {} );
+
+ expect( apiFetch ).toHaveBeenCalledWith( {
+ path: '/wp-abilities/v1/abilities/test/read-only/run?',
+ method: 'GET',
+ } );
+ expect( result ).toEqual( mockResponse );
+ } );
+
+ it( 'should execute a destructive idempotent ability via DELETE', async () => {
+ const mockAbility: Ability = {
+ name: 'test/destructive',
+ label: 'Destructive Ability',
+ description: 'Test destructive idempotent ability.',
+ category: 'test-category',
+ input_schema: {
+ type: 'object',
+ properties: {
+ id: { type: 'string' },
+ format: { type: 'string' },
+ },
+ },
+ output_schema: { type: 'string' },
+ meta: {
+ annotations: {
+ destructive: true,
+ idempotent: true,
+ },
+ },
+ };
+
+ const mockGetAbility = jest.fn().mockResolvedValue( mockAbility );
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbility: mockGetAbility,
+ } );
+
+ const mockResponse = 'Item deleted successfully.';
+ ( apiFetch as unknown as jest.Mock ).mockResolvedValue(
+ mockResponse
+ );
+
+ const input = { id: '123', format: 'json' };
+ const result = await executeAbility( 'test/destructive', input );
+
+ expect( apiFetch ).toHaveBeenCalledWith( {
+ path: '/wp-abilities/v1/abilities/test/destructive/run?input%5Bid%5D=123&input%5Bformat%5D=json',
+ method: 'DELETE',
+ } );
+ expect( result ).toEqual( mockResponse );
+ } );
+
+ it( 'should handle errors in client ability execution', async () => {
+ const consoleErrorSpy = jest
+ .spyOn( console, 'error' )
+ .mockImplementation();
+ const executionError = new Error( 'Execution failed' );
+ const mockCallback = jest.fn().mockRejectedValue( executionError );
+
+ const mockAbility: Ability = {
+ name: 'test/client-ability',
+ label: 'Client Ability',
+ description: 'Test client ability',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ callback: mockCallback,
+ };
+
+ const mockGetAbility = jest.fn().mockResolvedValue( mockAbility );
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbility: mockGetAbility,
+ } );
+
+ await expect(
+ executeAbility( 'test/client-ability', {} )
+ ).rejects.toThrow( 'Execution failed' );
+
+ expect( consoleErrorSpy ).toHaveBeenCalledWith(
+ 'Error executing client ability test/client-ability:',
+ executionError
+ );
+
+ consoleErrorSpy.mockRestore();
+ } );
+
+ it( 'should handle errors in server ability execution', async () => {
+ const consoleErrorSpy = jest
+ .spyOn( console, 'error' )
+ .mockImplementation();
+ const apiError = new Error( 'API request failed' );
+
+ const mockAbility: Ability = {
+ name: 'test/server-ability',
+ label: 'Server Ability',
+ description: 'Test server ability',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ };
+
+ const mockGetAbility = jest.fn().mockResolvedValue( mockAbility );
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbility: mockGetAbility,
+ } );
+
+ ( apiFetch as unknown as jest.Mock ).mockRejectedValue( apiError );
+
+ await expect(
+ executeAbility( 'test/server-ability', {} )
+ ).rejects.toThrow( 'API request failed' );
+
+ expect( consoleErrorSpy ).toHaveBeenCalledWith(
+ 'Error executing ability test/server-ability:',
+ apiError
+ );
+
+ consoleErrorSpy.mockRestore();
+ } );
+
+ it( 'should execute ability without callback as server ability', async () => {
+ const mockAbility: Ability = {
+ name: 'test/ability',
+ label: 'Test Ability',
+ description: 'Test ability without callback',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ // No callback - should execute as server ability
+ };
+
+ const mockGetAbility = jest.fn().mockResolvedValue( mockAbility );
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbility: mockGetAbility,
+ } );
+
+ const mockResponse = { success: true };
+ ( apiFetch as unknown as jest.Mock ).mockResolvedValue(
+ mockResponse
+ );
+
+ const result = await executeAbility( 'test/ability', {
+ data: 'test',
+ } );
+
+ expect( apiFetch ).toHaveBeenCalledWith( {
+ path: '/wp-abilities/v1/abilities/test/ability/run',
+ method: 'POST',
+ data: { input: { data: 'test' } },
+ } );
+ expect( result ).toEqual( mockResponse );
+ } );
+
+ it( 'should validate output for client abilities', async () => {
+ const mockCallback = jest
+ .fn()
+ .mockResolvedValue( { invalid: 'response' } );
+ const mockAbility: Ability = {
+ name: 'test/client-ability',
+ label: 'Client Ability',
+ description: 'Test client ability',
+ category: 'test-category',
+ input_schema: { type: 'object' },
+ output_schema: {
+ type: 'object',
+ properties: {
+ result: { type: 'string' },
+ },
+ required: [ 'result' ],
+ },
+ callback: mockCallback,
+ };
+
+ const mockGetAbility = jest.fn().mockResolvedValue( mockAbility );
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbility: mockGetAbility,
+ } );
+
+ await expect(
+ executeAbility( 'test/client-ability', {} )
+ ).rejects.toThrow( 'invalid output' );
+ } );
+ } );
+
+ describe( 'getAbilityCategories', () => {
+ it( 'should resolve and return all categories from the store', async () => {
+ const mockCategories: AbilityCategory[] = [
+ {
+ slug: 'data-retrieval',
+ label: 'Data Retrieval',
+ description: 'Abilities that retrieve data',
+ },
+ {
+ slug: 'user-management',
+ label: 'User Management',
+ description: 'Abilities for managing users',
+ },
+ ];
+
+ const mockGetAbilityCategories = jest
+ .fn()
+ .mockResolvedValue( mockCategories );
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbilityCategories: mockGetAbilityCategories,
+ } );
+
+ const result = await getAbilityCategories();
+
+ expect( resolveSelect ).toHaveBeenCalledWith( store );
+ expect( mockGetAbilityCategories ).toHaveBeenCalled();
+ expect( result ).toEqual( mockCategories );
+ } );
+
+ it( 'should return empty array when no categories exist', async () => {
+ const mockGetAbilityCategories = jest.fn().mockResolvedValue( [] );
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbilityCategories: mockGetAbilityCategories,
+ } );
+
+ const result = await getAbilityCategories();
+
+ expect( result ).toEqual( [] );
+ } );
+ } );
+
+ describe( 'getAbilityCategory', () => {
+ it( 'should return a specific category by slug', async () => {
+ const mockCategory: AbilityCategory = {
+ slug: 'data-retrieval',
+ label: 'Data Retrieval',
+ description: 'Abilities that retrieve data',
+ };
+
+ const mockGetAbilityCategory = jest
+ .fn()
+ .mockResolvedValue( mockCategory );
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbilityCategory: mockGetAbilityCategory,
+ } );
+
+ const result = await getAbilityCategory( 'data-retrieval' );
+
+ expect( resolveSelect ).toHaveBeenCalledWith( store );
+ expect( mockGetAbilityCategory ).toHaveBeenCalledWith(
+ 'data-retrieval'
+ );
+ expect( result ).toEqual( mockCategory );
+ } );
+
+ it( 'should return null if category not found', async () => {
+ const mockGetAbilityCategory = jest.fn().mockResolvedValue( null );
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbilityCategory: mockGetAbilityCategory,
+ } );
+
+ const result = await getAbilityCategory( 'non-existent' );
+
+ expect( mockGetAbilityCategory ).toHaveBeenCalledWith(
+ 'non-existent'
+ );
+ expect( result ).toBeNull();
+ } );
+
+ it( 'should handle categories with meta', async () => {
+ const mockCategory: AbilityCategory = {
+ slug: 'user-management',
+ label: 'User Management',
+ description: 'Abilities for managing users',
+ meta: {
+ priority: 'high',
+ },
+ };
+
+ const mockGetAbilityCategory = jest
+ .fn()
+ .mockResolvedValue( mockCategory );
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
+ getAbilityCategory: mockGetAbilityCategory,
+ } );
+
+ const result = await getAbilityCategory( 'user-management' );
+
+ expect( result ).toEqual( mockCategory );
+ expect( result?.meta ).toBeDefined();
+ expect( result?.meta?.priority ).toBe( 'high' );
+ } );
+ } );
+} );
diff --git a/packages/abilities/src/tests/validation.test.ts b/packages/abilities/src/tests/validation.test.ts
new file mode 100644
index 00000000000000..8a3472ab514c62
--- /dev/null
+++ b/packages/abilities/src/tests/validation.test.ts
@@ -0,0 +1,532 @@
+/**
+ * Tests for schema validation utilities.
+ */
+
+/**
+ * Internal dependencies
+ */
+import { validateValueFromSchema } from '../validation';
+
+describe( 'validateValueFromSchema', () => {
+ describe( 'type validation', () => {
+ it( 'should validate string type', () => {
+ const schema = { type: 'string' };
+ expect( validateValueFromSchema( 'hello', schema ) ).toBe( true );
+ expect( validateValueFromSchema( 123, schema ) ).toBe(
+ ' is not of type string.'
+ );
+ } );
+
+ it( 'should validate number type', () => {
+ const schema = { type: 'number' };
+ expect( validateValueFromSchema( 123, schema ) ).toBe( true );
+ expect( validateValueFromSchema( 45.67, schema ) ).toBe( true );
+ expect( validateValueFromSchema( 'hello', schema ) ).toBe(
+ ' is not of type number.'
+ );
+ } );
+
+ it( 'should validate boolean type', () => {
+ const schema = { type: 'boolean' };
+ expect( validateValueFromSchema( true, schema ) ).toBe( true );
+ expect( validateValueFromSchema( false, schema ) ).toBe( true );
+ expect( validateValueFromSchema( 'true', schema ) ).toBe(
+ ' is not of type boolean.'
+ );
+ } );
+
+ it( 'should validate array type', () => {
+ const schema = { type: 'array' };
+ expect( validateValueFromSchema( [ 1, 2, 3 ], schema ) ).toBe(
+ true
+ );
+ expect( validateValueFromSchema( {}, schema ) ).toBe(
+ ' is not of type array.'
+ );
+ } );
+
+ it( 'should validate object type', () => {
+ const schema = { type: 'object' };
+ expect( validateValueFromSchema( { a: 1 }, schema ) ).toBe( true );
+ expect( validateValueFromSchema( [], schema ) ).toBe(
+ ' is not of type object.'
+ );
+ } );
+ } );
+
+ describe( 'object validation', () => {
+ it( 'should validate required properties', () => {
+ const schema = {
+ type: 'object',
+ properties: {
+ name: { type: 'string' },
+ age: { type: 'number' },
+ },
+ required: [ 'name' ],
+ };
+
+ expect(
+ validateValueFromSchema( { name: 'John', age: 30 }, schema )
+ ).toBe( true );
+ expect( validateValueFromSchema( { name: 'John' }, schema ) ).toBe(
+ true
+ );
+ expect( validateValueFromSchema( { age: 30 }, schema ) ).toBe(
+ 'name is a required property of .'
+ );
+ } );
+
+ it( 'should validate nested objects', () => {
+ const schema = {
+ type: 'object',
+ properties: {
+ user: {
+ type: 'object',
+ properties: {
+ name: { type: 'string' },
+ },
+ required: [ 'name' ],
+ },
+ },
+ };
+
+ expect(
+ validateValueFromSchema( { user: { name: 'John' } }, schema )
+ ).toBe( true );
+ expect( validateValueFromSchema( { user: {} }, schema ) ).toBe(
+ 'name is a required property of [user].'
+ );
+ } );
+
+ it( 'should validate additionalProperties: false', () => {
+ const schema = {
+ type: 'object',
+ properties: {
+ name: { type: 'string' },
+ },
+ additionalProperties: false,
+ };
+
+ expect( validateValueFromSchema( { name: 'John' }, schema ) ).toBe(
+ true
+ );
+ expect(
+ validateValueFromSchema(
+ { name: 'John', extra: 'value' },
+ schema
+ )
+ ).toBe( 'extra is not a valid property of Object.' );
+ } );
+ } );
+
+ describe( 'array validation', () => {
+ it( 'should validate array items', () => {
+ const schema = {
+ type: 'array',
+ items: { type: 'number' },
+ };
+
+ expect( validateValueFromSchema( [ 1, 2, 3 ], schema ) ).toBe(
+ true
+ );
+ expect( validateValueFromSchema( [ 1, 'two', 3 ], schema ) ).toBe(
+ '[1] is not of type number.'
+ );
+ } );
+ } );
+
+ describe( 'edge cases', () => {
+ it( 'should pass validation when empty schema provided', () => {
+ const consoleSpy = jest
+ .spyOn( console, 'warn' )
+ .mockImplementation();
+
+ expect( validateValueFromSchema( 'anything', {} ) ).toBe( true );
+ expect( consoleSpy ).toHaveBeenCalledWith(
+ 'The "type" schema keyword for value is required.'
+ );
+
+ consoleSpy.mockRestore();
+ } );
+
+ it( 'should warn when type is missing but still pass validation', () => {
+ const consoleSpy = jest
+ .spyOn( console, 'warn' )
+ .mockImplementation();
+ const schema = { properties: { name: { type: 'string' } } };
+ const result = validateValueFromSchema( { name: 'test' }, schema );
+
+ expect( result ).toBe( true );
+ expect( consoleSpy ).toHaveBeenCalledWith(
+ 'The "type" schema keyword for value is required.'
+ );
+
+ consoleSpy.mockRestore();
+ } );
+
+ it( 'should include param name in warning when provided', () => {
+ const consoleSpy = jest
+ .spyOn( console, 'warn' )
+ .mockImplementation();
+ const schema = { format: 'email' }; // Schema without type
+ const result = validateValueFromSchema(
+ 'test@example.com',
+ schema,
+ 'email_field'
+ );
+
+ expect( result ).toBe( true );
+ expect( consoleSpy ).toHaveBeenCalledWith(
+ 'The "type" schema keyword for email_field is required.'
+ );
+
+ consoleSpy.mockRestore();
+ } );
+
+ it( 'should handle null values correctly', () => {
+ const schema = { type: [ 'string', 'null' ] };
+ expect( validateValueFromSchema( null, schema ) ).toBe( true );
+ expect( validateValueFromSchema( 'hello', schema ) ).toBe( true );
+ } );
+ } );
+
+ describe( 'AI-relevant format validation', () => {
+ it( 'should validate email format', () => {
+ const schema = { type: 'string', format: 'email' };
+ expect(
+ validateValueFromSchema( 'user@example.com', schema )
+ ).toBe( true );
+ expect( validateValueFromSchema( 'invalid-email', schema ) ).toBe(
+ 'Invalid email address.'
+ );
+ } );
+
+ it( 'should validate date-time format', () => {
+ const schema = { type: 'string', format: 'date-time' };
+ expect(
+ validateValueFromSchema( '2024-01-15T10:30:00Z', schema )
+ ).toBe( true );
+ expect(
+ validateValueFromSchema( '2024-01-15T10:30:00.123Z', schema )
+ ).toBe( true );
+ expect( validateValueFromSchema( 'not-a-date', schema ) ).toBe(
+ 'Invalid date.'
+ );
+ } );
+
+ it( 'should validate UUID format', () => {
+ const schema = { type: 'string', format: 'uuid' };
+ expect(
+ validateValueFromSchema(
+ '123e4567-e89b-12d3-a456-426614174000',
+ schema
+ )
+ ).toBe( true );
+ expect( validateValueFromSchema( 'not-a-uuid', schema ) ).toBe(
+ ' is not a valid UUID.'
+ );
+ } );
+
+ it( 'should validate IPv4 format', () => {
+ const schema = { type: 'string', format: 'ipv4' };
+ expect( validateValueFromSchema( '192.168.1.1', schema ) ).toBe(
+ true
+ );
+ expect( validateValueFromSchema( '256.256.256.256', schema ) ).toBe(
+ ' is not a valid IP address.'
+ );
+ } );
+
+ it( 'should validate hostname format', () => {
+ const schema = { type: 'string', format: 'hostname' };
+ expect( validateValueFromSchema( 'example.com', schema ) ).toBe(
+ true
+ );
+ expect( validateValueFromSchema( 'sub.example.com', schema ) ).toBe(
+ true
+ );
+ expect( validateValueFromSchema( 'not a hostname!', schema ) ).toBe(
+ ' is not a valid hostname.'
+ );
+ } );
+ } );
+
+ describe( 'pattern validation', () => {
+ it( 'should validate string patterns', () => {
+ const schema = { type: 'string', pattern: '^[A-Z][a-z]+$' };
+ expect( validateValueFromSchema( 'Hello', schema ) ).toBe( true );
+ expect( validateValueFromSchema( 'hello', schema ) ).toBe(
+ ' does not match pattern ^[A-Z][a-z]+$.'
+ );
+ } );
+ } );
+
+ describe( 'number constraints', () => {
+ it( 'should validate minimum and maximum', () => {
+ const schema = { type: 'number', minimum: 0, maximum: 100 };
+ expect( validateValueFromSchema( 50, schema ) ).toBe( true );
+ expect( validateValueFromSchema( 0, schema ) ).toBe( true );
+ expect( validateValueFromSchema( 100, schema ) ).toBe( true );
+ expect( validateValueFromSchema( -1, schema ) ).toBe(
+ ' must be greater than or equal to 0'
+ );
+ expect( validateValueFromSchema( 101, schema ) ).toBe(
+ ' must be less than or equal to 100'
+ );
+ } );
+
+ it( 'should validate multipleOf', () => {
+ const schema = { type: 'number', multipleOf: 5 };
+ expect( validateValueFromSchema( 10, schema ) ).toBe( true );
+ expect( validateValueFromSchema( 15, schema ) ).toBe( true );
+ expect( validateValueFromSchema( 7, schema ) ).toBe(
+ ' must be a multiple of 5.'
+ );
+ } );
+ } );
+
+ describe( 'enum validation', () => {
+ it( 'should validate enum values', () => {
+ const schema = {
+ type: 'string',
+ enum: [ 'read', 'write', 'execute' ],
+ };
+ expect( validateValueFromSchema( 'read', schema ) ).toBe( true );
+ expect( validateValueFromSchema( 'delete', schema ) ).toBe(
+ ' is not one of read, write, execute.'
+ );
+ } );
+ } );
+
+ describe( 'anyOf validation', () => {
+ it( 'should validate anyOf schemas', () => {
+ const schema = {
+ anyOf: [ { type: 'string' }, { type: 'number' } ],
+ };
+ expect( validateValueFromSchema( 'hello', schema ) ).toBe( true );
+ expect( validateValueFromSchema( 42, schema ) ).toBe( true );
+ expect( validateValueFromSchema( true, schema ) ).toBe(
+ ' is invalid (failed anyOf validation).'
+ );
+ } );
+ } );
+
+ describe( 'strict JSON validation (no type coercion)', () => {
+ it( 'should NOT coerce string "true" to boolean', () => {
+ const schema = { type: 'boolean' };
+ expect( validateValueFromSchema( true, schema ) ).toBe( true );
+ expect( validateValueFromSchema( false, schema ) ).toBe( true );
+ expect( validateValueFromSchema( 'true', schema ) ).toBe(
+ ' is not of type boolean.'
+ );
+ expect( validateValueFromSchema( '1', schema ) ).toBe(
+ ' is not of type boolean.'
+ );
+ } );
+
+ it( 'should NOT accept empty string as object', () => {
+ const schema = { type: 'object' };
+ expect( validateValueFromSchema( {}, schema ) ).toBe( true );
+ expect( validateValueFromSchema( '', schema ) ).toBe(
+ ' is not of type object.'
+ );
+ } );
+ } );
+
+ describe( 'string length constraints', () => {
+ it( 'should validate minLength constraint', () => {
+ const schema = { type: 'string', minLength: 3 };
+ expect( validateValueFromSchema( 'hello', schema ) ).toBe( true );
+ expect( validateValueFromSchema( 'hi', schema ) ).toBe(
+ ' must be at least 3 characters long.'
+ );
+ } );
+
+ it( 'should validate maxLength constraint', () => {
+ const schema = { type: 'string', maxLength: 5 };
+ expect( validateValueFromSchema( 'hello', schema ) ).toBe( true );
+ expect( validateValueFromSchema( 'hello world', schema ) ).toBe(
+ ' must be at most 5 characters long.'
+ );
+ } );
+
+ it( 'should handle singular character in length messages', () => {
+ const minSchema = { type: 'string', minLength: 1 };
+ expect( validateValueFromSchema( '', minSchema ) ).toBe(
+ ' must be at least 1 character long.'
+ );
+
+ const maxSchema = { type: 'string', maxLength: 1 };
+ expect( validateValueFromSchema( 'ab', maxSchema ) ).toBe(
+ ' must be at most 1 character long.'
+ );
+ } );
+ } );
+
+ describe( 'array item constraints', () => {
+ it( 'should validate minItems constraint', () => {
+ const schema = { type: 'array', minItems: 2 };
+ expect( validateValueFromSchema( [ 1, 2, 3 ], schema ) ).toBe(
+ true
+ );
+ expect( validateValueFromSchema( [ 1 ], schema ) ).toBe(
+ ' must contain at least 2 items.'
+ );
+ } );
+
+ it( 'should validate maxItems constraint', () => {
+ const schema = { type: 'array', maxItems: 3 };
+ expect( validateValueFromSchema( [ 1, 2, 3 ], schema ) ).toBe(
+ true
+ );
+ expect( validateValueFromSchema( [ 1, 2, 3, 4 ], schema ) ).toBe(
+ ' must contain at most 3 items.'
+ );
+ } );
+
+ it( 'should validate uniqueItems constraint', () => {
+ const schema = { type: 'array', uniqueItems: true };
+ expect( validateValueFromSchema( [ 1, 2, 3 ], schema ) ).toBe(
+ true
+ );
+ expect( validateValueFromSchema( [ 1, 2, 2, 3 ], schema ) ).toBe(
+ ' has duplicate items.'
+ );
+ } );
+
+ it( 'should handle singular item in item messages', () => {
+ const minSchema = { type: 'array', minItems: 1 };
+ expect( validateValueFromSchema( [], minSchema ) ).toBe(
+ ' must contain at least 1 item.'
+ );
+
+ const maxSchema = { type: 'array', maxItems: 1 };
+ expect( validateValueFromSchema( [ 1, 2 ], maxSchema ) ).toBe(
+ ' must contain at most 1 item.'
+ );
+ } );
+ } );
+
+ describe( 'object property constraints', () => {
+ it( 'should validate minProperties constraint', () => {
+ const schema = { type: 'object', minProperties: 2 };
+ expect( validateValueFromSchema( { a: 1, b: 2 }, schema ) ).toBe(
+ true
+ );
+ expect( validateValueFromSchema( { a: 1 }, schema ) ).toBe(
+ ' must contain at least 2 properties.'
+ );
+ } );
+
+ it( 'should validate maxProperties constraint', () => {
+ const schema = { type: 'object', maxProperties: 2 };
+ expect( validateValueFromSchema( { a: 1, b: 2 }, schema ) ).toBe(
+ true
+ );
+ expect(
+ validateValueFromSchema( { a: 1, b: 2, c: 3 }, schema )
+ ).toBe( ' must contain at most 2 properties.' );
+ } );
+
+ it( 'should handle singular property in property messages', () => {
+ const minSchema = { type: 'object', minProperties: 1 };
+ expect( validateValueFromSchema( {}, minSchema ) ).toBe(
+ ' must contain at least 1 property.'
+ );
+
+ const maxSchema = { type: 'object', maxProperties: 1 };
+ expect( validateValueFromSchema( { a: 1, b: 2 }, maxSchema ) ).toBe(
+ ' must contain at most 1 property.'
+ );
+ } );
+ } );
+
+ describe( 'schema edge cases and errors', () => {
+ it( 'should handle empty schema object as valid but warn about missing type', () => {
+ const consoleSpy = jest
+ .spyOn( console, 'warn' )
+ .mockImplementation();
+
+ // Empty object schema triggers warning about missing type
+ expect( validateValueFromSchema( 'anything', {} ) ).toBe( true );
+ expect( consoleSpy ).toHaveBeenCalledWith(
+ 'The "type" schema keyword for value is required.'
+ );
+
+ consoleSpy.mockRestore();
+ } );
+
+ it( 'should warn for invalid schema types but still pass validation', () => {
+ const consoleSpy = jest
+ .spyOn( console, 'warn' )
+ .mockImplementation();
+
+ // Testing edge cases where schema is not a valid object
+ expect(
+ validateValueFromSchema(
+ 'anything',
+ undefined as unknown as Record< string, any >
+ )
+ ).toBe( true );
+ expect( consoleSpy ).toHaveBeenCalledWith(
+ 'Schema must be an object. Received undefined.'
+ );
+
+ consoleSpy.mockClear();
+ expect(
+ validateValueFromSchema(
+ 123,
+ null as unknown as Record< string, any >
+ )
+ ).toBe( true );
+ expect( consoleSpy ).toHaveBeenCalledWith(
+ 'Schema must be an object. Received object.' // typeof null === 'object'
+ );
+
+ consoleSpy.mockClear();
+ expect(
+ validateValueFromSchema(
+ true,
+ false as unknown as Record< string, any >
+ )
+ ).toBe( true );
+ expect( consoleSpy ).toHaveBeenCalledWith(
+ 'Schema must be an object. Received boolean.'
+ );
+
+ consoleSpy.mockRestore();
+ } );
+
+ it( 'should handle schema compilation errors', () => {
+ // Pass an invalid schema that will cause compilation error
+ const invalidSchema = { type: 'invalid-type' };
+ const consoleErrorSpy = jest
+ .spyOn( console, 'error' )
+ .mockImplementation();
+
+ const result = validateValueFromSchema( 'test', invalidSchema );
+
+ expect( result ).toBe( 'Invalid schema provided for validation.' );
+ expect( consoleErrorSpy ).toHaveBeenCalledWith(
+ 'Schema compilation error:',
+ expect.any( Error )
+ );
+
+ consoleErrorSpy.mockRestore();
+ } );
+
+ it( 'should handle const validation keyword', () => {
+ const schema = {
+ type: 'string',
+ const: 'exact-value',
+ };
+
+ expect( validateValueFromSchema( 'exact-value', schema ) ).toBe(
+ true
+ );
+ expect( validateValueFromSchema( 'different-value', schema ) ).toBe(
+ 'must be equal to constant'
+ );
+ } );
+ } );
+} );
diff --git a/packages/abilities/src/types.ts b/packages/abilities/src/types.ts
new file mode 100644
index 00000000000000..f645d331efd68b
--- /dev/null
+++ b/packages/abilities/src/types.ts
@@ -0,0 +1,190 @@
+/**
+ * WordPress Abilities API Types
+ */
+
+/**
+ * Callback function for client-side abilities.
+ */
+export type AbilityCallback = (
+ input: AbilityInput
+) => AbilityOutput | Promise< AbilityOutput >;
+
+/**
+ * Permission callback function for client-side abilities.
+ * Returns true if the ability can be executed, false otherwise.
+ */
+export type PermissionCallback = (
+ input?: AbilityInput
+) => boolean | Promise< boolean >;
+
+/**
+ * Represents an ability in the WordPress Abilities API.
+ *
+ * @see WP_Ability
+ */
+export interface Ability {
+ /**
+ * The unique name/identifier of the ability, with its namespace.
+ * Example: 'my-plugin/my-ability'
+ * @see WP_Ability::get_name()
+ */
+ name: string;
+
+ /**
+ * The human-readable label for the ability.
+ * @see WP_Ability::get_label()
+ */
+ label: string;
+
+ /**
+ * The detailed description of the ability.
+ * @see WP_Ability::get_description()
+ */
+ description: string;
+
+ /**
+ * The category this ability belongs to.
+ * Must be a valid category slug (lowercase alphanumeric with dashes).
+ * Example: 'data-retrieval', 'user-management'
+ * @see WP_Ability::get_category()
+ */
+ category: string;
+
+ /**
+ * JSON Schema for the ability's input parameters.
+ * @see WP_Ability::get_input_schema()
+ */
+ input_schema?: Record< string, any >;
+
+ /**
+ * JSON Schema for the ability's output format.
+ * @see WP_Ability::get_output_schema()
+ */
+ output_schema?: Record< string, any >;
+
+ /**
+ * Callback function for client-side abilities.
+ * If present, the ability will be executed locally in the browser.
+ * If not present, the ability will be executed via REST API on the server.
+ */
+ callback?: AbilityCallback;
+
+ /**
+ * Client Permission callback for abilities.
+ * Called before executing the ability to check if it's allowed.
+ * If it returns false, the ability execution will be denied.
+ */
+ permissionCallback?: PermissionCallback;
+
+ /**
+ * Metadata about the ability.
+ * @see WP_Ability::get_meta()
+ */
+ meta?: {
+ annotations?: {
+ readonly?: boolean | null;
+ destructive?: boolean | null;
+ idempotent?: boolean | null;
+ };
+ [ key: string ]: any;
+ };
+}
+
+/**
+ * The shape of the arguments for querying abilities.
+ */
+export interface AbilitiesQueryArgs {
+ /**
+ * Optional category slug to filter abilities.
+ */
+ category?: string;
+}
+
+/**
+ * Represents an ability category in the WordPress Abilities API.
+ *
+ * @see WP_Ability_Category
+ */
+export interface AbilityCategory {
+ /**
+ * The unique slug identifier for the category.
+ * Must be lowercase alphanumeric with dashes only.
+ * Example: 'data-retrieval', 'user-management'
+ * @see WP_Ability_Category::get_slug()
+ */
+ slug: string;
+
+ /**
+ * The human-readable label for the category.
+ * @see WP_Ability_Category::get_label()
+ */
+ label: string;
+
+ /**
+ * The detailed description of the category.
+ * @see WP_Ability_Category::get_description()
+ */
+ description: string;
+
+ /**
+ * Metadata about the category.
+ * @see WP_Ability_Category::get_meta()
+ */
+ meta?: Record< string, any >;
+}
+
+/**
+ * Arguments for registering an ability category.
+ * Matches the server-side wp_register_ability_category() $args parameter.
+ *
+ * @see wp_register_ability_category()
+ */
+export interface AbilityCategoryArgs {
+ /**
+ * The human-readable label for the category.
+ */
+ label: string;
+
+ /**
+ * The detailed description of the category.
+ */
+ description: string;
+
+ /**
+ * Optional metadata about the category.
+ */
+ meta?: Record< string, any >;
+}
+
+/**
+ * The state shape for the abilities store.
+ */
+export interface AbilitiesState {
+ /**
+ * Map of ability names to ability objects.
+ */
+ abilitiesByName: Record< string, Ability >;
+
+ /**
+ * Map of category slugs to category objects.
+ */
+ categoriesBySlug: Record< string, AbilityCategory >;
+}
+
+/**
+ * Input parameters for ability execution.
+ * Can be any JSON-serializable value: primitive, array, object, or null.
+ */
+export type AbilityInput = any;
+
+/**
+ * Result from ability execution.
+ * The actual shape depends on the ability's output schema.
+ */
+export type AbilityOutput = any;
+
+/**
+ * Validation error - just a message string.
+ * The Abilities API wraps this with the appropriate error code.
+ */
+export type ValidationError = string;
diff --git a/packages/abilities/src/validation.ts b/packages/abilities/src/validation.ts
new file mode 100644
index 00000000000000..11ada5a6200fc6
--- /dev/null
+++ b/packages/abilities/src/validation.ts
@@ -0,0 +1,207 @@
+/**
+ * Schema validation for client-side ability input and output schemas using AJV and ajv-formats.
+ *
+ * This utility provides validation for JSON Schema draft-04.
+ * Rules are configured to support the intersection of common rules between JSON Schema draft-04, WordPress (a subset of JSON Schema draft-04),
+ * and various providers like OpenAI and Anthropic.
+ *
+ * @see https://developer.wordpress.org/rest-api/extending-the-rest-api/schema/#json-schema-basics
+ */
+
+/**
+ * External dependencies
+ */
+import Ajv from 'ajv-draft-04';
+import addFormats from 'ajv-formats';
+
+/**
+ * Internal dependencies
+ */
+import type { ValidationError } from './types';
+
+const ajv = new Ajv( {
+ coerceTypes: false, // No type coercion - AI should send proper JSON
+ useDefaults: true,
+ removeAdditional: false, // Keep additional properties
+ allErrors: true,
+ verbose: true,
+ allowUnionTypes: true, // Allow anyOf without explicit type
+} );
+
+addFormats( ajv, [ 'date-time', 'email', 'hostname', 'ipv4', 'ipv6', 'uuid' ] );
+
+/**
+ * Formats AJV errors into a simple error message.
+ * The Abilities API will wrap this with ability_invalid_input/output to match the server side format.
+ *
+ * @param ajvError The AJV validation error.
+ * @param param The base parameter name.
+ * @return Simple error message string.
+ */
+function formatAjvError( ajvError: any, param: string ): string {
+ // Convert AJV's instancePath format (/0/prop) to an array like format to better match WordPress ([0][prop])
+ const instancePath = ajvError.instancePath
+ ? ajvError.instancePath.replace( /\//g, '][' ).replace( /^\]\[/, '[' ) +
+ ']'
+ : '';
+ const fullParam = param + instancePath;
+
+ switch ( ajvError.keyword ) {
+ case 'type':
+ return `${ fullParam } is not of type ${ ajvError.params.type }.`;
+
+ case 'required':
+ return `${ ajvError.params.missingProperty } is a required property of ${ fullParam }.`;
+
+ case 'additionalProperties':
+ return `${ ajvError.params.additionalProperty } is not a valid property of Object.`;
+
+ case 'enum':
+ const enumValues = ajvError.params.allowedValues
+ .map( ( v: any ) =>
+ typeof v === 'string' ? v : JSON.stringify( v )
+ )
+ .join( ', ' );
+ return ajvError.params.allowedValues.length === 1
+ ? `${ fullParam } is not ${ enumValues }.`
+ : `${ fullParam } is not one of ${ enumValues }.`;
+
+ case 'pattern':
+ return `${ fullParam } does not match pattern ${ ajvError.params.pattern }.`;
+
+ case 'format':
+ const format = ajvError.params.format;
+ const formatMessages: Record< string, string > = {
+ email: 'Invalid email address.',
+ 'date-time': 'Invalid date.',
+ uuid: `${ fullParam } is not a valid UUID.`,
+ ipv4: `${ fullParam } is not a valid IP address.`,
+ ipv6: `${ fullParam } is not a valid IP address.`,
+ hostname: `${ fullParam } is not a valid hostname.`,
+ };
+ return formatMessages[ format ] || `Invalid ${ format }.`;
+
+ case 'minimum':
+ case 'exclusiveMinimum':
+ return ajvError.keyword === 'exclusiveMinimum'
+ ? `${ fullParam } must be greater than ${ ajvError.params.limit }`
+ : `${ fullParam } must be greater than or equal to ${ ajvError.params.limit }`;
+
+ case 'maximum':
+ case 'exclusiveMaximum':
+ return ajvError.keyword === 'exclusiveMaximum'
+ ? `${ fullParam } must be less than ${ ajvError.params.limit }`
+ : `${ fullParam } must be less than or equal to ${ ajvError.params.limit }`;
+
+ case 'multipleOf':
+ return `${ fullParam } must be a multiple of ${ ajvError.params.multipleOf }.`;
+
+ case 'anyOf':
+ case 'oneOf':
+ return `${ fullParam } is invalid (failed ${ ajvError.keyword } validation).`;
+
+ case 'minLength':
+ return `${ fullParam } must be at least ${
+ ajvError.params.limit
+ } character${ ajvError.params.limit === 1 ? '' : 's' } long.`;
+
+ case 'maxLength':
+ return `${ fullParam } must be at most ${
+ ajvError.params.limit
+ } character${ ajvError.params.limit === 1 ? '' : 's' } long.`;
+
+ case 'minItems':
+ return `${ fullParam } must contain at least ${
+ ajvError.params.limit
+ } item${ ajvError.params.limit === 1 ? '' : 's' }.`;
+
+ case 'maxItems':
+ return `${ fullParam } must contain at most ${
+ ajvError.params.limit
+ } item${ ajvError.params.limit === 1 ? '' : 's' }.`;
+
+ case 'uniqueItems':
+ return `${ fullParam } has duplicate items.`;
+
+ case 'minProperties':
+ return `${ fullParam } must contain at least ${
+ ajvError.params.limit
+ } propert${ ajvError.params.limit === 1 ? 'y' : 'ies' }.`;
+
+ case 'maxProperties':
+ return `${ fullParam } must contain at most ${
+ ajvError.params.limit
+ } propert${ ajvError.params.limit === 1 ? 'y' : 'ies' }.`;
+
+ default:
+ // Fallback for any unhandled validation keywords
+ return (
+ ajvError.message ||
+ `${ fullParam } is invalid (failed ${ ajvError.keyword } validation).`
+ );
+ }
+}
+
+/**
+ * Validates a value against a JSON Schema.
+ *
+ * @param value The value to validate.
+ * @param args The JSON Schema to validate against.
+ * @param param Optional parameter name for error messages.
+ * @return True if valid, error message string if invalid.
+ */
+export function validateValueFromSchema(
+ value: any,
+ args: Record< string, any >,
+ param = ''
+): true | ValidationError {
+ // WordPress server expects schema to be an array/object
+ if ( ! args || typeof args !== 'object' ) {
+ // WordPress issues a _doing_it_wrong for invalid schema
+ // Match this behavior with console.warn on client-side
+ // eslint-disable-next-line no-console
+ console.warn( `Schema must be an object. Received ${ typeof args }.` );
+ // Continue validation, treating as valid (matching server behavior)
+ return true;
+ }
+
+ // Type validation - WordPress REST API requires type to be set
+ if ( ! args.type && ! args.anyOf && ! args.oneOf ) {
+ // WordPress issues a _doing_it_wrong but continues
+ // eslint-disable-next-line no-console
+ console.warn(
+ `The "type" schema keyword for ${ param || 'value' } is required.`
+ );
+ return true;
+ }
+
+ try {
+ const { default: defaultValue, ...schemaWithoutDefault } = args;
+ const validate = ajv.compile( schemaWithoutDefault );
+ const valid = validate( value === undefined ? defaultValue : value );
+
+ if ( valid ) {
+ return true;
+ }
+
+ // Return the first error as a simple message string
+ // The API will wrap this with ability_invalid_input/output
+ if ( validate.errors && validate.errors.length > 0 ) {
+ // For anyOf/oneOf, look for the more specific error
+ const anyOfError = validate.errors.find(
+ ( e ) => e.keyword === 'anyOf' || e.keyword === 'oneOf'
+ );
+ if ( anyOfError ) {
+ return formatAjvError( anyOfError, param );
+ }
+ return formatAjvError( validate.errors[ 0 ], param );
+ }
+
+ return `${ param } is invalid.`;
+ } catch ( error ) {
+ // Handle schema compilation errors
+ // eslint-disable-next-line no-console
+ console.error( 'Schema compilation error:', error );
+ return 'Invalid schema provided for validation.';
+ }
+}
diff --git a/packages/abilities/tsconfig.json b/packages/abilities/tsconfig.json
new file mode 100644
index 00000000000000..f88dee6fb8a35d
--- /dev/null
+++ b/packages/abilities/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig.json",
+ "extends": "../../tsconfig.base.json",
+ "exclude": [ "**/*.test.ts", "**/tests/**" ],
+ "references": [
+ {
+ "path": "../api-fetch"
+ },
+ {
+ "path": "../core-data"
+ },
+ {
+ "path": "../data"
+ },
+ {
+ "path": "../i18n"
+ },
+ {
+ "path": "../url"
+ }
+ ]
+}
diff --git a/packages/base-styles/_variables.scss b/packages/base-styles/_variables.scss
index 359303208a13e9..6e618b77ba2f1e 100644
--- a/packages/base-styles/_variables.scss
+++ b/packages/base-styles/_variables.scss
@@ -111,6 +111,7 @@ $modal-width-medium: 512px;
$modal-width-large: 840px;
$spinner-size: 16px;
$canvas-padding: $grid-unit-20;
+$palette-max-height: 368px;
/**
* Mobile specific styles
diff --git a/packages/commands/src/components/command-menu.js b/packages/commands/src/components/command-menu.js
index 8d0ef64b80eee2..f55d4fee05f6dc 100644
--- a/packages/commands/src/components/command-menu.js
+++ b/packages/commands/src/components/command-menu.js
@@ -20,6 +20,7 @@ import {
Modal,
TextHighlight,
__experimentalHStack as HStack,
+ privateApis as componentsPrivateApis,
} from '@wordpress/components';
import {
store as keyboardShortcutsStore,
@@ -31,6 +32,9 @@ import { Icon, search as inputIcon } from '@wordpress/icons';
* Internal dependencies
*/
import { store as commandsStore } from '../store';
+import { unlock } from '../lock-unlock';
+
+const { withIgnoreIMEEvents } = unlock( componentsPrivateApis );
const inputLabel = __( 'Search commands and settings' );
@@ -177,7 +181,6 @@ function CommandInput( { isOpen, search, setSearch } ) {
onValueChange={ setSearch }
placeholder={ inputLabel }
aria-activedescendant={ selectedItemId }
- icon={ search }
/>
);
}
@@ -210,7 +213,7 @@ export function CommandMenu() {
useShortcut(
'core/commands',
/** @type {import('react').KeyboardEventHandler} */
- ( event ) => {
+ withIgnoreIMEEvents( ( event ) => {
// Bails to avoid obscuring the effect of the preceding handler(s).
if ( event.defaultPrevented ) {
return;
@@ -222,7 +225,7 @@ export function CommandMenu() {
} else {
open();
}
- },
+ } ),
{
bindGlobal: true,
}
@@ -245,19 +248,6 @@ export function CommandMenu() {
return false;
}
- const onKeyDown = ( event ) => {
- if (
- // Ignore keydowns from IMEs
- event.nativeEvent.isComposing ||
- // Workaround for Mac Safari where the final Enter/Backspace of an IME composition
- // is `isComposing=false`, even though it's technically still part of the composition.
- // These can only be detected by keyCode.
- event.keyCode === 229
- ) {
- event.preventDefault();
- }
- };
-
const isLoading = Object.values( loaders ).some( Boolean );
return (
@@ -269,7 +259,7 @@ export function CommandMenu() {
contentLabel={ __( 'Command palette' ) }
>
-
+
[cmdk-list] {
- max-height: 368px; // Specific to not have commands overflow oddly.
+ max-height: $palette-max-height; // Specific to not have commands overflow oddly.
overflow: auto;
// Ensures there is always padding bottom on the last group, when there are commands.
- & [cmdk-list-sizer] > [cmdk-group]:last-child [cmdk-group-items]:not(:empty) {
+ &
+ [cmdk-list-sizer] > [cmdk-group]:last-child
+ [cmdk-group-items]:not(:empty) {
padding-bottom: $grid-unit-10;
}
diff --git a/packages/private-apis/src/implementation.ts b/packages/private-apis/src/implementation.ts
index 2e994e98650e7c..6b1e676df75b6c 100644
--- a/packages/private-apis/src/implementation.ts
+++ b/packages/private-apis/src/implementation.ts
@@ -16,6 +16,7 @@ const CORE_MODULES_USING_PRIVATE_APIS = [
'@wordpress/blocks',
'@wordpress/boot',
'@wordpress/commands',
+ '@wordpress/workflows',
'@wordpress/components',
'@wordpress/core-commands',
'@wordpress/core-data',
diff --git a/packages/workflow/CHANGELOG.md b/packages/workflow/CHANGELOG.md
new file mode 100644
index 00000000000000..91e791a0ba61fa
--- /dev/null
+++ b/packages/workflow/CHANGELOG.md
@@ -0,0 +1,7 @@
+
+
+## Unreleased
+
+## 0.1.0 (2025-10-23)
+
+Initial release.
diff --git a/packages/workflow/README.md b/packages/workflow/README.md
new file mode 100644
index 00000000000000..1873ce9cde8131
--- /dev/null
+++ b/packages/workflow/README.md
@@ -0,0 +1,36 @@
+# Workflow
+
+Workflow palette for running abilities in WordPress. This package is private and should not be used directly.
+
+## Installation
+
+Install the module:
+
+```bash
+npm install @wordpress/workflow --save
+```
+
+_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for such language features and APIs, you should include [the polyfill shipped in `@wordpress/babel-preset-default`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/babel-preset-default#polyfill) in your code._
+
+## Usage
+
+The workflow palette provides a searchable interface for discovering and executing abilities registered with the `@wordpress/abilities` package.
+
+### Opening the Palette
+
+The workflow palette can be opened using the keyboard shortcut `Cmd+J` (or `Ctrl+J` on Windows/Linux).
+
+### Features
+
+- **Search and filter abilities** - Type to filter the list of available abilities
+- **Execute abilities** - Select an ability to execute it
+- **View output** - See the results of ability execution in a formatted view
+- **Keyboard navigation** - Navigate with arrow keys, Enter to execute, ESC/Backspace/Delete to go back
+
+## Contributing to this package
+
+This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects.
+
+To find out more about contributing to this package or Gutenberg as a whole, please read the project's main [contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md).
+
+

diff --git a/packages/workflow/package.json b/packages/workflow/package.json
new file mode 100644
index 00000000000000..7b9197ee964a0b
--- /dev/null
+++ b/packages/workflow/package.json
@@ -0,0 +1,60 @@
+{
+ "name": "@wordpress/workflow",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Workflow palette for running abilities in WordPress.",
+ "author": "The WordPress Contributors",
+ "license": "GPL-2.0-or-later",
+ "keywords": [
+ "wordpress",
+ "gutenberg",
+ "workflow",
+ "abilities",
+ "palette"
+ ],
+ "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/workflow/README.md",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/WordPress/gutenberg.git",
+ "directory": "packages/workflow"
+ },
+ "bugs": {
+ "url": "https://github.com/WordPress/gutenberg/issues"
+ },
+ "engines": {
+ "node": ">=18.12.0",
+ "npm": ">=8.19.2"
+ },
+ "main": "build/index.js",
+ "module": "build-module/index.js",
+ "exports": {
+ ".": {
+ "import": "./build-module/index.js",
+ "require": "./build/index.js"
+ },
+ "./package.json": "./package.json",
+ "./build-style/": "./build-style/"
+ },
+ "react-native": "src/index",
+ "wpScript": true,
+ "dependencies": {
+ "@wordpress/abilities": "file:../abilities",
+ "@wordpress/base-styles": "file:../base-styles",
+ "@wordpress/components": "file:../components",
+ "@wordpress/data": "file:../data",
+ "@wordpress/element": "file:../element",
+ "@wordpress/i18n": "file:../i18n",
+ "@wordpress/icons": "file:../icons",
+ "@wordpress/keyboard-shortcuts": "file:../keyboard-shortcuts",
+ "@wordpress/private-apis": "file:../private-apis",
+ "clsx": "^2.1.1",
+ "cmdk": "^1.0.0"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/workflow/src/components/style.scss b/packages/workflow/src/components/style.scss
new file mode 100644
index 00000000000000..f73fd38cf36cd3
--- /dev/null
+++ b/packages/workflow/src/components/style.scss
@@ -0,0 +1,200 @@
+@use "sass:color";
+@use "@wordpress/base-styles/mixins" as *;
+@use "@wordpress/base-styles/variables" as *;
+@use "@wordpress/base-styles/colors" as *;
+
+// Here we extend the modal styles to be tighter, and to the center. Because the palette uses the modal as a container.
+.workflows-workflow-menu {
+ border-radius: $grid-unit-05;
+ width: calc(100% - #{$grid-unit-40});
+ margin: auto;
+ max-width: 400px;
+ position: relative;
+ top: calc(5% + #{$header-height});
+
+ @include break-small() {
+ top: calc(10% + #{$header-height});
+ }
+
+ .components-modal__content {
+ margin: 0;
+ padding: 0;
+ }
+}
+
+.workflows-workflow-menu__overlay {
+ display: block;
+ align-items: start;
+}
+
+.workflows-workflow-menu__header {
+ padding: 0 $grid-unit-20;
+}
+
+.workflows-workflow-menu__header-search-icon {
+ &:dir(ltr) {
+ transform: scaleX(-1);
+ }
+}
+
+.workflows-workflow-menu__container {
+ // the style here is a hack to force safari to repaint to avoid a style glitch
+ will-change: transform;
+
+ &:focus {
+ outline: none;
+ }
+
+ [cmdk-input] {
+ border: none;
+ width: 100%;
+ padding: $grid-unit-20 $grid-unit-05;
+ outline: none;
+ color: $gray-900;
+ margin: 0;
+ font-size: 15px;
+ line-height: 28px;
+ border-radius: 0;
+
+ &::placeholder {
+ color: $gray-700;
+ }
+
+ &:focus {
+ box-shadow: none;
+ outline: none;
+ }
+ }
+
+ [cmdk-item] {
+ border-radius: $radius-small;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ color: $gray-900;
+ font-size: $default-font-size;
+
+ &[aria-selected="true"],
+ &:active {
+ background: var(--wp-admin-theme-color);
+ color: $white;
+ }
+
+ &[aria-disabled="true"] {
+ color: $gray-600;
+ cursor: not-allowed;
+ }
+
+ > div {
+ min-height: $button-size-next-default-40px;
+ padding: $grid-unit-05;
+ padding-left: $grid-unit-20;
+ }
+ }
+
+ [cmdk-root] > [cmdk-list] {
+ max-height: $palette-max-height; // Specific to not have workflows overflow oddly.
+ overflow: auto;
+
+ // Ensures there is always padding bottom on the last group, when there are workflows.
+ &
+ [cmdk-list-sizer] > [cmdk-group]:last-child
+ [cmdk-group-items]:not(:empty) {
+ padding-bottom: $grid-unit-10;
+ }
+
+ & [cmdk-list-sizer] > [cmdk-group] > [cmdk-group-items]:not(:empty) {
+ padding: 0 $grid-unit-10;
+ }
+ }
+
+ [cmdk-empty] {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ white-space: pre-wrap;
+ color: $gray-900;
+ padding: $grid-unit-10 0 $grid-unit-40;
+ }
+
+ [cmdk-loading] {
+ padding: $grid-unit-20;
+ }
+
+ [cmdk-list-sizer] {
+ position: relative;
+ }
+}
+
+.workflows-workflow-menu__item span {
+ // Ensure workflows do not run off the edge (great for post titles).
+ display: inline-block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.workflows-workflow-menu__item mark {
+ color: inherit;
+ background: unset;
+ font-weight: 600;
+}
+
+.workflows-workflow-menu__output {
+ padding: $grid-unit-20;
+}
+
+.workflows-workflow-menu__output-header {
+ margin-bottom: $grid-unit-20;
+ border-bottom: 1px solid $gray-300;
+ padding-bottom: $grid-unit-10;
+
+ h3 {
+ margin: 0 0 $grid-unit-05;
+ font-size: 16px;
+ font-weight: 600;
+ color: $gray-900;
+ }
+}
+
+.workflows-workflow-menu__output-hint {
+ margin: 0;
+ font-size: 12px;
+ color: $gray-700;
+}
+
+.workflows-workflow-menu__output-content {
+ max-height: 400px;
+ overflow: auto;
+
+ pre {
+ margin: 0;
+ padding: $grid-unit-15;
+ background: $gray-100;
+ border-radius: $radius-small;
+ font-size: 12px;
+ line-height: 1.5;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: $gray-900;
+ }
+}
+
+.workflows-workflow-menu__output-error {
+ padding: $grid-unit-15;
+ background: $gray-200;
+ border: 1px solid #{color.adjust( $alert-red, $lightness: -10% )};
+ border-radius: $radius-small;
+ color: $alert-red;
+
+ p {
+ margin: 0;
+ font-size: 13px;
+ }
+}
+
+.workflows-workflow-menu__executing {
+ padding: $grid-unit-30 $grid-unit-20;
+ color: $gray-700;
+ font-size: 14px;
+}
diff --git a/packages/workflow/src/components/workflow-menu.js b/packages/workflow/src/components/workflow-menu.js
new file mode 100644
index 00000000000000..8463ba20be2744
--- /dev/null
+++ b/packages/workflow/src/components/workflow-menu.js
@@ -0,0 +1,284 @@
+/**
+ * External dependencies
+ */
+import { Command, useCommandState } from 'cmdk';
+
+/**
+ * WordPress dependencies
+ */
+import { useSelect, useDispatch } from '@wordpress/data';
+import { useState, useEffect, useRef, useMemo } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import {
+ Modal,
+ TextHighlight,
+ __experimentalHStack as HStack,
+ privateApis as componentsPrivateApis,
+} from '@wordpress/components';
+import {
+ store as keyboardShortcutsStore,
+ useShortcut,
+} from '@wordpress/keyboard-shortcuts';
+import { Icon, search as inputIcon } from '@wordpress/icons';
+import { executeAbility, store as abilitiesStore } from '@wordpress/abilities';
+
+/**
+ * Internal dependencies
+ */
+import { unlock } from '../lock-unlock';
+
+const { withIgnoreIMEEvents } = unlock( componentsPrivateApis );
+
+/**
+ * Constants
+ */
+const EMPTY_ARRAY = [];
+const inputLabel = __( 'Run abilities and workflows' );
+
+function WorkflowInput( { isOpen, search, setSearch, abilities } ) {
+ const workflowMenuInput = useRef();
+ const _value = useCommandState( ( state ) => state.value );
+ const selectedItemId = useMemo( () => {
+ // Find the ability whose label matches the selected value
+ const ability = abilities.find( ( a ) => a.label === _value );
+ return ability?.name;
+ }, [ _value, abilities ] );
+ useEffect( () => {
+ // Focus the workflow palette input when mounting the modal.
+ if ( isOpen ) {
+ workflowMenuInput.current.focus();
+ }
+ }, [ isOpen ] );
+ return (
+
+ );
+}
+
+/**
+ * @ignore
+ */
+export function WorkflowMenu() {
+ const { registerShortcut } = useDispatch( keyboardShortcutsStore );
+ const [ search, setSearch ] = useState( '' );
+ const [ isOpen, setIsOpen ] = useState( false );
+ const [ abilityOutput, setAbilityOutput ] = useState( null );
+ const [ isExecuting, setIsExecuting ] = useState( false );
+ const containerRef = useRef();
+
+ const abilities = useSelect( ( select ) => {
+ const allAbilities = select( abilitiesStore ).getAbilities();
+ return allAbilities || EMPTY_ARRAY;
+ }, [] );
+
+ const filteredAbilities = useMemo( () => {
+ if ( ! search ) {
+ return abilities;
+ }
+ const searchLower = search.toLowerCase();
+ return abilities.filter(
+ ( ability ) =>
+ ability.label?.toLowerCase().includes( searchLower ) ||
+ ability.name?.toLowerCase().includes( searchLower )
+ );
+ }, [ abilities, search ] );
+
+ // Focus container when output is shown so it can receive keyboard events
+ useEffect( () => {
+ if ( abilityOutput && containerRef.current ) {
+ containerRef.current.focus();
+ }
+ }, [ abilityOutput ] );
+
+ useEffect( () => {
+ registerShortcut( {
+ name: 'core/workflows',
+ category: 'global',
+ description: __( 'Open the workflow palette.' ),
+ keyCombination: {
+ modifier: 'primary',
+ character: 'j',
+ },
+ } );
+ }, [ registerShortcut ] );
+
+ useShortcut(
+ 'core/workflows',
+ /** @type {import('react').KeyboardEventHandler} */
+ withIgnoreIMEEvents( ( event ) => {
+ // Bails to avoid obscuring the effect of the preceding handler(s).
+ if ( event.defaultPrevented ) {
+ return;
+ }
+
+ event.preventDefault();
+ setIsOpen( ! isOpen );
+ } ),
+ {
+ bindGlobal: true,
+ }
+ );
+
+ const closeAndReset = () => {
+ setSearch( '' );
+ setIsOpen( false );
+ setAbilityOutput( null );
+ setIsExecuting( false );
+ };
+
+ const goBack = () => {
+ setAbilityOutput( null );
+ setIsExecuting( false );
+ setSearch( '' );
+ };
+
+ const handleExecuteAbility = async ( ability ) => {
+ setIsExecuting( true );
+ try {
+ const result = await executeAbility( ability.name );
+ setAbilityOutput( {
+ name: ability.name,
+ label: ability?.label || ability.name,
+ description: ability?.description || '',
+ success: true,
+ data: result,
+ } );
+ } catch ( error ) {
+ setAbilityOutput( {
+ name: ability.name,
+ label: ability?.label || ability.name,
+ description: ability?.description || '',
+ success: false,
+ error: error.message || String( error ),
+ } );
+ } finally {
+ setIsExecuting( false );
+ }
+ };
+
+ const onContainerKeyDown = ( event ) => {
+ // Handle going back when viewing output
+ if (
+ abilityOutput &&
+ ( event.key === 'Escape' ||
+ event.key === 'Backspace' ||
+ event.key === 'Delete' )
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ goBack();
+ }
+ };
+
+ if ( ! isOpen ) {
+ return null;
+ }
+
+ return (
+
+
+ { abilityOutput ? (
+
+
+
{ abilityOutput.label }
+ { abilityOutput.description && (
+
+ { abilityOutput.description }
+
+ ) }
+
+
+ { abilityOutput.success ? (
+
+ { JSON.stringify(
+ abilityOutput.data,
+ null,
+ 2
+ ) }
+
+ ) : (
+
+
{ abilityOutput.error }
+
+ ) }
+
+
+ ) : (
+
+
+
+
+
+
+ { isExecuting && (
+
+ { __( 'Executing ability…' ) }
+
+ ) }
+ { ! isExecuting &&
+ search &&
+ filteredAbilities.length === 0 && (
+
+ { __( 'No results found.' ) }
+
+ ) }
+ { ! isExecuting && filteredAbilities.length > 0 && (
+
+ { filteredAbilities.map( ( ability ) => (
+
+ handleExecuteAbility( ability )
+ }
+ id={ ability.name }
+ >
+
+
+
+
+
+
+ ) ) }
+
+ ) }
+
+
+ ) }
+
+
+ );
+}
diff --git a/packages/workflow/src/index.js b/packages/workflow/src/index.js
new file mode 100644
index 00000000000000..461848deae7879
--- /dev/null
+++ b/packages/workflow/src/index.js
@@ -0,0 +1,13 @@
+/**
+ * WordPress dependencies
+ */
+import { createRoot, createElement } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { WorkflowMenu } from './components/workflow-menu';
+
+const root = document.createElement( 'div' );
+document.body.appendChild( root );
+createRoot( root ).render( createElement( WorkflowMenu ) );
diff --git a/packages/workflow/src/lock-unlock.js b/packages/workflow/src/lock-unlock.js
new file mode 100644
index 00000000000000..75fb21f68eef27
--- /dev/null
+++ b/packages/workflow/src/lock-unlock.js
@@ -0,0 +1,10 @@
+/**
+ * WordPress dependencies
+ */
+import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis';
+
+export const { lock, unlock } =
+ __dangerousOptInToUnstableAPIsOnlyForCoreModules(
+ 'I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.',
+ '@wordpress/workflows'
+ );
diff --git a/packages/workflow/src/style.scss b/packages/workflow/src/style.scss
new file mode 100644
index 00000000000000..53b8d800efc3f5
--- /dev/null
+++ b/packages/workflow/src/style.scss
@@ -0,0 +1,2 @@
+@use "@wordpress/base-styles/default-custom-properties" as *;
+@use "./components/style.scss" as *;
diff --git a/tsconfig.json b/tsconfig.json
index 6961d50c0e46a0..2528054c3eb6d5 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -3,6 +3,7 @@
"references": [
{ "path": "bin" },
{ "path": "packages/a11y" },
+ { "path": "packages/abilities" },
{ "path": "packages/admin-ui" },
{ "path": "packages/api-fetch" },
{ "path": "packages/asset-loader" },