Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Outlets API #576

Merged
merged 11 commits into from Nov 17, 2022
22 changes: 21 additions & 1 deletion src/core/context.ts
Expand Up @@ -8,14 +8,17 @@ import { Schema } from "./schema"
import { Scope } from "./scope"
import { ValueObserver } from "./value_observer"
import { TargetObserver, TargetObserverDelegate } from "./target_observer"
import { OutletObserver, OutletObserverDelegate } from "./outlet_observer"
import { namespaceCamelize } from "./string_helpers"

export class Context implements ErrorHandler, TargetObserverDelegate {
export class Context implements ErrorHandler, TargetObserverDelegate, OutletObserverDelegate {
readonly module: Module
readonly scope: Scope
readonly controller: Controller
private bindingObserver: BindingObserver
private valueObserver: ValueObserver
private targetObserver: TargetObserver
private outletObserver: OutletObserver

constructor(module: Module, scope: Scope) {
this.module = module
Expand All @@ -24,6 +27,7 @@ export class Context implements ErrorHandler, TargetObserverDelegate {
this.bindingObserver = new BindingObserver(this, this.dispatcher)
this.valueObserver = new ValueObserver(this, this.controller)
this.targetObserver = new TargetObserver(this, this)
this.outletObserver = new OutletObserver(this, this)

try {
this.controller.initialize()
Expand All @@ -37,6 +41,7 @@ export class Context implements ErrorHandler, TargetObserverDelegate {
this.bindingObserver.start()
this.valueObserver.start()
this.targetObserver.start()
this.outletObserver.start()

try {
this.controller.connect()
Expand All @@ -46,6 +51,10 @@ export class Context implements ErrorHandler, TargetObserverDelegate {
}
}

refresh() {
this.outletObserver.refresh()
}

disconnect() {
try {
this.controller.disconnect()
Expand All @@ -54,6 +63,7 @@ export class Context implements ErrorHandler, TargetObserverDelegate {
this.handleError(error, "disconnecting controller")
}

this.outletObserver.stop()
this.targetObserver.stop()
this.valueObserver.stop()
this.bindingObserver.stop()
Expand Down Expand Up @@ -109,6 +119,16 @@ export class Context implements ErrorHandler, TargetObserverDelegate {
this.invokeControllerMethod(`${name}TargetDisconnected`, element)
}

// Outlet observer delegate

outletConnected(outlet: Controller, element: Element, name: string) {
this.invokeControllerMethod(`${namespaceCamelize(name)}OutletConnected`, outlet, element)
}

outletDisconnected(outlet: Controller, element: Element, name: string) {
this.invokeControllerMethod(`${namespaceCamelize(name)}OutletDisconnected`, outlet, element)
}

// Private

invokeControllerMethod(methodName: string, ...args: any[]) {
Expand Down
13 changes: 12 additions & 1 deletion src/core/controller.ts
Expand Up @@ -2,14 +2,21 @@ import { Application } from "./application"
import { ClassPropertiesBlessing } from "./class_properties"
import { Constructor } from "./constructor"
import { Context } from "./context"
import { OutletPropertiesBlessing } from "./outlet_properties"
import { TargetPropertiesBlessing } from "./target_properties"
import { ValuePropertiesBlessing, ValueDefinitionMap } from "./value_properties"

export type ControllerConstructor = Constructor<Controller>

export class Controller<ElementType extends Element = Element> {
static blessings = [ClassPropertiesBlessing, TargetPropertiesBlessing, ValuePropertiesBlessing]
static blessings = [
ClassPropertiesBlessing,
TargetPropertiesBlessing,
ValuePropertiesBlessing,
OutletPropertiesBlessing,
]
static targets: string[] = []
static outlets: string[] = []
static values: ValueDefinitionMap = {}

static get shouldLoad() {
Expand Down Expand Up @@ -46,6 +53,10 @@ export class Controller<ElementType extends Element = Element> {
return this.scope.targets
}

get outlets() {
return this.scope.outlets
}

get classes() {
return this.scope.classes
}
Expand Down
173 changes: 173 additions & 0 deletions src/core/outlet_observer.ts
@@ -0,0 +1,173 @@
import { Multimap } from "../multimap"
import { SelectorObserver, SelectorObserverDelegate } from "../mutation-observers"
import { Context } from "./context"
import { Controller } from "./controller"

import { readInheritableStaticArrayValues } from "./inheritable_statics"

type SelectorObserverDetails = { outletName: string }

export interface OutletObserverDelegate {
outletConnected(outlet: Controller, element: Element, outletName: string): void
outletDisconnected(outlet: Controller, element: Element, outletName: string): void
}

export class OutletObserver implements SelectorObserverDelegate {
readonly context: Context
readonly delegate: OutletObserverDelegate
readonly outletsByName: Multimap<string, Controller>
readonly outletElementsByName: Multimap<string, Element>
private selectorObserverMap: Map<string, SelectorObserver>

constructor(context: Context, delegate: OutletObserverDelegate) {
this.context = context
this.delegate = delegate
this.outletsByName = new Multimap()
this.outletElementsByName = new Multimap()
this.selectorObserverMap = new Map()
}

start() {
if (this.selectorObserverMap.size === 0) {
this.outletDefinitions.forEach((outletName) => {
const selector = this.selector(outletName)
const details: SelectorObserverDetails = { outletName }

if (selector) {
this.selectorObserverMap.set(outletName, new SelectorObserver(document.body, selector, this, details))
}
})

this.selectorObserverMap.forEach((observer) => observer.start())
}

this.dependentContexts.forEach((context) => context.refresh())
}

stop() {
if (this.selectorObserverMap.size > 0) {
this.disconnectAllOutlets()
this.selectorObserverMap.forEach((observer) => observer.stop())
this.selectorObserverMap.clear()
}
}

refresh() {
this.selectorObserverMap.forEach((observer) => observer.refresh())
}

// Selector observer delegate

selectorMatched(element: Element, _selector: string, { outletName }: SelectorObserverDetails) {
const outlet = this.getOutlet(element, outletName)

if (outlet) {
this.connectOutlet(outlet, element, outletName)
}
}

selectorUnmatched(element: Element, _selector: string, { outletName }: SelectorObserverDetails) {
const outlet = this.getOutletFromMap(element, outletName)

if (outlet) {
this.disconnectOutlet(outlet, element, outletName)
}
}

selectorMatchElement(element: Element, { outletName }: SelectorObserverDetails) {
return (
this.hasOutlet(element, outletName) &&
element.matches(`[${this.context.application.schema.controllerAttribute}~=${outletName}]`)
)
}

// Outlet management

connectOutlet(outlet: Controller, element: Element, outletName: string) {
if (!this.outletElementsByName.has(outletName, element)) {
this.outletsByName.add(outletName, outlet)
this.outletElementsByName.add(outletName, element)
this.selectorObserverMap.get(outletName)?.pause(() => this.delegate.outletConnected(outlet, element, outletName))
}
}

disconnectOutlet(outlet: Controller, element: Element, outletName: string) {
if (this.outletElementsByName.has(outletName, element)) {
this.outletsByName.delete(outletName, outlet)
this.outletElementsByName.delete(outletName, element)
this.selectorObserverMap
.get(outletName)
?.pause(() => this.delegate.outletDisconnected(outlet, element, outletName))
}
}

disconnectAllOutlets() {
for (const outletName of this.outletElementsByName.keys) {
for (const element of this.outletElementsByName.getValuesForKey(outletName)) {
for (const outlet of this.outletsByName.getValuesForKey(outletName)) {
this.disconnectOutlet(outlet, element, outletName)
}
}
}
}

// Private

private selector(outletName: string) {
return this.scope.outlets.getSelectorForOutletName(outletName)
}

private get outletDependencies() {
const dependencies = new Multimap<string, string>()

this.router.modules.forEach((module) => {
const constructor = module.definition.controllerConstructor
const outlets = readInheritableStaticArrayValues(constructor, "outlets")

outlets.forEach((outlet) => dependencies.add(outlet, module.identifier))
})

return dependencies
}

private get outletDefinitions() {
return this.outletDependencies.getKeysForValue(this.identifier)
}

private get dependentControllerIdentifiers() {
return this.outletDependencies.getValuesForKey(this.identifier)
}

private get dependentContexts() {
const identifiers = this.dependentControllerIdentifiers
return this.router.contexts.filter((context) => identifiers.includes(context.identifier))
}

private hasOutlet(element: Element, outletName: string) {
return !!this.getOutlet(element, outletName) || !!this.getOutletFromMap(element, outletName)
}

private getOutlet(element: Element, outletName: string) {
return this.application.getControllerForElementAndIdentifier(element, outletName)
}

private getOutletFromMap(element: Element, outletName: string) {
return this.outletsByName.getValuesForKey(outletName).find((outlet) => outlet.element === element)
}

private get scope() {
return this.context.scope
}

private get identifier() {
return this.context.identifier
}

private get application() {
return this.context.application
}

private get router() {
return this.application.router
}
}
83 changes: 83 additions & 0 deletions src/core/outlet_properties.ts
@@ -0,0 +1,83 @@
import { Constructor } from "./constructor"
import { Controller } from "./controller"
import { readInheritableStaticArrayValues } from "./inheritable_statics"
import { capitalize, namespaceCamelize } from "./string_helpers"

export function OutletPropertiesBlessing<T>(constructor: Constructor<T>) {
const outlets = readInheritableStaticArrayValues(constructor, "outlets")
return outlets.reduce((properties: any, outletDefinition: any) => {
return Object.assign(properties, propertiesForOutletDefinition(outletDefinition))
}, {} as PropertyDescriptorMap)
}

function propertiesForOutletDefinition(name: string) {
const camelizedName = namespaceCamelize(name)

return {
[`${camelizedName}Outlet`]: {
get(this: Controller) {
const outlet = this.outlets.find(name)

if (outlet) {
const outletController = this.application.getControllerForElementAndIdentifier(outlet, name)
if (outletController) {
return outletController
} else {
throw new Error(
`Missing "data-controller=${name}" attribute on outlet element for "${this.identifier}" controller`
)
}
}

throw new Error(`Missing outlet element "${name}" for "${this.identifier}" controller`)
},
},

[`${camelizedName}Outlets`]: {
get(this: Controller) {
const outlets = this.outlets.findAll(name)

if (outlets.length > 0) {
return outlets
.map((outlet: Element) => {
const controller = this.application.getControllerForElementAndIdentifier(outlet, name)
if (controller) {
return controller
} else {
console.warn(
`The provided outlet element is missing the outlet controller "${name}" for "${this.identifier}"`,
outlet
)
}
})
.filter((controller) => controller) as Controller[]
}

return []
},
},

[`${camelizedName}OutletElement`]: {
get(this: Controller) {
const outlet = this.outlets.find(name)
if (outlet) {
return outlet
} else {
throw new Error(`Missing outlet element "${name}" for "${this.identifier}" controller`)
}
},
},

[`${camelizedName}OutletElements`]: {
get(this: Controller) {
return this.outlets.findAll(name)
},
},

[`has${capitalize(camelizedName)}Outlet`]: {
get(this: Controller) {
return this.outlets.has(name)
},
},
}
}