Merge branch 'main' into feature/partial-view-editor

This commit is contained in:
Julia Gru
2023-05-10 10:39:26 +02:00
28 changed files with 1068 additions and 38 deletions

View File

@@ -27,10 +27,15 @@ The development environment is the default environment and is used when running
### Run against a local Umbraco instance
> **Note**
> Make sure you have followed the [Authentication guide](../docs/authentication.md) before continuing.
If you have a local Umbraco instance running, you can use the development environment to run against it by overriding the API URL and bypassing the mock-service-worker in the frontend client.
Create a `.env.local` file and set the following variables:
```bash
VITE_UMBRACO_API_URL=http://localhost:5000 # This will be the URL to your Umbraco instance
VITE_UMBRACO_API_URL=https://localhost:44339 # This will be the URL to your Umbraco instance
VITE_UMBRACO_USE_MSW=off # Indicate that you want all API calls to bypass MSW (mock-service-worker)
```

View File

@@ -0,0 +1,66 @@
# Authentication
## What is this?
You can now authorize against the Management API using OpenID Connect. Most endpoints will soon require a token, albeit they are open for now.
## How does it work?
You need to authorize against the Management API using OpenID Connect if you want to access protected endpoints running on a real Umbraco instance. This will give you a token that you can use to access the API. The token is stored in local storage and will be used for all subsequent requests.
If you are running the backoffice locally, you can use the `VITE_UMBRACO_USE_MSW` environment variable to bypass the OpenID Connect flow and use mocked responses instead by setting it to `on` in the `.env.local` file.
## How to use
There are two ways to use this:
### Running directly in the Umbraco-CMS repository
1. Checkout the `v13/dev` branch of [Umbraco-CMS](https://github.com/umbraco/Umbraco-cms/tree/v13/dev)
2. Run `git submodule update --init` to initialize and pull down the backoffice repository
1. If you are using a Git GUI client, you might need to do this manually
3. Go to src/Umbraco.Web.UI.New or switch default startup project to "Umbraco.Web.UI.New"
4. Start the backend server: `dotnet run` or run the project from your IDE
5. Access https://localhost:44339/umbraco and complete the installation of Umbraco
6. You should see the log in screen after installation
7. Log in using the credentials you provided during installation
### Running with Vite
1. Perform steps 1 to 5 from before
2. Open this file in an editor: `src/Umbraco.Web.UI.New/appsettings.Development.json`
3. Add this to the Umbraco.CMS section to override the backoffice host:
```json
"Umbraco": {
"CMS": {
"NewBackOffice":{
"BackOfficeHost": "http://localhost:5173",
"AuthorizeCallbackPathName": "/"
},
},
[...]
}
```
4. Set Vite to use Umbraco API by copying the ".env" file to ".env.local" and setting the following:
```
VITE_UMBRACO_USE_MSW=off
VITE_UMBRACO_API_URL=https://localhost:44339
```
5. Start the vite server: `npm run dev` in your backoffice folder
6. Check that you are sent to the login page
7. Log in
## To test a secure endpoint
If you want to mark an endpoint as secure, you can add the `[Authorize]` attribute to the controller or action. This will require you to be logged in to access the endpoint.
## What does not work yet
- You cannot log out through the UI
- Clear your local storage to log out for now
- If your session expires or your token is revoked, you will start getting 401 network errors, which for now only will be shown as a notification in the UI - we need to figure out how to send you back to log in
- We do not _yet_ poll to see if the token is still valid or check how long before you are logged out, so you won't be notified before trying to perfor actions that require a token

View File

@@ -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[] = [
{

View File

@@ -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')

View File

@@ -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',

View File

@@ -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;
}
}

View File

@@ -0,0 +1 @@
export * from './tags-input/tags-input.element';

View File

@@ -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;
}
}

View File

@@ -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',
],
},
};

View 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);
};

View File

@@ -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',
},
],
},
},
};

View File

@@ -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];

View File

@@ -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;
}
}

View File

@@ -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];

View File

@@ -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 }));
}
}

View File

@@ -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 });
}
}

View File

@@ -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);
}
}

View File

@@ -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'),
},
];

View File

@@ -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) {

View File

@@ -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: '',

View 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,
},
];