link providers

This commit is contained in:
2021-02-10 14:53:56 +01:00
parent 0a1ae40ff4
commit 6dce5c3599
18 changed files with 401 additions and 215 deletions
@@ -0,0 +1,26 @@
using System.Collections.Generic;
namespace zero.Core.Extensions
{
public static class DictionaryExtensions
{
public static bool TryGetValue<T>(this Dictionary<string, object> model, string key, out T value)
{
if (!model.TryGetValue(key, out object valueObj) || !(valueObj is T))
{
value = default;
return false;
}
value = (T)valueObj;
return true;
}
public static T GetValueOrDefault<T>(this Dictionary<string, object> model, string key)
{
object? value = model.GetValueOrDefault(key);
return value == default || !(value is T) ? default : (T)value;
}
}
}
-18
View File
@@ -1,18 +0,0 @@
using Raven.Client.Documents.Session;
using System.Collections.Generic;
using System.Threading.Tasks;
using zero.Core.Entities;
namespace zero.Core.Routing
{
public interface ILinkListProvider : ILinkProvider
{
/// <summary>
/// Get paged list items.
/// <param name="session">Current document session</param>
/// <param name="page">Current page number (one-based)</param>
/// <param name="search">Search query</param>
/// </summary>
Task<IList<TreeItem>> GetLinkListItems(IAsyncDocumentSession session, int page = 1, string search = null);
}
}
+12 -5
View File
@@ -1,17 +1,24 @@
using System.Threading.Tasks;
using Raven.Client.Documents.Session;
using System.Threading.Tasks;
using zero.Core.Entities;
namespace zero.Core.Routing
{
public interface ILinkProvider
{
string Name { get; }
string Alias { get; }
/// <summary>
///
/// </summary>
bool CanProcess(ILink link);
/// <summary>
///
/// </summary>
Task<string> ResolveLink(ILink link);
Task<string> Resolve(ILink link);
/// <summary>
///
/// </summary>
Task<PreviewModel> Preview(IAsyncDocumentSession session, ILink link);
}
}
-18
View File
@@ -1,18 +0,0 @@
using Raven.Client.Documents.Session;
using System.Collections.Generic;
using System.Threading.Tasks;
using zero.Core.Entities;
namespace zero.Core.Routing
{
public interface ILinkTreeProvider : ILinkProvider
{
/// <summary>
/// Get tree children for the current parent id.
/// <param name="session">Current document session</param>
/// <param name="parentId">Parent node id</param>
/// <param name="activeId">Selected node so parents can be set to open for the tree to load correclty</param>
/// </summary>
Task<IList<TreeItem>> GetLinkTreeItems(IAsyncDocumentSession session, string parentId = null, string activeId = null);
}
}
+79
View File
@@ -0,0 +1,79 @@
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using zero.Core.Database;
using zero.Core.Entities;
namespace zero.Core.Routing
{
public class Links : ILinks
{
protected IZeroStore Store { get; set; }
protected ILogger<Links> Logger { get; set; }
protected IEnumerable<ILinkProvider> Providers { get; set; }
public Links(IZeroStore store, ILogger<Links> logger, IEnumerable<ILinkProvider> providers)
{
Store = store;
Logger = logger;
Providers = providers;
}
/// <inheritdoc />
public async Task<string> GetUrl(ILink link)
{
ILinkProvider provider = Providers.LastOrDefault(x => x.CanProcess(link));
if (provider == null)
{
Logger.LogWarning("Could not find provider for link with area {area}", link.Area);
return null;
}
return await provider.Resolve(link);
}
/// <inheritdoc />
public async Task<Dictionary<ILink, string>> GetUrls(params ILink[] links)
{
Dictionary<ILink, string> result = new();
foreach (ILink link in links)
{
result.Add(link, await GetUrl(link));
}
return result;
}
/// <inheritdoc />
public ILinkProvider GetProvider(ILink link)
{
return Providers.LastOrDefault(x => x.CanProcess(link));
}
}
public interface ILinks
{
/// <summary>
/// Get URL from a link object by finding a provider which can resolve the link
/// </summary>
Task<string> GetUrl(ILink link);
/// <summary>
/// Get URLs from link objects by finding matching providers
/// </summary>
Task<Dictionary<ILink, string>> GetUrls(params ILink[] links);
/// <summary>
/// Get the provider for a specific link
/// </summary>
ILinkProvider GetProvider(ILink link);
}
}
+36 -20
View File
@@ -1,49 +1,65 @@
using Raven.Client.Documents.Session;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using zero.Core.Database;
using zero.Core.Entities;
using zero.Core.Extensions;
using zero.Core.Options;
namespace zero.Core.Routing
{
public class PageLinkProvider : ILinkProvider
{
/// <inheritdoc />
public string Name { get; } = "@links.providers.page";
/// <inheritdoc />
public string Alias { get; } = "zero.pages";
protected IZeroStore Store { get; set; }
protected IRoutes Routes { get; set; }
protected IZeroOptions Options { get; set; }
public PageLinkProvider(IZeroStore store, IRoutes routes)
public PageLinkProvider(IRoutes routes, IZeroOptions options)
{
Store = store;
Routes = routes;
Options = options;
}
/// <inheritdoc />
public async Task<string> ResolveLink(ILink link)
public bool CanProcess(ILink link) => link.Area == "zero.pages";
/// <inheritdoc />
public async Task<string> Resolve(ILink link)
{
if (!link.Values.TryGetValue("pageId", out object pageIdObj))
return await Routes.GetUrl<IPage>(link.Values.GetValueOrDefault<string>("id"));
}
/// <inheritdoc />
public async Task<PreviewModel> Preview(IAsyncDocumentSession session, ILink link)
{
string id = link.Values.GetValueOrDefault<string>("id");
if (id.IsNullOrEmpty())
{
return null;
}
string pageId = pageIdObj.ToString();
IPage page = await session.LoadAsync<IPage>(id);
using IAsyncDocumentSession session = Store.OpenAsyncSession();
IPage page = await session.LoadAsync<IPage>(pageId);
IRoute route = await Routes.GetRoute(page);
if (page == null)
{
return null;
}
return route.Url;
PageType pageType = Options.Pages.GetAllItems().FirstOrDefault(x => x.Alias == page.PageTypeAlias);
string url = await Routes.GetUrl<IPage>(page);
return new()
{
Id = page.Id,
Icon = pageType?.Icon ?? "fth-folder",
Name = page.Name,
Text = url
};
}
}
}
+42 -1
View File
@@ -8,7 +8,6 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using zero.Core.Api;
using zero.Core.Database;
using zero.Core.Entities;
using zero.Core.Extensions;
@@ -39,10 +38,42 @@ namespace zero.Core.Routing
public async Task<string> GetUrl<T>(T model) => (await GetRoute(model))?.Url;
/// <inheritdoc />
public async Task<string> GetUrl<T>(string id) where T : IZeroIdEntity => (await GetRoute(id))?.Url;
/// <inheritdoc />
public async Task<Dictionary<T, string>> GetUrls<T>(params T[] models) => (await GetRoutes(models)).ToDictionary(x => x.Key, x => x.Value?.Url);
/// <inheritdoc />
public async Task<IRoute> GetRoute<T>(string id) where T : IZeroIdEntity
{
if (id.IsNullOrEmpty())
{
return null;
}
Type type = typeof(T);
IRouteProvider routeProvider = Providers.FirstOrDefault(x => x.AffectedTypes.Any(t => t.IsAssignableFrom(type)));
if (routeProvider == null)
{
return null;
}
using IAsyncDocumentSession session = Store.OpenAsyncSession();
T model = await session.LoadAsync<T>(id);
if (model == null)
{
return null;
}
return await routeProvider.GetRoute(session, model);
}
/// <inheritdoc />
public async Task<IRoute> GetRoute<T>(T model)
{
@@ -246,11 +277,21 @@ namespace zero.Core.Routing
/// </summary>
Task<string> GetUrl<T>(T model);
/// <summary>
/// Get the URL for an entity
/// </summary>
Task<string> GetUrl<T>(string id) where T : IZeroIdEntity;
/// <summary>
/// Get the route object for an entity
/// </summary>
Task<IRoute> GetRoute<T>(T model);
/// <summary>
/// Get the route object for an entity
/// </summary>
Task<IRoute> GetRoute<T>(string id) where T : IZeroIdEntity;
/// <summary>
/// Get URLs for multiple entities
/// </summary>
+7
View File
@@ -0,0 +1,7 @@
import { get, post, del } from '../helpers/request.ts';
const base = 'links/';
export default {
getPreviews: async links => await post(base + 'getPreviews', links)
};
@@ -4,7 +4,7 @@
<ui-search v-model="search" />
</ui-property>
<ui-property :vertical="true">
<ui-tree ref="tree" v-bind="treeConfig" :get="getTreeItems" @select="onSelect" class="ui-linkpicker-area-pages-tree" />
<ui-tree ref="tree" v-bind="treeConfig" :get="getTreeItems" @select="onSelect" :selection="selection" class="ui-linkpicker-area-pages-tree" />
</ui-property>
</div>
</template>
@@ -29,9 +29,11 @@
},
data: () => ({
selection: [],
treeConfig: {
parent: null,
active: null
active: null,
mode: 'select'
},
search: null,
debouncedSearch: null
@@ -41,22 +43,40 @@
search()
{
this.debouncedSearch();
},
value()
{
this.selection = [this.value.values.id];
}
},
mounted()
{
this.debouncedSearch = _debounce(() => this.$refs.tree.refresh(), 300);
this.selection = this.value.values.id ? [this.value.values.id] : [];
},
methods: {
onSelect(item)
isValid()
{
this.value.values = {
id: item.id
};
return this.selection.length > 0;
},
onSelect(id)
{
if (id)
{
this.selection = [id];
this.value.values = { id };
}
else
{
this.selection = [];
this.value.values = { id: null };
}
this.$emit('change', this.value);
this.$emit('input', this.value);
},
getTreeItems(parent)
@@ -67,10 +87,6 @@
res.forEach(item =>
{
//if (item.id === this.model)
//{
// item.isSelected = true;
//}
item.hasActions = false;
});
@@ -92,4 +108,33 @@
{
background: var(--color-tree-selected);
}
.ui-linkpicker-area-pages-tree
{
.ui-tree-item.is-disabled
{
opacity: .5;
}
.ui-tree-item.is-selected, .ui-tree-item:hover:not(.is-disabled)
{
background: var(--color-tree-selected);
}
.ui-tree-item.is-selected
{
&:after
{
font-family: "Feather";
content: "\e83e";
font-size: 16px;
color: var(--color-primary);
}
.ui-tree-item-text
{
font-weight: bold;
}
}
}
</style>
@@ -1,12 +1,12 @@
<template>
<div class="ui-linkpicker" :class="{'is-disabled': disabled }">
<input ref="input" type="hidden" :value="value" />
<!--<div class="ui-pagepicker-previews" v-if="previews.length > 0">
<div class="ui-pagepicker-previews" v-if="previews.length > 0">
<div v-for="preview in previews" class="ui-pagepicker-preview">
<ui-select-button :icon="preview.icon" :label="preview.name" :description="preview.text" :disabled="disabled" @click="pick(preview.id)" :tokens="{ id: preview.id }" />
<ui-select-button :icon="preview.icon" :label="preview.name" :description="preview.text" :disabled="disabled" @click="onPick(preview.id)" :tokens="{ id: preview.id }" />
<ui-icon-button v-if="!disabled" @click="remove(preview.id)" icon="fth-x" title="@ui.close" />
</div>
</div>-->
</div>
<ui-select-button v-if="canAdd" icon="fth-plus" :label="limit > 1 ? '@ui.add' : '@ui.select'" @click="onPick()" :disabled="disabled" />
</div>
</template>
@@ -14,6 +14,7 @@
<script>
import LinkpickerOverlay from './overlay.vue';
import LinksApi from 'zero/api/links.js';
import Overlay from 'zero/helpers/overlay.js';
import { extend as _extend, isArray as _isArray, isEmpty as _isEmpty, clone as _clone } from 'underscore';
@@ -77,7 +78,6 @@
},
canAdd()
{
return true; // TODO
let count = Array.isArray(this.value) ? this.value.length : (!this.value ? 0 : 1);
return !this.disabled && count < this.limit;
}
@@ -101,17 +101,19 @@
updatePreviews()
{
this.previews = [];
//if (!this.value || _isEmpty(this.value))
//{
// this.previews = [];
// return;
//}
//let ids = _isArray(this.value) ? this.value : [this.value];
//PagesApi.getPreviews(ids).then(res =>
//{
// this.previews = res;
//});
if (!this.value || _isEmpty(this.value))
{
this.previews = [];
return;
}
let links = Array.isArray(this.value) ? this.value : [this.value];
LinksApi.getPreviews(links).then(res =>
{
this.previews = res;
});
},
@@ -125,7 +127,7 @@
}
else
{
this.onChange(this.limit > 1 ? [] : null);
this.onChange(this.multiple ? [] : null);
}
},
@@ -134,7 +136,8 @@
{
this.pick(id).then(res =>
{
if (this.limit > 1)
console.info(JSON.parse(JSON.stringify(res)));
if (this.multiple)
{
this.value.push(res);
this.onChange(this.value);
@@ -33,7 +33,7 @@
</div>
<div class="ui-box">
<component v-if="area && area.component" :is="area.component" :area="area" :value="link" />
<component v-if="area && area.component" :is="area.component" :area="area" v-model="link" />
</div>
</div>
</ui-overlay-editor>
@@ -90,6 +90,8 @@
this.link = JSON.parse(JSON.stringify(this.model || this.template));
this.link.area = this.current;
console.info(JSON.parse(JSON.stringify(this.link)));
setTimeout(() => this.opened = true, 300);
},
+18 -12
View File
@@ -28,6 +28,10 @@
activeId: {
type: String,
default: null
},
selected: {
type: Boolean,
default: false
}
},
@@ -62,7 +66,7 @@
'has-children': item.hasChildren,
'is-inactive': item.isInactive,
'is-open': item.isOpen,
'is-selected': item.isSelected,
'is-selected': this.selected || item.isSelected,
'is-disabled': item.disabled,
'is-active': this.isLink && ((!!item.id && item.id === this.activeId) || item.id == this.$route.params.id || (item.url && !item.url.params && item.url.name === this.$route.name))
};
@@ -128,7 +132,7 @@
opacity: .5;
}
&.is-active:before
&.is-active:before, &.is-selected:before
{
content: ' ';
position: absolute;
@@ -139,17 +143,19 @@
background: var(--color-tree-selected);
}
/*&.is-active:after
&.is-selected:after
{
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
display: inline-block;
background: var(--color-tree-selected-line);
}*/
font-family: "Feather";
content: "\e83e";
font-size: 16px;
color: var(--color-primary);
z-index: 2;
}
&.is-selected .ui-tree-item-text
{
font-weight: bold;
}
}
.ui-tree-item-link
+38 -3
View File
@@ -6,8 +6,8 @@
<slot></slot>
<span v-if="status === 'loading'" class="ui-tree-item-loading"><i></i></span>
<template v-for="item in items">
<ui-tree-item :value="item" @rightclick="onRightClicked" @click="onSelect(item, $event)" @actions="onActionsClicked" @open="toggle" :active-id="active" />
<ui-tree v-if="item.hasChildren && item.isOpen && status != 'loading'" :get="get" :parent="item.id" :depth="depth + 1" :active="active" @select="onSelect">
<ui-tree-item :value="item" @rightclick="onRightClicked" @click="onSelect(item, $event)" @actions="onActionsClicked" @open="toggle" :active-id="active" :selected="selection.indexOf(item.id) > -1" />
<ui-tree v-if="item.hasChildren && item.isOpen && status != 'loading'" v-bind="{ get, parent: item.id, depth: depth + 1, active, onSelect, mode, selection, selectionLimit }">
<template v-slot:actions="props">
<slot name="actions" v-bind="props"></slot>
</template>
@@ -51,6 +51,18 @@
hasActions: {
type: Function,
default: null
},
mode: {
type: String,
default: 'link'
},
selection: {
type: Array,
default: () => []
},
selectionLimit: {
type: Number,
default: 1
}
},
@@ -123,7 +135,29 @@
// selected an item
onSelect(item, ev)
{
this.$emit('select', item, ev);
if (this.mode === 'select')
{
let index = this.selection.indexOf(item.id);
if (index > -1)
{
this.selection.splice(index, 1);
}
else if (this.selectionLimit === 1)
{
this.selection.splice(0, this.selection.length);
this.selection.push(item.id);
}
else if (this.selection.length < this.selectionLimit)
{
this.selection.push(item.id);
}
this.$emit('select', this.selectionLimit > 1 ? this.selection : (this.selection.length > 0 ? this.selection[0] : null), ev);
}
else
{
this.$emit('select', item, ev);
}
},
@@ -197,6 +231,7 @@
.ui-tree
{
position: relative;
overflow-x: hidden;
}
.ui-tree-header + .ui-tree-item
+5 -38
View File
@@ -15,7 +15,7 @@
</ui-property>
</div>
<div class="ui-box pages-copy-items">
<ui-tree ref="tree" :get="getItems" @select="onSelect" />
<ui-tree ref="tree" :get="getItems" @select="onSelect" mode="select" />
</div>
</ui-overlay-editor>
</template>
@@ -39,7 +39,6 @@
data: () => ({
items: [],
selected: [],
state: 'default',
cache: {},
prevItem: null,
@@ -56,18 +55,9 @@
methods: {
onSelect(item)
onSelect(id)
{
item.isSelected = true;
if (this.prevItem && this.prevItem.id != item.id)
{
this.prevItem.isSelected = false;
}
this.prevItem = item;
this.selected = item;
//this.config.confirm(item);
this.selected = id;
},
getItems(parent)
@@ -100,8 +90,6 @@
response.forEach(item =>
{
//item.disabled = true;
item.isSelected = false;
item.disabled = item.id === 'recyclebin' || item.id == this.model.id;
item.hasActions = false;
});
@@ -115,7 +103,7 @@
{
this.state = 'loading';
PagesApi.copy(this.model.id, this.selected.id, this.includeDescendants).then(res =>
PagesApi.copy(this.model.id, this.selected, this.includeDescendants).then(res =>
{
if (res.success)
{
@@ -154,7 +142,7 @@
.pages-copy .ui-box
{
margin: 0;
padding: 20px var(--padding) 18px;
padding: 20px var(--padding) 18px;
& + .ui-box
{
@@ -166,27 +154,6 @@
{
opacity: .5;
}
.ui-tree-item.is-selected, .ui-tree-item:hover:not(.is-disabled)
{
background: var(--color-tree-selected);
}
.ui-tree-item.is-selected
{
&:after
{
font-family: "Feather";
content: "\e83e";
font-size: 16px;
color: var(--color-primary);
}
.ui-tree-item-text
{
font-weight: bold;
}
}
}
.pages-copy content
+5 -42
View File
@@ -10,7 +10,7 @@
<p class="pages-move-text" v-localize:html="{ key: '@ui.move.text', tokens: { name: model.name } }"></p>
<div class="ui-box pages-move-items">
<ui-tree ref="tree" :get="getItems" @select="onSelect" />
<ui-tree ref="tree" :get="getItems" mode="select" @select="onSelect" />
</div>
</ui-overlay-editor>
</template>
@@ -50,18 +50,9 @@
methods: {
onSelect(item)
onSelect(id)
{
item.isSelected = true;
if (this.prevItem && this.prevItem.id != item.id)
{
this.prevItem.isSelected = false;
}
this.prevItem = item;
this.selected = item;
//this.config.confirm(item);
this.selected = id;
},
getItems(parent)
@@ -94,13 +85,6 @@
response.forEach(item =>
{
//item.disabled = true;
item.isSelected = this.model.parentId == item.id;
if (item.isSelected)
{
this.prevItem = item;
}
item.disabled = item.id === 'recyclebin' || item.id == this.model.id;
item.hasActions = false;
});
@@ -112,7 +96,7 @@
onSave()
{
if (this.model.parentId == this.selected.id)
if (this.model.parentId == this.selected)
{
this.config.close();
return;
@@ -120,7 +104,7 @@
this.state = 'loading';
PagesApi.move(this.model.id, this.selected.id).then(res =>
PagesApi.move(this.model.id, this.selected).then(res =>
{
if (res.success)
{
@@ -148,27 +132,6 @@
{
opacity: .5;
}
.ui-tree-item.is-selected, .ui-tree-item:hover:not(.is-disabled)
{
background: var(--color-tree-selected);
}
.ui-tree-item.is-selected
{
&:after
{
font-family: "Feather";
content: "\e83e";
font-size: 16px;
color: var(--color-primary);
}
.ui-tree-item-text
{
font-weight: bold;
}
}
}
.pages-move content
@@ -1,31 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using System.Threading.Tasks;
using zero.Core.Api;
using zero.Core.Entities;
namespace zero.Web.Controllers
{
public class LinkPickerController : BackofficeController
{
IPageTreeApi Api;
IPagesApi PagesApi;
public LinkPickerController(IPageTreeApi api, IPagesApi pagesApi)
{
Api = api;
PagesApi = pagesApi;
}
public async Task<IList<TreeItem>> GetChildren([FromQuery] string areaAlias, [FromQuery] string parent = null, [FromQuery] string active = null)
{
return await Api.GetChildren(parent, active);
}
//public async Task<IList<PreviewModel>> GetPreviews([FromQuery] List<string> ids)
//{
// return Previews(await PagesApi.GetByIds(ids.ToArray()), PreviewTransform);
//}
}
}
+54
View File
@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Mvc;
using Raven.Client.Documents.Session;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using zero.Core.Api;
using zero.Core.Database;
using zero.Core.Entities;
using zero.Core.Routing;
using zero.Core.Utils;
namespace zero.Web.Controllers
{
public class LinksController : BackofficeController
{
IZeroStore Store;
ILinks Links;
public LinksController(IZeroStore store, ILinks links)
{
Store = store;
Links = links;
}
[HttpPost]
public async Task<IList<PreviewModel>> GetPreviews([FromBody] List<Link> links)
{
IList<PreviewModel> previews = new List<PreviewModel>();
using IAsyncDocumentSession session = Store.OpenAsyncSession();
foreach (Link link in links)
{
ILinkProvider provider = Links.GetProvider(link);
PreviewModel model = null;
if (provider != null)
{
model = await provider.Preview(session, link);
}
previews.Add(model ?? new PreviewModel()
{
HasError = true,
Icon = "fth-alert-circle color-red",
Id = IdGenerator.Create(),
Name = "@errors.preview.notfound",
Text = "@errors.preview.notfound_text"
});
}
return previews;
}
}
}
@@ -112,6 +112,8 @@ namespace zero.Web.Defaults
//services.AddTransient<IUrlProvider, PageUrlProvider>();
services.AddScoped<IRouteProvider, PageRouteProvider>();
services.AddScoped<PageRouteProvider>();
services.AddScoped<ILinks, Links>();
services.AddScoped<ILinkProvider, PageLinkProvider>();
services.AddScoped<ZeroRoutesTransformer>();