@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 +16 -0
- package/package.json +3 -3
- package/src/common/ofetchClient.ts +12 -0
- package/src/index.ts +1 -0
- package/src/rfcErrors/RFCErrorToastTemplate.vue +157 -0
- package/src/rfcErrors/index.ts +60 -0
- package/src/rfcErrors/types.ts +20 -0
- package/src/useFileUpload/useFileUpload.ts +44 -42
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.
|
|
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.
|
|
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
|
@@ -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
|
-
{
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
return
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
152
|
+
const completeUpload = (
|
|
155
153
|
uploadId: string,
|
|
156
154
|
partIds: Record<number, string>,
|
|
157
155
|
) => {
|
|
158
|
-
|
|
156
|
+
return $ofetch(`${baseUrl.value}/uploads/${uploadId}`, {
|
|
159
157
|
method: "POST",
|
|
160
|
-
|
|
161
|
-
body: JSON.stringify(partIds),
|
|
158
|
+
body: partIds,
|
|
162
159
|
});
|
|
163
|
-
|
|
164
|
-
return response.json();
|
|
165
160
|
};
|
|
166
161
|
|
|
167
|
-
const cancelUpload =
|
|
168
|
-
|
|
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
|
|
201
|
+
const prepareQueueSize = ref(0);
|
|
209
202
|
|
|
210
203
|
return {
|
|
211
204
|
...useUploadManagerResult,
|
|
212
205
|
|
|
213
|
-
isPreparingUpload: computed(() =>
|
|
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
|
-
|
|
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,
|
|
220
|
+
prepareUpload(parentId, enqueableFiles),
|
|
222
221
|
);
|
|
223
222
|
|
|
224
|
-
|
|
223
|
+
prepareQueueSize.value -= enqueableFiles.length;
|
|
225
224
|
|
|
226
225
|
useUploadManagerResult.start(parentId, uploadPayload);
|
|
227
226
|
} catch (error) {
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
238
|
+
consola.error("There was a problem cancelling the upload", { error });
|
|
237
239
|
});
|
|
238
240
|
},
|
|
239
241
|
};
|