diff --git a/src/Umbraco.Web.UI.Client/.github/README.md b/src/Umbraco.Web.UI.Client/.github/README.md index 5d50e21a29..52bcd87919 100644 --- a/src/Umbraco.Web.UI.Client/.github/README.md +++ b/src/Umbraco.Web.UI.Client/.github/README.md @@ -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) ``` diff --git a/src/Umbraco.Web.UI.Client/docs/authentication.md b/src/Umbraco.Web.UI.Client/docs/authentication.md new file mode 100644 index 0000000000..0ab31d4be6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/docs/authentication.md @@ -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 diff --git a/src/Umbraco.Web.UI.Client/src/app.ts b/src/Umbraco.Web.UI.Client/src/app.ts index 67bd2eb16a..7c299b310f 100644 --- a/src/Umbraco.Web.UI.Client/src/app.ts +++ b/src/Umbraco.Web.UI.Client/src/app.ts @@ -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[] = [ { diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/backoffice.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/backoffice.element.ts index d607346ee5..5b6c0c55e8 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/backoffice.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/backoffice.element.ts @@ -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') diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/core/property-editors/uis/manifests.ts b/src/Umbraco.Web.UI.Client/src/backoffice/core/property-editors/uis/manifests.ts index 8ee7084153..d0975419e2 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/core/property-editors/uis/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/core/property-editors/uis/manifests.ts @@ -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 = [ ...blockGrid, ...collectionView, ...tinyMCE, - ...tags, { type: 'propertyEditorUI', alias: 'Umb.PropertyEditorUI.Number', diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/core/property-editors/uis/tags/property-editor-ui-tags.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/core/property-editors/uis/tags/property-editor-ui-tags.element.ts deleted file mode 100644 index 935121c1f3..0000000000 --- a/src/Umbraco.Web.UI.Client/src/backoffice/core/property-editors/uis/tags/property-editor-ui-tags.element.ts +++ /dev/null @@ -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`
umb-property-editor-ui-tags
`; - } - - static styles = [UUITextStyles]; -} - -export default UmbPropertyEditorUITagsElement; - -declare global { - interface HTMLElementTagNameMap { - 'umb-property-editor-ui-tags': UmbPropertyEditorUITagsElement; - } -} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/tags/components/index.ts b/src/Umbraco.Web.UI.Client/src/backoffice/tags/components/index.ts new file mode 100644 index 0000000000..a4fa49584b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/tags/components/index.ts @@ -0,0 +1 @@ +export * from './tags-input/tags-input.element'; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/tags/components/tags-input/tags-input.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/tags/components/tags-input/tags-input.element.ts new file mode 100644 index 0000000000..0d0b0fd37c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/tags/components/tags-input/tags-input.element.ts @@ -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 = []; + + @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; + + #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` +
+ ${this.#enteredTags()} + + + + + + ${this.#renderTagOptions()} + + +
+ `; + } + + #enteredTags() { + return html` ${this.items.map((tag) => { + return html` + + ${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` +
+ ${repeat( + matchfilter.slice(0, 5), + (tag: TagResponseModel) => tag.id, + (tag: TagResponseModel, index: number) => { + return html` + `; + } + )} +
+ `; + } + + 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; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/tags/components/tags-input/tags-input.stories.ts b/src/Umbraco.Web.UI.Client/src/backoffice/tags/components/tags-input/tags-input.stories.ts new file mode 100644 index 0000000000..96a9cf4bd0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/tags/components/tags-input/tags-input.stories.ts @@ -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 = { + title: 'Components/Inputs/Tags', + component: 'umb-tags-input', +}; + +export default meta; +type Story = StoryObj; + +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', + ], + }, +}; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/tags/index.ts b/src/Umbraco.Web.UI.Client/src/backoffice/tags/index.ts new file mode 100644 index 0000000000..8b556a6664 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/tags/index.ts @@ -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); +}; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/tags/property-editors/Umbraco.Tags.ts b/src/Umbraco.Web.UI.Client/src/backoffice/tags/property-editors/Umbraco.Tags.ts new file mode 100644 index 0000000000..5385d679b3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/tags/property-editors/Umbraco.Tags.ts @@ -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', + }, + ], + }, + }, +}; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/tags/property-editors/manifests.ts b/src/Umbraco.Web.UI.Client/src/backoffice/tags/property-editors/manifests.ts new file mode 100644 index 0000000000..1f987be412 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/tags/property-editors/manifests.ts @@ -0,0 +1,4 @@ +import { manifests as tagsUI } from './tags/manifests'; +import type { ManifestTypes } from '@umbraco-cms/backoffice/extensions-registry'; + +export const manifests: Array = [...tagsUI]; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/core/property-editors/uis/tags/config/storage-type/manifests.ts b/src/Umbraco.Web.UI.Client/src/backoffice/tags/property-editors/tags/config/storage-type/manifests.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/backoffice/core/property-editors/uis/tags/config/storage-type/manifests.ts rename to src/Umbraco.Web.UI.Client/src/backoffice/tags/property-editors/tags/config/storage-type/manifests.ts diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/core/property-editors/uis/tags/config/storage-type/property-editor-ui-tags-storage-type.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/tags/property-editors/tags/config/storage-type/property-editor-ui-tags-storage-type.element.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/backoffice/core/property-editors/uis/tags/config/storage-type/property-editor-ui-tags-storage-type.element.ts rename to src/Umbraco.Web.UI.Client/src/backoffice/tags/property-editors/tags/config/storage-type/property-editor-ui-tags-storage-type.element.ts diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/core/property-editors/uis/tags/config/storage-type/property-editor-ui-tags-storage-type.stories.ts b/src/Umbraco.Web.UI.Client/src/backoffice/tags/property-editors/tags/config/storage-type/property-editor-ui-tags-storage-type.stories.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/backoffice/core/property-editors/uis/tags/config/storage-type/property-editor-ui-tags-storage-type.stories.ts rename to src/Umbraco.Web.UI.Client/src/backoffice/tags/property-editors/tags/config/storage-type/property-editor-ui-tags-storage-type.stories.ts diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/core/property-editors/uis/tags/config/storage-type/property-editor-ui-tags-storage-type.test.ts b/src/Umbraco.Web.UI.Client/src/backoffice/tags/property-editors/tags/config/storage-type/property-editor-ui-tags-storage-type.test.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/backoffice/core/property-editors/uis/tags/config/storage-type/property-editor-ui-tags-storage-type.test.ts rename to src/Umbraco.Web.UI.Client/src/backoffice/tags/property-editors/tags/config/storage-type/property-editor-ui-tags-storage-type.test.ts diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/core/property-editors/uis/tags/manifests.ts b/src/Umbraco.Web.UI.Client/src/backoffice/tags/property-editors/tags/manifests.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/backoffice/core/property-editors/uis/tags/manifests.ts rename to src/Umbraco.Web.UI.Client/src/backoffice/tags/property-editors/tags/manifests.ts diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/tags/property-editors/tags/property-editor-ui-tags.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/tags/property-editors/tags/property-editor-ui-tags.element.ts new file mode 100644 index 0000000000..e46ba4b81e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/tags/property-editors/tags/property-editor-ui-tags.element.ts @@ -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) { + 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; + } + + 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``; + } + + static styles = [UUITextStyles]; +} + +export default UmbPropertyEditorUITagsElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-property-editor-ui-tags': UmbPropertyEditorUITagsElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/core/property-editors/uis/tags/property-editor-ui-tags.stories.ts b/src/Umbraco.Web.UI.Client/src/backoffice/tags/property-editors/tags/property-editor-ui-tags.stories.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/backoffice/core/property-editors/uis/tags/property-editor-ui-tags.stories.ts rename to src/Umbraco.Web.UI.Client/src/backoffice/tags/property-editors/tags/property-editor-ui-tags.stories.ts diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/core/property-editors/uis/tags/property-editor-ui-tags.test.ts b/src/Umbraco.Web.UI.Client/src/backoffice/tags/property-editors/tags/property-editor-ui-tags.test.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/backoffice/core/property-editors/uis/tags/property-editor-ui-tags.test.ts rename to src/Umbraco.Web.UI.Client/src/backoffice/tags/property-editors/tags/property-editor-ui-tags.test.ts diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/tags/repository/manifests.ts b/src/Umbraco.Web.UI.Client/src/backoffice/tags/repository/manifests.ts new file mode 100644 index 0000000000..4eb7b6b7f3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/tags/repository/manifests.ts @@ -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]; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/tags/repository/sources/tag.server.data.ts b/src/Umbraco.Web.UI.Client/src/backoffice/tags/repository/sources/tag.server.data.ts new file mode 100644 index 0000000000..a9d60e0fb5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/tags/repository/sources/tag.server.data.ts @@ -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 })); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/tags/repository/tag.repository.ts b/src/Umbraco.Web.UI.Client/src/backoffice/tags/repository/tag.repository.ts new file mode 100644 index 0000000000..c244f5ce90 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/tags/repository/tag.repository.ts @@ -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; + + #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 }); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/tags/repository/tag.store.ts b/src/Umbraco.Web.UI.Client/src/backoffice/tags/repository/tag.store.ts new file mode 100644 index 0000000000..7fc8a4aa97 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/tags/repository/tag.store.ts @@ -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'); +/** + * @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([], (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) { + this._data.remove(uniques); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/tags/umbraco-package.ts b/src/Umbraco.Web.UI.Client/src/backoffice/tags/umbraco-package.ts new file mode 100644 index 0000000000..6f6c4e8434 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/tags/umbraco-package.ts @@ -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'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/core/mocks/browser-handlers.ts b/src/Umbraco.Web.UI.Client/src/core/mocks/browser-handlers.ts index fb305268a6..e6348ff511 100644 --- a/src/Umbraco.Web.UI.Client/src/core/mocks/browser-handlers.ts +++ b/src/Umbraco.Web.UI.Client/src/core/mocks/browser-handlers.ts @@ -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) { diff --git a/src/Umbraco.Web.UI.Client/src/core/mocks/data/data-type.data.ts b/src/Umbraco.Web.UI.Client/src/core/mocks/data/data-type.data.ts index 33cd8fe2ed..1e0d80fcbc 100644 --- a/src/Umbraco.Web.UI.Client/src/core/mocks/data/data-type.data.ts +++ b/src/Umbraco.Web.UI.Client/src/core/mocks/data/data-type.data.ts @@ -378,7 +378,16 @@ export const data: Array = parentId: null, propertyEditorAlias: 'Umbraco.Tags', propertyEditorUiAlias: 'Umb.PropertyEditorUI.Tags', - values: [], + values: [ + { + alias: 'group', + value: 'Fruits', + }, + { + alias: 'items', + value: [], + }, + ], }, { $type: '', diff --git a/src/Umbraco.Web.UI.Client/src/core/mocks/domains/tag-handlers.ts b/src/Umbraco.Web.UI.Client/src/core/mocks/domains/tag-handlers.ts new file mode 100644 index 0000000000..086270399e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/core/mocks/domains/tag-handlers.ts @@ -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(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, + }, +];