Merge remote-tracking branch 'origin/main' into manual-json-schema

# Conflicts:
#	package-lock.json
This commit is contained in:
Warren Buckley
2023-03-14 11:29:39 +00:00
248 changed files with 19995 additions and 4323 deletions

View File

@@ -1,4 +1,4 @@
import config from '../../utils/rollup.config.js';
export default {
export default [
...config,
};
];

View File

@@ -1,27 +1,26 @@
import { expect, fixture, html } from '@open-wc/testing';
import { expect, fixture, defineCE } from '@open-wc/testing';
import { UmbContextConsumer } from '../consume/context-consumer';
import { UmbContextProviderController } from './context-provider.controller';
import { UmbControllerHostTestElement, UmbLitElement } from '@umbraco-cms/element';
import { UmbLitElement } from '@umbraco-cms/element';
class MyClass {
prop = 'value from provider';
}
class ControllerHostElement extends UmbLitElement {}
const controllerHostElement = defineCE(ControllerHostElement);
describe('UmbContextProviderController', () => {
let instance: MyClass;
let provider: UmbContextProviderController;
let element: UmbLitElement;
beforeEach(async () => {
element = await fixture(html`<umb-controller-host-test></umb-controller-host-test>`);
element = await fixture(`<${controllerHostElement}></${controllerHostElement}>`);
instance = new MyClass();
provider = new UmbContextProviderController(element, 'my-test-context', instance);
});
it('is defined with its own instance', () => {
expect(element).to.be.instanceOf(UmbControllerHostTestElement);
});
describe('Public API', () => {
describe('properties', () => {
it('has a unique property', () => {

View File

@@ -1,4 +1,4 @@
import config from '../../utils/rollup.config.js';
export default {
export default [
...config,
};
];

View File

@@ -1,4 +1,3 @@
import config from '../../utils/rollup.config.js';
export default {
...config,
};
export default config;

View File

@@ -1,38 +0,0 @@
import { expect, fixture, html } from '@open-wc/testing';
import { customElement } from 'lit/decorators.js';
import { UmbContextProviderElement } from './context-provider.element';
import { UmbLitElement } from './lit-element.element';
@customElement('umb-context-test')
export class ContextTestElement extends UmbLitElement {
public value: string | null = null;
constructor() {
super();
this.consumeContext<string>('test-context', (value) => {
this.value = value;
});
}
}
describe('UmbContextProvider', () => {
let element: UmbContextProviderElement;
let consumer: ContextTestElement;
const contextValue = 'test-value';
beforeEach(async () => {
element = await fixture(
html` <umb-context-provider key="test-context" .value=${contextValue}>
<umb-context-test></umb-context-test>
</umb-context-provider>`
);
consumer = element.getElementsByTagName('umb-context-test')[0] as ContextTestElement;
});
it('is defined with its own instance', () => {
expect(element).to.be.instanceOf(UmbContextProviderElement);
});
it('provides the context', () => {
expect(consumer.value).to.equal(contextValue);
});
});

View File

@@ -1,46 +0,0 @@
import { html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { UmbLitElement } from './lit-element.element';
import type { UmbControllerHostInterface } from '@umbraco-cms/controller';
@customElement('umb-context-provider')
export class UmbContextProviderElement extends UmbLitElement {
/**
* The value to provide to the context.
* @required
*/
@property({ type: Object, attribute: false })
create?: (host:UmbControllerHostInterface) => unknown;
/**
* The value to provide to the context.
* @required
*/
@property({ type: Object })
value: unknown;
/**
* The key to provide to the context.
* @required
*/
@property({ type: String })
key!: string;
connectedCallback() {
super.connectedCallback();
if (!this.key) {
throw new Error('The key property is required.');
}
if (this.create) {
this.value = this.create(this);
} else if (!this.value) {
throw new Error('The value property is required.');
}
this.provideContext(this.key, this.value);
}
render() {
return html`<slot></slot>`;
}
}

View File

@@ -1,44 +0,0 @@
import { expect, fixture, html } from '@open-wc/testing';
import { customElement } from 'lit/decorators.js';
import { UmbControllerHostTestElement } from './controller-host.element';
import { UmbLitElement } from './lit-element.element';
import { UmbContextProviderController } from '@umbraco-cms/context-api';
import { UmbControllerHostInterface } from '@umbraco-cms/controller';
@customElement('umb-controller-host-test-consumer')
export class ControllerHostTestConsumerElement extends UmbLitElement {
public value: string | null = null;
constructor() {
super();
this.consumeContext<string>('my-test-context-alias', (value) => {
this.value = value;
});
}
}
describe('UmbControllerHostTestElement', () => {
let element: UmbControllerHostTestElement;
let consumer: ControllerHostTestConsumerElement;
const contextValue = 'test-value';
beforeEach(async () => {
element = await fixture(
html` <umb-controller-host-test
.create=${(host: UmbControllerHostInterface) =>
new UmbContextProviderController(host, 'my-test-context-alias', contextValue)}>
<umb-controller-host-test-consumer></umb-controller-host-test-consumer>
</umb-controller-host-test>`
);
consumer = element.getElementsByTagName(
'umb-controller-host-test-consumer'
)[0] as ControllerHostTestConsumerElement;
});
it('element is defined with its own instance', () => {
expect(element).to.be.instanceOf(UmbControllerHostTestElement);
});
it('provides the context', () => {
expect(consumer.value).to.equal(contextValue);
});
});

View File

@@ -1,31 +0,0 @@
import { html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { UmbLitElement } from './lit-element.element';
import type { UmbControllerHostInterface } from '@umbraco-cms/controller';
@customElement('umb-controller-host-test')
export class UmbControllerHostTestElement extends UmbLitElement {
/**
* A way to initialize controllers.
* @required
*/
@property({ type: Object, attribute: false })
create?: (host: UmbControllerHostInterface) => void;
connectedCallback() {
super.connectedCallback();
if (this.create) {
this.create(this);
}
}
render() {
return html`<slot></slot>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'umb-controller-host-test': UmbControllerHostTestElement;
}
}

View File

@@ -1,4 +1,2 @@
export * from './element.mixin';
export * from './lit-element.element';
export * from './context-provider.element';
export * from './controller-host.element';

View File

@@ -1,5 +1,6 @@
import config from '../../utils/rollup.config.js';
export default {
...config,
input: 'index.out.ts'
};
config[0].input = 'index.module.ts';
config[1].input = 'index.module.ts';
export default config;

View File

@@ -1,3 +1,4 @@
import { UMB_CONFIRM_MODAL_TOKEN } from '../../../../src/backoffice/shared/modals/confirm';
import { UmbEntityActionBase } from '@umbraco-cms/entity-action';
import { UmbContextConsumerController } from '@umbraco-cms/context-api';
import { UmbControllerHostInterface } from '@umbraco-cms/controller';
@@ -24,17 +25,15 @@ export class UmbDeleteEntityAction<
if (data) {
const item = data[0];
const modalHandler = this.#modalContext.confirm({
const modalHandler = this.#modalContext.open(UMB_CONFIRM_MODAL_TOKEN, {
headline: `Delete ${item.name}`,
content: 'Are you sure you want to delete this item?',
color: 'danger',
confirmLabel: 'Delete',
});
const { confirmed } = await modalHandler.onClose();
if (confirmed) {
await this.repository?.delete(this.unique);
}
await modalHandler.onSubmit();
await this.repository?.delete(this.unique);
}
}
}

View File

@@ -1,3 +1,4 @@
import { UMB_CONFIRM_MODAL_TOKEN } from '../../../../src/backoffice/shared/modals/confirm';
import { UmbEntityActionBase } from '@umbraco-cms/entity-action';
import { UmbContextConsumerController } from '@umbraco-cms/context-api';
import { UmbControllerHostInterface } from '@umbraco-cms/controller';
@@ -24,17 +25,15 @@ export class UmbTrashEntityAction<
if (data) {
const item = data[0];
const modalHandler = this.#modalContext?.confirm({
const modalHandler = this.#modalContext?.open(UMB_CONFIRM_MODAL_TOKEN, {
headline: `Trash ${item.name}`,
content: 'Are you sure you want to move this item to the recycle bin?',
color: 'danger',
confirmLabel: 'Trash',
});
modalHandler?.onClose().then(({ confirmed }) => {
if (confirmed) {
this.repository?.trash([this.unique]);
}
modalHandler?.onSubmit().then(() => {
this.repository?.trash([this.unique]);
});
}
}

View File

@@ -1,4 +1,3 @@
import config from '../../utils/rollup.config.js';
export default {
...config,
};
export default config;

View File

@@ -1,4 +1,3 @@
import config from '../../utils/rollup.config.js';
export default {
...config,
};
export default config;

View File

@@ -0,0 +1,5 @@
import type { ManifestElement } from './models';
export interface ManifestModal extends ManifestElement {
type: 'modal';
}

View File

@@ -22,6 +22,7 @@ import type { ManifestWorkspaceAction } from './workspace-action.models';
import type { ManifestWorkspaceView } from './workspace-view.models';
import type { ManifestWorkspaceViewCollection } from './workspace-view-collection.models';
import type { ManifestRepository } from './repository.models';
import type { ManifestModal } from './modal.models';
import type { ManifestStore, ManifestTreeStore } from './store.models';
export * from './collection-view.models';
@@ -49,6 +50,7 @@ export * from './workspace-view.models';
export * from './repository.models';
export * from './store.models';
export * from './workspace.models';
export * from './modal.models';
export type ManifestTypes =
| ManifestCollectionView
@@ -79,6 +81,7 @@ export type ManifestTypes =
| ManifestWorkspaceAction
| ManifestWorkspaceView
| ManifestWorkspaceViewCollection
| ManifestModal
| ManifestStore
| ManifestTreeStore
| ManifestBase;

View File

@@ -1,4 +1,3 @@
import config from '../../utils/rollup.config.js';
export default {
...config,
};
export default config;

View File

@@ -0,0 +1,58 @@
import { property } from 'lit/decorators.js';
import { UmbModalBaseElement } from '..';
import './modal-element.element';
export interface UmbPickerModalData<T> {
multiple: boolean;
selection: Array<string>;
filter?: (language: T) => boolean;
}
export interface UmbPickerModalResult<T> {
selection: Array<string>;
}
// 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 UmbModalElementPickerBase<T> extends UmbModalBaseElement<UmbPickerModalData<T>, UmbPickerModalResult<T>> {
@property()
selection: Array<string> = [];
connectedCallback(): void {
super.connectedCallback();
this.selection = this.data?.selection || [];
}
submit() {
this.modalHandler?.submit({ selection: this.selection });
}
close() {
this.modalHandler?.reject();
}
protected _handleKeydown(e: KeyboardEvent, key: string) {
if (e.key === 'Enter') {
this.handleSelection(key);
}
}
/* TODO: Write test for this select/deselect method. */
handleSelection(key: string) {
if (this.data?.multiple) {
if (this.isSelected(key)) {
this.selection = this.selection.filter((selectedKey) => selectedKey !== key);
} else {
this.selection.push(key);
}
} else {
this.selection = [key];
}
this.requestUpdate('_selection');
}
isSelected(key: string): boolean {
return this.selection.includes(key);
}
}

View File

@@ -0,0 +1,18 @@
import { customElement, property } from 'lit/decorators.js';
import { UmbModalHandler } from '..';
import { UmbLitElement } from '@umbraco-cms/element';
@customElement('umb-modal-element')
export class UmbModalBaseElement<UmbModalData = void, UmbModalResult = void> extends UmbLitElement {
@property({ attribute: false })
modalHandler?: UmbModalHandler<UmbModalData, UmbModalResult>;
@property({ type: Object, attribute: false })
data?: UmbModalData;
}
declare global {
interface HTMLElementTagNameMap {
'umb-modal-element': UmbModalBaseElement<unknown>;
}
}

View File

@@ -0,0 +1,5 @@
export * from './modal.context';
export * from './modal-handler';
export * from './elements/modal-element.element';
export * from './elements/modal-element-picker-base';
export * from './token/modal-token';

View File

@@ -0,0 +1,159 @@
import type { UUIDialogElement } from '@umbraco-ui/uui';
import type { UUIModalDialogElement } from '@umbraco-ui/uui-modal-dialog';
import { UUIModalSidebarElement, UUIModalSidebarSize } from '@umbraco-ui/uui-modal-sidebar';
import { v4 as uuidv4 } from 'uuid';
import { BehaviorSubject } from 'rxjs';
import { UmbModalConfig, UmbModalType } from './modal.context';
import { UmbModalToken } from './token/modal-token';
import { createExtensionElement, umbExtensionsRegistry } from '@umbraco-cms/extensions-api';
import { UmbObserverController } from '@umbraco-cms/observable-api';
import { UmbControllerHostInterface } from '@umbraco-cms/controller';
import { ManifestModal } from '@umbraco-cms/extensions-registry';
/**
* Type which omits the real submit method, and replaces it with a submit method which accepts an optional argument depending on the generic type.
*/
export type UmbModalHandler<ModalData = unknown, ModalResult = unknown> = Omit<
UmbModalHandlerClass<ModalData, ModalResult>,
'submit'
> &
OptionalSubmitArgumentIfUndefined<ModalResult>;
// If Type is undefined we don't accept an argument,
// If type is unknown, we accept an option argument.
// If type is anything else, we require an argument of that type.
type OptionalSubmitArgumentIfUndefined<T> = T extends undefined
? {
submit: () => void;
}
: T extends unknown
? {
submit: (arg?: T) => void;
}
: {
submit: (arg: T) => void;
};
//TODO consider splitting this into two separate handlers
export class UmbModalHandlerClass<ModalData, ModalResult> {
private _submitPromise: Promise<ModalResult>;
private _submitResolver?: (value: ModalResult) => void;
private _submitRejecter?: () => void;
#host: UmbControllerHostInterface;
public modalElement: UUIModalDialogElement | UUIModalSidebarElement;
#innerElement = new BehaviorSubject<any | undefined>(undefined);
public readonly innerElement = this.#innerElement.asObservable();
#modalElement?: UUIModalSidebarElement | UUIDialogElement;
public key: string;
public type: UmbModalType = 'dialog';
public size: UUIModalSidebarSize = 'small';
constructor(
host: UmbControllerHostInterface,
modalAlias: string | UmbModalToken<ModalData, ModalResult>,
data?: ModalData,
config?: UmbModalConfig
) {
this.#host = host;
this.key = config?.key || uuidv4();
if (modalAlias instanceof UmbModalToken) {
this.type = modalAlias.getDefaultConfig()?.type || this.type;
this.size = modalAlias.getDefaultConfig()?.size || this.size;
}
this.type = config?.type || this.type;
this.size = config?.size || this.size;
// TODO: Consider if its right to use Promises, or use another event based system? Would we need to be able to cancel an event, to then prevent the closing..?
this._submitPromise = new Promise((resolve, reject) => {
this._submitResolver = resolve;
this._submitRejecter = reject;
});
this.modalElement = this.#createContainerElement();
this.#observeModal(modalAlias.toString(), data);
}
#createContainerElement() {
return this.type === 'sidebar' ? this.#createSidebarElement() : this.#createDialogElement();
}
#createSidebarElement() {
const sidebarElement = document.createElement('uui-modal-sidebar');
this.#modalElement = sidebarElement;
sidebarElement.size = this.size;
return sidebarElement;
}
#createDialogElement() {
const modalDialogElement = document.createElement('uui-modal-dialog');
const dialogElement: UUIDialogElement = document.createElement('uui-dialog');
this.#modalElement = dialogElement;
modalDialogElement.appendChild(dialogElement);
return modalDialogElement;
}
async #createInnerElement(manifest: ManifestModal, data?: ModalData) {
// TODO: add inner fallback element if no extension element is found
const innerElement = (await createExtensionElement(manifest)) as any;
if (innerElement) {
innerElement.data = data; //
//innerElement.observable = this.#dataObservable;
innerElement.modalHandler = this;
}
return innerElement;
}
// note, this methods argument is not defined correctly here, but requires to be fix by appending the OptionalSubmitArgumentIfUndefined type when newing up this class.
private submit(result?: ModalResult) {
this._submitResolver?.(result as ModalResult);
this.modalElement.close();
}
public reject() {
this._submitRejecter?.();
this.modalElement.close();
}
public onSubmit(): Promise<ModalResult> {
return this._submitPromise;
}
/* TODO: modals being part of the extension registry now means that a modal element can change over time.
It makes this code a bit more complex. The main idea is to have the element as part of the modalHandler so it is possible to dispatch events from within the modal element to the one that opened it.
Now when the element is an observable it makes it more complex because this host needs to subscribe to updates to the element, instead of just having a reference to it.
If we find a better generic solution to communicate between the modal and the implementor, then we can remove the element as part of the modalHandler. */
#observeModal(modalAlias: string, data?: ModalData) {
new UmbObserverController(
this.#host,
umbExtensionsRegistry.getByTypeAndAlias('modal', modalAlias),
async (manifest) => {
if (manifest) {
const innerElement = await this.#createInnerElement(manifest, data);
this.#appendInnerElement(innerElement);
} else {
this.#removeInnerElement();
}
}
);
}
#appendInnerElement(element: any) {
this.#modalElement?.appendChild(element);
this.#innerElement.next(element);
}
#removeInnerElement() {
if (this.#innerElement.getValue()) {
this.#modalElement?.removeChild(this.#innerElement.getValue());
this.#innerElement.next(undefined);
}
}
}

View File

@@ -0,0 +1,124 @@
// TODO: remove this import when the search hack is removed
import '../../src/backoffice/search/modals/search/search-modal.element';
import { UUIModalSidebarSize } from '@umbraco-ui/uui-modal-sidebar';
import { BehaviorSubject } from 'rxjs';
import type { UUIModalDialogElement } from '@umbraco-ui/uui-modal-dialog';
import { UmbModalHandler, UmbModalHandlerClass } from './modal-handler';
import type { UmbModalToken } from './token/modal-token';
import { UmbContextToken } from '@umbraco-cms/context-api';
import { UmbControllerHostInterface } from '@umbraco-cms/controller';
export type UmbModalType = 'dialog' | 'sidebar';
export interface UmbModalConfig {
key?: string;
type?: UmbModalType;
size?: UUIModalSidebarSize;
}
// TODO: we should find a way to easily open a modal without adding custom methods to this context. It would result in a better separation of concerns.
// TODO: move all layouts into their correct "silo" folders. User picker should live with users etc.
export class UmbModalContext {
host: UmbControllerHostInterface;
// TODO: Investigate if we can get rid of HTML elements in our store, so we can use one of our states.
#modals = new BehaviorSubject(<Array<UmbModalHandler<any, any>>>[]);
public readonly modals = this.#modals.asObservable();
constructor(host: UmbControllerHostInterface) {
this.host = host;
}
// TODO: Remove this when the modal system is more flexible
public search() {
const modalHandler = new UmbModalHandlerClass(this.host, 'Umb.Modal.Search') as unknown as UmbModalHandler<
any,
any
>;
//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-search-modal');
dialog.appendChild(search);
requestAnimationFrame(() => {
dialog.showModal();
});
modalHandler.modalElement = dialog as unknown as UUIModalDialogElement;
//TODO END
modalHandler.modalElement.addEventListener('close-end', () => this.#onCloseEnd(modalHandler));
this.#modals.next([...this.#modals.getValue(), modalHandler]);
return modalHandler;
}
/**
* Opens a modal or sidebar modal
* @public
* @param {(string | HTMLElement)} element
* @param {UmbModalOptions<unknown>} [options]
* @return {*} {UmbModalHandler}
* @memberof UmbModalContext
*/
public open<ModalData = unknown, ModalResult = unknown>(
modalAlias: string | UmbModalToken<ModalData, ModalResult>,
data?: ModalData,
config?: UmbModalConfig
) {
const modalHandler = new UmbModalHandlerClass(this.host, modalAlias, data, config) as unknown as UmbModalHandler<
ModalData,
ModalResult
>;
modalHandler.modalElement.addEventListener('close-end', () => this.#onCloseEnd(modalHandler));
this.#modals.next([...this.#modals.getValue(), modalHandler]);
return modalHandler;
}
/**
* Closes a modal or sidebar modal
* @private
* @param {string} key
* @memberof UmbModalContext
*/
public close(key: string) {
const modal = this.#modals.getValue().find((modal) => modal.key === key);
if (modal) {
modal.reject();
}
}
#remove(key: string) {
this.#modals.next(this.#modals.getValue().filter((modal) => modal.key !== key));
}
/**
* Handles the close-end event
* @private
* @param {UmbModalHandler} modalHandler
* @memberof UmbModalContext
*/
#onCloseEnd(modalHandler: UmbModalHandler<any, any>) {
modalHandler.modalElement.removeEventListener('close-end', () => this.#onCloseEnd(modalHandler));
this.#remove(modalHandler.key);
}
}
export const UMB_MODAL_CONTEXT_TOKEN = new UmbContextToken<UmbModalContext>('UmbModalContext');

View File

@@ -0,0 +1,145 @@
import { Meta } from '@storybook/blocks';
<Meta title="API/Modals/Intro" />
# Modals
A modal is a popup that darkens the background and has focus lock. There are two types of modals: "dialog" and "sidebar".
**Dialog modals** appears in the middle of the screen.
| option | values |
|:------:|:--------------------------:|
| No options yet | |
**Sidebar modals** slides in from the right.
| option | values |
|:------:|:--------------------------:|
| size | small, medium, large, full |
## Basic Usage
### Consume UmbModalContext from an element
The UmbModal context can be used to open modals.
```ts
import { LitElement } from 'lit';
import { UmbElementMixin } from '@umbraco-cms/element';
import { UmbModalContext, UMB_MODAL_CONTEXT_ALIAS } from '@umbraco-cms/modal';
class MyElement extends UmbElementMixin(LitElement) {
#modalContext?: UmbModalContext;
constructor() {
super();
this.consumeContext(UMB_MODAL_CONTEXT_ALIAS, (instance) => {
this.#modalContext = instance;
// modalContext is now ready to be used.
});
}
}
```
### Open a modal
A modal is opened by calling the open method on the UmbModalContext. The methods will accept a modal token (or extension alias), an optional dataset, and optional modal options .It returns an instance of UmbModalHandler.
```ts
import { html, LitElement } from 'lit';
import { UmbElementMixin } from '@umbraco-cms/element';
import { UmbModalContext, UMB_MODAL_CONTEXT_ALIAS } from '@umbraco-cms/modal';
class MyElement extends UmbElementMixin(LitElement) {
#modalContext?: UmbModalContext;
constructor() {
super();
this.consumeContext(UMB_MODAL_CONTEXT_ALIAS, (instance) => {
this.#modalContext = instance;
// modalContext is now ready to be used
});
}
#onClick() {
const data = {'data goes here'};
const options {'options go here'};
const modalHandler = this.#modalContext?.open(SOME_MODAL_TOKEN), data, options);
modalHandler?.onSubmit().then((data) => {
// if modal submitted, then data is supplied here.
});
}
render() {
return html`<button @click=${this.#onClick}>Open modal</button>`;
}
}
```
## Create a custom modal
### Register in the extension registry
The manifest
```json
{
"type": "modal",
"alias": "My.Modal",
"name": "My Modal",
"js": "../path/to/my-modal.element.js"
}
```
### Create a modal token
A modal token is a string that identifies a modal. It should be the modal extension alias. It is used to open a modal and is also to set default options for the modal.
```ts
interface MyModalData = {
headline: string;
content: string;
}
interface MyModalResult = {
myReturnData: string;
}
const MY_MODAL_TOKEN = new ModalToken<MyModalData, MyModalResult>('My.Modal', {
type: 'sidebar',
size: 'small'
});
```
The Modal element
```ts
import { html, LitElement } from 'lit';
import { UmbElementMixin } from '@umbraco-cms/element';
import type { UmbModalHandler } from '@umbraco-cms/modal';
class MyDialog extends UmbElementMixin(LitElement) {
// the modal handler will be injected into the element when the modal is opened.
@property({ attribute: false })
modalHandler?: UmbModalHandler<MyModalData, MyModalResult>;
private _handleCancel() {
this._modalHandler?.close();
}
private _handleSubmit() {
/* Optional data of any type can be applied to the submit method to pass it
to the modal parent through the onSubmit promise. */
this._modalHandler?.submit({ myReturnData: 'hello world' });
}
render() {
return html`
<div>
<h1>My Modal</h1>
<button @click=${this._handleCancel}>Cancel</button>
<button @click=${this._handleSubmit}>Submit</button>
</div>
`;
}
}
```

View File

@@ -0,0 +1,22 @@
import { Meta, Story } from '@storybook/web-components';
import { html } from 'lit-html';
export default {
title: 'API/Modals',
id: 'umb-modal-context',
argTypes: {
modalLayout: {
control: 'select',
//options: ['Confirm', 'Content Picker', 'Property Editor UI Picker', 'Icon Picker'],
},
},
} as Meta;
const Template: Story = (props) => {
return html`
Under construction
<story-modal-context-example .modalLayout=${props.modalLayout}></story-modal-context-example>
`;
};
export const Overview = Template.bind({});

View File

@@ -0,0 +1,53 @@
import { html } from 'lit-html';
import { customElement, property, state } from 'lit/decorators.js';
import { UmbModalContext, UMB_MODAL_CONTEXT_TOKEN } from '..';
import { UmbLitElement } from '@umbraco-cms/element';
@customElement('story-modal-context-example')
export class StoryModalContextExampleElement extends UmbLitElement {
@property()
modalLayout = 'confirm';
@state()
value = '';
private _modalContext?: UmbModalContext;
constructor() {
super();
this.consumeContext(UMB_MODAL_CONTEXT_TOKEN, (instance) => {
this._modalContext = instance;
});
}
private _open() {
// TODO: use the extension registry to get all modals
/*
switch (this.modalLayout) {
case 'Content Picker':
this._modalContext?.documentPicker();
break;
case 'Property Editor UI Picker':
this._modalContext?.propertyEditorUIPicker();
break;
case 'Icon Picker':
this._modalContext?.iconPicker();
break;
default:
this._modalContext?.confirm({
headline: 'Headline',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
});
break;
}
*/
}
render() {
return html`
<uui-button label="open-dialog" look="primary" @click=${() => this._open()} style="margin-right: 9px;"
>Open modal</uui-button
>
`;
}
}

View File

@@ -0,0 +1,48 @@
import { UmbModalConfig } from '../modal.context';
export class UmbModalToken<Data = unknown, Result = unknown> {
/**
* Get the data type of the token's data.
*
* @public
* @type {Data}
* @memberOf UmbModalToken
* @example `typeof MyModal.TYPE`
* @returns undefined
*/
readonly DATA: Data = undefined as never;
/**
* Get the result type of the token
*
* @public
* @type {Result}
* @memberOf UmbModalToken
* @example `typeof MyModal.RESULT`
* @returns undefined
*/
readonly RESULT: Result = undefined as never;
/**
* @param alias Unique identifier for the token,
* @param defaultConfig Default configuration for the modal,
* @param _desc Description for the token,
* used only for debugging purposes,
* it should but does not need to be unique
*/
constructor(protected alias: string, protected defaultConfig?: UmbModalConfig, protected _desc?: string) {}
/**
* This method must always return the unique alias of the token since that
* will be used to look up the token in the injector.
*
* @returns the unique alias of the token
*/
toString(): string {
return this.alias;
}
public getDefaultConfig(): UmbModalConfig | undefined {
return this.defaultConfig;
}
}

View File

@@ -1,4 +1,3 @@
import config from '../../utils/rollup.config.js';
export default {
...config,
};
export default config;

View File

@@ -1 +0,0 @@
export * from './notification-layout-default.element';

View File

@@ -1,29 +0,0 @@
import { html, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { UUITextStyles } from '@umbraco-ui/uui-css';
import type { UmbNotificationHandler } from '../..';
export interface UmbNotificationDefaultData {
message: string;
headline?: string;
}
@customElement('umb-notification-layout-default')
export class UmbNotificationLayoutDefaultElement extends LitElement {
static styles = [UUITextStyles];
@property({ attribute: false })
notificationHandler!: UmbNotificationHandler;
@property({ type: Object })
data!: UmbNotificationDefaultData;
render() {
return html`
<uui-toast-notification-layout id="layout" headline="${ifDefined(this.data.headline)}" class="uui-text">
<div id="message">${this.data.message}</div>
</uui-toast-notification-layout>
`;
}
}

View File

@@ -1,22 +0,0 @@
import { Meta, Story } from '@storybook/web-components';
import { html } from 'lit';
import { UmbNotificationLayoutDefaultElement, UmbNotificationDefaultData } from '.';
export default {
title: 'API/Notifications/Layouts/Default',
component: 'umb-notification-layout-default',
id: 'notification-layout-default',
} as Meta;
const data: UmbNotificationDefaultData = {
headline: 'Headline',
message: 'This is a default notification',
};
const Template: Story<UmbNotificationLayoutDefaultElement> = () => html`
<uui-toast-notification .open=${true}>
<umb-notification-layout-default .data=${data}></umb-notification-layout-default>
</uui-toast-notification>
`;
export const Default = Template.bind({});

View File

@@ -1,56 +0,0 @@
import { fixture, expect, html } from '@open-wc/testing';
import { UUIToastNotificationLayoutElement } from '@umbraco-ui/uui';
import { UmbNotificationHandler } from '../..';
import type { UmbNotificationLayoutDefaultElement, UmbNotificationDefaultData } from '.';
import '.';
describe('UmbNotificationLayoutDefault', () => {
let element: UmbNotificationLayoutDefaultElement;
const data: UmbNotificationDefaultData = {
headline: 'Notification Headline',
message: 'Notification message',
};
const options = { elementName: 'umb-notification-layout-default', data };
let notificationHandler: UmbNotificationHandler;
beforeEach(async () => {
notificationHandler = new UmbNotificationHandler(options);
element = await fixture(
html`<umb-notification-layout-default
.notificationHandler=${notificationHandler}
.data=${options.data}></umb-notification-layout-default>`
);
});
describe('Public API', () => {
describe('properties', () => {
it('has a notificationHandler property', () => {
expect(element).to.have.property('notificationHandler');
});
it('has a data property', () => {
expect(element).to.have.property('data');
});
});
});
describe('Data options', () => {
describe('Headline', () => {
it('sets headline on uui notification layout', () => {
const uuiNotificationLayout: UUIToastNotificationLayoutElement | null =
element.renderRoot.querySelector('#layout');
expect(uuiNotificationLayout?.getAttribute('headline')).to.equal('Notification Headline');
});
});
describe('message', () => {
it('renders the message', () => {
const messageElement: HTMLElement | null = element.renderRoot.querySelector('#message');
expect(messageElement?.innerText).to.equal('Notification message');
});
});
});
});

View File

@@ -3,14 +3,13 @@ import { validate as uuidValidate } from 'uuid';
import { UmbNotificationHandler } from './notification-handler';
import type { UmbNotificationDefaultData } from './layouts/default';
import type { UmbNotificationOptions } from './notification.context';
describe('UmbNotificationHandler', () => {
let notificationHandler: UmbNotificationHandler;
beforeEach(async () => {
const options: UmbNotificationOptions<UmbNotificationDefaultData> = {};
const options: UmbNotificationOptions = {};
notificationHandler = new UmbNotificationHandler(options);
});
@@ -72,7 +71,7 @@ describe('UmbNotificationHandler', () => {
let layoutElement: any;
beforeEach(async () => {
const options: UmbNotificationOptions<UmbNotificationDefaultData> = {
const options: UmbNotificationOptions = {
color: 'positive',
data: {
message: 'Notification default layout message',
@@ -100,7 +99,7 @@ describe('UmbNotificationHandler', () => {
let layoutElement: any;
beforeEach(async () => {
const options: UmbNotificationOptions<UmbNotificationDefaultData> = {
const options: UmbNotificationOptions = {
elementName: 'umb-notification-test-element',
color: 'positive',
data: {

View File

@@ -1,8 +1,6 @@
import { UUIToastNotificationElement } from '@umbraco-ui/uui';
import { v4 as uuidv4 } from 'uuid';
import type { UmbNotificationOptions, UmbNotificationColor, UmbNotificationDefaultData } from '.';
import './layouts/default';
import type { UmbNotificationOptions, UmbNotificationColor, UmbNotificationDefaultData } from './notification.context';
/**
* @export

View File

@@ -72,10 +72,7 @@ export class UmbNotificationContext {
* @return {*}
* @memberof UmbNotificationContext
*/
public peek(
color: UmbNotificationColor,
options: UmbNotificationOptions
): UmbNotificationHandler {
public peek(color: UmbNotificationColor, options: UmbNotificationOptions): UmbNotificationHandler {
return this._open({ color, ...options });
}
@@ -86,10 +83,7 @@ export class UmbNotificationContext {
* @return {*}
* @memberof UmbNotificationContext
*/
public stay(
color: UmbNotificationColor,
options: UmbNotificationOptions
): UmbNotificationHandler {
public stay(color: UmbNotificationColor, options: UmbNotificationOptions): UmbNotificationHandler {
return this._open({ ...options, color, duration: null });
}
}

View File

@@ -1,4 +1,3 @@
import config from '../../utils/rollup.config.js';
export default {
...config,
};
export default config;

View File

@@ -1,177 +0,0 @@
import { Meta } from '@storybook/blocks';
<Meta title="API/Notifications/Intro" />
# Notifications
Notifications appear in the bottom right corner of the Backoffice. There are two types of notifications: "Peek" and "Stay".
**Peek notifications**
Goes away automatically and should be used as feedback on user actions.
**Stay notifications**
Stays on the screen until dismissed by the user or custom code. Stay notification should be used when you need user feedback or want to control when the notification disappears.
## Basic usage
### Consume UmbNotificationContext from an element
The UmbNotification context can be used to open notifications.
```ts
import { html, LitElement } from 'lit';
import { UmbLitElement } from '@umbraco-cms/element';
import type { UmbNotificationContext, UMB_NOTIFICATION_CONTEXT_ALIAS } from '@umbraco-cms/notification';
class MyElement extends UmbLitElement {
private _notificationContext?: UmbNotificationContext;
constructor() {
super();
this.consumeContext(UMB_NOTIFICATION_CONTEXT_ALIAS, (instance) => {
this._notificationContext = notificationContext;
// notificationContext is now ready to be used
});
}
}
```
### Open a notification
A notification is opened by calling one of the helper methods on the UmbNotificationContext. The methods will return an instance of UmbNotificationHandler.
```ts
import { html, LitElement } from 'lit';
import { state } from 'lit/decorators.js';
import { UmbLitElement } from '@umbraco-cms/element';
import type {
UmbNotificationContext,
UmbNotificationDefaultData,
UMB_NOTIFICATION_CONTEXT_ALIAS,
} from '@umbraco-cms/notification';
class MyElement extends UmbLitElement {
private _notificationContext?: UmbNotificationContext;
constructor() {
super();
this.consumeContext(UMB_NOTIFICATION_CONTEXT_ALIAS, (notificationContext) => {
this._notificationContext = notificationContext;
// notificationContext is now ready to be used
});
}
private _handleClick() {
const data: UmbNotificationDefaultData = { headline: 'Look at this', message: 'Something good happened' };
const notificationHandler = this._notificationContext?.peek('positive', { data });
notificationHandler.onClose().then(() => {
// if you need any logic when the notification is closed you can run it here
});
}
render() {
return html`<button @click="${this._handleClick}">Open Notification</button>`;
}
}
```
## Advanced usage: creating custom layouts
The default layout will cover most cases, but there might be situations where we want a more complex layout. You can create a new Custom Element to use as the layout.
### Custom layout element
```ts
import { html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { UUITextStyles } from '@umbraco-ui/uui-css';
import type { UmbNotificationHandler } from '@umbraco-cms/notification';
export interface UmbNotificationCustomData {
headline: string;
user: {
name: string;
};
}
export class UmbNotificationLayoutCustom extends LitElement {
static styles = [UUITextStyles];
@property({ attribute: false })
public notificationHandler: UmbNotificationHandler;
@property({ type: Object })
public data: UmbNotificationCustomData;
private _handleConfirm() {
this.notificationHandler.close(true);
}
render() {
return html`
<uui-toast-notification-layout headline="${this.data.headline}" class="uui-text">
${this.data.user.name}
<uui-button slot="actions" @click="${this._handleConfirm}" label="Confirm">Confirm</uui-button>
</uui-toast-notification-layout>
`;
}
}
```
### Open notification with custom layout
```ts
import { html, LitElement } from 'lit';
import { UmbContextInjectMixin } from '@umbraco-cms/context-api';
import type {
UmbNotificationContext,
UmbNotificationOptions,
UMB_NOTIFICATION_CONTEXT_ALIAS,
} from '@umbraco-cms/notification';
import type { UmbNotificationCustomData } from './custom-notification-layout';
class MyElement extends LitElement {
private _notificationContext?: UmbNotificationContext;
constructor() {
super();
this.consumeContext(UMB_NOTIFICATION_CONTEXT_ALIAS, (instance) => {
this._notificationContext = instance;
// notificationContext is now ready to be used
});
}
private _handleClick() {
const options: UmbNotificationOptions<UmbNotificationCustomData> = {
elementName: 'umb-notification-layout-custom',
data: {
headline: 'Attention',
user: { name: 'Peter Parker' },
},
};
const notificationHandler = this._notificationContext?.stay('default', options);
notificationHandler.onClose().then((result) => {
if (result) {
console.log('He agreed!');
}
});
}
render() {
return html`<button @click="${this._handleClick}">Open Notification</button>`;
}
}
```
## Best practices
- Keep messages in notifications short and friendly.
- Only use headlines when you need extra attention to the notification
- If a custom notification layout is only used in one module keep the files layout files local to that module.
- If a custom notification will be used across the project. Create it as a layout in the notification folder and add a helper method to the UmbNotificationContext.

View File

@@ -1,38 +0,0 @@
import '../layouts/default';
import { Meta, Story } from '@storybook/web-components';
import { html } from 'lit';
import { UmbNotificationContext } from '..';
export default {
title: 'API/Notifications/Overview',
component: 'ucp-notification-layout-default',
decorators: [
(story) =>
html`<umb-context-provider key="UmbNotificationContext" .value=${new UmbNotificationContext()}>
${story()}
</umb-context-provider>`,
],
} as Meta;
const Template: Story = () => html`<story-notification-default-example></story-notification-default-example>`;
export const Default = Template.bind({});
Default.parameters = {
docs: {
source: {
language: 'js',
code: `
const options: UmbNotificationOptions<UmbNotificationDefaultData> = {
data: {
headline: 'Headline',
message: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit'
}
};
this._notificationContext?.peek('positive', options);
`,
},
},
};

View File

@@ -1,56 +0,0 @@
import { html } from 'lit';
import { customElement } from 'lit/decorators.js';
import { UmbNotificationDefaultData } from '../layouts/default';
import {
UmbNotificationColor,
UmbNotificationOptions,
UmbNotificationContext,
UMB_NOTIFICATION_CONTEXT_TOKEN,
} from '..';
import { UmbLitElement } from '@umbraco-cms/element';
@customElement('story-notification-default-example')
export class StoryNotificationDefaultExampleElement extends UmbLitElement {
private _notificationContext?: UmbNotificationContext;
connectedCallback(): void {
super.connectedCallback();
this.consumeContext(UMB_NOTIFICATION_CONTEXT_TOKEN, (instance) => {
this._notificationContext = instance;
});
}
private _handleNotification = (color: UmbNotificationColor) => {
const options: UmbNotificationOptions<UmbNotificationDefaultData> = {
data: {
headline: 'Headline',
message: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
},
};
this._notificationContext?.peek(color, options);
};
render() {
return html`
<uui-button @click="${() => this._handleNotification('default')}" label="Default"></uui-button>
<uui-button
@click="${() => this._handleNotification('positive')}"
label="Positive"
look="primary"
color="positive"></uui-button>
<uui-button
@click="${() => this._handleNotification('warning')}"
label="Warning"
look="primary"
color="warning"></uui-button>
<uui-button
@click="${() => this._handleNotification('danger')}"
label="Danger"
look="primary"
color="danger"></uui-button>
<umb-backoffice-notification-container></umb-backoffice-notification-container>
`;
}
}

View File

@@ -1,4 +1,3 @@
import config from '../../utils/rollup.config.js';
export default {
...config,
};
export default config;

View File

@@ -0,0 +1,6 @@
import { DataTypePropertyModel } from '@umbraco-cms/backend-api';
export interface UmbPropertyEditorElement extends HTMLElement {
value: unknown;
config: DataTypePropertyModel[];
}

View File

@@ -1,4 +1,3 @@
import config from '../../utils/rollup.config.js';
export default {
...config,
};
export default config;

View File

@@ -0,0 +1,3 @@
import config from '../../utils/rollup.config.js';
export default config;

View File

@@ -1,4 +1,4 @@
import config from '../../utils/rollup.config.js';
export default {
export default [
...config,
};
];

View File

@@ -1,3 +0,0 @@
export * from './router-slot.element';
export * from './router-slot-change.event';
export * from './router-slot-init.event';

View File

@@ -1,8 +0,0 @@
import { UUIEvent } from '@umbraco-ui/uui-base/lib/events';
import type { UmbRouterSlotElement } from './router-slot.element';
export class UmbRouterSlotChangeEvent extends UUIEvent<never, UmbRouterSlotElement> {
static readonly CHANGE = 'change';
constructor() {
super(UmbRouterSlotChangeEvent.CHANGE);
}
}

View File

@@ -1,8 +0,0 @@
import { UUIEvent } from '@umbraco-ui/uui-base/lib/events';
import type { UmbRouterSlotElement } from './router-slot.element';
export class UmbRouterSlotInitEvent extends UUIEvent<never, UmbRouterSlotElement> {
static readonly INIT = 'init';
constructor() {
super(UmbRouterSlotInitEvent.INIT);
}
}

View File

@@ -1,120 +0,0 @@
import { IRoute, RouterSlot, ensureSlash } from 'router-slot';
import { LitElement, PropertyValueMap } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { UmbRouterSlotChangeEvent, UmbRouterSlotInitEvent } from '@umbraco-cms/router';
/**
* @element umb-router-slot-element
* @description - Component for wrapping Router Slot element, providing some local events for implementation.
* @extends UmbRouterSlotElement
* @fires {UmbRouterSlotInitEvent} init - fires when the router is connected
* @fires {UmbRouterSlotChangeEvent} change - fires when a path of this router is changed
*/
@customElement('umb-router-slot')
export class UmbRouterSlotElement extends LitElement {
#router: RouterSlot = new RouterSlot();
#listening = false;
@property()
public get routes(): IRoute[] | undefined {
return (this.#router as any).routes;
}
public set routes(value: IRoute[] | undefined) {
/*
Concept for extending routes with modal routes.
const routesWithModals = value?.map((route, i, array) => {
{
path: 'bla/:key/'
component: () => {
return import('.....');
}
setup: () => {
...
}
}
if (route.path === '') {
{
...route,
path: route.path + '/modal/:modal-alias',
setup: () => {
route.setup?.();
// Call modal service to open modal.
}
}
});
*/
(this.#router as any).routes = value;
}
private _routerPath?: string;
public get absoluteRouterPath() {
return this._routerPath;
}
private _activeLocalPath?: string;
public get localActiveViewPath() {
return this._activeLocalPath;
}
public get absoluteActiveViewPath() {
return this._routerPath + '/' + this._activeLocalPath;
}
constructor() {
super();
this.#router.addEventListener('changestate', this._onChangeState);
this.#router.appendChild(document.createElement('slot'));
}
connectedCallback() {
super.connectedCallback();
if (this.#listening === false) {
window.addEventListener('navigationsuccess', this._onNavigationChanged);
this.#listening = true;
}
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener('navigationsuccess', this._onNavigationChanged);
this.#listening = false;
}
protected firstUpdated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
super.firstUpdated(_changedProperties);
this._routerPath = this.#router.constructAbsolutePath('') || '';
this.dispatchEvent(new UmbRouterSlotInitEvent());
}
private _onChangeState = () => {
const newAbsolutePath = this.#router.constructAbsolutePath('') || '';
if (this._routerPath !== newAbsolutePath) {
this._routerPath = newAbsolutePath;
this.dispatchEvent(new UmbRouterSlotInitEvent());
const newActiveLocalPath = this.#router.match?.route.path;
if (this._activeLocalPath !== newActiveLocalPath) {
this._activeLocalPath = newActiveLocalPath;
this.dispatchEvent(new UmbRouterSlotChangeEvent());
}
}
};
private _onNavigationChanged = (event?: any) => {
if (event.detail.slot === this.#router) {
this._activeLocalPath = event.detail.match.route.path;
this.dispatchEvent(new UmbRouterSlotChangeEvent());
}
};
render() {
return this.#router;
}
}
declare global {
interface HTMLElementTagNameMap {
'umb-router-slot': UmbRouterSlotElement;
}
}

View File

@@ -1,4 +1,4 @@
import { UUIIconRegistry } from '@umbraco-ui/uui';
import { UUIIconRegistry } from '@umbraco-ui/uui-icon-registry';
import icons from '../../../public-assets/icons/icons.json';
interface UmbIconDescriptor {

View File

@@ -1,4 +1,3 @@
import config from '../../utils/rollup.config.js';
export default {
...config,
};
export default config;

View File

@@ -1,4 +1,4 @@
import config from '../../utils/rollup.config.js';
export default {
export default [
...config,
};
];

View File

@@ -1,6 +1,6 @@
import { UmbWorkspaceContextInterface } from '../../../../src/backoffice/shared/components/workspace/workspace-context/workspace-context.interface';
import { UmbWorkspaceActionBase } from '@umbraco-cms/workspace';
import { UmbControllerHostInterface } from '@umbraco-cms/controller';
import { UmbWorkspaceActionBase } from '../workspace-action-base';
import type { UmbControllerHostInterface } from '@umbraco-cms/controller';
// TODO: add interface for repo/partial repo/save-repo
export class UmbSaveWorkspaceAction extends UmbWorkspaceActionBase<UmbWorkspaceContextInterface> {

View File

@@ -1,4 +1,4 @@
import { UmbControllerHostInterface } from '@umbraco-cms/controller';
import type { UmbControllerHostInterface } from '@umbraco-cms/controller';
import { UmbContextConsumerController } from '@umbraco-cms/context-api';
export interface UmbWorkspaceAction<T = unknown> {

View File

@@ -1,4 +1,4 @@
import config from '../../utils/rollup.config.js';
export default {
export default [
...config,
};
];