Culture and Hostnames: Add ability to sort hostnames (closes #20691) (#20826)

* 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ø <nsl@umbraco.dk>
Co-authored-by: Andreas Zerbst <73799582+andr317c@users.noreply.github.com>
Co-authored-by: Andreas Zerbst <andr317c@live.dk>
This commit is contained in:
Mathias Helsengren
2025-11-28 10:06:20 +01:00
committed by GitHub
parent 9c038bc68b
commit f99e9394f8
5 changed files with 182 additions and 89 deletions

View File

@@ -113,26 +113,26 @@ export default {
}, },
assignDomain: { assignDomain: {
permissionDenied: 'Permission denied.', permissionDenied: 'Permission denied.',
addNew: 'Add new domain', addNew: 'Add new hostname',
addCurrent: 'Add current domain', addCurrent: 'Add current hostname',
remove: 'remove', remove: 'remove',
invalidNode: 'Invalid node.', invalidNode: 'Invalid node.',
invalidDomain: 'One or more domains have an invalid format.', invalidDomain: 'One or more hostnames have an invalid format.',
duplicateDomain: 'Domain has already been assigned.', duplicateDomain: 'Hostname has already been assigned.',
language: 'Language', language: 'Culture',
domain: 'Domain', domain: 'Hostname',
domainCreated: "New domain '%0%' has been created", domainCreated: "New hostname '%0%' has been created",
domainDeleted: "Domain '%0%' is deleted", domainDeleted: "Hostname '%0%' is deleted",
domainExists: "Domain '%0%' has already been assigned", domainExists: "Hostname '%0%' has already been assigned",
domainUpdated: "Domain '%0%' has been updated", domainUpdated: "Hostname '%0%' has been updated",
orEdit: 'Edit Current Domains', orEdit: 'Edit Current Hostnames',
domainHelpWithVariants: 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', inherit: 'Inherit',
setLanguage: 'Culture', setLanguage: 'Culture',
setLanguageHelp: setLanguageHelp:
'Set the culture for nodes below the current node,<br /> or inherit culture from parent nodes. Will also apply<br /> to the current node, unless a domain below applies too.', '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: 'Domains', setDomains: 'Hostnames',
}, },
buttons: { buttons: {
clearSelection: 'Clear selection', clearSelection: 'Clear selection',
@@ -191,7 +191,7 @@ export default {
save: 'Media saved', save: 'Media saved',
}, },
auditTrails: { auditTrails: {
assigndomain: 'Domain assigned: %0%', assigndomain: 'Hostname assigned: %0%',
atViewingFor: 'Viewing for', atViewingFor: 'Viewing for',
delete: 'Content deleted', delete: 'Content deleted',
unpublish: 'Content unpublished', unpublish: 'Content unpublished',
@@ -209,7 +209,7 @@ export default {
custom: '%0%', custom: '%0%',
contentversionpreventcleanup: 'Clean up disabled for version: %0%', contentversionpreventcleanup: 'Clean up disabled for version: %0%',
contentversionenablecleanup: 'Clean up enabled for version: %0%', contentversionenablecleanup: 'Clean up enabled for version: %0%',
smallAssignDomain: 'Assign Domain', smallAssignDomain: 'Assign Hostname',
smallCopy: 'Copy', smallCopy: 'Copy',
smallPublish: 'Publish', smallPublish: 'Publish',
smallPublishVariant: 'Publish', smallPublishVariant: 'Publish',
@@ -1562,9 +1562,9 @@ export default {
dictionaryItemExportedError: 'An error occurred while exporting the dictionary item(s)', dictionaryItemExportedError: 'An error occurred while exporting the dictionary item(s)',
dictionaryItemImported: 'The following dictionary item(s) has been imported!', dictionaryItemImported: 'The following dictionary item(s) has been imported!',
publishWithNoDomains: 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: 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', copySuccessMessage: 'Your system information has successfully been copied to the clipboard',
cannotCopyInformation: 'Could not copy your system information to the clipboard', cannotCopyInformation: 'Could not copy your system information to the clipboard',
webhookSaved: 'Webhook saved', 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.', 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', basicLevelDescription: 'We will send an anonymised site ID, Umbraco version, and packages installed',
detailedLevelDescription: detailedLevelDescription:
'We will send: <ul><li>Anonymised site ID, Umbraco version, and packages installed.</li><li>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.</li><li>System information: Webserver, server OS, server framework, server OS language, and database provider.</li><li>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.</li></ul> <em>We might change what we send on the Detailed level in the future. If so, it will be listed above.<br>By choosing "Detailed" you agree to current and future anonymised information being collected.</em>', 'We will send: <ul><li>Anonymised site ID, Umbraco version, and packages installed.</li><li>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.</li><li>System information: Webserver, server OS, server framework, server OS language, and database provider.</li><li>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.</li></ul> <em>We might change what we send on the Detailed level in the future. If so, it will be listed above.<br>By choosing "Detailed" you agree to current and future anonymised information being collected.</em>',
}, },
routing: { routing: {
routeNotFoundTitle: 'Not found', routeNotFoundTitle: 'Not found',

View File

@@ -3,19 +3,49 @@ import type {
UmbCultureAndHostnamesModalData, UmbCultureAndHostnamesModalData,
UmbCultureAndHostnamesModalValue, UmbCultureAndHostnamesModalValue,
} from './culture-and-hostnames-modal.token.js'; } 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 { UmbLanguageCollectionRepository } from '@umbraco-cms/backoffice/language';
import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; 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 { UmbLanguageDetailModel } from '@umbraco-cms/backoffice/language';
import type { UUIInputEvent, UUIPopoverContainerElement, UUISelectEvent } from '@umbraco-cms/backoffice/external/uui'; 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') @customElement('umb-culture-and-hostnames-modal')
export class UmbCultureAndHostnamesModalElement extends UmbModalBaseElement< export class UmbCultureAndHostnamesModalElement extends UmbModalBaseElement<
UmbCultureAndHostnamesModalData, UmbCultureAndHostnamesModalData,
UmbCultureAndHostnamesModalValue 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); #documentRepository = new UmbDocumentCultureAndHostnamesRepository(this);
#languageCollectionRepository = new UmbLanguageCollectionRepository(this); #languageCollectionRepository = new UmbLanguageCollectionRepository(this);
@@ -28,13 +58,20 @@ export class UmbCultureAndHostnamesModalElement extends UmbModalBaseElement<
private _defaultIsoCode?: string | null; private _defaultIsoCode?: string | null;
@state() @state()
private _domains: Array<DomainPresentationModel> = []; private _domains: Array<UmbDomainPresentationModel> = [];
@query('#more-options') @query('#more-options')
popoverContainerElement?: UUIPopoverContainerElement; popoverContainerElement?: UUIPopoverContainerElement;
// Init // Init
override willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has('_domains')) {
// Update sorter whenever _domains changes
this.#sorter.setModel(this._domains);
}
}
override firstUpdated() { override firstUpdated() {
this.#unique = this.data?.unique; this.#unique = this.data?.unique;
this.#requestLanguages(); this.#requestLanguages();
@@ -47,7 +84,7 @@ export class UmbCultureAndHostnamesModalElement extends UmbModalBaseElement<
if (!data) return; if (!data) return;
this._defaultIsoCode = data.defaultIsoCode; this._defaultIsoCode = data.defaultIsoCode;
this._domains = data.domains; this._domains = data.domains.map((domain) => ({ ...domain, unique: UmbId.new() }));
} }
async #requestLanguages() { async #requestLanguages() {
@@ -57,7 +94,8 @@ export class UmbCultureAndHostnamesModalElement extends UmbModalBaseElement<
} }
async #handleSave() { 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); const { error } = await this.#documentRepository.updateCultureAndHostnames(this.#unique!, this.value);
if (!error) { if (!error) {
@@ -101,18 +139,61 @@ export class UmbCultureAndHostnamesModalElement extends UmbModalBaseElement<
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
this.popoverContainerElement?.hidePopover(); 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 { } 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<HTMLElement>;
const newItem = items[items.length - 1];
const firstInput = newItem?.querySelector('uui-input') as HTMLElement;
firstInput?.focus();
}
// Renders // Renders
override render() { override render() {
return html` return html`
<umb-body-layout headline=${this.localize.term('actions_assigndomain')}> <umb-body-layout headline=${this.localize.term('actions_assigndomain')}>
${this.#renderCultureSection()} ${this.#renderDomainSection()} <uui-box>
<umb-property-layout
label=${this.localize.term('assignDomain_language')}
description=${this.localize.term('assignDomain_setLanguageHelp')}
orientation="vertical"
><div slot="editor">
<uui-combobox
id="select"
label=${this.localize.term('assignDomain_language')}
.value=${(this._defaultIsoCode as string) ?? 'inherit'}
@change=${this.#onChangeLanguage}>
<uui-combobox-list>
<uui-combobox-list-option .value=${'inherit'}>
${this.localize.term('assignDomain_inherit')}
</uui-combobox-list-option>
${this.#renderLanguageModelOptions()}
</uui-combobox-list>
</uui-combobox>
</div>
</umb-property-layout>
</uui-box>
<uui-box>
<umb-property-layout
label=${this.localize.term('assignDomain_setDomains')}
description=${this.localize.term('assignDomain_domainHelpWithVariants')}
orientation="vertical"
><div slot="editor">${this.#renderDomains()} ${this.#renderAddNewDomainButton()}</div></umb-property-layout
>
</uui-box>
<uui-button <uui-button
slot="actions" slot="actions"
id="close" id="close"
@@ -129,64 +210,35 @@ export class UmbCultureAndHostnamesModalElement extends UmbModalBaseElement<
`; `;
} }
#renderCultureSection() {
return html`
<uui-box headline=${this.localize.term('assignDomain_setLanguage')}>
<uui-label for="select">${this.localize.term('assignDomain_language')}</uui-label>
<uui-combobox
id="select"
label=${this.localize.term('assignDomain_language')}
.value=${(this._defaultIsoCode as string) ?? 'inherit'}
@change=${this.#onChangeLanguage}>
<uui-combobox-list>
<uui-combobox-list-option .value=${'inherit'}>
${this.localize.term('assignDomain_inherit')}
</uui-combobox-list-option>
${this.#renderLanguageModelOptions()}
</uui-combobox-list>
</uui-combobox>
</uui-box>
`;
}
#renderDomainSection() {
return html`
<uui-box headline=${this.localize.term('assignDomain_setDomains')}>
<umb-localize key="assignDomain_domainHelpWithVariants">
Valid domain names are: "example.com", "www.example.com", "example.com:8080", or
"https://www.example.com/".<br />Furthermore also one-level paths in domains are supported, eg.
"example.com/en" or "/en".
</umb-localize>
${this.#renderDomains()} ${this.#renderAddNewDomainButton()}
</uui-box>
`;
}
#renderDomains() { #renderDomains() {
if (!this._domains?.length) return;
return html` return html`
<div id="domains"> <div id="sorter-wrapper">
${repeat( ${repeat(
this._domains, this._domains,
(domain) => domain.isoCode, (domain) => domain.unique,
(domain, index) => html` (domain, index) => html`
<uui-input <div class="hostname-item" data-sort-entry-id=${domain.unique}>
label=${this.localize.term('assignDomain_domain')} <uui-icon name="icon-grip" class="handle"></uui-icon>
.value=${domain.domainName} <div class="hostname-wrapper">
@change=${(e: UUIInputEvent) => this.#onChangeDomainHostname(e, index)}></uui-input> <uui-input
<uui-combobox label=${this.localize.term('assignDomain_domain')}
.value=${domain.isoCode as string} .value=${domain.domainName}
label=${this.localize.term('assignDomain_language')} @change=${(e: UUIInputEvent) => this.#onChangeDomainHostname(e, index)}></uui-input>
@change=${(e: UUISelectEvent) => this.#onChangeDomainLanguage(e, index)}> <uui-combobox
<uui-combobox-list> ${this.#renderLanguageModelOptions()} </uui-combobox-list> .value=${domain.isoCode as string}
</uui-combobox> label=${this.localize.term('assignDomain_language')}
<uui-button @change=${(e: UUISelectEvent) => this.#onChangeDomainLanguage(e, index)}>
look="outline" <uui-combobox-list> ${this.#renderLanguageModelOptions()} </uui-combobox-list>
color="danger" </uui-combobox>
label=${this.localize.term('assignDomain_remove')} <uui-button
@click=${() => this.#onRemoveDomain(index)}> look="outline"
<uui-icon name="icon-trash"></uui-icon> color="danger"
</uui-button> label=${this.localize.term('assignDomain_remove')}
@click=${() => this.#onRemoveDomain(index)}>
<uui-icon name="icon-trash"></uui-icon>
</uui-button>
</div>
</div>
`, `,
)} )}
</div> </div>
@@ -229,6 +281,9 @@ export class UmbCultureAndHostnamesModalElement extends UmbModalBaseElement<
static override styles = [ static override styles = [
UmbTextStyles, UmbTextStyles,
css` css`
umb-property-layout[orientation='vertical'] {
padding: 0;
}
uui-button-group { uui-button-group {
width: 100%; width: 100%;
} }
@@ -241,12 +296,49 @@ export class UmbCultureAndHostnamesModalElement extends UmbModalBaseElement<
flex-grow: 0; flex-grow: 0;
} }
#domains { .hostname-item {
margin-top: var(--uui-size-layout-1); position: relative;
margin-bottom: var(--uui-size-2); display: flex;
gap: var(--uui-size-1);
align-items: center;
}
.hostname-wrapper {
position: relative;
flex: 1;
display: grid; display: grid;
grid-template-columns: 1fr 1fr auto; 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);
} }
`, `,
]; ];

View File

@@ -8,7 +8,7 @@
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@umbraco/json-models-builders": "^2.0.42", "@umbraco/json-models-builders": "^2.0.42",
"@umbraco/playwright-testhelpers": "^17.0.11", "@umbraco/playwright-testhelpers": "^17.0.12",
"camelize": "^1.0.0", "camelize": "^1.0.0",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"node-fetch": "^2.6.7" "node-fetch": "^2.6.7"
@@ -67,9 +67,9 @@
} }
}, },
"node_modules/@umbraco/playwright-testhelpers": { "node_modules/@umbraco/playwright-testhelpers": {
"version": "17.0.11", "version": "17.0.12",
"resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-17.0.11.tgz", "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-17.0.12.tgz",
"integrity": "sha512-+2zijm64oppD17NQg0om7ip1iFJsTQy0ugGgQamZvpf2mUPoGV2CpIz7enPY5YmrQerPacS/1riBMWx/eafqHA==", "integrity": "sha512-GhOj5ytXEY1sG8Nt6CAkJcqjxfRWUFKLl63SCk2quew/1rLCeaUV5I2+YJ3LkfQetMdDlqtMVZP7FdMk+iWJNQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@umbraco/json-models-builders": "2.0.42", "@umbraco/json-models-builders": "2.0.42",

View File

@@ -23,7 +23,7 @@
}, },
"dependencies": { "dependencies": {
"@umbraco/json-models-builders": "^2.0.42", "@umbraco/json-models-builders": "^2.0.42",
"@umbraco/playwright-testhelpers": "^17.0.11", "@umbraco/playwright-testhelpers": "^17.0.12",
"camelize": "^1.0.0", "camelize": "^1.0.0",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"node-fetch": "^2.6.7" "node-fetch": "^2.6.7"

View File

@@ -36,6 +36,7 @@ test('can add a culture', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => {
// Act // Act
await umbracoUi.content.clickActionsMenuForContent(contentName); await umbracoUi.content.clickActionsMenuForContent(contentName);
await umbracoUi.content.clickCultureAndHostnamesActionMenuOption(); await umbracoUi.content.clickCultureAndHostnamesActionMenuOption();
await umbracoUi.content.clickAddNewHostnameButton();
await umbracoUi.content.selectCultureLanguageOption(languageName); await umbracoUi.content.selectCultureLanguageOption(languageName);
await umbracoUi.content.clickSaveModalButton(); await umbracoUi.content.clickSaveModalButton();