Help with posting a form in a workspaceView (Shadow Dom?)

I’m trying to retreive the values that are in a sorter-container when posting a form, but when the form controls are in the sorter-container they do not appear in the form collection.

Can anyone explain why this is, and how to get their vaules when posting?

This is the form, the top section is a sorter-control, the bottm section the inputs are just rendered in a repeat. when the form is submitted, the inputs in the botton section are in the form collection, but the items in the repeater are not :neutral_face:

My assumption here is that the sorter items are in the shadow DOM, so I need to do something to expose them to the main form if anyone can help :slightly_smiling_face:

Usually, this is when you use a custom context at the higest level and consume that context in any sub components that you have. This is the Umbraco way of making sure you are not tightly coupling everything.

CoPilot to the rescue :smiley:

I basically needed to add an association with the form and add some callback methods etc to keep the form in sync. Code below for anyone interested, obviosly it is specific to my needs, but shoud give the general idea.

import type { PollSorterItem } from './sorter-item.js';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { css, html, customElement, LitElement, repeat, property, query } from '@umbraco-cms/backoffice/external/lit';
import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api';
import { UmbSorterController } from '@umbraco-cms/backoffice/sorter';

import './sorter-item.js';

export type ModelEntryType = {
	id: string;
	name: string;
	sort: number;
	question: number;
};

@customElement('polls-sorter-group')
export class PollsSorterGroup extends UmbElementMixin(LitElement) {
	// Make this element form-associated
	static formAssociated = true;

	private _items?: ModelEntryType[];
	private _internals?: ElementInternals;
	private _form?: HTMLFormElement;

	@property({ type: Array, attribute: false })
	public get items(): ModelEntryType[] {
		return this._items ?? [];
	}
	public set items(value: ModelEntryType[]) {
		// Only set initial model
		if (this._items !== undefined) return;
		this._items = value;
		this.#sorter.setModel(this._items);
		this.#updateFormValue();
	}

	@query('#new-answer') newValueInp!: HTMLInputElement;
	@query('#question-id') questionId!: HTMLInputElement;

	constructor() {
		super();
		// Attach internals if supported
		try {
			if ('attachInternals' in HTMLElement.prototype) {
				this._internals = this.attachInternals();
			}
		} catch {
			// ignore
		}
	}

	connectedCallback(): void {
		super.connectedCallback();
		// Fallback: hook the closest form's formdata event if ElementInternals not available
		if (!this._internals) {
			this._form = this.closest('form') ?? undefined;
			this._form?.addEventListener('formdata', this.#onFormData);
		}
	}

	disconnectedCallback(): void {
		// Clean up fallback listener
		this._form?.removeEventListener('formdata', this.#onFormData);
		super.disconnectedCallback();
	}

	// Called when form is reset
	formResetCallback() {
		// No-op, but if you keep initial items elsewhere, restore here and update form value
		this.#updateFormValue();
	}

	// Custom validity (example, prevent empty list)
	#validate() {
		if (!this._internals) return;
		const valid = !!this._items && this._items.length > 0 && this._items.every((i) => i.name.trim().length > 0);
		if (!valid) {
			this._internals.setValidity({ customError: true }, 'Please add at least one answer.');
		} else {
			this._internals.setValidity({});
		}
	}

	// Contribute fields to parent form
	#updateFormValue() {
		// Keep validity in sync
		this.#validate();

		// Build a FormData payload with repeated keys
		const fd = new FormData();
		(this._items ?? []).forEach((item) => {
			fd.append('Answers', item.name);
			fd.append('answerssort', String(item.sort));
			fd.append('answersid', item.id);
		});

		if (this._internals) {
			// ElementInternals can submit multiple fields via FormData
			this._internals.setFormValue(fd);
		}
		// Fallback path handled in #onFormData
	}

	// Fallback: append our data to the FormData right before submission
	#onFormData = (e: FormDataEvent) => {
		// Clear existing keys (optional): if other inputs named Answers exist outside, skip the clear
		// This shows how you'd avoid duplicates if you rely only on this component:
		// e.formData.delete('Answers');
		// e.formData.delete('answerssort');
		// e.formData.delete('answersid');

		(this._items ?? []).forEach((item) => {
			e.formData.append('Answers', item.name);
			e.formData.append('answerssort', String(item.sort));
			e.formData.append('answersid', item.id);
		});
	};

	#sorter = new UmbSorterController<ModelEntryType, PollSorterItem>(this, {
		getUniqueOfElement: (element) => element.name,
		getUniqueOfModel: (modelEntry) => modelEntry.name,
		identifier: 'mediawiz-polls-sorters',
		itemSelector: 'poll-sorter-item',
		containerSelector: '.sorter-container',
		onChange: ({ model }) => {
			const oldValue = this._items;
			model.forEach((row, index) => (row.sort = index));
			this._items = model;
			this.requestUpdate('items', oldValue);
			this.#updateFormValue();
		},
	});

	removeItem = (item: ModelEntryType) => {
		this._items = this._items!.filter((r) => r.name !== item.name);
		this.#sorter.setModel(this._items);
		this.requestUpdate();
		this.#updateFormValue();
	};

	addItem() {
		const newVal = this.newValueInp.value.trim();
		if (!newVal) return;
		const qId = this.questionId.value;
		this._items?.push({ id: '0', name: newVal, sort: 9, question: Number(qId) });
		this._items?.forEach((row, index) => (row.sort = index));
		this.#sorter.setModel(this._items);
		this.newValueInp.value = '';
		this.requestUpdate();
		this.#updateFormValue();
	}

	override render() {
		return html`
			<div class="sorter-container">
				${repeat(
					this.items,
					(item) => item.name,
					(item) => html`
						<poll-sorter-item name=${item.name} id=${item.id} sort=${item.sort} question="${item.question}">
							<uui-icon name="icon-grip" class="handle" aria-hidden="true"></uui-icon>
							<uui-input slot="action" id="${'Answer' + item.id}" name="Answers" type="text" label="Answer" pristine="" value="${item.name}">
								<div slot="append" style="padding-left:var(--uui-size-2, 6px)">
									<uui-icon-registry-essential>
										<uui-icon color="red" data-id="${item.name}" title="Remove Answer" name="delete" @click=${() => this.removeItem(item)}></uui-icon>
									</uui-icon-registry-essential>
								</div>
							</uui-input>
						</poll-sorter-item>
					`,
				)}
			</div>

			<uui-form-layout-item>
				<uui-label slot="label">Add new Answer</uui-label>
				<span slot="description">Form item accepts a sort order + description, keep it short.</span>
				<uui-input style="display:none;" id="question-id" name="Question" type="text" pristine value="${this.items[0]?.question ?? ''}"></uui-input>
				<uui-input id="new-answer" name="Answers" type="text" pristine value="" placeholder="Add another Answer">
					<div slot="append">
						<uui-icon name="icon-badge-add" @click=${() => this.addItem()}></uui-icon>
					</div>
				</uui-input>
			</uui-form-layout-item>
		`;
	}

	static override styles = [
		UmbTextStyles,
		css`
			:host {
				display: block;
				width: max-content;
				border-radius: calc(var(--uui-border-radius) * 2);
				padding: var(--uui-size-space-1);
			}
			.sorter-placeholder {
				opacity: 0.2;
			}
			.sorter-container {
				min-height: 20px;
			}
		`,
	];
}

export default PollsSorterGroup;

declare global {
	interface HTMLElementTagNameMap {
		'polls-sorter-group': PollsSorterGroup;
	}
}

I was wondering in this case. If you have a property editor, you need to extend your component with the UmbFormControlMixin mixin to make it behave like a form and allow for custom validation that is picked up when you save and publish a content node.

I wonder if it is applicable to your situation in this case.

Ah, that’s a possibility, might try changing it to UmbFormControlMixin see if that makes it easier, but working for now anyway :smiling_face_with_sunglasses:

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.