Skip to content

Commit

Permalink
Add effect and effect-fn controllers
Browse files Browse the repository at this point in the history
  • Loading branch information
sleepyfran committed Aug 31, 2024
1 parent 5e85697 commit eb4155c
Show file tree
Hide file tree
Showing 12 changed files with 258 additions and 96 deletions.
1 change: 1 addition & 0 deletions packages/components/add-provider/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"dependencies": {
"@echo/core-types": "^1.0.0",
"@echo/components-provider-status": "^1.0.0",
"@echo/components-shared-controllers": "^1.0.0",
"@echo/services-bootstrap-runtime": "^1.0.0",
"effect": "^3.6.5",
"lit": "^3.2.0",
Expand Down
64 changes: 37 additions & 27 deletions packages/components/add-provider/src/add-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import {
type FolderMetadata,
type ProviderMetadata,
} from "@echo/core-types";
import { Task } from "@lit/task";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { getOrCreateRuntime } from "@echo/services-bootstrap-runtime";
import type { ProviderLoadedEvent } from "./provider-loader";
import "@echo/components-provider-status";
import "./provider-loader";
import "./select-root";
import { EffectController } from "@echo/components-shared-controllers";
import { Match } from "effect";

type ProviderStatus =
| { _tag: "LoadingProviders" }
Expand All @@ -27,34 +27,44 @@ export class AddProvider extends LitElement {
@property()
private _providerStatus: ProviderStatus = { _tag: "LoadingProviders" };

// @ts-expect-error "Task executes automatically"
private _availableProvidersTask = new Task(this, {
task: () =>
getOrCreateRuntime()
.runPromise(AddProviderWorkflow.availableProviders)
.then((availableProviders) => {
this._providerStatus = {
_tag: "ProvidersLoaded",
availableProviders,
};
}),
args: () => [],
});
connectedCallback(): void {
super.connectedCallback();

new EffectController(this, AddProviderWorkflow.availableProviders, {
complete: (availableProviders) => {
this._providerStatus = {
_tag: "ProvidersLoaded",
availableProviders,
};
},
});
}

render() {
return this._providerStatus._tag === "LoadingProviders"
? html`<h1>Loading providers...</h1>`
: this._providerStatus._tag === "ProvidersLoaded"
? html`<provider-loader
.availableProviders=${this._providerStatus.availableProviders}
return Match.value(this._providerStatus).pipe(
Match.tag("LoadingProviders", () => html`<h1>Loading providers...</h1>`),
Match.tag(
"ProvidersLoaded",
({ availableProviders }) =>
html`<provider-loader
.availableProviders=${availableProviders}
@provider-loaded=${this._onProviderLoaded}
></provider-loader>`
: this._providerStatus._tag === "WaitingForRootFolderSelection"
? html`<select-root
.availableFolders=${this._providerStatus.folders}
@root-selected=${this._onRootSelected}
></select-root>`
: html`<provider-status></provider-status>`;
></provider-loader>`,
),
Match.tag(
"WaitingForRootFolderSelection",
({ folders }) =>
html`<select-root
.availableFolders=${folders}
@root-selected=${this._onRootSelected}
></select-root>`,
),
Match.tag(
"ProviderStarted",
() => html`<provider-status></provider-status>`,
),
Match.exhaustive,
);
}

private _onProviderLoaded(event: ProviderLoadedEvent) {
Expand Down
96 changes: 55 additions & 41 deletions packages/components/add-provider/src/provider-loader.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { EffectFnController } from "@echo/components-shared-controllers/src/effect-fn.controller";
import {
AddProviderWorkflow,
type FolderMetadata,
type ProviderMetadata,
} from "@echo/core-types";
import { getOrCreateRuntime } from "@echo/services-bootstrap-runtime";
import { Task } from "@lit/task";
import { Match } from "effect";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";

Expand All @@ -18,58 +18,72 @@ export class ProviderLoadedEvent extends Event {
}
}

type LoaderStatus =
| { _tag: "Initial" }
| { _tag: "LoadingProvider" }
| { _tag: "WaitingToConnect"; metadata: ProviderMetadata }
| { _tag: "ConnectingToProvider" }
| { _tag: "Connected" };

/**
* Component that displays a list of available providers and loads them upon selection.
*/
@customElement("provider-loader")
export class ProviderLoader extends LitElement {
@property()
private _loaderStatus: LoaderStatus = { _tag: "Initial" };

@property({ type: Array })
availableProviders: ProviderMetadata[] = [];

private _loadProvider = new Task(this, {
task: ([provider]: [ProviderMetadata]) =>
getOrCreateRuntime().runPromise(
AddProviderWorkflow.loadProvider(provider),
),
autoRun: false,
});

private _connectToProvider = new Task(this, {
task: () =>
getOrCreateRuntime().runPromise(AddProviderWorkflow.connectToProvider()),
autoRun: false,
});
private _loadProvider = new EffectFnController(
this,
(metadata: ProviderMetadata) => AddProviderWorkflow.loadProvider(metadata),
{
complete: (metadata) => {
this._loaderStatus = { _tag: "WaitingToConnect", metadata };
},
},
);

// @ts-expect-error "Task executes automatically"
private _notifyProviderLoaded = new Task(this, {
args: () => [this._connectToProvider.value],
task: ([rootFolder]) => {
if (rootFolder) {
private _connectToProvider = new EffectFnController(
this,
() => AddProviderWorkflow.connectToProvider,
{
pending: () => {
this._loaderStatus = { _tag: "ConnectingToProvider" };
},
complete: (rootFolder) => {
this._loaderStatus = { _tag: "Connected" };
this.dispatchEvent(new ProviderLoadedEvent(rootFolder));
}
},
},
});
);

render() {
return this._connectToProvider.render({
initial: () =>
this._loadProvider.render({
initial: () =>
this.availableProviders.map(
(provider) => html`
<button @click=${() => this._loadProvider.run([provider])}>
${provider.id}
</button>
`,
),
complete: (providerMetadata) =>
html`<button @click="${() => this._connectToProvider.run()}">
Connect to ${providerMetadata.id}
</button>`,
}),
pending: () => html`<h1>Connecting...</h1>`,
complete: () => html`<h1>Connected!</h1>`,
});
return Match.value(this._loaderStatus).pipe(
Match.tag("Initial", () =>
this.availableProviders.map(
(provider) => html`
<button @click=${() => this._loadProvider.run(provider)}>
${provider.id}
</button>
`,
),
),
Match.tag(
"WaitingToConnect",
({ metadata }) => html`
<button @click=${() => this._connectToProvider.run({})}>
Connect to ${metadata.id}
</button>
`,
),
Match.tag("LoadingProvider", () => html`<h1>Loading provider...</h1>`),
Match.tag("ConnectingToProvider", () => html`<h1>Connecting...</h1>`),
Match.tag("Connected", () => html`<h1>Connected!</h1>`),
Match.exhaustive,
);
}
}

Expand Down
27 changes: 8 additions & 19 deletions packages/components/add-provider/src/select-root.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { EffectFnController } from "@echo/components-shared-controllers/src/effect-fn.controller";
import { AddProviderWorkflow, type FolderMetadata } from "@echo/core-types";
import { getOrCreateRuntime } from "@echo/services-bootstrap-runtime";
import { Task } from "@lit/task";
import { LitElement, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";

Expand All @@ -23,31 +22,21 @@ export class SelectRoot extends LitElement {
@property({ type: Array })
availableFolders: FolderMetadata[] = [];

private _selectRoot = new Task(this, {
task: ([rootFolder]: [FolderMetadata]) =>
getOrCreateRuntime().runPromise(
AddProviderWorkflow.selectRoot(rootFolder),
),
autoRun: false,
});

// @ts-expect-error "Task executes automatically"
private _notifyProviderStarted = new Task(this, {
args: () => [this._selectRoot.value],
task: ([completed]) => {
if (completed !== undefined) {
this.dispatchEvent(new ProviderStartedEvent());
}
private _selectRoot = new EffectFnController(
this,
(rootFolder: FolderMetadata) => AddProviderWorkflow.selectRoot(rootFolder),
{
complete: () => this.dispatchEvent(new ProviderStartedEvent()),
},
});
);

render() {
return this._selectRoot.render({
initial: () => html`
<h1>Select a root folder:</h1>
${this.availableFolders.map(
(folder) =>
html`<button @click=${() => this._selectRoot.run([folder])}>
html`<button @click=${() => this._selectRoot.run(folder)}>
${folder.name}
</button>`,
)}
Expand Down
1 change: 1 addition & 0 deletions packages/components/shared-controllers/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./src/effect.controller";
export * from "./src/stream-effect.controller";
93 changes: 93 additions & 0 deletions packages/components/shared-controllers/src/effect-fn.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {
getOrCreateRuntime,
type EchoRuntimeServices,
} from "@echo/services-bootstrap-runtime";
import { Effect } from "effect";
import type { ReactiveController, ReactiveControllerHost } from "lit";
import type { StatusListener } from "./shared.interface";

type StreamStatus<A, E> =
| { _tag: "Initial" }
| { _tag: "Pending" }
| { _tag: "Complete"; result: A }
| { _tag: "Error"; error: E };

/**
* Controller that takes a function that produces an effect and exposes a
* method to execute the effect and render the different states of the effect.
*/
export class EffectFnController<P, A, E> implements ReactiveController {
private host: ReactiveControllerHost;
private _status: StreamStatus<A, E> = { _tag: "Initial" };

constructor(
host: ReactiveControllerHost,
private readonly _effectFn: (
p: P,
) => Effect.Effect<A, E, EchoRuntimeServices>,
/**
* Optional listeners that will be called when the effect produces a value
* or errors. Only meant to be used by hosts that require side effects
* when the effect produces a value or errors, otherwise use the render
* method to render the different states of the effect.
*/
private readonly _listeners?: Omit<StatusListener<A, E>, "initial">,
) {
(this.host = host).addController(this);
}

hostConnected(): void {}

/**
* Runs the effect with the given parameters. This produces a value or an error
* that gets notified to the host and triggers the listeners if they are provided.
*/
run(params: P) {
const consumer = this._effectFn(params).pipe(
Effect.tap((result) =>
Effect.sync(() => this.handleUpdate$({ _tag: "Complete", result })),
),
Effect.tapError((error) =>
Effect.sync(() => this.handleUpdate$({ _tag: "Error", error })),
),
);

this.handleUpdate$({
_tag: "Pending",
});
getOrCreateRuntime().runPromise(consumer);
}

/**
* Maps the different states of the effect to a renderer.
*/
render(renderer: StatusListener<A, E>) {
switch (this._status._tag) {
case "Initial":
return renderer.initial?.();
case "Pending":
return renderer.pending?.();
case "Complete":
return renderer.complete?.(this._status.result);
case "Error":
return renderer.error?.(this._status.error);
}
}

private handleUpdate$(state: StreamStatus<A, E>) {
switch (state._tag) {
case "Pending":
this._listeners?.pending?.();
break;
case "Complete":
this._listeners?.complete?.(state.result);
break;
case "Error":
this._listeners?.error?.(state.error);
break;
}

this._status = state;
this.host.requestUpdate();
}
}
33 changes: 33 additions & 0 deletions packages/components/shared-controllers/src/effect.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { type EchoRuntimeServices } from "@echo/services-bootstrap-runtime";
import { Effect } from "effect";
import type { ReactiveControllerHost } from "lit";
import type { StatusListener } from "./shared.interface";
import { EffectFnController } from "./effect-fn.controller";

/**
* Controller that takes an effect that can be executed by the default runtime
* of the application, and exposes a render method that maps each different
* status of the effect to a renderer. This is meant to be used with effects
* that are one-shot, meaning they only produce a single value. For multiple
* values, use the StreamEffectController, which can handle streams and
* subscription refs.
*/
export class EffectController<A, E> extends EffectFnController<void, A, E> {
constructor(
host: ReactiveControllerHost,
_effect: Effect.Effect<A, E, EchoRuntimeServices>,
/**
* Optional listeners that will be called when the effect produces a value
* or errors. Only meant to be used by hosts that require side effects
* when the effect produces a value or errors, otherwise use the render
* method to render the different states of the effect.
*/
_listeners?: Omit<StatusListener<A, E>, "initial">,
) {
super(host, () => _effect, _listeners);
}

hostConnected(): void {
this.run();
}
}
Loading

0 comments on commit eb4155c

Please sign in to comment.