add context api first draft

This commit is contained in:
Mads Rasmussen
2022-05-19 16:38:42 +02:00
parent ad9e68fa51
commit 9c50a70415
15 changed files with 3119 additions and 15 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();
});
});
});

View File

@@ -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);
}
}

View File

@@ -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;
});
});

View File

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

View File

@@ -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();
});
});

View File

@@ -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);
}
}

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

View File

@@ -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({});