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

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

Code is Poetry.

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). + +

Code is Poetry.

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" },