From 135ad3b0c0aded46db9a2f54a749951781d19b87 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Wed, 19 Aug 2026 14:31:53 +1000 Subject: [PATCH 1/4] First pass of a full clientside rewrite --- build/index.asset.php | 2 +- build/index.js | 2 +- build/settings.asset.php | 1 + build/settings.js | 2 + build/settings.js.map | 1 + eslint.config.js | 2 + governance/init-governance.php | 1 + governance/settings/settings-view.php | 46 ++++++---- governance/settings/settings.css | 40 +++++++-- governance/settings/settings.js | 82 ------------------ governance/settings/settings.php | 20 ++++- package-lock.json | 35 ++++++-- package.json | 1 + src/block-locking.jsx | 104 ++++++++++++++-------- src/block-locking.test.js | 62 +++++++++++++ src/block-utils.js | 6 +- src/block-utils.test.js | 12 +++ src/index.js | 92 +++++--------------- src/nested-governance-loader.js | 19 ++-- src/nested-governance-loader.test.js | 57 ++++++++++++ src/nested-settings-filter.js | 81 +++++++++++++++++ src/nested-settings-filter.test.js | 120 ++++++++++++++++++++++++++ src/settings/index.js | 5 ++ src/settings/rules-viewer.js | 101 ++++++++++++++++++++++ src/settings/rules-viewer.test.js | 101 ++++++++++++++++++++++ tests/e2e/settings-page.spec.js | 25 ++++++ webpack.config.js | 12 +-- 27 files changed, 792 insertions(+), 240 deletions(-) create mode 100644 build/settings.asset.php create mode 100644 build/settings.js create mode 100644 build/settings.js.map delete mode 100644 governance/settings/settings.js create mode 100644 src/block-locking.test.js create mode 100644 src/nested-settings-filter.js create mode 100644 src/nested-settings-filter.test.js create mode 100644 src/settings/index.js create mode 100644 src/settings/rules-viewer.js create mode 100644 src/settings/rules-viewer.test.js create mode 100644 tests/e2e/settings-page.spec.js diff --git a/build/index.asset.php b/build/index.asset.php index 82aab39..8103f3c 100644 --- a/build/index.asset.php +++ b/build/index.asset.php @@ -1 +1 @@ - array('react-jsx-runtime', 'wp-block-editor', 'wp-components', 'wp-compose', 'wp-data', 'wp-hooks', 'wp-i18n', 'wp-notices'), 'version' => '06926ed746d6307eb9a6'); + array('react-jsx-runtime', 'wp-block-editor', 'wp-components', 'wp-compose', 'wp-data', 'wp-hooks', 'wp-i18n', 'wp-notices'), 'version' => 'b2ccea7aa069a0dcba25'); diff --git a/build/index.js b/build/index.js index e0aee59..16e425c 100644 --- a/build/index.js +++ b/build/index.js @@ -1,2 +1,2 @@ -(()=>{"use strict";var e={122(e,o,t){t.d(o,{G:()=>d});var n=t(715),r=t(427),i=t(491),c=t(143),s=t(619),l=t(418),a=t(790);function d(e){const o=(0,i.createHigherOrderComponent)(o=>t=>{const{name:i,clientId:d}=t,{getBlockParents:u,getBlockName:g}=(0,c.select)(n.store),f=u(d,!0),v=f.some(e=>function(e){return e in p}(e));if(v)return(0,a.jsx)(o,{...t});const m=f.map(e=>g(e));let w=(0,l.yw)(i,m,e);if(w=(0,s.applyFilters)("vip_governance__is_block_allowed_for_editing",w,i,m,e),w)return(0,a.jsx)(o,{...t});if(wp?.blockEditor?.useBlockEditingMode){const{useBlockEditingMode:e}=wp.blockEditor;e("disabled")}return function(e){p[e]=!0}(d),(0,a.jsx)(a.Fragment,{children:(0,a.jsx)(r.Disabled,{children:(0,a.jsx)("div",{style:{opacity:.6,backgroundColor:"#eee",border:"2px dashed #999"},children:(0,a.jsx)(o,{...t})})})})},"withDisabledBlocks");(0,s.addFilter)("editor.BlockEdit","wpcomvip-governance/with-disabled-blocks",o)}const p={}},418(e,o,t){t.d(o,{aV:()=>s,yw:()=>c});var n=t(619),r=t(325);const i={"core/list":["core/list-item"],"core/columns":["core/column"],"core/page-list":["core/page-list-item"],"core/navigation":["core/navigation-link","core/navigation-submenu"],"core/navigation-link":["core/navigation-link","core/navigation-submenu","core/page-list"],"core/quote":["core/paragraph"],"core/media-text":["core/paragraph"],"core/social-links":["core/social-link"],"core/comments-pagination":["core/comments-pagination-previous","core/comments-pagination-numbers","core/comments-pagination-next"]};function c(e,o,t){const c=(0,n.applyFilters)("vip_governance__is_block_allowed_in_hierarchy",!0,e,o,t)||0===o.length?[...t.allowedBlocks]:[];if(o.length>0){if(i[o[0]]&&i[o[0]].includes(e))return!0;if(t.blockSettings){const e=(0,r.W)(o.reverse(),"allowedBlocks",t.blockSettings);e&&e.value&&c.push(...e.value)}}return function(e,o){return o.some(o=>s(e,o))}(e,c)}function s(e,o){return o.includes("*")?e.match(new RegExp(o.replace("*",".*"))):o===e}},325(e,o,t){function n(e,o={},t=!1){const r=["allowedBlocks"];for(const[i,s]of Object.entries(e))if(!r.includes(i))if(i.includes("/")||"*"===i)Object.entries(e).forEach(([e,t])=>{r.includes(e)||n(t,o,e)});else if(!1!==t){const e=c(s,`${i}.`);o[t]={...o[t]??{},...e}}return o}function r(e,o,t,n={depth:0,value:void 0},c=1){const[s,...l]=e,a=t[s];if(0===l.length){const e=i(a,o);return void 0!==e&&c>=n.depth&&(n.depth=c,n.value=e),n}return void 0!==a&&(n=r(l,o,a,n,c+1)),r(l,o,t,n,c)}function i(e,o,t=void 0){const n=Array.isArray(o)?o:o.replace(/(\[(\d)\])/g,".$2").replace(/^\./,"").split(".");if(!n.length||void 0===n[0])return e;const r=n[0];return"object"==typeof e&&null!==e&&r in e&&void 0!==e[r]?i(e[r],n.slice(1),t):t}function c(e,o=""){const t={};return Object.entries(e).forEach(([e,n])=>{"object"==typeof n&&Boolean(n)&&!Array.isArray(n)?(t[`${o}${e}`]=!0,Object.assign(t,c(n,`${o}${e}.`))):t[`${o}${e}`]=!0}),t}t.d(o,{S:()=>n,W:()=>r})},790(e){e.exports=window.ReactJSXRuntime},715(e){e.exports=window.wp.blockEditor},427(e){e.exports=window.wp.components},491(e){e.exports=window.wp.compose},143(e){e.exports=window.wp.data},619(e){e.exports=window.wp.hooks},723(e){e.exports=window.wp.i18n},692(e){e.exports=window.wp.notices}};const o={};function t(n){const r=o[n];if(void 0!==r)return r.exports;const i=o[n]={exports:{}};return e[n](i,i.exports,t),i.exports}t.n=e=>{const o=e&&e.__esModule?()=>e.default:()=>e;return t.d(o,{a:o}),o},t.d=(e,o)=>{if(Array.isArray(o))for(var n=0;nObject.hasOwn(e,o);var n=t(715),r=t(143),i=t(619),c=t(723),s=t(692),l=t(122),a=t(418),d=t(325);!function(){if(VIP_GOVERNANCE.error)return void(0,r.dispatch)(s.store).createErrorNotice(VIP_GOVERNANCE.error,{id:"wpcomvip-governance-error",isDismissible:!0,actions:[{label:(0,c.__)("Open governance settings"),url:VIP_GOVERNANCE.urlSettingsPage}]});const e=VIP_GOVERNANCE.governanceRules;(0,i.addFilter)("blockEditor.__unstableCanInsertBlockType","wpcomvip-governance/block-insertion",(o,t,c,{getBlock:s})=>{if(!1===o)return o;let l=[];if(c){const{getBlockParents:e,getBlockName:o}=(0,r.select)(n.store),t=s(c),i=e(c,!0);l=[t.clientId,...i].map(e=>o(e))}const d=(0,a.yw)(t.name,l,e);return(0,i.applyFilters)("vip_governance__is_block_allowed_for_insertion",d,t.name,l,e)});const o=VIP_GOVERNANCE.nestedSettings,t=(0,d.S)(o),p={},u={};for(const e in t)-1===e.indexOf("*")?u[e]=t[e]:p[e]=t[e];(0,i.addFilter)("blockEditor.useSetting.before","wpcomvip-governance/nested-block-settings",(e,t,i,c)=>{if(!c)return e;if(void 0!==u[c]&&!0===u[c][t]){const c=[i,...(0,r.select)(n.store).getBlockParents(i,!0)].map(e=>(0,r.select)(n.store).getBlockName(e)).reverse();return({value:e}=(0,d.W)(c,t,o)),e&&e.theme?e.theme:e}if(0!==p.length)for(const s in p)if((0,a.aV)(c,s)&&!0===p[s][t]){const c=[i,...(0,r.select)(n.store).getBlockParents(i,!0)].map(e=>(0,r.select)(n.store).getBlockName(e)).reverse();return-1!==s.indexOf("*")&&(c[c.length-1]=s),({value:e}=(0,d.W)(c,t,o)),e&&e.theme?e.theme:e}return e}),e?.allowedBlocks&&(0,l.G)(e)}()})(); +(()=>{"use strict";var e={122(e,t,n){n.d(t,{G:()=>u});var o=n(715),r=n(427),i=n(491),c=n(143),s=n(619),a=n(418),l=n(790);const d={opacity:.6,backgroundColor:"#eee",border:"2px dashed #999"};function u(e){const t=(0,i.createHigherOrderComponent)(t=>function(n){const{name:i,clientId:u}=n,{getBlockParents:g,getBlockName:f}=(0,c.select)(o.store),v=g(u,!0),w=v.some(e=>function(e){return p.has(e)}(e)),m=function(e,t,n,o){if(o)return!0;const r=(0,a.yw)(e,t,n);return(0,s.applyFilters)("vip_governance__is_block_allowed_for_editing",r,e,t,n)}(i,v.map(e=>f(e)),e,w);return(0,o.useBlockEditingMode)(m?void 0:"disabled"),function(e,t){t?p.add(e):p.delete(e)}(u,!m),m?(0,l.jsx)(t,{...n}):(0,l.jsx)(r.Disabled,{children:(0,l.jsx)("div",{style:d,children:(0,l.jsx)(t,{...n})})})},"withDisabledBlocks");(0,s.addFilter)("editor.BlockEdit","wpcomvip-governance/with-disabled-blocks",t)}const p=new Set},418(e,t,n){n.d(t,{aV:()=>s,yw:()=>c});var o=n(619),r=n(325);const i={"core/list":["core/list-item"],"core/columns":["core/column"],"core/page-list":["core/page-list-item"],"core/navigation":["core/navigation-link","core/navigation-submenu"],"core/navigation-link":["core/navigation-link","core/navigation-submenu","core/page-list"],"core/quote":["core/paragraph"],"core/media-text":["core/paragraph"],"core/social-links":["core/social-link"],"core/comments-pagination":["core/comments-pagination-previous","core/comments-pagination-numbers","core/comments-pagination-next"]};function c(e,t,n){const c=(0,o.applyFilters)("vip_governance__is_block_allowed_in_hierarchy",!0,e,t,n)||0===t.length?[...n.allowedBlocks]:[];if(t.length>0){if(i[t[0]]&&i[t[0]].includes(e))return!0;if(n.blockSettings){const e=(0,r.W)([...t].reverse(),"allowedBlocks",n.blockSettings);e&&e.value&&c.push(...e.value)}}return function(e,t){return t.some(t=>s(e,t))}(e,c)}function s(e,t){if(t.includes("*")){const n=t.replace(/[.+?^${}()|[\]\\]/g,"\\$&").replaceAll("*",".*");return new RegExp(`^${n}$`).test(e)}return t===e}},325(e,t,n){function o(e,t={},n=!1){const r=["allowedBlocks"];for(const[i,s]of Object.entries(e))if(!r.includes(i))if(i.includes("/")||"*"===i)o(s,t,i);else if(!1!==n){const e="object"!=typeof s||null===s||Array.isArray(s)?{[i]:!0}:c(s,`${i}.`);t[n]={...t[n]??{},...e}}return t}function r(e,t,n,o={depth:0,value:void 0},c=1){if(0===e.length)return o;const[s,...a]=e,l=n[s];if(0===a.length){const e=i(l,t);return void 0!==e&&c>=o.depth&&(o.depth=c,o.value=e),o}return void 0!==l&&(o=r(a,t,l,o,c+1)),r(a,t,n,o,c)}function i(e,t,n=void 0){const o=Array.isArray(t)?t:t.replace(/(\[(\d)\])/g,".$2").replace(/^\./,"").split(".");if(!o.length||void 0===o[0])return e;const r=o[0];return"object"==typeof e&&null!==e&&r in e&&void 0!==e[r]?i(e[r],o.slice(1),n):n}function c(e,t=""){const n={};return Object.entries(e).forEach(([e,o])=>{"object"==typeof o&&Boolean(o)&&!Array.isArray(o)?(n[`${t}${e}`]=!0,Object.assign(n,c(o,`${t}${e}.`))):n[`${t}${e}`]=!0}),n}n.d(t,{S:()=>o,W:()=>r})},975(e,t,n){n.d(t,{ED:()=>c,YW:()=>i});var o=n(418),r=n(325);function i(e){const t=new Map,n=new Map;for(const[o,i]of Object.entries((0,r.S)(e)))(o.includes("*")?n:t).set(o,i);return{exactPaths:t,wildcardPaths:n}}function c({defaultValue:e,path:t,clientId:n,blockName:i,nestedSettings:c,exactPaths:s,wildcardPaths:a,getBlockParents:l,getBlockName:d}){let u;if(u=!0===s.get(i)?.[t]?i:[...a.entries()].find(([e,n])=>(0,o.aV)(i,e)&&!0===n[t])?.[0],!u)return e;const p=function(e,t,n){return[...[...t(e,!0)].reverse(),e].map(n)}(n,l,d);p[p.length-1]=u;const{value:g}=(0,r.W)(p,t,c);return g?.theme??g}},790(e){e.exports=window.ReactJSXRuntime},715(e){e.exports=window.wp.blockEditor},427(e){e.exports=window.wp.components},491(e){e.exports=window.wp.compose},143(e){e.exports=window.wp.data},619(e){e.exports=window.wp.hooks},723(e){e.exports=window.wp.i18n},692(e){e.exports=window.wp.notices}};const t={};function n(o){const r=t[o];if(void 0!==r)return r.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,n),i.exports}n.n=e=>{const t=e&&e.__esModule?()=>e.default:()=>e;return n.d(t,{a:t}),t},n.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oObject.hasOwn(e,t);var o=n(715),r=n(143),i=n(619),c=n(723),s=n(692),a=n(122),l=n(418),d=n(975);!function(){if(VIP_GOVERNANCE.error)return void(0,r.dispatch)(s.store).createErrorNotice(VIP_GOVERNANCE.error,{id:"wpcomvip-governance-error",isDismissible:!0,actions:[{label:(0,c.__)("Open governance settings","vip-governance"),url:VIP_GOVERNANCE.urlSettingsPage}]});const e=VIP_GOVERNANCE.governanceRules,{getBlockParents:t,getBlockName:n}=(0,r.select)(o.store);(0,i.addFilter)("blockEditor.__unstableCanInsertBlockType","wpcomvip-governance/block-insertion",(o,r,c,{getBlock:s})=>{if(!1===o)return o;let a=[];if(c){const e=s(c),o=t(c,!0);a=[e?.name??n(c),...o.map(n)]}const d=(0,l.yw)(r.name,a,e);return(0,i.applyFilters)("vip_governance__is_block_allowed_for_insertion",d,r.name,a,e)});const u=VIP_GOVERNANCE.nestedSettings,{exactPaths:p,wildcardPaths:g}=(0,d.YW)(u);(0,i.addFilter)("blockEditor.useSetting.before","wpcomvip-governance/nested-block-settings",(e,o,r,i)=>i?(0,d.ED)({defaultValue:e,path:o,clientId:r,blockName:i,nestedSettings:u,exactPaths:p,wildcardPaths:g,getBlockParents:t,getBlockName:n}):e),e?.allowedBlocks&&(0,a.G)(e)}()})(); //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/build/settings.asset.php b/build/settings.asset.php new file mode 100644 index 0000000..fe5ab7a --- /dev/null +++ b/build/settings.asset.php @@ -0,0 +1 @@ + array('wp-api-fetch'), 'version' => '718fa688c8a548b1f421'); diff --git a/build/settings.js b/build/settings.js new file mode 100644 index 0000000..c3923cc --- /dev/null +++ b/build/settings.js @@ -0,0 +1,2 @@ +(()=>{"use strict";var e={761(e,t,n){function r({root:e=document,request:t}){const n=e.getElementById("user-role-selector"),r=e.getElementById("post-type-selector"),s=e.getElementById("view-rules-button"),a=e.querySelector(".vip-governance-query-spinner"),o=e.getElementById("combined-governance-rules-json");if(!(n&&r&&s&&a&&o&&"function"==typeof t))return;let d=0;const i=()=>{const e={};return n.value&&(e.role=n.value),r.value&&(e.postType=r.value),e},c=()=>{const e=Object.keys(i()).length>0;s.hidden=!e,e||(o.hidden=!0)},l=e=>{o.textContent=e,o.hidden=!1};n.addEventListener("change",c),r.addEventListener("change",c),s.addEventListener("click",async()=>{const e=i();if(0===Object.keys(e).length)return;const c=++d;n.disabled=!0,r.disabled=!0,s.disabled=!0,a.hidden=!1,a.classList.add("is-active");try{const n=await t({path:`/vip-governance/v1/rules?${new URLSearchParams(e)}`});c===d&&l(JSON.stringify(n,null,4))}catch(e){c===d&&l(e?.message||o.dataset.errorMessage)}finally{c===d&&(n.disabled=!1,r.disabled=!1,s.disabled=!1,a.hidden=!0,a.classList.remove("is-active"))}}),c()}n.d(t,{S:()=>r})},455(e){e.exports=window.wp.apiFetch}};const t={};function n(r){const s=t[r];if(void 0!==s)return s.exports;const a=t[r]={exports:{}};return e[r](a,a.exports,n),a.exports}n.n=e=>{const t=e&&e.__esModule?()=>e.default:()=>e;return n.d(t,{a:t}),t},n.d=(e,t)=>{if(Array.isArray(t))for(var r=0;rObject.hasOwn(e,t);var r=n(455),s=n.n(r);(0,n(761).S)({request:s()})})(); +//# sourceMappingURL=settings.js.map \ No newline at end of file diff --git a/build/settings.js.map b/build/settings.js.map new file mode 100644 index 0000000..d0cc029 --- /dev/null +++ b/build/settings.js.map @@ -0,0 +1 @@ +{"version":3,"file":"settings.js","mappings":"qCAUO,SAASA,GAAuBC,KAAEA,EAAOC,SAAQC,QAAEA,IACzD,MAAMC,EAAeH,EAAKI,eAAgB,sBACpCC,EAAmBL,EAAKI,eAAgB,sBACxCE,EAAaN,EAAKI,eAAgB,qBAClCG,EAAUP,EAAKQ,cAAe,iCAC9BC,EAAST,EAAKI,eAAgB,kCAEpC,KACGD,GACAE,GACAC,GACAC,GACAE,GACiB,mBAAZP,GAEP,OAGD,IAAIQ,EAAgB,EAEpB,MAAMC,EAAiBA,KACtB,MAAMC,EAAO,CAAC,EAUd,OARKT,EAAaU,QACjBD,EAAKE,KAAOX,EAAaU,OAGrBR,EAAiBQ,QACrBD,EAAKG,SAAWV,EAAiBQ,OAG3BD,GAGFI,EAAiBA,KACtB,MAAMC,EAAeC,OAAOC,KAAMR,KAAmBS,OAAS,EAC9Dd,EAAWe,QAAWJ,EAEfA,IACNR,EAAOY,QAAS,IAIZC,EAAaT,IAClBJ,EAAOc,YAAcV,EACrBJ,EAAOY,QAAS,GAwCjBlB,EAAaqB,iBAAkB,SAAUR,GACzCX,EAAiBmB,iBAAkB,SAAUR,GAC7CV,EAAWkB,iBAAkB,QAvCPC,UACrB,MAAMb,EAAOD,IAEb,GAAoC,IAA/BO,OAAOC,KAAMP,GAAOQ,OACxB,OAGD,MAAMM,IAAchB,EACpBP,EAAawB,UAAW,EACxBtB,EAAiBsB,UAAW,EAC5BrB,EAAWqB,UAAW,EACtBpB,EAAQc,QAAS,EACjBd,EAAQqB,UAAUC,IAAK,aAEvB,IACC,MAAMC,QAAc5B,EAAS,CAC5B6B,KAAM,4BAAwB,IAAIC,gBAAiBpB,OAG/Cc,IAAchB,GAClBY,EAAYW,KAAKC,UAAWJ,EAAO,KAAM,GAE3C,CAAE,MAAQK,GACJT,IAAchB,GAClBY,EAAYa,GAAOC,SAAW3B,EAAO4B,QAAQC,aAE/C,CAAC,QACKZ,IAAchB,IAClBP,EAAawB,UAAW,EACxBtB,EAAiBsB,UAAW,EAC5BrB,EAAWqB,UAAW,EACtBpB,EAAQc,QAAS,EACjBd,EAAQqB,UAAUW,OAAQ,aAE5B,IAODvB,GACD,0BCpGAwB,EAAAC,QAAAC,OAAA,cCCA,MAAAC,EAAA,GAGA,SAAAC,EAAAC,GAEA,MAAAC,EAAAH,EAAAE,GACA,QAAAE,IAAAD,EACA,OAAAA,EAAAL,QAGA,MAAAD,EAAAG,EAAAE,GAAA,CAGAJ,QAAA,IAOA,OAHAO,EAAAH,GAAAL,EAAAA,EAAAC,QAAAG,GAGAJ,EAAAC,OACA,CCrBAG,EAAAK,EAAAT,IACA,MAAAU,EAAAV,GAAAA,EAAAW,WACA,IAAAX,EAAA,QACA,MAEA,OADAI,EAAAQ,EAAAF,EAAA,CAAiCG,EAAAH,IACjCA,GCLAN,EAAAQ,EAAA,CAAAX,EAAAa,KACA,GAAAC,MAAAC,QAAAF,GAEA,IADA,IAAAG,EAAA,EACAA,EAAAH,EAAAlC,QAAA,CACA,IAAAsC,EAAAJ,EAAAG,KACAE,EAAAL,EAAAG,KACAb,EAAAgB,EAAAnB,EAAAiB,GAMK,IAAAC,GAAyBF,IAL9B,IAAAE,EACAzC,OAAA2C,eAAApB,EAAAiB,EAAA,CAA2CI,YAAA,EAAAjD,MAAAyC,EAAAG,OAE3CvC,OAAA2C,eAAApB,EAAAiB,EAAA,CAA2CI,YAAA,EAAAC,IAAAJ,GAG3C,MAEA,QAAAD,KAAAJ,EACAV,EAAAgB,EAAAN,EAAAI,KAAAd,EAAAgB,EAAAnB,EAAAiB,IACAxC,OAAA2C,eAAApB,EAAAiB,EAAA,CAA0CI,YAAA,EAAAC,IAAAT,EAAAI,MClB1Cd,EAAAgB,EAAA,CAAAI,EAAAC,IAAA/C,OAAAgD,OAAAF,EAAAC,0BCIAlE,SAAAA,GAAuB,CAAEG,QAASiE","sources":["webpack://vip-governance/./src/settings/rules-viewer.js","webpack://vip-governance/external window [\"wp\",\"apiFetch\"]","webpack://vip-governance/webpack/bootstrap","webpack://vip-governance/webpack/runtime/compat get default export","webpack://vip-governance/webpack/runtime/define property getters","webpack://vip-governance/webpack/runtime/hasOwnProperty shorthand","webpack://vip-governance/./src/settings/index.js"],"sourcesContent":["const RULES_ENDPOINT = '/vip-governance/v1/rules';\n\n/**\n * Initialize the role and post type rules viewer on the plugin settings page.\n *\n * @param {Object} options Initialization options.\n * @param {Document} [options.root] Document containing the settings page.\n * @param {Function} options.request Function used to request combined rules.\n * @return {void}\n */\nexport function initializeRulesViewer( { root = document, request } ) {\n\tconst roleSelector = root.getElementById( 'user-role-selector' );\n\tconst postTypeSelector = root.getElementById( 'post-type-selector' );\n\tconst viewButton = root.getElementById( 'view-rules-button' );\n\tconst spinner = root.querySelector( '.vip-governance-query-spinner' );\n\tconst output = root.getElementById( 'combined-governance-rules-json' );\n\n\tif (\n\t\t! roleSelector ||\n\t\t! postTypeSelector ||\n\t\t! viewButton ||\n\t\t! spinner ||\n\t\t! output ||\n\t\ttypeof request !== 'function'\n\t) {\n\t\treturn;\n\t}\n\n\tlet latestRequest = 0;\n\n\tconst getRequestData = () => {\n\t\tconst data = {};\n\n\t\tif ( roleSelector.value ) {\n\t\t\tdata.role = roleSelector.value;\n\t\t}\n\n\t\tif ( postTypeSelector.value ) {\n\t\t\tdata.postType = postTypeSelector.value;\n\t\t}\n\n\t\treturn data;\n\t};\n\n\tconst updateControls = () => {\n\t\tconst hasSelection = Object.keys( getRequestData() ).length > 0;\n\t\tviewButton.hidden = ! hasSelection;\n\n\t\tif ( ! hasSelection ) {\n\t\t\toutput.hidden = true;\n\t\t}\n\t};\n\n\tconst showOutput = value => {\n\t\toutput.textContent = value;\n\t\toutput.hidden = false;\n\t};\n\n\tconst handleRequest = async () => {\n\t\tconst data = getRequestData();\n\n\t\tif ( Object.keys( data ).length === 0 ) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst requestId = ++latestRequest;\n\t\troleSelector.disabled = true;\n\t\tpostTypeSelector.disabled = true;\n\t\tviewButton.disabled = true;\n\t\tspinner.hidden = false;\n\t\tspinner.classList.add( 'is-active' );\n\n\t\ttry {\n\t\t\tconst rules = await request( {\n\t\t\t\tpath: `${ RULES_ENDPOINT }?${ new URLSearchParams( data ) }`,\n\t\t\t} );\n\n\t\t\tif ( requestId === latestRequest ) {\n\t\t\t\tshowOutput( JSON.stringify( rules, null, 4 ) );\n\t\t\t}\n\t\t} catch ( error ) {\n\t\t\tif ( requestId === latestRequest ) {\n\t\t\t\tshowOutput( error?.message || output.dataset.errorMessage );\n\t\t\t}\n\t\t} finally {\n\t\t\tif ( requestId === latestRequest ) {\n\t\t\t\troleSelector.disabled = false;\n\t\t\t\tpostTypeSelector.disabled = false;\n\t\t\t\tviewButton.disabled = false;\n\t\t\t\tspinner.hidden = true;\n\t\t\t\tspinner.classList.remove( 'is-active' );\n\t\t\t}\n\t\t}\n\t};\n\n\troleSelector.addEventListener( 'change', updateControls );\n\tpostTypeSelector.addEventListener( 'change', updateControls );\n\tviewButton.addEventListener( 'click', handleRequest );\n\n\tupdateControls();\n}\n","module.exports = window[\"wp\"][\"apiFetch\"];","// The module cache\nconst __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tconst cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tconst module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId](module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","// getDefaultExport function for compatibility with non-harmony modules\n__webpack_require__.n = (module) => {\n\tconst getter = module && module.__esModule ?\n\t\t() => (module['default']) :\n\t\t() => (module);\n\t__webpack_require__.d(getter, { a: getter });\n\treturn getter;\n};","// define getter/value functions for harmony exports\n__webpack_require__.d = (exports, definition) => {\n\tif(Array.isArray(definition)) {\n\t\tvar i = 0;\n\t\twhile(i < definition.length) {\n\t\t\tvar key = definition[i++];\n\t\t\tvar binding = definition[i++];\n\t\t\tif(!__webpack_require__.o(exports, key)) {\n\t\t\t\tif(binding === 0) {\n\t\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, value: definition[i++] });\n\t\t\t\t} else {\n\t\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: binding });\n\t\t\t\t}\n\t\t\t} else if(binding === 0) { i++; }\n\t\t}\n\t} else {\n\t\tfor(var key in definition) {\n\t\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t\t}\n\t\t}\n\t}\n};","__webpack_require__.o = (obj, prop) => (Object.hasOwn(obj, prop))","import apiFetch from '@wordpress/api-fetch';\n\nimport { initializeRulesViewer } from './rules-viewer';\n\ninitializeRulesViewer( { request: apiFetch } );\n"],"names":["initializeRulesViewer","root","document","request","roleSelector","getElementById","postTypeSelector","viewButton","spinner","querySelector","output","latestRequest","getRequestData","data","value","role","postType","updateControls","hasSelection","Object","keys","length","hidden","showOutput","textContent","addEventListener","async","requestId","disabled","classList","add","rules","path","URLSearchParams","JSON","stringify","error","message","dataset","errorMessage","remove","module","exports","window","__webpack_module_cache__","__webpack_require__","moduleId","cachedModule","undefined","__webpack_modules__","n","getter","__esModule","d","a","definition","Array","isArray","i","key","binding","o","defineProperty","enumerable","get","obj","prop","hasOwn","apiFetch"],"sourceRoot":""} \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 500ef09..c8e506f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -12,6 +12,8 @@ module.exports = [ { languageOptions: { globals: { + document: 'readonly', + URLSearchParams: 'readonly', VIP_GOVERNANCE: 'readonly', }, }, diff --git a/governance/init-governance.php b/governance/init-governance.php index 1002a52..ab9c0f8 100644 --- a/governance/init-governance.php +++ b/governance/init-governance.php @@ -75,6 +75,7 @@ public static function load_settings(): void { $asset_file['version'], true /* in_footer */ ); + wp_set_script_translations( 'wpcomvip-governance', 'vip-governance' ); $nested_settings_and_css = self::$governance_configuration['nestedSettingsAndCss']; diff --git a/governance/settings/settings-view.php b/governance/settings/settings-view.php index 0324eb6..1774bd3 100644 --- a/governance/settings/settings-view.php +++ b/governance/settings/settings-view.php @@ -79,24 +79,34 @@ static function ( $line ) {

- - - - - - - - +
+ + + + + + + +
+
diff --git a/governance/settings/settings.css b/governance/settings/settings.css index 1694f41..54b20fe 100644 --- a/governance/settings/settings.css +++ b/governance/settings/settings.css @@ -1,4 +1,5 @@ -.governance-rules pre, .combined-governance-rules pre { +.governance-rules pre, +.combined-governance-rules pre { background: rgba(0, 0, 0, .07); padding: 1rem 2rem 1rem 1rem; margin: 0; @@ -28,31 +29,54 @@ display: inline-block; } -.governance-rules-json summary, .combined-governance-rules summary { +.governance-rules-json summary, +.combined-governance-rules summary { cursor: pointer; padding: 0.5rem 0 1rem 0; } -.validation-errors, .validation-warnings { +.validation-errors, +.validation-warnings { font-size: 1rem; font-weight: 700; } -.vip-governance-query-spinner span { +.vip-governance-rule-filters { + display: flex; + align-items: flex-end; + flex-wrap: wrap; + gap: 0.5rem; + margin: 1rem 0 0.5rem; +} + +.vip-governance-rule-filters label { + display: grid; + gap: 0.25rem; +} + +.vip-governance-rule-filters [hidden] { + display: none !important; +} + +.vip-governance-query-spinner { float: none; - margin-top: 0; + margin: 0 0 0.25rem; +} + +.combined-governance-rules-json { + margin: 1rem 0; } -pre { +.governance-rules pre { counter-reset: line; tab-size: 4; } -code { +.governance-rules pre code { counter-increment: line; } -code:before { +.governance-rules pre code::before { content: counter(line); width: 2rem; display: inline-block; diff --git a/governance/settings/settings.js b/governance/settings/settings.js deleted file mode 100644 index ad8d0f7..0000000 --- a/governance/settings/settings.js +++ /dev/null @@ -1,82 +0,0 @@ -/* eslint-disable no-undef */ -// eslint-disable-next-line no-unused-vars -( () => { - function showRulesForRuleType() { - const roleSelector = document.getElementById( 'user-role-selector' ); - const rolePicked = roleSelector?.value ?? null; - - const postTypeSelector = document.getElementById( 'post-type-selector' ); - const postTypePicked = postTypeSelector?.value ?? null; - - if ( ( rolePicked || postTypePicked ) && window.wp && window.wp.apiRequest ) { - dataToBeSent = {}; - if ( rolePicked ) { - dataToBeSent.role = rolePicked; - } - - if ( postTypePicked ) { - dataToBeSent.postType = postTypePicked; - } - - document.querySelector( '.vip-governance-query-spinner' ).classList.add( 'is-active' ); - window.wp - .apiRequest( { - path: `/vip-governance/v1/rules`, - data: dataToBeSent, - } ) - .done( rules => { - document.getElementById( 'json' ).textContent = JSON.stringify( rules, undefined, 4 ); - document.getElementById( 'json' ).hidden = false; - } ) - .fail( error => { - document.getElementById( 'json' ).textContent = error.responseJSON.message; - document.getElementById( 'json' ).hidden = false; - } ) - .complete( () => { - document.querySelector( '.vip-governance-query-spinner' ).classList.remove( 'is-active' ); - } ); - } - } - - const roleSelector = document.getElementById( 'user-role-selector' ); - - if ( roleSelector ) { - // Reset to the default value on refresh - roleSelector.value = ''; - - roleSelector.addEventListener( 'change', () => { - const postTypePicked = document.getElementById( 'post-type-selector' )?.value ?? null; - if ( roleSelector.value ) { - document.getElementById( 'view-rules-button' ).style.display = 'inline'; - } else if ( ! postTypePicked ) { - document.getElementById( 'view-rules-button' ).style.display = 'none'; - document.getElementById( 'json' ).hidden = true; - } - } ); - } - - const postTypeSelector = document.getElementById( 'post-type-selector' ); - - if ( postTypeSelector ) { - // Reset to the default value on refresh - postTypeSelector.value = ''; - - postTypeSelector.addEventListener( 'change', () => { - const rolePicked = document.getElementById( 'user-role-selector' )?.value ?? null; - if ( postTypeSelector.value ) { - document.getElementById( 'view-rules-button' ).style.display = 'inline'; - } else if ( ! rolePicked ) { - document.getElementById( 'view-rules-button' ).style.display = 'none'; - document.getElementById( 'json' ).hidden = true; - } - } ); - } - - const viewButton = document.getElementById( 'view-rules-button' ); - - if ( viewButton ) { - viewButton.style.display = 'none'; - - viewButton.addEventListener( 'click', showRulesForRuleType ); - } -} )(); diff --git a/governance/settings/settings.php b/governance/settings/settings.php index 800d482..de186e5 100644 --- a/governance/settings/settings.php +++ b/governance/settings/settings.php @@ -91,11 +91,25 @@ public static function enqueue_resources(): void { * @access private */ public static function enqueue_scripts(): void { + $asset_file_path = WPCOMVIP_GOVERNANCE_ROOT_PLUGIN_DIR . '/build/settings.asset.php'; + if ( ! is_readable( $asset_file_path ) ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( sprintf( 'VIP Block Governance settings asset manifest is not readable: %s', $asset_file_path ) ); + return; + } + + $asset_file = include $asset_file_path; + if ( ! is_array( $asset_file ) || ! isset( $asset_file['dependencies'], $asset_file['version'] ) || ! is_array( $asset_file['dependencies'] ) ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( sprintf( 'VIP Block Governance settings asset manifest is invalid: %s', $asset_file_path ) ); + return; + } + wp_enqueue_script( 'wpcomvip-governance-settings', - plugins_url( '/governance/settings/settings.js', WPCOMVIP_GOVERNANCE_ROOT_PLUGIN_FILE ), - /* dependencies */ [ 'wp-api' ], - WPCOMVIP__GOVERNANCE__PLUGIN_VERSION, + plugins_url( '/build/settings.js', WPCOMVIP_GOVERNANCE_ROOT_PLUGIN_FILE ), + $asset_file['dependencies'], + $asset_file['version'], /* in footer */ true ); } diff --git a/package-lock.json b/package-lock.json index d86a0c5..c917f7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "devDependencies": { "@automattic/eslint-plugin-wpvip": "^1.3.0", "@playwright/test": "^1.62.1", + "@wordpress/api-fetch": "^7.53.0", "@wordpress/block-editor": "^16.1.0", "@wordpress/components": "^38.0.0", "@wordpress/compose": "^8.5.0", @@ -8355,6 +8356,22 @@ "npm": ">=8.19.2" } }, + "node_modules/@wordpress/api-fetch": { + "version": "7.53.0", + "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-7.53.0.tgz", + "integrity": "sha512-601xtWJS35s/1TaFEIP5gAeR87Y4EfGhByTYoqoX4MvBo+h/yBM75M/O7ZuqPT1Q8Se0aDwI2eDw6r/nbCW5Mg==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/i18n": "^6.26.0", + "@wordpress/private-apis": "^1.53.0", + "@wordpress/url": "^4.53.0" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, "node_modules/@wordpress/autop": { "version": "4.52.0", "dev": true, @@ -9076,7 +9093,9 @@ } }, "node_modules/@wordpress/hooks": { - "version": "4.52.0", + "version": "4.53.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.53.0.tgz", + "integrity": "sha512-YU30rweJCTu5g7X8bc+w6QyoxJBYLEHH++Wwo/IbbROHUC/4I7e+ZtxnPWls8BTtMP4d28JJ74s+UoKpzd2J7w==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9094,12 +9113,14 @@ } }, "node_modules/@wordpress/i18n": { - "version": "6.25.0", + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-6.26.0.tgz", + "integrity": "sha512-v/LzeB7T5YKIfBgmLM0jYG8zOuu81biZ90j8RY9pHIVu+jdRE+DSmt55S3FvtXRo5J2X9FoigFRzYPKXLbitWA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@tannin/sprintf": "^1.3.2", - "@wordpress/hooks": "^4.52.0", + "@wordpress/hooks": "^4.53.0", "gettext-parser": "^1.3.1", "tannin": "^1.2.0" }, @@ -9394,7 +9415,9 @@ } }, "node_modules/@wordpress/private-apis": { - "version": "1.52.0", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-1.53.0.tgz", + "integrity": "sha512-xXW2yeBC0n/doFLsv6chEl3Qs5phqmRH7wcUyil+Xvh3msVGzUJxvyo0szmKpQcmoYafASpkQux82HCtNp6jxQ==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9919,7 +9942,9 @@ } }, "node_modules/@wordpress/url": { - "version": "4.52.0", + "version": "4.53.0", + "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-4.53.0.tgz", + "integrity": "sha512-o/s7CRfmxT4XAQWeBCfaURUQv15HBmLAuCQV/DdO8h3+c0cLJX+mcIDrdMWA49ANo4ncGQdZRtTKU0rY/I6+wA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { diff --git a/package.json b/package.json index 6cef41b..c067a2d 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "devDependencies": { "@automattic/eslint-plugin-wpvip": "^1.3.0", "@playwright/test": "^1.62.1", + "@wordpress/api-fetch": "^7.53.0", "@wordpress/block-editor": "^16.1.0", "@wordpress/components": "^38.0.0", "@wordpress/compose": "^8.5.0", diff --git a/src/block-locking.jsx b/src/block-locking.jsx index 39fc2ef..850d6fa 100644 --- a/src/block-locking.jsx +++ b/src/block-locking.jsx @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { store as blockEditorStore } from '@wordpress/block-editor'; +import { store as blockEditorStore, useBlockEditingMode } from '@wordpress/block-editor'; import { Disabled } from '@wordpress/components'; import { createHigherOrderComponent } from '@wordpress/compose'; import { select } from '@wordpress/data'; @@ -12,9 +12,15 @@ import { addFilter, applyFilters } from '@wordpress/hooks'; */ import { isBlockAllowedInHierarchy } from './block-utils'; +const LOCKED_BLOCK_STYLES = { + opacity: 0.6, + backgroundColor: '#eee', + border: '2px dashed #999', +}; + export function setupBlockLocking( governanceRules ) { const withDisabledBlocks = createHigherOrderComponent( BlockEdit => { - return props => { + return function GovernedBlockEdit( props ) { const { name: blockName, clientId } = props; const { getBlockParents, getBlockName } = select( blockEditorStore ); @@ -24,17 +30,10 @@ export function setupBlockLocking( governanceRules ) { isBlockLocked( parentClientId ) ); - if ( isParentLocked ) { - // To avoid layout issues, only disable the outermost locked block - return ; - } - const parentBlockNames = parentClientIds.map( parentClientId => getBlockName( parentClientId ) ); - let isAllowed = isBlockAllowedInHierarchy( blockName, parentBlockNames, governanceRules ); - /** * Change what blocks are allowed to be edited in the block editor. * @@ -45,36 +44,27 @@ export function setupBlockLocking( governanceRules ) { * @param {Object} governanceRules An object containing the full set of governance * rules for the current user. */ - isAllowed = applyFilters( - 'vip_governance__is_block_allowed_for_editing', - isAllowed, + const isAllowed = isBlockAllowedForEditing( blockName, parentBlockNames, - governanceRules + governanceRules, + isParentLocked ); + useBlockEditingMode( isAllowed ? undefined : 'disabled' ); + setBlockLocked( clientId, ! isAllowed ); + if ( isAllowed ) { return ; - } else { - // Only available on WP 6.4 and above, so this guards against that. - if ( wp?.blockEditor?.useBlockEditingMode ) { - const { useBlockEditingMode } = wp.blockEditor; - useBlockEditingMode( 'disabled' ); - } - - // Mark block as locked so that children can detect they're within an existing locked block - setBlockLocked( clientId ); - - return ( - <> - -
- -
-
- - ); } + + return ( + +
+ +
+
+ ); }; }, 'withDisabledBlocks' ); @@ -82,21 +72,59 @@ export function setupBlockLocking( governanceRules ) { } /** - * In-memory map of block clientIds that have been marked as locked. + * Determine whether a block should remain editable. + * + * Children inherit a locked parent's editing mode, so they do not need another + * disabled wrapper or another invocation of the public editing filter. + * + * @param {string} blockName Current block name. + * @param {string[]} parentBlockNames Parent names, nearest parent first. + * @param {Object} governanceRules Effective governance rules. + * @param {boolean} isParentLocked Whether an ancestor is already locked. + * @return {boolean} Whether the block should remain editable. + */ +export function isBlockAllowedForEditing( + blockName, + parentBlockNames, + governanceRules, + isParentLocked +) { + if ( isParentLocked ) { + return true; + } + + const isAllowed = isBlockAllowedInHierarchy( blockName, parentBlockNames, governanceRules ); + + return applyFilters( + 'vip_governance__is_block_allowed_for_editing', + isAllowed, + blockName, + parentBlockNames, + governanceRules + ); +} + +/** + * In-memory set of block clientIds that have been marked as locked. * * This replaces using props.setAttributes() to set lock status, as this caused an * "unsaved changes" warning to appear in the editor when block locking was in use. */ -const lockedBlockMap = {}; +const lockedBlockIds = new Set(); /** - * Marks a block as locked via the block's clientId. + * Updates whether a block is locked via the block's clientId. * * @param {string} clientId Block clientId in editor + * @param {boolean} isLocked Whether the block is locked. * @returns {void} */ -function setBlockLocked( clientId ) { - lockedBlockMap[ clientId ] = true; +function setBlockLocked( clientId, isLocked ) { + if ( isLocked ) { + lockedBlockIds.add( clientId ); + } else { + lockedBlockIds.delete( clientId ); + } } /** @@ -106,5 +134,5 @@ function setBlockLocked( clientId ) { * @returns {boolean} */ function isBlockLocked( clientId ) { - return clientId in lockedBlockMap; + return lockedBlockIds.has( clientId ); } diff --git a/src/block-locking.test.js b/src/block-locking.test.js new file mode 100644 index 0000000..efd97c0 --- /dev/null +++ b/src/block-locking.test.js @@ -0,0 +1,62 @@ +import { applyFilters } from '@wordpress/hooks'; + +import { isBlockAllowedForEditing } from './block-locking'; +import { isBlockAllowedInHierarchy } from './block-utils'; + +jest.mock( '@wordpress/block-editor', () => ( { + store: 'core/block-editor', + useBlockEditingMode: jest.fn(), +} ) ); + +jest.mock( '@wordpress/components', () => ( { + Disabled: jest.fn(), +} ) ); + +jest.mock( '@wordpress/compose', () => ( { + createHigherOrderComponent: jest.fn(), +} ) ); + +jest.mock( '@wordpress/data', () => ( { + select: jest.fn(), +} ) ); + +jest.mock( '@wordpress/hooks', () => ( { + addFilter: jest.fn(), + applyFilters: jest.fn(), +} ) ); + +jest.mock( './block-utils', () => ( { + isBlockAllowedInHierarchy: jest.fn(), +} ) ); + +describe( 'isBlockAllowedForEditing', () => { + const governanceRules = { allowedBlocks: [ 'core/paragraph' ] }; + + beforeEach( () => { + jest.clearAllMocks(); + } ); + + it( 'applies the public editing filter to the hierarchy decision', () => { + isBlockAllowedInHierarchy.mockReturnValue( false ); + applyFilters.mockReturnValue( true ); + + expect( + isBlockAllowedForEditing( 'core/heading', [ 'core/group' ], governanceRules, false ) + ).toBe( true ); + expect( applyFilters ).toHaveBeenCalledWith( + 'vip_governance__is_block_allowed_for_editing', + false, + 'core/heading', + [ 'core/group' ], + governanceRules + ); + } ); + + it( 'allows a child to inherit an existing parent lock without reevaluating it', () => { + expect( + isBlockAllowedForEditing( 'core/heading', [ 'core/group' ], governanceRules, true ) + ).toBe( true ); + expect( isBlockAllowedInHierarchy ).not.toHaveBeenCalled(); + expect( applyFilters ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/src/block-utils.js b/src/block-utils.js index d4fcc55..b1a2a9a 100644 --- a/src/block-utils.js +++ b/src/block-utils.js @@ -65,7 +65,7 @@ export function isBlockAllowedInHierarchy( blockName, parentBlockNames, governan if ( governanceRules.blockSettings ) { // Get the child block's parent block settings at whatever depth its located at. const nestedSetting = getNestedSetting( - parentBlockNames.reverse(), + [ ...parentBlockNames ].reverse(), 'allowedBlocks', governanceRules.blockSettings ); @@ -106,8 +106,10 @@ export function isBlockAllowedByBlockWildcards( blockName, rules ) { */ export function doesBlockNameMatchBlockWildcard( blockName, rule ) { if ( rule.includes( '*' ) ) { + const escapedRule = rule.replace( /[.+?^${}()|[\]\\]/g, '\\$&' ).replaceAll( '*', '.*' ); + // The rule is escaped above before it is compiled into an anchored expression. // eslint-disable-next-line security/detect-non-literal-regexp - return blockName.match( new RegExp( rule.replace( '*', '.*' ) ) ); + return new RegExp( `^${ escapedRule }$` ).test( blockName ); } return rule === blockName; diff --git a/src/block-utils.test.js b/src/block-utils.test.js index 92dd5d2..dfdeadd 100644 --- a/src/block-utils.test.js +++ b/src/block-utils.test.js @@ -49,6 +49,7 @@ describe( 'blockUtils', () => { const result = isBlockAllowedInHierarchy( blockName, parentBlockNames, governanceRules ); expect( result ).toBe( true ); + expect( parentBlockNames ).toEqual( [ 'core/media-text' ] ); } ); it( 'should return false if the child block is not allowed in the hierarchy', () => { @@ -286,6 +287,17 @@ describe( 'blockUtils', () => { expect( result ).toBeFalsy(); } ); + it( 'should only match namespace wildcards at the start of the block name', () => { + expect( doesBlockNameMatchBlockWildcard( 'core/heading', 'core/*' ) ).toBe( true ); + expect( doesBlockNameMatchBlockWildcard( 'custom/core/heading', 'core/*' ) ).toBe( false ); + } ); + + it( 'should support wildcards in other positions without treating text as a regex', () => { + expect( doesBlockNameMatchBlockWildcard( 'core/heading', 'core/head*' ) ).toBe( true ); + expect( doesBlockNameMatchBlockWildcard( 'core/heading', '*heading' ) ).toBe( true ); + expect( doesBlockNameMatchBlockWildcard( 'core/heading', 'core/.+' ) ).toBe( false ); + } ); + it( 'should return true if the block name matches any of the rules', () => { const blockName = 'core/heading'; const rules = 'core/heading'; diff --git a/src/index.js b/src/index.js index 5e0aef3..0d82bd2 100644 --- a/src/index.js +++ b/src/index.js @@ -5,8 +5,8 @@ import { __ } from '@wordpress/i18n'; import { store as noticeStore } from '@wordpress/notices'; import { setupBlockLocking } from './block-locking'; -import { doesBlockNameMatchBlockWildcard, isBlockAllowedInHierarchy } from './block-utils'; -import { getNestedSetting, getNestedSettingPaths } from './nested-governance-loader'; +import { isBlockAllowedInHierarchy } from './block-utils'; +import { createNestedSettingPathMaps, resolveNestedSetting } from './nested-settings-filter'; function setup() { if ( VIP_GOVERNANCE.error ) { @@ -15,7 +15,7 @@ function setup() { isDismissible: true, actions: [ { - label: __( 'Open governance settings' ), + label: __( 'Open governance settings', 'vip-governance' ), url: VIP_GOVERNANCE.urlSettingsPage, }, ], @@ -25,6 +25,7 @@ function setup() { } const governanceRules = VIP_GOVERNANCE.governanceRules; + const { getBlockParents, getBlockName } = select( blockEditorStore ); addFilter( 'blockEditor.__unstableCanInsertBlockType', @@ -38,13 +39,13 @@ function setup() { if ( rootClientId ) { // This block has parents. Build a list of parentBlockNames - const { getBlockParents, getBlockName } = select( blockEditorStore ); const parentBlock = getBlock( rootClientId ); const ancestorClientIds = getBlockParents( rootClientId, true ); - parentBlockNames = [ parentBlock.clientId, ...ancestorClientIds ].map( parentClientId => - getBlockName( parentClientId ) - ); + parentBlockNames = [ + parentBlock?.name ?? getBlockName( rootClientId ), + ...ancestorClientIds.map( getBlockName ), + ]; } const isAllowed = isBlockAllowedInHierarchy( @@ -74,76 +75,27 @@ function setup() { ); const nestedSettings = VIP_GOVERNANCE.nestedSettings; - const nestedSettingPaths = getNestedSettingPaths( nestedSettings ); - - const nestedWildcardPaths = {}; - const nestedNonWildcardPaths = {}; - - for ( const blockName in nestedSettingPaths ) { - if ( blockName.indexOf( '*' ) === -1 ) { - // eslint-disable-next-line security/detect-object-injection - nestedNonWildcardPaths[ blockName ] = nestedSettingPaths[ blockName ]; - } else { - // eslint-disable-next-line security/detect-object-injection - nestedWildcardPaths[ blockName ] = nestedSettingPaths[ blockName ]; - } - } + const { exactPaths, wildcardPaths } = createNestedSettingPathMaps( nestedSettings ); addFilter( 'blockEditor.useSetting.before', `wpcomvip-governance/nested-block-settings`, - ( result, path, clientId, blockName ) => { + ( defaultValue, path, clientId, blockName ) => { if ( ! blockName ) { - return result; - } - - // Test if the blockName is in the nestedNonWildcardPaths. - if ( - // eslint-disable-next-line security/detect-object-injection - nestedNonWildcardPaths[ blockName ] !== undefined && - // eslint-disable-next-line security/detect-object-injection - nestedNonWildcardPaths[ blockName ][ path ] === true - ) { - const blockNamePath = [ - clientId, - ...select( blockEditorStore ).getBlockParents( clientId, /* ascending */ true ), - ] - .map( candidateId => select( blockEditorStore ).getBlockName( candidateId ) ) - .reverse(); - ( { value: result } = getNestedSetting( blockNamePath, path, nestedSettings ) ); - - // This is necessary because the nestedSettingPaths are flattened, so a child's path could match the parent's path. - return result && result.theme ? result.theme : result; - // Test if the blockName is in the nestedWildcardPaths. - } else if ( nestedWildcardPaths.length !== 0 ) { - for ( const nestedBlockName in nestedWildcardPaths ) { - if ( - doesBlockNameMatchBlockWildcard( blockName, nestedBlockName ) && - // eslint-disable-next-line security/detect-object-injection - nestedWildcardPaths[ nestedBlockName ][ path ] === true - ) { - const blockNamePath = [ - clientId, - ...select( blockEditorStore ).getBlockParents( clientId, /* ascending */ true ), - ] - .map( candidateId => select( blockEditorStore ).getBlockName( candidateId ) ) - .reverse(); - - // Replace the original block name with the matched wildcard block name, for easier lookup. - // This will be at the end of the blockNamePath array. - if ( nestedBlockName.indexOf( '*' ) !== -1 ) { - blockNamePath[ blockNamePath.length - 1 ] = nestedBlockName; - } - - ( { value: result } = getNestedSetting( blockNamePath, path, nestedSettings ) ); - - // This is necessary because the nestedSettingPaths are flattened, so a child's path could match the parent's path. - return result && result.theme ? result.theme : result; - } - } + return defaultValue; } - return result; + return resolveNestedSetting( { + defaultValue, + path, + clientId, + blockName, + nestedSettings, + exactPaths, + wildcardPaths, + getBlockParents, + getBlockName, + } ); } ); diff --git a/src/nested-governance-loader.js b/src/nested-governance-loader.js index 5ce913e..7eba9db 100644 --- a/src/nested-governance-loader.js +++ b/src/nested-governance-loader.js @@ -18,15 +18,16 @@ export function getNestedSettingPaths( nestedSettings, nestedMetadata = {}, curr const isNestedBlock = settingKey.includes( '/' ) || settingKey === '*'; if ( isNestedBlock ) { - // This setting contains another block, look at the child for metadata - Object.entries( nestedSettings ).forEach( ( [ blockName, blockNestedSettings ] ) => { - if ( ! SETTINGS_TO_SKIP.includes( blockName ) ) { - getNestedSettingPaths( blockNestedSettings, nestedMetadata, blockName ); - } - } ); + getNestedSettingPaths( settingValue, nestedMetadata, settingKey ); } else if ( currentBlock !== false ) { // This is a leaf block, add setting paths to nestedMetadata - const settingPaths = flattenSettingPaths( settingValue, `${ settingKey }.` ); + const isObjectSetting = + typeof settingValue === 'object' && + settingValue !== null && + ! Array.isArray( settingValue ); + const settingPaths = isObjectSetting + ? flattenSettingPaths( settingValue, `${ settingKey }.` ) + : { [ settingKey ]: true }; // eslint-disable-next-line security/detect-object-injection nestedMetadata[ currentBlock ] = { @@ -70,6 +71,10 @@ export function getNestedSetting( result = { depth: 0, value: undefined }, depth = 1 ) { + if ( blockNamePath.length === 0 ) { + return result; + } + const [ currentBlockName, ...remainingBlockNames ] = blockNamePath; // eslint-disable-next-line security/detect-object-injection const blockSettings = settings[ currentBlockName ]; diff --git a/src/nested-governance-loader.test.js b/src/nested-governance-loader.test.js index 5d8f997..19353b7 100644 --- a/src/nested-governance-loader.test.js +++ b/src/nested-governance-loader.test.js @@ -15,6 +15,56 @@ describe( 'getNestedSettingPaths', () => { }, } ); } ); + + it( 'should collect wildcard setting paths', () => { + const result = getNestedSettingPaths( { + 'core/*': { + color: { text: false }, + }, + '*': { + typography: { customFontSize: false }, + }, + } ); + + expect( result ).toEqual( { + 'core/*': { 'color.text': true }, + '*': { 'typography.customFontSize': true }, + } ); + } ); + + it( 'should merge paths for the same block nested under different parents', () => { + const result = getNestedSettingPaths( { + 'core/group': { + 'core/paragraph': { color: { text: false } }, + }, + 'core/quote': { + 'core/paragraph': { typography: { dropCap: false } }, + }, + } ); + + expect( result ).toEqual( { + 'core/paragraph': { + 'color.text': true, + 'typography.dropCap': true, + }, + } ); + } ); + + it( 'should include top-level primitive and array setting paths', () => { + const result = getNestedSettingPaths( { + 'core/group': { + useRootPaddingAwareAlignments: true, + spacingUnits: [ 'px', 'rem' ], + }, + } ); + + expect( result ).toEqual( { + 'core/group': { + useRootPaddingAwareAlignments: true, + spacingUnits: true, + }, + } ); + } ); } ); describe( 'getNestedSetting', () => { @@ -53,6 +103,13 @@ describe( 'getNestedSettingPaths', () => { ], } ); } ); + + it( 'should return an empty result for an empty block path', () => { + expect( getNestedSetting( [], 'color.text', getTestNestedSettings() ) ).toEqual( { + depth: 0, + value: undefined, + } ); + } ); } ); } ); diff --git a/src/nested-settings-filter.js b/src/nested-settings-filter.js new file mode 100644 index 0000000..74ca9c8 --- /dev/null +++ b/src/nested-settings-filter.js @@ -0,0 +1,81 @@ +import { doesBlockNameMatchBlockWildcard } from './block-utils'; +import { getNestedSetting, getNestedSettingPaths } from './nested-governance-loader'; + +/** + * Build lookup maps for exact and wildcard block setting paths. + * + * @param {Object} nestedSettings Nested governance settings. + * @return {{ exactPaths: Map, wildcardPaths: Map }} Setting path lookup maps. + */ +export function createNestedSettingPathMaps( nestedSettings ) { + const exactPaths = new Map(); + const wildcardPaths = new Map(); + + for ( const [ blockName, paths ] of Object.entries( getNestedSettingPaths( nestedSettings ) ) ) { + const destination = blockName.includes( '*' ) ? wildcardPaths : exactPaths; + destination.set( blockName, paths ); + } + + return { exactPaths, wildcardPaths }; +} + +/** + * Return block names from the root ancestor through the current block. + * + * @param {string} clientId Current block client ID. + * @param {Function} getBlockParents Block editor parent selector. + * @param {Function} getBlockName Block editor name selector. + * @return {string[]} Block name path. + */ +export function getBlockNamePath( clientId, getBlockParents, getBlockName ) { + const ancestorIds = [ ...getBlockParents( clientId, true ) ].reverse(); + return [ ...ancestorIds, clientId ].map( getBlockName ); +} + +/** + * Resolve a governed setting for a block, preserving exact-rule precedence. + * + * @param {Object} options Resolution options. + * @param {*} options.defaultValue Original WordPress setting value. + * @param {string} options.path Setting path being requested. + * @param {string} options.clientId Current block client ID. + * @param {string} options.blockName Current block name. + * @param {Object} options.nestedSettings Nested governance settings. + * @param {Map} options.exactPaths Exact block path lookup. + * @param {Map} options.wildcardPaths Wildcard block path lookup. + * @param {Function} options.getBlockParents Block editor parent selector. + * @param {Function} options.getBlockName Block editor name selector. + * @return {*} Governed setting value or the original value when no rule applies. + */ +export function resolveNestedSetting( { + defaultValue, + path, + clientId, + blockName, + nestedSettings, + exactPaths, + wildcardPaths, + getBlockParents, + getBlockName, +} ) { + let matchedBlockName; + + if ( exactPaths.get( blockName )?.[ path ] === true ) { + matchedBlockName = blockName; + } else { + matchedBlockName = [ ...wildcardPaths.entries() ].find( + ( [ candidate, paths ] ) => + doesBlockNameMatchBlockWildcard( blockName, candidate ) && paths[ path ] === true + )?.[ 0 ]; + } + + if ( ! matchedBlockName ) { + return defaultValue; + } + + const blockNamePath = getBlockNamePath( clientId, getBlockParents, getBlockName ); + blockNamePath[ blockNamePath.length - 1 ] = matchedBlockName; + + const { value } = getNestedSetting( blockNamePath, path, nestedSettings ); + return value?.theme ?? value; +} diff --git a/src/nested-settings-filter.test.js b/src/nested-settings-filter.test.js new file mode 100644 index 0000000..baf19ca --- /dev/null +++ b/src/nested-settings-filter.test.js @@ -0,0 +1,120 @@ +import { createNestedSettingPathMaps, resolveNestedSetting } from './nested-settings-filter'; + +describe( 'nested settings filter', () => { + const namesById = { + root: 'core/group', + current: 'core/heading', + }; + const getBlockParents = jest.fn( () => [ 'root' ] ); + const getBlockName = jest.fn( id => namesById[ id ] ); + + it( 'prefers an exact block rule over a matching wildcard rule', () => { + const nestedSettings = { + 'core/*': { color: { text: false } }, + 'core/heading': { color: { text: true } }, + }; + const pathMaps = createNestedSettingPathMaps( nestedSettings ); + + expect( + resolveNestedSetting( { + defaultValue: 'original', + path: 'color.text', + clientId: 'current', + blockName: 'core/heading', + nestedSettings, + ...pathMaps, + getBlockParents, + getBlockName, + } ) + ).toBe( true ); + } ); + + it( 'applies a wildcard block setting when no exact rule exists', () => { + const nestedSettings = { + 'core/*': { color: { text: false } }, + }; + const pathMaps = createNestedSettingPathMaps( nestedSettings ); + + expect( + resolveNestedSetting( { + defaultValue: 'original', + path: 'color.text', + clientId: 'current', + blockName: 'core/heading', + nestedSettings, + ...pathMaps, + getBlockParents, + getBlockName, + } ) + ).toBe( false ); + } ); + + it( 'returns the original value when no governed path applies', () => { + const nestedSettings = { 'core/*': { color: { text: false } } }; + const pathMaps = createNestedSettingPathMaps( nestedSettings ); + + expect( + resolveNestedSetting( { + defaultValue: 'original', + path: 'typography.dropCap', + clientId: 'current', + blockName: 'core/heading', + nestedSettings, + ...pathMaps, + getBlockParents, + getBlockName, + } ) + ).toBe( 'original' ); + } ); + + it( 'uses the deepest matching setting in the current hierarchy', () => { + const nestedSettings = { + 'core/paragraph': { color: { text: true } }, + 'core/group': { + 'core/quote': { + 'core/paragraph': { color: { text: false } }, + }, + }, + }; + const pathMaps = createNestedSettingPathMaps( nestedSettings ); + const hierarchyNames = { + root: 'core/group', + parent: 'core/quote', + current: 'core/paragraph', + }; + + expect( + resolveNestedSetting( { + defaultValue: 'original', + path: 'color.text', + clientId: 'current', + blockName: 'core/paragraph', + nestedSettings, + ...pathMaps, + getBlockParents: () => [ 'parent', 'root' ], + getBlockName: id => hierarchyNames[ id ], + } ) + ).toBe( false ); + } ); + + it( 'unwraps the theme value expected by the WordPress settings API', () => { + const palette = [ { color: '#fff', name: 'White', slug: 'white' } ]; + const nestedSettings = { + 'core/heading': { color: { palette: { theme: palette } } }, + }; + const pathMaps = createNestedSettingPathMaps( nestedSettings ); + + expect( + resolveNestedSetting( { + defaultValue: 'original', + path: 'color.palette', + clientId: 'current', + blockName: 'core/heading', + nestedSettings, + ...pathMaps, + getBlockParents, + getBlockName, + } ) + ).toBe( palette ); + } ); +} ); diff --git a/src/settings/index.js b/src/settings/index.js new file mode 100644 index 0000000..59a7d35 --- /dev/null +++ b/src/settings/index.js @@ -0,0 +1,5 @@ +import apiFetch from '@wordpress/api-fetch'; + +import { initializeRulesViewer } from './rules-viewer'; + +initializeRulesViewer( { request: apiFetch } ); diff --git a/src/settings/rules-viewer.js b/src/settings/rules-viewer.js new file mode 100644 index 0000000..1eff76a --- /dev/null +++ b/src/settings/rules-viewer.js @@ -0,0 +1,101 @@ +const RULES_ENDPOINT = '/vip-governance/v1/rules'; + +/** + * Initialize the role and post type rules viewer on the plugin settings page. + * + * @param {Object} options Initialization options. + * @param {Document} [options.root] Document containing the settings page. + * @param {Function} options.request Function used to request combined rules. + * @return {void} + */ +export function initializeRulesViewer( { root = document, request } ) { + const roleSelector = root.getElementById( 'user-role-selector' ); + const postTypeSelector = root.getElementById( 'post-type-selector' ); + const viewButton = root.getElementById( 'view-rules-button' ); + const spinner = root.querySelector( '.vip-governance-query-spinner' ); + const output = root.getElementById( 'combined-governance-rules-json' ); + + if ( + ! roleSelector || + ! postTypeSelector || + ! viewButton || + ! spinner || + ! output || + typeof request !== 'function' + ) { + return; + } + + let latestRequest = 0; + + const getRequestData = () => { + const data = {}; + + if ( roleSelector.value ) { + data.role = roleSelector.value; + } + + if ( postTypeSelector.value ) { + data.postType = postTypeSelector.value; + } + + return data; + }; + + const updateControls = () => { + const hasSelection = Object.keys( getRequestData() ).length > 0; + viewButton.hidden = ! hasSelection; + + if ( ! hasSelection ) { + output.hidden = true; + } + }; + + const showOutput = value => { + output.textContent = value; + output.hidden = false; + }; + + const handleRequest = async () => { + const data = getRequestData(); + + if ( Object.keys( data ).length === 0 ) { + return; + } + + const requestId = ++latestRequest; + roleSelector.disabled = true; + postTypeSelector.disabled = true; + viewButton.disabled = true; + spinner.hidden = false; + spinner.classList.add( 'is-active' ); + + try { + const rules = await request( { + path: `${ RULES_ENDPOINT }?${ new URLSearchParams( data ) }`, + } ); + + if ( requestId === latestRequest ) { + showOutput( JSON.stringify( rules, null, 4 ) ); + } + } catch ( error ) { + if ( requestId === latestRequest ) { + showOutput( error?.message || output.dataset.errorMessage ); + } + } finally { + if ( requestId === latestRequest ) { + roleSelector.disabled = false; + postTypeSelector.disabled = false; + viewButton.disabled = false; + spinner.hidden = true; + spinner.classList.remove( 'is-active' ); + } + } + }; + + roleSelector.addEventListener( 'change', updateControls ); + postTypeSelector.addEventListener( 'change', updateControls ); + viewButton.addEventListener( 'click', handleRequest ); + + updateControls(); +} diff --git a/src/settings/rules-viewer.test.js b/src/settings/rules-viewer.test.js new file mode 100644 index 0000000..eeea6fd --- /dev/null +++ b/src/settings/rules-viewer.test.js @@ -0,0 +1,101 @@ +import { initializeRulesViewer } from './rules-viewer'; + +const SETTINGS_MARKUP = ` + + + + + +`; + +describe( 'initializeRulesViewer', () => { + beforeEach( () => { + document.body.innerHTML = SETTINGS_MARKUP; + } ); + + it( 'only shows the action when at least one filter is selected', () => { + initializeRulesViewer( { request: jest.fn() } ); + + const roleSelector = document.getElementById( 'user-role-selector' ); + const viewButton = document.getElementById( 'view-rules-button' ); + + expect( viewButton.hidden ).toBe( true ); + + roleSelector.value = 'editor'; + roleSelector.dispatchEvent( new Event( 'change' ) ); + + expect( viewButton.hidden ).toBe( false ); + } ); + + it( 'requests and displays rules for both selected filters', async () => { + const request = jest.fn().mockResolvedValue( { allowedBlocks: [ 'core/paragraph' ] } ); + initializeRulesViewer( { request } ); + + document.getElementById( 'user-role-selector' ).value = 'editor'; + document.getElementById( 'post-type-selector' ).value = 'page'; + document.getElementById( 'view-rules-button' ).click(); + + await Promise.resolve(); + await Promise.resolve(); + + expect( request ).toHaveBeenCalledWith( { + path: '/vip-governance/v1/rules?role=editor&postType=page', + } ); + expect( document.getElementById( 'combined-governance-rules-json' ).textContent ).toBe( + JSON.stringify( { allowedBlocks: [ 'core/paragraph' ] }, null, 4 ) + ); + } ); + + it( 'restores the controls and displays a useful request error', async () => { + const request = jest.fn().mockRejectedValue( new Error( 'Request failed' ) ); + initializeRulesViewer( { request } ); + + const roleSelector = document.getElementById( 'user-role-selector' ); + const viewButton = document.getElementById( 'view-rules-button' ); + const spinner = document.querySelector( '.vip-governance-query-spinner' ); + + roleSelector.value = 'editor'; + viewButton.click(); + + expect( viewButton.disabled ).toBe( true ); + expect( roleSelector.disabled ).toBe( true ); + expect( spinner.hidden ).toBe( false ); + + await Promise.resolve(); + await Promise.resolve(); + + expect( document.getElementById( 'combined-governance-rules-json' ).textContent ).toBe( + 'Request failed' + ); + expect( viewButton.disabled ).toBe( false ); + expect( roleSelector.disabled ).toBe( false ); + expect( spinner.hidden ).toBe( true ); + } ); + + it( 'uses the localized fallback when a request has no error message', async () => { + const request = jest.fn().mockRejectedValue( {} ); + initializeRulesViewer( { request } ); + + document.getElementById( 'user-role-selector' ).value = 'editor'; + document.getElementById( 'view-rules-button' ).click(); + + await Promise.resolve(); + await Promise.resolve(); + + expect( document.getElementById( 'combined-governance-rules-json' ).textContent ).toBe( + 'Unable to load governance rules.' + ); + } ); + + it( 'does nothing when the settings markup is not present', () => { + document.body.innerHTML = ''; + + expect( () => initializeRulesViewer( { request: jest.fn() } ) ).not.toThrow(); + } ); +} ); diff --git a/tests/e2e/settings-page.spec.js b/tests/e2e/settings-page.spec.js new file mode 100644 index 0000000..183f73a --- /dev/null +++ b/tests/e2e/settings-page.spec.js @@ -0,0 +1,25 @@ +import { expect, test } from '@wordpress/e2e-test-utils-playwright'; + +test.describe( 'Governance settings page', () => { + test( 'loads combined rules for the selected role and post type', async ( { page } ) => { + await page.goto( '/wp-admin/admin.php?page=vip-block-governance' ); + + const roleSelector = page.getByLabel( 'User role' ); + const postTypeSelector = page.getByLabel( 'Post type' ); + const viewButton = page.getByRole( 'button', { name: 'View Rules' } ); + const output = page.locator( '#combined-governance-rules-json' ); + + await expect( viewButton ).toBeHidden(); + await roleSelector.selectOption( 'administrator' ); + await postTypeSelector.selectOption( 'post' ); + await expect( viewButton ).toBeVisible(); + + await viewButton.click(); + + await expect( output ).toBeVisible(); + await expect( output ).toContainText( '"allowedBlocks"' ); + await expect( roleSelector ).toBeEnabled(); + await expect( postTypeSelector ).toBeEnabled(); + await expect( viewButton ).toBeEnabled(); + } ); +} ); diff --git a/webpack.config.js b/webpack.config.js index a7c6e00..272ea9a 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,7 +1,9 @@ const defaultScriptsConfig = require( '@wordpress/scripts/config/webpack.config' ); -// This is the default webpack config used by `@wordpress/scripts` -// It's been exported so we can extend it in our custom webpack config -// to add our own customizations like HMR, etc. - -module.exports = [ defaultScriptsConfig ]; +module.exports = { + ...defaultScriptsConfig, + entry: { + index: './src/index.js', + settings: './src/settings/index.js', + }, +}; From 6214877db127407729be947260a4e8a229f22233 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Thu, 20 Aug 2026 15:53:13 +1000 Subject: [PATCH 2/4] Tweak based on a review --- .gitignore | 4 +- build/index.asset.php | 2 +- build/index.js | 2 +- build/settings.asset.php | 2 +- build/settings.js | 2 +- build/settings.js.map | 2 +- package-lock.json | 1 + package.json | 1 + src/block-locking.jsx | 69 +++------- src/block-locking.test.js | 5 +- src/index.js | 9 +- src/nested-settings-filter.js | 154 +++++++++++++++++------ src/nested-settings-filter.test.js | 195 +++++++++++++++++------------ src/settings/rules-viewer.js | 3 + src/settings/rules-viewer.test.js | 13 ++ 15 files changed, 286 insertions(+), 178 deletions(-) diff --git a/.gitignore b/.gitignore index 50e45af..72040e6 100644 --- a/.gitignore +++ b/.gitignore @@ -36,5 +36,5 @@ vendor/* /playwright/.cache/ /artifacts -# Map file which is generated by webpack -build/index.js.map \ No newline at end of file +# Development-only source maps generated by webpack. +build/*.js.map diff --git a/build/index.asset.php b/build/index.asset.php index 8103f3c..23f99cd 100644 --- a/build/index.asset.php +++ b/build/index.asset.php @@ -1 +1 @@ - array('react-jsx-runtime', 'wp-block-editor', 'wp-components', 'wp-compose', 'wp-data', 'wp-hooks', 'wp-i18n', 'wp-notices'), 'version' => 'b2ccea7aa069a0dcba25'); + array('react-jsx-runtime', 'wp-block-editor', 'wp-components', 'wp-compose', 'wp-data', 'wp-element', 'wp-hooks', 'wp-i18n', 'wp-notices'), 'version' => '02eaa8c5b6bf20b86b84'); diff --git a/build/index.js b/build/index.js index 16e425c..6d0089d 100644 --- a/build/index.js +++ b/build/index.js @@ -1,2 +1,2 @@ -(()=>{"use strict";var e={122(e,t,n){n.d(t,{G:()=>u});var o=n(715),r=n(427),i=n(491),c=n(143),s=n(619),a=n(418),l=n(790);const d={opacity:.6,backgroundColor:"#eee",border:"2px dashed #999"};function u(e){const t=(0,i.createHigherOrderComponent)(t=>function(n){const{name:i,clientId:u}=n,{getBlockParents:g,getBlockName:f}=(0,c.select)(o.store),v=g(u,!0),w=v.some(e=>function(e){return p.has(e)}(e)),m=function(e,t,n,o){if(o)return!0;const r=(0,a.yw)(e,t,n);return(0,s.applyFilters)("vip_governance__is_block_allowed_for_editing",r,e,t,n)}(i,v.map(e=>f(e)),e,w);return(0,o.useBlockEditingMode)(m?void 0:"disabled"),function(e,t){t?p.add(e):p.delete(e)}(u,!m),m?(0,l.jsx)(t,{...n}):(0,l.jsx)(r.Disabled,{children:(0,l.jsx)("div",{style:d,children:(0,l.jsx)(t,{...n})})})},"withDisabledBlocks");(0,s.addFilter)("editor.BlockEdit","wpcomvip-governance/with-disabled-blocks",t)}const p=new Set},418(e,t,n){n.d(t,{aV:()=>s,yw:()=>c});var o=n(619),r=n(325);const i={"core/list":["core/list-item"],"core/columns":["core/column"],"core/page-list":["core/page-list-item"],"core/navigation":["core/navigation-link","core/navigation-submenu"],"core/navigation-link":["core/navigation-link","core/navigation-submenu","core/page-list"],"core/quote":["core/paragraph"],"core/media-text":["core/paragraph"],"core/social-links":["core/social-link"],"core/comments-pagination":["core/comments-pagination-previous","core/comments-pagination-numbers","core/comments-pagination-next"]};function c(e,t,n){const c=(0,o.applyFilters)("vip_governance__is_block_allowed_in_hierarchy",!0,e,t,n)||0===t.length?[...n.allowedBlocks]:[];if(t.length>0){if(i[t[0]]&&i[t[0]].includes(e))return!0;if(n.blockSettings){const e=(0,r.W)([...t].reverse(),"allowedBlocks",n.blockSettings);e&&e.value&&c.push(...e.value)}}return function(e,t){return t.some(t=>s(e,t))}(e,c)}function s(e,t){if(t.includes("*")){const n=t.replace(/[.+?^${}()|[\]\\]/g,"\\$&").replaceAll("*",".*");return new RegExp(`^${n}$`).test(e)}return t===e}},325(e,t,n){function o(e,t={},n=!1){const r=["allowedBlocks"];for(const[i,s]of Object.entries(e))if(!r.includes(i))if(i.includes("/")||"*"===i)o(s,t,i);else if(!1!==n){const e="object"!=typeof s||null===s||Array.isArray(s)?{[i]:!0}:c(s,`${i}.`);t[n]={...t[n]??{},...e}}return t}function r(e,t,n,o={depth:0,value:void 0},c=1){if(0===e.length)return o;const[s,...a]=e,l=n[s];if(0===a.length){const e=i(l,t);return void 0!==e&&c>=o.depth&&(o.depth=c,o.value=e),o}return void 0!==l&&(o=r(a,t,l,o,c+1)),r(a,t,n,o,c)}function i(e,t,n=void 0){const o=Array.isArray(t)?t:t.replace(/(\[(\d)\])/g,".$2").replace(/^\./,"").split(".");if(!o.length||void 0===o[0])return e;const r=o[0];return"object"==typeof e&&null!==e&&r in e&&void 0!==e[r]?i(e[r],o.slice(1),n):n}function c(e,t=""){const n={};return Object.entries(e).forEach(([e,o])=>{"object"==typeof o&&Boolean(o)&&!Array.isArray(o)?(n[`${t}${e}`]=!0,Object.assign(n,c(o,`${t}${e}.`))):n[`${t}${e}`]=!0}),n}n.d(t,{S:()=>o,W:()=>r})},975(e,t,n){n.d(t,{ED:()=>c,YW:()=>i});var o=n(418),r=n(325);function i(e){const t=new Map,n=new Map;for(const[o,i]of Object.entries((0,r.S)(e)))(o.includes("*")?n:t).set(o,i);return{exactPaths:t,wildcardPaths:n}}function c({defaultValue:e,path:t,clientId:n,blockName:i,nestedSettings:c,exactPaths:s,wildcardPaths:a,getBlockParents:l,getBlockName:d}){let u;if(u=!0===s.get(i)?.[t]?i:[...a.entries()].find(([e,n])=>(0,o.aV)(i,e)&&!0===n[t])?.[0],!u)return e;const p=function(e,t,n){return[...[...t(e,!0)].reverse(),e].map(n)}(n,l,d);p[p.length-1]=u;const{value:g}=(0,r.W)(p,t,c);return g?.theme??g}},790(e){e.exports=window.ReactJSXRuntime},715(e){e.exports=window.wp.blockEditor},427(e){e.exports=window.wp.components},491(e){e.exports=window.wp.compose},143(e){e.exports=window.wp.data},619(e){e.exports=window.wp.hooks},723(e){e.exports=window.wp.i18n},692(e){e.exports=window.wp.notices}};const t={};function n(o){const r=t[o];if(void 0!==r)return r.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,n),i.exports}n.n=e=>{const t=e&&e.__esModule?()=>e.default:()=>e;return n.d(t,{a:t}),t},n.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oObject.hasOwn(e,t);var o=n(715),r=n(143),i=n(619),c=n(723),s=n(692),a=n(122),l=n(418),d=n(975);!function(){if(VIP_GOVERNANCE.error)return void(0,r.dispatch)(s.store).createErrorNotice(VIP_GOVERNANCE.error,{id:"wpcomvip-governance-error",isDismissible:!0,actions:[{label:(0,c.__)("Open governance settings","vip-governance"),url:VIP_GOVERNANCE.urlSettingsPage}]});const e=VIP_GOVERNANCE.governanceRules,{getBlockParents:t,getBlockName:n}=(0,r.select)(o.store);(0,i.addFilter)("blockEditor.__unstableCanInsertBlockType","wpcomvip-governance/block-insertion",(o,r,c,{getBlock:s})=>{if(!1===o)return o;let a=[];if(c){const e=s(c),o=t(c,!0);a=[e?.name??n(c),...o.map(n)]}const d=(0,l.yw)(r.name,a,e);return(0,i.applyFilters)("vip_governance__is_block_allowed_for_insertion",d,r.name,a,e)});const u=VIP_GOVERNANCE.nestedSettings,{exactPaths:p,wildcardPaths:g}=(0,d.YW)(u);(0,i.addFilter)("blockEditor.useSetting.before","wpcomvip-governance/nested-block-settings",(e,o,r,i)=>i?(0,d.ED)({defaultValue:e,path:o,clientId:r,blockName:i,nestedSettings:u,exactPaths:p,wildcardPaths:g,getBlockParents:t,getBlockName:n}):e),e?.allowedBlocks&&(0,a.G)(e)}()})(); +(()=>{"use strict";var e={122(e,t,n){n.d(t,{G:()=>g});var o=n(715),r=n(427),i=n(491),c=n(143),s=n(87),a=n(619),l=n(418),d=n(790);const u={opacity:.6,backgroundColor:"#eee",border:"2px dashed #999"},p=(0,s.createContext)(!1);function g(e){const t=(0,i.createHigherOrderComponent)(t=>function(n){const{name:i,clientId:g}=n,v=(0,s.useContext)(p),{getBlockParents:f,getBlockName:h}=(0,c.select)(o.store),w=function(e,t,n,o){if(o)return!0;const r=(0,l.yw)(e,t,n);return(0,a.applyFilters)("vip_governance__is_block_allowed_for_editing",r,e,t,n)}(i,f(g,!0).map(h),e,v);return(0,o.useBlockEditingMode)(w?void 0:"disabled"),w?(0,d.jsx)(p.Provider,{value:v,children:(0,d.jsx)(t,{...n})}):(0,d.jsx)(p.Provider,{value:!0,children:(0,d.jsx)(r.Disabled,{children:(0,d.jsx)("div",{style:u,children:(0,d.jsx)(t,{...n})})})})},"withDisabledBlocks");(0,a.addFilter)("editor.BlockEdit","wpcomvip-governance/with-disabled-blocks",t)}},418(e,t,n){n.d(t,{aV:()=>s,yw:()=>c});var o=n(619),r=n(325);const i={"core/list":["core/list-item"],"core/columns":["core/column"],"core/page-list":["core/page-list-item"],"core/navigation":["core/navigation-link","core/navigation-submenu"],"core/navigation-link":["core/navigation-link","core/navigation-submenu","core/page-list"],"core/quote":["core/paragraph"],"core/media-text":["core/paragraph"],"core/social-links":["core/social-link"],"core/comments-pagination":["core/comments-pagination-previous","core/comments-pagination-numbers","core/comments-pagination-next"]};function c(e,t,n){const c=(0,o.applyFilters)("vip_governance__is_block_allowed_in_hierarchy",!0,e,t,n)||0===t.length?[...n.allowedBlocks]:[];if(t.length>0){if(i[t[0]]&&i[t[0]].includes(e))return!0;if(n.blockSettings){const e=(0,r.W)([...t].reverse(),"allowedBlocks",n.blockSettings);e&&e.value&&c.push(...e.value)}}return function(e,t){return t.some(t=>s(e,t))}(e,c)}function s(e,t){if(t.includes("*")){const n=t.replace(/[.+?^${}()|[\]\\]/g,"\\$&").replaceAll("*",".*");return new RegExp(`^${n}$`).test(e)}return t===e}},325(e,t,n){function o(e,t,n,i={depth:0,value:void 0},c=1){if(0===e.length)return i;const[s,...a]=e,l=n[s];if(0===a.length){const e=r(l,t);return void 0!==e&&c>=i.depth&&(i.depth=c,i.value=e),i}return void 0!==l&&(i=o(a,t,l,i,c+1)),o(a,t,n,i,c)}function r(e,t,n=void 0){const o=Array.isArray(t)?t:t.replace(/(\[(\d)\])/g,".$2").replace(/^\./,"").split(".");if(!o.length||void 0===o[0])return e;const i=o[0];return"object"==typeof e&&null!==e&&i in e&&void 0!==e[i]?r(e[i],o.slice(1),n):n}n.d(t,{W:()=>o})},975(e,t,n){n.d(t,{ED:()=>i,Tr:()=>r});var o=n(418);function r(e){const t=new Map;let n=0;const o=(e,r,i)=>{if(((e,o,r)=>{const i=t.get(e)??[];i.push({blockPatterns:r,value:o,depth:r.length,exactMatches:r.filter(e=>!e.includes("*")).length,order:n++}),t.set(e,i)})(r,e,i),"object"==typeof e&&null!==e&&!Array.isArray(e))for(const[t,n]of Object.entries(e))o(n,`${r}.${t}`,i)},r=(e,t=[])=>{for(const[n,i]of Object.entries(e))"allowedBlocks"!==n&&(n.includes("/")||"*"===n?r(i,[...t,n]):t.length>0&&o(i,n,t))};return r(e),t}function i({defaultValue:e,path:t,clientId:n,rulesByPath:r,getBlockParents:i,getBlockName:c}){const s=function(e,t,n){return[...[...t(e,!0)].reverse(),e].map(n)}(n,i,c),a=(r.get(t)??[]).filter(e=>function(e,t){if(0===e.length||0===t.length)return!1;let n=e.length-1,r=t.length-1;if(!(0,o.aV)(t[r],e[n]))return!1;for(n--,r--;n>=0;){for(;r>=0&&!(0,o.aV)(t[r],e[n]);)r--;if(r<0)return!1;n--,r--}return!0}(e.blockPatterns,s));if(0===a.length)return e;const l=a.reduce((e,t)=>t.depth!==e.depth?t.depth>e.depth?t:e:t.exactMatches!==e.exactMatches?t.exactMatches>e.exactMatches?t:e:t.order>e.order?t:e);return l.value?.theme??l.value}},790(e){e.exports=window.ReactJSXRuntime},715(e){e.exports=window.wp.blockEditor},427(e){e.exports=window.wp.components},491(e){e.exports=window.wp.compose},143(e){e.exports=window.wp.data},87(e){e.exports=window.wp.element},619(e){e.exports=window.wp.hooks},723(e){e.exports=window.wp.i18n},692(e){e.exports=window.wp.notices}};const t={};function n(o){const r=t[o];if(void 0!==r)return r.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,n),i.exports}n.n=e=>{const t=e&&e.__esModule?()=>e.default:()=>e;return n.d(t,{a:t}),t},n.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oObject.hasOwn(e,t);var o=n(715),r=n(143),i=n(619),c=n(723),s=n(692),a=n(122),l=n(418),d=n(975);!function(){if(VIP_GOVERNANCE.error)return void(0,r.dispatch)(s.store).createErrorNotice(VIP_GOVERNANCE.error,{id:"wpcomvip-governance-error",isDismissible:!0,actions:[{label:(0,c.__)("Open governance settings","vip-governance"),url:VIP_GOVERNANCE.urlSettingsPage}]});const e=VIP_GOVERNANCE.governanceRules,{getBlockParents:t,getBlockName:n}=(0,r.select)(o.store);(0,i.addFilter)("blockEditor.__unstableCanInsertBlockType","wpcomvip-governance/block-insertion",(o,r,c,{getBlock:s})=>{if(!1===o)return o;let a=[];if(c){const e=s(c),o=t(c,!0);a=[e?.name??n(c),...o.map(n)]}const d=(0,l.yw)(r.name,a,e);return(0,i.applyFilters)("vip_governance__is_block_allowed_for_insertion",d,r.name,a,e)});const u=VIP_GOVERNANCE.nestedSettings,p=(0,d.Tr)(u);(0,i.addFilter)("blockEditor.useSetting.before","wpcomvip-governance/nested-block-settings",(e,o,r,i)=>i?(0,d.ED)({defaultValue:e,path:o,clientId:r,rulesByPath:p,getBlockParents:t,getBlockName:n}):e),e?.allowedBlocks&&(0,a.G)(e)}()})(); //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/build/settings.asset.php b/build/settings.asset.php index fe5ab7a..6769137 100644 --- a/build/settings.asset.php +++ b/build/settings.asset.php @@ -1 +1 @@ - array('wp-api-fetch'), 'version' => '718fa688c8a548b1f421'); + array('wp-api-fetch'), 'version' => '8ae7c6c54099bfdbf201'); diff --git a/build/settings.js b/build/settings.js index c3923cc..6ad081c 100644 --- a/build/settings.js +++ b/build/settings.js @@ -1,2 +1,2 @@ -(()=>{"use strict";var e={761(e,t,n){function r({root:e=document,request:t}){const n=e.getElementById("user-role-selector"),r=e.getElementById("post-type-selector"),s=e.getElementById("view-rules-button"),a=e.querySelector(".vip-governance-query-spinner"),o=e.getElementById("combined-governance-rules-json");if(!(n&&r&&s&&a&&o&&"function"==typeof t))return;let d=0;const i=()=>{const e={};return n.value&&(e.role=n.value),r.value&&(e.postType=r.value),e},c=()=>{const e=Object.keys(i()).length>0;s.hidden=!e,e||(o.hidden=!0)},l=e=>{o.textContent=e,o.hidden=!1};n.addEventListener("change",c),r.addEventListener("change",c),s.addEventListener("click",async()=>{const e=i();if(0===Object.keys(e).length)return;const c=++d;n.disabled=!0,r.disabled=!0,s.disabled=!0,a.hidden=!1,a.classList.add("is-active");try{const n=await t({path:`/vip-governance/v1/rules?${new URLSearchParams(e)}`});c===d&&l(JSON.stringify(n,null,4))}catch(e){c===d&&l(e?.message||o.dataset.errorMessage)}finally{c===d&&(n.disabled=!1,r.disabled=!1,s.disabled=!1,a.hidden=!0,a.classList.remove("is-active"))}}),c()}n.d(t,{S:()=>r})},455(e){e.exports=window.wp.apiFetch}};const t={};function n(r){const s=t[r];if(void 0!==s)return s.exports;const a=t[r]={exports:{}};return e[r](a,a.exports,n),a.exports}n.n=e=>{const t=e&&e.__esModule?()=>e.default:()=>e;return n.d(t,{a:t}),t},n.d=(e,t)=>{if(Array.isArray(t))for(var r=0;rObject.hasOwn(e,t);var r=n(455),s=n.n(r);(0,n(761).S)({request:s()})})(); +(()=>{"use strict";var e={761(e,t,n){function r({root:e=document,request:t}){const n=e.getElementById("user-role-selector"),r=e.getElementById("post-type-selector"),s=e.getElementById("view-rules-button"),a=e.querySelector(".vip-governance-query-spinner"),o=e.getElementById("combined-governance-rules-json");if(!(n&&r&&s&&a&&o&&"function"==typeof t))return;let d=0;const i=()=>{const e={};return n.value&&(e.role=n.value),r.value&&(e.postType=r.value),e},c=()=>{const e=Object.keys(i()).length>0;s.hidden=!e,e||(o.hidden=!0)},l=e=>{o.textContent=e,o.hidden=!1};n.addEventListener("change",c),r.addEventListener("change",c),s.addEventListener("click",async()=>{const e=i();if(0===Object.keys(e).length)return;const c=++d;n.disabled=!0,r.disabled=!0,s.disabled=!0,a.hidden=!1,a.classList.add("is-active");try{const n=await t({path:`/vip-governance/v1/rules?${new URLSearchParams(e)}`});c===d&&l(JSON.stringify(n,null,4))}catch(e){c===d&&l(e?.message||o.dataset.errorMessage)}finally{c===d&&(n.disabled=!1,r.disabled=!1,s.disabled=!1,a.hidden=!0,a.classList.remove("is-active"))}}),n.value="",r.value="",c()}n.d(t,{S:()=>r})},455(e){e.exports=window.wp.apiFetch}};const t={};function n(r){const s=t[r];if(void 0!==s)return s.exports;const a=t[r]={exports:{}};return e[r](a,a.exports,n),a.exports}n.n=e=>{const t=e&&e.__esModule?()=>e.default:()=>e;return n.d(t,{a:t}),t},n.d=(e,t)=>{if(Array.isArray(t))for(var r=0;rObject.hasOwn(e,t);var r=n(455),s=n.n(r);(0,n(761).S)({request:s()})})(); //# sourceMappingURL=settings.js.map \ No newline at end of file diff --git a/build/settings.js.map b/build/settings.js.map index d0cc029..391cb62 100644 --- a/build/settings.js.map +++ b/build/settings.js.map @@ -1 +1 @@ -{"version":3,"file":"settings.js","mappings":"qCAUO,SAASA,GAAuBC,KAAEA,EAAOC,SAAQC,QAAEA,IACzD,MAAMC,EAAeH,EAAKI,eAAgB,sBACpCC,EAAmBL,EAAKI,eAAgB,sBACxCE,EAAaN,EAAKI,eAAgB,qBAClCG,EAAUP,EAAKQ,cAAe,iCAC9BC,EAAST,EAAKI,eAAgB,kCAEpC,KACGD,GACAE,GACAC,GACAC,GACAE,GACiB,mBAAZP,GAEP,OAGD,IAAIQ,EAAgB,EAEpB,MAAMC,EAAiBA,KACtB,MAAMC,EAAO,CAAC,EAUd,OARKT,EAAaU,QACjBD,EAAKE,KAAOX,EAAaU,OAGrBR,EAAiBQ,QACrBD,EAAKG,SAAWV,EAAiBQ,OAG3BD,GAGFI,EAAiBA,KACtB,MAAMC,EAAeC,OAAOC,KAAMR,KAAmBS,OAAS,EAC9Dd,EAAWe,QAAWJ,EAEfA,IACNR,EAAOY,QAAS,IAIZC,EAAaT,IAClBJ,EAAOc,YAAcV,EACrBJ,EAAOY,QAAS,GAwCjBlB,EAAaqB,iBAAkB,SAAUR,GACzCX,EAAiBmB,iBAAkB,SAAUR,GAC7CV,EAAWkB,iBAAkB,QAvCPC,UACrB,MAAMb,EAAOD,IAEb,GAAoC,IAA/BO,OAAOC,KAAMP,GAAOQ,OACxB,OAGD,MAAMM,IAAchB,EACpBP,EAAawB,UAAW,EACxBtB,EAAiBsB,UAAW,EAC5BrB,EAAWqB,UAAW,EACtBpB,EAAQc,QAAS,EACjBd,EAAQqB,UAAUC,IAAK,aAEvB,IACC,MAAMC,QAAc5B,EAAS,CAC5B6B,KAAM,4BAAwB,IAAIC,gBAAiBpB,OAG/Cc,IAAchB,GAClBY,EAAYW,KAAKC,UAAWJ,EAAO,KAAM,GAE3C,CAAE,MAAQK,GACJT,IAAchB,GAClBY,EAAYa,GAAOC,SAAW3B,EAAO4B,QAAQC,aAE/C,CAAC,QACKZ,IAAchB,IAClBP,EAAawB,UAAW,EACxBtB,EAAiBsB,UAAW,EAC5BrB,EAAWqB,UAAW,EACtBpB,EAAQc,QAAS,EACjBd,EAAQqB,UAAUW,OAAQ,aAE5B,IAODvB,GACD,0BCpGAwB,EAAAC,QAAAC,OAAA,cCCA,MAAAC,EAAA,GAGA,SAAAC,EAAAC,GAEA,MAAAC,EAAAH,EAAAE,GACA,QAAAE,IAAAD,EACA,OAAAA,EAAAL,QAGA,MAAAD,EAAAG,EAAAE,GAAA,CAGAJ,QAAA,IAOA,OAHAO,EAAAH,GAAAL,EAAAA,EAAAC,QAAAG,GAGAJ,EAAAC,OACA,CCrBAG,EAAAK,EAAAT,IACA,MAAAU,EAAAV,GAAAA,EAAAW,WACA,IAAAX,EAAA,QACA,MAEA,OADAI,EAAAQ,EAAAF,EAAA,CAAiCG,EAAAH,IACjCA,GCLAN,EAAAQ,EAAA,CAAAX,EAAAa,KACA,GAAAC,MAAAC,QAAAF,GAEA,IADA,IAAAG,EAAA,EACAA,EAAAH,EAAAlC,QAAA,CACA,IAAAsC,EAAAJ,EAAAG,KACAE,EAAAL,EAAAG,KACAb,EAAAgB,EAAAnB,EAAAiB,GAMK,IAAAC,GAAyBF,IAL9B,IAAAE,EACAzC,OAAA2C,eAAApB,EAAAiB,EAAA,CAA2CI,YAAA,EAAAjD,MAAAyC,EAAAG,OAE3CvC,OAAA2C,eAAApB,EAAAiB,EAAA,CAA2CI,YAAA,EAAAC,IAAAJ,GAG3C,MAEA,QAAAD,KAAAJ,EACAV,EAAAgB,EAAAN,EAAAI,KAAAd,EAAAgB,EAAAnB,EAAAiB,IACAxC,OAAA2C,eAAApB,EAAAiB,EAAA,CAA0CI,YAAA,EAAAC,IAAAT,EAAAI,MClB1Cd,EAAAgB,EAAA,CAAAI,EAAAC,IAAA/C,OAAAgD,OAAAF,EAAAC,0BCIAlE,SAAAA,GAAuB,CAAEG,QAASiE","sources":["webpack://vip-governance/./src/settings/rules-viewer.js","webpack://vip-governance/external window [\"wp\",\"apiFetch\"]","webpack://vip-governance/webpack/bootstrap","webpack://vip-governance/webpack/runtime/compat get default export","webpack://vip-governance/webpack/runtime/define property getters","webpack://vip-governance/webpack/runtime/hasOwnProperty shorthand","webpack://vip-governance/./src/settings/index.js"],"sourcesContent":["const RULES_ENDPOINT = '/vip-governance/v1/rules';\n\n/**\n * Initialize the role and post type rules viewer on the plugin settings page.\n *\n * @param {Object} options Initialization options.\n * @param {Document} [options.root] Document containing the settings page.\n * @param {Function} options.request Function used to request combined rules.\n * @return {void}\n */\nexport function initializeRulesViewer( { root = document, request } ) {\n\tconst roleSelector = root.getElementById( 'user-role-selector' );\n\tconst postTypeSelector = root.getElementById( 'post-type-selector' );\n\tconst viewButton = root.getElementById( 'view-rules-button' );\n\tconst spinner = root.querySelector( '.vip-governance-query-spinner' );\n\tconst output = root.getElementById( 'combined-governance-rules-json' );\n\n\tif (\n\t\t! roleSelector ||\n\t\t! postTypeSelector ||\n\t\t! viewButton ||\n\t\t! spinner ||\n\t\t! output ||\n\t\ttypeof request !== 'function'\n\t) {\n\t\treturn;\n\t}\n\n\tlet latestRequest = 0;\n\n\tconst getRequestData = () => {\n\t\tconst data = {};\n\n\t\tif ( roleSelector.value ) {\n\t\t\tdata.role = roleSelector.value;\n\t\t}\n\n\t\tif ( postTypeSelector.value ) {\n\t\t\tdata.postType = postTypeSelector.value;\n\t\t}\n\n\t\treturn data;\n\t};\n\n\tconst updateControls = () => {\n\t\tconst hasSelection = Object.keys( getRequestData() ).length > 0;\n\t\tviewButton.hidden = ! hasSelection;\n\n\t\tif ( ! hasSelection ) {\n\t\t\toutput.hidden = true;\n\t\t}\n\t};\n\n\tconst showOutput = value => {\n\t\toutput.textContent = value;\n\t\toutput.hidden = false;\n\t};\n\n\tconst handleRequest = async () => {\n\t\tconst data = getRequestData();\n\n\t\tif ( Object.keys( data ).length === 0 ) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst requestId = ++latestRequest;\n\t\troleSelector.disabled = true;\n\t\tpostTypeSelector.disabled = true;\n\t\tviewButton.disabled = true;\n\t\tspinner.hidden = false;\n\t\tspinner.classList.add( 'is-active' );\n\n\t\ttry {\n\t\t\tconst rules = await request( {\n\t\t\t\tpath: `${ RULES_ENDPOINT }?${ new URLSearchParams( data ) }`,\n\t\t\t} );\n\n\t\t\tif ( requestId === latestRequest ) {\n\t\t\t\tshowOutput( JSON.stringify( rules, null, 4 ) );\n\t\t\t}\n\t\t} catch ( error ) {\n\t\t\tif ( requestId === latestRequest ) {\n\t\t\t\tshowOutput( error?.message || output.dataset.errorMessage );\n\t\t\t}\n\t\t} finally {\n\t\t\tif ( requestId === latestRequest ) {\n\t\t\t\troleSelector.disabled = false;\n\t\t\t\tpostTypeSelector.disabled = false;\n\t\t\t\tviewButton.disabled = false;\n\t\t\t\tspinner.hidden = true;\n\t\t\t\tspinner.classList.remove( 'is-active' );\n\t\t\t}\n\t\t}\n\t};\n\n\troleSelector.addEventListener( 'change', updateControls );\n\tpostTypeSelector.addEventListener( 'change', updateControls );\n\tviewButton.addEventListener( 'click', handleRequest );\n\n\tupdateControls();\n}\n","module.exports = window[\"wp\"][\"apiFetch\"];","// The module cache\nconst __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tconst cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tconst module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId](module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","// getDefaultExport function for compatibility with non-harmony modules\n__webpack_require__.n = (module) => {\n\tconst getter = module && module.__esModule ?\n\t\t() => (module['default']) :\n\t\t() => (module);\n\t__webpack_require__.d(getter, { a: getter });\n\treturn getter;\n};","// define getter/value functions for harmony exports\n__webpack_require__.d = (exports, definition) => {\n\tif(Array.isArray(definition)) {\n\t\tvar i = 0;\n\t\twhile(i < definition.length) {\n\t\t\tvar key = definition[i++];\n\t\t\tvar binding = definition[i++];\n\t\t\tif(!__webpack_require__.o(exports, key)) {\n\t\t\t\tif(binding === 0) {\n\t\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, value: definition[i++] });\n\t\t\t\t} else {\n\t\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: binding });\n\t\t\t\t}\n\t\t\t} else if(binding === 0) { i++; }\n\t\t}\n\t} else {\n\t\tfor(var key in definition) {\n\t\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t\t}\n\t\t}\n\t}\n};","__webpack_require__.o = (obj, prop) => (Object.hasOwn(obj, prop))","import apiFetch from '@wordpress/api-fetch';\n\nimport { initializeRulesViewer } from './rules-viewer';\n\ninitializeRulesViewer( { request: apiFetch } );\n"],"names":["initializeRulesViewer","root","document","request","roleSelector","getElementById","postTypeSelector","viewButton","spinner","querySelector","output","latestRequest","getRequestData","data","value","role","postType","updateControls","hasSelection","Object","keys","length","hidden","showOutput","textContent","addEventListener","async","requestId","disabled","classList","add","rules","path","URLSearchParams","JSON","stringify","error","message","dataset","errorMessage","remove","module","exports","window","__webpack_module_cache__","__webpack_require__","moduleId","cachedModule","undefined","__webpack_modules__","n","getter","__esModule","d","a","definition","Array","isArray","i","key","binding","o","defineProperty","enumerable","get","obj","prop","hasOwn","apiFetch"],"sourceRoot":""} \ No newline at end of file +{"version":3,"file":"settings.js","mappings":"qCAUO,SAASA,GAAuBC,KAAEA,EAAOC,SAAQC,QAAEA,IACzD,MAAMC,EAAeH,EAAKI,eAAgB,sBACpCC,EAAmBL,EAAKI,eAAgB,sBACxCE,EAAaN,EAAKI,eAAgB,qBAClCG,EAAUP,EAAKQ,cAAe,iCAC9BC,EAAST,EAAKI,eAAgB,kCAEpC,KACGD,GACAE,GACAC,GACAC,GACAE,GACiB,mBAAZP,GAEP,OAGD,IAAIQ,EAAgB,EAEpB,MAAMC,EAAiBA,KACtB,MAAMC,EAAO,CAAC,EAUd,OARKT,EAAaU,QACjBD,EAAKE,KAAOX,EAAaU,OAGrBR,EAAiBQ,QACrBD,EAAKG,SAAWV,EAAiBQ,OAG3BD,GAGFI,EAAiBA,KACtB,MAAMC,EAAeC,OAAOC,KAAMR,KAAmBS,OAAS,EAC9Dd,EAAWe,QAAWJ,EAEfA,IACNR,EAAOY,QAAS,IAIZC,EAAaT,IAClBJ,EAAOc,YAAcV,EACrBJ,EAAOY,QAAS,GAwCjBlB,EAAaqB,iBAAkB,SAAUR,GACzCX,EAAiBmB,iBAAkB,SAAUR,GAC7CV,EAAWkB,iBAAkB,QAvCPC,UACrB,MAAMb,EAAOD,IAEb,GAAoC,IAA/BO,OAAOC,KAAMP,GAAOQ,OACxB,OAGD,MAAMM,IAAchB,EACpBP,EAAawB,UAAW,EACxBtB,EAAiBsB,UAAW,EAC5BrB,EAAWqB,UAAW,EACtBpB,EAAQc,QAAS,EACjBd,EAAQqB,UAAUC,IAAK,aAEvB,IACC,MAAMC,QAAc5B,EAAS,CAC5B6B,KAAM,4BAAwB,IAAIC,gBAAiBpB,OAG/Cc,IAAchB,GAClBY,EAAYW,KAAKC,UAAWJ,EAAO,KAAM,GAE3C,CAAE,MAAQK,GACJT,IAAchB,GAClBY,EAAYa,GAAOC,SAAW3B,EAAO4B,QAAQC,aAE/C,CAAC,QACKZ,IAAchB,IAClBP,EAAawB,UAAW,EACxBtB,EAAiBsB,UAAW,EAC5BrB,EAAWqB,UAAW,EACtBpB,EAAQc,QAAS,EACjBd,EAAQqB,UAAUW,OAAQ,aAE5B,IAQDpC,EAAaU,MAAQ,GACrBR,EAAiBQ,MAAQ,GACzBG,GACD,0BCvGAwB,EAAAC,QAAAC,OAAA,cCCA,MAAAC,EAAA,GAGA,SAAAC,EAAAC,GAEA,MAAAC,EAAAH,EAAAE,GACA,QAAAE,IAAAD,EACA,OAAAA,EAAAL,QAGA,MAAAD,EAAAG,EAAAE,GAAA,CAGAJ,QAAA,IAOA,OAHAO,EAAAH,GAAAL,EAAAA,EAAAC,QAAAG,GAGAJ,EAAAC,OACA,CCrBAG,EAAAK,EAAAT,IACA,MAAAU,EAAAV,GAAAA,EAAAW,WACA,IAAAX,EAAA,QACA,MAEA,OADAI,EAAAQ,EAAAF,EAAA,CAAiCG,EAAAH,IACjCA,GCLAN,EAAAQ,EAAA,CAAAX,EAAAa,KACA,GAAAC,MAAAC,QAAAF,GAEA,IADA,IAAAG,EAAA,EACAA,EAAAH,EAAAlC,QAAA,CACA,IAAAsC,EAAAJ,EAAAG,KACAE,EAAAL,EAAAG,KACAb,EAAAgB,EAAAnB,EAAAiB,GAMK,IAAAC,GAAyBF,IAL9B,IAAAE,EACAzC,OAAA2C,eAAApB,EAAAiB,EAAA,CAA2CI,YAAA,EAAAjD,MAAAyC,EAAAG,OAE3CvC,OAAA2C,eAAApB,EAAAiB,EAAA,CAA2CI,YAAA,EAAAC,IAAAJ,GAG3C,MAEA,QAAAD,KAAAJ,EACAV,EAAAgB,EAAAN,EAAAI,KAAAd,EAAAgB,EAAAnB,EAAAiB,IACAxC,OAAA2C,eAAApB,EAAAiB,EAAA,CAA0CI,YAAA,EAAAC,IAAAT,EAAAI,MClB1Cd,EAAAgB,EAAA,CAAAI,EAAAC,IAAA/C,OAAAgD,OAAAF,EAAAC,0BCIAlE,SAAAA,GAAuB,CAAEG,QAASiE","sources":["webpack://vip-governance/./src/settings/rules-viewer.js","webpack://vip-governance/external window [\"wp\",\"apiFetch\"]","webpack://vip-governance/webpack/bootstrap","webpack://vip-governance/webpack/runtime/compat get default export","webpack://vip-governance/webpack/runtime/define property getters","webpack://vip-governance/webpack/runtime/hasOwnProperty shorthand","webpack://vip-governance/./src/settings/index.js"],"sourcesContent":["const RULES_ENDPOINT = '/vip-governance/v1/rules';\n\n/**\n * Initialize the role and post type rules viewer on the plugin settings page.\n *\n * @param {Object} options Initialization options.\n * @param {Document} [options.root] Document containing the settings page.\n * @param {Function} options.request Function used to request combined rules.\n * @return {void}\n */\nexport function initializeRulesViewer( { root = document, request } ) {\n\tconst roleSelector = root.getElementById( 'user-role-selector' );\n\tconst postTypeSelector = root.getElementById( 'post-type-selector' );\n\tconst viewButton = root.getElementById( 'view-rules-button' );\n\tconst spinner = root.querySelector( '.vip-governance-query-spinner' );\n\tconst output = root.getElementById( 'combined-governance-rules-json' );\n\n\tif (\n\t\t! roleSelector ||\n\t\t! postTypeSelector ||\n\t\t! viewButton ||\n\t\t! spinner ||\n\t\t! output ||\n\t\ttypeof request !== 'function'\n\t) {\n\t\treturn;\n\t}\n\n\tlet latestRequest = 0;\n\n\tconst getRequestData = () => {\n\t\tconst data = {};\n\n\t\tif ( roleSelector.value ) {\n\t\t\tdata.role = roleSelector.value;\n\t\t}\n\n\t\tif ( postTypeSelector.value ) {\n\t\t\tdata.postType = postTypeSelector.value;\n\t\t}\n\n\t\treturn data;\n\t};\n\n\tconst updateControls = () => {\n\t\tconst hasSelection = Object.keys( getRequestData() ).length > 0;\n\t\tviewButton.hidden = ! hasSelection;\n\n\t\tif ( ! hasSelection ) {\n\t\t\toutput.hidden = true;\n\t\t}\n\t};\n\n\tconst showOutput = value => {\n\t\toutput.textContent = value;\n\t\toutput.hidden = false;\n\t};\n\n\tconst handleRequest = async () => {\n\t\tconst data = getRequestData();\n\n\t\tif ( Object.keys( data ).length === 0 ) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst requestId = ++latestRequest;\n\t\troleSelector.disabled = true;\n\t\tpostTypeSelector.disabled = true;\n\t\tviewButton.disabled = true;\n\t\tspinner.hidden = false;\n\t\tspinner.classList.add( 'is-active' );\n\n\t\ttry {\n\t\t\tconst rules = await request( {\n\t\t\t\tpath: `${ RULES_ENDPOINT }?${ new URLSearchParams( data ) }`,\n\t\t\t} );\n\n\t\t\tif ( requestId === latestRequest ) {\n\t\t\t\tshowOutput( JSON.stringify( rules, null, 4 ) );\n\t\t\t}\n\t\t} catch ( error ) {\n\t\t\tif ( requestId === latestRequest ) {\n\t\t\t\tshowOutput( error?.message || output.dataset.errorMessage );\n\t\t\t}\n\t\t} finally {\n\t\t\tif ( requestId === latestRequest ) {\n\t\t\t\troleSelector.disabled = false;\n\t\t\t\tpostTypeSelector.disabled = false;\n\t\t\t\tviewButton.disabled = false;\n\t\t\t\tspinner.hidden = true;\n\t\t\t\tspinner.classList.remove( 'is-active' );\n\t\t\t}\n\t\t}\n\t};\n\n\troleSelector.addEventListener( 'change', updateControls );\n\tpostTypeSelector.addEventListener( 'change', updateControls );\n\tviewButton.addEventListener( 'click', handleRequest );\n\n\t// Preserve the original settings-page behavior on reload and browser restore.\n\troleSelector.value = '';\n\tpostTypeSelector.value = '';\n\tupdateControls();\n}\n","module.exports = window[\"wp\"][\"apiFetch\"];","// The module cache\nconst __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tconst cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tconst module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId](module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","// getDefaultExport function for compatibility with non-harmony modules\n__webpack_require__.n = (module) => {\n\tconst getter = module && module.__esModule ?\n\t\t() => (module['default']) :\n\t\t() => (module);\n\t__webpack_require__.d(getter, { a: getter });\n\treturn getter;\n};","// define getter/value functions for harmony exports\n__webpack_require__.d = (exports, definition) => {\n\tif(Array.isArray(definition)) {\n\t\tvar i = 0;\n\t\twhile(i < definition.length) {\n\t\t\tvar key = definition[i++];\n\t\t\tvar binding = definition[i++];\n\t\t\tif(!__webpack_require__.o(exports, key)) {\n\t\t\t\tif(binding === 0) {\n\t\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, value: definition[i++] });\n\t\t\t\t} else {\n\t\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: binding });\n\t\t\t\t}\n\t\t\t} else if(binding === 0) { i++; }\n\t\t}\n\t} else {\n\t\tfor(var key in definition) {\n\t\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t\t}\n\t\t}\n\t}\n};","__webpack_require__.o = (obj, prop) => (Object.hasOwn(obj, prop))","import apiFetch from '@wordpress/api-fetch';\n\nimport { initializeRulesViewer } from './rules-viewer';\n\ninitializeRulesViewer( { request: apiFetch } );\n"],"names":["initializeRulesViewer","root","document","request","roleSelector","getElementById","postTypeSelector","viewButton","spinner","querySelector","output","latestRequest","getRequestData","data","value","role","postType","updateControls","hasSelection","Object","keys","length","hidden","showOutput","textContent","addEventListener","async","requestId","disabled","classList","add","rules","path","URLSearchParams","JSON","stringify","error","message","dataset","errorMessage","remove","module","exports","window","__webpack_module_cache__","__webpack_require__","moduleId","cachedModule","undefined","__webpack_modules__","n","getter","__esModule","d","a","definition","Array","isArray","i","key","binding","o","defineProperty","enumerable","get","obj","prop","hasOwn","apiFetch"],"sourceRoot":""} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c917f7f..6bc5300 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@wordpress/components": "^38.0.0", "@wordpress/compose": "^8.5.0", "@wordpress/data": "^10.52.0", + "@wordpress/element": "^8.4.0", "@wordpress/e2e-test-utils-playwright": "^1.52.0", "@wordpress/env": "^11.12.0", "@wordpress/hooks": "^4.52.0", diff --git a/package.json b/package.json index c067a2d..a28556f 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@wordpress/components": "^38.0.0", "@wordpress/compose": "^8.5.0", "@wordpress/data": "^10.52.0", + "@wordpress/element": "^8.4.0", "@wordpress/e2e-test-utils-playwright": "^1.52.0", "@wordpress/env": "^11.12.0", "@wordpress/hooks": "^4.52.0", diff --git a/src/block-locking.jsx b/src/block-locking.jsx index 850d6fa..6fd12fa 100644 --- a/src/block-locking.jsx +++ b/src/block-locking.jsx @@ -5,6 +5,7 @@ import { store as blockEditorStore, useBlockEditingMode } from '@wordpress/block import { Disabled } from '@wordpress/components'; import { createHigherOrderComponent } from '@wordpress/compose'; import { select } from '@wordpress/data'; +import { createContext, useContext } from '@wordpress/element'; import { addFilter, applyFilters } from '@wordpress/hooks'; /** @@ -18,21 +19,15 @@ const LOCKED_BLOCK_STYLES = { border: '2px dashed #999', }; +const GovernanceLockContext = createContext( false ); + export function setupBlockLocking( governanceRules ) { const withDisabledBlocks = createHigherOrderComponent( BlockEdit => { return function GovernedBlockEdit( props ) { const { name: blockName, clientId } = props; - + const isParentLocked = useContext( GovernanceLockContext ); const { getBlockParents, getBlockName } = select( blockEditorStore ); - const parentClientIds = getBlockParents( clientId, true ); - - const isParentLocked = parentClientIds.some( parentClientId => - isBlockLocked( parentClientId ) - ); - - const parentBlockNames = parentClientIds.map( parentClientId => - getBlockName( parentClientId ) - ); + const parentBlockNames = getBlockParents( clientId, true ).map( getBlockName ); /** * Change what blocks are allowed to be edited in the block editor. @@ -52,18 +47,23 @@ export function setupBlockLocking( governanceRules ) { ); useBlockEditingMode( isAllowed ? undefined : 'disabled' ); - setBlockLocked( clientId, ! isAllowed ); if ( isAllowed ) { - return ; + return ( + + + + ); } return ( - -
- -
-
+ + +
+ +
+
+
); }; }, 'withDisabledBlocks' ); @@ -80,7 +80,7 @@ export function setupBlockLocking( governanceRules ) { * @param {string} blockName Current block name. * @param {string[]} parentBlockNames Parent names, nearest parent first. * @param {Object} governanceRules Effective governance rules. - * @param {boolean} isParentLocked Whether an ancestor is already locked. + * @param {boolean} isParentLocked Whether governance locked an ancestor. * @return {boolean} Whether the block should remain editable. */ export function isBlockAllowedForEditing( @@ -103,36 +103,3 @@ export function isBlockAllowedForEditing( governanceRules ); } - -/** - * In-memory set of block clientIds that have been marked as locked. - * - * This replaces using props.setAttributes() to set lock status, as this caused an - * "unsaved changes" warning to appear in the editor when block locking was in use. - */ -const lockedBlockIds = new Set(); - -/** - * Updates whether a block is locked via the block's clientId. - * - * @param {string} clientId Block clientId in editor - * @param {boolean} isLocked Whether the block is locked. - * @returns {void} - */ -function setBlockLocked( clientId, isLocked ) { - if ( isLocked ) { - lockedBlockIds.add( clientId ); - } else { - lockedBlockIds.delete( clientId ); - } -} - -/** - * Returns true if a block has previously been marked as locked, false otherwise. - * - * @param {string} clientId Block clientId in editor - * @returns {boolean} - */ -function isBlockLocked( clientId ) { - return lockedBlockIds.has( clientId ); -} diff --git a/src/block-locking.test.js b/src/block-locking.test.js index efd97c0..fb355bb 100644 --- a/src/block-locking.test.js +++ b/src/block-locking.test.js @@ -16,8 +16,9 @@ jest.mock( '@wordpress/compose', () => ( { createHigherOrderComponent: jest.fn(), } ) ); -jest.mock( '@wordpress/data', () => ( { - select: jest.fn(), +jest.mock( '@wordpress/element', () => ( { + createContext: jest.fn( () => ( { Provider: jest.fn() } ) ), + useContext: jest.fn(), } ) ); jest.mock( '@wordpress/hooks', () => ( { diff --git a/src/index.js b/src/index.js index 0d82bd2..f1ebef5 100644 --- a/src/index.js +++ b/src/index.js @@ -6,7 +6,7 @@ import { store as noticeStore } from '@wordpress/notices'; import { setupBlockLocking } from './block-locking'; import { isBlockAllowedInHierarchy } from './block-utils'; -import { createNestedSettingPathMaps, resolveNestedSetting } from './nested-settings-filter'; +import { createNestedSettingRules, resolveNestedSetting } from './nested-settings-filter'; function setup() { if ( VIP_GOVERNANCE.error ) { @@ -75,7 +75,7 @@ function setup() { ); const nestedSettings = VIP_GOVERNANCE.nestedSettings; - const { exactPaths, wildcardPaths } = createNestedSettingPathMaps( nestedSettings ); + const rulesByPath = createNestedSettingRules( nestedSettings ); addFilter( 'blockEditor.useSetting.before', @@ -89,10 +89,7 @@ function setup() { defaultValue, path, clientId, - blockName, - nestedSettings, - exactPaths, - wildcardPaths, + rulesByPath, getBlockParents, getBlockName, } ); diff --git a/src/nested-settings-filter.js b/src/nested-settings-filter.js index 74ca9c8..44467ed 100644 --- a/src/nested-settings-filter.js +++ b/src/nested-settings-filter.js @@ -1,22 +1,61 @@ import { doesBlockNameMatchBlockWildcard } from './block-utils'; -import { getNestedSetting, getNestedSettingPaths } from './nested-governance-loader'; + +const ALLOWED_BLOCKS_KEY = 'allowedBlocks'; /** - * Build lookup maps for exact and wildcard block setting paths. + * Compile nested governance settings into candidates keyed by setting path. + * + * Each candidate retains its complete block hierarchy so wildcard ancestors + * can be matched alongside exact block names at runtime. * * @param {Object} nestedSettings Nested governance settings. - * @return {{ exactPaths: Map, wildcardPaths: Map }} Setting path lookup maps. + * @return {Map} Setting candidates keyed by normalized path. */ -export function createNestedSettingPathMaps( nestedSettings ) { - const exactPaths = new Map(); - const wildcardPaths = new Map(); +export function createNestedSettingRules( nestedSettings ) { + const rulesByPath = new Map(); + let declarationOrder = 0; - for ( const [ blockName, paths ] of Object.entries( getNestedSettingPaths( nestedSettings ) ) ) { - const destination = blockName.includes( '*' ) ? wildcardPaths : exactPaths; - destination.set( blockName, paths ); - } + const addSetting = ( path, value, blockPatterns ) => { + const candidates = rulesByPath.get( path ) ?? []; + candidates.push( { + blockPatterns, + value, + depth: blockPatterns.length, + exactMatches: blockPatterns.filter( pattern => ! pattern.includes( '*' ) ).length, + order: declarationOrder++, + } ); + rulesByPath.set( path, candidates ); + }; + + const visitSetting = ( value, path, blockPatterns ) => { + addSetting( path, value, blockPatterns ); + + if ( typeof value !== 'object' || value === null || Array.isArray( value ) ) { + return; + } + + for ( const [ key, childValue ] of Object.entries( value ) ) { + visitSetting( childValue, `${ path }.${ key }`, blockPatterns ); + } + }; + + const visitBlocks = ( settings, blockPatterns = [] ) => { + for ( const [ key, value ] of Object.entries( settings ) ) { + if ( key === ALLOWED_BLOCKS_KEY ) { + continue; + } - return { exactPaths, wildcardPaths }; + const isBlockPattern = key.includes( '/' ) || key === '*'; + if ( isBlockPattern ) { + visitBlocks( value, [ ...blockPatterns, key ] ); + } else if ( blockPatterns.length > 0 ) { + visitSetting( value, key, blockPatterns ); + } + } + }; + + visitBlocks( nestedSettings ); + return rulesByPath; } /** @@ -33,16 +72,62 @@ export function getBlockNamePath( clientId, getBlockParents, getBlockName ) { } /** - * Resolve a governed setting for a block, preserving exact-rule precedence. + * Determine whether a rule hierarchy matches a block hierarchy. + * + * The final rule must match the current block. Earlier rule segments can match + * any ancestor in order, preserving the existing descendant matching behavior. + * + * @param {string[]} blockPatterns Rule block hierarchy. + * @param {string[]} blockNames Actual block hierarchy. + * @return {boolean} Whether the rule applies to the current block. + */ +export function doesBlockHierarchyMatch( blockPatterns, blockNames ) { + if ( blockPatterns.length === 0 || blockNames.length === 0 ) { + return false; + } + + let patternIndex = blockPatterns.length - 1; + let blockIndex = blockNames.length - 1; + + if ( + ! doesBlockNameMatchBlockWildcard( blockNames[ blockIndex ], blockPatterns[ patternIndex ] ) + ) { + return false; + } + + patternIndex--; + blockIndex--; + + while ( patternIndex >= 0 ) { + while ( + blockIndex >= 0 && + ! doesBlockNameMatchBlockWildcard( blockNames[ blockIndex ], blockPatterns[ patternIndex ] ) + ) { + blockIndex--; + } + + if ( blockIndex < 0 ) { + return false; + } + + patternIndex--; + blockIndex--; + } + + return true; +} + +/** + * Resolve the most specific governed setting for a block. + * + * Deeper hierarchies win. At equal depth, exact block names win over + * wildcards. Equal-specificity rules retain declaration-order precedence. * * @param {Object} options Resolution options. * @param {*} options.defaultValue Original WordPress setting value. * @param {string} options.path Setting path being requested. * @param {string} options.clientId Current block client ID. - * @param {string} options.blockName Current block name. - * @param {Object} options.nestedSettings Nested governance settings. - * @param {Map} options.exactPaths Exact block path lookup. - * @param {Map} options.wildcardPaths Wildcard block path lookup. + * @param {Map} options.rulesByPath Compiled setting candidates. * @param {Function} options.getBlockParents Block editor parent selector. * @param {Function} options.getBlockName Block editor name selector. * @return {*} Governed setting value or the original value when no rule applies. @@ -51,31 +136,30 @@ export function resolveNestedSetting( { defaultValue, path, clientId, - blockName, - nestedSettings, - exactPaths, - wildcardPaths, + rulesByPath, getBlockParents, getBlockName, } ) { - let matchedBlockName; - - if ( exactPaths.get( blockName )?.[ path ] === true ) { - matchedBlockName = blockName; - } else { - matchedBlockName = [ ...wildcardPaths.entries() ].find( - ( [ candidate, paths ] ) => - doesBlockNameMatchBlockWildcard( blockName, candidate ) && paths[ path ] === true - )?.[ 0 ]; - } + const blockNames = getBlockNamePath( clientId, getBlockParents, getBlockName ); + const candidates = ( rulesByPath.get( path ) ?? [] ).filter( candidate => + doesBlockHierarchyMatch( candidate.blockPatterns, blockNames ) + ); - if ( ! matchedBlockName ) { + if ( candidates.length === 0 ) { return defaultValue; } - const blockNamePath = getBlockNamePath( clientId, getBlockParents, getBlockName ); - blockNamePath[ blockNamePath.length - 1 ] = matchedBlockName; + const winner = candidates.reduce( ( currentWinner, candidate ) => { + if ( candidate.depth !== currentWinner.depth ) { + return candidate.depth > currentWinner.depth ? candidate : currentWinner; + } + + if ( candidate.exactMatches !== currentWinner.exactMatches ) { + return candidate.exactMatches > currentWinner.exactMatches ? candidate : currentWinner; + } + + return candidate.order > currentWinner.order ? candidate : currentWinner; + } ); - const { value } = getNestedSetting( blockNamePath, path, nestedSettings ); - return value?.theme ?? value; + return winner.value?.theme ?? winner.value; } diff --git a/src/nested-settings-filter.test.js b/src/nested-settings-filter.test.js index baf19ca..0b7fb7e 100644 --- a/src/nested-settings-filter.test.js +++ b/src/nested-settings-filter.test.js @@ -1,82 +1,53 @@ -import { createNestedSettingPathMaps, resolveNestedSetting } from './nested-settings-filter'; +import { + createNestedSettingRules, + doesBlockHierarchyMatch, + resolveNestedSetting, +} from './nested-settings-filter'; describe( 'nested settings filter', () => { const namesById = { root: 'core/group', + parent: 'core/quote', current: 'core/heading', }; - const getBlockParents = jest.fn( () => [ 'root' ] ); const getBlockName = jest.fn( id => namesById[ id ] ); - it( 'prefers an exact block rule over a matching wildcard rule', () => { - const nestedSettings = { - 'core/*': { color: { text: false } }, - 'core/heading': { color: { text: true } }, - }; - const pathMaps = createNestedSettingPathMaps( nestedSettings ); + function resolve( nestedSettings, options = {} ) { + return resolveNestedSetting( { + defaultValue: 'original', + path: 'color.text', + clientId: 'current', + rulesByPath: createNestedSettingRules( nestedSettings ), + getBlockParents: () => [ 'root' ], + getBlockName, + ...options, + } ); + } + it( 'prefers an exact block rule over a wildcard at the same depth', () => { expect( - resolveNestedSetting( { - defaultValue: 'original', - path: 'color.text', - clientId: 'current', - blockName: 'core/heading', - nestedSettings, - ...pathMaps, - getBlockParents, - getBlockName, + resolve( { + 'core/*': { color: { text: false } }, + 'core/heading': { color: { text: true } }, } ) ).toBe( true ); } ); it( 'applies a wildcard block setting when no exact rule exists', () => { - const nestedSettings = { - 'core/*': { color: { text: false } }, - }; - const pathMaps = createNestedSettingPathMaps( nestedSettings ); - expect( - resolveNestedSetting( { - defaultValue: 'original', - path: 'color.text', - clientId: 'current', - blockName: 'core/heading', - nestedSettings, - ...pathMaps, - getBlockParents, - getBlockName, + resolve( { + 'core/*': { color: { text: false } }, } ) ).toBe( false ); } ); it( 'returns the original value when no governed path applies', () => { - const nestedSettings = { 'core/*': { color: { text: false } } }; - const pathMaps = createNestedSettingPathMaps( nestedSettings ); - expect( - resolveNestedSetting( { - defaultValue: 'original', - path: 'typography.dropCap', - clientId: 'current', - blockName: 'core/heading', - nestedSettings, - ...pathMaps, - getBlockParents, - getBlockName, - } ) + resolve( { 'core/*': { color: { text: false } } }, { path: 'typography.dropCap' } ) ).toBe( 'original' ); } ); - it( 'uses the deepest matching setting in the current hierarchy', () => { - const nestedSettings = { - 'core/paragraph': { color: { text: true } }, - 'core/group': { - 'core/quote': { - 'core/paragraph': { color: { text: false } }, - }, - }, - }; - const pathMaps = createNestedSettingPathMaps( nestedSettings ); + it( 'uses the deepest exact setting in the current hierarchy', () => { const hierarchyNames = { root: 'core/group', parent: 'core/quote', @@ -84,37 +55,107 @@ describe( 'nested settings filter', () => { }; expect( - resolveNestedSetting( { - defaultValue: 'original', - path: 'color.text', - clientId: 'current', - blockName: 'core/paragraph', - nestedSettings, - ...pathMaps, - getBlockParents: () => [ 'parent', 'root' ], - getBlockName: id => hierarchyNames[ id ], + resolve( + { + 'core/paragraph': { color: { text: true } }, + 'core/group': { + 'core/quote': { + 'core/paragraph': { color: { text: false } }, + }, + }, + }, + { + getBlockParents: () => [ 'parent', 'root' ], + getBlockName: id => hierarchyNames[ id ], + } + ) + ).toBe( false ); + } ); + + it( 'uses a deeper wildcard rule instead of a root exact rule', () => { + expect( + resolve( + { + 'core/heading': { color: { text: true } }, + 'core/quote': { + 'core/*': { color: { text: false } }, + }, + }, + { getBlockParents: () => [ 'parent' ] } + ) + ).toBe( false ); + } ); + + it( 'matches wildcard ancestors in a nested rule', () => { + const hierarchyNames = { + root: 'core/group', + current: 'core/paragraph', + }; + + expect( + resolve( + { + 'core/*': { + 'core/paragraph': { color: { text: false } }, + }, + }, + { getBlockName: id => hierarchyNames[ id ] } + ) + ).toBe( false ); + } ); + + it( 'uses the later declaration when depth and exactness are equal', () => { + expect( + resolve( { + '*': { color: { text: true } }, + 'core/*': { color: { text: false } }, } ) ).toBe( false ); } ); it( 'unwraps the theme value expected by the WordPress settings API', () => { const palette = [ { color: '#fff', name: 'White', slug: 'white' } ]; - const nestedSettings = { - 'core/heading': { color: { palette: { theme: palette } } }, - }; - const pathMaps = createNestedSettingPathMaps( nestedSettings ); expect( - resolveNestedSetting( { - defaultValue: 'original', - path: 'color.palette', - clientId: 'current', - blockName: 'core/heading', - nestedSettings, - ...pathMaps, - getBlockParents, - getBlockName, - } ) + resolve( + { + 'core/heading': { color: { palette: { theme: palette } } }, + }, + { path: 'color.palette' } + ) ).toBe( palette ); } ); + + it( 'applies a top-level primitive setting', () => { + expect( + resolve( + { + 'core/heading': { useRootPaddingAwareAlignments: true }, + }, + { path: 'useRootPaddingAwareAlignments' } + ) + ).toBe( true ); + } ); + + it( 'returns an array setting without changing its value', () => { + const units = [ 'px', 'rem', '%' ]; + + expect( + resolve( + { + 'core/heading': { spacing: { units } }, + }, + { path: 'spacing.units' } + ) + ).toBe( units ); + } ); + + it( 'allows nested rules to match ordered non-direct ancestors', () => { + expect( + doesBlockHierarchyMatch( + [ 'core/group', 'core/heading' ], + [ 'core/group', 'core/quote', 'core/heading' ] + ) + ).toBe( true ); + } ); } ); diff --git a/src/settings/rules-viewer.js b/src/settings/rules-viewer.js index 1eff76a..dc9fdcf 100644 --- a/src/settings/rules-viewer.js +++ b/src/settings/rules-viewer.js @@ -97,5 +97,8 @@ export function initializeRulesViewer( { root = document, request } ) { postTypeSelector.addEventListener( 'change', updateControls ); viewButton.addEventListener( 'click', handleRequest ); + // Preserve the original settings-page behavior on reload and browser restore. + roleSelector.value = ''; + postTypeSelector.value = ''; updateControls(); } diff --git a/src/settings/rules-viewer.test.js b/src/settings/rules-viewer.test.js index eeea6fd..192c217 100644 --- a/src/settings/rules-viewer.test.js +++ b/src/settings/rules-viewer.test.js @@ -33,6 +33,19 @@ describe( 'initializeRulesViewer', () => { expect( viewButton.hidden ).toBe( false ); } ); + it( 'resets browser-restored selections during initialization', () => { + const roleSelector = document.getElementById( 'user-role-selector' ); + const postTypeSelector = document.getElementById( 'post-type-selector' ); + roleSelector.value = 'editor'; + postTypeSelector.value = 'page'; + + initializeRulesViewer( { request: jest.fn() } ); + + expect( roleSelector.value ).toBe( '' ); + expect( postTypeSelector.value ).toBe( '' ); + expect( document.getElementById( 'view-rules-button' ).hidden ).toBe( true ); + } ); + it( 'requests and displays rules for both selected filters', async () => { const request = jest.fn().mockResolvedValue( { allowedBlocks: [ 'core/paragraph' ] } ); initializeRulesViewer( { request } ); From f03ea13b0d5f5bc4474adb0dfcb6643ac876aaba Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Mon, 7 Sep 2026 13:57:09 +1000 Subject: [PATCH 3/4] Convert everything to typescript and simplify the packages down --- .github/workflows/unit-tests.yml | 15 +- AGENTS.md | 35 +- README.md | 9 +- build/index.asset.php | 2 +- build/index.js | 3 +- build/settings.asset.php | 2 +- build/settings.js | 3 +- build/settings.js.map | 1 - eslint.config.js | 1 + governance/settings/settings-view.php | 8 +- package-lock.json | 594 ++++++++++++++---- package.json | 19 +- playwright.config.js => playwright.config.ts | 2 +- src/block-locking.jsx | 105 ---- src/block-locking.test.js | 63 -- src/block-locking.test.tsx | 124 ++++ src/block-locking.tsx | 101 +++ src/block-matching.ts | 26 + src/block-utils.js | 116 ---- ...lock-utils.test.js => block-utils.test.ts} | 79 ++- src/block-utils.ts | 67 ++ src/editor.test.ts | 119 ++++ src/editor.ts | 107 ++++ src/index.js | 105 ---- src/index.ts | 3 + src/nested-governance-loader.js | 161 ----- src/nested-governance-loader.test.js | 180 ------ src/nested-settings-filter.js | 165 ----- src/nested-settings-filter.test.js | 161 ----- src/nested-settings-filter.test.ts | 261 ++++++++ src/nested-settings-filter.ts | 202 ++++++ src/settings/{index.js => index.ts} | 0 src/settings/rules-viewer.test.js | 114 ---- src/settings/rules-viewer.test.ts | 105 ++++ .../{rules-viewer.js => rules-viewer.ts} | 87 +-- src/types.ts | 39 ++ src/wordpress-block-editor.d.ts | 6 + ... default-administrator-rule-check.spec.ts} | 57 +- tests/e2e/{globalSetup.js => globalSetup.ts} | 6 +- ...ngs-page.spec.js => settings-page.spec.ts} | 2 +- tsconfig.json | 18 + vendor/composer/installed.php | 4 +- webpack.config.js | 4 +- 43 files changed, 1870 insertions(+), 1411 deletions(-) delete mode 100644 build/settings.js.map rename playwright.config.js => playwright.config.ts (95%) delete mode 100644 src/block-locking.jsx delete mode 100644 src/block-locking.test.js create mode 100644 src/block-locking.test.tsx create mode 100644 src/block-locking.tsx create mode 100644 src/block-matching.ts delete mode 100644 src/block-utils.js rename src/{block-utils.test.js => block-utils.test.ts} (82%) create mode 100644 src/block-utils.ts create mode 100644 src/editor.test.ts create mode 100644 src/editor.ts delete mode 100644 src/index.js create mode 100644 src/index.ts delete mode 100644 src/nested-governance-loader.js delete mode 100644 src/nested-governance-loader.test.js delete mode 100644 src/nested-settings-filter.js delete mode 100644 src/nested-settings-filter.test.js create mode 100644 src/nested-settings-filter.test.ts create mode 100644 src/nested-settings-filter.ts rename src/settings/{index.js => index.ts} (100%) delete mode 100644 src/settings/rules-viewer.test.js create mode 100644 src/settings/rules-viewer.test.ts rename src/settings/{rules-viewer.js => rules-viewer.ts} (50%) create mode 100644 src/types.ts create mode 100644 src/wordpress-block-editor.d.ts rename tests/e2e/{default-administrator-rule-check.spec.js => default-administrator-rule-check.spec.ts} (79%) rename tests/e2e/{globalSetup.js => globalSetup.ts} (89%) rename tests/e2e/{settings-page.spec.js => settings-page.spec.ts} (93%) create mode 100644 tsconfig.json diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index dc690b0..8f025bd 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -12,7 +12,7 @@ concurrency: jobs: lint-js: - name: JavaScript linting + name: Frontend linting and type checking runs-on: ubuntu-latest strategy: @@ -28,15 +28,22 @@ jobs: node-version: '24' cache: npm - - name: Build the plugin + - name: Install dependencies + run: npm ci + + - name: Verify production bundles run: | - npm ci + npm run build + git diff --exit-code -- build - name: Lint JS files run: npm run lint:js + - name: Type check frontend files + run: npm run typecheck + unit-js: - name: JavaScript Unit Tests + name: Frontend Unit Tests runs-on: ubuntu-latest strategy: diff --git a/AGENTS.md b/AGENTS.md index 49fdb20..0cf5733 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ The plugin uses a JSON-based rules system (`governance-rules.json`) with support ### Tech Stack - **PHP 8.2+** — Backend logic, rule parsing, REST API, settings panel -- **JavaScript/JSX** — Block editor integration via WordPress filters +- **TypeScript/TSX** — Block editor integration via WordPress filters - **WordPress 6.8+** — Target platform - **Webpack** — Build system (via `@wordpress/scripts`) - **Composer** — PHP dependency management @@ -39,13 +39,14 @@ governance/ settings/ settings.php # Admin settings page registration settings-view.php # Settings page HTML template - settings.js # Settings page frontend logic settings.css # Settings page styles src/ - index.js # JS entry point — block editor filter setup - block-utils.js # Block name matching, hierarchy validation - block-locking.jsx # React UI for block locking - nested-governance-loader.js # Loads nested governance settings + index.ts # Frontend entry point + editor.ts # Block editor filter setup + block-utils.ts # Block name matching, hierarchy validation + block-locking.tsx # React UI for block locking + nested-settings-filter.ts # Resolves nested governance settings + settings/ # Settings page frontend logic bin/release # Creates and commits major/minor/patch release branches build/ # Compiled JS output (do not edit directly) tests/ @@ -67,7 +68,7 @@ WPCOMVIP__GOVERNANCE__RULES_REST_ROUTE // 'vip-governance/v1' WPCOMVIP_GOVERNANCE_RULES_FILENAME // 'governance-rules.json' ``` -### JavaScript Global +### Frontend Global The plugin exposes `VIP_GOVERNANCE` to the block editor with: @@ -181,7 +182,7 @@ npm run test # Runs both PHP and JS unit tests ### Code Standards - **PHP**: WordPress-VIP-Go + WordPress-Extra via PHPCS -- **JS**: `@automattic/eslint-plugin-wpvip` +- **TypeScript/JavaScript**: `@automattic/eslint-plugin-wpvip` - **Formatting**: Prettier with `@automattic/eslint-plugin-wpvip/prettierrc` ### CI Matrix (GitHub Actions) @@ -219,17 +220,17 @@ class MyNewTest extends TestCase { } ``` -### JS Unit Tests (Jest) +### Frontend Unit Tests (Jest) -- **Location**: Co-located with source as `src/foo.test.js` next to `src/foo.js` +- **Location**: Co-located with source as `src/foo.test.ts` or `src/foo.test.tsx` - **Framework**: Jest with `@wordpress/jest-preset-default` - **WordPress mocks**: Mock `@wordpress/hooks`, `@wordpress/data`, etc. via `jest.mock()` - **Style**: `describe` / `it` blocks -- **Run one file**: `npx jest src/block-utils.test.js` +- **Run one file**: `npx jest src/block-utils.test.ts` Example structure: -```js +```ts import { myFunction } from './my-module'; describe( 'myFunction', () => { @@ -241,16 +242,16 @@ describe( 'myFunction', () => { ### E2E Tests (Playwright) -- **Location**: `tests/e2e/*.spec.js` +- **Location**: `tests/e2e/*.spec.ts` - **Framework**: Playwright with `@wordpress/e2e-test-utils-playwright` -- **Global setup**: `tests/e2e/globalSetup.js` — authenticates as admin, resets posts/blocks/preferences +- **Global setup**: `tests/e2e/globalSetup.ts` — authenticates as admin, resets posts/blocks/preferences - **Auth state**: Saved to `artifacts/storage-states/admin.json` - **Fixtures**: Uses WordPress `admin`, `editor`, and `page` fixtures from `@wordpress/e2e-test-utils-playwright` -- **Run one file**: `npx playwright test tests/e2e/my-test.spec.js` +- **Run one file**: `npx playwright test tests/e2e/my-test.spec.ts` Example structure: -```js +```ts import { expect, test } from '@wordpress/e2e-test-utils-playwright'; test.describe( 'My Feature', () => { @@ -288,7 +289,7 @@ test.describe( 'My Feature', () => { - **Rules not applying?** Check that `governance-rules.json` is in the private directory and validates against the schema. Use the admin settings page to verify. - **Block still visible?** Starting from WordPress 6.8, the block inserter shows all blocks — disallowed ones trigger a snackbar on insert attempt. -- **JS not updating?** Run `npm run build` or `npm run dev` (watch mode). Check that `build/index.js` was regenerated. +- **Frontend not updating?** Run `npm run build` or `npm run dev` (watch mode). Check that `build/index.js` was regenerated. - **PHP changes not reflecting?** Ensure `wp-env` is running. No build step needed for PHP. - **Test failures in CI?** Check the CI matrix — tests run against PHP 8.2–8.5 and WP 6.8/latest. Failures may be version-specific. - **Unexpected vendor changes?** Composer regenerates tracked metadata under `vendor/composer`. Only commit those changes when intentionally updating the production dependency bundle. diff --git a/README.md b/README.md index c833920..298cc1a 100644 --- a/README.md +++ b/README.md @@ -508,7 +508,7 @@ With this rule set, the following rules will apply: - Support for `color.duotone` has not been implemented. - Currently, the plugin is restricted to the post editor only and won't work on other pages like site-editor, widgets, etc. -- Starting from WordPress 6.8, the block inserter sidebar will show all the blocks regardless of the ability to insert them or not. Upon attempting to insert a block that isn't allowed, a snackbar will show up in the lower left corner to highlight that this isn't possible. The regular `/blockname` approach will work just fine, and would be the recommended way of inserting blocks when using this plugin. +- In WordPress 6.8 and 6.9, the main block inserter can show blocks that governance will not allow. Attempting to insert one is still rejected and displays a snackbar notice. WordPress 7.0 and later filter those unavailable blocks from the inserter again, following the [Gutenberg 22.4 inserter fix][gutenberg-22-4]. ## Code Filters @@ -855,7 +855,7 @@ The npm `postinstall` script installs production Composer dependencies, and the ### Tests -The PHP, JavaScript, and end-to-end tests can be run locally with [`wp-env`][wp-env] and Docker. +The PHP, TypeScript, and end-to-end tests can be run locally with [`wp-env`][wp-env] and Docker. The default configuration starts a development site at `http://localhost:8888`. Automated tests use an isolated configuration at `http://localhost:8889`, which mounts the governance fixture from `tests/private` without affecting the development site. @@ -866,7 +866,7 @@ npx wp-env start --config=.wp-env.test.json composer run test ``` -For the JS unit tests: +For the frontend unit tests: ``` npm run test:js @@ -882,7 +882,7 @@ npm run test:e2e Run multisite PHP coverage with `composer run test-multisite`. Playwright uses the isolated test site at `http://localhost:8889`, with `admin` / `password` as the default credentials. -Run both PHP and JavaScript unit tests with `npm test` after the isolated test environment is running. +Run both PHP and TypeScript unit tests with `npm test` after the isolated test environment is running. @@ -892,6 +892,7 @@ Run both PHP and JavaScript unit tests with `npm test` after the isolated test e [repo-governance-file-location]: governance-rules.json [repo-schema-location]: governance-schema.json [gutenberg-block-settings]: https://developer.wordpress.org/block-editor/how-to-guides/themes/theme-json/#settings +[gutenberg-22-4]: https://make.wordpress.org/core/2026/01/22/whats-new-in-gutenberg-22-4-20-january/ [repo-analytics]: governance/analytics.php [repo-releases]: https://github.com/automattic/vip-governance-plugin/releases [vip-go-mu-plugins]: https://github.com/Automattic/vip-go-mu-plugins/ diff --git a/build/index.asset.php b/build/index.asset.php index 23f99cd..e218816 100644 --- a/build/index.asset.php +++ b/build/index.asset.php @@ -1 +1 @@ - array('react-jsx-runtime', 'wp-block-editor', 'wp-components', 'wp-compose', 'wp-data', 'wp-element', 'wp-hooks', 'wp-i18n', 'wp-notices'), 'version' => '02eaa8c5b6bf20b86b84'); + array('react-jsx-runtime', 'wp-block-editor', 'wp-components', 'wp-data', 'wp-element', 'wp-hooks', 'wp-i18n', 'wp-notices'), 'version' => '26c4d053b38ba922995b'); diff --git a/build/index.js b/build/index.js index 6d0089d..596a138 100644 --- a/build/index.js +++ b/build/index.js @@ -1,2 +1 @@ -(()=>{"use strict";var e={122(e,t,n){n.d(t,{G:()=>g});var o=n(715),r=n(427),i=n(491),c=n(143),s=n(87),a=n(619),l=n(418),d=n(790);const u={opacity:.6,backgroundColor:"#eee",border:"2px dashed #999"},p=(0,s.createContext)(!1);function g(e){const t=(0,i.createHigherOrderComponent)(t=>function(n){const{name:i,clientId:g}=n,v=(0,s.useContext)(p),{getBlockParents:f,getBlockName:h}=(0,c.select)(o.store),w=function(e,t,n,o){if(o)return!0;const r=(0,l.yw)(e,t,n);return(0,a.applyFilters)("vip_governance__is_block_allowed_for_editing",r,e,t,n)}(i,f(g,!0).map(h),e,v);return(0,o.useBlockEditingMode)(w?void 0:"disabled"),w?(0,d.jsx)(p.Provider,{value:v,children:(0,d.jsx)(t,{...n})}):(0,d.jsx)(p.Provider,{value:!0,children:(0,d.jsx)(r.Disabled,{children:(0,d.jsx)("div",{style:u,children:(0,d.jsx)(t,{...n})})})})},"withDisabledBlocks");(0,a.addFilter)("editor.BlockEdit","wpcomvip-governance/with-disabled-blocks",t)}},418(e,t,n){n.d(t,{aV:()=>s,yw:()=>c});var o=n(619),r=n(325);const i={"core/list":["core/list-item"],"core/columns":["core/column"],"core/page-list":["core/page-list-item"],"core/navigation":["core/navigation-link","core/navigation-submenu"],"core/navigation-link":["core/navigation-link","core/navigation-submenu","core/page-list"],"core/quote":["core/paragraph"],"core/media-text":["core/paragraph"],"core/social-links":["core/social-link"],"core/comments-pagination":["core/comments-pagination-previous","core/comments-pagination-numbers","core/comments-pagination-next"]};function c(e,t,n){const c=(0,o.applyFilters)("vip_governance__is_block_allowed_in_hierarchy",!0,e,t,n)||0===t.length?[...n.allowedBlocks]:[];if(t.length>0){if(i[t[0]]&&i[t[0]].includes(e))return!0;if(n.blockSettings){const e=(0,r.W)([...t].reverse(),"allowedBlocks",n.blockSettings);e&&e.value&&c.push(...e.value)}}return function(e,t){return t.some(t=>s(e,t))}(e,c)}function s(e,t){if(t.includes("*")){const n=t.replace(/[.+?^${}()|[\]\\]/g,"\\$&").replaceAll("*",".*");return new RegExp(`^${n}$`).test(e)}return t===e}},325(e,t,n){function o(e,t,n,i={depth:0,value:void 0},c=1){if(0===e.length)return i;const[s,...a]=e,l=n[s];if(0===a.length){const e=r(l,t);return void 0!==e&&c>=i.depth&&(i.depth=c,i.value=e),i}return void 0!==l&&(i=o(a,t,l,i,c+1)),o(a,t,n,i,c)}function r(e,t,n=void 0){const o=Array.isArray(t)?t:t.replace(/(\[(\d)\])/g,".$2").replace(/^\./,"").split(".");if(!o.length||void 0===o[0])return e;const i=o[0];return"object"==typeof e&&null!==e&&i in e&&void 0!==e[i]?r(e[i],o.slice(1),n):n}n.d(t,{W:()=>o})},975(e,t,n){n.d(t,{ED:()=>i,Tr:()=>r});var o=n(418);function r(e){const t=new Map;let n=0;const o=(e,r,i)=>{if(((e,o,r)=>{const i=t.get(e)??[];i.push({blockPatterns:r,value:o,depth:r.length,exactMatches:r.filter(e=>!e.includes("*")).length,order:n++}),t.set(e,i)})(r,e,i),"object"==typeof e&&null!==e&&!Array.isArray(e))for(const[t,n]of Object.entries(e))o(n,`${r}.${t}`,i)},r=(e,t=[])=>{for(const[n,i]of Object.entries(e))"allowedBlocks"!==n&&(n.includes("/")||"*"===n?r(i,[...t,n]):t.length>0&&o(i,n,t))};return r(e),t}function i({defaultValue:e,path:t,clientId:n,rulesByPath:r,getBlockParents:i,getBlockName:c}){const s=function(e,t,n){return[...[...t(e,!0)].reverse(),e].map(n)}(n,i,c),a=(r.get(t)??[]).filter(e=>function(e,t){if(0===e.length||0===t.length)return!1;let n=e.length-1,r=t.length-1;if(!(0,o.aV)(t[r],e[n]))return!1;for(n--,r--;n>=0;){for(;r>=0&&!(0,o.aV)(t[r],e[n]);)r--;if(r<0)return!1;n--,r--}return!0}(e.blockPatterns,s));if(0===a.length)return e;const l=a.reduce((e,t)=>t.depth!==e.depth?t.depth>e.depth?t:e:t.exactMatches!==e.exactMatches?t.exactMatches>e.exactMatches?t:e:t.order>e.order?t:e);return l.value?.theme??l.value}},790(e){e.exports=window.ReactJSXRuntime},715(e){e.exports=window.wp.blockEditor},427(e){e.exports=window.wp.components},491(e){e.exports=window.wp.compose},143(e){e.exports=window.wp.data},87(e){e.exports=window.wp.element},619(e){e.exports=window.wp.hooks},723(e){e.exports=window.wp.i18n},692(e){e.exports=window.wp.notices}};const t={};function n(o){const r=t[o];if(void 0!==r)return r.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,n),i.exports}n.n=e=>{const t=e&&e.__esModule?()=>e.default:()=>e;return n.d(t,{a:t}),t},n.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oObject.hasOwn(e,t);var o=n(715),r=n(143),i=n(619),c=n(723),s=n(692),a=n(122),l=n(418),d=n(975);!function(){if(VIP_GOVERNANCE.error)return void(0,r.dispatch)(s.store).createErrorNotice(VIP_GOVERNANCE.error,{id:"wpcomvip-governance-error",isDismissible:!0,actions:[{label:(0,c.__)("Open governance settings","vip-governance"),url:VIP_GOVERNANCE.urlSettingsPage}]});const e=VIP_GOVERNANCE.governanceRules,{getBlockParents:t,getBlockName:n}=(0,r.select)(o.store);(0,i.addFilter)("blockEditor.__unstableCanInsertBlockType","wpcomvip-governance/block-insertion",(o,r,c,{getBlock:s})=>{if(!1===o)return o;let a=[];if(c){const e=s(c),o=t(c,!0);a=[e?.name??n(c),...o.map(n)]}const d=(0,l.yw)(r.name,a,e);return(0,i.applyFilters)("vip_governance__is_block_allowed_for_insertion",d,r.name,a,e)});const u=VIP_GOVERNANCE.nestedSettings,p=(0,d.Tr)(u);(0,i.addFilter)("blockEditor.useSetting.before","wpcomvip-governance/nested-block-settings",(e,o,r,i)=>i?(0,d.ED)({defaultValue:e,path:o,clientId:r,rulesByPath:p,getBlockParents:t,getBlockName:n}):e),e?.allowedBlocks&&(0,a.G)(e)}()})(); -//# sourceMappingURL=index.js.map \ No newline at end of file +(()=>{"use strict";const e=window.wp.blockEditor,t=window.wp.data,n=window.wp.hooks,o=window.wp.i18n,r=window.wp.notices,i=window.wp.components,c=window.wp.element;function l(e,t){return t.includes("*")?e.match(new RegExp(t.replace("*",".*"))):t===e}function s(e){return"object"==typeof e&&null!==e&&!Array.isArray(e)}function a(e,t,n){if("object"==typeof e&&null!==e)for(const[o,r]of Object.entries(e)){const e=`${t}${o}`;n.add(e),s(r)&&a(r,`${e}.`,n)}}function u(e,t){const n=Array.isArray(t)?t:t.replace(/(\[(\d)\])/g,".$2").replace(/^\./,"").split(".");if(0===n.length)return e;if("object"!=typeof e||null===e)return;const[o,...r]=n;return o in e?u(e[o],r):void 0}function d(e,t,n,o={depth:0,value:void 0},r=1){if(0===e.length)return o;const i=e.at(-1);if(void 0===i)return o;const c=e.slice(0,-1),l=n[i],a=s(l)?l:void 0;if(0===c.length){const e=u(a,t);return void 0!==e&&r>=o.depth?{depth:r,value:e}:o}return a&&(o=d(c,t,a,o,r+1)),d(c,t,n,o,r)}function p(e,t,n,o){return[o??n(e),...t(e,!0).map(n)].filter(e=>void 0!==e)}function f(e){return s(e)&&e.theme?e.theme:e}const g={"core/list":["core/list-item"],"core/columns":["core/column"],"core/page-list":["core/page-list-item"],"core/navigation":["core/navigation-link","core/navigation-submenu"],"core/navigation-link":["core/navigation-link","core/navigation-submenu","core/page-list"],"core/quote":["core/paragraph"],"core/media-text":["core/paragraph"],"core/social-links":["core/social-link"],"core/comments-pagination":["core/comments-pagination-previous","core/comments-pagination-numbers","core/comments-pagination-next"]};function v(e,t,o){const r=(0,n.applyFilters)("vip_governance__is_block_allowed_in_hierarchy",!0,e,t,o)||0===t.length?[...o.allowedBlocks]:[];if(t.length>0){const n=g[t[0]];if(n?.includes(e))return!0;r.push(...function(e,t){if(!t)return[];const{value:n}=d(e,"allowedBlocks",t);return Array.isArray(n)&&n.every(e=>"string"==typeof e)?n:[]}(t,o.blockSettings))}return function(e,t){return t.some(t=>l(e,t))}(e,r)}const m=window.ReactJSXRuntime,k={opacity:.6,backgroundColor:"#eee",border:"2px dashed #999"},w=(0,c.createContext)(!1);!function(u){if(u.error)return void(0,t.dispatch)(r.store).createErrorNotice(u.error,{id:"wpcomvip-governance-error",isDismissible:!0,actions:[{label:(0,o.__)("Open governance settings","vip-governance"),url:u.urlSettingsPage}]});const{governanceRules:g}=u,{getBlockParents:h,getBlockName:b}=(0,t.select)(e.store);(0,n.addFilter)("blockEditor.__unstableCanInsertBlockType","wpcomvip-governance/block-insertion",(e,t,o,{getBlock:r})=>{if(!1===e)return!1;let i=[];if(o){const e=r(o),t=h(o,!0).map(b).filter(e=>void 0!==e),n=e?.name??b(o);i=n?[n,...t]:t}const c=v(t.name,i,g);return(0,n.applyFilters)("vip_governance__is_block_allowed_for_insertion",c,t.name,i,g)});const _=function(e){const t=new Map,n=(e,o)=>{for(const[r,i]of Object.entries(e))if("allowedBlocks"!==r)if((r.includes("/")||"*"===r)&&s(i))n(i,r);else if(o){const e=t.get(o)??new Set;a(i,`${r}.`,e),t.set(o,e)}};return n(e),{pathsByBlock:t,settings:e}}(u.nestedSettings);(0,n.addFilter)("blockEditor.useSetting.before","wpcomvip-governance/nested-block-settings",(e,t,n,o)=>o?function({clientId:e,currentBlockName:t,defaultValue:n,getBlockName:o,getBlockParents:r,path:i,rules:c}){return function({blockNames:e,defaultValue:t,path:n,rules:o}){const r=e[0];if(!r)return t;if(o.pathsByBlock.get(r)?.has(n))return f(d(e,n,o.settings).value);for(const[t,i]of o.pathsByBlock)if(t.includes("*")&&i.has(n)&&l(r,t)){const r=[...e];return r[0]=t,f(d(r,n,o.settings).value)}return t}({blockNames:p(e,r,o,t),defaultValue:n,path:i,rules:c})}({clientId:n,currentBlockName:o,defaultValue:e,getBlockName:b,getBlockParents:h,path:t,rules:_}):e),g.allowedBlocks&&function(o){(0,n.addFilter)("editor.BlockEdit","wpcomvip-governance/with-disabled-blocks",r=>function(o,r){return function(l){const{name:s,clientId:a}=l,u=(0,c.useContext)(w),d=function(e,t,o,r){if(r)return!0;const i=v(e,t,o);return(0,n.applyFilters)("vip_governance__is_block_allowed_for_editing",i,e,t,o)}(s,(0,t.useSelect)(t=>{const{getBlockParents:n,getBlockName:o}=t(e.store);return n(a,!0).map(o).filter(e=>void 0!==e)},[a]),r,u);return(0,e.useBlockEditingMode)(d?void 0:"disabled"),d?(0,m.jsx)(o,{...l}):(0,m.jsx)(w.Provider,{value:!0,children:(0,m.jsx)(i.Disabled,{children:(0,m.jsx)("div",{style:k,children:(0,m.jsx)(o,{...l})})})})}}(r,o))}(g)}(VIP_GOVERNANCE)})(); \ No newline at end of file diff --git a/build/settings.asset.php b/build/settings.asset.php index 6769137..a33bbb2 100644 --- a/build/settings.asset.php +++ b/build/settings.asset.php @@ -1 +1 @@ - array('wp-api-fetch'), 'version' => '8ae7c6c54099bfdbf201'); + array('wp-api-fetch'), 'version' => 'f88a32824cccaaaaee1c'); diff --git a/build/settings.js b/build/settings.js index 6ad081c..690e7f3 100644 --- a/build/settings.js +++ b/build/settings.js @@ -1,2 +1 @@ -(()=>{"use strict";var e={761(e,t,n){function r({root:e=document,request:t}){const n=e.getElementById("user-role-selector"),r=e.getElementById("post-type-selector"),s=e.getElementById("view-rules-button"),a=e.querySelector(".vip-governance-query-spinner"),o=e.getElementById("combined-governance-rules-json");if(!(n&&r&&s&&a&&o&&"function"==typeof t))return;let d=0;const i=()=>{const e={};return n.value&&(e.role=n.value),r.value&&(e.postType=r.value),e},c=()=>{const e=Object.keys(i()).length>0;s.hidden=!e,e||(o.hidden=!0)},l=e=>{o.textContent=e,o.hidden=!1};n.addEventListener("change",c),r.addEventListener("change",c),s.addEventListener("click",async()=>{const e=i();if(0===Object.keys(e).length)return;const c=++d;n.disabled=!0,r.disabled=!0,s.disabled=!0,a.hidden=!1,a.classList.add("is-active");try{const n=await t({path:`/vip-governance/v1/rules?${new URLSearchParams(e)}`});c===d&&l(JSON.stringify(n,null,4))}catch(e){c===d&&l(e?.message||o.dataset.errorMessage)}finally{c===d&&(n.disabled=!1,r.disabled=!1,s.disabled=!1,a.hidden=!0,a.classList.remove("is-active"))}}),n.value="",r.value="",c()}n.d(t,{S:()=>r})},455(e){e.exports=window.wp.apiFetch}};const t={};function n(r){const s=t[r];if(void 0!==s)return s.exports;const a=t[r]={exports:{}};return e[r](a,a.exports,n),a.exports}n.n=e=>{const t=e&&e.__esModule?()=>e.default:()=>e;return n.d(t,{a:t}),t},n.d=(e,t)=>{if(Array.isArray(t))for(var r=0;rObject.hasOwn(e,t);var r=n(455),s=n.n(r);(0,n(761).S)({request:s()})})(); -//# sourceMappingURL=settings.js.map \ No newline at end of file +(()=>{"use strict";const e={n:t=>{const n=t&&t.__esModule?()=>t.default:()=>t;return e.d(n,{a:n}),n},d:(t,n)=>{if(Array.isArray(n))for(var s=0;sObject.hasOwn(e,t)},t=window.wp.apiFetch;!function({root:e=document,request:t}){const n=e.getElementById("user-role-selector"),s=e.getElementById("post-type-selector"),r=e.getElementById("view-rules-button"),a=e.querySelector(".vip-governance-query-spinner"),o=e.getElementById("json");if(!(n instanceof HTMLSelectElement&&s instanceof HTMLSelectElement&&r instanceof HTMLButtonElement&&a instanceof HTMLElement&&o instanceof HTMLElement))return;const l=()=>{const e={};return n.value&&(e.role=n.value),s.value&&(e.postType=s.value),e},c=()=>{const e=Object.keys(l()).length>0;r.hidden=!e,e||(o.hidden=!0)},i=e=>{o.textContent=e,o.hidden=!1};n.addEventListener("change",c),s.addEventListener("change",c),r.addEventListener("click",()=>{(async()=>{const e=l();if(0!==Object.keys(e).length){a.hidden=!1,a.classList.add("is-active");try{const n=await t({path:`/vip-governance/v1/rules?${new URLSearchParams(e)}`});i(JSON.stringify(n,null,4))}catch(e){i(function(e,t){return e instanceof Error&&e.message||"object"==typeof e&&null!==e&&"message"in e&&"string"==typeof e.message&&e.message?e.message:t}(e,o.dataset.errorMessage??"Unable to load rules."))}finally{a.hidden=!0,a.classList.remove("is-active")}}})()}),n.value="",s.value="",c()}({request:e.n(t)()})})(); \ No newline at end of file diff --git a/build/settings.js.map b/build/settings.js.map deleted file mode 100644 index 391cb62..0000000 --- a/build/settings.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"settings.js","mappings":"qCAUO,SAASA,GAAuBC,KAAEA,EAAOC,SAAQC,QAAEA,IACzD,MAAMC,EAAeH,EAAKI,eAAgB,sBACpCC,EAAmBL,EAAKI,eAAgB,sBACxCE,EAAaN,EAAKI,eAAgB,qBAClCG,EAAUP,EAAKQ,cAAe,iCAC9BC,EAAST,EAAKI,eAAgB,kCAEpC,KACGD,GACAE,GACAC,GACAC,GACAE,GACiB,mBAAZP,GAEP,OAGD,IAAIQ,EAAgB,EAEpB,MAAMC,EAAiBA,KACtB,MAAMC,EAAO,CAAC,EAUd,OARKT,EAAaU,QACjBD,EAAKE,KAAOX,EAAaU,OAGrBR,EAAiBQ,QACrBD,EAAKG,SAAWV,EAAiBQ,OAG3BD,GAGFI,EAAiBA,KACtB,MAAMC,EAAeC,OAAOC,KAAMR,KAAmBS,OAAS,EAC9Dd,EAAWe,QAAWJ,EAEfA,IACNR,EAAOY,QAAS,IAIZC,EAAaT,IAClBJ,EAAOc,YAAcV,EACrBJ,EAAOY,QAAS,GAwCjBlB,EAAaqB,iBAAkB,SAAUR,GACzCX,EAAiBmB,iBAAkB,SAAUR,GAC7CV,EAAWkB,iBAAkB,QAvCPC,UACrB,MAAMb,EAAOD,IAEb,GAAoC,IAA/BO,OAAOC,KAAMP,GAAOQ,OACxB,OAGD,MAAMM,IAAchB,EACpBP,EAAawB,UAAW,EACxBtB,EAAiBsB,UAAW,EAC5BrB,EAAWqB,UAAW,EACtBpB,EAAQc,QAAS,EACjBd,EAAQqB,UAAUC,IAAK,aAEvB,IACC,MAAMC,QAAc5B,EAAS,CAC5B6B,KAAM,4BAAwB,IAAIC,gBAAiBpB,OAG/Cc,IAAchB,GAClBY,EAAYW,KAAKC,UAAWJ,EAAO,KAAM,GAE3C,CAAE,MAAQK,GACJT,IAAchB,GAClBY,EAAYa,GAAOC,SAAW3B,EAAO4B,QAAQC,aAE/C,CAAC,QACKZ,IAAchB,IAClBP,EAAawB,UAAW,EACxBtB,EAAiBsB,UAAW,EAC5BrB,EAAWqB,UAAW,EACtBpB,EAAQc,QAAS,EACjBd,EAAQqB,UAAUW,OAAQ,aAE5B,IAQDpC,EAAaU,MAAQ,GACrBR,EAAiBQ,MAAQ,GACzBG,GACD,0BCvGAwB,EAAAC,QAAAC,OAAA,cCCA,MAAAC,EAAA,GAGA,SAAAC,EAAAC,GAEA,MAAAC,EAAAH,EAAAE,GACA,QAAAE,IAAAD,EACA,OAAAA,EAAAL,QAGA,MAAAD,EAAAG,EAAAE,GAAA,CAGAJ,QAAA,IAOA,OAHAO,EAAAH,GAAAL,EAAAA,EAAAC,QAAAG,GAGAJ,EAAAC,OACA,CCrBAG,EAAAK,EAAAT,IACA,MAAAU,EAAAV,GAAAA,EAAAW,WACA,IAAAX,EAAA,QACA,MAEA,OADAI,EAAAQ,EAAAF,EAAA,CAAiCG,EAAAH,IACjCA,GCLAN,EAAAQ,EAAA,CAAAX,EAAAa,KACA,GAAAC,MAAAC,QAAAF,GAEA,IADA,IAAAG,EAAA,EACAA,EAAAH,EAAAlC,QAAA,CACA,IAAAsC,EAAAJ,EAAAG,KACAE,EAAAL,EAAAG,KACAb,EAAAgB,EAAAnB,EAAAiB,GAMK,IAAAC,GAAyBF,IAL9B,IAAAE,EACAzC,OAAA2C,eAAApB,EAAAiB,EAAA,CAA2CI,YAAA,EAAAjD,MAAAyC,EAAAG,OAE3CvC,OAAA2C,eAAApB,EAAAiB,EAAA,CAA2CI,YAAA,EAAAC,IAAAJ,GAG3C,MAEA,QAAAD,KAAAJ,EACAV,EAAAgB,EAAAN,EAAAI,KAAAd,EAAAgB,EAAAnB,EAAAiB,IACAxC,OAAA2C,eAAApB,EAAAiB,EAAA,CAA0CI,YAAA,EAAAC,IAAAT,EAAAI,MClB1Cd,EAAAgB,EAAA,CAAAI,EAAAC,IAAA/C,OAAAgD,OAAAF,EAAAC,0BCIAlE,SAAAA,GAAuB,CAAEG,QAASiE","sources":["webpack://vip-governance/./src/settings/rules-viewer.js","webpack://vip-governance/external window [\"wp\",\"apiFetch\"]","webpack://vip-governance/webpack/bootstrap","webpack://vip-governance/webpack/runtime/compat get default export","webpack://vip-governance/webpack/runtime/define property getters","webpack://vip-governance/webpack/runtime/hasOwnProperty shorthand","webpack://vip-governance/./src/settings/index.js"],"sourcesContent":["const RULES_ENDPOINT = '/vip-governance/v1/rules';\n\n/**\n * Initialize the role and post type rules viewer on the plugin settings page.\n *\n * @param {Object} options Initialization options.\n * @param {Document} [options.root] Document containing the settings page.\n * @param {Function} options.request Function used to request combined rules.\n * @return {void}\n */\nexport function initializeRulesViewer( { root = document, request } ) {\n\tconst roleSelector = root.getElementById( 'user-role-selector' );\n\tconst postTypeSelector = root.getElementById( 'post-type-selector' );\n\tconst viewButton = root.getElementById( 'view-rules-button' );\n\tconst spinner = root.querySelector( '.vip-governance-query-spinner' );\n\tconst output = root.getElementById( 'combined-governance-rules-json' );\n\n\tif (\n\t\t! roleSelector ||\n\t\t! postTypeSelector ||\n\t\t! viewButton ||\n\t\t! spinner ||\n\t\t! output ||\n\t\ttypeof request !== 'function'\n\t) {\n\t\treturn;\n\t}\n\n\tlet latestRequest = 0;\n\n\tconst getRequestData = () => {\n\t\tconst data = {};\n\n\t\tif ( roleSelector.value ) {\n\t\t\tdata.role = roleSelector.value;\n\t\t}\n\n\t\tif ( postTypeSelector.value ) {\n\t\t\tdata.postType = postTypeSelector.value;\n\t\t}\n\n\t\treturn data;\n\t};\n\n\tconst updateControls = () => {\n\t\tconst hasSelection = Object.keys( getRequestData() ).length > 0;\n\t\tviewButton.hidden = ! hasSelection;\n\n\t\tif ( ! hasSelection ) {\n\t\t\toutput.hidden = true;\n\t\t}\n\t};\n\n\tconst showOutput = value => {\n\t\toutput.textContent = value;\n\t\toutput.hidden = false;\n\t};\n\n\tconst handleRequest = async () => {\n\t\tconst data = getRequestData();\n\n\t\tif ( Object.keys( data ).length === 0 ) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst requestId = ++latestRequest;\n\t\troleSelector.disabled = true;\n\t\tpostTypeSelector.disabled = true;\n\t\tviewButton.disabled = true;\n\t\tspinner.hidden = false;\n\t\tspinner.classList.add( 'is-active' );\n\n\t\ttry {\n\t\t\tconst rules = await request( {\n\t\t\t\tpath: `${ RULES_ENDPOINT }?${ new URLSearchParams( data ) }`,\n\t\t\t} );\n\n\t\t\tif ( requestId === latestRequest ) {\n\t\t\t\tshowOutput( JSON.stringify( rules, null, 4 ) );\n\t\t\t}\n\t\t} catch ( error ) {\n\t\t\tif ( requestId === latestRequest ) {\n\t\t\t\tshowOutput( error?.message || output.dataset.errorMessage );\n\t\t\t}\n\t\t} finally {\n\t\t\tif ( requestId === latestRequest ) {\n\t\t\t\troleSelector.disabled = false;\n\t\t\t\tpostTypeSelector.disabled = false;\n\t\t\t\tviewButton.disabled = false;\n\t\t\t\tspinner.hidden = true;\n\t\t\t\tspinner.classList.remove( 'is-active' );\n\t\t\t}\n\t\t}\n\t};\n\n\troleSelector.addEventListener( 'change', updateControls );\n\tpostTypeSelector.addEventListener( 'change', updateControls );\n\tviewButton.addEventListener( 'click', handleRequest );\n\n\t// Preserve the original settings-page behavior on reload and browser restore.\n\troleSelector.value = '';\n\tpostTypeSelector.value = '';\n\tupdateControls();\n}\n","module.exports = window[\"wp\"][\"apiFetch\"];","// The module cache\nconst __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tconst cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tconst module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId](module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","// getDefaultExport function for compatibility with non-harmony modules\n__webpack_require__.n = (module) => {\n\tconst getter = module && module.__esModule ?\n\t\t() => (module['default']) :\n\t\t() => (module);\n\t__webpack_require__.d(getter, { a: getter });\n\treturn getter;\n};","// define getter/value functions for harmony exports\n__webpack_require__.d = (exports, definition) => {\n\tif(Array.isArray(definition)) {\n\t\tvar i = 0;\n\t\twhile(i < definition.length) {\n\t\t\tvar key = definition[i++];\n\t\t\tvar binding = definition[i++];\n\t\t\tif(!__webpack_require__.o(exports, key)) {\n\t\t\t\tif(binding === 0) {\n\t\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, value: definition[i++] });\n\t\t\t\t} else {\n\t\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: binding });\n\t\t\t\t}\n\t\t\t} else if(binding === 0) { i++; }\n\t\t}\n\t} else {\n\t\tfor(var key in definition) {\n\t\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t\t}\n\t\t}\n\t}\n};","__webpack_require__.o = (obj, prop) => (Object.hasOwn(obj, prop))","import apiFetch from '@wordpress/api-fetch';\n\nimport { initializeRulesViewer } from './rules-viewer';\n\ninitializeRulesViewer( { request: apiFetch } );\n"],"names":["initializeRulesViewer","root","document","request","roleSelector","getElementById","postTypeSelector","viewButton","spinner","querySelector","output","latestRequest","getRequestData","data","value","role","postType","updateControls","hasSelection","Object","keys","length","hidden","showOutput","textContent","addEventListener","async","requestId","disabled","classList","add","rules","path","URLSearchParams","JSON","stringify","error","message","dataset","errorMessage","remove","module","exports","window","__webpack_module_cache__","__webpack_require__","moduleId","cachedModule","undefined","__webpack_modules__","n","getter","__esModule","d","a","definition","Array","isArray","i","key","binding","o","defineProperty","enumerable","get","obj","prop","hasOwn","apiFetch"],"sourceRoot":""} \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index c8e506f..edc7acf 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -8,6 +8,7 @@ module.exports = [ ...wpvip.configs.formatting, ...wpvip.configs.testing, ...wpvip.configs.react, + ...wpvip.configs.typescript, ...wpvip.configs.prettier, { languageOptions: { diff --git a/governance/settings/settings-view.php b/governance/settings/settings-view.php index 1774bd3..3ea0c33 100644 --- a/governance/settings/settings-view.php +++ b/governance/settings/settings-view.php @@ -83,7 +83,7 @@ static function ( $line ) { - + - + diff --git a/package-lock.json b/package-lock.json index f008162..fcff633 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,24 +11,24 @@ "devDependencies": { "@automattic/eslint-plugin-wpvip": "^1.3.0", "@playwright/test": "^1.62.1", + "@types/jest": "^30.0.0", + "@types/react": "^18.3.31", "@wordpress/api-fetch": "^7.53.0", "@wordpress/block-editor": "^16.1.0", "@wordpress/components": "^38.0.0", - "@wordpress/compose": "^8.5.0", "@wordpress/data": "^10.52.0", - "@wordpress/element": "^8.4.0", "@wordpress/e2e-test-utils-playwright": "^1.52.0", + "@wordpress/element": "^8.4.0", "@wordpress/env": "^11.12.0", "@wordpress/hooks": "^4.52.0", "@wordpress/i18n": "^6.25.0", "@wordpress/notices": "^5.52.0", "@wordpress/scripts": "^34.0.0", - "babel-jest": "^30.4.1", "eslint": "^9.39.5", "lint-staged": "^17.3.0", "prettier": "npm:wp-prettier@3.0.3", "simple-git-hooks": "^2.13.1", - "webpack": "^5.109.2" + "typescript": "~6.0.3" } }, "node_modules/@ariakit/components": { @@ -1543,6 +1543,8 @@ }, "node_modules/@babel/plugin-transform-runtime": { "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", + "integrity": "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1562,6 +1564,8 @@ }, "node_modules/@babel/plugin-transform-runtime/node_modules/babel-plugin-polyfill-corejs3": { "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", "dev": true, "license": "MIT", "dependencies": { @@ -2148,6 +2152,8 @@ }, "node_modules/@csstools/media-query-list-parser": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-3.0.1.tgz", + "integrity": "sha512-HNo8gGD02kHmcbX6PvCoUuOQvn4szyB9ca63vZHKX5A81QytgDG4oxG4IaEfHTlEZSZ6MjPEMWIVU+zF2PZcgw==", "dev": true, "funding": [ { @@ -2404,6 +2410,8 @@ }, "node_modules/@eslint-community/eslint-plugin-eslint-comments": { "version": "4.7.2", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-4.7.2.tgz", + "integrity": "sha512-LF03qURSwEWm2dz5wtdDCzNk+7Opl0X7q6I3undsaIuNsEiNvRV3BCtqu14Q/6Pzg1tBj44LcxpW2EpSLZStZw==", "dev": true, "license": "MIT", "dependencies": { @@ -2457,7 +2465,9 @@ } }, "node_modules/@eslint/compat": { - "version": "2.1.0", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.1.1.tgz", + "integrity": "sha512-rMcy8GSrwNzcISX/BlTDY/GLB4eCopEuy9woIls3To+15OLxykZrxxq+WUcylCPCQ6F4MujjBM1DX5V1aqI3Vw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -6843,6 +6853,8 @@ }, "node_modules/@stylistic/stylelint-plugin": { "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@stylistic/stylelint-plugin/-/stylelint-plugin-3.1.3.tgz", + "integrity": "sha512-85fsmzgsIVmyG3/GFrjuYj6Cz8rAM7IZiPiXCMiSMfoDOC1lOrzrXPDk24WqviAghnPqGpx8b0caK2PuewWGFg==", "dev": true, "license": "MIT", "dependencies": { @@ -7368,6 +7380,17 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, "node_modules/@types/jsdom": { "version": "21.1.7", "dev": true, @@ -7416,6 +7439,8 @@ }, "node_modules/@types/minimist": { "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", "dev": true, "license": "MIT" }, @@ -7449,6 +7474,8 @@ }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "dev": true, "license": "MIT" }, @@ -7492,6 +7519,8 @@ }, "node_modules/@types/react": { "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "dev": true, "license": "MIT", "dependencies": { @@ -7499,14 +7528,6 @@ "csstype": "^3.2.2" } }, - "node_modules/@types/react-dom": { - "version": "18.3.7", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, "node_modules/@types/responselike": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", @@ -8383,7 +8404,9 @@ } }, "node_modules/@wordpress/babel-preset-default": { - "version": "8.52.0", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/babel-preset-default/-/babel-preset-default-8.54.0.tgz", + "integrity": "sha512-L2X5Neh5qTpa7THqBFATmGnfnhXsAGrDCwNYJSJ6pfgnCSyXNPKR07bbU0hreVTl1G+c42di4z7PH8sFdUxLdA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8393,8 +8416,8 @@ "@babel/plugin-transform-runtime": "^7.25.7", "@babel/preset-env": "^7.25.7", "@babel/preset-typescript": "^7.25.7", - "@wordpress/browserslist-config": "^6.52.0", - "@wordpress/warning": "^3.52.0", + "@wordpress/browserslist-config": "^6.54.0", + "@wordpress/warning": "^3.54.0", "browserslist": "^4.28.4", "core-js": "^3.31.0", "react": "^18.3.1" @@ -8564,7 +8587,9 @@ } }, "node_modules/@wordpress/browserslist-config": { - "version": "6.52.0", + "version": "6.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/browserslist-config/-/browserslist-config-6.54.0.tgz", + "integrity": "sha512-6fH6DVILWiAhDgtCvEe8ZMOkKdBaJXXs68cKVnUJVPv8pZTCsJ3zABoUAxoj6U0Kiu65leSO/mMyoP7lJmcgJQ==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8671,19 +8696,21 @@ } }, "node_modules/@wordpress/compose": { - "version": "8.5.0", + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-8.7.0.tgz", + "integrity": "sha512-wx+J+ffWOiKhFsmCLio9dE8JGK1/xh6IIEuVqQ7KV/Hakrwt6C1Zdfyhv+6tiNzYowB9gX2aXi5ESnHtDNzd5Q==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@types/mousetrap": "^1.6.8", - "@wordpress/deprecated": "^4.52.0", - "@wordpress/dom": "^4.52.0", - "@wordpress/element": "^8.4.0", - "@wordpress/is-shallow-equal": "^5.52.0", - "@wordpress/keycodes": "^4.52.0", - "@wordpress/priority-queue": "^3.52.0", - "@wordpress/private-apis": "^1.52.0", - "@wordpress/undo-manager": "^1.52.0", + "@wordpress/deprecated": "^4.54.0", + "@wordpress/dom": "^4.54.0", + "@wordpress/element": "^8.6.0", + "@wordpress/is-shallow-equal": "^5.54.0", + "@wordpress/keycodes": "^4.54.0", + "@wordpress/priority-queue": "^3.54.0", + "@wordpress/private-apis": "^1.54.0", + "@wordpress/undo-manager": "^1.54.0", "change-case": "^4.1.2", "mousetrap": "^1.6.5", "use-memo-one": "^1.1.1" @@ -8795,7 +8822,9 @@ } }, "node_modules/@wordpress/dependency-extraction-webpack-plugin": { - "version": "6.52.0", + "version": "6.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/dependency-extraction-webpack-plugin/-/dependency-extraction-webpack-plugin-6.54.0.tgz", + "integrity": "sha512-ttmz4kHACQlh4LffKBowI1MjdofrYtHITPvkv58r1shBUS+F9BAntwXKAwjdKpQJ3/FGBzWiOS/AZ1KYI+3Y2A==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8810,11 +8839,13 @@ } }, "node_modules/@wordpress/deprecated": { - "version": "4.52.0", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/deprecated/-/deprecated-4.54.0.tgz", + "integrity": "sha512-CpTcvBtDLStxslBm2HvMrQVbZ8cS0iYjKQvaW2vJcJxf+C0CDO0bS86juBnjcxtSRXFPGtBhxx6DKVP6L3pntw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/hooks": "^4.52.0" + "@wordpress/hooks": "^4.54.0" }, "engines": { "node": ">=18.12.0", @@ -8822,11 +8853,13 @@ } }, "node_modules/@wordpress/dom": { - "version": "4.52.0", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/dom/-/dom-4.54.0.tgz", + "integrity": "sha512-HcxOhNVwkFqOakvViddUsSjjkbqHyRhFqmjkPJpiLWWtuIaCzIenPhT/GJID8W8MgLhWFU87krLDXg3yJuLnzQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/deprecated": "^4.52.0" + "@wordpress/deprecated": "^4.54.0" }, "engines": { "node": ">=18.12.0", @@ -8843,7 +8876,9 @@ } }, "node_modules/@wordpress/e2e-test-utils-playwright": { - "version": "1.52.0", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-1.54.0.tgz", + "integrity": "sha512-xpsb5c8T8VXBBrYfr98OqK2yevtiHVwpVE+u0vqYBYgWNiqQJj8Eyq7geRdY6v4SctbAVe8vGFVV+DfPaEhvRg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8863,34 +8898,46 @@ } }, "node_modules/@wordpress/element": { - "version": "8.4.0", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-8.6.0.tgz", + "integrity": "sha512-9stRsEfNoJf7K3jmP4GEvdUPdRqUn/JCYobSsrDzREmaAKNy1OBVW5ZqgK3/s+hbKBlkX8WKU48sTgJ/9aqCzQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@types/react": "^18.3.27", - "@types/react-dom": "^18.3.1", - "@wordpress/deprecated": "^4.52.0", - "@wordpress/escape-html": "^3.52.0", + "@wordpress/deprecated": "^4.54.0", + "@wordpress/escape-html": "^3.54.0", "change-case": "^4.1.2", - "is-plain-object": "^5.0.0", - "react": "^18.3.1", - "react-dom": "^18.3.1" + "is-plain-object": "^5.1.0" }, "engines": { "node": ">=18.12.0", "npm": ">=8.19.2" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "@types/react-dom": "^18 || ^19", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, "node_modules/@wordpress/env": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-11.12.0.tgz", - "integrity": "sha512-P1mYGEgnkYCbBPRaT9uAyxe3hFfsfkvxXKQ+Pfb00z/mXz5xbPC48ZdXTQmxTcpaj0nDwJfrBz0vzc074B5nrA==", + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-11.14.0.tgz", + "integrity": "sha512-Q/XUhGjFJtlfybw/mq+7H5MsF2u3zWH5YxNR5y473+CeIjZx20wt5Dx0RDHAJcG2ICTky4pJevNlIq/EFnUwIQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@inquirer/prompts": "^7.2.0", "@wp-playground/cli": "^3.0.48", - "adm-zip": "^0.5.9", + "adm-zip": "^0.6.0", "chalk": "^4.1.1", "copy-dir": "^1.3.0", "cross-spawn": "^7.0.6", @@ -8911,7 +8958,9 @@ } }, "node_modules/@wordpress/escape-html": { - "version": "3.52.0", + "version": "3.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/escape-html/-/escape-html-3.54.0.tgz", + "integrity": "sha512-dbXSgUZgJ4KcZF9OLO+suayc7PxptZ0qhSE1bgJraD8bnk/jkaCohp5J7WQKzTWw1OSiJtQFZLw2T+BnyqQOpA==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8920,20 +8969,22 @@ } }, "node_modules/@wordpress/eslint-plugin": { - "version": "25.8.0", + "version": "25.10.0", + "resolved": "https://registry.npmjs.org/@wordpress/eslint-plugin/-/eslint-plugin-25.10.0.tgz", + "integrity": "sha512-Lx3LVVVQuhCOfwVQDUL7Mh8c++y/JKHh+GozJmYNEyOosEZGW+LFTWS6GQiqoR3d4qIRJTZ3lRlmRf0Md568aQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/eslint-parser": "^7.28.6", "@eslint-community/eslint-plugin-eslint-comments": "^4.7.0", "@eslint/compat": "^2.0.0", - "@wordpress/babel-preset-default": "^8.52.0", - "@wordpress/prettier-config": "^4.52.0", - "@wordpress/theme": "^1.1.0", + "@wordpress/babel-preset-default": "^8.54.0", + "@wordpress/prettier-config": "^4.54.0", + "@wordpress/theme": "^2.0.0", "cosmiconfig": "^7.0.0", "eslint-config-prettier": "^10.0.0", "eslint-import-resolver-typescript": "^4.4.4", - "eslint-plugin-import": "^2.31.0", + "eslint-plugin-import": "^2.32.0", "eslint-plugin-jest": "^29.16.0", "eslint-plugin-jsdoc": "^50.0.0", "eslint-plugin-jsx-a11y": "^6.10.0", @@ -8966,6 +9017,8 @@ }, "node_modules/@wordpress/eslint-plugin/node_modules/@es-joy/jsdoccomment": { "version": "0.50.2", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.50.2.tgz", + "integrity": "sha512-YAdE/IJSpwbOTiaURNCKECdAwqrJuFiZhylmesBcIRawtYKnBR2wxPhoIewMg+Yu+QuYvHfJNReWpoxGBKOChA==", "dev": true, "license": "MIT", "dependencies": { @@ -8979,8 +9032,67 @@ "node": ">=18" } }, + "node_modules/@wordpress/eslint-plugin/node_modules/@wordpress/style-runtime": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@wordpress/style-runtime/-/style-runtime-0.10.0.tgz", + "integrity": "sha512-yZm2r8qzt++oW+4X2UEHl2FBuO/9O+BazXsQbSjGxcabA2g4apjBH2pEZqKbdbNqlArkj5KZm0LCxY1p6DKYTQ==", + "dev": true, + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@wordpress/eslint-plugin/node_modules/@wordpress/theme": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/theme/-/theme-2.0.0.tgz", + "integrity": "sha512-4yFei1ayJinMOVY6cTzKZV961p2Vjex9Nst1SReIFKv3ckb6ih5QN+qMpJQdpuukwabXrBTaoiA4ZB1akl2CfQ==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/compose": "^8.7.0", + "@wordpress/deprecated": "^4.54.0", + "@wordpress/element": "^8.6.0", + "@wordpress/private-apis": "^1.54.0", + "@wordpress/style-runtime": "^0.10.0", + "colorjs.io": "^0.7.1", + "memize": "^2.1.1" + }, + "engines": { + "node": "^20.19.0 || >=22.13.0", + "npm": ">=10.2.3" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "esbuild": ">=0.27.2 <1.0.0", + "postcss": "^8.0.0", + "react": "^18 || ^19", + "react-dom": "^18 || ^19", + "stylelint": "^16 || ^17", + "vite": "^7 || ^8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "postcss": { + "optional": true + }, + "stylelint": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "node_modules/@wordpress/eslint-plugin/node_modules/comment-parser": { "version": "1.4.1", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", + "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", "dev": true, "license": "MIT", "engines": { @@ -8989,6 +9101,8 @@ }, "node_modules/@wordpress/eslint-plugin/node_modules/cosmiconfig": { "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", "dev": true, "license": "MIT", "dependencies": { @@ -9004,6 +9118,8 @@ }, "node_modules/@wordpress/eslint-plugin/node_modules/eslint-plugin-jsdoc": { "version": "50.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.8.0.tgz", + "integrity": "sha512-UyGb5755LMFWPrZTEqqvTJ3urLz1iqj+bYOHFNag+sw3NvaMWP9K2z+uIn37XfNALmQLQyrBlJ5mkiVPL7ADEg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -9027,6 +9143,8 @@ }, "node_modules/@wordpress/eslint-plugin/node_modules/globals": { "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", "dev": true, "license": "MIT", "engines": { @@ -9038,6 +9156,8 @@ }, "node_modules/@wordpress/eslint-plugin/node_modules/jsdoc-type-pratt-parser": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz", + "integrity": "sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==", "dev": true, "license": "MIT", "engines": { @@ -9046,6 +9166,8 @@ }, "node_modules/@wordpress/eslint-plugin/node_modules/semver": { "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -9057,6 +9179,8 @@ }, "node_modules/@wordpress/eslint-plugin/node_modules/spdx-expression-parse": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9066,6 +9190,8 @@ }, "node_modules/@wordpress/eslint-plugin/node_modules/yaml": { "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "dev": true, "license": "ISC", "engines": { @@ -9094,9 +9220,9 @@ } }, "node_modules/@wordpress/hooks": { - "version": "4.53.0", - "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.53.0.tgz", - "integrity": "sha512-YU30rweJCTu5g7X8bc+w6QyoxJBYLEHH++Wwo/IbbROHUC/4I7e+ZtxnPWls8BTtMP4d28JJ74s+UoKpzd2J7w==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.54.0.tgz", + "integrity": "sha512-9eMe48ixxAd+ILuXe3n+zOc1XlphfPppf0drTuJEjJy0xGXV7eUw459KEvk4KCrznb9pKemHjj4mGjogV5f1CQ==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9114,14 +9240,14 @@ } }, "node_modules/@wordpress/i18n": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-6.26.0.tgz", - "integrity": "sha512-v/LzeB7T5YKIfBgmLM0jYG8zOuu81biZ90j8RY9pHIVu+jdRE+DSmt55S3FvtXRo5J2X9FoigFRzYPKXLbitWA==", + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-6.27.0.tgz", + "integrity": "sha512-6+mBSNGrB9EvnGa33tgNzZ7/vq98yCJ75eX81CU1XBKcoIvPcz5/G+zURpSuoOwAgAYaCxoXMDFX4gJDJjSbaw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@tannin/sprintf": "^1.3.2", - "@wordpress/hooks": "^4.53.0", + "@wordpress/hooks": "^4.54.0", "gettext-parser": "^1.3.1", "tannin": "^1.2.0" }, @@ -9198,7 +9324,9 @@ } }, "node_modules/@wordpress/is-shallow-equal": { - "version": "5.52.0", + "version": "5.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/is-shallow-equal/-/is-shallow-equal-5.54.0.tgz", + "integrity": "sha512-j5mskkhAD3jWrgNSdoSsexos1dxI9EQftfAZAbI2GZwFi6I20cgSrktgCewAUSZDqJ+vPwaJs0bTDhtUDMHXLg==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9207,7 +9335,9 @@ } }, "node_modules/@wordpress/jest-console": { - "version": "9.0.0", + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@wordpress/jest-console/-/jest-console-9.2.0.tgz", + "integrity": "sha512-IzcyKAUrD33xRs+jq0wWsiyB76pL/ddORxOPj21CXdq1/NirMwOLcrPsIymlJvi6G8ZcxqeCWMBmAPtskSMz/Q==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -9223,11 +9353,13 @@ } }, "node_modules/@wordpress/jest-preset-default": { - "version": "13.0.0", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/jest-preset-default/-/jest-preset-default-14.1.0.tgz", + "integrity": "sha512-TE2cSni2tEsmOcpWWCVCI9xKvLGxeTQRJEiHJWZ84qJKndk5eXCO3ANZFXO6whW7Sr/8AG/lXH2vUG/odk9uBg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/jest-console": "^9.0.0", + "@wordpress/jest-console": "^9.2.0", "babel-jest": "^30.4.1", "change-case": "^4.1.2" }, @@ -9264,11 +9396,13 @@ } }, "node_modules/@wordpress/keycodes": { - "version": "4.52.0", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-4.54.0.tgz", + "integrity": "sha512-WeaKup+THfx62hGOOVZpeK0AvEA0d3xwWo2P44pUbUKaBFydTXWqmm7HNsZ7bX9lDdk7JshuLSwhpHfwFPOQEw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/i18n": "^6.25.0" + "@wordpress/i18n": "^6.27.0" }, "engines": { "node": ">=18.12.0", @@ -9308,7 +9442,9 @@ } }, "node_modules/@wordpress/npm-package-json-lint-config": { - "version": "5.52.0", + "version": "5.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/npm-package-json-lint-config/-/npm-package-json-lint-config-5.54.0.tgz", + "integrity": "sha512-T3041Brq2tD9ugUVBWj1P3/VWn87dKEpn3O9r987wdZ4MjLBpWaoER066MHISzB/DE0fSK0SuM0KGRT+30kpsQ==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9320,12 +9456,14 @@ } }, "node_modules/@wordpress/postcss-plugins-preset": { - "version": "5.52.0", + "version": "5.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/postcss-plugins-preset/-/postcss-plugins-preset-5.54.0.tgz", + "integrity": "sha512-T54ouByzhopTMfCwvCtgK+KdmBzf7+a/0Mv7NFfQuXYP3+kFV+SyyYUnRC5Js3SnDANL2Ui0RLil3M2e8SIzUw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/base-styles": "^12.0.0", - "@wordpress/browserslist-config": "^6.52.0", + "@wordpress/base-styles": "^13.0.0", + "@wordpress/browserslist-config": "^6.54.0", "autoprefixer": "^10.4.21", "postcss-import": "^16.1.1" }, @@ -9337,6 +9475,17 @@ "postcss": "^8.0.0" } }, + "node_modules/@wordpress/postcss-plugins-preset/node_modules/@wordpress/base-styles": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/base-styles/-/base-styles-13.0.0.tgz", + "integrity": "sha512-IBDH2iG+U373NiY8l6UVNvjMXKVtYN/iFaLpavt1vO/I/S2aOE+m693+iMJv9ELC5oYQZSXzTWh+p/1RjmmC6w==", + "dev": true, + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, "node_modules/@wordpress/preferences": { "version": "4.52.0", "dev": true, @@ -9370,7 +9519,9 @@ } }, "node_modules/@wordpress/prettier-config": { - "version": "4.52.0", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-4.54.0.tgz", + "integrity": "sha512-uwiRJ3S2d/T/+ZwB2HjehVfu3rw+LYBjSiJcE/CSxbBC7eobtQ3WZsNsUc5p1M7Be6orpowtl2wtAFg/bUDJuA==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9404,7 +9555,9 @@ } }, "node_modules/@wordpress/priority-queue": { - "version": "3.52.0", + "version": "3.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/priority-queue/-/priority-queue-3.54.0.tgz", + "integrity": "sha512-YOJAKb80OFzHCdamyfKu9L3k+AZWexo5f1Jjm8HFs3+6CIxmkPyTe7uYccpRvmgag8MkVAdus/VaO5thzOy+gA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -9416,9 +9569,9 @@ } }, "node_modules/@wordpress/private-apis": { - "version": "1.53.0", - "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-1.53.0.tgz", - "integrity": "sha512-xXW2yeBC0n/doFLsv6chEl3Qs5phqmRH7wcUyil+Xvh3msVGzUJxvyo0szmKpQcmoYafASpkQux82HCtNp6jxQ==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-1.54.0.tgz", + "integrity": "sha512-OOZXNT5O0D+w6pPipc0RYIfxxNQm8ahzQjIyYkVq6Qep6sq9sXut6cjgiznOApIywQNCsVOn/S34anZ4f2ONMg==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9475,24 +9628,26 @@ } }, "node_modules/@wordpress/scripts": { - "version": "34.0.0", + "version": "34.2.0", + "resolved": "https://registry.npmjs.org/@wordpress/scripts/-/scripts-34.2.0.tgz", + "integrity": "sha512-BDSNYJgx6c8V6IJZMLc8hjQXt4+qzJ3JHwKRmWBqupFj85nKpcrZbRXpTzLxULrx4wfpCCnvt27R2zw7xmVCKg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/core": "^7.25.7", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.11", "@svgr/webpack": "^8.0.1", - "@wordpress/babel-preset-default": "^8.52.0", - "@wordpress/browserslist-config": "^6.52.0", - "@wordpress/dependency-extraction-webpack-plugin": "^6.52.0", - "@wordpress/e2e-test-utils-playwright": "^1.52.0", - "@wordpress/eslint-plugin": "^25.8.0", - "@wordpress/jest-preset-default": "^13.0.0", - "@wordpress/npm-package-json-lint-config": "^5.52.0", - "@wordpress/postcss-plugins-preset": "^5.52.0", - "@wordpress/prettier-config": "^4.52.0", - "@wordpress/stylelint-config": "^24.1.0", - "adm-zip": "^0.5.9", + "@wordpress/babel-preset-default": "^8.54.0", + "@wordpress/browserslist-config": "^6.54.0", + "@wordpress/dependency-extraction-webpack-plugin": "^6.54.0", + "@wordpress/e2e-test-utils-playwright": "^1.54.0", + "@wordpress/eslint-plugin": "^25.10.0", + "@wordpress/jest-preset-default": "^14.1.0", + "@wordpress/npm-package-json-lint-config": "^5.54.0", + "@wordpress/postcss-plugins-preset": "^5.54.0", + "@wordpress/prettier-config": "^4.54.0", + "@wordpress/stylelint-config": "^24.3.0", + "adm-zip": "^0.6.0", "babel-jest": "^30.4.1", "babel-loader": "^9.2.1", "browserslist": "^4.28.4", @@ -9793,12 +9948,14 @@ } }, "node_modules/@wordpress/stylelint-config": { - "version": "24.1.0", + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/@wordpress/stylelint-config/-/stylelint-config-24.3.0.tgz", + "integrity": "sha512-DfuhPR402icUZd/6MyWRm6jwr7tY5Fbtt+sswFVql8Z+95sNe0kdS4vJyAwhRGWNnZkJ1SuQk6ye6nHFu+cm/g==", "dev": true, "license": "MIT", "dependencies": { "@stylistic/stylelint-plugin": "^3.1.3", - "@wordpress/theme": "^1.1.0", + "@wordpress/theme": "^2.0.0", "stylelint-config-recommended": "^14.0.1", "stylelint-config-recommended-scss": "^14.1.0" }, @@ -9811,6 +9968,63 @@ "stylelint-scss": "^6.4.0" } }, + "node_modules/@wordpress/stylelint-config/node_modules/@wordpress/style-runtime": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@wordpress/style-runtime/-/style-runtime-0.10.0.tgz", + "integrity": "sha512-yZm2r8qzt++oW+4X2UEHl2FBuO/9O+BazXsQbSjGxcabA2g4apjBH2pEZqKbdbNqlArkj5KZm0LCxY1p6DKYTQ==", + "dev": true, + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@wordpress/stylelint-config/node_modules/@wordpress/theme": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/theme/-/theme-2.0.0.tgz", + "integrity": "sha512-4yFei1ayJinMOVY6cTzKZV961p2Vjex9Nst1SReIFKv3ckb6ih5QN+qMpJQdpuukwabXrBTaoiA4ZB1akl2CfQ==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/compose": "^8.7.0", + "@wordpress/deprecated": "^4.54.0", + "@wordpress/element": "^8.6.0", + "@wordpress/private-apis": "^1.54.0", + "@wordpress/style-runtime": "^0.10.0", + "colorjs.io": "^0.7.1", + "memize": "^2.1.1" + }, + "engines": { + "node": "^20.19.0 || >=22.13.0", + "npm": ">=10.2.3" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "esbuild": ">=0.27.2 <1.0.0", + "postcss": "^8.0.0", + "react": "^18 || ^19", + "react-dom": "^18 || ^19", + "stylelint": "^16 || ^17", + "vite": "^7 || ^8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "postcss": { + "optional": true + }, + "stylelint": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "node_modules/@wordpress/theme": { "version": "1.1.0", "dev": true, @@ -9899,11 +10113,13 @@ } }, "node_modules/@wordpress/undo-manager": { - "version": "1.52.0", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/undo-manager/-/undo-manager-1.54.0.tgz", + "integrity": "sha512-6b10QkKqvvWl1v67CXT04V/eM9dOzSoTcu7ZenA8ZmImLjYT1fDJ5ckJnldY5HkCMO8STd6gnJMgs/ZkXYv35A==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/is-shallow-equal": "^5.52.0" + "@wordpress/is-shallow-equal": "^5.54.0" }, "engines": { "node": ">=18.12.0", @@ -9983,7 +10199,9 @@ } }, "node_modules/@wordpress/warning": { - "version": "3.52.0", + "version": "3.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-3.54.0.tgz", + "integrity": "sha512-0QWxBc/CHQdgzTwnUg+Ha5Ir0qyuIQUDzA2gmGSbbVshgm3d5FugLGpZHL1/S2+KlczyjKS2fftDJmK1dxdsEg==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -10325,11 +10543,13 @@ } }, "node_modules/adm-zip": { - "version": "0.5.18", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz", + "integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==", "dev": true, "license": "MIT", "engines": { - "node": ">=12.0" + "node": ">=14.0" } }, "node_modules/agent-base": { @@ -10371,6 +10591,8 @@ }, "node_modules/ajv-errors": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz", + "integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -10723,6 +10945,8 @@ }, "node_modules/arrify": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", "dev": true, "license": "MIT", "engines": { @@ -10791,7 +11015,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.5.4", + "version": "10.5.5", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.5.tgz", + "integrity": "sha512-uiRYvQYe/nNSzBJ7OUnd2/TZVsAdob3blml44teEpee9Cc1f4rGZFewO+JT3Wo8mgFOSzNqes4FHZn/Qz8WOuw==", "dev": true, "funding": [ { @@ -10809,8 +11035,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.6", - "caniuse-lite": "^1.0.30001806", + "browserslist": "^4.28.9", + "caniuse-lite": "^1.0.30001810", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -11156,7 +11382,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.12", + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -11296,7 +11524,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.7", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "dev": true, "funding": [ { @@ -11314,11 +11544,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.44", - "caniuse-lite": "^1.0.30001806", - "electron-to-chromium": "^1.5.393", - "node-releases": "^2.0.51", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -11556,6 +11786,8 @@ }, "node_modules/camelcase-keys": { "version": "6.2.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", + "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", "dev": true, "license": "MIT", "dependencies": { @@ -11572,6 +11804,8 @@ }, "node_modules/camelcase-keys/node_modules/camelcase": { "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "dev": true, "license": "MIT", "engines": { @@ -11590,7 +11824,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001806", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -12181,6 +12417,8 @@ }, "node_modules/core-js": { "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -12650,6 +12888,8 @@ }, "node_modules/decamelize": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", "dev": true, "license": "MIT", "engines": { @@ -12658,6 +12898,8 @@ }, "node_modules/decamelize-keys": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", + "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", "dev": true, "license": "MIT", "dependencies": { @@ -12673,6 +12915,8 @@ }, "node_modules/decamelize-keys/node_modules/map-obj": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", "dev": true, "license": "MIT", "engines": { @@ -13098,7 +13342,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.401", + "version": "1.5.422", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", + "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", "dev": true, "license": "ISC" }, @@ -13900,6 +14146,8 @@ }, "node_modules/eslint-plugin-playwright": { "version": "2.11.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.11.0.tgz", + "integrity": "sha512-zOIEYyv1TSn2izXkyg/yj0LXc4YBQI6pl3xIFWwMCcXt/dgQCz8dBqXiXFMoM3xc/GqZThAtaGYo8aPu/vBd+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -14829,6 +15077,8 @@ }, "node_modules/fraction.js": { "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", "dev": true, "license": "MIT", "engines": { @@ -15365,6 +15615,8 @@ }, "node_modules/hard-rejection": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", + "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", "dev": true, "license": "MIT", "engines": { @@ -15509,6 +15761,8 @@ }, "node_modules/hosted-git-info": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", "dev": true, "license": "ISC", "dependencies": { @@ -15520,6 +15774,8 @@ }, "node_modules/hosted-git-info/node_modules/lru-cache": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, "license": "ISC", "dependencies": { @@ -15531,6 +15787,8 @@ }, "node_modules/hosted-git-info/node_modules/yallist": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true, "license": "ISC" }, @@ -16029,6 +16287,8 @@ }, "node_modules/irregular-plurals": { "version": "3.5.0", + "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-3.5.0.tgz", + "integrity": "sha512-1ANGLZ+Nkv1ptFb2pa8oG8Lem4krflKuX/gINiHJHjJUKaJHk/SXk5x6K3J+39/p0h1RQ2saROclJJ+QLvETCQ==", "dev": true, "license": "MIT", "engines": { @@ -16385,7 +16645,9 @@ } }, "node_modules/is-plain-object": { - "version": "5.0.0", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz", + "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==", "dev": true, "license": "MIT", "engines": { @@ -16502,6 +16764,8 @@ }, "node_modules/is-unicode-supported": { "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true, "license": "MIT", "engines": { @@ -17414,6 +17678,8 @@ }, "node_modules/json2php": { "version": "0.0.9", + "resolved": "https://registry.npmjs.org/json2php/-/json2php-0.0.9.tgz", + "integrity": "sha512-fQMYwvPsQt8hxRnCGyg1r2JVi6yL8Um0DIIawiKiMK9yhAAkcRNj5UsBWoaFvFzPpcWbgw9L6wzj+UMYA702Mw==", "dev": true, "license": "BSD" }, @@ -17845,6 +18111,8 @@ }, "node_modules/log-symbols": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "dev": true, "license": "MIT", "dependencies": { @@ -17935,6 +18203,8 @@ }, "node_modules/map-obj": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", + "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", "dev": true, "license": "MIT", "engines": { @@ -18213,6 +18483,8 @@ }, "node_modules/meow": { "version": "9.0.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz", + "integrity": "sha512-+obSblOQmRhcyBt62furQqRAQpNyWXo8BuQ5bN7dG8wmwQ+vwHKp/rCFD4CrTP8CsDQD1sjoZ94K417XEUk8IQ==", "dev": true, "license": "MIT", "dependencies": { @@ -18238,6 +18510,8 @@ }, "node_modules/meow/node_modules/type-fest": { "version": "0.18.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", + "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -18249,6 +18523,8 @@ }, "node_modules/meow/node_modules/yargs-parser": { "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", "dev": true, "license": "ISC", "engines": { @@ -18362,6 +18638,8 @@ }, "node_modules/min-indent": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", "dev": true, "license": "MIT", "engines": { @@ -18416,6 +18694,8 @@ }, "node_modules/minimist-options": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", + "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", "dev": true, "license": "MIT", "dependencies": { @@ -18429,6 +18709,8 @@ }, "node_modules/minimist-options/node_modules/is-plain-obj": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", "dev": true, "license": "MIT", "engines": { @@ -18723,7 +19005,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.52", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, "license": "MIT", "engines": { @@ -18732,6 +19016,8 @@ }, "node_modules/normalize-package-data": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", + "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -18746,6 +19032,8 @@ }, "node_modules/normalize-package-data/node_modules/semver": { "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -18796,6 +19084,8 @@ }, "node_modules/npm-package-json-lint": { "version": "6.4.0", + "resolved": "https://registry.npmjs.org/npm-package-json-lint/-/npm-package-json-lint-6.4.0.tgz", + "integrity": "sha512-cuXAJJB1Rdqz0UO6w524matlBqDBjcNt7Ru+RDIu4y6RI1gVqiWBnylrK8sPRk81gGBA0X8hJbDXolVOoTc+sA==", "dev": true, "license": "MIT", "dependencies": { @@ -18827,6 +19117,8 @@ }, "node_modules/npm-package-json-lint/node_modules/array-union": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true, "license": "MIT", "engines": { @@ -18835,6 +19127,8 @@ }, "node_modules/npm-package-json-lint/node_modules/globby": { "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", "dev": true, "license": "MIT", "dependencies": { @@ -18854,6 +19148,8 @@ }, "node_modules/npm-package-json-lint/node_modules/ignore": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { @@ -18862,11 +19158,15 @@ }, "node_modules/npm-package-json-lint/node_modules/jsonc-parser": { "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "dev": true, "license": "MIT" }, "node_modules/npm-package-json-lint/node_modules/semver": { "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -18878,6 +19178,8 @@ }, "node_modules/npm-package-json-lint/node_modules/type-fest": { "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -19768,14 +20070,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { - "version": "2.3.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/pirates": { "version": "4.0.7", "dev": true, @@ -19932,6 +20226,8 @@ }, "node_modules/plur": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/plur/-/plur-4.0.0.tgz", + "integrity": "sha512-4UGewrYgqDFw9vV6zNV+ADmPAUAfJPKtGvb/VdpQAx25X5f3xXdGdyOEVFwkl8Hl/tl7+xbeHqSEM+D5/TirUg==", "dev": true, "license": "MIT", "dependencies": { @@ -20071,7 +20367,9 @@ } }, "node_modules/postcss-import": { - "version": "16.1.1", + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-16.2.0.tgz", + "integrity": "sha512-0mQUGlSp87Zl70K58RNwQAN1WS9plFE6KWGui9nyK6ninduMyjW/Khaoy/BpBoFTBh7ndAvAKrSKVbwy6vd/BA==", "dev": true, "license": "MIT", "dependencies": { @@ -20143,6 +20441,8 @@ }, "node_modules/postcss-media-query-parser": { "version": "0.2.3", + "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", + "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", "dev": true, "license": "MIT" }, @@ -20525,6 +20825,8 @@ }, "node_modules/postcss-scss": { "version": "4.0.9", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", + "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", "dev": true, "funding": [ { @@ -20927,6 +21229,8 @@ }, "node_modules/quick-lru": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", + "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", "dev": true, "license": "MIT", "engines": { @@ -21028,6 +21332,7 @@ "version": "18.3.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -21141,15 +21446,16 @@ } }, "node_modules/read-cache": { - "version": "1.0.0", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz", + "integrity": "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA==", "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } + "license": "MIT" }, "node_modules/read-pkg": { "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", "dev": true, "license": "MIT", "dependencies": { @@ -21164,6 +21470,8 @@ }, "node_modules/read-pkg-up": { "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", "dev": true, "license": "MIT", "dependencies": { @@ -21180,6 +21488,8 @@ }, "node_modules/read-pkg-up/node_modules/type-fest": { "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -21188,11 +21498,15 @@ }, "node_modules/read-pkg/node_modules/hosted-git-info": { "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", "dev": true, "license": "ISC" }, "node_modules/read-pkg/node_modules/normalize-package-data": { "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -21204,6 +21518,8 @@ }, "node_modules/read-pkg/node_modules/semver": { "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "dev": true, "license": "ISC", "bin": { @@ -21212,6 +21528,8 @@ }, "node_modules/read-pkg/node_modules/type-fest": { "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -21256,6 +21574,8 @@ }, "node_modules/redent": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", "dev": true, "license": "MIT", "dependencies": { @@ -21418,6 +21738,8 @@ }, "node_modules/requireindex": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz", + "integrity": "sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==", "dev": true, "license": "MIT", "engines": { @@ -21797,6 +22119,7 @@ "version": "0.23.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -22430,6 +22753,8 @@ }, "node_modules/spdx-correct": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -22439,6 +22764,8 @@ }, "node_modules/spdx-correct/node_modules/spdx-expression-parse": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -22836,6 +23163,8 @@ }, "node_modules/strip-indent": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -22887,6 +23216,8 @@ }, "node_modules/style-search": { "version": "0.1.0", + "resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz", + "integrity": "sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==", "dev": true, "license": "ISC" }, @@ -22969,6 +23300,8 @@ }, "node_modules/stylelint-config-recommended": { "version": "14.0.1", + "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-14.0.1.tgz", + "integrity": "sha512-bLvc1WOz/14aPImu/cufKAZYfXs/A/owZfSMZ4N+16WGXLoX5lOir53M6odBxvhgmgdxCVnNySJmZKx73T93cg==", "dev": true, "funding": [ { @@ -22990,6 +23323,8 @@ }, "node_modules/stylelint-config-recommended-scss": { "version": "14.1.0", + "resolved": "https://registry.npmjs.org/stylelint-config-recommended-scss/-/stylelint-config-recommended-scss-14.1.0.tgz", + "integrity": "sha512-bhaMhh1u5dQqSsf6ri2GVWWQW5iUjBYgcHkh7SgDDn92ijoItC/cfO/W+fpXshgTQWhwFkP1rVcewcv4jaftRg==", "dev": true, "license": "MIT", "dependencies": { @@ -23012,6 +23347,8 @@ }, "node_modules/stylelint-scss": { "version": "6.14.0", + "resolved": "https://registry.npmjs.org/stylelint-scss/-/stylelint-scss-6.14.0.tgz", + "integrity": "sha512-ZKmHMZolxeuYsnB+PCYrTpFce0/QWX9i9gh0hPXzp73WjuIMqUpzdQaBCrKoLWh6XtCFSaNDErkMPqdjy1/8aA==", "dev": true, "license": "MIT", "dependencies": { @@ -23032,7 +23369,9 @@ } }, "node_modules/stylelint-scss/node_modules/postcss-selector-parser": { - "version": "7.1.4", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -23949,6 +24288,8 @@ }, "node_modules/trim-newlines": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", + "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==", "dev": true, "license": "MIT", "engines": { @@ -24136,9 +24477,10 @@ }, "node_modules/typescript": { "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -24305,7 +24647,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { @@ -24497,6 +24841,8 @@ }, "node_modules/validate-npm-package-license": { "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -24506,6 +24852,8 @@ }, "node_modules/validate-npm-package-license/node_modules/spdx-expression-parse": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -24515,6 +24863,8 @@ }, "node_modules/validate-npm-package-name": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", + "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", "dev": true, "license": "ISC", "engines": { diff --git a/package.json b/package.json index 6039b5d..b46344f 100644 --- a/package.json +++ b/package.json @@ -16,34 +16,34 @@ "devDependencies": { "@automattic/eslint-plugin-wpvip": "^1.3.0", "@playwright/test": "^1.62.1", + "@types/jest": "^30.0.0", + "@types/react": "^18.3.31", "@wordpress/api-fetch": "^7.53.0", "@wordpress/block-editor": "^16.1.0", "@wordpress/components": "^38.0.0", - "@wordpress/compose": "^8.5.0", "@wordpress/data": "^10.52.0", - "@wordpress/element": "^8.4.0", "@wordpress/e2e-test-utils-playwright": "^1.52.0", + "@wordpress/element": "^8.4.0", "@wordpress/env": "^11.12.0", "@wordpress/hooks": "^4.52.0", "@wordpress/i18n": "^6.25.0", "@wordpress/notices": "^5.52.0", "@wordpress/scripts": "^34.0.0", - "babel-jest": "^30.4.1", "eslint": "^9.39.5", "lint-staged": "^17.3.0", "prettier": "npm:wp-prettier@3.0.3", "simple-git-hooks": "^2.13.1", - "webpack": "^5.109.2" + "typescript": "~6.0.3" }, "scripts": { - "build": "webpack --mode production", - "dev": "webpack --watch", + "build": "wp-scripts build", + "dev": "wp-scripts start", "cmd:format": "prettier '**/*.(js|json|jsx|md|ts|tsx|yml|yaml)'", "cmd:lint:js": "eslint '**/*.{js,jsx,ts,tsx}' --quiet", "format": "npm run cmd:format -- --write", "format:check": "npm run cmd:format -- --check", - "lint": "npm run format:check && npm run lint:js && npm run lint:php", - "lint:fix": "npm run cmd:lint:js . -- --fix", + "lint": "npm run format:check && npm run lint:js && npm run typecheck && npm run lint:php", + "lint:fix": "npm run cmd:lint:js -- --fix", "lint:js": "npm run cmd:lint:js", "lint:php": "npm run lint:phpcs", "lint:phpcs": "vendor/squizlabs/php_codesniffer/bin/phpcs --cache", @@ -57,11 +57,12 @@ "test:e2e": "npx playwright test", "test:php": "composer run test", "test:js": "wp-scripts test-unit-js", + "typecheck": "tsc --noEmit", "wp-cli": "wp-env run cli -- wp" }, "jest": { "testMatch": [ - "/src/**/*.test.js" + "/src/**/*.test.[jt]s?(x)" ], "preset": "@wordpress/jest-preset-default", "testPathIgnorePatterns": [ diff --git a/playwright.config.js b/playwright.config.ts similarity index 95% rename from playwright.config.js rename to playwright.config.ts index fa6a1f3..f60a165 100644 --- a/playwright.config.js +++ b/playwright.config.ts @@ -15,7 +15,7 @@ const config = defineConfig( { timeout: parseInt( process.env.TIMEOUT || '', 10 ) || 100_000, // Defaults to 100 seconds. // Don't report slow test "files", as we will be running our tests in serial. reportSlowTests: null, - globalSetup: require.resolve( './tests/e2e/globalSetup.js' ), + globalSetup: require.resolve( './tests/e2e/globalSetup.ts' ), testDir: 'tests/e2e', outputDir: path.join( process.cwd(), 'artifacts/test-results' ), snapshotPathTemplate: '{testDir}/{testFileDir}/__snapshots__/{arg}-{projectName}{ext}', diff --git a/src/block-locking.jsx b/src/block-locking.jsx deleted file mode 100644 index 6fd12fa..0000000 --- a/src/block-locking.jsx +++ /dev/null @@ -1,105 +0,0 @@ -/** - * WordPress dependencies - */ -import { store as blockEditorStore, useBlockEditingMode } from '@wordpress/block-editor'; -import { Disabled } from '@wordpress/components'; -import { createHigherOrderComponent } from '@wordpress/compose'; -import { select } from '@wordpress/data'; -import { createContext, useContext } from '@wordpress/element'; -import { addFilter, applyFilters } from '@wordpress/hooks'; - -/** - * Internal dependencies - */ -import { isBlockAllowedInHierarchy } from './block-utils'; - -const LOCKED_BLOCK_STYLES = { - opacity: 0.6, - backgroundColor: '#eee', - border: '2px dashed #999', -}; - -const GovernanceLockContext = createContext( false ); - -export function setupBlockLocking( governanceRules ) { - const withDisabledBlocks = createHigherOrderComponent( BlockEdit => { - return function GovernedBlockEdit( props ) { - const { name: blockName, clientId } = props; - const isParentLocked = useContext( GovernanceLockContext ); - const { getBlockParents, getBlockName } = select( blockEditorStore ); - const parentBlockNames = getBlockParents( clientId, true ).map( getBlockName ); - - /** - * Change what blocks are allowed to be edited in the block editor. - * - * @param {bool} isAllowed Whether or not the block will be allowed. - * @param {string} blockName The name of the block to be edited. - * @param {string[]} parentBlockNames An array of zero or more parent block names, - * starting with the most recent parent ancestor. - * @param {Object} governanceRules An object containing the full set of governance - * rules for the current user. - */ - const isAllowed = isBlockAllowedForEditing( - blockName, - parentBlockNames, - governanceRules, - isParentLocked - ); - - useBlockEditingMode( isAllowed ? undefined : 'disabled' ); - - if ( isAllowed ) { - return ( - - - - ); - } - - return ( - - -
- -
-
-
- ); - }; - }, 'withDisabledBlocks' ); - - addFilter( 'editor.BlockEdit', 'wpcomvip-governance/with-disabled-blocks', withDisabledBlocks ); -} - -/** - * Determine whether a block should remain editable. - * - * Children inherit a locked parent's editing mode, so they do not need another - * disabled wrapper or another invocation of the public editing filter. - * - * @param {string} blockName Current block name. - * @param {string[]} parentBlockNames Parent names, nearest parent first. - * @param {Object} governanceRules Effective governance rules. - * @param {boolean} isParentLocked Whether governance locked an ancestor. - * @return {boolean} Whether the block should remain editable. - */ -export function isBlockAllowedForEditing( - blockName, - parentBlockNames, - governanceRules, - isParentLocked -) { - if ( isParentLocked ) { - return true; - } - - const isAllowed = isBlockAllowedInHierarchy( blockName, parentBlockNames, governanceRules ); - - return applyFilters( - 'vip_governance__is_block_allowed_for_editing', - isAllowed, - blockName, - parentBlockNames, - governanceRules - ); -} diff --git a/src/block-locking.test.js b/src/block-locking.test.js deleted file mode 100644 index fb355bb..0000000 --- a/src/block-locking.test.js +++ /dev/null @@ -1,63 +0,0 @@ -import { applyFilters } from '@wordpress/hooks'; - -import { isBlockAllowedForEditing } from './block-locking'; -import { isBlockAllowedInHierarchy } from './block-utils'; - -jest.mock( '@wordpress/block-editor', () => ( { - store: 'core/block-editor', - useBlockEditingMode: jest.fn(), -} ) ); - -jest.mock( '@wordpress/components', () => ( { - Disabled: jest.fn(), -} ) ); - -jest.mock( '@wordpress/compose', () => ( { - createHigherOrderComponent: jest.fn(), -} ) ); - -jest.mock( '@wordpress/element', () => ( { - createContext: jest.fn( () => ( { Provider: jest.fn() } ) ), - useContext: jest.fn(), -} ) ); - -jest.mock( '@wordpress/hooks', () => ( { - addFilter: jest.fn(), - applyFilters: jest.fn(), -} ) ); - -jest.mock( './block-utils', () => ( { - isBlockAllowedInHierarchy: jest.fn(), -} ) ); - -describe( 'isBlockAllowedForEditing', () => { - const governanceRules = { allowedBlocks: [ 'core/paragraph' ] }; - - beforeEach( () => { - jest.clearAllMocks(); - } ); - - it( 'applies the public editing filter to the hierarchy decision', () => { - isBlockAllowedInHierarchy.mockReturnValue( false ); - applyFilters.mockReturnValue( true ); - - expect( - isBlockAllowedForEditing( 'core/heading', [ 'core/group' ], governanceRules, false ) - ).toBe( true ); - expect( applyFilters ).toHaveBeenCalledWith( - 'vip_governance__is_block_allowed_for_editing', - false, - 'core/heading', - [ 'core/group' ], - governanceRules - ); - } ); - - it( 'allows a child to inherit an existing parent lock without reevaluating it', () => { - expect( - isBlockAllowedForEditing( 'core/heading', [ 'core/group' ], governanceRules, true ) - ).toBe( true ); - expect( isBlockAllowedInHierarchy ).not.toHaveBeenCalled(); - expect( applyFilters ).not.toHaveBeenCalled(); - } ); -} ); diff --git a/src/block-locking.test.tsx b/src/block-locking.test.tsx new file mode 100644 index 0000000..8be3bc7 --- /dev/null +++ b/src/block-locking.test.tsx @@ -0,0 +1,124 @@ +import { useBlockEditingMode } from '@wordpress/block-editor'; +import { useSelect } from '@wordpress/data'; +import { useContext } from '@wordpress/element'; +import { addFilter, applyFilters } from '@wordpress/hooks'; + +import { + createGovernedBlockEdit, + isBlockAllowedForEditing, + setupBlockLocking, + type BlockEditProps, +} from './block-locking'; +import { isBlockAllowedInHierarchy } from './block-utils'; + +import type { ReactElement } from 'react'; + +jest.mock( '@wordpress/block-editor', () => ( { + store: 'core/block-editor', + useBlockEditingMode: jest.fn(), +} ) ); + +jest.mock( '@wordpress/components', () => ( { + Disabled: jest.fn(), +} ) ); + +jest.mock( '@wordpress/data', () => ( { + useSelect: jest.fn(), +} ) ); + +jest.mock( '@wordpress/element', () => ( { + createContext: jest.fn( () => ( { Provider: jest.fn() } ) ), + useContext: jest.fn(), +} ) ); + +jest.mock( '@wordpress/hooks', () => ( { + addFilter: jest.fn(), + applyFilters: jest.fn(), +} ) ); + +jest.mock( './block-utils', () => ( { + isBlockAllowedInHierarchy: jest.fn(), +} ) ); + +describe( 'block locking', () => { + type ProviderElement = ReactElement< { value: boolean; children: ReactElement } >; + + const governanceRules = { allowedBlocks: [ 'core/paragraph' ] }; + const props = { clientId: 'current', name: 'core/heading' }; + + beforeEach( () => { + jest.clearAllMocks(); + jest.mocked( useSelect ).mockReturnValue( [ 'core/group' ] ); + jest.mocked( useContext ).mockReturnValue( false ); + } ); + + it( 'applies the public editing filter to the hierarchy decision', () => { + jest.mocked( isBlockAllowedInHierarchy ).mockReturnValue( false ); + jest.mocked( applyFilters ).mockReturnValue( true ); + + expect( + isBlockAllowedForEditing( 'core/heading', [ 'core/group' ], governanceRules, false ) + ).toBe( true ); + expect( applyFilters ).toHaveBeenCalledWith( + 'vip_governance__is_block_allowed_for_editing', + false, + 'core/heading', + [ 'core/group' ], + governanceRules + ); + } ); + + it( 'allows a child to inherit an existing parent lock without reevaluating it', () => { + expect( + isBlockAllowedForEditing( 'core/heading', [ 'core/group' ], governanceRules, true ) + ).toBe( true ); + expect( isBlockAllowedInHierarchy ).not.toHaveBeenCalled(); + expect( applyFilters ).not.toHaveBeenCalled(); + } ); + + it( 'keeps allowed blocks editable without adding a redundant provider', () => { + jest.mocked( isBlockAllowedInHierarchy ).mockReturnValue( true ); + jest.mocked( applyFilters ).mockReturnValue( true ); + const BlockEdit = jest.fn( () => null ); + const GovernedBlockEdit = createGovernedBlockEdit( BlockEdit, governanceRules ) as ( + blockProps: BlockEditProps + ) => ReactElement; + + const result = GovernedBlockEdit( props ); + + expect( useBlockEditingMode ).toHaveBeenCalledWith( undefined ); + expect( result.type ).toBe( BlockEdit ); + } ); + + it( 'disables a governed block and propagates the locked state', () => { + jest.mocked( isBlockAllowedInHierarchy ).mockReturnValue( false ); + jest.mocked( applyFilters ).mockReturnValue( false ); + const BlockEdit = jest.fn( () => null ); + const GovernedBlockEdit = createGovernedBlockEdit( BlockEdit, governanceRules ) as ( + blockProps: BlockEditProps + ) => ProviderElement; + + const result = GovernedBlockEdit( props ); + + expect( useBlockEditingMode ).toHaveBeenCalledWith( 'disabled' ); + expect( result.props.value ).toBe( true ); + const disabledElement = result.props.children as ReactElement< { + children: ReactElement< { style: Record< string, string | number > } >; + } >; + expect( disabledElement.props.children.props.style ).toEqual( { + opacity: 0.6, + backgroundColor: '#eee', + border: '2px dashed #999', + } ); + } ); + + it( 'registers the Gutenberg BlockEdit wrapper once', () => { + setupBlockLocking( governanceRules ); + + expect( addFilter ).toHaveBeenCalledWith( + 'editor.BlockEdit', + 'wpcomvip-governance/with-disabled-blocks', + expect.any( Function ) + ); + } ); +} ); diff --git a/src/block-locking.tsx b/src/block-locking.tsx new file mode 100644 index 0000000..4383151 --- /dev/null +++ b/src/block-locking.tsx @@ -0,0 +1,101 @@ +import { store as blockEditorStore, useBlockEditingMode } from '@wordpress/block-editor'; +import { Disabled } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; +import { createContext, useContext } from '@wordpress/element'; +import { addFilter, applyFilters } from '@wordpress/hooks'; + +import { isBlockAllowedInHierarchy } from './block-utils'; + +import type { BlockEditorSelectors, BlockName, ClientId, GovernanceRules } from './types'; +import type { ComponentType } from 'react'; + +const LOCKED_BLOCK_STYLES = { + opacity: 0.6, + backgroundColor: '#eee', + border: '2px dashed #999', +}; + +const GovernanceLockContext = createContext( false ); + +export interface BlockEditProps { + clientId: ClientId; + name: BlockName; + [ key: string ]: unknown; +} + +type BlockEditComponent = ComponentType< BlockEditProps >; + +/** Create the governed block editor wrapper. Exported for focused integration tests. */ +export function createGovernedBlockEdit( + BlockEdit: BlockEditComponent, + governanceRules: GovernanceRules +): BlockEditComponent { + return function GovernedBlockEdit( props: BlockEditProps ) { + const { name: blockName, clientId } = props; + const isParentLocked = useContext( GovernanceLockContext ); + const parentBlockNames = useSelect( + selectStore => { + const { getBlockParents, getBlockName } = selectStore( + blockEditorStore + ) as BlockEditorSelectors; + return getBlockParents( clientId, true ) + .map( getBlockName ) + .filter( ( name ): name is BlockName => name !== undefined ); + }, + [ clientId ] + ); + const isAllowed = isBlockAllowedForEditing( + blockName, + parentBlockNames, + governanceRules, + isParentLocked + ); + + useBlockEditingMode( isAllowed ? undefined : 'disabled' ); + + if ( isAllowed ) { + return ; + } + + return ( + + +
+ +
+
+
+ ); + }; +} + +/** Register governance-driven editing restrictions with Gutenberg. */ +export function setupBlockLocking( governanceRules: GovernanceRules ): void { + addFilter( + 'editor.BlockEdit', + 'wpcomvip-governance/with-disabled-blocks', + ( BlockEdit: BlockEditComponent ) => createGovernedBlockEdit( BlockEdit, governanceRules ) + ); +} + +/** Determine whether a block should remain editable. */ +export function isBlockAllowedForEditing( + blockName: BlockName, + parentBlockNames: BlockName[], + governanceRules: GovernanceRules, + isParentLocked: boolean +): boolean { + if ( isParentLocked ) { + return true; + } + + const isAllowed = isBlockAllowedInHierarchy( blockName, parentBlockNames, governanceRules ); + + return applyFilters( + 'vip_governance__is_block_allowed_for_editing', + isAllowed, + blockName, + parentBlockNames, + governanceRules + ) as boolean; +} diff --git a/src/block-matching.ts b/src/block-matching.ts new file mode 100644 index 0000000..22e4e98 --- /dev/null +++ b/src/block-matching.ts @@ -0,0 +1,26 @@ +import type { BlockName } from './types'; + +type BlockRuleMatch = boolean | RegExpMatchArray | null; + +/** Match a block name using the plugin's legacy wildcard semantics. */ +export function doesBlockNameMatchBlockWildcard( + blockName: BlockName, + rule: string +): BlockRuleMatch { + if ( rule.includes( '*' ) ) { + // Keep compatibility with existing rules: only the first wildcard is expanded, + // other regular-expression syntax remains active, and matching is not anchored. + // eslint-disable-next-line security/detect-non-literal-regexp + return blockName.match( new RegExp( rule.replace( '*', '.*' ) ) ); + } + + return rule === blockName; +} + +/** Return whether a block name matches at least one rule. */ +export function isBlockAllowedByBlockWildcards( + blockName: BlockName, + rules: readonly string[] +): boolean { + return rules.some( rule => doesBlockNameMatchBlockWildcard( blockName, rule ) ); +} diff --git a/src/block-utils.js b/src/block-utils.js deleted file mode 100644 index b1a2a9a..0000000 --- a/src/block-utils.js +++ /dev/null @@ -1,116 +0,0 @@ -import { applyFilters } from '@wordpress/hooks'; - -import { getNestedSetting } from './nested-governance-loader'; - -// The list of default core blocks that should be allowed to be inserted, in order to make life easier. -const DEFAULT_CORE_BLOCK_LIST = { - 'core/list': [ 'core/list-item' ], - 'core/columns': [ 'core/column' ], - 'core/page-list': [ 'core/page-list-item' ], - 'core/navigation': [ 'core/navigation-link', 'core/navigation-submenu' ], - 'core/navigation-link': [ 'core/navigation-link', 'core/navigation-submenu', 'core/page-list' ], - 'core/quote': [ 'core/paragraph' ], - 'core/media-text': [ 'core/paragraph' ], - 'core/social-links': [ 'core/social-link' ], - 'core/comments-pagination': [ - 'core/comments-pagination-previous', - 'core/comments-pagination-numbers', - 'core/comments-pagination-next', - ], -}; - -/** - * Given a block name, a parent list and a set of governance rules, determine if - * the block can be inserted. - * - * By default, will return if the block is allowed to be inserted at the root level - * per the user's rules. If a parent block contains a rule for allowedBlocks, - * the function will return if the block is allowed as a child of that parent. - * - * Rules declared in allowedBlocks will override root level rules when the block - * is currently a child of the parent with allowedBlocks. - * - * @param {string} blockName The current block's name. - * @param {string[]} parentBlockNames A list of zero or more parent block names, - * starting with the most recent parent ancestor. - * @param {Object} governanceRules An object containing the full set of governance - * rules for the current user. - * @returns True if the block is allowed in set of parent blocks, or false otherwise. - */ -export function isBlockAllowedInHierarchy( blockName, parentBlockNames, governanceRules ) { - // Filter to decide if the mode should be cascading or restrictive, where true is cascading and false is restrictive. - const isInCascadingMode = applyFilters( - 'vip_governance__is_block_allowed_in_hierarchy', - true, - blockName, - parentBlockNames, - governanceRules - ); - - // Build the blocks that are allowed using the root level blocks for cascading mode or if no parent has been past, or empty otherwise. - const blocksAllowedToBeInserted = - isInCascadingMode || parentBlockNames.length === 0 ? [ ...governanceRules.allowedBlocks ] : []; - - // Only execute this if we are determining the block under a parent. - if ( parentBlockNames.length > 0 ) { - // Shortcircuit the parent-child hierarchy for some core blocks - if ( - DEFAULT_CORE_BLOCK_LIST[ parentBlockNames[ 0 ] ] && - DEFAULT_CORE_BLOCK_LIST[ parentBlockNames[ 0 ] ].includes( blockName ) - ) { - return true; - } - - // Only do a search if there are block settings to search through. - if ( governanceRules.blockSettings ) { - // Get the child block's parent block settings at whatever depth its located at. - const nestedSetting = getNestedSetting( - [ ...parentBlockNames ].reverse(), - 'allowedBlocks', - governanceRules.blockSettings - ); - - // If we found the allowedBlocks for the parent block, add that to the array of blocks that can be inserted. - if ( nestedSetting && nestedSetting.value ) { - blocksAllowedToBeInserted.push( ...nestedSetting.value ); - } - } - } - - // Check if the block is allowed using the array of blocks that can be inserted. - return isBlockAllowedByBlockWildcards( blockName, blocksAllowedToBeInserted ); -} - -/** - * Matches a block name to a list of block wildcard rules. - * For wildcard rules, see doesBlockNameMatchBlockWildcard(). - * - * @param {string} blockName - * @param {string[]} rules - * @returns True if the block name matches any of the rules, false otherwise. - */ -export function isBlockAllowedByBlockWildcards( blockName, rules ) { - return rules.some( rule => doesBlockNameMatchBlockWildcard( blockName, rule ) ); -} - -/** - * Matches a rule to a block name, with the following cases being possible: - * - * 1. ['*'] - matches all blocks - * 2. '*' can be located somewhere else alongside a string, e.g. 'core/*' - matches all core blocks - * 3. ['core/paragraph'] - matches only the core/paragraph block - * - * @param {string} blockName - * @param {string} rule - * @returns True if the block name matches the rule, or false otherwise - */ -export function doesBlockNameMatchBlockWildcard( blockName, rule ) { - if ( rule.includes( '*' ) ) { - const escapedRule = rule.replace( /[.+?^${}()|[\]\\]/g, '\\$&' ).replaceAll( '*', '.*' ); - // The rule is escaped above before it is compiled into an anchored expression. - // eslint-disable-next-line security/detect-non-literal-regexp - return new RegExp( `^${ escapedRule }$` ).test( blockName ); - } - - return rule === blockName; -} diff --git a/src/block-utils.test.js b/src/block-utils.test.ts similarity index 82% rename from src/block-utils.test.js rename to src/block-utils.test.ts index dfdeadd..23bf17e 100644 --- a/src/block-utils.test.js +++ b/src/block-utils.test.ts @@ -14,7 +14,7 @@ describe( 'blockUtils', () => { describe( 'isBlockAllowedInHierarchy', () => { describe( 'cascading mode', () => { beforeEach( () => { - applyFilters.mockImplementation( () => true ); + jest.mocked( applyFilters ).mockImplementation( () => true ); } ); it( 'should return true if the child block is a special core block', () => { @@ -88,6 +88,40 @@ describe( 'blockUtils', () => { expect( result ).toBe( false ); } ); + it( 'keeps the documented nearest-parent-first order after a nested lookup', () => { + const parentBlockNames = [ 'core/quote', 'core/group' ]; + + isBlockAllowedInHierarchy( 'core/heading', parentBlockNames, { + allowedBlocks: [ 'core/paragraph' ], + blockSettings: { + 'core/group': { + 'core/quote': { + allowedBlocks: [ 'core/heading' ], + }, + }, + }, + } ); + + expect( parentBlockNames ).toEqual( [ 'core/quote', 'core/group' ] ); + } ); + + it( 'resolves nested allowedBlocks through repeated parent names', () => { + expect( + isBlockAllowedInHierarchy( 'core/heading', [ 'core/quote', 'core/group', 'core/group' ], { + allowedBlocks: [ 'core/paragraph' ], + blockSettings: { + 'core/group': { + 'core/group': { + 'core/quote': { + allowedBlocks: [ 'core/heading' ], + }, + }, + }, + }, + } ) + ).toBe( true ); + } ); + it( 'should return true if the child block is allowed in the hierarchy with no blockSettings', () => { const blockName = 'core/heading'; const parentBlockNames = [ 'core/media-text' ]; @@ -114,7 +148,7 @@ describe( 'blockUtils', () => { it( 'should return true if the root block is allowed in the hierarchy', () => { const blockName = 'core/heading'; - const parentBlockNames = []; + const parentBlockNames: string[] = []; const governanceRules = { allowedBlocks: [ 'core/heading', 'core/paragraph' ], }; @@ -126,7 +160,7 @@ describe( 'blockUtils', () => { it( 'should return false if the root block is not allowed in the hierarchy', () => { const blockName = 'core/heading'; - const parentBlockNames = []; + const parentBlockNames: string[] = []; const governanceRules = { allowedBlocks: [ 'core/paragraph' ], }; @@ -135,11 +169,24 @@ describe( 'blockUtils', () => { expect( result ).toBe( false ); } ); + + it( 'does not apply allowedBlocks declared under a wildcard parent', () => { + const result = isBlockAllowedInHierarchy( 'core/heading', [ 'core/group' ], { + allowedBlocks: [ 'core/group' ], + blockSettings: { + 'core/*': { + allowedBlocks: [ 'core/heading' ], + }, + }, + } ); + + expect( result ).toBe( false ); + } ); } ); describe( 'restrictive mode', () => { beforeEach( () => { - applyFilters.mockImplementation( () => false ); + jest.mocked( applyFilters ).mockImplementation( () => false ); } ); it( 'should return true if the child block is a special core block', () => { @@ -219,7 +266,7 @@ describe( 'blockUtils', () => { it( 'should return true if the root block is allowed in the hierarchy', () => { const blockName = 'core/heading'; - const parentBlockNames = []; + const parentBlockNames: string[] = []; const governanceRules = { allowedBlocks: [ 'core/heading', 'core/paragraph' ], }; @@ -231,7 +278,7 @@ describe( 'blockUtils', () => { it( 'should return false if the root block is not allowed in the hierarchy', () => { const blockName = 'core/heading'; - const parentBlockNames = []; + const parentBlockNames: string[] = []; const governanceRules = { allowedBlocks: [ 'core/paragraph' ], }; @@ -287,15 +334,21 @@ describe( 'blockUtils', () => { expect( result ).toBeFalsy(); } ); - it( 'should only match namespace wildcards at the start of the block name', () => { - expect( doesBlockNameMatchBlockWildcard( 'core/heading', 'core/*' ) ).toBe( true ); - expect( doesBlockNameMatchBlockWildcard( 'custom/core/heading', 'core/*' ) ).toBe( false ); + it( 'preserves unanchored wildcard matching from existing rules', () => { + expect( doesBlockNameMatchBlockWildcard( 'core/heading', 'core/*' ) ).toBeTruthy(); + expect( doesBlockNameMatchBlockWildcard( 'custom/core/heading', 'core/*' ) ).toBeTruthy(); + } ); + + it( 'preserves regular-expression syntax in wildcard rules', () => { + expect( doesBlockNameMatchBlockWildcard( 'core/heading', 'core/head*' ) ).toBeTruthy(); + expect( doesBlockNameMatchBlockWildcard( 'core/heading', '*heading' ) ).toBeTruthy(); + expect( doesBlockNameMatchBlockWildcard( 'core/heading', 'core/.+*' ) ).toBeTruthy(); } ); - it( 'should support wildcards in other positions without treating text as a regex', () => { - expect( doesBlockNameMatchBlockWildcard( 'core/heading', 'core/head*' ) ).toBe( true ); - expect( doesBlockNameMatchBlockWildcard( 'core/heading', '*heading' ) ).toBe( true ); - expect( doesBlockNameMatchBlockWildcard( 'core/heading', 'core/.+' ) ).toBe( false ); + it( 'only expands the first wildcard for compatibility', () => { + expect( () => doesBlockNameMatchBlockWildcard( 'core/heading', 'core/**' ) ).toThrow( + SyntaxError + ); } ); it( 'should return true if the block name matches any of the rules', () => { diff --git a/src/block-utils.ts b/src/block-utils.ts new file mode 100644 index 0000000..b0430a7 --- /dev/null +++ b/src/block-utils.ts @@ -0,0 +1,67 @@ +import { applyFilters } from '@wordpress/hooks'; + +export { doesBlockNameMatchBlockWildcard, isBlockAllowedByBlockWildcards } from './block-matching'; +import { isBlockAllowedByBlockWildcards } from './block-matching'; +import { getNestedSetting } from './nested-settings-filter'; + +import type { BlockName, BlockSettings, GovernanceRules } from './types'; + +const DEFAULT_CORE_BLOCK_LIST: Readonly< Record< BlockName, readonly BlockName[] > > = { + 'core/list': [ 'core/list-item' ], + 'core/columns': [ 'core/column' ], + 'core/page-list': [ 'core/page-list-item' ], + 'core/navigation': [ 'core/navigation-link', 'core/navigation-submenu' ], + 'core/navigation-link': [ 'core/navigation-link', 'core/navigation-submenu', 'core/page-list' ], + 'core/quote': [ 'core/paragraph' ], + 'core/media-text': [ 'core/paragraph' ], + 'core/social-links': [ 'core/social-link' ], + 'core/comments-pagination': [ + 'core/comments-pagination-previous', + 'core/comments-pagination-numbers', + 'core/comments-pagination-next', + ], +}; + +function getNestedAllowedBlocks( + parentBlockNames: BlockName[], + blockSettings?: BlockSettings +): BlockName[] { + if ( ! blockSettings ) { + return []; + } + + const { value } = getNestedSetting( parentBlockNames, 'allowedBlocks', blockSettings ); + + return Array.isArray( value ) && value.every( item => typeof item === 'string' ) ? value : []; +} + +/** Determine whether a block can be inserted or edited at a hierarchy location. */ +export function isBlockAllowedInHierarchy( + blockName: BlockName, + parentBlockNames: BlockName[], + governanceRules: GovernanceRules +): boolean { + const isInCascadingMode = applyFilters( + 'vip_governance__is_block_allowed_in_hierarchy', + true, + blockName, + parentBlockNames, + governanceRules + ) as boolean; + + const allowedBlocks = + isInCascadingMode || parentBlockNames.length === 0 ? [ ...governanceRules.allowedBlocks ] : []; + + if ( parentBlockNames.length > 0 ) { + const immediateParentDefaults = DEFAULT_CORE_BLOCK_LIST[ parentBlockNames[ 0 ] ]; + if ( immediateParentDefaults?.includes( blockName ) ) { + return true; + } + + allowedBlocks.push( + ...getNestedAllowedBlocks( parentBlockNames, governanceRules.blockSettings ) + ); + } + + return isBlockAllowedByBlockWildcards( blockName, allowedBlocks ); +} diff --git a/src/editor.test.ts b/src/editor.test.ts new file mode 100644 index 0000000..81cdac2 --- /dev/null +++ b/src/editor.test.ts @@ -0,0 +1,119 @@ +import { dispatch, select } from '@wordpress/data'; +import { addFilter, applyFilters } from '@wordpress/hooks'; + +import { setupBlockLocking } from './block-locking'; +import { isBlockAllowedInHierarchy } from './block-utils'; +import { setupGovernance } from './editor'; +import { resolveNestedSetting } from './nested-settings-filter'; + +import type { GovernanceRuntimeConfig } from './types'; + +jest.mock( '@wordpress/block-editor', () => ( { store: 'core/block-editor' } ) ); +jest.mock( '@wordpress/data', () => ( { dispatch: jest.fn(), select: jest.fn() } ) ); +jest.mock( '@wordpress/hooks', () => ( { addFilter: jest.fn(), applyFilters: jest.fn() } ) ); +jest.mock( '@wordpress/i18n', () => ( { __: jest.fn( ( value: string ) => value ) } ) ); +jest.mock( '@wordpress/notices', () => ( { store: 'core/notices' } ) ); +jest.mock( './block-locking', () => ( { setupBlockLocking: jest.fn() } ) ); +jest.mock( './block-utils', () => ( { isBlockAllowedInHierarchy: jest.fn() } ) ); +jest.mock( './nested-settings-filter', () => ( { + createNestedSettingRules: jest.fn( () => ( { pathsByBlock: new Map(), settings: {} } ) ), + resolveNestedSetting: jest.fn(), +} ) ); + +const config: GovernanceRuntimeConfig = { + error: false, + governanceRules: { + allowedBlocks: [ 'core/paragraph' ], + allowedFeatures: [], + blockSettings: {}, + }, + nestedSettings: {}, + urlSettingsPage: '/wp-admin/admin.php?page=vip-block-governance', +}; + +type FilterCallback = ( ...args: unknown[] ) => unknown; + +function getRegisteredFilter( hookName: string ): FilterCallback { + const filterCall = jest.mocked( addFilter ).mock.calls.find( call => call[ 0 ] === hookName ); + if ( ! filterCall ) { + throw new Error( `Filter ${ hookName } was not registered.` ); + } + + return filterCall[ 2 ] as FilterCallback; +} + +describe( 'setupGovernance', () => { + const createErrorNotice = jest.fn( () => Promise.resolve() ); + const getBlockName = jest.fn( ( clientId: string ) => { + return clientId === 'ancestor' ? 'core/group' : 'core/quote'; + } ); + const getBlockParents = jest.fn( () => [ 'ancestor' ] ); + + beforeEach( () => { + jest.clearAllMocks(); + jest.mocked( dispatch ).mockReturnValue( { createErrorNotice } ); + jest.mocked( select ).mockReturnValue( { getBlockName, getBlockParents } as never ); + } ); + + it( 'shows a settings action and registers no filters when configuration failed', () => { + setupGovernance( { ...config, error: 'Rules failed to load.' } ); + + expect( createErrorNotice ).toHaveBeenCalledWith( 'Rules failed to load.', { + id: 'wpcomvip-governance-error', + isDismissible: true, + actions: [ + { + label: 'Open governance settings', + url: config.urlSettingsPage, + }, + ], + } ); + expect( addFilter ).not.toHaveBeenCalled(); + } ); + + it( 'preserves an insertion veto from WordPress without evaluating governance', () => { + setupGovernance( config ); + const insertionFilter = getRegisteredFilter( 'blockEditor.__unstableCanInsertBlockType' ); + + expect( + insertionFilter( false, { name: 'core/heading' }, undefined, { + getBlock: jest.fn(), + } ) + ).toBe( false ); + expect( isBlockAllowedInHierarchy ).not.toHaveBeenCalled(); + } ); + + it( 'passes the nearest-parent-first hierarchy through the public insertion filter', () => { + jest.mocked( isBlockAllowedInHierarchy ).mockReturnValue( true ); + jest.mocked( applyFilters ).mockReturnValue( false ); + setupGovernance( config ); + const insertionFilter = getRegisteredFilter( 'blockEditor.__unstableCanInsertBlockType' ); + + expect( + insertionFilter( true, { name: 'core/heading' }, 'parent', { + getBlock: () => ( { clientId: 'parent', name: 'core/quote' } ), + } ) + ).toBe( false ); + expect( isBlockAllowedInHierarchy ).toHaveBeenCalledWith( + 'core/heading', + [ 'core/quote', 'core/group' ], + config.governanceRules + ); + expect( applyFilters ).toHaveBeenCalledWith( + 'vip_governance__is_block_allowed_for_insertion', + true, + 'core/heading', + [ 'core/quote', 'core/group' ], + config.governanceRules + ); + } ); + + it( 'registers nested settings and block locking while preserving missing block names', () => { + setupGovernance( config ); + const settingsFilter = getRegisteredFilter( 'blockEditor.useSetting.before' ); + + expect( settingsFilter( 'original', 'color.text', 'current', undefined ) ).toBe( 'original' ); + expect( resolveNestedSetting ).not.toHaveBeenCalled(); + expect( setupBlockLocking ).toHaveBeenCalledWith( config.governanceRules ); + } ); +} ); diff --git a/src/editor.ts b/src/editor.ts new file mode 100644 index 0000000..c438e7d --- /dev/null +++ b/src/editor.ts @@ -0,0 +1,107 @@ +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { dispatch, select } from '@wordpress/data'; +import { addFilter, applyFilters } from '@wordpress/hooks'; +import { __ } from '@wordpress/i18n'; +import { store as noticeStore } from '@wordpress/notices'; + +import { setupBlockLocking } from './block-locking'; +import { isBlockAllowedInHierarchy } from './block-utils'; +import { createNestedSettingRules, resolveNestedSetting } from './nested-settings-filter'; + +import type { + BlockEditorInsertionSelectors, + BlockEditorSelectors, + BlockName, + BlockType, + ClientId, + GovernanceRuntimeConfig, +} from './types'; + +type InsertionFilterSelectors = Pick< BlockEditorInsertionSelectors, 'getBlock' >; + +/** Register editor governance filters from the server-localized configuration. */ +export function setupGovernance( config: GovernanceRuntimeConfig ): void { + if ( config.error ) { + void dispatch( noticeStore ).createErrorNotice( config.error, { + id: 'wpcomvip-governance-error', + isDismissible: true, + actions: [ + { + label: __( 'Open governance settings', 'vip-governance' ), + url: config.urlSettingsPage, + }, + ], + } ); + return; + } + + const { governanceRules } = config; + const { getBlockParents, getBlockName } = select( blockEditorStore ) as BlockEditorSelectors; + + addFilter( + 'blockEditor.__unstableCanInsertBlockType', + 'wpcomvip-governance/block-insertion', + ( + canInsert: boolean, + blockType: BlockType, + rootClientId: ClientId | undefined, + { getBlock }: InsertionFilterSelectors + ): boolean => { + if ( canInsert === false ) { + return false; + } + + let parentBlockNames: BlockName[] = []; + if ( rootClientId ) { + const parentBlock = getBlock( rootClientId ); + const ancestorNames = getBlockParents( rootClientId, true ) + .map( getBlockName ) + .filter( ( name ): name is BlockName => name !== undefined ); + const immediateParentName = parentBlock?.name ?? getBlockName( rootClientId ); + + parentBlockNames = immediateParentName + ? [ immediateParentName, ...ancestorNames ] + : ancestorNames; + } + + const isAllowed = isBlockAllowedInHierarchy( + blockType.name, + parentBlockNames, + governanceRules + ); + + return applyFilters( + 'vip_governance__is_block_allowed_for_insertion', + isAllowed, + blockType.name, + parentBlockNames, + governanceRules + ) as boolean; + } + ); + + const nestedSettingRules = createNestedSettingRules( config.nestedSettings ); + addFilter( + 'blockEditor.useSetting.before', + 'wpcomvip-governance/nested-block-settings', + ( defaultValue: unknown, path: string, clientId: ClientId, blockName?: BlockName ): unknown => { + if ( ! blockName ) { + return defaultValue; + } + + return resolveNestedSetting( { + clientId, + currentBlockName: blockName, + defaultValue, + getBlockName, + getBlockParents, + path, + rules: nestedSettingRules, + } ); + } + ); + + if ( governanceRules.allowedBlocks ) { + setupBlockLocking( governanceRules ); + } +} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index f1ebef5..0000000 --- a/src/index.js +++ /dev/null @@ -1,105 +0,0 @@ -import { store as blockEditorStore } from '@wordpress/block-editor'; -import { dispatch, select } from '@wordpress/data'; -import { addFilter, applyFilters } from '@wordpress/hooks'; -import { __ } from '@wordpress/i18n'; -import { store as noticeStore } from '@wordpress/notices'; - -import { setupBlockLocking } from './block-locking'; -import { isBlockAllowedInHierarchy } from './block-utils'; -import { createNestedSettingRules, resolveNestedSetting } from './nested-settings-filter'; - -function setup() { - if ( VIP_GOVERNANCE.error ) { - dispatch( noticeStore ).createErrorNotice( VIP_GOVERNANCE.error, { - id: 'wpcomvip-governance-error', - isDismissible: true, - actions: [ - { - label: __( 'Open governance settings', 'vip-governance' ), - url: VIP_GOVERNANCE.urlSettingsPage, - }, - ], - } ); - - return; - } - - const governanceRules = VIP_GOVERNANCE.governanceRules; - const { getBlockParents, getBlockName } = select( blockEditorStore ); - - addFilter( - 'blockEditor.__unstableCanInsertBlockType', - `wpcomvip-governance/block-insertion`, - ( canInsert, blockType, rootClientId, { getBlock } ) => { - if ( canInsert === false ) { - return canInsert; - } - - let parentBlockNames = []; - - if ( rootClientId ) { - // This block has parents. Build a list of parentBlockNames - const parentBlock = getBlock( rootClientId ); - const ancestorClientIds = getBlockParents( rootClientId, true ); - - parentBlockNames = [ - parentBlock?.name ?? getBlockName( rootClientId ), - ...ancestorClientIds.map( getBlockName ), - ]; - } - - const isAllowed = isBlockAllowedInHierarchy( - blockType.name, - parentBlockNames, - governanceRules - ); - - /** - * Change what blocks are allowed to be inserted in the block editor. - * - * @param {bool} isAllowed Whether or not the block will be allowed. - * @param {string} blockName The name of the block to be inserted. - * @param {string[]} parentBlockNames An array of zero or more parent block names, - * starting with the most recent parent ancestor. - * @param {Object} governanceRules An object containing the full set of governance - * rules for the current user. - */ - return applyFilters( - 'vip_governance__is_block_allowed_for_insertion', - isAllowed, - blockType.name, - parentBlockNames, - governanceRules - ); - } - ); - - const nestedSettings = VIP_GOVERNANCE.nestedSettings; - const rulesByPath = createNestedSettingRules( nestedSettings ); - - addFilter( - 'blockEditor.useSetting.before', - `wpcomvip-governance/nested-block-settings`, - ( defaultValue, path, clientId, blockName ) => { - if ( ! blockName ) { - return defaultValue; - } - - return resolveNestedSetting( { - defaultValue, - path, - clientId, - rulesByPath, - getBlockParents, - getBlockName, - } ); - } - ); - - // Block locking - if ( governanceRules?.allowedBlocks ) { - setupBlockLocking( governanceRules ); - } -} - -setup(); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..97e7f3b --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +import { setupGovernance } from './editor'; + +setupGovernance( VIP_GOVERNANCE ); diff --git a/src/nested-governance-loader.js b/src/nested-governance-loader.js deleted file mode 100644 index 7eba9db..0000000 --- a/src/nested-governance-loader.js +++ /dev/null @@ -1,161 +0,0 @@ -/** - * Find the list of nestedPaths that can be found in the block settings, so that - * it's faster to find out if a deeper nested setting exists or not. - * - * @param {Object} nestedSettings the nestedSettings found from the governance rules. - * @param {Object} nestedMetadata the nestedMetadata object that's to be populated with the paths. - * @param {String} currentBlock the current nested block name being processed. - * @returns {Object} Map of the block name along with the nested paths that can be found inside. - */ -export function getNestedSettingPaths( nestedSettings, nestedMetadata = {}, currentBlock = false ) { - const SETTINGS_TO_SKIP = [ 'allowedBlocks' ]; - for ( const [ settingKey, settingValue ] of Object.entries( nestedSettings ) ) { - if ( SETTINGS_TO_SKIP.includes( settingKey ) ) { - continue; - } - - // A nested block would be in the form of blockName/childBlockName or blockName/* or * - const isNestedBlock = settingKey.includes( '/' ) || settingKey === '*'; - - if ( isNestedBlock ) { - getNestedSettingPaths( settingValue, nestedMetadata, settingKey ); - } else if ( currentBlock !== false ) { - // This is a leaf block, add setting paths to nestedMetadata - const isObjectSetting = - typeof settingValue === 'object' && - settingValue !== null && - ! Array.isArray( settingValue ); - const settingPaths = isObjectSetting - ? flattenSettingPaths( settingValue, `${ settingKey }.` ) - : { [ settingKey ]: true }; - - // eslint-disable-next-line security/detect-object-injection - nestedMetadata[ currentBlock ] = { - // eslint-disable-next-line security/detect-object-injection - ...( nestedMetadata[ currentBlock ] ?? {} ), - ...settingPaths, - }; - } - } - - return nestedMetadata; -} - -/** - * Find block settings nested in other block settings. - * - * Given an array of blocks names from the top level of the editor to the - * current block (`blockNamePath`), return the value for the deepest-nested - * settings value that applies to the current block. - * - * If two setting values share the same nesting depth, use the last one that - * occurs in settings (like CSS). - * - * @param {string[]} blockNamePath Block names representing the path to the - * current block from the top level of the - * block editor. - * @param {string} normalizedPath Path to the setting being retrieved. - * @param {Object} settings Object containing all block settings. - * @param {Object} result Optional. Object with keys `depth` and - * `value` used to track current most-nested - * setting. - * @param {number} depth Optional. The current recursion depth used - * to calculate the most-nested setting. - * @return {Object} Object with keys `depth` and `value`. - * Destructure the `value` key for the result. - */ -export function getNestedSetting( - blockNamePath, - normalizedPath, - settings, - result = { depth: 0, value: undefined }, - depth = 1 -) { - if ( blockNamePath.length === 0 ) { - return result; - } - - const [ currentBlockName, ...remainingBlockNames ] = blockNamePath; - // eslint-disable-next-line security/detect-object-injection - const blockSettings = settings[ currentBlockName ]; - - if ( remainingBlockNames.length === 0 ) { - const settingValue = deepGet( blockSettings, normalizedPath ); - - if ( settingValue !== undefined && depth >= result.depth ) { - result.depth = depth; - result.value = settingValue; - } - - return result; - } else if ( blockSettings !== undefined ) { - // Recurse into the parent block's settings - result = getNestedSetting( - remainingBlockNames, - normalizedPath, - blockSettings, - result, - depth + 1 - ); - } - - // Continue down the array of blocks - return getNestedSetting( remainingBlockNames, normalizedPath, settings, result, depth ); -} - -/** - * Port of lodash's get function from https://gist.github.com/andrewchilds/30a7fb18981d413260c7a36428ed13da?permalink_comment_id=4433741#gistcomment-4433741 - * @param {Object} value The value to query. - * @param {String} query The query to run. - * @param {Object} defaultVal The default value to return if the query doesn't exist. - * @returns - */ -function deepGet( value, query, defaultVal = undefined ) { - const splitQuery = Array.isArray( query ) - ? query - : query - .replace( /(\[(\d)\])/g, '.$2' ) - .replace( /^\./, '' ) - .split( '.' ); - - if ( ! splitQuery.length || splitQuery[ 0 ] === undefined ) return value; - - const key = splitQuery[ 0 ]; - - if ( - typeof value !== 'object' || - value === null || - ! ( key in value ) || - // eslint-disable-next-line security/detect-object-injection - value[ key ] === undefined - ) { - return defaultVal; - } - - // eslint-disable-next-line security/detect-object-injection - return deepGet( value[ key ], splitQuery.slice( 1 ), defaultVal ); -} - -/** - * Flatten a nested object into a map of paths. - * @param {Object} settings The settings value that is to be flattened. - * @param {String} prefix The key for the settings value. - * @returns {Object} the flattened settings object. - */ -function flattenSettingPaths( settings, prefix = '' ) { - const result = {}; - - Object.entries( settings ).forEach( ( [ key, value ] ) => { - const isRegularObject = - typeof value === 'object' && Boolean( value ) && ! Array.isArray( value ); - - if ( isRegularObject ) { - result[ `${ prefix }${ key }` ] = true; - Object.assign( result, flattenSettingPaths( value, `${ prefix }${ key }.` ) ); - } else { - result[ `${ prefix }${ key }` ] = true; - } - } ); - - return result; -} diff --git a/src/nested-governance-loader.test.js b/src/nested-governance-loader.test.js deleted file mode 100644 index 19353b7..0000000 --- a/src/nested-governance-loader.test.js +++ /dev/null @@ -1,180 +0,0 @@ -import { getNestedSettingPaths, getNestedSetting } from './nested-governance-loader'; - -describe( 'getNestedSettingPaths', () => { - describe( 'getNestedSettingPaths', () => { - it( 'should return the nested setting paths given the nested settings', () => { - const result = getNestedSettingPaths( getTestNestedSettings() ); - expect( result ).toEqual( { - 'core/heading': { - 'color.palette': true, - 'color.palette.theme': true, - }, - 'core/paragraph': { - 'color.palette': true, - 'color.palette.theme': true, - }, - } ); - } ); - - it( 'should collect wildcard setting paths', () => { - const result = getNestedSettingPaths( { - 'core/*': { - color: { text: false }, - }, - '*': { - typography: { customFontSize: false }, - }, - } ); - - expect( result ).toEqual( { - 'core/*': { 'color.text': true }, - '*': { 'typography.customFontSize': true }, - } ); - } ); - - it( 'should merge paths for the same block nested under different parents', () => { - const result = getNestedSettingPaths( { - 'core/group': { - 'core/paragraph': { color: { text: false } }, - }, - 'core/quote': { - 'core/paragraph': { typography: { dropCap: false } }, - }, - } ); - - expect( result ).toEqual( { - 'core/paragraph': { - 'color.text': true, - 'typography.dropCap': true, - }, - } ); - } ); - - it( 'should include top-level primitive and array setting paths', () => { - const result = getNestedSettingPaths( { - 'core/group': { - useRootPaddingAwareAlignments: true, - spacingUnits: [ 'px', 'rem' ], - }, - } ); - - expect( result ).toEqual( { - 'core/group': { - useRootPaddingAwareAlignments: true, - spacingUnits: true, - }, - } ); - } ); - } ); - - describe( 'getNestedSetting', () => { - it( 'should return the nested setting found at the top level', () => { - const result = getNestedSetting( - [ 'core/heading' ], - 'color.palette.theme', - getTestNestedSettings() - ); - expect( result ).toEqual( { - depth: 1, - value: [ - { - color: '#FFFFF', - name: 'Primary', - slug: 'primary', - }, - ], - } ); - } ); - - it( 'should return the nested setting found deep inside a nested block', () => { - const result = getNestedSetting( - [ 'core/media-text', 'core/quote', 'core/paragraph' ], - 'color.palette.theme', - getTestNestedSettings() - ); - expect( result ).toEqual( { - depth: 3, - value: [ - { - color: '#F00001', - name: 'Tertiary', - slug: 'tertiary', - }, - ], - } ); - } ); - - it( 'should return an empty result for an empty block path', () => { - expect( getNestedSetting( [], 'color.text', getTestNestedSettings() ) ).toEqual( { - depth: 0, - value: undefined, - } ); - } ); - } ); -} ); - -function getTestNestedSettings() { - return { - 'core/heading': { - color: { - palette: { - theme: [ - { - color: '#FFFFF', - name: 'Primary', - slug: 'primary', - }, - ], - }, - }, - }, - 'core/quote': { - allowedBlocks: [ 'core/paragraph' ], - 'core/paragraph': { - color: { - palette: { - theme: [ - { - color: '#F00000', - name: 'Secondary', - slug: 'secondary', - }, - ], - }, - }, - }, - }, - 'core/media-text': { - allowedBlocks: [ 'core/heading', 'core/paragraph' ], - 'core/heading': { - color: { - palette: { - theme: [ - { - color: '#FFFFF', - name: 'Primary', - slug: 'primary', - }, - ], - }, - }, - }, - 'core/quote': { - allowedBlocks: [ 'core/paragraph' ], - 'core/paragraph': { - color: { - palette: { - theme: [ - { - color: '#F00001', - name: 'Tertiary', - slug: 'tertiary', - }, - ], - }, - }, - }, - }, - }, - }; -} diff --git a/src/nested-settings-filter.js b/src/nested-settings-filter.js deleted file mode 100644 index 44467ed..0000000 --- a/src/nested-settings-filter.js +++ /dev/null @@ -1,165 +0,0 @@ -import { doesBlockNameMatchBlockWildcard } from './block-utils'; - -const ALLOWED_BLOCKS_KEY = 'allowedBlocks'; - -/** - * Compile nested governance settings into candidates keyed by setting path. - * - * Each candidate retains its complete block hierarchy so wildcard ancestors - * can be matched alongside exact block names at runtime. - * - * @param {Object} nestedSettings Nested governance settings. - * @return {Map} Setting candidates keyed by normalized path. - */ -export function createNestedSettingRules( nestedSettings ) { - const rulesByPath = new Map(); - let declarationOrder = 0; - - const addSetting = ( path, value, blockPatterns ) => { - const candidates = rulesByPath.get( path ) ?? []; - candidates.push( { - blockPatterns, - value, - depth: blockPatterns.length, - exactMatches: blockPatterns.filter( pattern => ! pattern.includes( '*' ) ).length, - order: declarationOrder++, - } ); - rulesByPath.set( path, candidates ); - }; - - const visitSetting = ( value, path, blockPatterns ) => { - addSetting( path, value, blockPatterns ); - - if ( typeof value !== 'object' || value === null || Array.isArray( value ) ) { - return; - } - - for ( const [ key, childValue ] of Object.entries( value ) ) { - visitSetting( childValue, `${ path }.${ key }`, blockPatterns ); - } - }; - - const visitBlocks = ( settings, blockPatterns = [] ) => { - for ( const [ key, value ] of Object.entries( settings ) ) { - if ( key === ALLOWED_BLOCKS_KEY ) { - continue; - } - - const isBlockPattern = key.includes( '/' ) || key === '*'; - if ( isBlockPattern ) { - visitBlocks( value, [ ...blockPatterns, key ] ); - } else if ( blockPatterns.length > 0 ) { - visitSetting( value, key, blockPatterns ); - } - } - }; - - visitBlocks( nestedSettings ); - return rulesByPath; -} - -/** - * Return block names from the root ancestor through the current block. - * - * @param {string} clientId Current block client ID. - * @param {Function} getBlockParents Block editor parent selector. - * @param {Function} getBlockName Block editor name selector. - * @return {string[]} Block name path. - */ -export function getBlockNamePath( clientId, getBlockParents, getBlockName ) { - const ancestorIds = [ ...getBlockParents( clientId, true ) ].reverse(); - return [ ...ancestorIds, clientId ].map( getBlockName ); -} - -/** - * Determine whether a rule hierarchy matches a block hierarchy. - * - * The final rule must match the current block. Earlier rule segments can match - * any ancestor in order, preserving the existing descendant matching behavior. - * - * @param {string[]} blockPatterns Rule block hierarchy. - * @param {string[]} blockNames Actual block hierarchy. - * @return {boolean} Whether the rule applies to the current block. - */ -export function doesBlockHierarchyMatch( blockPatterns, blockNames ) { - if ( blockPatterns.length === 0 || blockNames.length === 0 ) { - return false; - } - - let patternIndex = blockPatterns.length - 1; - let blockIndex = blockNames.length - 1; - - if ( - ! doesBlockNameMatchBlockWildcard( blockNames[ blockIndex ], blockPatterns[ patternIndex ] ) - ) { - return false; - } - - patternIndex--; - blockIndex--; - - while ( patternIndex >= 0 ) { - while ( - blockIndex >= 0 && - ! doesBlockNameMatchBlockWildcard( blockNames[ blockIndex ], blockPatterns[ patternIndex ] ) - ) { - blockIndex--; - } - - if ( blockIndex < 0 ) { - return false; - } - - patternIndex--; - blockIndex--; - } - - return true; -} - -/** - * Resolve the most specific governed setting for a block. - * - * Deeper hierarchies win. At equal depth, exact block names win over - * wildcards. Equal-specificity rules retain declaration-order precedence. - * - * @param {Object} options Resolution options. - * @param {*} options.defaultValue Original WordPress setting value. - * @param {string} options.path Setting path being requested. - * @param {string} options.clientId Current block client ID. - * @param {Map} options.rulesByPath Compiled setting candidates. - * @param {Function} options.getBlockParents Block editor parent selector. - * @param {Function} options.getBlockName Block editor name selector. - * @return {*} Governed setting value or the original value when no rule applies. - */ -export function resolveNestedSetting( { - defaultValue, - path, - clientId, - rulesByPath, - getBlockParents, - getBlockName, -} ) { - const blockNames = getBlockNamePath( clientId, getBlockParents, getBlockName ); - const candidates = ( rulesByPath.get( path ) ?? [] ).filter( candidate => - doesBlockHierarchyMatch( candidate.blockPatterns, blockNames ) - ); - - if ( candidates.length === 0 ) { - return defaultValue; - } - - const winner = candidates.reduce( ( currentWinner, candidate ) => { - if ( candidate.depth !== currentWinner.depth ) { - return candidate.depth > currentWinner.depth ? candidate : currentWinner; - } - - if ( candidate.exactMatches !== currentWinner.exactMatches ) { - return candidate.exactMatches > currentWinner.exactMatches ? candidate : currentWinner; - } - - return candidate.order > currentWinner.order ? candidate : currentWinner; - } ); - - return winner.value?.theme ?? winner.value; -} diff --git a/src/nested-settings-filter.test.js b/src/nested-settings-filter.test.js deleted file mode 100644 index 0b7fb7e..0000000 --- a/src/nested-settings-filter.test.js +++ /dev/null @@ -1,161 +0,0 @@ -import { - createNestedSettingRules, - doesBlockHierarchyMatch, - resolveNestedSetting, -} from './nested-settings-filter'; - -describe( 'nested settings filter', () => { - const namesById = { - root: 'core/group', - parent: 'core/quote', - current: 'core/heading', - }; - const getBlockName = jest.fn( id => namesById[ id ] ); - - function resolve( nestedSettings, options = {} ) { - return resolveNestedSetting( { - defaultValue: 'original', - path: 'color.text', - clientId: 'current', - rulesByPath: createNestedSettingRules( nestedSettings ), - getBlockParents: () => [ 'root' ], - getBlockName, - ...options, - } ); - } - - it( 'prefers an exact block rule over a wildcard at the same depth', () => { - expect( - resolve( { - 'core/*': { color: { text: false } }, - 'core/heading': { color: { text: true } }, - } ) - ).toBe( true ); - } ); - - it( 'applies a wildcard block setting when no exact rule exists', () => { - expect( - resolve( { - 'core/*': { color: { text: false } }, - } ) - ).toBe( false ); - } ); - - it( 'returns the original value when no governed path applies', () => { - expect( - resolve( { 'core/*': { color: { text: false } } }, { path: 'typography.dropCap' } ) - ).toBe( 'original' ); - } ); - - it( 'uses the deepest exact setting in the current hierarchy', () => { - const hierarchyNames = { - root: 'core/group', - parent: 'core/quote', - current: 'core/paragraph', - }; - - expect( - resolve( - { - 'core/paragraph': { color: { text: true } }, - 'core/group': { - 'core/quote': { - 'core/paragraph': { color: { text: false } }, - }, - }, - }, - { - getBlockParents: () => [ 'parent', 'root' ], - getBlockName: id => hierarchyNames[ id ], - } - ) - ).toBe( false ); - } ); - - it( 'uses a deeper wildcard rule instead of a root exact rule', () => { - expect( - resolve( - { - 'core/heading': { color: { text: true } }, - 'core/quote': { - 'core/*': { color: { text: false } }, - }, - }, - { getBlockParents: () => [ 'parent' ] } - ) - ).toBe( false ); - } ); - - it( 'matches wildcard ancestors in a nested rule', () => { - const hierarchyNames = { - root: 'core/group', - current: 'core/paragraph', - }; - - expect( - resolve( - { - 'core/*': { - 'core/paragraph': { color: { text: false } }, - }, - }, - { getBlockName: id => hierarchyNames[ id ] } - ) - ).toBe( false ); - } ); - - it( 'uses the later declaration when depth and exactness are equal', () => { - expect( - resolve( { - '*': { color: { text: true } }, - 'core/*': { color: { text: false } }, - } ) - ).toBe( false ); - } ); - - it( 'unwraps the theme value expected by the WordPress settings API', () => { - const palette = [ { color: '#fff', name: 'White', slug: 'white' } ]; - - expect( - resolve( - { - 'core/heading': { color: { palette: { theme: palette } } }, - }, - { path: 'color.palette' } - ) - ).toBe( palette ); - } ); - - it( 'applies a top-level primitive setting', () => { - expect( - resolve( - { - 'core/heading': { useRootPaddingAwareAlignments: true }, - }, - { path: 'useRootPaddingAwareAlignments' } - ) - ).toBe( true ); - } ); - - it( 'returns an array setting without changing its value', () => { - const units = [ 'px', 'rem', '%' ]; - - expect( - resolve( - { - 'core/heading': { spacing: { units } }, - }, - { path: 'spacing.units' } - ) - ).toBe( units ); - } ); - - it( 'allows nested rules to match ordered non-direct ancestors', () => { - expect( - doesBlockHierarchyMatch( - [ 'core/group', 'core/heading' ], - [ 'core/group', 'core/quote', 'core/heading' ] - ) - ).toBe( true ); - } ); -} ); diff --git a/src/nested-settings-filter.test.ts b/src/nested-settings-filter.test.ts new file mode 100644 index 0000000..88bf80b --- /dev/null +++ b/src/nested-settings-filter.test.ts @@ -0,0 +1,261 @@ +import { + createNestedSettingRules, + getNestedSetting, + resolveNestedSetting, +} from './nested-settings-filter'; + +import type { BlockSettings } from './types'; + +describe( 'nested settings filter', () => { + const namesById: Record< string, string > = { + root: 'core/group', + parent: 'core/quote', + current: 'core/heading', + }; + const getBlockName = jest.fn( ( id: string ) => namesById[ id ] ); + + function resolve( + nestedSettings: BlockSettings, + options: { + getBlockName?: ( id: string ) => string | undefined; + getBlockParents?: () => string[]; + path?: string; + } = {} + ) { + return resolveNestedSetting( { + defaultValue: 'original', + path: 'color.text', + clientId: 'current', + rules: createNestedSettingRules( nestedSettings ), + getBlockParents: () => [ 'root' ], + getBlockName, + ...options, + } ); + } + + it( 'prefers an exact block rule over a wildcard at the same depth', () => { + expect( + resolve( { + 'core/*': { color: { text: false } }, + 'core/heading': { color: { text: true } }, + } ) + ).toBe( true ); + } ); + + it( 'applies a wildcard block setting when no exact rule exists', () => { + expect( + resolve( { + 'core/*': { color: { text: false } }, + } ) + ).toBe( false ); + } ); + + it( 'returns the original value when no governed path applies', () => { + expect( + resolve( { 'core/*': { color: { text: false } } }, { path: 'typography.dropCap' } ) + ).toBe( 'original' ); + } ); + + it( 'uses the deepest exact setting in the current hierarchy', () => { + const hierarchyNames: Record< string, string > = { + root: 'core/group', + parent: 'core/quote', + current: 'core/paragraph', + }; + + expect( + resolve( + { + 'core/paragraph': { color: { text: true } }, + 'core/group': { + 'core/quote': { + 'core/paragraph': { color: { text: false } }, + }, + }, + }, + { + getBlockParents: () => [ 'parent', 'root' ], + getBlockName: ( id: string ) => hierarchyNames[ id ], + } + ) + ).toBe( false ); + } ); + + it( 'uses the deepest setting when ancestor block names repeat', () => { + const hierarchyNames: Record< string, string > = { + root: 'core/group', + parent: 'core/group', + current: 'core/paragraph', + }; + + expect( + resolve( + { + 'core/paragraph': { color: { text: true } }, + 'core/group': { + 'core/group': { + 'core/paragraph': { color: { text: false } }, + }, + }, + }, + { + getBlockParents: () => [ 'parent', 'root' ], + getBlockName: ( id: string ) => hierarchyNames[ id ], + } + ) + ).toBe( false ); + } ); + + it( 'uses the later setting when matching paths have equal depth', () => { + const hierarchyNames: Record< string, string > = { + root: 'core/group', + parent: 'core/quote', + current: 'core/paragraph', + }; + + expect( + resolve( + { + 'core/group': { + 'core/paragraph': { color: { text: true } }, + }, + 'core/quote': { + 'core/paragraph': { color: { text: false } }, + }, + }, + { + getBlockParents: () => [ 'parent', 'root' ], + getBlockName: ( id: string ) => hierarchyNames[ id ], + } + ) + ).toBe( false ); + } ); + + it( 'ignores a missing ancestor name while resolving nested settings', () => { + const hierarchyNames: Record< string, string > = { + root: 'core/group', + current: 'core/heading', + }; + + expect( + resolve( + { + 'core/group': { + 'core/heading': { color: { text: false } }, + }, + }, + { + getBlockParents: () => [ 'missing', 'root' ], + getBlockName: ( id: string ) => hierarchyNames[ id ], + } + ) + ).toBe( false ); + } ); + + it( 'preserves a root exact rule instead of using a deeper wildcard rule', () => { + expect( + resolve( + { + 'core/heading': { color: { text: true } }, + 'core/quote': { + 'core/*': { color: { text: false } }, + }, + }, + { getBlockParents: () => [ 'parent' ] } + ) + ).toBe( true ); + } ); + + it( 'preserves the legacy limitation on wildcard ancestors', () => { + const hierarchyNames: Record< string, string > = { + root: 'core/group', + current: 'core/paragraph', + }; + + expect( + resolve( + { + 'core/*': { + 'core/paragraph': { color: { text: false } }, + }, + }, + { getBlockName: ( id: string ) => hierarchyNames[ id ] } + ) + ).toBeUndefined(); + } ); + + it( 'uses the first matching wildcard declaration', () => { + expect( + resolve( { + '*': { color: { text: true } }, + 'core/*': { color: { text: false } }, + } ) + ).toBe( true ); + } ); + + it( 'unwraps the theme value expected by the WordPress settings API', () => { + const palette = [ { color: '#fff', name: 'White', slug: 'white' } ]; + + expect( + resolve( + { + 'core/heading': { color: { palette: { theme: palette } } }, + }, + { path: 'color.palette' } + ) + ).toBe( palette ); + } ); + + it( 'preserves the legacy limitation on top-level primitive settings', () => { + expect( + resolve( + { + 'core/heading': { useRootPaddingAwareAlignments: true }, + }, + { path: 'useRootPaddingAwareAlignments' } + ) + ).toBe( 'original' ); + } ); + + it( 'returns an array setting without changing its value', () => { + const units = [ 'px', 'rem', '%' ]; + + expect( + resolve( + { + 'core/heading': { spacing: { units } }, + }, + { path: 'spacing.units' } + ) + ).toBe( units ); + } ); + + it( 'preserves exact-name nested allowedBlocks traversal', () => { + const settings = { + 'core/group': { + allowedBlocks: [ 'core/heading' ], + 'core/quote': { allowedBlocks: [ 'core/paragraph' ] }, + }, + }; + + expect( getNestedSetting( [ 'core/quote', 'core/group' ], 'allowedBlocks', settings ) ).toEqual( + { depth: 2, value: [ 'core/paragraph' ] } + ); + } ); + + it( 'preserves bracket path lookup compatibility', () => { + expect( + getNestedSetting( [ 'core/group' ], 'items[0].enabled', { + 'core/group': { items: [ { enabled: true } ] }, + } ).value + ).toBe( true ); + } ); + + it( 'preserves the documented wildcard limitation for parent allowedBlocks', () => { + expect( + getNestedSetting( [ 'core/group' ], 'allowedBlocks', { + 'core/*': { allowedBlocks: [ 'core/paragraph' ] }, + } ) + ).toEqual( { depth: 0, value: undefined } ); + } ); +} ); diff --git a/src/nested-settings-filter.ts b/src/nested-settings-filter.ts new file mode 100644 index 0000000..ac9b831 --- /dev/null +++ b/src/nested-settings-filter.ts @@ -0,0 +1,202 @@ +import { doesBlockNameMatchBlockWildcard } from './block-matching'; + +import type { BlockEditorSelectors, BlockName, BlockSettings, ClientId } from './types'; + +const ALLOWED_BLOCKS_KEY = 'allowedBlocks'; + +export interface NestedSettingRules { + pathsByBlock: Map< BlockName, Set< string > >; + settings: BlockSettings; +} + +interface ResolveForBlockNamesOptions { + blockNames: BlockName[]; + defaultValue: unknown; + path: string; + rules: NestedSettingRules; +} + +interface ResolveNestedSettingOptions + extends Omit< ResolveForBlockNamesOptions, 'blockNames' >, + BlockEditorSelectors { + clientId: ClientId; + currentBlockName?: BlockName; +} + +export interface NestedSettingResult { + depth: number; + value: unknown; +} + +function isRecord( value: unknown ): value is Record< string, unknown > { + return typeof value === 'object' && value !== null && ! Array.isArray( value ); +} + +function addSettingPaths( value: unknown, prefix: string, paths: Set< string > ): void { + if ( typeof value !== 'object' || value === null ) { + return; + } + + for ( const [ key, childValue ] of Object.entries( value ) ) { + const path = `${ prefix }${ key }`; + paths.add( path ); + + if ( isRecord( childValue ) ) { + addSettingPaths( childValue, `${ path }.`, paths ); + } + } +} + +/** Index visual/editor setting paths by their final block name or wildcard. */ +export function createNestedSettingRules( settings: BlockSettings ): NestedSettingRules { + const pathsByBlock: NestedSettingRules[ 'pathsByBlock' ] = new Map(); + + const visit = ( nestedSettings: BlockSettings, currentBlock?: BlockName ) => { + for ( const [ key, value ] of Object.entries( nestedSettings ) ) { + if ( key === ALLOWED_BLOCKS_KEY ) { + continue; + } + + if ( ( key.includes( '/' ) || key === '*' ) && isRecord( value ) ) { + visit( value, key ); + } else if ( currentBlock ) { + const paths = pathsByBlock.get( currentBlock ) ?? new Set(); + addSettingPaths( value, `${ key }.`, paths ); + pathsByBlock.set( currentBlock, paths ); + } + } + }; + + visit( settings ); + return { pathsByBlock, settings }; +} + +function deepGet( value: unknown, path: string | string[] ): unknown { + const keys = Array.isArray( path ) + ? path + : path + .replace( /(\[(\d)\])/g, '.$2' ) + .replace( /^\./, '' ) + .split( '.' ); + + if ( keys.length === 0 ) { + return value; + } + + if ( typeof value !== 'object' || value === null ) { + return undefined; + } + + const [ key, ...remainingPath ] = keys; + return key in value + ? deepGet( ( value as Record< string, unknown > )[ key ], remainingPath ) + : undefined; +} + +/** Resolve an exact-name setting from a nearest-block-first hierarchy. */ +export function getNestedSetting( + blockNamePath: BlockName[], + normalizedPath: string, + settings: BlockSettings, + result: NestedSettingResult = { depth: 0, value: undefined }, + depth = 1 +): NestedSettingResult { + if ( blockNamePath.length === 0 ) { + return result; + } + + const currentBlockName = blockNamePath.at( -1 ); + if ( currentBlockName === undefined ) { + return result; + } + + const remainingBlockNames = blockNamePath.slice( 0, -1 ); + const candidate = settings[ currentBlockName ]; + const blockSettings = isRecord( candidate ) ? candidate : undefined; + + if ( remainingBlockNames.length === 0 ) { + const value = deepGet( blockSettings, normalizedPath ); + return value !== undefined && depth >= result.depth ? { depth, value } : result; + } + + if ( blockSettings ) { + result = getNestedSetting( + remainingBlockNames, + normalizedPath, + blockSettings, + result, + depth + 1 + ); + } + + return getNestedSetting( remainingBlockNames, normalizedPath, settings, result, depth ); +} + +/** Return block names from the current block through the root ancestor. */ +export function getBlockNamePath( + clientId: ClientId, + getBlockParents: BlockEditorSelectors[ 'getBlockParents' ], + getBlockName: BlockEditorSelectors[ 'getBlockName' ], + currentBlockName?: BlockName +): BlockName[] { + return [ + currentBlockName ?? getBlockName( clientId ), + ...getBlockParents( clientId, true ).map( getBlockName ), + ].filter( ( blockName ): blockName is BlockName => blockName !== undefined ); +} + +function unwrapThemeValue( value: unknown ): unknown { + return isRecord( value ) && value.theme ? value.theme : value; +} + +/** Resolve a governed value for an already resolved block-name hierarchy. */ +export function resolveSettingForBlockNames( { + blockNames, + defaultValue, + path, + rules, +}: ResolveForBlockNamesOptions ): unknown { + const currentBlockName = blockNames[ 0 ]; + + if ( ! currentBlockName ) { + return defaultValue; + } + + if ( rules.pathsByBlock.get( currentBlockName )?.has( path ) ) { + return unwrapThemeValue( getNestedSetting( blockNames, path, rules.settings ).value ); + } + + for ( const [ blockPattern, settingPaths ] of rules.pathsByBlock ) { + if ( + blockPattern.includes( '*' ) && + settingPaths.has( path ) && + doesBlockNameMatchBlockWildcard( currentBlockName, blockPattern ) + ) { + const normalizedBlockNames = [ ...blockNames ]; + normalizedBlockNames[ 0 ] = blockPattern; + return unwrapThemeValue( + getNestedSetting( normalizedBlockNames, path, rules.settings ).value + ); + } + } + + return defaultValue; +} + +/** Resolve the legacy-precedence setting for a block editor client ID. */ +export function resolveNestedSetting( { + clientId, + currentBlockName, + defaultValue, + getBlockName, + getBlockParents, + path, + rules, +}: ResolveNestedSettingOptions ): unknown { + return resolveSettingForBlockNames( { + blockNames: getBlockNamePath( clientId, getBlockParents, getBlockName, currentBlockName ), + defaultValue, + path, + rules, + } ); +} diff --git a/src/settings/index.js b/src/settings/index.ts similarity index 100% rename from src/settings/index.js rename to src/settings/index.ts diff --git a/src/settings/rules-viewer.test.js b/src/settings/rules-viewer.test.js deleted file mode 100644 index 192c217..0000000 --- a/src/settings/rules-viewer.test.js +++ /dev/null @@ -1,114 +0,0 @@ -import { initializeRulesViewer } from './rules-viewer'; - -const SETTINGS_MARKUP = ` - - - - - -`; - -describe( 'initializeRulesViewer', () => { - beforeEach( () => { - document.body.innerHTML = SETTINGS_MARKUP; - } ); - - it( 'only shows the action when at least one filter is selected', () => { - initializeRulesViewer( { request: jest.fn() } ); - - const roleSelector = document.getElementById( 'user-role-selector' ); - const viewButton = document.getElementById( 'view-rules-button' ); - - expect( viewButton.hidden ).toBe( true ); - - roleSelector.value = 'editor'; - roleSelector.dispatchEvent( new Event( 'change' ) ); - - expect( viewButton.hidden ).toBe( false ); - } ); - - it( 'resets browser-restored selections during initialization', () => { - const roleSelector = document.getElementById( 'user-role-selector' ); - const postTypeSelector = document.getElementById( 'post-type-selector' ); - roleSelector.value = 'editor'; - postTypeSelector.value = 'page'; - - initializeRulesViewer( { request: jest.fn() } ); - - expect( roleSelector.value ).toBe( '' ); - expect( postTypeSelector.value ).toBe( '' ); - expect( document.getElementById( 'view-rules-button' ).hidden ).toBe( true ); - } ); - - it( 'requests and displays rules for both selected filters', async () => { - const request = jest.fn().mockResolvedValue( { allowedBlocks: [ 'core/paragraph' ] } ); - initializeRulesViewer( { request } ); - - document.getElementById( 'user-role-selector' ).value = 'editor'; - document.getElementById( 'post-type-selector' ).value = 'page'; - document.getElementById( 'view-rules-button' ).click(); - - await Promise.resolve(); - await Promise.resolve(); - - expect( request ).toHaveBeenCalledWith( { - path: '/vip-governance/v1/rules?role=editor&postType=page', - } ); - expect( document.getElementById( 'combined-governance-rules-json' ).textContent ).toBe( - JSON.stringify( { allowedBlocks: [ 'core/paragraph' ] }, null, 4 ) - ); - } ); - - it( 'restores the controls and displays a useful request error', async () => { - const request = jest.fn().mockRejectedValue( new Error( 'Request failed' ) ); - initializeRulesViewer( { request } ); - - const roleSelector = document.getElementById( 'user-role-selector' ); - const viewButton = document.getElementById( 'view-rules-button' ); - const spinner = document.querySelector( '.vip-governance-query-spinner' ); - - roleSelector.value = 'editor'; - viewButton.click(); - - expect( viewButton.disabled ).toBe( true ); - expect( roleSelector.disabled ).toBe( true ); - expect( spinner.hidden ).toBe( false ); - - await Promise.resolve(); - await Promise.resolve(); - - expect( document.getElementById( 'combined-governance-rules-json' ).textContent ).toBe( - 'Request failed' - ); - expect( viewButton.disabled ).toBe( false ); - expect( roleSelector.disabled ).toBe( false ); - expect( spinner.hidden ).toBe( true ); - } ); - - it( 'uses the localized fallback when a request has no error message', async () => { - const request = jest.fn().mockRejectedValue( {} ); - initializeRulesViewer( { request } ); - - document.getElementById( 'user-role-selector' ).value = 'editor'; - document.getElementById( 'view-rules-button' ).click(); - - await Promise.resolve(); - await Promise.resolve(); - - expect( document.getElementById( 'combined-governance-rules-json' ).textContent ).toBe( - 'Unable to load governance rules.' - ); - } ); - - it( 'does nothing when the settings markup is not present', () => { - document.body.innerHTML = ''; - - expect( () => initializeRulesViewer( { request: jest.fn() } ) ).not.toThrow(); - } ); -} ); diff --git a/src/settings/rules-viewer.test.ts b/src/settings/rules-viewer.test.ts new file mode 100644 index 0000000..da5ea00 --- /dev/null +++ b/src/settings/rules-viewer.test.ts @@ -0,0 +1,105 @@ +import { initializeRulesViewer } from './rules-viewer'; + +const SETTINGS_MARKUP = ` + + + + + +`; + +const roleSelector = () => document.getElementById( 'user-role-selector' ) as HTMLSelectElement; +const postTypeSelector = () => document.getElementById( 'post-type-selector' ) as HTMLSelectElement; +const viewButton = () => document.getElementById( 'view-rules-button' ) as HTMLButtonElement; +const spinner = () => document.querySelector( '.vip-governance-query-spinner' ) as HTMLElement; +const output = () => document.getElementById( 'json' ) as HTMLElement; + +describe( 'initializeRulesViewer', () => { + beforeEach( () => { + document.body.innerHTML = SETTINGS_MARKUP; + } ); + + it( 'only shows the action when at least one filter is selected', () => { + initializeRulesViewer( { request: jest.fn() } ); + + expect( viewButton().hidden ).toBe( true ); + roleSelector().value = 'editor'; + roleSelector().dispatchEvent( new Event( 'change' ) ); + + expect( viewButton().hidden ).toBe( false ); + } ); + + it( 'resets browser-restored selections during initialization', () => { + roleSelector().value = 'editor'; + postTypeSelector().value = 'page'; + + initializeRulesViewer( { request: jest.fn() } ); + + expect( roleSelector().value ).toBe( '' ); + expect( postTypeSelector().value ).toBe( '' ); + expect( viewButton().hidden ).toBe( true ); + } ); + + it( 'requests and displays rules for both selected filters', async () => { + const request = jest.fn().mockResolvedValue( { allowedBlocks: [ 'core/paragraph' ] } ); + initializeRulesViewer( { request } ); + + roleSelector().value = 'editor'; + postTypeSelector().value = 'page'; + viewButton().click(); + + await Promise.resolve(); + await Promise.resolve(); + + expect( request ).toHaveBeenCalledWith( { + path: '/vip-governance/v1/rules?role=editor&postType=page', + } ); + expect( output().textContent ).toBe( + JSON.stringify( { allowedBlocks: [ 'core/paragraph' ] }, null, 4 ) + ); + } ); + + it( 'displays a useful request error and restores the spinner', async () => { + const request = jest.fn().mockRejectedValue( { + code: 'rest_forbidden', + message: 'Request failed', + } ); + initializeRulesViewer( { request } ); + + roleSelector().value = 'editor'; + viewButton().click(); + + expect( spinner().hidden ).toBe( false ); + + await Promise.resolve(); + await Promise.resolve(); + + expect( output().textContent ).toBe( 'Request failed' ); + expect( spinner().hidden ).toBe( true ); + } ); + + it( 'uses the localized fallback when a request has no error message', async () => { + const request = jest.fn().mockRejectedValue( {} ); + initializeRulesViewer( { request } ); + + roleSelector().value = 'editor'; + viewButton().click(); + + await Promise.resolve(); + await Promise.resolve(); + + expect( output().textContent ).toBe( 'Unable to load governance rules.' ); + } ); + + it( 'does nothing when the settings markup is not present', () => { + document.body.innerHTML = ''; + + expect( () => initializeRulesViewer( { request: jest.fn() } ) ).not.toThrow(); + } ); +} ); diff --git a/src/settings/rules-viewer.js b/src/settings/rules-viewer.ts similarity index 50% rename from src/settings/rules-viewer.js rename to src/settings/rules-viewer.ts index dc9fdcf..6c08831 100644 --- a/src/settings/rules-viewer.js +++ b/src/settings/rules-viewer.ts @@ -1,35 +1,52 @@ const RULES_ENDPOINT = '/vip-governance/v1/rules'; -/** - * Initialize the role and post type rules viewer on the plugin settings page. - * - * @param {Object} options Initialization options. - * @param {Document} [options.root] Document containing the settings page. - * @param {Function} options.request Function used to request combined rules. - * @return {void} - */ -export function initializeRulesViewer( { root = document, request } ) { +type RulesRequest = ( options: { path: string } ) => Promise< unknown >; + +interface RulesViewerOptions { + request: RulesRequest; + root?: Document; +} + +type RequestData = Record< string, string >; + +function getErrorMessage( error: unknown, fallback: string ): string { + if ( error instanceof Error && error.message ) { + return error.message; + } + + if ( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof error.message === 'string' && + error.message + ) { + return error.message; + } + + return fallback; +} + +/** Initialize the role and post type rules viewer on the plugin settings page. */ +export function initializeRulesViewer( { root = document, request }: RulesViewerOptions ): void { const roleSelector = root.getElementById( 'user-role-selector' ); const postTypeSelector = root.getElementById( 'post-type-selector' ); const viewButton = root.getElementById( 'view-rules-button' ); const spinner = root.querySelector( '.vip-governance-query-spinner' ); - const output = root.getElementById( 'combined-governance-rules-json' ); + const output = root.getElementById( 'json' ); if ( - ! roleSelector || - ! postTypeSelector || - ! viewButton || - ! spinner || - ! output || - typeof request !== 'function' + ! ( roleSelector instanceof HTMLSelectElement ) || + ! ( postTypeSelector instanceof HTMLSelectElement ) || + ! ( viewButton instanceof HTMLButtonElement ) || + ! ( spinner instanceof HTMLElement ) || + ! ( output instanceof HTMLElement ) ) { return; } - let latestRequest = 0; - - const getRequestData = () => { - const data = {}; + const getRequestData = (): RequestData => { + const data: RequestData = {}; if ( roleSelector.value ) { data.role = roleSelector.value; @@ -51,7 +68,7 @@ export function initializeRulesViewer( { root = document, request } ) { } }; - const showOutput = value => { + const showOutput = ( value: string ) => { output.textContent = value; output.hidden = false; }; @@ -63,10 +80,6 @@ export function initializeRulesViewer( { root = document, request } ) { return; } - const requestId = ++latestRequest; - roleSelector.disabled = true; - postTypeSelector.disabled = true; - viewButton.disabled = true; spinner.hidden = false; spinner.classList.add( 'is-active' ); @@ -75,29 +88,23 @@ export function initializeRulesViewer( { root = document, request } ) { path: `${ RULES_ENDPOINT }?${ new URLSearchParams( data ) }`, } ); - if ( requestId === latestRequest ) { - showOutput( JSON.stringify( rules, null, 4 ) ); - } + showOutput( JSON.stringify( rules, null, 4 ) ); } catch ( error ) { - if ( requestId === latestRequest ) { - showOutput( error?.message || output.dataset.errorMessage ); - } + showOutput( + getErrorMessage( error, output.dataset.errorMessage ?? 'Unable to load rules.' ) + ); } finally { - if ( requestId === latestRequest ) { - roleSelector.disabled = false; - postTypeSelector.disabled = false; - viewButton.disabled = false; - spinner.hidden = true; - spinner.classList.remove( 'is-active' ); - } + spinner.hidden = true; + spinner.classList.remove( 'is-active' ); } }; roleSelector.addEventListener( 'change', updateControls ); postTypeSelector.addEventListener( 'change', updateControls ); - viewButton.addEventListener( 'click', handleRequest ); + viewButton.addEventListener( 'click', () => { + void handleRequest(); + } ); - // Preserve the original settings-page behavior on reload and browser restore. roleSelector.value = ''; postTypeSelector.value = ''; updateControls(); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..20fea41 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,39 @@ +export type BlockName = string; +export type ClientId = string; + +export type BlockSettings = Record< string, unknown >; + +export interface GovernanceRules { + allowedBlocks: BlockName[]; + allowedFeatures?: string[]; + blockSettings?: BlockSettings; +} + +export interface GovernanceRuntimeConfig { + error: false | string; + governanceRules: GovernanceRules; + nestedSettings: BlockSettings; + urlSettingsPage: string; +} + +export interface BlockEditorSelectors { + getBlockName: ( clientId: ClientId ) => BlockName | undefined; + getBlockParents: ( clientId: ClientId, ascending?: boolean ) => ClientId[]; +} + +export interface BlockRecord { + clientId: ClientId; + name: BlockName; +} + +export interface BlockEditorInsertionSelectors extends BlockEditorSelectors { + getBlock: ( clientId: ClientId ) => BlockRecord | null; +} + +export interface BlockType { + name: BlockName; +} + +declare global { + const VIP_GOVERNANCE: GovernanceRuntimeConfig; +} diff --git a/src/wordpress-block-editor.d.ts b/src/wordpress-block-editor.d.ts new file mode 100644 index 0000000..8330dcd --- /dev/null +++ b/src/wordpress-block-editor.d.ts @@ -0,0 +1,6 @@ +declare module '@wordpress/block-editor' { + export type BlockEditingMode = 'disabled' | 'contentOnly' | 'default'; + + export const store: never; + export function useBlockEditingMode( mode?: BlockEditingMode ): BlockEditingMode; +} diff --git a/tests/e2e/default-administrator-rule-check.spec.js b/tests/e2e/default-administrator-rule-check.spec.ts similarity index 79% rename from tests/e2e/default-administrator-rule-check.spec.js rename to tests/e2e/default-administrator-rule-check.spec.ts index cf1bf88..f79c9d7 100644 --- a/tests/e2e/default-administrator-rule-check.spec.js +++ b/tests/e2e/default-administrator-rule-check.spec.ts @@ -1,6 +1,13 @@ import { expect, test } from '@wordpress/e2e-test-utils-playwright'; -async function openTextColorPicker( page ) { +import type { Page } from '@playwright/test'; + +interface BlockWithClientId { + clientId: string; + name: string; +} + +async function openTextColorPicker( page: Page ) { // WordPress 6.8 labels this control "Text". Current WordPress versions label // the typography control "Color" and also expose a later background control. const legacyTextControl = page.getByText( 'Text', { exact: true } ); @@ -14,7 +21,7 @@ async function openTextColorPicker( page ) { test.describe( 'Role/Post Type - Default, Administrator and Post Rules Flow', () => { test.beforeEach( async ( { admin } ) => { - await admin.createNewPost( { legacyCanvas: true } ); + await admin.createNewPost( { legacyCanvas: true } as never ); } ); test( 'should confirm that only the administrator and default allowedBlocks are allowed to be inserted', async ( { @@ -41,7 +48,7 @@ test.describe( 'Role/Post Type - Default, Administrator and Post Rules Flow', () ) ); - // WordPress 6.8+ displays disallowed blocks, but rejects their insertion. + // WordPress 6.8 and 6.9 display disallowed blocks, but reject their insertion. await editor.insertBlock( { name: 'core/list' } ); await expect .poll( async () => ( await editor.getBlocks() ).map( ( { name } ) => name ) ) @@ -87,9 +94,9 @@ test.describe( 'Role/Post Type - Default, Administrator and Post Rules Flow', () ) ); - const [ mediaText ] = ( await editor.getBlocks( { full: true } ) ).filter( - ( { name } ) => name === 'core/media-text' - ); + const [ mediaText ] = ( + ( await editor.getBlocks( { full: true } ) ) as unknown as BlockWithClientId[] + ).filter( ( { name } ) => name === 'core/media-text' ); await editor.insertBlock( { name: 'core/list' }, { clientId: mediaText.clientId } ); await expect .poll( async () => { @@ -134,9 +141,9 @@ test.describe( 'Role/Post Type - Default, Administrator and Post Rules Flow', () // Insert a media-text, and a heading under it as that should be allowed as well. await editor.insertBlock( { name: 'core/media-text' } ); - const [ mediaText ] = ( await editor.getBlocks( { full: true } ) ).filter( - ( { name } ) => name === 'core/media-text' - ); + const [ mediaText ] = ( + ( await editor.getBlocks( { full: true } ) ) as unknown as BlockWithClientId[] + ).filter( ( { name } ) => name === 'core/media-text' ); await editor.insertBlock( { name: 'core/heading', @@ -153,10 +160,20 @@ test.describe( 'Role/Post Type - Default, Administrator and Post Rules Flow', () ) .toContain( 'core/heading' ); const [ nestedHeadingBlock ] = ( - await editor.getBlocks( { clientId: mediaText.clientId, full: true } ) - ).filter( ( { name } ) => name === 'core/heading' ); + ( await editor.getBlocks( { + clientId: mediaText.clientId, + full: true, + } ) ) as unknown as BlockWithClientId[] + ).filter( ( { name } ) => name === 'core/heading' ); await page.evaluate( clientId => { - globalThis.wp.data.dispatch( 'core/block-editor' ).selectBlock( clientId ); + const { wp } = globalThis as unknown as { + wp: { + data: { + dispatch: ( storeName: string ) => { selectBlock: ( id: string ) => void }; + }; + }; + }; + wp.data.dispatch( 'core/block-editor' ).selectBlock( clientId ); }, nestedHeadingBlock.clientId ); // Pick the custom red colour for the heading. @@ -220,11 +237,25 @@ test.describe( 'Role/Post Type - Default, Administrator and Post Rules Flow', () ] ); // Verify if the CSS was actually applied. - const frame = page.frame( 'editor-canvas' ); + const frame = page.frameLocator( '[name="editor-canvas"]' ); const rootHeading = frame.locator( 'text="This is a heading"' ); await expect( rootHeading ).toHaveCSS( 'color', 'rgb(255, 255, 0)' ); const nestedHeading = frame.locator( 'text="This is a heading inside a media-text"' ); await expect( nestedHeading ).toHaveCSS( 'color', 'rgb(255, 0, 0)' ); } ); + + test( 'disables a governed parent without wrapping its children again', async ( { editor } ) => { + await editor.setContent( ` + +
+

Nested governed content

+
+ + ` ); + + const governedWrappers = editor.canvas.locator( 'div[style*="opacity: 0.6"]' ); + await expect( governedWrappers ).toHaveCount( 1 ); + await expect( governedWrappers ).toContainText( 'Nested governed content' ); + } ); } ); diff --git a/tests/e2e/globalSetup.js b/tests/e2e/globalSetup.ts similarity index 89% rename from tests/e2e/globalSetup.js rename to tests/e2e/globalSetup.ts index 27314f7..98a2b10 100644 --- a/tests/e2e/globalSetup.js +++ b/tests/e2e/globalSetup.ts @@ -2,12 +2,14 @@ * External dependencies */ import { request } from '@playwright/test'; +import { RequestUtils } from '@wordpress/e2e-test-utils-playwright'; + +import type { FullConfig } from '@playwright/test'; /** * WordPress dependencies */ -import { RequestUtils } from '@wordpress/e2e-test-utils-playwright'; -async function globalSetup( config ) { +async function globalSetup( config: FullConfig ) { const { storageState, baseURL } = config.projects[ 0 ].use; const storageStatePath = typeof storageState === 'string' ? storageState : undefined; diff --git a/tests/e2e/settings-page.spec.js b/tests/e2e/settings-page.spec.ts similarity index 93% rename from tests/e2e/settings-page.spec.js rename to tests/e2e/settings-page.spec.ts index 183f73a..1c5177d 100644 --- a/tests/e2e/settings-page.spec.js +++ b/tests/e2e/settings-page.spec.ts @@ -7,7 +7,7 @@ test.describe( 'Governance settings page', () => { const roleSelector = page.getByLabel( 'User role' ); const postTypeSelector = page.getByLabel( 'Post type' ); const viewButton = page.getByRole( 'button', { name: 'View Rules' } ); - const output = page.locator( '#combined-governance-rules-json' ); + const output = page.locator( '#json' ); await expect( viewButton ).toBeHidden(); await roleSelector.selectOption( 'administrator' ); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9cb9af2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "allowJs": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "lib": [ "DOM", "ES2022" ], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": [ "jest", "node", "react" ] + }, + "include": [ "src/**/*.ts", "src/**/*.tsx", "tests/e2e/**/*.ts", "playwright.config.ts" ] +} diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php index 265a388..d0a20a6 100644 --- a/vendor/composer/installed.php +++ b/vendor/composer/installed.php @@ -3,7 +3,7 @@ 'name' => 'automattic/vip-governance', 'pretty_version' => 'dev-trunk', 'version' => 'dev-trunk', - 'reference' => 'd1e30f0ab91dbffdae04bc2be72ff39e86bea816', + 'reference' => 'd848df6c7675474ecfe400416c9b7fece098900c', 'type' => 'wordpress-plugin', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), @@ -13,7 +13,7 @@ 'automattic/vip-governance' => array( 'pretty_version' => 'dev-trunk', 'version' => 'dev-trunk', - 'reference' => 'd1e30f0ab91dbffdae04bc2be72ff39e86bea816', + 'reference' => 'd848df6c7675474ecfe400416c9b7fece098900c', 'type' => 'wordpress-plugin', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), diff --git a/webpack.config.js b/webpack.config.js index 272ea9a..490e084 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -3,7 +3,7 @@ const defaultScriptsConfig = require( '@wordpress/scripts/config/webpack.config' module.exports = { ...defaultScriptsConfig, entry: { - index: './src/index.js', - settings: './src/settings/index.js', + index: './src/index.ts', + settings: './src/settings/index.ts', }, }; From b03cf54700353250ba785cf0c09d5908eade1845 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Mon, 7 Sep 2026 14:24:22 +1000 Subject: [PATCH 4/4] Update the release script --- .github/workflows/release.yml | 3 ++- package.json | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 026765a..ceb3c2a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,7 +56,8 @@ jobs: if: steps.version_check.outputs.new_version run: | VERSION="${{ steps.version_check.outputs.new_version }}" - git archive --prefix "vip-governance/" HEAD -o "vip-governance-${VERSION}.zip" + npm run release:zip + mv vip-governance.zip "vip-governance-${VERSION}.zip" - name: Create Release if: steps.version_check.outputs.new_version diff --git a/package.json b/package.json index b46344f..f79ec12 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "prepare": "npx simple-git-hooks", "postinstall": "composer install --no-dev", "release": "./bin/release", + "release:zip": "npm run build && git diff --exit-code HEAD -- build && wp-scripts plugin-zip --root-folder=vip-governance", "test": "npm run test:unit", "test:unit": "npm run test:php && npm run test:js", "test:e2e": "npx playwright test",