|
| 1 | +<!-- Copyright by the Spark Development Network; Licensed under the Rock Community License --> |
| 2 | +<template> |
| 3 | + <NotificationBox v-if="blockError" alertType="danger" v-html="blockError"></NotificationBox> |
| 4 | + <Panel :title="panelName" |
| 5 | + type="block" |
| 6 | + :isCollapsible="false" |
| 7 | + :isInitiallyCollapsed="false"> |
| 8 | + <template #headerActions> |
| 9 | + <DropDownMenu title="Add Page" |
| 10 | + :items="dropDownMenuItems" |
| 11 | + :align="'left'"> |
| 12 | + <i class="ti ti-plus"></i> |
| 13 | + </DropDownMenu> |
| 14 | + </template> |
| 15 | + <TreeList v-if="isTreeListDataInitialized" |
| 16 | + v-model="treeListSelectedItems" |
| 17 | + :items="treeListItems" |
| 18 | + :initiallyExpanded="treeListInitiallyExpandedGuids" |
| 19 | + @update:modelValue="onSelect" |
| 20 | + @update:items="onUpdateItems" |
| 21 | + @treeitem-expanded="onTreeItemExpanded" |
| 22 | + @treeitem-collapsed="onTreeItemCollapsed" |
| 23 | + autoExpand /> |
| 24 | + </Panel> |
| 25 | +</template> |
| 26 | + |
| 27 | +<style scoped> |
| 28 | +:deep(.panel-body .rocktree span:hover) { |
| 29 | + background-color: var(--base-interface-softer); |
| 30 | +} |
| 31 | + |
| 32 | +:deep(.panel-body .rocktree .rocktree-name.selected) { |
| 33 | + background-color: var(--color-info-soft); |
| 34 | +} |
| 35 | +</style> |
| 36 | + |
| 37 | +<script setup lang="ts"> |
| 38 | + import { ref, computed, onBeforeMount } from "vue"; |
| 39 | + import NotificationBox from "@Obsidian/Controls/notificationBox.obs"; |
| 40 | + import Panel from "@Obsidian/Controls/panel.obs"; |
| 41 | + import DropDownMenu from "@Obsidian/Controls/dropDownMenu.obs"; |
| 42 | + import TreeList from "@Obsidian/Controls/treeList.obs"; |
| 43 | + import { Guid } from "@Obsidian/Types"; |
| 44 | + import { SiteType } from "@Obsidian/Enums/Cms/siteType"; |
| 45 | + import { areEqual, emptyGuid, toGuidOrNull } from "@Obsidian/Utility/guid"; |
| 46 | + import { MenuAction } from "@Obsidian/Types/Controls/dropDownMenu"; |
| 47 | + import { TreeItemBag } from "@Obsidian/ViewModels/Utility/treeItemBag"; |
| 48 | + import { CustomBlockBox } from "@Obsidian/ViewModels/Blocks/customBlockBox"; |
| 49 | + import { PageMapBag } from "@Obsidian/ViewModels/Blocks/Cms/PageMap/pageMapBag"; |
| 50 | + import { PageMapOptionsBag } from "@Obsidian/ViewModels/Blocks/Cms/PageMap/pageMapOptionsBag"; |
| 51 | + import { PageMapTreeListBag } from "@Obsidian/ViewModels/Blocks/Cms/PageMap/pageMapTreeListBag"; |
| 52 | + import { PageTreeItemProvider } from "@Obsidian/Utility/treeItemProviders"; |
| 53 | + import { onConfigurationValuesChanged, useConfigurationValues, useInvokeBlockAction, useReloadBlock } from "@Obsidian/Utility/block"; |
| 54 | + |
| 55 | + const invokeBlockAction = useInvokeBlockAction(); |
| 56 | + |
| 57 | + const box = useConfigurationValues<CustomBlockBox<PageMapBag, PageMapOptionsBag>>(); |
| 58 | + const bag = box.bag ?? {}; |
| 59 | + const options = box.options ?? getEmptyOptionsBag(); |
| 60 | + |
| 61 | + // #region Values |
| 62 | + |
| 63 | + const panelName = "Pages"; |
| 64 | + |
| 65 | + const itemProvider = new PageTreeItemProvider(); |
| 66 | + itemProvider.rootPageGuid = toGuidOrNull(options.blockProperties?.rootPage) ?? undefined; |
| 67 | + itemProvider.siteType = options.blockProperties?.siteType !== null ? options.blockProperties?.siteType : undefined; |
| 68 | + itemProvider.selectedPageGuids = []; |
| 69 | + |
| 70 | + const blockError = ref(""); |
| 71 | + |
| 72 | + const isTreeListDataInitialized = ref<boolean>(false); |
| 73 | + |
| 74 | + const treeList = ref<PageMapTreeListBag>(bag.treeList ?? getEmptyTreeListBag()); |
| 75 | + const treeListSelectedItems = ref<Guid[]>(treeList.value.selectedItems ?? []); |
| 76 | + const treeListItems = ref<TreeItemBag[]>(treeList.value.items ?? []); |
| 77 | + const treeListExpandedItems = ref<Guid[]>(treeList.value.expandedItems ?? []); |
| 78 | + const treeListInitiallyExpandedGuids = ref<Guid[]>(treeList.value.initiallyExpandedItems ?? []); |
| 79 | + |
| 80 | + // #endregion Values |
| 81 | + |
| 82 | + // #region Computed Values |
| 83 | + |
| 84 | + const dropDownMenuItems = computed<MenuAction[]>(() => [ |
| 85 | + { |
| 86 | + title: "Add Top-Level", |
| 87 | + type: "default", |
| 88 | + handler: onAddRootPage |
| 89 | + }, |
| 90 | + { |
| 91 | + title: "Add Child To Selected", |
| 92 | + type: "default", |
| 93 | + handler: onAddChildPage, |
| 94 | + disabled: !treeListSelectedItems || treeListSelectedItems.value.length !== 1 |
| 95 | + } |
| 96 | + ]); |
| 97 | + |
| 98 | + // #endregion Computed Values |
| 99 | + |
| 100 | + // #region Functions |
| 101 | + |
| 102 | + /** |
| 103 | + * Creates and returns an empty PageMapTreeListBag. |
| 104 | + * |
| 105 | + * @returns An empty PageMapTreeListBag. |
| 106 | + */ |
| 107 | + function getEmptyTreeListBag(): PageMapTreeListBag { |
| 108 | + return { |
| 109 | + selectedItems: [], |
| 110 | + items: [], |
| 111 | + expandedItems: [], |
| 112 | + initiallyExpandedItems: [], |
| 113 | + }; |
| 114 | + } |
| 115 | + |
| 116 | + /** |
| 117 | + * Creates and returns an empty PageMapOptionsBag. |
| 118 | + */ |
| 119 | + function getEmptyOptionsBag(): PageMapOptionsBag { |
| 120 | + return { |
| 121 | + blockProperties: { |
| 122 | + rootPage: null, |
| 123 | + siteType: SiteType.Web, |
| 124 | + } |
| 125 | + }; |
| 126 | + } |
| 127 | + |
| 128 | + /** |
| 129 | + * Sets the tree list items by retrieving and formatting them. |
| 130 | + */ |
| 131 | + async function setTreeListItems(): Promise<void> { |
| 132 | + treeListItems.value = await getFormattedTreeListItems(); |
| 133 | + } |
| 134 | + |
| 135 | + /** |
| 136 | + * Recursively retrieves and formats tree list items, setting default icons and loading children for expanded/selected items. |
| 137 | + * |
| 138 | + * @param items Optional array of TreeItemBag to format; if not provided, root items will be fetched. |
| 139 | + * @returns A Promise that resolves to an array of formatted TreeItemBag. |
| 140 | + */ |
| 141 | + async function getFormattedTreeListItems(items: TreeItemBag[] | null = null): Promise<TreeItemBag[]> { |
| 142 | + /** Recursively sets default icons for items that don't have one and populates children for expanded/selected items */ |
| 143 | + async function processTreeItems(items: TreeItemBag[]): Promise<void> { |
| 144 | + for (const item of items) { |
| 145 | + if (!item.value) { |
| 146 | + continue; |
| 147 | + } |
| 148 | + |
| 149 | + if (!item.iconCssClass || item.iconCssClass === "") { |
| 150 | + item.iconCssClass = "ti ti-file"; |
| 151 | + } |
| 152 | + |
| 153 | + // Load children for expanded or selected items |
| 154 | + const isItemSelected = treeListSelectedItems.value.includes(item.value); |
| 155 | + const isItemExpanded = treeListExpandedItems.value.includes(item.value); |
| 156 | + const isItemExpandedOrSelected = isItemExpanded || isItemSelected; |
| 157 | + |
| 158 | + if (item.hasChildren && isItemExpandedOrSelected) { |
| 159 | + if (!item.children || item.children.length === 0) { |
| 160 | + item.children = await itemProvider.getChildItems(item); |
| 161 | + } |
| 162 | + if (item.children && item.children.length > 0) { |
| 163 | + await processTreeItems(item.children); |
| 164 | + item.childCount = item.children.length; |
| 165 | + item.isFolder = true; |
| 166 | + } |
| 167 | + } |
| 168 | + } |
| 169 | + } |
| 170 | + |
| 171 | + // Ensure selected items are considered expanded |
| 172 | + treeListSelectedItems.value.forEach(selectedItem => { |
| 173 | + if (!treeListExpandedItems.value.includes(selectedItem)) { |
| 174 | + treeListExpandedItems.value = [...treeListExpandedItems.value, selectedItem]; |
| 175 | + } |
| 176 | + if (!treeListInitiallyExpandedGuids.value.includes(selectedItem)) { |
| 177 | + treeListInitiallyExpandedGuids.value = [...treeListInitiallyExpandedGuids.value, selectedItem]; |
| 178 | + } |
| 179 | + }); |
| 180 | + |
| 181 | + let formattedItems = items !== null ? JSON.parse(JSON.stringify(items)) as TreeItemBag[] : await itemProvider.getRootItems(treeListInitiallyExpandedGuids.value); |
| 182 | + |
| 183 | + // Recursively process all items |
| 184 | + await processTreeItems(formattedItems); |
| 185 | + |
| 186 | + return formattedItems; |
| 187 | + } |
| 188 | + |
| 189 | + // #endregion Functions |
| 190 | + |
| 191 | + // #region Event Handlers |
| 192 | + |
| 193 | + /** |
| 194 | + * Handles the expanding of a tree item by tracking its value and loading children if necessary. |
| 195 | + * |
| 196 | + * @param item The tree item that was expanded. |
| 197 | + */ |
| 198 | + async function onTreeItemExpanded(item: TreeItemBag): Promise<void> { |
| 199 | + if (item.value) { |
| 200 | + // Load child items if they haven't been loaded yet |
| 201 | + if (!item.children || item.children.length === 0) { |
| 202 | + const itemChildren = await itemProvider.getChildItems(item); |
| 203 | + |
| 204 | + if (itemChildren && itemChildren.length > 0) { |
| 205 | + // Set default icons for newly loaded children |
| 206 | + const setDefaultIconsRecursively = (items: TreeItemBag[]): void => { |
| 207 | + items.forEach(childItem => { |
| 208 | + if (!childItem.iconCssClass || childItem.iconCssClass === "") { |
| 209 | + childItem.iconCssClass = "ti ti-file"; |
| 210 | + } |
| 211 | + if (childItem.children && childItem.children.length > 0) { |
| 212 | + setDefaultIconsRecursively(childItem.children); |
| 213 | + } |
| 214 | + }); |
| 215 | + }; |
| 216 | + setDefaultIconsRecursively(itemChildren); |
| 217 | + item.children = itemChildren; |
| 218 | + item.hasChildren = true; |
| 219 | + item.childCount = itemChildren.length; |
| 220 | + item.isFolder = true; |
| 221 | + } |
| 222 | + } |
| 223 | + |
| 224 | + treeListExpandedItems.value = [...treeListExpandedItems.value, item.value]; |
| 225 | + } |
| 226 | + } |
| 227 | + |
| 228 | + /** |
| 229 | + * Handles the collapsing of a tree item by removing its value from the tracked expanded items. |
| 230 | + * |
| 231 | + * @param item The tree item that was collapsed. |
| 232 | + */ |
| 233 | + function onTreeItemCollapsed(item: TreeItemBag): void { |
| 234 | + if (item.value) { |
| 235 | + treeListExpandedItems.value = treeListExpandedItems.value.filter(id => id !== item.value); |
| 236 | + } |
| 237 | + } |
| 238 | + |
| 239 | + /** |
| 240 | + * Handles the selection of a page and navigates to the corresponding URL. |
| 241 | + * |
| 242 | + * @param value The array of selected page values. |
| 243 | + */ |
| 244 | + async function onSelect(value: string[]): Promise<void> { |
| 245 | + if (isTreeListDataInitialized.value === false) { |
| 246 | + return; |
| 247 | + } |
| 248 | + |
| 249 | + if (value.length > 1 || value.length < 0) { |
| 250 | + treeListSelectedItems.value = []; |
| 251 | + return; |
| 252 | + } |
| 253 | + |
| 254 | + if (treeListItems.value) { |
| 255 | + // Recursively search for the matching item in treeListItems and their children |
| 256 | + const findMatchingTreeListItem = (items: TreeItemBag[], targetValue: string): TreeItemBag | undefined => { |
| 257 | + for (const item of items) { |
| 258 | + if (item.value === targetValue) { |
| 259 | + return item; |
| 260 | + } |
| 261 | + if (item.children && item.children.length > 0) { |
| 262 | + const found = findMatchingTreeListItem(item.children, targetValue); |
| 263 | + if (found) { |
| 264 | + return found; |
| 265 | + } |
| 266 | + } |
| 267 | + } |
| 268 | + return undefined; |
| 269 | + }; |
| 270 | + |
| 271 | + const matchingTreeListItem = findMatchingTreeListItem(treeListItems.value, value[0]); |
| 272 | + |
| 273 | + if (matchingTreeListItem && matchingTreeListItem.value) { |
| 274 | + |
| 275 | + window.location.href = await getNavigationUrl(matchingTreeListItem.value, emptyGuid, treeListExpandedItems.value); |
| 276 | + } |
| 277 | + } |
| 278 | + } |
| 279 | + |
| 280 | + /** |
| 281 | + * Event handler for when the list of items in the tree list has been |
| 282 | + * updated outside of this component. |
| 283 | + * |
| 284 | + * @param newItems The new root items being used by the tree list. |
| 285 | + */ |
| 286 | + async function onUpdateItems(newItems: TreeItemBag[]): Promise<void> { |
| 287 | + treeListItems.value = await getFormattedTreeListItems(newItems); |
| 288 | + } |
| 289 | + |
| 290 | + /** |
| 291 | + * Handles adding a new root page by navigating to the appropriate URL. |
| 292 | + */ |
| 293 | + async function onAddRootPage(): Promise<void> { |
| 294 | + const rootPageGuid = toGuidOrNull(options.blockProperties?.rootPage); |
| 295 | + |
| 296 | + if (rootPageGuid && !areEqual(rootPageGuid, emptyGuid)) { |
| 297 | + window.location.href = await getNavigationUrl( |
| 298 | + emptyGuid, |
| 299 | + rootPageGuid, |
| 300 | + treeListExpandedItems.value, |
| 301 | + ); |
| 302 | + } |
| 303 | + else { |
| 304 | + window.location.href = await getNavigationUrl( |
| 305 | + emptyGuid, |
| 306 | + emptyGuid, |
| 307 | + treeListExpandedItems.value, |
| 308 | + ); |
| 309 | + } |
| 310 | + } |
| 311 | + |
| 312 | + /** |
| 313 | + * Handles adding a new child page to the selected page by navigating to the appropriate URL. |
| 314 | + */ |
| 315 | + async function onAddChildPage(): Promise<void> { |
| 316 | + window.location.href = await getNavigationUrl( |
| 317 | + emptyGuid, |
| 318 | + treeListSelectedItems.value[0], |
| 319 | + treeListExpandedItems.value, |
| 320 | + ); |
| 321 | + } |
| 322 | + |
| 323 | + // #endregion Event Handlers |
| 324 | + |
| 325 | + // #region Block Actions |
| 326 | + |
| 327 | + /** |
| 328 | + * Invokes the GetNavigationUrl block action to retrieve the navigation URL for a page. |
| 329 | + * |
| 330 | + * @param pageGuid The GUID of the page for the Page ID. |
| 331 | + * @param parentGuid The GUID of the parent page. |
| 332 | + * @param expandedGuids An array of GUIDs representing the currently expanded pages. |
| 333 | + * |
| 334 | + * @returns A Promise that resolves to the navigation URL as a string. |
| 335 | + */ |
| 336 | + async function getNavigationUrl(pageGuid: Guid | null, parentGuid: Guid | null, expandedGuids: Guid[] | null): Promise<string> { |
| 337 | + if (!options.blockProperties) { |
| 338 | + return ""; |
| 339 | + } |
| 340 | + |
| 341 | + if (pageGuid === null) { |
| 342 | + pageGuid = emptyGuid; |
| 343 | + } |
| 344 | + if (parentGuid === null) { |
| 345 | + parentGuid = emptyGuid; |
| 346 | + } |
| 347 | + if (!expandedGuids) { |
| 348 | + expandedGuids = []; |
| 349 | + } |
| 350 | + |
| 351 | + const result = await invokeBlockAction<string>("GetNavigationUrl", { |
| 352 | + entityGuid: pageGuid, |
| 353 | + parentGuid: parentGuid, |
| 354 | + expandedGuids: expandedGuids, |
| 355 | + }); |
| 356 | + |
| 357 | + if (result.isSuccess && result.data) { |
| 358 | + return result.data; |
| 359 | + } |
| 360 | + else if (result.errorMessage) { |
| 361 | + blockError.value = result.errorMessage; |
| 362 | + } |
| 363 | + |
| 364 | + return ""; |
| 365 | + } |
| 366 | + |
| 367 | + // #endregion Block Actions |
| 368 | + |
| 369 | + if (box.errorMessage) { |
| 370 | + blockError.value = box.errorMessage; |
| 371 | + } |
| 372 | + |
| 373 | + onBeforeMount(async () => { |
| 374 | + await setTreeListItems(); |
| 375 | + isTreeListDataInitialized.value = true; |
| 376 | + }); |
| 377 | + |
| 378 | + onConfigurationValuesChanged(useReloadBlock()); |
| 379 | +</script> |
0 commit comments