diff --git a/src/pat/contentbrowser/README.md b/src/pat/contentbrowser/README.md index 22daea98e..1aff8656e 100644 --- a/src/pat/contentbrowser/README.md +++ b/src/pat/contentbrowser/README.md @@ -10,17 +10,25 @@ Show a widget to select items in an offcanvas miller-column browser. ## Configuration -| Option | Type | Default | Description | -| :------------------------: | :-----: | :-------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | -| attributes | array | ['UID', 'Title', 'portal_type', 'path'] | This list is passed to the server during an AJAX request to specify the attributes which should be included on each item. | -| basePath | string | set to rootPath. | Start browse/search in this path. | -| contextPath | string | | Path of the object, which is currently edited. If this path is given, this object will not be selectable. | -| favorites | array | [] | Array of objects. These are favorites, which can be used to quickly jump to different locations. Objects have the attributes "title" and "path". | -| maximumSelectionSize | integer | -1 | The maximum number of items that can be selected in a multi-select control. If this number is less than 1 selection is not limited. | -| bSize | integer | 10 | Batch size to break down big result sets into multiple pages. | -| separator | string | ',' | Select2 option. String which separates multiple items. | -| upload | boolean | | Allow file and image uploads from within the related items widget. | -| vocabularyUrl | string | null | This is a URL to a JSON-formatted file used to populate the list | +| Option | Type | Default | Description | +| :------------------------: | :-----: | :-------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------: | +| vocabularyUrl | string | null | This is a URL to a JSON-formatted file used to populate the list | +| attributes | array | ['UID', 'Title', 'portal_type', 'path'] | This list is passed to the server during an AJAX request to specify the attributes which should be included on each item. | +| rootPath | string | "/" | Browsing/searching root path. You will not get beneath this path | +| rootUrl | string | | Browsing/searching root url. | +| basePath | string | set to rootPath. | Start browse/search in this path. | +| contextPath | string | | Path of the object, which is currently edited. If this path is given, this object will not be selectable. | +| favorites | array | [] | Array of objects. These are favorites, which can be used to quickly jump to different locations. Objects have the attributes "title" and "path". | +| maximumSelectionSize | integer | -1 | The maximum number of items that can be selected in a multi-select control. If this number is less than 1 selection is not limited. | +| mode | string | "browse" | Toggle between "browse" and "search" | +| width | integer | | Override the width of the selected items field | +| bSize | integer | 10 | Batch size of the items listed in levels | +| maxDepth | integer | | Maximum level depth for "browse" mode | +| separator | string | ',' | Select2 option. String which separates multiple items. | +| upload | boolean | | Allow file and image uploads from within the related items widget. | +| recentlyUsed | boolean | false | Show the recently used items dropdown. | +| recentlyUsedKey | integer | | Storage key for saving the recently used items. This is generated with fieldname and username in the patternoptions. | +| recentlyUsedMaxItems | integer | 20 | Maximum items to keep in recently used list. 0: no restriction. | ## Default diff --git a/src/pat/contentbrowser/contentbrowser.js b/src/pat/contentbrowser/contentbrowser.js index 60c7e3846..71cdad1c4 100644 --- a/src/pat/contentbrowser/contentbrowser.js +++ b/src/pat/contentbrowser/contentbrowser.js @@ -23,7 +23,10 @@ parser.addArgument( ], null, true ); parser.addArgument("width"); +parser.addArgument("mode"); parser.addArgument("max-depth"); +parser.addArgument("root-path"); +parser.addArgument("root-url"); parser.addArgument("base-path"); parser.addArgument("context-path"); parser.addArgument("maximum-selection-size"); @@ -34,7 +37,7 @@ parser.addArgument("selection-template"); parser.addArgument("favorites"); parser.addArgument("recently-used"); parser.addArgument("recently-used-key"); -parser.addArgument("recently-used-max-items", 20); +parser.addArgument("recently-used-max-items"); parser.addArgument("b-size"); class Pattern extends BasePattern { @@ -43,7 +46,7 @@ class Pattern extends BasePattern { static parser = parser; async init() { - this.el.setAttribute('style', 'display: none'); + this.el.style.display = "none"; // ensure an id on our element (TinyMCE doesn't have one) let nodeId = this.el.getAttribute("id"); diff --git a/src/pat/contentbrowser/src/App.svelte b/src/pat/contentbrowser/src/App.svelte index 34bc2e45e..2d3ad6441 100644 --- a/src/pat/contentbrowser/src/App.svelte +++ b/src/pat/contentbrowser/src/App.svelte @@ -19,6 +19,8 @@ export let contextPath; export let vocabularyUrl; export let mode = "browse"; + export let rootPath = ""; + export let rootUrl = ""; export let basePath = ""; export let selectableTypes = []; export let maximumSelectionSize = -1; @@ -48,12 +50,10 @@ const currentPath = getContext("currentPath"); if (!$currentPath) { - $currentPath = basePath || "/"; + // if root path is not above base path we start at rootPath + $currentPath = basePath.indexOf(rootPath) != 0 ? rootPath : basePath; } - // base_url information - const base_url = document.body.getAttribute("data-portal-url"); - let config = getContext("config"); $config = { mode: mode, @@ -62,6 +62,8 @@ vocabularyUrl: vocabularyUrl, width: width, maxDepth: maxDepth, + rootPath: rootPath, + rootUrl: rootUrl, basePath: basePath, selectableTypes: selectableTypes, maximumSelectionSize: maximumSelectionSize, @@ -74,7 +76,6 @@ recentlyUsed: recentlyUsed, recentlyUsedKey: recentlyUsedKey, recentlyUsedMaxItems: recentlyUsedMaxItems, - base_url: base_url, pageSize: bSize, }; diff --git a/src/pat/contentbrowser/src/ContentBrowser.svelte b/src/pat/contentbrowser/src/ContentBrowser.svelte index 6795f52a0..0cc19fe62 100644 --- a/src/pat/contentbrowser/src/ContentBrowser.svelte +++ b/src/pat/contentbrowser/src/ContentBrowser.svelte @@ -72,7 +72,7 @@ const uploadEl = document.querySelector(".upload-wrapper"); uploadEl.classList.add("pat-upload"); const patUpload = new Upload(uploadEl, { - baseUrl: $config.base_url, + baseUrl: $config.rootUrl, currentPath: $currentPath, relativePath: "@@fileUpload", allowPathSelection: false, @@ -92,21 +92,22 @@ } else { const pathParts = item.path.split("/"); const folderPath = pathParts.slice(0, pathParts.length - 1).join("/"); - currentPath.set(folderPath || "/"); + currentPath.set(folderPath || $config.rootPath); updatePreview({ data: item }); } scrollToRight(); } function changePath(item, e) { + // always hide upload when changing path showUpload = false; // clear previous selection updatePreview({ action: "clear" }); - if (item === "/") { + if (item === "/" || item === $config.rootPath) { // clicked "home" button - currentPath.set(item); + currentPath.set($config.rootPath); return; } @@ -179,8 +180,10 @@ const possibleFocusEls = [ ...document.querySelectorAll(".levelColumn .inPath"), // previously selected folder ...document.querySelectorAll(".levelColumn .selectedItem"), // previously selected item - document.querySelector(".levelColumn .contentItem"), // default first item ]; + if(!possibleFocusEls.length && document.querySelector(".levelColumn .contentItem")) { + possibleFocusEls.push(document.querySelector(".levelColumn .contentItem")); + } if (possibleFocusEls.length) { keyboardNavInitialized = true; possibleFocusEls[0].focus(); @@ -282,8 +285,8 @@ } const item = response.results[0]; if (!item.path) { - // fix for Plone Site - item.path = "/"; + // fix for root + item.path = $config.rootPath; } changePath(item); } @@ -309,18 +312,12 @@ } function itemInPath(item) { - return $currentPath.indexOf(item.path) != -1; + return $config.mode == "browse" && $currentPath.indexOf(item.path) != -1; } - function filterItems() { - let timeoutId; - if (timeoutId) { - clearTimeout(timeoutId); - } - timeoutId = setTimeout(() => { - contentItems.get({ path: $currentPath, searchTerm: this.value }); - }, 300); - } + const filterItems = utils.debounce((e) => { + contentItems.get({ path: $currentPath, searchTerm: e.target.value }); + }, 300); function loadMore(node) { const observer = new IntersectionObserver( @@ -413,13 +410,13 @@ in:fly|local={{ duration: 300 }} >
- {#if i == 0} + {#if i == 0 && $config.mode == "browse"} addItem(level)} > {_t("select ${level_path}", { - level_path: level.absPath || "/", + level_path: level.displayPath, })} {/if} @@ -505,6 +502,9 @@ }} /> {item.Title} + {#if $config.mode == "search"} +
{item.path} + {/if}
{/if} {#if item.is_folderish && $config.mode == "browse"} diff --git a/src/pat/contentbrowser/src/ContentStore.js b/src/pat/contentbrowser/src/ContentStore.js index a1d08fab8..d986aa54e 100644 --- a/src/pat/contentbrowser/src/ContentStore.js +++ b/src/pat/contentbrowser/src/ContentStore.js @@ -20,23 +20,56 @@ export default function (config, pathCache) { return await request(query); } - const browse = async (portalPath, path, searchTerm, updateCache) => { + const browse = async (path, searchTerm, updateCache) => { + + let rootPath = config.rootPath; + let rootPathParts = rootPath.replace(/^\/+/, '').split("/"); + let physicalPath = path; + let hideRootPath = rootPath; + + if (!physicalPath.startsWith(rootPath)) { + // The path from the returned items from "vocabularyUrl" are starting + // relative from the Plone Site. So we need to generate the phyiscalPath here. + if (rootPathParts.length === 1) { + physicalPath = rootPath + physicalPath; + } else { + // We also have to merge the rootPath and the clicked path correctly for example: + // rootPath: /Plone/media, clicked path: /media/subfolder + // has to become: + // /Plone/media/subfolder + let pathParts = physicalPath.replace(/^\/+/, '').split("/"); + let overlapIdx = rootPathParts.length; + for (let idx = 0; idx < rootPathParts.length; idx++) { + if (rootPathParts[idx] === pathParts[0]) { + overlapIdx = idx; + break; + } + } + hideRootPath = "/" + (rootPathParts.filter(it => pathParts.includes(it))).join("/"); + physicalPath = "/" + (rootPathParts.slice(1, overlapIdx).concat(pathParts)).join("/"); + } + } + let paths = []; - let parts = path.split("/") || []; - const depth = parts.length >= config.maxDepth ? config.maxDepth : parts.length; + let parts = physicalPath.split("/") || []; + const maxDepth = Math.min(parts.length, config.maxDepth || 999); - let partsToShow = parts.slice(parts.length - depth, parts.length); - let partsToHide = parts.slice(0, parts.length - depth); - const pathPrefix = portalPath + partsToHide.join("/"); - const pC = get(pathCache); + let partsToShow = parts.slice(parts.length - maxDepth, parts.length); + let partsToHide = parts.slice(0, parts.length - maxDepth); + const pathPrefix = partsToHide.join("/"); while (partsToShow.length > 0) { let sub_path = partsToShow.join("/").replace(/^\//, ""); const poped = partsToShow.pop(); sub_path = pathPrefix + ((poped != "") ? `/${sub_path}` : ""); - if (paths.indexOf(sub_path) === -1) paths.push(sub_path); + if (sub_path && paths.indexOf(sub_path) === -1) paths.push(sub_path); + if (sub_path == rootPath) { + // respect rootPath + break; + } } + const pC = get(pathCache); let levels = []; let pathCounter = 0; @@ -65,7 +98,7 @@ export default function (config, pathCache) { level.searchTerm = searchTerm; level.page = 1; level.path = p; - level.absPath = p.replace(portalPath, ""); + level.displayPath = p.replace(new RegExp(`^(${hideRootPath}|${rootPath})`), "") || "/" // do not update cache when searching if (!searchTerm) { @@ -94,12 +127,16 @@ export default function (config, pathCache) { store.set(levels); } - const search = async (portalPath, searchTerm, page) => { + const search = async (searchTerm, page) => { let query = { - searchPath: portalPath, + searchPath: config.rootPath, page: page, }; if (searchTerm) { + if (searchTerm.length < 2) { + // minimum length of search term + return; + } query["searchTerm"] = "*" + searchTerm + "*"; } let level = await load(query); @@ -167,11 +204,8 @@ export default function (config, pathCache) { loadMorePath = "", page = 1, }) => { - const base_url = new URL(config.base_url); - const portalPath = base_url.pathname; - if (config.mode === "search") { - await search(portalPath, searchTerm, page) + await search(searchTerm, page); } else if (loadMorePath) { const pC = get(pathCache); if (!(loadMorePath in pC)) { @@ -182,8 +216,7 @@ export default function (config, pathCache) { await nextBatch(loadMorePath, page, level.searchTerm); } } else if (path) { - path = path.replace(new RegExp(`^${portalPath}`), ""); - await browse(portalPath, path, searchTerm, updateCache); + await browse(path, searchTerm, updateCache); } }; diff --git a/src/pat/contentbrowser/src/RecentlyUsed.svelte b/src/pat/contentbrowser/src/RecentlyUsed.svelte index 9be8bfdba..1663d69ad 100644 --- a/src/pat/contentbrowser/src/RecentlyUsed.svelte +++ b/src/pat/contentbrowser/src/RecentlyUsed.svelte @@ -35,7 +35,6 @@ class="dropdown-item" > selectedItemsUids.map((x) => x.UID)); + $selectedItems = await get_items_from_uids(initialValue, $config); + selectedUids.update(() => $selectedItems.map((x) => x.UID)); } function initializeSorting() { diff --git a/src/pat/contentbrowser/src/utils.js b/src/pat/contentbrowser/src/utils.js index 3616b6bdb..395af9861 100644 --- a/src/pat/contentbrowser/src/utils.js +++ b/src/pat/contentbrowser/src/utils.js @@ -17,6 +17,7 @@ export async function request({ criteria: [], }; if (path) { + // query sublevel of path vocabQuery = { criteria: [ { @@ -30,6 +31,7 @@ export async function request({ }; } if (levelInfoPath) { + // query exact path vocabQuery = { criteria: [ { @@ -51,7 +53,7 @@ export async function request({ }, ], }; - if (selectableTypes) { + if (selectableTypes.length) { vocabQuery.criteria.push({ i: "portal_type", o: "plone.app.querystring.operation.list.contains", @@ -85,6 +87,7 @@ export async function request({ total: 0, } }; + let url = `${vocabularyUrl}&query=${JSON.stringify( vocabQuery )}&attributes=${JSON.stringify(attributes)}&batch=${JSON.stringify({ @@ -99,32 +102,33 @@ export async function request({ method: method, headers: headers, }); - const json = await response.json(); - if (response.ok) { - if (!searchPath && !levelInfoPath && selectableTypes.length) { - // we iter through response and filter out non-selectable - // types but keep folderish types to maintain browsing - // the content structure. - const filtered_response = { - results: [], - total: json.total, - } - for (const it of json.results) { - if (selectableTypes.indexOf(it.portal_type) != -1 || it.is_folderish) { - filtered_response.results.push(it); - } - } - return filtered_response; - } - return json; - } else { + if (!response.ok) { return { results: [], total: 0, errors: json.errors, }; } + + const json = await response.json(); + + if (!searchPath && !levelInfoPath && selectableTypes.length) { + // we iter through response and filter out non-selectable + // types but keep folderish types to maintain browsing + // the content structure. + const filtered_response = { + results: [], + total: json.total, + } + for (const it of json.results) { + if (selectableTypes.indexOf(it.portal_type) != -1 || it.is_folderish) { + filtered_response.results.push(it); + } + } + return filtered_response; + } + return json; } export async function get_items_from_uids(uids, config) { @@ -147,7 +151,6 @@ export async function get_items_from_uids(uids, config) { /** use Plone resolveIcon to load a SVG icon and replace node with icon code */ export async function resolveIcon(node, { iconName }) { - async function getIcon(iconName) { const icon = await utils.resolveIcon(iconName) return icon; @@ -186,7 +189,7 @@ export function recentlyUsedItems(filterItems, config) { }); } // max is applied AFTER filtering selectable items. - const max = parseInt(config.recentlyUsedMaxItems, 10); + const max = parseInt(config.recentlyUsedMaxItems, 20); if (max) { // return the slice from the end, as we want to display newest items first. ret = ret.slice(ret.length - max, ret.length);