Merge remote-tracking branch 'origin/main' into feature/document-variants

# Conflicts:
#	src/backoffice/backoffice.element.ts
#	src/backoffice/documents/documents/workspace/document-workspace.context.ts
#	src/backoffice/settings/languages/language-picker/language-picker-modal-layout.element.ts
#	src/backoffice/settings/languages/workspace/language/language-workspace.context.ts
#	src/backoffice/settings/languages/workspace/language/views/edit/edit-language-workspace-view.element.ts
#	src/backoffice/shared/components/index.ts
#	src/backoffice/shared/components/input-culture-select/input-culture-select.element.ts
#	src/backoffice/shared/components/input-language-picker/input-language-picker.element.ts
#	src/backoffice/shared/components/section/section-sidebar/section-sidebar.element.ts
#	src/backoffice/shared/components/section/section.element.ts
#	src/backoffice/shared/components/workspace/workspace-context/workspace-entity-context.interface.ts
#	src/backoffice/shared/workspace-actions/save.action.ts
#	src/backoffice/translation/dashboards/dictionary/dashboard-translation-dictionary.element.ts
#	src/backoffice/translation/dictionary/entity-actions/create/create.action.ts
#	src/backoffice/users/users/workspace/user-workspace.context.ts
#	src/core/mocks/data/languages.data.ts
#	src/core/mocks/domains/language.handlers.ts
#	src/core/modal/layouts/modal-layout-picker-base.ts
#	src/core/modal/modal.service.ts
This commit is contained in:
Niels Lyngsø
2023-02-23 10:03:11 +01:00
23 changed files with 565 additions and 59 deletions

View File

@@ -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`
<umb-context-provider style="display: block; padding: 32px;" key="umbModalService" .value=${new UmbModalService()}>
<umb-context-provider
style="display: block; padding: 32px;"
key="${UMB_MODAL_SERVICE_CONTEXT_TOKEN}"
.value=${new UmbModalService()}>
${story()}
<umb-backoffice-modal-container></umb-backoffice-modal-container>
</umb-context-provider>
@@ -94,7 +91,7 @@ export const parameters = {
storySort: {
method: 'alphabetical',
includeNames: true,
order: ['Guides', ['Getting started'], '*']
order: ['Guides', ['Getting started'], '*'],
},
},
actions: { argTypesRegex: '^on.*' },

View File

@@ -5,7 +5,7 @@ const headerApps: Array<ManifestHeaderApp> = [
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',

View File

@@ -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`
<uui-button @click=${this.#onSearchClick} look="primary" label="search" compact>
<uui-icon name="umb:search"></uui-icon>
</uui-button>
`;
}
}
export default UmbSearchHeaderApp;
declare global {
interface HTMLElementTagNameMap {
'umb-search-header-app': UmbSearchHeaderApp;
}
}

View File

@@ -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<LanguageModel> {
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`<umb-body-layout headline="Select languages">
<uui-box>
${repeat(
this._languages,
this.#filteredLanguages,
(item) => item.isoCode,
(item) => html`
<uui-menu-item

View File

@@ -11,6 +11,10 @@ export class UmbLanguageWorkspaceContext extends UmbWorkspaceContext {
#data = new ObjectState<LanguageModel | undefined>(undefined);
data = this.#data.asObservable();
// TODO: this is a temp solution to bubble validation errors to the UI
#validationErrors = new ObjectState<any | undefined>(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();
}

View File

@@ -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<any> };
#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<UmbLanguageWorkspaceContext>('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 {
<uui-box>
<umb-workspace-property-layout label="Language">
<div slot="editor">
<!-- TODO: disable already created cultures in the select -->
<umb-input-culture-select
value=${ifDefined(this._language.isoCode)}
@change=${this.#handleCultureChange}
?readonly=${this._isNew === false}></umb-input-culture-select>
<!-- TEMP VALIDATION ERROR -->
${this._validationErrors?.isoCode.map(
(isoCodeError) => html`<div class="validation-message">${isoCodeError}</div>`
)}
</div>
</umb-workspace-property-layout>
@@ -157,7 +176,6 @@ export class UmbEditLanguageWorkspaceViewElement extends UmbLitElement {
<div>An Umbraco site can only have one default language set.</div>
</div>
</uui-toggle>
<!-- TODO: we need a UUI component for this -->
${this._language.isDefault !== this._isDefaultLanguage
? html`<div id="default-language-warning">
@@ -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}></umb-input-language-picker>
@change=${this.#handleFallbackChange}
.filter=${(language: LanguageModel) =>
language.isoCode !== this._language?.isoCode}></umb-input-language-picker>
</umb-workspace-property-layout>
</uui-box>
`;

View File

@@ -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';

View File

@@ -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';

View File

@@ -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<string> = [];
public get selectedIsoCodes(): Array<string> {
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) {

View File

@@ -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<string>) => {
modalHandler?.onClose().then((data: UmbPickerModalData<string>) => {
if (data) {
this.value = data.selection;
this.selectionUpdated();

View File

@@ -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`
<umb-section-sidebar-context-menu>
<uui-scroll-container>
<uui-scroll-container id="scroll-container">
<slot></slot>
</uui-scroll-container>
</umb-section-sidebar-context-menu>

View File

@@ -45,6 +45,12 @@ export class UmbSectionElement extends UmbLitElement {
@state()
private _views?: Array<ManifestSectionView>;
@state()
private _sectionLabel = '';
@state()
private _sectionPathname = '';
private _workspaces?: Array<ManifestWorkspace>;
private _sectionContext?: UmbSectionContext;
private _sectionAlias?: string;
@@ -194,6 +200,8 @@ export class UmbSectionElement extends UmbLitElement {
${this._menus && this._menus.length > 0
? html`
<umb-section-sidebar>
<!-- TODO: this should be an extension point and only shown in the content section sidebar -->
<umb-app-language-select></umb-app-language-select>
<umb-extension-slot
type="sidebarMenu"
.filter=${(items: ManifestSidebarMenu) => items.meta.sections.includes(this._sectionAlias || '')}

View File

@@ -7,4 +7,6 @@ export interface UmbWorkspaceContextInterface<T = unknown> {
getEntityType(): string;
getData(): T;
destroy(): void;
// TODO: temp solution to bubble validation errors to the UI
setValidationErrors?(errorMap: any): void;
}

View File

@@ -8,6 +8,10 @@ export class UmbSaveWorkspaceAction extends UmbWorkspaceAction<any, UmbWorkspace
super(host, repositoryAlias);
}
/* TODO: we need a solution for all actions to notify the system that is has been executed.
There might be cases where we need to do something after the action has been executed.
Ex. "reset" a workspace after a save action has been executed.
*/
async execute() {
if (!this.workspaceContext) return;
// TODO: it doesn't get the updated value
@@ -15,11 +19,28 @@ export class UmbSaveWorkspaceAction extends UmbWorkspaceAction<any, UmbWorkspace
// TODO: handle errors
if (!data) return;
if (this.workspaceContext.getIsNew()) {
await this.repository?.create(data);
this.workspaceContext.setIsNew(false);
this.workspaceContext.getIsNew() ? this.#create(data) : this.#update(data);
}
async #create(data: any) {
if (!this.workspaceContext) return;
// TODO: preferably the actions dont talk directly with repository, but instead with its context.
const { error } = await this.repository.create(data);
// TODO: this is temp solution to bubble validation errors to the UI
if (error) {
if (error.type === 'validation') {
this.workspaceContext.setValidationErrors?.(error.errors);
}
} else {
await this.repository?.save(data);
this.workspaceContext.setValidationErrors?.(undefined);
// TODO: do not make it the buttons responsibility to set the workspace to not new.
this.workspaceContext.setIsNew(false);
}
}
#update(data: any) {
this.repository?.save(data);
}
}

View File

@@ -16,6 +16,9 @@ export class UmbWorkspaceUserContext
// TODO: remove this magic connection, instead create the necessary methods to update parts.
update = this.#manager.state.update;
setName(name: string) {
this.#manager.state.update({ name: name });
}
getEntityType = this.#manager.getEntityType;
getUnique = this.#manager.getEntityKey;
getEntityKey = this.#manager.getEntityKey;
@@ -29,9 +32,6 @@ export class UmbWorkspaceUserContext
getName() {
throw new Error('getName is not implemented for UmbWorkspaceUserContext');
}
setName(name: string) {
this.#manager.state.update({ name: name });
}
propertyValueByAlias(alias: string) {
throw new Error('setPropertyValue is not implemented for UmbWorkspaceUserContext');

View File

@@ -16,6 +16,16 @@ class UmbLanguagesData extends UmbData<LanguageModel> {
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<LanguageModel>) {
saveItems.forEach((saveItem) => {
const foundIndex = this.data.findIndex((item) => item.isoCode === saveItem.isoCode);

View File

@@ -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<ProblemDetailsModel>({
status: 400,
type: 'validation',
detail: 'Something went wrong',
errors: {
isoCode: ['Language with same iso code already exists'],
},
})
);
}
}),
rest.put<LanguageModel>(umbracoPath('/language/:key'), async (req, res, ctx) => {

View File

@@ -1,16 +1,17 @@
import { property } from 'lit/decorators.js';
import { UmbModalLayoutElement } from '..';
export interface UmbPickerData<selectType = string> {
export interface UmbPickerModalData<T> {
multiple: boolean;
selection: Array<selectType>;
selection: Array<string>;
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<selectType = string> extends UmbModalLayoutElement<UmbPickerData<selectType>> {
export class UmbModalLayoutPickerBase<T> extends UmbModalLayoutElement<UmbPickerModalData<T>> {
@property()
selection: Array<selectType> = [];
selection: Array<string> = [];
connectedCallback(): void {
super.connectedCallback();
@@ -25,14 +26,14 @@ export class UmbModalLayoutPickerBase<selectType = string> 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<selectType = string> extends UmbModalLayou
this.requestUpdate('_selection');
}
isSelected(key: selectType): boolean {
isSelected(key: string): boolean {
return this.selection.includes(key);
}
}

View File

@@ -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<ManifestSection> {
static styles = [
UUITextStyles,
css`

View File

@@ -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<UserGroupDetails> {
static styles = [
UUITextStyles,
css`

View File

@@ -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<UserDetails> {
static styles = [
UUITextStyles,
css`

View File

@@ -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<SearchItem>;
};
@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<SearchGroupItem> = [];
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<SearchGroupItem> = 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<SearchGroupItem>);
this._groups = grouped;
}
render() {
return html`
<div id="top">
<div id="search-icon">
<uui-icon name="search"></uui-icon>
</div>
<input
value=${this._search}
@input=${this.#onSearchChange}
type="text"
placeholder="Search..."
autocomplete="off" />
<div id="close-icon">
<button @click=${this.#onClearSearch}>clear</button>
</div>
</div>
${this._search
? html`<div id="main">
${this._groups.length > 0
? repeat(
this._groups,
(group) => group.name,
(group) => this.#renderGroup(group.name, group.items)
)
: html`<div id="no-results">Only mock data for now <strong>Search for blog</strong></div>`}
</div>`
: nothing}
`;
}
#renderGroup(name: string, items: Array<SearchItem>) {
return html`
<div class="group">
<div class="group-name">${name}</div>
<div class="group-items">${repeat(items, (item) => item.name, this.#renderItem.bind(this))}</div>
</div>
`;
}
#renderItem(item: SearchItem) {
return html`
<a href="${item.href}" class="item">
<span class="item-icon">
${item.icon ? html`<uui-icon name="${item.icon}"></uui-icon>` : this.#renderHashTag()}
</span>
<span class="item-name">
${item.name} ${item.url ? html`<span class="item-url">${item.url}</span>` : nothing}
</span>
<span class="item-symbol">></span>
</a>
`;
}
#renderHashTag() {
return html`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path fill="none" d="M0 0h24v24H0z" />
<path
fill="currentColor"
d="M7.784 14l.42-4H4V8h4.415l.525-5h2.011l-.525 5h3.989l.525-5h2.011l-.525 5H20v2h-3.784l-.42 4H20v2h-4.415l-.525 5h-2.011l.525-5H9.585l-.525 5H7.049l.525-5H4v-2h3.784zm2.011 0h3.99l.42-4h-3.99l-.42 4z" />
</svg>
`;
}
#mockData: Array<SearchItem> = [
{
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;
}
}

View File

@@ -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<LanguageModel>): 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