add context api first draft
This commit is contained in:
2648
src/Umbraco.Web.UI.Client/package-lock.json
generated
2648
src/Umbraco.Web.UI.Client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,9 @@
|
||||
"openapi-typescript-fetch": "^1.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@open-wc/testing": "^3.1.5",
|
||||
"@types/chai": "^4.3.1",
|
||||
"@types/mocha": "^9.1.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.24.0",
|
||||
"@typescript-eslint/parser": "^5.24.0",
|
||||
"eslint": "^8.15.0",
|
||||
@@ -52,4 +55,4 @@
|
||||
"msw": {
|
||||
"workerDirectory": "public"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@ import { customElement, state } from 'lit/decorators.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
|
||||
import { postUserLogin } from '../../api/fetcher';
|
||||
import { UmbContextInjectMixin } from '../../core/context';
|
||||
import { UmbRouter } from '../../core/router';
|
||||
|
||||
// create custom element with lit-element named 'umb-login'
|
||||
|
||||
@customElement('umb-login')
|
||||
export class UmbLogin extends LitElement {
|
||||
// TODO: maybe rename the mixin to UmbContextRequestMixin?
|
||||
export class UmbLogin extends UmbContextInjectMixin(LitElement) {
|
||||
static styles: CSSResultGroup = [
|
||||
UUITextStyles,
|
||||
css`
|
||||
@@ -22,6 +25,26 @@ export class UmbLogin extends LitElement {
|
||||
@state()
|
||||
private _loggingIn = false;
|
||||
|
||||
_router?: UmbRouter;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// TODO: find solution for magic string
|
||||
// TODO: can we use a property decorator and a callback?
|
||||
this.requestContext('umbRouter');
|
||||
}
|
||||
|
||||
// TODO: maybe rename this callback to contextReceived?
|
||||
/* TODO: this callback is called every time a new context is received.
|
||||
Maybe is would make sense to return the updated contexts (like updatedProperties) so unnecessary code is not run because a new context is added to the map.
|
||||
*/
|
||||
contextInjected(contexts: Map<string, any>): void {
|
||||
if (contexts.has('umbRouter')) {
|
||||
this._router = contexts.get('umbRouter');
|
||||
}
|
||||
}
|
||||
|
||||
private _handleSubmit = (e: SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -47,10 +70,8 @@ export class UmbLogin extends LitElement {
|
||||
try {
|
||||
await postUserLogin({ username, password, persist });
|
||||
this._loggingIn = false;
|
||||
// TODO: Change to redirect when router has been added.
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('login', { bubbles: true, composed: true, detail: { username, password, persist } })
|
||||
);
|
||||
// TODO: how do we know where to go?
|
||||
this._router?.push('/section/content');
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
this._loggingIn = false;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { UmbContextRequester } from './context-requester';
|
||||
import { umbContextProvideType, isUmbContextProvideEvent } from './context-provide.event';
|
||||
|
||||
type Constructor = new (...args: any[]) => HTMLElement;
|
||||
|
||||
export function UmbContextInjectMixin<TBase extends Constructor>(Base: TBase) {
|
||||
return class Providing extends Base {
|
||||
// all context requesters in the element
|
||||
_requesters: Map<string, UmbContextRequester> = new Map();
|
||||
// all successfully resolved context requests
|
||||
_resolved: Map<string, any> = new Map();
|
||||
|
||||
/**
|
||||
* Requests a context from any parent provider
|
||||
* @param {string} contextKey
|
||||
*/
|
||||
requestContext(contextKey: string) {
|
||||
if (this._requesters.has(contextKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requester = new UmbContextRequester(this, contextKey, (_instance: any) => {
|
||||
this._resolved.set(contextKey, _instance);
|
||||
this.contextInjected(this._resolved);
|
||||
});
|
||||
|
||||
this._requesters.set(contextKey, requester);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a new context request for when a new provider is added.
|
||||
* It only sends requests that matches the provider context key.
|
||||
* @private
|
||||
* @param {UmbContextProvideEvent} event
|
||||
*/
|
||||
handleNewProvider = (event: Event) => {
|
||||
if (!isUmbContextProvideEvent(event)) return;
|
||||
|
||||
if (this._requesters.has(event.contextKey)) {
|
||||
const requester = this._requesters.get(event.contextKey);
|
||||
requester?.dispatchRequest();
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback?.();
|
||||
this._requesters.forEach(requester => requester.dispatchRequest());
|
||||
window.addEventListener(umbContextProvideType, this.handleNewProvider);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback?.();
|
||||
window.removeEventListener(umbContextProvideType, this.handleNewProvider);
|
||||
}
|
||||
|
||||
/**
|
||||
* This is called once a context was successfully requested.
|
||||
* Run logic here when the dependecy is ready.
|
||||
* @param contexts Map of all resolved contexts
|
||||
*/
|
||||
contextInjected(contexts: Map<string, any>) {
|
||||
// This is a stub
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElement {
|
||||
connectedCallback(): void;
|
||||
disconnectedCallback(): void;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export const umbContextProvideType = 'umb:context-provide';
|
||||
|
||||
/**
|
||||
* @export
|
||||
* @interface UmbContextProvide
|
||||
*/
|
||||
export interface UmbContextProvide {
|
||||
readonly contextKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @export
|
||||
* @class UmbContextProvideEvent
|
||||
* @extends {Event}
|
||||
* @implements {UmbContextProvide}
|
||||
*/
|
||||
export class UmbContextProvideEvent extends Event implements UmbContextProvide {
|
||||
public constructor(
|
||||
public readonly contextKey: string,
|
||||
) {
|
||||
super(umbContextProvideType, {bubbles: true, composed: true });
|
||||
}
|
||||
}
|
||||
|
||||
export const isUmbContextProvideEvent = (event: Event): event is UmbContextProvideEvent => {
|
||||
return event.type === umbContextProvideType;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { expect, fixture, html } from '@open-wc/testing';
|
||||
import { UmbContextProvideMixin } from './context-provide.mixin';
|
||||
|
||||
class MyClass {
|
||||
prop: string;
|
||||
|
||||
constructor(text: string) {
|
||||
this.prop = text
|
||||
}
|
||||
}
|
||||
|
||||
class MyTestProviderElement extends UmbContextProvideMixin(HTMLElement) {
|
||||
constructor() {
|
||||
super();
|
||||
this.provide('my-test-context-1', new MyClass('context value 1'));
|
||||
this.provide('my-test-context-2', new MyClass('context value 2'));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('my-test-provider-element', MyTestProviderElement);
|
||||
|
||||
describe('UmbContextProvideMixin', async () => {
|
||||
const element: MyTestProviderElement = await fixture(html`<my-test-provider-element></my-test-provider-element>`);
|
||||
const _providers = element['_providers'];
|
||||
|
||||
it('sets all providers to element', () => {
|
||||
expect(_providers.has('my-test-context-1')).to.be.true;
|
||||
expect(_providers.has('my-test-context-2')).to.be.true;
|
||||
});
|
||||
|
||||
it('can not set context with same key as already existing context', () => {
|
||||
const provider = _providers.get('my-test-context-1');
|
||||
expect(provider).to.not.be.undefined;
|
||||
if (!provider) return;
|
||||
expect(provider['_instance'].prop).to.eq('context value 1');
|
||||
element.provide('my-test-context-1', new MyClass('new context value 1'));
|
||||
expect(provider['_instance'].prop).to.eq('context value 1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { UmbContextProvider } from './context-provider';
|
||||
|
||||
type Constructor = new (...args: any[]) => HTMLElement;
|
||||
|
||||
export function UmbContextProvideMixin<TBase extends Constructor>(Base: TBase) {
|
||||
return class Providing extends Base {
|
||||
_providers: Map<string, UmbContextProvider> = new Map();
|
||||
|
||||
constructor(...args: any[]) {
|
||||
super();
|
||||
}
|
||||
|
||||
provide(contextKey: string, instance: any) {
|
||||
if (this._providers.has(contextKey)) return;
|
||||
this._providers.set(contextKey, new UmbContextProvider(this, contextKey, instance));
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback?.();
|
||||
this._providers.forEach(provider => provider.attach());
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback?.();
|
||||
this._providers.forEach(provider => provider.detach());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElement {
|
||||
connectedCallback(): void;
|
||||
disconnectedCallback(): void;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { expect } from '@open-wc/testing';
|
||||
import { UmbContextProvider } from './context-provider';
|
||||
import { UmbContextRequester } from './context-requester';
|
||||
import { UmbContextRequestEvent } from './context-request.event';
|
||||
|
||||
class MyClass {
|
||||
prop = 'value from provider';
|
||||
}
|
||||
|
||||
describe('UmbContextProvider', () => {
|
||||
let provider: UmbContextProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new UmbContextProvider(document.body, 'my-test-context', new MyClass());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
provider.detach();
|
||||
});
|
||||
|
||||
describe('Public API', () => {
|
||||
|
||||
describe('properties', () => {
|
||||
it('has a host property', () => {
|
||||
expect(provider).to.have.property('host');
|
||||
});
|
||||
});
|
||||
|
||||
describe('methods', () => {
|
||||
it('has an attach method', () => {
|
||||
expect(provider).to.have.property('attach').that.is.a('function');
|
||||
});
|
||||
|
||||
it('has a detach method', () => {
|
||||
expect(provider).to.have.property('detach').that.is.a('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('handles context request events', (done) => {
|
||||
const event = new UmbContextRequestEvent('my-test-context', (_instance) => {
|
||||
expect(_instance.prop).to.eq('value from provider');
|
||||
done();
|
||||
});
|
||||
|
||||
document.body.dispatchEvent(event);
|
||||
});
|
||||
|
||||
it('works with UmbContextRequester', (done) => {
|
||||
const element = document.createElement('div');
|
||||
document.body.appendChild(element);
|
||||
|
||||
new UmbContextRequester(element, 'my-test-context', (_instance) => {
|
||||
expect(_instance.prop).to.eq('value from provider');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { umbContextRequestType, isUmbContextRequestEvent } from './context-request.event';
|
||||
import { UmbContextProvideEvent } from './context-provide.event';
|
||||
|
||||
/**
|
||||
* @export
|
||||
* @class UmbContextProvider
|
||||
*/
|
||||
export class UmbContextProvider {
|
||||
protected host: HTMLElement;
|
||||
private _contextKey: string;
|
||||
private _instance: any;
|
||||
|
||||
/**
|
||||
* Creates an instance of UmbContextProvider.
|
||||
* @param {HTMLElement} host
|
||||
* @param {string} contextKey
|
||||
* @param {*} instance
|
||||
* @memberof UmbContextProvider
|
||||
*/
|
||||
constructor (host: HTMLElement, contextKey: string, instance: any) {
|
||||
this.host = host;
|
||||
this._contextKey = contextKey;
|
||||
this._instance = instance;
|
||||
this.attach();
|
||||
}
|
||||
|
||||
/**
|
||||
* @memberof UmbContextProvider
|
||||
*/
|
||||
public attach () {
|
||||
this.host.addEventListener(umbContextRequestType, this._handleContextRequest);
|
||||
this.host.dispatchEvent(new UmbContextProvideEvent(this._contextKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* @memberof UmbContextProvider
|
||||
*/
|
||||
public detach () {
|
||||
this.host.removeEventListener(umbContextRequestType, this._handleContextRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {UmbContextRequestEvent} event
|
||||
* @memberof UmbContextProvider
|
||||
*/
|
||||
private _handleContextRequest = (event: Event) => {
|
||||
if (!isUmbContextRequestEvent(event)) return;
|
||||
if (event.contextKey !== this._contextKey) return;
|
||||
|
||||
event.stopPropagation();
|
||||
event.callback(this._instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect } from '@open-wc/testing';
|
||||
import { UmbContextRequestEvent } from './context-request.event';
|
||||
|
||||
describe('UmbContextRequestEvent', () => {
|
||||
const contextRequestCallback = () => {
|
||||
console.log('hello from callback');
|
||||
};
|
||||
|
||||
const contextRequestEvent: UmbContextRequestEvent = new UmbContextRequestEvent('my-test-context', contextRequestCallback);
|
||||
|
||||
it('has context', () => {
|
||||
expect(contextRequestEvent.contextKey).to.eq('my-test-context');
|
||||
});
|
||||
|
||||
it('has a callback', () => {
|
||||
expect(contextRequestEvent.callback).to.eq(contextRequestCallback);
|
||||
});
|
||||
|
||||
it('bubbles', () => {
|
||||
expect(contextRequestEvent.bubbles).to.be.true;
|
||||
});
|
||||
|
||||
it('is composed', () => {
|
||||
expect(contextRequestEvent.composed).to.be.true;
|
||||
});
|
||||
|
||||
it('is cancelable', () => {
|
||||
expect(contextRequestEvent.composed).to.be.true;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
export const umbContextRequestType = 'umb:context-request';
|
||||
|
||||
export type UmbContextCallback = (instance: any) => void;
|
||||
|
||||
/**
|
||||
* @export
|
||||
* @interface UmbContextRequest
|
||||
*/
|
||||
export interface UmbContextRequest {
|
||||
readonly contextKey: string;
|
||||
readonly callback: UmbContextCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @export
|
||||
* @class UmbContextRequestEvent
|
||||
* @extends {Event}
|
||||
* @implements {UmbContextRequest}
|
||||
*/
|
||||
export class UmbContextRequestEvent extends Event implements UmbContextRequest {
|
||||
public constructor(
|
||||
public readonly contextKey: string,
|
||||
public readonly callback: UmbContextCallback
|
||||
) {
|
||||
super(umbContextRequestType, {bubbles: true, composed: true, cancelable: true });
|
||||
}
|
||||
}
|
||||
|
||||
export const isUmbContextRequestEvent = (event: Event): event is UmbContextRequestEvent => {
|
||||
return event.type === umbContextRequestType;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { expect, oneEvent } from '@open-wc/testing';
|
||||
import { UmbContextProvider } from './context-provider';
|
||||
import { UmbContextRequester } from './context-requester';
|
||||
import { UmbContextRequestEvent, umbContextRequestType } from './context-request.event';
|
||||
|
||||
const testContextKey = 'my-test-context';
|
||||
|
||||
class MyClass {
|
||||
prop = 'value from provider';
|
||||
}
|
||||
|
||||
describe('UmbContextRequester', () => {
|
||||
let requestor: UmbContextRequester;
|
||||
|
||||
beforeEach(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
requestor = new UmbContextRequester(document.body, testContextKey, () => {});
|
||||
});
|
||||
|
||||
describe('Public API', () => {
|
||||
describe('methods', () => {
|
||||
it('has a dispatchRequest method', () => {
|
||||
expect(requestor).to.have.property('dispatchRequest').that.is.a('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('events', () => {
|
||||
it('dispatches request context event when constructed', async () => {
|
||||
const listener = oneEvent(window, umbContextRequestType);
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
new UmbContextRequester(document.body, testContextKey, () => {});
|
||||
const event = await listener as unknown as UmbContextRequestEvent;
|
||||
expect(event).to.exist;
|
||||
expect(event.type).to.eq(umbContextRequestType);
|
||||
expect(event.contextKey).to.eq(testContextKey);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('works with UmbContextProvider', (done: any) => {
|
||||
const provider = new UmbContextProvider(document.body, testContextKey, new MyClass());
|
||||
|
||||
const element = document.createElement('div');
|
||||
document.body.appendChild(element);
|
||||
|
||||
new UmbContextRequester(element, testContextKey, (_instance) => {
|
||||
expect(_instance.prop).to.eq('value from provider');
|
||||
done();
|
||||
});
|
||||
|
||||
provider.detach();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { UmbContextRequestEvent, UmbContextCallback } from './context-request.event';
|
||||
|
||||
/**
|
||||
* @export
|
||||
* @class UmbContextRequester
|
||||
*/
|
||||
export class UmbContextRequester {
|
||||
|
||||
/**
|
||||
* Creates an instance of UmbContextRequester.
|
||||
* @param {HTMLElement} element
|
||||
* @param {string} _contextKey
|
||||
* @param {UmbContextCallback} _callback
|
||||
* @memberof UmbContextRequester
|
||||
*/
|
||||
constructor (
|
||||
protected element: HTMLElement,
|
||||
private _contextKey: string,
|
||||
private _callback: UmbContextCallback
|
||||
) {
|
||||
this.dispatchRequest();
|
||||
}
|
||||
|
||||
/**
|
||||
* @memberof UmbContextRequester
|
||||
*/
|
||||
dispatchRequest() {
|
||||
const event = new UmbContextRequestEvent(this._contextKey, this._callback);
|
||||
this.element.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
5
src/Umbraco.Web.UI.Client/src/core/context/index.ts
Normal file
5
src/Umbraco.Web.UI.Client/src/core/context/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './context-requester';
|
||||
export * from './context-request.event';
|
||||
export * from './context-provider';
|
||||
export * from './context-provide.mixin';
|
||||
export * from './context-inject.mixin';
|
||||
@@ -11,6 +11,7 @@ import { customElement, state } from 'lit/decorators.js';
|
||||
import { getInitStatus } from './api/fetcher';
|
||||
import { UmbRoute, UmbRouter } from './core/router';
|
||||
import { worker } from './mocks/browser';
|
||||
import { UmbContextProvideMixin } from './core/context';
|
||||
|
||||
const routes: Array<UmbRoute> = [
|
||||
{
|
||||
@@ -29,7 +30,7 @@ const routes: Array<UmbRoute> = [
|
||||
|
||||
// Import somewhere else?
|
||||
@customElement('umb-app')
|
||||
export class UmbApp extends LitElement {
|
||||
export class UmbApp extends UmbContextProvideMixin(LitElement) {
|
||||
static styles = css`
|
||||
:host,
|
||||
#outlet {
|
||||
@@ -50,14 +51,6 @@ export class UmbApp extends LitElement {
|
||||
this._authorized = sessionStorage.getItem('is-authenticated') === 'true';
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
// TODO: remove when router can be injected into login element
|
||||
this.addEventListener('login', () => {
|
||||
this._router?.push('/section/content');
|
||||
});
|
||||
}
|
||||
|
||||
protected async firstUpdated(): Promise<void> {
|
||||
const outlet = this.shadowRoot?.getElementById('outlet');
|
||||
if (!outlet) return;
|
||||
@@ -65,6 +58,9 @@ export class UmbApp extends LitElement {
|
||||
this._router = new UmbRouter(this, outlet);
|
||||
this._router.setRoutes(routes);
|
||||
|
||||
// TODO: find a solution for magic strings
|
||||
this.provide('umbRouter', this._router);
|
||||
|
||||
try {
|
||||
const { data } = await getInitStatus({});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user