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 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 diff --git a/src/FormModal.ts b/src/FormModal.ts index ca2f1b0f..90960a99 100644 --- a/src/FormModal.ts +++ b/src/FormModal.ts @@ -1,180 +1,200 @@ 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"; 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 "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, - 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, + 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, - 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, + 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/core/formDefinition.ts b/src/core/formDefinition.ts index 2c5354b6..e95710d6 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', source: 'notes', folder: string } | { type: 'multiselect', source: 'fixed', options: string[] } type inputType = | basicInput | inputNoteFromFolder | inputSlider | selectFromNotes | inputDataviewSource + | multiselect | inputSelectFixed; export const FieldTypeReadable: Record = { @@ -41,6 +43,7 @@ export const FieldTypeReadable: Record = { "slider": "Slider", "select": "Select", "dataview": "Dataview", + "multiselect": "Multiselect", } as const; function isObject(input: unknown): input is Record { @@ -135,21 +138,27 @@ export function isValidBasicInput(input: unknown): input is basicInput { 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; + } } diff --git a/src/exampleModalDefinition.ts b/src/exampleModalDefinition.ts index 13375bc7..07b2f9c1 100644 --- a/src/exampleModalDefinition.ts +++ b/src/exampleModalDefinition.ts @@ -1,94 +1,105 @@ 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 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", + 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/MultiSuggest.ts b/src/suggesters/MultiSuggest.ts new file mode 100644 index 00000000..c154aefa --- /dev/null +++ b/src/suggesters/MultiSuggest.ts @@ -0,0 +1,29 @@ +import { TextInputSuggest } from "./suggest"; + +export class MultiSuggest extends TextInputSuggest { + content: Set; + + constructor(input: HTMLInputElement, content: Set, 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.onSelect(content); + this.inputEl.value = ""; + // this.inputEl.trigger("blur"); + this.inputEl.blur() + this.close(); + } +} 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 @@
- {#if field.input.type === "select"} + {#if field.input.type === "select" || field.input.type === "multiselect"} {@const source_id = `source_${index}`}
diff --git a/src/views/components/MultiSelect.svelte b/src/views/components/MultiSelect.svelte new file mode 100644 index 00000000..0231257d --- /dev/null +++ b/src/views/components/MultiSelect.svelte @@ -0,0 +1,133 @@ + + +
+ +
+ {#each selectedVales as value} +
+ {value} + +
+ {:else} + + {/each} +
+
+ +