@knime/hub-features 1.0.14 → 1.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @knime/hub-features
2
2
 
3
+ ## 1.0.16
4
+
5
+ ### Patch Changes
6
+
7
+ - 5b0167e: - Add RFC error utils to handle API error responses
8
+ - Improve error handling for manifest upload request
9
+ - Updated dependencies [5b0167e]
10
+ - @knime/components@1.11.0
11
+
12
+ ## 1.0.15
13
+
14
+ ### Patch Changes
15
+
16
+ - Updated dependencies [c0fc6a3]
17
+ - @knime/components@1.10.0
18
+
3
19
  ## 1.0.14
4
20
 
5
21
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knime/hub-features",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "description": "Vue components & composables for shared hub features",
5
5
  "homepage": "https://knime.github.io/webapps-common/",
6
6
  "license": "GPL 3 and Additional Permissions according to Sec. 7 (SEE the file LICENSE)",
@@ -25,8 +25,9 @@
25
25
  "@vueuse/core": "10.4.1",
26
26
  "@vueuse/shared": "^10.10.0",
27
27
  "consola": "3.2.3",
28
+ "ofetch": "^1.4.1",
28
29
  "typescript": "^5.4.5",
29
- "@knime/components": "1.9.3",
30
+ "@knime/components": "1.11.0",
30
31
  "@knime/styles": "1.1.1",
31
32
  "@knime/utils": "1.1.2"
32
33
  },
@@ -39,7 +40,6 @@
39
40
  "@vue/test-utils": "2.4.4",
40
41
  "vite": "^5.4.7",
41
42
  "vite-svg-loader": "4.0.0",
42
- "vitest-fetch-mock": "^0.4.1",
43
43
  "vue-tsc": "^2.0.19"
44
44
  },
45
45
  "scripts": {
@@ -0,0 +1,12 @@
1
+ import { ofetch } from "ofetch";
2
+
3
+ const defaultConfig = {
4
+ headers: {
5
+ "Content-Type": "application/json",
6
+
7
+ // see: https://knime-com.atlassian.net/wiki/spaces/SPECS/pages/4126769212/API+Errors#Client-Request-Headers
8
+ Accept: "application/json; application/problem+json",
9
+ },
10
+ } as const;
11
+
12
+ export const $ofetch = ofetch.create(defaultConfig);
package/src/index.ts CHANGED
@@ -1 +1,2 @@
1
+ export * from "./rfcErrors";
1
2
  export * from "./useFileUpload";
@@ -0,0 +1,157 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from "vue";
3
+ import { useClipboard } from "@vueuse/core";
4
+
5
+ import { Button } from "@knime/components";
6
+ import CheckIcon from "@knime/styles/img/icons/check.svg";
7
+ import CopyIcon from "@knime/styles/img/icons/copy.svg";
8
+
9
+ interface Props {
10
+ headline: string; // toast headline, will not be displayed here but needed when copying to clipboard
11
+ title: string;
12
+ details?: string[];
13
+ status: number;
14
+ date: Date;
15
+ requestId: string;
16
+ errorId?: string;
17
+ }
18
+
19
+ const props = defineProps<Props>();
20
+ const emits = defineEmits(["showMore"]);
21
+
22
+ const { copy, copied } = useClipboard({
23
+ copiedDuring: 3000,
24
+ });
25
+
26
+ const showDetails = ref(false);
27
+
28
+ const dateFormatOptions = {
29
+ year: "numeric",
30
+ month: "short",
31
+ day: "numeric",
32
+ hour: "numeric",
33
+ minute: "numeric",
34
+ second: "numeric",
35
+ hour12: true,
36
+ } as const;
37
+
38
+ const formattedDate = computed(() => {
39
+ // eslint-disable-next-line no-undefined
40
+ const formatter = new Intl.DateTimeFormat(undefined, dateFormatOptions); // use default locale
41
+ return formatter.format(props.date);
42
+ });
43
+
44
+ const errorForClipboard = computed(() => {
45
+ let details = "";
46
+ if (props.details?.length) {
47
+ if (props.details.length > 1) {
48
+ const detailLines = props.details
49
+ .map((item) => `\u2022 ${item}`)
50
+ .join("\n");
51
+ details = `\n${detailLines}`;
52
+ } else {
53
+ details = props.details[0];
54
+ }
55
+ }
56
+
57
+ let errorText = `${props.headline}\n\n`;
58
+ errorText += `${props.title}\n\n`;
59
+ errorText += details ? `Details: ${details}\n\n` : "";
60
+
61
+ errorText += `Status: ${props.status}\n`;
62
+ errorText += `Date: ${formattedDate.value}\n`;
63
+ errorText += `Request Id: ${props.requestId}\n`;
64
+ errorText += props.errorId ? `Error Id: ${props.errorId}\n` : "";
65
+
66
+ return errorText;
67
+ });
68
+
69
+ const copyToClipboard = () => {
70
+ copy(errorForClipboard.value);
71
+ };
72
+
73
+ const onShowDetailsClicked = () => {
74
+ showDetails.value = true;
75
+ emits("showMore");
76
+ };
77
+ </script>
78
+
79
+ <template>
80
+ <div class="wrapper">
81
+ <div class="title">
82
+ {{ props.title }}
83
+ </div>
84
+ <button v-if="!showDetails" class="show-more" @click="onShowDetailsClicked">
85
+ Show details
86
+ </button>
87
+ <div v-if="showDetails" class="additional-info">
88
+ <div v-if="props.details?.length" class="details">
89
+ <strong>Details: </strong>
90
+ <template v-if="props.details.length == 1">
91
+ {{ props.details[0] }}
92
+ </template>
93
+ <template v-else>
94
+ <ul class="details-list">
95
+ <li v-for="(item, index) in details" :key="index">{{ item }}</li>
96
+ </ul>
97
+ </template>
98
+ </div>
99
+ <div><strong>Status: </strong>{{ status }}</div>
100
+ <div><strong>Date: </strong>{{ formattedDate }}</div>
101
+ <div><strong>Request id: </strong>{{ requestId }}</div>
102
+ <div v-if="errorId"><strong>Error id: </strong>{{ errorId }}</div>
103
+ <div class="copy-button-wrapper">
104
+ <Button @click="copyToClipboard">
105
+ <template v-if="copied"
106
+ ><CheckIcon class="copy-icon" />Error was copied</template
107
+ >
108
+ <template v-else
109
+ ><CopyIcon class="copy-icon" />Copy error to clipboard
110
+ </template>
111
+ </Button>
112
+ </div>
113
+ </div>
114
+ </div>
115
+ </template>
116
+
117
+ <style lang="postcss" scoped>
118
+ .wrapper {
119
+ display: flex;
120
+ flex-direction: column;
121
+ gap: 4px;
122
+ }
123
+
124
+ .details {
125
+ margin: 12px 0;
126
+
127
+ & .details-list {
128
+ padding-left: 25px;
129
+ margin-top: 6px;
130
+ margin-bottom: 6px;
131
+ }
132
+ }
133
+
134
+ .show-more {
135
+ all: unset;
136
+ cursor: pointer;
137
+ font-weight: 500;
138
+ white-space: nowrap;
139
+
140
+ &:active,
141
+ &:hover {
142
+ text-decoration: underline;
143
+ }
144
+ }
145
+
146
+ .copy-button-wrapper {
147
+ & .button {
148
+ font-size: 13px;
149
+ padding: 0;
150
+ margin-top: 10px;
151
+ }
152
+ }
153
+
154
+ svg.copy-icon {
155
+ width: 12px;
156
+ }
157
+ </style>
@@ -0,0 +1,60 @@
1
+ import { h } from "vue";
2
+ import { FetchError } from "ofetch";
3
+ import type { Toast } from "packages/components/src";
4
+
5
+ import RFCErrorToastTemplate from "./RFCErrorToastTemplate.vue";
6
+ import { RFCError, type RFCErrorData } from "./types";
7
+
8
+ /**
9
+ * Map to a toast template component compatible with an RFCError format
10
+ */
11
+ const toToast = ({
12
+ headline,
13
+ rfcError,
14
+ }: {
15
+ headline: string;
16
+ rfcError: RFCError;
17
+ }): Toast => {
18
+ const { data } = rfcError;
19
+
20
+ const rfcErrorToastContent = h(RFCErrorToastTemplate, { headline, ...data });
21
+
22
+ return {
23
+ type: "error",
24
+ headline,
25
+ component: rfcErrorToastContent,
26
+ autoRemove: false,
27
+ };
28
+ };
29
+
30
+ /**
31
+ * Try to parse an ofetch's `FetchError` to an `RFCError`. When parsing is not possible
32
+ * it returns the same `FetchError` given unchanged.
33
+ */
34
+ const tryParse = (error: FetchError): RFCError | FetchError => {
35
+ if (!error.response) {
36
+ return error;
37
+ }
38
+
39
+ const responseDate = error.response.headers.get("date")
40
+ ? new Date(error.response.headers.get("date")!)
41
+ : new Date();
42
+
43
+ const rfcErrorData: RFCErrorData = {
44
+ title: error.data.title as string,
45
+ details: error.data.details as string[] | undefined,
46
+ status: error.statusCode as number,
47
+ date: responseDate,
48
+ requestId: error.response.headers.get("x-request-id") ?? "",
49
+ // eslint-disable-next-line no-undefined
50
+ errorId: error.response.headers.get("x-error-id") ?? undefined,
51
+ };
52
+
53
+ return new RFCError(rfcErrorData);
54
+ };
55
+
56
+ export const rfcErrors = {
57
+ toToast,
58
+ tryParse,
59
+ RFCError,
60
+ };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Type based on an RFC-9457 error standard (https://www.rfc-editor.org/rfc/rfc9457)
3
+ */
4
+ export type RFCErrorData = {
5
+ title: string;
6
+ details?: string[];
7
+ status: number;
8
+ date: Date;
9
+ requestId: string;
10
+ errorId?: string;
11
+ };
12
+
13
+ export class RFCError extends Error {
14
+ data: RFCErrorData;
15
+
16
+ constructor(data: RFCErrorData) {
17
+ super(data.title);
18
+ this.data = data;
19
+ }
20
+ }
@@ -1,8 +1,12 @@
1
1
  import { computed, ref } from "vue";
2
+ import { FetchError } from "ofetch";
2
3
 
3
4
  import { useUploadManager } from "@knime/components";
4
5
  import { getFileMimeType, promise } from "@knime/utils";
5
6
 
7
+ import { $ofetch } from "../common/ofetchClient";
8
+ import { rfcErrors } from "../rfcErrors";
9
+
6
10
  const DEFAULT_MAX_UPLOAD_QUEUE_SIZE = 10;
7
11
 
8
12
  const DEFAULT_API_BASE_URL = "/_/api";
@@ -35,8 +39,8 @@ type UseFileUploadOptions = {
35
39
  * ```
36
40
  * {
37
41
  * "items": {
38
- * "my-file.txt": { itemContentType: "text/plain" },
39
- * "folder/my-file.zip": { itemContentType: "application/zip" },
42
+ * "my-file.txt": { itemContentType: "text/plain", itemContentSize: 5000 },
43
+ * "folder/my-file.zip": { itemContentType: "application/zip", itemContentSize: 5000 },
40
44
  * }
41
45
  * }
42
46
  * ```
@@ -93,7 +97,6 @@ type UseFileUploadOptions = {
93
97
  * </template>
94
98
  * ```
95
99
  */
96
-
97
100
  export const useFileUpload = (options: UseFileUploadOptions = {}) => {
98
101
  const baseUrl = computed(() => options.apiBaseUrl ?? DEFAULT_API_BASE_URL);
99
102
 
@@ -110,7 +113,10 @@ export const useFileUpload = (options: UseFileUploadOptions = {}) => {
110
113
  Object.fromEntries(
111
114
  Object.entries(fileDictionary).map(([name, file]) => [
112
115
  name,
113
- { itemContentType: getFileMimeType(file) },
116
+ {
117
+ itemContentType: getFileMimeType(file),
118
+ itemContentSize: file.size,
119
+ },
114
120
  ]),
115
121
  );
116
122
 
@@ -118,58 +124,45 @@ export const useFileUpload = (options: UseFileUploadOptions = {}) => {
118
124
  items: Record<string, { uploadId: string }>;
119
125
  };
120
126
 
121
- return fetch(`${baseUrl.value}/repository/${parentId}/manifest`, {
122
- method: "POST",
123
- headers: { "Content-Type": "application/json;charset=UTF8" },
124
- body: JSON.stringify({ items }),
125
- })
126
- .then((response) => response.json() as Promise<PrepareUploadResponse>)
127
- .then(({ items }) => {
128
- return Object.keys(items).map((name) => {
129
- const { uploadId } = items[name];
130
- const file = fileDictionary[name];
131
- return { uploadId, file };
132
- });
127
+ return $ofetch<PrepareUploadResponse>(
128
+ `${baseUrl.value}/repository/${parentId}/manifest`,
129
+ { method: "POST", body: { items } },
130
+ ).then(({ items }) => {
131
+ return Object.keys(items).map((name) => {
132
+ const { uploadId } = items[name];
133
+ const file = fileDictionary[name];
134
+ return { uploadId, file };
133
135
  });
136
+ });
134
137
  };
135
138
 
136
- const resolveFilePartUploadURL = async (
137
- uploadId: string,
138
- partNumber: number,
139
- ) => {
139
+ const resolveFilePartUploadURL = (uploadId: string, partNumber: number) => {
140
140
  type UploadURLResponse = {
141
141
  method: string;
142
142
  url: string;
143
143
  header: { Host: string };
144
144
  };
145
145
 
146
- const response = await fetch(
146
+ return $ofetch<UploadURLResponse>(
147
147
  `${baseUrl.value}/uploads/${uploadId}/parts/?partNumber=${partNumber}`,
148
148
  { method: "POST" },
149
149
  );
150
-
151
- return response.json() as Promise<UploadURLResponse>;
152
150
  };
153
151
 
154
- const completeUpload = async (
152
+ const completeUpload = (
155
153
  uploadId: string,
156
154
  partIds: Record<number, string>,
157
155
  ) => {
158
- const response = await fetch(`${baseUrl.value}/uploads/${uploadId}`, {
156
+ return $ofetch(`${baseUrl.value}/uploads/${uploadId}`, {
159
157
  method: "POST",
160
- headers: { "Content-Type": "application/json" },
161
- body: JSON.stringify(partIds),
158
+ body: partIds,
162
159
  });
163
-
164
- return response.json();
165
160
  };
166
161
 
167
- const cancelUpload = async (uploadId: string) => {
168
- const response = await fetch(`${baseUrl.value}/uploads/${uploadId}`, {
162
+ const cancelUpload = (uploadId: string) => {
163
+ return $ofetch(`${baseUrl.value}/uploads/${uploadId}`, {
169
164
  method: "DELETE",
170
165
  });
171
-
172
- return response.json();
173
166
  };
174
167
 
175
168
  const useUploadManagerResult = useUploadManager({
@@ -205,35 +198,44 @@ export const useFileUpload = (options: UseFileUploadOptions = {}) => {
205
198
  return files.slice(0, maxUploadQueueSize - totalPendingUploads.value);
206
199
  };
207
200
 
208
- const isPreparingUpload = ref(false);
201
+ const prepareQueueSize = ref(0);
209
202
 
210
203
  return {
211
204
  ...useUploadManagerResult,
212
205
 
213
- isPreparingUpload: computed(() => isPreparingUpload.value),
206
+ isPreparingUpload: computed(() => prepareQueueSize.value > 0),
207
+ totalFilesBeingPrepared: computed(() => prepareQueueSize.value),
214
208
 
215
209
  start: async (parentId: string, files: File[]) => {
216
210
  try {
217
- isPreparingUpload.value = true;
211
+ const enqueableFiles = getEnqueueableFiles(files);
212
+
213
+ if (enqueableFiles.length === 0) {
214
+ return;
215
+ }
216
+
217
+ prepareQueueSize.value += enqueableFiles.length;
218
218
 
219
- // TODO: HUB-9102: improve error handling here. see ticket for details
220
219
  const uploadPayload = await promise.retryPromise(() =>
221
- prepareUpload(parentId, getEnqueueableFiles(files)),
220
+ prepareUpload(parentId, enqueableFiles),
222
221
  );
223
222
 
224
- isPreparingUpload.value = false;
223
+ prepareQueueSize.value -= enqueableFiles.length;
225
224
 
226
225
  useUploadManagerResult.start(parentId, uploadPayload);
227
226
  } catch (error) {
228
- // TODO: HUB-8152 process max file size error
229
- consola.error(error);
227
+ if (error instanceof FetchError) {
228
+ throw rfcErrors.tryParse(error);
229
+ }
230
+
231
+ throw error;
230
232
  }
231
233
  },
232
234
 
233
235
  cancel: (uploadId: string) => {
234
236
  useUploadManagerResult.cancel(uploadId);
235
237
  cancelUpload(uploadId).catch((error) => {
236
- consola.error("There was a problem canceling the upload", { error });
238
+ consola.error("There was a problem cancelling the upload", { error });
237
239
  });
238
240
  },
239
241
  };