start media section

This commit is contained in:
2021-12-16 00:19:51 +01:00
parent 053847f4ab
commit e8159f8eb0
20 changed files with 443 additions and 864 deletions
@@ -25,7 +25,7 @@ public abstract class ZeroApiEntityStoreController<TModel, TStore> : ZeroApiCont
}
protected async Task<ActionResult<T>> EmptyModel<T>(string flavorAlias = null, Action<T> modify = null) where T : DisplayModel<TModel>
protected async Task<ActionResult<T>> EmptyModel<T>(string flavorAlias = null, Action<T> modify = null)
{
TModel model = await Store.Empty(flavorAlias);
@@ -54,7 +54,7 @@ public abstract class ZeroApiEntityStoreController<TModel, TStore> : ZeroApiCont
}
protected async Task<ActionResult<T>> GetModel<T>(string id, string changeVector = null) where T : DisplayModel<TModel>
protected async Task<ActionResult<T>> GetModel<T>(string id, string changeVector = null)
{
TModel model = await Store.Load(id, changeVector);
@@ -84,7 +84,7 @@ public abstract class ZeroApiEntityStoreController<TModel, TStore> : ZeroApiCont
}
protected async Task<ActionResult<Paged>> GetModels<T>(ListQuery<TModel> query) where T : BasicModel<TModel>
protected async Task<ActionResult<Paged>> GetModels<T>(ListQuery<TModel> query)
{
query.OrderQuery ??= q => q.OrderByDescending(x => x.CreatedDate);
query.SearchSelector ??= x => x.Name;
@@ -103,7 +103,7 @@ public abstract class ZeroApiEntityStoreController<TModel, TStore> : ZeroApiCont
}
protected async Task<ActionResult<Paged>> GetModelsByIndex<T, TIndex>(ListQuery<TModel> query) where T : BasicModel<TModel> where TIndex : AbstractCommonApiForIndexes, new()
protected async Task<ActionResult<Paged>> GetModelsByIndex<T, TIndex>(ListQuery<TModel> query) where TIndex : AbstractCommonApiForIndexes, new()
{
query.OrderQuery ??= q => q.OrderByDescending(x => x.CreatedDate);
query.SearchSelector ??= x => x.Name;
@@ -122,7 +122,7 @@ public abstract class ZeroApiEntityStoreController<TModel, TStore> : ZeroApiCont
}
protected async Task<ActionResult<Result>> CreateModel<T, TEdit>(T saveModel) where T : SaveModel<TModel> where TEdit : DisplayModel<TModel>
protected async Task<ActionResult<Result>> CreateModel<T, TEdit>(T saveModel) where T : ISupportsFlavors
{
TModel emptyModel = saveModel.Flavor.IsNullOrEmpty() ? await Store.Empty() : await Store.Empty(saveModel.Flavor);
TModel model = Mapper.Map(saveModel, emptyModel);
@@ -156,7 +156,7 @@ public abstract class ZeroApiEntityStoreController<TModel, TStore> : ZeroApiCont
}
protected async Task<ActionResult<Result>> UpdateModel<T, TEdit>(string id, T updateModel, string changeToken = null) where T : SaveModel<TModel> where TEdit : DisplayModel<TModel>
protected async Task<ActionResult<Result>> UpdateModel<T, TEdit>(string id, T updateModel, string changeToken = null) where T : ZeroIdEntity
{
if (id != updateModel.Id)
{
@@ -10,7 +10,7 @@ public abstract class ZeroApiTreeEntityStoreController<TModel, TStore> : ZeroApi
public ZeroApiTreeEntityStoreController(TStore store) : base(store) { }
protected async Task<ActionResult<Paged>> GetChildModels<T>(string parentId, ListQuery<TModel> query) where T : BasicModel<TModel>
protected async Task<ActionResult<Paged>> GetChildModels<T>(string parentId, ListQuery<TModel> query)
{
query.OrderQuery ??= q => q.OrderByDescending(x => x.CreatedDate);
Paged<TModel> result = await Store.LoadChildren(parentId, query.Page, query.PageSize, q => q.Filter(query));
@@ -28,7 +28,7 @@ public abstract class ZeroApiTreeEntityStoreController<TModel, TStore> : ZeroApi
}
protected async Task<ActionResult<Paged>> GetChildModelsByIndex<T, TIndex>(string parentId, ListQuery<TModel> query) where T : BasicModel<TModel> where TIndex : AbstractCommonApiForIndexes, new()
protected async Task<ActionResult<Paged>> GetChildModelsByIndex<T, TIndex>(string parentId, ListQuery<TModel> query) where TIndex : AbstractCommonApiForIndexes, new()
{
query.OrderQuery ??= q => q.OrderByDescending(x => x.CreatedDate);
Paged<TModel> result = await Store.LoadChildren<TIndex>(parentId, query.Page, query.PageSize, q => q.Filter(query));
@@ -52,7 +52,7 @@ public abstract class ZeroApiTreeEntityStoreController<TModel, TStore> : ZeroApi
}
protected async Task<ActionResult<Result>> MoveModel<TEdit>(string pageId, string newParentId) where TEdit : DisplayModel<TModel>
protected async Task<ActionResult<Result>> MoveModel<TEdit>(string pageId, string newParentId)
{
return await PutOperation<TEdit>(async () => await Store.Move(pageId, newParentId));
}
@@ -64,7 +64,7 @@ public abstract class ZeroApiTreeEntityStoreController<TModel, TStore> : ZeroApi
}
protected async Task<ActionResult<Result>> CopyModel<TEdit>(string pageId, string newParentId) where TEdit : DisplayModel<TModel>
protected async Task<ActionResult<Result>> CopyModel<TEdit>(string pageId, string newParentId)
{
return await PutOperation<TEdit>(async () => await Store.Copy(pageId, newParentId));
}
@@ -76,7 +76,7 @@ public abstract class ZeroApiTreeEntityStoreController<TModel, TStore> : ZeroApi
}
protected async Task<ActionResult<Result>> CopyModelWithDescendants<TEdit>(string pageId, string newParentId) where TEdit : DisplayModel<TModel>
protected async Task<ActionResult<Result>> CopyModelWithDescendants<TEdit>(string pageId, string newParentId)
{
return await PutOperation<TEdit>(async () => await Store.CopyWithDescendants(pageId, newParentId));
}
@@ -103,7 +103,7 @@ public abstract class ZeroApiTreeEntityStoreController<TModel, TStore> : ZeroApi
}
async Task<ActionResult<Result>> PutOperation<TEdit>(Func<Task<Result<TModel>>> action) where TEdit : DisplayModel<TModel>
async Task<ActionResult<Result>> PutOperation<TEdit>(Func<Task<Result<TModel>>> action)
{
Result<TModel> result = await action();
@@ -9,10 +9,14 @@ public class zero_Api_Media_Listing : ZeroIndex<zero.Media.Media>
Map = items => items.Select(item => new
{
Name = item.Name,
ParentId = item.ParentId
ParentId = item.ParentId,
CreatedDate = item.CreatedDate,
Type = item.Type
});
Index(x => x.Name, FieldIndexing.Search);
Index(x => x.ParentId, FieldIndexing.Exact);
Index(x => x.CreatedDate, FieldIndexing.Exact);
Index(x => x.Type, FieldIndexing.Exact);
}
}
+9 -3
View File
@@ -1,8 +1,14 @@
namespace zero.Api.Endpoints.Media;
public class MediaBasic : BasicModel<zero.Media.Media>
public class MediaBasic : ZeroIdEntity
{
public bool IsPreferred { get; set; }
public string ParentId { get; set; }
public string Code { get; set; }
public string Name { get; set; }
public bool IsFolder { get; set; }
public string Image { get; set; }
public int Children { get; set; }
}
@@ -14,8 +14,12 @@ public class MediaMapperProfile : ZeroMapperProfile
protected virtual void Map(zero.Media.Media source, MediaBasic target, IZeroMapperContext ctx)
{
this.MapBasicData(source, target);
target.Id = source.Id;
target.Name = source.Name;
target.ParentId = source.ParentId;
target.IsFolder = source.Type == MediaType.Folder;
target.Image = null;
target.Children = 0;
}
protected virtual void Map(zero.Media.Media source, MediaEdit target, IZeroMapperContext ctx)
+9 -17
View File
@@ -17,28 +17,20 @@ public class MediaController : ZeroApiTreeEntityStoreController<zero.Media.Media
[HttpGet("empty")]
[ZeroAuthorize(MediaPermissions.Create)]
public virtual async Task<ActionResult<MediaEdit>> Empty(MediaType type, string flavor = null) => await EmptyModel<MediaEdit>(flavor, x => x.Type = type);
public virtual async Task<ActionResult<zero.Media.Media>> Empty(MediaType type, string flavor = null) => await EmptyModel(flavor, x => x.Type = type);
[HttpGet("{id}")]
[ZeroAuthorize(MediaPermissions.Read)]
public virtual async Task<ActionResult<MediaEdit>> Get(string id, string changeVector = null)
public virtual async Task<ActionResult<zero.Media.Media>> Get(string id, string changeVector = null) => await GetModel(id, changeVector);
[HttpGet("{parentId}/children")]
[ZeroAuthorize(MediaPermissions.Read)]
public virtual async Task<ActionResult<Paged>> GetChildren(string parentId, [FromQuery] ListQuery<zero.Media.Media> query)
{
zero.Media.Media model = await Store.Load(id, changeVector);
if (model == null)
{
return NotFound();
}
HttpContext.Items[ApiConstants.ChangeToken] = Store.GetChangeToken(model);
if (model.Type == MediaType.Folder)
{
return Mapper.Map<zero.Media.Media, MediaFolderEdit>(model);
}
return Mapper.Map<zero.Media.Media, MediaFileEdit>(model);
query.OrderQuery = q => q.OrderBy(x => x.Type).OrderByDescending(x => x.CreatedDate);
return await GetChildModelsByIndex<MediaBasic, zero_Api_Media_Listing>(parentId == "root" ? null : parentId, query);
}
+2 -1
View File
@@ -9,7 +9,7 @@ import registerComponents from '../components/register';
import registerFormComponents from '../forms/register';
import registerEditorComponents from '../editor/register';
import { getRouterConfig, appendRouterGuards } from './router/routerConfig';
import { countryPlugin, applicationPlugin, settingsPlugin, languagePlugin } from '../modules';
import { countryPlugin, applicationPlugin, settingsPlugin, languagePlugin, mediaPlugin } from '../modules';
import { ZeroSchema } from 'zero/schemas';
import { ZeroSchemaProp } from './zero';
import * as zeroOptions from '../options';
@@ -86,6 +86,7 @@ export class ZeroRuntime implements Zero
applicationPlugin.install(pluginOptions);
settingsPlugin.install(pluginOptions);
languagePlugin.install(pluginOptions);
mediaPlugin.install(pluginOptions);
}
+2 -1
View File
@@ -2,4 +2,5 @@
export * from './countries';
export * from './applications';
export * from './settings';
export * from './languages';
export * from './languages';
export * from './media';
@@ -0,0 +1,17 @@
import { MediaType } from 'zero/media';
import { get, post, put, del, ApiRequestConfig, ApiRequestQuery } from '../../services/request';
export default {
getEmpty: (type: MediaType, flavor?: string, config?: ApiRequestConfig) => get("media/empty", { ...config, params: { type, flavor } }),
getById: (id: string, changeVector?: string, config?: ApiRequestConfig) => get('media/' + id, { ...config, params: { changeVector } }),
getChildren: (parentId: string, query: ApiRequestQuery, config?: ApiRequestConfig) => get(`media/${parentId}/children`, { ...config, params: { ...query } }),
//create: (model: any, config?: ApiRequestConfig) => post('countries', model, config),
//update: (model: any, config?: ApiRequestConfig) => put('countries/' + model.id, model, config),
//delete: (id: string, config?: ApiRequestConfig) => del('countries/' + id, config),
};
@@ -0,0 +1,12 @@
<template>
<div>
media {{id}}
</div>
</template>
<script>
export default {
props: ['id']
}
</script>
@@ -0,0 +1,10 @@
import api from './api';
import editor from './schemas/editor';
//import list from './schemas/list';
import plugin from './plugin';
export {
api as mediaApi,
editor as mediaSchema,
plugin as mediaPlugin
};
@@ -0,0 +1,89 @@
<template>
<div class="media-content">
<ui-header-bar :back-button="!!parentId" title="Media">
<!--<template v-slot:title>
<h2 class="ui-header-bar-title">
<span v-for="(item, index) in hierarchy" :key="item.id" class="media-items-hierarchy-item">
<router-link :to="{ name: 'media', params: { id: item.id } }" v-localize="item.name"></router-link>
<ui-icon v-if="index < hierarchy.length - 1" symbol="fth-chevron-right" />
</span>
</h2>
</template>-->
</ui-header-bar>
<div class="ui-view-box">
<div class="media-items">
<div class="media-items-grid">
<media-item v-for="item in items" :key="item.id" :value="item" />
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import api from './api';
import MediaItem from './partials/overview-item.vue';
export default defineComponent({
props: ['parentId'],
components: { MediaItem },
data: () => ({
items: [],
paging: {}
}),
watch: {
'$route': async function (val)
{
await this.setup();
}
},
async mounted()
{
await this.setup();
},
methods: {
async setup()
{
const response = await api.getChildren(this.$route.params.parentId || 'root', {});
this.items = response.data;
this.paging = response.paging;
}
}
});
</script>
<style lang="scss">
.media
{
width: 100%;
height: 100vh;
overflow-y: auto;
}
.media-content
{
height: 100vh;
overflow-y: auto;
}
.media-items-grid
{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
align-items: stretch;
gap: var(--padding);
}
</style>
@@ -0,0 +1,156 @@
<template>
<router-link :to="link" class="media-item">
<div class="media-item-preview" :class="{'media-pattern': value.image, 'is-covered': covered }">
<span class="media-item-check"><ui-icon symbol="fth-check" :size="14" /></span>
<img class="media-item-image" v-if="value.image" :src="value.image" />
<span class="media-item-icon" v-if="!value.image"><ui-icon :symbol="(value.isFolder ? 'fth-folder' : 'fth-file')" :size="36" :stroke-width="1.5" /></span>
</div>
<p class="media-item-text">
<span :title="value.name">{{value.name}} <!--<ui-icon symbol="fth-cloud" v-if="value.isShared" :size="15" class="media-item-shared" />--></span>
<!--<span class="-minor" v-if="!value.isFolder"><br><span v-filesize="value.size"></span></span>-->
<span class="-minor" v-if="value.isFolder"><br><span v-localize="{ key: value.children === 1 ? '@media.child_count_1' : '@media.child_count_x', tokens: { count: value.children }}"></span></span>
</p>
</router-link>
</template>
<script>
export default {
name: 'mediaOverviewItem',
props: {
value: {
type: Object,
default: () => { }
},
selected: {
type: Boolean,
default: false
}
},
computed: {
link()
{
if (this.value.isFolder)
{
return { name: 'media', params: { parentId: this.value.id } };
}
return { name: 'media-edit', params: { id: this.value.id } };
},
covered()
{
return false; //this.value.aspectRatio < 1.2 && this.value.aspectRatio > 0.8;
}
}
};
</script>
<style lang="scss">
.media-item
{
width: 100%;
min-height: 200px;
display: grid;
grid-template-rows: auto 1fr;
gap: 10px;
align-items: center;
line-height: 1.4;
color: var(--color-text);
font-size: var(--font-size);
border-radius: var(--radius);
}
.media-items.is-selecting .media-item
{
opacity: .5;
}
.media-items.is-selecting .media-item.is-selected
{
opacity: 1;
}
.media-item-preview
{
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
height: 200px;
width: 100%;
background: var(--color-box);
border-radius: var(--radius);
overflow: visible !important;
position: relative;
text-align: center;
box-shadow: var(--shadow-short);
}
.media-item.is-selected .media-item-preview
{
//border: 3px solid var(--color-primary);
}
.media-item-image
{
width: 100%;
height: 100%;
object-fit: contain;
position: relative;
border-radius: var(--radius);
z-index: 1;
}
.media-item-preview.is-covered .media-item-image
{
object-fit: cover;
}
.media-item-check
{
display: none;
justify-content: center;
align-items: center;
width: 30px;
height: 30px;
border-radius: 20px;
border: 5px solid var(--color-bg);
position: absolute;
z-index: 2;
left: -13px;
top: -13px;
background: var(--color-primary);
color: var(--color-primary-text);
box-shadow: 1px 1px 0 1px var(--color-shadow);
font-size: 11px;
.is-selected &
{
display: inline-flex;
}
}
.media-item-text
{
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
padding-right: 16px;
font-weight: bold;
.-minor
{
font-weight: 400;
color: var(--color-text-dim);
}
}
.media-item-shared
{
margin-left: .4em;
color: var(--color-synchronized);
position: relative;
top: 2px;
}
</style>
@@ -0,0 +1,17 @@
import { ZeroPlugin, ZeroPluginOptions } from '../../core';
import { defineAsyncComponent } from 'vue';
export default {
name: "zero.countries",
install(app: ZeroPluginOptions)
{
//app.vue.component('ui-countrypicker', defineAsyncComponent(() => import('./ui-countrypicker.vue')));
app.route({ name: 'media', path: '/media/:parentId?', component: () => import('./overview.vue'), props: true });
app.route({ name: 'media-edit', path: '/media/edit/:id?', component: () => import('./detail.vue'), props: true });
//app.schema('countries', () => import('./schemas/list'));
//app.schema('countries:edit', () => import('./schemas/editor'));
}
} as ZeroPlugin;
@@ -0,0 +1,12 @@
import Editor from '../../../editor/editor';
const editor = new Editor('countries:edit', '@country.fields.');
editor.blueprintAlias = 'country';
editor.field('name', { label: '@ui.name' }).text(120).required();
//editor.field('alias', { label: '@ui.alias' }).text().required();
editor.field('code').text(2).required();
editor.field('isPreferred').toggle();
export default editor;
@@ -0,0 +1,19 @@
import List from '../../../schemas/list/list';
import api from '../api';
const list = new List('countries');
const prefix = '@country.fields.';
list.templateLabel = x => prefix + x;
list.link = 'countries-edit';
list.onFetch(filter => api.getByQuery(filter));
list.column('flag', { width: 62, canSort: false, hideLabel: true }).custom((value, model) => `<i class="ui-icon" data-symbol="flag-${model.code.toLowerCase()}"></i>`, true, 'flag');
list.column('name').name();
list.column('code').custom(val => val.toUpperCase());
list.column('isPreferred').boolean();
list.column('isActive').active();
export default list;
@@ -0,0 +1,61 @@
<template>
<div class="ui-countrypicker" :class="{'is-disabled': disabled }">
country picker
<!--<ui-pick :config="pickerConfig" :value="value" @input="onChange" :disabled="disabled" />-->
</div>
</template>
<script>
import api from './api';
export default {
name: 'uiCountrypicker',
props: {
modelValue: {
type: [String, Array],
default: null
},
limit: {
type: Number,
default: 1
},
disabled: {
type: Boolean,
default: false
},
options: {
type: Object,
default: () => {}
}
},
data: () => ({
previews: [],
pickerConfig: {}
}),
//created()
//{
// this.pickerConfig = _extend({
// scope: 'country',
// items: CountriesApi.getForPicker,
// previews: CountriesApi.getPreviews,
// limit: this.limit,
// multiple: this.limit > 1,
// preview: {
// }
// }, this.options);
//},
//methods: {
// onChange(value)
// {
// this.$emit('input', value);
// }
//}
}
</script>
+5
View File
@@ -0,0 +1,5 @@
declare module 'zero/media'
{
export type MediaType = 'folder' | 'file' | 'image';
}
@@ -1,58 +0,0 @@
<template>
<div class="ui-languagepicker" :class="{'is-disabled': disabled }">
<ui-pick :config="pickerConfig" :value="value" @input="onChange" :disabled="disabled" />
</div>
</template>
<script>
import LanguagesApi from 'zero/api/languages.js'
import { extend as _extend } from 'underscore'
export default {
name: 'uiLanguagepicker',
props: {
value: {
type: [String, Array],
default: null
},
limit: {
type: Number,
default: 1
},
disabled: {
type: Boolean,
default: false
},
options: {
type: Object,
default: () => {}
}
},
data: () => ({
previews: [],
pickerConfig: {}
}),
created()
{
this.pickerConfig = _extend({
scope: 'language',
items: LanguagesApi.getForPicker,
previews: LanguagesApi.getPreviews,
limit: this.limit,
multiple: this.limit > 1
}, this.options);
},
methods: {
onChange(value)
{
this.$emit('input', value);
}
}
}
</script>
@@ -1,769 +0,0 @@
<template>
<div class="ui-pick" :class="{'is-disabled': disabled, 'is-combined': configuration.preview.combined }">
<!-- previews -->
<div class="ui-pick-previews" v-if="configuration.preview.enabled && previews.length > 0 && !configuration.preview.combined" v-sortable="{ onUpdate: onSortingUpdated, enabled: configuration.sortable }">
<div v-for="preview in previews" :key="preview[configuration.keys.id]" class="ui-pick-preview">
<ui-select-button :icon="getPreviewIcon(preview)" :icon-as-image="configuration.preview.iconAsImage" :label="preview[configuration.keys.name]" :description="getPreviewDescription(preview)" :disabled="disabled" @click="pick(preview[configuration.keys.id])" :tokens="preview" />
<ui-icon-button v-if="!disabled && configuration.preview.delete" @click="remove(preview[configuration.keys.id])" icon="fth-x" title="@ui.close" :size="14" />
</div>
</div>
<!-- combined preview -->
<div class="ui-pick-previews" v-if="configuration.preview.enabled && configuration.preview.combined">
<div class="ui-pick-preview">
<ui-select-button icon="fth-check-circle" :label="combinedTitle" :disabled="disabled" @click="pick()" />
</div>
</div>
<!-- add button -->
<div class="ui-pick-add" v-if="canAdd && configuration.addButton.enabled && !configuration.preview.combined">
<slot name="add">
<ui-select-button icon="fth-plus" :label="configuration.addButton.label" @click="pick()" :disabled="disabled" />
</slot>
</div>
<!-- overlay -->
<ui-dropdown ref="overlay" class="ui-pick-overlay" @opened="overlayOpened">
<!-- headline -->
<div class="ui-pick-overlay-head">
<div class="ui-pick-overlay-head-title">
<span class="-name" v-localize="title"></span>
<span class="-max" v-if="configuration.limit > 1" v-localize="{ key: '@ui.pick.max', tokens: { count: configuration.limit }}"></span>
</div>
<ui-icon-button @click="hide" icon="fth-x" title="@ui.close" />
</div>
<!-- search -->
<ui-search v-if="configuration.search.enabled" ref="search" class="ui-pick-overlay-search" :value="searchValue" @input="onSearch" @submit="onSearchSubmit">
<template v-slot:button="search" v-if="configuration.autocomplete">
<button type="button" class="ui-searchinput-button" v-localize:title="'@ui.search.button'" @click="search.onSubmit"><ui-icon symbol="fth-check"></ui-icon></button>
</template>
</ui-search>
<!-- items -->
<div class="ui-pick-overlay-items">
<button v-for="item in items" :key="item[configuration.keys.id]" type="button" class="ui-pick-overlay-item" @click="select(item)" :class="{'is-selected': isSelected(item) }">
<ui-icon v-if="item[configuration.keys.icon]" class="-icon" :symbol="item[configuration.keys.icon]" />
<div class="ui-pick-overlay-item-title">
<span class="-name" v-localize="item[configuration.keys.name]"></span>
<span v-if="item[configuration.keys.description] && configuration.list.description" class="-text" v-localize="item[configuration.keys.description]"></span>
</div>
</button>
</div>
<!-- loading + empty states -->
<div class="ui-pick-overlay-center" v-if="isLoading || (!configuration.autocomplete && !items.length)">
<div v-if="!isLoading && !configuration.autocomplete && !items.length" class="ui-pick-overlay-message">
<ui-icon class="ui-pick-overlay-message-icon" symbol="fth-list" :size="18" />
No items found
</div>
<ui-loading v-if="isLoading" />
</div>
</ui-dropdown>
</div>
</template>
<script>
import { extend as _extend, filter as _filter, debounce as _debounce, isArray as _isArray, clone as _clone, find as _find, map as _map } from 'underscore';
import Arrays from 'zero/helpers/arrays.js';
const defaultConfig = {
// picker items, can either be a static list or a promise
items: [],
// preview items, can either be a static list or a promise. If null, take the items list
previews: [],
// exclude Ids from the picker selection items
excludedIds: [],
// autocomplete allows entering custom texts
autocomplete: false,
// maximum selection count
limit: 10,
// multiple selection
multiple: true,
// whether the previews/results are sortable or not
sortable: true,
// close picker when an item is clicked
closeOnClick: true,
// title in dropdown
title: null,
// automatically open picker
autoOpen: false,
// picker has pagination
paging: false,
keys: {
// id key
id: 'id',
// name key
name: 'name',
// description key
description: 'text',
// icon key
icon: 'icon'
},
addButton: {
// hide the add button
enabled: true,
// text key for the add button
label: '@ui.select'
},
list: {
// output description text if available (second line)
description: false
},
preview: {
// output previews
enabled: true,
// output all selected items in one line (comma-separated)
combined: false,
// prefixed title when combine=true
combinedTitle: null,
// default icon used for previews
defaultIcon: 'fth-square',
// hides the icon in the preview
icon: true,
// hides the delete icon in preview
delete: true,
// displays an image instead of an icon in the preview
iconAsImage: false,
// output description text if available (second line)
description: true
},
search: {
// hides the search input
enabled: true,
// can force a local search for remote items (via promise)
local: true,
// sets the current model as the search input when opened
setDefaultValue: false,
// focus search input on open
focus: false,
// placeholder key in search input
placeholder: null //vm.options.autocomplete ? 'ui_search_autocomplete' : 'ui_search',
}
};
export default {
name: 'uiPick',
props: {
value: {
type: [String, Array],
default: null
},
disabled: {
type: Boolean,
default: false
},
config: {
type: Object,
default: () =>
{
return defaultConfig;
}
}
},
watch: {
config: {
deep: true,
handler(newval, oldval)
{
if (JSON.stringify(newval) !== JSON.stringify(oldval))
{
this.buildConfig();
this.loaded = false;
}
}
},
value(val)
{
this.onValueChanged(val);
}
},
computed: {
multiple()
{
return this.configuration.multiple;
},
canAdd()
{
return (!this.value && !this.multiple) || (!this.value || this.value.length < this.configuration.limit);
},
isRemote()
{
return typeof this.configuration.items === 'function';
},
title()
{
if (!!this.configuration.title) return this.configuration.title;
if (this.configuration.autocomplete) return '@ui.pick.title_autocomplete';
if (this.multiple) return '@ui.pick.title_multiple';
return '@ui.pick.title';
},
combinedTitle()
{
let html = this.configuration.preview.combinedTitle ? this.configuration.preview.combinedTitle : '';
if (this.previews.length > 0)
{
html += ': <b>' + _map(this.previews, p => p[this.configuration.keys.name]).join(', ') + '</b>';
}
return html;
}
},
data: () => ({
configuration: {},
previews: [],
allItems: [],
items: [],
selected: [],
loaded: false,
isLoading: false,
debouncedUpdate: null,
searchValue: ''
}),
created()
{
this.buildConfig();
this.debouncedUpdate = _debounce(this.loadSuggestions, 300);
this.onValueChanged(this.value);
},
mounted()
{
if (this.configuration.autoOpen)
{
this.pick();
}
},
methods: {
refresh()
{
this.buildConfig();
this.loaded = false;
},
onValueChanged(val)
{
if (this.multiple)
{
this.selected = _isArray(val) && val.length ? _clone(val) : [];
}
else
{
this.selected = val ? [val] : [];
}
this.loadPreviews();
},
buildConfig()
{
var config = JSON.parse(JSON.stringify(defaultConfig));
this.configuration = _extend(JSON.parse(JSON.stringify(config)), this.config);
this.configuration.search = _extend(config.search, this.config.search || {});
this.configuration.addButton = _extend(config.addButton, this.config.addButton || {});
this.configuration.preview = _extend(config.preview, this.config.preview || {});
this.configuration.list = _extend(config.list, this.config.list || {});
this.configuration.keys = _extend(config.keys, this.config.keys || {});
},
overlayOpened()
{
if (!this.loaded)
{
this.load();
}
if (this.configuration.search.enabled && this.configuration.search.focus)
{
this.$nextTick(() => this.$refs.search.focus());
}
},
pick()
{
this.$refs.overlay.toggle();
if (!this.loaded)
{
this.load();
}
},
hide()
{
this.$refs.overlay.hide();
},
remove(id)
{
let index = this.selected.indexOf(id);
this.selected.splice(index, 1);
this.onChange(this.multiple ? this.selected : null);
},
clear()
{
this.selected = [];
this.onChange(this.multiple ? this.selected : null);
},
// loads remote items or items from the given configuration
// this will store both all items and the reduced search results
load()
{
if (this.isLoading)
{
return;
}
this.isLoading = true;
this.allItems = [];
let onLoaded = (items) =>
{
if (this.configuration.paging && typeof items === 'object' && !Array.isArray(items))
{
// TODO we need to store metadata to allow pagination
items = items.items;
}
this.allItems = items;
this.loadSuggestions();
this.loaded = true;
this.isLoading = false;
};
if (this.isRemote)
{
this.configuration.items().then(onLoaded);
}
else
{
onLoaded(this.configuration.items);
}
},
loadSuggestions()
{
let items = [];
let search = this.searchValue;
let handleResult = (res) =>
{
if (this.configuration.paging && typeof res === 'object' && !Array.isArray(res))
{
// TODO we need to store metadata to allow pagination
res = res.items;
}
if (this.configuration.excludedIds && this.configuration.excludedIds.length)
{
res = _filter(res, (item) => this.configuration.excludedIds.indexOf(item[this.configuration.keys.id]) < 0);
}
this.items = res;
};
if (!search)
{
items = this.allItems;
}
else
{
if (!this.isRemote || this.configuration.search.local)
{
items = _filter(this.allItems, (item) => item.name.toLowerCase().indexOf(search) > -1);
}
else if (this.isRemote)
{
this.configuration.items(search).then(res => handleResult(res));
return;
}
}
handleResult(items);
},
loadPreviews()
{
let onLoaded = (items, needsFilter) =>
{
if (this.configuration.paging && typeof items === 'object' && !Array.isArray(items))
{
// TODO we need to store metadata to allow pagination
items = items.items;
}
if (needsFilter)
{
this.previews = [];
this.selected.forEach(id =>
{
let res = _find(items, item => item[this.configuration.keys.id] === id);
if (!res)
{
// TODO push error output
}
else
{
this.previews.push(_clone(res));
}
});
}
else
{
this.previews = items;
}
//console.info(JSON.parse(JSON.stringify(this.previews)));
};
if (this.configuration.autocomplete)
{
onLoaded(_map(this.selected, s =>
{
let item = { };
item[this.configuration.keys.id] = s;
item[this.configuration.keys.name] = s;
return item;
}), false);
}
else
{
let promise = (_isArray(this.configuration.previews) && this.configuration.previews.length) || typeof this.configuration.previews === 'function' ? this.configuration.previews : this.configuration.items;
let isFunc = typeof promise === 'function';
if (isFunc && this.selected.length > 0)
{
promise(this.selected).then(onLoaded);
}
else if (isFunc)
{
onLoaded([]);
}
else
{
onLoaded(promise, true);
}
}
},
onSearch(value)
{
this.searchValue = value;
if (!this.isRemote || this.configuration.search.local)
{
this.loadSuggestions();
}
else
{
this.debouncedUpdate();
}
},
onSearchSubmit()
{
let value = this.searchValue.trim();
let item = {};
item[this.configuration.keys.id] = value;
item[this.configuration.keys.name] = value;
this.select(item);
},
select(item)
{
let value = this.configuration.autocomplete ? item[this.configuration.keys.name] : item[this.configuration.keys.id];
if (this.multiple)
{
if (!this.canAdd)
{
if (this.limit > 1)
{
return;
}
this.selected = [];
}
var index = this.selected.indexOf(value);
if (index > -1)
{
this.selected.splice(index, 1);
this.onDeselected(value, index);
}
else
{
this.selected.push(value);
this.onSelected(value, item);
}
this.onChange(this.selected);
}
else
{
this.selected = [value];
this.onChange(value);
this.onSelected(value, item);
}
},
onSelected(value, item)
{
this.$emit('select', value, item);
},
onDeselected(value, index)
{
this.$emit('deselect', value, index);
},
onChange(value)
{
this.$emit('input', value);
this.$emit('change', value);
if (this.configuration.closeOnClick)
{
this.$refs.overlay.hide();
}
},
isSelected(item)
{
let value = this.configuration.autocomplete ? item[this.configuration.keys.name] : item[this.configuration.keys.id];
return this.selected.indexOf(value) > -1;
},
getPreviewIcon(preview)
{
return this.configuration.preview.icon ? (preview[this.configuration.keys.icon] || this.configuration.preview.defaultIcon) : null;
},
getPreviewDescription(preview)
{
return this.configuration.preview.description ? preview[this.configuration.keys.description] : null;
},
onSortingUpdated(ev)
{
this.selected = Arrays.move(this.selected, ev.oldIndex, ev.newIndex);
this.onChange(this.multiple ? this.selected : this.selected[0]);
}
}
}
</script>
<style lang="scss">
.ui-pick-overlay-search
{
margin: 15px 15px 5px;
.ui-input
{
min-width: 0;
}
}
.ui-pick-overlay
{
.ui-dropdown
{
min-width: 0;
width: 100%;
max-width: 380px;
}
}
.ui-pick-overlay-center
{
display: flex;
justify-content: center;
margin: 30px 0;
}
.ui-pick-overlay-head
{
padding: 15px 15px 5px;
display: flex;
justify-content: space-between;
align-items: center;
.ui-icon-button
{
background: none;
}
}
.ui-pick-overlay-head-title
{
font-size: var(--font-size-l);
font-weight: 600;
.-max
{
font-size: var(--font-size-s);
font-weight: 400;
color: var(--color-text-dim);
margin-left: .6em;
}
}
.ui-pick-overlay-message
{
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding: 0 15px;
}
.ui-pick-overlay-message-icon
{
font-size: 18px;
margin-bottom: 10px;
}
.ui-pick-overlay-items
{
margin: 15px 0;
max-height: 290px;
overflow-y: auto;
}
.ui-pick-overlay-item
{
display: grid;
grid-template-columns: auto 1fr;
padding: 10px 18px;
min-height: 48px;
width: 100%;
border-radius: var(--radius);
align-items: center;
transition: background .2s, transform .2s, opacity .2s;
position: relative;
&:hover
{
background: var(--color-tree-selected);
}
&.is-selected
{
padding-right: 32px;
.-name
{
font-weight: 600;
}
}
&.is-selected:after
{
font-family: "Feather";
content: "\e83e";
font-size: 16px;
color: var(--color-primary);
position: absolute;
right: 20px;
}
.-icon
{
font-size: var(--font-size-l);
margin-right: var(--padding-s);
}
}
.ui-pick-overlay-item-title
{
.-name
{
display: block;
}
.-text
{
display: block;
color: var(--color-text-dim);
font-size: var(--font-size-s);
margin-top: 3px;
}
}
.ui-pick-preview
{
display: flex;
justify-content: space-between;
align-items: center;
}
.ui-pick-preview + .ui-pick-preview,
.ui-pick-previews + .ui-select-button,
.ui-pick-previews + .ui-pick-add
{
margin-top: 10px;
}
.ui-pick.is-combined .ui-select-button-label
{
font-weight: 400;
}
.ui-pick-previews
{
.ui-icon-button
{
height: 24px;
width: 24px;
flex-shrink: 0;
.ui-button-icon
{
font-size: 13px;
}
}
}
</style>