From 179b6e136cf05590d6c42d7e2b39e52a0fdad4ab Mon Sep 17 00:00:00 2001 From: Danielo Rodriguez <rdanielo@gmail.com> Date: Thu, 14 Sep 2023 20:07:07 +0200 Subject: [PATCH 1/7] WIP: multi select --- src/FormModal.ts | 17 +++++++++------ src/core/formDefinition.ts | 3 +++ src/suggesters/MultiSelect.ts | 28 +++++++++++++++++++++++++ src/views/components/MultiSelect.svelte | 21 +++++++++++++++++++ 4 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 src/suggesters/MultiSelect.ts create mode 100644 src/views/components/MultiSelect.svelte diff --git a/src/FormModal.ts b/src/FormModal.ts index ca2f1b0f..c2ceac92 100644 --- a/src/FormModal.ts +++ b/src/FormModal.ts @@ -1,4 +1,5 @@ import { App, Modal, Setting } from "obsidian"; +import MultiSelect from "./views/components/MultiSelect.svelte"; import FormResult, { type ModalFormData } from "./FormResult"; import { exhaustiveGuard } from "./safety"; import { get_tfiles_from_folder } from "./utils/files"; @@ -101,14 +102,18 @@ export class FormModal extends Modal { this.formResult[definition.name] = value; }); }); + case 'multiselect': + return fieldBase.controlEl.appendChild(new MultiSelect({})) case "dataview": - const query = fieldInput.query; - return fieldBase.addText((element) => { - new DataviewSuggest(element.inputEl, query, this.app); - element.onChange(async (value) => { - this.formResult[definition.name] = value; + { + const query = fieldInput.query; + return fieldBase.addText((element) => { + new DataviewSuggest(element.inputEl, query, this.app); + element.onChange(async (value) => { + this.formResult[definition.name] = value; + }); }); - }); + } case "select": { const source = fieldInput.source; diff --git a/src/core/formDefinition.ts b/src/core/formDefinition.ts index 2c5354b6..649595c0 100644 --- a/src/core/formDefinition.ts +++ b/src/core/formDefinition.ts @@ -22,12 +22,14 @@ type inputSelectFixed = { options: { value: string; label: string }[]; } type basicInput = { type: FieldType }; +type multiselect = { type: 'multiselect' }; type inputType = | basicInput | inputNoteFromFolder | inputSlider | selectFromNotes | inputDataviewSource + | multiselect | inputSelectFixed; export const FieldTypeReadable: Record<AllFieldTypes, string> = { @@ -41,6 +43,7 @@ export const FieldTypeReadable: Record<AllFieldTypes, string> = { "slider": "Slider", "select": "Select", "dataview": "Dataview", + "multiselect": "Multiselect", } as const; function isObject(input: unknown): input is Record<string, unknown> { diff --git a/src/suggesters/MultiSelect.ts b/src/suggesters/MultiSelect.ts new file mode 100644 index 00000000..6ff3b4a1 --- /dev/null +++ b/src/suggesters/MultiSelect.ts @@ -0,0 +1,28 @@ +import { TextInputSuggest } from "./suggest"; + +export class MultiSelect extends TextInputSuggest<string> { + content: Set<string>; + + constructor(input: HTMLInputElement, content: Set<string>, private onSelect: (value: string) => void) { + super(app, input); + this.content = content; + } + + getSuggestions(inputStr: string): string[] { + const lowerCaseInputStr = inputStr.toLowerCase(); + return [...this.content].filter((content) => + content.contains(lowerCaseInputStr) + ); + } + + renderSuggestion(content: string, el: HTMLElement): void { + el.setText(content); + } + + selectSuggestion(content: string): void { + this.inputEl.value = ""; + this.inputEl.trigger("input"); + this.onSelect(content); + this.close(); + } +} diff --git a/src/views/components/MultiSelect.svelte b/src/views/components/MultiSelect.svelte new file mode 100644 index 00000000..aa2148f8 --- /dev/null +++ b/src/views/components/MultiSelect.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + import { MultiSelect } from "src/suggesters/MultiSelect"; + + export let selectedVales: string[] = []; + export let availableOptions: string[] = []; + + function createInput(element){ + new MultiSelect(element, availableOptions, (selected) => { + selectedVales.push(selected) + }); + } +</script > + +<div > + + <input use:createInput type="text" class="form-control" placeholder="Select" /> + + {#each selectedVales as value} + <div class="badge bg-primary">{value}</div> + {/each} +</div> From 9688ffc65b3cc0801e69acf78751737c6647c53a Mon Sep 17 00:00:00 2001 From: Danielo Rodriguez <rdanielo@gmail.com> Date: Fri, 15 Sep 2023 12:49:54 +0200 Subject: [PATCH 2/7] WIP: multi-select UI --- src/FormModal.ts | 350 +++++++++--------- src/FormResult.ts | 62 ++-- src/exampleModalDefinition.ts | 181 ++++----- .../{MultiSelect.ts => MultiSuggest.ts} | 7 +- src/views/components/MultiSelect.svelte | 134 ++++++- 5 files changed, 432 insertions(+), 302 deletions(-) rename src/suggesters/{MultiSelect.ts => MultiSuggest.ts} (84%) diff --git a/src/FormModal.ts b/src/FormModal.ts index c2ceac92..346c2cb6 100644 --- a/src/FormModal.ts +++ b/src/FormModal.ts @@ -6,180 +6,192 @@ import { get_tfiles_from_folder } from "./utils/files"; import type { FormDefinition } from "./core/formDefinition"; import { FileSuggest } from "./suggesters/suggestFile"; import { DataviewSuggest } from "./suggesters/suggestFromDataview"; +import { SvelteComponent } from "svelte"; export type SubmitFn = (formResult: FormResult) => void; export class FormModal extends Modal { - modalDefinition: FormDefinition; - formResult: ModalFormData; - onSubmit: SubmitFn; - constructor(app: App, modalDefinition: FormDefinition, onSubmit: SubmitFn) { - super(app); - this.modalDefinition = modalDefinition; - this.onSubmit = onSubmit; - this.formResult = {}; - } + modalDefinition: FormDefinition; + formResult: ModalFormData; + svelteComponents: SvelteComponent[] = []; + constructor(app: App, modalDefinition: FormDefinition, private onSubmit: SubmitFn) { + super(app); + this.modalDefinition = modalDefinition; + this.formResult = {}; + } - onOpen() { - const { contentEl } = this; - contentEl.createEl("h1", { text: this.modalDefinition.title }); - this.modalDefinition.fields.forEach((definition) => { - const fieldBase = new Setting(contentEl) - .setName(definition.label || definition.name) - .setDesc(definition.description); - // This intermediary constants are necessary so typescript can narrow down the proper types. - // without them, you will have to use the whole access path (definition.input.folder), - // and it is no specific enough when you use it in a switch statement. - const fieldInput = definition.input; - const type = fieldInput.type; - switch (type) { - case "text": - return fieldBase.addText((text) => - text.onChange(async (value) => { - this.formResult[definition.name] = value; - }) - ); - case "number": - return fieldBase.addText((text) => { - text.inputEl.type = "number"; - text.onChange(async (value) => { - if (value !== "") { - this.formResult[definition.name] = - Number(value) + ""; - } - }); - }); - case "date": - return fieldBase.addText((text) => { - text.inputEl.type = "date"; - text.onChange(async (value) => { - this.formResult[definition.name] = value; - }); - }); - case "time": - return fieldBase.addText((text) => { - text.inputEl.type = "time"; - text.onChange(async (value) => { - this.formResult[definition.name] = value; - }); - }); - case "datetime": - return fieldBase.addText((text) => { - text.inputEl.type = "datetime-local"; - text.onChange(async (value) => { - this.formResult[definition.name] = value; - }); - }); - case "toggle": - return fieldBase.addToggle((toggle) => { - toggle.setValue(false); - this.formResult[definition.name] = false; - return toggle.onChange(async (value) => { - this.formResult[definition.name] = value; - }); - } - ); - case "note": - return fieldBase.addText((element) => { - new FileSuggest(this.app, element.inputEl, { - renderSuggestion(file) { - return file.basename; - }, - selectSuggestion(file) { - return file.basename; - }, - }, fieldInput.folder); - element.onChange(async (value) => { - this.formResult[definition.name] = value; - }); - }); - case "slider": - return fieldBase.addSlider((slider) => { - slider.setLimits(fieldInput.min, fieldInput.max, 1); - slider.setDynamicTooltip(); - slider.setValue(fieldInput.min); - slider.onChange(async (value) => { - this.formResult[definition.name] = value; - }); - }); - case 'multiselect': - return fieldBase.controlEl.appendChild(new MultiSelect({})) - case "dataview": - { - const query = fieldInput.query; - return fieldBase.addText((element) => { - new DataviewSuggest(element.inputEl, query, this.app); - element.onChange(async (value) => { - this.formResult[definition.name] = value; - }); - }); - } - case "select": - { - const source = fieldInput.source; - switch (source) { - case "fixed": - return fieldBase.addDropdown((element) => { - const options = fieldInput.options.reduce( - ( - acc: Record<string, string>, - option - ) => { - acc[option.value] = option.label; - return acc; - }, - {} - ); - element.addOptions(options); - element.onChange(async (value) => { - this.formResult[definition.name] = - value; - }); - }); + onOpen() { + const { contentEl } = this; + contentEl.createEl("h1", { text: this.modalDefinition.title }); + this.modalDefinition.fields.forEach((definition) => { + const fieldBase = new Setting(contentEl) + .setName(definition.label || definition.name) + .setDesc(definition.description); + // This intermediary constants are necessary so typescript can narrow down the proper types. + // without them, you will have to use the whole access path (definition.input.folder), + // and it is no specific enough when you use it in a switch statement. + const fieldInput = definition.input; + const type = fieldInput.type; + switch (type) { + case "text": + return fieldBase.addText((text) => + text.onChange(async (value) => { + this.formResult[definition.name] = value; + }) + ); + case "number": + return fieldBase.addText((text) => { + text.inputEl.type = "number"; + text.onChange(async (value) => { + if (value !== "") { + this.formResult[definition.name] = + Number(value) + ""; + } + }); + }); + case "date": + return fieldBase.addText((text) => { + text.inputEl.type = "date"; + text.onChange(async (value) => { + this.formResult[definition.name] = value; + }); + }); + case "time": + return fieldBase.addText((text) => { + text.inputEl.type = "time"; + text.onChange(async (value) => { + this.formResult[definition.name] = value; + }); + }); + case "datetime": + return fieldBase.addText((text) => { + text.inputEl.type = "datetime-local"; + text.onChange(async (value) => { + this.formResult[definition.name] = value; + }); + }); + case "toggle": + return fieldBase.addToggle((toggle) => { + toggle.setValue(false); + this.formResult[definition.name] = false; + return toggle.onChange(async (value) => { + this.formResult[definition.name] = value; + }); + } + ); + case "note": + return fieldBase.addText((element) => { + new FileSuggest(this.app, element.inputEl, { + renderSuggestion(file) { + return file.basename; + }, + selectSuggestion(file) { + return file.basename; + }, + }, fieldInput.folder); + element.onChange(async (value) => { + this.formResult[definition.name] = value; + }); + }); + case "slider": + return fieldBase.addSlider((slider) => { + slider.setLimits(fieldInput.min, fieldInput.max, 1); + slider.setDynamicTooltip(); + slider.setValue(fieldInput.min); + slider.onChange(async (value) => { + this.formResult[definition.name] = value; + }); + }); + case 'multiselect': + { + this.formResult[definition.name] = this.formResult[definition.name] || [] + this.svelteComponents.push(new MultiSelect({ + target: fieldBase.controlEl, + props: { + selectedVales: this.formResult[definition.name] as string[], + availableOptions: ['a', 'b', 'c'], + setting: fieldBase, + } + })) + return; + } + case "dataview": + { + const query = fieldInput.query; + return fieldBase.addText((element) => { + new DataviewSuggest(element.inputEl, query, this.app); + element.onChange(async (value) => { + this.formResult[definition.name] = value; + }); + }); + } + case "select": + { + const source = fieldInput.source; + switch (source) { + case "fixed": + return fieldBase.addDropdown((element) => { + const options = fieldInput.options.reduce( + ( + acc: Record<string, string>, + option + ) => { + acc[option.value] = option.label; + return acc; + }, + {} + ); + element.addOptions(options); + element.onChange(async (value) => { + this.formResult[definition.name] = + value; + }); + }); - case "notes": - return fieldBase.addDropdown((element) => { - const files = get_tfiles_from_folder(fieldInput.folder, this.app); - const options = files.reduce( - ( - acc: Record<string, string>, - option - ) => { - acc[option.basename] = - option.basename; - return acc; - }, - {} - ); - element.addOptions(options); - element.onChange(async (value) => { - this.formResult[definition.name] = - value; - }); - }); - default: - exhaustiveGuard(source); - } - } - break; - default: - return exhaustiveGuard(type); - } - }); - new Setting(contentEl).addButton((btn) => - btn - .setButtonText("Submit") - .setCta() - .onClick(() => { - this.onSubmit(new FormResult(this.formResult, "ok")); - this.close(); - }) - ); - } + case "notes": + return fieldBase.addDropdown((element) => { + const files = get_tfiles_from_folder(fieldInput.folder, this.app); + const options = files.reduce( + ( + acc: Record<string, string>, + option + ) => { + acc[option.basename] = + option.basename; + return acc; + }, + {} + ); + element.addOptions(options); + element.onChange(async (value) => { + this.formResult[definition.name] = + value; + }); + }); + default: + exhaustiveGuard(source); + } + } + break; + default: + return exhaustiveGuard(type); + } + }); + new Setting(contentEl).addButton((btn) => + btn + .setButtonText("Submit") + .setCta() + .onClick(() => { + this.onSubmit(new FormResult(this.formResult, "ok")); + this.close(); + }) + ); + } - onClose() { - const { contentEl } = this; - contentEl.empty(); - this.formResult = {}; - } + onClose() { + const { contentEl } = this; + this.svelteComponents.forEach(component => component.$destroy()) + contentEl.empty(); + this.formResult = {}; + } } diff --git a/src/FormResult.ts b/src/FormResult.ts index 2ec34733..4df9df70 100644 --- a/src/FormResult.ts +++ b/src/FormResult.ts @@ -3,37 +3,37 @@ import { stringifyYaml } from "obsidian"; type ResultStatus = "ok" | "cancelled"; // We don't use FormData because that is builtin browser API -export type ModalFormData = { [key: string]: string | boolean | number }; +export type ModalFormData = { [key: string]: string | boolean | number | string[] }; export default class FormResult { - constructor(private data: ModalFormData, public status: ResultStatus) { } - asFrontmatterString() { - return stringifyYaml(this.data); - } - /** - * Return the current data as a block of dataview properties - * @returns string - */ - asDataviewProperties(): string { - return Object.entries(this.data) - .map(([key, value]) => `${key}:: ${value}`) - .join("\n"); - } - /** - Returns a copy of the data contained on this result. - */ - getData() { - return { ...this.data }; - } - /** - * Returns the data formatted as a string matching the provided - * template. - */ - asString(template: string): string { - let result = template; - for (const [key, value] of Object.entries(this.data)) { - result = result.replace(new RegExp(`{{${key}}}`, 'g'), value + ""); - } - return result; - } + constructor(private data: ModalFormData, public status: ResultStatus) { } + asFrontmatterString() { + return stringifyYaml(this.data); + } + /** + * Return the current data as a block of dataview properties + * @returns string + */ + asDataviewProperties(): string { + return Object.entries(this.data) + .map(([key, value]) => `${key}:: ${value}`) + .join("\n"); + } + /** + Returns a copy of the data contained on this result. + */ + getData() { + return { ...this.data }; + } + /** + * Returns the data formatted as a string matching the provided + * template. + */ + asString(template: string): string { + let result = template; + for (const [key, value] of Object.entries(this.data)) { + result = result.replace(new RegExp(`{{${key}}}`, 'g'), value + ""); + } + return result; + } } diff --git a/src/exampleModalDefinition.ts b/src/exampleModalDefinition.ts index 13375bc7..e36dd9f7 100644 --- a/src/exampleModalDefinition.ts +++ b/src/exampleModalDefinition.ts @@ -1,94 +1,99 @@ import type { FormDefinition } from "./core/formDefinition"; export const exampleModalDefinition: FormDefinition = { - title: "Example form", - name: "example-form", - fields: [ - { - name: "Name", - description: "It is named how?", - input: { type: "text" }, - }, - { - name: "age", - label: "Age", - description: "How old", - input: { type: "number" }, - }, - { - name: "dateOfBirth", - label: "Date of Birth", - description: "When were you born?", - input: { type: "date" }, - }, - { - name: "timeOfDay", - label: "Time of day", - description: "The time you can do this", - input: { type: "time" }, - }, - { - name: "is_family", - label: "Is family", - description: "If it is part of the family", - input: { type: "toggle" }, - }, - { - name: "favorite_book", - label: "Favorite book", - description: "Pick one", - input: { type: "note", folder: "Books" }, - }, + title: "Example form", + name: "example-form", + fields: [ + { + name: "Name", + description: "It is named how?", + input: { type: "text" }, + }, + { + name: "age", + label: "Age", + description: "How old", + input: { type: "number" }, + }, + { + name: "dateOfBirth", + label: "Date of Birth", + description: "When were you born?", + input: { type: "date" }, + }, + { + name: "timeOfDay", + label: "Time of day", + description: "The time you can do this", + input: { type: "time" }, + }, + { + name: "is_family", + label: "Is family", + description: "If it is part of the family", + input: { type: "toggle" }, + }, + { + name: "favorite_book", + label: "Favorite book", + description: "Pick one", + input: { type: "note", folder: "Books" }, + }, + { + name: "multi_example", + label: "Multi select example", + description: "Pick many", + input: { type: "multiselect" }, + }, + { + name: "best_fried", + label: "Best friend", + description: "Pick one", + input: { + type: 'select', + source: 'notes', + folder: 'People' + } + }, + { + name: 'dataview_example', + label: 'Dataview example', + description: 'Only people matching the dataview query will be shown', + input: { + type: 'dataview', + query: 'dv.pages("#person").filter(p => p.age < 30).map(p => p.file.name)' + } + }, + { + name: "friendship_level", + label: "Friendship level", + description: "How good friends are you?", + input: { + type: 'slider', + min: 0, + max: 10 + } + }, + { + name: "favorite_meal", + label: "Favorite meal", + description: "Pick one option", + input: { + type: "select", source: "fixed", options: [ + { value: "pizza", label: "🍕 Pizza" }, + { value: "pasta", label: "🍝 Pasta" }, + { value: "burger", label: "🍔 Burger" }, + { value: "salad", label: "🥗 Salad" }, + { value: "steak", label: "🥩 Steak" }, + { value: "sushi", label: "🍣 Sushi" }, + { value: "ramen", label: "🍜 Ramen" }, + { value: "tacos", label: "🌮 Tacos" }, + { value: "fish", label: "🐟 Fish" }, + { value: "chicken", label: "🍗 Chicken" } + ] + }, + }, - { - name: "best_fried", - label: "Best friend", - description: "Pick one", - input: { - type: 'select', - source: 'notes', - folder: 'People' - } - }, - { - name: 'dataview_example', - label: 'Dataview example', - description: 'Only people matching the dataview query will be shown', - input: { - type: 'dataview', - query: 'dv.pages("#person").filter(p => p.age < 30).map(p => p.file.name)' - } - }, - { - name: "friendship_level", - label: "Friendship level", - description: "How good friends are you?", - input: { - type: 'slider', - min: 0, - max: 10 - } - }, - { - name: "favorite_meal", - label: "Favorite meal", - description: "Pick one option", - input: { - type: "select", source: "fixed", options: [ - { value: "pizza", label: "🍕 Pizza" }, - { value: "pasta", label: "🍝 Pasta" }, - { value: "burger", label: "🍔 Burger" }, - { value: "salad", label: "🥗 Salad" }, - { value: "steak", label: "🥩 Steak" }, - { value: "sushi", label: "🍣 Sushi" }, - { value: "ramen", label: "🍜 Ramen" }, - { value: "tacos", label: "🌮 Tacos" }, - { value: "fish", label: "🐟 Fish" }, - { value: "chicken", label: "🍗 Chicken" } - ] - }, - }, - - ], + ], }; diff --git a/src/suggesters/MultiSelect.ts b/src/suggesters/MultiSuggest.ts similarity index 84% rename from src/suggesters/MultiSelect.ts rename to src/suggesters/MultiSuggest.ts index 6ff3b4a1..c154aefa 100644 --- a/src/suggesters/MultiSelect.ts +++ b/src/suggesters/MultiSuggest.ts @@ -1,6 +1,6 @@ import { TextInputSuggest } from "./suggest"; -export class MultiSelect extends TextInputSuggest<string> { +export class MultiSuggest extends TextInputSuggest<string> { content: Set<string>; constructor(input: HTMLInputElement, content: Set<string>, private onSelect: (value: string) => void) { @@ -20,9 +20,10 @@ export class MultiSelect extends TextInputSuggest<string> { } selectSuggestion(content: string): void { - this.inputEl.value = ""; - this.inputEl.trigger("input"); this.onSelect(content); + this.inputEl.value = ""; + // this.inputEl.trigger("blur"); + this.inputEl.blur() this.close(); } } diff --git a/src/views/components/MultiSelect.svelte b/src/views/components/MultiSelect.svelte index aa2148f8..0231257d 100644 --- a/src/views/components/MultiSelect.svelte +++ b/src/views/components/MultiSelect.svelte @@ -1,21 +1,133 @@ <script lang="ts"> - import { MultiSelect } from "src/suggesters/MultiSelect"; + import type { Setting } from "obsidian"; + import { MultiSuggest } from "../../suggesters/MultiSuggest"; export let selectedVales: string[] = []; export let availableOptions: string[] = []; + // We take the setting to make it consistent with the other input components + export let setting: Setting; - function createInput(element){ - new MultiSelect(element, availableOptions, (selected) => { - selectedVales.push(selected) + setting.settingEl.setCssStyles({ + alignItems: "baseline", + }); + + let remainingOptions = new Set(availableOptions); + + function createInput(element: HTMLInputElement) { + new MultiSuggest(element, remainingOptions, (selected) => { + selectedVales.push(selected); + selectedVales = [...new Set(selectedVales)]; + remainingOptions.delete(selected); }); } -</script > + function reomoveValue(value: string) { + selectedVales = selectedVales.filter((v) => v !== value); + remainingOptions.add(value); + } +</script> -<div > +<div class="multi-select-root"> + <input + use:createInput + type="text" + class="form-control" + placeholder="Select" + /> + <div class="badges"> + {#each selectedVales as value} + <div class="badge"> + <span>{value}</span> + <button on:click={() => reomoveValue(value)}> + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + class="svg-icon lucide-x" + ><line x1="18" y1="6" x2="6" y2="18" /><line + x1="6" + y1="6" + x2="18" + y2="18" + /></svg + ></button + > + </div> + {:else} + <div class="badge hidden"> + <span>Nothing selected</span> + </div> + {/each} + </div> +</div> - <input use:createInput type="text" class="form-control" placeholder="Select" /> +<style> + .multi-select-root { + display: flex; + flex-direction: column; + gap: 0.5rem; + flex: 1; + --button-size: 1.5rem; + } + .badge { + --icon-size: var(--icon-xs); + --icon-stroke: var(--icon-xs-stroke-width); + display: flex; + align-items: center; + background-color: var(--pill-background); + border: var(--pill-border-width) solid var(--pill-border-color); + border-radius: var(--pill-radius); + color: var(--pill-color); + cursor: var(--cursor); + font-weight: var(--pill-weight); + padding: var(--pill-padding-y); + line-height: 1; + max-width: 100%; + gap: var(--size-4-2); + justify-content: center; + align-items: center; + } + .hidden { + visibility: hidden; + } + .hidden span { + height: var(--button-size); + } + .badge span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 1rem; + } + .badges { + display: flex; + flex-wrap: wrap; + gap: 8px; + min-height: 2rem; + padding: 0.5rem 0 0 0; + } + button { + /* reset button styles */ - {#each selectedVales as value} - <div class="badge bg-primary">{value}</div> - {/each} -</div> + background: none; + border: none; + color: inherit; + font: inherit; + line-height: inherit; + padding: 0; + -webkit-appearance: none; + -moz-appearance: none; + -o-appearance: none; + appearance: none; + box-shadow: none; + border: none; + cursor: pointer; + height: var(--button-size); + width: var(--button-size); + } +</style> From e3ee49281f39ed6708cdd48f8bba527e74d5584b Mon Sep 17 00:00:00 2001 From: Danielo Rodriguez <rdanielo@gmail.com> Date: Fri, 15 Sep 2023 13:27:07 +0200 Subject: [PATCH 3/7] feat: multi-select v1 --- src/FormModal.ts | 361 +++++++++++++++++----------------- src/core/formDefinition.ts | 260 ++++++++++++------------ src/exampleModalDefinition.ts | 12 +- 3 files changed, 321 insertions(+), 312 deletions(-) diff --git a/src/FormModal.ts b/src/FormModal.ts index 346c2cb6..90960a99 100644 --- a/src/FormModal.ts +++ b/src/FormModal.ts @@ -11,187 +11,190 @@ import { SvelteComponent } from "svelte"; export type SubmitFn = (formResult: FormResult) => void; export class FormModal extends Modal { - modalDefinition: FormDefinition; - formResult: ModalFormData; - svelteComponents: SvelteComponent[] = []; - constructor(app: App, modalDefinition: FormDefinition, private onSubmit: SubmitFn) { - super(app); - this.modalDefinition = modalDefinition; - this.formResult = {}; - } + modalDefinition: FormDefinition; + formResult: ModalFormData; + svelteComponents: SvelteComponent[] = []; + constructor(app: App, modalDefinition: FormDefinition, private onSubmit: SubmitFn) { + super(app); + this.modalDefinition = modalDefinition; + this.formResult = {}; + } - onOpen() { - const { contentEl } = this; - contentEl.createEl("h1", { text: this.modalDefinition.title }); - this.modalDefinition.fields.forEach((definition) => { - const fieldBase = new Setting(contentEl) - .setName(definition.label || definition.name) - .setDesc(definition.description); - // This intermediary constants are necessary so typescript can narrow down the proper types. - // without them, you will have to use the whole access path (definition.input.folder), - // and it is no specific enough when you use it in a switch statement. - const fieldInput = definition.input; - const type = fieldInput.type; - switch (type) { - case "text": - return fieldBase.addText((text) => - text.onChange(async (value) => { - this.formResult[definition.name] = value; - }) - ); - case "number": - return fieldBase.addText((text) => { - text.inputEl.type = "number"; - text.onChange(async (value) => { - if (value !== "") { - this.formResult[definition.name] = - Number(value) + ""; - } - }); - }); - case "date": - return fieldBase.addText((text) => { - text.inputEl.type = "date"; - text.onChange(async (value) => { - this.formResult[definition.name] = value; - }); - }); - case "time": - return fieldBase.addText((text) => { - text.inputEl.type = "time"; - text.onChange(async (value) => { - this.formResult[definition.name] = value; - }); - }); - case "datetime": - return fieldBase.addText((text) => { - text.inputEl.type = "datetime-local"; - text.onChange(async (value) => { - this.formResult[definition.name] = value; - }); - }); - case "toggle": - return fieldBase.addToggle((toggle) => { - toggle.setValue(false); - this.formResult[definition.name] = false; - return toggle.onChange(async (value) => { - this.formResult[definition.name] = value; - }); - } - ); - case "note": - return fieldBase.addText((element) => { - new FileSuggest(this.app, element.inputEl, { - renderSuggestion(file) { - return file.basename; - }, - selectSuggestion(file) { - return file.basename; - }, - }, fieldInput.folder); - element.onChange(async (value) => { - this.formResult[definition.name] = value; - }); - }); - case "slider": - return fieldBase.addSlider((slider) => { - slider.setLimits(fieldInput.min, fieldInput.max, 1); - slider.setDynamicTooltip(); - slider.setValue(fieldInput.min); - slider.onChange(async (value) => { - this.formResult[definition.name] = value; - }); - }); - case 'multiselect': - { - this.formResult[definition.name] = this.formResult[definition.name] || [] - this.svelteComponents.push(new MultiSelect({ - target: fieldBase.controlEl, - props: { - selectedVales: this.formResult[definition.name] as string[], - availableOptions: ['a', 'b', 'c'], - setting: fieldBase, - } - })) - return; - } - case "dataview": - { - const query = fieldInput.query; - return fieldBase.addText((element) => { - new DataviewSuggest(element.inputEl, query, this.app); - element.onChange(async (value) => { - this.formResult[definition.name] = value; - }); - }); - } - case "select": - { - const source = fieldInput.source; - switch (source) { - case "fixed": - return fieldBase.addDropdown((element) => { - const options = fieldInput.options.reduce( - ( - acc: Record<string, string>, - option - ) => { - acc[option.value] = option.label; - return acc; - }, - {} - ); - element.addOptions(options); - element.onChange(async (value) => { - this.formResult[definition.name] = - value; - }); - }); + onOpen() { + const { contentEl } = this; + contentEl.createEl("h1", { text: this.modalDefinition.title }); + this.modalDefinition.fields.forEach((definition) => { + const fieldBase = new Setting(contentEl) + .setName(definition.label || definition.name) + .setDesc(definition.description); + // This intermediary constants are necessary so typescript can narrow down the proper types. + // without them, you will have to use the whole access path (definition.input.folder), + // and it is no specific enough when you use it in a switch statement. + const fieldInput = definition.input; + const type = fieldInput.type; + switch (type) { + case "text": + return fieldBase.addText((text) => + text.onChange(async (value) => { + this.formResult[definition.name] = value; + }) + ); + case "number": + return fieldBase.addText((text) => { + text.inputEl.type = "number"; + text.onChange(async (value) => { + if (value !== "") { + this.formResult[definition.name] = + Number(value) + ""; + } + }); + }); + case "date": + return fieldBase.addText((text) => { + text.inputEl.type = "date"; + text.onChange(async (value) => { + this.formResult[definition.name] = value; + }); + }); + case "time": + return fieldBase.addText((text) => { + text.inputEl.type = "time"; + text.onChange(async (value) => { + this.formResult[definition.name] = value; + }); + }); + case "datetime": + return fieldBase.addText((text) => { + text.inputEl.type = "datetime-local"; + text.onChange(async (value) => { + this.formResult[definition.name] = value; + }); + }); + case "toggle": + return fieldBase.addToggle((toggle) => { + toggle.setValue(false); + this.formResult[definition.name] = false; + return toggle.onChange(async (value) => { + this.formResult[definition.name] = value; + }); + } + ); + case "note": + return fieldBase.addText((element) => { + new FileSuggest(this.app, element.inputEl, { + renderSuggestion(file) { + return file.basename; + }, + selectSuggestion(file) { + return file.basename; + }, + }, fieldInput.folder); + element.onChange(async (value) => { + this.formResult[definition.name] = value; + }); + }); + case "slider": + return fieldBase.addSlider((slider) => { + slider.setLimits(fieldInput.min, fieldInput.max, 1); + slider.setDynamicTooltip(); + slider.setValue(fieldInput.min); + slider.onChange(async (value) => { + this.formResult[definition.name] = value; + }); + }); + case 'multiselect': + { + this.formResult[definition.name] = this.formResult[definition.name] || [] + const options = fieldInput.source == 'fixed' + ? fieldInput.options + : get_tfiles_from_folder(fieldInput.folder, this.app).map(file => file.basename); + this.svelteComponents.push(new MultiSelect({ + target: fieldBase.controlEl, + props: { + selectedVales: this.formResult[definition.name] as string[], + availableOptions: options, + setting: fieldBase, + } + })) + return; + } + case "dataview": + { + const query = fieldInput.query; + return fieldBase.addText((element) => { + new DataviewSuggest(element.inputEl, query, this.app); + element.onChange(async (value) => { + this.formResult[definition.name] = value; + }); + }); + } + case "select": + { + const source = fieldInput.source; + switch (source) { + case "fixed": + return fieldBase.addDropdown((element) => { + const options = fieldInput.options.reduce( + ( + acc: Record<string, string>, + option + ) => { + acc[option.value] = option.label; + return acc; + }, + {} + ); + element.addOptions(options); + element.onChange(async (value) => { + this.formResult[definition.name] = + value; + }); + }); - case "notes": - return fieldBase.addDropdown((element) => { - const files = get_tfiles_from_folder(fieldInput.folder, this.app); - const options = files.reduce( - ( - acc: Record<string, string>, - option - ) => { - acc[option.basename] = - option.basename; - return acc; - }, - {} - ); - element.addOptions(options); - element.onChange(async (value) => { - this.formResult[definition.name] = - value; - }); - }); - default: - exhaustiveGuard(source); - } - } - break; - default: - return exhaustiveGuard(type); + case "notes": + return fieldBase.addDropdown((element) => { + const files = get_tfiles_from_folder(fieldInput.folder, this.app); + const options = files.reduce( + ( + acc: Record<string, string>, + option + ) => { + acc[option.basename] = + option.basename; + return acc; + }, + {} + ); + element.addOptions(options); + element.onChange(async (value) => { + this.formResult[definition.name] = + value; + }); + }); + default: + exhaustiveGuard(source); } - }); - new Setting(contentEl).addButton((btn) => - btn - .setButtonText("Submit") - .setCta() - .onClick(() => { - this.onSubmit(new FormResult(this.formResult, "ok")); - this.close(); - }) - ); - } + } + break; + default: + return exhaustiveGuard(type); + } + }); + new Setting(contentEl).addButton((btn) => + btn + .setButtonText("Submit") + .setCta() + .onClick(() => { + this.onSubmit(new FormResult(this.formResult, "ok")); + this.close(); + }) + ); + } - onClose() { - const { contentEl } = this; - this.svelteComponents.forEach(component => component.$destroy()) - contentEl.empty(); - this.formResult = {}; - } + onClose() { + const { contentEl } = this; + this.svelteComponents.forEach(component => component.$destroy()) + contentEl.empty(); + this.formResult = {}; + } } diff --git a/src/core/formDefinition.ts b/src/core/formDefinition.ts index 649595c0..98faae22 100644 --- a/src/core/formDefinition.ts +++ b/src/core/formDefinition.ts @@ -5,91 +5,91 @@ */ export type FieldType = - | "text" - | "number" - | "date" - | "time" - | "datetime" - | "toggle"; + | "text" + | "number" + | "date" + | "time" + | "datetime" + | "toggle"; type selectFromNotes = { type: "select"; source: "notes", folder: string }; type inputSlider = { type: "slider"; min: number, max: number }; type inputNoteFromFolder = { type: "note"; folder: string }; type inputDataviewSource = { type: 'dataview', query: string }; type inputSelectFixed = { - type: "select"; - source: "fixed"; - options: { value: string; label: string }[]; + type: "select"; + source: "fixed"; + options: { value: string; label: string }[]; } type basicInput = { type: FieldType }; -type multiselect = { type: 'multiselect' }; +type multiselect = { type: 'multiselect', source: 'notes', folder: string } | { type: 'multiselect', source: 'fixed', options: string[] } type inputType = - | basicInput - | inputNoteFromFolder - | inputSlider - | selectFromNotes - | inputDataviewSource - | multiselect - | inputSelectFixed; + | basicInput + | inputNoteFromFolder + | inputSlider + | selectFromNotes + | inputDataviewSource + | multiselect + | inputSelectFixed; export const FieldTypeReadable: Record<AllFieldTypes, string> = { - "text": "Text", - "number": "Number", - "date": "Date", - "time": "Time", - "datetime": "DateTime", - "toggle": "Toggle", - "note": "Note", - "slider": "Slider", - "select": "Select", - "dataview": "Dataview", - "multiselect": "Multiselect", + "text": "Text", + "number": "Number", + "date": "Date", + "time": "Time", + "datetime": "DateTime", + "toggle": "Toggle", + "note": "Note", + "slider": "Slider", + "select": "Select", + "dataview": "Dataview", + "multiselect": "Multiselect", } as const; function isObject(input: unknown): input is Record<string, unknown> { - return typeof input === "object" && input !== null; + return typeof input === "object" && input !== null; } export function isDataViewSource(input: unknown): input is inputDataviewSource { - return isObject(input) && input.type === 'dataview' && typeof input.query === 'string'; + return isObject(input) && input.type === 'dataview' && typeof input.query === 'string'; } export function isInputSlider(input: unknown): input is inputSlider { - if (!isObject(input)) { - return false; - } - if ('min' in input && 'max' in input && typeof input.min === 'number' && typeof input.max === 'number' && input.type === 'slider') { - return true; - } - return false + if (!isObject(input)) { + return false; + } + if ('min' in input && 'max' in input && typeof input.min === 'number' && typeof input.max === 'number' && input.type === 'slider') { + return true; + } + return false } export function isSelectFromNotes(input: unknown): input is selectFromNotes { - if (!isObject(input)) { - return false; - } - return input.type === "select" && input.source === "notes" && typeof input.folder === "string"; + if (!isObject(input)) { + return false; + } + return input.type === "select" && input.source === "notes" && typeof input.folder === "string"; } export function isInputNoteFromFolder(input: unknown): input is inputNoteFromFolder { - if (!isObject(input)) { - return false; - } - return input.type === "note" && typeof input.folder === "string"; + if (!isObject(input)) { + return false; + } + return input.type === "note" && typeof input.folder === "string"; } export function isInputSelectFixed(input: unknown): input is inputSelectFixed { - if (!isObject(input)) { - return false; - } - return input.type === "select" && input.source === "fixed" && Array.isArray(input.options) && input.options.every((option: unknown) => { - return isObject(option) && typeof option.value === "string" && typeof option.label === "string"; - }) + if (!isObject(input)) { + return false; + } + return input.type === "select" && input.source === "fixed" && Array.isArray(input.options) && input.options.every((option: unknown) => { + return isObject(option) && typeof option.value === "string" && typeof option.label === "string"; + }) } export type AllFieldTypes = inputType['type'] export type FieldDefinition = { - name: string; - label?: string; - description: string; - input: inputType; + name: string; + label?: string; + description: string; + input: inputType; } /** * FormDefinition is an already valid form, ready to be used in the form modal. @@ -102,104 +102,104 @@ export type FieldDefinition = { * @param type - The type of the field. Can be one of "text", "number", "date", "time", "datetime", "toggle". */ export type FormDefinition = { - title: string; - name: string; - fields: FieldDefinition[]; + title: string; + name: string; + fields: FieldDefinition[]; }; // When an input is in edit state, it is represented by this type. // It has all the possible values, and then you need to narrow it down // to the actual type. export type EditableInput = { - type: AllFieldTypes; - source?: "notes" | "fixed"; - folder?: string; - min?: number; - max?: number; - options?: { value: string; label: string }[]; - query?: string; + type: AllFieldTypes; + source?: "notes" | "fixed"; + folder?: string; + min?: number; + max?: number; + options?: { value: string; label: string }[]; + query?: string; }; export type EditableFormDefinition = { - title: string; - name: string; - fields: { - name: string; - label?: string; - description: string; - input: EditableInput; - }[]; + title: string; + name: string; + fields: { + name: string; + label?: string; + description: string; + input: EditableInput; + }[]; }; export function isValidBasicInput(input: unknown): input is basicInput { - if (!isObject(input)) { - return false; - } - return ["text", "number", "date", "time", "datetime", "toggle"].includes(input.type as string); + if (!isObject(input)) { + return false; + } + return ["text", "number", "date", "time", "datetime", "toggle"].includes(input.type as string); } export function isInputTypeValid(input: unknown): input is inputType { - if (isValidBasicInput(input)) { - return true; - } else if (isInputNoteFromFolder(input)) { - return true; - } else if (isInputSlider(input)) { - return true; - } else if (isSelectFromNotes(input)) { - return true; - } else if (isInputSelectFixed(input)) { - return true; - } else if (isDataViewSource(input)) { - return true; - } else { - return false; - } + if (isValidBasicInput(input)) { + return true; + } else if (isInputNoteFromFolder(input)) { + return true; + } else if (isInputSlider(input)) { + return true; + } else if (isSelectFromNotes(input)) { + return true; + } else if (isInputSelectFixed(input)) { + return true; + } else if (isDataViewSource(input)) { + return true; + } else { + return false; + } } export function decodeInputType(input: EditableInput): inputType | null { - if (isInputSlider(input)) { - return { type: "slider", min: input.min, max: input.max }; - } else if (isSelectFromNotes(input)) { - return { type: "select", source: "notes", folder: input.folder }; - } else if (isInputNoteFromFolder(input)) { - return { type: "note", folder: input.folder! }; - } else if (isInputSelectFixed(input)) { - return { type: "select", source: "fixed", options: input.options }; - } else if (isValidBasicInput(input)) { - return { type: input.type }; - } else { - return null; - } + if (isInputSlider(input)) { + return { type: "slider", min: input.min, max: input.max }; + } else if (isSelectFromNotes(input)) { + return { type: "select", source: "notes", folder: input.folder }; + } else if (isInputNoteFromFolder(input)) { + return { type: "note", folder: input.folder! }; + } else if (isInputSelectFixed(input)) { + return { type: "select", source: "fixed", options: input.options }; + } else if (isValidBasicInput(input)) { + return { type: input.type }; + } else { + return null; + } } export function isFieldValid(input: unknown): input is FieldDefinition { - if (!isObject(input)) { - return false; - } - if (typeof input.name !== "string" || input.name.length === 0) { - return false; - } - if (typeof input.description !== "string") { - return false; - } - if (input.label !== undefined && typeof input.label !== "string") { - return false; - } - console.log('basic input fields are valid') - return isInputTypeValid(input.input); + if (!isObject(input)) { + return false; + } + if (typeof input.name !== "string" || input.name.length === 0) { + return false; + } + if (typeof input.description !== "string") { + return false; + } + if (input.label !== undefined && typeof input.label !== "string") { + return false; + } + console.log('basic input fields are valid') + return isInputTypeValid(input.input); } export function isValidFormDefinition(input: unknown): input is FormDefinition { - if (!isObject(input)) { - return false; - } - if (typeof input.title !== "string") { - return false; - } - if (typeof input.name !== "string" || input.name === '') { - return false; - } - console.log('basic is valid'); - return Array.isArray(input.fields) && input.fields.every(isFieldValid); + if (!isObject(input)) { + return false; + } + if (typeof input.title !== "string") { + return false; + } + if (typeof input.name !== "string" || input.name === '') { + return false; + } + console.log('basic is valid'); + return Array.isArray(input.fields) && input.fields.every(isFieldValid); } diff --git a/src/exampleModalDefinition.ts b/src/exampleModalDefinition.ts index e36dd9f7..07b2f9c1 100644 --- a/src/exampleModalDefinition.ts +++ b/src/exampleModalDefinition.ts @@ -41,9 +41,15 @@ export const exampleModalDefinition: FormDefinition = { }, { name: "multi_example", - label: "Multi select example", - description: "Pick many", - input: { type: "multiselect" }, + label: "Multi select folder", + description: "Allows to pick many notes from a folder", + input: { type: "multiselect", source: "notes", folder: "Books" }, + }, + { + name: "multi_example_2", + label: "Multi select fixed", + description: "Allows to pick many notes from a fixed list", + input: { type: "multiselect", source: "fixed", options: ['Android', 'iOS', 'Windows', 'MacOS', 'Linux', 'Solaris', 'MS2'] }, }, { name: "best_fried", From fe5610818ad725303afea61b6f15a5972beed7fd Mon Sep 17 00:00:00 2001 From: Danielo Rodriguez <rdanielo@gmail.com> Date: Fri, 15 Sep 2023 13:28:34 +0200 Subject: [PATCH 4/7] chore: docs --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1dd16e23..27900126 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ https://github.com/danielo515/obsidian-modal-form/assets/2270425/542974aa-c58b-4 - free text - text with autocompletion for note names (from a folder or root) - text with autocompletion from a dataview query (requires dataview plugin) + - multiple choice input - select from a list - list of fixed values - list of notes from a folder From 3eff7a89c7177faa47940c16d04f97f5d72a3a21 Mon Sep 17 00:00:00 2001 From: Danielo Rodriguez <rdanielo@gmail.com> Date: Fri, 15 Sep 2023 14:34:12 +0200 Subject: [PATCH 5/7] feat: multi select UI builder --- src/core/formDefinition.ts | 264 +++++++++--------- src/views/FormBuilder.svelte | 2 +- src/views/components/inputBuilderMulti.svelte | 32 +++ 3 files changed, 168 insertions(+), 130 deletions(-) create mode 100644 src/views/components/inputBuilderMulti.svelte diff --git a/src/core/formDefinition.ts b/src/core/formDefinition.ts index 98faae22..e95710d6 100644 --- a/src/core/formDefinition.ts +++ b/src/core/formDefinition.ts @@ -5,91 +5,91 @@ */ export type FieldType = - | "text" - | "number" - | "date" - | "time" - | "datetime" - | "toggle"; + | "text" + | "number" + | "date" + | "time" + | "datetime" + | "toggle"; type selectFromNotes = { type: "select"; source: "notes", folder: string }; type inputSlider = { type: "slider"; min: number, max: number }; type inputNoteFromFolder = { type: "note"; folder: string }; type inputDataviewSource = { type: 'dataview', query: string }; type inputSelectFixed = { - type: "select"; - source: "fixed"; - options: { value: string; label: string }[]; + type: "select"; + source: "fixed"; + options: { value: string; label: string }[]; } type basicInput = { type: FieldType }; type multiselect = { type: 'multiselect', source: 'notes', folder: string } | { type: 'multiselect', source: 'fixed', options: string[] } type inputType = - | basicInput - | inputNoteFromFolder - | inputSlider - | selectFromNotes - | inputDataviewSource - | multiselect - | inputSelectFixed; + | basicInput + | inputNoteFromFolder + | inputSlider + | selectFromNotes + | inputDataviewSource + | multiselect + | inputSelectFixed; export const FieldTypeReadable: Record<AllFieldTypes, string> = { - "text": "Text", - "number": "Number", - "date": "Date", - "time": "Time", - "datetime": "DateTime", - "toggle": "Toggle", - "note": "Note", - "slider": "Slider", - "select": "Select", - "dataview": "Dataview", - "multiselect": "Multiselect", + "text": "Text", + "number": "Number", + "date": "Date", + "time": "Time", + "datetime": "DateTime", + "toggle": "Toggle", + "note": "Note", + "slider": "Slider", + "select": "Select", + "dataview": "Dataview", + "multiselect": "Multiselect", } as const; function isObject(input: unknown): input is Record<string, unknown> { - return typeof input === "object" && input !== null; + return typeof input === "object" && input !== null; } export function isDataViewSource(input: unknown): input is inputDataviewSource { - return isObject(input) && input.type === 'dataview' && typeof input.query === 'string'; + return isObject(input) && input.type === 'dataview' && typeof input.query === 'string'; } export function isInputSlider(input: unknown): input is inputSlider { - if (!isObject(input)) { - return false; - } - if ('min' in input && 'max' in input && typeof input.min === 'number' && typeof input.max === 'number' && input.type === 'slider') { - return true; - } - return false + if (!isObject(input)) { + return false; + } + if ('min' in input && 'max' in input && typeof input.min === 'number' && typeof input.max === 'number' && input.type === 'slider') { + return true; + } + return false } export function isSelectFromNotes(input: unknown): input is selectFromNotes { - if (!isObject(input)) { - return false; - } - return input.type === "select" && input.source === "notes" && typeof input.folder === "string"; + if (!isObject(input)) { + return false; + } + return input.type === "select" && input.source === "notes" && typeof input.folder === "string"; } export function isInputNoteFromFolder(input: unknown): input is inputNoteFromFolder { - if (!isObject(input)) { - return false; - } - return input.type === "note" && typeof input.folder === "string"; + if (!isObject(input)) { + return false; + } + return input.type === "note" && typeof input.folder === "string"; } export function isInputSelectFixed(input: unknown): input is inputSelectFixed { - if (!isObject(input)) { - return false; - } - return input.type === "select" && input.source === "fixed" && Array.isArray(input.options) && input.options.every((option: unknown) => { - return isObject(option) && typeof option.value === "string" && typeof option.label === "string"; - }) + if (!isObject(input)) { + return false; + } + return input.type === "select" && input.source === "fixed" && Array.isArray(input.options) && input.options.every((option: unknown) => { + return isObject(option) && typeof option.value === "string" && typeof option.label === "string"; + }) } export type AllFieldTypes = inputType['type'] export type FieldDefinition = { - name: string; - label?: string; - description: string; - input: inputType; + name: string; + label?: string; + description: string; + input: inputType; } /** * FormDefinition is an already valid form, ready to be used in the form modal. @@ -102,104 +102,110 @@ export type FieldDefinition = { * @param type - The type of the field. Can be one of "text", "number", "date", "time", "datetime", "toggle". */ export type FormDefinition = { - title: string; - name: string; - fields: FieldDefinition[]; + title: string; + name: string; + fields: FieldDefinition[]; }; // When an input is in edit state, it is represented by this type. // It has all the possible values, and then you need to narrow it down // to the actual type. export type EditableInput = { - type: AllFieldTypes; - source?: "notes" | "fixed"; - folder?: string; - min?: number; - max?: number; - options?: { value: string; label: string }[]; - query?: string; + type: AllFieldTypes; + source?: "notes" | "fixed"; + folder?: string; + min?: number; + max?: number; + options?: { value: string; label: string }[]; + query?: string; }; export type EditableFormDefinition = { - title: string; - name: string; - fields: { - name: string; - label?: string; - description: string; - input: EditableInput; - }[]; + title: string; + name: string; + fields: { + name: string; + label?: string; + description: string; + input: EditableInput; + }[]; }; export function isValidBasicInput(input: unknown): input is basicInput { - if (!isObject(input)) { - return false; - } - return ["text", "number", "date", "time", "datetime", "toggle"].includes(input.type as string); + if (!isObject(input)) { + return false; + } + return ["text", "number", "date", "time", "datetime", "toggle"].includes(input.type as string); +} + +export function isMultiSelect(input: unknown): input is multiselect { + return isObject(input) + && input.type === 'multiselect' + && ( + (input.source === 'notes' && typeof input.folder === 'string') || (input.source === 'fixed' && Array.isArray(input.options)) + ) } export function isInputTypeValid(input: unknown): input is inputType { - if (isValidBasicInput(input)) { - return true; - } else if (isInputNoteFromFolder(input)) { - return true; - } else if (isInputSlider(input)) { - return true; - } else if (isSelectFromNotes(input)) { - return true; - } else if (isInputSelectFixed(input)) { - return true; - } else if (isDataViewSource(input)) { - return true; - } else { - return false; - } + switch (true) { + case isValidBasicInput(input): + case isInputNoteFromFolder(input): + case isInputSlider(input): + case isSelectFromNotes(input): + case isInputSelectFixed(input): + case isDataViewSource(input): + case isMultiSelect(input): + return true; + default: + return false; + + } } export function decodeInputType(input: EditableInput): inputType | null { - if (isInputSlider(input)) { - return { type: "slider", min: input.min, max: input.max }; - } else if (isSelectFromNotes(input)) { - return { type: "select", source: "notes", folder: input.folder }; - } else if (isInputNoteFromFolder(input)) { - return { type: "note", folder: input.folder! }; - } else if (isInputSelectFixed(input)) { - return { type: "select", source: "fixed", options: input.options }; - } else if (isValidBasicInput(input)) { - return { type: input.type }; - } else { - return null; - } + if (isInputSlider(input)) { + return { type: "slider", min: input.min, max: input.max }; + } else if (isSelectFromNotes(input)) { + return { type: "select", source: "notes", folder: input.folder }; + } else if (isInputNoteFromFolder(input)) { + return { type: "note", folder: input.folder! }; + } else if (isInputSelectFixed(input)) { + return { type: "select", source: "fixed", options: input.options }; + } else if (isValidBasicInput(input)) { + return { type: input.type }; + } else { + return null; + } } export function isFieldValid(input: unknown): input is FieldDefinition { - if (!isObject(input)) { - return false; - } - if (typeof input.name !== "string" || input.name.length === 0) { - return false; - } - if (typeof input.description !== "string") { - return false; - } - if (input.label !== undefined && typeof input.label !== "string") { - return false; - } - console.log('basic input fields are valid') - return isInputTypeValid(input.input); + if (!isObject(input)) { + return false; + } + if (typeof input.name !== "string" || input.name.length === 0) { + return false; + } + if (typeof input.description !== "string") { + return false; + } + if (input.label !== undefined && typeof input.label !== "string") { + return false; + } + console.log('basic input fields are valid') + return isInputTypeValid(input.input); } export function isValidFormDefinition(input: unknown): input is FormDefinition { - if (!isObject(input)) { - return false; - } - if (typeof input.title !== "string") { - return false; - } - if (typeof input.name !== "string" || input.name === '') { - return false; - } - console.log('basic is valid'); - return Array.isArray(input.fields) && input.fields.every(isFieldValid); + if (!isObject(input)) { + return false; + } + if (typeof input.title !== "string") { + return false; + } + if (typeof input.name !== "string" || input.name === '') { + return false; + } + console.log('basic is valid'); + return Array.isArray(input.fields) && input.fields.every(isFieldValid); } diff --git a/src/views/FormBuilder.svelte b/src/views/FormBuilder.svelte index 765ec925..6ef989c2 100644 --- a/src/views/FormBuilder.svelte +++ b/src/views/FormBuilder.svelte @@ -205,7 +205,7 @@ </div> </div> <div class="flex gap1"> - {#if field.input.type === "select"} + {#if field.input.type === "select" || field.input.type === "multiselect"} {@const source_id = `source_${index}`} <div class="flex column gap1"> <label for={source_id}>Source</label> diff --git a/src/views/components/inputBuilderMulti.svelte b/src/views/components/inputBuilderMulti.svelte new file mode 100644 index 00000000..522e54d3 --- /dev/null +++ b/src/views/components/inputBuilderMulti.svelte @@ -0,0 +1,32 @@ +<script lang="ts"> + import FormRow from "./FormRow.svelte"; + + export let index: number; + export let value: string = ""; + $: id = `dataview_${index}`; +</script> + +<FormRow label="Dataview Query" {id}> + <span class="modal-form-hint"> + This is a <a + href="https://blacksmithgu.github.io/obsidian-dataview/api/intro/" + >Dataview</a + > + query that will be used to populate the input suggestions. You should provide + a query that returns a list of strings, for example: + <pre class="language-js"><code + >dv.pages('#tag').map(p => p.file.name)</code + ></pre> + </span> + <textarea + {id} + bind:value + name="dataview_query" + class="form-control" + rows="3" + placeholder="dv.pages('#tag').map(p => p.file.name)" + /> +</FormRow> + +<style> +</style> From c799a28a74f99f2c2b9cbe6f845a2085b5809c5e Mon Sep 17 00:00:00 2001 From: Danielo Rodriguez <rdanielo@gmail.com> Date: Fri, 15 Sep 2023 14:41:18 +0200 Subject: [PATCH 6/7] chore: remove unused --- src/views/components/inputBuilderMulti.svelte | 32 ------------------- 1 file changed, 32 deletions(-) delete mode 100644 src/views/components/inputBuilderMulti.svelte diff --git a/src/views/components/inputBuilderMulti.svelte b/src/views/components/inputBuilderMulti.svelte deleted file mode 100644 index 522e54d3..00000000 --- a/src/views/components/inputBuilderMulti.svelte +++ /dev/null @@ -1,32 +0,0 @@ -<script lang="ts"> - import FormRow from "./FormRow.svelte"; - - export let index: number; - export let value: string = ""; - $: id = `dataview_${index}`; -</script> - -<FormRow label="Dataview Query" {id}> - <span class="modal-form-hint"> - This is a <a - href="https://blacksmithgu.github.io/obsidian-dataview/api/intro/" - >Dataview</a - > - query that will be used to populate the input suggestions. You should provide - a query that returns a list of strings, for example: - <pre class="language-js"><code - >dv.pages('#tag').map(p => p.file.name)</code - ></pre> - </span> - <textarea - {id} - bind:value - name="dataview_query" - class="form-control" - rows="3" - placeholder="dv.pages('#tag').map(p => p.file.name)" - /> -</FormRow> - -<style> -</style> From f6f52270dfa03924f0f9beb09215982eb0b3ba48 Mon Sep 17 00:00:00 2001 From: Danielo Rodriguez <rdanielo@gmail.com> Date: Fri, 15 Sep 2023 14:43:53 +0200 Subject: [PATCH 7/7] chore: experiment with sync-version --- .github/workflows/sync-version.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-version.yml b/.github/workflows/sync-version.yml index c7d31e83..e567fbdc 100644 --- a/.github/workflows/sync-version.yml +++ b/.github/workflows/sync-version.yml @@ -5,7 +5,6 @@ name: sync versions on: pull_request: - types: [labeled] permissions: contents: write @@ -13,6 +12,7 @@ permissions: jobs: sync-versions: + if: "${{ github.event.label.name == 'autorelease: pending' }}" runs-on: ubuntu-latest steps: - uses: actions/checkout@v3