ホームページ
2026年3月11日

Umbraco 17で動的アイテム付きツリーを作る方法

Umbraco
C#
TypeScript

Auto DictionariesはUmbraco 17にアップグレード冒険の中で、動的アイテム付きツリーを作った。

でもこの新しいツリーの実装方法は、本当に複雑だと思う。

実装していた時、いろんなブログ記事やUmbracoのドキュメントなどを探したけど、見つけた方法はどれも違っていた。

僕がしたい実装に近い例は、あまり見つからなかった。

だから、ツリーの実装には時間がかかりすぎた。

そのため、僕の方法を説明したいために、このブログ記事を書くことにした。

この方法は Umbraco 17.0.1 で実装した。

すべてのステップを説明する。

そして、最終的な結果はこれだ。

動的アイテム付きツリーを作る。そのあと、workspaceでアイテムを表示する。

この結果を作るために、いくつかのものを実装しなければいけない。

  • Controller - ツリーアイテムを返すバックエンドのコントローラー
  • Section Sidebar App - sidebarに新しいsectionを追加する
  • Menu - ツリーを登録するコンテナー
  • Tree Repository - ツリーのデータを取得する
  • Tree - ツリーの構造を定義する
  • Tree Root - ツリーのルートノード
  • Workspace - エンティティの編集画面

新しい用語やアブストラクトな概念が多いと思う。

だから、僕はこんらんしていたから、この図を作った!

僕はこの図でこう考えている

始める前に、プロジェクトの構造を話したほうがいい。

(第2回)Auto Dictionaries:Umbraco 13 から 17 への冒険 — プロジェクト構造と TypeScript への移行準備」というブログ記事で詳しく説明した。

でも今は少しまとめる。

1つのmanifests.tsのファイルを使っている。

そのファイルの中でextensionRegistry.registerMany(manifests);を使っている。

だから、毎回manifests.tsのファイルを話した時、同じファイルを話す!

重要!

僕のTypeScriptはまだ上手じゃないから、ほかの方法があると思う。

全部のコードを見たい時、Auto Dictionraiesのリポジトリで読める

では行きましょう!


1. Controller

まずはバックエンドのコードのコントローラーを作ったほうがいい。

このコントローラーはすべてのツリーアイテムを返す。

public async Task<ActionResult<PagedViewModel<AutoDictionariesModel>>> GetChildren()
{    
    return Ok(new PagedViewModel<AutoDictionariesModel>
    {
        Items = await GetAllViews(),
        Total = 100
    });
}

PagedViewModelの戻り値が必要だよ!

書くバックエンドのコードはそれだけ!

次に、TypeScriptでHey APIのnpmのパッケージを使うから、型付きAPIを作る。

ここでもっと読める


2. Section Sidebar App

そして、「Section Sidebar App」を実装する。

図でこれはだ。

「Section Sidebar Appsidebarに新しいsectionを作れる。

manifest.tsにはsectionSidebarAppを追加する。

const menuSidebarApp: UmbExtensionManifest = {
    type: "sectionSidebarApp",
    kind: "menu",
    alias: "autoDictionaries.sidebarapp",
    name: "Auto Dictionarie sidebar menu",
    weight: 100,
    meta: {
        label: "Auto Dictionary",
        menu: menu.alias, //<-- 次に作る
    },
    conditions: [
        {
            alias: "Umb.Condition.SectionAlias",
            match: "Umb.Section.Translation",
        },
    ],
};

kindmenumenuWithEntityActionsがある.

kindを追加しない時、自分のツリーアイテムのUIを実装しなければいけない。

例えば、

import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";

@customElement("auto-dictionaries-sidebar")
export class AutoDictionariesSidebarElement extends LitElement {
  protected createRenderRoot() {
    return this; // allow Umbraco global styling
  }

  render() {
    return html`
      <umb-tree alias="autoDictionaries.tree"></umb-tree>
    `;
  }
}

sectionSidebarAppを追加した時、これはけっかだ。

これから、TreeRootを実装するまで、あまりUIがない。


3. Menu

Menu」はツリーを登録するコンテナーだ。

図でこれはだ。

manifest.tsにはmenuで追加する。

const menu: UmbExtensionManifest = {
    type: "menu",
    alias: "autoDictionaries.menu",
    name: "Auto dictionaries menu"
};


4. Tree Repository

次に、バックエンドのコントローラーからデータを取得する方法が必要だ。

だから、「Tree Repository」を作ったほうがいい。

repositoryを実装するために、 UmbTreeRepositoryBaseUmbTreeServerDataSourceBaseも実装しなければいけない。

  • UmbTreeRepositoryBase - 取得したデータを ツリーから使えるようにする
  • UmbTreeServerDataSourceBase - API からデータを取る

まずはUmbTreeServerDataSourceBaseを実装する。

新しいファイルを作ったほうがいい。

そのファイル(data-source.ts)でUmbTreeServerDataSourceBase<any, any>を継承した時、4つの関数を実装しなければいけない。

  • getRootItems
  • getAncestorsOf
  • getChildrenOf
  • mapper

このツリーの方法は1つのアイテムがあるから、getAncestorsOfgetChildrenOfの戻り値は少なくできる。

const getAncestorsOf = async (): Promise<UmbDataSourceResponse<any>> => {
    return { data: { items: [] } };
};

const getChildrenOf = async (): Promise<UmbDataSourceResponse<any>> => {
    return { data: { items: [] } };
};

そして、getRootItemsにはツリーアイテムを返したい。

そのアイテムはバックエンドのコントローラーから取得するため、TypeScriptの型付きAPIを使う。

const getRootItems = async (): Promise<UmbDataSourceResponse<any>> => {
    try {
        const { data, error } = await AutoDictionariesService.getChildren();
        
        if (error || !data) {
            return { error: error ? new Error(JSON.stringify(error)) : new Error('Unknown error') };
        }
        
        return {
            data: {
                items: data.items ?? [],
                total: data.total ?? 0
            }
        };
    } catch (err) {
        return { error: err instanceof Error ? err : new Error('Failed to fetch root items') };
    }
};

最後の関数mapperは、ここでバックエンドのツリーアイテムモデルをUmbTreeItemModelにマッピングする

const mapper = (item: AutoDictionariesModel): UmbTreeItemModel => {
    return {
        unique: item?.key ?? null,
        parent: { unique: null, entityType: "auto-dictionaries-root" },
        name: item.name ?? "unknown",
        entityType: "auto-dictionaries-item",
        hasChildren: false,
        isFolder: false,
        icon: "icon-book"
    };
};

次に、UmbTreeRepositoryBaseを実装する。

新しいファイル(repository.tree.ts)を作ったほうがいい。

そのファイルにはUmbTreeRepositoryBase<UmbTreeItemModel, UmbTreeRootModel> 継承しなければいけない。

import type { UmbControllerHost } from "@umbraco-cms/backoffice/controller-api";
import type { UmbApi } from "@umbraco-cms/backoffice/extension-api";
import { UmbTreeRepositoryBase, type UmbTreeItemModel, type UmbTreeRootModel } from "@umbraco-cms/backoffice/tree";
import { autoDictionariesTreeDataSource } from "./auto-dictionaries.data-source";

export class autoDictionariesTreeRepository extends UmbTreeRepositoryBase<UmbTreeItemModel, UmbTreeRootModel>
    implements UmbApi {
    constructor(host: UmbControllerHost) {
        super(host, autoDictionariesTreeDataSource);
    }
    async requestTreeRoot() {
       
        const data: UmbTreeRootModel = {
            unique: null,
            entityType: "auto-dictionaries-root",
            name: "Auto Dictionaries",
            icon: "icon-book",
            hasChildren: false,
            isFolder: true,
        };
        return { data };
    }
}

export { autoDictionariesTreeRepository as api };

最後、manifest.tsにはrepositoryを追加する。

const repository: UmbExtensionManifest = {
    type: "repository",
    alias: "autoDictionaries.tree.repository",
    name: "AutoDictionaries Repository Settings",
    api: () => import("./auto-dictionaries.repository.tree.js"),
};


5. Tree

ツリーの構造を定義する。

図でこれはだ。

これを実装するために、manifest.tstypetreeを追加する。

const tree: UmbExtensionManifest = {
    type: "tree",
    kind: "default",
    alias: "autoDictionaries.tree",
    name: "Auto Dictionaries Tree Settings",
    meta: {
        repositoryAlias: repository.alias,
    },
};

重要!

kindの値はdefaultにする必要がある。さもないと動作しない。


6. Tree Root

Tree Root」はツリーのルートノード。

図でこれは4だ。

これを実装するために、manifest.tstypemenuItemを追加する。

const menuItem: UmbExtensionManifest = {
    type: 'menuItem',
    //kind: "tree",
    alias: 'autoDictionaries.tree.menu.item',
    name: 'Auto Dictionarie Tree Item',
    meta: {
        label: 'Auto dictionaries',
        icon: 'icon-book',
        entityType: "auto-dictionaries-root",
        menus: [
            menu.alias
        ],
        treeAlias: tree.alias,
    }
};

kind: "tree"を使ったほうがいいけど、僕は使わない。

使わない時、この違うことに気がついた。

  1. 子ノードが見られる

manifest.tsにはkind: "tree"を使わない。

repository.tree.tsにはhasChildren: trueと子ノードがあるのに、下の矢印が見られない。

  1. URL

kind: "tree"を使わない時、URLはここになる。

/umbraco/section/translation/workspace/auto-dictionaries-root

でも、kind: "tree"を使う時、URLはここになる

/umbraco/section/translation/workspace/auto-dictionaries-root/edit/null


結果

ツリー終わった!

※この「…」は、表示されていないアイテムがまだあることを示している

今、その ツリーアイテムにナビゲーションするために、「Workspace」を作る。


7. Workspace

Workspace」を作るために、workspaceworkspaceContextworkspaceViewの実装をしなければいけない。

Workspace
   └ Context (状態とロジック)
        └ Views (UI)

僕は「Workspace」を作る時、たいてい2つ作っている。

  1. Overview - すべてのエントリーがある
  2. Item - 個別のエントリーをよく見られる

僕はその「Workspace」の名前を使っているけど、何でも使わない。

この構造を使っている。

# Workspace
   # Overview
      - manifest.ts
      - workspace.context.ts
      - workspace.element.ts
      - overview.element.ts
   # Item
      - manifest.ts
      - workspace.context.ts
      - workspace.element.ts
      - item.element.ts

1. Overview

まずはoverviewworkspaceを追加する。

Workspace Context

バックエンドのツリーアイテムを取得するために、「Workspace Context」を使っている。

workspace.context.ts

export class autoDictionariesWorkspaceContext extends UmbControllerBase implements UmbWorkspaceContext {
    public readonly workspaceAlias: string = "autoDictionaries.workspace";

    #repository: AutoDictionariesRepository;

    #views = new UmbObjectState<AutoDictionariesModel[] | undefined>(undefined);
    public readonly views = this.#views.asObservable();

    constructor(host: UmbControllerHostElement) {
        super(host);
        this.provideContext(UMB_WORKSPACE_CONTEXT, this); 
        this.#repository = new AutoDictionariesRepository(this);
    }

    async load() {
        const data = await this.#repository.getAllViews();
        if (data) {
            this.#views.setValue(data);
        }
    }

    getRepository(): AutoDictionariesRepository {
        return this.#repository;
    }

	getEntityType(): string {
       return "auto-dictionaries-root";
    }
}

export default autoDictionariesWorkspaceContext;

そして、overviewmanifest.tsを作ると、これを追加する。

const context: UmbExtensionManifest = {
	type: "workspaceContext",
	alias: "autoDictionaries.workspace.context",
	name: "Auto dictionaries workspace context",
	js: () => import("./workspace.context.js"),
	conditions: [
		{
			alias: "Umb.Condition.WorkspaceAlias",
			match: "autoDictionaries.workspace",
		},
	],
};

Workspace

これはworkspaceのコンテナーだ

overviewworkspace.element.ts

@customElement("auto-dictionaries-root")
export class autoDictionariesWorkspaceRootElement extends UmbElementMixin(LitElement) {
	_workspaceContext: autoDictionariesWorkspaceContext;

	constructor() {
		super();
		this._workspaceContext = new autoDictionariesWorkspaceContext(this);
	}

	render() {
		return html`
			<umb-workspace-editor headline="Auto Dictionaries" alias="autoDictionaries.workspace" .enforceNoFooter=${true}></umb-workspace-editor>`;
	}
}

export default autoDictionariesWorkspaceRootElement;

overviewmanifest.tsにはこれを追加する

const workspace: UmbExtensionManifest = {
	type: "workspace",
	alias: "autoDictionaries.workspace",
	name: "Auto dictionaries workspace",
	js: () => import('./workspace.element.js'),
	meta: {
		entityType: "auto-dictionaries-root",
	}
};

Workspace view

ここで、すべてのUIを追加したほうがいい。

overview.element.ts

@customElement("auto-dictionaries-overview")
export class autoDictionariesOverviewViewElement extends UmbElementMixin(LitElement) {
	#workspaceContext?: autoDictionariesWorkspaceContext;

	@state()
	private _views?: AutoDictionariesModel[] = [];

	constructor() {
		super();

		this.consumeContext(UMB_WORKSPACE_CONTEXT, (context) => {
			this.#workspaceContext = context as autoDictionariesWorkspaceContext;
			this.#observeContext();
		});
	}

	#observeContext() {
		if (!this.#workspaceContext) return;

		this.observe(this.#workspaceContext.views, (view) => {
			this._views = view;
		});
	}

	connectedCallback() {
		super.connectedCallback();

		if (this.#workspaceContext) {
			this.#workspaceContext.load();
		}
	}

	private _getUrl(view: AutoDictionariesModel) {
		if (!view?.id) return;
		return `/umbraco/section/translation/workspace/auto-dictionaries-item/edit/${view.id}`;
	};

	private _renderView(view: AutoDictionariesModel) {
		if (!view) return;
		return html`<a href=${this._getUrl(view)}>view.name</a>`;
	}

	render() {
		return html`${repeat(this.views?? [], (view) => view.id, (view) => this._renderView(view))}`;
	}
}

export default autoDictionariesOverviewViewElement;

overviewmanifest.tsにはこれを追加する。

const workspaceViews: Array<UmbExtensionManifest> = {
		type: "workspaceView",
		alias: "autoDictionaries.workspaceView.overview",
		name: "Auto dictionaries workspace overview view",
		js: () => import("./views/overview/overview.element.js"),
		weight: 20,
		meta: {
			label: "#autoDictionaries_overview",
			pathname: "overview",
			icon:"icon-book"
		},
		conditions: [
			{
				alias: "Umb.Condition.WorkspaceAlias",
				match: "autoDictionaries.workspace"
			}
		]
};

結果

overviewworkspaceを終わった。

Item

今、overviewworkspaceがあるけど、アイテムの詳細を見たいからitemworkspaceも作った。

今回もworkspaceworkspaceContextworkspaceViewの実装をしなければいけない。

Workspace Context

workspace.context.ts

export class AutoDictionariesItemWorkspaceContext extends UmbControllerBase implements UmbWorkspaceContext {
    public readonly workspaceAlias: string = "autoDictionaries.item.workspace";

    #repository: AutoDictionariesRepository;

    #unique = new UmbObjectState<string | undefined>(undefined);
    public readonly unique = this.#unique.asObservable();

    #currentItem = new UmbObjectState<AutoDictionariesModel | undefined>(undefined);
    public readonly currentItem = this.#currentItem.asObservable();

    constructor(host: UmbControllerHostElement) {
        super(host);
        this.provideContext(UMB_WORKSPACE_CONTEXT, this);
        this.#repository = new AutoDictionariesRepository(this);
    }

    setUnique(unique: string | undefined) {
        this.#unique.setValue(unique);
        if (unique) {
            this.load(unique);
        }
    }

    getUnique(): string | undefined {
        return this.#unique.getValue();
    }

    async load(id: string) {
        const data = await this.#repository.getViewById(id);

        if (data) {
            this.#currentItem.setValue(data);
        }
    }

    getCurrentItem(): AutoDictionariesModel | undefined {
        return this.#currentItem.getValue();
    }

    getRepository(): AutoDictionariesRepository {
        return this.#repository;
    }

    getEntityType(): string {
        return "auto-dictionaries-item";
    }
}

export default AutoDictionariesItemWorkspaceContext;

itemmanifest.ts

const itemContext: UmbExtensionManifest = {
	type: "workspaceContext",
	alias: "autoDictionaries.item.workspace.context",
	name: "Auto dictionaries item workspace context",
	js: () => import("./workspace.context.js"),
	conditions: [
		{
			alias: "Umb.Condition.WorkspaceAlias",
			match: "autoDictionaries.item.workspace",
		},
	],
};

Workspace

itemworkspaceはちょっと違う。

Auto Dictionaries:Umbraco 13 から 17 への冒険の中で、どうやってitemのIDをworkspaceに取得したほうがいいか。

答えを長らく探したのに、 あまり答えを見つけなかった。

でも!やっと答えを見つけた!

答えはUmbRoute[]だよ!

workspace.context.ts

@customElement('auto-dictionaries-item-workspace')
export class AutoDictionariesItemWorkspaceElement extends UmbElementMixin(LitElement) {

    #workspaceContext: AutoDictionariesItemWorkspaceContext;

    #routes: UmbRoute[] = [
        {
            path: 'edit/:id',
            component: () => import('./item.element'),
            setup: (_component, info) => {
                const id = info.match.params.id;
                if (id) {
                    this.#workspaceContext.setUnique(id);
                }
            },
        },
        {
            path: '',
            redirectTo: 'edit/',
        }
    ];

    constructor() {
        super();
        this.#workspaceContext = new AutoDictionariesItemWorkspaceContext(this);
    }

    #handleBack() {
        window.history.pushState({}, '', "/umbraco/section/translation/workspace/auto-dictionaries-root/");
    }

    render() {
        return html`
            <umb-workspace-editor .enforceNoFooter=${true}>
                <uui-button id="back-button" slot="header" compact @click=${this.#handleBack} label="Back">
                    <uui-icon name="icon-arrow-left"></uui-icon>
                </uui-button>
                <div slot="header">Auto dictionaries</div>
                
                <umb-router-slot id="router-slot" .routes=${this.#routes}></umb-router-slot>
            </umb-workspace-editor>`;
    }
}

export default AutoDictionariesItemWorkspaceElement;

declare global {
    interface HTMLElementTagNameMap {
        'auto-dictionaries-item-workspace': AutoDictionariesItemWorkspaceElement;
    }
}

itemmanifest.tsにはこれを追加する

const itemWorkspace: UmbExtensionManifest = {
	type: "workspace",
	alias: "autoDictionaries.item.workspace",
	name: "Auto dictionaries item workspace",
	js: () => import('./workspace.element.js'),
	meta: {
		entityType: "auto-dictionaries-item",
	},
};

Workspace view

item.element.ts

@customElement("auto-dictionaries-item-edit")
export class autoDictionariesItemViewElement extends UmbElementMixin(LitElement) {
	private _routeBuilder?: UmbModalRouteBuilder;

	#workspaceContext?: AutoDictionariesItemWorkspaceContext;
	#observerCleanup?: () => void;

	@state()
	private _item?: AutoDictionariesModel;

	constructor() {
		super();

		new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL)
			.addAdditionalPath('general/:entityType')
			.onSetup((params) => {
				return { data: { entityType: params.entityType, preset: {} } };
			})
			.observeRouteBuilder((routeBuilder) => {
				this._routeBuilder = routeBuilder;
			});

		this.consumeContext(UMB_WORKSPACE_CONTEXT, (context) => {
			this.#workspaceContext = context as AutoDictionariesItemWorkspaceContext;
			this.#observeContext();
		});
	}

	#observeContext() {
		if (!this.#workspaceContext) return;

		this.observe(this.#workspaceContext.currentItem, (item) => {
			this._item = item;
		});
	}

	render() {
		return html`
			<umb-body-layout header-transparent>
				<div id="autoDictionaries-layout">
					<div id="autoDictionaries-main">{{this._item.name}}</div>				
				</div>
			</umb-body-layout>`;
	}
}

export default autoDictionariesItemViewElement;

結果

itemworkspaceを終わった。


感想

Umbraco 13の動的アイテム付きツリーはもっと簡単だと思う!

Auto Dictionariesのアップグレードの冒険の間に、あまりドキュメントがないから、よく他のUmbraco 17のパッケージやブログやUmbraco 17のソースコードなどを見た。

そのアップグレードした時、ツリーの実装はずっと一番難しいと思う。

今は、Umbraco のドキュメントも増えたのに、Umbracoの例は簡単すきて、自分のプロジェクトをした時、あまり手伝わない。

このブログ記事を書いたから、将来の僕を手伝うといい。

でも、みなさんにも手伝うといい!


リンク

ヨハネス・ランツ

ヨハネス・ランツ

ウェブ開発者・経験8年

採用可能

お問い合わせ

Umbraco 17で動的アイテム付きツリーを作る方法 | ブログ | ヨハネス・ランツ