diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/token/import-dictionary-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/token/import-dictionary-modal.token.ts index 5890b6188d..aefea44ec1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/token/import-dictionary-modal.token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/token/import-dictionary-modal.token.ts @@ -5,7 +5,7 @@ export interface UmbImportDictionaryModalData { } export interface UmbImportDictionaryModalValue { - temporaryFileId?: string; + temporaryFileId: string; parentId?: string; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/dictionary/dictionary/components/dictionary-item-input/dictionary-item-input.context.ts b/src/Umbraco.Web.UI.Client/src/packages/dictionary/dictionary/components/dictionary-item-input/dictionary-item-input.context.ts new file mode 100644 index 0000000000..87b963ab7d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/dictionary/dictionary/components/dictionary-item-input/dictionary-item-input.context.ts @@ -0,0 +1,10 @@ +import { UmbPickerInputContext } from '@umbraco-cms/backoffice/picker-input'; +import { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api'; +import { UMB_DICTIONARY_ITEM_PICKER_MODAL } from '@umbraco-cms/backoffice/modal'; +import { DictionaryItemItemResponseModel } from '@umbraco-cms/backoffice/backend-api'; + +export class UmbDictionaryItemPickerContext extends UmbPickerInputContext { + constructor(host: UmbControllerHostElement) { + super(host, 'Umb.Repository.Dictionary', UMB_DICTIONARY_ITEM_PICKER_MODAL); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/dictionary/dictionary/components/dictionary-item-input/dictionary-item-input.element.ts b/src/Umbraco.Web.UI.Client/src/packages/dictionary/dictionary/components/dictionary-item-input/dictionary-item-input.element.ts new file mode 100644 index 0000000000..b0860583b5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/dictionary/dictionary/components/dictionary-item-input/dictionary-item-input.element.ts @@ -0,0 +1,149 @@ +import { UmbDictionaryItemPickerContext } from './dictionary-item-input.context.js'; +import { css, html, customElement, property, state, ifDefined, repeat } from '@umbraco-cms/backoffice/external/lit'; +import { FormControlMixin } from '@umbraco-cms/backoffice/external/uui'; +import { UmbLitElement } from '@umbraco-cms/internal/lit-element'; +import type { DictionaryItemItemResponseModel } from '@umbraco-cms/backoffice/backend-api'; + +@customElement('umb-dictionary-item-input') +export class UmbDictionaryItemInputElement extends FormControlMixin(UmbLitElement) { + /** + * This is a minimum amount of selected items in this input. + * @type {number} + * @attr + * @default 0 + */ + @property({ type: Number }) + public get min(): number { + return this.#pickerContext.min; + } + public set min(value: number) { + this.#pickerContext.min = value; + } + + /** + * Min validation message. + * @type {boolean} + * @attr + * @default + */ + @property({ type: String, attribute: 'min-message' }) + minMessage = 'This field need more items'; + + /** + * This is a maximum amount of selected items in this input. + * @type {number} + * @attr + * @default Infinity + */ + @property({ type: Number }) + public get max(): number { + return this.#pickerContext.max; + } + public set max(value: number) { + this.#pickerContext.max = value; + } + + /** + * Max validation message. + * @type {boolean} + * @attr + * @default + */ + @property({ type: String, attribute: 'min-message' }) + maxMessage = 'This field exceeds the allowed amount of items'; + + public get selectedIds(): Array { + return this.#pickerContext.getSelection(); + } + public set selectedIds(ids: Array) { + this.#pickerContext.setSelection(ids); + } + + @property() + public set value(idsString: string) { + // Its with full purpose we don't call super.value, as thats being handled by the observation of the context selection. + this.selectedIds = idsString.split(/[ ,]+/); + } + + @state() + private _items?: Array; + + #pickerContext = new UmbDictionaryItemPickerContext(this); + + constructor() { + super(); + + this.addValidator( + 'rangeUnderflow', + () => this.minMessage, + () => !!this.min && this.#pickerContext.getSelection().length < this.min, + ); + + this.addValidator( + 'rangeOverflow', + () => this.maxMessage, + () => !!this.max && this.#pickerContext.getSelection().length > this.max, + ); + + this.observe(this.#pickerContext.selection, (selection) => (super.value = selection.join(','))); + this.observe(this.#pickerContext.selectedItems, (selectedItems) => (this._items = selectedItems)); + } + + protected getFormElement() { + return undefined; + } + + render() { + return html` + ${this._items + ? html` ${repeat( + this._items, + (item) => item.id, + (item) => this._renderItem(item), + )} + ` + : ''} + ${this.#renderAddButton()} + `; + } + + #renderAddButton() { + if (this.max > 0 && this.selectedIds.length >= this.max) return; + return html` this.#pickerContext.openPicker()} + label=${this.localize.term('general_add')}>`; + } + + private _renderItem(item: DictionaryItemItemResponseModel) { + if (!item.id) return; + return html` + + + + this.#pickerContext.requestRemoveItem(item.id!)} + label=${this.localize.term('actions_remove')}> + + + `; + } + + static styles = [ + css` + #add-button { + width: 100%; + } + `, + ]; +} + +export default UmbDictionaryItemInputElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-dictionary-item-input': UmbDictionaryItemInputElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/dictionary/dictionary/components/index.ts b/src/Umbraco.Web.UI.Client/src/packages/dictionary/dictionary/components/index.ts new file mode 100644 index 0000000000..2cf0fea3fc --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/dictionary/dictionary/components/index.ts @@ -0,0 +1 @@ +export * from './dictionary-item-input/dictionary-item-input.element.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/dictionary/dictionary/entity-actions/export/export.action.ts b/src/Umbraco.Web.UI.Client/src/packages/dictionary/dictionary/entity-actions/export/export.action.ts index e5620a6e0c..75c0c8e448 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/dictionary/dictionary/entity-actions/export/export.action.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/dictionary/dictionary/entity-actions/export/export.action.ts @@ -1,5 +1,5 @@ import { UmbDictionaryRepository } from '../../repository/dictionary.repository.js'; -import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { UmbEntityActionBase } from '@umbraco-cms/backoffice/entity-action'; import { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api'; import { @@ -22,18 +22,30 @@ export default class UmbExportDictionaryEntityAction extends UmbEntityActionBase } async execute() { - // TODO: what to do if modal service is not available? if (!this.#modalContext) return; const modalContext = this.#modalContext?.open(UMB_EXPORT_DICTIONARY_MODAL, { unique: this.unique }); - // TODO: get type from modal result const { includeChildren } = await modalContext.onSubmit(); if (includeChildren === undefined) return; + // Export the file const result = await this.repository?.export(this.unique, includeChildren); + const blobContent = await result?.data; - // TODO => get location header to route to new item - console.log(result); + if (!blobContent) return; + const blob = new Blob([blobContent], { type: 'text/plain' }); + const a = document.createElement('a'); + const url = window.URL.createObjectURL(blob); + + // Download + a.href = url; + a.download = `${this.unique}.udt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + + // Clean up + window.URL.revokeObjectURL(url); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/dictionary/dictionary/entity-actions/import/import-dictionary-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/dictionary/dictionary/entity-actions/import/import-dictionary-modal.element.ts index 5a3b2b1589..e054fc78de 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/dictionary/dictionary/entity-actions/import/import-dictionary-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/dictionary/dictionary/entity-actions/import/import-dictionary-modal.element.ts @@ -1,3 +1,5 @@ +import '../../components/dictionary-item-input/dictionary-item-input.element.js'; +import UmbDictionaryItemInputElement from '../../components/dictionary-item-input/dictionary-item-input.element.js'; import { UmbDictionaryRepository } from '../../repository/dictionary.repository.js'; import { css, html, customElement, query, state, when } from '@umbraco-cms/backoffice/external/lit'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; @@ -7,167 +9,213 @@ import { UmbModalBaseElement, } from '@umbraco-cms/backoffice/modal'; import { ImportDictionaryRequestModel } from '@umbraco-cms/backoffice/backend-api'; +import { UmbId } from '@umbraco-cms/backoffice/id'; + +interface DictionaryItemPreview { + name: string; + children: Array; +} @customElement('umb-import-dictionary-modal') export class UmbImportDictionaryModalLayout extends UmbModalBaseElement< UmbImportDictionaryModalData, UmbImportDictionaryModalValue > { + @state() + private _parentId?: string; + + @state() + private _temporaryFileId?: string; + + @query('#form') + private _form!: HTMLFormElement; + + #fileReader; + + #fileContent: Array = []; + + #handleClose() { + this.modalContext?.reject(); + } + + #submit() { + // TODO: Gotta do a temp file upload before submitting, so that the server can use it + console.log('submit:', this._temporaryFileId, this._parentId); + //this.modalContext?.submit({ temporaryFileId: this._temporaryFileId, parentId: this._parentId }); + } + + constructor() { + super(); + this.#fileReader = new FileReader(); + this.#fileReader.onload = (e) => { + if (typeof e.target?.result === 'string') { + const fileContent = e.target.result; + this.#dictionaryItemBuilder(fileContent); + } + }; + } + + connectedCallback(): void { + super.connectedCallback(); + this._parentId = this.data?.unique ?? undefined; + } + + #dictionaryItemBuilder(htmlString: string) { + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlString, 'text/xml'); + const elements = doc.childNodes; + + this.#fileContent = this.#makeDictionaryItems(elements); + this.requestUpdate(); + } + + #makeDictionaryItems(nodeList: NodeListOf): Array { + const items: Array = []; + const list: Array = []; + nodeList.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE && node.nodeName === 'DictionaryItem') { + list.push(node as Element); + } + }); + + list.forEach((item) => { + items.push({ + name: item.getAttribute('Name') ?? '', + children: this.#makeDictionaryItems(item.childNodes) ?? undefined, + }); + }); + + return items; + } + + #onUpload(e: Event) { + e.preventDefault(); + const formData = new FormData(this._form); + const file = formData.get('file') as Blob; + + this.#fileReader.readAsText(file); + this._temporaryFileId = file ? UmbId.new() : undefined; + } + + #onParentChange(event: CustomEvent) { + this._parentId = (event.target as UmbDictionaryItemInputElement).selectedIds[0] || undefined; + //console.log((event.target as UmbDictionaryItemInputElement).selectedIds[0] || undefined); + } + + async #onFileInput() { + requestAnimationFrame(() => { + this._form.requestSubmit(); + }); + } + + #onClear() { + this._temporaryFileId = ''; + } + + render() { + return html` + + ${when( + this._temporaryFileId, + () => this.#renderImportDestination(), + () => this.#renderUploadZone(), + )} + + + `; + } + + #renderFileContents(items: Array): any { + return html`${items.map((item: DictionaryItemPreview) => { + return html`${item.name} +
${this.#renderFileContents(item.children)}
`; + })}`; + } + + #renderImportDestination() { + return html` +
+
+ Dictionary items: +
${this.#renderFileContents(this.#fileContent)}
+
+
+ Choose where to import: + Work in progress
+ ${ + this._parentId + // TODO + // + // + } +
+ + ${this.#renderNavigate()} +
+ `; + } + + #renderNavigate() { + return html``; + } + + #renderUploadZone() { + return html` + +
+ + ${this.localize.term('formFileUpload_pickFile')} + + +
+
`; + } + static styles = [ UmbTextStyles, css` uui-input { width: 100%; } + #item-list { + padding: var(--uui-size-3) var(--uui-size-4); + border: 1px solid var(--uui-color-border); + border-radius: var(--uui-border-radius); + } + #item-list div { + padding-left: 20px; + } + + #wrapper { + display: flex; + flex-direction: column; + gap: var(--uui-size-3); + } `, ]; - - @query('#form') - private _form!: HTMLFormElement; - - @state() - private _uploadedDictionaryTempId?: string; - - @state() - private _showUploadView = true; - - @state() - private _showImportView = false; - - @state() - private _showErrorView = false; - - @state() - private _selection: Array = []; - - #detailRepo = new UmbDictionaryRepository(this); - - async #importDictionary() { - if (!this._uploadedDictionaryTempId) return; - - this.modalContext?.submit({ - temporaryFileId: this._uploadedDictionaryTempId, - parentId: this._selection[0], - }); - } - - #handleClose() { - this.modalContext?.reject(); - } - - #submitForm() { - this._form?.requestSubmit(); - } - - async #handleSubmit(e: SubmitEvent) { - e.preventDefault(); - - if (!this._form.checkValidity()) return; - - const formData = new FormData(this._form); - - const uploadData: ImportDictionaryRequestModel = { - temporaryFileId: formData.get('file')?.toString() ?? '', - }; - - // TODO: fix this upload experience. We need to update our form so it gets temporary file id from the server: - const { data } = await this.#detailRepo.upload(uploadData); - - if (!data) return; - - this._uploadedDictionaryTempId = data; - // TODO: We need to find another way to gather the data of the uploaded dictionary, to represent the dictionaryItems? See further below. - //this._uploadedDictionary = data; - - if (!this._uploadedDictionaryTempId) { - this._showErrorView = true; - this._showImportView = false; - return; - } - - this._showErrorView = false; - this._showUploadView = false; - this._showImportView = true; - } - - /* - #handleSelectionChange(e: CustomEvent) { - e.stopPropagation(); - const element = e.target as UmbTreeElement; - this._selection = element.selection; - } - */ - - #renderUploadView() { - return html`

- To import a dictionary item, find the ".udt" file on your computer by clicking the "Import" button (you'll be - asked for confirmation on the next screen) -

- -
- - File -
- -
-
-
-
- - `; - } - - /// TODO => Tree view needs isolation and single-select option - #renderImportView() { - //TODO: gather this data in some other way, we cannot use the feedback from the server anymore. can we use info about the file directly? or is a change to the end point required? - /* - if (!this._uploadedDictionary?.dictionaryItems) return; - - return html` - Dictionary items -
    - ${repeat( - this._uploadedDictionary.dictionaryItems, - (item) => item.name, - (item) => html`
  • ${item.name}
  • ` - )} -
-
- Choose where to import dictionary items (optional) - - - - - `; - */ - } - - // TODO => Determine what to display when dictionary import/upload fails - #renderErrorView() { - return html`Something went wrong`; - } - - render() { - return html` - ${when(this._showUploadView, () => this.#renderUploadView())} - ${when(this._showImportView, () => this.#renderImportView())} - ${when(this._showErrorView, () => this.#renderErrorView())} - `; - } } export default UmbImportDictionaryModalLayout; diff --git a/src/Umbraco.Web.UI.Client/src/packages/dictionary/dictionary/entity-actions/import/import.action.ts b/src/Umbraco.Web.UI.Client/src/packages/dictionary/dictionary/entity-actions/import/import.action.ts index 1abf9e025b..264f28715d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/dictionary/dictionary/entity-actions/import/import.action.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/dictionary/dictionary/entity-actions/import/import.action.ts @@ -1,5 +1,5 @@ import { UmbDictionaryRepository } from '../../repository/dictionary.repository.js'; -import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { UmbEntityActionBase } from '@umbraco-cms/backoffice/entity-action'; import { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api'; import { @@ -22,18 +22,12 @@ export default class UmbImportDictionaryEntityAction extends UmbEntityActionBase } async execute() { - // TODO: what to do if modal service is not available? if (!this.#modalContext) return; const modalContext = this.#modalContext?.open(UMB_IMPORT_DICTIONARY_MODAL, { unique: this.unique }); - // TODO: get type from modal result - const { temporaryFileId, parentId } = await modalContext.onSubmit(); - if (!temporaryFileId) return; + const { parentId, temporaryFileId } = await modalContext.onSubmit(); - const result = await this.repository?.import(temporaryFileId, parentId); - - // TODO => get location header to route to new item - console.log(result); + await this.repository?.import(temporaryFileId, parentId); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/dictionary/dictionary/index.ts b/src/Umbraco.Web.UI.Client/src/packages/dictionary/dictionary/index.ts index 5c342dd939..a24a0f6363 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/dictionary/dictionary/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/dictionary/dictionary/index.ts @@ -1,2 +1,3 @@ export * from './repository/index.js'; export * from './tree/index.js'; +export * from './components/index.js';