Open create modal for document

I am trying to open create modal for a new document in a custom picker, something similar to this:

I have the following:

this.#createModalRoute = new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL)
            .onSetup(async () => ({
                data: {
                    entityType: UMB_DOCUMENT_ENTITY_TYPE,
                    preset: {},
                },
            }))
            .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, nothing created
            })
            .observeRouteBuilder((routeBuilder) => {
                const modalBase = routeBuilder({});
                const createPath = UMB_CREATE_DOCUMENT_WORKSPACE_PATH_PATTERN.generateLocal({
                    parentEntityType: 'document',
                    parentUnique: this._rootUnique, //options.parentId,
                    documentTypeUnique: documentTypeUnique //options.documentTypeId
                });

                const href = `${modalBase}${createPath}`;

                history.pushState(null, '', href);
                window.dispatchEvent(new PopStateEvent('popstate'));
            });

which open a route from current node:

/umbraco/section/content/workspace/document/edit/a9b2ee69-23cb-41c5-b087-50273a308209/invariant/tab/ejerforhold-og-tilgængelighed/modal/umb-modal-workspace/invariant/test/create/parent/document/aecb6910-312e-46f0-b926-7720b916aaa0/0

Where test seems to be the property on current node from where the modal is opened.

A normal content creation has this format:

/umbraco/section/content/workspace/document/create/parent/document/139b130a-7e33-4e22-8753-ea76ad860d5f/4e0553c6-20e4-4eb4-b419-79f0ca908be7/invariant

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.

1 Like