From 287515b61e0b8cd4c7c6b12081b96187b2da10c8 Mon Sep 17 00:00:00 2001 From: Kevin Meijer Date: Tue, 4 Aug 2026 14:04:04 +0200 Subject: [PATCH 1/7] Added Runway support --- CHANGELOG.md | 156 +++++++++--------- README.md | 40 ++++- composer.json | 3 + config/structured-data.php | 5 + phpstan.neon | 2 + .../structured_data_templates.yaml | 18 ++ ... => StructuredDataFieldEditor-BnA2g-9i.js} | 2 +- ...s => statamic-structured-data-BmgHG2xH.js} | 12 +- ...{style-CW98Jme1.css => style-BI9jtWpQ.css} | 2 +- resources/dist/build/manifest.json | 6 +- resources/dist/hot | 1 + src/Actions/InjectStructuredDataAction.php | 63 +++++++ .../AvailableVariablesFieldtype.php | 84 ++++++++++ src/Fieldtypes/RunwayResourcesFieldtype.php | 59 +++++++ src/Parser/StructuredDataParser.php | 38 ++++- src/ServiceProvider.php | 7 + src/Services/ReplicatorFieldService.php | 19 ++- src/Services/StructuredDataService.php | 65 ++++++-- src/Support/RunwaySupport.php | 106 ++++++++++++ src/Tags/StructuredData.php | 14 ++ stubs/runway.php | 54 ++++++ .../InjectStructuredDataActionRunwayTest.php | 83 ++++++++++ .../StructuredDataServiceRunwayTest.php | 125 ++++++++++++++ tests/Unit/Support/RunwaySupportTest.php | 86 ++++++++++ tests/Unit/Tags/StructuredDataTest.php | 42 +++++ 25 files changed, 982 insertions(+), 110 deletions(-) rename resources/dist/build/assets/{StructuredDataFieldEditor-CO12Y0iM.js => StructuredDataFieldEditor-BnA2g-9i.js} (99%) rename resources/dist/build/assets/{statamic-structured-data-DSPlP4f9.js => statamic-structured-data-BmgHG2xH.js} (73%) rename resources/dist/build/assets/{style-CW98Jme1.css => style-BI9jtWpQ.css} (80%) create mode 100644 resources/dist/hot create mode 100644 src/Fieldtypes/RunwayResourcesFieldtype.php create mode 100644 src/Support/RunwaySupport.php create mode 100644 stubs/runway.php create mode 100644 tests/Unit/Actions/InjectStructuredDataActionRunwayTest.php create mode 100644 tests/Unit/Services/StructuredDataServiceRunwayTest.php create mode 100644 tests/Unit/Support/RunwaySupportTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b61847..7e0ef26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,19 +1,25 @@ -# Changelog - -[Unreleased changes](https://github.com/justbetter/statamic-structured-data/compare/2.3.0...2.3.0) -## [2.3.0](https://github.com/justbetter/statamic-structured-data/releases/tag/2.3.0) - 2026-07-28 - +# Changelog + +[Unreleased changes](https://github.com/justbetter/statamic-structured-data/compare/2.3.0...2.3.0) +## Unreleased + +### Added +- Added optional Runway resource support (templates apply to all models of a resource) +- Added `structured-data:for` tag for explicit item/model rendering + +## [2.3.0](https://github.com/justbetter/statamic-structured-data/releases/tag/2.3.0) - 2026-07-28 + ### Added - Added SEO Pro support (#32) -- Added base package (#31) - -## [2.2.1](https://github.com/justbetter/statamic-structured-data/releases/tag/2.2.1) - 2026-05-05 - +- Added base package (#31) + +## [2.2.1](https://github.com/justbetter/statamic-structured-data/releases/tag/2.2.1) - 2026-05-05 + ### Fixed -- Fixed vite script loading (#29) - -## [2.2.0](https://github.com/justbetter/statamic-structured-data/releases/tag/2.2.0) - 2026-05-05 - +- Fixed vite script loading (#29) + +## [2.2.0](https://github.com/justbetter/statamic-structured-data/releases/tag/2.2.0) - 2026-05-05 + ### Added - Added field duplication (#26) - Added field collapse (#26) @@ -22,16 +28,16 @@ - show imported fieldset fields (#28) ### Fixed -- Parse Bard content correctly (#27) - -## [2.1.0](https://github.com/justbetter/statamic-structured-data/releases/tag/2.1.0) - 2026-04-13 - +- Parse Bard content correctly (#27) + +## [2.1.0](https://github.com/justbetter/statamic-structured-data/releases/tag/2.1.0) - 2026-04-13 + ### Added -- Add Support for Laravel 13 (#24) - -## [2.0.0](https://github.com/justbetter/statamic-structured-data/releases/tag/2.0.0) - 2026-03-24 - +- Add Support for Laravel 13 (#24) + +## [2.0.0](https://github.com/justbetter/statamic-structured-data/releases/tag/2.0.0) - 2026-03-24 + ### Added - Added apply to all action for templates (#19) @@ -40,77 +46,77 @@ - Added tests + Small refactor (#22) ### Changed -- Statamic 6 update + Vue 3 & Statamic UI refactor (#23) - -## [1.5.0](https://github.com/justbetter/statamic-structured-data/releases/tag/1.5.0) - 2025-12-15 - +- Statamic 6 update + Vue 3 & Statamic UI refactor (#23) + +## [1.5.0](https://github.com/justbetter/statamic-structured-data/releases/tag/1.5.0) - 2025-12-15 + ### Added - Added option to auto assign templates to new entries (#17) -- Changed Phpstan level & fixed Phpstan errors + ran Pint (#18) - -## [1.4.2](https://github.com/justbetter/statamic-structured-data/releases/tag/1.4.2) - 2025-12-05 - +- Changed Phpstan level & fixed Phpstan errors + ran Pint (#18) + +## [1.4.2](https://github.com/justbetter/statamic-structured-data/releases/tag/1.4.2) - 2025-12-05 + ### Fixed -- Fixed object array output (#15) - -## [1.4.0](https://github.com/justbetter/statamic-structured-data/releases/tag/1.4.0) - 2025-08-26 - +- Fixed object array output (#15) + +## [1.4.0](https://github.com/justbetter/statamic-structured-data/releases/tag/1.4.0) - 2025-08-26 + ### Added -- Presets (#11) - -## [1.3.1](https://github.com/justbetter/statamic-structured-data/releases/tag/1.3.1) - 2025-08-12 - +- Presets (#11) + +## [1.3.1](https://github.com/justbetter/statamic-structured-data/releases/tag/1.3.1) - 2025-08-12 + ### Fixed -- Dark mode styling fix (#10) - -## [1.3.0](https://github.com/justbetter/statamic-structured-data/releases/tag/1.3.0) - 2025-05-20 - +- Dark mode styling fix (#10) + +## [1.3.0](https://github.com/justbetter/statamic-structured-data/releases/tag/1.3.0) - 2025-05-20 + ### Changed - - Changed injection of structured data (#9) - -## [1.2.2](https://github.com/justbetter/statamic-structured-data/releases/tag/1.2.2) - 2025-05-19 - + - Changed injection of structured data (#9) + +## [1.2.2](https://github.com/justbetter/statamic-structured-data/releases/tag/1.2.2) - 2025-05-19 + ### Changed - - Allow json response from service class (#8) - -## [1.2.1](https://github.com/justbetter/statamic-structured-data/releases/tag/1.2.1) - 2025-05-14 - + - Allow json response from service class (#8) + +## [1.2.1](https://github.com/justbetter/statamic-structured-data/releases/tag/1.2.1) - 2025-05-14 + ### Changed - - Updated readme (#7) - -## [1.2.0](https://github.com/justbetter/statamic-structured-data/releases/tag/1.2.0) - 2025-05-14 - + - Updated readme (#7) + +## [1.2.0](https://github.com/justbetter/statamic-structured-data/releases/tag/1.2.0) - 2025-05-14 + ### Added - - Added numeric field to builder (#5) - -## [1.1.0](https://github.com/justbetter/statamic-structured-data/releases/tag/1.1.0) - 2025-05-12 - + - Added numeric field to builder (#5) + +## [1.1.0](https://github.com/justbetter/statamic-structured-data/releases/tag/1.1.0) - 2025-05-12 + ### Added - - Added support for structured data templates on Terms (#4) - -## [1.0.3](https://github.com/justbetter/statamic-structured-data/releases/tag/1.0.3) - 2025-05-09 - + - Added support for structured data templates on Terms (#4) + +## [1.0.3](https://github.com/justbetter/statamic-structured-data/releases/tag/1.0.3) - 2025-05-09 + ### Fixed - Skip collection & taxonomy boot when running in console (#3) - - -## [1.0.2](https://github.com/justbetter/statamic-structured-data/releases/tag/1.0.2) - 2025-05-09 - + + +## [1.0.2](https://github.com/justbetter/statamic-structured-data/releases/tag/1.0.2) - 2025-05-09 + ### Fixed -- Fixed infinite loop when loading variables (#2). - -## [1.0.1](https://github.com/justbetter/statamic-structured-data/releases/tag/1.0.1) - 2025-05-02 - +- Fixed infinite loop when loading variables (#2). + +## [1.0.1](https://github.com/justbetter/statamic-structured-data/releases/tag/1.0.1) - 2025-05-02 + ### Added -- Added Laravel 12 support https://github.com/justbetter/statamic-structured-data/pull/1 - -## [1.0.0](https://github.com/justbetter/statamic-structured-data/releases/tag/1.0.0) - 2025-04-25 - -**Initial release**: https://github.com/justbetter/statamic-structured-data/commits/1.0.0 - +- Added Laravel 12 support https://github.com/justbetter/statamic-structured-data/pull/1 + +## [1.0.0](https://github.com/justbetter/statamic-structured-data/releases/tag/1.0.0) - 2025-04-25 + +**Initial release**: https://github.com/justbetter/statamic-structured-data/commits/1.0.0 + diff --git a/README.md b/README.md index 7c60779..f1f87cd 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,13 @@ This Statamic addon provides a powerful and flexible way to add structured data ## Features -- 🔄 Dynamic JSON-LD generation based on entry and term data +- 🔄 Dynamic JSON-LD generation based on entry, term, and Runway model data - 📝 Template-based structured data configuration - 📦 Built-in schema presets (WebSite, WebPage, Organization, Article, LocalBusiness) - 🎯 Support for multiple schemas per page - 🛠 Antlers template parsing support - 🧩 Support for replicator-to-JSON-LD field mapping +- ✈️ Optional [Runway](https://statamic.com/addons/rad-pack/runway) resource support - 💪 Flexible and extensible architecture ## Requirements @@ -57,6 +58,7 @@ After publishing the config, you can configure: - which collections support structured data templates - which taxonomies support structured data objects +- which Runway resource handles should use structured data templates - whether presets are enabled - which default presets are available - custom preset paths @@ -71,11 +73,41 @@ Create templates in your Statamic control panel that define your structured data - Custom fields with various data types (strings, numeric, arrays, objects) - Dynamic values using Antlers templating syntax -### 2. Assigning Templates to Entries +### 2. Assigning Templates to Entries and Terms In your entry or term's content, you can assign one or more structured data templates using the `structured_data_templates` field. The addon will automatically process these templates and generate the appropriate JSON-LD scripts. -### 3. Rendering Structured Data +### 3. Runway resources (optional) + +Runway is soft-optional. When `statamic-rad-pack/runway` is installed, templates can target a Runway resource (`blueprint_type: runway` + `use_for_runway`). Those templates apply to **all** models of that resource at render time — there is no per-model template picker. + +Enable resources in config: + +```php +'runway' => [ + 'product', + 'category', +], +``` + +For projects that use Runway frontend routing, `structured-data:head` resolves the current model via Runway URI lookup. + +For Magento/Rapidez-style routes (or any custom routing), pass the current model explicitly: + +**Blade**: + +```blade +@isset($product) + {!! Statamic::tag('structured-data:for')->param('item', $product)->param('resource', 'product')->fetch() !!} +@endisset +{!! Statamic::tag('structured-data:head')->fetch() !!} +``` + +The optional `resource` param forces the Runway handle when the storefront model class differs from the Runway model class. + +Available variables for Runway templates include blueprint fields plus model attributes/appends. + +### 4. Rendering Structured Data Render the generated JSON-LD where you need it in your layout: @@ -136,4 +168,4 @@ The MIT License (MIT). Please see [License File](LICENSE) for more information. JustBetter logo - \ No newline at end of file + diff --git a/composer.json b/composer.json index 5eeb87a..f9466e8 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,9 @@ "phpstan/phpstan-mockery": "^2.0", "phpunit/phpunit": "^11.5" }, + "suggest": { + "statamic-rad-pack/runway": "Required to attach structured data templates to Runway resources." + }, "config": { "allow-plugins": { "pixelfear/composer-dist-plugin": true, diff --git a/config/structured-data.php b/config/structured-data.php index ec70d6d..3ec2895 100644 --- a/config/structured-data.php +++ b/config/structured-data.php @@ -9,6 +9,11 @@ // Add taxonomy handles here that should have structured data objects // Example: 'categories', 'tags' ], + 'runway' => [ + // Add Runway resource handles here that should use structured data templates + // Example: 'product', 'category' + // Templates targeting a Runway resource apply to all models of that resource. + ], 'presets' => [ 'enabled' => true, 'default_presets' => ['website', 'organization', 'article', 'webpage', 'localbusiness'], diff --git a/phpstan.neon b/phpstan.neon index adecec8..c9aa18f 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -7,3 +7,5 @@ parameters: - src - tests level: 9 + scanFiles: + - stubs/runway.php diff --git a/resources/blueprints/collections/structured_data_templates/structured_data_templates.yaml b/resources/blueprints/collections/structured_data_templates/structured_data_templates.yaml index bda8ccf..3330118 100644 --- a/resources/blueprints/collections/structured_data_templates/structured_data_templates.yaml +++ b/resources/blueprints/collections/structured_data_templates/structured_data_templates.yaml @@ -42,6 +42,9 @@ tabs: - key: taxonomy value: Taxonomy + - + key: runway + value: Runway default: collection type: select display: 'Blueprint type' @@ -70,6 +73,19 @@ tabs: - required if: blueprint_type: 'equals taxonomy' + - + handle: use_for_runway + field: + type: structured_data_runway_resources + display: 'Use for Runway resource' + instructions: 'Templates targeting a Runway resource apply to all models of that resource.' + clearable: true + localizable: true + validate: + - sometimes + - required + if: + blueprint_type: 'equals runway' - handle: apply_automatically field: @@ -78,6 +94,8 @@ tabs: instructions: 'Apply the structured data template automatically to new entries of the selected collection or taxonomy.' localizable: true default: false + unless: + blueprint_type: 'equals runway' - handle: structured_data_available_variables field: diff --git a/resources/dist/build/assets/StructuredDataFieldEditor-CO12Y0iM.js b/resources/dist/build/assets/StructuredDataFieldEditor-BnA2g-9i.js similarity index 99% rename from resources/dist/build/assets/StructuredDataFieldEditor-CO12Y0iM.js rename to resources/dist/build/assets/StructuredDataFieldEditor-BnA2g-9i.js index 69cf8eb..337d3ef 100644 --- a/resources/dist/build/assets/StructuredDataFieldEditor-CO12Y0iM.js +++ b/resources/dist/build/assets/StructuredDataFieldEditor-BnA2g-9i.js @@ -1 +1 @@ -import{_ as T,S as x,P as E,a as B,L as S,I as R,H as L,D as K,C as q,b as w,B as C,r as p,o as n,c as f,d as i,e as o,w as d,f as c,t as r,g as V,h as _,F as O,i as D,j as z,k as H,v as Z,l as G,m as Q,n as W,p as X,u as b}from"./statamic-structured-data-DSPlP4f9.js";const Y={name:"ReplicatorFieldMapper",components:{Button:C,Card:w,Checkbox:q,Description:K,Heading:L,Input:R,Label:S,Panel:B,PanelHeader:E,Select:x},props:{modelValue:{type:Object,default:()=>({replicator_field:"",set:"",mappings:[],flat:!1,flat_key_field:"",flat_value_field:""})},replicatorFields:{type:Array,default:()=>[]}},emits:["update:modelValue"],data(){return{localConfig:this.normalizeConfig(this.modelValue),modeOptions:[{value:"field",label:"From Replicator Field"},{value:"static",label:"Static Value"},{value:"nested_replicator",label:"Nested Replicator"}],confirmRemoveMappingOpen:!1,mappingIndexToRemove:null}},computed:{availableReplicatorFields(){return!this.replicatorFields||!Array.isArray(this.replicatorFields)?[]:this.replicatorFields},replicatorFieldOptions(){return!this.availableReplicatorFields||this.availableReplicatorFields.length===0?[]:this.availableReplicatorFields.map(e=>({value:e.handle,label:e.display||e.handle}))},selectedReplicatorField(){return this.localConfig.replicator_field?this.availableReplicatorFields.find(e=>e.handle===this.localConfig.replicator_field):null},setOptions(){return this.selectedReplicatorField?this.selectedReplicatorField.sets.map(e=>({value:e.value,label:e.label})):[]},flatFieldOptions(){return this.getFieldOptionsForMapping({})}},watch:{localConfig:{deep:!0,handler(e){const a=JSON.stringify(e),u=JSON.stringify(this.modelValue);a!==u&&this.$emit("update:modelValue",JSON.parse(a))}},modelValue:{deep:!0,handler(e){const a=this.normalizeConfig(e),u=JSON.stringify(a),v=JSON.stringify(this.localConfig);u!==v&&(this.localConfig=JSON.parse(u))}}},methods:{normalizeConfig(e){const a={replicator_field:"",set:"",mappings:[],flat:!1,flat_key_field:"",flat_value_field:""};return!e||typeof e!="object"?a:{replicator_field:e.replicator_field||"",set:e.set||"",mappings:Array.isArray(e.mappings)?JSON.parse(JSON.stringify(e.mappings)):[],flat:e.flat===!0,flat_key_field:e.flat_key_field||"",flat_value_field:e.flat_value_field||""}},addMapping(){this.localConfig.mappings.push({key:"",mode:"field",field:"",static:"",nested:{replicator_field:"",set:"",mappings:[]}})},removeMapping(e){this.localConfig.mappings.splice(e,1)},requestRemoveMapping(e){this.mappingIndexToRemove=e,this.confirmRemoveMappingOpen=!0},confirmRemoveMapping(){this.mappingIndexToRemove!==null&&(this.removeMapping(this.mappingIndexToRemove),this.mappingIndexToRemove=null)},sanitizeKey(e){e.key=e.key.replace(/[^a-zA-Z0-9@:-]/g,"")},getFieldOptionsForMapping(e){if(!this.selectedReplicatorField)return[];const a=this.selectedReplicatorField.sets||[];let u=[];if(this.localConfig.set){const t=a.find(m=>m.value===this.localConfig.set);t&&t.fields&&(u=t.fields)}else a.forEach(t=>{t.fields&&(u=u.concat(t.fields))});const v=[],l=new Set;return u.forEach(t=>{l.has(t.value)||(l.add(t.value),v.push(t))}),v}}},$={class:"replicator-mapper space-y-3"},ee={class:"grid grid-cols-1 md:grid-cols-2 gap-3"},le={key:0,class:"text-xs text-yellow-600 mt-1 bg-yellow-50 p-2 rounded"},te={class:"flex flex-col justify-center gap-2"},ae={class:"grid grid-cols-1 md:grid-cols-2 gap-3"},oe={class:"text-xs text-gray-500 mt-1"},ie={class:"text-xs text-gray-500 mt-1"},de={class:"flex items-center justify-between mb-2"},ne={class:"font-semibold text-gray-700"},se={key:0,class:"text-sm text-gray-500"},re={class:"flex items-start gap-2"},ue={class:"flex-1"},me={class:"w-40"},ce={class:"mt-2"};function fe(e,a,u,v,l,t){const m=p("Label"),y=p("Select"),A=p("Checkbox"),U=p("Description"),j=p("PanelHeader"),F=p("Card"),g=p("Panel"),k=p("Button"),N=p("Input"),I=p("replicator-field-mapper",!0),J=p("ConfirmationModal");return n(),f("div",$,[i("div",ee,[i("div",null,[o(m,{class:"mb-1.5"},{default:d(()=>[c(r(e.__("Replicator Field")),1)]),_:1}),o(y,{modelValue:l.localConfig.replicator_field,"onUpdate:modelValue":[a[0]||(a[0]=s=>l.localConfig.replicator_field=s),a[1]||(a[1]=()=>{l.localConfig.set=""})],options:t.replicatorFieldOptions,placeholder:t.replicatorFieldOptions.length>0?e.__("Select replicator field"):e.__("No replicator fields available"),disabled:t.replicatorFieldOptions.length===0},null,8,["modelValue","options","placeholder","disabled"]),t.replicatorFieldOptions.length===0?(n(),f("div",le,[i("strong",null,r(e.__("No replicator fields found.")),1),a[7]||(a[7]=i("br",null,null,-1)),c(" "+r(e.__('Make sure your Structured Data Template has "Use for collection" or "Use for taxonomy" set, and that collection/taxonomy has replicator fields in its blueprint.')),1)])):V("",!0)]),i("div",null,[o(m,{class:"mb-1.5"},{default:d(()=>[c(r(e.__("Limit to Set (optional)")),1)]),_:1}),o(y,{modelValue:l.localConfig.set,"onUpdate:modelValue":a[2]||(a[2]=s=>l.localConfig.set=s),options:t.setOptions,placeholder:e.__("All sets"),clearable:!0},null,8,["modelValue","options","placeholder"])])]),o(g,null,{default:d(()=>[o(j,null,{default:d(()=>[i("div",te,[i("span",null,r(e.__("Flat mode")),1),o(U,null,{default:d(()=>[o(A,{modelValue:l.localConfig.flat,"onUpdate:modelValue":a[3]||(a[3]=s=>l.localConfig.flat=s),label:e.__("Create a flat object where each replicator row becomes a key-value pair."),class:"items-center"},null,8,["modelValue","label"])]),_:1})])]),_:1}),l.localConfig.flat?(n(),_(F,{key:0},{default:d(()=>[i("div",ae,[i("div",null,[o(m,{class:"mb-1.5"},{default:d(()=>[c(r(e.__("Key Field")),1)]),_:1}),o(y,{modelValue:l.localConfig.flat_key_field,"onUpdate:modelValue":a[4]||(a[4]=s=>l.localConfig.flat_key_field=s),options:t.flatFieldOptions,placeholder:e.__("Select field to use as key")},null,8,["modelValue","options","placeholder"]),i("p",oe,r(e.__("Field value will be used as the object key")),1)]),i("div",null,[o(m,{class:"mb-1.5"},{default:d(()=>[c(r(e.__("Value Field")),1)]),_:1}),o(y,{modelValue:l.localConfig.flat_value_field,"onUpdate:modelValue":a[5]||(a[5]=s=>l.localConfig.flat_value_field=s),options:t.flatFieldOptions,placeholder:e.__("Select field to use as value")},null,8,["modelValue","options","placeholder"]),i("p",ie,r(e.__("Field value will be used as the object value")),1)])])]),_:1})):V("",!0)]),_:1}),l.localConfig.flat?V("",!0):(n(),_(F,{key:0,class:"mt-3"},{default:d(()=>[i("div",de,[i("h4",ne,r(e.__("Field Mappings")),1),o(k,{class:"text-sm",onClick:t.addMapping},{default:d(()=>[c(r(e.__("Add Mapping")),1)]),_:1},8,["onClick"])]),l.localConfig.mappings.length?V("",!0):(n(),f("div",se,r(e.__("No mappings yet. Add one to map replicator fields into your JSON-LD object.")),1)),(n(!0),f(O,null,D(l.localConfig.mappings,(s,M)=>(n(),_(F,{key:M,class:"mb-3"},{default:d(()=>[i("div",re,[i("div",ue,[o(m,{class:"mb-1.5"},{default:d(()=>[c(r(e.__("JSON-LD Key")),1)]),_:1}),o(N,{modelValue:s.key,"onUpdate:modelValue":[h=>s.key=h,()=>t.sanitizeKey(s)],placeholder:"e.g. name"},null,8,["modelValue","onUpdate:modelValue"])]),i("div",me,[o(m,{class:"mb-1.5"},{default:d(()=>[c(r(e.__("Source")),1)]),_:1}),o(y,{modelValue:s.mode,"onUpdate:modelValue":h=>s.mode=h,options:l.modeOptions},null,8,["modelValue","onUpdate:modelValue","options"])]),o(k,{class:"mt-6",variant:"danger",onClick:h=>t.requestRemoveMapping(M)},{default:d(()=>[c(r(e.__("Remove")),1)]),_:1},8,["onClick"])]),i("div",ce,[s.mode==="static"?(n(),f(O,{key:0},[o(m,{class:"mb-1.5"},{default:d(()=>[c(r(e.__("Static Value")),1)]),_:1}),o(N,{modelValue:s.static,"onUpdate:modelValue":h=>s.static=h,placeholder:e.__("e.g. PropertyValue")},null,8,["modelValue","onUpdate:modelValue","placeholder"])],64)):s.mode==="field"?(n(),f(O,{key:1},[o(m,{class:"mb-1.5"},{default:d(()=>[c(r(e.__("Replicator Field")),1)]),_:1}),o(y,{modelValue:s.field,"onUpdate:modelValue":h=>s.field=h,options:t.getFieldOptionsForMapping(s),placeholder:e.__("Select field")},null,8,["modelValue","onUpdate:modelValue","options","placeholder"])],64)):s.mode==="nested_replicator"?(n(),_(F,{key:2,class:"mt-2"},{default:d(()=>[o(I,{modelValue:s.nested,"onUpdate:modelValue":h=>s.nested=h,"replicator-fields":u.replicatorFields},null,8,["modelValue","onUpdate:modelValue","replicator-fields"])]),_:2},1024)):V("",!0)])]),_:2},1024))),128))]),_:1})),l.confirmRemoveMappingOpen?(n(),_(J,{key:1,open:l.confirmRemoveMappingOpen,"onUpdate:open":a[6]||(a[6]=s=>l.confirmRemoveMappingOpen=s),"body-text":e.__("Are you sure you want to remove this mapping? This action cannot be undone."),onConfirm:t.confirmRemoveMapping},null,8,["open","body-text","onConfirm"])):V("",!0)])}const pe=T(Y,[["render",fe]]),ye={name:"StructuredDataObject",components:{Button:C,Card:w,ConfirmationModal:z,Input:R,Label:S,StructuredDataFieldEditor:W(()=>X(()=>Promise.resolve().then(()=>xe),void 0))},props:{modelValue:{type:Object,default:()=>({specialProps:{type:"",id:""},fields:[]})},baseUrl:{type:String,required:!0},parentType:{type:String,default:""},fieldKey:{type:String,default:""},replicatorFields:{type:Array,default:()=>[]}},data(){return{objectData:JSON.parse(JSON.stringify(this.modelValue)),confirmRemoveFieldOpen:!1,fieldIndexToRemove:null,confirmRemoveArrayValueOpen:!1,arrayRemoveContext:{field:null,valueIndex:null}}},computed:{suggestedId(){return this.objectData.specialProps.type?`{{ site:url }}/#${this.objectData.specialProps.type}`:""},selectOptions(){return[{value:"string",label:"String"},{value:"numeric",label:"Numeric"},{value:"array",label:"Array"},{value:"object",label:"Object"},{value:"replicator_object_array",label:"Replicator Object Array"}]}},watch:{objectData:{deep:!0,handler(e){const a=JSON.stringify(e),u=JSON.stringify(this.modelValue);a!==u&&this.$emit("update:modelValue",JSON.parse(JSON.stringify(e)))}},"objectData.specialProps.type"(e){!this.objectData.specialProps.id&&e&&this.useDefaultId()},modelValue:{deep:!0,handler(e){const a=JSON.stringify(e),u=JSON.stringify(this.objectData);a!==u&&(this.objectData=JSON.parse(JSON.stringify(e)))}}},methods:{addField(){this.objectData.fields.push({key:"",type:"string",value:"",values:[],fields:[],config:{}})},removeField(e){this.objectData.fields.splice(e,1)},addArrayValue(e){Q(e)},removeArrayValue(e,a){G(e,a)},validateKey(e){Z(e)},useDefaultId(){this.objectData.specialProps.id=this.suggestedId},handleTypeChange(e){H(e)},requestRemoveField(e){this.fieldIndexToRemove=e,this.confirmRemoveFieldOpen=!0},confirmRemoveField(){this.fieldIndexToRemove!==null&&(this.removeField(this.fieldIndexToRemove),this.fieldIndexToRemove=null)},requestRemoveArrayValue(e,a){this.arrayRemoveContext={field:e,valueIndex:a},this.confirmRemoveArrayValueOpen=!0},confirmRemoveArrayValue(){const e=this.arrayRemoveContext;!e||!e.field||e.valueIndex===null||(this.removeArrayValue(e.field,e.valueIndex),this.arrayRemoveContext={field:null,valueIndex:null})}}},ve={class:"mb-4"},be={class:"mb-2"},ge={class:"mb-2"},Ve={class:"flex"};function he(e,a,u,v,l,t){const m=p("Label"),y=p("Input"),A=p("Button"),U=p("StructuredDataFieldEditor"),j=p("ConfirmationModal"),F=p("Card");return n(),_(F,{class:"structured-data-object"},{default:d(()=>[i("div",ve,[i("div",be,[o(m,{class:"mb-1.5"},{default:d(()=>[...a[4]||(a[4]=[c("@type",-1)])]),_:1}),o(y,{modelValue:l.objectData.specialProps.type,"onUpdate:modelValue":a[0]||(a[0]=g=>l.objectData.specialProps.type=g),placeholder:"e.g. Organization, Product, BlogPosting"},null,8,["modelValue"])]),i("div",ge,[o(m,{class:"mb-1.5"},{default:d(()=>[...a[5]||(a[5]=[c("@id",-1)])]),_:1}),i("div",Ve,[o(y,{modelValue:l.objectData.specialProps.id,"onUpdate:modelValue":a[1]||(a[1]=g=>l.objectData.specialProps.id=g),class:"flex-1",placeholder:t.suggestedId},null,8,["modelValue","placeholder"]),o(A,{class:"ml-2",onClick:t.useDefaultId},{default:d(()=>[c(r(e.__("Use Default")),1)]),_:1},8,["onClick"])])])]),i("div",null,[(n(!0),f(O,null,D(l.objectData.fields,(g,k)=>(n(),f("div",{key:k,class:"mb-4 border-b pb-4"},[o(U,{field:g,"select-options":t.selectOptions,"base-url":u.baseUrl,"replicator-fields":u.replicatorFields,"remove-field-label":e.__("Remove Property"),onValidateKey:t.validateKey,onTypeChange:t.handleTypeChange,onAddArrayValue:t.addArrayValue,onRemoveArrayValue:t.requestRemoveArrayValue,onRemoveField:N=>t.requestRemoveField(k)},null,8,["field","select-options","base-url","replicator-fields","remove-field-label","onValidateKey","onTypeChange","onAddArrayValue","onRemoveArrayValue","onRemoveField"])]))),128)),o(A,{onClick:t.addField,variant:"primary"},{default:d(()=>[c(r(e.__("Add Property")),1)]),_:1},8,["onClick"])]),l.confirmRemoveFieldOpen?(n(),_(j,{key:0,open:l.confirmRemoveFieldOpen,"onUpdate:open":a[2]||(a[2]=g=>l.confirmRemoveFieldOpen=g),"body-text":e.__("Are you sure you want to remove this field? This action cannot be undone."),onConfirm:t.confirmRemoveField},null,8,["open","body-text","onConfirm"])):V("",!0),l.confirmRemoveArrayValueOpen?(n(),_(j,{key:1,open:l.confirmRemoveArrayValueOpen,"onUpdate:open":a[3]||(a[3]=g=>l.confirmRemoveArrayValueOpen=g),"body-text":e.__("Are you sure you want to remove this value? This action cannot be undone."),onConfirm:t.confirmRemoveArrayValue},null,8,["open","body-text","onConfirm"])):V("",!0)]),_:1})}const P=T(ye,[["render",he]]),_e={class:"grid grid-cols-1 md:grid-cols-3 gap-3"},Ce={class:"col-span-2"},Fe={class:"mt-3"},Re={key:2,class:"mt-2"},Oe={class:"flex flex-col gap-2 space-y-2"},ke={key:3,class:"mt-2"},Se={key:4,class:"mt-2"},Ae={class:"flex flex-col gap-2 space-y-2"},je={key:5,class:"mt-2"},De={key:6,class:"mt-2"},Ue={key:0,class:"flex justify-end mt-3"},Ne={__name:"StructuredDataFieldEditor",props:{field:{type:Object,required:!0},selectOptions:{type:Array,required:!0},taxonomyTermOptions:{type:Array,default:()=>[]},baseUrl:{type:String,default:""},replicatorFields:{type:Array,default:()=>[]},objectArrayEnabled:{type:Boolean,default:!1},dataObjectEnabled:{type:Boolean,default:!1},showRemoveButton:{type:Boolean,default:!0},removeFieldLabel:{type:String,default:"Remove Field"}},emits:["validate-key","type-change","add-array-value","remove-array-value","remove-field"],setup(e,{emit:a}){const u=a;return(v,l)=>(n(),f("div",null,[i("div",_e,[i("div",Ce,[o(b(S),{class:"mb-1.5"},{default:d(()=>[c(r(v.__("Key")),1)]),_:1}),o(b(R),{modelValue:e.field.key,"onUpdate:modelValue":[l[0]||(l[0]=t=>e.field.key=t),l[1]||(l[1]=t=>u("validate-key",e.field))]},null,8,["modelValue"])]),i("div",null,[o(b(S),{class:"mb-1.5"},{default:d(()=>[c(r(v.__("Type")),1)]),_:1}),o(b(x),{modelValue:e.field.type,"onUpdate:modelValue":[l[2]||(l[2]=t=>e.field.type=t),l[3]||(l[3]=t=>u("type-change",e.field))],options:e.selectOptions},null,8,["modelValue","options"])])]),i("div",Fe,[o(b(S),{class:"mb-1.5"},{default:d(()=>[c(r(v.__("Value")),1)]),_:1}),e.field.type==="string"?(n(),_(b(R),{key:0,modelValue:e.field.value,"onUpdate:modelValue":l[4]||(l[4]=t=>e.field.value=t),placeholder:"Enter value"},null,8,["modelValue"])):V("",!0),e.field.type==="numeric"?(n(),_(b(R),{key:1,type:"number",modelValue:e.field.value,"onUpdate:modelValue":l[5]||(l[5]=t=>e.field.value=t),placeholder:"Enter value"},null,8,["modelValue"])):e.field.type==="array"?(n(),f("div",Re,[i("div",Oe,[(n(!0),f(O,null,D(e.field.values,(t,m)=>(n(),f("div",{key:m,class:"flex items-center gap-2"},[o(b(R),{modelValue:e.field.values[m],"onUpdate:modelValue":y=>e.field.values[m]=y},null,8,["modelValue","onUpdate:modelValue"]),o(b(C),{onClick:y=>u("remove-array-value",e.field,m),class:"inline-flex items-center px-2 py-1",variant:"danger"},{default:d(()=>[i("span",null,r(v.__("Remove")),1)]),_:1},8,["onClick"])]))),128))]),o(b(C),{onClick:l[6]||(l[6]=t=>u("add-array-value",e.field)),variant:"primary",class:"mt-2 text-sm"},{default:d(()=>[c(r(v.__("Add Value")),1)]),_:1})])):e.field.type==="object"?(n(),f("div",ke,[o(P,{modelValue:e.field.value,"onUpdate:modelValue":l[7]||(l[7]=t=>e.field.value=t),"base-url":e.baseUrl,"field-key":e.field.key,"replicator-fields":e.replicatorFields},null,8,["modelValue","base-url","field-key","replicator-fields"])])):e.objectArrayEnabled&&e.field.type==="object_array"?(n(),f("div",Se,[i("div",Ae,[(n(!0),f(O,null,D(e.field.values,(t,m)=>(n(),f("div",{key:m,class:"flex flex-col gap-2"},[o(P,{modelValue:e.field.values[m],"onUpdate:modelValue":y=>e.field.values[m]=y,"base-url":e.baseUrl},null,8,["modelValue","onUpdate:modelValue","base-url"]),i("div",null,[o(b(C),{onClick:y=>u("remove-array-value",e.field,m),class:"inline-flex self-end items-center px-2 py-1",variant:"danger"},{default:d(()=>[i("span",null,r(v.__("Remove")),1)]),_:1},8,["onClick"])])]))),128))]),o(b(C),{onClick:l[8]||(l[8]=t=>u("add-array-value",e.field)),class:"mt-2 text-sm",variant:"primary"},{default:d(()=>[c(r(v.__("Add Value")),1)]),_:1})])):e.dataObjectEnabled&&e.field.type==="data_object"?(n(),f("div",je,[o(b(x),{modelValue:e.field.value,"onUpdate:modelValue":l[9]||(l[9]=t=>e.field.value=t),options:e.taxonomyTermOptions},null,8,["modelValue","options"])])):e.field.type==="replicator_object_array"?(n(),f("div",De,[o(pe,{modelValue:e.field.config,"onUpdate:modelValue":l[10]||(l[10]=t=>e.field.config=t),"base-url":e.baseUrl,"replicator-fields":e.replicatorFields},null,8,["modelValue","base-url","replicator-fields"])])):V("",!0)]),e.showRemoveButton?(n(),f("div",Ue,[o(b(C),{variant:"danger",onClick:l[11]||(l[11]=t=>u("remove-field"))},{default:d(()=>[c(r(e.removeFieldLabel),1)]),_:1})])):V("",!0)]))}},xe=Object.freeze(Object.defineProperty({__proto__:null,default:Ne},Symbol.toStringTag,{value:"Module"}));export{Ne as default}; +import{_ as T,S as x,P as E,a as B,L as S,I as R,H as L,D as K,C as q,b as w,B as C,r as p,o as n,c as f,d as i,e as o,w as d,f as c,t as r,g as V,h as _,F as O,i as D,j as z,k as H,v as Z,l as G,m as Q,n as W,p as X,u as b}from"./statamic-structured-data-BmgHG2xH.js";const Y={name:"ReplicatorFieldMapper",components:{Button:C,Card:w,Checkbox:q,Description:K,Heading:L,Input:R,Label:S,Panel:B,PanelHeader:E,Select:x},props:{modelValue:{type:Object,default:()=>({replicator_field:"",set:"",mappings:[],flat:!1,flat_key_field:"",flat_value_field:""})},replicatorFields:{type:Array,default:()=>[]}},emits:["update:modelValue"],data(){return{localConfig:this.normalizeConfig(this.modelValue),modeOptions:[{value:"field",label:"From Replicator Field"},{value:"static",label:"Static Value"},{value:"nested_replicator",label:"Nested Replicator"}],confirmRemoveMappingOpen:!1,mappingIndexToRemove:null}},computed:{availableReplicatorFields(){return!this.replicatorFields||!Array.isArray(this.replicatorFields)?[]:this.replicatorFields},replicatorFieldOptions(){return!this.availableReplicatorFields||this.availableReplicatorFields.length===0?[]:this.availableReplicatorFields.map(e=>({value:e.handle,label:e.display||e.handle}))},selectedReplicatorField(){return this.localConfig.replicator_field?this.availableReplicatorFields.find(e=>e.handle===this.localConfig.replicator_field):null},setOptions(){return this.selectedReplicatorField?this.selectedReplicatorField.sets.map(e=>({value:e.value,label:e.label})):[]},flatFieldOptions(){return this.getFieldOptionsForMapping({})}},watch:{localConfig:{deep:!0,handler(e){const a=JSON.stringify(e),u=JSON.stringify(this.modelValue);a!==u&&this.$emit("update:modelValue",JSON.parse(a))}},modelValue:{deep:!0,handler(e){const a=this.normalizeConfig(e),u=JSON.stringify(a),v=JSON.stringify(this.localConfig);u!==v&&(this.localConfig=JSON.parse(u))}}},methods:{normalizeConfig(e){const a={replicator_field:"",set:"",mappings:[],flat:!1,flat_key_field:"",flat_value_field:""};return!e||typeof e!="object"?a:{replicator_field:e.replicator_field||"",set:e.set||"",mappings:Array.isArray(e.mappings)?JSON.parse(JSON.stringify(e.mappings)):[],flat:e.flat===!0,flat_key_field:e.flat_key_field||"",flat_value_field:e.flat_value_field||""}},addMapping(){this.localConfig.mappings.push({key:"",mode:"field",field:"",static:"",nested:{replicator_field:"",set:"",mappings:[]}})},removeMapping(e){this.localConfig.mappings.splice(e,1)},requestRemoveMapping(e){this.mappingIndexToRemove=e,this.confirmRemoveMappingOpen=!0},confirmRemoveMapping(){this.mappingIndexToRemove!==null&&(this.removeMapping(this.mappingIndexToRemove),this.mappingIndexToRemove=null)},sanitizeKey(e){e.key=e.key.replace(/[^a-zA-Z0-9@:-]/g,"")},getFieldOptionsForMapping(e){if(!this.selectedReplicatorField)return[];const a=this.selectedReplicatorField.sets||[];let u=[];if(this.localConfig.set){const t=a.find(m=>m.value===this.localConfig.set);t&&t.fields&&(u=t.fields)}else a.forEach(t=>{t.fields&&(u=u.concat(t.fields))});const v=[],l=new Set;return u.forEach(t=>{l.has(t.value)||(l.add(t.value),v.push(t))}),v}}},$={class:"replicator-mapper space-y-3"},ee={class:"grid grid-cols-1 md:grid-cols-2 gap-3"},le={key:0,class:"text-xs text-yellow-600 mt-1 bg-yellow-50 p-2 rounded"},te={class:"flex flex-col justify-center gap-2"},ae={class:"grid grid-cols-1 md:grid-cols-2 gap-3"},oe={class:"text-xs text-gray-500 mt-1"},ie={class:"text-xs text-gray-500 mt-1"},de={class:"flex items-center justify-between mb-2"},ne={class:"font-semibold text-gray-700"},se={key:0,class:"text-sm text-gray-500"},re={class:"flex items-start gap-2"},ue={class:"flex-1"},me={class:"w-40"},ce={class:"mt-2"};function fe(e,a,u,v,l,t){const m=p("Label"),y=p("Select"),A=p("Checkbox"),U=p("Description"),j=p("PanelHeader"),F=p("Card"),g=p("Panel"),k=p("Button"),N=p("Input"),I=p("replicator-field-mapper",!0),J=p("ConfirmationModal");return n(),f("div",$,[i("div",ee,[i("div",null,[o(m,{class:"mb-1.5"},{default:d(()=>[c(r(e.__("Replicator Field")),1)]),_:1}),o(y,{modelValue:l.localConfig.replicator_field,"onUpdate:modelValue":[a[0]||(a[0]=s=>l.localConfig.replicator_field=s),a[1]||(a[1]=()=>{l.localConfig.set=""})],options:t.replicatorFieldOptions,placeholder:t.replicatorFieldOptions.length>0?e.__("Select replicator field"):e.__("No replicator fields available"),disabled:t.replicatorFieldOptions.length===0},null,8,["modelValue","options","placeholder","disabled"]),t.replicatorFieldOptions.length===0?(n(),f("div",le,[i("strong",null,r(e.__("No replicator fields found.")),1),a[7]||(a[7]=i("br",null,null,-1)),c(" "+r(e.__('Make sure your Structured Data Template has "Use for collection" or "Use for taxonomy" set, and that collection/taxonomy has replicator fields in its blueprint.')),1)])):V("",!0)]),i("div",null,[o(m,{class:"mb-1.5"},{default:d(()=>[c(r(e.__("Limit to Set (optional)")),1)]),_:1}),o(y,{modelValue:l.localConfig.set,"onUpdate:modelValue":a[2]||(a[2]=s=>l.localConfig.set=s),options:t.setOptions,placeholder:e.__("All sets"),clearable:!0},null,8,["modelValue","options","placeholder"])])]),o(g,null,{default:d(()=>[o(j,null,{default:d(()=>[i("div",te,[i("span",null,r(e.__("Flat mode")),1),o(U,null,{default:d(()=>[o(A,{modelValue:l.localConfig.flat,"onUpdate:modelValue":a[3]||(a[3]=s=>l.localConfig.flat=s),label:e.__("Create a flat object where each replicator row becomes a key-value pair."),class:"items-center"},null,8,["modelValue","label"])]),_:1})])]),_:1}),l.localConfig.flat?(n(),_(F,{key:0},{default:d(()=>[i("div",ae,[i("div",null,[o(m,{class:"mb-1.5"},{default:d(()=>[c(r(e.__("Key Field")),1)]),_:1}),o(y,{modelValue:l.localConfig.flat_key_field,"onUpdate:modelValue":a[4]||(a[4]=s=>l.localConfig.flat_key_field=s),options:t.flatFieldOptions,placeholder:e.__("Select field to use as key")},null,8,["modelValue","options","placeholder"]),i("p",oe,r(e.__("Field value will be used as the object key")),1)]),i("div",null,[o(m,{class:"mb-1.5"},{default:d(()=>[c(r(e.__("Value Field")),1)]),_:1}),o(y,{modelValue:l.localConfig.flat_value_field,"onUpdate:modelValue":a[5]||(a[5]=s=>l.localConfig.flat_value_field=s),options:t.flatFieldOptions,placeholder:e.__("Select field to use as value")},null,8,["modelValue","options","placeholder"]),i("p",ie,r(e.__("Field value will be used as the object value")),1)])])]),_:1})):V("",!0)]),_:1}),l.localConfig.flat?V("",!0):(n(),_(F,{key:0,class:"mt-3"},{default:d(()=>[i("div",de,[i("h4",ne,r(e.__("Field Mappings")),1),o(k,{class:"text-sm",onClick:t.addMapping},{default:d(()=>[c(r(e.__("Add Mapping")),1)]),_:1},8,["onClick"])]),l.localConfig.mappings.length?V("",!0):(n(),f("div",se,r(e.__("No mappings yet. Add one to map replicator fields into your JSON-LD object.")),1)),(n(!0),f(O,null,D(l.localConfig.mappings,(s,M)=>(n(),_(F,{key:M,class:"mb-3"},{default:d(()=>[i("div",re,[i("div",ue,[o(m,{class:"mb-1.5"},{default:d(()=>[c(r(e.__("JSON-LD Key")),1)]),_:1}),o(N,{modelValue:s.key,"onUpdate:modelValue":[h=>s.key=h,()=>t.sanitizeKey(s)],placeholder:"e.g. name"},null,8,["modelValue","onUpdate:modelValue"])]),i("div",me,[o(m,{class:"mb-1.5"},{default:d(()=>[c(r(e.__("Source")),1)]),_:1}),o(y,{modelValue:s.mode,"onUpdate:modelValue":h=>s.mode=h,options:l.modeOptions},null,8,["modelValue","onUpdate:modelValue","options"])]),o(k,{class:"mt-6",variant:"danger",onClick:h=>t.requestRemoveMapping(M)},{default:d(()=>[c(r(e.__("Remove")),1)]),_:1},8,["onClick"])]),i("div",ce,[s.mode==="static"?(n(),f(O,{key:0},[o(m,{class:"mb-1.5"},{default:d(()=>[c(r(e.__("Static Value")),1)]),_:1}),o(N,{modelValue:s.static,"onUpdate:modelValue":h=>s.static=h,placeholder:e.__("e.g. PropertyValue")},null,8,["modelValue","onUpdate:modelValue","placeholder"])],64)):s.mode==="field"?(n(),f(O,{key:1},[o(m,{class:"mb-1.5"},{default:d(()=>[c(r(e.__("Replicator Field")),1)]),_:1}),o(y,{modelValue:s.field,"onUpdate:modelValue":h=>s.field=h,options:t.getFieldOptionsForMapping(s),placeholder:e.__("Select field")},null,8,["modelValue","onUpdate:modelValue","options","placeholder"])],64)):s.mode==="nested_replicator"?(n(),_(F,{key:2,class:"mt-2"},{default:d(()=>[o(I,{modelValue:s.nested,"onUpdate:modelValue":h=>s.nested=h,"replicator-fields":u.replicatorFields},null,8,["modelValue","onUpdate:modelValue","replicator-fields"])]),_:2},1024)):V("",!0)])]),_:2},1024))),128))]),_:1})),l.confirmRemoveMappingOpen?(n(),_(J,{key:1,open:l.confirmRemoveMappingOpen,"onUpdate:open":a[6]||(a[6]=s=>l.confirmRemoveMappingOpen=s),"body-text":e.__("Are you sure you want to remove this mapping? This action cannot be undone."),onConfirm:t.confirmRemoveMapping},null,8,["open","body-text","onConfirm"])):V("",!0)])}const pe=T(Y,[["render",fe]]),ye={name:"StructuredDataObject",components:{Button:C,Card:w,ConfirmationModal:z,Input:R,Label:S,StructuredDataFieldEditor:W(()=>X(()=>Promise.resolve().then(()=>xe),void 0))},props:{modelValue:{type:Object,default:()=>({specialProps:{type:"",id:""},fields:[]})},baseUrl:{type:String,required:!0},parentType:{type:String,default:""},fieldKey:{type:String,default:""},replicatorFields:{type:Array,default:()=>[]}},data(){return{objectData:JSON.parse(JSON.stringify(this.modelValue)),confirmRemoveFieldOpen:!1,fieldIndexToRemove:null,confirmRemoveArrayValueOpen:!1,arrayRemoveContext:{field:null,valueIndex:null}}},computed:{suggestedId(){return this.objectData.specialProps.type?`{{ site:url }}/#${this.objectData.specialProps.type}`:""},selectOptions(){return[{value:"string",label:"String"},{value:"numeric",label:"Numeric"},{value:"array",label:"Array"},{value:"object",label:"Object"},{value:"replicator_object_array",label:"Replicator Object Array"}]}},watch:{objectData:{deep:!0,handler(e){const a=JSON.stringify(e),u=JSON.stringify(this.modelValue);a!==u&&this.$emit("update:modelValue",JSON.parse(JSON.stringify(e)))}},"objectData.specialProps.type"(e){!this.objectData.specialProps.id&&e&&this.useDefaultId()},modelValue:{deep:!0,handler(e){const a=JSON.stringify(e),u=JSON.stringify(this.objectData);a!==u&&(this.objectData=JSON.parse(JSON.stringify(e)))}}},methods:{addField(){this.objectData.fields.push({key:"",type:"string",value:"",values:[],fields:[],config:{}})},removeField(e){this.objectData.fields.splice(e,1)},addArrayValue(e){Q(e)},removeArrayValue(e,a){G(e,a)},validateKey(e){Z(e)},useDefaultId(){this.objectData.specialProps.id=this.suggestedId},handleTypeChange(e){H(e)},requestRemoveField(e){this.fieldIndexToRemove=e,this.confirmRemoveFieldOpen=!0},confirmRemoveField(){this.fieldIndexToRemove!==null&&(this.removeField(this.fieldIndexToRemove),this.fieldIndexToRemove=null)},requestRemoveArrayValue(e,a){this.arrayRemoveContext={field:e,valueIndex:a},this.confirmRemoveArrayValueOpen=!0},confirmRemoveArrayValue(){const e=this.arrayRemoveContext;!e||!e.field||e.valueIndex===null||(this.removeArrayValue(e.field,e.valueIndex),this.arrayRemoveContext={field:null,valueIndex:null})}}},ve={class:"mb-4"},be={class:"mb-2"},ge={class:"mb-2"},Ve={class:"flex"};function he(e,a,u,v,l,t){const m=p("Label"),y=p("Input"),A=p("Button"),U=p("StructuredDataFieldEditor"),j=p("ConfirmationModal"),F=p("Card");return n(),_(F,{class:"structured-data-object"},{default:d(()=>[i("div",ve,[i("div",be,[o(m,{class:"mb-1.5"},{default:d(()=>[...a[4]||(a[4]=[c("@type",-1)])]),_:1}),o(y,{modelValue:l.objectData.specialProps.type,"onUpdate:modelValue":a[0]||(a[0]=g=>l.objectData.specialProps.type=g),placeholder:"e.g. Organization, Product, BlogPosting"},null,8,["modelValue"])]),i("div",ge,[o(m,{class:"mb-1.5"},{default:d(()=>[...a[5]||(a[5]=[c("@id",-1)])]),_:1}),i("div",Ve,[o(y,{modelValue:l.objectData.specialProps.id,"onUpdate:modelValue":a[1]||(a[1]=g=>l.objectData.specialProps.id=g),class:"flex-1",placeholder:t.suggestedId},null,8,["modelValue","placeholder"]),o(A,{class:"ml-2",onClick:t.useDefaultId},{default:d(()=>[c(r(e.__("Use Default")),1)]),_:1},8,["onClick"])])])]),i("div",null,[(n(!0),f(O,null,D(l.objectData.fields,(g,k)=>(n(),f("div",{key:k,class:"mb-4 border-b pb-4"},[o(U,{field:g,"select-options":t.selectOptions,"base-url":u.baseUrl,"replicator-fields":u.replicatorFields,"remove-field-label":e.__("Remove Property"),onValidateKey:t.validateKey,onTypeChange:t.handleTypeChange,onAddArrayValue:t.addArrayValue,onRemoveArrayValue:t.requestRemoveArrayValue,onRemoveField:N=>t.requestRemoveField(k)},null,8,["field","select-options","base-url","replicator-fields","remove-field-label","onValidateKey","onTypeChange","onAddArrayValue","onRemoveArrayValue","onRemoveField"])]))),128)),o(A,{onClick:t.addField,variant:"primary"},{default:d(()=>[c(r(e.__("Add Property")),1)]),_:1},8,["onClick"])]),l.confirmRemoveFieldOpen?(n(),_(j,{key:0,open:l.confirmRemoveFieldOpen,"onUpdate:open":a[2]||(a[2]=g=>l.confirmRemoveFieldOpen=g),"body-text":e.__("Are you sure you want to remove this field? This action cannot be undone."),onConfirm:t.confirmRemoveField},null,8,["open","body-text","onConfirm"])):V("",!0),l.confirmRemoveArrayValueOpen?(n(),_(j,{key:1,open:l.confirmRemoveArrayValueOpen,"onUpdate:open":a[3]||(a[3]=g=>l.confirmRemoveArrayValueOpen=g),"body-text":e.__("Are you sure you want to remove this value? This action cannot be undone."),onConfirm:t.confirmRemoveArrayValue},null,8,["open","body-text","onConfirm"])):V("",!0)]),_:1})}const P=T(ye,[["render",he]]),_e={class:"grid grid-cols-1 md:grid-cols-3 gap-3"},Ce={class:"col-span-2"},Fe={class:"mt-3"},Re={key:2,class:"mt-2"},Oe={class:"flex flex-col gap-2 space-y-2"},ke={key:3,class:"mt-2"},Se={key:4,class:"mt-2"},Ae={class:"flex flex-col gap-2 space-y-2"},je={key:5,class:"mt-2"},De={key:6,class:"mt-2"},Ue={key:0,class:"flex justify-end mt-3"},Ne={__name:"StructuredDataFieldEditor",props:{field:{type:Object,required:!0},selectOptions:{type:Array,required:!0},taxonomyTermOptions:{type:Array,default:()=>[]},baseUrl:{type:String,default:""},replicatorFields:{type:Array,default:()=>[]},objectArrayEnabled:{type:Boolean,default:!1},dataObjectEnabled:{type:Boolean,default:!1},showRemoveButton:{type:Boolean,default:!0},removeFieldLabel:{type:String,default:"Remove Field"}},emits:["validate-key","type-change","add-array-value","remove-array-value","remove-field"],setup(e,{emit:a}){const u=a;return(v,l)=>(n(),f("div",null,[i("div",_e,[i("div",Ce,[o(b(S),{class:"mb-1.5"},{default:d(()=>[c(r(v.__("Key")),1)]),_:1}),o(b(R),{modelValue:e.field.key,"onUpdate:modelValue":[l[0]||(l[0]=t=>e.field.key=t),l[1]||(l[1]=t=>u("validate-key",e.field))]},null,8,["modelValue"])]),i("div",null,[o(b(S),{class:"mb-1.5"},{default:d(()=>[c(r(v.__("Type")),1)]),_:1}),o(b(x),{modelValue:e.field.type,"onUpdate:modelValue":[l[2]||(l[2]=t=>e.field.type=t),l[3]||(l[3]=t=>u("type-change",e.field))],options:e.selectOptions},null,8,["modelValue","options"])])]),i("div",Fe,[o(b(S),{class:"mb-1.5"},{default:d(()=>[c(r(v.__("Value")),1)]),_:1}),e.field.type==="string"?(n(),_(b(R),{key:0,modelValue:e.field.value,"onUpdate:modelValue":l[4]||(l[4]=t=>e.field.value=t),placeholder:"Enter value"},null,8,["modelValue"])):V("",!0),e.field.type==="numeric"?(n(),_(b(R),{key:1,type:"number",modelValue:e.field.value,"onUpdate:modelValue":l[5]||(l[5]=t=>e.field.value=t),placeholder:"Enter value"},null,8,["modelValue"])):e.field.type==="array"?(n(),f("div",Re,[i("div",Oe,[(n(!0),f(O,null,D(e.field.values,(t,m)=>(n(),f("div",{key:m,class:"flex items-center gap-2"},[o(b(R),{modelValue:e.field.values[m],"onUpdate:modelValue":y=>e.field.values[m]=y},null,8,["modelValue","onUpdate:modelValue"]),o(b(C),{onClick:y=>u("remove-array-value",e.field,m),class:"inline-flex items-center px-2 py-1",variant:"danger"},{default:d(()=>[i("span",null,r(v.__("Remove")),1)]),_:1},8,["onClick"])]))),128))]),o(b(C),{onClick:l[6]||(l[6]=t=>u("add-array-value",e.field)),variant:"primary",class:"mt-2 text-sm"},{default:d(()=>[c(r(v.__("Add Value")),1)]),_:1})])):e.field.type==="object"?(n(),f("div",ke,[o(P,{modelValue:e.field.value,"onUpdate:modelValue":l[7]||(l[7]=t=>e.field.value=t),"base-url":e.baseUrl,"field-key":e.field.key,"replicator-fields":e.replicatorFields},null,8,["modelValue","base-url","field-key","replicator-fields"])])):e.objectArrayEnabled&&e.field.type==="object_array"?(n(),f("div",Se,[i("div",Ae,[(n(!0),f(O,null,D(e.field.values,(t,m)=>(n(),f("div",{key:m,class:"flex flex-col gap-2"},[o(P,{modelValue:e.field.values[m],"onUpdate:modelValue":y=>e.field.values[m]=y,"base-url":e.baseUrl},null,8,["modelValue","onUpdate:modelValue","base-url"]),i("div",null,[o(b(C),{onClick:y=>u("remove-array-value",e.field,m),class:"inline-flex self-end items-center px-2 py-1",variant:"danger"},{default:d(()=>[i("span",null,r(v.__("Remove")),1)]),_:1},8,["onClick"])])]))),128))]),o(b(C),{onClick:l[8]||(l[8]=t=>u("add-array-value",e.field)),class:"mt-2 text-sm",variant:"primary"},{default:d(()=>[c(r(v.__("Add Value")),1)]),_:1})])):e.dataObjectEnabled&&e.field.type==="data_object"?(n(),f("div",je,[o(b(x),{modelValue:e.field.value,"onUpdate:modelValue":l[9]||(l[9]=t=>e.field.value=t),options:e.taxonomyTermOptions},null,8,["modelValue","options"])])):e.field.type==="replicator_object_array"?(n(),f("div",De,[o(pe,{modelValue:e.field.config,"onUpdate:modelValue":l[10]||(l[10]=t=>e.field.config=t),"base-url":e.baseUrl,"replicator-fields":e.replicatorFields},null,8,["modelValue","base-url","replicator-fields"])])):V("",!0)]),e.showRemoveButton?(n(),f("div",Ue,[o(b(C),{variant:"danger",onClick:l[11]||(l[11]=t=>u("remove-field"))},{default:d(()=>[c(r(e.removeFieldLabel),1)]),_:1})])):V("",!0)]))}},xe=Object.freeze(Object.defineProperty({__proto__:null,default:Ne},Symbol.toStringTag,{value:"Module"}));export{Ne as default}; diff --git a/resources/dist/build/assets/statamic-structured-data-DSPlP4f9.js b/resources/dist/build/assets/statamic-structured-data-BmgHG2xH.js similarity index 73% rename from resources/dist/build/assets/statamic-structured-data-DSPlP4f9.js rename to resources/dist/build/assets/statamic-structured-data-BmgHG2xH.js index 0362870..b4b38d1 100644 --- a/resources/dist/build/assets/statamic-structured-data-DSPlP4f9.js +++ b/resources/dist/build/assets/statamic-structured-data-BmgHG2xH.js @@ -1,11 +1,11 @@ -const wn="modulepreload",Fn=function(o){return"/build/"+o},Yr={},un=function(e,n,s){let r=Promise.resolve();if(n&&n.length>0){let t=function(l){return Promise.all(l.map(c=>Promise.resolve(c).then(f=>({status:"fulfilled",value:f}),f=>({status:"rejected",reason:f}))))};document.getElementsByTagName("link");const a=document.querySelector("meta[property=csp-nonce]"),i=(a==null?void 0:a.nonce)||(a==null?void 0:a.getAttribute("nonce"));r=t(n.map(l=>{if(l=Fn(l),l in Yr)return;Yr[l]=!0;const c=l.endsWith(".css"),f=c?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${l}"]${f}`))return;const d=document.createElement("link");if(d.rel=c?"stylesheet":wn,c||(d.as="script"),d.crossOrigin="",d.href=l,i&&d.setAttribute("nonce",i),document.head.appendChild(d),c)return new Promise((v,p)=>{d.addEventListener("load",v),d.addEventListener("error",()=>p(new Error(`Unable to preload CSS for ${l}`)))})}))}function u(t){const a=new Event("vite:preloadError",{cancelable:!0});if(a.payload=t,window.dispatchEvent(a),!a.defaultPrevented)throw t}return r.then(t=>{for(const a of t||[])a.status==="rejected"&&u(a.reason);return e().catch(u)})},cn=window.Vue,{BaseTransition:jn,BaseTransitionPropsValidators:Mn,Comment:Nn,DeprecationTypes:Ln,EffectScope:Un,ErrorCodes:Bn,ErrorTypeStrings:Gn,Fragment:ce,KeepAlive:Vn,ReactiveEffect:Kn,Static:Hn,Suspense:Wn,Teleport:Xn,Text:Yn,TrackOpTypes:zn,Transition:Jn,TransitionGroup:Zn,TriggerOpTypes:Qn,VueElement:kn,__esModule:qn,assertNumber:_n,callWithAsyncErrorHandling:to,callWithErrorHandling:eo,camelize:ro,capitalize:no,cloneVNode:oo,compatUtils:ao,compile:io,computed:Nt,createApp:so,createBlock:Xt,createCommentVNode:Vt,createElementBlock:st,createElementVNode:B,createHydrationRenderer:lo,createPropsRestProxy:uo,createRenderer:co,createSSRApp:fo,createSlots:vo,createStaticVNode:po,createTextVNode:It,createVNode:J,customRef:ho,defineAsyncComponent:Vr,defineComponent:go,defineCustomElement:mo,defineEmits:yo,defineExpose:bo,defineModel:So,defineOptions:xo,defineProps:Eo,defineSSRCustomElement:Oo,defineSlots:Po,devtools:Co,effect:To,effectScope:Io,getCurrentInstance:fn,getCurrentScope:Ao,getCurrentWatcher:Do,getTransitionRawChildren:Ro,guardReactiveProps:$o,h:wo,handleError:Fo,hasInjectionContext:jo,hydrate:Mo,hydrateOnIdle:No,hydrateOnInteraction:Lo,hydrateOnMediaQuery:Uo,hydrateOnVisible:Bo,initCustomFormatter:Go,initDirectivesForSSR:Vo,inject:Ko,isMemoSame:Ho,isProxy:Wo,isReactive:Xo,isReadonly:Yo,isRef:zo,isRuntimeOnly:Jo,isShallow:Zo,isVNode:Qo,markRaw:ko,mergeDefaults:qo,mergeModels:_o,mergeProps:ta,nextTick:ea,nodeOps:ra,normalizeClass:jr,normalizeProps:na,normalizeStyle:dn,onActivated:oa,onBeforeMount:aa,onBeforeUnmount:ia,onBeforeUpdate:sa,onDeactivated:la,onErrorCaptured:ua,onMounted:ca,onRenderTracked:fa,onRenderTriggered:da,onScopeDispose:va,onServerPrefetch:pa,onUnmounted:ha,onUpdated:ga,onWatcherCleanup:ma,openBlock:Q,patchProp:ya,popScopeId:ba,provide:Sa,proxyRefs:xa,pushScopeId:Ea,queuePostFlushCb:Oa,reactive:Mr,readonly:Pa,ref:At,registerRuntimeCompiler:Ca,render:Ta,renderList:fe,renderSlot:Ia,resolveComponent:Ge,resolveDirective:Aa,resolveDynamicComponent:Da,resolveFilter:Ra,resolveTransitionHooks:$a,setBlockTracking:wa,setDevtoolsHook:Fa,setTransitionHooks:ja,shallowReactive:Ma,shallowReadonly:Na,shallowRef:La,ssrContextKey:Ua,ssrUtils:Ba,stop:Ga,toDisplayString:X,toHandlerKey:Va,toHandlers:Ka,toRaw:Ha,toRef:Wa,toRefs:Xa,toValue:Ya,transformVNodeArgs:za,triggerRef:Ja,unref:z,useAttrs:Za,useCssModule:Qa,useCssVars:ka,useHost:qa,useId:_a,useModel:ti,useSSRContext:ei,useShadowRoot:ri,useSlots:ni,useTemplateRef:oi,useTransitionState:ai,vModelCheckbox:ii,vModelDynamic:si,vModelRadio:li,vModelSelect:ui,vModelText:ci,vShow:Pe,version:fi,warn:di,watch:je,watchEffect:vi,watchPostEffect:pi,watchSyncEffect:hi,withAsyncContext:gi,withCtx:tt,withDefaults:mi,withDirectives:Ce,withKeys:yi,withMemo:bi,withModifiers:ir,withScopeId:Si}=cn,xi=Object.freeze(Object.defineProperty({__proto__:null,BaseTransition:jn,BaseTransitionPropsValidators:Mn,Comment:Nn,DeprecationTypes:Ln,EffectScope:Un,ErrorCodes:Bn,ErrorTypeStrings:Gn,Fragment:ce,KeepAlive:Vn,ReactiveEffect:Kn,Static:Hn,Suspense:Wn,Teleport:Xn,Text:Yn,TrackOpTypes:zn,Transition:Jn,TransitionGroup:Zn,TriggerOpTypes:Qn,VueElement:kn,__esModule:qn,assertNumber:_n,callWithAsyncErrorHandling:to,callWithErrorHandling:eo,camelize:ro,capitalize:no,cloneVNode:oo,compatUtils:ao,compile:io,computed:Nt,createApp:so,createBlock:Xt,createCommentVNode:Vt,createElementBlock:st,createElementVNode:B,createHydrationRenderer:lo,createPropsRestProxy:uo,createRenderer:co,createSSRApp:fo,createSlots:vo,createStaticVNode:po,createTextVNode:It,createVNode:J,customRef:ho,default:cn,defineAsyncComponent:Vr,defineComponent:go,defineCustomElement:mo,defineEmits:yo,defineExpose:bo,defineModel:So,defineOptions:xo,defineProps:Eo,defineSSRCustomElement:Oo,defineSlots:Po,devtools:Co,effect:To,effectScope:Io,getCurrentInstance:fn,getCurrentScope:Ao,getCurrentWatcher:Do,getTransitionRawChildren:Ro,guardReactiveProps:$o,h:wo,handleError:Fo,hasInjectionContext:jo,hydrate:Mo,hydrateOnIdle:No,hydrateOnInteraction:Lo,hydrateOnMediaQuery:Uo,hydrateOnVisible:Bo,initCustomFormatter:Go,initDirectivesForSSR:Vo,inject:Ko,isMemoSame:Ho,isProxy:Wo,isReactive:Xo,isReadonly:Yo,isRef:zo,isRuntimeOnly:Jo,isShallow:Zo,isVNode:Qo,markRaw:ko,mergeDefaults:qo,mergeModels:_o,mergeProps:ta,nextTick:ea,nodeOps:ra,normalizeClass:jr,normalizeProps:na,normalizeStyle:dn,onActivated:oa,onBeforeMount:aa,onBeforeUnmount:ia,onBeforeUpdate:sa,onDeactivated:la,onErrorCaptured:ua,onMounted:ca,onRenderTracked:fa,onRenderTriggered:da,onScopeDispose:va,onServerPrefetch:pa,onUnmounted:ha,onUpdated:ga,onWatcherCleanup:ma,openBlock:Q,patchProp:ya,popScopeId:ba,provide:Sa,proxyRefs:xa,pushScopeId:Ea,queuePostFlushCb:Oa,reactive:Mr,readonly:Pa,ref:At,registerRuntimeCompiler:Ca,render:Ta,renderList:fe,renderSlot:Ia,resolveComponent:Ge,resolveDirective:Aa,resolveDynamicComponent:Da,resolveFilter:Ra,resolveTransitionHooks:$a,setBlockTracking:wa,setDevtoolsHook:Fa,setTransitionHooks:ja,shallowReactive:Ma,shallowReadonly:Na,shallowRef:La,ssrContextKey:Ua,ssrUtils:Ba,stop:Ga,toDisplayString:X,toHandlerKey:Va,toHandlers:Ka,toRaw:Ha,toRef:Wa,toRefs:Xa,toValue:Ya,transformVNodeArgs:za,triggerRef:Ja,unref:z,useAttrs:Za,useCssModule:Qa,useCssVars:ka,useHost:qa,useId:_a,useModel:ti,useSSRContext:ei,useShadowRoot:ri,useSlots:ni,useTemplateRef:oi,useTransitionState:ai,vModelCheckbox:ii,vModelDynamic:si,vModelRadio:li,vModelSelect:ui,vModelText:ci,vShow:Pe,version:fi,warn:di,watch:je,watchEffect:vi,watchPostEffect:pi,watchSyncEffect:hi,withAsyncContext:gi,withCtx:tt,withDefaults:mi,withDirectives:Ce,withKeys:yi,withMemo:bi,withModifiers:ir,withScopeId:Si},Symbol.toStringTag,{value:"Module"})),{Fieldtype:te,IndexFieldtype:ru,FieldtypeMixin:nu,HasActionsMixin:ou,HasInputOptionsMixin:au,HasPreferencesMixin:iu,IndexFieldtypeMixin:su,InlineEditForm:lu,DateFormatter:uu,ItemActions:cu,RelatedItem:fu,RestoreRevision:du,RevisionHistory:vu,RevisionPreview:pu,SaveButtonOptions:hu,SortableList:gu,requireElevatedSession:mu,requireElevatedSessionIf:yu,clone:bu,deepClone:Su,resetValuesFromResponse:xu}=__STATAMIC__.core,{Alert:Eu,AuthCard:Ou,Avatar:Pu,Badge:Ei,Button:Ft,ButtonGroup:Cu,Calendar:Tu,Card:Qe,CardList:Iu,CardListItem:Au,CardPanel:Du,CharacterCounter:Ru,Checkbox:$u,CheckboxGroup:wu,CodeEditor:Fu,Combobox:ju,CommandPaletteItem:Mu,ConfirmationModal:xr,Context:Nu,ContextFooter:Lu,ContextHeader:Uu,ContextItem:Bu,ContextLabel:Gu,ContextMenu:Vu,ContextSeparator:Ku,CreateForm:Hu,DatePicker:Wu,DateRangePicker:Xu,Description:Yu,DocsCallout:zu,DragHandle:Oi,Dropdown:Ju,DropdownItem:Zu,DropdownLabel:Qu,DropdownMenu:ku,DropdownSeparator:qu,DropdownFooter:_u,DropdownHeader:tc,Editable:ec,ErrorMessage:rc,EmptyStateItem:nc,EmptyStateMenu:oc,Field:ac,Header:ic,Heading:Er,HoverCard:sc,Icon:zr,Input:Or,InputGroup:lc,InputGroupAppend:uc,InputGroupPrepend:cc,Label:Te,Listing:fc,ListingCustomizeColumns:dc,ListingFilters:vc,ListingHeaderCell:pc,ListingPagination:hc,ListingPresets:gc,ListingPresetTrigger:mc,ListingRowActions:yc,ListingSearch:bc,ListingTable:Sc,ListingTableBody:xc,ListingTableHead:Ec,ListingToggleAll:Oc,LivePreview:Pc,LivePreviewPopout:Cc,MiddleEllipsis:Tc,Modal:Ic,ModalClose:Ac,ModalTitle:Dc,Pagination:Rc,Panel:Jr,PanelFooter:$c,PanelHeader:Zr,Popover:wc,PublishComponents:Fc,PublishContainer:jc,publishContextKey:Mc,injectPublishContext:Pi,PublishField:Nc,PublishFields:Lc,PublishFieldsProvider:Uc,PublishForm:Bc,PublishLocalizations:Gc,PublishSections:Vc,PublishTabs:Kc,Radio:Hc,RadioGroup:Wc,Select:Xc,Separator:Ci,Slider:Yc,Skeleton:zc,SplitterGroup:Jc,SplitterPanel:Zc,SplitterResizeHandle:Qc,StatusIndicator:kc,Subheading:qc,Switch:_c,TabContent:tf,Stack:Ti,StackClose:ef,StackHeader:rf,StackFooter:nf,StackContent:of,Table:af,TableCell:sf,TableColumn:lf,TableColumns:uf,TableRow:cf,TableRows:ff,TabList:df,TabProvider:vf,Tabs:pf,TabTrigger:hf,Textarea:gf,TimePicker:mf,ToggleGroup:yf,ToggleItem:bf,Widget:Sf,registerIconSet:xf,registerIconSetFromStrings:Ef}=__STATAMIC__.ui,vn=(o,e)=>{const n=o.__vccOpts||o;for(const[s,r]of e)n[s]=r;return n},Ii={name:"PresetStack",components:{Badge:Ei,Button:Ft,Card:Qe,Stack:Ti},emits:["close","preset-selected"],props:{visible:{type:Boolean,default:!1},presets:{type:Array,default:()=>[]},hasExistingSchemas:{type:Boolean,default:!1}},data(){return{selectedPreset:null}},computed:{isOpen:{get(){return this.visible},set(o){o||this.close()}}},methods:{close(){this.selectedPreset=null,this.$emit("close")},selectPreset(o){this.selectedPreset=o},goBack(){this.selectedPreset=null},handleAction(o){this.$emit("preset-selected",{preset:this.selectedPreset,action:o}),this.close()}}},Ai={class:"max-h-[60vh] flex flex-col gap-4"},Di={key:0,class:"preset-selection space-y-4"},Ri={class:"text-sm"},$i={class:"grid grid-cols-1 gap-3"},wi={class:"flex justify-between items-start gap-3"},Fi={class:"flex-1"},ji={class:"font-semibold text-sm"},Mi={class:"text-xs mt-1"},Ni={class:"mt-2"},Li={class:"text-[11px] mb-1"},Ui={class:"flex flex-wrap gap-1"},Bi={class:"py-1 px-2"},Gi={class:"py-1 px-2"},Vi={class:"py-1 px-2"},Ki={key:1,class:"preset-actions space-y-4"},Hi={class:"font-semibold"},Wi={class:"text-sm mt-1"},Xi={key:0,class:"action-selection space-y-3"},Yi={class:"text-sm"},zi={class:"flex flex-col gap-1"},Ji={class:"font-semibold"},Zi={class:"text-sm"},Qi={class:"flex flex-col gap-1"},ki={class:"font-semibold"},qi={class:"text-sm"},_i={key:1,class:"no-existing-schemas space-y-3"},ts={class:"text-sm"},es={class:"flex justify-end gap-2"};function rs(o,e,n,s,r,u){const t=Ge("Badge"),a=Ge("Card"),i=Ge("Button"),l=Ge("Stack");return Q(),Xt(l,{open:u.isOpen,"onUpdate:open":e[3]||(e[3]=c=>u.isOpen=c),title:o.__("Add Preset")},{footer:tt(()=>[B("div",es,[r.selectedPreset?(Q(),Xt(i,{key:0,variant:"ghost",size:"sm",onClick:u.goBack},{default:tt(()=>[It(X(o.__("Back")),1)]),_:1},8,["onClick"])):Vt("",!0),J(i,{variant:"subtle",size:"sm",onClick:u.close},{default:tt(()=>[It(X(o.__("Cancel")),1)]),_:1},8,["onClick"])])]),default:tt(()=>[B("div",Ai,[r.selectedPreset?(Q(),st("div",Ki,[J(a,null,{default:tt(()=>[B("h4",Hi,X(r.selectedPreset.name),1),B("p",Wi,X(r.selectedPreset.description),1)]),_:1}),n.hasExistingSchemas?(Q(),st("div",Xi,[B("p",Yi,X(o.__("You have existing schemas. How would you like to add this preset?")),1),J(a,{class:"cursor-pointer",onClick:e[0]||(e[0]=c=>u.handleAction("merge"))},{default:tt(()=>[B("div",zi,[B("div",Ji,X(o.__("Merge (Recommended)")),1),B("div",Zi,X(o.__("Add this preset as an additional schema alongside your existing ones")),1)])]),_:1}),J(a,{class:"cursor-pointer",onClick:e[1]||(e[1]=c=>u.handleAction("override"))},{default:tt(()=>[B("div",Qi,[B("div",ki,X(o.__("Override")),1),B("div",qi,X(o.__("Replace all existing schemas with this preset")),1)])]),_:1})])):(Q(),st("div",_i,[B("p",ts,X(o.__("This preset will be added as your first schema.")),1),J(i,{class:"w-full",onClick:e[2]||(e[2]=c=>u.handleAction("add"))},{default:tt(()=>[It(X(o.__("Add Preset")),1)]),_:1})]))])):(Q(),st("div",Di,[B("p",Ri,X(o.__("Choose a preset to add to your schema:")),1),B("div",$i,[(Q(!0),st(ce,null,fe(n.presets,c=>(Q(),Xt(a,{key:c.name,class:"cursor-pointer",onClick:f=>u.selectPreset(c)},{default:tt(()=>[B("div",wi,[B("div",Fi,[B("h4",ji,X(c.name),1),B("p",Mi,X(c.description),1),B("div",Ni,[B("div",Li,X(o.__("Fields:")),1),B("div",Ui,[(Q(!0),st(ce,null,fe(c.schema.fields.slice(0,3),f=>(Q(),Xt(t,{key:f.key,size:"xs",pill:""},{default:tt(()=>[B("span",Bi,X(f.key),1)]),_:2},1024))),128)),c.schema.fields.length>3?(Q(),Xt(t,{key:0,size:"xs",pill:""},{default:tt(()=>[B("span",Gi," +"+X(c.schema.fields.length-3),1)]),_:2},1024)):Vt("",!0)])])]),J(t,{size:"xs",pill:""},{default:tt(()=>[B("span",Vi,X(c.schema.specialProps.type),1)]),_:2},1024)])]),_:2},1032,["onClick"]))),128))])]))])]),_:1},8,["open","title"])}const ns=vn(Ii,[["render",rs]]);function vr(o){const e={};if(o.specialProps&&(o.specialProps.context&&(e["@context"]=o.specialProps.context),o.specialProps.type&&(e["@type"]=o.specialProps.type),o.specialProps.id&&(e["@id"]=o.specialProps.id)),o.fields&&Array.isArray(o.fields)){for(const n of o.fields)if(n.key)if(n.type==="array"&&n.values)e[n.key]=n.values;else if(n.type==="object"&&n.value)e[n.key]=vr(n.value);else if(n.type==="object_array"&&n.values)e[n.key]=n.values.map(s=>vr(s));else if(n.type==="replicator_object_array"&&n.config){const s=n.config||{};if(s.flat===!0&&s.flat_key_field&&s.flat_value_field){const r=`{{ ${s.flat_key_field} }}`,u=`{{ ${s.flat_value_field} }}`;e[n.key]=[{[r]:u}]}else{const r={};(Array.isArray(s.mappings)?s.mappings:[]).forEach(t=>{t.key&&(t.mode==="static"?r[t.key]=t.static??"":t.mode==="field"?r[t.key]=`{{ ${t.field||"field"} }}`:t.mode==="nested_replicator"?r[t.key]=[{}]:r[t.key]="")}),e[n.key]=[r]}}else e[n.key]=n.value??null}return e}function pn(o,e=2){try{if(Array.isArray(o)){const s=o.map(r=>vr(r));return JSON.stringify(s,null,e)}const n=vr(o);return JSON.stringify(n,null,e)}catch{return JSON.stringify(o,null,e)}}const os=/[^a-zA-Z0-9@:-]/g,as=o=>typeof o!="string"?"":o.replace(os,""),hn=o=>{!o||typeof o!="object"||(o.key=as(o.key))},gn=(o,{objectArrayType:e}={})=>{if(!(!o||typeof o!="object")){if(Array.isArray(o.values)||(o.values=[]),e&&o.type===e){o.values.push({specialProps:{type:"",id:""},fields:[]});return}o.values.push("")}},mn=(o,e)=>{!o||typeof o!="object"||!Array.isArray(o.values)||o.values.splice(e,1)},yn=(o,{objectType:e="object",arrayType:n="array",objectArrayType:s,dataObjectType:r,replicatorObjectArrayType:u="replicator_object_array"}={})=>{if(!(!o||typeof o!="object")){if(o.type===e){o.value={specialProps:{type:"",id:""},fields:[]};return}if(o.type===n){o.values=[];return}if(s&&o.type===s){o.values=[];return}if(r&&o.type===r){o.value="";return}if(o.type===u){o.config={replicator_field:"",set:"",mappings:[]},o.values=[];return}o.value=""}};function is(o){return o&&o.__esModule&&Object.prototype.hasOwnProperty.call(o,"default")?o.default:o}function bn(o){if(Object.prototype.hasOwnProperty.call(o,"__esModule"))return o;var e=o.default;if(typeof e=="function"){var n=function s(){return this instanceof s?Reflect.construct(e,arguments,this.constructor):e.apply(this,arguments)};n.prototype=e.prototype}else n={};return Object.defineProperty(n,"__esModule",{value:!0}),Object.keys(o).forEach(function(s){var r=Object.getOwnPropertyDescriptor(o,s);Object.defineProperty(n,s,r.get?r:{enumerable:!0,get:function(){return o[s]}})}),n}var sr={exports:{}};const ss=bn(xi);/**! +const Fn="modulepreload",jn=function(o){return"/build/"+o},Yr={},un=function(e,n,s){let r=Promise.resolve();if(n&&n.length>0){let t=function(l){return Promise.all(l.map(c=>Promise.resolve(c).then(f=>({status:"fulfilled",value:f}),f=>({status:"rejected",reason:f}))))};document.getElementsByTagName("link");const a=document.querySelector("meta[property=csp-nonce]"),i=(a==null?void 0:a.nonce)||(a==null?void 0:a.getAttribute("nonce"));r=t(n.map(l=>{if(l=jn(l),l in Yr)return;Yr[l]=!0;const c=l.endsWith(".css"),f=c?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${l}"]${f}`))return;const d=document.createElement("link");if(d.rel=c?"stylesheet":Fn,c||(d.as="script"),d.crossOrigin="",d.href=l,i&&d.setAttribute("nonce",i),document.head.appendChild(d),c)return new Promise((v,p)=>{d.addEventListener("load",v),d.addEventListener("error",()=>p(new Error(`Unable to preload CSS for ${l}`)))})}))}function u(t){const a=new Event("vite:preloadError",{cancelable:!0});if(a.payload=t,window.dispatchEvent(a),!a.defaultPrevented)throw t}return r.then(t=>{for(const a of t||[])a.status==="rejected"&&u(a.reason);return e().catch(u)})},cn=window.Vue,{BaseTransition:Mn,BaseTransitionPropsValidators:Nn,Comment:Ln,DeprecationTypes:$n,EffectScope:Un,ErrorCodes:Bn,ErrorTypeStrings:Gn,Fragment:ce,KeepAlive:Vn,ReactiveEffect:Kn,Static:Hn,Suspense:Wn,Teleport:Xn,Text:Yn,TrackOpTypes:zn,Transition:Jn,TransitionGroup:Zn,TriggerOpTypes:Qn,VueElement:kn,__esModule:qn,assertNumber:_n,callWithAsyncErrorHandling:to,callWithErrorHandling:eo,camelize:ro,capitalize:no,cloneVNode:oo,compatUtils:ao,compile:io,computed:Lt,createApp:so,createBlock:Xt,createCommentVNode:Vt,createElementBlock:st,createElementVNode:B,createHydrationRenderer:lo,createPropsRestProxy:uo,createRenderer:co,createSSRApp:fo,createSlots:vo,createStaticVNode:po,createTextVNode:It,createVNode:J,customRef:ho,defineAsyncComponent:Vr,defineComponent:go,defineCustomElement:mo,defineEmits:yo,defineExpose:bo,defineModel:So,defineOptions:xo,defineProps:Eo,defineSSRCustomElement:Oo,defineSlots:Po,devtools:Co,effect:To,effectScope:Io,getCurrentInstance:fn,getCurrentScope:Ao,getCurrentWatcher:Do,getTransitionRawChildren:Ro,guardReactiveProps:wo,h:Fo,handleError:jo,hasInjectionContext:Mo,hydrate:No,hydrateOnIdle:Lo,hydrateOnInteraction:$o,hydrateOnMediaQuery:Uo,hydrateOnVisible:Bo,initCustomFormatter:Go,initDirectivesForSSR:Vo,inject:Ko,isMemoSame:Ho,isProxy:Wo,isReactive:Xo,isReadonly:Yo,isRef:zo,isRuntimeOnly:Jo,isShallow:Zo,isVNode:Qo,markRaw:ko,mergeDefaults:qo,mergeModels:_o,mergeProps:ta,nextTick:ea,nodeOps:ra,normalizeClass:Mr,normalizeProps:na,normalizeStyle:dn,onActivated:oa,onBeforeMount:aa,onBeforeUnmount:ia,onBeforeUpdate:sa,onDeactivated:la,onErrorCaptured:ua,onMounted:ca,onRenderTracked:fa,onRenderTriggered:da,onScopeDispose:va,onServerPrefetch:pa,onUnmounted:ha,onUpdated:ga,onWatcherCleanup:ma,openBlock:Q,patchProp:ya,popScopeId:ba,provide:Sa,proxyRefs:xa,pushScopeId:Ea,queuePostFlushCb:Oa,reactive:Nr,readonly:Pa,ref:At,registerRuntimeCompiler:Ca,render:Ta,renderList:fe,renderSlot:Ia,resolveComponent:Ge,resolveDirective:Aa,resolveDynamicComponent:Da,resolveFilter:Ra,resolveTransitionHooks:wa,setBlockTracking:Fa,setDevtoolsHook:ja,setTransitionHooks:Ma,shallowReactive:Na,shallowReadonly:La,shallowRef:$a,ssrContextKey:Ua,ssrUtils:Ba,stop:Ga,toDisplayString:X,toHandlerKey:Va,toHandlers:Ka,toRaw:Ha,toRef:Wa,toRefs:Xa,toValue:Ya,transformVNodeArgs:za,triggerRef:Ja,unref:z,useAttrs:Za,useCssModule:Qa,useCssVars:ka,useHost:qa,useId:_a,useModel:ti,useSSRContext:ei,useShadowRoot:ri,useSlots:ni,useTemplateRef:oi,useTransitionState:ai,vModelCheckbox:ii,vModelDynamic:si,vModelRadio:li,vModelSelect:ui,vModelText:ci,vShow:Pe,version:fi,warn:di,watch:Me,watchEffect:vi,watchPostEffect:pi,watchSyncEffect:hi,withAsyncContext:gi,withCtx:tt,withDefaults:mi,withDirectives:Ce,withKeys:yi,withMemo:bi,withModifiers:ir,withScopeId:Si}=cn,xi=Object.freeze(Object.defineProperty({__proto__:null,BaseTransition:Mn,BaseTransitionPropsValidators:Nn,Comment:Ln,DeprecationTypes:$n,EffectScope:Un,ErrorCodes:Bn,ErrorTypeStrings:Gn,Fragment:ce,KeepAlive:Vn,ReactiveEffect:Kn,Static:Hn,Suspense:Wn,Teleport:Xn,Text:Yn,TrackOpTypes:zn,Transition:Jn,TransitionGroup:Zn,TriggerOpTypes:Qn,VueElement:kn,__esModule:qn,assertNumber:_n,callWithAsyncErrorHandling:to,callWithErrorHandling:eo,camelize:ro,capitalize:no,cloneVNode:oo,compatUtils:ao,compile:io,computed:Lt,createApp:so,createBlock:Xt,createCommentVNode:Vt,createElementBlock:st,createElementVNode:B,createHydrationRenderer:lo,createPropsRestProxy:uo,createRenderer:co,createSSRApp:fo,createSlots:vo,createStaticVNode:po,createTextVNode:It,createVNode:J,customRef:ho,default:cn,defineAsyncComponent:Vr,defineComponent:go,defineCustomElement:mo,defineEmits:yo,defineExpose:bo,defineModel:So,defineOptions:xo,defineProps:Eo,defineSSRCustomElement:Oo,defineSlots:Po,devtools:Co,effect:To,effectScope:Io,getCurrentInstance:fn,getCurrentScope:Ao,getCurrentWatcher:Do,getTransitionRawChildren:Ro,guardReactiveProps:wo,h:Fo,handleError:jo,hasInjectionContext:Mo,hydrate:No,hydrateOnIdle:Lo,hydrateOnInteraction:$o,hydrateOnMediaQuery:Uo,hydrateOnVisible:Bo,initCustomFormatter:Go,initDirectivesForSSR:Vo,inject:Ko,isMemoSame:Ho,isProxy:Wo,isReactive:Xo,isReadonly:Yo,isRef:zo,isRuntimeOnly:Jo,isShallow:Zo,isVNode:Qo,markRaw:ko,mergeDefaults:qo,mergeModels:_o,mergeProps:ta,nextTick:ea,nodeOps:ra,normalizeClass:Mr,normalizeProps:na,normalizeStyle:dn,onActivated:oa,onBeforeMount:aa,onBeforeUnmount:ia,onBeforeUpdate:sa,onDeactivated:la,onErrorCaptured:ua,onMounted:ca,onRenderTracked:fa,onRenderTriggered:da,onScopeDispose:va,onServerPrefetch:pa,onUnmounted:ha,onUpdated:ga,onWatcherCleanup:ma,openBlock:Q,patchProp:ya,popScopeId:ba,provide:Sa,proxyRefs:xa,pushScopeId:Ea,queuePostFlushCb:Oa,reactive:Nr,readonly:Pa,ref:At,registerRuntimeCompiler:Ca,render:Ta,renderList:fe,renderSlot:Ia,resolveComponent:Ge,resolveDirective:Aa,resolveDynamicComponent:Da,resolveFilter:Ra,resolveTransitionHooks:wa,setBlockTracking:Fa,setDevtoolsHook:ja,setTransitionHooks:Ma,shallowReactive:Na,shallowReadonly:La,shallowRef:$a,ssrContextKey:Ua,ssrUtils:Ba,stop:Ga,toDisplayString:X,toHandlerKey:Va,toHandlers:Ka,toRaw:Ha,toRef:Wa,toRefs:Xa,toValue:Ya,transformVNodeArgs:za,triggerRef:Ja,unref:z,useAttrs:Za,useCssModule:Qa,useCssVars:ka,useHost:qa,useId:_a,useModel:ti,useSSRContext:ei,useShadowRoot:ri,useSlots:ni,useTemplateRef:oi,useTransitionState:ai,vModelCheckbox:ii,vModelDynamic:si,vModelRadio:li,vModelSelect:ui,vModelText:ci,vShow:Pe,version:fi,warn:di,watch:Me,watchEffect:vi,watchPostEffect:pi,watchSyncEffect:hi,withAsyncContext:gi,withCtx:tt,withDefaults:mi,withDirectives:Ce,withKeys:yi,withMemo:bi,withModifiers:ir,withScopeId:Si},Symbol.toStringTag,{value:"Module"})),{Fieldtype:te,IndexFieldtype:ru,FieldtypeMixin:nu,HasActionsMixin:ou,HasInputOptionsMixin:au,HasPreferencesMixin:iu,IndexFieldtypeMixin:su,InlineEditForm:lu,DateFormatter:uu,NumberFormatter:cu,ItemActions:fu,RelatedItem:du,RestoreRevision:vu,RevisionHistory:pu,RevisionPreview:hu,SaveButtonOptions:gu,SortableList:mu,requireElevatedSession:yu,requireElevatedSessionIf:bu,clone:Su,debounce:xu,deepClone:Eu,resetValuesFromResponse:Ou}=__STATAMIC__.core,{Alert:Pu,AuthCard:Cu,Avatar:Tu,Badge:Ei,Button:jt,ButtonGroup:Iu,Calendar:Au,Card:Qe,CardList:Du,CardListItem:Ru,CardPanel:wu,CharacterCounter:Fu,Checkbox:ju,CheckboxGroup:Mu,CodeEditor:Nu,Combobox:Lu,CommandPaletteItem:$u,ConfirmationModal:xr,Context:Uu,ContextFooter:Bu,ContextHeader:Gu,ContextItem:Vu,ContextLabel:Ku,ContextMenu:Hu,ContextSeparator:Wu,CreateForm:Xu,DatePicker:Yu,DateRangePicker:zu,Description:Ju,DocsCallout:Zu,DragHandle:Oi,Dropdown:Qu,DropdownItem:ku,DropdownLabel:qu,DropdownMenu:_u,DropdownSeparator:tc,DropdownFooter:ec,DropdownHeader:rc,Editable:nc,ErrorMessage:oc,EmptyStateItem:ac,EmptyStateMenu:ic,Field:sc,Header:lc,Heading:Er,HoverCard:uc,Icon:zr,Input:Or,InputGroup:cc,InputGroupAppend:fc,InputGroupPrepend:dc,Label:Te,Listing:vc,ListingCustomizeColumns:pc,ListingFilters:hc,ListingHeaderCell:gc,ListingPagination:mc,ListingPresets:yc,ListingPresetTrigger:bc,ListingRowActions:Sc,ListingSearch:xc,ListingTable:Ec,ListingTableBody:Oc,ListingTableHead:Pc,ListingToggleAll:Cc,LivePreview:Tc,LivePreviewPopout:Ic,MiddleEllipsis:Ac,Modal:Dc,ModalClose:Rc,ModalTitle:wc,Pagination:Fc,Panel:Jr,PanelFooter:jc,PanelHeader:Zr,Popover:Mc,PublishComponents:Nc,PublishContainer:Lc,publishContextKey:$c,injectPublishContext:Pi,PublishField:Uc,PublishFields:Bc,PublishFieldsProvider:Gc,PublishForm:Vc,PublishLocalizations:Kc,PublishSections:Hc,PublishTabs:Wc,Radio:Xc,RadioGroup:Yc,Select:zc,Separator:Ci,Slider:Jc,Skeleton:Zc,SplitterGroup:Qc,SplitterPanel:kc,SplitterResizeHandle:qc,StatusIndicator:_c,Subheading:tf,Switch:ef,TabContent:rf,Stack:Ti,StackClose:nf,StackHeader:of,StackFooter:af,StackContent:sf,Table:lf,TableCell:uf,TableColumn:cf,TableColumns:ff,TableRow:df,TableRows:vf,TabList:pf,TabProvider:hf,Tabs:gf,TabTrigger:mf,Text:yf,Textarea:bf,TimePicker:Sf,TimezoneHoverCard:xf,Timezones:Ef,ToggleGroup:Of,ToggleItem:Pf,Widget:Cf,registerIconSet:Tf,registerIconSetFromStrings:If}=__STATAMIC__.ui,vn=(o,e)=>{const n=o.__vccOpts||o;for(const[s,r]of e)n[s]=r;return n},Ii={name:"PresetStack",components:{Badge:Ei,Button:jt,Card:Qe,Stack:Ti},emits:["close","preset-selected"],props:{visible:{type:Boolean,default:!1},presets:{type:Array,default:()=>[]},hasExistingSchemas:{type:Boolean,default:!1}},data(){return{selectedPreset:null}},computed:{isOpen:{get(){return this.visible},set(o){o||this.close()}}},methods:{close(){this.selectedPreset=null,this.$emit("close")},selectPreset(o){this.selectedPreset=o},goBack(){this.selectedPreset=null},handleAction(o){this.$emit("preset-selected",{preset:this.selectedPreset,action:o}),this.close()}}},Ai={class:"max-h-[60vh] flex flex-col gap-4"},Di={key:0,class:"preset-selection space-y-4"},Ri={class:"text-sm"},wi={class:"grid grid-cols-1 gap-3"},Fi={class:"flex justify-between items-start gap-3"},ji={class:"flex-1"},Mi={class:"font-semibold text-sm"},Ni={class:"text-xs mt-1"},Li={class:"mt-2"},$i={class:"text-[11px] mb-1"},Ui={class:"flex flex-wrap gap-1"},Bi={class:"py-1 px-2"},Gi={class:"py-1 px-2"},Vi={class:"py-1 px-2"},Ki={key:1,class:"preset-actions space-y-4"},Hi={class:"font-semibold"},Wi={class:"text-sm mt-1"},Xi={key:0,class:"action-selection space-y-3"},Yi={class:"text-sm"},zi={class:"flex flex-col gap-1"},Ji={class:"font-semibold"},Zi={class:"text-sm"},Qi={class:"flex flex-col gap-1"},ki={class:"font-semibold"},qi={class:"text-sm"},_i={key:1,class:"no-existing-schemas space-y-3"},ts={class:"text-sm"},es={class:"flex justify-end gap-2"};function rs(o,e,n,s,r,u){const t=Ge("Badge"),a=Ge("Card"),i=Ge("Button"),l=Ge("Stack");return Q(),Xt(l,{open:u.isOpen,"onUpdate:open":e[3]||(e[3]=c=>u.isOpen=c),title:o.__("Add Preset")},{footer:tt(()=>[B("div",es,[r.selectedPreset?(Q(),Xt(i,{key:0,variant:"ghost",size:"sm",onClick:u.goBack},{default:tt(()=>[It(X(o.__("Back")),1)]),_:1},8,["onClick"])):Vt("",!0),J(i,{variant:"subtle",size:"sm",onClick:u.close},{default:tt(()=>[It(X(o.__("Cancel")),1)]),_:1},8,["onClick"])])]),default:tt(()=>[B("div",Ai,[r.selectedPreset?(Q(),st("div",Ki,[J(a,null,{default:tt(()=>[B("h4",Hi,X(r.selectedPreset.name),1),B("p",Wi,X(r.selectedPreset.description),1)]),_:1}),n.hasExistingSchemas?(Q(),st("div",Xi,[B("p",Yi,X(o.__("You have existing schemas. How would you like to add this preset?")),1),J(a,{class:"cursor-pointer",onClick:e[0]||(e[0]=c=>u.handleAction("merge"))},{default:tt(()=>[B("div",zi,[B("div",Ji,X(o.__("Merge (Recommended)")),1),B("div",Zi,X(o.__("Add this preset as an additional schema alongside your existing ones")),1)])]),_:1}),J(a,{class:"cursor-pointer",onClick:e[1]||(e[1]=c=>u.handleAction("override"))},{default:tt(()=>[B("div",Qi,[B("div",ki,X(o.__("Override")),1),B("div",qi,X(o.__("Replace all existing schemas with this preset")),1)])]),_:1})])):(Q(),st("div",_i,[B("p",ts,X(o.__("This preset will be added as your first schema.")),1),J(i,{class:"w-full",onClick:e[2]||(e[2]=c=>u.handleAction("add"))},{default:tt(()=>[It(X(o.__("Add Preset")),1)]),_:1})]))])):(Q(),st("div",Di,[B("p",Ri,X(o.__("Choose a preset to add to your schema:")),1),B("div",wi,[(Q(!0),st(ce,null,fe(n.presets,c=>(Q(),Xt(a,{key:c.name,class:"cursor-pointer",onClick:f=>u.selectPreset(c)},{default:tt(()=>[B("div",Fi,[B("div",ji,[B("h4",Mi,X(c.name),1),B("p",Ni,X(c.description),1),B("div",Li,[B("div",$i,X(o.__("Fields:")),1),B("div",Ui,[(Q(!0),st(ce,null,fe(c.schema.fields.slice(0,3),f=>(Q(),Xt(t,{key:f.key,size:"xs",pill:""},{default:tt(()=>[B("span",Bi,X(f.key),1)]),_:2},1024))),128)),c.schema.fields.length>3?(Q(),Xt(t,{key:0,size:"xs",pill:""},{default:tt(()=>[B("span",Gi," +"+X(c.schema.fields.length-3),1)]),_:2},1024)):Vt("",!0)])])]),J(t,{size:"xs",pill:""},{default:tt(()=>[B("span",Vi,X(c.schema.specialProps.type),1)]),_:2},1024)])]),_:2},1032,["onClick"]))),128))])]))])]),_:1},8,["open","title"])}const ns=vn(Ii,[["render",rs]]);function vr(o){const e={};if(o.specialProps&&(o.specialProps.context&&(e["@context"]=o.specialProps.context),o.specialProps.type&&(e["@type"]=o.specialProps.type),o.specialProps.id&&(e["@id"]=o.specialProps.id)),o.fields&&Array.isArray(o.fields)){for(const n of o.fields)if(n.key)if(n.type==="array"&&n.values)e[n.key]=n.values;else if(n.type==="object"&&n.value)e[n.key]=vr(n.value);else if(n.type==="object_array"&&n.values)e[n.key]=n.values.map(s=>vr(s));else if(n.type==="replicator_object_array"&&n.config){const s=n.config||{};if(s.flat===!0&&s.flat_key_field&&s.flat_value_field){const r=`{{ ${s.flat_key_field} }}`,u=`{{ ${s.flat_value_field} }}`;e[n.key]=[{[r]:u}]}else{const r={};(Array.isArray(s.mappings)?s.mappings:[]).forEach(t=>{t.key&&(t.mode==="static"?r[t.key]=t.static??"":t.mode==="field"?r[t.key]=`{{ ${t.field||"field"} }}`:t.mode==="nested_replicator"?r[t.key]=[{}]:r[t.key]="")}),e[n.key]=[r]}}else e[n.key]=n.value??null}return e}function pn(o,e=2){try{if(Array.isArray(o)){const s=o.map(r=>vr(r));return JSON.stringify(s,null,e)}const n=vr(o);return JSON.stringify(n,null,e)}catch{return JSON.stringify(o,null,e)}}const os=/[^a-zA-Z0-9@:-]/g,as=o=>typeof o!="string"?"":o.replace(os,""),hn=o=>{!o||typeof o!="object"||(o.key=as(o.key))},gn=(o,{objectArrayType:e}={})=>{if(!(!o||typeof o!="object")){if(Array.isArray(o.values)||(o.values=[]),e&&o.type===e){o.values.push({specialProps:{type:"",id:""},fields:[]});return}o.values.push("")}},mn=(o,e)=>{!o||typeof o!="object"||!Array.isArray(o.values)||o.values.splice(e,1)},yn=(o,{objectType:e="object",arrayType:n="array",objectArrayType:s,dataObjectType:r,replicatorObjectArrayType:u="replicator_object_array"}={})=>{if(!(!o||typeof o!="object")){if(o.type===e){o.value={specialProps:{type:"",id:""},fields:[]};return}if(o.type===n){o.values=[];return}if(s&&o.type===s){o.values=[];return}if(r&&o.type===r){o.value="";return}if(o.type===u){o.config={replicator_field:"",set:"",mappings:[]},o.values=[];return}o.value=""}};function is(o){return o&&o.__esModule&&Object.prototype.hasOwnProperty.call(o,"default")?o.default:o}function bn(o){if(Object.prototype.hasOwnProperty.call(o,"__esModule"))return o;var e=o.default;if(typeof e=="function"){var n=function s(){return this instanceof s?Reflect.construct(e,arguments,this.constructor):e.apply(this,arguments)};n.prototype=e.prototype}else n={};return Object.defineProperty(n,"__esModule",{value:!0}),Object.keys(o).forEach(function(s){var r=Object.getOwnPropertyDescriptor(o,s);Object.defineProperty(n,s,r.get?r:{enumerable:!0,get:function(){return o[s]}})}),n}var sr={exports:{}};const ss=bn(xi);/**! * Sortable 1.14.0 * @author RubaXa * @author owenm * @license MIT - */function Qr(o,e){var n=Object.keys(o);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(o);e&&(s=s.filter(function(r){return Object.getOwnPropertyDescriptor(o,r).enumerable})),n.push.apply(n,s)}return n}function le(o){for(var e=1;e=0)&&(n[r]=o[r]);return n}function cs(o,e){if(o==null)return{};var n=us(o,e),s,r;if(Object.getOwnPropertySymbols){var u=Object.getOwnPropertySymbols(o);for(r=0;r=0)&&Object.prototype.propertyIsEnumerable.call(o,s)&&(n[s]=o[s])}return n}function fs(o){return ds(o)||vs(o)||ps(o)||hs()}function ds(o){if(Array.isArray(o))return Nr(o)}function vs(o){if(typeof Symbol<"u"&&o[Symbol.iterator]!=null||o["@@iterator"]!=null)return Array.from(o)}function ps(o,e){if(o){if(typeof o=="string")return Nr(o,e);var n=Object.prototype.toString.call(o).slice(8,-1);if(n==="Object"&&o.constructor&&(n=o.constructor.name),n==="Map"||n==="Set")return Array.from(o);if(n==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))return Nr(o,e)}}function Nr(o,e){(e==null||e>o.length)&&(e=o.length);for(var n=0,s=new Array(e);n"&&(e=e.substring(1)),o)try{if(o.matches)return o.matches(e);if(o.msMatchesSelector)return o.msMatchesSelector(e);if(o.webkitMatchesSelector)return o.webkitMatchesSelector(e)}catch{return!1}return!1}}function ys(o){return o.host&&o!==document&&o.host.nodeType?o.host:o.parentNode}function ae(o,e,n,s){if(o){n=n||document;do{if(e!=null&&(e[0]===">"?o.parentNode===n&&pr(o,e):pr(o,e))||s&&o===n)return o;if(o===n)break}while(o=ys(o))}return null}var qr=/\s+/g;function bt(o,e,n){if(o&&e)if(o.classList)o.classList[n?"add":"remove"](e);else{var s=(" "+o.className+" ").replace(qr," ").replace(" "+e+" "," ");o.className=(s+(n?" "+e:"")).replace(qr," ")}}function V(o,e,n){var s=o&&o.style;if(s){if(n===void 0)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(o,""):o.currentStyle&&(n=o.currentStyle),e===void 0?n:n[e];!(e in s)&&e.indexOf("webkit")===-1&&(e="-webkit-"+e),s[e]=n+(typeof n=="string"?"":"px")}}function Ie(o,e){var n="";if(typeof o=="string")n=o;else do{var s=V(o,"transform");s&&s!=="none"&&(n=s+" "+n)}while(!e&&(o=o.parentNode));var r=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return r&&new r(n)}function En(o,e,n){if(o){var s=o.getElementsByTagName(e),r=0,u=s.length;if(n)for(;r=u,!t)return s;if(s===se())break;s=me(s,!1)}return!1}function Me(o,e,n,s){for(var r=0,u=0,t=o.children;u2&&arguments[2]!==void 0?arguments[2]:{},r=s.evt,u=cs(s,Cs);qe.pluginEvent.bind(Z)(e,n,le({dragEl:w,parentEl:Et,ghostEl:rt,rootEl:mt,nextEl:Oe,lastDownEl:ur,cloneEl:Ot,cloneHidden:ge,dragStarted:Ke,putSortable:Mt,activeSortable:Z.active,originalEvent:r,oldIndex:Fe,oldDraggableIndex:ze,newIndex:Zt,newDraggableIndex:he,hideGhostForTarget:An,unhideGhostForTarget:Dn,cloneNowHidden:function(){ge=!0},cloneNowShown:function(){ge=!1},dispatchSortableEvent:function(a){Gt({sortable:n,name:a,originalEvent:r})}},u))};function Gt(o){Ve(le({putSortable:Mt,cloneEl:Ot,targetEl:w,rootEl:mt,oldIndex:Fe,oldDraggableIndex:ze,newIndex:Zt,newDraggableIndex:he},o))}var w,Et,rt,mt,Oe,ur,Ot,ge,Fe,Zt,ze,he,tr,Mt,we=!1,hr=!1,gr=[],xe,ne,Ir,Ar,rn,nn,Ke,Re,Je,Ze=!1,er=!1,cr,Lt,Dr=[],Lr=!1,mr=[],br=typeof document<"u",rr=Sn,on=ke||ve?"cssFloat":"float",Ts=br&&!ms&&!Sn&&"draggable"in document.createElement("div"),Cn=(function(){if(br){if(ve)return!1;var o=document.createElement("x");return o.style.cssText="pointer-events:auto",o.style.pointerEvents==="auto"}})(),Tn=function(e,n){var s=V(e),r=parseInt(s.width)-parseInt(s.paddingLeft)-parseInt(s.paddingRight)-parseInt(s.borderLeftWidth)-parseInt(s.borderRightWidth),u=Me(e,0,n),t=Me(e,1,n),a=u&&V(u),i=t&&V(t),l=a&&parseInt(a.marginLeft)+parseInt(a.marginRight)+yt(u).width,c=i&&parseInt(i.marginLeft)+parseInt(i.marginRight)+yt(t).width;if(s.display==="flex")return s.flexDirection==="column"||s.flexDirection==="column-reverse"?"vertical":"horizontal";if(s.display==="grid")return s.gridTemplateColumns.split(" ").length<=1?"vertical":"horizontal";if(u&&a.float&&a.float!=="none"){var f=a.float==="left"?"left":"right";return t&&(i.clear==="both"||i.clear===f)?"vertical":"horizontal"}return u&&(a.display==="block"||a.display==="flex"||a.display==="table"||a.display==="grid"||l>=r&&s[on]==="none"||t&&s[on]==="none"&&l+c>r)?"vertical":"horizontal"},Is=function(e,n,s){var r=s?e.left:e.top,u=s?e.right:e.bottom,t=s?e.width:e.height,a=s?n.left:n.top,i=s?n.right:n.bottom,l=s?n.width:n.height;return r===a||u===i||r+t/2===a+l/2},As=function(e,n){var s;return gr.some(function(r){var u=r[Ut].options.emptyInsertThreshold;if(!(!u||Kr(r))){var t=yt(r),a=e>=t.left-u&&e<=t.right+u,i=n>=t.top-u&&n<=t.bottom+u;if(a&&i)return s=r}}),s},In=function(e){function n(u,t){return function(a,i,l,c){var f=a.options.group.name&&i.options.group.name&&a.options.group.name===i.options.group.name;if(u==null&&(t||f))return!0;if(u==null||u===!1)return!1;if(t&&u==="clone")return u;if(typeof u=="function")return n(u(a,i,l,c),t)(a,i,l,c);var d=(t?a:i).options.group.name;return u===!0||typeof u=="string"&&u===d||u.join&&u.indexOf(d)>-1}}var s={},r=e.group;(!r||lr(r)!="object")&&(r={name:r}),s.name=r.name,s.checkPull=n(r.pull,!0),s.checkPut=n(r.put),s.revertClone=r.revertClone,e.group=s},An=function(){!Cn&&rt&&V(rt,"display","none")},Dn=function(){!Cn&&rt&&V(rt,"display","")};br&&document.addEventListener("click",function(o){if(hr)return o.preventDefault(),o.stopPropagation&&o.stopPropagation(),o.stopImmediatePropagation&&o.stopImmediatePropagation(),hr=!1,!1},!0);var Ee=function(e){if(w){e=e.touches?e.touches[0]:e;var n=As(e.clientX,e.clientY);if(n){var s={};for(var r in e)e.hasOwnProperty(r)&&(s[r]=e[r]);s.target=s.rootEl=n,s.preventDefault=void 0,s.stopPropagation=void 0,n[Ut]._onDragOver(s)}}},Ds=function(e){w&&w.parentNode[Ut]._isOutsideThisEl(e.target)};function Z(o,e){if(!(o&&o.nodeType&&o.nodeType===1))throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(o));this.el=o,this.options=e=ee({},e),o[Ut]=this;var n={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(o.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return Tn(o,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,a){t.setData("Text",a.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:Z.supportPointer!==!1&&"PointerEvent"in window&&!Xe,emptyInsertThreshold:5};qe.initializePlugins(this,o,n);for(var s in n)!(s in e)&&(e[s]=n[s]);In(e);for(var r in this)r.charAt(0)==="_"&&typeof this[r]=="function"&&(this[r]=this[r].bind(this));this.nativeDraggable=e.forceFallback?!1:Ts,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?at(o,"pointerdown",this._onTapStart):(at(o,"mousedown",this._onTapStart),at(o,"touchstart",this._onTapStart)),this.nativeDraggable&&(at(o,"dragover",this),at(o,"dragenter",this)),gr.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),ee(this,Es())}Z.prototype={constructor:Z,_isOutsideThisEl:function(e){!this.el.contains(e)&&e!==this.el&&(Re=null)},_getDirection:function(e,n){return typeof this.options.direction=="function"?this.options.direction.call(this,e,n,w):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,s=this.el,r=this.options,u=r.preventOnFilter,t=e.type,a=e.touches&&e.touches[0]||e.pointerType&&e.pointerType==="touch"&&e,i=(a||e).target,l=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||i,c=r.filter;if(Ls(s),!w&&!(/mousedown|pointerdown/.test(t)&&e.button!==0||r.disabled)&&!l.isContentEditable&&!(!this.nativeDraggable&&Xe&&i&&i.tagName.toUpperCase()==="SELECT")&&(i=ae(i,r.draggable,s,!1),!(i&&i.animated)&&ur!==i)){if(Fe=Pt(i),ze=Pt(i,r.draggable),typeof c=="function"){if(c.call(this,e,i,this)){Gt({sortable:n,rootEl:l,name:"filter",targetEl:i,toEl:s,fromEl:s}),Ht("filter",n,{evt:e}),u&&e.cancelable&&e.preventDefault();return}}else if(c&&(c=c.split(",").some(function(f){if(f=ae(l,f.trim(),s,!1),f)return Gt({sortable:n,rootEl:f,name:"filter",targetEl:i,fromEl:s,toEl:s}),Ht("filter",n,{evt:e}),!0}),c)){u&&e.cancelable&&e.preventDefault();return}r.handle&&!ae(l,r.handle,s,!1)||this._prepareDragStart(e,a,i)}}},_prepareDragStart:function(e,n,s){var r=this,u=r.el,t=r.options,a=u.ownerDocument,i;if(s&&!w&&s.parentNode===u){var l=yt(s);if(mt=u,w=s,Et=w.parentNode,Oe=w.nextSibling,ur=s,tr=t.group,Z.dragged=w,xe={target:w,clientX:(n||e).clientX,clientY:(n||e).clientY},rn=xe.clientX-l.left,nn=xe.clientY-l.top,this._lastX=(n||e).clientX,this._lastY=(n||e).clientY,w.style["will-change"]="all",i=function(){if(Ht("delayEnded",r,{evt:e}),Z.eventCanceled){r._onDrop();return}r._disableDelayedDragEvents(),!kr&&r.nativeDraggable&&(w.draggable=!0),r._triggerDragStart(e,n),Gt({sortable:r,name:"choose",originalEvent:e}),bt(w,t.chosenClass,!0)},t.ignore.split(",").forEach(function(c){En(w,c.trim(),Rr)}),at(a,"dragover",Ee),at(a,"mousemove",Ee),at(a,"touchmove",Ee),at(a,"mouseup",r._onDrop),at(a,"touchend",r._onDrop),at(a,"touchcancel",r._onDrop),kr&&this.nativeDraggable&&(this.options.touchStartThreshold=4,w.draggable=!0),Ht("delayStart",this,{evt:e}),t.delay&&(!t.delayOnTouchOnly||n)&&(!this.nativeDraggable||!(ke||ve))){if(Z.eventCanceled){this._onDrop();return}at(a,"mouseup",r._disableDelayedDrag),at(a,"touchend",r._disableDelayedDrag),at(a,"touchcancel",r._disableDelayedDrag),at(a,"mousemove",r._delayedDragTouchMoveHandler),at(a,"touchmove",r._delayedDragTouchMoveHandler),t.supportPointer&&at(a,"pointermove",r._delayedDragTouchMoveHandler),r._dragStartTimer=setTimeout(i,t.delay)}else i()}},_delayedDragTouchMoveHandler:function(e){var n=e.touches?e.touches[0]:e;Math.max(Math.abs(n.clientX-this._lastX),Math.abs(n.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){w&&Rr(w),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var e=this.el.ownerDocument;ot(e,"mouseup",this._disableDelayedDrag),ot(e,"touchend",this._disableDelayedDrag),ot(e,"touchcancel",this._disableDelayedDrag),ot(e,"mousemove",this._delayedDragTouchMoveHandler),ot(e,"touchmove",this._delayedDragTouchMoveHandler),ot(e,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(e,n){n=n||e.pointerType=="touch"&&e,!this.nativeDraggable||n?this.options.supportPointer?at(document,"pointermove",this._onTouchMove):n?at(document,"touchmove",this._onTouchMove):at(document,"mousemove",this._onTouchMove):(at(w,"dragend",this),at(mt,"dragstart",this._onDragStart));try{document.selection?fr(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch{}},_dragStarted:function(e,n){if(we=!1,mt&&w){Ht("dragStarted",this,{evt:n}),this.nativeDraggable&&at(document,"dragover",Ds);var s=this.options;!e&&bt(w,s.dragClass,!1),bt(w,s.ghostClass,!0),Z.active=this,e&&this._appendGhost(),Gt({sortable:this,name:"start",originalEvent:n})}else this._nulling()},_emulateDragOver:function(){if(ne){this._lastX=ne.clientX,this._lastY=ne.clientY,An();for(var e=document.elementFromPoint(ne.clientX,ne.clientY),n=e;e&&e.shadowRoot&&(e=e.shadowRoot.elementFromPoint(ne.clientX,ne.clientY),e!==n);)n=e;if(w.parentNode[Ut]._isOutsideThisEl(e),n)do{if(n[Ut]){var s=void 0;if(s=n[Ut]._onDragOver({clientX:ne.clientX,clientY:ne.clientY,target:e,rootEl:n}),s&&!this.options.dragoverBubble)break}e=n}while(n=n.parentNode);Dn()}},_onTouchMove:function(e){if(xe){var n=this.options,s=n.fallbackTolerance,r=n.fallbackOffset,u=e.touches?e.touches[0]:e,t=rt&&Ie(rt,!0),a=rt&&t&&t.a,i=rt&&t&&t.d,l=rr&&Lt&&tn(Lt),c=(u.clientX-xe.clientX+r.x)/(a||1)+(l?l[0]-Dr[0]:0)/(a||1),f=(u.clientY-xe.clientY+r.y)/(i||1)+(l?l[1]-Dr[1]:0)/(i||1);if(!Z.active&&!we){if(s&&Math.max(Math.abs(u.clientX-this._lastX),Math.abs(u.clientY-this._lastY))=0&&(Gt({rootEl:Et,name:"add",toEl:Et,fromEl:mt,originalEvent:e}),Gt({sortable:this,name:"remove",toEl:Et,originalEvent:e}),Gt({rootEl:Et,name:"sort",toEl:Et,fromEl:mt,originalEvent:e}),Gt({sortable:this,name:"sort",toEl:Et,originalEvent:e})),Mt&&Mt.save()):Zt!==Fe&&Zt>=0&&(Gt({sortable:this,name:"update",toEl:Et,originalEvent:e}),Gt({sortable:this,name:"sort",toEl:Et,originalEvent:e})),Z.active&&((Zt==null||Zt===-1)&&(Zt=Fe,he=ze),Gt({sortable:this,name:"end",toEl:Et,originalEvent:e}),this.save()))),this._nulling()},_nulling:function(){Ht("nulling",this),mt=w=Et=rt=Oe=Ot=ur=ge=xe=ne=Ke=Zt=he=Fe=ze=Re=Je=Mt=tr=Z.dragged=Z.ghost=Z.clone=Z.active=null,mr.forEach(function(e){e.checked=!0}),mr.length=Ir=Ar=0},handleEvent:function(e){switch(e.type){case"drop":case"dragend":this._onDrop(e);break;case"dragenter":case"dragover":w&&(this._onDragOver(e),Rs(e));break;case"selectstart":e.preventDefault();break}},toArray:function(){for(var e=[],n,s=this.el.children,r=0,u=s.length,t=this.options;rs.right+r||o.clientX<=s.right&&o.clientY>s.bottom&&o.clientX>=s.left:o.clientX>s.right&&o.clientY>s.top||o.clientX<=s.right&&o.clientY>s.bottom+r}function js(o,e,n,s,r,u,t,a){var i=s?o.clientY:o.clientX,l=s?n.height:n.width,c=s?n.top:n.left,f=s?n.bottom:n.right,d=!1;if(!t){if(a&&crc+l*u/2:if-cr)return-Je}else if(i>c+l*(1-r)/2&&if-l*u/2)?i>c+l/2?1:-1:0}function Ms(o){return Pt(w)1&&(et.forEach(function(a){u.addAnimationState({target:a,rect:Wt?yt(a):t}),Cr(a),a.fromRect=t,s.removeAnimationState(a)}),Wt=!1,Ks(!this.options.removeCloneOnHide,r))},dragOverCompleted:function(n){var s=n.sortable,r=n.isOwner,u=n.insertion,t=n.activeSortable,a=n.parentEl,i=n.putSortable,l=this.options;if(u){if(r&&t._hideClone(),Ue=!1,l.animation&&et.length>1&&(Wt||!r&&!t.options.sort&&!i)){var c=yt(vt,!1,!0,!0);et.forEach(function(d){d!==vt&&(en(d,c),a.appendChild(d))}),Wt=!0}if(!r)if(Wt||ar(),et.length>1){var f=or;t._showClone(s),t.options.animation&&!or&&f&&Jt.forEach(function(d){t.addAnimationState({target:d,rect:Be}),d.fromRect=Be,d.thisAnimationDuration=null})}else t._showClone(s)}},dragOverAnimationCapture:function(n){var s=n.dragRect,r=n.isOwner,u=n.activeSortable;if(et.forEach(function(a){a.thisAnimationDuration=null}),u.options.animation&&!r&&u.multiDrag.isMultiDrag){Be=ee({},s);var t=Ie(vt,!0);Be.top-=t.f,Be.left-=t.e}},dragOverAnimationComplete:function(){Wt&&(Wt=!1,ar())},drop:function(n){var s=n.originalEvent,r=n.rootEl,u=n.parentEl,t=n.sortable,a=n.dispatchSortableEvent,i=n.oldIndex,l=n.putSortable,c=l||this.sortable;if(s){var f=this.options,d=u.children;if(!$e)if(f.multiDragKey&&!this.multiDragKeyDown&&this._deselectMultiDrag(),bt(vt,f.selectedClass,!~et.indexOf(vt)),~et.indexOf(vt))et.splice(et.indexOf(vt),1),Le=null,Ve({sortable:t,rootEl:r,name:"deselect",targetEl:vt});else{if(et.push(vt),Ve({sortable:t,rootEl:r,name:"select",targetEl:vt}),s.shiftKey&&Le&&t.el.contains(Le)){var v=Pt(Le),p=Pt(vt);if(~v&&~p&&v!==p){var h,g;for(p>v?(g=v,h=p):(g=p,h=v+1);g1){var b=yt(vt),S=Pt(vt,":not(."+this.options.selectedClass+")");if(!Ue&&f.animation&&(vt.thisAnimationDuration=null),c.captureAnimationState(),!Ue&&(f.animation&&(vt.fromRect=b,et.forEach(function(x){if(x.thisAnimationDuration=null,x!==vt){var C=Wt?yt(x):b;x.fromRect=C,c.addAnimationState({target:x,rect:C})}})),ar(),et.forEach(function(x){d[S]?u.insertBefore(x,d[S]):u.appendChild(x),S++}),i===Pt(vt))){var I=!1;et.forEach(function(x){if(x.sortableIndex!==Pt(x)){I=!0;return}}),I&&a("update")}et.forEach(function(x){Cr(x)}),c.animateAll()}oe=c}(r===u||l&&l.lastPutMode!=="clone")&&Jt.forEach(function(x){x.parentNode&&x.parentNode.removeChild(x)})}},nullingGlobal:function(){this.isMultiDrag=$e=!1,Jt.length=0},destroyGlobal:function(){this._deselectMultiDrag(),ot(document,"pointerup",this._deselectMultiDrag),ot(document,"mouseup",this._deselectMultiDrag),ot(document,"touchend",this._deselectMultiDrag),ot(document,"keydown",this._checkKeyDown),ot(document,"keyup",this._checkKeyUp)},_deselectMultiDrag:function(n){if(!(typeof $e<"u"&&$e)&&oe===this.sortable&&!(n&&ae(n.target,this.options.draggable,this.sortable.el,!1))&&!(n&&n.button!==0))for(;et.length;){var s=et[0];bt(s,this.options.selectedClass,!1),et.shift(),Ve({sortable:this.sortable,rootEl:this.sortable.el,name:"deselect",targetEl:s})}},_checkKeyDown:function(n){n.key===this.options.multiDragKey&&(this.multiDragKeyDown=!0)},_checkKeyUp:function(n){n.key===this.options.multiDragKey&&(this.multiDragKeyDown=!1)}},ee(o,{pluginName:"multiDrag",utils:{select:function(n){var s=n.parentNode[Ut];!s||!s.options.multiDrag||~et.indexOf(n)||(oe&&oe!==s&&(oe.multiDrag._deselectMultiDrag(),oe=s),bt(n,s.options.selectedClass,!0),et.push(n))},deselect:function(n){var s=n.parentNode[Ut],r=et.indexOf(n);!s||!s.options.multiDrag||!~r||(bt(n,s.options.selectedClass,!1),et.splice(r,1))}},eventProperties:function(){var n=this,s=[],r=[];return et.forEach(function(u){s.push({multiDragElement:u,index:u.sortableIndex});var t;Wt&&u!==vt?t=-1:Wt?t=Pt(u,":not(."+n.options.selectedClass+")"):t=Pt(u),r.push({multiDragElement:u,index:t})}),{items:fs(et),clones:[].concat(Jt),oldIndicies:s,newIndicies:r}},optionListeners:{multiDragKey:function(n){return n=n.toLowerCase(),n==="ctrl"?n="Control":n.length>1&&(n=n.charAt(0).toUpperCase()+n.substr(1)),n}}})}function Ks(o,e){et.forEach(function(n,s){var r=e.children[n.sortableIndex+(o?Number(s):0)];r?e.insertBefore(n,r):e.appendChild(n)})}function sn(o,e){Jt.forEach(function(n,s){var r=e.children[n.sortableIndex+(o?Number(s):0)];r?e.insertBefore(n,r):e.appendChild(n)})}function ar(){et.forEach(function(o){o!==vt&&o.parentNode&&o.parentNode.removeChild(o)})}Z.mount(new Us);Z.mount(Xr,Wr);const Hs=Object.freeze(Object.defineProperty({__proto__:null,MultiDrag:Vs,Sortable:Z,Swap:Bs,default:Z},Symbol.toStringTag,{value:"Module"})),Ws=bn(Hs);var Xs=sr.exports,ln;function Ys(){return ln||(ln=1,(function(o,e){(function(s,r){o.exports=r(ss,Ws)})(typeof self<"u"?self:Xs,function(n,s){return(function(r){var u={};function t(a){if(u[a])return u[a].exports;var i=u[a]={i:a,l:!1,exports:{}};return r[a].call(i.exports,i,i.exports,t),i.l=!0,i.exports}return t.m=r,t.c=u,t.d=function(a,i,l){t.o(a,i)||Object.defineProperty(a,i,{enumerable:!0,get:l})},t.r=function(a){typeof Symbol<"u"&&Symbol.toStringTag&&Object.defineProperty(a,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(a,"__esModule",{value:!0})},t.t=function(a,i){if(i&1&&(a=t(a)),i&8||i&4&&typeof a=="object"&&a&&a.__esModule)return a;var l=Object.create(null);if(t.r(l),Object.defineProperty(l,"default",{enumerable:!0,value:a}),i&2&&typeof a!="string")for(var c in a)t.d(l,c,(function(f){return a[f]}).bind(null,c));return l},t.n=function(a){var i=a&&a.__esModule?function(){return a.default}:function(){return a};return t.d(i,"a",i),i},t.o=function(a,i){return Object.prototype.hasOwnProperty.call(a,i)},t.p="",t(t.s="fb15")})({"00ee":(function(r,u,t){var a=t("b622"),i=a("toStringTag"),l={};l[i]="z",r.exports=String(l)==="[object z]"}),"0366":(function(r,u,t){var a=t("1c0b");r.exports=function(i,l,c){if(a(i),l===void 0)return i;switch(c){case 0:return function(){return i.call(l)};case 1:return function(f){return i.call(l,f)};case 2:return function(f,d){return i.call(l,f,d)};case 3:return function(f,d,v){return i.call(l,f,d,v)}}return function(){return i.apply(l,arguments)}}}),"057f":(function(r,u,t){var a=t("fc6a"),i=t("241c").f,l={}.toString,c=typeof window=="object"&&window&&Object.getOwnPropertyNames?Object.getOwnPropertyNames(window):[],f=function(d){try{return i(d)}catch{return c.slice()}};r.exports.f=function(v){return c&&l.call(v)=="[object Window]"?f(v):i(a(v))}}),"06cf":(function(r,u,t){var a=t("83ab"),i=t("d1e7"),l=t("5c6c"),c=t("fc6a"),f=t("c04e"),d=t("5135"),v=t("0cfb"),p=Object.getOwnPropertyDescriptor;u.f=a?p:function(g,b){if(g=c(g),b=f(b,!0),v)try{return p(g,b)}catch{}if(d(g,b))return l(!i.f.call(g,b),g[b])}}),"0cfb":(function(r,u,t){var a=t("83ab"),i=t("d039"),l=t("cc12");r.exports=!a&&!i(function(){return Object.defineProperty(l("div"),"a",{get:function(){return 7}}).a!=7})}),"13d5":(function(r,u,t){var a=t("23e7"),i=t("d58f").left,l=t("a640"),c=t("ae40"),f=l("reduce"),d=c("reduce",{1:0});a({target:"Array",proto:!0,forced:!f||!d},{reduce:function(p){return i(this,p,arguments.length,arguments.length>1?arguments[1]:void 0)}})}),"14c3":(function(r,u,t){var a=t("c6b6"),i=t("9263");r.exports=function(l,c){var f=l.exec;if(typeof f=="function"){var d=f.call(l,c);if(typeof d!="object")throw TypeError("RegExp exec method returned something other than an Object or null");return d}if(a(l)!=="RegExp")throw TypeError("RegExp#exec called on incompatible receiver");return i.call(l,c)}}),"159b":(function(r,u,t){var a=t("da84"),i=t("fdbc"),l=t("17c2"),c=t("9112");for(var f in i){var d=a[f],v=d&&d.prototype;if(v&&v.forEach!==l)try{c(v,"forEach",l)}catch{v.forEach=l}}}),"17c2":(function(r,u,t){var a=t("b727").forEach,i=t("a640"),l=t("ae40"),c=i("forEach"),f=l("forEach");r.exports=!c||!f?function(v){return a(this,v,arguments.length>1?arguments[1]:void 0)}:[].forEach}),"1be4":(function(r,u,t){var a=t("d066");r.exports=a("document","documentElement")}),"1c0b":(function(r,u){r.exports=function(t){if(typeof t!="function")throw TypeError(String(t)+" is not a function");return t}}),"1c7e":(function(r,u,t){var a=t("b622"),i=a("iterator"),l=!1;try{var c=0,f={next:function(){return{done:!!c++}},return:function(){l=!0}};f[i]=function(){return this},Array.from(f,function(){throw 2})}catch{}r.exports=function(d,v){if(!v&&!l)return!1;var p=!1;try{var h={};h[i]=function(){return{next:function(){return{done:p=!0}}}},d(h)}catch{}return p}}),"1d80":(function(r,u){r.exports=function(t){if(t==null)throw TypeError("Can't call method on "+t);return t}}),"1dde":(function(r,u,t){var a=t("d039"),i=t("b622"),l=t("2d00"),c=i("species");r.exports=function(f){return l>=51||!a(function(){var d=[],v=d.constructor={};return v[c]=function(){return{foo:1}},d[f](Boolean).foo!==1})}}),"23cb":(function(r,u,t){var a=t("a691"),i=Math.max,l=Math.min;r.exports=function(c,f){var d=a(c);return d<0?i(d+f,0):l(d,f)}}),"23e7":(function(r,u,t){var a=t("da84"),i=t("06cf").f,l=t("9112"),c=t("6eeb"),f=t("ce4e"),d=t("e893"),v=t("94ca");r.exports=function(p,h){var g=p.target,b=p.global,S=p.stat,I,x,C,P,L,U;if(b?x=a:S?x=a[g]||f(g,{}):x=(a[g]||{}).prototype,x)for(C in h){if(L=h[C],p.noTargetGet?(U=i(x,C),P=U&&U.value):P=x[C],I=v(b?C:g+(S?".":"#")+C,p.forced),!I&&P!==void 0){if(typeof L==typeof P)continue;d(L,P)}(p.sham||P&&P.sham)&&l(L,"sham",!0),c(x,C,L,p)}}}),"241c":(function(r,u,t){var a=t("ca84"),i=t("7839"),l=i.concat("length","prototype");u.f=Object.getOwnPropertyNames||function(f){return a(f,l)}}),"25f0":(function(r,u,t){var a=t("6eeb"),i=t("825a"),l=t("d039"),c=t("ad6d"),f="toString",d=RegExp.prototype,v=d[f],p=l(function(){return v.call({source:"a",flags:"b"})!="/a/b"}),h=v.name!=f;(p||h)&&a(RegExp.prototype,f,function(){var b=i(this),S=String(b.source),I=b.flags,x=String(I===void 0&&b instanceof RegExp&&!("flags"in d)?c.call(b):I);return"/"+S+"/"+x},{unsafe:!0})}),"2ca0":(function(r,u,t){var a=t("23e7"),i=t("06cf").f,l=t("50c4"),c=t("5a34"),f=t("1d80"),d=t("ab13"),v=t("c430"),p="".startsWith,h=Math.min,g=d("startsWith"),b=!v&&!g&&!!(function(){var S=i(String.prototype,"startsWith");return S&&!S.writable})();a({target:"String",proto:!0,forced:!b&&!g},{startsWith:function(I){var x=String(f(this));c(I);var C=l(h(arguments.length>1?arguments[1]:void 0,x.length)),P=String(I);return p?p.call(x,P,C):x.slice(C,C+P.length)===P}})}),"2d00":(function(r,u,t){var a=t("da84"),i=t("342f"),l=a.process,c=l&&l.versions,f=c&&c.v8,d,v;f?(d=f.split("."),v=d[0]+d[1]):i&&(d=i.match(/Edge\/(\d+)/),(!d||d[1]>=74)&&(d=i.match(/Chrome\/(\d+)/),d&&(v=d[1]))),r.exports=v&&+v}),"342f":(function(r,u,t){var a=t("d066");r.exports=a("navigator","userAgent")||""}),"35a1":(function(r,u,t){var a=t("f5df"),i=t("3f8c"),l=t("b622"),c=l("iterator");r.exports=function(f){if(f!=null)return f[c]||f["@@iterator"]||i[a(f)]}}),"37e8":(function(r,u,t){var a=t("83ab"),i=t("9bf2"),l=t("825a"),c=t("df75");r.exports=a?Object.defineProperties:function(d,v){l(d);for(var p=c(v),h=p.length,g=0,b;h>g;)i.f(d,b=p[g++],v[b]);return d}}),"3bbe":(function(r,u,t){var a=t("861d");r.exports=function(i){if(!a(i)&&i!==null)throw TypeError("Can't set "+String(i)+" as a prototype");return i}}),"3ca3":(function(r,u,t){var a=t("6547").charAt,i=t("69f3"),l=t("7dd0"),c="String Iterator",f=i.set,d=i.getterFor(c);l(String,"String",function(v){f(this,{type:c,string:String(v),index:0})},function(){var p=d(this),h=p.string,g=p.index,b;return g>=h.length?{value:void 0,done:!0}:(b=a(h,g),p.index+=b.length,{value:b,done:!1})})}),"3f8c":(function(r,u){r.exports={}}),4160:(function(r,u,t){var a=t("23e7"),i=t("17c2");a({target:"Array",proto:!0,forced:[].forEach!=i},{forEach:i})}),"428f":(function(r,u,t){var a=t("da84");r.exports=a}),"44ad":(function(r,u,t){var a=t("d039"),i=t("c6b6"),l="".split;r.exports=a(function(){return!Object("z").propertyIsEnumerable(0)})?function(c){return i(c)=="String"?l.call(c,""):Object(c)}:Object}),"44d2":(function(r,u,t){var a=t("b622"),i=t("7c73"),l=t("9bf2"),c=a("unscopables"),f=Array.prototype;f[c]==null&&l.f(f,c,{configurable:!0,value:i(null)}),r.exports=function(d){f[c][d]=!0}}),"44e7":(function(r,u,t){var a=t("861d"),i=t("c6b6"),l=t("b622"),c=l("match");r.exports=function(f){var d;return a(f)&&((d=f[c])!==void 0?!!d:i(f)=="RegExp")}}),4930:(function(r,u,t){var a=t("d039");r.exports=!!Object.getOwnPropertySymbols&&!a(function(){return!String(Symbol())})}),"4d64":(function(r,u,t){var a=t("fc6a"),i=t("50c4"),l=t("23cb"),c=function(f){return function(d,v,p){var h=a(d),g=i(h.length),b=l(p,g),S;if(f&&v!=v){for(;g>b;)if(S=h[b++],S!=S)return!0}else for(;g>b;b++)if((f||b in h)&&h[b]===v)return f||b||0;return!f&&-1}};r.exports={includes:c(!0),indexOf:c(!1)}}),"4de4":(function(r,u,t){var a=t("23e7"),i=t("b727").filter,l=t("1dde"),c=t("ae40"),f=l("filter"),d=c("filter");a({target:"Array",proto:!0,forced:!f||!d},{filter:function(p){return i(this,p,arguments.length>1?arguments[1]:void 0)}})}),"4df4":(function(r,u,t){var a=t("0366"),i=t("7b0b"),l=t("9bdd"),c=t("e95a"),f=t("50c4"),d=t("8418"),v=t("35a1");r.exports=function(h){var g=i(h),b=typeof this=="function"?this:Array,S=arguments.length,I=S>1?arguments[1]:void 0,x=I!==void 0,C=v(g),P=0,L,U,O,A,$,W;if(x&&(I=a(I,S>2?arguments[2]:void 0,2)),C!=null&&!(b==Array&&c(C)))for(A=C.call(g),$=A.next,U=new b;!(O=$.call(A)).done;P++)W=x?l(A,I,[O.value,P],!0):O.value,d(U,P,W);else for(L=f(g.length),U=new b(L);L>P;P++)W=x?I(g[P],P):g[P],d(U,P,W);return U.length=P,U}}),"4fad":(function(r,u,t){var a=t("23e7"),i=t("6f53").entries;a({target:"Object",stat:!0},{entries:function(c){return i(c)}})}),"50c4":(function(r,u,t){var a=t("a691"),i=Math.min;r.exports=function(l){return l>0?i(a(l),9007199254740991):0}}),5135:(function(r,u){var t={}.hasOwnProperty;r.exports=function(a,i){return t.call(a,i)}}),5319:(function(r,u,t){var a=t("d784"),i=t("825a"),l=t("7b0b"),c=t("50c4"),f=t("a691"),d=t("1d80"),v=t("8aa5"),p=t("14c3"),h=Math.max,g=Math.min,b=Math.floor,S=/\$([$&'`]|\d\d?|<[^>]*>)/g,I=/\$([$&'`]|\d\d?)/g,x=function(C){return C===void 0?C:String(C)};a("replace",2,function(C,P,L,U){var O=U.REGEXP_REPLACE_SUBSTITUTES_UNDEFINED_CAPTURE,A=U.REPLACE_KEEPS_$0,$=O?"$":"$0";return[function(F,k){var M=d(this),H=F==null?void 0:F[C];return H!==void 0?H.call(F,M,k):P.call(String(M),F,k)},function(R,F){if(!O&&A||typeof F=="string"&&F.indexOf($)===-1){var k=L(P,R,this,F);if(k.done)return k.value}var M=i(R),H=String(this),lt=typeof F=="function";lt||(F=String(F));var dt=M.global;if(dt){var wt=M.unicode;M.lastIndex=0}for(var ht=[];;){var gt=p(M,H);if(gt===null||(ht.push(gt),!dt))break;var Ct=String(gt[0]);Ct===""&&(M.lastIndex=v(H,c(M.lastIndex),wt))}for(var Dt="",St=0,ft=0;ft=St&&(Dt+=H.slice(St,Bt)+Rt,St=Bt+pt.length)}return Dt+H.slice(St)}];function W(R,F,k,M,H,lt){var dt=k+R.length,wt=M.length,ht=I;return H!==void 0&&(H=l(H),ht=S),P.call(lt,ht,function(gt,Ct){var Dt;switch(Ct.charAt(0)){case"$":return"$";case"&":return R;case"`":return F.slice(0,k);case"'":return F.slice(dt);case"<":Dt=H[Ct.slice(1,-1)];break;default:var St=+Ct;if(St===0)return gt;if(St>wt){var ft=b(St/10);return ft===0?gt:ft<=wt?M[ft-1]===void 0?Ct.charAt(1):M[ft-1]+Ct.charAt(1):gt}Dt=M[St-1]}return Dt===void 0?"":Dt})}})}),5692:(function(r,u,t){var a=t("c430"),i=t("c6cd");(r.exports=function(l,c){return i[l]||(i[l]=c!==void 0?c:{})})("versions",[]).push({version:"3.6.5",mode:a?"pure":"global",copyright:"© 2020 Denis Pushkarev (zloirock.ru)"})}),"56ef":(function(r,u,t){var a=t("d066"),i=t("241c"),l=t("7418"),c=t("825a");r.exports=a("Reflect","ownKeys")||function(d){var v=i.f(c(d)),p=l.f;return p?v.concat(p(d)):v}}),"5a34":(function(r,u,t){var a=t("44e7");r.exports=function(i){if(a(i))throw TypeError("The method doesn't accept regular expressions");return i}}),"5c6c":(function(r,u){r.exports=function(t,a){return{enumerable:!(t&1),configurable:!(t&2),writable:!(t&4),value:a}}}),"5db7":(function(r,u,t){var a=t("23e7"),i=t("a2bf"),l=t("7b0b"),c=t("50c4"),f=t("1c0b"),d=t("65f0");a({target:"Array",proto:!0},{flatMap:function(p){var h=l(this),g=c(h.length),b;return f(p),b=d(h,0),b.length=i(b,h,h,g,0,1,p,arguments.length>1?arguments[1]:void 0),b}})}),6547:(function(r,u,t){var a=t("a691"),i=t("1d80"),l=function(c){return function(f,d){var v=String(i(f)),p=a(d),h=v.length,g,b;return p<0||p>=h?c?"":void 0:(g=v.charCodeAt(p),g<55296||g>56319||p+1===h||(b=v.charCodeAt(p+1))<56320||b>57343?c?v.charAt(p):g:c?v.slice(p,p+2):(g-55296<<10)+(b-56320)+65536)}};r.exports={codeAt:l(!1),charAt:l(!0)}}),"65f0":(function(r,u,t){var a=t("861d"),i=t("e8b5"),l=t("b622"),c=l("species");r.exports=function(f,d){var v;return i(f)&&(v=f.constructor,typeof v=="function"&&(v===Array||i(v.prototype))?v=void 0:a(v)&&(v=v[c],v===null&&(v=void 0))),new(v===void 0?Array:v)(d===0?0:d)}}),"69f3":(function(r,u,t){var a=t("7f9a"),i=t("da84"),l=t("861d"),c=t("9112"),f=t("5135"),d=t("f772"),v=t("d012"),p=i.WeakMap,h,g,b,S=function(O){return b(O)?g(O):h(O,{})},I=function(O){return function(A){var $;if(!l(A)||($=g(A)).type!==O)throw TypeError("Incompatible receiver, "+O+" required");return $}};if(a){var x=new p,C=x.get,P=x.has,L=x.set;h=function(O,A){return L.call(x,O,A),A},g=function(O){return C.call(x,O)||{}},b=function(O){return P.call(x,O)}}else{var U=d("state");v[U]=!0,h=function(O,A){return c(O,U,A),A},g=function(O){return f(O,U)?O[U]:{}},b=function(O){return f(O,U)}}r.exports={set:h,get:g,has:b,enforce:S,getterFor:I}}),"6eeb":(function(r,u,t){var a=t("da84"),i=t("9112"),l=t("5135"),c=t("ce4e"),f=t("8925"),d=t("69f3"),v=d.get,p=d.enforce,h=String(String).split("String");(r.exports=function(g,b,S,I){var x=I?!!I.unsafe:!1,C=I?!!I.enumerable:!1,P=I?!!I.noTargetGet:!1;if(typeof S=="function"&&(typeof b=="string"&&!l(S,"name")&&i(S,"name",b),p(S).source=h.join(typeof b=="string"?b:"")),g===a){C?g[b]=S:c(b,S);return}else x?!P&&g[b]&&(C=!0):delete g[b];C?g[b]=S:i(g,b,S)})(Function.prototype,"toString",function(){return typeof this=="function"&&v(this).source||f(this)})}),"6f53":(function(r,u,t){var a=t("83ab"),i=t("df75"),l=t("fc6a"),c=t("d1e7").f,f=function(d){return function(v){for(var p=l(v),h=i(p),g=h.length,b=0,S=[],I;g>b;)I=h[b++],(!a||c.call(p,I))&&S.push(d?[I,p[I]]:p[I]);return S}};r.exports={entries:f(!0),values:f(!1)}}),"73d9":(function(r,u,t){var a=t("44d2");a("flatMap")}),7418:(function(r,u){u.f=Object.getOwnPropertySymbols}),"746f":(function(r,u,t){var a=t("428f"),i=t("5135"),l=t("e538"),c=t("9bf2").f;r.exports=function(f){var d=a.Symbol||(a.Symbol={});i(d,f)||c(d,f,{value:l.f(f)})}}),7839:(function(r,u){r.exports=["constructor","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","toLocaleString","toString","valueOf"]}),"7b0b":(function(r,u,t){var a=t("1d80");r.exports=function(i){return Object(a(i))}}),"7c73":(function(r,u,t){var a=t("825a"),i=t("37e8"),l=t("7839"),c=t("d012"),f=t("1be4"),d=t("cc12"),v=t("f772"),p=">",h="<",g="prototype",b="script",S=v("IE_PROTO"),I=function(){},x=function(O){return h+b+p+O+h+"/"+b+p},C=function(O){O.write(x("")),O.close();var A=O.parentWindow.Object;return O=null,A},P=function(){var O=d("iframe"),A="java"+b+":",$;return O.style.display="none",f.appendChild(O),O.src=String(A),$=O.contentWindow.document,$.open(),$.write(x("document.F=Object")),$.close(),$.F},L,U=function(){try{L=document.domain&&new ActiveXObject("htmlfile")}catch{}U=L?C(L):P();for(var O=l.length;O--;)delete U[g][l[O]];return U()};c[S]=!0,r.exports=Object.create||function(A,$){var W;return A!==null?(I[g]=a(A),W=new I,I[g]=null,W[S]=A):W=U(),$===void 0?W:i(W,$)}}),"7dd0":(function(r,u,t){var a=t("23e7"),i=t("9ed3"),l=t("e163"),c=t("d2bb"),f=t("d44e"),d=t("9112"),v=t("6eeb"),p=t("b622"),h=t("c430"),g=t("3f8c"),b=t("ae93"),S=b.IteratorPrototype,I=b.BUGGY_SAFARI_ITERATORS,x=p("iterator"),C="keys",P="values",L="entries",U=function(){return this};r.exports=function(O,A,$,W,R,F,k){i($,A,W);var M=function(ft){if(ft===R&&ht)return ht;if(!I&&ft in dt)return dt[ft];switch(ft){case C:return function(){return new $(this,ft)};case P:return function(){return new $(this,ft)};case L:return function(){return new $(this,ft)}}return function(){return new $(this)}},H=A+" Iterator",lt=!1,dt=O.prototype,wt=dt[x]||dt["@@iterator"]||R&&dt[R],ht=!I&&wt||M(R),gt=A=="Array"&&dt.entries||wt,Ct,Dt,St;if(gt&&(Ct=l(gt.call(new O)),S!==Object.prototype&&Ct.next&&(!h&&l(Ct)!==S&&(c?c(Ct,S):typeof Ct[x]!="function"&&d(Ct,x,U)),f(Ct,H,!0,!0),h&&(g[H]=U))),R==P&&wt&&wt.name!==P&&(lt=!0,ht=function(){return wt.call(this)}),(!h||k)&&dt[x]!==ht&&d(dt,x,ht),g[A]=ht,R)if(Dt={values:M(P),keys:F?ht:M(C),entries:M(L)},k)for(St in Dt)(I||lt||!(St in dt))&&v(dt,St,Dt[St]);else a({target:A,proto:!0,forced:I||lt},Dt);return Dt}}),"7f9a":(function(r,u,t){var a=t("da84"),i=t("8925"),l=a.WeakMap;r.exports=typeof l=="function"&&/native code/.test(i(l))}),"825a":(function(r,u,t){var a=t("861d");r.exports=function(i){if(!a(i))throw TypeError(String(i)+" is not an object");return i}}),"83ab":(function(r,u,t){var a=t("d039");r.exports=!a(function(){return Object.defineProperty({},1,{get:function(){return 7}})[1]!=7})}),8418:(function(r,u,t){var a=t("c04e"),i=t("9bf2"),l=t("5c6c");r.exports=function(c,f,d){var v=a(f);v in c?i.f(c,v,l(0,d)):c[v]=d}}),"861d":(function(r,u){r.exports=function(t){return typeof t=="object"?t!==null:typeof t=="function"}}),8875:(function(r,u,t){var a,i,l;(function(c,f){i=[],a=f,l=typeof a=="function"?a.apply(u,i):a,l!==void 0&&(r.exports=l)})(typeof self<"u"?self:this,function(){function c(){var f=Object.getOwnPropertyDescriptor(document,"currentScript");if(!f&&"currentScript"in document&&document.currentScript||f&&f.get!==c&&document.currentScript)return document.currentScript;try{throw new Error}catch(L){var d=/.*at [^(]*\((.*):(.+):(.+)\)$/ig,v=/@([^@]*):(\d+):(\d+)\s*$/ig,p=d.exec(L.stack)||v.exec(L.stack),h=p&&p[1]||!1,g=p&&p[2]||!1,b=document.location.href.replace(document.location.hash,""),S,I,x,C=document.getElementsByTagName("script");h===b&&(S=document.documentElement.outerHTML,I=new RegExp("(?:[^\\n]+?\\n){0,"+(g-2)+"}[^<]*']); + }); + + /** @var StructuredDataService $service */ + $action = new InjectStructuredDataAction($service); + + $result = $action->executeForItem($model, 'product'); + + $this->assertSame('', $result); + } + + #[Test] + public function execute_for_item_returns_null_when_resource_not_enabled(): void + { + Config::set('justbetter.structured-data.runway', []); + + $model = new class extends Model + { + protected $table = 'products'; + }; + + $service = $this->mock(StructuredDataService::class, function (MockInterface $mock): void { + $mock->shouldNotReceive('getJsonLdScripts'); + }); + + /** @var StructuredDataService $service */ + $action = new InjectStructuredDataAction($service); + + $this->assertNull($action->executeForItem($model, 'product')); + } + + #[Test] + public function execute_for_item_handles_entry(): void + { + Config::set('justbetter.structured-data.collections', ['blog']); + + $collection = CollectionFacade::make('blog'); + $collection->save(); + $entry = (new Entry)->collection($collection)->id('entry-123'); + + $service = $this->mock(StructuredDataService::class, function (MockInterface $mock) use ($entry): void { + $mock->shouldReceive('getJsonLdScripts') + ->once() + ->with($entry) + ->andReturn(['']); + }); + + /** @var StructuredDataService $service */ + $action = new InjectStructuredDataAction($service); + + $this->assertSame('', $action->executeForItem($entry)); + } +} diff --git a/tests/Unit/Services/StructuredDataServiceRunwayTest.php b/tests/Unit/Services/StructuredDataServiceRunwayTest.php new file mode 100644 index 0000000..4867b36 --- /dev/null +++ b/tests/Unit/Services/StructuredDataServiceRunwayTest.php @@ -0,0 +1,125 @@ +save(); + + $template = (new Entry) + ->collection($templatesCollection) + ->id('runway-template-1') + ->set('blueprint_type', 'runway') + ->set('use_for_runway', 'product') + ->set('schema_data', [[ + 'specialProps' => ['context' => 'https://schema.org', 'type' => 'Product'], + 'fields' => [], + ]]); + $template->save(); + + $model = new class extends Model + { + protected $table = 'products'; + + protected $attributes = [ + 'name' => 'Test Bike', + 'sku' => 'BIKE-1', + ]; + }; + + $parser = $this->mock(StructuredDataParser::class); + /** @var StructuredDataParser $parser */ + $service = new StructuredDataService($parser); + + $templates = $service->getTemplates($model, 'product'); + + $this->assertContains('runway-template-1', $templates); + } + + #[Test] + public function get_templates_returns_empty_when_runway_handle_not_enabled(): void + { + Config::set('justbetter.structured-data.runway', []); + + $model = new class extends Model + { + protected $table = 'products'; + }; + + $parser = $this->mock(StructuredDataParser::class); + /** @var StructuredDataParser $parser */ + $service = new StructuredDataService($parser); + + $this->assertSame([], $service->getTemplates($model, 'product')); + } + + #[Test] + public function get_json_ld_scripts_parses_schemas_for_runway_model(): void + { + Config::set('justbetter.structured-data.runway', ['product']); + + $templatesCollection = CollectionFacade::make('structured_data_templates'); + $templatesCollection->save(); + + $schema = [[ + 'specialProps' => ['context' => 'https://schema.org', 'type' => 'Product'], + 'fields' => [ + ['key' => 'name', 'type' => 'string', 'value' => '{{ name }}'], + ], + ]]; + + $template = (new Entry) + ->collection($templatesCollection) + ->id('runway-template-2') + ->set('blueprint_type', 'runway') + ->set('use_for_runway', 'product') + ->set('schema_data', $schema); + $template->save(); + + $model = new class extends Model + { + protected $table = 'products'; + + protected $attributes = [ + 'name' => 'Test Bike', + ]; + }; + + $parser = $this->mock(StructuredDataParser::class, function (MockInterface $mock) use ($schema, $model): void { + $mock->shouldReceive('parse') + ->once() + ->with($schema, $model) + ->andReturn([[ + 'specialProps' => ['context' => 'https://schema.org', 'type' => 'Product'], + 'fields' => [ + ['key' => 'name', 'type' => 'string', 'value' => 'Test Bike'], + ], + ]]); + }); + + /** @var StructuredDataParser $parser */ + $service = new StructuredDataService($parser); + + $scripts = $service->getJsonLdScripts($model, false, 'product'); + + $this->assertCount(1, $scripts); + $this->assertStringContainsString('application/ld+json', $scripts[0]); + $this->assertStringContainsString('Product', $scripts[0]); + } +} diff --git a/tests/Unit/Support/RunwaySupportTest.php b/tests/Unit/Support/RunwaySupportTest.php new file mode 100644 index 0000000..dddd71d --- /dev/null +++ b/tests/Unit/Support/RunwaySupportTest.php @@ -0,0 +1,86 @@ +assertSame(['product', 'category'], RunwaySupport::enabledHandles()); + } + + #[Test] + public function is_handle_enabled_checks_config(): void + { + Config::set('justbetter.structured-data.runway', ['product']); + + $this->assertTrue(RunwaySupport::isHandleEnabled('product')); + $this->assertFalse(RunwaySupport::isHandleEnabled('category')); + } + + #[Test] + public function resolve_resource_handle_uses_explicit_resource(): void + { + Config::set('justbetter.structured-data.runway', ['product']); + + $model = new class extends Model + { + protected $table = 'products'; + }; + + $this->assertSame('product', RunwaySupport::resolveResourceHandle($model, 'product')); + $this->assertNull(RunwaySupport::resolveResourceHandle($model, 'category')); + } + + #[Test] + public function resolve_resource_handle_falls_back_to_class_basename(): void + { + Config::set('justbetter.structured-data.runway', ['runway_support_test_product']); + + $model = new class extends Model + { + protected $table = 'products'; + }; + + // Anonymous class basename won't match; use a named stub instead. + $named = new RunwaySupportTestProduct; + + $this->assertSame('runway_support_test_product', RunwaySupport::resolveResourceHandle($named)); + } + + #[Test] + public function resource_options_returns_empty_when_runway_not_installed(): void + { + Config::set('justbetter.structured-data.runway', ['product']); + + if (RunwaySupport::isInstalled()) { + $this->markTestSkipped('Runway is installed in this environment.'); + } + + $this->assertSame([], RunwaySupport::resourceOptions()); + } + + #[Test] + public function find_by_uri_returns_null_when_runway_not_installed(): void + { + if (RunwaySupport::isInstalled()) { + $this->markTestSkipped('Runway is installed in this environment.'); + } + + $this->assertNull(RunwaySupport::findByUri('/some-product')); + } +} + +class RunwaySupportTestProduct extends Model +{ + protected $table = 'products'; +} diff --git a/tests/Unit/Tags/StructuredDataTest.php b/tests/Unit/Tags/StructuredDataTest.php index cfcb3ab..abfc491 100644 --- a/tests/Unit/Tags/StructuredDataTest.php +++ b/tests/Unit/Tags/StructuredDataTest.php @@ -2,6 +2,7 @@ namespace Justbetter\StatamicStructuredData\Tests\Unit\Tags; +use Illuminate\Database\Eloquent\Model; use Justbetter\StatamicStructuredData\Actions\InjectStructuredDataAction; use Justbetter\StatamicStructuredData\Tags\StructuredData; use Justbetter\StatamicStructuredData\Tests\TestCase; @@ -45,4 +46,45 @@ public function head_method_returns_null_when_action_returns_null(): void $this->assertNull($result); } + + #[Test] + public function for_method_passes_item_and_resource_to_action(): void + { + $model = new class extends Model + { + protected $table = 'products'; + }; + + $action = $this->mock(InjectStructuredDataAction::class, function (MockInterface $mock) use ($model): void { + $mock->shouldReceive('executeForItem') + ->once() + ->with($model, 'product') + ->andReturn(''); + }); + + /** @var InjectStructuredDataAction $action */ + $tag = new StructuredData($action); + $tag->setContext([]); + $tag->setParameters([ + 'item' => $model, + 'resource' => 'product', + ]); + + $this->assertSame('', $tag->for()); + } + + #[Test] + public function for_method_returns_null_when_item_missing(): void + { + $action = $this->mock(InjectStructuredDataAction::class, function (MockInterface $mock): void { + $mock->shouldNotReceive('executeForItem'); + }); + + /** @var InjectStructuredDataAction $action */ + $tag = new StructuredData($action); + $tag->setContext([]); + $tag->setParameters([]); + + $this->assertNull($tag->for()); + } } From 00dda03469c5520290757c81f798737fd772bfa1 Mon Sep 17 00:00:00 2001 From: Kevin Meijer Date: Tue, 4 Aug 2026 14:13:12 +0200 Subject: [PATCH 2/7] Updated translations --- resources/lang/nl.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/resources/lang/nl.json b/resources/lang/nl.json index 0d84753..33f3eb8 100644 --- a/resources/lang/nl.json +++ b/resources/lang/nl.json @@ -86,6 +86,9 @@ "Use Default": "Standaard gebruiken", "Use for collection": "Gebruiken voor collectie", "Use for taxonomy": "Gebruiken voor taxonomie", + "Use for Runway resource": "Gebruiken voor Runway-resource", + "Templates targeting a Runway resource apply to all models of that resource.": "Templates die een Runway-resource targeten worden toegepast op alle modellen van die resource.", + "Runway": "Runway", "Taxonomy": "Taxonomie", "Value": "Waarde", "Value Field": "Waardeveld", From 4b8d2af5b235e13a86153f2f33a801dd11cacbb2 Mon Sep 17 00:00:00 2001 From: Kevin Meijer Date: Tue, 4 Aug 2026 14:41:17 +0200 Subject: [PATCH 3/7] Fixed tests & phpstan + pint errors --- composer.json | 5 +- resources/dist/hot | 1 - src/Support/RunwaySupport.php | 9 +- stubs/runway.php | 61 +++++- tests/TestCase.php | 22 +++ .../InjectStructuredDataActionRunwayTest.php | 177 +++++++++++++++++ .../AvailableVariablesFieldtypeTest.php | 178 ++++++++++++++++++ .../Unit/Parser/StructuredDataParserTest.php | 90 +++++++++ .../Services/ReplicatorFieldServiceTest.php | 107 +++++++++++ .../StructuredDataServiceRunwayTest.php | 12 ++ tests/Unit/Support/RunwaySupportTest.php | 165 ++++++++++++++-- 11 files changed, 805 insertions(+), 22 deletions(-) delete mode 100644 resources/dist/hot diff --git a/composer.json b/composer.json index f9466e8..ba464cd 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,10 @@ "autoload-dev": { "psr-4": { "Justbetter\\StatamicStructuredData\\Tests\\": "tests" - } + }, + "files": [ + "stubs/runway.php" + ] }, "require": { "php": "^8.4|^8.5", diff --git a/resources/dist/hot b/resources/dist/hot deleted file mode 100644 index 7ab76d5..0000000 --- a/resources/dist/hot +++ /dev/null @@ -1 +0,0 @@ -http://[::1]:5175 \ No newline at end of file diff --git a/src/Support/RunwaySupport.php b/src/Support/RunwaySupport.php index 439626d..227e6bc 100644 --- a/src/Support/RunwaySupport.php +++ b/src/Support/RunwaySupport.php @@ -10,9 +10,16 @@ class RunwaySupport { + private static ?bool $installedOverride = null; + + public static function fakeInstalled(?bool $installed): void + { + self::$installedOverride = $installed; + } + public static function isInstalled(): bool { - return class_exists(Runway::class); + return self::$installedOverride ?? class_exists(Runway::class); } /** diff --git a/stubs/runway.php b/stubs/runway.php index 3d1f370..d166fac 100644 --- a/stubs/runway.php +++ b/stubs/runway.php @@ -4,51 +4,98 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; +use Statamic\Facades\Blueprint as BlueprintFacade; use Statamic\Fields\Blueprint; class Runway { + /** @var Collection|null */ + public static $resources = null; + + /** @var array */ + public static array $findResults = []; + + public static function reset(): void + { + self::$resources = null; + self::$findResults = []; + Resource::reset(); + ModelRepository::reset(); + } + + /** + * @param array $resources + */ + public static function fakeResources(array $resources): void + { + self::$resources = new Collection($resources); + } + /** * @return Collection */ public static function allResources(): Collection { - return new Collection; + return self::$resources ?? new Collection; } public static function findResource(string $resourceHandle): ?Resource { - return null; + if (array_key_exists($resourceHandle, self::$findResults)) { + return self::$findResults[$resourceHandle]; + } + + return self::allResources()->get($resourceHandle); } } class Resource { + public string $resourceHandle = ''; + + public string $resourceName = ''; + + public ?Blueprint $resourceBlueprint = null; + + public ?Model $resourceModel = null; + + public static function reset(): void + { + // no static state beyond instances + } + public function handle(): string { - return ''; + return $this->resourceHandle; } public function name(): string { - return ''; + return $this->resourceName; } public function blueprint(): Blueprint { - return \Statamic\Facades\Blueprint::make(); + return $this->resourceBlueprint ?? BlueprintFacade::make(); } public function model(): Model { - return new class extends Model {}; + return $this->resourceModel ?? new class extends Model {}; } } class ModelRepository { + public static mixed $findByUriResult = null; + + public static function reset(): void + { + self::$findByUriResult = null; + } + public function findByUri(string $uri): mixed { - return null; + return self::$findByUriResult; } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 9ca6f57..4b9df45 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,8 +4,11 @@ use Illuminate\Foundation\Testing\LazilyRefreshDatabase; use Justbetter\StatamicStructuredData\ServiceProvider; +use Justbetter\StatamicStructuredData\Support\RunwaySupport; use Statamic\Testing\AddonTestCase; use Statamic\Testing\Concerns\PreventsSavingStacheItemsToDisk; +use StatamicRadPack\Runway\ModelRepository; +use StatamicRadPack\Runway\Runway; abstract class TestCase extends AddonTestCase { @@ -13,4 +16,23 @@ abstract class TestCase extends AddonTestCase use PreventsSavingStacheItemsToDisk; protected string $addonServiceProvider = ServiceProvider::class; + + protected function setUp(): void + { + parent::setUp(); + + Runway::reset(); + RunwaySupport::fakeInstalled(false); + + assert($this->app !== null); + $this->app->instance(ModelRepository::class, new ModelRepository); + } + + protected function tearDown(): void + { + RunwaySupport::fakeInstalled(null); + Runway::reset(); + + parent::tearDown(); + } } diff --git a/tests/Unit/Actions/InjectStructuredDataActionRunwayTest.php b/tests/Unit/Actions/InjectStructuredDataActionRunwayTest.php index 5f604cc..ab4288b 100644 --- a/tests/Unit/Actions/InjectStructuredDataActionRunwayTest.php +++ b/tests/Unit/Actions/InjectStructuredDataActionRunwayTest.php @@ -6,11 +6,22 @@ use Illuminate\Support\Facades\Config; use Justbetter\StatamicStructuredData\Actions\InjectStructuredDataAction; use Justbetter\StatamicStructuredData\Services\StructuredDataService; +use Justbetter\StatamicStructuredData\Support\RunwaySupport; use Justbetter\StatamicStructuredData\Tests\TestCase; use Mockery\MockInterface; use PHPUnit\Framework\Attributes\Test; use Statamic\Entries\Entry; use Statamic\Facades\Collection as CollectionFacade; +use Statamic\Facades\Entry as EntryFacade; +use Statamic\Facades\Site as SiteFacade; +use Statamic\Facades\Taxonomy as TaxonomyFacade; +use Statamic\Facades\Term; +use Statamic\Facades\URL; +use Statamic\Sites\Site; +use Statamic\Taxonomies\LocalizedTerm; +use Statamic\Taxonomies\Taxonomy; +use StatamicRadPack\Runway\ModelRepository; +use StatamicRadPack\Runway\Resource; class InjectStructuredDataActionRunwayTest extends TestCase { @@ -59,6 +70,29 @@ public function execute_for_item_returns_null_when_resource_not_enabled(): void $this->assertNull($action->executeForItem($model, 'product')); } + #[Test] + public function execute_for_item_returns_null_when_scripts_empty(): void + { + Config::set('justbetter.structured-data.runway', ['product']); + + $model = new class extends Model + { + protected $table = 'products'; + }; + + $service = $this->mock(StructuredDataService::class, function (MockInterface $mock) use ($model): void { + $mock->shouldReceive('getJsonLdScripts') + ->once() + ->with($model, false, 'product') + ->andReturn([]); + }); + + /** @var StructuredDataService $service */ + $action = new InjectStructuredDataAction($service); + + $this->assertNull($action->executeForItem($model, 'product')); + } + #[Test] public function execute_for_item_handles_entry(): void { @@ -80,4 +114,147 @@ public function execute_for_item_handles_entry(): void $this->assertSame('', $action->executeForItem($entry)); } + + #[Test] + public function execute_for_item_handles_term(): void + { + Config::set('justbetter.structured-data.taxonomies', ['categories']); + + /** @var Taxonomy $taxonomy */ + $taxonomy = TaxonomyFacade::make('categories'); + $taxonomy->save(); + + $term = $this->mock(LocalizedTerm::class, function (MockInterface $mock) use ($taxonomy): void { + $mock->shouldReceive('taxonomy')->andReturn($taxonomy); + }); + + $service = $this->mock(StructuredDataService::class, function (MockInterface $mock) use ($term): void { + $mock->shouldReceive('getJsonLdScripts') + ->once() + ->with($term) + ->andReturn(['']); + }); + + /** @var StructuredDataService $service */ + $action = new InjectStructuredDataAction($service); + + $this->assertSame('', $action->executeForItem($term)); + } + + #[Test] + public function execute_for_item_returns_null_for_unsupported_type(): void + { + $service = $this->mock(StructuredDataService::class, function (MockInterface $mock): void { + $mock->shouldNotReceive('getJsonLdScripts'); + }); + + /** @var StructuredDataService $service */ + $action = new InjectStructuredDataAction($service); + + /** @phpstan-ignore-next-line argument.type */ + $this->assertNull($action->executeForItem('not-supported')); + } + + #[Test] + public function execute_handles_runway_model_from_uri(): void + { + RunwaySupport::fakeInstalled(true); + Config::set('justbetter.structured-data.runway', ['product']); + + $resource = new Resource; + $resource->resourceHandle = 'product'; + + $model = new class extends Model + { + public Resource $resource; + + protected $table = 'products'; + + public function runwayResource(): Resource + { + return $this->resource; + } + }; + $model->resource = $resource; + + ModelRepository::$findByUriResult = $model; + + URL::shouldReceive('getCurrent')->andReturn('/product-url'); + $site = $this->mock(Site::class, function (MockInterface $mock): void { + $mock->shouldReceive('handle')->andReturn('default'); + }); + SiteFacade::shouldReceive('current')->andReturn($site); + SiteFacade::shouldReceive('multiEnabled')->andReturn(false); + EntryFacade::shouldReceive('findByUri')->andReturn(null); + Term::shouldReceive('findByUri')->andReturn(null); + + $service = $this->mock(StructuredDataService::class, function (MockInterface $mock) use ($model): void { + $mock->shouldReceive('getJsonLdScripts') + ->once() + ->with($model, false, 'product') + ->andReturn(['']); + }); + + /** @var StructuredDataService $service */ + $action = new InjectStructuredDataAction($service); + + $this->assertSame('', $action->execute()); + } + + #[Test] + public function execute_returns_null_when_runway_uri_model_not_enabled(): void + { + RunwaySupport::fakeInstalled(true); + Config::set('justbetter.structured-data.runway', []); + + $model = new class extends Model + { + protected $table = 'products'; + }; + + ModelRepository::$findByUriResult = $model; + + URL::shouldReceive('getCurrent')->andReturn('/product-url'); + $site = $this->mock(Site::class, function (MockInterface $mock): void { + $mock->shouldReceive('handle')->andReturn('default'); + }); + SiteFacade::shouldReceive('current')->andReturn($site); + SiteFacade::shouldReceive('multiEnabled')->andReturn(false); + EntryFacade::shouldReceive('findByUri')->andReturn(null); + Term::shouldReceive('findByUri')->andReturn(null); + + $service = $this->mock(StructuredDataService::class, function (MockInterface $mock): void { + $mock->shouldNotReceive('getJsonLdScripts'); + }); + + /** @var StructuredDataService $service */ + $action = new InjectStructuredDataAction($service); + + $this->assertNull($action->execute()); + } + + #[Test] + public function execute_returns_null_when_runway_uri_result_is_not_model(): void + { + RunwaySupport::fakeInstalled(true); + ModelRepository::$findByUriResult = 'not-a-model'; + + URL::shouldReceive('getCurrent')->andReturn('/product-url'); + $site = $this->mock(Site::class, function (MockInterface $mock): void { + $mock->shouldReceive('handle')->andReturn('default'); + }); + SiteFacade::shouldReceive('current')->andReturn($site); + SiteFacade::shouldReceive('multiEnabled')->andReturn(false); + EntryFacade::shouldReceive('findByUri')->andReturn(null); + Term::shouldReceive('findByUri')->andReturn(null); + + $service = $this->mock(StructuredDataService::class, function (MockInterface $mock): void { + $mock->shouldNotReceive('getJsonLdScripts'); + }); + + /** @var StructuredDataService $service */ + $action = new InjectStructuredDataAction($service); + + $this->assertNull($action->execute()); + } } diff --git a/tests/Unit/Fieldtypes/AvailableVariablesFieldtypeTest.php b/tests/Unit/Fieldtypes/AvailableVariablesFieldtypeTest.php index e2dbe42..93f5bb8 100644 --- a/tests/Unit/Fieldtypes/AvailableVariablesFieldtypeTest.php +++ b/tests/Unit/Fieldtypes/AvailableVariablesFieldtypeTest.php @@ -2,7 +2,10 @@ namespace Justbetter\StatamicStructuredData\Tests\Unit\Fieldtypes; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Config; use Justbetter\StatamicStructuredData\Fieldtypes\AvailableVariablesFieldtype; +use Justbetter\StatamicStructuredData\Support\RunwaySupport; use Justbetter\StatamicStructuredData\Tests\TestCase; use Mockery; use Mockery\MockInterface; @@ -19,8 +22,11 @@ use Statamic\Fields\Field; use Statamic\Fields\Fields; use Statamic\Fields\Fieldset; +use Statamic\Fields\LabeledValue; use Statamic\Globals\GlobalCollection; use Statamic\Taxonomies\Taxonomy; +use StatamicRadPack\Runway\Resource; +use StatamicRadPack\Runway\Runway; class AvailableVariablesFieldtypeTest extends TestCase { @@ -1344,4 +1350,176 @@ protected function seoProIsInstalled(): bool $this->assertNotContains('seo:title', $fieldNames); } + + #[Test] + public function get_runway_fields_returns_empty_when_runway_not_installed(): void + { + RunwaySupport::fakeInstalled(false); + + $fieldtype = new AvailableVariablesFieldtype; + $fieldMock = $this->mock(Field::class, function (MockInterface $mock): void { + $mock->shouldReceive('parent')->andReturn(null); + }); + $fieldtype->setField($fieldMock); + + $method = (new \ReflectionClass($fieldtype))->getMethod('getRunwayFields'); + $method->setAccessible(true); + + $this->assertSame([], $method->invoke($fieldtype)); + } + + #[Test] + public function get_runway_fields_returns_empty_when_parent_is_not_entry(): void + { + RunwaySupport::fakeInstalled(true); + + $fieldtype = new AvailableVariablesFieldtype; + $fieldMock = $this->mock(Field::class, function (MockInterface $mock): void { + $mock->shouldReceive('parent')->andReturn(null); + }); + $fieldtype->setField($fieldMock); + + $method = (new \ReflectionClass($fieldtype))->getMethod('getRunwayFields'); + $method->setAccessible(true); + + $this->assertSame([], $method->invoke($fieldtype)); + } + + #[Test] + public function get_runway_fields_returns_empty_when_no_resource_handle(): void + { + RunwaySupport::fakeInstalled(true); + + $templatesCollection = CollectionFacade::make('structured_data_templates'); + $templatesCollection->save(); + $entry = (new Entry)->collection($templatesCollection)->id('template-123'); + + $fieldtype = new AvailableVariablesFieldtype; + $fieldMock = $this->mock(Field::class, function (MockInterface $mock) use ($entry): void { + $mock->shouldReceive('parent')->andReturn($entry); + }); + $fieldtype->setField($fieldMock); + + $method = (new \ReflectionClass($fieldtype))->getMethod('getRunwayFields'); + $method->setAccessible(true); + + $this->assertSame([], $method->invoke($fieldtype)); + } + + #[Test] + public function get_runway_fields_returns_empty_when_resource_not_found(): void + { + RunwaySupport::fakeInstalled(true); + Config::set('justbetter.structured-data.runway', ['product']); + + $templatesCollection = CollectionFacade::make('structured_data_templates'); + $templatesCollection->save(); + $entry = (new Entry)->collection($templatesCollection)->id('template-123'); + $entry->use_for_runway = 'product'; + + $fieldtype = new AvailableVariablesFieldtype; + $fieldMock = $this->mock(Field::class, function (MockInterface $mock) use ($entry): void { + $mock->shouldReceive('parent')->andReturn($entry); + }); + $fieldtype->setField($fieldMock); + + $method = (new \ReflectionClass($fieldtype))->getMethod('getRunwayFields'); + $method->setAccessible(true); + + $this->assertSame([], $method->invoke($fieldtype)); + } + + #[Test] + public function get_runway_fields_returns_blueprint_and_model_variables(): void + { + RunwaySupport::fakeInstalled(true); + Config::set('justbetter.structured-data.runway', ['product']); + + $blueprint = BlueprintFacade::make('runway-product')->setContents([ + 'fields' => [ + [ + 'handle' => 'name', + 'field' => [ + 'type' => 'text', + 'display' => 'Name', + ], + ], + ], + ]); + + $model = new class extends Model + { + protected $table = 'products'; + + protected $fillable = ['sku', 'color']; + + protected $attributes = [ + 'sku' => 'ABC', + 'name' => 'Bike', + ]; + }; + + $resource = new Resource; + $resource->resourceHandle = 'product'; + $resource->resourceName = 'Products'; + $resource->resourceBlueprint = $blueprint; + $resource->resourceModel = $model; + Runway::$findResults['product'] = $resource; + + $templatesCollection = CollectionFacade::make('structured_data_templates'); + $templatesCollection->save(); + $entry = (new Entry)->collection($templatesCollection)->id('template-123'); + $entry->use_for_runway = 'product'; + + $fieldtype = new AvailableVariablesFieldtype; + $fieldMock = $this->mock(Field::class, function (MockInterface $mock) use ($entry): void { + $mock->shouldReceive('parent')->andReturn($entry); + }); + $fieldtype->setField($fieldMock); + + $method = (new \ReflectionClass($fieldtype))->getMethod('getRunwayFields'); + $method->setAccessible(true); + $result = $method->invoke($fieldtype); + + $this->assertIsArray($result); + $names = array_column($result, 'name'); + $this->assertContains('absolute_url', $names); + $this->assertContains('name', $names); + $this->assertContains('sku', $names); + $this->assertContains('color', $names); + } + + #[Test] + public function resolve_runway_resource_handle_unwraps_labeled_value(): void + { + $templatesCollection = CollectionFacade::make('structured_data_templates'); + $templatesCollection->save(); + $entry = (new Entry)->collection($templatesCollection)->id('template-123'); + $entry->set('use_for_runway', 'product'); + $this->assertInstanceOf(LabeledValue::class, $entry->use_for_runway); + + $fieldtype = new AvailableVariablesFieldtype; + $method = (new \ReflectionClass($fieldtype))->getMethod('resolveRunwayResourceHandle'); + $method->setAccessible(true); + + $this->assertSame('product', $method->invoke($fieldtype, $entry)); + } + + #[Test] + public function preload_includes_runway_variables_key(): void + { + $fieldtype = new AvailableVariablesFieldtype; + $fieldMock = $this->mock(Field::class, function (MockInterface $mock): void { + $mock->shouldReceive('parent')->andReturn(null); + }); + $fieldtype->setField($fieldMock); + + $result = $fieldtype->preload(); + + /** @var array $result */ + $this->assertArrayHasKey('variables', $result); + /** @var array $resultVariables */ + $resultVariables = $result['variables']; + $this->assertArrayHasKey('runway', $resultVariables); + } } diff --git a/tests/Unit/Parser/StructuredDataParserTest.php b/tests/Unit/Parser/StructuredDataParserTest.php index 76222ac..14bcd21 100644 --- a/tests/Unit/Parser/StructuredDataParserTest.php +++ b/tests/Unit/Parser/StructuredDataParserTest.php @@ -2,6 +2,7 @@ namespace Justbetter\StatamicStructuredData\Tests\Unit\Parser; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection as SupportCollection; use Justbetter\StatamicStructuredData\Parser\StructuredDataParser; use Justbetter\StatamicStructuredData\Tests\TestCase; @@ -617,4 +618,93 @@ public function render_html_from_bard_segments_handles_text_value_instance(): vo $this->assertSame('

Value text

', $result); } + + #[Test] + public function resolve_absolute_url_uses_model_url_attribute(): void + { + $model = new class extends Model + { + protected $table = 'products'; + + protected $attributes = [ + 'url' => '/tenways-cgo800s', + ]; + }; + + $parser = new StructuredDataParser; + $method = (new \ReflectionClass($parser))->getMethod('resolveAbsoluteUrl'); + $method->setAccessible(true); + + $result = $method->invoke($parser, $model); + + $this->assertIsString($result); + $this->assertStringContainsString('/tenways-cgo800s', $result); + } + + #[Test] + public function resolve_absolute_url_keeps_absolute_model_urls(): void + { + $model = new class extends Model + { + protected $table = 'products'; + + protected $attributes = [ + 'url' => 'https://example.com/bike', + ]; + }; + + $parser = new StructuredDataParser; + $method = (new \ReflectionClass($parser))->getMethod('resolveAbsoluteUrl'); + $method->setAccessible(true); + + $this->assertSame('https://example.com/bike', $method->invoke($parser, $model)); + } + + #[Test] + public function resolve_absolute_url_returns_empty_string_for_model_without_url(): void + { + $model = new class extends Model + { + protected $table = 'products'; + }; + + $parser = new StructuredDataParser; + $method = (new \ReflectionClass($parser))->getMethod('resolveAbsoluteUrl'); + $method->setAccessible(true); + + $this->assertSame('', $method->invoke($parser, $model)); + } + + #[Test] + public function parse_context_merges_model_attributes(): void + { + $site = $this->mock(Site::class, function (MockInterface $mock): void { + $mock->shouldReceive('toAugmentedArray')->andReturn(['handle' => 'default'])->byDefault(); + $mock->shouldReceive('handle')->andReturn('default')->byDefault(); + }); + SiteFacade::shouldReceive('current')->andReturn($site)->byDefault(); + SiteFacade::shouldReceive('multiEnabled')->andReturn(false)->byDefault(); + SiteFacade::shouldReceive('hasMultiple')->andReturn(false)->byDefault(); + SiteFacade::shouldReceive('default')->andReturn($site)->byDefault(); + SiteFacade::shouldReceive('all')->andReturn(collect([$site]))->byDefault(); + + $model = new class extends Model + { + protected $table = 'products'; + + protected $attributes = [ + 'name' => 'Test Bike', + 'sku' => 'BIKE-1', + ]; + }; + + $parser = new StructuredDataParser; + $method = (new \ReflectionClass($parser))->getMethod('getParseContext'); + $method->setAccessible(true); + $result = $method->invoke($parser, $model); + + $this->assertIsArray($result); + $this->assertSame('Test Bike', $result['name']); + $this->assertSame('BIKE-1', $result['sku']); + } } diff --git a/tests/Unit/Services/ReplicatorFieldServiceTest.php b/tests/Unit/Services/ReplicatorFieldServiceTest.php index 1a0e1e3..2dd290e 100644 --- a/tests/Unit/Services/ReplicatorFieldServiceTest.php +++ b/tests/Unit/Services/ReplicatorFieldServiceTest.php @@ -2,7 +2,9 @@ namespace Justbetter\StatamicStructuredData\Tests\Unit\Services; +use Illuminate\Support\Facades\Config; use Justbetter\StatamicStructuredData\Services\ReplicatorFieldService; +use Justbetter\StatamicStructuredData\Support\RunwaySupport; use Justbetter\StatamicStructuredData\Tests\TestCase; use Mockery; use Mockery\MockInterface; @@ -14,7 +16,10 @@ use Statamic\Facades\Taxonomy as TaxonomyFacade; use Statamic\Fields\Blueprint; use Statamic\Fields\Fields; +use Statamic\Fields\LabeledValue; use Statamic\Taxonomies\Taxonomy; +use StatamicRadPack\Runway\Resource; +use StatamicRadPack\Runway\Runway; class ReplicatorFieldServiceTest extends TestCase { @@ -1048,4 +1053,106 @@ public function it_returns_empty_array_when_taxonomy_term_blueprints_returns_emp $this->assertEmpty($result); } + + #[Test] + public function get_replicator_fields_uses_runway_blueprint_when_configured(): void + { + RunwaySupport::fakeInstalled(true); + Config::set('justbetter.structured-data.runway', ['product']); + + $blueprint = BlueprintFacade::make('runway-product')->setContents([ + 'fields' => [ + [ + 'handle' => 'blocks', + 'field' => [ + 'type' => 'replicator', + 'display' => 'Blocks', + 'sets' => [ + 'text' => [ + 'display' => 'Text', + 'fields' => [ + [ + 'handle' => 'content', + 'field' => [ + 'type' => 'text', + 'display' => 'Content', + ], + ], + ], + ], + ], + ], + ], + ], + ]); + + $resource = new Resource; + $resource->resourceHandle = 'product'; + $resource->resourceBlueprint = $blueprint; + Runway::$findResults['product'] = $resource; + + $collection = CollectionFacade::make('structured_data_templates'); + $collection->save(); + $template = (new Entry) + ->collection($collection) + ->id('template-runway'); + $template->use_for_runway = 'product'; + + $service = new ReplicatorFieldService; + $result = $service->getReplicatorFields($template); + + $this->assertNotEmpty($result); + $this->assertSame('blocks', $result[0]['handle']); + } + + #[Test] + public function get_replicator_fields_unwraps_labeled_value_runway_handle(): void + { + RunwaySupport::fakeInstalled(true); + Config::set('justbetter.structured-data.runway', ['product']); + + $blueprint = BlueprintFacade::make('runway-product-labeled')->setContents([ + 'fields' => [ + [ + 'handle' => 'blocks', + 'field' => [ + 'type' => 'replicator', + 'display' => 'Blocks', + 'sets' => [ + 'text' => [ + 'display' => 'Text', + 'fields' => [ + [ + 'handle' => 'content', + 'field' => [ + 'type' => 'text', + 'display' => 'Content', + ], + ], + ], + ], + ], + ], + ], + ], + ]); + + $resource = new Resource; + $resource->resourceHandle = 'product'; + $resource->resourceBlueprint = $blueprint; + Runway::$findResults['product'] = $resource; + + $collection = CollectionFacade::make('structured_data_templates'); + $collection->save(); + $template = (new Entry) + ->collection($collection) + ->id('template-runway-labeled'); + $template->set('use_for_runway', 'product'); + $this->assertInstanceOf(LabeledValue::class, $template->use_for_runway); + + $service = new ReplicatorFieldService; + $result = $service->getReplicatorFields($template); + + $this->assertNotEmpty($result); + } } diff --git a/tests/Unit/Services/StructuredDataServiceRunwayTest.php b/tests/Unit/Services/StructuredDataServiceRunwayTest.php index 4867b36..755ecda 100644 --- a/tests/Unit/Services/StructuredDataServiceRunwayTest.php +++ b/tests/Unit/Services/StructuredDataServiceRunwayTest.php @@ -122,4 +122,16 @@ public function get_json_ld_scripts_parses_schemas_for_runway_model(): void $this->assertStringContainsString('application/ld+json', $scripts[0]); $this->assertStringContainsString('Product', $scripts[0]); } + + #[Test] + public function get_runway_template_ids_returns_empty_when_handle_not_enabled(): void + { + Config::set('justbetter.structured-data.runway', []); + + $parser = $this->mock(StructuredDataParser::class); + /** @var StructuredDataParser $parser */ + $service = new StructuredDataService($parser); + + $this->assertSame([], $service->getRunwayTemplateIds('product')); + } } diff --git a/tests/Unit/Support/RunwaySupportTest.php b/tests/Unit/Support/RunwaySupportTest.php index dddd71d..7ba4271 100644 --- a/tests/Unit/Support/RunwaySupportTest.php +++ b/tests/Unit/Support/RunwaySupportTest.php @@ -7,6 +7,9 @@ use Justbetter\StatamicStructuredData\Support\RunwaySupport; use Justbetter\StatamicStructuredData\Tests\TestCase; use PHPUnit\Framework\Attributes\Test; +use StatamicRadPack\Runway\ModelRepository; +use StatamicRadPack\Runway\Resource; +use StatamicRadPack\Runway\Runway; class RunwaySupportTest extends TestCase { @@ -46,12 +49,6 @@ public function resolve_resource_handle_falls_back_to_class_basename(): void { Config::set('justbetter.structured-data.runway', ['runway_support_test_product']); - $model = new class extends Model - { - protected $table = 'products'; - }; - - // Anonymous class basename won't match; use a named stub instead. $named = new RunwaySupportTestProduct; $this->assertSame('runway_support_test_product', RunwaySupport::resolveResourceHandle($named)); @@ -61,23 +58,167 @@ public function resolve_resource_handle_falls_back_to_class_basename(): void public function resource_options_returns_empty_when_runway_not_installed(): void { Config::set('justbetter.structured-data.runway', ['product']); + RunwaySupport::fakeInstalled(false); - if (RunwaySupport::isInstalled()) { - $this->markTestSkipped('Runway is installed in this environment.'); - } + $this->assertSame([], RunwaySupport::resourceOptions()); + } + + #[Test] + public function resource_options_returns_empty_when_no_handles_enabled(): void + { + RunwaySupport::fakeInstalled(true); + Config::set('justbetter.structured-data.runway', []); $this->assertSame([], RunwaySupport::resourceOptions()); } + #[Test] + public function resource_options_returns_enabled_resources_when_installed(): void + { + RunwaySupport::fakeInstalled(true); + Config::set('justbetter.structured-data.runway', ['product']); + + $product = new Resource; + $product->resourceHandle = 'product'; + $product->resourceName = 'Products'; + + $category = new Resource; + $category->resourceHandle = 'category'; + $category->resourceName = 'Categories'; + + Runway::fakeResources([ + 'product' => $product, + 'category' => $category, + ]); + + $this->assertSame(['product' => 'Products'], RunwaySupport::resourceOptions()); + } + + #[Test] + public function resolve_resource_handle_uses_runway_resource_method(): void + { + RunwaySupport::fakeInstalled(true); + Config::set('justbetter.structured-data.runway', ['product']); + + $resource = new Resource; + $resource->resourceHandle = 'product'; + + $model = new class extends Model + { + public Resource $resource; + + public function runwayResource(): Resource + { + return $this->resource; + } + }; + $model->resource = $resource; + + $this->assertSame('product', RunwaySupport::resolveResourceHandle($model)); + } + + #[Test] + public function resolve_resource_handle_ignores_disabled_runway_resource(): void + { + RunwaySupport::fakeInstalled(true); + Config::set('justbetter.structured-data.runway', ['category']); + + $resource = new Resource; + $resource->resourceHandle = 'product'; + + $model = new class extends Model + { + public Resource $resource; + + public function runwayResource(): Resource + { + return $this->resource; + } + }; + $model->resource = $resource; + + $this->assertNull(RunwaySupport::resolveResourceHandle($model)); + } + + #[Test] + public function resolve_resource_handle_catches_runway_resource_exceptions(): void + { + RunwaySupport::fakeInstalled(true); + Config::set('justbetter.structured-data.runway', ['product']); + + $model = new class extends Model + { + public function runwayResource(): Resource + { + throw new \RuntimeException('not registered'); + } + }; + + $this->assertNull(RunwaySupport::resolveResourceHandle($model)); + } + + #[Test] + public function resolve_resource_handle_returns_null_for_non_objects_without_explicit_handle(): void + { + $this->assertNull(RunwaySupport::resolveResourceHandle('not-an-object')); + } + #[Test] public function find_by_uri_returns_null_when_runway_not_installed(): void { - if (RunwaySupport::isInstalled()) { - $this->markTestSkipped('Runway is installed in this environment.'); - } + RunwaySupport::fakeInstalled(false); $this->assertNull(RunwaySupport::findByUri('/some-product')); } + + #[Test] + public function find_by_uri_returns_model_when_repository_finds_one(): void + { + RunwaySupport::fakeInstalled(true); + + $model = new class extends Model + { + protected $table = 'products'; + }; + + ModelRepository::$findByUriResult = $model; + + $this->assertSame($model, RunwaySupport::findByUri('/product')); + } + + #[Test] + public function find_by_uri_returns_null_when_repository_result_is_not_a_model(): void + { + RunwaySupport::fakeInstalled(true); + ModelRepository::$findByUriResult = 'not-a-model'; + + $this->assertNull(RunwaySupport::findByUri('/product')); + } + + #[Test] + public function find_resource_returns_null_when_not_installed_or_disabled(): void + { + Config::set('justbetter.structured-data.runway', ['product']); + RunwaySupport::fakeInstalled(false); + $this->assertNull(RunwaySupport::findResource('product')); + + RunwaySupport::fakeInstalled(true); + Config::set('justbetter.structured-data.runway', []); + $this->assertNull(RunwaySupport::findResource('product')); + } + + #[Test] + public function find_resource_returns_resource_when_enabled(): void + { + RunwaySupport::fakeInstalled(true); + Config::set('justbetter.structured-data.runway', ['product']); + + $resource = new Resource; + $resource->resourceHandle = 'product'; + Runway::$findResults['product'] = $resource; + + $this->assertSame($resource, RunwaySupport::findResource('product')); + } } class RunwaySupportTestProduct extends Model From 8b6a59e6865719da7e6b3cb031801fb122d9153f Mon Sep 17 00:00:00 2001 From: Kevin Meijer Date: Tue, 4 Aug 2026 14:55:10 +0200 Subject: [PATCH 4/7] Fixed tests --- .../Fieldtypes/AvailableVariablesFieldtypeTest.php | 10 +++++++--- tests/Unit/Services/ReplicatorFieldServiceTest.php | 11 ++++++----- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/Unit/Fieldtypes/AvailableVariablesFieldtypeTest.php b/tests/Unit/Fieldtypes/AvailableVariablesFieldtypeTest.php index 93f5bb8..8f97b3e 100644 --- a/tests/Unit/Fieldtypes/AvailableVariablesFieldtypeTest.php +++ b/tests/Unit/Fieldtypes/AvailableVariablesFieldtypeTest.php @@ -1494,9 +1494,13 @@ public function resolve_runway_resource_handle_unwraps_labeled_value(): void { $templatesCollection = CollectionFacade::make('structured_data_templates'); $templatesCollection->save(); - $entry = (new Entry)->collection($templatesCollection)->id('template-123'); - $entry->set('use_for_runway', 'product'); - $this->assertInstanceOf(LabeledValue::class, $entry->use_for_runway); + + $entry = new class extends Entry + { + public mixed $use_for_runway; + }; + $entry->collection($templatesCollection)->id('template-123'); + $entry->use_for_runway = new LabeledValue('product', 'Products'); $fieldtype = new AvailableVariablesFieldtype; $method = (new \ReflectionClass($fieldtype))->getMethod('resolveRunwayResourceHandle'); diff --git a/tests/Unit/Services/ReplicatorFieldServiceTest.php b/tests/Unit/Services/ReplicatorFieldServiceTest.php index 2dd290e..67a0c8f 100644 --- a/tests/Unit/Services/ReplicatorFieldServiceTest.php +++ b/tests/Unit/Services/ReplicatorFieldServiceTest.php @@ -1144,11 +1144,12 @@ public function get_replicator_fields_unwraps_labeled_value_runway_handle(): voi $collection = CollectionFacade::make('structured_data_templates'); $collection->save(); - $template = (new Entry) - ->collection($collection) - ->id('template-runway-labeled'); - $template->set('use_for_runway', 'product'); - $this->assertInstanceOf(LabeledValue::class, $template->use_for_runway); + $template = new class extends Entry + { + public mixed $use_for_runway; + }; + $template->collection($collection)->id('template-runway-labeled'); + $template->use_for_runway = new LabeledValue('product', 'Products'); $service = new ReplicatorFieldService; $result = $service->getReplicatorFields($template); From 2927f00ddd25de44c8031a03c9e7087072c7fd99 Mon Sep 17 00:00:00 2001 From: Kevin Meijer Date: Wed, 5 Aug 2026 16:35:14 +0200 Subject: [PATCH 5/7] Feedback + fixed tests --- composer.json | 8 +- phpstan.neon | 4 +- src/Actions/InjectStructuredDataAction.php | 154 +++------------- src/Resolvers/AbstractResourceResolver.php | 23 +++ src/Resolvers/EntryResolver.php | 50 ++++++ src/Resolvers/ResourceResolver.php | 12 ++ src/Resolvers/RunwayResolver.php | 54 ++++++ src/Resolvers/TaxonomyResolver.php | 44 +++++ src/Services/StructuredDataService.php | 26 ++- stubs/runway/ModelRepository.php | 18 ++ stubs/runway/Resource.php | 43 +++++ stubs/{runway.php => runway/Runway.php} | 54 ------ tests/Stubs/BrokenProduct.php | 13 ++ tests/Stubs/Product.php | 20 +++ tests/Stubs/RunwaySupportTestProduct.php | 11 ++ .../InjectStructuredDataActionRunwayTest.php | 51 ++---- .../InjectStructuredDataActionTest.php | 133 -------------- .../AvailableVariablesFieldtypeTest.php | 18 +- .../Unit/Parser/StructuredDataParserTest.php | 42 ++--- tests/Unit/Resolvers/EntryResolverTest.php | 165 ++++++++++++++++++ tests/Unit/Resolvers/RunwayResolverTest.php | 160 +++++++++++++++++ tests/Unit/Resolvers/TaxonomyResolverTest.php | 129 ++++++++++++++ .../StructuredDataServiceRunwayTest.php | 31 +--- .../Services/StructuredDataServiceTest.php | 11 +- tests/Unit/Support/RunwaySupportTest.php | 53 ++---- tests/Unit/Tags/StructuredDataTest.php | 7 +- 26 files changed, 839 insertions(+), 495 deletions(-) create mode 100644 src/Resolvers/AbstractResourceResolver.php create mode 100644 src/Resolvers/EntryResolver.php create mode 100644 src/Resolvers/ResourceResolver.php create mode 100644 src/Resolvers/RunwayResolver.php create mode 100644 src/Resolvers/TaxonomyResolver.php create mode 100644 stubs/runway/ModelRepository.php create mode 100644 stubs/runway/Resource.php rename stubs/{runway.php => runway/Runway.php} (50%) create mode 100644 tests/Stubs/BrokenProduct.php create mode 100644 tests/Stubs/Product.php create mode 100644 tests/Stubs/RunwaySupportTestProduct.php create mode 100644 tests/Unit/Resolvers/EntryResolverTest.php create mode 100644 tests/Unit/Resolvers/RunwayResolverTest.php create mode 100644 tests/Unit/Resolvers/TaxonomyResolverTest.php diff --git a/composer.json b/composer.json index ba464cd..94a98a6 100644 --- a/composer.json +++ b/composer.json @@ -7,11 +7,9 @@ }, "autoload-dev": { "psr-4": { - "Justbetter\\StatamicStructuredData\\Tests\\": "tests" - }, - "files": [ - "stubs/runway.php" - ] + "Justbetter\\StatamicStructuredData\\Tests\\": "tests", + "StatamicRadPack\\Runway\\": "stubs/runway" + } }, "require": { "php": "^8.4|^8.5", diff --git a/phpstan.neon b/phpstan.neon index c9aa18f..940263e 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -8,4 +8,6 @@ parameters: - tests level: 9 scanFiles: - - stubs/runway.php + - stubs/runway/Runway.php + - stubs/runway/Resource.php + - stubs/runway/ModelRepository.php diff --git a/src/Actions/InjectStructuredDataAction.php b/src/Actions/InjectStructuredDataAction.php index dd1bb15..7fa1587 100644 --- a/src/Actions/InjectStructuredDataAction.php +++ b/src/Actions/InjectStructuredDataAction.php @@ -3,40 +3,37 @@ namespace Justbetter\StatamicStructuredData\Actions; use Illuminate\Database\Eloquent\Model; +use Justbetter\StatamicStructuredData\Resolvers\EntryResolver; +use Justbetter\StatamicStructuredData\Resolvers\ResourceResolver; +use Justbetter\StatamicStructuredData\Resolvers\RunwayResolver; +use Justbetter\StatamicStructuredData\Resolvers\TaxonomyResolver; use Justbetter\StatamicStructuredData\Services\StructuredDataService; -use Justbetter\StatamicStructuredData\Support\RunwaySupport; use Statamic\Contracts\Entries\Entry as EntryContract; -use Statamic\Entries\Entry; -use Statamic\Facades\Entry as EntryFacade; -use Statamic\Facades\Site as SiteFacade; -use Statamic\Facades\Term; -use Statamic\Facades\URL; -use Statamic\Sites\Site; use Statamic\Structures\Page; use Statamic\Taxonomies\LocalizedTerm; class InjectStructuredDataAction { - public function __construct(protected StructuredDataService $structuredDataService) {} + /** @var array */ + protected array $resolvers; - public function execute(): ?string + public function __construct(protected StructuredDataService $structuredDataService) { - $entry = $this->getCurrentEntry(); - - if ($entry) { - return $this->handleEntry($entry); - } - - $term = $this->getCurrentTerm(); - - if ($term) { - return $this->handleTaxonomy($term); - } + $this->resolvers = [ + new EntryResolver($structuredDataService), + new TaxonomyResolver($structuredDataService), + new RunwayResolver($structuredDataService), + ]; + } - $runwayModel = $this->getCurrentRunwayModel(); + public function execute(): ?string + { + foreach ($this->resolvers as $resolver) { + $item = $resolver->resolveCurrent(); - if ($runwayModel) { - return $this->handleRunwayModel($runwayModel); + if ($item) { + return $resolver->handle($item); + } } return null; @@ -47,115 +44,12 @@ public function execute(): ?string */ public function executeForItem($item, ?string $resourceHandle = null): ?string { - if ($item instanceof Entry || $item instanceof Page) { - return $this->handleEntry($item); - } - - if ($item instanceof LocalizedTerm) { - return $this->handleTaxonomy($item); - } - - if ($item instanceof Model) { - return $this->handleRunwayModel($item, $resourceHandle); + foreach ($this->resolvers as $resolver) { + if ($resolver->supports($item)) { + return $resolver->handle($item, $resourceHandle); + } } return null; } - - protected function handleEntry(Entry|Page $entry): ?string - { - if ($entry instanceof Page) { - /** @var Entry $entry */ - $entry = $entry->entry(); - } - - $enabledCollections = config()->array('justbetter.structured-data.collections', []); - - if (! in_array($entry->collection()->handle(), $enabledCollections)) { - return null; - } - - return $this->handleScripts($entry); - } - - protected function handleTaxonomy(LocalizedTerm $term): ?string - { - $enabledTaxonomies = config()->array('justbetter.structured-data.taxonomies', []); - - if (! in_array($term->taxonomy()->handle(), $enabledTaxonomies)) { - return null; - } - - return $this->handleScripts($term); - } - - protected function handleRunwayModel(Model $model, ?string $resourceHandle = null): ?string - { - $handle = RunwaySupport::resolveResourceHandle($model, $resourceHandle); - - if (! $handle) { - return null; - } - - $scripts = $this->structuredDataService->getJsonLdScripts($model, false, $handle); - - if (! $scripts) { - return null; - } - - return implode("\n", $scripts); - } - - protected function handleScripts(EntryContract|Page|LocalizedTerm $item): ?string - { - $scripts = $this->structuredDataService->getJsonLdScripts($item); - - if (! $scripts) { - return null; - } - - return implode("\n", $scripts); - } - - protected function getCurrentEntry(): Entry|Page|null - { - $url = URL::getCurrent(); - - /** @var Site $site */ - $site = SiteFacade::current(); - - $entry = EntryFacade::findByUri($url, $site->handle()); - - return $entry instanceof Entry ? $entry : ($entry instanceof Page ? $entry : null); - } - - protected function getCurrentTerm(): ?LocalizedTerm - { - $url = URL::getCurrent(); - - /** @var Site $site */ - $site = SiteFacade::current(); - - $term = Term::findByUri($url, $site->handle()); - - return $term instanceof LocalizedTerm ? $term : null; - } - - protected function getCurrentRunwayModel(): ?Model - { - if (! RunwaySupport::isInstalled()) { - return null; - } - - $url = URL::getCurrent(); - $model = RunwaySupport::findByUri($url); - - if (! $model instanceof Model) { - return null; - } - - $handle = RunwaySupport::resolveResourceHandle($model); - - return $handle ? $model : null; - } } diff --git a/src/Resolvers/AbstractResourceResolver.php b/src/Resolvers/AbstractResourceResolver.php new file mode 100644 index 0000000..b822da7 --- /dev/null +++ b/src/Resolvers/AbstractResourceResolver.php @@ -0,0 +1,23 @@ +structuredDataService->getJsonLdScripts($item); + + if (! $scripts) { + return null; + } + + return implode("\n", $scripts); + } +} diff --git a/src/Resolvers/EntryResolver.php b/src/Resolvers/EntryResolver.php new file mode 100644 index 0000000..9cac9f0 --- /dev/null +++ b/src/Resolvers/EntryResolver.php @@ -0,0 +1,50 @@ +handle()); + + return $entry instanceof Entry ? $entry : ($entry instanceof Page ? $entry : null); + } + + public function supports(mixed $item): bool + { + return $item instanceof Entry || $item instanceof Page; + } + + public function handle(mixed $item, ?string $resourceHandle = null): ?string + { + if (! ($item instanceof Entry || $item instanceof Page)) { + return null; + } + + if ($item instanceof Page) { + /** @var Entry $item */ + $item = $item->entry(); + } + + $enabledCollections = config()->array('justbetter.structured-data.collections', []); + + if (! in_array($item->collection()->handle(), $enabledCollections)) { + return null; + } + + return $this->handleScripts($item); + } +} diff --git a/src/Resolvers/ResourceResolver.php b/src/Resolvers/ResourceResolver.php new file mode 100644 index 0000000..434013c --- /dev/null +++ b/src/Resolvers/ResourceResolver.php @@ -0,0 +1,12 @@ +structuredDataService->getJsonLdScripts($item, false, $handle); + + if (! $scripts) { + return null; + } + + return implode("\n", $scripts); + } +} diff --git a/src/Resolvers/TaxonomyResolver.php b/src/Resolvers/TaxonomyResolver.php new file mode 100644 index 0000000..3c808aa --- /dev/null +++ b/src/Resolvers/TaxonomyResolver.php @@ -0,0 +1,44 @@ +handle()); + + return $term instanceof LocalizedTerm ? $term : null; + } + + public function supports(mixed $item): bool + { + return $item instanceof LocalizedTerm; + } + + public function handle(mixed $item, ?string $resourceHandle = null): ?string + { + if (! $item instanceof LocalizedTerm) { + return null; + } + + $enabledTaxonomies = config()->array('justbetter.structured-data.taxonomies', []); + + if (! in_array($item->taxonomy()->handle(), $enabledTaxonomies)) { + return null; + } + + return $this->handleScripts($item); + } +} diff --git a/src/Services/StructuredDataService.php b/src/Services/StructuredDataService.php index 1cc7619..93b1c98 100644 --- a/src/Services/StructuredDataService.php +++ b/src/Services/StructuredDataService.php @@ -8,7 +8,7 @@ use Justbetter\StatamicStructuredData\Support\RunwaySupport; use Statamic\Contracts\Entries\Entry as EntryContract; use Statamic\Contracts\Taxonomies\Term as TermContract; -use Statamic\Entries\Entry as EntryModel; +use Statamic\Entries\Entry; use Statamic\Facades\Entry as EntryFacade; use Statamic\Query\EloquentQueryBuilder; use Statamic\Structures\Page; @@ -27,10 +27,9 @@ public function __construct(StructuredDataParser $parser) } /** - * @param EntryContract|Page|LocalizedTerm|TermContract|Model $item * @return array */ - public function getJsonLdScripts($item, bool $json = false, ?string $resourceHandle = null): array + public function getJsonLdScripts(EntryContract|Page|LocalizedTerm|TermContract|Model $item, bool $json = false, ?string $resourceHandle = null): array { $templates = $this->getTemplates($item, $resourceHandle); @@ -43,7 +42,7 @@ public function getJsonLdScripts($item, bool $json = false, ?string $resourceHan foreach ($templates as $templateId) { $template = EntryFacade::find($templateId); - if (! $template instanceof EntryModel) { + if (! $template instanceof Entry) { continue; } @@ -79,9 +78,8 @@ public function getJsonLdScripts($item, bool $json = false, ?string $resourceHan /** * @param array $schema - * @param EntryContract|Page|LocalizedTerm|TermContract|Model|null $item */ - public function formatJsonLd(array $schema, bool $json = false, $item = null): string + public function formatJsonLd(array $schema, bool $json = false, EntryContract|Page|LocalizedTerm|TermContract|Model|null $item = null): string { $transformedSchema = $this->transformSchema($schema, $item); $encodedSchema = json_encode($transformedSchema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); @@ -98,10 +96,9 @@ public function formatJsonLd(array $schema, bool $json = false, $item = null): s /** * @param mixed $schemas - * @param EntryContract|Page|LocalizedTerm|TermContract|Model|null $item * @return array> */ - public function parseAndTransformSchemas($schemas, $item = null): array + public function parseAndTransformSchemas($schemas, EntryContract|Page|LocalizedTerm|TermContract|Model|null $item = null): array { if ( ! $item instanceof EntryContract @@ -128,10 +125,9 @@ public function parseAndTransformSchemas($schemas, $item = null): array /** * @param array $schema - * @param EntryContract|Page|LocalizedTerm|TermContract|Model|null $item * @return array */ - public function transformSchema(array $schema, $item = null): array + public function transformSchema(array $schema, EntryContract|Page|LocalizedTerm|TermContract|Model|null $item = null): array { /** @var array $result */ $result = []; @@ -185,10 +181,9 @@ protected function isAssociativeArray(array $array): bool /** * @param array $field - * @param EntryContract|Page|LocalizedTerm|TermContract|Model|null $item * @param array $result */ - protected function transformField(array $field, $item = null, array &$result = []): mixed + protected function transformField(array $field, EntryContract|Page|LocalizedTerm|TermContract|Model|null $item = null, array &$result = []): mixed { $type = $field['type'] ?? null; @@ -232,17 +227,16 @@ protected function transformField(array $field, $item = null, array &$result = [ } /** - * @param EntryContract|Page|LocalizedTerm|Model|mixed $item * @return array */ - public function getTemplates($item, ?string $resourceHandle = null): array + public function getTemplates(EntryContract|Page|LocalizedTerm|TermContract|Model $item, ?string $resourceHandle = null): array { if ($item instanceof Page) { /** @var EntryContract $item */ $item = $item->entry(); } - if ($item instanceof EntryModel) { + if ($item instanceof Entry) { /** @var array|null $templates */ $templates = $item->get('structured_data_templates'); @@ -287,7 +281,7 @@ public function getRunwayTemplateIds(string $resourceHandle): array ->where('blueprint_type', 'runway') ->where('use_for_runway', $resourceHandle) ->get() - ->map(fn ($entry): ?string => $entry->id() !== null ? (string) $entry->id() : null) + ->map(fn (Entry $entry): ?string => $entry->id() !== null ? (string) $entry->id() : null) ->filter() ->values() ->all(); diff --git a/stubs/runway/ModelRepository.php b/stubs/runway/ModelRepository.php new file mode 100644 index 0000000..ec7dee0 --- /dev/null +++ b/stubs/runway/ModelRepository.php @@ -0,0 +1,18 @@ +resourceHandle; + } + + public function name(): string + { + return $this->resourceName; + } + + public function blueprint(): Blueprint + { + return $this->resourceBlueprint ?? BlueprintFacade::make(); + } + + public function model(): Model + { + return $this->resourceModel ?? new class extends Model {}; + } +} diff --git a/stubs/runway.php b/stubs/runway/Runway.php similarity index 50% rename from stubs/runway.php rename to stubs/runway/Runway.php index d166fac..7e081c1 100644 --- a/stubs/runway.php +++ b/stubs/runway/Runway.php @@ -2,10 +2,7 @@ namespace StatamicRadPack\Runway; -use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; -use Statamic\Facades\Blueprint as BlueprintFacade; -use Statamic\Fields\Blueprint; class Runway { @@ -48,54 +45,3 @@ public static function findResource(string $resourceHandle): ?Resource return self::allResources()->get($resourceHandle); } } - -class Resource -{ - public string $resourceHandle = ''; - - public string $resourceName = ''; - - public ?Blueprint $resourceBlueprint = null; - - public ?Model $resourceModel = null; - - public static function reset(): void - { - // no static state beyond instances - } - - public function handle(): string - { - return $this->resourceHandle; - } - - public function name(): string - { - return $this->resourceName; - } - - public function blueprint(): Blueprint - { - return $this->resourceBlueprint ?? BlueprintFacade::make(); - } - - public function model(): Model - { - return $this->resourceModel ?? new class extends Model {}; - } -} - -class ModelRepository -{ - public static mixed $findByUriResult = null; - - public static function reset(): void - { - self::$findByUriResult = null; - } - - public function findByUri(string $uri): mixed - { - return self::$findByUriResult; - } -} diff --git a/tests/Stubs/BrokenProduct.php b/tests/Stubs/BrokenProduct.php new file mode 100644 index 0000000..0c22bdc --- /dev/null +++ b/tests/Stubs/BrokenProduct.php @@ -0,0 +1,13 @@ +runwayResource; + } +} diff --git a/tests/Stubs/RunwaySupportTestProduct.php b/tests/Stubs/RunwaySupportTestProduct.php new file mode 100644 index 0000000..4e5eb74 --- /dev/null +++ b/tests/Stubs/RunwaySupportTestProduct.php @@ -0,0 +1,11 @@ +set('justbetter.structured-data.runway', ['product']); - $model = new class extends Model - { - protected $table = 'products'; - }; + $model = new Product; $service = $this->mock(StructuredDataService::class, function (MockInterface $mock) use ($model): void { $mock->shouldReceive('getJsonLdScripts') @@ -53,12 +49,9 @@ public function execute_for_item_handles_runway_model(): void #[Test] public function execute_for_item_returns_null_when_resource_not_enabled(): void { - Config::set('justbetter.structured-data.runway', []); + config()->set('justbetter.structured-data.runway', []); - $model = new class extends Model - { - protected $table = 'products'; - }; + $model = new Product; $service = $this->mock(StructuredDataService::class, function (MockInterface $mock): void { $mock->shouldNotReceive('getJsonLdScripts'); @@ -73,12 +66,9 @@ public function execute_for_item_returns_null_when_resource_not_enabled(): void #[Test] public function execute_for_item_returns_null_when_scripts_empty(): void { - Config::set('justbetter.structured-data.runway', ['product']); + config()->set('justbetter.structured-data.runway', ['product']); - $model = new class extends Model - { - protected $table = 'products'; - }; + $model = new Product; $service = $this->mock(StructuredDataService::class, function (MockInterface $mock) use ($model): void { $mock->shouldReceive('getJsonLdScripts') @@ -96,7 +86,7 @@ public function execute_for_item_returns_null_when_scripts_empty(): void #[Test] public function execute_for_item_handles_entry(): void { - Config::set('justbetter.structured-data.collections', ['blog']); + config()->set('justbetter.structured-data.collections', ['blog']); $collection = CollectionFacade::make('blog'); $collection->save(); @@ -118,7 +108,7 @@ public function execute_for_item_handles_entry(): void #[Test] public function execute_for_item_handles_term(): void { - Config::set('justbetter.structured-data.taxonomies', ['categories']); + config()->set('justbetter.structured-data.taxonomies', ['categories']); /** @var Taxonomy $taxonomy */ $taxonomy = TaxonomyFacade::make('categories'); @@ -159,23 +149,13 @@ public function execute_for_item_returns_null_for_unsupported_type(): void public function execute_handles_runway_model_from_uri(): void { RunwaySupport::fakeInstalled(true); - Config::set('justbetter.structured-data.runway', ['product']); + config()->set('justbetter.structured-data.runway', ['product']); $resource = new Resource; $resource->resourceHandle = 'product'; - $model = new class extends Model - { - public Resource $resource; - - protected $table = 'products'; - - public function runwayResource(): Resource - { - return $this->resource; - } - }; - $model->resource = $resource; + $model = new Product; + $model->runwayResource = $resource; ModelRepository::$findByUriResult = $model; @@ -205,12 +185,9 @@ public function runwayResource(): Resource public function execute_returns_null_when_runway_uri_model_not_enabled(): void { RunwaySupport::fakeInstalled(true); - Config::set('justbetter.structured-data.runway', []); + config()->set('justbetter.structured-data.runway', []); - $model = new class extends Model - { - protected $table = 'products'; - }; + $model = new Product; ModelRepository::$findByUriResult = $model; diff --git a/tests/Unit/Actions/InjectStructuredDataActionTest.php b/tests/Unit/Actions/InjectStructuredDataActionTest.php index 6583684..d71d791 100644 --- a/tests/Unit/Actions/InjectStructuredDataActionTest.php +++ b/tests/Unit/Actions/InjectStructuredDataActionTest.php @@ -258,137 +258,4 @@ public function execute_returns_imploded_scripts(): void $this->assertEquals("\n", $result); } - - #[Test] - public function get_current_entry_returns_entry(): void - { - $collection = CollectionFacade::make('blog'); - $collection->save(); - $entry = (new Entry)->collection($collection)->id('entry-123'); - - URL::shouldReceive('getCurrent')->andReturn('/test-url'); - $site = $this->mock(Site::class, function (MockInterface $mock): void { - $mock->shouldReceive('handle')->andReturn('default'); - }); - SiteFacade::shouldReceive('current')->andReturn($site); - SiteFacade::shouldReceive('multiEnabled')->andReturn(false); - EntryFacade::shouldReceive('findByUri')->with('/test-url', 'default')->andReturn($entry); - - /** @var StructuredDataService $service */ - $service = $this->mock(StructuredDataService::class); - /** @var StructuredDataService $service */ - $action = new InjectStructuredDataAction($service); - - $reflection = new \ReflectionClass($action); - $method = $reflection->getMethod('getCurrentEntry'); - $method->setAccessible(true); - $result = $method->invoke($action); - - $this->assertInstanceOf(Entry::class, $result); - } - - #[Test] - public function get_current_entry_returns_page(): void - { - $page = $this->mock(Page::class); - - URL::shouldReceive('getCurrent')->andReturn('/test-url'); - $site = $this->mock(Site::class, function (MockInterface $mock): void { - $mock->shouldReceive('handle')->andReturn('default'); - }); - SiteFacade::shouldReceive('current')->andReturn($site); - SiteFacade::shouldReceive('multiEnabled')->andReturn(false); - EntryFacade::shouldReceive('findByUri')->with('/test-url', 'default')->andReturn($page); - - /** @var StructuredDataService $service */ - $service = $this->mock(StructuredDataService::class); - /** @var StructuredDataService $service */ - $action = new InjectStructuredDataAction($service); - - $reflection = new \ReflectionClass($action); - $method = $reflection->getMethod('getCurrentEntry'); - $method->setAccessible(true); - $result = $method->invoke($action); - - $this->assertInstanceOf(Page::class, $result); - } - - #[Test] - public function get_current_entry_returns_null_when_not_entry_or_page(): void - { - URL::shouldReceive('getCurrent')->andReturn('/test-url'); - $site = $this->mock(Site::class, function (MockInterface $mock): void { - $mock->shouldReceive('handle')->andReturn('default'); - }); - SiteFacade::shouldReceive('current')->andReturn($site); - SiteFacade::shouldReceive('multiEnabled')->andReturn(false); - EntryFacade::shouldReceive('findByUri')->with('/test-url', 'default')->andReturn(null); - - /** @var StructuredDataService $service */ - $service = $this->mock(StructuredDataService::class); - /** @var StructuredDataService $service */ - $action = new InjectStructuredDataAction($service); - - $reflection = new \ReflectionClass($action); - $method = $reflection->getMethod('getCurrentEntry'); - $method->setAccessible(true); - $result = $method->invoke($action); - - $this->assertNull($result); - } - - #[Test] - public function get_current_term_returns_localized_term(): void - { - /** @var Taxonomy $taxonomy */ - $taxonomy = TaxonomyFacade::make('categories'); - $taxonomy->save(); - $term = $this->mock(LocalizedTerm::class, function (MockInterface $mock) use ($taxonomy): void { - $mock->shouldReceive('taxonomy')->andReturn($taxonomy); - }); - - URL::shouldReceive('getCurrent')->andReturn('/test-url'); - $site = $this->mock(Site::class, function (MockInterface $mock): void { - $mock->shouldReceive('handle')->andReturn('default'); - }); - SiteFacade::shouldReceive('current')->andReturn($site); - SiteFacade::shouldReceive('multiEnabled')->andReturn(false); - Term::shouldReceive('findByUri')->with('/test-url', 'default')->andReturn($term); - - /** @var StructuredDataService $service */ - $service = $this->mock(StructuredDataService::class); - /** @var StructuredDataService $service */ - $action = new InjectStructuredDataAction($service); - - $reflection = new \ReflectionClass($action); - $method = $reflection->getMethod('getCurrentTerm'); - $method->setAccessible(true); - $result = $method->invoke($action); - - $this->assertInstanceOf(LocalizedTerm::class, $result); - } - - #[Test] - public function get_current_term_returns_null_when_not_localized_term(): void - { - URL::shouldReceive('getCurrent')->andReturn('/test-url'); - $site = $this->mock(Site::class, function (MockInterface $mock): void { - $mock->shouldReceive('handle')->andReturn('default'); - }); - SiteFacade::shouldReceive('current')->andReturn($site); - SiteFacade::shouldReceive('multiEnabled')->andReturn(false); - Term::shouldReceive('findByUri')->with('/test-url', 'default')->andReturn(null); - - /** @var StructuredDataService $service */ - $service = $this->mock(StructuredDataService::class); - /** @var StructuredDataService $service */ - $action = new InjectStructuredDataAction($service); - - $reflection = new \ReflectionClass($action); - $method = $reflection->getMethod('getCurrentTerm'); - $method->setAccessible(true); - $result = $method->invoke($action); - - $this->assertNull($result); - } } diff --git a/tests/Unit/Fieldtypes/AvailableVariablesFieldtypeTest.php b/tests/Unit/Fieldtypes/AvailableVariablesFieldtypeTest.php index 8f97b3e..d488cb9 100644 --- a/tests/Unit/Fieldtypes/AvailableVariablesFieldtypeTest.php +++ b/tests/Unit/Fieldtypes/AvailableVariablesFieldtypeTest.php @@ -2,10 +2,10 @@ namespace Justbetter\StatamicStructuredData\Tests\Unit\Fieldtypes; -use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Config; use Justbetter\StatamicStructuredData\Fieldtypes\AvailableVariablesFieldtype; use Justbetter\StatamicStructuredData\Support\RunwaySupport; +use Justbetter\StatamicStructuredData\Tests\Stubs\Product; use Justbetter\StatamicStructuredData\Tests\TestCase; use Mockery; use Mockery\MockInterface; @@ -1447,17 +1447,11 @@ public function get_runway_fields_returns_blueprint_and_model_variables(): void ], ]); - $model = new class extends Model - { - protected $table = 'products'; - - protected $fillable = ['sku', 'color']; - - protected $attributes = [ - 'sku' => 'ABC', - 'name' => 'Bike', - ]; - }; + $model = new Product([ + 'sku' => 'ABC', + 'name' => 'Bike', + ]); + $model->fillable(['sku', 'color']); $resource = new Resource; $resource->resourceHandle = 'product'; diff --git a/tests/Unit/Parser/StructuredDataParserTest.php b/tests/Unit/Parser/StructuredDataParserTest.php index 14bcd21..0194bc5 100644 --- a/tests/Unit/Parser/StructuredDataParserTest.php +++ b/tests/Unit/Parser/StructuredDataParserTest.php @@ -2,9 +2,9 @@ namespace Justbetter\StatamicStructuredData\Tests\Unit\Parser; -use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection as SupportCollection; use Justbetter\StatamicStructuredData\Parser\StructuredDataParser; +use Justbetter\StatamicStructuredData\Tests\Stubs\Product; use Justbetter\StatamicStructuredData\Tests\TestCase; use Mockery\MockInterface; use PHPUnit\Framework\Attributes\Test; @@ -622,14 +622,9 @@ public function render_html_from_bard_segments_handles_text_value_instance(): vo #[Test] public function resolve_absolute_url_uses_model_url_attribute(): void { - $model = new class extends Model - { - protected $table = 'products'; - - protected $attributes = [ - 'url' => '/tenways-cgo800s', - ]; - }; + $model = new Product([ + 'url' => '/tenways-cgo800s', + ]); $parser = new StructuredDataParser; $method = (new \ReflectionClass($parser))->getMethod('resolveAbsoluteUrl'); @@ -644,14 +639,9 @@ public function resolve_absolute_url_uses_model_url_attribute(): void #[Test] public function resolve_absolute_url_keeps_absolute_model_urls(): void { - $model = new class extends Model - { - protected $table = 'products'; - - protected $attributes = [ - 'url' => 'https://example.com/bike', - ]; - }; + $model = new Product([ + 'url' => 'https://example.com/bike', + ]); $parser = new StructuredDataParser; $method = (new \ReflectionClass($parser))->getMethod('resolveAbsoluteUrl'); @@ -663,10 +653,7 @@ public function resolve_absolute_url_keeps_absolute_model_urls(): void #[Test] public function resolve_absolute_url_returns_empty_string_for_model_without_url(): void { - $model = new class extends Model - { - protected $table = 'products'; - }; + $model = new Product; $parser = new StructuredDataParser; $method = (new \ReflectionClass($parser))->getMethod('resolveAbsoluteUrl'); @@ -688,15 +675,10 @@ public function parse_context_merges_model_attributes(): void SiteFacade::shouldReceive('default')->andReturn($site)->byDefault(); SiteFacade::shouldReceive('all')->andReturn(collect([$site]))->byDefault(); - $model = new class extends Model - { - protected $table = 'products'; - - protected $attributes = [ - 'name' => 'Test Bike', - 'sku' => 'BIKE-1', - ]; - }; + $model = new Product([ + 'name' => 'Test Bike', + 'sku' => 'BIKE-1', + ]); $parser = new StructuredDataParser; $method = (new \ReflectionClass($parser))->getMethod('getParseContext'); diff --git a/tests/Unit/Resolvers/EntryResolverTest.php b/tests/Unit/Resolvers/EntryResolverTest.php new file mode 100644 index 0000000..e4def11 --- /dev/null +++ b/tests/Unit/Resolvers/EntryResolverTest.php @@ -0,0 +1,165 @@ +save(); + $entry = (new Entry)->collection($collection)->id('entry-123'); + + URL::shouldReceive('getCurrent')->andReturn('/test-url'); + $site = $this->mock(Site::class, function (MockInterface $mock): void { + $mock->shouldReceive('handle')->andReturn('default'); + }); + SiteFacade::shouldReceive('current')->andReturn($site); + SiteFacade::shouldReceive('multiEnabled')->andReturn(false); + EntryFacade::shouldReceive('findByUri')->with('/test-url', 'default')->andReturn($entry); + + /** @var StructuredDataService $service */ + $service = $this->mock(StructuredDataService::class); + $resolver = new EntryResolver($service); + + $result = $resolver->resolveCurrent(); + + $this->assertInstanceOf(Entry::class, $result); + } + + #[Test] + public function resolve_current_returns_page(): void + { + $page = $this->mock(Page::class); + + URL::shouldReceive('getCurrent')->andReturn('/test-url'); + $site = $this->mock(Site::class, function (MockInterface $mock): void { + $mock->shouldReceive('handle')->andReturn('default'); + }); + SiteFacade::shouldReceive('current')->andReturn($site); + SiteFacade::shouldReceive('multiEnabled')->andReturn(false); + EntryFacade::shouldReceive('findByUri')->with('/test-url', 'default')->andReturn($page); + + /** @var StructuredDataService $service */ + $service = $this->mock(StructuredDataService::class); + $resolver = new EntryResolver($service); + + $result = $resolver->resolveCurrent(); + + $this->assertInstanceOf(Page::class, $result); + } + + #[Test] + public function resolve_current_returns_null_when_not_entry_or_page(): void + { + URL::shouldReceive('getCurrent')->andReturn('/test-url'); + $site = $this->mock(Site::class, function (MockInterface $mock): void { + $mock->shouldReceive('handle')->andReturn('default'); + }); + SiteFacade::shouldReceive('current')->andReturn($site); + SiteFacade::shouldReceive('multiEnabled')->andReturn(false); + EntryFacade::shouldReceive('findByUri')->with('/test-url', 'default')->andReturn(null); + + /** @var StructuredDataService $service */ + $service = $this->mock(StructuredDataService::class); + $resolver = new EntryResolver($service); + + $result = $resolver->resolveCurrent(); + + $this->assertNull($result); + } + + #[Test] + public function supports_returns_true_for_entry_and_page(): void + { + /** @var StructuredDataService $service */ + $service = $this->mock(StructuredDataService::class); + $resolver = new EntryResolver($service); + + $this->assertTrue($resolver->supports(new Entry)); + $this->assertTrue($resolver->supports($this->mock(Page::class))); + $this->assertFalse($resolver->supports('not-an-entry')); + } + + #[Test] + public function handle_returns_null_for_unsupported_item(): void + { + /** @var StructuredDataService $service */ + $service = $this->mock(StructuredDataService::class); + $resolver = new EntryResolver($service); + + $this->assertNull($resolver->handle('not-an-entry')); + } + + #[Test] + public function handle_returns_null_when_collection_not_enabled(): void + { + Config::set('justbetter.structured-data.collections', ['other']); + + $collection = CollectionFacade::make('blog'); + $collection->save(); + $entry = (new Entry)->collection($collection)->id('entry-123'); + + /** @var StructuredDataService $service */ + $service = $this->mock(StructuredDataService::class); + $resolver = new EntryResolver($service); + + $this->assertNull($resolver->handle($entry)); + } + + #[Test] + public function handle_returns_scripts_for_enabled_entry(): void + { + Config::set('justbetter.structured-data.collections', ['blog']); + + $collection = CollectionFacade::make('blog'); + $collection->save(); + $entry = (new Entry)->collection($collection)->id('entry-123'); + + $service = $this->mock(StructuredDataService::class, function (MockInterface $mock) use ($entry): void { + $mock->shouldReceive('getJsonLdScripts')->once()->with($entry)->andReturn(['', '']); + }); + + /** @var StructuredDataService $service */ + $resolver = new EntryResolver($service); + + $this->assertSame("\n", $resolver->handle($entry)); + } + + #[Test] + public function handle_unwraps_page_to_entry(): void + { + Config::set('justbetter.structured-data.collections', ['blog']); + + $collection = CollectionFacade::make('blog'); + $collection->save(); + $entry = (new Entry)->collection($collection)->id('entry-123'); + $page = $this->mock(Page::class, function (MockInterface $mock) use ($entry): void { + $mock->shouldReceive('entry')->andReturn($entry); + }); + + $service = $this->mock(StructuredDataService::class, function (MockInterface $mock) use ($entry): void { + $mock->shouldReceive('getJsonLdScripts')->once()->with($entry)->andReturn(['']); + }); + + /** @var StructuredDataService $service */ + $resolver = new EntryResolver($service); + + $this->assertSame('', $resolver->handle($page)); + } +} diff --git a/tests/Unit/Resolvers/RunwayResolverTest.php b/tests/Unit/Resolvers/RunwayResolverTest.php new file mode 100644 index 0000000..0a3ff52 --- /dev/null +++ b/tests/Unit/Resolvers/RunwayResolverTest.php @@ -0,0 +1,160 @@ +mock(StructuredDataService::class); + $resolver = new RunwayResolver($service); + + $this->assertNull($resolver->resolveCurrent()); + } + + #[Test] + public function resolve_current_returns_null_when_uri_result_is_not_model(): void + { + RunwaySupport::fakeInstalled(true); + ModelRepository::$findByUriResult = 'not-a-model'; + + URL::shouldReceive('getCurrent')->andReturn('/product-url'); + + /** @var StructuredDataService $service */ + $service = $this->mock(StructuredDataService::class); + $resolver = new RunwayResolver($service); + + $this->assertNull($resolver->resolveCurrent()); + } + + #[Test] + public function resolve_current_returns_null_when_resource_not_enabled(): void + { + RunwaySupport::fakeInstalled(true); + Config::set('justbetter.structured-data.runway', []); + + $model = new Product; + + ModelRepository::$findByUriResult = $model; + URL::shouldReceive('getCurrent')->andReturn('/product-url'); + + /** @var StructuredDataService $service */ + $service = $this->mock(StructuredDataService::class); + $resolver = new RunwayResolver($service); + + $this->assertNull($resolver->resolveCurrent()); + } + + #[Test] + public function resolve_current_returns_model_when_enabled(): void + { + RunwaySupport::fakeInstalled(true); + Config::set('justbetter.structured-data.runway', ['product']); + + $resource = new Resource; + $resource->resourceHandle = 'product'; + + $model = new Product; + $model->runwayResource = $resource; + + ModelRepository::$findByUriResult = $model; + URL::shouldReceive('getCurrent')->andReturn('/product-url'); + + /** @var StructuredDataService $service */ + $service = $this->mock(StructuredDataService::class); + $resolver = new RunwayResolver($service); + + $this->assertSame($model, $resolver->resolveCurrent()); + } + + #[Test] + public function supports_returns_true_for_model(): void + { + /** @var StructuredDataService $service */ + $service = $this->mock(StructuredDataService::class); + $resolver = new RunwayResolver($service); + + $this->assertTrue($resolver->supports(new Product)); + $this->assertFalse($resolver->supports('not-a-model')); + } + + #[Test] + public function handle_returns_null_for_unsupported_item(): void + { + /** @var StructuredDataService $service */ + $service = $this->mock(StructuredDataService::class); + $resolver = new RunwayResolver($service); + + $this->assertNull($resolver->handle('not-a-model')); + } + + #[Test] + public function handle_returns_null_when_resource_not_enabled(): void + { + Config::set('justbetter.structured-data.runway', []); + + $model = new Product; + + /** @var StructuredDataService $service */ + $service = $this->mock(StructuredDataService::class); + $resolver = new RunwayResolver($service); + + $this->assertNull($resolver->handle($model, 'product')); + } + + #[Test] + public function handle_returns_null_when_scripts_empty(): void + { + Config::set('justbetter.structured-data.runway', ['product']); + + $model = new Product; + + $service = $this->mock(StructuredDataService::class, function (MockInterface $mock) use ($model): void { + $mock->shouldReceive('getJsonLdScripts') + ->once() + ->with($model, false, 'product') + ->andReturn([]); + }); + + /** @var StructuredDataService $service */ + $resolver = new RunwayResolver($service); + + $this->assertNull($resolver->handle($model, 'product')); + } + + #[Test] + public function handle_returns_imploded_scripts(): void + { + Config::set('justbetter.structured-data.runway', ['product']); + + $model = new Product; + + $service = $this->mock(StructuredDataService::class, function (MockInterface $mock) use ($model): void { + $mock->shouldReceive('getJsonLdScripts') + ->once() + ->with($model, false, 'product') + ->andReturn(['', '']); + }); + + /** @var StructuredDataService $service */ + $resolver = new RunwayResolver($service); + + $this->assertSame("\n", $resolver->handle($model, 'product')); + } +} diff --git a/tests/Unit/Resolvers/TaxonomyResolverTest.php b/tests/Unit/Resolvers/TaxonomyResolverTest.php new file mode 100644 index 0000000..323fe08 --- /dev/null +++ b/tests/Unit/Resolvers/TaxonomyResolverTest.php @@ -0,0 +1,129 @@ +save(); + $term = $this->mock(LocalizedTerm::class, function (MockInterface $mock) use ($taxonomy): void { + $mock->shouldReceive('taxonomy')->andReturn($taxonomy); + }); + + URL::shouldReceive('getCurrent')->andReturn('/test-url'); + $site = $this->mock(Site::class, function (MockInterface $mock): void { + $mock->shouldReceive('handle')->andReturn('default'); + }); + SiteFacade::shouldReceive('current')->andReturn($site); + SiteFacade::shouldReceive('multiEnabled')->andReturn(false); + Term::shouldReceive('findByUri')->with('/test-url', 'default')->andReturn($term); + + /** @var StructuredDataService $service */ + $service = $this->mock(StructuredDataService::class); + $resolver = new TaxonomyResolver($service); + + $result = $resolver->resolveCurrent(); + + $this->assertInstanceOf(LocalizedTerm::class, $result); + } + + #[Test] + public function resolve_current_returns_null_when_not_localized_term(): void + { + URL::shouldReceive('getCurrent')->andReturn('/test-url'); + $site = $this->mock(Site::class, function (MockInterface $mock): void { + $mock->shouldReceive('handle')->andReturn('default'); + }); + SiteFacade::shouldReceive('current')->andReturn($site); + SiteFacade::shouldReceive('multiEnabled')->andReturn(false); + Term::shouldReceive('findByUri')->with('/test-url', 'default')->andReturn(null); + + /** @var StructuredDataService $service */ + $service = $this->mock(StructuredDataService::class); + $resolver = new TaxonomyResolver($service); + + $result = $resolver->resolveCurrent(); + + $this->assertNull($result); + } + + #[Test] + public function supports_returns_true_for_localized_term(): void + { + /** @var StructuredDataService $service */ + $service = $this->mock(StructuredDataService::class); + $resolver = new TaxonomyResolver($service); + + $this->assertTrue($resolver->supports($this->mock(LocalizedTerm::class))); + $this->assertFalse($resolver->supports('not-a-term')); + } + + #[Test] + public function handle_returns_null_for_unsupported_item(): void + { + /** @var StructuredDataService $service */ + $service = $this->mock(StructuredDataService::class); + $resolver = new TaxonomyResolver($service); + + $this->assertNull($resolver->handle('not-a-term')); + } + + #[Test] + public function handle_returns_null_when_taxonomy_not_enabled(): void + { + Config::set('justbetter.structured-data.taxonomies', ['other']); + + /** @var Taxonomy $taxonomy */ + $taxonomy = TaxonomyFacade::make('categories'); + $taxonomy->save(); + $term = $this->mock(LocalizedTerm::class, function (MockInterface $mock) use ($taxonomy): void { + $mock->shouldReceive('taxonomy')->andReturn($taxonomy); + }); + + /** @var StructuredDataService $service */ + $service = $this->mock(StructuredDataService::class); + $resolver = new TaxonomyResolver($service); + + $this->assertNull($resolver->handle($term)); + } + + #[Test] + public function handle_returns_scripts_for_enabled_term(): void + { + Config::set('justbetter.structured-data.taxonomies', ['categories']); + + /** @var Taxonomy $taxonomy */ + $taxonomy = TaxonomyFacade::make('categories'); + $taxonomy->save(); + $term = $this->mock(LocalizedTerm::class, function (MockInterface $mock) use ($taxonomy): void { + $mock->shouldReceive('taxonomy')->andReturn($taxonomy); + }); + + $service = $this->mock(StructuredDataService::class, function (MockInterface $mock) use ($term): void { + $mock->shouldReceive('getJsonLdScripts')->once()->with($term)->andReturn(['']); + }); + + /** @var StructuredDataService $service */ + $resolver = new TaxonomyResolver($service); + + $this->assertSame('', $resolver->handle($term)); + } +} diff --git a/tests/Unit/Services/StructuredDataServiceRunwayTest.php b/tests/Unit/Services/StructuredDataServiceRunwayTest.php index 755ecda..29c2bdd 100644 --- a/tests/Unit/Services/StructuredDataServiceRunwayTest.php +++ b/tests/Unit/Services/StructuredDataServiceRunwayTest.php @@ -2,10 +2,10 @@ namespace Justbetter\StatamicStructuredData\Tests\Unit\Services; -use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Config; use Justbetter\StatamicStructuredData\Parser\StructuredDataParser; use Justbetter\StatamicStructuredData\Services\StructuredDataService; +use Justbetter\StatamicStructuredData\Tests\Stubs\Product; use Justbetter\StatamicStructuredData\Tests\TestCase; use Mockery\MockInterface; use PHPUnit\Framework\Attributes\Test; @@ -33,15 +33,10 @@ public function get_templates_returns_runway_templates_for_model(): void ]]); $template->save(); - $model = new class extends Model - { - protected $table = 'products'; - - protected $attributes = [ - 'name' => 'Test Bike', - 'sku' => 'BIKE-1', - ]; - }; + $model = new Product([ + 'name' => 'Test Bike', + 'sku' => 'BIKE-1', + ]); $parser = $this->mock(StructuredDataParser::class); /** @var StructuredDataParser $parser */ @@ -57,10 +52,7 @@ public function get_templates_returns_empty_when_runway_handle_not_enabled(): vo { Config::set('justbetter.structured-data.runway', []); - $model = new class extends Model - { - protected $table = 'products'; - }; + $model = new Product; $parser = $this->mock(StructuredDataParser::class); /** @var StructuredDataParser $parser */ @@ -92,14 +84,9 @@ public function get_json_ld_scripts_parses_schemas_for_runway_model(): void ->set('schema_data', $schema); $template->save(); - $model = new class extends Model - { - protected $table = 'products'; - - protected $attributes = [ - 'name' => 'Test Bike', - ]; - }; + $model = new Product([ + 'name' => 'Test Bike', + ]); $parser = $this->mock(StructuredDataParser::class, function (MockInterface $mock) use ($schema, $model): void { $mock->shouldReceive('parse') diff --git a/tests/Unit/Services/StructuredDataServiceTest.php b/tests/Unit/Services/StructuredDataServiceTest.php index 9cc8181..b46d249 100644 --- a/tests/Unit/Services/StructuredDataServiceTest.php +++ b/tests/Unit/Services/StructuredDataServiceTest.php @@ -9,6 +9,7 @@ use Justbetter\StatamicStructuredData\Tests\TestCase; use Mockery\MockInterface; use PHPUnit\Framework\Attributes\Test; +use Statamic\Contracts\Entries\Entry as EntryContract; use Statamic\Entries\Entry; use Statamic\Facades\Collection as CollectionFacade; use Statamic\Facades\Entry as EntryFacade; @@ -682,19 +683,15 @@ public function get_templates_handles_localized_term(): void } #[Test] - public function get_templates_returns_empty_array_for_invalid_item(): void + public function get_templates_returns_empty_array_for_unsupported_entry_implementation(): void { $parser = $this->mock(StructuredDataParser::class); /** @var StructuredDataParser $parser */ $service = new StructuredDataService($parser); - $reflection = new \ReflectionClass($service); - $method = $reflection->getMethod('getTemplates'); - $method->setAccessible(true); - $result = $method->invoke($service, 'invalid-item'); + $entry = $this->mock(EntryContract::class); - $this->assertIsArray($result); - $this->assertEmpty($result); + $this->assertSame([], $service->getTemplates($entry)); } #[Test] diff --git a/tests/Unit/Support/RunwaySupportTest.php b/tests/Unit/Support/RunwaySupportTest.php index 7ba4271..1b26798 100644 --- a/tests/Unit/Support/RunwaySupportTest.php +++ b/tests/Unit/Support/RunwaySupportTest.php @@ -2,9 +2,11 @@ namespace Justbetter\StatamicStructuredData\Tests\Unit\Support; -use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Config; use Justbetter\StatamicStructuredData\Support\RunwaySupport; +use Justbetter\StatamicStructuredData\Tests\Stubs\BrokenProduct; +use Justbetter\StatamicStructuredData\Tests\Stubs\Product; +use Justbetter\StatamicStructuredData\Tests\Stubs\RunwaySupportTestProduct; use Justbetter\StatamicStructuredData\Tests\TestCase; use PHPUnit\Framework\Attributes\Test; use StatamicRadPack\Runway\ModelRepository; @@ -35,10 +37,7 @@ public function resolve_resource_handle_uses_explicit_resource(): void { Config::set('justbetter.structured-data.runway', ['product']); - $model = new class extends Model - { - protected $table = 'products'; - }; + $model = new Product; $this->assertSame('product', RunwaySupport::resolveResourceHandle($model, 'product')); $this->assertNull(RunwaySupport::resolveResourceHandle($model, 'category')); @@ -103,16 +102,8 @@ public function resolve_resource_handle_uses_runway_resource_method(): void $resource = new Resource; $resource->resourceHandle = 'product'; - $model = new class extends Model - { - public Resource $resource; - - public function runwayResource(): Resource - { - return $this->resource; - } - }; - $model->resource = $resource; + $model = new Product; + $model->runwayResource = $resource; $this->assertSame('product', RunwaySupport::resolveResourceHandle($model)); } @@ -126,16 +117,8 @@ public function resolve_resource_handle_ignores_disabled_runway_resource(): void $resource = new Resource; $resource->resourceHandle = 'product'; - $model = new class extends Model - { - public Resource $resource; - - public function runwayResource(): Resource - { - return $this->resource; - } - }; - $model->resource = $resource; + $model = new Product; + $model->runwayResource = $resource; $this->assertNull(RunwaySupport::resolveResourceHandle($model)); } @@ -146,15 +129,7 @@ public function resolve_resource_handle_catches_runway_resource_exceptions(): vo RunwaySupport::fakeInstalled(true); Config::set('justbetter.structured-data.runway', ['product']); - $model = new class extends Model - { - public function runwayResource(): Resource - { - throw new \RuntimeException('not registered'); - } - }; - - $this->assertNull(RunwaySupport::resolveResourceHandle($model)); + $this->assertNull(RunwaySupport::resolveResourceHandle(new BrokenProduct)); } #[Test] @@ -176,10 +151,7 @@ public function find_by_uri_returns_model_when_repository_finds_one(): void { RunwaySupport::fakeInstalled(true); - $model = new class extends Model - { - protected $table = 'products'; - }; + $model = new Product; ModelRepository::$findByUriResult = $model; @@ -220,8 +192,3 @@ public function find_resource_returns_resource_when_enabled(): void $this->assertSame($resource, RunwaySupport::findResource('product')); } } - -class RunwaySupportTestProduct extends Model -{ - protected $table = 'products'; -} diff --git a/tests/Unit/Tags/StructuredDataTest.php b/tests/Unit/Tags/StructuredDataTest.php index abfc491..1cb71d6 100644 --- a/tests/Unit/Tags/StructuredDataTest.php +++ b/tests/Unit/Tags/StructuredDataTest.php @@ -2,9 +2,9 @@ namespace Justbetter\StatamicStructuredData\Tests\Unit\Tags; -use Illuminate\Database\Eloquent\Model; use Justbetter\StatamicStructuredData\Actions\InjectStructuredDataAction; use Justbetter\StatamicStructuredData\Tags\StructuredData; +use Justbetter\StatamicStructuredData\Tests\Stubs\Product; use Justbetter\StatamicStructuredData\Tests\TestCase; use Mockery\MockInterface; use PHPUnit\Framework\Attributes\Test; @@ -50,10 +50,7 @@ public function head_method_returns_null_when_action_returns_null(): void #[Test] public function for_method_passes_item_and_resource_to_action(): void { - $model = new class extends Model - { - protected $table = 'products'; - }; + $model = new Product; $action = $this->mock(InjectStructuredDataAction::class, function (MockInterface $mock) use ($model): void { $mock->shouldReceive('executeForItem') From a128e6f5b17229d0997d77f06b0088f4df69378d Mon Sep 17 00:00:00 2001 From: Kevin Meijer Date: Wed, 5 Aug 2026 16:44:30 +0200 Subject: [PATCH 6/7] Split available variables to its own services --- .../AvailableVariablesFieldtype.php | 497 +----- .../AvailableVariablesService.php | 72 + .../BlueprintVariableMapper.php | 256 +++ .../Providers/EntryVariableProvider.php | 43 + .../Providers/GlobalVariableProvider.php | 37 + .../Providers/RunwayVariableProvider.php | 94 ++ .../Providers/SiteVariableProvider.php | 16 + .../Providers/TermVariableProvider.php | 43 + .../AvailableVariables/SeoProVariables.php | 61 + .../AvailableVariables/VariableProvider.php | 11 + .../AvailableVariablesFieldtypeTest.php | 1459 ----------------- .../AvailableVariablesProvidersTest.php | 633 +++++++ .../BlueprintVariableMapperTest.php | 560 +++++++ 13 files changed, 1832 insertions(+), 1950 deletions(-) create mode 100644 src/Services/AvailableVariables/AvailableVariablesService.php create mode 100644 src/Services/AvailableVariables/BlueprintVariableMapper.php create mode 100644 src/Services/AvailableVariables/Providers/EntryVariableProvider.php create mode 100644 src/Services/AvailableVariables/Providers/GlobalVariableProvider.php create mode 100644 src/Services/AvailableVariables/Providers/RunwayVariableProvider.php create mode 100644 src/Services/AvailableVariables/Providers/SiteVariableProvider.php create mode 100644 src/Services/AvailableVariables/Providers/TermVariableProvider.php create mode 100644 src/Services/AvailableVariables/SeoProVariables.php create mode 100644 src/Services/AvailableVariables/VariableProvider.php create mode 100644 tests/Unit/Services/AvailableVariables/AvailableVariablesProvidersTest.php create mode 100644 tests/Unit/Services/AvailableVariables/BlueprintVariableMapperTest.php diff --git a/src/Fieldtypes/AvailableVariablesFieldtype.php b/src/Fieldtypes/AvailableVariablesFieldtype.php index f806a04..05f053c 100644 --- a/src/Fieldtypes/AvailableVariablesFieldtype.php +++ b/src/Fieldtypes/AvailableVariablesFieldtype.php @@ -2,20 +2,8 @@ namespace Justbetter\StatamicStructuredData\Fieldtypes; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Collection as SupportCollection; -use Justbetter\StatamicStructuredData\Support\RunwaySupport; -use Statamic\Entries\Collection; -use Statamic\Entries\Entry; -use Statamic\Facades\Collection as CollectionFacade; -use Statamic\Facades\Fieldset as FieldsetFacade; -use Statamic\Facades\GlobalSet; -use Statamic\Fields\Blueprint; +use Justbetter\StatamicStructuredData\Services\AvailableVariables\AvailableVariablesService; use Statamic\Fields\Fieldtype; -use Statamic\Fields\LabeledValue; -use Statamic\Globals\GlobalSet as StatamicGlobalSet; -use Statamic\SeoPro\Cascade; -use Statamic\Taxonomies\Taxonomy; class AvailableVariablesFieldtype extends Fieldtype { @@ -28,6 +16,10 @@ class AvailableVariablesFieldtype extends Fieldtype /** @var string */ protected static $handle = 'structured_data_available_variables'; + public function __construct( + protected AvailableVariablesService $availableVariables = new AvailableVariablesService, + ) {} + /** @return null */ public function defaultValue() { @@ -38,484 +30,7 @@ public function defaultValue() public function preload(): array { return [ - 'variables' => $this->getAvailableVariables(), + 'variables' => $this->availableVariables->all($this->field->parent()), ]; } - - /** @return array> */ - protected function getAvailableVariables(): array - { - return [ - 'site' => [ - ['name' => 'site:name', 'description' => 'Site Name'], - ['name' => 'site:url', 'description' => 'Site URL'], - ], - 'globals' => $this->getGlobalVariables(), - 'entry' => $this->getEntryFields(), - 'term' => $this->getTermFields(), - 'runway' => $this->getRunwayFields(), - ]; - } - - protected function fieldTypeIsEligible(string $fieldType): bool - { - $eligibleFieldTypes = [ - 'text', - 'assets', - 'bard', - 'toggle', - 'select', - 'integer', - 'slug', - 'date', - 'entries', - 'aardvark_seo_meta_title', - 'aardvark_seo_meta_description', - ]; - - return in_array($fieldType, $eligibleFieldTypes, true); - } - - /** @return array> */ - protected function getEntryFields(): array - { - /** @var ?Entry $dataTemplate */ - $dataTemplate = $this->field->parent(); - /** @var ?Collection $collection */ - $collection = $dataTemplate?->use_for_collection; /** @phpstan-ignore-line */ - if (! $collection instanceof Collection) { - return []; - } - - /** @var SupportCollection|array|null $entryBlueprints */ - $entryBlueprints = $collection->entryBlueprints(); - $blueprints = collect($entryBlueprints)->filter(); - - /** @var array> $fieldsArray */ - $fieldsArray = $blueprints->reduce(function (array $carry, $blueprint): array { - $items = $this->normalizeBlueprintItems($blueprint->fields()->items()); - - return array_merge($carry, $items); - }, []); - - $fields = collect($fieldsArray); - - $collectionFields = $fields->map(function (array $field) { - return $this->setFieldData($field); - }) - ->filter() - ->values() - ->all(); - - $baseFields = [['name' => 'absolute_url', 'description' => 'Full URL']]; - - return array_merge( - $baseFields, - $collectionFields, - $this->getSeoProVariablesForSection($collection) - ); - } - - /** @return array> */ - protected function getTermFields(): array - { - /** @var ?Entry $dataTemplate */ - $dataTemplate = $this->field->parent(); - /** @var ?Taxonomy $taxonomy */ - $taxonomy = $dataTemplate?->use_for_taxonomy; /** @phpstan-ignore-line */ - if (! $taxonomy instanceof Taxonomy) { - return []; - } - - /** @var SupportCollection|array|null $termBlueprints */ - $termBlueprints = $taxonomy->termBlueprints(); - $blueprints = collect($termBlueprints)->filter(); - - /** @var array> $fieldsArray */ - $fieldsArray = $blueprints->reduce(function (array $carry, $blueprint): array { - $items = $this->normalizeBlueprintItems($blueprint->fields()->items()); - - return array_merge($carry, $items); - }, []); - - $fields = collect($fieldsArray); - - $collectionFields = $fields->map(function (array $field) { - return $this->setFieldData($field); - }) - ->filter() - ->values() - ->all(); - - $baseFields = [['name' => 'absolute_url', 'description' => 'Full URL']]; - - return array_merge( - $baseFields, - $collectionFields, - $this->getSeoProVariablesForSection($taxonomy) - ); - } - - /** @return array> */ - protected function getRunwayFields(): array - { - if (! RunwaySupport::isInstalled()) { - return []; - } - - $resourceHandle = $this->resolveRunwayResourceHandle($this->field->parent()); - - if (! $resourceHandle) { - return []; - } - - $resource = RunwaySupport::findResource($resourceHandle); - - if (! $resource) { - return []; - } - - $blueprint = $resource->blueprint(); - $items = $this->normalizeBlueprintItems($blueprint->fields()->items()); - - $blueprintFields = collect($items) - ->map(fn (array $field) => $this->setFieldData($field)) - ->filter() - ->values() - ->all(); - - $blueprintHandles = collect($blueprintFields)->pluck('name')->filter()->all(); - - $modelAttributes = $this->getRunwayModelAttributeVariables($resource->model(), $blueprintHandles); - - $baseFields = [['name' => 'absolute_url', 'description' => 'Full URL']]; - - return array_merge($baseFields, $blueprintFields, $modelAttributes); - } - - protected function resolveRunwayResourceHandle(mixed $dataTemplate): ?string - { - if (! $dataTemplate instanceof Entry) { - return null; - } - - $runwayHandle = $dataTemplate->use_for_runway ?? null; - if ($runwayHandle instanceof LabeledValue) { - $runwayHandle = $runwayHandle->value(); - } - - return is_string($runwayHandle) && $runwayHandle !== '' ? $runwayHandle : null; - } - - /** - * @param array $existingHandles - * @return array> - */ - protected function getRunwayModelAttributeVariables(Model $model, array $existingHandles): array - { - $existing = collect($existingHandles) - ->filter(fn ($handle): bool => is_string($handle)) - ->values() - ->all(); - - $attributeNames = collect() - ->merge($model->getFillable()) - ->merge($model->getAppends()) - ->merge(array_keys($model->getAttributes())) - ->filter(fn ($name): bool => is_string($name) && $name !== '') - ->unique() - ->reject(fn (string $name): bool => in_array($name, $existing, true) || $name === 'parent') - ->values(); - - return $attributeNames - ->map(fn (string $name): array => [ - 'name' => $name, - 'description' => $name, - 'children' => [], - ]) - ->all(); - } - - protected function seoProIsInstalled(): bool - { - return class_exists(Cascade::class); - } - - protected function seoIsEnabledForSection(Collection|Taxonomy $section): bool - { - if (! $this->seoProIsInstalled()) { - return false; - } - - return $section->cascade('seo') !== false; - } - - /** - * @return array> - */ - protected function getSeoProVariablesForSection(Collection|Taxonomy $section): array - { - if (! $this->seoIsEnabledForSection($section)) { - return []; - } - - return $this->getSeoProVariables(); - } - - /** - * @return array> - */ - protected function getSeoProVariables(): array - { - $variables = [ - 'title' => 'Meta Title', - 'compiled_title' => 'Compiled Title', - 'description' => 'Meta Description', - 'canonical_url' => 'Canonical URL', - 'og_title' => 'Open Graph Title', - 'og_type' => 'Open Graph Type', - 'image' => 'Open Graph Image', - ]; - - return collect($variables) - ->map(fn (string $description, string $handle): array => [ - 'name' => 'seo:'.$handle, - 'description' => $description, - 'children' => [], - ]) - ->values() - ->all(); - } - - /** @return array */ - protected function getGlobalVariables(): array - { - /** @var SupportCollection> $variables */ - $variables = collect(); - - GlobalSet::all()->each(function ($globalSet) use (&$variables): void { - /** @var StatamicGlobalSet $globalSet */ - $fields = $globalSet->blueprint()?->fields(); - $globalVariables = []; - - if ($fields) { - $globalVariables = $fields->items()->map(function (array $field) use ($globalSet): ?array { - $name = ($globalSet->handle().':'.($field['handle'] ?? '')); - $description = ($field['field']['display'] ?? ($field['handle'] ?? '')); - - return $this->setFieldData($field, $name, $description); - })->values()->all(); - } - - $variables = $variables->merge($globalVariables); - }); - - return $variables->filter()->values()->all(); - } - - /** @param array $field - * @return array> - */ - protected function getCollectionVariables(string $collectionHandle, array $field): array - { - if (! $collectionHandle || $collectionHandle === 'structured_data_templates') { - return []; - } - - $collection = CollectionFacade::find($collectionHandle); - - if (! $collection) { - return []; - } - - $blueprint = $collection->entryBlueprints()->first(); - - if (! $blueprint) { - return []; - } - - return $blueprint->fields()->items()->map(function (array $entryField) use ($field) { - $entryFieldConfig = is_array($entryField['field'] ?? null) ? $entryField['field'] : []; - $parentFieldConfig = is_array($field['field'] ?? null) ? $field['field'] : []; - $fieldHandle = is_string($field['handle'] ?? null) ? $field['handle'] : ''; - $entryFieldHandle = is_string($entryField['handle'] ?? null) ? $entryField['handle'] : ''; - $name = $fieldHandle.':'.$entryFieldHandle; - $parentDisplay = is_string($parentFieldConfig['display'] ?? null) ? $parentFieldConfig['display'] : ''; - $entryDisplay = is_string($entryFieldConfig['display'] ?? null) ? $entryFieldConfig['display'] : ''; - $description = $parentDisplay.': '.($entryDisplay ?: $entryFieldHandle); - - return $this->setFieldData($entryField, $name, $description, false); - })->filter()->values()->all(); - } - - /** @param array $field - * @return array{name: string, description: string, children: array>}|null - */ - protected function setFieldData(array $field, ?string $name = null, ?string $description = null, bool $recursive = true): ?array - { - $fieldHandle = $field['handle'] ?? null; - /** @var array $fieldConfig */ - $fieldConfig = is_array($field['field'] ?? null) ? $field['field'] : []; - $fieldType = $fieldConfig['type'] ?? null; - - if (! is_string($fieldHandle) || $fieldHandle === 'parent' || ! is_string($fieldType)) { - return null; - } - - $isGroupField = $fieldType === 'group'; - if (! $isGroupField && ! $this->fieldTypeIsEligible($fieldType)) { - return null; - } - - /** @var array> $children */ - $children = []; - - if ($fieldType === 'entries' && $recursive) { - $collections = is_array($fieldConfig['collections'] ?? null) ? $fieldConfig['collections'] : null; - - if (! isset($collections[0]) || ! is_string($collections[0])) { - return null; - } - - $children = $this->getCollectionVariables($collections[0], $field); - } - - if ($isGroupField && $recursive) { - $children = $this->getGroupVariables($field, $fieldConfig); - } - - $descriptionValue = is_string($description) ? $description : null; - $display = is_string($fieldConfig['display'] ?? null) ? $fieldConfig['display'] : null; - - if ($isGroupField && empty($children)) { - return null; - } - - return [ - 'name' => $name ?? $fieldHandle, - 'description' => $descriptionValue ?? $display ?? $fieldHandle, - 'children' => $children, - ]; - } - - /** - * @param array $field - * @param array $fieldConfig - * @return array> - */ - protected function getGroupVariables(array $field, array $fieldConfig): array - { - $groupFields = $this->normalizeBlueprintItems($fieldConfig['fields'] ?? []); - $groupHandle = is_string($field['handle'] ?? null) ? $field['handle'] : ''; - $groupDisplay = is_string($fieldConfig['display'] ?? null) ? $fieldConfig['display'] : $groupHandle; - - return collect($groupFields) - ->map(function (array $groupField) use ($groupHandle, $groupDisplay): ?array { - /** @var string $childHandle */ - $childHandle = $groupField['handle']; - - $childConfig = is_array($groupField['field'] ?? null) ? $groupField['field'] : []; - $childDisplay = is_string($childConfig['display'] ?? null) ? $childConfig['display'] : $childHandle; - - return $this->setFieldData( - $groupField, - $groupHandle.':'.$childHandle, - $groupDisplay.': '.$childDisplay, - false - ); - }) - ->filter() - ->values() - ->all(); - } - - /** - * Normalizes blueprint items to ensure they are an array. - * - * @param mixed $items - * @return array> - */ - protected function normalizeBlueprintItems($items): array - { - if ($items instanceof SupportCollection) { - $items = $items->all(); - } - - if (! is_array($items)) { - return []; - } - - $normalizedItems = []; - - foreach ($items as $item) { - if ($item instanceof SupportCollection) { - $item = $item->all(); - } - - if (! is_array($item)) { - continue; - } - - $import = $item['import'] ?? null; - if (is_string($import) && $import !== '') { - $normalizedItems = array_merge($normalizedItems, $this->getImportedFieldsetItems($item)); - - continue; - } - - if (isset($item['handle']) && is_array($item['field'] ?? null)) { - /** @var array $item */ - $normalizedItems[] = $item; - } - - $nestedItems = $item['fields'] ?? null; - if (is_array($nestedItems) || $nestedItems instanceof SupportCollection) { - $normalizedItems = array_merge( - $normalizedItems, - $this->normalizeBlueprintItems($nestedItems) - ); - } - } - - return $normalizedItems; - } - - /** - * @param array $importConfig - * @return array> - */ - protected function getImportedFieldsetItems(array $importConfig): array - { - $fieldsetHandle = $importConfig['import'] ?? null; - if (! is_string($fieldsetHandle) || $fieldsetHandle === '') { - return []; - } - - $prefix = $importConfig['prefix'] ?? null; - if (! is_string($prefix)) { - $prefix = ''; - } - - $fieldset = FieldsetFacade::find($fieldsetHandle); - - if (! $fieldset) { - return []; - } - - $items = $this->normalizeBlueprintItems($fieldset->fields()->items()); - - if ($prefix === '') { - return $items; - } - - return array_map(function (array $item) use ($prefix): array { - $handle = $item['handle'] ?? null; - - if (is_string($handle) && $handle !== '') { - $item['handle'] = $prefix.$handle; - } - - return $item; - }, $items); - } } diff --git a/src/Services/AvailableVariables/AvailableVariablesService.php b/src/Services/AvailableVariables/AvailableVariablesService.php new file mode 100644 index 0000000..cf1cad4 --- /dev/null +++ b/src/Services/AvailableVariables/AvailableVariablesService.php @@ -0,0 +1,72 @@ +site = new SiteVariableProvider; + $this->globals = new GlobalVariableProvider($this->mapper); + $this->entry = new EntryVariableProvider($this->mapper, $this->seoProVariables); + $this->term = new TermVariableProvider($this->mapper, $this->seoProVariables); + $this->runway = new RunwayVariableProvider($this->mapper); + } + + /** + * @return array> + */ + public function all(mixed $parent = null): array + { + return [ + 'site' => $this->site->variables($parent), + 'globals' => $this->globals->variables($parent), + 'entry' => $this->entry->variables($parent), + 'term' => $this->term->variables($parent), + 'runway' => $this->runway->variables($parent), + ]; + } + + public function seoPro(): SeoProVariables + { + return $this->seoProVariables; + } + + public function entry(): EntryVariableProvider + { + return $this->entry; + } + + public function term(): TermVariableProvider + { + return $this->term; + } + + public function globals(): GlobalVariableProvider + { + return $this->globals; + } + + public function runway(): RunwayVariableProvider + { + return $this->runway; + } +} diff --git a/src/Services/AvailableVariables/BlueprintVariableMapper.php b/src/Services/AvailableVariables/BlueprintVariableMapper.php new file mode 100644 index 0000000..6ea83ce --- /dev/null +++ b/src/Services/AvailableVariables/BlueprintVariableMapper.php @@ -0,0 +1,256 @@ +> + */ + public function normalizeBlueprintItems($items): array + { + if ($items instanceof SupportCollection) { + $items = $items->all(); + } + + if (! is_array($items)) { + return []; + } + + $normalizedItems = []; + + foreach ($items as $item) { + if ($item instanceof SupportCollection) { + $item = $item->all(); + } + + if (! is_array($item)) { + continue; + } + + $import = $item['import'] ?? null; + if (is_string($import) && $import !== '') { + $normalizedItems = array_merge($normalizedItems, $this->getImportedFieldsetItems($item)); + + continue; + } + + if (isset($item['handle']) && is_array($item['field'] ?? null)) { + /** @var array $item */ + $normalizedItems[] = $item; + } + + $nestedItems = $item['fields'] ?? null; + if (is_array($nestedItems) || $nestedItems instanceof SupportCollection) { + $normalizedItems = array_merge( + $normalizedItems, + $this->normalizeBlueprintItems($nestedItems) + ); + } + } + + return $normalizedItems; + } + + /** + * @param array $importConfig + * @return array> + */ + public function getImportedFieldsetItems(array $importConfig): array + { + $fieldsetHandle = $importConfig['import'] ?? null; + if (! is_string($fieldsetHandle) || $fieldsetHandle === '') { + return []; + } + + $prefix = $importConfig['prefix'] ?? null; + if (! is_string($prefix)) { + $prefix = ''; + } + + $fieldset = FieldsetFacade::find($fieldsetHandle); + + if (! $fieldset) { + return []; + } + + $items = $this->normalizeBlueprintItems($fieldset->fields()->items()); + + if ($prefix === '') { + return $items; + } + + return array_map(function (array $item) use ($prefix): array { + $handle = $item['handle'] ?? null; + + if (is_string($handle) && $handle !== '') { + $item['handle'] = $prefix.$handle; + } + + return $item; + }, $items); + } + + /** + * @param array $field + * @return array{name: string, description: string, children: array>}|null + */ + public function setFieldData(array $field, ?string $name = null, ?string $description = null, bool $recursive = true): ?array + { + $fieldHandle = $field['handle'] ?? null; + /** @var array $fieldConfig */ + $fieldConfig = is_array($field['field'] ?? null) ? $field['field'] : []; + $fieldType = $fieldConfig['type'] ?? null; + + if (! is_string($fieldHandle) || $fieldHandle === 'parent' || ! is_string($fieldType)) { + return null; + } + + $isGroupField = $fieldType === 'group'; + if (! $isGroupField && ! $this->fieldTypeIsEligible($fieldType)) { + return null; + } + + /** @var array> $children */ + $children = []; + + if ($fieldType === 'entries' && $recursive) { + $collections = is_array($fieldConfig['collections'] ?? null) ? $fieldConfig['collections'] : null; + + if (! isset($collections[0]) || ! is_string($collections[0])) { + return null; + } + + $children = $this->getCollectionVariables($collections[0], $field); + } + + if ($isGroupField && $recursive) { + $children = $this->getGroupVariables($field, $fieldConfig); + } + + $descriptionValue = is_string($description) ? $description : null; + $display = is_string($fieldConfig['display'] ?? null) ? $fieldConfig['display'] : null; + + if ($isGroupField && empty($children)) { + return null; + } + + return [ + 'name' => $name ?? $fieldHandle, + 'description' => $descriptionValue ?? $display ?? $fieldHandle, + 'children' => $children, + ]; + } + + /** + * @param array $field + * @return array> + */ + public function getCollectionVariables(string $collectionHandle, array $field): array + { + if (! $collectionHandle || $collectionHandle === 'structured_data_templates') { + return []; + } + + $collection = CollectionFacade::find($collectionHandle); + + if (! $collection) { + return []; + } + + $blueprint = $collection->entryBlueprints()->first(); + + if (! $blueprint) { + return []; + } + + return $blueprint->fields()->items()->map(function (array $entryField) use ($field) { + $entryFieldConfig = is_array($entryField['field'] ?? null) ? $entryField['field'] : []; + $parentFieldConfig = is_array($field['field'] ?? null) ? $field['field'] : []; + $fieldHandle = is_string($field['handle'] ?? null) ? $field['handle'] : ''; + $entryFieldHandle = is_string($entryField['handle'] ?? null) ? $entryField['handle'] : ''; + $name = $fieldHandle.':'.$entryFieldHandle; + $parentDisplay = is_string($parentFieldConfig['display'] ?? null) ? $parentFieldConfig['display'] : ''; + $entryDisplay = is_string($entryFieldConfig['display'] ?? null) ? $entryFieldConfig['display'] : ''; + $description = $parentDisplay.': '.($entryDisplay ?: $entryFieldHandle); + + return $this->setFieldData($entryField, $name, $description, false); + })->filter()->values()->all(); + } + + /** + * @param array $field + * @param array $fieldConfig + * @return array> + */ + public function getGroupVariables(array $field, array $fieldConfig): array + { + $groupFields = $this->normalizeBlueprintItems($fieldConfig['fields'] ?? []); + $groupHandle = is_string($field['handle'] ?? null) ? $field['handle'] : ''; + $groupDisplay = is_string($fieldConfig['display'] ?? null) ? $fieldConfig['display'] : $groupHandle; + + return collect($groupFields) + ->map(function (array $groupField) use ($groupHandle, $groupDisplay): ?array { + /** @var string $childHandle */ + $childHandle = $groupField['handle']; + + $childConfig = is_array($groupField['field'] ?? null) ? $groupField['field'] : []; + $childDisplay = is_string($childConfig['display'] ?? null) ? $childConfig['display'] : $childHandle; + + return $this->setFieldData( + $groupField, + $groupHandle.':'.$childHandle, + $groupDisplay.': '.$childDisplay, + false + ); + }) + ->filter() + ->values() + ->all(); + } + + /** + * @param iterable $blueprints + * @return array> + */ + public function mapBlueprintsToVariables(iterable $blueprints): array + { + /** @var array> $fieldsArray */ + $fieldsArray = collect($blueprints)->filter()->reduce(function (array $carry, $blueprint): array { + $items = $this->normalizeBlueprintItems($blueprint->fields()->items()); + + return array_merge($carry, $items); + }, []); + + return collect($fieldsArray) + ->map(fn (array $field) => $this->setFieldData($field)) + ->filter() + ->values() + ->all(); + } +} diff --git a/src/Services/AvailableVariables/Providers/EntryVariableProvider.php b/src/Services/AvailableVariables/Providers/EntryVariableProvider.php new file mode 100644 index 0000000..773580f --- /dev/null +++ b/src/Services/AvailableVariables/Providers/EntryVariableProvider.php @@ -0,0 +1,43 @@ +use_for_collection; /** @phpstan-ignore-line */ + if (! $collection instanceof Collection) { + return []; + } + + /** @var SupportCollection|array|null $entryBlueprints */ + $entryBlueprints = $collection->entryBlueprints(); + + $baseFields = [['name' => 'absolute_url', 'description' => 'Full URL']]; + + return array_merge( + $baseFields, + $this->mapper->mapBlueprintsToVariables(collect($entryBlueprints)->filter()), + $this->seoProVariables->forSection($collection) + ); + } +} diff --git a/src/Services/AvailableVariables/Providers/GlobalVariableProvider.php b/src/Services/AvailableVariables/Providers/GlobalVariableProvider.php new file mode 100644 index 0000000..5a48fe7 --- /dev/null +++ b/src/Services/AvailableVariables/Providers/GlobalVariableProvider.php @@ -0,0 +1,37 @@ +each(function ($globalSet) use (&$variables): void { + /** @var StatamicGlobalSet $globalSet */ + $fields = $globalSet->blueprint()?->fields(); + $globalVariables = []; + + if ($fields) { + $globalVariables = $fields->items()->map(function (array $field) use ($globalSet): ?array { + $name = ($globalSet->handle().':'.($field['handle'] ?? '')); + $description = ($field['field']['display'] ?? ($field['handle'] ?? '')); + + return $this->mapper->setFieldData($field, $name, $description); + })->values()->all(); + } + + $variables = $variables->merge($globalVariables); + }); + + return $variables->filter()->values()->all(); + } +} diff --git a/src/Services/AvailableVariables/Providers/RunwayVariableProvider.php b/src/Services/AvailableVariables/Providers/RunwayVariableProvider.php new file mode 100644 index 0000000..60bc683 --- /dev/null +++ b/src/Services/AvailableVariables/Providers/RunwayVariableProvider.php @@ -0,0 +1,94 @@ +resolveResourceHandle($parent); + + if (! $resourceHandle) { + return []; + } + + $resource = RunwaySupport::findResource($resourceHandle); + + if (! $resource) { + return []; + } + + $blueprint = $resource->blueprint(); + $items = $this->mapper->normalizeBlueprintItems($blueprint->fields()->items()); + + $blueprintFields = collect($items) + ->map(fn (array $field) => $this->mapper->setFieldData($field)) + ->filter() + ->values() + ->all(); + + $blueprintHandles = collect($blueprintFields)->pluck('name')->filter()->all(); + + $modelAttributes = $this->modelAttributeVariables($resource->model(), $blueprintHandles); + + $baseFields = [['name' => 'absolute_url', 'description' => 'Full URL']]; + + return array_merge($baseFields, $blueprintFields, $modelAttributes); + } + + public function resolveResourceHandle(mixed $dataTemplate): ?string + { + if (! $dataTemplate instanceof Entry) { + return null; + } + + $runwayHandle = $dataTemplate->use_for_runway ?? null; + if ($runwayHandle instanceof LabeledValue) { + $runwayHandle = $runwayHandle->value(); + } + + return is_string($runwayHandle) && $runwayHandle !== '' ? $runwayHandle : null; + } + + /** + * @param array $existingHandles + * @return array> + */ + public function modelAttributeVariables(Model $model, array $existingHandles): array + { + $existing = collect($existingHandles) + ->filter(fn ($handle): bool => is_string($handle)) + ->values() + ->all(); + + $attributeNames = collect() + ->merge($model->getFillable()) + ->merge($model->getAppends()) + ->merge(array_keys($model->getAttributes())) + ->filter(fn ($name): bool => is_string($name) && $name !== '') + ->unique() + ->reject(fn (string $name): bool => in_array($name, $existing, true) || $name === 'parent') + ->values(); + + return $attributeNames + ->map(fn (string $name): array => [ + 'name' => $name, + 'description' => $name, + 'children' => [], + ]) + ->all(); + } +} diff --git a/src/Services/AvailableVariables/Providers/SiteVariableProvider.php b/src/Services/AvailableVariables/Providers/SiteVariableProvider.php new file mode 100644 index 0000000..66b237c --- /dev/null +++ b/src/Services/AvailableVariables/Providers/SiteVariableProvider.php @@ -0,0 +1,16 @@ + 'site:name', 'description' => 'Site Name'], + ['name' => 'site:url', 'description' => 'Site URL'], + ]; + } +} diff --git a/src/Services/AvailableVariables/Providers/TermVariableProvider.php b/src/Services/AvailableVariables/Providers/TermVariableProvider.php new file mode 100644 index 0000000..085df9e --- /dev/null +++ b/src/Services/AvailableVariables/Providers/TermVariableProvider.php @@ -0,0 +1,43 @@ +use_for_taxonomy; /** @phpstan-ignore-line */ + if (! $taxonomy instanceof Taxonomy) { + return []; + } + + /** @var SupportCollection|array|null $termBlueprints */ + $termBlueprints = $taxonomy->termBlueprints(); + + $baseFields = [['name' => 'absolute_url', 'description' => 'Full URL']]; + + return array_merge( + $baseFields, + $this->mapper->mapBlueprintsToVariables(collect($termBlueprints)->filter()), + $this->seoProVariables->forSection($taxonomy) + ); + } +} diff --git a/src/Services/AvailableVariables/SeoProVariables.php b/src/Services/AvailableVariables/SeoProVariables.php new file mode 100644 index 0000000..a9ef1a2 --- /dev/null +++ b/src/Services/AvailableVariables/SeoProVariables.php @@ -0,0 +1,61 @@ +isInstalled()) { + return false; + } + + return $section->cascade('seo') !== false; + } + + /** + * @return array> + */ + public function forSection(Collection|Taxonomy $section): array + { + if (! $this->isEnabledForSection($section)) { + return []; + } + + return $this->all(); + } + + /** + * @return array> + */ + public function all(): array + { + $variables = [ + 'title' => 'Meta Title', + 'compiled_title' => 'Compiled Title', + 'description' => 'Meta Description', + 'canonical_url' => 'Canonical URL', + 'og_title' => 'Open Graph Title', + 'og_type' => 'Open Graph Type', + 'image' => 'Open Graph Image', + ]; + + return collect($variables) + ->map(fn (string $description, string $handle): array => [ + 'name' => 'seo:'.$handle, + 'description' => $description, + 'children' => [], + ]) + ->values() + ->all(); + } +} diff --git a/src/Services/AvailableVariables/VariableProvider.php b/src/Services/AvailableVariables/VariableProvider.php new file mode 100644 index 0000000..282c686 --- /dev/null +++ b/src/Services/AvailableVariables/VariableProvider.php @@ -0,0 +1,11 @@ + + */ + public function variables(mixed $parent = null): array; +} diff --git a/tests/Unit/Fieldtypes/AvailableVariablesFieldtypeTest.php b/tests/Unit/Fieldtypes/AvailableVariablesFieldtypeTest.php index d488cb9..163564c 100644 --- a/tests/Unit/Fieldtypes/AvailableVariablesFieldtypeTest.php +++ b/tests/Unit/Fieldtypes/AvailableVariablesFieldtypeTest.php @@ -2,31 +2,11 @@ namespace Justbetter\StatamicStructuredData\Tests\Unit\Fieldtypes; -use Illuminate\Support\Facades\Config; use Justbetter\StatamicStructuredData\Fieldtypes\AvailableVariablesFieldtype; -use Justbetter\StatamicStructuredData\Support\RunwaySupport; -use Justbetter\StatamicStructuredData\Tests\Stubs\Product; use Justbetter\StatamicStructuredData\Tests\TestCase; -use Mockery; use Mockery\MockInterface; use PHPUnit\Framework\Attributes\Test; -use Statamic\Entries\Collection; -use Statamic\Entries\Entry; -use Statamic\Facades\Blueprint as BlueprintFacade; -use Statamic\Facades\Collection as CollectionFacade; -use Statamic\Facades\Fieldset as FieldsetFacade; -use Statamic\Facades\GlobalSet; -use Statamic\Facades\GlobalSet as GlobalSetFacade; -use Statamic\Facades\Taxonomy as TaxonomyFacade; -use Statamic\Fields\Blueprint; use Statamic\Fields\Field; -use Statamic\Fields\Fields; -use Statamic\Fields\Fieldset; -use Statamic\Fields\LabeledValue; -use Statamic\Globals\GlobalCollection; -use Statamic\Taxonomies\Taxonomy; -use StatamicRadPack\Runway\Resource; -use StatamicRadPack\Runway\Runway; class AvailableVariablesFieldtypeTest extends TestCase { @@ -64,1445 +44,6 @@ public function preload_returns_variables(): void $this->assertArrayHasKey('term', $resultVariables); } - #[Test] - public function get_entry_fields_returns_empty_when_no_collection(): void - { - $fieldtype = new AvailableVariablesFieldtype; - - /** @var Field $fieldMock */ - $fieldMock = $this->mock(Field::class, function (MockInterface $mock): void { - $mock->shouldReceive('parent')->andReturn(null); - }); - - $fieldtype->setField($fieldMock); - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('getEntryFields'); - $method->setAccessible(true); - - $result = $method->invoke($fieldtype); - - $this->assertIsArray($result); - $this->assertEmpty($result); - } - - #[Test] - public function get_entry_fields_handles_no_blueprints_gracefully(): void - { - $fieldtype = new AvailableVariablesFieldtype; - - $collection = CollectionFacade::make('test'); - $collection->save(); - - $templatesCollection = CollectionFacade::make('structured_data_templates'); - $templatesCollection->save(); - $entry = (new Entry) - ->collection($templatesCollection) - ->id('template-123'); - - $collectionMock = Mockery::mock($collection)->makePartial(); - $collectionMock->shouldReceive('entryBlueprints')->andReturn(collect([])); - - $entry->use_for_collection = $collectionMock; - $entryMock = $entry; - - /** @var Field $fieldMock */ - $fieldMock = $this->mock(Field::class, function (MockInterface $mock) use ($entryMock): void { - $mock->shouldReceive('parent')->andReturn($entryMock); - }); - - $fieldtype->setField($fieldMock); - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('getEntryFields'); - $method->setAccessible(true); - - $result = $method->invoke($fieldtype); - - $this->assertIsArray($result); - } - - #[Test] - public function get_term_fields_returns_empty_when_no_taxonomy(): void - { - $fieldtype = new AvailableVariablesFieldtype; - - /** @var Field $fieldMock */ - $fieldMock = $this->mock(Field::class, function (MockInterface $mock): void { - $mock->shouldReceive('parent')->andReturn(null); - }); - - $fieldtype->setField($fieldMock); - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('getTermFields'); - $method->setAccessible(true); - - $result = $method->invoke($fieldtype); - - $this->assertIsArray($result); - $this->assertEmpty($result); - } - - #[Test] - public function get_term_fields_handles_no_blueprints_gracefully(): void - { - $fieldtype = new AvailableVariablesFieldtype; - - /** @var Taxonomy $taxonomy */ - $taxonomy = TaxonomyFacade::make('test'); - $taxonomy->save(); - - $templatesCollection = CollectionFacade::make('structured_data_templates'); - $templatesCollection->save(); - $entry = (new Entry) - ->collection($templatesCollection) - ->id('template-123'); - - $taxonomyMock = Mockery::mock($taxonomy)->makePartial(); - $taxonomyMock->shouldReceive('termBlueprints')->andReturn(collect([])); - - $entry->use_for_taxonomy = $taxonomyMock; - $entryMock = $entry; - - /** @var Field $fieldMock */ - $fieldMock = $this->mock(Field::class, function (MockInterface $mock) use ($entryMock): void { - $mock->shouldReceive('parent')->andReturn($entryMock); - }); - - $fieldtype->setField($fieldMock); - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('getTermFields'); - $method->setAccessible(true); - - $result = $method->invoke($fieldtype); - - $this->assertIsArray($result); - } - - #[Test] - public function field_type_is_eligible_returns_true_for_eligible_types(): void - { - $fieldtype = new AvailableVariablesFieldtype; - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('fieldTypeIsEligible'); - $method->setAccessible(true); - - $this->assertTrue($method->invoke($fieldtype, 'text')); - $this->assertTrue($method->invoke($fieldtype, 'assets')); - $this->assertTrue($method->invoke($fieldtype, 'bard')); - $this->assertTrue($method->invoke($fieldtype, 'toggle')); - $this->assertTrue($method->invoke($fieldtype, 'integer')); - $this->assertTrue($method->invoke($fieldtype, 'slug')); - $this->assertTrue($method->invoke($fieldtype, 'date')); - $this->assertTrue($method->invoke($fieldtype, 'entries')); - $this->assertTrue($method->invoke($fieldtype, 'aardvark_seo_meta_title')); - $this->assertTrue($method->invoke($fieldtype, 'aardvark_seo_meta_description')); - } - - #[Test] - public function field_type_is_eligible_returns_false_for_ineligible_types(): void - { - $fieldtype = new AvailableVariablesFieldtype; - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('fieldTypeIsEligible'); - $method->setAccessible(true); - - $this->assertFalse($method->invoke($fieldtype, 'unknown_type')); - $this->assertFalse($method->invoke($fieldtype, 'grid')); - $this->assertFalse($method->invoke($fieldtype, 'replicator')); - } - - #[Test] - public function get_global_variables_returns_variables(): void - { - $fieldtype = new AvailableVariablesFieldtype; - - $blueprint = BlueprintFacade::make('test'); - $blueprint->setContents([ - 'fields' => [ - [ - 'handle' => 'test_field', - 'field' => [ - 'type' => 'text', - 'display' => 'Test Field', - ], - ], - ], - ]); - $blueprint->save(); - - $globalSet = GlobalSetFacade::make('test'); - $globalSet->save(); - $globalSet->blueprint('test'); - - $fieldsMock = $this->mock(Fields::class, function (MockInterface $mock): void { - $mock->shouldReceive('items')->andReturn(collect([ - [ - 'handle' => 'test_field', - 'field' => [ - 'type' => 'text', - 'display' => 'Test Field', - ], - ], - ])); - }); - - $blueprintMock = $this->mock(Blueprint::class, function (MockInterface $mock) use ($fieldsMock): void { - $mock->shouldReceive('fields')->andReturn($fieldsMock); - }); - - $globalSetMock = Mockery::mock($globalSet)->makePartial(); - $globalSetMock->shouldReceive('blueprint')->andReturn($blueprintMock); - $globalSetMock->shouldReceive('handle')->andReturn('test'); - - $globalCollection = new GlobalCollection([$globalSetMock]); - GlobalSet::shouldReceive('all')->andReturn($globalCollection); - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('getGlobalVariables'); - $method->setAccessible(true); - - $result = $method->invoke($fieldtype); - - $this->assertIsArray($result); - $this->assertNotEmpty($result); - } - - #[Test] - public function set_field_data_returns_null_for_ineligible_field(): void - { - $fieldtype = new AvailableVariablesFieldtype; - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('setFieldData'); - $method->setAccessible(true); - - $field = [ - 'handle' => 'test', - 'field' => [ - 'type' => 'grid', - ], - ]; - - $result = $method->invoke($fieldtype, $field); - - $this->assertNull($result); - } - - #[Test] - public function set_field_data_returns_null_when_handle_is_parent(): void - { - $fieldtype = new AvailableVariablesFieldtype; - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('setFieldData'); - $method->setAccessible(true); - - $field = [ - 'handle' => 'parent', - 'field' => [ - 'type' => 'text', - ], - ]; - - $result = $method->invoke($fieldtype, $field); - - $this->assertNull($result); - } - - #[Test] - public function set_field_data_returns_field_data_for_eligible_field(): void - { - $fieldtype = new AvailableVariablesFieldtype; - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('setFieldData'); - $method->setAccessible(true); - - $field = [ - 'handle' => 'test_field', - 'field' => [ - 'type' => 'text', - 'display' => 'Test Field', - ], - ]; - - $result = $method->invoke($fieldtype, $field); - - $this->assertIsArray($result); - /** @var array $result */ - $this->assertArrayHasKey('name', $result); - $this->assertArrayHasKey('description', $result); - $this->assertArrayHasKey('children', $result); - $this->assertEquals('test_field', $result['name']); - } - - #[Test] - public function get_collection_variables_returns_empty_when_no_collection_handle(): void - { - $fieldtype = new AvailableVariablesFieldtype; - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('getCollectionVariables'); - $method->setAccessible(true); - - $result = $method->invoke($fieldtype, '', []); - - $this->assertIsArray($result); - $this->assertEmpty($result); - } - - #[Test] - public function get_collection_variables_returns_empty_when_handle_is_structured_data_templates(): void - { - $fieldtype = new AvailableVariablesFieldtype; - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('getCollectionVariables'); - $method->setAccessible(true); - - $result = $method->invoke($fieldtype, 'structured_data_templates', []); - - $this->assertIsArray($result); - $this->assertEmpty($result); - } - - #[Test] - public function get_collection_variables_returns_empty_when_collection_not_found(): void - { - $fieldtype = new AvailableVariablesFieldtype; - - CollectionFacade::shouldReceive('find')->with('nonexistent')->andReturn(null); - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('getCollectionVariables'); - $method->setAccessible(true); - - $result = $method->invoke($fieldtype, 'nonexistent', []); - - $this->assertIsArray($result); - $this->assertEmpty($result); - } - - #[Test] - public function get_collection_variables_returns_variables_when_collection_exists(): void - { - $fieldtype = new AvailableVariablesFieldtype; - - $collection = CollectionFacade::make('blog'); - $collection->save(); - $blueprint = BlueprintFacade::make('blog'); - $blueprint->setContents([ - 'fields' => [ - [ - 'handle' => 'title', - 'field' => [ - 'type' => 'text', - 'display' => 'Title', - ], - ], - ], - ]); - $blueprint->save(); - $collection->entryBlueprints(); - - $field = [ - 'handle' => 'related_entry', - 'field' => [ - 'type' => 'entries', - 'display' => 'Related Entry', - 'collections' => ['blog'], - ], - ]; - - CollectionFacade::shouldReceive('find')->with('blog')->andReturn($collection); - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('getCollectionVariables'); - $method->setAccessible(true); - - $result = $method->invoke($fieldtype, 'blog', $field); - - $this->assertIsArray($result); - $this->assertNotEmpty($result); - } - - #[Test] - public function get_collection_variables_returns_empty_when_no_blueprint(): void - { - $fieldtype = new AvailableVariablesFieldtype; - - $collectionMock = $this->mock(Collection::class, function ($mock): void { - $mock->shouldReceive('entryBlueprints')->andReturn(collect([])); - }); - - $field = [ - 'handle' => 'related_entry', - 'field' => [ - 'type' => 'entries', - 'display' => 'Related Entry', - 'collections' => ['blog'], - ], - ]; - - CollectionFacade::shouldReceive('find')->with('blog')->andReturn($collectionMock); - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('getCollectionVariables'); - $method->setAccessible(true); - - $result = $method->invoke($fieldtype, 'blog', $field); - - $this->assertIsArray($result); - $this->assertEmpty($result); - } - - #[Test] - public function set_field_data_returns_field_with_children_for_entries_field_type(): void - { - $fieldtype = new AvailableVariablesFieldtype; - - $collection = CollectionFacade::make('blog'); - $collection->save(); - $blueprint = BlueprintFacade::make('blog'); - $blueprint->setContents([ - 'fields' => [ - [ - 'handle' => 'title', - 'field' => [ - 'type' => 'text', - 'display' => 'Title', - ], - ], - ], - ]); - $blueprint->save(); - $collection->entryBlueprints(); - - CollectionFacade::shouldReceive('find')->with('blog')->andReturn($collection); - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('setFieldData'); - $method->setAccessible(true); - - $field = [ - 'handle' => 'related_entry', - 'field' => [ - 'type' => 'entries', - 'display' => 'Related Entry', - 'collections' => ['blog'], - ], - ]; - - $result = $method->invoke($fieldtype, $field); - - $this->assertIsArray($result); - /** @var array $result */ - $this->assertArrayHasKey('name', $result); - $this->assertArrayHasKey('description', $result); - $this->assertArrayHasKey('children', $result); - $this->assertNotEmpty($result['children']); - } - - #[Test] - public function set_field_data_returns_null_when_entries_field_has_no_collections(): void - { - $fieldtype = new AvailableVariablesFieldtype; - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('setFieldData'); - $method->setAccessible(true); - - $field = [ - 'handle' => 'related_entry', - 'field' => [ - 'type' => 'entries', - 'display' => 'Related Entry', - ], - ]; - - $result = $method->invoke($fieldtype, $field); - - $this->assertNull($result); - } - - #[Test] - public function set_field_data_returns_group_field_with_supported_children(): void - { - $fieldtype = new AvailableVariablesFieldtype; - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('setFieldData'); - $method->setAccessible(true); - - $field = [ - 'handle' => 'media_options', - 'field' => [ - 'type' => 'group', - 'display' => 'Media opties', - 'fields' => [ - [ - 'handle' => 'object_fit', - 'field' => [ - 'type' => 'select', - 'display' => 'Object fit', - ], - ], - [ - 'handle' => 'video', - 'field' => [ - 'type' => 'toggle', - 'display' => 'Video', - ], - ], - [ - 'handle' => 'layout', - 'field' => [ - 'type' => 'grid', - 'display' => 'Layout', - ], - ], - ], - ], - ]; - - $result = $method->invoke($fieldtype, $field); - - $this->assertIsArray($result); - /** @var array $result */ - $this->assertSame('media_options', $result['name']); - $this->assertSame('Media opties', $result['description']); - $this->assertArrayHasKey('children', $result); - $this->assertIsArray($result['children']); - $this->assertCount(2, $result['children']); - - /** @var array> $children */ - $children = $result['children']; - $childNames = array_column($children, 'name'); - $childDescriptions = array_column($children, 'description'); - - $this->assertContains('media_options:object_fit', $childNames); - $this->assertContains('Media opties: Object fit', $childDescriptions); - $this->assertContains('media_options:video', $childNames); - $this->assertContains('Media opties: Video', $childDescriptions); - $this->assertNotContains('media_options:layout', $childNames); - } - - #[Test] - public function set_field_data_returns_null_for_group_without_supported_children(): void - { - $fieldtype = new AvailableVariablesFieldtype; - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('setFieldData'); - $method->setAccessible(true); - - $field = [ - 'handle' => 'media_options', - 'field' => [ - 'type' => 'group', - 'display' => 'Media opties', - 'fields' => [ - [ - 'handle' => 'layout', - 'field' => [ - 'type' => 'grid', - 'display' => 'Layout', - ], - ], - ], - ], - ]; - - $result = $method->invoke($fieldtype, $field); - - $this->assertNull($result); - } - - #[Test] - public function normalize_blueprint_items_converts_collection_to_array(): void - { - $fieldtype = new AvailableVariablesFieldtype; - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('normalizeBlueprintItems'); - $method->setAccessible(true); - - $collection = collect([ - ['handle' => 'test', 'field' => ['type' => 'text']], - ]); - - $result = $method->invoke($fieldtype, $collection); - - $this->assertIsArray($result); - $this->assertCount(1, $result); - /** @var array $firstResult */ - $firstResult = $result[0]; - $this->assertEquals('test', $firstResult['handle']); - } - - #[Test] - public function normalize_blueprint_items_returns_array_as_is(): void - { - $fieldtype = new AvailableVariablesFieldtype; - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('normalizeBlueprintItems'); - $method->setAccessible(true); - - $input = [ - ['handle' => 'test', 'field' => ['type' => 'text']], - ]; - - $result = $method->invoke($fieldtype, $input); - - $this->assertIsArray($result); - $this->assertEquals($input, $result); - } - - #[Test] - public function normalize_blueprint_items_returns_empty_array_for_invalid_input(): void - { - $fieldtype = new AvailableVariablesFieldtype; - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('normalizeBlueprintItems'); - $method->setAccessible(true); - - $result = $method->invoke($fieldtype, 'not-an-array'); - - $this->assertIsArray($result); - $this->assertEmpty($result); - } - - #[Test] - public function normalize_blueprint_items_includes_imported_fieldset_items(): void - { - $fieldtype = new AvailableVariablesFieldtype; - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('normalizeBlueprintItems'); - $method->setAccessible(true); - - $fieldsetFields = $this->mock(Fields::class, function (MockInterface $mock): void { - $mock->shouldReceive('items')->andReturn([ - [ - 'handle' => 'imported_title', - 'field' => [ - 'type' => 'text', - 'display' => 'Imported Title', - ], - ], - ]); - }); - - $fieldset = $this->mock(Fieldset::class, function (MockInterface $mock) use ($fieldsetFields): void { - $mock->shouldReceive('fields')->andReturn($fieldsetFields); - }); - - FieldsetFacade::shouldReceive('find')->with('seo_fields')->andReturn($fieldset); - - $result = $method->invoke($fieldtype, [ - ['import' => 'seo_fields'], - ]); - - $this->assertIsArray($result); - $this->assertCount(1, $result); - /** @var array $firstResult */ - $firstResult = $result[0]; - $this->assertSame('imported_title', $firstResult['handle']); - } - - #[Test] - public function normalize_blueprint_items_handles_missing_imported_fieldset(): void - { - $fieldtype = new AvailableVariablesFieldtype; - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('normalizeBlueprintItems'); - $method->setAccessible(true); - - FieldsetFacade::shouldReceive('find')->with('missing_fields')->andReturn(null); - - $result = $method->invoke($fieldtype, [ - ['import' => 'missing_fields'], - ]); - - $this->assertIsArray($result); - $this->assertEmpty($result); - } - - #[Test] - public function normalize_blueprint_items_applies_prefix_to_imported_fieldset_items(): void - { - $fieldtype = new AvailableVariablesFieldtype; - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('normalizeBlueprintItems'); - $method->setAccessible(true); - - $fieldsetFields = $this->mock(Fields::class, function (MockInterface $mock): void { - $mock->shouldReceive('items')->andReturn([ - [ - 'handle' => 'image', - 'field' => [ - 'type' => 'assets', - 'display' => 'Image', - ], - ], - ]); - }); - - $fieldset = $this->mock(Fieldset::class, function (MockInterface $mock) use ($fieldsetFields): void { - $mock->shouldReceive('fields')->andReturn($fieldsetFields); - }); - - FieldsetFacade::shouldReceive('find')->with('media')->andReturn($fieldset); - - $result = $method->invoke($fieldtype, [ - ['import' => 'media', 'prefix' => 'featured_'], - ]); - - $this->assertIsArray($result); - $this->assertCount(1, $result); - /** @var array $firstResult */ - $firstResult = $result[0]; - $this->assertSame('featured_image', $firstResult['handle']); - } - - #[Test] - public function normalize_blueprint_items_handles_nested_support_collection_and_invalid_items(): void - { - $fieldtype = new AvailableVariablesFieldtype; - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('normalizeBlueprintItems'); - $method->setAccessible(true); - - $nestedFields = collect([ - [ - 'handle' => 'nested_title', - 'field' => [ - 'type' => 'text', - 'display' => 'Nested Title', - ], - ], - ]); - - $items = [ - collect([ - 'handle' => 'wrapped_field', - 'field' => [ - 'type' => 'text', - 'display' => 'Wrapped Field', - ], - ]), - 'invalid-item', - [ - 'fields' => $nestedFields, - ], - ]; - - $result = $method->invoke($fieldtype, $items); - - $this->assertIsArray($result); - $fieldHandles = array_column($result, 'handle'); - $this->assertContains('wrapped_field', $fieldHandles); - $this->assertContains('nested_title', $fieldHandles); - } - - #[Test] - public function normalize_blueprint_items_handles_invalid_import_handle(): void - { - $fieldtype = new AvailableVariablesFieldtype; - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('normalizeBlueprintItems'); - $method->setAccessible(true); - - $result = $method->invoke($fieldtype, [ - ['import' => null], - ]); - - $this->assertIsArray($result); - $this->assertEmpty($result); - } - - #[Test] - public function get_imported_fieldset_items_returns_empty_for_invalid_import_config(): void - { - $fieldtype = new AvailableVariablesFieldtype; - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('getImportedFieldsetItems'); - $method->setAccessible(true); - - $result = $method->invoke($fieldtype, ['import' => null]); - - $this->assertIsArray($result); - $this->assertEmpty($result); - } - - #[Test] - public function get_entry_fields_includes_base_fields_and_maps_collection_fields(): void - { - $fieldtype = new AvailableVariablesFieldtype; - - $collection = CollectionFacade::make('test'); - $collection->save(); - $blueprint = BlueprintFacade::make('test'); - $blueprint->setNamespace('collections.test'); - $blueprint->setContents([ - 'fields' => [ - [ - 'handle' => 'title', - 'field' => [ - 'type' => 'text', - 'display' => 'Title', - ], - ], - ], - ]); - $blueprint->save(); - $collection->entryBlueprints(); - - $templatesCollection = CollectionFacade::make('structured_data_templates'); - $templatesCollection->save(); - $entry = (new Entry) - ->collection($templatesCollection) - ->id('template-123'); - - $entry->use_for_collection = $collection; - $entryMock = $entry; - - /** @var Field $fieldMock */ - $fieldMock = $this->mock(Field::class, function (MockInterface $mock) use ($entryMock): void { - $mock->shouldReceive('parent')->andReturn($entryMock); - }); - - $fieldtype->setField($fieldMock); - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('getEntryFields'); - $method->setAccessible(true); - - $result = $method->invoke($fieldtype); - - $this->assertIsArray($result); - $this->assertNotEmpty($result); - /** @var array $firstResult */ - $firstResult = $result[0]; - $this->assertEquals('absolute_url', $firstResult['name']); - $this->assertGreaterThanOrEqual(2, count($result)); - $fieldNames = array_column($result, 'name'); - $this->assertContains('title', $fieldNames); - } - - #[Test] - public function set_field_data_group_skips_children_without_handle(): void - { - $fieldtype = new AvailableVariablesFieldtype; - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('setFieldData'); - $method->setAccessible(true); - - $field = [ - 'handle' => 'media_options', - 'field' => [ - 'type' => 'group', - 'display' => 'Media opties', - 'fields' => [ - [ - 'field' => [ - 'type' => 'toggle', - 'display' => 'Missing Handle', - ], - ], - [ - 'handle' => 'video', - 'field' => [ - 'type' => 'toggle', - 'display' => 'Video', - ], - ], - ], - ], - ]; - - $result = $method->invoke($fieldtype, $field); - - $this->assertIsArray($result); - /** @var array $result */ - $this->assertArrayHasKey('children', $result); - /** @var array> $children */ - $children = $result['children']; - $this->assertCount(1, $children); - $this->assertSame('media_options:video', $children[0]['name'] ?? null); - } - - #[Test] - public function get_entry_fields_handles_empty_fields_after_merge_gracefully(): void - { - $fieldtype = new AvailableVariablesFieldtype; - - $collection = CollectionFacade::make('test'); - $collection->save(); - $blueprint = $this->mock(Blueprint::class); - $fieldsMock = $this->mock(Fields::class, function (MockInterface $mock): void { - $mock->shouldReceive('items')->andReturn([]); - }); - $blueprint->shouldReceive('fields')->andReturn($fieldsMock); - - $collectionMock = Mockery::mock($collection)->makePartial(); - $collectionMock->shouldReceive('entryBlueprints')->andReturn(collect([$blueprint])); - - $templatesCollection = CollectionFacade::make('structured_data_templates'); - $templatesCollection->save(); - $entry = (new Entry) - ->collection($templatesCollection) - ->id('template-123'); - - $entry->use_for_collection = $collectionMock; - $entryMock = $entry; - - /** @var Field $fieldMock */ - $fieldMock = $this->mock(Field::class, function (MockInterface $mock) use ($entryMock): void { - $mock->shouldReceive('parent')->andReturn($entryMock); - }); - - $fieldtype->setField($fieldMock); - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('getEntryFields'); - $method->setAccessible(true); - - $result = $method->invoke($fieldtype); - - $this->assertIsArray($result); - } - - #[Test] - public function get_term_fields_handles_null_first_blueprint_gracefully(): void - { - $fieldtype = new AvailableVariablesFieldtype; - - /** @var Taxonomy $taxonomy */ - $taxonomy = TaxonomyFacade::make('test'); - $taxonomy->save(); - - $templatesCollection = CollectionFacade::make('structured_data_templates'); - $templatesCollection->save(); - $entry = (new Entry) - ->collection($templatesCollection) - ->id('template-123'); - - $taxonomyMock = Mockery::mock($taxonomy)->makePartial(); - $taxonomyMock->shouldReceive('termBlueprints')->andReturn(collect([null])); - - $entry->use_for_taxonomy = $taxonomyMock; - $entryMock = $entry; - - /** @var Field $fieldMock */ - $fieldMock = $this->mock(Field::class, function (MockInterface $mock) use ($entryMock): void { - $mock->shouldReceive('parent')->andReturn($entryMock); - }); - - $fieldtype->setField($fieldMock); - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('getTermFields'); - $method->setAccessible(true); - - $result = $method->invoke($fieldtype); - - $this->assertIsArray($result); - } - - #[Test] - public function get_term_fields_handles_empty_fields_after_merge_gracefully(): void - { - $fieldtype = new AvailableVariablesFieldtype; - - /** @var Taxonomy $taxonomy */ - $taxonomy = TaxonomyFacade::make('test'); - $taxonomy->save(); - $blueprint = $this->mock(Blueprint::class); - $fieldsMock = $this->mock(Fields::class, function (MockInterface $mock): void { - $mock->shouldReceive('items')->andReturn([]); - }); - $blueprint->shouldReceive('fields')->andReturn($fieldsMock); - - $taxonomyMock = Mockery::mock($taxonomy)->makePartial(); - $taxonomyMock->shouldReceive('termBlueprints')->andReturn(collect([$blueprint])); - - $templatesCollection = CollectionFacade::make('structured_data_templates'); - $templatesCollection->save(); - $entry = (new Entry) - ->collection($templatesCollection) - ->id('template-123'); - - $entry->use_for_taxonomy = $taxonomyMock; - $entryMock = $entry; - - /** @var Field $fieldMock */ - $fieldMock = $this->mock(Field::class, function (MockInterface $mock) use ($entryMock): void { - $mock->shouldReceive('parent')->andReturn($entryMock); - }); - - $fieldtype->setField($fieldMock); - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('getTermFields'); - $method->setAccessible(true); - - $result = $method->invoke($fieldtype); - - $this->assertIsArray($result); - } - - #[Test] - public function get_term_fields_includes_base_fields_and_maps_taxonomy_fields(): void - { - $fieldtype = new AvailableVariablesFieldtype; - - /** @var Taxonomy $taxonomy */ - $taxonomy = TaxonomyFacade::make('test'); - $taxonomy->save(); - $blueprint = BlueprintFacade::make('test'); - $blueprint->setNamespace('taxonomies.test'); - $blueprint->setContents([ - 'fields' => [ - [ - 'handle' => 'title', - 'field' => [ - 'type' => 'text', - 'display' => 'Title', - ], - ], - ], - ]); - $blueprint->save(); - /** @phpstan-ignore-next-line */ - $taxonomy->termBlueprints(['test']); - - $templatesCollection = CollectionFacade::make('structured_data_templates'); - $templatesCollection->save(); - $entry = (new Entry) - ->collection($templatesCollection) - ->id('template-123'); - - $entry->use_for_taxonomy = $taxonomy; - $entryMock = $entry; - - /** @var Field $fieldMock */ - $fieldMock = $this->mock(Field::class, function (MockInterface $mock) use ($entryMock): void { - $mock->shouldReceive('parent')->andReturn($entryMock); - }); - - $fieldtype->setField($fieldMock); - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('getTermFields'); - $method->setAccessible(true); - - $result = $method->invoke($fieldtype); - - $this->assertIsArray($result); - $this->assertNotEmpty($result); - /** @var array $firstResult */ - $firstResult = $result[0]; - $this->assertEquals('absolute_url', $firstResult['name']); - $this->assertGreaterThanOrEqual(2, count($result)); - $fieldNames = array_column($result, 'name'); - $this->assertContains('title', $fieldNames); - } - - #[Test] - public function get_seo_pro_variables_returns_core_cascaded_fields(): void - { - $fieldtype = new AvailableVariablesFieldtype; - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('getSeoProVariables'); - $method->setAccessible(true); - - $result = $method->invoke($fieldtype); - - $this->assertIsArray($result); - /** @var array> $result */ - $this->assertCount(7, $result); - $names = array_column($result, 'name'); - $this->assertSame([ - 'seo:title', - 'seo:compiled_title', - 'seo:description', - 'seo:canonical_url', - 'seo:og_title', - 'seo:og_type', - 'seo:image', - ], $names); - } - - #[Test] - public function get_entry_fields_appends_seo_pro_variables_when_seo_pro_is_available(): void - { - $fieldtype = new class extends AvailableVariablesFieldtype - { - protected function seoProIsInstalled(): bool - { - return true; - } - }; - - $collection = CollectionFacade::make('test'); - $collection->save(); - $blueprint = BlueprintFacade::make('test'); - $blueprint->setNamespace('collections.test'); - $blueprint->setContents([ - 'fields' => [ - [ - 'handle' => 'title', - 'field' => [ - 'type' => 'text', - 'display' => 'Title', - ], - ], - ], - ]); - $blueprint->save(); - $collection->entryBlueprints(); - - $templatesCollection = CollectionFacade::make('structured_data_templates'); - $templatesCollection->save(); - $entry = (new Entry) - ->collection($templatesCollection) - ->id('template-123'); - - $entry->use_for_collection = $collection; - $entryMock = $entry; - - /** @var Field $fieldMock */ - $fieldMock = $this->mock(Field::class, function (MockInterface $mock) use ($entryMock): void { - $mock->shouldReceive('parent')->andReturn($entryMock); - }); - - $fieldtype->setField($fieldMock); - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('getEntryFields'); - $method->setAccessible(true); - - $result = $method->invoke($fieldtype); - - $this->assertIsArray($result); - /** @var array> $result */ - $fieldNames = array_column($result, 'name'); - - $this->assertContains('seo:compiled_title', $fieldNames); - - $titleIndex = array_search('title', $fieldNames, true); - $seoTitleIndex = array_search('seo:title', $fieldNames, true); - - $this->assertNotFalse($titleIndex); - $this->assertNotFalse($seoTitleIndex); - $this->assertGreaterThan($titleIndex, $seoTitleIndex); - } - - #[Test] - public function get_entry_fields_omits_seo_pro_variables_when_seo_is_disabled_for_collection(): void - { - $fieldtype = new class extends AvailableVariablesFieldtype - { - protected function seoProIsInstalled(): bool - { - return true; - } - }; - - $collection = CollectionFacade::make('test'); - $collection->cascade(['seo' => false]); - $collection->save(); - - $templatesCollection = CollectionFacade::make('structured_data_templates'); - $templatesCollection->save(); - $entry = (new Entry) - ->collection($templatesCollection) - ->id('template-123'); - - $entry->use_for_collection = $collection; - $entryMock = $entry; - - /** @var Field $fieldMock */ - $fieldMock = $this->mock(Field::class, function (MockInterface $mock) use ($entryMock): void { - $mock->shouldReceive('parent')->andReturn($entryMock); - }); - - $fieldtype->setField($fieldMock); - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('getEntryFields'); - $method->setAccessible(true); - - $result = $method->invoke($fieldtype); - - $this->assertIsArray($result); - /** @var array> $result */ - $fieldNames = array_column($result, 'name'); - - $this->assertNotContains('seo:title', $fieldNames); - } - - #[Test] - public function get_term_fields_appends_seo_pro_variables_when_seo_pro_is_available(): void - { - $fieldtype = new class extends AvailableVariablesFieldtype - { - protected function seoProIsInstalled(): bool - { - return true; - } - }; - - /** @var Taxonomy $taxonomy */ - $taxonomy = TaxonomyFacade::make('test'); - $taxonomy->save(); - $blueprint = BlueprintFacade::make('test'); - $blueprint->setNamespace('taxonomies.test'); - $blueprint->setContents([ - 'fields' => [ - [ - 'handle' => 'title', - 'field' => [ - 'type' => 'text', - 'display' => 'Title', - ], - ], - ], - ]); - $blueprint->save(); - /** @phpstan-ignore-next-line */ - $taxonomy->termBlueprints(['test']); - - $templatesCollection = CollectionFacade::make('structured_data_templates'); - $templatesCollection->save(); - $entry = (new Entry) - ->collection($templatesCollection) - ->id('template-123'); - - $entry->use_for_taxonomy = $taxonomy; - $entryMock = $entry; - - /** @var Field $fieldMock */ - $fieldMock = $this->mock(Field::class, function (MockInterface $mock) use ($entryMock): void { - $mock->shouldReceive('parent')->andReturn($entryMock); - }); - - $fieldtype->setField($fieldMock); - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('getTermFields'); - $method->setAccessible(true); - - $result = $method->invoke($fieldtype); - - $this->assertIsArray($result); - /** @var array> $result */ - $fieldNames = array_column($result, 'name'); - - $this->assertContains('seo:description', $fieldNames); - } - - #[Test] - public function get_term_fields_omits_seo_pro_variables_when_seo_is_disabled_for_taxonomy(): void - { - $fieldtype = new class extends AvailableVariablesFieldtype - { - protected function seoProIsInstalled(): bool - { - return true; - } - }; - - /** @var Taxonomy $taxonomy */ - $taxonomy = TaxonomyFacade::make('test'); - $taxonomy->cascade(['seo' => false]); - $taxonomy->save(); - - $templatesCollection = CollectionFacade::make('structured_data_templates'); - $templatesCollection->save(); - $entry = (new Entry) - ->collection($templatesCollection) - ->id('template-123'); - - $entry->use_for_taxonomy = $taxonomy; - $entryMock = $entry; - - /** @var Field $fieldMock */ - $fieldMock = $this->mock(Field::class, function (MockInterface $mock) use ($entryMock): void { - $mock->shouldReceive('parent')->andReturn($entryMock); - }); - - $fieldtype->setField($fieldMock); - - $reflection = new \ReflectionClass($fieldtype); - $method = $reflection->getMethod('getTermFields'); - $method->setAccessible(true); - - $result = $method->invoke($fieldtype); - - $this->assertIsArray($result); - /** @var array> $result */ - $fieldNames = array_column($result, 'name'); - - $this->assertNotContains('seo:title', $fieldNames); - } - - #[Test] - public function get_runway_fields_returns_empty_when_runway_not_installed(): void - { - RunwaySupport::fakeInstalled(false); - - $fieldtype = new AvailableVariablesFieldtype; - $fieldMock = $this->mock(Field::class, function (MockInterface $mock): void { - $mock->shouldReceive('parent')->andReturn(null); - }); - $fieldtype->setField($fieldMock); - - $method = (new \ReflectionClass($fieldtype))->getMethod('getRunwayFields'); - $method->setAccessible(true); - - $this->assertSame([], $method->invoke($fieldtype)); - } - - #[Test] - public function get_runway_fields_returns_empty_when_parent_is_not_entry(): void - { - RunwaySupport::fakeInstalled(true); - - $fieldtype = new AvailableVariablesFieldtype; - $fieldMock = $this->mock(Field::class, function (MockInterface $mock): void { - $mock->shouldReceive('parent')->andReturn(null); - }); - $fieldtype->setField($fieldMock); - - $method = (new \ReflectionClass($fieldtype))->getMethod('getRunwayFields'); - $method->setAccessible(true); - - $this->assertSame([], $method->invoke($fieldtype)); - } - - #[Test] - public function get_runway_fields_returns_empty_when_no_resource_handle(): void - { - RunwaySupport::fakeInstalled(true); - - $templatesCollection = CollectionFacade::make('structured_data_templates'); - $templatesCollection->save(); - $entry = (new Entry)->collection($templatesCollection)->id('template-123'); - - $fieldtype = new AvailableVariablesFieldtype; - $fieldMock = $this->mock(Field::class, function (MockInterface $mock) use ($entry): void { - $mock->shouldReceive('parent')->andReturn($entry); - }); - $fieldtype->setField($fieldMock); - - $method = (new \ReflectionClass($fieldtype))->getMethod('getRunwayFields'); - $method->setAccessible(true); - - $this->assertSame([], $method->invoke($fieldtype)); - } - - #[Test] - public function get_runway_fields_returns_empty_when_resource_not_found(): void - { - RunwaySupport::fakeInstalled(true); - Config::set('justbetter.structured-data.runway', ['product']); - - $templatesCollection = CollectionFacade::make('structured_data_templates'); - $templatesCollection->save(); - $entry = (new Entry)->collection($templatesCollection)->id('template-123'); - $entry->use_for_runway = 'product'; - - $fieldtype = new AvailableVariablesFieldtype; - $fieldMock = $this->mock(Field::class, function (MockInterface $mock) use ($entry): void { - $mock->shouldReceive('parent')->andReturn($entry); - }); - $fieldtype->setField($fieldMock); - - $method = (new \ReflectionClass($fieldtype))->getMethod('getRunwayFields'); - $method->setAccessible(true); - - $this->assertSame([], $method->invoke($fieldtype)); - } - - #[Test] - public function get_runway_fields_returns_blueprint_and_model_variables(): void - { - RunwaySupport::fakeInstalled(true); - Config::set('justbetter.structured-data.runway', ['product']); - - $blueprint = BlueprintFacade::make('runway-product')->setContents([ - 'fields' => [ - [ - 'handle' => 'name', - 'field' => [ - 'type' => 'text', - 'display' => 'Name', - ], - ], - ], - ]); - - $model = new Product([ - 'sku' => 'ABC', - 'name' => 'Bike', - ]); - $model->fillable(['sku', 'color']); - - $resource = new Resource; - $resource->resourceHandle = 'product'; - $resource->resourceName = 'Products'; - $resource->resourceBlueprint = $blueprint; - $resource->resourceModel = $model; - Runway::$findResults['product'] = $resource; - - $templatesCollection = CollectionFacade::make('structured_data_templates'); - $templatesCollection->save(); - $entry = (new Entry)->collection($templatesCollection)->id('template-123'); - $entry->use_for_runway = 'product'; - - $fieldtype = new AvailableVariablesFieldtype; - $fieldMock = $this->mock(Field::class, function (MockInterface $mock) use ($entry): void { - $mock->shouldReceive('parent')->andReturn($entry); - }); - $fieldtype->setField($fieldMock); - - $method = (new \ReflectionClass($fieldtype))->getMethod('getRunwayFields'); - $method->setAccessible(true); - $result = $method->invoke($fieldtype); - - $this->assertIsArray($result); - $names = array_column($result, 'name'); - $this->assertContains('absolute_url', $names); - $this->assertContains('name', $names); - $this->assertContains('sku', $names); - $this->assertContains('color', $names); - } - - #[Test] - public function resolve_runway_resource_handle_unwraps_labeled_value(): void - { - $templatesCollection = CollectionFacade::make('structured_data_templates'); - $templatesCollection->save(); - - $entry = new class extends Entry - { - public mixed $use_for_runway; - }; - $entry->collection($templatesCollection)->id('template-123'); - $entry->use_for_runway = new LabeledValue('product', 'Products'); - - $fieldtype = new AvailableVariablesFieldtype; - $method = (new \ReflectionClass($fieldtype))->getMethod('resolveRunwayResourceHandle'); - $method->setAccessible(true); - - $this->assertSame('product', $method->invoke($fieldtype, $entry)); - } - #[Test] public function preload_includes_runway_variables_key(): void { diff --git a/tests/Unit/Services/AvailableVariables/AvailableVariablesProvidersTest.php b/tests/Unit/Services/AvailableVariables/AvailableVariablesProvidersTest.php new file mode 100644 index 0000000..362a749 --- /dev/null +++ b/tests/Unit/Services/AvailableVariables/AvailableVariablesProvidersTest.php @@ -0,0 +1,633 @@ +save(); + $entry = (new Entry) + ->collection($templatesCollection) + ->id('template-123'); + + $this->assertSame([], $service->entry()->variables(null)); + $this->assertSame([], $service->entry()->variables($entry)); + } + + #[Test] + public function get_entry_fields_handles_no_blueprints_gracefully(): void + { + $service = new AvailableVariablesService; + + $collection = CollectionFacade::make('test'); + $collection->save(); + + $templatesCollection = CollectionFacade::make('structured_data_templates'); + $templatesCollection->save(); + $entry = (new Entry) + ->collection($templatesCollection) + ->id('template-123'); + + $collectionMock = Mockery::mock($collection)->makePartial(); + $collectionMock->shouldReceive('entryBlueprints')->andReturn(collect([])); + + $entry->use_for_collection = $collectionMock; + + $result = $service->entry()->variables($entry); + + $this->assertNotEmpty($result); + } + + #[Test] + public function get_entry_fields_includes_base_fields_and_maps_collection_fields(): void + { + $service = new AvailableVariablesService; + + $collection = CollectionFacade::make('test'); + $collection->save(); + $blueprint = BlueprintFacade::make('test'); + $blueprint->setNamespace('collections.test'); + $blueprint->setContents([ + 'fields' => [ + [ + 'handle' => 'title', + 'field' => [ + 'type' => 'text', + 'display' => 'Title', + ], + ], + ], + ]); + $blueprint->save(); + $collection->entryBlueprints(); + + $templatesCollection = CollectionFacade::make('structured_data_templates'); + $templatesCollection->save(); + $entry = (new Entry) + ->collection($templatesCollection) + ->id('template-123'); + + $entry->use_for_collection = $collection; + + $result = $service->entry()->variables($entry); + + $this->assertNotEmpty($result); + /** @var array $firstResult */ + $firstResult = $result[0]; + $this->assertEquals('absolute_url', $firstResult['name']); + $this->assertGreaterThanOrEqual(2, count($result)); + $fieldNames = array_column($result, 'name'); + $this->assertContains('title', $fieldNames); + } + + #[Test] + public function get_entry_fields_handles_empty_fields_after_merge_gracefully(): void + { + $service = new AvailableVariablesService; + + $collection = CollectionFacade::make('test'); + $collection->save(); + $blueprint = $this->mock(Blueprint::class); + $fieldsMock = $this->mock(Fields::class, function (MockInterface $mock): void { + $mock->shouldReceive('items')->andReturn([]); + }); + $blueprint->shouldReceive('fields')->andReturn($fieldsMock); + + $collectionMock = Mockery::mock($collection)->makePartial(); + $collectionMock->shouldReceive('entryBlueprints')->andReturn(collect([$blueprint])); + + $templatesCollection = CollectionFacade::make('structured_data_templates'); + $templatesCollection->save(); + $entry = (new Entry) + ->collection($templatesCollection) + ->id('template-123'); + + $entry->use_for_collection = $collectionMock; + + $result = $service->entry()->variables($entry); + + $this->assertNotEmpty($result); + } + + #[Test] + public function get_entry_fields_appends_seo_pro_variables_when_seo_pro_is_available(): void + { + $seoProVariables = new class extends SeoProVariables + { + public function isInstalled(): bool + { + return true; + } + }; + + $provider = new EntryVariableProvider(new BlueprintVariableMapper, $seoProVariables); + + $collection = CollectionFacade::make('test'); + $collection->save(); + $blueprint = BlueprintFacade::make('test'); + $blueprint->setNamespace('collections.test'); + $blueprint->setContents([ + 'fields' => [ + [ + 'handle' => 'title', + 'field' => [ + 'type' => 'text', + 'display' => 'Title', + ], + ], + ], + ]); + $blueprint->save(); + $collection->entryBlueprints(); + + $templatesCollection = CollectionFacade::make('structured_data_templates'); + $templatesCollection->save(); + $entry = (new Entry) + ->collection($templatesCollection) + ->id('template-123'); + + $entry->use_for_collection = $collection; + + $result = $provider->variables($entry); + + $fieldNames = array_column($result, 'name'); + + $this->assertContains('seo:compiled_title', $fieldNames); + + $titleIndex = array_search('title', $fieldNames, true); + $seoTitleIndex = array_search('seo:title', $fieldNames, true); + + $this->assertNotFalse($titleIndex); + $this->assertNotFalse($seoTitleIndex); + $this->assertGreaterThan($titleIndex, $seoTitleIndex); + } + + #[Test] + public function get_entry_fields_omits_seo_pro_variables_when_seo_is_disabled_for_collection(): void + { + $seoProVariables = new class extends SeoProVariables + { + public function isInstalled(): bool + { + return true; + } + }; + + $provider = new EntryVariableProvider(new BlueprintVariableMapper, $seoProVariables); + + $collection = CollectionFacade::make('test'); + $collection->cascade(['seo' => false]); + $collection->save(); + + $templatesCollection = CollectionFacade::make('structured_data_templates'); + $templatesCollection->save(); + $entry = (new Entry) + ->collection($templatesCollection) + ->id('template-123'); + + $entry->use_for_collection = $collection; + + $result = $provider->variables($entry); + + $fieldNames = array_column($result, 'name'); + + $this->assertNotContains('seo:title', $fieldNames); + } + + #[Test] + public function get_term_fields_returns_empty_when_no_taxonomy(): void + { + $service = new AvailableVariablesService; + + $templatesCollection = CollectionFacade::make('structured_data_templates'); + $templatesCollection->save(); + $entry = (new Entry) + ->collection($templatesCollection) + ->id('template-123'); + + $this->assertSame([], $service->term()->variables(null)); + $this->assertSame([], $service->term()->variables($entry)); + } + + #[Test] + public function get_term_fields_handles_no_blueprints_gracefully(): void + { + $service = new AvailableVariablesService; + + /** @var Taxonomy $taxonomy */ + $taxonomy = TaxonomyFacade::make('test'); + $taxonomy->save(); + + $templatesCollection = CollectionFacade::make('structured_data_templates'); + $templatesCollection->save(); + $entry = (new Entry) + ->collection($templatesCollection) + ->id('template-123'); + + $taxonomyMock = Mockery::mock($taxonomy)->makePartial(); + $taxonomyMock->shouldReceive('termBlueprints')->andReturn(collect([])); + + $entry->use_for_taxonomy = $taxonomyMock; + + $result = $service->term()->variables($entry); + + $this->assertNotEmpty($result); + } + + #[Test] + public function get_term_fields_handles_null_first_blueprint_gracefully(): void + { + $service = new AvailableVariablesService; + + /** @var Taxonomy $taxonomy */ + $taxonomy = TaxonomyFacade::make('test'); + $taxonomy->save(); + + $templatesCollection = CollectionFacade::make('structured_data_templates'); + $templatesCollection->save(); + $entry = (new Entry) + ->collection($templatesCollection) + ->id('template-123'); + + $taxonomyMock = Mockery::mock($taxonomy)->makePartial(); + $taxonomyMock->shouldReceive('termBlueprints')->andReturn(collect([null])); + + $entry->use_for_taxonomy = $taxonomyMock; + + $result = $service->term()->variables($entry); + + $this->assertNotEmpty($result); + } + + #[Test] + public function get_term_fields_handles_empty_fields_after_merge_gracefully(): void + { + $service = new AvailableVariablesService; + + /** @var Taxonomy $taxonomy */ + $taxonomy = TaxonomyFacade::make('test'); + $taxonomy->save(); + $blueprint = $this->mock(Blueprint::class); + $fieldsMock = $this->mock(Fields::class, function (MockInterface $mock): void { + $mock->shouldReceive('items')->andReturn([]); + }); + $blueprint->shouldReceive('fields')->andReturn($fieldsMock); + + $taxonomyMock = Mockery::mock($taxonomy)->makePartial(); + $taxonomyMock->shouldReceive('termBlueprints')->andReturn(collect([$blueprint])); + + $templatesCollection = CollectionFacade::make('structured_data_templates'); + $templatesCollection->save(); + $entry = (new Entry) + ->collection($templatesCollection) + ->id('template-123'); + + $entry->use_for_taxonomy = $taxonomyMock; + + $result = $service->term()->variables($entry); + + $this->assertNotEmpty($result); + } + + #[Test] + public function get_term_fields_includes_base_fields_and_maps_taxonomy_fields(): void + { + $service = new AvailableVariablesService; + + /** @var Taxonomy $taxonomy */ + $taxonomy = TaxonomyFacade::make('test'); + $taxonomy->save(); + $blueprint = BlueprintFacade::make('test'); + $blueprint->setNamespace('taxonomies.test'); + $blueprint->setContents([ + 'fields' => [ + [ + 'handle' => 'title', + 'field' => [ + 'type' => 'text', + 'display' => 'Title', + ], + ], + ], + ]); + $blueprint->save(); + /** @phpstan-ignore-next-line */ + $taxonomy->termBlueprints(['test']); + + $templatesCollection = CollectionFacade::make('structured_data_templates'); + $templatesCollection->save(); + $entry = (new Entry) + ->collection($templatesCollection) + ->id('template-123'); + + $entry->use_for_taxonomy = $taxonomy; + + $result = $service->term()->variables($entry); + + $this->assertNotEmpty($result); + /** @var array $firstResult */ + $firstResult = $result[0]; + $this->assertEquals('absolute_url', $firstResult['name']); + $this->assertGreaterThanOrEqual(2, count($result)); + $fieldNames = array_column($result, 'name'); + $this->assertContains('title', $fieldNames); + } + + #[Test] + public function get_term_fields_appends_seo_pro_variables_when_seo_pro_is_available(): void + { + $seoProVariables = new class extends SeoProVariables + { + public function isInstalled(): bool + { + return true; + } + }; + + $provider = new TermVariableProvider(new BlueprintVariableMapper, $seoProVariables); + + /** @var Taxonomy $taxonomy */ + $taxonomy = TaxonomyFacade::make('test'); + $taxonomy->save(); + $blueprint = BlueprintFacade::make('test'); + $blueprint->setNamespace('taxonomies.test'); + $blueprint->setContents([ + 'fields' => [ + [ + 'handle' => 'title', + 'field' => [ + 'type' => 'text', + 'display' => 'Title', + ], + ], + ], + ]); + $blueprint->save(); + /** @phpstan-ignore-next-line */ + $taxonomy->termBlueprints(['test']); + + $templatesCollection = CollectionFacade::make('structured_data_templates'); + $templatesCollection->save(); + $entry = (new Entry) + ->collection($templatesCollection) + ->id('template-123'); + + $entry->use_for_taxonomy = $taxonomy; + + $result = $provider->variables($entry); + + $fieldNames = array_column($result, 'name'); + + $this->assertContains('seo:description', $fieldNames); + } + + #[Test] + public function get_term_fields_omits_seo_pro_variables_when_seo_is_disabled_for_taxonomy(): void + { + $seoProVariables = new class extends SeoProVariables + { + public function isInstalled(): bool + { + return true; + } + }; + + $provider = new TermVariableProvider(new BlueprintVariableMapper, $seoProVariables); + + /** @var Taxonomy $taxonomy */ + $taxonomy = TaxonomyFacade::make('test'); + $taxonomy->cascade(['seo' => false]); + $taxonomy->save(); + + $templatesCollection = CollectionFacade::make('structured_data_templates'); + $templatesCollection->save(); + $entry = (new Entry) + ->collection($templatesCollection) + ->id('template-123'); + + $entry->use_for_taxonomy = $taxonomy; + + $result = $provider->variables($entry); + + $fieldNames = array_column($result, 'name'); + + $this->assertNotContains('seo:title', $fieldNames); + } + + #[Test] + public function get_global_variables_returns_variables(): void + { + $service = new AvailableVariablesService; + + $blueprint = BlueprintFacade::make('test'); + $blueprint->setContents([ + 'fields' => [ + [ + 'handle' => 'test_field', + 'field' => [ + 'type' => 'text', + 'display' => 'Test Field', + ], + ], + ], + ]); + $blueprint->save(); + + $globalSet = GlobalSetFacade::make('test'); + $globalSet->save(); + $globalSet->blueprint('test'); + + $fieldsMock = $this->mock(Fields::class, function (MockInterface $mock): void { + $mock->shouldReceive('items')->andReturn(collect([ + [ + 'handle' => 'test_field', + 'field' => [ + 'type' => 'text', + 'display' => 'Test Field', + ], + ], + ])); + }); + + $blueprintMock = $this->mock(Blueprint::class, function (MockInterface $mock) use ($fieldsMock): void { + $mock->shouldReceive('fields')->andReturn($fieldsMock); + }); + + $globalSetMock = Mockery::mock($globalSet)->makePartial(); + $globalSetMock->shouldReceive('blueprint')->andReturn($blueprintMock); + $globalSetMock->shouldReceive('handle')->andReturn('test'); + + $globalCollection = new GlobalCollection([$globalSetMock]); + GlobalSet::shouldReceive('all')->andReturn($globalCollection); + + $result = $service->globals()->variables(); + + $this->assertNotEmpty($result); + } + + #[Test] + public function get_seo_pro_variables_returns_core_cascaded_fields(): void + { + $service = new AvailableVariablesService; + + $result = $service->seoPro()->all(); + + $this->assertCount(7, $result); + $names = array_column($result, 'name'); + $this->assertSame([ + 'seo:title', + 'seo:compiled_title', + 'seo:description', + 'seo:canonical_url', + 'seo:og_title', + 'seo:og_type', + 'seo:image', + ], $names); + } + + #[Test] + public function get_runway_fields_returns_empty_when_runway_not_installed(): void + { + RunwaySupport::fakeInstalled(false); + + $service = new AvailableVariablesService; + + $this->assertSame([], $service->runway()->variables(null)); + } + + #[Test] + public function get_runway_fields_returns_empty_when_parent_is_not_entry(): void + { + RunwaySupport::fakeInstalled(true); + + $service = new AvailableVariablesService; + + $this->assertSame([], $service->runway()->variables(null)); + } + + #[Test] + public function get_runway_fields_returns_empty_when_no_resource_handle(): void + { + RunwaySupport::fakeInstalled(true); + + $templatesCollection = CollectionFacade::make('structured_data_templates'); + $templatesCollection->save(); + $entry = (new Entry)->collection($templatesCollection)->id('template-123'); + + $service = new AvailableVariablesService; + + $this->assertSame([], $service->runway()->variables($entry)); + } + + #[Test] + public function get_runway_fields_returns_empty_when_resource_not_found(): void + { + RunwaySupport::fakeInstalled(true); + Config::set('justbetter.structured-data.runway', ['product']); + + $templatesCollection = CollectionFacade::make('structured_data_templates'); + $templatesCollection->save(); + $entry = (new Entry)->collection($templatesCollection)->id('template-123'); + $entry->use_for_runway = 'product'; + + $service = new AvailableVariablesService; + + $this->assertSame([], $service->runway()->variables($entry)); + } + + #[Test] + public function get_runway_fields_returns_blueprint_and_model_variables(): void + { + RunwaySupport::fakeInstalled(true); + Config::set('justbetter.structured-data.runway', ['product']); + + $blueprint = BlueprintFacade::make('runway-product')->setContents([ + 'fields' => [ + [ + 'handle' => 'name', + 'field' => [ + 'type' => 'text', + 'display' => 'Name', + ], + ], + ], + ]); + + $model = new Product([ + 'sku' => 'ABC', + 'name' => 'Bike', + ]); + $model->fillable(['sku', 'color']); + + $resource = new Resource; + $resource->resourceHandle = 'product'; + $resource->resourceName = 'Products'; + $resource->resourceBlueprint = $blueprint; + $resource->resourceModel = $model; + Runway::$findResults['product'] = $resource; + + $templatesCollection = CollectionFacade::make('structured_data_templates'); + $templatesCollection->save(); + $entry = (new Entry)->collection($templatesCollection)->id('template-123'); + $entry->use_for_runway = 'product'; + + $service = new AvailableVariablesService; + + $result = $service->runway()->variables($entry); + + $names = array_column($result, 'name'); + $this->assertContains('absolute_url', $names); + $this->assertContains('name', $names); + $this->assertContains('sku', $names); + $this->assertContains('color', $names); + } + + #[Test] + public function resolve_runway_resource_handle_unwraps_labeled_value(): void + { + $templatesCollection = CollectionFacade::make('structured_data_templates'); + $templatesCollection->save(); + + $entry = new class extends Entry + { + public mixed $use_for_runway; + }; + $entry->collection($templatesCollection)->id('template-123'); + $entry->use_for_runway = new LabeledValue('product', 'Products'); + + $service = new AvailableVariablesService; + + $this->assertSame('product', $service->runway()->resolveResourceHandle($entry)); + } +} diff --git a/tests/Unit/Services/AvailableVariables/BlueprintVariableMapperTest.php b/tests/Unit/Services/AvailableVariables/BlueprintVariableMapperTest.php new file mode 100644 index 0000000..864680a --- /dev/null +++ b/tests/Unit/Services/AvailableVariables/BlueprintVariableMapperTest.php @@ -0,0 +1,560 @@ +assertTrue($mapper->fieldTypeIsEligible('text')); + $this->assertTrue($mapper->fieldTypeIsEligible('assets')); + $this->assertTrue($mapper->fieldTypeIsEligible('bard')); + $this->assertTrue($mapper->fieldTypeIsEligible('toggle')); + $this->assertTrue($mapper->fieldTypeIsEligible('integer')); + $this->assertTrue($mapper->fieldTypeIsEligible('slug')); + $this->assertTrue($mapper->fieldTypeIsEligible('date')); + $this->assertTrue($mapper->fieldTypeIsEligible('entries')); + $this->assertTrue($mapper->fieldTypeIsEligible('aardvark_seo_meta_title')); + $this->assertTrue($mapper->fieldTypeIsEligible('aardvark_seo_meta_description')); + } + + #[Test] + public function field_type_is_eligible_returns_false_for_ineligible_types(): void + { + $mapper = new BlueprintVariableMapper; + + $this->assertFalse($mapper->fieldTypeIsEligible('unknown_type')); + $this->assertFalse($mapper->fieldTypeIsEligible('grid')); + $this->assertFalse($mapper->fieldTypeIsEligible('replicator')); + } + + #[Test] + public function set_field_data_returns_null_for_ineligible_field(): void + { + $mapper = new BlueprintVariableMapper; + + $field = [ + 'handle' => 'test', + 'field' => [ + 'type' => 'grid', + ], + ]; + + $result = $mapper->setFieldData($field); + + $this->assertNull($result); + } + + #[Test] + public function set_field_data_returns_null_when_handle_is_parent(): void + { + $mapper = new BlueprintVariableMapper; + + $field = [ + 'handle' => 'parent', + 'field' => [ + 'type' => 'text', + ], + ]; + + $result = $mapper->setFieldData($field); + + $this->assertNull($result); + } + + #[Test] + public function set_field_data_returns_field_data_for_eligible_field(): void + { + $mapper = new BlueprintVariableMapper; + + $field = [ + 'handle' => 'test_field', + 'field' => [ + 'type' => 'text', + 'display' => 'Test Field', + ], + ]; + + $result = $mapper->setFieldData($field); + + $this->assertIsArray($result); + /** @var array $result */ + $this->assertArrayHasKey('name', $result); + $this->assertArrayHasKey('description', $result); + $this->assertArrayHasKey('children', $result); + $this->assertEquals('test_field', $result['name']); + } + + #[Test] + public function get_collection_variables_returns_empty_when_no_collection_handle(): void + { + $mapper = new BlueprintVariableMapper; + + $result = $mapper->getCollectionVariables('', []); + + $this->assertEmpty($result); + } + + #[Test] + public function get_collection_variables_returns_empty_when_handle_is_structured_data_templates(): void + { + $mapper = new BlueprintVariableMapper; + + $result = $mapper->getCollectionVariables('structured_data_templates', []); + + $this->assertEmpty($result); + } + + #[Test] + public function get_collection_variables_returns_empty_when_collection_not_found(): void + { + $mapper = new BlueprintVariableMapper; + + CollectionFacade::shouldReceive('find')->with('nonexistent')->andReturn(null); + + $result = $mapper->getCollectionVariables('nonexistent', []); + + $this->assertEmpty($result); + } + + #[Test] + public function get_collection_variables_returns_variables_when_collection_exists(): void + { + $mapper = new BlueprintVariableMapper; + + $collection = CollectionFacade::make('blog'); + $collection->save(); + $blueprint = BlueprintFacade::make('blog'); + $blueprint->setContents([ + 'fields' => [ + [ + 'handle' => 'title', + 'field' => [ + 'type' => 'text', + 'display' => 'Title', + ], + ], + ], + ]); + $blueprint->save(); + $collection->entryBlueprints(); + + $field = [ + 'handle' => 'related_entry', + 'field' => [ + 'type' => 'entries', + 'display' => 'Related Entry', + 'collections' => ['blog'], + ], + ]; + + CollectionFacade::shouldReceive('find')->with('blog')->andReturn($collection); + + $result = $mapper->getCollectionVariables('blog', $field); + + $this->assertNotEmpty($result); + } + + #[Test] + public function get_collection_variables_returns_empty_when_no_blueprint(): void + { + $mapper = new BlueprintVariableMapper; + + $collectionMock = $this->mock(Collection::class, function ($mock): void { + $mock->shouldReceive('entryBlueprints')->andReturn(collect([])); + }); + + $field = [ + 'handle' => 'related_entry', + 'field' => [ + 'type' => 'entries', + 'display' => 'Related Entry', + 'collections' => ['blog'], + ], + ]; + + CollectionFacade::shouldReceive('find')->with('blog')->andReturn($collectionMock); + + $result = $mapper->getCollectionVariables('blog', $field); + + $this->assertEmpty($result); + } + + #[Test] + public function set_field_data_returns_field_with_children_for_entries_field_type(): void + { + $mapper = new BlueprintVariableMapper; + + $collection = CollectionFacade::make('blog'); + $collection->save(); + $blueprint = BlueprintFacade::make('blog'); + $blueprint->setContents([ + 'fields' => [ + [ + 'handle' => 'title', + 'field' => [ + 'type' => 'text', + 'display' => 'Title', + ], + ], + ], + ]); + $blueprint->save(); + $collection->entryBlueprints(); + + CollectionFacade::shouldReceive('find')->with('blog')->andReturn($collection); + + $field = [ + 'handle' => 'related_entry', + 'field' => [ + 'type' => 'entries', + 'display' => 'Related Entry', + 'collections' => ['blog'], + ], + ]; + + $result = $mapper->setFieldData($field); + + $this->assertIsArray($result); + /** @var array $result */ + $this->assertArrayHasKey('name', $result); + $this->assertArrayHasKey('description', $result); + $this->assertArrayHasKey('children', $result); + $this->assertNotEmpty($result['children']); + } + + #[Test] + public function set_field_data_returns_null_when_entries_field_has_no_collections(): void + { + $mapper = new BlueprintVariableMapper; + + $field = [ + 'handle' => 'related_entry', + 'field' => [ + 'type' => 'entries', + 'display' => 'Related Entry', + ], + ]; + + $result = $mapper->setFieldData($field); + + $this->assertNull($result); + } + + #[Test] + public function set_field_data_returns_group_field_with_supported_children(): void + { + $mapper = new BlueprintVariableMapper; + + $field = [ + 'handle' => 'media_options', + 'field' => [ + 'type' => 'group', + 'display' => 'Media opties', + 'fields' => [ + [ + 'handle' => 'object_fit', + 'field' => [ + 'type' => 'select', + 'display' => 'Object fit', + ], + ], + [ + 'handle' => 'video', + 'field' => [ + 'type' => 'toggle', + 'display' => 'Video', + ], + ], + [ + 'handle' => 'layout', + 'field' => [ + 'type' => 'grid', + 'display' => 'Layout', + ], + ], + ], + ], + ]; + + $result = $mapper->setFieldData($field); + + $this->assertIsArray($result); + /** @var array $result */ + $this->assertSame('media_options', $result['name']); + $this->assertSame('Media opties', $result['description']); + $this->assertArrayHasKey('children', $result); + $this->assertIsArray($result['children']); + $this->assertCount(2, $result['children']); + + /** @var array> $children */ + $children = $result['children']; + $childNames = array_column($children, 'name'); + $childDescriptions = array_column($children, 'description'); + + $this->assertContains('media_options:object_fit', $childNames); + $this->assertContains('Media opties: Object fit', $childDescriptions); + $this->assertContains('media_options:video', $childNames); + $this->assertContains('Media opties: Video', $childDescriptions); + $this->assertNotContains('media_options:layout', $childNames); + } + + #[Test] + public function set_field_data_returns_null_for_group_without_supported_children(): void + { + $mapper = new BlueprintVariableMapper; + + $field = [ + 'handle' => 'media_options', + 'field' => [ + 'type' => 'group', + 'display' => 'Media opties', + 'fields' => [ + [ + 'handle' => 'layout', + 'field' => [ + 'type' => 'grid', + 'display' => 'Layout', + ], + ], + ], + ], + ]; + + $result = $mapper->setFieldData($field); + + $this->assertNull($result); + } + + #[Test] + public function set_field_data_group_skips_children_without_handle(): void + { + $mapper = new BlueprintVariableMapper; + + $field = [ + 'handle' => 'media_options', + 'field' => [ + 'type' => 'group', + 'display' => 'Media opties', + 'fields' => [ + [ + 'field' => [ + 'type' => 'toggle', + 'display' => 'Missing Handle', + ], + ], + [ + 'handle' => 'video', + 'field' => [ + 'type' => 'toggle', + 'display' => 'Video', + ], + ], + ], + ], + ]; + + $result = $mapper->setFieldData($field); + + $this->assertIsArray($result); + /** @var array $result */ + $this->assertArrayHasKey('children', $result); + /** @var array> $children */ + $children = $result['children']; + $this->assertCount(1, $children); + $this->assertSame('media_options:video', $children[0]['name'] ?? null); + } + + #[Test] + public function normalize_blueprint_items_converts_collection_to_array(): void + { + $mapper = new BlueprintVariableMapper; + + $collection = collect([ + ['handle' => 'test', 'field' => ['type' => 'text']], + ]); + + $result = $mapper->normalizeBlueprintItems($collection); + + $this->assertCount(1, $result); + /** @var array $firstResult */ + $firstResult = $result[0]; + $this->assertEquals('test', $firstResult['handle']); + } + + #[Test] + public function normalize_blueprint_items_returns_array_as_is(): void + { + $mapper = new BlueprintVariableMapper; + + $input = [ + ['handle' => 'test', 'field' => ['type' => 'text']], + ]; + + $result = $mapper->normalizeBlueprintItems($input); + + $this->assertEquals($input, $result); + } + + #[Test] + public function normalize_blueprint_items_returns_empty_array_for_invalid_input(): void + { + $mapper = new BlueprintVariableMapper; + + $result = $mapper->normalizeBlueprintItems('not-an-array'); + + $this->assertEmpty($result); + } + + #[Test] + public function normalize_blueprint_items_includes_imported_fieldset_items(): void + { + $mapper = new BlueprintVariableMapper; + + $fieldsetFields = $this->mock(Fields::class, function (MockInterface $mock): void { + $mock->shouldReceive('items')->andReturn([ + [ + 'handle' => 'imported_title', + 'field' => [ + 'type' => 'text', + 'display' => 'Imported Title', + ], + ], + ]); + }); + + $fieldset = $this->mock(Fieldset::class, function (MockInterface $mock) use ($fieldsetFields): void { + $mock->shouldReceive('fields')->andReturn($fieldsetFields); + }); + + FieldsetFacade::shouldReceive('find')->with('seo_fields')->andReturn($fieldset); + + $result = $mapper->normalizeBlueprintItems([ + ['import' => 'seo_fields'], + ]); + + $this->assertCount(1, $result); + /** @var array $firstResult */ + $firstResult = $result[0]; + $this->assertSame('imported_title', $firstResult['handle']); + } + + #[Test] + public function normalize_blueprint_items_handles_missing_imported_fieldset(): void + { + $mapper = new BlueprintVariableMapper; + + FieldsetFacade::shouldReceive('find')->with('missing_fields')->andReturn(null); + + $result = $mapper->normalizeBlueprintItems([ + ['import' => 'missing_fields'], + ]); + + $this->assertEmpty($result); + } + + #[Test] + public function normalize_blueprint_items_applies_prefix_to_imported_fieldset_items(): void + { + $mapper = new BlueprintVariableMapper; + + $fieldsetFields = $this->mock(Fields::class, function (MockInterface $mock): void { + $mock->shouldReceive('items')->andReturn([ + [ + 'handle' => 'image', + 'field' => [ + 'type' => 'assets', + 'display' => 'Image', + ], + ], + ]); + }); + + $fieldset = $this->mock(Fieldset::class, function (MockInterface $mock) use ($fieldsetFields): void { + $mock->shouldReceive('fields')->andReturn($fieldsetFields); + }); + + FieldsetFacade::shouldReceive('find')->with('media')->andReturn($fieldset); + + $result = $mapper->normalizeBlueprintItems([ + ['import' => 'media', 'prefix' => 'featured_'], + ]); + + $this->assertCount(1, $result); + /** @var array $firstResult */ + $firstResult = $result[0]; + $this->assertSame('featured_image', $firstResult['handle']); + } + + #[Test] + public function normalize_blueprint_items_handles_nested_support_collection_and_invalid_items(): void + { + $mapper = new BlueprintVariableMapper; + + $nestedFields = collect([ + [ + 'handle' => 'nested_title', + 'field' => [ + 'type' => 'text', + 'display' => 'Nested Title', + ], + ], + ]); + + $items = [ + collect([ + 'handle' => 'wrapped_field', + 'field' => [ + 'type' => 'text', + 'display' => 'Wrapped Field', + ], + ]), + 'invalid-item', + [ + 'fields' => $nestedFields, + ], + ]; + + $result = $mapper->normalizeBlueprintItems($items); + + $fieldHandles = array_column($result, 'handle'); + $this->assertContains('wrapped_field', $fieldHandles); + $this->assertContains('nested_title', $fieldHandles); + } + + #[Test] + public function normalize_blueprint_items_handles_invalid_import_handle(): void + { + $mapper = new BlueprintVariableMapper; + + $result = $mapper->normalizeBlueprintItems([ + ['import' => null], + ]); + + $this->assertEmpty($result); + } + + #[Test] + public function get_imported_fieldset_items_returns_empty_for_invalid_import_config(): void + { + $mapper = new BlueprintVariableMapper; + + $result = $mapper->getImportedFieldsetItems(['import' => null]); + + $this->assertEmpty($result); + } +} From 5699489742d5ace2140e562ab6381b3d8671d63b Mon Sep 17 00:00:00 2001 From: Kevin Meijer Date: Wed, 5 Aug 2026 16:47:52 +0200 Subject: [PATCH 7/7] Added test stub --- tests/Stubs/InstalledSeoProVariables.php | 13 +++++++ .../AvailableVariablesProvidersTest.php | 34 +++---------------- 2 files changed, 18 insertions(+), 29 deletions(-) create mode 100644 tests/Stubs/InstalledSeoProVariables.php diff --git a/tests/Stubs/InstalledSeoProVariables.php b/tests/Stubs/InstalledSeoProVariables.php new file mode 100644 index 0000000..dc0885f --- /dev/null +++ b/tests/Stubs/InstalledSeoProVariables.php @@ -0,0 +1,13 @@ +