Skip to content

Commit

Permalink
feat(view-locator): allow associating views with classes
Browse files Browse the repository at this point in the history
This enables any class to have one or more associated views. With a view associated, the au-compose element can then render instances of the class by using the view value converter.
  • Loading branch information
EisenbergEffect committed Aug 19, 2019
1 parent c65ccf0 commit 9a89686
Show file tree
Hide file tree
Showing 4 changed files with 174 additions and 1 deletion.
3 changes: 3 additions & 0 deletions packages/runtime/src/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { Repeat } from './resources/custom-attributes/repeat';
import { Replaceable } from './resources/custom-attributes/replaceable';
import { With } from './resources/custom-attributes/with';
import { SanitizeValueConverter } from './resources/value-converters/sanitize';
import { ViewValueConverter } from './resources/value-converters/view';

export const IObserverLocatorRegistration = ObserverLocator as IRegistry;
export const ILifecycleRegistration = Lifecycle as IRegistry;
Expand Down Expand Up @@ -67,6 +68,7 @@ export const RepeatRegistration = Repeat as IRegistry;
export const ReplaceableRegistration = Replaceable as unknown as IRegistry;
export const WithRegistration = With as IRegistry;
export const SanitizeValueConverterRegistration = SanitizeValueConverter as unknown as IRegistry;
export const ViewValueConverterRegistration = ViewValueConverter as unknown as IRegistry;
export const DebounceBindingBehaviorRegistration = DebounceBindingBehavior as unknown as IRegistry;
export const OneTimeBindingBehaviorRegistration = OneTimeBindingBehavior as unknown as IRegistry;
export const ToViewBindingBehaviorRegistration = ToViewBindingBehavior as unknown as IRegistry;
Expand All @@ -92,6 +94,7 @@ export const DefaultResources = [
ReplaceableRegistration,
WithRegistration,
SanitizeValueConverterRegistration,
ViewValueConverterRegistration,
DebounceBindingBehaviorRegistration,
OneTimeBindingBehaviorRegistration,
ToViewBindingBehaviorRegistration,
Expand Down
3 changes: 3 additions & 0 deletions packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,9 @@ export {
} from './templating/controller';
export {
ViewFactory,
IViewLocator,
ViewLocator,
view
} from './templating/view';

export {
Expand Down
14 changes: 14 additions & 0 deletions packages/runtime/src/resources/value-converters/view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { IViewLocator } from '../../templating/view';
import { valueConverter } from '../value-converter';

@valueConverter('view')
export class ViewValueConverter {
constructor(@IViewLocator private viewLocator: IViewLocator) {}

public toView(subject: object, viewName?: string) {
return this.viewLocator.getViewComponentForModelInstance(
subject,
viewName
);
}
}
155 changes: 154 additions & 1 deletion packages/runtime/src/templating/view.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PLATFORM, Reporter } from '@aurelia/kernel';
import { PLATFORM, Reporter, Constructable, DI, Writable } from '@aurelia/kernel';
import { ITemplateDefinition, TemplatePartDefinitions } from '../definitions';
import { INode } from '../dom';
import { LifecycleFlags, State } from '../flags';
Expand All @@ -9,6 +9,9 @@ import {
} from '../lifecycle';
import { ITemplate } from '../rendering-engine';
import { Controller } from './controller';
import { CustomElement } from '../resources/custom-element';
import { Scope } from '../observation/binding-context';
import { IScope } from '../observation';

export class ViewFactory<T extends INode = INode> implements IViewFactory<T> {
public static maxCacheSize: number = 0xFFFF;
Expand Down Expand Up @@ -95,3 +98,153 @@ export class ViewFactory<T extends INode = INode> implements IViewFactory<T> {
}
}
}

type HasAssociatedViews = {
$views: ITemplateDefinition[];
};

export function view(v: ITemplateDefinition) {
return function<T extends Constructable>(target: T & Partial<HasAssociatedViews>) {
const views = target.$views || (target.$views = []);
views.push(v);
};
}

function hasAssociatedViews<T>(object: T): object is T & HasAssociatedViews {
return object && '$views' in object;
}

export const IViewLocator = DI.createInterface<IViewLocator>('IViewLocator')
.withDefault(x => x.singleton(ViewLocator));

export interface IViewLocator {
getViewComponentForModelInstance(model: object, requestedViewName?: string): Constructable | null;
}

const lifecycleCallbacks = [
'binding',
'bound',
'attaching',
'attached',
'detaching',
'caching',
'detached',
'unbinding',
'unbound'
];

export class ViewLocator implements IViewLocator {
private modelInstanceToBoundComponent: WeakMap<object, Record<string, Constructable>> = new WeakMap();
private modelTypeToUnboundComponent: Map<object, Record<string, Constructable>> = new Map();

public getViewComponentForModelInstance(model: object, viewName?: string) {
if (model && hasAssociatedViews(model.constructor)) {
const availableViews = model.constructor.$views;
const resolvedViewName = this.getViewName(availableViews, viewName);

return this.getOrCreateBoundComponent(
model,
availableViews,
resolvedViewName
);
}

return null;
}

private getOrCreateBoundComponent(model: object, availableViews: ITemplateDefinition[], resolvedViewName: string) {
let lookup = this.modelInstanceToBoundComponent.get(model);
let BoundComponent;

if (!lookup) {
lookup = {};
this.modelInstanceToBoundComponent.set(model, lookup);
} else {
BoundComponent = lookup[resolvedViewName];
}

if (!BoundComponent) {
const UnboundComponent = this.getOrCreateUnboundComponent(
model,
availableViews,
resolvedViewName
);

BoundComponent = class extends UnboundComponent {
constructor() {
super(model);
}
};

lookup[resolvedViewName] = BoundComponent;
}

return BoundComponent;
}

private getOrCreateUnboundComponent(model: object, availableViews: ITemplateDefinition[], resolvedViewName: string) {
let lookup = this.modelTypeToUnboundComponent.get(model.constructor);
let UnboundComponent;

if (!lookup) {
lookup = {};
this.modelTypeToUnboundComponent.set(model.constructor, lookup);
} else {
UnboundComponent = lookup[resolvedViewName];
}

if (!UnboundComponent) {
UnboundComponent = CustomElement.define(
this.getView(availableViews, resolvedViewName),
class {
protected $scope!: IScope;

constructor(protected viewModel: any) {}

public created() {
this.$scope = Scope.fromParent(0, this.$scope, this.viewModel);

if ('created' in this.viewModel) {
this.viewModel.created();
}
}
}
);

const proto = UnboundComponent.prototype as any;

lifecycleCallbacks.forEach(x => {
if (x in model) {
proto[x] = function() { return this.viewModel[x](); };
}
});

lookup[resolvedViewName] = UnboundComponent;
}

return UnboundComponent;
}

private getViewName(views: ITemplateDefinition[], requestedName?: string) {
if (requestedName) {
return requestedName;
}

if (views.length === 1) {
return views[0].name;
}

return 'default';
}

private getView(views: ITemplateDefinition[], name: string): ITemplateDefinition {
const v = views.find(x => x.name === name);

if (!v) {
// TODO: user Reporter
throw new Error(`Could not find view: ${name}`);
}

return v;
}
}

0 comments on commit 9a89686

Please sign in to comment.