Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,14 @@ questions *by the label you wrote*, grouped with the submitter, the submission,
the form and the site — each row showing what it actually resolves to on this
site. Under the box, a "reads as" line with the tags filled in.

Typing `{` in any of those boxes opens the same list in place — and it is not
only for emails. Labels, hints, placeholders, headings and HTML blocks take it too, from the
inspector or straight on the canvas, where it offers the visitor's **earlier
answers**: "Nice to meet you, {field:name}" fills in live as they type, and is
blank until they do. Formula boxes offer the number-shaped questions, each with a
line on what it counts as (a dropdown counts as its option's price, a switch as
one or zero), and the formula editor explains every function.

**Send it to** is asked in plain language too: whoever runs this site, the person
who filled it in (naming your email question), a specific address, or free text
for the rest. The common cases need no tags at all.
Expand Down
56 changes: 56 additions & 0 deletions assets/css/builder.css
Original file line number Diff line number Diff line change
Expand Up @@ -2520,8 +2520,64 @@ html[data-os-mode='mobile'] :is( .atfb, .atfe, .atfs, .atfa ) :is( input, textar

.atfb-tagpick__meta {
display: block;
margin-block-start: 2px;
font-size: 11px;
line-height: 1.4;
color: var( --atfb-muted );
}

/* What it looks like once filled in. Quieter than the explanation above it, and
cut to one line: a long sample (a whole URL, every answer as a table) is there
to be recognised, not read. */
.atfb-tagpick__sample {
display: flex;
gap: 4px;
margin-block-start: 2px;
font-size: 11px;
color: var( --atfb-muted );
opacity: 0.85;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.atfb-tagpick__sample > span:last-child {
overflow: hidden;
text-overflow: ellipsis;
font-style: italic;
}

.atfb-tagpick__sample-label {
flex: 0 0 auto;
font-weight: 600;
}

.atfb-tagpick__tip {
flex-shrink: 0;
margin: 0;
padding-block-start: 6px;
border-block-start: 1px solid var( --atfb-border );
font-size: 10px;
color: var( --atfb-muted );
}

.atfb-tagpick__tip kbd {
padding: 0 4px;
border: 1px solid var( --atfb-border );
border-radius: 3px;
background: var( --atfb-surface );
font-size: 10px;
}

/* Beside text on the canvas. A card clips its overflow and is a drag handle, so
the picker cannot sit inside one; it floats at the caret's line instead, and
`showPicker()` sets `top` / `left` from the text's own box. */
.atfb-tagpick--floating {
position: fixed;
inset-inline-end: auto;
inline-size: 340px;
max-inline-size: calc( 100vw - 8px );
z-index: 1000;
}

.atfb-recipient {
Expand Down
1,160 changes: 753 additions & 407 deletions assets/js/builder.js

Large diffs are not rendered by default.

235 changes: 94 additions & 141 deletions assets/js/builder.min.js

Large diffs are not rendered by default.

79 changes: 79 additions & 0 deletions assets/js/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -1346,6 +1346,37 @@ var allTerrainFormsFront = function(exports) {
}
return true;
}
function fillRecall(template, answer) {
return template.replace(/\{field:([a-zA-Z0-9_-]+)\}/g, (_match, id) => answer(id));
}
const UNRECALLABLE = ["password", "file", "signature", "repeater", "page_break", "heading", "html", "divider", "spacer"];
function recallable(field) {
return !UNRECALLABLE.includes(field.type);
}
function choiceLabel(field, value) {
const match = (field.choices ?? []).find((choice) => String(choice.value) === String(value));
return match && match.label ? String(match.label) : String(value ?? "");
}
function recallText(field, value, words = { yes: "Yes", no: "No" }) {
if (null === value || void 0 === value || "" === value) {
return "";
}
if (typeof value === "boolean") {
return value ? words.yes : words.no;
}
const hasChoices = Array.isArray(field.choices) && field.choices.length > 0;
if (Array.isArray(value)) {
return value.map((item) => (hasChoices ? choiceLabel(field, item) : String(item ?? "")).trim()).filter(Boolean).join(", ");
}
if (typeof value === "object") {
const parts = Object.values(value).map((item) => (hasChoices ? choiceLabel(field, item) : String(item ?? "")).trim()).filter(Boolean);
if ("date_range" === field.type) {
return parts.join(" – ");
}
return parts.join("name" === field.type ? " " : ", ");
}
return hasChoices ? choiceLabel(field, value) : String(value);
}
const config = window.allTerrainForms;
const i18n = (key, fallback) => config?.i18n?.[key] ?? fallback;
class AllTerrainForm {
Expand Down Expand Up @@ -1600,6 +1631,54 @@ var allTerrainFormsFront = function(exports) {
submitted.value = input.value;
}
}
this.recall(values, calculated);
}
/**
* Fills every recalled answer — `{field:name}` in a label, hint or heading.
*
* The server prints an empty `.atf-recall` span per tag; this writes the
* answer into it on every change. A placeholder or a dropdown's first
* option cannot hold a span, so those carry their whole template in
* `data-atf-recall-template` and get the resolved text written back — into
* the attribute named by `data-atf-recall-attr`, or as text.
*
* A total reads its computed value, since its input is disabled and never
* part of `values()`. A tag naming a field the form does not have is put
* back as typed, which is what a notification does with it too — a typo
* should be visible, not silently blank.
*/
recall(values, calculated) {
const words = { yes: i18n("yes", "Yes"), no: i18n("no", "No") };
const answer = (id) => {
const field = this.schema.fields.find((candidate) => candidate.id === id);
if (!field) {
return `{field:${id}}`;
}
if (!recallable(field)) {
return "";
}
const computed = calculated[id];
const decimals = typeof field.decimals === "number" ? field.decimals : 2;
return field.formula && typeof computed === "number" ? computed.toFixed(decimals) : recallText(field, values[id], words);
};
this.form.querySelectorAll("[data-atf-recall]").forEach((span) => {
const text = answer(span.dataset.atfRecall ?? "");
if (span.textContent !== text) {
span.textContent = text;
}
});
this.form.querySelectorAll("[data-atf-recall-template]").forEach((element) => {
const text = fillRecall(element.dataset.atfRecallTemplate ?? "", answer);
const attribute = element.dataset.atfRecallAttr;
if (attribute) {
const next = text.trim() ? text : " ";
if (element.getAttribute(attribute) !== next) {
element.setAttribute(attribute, next);
}
} else if (element.textContent !== text) {
element.textContent = text;
}
});
}
/* ---------------------------------------------------------------- Steps */
/** Shows one page of a multi-page form. */
Expand Down
2 changes: 1 addition & 1 deletion assets/js/form.min.js

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ includes/
themes.php tokens, ten themes, CSS emitter
logic.php conditional logic ← twin of src/shared/logic.ts
calc.php the expression evaluator ← twin of src/shared/calc.ts
merge-tags.php {field:f1}, {all_fields}, quiz scoring
merge-tags.php {field:f1}, {all_fields}, quiz scoring, answer recall slots
render.php form chrome, client schema
render-controls.php one control per field type
availability.php scheduling, limits, prefill
Expand Down Expand Up @@ -358,13 +358,14 @@ src/
widget.ts the desktop widget
preview-button.ts the eye in the title bar
logic-map.ts conditions in words + the curves that draw them
merge-tags.ts the Insert-a-value picker
merge-tags.ts the Insert-a-value picker, and the `{` shortcut on canvas text
handoff.ts admin URL → native window
dock.ts the one dock tile and its flyout
relations.ts the window content graph
dnd.ts drag manager + fallback
api.ts / ui.ts / types.ts
shared/logic.ts, shared/calc.ts
shared/recall.ts which answers {field:…} can show inside the form, and how they read
tests/
fixtures/*.json the shared conformance tables
vitest/ TypeScript
Expand Down
13 changes: 13 additions & 0 deletions docs/mio/merge-tags.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ Notification templates, confirmation content and action templates may reference

Field IDs are stable references; labels are display text. Compound field tags can select supported subkeys, and extension tags may exist. Use the builder's merge-tag picker for exact available tokens. Unknown tag syntax is not an instruction. The resolver preserves unrecognized tags; definition validation does not establish that every tag will resolve to a nonempty value on every submission.

### Recalling answers inside the form

`{field:<id>}` also works in a field's `label`, `hint` and `placeholder`, a heading's `label`, an HTML block's `content` and a consent field's `consentText`. There it shows the visitor's answer to that field live, as they fill the form in, and is empty until they answer. Choices show the label they picked. Only `{field:…}` recalls — every other tag is resolved when the form is submitted, so it would print as written in a label. Password, file, signature and repeater answers are never shown. A required-field error names the field with the answer filled in ("Which size, Ada? is required.").

```yaml
- id: name
type: text
label: Your name
- id: size
type: radio
label: 'Which size, {field:name}?'
```

## Prefill

The field’s `prefill` string selects a supported query/user source. The builder lists current sources and keys. A hidden campaign value is useful for reporting but remains client-controlled. Do not use query prefilling to grant privileges or establish a trusted price. Preserve existing prefill expressions when editing labels or theme tokens.
Expand Down
2 changes: 2 additions & 0 deletions includes/assets.php
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,8 @@ function alltfo_client_strings() {
'colorSwatches' => __( 'Suggested colours', 'allterrain-forms' ),
'colorPick' => __( 'Pick a colour from the screen', 'allterrain-forms' ),
'colorNone' => __( 'No colour', 'allterrain-forms' ),
'yes' => __( 'Yes', 'allterrain-forms' ),
'no' => __( 'No', 'allterrain-forms' ),
'sending' => __( 'Sending…', 'allterrain-forms' ),
'sent' => __( 'Sent.', 'allterrain-forms' ),
'failed' => __( 'That did not send. Please try again.', 'allterrain-forms' ),
Expand Down
161 changes: 160 additions & 1 deletion includes/merge-tags.php
Original file line number Diff line number Diff line change
Expand Up @@ -649,7 +649,7 @@ function alltfo_merge_tag_answer_group( $schema ) {
$items = array();

foreach ( alltfo_input_fields( $schema ) as $field ) {
$label = isset( $field['label'] ) ? trim( (string) $field['label'] ) : '';
$label = isset( $field['label'] ) ? trim( alltfo_recall_readable( (string) $field['label'], $schema ) ) : '';

$items[] = array(
'tag' => '{field:' . $field['id'] . '}',
Expand Down Expand Up @@ -738,3 +738,162 @@ function alltfo_merge_tag_placeholder_for( $field ) {
function alltfo_merge_tag_sample( $tag ) {
return alltfo_replace_merge_tags( $tag, array( 'format' => 'text' ) );
}

/**
* Turns `{field:…}` in text shown inside a form into live answer slots.
*
* A label, hint, heading or HTML block is read long before anything is
* submitted, so the only tag that means anything there is an earlier answer.
* Each becomes an empty `<span class="atf-recall">` the form bundle fills as the
* visitor answers. Every other brace is left as written.
*
* Takes **already escaped or sanitised** markup and only touches text between
* tags: a `{field:x}` inside an attribute — a link's `href` in an HTML block —
* would otherwise have a `<span>` written into the middle of it.
*
* @since 1.4.0
*
* @param string $html Escaped or sanitised HTML.
* @return string The same HTML with recall slots.
*/
function alltfo_recall_markup( $html ) {
$html = (string) $html;

if ( false === strpos( $html, '{field:' ) ) {
return $html;
}

$chunks = preg_split( '/(<[^>]*>)/', $html, -1, PREG_SPLIT_DELIM_CAPTURE );

foreach ( $chunks as $index => $chunk ) {
if ( '' === $chunk || '<' === $chunk[0] ) {
continue;
}

$chunks[ $index ] = preg_replace_callback(
'/\{field:([a-zA-Z0-9_-]+)\}/',
static function ( $matches ) {
return sprintf( '<span class="atf-recall" data-atf-recall="%s"></span>', esc_attr( $matches[1] ) );
},
$chunk
);
}

return implode( '', $chunks );
}

/**
* Text with recalled answers filled in, for messages written on the server.
*
* "How old is {field:name}? is required" is what a validation error would say
* about a label that recalls an answer. The server has the answers by then, so
* it says "How old is Ada? is required" instead — the same words the visitor
* is looking at above the box.
*
* @since 1.4.0
*
* @param string $text Plain text, e.g. a field label.
* @param array $schema The form schema.
* @param array $values Field id => value.
* @return string
*/
function alltfo_recall_text( $text, $schema, $values ) {
$text = (string) $text;

if ( false === strpos( $text, '{field:' ) ) {
return $text;
}

return (string) preg_replace_callback(
'/\{field:([a-zA-Z0-9_-]+)\}/',
static function ( $matches ) use ( $schema, $values ) {
$field = alltfo_find_field( $schema, $matches[1] );

if ( ! $field ) {
return $matches[0];
}

// Never echoed back, whatever the label asks for — the same list
// `recallable()` in src/shared/recall.ts keeps out of the picker.
if ( in_array( $field['type'], array( 'password', 'file', 'signature', 'repeater' ), true ) ) {
return '';
}

$value = isset( $values[ $matches[1] ] ) ? $values[ $matches[1] ] : '';

return alltfo_value_is_empty( $value ) ? '' : alltfo_format_field_value( $value, $field, 'text' );
},
$text
);
}

/**
* Text with its recall tags removed — what a placeholder shows before any
* answer exists.
*
* @since 1.4.0
*
* @param string $text Plain text.
* @return string
*/
function alltfo_recall_strip( $text ) {
return (string) preg_replace( '/\{field:[a-zA-Z0-9_-]+\}/', '', (string) $text );
}

/**
* Attributes that let the form bundle re-fill text it cannot put a `<span>` in.
*
* A placeholder is an attribute and a dropdown's first option is text-only, so
* neither can carry a recall slot. Instead the element carries its template;
* the bundle resolves it on every change and writes the result into the named
* attribute, or into the element's text when none is named.
*
* @since 1.4.0
*
* @param string $text The text as written, tags included.
* @param string $attribute Optional. The attribute to fill, e.g. `placeholder`.
* @return string Attribute markup with a leading space, or '' without tags.
*/
function alltfo_recall_template( $text, $attribute = '' ) {
if ( false === strpos( (string) $text, '{field:' ) ) {
return '';
}

$out = sprintf( ' data-atf-recall-template="%s"', esc_attr( $text ) );

if ( '' !== $attribute ) {
$out .= sprintf( ' data-atf-recall-attr="%s"', esc_attr( $attribute ) );
}

return $out;
}

/**
* A label as a person reads it in a list, with its recall tags named.
*
* "{field:name} choose the brand colour" reads as "‹Your name› choose the brand
* colour" in the value picker. Twin of `readableLabel()` in
* src/shared/recall.ts.
*
* @since 1.4.0
*
* @param string $label The label as written.
* @param array $schema The form schema.
* @return string
*/
function alltfo_recall_readable( $label, $schema ) {
if ( false === strpos( $label, '{field:' ) ) {
return $label;
}

return (string) preg_replace_callback(
'/\{field:([a-zA-Z0-9_-]+)\}/',
static function ( $matches ) use ( $schema ) {
$target = alltfo_find_field( $schema, $matches[1] );
$name = $target ? alltfo_recall_strip( (string) $target['label'] ) : '';

return '‹' . ( '' !== trim( $name ) ? trim( $name ) : $matches[1] ) . '›';
},
$label
);
}
Loading
Loading