From f5b10c6cd84fd1c0d5fce815eb025b28e4efad25 Mon Sep 17 00:00:00 2001 From: Bernhard Seefeld Date: Fri, 21 Jun 2024 11:58:57 -0700 Subject: [PATCH 1/3] sagas can name themselves now --- .../common-ui/src/components/input.ts | 2 +- .../common-ui/src/hyperscript/tags.ts | 1 + .../src/components/saga-link.ts | 42 +++++++++++++-- .../packages/lookslike-high-level/src/data.ts | 31 +++++------ .../packages/lookslike-high-level/src/main.ts | 6 +-- .../lookslike-high-level/src/recipe.ts | 4 +- .../src/recipes/{saga-list.ts => home.ts} | 11 ++-- .../src/recipes/todo-list-as-task.ts | 2 +- .../src/recipes/todo-list.ts | 52 ++++++++++++------- 9 files changed, 103 insertions(+), 48 deletions(-) rename typescript/packages/lookslike-high-level/src/recipes/{saga-list.ts => home.ts} (59%) diff --git a/typescript/packages/common-ui/src/components/input.ts b/typescript/packages/common-ui/src/components/input.ts index 3b9a40f24..4706b8191 100644 --- a/typescript/packages/common-ui/src/components/input.ts +++ b/typescript/packages/common-ui/src/components/input.ts @@ -27,4 +27,4 @@ export const fileInput = (props: Props) => input({ ...props, type: "file" }); export const imageInput = (props: Props) => input({ ...props, type: "image" }); export const password = (props: Props) => input({ ...props, type: "password" }); export const searchInput = (props: Props) => - input({ ...props, type: "search" }); \ No newline at end of file + input({ ...props, type: "search" }); diff --git a/typescript/packages/common-ui/src/hyperscript/tags.ts b/typescript/packages/common-ui/src/hyperscript/tags.ts index 8a521e59f..c707c1c10 100644 --- a/typescript/packages/common-ui/src/hyperscript/tags.ts +++ b/typescript/packages/common-ui/src/hyperscript/tags.ts @@ -5,6 +5,7 @@ export { navpanel } from "../components/common-navpanel.js"; export { record } from "../components/common-record.js"; export { dict } from "../components/common-dict.js"; export { datatable } from "../components/common-datatable.js"; +export { commonInput } from "../components/common-input.js"; export { list } from "../components/common-list.js"; export { suggestions } from "../components/common-suggestions.js"; export { unibox } from "../components/common-unibox.js"; diff --git a/typescript/packages/lookslike-high-level/src/components/saga-link.ts b/typescript/packages/lookslike-high-level/src/components/saga-link.ts index 88da1f655..feafbc20b 100644 --- a/typescript/packages/lookslike-high-level/src/components/saga-link.ts +++ b/typescript/packages/lookslike-high-level/src/components/saga-link.ts @@ -1,11 +1,12 @@ import { LitElement, html, css } from "lit"; import { customElement, property } from "lit/decorators.js"; import { render } from "@commontools/common-ui"; +import { signal, Cancel } from "@commontools/common-frp"; import { Gem, ID, NAME } from "../recipe.js"; export const sagaLink = render.view("common-saga-link", { saga: { type: "object" }, - name: { tyoe: "string" }, + name: { type: "string" }, }); @customElement("common-saga-link") @@ -26,6 +27,9 @@ export class CommonSagaLink extends LitElement { @property({ type: String }) name: string | undefined = undefined; + private nameEffect: Cancel | undefined; + private nameFromGem: string | undefined; + handleClick(e: Event) { e.preventDefault(); this.dispatchEvent( @@ -37,13 +41,43 @@ export class CommonSagaLink extends LitElement { ); } + override connectedCallback() { + super.connectedCallback(); + this.maybeListenToName(); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.nameEffect?.(); + } + + override updated(changedProperties: Map) { + super.updated(changedProperties); + if (changedProperties.has("saga")) { + this.maybeListenToName(); + } + } + + private maybeListenToName() { + if (signal.isSignal(this.saga?.[NAME])) { + console.log("listening to name", this.saga[NAME]); + this.nameEffect = signal.effect([this.saga[NAME]], (name: string) => { + this.nameFromGem = name; + this.requestUpdate(); + }); + } else { + this.nameEffect?.(); + this.nameFromGem = this.saga?.[NAME]; + } + } + override render() { console.log("rendering saga link", this.saga, this.name); if (!this.saga) return html``; + const name = this.name ?? this.nameFromGem; + console.log("rendering saga link", name, this.saga[NAME]); return html` - - ${this.name ?? this.saga[NAME]} - + 🔮 ${name} `; } } diff --git a/typescript/packages/lookslike-high-level/src/data.ts b/typescript/packages/lookslike-high-level/src/data.ts index 4ac40af7b..96bb54e25 100644 --- a/typescript/packages/lookslike-high-level/src/data.ts +++ b/typescript/packages/lookslike-high-level/src/data.ts @@ -5,25 +5,21 @@ const { state } = signal; import { todoList, todoTask } from "./recipes/todo-list.js"; import "./recipes/todo-list-as-task.js"; // Necessary, so that suggestions are indexed. -import { sagaList } from "./recipes/saga-list.js"; +import { todo } from "@commontools/common-ui/tags.js"; export const keywords: { [key: string]: string[] } = { groceries: ["grocery list"], }; -export const dataGems = state<{ [key: string]: Gem }>({}); +export const dataGems = state([]); -export function addGems(gems: { [key: string]: Gem }) { - Object.entries(gems).forEach(([name, gem]) => (gem[NAME] = name)); - dataGems.send({ ...dataGems.get(), ...gems }); +export function addGems(gems: Gem[]) { + dataGems.send([...dataGems.get(), ...gems]); } -export function getGemByName(name: string): Gem | undefined { - return dataGems.get()[name]; -} - -const recipes: { [name: string]: Gem } = { - "todo list": todoList({ +addGems([ + todoList({ + title: "My TODOs", items: ["Buy groceries", "Walk the dog", "Wash the car"].map((item) => todoTask({ title: item, @@ -31,7 +27,8 @@ const recipes: { [name: string]: Gem } = { }) ), }), - "grocery list": todoList({ + todoList({ + title: "My grocery shopping list", items: ["milk", "eggs", "bread"].map((item) => todoTask({ title: item, @@ -39,7 +36,11 @@ const recipes: { [name: string]: Gem } = { }) ), }), - home: sagaList({ sagas: dataGems }), -}; +]); -addGems(recipes); +export const recipes = { + "Create a new TODO list": { + recipe: todoList, + inputs: { title: "", items: [] }, + }, +}; diff --git a/typescript/packages/lookslike-high-level/src/main.ts b/typescript/packages/lookslike-high-level/src/main.ts index 1e5bc9d5b..c79ce2494 100644 --- a/typescript/packages/lookslike-high-level/src/main.ts +++ b/typescript/packages/lookslike-high-level/src/main.ts @@ -1,12 +1,12 @@ export { components } from "@commontools/common-ui"; import { CommonWindowManager } from "./components/window-manager.js"; export { components as myComponents } from "./components.js"; -import { getGemByName } from "./data.js"; +import { dataGems } from "./data.js"; +import { home } from "./recipes/home.js"; document.addEventListener("DOMContentLoaded", () => { const windowManager = document.getElementById( "window-manager" )! as CommonWindowManager; - console.log(getGemByName("home")); - windowManager.openSaga(getGemByName("home")!); + windowManager.openSaga(home({ sagas: dataGems })); }); diff --git a/typescript/packages/lookslike-high-level/src/recipe.ts b/typescript/packages/lookslike-high-level/src/recipe.ts index 93f7b6665..f6abe93bc 100644 --- a/typescript/packages/lookslike-high-level/src/recipe.ts +++ b/typescript/packages/lookslike-high-level/src/recipe.ts @@ -34,7 +34,7 @@ export type Recipe = (inputs: RecipeInputs) => Gem; let id = 0; export const recipe = ( - name: string, + type: string, impl: (inputs: Bindings) => Bindings ): Recipe => { return (inputs: Bindings) => { @@ -45,7 +45,7 @@ export const recipe = ( ]) ); const outputs = impl(inputsAsSignals); - return { [ID]: id++, [TYPE]: name, ...outputs }; + return { [ID]: id++, [TYPE]: type, ...outputs }; }; }; diff --git a/typescript/packages/lookslike-high-level/src/recipes/saga-list.ts b/typescript/packages/lookslike-high-level/src/recipes/home.ts similarity index 59% rename from typescript/packages/lookslike-high-level/src/recipes/saga-list.ts rename to typescript/packages/lookslike-high-level/src/recipes/home.ts index aecabf62d..99c7e6ce4 100644 --- a/typescript/packages/lookslike-high-level/src/recipes/saga-list.ts +++ b/typescript/packages/lookslike-high-level/src/recipes/home.ts @@ -3,9 +3,9 @@ import { signal } from "@commontools/common-frp"; import { recipe, Gem, ID } from "../recipe.js"; import { sagaLink } from "../components/saga-link.js"; const { binding, repeat } = view; -const { list } = tags; +const { list, vstack } = tags; -export const sagaList = recipe("saga list", ({ sagas }) => { +export const home = recipe("home screen", ({ sagas, recipes }) => { const sagasWithIDs = signal.computed( [sagas], (sagas: { [key: string]: Gem }) => @@ -17,8 +17,11 @@ export const sagaList = recipe("saga list", ({ sagas }) => { return { UI: [ - list({}, repeat("sagas", sagaLink({ saga: binding("saga") }))), - { sagas: sagasWithIDs }, + list({}, [ + vstack({}, repeat("sagas", sagaLink({ saga: binding("saga") }))), + // vstack({}, repeat("recipes", recipeLink({ saga: binding("saga") }))), + ]), + { sagas: sagasWithIDs, recipes }, ], }; }); diff --git a/typescript/packages/lookslike-high-level/src/recipes/todo-list-as-task.ts b/typescript/packages/lookslike-high-level/src/recipes/todo-list-as-task.ts index 8b1c23149..5e243b33e 100644 --- a/typescript/packages/lookslike-high-level/src/recipes/todo-list-as-task.ts +++ b/typescript/packages/lookslike-high-level/src/recipes/todo-list-as-task.ts @@ -81,7 +81,7 @@ export const todoListAsTask = recipe("todo list as task", ({ list, done }) => { }); addSuggestion({ - description: description`Add ${"list"} as sub tasks`, + description: description`Add 💎${"list"} as sub tasks`, recipe: todoListAsTask, bindings: { done: "done" }, dataGems: { diff --git a/typescript/packages/lookslike-high-level/src/recipes/todo-list.ts b/typescript/packages/lookslike-high-level/src/recipes/todo-list.ts index 866013f15..c4308cca8 100644 --- a/typescript/packages/lookslike-high-level/src/recipes/todo-list.ts +++ b/typescript/packages/lookslike-high-level/src/recipes/todo-list.ts @@ -1,18 +1,23 @@ import { view, tags } from "@commontools/common-ui"; import { signal, stream } from "@commontools/common-frp"; -import { recipe } from "../recipe.js"; +import { recipe, NAME } from "../recipe.js"; import { annotation } from "../components/annotation.js"; const { binding, repeat } = view; -const { list, vstack, hstack, checkbox, div, include, sendInput, todo } = tags; +const { list, vstack, include, sendInput, todo, commonInput } = tags; const { state } = signal; const { subject } = stream; -export const todoList = recipe("todo list", ({ items }) => { - const newTasks = subject<{ - type: "messageSend"; - detail: { message: string }; - }>(); +export const todoList = recipe("todo list", ({ title, items }) => { + const newTitle = subject<{ detail: { value: string } }>(); + newTitle.sink({ + send: (event) => { + const updatedTitle = event.detail?.value?.trim(); + if (!updatedTitle) return; + title.send(updatedTitle); + }, + }); + const newTasks = subject<{ detail: { message: string } }>(); newTasks.sink({ send: (event) => { const task = event.detail?.message?.trim(); @@ -24,6 +29,11 @@ export const todoList = recipe("todo list", ({ items }) => { return { UI: [ list({}, [ + commonInput({ + value: binding("title"), + placeholder: "List title", + "@common-input": binding("newTitle"), + }), vstack({}, repeat("items", include({ content: binding("itemUI") }))), sendInput({ name: "Add", @@ -31,9 +41,11 @@ export const todoList = recipe("todo list", ({ items }) => { "@messageSend": binding("newTasks"), }), ]), - { items, newTasks }, + { items, title, newTitle, newTasks }, ], + title, items, + [NAME]: title, }; }); @@ -52,16 +64,20 @@ export const todoTask = recipe("todo task", ({ title, done }) => { return { itemUI: state([ vstack({}, [ - todo({ - checked: binding("done"), - value: binding("title"), - "@todo-checked": binding("update"), - "@todo-input": binding("update"), - }), - annotation({ - query: title, - data: { done, title }, - }), + todo( + { + checked: binding("done"), + value: binding("title"), + "@todo-checked": binding("update"), + "@todo-input": binding("update"), + }, + [ + annotation({ + query: title, + data: { done, title }, + }), + ] + ), ]), { done, title, update }, ]), From 7cfd6f29aced4d5102ec9a3e3a8fa432507690cf Mon Sep 17 00:00:00 2001 From: Bernhard Seefeld Date: Fri, 21 Jun 2024 11:59:38 -0700 Subject: [PATCH 2/3] add vdom for common-input --- .../common-ui/src/components/common-input.ts | 90 ++++++++++--------- 1 file changed, 49 insertions(+), 41 deletions(-) diff --git a/typescript/packages/common-ui/src/components/common-input.ts b/typescript/packages/common-ui/src/components/common-input.ts index 3f417b27c..80ac5ba59 100644 --- a/typescript/packages/common-ui/src/components/common-input.ts +++ b/typescript/packages/common-ui/src/components/common-input.ts @@ -1,11 +1,20 @@ import { LitElement, html, css } from "lit"; import { customElement, property } from "lit/decorators.js"; import { baseStyles } from "./style.js"; +import { view } from "../hyperscript/render.js"; +import { eventProps } from "../hyperscript/schema-helpers.js"; + +export const commonInput = view("common-input", { + ...eventProps(), + value: { type: "string" }, + placeholder: { type: "string" }, + appearance: { type: "string" }, +}); export type CommonInput = { id: string; value: string; -} +}; export class CommonInputEvent extends Event { detail: CommonInput; @@ -21,35 +30,35 @@ export class CommonInputElement extends LitElement { static override styles = [ baseStyles, css` - :host { - display: block; - --height: 24px; - } - - .input-wrapper { - display: flex; - flex-direction: column; - justify-content: center; - } + :host { + display: block; + --height: 24px; + } - .input { - appearance: none; - border: 0; - outline: 0; - box-sizing: border-box; - font-size: var(--body-size); - width: 100%; - height: var(--height); - } + .input-wrapper { + display: flex; + flex-direction: column; + justify-content: center; + } - :host([appearance="rounded"]) .input { - --height: 40px; - background-color: var(--input-background); - border-radius: calc(var(--height) / 2); - padding: 8px 16px; - height: var(--height); - } - ` + .input { + appearance: none; + border: 0; + outline: 0; + box-sizing: border-box; + font-size: var(--body-size); + width: 100%; + height: var(--height); + } + + :host([appearance="rounded"]) .input { + --height: 40px; + background-color: var(--input-background); + border-radius: calc(var(--height) / 2); + padding: 8px 16px; + height: var(--height); + } + `, ]; @property({ type: String }) value = ""; @@ -61,20 +70,19 @@ export class CommonInputElement extends LitElement { const value = (event.target as HTMLInputElement).value; this.value = value; - this.dispatchEvent( - new CommonInputEvent({ id: this.id, value }) - ); - } + this.dispatchEvent(new CommonInputEvent({ id: this.id, value })); + }; return html` -
- -
+
+ +
`; } -} \ No newline at end of file +} From 37801640095b22cd6bf4a4f22d4ca0787db87a3e Mon Sep 17 00:00:00 2001 From: Bernhard Seefeld Date: Fri, 21 Jun 2024 12:30:09 -0700 Subject: [PATCH 3/3] fixed suggestions again --- .../src/components/saga-link.ts | 10 +-- .../packages/lookslike-high-level/src/data.ts | 5 -- .../lookslike-high-level/src/recipe.ts | 9 +-- .../src/recipes/annotation.ts | 76 +++++++++++-------- 4 files changed, 52 insertions(+), 48 deletions(-) diff --git a/typescript/packages/lookslike-high-level/src/components/saga-link.ts b/typescript/packages/lookslike-high-level/src/components/saga-link.ts index feafbc20b..7d8840e5f 100644 --- a/typescript/packages/lookslike-high-level/src/components/saga-link.ts +++ b/typescript/packages/lookslike-high-level/src/components/saga-link.ts @@ -54,16 +54,16 @@ export class CommonSagaLink extends LitElement { override updated(changedProperties: Map) { super.updated(changedProperties); if (changedProperties.has("saga")) { - this.maybeListenToName(); + this.maybeListenToName(true); } } - private maybeListenToName() { + private maybeListenToName(skipUpdate = false) { if (signal.isSignal(this.saga?.[NAME])) { - console.log("listening to name", this.saga[NAME]); this.nameEffect = signal.effect([this.saga[NAME]], (name: string) => { this.nameFromGem = name; - this.requestUpdate(); + if (!skipUpdate) this.requestUpdate(); + skipUpdate = false; }); } else { this.nameEffect?.(); @@ -72,10 +72,8 @@ export class CommonSagaLink extends LitElement { } override render() { - console.log("rendering saga link", this.saga, this.name); if (!this.saga) return html``; const name = this.name ?? this.nameFromGem; - console.log("rendering saga link", name, this.saga[NAME]); return html` 🔮 ${name} `; diff --git a/typescript/packages/lookslike-high-level/src/data.ts b/typescript/packages/lookslike-high-level/src/data.ts index 96bb54e25..98525f5ab 100644 --- a/typescript/packages/lookslike-high-level/src/data.ts +++ b/typescript/packages/lookslike-high-level/src/data.ts @@ -5,11 +5,6 @@ const { state } = signal; import { todoList, todoTask } from "./recipes/todo-list.js"; import "./recipes/todo-list-as-task.js"; // Necessary, so that suggestions are indexed. -import { todo } from "@commontools/common-ui/tags.js"; - -export const keywords: { [key: string]: string[] } = { - groceries: ["grocery list"], -}; export const dataGems = state([]); diff --git a/typescript/packages/lookslike-high-level/src/recipe.ts b/typescript/packages/lookslike-high-level/src/recipe.ts index f6abe93bc..97348ffe6 100644 --- a/typescript/packages/lookslike-high-level/src/recipe.ts +++ b/typescript/packages/lookslike-high-level/src/recipe.ts @@ -63,12 +63,11 @@ export type Suggestion = { dataGems: { [key: string]: string }; }; -export const suggestions = signal.state([]); +export const suggestions: Suggestion[] = []; +export function addSuggestion(suggestion: Suggestion) { + suggestions.push(suggestion); +} export function description(strings: TemplateStringsArray, ...values: any[]) { return strings.map((string, i) => [string, values[i]]).flat(); } - -export function addSuggestion(suggestion: Suggestion) { - setTimeout(() => suggestions.send([...suggestions.get(), suggestion])); -} diff --git a/typescript/packages/lookslike-high-level/src/recipes/annotation.ts b/typescript/packages/lookslike-high-level/src/recipes/annotation.ts index f5e288cd1..7036cf039 100644 --- a/typescript/packages/lookslike-high-level/src/recipes/annotation.ts +++ b/typescript/packages/lookslike-high-level/src/recipes/annotation.ts @@ -1,16 +1,17 @@ import { view, tags } from "@commontools/common-ui"; import { signal, stream } from "@commontools/common-frp"; -import { dataGems, keywords } from "../data.js"; +import { dataGems } from "../data.js"; import { recipe, Recipe, Gem, TYPE, + NAME, suggestions, type Suggestion, } from "../recipe.js"; const { include } = tags; -const { state, computed } = signal; +const { state, computed, isSignal } = signal; const { subject } = stream; const { binding } = view; @@ -30,10 +31,8 @@ const { binding } = view; */ export const annotation = recipe("annotation", ({ "?": query, ...data }) => { - const suggestion = computed( - [dataGems, suggestions, query], - (dataGems, suggestions, query: string) => - findSuggestion(dataGems, suggestions, query, Object.keys(data)) + const suggestion = computed([dataGems, query], (dataGems, query: string) => + findSuggestion(dataGems, suggestions, query, Object.keys(data)) ); const acceptSuggestion = subject(); @@ -81,30 +80,16 @@ type Result = { }; function findSuggestion( - dataGems: { [key: string]: Gem }, + dataGems: Gem[], suggestions: Suggestion[], query: string, data: string[] ): Result | undefined { // Step 1: Find candidate data gems by doing a dumb keyword seach - const parts: string[] = query - .toLowerCase() - .split(/ +/) - .map((word) => word.trim()) - .filter((word) => word.length > 0); - - const words = parts.flatMap((word, i) => { - const w = [word]; - if (i < parts.length - 1) w.push(word + " " + parts[i + 1]); - if (i < parts.length - 2) - w.push(word + " " + parts[i + 1] + " " + parts[i + 2]); - return w; - }); + const terms = queryToTerms(query); - const aliases = words.flatMap((word) => keywords[word] ?? []); - - const gems = Object.entries(dataGems).filter( - ([name]) => words.includes(name) || aliases.includes(name) + const gems = dataGems.filter((gem) => + queryToTerms(getNameFromGem(gem)).some((term) => terms.includes(term)) ); // Step 2: Find suggestions that bridge matching gems to recipes: @@ -116,27 +101,26 @@ function findSuggestion( const suggestion = suggestions.find( (suggestion) => Object.values(suggestion.dataGems).every((type) => - gems.find(([_, gem]) => gem[TYPE] === type) + gems.find((gem) => gem[TYPE] === type) ) && Object.values(suggestion.bindings).every((binding) => data.includes(binding) ) ); - console.log("suggestion", suggestion, suggestions); + console.log("suggestion", suggestion, query, gems, suggestions); if (suggestion) { const bindings = Object.entries(suggestion.dataGems).map(([key, type]) => [ key, - gems.find(([_, gem]) => gem[TYPE] === type)!, - ]); + gems.find((gem) => gem[TYPE] === type), + ]) as [[string, Gem]]; const nameBindings = Object.fromEntries( - bindings.map(([key, [name, _gem]]) => [key, name]) - ); - const gemBindings = Object.fromEntries( - bindings.map(([key, [_name, gem]]) => [key, gem]) + bindings.map(([key, gem]) => [key, getNameFromGem(gem)]) ); + const gemBindings = Object.fromEntries(bindings); + const description = suggestion.description .map((part, i) => (i % 2 === 0 ? part : nameBindings[part])) .join(""); @@ -146,3 +130,31 @@ function findSuggestion( return undefined; } } + +function getNameFromGem(gem: Gem): string { + return (isSignal(gem[NAME]) ? gem[NAME].get() : gem[NAME]) as string; +} + +const keywords: { [key: string]: string[] } = { + groceries: ["grocery"], +}; + +function queryToTerms(query: string): string[] { + const parts: string[] = query + .toLowerCase() + .split(/ +/) + .map((word) => word.trim()) + .filter((word) => word.length > 0); + + const aliases = parts.flatMap((word) => keywords[word] ?? []); + + const words = [...parts, ...aliases].flatMap((word, i) => { + const w = [word]; + if (i < parts.length - 1) w.push(word + " " + parts[i + 1]); + if (i < parts.length - 2) + w.push(word + " " + parts[i + 1] + " " + parts[i + 2]); + return w; + }); + + return words; +}