diff --git a/src/Umbraco.Web.UI.Client/.storybook/preview.js b/src/Umbraco.Web.UI.Client/.storybook/preview.js index dbc166a597..5b0150c957 100644 --- a/src/Umbraco.Web.UI.Client/.storybook/preview.js +++ b/src/Umbraco.Web.UI.Client/.storybook/preview.js @@ -11,21 +11,15 @@ import { html } from 'lit-html'; import { initialize, mswDecorator } from 'msw-storybook-addon'; import { setCustomElements } from '@storybook/web-components'; -import { - UMB_DATA_TYPE_STORE_CONTEXT_TOKEN, - UmbDataTypeStore, -} from '../src/backoffice/settings/data-types/repository/data-type.store.ts'; -import { - UMB_DOCUMENT_TYPE_STORE_CONTEXT_TOKEN, - UmbDocumentTypeStore, -} from '../src/backoffice/documents/document-types/repository/document-type.store.ts'; +import { UmbDataTypeStore } from '../src/backoffice/settings/data-types/repository/data-type.store.ts'; +import { UmbDocumentTypeStore } from '../src/backoffice/documents/document-types/repository/document-type.store.ts'; import customElementManifests from '../custom-elements.json'; import { UmbIconStore } from '../libs/store/icon/icon.store'; import { onUnhandledRequest } from '../src/core/mocks/browser'; import { handlers } from '../src/core/mocks/browser-handlers'; import { LitElement } from 'lit'; -import { UmbModalService } from '../src/core/modal'; +import { UMB_MODAL_SERVICE_CONTEXT_TOKEN, UmbModalService } from '../src/core/modal'; // TODO: Fix storybook manifest registrations. @@ -71,7 +65,10 @@ const documentTypeStoreProvider = (story) => html` `; const modalServiceProvider = (story) => html` - + ${story()} @@ -94,7 +91,7 @@ export const parameters = { storySort: { method: 'alphabetical', includeNames: true, - order: ['Guides', ['Getting started'], '*'] + order: ['Guides', ['Getting started'], '*'], }, }, actions: { argTypesRegex: '^on.*' }, diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/search/manifests.ts b/src/Umbraco.Web.UI.Client/src/backoffice/search/manifests.ts index c69d4d6a02..ea025667f8 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/search/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/search/manifests.ts @@ -5,7 +5,7 @@ const headerApps: Array = [ type: 'headerApp', alias: 'Umb.HeaderApp.Search', name: 'Header App Search', - loader: () => import('src/backoffice/shared/components/header-app/header-app-button.element'), + loader: () => import('./umb-search-header-app.element'), weight: 10, meta: { label: 'Search', diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/search/umb-search-header-app.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/search/umb-search-header-app.element.ts new file mode 100644 index 0000000000..9518fb409f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/search/umb-search-header-app.element.ts @@ -0,0 +1,48 @@ +import { UUITextStyles } from '@umbraco-ui/uui-css/lib'; +import { css, CSSResultGroup, html } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import { UmbModalService, UMB_MODAL_SERVICE_CONTEXT_TOKEN } from '@umbraco-cms/modal'; +import { UmbLitElement } from '@umbraco-cms/element'; + +@customElement('umb-search-header-app') +export class UmbSearchHeaderApp extends UmbLitElement { + static styles: CSSResultGroup = [ + UUITextStyles, + css` + uui-button { + font-size: 18px; + --uui-button-background-color: transparent; + } + `, + ]; + + private _modalService?: UmbModalService; + + constructor() { + super(); + + this.consumeContext(UMB_MODAL_SERVICE_CONTEXT_TOKEN, (_instance) => { + this._modalService = _instance; + }); + } + + #onSearchClick() { + this._modalService?.search(); + } + + render() { + return html` + + + + `; + } +} + +export default UmbSearchHeaderApp; + +declare global { + interface HTMLElementTagNameMap { + 'umb-search-header-app': UmbSearchHeaderApp; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/language-picker/language-picker-modal-layout.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/language-picker/language-picker-modal-layout.element.ts index 7e5f9af383..f4f9d72886 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/language-picker/language-picker-modal-layout.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/language-picker/language-picker-modal-layout.element.ts @@ -4,8 +4,8 @@ import { customElement, state } from 'lit/decorators.js'; import { repeat } from 'lit-html/directives/repeat.js'; import { UUIMenuItemElement, UUIMenuItemEvent } from '@umbraco-ui/uui'; import { ifDefined } from 'lit-html/directives/if-defined.js'; -import { UmbModalLayoutPickerBase } from '../../../../core/modal/layouts/modal-layout-picker-base'; import { UmbLanguageRepository } from '../repository/language.repository'; +import { UmbModalLayoutPickerBase } from '../../../../core/modal/layouts/modal-layout-picker-base'; import { LanguageModel } from '@umbraco-cms/backend-api'; export interface UmbLanguagePickerModalData { @@ -14,7 +14,7 @@ export interface UmbLanguagePickerModalData { } @customElement('umb-language-picker-modal-layout') -export class UmbLanguagePickerModalLayoutElement extends UmbModalLayoutPickerBase { +export class UmbLanguagePickerModalLayoutElement extends UmbModalLayoutPickerBase { static styles = [UUITextStyles, css``]; @state() @@ -35,11 +35,19 @@ export class UmbLanguagePickerModalLayoutElement extends UmbModalLayoutPickerBas this.handleSelection(isoCode); } + get #filteredLanguages() { + if (this.data?.filter) { + return this._languages.filter(this.data.filter); + } else { + return this._languages; + } + } + render() { return html` ${repeat( - this._languages, + this.#filteredLanguages, (item) => item.isoCode, (item) => html` (undefined); data = this.#data.asObservable(); + // TODO: this is a temp solution to bubble validation errors to the UI + #validationErrors = new ObjectState(undefined); + validationErrors = this.#validationErrors.asObservable(); + constructor(host: UmbControllerHostInterface) { super(host); this.#host = host; @@ -60,6 +64,12 @@ export class UmbLanguageWorkspaceContext extends UmbWorkspaceContext { this.#data.update({ fallbackIsoCode: isoCode }); } + // TODO: this is a temp solution to bubble validation errors to the UI + setValidationErrors(errorMap: any) { + // TODO: I can't use the update method to set the value to undefined + this.#validationErrors.next(errorMap); + } + destroy(): void { this.#data.complete(); } diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language/views/edit/edit-language-workspace-view.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language/views/edit/edit-language-workspace-view.element.ts index 02c86e198b..c6b97de9bd 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language/views/edit/edit-language-workspace-view.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/settings/languages/workspace/language/views/edit/edit-language-workspace-view.element.ts @@ -32,12 +32,15 @@ export class UmbEditLanguageWorkspaceViewElement extends UmbLitElement { #default-language-warning { background-color: var(--uui-color-warning); color: var(--uui-color-warning-contrast); - border-color: var(--uui-color-warning-standalone); padding: var(--uui-size-space-4) var(--uui-size-space-5); - border: 1px solid; + border: 1px solid var(--uui-color-warning-standalone); margin-top: var(--uui-size-space-4); border-radius: var(--uui-border-radius); } + + .validation-message { + color: var(--uui-color-danger); + } `, ]; @@ -50,11 +53,16 @@ export class UmbEditLanguageWorkspaceViewElement extends UmbLitElement { @state() _isNew = false; + @state() + _validationErrors?: { [key: string]: Array }; + #languageWorkspaceContext?: UmbLanguageWorkspaceContext; constructor() { super(); + /* TODO: we will need some system to notify about an action has been executed. + In the language workspace we want to clear a default language change warning and reset the initial state after a save action has been executed. */ let initialStateSet = false; this.consumeContext('umbWorkspaceContext', (instance) => { @@ -75,6 +83,11 @@ export class UmbEditLanguageWorkspaceViewElement extends UmbLitElement { this.observe(this.#languageWorkspaceContext.isNew, (value) => { this._isNew = value; }); + + this.observe(this.#languageWorkspaceContext.validationErrors, (value) => { + this._validationErrors = value; + this.requestUpdate('_validationErrors'); + }); }); } @@ -99,8 +112,8 @@ export class UmbEditLanguageWorkspaceViewElement extends UmbLitElement { this.#languageWorkspaceContext?.setCulture(isoCode); - // If the language name is not set, we set it to the name of the selected language. - if (!this._language?.name && cultureName) { + // to improve UX, we set the name to the culture name if it's a new language + if (this._isNew && cultureName) { this.#languageWorkspaceContext?.setName(cultureName); } } @@ -135,10 +148,16 @@ export class UmbEditLanguageWorkspaceViewElement extends UmbLitElement {
+ + + + ${this._validationErrors?.isoCode.map( + (isoCodeError) => html`
${isoCodeError}
` + )}
@@ -157,7 +176,6 @@ export class UmbEditLanguageWorkspaceViewElement extends UmbLitElement {
An Umbraco site can only have one default language set.
- ${this._language.isDefault !== this._isDefaultLanguage ? html`
@@ -182,7 +200,9 @@ export class UmbEditLanguageWorkspaceViewElement extends UmbLitElement { value=${ifDefined(this._language.fallbackIsoCode === null ? undefined : this._language.fallbackIsoCode)} slot="editor" max="1" - @change=${this.#handleFallbackChange}> + @change=${this.#handleFallbackChange} + .filter=${(language: LanguageModel) => + language.isoCode !== this._language?.isoCode}> `; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/index.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/index.ts index 633821a043..3fedc54bdd 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/index.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/index.ts @@ -8,6 +8,7 @@ import './backoffice-frame/backoffice-main.element'; import './backoffice-frame/backoffice-modal-container.element'; import './backoffice-frame/backoffice-notification-container.element'; import './code-block/code-block.element'; +import './debug/debug.element'; import './dropdown/dropdown.element'; import './empty-state/empty-state.element'; import './extension-slot/extension-slot.element'; @@ -31,5 +32,3 @@ import './workspace/workspace-action-menu/workspace-action-menu.element'; import './workspace/workspace-action/workspace-action.element'; import './workspace/workspace-content/workspace-content.element'; import './workspace/workspace-layout/workspace-layout.element'; - -import './debug/debug.element'; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-culture-select/input-culture-select.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-culture-select/input-culture-select.element.ts index bdcf53b259..3e4d61e854 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-culture-select/input-culture-select.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-culture-select/input-culture-select.element.ts @@ -5,8 +5,8 @@ import { FormControlMixin } from '@umbraco-ui/uui-base/lib/mixins'; import { ifDefined } from 'lit-html/directives/if-defined.js'; import { repeat } from 'lit/directives/repeat.js'; import { UUIComboboxElement, UUIComboboxEvent } from '@umbraco-ui/uui'; -import { UmbCultureRepository } from '../../../settings/cultures/repository/culture.repository'; import { UmbChangeEvent } from '@umbraco-cms/events'; +import { UmbCultureRepository } from '../../../settings/cultures/repository/culture.repository'; import { UmbLitElement } from '@umbraco-cms/element'; import { CultureModel } from '@umbraco-cms/backend-api'; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-language-picker/input-language-picker.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-language-picker/input-language-picker.element.ts index 117b74c928..b44005a3df 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-language-picker/input-language-picker.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-language-picker/input-language-picker.element.ts @@ -3,9 +3,9 @@ import { UUITextStyles } from '@umbraco-ui/uui-css/lib'; import { customElement, property, state } from 'lit/decorators.js'; import { ifDefined } from 'lit-html/directives/if-defined.js'; import { FormControlMixin } from '@umbraco-ui/uui-base/lib/mixins'; +import { UmbChangeEvent } from '@umbraco-cms/events'; import { UmbModalService, UMB_MODAL_SERVICE_CONTEXT_TOKEN } from '../../../../core/modal'; import { UmbLanguageRepository } from '../../../settings/languages/repository/language.repository'; -import { UmbChangeEvent } from '@umbraco-cms/events'; import { UmbLitElement } from '@umbraco-cms/element'; import type { LanguageModel } from '@umbraco-cms/backend-api'; import type { UmbObserverController } from '@umbraco-cms/observable-api'; @@ -56,6 +56,9 @@ export class UmbInputLanguagePickerElement extends FormControlMixin(UmbLitElemen @property({ type: String, attribute: 'min-message' }) maxMessage = 'This field exceeds the allowed amount of items'; + @property({ type: Object, attribute: false }) + public filter: (language: LanguageModel) => boolean = () => true; + private _selectedIsoCodes: Array = []; public get selectedIsoCodes(): Array { return this._selectedIsoCodes; @@ -116,17 +119,15 @@ export class UmbInputLanguagePickerElement extends FormControlMixin(UmbLitElemen } private _openPicker() { - /* - TODO: re implement when language picker PR is merged const modalHandler = this._modalService?.languagePicker({ multiple: this.max === 1 ? false : true, selection: [...this._selectedIsoCodes], + filter: this.filter, }); modalHandler?.onClose().then(({ selection }: any) => { this._setSelection(selection); }); - */ } private _removeItem(item: LanguageModel) { diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-list-base/input-list-base.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-list-base/input-list-base.ts index 847b7b9155..78f2d3d54a 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-list-base/input-list-base.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-list-base/input-list-base.ts @@ -1,7 +1,7 @@ import { html } from 'lit'; import { property } from 'lit/decorators.js'; import { UUIModalSidebarSize } from '@umbraco-ui/uui-modal-sidebar'; -import { UmbPickerData } from '../../../../core/modal/layouts/modal-layout-picker-base'; +import { UmbPickerModalData } from '../../../../core/modal/layouts/modal-layout-picker-base'; import { UmbModalService, UmbModalType, UMB_MODAL_SERVICE_CONTEXT_TOKEN } from '../../../../core/modal'; //TODO: These should probably be imported dynamically. @@ -45,7 +45,7 @@ export class UmbInputListBase extends UmbLitElement { selection: this.value, }, }); - modalHandler?.onClose().then((data: UmbPickerData) => { + modalHandler?.onClose().then((data: UmbPickerModalData) => { if (data) { this.value = data.selection; this.selectionUpdated(); diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/section/section-sidebar/section-sidebar.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/section/section-sidebar/section-sidebar.element.ts index e487e5b59a..d388e8bfc2 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/section/section-sidebar/section-sidebar.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/section/section-sidebar/section-sidebar.element.ts @@ -1,11 +1,11 @@ import { UUITextStyles } from '@umbraco-ui/uui-css/lib'; import { css, html } from 'lit'; import { customElement } from 'lit/decorators.js'; +import { UmbSectionSidebarContext, UMB_SECTION_SIDEBAR_CONTEXT_TOKEN } from './section-sidebar.context'; +import { UmbLitElement } from '@umbraco-cms/element'; import '../../tree/context-menu/tree-context-menu.service'; import '../section-sidebar-context-menu/section-sidebar-context-menu.element'; -import { UmbSectionSidebarContext, UMB_SECTION_SIDEBAR_CONTEXT_TOKEN } from './section-sidebar.context'; -import { UmbLitElement } from '@umbraco-cms/element'; @customElement('umb-section-sidebar') export class UmbSectionSidebarElement extends UmbLitElement { @@ -22,6 +22,11 @@ export class UmbSectionSidebarElement extends UmbLitElement { flex-direction: column; z-index: 10; } + + #scroll-container { + height: 100%; + overflow-y: auto; + } `, ]; @@ -35,7 +40,7 @@ export class UmbSectionSidebarElement extends UmbLitElement { render() { return html` - + diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/section/section.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/section/section.element.ts index 450d90d34f..03440f01c1 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/section/section.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/section/section.element.ts @@ -45,6 +45,12 @@ export class UmbSectionElement extends UmbLitElement { @state() private _views?: Array; + @state() + private _sectionLabel = ''; + + @state() + private _sectionPathname = ''; + private _workspaces?: Array; private _sectionContext?: UmbSectionContext; private _sectionAlias?: string; @@ -194,6 +200,8 @@ export class UmbSectionElement extends UmbLitElement { ${this._menus && this._menus.length > 0 ? html` + + items.meta.sections.includes(this._sectionAlias || '')} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/workspace/workspace-context/workspace-context.interface.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/workspace/workspace-context/workspace-context.interface.ts index 6b6a0332a9..e372dfc228 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/workspace/workspace-context/workspace-context.interface.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/workspace/workspace-context/workspace-context.interface.ts @@ -7,4 +7,6 @@ export interface UmbWorkspaceContextInterface { getEntityType(): string; getData(): T; destroy(): void; + // TODO: temp solution to bubble validation errors to the UI + setValidationErrors?(errorMap: any): void; } diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/workspace-actions/save.action.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/workspace-actions/save.action.ts index 7c51ab0d96..4719ead35b 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/workspace-actions/save.action.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/workspace-actions/save.action.ts @@ -8,6 +8,10 @@ export class UmbSaveWorkspaceAction extends UmbWorkspaceAction { return this.data.find((item) => item.isoCode === key); } + insert(language: LanguageModel) { + const foundIndex = this.data.findIndex((item) => item.isoCode === language.isoCode); + + if (foundIndex !== -1) { + throw new Error('Language with same iso code already exists'); + } + + this.data.push(language); + } + save(saveItems: Array) { saveItems.forEach((saveItem) => { const foundIndex = this.data.findIndex((item) => item.isoCode === saveItem.isoCode); diff --git a/src/Umbraco.Web.UI.Client/src/core/mocks/domains/language.handlers.ts b/src/Umbraco.Web.UI.Client/src/core/mocks/domains/language.handlers.ts index a4399f0cf8..153894b174 100644 --- a/src/Umbraco.Web.UI.Client/src/core/mocks/domains/language.handlers.ts +++ b/src/Umbraco.Web.UI.Client/src/core/mocks/domains/language.handlers.ts @@ -1,7 +1,6 @@ import { rest } from 'msw'; -import { v4 as uuidv4 } from 'uuid'; import { umbLanguagesData } from '../data/languages.data'; -import { LanguageModel } from '@umbraco-cms/backend-api'; +import { LanguageModel, ProblemDetailsModel } from '@umbraco-cms/backend-api'; import { umbracoPath } from '@umbraco-cms/utils'; // TODO: add schema @@ -36,12 +35,22 @@ export const handlers = [ if (!data) return; - data.id = umbLanguagesData.getAll().length + 1; - data.key = uuidv4(); - - umbLanguagesData.save([data]); - - return res(ctx.status(201)); + try { + umbLanguagesData.insert(data); + return res(ctx.status(201)); + } catch (error) { + return res( + ctx.status(400), + ctx.json({ + status: 400, + type: 'validation', + detail: 'Something went wrong', + errors: { + isoCode: ['Language with same iso code already exists'], + }, + }) + ); + } }), rest.put(umbracoPath('/language/:key'), async (req, res, ctx) => { diff --git a/src/Umbraco.Web.UI.Client/src/core/modal/layouts/modal-layout-picker-base.ts b/src/Umbraco.Web.UI.Client/src/core/modal/layouts/modal-layout-picker-base.ts index 51f0b8cc03..7c84bd0a43 100644 --- a/src/Umbraco.Web.UI.Client/src/core/modal/layouts/modal-layout-picker-base.ts +++ b/src/Umbraco.Web.UI.Client/src/core/modal/layouts/modal-layout-picker-base.ts @@ -1,16 +1,17 @@ import { property } from 'lit/decorators.js'; import { UmbModalLayoutElement } from '..'; -export interface UmbPickerData { +export interface UmbPickerModalData { multiple: boolean; - selection: Array; + selection: Array; + filter?: (language: T) => boolean; } // TODO: we should consider moving this into a class/context instead of an element. // So we don't have to extend an element to get basic picker/selection logic -export class UmbModalLayoutPickerBase extends UmbModalLayoutElement> { +export class UmbModalLayoutPickerBase extends UmbModalLayoutElement> { @property() - selection: Array = []; + selection: Array = []; connectedCallback(): void { super.connectedCallback(); @@ -25,14 +26,14 @@ export class UmbModalLayoutPickerBase extends UmbModalLayou this.modalHandler?.close(); } - protected _handleKeydown(e: KeyboardEvent, key: selectType) { + protected _handleKeydown(e: KeyboardEvent, key: string) { if (e.key === 'Enter') { this.handleSelection(key); } } /* TODO: Write test for this select/deselect method. */ - handleSelection(key: selectType) { + handleSelection(key: string) { if (this.data?.multiple) { if (this.isSelected(key)) { this.selection = this.selection.filter((selectedKey) => selectedKey !== key); @@ -46,7 +47,7 @@ export class UmbModalLayoutPickerBase extends UmbModalLayou this.requestUpdate('_selection'); } - isSelected(key: selectType): boolean { + isSelected(key: string): boolean { return this.selection.includes(key); } } diff --git a/src/Umbraco.Web.UI.Client/src/core/modal/layouts/picker-section/picker-layout-section.element.ts b/src/Umbraco.Web.UI.Client/src/core/modal/layouts/picker-section/picker-layout-section.element.ts index 361e18acc1..ff0470d6c7 100644 --- a/src/Umbraco.Web.UI.Client/src/core/modal/layouts/picker-section/picker-layout-section.element.ts +++ b/src/Umbraco.Web.UI.Client/src/core/modal/layouts/picker-section/picker-layout-section.element.ts @@ -6,7 +6,7 @@ import { umbExtensionsRegistry } from '@umbraco-cms/extensions-api'; import type { ManifestSection } from '@umbraco-cms/models'; @customElement('umb-picker-layout-section') -export class UmbPickerLayoutSectionElement extends UmbModalLayoutPickerBase { +export class UmbPickerLayoutSectionElement extends UmbModalLayoutPickerBase { static styles = [ UUITextStyles, css` diff --git a/src/Umbraco.Web.UI.Client/src/core/modal/layouts/picker-user-group/picker-layout-user-group.element.ts b/src/Umbraco.Web.UI.Client/src/core/modal/layouts/picker-user-group/picker-layout-user-group.element.ts index 4e32140f45..9d103d4d19 100644 --- a/src/Umbraco.Web.UI.Client/src/core/modal/layouts/picker-user-group/picker-layout-user-group.element.ts +++ b/src/Umbraco.Web.UI.Client/src/core/modal/layouts/picker-user-group/picker-layout-user-group.element.ts @@ -7,7 +7,7 @@ import type { UmbUserGroupStore } from '../../../../backoffice/users/user-groups import type { UserGroupDetails } from '@umbraco-cms/models'; @customElement('umb-picker-layout-user-group') -export class UmbPickerLayoutUserGroupElement extends UmbModalLayoutPickerBase { +export class UmbPickerLayoutUserGroupElement extends UmbModalLayoutPickerBase { static styles = [ UUITextStyles, css` diff --git a/src/Umbraco.Web.UI.Client/src/core/modal/layouts/picker-user/picker-layout-user.element.ts b/src/Umbraco.Web.UI.Client/src/core/modal/layouts/picker-user/picker-layout-user.element.ts index ca74c4aefb..b2c0ddbb56 100644 --- a/src/Umbraco.Web.UI.Client/src/core/modal/layouts/picker-user/picker-layout-user.element.ts +++ b/src/Umbraco.Web.UI.Client/src/core/modal/layouts/picker-user/picker-layout-user.element.ts @@ -6,7 +6,7 @@ import { UmbUserStore, UMB_USER_STORE_CONTEXT_TOKEN } from '../../../../backoffi import type { UserDetails } from '@umbraco-cms/models'; @customElement('umb-picker-layout-user') -export class UmbPickerLayoutUserElement extends UmbModalLayoutPickerBase { +export class UmbPickerLayoutUserElement extends UmbModalLayoutPickerBase { static styles = [ UUITextStyles, css` diff --git a/src/Umbraco.Web.UI.Client/src/core/modal/layouts/search/modal-layout-search.element.ts b/src/Umbraco.Web.UI.Client/src/core/modal/layouts/search/modal-layout-search.element.ts new file mode 100644 index 0000000000..df4ef06d2f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/core/modal/layouts/search/modal-layout-search.element.ts @@ -0,0 +1,317 @@ +import { UUITextStyles } from '@umbraco-ui/uui-css'; +import { css, html, LitElement, nothing } from 'lit'; +import { repeat } from 'lit-html/directives/repeat.js'; +import { customElement, query, state } from 'lit/decorators.js'; + +export type SearchItem = { + name: string; + icon?: string; + href: string; + parent: string; + url?: string; +}; +export type SearchGroupItem = { + name: string; + items: Array; +}; +@customElement('umb-modal-layout-search') +export class UmbModalLayoutSearchElement extends LitElement { + static styles = [ + UUITextStyles, + css` + :host { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + height: 100%; + background-color: var(--uui-color-background); + box-sizing: border-box; + color: var(--uui-color-text); + font-size: 1rem; + } + input { + all: unset; + height: 100%; + width: 100%; + } + #search-icon, + #close-icon { + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 1; + height: 100%; + } + #close-icon { + padding: 0 var(--uui-size-space-4); + } + #close-icon > button { + background: var(--uui-color-surface-alt); + border: 1px solid var(--uui-color-border); + padding: 3px 6px 4px 6px; + line-height: 1; + border-radius: 3px; + color: var(--uui-color-text-alt); + font-weight: 800; + font-size: 12px; + cursor: pointer; + } + #close-icon > button:hover { + border-color: var(--uui-color-focus); + color: var(--uui-color-focus); + } + #top { + background-color: var(--uui-color-surface); + display: flex; + height: 48px; + } + #main { + display: flex; + flex-direction: column; + padding: 0px var(--uui-size-space-6) var(--uui-size-space-5) var(--uui-size-space-6); + height: 100%; + border-top: 1px solid var(--uui-color-border); + } + .group { + margin-top: var(--uui-size-space-4); + } + .group-name { + font-weight: 600; + margin-bottom: var(--uui-size-space-1); + } + .group-items { + display: flex; + flex-direction: column; + gap: var(--uui-size-space-3); + } + .item { + background: var(--uui-color-surface); + border: 1px solid var(--uui-color-border); + padding: var(--uui-size-space-3) var(--uui-size-space-4); + border-radius: var(--uui-border-radius); + color: var(--uui-color-interactive); + display: grid; + grid-template-columns: var(--uui-size-space-6) 1fr var(--uui-size-space-5); + height: min-content; + align-items: center; + } + .item:hover { + background-color: var(--uui-color-surface-emphasis); + color: var(--uui-color-interactive-emphasis); + } + .item:hover .item-symbol { + font-weight: unset; + opacity: 1; + } + .item-icon { + margin-bottom: auto; + margin-top: 5px; + } + .item-icon, + .item-symbol { + opacity: 0.4; + } + .item-url { + font-size: 0.8rem; + line-height: 1.2; + font-weight: 100; + } + .item-name { + display: flex; + flex-direction: column; + } + .item-icon > * { + height: 1rem; + display: flex; + width: min-content; + } + .item-symbol { + font-weight: 100; + } + a { + text-decoration: none; + color: inherit; + } + #no-results { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + margin-top: var(--uui-size-space-5); + color: var(--uui-color-text-alt); + } + `, + ]; + + @query('input') + private _input!: HTMLInputElement; + + @state() + private _search = ''; + + @state() + private _groups: Array = []; + + connectedCallback() { + super.connectedCallback(); + + requestAnimationFrame(() => { + this._input.focus(); + }); + } + + #onSearchChange(event: InputEvent) { + const target = event.target as HTMLInputElement; + this._search = target.value; + + this.#updateGroups(); + } + + #onClearSearch() { + this._search = ''; + this._input.value = ''; + this._input.focus(); + this.#updateGroups(); + } + + #updateGroups() { + const filtered = this.#mockData.filter((item) => { + return item.name.toLowerCase().includes(this._search.toLowerCase()); + }); + + const grouped: Array = filtered.reduce((acc, item) => { + const group = acc.find((group) => group.name === item.parent); + if (group) { + group.items.push(item); + } else { + acc.push({ + name: item.parent, + items: [item], + }); + } + return acc; + }, [] as Array); + + this._groups = grouped; + } + + render() { + return html` +
+
+ +
+ +
+ +
+
+ ${this._search + ? html`
+ ${this._groups.length > 0 + ? repeat( + this._groups, + (group) => group.name, + (group) => this.#renderGroup(group.name, group.items) + ) + : html`
Only mock data for now Search for blog
`} +
` + : nothing} + `; + } + + #renderGroup(name: string, items: Array) { + return html` +
+
${name}
+
${repeat(items, (item) => item.name, this.#renderItem.bind(this))}
+
+ `; + } + + #renderItem(item: SearchItem) { + return html` + + + ${item.icon ? html`` : this.#renderHashTag()} + + + ${item.name} ${item.url ? html`${item.url}` : nothing} + + > + + `; + } + + #renderHashTag() { + return html` + + + + + `; + } + + #mockData: Array = [ + { + name: 'Blog', + href: '#', + icon: 'umb:thumbnail-list', + parent: 'Content', + url: '/blog/', + }, + { + name: 'Popular blogs', + href: '#', + icon: 'umb:article', + parent: 'Content', + url: '/blog/popular-blogs/', + }, + { + name: 'How to write a blog', + href: '#', + icon: 'umb:article', + parent: 'Content', + url: '/blog/how-to-write-a-blog/', + }, + { + name: 'Blog hero', + href: '#', + icon: 'umb:picture', + parent: 'Media', + }, + { + name: 'Contact form for blog', + href: '#', + parent: 'Document Types', + }, + { + name: 'Blog', + href: '#', + parent: 'Document Types', + }, + { + name: 'Blog link item', + href: '#', + parent: 'Document Types', + }, + ]; +} + +export default UmbModalLayoutSearchElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-modal-layout-search': UmbModalLayoutSearchElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/core/modal/modal.service.ts b/src/Umbraco.Web.UI.Client/src/core/modal/modal.service.ts index 370f536a5a..86b768f440 100644 --- a/src/Umbraco.Web.UI.Client/src/core/modal/modal.service.ts +++ b/src/Umbraco.Web.UI.Client/src/core/modal/modal.service.ts @@ -7,9 +7,12 @@ import './layouts/modal-layout-current-user.element'; import './layouts/icon-picker/modal-layout-icon-picker.element'; import '../../backoffice/settings/languages/language-picker/language-picker-modal-layout.element'; import './layouts/link-picker/modal-layout-link-picker.element'; +import './layouts/basic/modal-layout-basic.element'; +import './layouts/search/modal-layout-search.element.ts'; import { UUIModalSidebarSize } from '@umbraco-ui/uui-modal-sidebar'; import { BehaviorSubject } from 'rxjs'; +import type { UUIModalDialogElement } from '@umbraco-ui/uui-modal-dialog'; import { UmbModalChangePasswordData } from './layouts/modal-layout-change-password.element'; import type { UmbModalIconPickerData } from './layouts/icon-picker/modal-layout-icon-picker.element'; import type { UmbModalConfirmData } from './layouts/confirm/modal-layout-confirm.element'; @@ -18,8 +21,10 @@ import type { UmbModalPropertyEditorUIPickerData } from './layouts/property-edit import type { UmbModalMediaPickerData } from './layouts/media-picker/modal-layout-media-picker.element'; import type { UmbModalLinkPickerData } from './layouts/link-picker/modal-layout-link-picker.element'; import { UmbModalHandler } from './modal-handler'; -import { UmbBasicModalData } from './layouts/basic/modal-layout-basic.element'; +import type { UmbBasicModalData } from './layouts/basic/modal-layout-basic.element'; +import { UmbPickerModalData } from './layouts/modal-layout-picker-base'; import { UmbContextToken } from '@umbraco-cms/context-api'; +import { LanguageModel } from '@umbraco-cms/backend-api'; export type UmbModalType = 'dialog' | 'sidebar'; @@ -127,6 +132,16 @@ export class UmbModalService { return this.open('umb-modal-layout-change-password', { data, type: 'dialog' }); } + /** + * Opens a language picker sidebar modal + * @public + * @return {*} {UmbModalHandler} + * @memberof UmbModalService + */ + public languagePicker(data: UmbPickerModalData): UmbModalHandler { + return this.open('umb-language-picker-modal-layout', { data, type: 'sidebar' }); + } + /** * Opens a basic sidebar modal to display readonly information * @public @@ -141,6 +156,41 @@ export class UmbModalService { }); } + public search(): UmbModalHandler { + const modalHandler = new UmbModalHandler('umb-modal-layout-search'); + + //TODO START: This is a hack to get the search modal layout to look like i want it to. + //TODO: Remove from here to END when the modal system is more flexible + const topDistance = '50%'; + const margin = '16px'; + const maxHeight = '600px'; + const maxWidth = '500px'; + const dialog = document.createElement('dialog') as HTMLDialogElement; + dialog.style.top = `max(${margin}, calc(${topDistance} - ${maxHeight} / 2))`; + dialog.style.margin = '0 auto'; + dialog.style.transform = `translateY(${-maxHeight})`; + dialog.style.maxHeight = `min(${maxHeight}, calc(100% - ${margin}px * 2))`; + dialog.style.width = `min(${maxWidth}, calc(100vw - ${margin}))`; + dialog.style.boxSizing = 'border-box'; + dialog.style.background = 'none'; + dialog.style.border = 'none'; + dialog.style.padding = '0'; + dialog.style.boxShadow = 'var(--uui-shadow-depth-5)'; + dialog.style.borderRadius = '9px'; + const search = document.createElement('umb-modal-layout-search'); + dialog.appendChild(search); + requestAnimationFrame(() => { + dialog.showModal(); + }); + modalHandler.element = dialog as unknown as UUIModalDialogElement; + //TODO END + + modalHandler.element.addEventListener('close-end', () => this._handleCloseEnd(modalHandler)); + + this.#modals.next([...this.#modals.getValue(), modalHandler]); + return modalHandler; + } + /** * Opens a modal or sidebar modal * @public