With a bit help from AI this seems to work.
It is inspired from content picker, but the additional create button, allows to create content node in context and select the node after creation.
A limitation is that it requires a single start node id, but it could possible allow content editors to select a start node first:
import { css, html, customElement, property, state } from '@umbraco-cms/backoffice/external/lit';
import { splitStringToArray } from '@umbraco-cms/backoffice/utils';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';
import { UmbPropertyEditorConfigCollection, UmbPropertyEditorUiElement } from '@umbraco-cms/backoffice/property-editor';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import type { UmbReferenceByUniqueAndType } from '@umbraco-cms/backoffice/models';
import type { UmbTreeStartNode } from '@umbraco-cms/backoffice/tree';
import { UmbModalRouteBuilder, UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router';
import { UMB_WORKSPACE_MODAL, UmbSubmittableWorkspaceContext } from '@umbraco-cms/backoffice/workspace';
import {
UMB_MODAL_MANAGER_CONTEXT,
type UmbModalManagerContext,
} from '@umbraco-cms/backoffice/modal';
import {
UMB_DOCUMENT_PICKER_MODAL,
UMB_DOCUMENT_ENTITY_TYPE,
//UMB_EDIT_DOCUMENT_WORKSPACE_PATH_PATTERN,
UMB_CREATE_DOCUMENT_WORKSPACE_PATH_PATTERN,
//UmbDocumentDetailRepository,
} from '@umbraco-cms/backoffice/document';
import "../../components/input-document/input-document.element.js";
import { UmbContentPickerDynamicRootRepository, UmbContentPickerSource } from '@umbraco-cms/backoffice/content-picker';
//import { UMB_MEDIA_ENTITY_TYPE } from '@umbraco-cms/backoffice/media';
//import { UMB_MEMBER_ENTITY_TYPE } from '@umbraco-cms/backoffice/member';
import { UMB_CONTENT_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/content';
import { UMB_PARENT_ENTITY_CONTEXT } from '@umbraco-cms/backoffice/entity';
@customElement('mcb-extended-content-picker')
export class McbExtendedContentPickerPropertyEditorUiElement
extends UmbFormControlMixin<string | undefined, typeof UmbLitElement, undefined>(UmbLitElement, undefined)
implements UmbPropertyEditorUiElement {
//#entityTypeLookup = { content: 'document' };
#selection: Array<string> = [];
#modalManager?: UmbModalManagerContext;
#createModalRoute?: UmbModalRouteRegistrationController;
#routeBuilder?: UmbModalRouteBuilder;
//#documentRepository = new UmbDocumentDetailRepository(this);
/*public get type(): UmbContentPickerSource['type'] {
return this.#type;
}
#type: UmbContentPickerSource['type'] = 'content';*/
@property({ type: Number })
min = 0;
@property({ type: String, attribute: 'min-message' })
minMessage = 'This field need more items';
@property({ type: Number })
max = 0;
@property({ type: String, attribute: 'max-message' })
maxMessage = 'This field exceeds the allowed amount of items';
@property({ type: Object, attribute: false })
startNode?: UmbTreeStartNode;
@property({ type: Boolean, reflect: true })
readonly = false;
@property({ type: Boolean })
mandatory = false;
@property({ type: String })
mandatoryMessage = UMB_VALIDATION_EMPTY_LOCALIZATION_KEY;
/*@state()
private _type: UmbContentPickerSource['type'] = 'content';
@state()
private _min = 0;
@state()
private _minMessage = '';
@state()
private _max = Infinity;
@state()
private _maxMessage = '';*/
@state()
private _allowedContentTypeUniques?: string;
@state()
private _rootUnique?: string | null;
/*@state()
private _rootEntityType?: string;*/
#dynamicRoot?: UmbContentPickerSource['dynamicRoot'];
#dynamicRootRepository = new UmbContentPickerDynamicRootRepository(this);
/*#entityTypeDictionary: { [type in UmbContentPickerSourceType]: string } = {
content: UMB_DOCUMENT_ENTITY_TYPE,
media: UMB_MEDIA_ENTITY_TYPE,
member: UMB_MEMBER_ENTITY_TYPE,
};*/
@property({ attribute: 'display-property-aliases' })
public set displayPropertyAliases(value: string | Array<string> | undefined) {
this.#displayPropertyAliases = Array.isArray(value) ? value : splitStringToArray(value);
}
public get displayPropertyAliases(): Array<string> {
return this.#displayPropertyAliases;
}
#displayPropertyAliases: Array<string> = ['periodFrom', 'periodTo'];
@property({ type: Array })
public set selection(values: Array<UmbReferenceByUniqueAndType>) {
this.#selection = values?.map((item) => item.unique) ?? [];
}
public get selection(): Array<UmbReferenceByUniqueAndType> {
return this.#selection.map((id) => ({ type: 'document', unique: id })); //type: this.#entityTypeLookup[this.#type]
}
@property({ type: String })
public override set value(selectionString: string | undefined) {
this.#selection = splitStringToArray(selectionString);
super.value = selectionString; // Call the parent setter to ensure the value change is triggered in the FormControlMixin. [NL]
}
public override get value(): string | undefined {
return this.#selection.length > 0 ? this.#selection.join(',') : undefined;
}
constructor() {
super();
this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (instance) => {
this.#modalManager = instance;
});
this.#createModalRoute = new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL)
.onSetup(async () => {
const documentTypeUnique = splitStringToArray(this._allowedContentTypeUniques ?? '')[0];
if (!documentTypeUnique) return false;
return {
data: {
entityType: UMB_DOCUMENT_ENTITY_TYPE,
preset: {
documentTypeUnique,
parent: { unique: this._rootUnique ?? null },
},
},
};
})
.onSubmit(async (result: any) => {
const createdUnique = result?.unique ?? result?.data?.unique;
if (!createdUnique) return;
this.#selection = [createdUnique];
this.value = createdUnique;
this.dispatchEvent(new UmbChangeEvent());
})
.onReject(async () => {
// cancelled
})
.observeRouteBuilder((routeBuilder) => {
this.#routeBuilder = routeBuilder ?? undefined;
});
}
override disconnectedCallback(): void {
this.#createModalRoute?.destroy();
super.disconnectedCallback();
}
public set config(config: UmbPropertyEditorConfigCollection | undefined) {
//this.#interactionMemoryManager.setPropertyEditorConfig(config);
if (!config) return;
const startNode = config.getValueByAlias<UmbContentPickerSource>('startNode');
if (startNode) {
console.log('Start node from config:', startNode);
//this._type = startNode.type;
this._rootUnique = startNode.id;
//this._rootEntityType = this.#entityTypeDictionary[startNode.type];
this.#dynamicRoot = startNode.dynamicRoot;
// NOTE: Filter out any items that do not match the entity type. [LK]
/*this._invalidData = this.value?.filter((x) => x.type !== this._rootEntityType);
if (this._invalidData?.length) {
this.readonly = true;
}*/
}
/*this._min = this.#parseInt(config.getValueByAlias('minNumber'), 0);
this._max = this.#parseInt(config.getValueByAlias('maxNumber'), Infinity);*/
this._allowedContentTypeUniques = config.getValueByAlias('filter');
console.log('Allowed content type uniques from config:', this._allowedContentTypeUniques);
/*this._minMessage = `${this.localize.term('validation_minCount')} ${this._min} ${this.localize.term('validation_items')}`;
this._maxMessage = `${this.localize.term('validation_maxCount')} ${this._max} ${this.localize.term('validation_itemsSelected')}`;*/
}
/*#parseInt(value: unknown, fallback: number): number {
const num = Number(value);
return !isNaN(num) && num > 0 ? num : fallback;
}*/
override firstUpdated(changedProperties: Map<string | number | symbol, unknown>) {
super.firstUpdated(changedProperties);
//this.addFormControlElement(this.shadowRoot!.querySelector('umb-input-content')!);
this.#setPickerRootUnique();
/*if (this._min && this._max && this._min > this._max) {
console.warn(
`Property (Content Picker) has been misconfigured, 'minNumber' is greater than 'maxNumber'. Please correct your data type configuration.`,
this,
);
}*/
}
async #setPickerRootUnique() {
// If we have a root unique value, we don't need to fetch it from the dynamic root
if (this._rootUnique) return;
if (!this.#dynamicRoot) return;
// Use passContextAliasMatches to skip past block element workspaces and find the document workspace.
const workspaceContext = await this.getContext(UMB_CONTENT_WORKSPACE_CONTEXT, {
passContextAliasMatches: true,
}).catch(() => undefined);
// For new documents, the unique is a client-generated GUID that doesn't exist in the DB.
// The backend expects null for CurrentKey when creating new content and falls back to ParentKey.
const isNew =
workspaceContext &&
'getIsNew' in workspaceContext &&
(workspaceContext as UmbSubmittableWorkspaceContext).getIsNew() === true;
const unique = isNew ? null : (workspaceContext?.getUnique() ?? null);
// Use parent entity context to get the parent unique. Its observable starts as undefined,
// so asPromise() properly waits for the async structure loading to complete.
const parentContext = await this.getContext(UMB_PARENT_ENTITY_CONTEXT);
const parent = await this.observe(parentContext?.parent, () => {})?.asPromise();
const parentUnique = parent?.unique ?? null;
console.log('Fetching dynamic root with unique:', unique, 'and parentUnique:', parentUnique);
const result = await this.#dynamicRootRepository.requestRoot(this.#dynamicRoot, unique, parentUnique);
console.log('Dynamic root result:', result);
if (result && result.length > 0) {
this._rootUnique = result[0];
}
}
async #openPicker() {
if (!this.#modalManager) return;
const modal = this.#modalManager.open(
this,
UMB_DOCUMENT_PICKER_MODAL,
{
data: {
multiple: false,
}
}
);
try {
const result = await modal.onSubmit();
if (!result?.selection?.length) return;
this.#selection = result.selection.filter(
(x): x is string => x !== null
);
this.value = this.#selection.join(',');
this.dispatchEvent(new UmbChangeEvent());
} catch {
// cancelled
}
}
async #createDocument() {
if (this.readonly || !this.#routeBuilder) return;
const documentTypeUnique = splitStringToArray(this._allowedContentTypeUniques ?? '')[0];
if (!documentTypeUnique) {
console.warn('No allowed content type specified, cannot create document.');
return;
}
const modalBase = this.#routeBuilder({});
const createPath = UMB_CREATE_DOCUMENT_WORKSPACE_PATH_PATTERN.generateLocal({
parentEntityType: 'document',
parentUnique: this._rootUnique,
documentTypeUnique,
});
const href = `${modalBase}${createPath}`;
history.pushState(null, '', href);
window.dispatchEvent(new PopStateEvent('popstate'));
}
#onChange(event: Event) {
const target = event.target as any;
this.#selection = target.selection ?? [];
this.value = this.#selection.join(',');
this.dispatchEvent(new UmbChangeEvent());
}
render() {
return html`
<div class="wrapper">
<mcb-input-document
.selection=${this.#selection}
.allowedContentTypeIds=${this._allowedContentTypeUniques}
.displayPropertyAliases=${this.displayPropertyAliases}
?readonly=${this.readonly}
?required=${this.mandatory}
.requiredMessage=${this.mandatoryMessage}
@change=${this.#onChange}>
</mcb-input-document>
<uui-button
look="outline"
?disabled=${this.readonly}
@click=${this.#openPicker}>
Select
</uui-button>
<uui-button
look="outline"
?disabled=${this.readonly}
@click=${this.#createDocument}>
Create
</uui-button>
</div>
`;
}
static override styles = css`
.wrapper {
display: flex;
flex-direction: column;
gap: 12px;
}
`;
}
export default McbExtendedContentPickerPropertyEditorUiElement;
declare global {
interface HTMLElementTagNameMap {
'mcb-extended-content-picker-property-editor-ui': McbExtendedContentPickerPropertyEditorUiElement;
}
}
One thing I noticed though, is that only “Save & Publish” only seem to return onSubmit callback, while “Save” created document, but didn’t return callback or closed modal.