@knime/hub-features 1.0.15 → 1.0.17

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,23 @@
1
1
  # @knime/hub-features
2
2
 
3
+ ## 1.0.17
4
+
5
+ ### Patch Changes
6
+
7
+ - 9fa8163: bugfix/improvements in uploads: retry only on server error, reset placeholder skeleton on prepare upload error
8
+ - Updated dependencies [9fa8163]
9
+ - @knime/components@1.11.1
10
+ - @knime/utils@1.1.3
11
+
12
+ ## 1.0.16
13
+
14
+ ### Patch Changes
15
+
16
+ - 5b0167e: - Add RFC error utils to handle API error responses
17
+ - Improve error handling for manifest upload request
18
+ - Updated dependencies [5b0167e]
19
+ - @knime/components@1.11.0
20
+
3
21
  ## 1.0.15
4
22
 
5
23
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knime/hub-features",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
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,10 +25,11 @@
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.10.0",
30
+ "@knime/components": "1.11.1",
30
31
  "@knime/styles": "1.1.1",
31
- "@knime/utils": "1.1.2"
32
+ "@knime/utils": "1.1.3"
32
33
  },
33
34
  "peerDependencies": {
34
35
  "vue": "3.x"
@@ -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,12 +1,18 @@
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";
9
13
 
14
+ const DEFAULT_RETRY_DELAY_MS = 50;
15
+
10
16
  type UseFileUploadOptions = {
11
17
  /**
12
18
  * Max number of concurrent uploads allowed in the upload queue.
@@ -35,8 +41,8 @@ type UseFileUploadOptions = {
35
41
  * ```
36
42
  * {
37
43
  * "items": {
38
- * "my-file.txt": { itemContentType: "text/plain" },
39
- * "folder/my-file.zip": { itemContentType: "application/zip" },
44
+ * "my-file.txt": { itemContentType: "text/plain", itemContentSize: 5000 },
45
+ * "folder/my-file.zip": { itemContentType: "application/zip", itemContentSize: 5000 },
40
46
  * }
41
47
  * }
42
48
  * ```
@@ -93,7 +99,6 @@ type UseFileUploadOptions = {
93
99
  * </template>
94
100
  * ```
95
101
  */
96
-
97
102
  export const useFileUpload = (options: UseFileUploadOptions = {}) => {
98
103
  const baseUrl = computed(() => options.apiBaseUrl ?? DEFAULT_API_BASE_URL);
99
104
 
@@ -110,7 +115,10 @@ export const useFileUpload = (options: UseFileUploadOptions = {}) => {
110
115
  Object.fromEntries(
111
116
  Object.entries(fileDictionary).map(([name, file]) => [
112
117
  name,
113
- { itemContentType: getFileMimeType(file) },
118
+ {
119
+ itemContentType: getFileMimeType(file),
120
+ itemContentSize: file.size,
121
+ },
114
122
  ]),
115
123
  );
116
124
 
@@ -118,58 +126,45 @@ export const useFileUpload = (options: UseFileUploadOptions = {}) => {
118
126
  items: Record<string, { uploadId: string }>;
119
127
  };
120
128
 
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
- });
129
+ return $ofetch<PrepareUploadResponse>(
130
+ `${baseUrl.value}/repository/${parentId}/manifest`,
131
+ { method: "POST", body: { items } },
132
+ ).then(({ items }) => {
133
+ return Object.keys(items).map((name) => {
134
+ const { uploadId } = items[name];
135
+ const file = fileDictionary[name];
136
+ return { uploadId, file };
133
137
  });
138
+ });
134
139
  };
135
140
 
136
- const resolveFilePartUploadURL = async (
137
- uploadId: string,
138
- partNumber: number,
139
- ) => {
141
+ const resolveFilePartUploadURL = (uploadId: string, partNumber: number) => {
140
142
  type UploadURLResponse = {
141
143
  method: string;
142
144
  url: string;
143
145
  header: { Host: string };
144
146
  };
145
147
 
146
- const response = await fetch(
148
+ return $ofetch<UploadURLResponse>(
147
149
  `${baseUrl.value}/uploads/${uploadId}/parts/?partNumber=${partNumber}`,
148
150
  { method: "POST" },
149
151
  );
150
-
151
- return response.json() as Promise<UploadURLResponse>;
152
152
  };
153
153
 
154
- const completeUpload = async (
154
+ const completeUpload = (
155
155
  uploadId: string,
156
156
  partIds: Record<number, string>,
157
157
  ) => {
158
- const response = await fetch(`${baseUrl.value}/uploads/${uploadId}`, {
158
+ return $ofetch(`${baseUrl.value}/uploads/${uploadId}`, {
159
159
  method: "POST",
160
- headers: { "Content-Type": "application/json" },
161
- body: JSON.stringify(partIds),
160
+ body: partIds,
162
161
  });
163
-
164
- return response.json();
165
162
  };
166
163
 
167
- const cancelUpload = async (uploadId: string) => {
168
- const response = await fetch(`${baseUrl.value}/uploads/${uploadId}`, {
164
+ const cancelUpload = (uploadId: string) => {
165
+ return $ofetch(`${baseUrl.value}/uploads/${uploadId}`, {
169
166
  method: "DELETE",
170
167
  });
171
-
172
- return response.json();
173
168
  };
174
169
 
175
170
  const useUploadManagerResult = useUploadManager({
@@ -177,7 +172,7 @@ export const useFileUpload = (options: UseFileUploadOptions = {}) => {
177
172
 
178
173
  onFileUploadComplete: ({ uploadId, filePartIds }) => {
179
174
  promise
180
- .retryPromise(() => completeUpload(uploadId, filePartIds))
175
+ .retryPromise({ fn: () => completeUpload(uploadId, filePartIds) })
181
176
  .then(() => {
182
177
  options.onFileUploadComplete?.(uploadId, filePartIds);
183
178
  })
@@ -205,35 +200,48 @@ export const useFileUpload = (options: UseFileUploadOptions = {}) => {
205
200
  return files.slice(0, maxUploadQueueSize - totalPendingUploads.value);
206
201
  };
207
202
 
208
- const isPreparingUpload = ref(false);
203
+ const prepareQueueSize = ref(0);
209
204
 
210
205
  return {
211
206
  ...useUploadManagerResult,
212
207
 
213
- isPreparingUpload: computed(() => isPreparingUpload.value),
208
+ isPreparingUpload: computed(() => prepareQueueSize.value > 0),
209
+ totalFilesBeingPrepared: computed(() => prepareQueueSize.value),
214
210
 
215
211
  start: async (parentId: string, files: File[]) => {
212
+ const enqueableFiles = getEnqueueableFiles(files);
216
213
  try {
217
- isPreparingUpload.value = true;
218
-
219
- // TODO: HUB-9102: improve error handling here. see ticket for details
220
- const uploadPayload = await promise.retryPromise(() =>
221
- prepareUpload(parentId, getEnqueueableFiles(files)),
222
- );
223
-
224
- isPreparingUpload.value = false;
214
+ if (enqueableFiles.length === 0) {
215
+ return;
216
+ }
217
+
218
+ prepareQueueSize.value += enqueableFiles.length;
219
+
220
+ const uploadPayload = await promise.retryPromise({
221
+ fn: () => prepareUpload(parentId, enqueableFiles),
222
+ excludeError: (error: FetchError) =>
223
+ // eslint-disable-next-line no-magic-numbers
224
+ Boolean(error.statusCode && error.statusCode < 500),
225
+ retryDelayMS: DEFAULT_RETRY_DELAY_MS,
226
+ });
225
227
 
226
228
  useUploadManagerResult.start(parentId, uploadPayload);
227
229
  } catch (error) {
228
- // TODO: HUB-8152 process max file size error
229
- consola.error(error);
230
+ if (error instanceof FetchError) {
231
+ throw rfcErrors.tryParse(error);
232
+ }
233
+
234
+ throw error;
235
+ } finally {
236
+ // errors can only be thrown in the prepareUpload call, useUploadManagerResult.start does its own error handling
237
+ prepareQueueSize.value -= enqueableFiles.length;
230
238
  }
231
239
  },
232
240
 
233
241
  cancel: (uploadId: string) => {
234
242
  useUploadManagerResult.cancel(uploadId);
235
243
  cancelUpload(uploadId).catch((error) => {
236
- consola.error("There was a problem canceling the upload", { error });
244
+ consola.error("There was a problem cancelling the upload", { error });
237
245
  });
238
246
  },
239
247
  };