prior to portal save logic update

This commit is contained in:
Adrian Stephens 2026-05-15 15:07:57 +01:00
parent ff3dff9a9a
commit 514d6ee1ae
8 changed files with 1100 additions and 1 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

@ -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

@ -0,0 +1,509 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8"/>
<title></title>
<meta name="generator" content="LibreOffice 25.2.3.2 (Linux)"/>
<meta name="created" content="2026-05-15T09:28:08.898968877"/>
<meta name="changed" content="2026-05-15T14:23:21.219599799"/>
<style type="text/css">
@page { size: 21cm 29.7cm; margin: 2cm }
p { margin-bottom: 0.25cm; line-height: 115%; background: transparent }
h1 { margin-bottom: 0.21cm; background: transparent; page-break-after: avoid }
h1.western { font-weight: bold; font-size: 18pt; font-family: "Liberation Sans", sans-serif }
h1.cjk { font-weight: bold; font-size: 18pt; font-family: "UKIJ CJK" }
h1.ctl { font-weight: bold; font-family: "FreeSans"; font-size: 18pt }
h2 { margin-top: 0.35cm; margin-bottom: 0.21cm; background: transparent; page-break-after: avoid }
h2.western { font-weight: bold; font-size: 16pt; font-family: "Liberation Sans", sans-serif }
h2.cjk { font-weight: bold; font-size: 16pt; font-family: "UKIJ CJK" }
h2.ctl { font-weight: bold; font-family: "FreeSans"; font-size: 16pt }
td p { widows: 0; orphans: 0; background: transparent }
</style>
</head>
<body lang="en-GB" link="#000080" vlink="#800000" dir="ltr"><h1 class="western">
What do we do about the bottom (of the members Portal)?</h1>
<p>The goal of this document is to provide an improved design of the
bottom panes.</p>
<p style="line-height: 100%; margin-bottom: 0cm"><br/>
</p>
<p style="line-height: 100%; margin-bottom: 0cm">Problems:</p>
<ul>
<li><p style="line-height: 100%; margin-bottom: 0cm">The user
doesnt necessarily understand the language use</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">They
undoubtedly wont understand the model of a progression of
application states</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">They wont
want to read too much</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">They want a
clear indication of what they can and should do next</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">They cant do
or dont know how to do something they reasonably want to do</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">They wont
know in advance the effect of pressing save / submit</p></li>
</ul>
<p style="line-height: 100%; margin-bottom: 0cm"><br/>
</p>
<p style="line-height: 100%; margin-bottom: 0cm">The goals are the
resolution of these problems:</p>
<ul>
<li><p style="line-height: 100%; margin-bottom: 0cm">Use simple
language, avoid jargon (e.g. “boxes” vs “fields”)</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">Make the state
explicit in terms they can understand</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">Be brief</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">Tell them what
they should do next</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">If something is
not possible because of the state of the form, tell them how to fix
it</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">Tell them what
is going to happen before they commit to pressing a button</p></li>
</ul>
<p style="line-height: 100%; margin-bottom: 0cm">(some of these goals
are in tension)</p>
<p style="line-height: 100%; margin-bottom: 0cm"><br/>
</p>
<p style="line-height: 100%; margin-bottom: 0cm">Underlying states of
a record:</p>
<ul>
<li><p style="line-height: 100%; margin-bottom: 0cm">New (not yet
saved)</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">Draft (saved,
not submitted)</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">Pending
(submitted)</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">Member /
Affiliate (approved by MC)</p></li>
</ul>
<p style="line-height: 100%; margin-bottom: 0cm"><br/>
</p>
<p style="line-height: 100%; margin-bottom: 0cm">The “changed
state” of the form can also be:</p>
<ul>
<li><p style="line-height: 100%; margin-bottom: 0cm">clean (new /
reviewing)</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">dirty (editing)</p></li>
</ul>
<p style="line-height: 100%; margin-bottom: 0cm"><br/>
</p>
<p style="line-height: 100%; margin-bottom: 0cm">And the validation
of the form can be:</p>
<ul>
<li><p style="line-height: 100%; margin-bottom: 0cm">Not valid for
save as draft (missing Organisation name, Contact 1 name or Contact
1 email)</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">Valid for save
as draft, not valid for submit (missing required field(s))</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">Valid for
submit / update (required fields present)</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">Not valid for
update (missing required field(s))</p></li>
</ul>
<p style="line-height: 100%; margin-bottom: 0cm"><br/>
</p>
<p style="line-height: 100%; margin-bottom: 0cm">The actions are:</p>
<ul>
<li><p style="line-height: 100%; margin-bottom: 0cm">Save as draft</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">Update draft</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">Submit
application</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">Update
application</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">Update
member/affiliate record</p></li>
</ul>
<p style="line-height: 100%; margin-bottom: 0cm"><br/>
</p>
<p style="line-height: 100%; margin-bottom: 0cm">The effects of
actions are:</p>
<ul>
<li><p style="line-height: 100%; margin-bottom: 0cm">Create a new
record (draft)</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">Update an
existing record with (draft → pending) or without (pending,
member/affiliate) state change</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">Generate an
email to contact 1 with details and access token</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">Generate an
email to FECA officers with details</p></li>
</ul>
<p style="line-height: 100%; margin-bottom: 0cm"><br/>
</p>
<h2 class="western">User interface generally</h2>
<p style="line-height: 100%; margin-bottom: 0cm"><br/>
</p>
<p style="line-height: 100%; margin-bottom: 0cm">The bottom area
(below the public contact pane) holds one or more action panes. Each
action pane is related to a single possible action. Each pane may
or may not be visible depending on the state of the record and form.</p>
<p style="line-height: 100%; margin-bottom: 0cm"><br/>
</p>
<p style="line-height: 100%; margin-bottom: 0cm">More precisely an
action pane is in one of three states:</p>
<ul>
<li><p style="line-height: 100%; margin-bottom: 0cm">Not visible
the action is irrelevant</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">Visible but
disabled the action is a possible next step, but there is
something to do first</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">Visible and
enabled the action can be performed</p></li>
</ul>
<p style="line-height: 100%; margin-bottom: 0cm"><br/>
</p>
<p style="line-height: 100%; margin-bottom: 0cm">The action pane
consists of:</p>
<ul>
<li><p style="line-height: 100%; margin-bottom: 0cm">A rounded
boundary rectangle to tie the components of the pane together</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">A title</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">A button with a
legend, which performs the indicated action when pressed</p></li>
<li><p style="line-height: 100%; margin-bottom: 0cm">Explanatory
text to the right of the title and button, which varies according to
enable/disable state</p></li>
</ul>
<p style="line-height: 100%; margin-bottom: 0cm"><br/>
</p>
<p style="line-height: 100%; margin-bottom: 0cm">The following
conditions, along with the apps overall state are used to control
the action panes.</p>
<p style="line-height: 100%; margin-bottom: 0cm"><br/>
</p>
<table width="100%" cellpadding="4" cellspacing="0">
<col width="83*"/>
<col width="173*"/>
<tr valign="top">
<td width="32%" style="border-top: 1px solid #000000; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0.1cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Condition</b></p>
</td>
<td width="68%" style="border: 1px solid #000000; padding: 0.1cm"><p>
<b>Definition</b></p>
</td>
</tr>
<tr valign="top">
<td width="32%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
Save as Draft Valid</p>
</td>
<td width="68%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0.1cm"><p>
Organisation Name, Contact 1 First Name and Last Name and Contact
1 Email specified</p>
</td>
</tr>
<tr valign="top">
<td width="32%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
Form Complete</p>
</td>
<td width="68%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0.1cm"><p>
All required fields are present</p>
</td>
</tr>
</table>
<p style="line-height: 100%; margin-bottom: 0cm"><br/>
</p>
<p style="line-height: 100%; margin-bottom: 0cm"><br/>
</p>
<h2 class="western">Save As Draft</h2>
<table width="100%" cellpadding="4" cellspacing="0">
<col width="64*"/>
<col width="192*"/>
<tr valign="top">
<td width="25%" style="border-top: 1px solid #000000; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0.1cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Title / Button Legend</b></p>
</td>
<td width="75%" style="border: 1px solid #000000; padding: 0.1cm"><p>
Save As Draft</p>
</td>
</tr>
<tr valign="top">
<td width="25%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Pane Visible When</b></p>
</td>
<td width="75%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0.1cm"><p>
Editing a new FECA application</p>
</td>
</tr>
<tr valign="top">
<td width="25%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Button Enabled When</b></p>
</td>
<td width="75%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0.1cm"><p>
Save as Draft Valid</p>
</td>
</tr>
<tr valign="top">
<td width="25%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Panel “Disabled” Text</b></p>
</td>
<td width="75%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0.1cm"><p>
Fill in the ** boxes (replacing ** with a list of the fields
missing from the Save as Draft Valid condition)</p>
</td>
</tr>
<tr valign="top">
<td width="25%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Panel “Enabled” Text</b></p>
</td>
<td width="75%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0.1cm"><p>
You can save this as a draft application. You will receive an
email with a link that will enable you to continue working on it.</p>
<p>FECA officers will not be notified and will not take any action
on draft applications.</p>
</td>
</tr>
</table>
<p><br/>
<br/>
</p>
<p><br/>
<br/>
</p>
<p><br/>
<br/>
</p>
<p style="line-height: 100%; margin-bottom: 0cm"><br/>
</p>
<h2 class="western">Update Draft</h2>
<table width="100%" cellpadding="4" cellspacing="0">
<col width="64*"/>
<col width="192*"/>
<tr valign="top">
<td width="25%" style="border-top: 1px solid #000000; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0.1cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Title / Button Legend</b></p>
</td>
<td width="75%" style="border: 1px solid #000000; padding: 0.1cm"><p>
Update Draft</p>
</td>
</tr>
<tr valign="top">
<td width="25%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Pane Visible When</b></p>
</td>
<td width="75%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0.1cm"><p>
Editing an existing draft application</p>
</td>
</tr>
<tr valign="top">
<td width="25%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Button Enabled When</b></p>
</td>
<td width="75%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0.1cm"><p>
Save as Draft Valid</p>
</td>
</tr>
<tr valign="top">
<td width="25%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Panel “Disabled” Text</b></p>
</td>
<td width="75%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0.1cm"><p>
Fill in the ** boxes (replacing ** with a list of the fields
missing from the Save as Draft Valid condition)</p>
</td>
</tr>
<tr valign="top">
<td width="25%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Panel “Enabled” Text</b></p>
</td>
<td width="75%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0.1cm"><p>
You can update your draft application.
</p>
<p>FECA officers will not be notified and will not take any action
on draft applications.</p>
</td>
</tr>
</table>
<p style="line-height: 100%; margin-bottom: 0cm"><br/>
</p>
<h2 class="western">Submit Application</h2>
<table width="100%" cellpadding="4" cellspacing="0">
<col width="64*"/>
<col width="192*"/>
<tr valign="top">
<td width="25%" style="border-top: 1px solid #000000; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0.1cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Title / Button Legend</b></p>
</td>
<td width="75%" style="border: 1px solid #000000; padding: 0.1cm"><p>
Submit Application</p>
</td>
</tr>
<tr valign="top">
<td width="25%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Pane Visible When</b></p>
</td>
<td width="75%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0.1cm"><p>
Editing a new or existing draft application</p>
</td>
</tr>
<tr valign="top">
<td width="25%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Button Enabled When</b></p>
</td>
<td width="75%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0.1cm"><p>
Form Complete</p>
</td>
</tr>
<tr valign="top">
<td width="25%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Panel “Disabled” Text</b></p>
</td>
<td width="75%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0.1cm"><p>
Fill in the ** boxes (replacing ** with a list of the fields
missing from the Form Complete condition)</p>
</td>
</tr>
<tr valign="top">
<td width="25%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Panel “Enabled” Text</b></p>
</td>
<td width="75%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0.1cm"><p>
You can submit your application. You will receive an email with a
link that will enable you to make changes to it after submission.</p>
<p>FECA officers will be notified and will consider your
application at their next meeting. They will get back to Contact 1
with the outcome.</p>
</td>
</tr>
</table>
<p style="line-height: 100%; margin-bottom: 0cm"><br/>
</p>
<p style="line-height: 100%; margin-bottom: 0cm"><br/>
</p>
<h2 class="western">Update Application</h2>
<p style="line-height: 100%; margin-bottom: 0cm"><br/>
</p>
<table width="100%" cellpadding="4" cellspacing="0">
<col width="64*"/>
<col width="192*"/>
<tr valign="top">
<td width="25%" style="border-top: 1px solid #000000; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0.1cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Title / Button Legend</b></p>
</td>
<td width="75%" style="border: 1px solid #000000; padding: 0.1cm"><p>
Update Application</p>
</td>
</tr>
<tr valign="top">
<td width="25%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Pane Visible When</b></p>
</td>
<td width="75%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0.1cm"><p>
Editing an existing pending application</p>
</td>
</tr>
<tr valign="top">
<td width="25%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Button Enabled When</b></p>
</td>
<td width="75%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0.1cm"><p>
Form Complete</p>
</td>
</tr>
<tr valign="top">
<td width="25%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Panel “Disabled” Text</b></p>
</td>
<td width="75%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0.1cm"><p>
Fill in the ** boxes (replacing ** with a list of the fields
missing from the Form Complete condition)</p>
</td>
</tr>
<tr valign="top">
<td width="25%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Panel “Enabled” Text</b></p>
</td>
<td width="75%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0.1cm"><p>
You have made changes to your details. You can update your member
record. You will receive an email with a confirmation of changes
made.</p>
<p>FECA officers will be notified of these changes.</p>
</td>
</tr>
</table>
<p style="line-height: 100%; margin-bottom: 0cm"><br/>
</p>
<p style="line-height: 100%; margin-bottom: 0cm"><br/>
</p>
<h2 class="western">Update Member/ Affiliate record</h2>
<p style="line-height: 100%; margin-bottom: 0cm"><br/>
</p>
<table width="100%" cellpadding="4" cellspacing="0">
<col width="64*"/>
<col width="192*"/>
<tr valign="top">
<td width="25%" style="border-top: 1px solid #000000; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0.1cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Title / Button Legend</b></p>
</td>
<td width="75%" style="border: 1px solid #000000; padding: 0.1cm"><p>
Update Member record / Update Affiliate Member record (depending
on account.type.slug == “member” or “affiliate”)</p>
</td>
</tr>
<tr valign="top">
<td width="25%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Pane Visible When</b></p>
</td>
<td width="75%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0.1cm"><p>
Editing an existing member/affiliate record</p>
</td>
</tr>
<tr valign="top">
<td width="25%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Button Enabled When</b></p>
</td>
<td width="75%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0.1cm"><p>
Form Complete</p>
</td>
</tr>
<tr valign="top">
<td width="25%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Panel “Disabled” Text</b></p>
</td>
<td width="75%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0.1cm"><p>
Fill in the ** boxes (replacing ** with a list of the fields
missing from the Form Complete condition)</p>
</td>
</tr>
<tr valign="top">
<td width="25%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: none; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0cm"><p>
<b>Panel “Enabled” Text</b></p>
</td>
<td width="75%" style="border-top: none; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000; padding-top: 0cm; padding-bottom: 0.1cm; padding-left: 0.1cm; padding-right: 0.1cm"><p>
You have made changes to your details. You can update your member
record. You will receive an email with a confirmation of changes
made.</p>
<p>FECA officers will be notified of these changes.</p>
</td>
</tr>
</table>
<p style="line-height: 100%; margin-bottom: 0cm"><br/>
</p>
</body>
</html>

View File

@ -0,0 +1,460 @@
# What Do We Do About The Bottom Of The Member Portal? v3
This document replaces the existing Save and Submit panel at the bottom of the FECA Member Portal with separate action panes. It is based on the original LibreOffice/HTML draft plus a review of current behaviour in `../members-plugin/requirements/member-portal.md`, `../members-plugin/code/feca_plugin_2.php`, and `../members-plugin/code/data_member_interaction.php`.
The goal is to make the next available actions obvious, explain why an action cannot currently be taken, and state what will happen before the user presses a button.
## Problems To Solve
- Users do not necessarily understand internal language such as "fields", "states", or "workflow".
- Users will not naturally understand the model of progression from draft to pending to approved record.
- Users do not want to read a long explanation before acting.
- Users need a clear indication of what they can and should do next.
- Users may reasonably want to do something that is currently blocked, and need to know how to fix it.
- Users should know the effect of pressing save or submit before they commit.
## Design Goals
- Use simple language. Prefer "boxes" over "fields" in user-facing text.
- Make the record state explicit in terms users can understand.
- Keep text brief.
- Tell users what they should do next.
- If an action is blocked, tell users what to fix.
- Before each action, explain the effect of pressing the button.
Each pane should use one short primary sentence plus one short consequence sentence. Longer technical detail belongs in server-side validation messages only when an action fails.
## Current Behaviour Summary
The current portal uses a single `Save and Submit` panel below `Public Contact`.
Current buttons:
- `Save Draft`
- `Save Updates to Draft`
- `Save Changes`
- `Submit Application`
Current behaviour hides unavailable buttons rather than showing disabled buttons. A shared right-side guidance message explains why an action is unavailable or what is ready.
Current UI state handling:
- New application has no account ID until an action is taken.
- Draft save or submit from a new application first calls `public_start` to create draft context, then calls `portal_save` or `portal_submit`.
- Existing draft, pending, member, and affiliate records are loaded with token authentication.
- Existing update buttons are only shown when there are unsaved changes and the required validation passes.
- Submit is an exception for draft records because it can be available even when there are no unsaved changes; it changes the record state to pending.
Current backend action effects:
- Draft save calls `portal_save`, leaves or creates the record as `draft`, and sends Contact 1 a draft email. Officers are not notified.
- Submit calls `portal_submit`, saves the data, moves the record to `pending`, sends Contact 1 a confirmation email, and notifies configured FECA officers.
- Pending/member/affiliate updates call `portal_save`, keep the existing record state, send Contact 1 a confirmation email, and notify configured FECA officers.
Current post-action status behaviour:
- A status message is shown and scrolled into view after success or error.
- The status message remains on the page; the form is not exited.
- User-entered data is preserved on validation or server errors.
Action panes should be visible when the action is a meaningful next step for the current record state, but disabled when validation or dirty-state rules block it. This intentionally changes the current hidden-button behaviour so users can see what is possible next and why it is blocked.
## Record States
Underlying record states:
- `new`: no saved account exists yet.
- `draft`: saved but not submitted.
- `pending`: submitted for FECA officer/MC review.
- `member`: approved member record.
- `affiliate`: approved affiliate record.
- `rejected`: rejected application.
Rejected records are not editable through the public portal unless explicitly re-opened by an officer. No save/submit/update action pane should be shown for rejected records.
## Form Change State
The form also has a change state:
- `clean`: the form matches the last loaded or saved payload.
- `dirty`: the user has unsaved changes.
For a new unsaved application, treat the form as dirty once any application input is present.
Update actions for existing draft, pending, member, and affiliate records are disabled when the form is clean, with text saying there are no changes to save. Submit remains enabled for a clean draft if the form is complete because submit changes state.
## Validation States
The UI and server must agree on validation. The action panes must not rely on UI-only validation for state-changing saves.
**Implementation resolution:** add or reuse server-side validation so pending/member/affiliate update actions enforce the same `Form Complete` rule used to enable their panes. Server validation remains authoritative; if the browser enables an action incorrectly, the server must reject it and return missing-field details that can be displayed in the triggering pane.
### Draft Minimum Valid
Draft save/update requires:
- Organisation Name
- Contact 1 First Name
- Contact 1 Last Name
- Contact 1 Email
Both first name and last name are required for draft minimum validity. User-facing missing text may group these as "Contact 1 name".
### Form Complete
Submission/update requires all full application required fields.
Account (new, pending):
- Organisation Name
- Sector
- Public Location
- Number of members
- What does your group do?
- What benefit does your group provide to the Fen Edge Community?
- How does your group expect to benefit from FECA Membership?
Account (member, affiliate):
- Organisation Name
- Sector
- Public Location
- Number of members
Note: when updating an existing member or affiliate record, the "three questions" are not required.
This is primarily to grandfather existing records that do not include this data, and also because this data is mainly useful when the FECA committee considers an applicant for membership.
Contact 1:
- First Name
- Last Name
- Position in Organisation
- Email
- Address 1
- Town
- Postcode
Contact 2, when specified:
- First Name
- Last Name
- Position in Organisation
- Email
Fen Edge News Contact, when specified as a new standalone contact:
- First Name
- Last Name
- Email
Existing standalone Fen Edge News contacts should not block unrelated save/update actions solely because legacy data is incomplete. If the user edits or replaces the Fen Edge News contact as a standalone contact, the edited/new standalone contact must satisfy the same first-name, last-name, and email requirements.
**Implementation resolution:** preserve the current behaviour that does not require a Fen Edge News contact for submission. Tighten validation only for a newly created or edited standalone Fen Edge News contact.
Public Contact:
- If a standalone public contact is used: Last Name, or organisation contact such as "Office"
- At least one of Public Email or Public Phone
Format validation:
- Email boxes must contain a valid email shape.
- Phone boxes accept digits and spaces only.
- Picklist-backed boxes must use configured picklist values.
## Action Pane Model
The bottom area below the Public Contact pane holds one or more action panes. Each action pane maps to exactly one action.
Each pane may be:
- Not visible: the action is irrelevant for the current record state.
- Visible and disabled: the action is a possible next step, but something must be fixed first.
- Visible and enabled: the action can be performed.
Each pane contains:
- A rounded boundary rectangle.
- A title.
- A button with the same user-facing label as the title.
- Explanatory text beside the title/button on desktop and below them on narrow screens.
- An inline pending/error/result message area for that action.
Desktop uses side-by-side layout; mobile stacks button/title above explanatory text.
Transient action progress and action-specific failures appear inside the pane that triggered the action. The pane-local message is the primary result display for bottom-pane actions.
The existing top status panel may optionally mirror the final success/failure text for consistency, but bottom-pane actions must not scroll focus to the top status panel. Keeping the user at the action pane avoids disorienting movement after they press a bottom-of-page button.
## Action Visibility And Enablement Matrix
| Record state | Change state | Save As Draft | Update Draft | Submit Application | Update Application | Update Member/Affiliate Record |
| ------------ | -------------: | --------------------------------------: | --------------------------------------: | --------------------------------: | --------------------------------: | --------------------------------: |
| new | clean/no input | visible disabled | hidden | visible disabled | hidden | hidden |
| new | dirty | visible; enabled if Draft Minimum Valid | hidden | visible; enabled if Form Complete | hidden | hidden |
| draft | clean | hidden | visible disabled | visible; enabled if Form Complete | hidden | hidden |
| draft | dirty | hidden | visible; enabled if Draft Minimum Valid | visible; enabled if Form Complete | hidden | hidden |
| pending | clean | hidden | hidden | hidden | visible disabled | hidden |
| pending | dirty | hidden | hidden | hidden | visible; enabled if Form Complete | hidden |
| member | clean | hidden | hidden | hidden | hidden | visible disabled |
| member | dirty | hidden | hidden | hidden | hidden | visible; enabled if Form Complete |
| affiliate | clean | hidden | hidden | hidden | hidden | visible disabled |
| affiliate | dirty | hidden | hidden | hidden | hidden | visible; enabled if Form Complete |
| rejected | any | hidden | hidden | hidden | hidden | hidden |
Both Save As Draft and Submit Application are visible for a new application because both are legitimate paths. Submitting a new application creates draft context internally, then submits it to pending.
## Action Effects Matrix
| Action | API behaviour | Resulting state | Contact 1 email | Officer email |
| ----------------------- | ------------------------------------------------------------------- | --------------- | ---------------------------------------------------- | ------------- |
| Save As Draft | If no account exists, call `public_start`, then `portal_save` | `draft` | yes, draft saved email with return link | no |
| Update Draft | `portal_save` | `draft` | yes, draft saved/updated email with return link | no |
| Submit Application | If no account exists, call `public_start`, then `portal_submit` | `pending` | yes, application submitted email with return link | yes |
| Update Application | `portal_save` | `pending` | yes, application updated email with return link | yes |
| Update Member Record | `portal_save` | `member` | yes, member record updated email with return link | yes |
| Update Affiliate Record | `portal_save` | `affiliate` | yes, affiliate record updated email with return link | yes |
The matrix above is normative.
These actions ensure a usable return link but do not regenerate an existing token. Token regeneration remains a separate explicit officer/admin action.
## Action Pane Specifications
### Save As Draft
Title/button label: `Save As Draft`
Visible when:
- Editing a new FECA application.
Enabled when:
- Draft Minimum Valid.
Disabled text:
- If no application input has been entered: `Start filling in the application. You can save a draft after Organisation Name and Contact 1 details are filled in.`
- If draft minimum fields are missing: `Fill in the <missing boxes> boxes before saving a draft.`
- If format errors exist: `Fix the highlighted email or phone boxes before saving a draft.`
Enabled text:
- `You can save this as a draft application. Contact 1 will receive an email with a link to continue working on it.`
- `FECA officers will not be notified and will not take any action on draft applications.`
On click:
- Disable the action button while the request is running.
- Show `Saving draft...` in this pane.
- Create draft context if needed, then save via `portal_save`.
- On success, show the draft success message and recompute action panes from the saved draft state.
### Update Draft
Title/button label: `Update Draft`
Visible when:
- Editing an existing draft application.
Enabled when:
- The form is dirty.
- Draft Minimum Valid.
Disabled text:
- If clean: `No changes have been made, so there is nothing to update.`
- If draft minimum fields are missing: `Fill in the <missing boxes> boxes before updating the draft.`
- If format errors exist: `Fix the highlighted email or phone boxes before updating the draft.`
Enabled text:
- `You can update your draft application. Contact 1 will receive an email with the latest saved details and return link.`
- `FECA officers will not be notified and will not take any action on draft applications.`
### Submit Application
Title/button label: `Submit Application`
Visible when:
- Editing a new application.
- Reviewing or editing an existing draft application.
Enabled when:
- Form Complete.
Disabled text:
- `Fill in the <missing boxes> boxes before submitting the application.`
- If format errors exist: `Fix the highlighted email or phone boxes before submitting the application.`
Enabled text:
- `You can submit your application. Contact 1 will receive an email with a link to review or amend it after submission.`
- `FECA officers will be notified and will consider the application at their next meeting. They will get back to Contact 1 with the outcome.`
On click:
- Disable the action button while the request is running.
- Show `Submitting application...` in this pane.
- Create draft context if needed, then submit via `portal_submit`.
- On success, show the submission success message and recompute action panes from pending state.
Pane copy should use "application"; existing email wording may remain until separately revised.
### Update Application
Title/button label: `Update Application`
Visible when:
- Reviewing or editing an existing pending application.
Enabled when:
- The form is dirty.
- Form Complete.
Disabled text:
- If clean: `No changes have been made, so there is nothing to update.`
- If required fields are missing: `Fill in the <missing boxes> boxes before updating the application.`
- If format errors exist: `Fix the highlighted email or phone boxes before updating the application.`
Enabled text:
- `You can update your submitted application. Contact 1 will receive an email confirming the changes.`
- `FECA officers will be notified of these changes.`
### Update Member Record
Title/button label:
- `Update Member Record` when `account.type.slug == "member"`.
- `Update Affiliate Record` when `account.type.slug == "affiliate"`.
Visible when:
- Reviewing or editing an existing member or affiliate record.
Enabled when:
- The form is dirty.
- Form Complete.
Disabled text:
- If clean: `No changes have been made, so there is nothing to update.`
- If required fields are missing: `Fill in the <missing boxes> boxes before updating the record.`
- If format errors exist: `Fix the highlighted email or phone boxes before updating the record.`
Enabled text:
- `You can update your FECA membership record. Contact 1 will receive an email confirming the changes.`
- `FECA officers will be notified of these changes.`
## Missing Box Text
When panes list missing boxes, use friendly labels, not canonical API keys.
Examples:
- `Organisation Name`
- `Contact 1 name`
- `Contact 1 email`
- `Sector`
- `Public Location`
- `Number of members`
- `What does your group do?`
- `Contact 1 address`
- `Public Email or Public Phone`
For long missing lists:
- Show up to five missing boxes in the pane text.
- If more than five are missing, append `and <n> more highlighted boxes`.
- The highlighted boxes in the form remain the complete source of truth.
## Progress, Success, And Error Behaviour
While an action is running:
- Disable that action button.
- Keep other action buttons disabled until the request finishes.
- Show a pane-local progress message.
- Prevent duplicate submissions from double-clicks.
On success:
- Preserve the user on the same page.
- Update the loaded payload from the API response.
- Mark the form clean.
- Recompute record state, pane visibility, and pane enablement.
- Show a success message inside the pane that initiated the action.
- Optionally mirror the success message in the existing top status panel without scrolling to it.
Success messages:
- Draft save: `Draft application saved. Contact 1 should receive an email with details on how to re-enter this page. You can also make changes below and save them, or you can complete the application and submit it.`
- Application submit: `Application submitted. Contact 1 should receive a confirmation email. FECA officers have also been notified. You can continue to make changes below and save updates to the application.`
- Pending application update: `Application updates saved. Contact 1 should receive a confirmation email. FECA officers have also been notified.`
- Member/affiliate update: `Updates saved. Contact 1 should receive a confirmation email. FECA officers have also been notified.`
On failure:
- Do not leave the page.
- Preserve entered data.
- Show the error inside the pane that initiated the action.
- Optionally mirror the error in the existing top status panel without scrolling to it.
- Apply missing-field highlighting returned by the server.
## Accessibility And Layout
- Disabled buttons must use the HTML `disabled` attribute.
- Disabled explanations must remain visible as normal text, not only as tooltips.
- Pane-local messages should be announced with an appropriate live region.
- The existing top status panel should remain focusable for other portal flows, but bottom-pane actions should keep focus near the triggering pane unless a severe page-level error prevents pane-local feedback.
- On mobile, panes stack title/button above explanatory text.
## Replacement Scope
This v3 replaces only the bottom `Save and Submit` panel presentation and state logic. It does not change:
- API operation names.
- The validation rules, except to make them explicit in this document.
- Email formats.
- Token authentication.
- Contact identity semantics.
- Officer approval/rejection flows.
Implement action panes as a UI replacement over the existing `portal_save`, `portal_submit`, and `public_start` flows, with server validation remaining authoritative.
If current server behaviour does not yet enforce the same update validation used by these panes, update the server as part of this work rather than treating pane disabling as sufficient protection.
## Test Expectations
Add or update browser tests to cover:
- New application with empty form: Save As Draft and Submit Application panes visible but disabled.
- New application draft minimum valid but incomplete: Save As Draft enabled, Submit Application disabled.
- New application complete: Save As Draft and Submit Application enabled.
- Existing draft clean but incomplete: Update Draft disabled, Submit Application disabled.
- Existing draft clean and complete: Update Draft disabled, Submit Application enabled.
- Existing draft dirty and draft-minimum valid but incomplete: Update Draft enabled, Submit Application disabled.
- Existing draft dirty and complete: Update Draft and Submit Application enabled.
- Pending clean: Update Application visible disabled with "nothing to update".
- Pending dirty incomplete: Update Application visible disabled with missing boxes.
- Pending dirty complete: Update Application enabled.
- Member clean: Update Member Record visible disabled with "nothing to update".
- Member dirty complete: Update Member Record enabled.
- Affiliate clean/dirty equivalent, with `Update Affiliate Record` label.
- Rejected record: no action panes.
- Server validation failure remains on page and preserves data.
- Successful actions show existing success messages and recompute pane state.
The matrix above should be implemented as Playwright/browser coverage tests because this is primarily state-driven UI behaviour. Add at least one server/API regression test proving that pending/member/affiliate update saves reject incomplete required data even if called directly.

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
* [ ] 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');