@jobber/components-native 0.101.11 → 0.102.0
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/dist/docs/FormatFile/FormatFile.md +28 -2
- package/dist/docs/Icon/Icon.md +1 -0
- package/dist/package.json +3 -3
- package/dist/src/FormatFile/FormatFile.js +2 -2
- package/dist/src/FormatFile/FormatFileThumbnail.js +3 -2
- package/dist/src/FormatFile/FormatFileThumbnail.test.js +23 -0
- package/dist/src/FormatFile/components/FileView/FileView.js +6 -23
- package/dist/src/FormatFile/components/FileView/FileView.style.js +5 -0
- package/dist/src/FormatFile/components/MediaView/MediaView.js +40 -6
- package/dist/src/FormatFile/components/MediaView/MediaView.test.js +69 -0
- package/dist/src/FormatFile/constants.js +60 -0
- package/dist/src/FormatFile/index.js +1 -0
- package/dist/src/FormatFile/utils/getFileCategory.js +62 -0
- package/dist/src/FormatFile/utils/getFileCategory.test.js +83 -0
- package/dist/src/FormatFile/utils/index.js +4 -0
- package/dist/src/FormatFile/utils/mapFileTypeToIconName.js +31 -0
- package/dist/src/FormatFile/utils/mapFileTypeToIconName.test.js +41 -0
- package/dist/src/FormatFile/utils/sanitizeFileName.js +9 -0
- package/dist/src/FormatFile/utils/sanitizeFileName.test.js +18 -0
- package/dist/src/FormatFile/utils/truncateFileNameForLine.js +58 -0
- package/dist/src/FormatFile/utils/truncateFileNameForLine.test.js +40 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/dist/types/src/FormatFile/FormatFile.d.ts +11 -3
- package/dist/types/src/FormatFile/FormatFileThumbnail.d.ts +10 -2
- package/dist/types/src/FormatFile/components/FileView/FileView.d.ts +2 -1
- package/dist/types/src/FormatFile/components/FileView/FileView.style.d.ts +2 -0
- package/dist/types/src/FormatFile/components/MediaView/MediaView.d.ts +2 -1
- package/dist/types/src/FormatFile/constants.d.ts +29 -0
- package/dist/types/src/FormatFile/index.d.ts +2 -0
- package/dist/types/src/FormatFile/utils/getFileCategory.d.ts +28 -0
- package/dist/types/src/FormatFile/utils/getFileCategory.test.d.ts +1 -0
- package/dist/types/src/FormatFile/utils/index.d.ts +5 -0
- package/dist/types/src/FormatFile/utils/mapFileTypeToIconName.d.ts +18 -0
- package/dist/types/src/FormatFile/utils/mapFileTypeToIconName.test.d.ts +1 -0
- package/dist/types/src/FormatFile/utils/sanitizeFileName.d.ts +5 -0
- package/dist/types/src/FormatFile/utils/sanitizeFileName.test.d.ts +1 -0
- package/dist/types/src/FormatFile/utils/truncateFileNameForLine.d.ts +24 -0
- package/dist/types/src/FormatFile/utils/truncateFileNameForLine.test.d.ts +1 -0
- package/package.json +3 -3
- package/src/FormatFile/FormatFile.stories.tsx +41 -1
- package/src/FormatFile/FormatFile.tsx +14 -2
- package/src/FormatFile/FormatFileThumbnail.test.tsx +44 -0
- package/src/FormatFile/FormatFileThumbnail.tsx +13 -1
- package/src/FormatFile/components/FileView/FileView.style.ts +5 -0
- package/src/FormatFile/components/FileView/FileView.tsx +12 -29
- package/src/FormatFile/components/MediaView/MediaView.test.tsx +159 -2
- package/src/FormatFile/components/MediaView/MediaView.tsx +114 -5
- package/src/FormatFile/constants.ts +64 -0
- package/src/FormatFile/index.ts +7 -0
- package/src/FormatFile/utils/getFileCategory.test.ts +96 -0
- package/src/FormatFile/utils/getFileCategory.ts +106 -0
- package/src/FormatFile/utils/index.ts +5 -0
- package/src/FormatFile/utils/mapFileTypeToIconName.test.ts +43 -0
- package/src/FormatFile/utils/mapFileTypeToIconName.ts +42 -0
- package/src/FormatFile/utils/sanitizeFileName.test.ts +25 -0
- package/src/FormatFile/utils/sanitizeFileName.ts +9 -0
- package/src/FormatFile/utils/truncateFileNameForLine.test.ts +61 -0
- package/src/FormatFile/utils/truncateFileNameForLine.ts +68 -0
- package/src/ThumbnailList/__snapshots__/ThumbnailList.test.tsx.snap +1 -0
|
@@ -25,7 +25,7 @@ describe("MediaView", () => {
|
|
|
25
25
|
showFileTypeIndicator: false,
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
-
const defaultProps = {
|
|
28
|
+
const defaultProps: React.ComponentProps<typeof MediaView> = {
|
|
29
29
|
accessibilityLabel: "Test image",
|
|
30
30
|
showOverlay: false,
|
|
31
31
|
showError: false,
|
|
@@ -38,7 +38,9 @@ describe("MediaView", () => {
|
|
|
38
38
|
useCreateThumbnail: () => ({ thumbnail: undefined, error: false }),
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
-
const renderWithContext = (
|
|
41
|
+
const renderWithContext = (
|
|
42
|
+
props: React.ComponentProps<typeof MediaView> = defaultProps,
|
|
43
|
+
) => {
|
|
42
44
|
return render(
|
|
43
45
|
<AtlantisFormatFileContext.Provider value={mockContextValue}>
|
|
44
46
|
<MediaView {...props} />
|
|
@@ -280,4 +282,159 @@ describe("MediaView", () => {
|
|
|
280
282
|
});
|
|
281
283
|
});
|
|
282
284
|
});
|
|
285
|
+
|
|
286
|
+
describe("Video placeholder", () => {
|
|
287
|
+
const videoFile: FormattedFile = {
|
|
288
|
+
...mockFile,
|
|
289
|
+
type: "video/mp4",
|
|
290
|
+
source: undefined,
|
|
291
|
+
thumbnailUrl: undefined,
|
|
292
|
+
name: "clip.mp4",
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
it("renders the videoFile placeholder when a video has no URI", () => {
|
|
296
|
+
const { getByTestId, queryByTestId } = renderWithContext({
|
|
297
|
+
...defaultProps,
|
|
298
|
+
file: videoFile,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
expect(getByTestId("format-file-video-placeholder")).toBeTruthy();
|
|
302
|
+
expect(queryByTestId("test-image")).toBeNull();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("falls back to the placeholder when the ImageBackground errors", () => {
|
|
306
|
+
const fileWithSource: FormattedFile = {
|
|
307
|
+
...videoFile,
|
|
308
|
+
source: "https://example.com/broken-video.mp4",
|
|
309
|
+
};
|
|
310
|
+
const { getByTestId, queryByTestId } = renderWithContext({
|
|
311
|
+
...defaultProps,
|
|
312
|
+
file: fileWithSource,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const image = getByTestId("test-image");
|
|
316
|
+
expect(queryByTestId("format-file-video-placeholder")).toBeNull();
|
|
317
|
+
|
|
318
|
+
fireEvent(image, "error");
|
|
319
|
+
|
|
320
|
+
expect(getByTestId("format-file-video-placeholder")).toBeTruthy();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("clears the loading spinner and fires onLoadEnd when the decoder errors", () => {
|
|
324
|
+
const onLoadEnd = jest.fn();
|
|
325
|
+
const fileWithSource: FormattedFile = {
|
|
326
|
+
...videoFile,
|
|
327
|
+
source: "https://example.com/broken-video.mp4",
|
|
328
|
+
};
|
|
329
|
+
const { getByTestId, queryByTestId } = renderWithContext({
|
|
330
|
+
...defaultProps,
|
|
331
|
+
file: fileWithSource,
|
|
332
|
+
onLoadEnd,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const image = getByTestId("test-image");
|
|
336
|
+
fireEvent(image, "loadStart");
|
|
337
|
+
expect(queryByTestId("ActivityIndicator")).toBeTruthy();
|
|
338
|
+
|
|
339
|
+
fireEvent(image, "error");
|
|
340
|
+
|
|
341
|
+
expect(onLoadEnd).toHaveBeenCalledTimes(1);
|
|
342
|
+
expect(queryByTestId("ActivityIndicator")).toBeNull();
|
|
343
|
+
expect(getByTestId("format-file-video-placeholder")).toBeTruthy();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("only fires onLoadEnd once when an error is followed by loadEnd", () => {
|
|
347
|
+
const onLoadEnd = jest.fn();
|
|
348
|
+
const fileWithSource: FormattedFile = {
|
|
349
|
+
...videoFile,
|
|
350
|
+
source: "https://example.com/broken-video.mp4",
|
|
351
|
+
};
|
|
352
|
+
const { getByTestId } = renderWithContext({
|
|
353
|
+
...defaultProps,
|
|
354
|
+
file: fileWithSource,
|
|
355
|
+
onLoadEnd,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const image = getByTestId("test-image");
|
|
359
|
+
fireEvent(image, "error");
|
|
360
|
+
fireEvent(image, "loadEnd");
|
|
361
|
+
|
|
362
|
+
expect(onLoadEnd).toHaveBeenCalledTimes(1);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("does not render the placeholder for non-video files with no URI", () => {
|
|
366
|
+
const imageFileWithoutUri: FormattedFile = {
|
|
367
|
+
...mockFile,
|
|
368
|
+
source: undefined,
|
|
369
|
+
thumbnailUrl: undefined,
|
|
370
|
+
};
|
|
371
|
+
const { queryByTestId } = renderWithContext({
|
|
372
|
+
...defaultProps,
|
|
373
|
+
file: imageFileWithoutUri,
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
expect(queryByTestId("format-file-video-placeholder")).toBeNull();
|
|
377
|
+
expect(queryByTestId("test-image")).toBeTruthy();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("renders the progress overlay on top of the placeholder during upload", () => {
|
|
381
|
+
const uploadingVideo: FormattedFile = {
|
|
382
|
+
...videoFile,
|
|
383
|
+
status: StatusCode.InProgress,
|
|
384
|
+
progress: 0.4,
|
|
385
|
+
};
|
|
386
|
+
const { getByTestId } = renderWithContext({
|
|
387
|
+
...defaultProps,
|
|
388
|
+
file: uploadingVideo,
|
|
389
|
+
showOverlay: true,
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
expect(getByTestId("format-file-video-placeholder")).toBeTruthy();
|
|
393
|
+
expect(getByTestId("format-file-progress-bar-container")).toBeTruthy();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("renders the error overlay on top of the placeholder when the upload fails", () => {
|
|
397
|
+
const failedVideo: FormattedFile = {
|
|
398
|
+
...videoFile,
|
|
399
|
+
status: StatusCode.Failed,
|
|
400
|
+
error: true,
|
|
401
|
+
};
|
|
402
|
+
const { getByTestId } = renderWithContext({
|
|
403
|
+
...defaultProps,
|
|
404
|
+
file: failedVideo,
|
|
405
|
+
showError: true,
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
expect(getByTestId("format-file-video-placeholder")).toBeTruthy();
|
|
409
|
+
expect(getByTestId("format-file-error-container")).toBeTruthy();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("hides the videoFile placeholder icon when showFileTypeIndicator is false", () => {
|
|
413
|
+
const { getByTestId, queryByTestId } = renderWithContext({
|
|
414
|
+
...defaultProps,
|
|
415
|
+
file: {
|
|
416
|
+
...videoFile,
|
|
417
|
+
showFileTypeIndicator: false,
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
expect(getByTestId("format-file-video-placeholder")).toBeTruthy();
|
|
422
|
+
expect(queryByTestId("videoFile")).toBeNull();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("paints the placeholder with surfaceColor when provided", () => {
|
|
426
|
+
const { getByTestId } = renderWithContext({
|
|
427
|
+
...defaultProps,
|
|
428
|
+
file: videoFile,
|
|
429
|
+
surfaceColor: "#F4ECD9",
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
const placeholder = getByTestId("format-file-video-placeholder");
|
|
433
|
+
const styleArray = Array.isArray(placeholder.props.style)
|
|
434
|
+
? placeholder.props.style.filter(Boolean)
|
|
435
|
+
: [placeholder.props.style];
|
|
436
|
+
const merged = Object.assign({}, ...styleArray);
|
|
437
|
+
expect(merged.backgroundColor).toBe("#F4ECD9");
|
|
438
|
+
});
|
|
439
|
+
});
|
|
283
440
|
});
|
|
@@ -19,8 +19,10 @@ interface MediaViewProps {
|
|
|
19
19
|
readonly styleInGrid: boolean;
|
|
20
20
|
readonly onUploadComplete: () => void;
|
|
21
21
|
readonly onLoadEnd?: () => void;
|
|
22
|
+
readonly surfaceColor?: string;
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
// eslint-disable-next-line max-statements
|
|
24
26
|
export function MediaView({
|
|
25
27
|
accessibilityLabel,
|
|
26
28
|
showOverlay,
|
|
@@ -29,17 +31,20 @@ export function MediaView({
|
|
|
29
31
|
styleInGrid,
|
|
30
32
|
onUploadComplete,
|
|
31
33
|
onLoadEnd,
|
|
34
|
+
surfaceColor,
|
|
32
35
|
}: MediaViewProps) {
|
|
33
36
|
const { t } = useAtlantisI18n();
|
|
34
37
|
const { useCreateThumbnail } = useAtlantisFormatFileContext();
|
|
35
38
|
const { thumbnail, error } = useCreateThumbnail(file);
|
|
36
39
|
const [isLoading, setIsLoading] = useState(false);
|
|
40
|
+
const [decodeFailed, setDecodeFailed] = useState(false);
|
|
37
41
|
/**
|
|
38
42
|
* Tracks whether onLoadEnd has fired to prevent race conditions.
|
|
39
43
|
* ImageBackground can fire onLoadEnd before onLoadStart when loading cached images,
|
|
40
44
|
* which would cause isLoading to get stuck at true, showing an infinite spinner.
|
|
41
45
|
*/
|
|
42
46
|
const hasLoadedRef = useRef(false);
|
|
47
|
+
const hasNotifiedLoadEndRef = useRef(false);
|
|
43
48
|
|
|
44
49
|
const a11yLabel = computeA11yLabel({
|
|
45
50
|
accessibilityLabel,
|
|
@@ -63,13 +68,44 @@ export function MediaView({
|
|
|
63
68
|
setIsLoading(false);
|
|
64
69
|
};
|
|
65
70
|
|
|
71
|
+
const handleMediaLoadEnd = () => {
|
|
72
|
+
handleLoadEnd();
|
|
73
|
+
|
|
74
|
+
if (!hasNotifiedLoadEndRef.current) {
|
|
75
|
+
hasNotifiedLoadEndRef.current = true;
|
|
76
|
+
onLoadEnd?.();
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
66
80
|
useEffect(() => {
|
|
67
81
|
hasLoadedRef.current = false;
|
|
82
|
+
hasNotifiedLoadEndRef.current = false;
|
|
68
83
|
setIsLoading(false);
|
|
84
|
+
setDecodeFailed(false);
|
|
69
85
|
}, [uri]);
|
|
70
86
|
|
|
87
|
+
if (isVideo(file.type) && (!uri || decodeFailed)) {
|
|
88
|
+
return (
|
|
89
|
+
<VideoPlaceholder
|
|
90
|
+
a11yLabel={a11yLabel}
|
|
91
|
+
styleInGrid={styleInGrid}
|
|
92
|
+
surfaceColor={surfaceColor}
|
|
93
|
+
styles={styles}
|
|
94
|
+
isLoading={isLoading}
|
|
95
|
+
showOverlay={showOverlay}
|
|
96
|
+
hasError={hasError}
|
|
97
|
+
file={file}
|
|
98
|
+
onUploadComplete={onUploadComplete}
|
|
99
|
+
/>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
71
103
|
return (
|
|
72
|
-
<View
|
|
104
|
+
<View
|
|
105
|
+
accessible={true}
|
|
106
|
+
accessibilityLabel={a11yLabel}
|
|
107
|
+
style={surfaceColor ? { backgroundColor: surfaceColor } : undefined}
|
|
108
|
+
>
|
|
73
109
|
<ImageBackground
|
|
74
110
|
style={[
|
|
75
111
|
styles.imageBackground,
|
|
@@ -79,9 +115,10 @@ export function MediaView({
|
|
|
79
115
|
source={{ uri }}
|
|
80
116
|
testID={"test-image"}
|
|
81
117
|
onLoadStart={handleLoadStart}
|
|
82
|
-
onLoadEnd={
|
|
83
|
-
|
|
84
|
-
|
|
118
|
+
onLoadEnd={handleMediaLoadEnd}
|
|
119
|
+
onError={() => {
|
|
120
|
+
handleMediaLoadEnd();
|
|
121
|
+
setDecodeFailed(true);
|
|
85
122
|
}}
|
|
86
123
|
>
|
|
87
124
|
<Overlay
|
|
@@ -133,7 +170,7 @@ function Overlay({
|
|
|
133
170
|
return <Icon name="video" color="white" />;
|
|
134
171
|
}
|
|
135
172
|
|
|
136
|
-
return
|
|
173
|
+
return null;
|
|
137
174
|
}
|
|
138
175
|
|
|
139
176
|
interface ProgressOverlayProps {
|
|
@@ -183,3 +220,75 @@ function ErrorOverlay({
|
|
|
183
220
|
function isVideo(fileType = ""): boolean {
|
|
184
221
|
return fileType.includes("video");
|
|
185
222
|
}
|
|
223
|
+
|
|
224
|
+
interface VideoPlaceholderProps {
|
|
225
|
+
readonly a11yLabel: string;
|
|
226
|
+
readonly styleInGrid: boolean;
|
|
227
|
+
readonly surfaceColor?: string;
|
|
228
|
+
readonly styles: ReturnType<typeof useStyles>;
|
|
229
|
+
readonly isLoading: boolean;
|
|
230
|
+
readonly showOverlay: boolean;
|
|
231
|
+
readonly hasError: boolean;
|
|
232
|
+
readonly file: FormattedFile;
|
|
233
|
+
readonly onUploadComplete: () => void;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function VideoPlaceholder({
|
|
237
|
+
a11yLabel,
|
|
238
|
+
styleInGrid,
|
|
239
|
+
surfaceColor,
|
|
240
|
+
styles,
|
|
241
|
+
isLoading,
|
|
242
|
+
showOverlay,
|
|
243
|
+
hasError,
|
|
244
|
+
file,
|
|
245
|
+
onUploadComplete,
|
|
246
|
+
}: VideoPlaceholderProps) {
|
|
247
|
+
return (
|
|
248
|
+
<View
|
|
249
|
+
accessible={true}
|
|
250
|
+
accessibilityLabel={a11yLabel}
|
|
251
|
+
style={[
|
|
252
|
+
styles.imageBackground,
|
|
253
|
+
styleInGrid ? styles.imageBackgroundGrid : styles.imageBackgroundFlat,
|
|
254
|
+
surfaceColor ? { backgroundColor: surfaceColor } : undefined,
|
|
255
|
+
]}
|
|
256
|
+
testID={"format-file-video-placeholder"}
|
|
257
|
+
>
|
|
258
|
+
{file.showFileTypeIndicator && <Icon name="videoFile" size="large" />}
|
|
259
|
+
<PlaceholderStatusOverlay
|
|
260
|
+
isLoading={isLoading}
|
|
261
|
+
showOverlay={showOverlay}
|
|
262
|
+
hasError={hasError}
|
|
263
|
+
file={file}
|
|
264
|
+
onUploadComplete={onUploadComplete}
|
|
265
|
+
styles={styles}
|
|
266
|
+
/>
|
|
267
|
+
</View>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function PlaceholderStatusOverlay({
|
|
272
|
+
isLoading,
|
|
273
|
+
showOverlay,
|
|
274
|
+
hasError,
|
|
275
|
+
file,
|
|
276
|
+
onUploadComplete,
|
|
277
|
+
styles,
|
|
278
|
+
}: OverlayProps) {
|
|
279
|
+
if (isLoading) return <ActivityIndicator />;
|
|
280
|
+
if (hasError) return <ErrorOverlay styles={styles} />;
|
|
281
|
+
|
|
282
|
+
if (showOverlay) {
|
|
283
|
+
return (
|
|
284
|
+
<ProgressOverlay
|
|
285
|
+
status={file.status}
|
|
286
|
+
progress={file.progress}
|
|
287
|
+
onUploadComplete={onUploadComplete}
|
|
288
|
+
styles={styles}
|
|
289
|
+
/>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
@@ -10,6 +10,70 @@ export const acceptedExtensions = [
|
|
|
10
10
|
export const videoExtensions = [
|
|
11
11
|
{ type: "mov" },
|
|
12
12
|
{ type: "mp4" },
|
|
13
|
+
{ type: "m4v" },
|
|
14
|
+
{ type: "webm" },
|
|
15
|
+
{ type: "mkv" },
|
|
16
|
+
{ type: "avi" },
|
|
17
|
+
{ type: "wmv" },
|
|
18
|
+
{ type: "flv" },
|
|
19
|
+
{ type: "mpg" },
|
|
20
|
+
{ type: "mpeg" },
|
|
21
|
+
{ type: "3gp" },
|
|
13
22
|
{ type: "3gpp" },
|
|
14
23
|
{ type: "hevc" },
|
|
15
24
|
];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Extensions that should be treated as documents (Word-family + OpenDocument
|
|
28
|
+
* Text + RTF + Apple Pages + WordPerfect). Used to route to the document
|
|
29
|
+
* icon and the "document" file category.
|
|
30
|
+
*/
|
|
31
|
+
export const documentExtensions = [
|
|
32
|
+
{ type: "doc" },
|
|
33
|
+
{ type: "docx" },
|
|
34
|
+
{ type: "odt" },
|
|
35
|
+
{ type: "fodt" },
|
|
36
|
+
{ type: "rtf" },
|
|
37
|
+
{ type: "pages" },
|
|
38
|
+
{ type: "wpd" },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Extensions that should be treated as spreadsheets (Excel-family +
|
|
43
|
+
* OpenDocument Spreadsheet + Apple Numbers + CSV). Used to route to the
|
|
44
|
+
* spreadsheet icon and the "spreadsheet" file category.
|
|
45
|
+
*/
|
|
46
|
+
export const spreadsheetExtensions = [
|
|
47
|
+
{ type: "xls" },
|
|
48
|
+
{ type: "xlsx" },
|
|
49
|
+
{ type: "ods" },
|
|
50
|
+
{ type: "fods" },
|
|
51
|
+
{ type: "numbers" },
|
|
52
|
+
{ type: "csv" },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Substring patterns matched against the `Content-Type` MIME string for
|
|
57
|
+
* documents. Substring rather than exact match because real-world MIMEs
|
|
58
|
+
* vary widely (e.g. `application/msword`,
|
|
59
|
+
* `application/vnd.openxmlformats-officedocument.wordprocessingml.document`,
|
|
60
|
+
* `application/vnd.oasis.opendocument.text`).
|
|
61
|
+
*/
|
|
62
|
+
export const documentMimePatterns = [
|
|
63
|
+
"msword",
|
|
64
|
+
"ms-word",
|
|
65
|
+
"wordprocessingml",
|
|
66
|
+
"opendocument.text",
|
|
67
|
+
"rtf",
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Substring patterns matched against the `Content-Type` MIME string for
|
|
72
|
+
* spreadsheets.
|
|
73
|
+
*/
|
|
74
|
+
export const spreadsheetMimePatterns = [
|
|
75
|
+
"ms-excel",
|
|
76
|
+
"spreadsheetml",
|
|
77
|
+
"opendocument.spreadsheet",
|
|
78
|
+
"csv",
|
|
79
|
+
];
|
package/src/FormatFile/index.ts
CHANGED
|
@@ -2,6 +2,13 @@ export type { FormatFileProps } from "./FormatFile";
|
|
|
2
2
|
export { FormatFile } from "./FormatFile";
|
|
3
3
|
export type { FormatFileThumbnailProps } from "./FormatFileThumbnail";
|
|
4
4
|
export { FormatFileThumbnail } from "./FormatFileThumbnail";
|
|
5
|
+
export {
|
|
6
|
+
getFileCategory,
|
|
7
|
+
mapFileTypeToIconName,
|
|
8
|
+
sanitizeFileName,
|
|
9
|
+
truncateFileNameForLine,
|
|
10
|
+
} from "./utils";
|
|
11
|
+
export type { FileCategory } from "./utils";
|
|
5
12
|
export type {
|
|
6
13
|
FormattedFile,
|
|
7
14
|
File,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { getFileCategory } from "./getFileCategory";
|
|
2
|
+
|
|
3
|
+
describe("getFileCategory", () => {
|
|
4
|
+
it("returns 'other' when neither name nor type is provided", () => {
|
|
5
|
+
expect(getFileCategory({})).toBe("other");
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
it.each([
|
|
9
|
+
["application/pdf", undefined, "pdf"],
|
|
10
|
+
[undefined, "report.pdf", "pdf"],
|
|
11
|
+
[undefined, "REPORT.PDF", "pdf"],
|
|
12
|
+
])("pdf: type=%s fileName=%s → %s", (fileType, fileName, expected) => {
|
|
13
|
+
expect(getFileCategory({ fileType, fileName })).toBe(expected);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it.each([
|
|
17
|
+
["image/jpeg", undefined, "image"],
|
|
18
|
+
["image/png", "photo.png", "image"],
|
|
19
|
+
])("image: type=%s fileName=%s → %s", (fileType, fileName, expected) => {
|
|
20
|
+
expect(getFileCategory({ fileType, fileName })).toBe(expected);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it.each([
|
|
24
|
+
["video/mp4", undefined, "video"],
|
|
25
|
+
["video/quicktime", undefined, "video"],
|
|
26
|
+
[undefined, "clip.mp4", "video"],
|
|
27
|
+
[undefined, "clip.mov", "video"],
|
|
28
|
+
[undefined, "clip.webm", "video"],
|
|
29
|
+
[undefined, "clip.mkv", "video"],
|
|
30
|
+
[undefined, "clip.avi", "video"],
|
|
31
|
+
])("video: type=%s fileName=%s → %s", (fileType, fileName, expected) => {
|
|
32
|
+
expect(getFileCategory({ fileType, fileName })).toBe(expected);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it.each([
|
|
36
|
+
["application/msword", undefined, "document"],
|
|
37
|
+
[
|
|
38
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
39
|
+
undefined,
|
|
40
|
+
"document",
|
|
41
|
+
],
|
|
42
|
+
["application/vnd.oasis.opendocument.text", undefined, "document"],
|
|
43
|
+
["application/rtf", undefined, "document"],
|
|
44
|
+
["text/rtf", undefined, "document"],
|
|
45
|
+
[undefined, "report.doc", "document"],
|
|
46
|
+
[undefined, "report.docx", "document"],
|
|
47
|
+
[undefined, "report.odt", "document"],
|
|
48
|
+
[undefined, "report.fodt", "document"],
|
|
49
|
+
[undefined, "memo.rtf", "document"],
|
|
50
|
+
[undefined, "notes.pages", "document"],
|
|
51
|
+
[undefined, "legacy.wpd", "document"],
|
|
52
|
+
])("document: type=%s fileName=%s → %s", (fileType, fileName, expected) => {
|
|
53
|
+
expect(getFileCategory({ fileType, fileName })).toBe(expected);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it.each([
|
|
57
|
+
["application/vnd.ms-excel", undefined, "spreadsheet"],
|
|
58
|
+
[
|
|
59
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
60
|
+
undefined,
|
|
61
|
+
"spreadsheet",
|
|
62
|
+
],
|
|
63
|
+
[
|
|
64
|
+
"application/vnd.oasis.opendocument.spreadsheet",
|
|
65
|
+
undefined,
|
|
66
|
+
"spreadsheet",
|
|
67
|
+
],
|
|
68
|
+
["text/csv", undefined, "spreadsheet"],
|
|
69
|
+
[undefined, "data.xls", "spreadsheet"],
|
|
70
|
+
[undefined, "data.xlsx", "spreadsheet"],
|
|
71
|
+
[undefined, "data.ods", "spreadsheet"],
|
|
72
|
+
[undefined, "data.fods", "spreadsheet"],
|
|
73
|
+
[undefined, "data.numbers", "spreadsheet"],
|
|
74
|
+
[undefined, "data.csv", "spreadsheet"],
|
|
75
|
+
])(
|
|
76
|
+
"spreadsheet: type=%s fileName=%s → %s",
|
|
77
|
+
(fileType, fileName, expected) => {
|
|
78
|
+
expect(getFileCategory({ fileType, fileName })).toBe(expected);
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
it.each([
|
|
83
|
+
["application/octet-stream", "unknown.bin", "other"],
|
|
84
|
+
[undefined, "no-extension", "other"],
|
|
85
|
+
[undefined, "presentation.pptx", "other"],
|
|
86
|
+
[undefined, "presentation.odp", "other"],
|
|
87
|
+
])("other: type=%s fileName=%s → %s", (fileType, fileName, expected) => {
|
|
88
|
+
expect(getFileCategory({ fileType, fileName })).toBe(expected);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("prefers MIME over extension when both are present and disagree", () => {
|
|
92
|
+
expect(
|
|
93
|
+
getFileCategory({ fileType: "application/pdf", fileName: "weird.docx" }),
|
|
94
|
+
).toBe("pdf");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import {
|
|
2
|
+
documentExtensions,
|
|
3
|
+
documentMimePatterns,
|
|
4
|
+
spreadsheetExtensions,
|
|
5
|
+
spreadsheetMimePatterns,
|
|
6
|
+
videoExtensions,
|
|
7
|
+
} from "../constants";
|
|
8
|
+
|
|
9
|
+
export type FileCategory =
|
|
10
|
+
| "document"
|
|
11
|
+
| "spreadsheet"
|
|
12
|
+
| "pdf"
|
|
13
|
+
| "video"
|
|
14
|
+
| "image"
|
|
15
|
+
| "other";
|
|
16
|
+
|
|
17
|
+
interface GetFileCategoryParams {
|
|
18
|
+
readonly fileName?: string;
|
|
19
|
+
readonly fileType?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function extensionOf(fileName: string): string {
|
|
23
|
+
const dot = fileName.lastIndexOf(".");
|
|
24
|
+
|
|
25
|
+
return dot < 0 ? "" : fileName.slice(dot + 1).toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function matchesAnyExtension(
|
|
29
|
+
fileName: string | undefined,
|
|
30
|
+
list: { type: string }[],
|
|
31
|
+
): boolean {
|
|
32
|
+
if (!fileName) return false;
|
|
33
|
+
const ext = extensionOf(fileName);
|
|
34
|
+
if (!ext) return false;
|
|
35
|
+
|
|
36
|
+
return list.some(({ type }) => type === ext);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function matchesAnyMimePattern(
|
|
40
|
+
fileType: string | undefined,
|
|
41
|
+
patterns: string[],
|
|
42
|
+
): boolean {
|
|
43
|
+
if (!fileType) return false;
|
|
44
|
+
const lower = fileType.toLowerCase();
|
|
45
|
+
|
|
46
|
+
return patterns.some(pattern => lower.includes(pattern));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Bucket a file into one of the generic categories the design system
|
|
51
|
+
* recognises. Decoupled from icon names so that:
|
|
52
|
+
*
|
|
53
|
+
* 1. Consumers can use generic terminology in copy, accessibility
|
|
54
|
+
* labels, and filter UI ("Document", "Spreadsheet") without
|
|
55
|
+
* coupling to the brand-named icon assets (`word`, `excel`).
|
|
56
|
+
* 2. When `@jobber/design` adds generic-document / generic-spreadsheet
|
|
57
|
+
* icons, the icon mapping can be updated without breaking
|
|
58
|
+
* consumers who already use this categorisation.
|
|
59
|
+
*
|
|
60
|
+
* Detection precedence: `fileType` (MIME) first, then `fileName`
|
|
61
|
+
* extension. The extension lists cover OpenDocument Text / Spreadsheet,
|
|
62
|
+
* RTF, Apple Pages / Numbers, CSV, and WordPerfect alongside the
|
|
63
|
+
* Microsoft Office formats so callers do not have to enumerate them.
|
|
64
|
+
*
|
|
65
|
+
* `fileType` matching is case-insensitive and accepts both full MIME
|
|
66
|
+
* strings (`"video/mp4"`, `"image/jpeg"`) and coarse type strings
|
|
67
|
+
* (`"video"`, `"image"`) since `parseFile` collapses video MIMEs to
|
|
68
|
+
* the bare token `"video"` for external files.
|
|
69
|
+
*/
|
|
70
|
+
export function getFileCategory({
|
|
71
|
+
fileName,
|
|
72
|
+
fileType,
|
|
73
|
+
}: GetFileCategoryParams): FileCategory {
|
|
74
|
+
const normalizedType = fileType?.toLowerCase();
|
|
75
|
+
|
|
76
|
+
if (normalizedType?.includes("pdf") || fileName?.match(/\.pdf$/i)) {
|
|
77
|
+
return "pdf";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (normalizedType?.includes("image")) {
|
|
81
|
+
return "image";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (
|
|
85
|
+
normalizedType?.includes("video") ||
|
|
86
|
+
matchesAnyExtension(fileName, videoExtensions)
|
|
87
|
+
) {
|
|
88
|
+
return "video";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (
|
|
92
|
+
matchesAnyMimePattern(normalizedType, documentMimePatterns) ||
|
|
93
|
+
matchesAnyExtension(fileName, documentExtensions)
|
|
94
|
+
) {
|
|
95
|
+
return "document";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (
|
|
99
|
+
matchesAnyMimePattern(normalizedType, spreadsheetMimePatterns) ||
|
|
100
|
+
matchesAnyExtension(fileName, spreadsheetExtensions)
|
|
101
|
+
) {
|
|
102
|
+
return "spreadsheet";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return "other";
|
|
106
|
+
}
|
|
@@ -1,2 +1,7 @@
|
|
|
1
1
|
export { computeA11yLabel } from "./computeA11yLabel";
|
|
2
2
|
export { parseFile, isMediaFile } from "./parseFile";
|
|
3
|
+
export { mapFileTypeToIconName } from "./mapFileTypeToIconName";
|
|
4
|
+
export { getFileCategory } from "./getFileCategory";
|
|
5
|
+
export type { FileCategory } from "./getFileCategory";
|
|
6
|
+
export { sanitizeFileName } from "./sanitizeFileName";
|
|
7
|
+
export { truncateFileNameForLine } from "./truncateFileNameForLine";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { mapFileTypeToIconName } from "./mapFileTypeToIconName";
|
|
2
|
+
|
|
3
|
+
describe("mapFileTypeToIconName", () => {
|
|
4
|
+
it("returns 'alert' when neither name nor type is provided", () => {
|
|
5
|
+
expect(mapFileTypeToIconName({})).toBe("alert");
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
it.each([
|
|
9
|
+
["application/pdf", undefined, "pdf"],
|
|
10
|
+
[undefined, "report.pdf", "pdf"],
|
|
11
|
+
[undefined, "report.doc", "word"],
|
|
12
|
+
[undefined, "report.docx", "word"],
|
|
13
|
+
[undefined, "report.odt", "word"],
|
|
14
|
+
[undefined, "report.fodt", "word"],
|
|
15
|
+
[undefined, "memo.rtf", "word"],
|
|
16
|
+
[undefined, "notes.pages", "word"],
|
|
17
|
+
["application/vnd.ms-excel", undefined, "excel"],
|
|
18
|
+
[undefined, "data.xls", "excel"],
|
|
19
|
+
[undefined, "data.xlsx", "excel"],
|
|
20
|
+
[undefined, "data.ods", "excel"],
|
|
21
|
+
[undefined, "data.numbers", "excel"],
|
|
22
|
+
[undefined, "data.csv", "excel"],
|
|
23
|
+
["video/mp4", undefined, "videoFile"],
|
|
24
|
+
["video/quicktime", undefined, "videoFile"],
|
|
25
|
+
[undefined, "clip.mp4", "videoFile"],
|
|
26
|
+
[undefined, "clip.mov", "videoFile"],
|
|
27
|
+
[undefined, "clip.MOV", "videoFile"],
|
|
28
|
+
[undefined, "clip.webm", "videoFile"],
|
|
29
|
+
[undefined, "clip.mkv", "videoFile"],
|
|
30
|
+
[undefined, "clip.avi", "videoFile"],
|
|
31
|
+
[undefined, "clip.m4v", "videoFile"],
|
|
32
|
+
[undefined, "clip.wmv", "videoFile"],
|
|
33
|
+
[undefined, "clip.flv", "videoFile"],
|
|
34
|
+
[undefined, "clip.mpg", "videoFile"],
|
|
35
|
+
[undefined, "clip.mpeg", "videoFile"],
|
|
36
|
+
[undefined, "clip.3gp", "videoFile"],
|
|
37
|
+
["application/octet-stream", "unknown.bin", "file"],
|
|
38
|
+
[undefined, "no-extension", "file"],
|
|
39
|
+
[undefined, "presentation.pptx", "file"],
|
|
40
|
+
])("maps type=%s fileName=%s → %s", (fileType, fileName, expected) => {
|
|
41
|
+
expect(mapFileTypeToIconName({ fileType, fileName })).toBe(expected);
|
|
42
|
+
});
|
|
43
|
+
});
|