Skip to content

Commit 0fd52b2

Browse files
committed
HUB-8597: add component prop to Toast interface
and make toast width adjustable HUB-8597 (Adapt WAC toast to api errors)
1 parent 7219610 commit 0fd52b2

File tree

8 files changed

+288
-6
lines changed

8 files changed

+288
-6
lines changed

.changeset/strange-eels-itch.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@knime/components": minor
3+
"demo": patch
4+
---
5+
6+
add component prop to Toast interface to enable custom formatted toast content, update demo

demo/src/components/ToastService.vue

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
<script setup lang="ts">
22
import CodeExample from "./demo/CodeExample.vue";
3-
import { Button } from "@knime/components";
3+
import { ApiErrorTemplate, Button } from "@knime/components";
44
import Interactive from "@knime/styles/img/icons/interactive.svg";
55
// @ts-ignore
66
import { useToasts, type Toast } from "@knime/components";
7+
import { h } from "vue";
78
89
// import toastServiceCode from "@knime/components/toastService?raw";
910
// import typesCode from "@knime/components/types?raw";
@@ -325,6 +326,26 @@ const callbackToast: Toast = {
325326
},
326327
],
327328
};
329+
330+
const apiError = h(ApiErrorTemplate, {
331+
title:
332+
"Could not load the workflow from the deployment. Check if the workflow version for this deployment is still available.",
333+
details: [
334+
'Could not load workflow "/Users/some team/some space/workflowname" for deployment "cdf0fc54a-fasd123123-asd1234123-adfa123"',
335+
'Something else happend with workflow "/Users/some team/some space/workflowname" for deployment "cdf0fc54a-fasd123123-asd1234123-adfa123"',
336+
],
337+
status: 123,
338+
date: new Date(),
339+
requestId: "134123212413412321241341",
340+
errorId: "abcdefg",
341+
});
342+
343+
const apiErrorToast: Toast = {
344+
type: "error",
345+
headline: "Deployment could not be created",
346+
component: apiError,
347+
width: 400,
348+
};
328349
</script>
329350

330351
<template>
@@ -437,6 +458,9 @@ const callbackToast: Toast = {
437458
<Button with-border compact @click="show(callbackToast)"
438459
>Toast with a callback</Button
439460
>
461+
<Button with-border compact @click="show(apiErrorToast)"
462+
>Toast with an api error</Button
463+
>
440464
</div>
441465
</div>
442466
<h5>Stack control</h5>

packages/components/src/components/Toast/__tests__/Toast.test.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { mount, shallowMount } from "@vue/test-utils";
22
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
33

44
import Toast from "../components/Toast.vue";
5+
import { defineComponent, h } from "vue";
56

67
describe("Toast.vue", () => {
78
const MAX_LENGTH = 160;
@@ -124,4 +125,33 @@ describe("Toast.vue", () => {
124125
expect(wrapper.emitted().remove).toBeTruthy();
125126
});
126127
});
128+
129+
describe("custom component", () => {
130+
const dummyComponent = defineComponent({
131+
template: "<div class='dummy-component'>My custom toast</div>",
132+
});
133+
134+
it("renders custom component if provided", () => {
135+
const wrapper = mount(Toast, {
136+
props: {
137+
type: "info",
138+
component: h(dummyComponent),
139+
},
140+
});
141+
142+
expect(wrapper.find(".dummy-component").exists()).toBeTruthy();
143+
});
144+
145+
it("does not render custom component if message is provided", () => {
146+
const wrapper = mount(Toast, {
147+
props: {
148+
type: "info",
149+
message: "I want to break free",
150+
component: h(dummyComponent),
151+
},
152+
});
153+
154+
expect(wrapper.find(".dummy-component").exists()).toBeFalsy();
155+
});
156+
});
127157
});

packages/components/src/components/Toast/components/Toast.vue

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@ const props = withDefaults(defineProps<Toast>(), {
2424
type: "info",
2525
headline: "",
2626
message: "",
27+
component: null,
2728
autoRemove: true,
2829
active: true,
2930
buttons: () => [],
3031
stackId: "default",
32+
width: 350,
3133
});
3234
3335
const availableHeadline = computed(() => {
@@ -144,7 +146,11 @@ watch(toRef(props, "active"), (active) => {
144146
</script>
145147

146148
<template>
147-
<div ref="toastRef" :class="['toast', type]">
149+
<div
150+
ref="toastRef"
151+
:class="['toast', type]"
152+
:style="{ '--toast-width': `${width}px` }"
153+
>
148154
<div
149155
v-show="active"
150156
class="container"
@@ -155,13 +161,16 @@ watch(toRef(props, "active"), (active) => {
155161
</div>
156162
<div class="content">
157163
<div class="headline">{{ availableHeadline }}</div>
158-
<div class="message">
164+
<div v-if="message" class="message">
159165
<template v-if="isTruncated">
160166
{{ truncatedMessage }}
161167
<button class="show-more" @click="showMore">show more</button>
162168
</template>
163169
<template v-else>{{ message }}</template>
164170
</div>
171+
<div v-else-if="props.component" class="message">
172+
<component :is="props.component" />
173+
</div>
165174

166175
<div v-if="buttons.length" class="buttons">
167176
<ToastButton
@@ -197,7 +206,7 @@ watch(toRef(props, "active"), (active) => {
197206
overflow: hidden;
198207
background-color: var(--knime-white);
199208
color: var(--knime-masala);
200-
width: 350px;
209+
width: var(--toast-width, 350px);
201210
min-height: 75px;
202211
transition: all 0.3s;
203212
display: flex;
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<script setup lang="ts">
2+
import { Button } from "@knime/components";
3+
import CopyIcon from "@knime/styles/img/icons/copy.svg";
4+
import { useClipboard } from "@vueuse/core";
5+
import { computed } from "vue";
6+
7+
// see https://www.rfc-editor.org/rfc/rfc9457 for api error specification
8+
interface ApiErrorTemplateProps {
9+
title: string;
10+
details?: string[];
11+
status: number;
12+
date: Date;
13+
requestId: string;
14+
errorId?: string;
15+
}
16+
17+
const props = defineProps<ApiErrorTemplateProps>();
18+
19+
const { copy, copied } = useClipboard({
20+
copiedDuring: 3000,
21+
});
22+
23+
const dateFormatOptions = {
24+
year: "numeric",
25+
month: "short",
26+
day: "numeric",
27+
hour: "numeric",
28+
minute: "numeric",
29+
second: "numeric",
30+
hour12: true,
31+
} as const;
32+
33+
const formattedDate = computed(() => {
34+
// eslint-disable-next-line no-undefined
35+
const formatter = new Intl.DateTimeFormat(undefined, dateFormatOptions); // use default locale
36+
return formatter.format(props.date);
37+
});
38+
39+
const errorForClipboard = computed(() => {
40+
let details = "";
41+
if (props.details?.length) {
42+
if (props.details.length > 1) {
43+
const detailLines = props.details
44+
.map((item) => `\u2022 ${item}`)
45+
.join("\n");
46+
details = `\n${detailLines}`;
47+
} else {
48+
details = props.details[0];
49+
}
50+
}
51+
let errorText = `${props.title}\n\n`;
52+
errorText += details ? `Details: ${details}\n\n` : "";
53+
54+
errorText += `Status: ${props.status}\n`;
55+
errorText += `Date: ${formattedDate.value}\n`;
56+
errorText += `Request Id: ${props.requestId}\n`;
57+
errorText += props.errorId ? `Error Id: ${props.errorId}\n` : "";
58+
59+
return errorText;
60+
});
61+
62+
const copyButtonText = computed(() => {
63+
return copied.value ? "Error was copied" : "Copy error to clipboard";
64+
});
65+
66+
const copyToClipboard = () => {
67+
copy(errorForClipboard.value);
68+
};
69+
</script>
70+
71+
<template>
72+
<div class="wrapper">
73+
<div class="title">
74+
{{ props.title }}
75+
</div>
76+
<div v-if="props.details?.length" class="details">
77+
<strong>Details:</strong>
78+
<template v-if="props.details.length == 1">
79+
{{ props.details[0] }}
80+
</template>
81+
<template v-else>
82+
<ul>
83+
<li v-for="(item, index) in details" :key="index">{{ item }}</li>
84+
</ul>
85+
</template>
86+
</div>
87+
<div><strong>Status: </strong>{{ status }}</div>
88+
<div><strong>Date: </strong>{{ formattedDate }}</div>
89+
<div><strong>Request id: </strong>{{ requestId }}</div>
90+
<div v-if="errorId"><strong>Error id: </strong>{{ errorId }}</div>
91+
<div class="copy-button-wrapper">
92+
<Button @click="copyToClipboard"><CopyIcon />{{ copyButtonText }}</Button>
93+
</div>
94+
</div>
95+
</template>
96+
97+
<style lang="postcss">
98+
.wrapper {
99+
display: flex;
100+
flex-direction: column;
101+
gap: 4px;
102+
}
103+
104+
.details {
105+
margin: 12px 0;
106+
}
107+
108+
.copy-button-wrapper {
109+
& .button {
110+
font-size: 13px;
111+
padding: 0;
112+
113+
& svg {
114+
margin-top: 2px;
115+
width: 12px;
116+
}
117+
}
118+
}
119+
</style>
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { mount } from "@vue/test-utils";
2+
import { describe, expect, it, vi } from "vitest";
3+
import ApiErrorTemplate from "../ApiErrorTemplate.vue";
4+
import { ref } from "vue";
5+
6+
const { useClipboardMock } = vi.hoisted(() => ({
7+
useClipboardMock: vi.fn(),
8+
}));
9+
10+
vi.mock("@vueuse/core", () => ({
11+
useClipboard: useClipboardMock,
12+
}));
13+
14+
describe("ApiErrorTemplate", () => {
15+
const defaultProps = {
16+
title: "There was an error",
17+
details: ["Here", "are", "the", "deets"],
18+
status: 500,
19+
date: new Date(2012, 11, 12),
20+
requestId: "123456789",
21+
};
22+
const doMount = (props = defaultProps) => {
23+
const copiedMock = ref(false);
24+
const copyMock = vi.fn(() => {
25+
copiedMock.value = true;
26+
});
27+
useClipboardMock.mockReturnValue({
28+
copy: copyMock,
29+
copied: copiedMock,
30+
});
31+
const wrapper = mount(ApiErrorTemplate, {
32+
props,
33+
});
34+
return {
35+
wrapper,
36+
copyMock,
37+
};
38+
};
39+
40+
it("renders relevant information", () => {
41+
const { wrapper } = doMount();
42+
expect(wrapper.find(".title").text()).toBe(defaultProps.title);
43+
const details = wrapper.find(".details").text();
44+
defaultProps.details.forEach((item) => {
45+
expect(details).toContain(item);
46+
});
47+
expect(wrapper.text()).toContain("Status: 500");
48+
expect(wrapper.text()).toContain("Date: Dec 12, 2012, 12:00:00 AM");
49+
expect(wrapper.text()).toContain(`Request id: ${defaultProps.requestId}`);
50+
});
51+
52+
it("renders optional errorId", () => {
53+
const { wrapper } = doMount({
54+
...defaultProps,
55+
errorId: "extremely-fatal-error",
56+
} as any);
57+
expect(wrapper.text()).toContain("extremely-fatal-error");
58+
});
59+
60+
it("copies to clipboard", async () => {
61+
const { wrapper, copyMock } = doMount({
62+
...defaultProps,
63+
errorId: "extremely-fatal-error",
64+
} as any);
65+
expect(useClipboardMock).toHaveBeenCalled();
66+
await wrapper.find("button").trigger("click");
67+
expect(copyMock).toHaveBeenCalled();
68+
// @ts-ignore
69+
const copiedText = copyMock.mock.calls[0][0];
70+
expect(copiedText).toContain(defaultProps.title);
71+
defaultProps.details.forEach((item) => {
72+
expect(copiedText).toContain(item);
73+
});
74+
expect(copiedText).toContain("Status: 500");
75+
expect(copiedText).toContain("Date: Dec 12, 2012, 12:00:00 AM");
76+
expect(copiedText).toContain(`Request Id: ${defaultProps.requestId}`);
77+
expect(copiedText).toContain("Error Id: extremely-fatal-error");
78+
});
79+
80+
it("updates button text on copy", async () => {
81+
const { wrapper } = doMount();
82+
expect(wrapper.find("button").text()).toBe("Copy error to clipboard");
83+
await wrapper.find("button").trigger("click");
84+
expect(wrapper.find("button").text()).toBe("Error was copied");
85+
});
86+
});
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import ToastStack from "./components/ToastStack.vue";
2+
import ApiErrorTemplate from "./components/templates/ApiErrorTemplate.vue";
23

3-
export { ToastStack };
4+
export { ToastStack, ApiErrorTemplate };
45
export * from "./types";
56
export * from "./toastService";

packages/components/src/components/Toast/types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import type { ComputedRef, FunctionalComponent, SVGAttributes } from "vue";
1+
import type {
2+
ComputedRef,
3+
FunctionalComponent,
4+
SVGAttributes,
5+
VNode,
6+
} from "vue";
27

38
type BaseToastButton = {
49
/**
@@ -52,6 +57,7 @@ export interface Toast {
5257
headline?: string;
5358
message?: string;
5459
buttons?: ToastButton[];
60+
component?: VNode | null;
5561
/**
5662
* If set to true, the toast will have an animated progress bar indicating time before
5763
* being automatically dismissed.
@@ -62,6 +68,7 @@ export interface Toast {
6268
key?: string;
6369
meta?: any;
6470
stackId?: string;
71+
width?: number;
6572
}
6673

6774
export interface ToastService {

0 commit comments

Comments
 (0)