Improve error handling for app extensions (#17191)

* add util function to get vue component name

* add global error handler

* add v-error-boundary component

* use error boundary to wrap insights panels

* use error boundary to wrap form interfaces

* use error boundary in render display and template

* use error boundary in extension options

* use error boundary for flows operation overview

* extract default options-overview into a component

* add tests
This commit is contained in:
Azri Kahar
2023-03-16 20:04:17 +08:00
committed by GitHub
parent 4d7e81f295
commit eb65d60236
14 changed files with 372 additions and 112 deletions

View File

@@ -0,0 +1,3 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `""`;

View File

@@ -70,6 +70,7 @@ import VDatePicker from './v-date-picker.vue';
import VEmojiPicker from './v-emoji-picker.vue';
import VWorkspace from './v-workspace.vue';
import VWorkspaceTile from './v-workspace-tile.vue';
import VErrorBoundary from './v-error-boundary.vue';
export function registerComponents(app: App): void {
app.component('VAvatar', VAvatar);
@@ -132,6 +133,7 @@ export function registerComponents(app: App): void {
app.component('VEmojiPicker', VEmojiPicker);
app.component('VWorkspace', VWorkspace);
app.component('VWorkspaceTile', VWorkspaceTile);
app.component('VErrorBoundary', VErrorBoundary);
app.component('TransitionBounce', TransitionBounce);
app.component('TransitionDialog', TransitionDialog);

View File

@@ -0,0 +1,60 @@
/* eslint-disable vue/one-component-per-file */
import { mount } from '@vue/test-utils';
import { afterAll, beforeAll, expect, test, vi } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import VErrorBoundary from './v-error-boundary.vue';
beforeAll(() => {
vi.spyOn(console, 'warn').mockImplementation(() => vi.fn());
});
afterAll(() => {
vi.restoreAllMocks();
});
test('Mount component', () => {
expect(VErrorBoundary).toBeTruthy();
const wrapper = mount(VErrorBoundary);
expect(wrapper.html()).toMatchSnapshot();
});
test('Should show default component when there is no error', async () => {
const defaultComponent = defineComponent({ render: () => h('div', 'test') });
const fallbackComponent = defineComponent({ render: () => h('div', 'fallback') });
const wrapper = mount(VErrorBoundary, {
slots: {
default: defaultComponent,
fallback: fallbackComponent,
},
});
expect(wrapper.html()).toBe(`<div>test</div>`);
});
test('Should show fallback component when there is an error', async () => {
const defaultComponent = defineComponent({
setup: () => {
// intentionally throw error to break this component
throw new Error();
},
render: () => h('div', 'test'),
});
const fallbackComponent = defineComponent({ render: () => h('div', 'fallback') });
const wrapper = mount(VErrorBoundary, {
slots: {
default: defaultComponent,
fallback: fallbackComponent,
},
});
// wait for dom to update
await nextTick();
expect(wrapper.html()).toBe(`<div>fallback</div>`);
});

View File

@@ -0,0 +1,40 @@
<template>
<template v-if="hasError">
<template v-if="$slots.fallback">
<slot name="fallback" v-bind="{ error }" />
</template>
</template>
<slot v-else></slot>
</template>
<script setup lang="ts">
import { getVueComponentName } from '@/utils/get-vue-component-name';
import { kebabCase } from 'lodash';
import { computed, onErrorCaptured, ref } from 'vue';
interface Props {
/** Unique name to identify component wrapped by this error boundary */
name?: string;
/** Stops propagating the error to the parent */
stopPropagation?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
name: undefined,
stopPropagation: true,
});
const error = ref<Error | null>(null);
const hasError = computed(() => !!error.value);
onErrorCaptured((err, vm, info) => {
error.value = err;
const source = props.name ? kebabCase(props.name) : getVueComponentName(vm);
// eslint-disable-next-line no-console
console.warn(`[${source}-error] ${info}`);
// eslint-disable-next-line no-console
console.warn(err);
if (props.stopPropagation) return false;
});
</script>

View File

@@ -7,29 +7,30 @@
>
<v-skeleton-loader v-if="loading && field.hideLoader !== true" />
<component
:is="
field.meta && field.meta.interface
? `interface-${field.meta.interface}`
: `interface-${getDefaultInterfaceForType(field.type)}`
"
v-if="interfaceExists && !rawEditorActive"
v-bind="(field.meta && field.meta.options) || {}"
:autofocus="disabled !== true && autofocus"
:disabled="disabled"
:loading="loading"
:value="modelValue === undefined ? field.schema?.default_value : modelValue"
:width="(field.meta && field.meta.width) || 'full'"
:type="field.type"
:collection="field.collection"
:field="field.field"
:field-data="field"
:primary-key="primaryKey"
:length="field.schema && field.schema.max_length"
:direction="direction"
@input="$emit('update:modelValue', $event)"
@set-field-value="$emit('setFieldValue', $event)"
/>
<v-error-boundary v-if="interfaceExists && !rawEditorActive" :name="componentName">
<component
:is="componentName"
v-bind="(field.meta && field.meta.options) || {}"
:autofocus="disabled !== true && autofocus"
:disabled="disabled"
:loading="loading"
:value="modelValue === undefined ? field.schema?.default_value : modelValue"
:width="(field.meta && field.meta.width) || 'full'"
:type="field.type"
:collection="field.collection"
:field="field.field"
:field-data="field"
:primary-key="primaryKey"
:length="field.schema && field.schema.max_length"
:direction="direction"
@input="$emit('update:modelValue', $event)"
@set-field-value="$emit('setFieldValue', $event)"
/>
<template #fallback>
<v-notice type="warning">{{ t('unexpected_error') }}</v-notice>
</template>
</v-error-boundary>
<interface-system-raw-editor
v-else-if="rawEditorEnabled && rawEditorActive"
@@ -88,6 +89,12 @@ const inter = useExtension(
);
const interfaceExists = computed(() => !!inter.value);
const componentName = computed(() => {
return props.field?.meta?.interface
? `interface-${props.field.meta.interface}`
: `interface-${getDefaultInterfaceForType(props.field.type)}`;
});
</script>
<style lang="scss" scoped>

View File

@@ -1,5 +1,6 @@
/* eslint-disable no-console */
import { getVueComponentName } from '@/utils/get-vue-component-name';
import { createPinia } from 'pinia';
import { createApp } from 'vue';
import { version } from '../package.json';
@@ -35,6 +36,13 @@ async function init() {
app.use(i18n);
app.use(createPinia());
app.config.errorHandler = (err, vm, info) => {
const source = getVueComponentName(vm);
console.warn(`[app-${source}-error] ${info}`);
console.warn(err);
return false;
};
registerDirectives(app);
registerComponents(app);
registerViews(app);

View File

@@ -121,18 +121,27 @@
>
{{ t('no_data') }}
</div>
<component
:is="`panel-${tile.data.type}`"
v-else
v-bind="tile.data.options"
:id="tile.id"
:dashboard="primaryKey"
:show-header="tile.showHeader"
:height="tile.height"
:width="tile.width"
:now="now"
:data="data[tile.id]"
/>
<v-error-boundary v-else :name="`panel-${tile.data.type}`">
<component
:is="`panel-${tile.data.type}`"
v-bind="tile.data.options"
:id="tile.id"
:dashboard="primaryKey"
:show-header="tile.showHeader"
:height="tile.height"
:width="tile.width"
:now="now"
:data="data[tile.id]"
/>
<template #fallback="{ error }">
<div class="panel-error">
<v-icon name="warning" />
{{ t('unexpected_error') }}
<v-error :error="error" />
</div>
</template>
</v-error-boundary>
</div>
</template>
</v-workspace>

View File

@@ -14,14 +14,18 @@
primary-key="+"
/>
<component
:is="`${type}-options-${extensionInfo!.id}`"
v-else
:value="optionsValues"
:collection="collection"
:field="field"
@input="optionsValues = $event"
/>
<v-error-boundary v-else :name="`${type}-options-${extensionInfo!.id}`">
<component
:is="`${type}-options-${extensionInfo!.id}`"
:value="optionsValues"
:collection="collection"
:field="field"
@input="optionsValues = $event"
/>
<template #fallback>
<v-notice type="warning">{{ t('unexpected_error') }}</v-notice>
</template>
</v-error-boundary>
</template>
<script lang="ts">

View File

@@ -79,33 +79,36 @@
<v-icon name="adjust" />
</div>
</template>
<div v-if="typeof currentOperation?.overview === 'function'" class="block">
<div v-tooltip="panel.key" class="name">
{{ panel.id === '$trigger' ? t(`triggers.${panel.type}.name`) : panel.name }}
<v-error-boundary
v-if="typeof currentOperation?.overview === 'function'"
:name="`operation-overview-${currentOperation.id}`"
>
<div class="block">
<options-overview :panel="panel" :current-operation="currentOperation" :flow="flow" />
</div>
<dl class="options-overview selectable">
<div
v-for="{ label, text, copyable } of translate(currentOperation?.overview(panel.options ?? {}, { flow }))"
:key="label"
>
<dt>{{ label }}</dt>
<dd>{{ text }}</dd>
<v-icon
v-if="isCopySupported && copyable"
name="copy"
small
clickable
class="clipboard-icon"
@click="copyToClipboard(text)"
/>
<template #fallback="{ error: optionsOverviewError }">
<div class="options-overview-error">
<v-icon name="warning" />
{{ t('unexpected_error') }}
<v-error :error="optionsOverviewError" />
</div>
</dl>
</div>
<component
:is="`operation-overview-${currentOperation.id}`"
</template>
</v-error-boundary>
<v-error-boundary
v-else-if="currentOperation && 'id' in currentOperation"
:options="currentOperation"
/>
:name="`operation-overview-${currentOperation.id}`"
>
<component :is="`operation-overview-${currentOperation.id}`" :options="currentOperation" />
<template #fallback="{ error: operationOverviewError }">
<div class="options-overview-error">
<v-icon name="warning" />
{{ t('unexpected_error') }}
<v-error :error="operationOverviewError" />
</div>
</template>
</v-error-boundary>
<template v-if="panel.id === '$trigger'" #footer>
<div class="status-footer" :class="flowStatus">
<display-color
@@ -135,15 +138,14 @@
</template>
<script lang="ts" setup>
import { useClipboard } from '@/composables/use-clipboard';
import { useExtensions } from '@/extensions';
import { translate } from '@/utils/translate-object-values';
import { Vector2 } from '@/utils/vector2';
import { FlowRaw } from '@directus/shared/types';
import { computed, ref, toRefs } from 'vue';
import { useI18n } from 'vue-i18n';
import { ATTACHMENT_OFFSET, REJECT_OFFSET, RESOLVE_OFFSET } from '../constants';
import { getTriggers } from '../triggers';
import OptionsOverview from './options-overview.vue';
export type Target = 'resolve' | 'reject';
export type ArrowInfo = {
@@ -194,8 +196,6 @@ const emit = defineEmits([
const { t } = useI18n();
const { isCopySupported, copyToClipboard } = useClipboard();
const styleVars = {
'--reject-left': REJECT_OFFSET.x + 'px',
'--reject-top': REJECT_OFFSET.y + 'px',
@@ -483,27 +483,20 @@ function pointerLeave() {
}
}
.options-overview {
> div {
flex-wrap: wrap;
align-items: center;
margin-bottom: 6px;
}
.options-overview-error {
padding: 20px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
width: 100%;
height: 100%;
dt {
flex-basis: 100%;
margin-bottom: -2px;
}
--v-icon-color: var(--danger);
dd {
font-family: var(--family-monospace);
flex-basis: 0;
}
.clipboard-icon {
--v-icon-color: var(--foreground-subdued);
--v-icon-color-hover: var(--foreground-normal);
margin-left: 4px;
.v-error {
margin-top: 8px;
max-width: 100%;
}
}

View File

@@ -0,0 +1,65 @@
<template>
<div v-tooltip="panel.key" class="name">
{{ panel.id === '$trigger' ? t(`triggers.${panel.type}.name`) : panel.name }}
</div>
<dl class="options-overview selectable">
<div
v-for="{ label, text, copyable } of translate(currentOperation?.overview(panel.options ?? {}, { flow }))"
:key="label"
>
<dt>{{ label }}</dt>
<dd>{{ text }}</dd>
<v-icon
v-if="isCopySupported && copyable"
name="copy"
small
clickable
class="clipboard-icon"
@click="copyToClipboard(text)"
/>
</div>
</dl>
</template>
<script setup lang="ts">
import { useClipboard } from '@/composables/use-clipboard';
import { translate } from '@/utils/translate-object-values';
import { FlowRaw } from '@directus/shared/types';
import { useI18n } from 'vue-i18n';
defineProps<{
panel: Record<string, any>;
currentOperation: any;
flow: FlowRaw;
}>();
const { t } = useI18n();
const { isCopySupported, copyToClipboard } = useClipboard();
</script>
<style lang="scss" scoped>
.options-overview {
> div {
flex-wrap: wrap;
align-items: center;
margin-bottom: 6px;
}
dt {
flex-basis: 100%;
margin-bottom: -2px;
}
dd {
font-family: var(--family-monospace);
flex-basis: 0;
}
.clipboard-icon {
--v-icon-color: var(--foreground-subdued);
--v-icon-color-hover: var(--foreground-normal);
margin-left: 4px;
}
}
</style>

View File

@@ -0,0 +1,40 @@
/* eslint-disable vue/one-component-per-file */
import { mount } from '@vue/test-utils';
import { expect, test } from 'vitest';
import { defineComponent, h } from 'vue';
import { getVueComponentName } from './get-vue-component-name';
test('should return unknown', () => {
expect(getVueComponentName(null)).toBe('unknown');
});
test('should return root', () => {
const defaultComponent = defineComponent({ render: () => h('div', 'test') });
const wrapper = mount(defaultComponent);
expect(getVueComponentName(wrapper.vm.$root)).toBe('root');
});
test('should return component name in kebab case', () => {
const defaultComponent = defineComponent({ name: 'MyTestComponent', render: () => h('div', 'test') });
const wrapper = mount(defaultComponent);
expect(getVueComponentName(wrapper.vm)).toBe('my-test-component');
});
test('should return component file as name', () => {
const defaultComponent = defineComponent({ render: () => h('div', 'test') });
const wrapper = mount(defaultComponent);
wrapper.vm.$options.__file = 'src/components/MyTestComponent.vue';
expect(getVueComponentName(wrapper.vm)).toBe('my-test-component');
});
test('should return generic name "component"', () => {
const defaultComponent = defineComponent({ render: () => h('div', 'test') });
const wrapper = mount(defaultComponent);
expect(getVueComponentName(wrapper.vm)).toBe('component');
});

View File

@@ -0,0 +1,19 @@
import { kebabCase } from 'lodash';
import { ComponentPublicInstance } from 'vue';
/**
* Returns the name of a Vue component instance, if applicable.
* @see https://github.com/vuejs/vue/blob/0e8511a8becf627e00443bd799dd99e5fd1b8a35/src/core/util/debug.ts
*/
export function getVueComponentName(vm: ComponentPublicInstance | null): string {
if (!vm) return `unknown`;
if (vm.$root === vm) return 'root';
const options = typeof vm === 'function' && (vm as any).cid != null ? (vm as any)?.options : vm?.$options;
let name = options.name || options.__name || options._componentTag;
const file = options.__file;
if (!name && file) {
const match = file.match(/([^/\\]+)\.vue$/);
name = match && match[1];
}
return name ? kebabCase(name) : `component`;
}

View File

@@ -1,17 +1,22 @@
<template>
<value-null v-if="value === null || value === undefined" />
<v-text-overflow v-else-if="displayInfo === null" class="display" :text="value" />
<component
:is="`display-${display}`"
v-else
v-bind="options"
:interface="interface"
:interface-options="interfaceOptions"
:value="value"
:type="type"
:collection="collection"
:field="field"
/>
<v-error-boundary v-else :name="`display-${display}`">
<component
:is="`display-${display}`"
v-bind="options"
:interface="interface"
:interface-options="interfaceOptions"
:value="value"
:type="type"
:collection="collection"
:field="field"
/>
<template #fallback>
<v-text-overflow class="display" :text="value" />
</template>
</v-error-boundary>
</template>
<script lang="ts">

View File

@@ -3,17 +3,22 @@
<span class="vertical-aligner" />
<template v-for="(part, index) in parts" :key="index">
<value-null v-if="part === null || (typeof part === 'object' && part.value === null)" />
<component
:is="`display-${part.component}`"
v-else-if="typeof part === 'object' && part.component"
v-bind="part.options"
:value="part.value"
:interface="part.interface"
:interface-options="part.interfaceOptions"
:type="part.type"
:collection="part.collection"
:field="part.field"
/>
<v-error-boundary v-else-if="typeof part === 'object' && part.component" :name="`display-${part.component}`">
<component
:is="`display-${part.component}`"
v-bind="part.options"
:value="part.value"
:interface="part.interface"
:interface-options="part.interfaceOptions"
:type="part.type"
:collection="part.collection"
:field="part.field"
/>
<template #fallback>
<span>{{ part.value }}</span>
</template>
</v-error-boundary>
<span v-else-if="typeof part === 'string'" :dir="direction">{{ translate(part) }}</span>
<span v-else>{{ part }}</span>
</template>