Skip to content
Open
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,4 @@ exampleVault/.obsidian/plugins/obsidian-meta-bind-plugin/*

coverage/

dist/
dist/
7 changes: 5 additions & 2 deletions packages/core/src/api/API.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,11 @@ export abstract class API<Components extends MB_Comps> {

expectType<never>(type);

// TODO: Nice error message
throw new Error(`Unknown field type: ${type}`);
throw new MetaBindInternalError({
errorLevel: ErrorLevel.CRITICAL,
effect: 'failed to create field',
cause: `Unknown field type "${type}". Valid types are: ${Object.values(FieldType).join(', ')}`,
});
}

/**
Expand Down
13 changes: 6 additions & 7 deletions packages/core/src/config/validators/ButtonConfigValidators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ function actionFieldNumber(action: string, field: string, description: string) {
if (issue.input === undefined) {
return `The ${action} action requires a specified ${description} with the '${field}' field.`;
} else {
return `The ${action} action requires the value of the '${field}' fields to be a number, but got ${typeof issue.input}.`;
return `The ${action} action requires the value of the '${field}' field to be a number, but got ${typeof issue.input}.`;
}
},
});
Expand All @@ -40,7 +40,7 @@ function actionFieldString(action: string, field: string, description: string) {
if (issue.input === undefined) {
return `The ${action} action requires a specified ${description} with the '${field}' field.`;
} else {
return `The ${action} action requires the value of the '${field}' fields to be a string, but got ${typeof issue.input}.`;
return `The ${action} action requires the value of the '${field}' field to be a string, but got ${typeof issue.input}.`;
}
},
});
Expand All @@ -53,7 +53,7 @@ function actionFieldCoerceString(action: string, field: string, description: str
if (issue.input === undefined) {
return `The ${action} action requires a specified ${description} with the '${field}' field.`;
} else {
return `The ${action} action requires the value of the '${field}' fields to be a string, but got ${typeof issue.input}.`;
return `The ${action} action requires the value of the '${field}' field to be a string, but got ${typeof issue.input}.`;
}
},
});
Expand All @@ -66,7 +66,7 @@ function actionFieldBool(action: string, field: string, description: string) {
if (issue.input === undefined) {
return `The ${action} action requires a specified ${description} with the '${field}' field.`;
} else {
return `The ${action} action requires the value of the '${field}' fields to be a boolean, but got ${typeof issue.input}.`;
return `The ${action} action requires the value of the '${field}' field to be a boolean, but got ${typeof issue.input}.`;
}
},
});
Expand All @@ -91,7 +91,7 @@ export const V_OpenButtonAction = schemaForType<OpenButtonAction>()(
z.object({
type: z.literal(ButtonActionType.OPEN),
link: actionFieldString('open', 'link', 'link to open'),
newTab: actionFieldBool('open', 'newTab', '').optional(),
newTab: actionFieldBool('open', 'newTab', 'flag for whether to open in a new tab').optional(),
}),
);

Expand All @@ -109,7 +109,6 @@ export const V_SleepButtonAction = schemaForType<SleepButtonAction>()(
}),
);

// TODO: more better error messages
export const V_TemplaterCreateNoteButtonAction = schemaForType<TemplaterCreateNoteButtonAction>()(
z.object({
type: z.literal(ButtonActionType.TEMPLATER_CREATE_NOTE),
Expand Down Expand Up @@ -141,7 +140,7 @@ export const V_UpdateMetadataButtonAction = schemaForType<UpdateMetadataButtonAc
'evaluate',
'value for whether to evaluate the value as a JavaScript expression',
),
value: actionFieldCoerceString('updateMetadata', 'value for the update', 'value'),
value: actionFieldCoerceString('updateMetadata', 'value', 'value for the update'),
}),
);

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/fields/button/ButtonField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export class ButtonField extends Mountable {
this.config,
this.filePath,
this.getContext(),
ButtonClickContext.fromMouseEvent(event, ButtonClickType.LEFT),
ButtonClickContext.fromMouseEvent(event, ButtonClickType.MIDDLE),
);
},
},
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/utils/components/ButtonComponent.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
}
} catch (e) {
console.warn('failed to run button component on click', e);
mb.internal.showNotice('meta-bind | Error while running button action. Check the console for details.');
} finally {
disabled = false;
}
Expand Down
54 changes: 53 additions & 1 deletion tests/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, mock, type Mock, spyOn, test } from 'bun:test';
import { afterEach, beforeEach, describe, expect, mock, type Mock, spyOn, test } from 'bun:test';
import { TestMetaBind } from './__mocks__/TestPlugin';
import { InputFieldMountable } from 'packages/core/src/fields/inputFields/InputFieldMountable';
import { ViewFieldMountable } from 'packages/core/src/fields/viewFields/ViewFieldMountable';
Expand All @@ -12,6 +12,8 @@ import { FieldType, RenderChildType } from 'packages/core/src/config/APIConfigs'
import { PropPath } from 'packages/core/src/utils/prop/PropPath';
import { PropAccess, PropAccessType } from 'packages/core/src/utils/prop/PropAccess';
import { TestComponent } from './__mocks__/TestComponent';
import { ErrorLevel, MetaBindInternalError } from 'packages/core/src/utils/errors/MetaBindErrors';
import * as ZodUtils from 'packages/core/src/utils/ZodUtils';

describe('api', () => {
let plugin = new TestMetaBind();
Expand Down Expand Up @@ -129,6 +131,56 @@ action:

expect(field).toBeInstanceOf(ExcludedMountable);
});

describe('unknown field type', () => {
let validateSpy: ReturnType<typeof spyOn> | undefined;
let thrownError: MetaBindInternalError | undefined;

beforeEach(() => {
validateSpy = spyOn(ZodUtils, 'validateAPIArgs').mockImplementation(() => {});

const garbageType = 'GARBAGE_TYPE' as FieldType;

try {
plugin.api.createField(
garbageType,
'',
{
declaration: 'INPUT[toggle:foo]',
renderChildType: RenderChildType.BLOCK,
},
true,
);
} catch (e) {
thrownError = e as MetaBindInternalError;
}
});

afterEach(() => {
validateSpy?.mockRestore();
});

test('throws MetaBindInternalError', () => {
expect(thrownError).toBeInstanceOf(MetaBindInternalError);
});

test('errorLevel is CRITICAL', () => {
expect(thrownError!.errorLevel).toBe(ErrorLevel.CRITICAL);
});

test('cause mentions the invalid type', () => {
expect(thrownError!.cause).toContain('GARBAGE_TYPE');
});

test('cause lists valid field types', () => {
expect(thrownError!.cause).toContain('INPUT');
expect(thrownError!.cause).toContain('BUTTON');
});

test('effect is correct', () => {
expect(thrownError!.effect).toBe('failed to create field');
});
});
});

describe('createInlineFieldFromString', () => {
Expand Down
117 changes: 116 additions & 1 deletion tests/fields/Button.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
import {
type ButtonAction,
ButtonActionType,
ButtonClickContext,
ButtonClickType,
ButtonStyleType,
} from 'packages/core/src/config/ButtonConfig';
import { RenderChildType } from 'packages/core/src/config/APIConfigs';
import { ButtonField } from 'packages/core/src/fields/button/ButtonField';
import ButtonComponent from 'packages/core/src/utils/components/ButtonComponent.svelte';
import { mount, unmount } from 'svelte';
import { TestMetaBind } from 'tests/__mocks__/TestPlugin';

let testPlugin: TestMetaBind;
Expand Down Expand Up @@ -410,4 +415,114 @@ describe('Button', () => {
});
}
});

describe('ButtonClickContext', () => {
test('returns true for MIDDLE click', () => {
const ctx = new ButtonClickContext(ButtonClickType.MIDDLE, false, false, false);
expect(ctx.openInNewTab()).toBe(true);
});

test('returns false for LEFT click without ctrlKey', () => {
const ctx = new ButtonClickContext(ButtonClickType.LEFT, false, false, false);
expect(ctx.openInNewTab()).toBe(false);
});

test('returns true for LEFT click with ctrlKey', () => {
const ctx = new ButtonClickContext(ButtonClickType.LEFT, false, true, false);
expect(ctx.openInNewTab()).toBe(true);
});
});

describe('ButtonField auxclick', () => {
let container: HTMLDivElement;
let buttonField: ButtonField;

beforeEach(() => {
testPlugin = new TestMetaBind();
container = document.createElement('div');
document.body.appendChild(container);

buttonField = new ButtonField(
testPlugin,
{ label: 'Test', style: ButtonStyleType.DEFAULT },
testFilePath,
RenderChildType.BLOCK,
undefined,
false,
false,
);
buttonField.mount(container);
});

afterEach(() => {
buttonField?.unmount();
if (container.parentNode) {
document.body.removeChild(container);
}
});

test('dispatches MIDDLE click type on auxclick', async () => {
const spy = spyOn(testPlugin.buttonActionRunner, 'runButtonActions').mockResolvedValue(undefined);

const buttonEl = container.querySelector('button');
expect(buttonEl).not.toBeNull();

buttonEl!.dispatchEvent(new MouseEvent('auxclick', { bubbles: true }));

await new Promise(resolve => setTimeout(resolve, 0));

expect(spy).toHaveBeenCalledTimes(1);
const clickContext = spy.mock.calls[0][3] as ButtonClickContext;
expect(clickContext.type).toBe(ButtonClickType.MIDDLE);
});
});

describe('ButtonComponent', () => {
let container: HTMLDivElement;
let consoleWarnSpy: ReturnType<typeof spyOn> | undefined;

beforeEach(() => {
testPlugin = new TestMetaBind();
container = document.createElement('div');
document.body.appendChild(container);
});

afterEach(() => {
consoleWarnSpy?.mockRestore();
if (container.parentNode) {
document.body.removeChild(container);
}
});

test('shows a notice when onclick throws', async () => {
const showNoticeSpy = spyOn(testPlugin.internal, 'showNotice');
consoleWarnSpy = spyOn(console, 'warn').mockImplementation(() => {});

const component = mount(ButtonComponent, {
target: container,
props: {
mb: testPlugin,
label: 'Test',
onclick: async () => {
throw new Error('intentional test error');
},
},
});

const buttonEl = container.querySelector('button');
expect(buttonEl).not.toBeNull();

buttonEl!.dispatchEvent(new MouseEvent('click', { bubbles: true }));

await new Promise(resolve => setTimeout(resolve, 0));

expect(showNoticeSpy).toHaveBeenCalledTimes(1);
expect(showNoticeSpy).toHaveBeenCalledWith(
'meta-bind | Error while running button action. Check the console for details.',
);
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);

unmount(component);
});
});
});
Loading