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
79 changes: 67 additions & 12 deletions WebUI/src/main/ts/assembly/AssemblyHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@
* Preview-first Active Assembly host. Renders the assembled page or snippet
* template in an iframe with a light overlay. Slot add / create / arrange
* use relationship REST (no Data Flow HTML). Single-line text, long-text,
* HTML, link, and whole-number field edits use the assembled nodes (HTML keeps
* its markup; single-line text stays one line; long text keeps line breaks;
* a number is one whole number) and persist through itemmanagement — not
* leftover Content Editor HTML.
* HTML, link, whole-number, and calendar-date field edits use the assembled
* nodes (HTML keeps its markup; single-line text stays one line; long text
* keeps line breaks; a number is one whole number; a date is one calendar
* day) and persist through itemmanagement — not leftover Content Editor HTML.
* Datetime stays on the Content Editor.
*/

import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
Expand Down Expand Up @@ -69,6 +70,7 @@ import {
ASSEMBLY_VALUE_LONGTEXT,
blankRequiredTextFieldNames,
changedOverlayEdits,
invalidChangedDateFieldNames,
invalidChangedNumberFieldNames,
markAssemblyFieldErrors,
overlayEditKey,
Expand Down Expand Up @@ -632,29 +634,40 @@ export function AssemblyHost({
visibleEdits,
fieldBaselineRef.current,
);
if (blankRequired.length > 0 || badNumbers.length > 0) {
const badDates = invalidChangedDateFieldNames(
overlayFields,
visibleEdits,
fieldBaselineRef.current,
);
if (blankRequired.length > 0 || badNumbers.length > 0 || badDates.length > 0) {
const requiredText = message(ASSEMBLY_MSG.FIELD_REQUIRED);
const numberText = message(ASSEMBLY_MSG.FIELD_NUMBER);
const dateText = message(ASSEMBLY_MSG.FIELD_DATE);
const errors: Record<string, string> = {};
for (const name of blankRequired) {
errors[name] = requiredText;
}
for (const name of badNumbers) {
errors[name] = numberText;
}
if (badNumbers.length > 0) {
const numberFields = overlayFields.filter((field) =>
badNumbers.includes(field.name),
);
for (const name of badDates) {
errors[name] = dateText;
}
const refused = overlayFields.filter(
(field) => badNumbers.includes(field.name) || badDates.includes(field.name),
);
if (refused.length > 0) {
if (doc != null) {
restoreOverlayValues(doc, numberFields);
restoreOverlayValues(doc, refused);
}
if (bar != null) {
restoreOverlayValues(bar, numberFields);
restoreOverlayValues(bar, refused);
}
}
setFieldErrors(errors);
setFieldNotice(blankRequired.length > 0 ? requiredText : numberText);
setFieldNotice(
blankRequired.length > 0 ? requiredText : badNumbers.length > 0 ? numberText : dateText,
);
setFieldNoticeRole("alert");
return;
}
Expand Down Expand Up @@ -710,6 +723,25 @@ export function AssemblyHost({
}
}

function handleCancelFields(): void {
if (contentId == null) {
return;
}
const ownerId = String(contentId);
const doc = getPreviewDocument(frameRef.current);
if (doc != null) {
restoreOverlayValues(doc, overlayFields);
}
const bar = fieldBarNode();
if (bar != null) {
restoreOverlayValues(bar, overlayFields);
}
fieldBaselineRef.current = snapshotFieldBaseline(ownerId, [doc, bar]);
setFieldErrors({});
setFieldNotice(null);
setFieldNoticeRole("status");
}

return (
<div className={styles.root} data-testid="assembly-host">
<header className={styles.bar} data-testid="assembly-overlay">
Expand Down Expand Up @@ -946,6 +978,18 @@ export function AssemblyHost({
spellCheck={false}
autoComplete="off"
/>
) : field.kind === "date" ? (
<input
className={styles.fieldEdit}
type="date"
defaultValue={field.value}
data-assembly-field={field.name}
data-assembly-content-id={String(contentId ?? "")}
data-assembly-value="date"
data-testid={`assembly-overlay-field-${field.name}`}
aria-label={field.label}
aria-invalid={fieldErrors[field.name] ? true : undefined}
/>
) : (
<textarea
className={styles.fieldEdit}
Expand Down Expand Up @@ -986,6 +1030,17 @@ export function AssemblyHost({
savingFields ? ASSEMBLY_MSG.FIELD_SAVING : ASSEMBLY_MSG.FIELD_SAVE,
)}
</button>
<button
type="button"
className={styles.slotBtn}
data-testid="assembly-field-cancel"
disabled={
savingFields || fieldPayload == null || overlayFields.length === 0
}
onClick={handleCancelFields}
>
{message(ASSEMBLY_MSG.SLOT_CANCEL)}
</button>
{fieldNotice ? (
<span role={fieldNoticeRole} data-testid="assembly-field-notice">
{fieldNotice}
Expand Down
6 changes: 4 additions & 2 deletions WebUI/src/main/ts/assembly/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const ASSEMBLY_MSG = {
CONTENT_ID: "perc.ui.assembly@Content",
TEMPLATE_LABEL: "perc.ui.assembly@Template",
TEMPLATE_PLACEHOLDER: "perc.ui.assembly@Select a page or snippet template",
NOTE: "perc.ui.assembly@Assembled with the selected page or snippet template. Known single-line text, long text, HTML, link, and whole-number fields edit inline and save through itemmanagement.",
NOTE: "perc.ui.assembly@Assembled with the selected page or snippet template. Known single-line text, long text, HTML, link, whole-number, and calendar-date fields edit inline and save through itemmanagement.",
MISSING_ITEM: "perc.ui.assembly@Open Active Assembly from Explorer with a content item selected.",
NO_TEMPLATE: "perc.ui.assembly@No page or snippet template is available for this item.",
TEMPLATE_MISMATCH:
Expand Down Expand Up @@ -56,12 +56,14 @@ export const ASSEMBLY_MSG = {
SLOT_APPLY: "perc.ui.assembly@Apply",
SLOT_CANCEL: "perc.ui.assembly@Cancel",
FIELDS: "perc.ui.assembly@Fields",
FIELD_EMPTY: "perc.ui.assembly@No known text, HTML, link, or number fields on this item.",
FIELD_EMPTY: "perc.ui.assembly@No known text, HTML, link, number, or date fields on this item.",
FIELD_SAVE: "perc.ui.assembly@Save fields",
FIELD_SAVING: "perc.ui.assembly@Saving fields…",
FIELD_SAVED: "perc.ui.assembly@Fields saved",
FIELD_SAVE_FAILED: "perc.ui.assembly@Could not save fields",
FIELD_REQUIRED: "perc.ui.assembly@This field is required.",
FIELD_NUMBER: "perc.ui.assembly@Enter a whole number.",
FIELD_DATE:
"perc.ui.assembly@Enter a calendar date. A blank date does not clear a stored date.",
FIELD_INLINE: "perc.ui.assembly@Inline on preview",
};
116 changes: 108 additions & 8 deletions WebUI/src/main/ts/assembly/overlayFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,18 @@
*/

/**
* Map known text, long-text, HTML, link, and whole-number itemmanagement fields
* onto assembled preview nodes and persist edits through the same fields API as
* the React editor. Does not open leftover Active Assembly or Content Editor HTML.
* Map known text, long-text, HTML, link, whole-number, and calendar-date
* itemmanagement fields onto assembled preview nodes and persist edits through
* the same fields API as the React editor. Does not open leftover Active
* Assembly or Content Editor HTML. Datetime stays on the Content Editor.
*/

import type { ContentTypeFieldSummary } from "../api/developer/types";
import { classifyEditorControl } from "../editor/controlKinds";
import { toWidgetValue } from "../editor/dateField";
import type { ItemEditorField, ItemEditorFields } from "../editor/itemFieldsApi";

export type OverlayFieldKind = "text" | "longtext" | "html" | "link" | "number";
export type OverlayFieldKind = "text" | "longtext" | "html" | "link" | "number" | "date";

/** Assembled HTML nodes store markup in innerHTML, not stripped text. */
export const ASSEMBLY_VALUE_HTML = "html";
Expand All @@ -42,6 +44,9 @@ export const ASSEMBLY_VALUE_LONGTEXT = "longtext";
/** Whole-number edits send {@code dataType: integer} on the item field save. */
export const ASSEMBLY_VALUE_NUMBER = "number";

/** Calendar-date edits send {@code dataType: date} and {@code yyyy-MM-dd}. */
export const ASSEMBLY_VALUE_DATE = "date";

const WHOLE_NUMBER_RE = /^-?\d+$/;

/**
Expand All @@ -56,6 +61,19 @@ export function wholeNumberText(value: string): string | null {
return text;
}

/**
* One calendar date ({@code yyyy-MM-dd}), or null when blank, invalid, or a datetime.
* A blank result must not be written over a stored date.
*/
export function calendarDateText(value: string): string | null {
const text = value.trim();
if (!text) {
return null;
}
const widget = toWidgetValue("date", text);
return widget === text ? text : null;
}

/**
* Collapse line breaks so a single-line text field cannot store a new line.
* Long text does not use this.
Expand Down Expand Up @@ -198,6 +216,43 @@ export function invalidChangedNumberFieldNames(
return names;
}

/**
* Date fields the author changed to blank or to something other than one calendar date.
* Unchanged values, including a blank that was already stored, are not listed.
* A blank change is named so the save can leave the stored date in place.
* Datetime text is not a calendar date. Long text, HTML, link, and numbers are not checked.
*/
export function invalidChangedDateFieldNames(
fields: readonly Pick<OverlayField, "name" | "kind" | "value">[],
edits: readonly Pick<OverlayFieldEdit, "contentId" | "name" | "value">[],
baseline: ReadonlyMap<string, string>,
): string[] {
const latest = new Map<string, (typeof edits)[number]>();
for (const edit of edits) {
latest.set(edit.name, edit);
}
const names: string[] = [];
for (const field of fields) {
if (field.kind !== "date") {
continue;
}
const edit = latest.get(field.name);
if (!edit) {
continue;
}
const previous = baseline.has(overlayEditKey(edit))
? (baseline.get(overlayEditKey(edit)) ?? "")
: field.value;
if (edit.value.trim() === previous.trim()) {
continue;
}
if (calendarDateText(edit.value) == null) {
names.push(field.name);
}
}
return names;
}

/** Mark overlay controls invalid when a required-text save was refused. */
export function markAssemblyFieldErrors(
root: ParentNode | null,
Expand All @@ -222,6 +277,7 @@ export function markAssemblyFieldErrors(
}

const LINK_INPUT_ATTR = "data-assembly-link-input";
const DATE_INPUT_ATTR = "data-assembly-date-input";

export interface OverlayField {
name: string;
Expand Down Expand Up @@ -281,13 +337,14 @@ export function isOverlayFieldKind(kind: string): kind is OverlayFieldKind {
isScalarOverlayKind(kind) ||
kind === "html" ||
kind === "link" ||
kind === "number"
kind === "number" ||
kind === "date"
);
}

/**
* Text, long-text, HTML, link, and whole-number rows from itemmanagement.
* File, image, keyword, community, table, date, and float stay on the Content Editor.
* Text, long-text, HTML, link, whole-number, and calendar-date rows from itemmanagement.
* File, image, keyword, community, table, datetime, and float stay on the Content Editor.
* Read-only rows are omitted so the overlay cannot write them.
*/
export function scalarOverlayFields(
Expand All @@ -305,9 +362,10 @@ export function scalarOverlayFields(
if (kind === "number" && (schema?.dataType ?? "").trim().toLowerCase() === "float") {
continue;
}
const rawValue = field.value ?? "";
out.push({
name: field.name,
value: field.value,
value: kind === "date" ? toWidgetValue("date", rawValue) : rawValue,
label: schema?.label || field.name,
kind,
readOnly: false,
Expand Down Expand Up @@ -564,11 +622,42 @@ function mountLinkInput(
}
}

function mountDateInput(
host: HTMLElement,
hit: OverlayFieldHit,
field: OverlayField,
): void {
host.contentEditable = "false";
host.removeAttribute("contenteditable");
const input = host.ownerDocument.createElement("input");
input.type = "date";
input.value = toWidgetValue("date", field.value);
input.setAttribute("data-assembly-field", hit.name);
input.setAttribute("data-assembly-content-id", hit.contentId);
input.setAttribute("data-assembly-value", ASSEMBLY_VALUE_DATE);
input.setAttribute("data-testid", `assembly-inline-field-${hit.name}`);
input.setAttribute(DATE_INPUT_ATTR, hit.name);
input.setAttribute("aria-label", field.label || hit.name);
input.setAttribute(
"style",
"display:inline-block;margin-left:4px;color:#0f172a;background:#fff;border:1px solid #64748b;font:inherit;",
);
if (host.tagName === "A") {
host.insertAdjacentElement("afterend", input);
} else {
host.textContent = "";
host.appendChild(input);
}
}

/** Drop markers from a previous paint so a field that is no longer editable cannot be saved. */
export function clearFieldOverlay(root: ParentNode): void {
root.querySelectorAll(`input[${LINK_INPUT_ATTR}]`).forEach((el) => {
el.remove();
});
root.querySelectorAll(`input[${DATE_INPUT_ATTR}]`).forEach((el) => {
el.remove();
});
root.querySelectorAll("[data-assembly-field]").forEach((el) => {
const html = el as HTMLElement;
html.contentEditable = "false";
Expand Down Expand Up @@ -602,6 +691,10 @@ export function applyFieldOverlay(
mountLinkInput(html, hit, field);
continue;
}
if (field?.kind === "date") {
mountDateInput(html, hit, field);
continue;
}
html.contentEditable = "true";
html.setAttribute("data-assembly-field", hit.name);
html.setAttribute("data-assembly-content-id", hit.contentId);
Expand Down Expand Up @@ -677,6 +770,9 @@ function readNodeValue(el: Element): string {
if (valueKind === ASSEMBLY_VALUE_NUMBER) {
return raw.trim();
}
if (valueKind === ASSEMBLY_VALUE_DATE) {
return raw.trim();
}
return raw;
}
if (valueKind === ASSEMBLY_VALUE_HTML) {
Expand All @@ -688,6 +784,9 @@ function readNodeValue(el: Element): string {
if (valueKind === ASSEMBLY_VALUE_NUMBER) {
return (el.textContent ?? "").trim();
}
if (valueKind === ASSEMBLY_VALUE_DATE) {
return (el.textContent ?? "").trim();
}
const text = (el.textContent ?? "").trim();
return valueKind === ASSEMBLY_VALUE_TEXT ? singleLineText(text) : text;
}
Expand Down Expand Up @@ -727,6 +826,7 @@ export function readOverlayEdits(
value: readNodeValue(el),
...(valueKind === ASSEMBLY_VALUE_LINK ? { dataType: "link" } : {}),
...(valueKind === ASSEMBLY_VALUE_NUMBER ? { dataType: "integer" } : {}),
...(valueKind === ASSEMBLY_VALUE_DATE ? { dataType: "date" } : {}),
});
});
return edits;
Expand Down
Loading
Loading