Merge branch 'main' into feature/partial-view-editor
This commit is contained in:
@@ -40,7 +40,7 @@ export class UmbAppElement extends UmbLitElement {
|
||||
*/
|
||||
@property({ type: String })
|
||||
// TODO: get from server config
|
||||
private backofficePath = import.meta.env.DEV ? '' : '/umbraco';
|
||||
private backofficePath = import.meta.env.DEV ? '/' : '/umbraco';
|
||||
|
||||
private _routes: UmbRoute[] = [
|
||||
{
|
||||
|
||||
@@ -23,6 +23,7 @@ const CORE_PACKAGES = [
|
||||
import('./search/umbraco-package'),
|
||||
import('./templating/umbraco-package'),
|
||||
import('./umbraco-news/umbraco-package'),
|
||||
import('./tags/umbraco-package'),
|
||||
];
|
||||
|
||||
@defineElement('umb-backoffice')
|
||||
|
||||
@@ -10,7 +10,6 @@ import { manifest as multipleTextString } from './multiple-text-string/manifests
|
||||
import { manifest as textArea } from './textarea/manifests';
|
||||
import { manifest as slider } from './slider/manifests';
|
||||
import { manifest as toggle } from './toggle/manifests';
|
||||
import { manifests as tags } from './tags/manifests';
|
||||
import { manifest as markdownEditor } from './markdown-editor/manifests';
|
||||
import { manifest as radioButtonList } from './radio-button-list/manifests';
|
||||
import { manifest as checkboxList } from './checkbox-list/manifests';
|
||||
@@ -66,7 +65,6 @@ export const manifests: Array<ManifestPropertyEditorUI> = [
|
||||
...blockGrid,
|
||||
...collectionView,
|
||||
...tinyMCE,
|
||||
...tags,
|
||||
{
|
||||
type: 'propertyEditorUI',
|
||||
alias: 'Umb.PropertyEditorUI.Number',
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { html } from 'lit';
|
||||
import { UUITextStyles } from '@umbraco-ui/uui-css/lib';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { UmbPropertyEditorExtensionElement } from '@umbraco-cms/backoffice/extensions-registry';
|
||||
import { UmbLitElement } from '@umbraco-cms/internal/lit-element';
|
||||
|
||||
/**
|
||||
* @element umb-property-editor-ui-tags
|
||||
*/
|
||||
@customElement('umb-property-editor-ui-tags')
|
||||
export class UmbPropertyEditorUITagsElement extends UmbLitElement implements UmbPropertyEditorExtensionElement {
|
||||
|
||||
|
||||
@property()
|
||||
value = '';
|
||||
|
||||
@property({ type: Array, attribute: false })
|
||||
public config = [];
|
||||
|
||||
render() {
|
||||
return html`<div>umb-property-editor-ui-tags</div>`;
|
||||
}
|
||||
|
||||
static styles = [UUITextStyles];
|
||||
}
|
||||
|
||||
export default UmbPropertyEditorUITagsElement;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'umb-property-editor-ui-tags': UmbPropertyEditorUITagsElement;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './tags-input/tags-input.element';
|
||||
@@ -0,0 +1,414 @@
|
||||
import { css, html, nothing } from 'lit';
|
||||
import { UUITextStyles } from '@umbraco-ui/uui-css/lib';
|
||||
import { customElement, property, query, queryAll, state } from 'lit/decorators.js';
|
||||
import { FormControlMixin } from '@umbraco-ui/uui-base/lib/mixins';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { UUIInputElement, UUIInputEvent, UUITagElement } from '@umbraco-ui/uui';
|
||||
import { UmbTagRepository } from '../../repository/tag.repository';
|
||||
import { UmbLitElement } from '@umbraco-cms/internal/lit-element';
|
||||
import { TagResponseModel } from '@umbraco-cms/backoffice/backend-api';
|
||||
|
||||
@customElement('umb-tags-input')
|
||||
export class UmbTagsInputElement extends FormControlMixin(UmbLitElement) {
|
||||
@property({ type: String })
|
||||
group?: string;
|
||||
|
||||
@property({ type: String })
|
||||
culture?: string | null;
|
||||
|
||||
_items: string[] = [];
|
||||
@property({ type: Array })
|
||||
public set items(newTags: string[]) {
|
||||
const newItems = newTags.filter((x) => x !== '');
|
||||
this._items = newItems;
|
||||
super.value = this._items.join(',');
|
||||
}
|
||||
public get items(): string[] {
|
||||
return this._items;
|
||||
}
|
||||
|
||||
@state()
|
||||
private _matches: Array<TagResponseModel> = [];
|
||||
|
||||
@state()
|
||||
private _currentInput = '';
|
||||
|
||||
@query('#main-tag')
|
||||
private _mainTag!: UUITagElement;
|
||||
|
||||
@query('#tag-input')
|
||||
private _tagInput!: UUIInputElement;
|
||||
|
||||
@query('#input-width-tracker')
|
||||
private _widthTracker!: HTMLElement;
|
||||
|
||||
@queryAll('.options')
|
||||
private _optionCollection?: HTMLCollectionOf<HTMLInputElement>;
|
||||
|
||||
#repository = new UmbTagRepository(this);
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
console.log('tags-input');
|
||||
}
|
||||
|
||||
public focus() {
|
||||
this._tagInput.focus();
|
||||
}
|
||||
|
||||
protected getFormElement() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async #getExistingTags(query: string) {
|
||||
if (!this.group || this.culture === undefined || !query) return;
|
||||
const { data } = await this.#repository.queryTags(this.group, this.culture, query);
|
||||
if (!data) return;
|
||||
this._matches = data.items;
|
||||
}
|
||||
|
||||
#onKeydown(e: KeyboardEvent) {
|
||||
//Prevent tab away if there is a input.
|
||||
if (e.key === 'Tab' && (this._tagInput.value as string).trim().length && !this._matches.length) {
|
||||
e.preventDefault();
|
||||
this.#createTag();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
this.#createTag();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
this._currentInput = this._optionCollection?.item(0)?.value ?? this._currentInput;
|
||||
this._optionCollection?.item(0)?.focus();
|
||||
return;
|
||||
}
|
||||
this.#inputError(false);
|
||||
}
|
||||
|
||||
#onInput(e: UUIInputEvent) {
|
||||
this._currentInput = e.target.value as string;
|
||||
if (!this._currentInput || !this._currentInput.length) {
|
||||
this._matches = [];
|
||||
} else {
|
||||
this.#getExistingTags(this._currentInput);
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
this._mainTag.style.width = `${this._widthTracker.offsetWidth - 4}px`;
|
||||
}
|
||||
|
||||
#onBlur() {
|
||||
if (this._matches.length) return;
|
||||
else this.#createTag();
|
||||
}
|
||||
|
||||
#createTag() {
|
||||
this.#inputError(false);
|
||||
const newTag = (this._tagInput.value as string).trim();
|
||||
if (!newTag) return;
|
||||
|
||||
const tagExists = this.items.find((tag) => tag === newTag);
|
||||
if (tagExists) return this.#inputError(true);
|
||||
|
||||
this.#inputError(false);
|
||||
this.items = [...this.items, newTag];
|
||||
this._tagInput.value = '';
|
||||
this._currentInput = '';
|
||||
this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
#inputError(error: boolean) {
|
||||
if (error) {
|
||||
this._mainTag.style.border = '1px solid var(--uui-color-danger)';
|
||||
this._tagInput.style.color = 'var(--uui-color-danger)';
|
||||
return;
|
||||
}
|
||||
this._mainTag.style.border = '';
|
||||
this._tagInput.style.color = '';
|
||||
}
|
||||
|
||||
#delete(tag: string) {
|
||||
const currentItems = [...this.items];
|
||||
const index = currentItems.findIndex((x) => x === tag);
|
||||
currentItems.splice(index, 1);
|
||||
currentItems.length ? (this.items = [...currentItems]) : (this.items = []);
|
||||
this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
/** Dropdown */
|
||||
|
||||
#optionClick(index: number) {
|
||||
this._tagInput.value = this._optionCollection?.item(index)?.value ?? '';
|
||||
this.#createTag();
|
||||
this.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
#optionKeydown(e: KeyboardEvent, index: number) {
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
this._currentInput = this._optionCollection?.item(index)?.value ?? '';
|
||||
this.#createTag();
|
||||
this.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (!this._optionCollection?.item(index + 1)) return;
|
||||
this._optionCollection?.item(index + 1)?.focus();
|
||||
this._currentInput = this._optionCollection?.item(index + 1)?.value ?? '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (!this._optionCollection?.item(index - 1)) return;
|
||||
this._optionCollection?.item(index - 1)?.focus();
|
||||
this._currentInput = this._optionCollection?.item(index - 1)?.value ?? '';
|
||||
}
|
||||
|
||||
if (e.key === 'Backspace') {
|
||||
this.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/** Render */
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div id="wrapper">
|
||||
${this.#enteredTags()}
|
||||
<span id="main-tag-wrapper">
|
||||
<uui-tag id="input-width-tracker" aria-hidden="true" style="visibility:hidden;opacity:0;position:absolute;">
|
||||
${this._currentInput}
|
||||
</uui-tag>
|
||||
<uui-tag look="outline" id="main-tag" @click="${this.focus}" slot="trigger">
|
||||
<input
|
||||
id="tag-input"
|
||||
aria-label="tag input"
|
||||
placeholder="Enter tag"
|
||||
.value="${this._currentInput ?? undefined}"
|
||||
@keydown="${this.#onKeydown}"
|
||||
@input="${this.#onInput}"
|
||||
@blur="${this.#onBlur}" />
|
||||
<uui-icon id="icon-add" name="umb:add"></uui-icon>
|
||||
${this.#renderTagOptions()}
|
||||
</uui-tag>
|
||||
<span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
#enteredTags() {
|
||||
return html` ${this.items.map((tag) => {
|
||||
return html`
|
||||
<uui-tag class="tag">
|
||||
<span>${tag}</span>
|
||||
<uui-icon name="umb:wrong" @click="${() => this.#delete(tag)}"></uui-icon>
|
||||
</uui-tag>
|
||||
`;
|
||||
})}`;
|
||||
}
|
||||
|
||||
#renderTagOptions() {
|
||||
if (!this._currentInput.length || !this._matches.length) return nothing;
|
||||
const matchfilter = this._matches.filter((tag) => tag.text !== this._items.find((x) => x === tag.text));
|
||||
if (!matchfilter.length) return;
|
||||
return html`
|
||||
<div id="matchlist">
|
||||
${repeat(
|
||||
matchfilter.slice(0, 5),
|
||||
(tag: TagResponseModel) => tag.id,
|
||||
(tag: TagResponseModel, index: number) => {
|
||||
return html` <input
|
||||
class="options"
|
||||
id="tag-${tag.id}"
|
||||
type="radio"
|
||||
name="${tag.group}"
|
||||
@click="${() => this.#optionClick(index)}"
|
||||
@keydown="${(e: KeyboardEvent) => this.#optionKeydown(e, index)}"
|
||||
value="${tag.text}" />
|
||||
<label for="tag-${tag.id}"> ${tag.text} </label>`;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
UUITextStyles,
|
||||
css`
|
||||
#wrapper {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
gap: var(--uui-size-space-2);
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding: var(--uui-size-space-2);
|
||||
border: 1px solid var(--uui-color-border);
|
||||
background-color: var(--uui-input-background-color, var(--uui-color-surface));
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#main-tag-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/** Tags */
|
||||
|
||||
uui-tag {
|
||||
position: relative;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
uui-tag uui-icon {
|
||||
cursor: pointer;
|
||||
min-width: 12.8px !important;
|
||||
}
|
||||
|
||||
uui-tag span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/** Created tags */
|
||||
|
||||
.tag uui-icon {
|
||||
margin-left: var(--uui-size-space-2);
|
||||
}
|
||||
|
||||
.tag uui-icon:hover,
|
||||
.tag uui-icon:active {
|
||||
color: var(--uui-color-selected-contrast);
|
||||
}
|
||||
|
||||
/** Main tag */
|
||||
|
||||
#main-tag {
|
||||
padding: 3px;
|
||||
background-color: var(--uui-color-selected-contrast);
|
||||
min-width: 20px;
|
||||
position: relative;
|
||||
border-radius: var(--uui-size-5, 12px);
|
||||
}
|
||||
|
||||
#main-tag uui-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
#main-tag:hover uui-icon,
|
||||
#main-tag:active uui-icon {
|
||||
color: var(--uui-color-selected);
|
||||
}
|
||||
|
||||
#main-tag #tag-input:focus ~ uui-icon,
|
||||
#main-tag #tag-input:not(:placeholder-shown) ~ uui-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#main-tag:has(*:hover),
|
||||
#main-tag:has(*:active),
|
||||
#main-tag:has(*:focus) {
|
||||
border: 1px solid var(--uui-color-selected-emphasis);
|
||||
}
|
||||
|
||||
#main-tag:has(#tag-input:not(:focus)):hover {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--uui-color-selected-emphasis);
|
||||
}
|
||||
|
||||
#main-tag:not(:focus-within) #tag-input:placeholder-shown {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
#main-tag:has(#tag-input:focus),
|
||||
#main-tag:has(#tag-input:not(:placeholder-shown)) {
|
||||
min-width: 65px;
|
||||
}
|
||||
|
||||
#main-tag #tag-input {
|
||||
box-sizing: border-box;
|
||||
max-height: 25.8px;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: var(--uui-color-selected);
|
||||
line-height: reset;
|
||||
padding: 0 var(--uui-size-space-2);
|
||||
margin: 0.5px 0 -0.5px;
|
||||
border: none;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/** Dropdown matchlist */
|
||||
|
||||
#matchlist input[type='radio'] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
/* For iOS < 15 to remove gradient background */
|
||||
background-color: transparent;
|
||||
/* Not removed via appearance */
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
uui-tag:focus-within #matchlist {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#matchlist {
|
||||
display: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--uui-color-surface);
|
||||
position: absolute;
|
||||
width: 150px;
|
||||
left: 0;
|
||||
top: var(--uui-size-space-6);
|
||||
border-radius: var(--uui-border-radius);
|
||||
border: 1px solid var(--uui-color-border);
|
||||
}
|
||||
|
||||
#matchlist label {
|
||||
display: none;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
|
||||
/** Overflow */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#matchlist label:hover,
|
||||
#matchlist label:focus,
|
||||
#matchlist label:focus-within,
|
||||
#matchlist input[type='radio']:focus + label {
|
||||
display: block;
|
||||
background-color: var(--uui-color-focus);
|
||||
color: var(--uui-color-selected-contrast);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
export default UmbTagsInputElement;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'umb-tags-input': UmbTagsInputElement;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Meta, StoryObj } from '@storybook/web-components';
|
||||
import './tags-input.element';
|
||||
import type { UmbTagsInputElement } from './tags-input.element';
|
||||
|
||||
const meta: Meta<UmbTagsInputElement> = {
|
||||
title: 'Components/Inputs/Tags',
|
||||
component: 'umb-tags-input',
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<UmbTagsInputElement>;
|
||||
|
||||
export const Overview: Story = {
|
||||
args: {
|
||||
group: 'Fruits',
|
||||
items: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithTags: Story = {
|
||||
args: {
|
||||
group: 'default',
|
||||
items: ['Flour', 'Eggs', 'Butter', 'Sugar', 'Salt', 'Milk'],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithTags2: Story = {
|
||||
args: {
|
||||
group: 'default',
|
||||
items: [
|
||||
'Cranberry',
|
||||
'Kiwi',
|
||||
'Blueberries',
|
||||
'Watermelon',
|
||||
'Tomato',
|
||||
'Mango',
|
||||
'Strawberry',
|
||||
'Water Chestnut',
|
||||
'Papaya',
|
||||
'Orange Rind',
|
||||
'Olives',
|
||||
'Pear',
|
||||
'Sultana',
|
||||
'Mulberry',
|
||||
'Lychee',
|
||||
'Lemon',
|
||||
'Apple',
|
||||
'Banana',
|
||||
'Dragonfruit',
|
||||
'Blackberry',
|
||||
'Raspberry',
|
||||
],
|
||||
},
|
||||
};
|
||||
11
src/Umbraco.Web.UI.Client/src/backoffice/tags/index.ts
Normal file
11
src/Umbraco.Web.UI.Client/src/backoffice/tags/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { manifests as repositoryManifests } from './repository/manifests';
|
||||
import { manifests as propertyEditorManifests } from './property-editors/manifests';
|
||||
import { UmbEntrypointOnInit } from '@umbraco-cms/backoffice/extensions-api';
|
||||
|
||||
import './components';
|
||||
|
||||
export const manifests = [...repositoryManifests, ...propertyEditorManifests];
|
||||
|
||||
export const onInit: UmbEntrypointOnInit = (host, extensionRegistry) => {
|
||||
extensionRegistry.registerMany(manifests);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ManifestPropertyEditorModel } from '@umbraco-cms/backoffice/extensions-registry';
|
||||
|
||||
export const manifest: ManifestPropertyEditorModel = {
|
||||
type: 'propertyEditorModel',
|
||||
name: 'Tags',
|
||||
alias: 'Umbraco.Tags',
|
||||
meta: {
|
||||
config: {
|
||||
properties: [
|
||||
{
|
||||
alias: 'startNodeId',
|
||||
label: 'Start node',
|
||||
description: '',
|
||||
propertyEditorUI: 'Umb.PropertyEditorUI.Tags',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { manifests as tagsUI } from './tags/manifests';
|
||||
import type { ManifestTypes } from '@umbraco-cms/backoffice/extensions-registry';
|
||||
|
||||
export const manifests: Array<ManifestTypes> = [...tagsUI];
|
||||
@@ -0,0 +1,68 @@
|
||||
import { html } from 'lit';
|
||||
import { UUITextStyles } from '@umbraco-ui/uui-css/lib';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { UmbTagsInputElement } from '../../components/tags-input/tags-input.element';
|
||||
import { UMB_WORKSPACE_PROPERTY_CONTEXT_TOKEN } from '../../../core/components/workspace-property/workspace-property.context';
|
||||
import { UmbPropertyEditorExtensionElement } from '@umbraco-cms/backoffice/extensions-registry';
|
||||
import { UmbLitElement } from '@umbraco-cms/internal/lit-element';
|
||||
import { DataTypePropertyPresentationModel } from '@umbraco-cms/backoffice/backend-api';
|
||||
|
||||
/**
|
||||
* @element umb-property-editor-ui-tags
|
||||
*/
|
||||
@customElement('umb-property-editor-ui-tags')
|
||||
export class UmbPropertyEditorUITagsElement extends UmbLitElement implements UmbPropertyEditorExtensionElement {
|
||||
@property()
|
||||
value: string[] = [];
|
||||
|
||||
@state()
|
||||
private _group?: string;
|
||||
|
||||
@state()
|
||||
private _culture?: string | null;
|
||||
//TODO: Use type from VariantID
|
||||
|
||||
@property({ type: Array, attribute: false })
|
||||
public set config(config: Array<DataTypePropertyPresentationModel>) {
|
||||
const group = config.find((x) => x.alias === 'group');
|
||||
if (group) this._group = group.value as string;
|
||||
|
||||
const items = config.find((x) => x.alias === 'items');
|
||||
if (items) this.value = items.value as Array<string>;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.consumeContext(UMB_WORKSPACE_PROPERTY_CONTEXT_TOKEN, (context) => {
|
||||
this.observe(context.variantId, (id) => {
|
||||
if (id && id.culture !== undefined) {
|
||||
this._culture = id.culture;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private _onChange(event: CustomEvent) {
|
||||
this.value = ((event.target as UmbTagsInputElement).value as string).split(',');
|
||||
this.dispatchEvent(new CustomEvent('property-value-change'));
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<umb-tags-input
|
||||
group="${ifDefined(this._group)}"
|
||||
.culture=${this._culture}
|
||||
.items=${this.value}
|
||||
@change=${this._onChange}></umb-tags-input>`;
|
||||
}
|
||||
|
||||
static styles = [UUITextStyles];
|
||||
}
|
||||
|
||||
export default UmbPropertyEditorUITagsElement;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'umb-property-editor-ui-tags': UmbPropertyEditorUITagsElement;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { UmbTagRepository } from './tag.repository';
|
||||
import { UmbTagStore } from './tag.store';
|
||||
import type { ManifestStore, ManifestRepository } from '@umbraco-cms/backoffice/extensions-registry';
|
||||
|
||||
export const TAG_REPOSITORY_ALIAS = 'Umb.Repository.Tags';
|
||||
|
||||
const repository: ManifestRepository = {
|
||||
type: 'repository',
|
||||
alias: TAG_REPOSITORY_ALIAS,
|
||||
name: 'Tags Repository',
|
||||
class: UmbTagRepository,
|
||||
};
|
||||
|
||||
export const TAG_STORE_ALIAS = 'Umb.Store.Tags';
|
||||
|
||||
const store: ManifestStore = {
|
||||
type: 'store',
|
||||
alias: TAG_STORE_ALIAS,
|
||||
name: 'Tags Store',
|
||||
class: UmbTagStore,
|
||||
};
|
||||
|
||||
export const manifests = [repository, store];
|
||||
@@ -0,0 +1,44 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { TagResource } from '@umbraco-cms/backoffice/backend-api';
|
||||
import { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller';
|
||||
import { tryExecuteAndNotify } from '@umbraco-cms/backoffice/resources';
|
||||
|
||||
/**
|
||||
* A data source for the Tag that fetches data from the server
|
||||
* @export
|
||||
* @class UmbTagServerDataSource
|
||||
* @implements {RepositoryDetailDataSource}
|
||||
*/
|
||||
export class UmbTagServerDataSource {
|
||||
#host: UmbControllerHostElement;
|
||||
|
||||
/**
|
||||
* Creates an instance of UmbTagServerDataSource.
|
||||
* @param {UmbControllerHostElement} host
|
||||
* @memberof UmbTagServerDataSource
|
||||
*/
|
||||
constructor(host: UmbControllerHostElement) {
|
||||
this.#host = host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of tags on the server
|
||||
* @return {*}
|
||||
* @memberof UmbTagServerDataSource
|
||||
*/
|
||||
async getCollection({
|
||||
query,
|
||||
skip,
|
||||
take,
|
||||
tagGroup,
|
||||
culture,
|
||||
}: {
|
||||
query: string;
|
||||
skip: number;
|
||||
take: number;
|
||||
tagGroup?: string;
|
||||
culture?: string;
|
||||
}) {
|
||||
return tryExecuteAndNotify(this.#host, TagResource.getTag({ query, skip, take, tagGroup, culture }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { UmbTagServerDataSource } from './sources/tag.server.data';
|
||||
import { UmbTagStore, UMB_TAG_STORE_CONTEXT_TOKEN } from './tag.store';
|
||||
import { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller';
|
||||
import { UmbContextConsumerController } from '@umbraco-cms/backoffice/context-api';
|
||||
|
||||
export class UmbTagRepository {
|
||||
#init!: Promise<unknown>;
|
||||
|
||||
#host: UmbControllerHostElement;
|
||||
|
||||
#dataSource: UmbTagServerDataSource;
|
||||
#tagStore?: UmbTagStore;
|
||||
|
||||
constructor(host: UmbControllerHostElement) {
|
||||
this.#host = host;
|
||||
|
||||
this.#dataSource = new UmbTagServerDataSource(this.#host);
|
||||
|
||||
this.#init = Promise.all([
|
||||
new UmbContextConsumerController(this.#host, UMB_TAG_STORE_CONTEXT_TOKEN, (instance) => {
|
||||
this.#tagStore = instance;
|
||||
}).asPromise(),
|
||||
]);
|
||||
}
|
||||
|
||||
async requestTags(
|
||||
tagGroupName: string,
|
||||
culture: string | null,
|
||||
{ skip, take, query } = { skip: 0, take: 1000, query: '' }
|
||||
) {
|
||||
await this.#init;
|
||||
|
||||
const requestCulture = culture || '';
|
||||
|
||||
const { data, error } = await this.#dataSource.getCollection({
|
||||
skip,
|
||||
take,
|
||||
tagGroup: tagGroupName,
|
||||
culture: requestCulture,
|
||||
query,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
// TODO: allow to append an array of items to the store
|
||||
// TODO: append culture? "Invariant" if null.
|
||||
data.items.forEach((x) => this.#tagStore?.append(x));
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
error,
|
||||
asObservable: () => this.#tagStore!.byQuery(tagGroupName, requestCulture, query),
|
||||
};
|
||||
}
|
||||
|
||||
async queryTags(
|
||||
tagGroupName: string,
|
||||
culture: string | null,
|
||||
query: string,
|
||||
{ skip, take } = { skip: 0, take: 1000 }
|
||||
) {
|
||||
return this.requestTags(tagGroupName, culture, { skip, take, query });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { TagResponseModel } from '@umbraco-cms/backoffice/backend-api';
|
||||
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
|
||||
import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api';
|
||||
import { UmbStoreBase } from '@umbraco-cms/backoffice/store';
|
||||
import { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller';
|
||||
|
||||
export const UMB_TAG_STORE_CONTEXT_TOKEN = new UmbContextToken<UmbTagStore>('UmbTagStore');
|
||||
/**
|
||||
* @export
|
||||
* @class UmbTagStore
|
||||
* @extends {UmbStoreBase}
|
||||
* @description - Data Store for Template Details
|
||||
*/
|
||||
export class UmbTagStore extends UmbStoreBase {
|
||||
public readonly data = this._data.asObservable();
|
||||
|
||||
/**
|
||||
* Creates an instance of UmbTagStore.
|
||||
* @param {UmbControllerHostElement} host
|
||||
* @memberof UmbTagStore
|
||||
*/
|
||||
constructor(host: UmbControllerHostElement) {
|
||||
super(host, UMB_TAG_STORE_CONTEXT_TOKEN.toString(), new UmbArrayState<TagResponseModel>([], (x) => x.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a tag to the store
|
||||
* @param {TagResponseModel} TAG
|
||||
* @memberof UmbTagStore
|
||||
*/
|
||||
append(tag: TagResponseModel) {
|
||||
this._data.append([tag]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a tag to the store
|
||||
* @param {id} TagResponseModel id.
|
||||
* @memberof UmbTagStore
|
||||
*/
|
||||
byId(id: TagResponseModel['id']) {
|
||||
return this._data.getObservablePart((x) => x.find((y) => y.id === id));
|
||||
}
|
||||
|
||||
items(group: TagResponseModel['group'], culture: string) {
|
||||
return this._data.getObservablePart((items) =>
|
||||
items.filter((item) => item.group === group && item.culture === culture)
|
||||
);
|
||||
}
|
||||
|
||||
// TODO
|
||||
// There isnt really any way to exclude certain tags when searching for suggestions.
|
||||
// This is important for the skip/take in the endpoint. We do not want to get the tags from database that we already have picked.
|
||||
// Forexample: we have 10 different tags that includes "berry" (and searched for "berry") and we have a skip of 0 and take of 5.
|
||||
// If we already has picked lets say 4 of them, the list will only show 1 more, even though there is more remaining in the database.
|
||||
|
||||
byQuery(group: TagResponseModel['group'], culture: string, query: string) {
|
||||
return this._data.getObservablePart((items) =>
|
||||
items.filter(
|
||||
(item) =>
|
||||
item.group === group &&
|
||||
item.culture === culture &&
|
||||
item.query?.toLocaleLowerCase().includes(query.toLocaleLowerCase())
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes tag in the store with the given uniques
|
||||
* @param {string[]} uniques
|
||||
* @memberof UmbTagStore
|
||||
*/
|
||||
remove(uniques: Array<TagResponseModel['id']>) {
|
||||
this._data.remove(uniques);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const name = 'Umbraco.Core.UserManagement';
|
||||
export const version = '0.0.1';
|
||||
export const extensions = [
|
||||
{
|
||||
name: 'Tags Management Entry Point',
|
||||
alias: 'Umb.EntryPoint.TagsManagement',
|
||||
type: 'entryPoint',
|
||||
loader: () => import('./index'),
|
||||
},
|
||||
];
|
||||
@@ -30,6 +30,7 @@ import { handlers as packageHandlers } from './domains/package.handlers';
|
||||
import { handlers as rteEmbedHandlers } from './domains/rte-embed.handlers';
|
||||
import { handlers as stylesheetHandlers } from './domains/stylesheet.handlers';
|
||||
import { handlers as partialViewsHandlers } from './domains/partial-views.handlers';
|
||||
import { handlers as tagHandlers } from './domains/tag-handlers';
|
||||
|
||||
const handlers = [
|
||||
serverHandlers.serverVersionHandler,
|
||||
@@ -63,6 +64,7 @@ const handlers = [
|
||||
...rteEmbedHandlers,
|
||||
...stylesheetHandlers,
|
||||
...partialViewsHandlers,
|
||||
...tagHandlers,
|
||||
];
|
||||
|
||||
switch (import.meta.env.VITE_UMBRACO_INSTALL_STATUS) {
|
||||
|
||||
@@ -378,7 +378,16 @@ export const data: Array<DataTypeResponseModel | FolderTreeItemResponseModel> =
|
||||
parentId: null,
|
||||
propertyEditorAlias: 'Umbraco.Tags',
|
||||
propertyEditorUiAlias: 'Umb.PropertyEditorUI.Tags',
|
||||
values: [],
|
||||
values: [
|
||||
{
|
||||
alias: 'group',
|
||||
value: 'Fruits',
|
||||
},
|
||||
{
|
||||
alias: 'items',
|
||||
value: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
$type: '',
|
||||
|
||||
195
src/Umbraco.Web.UI.Client/src/core/mocks/domains/tag-handlers.ts
Normal file
195
src/Umbraco.Web.UI.Client/src/core/mocks/domains/tag-handlers.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { rest } from 'msw';
|
||||
import { umbracoPath } from '@umbraco-cms/backoffice/utils';
|
||||
import { PagedTagResponseModel, TagResponseModel } from '@umbraco-cms/backoffice/backend-api';
|
||||
|
||||
export const handlers = [
|
||||
rest.get(umbracoPath('/tag'), (_req, res, ctx) => {
|
||||
// didnt add culture logic here
|
||||
|
||||
const query = _req.url.searchParams.get('query');
|
||||
if (!query || !query.length) return;
|
||||
|
||||
const tagGroup = _req.url.searchParams.get('tagGroup') ?? 'default';
|
||||
const skip = parseInt(_req.url.searchParams.get('skip') ?? '0', 10);
|
||||
const take = parseInt(_req.url.searchParams.get('take') ?? '5', 10);
|
||||
|
||||
const TagsByGroup = TagData.filter((tag) => tag.group?.toLocaleLowerCase() === tagGroup.toLocaleLowerCase());
|
||||
const TagsMatch = TagsByGroup.filter((tag) => tag.text?.toLocaleLowerCase().includes(query.toLocaleLowerCase()));
|
||||
|
||||
const Tags = TagsMatch.slice(skip, skip + take);
|
||||
|
||||
const PagedData: PagedTagResponseModel = {
|
||||
total: Tags.length,
|
||||
items: Tags,
|
||||
};
|
||||
|
||||
return res(ctx.status(200), ctx.json<PagedTagResponseModel>(PagedData));
|
||||
}),
|
||||
];
|
||||
|
||||
// Mock Data
|
||||
|
||||
const TagData: TagResponseModel[] = [
|
||||
{
|
||||
id: '1',
|
||||
text: 'Cranberry',
|
||||
group: 'Fruits',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
text: 'Kiwi',
|
||||
group: 'Fruits',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
text: 'Blueberries',
|
||||
group: 'Fruits',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
text: 'Watermelon',
|
||||
group: 'Fruits',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
text: 'Tomato',
|
||||
group: 'Fruits',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
text: 'Mango',
|
||||
group: 'Fruits',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
text: 'Strawberry',
|
||||
group: 'Fruits',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
text: 'Water Chestnut',
|
||||
group: 'Fruits',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
text: 'Papaya',
|
||||
group: 'Fruits',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
text: 'Orange Rind',
|
||||
group: 'Fruits',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '11',
|
||||
text: 'Olives',
|
||||
group: 'Fruits',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '12',
|
||||
text: 'Pear',
|
||||
group: 'Fruits',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '13',
|
||||
text: 'Sultana',
|
||||
group: 'Fruits',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '14',
|
||||
text: 'Mulberry',
|
||||
group: 'Fruits',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '15',
|
||||
text: 'Lychee',
|
||||
group: 'Fruits',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '16',
|
||||
text: 'Lemon',
|
||||
group: 'Fruits',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '17',
|
||||
text: 'Apple',
|
||||
group: 'Fruits',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '18',
|
||||
text: 'Banana',
|
||||
group: 'Fruits',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '19',
|
||||
text: 'Dragonfruit',
|
||||
group: 'Fruits',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '20',
|
||||
text: 'Blackberry',
|
||||
group: 'Fruits',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '21',
|
||||
text: 'Raspberry',
|
||||
group: 'Fruits',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '22',
|
||||
text: 'Flour',
|
||||
group: 'Cake Ingredients',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '23',
|
||||
text: 'Eggs',
|
||||
group: 'Cake Ingredients',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '24',
|
||||
text: 'Butter',
|
||||
group: 'Cake Ingredients',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '25',
|
||||
text: 'Sugar',
|
||||
group: 'Cake Ingredients',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '26',
|
||||
text: 'Salt',
|
||||
group: 'Cake Ingredients',
|
||||
nodeCount: 1,
|
||||
},
|
||||
{
|
||||
id: '26',
|
||||
text: 'Milk',
|
||||
group: 'Cake Ingredients',
|
||||
nodeCount: 1,
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user