Compare commits

...

2 Commits

8 changed files with 134 additions and 4 deletions

View File

@ -3,7 +3,7 @@
* Plugin Name: FECA Mailshots * Plugin Name: FECA Mailshots
* Plugin URI: https://fenedge.co.uk/ * Plugin URI: https://fenedge.co.uk/
* Description: FECA mailshots plugin. * Description: FECA mailshots plugin.
* Version: 1.0.27 * Version: 1.0.29
* Requires at least: 6.0 * Requires at least: 6.0
* Requires PHP: 7.4 * Requires PHP: 7.4
* Author: FECA * Author: FECA

View File

@ -34,6 +34,7 @@ final class DataSourcesAdminPage
$this->wp->addAction('rest_api_init', [$this, 'registerRestRoutes']); $this->wp->addAction('rest_api_init', [$this, 'registerRestRoutes']);
$this->wp->addAction('admin_post_feca_mailshots_data_sources_ui_save', [$this, 'handleUiSave']); $this->wp->addAction('admin_post_feca_mailshots_data_sources_ui_save', [$this, 'handleUiSave']);
$this->wp->addAction('admin_post_feca_mailshots_data_sources_ui_delete', [$this, 'handleUiDelete']); $this->wp->addAction('admin_post_feca_mailshots_data_sources_ui_delete', [$this, 'handleUiDelete']);
$this->wp->addAction('admin_post_feca_mailshots_data_sources_ui_duplicate', [$this, 'handleUiDuplicate']);
$this->wp->addAction('admin_post_feca_mailshots_data_sources_ui_validate', [$this, 'handleUiValidate']); $this->wp->addAction('admin_post_feca_mailshots_data_sources_ui_validate', [$this, 'handleUiValidate']);
$this->wp->addAction('admin_post_feca_mailshots_data_sources_ui_preview', [$this, 'handleUiPreview']); $this->wp->addAction('admin_post_feca_mailshots_data_sources_ui_preview', [$this, 'handleUiPreview']);
} }
@ -269,6 +270,12 @@ final class DataSourcesAdminPage
echo '<td>' . htmlspecialchars((string) ($row['updated_at'] ?? '')) . '</td>'; echo '<td>' . htmlspecialchars((string) ($row['updated_at'] ?? '')) . '</td>';
echo '<td><a class="button button-small" href="' . htmlspecialchars($editUrl) . '">Edit</a> '; echo '<td><a class="button button-small" href="' . htmlspecialchars($editUrl) . '">Edit</a> ';
echo '<form method="post" action="' . $action . '" class="feca-inline-form">'; echo '<form method="post" action="' . $action . '" class="feca-inline-form">';
echo '<input type="hidden" name="action" value="feca_mailshots_data_sources_ui_duplicate">';
echo $this->hiddenNonceField(self::NONCE_ACTION);
echo '<input type="hidden" name="id" value="' . $id . '">';
echo '<button type="submit" class="button button-small">Duplicate</button>';
echo '</form> ';
echo '<form method="post" action="' . $action . '" class="feca-inline-form">';
echo '<input type="hidden" name="action" value="feca_mailshots_data_sources_ui_delete">'; echo '<input type="hidden" name="action" value="feca_mailshots_data_sources_ui_delete">';
echo $this->hiddenNonceField(self::NONCE_ACTION); echo $this->hiddenNonceField(self::NONCE_ACTION);
echo '<input type="hidden" name="id" value="' . $id . '">'; echo '<input type="hidden" name="id" value="' . $id . '">';
@ -453,6 +460,29 @@ final class DataSourcesAdminPage
} }
} }
public function handleUiDuplicate(): void
{
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$id = (int) ($this->wp->requestParam('id', '0') ?? '0');
$result = ['ok' => false, 'errors' => ['Data source ID is required.']];
try {
if ($id > 0) {
$result = $this->service()->duplicate($id);
}
} catch (\Throwable $e) {
$result = ['ok' => false, 'errors' => [$e->getMessage()]];
}
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
$editId = !empty($result['ok']) && isset($result['id']) ? (int) $result['id'] : 0;
$url = $this->wp->adminUrl('admin.php?page=feca-mailshot-data-sources' . ($editId > 0 ? '&edit_id=' . $editId : ''));
if (!headers_sent()) {
header('Location: ' . $url, true, 302);
exit;
}
}
public function handleApi(): void public function handleApi(): void
{ {
if (!$this->enforceCapabilityOrJson(self::CAPABILITY)) { if (!$this->enforceCapabilityOrJson(self::CAPABILITY)) {
@ -524,6 +554,14 @@ final class DataSourcesAdminPage
$this->wp->sendJson(['ok' => true]); $this->wp->sendJson(['ok' => true]);
return; return;
} }
if ($op === 'duplicate') {
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$id = (int) ($this->wp->requestParam('id', '0') ?? '0');
$this->wp->sendJson($this->service()->duplicate($id));
return;
}
$this->wp->sendJson(['ok' => false, 'error' => 'Unknown operation'], 400); $this->wp->sendJson(['ok' => false, 'error' => 'Unknown operation'], 400);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->wp->sendJson(['ok' => false, 'error' => $e->getMessage()], 500); $this->wp->sendJson(['ok' => false, 'error' => $e->getMessage()], 500);

View File

@ -165,7 +165,7 @@ final class ReviewRecipientsAdminPage
echo 'function sourceStorageKey(src){return storagePrefix+String(src||"");}'; echo 'function sourceStorageKey(src){return storagePrefix+String(src||"");}';
echo 'function loadSelection(src){if(!src){return {};}try{var raw=sessionStorage.getItem(sourceStorageKey(src));if(!raw){return {};}var parsed=JSON.parse(raw);if(parsed&&typeof parsed==="object"){return parsed;}}catch(_){ }return {};}'; echo 'function loadSelection(src){if(!src){return {};}try{var raw=sessionStorage.getItem(sourceStorageKey(src));if(!raw){return {};}var parsed=JSON.parse(raw);if(parsed&&typeof parsed==="object"){return parsed;}}catch(_){ }return {};}';
echo 'function saveSelection(){if(!state.source){return;}try{sessionStorage.setItem(sourceStorageKey(state.source),JSON.stringify(state.selectedKeys||{}));}catch(_){ }}'; echo 'function saveSelection(){if(!state.source){return;}try{sessionStorage.setItem(sourceStorageKey(state.source),JSON.stringify(state.selectedKeys||{}));}catch(_){ }}';
echo 'function detectKey(row){var r=row||{};var fields=["ID","id","Accountid","account_id"];for(var i=0;i<fields.length;i++){var f=fields[i];if(r[f]!==undefined&&r[f]!==null&&String(r[f]).trim()!==""){return String(r[f]).trim();}}if(r.Email!==undefined&&String(r.Email).trim()!==""){return String(r.Email).trim();}if(r.email!==undefined&&String(r.email).trim()!==""){return String(r.email).trim();}if(r.__rr_index!==undefined){return "row_index:"+String(r.__rr_index);}return "";}'; echo 'function detectKey(row){var r=row||{};if(r.__rr_index!==undefined){return "row_index:"+String(r.__rr_index);}return "";}';
echo 'function renderTable(){clearErr();var src=state.source;if(!src){setColumnWidths([]);headRow.innerHTML="<th>No recipients loaded.</th>";bodyEl.innerHTML="";status("Select a data source to load recipients.");rebuildSortColumns([], "");return;}var payload=cache[src];if(!payload){setColumnWidths([]);headRow.innerHTML="<th>No recipients loaded.</th>";bodyEl.innerHTML="";status("Loading recipients...");return;}if(payload.errors&&payload.errors.length){setColumnWidths([]);showErr(payload.errors.join("; "));headRow.innerHTML="<th>Unable to render recipients.</th>";bodyEl.innerHTML="";status("Load failed.");rebuildSortColumns([], "");return;}var rows=(payload.rows||[]).map(function(r,i){var out=(r&&typeof r==="object")?Object.assign({},r):{};out.__rr_index=i;return out;});var cols=normalizeColumns(payload.columns||[],rows);cols=dedupeEquivalentColumns(cols,rows);if(cols.length===0){setColumnWidths([]);headRow.innerHTML="<th>No fields</th>";bodyEl.innerHTML="";status("Rows: 0 | Total from query: "+(payload.count||0)+" | Selected: 0");rebuildSortColumns([], "");return;}if(state.sortBy&&cols.indexOf(state.sortBy)===-1){state.sortBy="";}rebuildSortColumns(cols,state.sortBy);var filtered=rows.filter(function(r){return rowMatches(r||{},state.filter,cols);});if(state.sortBy){filtered.sort(function(a,b){var l=text((a||{})[state.sortBy]).toLowerCase();var r=text((b||{})[state.sortBy]).toLowerCase();var cmp=l<r?-1:(l>r?1:0);return state.sortDirection==="desc"?-cmp:cmp;});}'; echo 'function renderTable(){clearErr();var src=state.source;if(!src){setColumnWidths([]);headRow.innerHTML="<th>No recipients loaded.</th>";bodyEl.innerHTML="";status("Select a data source to load recipients.");rebuildSortColumns([], "");return;}var payload=cache[src];if(!payload){setColumnWidths([]);headRow.innerHTML="<th>No recipients loaded.</th>";bodyEl.innerHTML="";status("Loading recipients...");return;}if(payload.errors&&payload.errors.length){setColumnWidths([]);showErr(payload.errors.join("; "));headRow.innerHTML="<th>Unable to render recipients.</th>";bodyEl.innerHTML="";status("Load failed.");rebuildSortColumns([], "");return;}var rows=(payload.rows||[]).map(function(r,i){var out=(r&&typeof r==="object")?Object.assign({},r):{};out.__rr_index=i;return out;});var cols=normalizeColumns(payload.columns||[],rows);cols=dedupeEquivalentColumns(cols,rows);if(cols.length===0){setColumnWidths([]);headRow.innerHTML="<th>No fields</th>";bodyEl.innerHTML="";status("Rows: 0 | Total from query: "+(payload.count||0)+" | Selected: 0");rebuildSortColumns([], "");return;}if(state.sortBy&&cols.indexOf(state.sortBy)===-1){state.sortBy="";}rebuildSortColumns(cols,state.sortBy);var filtered=rows.filter(function(r){return rowMatches(r||{},state.filter,cols);});if(state.sortBy){filtered.sort(function(a,b){var l=text((a||{})[state.sortBy]).toLowerCase();var r=text((b||{})[state.sortBy]).toLowerCase();var cmp=l<r?-1:(l>r?1:0);return state.sortDirection==="desc"?-cmp:cmp;});}';
echo 'setColumnWidths(cols);headRow.innerHTML="";var selHead=document.createElement("th");selHead.className="feca-rr-head-cell";selHead.innerHTML="<input id=\\"rr_select_all\\" type=\\"checkbox\\" title=\\"Select/Deselect all visible rows\\">";headRow.appendChild(selHead);cols.forEach(function(c){var th=document.createElement("th");th.className="feca-rr-head-cell";var p=headerParts(c);if(p.prefix){th.innerHTML="<div class=\\"feca-rr-head-wrap\\"><span class=\\"feca-rr-head-prefix\\">"+p.prefix+"</span><span class=\\"feca-rr-head-field\\">"+p.field+"</span></div>";}else{th.innerHTML="<div class=\\"feca-rr-head-wrap\\"><span class=\\"feca-rr-head-field\\">"+p.field+"</span></div>";}headRow.appendChild(th);});bodyEl.innerHTML="";filtered.forEach(function(r){var tr=document.createElement("tr");var rowKey=detectKey(r);tr.setAttribute("data-row-key",rowKey);var selTd=document.createElement("td");selTd.className="feca-rr-data-cell";var checked=!!(rowKey&&state.selectedKeys[rowKey]);selTd.innerHTML="<input class=\\"rr-row-select\\" type=\\"checkbox\\" data-key=\\""+String(rowKey).replace(/"/g,"&quot;")+"\\""+(checked?" checked":"")+">";tr.appendChild(selTd);cols.forEach(function(c){var td=document.createElement("td");var v=text((r||{})[c]);td.className="feca-rr-data-cell";td.title=v;td.textContent=v;tr.appendChild(td);});bodyEl.appendChild(tr);});'; echo 'setColumnWidths(cols);headRow.innerHTML="";var selHead=document.createElement("th");selHead.className="feca-rr-head-cell";selHead.innerHTML="<input id=\\"rr_select_all\\" type=\\"checkbox\\" title=\\"Select/Deselect all visible rows\\">";headRow.appendChild(selHead);cols.forEach(function(c){var th=document.createElement("th");th.className="feca-rr-head-cell";var p=headerParts(c);if(p.prefix){th.innerHTML="<div class=\\"feca-rr-head-wrap\\"><span class=\\"feca-rr-head-prefix\\">"+p.prefix+"</span><span class=\\"feca-rr-head-field\\">"+p.field+"</span></div>";}else{th.innerHTML="<div class=\\"feca-rr-head-wrap\\"><span class=\\"feca-rr-head-field\\">"+p.field+"</span></div>";}headRow.appendChild(th);});bodyEl.innerHTML="";filtered.forEach(function(r){var tr=document.createElement("tr");var rowKey=detectKey(r);tr.setAttribute("data-row-key",rowKey);var selTd=document.createElement("td");selTd.className="feca-rr-data-cell";var checked=!!(rowKey&&state.selectedKeys[rowKey]);selTd.innerHTML="<input class=\\"rr-row-select\\" type=\\"checkbox\\" data-key=\\""+String(rowKey).replace(/"/g,"&quot;")+"\\""+(checked?" checked":"")+">";tr.appendChild(selTd);cols.forEach(function(c){var td=document.createElement("td");var v=text((r||{})[c]);td.className="feca-rr-data-cell";td.title=v;td.textContent=v;tr.appendChild(td);});bodyEl.appendChild(tr);});';
echo 'var allKeys=rows.map(detectKey).filter(function(k){return !!k;});var selectedInSource=0;allKeys.forEach(function(k){if(state.selectedKeys[k]){selectedInSource++;}});var visibleKeys=filtered.map(detectKey).filter(function(k){return !!k;});var allVisibleSelected=visibleKeys.length>0&&visibleKeys.every(function(k){return !!state.selectedKeys[k];});var selAll=document.getElementById("rr_select_all");if(selAll){selAll.checked=allVisibleSelected;selAll.indeterminate=visibleKeys.length>0&&!allVisibleSelected&&visibleKeys.some(function(k){return !!state.selectedKeys[k];});}status("Rows: "+filtered.length+" | Total from query: "+(payload.count||0)+" | Selected: "+selectedInSource+(state.filter?(" | Filter: "+state.filter):""));saveSelection();}'; echo 'var allKeys=rows.map(detectKey).filter(function(k){return !!k;});var selectedInSource=0;allKeys.forEach(function(k){if(state.selectedKeys[k]){selectedInSource++;}});var visibleKeys=filtered.map(detectKey).filter(function(k){return !!k;});var allVisibleSelected=visibleKeys.length>0&&visibleKeys.every(function(k){return !!state.selectedKeys[k];});var selAll=document.getElementById("rr_select_all");if(selAll){selAll.checked=allVisibleSelected;selAll.indeterminate=visibleKeys.length>0&&!allVisibleSelected&&visibleKeys.some(function(k){return !!state.selectedKeys[k];});}status("Rows: "+filtered.length+" | Total from query: "+(payload.count||0)+" | Selected: "+selectedInSource+(state.filter?(" | Filter: "+state.filter):""));saveSelection();}';

View File

@ -12,6 +12,8 @@ use PDO;
final class DataSourceService final class DataSourceService
{ {
private const NAME_MAX_LENGTH = 100;
private MailshotQueryRepository $queries; private MailshotQueryRepository $queries;
private DatabaseRouter $router; private DatabaseRouter $router;
private DslParser $parser; private DslParser $parser;
@ -109,6 +111,22 @@ final class DataSourceService
$this->queries->delete($id); $this->queries->delete($id);
} }
/** @return array<string, mixed> */
public function duplicate(int $id): array
{
$source = $this->queries->find($id);
if ($source === null) {
return ['ok' => false, 'errors' => ['Data source not found.']];
}
$newId = $this->queries->create([
'name' => $this->duplicateName((string) ($source['name'] ?? ''), $id),
'dsl_text' => (string) ($source['dsl_text'] ?? ''),
]);
return ['ok' => true, 'id' => $newId];
}
/** @return array<string, mixed> */ /** @return array<string, mixed> */
public function preview(string $dsl, int $limit = 50): array public function preview(string $dsl, int $limit = 50): array
{ {
@ -231,4 +249,41 @@ final class DataSourceService
} }
return $this->metadata->sourceFields($source); return $this->metadata->sourceFields($source);
} }
private function duplicateName(string $name, int $sourceId): string
{
$base = trim($name);
if ($base === '') {
$base = 'Data source #' . $sourceId;
}
$existing = [];
foreach ($this->queries->all() as $row) {
$existing[strtolower(trim((string) ($row['name'] ?? '')))] = true;
}
$prefix = 'Copy of ';
$candidate = $this->truncateName($prefix . $base);
if (!isset($existing[strtolower($candidate)])) {
return $candidate;
}
for ($i = 2; $i < 1000; $i++) {
$suffix = ' (' . $i . ')';
$candidate = $this->truncateName($prefix . $base, $suffix);
if (!isset($existing[strtolower($candidate)])) {
return $candidate;
}
}
return $this->truncateName($prefix . $base, ' (' . time() . ')');
}
private function truncateName(string $value, string $suffix = ''): string
{
$suffixLength = strlen($suffix);
$baseMax = max(1, self::NAME_MAX_LENGTH - $suffixLength);
$base = substr($value, 0, $baseMax);
return rtrim($base) . $suffix;
}
} }

View File

@ -305,8 +305,8 @@ final class MailshotRunService
$selectedSet = array_fill_keys($selectedRecipientKeys, true); $selectedSet = array_fill_keys($selectedRecipientKeys, true);
$filteredRows = []; $filteredRows = [];
foreach (array_values($rows) as $index => $row) { foreach (array_values($rows) as $index => $row) {
[, $recipientKey] = $this->detectRecipientKey((array) $row, $index); $rowIndexKey = 'row_index:' . $index;
if (isset($selectedSet[$recipientKey])) { if (isset($selectedSet[$rowIndexKey])) {
$filteredRows[] = $row; $filteredRows[] = $row;
} }
} }

View File

@ -23,3 +23,4 @@
* [ ] I made an edit to a mailshot (), with a 9kb message. Save did not close the dialog - but did not show any errors. There was a long pause (long enough to start typing this report) before it appeared to have had an effect (mailshot saved message). But Edit button on Mailshot doesn't appear to do anything now browser shows page still loading. https://fenedge.co.uk is not responding. * [ ] I made an edit to a mailshot (), with a 9kb message. Save did not close the dialog - but did not show any errors. There was a long pause (long enough to start typing this report) before it appeared to have had an effect (mailshot saved message). But Edit button on Mailshot doesn't appear to do anything now browser shows page still loading. https://fenedge.co.uk is not responding.
* [ ] Edit Data Source save DSL="ads and advertisers where issue(125)" takes a very long long time to repond. * [ ] Edit Data Source save DSL="ads and advertisers where issue(125)" takes a very long long time to repond.
* [X] Downloaded merged pdf or .zip should be named after maiilshot purpose * [X] Downloaded merged pdf or .zip should be named after maiilshot purpose
* [X] Mailshot data sources. Add "Duplicate" button per row.

View File

@ -38,6 +38,41 @@ test('data sources: create + preview + validation failure path', async ({ page,
} }
}); });
test('data sources: duplicate row action creates editable copy', async ({ page, request }) => {
const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const dsName = `e2e_ds_duplicate_${uniq}`;
const copyName = `Copy of ${dsName}`;
try {
await ensureDataSource(request, dsName, 'contacts and accounts');
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('button', { name: 'Duplicate' }).click();
const copyRow = page.locator('tr', {
has: page.getByRole('cell', { name: copyName })
}).first();
await expect(copyRow).toBeVisible();
await copyRow.getByRole('link', { name: 'Edit' }).click();
await expect(page.locator('#ds-editor-modal')).toBeVisible();
await expect(page.locator('#ds_name')).toHaveValue(copyName);
await expect(page.locator('#ds_dsl')).toHaveValue('contacts and accounts');
} finally {
try {
await cleanupByNames(request, { dataSourceNames: [dsName, copyName] });
} catch {
// best effort
}
}
});
test('data sources: DSL builder round-trip preserves complex representable DSL', async ({ page, request }) => { test('data sources: DSL builder round-trip preserves complex representable DSL', async ({ page, request }) => {
const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`; const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const dsName = `e2e_ds_roundtrip_${uniq}`; const dsName = `e2e_ds_roundtrip_${uniq}`;

View File

@ -66,6 +66,7 @@ $dataSourcesHtml = $capture(static function () use ($container): void {
}); });
$assertContains('New Data Source', $dataSourcesHtml, 'Data Sources'); $assertContains('New Data Source', $dataSourcesHtml, 'Data Sources');
$assertContains('feca_mailshots_data_sources_ui_save', $dataSourcesHtml, 'Data Sources'); $assertContains('feca_mailshots_data_sources_ui_save', $dataSourcesHtml, 'Data Sources');
$assertContains('feca_mailshots_data_sources_ui_duplicate', $dataSourcesHtml, 'Data Sources');
$assertContains('Existing Data Sources', $dataSourcesHtml, 'Data Sources'); $assertContains('Existing Data Sources', $dataSourcesHtml, 'Data Sources');
$assertNotContains('Use endpoint', $dataSourcesHtml, 'Data Sources'); $assertNotContains('Use endpoint', $dataSourcesHtml, 'Data Sources');