From f99e9394f84139a9688cf6247b4975367a5d9519 Mon Sep 17 00:00:00 2001 From: Mathias Helsengren Date: Fri, 28 Nov 2025 10:06:20 +0100 Subject: [PATCH] Culture and Hostnames: Add ability to sort hostnames (closes #20691) (#20826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Adding the sorter controller, and fixing some ui elements so you are able to drag the hostname elements around to sort them * Fixed sorting * Changed the html structure and tweaked around with the css to make it look better. Added a description for the Culture section. Alligned the rendered text to allign better with the name "Culture and Hostnames" * Update src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/culture-and-hostnames/modal/culture-and-hostnames-modal.element.ts Forgot to remove this after I was done testing Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/culture-and-hostnames/modal/culture-and-hostnames-modal.element.ts Changing grid-gap to just gap Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Removed the disabled and readonly props I added since they are not needed. Removed the conditional rendering that was attached to the readonly and disabled properties * Removed the item id from the element and changed css and sorter logic to target the hostname-item class instead * Updated test * Bumped helpers --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Niels Lyngsø Co-authored-by: Andreas Zerbst <73799582+andr317c@users.noreply.github.com> Co-authored-by: Andreas Zerbst --- .../src/assets/lang/en.ts | 38 +-- .../culture-and-hostnames-modal.element.ts | 222 +++++++++++++----- .../package-lock.json | 8 +- .../Umbraco.Tests.AcceptanceTest/package.json | 2 +- .../Content/CultureAndHostnames.spec.ts | 1 + 5 files changed, 182 insertions(+), 89 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts index fa65c69959..d2e056d070 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts @@ -113,26 +113,26 @@ export default { }, assignDomain: { permissionDenied: 'Permission denied.', - addNew: 'Add new domain', - addCurrent: 'Add current domain', + addNew: 'Add new hostname', + addCurrent: 'Add current hostname', remove: 'remove', invalidNode: 'Invalid node.', - invalidDomain: 'One or more domains have an invalid format.', - duplicateDomain: 'Domain has already been assigned.', - language: 'Language', - domain: 'Domain', - domainCreated: "New domain '%0%' has been created", - domainDeleted: "Domain '%0%' is deleted", - domainExists: "Domain '%0%' has already been assigned", - domainUpdated: "Domain '%0%' has been updated", - orEdit: 'Edit Current Domains', + invalidDomain: 'One or more hostnames have an invalid format.', + duplicateDomain: 'Hostname has already been assigned.', + language: 'Culture', + domain: 'Hostname', + domainCreated: "New hostname '%0%' has been created", + domainDeleted: "Hostname '%0%' is deleted", + domainExists: "Hostname '%0%' has already been assigned", + domainUpdated: "Hostname '%0%' has been updated", + orEdit: 'Edit Current Hostnames', domainHelpWithVariants: - 'Valid domain names are: "example.com", "www.example.com", "example.com:8080", or "https://www.example.com/". Furthermore also one-level paths in domains are supported, e.g. "example.com/en" or "/en".', + 'Valid hostnames are: "example.com", "www.example.com", "example.com:8080", or "https://www.example.com/". Furthermore also one-level paths in hostnames are supported, e.g. "example.com/en" or "/en".', inherit: 'Inherit', setLanguage: 'Culture', setLanguageHelp: - 'Set the culture for nodes below the current node,
or inherit culture from parent nodes. Will also apply
to the current node, unless a domain below applies too.', - setDomains: 'Domains', + 'Set the culture for nodes below the current node, or inherit culture from parent nodes. Will also apply to the current node, unless a hostname below applies too.', + setDomains: 'Hostnames', }, buttons: { clearSelection: 'Clear selection', @@ -191,7 +191,7 @@ export default { save: 'Media saved', }, auditTrails: { - assigndomain: 'Domain assigned: %0%', + assigndomain: 'Hostname assigned: %0%', atViewingFor: 'Viewing for', delete: 'Content deleted', unpublish: 'Content unpublished', @@ -209,7 +209,7 @@ export default { custom: '%0%', contentversionpreventcleanup: 'Clean up disabled for version: %0%', contentversionenablecleanup: 'Clean up enabled for version: %0%', - smallAssignDomain: 'Assign Domain', + smallAssignDomain: 'Assign Hostname', smallCopy: 'Copy', smallPublish: 'Publish', smallPublishVariant: 'Publish', @@ -1562,9 +1562,9 @@ export default { dictionaryItemExportedError: 'An error occurred while exporting the dictionary item(s)', dictionaryItemImported: 'The following dictionary item(s) has been imported!', publishWithNoDomains: - 'Domains are not configured for multilingual site, please contact an administrator, see log for more information', + 'Hostnames are not configured for multilingual site, please contact an administrator, see log for more information', publishWithMissingDomain: - 'There is no domain configured for %0%, please contact an administrator, see log for more information', + 'There is no hostname configured for %0%, please contact an administrator, see log for more information', copySuccessMessage: 'Your system information has successfully been copied to the clipboard', cannotCopyInformation: 'Could not copy your system information to the clipboard', webhookSaved: 'Webhook saved', @@ -2786,7 +2786,7 @@ export default { minimalLevelDescription: 'We will only send an anonymised site ID to let us know that the site exists.', basicLevelDescription: 'We will send an anonymised site ID, Umbraco version, and packages installed', detailedLevelDescription: - 'We will send:
  • Anonymised site ID, Umbraco version, and packages installed.
  • Number of: Root nodes, Content nodes, Media, Document Types, Templates, Languages, Domains, User Group, Users, Members, Backoffice external login providers, and Property Editors in use.
  • System information: Webserver, server OS, server framework, server OS language, and database provider.
  • Configuration settings: ModelsBuilder mode, if custom Umbraco path exists, ASP environment, whether the delivery API is enabled, and allows public access, and if you are in debug mode.
We might change what we send on the Detailed level in the future. If so, it will be listed above.
By choosing "Detailed" you agree to current and future anonymised information being collected.
', + 'We will send:
  • Anonymised site ID, Umbraco version, and packages installed.
  • Number of: Root nodes, Content nodes, Media, Document Types, Templates, Languages, Hostnames, User Group, Users, Members, Backoffice external login providers, and Property Editors in use.
  • System information: Webserver, server OS, server framework, server OS language, and database provider.
  • Configuration settings: ModelsBuilder mode, if custom Umbraco path exists, ASP environment, whether the delivery API is enabled, and allows public access, and if you are in debug mode.
We might change what we send on the Detailed level in the future. If so, it will be listed above.
By choosing "Detailed" you agree to current and future anonymised information being collected.
', }, routing: { routeNotFoundTitle: 'Not found', diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/culture-and-hostnames/modal/culture-and-hostnames-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/culture-and-hostnames/modal/culture-and-hostnames-modal.element.ts index b03235b7cc..104e74e046 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/culture-and-hostnames/modal/culture-and-hostnames-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/culture-and-hostnames/modal/culture-and-hostnames-modal.element.ts @@ -3,19 +3,49 @@ import type { UmbCultureAndHostnamesModalData, UmbCultureAndHostnamesModalValue, } from './culture-and-hostnames-modal.token.js'; -import { css, customElement, html, query, repeat, state } from '@umbraco-cms/backoffice/external/lit'; +import { + css, + customElement, + html, + query, + repeat, + state, + type PropertyValues, +} from '@umbraco-cms/backoffice/external/lit'; import { UmbLanguageCollectionRepository } from '@umbraco-cms/backoffice/language'; import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import type { DomainPresentationModel } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbLanguageDetailModel } from '@umbraco-cms/backoffice/language'; import type { UUIInputEvent, UUIPopoverContainerElement, UUISelectEvent } from '@umbraco-cms/backoffice/external/uui'; +import { UmbSorterController } from '@umbraco-cms/backoffice/sorter'; +import { UmbId } from '@umbraco-cms/backoffice/id'; +interface UmbDomainPresentationModel { + unique: string; + domainName: string; + isoCode: string; +} @customElement('umb-culture-and-hostnames-modal') export class UmbCultureAndHostnamesModalElement extends UmbModalBaseElement< UmbCultureAndHostnamesModalData, UmbCultureAndHostnamesModalValue > { + #sorter = new UmbSorterController(this, { + getUniqueOfElement: (element) => { + return element.getAttribute('data-sort-entry-id'); + }, + getUniqueOfModel: (modelEntry: UmbDomainPresentationModel) => { + return modelEntry.unique; + }, + itemSelector: '.hostname-item', + containerSelector: '#sorter-wrapper', + onChange: ({ model }) => { + const oldValue = this._domains; + this._domains = model; + this.requestUpdate('_domains', oldValue); + }, + }); + #documentRepository = new UmbDocumentCultureAndHostnamesRepository(this); #languageCollectionRepository = new UmbLanguageCollectionRepository(this); @@ -28,13 +58,20 @@ export class UmbCultureAndHostnamesModalElement extends UmbModalBaseElement< private _defaultIsoCode?: string | null; @state() - private _domains: Array = []; + private _domains: Array = []; @query('#more-options') popoverContainerElement?: UUIPopoverContainerElement; // Init + override willUpdate(changedProperties: PropertyValues) { + if (changedProperties.has('_domains')) { + // Update sorter whenever _domains changes + this.#sorter.setModel(this._domains); + } + } + override firstUpdated() { this.#unique = this.data?.unique; this.#requestLanguages(); @@ -47,7 +84,7 @@ export class UmbCultureAndHostnamesModalElement extends UmbModalBaseElement< if (!data) return; this._defaultIsoCode = data.defaultIsoCode; - this._domains = data.domains; + this._domains = data.domains.map((domain) => ({ ...domain, unique: UmbId.new() })); } async #requestLanguages() { @@ -57,7 +94,8 @@ export class UmbCultureAndHostnamesModalElement extends UmbModalBaseElement< } async #handleSave() { - this.value = { defaultIsoCode: this._defaultIsoCode, domains: this._domains }; + const cleanDomains = this._domains.map((domain) => ({ domainName: domain.domainName, isoCode: domain.isoCode })); + this.value = { defaultIsoCode: this._defaultIsoCode, domains: cleanDomains }; const { error } = await this.#documentRepository.updateCultureAndHostnames(this.#unique!, this.value); if (!error) { @@ -101,18 +139,61 @@ export class UmbCultureAndHostnamesModalElement extends UmbModalBaseElement< // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore this.popoverContainerElement?.hidePopover(); - this._domains = [...this._domains, { isoCode: defaultModel?.unique ?? '', domainName: window.location.host }]; + this._domains = [ + ...this._domains, + { isoCode: defaultModel?.unique ?? '', domainName: window.location.host, unique: UmbId.new() }, + ]; + + this.#focusNewItem(); } else { - this._domains = [...this._domains, { isoCode: defaultModel?.unique ?? '', domainName: '' }]; + this._domains = [...this._domains, { isoCode: defaultModel?.unique ?? '', domainName: '', unique: UmbId.new() }]; + + this.#focusNewItem(); } } + async #focusNewItem() { + await this.updateComplete; + const items = this.shadowRoot?.querySelectorAll('div.hostname-item') as NodeListOf; + const newItem = items[items.length - 1]; + const firstInput = newItem?.querySelector('uui-input') as HTMLElement; + firstInput?.focus(); + } + // Renders override render() { return html` - ${this.#renderCultureSection()} ${this.#renderDomainSection()} + +
+ + + + ${this.localize.term('assignDomain_inherit')} + + ${this.#renderLanguageModelOptions()} + + +
+
+
+ +
${this.#renderDomains()} ${this.#renderAddNewDomainButton()}
+
- ${this.localize.term('assignDomain_language')} - - - - ${this.localize.term('assignDomain_inherit')} - - ${this.#renderLanguageModelOptions()} - - - - `; - } - - #renderDomainSection() { - return html` - - - Valid domain names are: "example.com", "www.example.com", "example.com:8080", or - "https://www.example.com/".
Furthermore also one-level paths in domains are supported, eg. - "example.com/en" or "/en". -
- ${this.#renderDomains()} ${this.#renderAddNewDomainButton()} -
- `; - } - #renderDomains() { - if (!this._domains?.length) return; return html` -
+
${repeat( this._domains, - (domain) => domain.isoCode, + (domain) => domain.unique, (domain, index) => html` - this.#onChangeDomainHostname(e, index)}> - this.#onChangeDomainLanguage(e, index)}> - ${this.#renderLanguageModelOptions()} - - this.#onRemoveDomain(index)}> - - +
+ +
+ this.#onChangeDomainHostname(e, index)}> + this.#onChangeDomainLanguage(e, index)}> + ${this.#renderLanguageModelOptions()} + + this.#onRemoveDomain(index)}> + + +
+
`, )}
@@ -229,6 +281,9 @@ export class UmbCultureAndHostnamesModalElement extends UmbModalBaseElement< static override styles = [ UmbTextStyles, css` + umb-property-layout[orientation='vertical'] { + padding: 0; + } uui-button-group { width: 100%; } @@ -241,12 +296,49 @@ export class UmbCultureAndHostnamesModalElement extends UmbModalBaseElement< flex-grow: 0; } - #domains { - margin-top: var(--uui-size-layout-1); - margin-bottom: var(--uui-size-2); + .hostname-item { + position: relative; + display: flex; + gap: var(--uui-size-1); + align-items: center; + } + + .hostname-wrapper { + position: relative; + flex: 1; display: grid; grid-template-columns: 1fr 1fr auto; - grid-gap: var(--uui-size-1); + gap: var(--uui-size-1); + } + + #sorter-wrapper { + margin-bottom: var(--uui-size-2); + display: flex; + flex-direction: column; + gap: var(--uui-size-1); + } + + .handle { + cursor: grab; + } + + .handle:active { + cursor: grabbing; + } + #action { + display: block; + } + + .--umb-sorter-placeholder { + position: relative; + visibility: hidden; + } + .--umb-sorter-placeholder::after { + content: ''; + position: absolute; + inset: 0px; + border-radius: var(--uui-border-radius); + border: 1px dashed var(--uui-color-divider-emphasis); } `, ]; diff --git a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json index bdda714dd7..5fa63bbeff 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json @@ -8,7 +8,7 @@ "hasInstallScript": true, "dependencies": { "@umbraco/json-models-builders": "^2.0.42", - "@umbraco/playwright-testhelpers": "^17.0.11", + "@umbraco/playwright-testhelpers": "^17.0.12", "camelize": "^1.0.0", "dotenv": "^16.3.1", "node-fetch": "^2.6.7" @@ -67,9 +67,9 @@ } }, "node_modules/@umbraco/playwright-testhelpers": { - "version": "17.0.11", - "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-17.0.11.tgz", - "integrity": "sha512-+2zijm64oppD17NQg0om7ip1iFJsTQy0ugGgQamZvpf2mUPoGV2CpIz7enPY5YmrQerPacS/1riBMWx/eafqHA==", + "version": "17.0.12", + "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-17.0.12.tgz", + "integrity": "sha512-GhOj5ytXEY1sG8Nt6CAkJcqjxfRWUFKLl63SCk2quew/1rLCeaUV5I2+YJ3LkfQetMdDlqtMVZP7FdMk+iWJNQ==", "license": "MIT", "dependencies": { "@umbraco/json-models-builders": "2.0.42", diff --git a/tests/Umbraco.Tests.AcceptanceTest/package.json b/tests/Umbraco.Tests.AcceptanceTest/package.json index e42d11593e..1e142aba1d 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package.json @@ -23,7 +23,7 @@ }, "dependencies": { "@umbraco/json-models-builders": "^2.0.42", - "@umbraco/playwright-testhelpers": "^17.0.11", + "@umbraco/playwright-testhelpers": "^17.0.12", "camelize": "^1.0.0", "dotenv": "^16.3.1", "node-fetch": "^2.6.7" diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/CultureAndHostnames.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/CultureAndHostnames.spec.ts index 766a628b39..7db43f1611 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/CultureAndHostnames.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/CultureAndHostnames.spec.ts @@ -36,6 +36,7 @@ test('can add a culture', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => { // Act await umbracoUi.content.clickActionsMenuForContent(contentName); await umbracoUi.content.clickCultureAndHostnamesActionMenuOption(); + await umbracoUi.content.clickAddNewHostnameButton(); await umbracoUi.content.selectCultureLanguageOption(languageName); await umbracoUi.content.clickSaveModalButton();