HTML editor work
This commit is contained in:
parent
d7e1995da3
commit
1835e75924
|
|
@ -29,3 +29,6 @@ Thumbs.db
|
|||
# Local test/coverage output
|
||||
coverage/
|
||||
.phpunit.result.cache
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* Plugin Name: FECA Mailshots
|
||||
* Plugin URI: https://fenedge.co.uk/
|
||||
* Description: FECA mailshots plugin.
|
||||
* Version: 0.1.6
|
||||
* Version: 0.1.26
|
||||
* Requires at least: 6.0
|
||||
* Requires PHP: 7.4
|
||||
* Author: FECA
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ final class AttachmentsAdminPage
|
|||
echo '<div id="att-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;">';
|
||||
echo '<div style="max-width:900px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">';
|
||||
echo '<p style="text-align:right;margin:0;"><button type="button" class="button" id="att-close-editor">Close</button></p>';
|
||||
echo '<form method="post" enctype="multipart/form-data" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
|
||||
echo '<form method="post" enctype="multipart/form-data" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;" id="att-editor-form">';
|
||||
echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit Attachment' : 'New Attachment') . '</h2>';
|
||||
echo '<input type="hidden" name="action" value="feca_mailshots_attachments_ui_save">';
|
||||
if ($editId > 0) {
|
||||
|
|
@ -119,7 +119,7 @@ final class AttachmentsAdminPage
|
|||
echo '<tr><td colspan="6">No attachments found.</td></tr>';
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
echo '<script>(function(){var modal=document.getElementById("att-editor-modal");var openBtn=document.getElementById("att-open-new");var closeBtn=document.getElementById("att-close-editor");if(openBtn&&modal){openBtn.addEventListener("click",function(){var id=document.getElementById("att-editor-id");if(id){id.value="";}["att_name","att_file_name","att_mime","att_base64"].forEach(function(x){var el=document.getElementById(x);if(el){el.value="";}});modal.style.display="block";});}if(closeBtn&&modal){closeBtn.addEventListener("click",function(){modal.style.display="none";});}var hasEdit=' . ($editId > 0 ? 'true' : 'false') . ';if(hasEdit&&modal){modal.style.display="block";}})();</script>';
|
||||
echo '<script>(function(){var modal=document.getElementById("att-editor-modal");var openBtn=document.getElementById("att-open-new");var closeBtn=document.getElementById("att-close-editor");var form=document.getElementById("att-editor-form");var isDirty=false;function confirmDiscard(){if(!isDirty){return true;}return window.confirm("You have unsaved changes. Close without saving?");}if(form){form.querySelectorAll("input,select,textarea").forEach(function(el){el.addEventListener("input",function(){isDirty=true;});el.addEventListener("change",function(){isDirty=true;});});form.addEventListener("submit",function(){isDirty=false;});}if(openBtn&&modal){openBtn.addEventListener("click",function(){if(!confirmDiscard()){return;}var id=document.getElementById("att-editor-id");if(id){id.value="";}["att_name","att_file_name","att_mime","att_base64"].forEach(function(x){var el=document.getElementById(x);if(el){el.value="";}}isDirty=false;modal.style.display="block";});}if(closeBtn&&modal){closeBtn.addEventListener("click",function(){if(!confirmDiscard()){return;}modal.style.display="none";});}var hasEdit=' . ($editId > 0 ? 'true' : 'false') . ';var hasResult=' . ($result !== null ? 'true' : 'false') . ';var resultOk=' . (!empty($result['ok']) ? 'true' : 'false') . ';if(hasEdit&&modal&&(!hasResult||!resultOk)){modal.style.display="block";isDirty=false;}})();</script>';
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
|
|
@ -232,6 +232,7 @@ final class AttachmentsAdminPage
|
|||
private function result(): ?array
|
||||
{
|
||||
$raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null);
|
||||
$this->wp->deleteOption(self::RESULT_OPTION_KEY);
|
||||
return is_array($raw) ? $raw : null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ final class DataSourcesAdminPage
|
|||
$bg = $ok ? '#f1f8e9' : '#ffebee';
|
||||
$border = $ok ? '#8bc34a' : '#ef9a9a';
|
||||
$title = $ok ? 'Action succeeded.' : 'Action failed.';
|
||||
echo '<div style="padding:10px;border:1px solid ' . $border . ';background:' . $bg . ';margin:12px 0;">';
|
||||
echo '<div id="ds-result-banner" style="padding:10px;border:1px solid ' . $border . ';background:' . $bg . ';margin:12px 0;">';
|
||||
echo '<strong>' . htmlspecialchars($title) . '</strong>';
|
||||
if (!empty($result['errors']) && is_array($result['errors'])) {
|
||||
echo '<p style="margin:6px 0 0 0;">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>';
|
||||
|
|
@ -106,6 +106,7 @@ final class DataSourcesAdminPage
|
|||
echo '<p style="margin:6px 0 0 0;">Preview count: ' . (int) $result['count'] . '</p>';
|
||||
}
|
||||
echo '</div>';
|
||||
echo '<script>(function(){var n=document.getElementById("ds-result-banner");if(!n){return;}window.setTimeout(function(){if(n&&n.parentNode){n.parentNode.removeChild(n);}},30000);})();</script>';
|
||||
}
|
||||
|
||||
echo '<div style="display:flex;gap:12px;margin:12px 0;">';
|
||||
|
|
@ -128,7 +129,6 @@ final class DataSourcesAdminPage
|
|||
echo '<p><button type="button" class="button button-primary" id="ds-open-new">New Data Source</button></p>';
|
||||
echo '<div id="ds-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;">';
|
||||
echo '<div style="max-width:980px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">';
|
||||
echo '<p style="text-align:right;margin:0;"><button type="button" class="button" id="ds-close-editor">Close</button></p>';
|
||||
echo '<form method="post" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;" id="ds-editor-form">';
|
||||
echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit Data Source' : 'New Data Source') . '</h2>';
|
||||
if ($editId > 0) {
|
||||
|
|
@ -141,14 +141,18 @@ final class DataSourcesAdminPage
|
|||
echo '<tr><th scope="row"><label for="ds_dsl">DSL Sentence</label></th><td><textarea id="ds_dsl" name="dsl_text" rows="7" class="large-text code" data-initial="' . htmlspecialchars($dsl, ENT_QUOTES) . '">' . htmlspecialchars($dsl) . '</textarea></td></tr>';
|
||||
echo '</table>';
|
||||
echo '<p>';
|
||||
echo '<button type="submit" class="button button-primary" name="action" value="feca_mailshots_data_sources_ui_save">' . ($editId > 0 ? 'Save' : 'Create') . '</button> ';
|
||||
echo '<button type="submit" class="button" name="action" value="feca_mailshots_data_sources_ui_validate">Validate Sentence</button> ';
|
||||
echo '<button type="submit" class="button" name="action" value="feca_mailshots_data_sources_ui_preview">Preview Recipients</button> ';
|
||||
echo '<button type="submit" class="button button-primary" name="action" value="feca_mailshots_data_sources_ui_save">Save</button> ';
|
||||
echo '<button type="button" class="button" id="ds-close-editor">Quit</button> ';
|
||||
echo '<button type="button" class="button" id="ds-validate-btn">Validate Sentence</button> ';
|
||||
echo '<button type="button" class="button" id="ds-preview-btn">Preview Recipients</button> ';
|
||||
echo '<button type="button" class="button" id="ds-build-dsl-open">Build DSL</button> ';
|
||||
if ($editId > 0) {
|
||||
echo '<a class="button" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=feca-mailshot-data-sources&q=' . rawurlencode($filter) . '&sort=' . rawurlencode($sort))) . '">Discard Changes</a>';
|
||||
}
|
||||
echo '</p></form>';
|
||||
echo '</p>';
|
||||
echo '<div id="ds-inline-result" style="display:none;margin:10px 0 0 0;padding:10px;border:1px solid #dcdcde;background:#f9f9f9;"></div>';
|
||||
echo '<div id="ds-inline-preview" style="display:none;margin:10px 0 0 0;padding:10px;border:1px solid #dcdcde;background:#fff;"></div>';
|
||||
echo '</form>';
|
||||
echo '</div></div>';
|
||||
|
||||
echo '<div id="ds-builder-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9999;">';
|
||||
|
|
@ -161,10 +165,13 @@ final class DataSourcesAdminPage
|
|||
echo '<label><input type="checkbox" class="ds-source-built" value="contacts"> contacts</label><br>';
|
||||
echo '<label><input type="checkbox" class="ds-source-built" value="accounts"> accounts</label><br>';
|
||||
echo '<label><input type="checkbox" class="ds-source-built" value="renewals"> renewals</label><br><br>';
|
||||
echo '<label><input type="checkbox" class="ds-source-built" value="grants"> grants</label><br><br>';
|
||||
echo '<strong>Add custom source</strong><br>';
|
||||
$defaultSchema = in_array('fenedgec_members', $schemaList, true) ? 'fenedgec_members' : '';
|
||||
echo '<label>Schema <select id="ds-builder-schema"><option value="">Select schema</option>';
|
||||
foreach ($schemaList as $schema) {
|
||||
echo '<option value="' . htmlspecialchars((string) $schema, ENT_QUOTES) . '">' . htmlspecialchars((string) $schema) . '</option>';
|
||||
$selected = ((string) $schema === $defaultSchema) ? ' selected' : '';
|
||||
echo '<option value="' . htmlspecialchars((string) $schema, ENT_QUOTES) . '"' . $selected . '>' . htmlspecialchars((string) $schema) . '</option>';
|
||||
}
|
||||
echo '</select></label> ';
|
||||
echo '<label>Table <select id="ds-builder-table"><option value="">Select table</option></select></label> ';
|
||||
|
|
@ -180,6 +187,7 @@ final class DataSourcesAdminPage
|
|||
echo '<strong>Generated DSL</strong>';
|
||||
echo '<textarea id="ds-builder-output" rows="4" class="large-text code" readonly></textarea>';
|
||||
echo '<p id="ds-builder-error" style="color:#a00;margin:6px 0 0 0;"></p>';
|
||||
echo '<p id="ds-builder-table-error" style="color:#a00;margin:6px 0 0 0;"></p>';
|
||||
echo '</div>';
|
||||
echo '<p style="margin-top:12px;">';
|
||||
echo '<button type="button" class="button button-primary" id="ds-builder-apply">Apply</button> ';
|
||||
|
|
@ -250,6 +258,7 @@ final class DataSourcesAdminPage
|
|||
echo 'window.fecaDataSourcesBuilderConfig = ' . json_encode([
|
||||
'sourceFields' => $sourceFieldsMap,
|
||||
'restBase' => '/wp-json/mailshots/v1/data-sources',
|
||||
'adminPostApi' => $this->wp->adminUrl('admin-post.php?action=feca_mailshots_data_sources_api'),
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';';
|
||||
echo '(function(){';
|
||||
echo 'var cfg=window.fecaDataSourcesBuilderConfig||{};';
|
||||
|
|
@ -263,7 +272,13 @@ final class DataSourcesAdminPage
|
|||
echo 'var rowsWrap=document.getElementById("ds-builder-rows");';
|
||||
echo 'var out=document.getElementById("ds-builder-output");';
|
||||
echo 'var err=document.getElementById("ds-builder-error");';
|
||||
echo 'var tableErr=document.getElementById("ds-builder-table-error");';
|
||||
echo 'var editorModal=document.getElementById("ds-editor-modal");';
|
||||
echo 'var editorForm=document.getElementById("ds-editor-form");';
|
||||
echo 'var validateBtn=document.getElementById("ds-validate-btn");';
|
||||
echo 'var previewBtn=document.getElementById("ds-preview-btn");';
|
||||
echo 'var inlineResult=document.getElementById("ds-inline-result");';
|
||||
echo 'var inlinePreview=document.getElementById("ds-inline-preview");';
|
||||
echo 'var openNew=document.getElementById("ds-open-new");';
|
||||
echo 'var closeEditor=document.getElementById("ds-close-editor");';
|
||||
echo 'var editorId=document.getElementById("ds-editor-id");';
|
||||
|
|
@ -275,19 +290,26 @@ final class DataSourcesAdminPage
|
|||
echo 'var addCustomBtn=document.getElementById("ds-builder-add-custom");';
|
||||
echo 'var customSources=[];';
|
||||
echo 'var constraints=[];';
|
||||
echo 'var filters=[["selected-renewal","Renewal is selected"],["pending-renewal","Renewal is pending"],["fen1-contact","Contact is FEN1"],["fen2-contact","Contact is FEN2"],["member-or-affiliate-or-parish-council","Account is member/affiliate/parish council"]];';
|
||||
echo 'var isDirty=false;';
|
||||
echo 'function confirmDiscard(){if(!isDirty){return true;}return window.confirm("You have unsaved changes. Close without saving?");}';
|
||||
echo 'if(editorForm){editorForm.querySelectorAll("input,select,textarea").forEach(function(el){el.addEventListener("input",function(){isDirty=true;});el.addEventListener("change",function(){isDirty=true;});});editorForm.addEventListener("submit",function(){isDirty=false;});}';
|
||||
echo 'var filters=[["selected-renewal","Renewal is selected"],["pending-renewal","Renewal is pending"],["primary-contact","Contact is primary"],["fen1-contact","Contact is FEN1"],["member-or-affiliate-or-parish-council","Account is member/affiliate/parish council"]];';
|
||||
echo 'function selectedSources(){var s=[];builtChecks.forEach(function(c){if(c.checked){s.push(c.value);}});customSources.forEach(function(v){s.push(v);});return s;}';
|
||||
echo 'function updateCustomList(){customList.innerHTML="";customSources.forEach(function(src,i){var li=document.createElement("li");li.textContent=src+" ";var b=document.createElement("button");b.type="button";b.className="button-link-delete";b.textContent="Remove";b.onclick=function(){customSources.splice(i,1);updateCustomList();renderRows();updateDsl();};li.appendChild(b);customList.appendChild(li);});}';
|
||||
echo 'function fetchTables(schema){tableSel.innerHTML="<option value=\"\">Loading...</option>";fetch(cfg.restBase+"/tables?schema="+encodeURIComponent(schema),{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(j){var items=(j&&j.items)||[];tableSel.innerHTML="<option value=\"\">Select table</option>";items.forEach(function(t){var o=document.createElement("option");o.value=t;o.textContent=t;tableSel.appendChild(o);});}).catch(function(){tableSel.innerHTML="<option value=\"\">Select table</option>";});}';
|
||||
echo 'function sourceFields(source){var map=cfg.sourceFields||{};if(map[source]){return map[source];}return [];}';
|
||||
echo 'function fetchTables(schema){tableSel.innerHTML="<option value=\"\">Loading...</option>";tableSel.style.color="#1d2327";if(tableErr){tableErr.textContent="";}var pickLabel=function(t){if(typeof t==="string"){return t.trim();}if(t===null||t===undefined){return "";}if(typeof t==="number"){return String(t);}if(typeof t==="object"){var direct=[t.table_name,t.name,t.table,t.label];for(var i=0;i<direct.length;i++){var dv=String(direct[i]||"").trim();if(dv){return dv;}}var keys=Object.keys(t||{});for(var k=0;k<keys.length;k++){var key=String(keys[k]||"").trim();if(/^Tables_in_/i.test(key)){var vv=String(t[key]||"").trim();if(vv){return vv;}}}var vals=Object.values(t||{});for(var j=0;j<vals.length;j++){var v=String(vals[j]||"").trim();if(v){return v;}}}return "";};var fill=function(items){tableSel.innerHTML="<option value=\"\">Select table</option>";var invalid=0;(items||[]).forEach(function(t){var label=pickLabel(t);if(!label){invalid++;return;}var o=document.createElement("option");o.value=label;o.textContent=label;tableSel.appendChild(o);});if((items||[]).length===0&&tableErr){tableErr.textContent="No tables returned for this schema. Check DB grants: SELECT and SHOW VIEW on schema tables.";}if(invalid>0&&tableErr){tableErr.textContent="Received "+invalid+" table entries without names; showing only valid table names.";}};var showErr=function(msg){tableSel.innerHTML="<option value=\"\">Select table</option>";if(tableErr){tableErr.textContent=msg||"Unable to list tables for this schema.";} };fetch(cfg.restBase+"/tables?schema="+encodeURIComponent(schema),{credentials:"same-origin"}).then(function(r){if(!r.ok){throw new Error("REST "+r.status);}return r.json();}).then(function(j){if(!j||j.ok===false){throw new Error((j&&j.error)||"REST error");}fill((j&&j.items)||[]);}).catch(function(){fetch((cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=tables&schema="+encodeURIComponent(schema),{credentials:"same-origin"}).then(function(r){if(!r.ok){throw new Error("API "+r.status);}return r.json();}).then(function(j){if(!j||j.ok===false){throw new Error((j&&j.error)||"API error");}fill((j&&j.items)||[]);}).catch(function(e){showErr((e&&e.message?e.message+". ":"")+"Need MySQL grants on the selected schema.");});});}';
|
||||
echo 'function sourceFields(source){var map=cfg.sourceFields||{};if(map[source]){return map[source];}if(source&&source.indexOf(".")!==-1){fetch((cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=source_fields&source="+encodeURIComponent(source),{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(j){var fields=(j&&j.fields)||[];if(fields&&fields.length){map[source]=fields;cfg.sourceFields=map;renderRows();updateDsl();}}).catch(function(){});}return [];}';
|
||||
echo 'function mkSelect(options,value){var s=document.createElement("select");options.forEach(function(opt){var o=document.createElement("option");o.value=opt[0];o.textContent=opt[1];if(opt[0]===value){o.selected=true;}s.appendChild(o);});return s;}';
|
||||
echo 'function addConstraint(){constraints.push({kind:"filter",negate:false,filter:"selected-renewal",lhsSource:"",lhsField:"",op:"=",rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""});renderRows();updateDsl();}';
|
||||
echo 'function renderRows(){rowsWrap.innerHTML="";constraints.forEach(function(row,idx){var box=document.createElement("div");box.style.border="1px solid #dcdcde";box.style.padding="8px";box.style.marginBottom="8px";var top=document.createElement("div");var not=document.createElement("input");not.type="checkbox";not.checked=!!row.negate;not.onchange=function(){row.negate=not.checked;updateDsl();};top.appendChild(not);top.appendChild(document.createTextNode(" NOT "));var kind=mkSelect([["filter","Predefined Filter"],["compare","Field Comparison"]],row.kind);kind.onchange=function(){row.kind=kind.value;renderRows();updateDsl();};top.appendChild(kind);var rem=document.createElement("button");rem.type="button";rem.className="button-link-delete";rem.style.marginLeft="8px";rem.textContent="Remove";rem.onclick=function(){constraints.splice(idx,1);renderRows();updateDsl();};top.appendChild(rem);box.appendChild(top);if(row.kind==="filter"){var f=mkSelect(filters,row.filter);f.onchange=function(){row.filter=f.value;updateDsl();};box.appendChild(f);}else{var srcs=selectedSources().map(function(s){return [s,s];});if(srcs.length===0){srcs=[["","Select source"]];}else{srcs.unshift(["","Select source"]);}var lhsS=mkSelect(srcs,row.lhsSource);lhsS.onchange=function(){row.lhsSource=lhsS.value;row.lhsField="";renderRows();updateDsl();};box.appendChild(lhsS);var lhsFields=(row.lhsSource?sourceFields(row.lhsSource):[]).map(function(f){return [f,f]});lhsFields.unshift(["","Field"]);var lhsF=mkSelect(lhsFields,row.lhsField);lhsF.onchange=function(){row.lhsField=lhsF.value;updateDsl();};box.appendChild(lhsF);var op=mkSelect([["=","="],["!=","!="],["contains","contains"],["starts-with","starts-with"],["ends-with","ends-with"],["in","in"]],row.op);op.onchange=function(){row.op=op.value;updateDsl();};box.appendChild(op);var mode=mkSelect([["literal","Literal"],["field","Field ref"]],row.rhsMode);mode.onchange=function(){row.rhsMode=mode.value;renderRows();updateDsl();};box.appendChild(mode);if(row.rhsMode==="literal"){var input=document.createElement("input");input.type="text";input.value=row.rhsLiteral||"";input.placeholder="Value";input.oninput=function(){row.rhsLiteral=input.value;updateDsl();};box.appendChild(input);}else{var rhsS=mkSelect(srcs,row.rhsSource);rhsS.onchange=function(){row.rhsSource=rhsS.value;row.rhsField="";renderRows();updateDsl();};box.appendChild(rhsS);var rhsFields=(row.rhsSource?sourceFields(row.rhsSource):[]).map(function(f){return [f,f]});rhsFields.unshift(["","Field"]);var rhsF=mkSelect(rhsFields,row.rhsField);rhsF.onchange=function(){row.rhsField=rhsF.value;updateDsl();};box.appendChild(rhsF);}}rowsWrap.appendChild(box);});}';
|
||||
echo 'function quote(v){if(v==="true"||v==="false"||/^\\d+$/.test(v)){return v;}return "\'"+String(v).replace(/\'/g,"")+"\'";}';
|
||||
echo 'function updateDsl(){err.textContent="";var srcs=selectedSources();if(srcs.length===0){out.value="";err.textContent="Select at least one data source.";return;}var base=srcs.join(" and ");var terms=[];for(var i=0;i<constraints.length;i++){var r=constraints[i];var t="";if(r.kind==="filter"){if(!r.filter){continue;}t=r.filter;}else{if(!r.lhsSource||!r.lhsField){continue;}var lhs=r.lhsSource+"."+r.lhsField;if(r.rhsMode==="field"){if(!r.rhsSource||!r.rhsField){continue;}var rhs=r.rhsSource+"."+r.rhsField;if(r.op==="in"){t=lhs+" in ("+rhs+")";}else{t=lhs+" "+r.op+" "+rhs;}}else{if((r.rhsLiteral||"")===""){continue;}if(r.op==="in"){t=lhs+" in ("+quote(r.rhsLiteral)+")";}else{t=lhs+" "+r.op+" "+quote(r.rhsLiteral);}}}if(r.negate&&t!==""){t="not ("+t+")";}if(t!==""){terms.push(t);}}out.value=base+(terms.length>0?" where "+terms.join(" and "):"");}';
|
||||
echo 'function resetBuilder(){builtChecks.forEach(function(c){c.checked=false;});customSources=[];constraints=[];updateCustomList();renderRows();updateDsl();}';
|
||||
echo 'function prefillFromDsl(){var text=(dslInput&&dslInput.value?dslInput.value:"").trim();if(!text){return;}var whereIndex=text.indexOf(" where ");var srcPart=whereIndex>=0?text.substring(0,whereIndex):text;var bits=srcPart.split(" and ");bits.map(function(v){return v.trim();}).forEach(function(src){if(!src){return;}var built=false;builtChecks.forEach(function(c){if(c.value===src){c.checked=true;built=true;}});if(!built&&customSources.indexOf(src)===-1){customSources.push(src);}});updateCustomList();renderRows();updateDsl();}';
|
||||
echo 'if(schemaSel){schemaSel.onchange=function(){if(schemaSel.value){fetchTables(schemaSel.value);}else{tableSel.innerHTML="<option value=\"\">Select table</option>";}};}';
|
||||
echo 'function splitAndTerms(text){var out=[];var cur="";var depth=0;var inQuote=false;for(var i=0;i<text.length;i++){var ch=text.charAt(i);if(ch==="\'"){inQuote=!inQuote;cur+=ch;continue;}if(!inQuote){if(ch==="("){depth++;cur+=ch;continue;}if(ch===")"){if(depth>0){depth--;}cur+=ch;continue;}if(depth===0&&text.substring(i,i+5)===" and "){if(cur.trim()){out.push(cur.trim());}cur="";i+=4;continue;}}cur+=ch;}if(cur.trim()){out.push(cur.trim());}return out;}';
|
||||
echo 'function splitFieldRef(text){var s=String(text||"").trim();var idx=s.lastIndexOf(".");if(idx<=0||idx>=s.length-1){return null;}return {source:s.substring(0,idx),field:s.substring(idx+1)};}';
|
||||
echo 'function parseLiteralValue(text){var v=String(text||"").trim();if(!v){return null;}if(v.length>=2&&v.charAt(0)==="\'"&&v.charAt(v.length-1)==="\'"){return v.substring(1,v.length-1);}if(v==="true"||v==="false"||/^\\d+$/.test(v)){return v;}return null;}';
|
||||
echo 'function parseCompareTerm(term){var m=/^(.+?)\\s+(=|!=|contains|starts-with|ends-with|in)\\s+(.+)$/.exec(String(term||"").trim());if(!m){return null;}var lhsRef=splitFieldRef(m[1]);if(!lhsRef){return null;}var op=String(m[2]||"");var rhsRaw=String(m[3]||"").trim();if(!rhsRaw){return null;}var row={kind:"compare",negate:false,filter:"selected-renewal",lhsSource:lhsRef.source,lhsField:lhsRef.field,op:op,rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""};if(op==="in"){if(rhsRaw.charAt(0)!=="("||rhsRaw.charAt(rhsRaw.length-1)!==")"){return null;}rhsRaw=rhsRaw.substring(1,rhsRaw.length-1).trim();if(!rhsRaw){return null;}}var rhsRef=splitFieldRef(rhsRaw);if(rhsRef){row.rhsMode="field";row.rhsSource=rhsRef.source;row.rhsField=rhsRef.field;return row;}var literal=parseLiteralValue(rhsRaw);if(literal===null){return null;}row.rhsLiteral=literal;return row;}';
|
||||
echo 'function prefillFromDsl(){var text=(dslInput&&dslInput.value?dslInput.value:"").trim();if(!text){return;}var lower=text.toLowerCase();var whereIndex=lower.indexOf(" where ");var srcPart=whereIndex>=0?text.substring(0,whereIndex):text;var wherePart=whereIndex>=0?text.substring(whereIndex+7).trim():"";var bits=srcPart.split(" and ");bits.map(function(v){return v.trim();}).forEach(function(src){if(!src){return;}var built=false;builtChecks.forEach(function(c){if(c.value===src){c.checked=true;built=true;}});if(!built&&customSources.indexOf(src)===-1){customSources.push(src);}});constraints=[];if(wherePart){var known={};filters.forEach(function(f){known[f[0]]=true;});splitAndTerms(wherePart).forEach(function(term){var t=(term||"").trim();if(!t){return;}var negate=false;var lt=t.toLowerCase();if(lt.indexOf("not (")===0&&t.charAt(t.length-1)===")"){negate=true;t=t.substring(5,t.length-1).trim();}else if(lt.indexOf("not ")===0){negate=true;t=t.substring(4).trim();}if(known[t]){constraints.push({kind:"filter",negate:negate,filter:t,lhsSource:"",lhsField:"",op:"=",rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""});return;}var parsed=parseCompareTerm(t);if(parsed){parsed.negate=negate;constraints.push(parsed);}});}updateCustomList();renderRows();updateDsl();}';
|
||||
echo 'if(schemaSel){schemaSel.onchange=function(){if(schemaSel.value){fetchTables(schemaSel.value);}else{tableSel.innerHTML="<option value=\"\">Select table</option>";}};if(schemaSel.value){fetchTables(schemaSel.value);}}';
|
||||
echo 'if(addCustomBtn){addCustomBtn.onclick=function(){if(!schemaSel.value||!tableSel.value){return;}var src=schemaSel.value+"."+tableSel.value;if(customSources.indexOf(src)===-1){customSources.push(src);}updateCustomList();renderRows();updateDsl();};}';
|
||||
echo 'builtChecks.forEach(function(c){c.onchange=updateDsl;});';
|
||||
echo 'if(addRowBtn){addRowBtn.onclick=addConstraint;}';
|
||||
|
|
@ -295,10 +317,16 @@ final class DataSourcesAdminPage
|
|||
echo 'if(cancelBtn){cancelBtn.onclick=function(){modal.style.display="none";};}';
|
||||
echo 'if(resetBtn){resetBtn.onclick=function(){resetBuilder();};}';
|
||||
echo 'if(applyBtn){applyBtn.onclick=function(){if(dslInput){dslInput.value=out.value;}modal.style.display="none";};}';
|
||||
echo 'if(openNew){openNew.onclick=function(){if(editorId){editorId.value="";}if(editorName){editorName.value="";}if(dslInput){dslInput.value="";}editorModal.style.display="block";};}';
|
||||
echo 'if(closeEditor){closeEditor.onclick=function(){editorModal.style.display="none";};}';
|
||||
echo 'if(openNew){openNew.onclick=function(){if(!confirmDiscard()){return;}if(editorId){editorId.value="";}if(editorName){editorName.value="";}if(dslInput){dslInput.value="";}isDirty=false;editorModal.style.display="block";};}';
|
||||
echo 'if(closeEditor){closeEditor.onclick=function(){if(!confirmDiscard()){return;}editorModal.style.display="none";};}';
|
||||
echo 'function esc(v){return String(v||"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");}';
|
||||
echo 'function showInlineResult(ok,title,lines){if(!inlineResult){return;}inlineResult.style.display="block";inlineResult.style.borderColor=ok?"#8bc34a":"#ef9a9a";inlineResult.style.background=ok?"#f1f8e9":"#ffebee";var h="<strong>"+esc(title)+"</strong>";if(lines&&lines.length){h+="<ul style=\"margin:8px 0 0 18px;\">"+lines.map(function(x){return "<li>"+esc(x)+"</li>";}).join("")+"</ul>";}inlineResult.innerHTML=h;}';
|
||||
echo 'if(validateBtn){validateBtn.onclick=function(){var dsl=(dslInput&&dslInput.value?dslInput.value:"");var payload=new URLSearchParams();payload.set("dsl_text",dsl);fetch((cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=validate",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded; charset=UTF-8"},body:payload.toString()}).then(function(r){return r.json();}).then(function(j){if(!j||j.ok===false){showInlineResult(false,"Validation failed",[j&&j.error?j.error:"Unknown validation error"]);return;}var errors=(j.errors||[]);var warnings=(j.warnings||[]);if(errors.length===0){showInlineResult(true,"Validation passed",warnings.length?warnings:["No errors"]);}else{showInlineResult(false,"Validation failed",errors.concat(warnings));}}).catch(function(e){showInlineResult(false,"Validation failed",[e&&e.message?e.message:"Request error"]);});};}';
|
||||
echo 'if(previewBtn){previewBtn.onclick=function(){var dsl=(dslInput&&dslInput.value?dslInput.value:"");var payload=new URLSearchParams();payload.set("dsl_text",dsl);payload.set("limit","50");fetch((cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=preview",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded; charset=UTF-8"},body:payload.toString()}).then(function(r){return r.json();}).then(function(j){if(!j||j.ok===false){showInlineResult(false,"Preview failed",[j&&j.error?j.error:"Unknown preview error"]);if(inlinePreview){inlinePreview.style.display="none";}return;}var errors=(j.errors||[]);var warnings=(j.warnings||[]);if(errors.length){showInlineResult(false,"Preview failed",errors.concat(warnings));if(inlinePreview){inlinePreview.style.display="none";}return;}showInlineResult(true,"Preview ready",["Count: "+(j.count||0)].concat(warnings));if(!inlinePreview){return;}var rows=(j.rows||[]);if(!rows.length){inlinePreview.style.display="block";inlinePreview.innerHTML="<p style=\"margin:0;\">No rows returned.</p>";return;}var cols=[];var seen={};rows.forEach(function(r){Object.keys(r||{}).forEach(function(k){if(!seen[k]){seen[k]=1;cols.push(k);}});});var html="<table class=\"widefat striped\"><thead><tr>"+cols.map(function(c){return "<th>"+esc(c)+"</th>";}).join("")+"</tr></thead><tbody>";rows.forEach(function(r){html+="<tr>"+cols.map(function(c){return "<td>"+esc((r&&r[c]!==undefined)?r[c]:"")+"</td>";}).join("")+"</tr>";});html+="</tbody></table>";inlinePreview.style.display="block";inlinePreview.innerHTML=html;}).catch(function(e){showInlineResult(false,"Preview failed",[e&&e.message?e.message:"Request error"]);if(inlinePreview){inlinePreview.style.display="none";}});};}';
|
||||
echo 'var hasEdit=' . ($editId > 0 ? 'true' : 'false') . ';';
|
||||
echo 'if(hasEdit&&editorModal){editorModal.style.display="block";}';
|
||||
echo 'var hasResult=' . ($result !== null ? 'true' : 'false') . ';';
|
||||
echo 'var resultOk=' . (!empty($result['ok']) ? 'true' : 'false') . ';';
|
||||
echo 'if(hasEdit&&editorModal&&(!hasResult||!resultOk)){editorModal.style.display="block";isDirty=false;}';
|
||||
echo '})();';
|
||||
echo '</script>';
|
||||
echo '</div>';
|
||||
|
|
@ -675,6 +703,7 @@ final class DataSourcesAdminPage
|
|||
private function result(): ?array
|
||||
{
|
||||
$raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null);
|
||||
$this->wp->deleteOption(self::RESULT_OPTION_KEY);
|
||||
return is_array($raw) ? $raw : null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -227,6 +227,7 @@ final class MailshotTestAdminPage
|
|||
private function result(): ?array
|
||||
{
|
||||
$raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null);
|
||||
$this->wp->deleteOption(self::RESULT_OPTION_KEY);
|
||||
return is_array($raw) ? $raw : null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ final class MailshotsAdminPage
|
|||
'Message' => (string) ($editItem['Message'] ?? ''),
|
||||
'PDFAttachment' => (string) ($editItem['PDFAttachment'] ?? ''),
|
||||
'PDFFilenameDerivedFrom' => (string) ($editItem['PDFFilenameDerivedFrom'] ?? ''),
|
||||
'RecipientEmailField' => (string) ($editItem['RecipientEmailField'] ?? ''),
|
||||
'ReplyTo' => (string) ($editItem['ReplyTo'] ?? ''),
|
||||
];
|
||||
|
||||
|
|
@ -110,7 +111,6 @@ final class MailshotsAdminPage
|
|||
echo '<p><button type="button" class="button button-primary" id="ms-open-new">New Mailshot</button></p>';
|
||||
echo '<div id="ms-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;">';
|
||||
echo '<div style="max-width:1100px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">';
|
||||
echo '<p style="text-align:right;margin:0;"><button type="button" class="button" id="ms-close-editor">Close</button></p>';
|
||||
echo '<form method="post" action="' . $action . '" id="mailshot-editor" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
|
||||
echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit Mailshot' : 'New Mailshot') . '</h2>';
|
||||
echo '<input type="hidden" name="action" value="feca_mailshots_mailshots_ui_save">';
|
||||
|
|
@ -121,48 +121,34 @@ final class MailshotsAdminPage
|
|||
}
|
||||
|
||||
echo '<table class="form-table" role="presentation">';
|
||||
echo '<tr><th scope="row"><label for="ms_purpose">Purpose</label></th><td><input class="regular-text" type="text" id="ms_purpose" name="Purpose" value="' . htmlspecialchars($form['Purpose'], ENT_QUOTES) . '"></td></tr>';
|
||||
echo '<tr><th scope="row"><label for="ms_ds">Data Source</label></th><td><select id="ms_ds" name="DataSource"><option value="">Select data source</option>';
|
||||
echo '<tr><th scope="row"><label for="ms_purpose">Purpose</label></th><td><input class="regular-text" type="text" id="ms_purpose" name="Purpose" value="' . htmlspecialchars($form['Purpose'], ENT_QUOTES) . '"></td>';
|
||||
echo '<th scope="row"><label for="ms_ds">Data Source</label></th><td><select id="ms_ds" name="DataSource"><option value="">Select data source</option>';
|
||||
foreach ($dataSources as $ds) {
|
||||
$sel = $ds === $form['DataSource'] ? ' selected' : '';
|
||||
echo '<option value="' . htmlspecialchars($ds, ENT_QUOTES) . '"' . $sel . '>' . htmlspecialchars($ds) . '</option>';
|
||||
}
|
||||
echo '</select> ';
|
||||
if ($form['DataSource'] !== '') {
|
||||
echo '<a class="button button-small" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=feca-mailshots-mailshots&edit_id=' . $editId)) . '">Refresh Source Fields</a>';
|
||||
}
|
||||
echo '</td></tr>';
|
||||
echo '</select></td></tr>';
|
||||
|
||||
echo '<tr><th scope="row">Available Tokens</th><td>';
|
||||
if (($tokenData['errors'] ?? []) !== []) {
|
||||
echo '<p class="description" style="color:#a00;">' . htmlspecialchars(implode('; ', (array) $tokenData['errors'])) . '</p>';
|
||||
} elseif (($tokenData['tokens'] ?? []) === []) {
|
||||
echo '<p class="description">Select a data source to view tokens.</p>';
|
||||
} else {
|
||||
foreach ((array) $tokenData['tokens'] as $token) {
|
||||
if (!is_array($token)) {
|
||||
continue;
|
||||
}
|
||||
$tokenText = (string) ($token['token'] ?? '');
|
||||
$fieldText = (string) ($token['field'] ?? '');
|
||||
echo '<button type="button" class="button button-small ms-token-btn" data-token="' . htmlspecialchars($tokenText, ENT_QUOTES) . '" style="margin-right:6px;margin-bottom:6px;" title="' . htmlspecialchars($fieldText, ENT_QUOTES) . '">' . htmlspecialchars($tokenText) . '</button>';
|
||||
}
|
||||
echo '<p class="description">Click a token to insert it at cursor in Subject/Message/PDF fields.</p>';
|
||||
}
|
||||
echo '</td></tr>';
|
||||
echo '<tr><th scope="row">Available Tokens</th><td colspan="3"><div id="ms-token-controls"></div><p class="description" id="ms-token-help">Select a data source to view tokens.</p></td></tr>';
|
||||
|
||||
echo '<tr><th scope="row"><label for="ms_subject">Subject</label></th><td><textarea id="ms_subject" name="Subject" rows="2" class="large-text">' . htmlspecialchars($form['Subject']) . '</textarea></td></tr>';
|
||||
echo '<tr><th scope="row"><label for="ms_message">Message (HTML)</label></th><td><textarea id="ms_message" name="Message" rows="10" class="large-text code">' . htmlspecialchars($form['Message']) . '</textarea></td></tr>';
|
||||
echo '<tr><th scope="row"><label for="ms_pdfa">PDF Attachment Template (HTML)</label></th><td><textarea id="ms_pdfa" name="PDFAttachment" rows="8" class="large-text code">' . htmlspecialchars($form['PDFAttachment']) . '</textarea></td></tr>';
|
||||
echo '<tr><th scope="row"><label for="ms_subject">Subject</label></th><td colspan="3"><textarea id="ms_subject" name="Subject" rows="2" class="large-text">' . htmlspecialchars($form['Subject']) . '</textarea></td></tr>';
|
||||
echo '<tr><th scope="row">Message</th><td><button type="button" class="button" id="ms-edit-message">Edit Message</button> <span class="description" id="ms-message-meta"></span><textarea id="ms_message" name="Message" rows="10" class="large-text code" style="display:none;">' . htmlspecialchars($form['Message']) . '</textarea><div id="ms-message-snippet" class="description" style="margin-top:8px;"></div></td>';
|
||||
echo '<th scope="row">PDF Attachment</th><td><button type="button" class="button" id="ms-edit-pdf">Edit PDF Attachment</button> <span class="description" id="ms-pdf-meta"></span><textarea id="ms_pdfa" name="PDFAttachment" rows="8" class="large-text code" style="display:none;">' . htmlspecialchars($form['PDFAttachment']) . '</textarea><div id="ms-pdf-snippet" class="description" style="margin-top:8px;"></div></td></tr>';
|
||||
|
||||
echo '<tr><th scope="row"><label for="ms_pdfname">PDF Filename Derived From</label></th><td><select id="ms_pdfname" name="PDFFilenameDerivedFrom"><option value="">(none)</option>';
|
||||
foreach ($pdfFieldOptions as $f) {
|
||||
$sel = $f === $form['PDFFilenameDerivedFrom'] ? ' selected' : '';
|
||||
echo '<option value="' . htmlspecialchars($f, ENT_QUOTES) . '"' . $sel . '>' . htmlspecialchars($f) . '</option>';
|
||||
}
|
||||
echo '</select></td></tr>';
|
||||
echo '</select></td>';
|
||||
echo '<th scope="row"><label for="ms_email_field">Recipient Email Field</label></th><td><select id="ms_email_field" name="RecipientEmailField"><option value="">Select field</option>';
|
||||
foreach ($pdfFieldOptions as $f) {
|
||||
$sel = $f === $form['RecipientEmailField'] ? ' selected' : '';
|
||||
echo '<option value="' . htmlspecialchars($f, ENT_QUOTES) . '"' . $sel . '>' . htmlspecialchars($f) . '</option>';
|
||||
}
|
||||
echo '</select><p class="description">Explicit field from data source row used as recipient email for run/send.</p></td></tr>';
|
||||
|
||||
echo '<tr><th scope="row">Attachments</th><td>';
|
||||
echo '<tr><th scope="row">Attachments</th><td colspan="3">';
|
||||
echo '<select id="ms_attachment_pick"><option value="">Select attachment</option>';
|
||||
foreach ($attachmentNames as $name) {
|
||||
echo '<option value="' . htmlspecialchars($name, ENT_QUOTES) . '">' . htmlspecialchars($name) . '</option>';
|
||||
|
|
@ -176,17 +162,33 @@ final class MailshotsAdminPage
|
|||
echo '<input type="hidden" name="AttachmentNamesCsv" id="ms_attachment_csv" value="' . htmlspecialchars(implode(', ', $selectedAttachments), ENT_QUOTES) . '">';
|
||||
echo '</td></tr>';
|
||||
|
||||
echo '<tr><th scope="row"><label for="ms_cc">CC</label></th><td><input class="regular-text" type="text" id="ms_cc" name="CC" value="' . htmlspecialchars($form['CC'], ENT_QUOTES) . '"></td></tr>';
|
||||
echo '<tr><th scope="row"><label for="ms_bcc">BCC</label></th><td><input class="regular-text" type="text" id="ms_bcc" name="BCC" value="' . htmlspecialchars($form['BCC'], ENT_QUOTES) . '"></td></tr>';
|
||||
echo '<tr><th scope="row"><label for="ms_reply">Reply-To</label></th><td><input class="regular-text" type="text" id="ms_reply" name="ReplyTo" value="' . htmlspecialchars($form['ReplyTo'], ENT_QUOTES) . '"></td></tr>';
|
||||
echo '<tr><th scope="row"><label for="ms_cc">CC</label></th><td><input class="regular-text" type="text" id="ms_cc" name="CC" value="' . htmlspecialchars($form['CC'], ENT_QUOTES) . '"></td>';
|
||||
echo '<th scope="row"><label for="ms_bcc">BCC</label></th><td><input class="regular-text" type="text" id="ms_bcc" name="BCC" value="' . htmlspecialchars($form['BCC'], ENT_QUOTES) . '"></td></tr>';
|
||||
echo '<tr><th scope="row"><label for="ms_reply">Reply-To</label></th><td colspan="3"><input class="regular-text" type="text" id="ms_reply" name="ReplyTo" value="' . htmlspecialchars($form['ReplyTo'], ENT_QUOTES) . '"></td></tr>';
|
||||
echo '</table>';
|
||||
|
||||
echo '<p><button type="submit" class="button button-primary">' . ($editId > 0 ? 'Update Mailshot' : 'Create Mailshot') . '</button> ';
|
||||
echo '<p><button type="submit" class="button button-primary">Save</button> ';
|
||||
echo '<button type="button" class="button" id="ms-close-editor">Quit</button> ';
|
||||
if ($editId > 0) {
|
||||
echo '<a class="button" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=feca-mailshots-mailshots')) . '">Discard Changes</a> ';
|
||||
}
|
||||
echo '<a class="button" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=feca-mailshots-mailshots')) . '">New</a></p>';
|
||||
echo '</p>';
|
||||
echo '</form>';
|
||||
|
||||
echo '<div id="ms-template-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:9999;">';
|
||||
echo '<div style="max-width:980px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">';
|
||||
echo '<h3 id="ms-template-title" style="margin-top:0;">Template Editor</h3>';
|
||||
echo '<p><button type="button" class="button button-small" id="ms-template-mode-visual">Visual</button> <button type="button" class="button button-small" id="ms-template-mode-code">Code</button></p>';
|
||||
echo '<div id="ms-template-token-controls" style="margin-bottom:10px;"></div>';
|
||||
echo '<div id="ms-template-pdf-assets" style="display:none;margin-bottom:10px;"><label>PDF Asset <select id="ms_pdf_asset_pick"><option value="">Select PDF asset</option></select></label> <button type="button" class="button button-small" id="ms_pdf_asset_insert">Insert Asset Token</button><p class="description">Inserts Twig helper token, example: {{ pdf_asset(\'logo_asset\') }}.</p></div>';
|
||||
if (function_exists('wp_enqueue_editor')) {
|
||||
wp_enqueue_editor();
|
||||
}
|
||||
echo '<textarea id="ms-template-html" rows="14" class="large-text code"></textarea>';
|
||||
echo '<textarea id="ms-template-code" rows="14" class="large-text code" style="display:none;font-family:monospace;"></textarea>';
|
||||
echo '<p class="description" style="margin-top:6px;">Use HTML for rich editing and Plain Text to view/edit exact HTML source (same underlying content).</p>';
|
||||
echo '<p><button type="button" class="button button-primary" id="ms-template-save">Save</button> <button type="button" class="button" id="ms-template-close">Quit</button></p>';
|
||||
echo '</div></div>';
|
||||
echo '</div></div>';
|
||||
|
||||
echo '<h2>Existing Mailshots</h2>';
|
||||
|
|
@ -213,53 +215,531 @@ final class MailshotsAdminPage
|
|||
|
||||
echo '<script>
|
||||
(function () {
|
||||
var lastTarget = null;
|
||||
var editorModal = document.getElementById("ms-editor-modal");
|
||||
var openNew = document.getElementById("ms-open-new");
|
||||
var closeEditor = document.getElementById("ms-close-editor");
|
||||
var editor = document.getElementById("mailshot-editor");
|
||||
if (!editor) { return; }
|
||||
|
||||
var adminApiBase = ' . json_encode($this->wp->adminUrl('admin-post.php?action=feca_mailshots_mailshots_api'), JSON_UNESCAPED_SLASHES) . ';
|
||||
var pdfApiBase = ' . json_encode($this->wp->adminUrl('admin-post.php?action=feca_mailshots_pdf_assets_api'), JSON_UNESCAPED_SLASHES) . ';
|
||||
var currentTokens = ' . json_encode(array_values(array_filter((array) ($tokenData['tokens'] ?? []), static fn($t): bool => is_array($t))), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';
|
||||
var lastTarget = null;
|
||||
var isDirty = false;
|
||||
var markDirty = function () { isDirty = true; };
|
||||
var confirmDiscard = function () {
|
||||
if (!isDirty) { return true; }
|
||||
return window.confirm("You have unsaved changes. Close without saving?");
|
||||
};
|
||||
|
||||
var dsSelect = document.getElementById("ms_ds");
|
||||
var tokenControls = document.getElementById("ms-token-controls");
|
||||
var tokenHelp = document.getElementById("ms-token-help");
|
||||
var pdfNameSelect = document.getElementById("ms_pdfname");
|
||||
var emailSelect = document.getElementById("ms_email_field");
|
||||
var subjectField = document.getElementById("ms_subject");
|
||||
var messageField = document.getElementById("ms_message");
|
||||
var pdfField = document.getElementById("ms_pdfa");
|
||||
|
||||
var templateModal = document.getElementById("ms-template-modal");
|
||||
var templateTitle = document.getElementById("ms-template-title");
|
||||
var templateClose = document.getElementById("ms-template-close");
|
||||
var templateSave = document.getElementById("ms-template-save");
|
||||
var templateHtml = document.getElementById("ms-template-html");
|
||||
var templateCode = document.getElementById("ms-template-code");
|
||||
var templateModeVisualBtn = document.getElementById("ms-template-mode-visual");
|
||||
var templateModeCodeBtn = document.getElementById("ms-template-mode-code");
|
||||
var templateTokenControls = document.getElementById("ms-template-token-controls");
|
||||
var templatePdfAssets = document.getElementById("ms-template-pdf-assets");
|
||||
var pdfAssetPick = document.getElementById("ms_pdf_asset_pick");
|
||||
var pdfAssetInsert = document.getElementById("ms_pdf_asset_insert");
|
||||
var openMessageEditor = document.getElementById("ms-edit-message");
|
||||
var openPdfEditor = document.getElementById("ms-edit-pdf");
|
||||
var messageSnippet = document.getElementById("ms-message-snippet");
|
||||
var messageMeta = document.getElementById("ms-message-meta");
|
||||
var pdfSnippet = document.getElementById("ms-pdf-snippet");
|
||||
var pdfMeta = document.getElementById("ms-pdf-meta");
|
||||
|
||||
var activeTemplateField = null;
|
||||
var templateDirty = false;
|
||||
var templateRichEditor = null;
|
||||
var templateCodeCanonicalRaw = "";
|
||||
var templateCodeEdited = false;
|
||||
var templateMode = "visual";
|
||||
|
||||
var cleanTokenLabel = function (value) {
|
||||
var text = String(value || "");
|
||||
text = text.replace(/{{\\s*|\\s*}}/g, "");
|
||||
text = text.replace(/fenedgec_members_/gi, "");
|
||||
return text.trim();
|
||||
};
|
||||
var stripHtml = function (html) {
|
||||
var s = String(html || "");
|
||||
s = s.replace(/<style[\\s\\S]*?<\\/style>/gi, " ");
|
||||
s = s.replace(/<script[\\s\\S]*?<\\/script>/gi, " ");
|
||||
s = s.replace(/<[^>]+>/g, " ");
|
||||
s = s.replace(/\\s+/g, " ").trim();
|
||||
return s;
|
||||
};
|
||||
var htmlEditorWrap = function () {
|
||||
return document.getElementById("wp-ms-template-html-wrap");
|
||||
};
|
||||
var getTemplateHtmlValue = function () {
|
||||
if (templateRichEditor) {
|
||||
return String(templateRichEditor.getContent({ format: "raw" }) || "");
|
||||
}
|
||||
return String((templateHtml && templateHtml.value) || "");
|
||||
};
|
||||
var setTemplateHtmlValue = function (value) {
|
||||
var htmlValue = String(value || "");
|
||||
if (templateRichEditor) {
|
||||
templateRichEditor.setContent(htmlValue);
|
||||
templateRichEditor.save();
|
||||
}
|
||||
if (templateHtml) {
|
||||
templateHtml.value = htmlValue;
|
||||
}
|
||||
};
|
||||
var scrubEditorArtifacts = function (value) {
|
||||
var htmlValue = String(value || "");
|
||||
htmlValue = htmlValue.replace(/<span\b[^>]*\bdata-mce-type=(["\'])bookmark\1[^>]*>[\s\S]*?<\/span>/gi, "");
|
||||
htmlValue = htmlValue.replace(/<span\b[^>]*\bclass=(["\'])mce_SELRES_(?:start|end)\1[^>]*>[\s\S]*?<\/span>/gi, "");
|
||||
htmlValue = htmlValue.replace(/\uFEFF/g, "");
|
||||
return htmlValue;
|
||||
};
|
||||
var prettyPrintHtml = function (value) {
|
||||
var src = String(value || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
var tokens = src.split(/(<[^>]+>)/g).filter(function (t) { return t !== ""; });
|
||||
var out = [];
|
||||
var indent = 0;
|
||||
var rawBlock = "";
|
||||
var pad = function (n) { return " ".repeat(Math.max(0, n)); };
|
||||
var voidTag = /^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/i;
|
||||
for (var i = 0; i < tokens.length; i++) {
|
||||
var token = String(tokens[i] || "");
|
||||
var trimmed = token.trim();
|
||||
if (!trimmed) { continue; }
|
||||
if (rawBlock && trimmed.toLowerCase().indexOf("</" + rawBlock) === 0) {
|
||||
indent = Math.max(0, indent - 1);
|
||||
out.push(pad(indent) + trimmed);
|
||||
rawBlock = "";
|
||||
continue;
|
||||
}
|
||||
if (rawBlock) {
|
||||
var rawLines = token.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
for (var rl = 0; rl < rawLines.length; rl++) {
|
||||
var line = rawLines[rl];
|
||||
if (line.trim() === "") {
|
||||
out.push("");
|
||||
} else {
|
||||
out.push(pad(indent) + line.trim());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (trimmed.indexOf("</") === 0) {
|
||||
indent = Math.max(0, indent - 1);
|
||||
}
|
||||
if (trimmed.charAt(0) === "<") {
|
||||
out.push(pad(indent) + trimmed);
|
||||
var openTag = /^<([a-zA-Z0-9:-]+)/.exec(trimmed);
|
||||
var closeSelf = /\/>\s*$/.test(trimmed);
|
||||
var isClose = /^<\//.test(trimmed);
|
||||
var isBang = /^<!/.test(trimmed) || /^<\?/.test(trimmed);
|
||||
if (!isClose && !isBang && openTag && !closeSelf && !voidTag.test(openTag[1])) {
|
||||
indent++;
|
||||
var tagLower = String(openTag[1] || "").toLowerCase();
|
||||
if (tagLower === "style" || tagLower === "script") {
|
||||
rawBlock = tagLower;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var textLines = token.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
for (var tl = 0; tl < textLines.length; tl++) {
|
||||
var tline = textLines[tl];
|
||||
if (tline.trim() === "") {
|
||||
out.push("");
|
||||
} else {
|
||||
out.push(pad(indent) + tline.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.join("\n").replace(/\n{3,}/g, "\n\n");
|
||||
};
|
||||
var syncTemplateTextareaFromEditor = function () {
|
||||
if (templateRichEditor) {
|
||||
templateRichEditor.save();
|
||||
}
|
||||
if (!templateHtml) { return; }
|
||||
var cleaned = scrubEditorArtifacts(templateHtml.value || "");
|
||||
if (cleaned !== templateHtml.value) {
|
||||
templateHtml.value = cleaned;
|
||||
if (templateRichEditor) {
|
||||
templateRichEditor.setContent(cleaned);
|
||||
templateRichEditor.save();
|
||||
}
|
||||
}
|
||||
};
|
||||
var setTemplateMode = function (mode) {
|
||||
templateMode = (mode === "code") ? "code" : "visual";
|
||||
var wrap = htmlEditorWrap();
|
||||
if (templateMode === "code") {
|
||||
syncTemplateTextareaFromEditor();
|
||||
templateCodeCanonicalRaw = scrubEditorArtifacts(String((templateHtml && templateHtml.value) || ""));
|
||||
templateCodeEdited = false;
|
||||
if (templateCode) {
|
||||
templateCode.value = prettyPrintHtml(templateCodeCanonicalRaw);
|
||||
templateCode.style.display = "block";
|
||||
}
|
||||
if (wrap) {
|
||||
wrap.style.display = "none";
|
||||
var tabs = wrap.querySelector(".wp-editor-tabs");
|
||||
if (tabs) { tabs.style.display = "none"; }
|
||||
} else if (templateHtml) {
|
||||
templateHtml.style.display = "none";
|
||||
}
|
||||
} else {
|
||||
var codeValue = templateCode ? String(templateCode.value || "") : "";
|
||||
if (templateCode && templateCodeEdited) {
|
||||
var cleaned = scrubEditorArtifacts(codeValue);
|
||||
if (templateRichEditor) {
|
||||
templateRichEditor.setContent(cleaned);
|
||||
templateRichEditor.save();
|
||||
}
|
||||
if (templateHtml) {
|
||||
templateHtml.value = cleaned;
|
||||
}
|
||||
}
|
||||
if (templateCode) {
|
||||
templateCode.style.display = "none";
|
||||
}
|
||||
if (wrap) {
|
||||
wrap.style.display = "block";
|
||||
var tabs2 = wrap.querySelector(".wp-editor-tabs");
|
||||
if (tabs2) { tabs2.style.display = "none"; }
|
||||
} else if (templateHtml) {
|
||||
templateHtml.style.display = "block";
|
||||
}
|
||||
}
|
||||
};
|
||||
var tokenFieldOptions = function (tokens) {
|
||||
var out = [];
|
||||
(tokens || []).forEach(function (t) {
|
||||
if (!t || typeof t !== "object") { return; }
|
||||
var field = String(t.field || "").trim();
|
||||
if (field && out.indexOf(field) === -1) { out.push(field); }
|
||||
});
|
||||
return out;
|
||||
};
|
||||
var repopulateFieldSelect = function (sel, values, emptyLabel) {
|
||||
if (!sel) { return; }
|
||||
var prev = sel.value || "";
|
||||
sel.innerHTML = "";
|
||||
var first = document.createElement("option");
|
||||
first.value = "";
|
||||
first.textContent = emptyLabel;
|
||||
sel.appendChild(first);
|
||||
values.forEach(function (v) {
|
||||
var o = document.createElement("option");
|
||||
o.value = v;
|
||||
o.textContent = v;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
if (prev && values.indexOf(prev) !== -1) {
|
||||
sel.value = prev;
|
||||
} else {
|
||||
sel.value = "";
|
||||
}
|
||||
};
|
||||
var insertIntoField = function (field, text) {
|
||||
if (!field || !text) { return; }
|
||||
var start = field.selectionStart || 0;
|
||||
var end = field.selectionEnd || 0;
|
||||
var value = field.value || "";
|
||||
field.value = value.substring(0, start) + text + value.substring(end);
|
||||
field.focus();
|
||||
var next = start + text.length;
|
||||
field.setSelectionRange(next, next);
|
||||
markDirty();
|
||||
};
|
||||
var insertToken = function (token) {
|
||||
if (!token) { return; }
|
||||
if (templateModal && templateModal.style.display === "block") {
|
||||
if (templateMode === "code" && templateCode) {
|
||||
insertIntoField(templateCode, token);
|
||||
templateCodeEdited = true;
|
||||
} else if (templateRichEditor) {
|
||||
templateRichEditor.insertContent(token);
|
||||
templateRichEditor.save();
|
||||
templateDirty = true;
|
||||
} else if (templateHtml) {
|
||||
insertIntoField(templateHtml, token);
|
||||
}
|
||||
templateDirty = true;
|
||||
return;
|
||||
}
|
||||
if (!lastTarget) {
|
||||
lastTarget = subjectField;
|
||||
}
|
||||
insertIntoField(lastTarget, token);
|
||||
};
|
||||
var renderTokenControlsInto = function (container) {
|
||||
if (!container) { return; }
|
||||
container.innerHTML = "";
|
||||
if (!currentTokens || currentTokens.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (currentTokens.length > 10) {
|
||||
var sel = document.createElement("select");
|
||||
sel.innerHTML = "<option value=\\"\\">Select field token</option>";
|
||||
currentTokens.forEach(function (t) {
|
||||
var token = String((t && t.token) || "");
|
||||
var label = cleanTokenLabel(String((t && (t.field || t.token)) || ""));
|
||||
var o = document.createElement("option");
|
||||
o.value = token;
|
||||
o.textContent = label || token;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
var btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "button button-small";
|
||||
btn.textContent = "Insert";
|
||||
btn.style.marginLeft = "6px";
|
||||
btn.addEventListener("click", function () { insertToken(sel.value || ""); });
|
||||
container.appendChild(sel);
|
||||
container.appendChild(btn);
|
||||
return;
|
||||
}
|
||||
currentTokens.forEach(function (t) {
|
||||
var token = String((t && t.token) || "");
|
||||
var label = cleanTokenLabel(String((t && (t.field || t.token)) || ""));
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "button button-small";
|
||||
b.style.marginRight = "6px";
|
||||
b.style.marginBottom = "6px";
|
||||
b.textContent = label || token;
|
||||
b.title = token;
|
||||
b.addEventListener("click", function () { insertToken(token); });
|
||||
container.appendChild(b);
|
||||
});
|
||||
};
|
||||
var refreshTokenUi = function () {
|
||||
renderTokenControlsInto(tokenControls);
|
||||
renderTokenControlsInto(templateTokenControls);
|
||||
var fields = tokenFieldOptions(currentTokens);
|
||||
repopulateFieldSelect(pdfNameSelect, fields, "(none)");
|
||||
repopulateFieldSelect(emailSelect, fields, "Select field");
|
||||
if (tokenHelp) {
|
||||
tokenHelp.textContent = fields.length ? "Insert tokens into Subject, Message or PDF Attachment editors." : "Select a data source to view tokens.";
|
||||
}
|
||||
};
|
||||
var fetchTokensForDataSource = function (name) {
|
||||
if (!name) {
|
||||
currentTokens = [];
|
||||
refreshTokenUi();
|
||||
return;
|
||||
}
|
||||
fetch(adminApiBase + "&op=tokens&data_source=" + encodeURIComponent(name), { credentials: "same-origin" })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) {
|
||||
currentTokens = (j && j.tokens && Array.isArray(j.tokens)) ? j.tokens : [];
|
||||
refreshTokenUi();
|
||||
})
|
||||
.catch(function () {
|
||||
currentTokens = [];
|
||||
refreshTokenUi();
|
||||
if (tokenHelp) { tokenHelp.textContent = "Unable to load tokens for selected data source."; }
|
||||
});
|
||||
};
|
||||
var updateTemplateMeta = function () {
|
||||
if (messageField && messageMeta) { messageMeta.textContent = (messageField.value || "").length + " chars"; }
|
||||
if (pdfField && pdfMeta) { pdfMeta.textContent = (pdfField.value || "").length + " chars"; }
|
||||
if (messageSnippet && messageField) {
|
||||
var msg = stripHtml(messageField.value || "");
|
||||
messageSnippet.textContent = msg ? msg.substring(0, 160) + (msg.length > 160 ? "..." : "") : "No message content yet.";
|
||||
}
|
||||
if (pdfSnippet && pdfField) {
|
||||
var pdf = stripHtml(pdfField.value || "");
|
||||
pdfSnippet.textContent = pdf ? pdf.substring(0, 160) + (pdf.length > 160 ? "..." : "") : "No PDF template content yet.";
|
||||
}
|
||||
};
|
||||
var ensureTemplateRichEditor = function () {
|
||||
if (!templateHtml || templateRichEditor) { return; }
|
||||
if (!(window.wp && wp.editor && typeof wp.editor.initialize === "function")) { return; }
|
||||
wp.editor.initialize("ms-template-html", {
|
||||
tinymce: {
|
||||
wpautop: false,
|
||||
menubar: false,
|
||||
toolbar1: "bold italic bullist numlist | link unlink | undo redo",
|
||||
forced_root_block: false,
|
||||
verify_html: false,
|
||||
valid_elements: "*[*]",
|
||||
extended_valid_elements: "style[type|media]",
|
||||
entity_encoding: "raw",
|
||||
convert_urls: false
|
||||
},
|
||||
quicktags: true,
|
||||
mediaButtons: false
|
||||
});
|
||||
if (window.tinymce && typeof window.tinymce.get === "function") {
|
||||
templateRichEditor = window.tinymce.get("ms-template-html");
|
||||
}
|
||||
if (templateRichEditor) {
|
||||
templateRichEditor.on("change input keyup setcontent", function () {
|
||||
templateRichEditor.save();
|
||||
templateDirty = true;
|
||||
});
|
||||
templateRichEditor.on("focus", function () { lastTarget = templateHtml; });
|
||||
}
|
||||
};
|
||||
var normalizeAssetName = function (name) {
|
||||
var n = String(name || "").toLowerCase();
|
||||
n = n.replace(/[^a-z0-9_]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
|
||||
return n;
|
||||
};
|
||||
var loadPdfAssets = function () {
|
||||
if (!pdfAssetPick) { return; }
|
||||
pdfAssetPick.innerHTML = "<option value=\\"\\">Loading...</option>";
|
||||
fetch(pdfApiBase + "&op=list", { credentials: "same-origin" })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) {
|
||||
var items = (j && j.items && Array.isArray(j.items)) ? j.items : [];
|
||||
pdfAssetPick.innerHTML = "<option value=\\"\\">Select PDF asset</option>";
|
||||
items.forEach(function (row) {
|
||||
var name = String((row && row.name) || "").trim();
|
||||
if (!name) { return; }
|
||||
var o = document.createElement("option");
|
||||
o.value = name;
|
||||
o.textContent = name;
|
||||
pdfAssetPick.appendChild(o);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
pdfAssetPick.innerHTML = "<option value=\\"\\">Unable to load assets</option>";
|
||||
});
|
||||
};
|
||||
var openTemplateEditor = function (field, title, isPdf) {
|
||||
if (!field || !templateModal) { return; }
|
||||
ensureTemplateRichEditor();
|
||||
activeTemplateField = field;
|
||||
templateTitle.textContent = title;
|
||||
setTemplateHtmlValue(field.value || "");
|
||||
templateCodeCanonicalRaw = scrubEditorArtifacts(String(field.value || ""));
|
||||
templateCodeEdited = false;
|
||||
templateDirty = false;
|
||||
setTemplateMode("visual");
|
||||
templatePdfAssets.style.display = isPdf ? "block" : "none";
|
||||
if (isPdf) { loadPdfAssets(); }
|
||||
templateModal.style.display = "block";
|
||||
};
|
||||
var closeTemplateEditor = function () {
|
||||
if (!templateModal) { return; }
|
||||
if (templateDirty && !window.confirm("Discard changes in template editor?")) { return; }
|
||||
templateModal.style.display = "none";
|
||||
};
|
||||
|
||||
editor.querySelectorAll("input,select,textarea").forEach(function (el) {
|
||||
el.addEventListener("input", markDirty);
|
||||
el.addEventListener("change", markDirty);
|
||||
});
|
||||
editor.addEventListener("submit", function () { isDirty = false; });
|
||||
|
||||
if (subjectField) {
|
||||
subjectField.addEventListener("focus", function () { lastTarget = subjectField; });
|
||||
}
|
||||
if (dsSelect) {
|
||||
dsSelect.addEventListener("change", function () {
|
||||
fetchTokensForDataSource(dsSelect.value || "");
|
||||
markDirty();
|
||||
});
|
||||
}
|
||||
|
||||
if (openMessageEditor && messageField) {
|
||||
openMessageEditor.addEventListener("click", function () {
|
||||
openTemplateEditor(messageField, "Edit Message", false);
|
||||
});
|
||||
}
|
||||
if (openPdfEditor && pdfField) {
|
||||
openPdfEditor.addEventListener("click", function () {
|
||||
openTemplateEditor(pdfField, "Edit PDF Attachment", true);
|
||||
});
|
||||
}
|
||||
if (templateClose) { templateClose.addEventListener("click", closeTemplateEditor); }
|
||||
if (templateHtml) {
|
||||
templateHtml.addEventListener("input", function () {
|
||||
templateDirty = true;
|
||||
});
|
||||
templateHtml.addEventListener("focus", function () { lastTarget = templateHtml; });
|
||||
}
|
||||
if (templateCode) {
|
||||
templateCode.addEventListener("input", function () {
|
||||
templateDirty = true;
|
||||
templateCodeEdited = true;
|
||||
});
|
||||
templateCode.addEventListener("focus", function () { lastTarget = templateCode; });
|
||||
}
|
||||
if (templateModeVisualBtn) { templateModeVisualBtn.addEventListener("click", function () { setTemplateMode("visual"); }); }
|
||||
if (templateModeCodeBtn) { templateModeCodeBtn.addEventListener("click", function () { setTemplateMode("code"); }); }
|
||||
if (templateSave) {
|
||||
templateSave.addEventListener("click", function () {
|
||||
if (!activeTemplateField) { return; }
|
||||
var valueToSave = "";
|
||||
if (templateMode === "code" && templateCode) {
|
||||
valueToSave = scrubEditorArtifacts(String(templateCode.value || ""));
|
||||
if (templateHtml) { templateHtml.value = valueToSave; }
|
||||
if (templateRichEditor) {
|
||||
templateRichEditor.setContent(valueToSave);
|
||||
templateRichEditor.save();
|
||||
}
|
||||
} else {
|
||||
syncTemplateTextareaFromEditor();
|
||||
valueToSave = scrubEditorArtifacts(getTemplateHtmlValue());
|
||||
if (templateHtml) { templateHtml.value = valueToSave; }
|
||||
}
|
||||
activeTemplateField.value = valueToSave;
|
||||
templateDirty = false;
|
||||
templateModal.style.display = "none";
|
||||
updateTemplateMeta();
|
||||
markDirty();
|
||||
});
|
||||
}
|
||||
if (pdfAssetInsert && pdfAssetPick) {
|
||||
pdfAssetInsert.addEventListener("click", function () {
|
||||
var raw = pdfAssetPick.value || "";
|
||||
if (!raw) { return; }
|
||||
var token = "{{ pdf_asset(\\"" + normalizeAssetName(raw) + "\\") }}";
|
||||
insertToken(token);
|
||||
});
|
||||
}
|
||||
|
||||
if (openNew && editorModal) {
|
||||
openNew.addEventListener("click", function () {
|
||||
if (!confirmDiscard()) { return; }
|
||||
var idInput = document.getElementById("ms-editor-id");
|
||||
if (idInput) { idInput.value = ""; }
|
||||
["ms_purpose","ms_ds","ms_subject","ms_message","ms_pdfa","ms_cc","ms_bcc","ms_reply","ms_pdfname"].forEach(function (id) {
|
||||
["ms_purpose","ms_ds","ms_subject","ms_message","ms_pdfa","ms_cc","ms_bcc","ms_reply","ms_pdfname","ms_email_field"].forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) { el.value = ""; }
|
||||
});
|
||||
var list = document.getElementById("ms_attachment_list");
|
||||
if (list) { list.innerHTML = ""; }
|
||||
var csv = document.getElementById("ms_attachment_csv");
|
||||
if (csv) { csv.value = ""; }
|
||||
currentTokens = [];
|
||||
refreshTokenUi();
|
||||
updateTemplateMeta();
|
||||
isDirty = false;
|
||||
editorModal.style.display = "block";
|
||||
});
|
||||
}
|
||||
if (closeEditor && editorModal) {
|
||||
closeEditor.addEventListener("click", function () {
|
||||
if (!confirmDiscard()) { return; }
|
||||
editorModal.style.display = "none";
|
||||
});
|
||||
}
|
||||
var targets = ["ms_subject", "ms_message", "ms_pdfa"];
|
||||
targets.forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) { return; }
|
||||
el.addEventListener("focus", function () { lastTarget = el; });
|
||||
});
|
||||
var tokenButtons = editor.querySelectorAll(".ms-token-btn");
|
||||
tokenButtons.forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
var token = btn.getAttribute("data-token") || "";
|
||||
if (!lastTarget) {
|
||||
lastTarget = document.getElementById("ms_message");
|
||||
}
|
||||
if (!lastTarget) { return; }
|
||||
var start = lastTarget.selectionStart || 0;
|
||||
var end = lastTarget.selectionEnd || 0;
|
||||
var value = lastTarget.value || "";
|
||||
lastTarget.value = value.substring(0, start) + token + value.substring(end);
|
||||
lastTarget.focus();
|
||||
var next = start + token.length;
|
||||
lastTarget.setSelectionRange(next, next);
|
||||
});
|
||||
});
|
||||
|
||||
var list = document.getElementById("ms_attachment_list");
|
||||
var pick = document.getElementById("ms_attachment_pick");
|
||||
|
|
@ -277,7 +757,7 @@ final class MailshotsAdminPage
|
|||
list.addEventListener("click", function (ev) {
|
||||
if (ev.target && ev.target.classList.contains("ms-att-remove")) {
|
||||
var li = ev.target.closest("li");
|
||||
if (li) { li.remove(); sync(); }
|
||||
if (li) { li.remove(); sync(); markDirty(); }
|
||||
}
|
||||
});
|
||||
addBtn.addEventListener("click", function () {
|
||||
|
|
@ -298,12 +778,20 @@ final class MailshotsAdminPage
|
|||
li.appendChild(rm);
|
||||
list.appendChild(li);
|
||||
sync();
|
||||
markDirty();
|
||||
});
|
||||
sync();
|
||||
}
|
||||
|
||||
refreshTokenUi();
|
||||
updateTemplateMeta();
|
||||
|
||||
var hasEdit = ' . ($editId > 0 ? 'true' : 'false') . ';
|
||||
if (hasEdit && editorModal) {
|
||||
var hasResult = ' . ($result !== null ? 'true' : 'false') . ';
|
||||
var resultOk = ' . (!empty($result['ok']) ? 'true' : 'false') . ';
|
||||
if (hasEdit && editorModal && (!hasResult || !resultOk)) {
|
||||
editorModal.style.display = "block";
|
||||
isDirty = false;
|
||||
}
|
||||
})();
|
||||
</script>';
|
||||
|
|
@ -332,6 +820,7 @@ final class MailshotsAdminPage
|
|||
'PDFAttachment' => (string) ($this->wp->requestParam('PDFAttachment', '') ?? ''),
|
||||
'AttachmentNames' => json_encode($attachmentNames, JSON_UNESCAPED_SLASHES),
|
||||
'PDFFilenameDerivedFrom' => (string) ($this->wp->requestParam('PDFFilenameDerivedFrom', '') ?? ''),
|
||||
'RecipientEmailField' => (string) ($this->wp->requestParam('RecipientEmailField', '') ?? ''),
|
||||
'ReplyTo' => (string) ($this->wp->requestParam('ReplyTo', '') ?? ''),
|
||||
];
|
||||
$result = $this->service()->save($id, $payload);
|
||||
|
|
@ -426,6 +915,7 @@ final class MailshotsAdminPage
|
|||
'PDFAttachment' => (string) ($this->wp->requestParam('PDFAttachment', '') ?? ''),
|
||||
'AttachmentNames' => (string) ($this->wp->requestParam('AttachmentNames', '[]') ?? '[]'),
|
||||
'PDFFilenameDerivedFrom' => (string) ($this->wp->requestParam('PDFFilenameDerivedFrom', '') ?? ''),
|
||||
'RecipientEmailField' => (string) ($this->wp->requestParam('RecipientEmailField', '') ?? ''),
|
||||
'ReplyTo' => (string) ($this->wp->requestParam('ReplyTo', '') ?? ''),
|
||||
];
|
||||
$this->wp->sendJson($this->service()->save($id, $payload));
|
||||
|
|
@ -450,10 +940,22 @@ final class MailshotsAdminPage
|
|||
return ($this->serviceFactory)();
|
||||
}
|
||||
|
||||
private function cleanTokenLabel(string $value): string
|
||||
{
|
||||
$label = trim($value);
|
||||
if ($label === '') {
|
||||
return $label;
|
||||
}
|
||||
$label = str_replace(['{{', '}}'], '', $label);
|
||||
$label = preg_replace('/\bfenedgec_members_/i', '', $label) ?? $label;
|
||||
return trim($label);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
private function result(): ?array
|
||||
{
|
||||
$raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null);
|
||||
$this->wp->deleteOption(self::RESULT_OPTION_KEY);
|
||||
return is_array($raw) ? $raw : null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ final class PdfAssetsAdminPage
|
|||
echo '<div id="pdf-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;">';
|
||||
echo '<div style="max-width:980px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">';
|
||||
echo '<p style="text-align:right;margin:0;"><button type="button" class="button" id="pdf-close-editor">Close</button></p>';
|
||||
echo '<form method="post" enctype="multipart/form-data" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
|
||||
echo '<form method="post" enctype="multipart/form-data" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;" id="pdf-editor-form">';
|
||||
echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit PDF Asset' : 'New PDF Asset') . '</h2>';
|
||||
echo '<input type="hidden" name="action" value="feca_mailshots_pdf_assets_ui_save">';
|
||||
if ($editId > 0) {
|
||||
|
|
@ -131,7 +131,7 @@ final class PdfAssetsAdminPage
|
|||
echo '<tr><td colspan="7">No PDF assets found.</td></tr>';
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
echo '<script>(function(){var modal=document.getElementById("pdf-editor-modal");var openBtn=document.getElementById("pdf-open-new");var closeBtn=document.getElementById("pdf-close-editor");if(openBtn&&modal){openBtn.addEventListener("click",function(){var id=document.getElementById("pdf-editor-id");if(id){id.value="";}["pdf_name","pdf_file_name","pdf_mime","pdf_width","pdf_height","pdf_base64"].forEach(function(x){var el=document.getElementById(x);if(el){el.value="";}}var j=document.getElementById("pdf_just");if(j){j.value="in-place";}modal.style.display="block";});}if(closeBtn&&modal){closeBtn.addEventListener("click",function(){modal.style.display="none";});}var hasEdit=' . ($editId > 0 ? 'true' : 'false') . ';if(hasEdit&&modal){modal.style.display="block";}})();</script>';
|
||||
echo '<script>(function(){var modal=document.getElementById("pdf-editor-modal");var openBtn=document.getElementById("pdf-open-new");var closeBtn=document.getElementById("pdf-close-editor");var form=document.getElementById("pdf-editor-form");var isDirty=false;function confirmDiscard(){if(!isDirty){return true;}return window.confirm("You have unsaved changes. Close without saving?");}if(form){form.querySelectorAll("input,select,textarea").forEach(function(el){el.addEventListener("input",function(){isDirty=true;});el.addEventListener("change",function(){isDirty=true;});});form.addEventListener("submit",function(){isDirty=false;});}if(openBtn&&modal){openBtn.addEventListener("click",function(){if(!confirmDiscard()){return;}var id=document.getElementById("pdf-editor-id");if(id){id.value="";}["pdf_name","pdf_file_name","pdf_mime","pdf_width","pdf_height","pdf_base64"].forEach(function(x){var el=document.getElementById(x);if(el){el.value="";}}var j=document.getElementById("pdf_just");if(j){j.value="in-place";}isDirty=false;modal.style.display="block";});}if(closeBtn&&modal){closeBtn.addEventListener("click",function(){if(!confirmDiscard()){return;}modal.style.display="none";});}var hasEdit=' . ($editId > 0 ? 'true' : 'false') . ';var hasResult=' . ($result !== null ? 'true' : 'false') . ';var resultOk=' . (!empty($result['ok']) ? 'true' : 'false') . ';if(hasEdit&&modal&&(!hasResult||!resultOk)){modal.style.display="block";isDirty=false;}})();</script>';
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
|
|
@ -250,6 +250,7 @@ final class PdfAssetsAdminPage
|
|||
private function result(): ?array
|
||||
{
|
||||
$raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null);
|
||||
$this->wp->deleteOption(self::RESULT_OPTION_KEY);
|
||||
return is_array($raw) ? $raw : null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -432,6 +432,7 @@ final class ProfileAdminPage
|
|||
private function testResult(int $uid): ?array
|
||||
{
|
||||
$raw = $this->wp->getOption($this->testResultOptionKey($uid), null);
|
||||
$this->wp->deleteOption($this->testResultOptionKey($uid));
|
||||
if (!is_array($raw) || !isset($raw['kind']) || !isset($raw['ok']) || !isset($raw['messages']) || !is_array($raw['messages'])) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -230,6 +230,7 @@ final class RunMailshotAdminPage
|
|||
private function result(): ?array
|
||||
{
|
||||
$raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null);
|
||||
$this->wp->deleteOption(self::RESULT_OPTION_KEY);
|
||||
return is_array($raw) ? $raw : null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -228,6 +228,7 @@ final class SetupAdminPage
|
|||
private function testResult(): ?array
|
||||
{
|
||||
$raw = $this->wp->getOption(self::TEST_RESULT_OPTION_KEY, null);
|
||||
$this->wp->deleteOption(self::TEST_RESULT_OPTION_KEY);
|
||||
if (!is_array($raw) || !isset($raw['ok']) || !isset($raw['messages']) || !is_array($raw['messages'])) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -170,6 +170,9 @@ final class DataSourceService
|
|||
$schemas[] = $membersSchema;
|
||||
}
|
||||
|
||||
// Shared-hosting DB users often cannot read information_schema.
|
||||
// Keep working with the configured members DB schema in that case.
|
||||
try {
|
||||
$sql = 'SELECT schema_name FROM information_schema.schemata ORDER BY schema_name';
|
||||
$rows = $this->router->membersPdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as $row) {
|
||||
|
|
@ -179,6 +182,9 @@ final class DataSourceService
|
|||
}
|
||||
$schemas[] = $name;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// no-op: fallback is the configured members schema above
|
||||
}
|
||||
|
||||
$schemas = array_values(array_unique($schemas));
|
||||
sort($schemas, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
|
|
@ -192,10 +198,18 @@ final class DataSourceService
|
|||
if ($schema === '') {
|
||||
return [];
|
||||
}
|
||||
$sql = 'SELECT table_name FROM information_schema.tables WHERE table_schema = :schema ORDER BY table_name';
|
||||
$stmt = $this->router->membersPdo()->prepare($sql);
|
||||
$stmt->execute(['schema' => $schema]);
|
||||
return array_map(static fn(array $row): string => (string) ($row['table_name'] ?? ''), $stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
// Shared-hosting users may not have information_schema access.
|
||||
// Use explicit SHOW TABLES for the selected schema.
|
||||
$sql = 'SHOW TABLES FROM `' . str_replace('`', '``', $schema) . '`';
|
||||
$rows = $this->router->membersPdo()->query($sql)->fetchAll(PDO::FETCH_NUM);
|
||||
$tables = [];
|
||||
foreach ($rows as $row) {
|
||||
if (isset($row[0]) && trim((string) $row[0]) !== '') {
|
||||
$tables[] = (string) $row[0];
|
||||
}
|
||||
}
|
||||
sort($tables, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
return $tables;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ final class DslCompiler
|
|||
|
||||
$whereSql = $whereParts === [] ? '' : ' WHERE ' . implode(' AND ', $whereParts);
|
||||
|
||||
$sql = 'SELECT * FROM ' . $from;
|
||||
$selectSql = count($sources) > 1 ? $this->buildUniqueSelectProjection($sources) : '*';
|
||||
$sql = 'SELECT ' . $selectSql . ' FROM ' . $from;
|
||||
if ($joins !== []) {
|
||||
$sql .= ' ' . implode(' ', $joins);
|
||||
}
|
||||
|
|
@ -134,14 +135,18 @@ final class DslCompiler
|
|||
return $this->alias('renewals') . '.`status` = \'pending\'';
|
||||
}
|
||||
if ($name === 'fen1-contact') {
|
||||
return $this->alias('contacts') . '.`FENContact1` = 1';
|
||||
return $this->alias('contacts') . '.`is_fen_1` = 1';
|
||||
}
|
||||
if ($name === 'fen2-contact') {
|
||||
return $this->alias('contacts') . '.`FENContact2` = 1';
|
||||
if ($name === 'primary-contact') {
|
||||
return $this->alias('contacts') . '.`is_contact_1` = 1';
|
||||
}
|
||||
if ($name === 'member-or-affiliate-or-parish-council') {
|
||||
$acc = $this->alias('accounts');
|
||||
return sprintf("(%s.`Type` IN ('Member','Affiliate') OR %s.`Name` LIKE '%%Parish Council%%')", $acc, $acc);
|
||||
return sprintf(
|
||||
"(%s.`account_type_id` IN (SELECT id FROM `picklist_account_type` WHERE value IN ('Member','Affiliate')) OR %s.`name` LIKE '%%Parish Council%%')",
|
||||
$acc,
|
||||
$acc
|
||||
);
|
||||
}
|
||||
|
||||
throw new AppError('dsl_compile', 'Unknown filter', ['filter' => $name]);
|
||||
|
|
@ -178,4 +183,20 @@ final class DslCompiler
|
|||
}
|
||||
return '`' . $source . '`';
|
||||
}
|
||||
|
||||
/** @param list<string> $sources */
|
||||
private function buildUniqueSelectProjection(array $sources): string
|
||||
{
|
||||
$parts = [];
|
||||
foreach ($sources as $source) {
|
||||
$alias = $this->alias($source);
|
||||
foreach ($this->metadata->sourceFields($source) as $field) {
|
||||
$parts[] = $alias . '.`' . $field . '` AS `' . $source . '.' . $field . '`';
|
||||
}
|
||||
}
|
||||
if ($parts === []) {
|
||||
throw new AppError('dsl_compile', 'No fields available for selected sources');
|
||||
}
|
||||
return implode(', ', $parts);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ final class DslValidator
|
|||
private array $filterSourceRequirements = [
|
||||
'selected-renewal' => ['renewals'],
|
||||
'pending-renewal' => ['renewals'],
|
||||
'primary-contact' => ['contacts'],
|
||||
'fen1-contact' => ['contacts'],
|
||||
'fen2-contact' => ['contacts'],
|
||||
'member-or-affiliate-or-parish-council' => ['accounts'],
|
||||
];
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ final class DslValidator
|
|||
}
|
||||
|
||||
if (!$this->hasAnyEmailField($sources)) {
|
||||
$warnings[] = 'Data Source does not have an email field, it cannot be used for a Mailshot';
|
||||
$warnings[] = 'Data Source has no obvious email field; select an explicit Recipient Email Field on each Mailshot.';
|
||||
}
|
||||
|
||||
foreach ($ast['where'] as $predicate) {
|
||||
|
|
@ -97,6 +97,10 @@ final class DslValidator
|
|||
|
||||
if ($predicate['type'] === 'filter') {
|
||||
$name = $predicate['name'];
|
||||
if ($name === 'fen2-contact') {
|
||||
$errors[] = 'Filter fen2-contact is not supported. Use fen1-contact.';
|
||||
return;
|
||||
}
|
||||
if (!isset($this->filterSourceRequirements[$name])) {
|
||||
$errors[] = 'Unknown filter: ' . $name;
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -58,13 +58,14 @@ final class MailshotRunService
|
|||
$limit = max(1, min(500, $limit));
|
||||
$rows = array_slice($rows, 0, $limit);
|
||||
$out = [];
|
||||
$recipientEmailField = trim((string) ($mailshot['RecipientEmailField'] ?? ''));
|
||||
foreach (array_values($rows) as $i => $row) {
|
||||
[$keyField, $keyValue] = $this->detectRecipientKey($row, $i);
|
||||
$out[] = [
|
||||
'index' => $i,
|
||||
'recipient_key_field' => $keyField,
|
||||
'recipient_key' => $keyValue,
|
||||
'recipient_email' => $this->detectEmail($row),
|
||||
'recipient_email' => $this->detectEmail($row, $recipientEmailField),
|
||||
'row' => $row,
|
||||
];
|
||||
}
|
||||
|
|
@ -119,6 +120,7 @@ final class MailshotRunService
|
|||
|
||||
$cc = $this->splitAddresses((string) ($mailshot['CC'] ?? ''));
|
||||
$bcc = $this->splitAddresses((string) ($mailshot['BCC'] ?? ''));
|
||||
$recipientEmailField = trim((string) ($mailshot['RecipientEmailField'] ?? ''));
|
||||
|
||||
$attemptId = 'test_' . $mailshotId . '_' . $recipientIndex . '_' . gmdate('YmdHis');
|
||||
|
||||
|
|
@ -268,18 +270,20 @@ final class MailshotRunService
|
|||
{
|
||||
$cc = $this->splitAddresses((string) ($mailshot['CC'] ?? ''));
|
||||
$bcc = $this->splitAddresses((string) ($mailshot['BCC'] ?? ''));
|
||||
$recipientEmailField = trim((string) ($mailshot['RecipientEmailField'] ?? ''));
|
||||
|
||||
$counters = ['attempted' => 0, 'sent' => 0, 'failed' => 0, 'warnings' => 0, 'skipped' => 0];
|
||||
$log = [];
|
||||
|
||||
foreach (array_values($rows) as $index => $row) {
|
||||
$counters['attempted']++;
|
||||
$recipientEmail = $this->detectEmail($row);
|
||||
$recipientEmail = $this->detectEmail($row, $recipientEmailField);
|
||||
[$recipientKeyField, $recipientKey] = $this->detectRecipientKey($row, $index);
|
||||
|
||||
if ($recipientEmail === null) {
|
||||
$counters['failed']++;
|
||||
$log[] = ['recipient_key' => $recipientKey, 'status' => 'failed', 'error' => 'No email field found in recipient row.'];
|
||||
$error = $recipientEmailField !== '' ? ('Recipient email field "' . $recipientEmailField . '" is missing or invalid in recipient row.') : 'No email field found in recipient row.';
|
||||
$log[] = ['recipient_key' => $recipientKey, 'status' => 'failed', 'error' => $error];
|
||||
$this->lastRun->create([
|
||||
'mailshot_id' => $mailshotId,
|
||||
'data_source' => (string) ($mailshot['DataSource'] ?? ''),
|
||||
|
|
@ -288,7 +292,7 @@ final class MailshotRunService
|
|||
'recipient_key_field' => $recipientKeyField,
|
||||
'recipient_email_last' => null,
|
||||
'status' => 'failed',
|
||||
'error_message' => 'No email field found in recipient row.',
|
||||
'error_message' => $error,
|
||||
'attempt_count' => $freshRun ? 1 : 2,
|
||||
]);
|
||||
continue;
|
||||
|
|
@ -404,8 +408,28 @@ final class MailshotRunService
|
|||
}
|
||||
|
||||
/** @param array<string,mixed> $row */
|
||||
private function detectEmail(array $row): ?string
|
||||
private function detectEmail(array $row, string $preferredField = ''): ?string
|
||||
{
|
||||
if ($preferredField !== '') {
|
||||
$candidates = [$preferredField];
|
||||
if (str_contains($preferredField, '.')) {
|
||||
$parts = explode('.', $preferredField);
|
||||
$last = (string) end($parts);
|
||||
if ($last !== '') {
|
||||
$candidates[] = $last;
|
||||
}
|
||||
}
|
||||
foreach ($candidates as $candidate) {
|
||||
foreach ($row as $k => $val) {
|
||||
if (strcasecmp((string) $k, $candidate) === 0) {
|
||||
$v = trim((string) $val);
|
||||
if ($v !== '' && filter_var($v, FILTER_VALIDATE_EMAIL)) {
|
||||
return $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach (['Email', 'email', 'contact_email', 'account_email', 'recipient_email', 'recipient_email_last'] as $key) {
|
||||
if (isset($row[$key])) {
|
||||
$v = trim((string) $row[$key]);
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ final class MailshotService
|
|||
{
|
||||
$purpose = trim((string) ($payload['Purpose'] ?? ''));
|
||||
$dataSource = trim((string) ($payload['DataSource'] ?? ''));
|
||||
$recipientEmailField = trim((string) ($payload['RecipientEmailField'] ?? ''));
|
||||
$subject = trim((string) ($payload['Subject'] ?? ''));
|
||||
$message = trim((string) ($payload['Message'] ?? ''));
|
||||
|
||||
|
|
@ -70,6 +71,20 @@ final class MailshotService
|
|||
if ($message === '') {
|
||||
$errors[] = 'Message is required.';
|
||||
}
|
||||
if ($recipientEmailField === '') {
|
||||
$errors[] = 'RecipientEmailField is required.';
|
||||
} elseif ($dataSource !== '') {
|
||||
$tokens = $this->tokenInsertionData($dataSource);
|
||||
$fields = [];
|
||||
foreach (($tokens['tokens'] ?? []) as $token) {
|
||||
if (is_array($token) && isset($token['field'])) {
|
||||
$fields[] = (string) $token['field'];
|
||||
}
|
||||
}
|
||||
if ($fields !== [] && !in_array($recipientEmailField, $fields, true)) {
|
||||
$errors[] = 'RecipientEmailField must be one of the selected DataSource fields.';
|
||||
}
|
||||
}
|
||||
|
||||
$attachmentNames = $this->normalizeAttachmentNames($payload['AttachmentNames'] ?? null);
|
||||
|
||||
|
|
@ -88,6 +103,7 @@ final class MailshotService
|
|||
'AttachmentNames' => json_encode($attachmentNames, JSON_UNESCAPED_SLASHES),
|
||||
'PDFFilenameDerivedFrom' => trim((string) ($payload['PDFFilenameDerivedFrom'] ?? '')),
|
||||
'ReplyTo' => trim((string) ($payload['ReplyTo'] ?? '')),
|
||||
'RecipientEmailField' => $recipientEmailField,
|
||||
];
|
||||
|
||||
if ($id === null) {
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@ final class DslParser
|
|||
$filters = [
|
||||
'selected-renewal',
|
||||
'pending-renewal',
|
||||
'primary-contact',
|
||||
'fen1-contact',
|
||||
'fen2-contact',
|
||||
'member-or-affiliate-or-parish-council',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||
namespace FecaMailshots\Infrastructure;
|
||||
|
||||
use FecaMailshots\Application\SourceMetadataProvider;
|
||||
use PDO;
|
||||
|
||||
final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
|
||||
{
|
||||
|
|
@ -21,18 +20,21 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
|
|||
{
|
||||
$this->router = $router;
|
||||
$this->builtInFields = [
|
||||
'contacts' => ['ID', 'Accountid', 'First', 'Last', 'Email', 'FENContact1', 'FENContact2'],
|
||||
'accounts' => ['ID', 'Name', 'Type', 'Email'],
|
||||
'contacts' => ['id', 'account_id', 'first_name', 'last_name', 'contact_email_1', 'contact_email_2', 'is_contact_1', 'is_contact_2', 'is_fen_1', 'is_fen_2', 'is_public_contact'],
|
||||
'accounts' => ['id', 'name', 'account_type_id'],
|
||||
'renewals' => ['id', 'account_id', 'status', 'selected', 'email'],
|
||||
'grants' => ['id', 'account_id', 'name', 'amount', 'applied_for', 'total_cost', 'grant_date', 'notes'],
|
||||
];
|
||||
|
||||
$this->joinMap = [
|
||||
'contacts|accounts' => ['left' => 'contacts.Accountid', 'right' => 'accounts.ID'],
|
||||
'accounts|contacts' => ['left' => 'accounts.ID', 'right' => 'contacts.Accountid'],
|
||||
'renewals|accounts' => ['left' => 'renewals.account_id', 'right' => 'accounts.ID'],
|
||||
'accounts|renewals' => ['left' => 'accounts.ID', 'right' => 'renewals.account_id'],
|
||||
'renewals|contacts' => ['left' => 'renewals.account_id', 'right' => 'contacts.Accountid'],
|
||||
'contacts|renewals' => ['left' => 'contacts.Accountid', 'right' => 'renewals.account_id'],
|
||||
'contacts|accounts' => ['left' => 'contacts.account_id', 'right' => 'accounts.id'],
|
||||
'accounts|contacts' => ['left' => 'accounts.id', 'right' => 'contacts.account_id'],
|
||||
'renewals|accounts' => ['left' => 'renewals.account_id', 'right' => 'accounts.id'],
|
||||
'accounts|renewals' => ['left' => 'accounts.id', 'right' => 'renewals.account_id'],
|
||||
'renewals|contacts' => ['left' => 'renewals.account_id', 'right' => 'contacts.account_id'],
|
||||
'contacts|renewals' => ['left' => 'contacts.account_id', 'right' => 'renewals.account_id'],
|
||||
'grants|accounts' => ['left' => 'grants.account_id', 'right' => 'accounts.id'],
|
||||
'accounts|grants' => ['left' => 'accounts.id', 'right' => 'grants.account_id'],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -47,10 +49,10 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
|
|||
}
|
||||
|
||||
[$schema, $table] = explode('.', $source, 2);
|
||||
$sql = 'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = :schema AND table_name = :table';
|
||||
$stmt = $this->router->membersPdo()->prepare($sql);
|
||||
$stmt->execute(['schema' => $schema, 'table' => $table]);
|
||||
return ((int) $stmt->fetchColumn()) > 0;
|
||||
$show = 'SHOW TABLES FROM `' . str_replace('`', '``', $schema) . '` LIKE ?';
|
||||
$stmt = $this->router->membersPdo()->prepare($show);
|
||||
$stmt->execute([$table]);
|
||||
return (bool) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
public function sourceFields(string $source): array
|
||||
|
|
@ -64,10 +66,9 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
|
|||
}
|
||||
|
||||
[$schema, $table] = explode('.', $source, 2);
|
||||
$sql = 'SELECT COLUMN_NAME FROM information_schema.columns WHERE table_schema = :schema AND table_name = :table ORDER BY ORDINAL_POSITION';
|
||||
$stmt = $this->router->membersPdo()->prepare($sql);
|
||||
$stmt->execute(['schema' => $schema, 'table' => $table]);
|
||||
return array_map(static fn(array $row): string => (string) $row['COLUMN_NAME'], $stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
$sql = 'SHOW COLUMNS FROM `' . str_replace('`', '``', $schema) . '`.`' . str_replace('`', '``', $table) . '`';
|
||||
$rows = $this->router->membersPdo()->query($sql)->fetchAll(\PDO::FETCH_ASSOC);
|
||||
return array_values(array_filter(array_map(static fn(array $row): string => (string) ($row['Field'] ?? ''), $rows), static fn(string $v): bool => $v !== ''));
|
||||
}
|
||||
|
||||
public function hasEmailField(string $source): bool
|
||||
|
|
@ -84,6 +85,6 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
|
|||
|
||||
public function allKnownSources(): array
|
||||
{
|
||||
return ['contacts', 'accounts', 'renewals'];
|
||||
return ['contacts', 'accounts', 'renewals', 'grants'];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use PDO;
|
|||
final class MailshotRepository
|
||||
{
|
||||
private DatabaseRouter $router;
|
||||
private bool $recipientEmailFieldColumnChecked = false;
|
||||
|
||||
public function __construct(DatabaseRouter $router)
|
||||
{
|
||||
|
|
@ -19,13 +20,15 @@ final class MailshotRepository
|
|||
/** @return list<array<string, mixed>> */
|
||||
public function all(): array
|
||||
{
|
||||
$sql = 'SELECT id, Purpose, DataSource, CC, BCC, Subject, Message, PDFAttachment, AttachmentNames, PDFFilenameDerivedFrom, ReplyTo FROM mailshots ORDER BY Purpose ASC';
|
||||
$this->ensureRecipientEmailFieldColumn();
|
||||
$sql = 'SELECT id, Purpose, DataSource, CC, BCC, Subject, Message, PDFAttachment, AttachmentNames, PDFFilenameDerivedFrom, ReplyTo, RecipientEmailField FROM mailshots ORDER BY Purpose ASC';
|
||||
return $this->router->mailshotsPdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function find(int $id): ?array
|
||||
{
|
||||
$stmt = $this->router->mailshotsPdo()->prepare('SELECT id, Purpose, DataSource, CC, BCC, Subject, Message, PDFAttachment, AttachmentNames, PDFFilenameDerivedFrom, ReplyTo FROM mailshots WHERE id = :id');
|
||||
$this->ensureRecipientEmailFieldColumn();
|
||||
$stmt = $this->router->mailshotsPdo()->prepare('SELECT id, Purpose, DataSource, CC, BCC, Subject, Message, PDFAttachment, AttachmentNames, PDFFilenameDerivedFrom, ReplyTo, RecipientEmailField FROM mailshots WHERE id = :id');
|
||||
$stmt->execute(['id' => $id]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return $row === false ? null : $row;
|
||||
|
|
@ -34,7 +37,8 @@ final class MailshotRepository
|
|||
/** @param array<string, mixed> $row */
|
||||
public function create(array $row): int
|
||||
{
|
||||
$sql = 'INSERT INTO mailshots (Purpose, DataSource, CC, BCC, Subject, Message, PDFAttachment, AttachmentNames, PDFFilenameDerivedFrom, ReplyTo) VALUES (:Purpose, :DataSource, :CC, :BCC, :Subject, :Message, :PDFAttachment, :AttachmentNames, :PDFFilenameDerivedFrom, :ReplyTo)';
|
||||
$this->ensureRecipientEmailFieldColumn();
|
||||
$sql = 'INSERT INTO mailshots (Purpose, DataSource, CC, BCC, Subject, Message, PDFAttachment, AttachmentNames, PDFFilenameDerivedFrom, ReplyTo, RecipientEmailField) VALUES (:Purpose, :DataSource, :CC, :BCC, :Subject, :Message, :PDFAttachment, :AttachmentNames, :PDFFilenameDerivedFrom, :ReplyTo, :RecipientEmailField)';
|
||||
$stmt = $this->router->mailshotsPdo()->prepare($sql);
|
||||
$stmt->execute($this->payload($row));
|
||||
return (int) $this->router->mailshotsPdo()->lastInsertId();
|
||||
|
|
@ -43,7 +47,8 @@ final class MailshotRepository
|
|||
/** @param array<string, mixed> $row */
|
||||
public function update(int $id, array $row): void
|
||||
{
|
||||
$sql = 'UPDATE mailshots SET Purpose = :Purpose, DataSource = :DataSource, CC = :CC, BCC = :BCC, Subject = :Subject, Message = :Message, PDFAttachment = :PDFAttachment, AttachmentNames = :AttachmentNames, PDFFilenameDerivedFrom = :PDFFilenameDerivedFrom, ReplyTo = :ReplyTo WHERE id = :id';
|
||||
$this->ensureRecipientEmailFieldColumn();
|
||||
$sql = 'UPDATE mailshots SET Purpose = :Purpose, DataSource = :DataSource, CC = :CC, BCC = :BCC, Subject = :Subject, Message = :Message, PDFAttachment = :PDFAttachment, AttachmentNames = :AttachmentNames, PDFFilenameDerivedFrom = :PDFFilenameDerivedFrom, ReplyTo = :ReplyTo, RecipientEmailField = :RecipientEmailField WHERE id = :id';
|
||||
$stmt = $this->router->mailshotsPdo()->prepare($sql);
|
||||
$payload = $this->payload($row);
|
||||
$payload['id'] = $id;
|
||||
|
|
@ -77,9 +82,39 @@ final class MailshotRepository
|
|||
'AttachmentNames' => $this->textOrNull($row['AttachmentNames'] ?? null),
|
||||
'PDFFilenameDerivedFrom' => $this->textOrNull($row['PDFFilenameDerivedFrom'] ?? null),
|
||||
'ReplyTo' => $this->textOrNull($row['ReplyTo'] ?? null),
|
||||
'RecipientEmailField' => $this->textOrNull($row['RecipientEmailField'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
private function ensureRecipientEmailFieldColumn(): void
|
||||
{
|
||||
if ($this->recipientEmailFieldColumnChecked) {
|
||||
return;
|
||||
}
|
||||
$this->recipientEmailFieldColumnChecked = true;
|
||||
|
||||
$pdo = $this->router->mailshotsPdo();
|
||||
$exists = false;
|
||||
try {
|
||||
$schema = $this->router->mailshotsDbName();
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = :schema AND table_name = 'mailshots' AND column_name = 'RecipientEmailField'");
|
||||
$stmt->execute(['schema' => $schema]);
|
||||
$exists = (int) $stmt->fetchColumn() > 0;
|
||||
} catch (\Throwable $e) {
|
||||
$cols = $pdo->query('SHOW COLUMNS FROM mailshots')->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($cols as $col) {
|
||||
if (strcasecmp((string) ($col['Field'] ?? ''), 'RecipientEmailField') === 0) {
|
||||
$exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$exists) {
|
||||
$pdo->exec('ALTER TABLE mailshots ADD COLUMN RecipientEmailField VARCHAR(255) NULL AFTER ReplyTo');
|
||||
}
|
||||
}
|
||||
|
||||
private function text($value): string
|
||||
{
|
||||
return trim((string) $value);
|
||||
|
|
|
|||
|
|
@ -79,6 +79,12 @@ final class FixtureWordPressFacade implements WordPressFacade
|
|||
return true;
|
||||
}
|
||||
|
||||
public function deleteOption(string $name): bool
|
||||
{
|
||||
unset($this->options[$name]);
|
||||
return true;
|
||||
}
|
||||
|
||||
public function dispatch(string $hook): void
|
||||
{
|
||||
foreach ($this->actions[$hook] ?? [] as $callback) {
|
||||
|
|
|
|||
|
|
@ -67,4 +67,9 @@ final class ProductionWordPressFacade implements WordPressFacade
|
|||
{
|
||||
return (bool) update_option($name, $value);
|
||||
}
|
||||
|
||||
public function deleteOption(string $name): bool
|
||||
{
|
||||
return (bool) delete_option($name);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,4 +35,6 @@ interface WordPressFacade
|
|||
* @param mixed $value
|
||||
*/
|
||||
public function updateOption(string $name, $value): bool;
|
||||
|
||||
public function deleteOption(string $name): bool;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
{
|
||||
"name": "feca-mailshots-plugin-e2e",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "feca-mailshots-plugin-e2e",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.53.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "feca-mailshots-plugin-e2e",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"e2e": "playwright test",
|
||||
"e2e:headed": "playwright test --headed",
|
||||
"e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.53.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e/specs',
|
||||
workers: 1,
|
||||
timeout: 180000,
|
||||
expect: { timeout: 15000 },
|
||||
retries: 0,
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL || 'http://127.0.0.1:8080',
|
||||
trace: 'on-first-retry'
|
||||
}
|
||||
});
|
||||
|
|
@ -15,6 +15,15 @@ This document remains in the working context for all operations.
|
|||
|
||||
No fallback behavior may be introduced or enabled unless a requirement explicitly instructs it.
|
||||
|
||||
Use the following rule in prompts:
|
||||
Hard constraints for this task:
|
||||
|
||||
1) No fallbacks unless explicitly listed below.
|
||||
2) No synthetic placeholders/default labels.
|
||||
3) Fail fast on missing/invalid data paths.
|
||||
4) Show explicit errors at point of detection.
|
||||
5) If a fallback seems necessary, stop and ask first.
|
||||
|
||||
## Repository Context
|
||||
|
||||
- Primary workspace: `mailshot-plugin`
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
{% macro br(text) %}
|
||||
{% if text %}
|
||||
{{ text }}<br>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
<style>
|
||||
body {
|
||||
font-family: Cambria, Cochin, Georgia, Times, 'Times New Roman', serif;
|
||||
|
|
@ -75,12 +70,12 @@ Cottenham<br>
|
|||
CB24 8UA</div>
|
||||
|
||||
<div class="to-address">
|
||||
{{ br(contact_name) }}
|
||||
<b>{{ br(AdvertiserName) }}</b>
|
||||
{{ br(address_1) }}
|
||||
{{ br(address_2) }}
|
||||
{{ br(town) }}
|
||||
{{ br(post_code) }}
|
||||
{{ contact_name }}
|
||||
<b>{{ AdvertiserName }}</b>
|
||||
{{ address_1 }}
|
||||
{{ address_2 }}
|
||||
{{ town }}
|
||||
{{ post_code }}
|
||||
</div>
|
||||
|
||||
<div class="invoice-header">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
# Browser E2E (Playwright)
|
||||
|
||||
This suite runs browser-based smoke/regression coverage against fixture-hosted admin UI (`tests/fixture/wp-admin/*`).
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
tests/e2e/run.sh
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
scripts/run_fixture_server.sh start
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
E2E_BASE_URL=http://127.0.0.1:8080 npx playwright test
|
||||
```
|
||||
|
||||
## Specs
|
||||
|
||||
- `admin-ui-smoke.spec.mjs`
|
||||
- page-load smoke for Data Sources, Mailshots, Attachments, PDF Assets.
|
||||
- `data-sources.spec.mjs`
|
||||
- modal create flow, preview regression guard (`contacts and accounts`), validation failure path.
|
||||
- `mailshots-editor.spec.mjs`
|
||||
- datasource-driven token/field population, message/PDF overlay editor flows, create mailshot.
|
||||
- `run-test-pages.spec.mjs`
|
||||
- Run/Test page load checks and API failure-path assertions (`run_mailshot` invalid id, `send_test` blank email).
|
||||
|
||||
## Notes
|
||||
|
||||
- Tests clean up created Data Source/Mailshot artefacts.
|
||||
- This suite is intended as a pre-manual gate before release/deploy.
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
FIXTURE_PORT="${FIXTURE_PORT:-8092}"
|
||||
BASE_URL="${E2E_BASE_URL:-http://127.0.0.1:${FIXTURE_PORT}}"
|
||||
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
FIXTURE_PORT="${FIXTURE_PORT}" scripts/run_fixture_server.sh start >/dev/null
|
||||
|
||||
if [[ ! -d node_modules ]]; then
|
||||
npm install
|
||||
fi
|
||||
|
||||
npx playwright install chromium
|
||||
E2E_BASE_URL="${BASE_URL}" npx playwright test
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { adminPath } from './helpers.mjs';
|
||||
|
||||
test('admin UI smoke: key admin pages load', async ({ page }) => {
|
||||
await page.goto(adminPath('feca-mailshot-data-sources'));
|
||||
await expect(page.getByRole('heading', { name: 'Mailshot Data Sources' })).toBeVisible();
|
||||
|
||||
await page.goto(adminPath('feca-mailshots-mailshots'));
|
||||
await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
|
||||
|
||||
await page.goto(adminPath('feca-mailshots-attachments'));
|
||||
await expect(page.locator('h1', { hasText: 'Attachments' })).toBeVisible();
|
||||
|
||||
await page.goto(adminPath('feca-mailshots-pdf-assets'));
|
||||
await expect(page.locator('h1', { hasText: 'PDF Assets' })).toBeVisible();
|
||||
});
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { adminPath, apiPost, cleanupByNames, ensureDataSource } from './helpers.mjs';
|
||||
|
||||
test('data sources: create + preview + validation failure path', async ({ page, request }) => {
|
||||
const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||
const dsName = `e2e_ds_${uniq}`;
|
||||
|
||||
try {
|
||||
await page.goto(adminPath('feca-mailshot-data-sources'));
|
||||
await expect(page.getByRole('heading', { name: 'Mailshot Data Sources' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'New Data Source' }).click();
|
||||
await page.locator('#ds_name').fill(dsName);
|
||||
await page.locator('#ds_dsl').fill('contacts and accounts');
|
||||
await page.getByRole('button', { name: /^Create|Save$/ }).first().click();
|
||||
|
||||
await expect(page.getByRole('cell', { name: dsName })).toBeVisible();
|
||||
|
||||
const preview = await apiPost(request, 'feca_mailshots_data_sources_api', 'preview', {
|
||||
dsl_text: 'contacts and accounts',
|
||||
limit: '10'
|
||||
});
|
||||
expect(preview._httpStatus).toBe(200);
|
||||
expect(JSON.stringify(preview)).not.toContain('Duplicate column name');
|
||||
|
||||
const invalid = await apiPost(request, 'feca_mailshots_data_sources_api', 'validate', {
|
||||
dsl_text: 'unknownsource'
|
||||
});
|
||||
expect(invalid._httpStatus).toBe(200);
|
||||
expect(Array.isArray(invalid.errors)).toBeTruthy();
|
||||
expect(invalid.errors.join(' | ')).toContain('Unknown source');
|
||||
} finally {
|
||||
try {
|
||||
await cleanupByNames(request, { dataSourceNames: [dsName] });
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('data sources: DSL builder round-trip preserves complex representable DSL', async ({ page, request }) => {
|
||||
const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||
const dsName = `e2e_ds_roundtrip_${uniq}`;
|
||||
const dsl =
|
||||
"contacts and accounts where fen1-contact and not (primary-contact) and contacts.account_id = accounts.id and contacts.last_name contains 'smith' and accounts.id in (123) and accounts.name starts-with 'St' and accounts.name ends-with 'Ltd' and contacts.account_id != accounts.id";
|
||||
|
||||
try {
|
||||
await ensureDataSource(request, dsName, dsl);
|
||||
|
||||
await page.goto(adminPath('feca-mailshot-data-sources'));
|
||||
await expect(page.getByRole('heading', { name: 'Mailshot Data Sources' })).toBeVisible();
|
||||
|
||||
const row = page.locator('tr', {
|
||||
has: page.getByRole('cell', { name: dsName })
|
||||
}).first();
|
||||
await expect(row).toBeVisible();
|
||||
await row.getByRole('link', { name: 'Edit' }).click();
|
||||
|
||||
await expect(page.locator('#ds-editor-modal')).toBeVisible();
|
||||
await expect(page.locator('#ds_dsl')).toHaveValue(dsl);
|
||||
|
||||
await page.locator('#ds-build-dsl-open').click();
|
||||
await expect(page.locator('#ds-builder-modal')).toBeVisible();
|
||||
await expect(page.locator('#ds-builder-output')).toHaveValue(dsl);
|
||||
|
||||
await page.locator('#ds-builder-apply').click();
|
||||
await expect(page.locator('#ds_dsl')).toHaveValue(dsl);
|
||||
|
||||
await page.locator('#ds-build-dsl-open').click();
|
||||
await expect(page.locator('#ds-builder-output')).toHaveValue(dsl);
|
||||
} finally {
|
||||
try {
|
||||
await cleanupByNames(request, { dataSourceNames: [dsName] });
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { expect } from '@playwright/test';
|
||||
|
||||
export const adminPath = (page) => `/wp-admin/admin.php?page=${page}`;
|
||||
export const postPath = (action, op = '') => `/wp-admin/admin-post.php?action=${encodeURIComponent(action)}${op ? `&op=${encodeURIComponent(op)}` : ''}`;
|
||||
|
||||
export async function apiPost(request, action, op, form = {}) {
|
||||
const res = await request.post(postPath(action, op), { form });
|
||||
const status = res.status();
|
||||
const raw = await res.text();
|
||||
let decoded;
|
||||
try {
|
||||
decoded = JSON.parse(raw);
|
||||
} catch {
|
||||
decoded = { ok: false, error: `Non-JSON response (HTTP ${status})`, raw };
|
||||
}
|
||||
return { _httpStatus: status, ...decoded };
|
||||
}
|
||||
|
||||
export async function apiList(request, action) {
|
||||
return await apiPost(request, action, 'list', {});
|
||||
}
|
||||
|
||||
export async function ensureDataSource(request, name, dslText) {
|
||||
const out = await apiPost(request, 'feca_mailshots_data_sources_api', 'save', {
|
||||
name,
|
||||
dsl_text: dslText
|
||||
});
|
||||
expect(out.ok).toBeTruthy();
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function ensureMailshot(request, payload) {
|
||||
const out = await apiPost(request, 'feca_mailshots_mailshots_api', 'save', payload);
|
||||
expect(out.ok).toBeTruthy();
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function cleanupByNames(request, { dataSourceNames = [], mailshotPurposes = [] } = {}) {
|
||||
const msList = await apiList(request, 'feca_mailshots_mailshots_api');
|
||||
for (const row of msList.items || []) {
|
||||
if (mailshotPurposes.includes(String(row.Purpose || ''))) {
|
||||
await apiPost(request, 'feca_mailshots_mailshots_api', 'delete', { id: String(row.id || 0) });
|
||||
}
|
||||
}
|
||||
|
||||
const dsList = await apiList(request, 'feca_mailshots_data_sources_api');
|
||||
for (const row of dsList.items || []) {
|
||||
if (dataSourceNames.includes(String(row.name || ''))) {
|
||||
await apiPost(request, 'feca_mailshots_data_sources_api', 'delete', { id: String(row.ID || row.id || 0) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { adminPath, ensureDataSource, cleanupByNames } from './helpers.mjs';
|
||||
|
||||
test('mailshots editor: datasource-driven options + overlay editors + create', async ({ page, request }) => {
|
||||
const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||
const dsName = `e2e_ms_ds_${uniq}`;
|
||||
const purpose = `e2e_mailshot_${uniq}`;
|
||||
|
||||
try {
|
||||
await ensureDataSource(request, dsName, 'contacts');
|
||||
|
||||
await page.goto(adminPath('feca-mailshots-mailshots'));
|
||||
await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
|
||||
|
||||
await page.locator('#ms-open-new').click();
|
||||
await expect(page.locator('#ms-editor-modal')).toBeVisible();
|
||||
|
||||
await page.locator('#ms_purpose').fill(purpose);
|
||||
await page.locator('#ms_ds').selectOption({ label: dsName });
|
||||
|
||||
await expect.poll(async () => await page.locator('#ms-token-controls button, #ms-token-controls select option').count()).toBeGreaterThan(0);
|
||||
await expect.poll(async () => await page.locator('#ms_pdfname option').count()).toBeGreaterThan(1);
|
||||
await expect.poll(async () => await page.locator('#ms_email_field option').count()).toBeGreaterThan(1);
|
||||
|
||||
await page.locator('#ms_subject').fill('Subject {{ contacts.id }}');
|
||||
|
||||
await page.locator('#ms-edit-message').click();
|
||||
await expect(page.locator('#ms-template-modal')).toBeVisible();
|
||||
await page.evaluate(() => {
|
||||
const html = '<style>.red-note{color:red;}</style><div class="red-note">Hello <strong>bold</strong> {{ contacts.id }}</div>';
|
||||
const ed = window.tinymce && window.tinymce.get ? window.tinymce.get('ms-template-html') : null;
|
||||
const raw = /** @type {HTMLTextAreaElement|null} */ (document.getElementById('ms-template-html'));
|
||||
if (ed) {
|
||||
ed.setContent(html);
|
||||
ed.save();
|
||||
} else if (raw) {
|
||||
raw.value = html;
|
||||
}
|
||||
});
|
||||
await page.locator('#ms-template-mode-code').click();
|
||||
await expect(page.locator('#ms-template-code')).toHaveValue(/<style[^>]*>[\s\S]*\.red-note\s*\{[^}]*color:\s*red;?[^}]*\}[\s\S]*<\/style>/i);
|
||||
await expect(page.locator('#ms-template-code')).toHaveValue(/class=["']red-note["']/i);
|
||||
await expect(page.locator('#ms-template-code')).toHaveValue(/<strong>bold<\/strong>/);
|
||||
await page.locator('#ms-template-mode-visual').click();
|
||||
await page.locator('#ms-template-mode-code').click();
|
||||
await expect(page.locator('#ms-template-code')).toHaveValue(/class=["']red-note["']/i);
|
||||
await expect(page.locator('#ms-template-code')).not.toHaveValue(/data-mce-type=["']bookmark["']/i);
|
||||
await expect(page.locator('#ms-template-code')).not.toHaveValue(/mce_SELRES_(?:start|end)/i);
|
||||
await expect(page.locator('#ms-template-code')).not.toHaveValue(/<style[^>]*>[\s\S]*<span[\s\S]*<\/style>/i);
|
||||
const formattedSource = `<style>
|
||||
.red-note { color: red; }
|
||||
</style>
|
||||
|
||||
<div class="red-note">
|
||||
hello
|
||||
</div>`;
|
||||
await page.locator('#ms-template-code').fill(formattedSource);
|
||||
await page.locator('#ms-template-mode-visual').click();
|
||||
await page.locator('#ms-template-mode-code').click();
|
||||
await expect(page.locator('#ms-template-code')).toHaveValue(/<style>\s*\n\s*\.red-note\s*\{\s*color:\s*red;\s*\}\s*\n<\/style>/i);
|
||||
await expect(page.locator('#ms-template-code')).toHaveValue(/\n\s*\n<div class="red-note">/i);
|
||||
await expect(page.locator('#ms-template-code')).toHaveValue(/\n\s+hello\s*\n<\/div>/i);
|
||||
await page.locator('#ms-template-save').click();
|
||||
await expect(page.locator('#ms_message')).toContainText('.red-note{color:red;}');
|
||||
await expect(page.locator('#ms_message')).toContainText('class="red-note"');
|
||||
await expect(page.locator('#ms_message')).toContainText('<strong>bold</strong>');
|
||||
|
||||
await page.locator('#ms-edit-pdf').click();
|
||||
await expect(page.locator('#ms-template-modal')).toBeVisible();
|
||||
await page.locator('#ms-template-mode-code').click();
|
||||
await page.locator('#ms-template-code').fill('<p>PDF {{ contacts.id }}</p>');
|
||||
await page.locator('#ms-template-save').click();
|
||||
|
||||
await page.locator('#ms_email_field').evaluate((el) => {
|
||||
const select = /** @type {HTMLSelectElement} */ (el);
|
||||
const option = Array.from(select.options).find((o) => o.value && /email/i.test(o.value)) || Array.from(select.options).find((o) => o.value);
|
||||
if (option) {
|
||||
select.value = option.value;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||
await expect(page.getByRole('cell', { name: purpose })).toBeVisible();
|
||||
} finally {
|
||||
try {
|
||||
await cleanupByNames(request, { dataSourceNames: [dsName], mailshotPurposes: [purpose] });
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { adminPath, apiPost, ensureDataSource, ensureMailshot, cleanupByNames } from './helpers.mjs';
|
||||
|
||||
test('run/test pages: load and failure-path API assertions', async ({ page, request }) => {
|
||||
const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||
const dsName = `e2e_run_ds_${uniq}`;
|
||||
const purpose = `e2e_run_ms_${uniq}`;
|
||||
|
||||
try {
|
||||
await ensureDataSource(request, dsName, 'contacts');
|
||||
await ensureMailshot(request, {
|
||||
Purpose: purpose,
|
||||
DataSource: dsName,
|
||||
CC: '',
|
||||
BCC: '',
|
||||
Subject: 'Subj {{ contacts.id }}',
|
||||
Message: '<p>Hi {{ contacts.id }}</p>',
|
||||
PDFAttachment: '<p>PDF</p>',
|
||||
AttachmentNames: '[]',
|
||||
PDFFilenameDerivedFrom: '',
|
||||
RecipientEmailField: 'contacts.contact_email_1',
|
||||
ReplyTo: ''
|
||||
});
|
||||
|
||||
await page.goto(adminPath('feca-mailshots-run'));
|
||||
await expect(page.getByRole('heading', { name: 'Run Mailshot' })).toBeVisible();
|
||||
|
||||
await page.goto(adminPath('feca-mailshots-test'));
|
||||
await expect(page.getByRole('heading', { name: 'Mailshot Test' })).toBeVisible();
|
||||
|
||||
const runInvalid = await apiPost(request, 'feca_mailshots_run_api', 'run_mailshot', { mailshot_id: '0' });
|
||||
expect(runInvalid.ok).toBeFalsy();
|
||||
expect(String((runInvalid.errors || []).join(' | ') || runInvalid.error || '')).toContain('Missing mail credentials');
|
||||
|
||||
const sendBlank = await apiPost(request, 'feca_mailshots_test_api', 'send_test', {
|
||||
mailshot_id: '1',
|
||||
recipient_index: '0',
|
||||
test_email: ''
|
||||
});
|
||||
expect(sendBlank.ok).toBeFalsy();
|
||||
expect(String((sendBlank.errors || []).join(' | ') || sendBlank.error || '')).toContain('Test email address is required');
|
||||
} finally {
|
||||
try {
|
||||
await cleanupByNames(request, { dataSourceNames: [dsName], mailshotPurposes: [purpose] });
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$ctx = require dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
$wp = $ctx['wp'];
|
||||
assert($wp instanceof FecaMailshots\WordPress\FixtureWordPressFacade);
|
||||
|
||||
$action = isset($_REQUEST['action']) ? (string) $_REQUEST['action'] : '';
|
||||
if ($action === '') {
|
||||
http_response_code(400);
|
||||
echo 'Missing action';
|
||||
exit;
|
||||
}
|
||||
|
||||
$hook = 'admin_post_' . $action;
|
||||
$wp->dispatch($hook);
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$ctx = require dirname(__DIR__) . '/bootstrap.php';
|
||||
$container = $ctx['container'];
|
||||
|
||||
$page = isset($_GET['page']) ? (string) $_GET['page'] : 'feca-mailshots-mailshots';
|
||||
|
||||
$map = [
|
||||
'feca-mailshots-mailshots' => FecaMailshots\Admin\MailshotsAdminPage::class,
|
||||
'feca-mailshot-data-sources' => FecaMailshots\Admin\DataSourcesAdminPage::class,
|
||||
'feca-mailshots-attachments' => FecaMailshots\Admin\AttachmentsAdminPage::class,
|
||||
'feca-mailshots-pdf-assets' => FecaMailshots\Admin\PdfAssetsAdminPage::class,
|
||||
'feca-mailshots-setup' => FecaMailshots\Admin\SetupAdminPage::class,
|
||||
'feca-mailshots-profile' => FecaMailshots\Admin\ProfileAdminPage::class,
|
||||
'feca-mailshots-test' => FecaMailshots\Admin\MailshotTestAdminPage::class,
|
||||
'feca-mailshots-run' => FecaMailshots\Admin\RunMailshotAdminPage::class,
|
||||
];
|
||||
|
||||
if (!isset($map[$page])) {
|
||||
http_response_code(404);
|
||||
echo 'Unknown admin page';
|
||||
exit;
|
||||
}
|
||||
|
||||
$instance = $container->get($map[$page]);
|
||||
$instance->render();
|
||||
|
|
@ -73,6 +73,26 @@ try {
|
|||
}
|
||||
$queryId = (int) ($saveDs['id'] ?? 0);
|
||||
|
||||
$tokenProbe = $mailshotService->tokenInsertionData($queryName);
|
||||
$recipientEmailField = '';
|
||||
foreach (($tokenProbe['tokens'] ?? []) as $token) {
|
||||
if (!is_array($token) || !isset($token['field'])) {
|
||||
continue;
|
||||
}
|
||||
$field = (string) $token['field'];
|
||||
if (stripos($field, 'email') !== false) {
|
||||
$recipientEmailField = $field;
|
||||
break;
|
||||
}
|
||||
if ($recipientEmailField === '') {
|
||||
$recipientEmailField = $field;
|
||||
}
|
||||
}
|
||||
if ($recipientEmailField === '') {
|
||||
fwrite(STDERR, "Expected at least one token field for datasource\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$saveAtt = $attachmentService->save(null, [
|
||||
'name' => 'phase3_att_' . $unique,
|
||||
'file_name' => 'terms.txt',
|
||||
|
|
@ -103,6 +123,7 @@ try {
|
|||
$saveMailshot = $mailshotService->save(null, [
|
||||
'Purpose' => 'Phase3 Mailshot ' . $unique,
|
||||
'DataSource' => $queryName,
|
||||
'RecipientEmailField' => $recipientEmailField,
|
||||
'Subject' => 'Subject {{ ' . strtolower($firstTable) . '_id }}',
|
||||
'Message' => '<p>Hello</p>',
|
||||
'AttachmentNames' => json_encode(['phase3_att_' . $unique], JSON_UNESCAPED_SLASHES),
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ foreach ($cases as $case) {
|
|||
|
||||
if ($isValid) {
|
||||
$compiled = $compiler->compile($ast);
|
||||
if (strpos($compiled['sql'], 'SELECT * FROM') !== 0) {
|
||||
if (strpos($compiled['sql'], 'SELECT ') !== 0) {
|
||||
$failures[] = ['dsl' => $case['dsl'], 'errors' => ['Compilation did not produce SELECT']];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue