Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

tags and multi-select inputs can allow any value to be selected, even if it does not exist yet #195

Merged
merged 4 commits into from
Jan 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions src/FormModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,14 @@ import { log_error, log_notice } from "./utils/Log";
import { FieldValue, FormEngine, makeFormEngine } from "./store/formStore";
import { Writable } from "svelte/store";
import { FolderSuggest } from "./suggesters/suggestFolder";
import { allowsUnknownValues } from "./core/InputDefinitionSchema";

export type SubmitFn = (formResult: FormResult) => void;

const notify = throttle((msg: string) => log_notice("⚠️ The form has errors ⚠️", msg, "notice-warning"), 2000);
const notify = throttle(
(msg: string) => log_notice("⚠️ The form has errors ⚠️", msg, "notice-warning"),
2000,
);
const notifyError = (title: string) =>
throttle((msg: string) => log_notice(`🚨 ${title} 🚨`, msg, "notice-error"), 2000);

Expand All @@ -34,7 +38,10 @@ export class FormModal extends Modal {
options?: FormOptions,
) {
super(app);
this.initialFormValues = formDataFromFormDefaults(modalDefinition.fields, options?.values ?? {});
this.initialFormValues = formDataFromFormDefaults(
modalDefinition.fields,
options?.values ?? {},
);
this.formEngine = makeFormEngine((result) => {
this.onSubmit(FormResult.make(result, "ok"));
this.close();
Expand All @@ -46,7 +53,8 @@ export class FormModal extends Modal {
const { contentEl } = this;
// This class is very important for scoped styles
contentEl.addClass("modal-form");
if (this.modalDefinition.customClassname) contentEl.addClass(this.modalDefinition.customClassname);
if (this.modalDefinition.customClassname)
contentEl.addClass(this.modalDefinition.customClassname);
contentEl.createEl("h1", { text: this.modalDefinition.title });
this.modalDefinition.fields.forEach((definition) => {
const fieldBase = new Setting(contentEl)
Expand Down Expand Up @@ -155,19 +163,23 @@ export class FormModal extends Modal {
});
case "multiselect": {
const source = fieldInput.source;
const allowUnknownValues = allowsUnknownValues(fieldInput);
const options =
source == "fixed"
? fieldInput.multi_select_options
: source == "notes"
? pipe(
? pipe(
get_tfiles_from_folder(fieldInput.folder, this.app),
E.map(A.map((file) => file.basename)),
E.getOrElse((err) => {
log_error(err);
return [] as string[];
}),
)
: executeSandboxedDvQuery(sandboxedDvQuery(fieldInput.query), this.app);
: executeSandboxedDvQuery(
sandboxedDvQuery(fieldInput.query),
this.app,
);
fieldStore.value.set(initialValue ?? []);
this.svelteComponents.push(
new MultiSelect({
Expand All @@ -178,13 +190,16 @@ export class FormModal extends Modal {
errors: fieldStore.errors,
setting: fieldBase,
app: this.app,
allowUnknownValues,
},
}),
);
return;
}
case "tag": {
const options = Object.keys(this.app.metadataCache.getTags()).map((tag) => tag.slice(1)); // remove the #
const options = Object.keys(this.app.metadataCache.getTags()).map((tag) =>
tag.slice(1),
); // remove the #
fieldStore.value.set(initialValue ?? []);
this.svelteComponents.push(
new MultiSelect({
Expand All @@ -195,6 +210,7 @@ export class FormModal extends Modal {
setting: fieldBase,
errors: fieldStore.errors,
app: this.app,
allowUnknownValues: true,
},
}),
);
Expand Down
23 changes: 18 additions & 5 deletions src/core/InputDefinitionSchema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { trySchemas, ParsingFn, parseC } from "@std";
import { AllFieldTypes } from "./formDefinition";
import { AllFieldTypes, AllSources } from "./formDefinition";
import {
object,
number,
Expand All @@ -13,6 +13,7 @@ import {
BaseSchema,
enumType,
Output,
boolean,
} from "valibot";

/**
Expand Down Expand Up @@ -87,12 +88,27 @@ const MultiSelectFixedSchema = object({
type: literal("multiselect"),
source: literal("fixed"),
multi_select_options: array(string()),
allowUnknownValues: optional(boolean(), false),
});
const MultiSelectQuerySchema = object({
type: literal("multiselect"),
source: literal("dataview"),
query: nonEmptyString("dataview query"),
allowUnknownValues: optional(boolean(), false),
});

export function canAllowUnknownValues(
type: "multiselect",
source: AllSources,
): source is "dataview" | "fixed" {
return type === "multiselect" && (source === "dataview" || source === "fixed");
}

export function allowsUnknownValues(input: multiselect): boolean {
if (input.source === "notes") return false;
return input.allowUnknownValues;
}

export const MultiselectSchema = union([
MultiSelectNotesSchema,
MultiSelectFixedSchema,
Expand Down Expand Up @@ -120,10 +136,7 @@ export const InputTypeSchema = union([
DocumentBlock,
]);

export const InputTypeToParserMap: Record<
AllFieldTypes,
ParsingFn<BaseSchema>
> = {
export const InputTypeToParserMap: Record<AllFieldTypes, ParsingFn<BaseSchema>> = {
number: parseC(InputBasicSchema),
text: parseC(InputBasicSchema),
email: parseC(InputBasicSchema),
Expand Down
3 changes: 2 additions & 1 deletion src/core/formDataFromDefaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ describe("formDataFromFormOptions", () => {
input: {
type: "multiselect",
source: "fixed",
allowUnknownValues: false,
multi_select_options: ["reading", "swimming", "running"],
},
},
Expand Down Expand Up @@ -61,7 +62,7 @@ describe("formDataFromFormOptions", () => {
expect(result).toEqual(expectedFormData);
});

it("should ensure toggles always have avalue even when no values are provided", () => {
it("should ensure toggles always have a value even when no values are provided", () => {
const values = {};

const expectedFormData = { isEmployed: false };
Expand Down
45 changes: 14 additions & 31 deletions src/core/formDefinition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,35 +10,25 @@ import { parse } from "valibot";

describe("isDataViewSource", () => {
it("should return true for valid inputDataviewSource objects", () => {
expect(
isDataViewSource({ type: "dataview", query: "some query" }),
).toBe(true);
expect(isDataViewSource({ type: "dataview", query: "some query" })).toBe(true);
});

it("should return false for invalid inputDataviewSource objects", () => {
expect(isDataViewSource({ type: "dataview" })).toBe(false);
expect(isDataViewSource({ type: "dataview", query: 123 })).toBe(false);
expect(isDataViewSource({ type: "select", query: "some query" })).toBe(
false,
);
expect(isDataViewSource({ type: "select", query: "some query" })).toBe(false);
});
});

describe("isInputNoteFromFolder", () => {
it("should return true for valid inputNoteFromFolder objects", () => {
expect(
isInputNoteFromFolder({ type: "note", folder: "some folder" }),
).toBe(true);
expect(isInputNoteFromFolder({ type: "note", folder: "some folder" })).toBe(true);
});

it("should return false for invalid inputNoteFromFolder objects", () => {
expect(isInputNoteFromFolder({ type: "note" })).toBe(false);
expect(isInputNoteFromFolder({ type: "note", folder: 123 })).toBe(
false,
);
expect(
isInputNoteFromFolder({ type: "select", folder: "some folder" }),
).toBe(false);
expect(isInputNoteFromFolder({ type: "note", folder: 123 })).toBe(false);
expect(isInputNoteFromFolder({ type: "select", folder: "some folder" })).toBe(false);
});
});

Expand All @@ -54,9 +44,7 @@ describe("isInputSelectFixed", () => {
});

it("should return false for invalid inputSelectFixed objects", () => {
expect(isInputSelectFixed({ type: "select", source: "fixed" })).toBe(
false,
);
expect(isInputSelectFixed({ type: "select", source: "fixed" })).toBe(false);
expect(
isInputSelectFixed({
type: "select",
Expand All @@ -81,9 +69,7 @@ describe("isInputSlider", () => {

it("should return false for invalid inputSlider objects", () => {
expect(isInputSlider({ type: "slider" })).toBe(false);
expect(isInputSlider({ type: "slider", min: "0", max: 10 })).toBe(
false,
);
expect(isInputSlider({ type: "slider", min: "0", max: 10 })).toBe(false);
expect(isInputSlider({ type: "select", min: 0, max: 10 })).toBe(false);
});
});
Expand All @@ -100,15 +86,9 @@ describe("isSelectFromNotes", () => {
});

it("should return false for invalid selectFromNotes objects", () => {
expect(isSelectFromNotes({ type: "select", source: "notes" })).toBe(
false,
);
expect(
isSelectFromNotes({ type: "select", source: "notes", folder: 123 }),
).toBe(false);
expect(isSelectFromNotes({ type: "note", folder: "some folder" })).toBe(
false,
);
expect(isSelectFromNotes({ type: "select", source: "notes" })).toBe(false);
expect(isSelectFromNotes({ type: "select", source: "notes", folder: 123 })).toBe(false);
expect(isSelectFromNotes({ type: "note", folder: "some folder" })).toBe(false);
});
});
describe("MultiSelectFixedSchema", () => {
Expand All @@ -118,7 +98,10 @@ describe("MultiSelectFixedSchema", () => {
source: "fixed",
multi_select_options: ["Option 1", "Option 2", "Option 3"],
};
expect(parse(MultiselectSchema, validSchema)).toEqual(validSchema);
expect(parse(MultiselectSchema, validSchema)).toEqual({
allowUnknownValues: false,
...validSchema,
});
});

it("should not validate an invalid multiselect fixed schema with missing properties", () => {
Expand Down
10 changes: 3 additions & 7 deletions src/core/formDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,7 @@ export function isSelectFromNotes(input: unknown): input is selectFromNotes {
return is(SelectFromNotesSchema, input);
}

export function isInputNoteFromFolder(
input: unknown,
): input is inputNoteFromFolder {
export function isInputNoteFromFolder(input: unknown): input is inputNoteFromFolder {
return is(InputNoteFromFolderSchema, input);
}
export function isInputSelectFixed(input: unknown): input is inputSelectFixed {
Expand Down Expand Up @@ -103,6 +101,7 @@ export type EditableInput = {
options?: { value: string; label: string }[];
multi_select_options?: string[];
query?: string;
allowUnknownValues?: boolean;
};

export type EditableFormDefinition = FormDefinition & {
Expand Down Expand Up @@ -157,10 +156,7 @@ export function isValidFormDefinition(input: unknown): input is FormDefinition {
return true;
}

export function duplicateForm(
formName: string,
forms: (FormDefinition | MigrationError)[],
) {
export function duplicateForm(formName: string, forms: (FormDefinition | MigrationError)[]) {
return pipe(
forms,
A.findFirstMap((f) => {
Expand Down
7 changes: 4 additions & 3 deletions src/exampleModalDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export const exampleModalDefinition: FormDefinition = {
input: {
type: "multiselect",
source: "fixed",
allowUnknownValues: false,
multi_select_options: [
"Android",
"iOS",
Expand All @@ -81,12 +82,13 @@ export const exampleModalDefinition: FormDefinition = {
type: "multiselect",
source: "dataview",
query: 'dv.pages("#person").map(p => p.file.name)',
allowUnknownValues: true,
},
},
{
name: "best_fried",
label: "Best friend",
description: "Pick one",
description: "Select of type note from a folder",
input: {
type: "select",
source: "notes",
Expand All @@ -96,8 +98,7 @@ export const exampleModalDefinition: FormDefinition = {
{
name: "dataview_example",
label: "Dataview example",
description:
"Only people matching the dataview query will be shown",
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)',
Expand Down
18 changes: 14 additions & 4 deletions src/suggesters/MultiSuggest.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
import { AbstractInputSuggest, App } from 'obsidian'
import { AbstractInputSuggest, App } from "obsidian";

export class MultiSuggest extends AbstractInputSuggest<string> {
content: Set<string>;

constructor(private inputEl: HTMLInputElement, content: Set<string>, private onSelectCb: (value: string) => void, app: App) {
constructor(
private inputEl: HTMLInputElement,
content: Set<string>,
private onSelectCb: (value: string) => void,
app: App,
private allowUnknownValues = false,
) {
super(app, inputEl);
this.content = content;
}

getSuggestions(inputStr: string): string[] {
const lowerCaseInputStr = inputStr.toLocaleLowerCase();
return [...this.content].filter((content) =>
content.toLocaleLowerCase().contains(lowerCaseInputStr)
const candidates =
this.allowUnknownValues && inputStr !== ""
? [...this.content, inputStr]
: Array.from(this.content);
return candidates.filter((content) =>
content.toLocaleLowerCase().contains(lowerCaseInputStr),
);
}

Expand Down
Loading