@jobber/components-native 0.90.1-JOB-142149-547612b.8 → 0.91.1
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/package.json +2 -2
- package/dist/src/Form/Form.js +1 -1
- package/dist/src/Form/components/FormBody/FormBody.js +5 -5
- package/dist/src/FormatFile/components/MediaView/MediaView.js +22 -5
- package/dist/src/InputDate/InputDate.js +2 -2
- package/dist/src/InputFieldWrapper/InputFieldWrapper.js +14 -12
- package/dist/src/InputFieldWrapper/components/Prefix/Prefix.js +5 -2
- package/dist/src/InputFieldWrapper/components/Suffix/Suffix.js +5 -2
- package/dist/src/InputPressable/InputPressable.js +20 -8
- package/dist/src/InputPressable/InputPressable.style.js +3 -0
- package/dist/src/InputText/InputText.js +22 -11
- package/dist/src/InputText/InputText.style.js +4 -0
- package/dist/src/InputTime/InputTime.js +2 -2
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/dist/types/src/InputDate/InputDate.d.ts +2 -1
- package/dist/types/src/InputFieldWrapper/InputFieldWrapper.d.ts +9 -2
- package/dist/types/src/InputFieldWrapper/components/Prefix/Prefix.d.ts +2 -3
- package/dist/types/src/InputFieldWrapper/components/Suffix/Suffix.d.ts +2 -3
- package/dist/types/src/InputPressable/InputPressable.d.ts +9 -1
- package/dist/types/src/InputPressable/InputPressable.style.d.ts +3 -0
- package/dist/types/src/InputSearch/InputSearch.d.ts +1 -1
- package/dist/types/src/InputText/InputText.d.ts +8 -0
- package/dist/types/src/InputText/InputText.style.d.ts +4 -0
- package/dist/types/src/InputTime/InputTime.d.ts +2 -1
- package/package.json +2 -2
- package/src/Form/Form.tsx +1 -0
- package/src/Form/components/FormBody/FormBody.tsx +6 -6
- package/src/FormatFile/components/MediaView/MediaView.test.tsx +283 -0
- package/src/FormatFile/components/MediaView/MediaView.tsx +27 -6
- package/src/InputDate/InputDate.tsx +5 -1
- package/src/InputFieldWrapper/InputFieldWrapper.test.tsx +48 -1
- package/src/InputFieldWrapper/InputFieldWrapper.tsx +38 -28
- package/src/InputFieldWrapper/components/Prefix/Prefix.test.tsx +3 -5
- package/src/InputFieldWrapper/components/Prefix/Prefix.tsx +6 -4
- package/src/InputFieldWrapper/components/Suffix/Suffix.test.tsx +2 -4
- package/src/InputFieldWrapper/components/Suffix/Suffix.tsx +6 -4
- package/src/InputPressable/InputPressable.style.ts +4 -0
- package/src/InputPressable/InputPressable.test.tsx +75 -1
- package/src/InputPressable/InputPressable.tsx +33 -7
- package/src/InputSearch/InputSearch.tsx +1 -0
- package/src/InputText/InputText.style.ts +5 -0
- package/src/InputText/InputText.test.tsx +75 -0
- package/src/InputText/InputText.tsx +32 -12
- package/src/InputTime/InputTime.tsx +5 -1
|
@@ -2,7 +2,8 @@ import type { UseControllerProps } from "react-hook-form";
|
|
|
2
2
|
import type { XOR } from "ts-xor";
|
|
3
3
|
import type { Clearable } from "@jobber/hooks";
|
|
4
4
|
import type { InputFieldWrapperProps } from "../InputFieldWrapper";
|
|
5
|
-
|
|
5
|
+
import type { InputPressableProps } from "../InputPressable/InputPressable";
|
|
6
|
+
interface BaseInputDateProps extends Pick<InputFieldWrapperProps, "invalid" | "disabled" | "placeholder">, Pick<InputPressableProps, "showMiniLabel"> {
|
|
6
7
|
/**
|
|
7
8
|
* Defaulted to "always" so user can clear the dates whenever there's a value.
|
|
8
9
|
*/
|
|
@@ -26,7 +26,14 @@ export interface InputFieldWrapperProps {
|
|
|
26
26
|
* Text that goes below the input to help the user understand the input
|
|
27
27
|
*/
|
|
28
28
|
readonly assistiveText?: string;
|
|
29
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Controls how the placeholder text is displayed.
|
|
31
|
+
* - normal: the placeholder text will be displayed in the normal placeholder position
|
|
32
|
+
* - mini: the placeholder text will float above the input value
|
|
33
|
+
* - hidden: the placeholder text will not be displayed
|
|
34
|
+
* @default "normal"
|
|
35
|
+
*/
|
|
36
|
+
readonly placeholderMode?: "normal" | "mini" | "hidden";
|
|
30
37
|
readonly hasValue?: boolean;
|
|
31
38
|
/**
|
|
32
39
|
* Symbol to display before the text input
|
|
@@ -78,4 +85,4 @@ export interface InputFieldWrapperProps {
|
|
|
78
85
|
}
|
|
79
86
|
export declare const INPUT_FIELD_WRAPPER_GLIMMERS_TEST_ID = "ATL-InputFieldWrapper-Glimmers";
|
|
80
87
|
export declare const INPUT_FIELD_WRAPPER_SPINNER_TEST_ID = "ATL-InputFieldWrapper-Spinner";
|
|
81
|
-
export declare function InputFieldWrapper({ invalid, disabled, placeholder, assistiveText, prefix, suffix,
|
|
88
|
+
export declare function InputFieldWrapper({ invalid, disabled, placeholder, assistiveText, prefix, suffix, placeholderMode, hasValue, error, focused, children, onClear, showClearAction, styleOverride, toolbar, toolbarVisibility, loading, loadingType, }: InputFieldWrapperProps): JSX.Element;
|
|
@@ -3,18 +3,17 @@ import type { IconNames } from "@jobber/design";
|
|
|
3
3
|
export interface PrefixLabelProps {
|
|
4
4
|
readonly focused: boolean;
|
|
5
5
|
readonly disabled?: boolean;
|
|
6
|
-
readonly
|
|
6
|
+
readonly miniLabelActive: boolean;
|
|
7
7
|
readonly inputInvalid: boolean;
|
|
8
8
|
readonly label: string;
|
|
9
9
|
readonly styleOverride?: StyleProp<TextStyle>;
|
|
10
10
|
}
|
|
11
11
|
export declare const prefixLabelTestId = "ATL-InputFieldWrapper-PrefixLabel";
|
|
12
12
|
export declare const prefixIconTestId = "ATL-InputFieldWrapper-PrefixIcon";
|
|
13
|
-
export declare function PrefixLabel({ focused, disabled,
|
|
13
|
+
export declare function PrefixLabel({ focused, disabled, miniLabelActive, inputInvalid, label, styleOverride, }: PrefixLabelProps): JSX.Element;
|
|
14
14
|
export interface PrefixIconProps {
|
|
15
15
|
readonly focused: boolean;
|
|
16
16
|
readonly disabled?: boolean;
|
|
17
|
-
readonly hasMiniLabel: boolean;
|
|
18
17
|
readonly inputInvalid?: boolean;
|
|
19
18
|
readonly icon: IconNames;
|
|
20
19
|
readonly styleOverride?: StyleProp<ViewStyle>;
|
|
@@ -3,7 +3,7 @@ import type { IconNames } from "@jobber/design";
|
|
|
3
3
|
export interface SuffixLabelProps {
|
|
4
4
|
readonly focused: boolean;
|
|
5
5
|
readonly disabled?: boolean;
|
|
6
|
-
readonly
|
|
6
|
+
readonly miniLabelActive: boolean;
|
|
7
7
|
readonly inputInvalid?: boolean;
|
|
8
8
|
readonly label: string;
|
|
9
9
|
readonly hasLeftMargin?: boolean;
|
|
@@ -11,11 +11,10 @@ export interface SuffixLabelProps {
|
|
|
11
11
|
}
|
|
12
12
|
export declare const suffixLabelTestId = "ATL-InputFieldWrapper-SuffixLabel";
|
|
13
13
|
export declare const suffixIconTestId = "ATL-InputFieldWrapper-SuffixIcon";
|
|
14
|
-
export declare function SuffixLabel({ focused, disabled,
|
|
14
|
+
export declare function SuffixLabel({ focused, disabled, miniLabelActive, inputInvalid, label, hasLeftMargin, styleOverride, }: SuffixLabelProps): JSX.Element;
|
|
15
15
|
export interface SuffixIconProps {
|
|
16
16
|
readonly focused: boolean;
|
|
17
17
|
readonly disabled?: boolean;
|
|
18
|
-
readonly hasMiniLabel: boolean;
|
|
19
18
|
readonly inputInvalid?: boolean;
|
|
20
19
|
readonly icon: IconNames;
|
|
21
20
|
readonly hasLeftMargin?: boolean;
|
|
@@ -39,6 +39,14 @@ export interface InputPressableProps {
|
|
|
39
39
|
* Indicates the current selection is invalid
|
|
40
40
|
*/
|
|
41
41
|
readonly invalid?: boolean | string;
|
|
42
|
+
/**
|
|
43
|
+
* Controls the visibility of the mini label that appears inside the input
|
|
44
|
+
* when a value is entered. By default, the placeholder text moves up to
|
|
45
|
+
* become a mini label. Set to false to disable this behavior.
|
|
46
|
+
*
|
|
47
|
+
* @default true
|
|
48
|
+
*/
|
|
49
|
+
readonly showMiniLabel?: boolean;
|
|
42
50
|
/**
|
|
43
51
|
* Callback that is called when the text input is focused
|
|
44
52
|
* @param event
|
|
@@ -79,5 +87,5 @@ export interface InputPressableProps {
|
|
|
79
87
|
}
|
|
80
88
|
export type InputPressableRef = NativeText;
|
|
81
89
|
export declare const InputPressable: React.ForwardRefExoticComponent<InputPressableProps & React.RefAttributes<NativeText>>;
|
|
82
|
-
export declare function InputPressableInternal({ value, placeholder, disabled, invalid, error, onPress, accessibilityLabel, accessibilityHint, prefix, suffix, clearable, onClear, focused, }: InputPressableProps, ref: Ref<InputPressableRef>): JSX.Element;
|
|
90
|
+
export declare function InputPressableInternal({ value, placeholder, disabled, invalid, error, showMiniLabel, onPress, accessibilityLabel, accessibilityHint, prefix, suffix, clearable, onClear, focused, }: InputPressableProps, ref: Ref<InputPressableRef>): JSX.Element;
|
|
83
91
|
export {};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import type { InputTextProps, InputTextRef } from "../InputText";
|
|
3
3
|
export declare const InputSearch: React.ForwardRefExoticComponent<InputSearchProps & React.RefAttributes<InputTextRef>>;
|
|
4
|
-
export interface InputSearchProps extends Pick<InputTextProps, "accessibilityHint" | "accessibilityLabel" | "autoFocus" | "placeholder" | "prefix"> {
|
|
4
|
+
export interface InputSearchProps extends Pick<InputTextProps, "accessibilityHint" | "accessibilityLabel" | "autoFocus" | "placeholder" | "prefix" | "showMiniLabel"> {
|
|
5
5
|
/**
|
|
6
6
|
* A callback function that handles the update of the new value of the property value.
|
|
7
7
|
*/
|
|
@@ -31,6 +31,14 @@ export interface InputTextProps extends Pick<InputFieldWrapperProps, "toolbar" |
|
|
|
31
31
|
* Text that helps the user understand the input
|
|
32
32
|
*/
|
|
33
33
|
readonly assistiveText?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Controls the visibility of the mini label that appears inside the input
|
|
36
|
+
* when a value is entered. By default, the placeholder text moves up to
|
|
37
|
+
* become a mini label. Set to false to disable this behavior.
|
|
38
|
+
*
|
|
39
|
+
* @default true
|
|
40
|
+
*/
|
|
41
|
+
readonly showMiniLabel?: boolean;
|
|
34
42
|
/**
|
|
35
43
|
* Determines what keyboard is shown
|
|
36
44
|
*/
|
|
@@ -2,7 +2,8 @@ import type { UseControllerProps } from "react-hook-form";
|
|
|
2
2
|
import type { XOR } from "ts-xor";
|
|
3
3
|
import type { Clearable } from "@jobber/hooks";
|
|
4
4
|
import type { InputFieldWrapperProps } from "../InputFieldWrapper";
|
|
5
|
-
|
|
5
|
+
import type { InputPressableProps } from "../InputPressable/InputPressable";
|
|
6
|
+
interface InputTimeBaseProps extends Pick<InputFieldWrapperProps, "invalid" | "disabled" | "placeholder">, Pick<InputPressableProps, "showMiniLabel"> {
|
|
6
7
|
/**
|
|
7
8
|
* Defaulted to "always" so user can clear the time whenever there's a value.
|
|
8
9
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jobber/components-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.91.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "React Native implementation of Atlantis",
|
|
6
6
|
"repository": {
|
|
@@ -94,5 +94,5 @@
|
|
|
94
94
|
"react-native-safe-area-context": "^5.4.0",
|
|
95
95
|
"react-native-svg": ">=12.0.0"
|
|
96
96
|
},
|
|
97
|
-
"gitHead": "
|
|
97
|
+
"gitHead": "5f68cfee448c36f14f589edd6359c9cb30c8ab79"
|
|
98
98
|
}
|
package/src/Form/Form.tsx
CHANGED
|
@@ -174,6 +174,7 @@ function InternalForm<T extends FieldValues, S>({
|
|
|
174
174
|
ref={scrollViewRef}
|
|
175
175
|
{...keyboardProps}
|
|
176
176
|
extraHeight={headerHeight}
|
|
177
|
+
extraScrollHeight={edgeToEdgeEnabled ? tokens["space-large"] : 0}
|
|
177
178
|
contentContainerStyle={
|
|
178
179
|
!keyboardHeight && styles.scrollContentContainer
|
|
179
180
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import React from "react";
|
|
1
|
+
import React, { useMemo } from "react";
|
|
2
2
|
import { View } from "react-native";
|
|
3
|
-
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
4
3
|
import { useStyles } from "./FormBody.style";
|
|
5
4
|
import { useScreenInformation } from "../../hooks/useScreenInformation";
|
|
6
5
|
import type { FormActionBarProps } from "../FormActionBar";
|
|
@@ -26,12 +25,13 @@ export function FormBody({
|
|
|
26
25
|
setSaveButtonHeight,
|
|
27
26
|
saveButtonOffset,
|
|
28
27
|
}: FormBodyProps): JSX.Element {
|
|
29
|
-
const
|
|
28
|
+
const paddingBottom = useBottomPadding();
|
|
29
|
+
const fullViewPadding = useMemo(() => ({ paddingBottom }), [paddingBottom]);
|
|
30
30
|
const styles = useStyles();
|
|
31
31
|
|
|
32
32
|
return (
|
|
33
33
|
<>
|
|
34
|
-
<View style={styles.container}>
|
|
34
|
+
<View style={[styles.container]}>
|
|
35
35
|
{children}
|
|
36
36
|
{shouldRenderActionBar && (
|
|
37
37
|
<FormActionBar
|
|
@@ -47,9 +47,9 @@ export function FormBody({
|
|
|
47
47
|
)}
|
|
48
48
|
</View>
|
|
49
49
|
|
|
50
|
-
{!saveButtonOffset && (
|
|
50
|
+
{shouldRenderActionBar && !saveButtonOffset && (
|
|
51
51
|
<View
|
|
52
|
-
style={[
|
|
52
|
+
style={[fullViewPadding, styles.safeArea]}
|
|
53
53
|
testID="ATL-FormSafeArea"
|
|
54
54
|
/>
|
|
55
55
|
)}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { fireEvent, render, waitFor } from "@testing-library/react-native";
|
|
3
|
+
import { MediaView } from "./MediaView";
|
|
4
|
+
import type { FormattedFile } from "../../types";
|
|
5
|
+
import { StatusCode } from "../../types";
|
|
6
|
+
import { AtlantisFormatFileContext } from "../../context/FormatFileContext";
|
|
7
|
+
|
|
8
|
+
jest.mock("../../../hooks/useAtlantisI18n", () => ({
|
|
9
|
+
useAtlantisI18n: () => ({ t: (key: string) => key }),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
describe("MediaView", () => {
|
|
13
|
+
const mockFile: FormattedFile = {
|
|
14
|
+
showPreview: true,
|
|
15
|
+
source: "https://example.com/image1.jpg",
|
|
16
|
+
thumbnailUrl: undefined,
|
|
17
|
+
name: "test.jpg",
|
|
18
|
+
size: 1024,
|
|
19
|
+
external: false,
|
|
20
|
+
progress: 0,
|
|
21
|
+
status: StatusCode.Completed,
|
|
22
|
+
error: false,
|
|
23
|
+
type: "image/jpeg",
|
|
24
|
+
isMedia: true,
|
|
25
|
+
showFileTypeIndicator: false,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const defaultProps = {
|
|
29
|
+
accessibilityLabel: "Test image",
|
|
30
|
+
showOverlay: false,
|
|
31
|
+
showError: false,
|
|
32
|
+
file: mockFile,
|
|
33
|
+
styleInGrid: false,
|
|
34
|
+
onUploadComplete: jest.fn(),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const mockContextValue = {
|
|
38
|
+
useCreateThumbnail: () => ({ thumbnail: undefined, error: false }),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const renderWithContext = (props = defaultProps) => {
|
|
42
|
+
return render(
|
|
43
|
+
<AtlantisFormatFileContext.Provider value={mockContextValue}>
|
|
44
|
+
<MediaView {...props} />
|
|
45
|
+
</AtlantisFormatFileContext.Provider>,
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
describe("Normal loading flow", () => {
|
|
50
|
+
it("shows loading indicator when onLoadStart fires", () => {
|
|
51
|
+
const { getByTestId, queryByTestId } = renderWithContext();
|
|
52
|
+
const image = getByTestId("test-image");
|
|
53
|
+
|
|
54
|
+
expect(queryByTestId("ActivityIndicator")).toBeNull();
|
|
55
|
+
|
|
56
|
+
fireEvent(image, "loadStart");
|
|
57
|
+
|
|
58
|
+
expect(queryByTestId("ActivityIndicator")).toBeTruthy();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("hides loading indicator when onLoadEnd fires", () => {
|
|
62
|
+
const { getByTestId, queryByTestId } = renderWithContext();
|
|
63
|
+
const image = getByTestId("test-image");
|
|
64
|
+
|
|
65
|
+
fireEvent(image, "loadStart");
|
|
66
|
+
expect(queryByTestId("ActivityIndicator")).toBeTruthy();
|
|
67
|
+
|
|
68
|
+
fireEvent(image, "loadEnd");
|
|
69
|
+
|
|
70
|
+
expect(queryByTestId("ActivityIndicator")).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("Race condition handling (cached images)", () => {
|
|
75
|
+
it("does not get stuck loading when onLoadEnd fires before onLoadStart", () => {
|
|
76
|
+
const { getByTestId, queryByTestId } = renderWithContext();
|
|
77
|
+
const image = getByTestId("test-image");
|
|
78
|
+
|
|
79
|
+
// Simulate cached image: LoadEnd fires BEFORE LoadStart
|
|
80
|
+
fireEvent(image, "loadEnd");
|
|
81
|
+
expect(queryByTestId("ActivityIndicator")).toBeNull();
|
|
82
|
+
|
|
83
|
+
fireEvent(image, "loadStart");
|
|
84
|
+
|
|
85
|
+
expect(queryByTestId("ActivityIndicator")).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("does not show infinite spinner when load events fire out of order", () => {
|
|
89
|
+
const { getByTestId, queryByTestId } = renderWithContext();
|
|
90
|
+
const image = getByTestId("test-image");
|
|
91
|
+
|
|
92
|
+
// Race condition scenario
|
|
93
|
+
fireEvent(image, "loadEnd");
|
|
94
|
+
fireEvent(image, "loadStart");
|
|
95
|
+
fireEvent(image, "loadEnd");
|
|
96
|
+
|
|
97
|
+
expect(queryByTestId("ActivityIndicator")).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("URI changes", () => {
|
|
102
|
+
it("shows loading indicator when URI changes to a new image", async () => {
|
|
103
|
+
const { getByTestId, queryByTestId, rerender } = renderWithContext();
|
|
104
|
+
const image = getByTestId("test-image");
|
|
105
|
+
|
|
106
|
+
// First image: simulate cached load (LoadEnd before LoadStart)
|
|
107
|
+
fireEvent(image, "loadEnd");
|
|
108
|
+
fireEvent(image, "loadStart");
|
|
109
|
+
expect(queryByTestId("ActivityIndicator")).toBeNull();
|
|
110
|
+
|
|
111
|
+
// Change URI to a new image
|
|
112
|
+
const newFile = {
|
|
113
|
+
...mockFile,
|
|
114
|
+
source: "https://example.com/image2.jpg",
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
rerender(
|
|
118
|
+
<AtlantisFormatFileContext.Provider value={mockContextValue}>
|
|
119
|
+
<MediaView {...defaultProps} file={newFile} />
|
|
120
|
+
</AtlantisFormatFileContext.Provider>,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
await waitFor(() => {
|
|
124
|
+
const updatedImage = getByTestId("test-image");
|
|
125
|
+
|
|
126
|
+
// New image starts loading
|
|
127
|
+
fireEvent(updatedImage, "loadStart");
|
|
128
|
+
|
|
129
|
+
expect(queryByTestId("ActivityIndicator")).toBeTruthy();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("shows loading indicator on second URI change", async () => {
|
|
134
|
+
const { getByTestId, queryByTestId, rerender } = renderWithContext();
|
|
135
|
+
|
|
136
|
+
const image1 = getByTestId("test-image");
|
|
137
|
+
fireEvent(image1, "loadStart");
|
|
138
|
+
fireEvent(image1, "loadEnd");
|
|
139
|
+
expect(queryByTestId("ActivityIndicator")).toBeNull();
|
|
140
|
+
|
|
141
|
+
const file2 = { ...mockFile, source: "https://example.com/image2.jpg" };
|
|
142
|
+
rerender(
|
|
143
|
+
<AtlantisFormatFileContext.Provider value={mockContextValue}>
|
|
144
|
+
<MediaView {...defaultProps} file={file2} />
|
|
145
|
+
</AtlantisFormatFileContext.Provider>,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
await waitFor(() => {
|
|
149
|
+
const image2 = getByTestId("test-image");
|
|
150
|
+
fireEvent(image2, "loadStart");
|
|
151
|
+
|
|
152
|
+
expect(queryByTestId("ActivityIndicator")).toBeTruthy();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("shows loading indicator on third URI change", async () => {
|
|
157
|
+
const { getByTestId, queryByTestId, rerender } = renderWithContext();
|
|
158
|
+
|
|
159
|
+
const image1 = getByTestId("test-image");
|
|
160
|
+
fireEvent(image1, "loadStart");
|
|
161
|
+
fireEvent(image1, "loadEnd");
|
|
162
|
+
|
|
163
|
+
const file2 = { ...mockFile, source: "https://example.com/image2.jpg" };
|
|
164
|
+
rerender(
|
|
165
|
+
<AtlantisFormatFileContext.Provider value={mockContextValue}>
|
|
166
|
+
<MediaView {...defaultProps} file={file2} />
|
|
167
|
+
</AtlantisFormatFileContext.Provider>,
|
|
168
|
+
);
|
|
169
|
+
const image2 = getByTestId("test-image");
|
|
170
|
+
fireEvent(image2, "loadEnd");
|
|
171
|
+
|
|
172
|
+
const file3 = { ...mockFile, source: "https://example.com/image3.jpg" };
|
|
173
|
+
rerender(
|
|
174
|
+
<AtlantisFormatFileContext.Provider value={mockContextValue}>
|
|
175
|
+
<MediaView {...defaultProps} file={file3} />
|
|
176
|
+
</AtlantisFormatFileContext.Provider>,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
await waitFor(() => {
|
|
180
|
+
const image3 = getByTestId("test-image");
|
|
181
|
+
fireEvent(image3, "loadStart");
|
|
182
|
+
|
|
183
|
+
expect(queryByTestId("ActivityIndicator")).toBeTruthy();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("handles URI change from cached to uncached image correctly", async () => {
|
|
188
|
+
const { getByTestId, queryByTestId, rerender } = renderWithContext();
|
|
189
|
+
|
|
190
|
+
// First image: cached (LoadEnd before LoadStart)
|
|
191
|
+
const image1 = getByTestId("test-image");
|
|
192
|
+
fireEvent(image1, "loadEnd");
|
|
193
|
+
fireEvent(image1, "loadStart");
|
|
194
|
+
expect(queryByTestId("ActivityIndicator")).toBeNull();
|
|
195
|
+
|
|
196
|
+
// Second image: uncached (normal LoadStart then LoadEnd)
|
|
197
|
+
const file2 = { ...mockFile, source: "https://example.com/image2.jpg" };
|
|
198
|
+
rerender(
|
|
199
|
+
<AtlantisFormatFileContext.Provider value={mockContextValue}>
|
|
200
|
+
<MediaView {...defaultProps} file={file2} />
|
|
201
|
+
</AtlantisFormatFileContext.Provider>,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
await waitFor(() => {
|
|
205
|
+
const image2 = getByTestId("test-image");
|
|
206
|
+
fireEvent(image2, "loadStart");
|
|
207
|
+
|
|
208
|
+
expect(queryByTestId("ActivityIndicator")).toBeTruthy();
|
|
209
|
+
|
|
210
|
+
fireEvent(image2, "loadEnd");
|
|
211
|
+
expect(queryByTestId("ActivityIndicator")).toBeNull();
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("thumbnailUrl changes", () => {
|
|
217
|
+
it("shows loading indicator when thumbnailUrl changes", async () => {
|
|
218
|
+
const { getByTestId, queryByTestId, rerender } = renderWithContext();
|
|
219
|
+
|
|
220
|
+
// First load completes
|
|
221
|
+
const image1 = getByTestId("test-image");
|
|
222
|
+
fireEvent(image1, "loadStart");
|
|
223
|
+
fireEvent(image1, "loadEnd");
|
|
224
|
+
expect(queryByTestId("ActivityIndicator")).toBeNull();
|
|
225
|
+
|
|
226
|
+
// Thumbnail URL changes (common when thumbnail generation completes)
|
|
227
|
+
const fileWithThumbnail = {
|
|
228
|
+
...mockFile,
|
|
229
|
+
thumbnailUrl: "https://example.com/thumbnail.jpg",
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
rerender(
|
|
233
|
+
<AtlantisFormatFileContext.Provider value={mockContextValue}>
|
|
234
|
+
<MediaView {...defaultProps} file={fileWithThumbnail} />
|
|
235
|
+
</AtlantisFormatFileContext.Provider>,
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
await waitFor(() => {
|
|
239
|
+
const image2 = getByTestId("test-image");
|
|
240
|
+
fireEvent(image2, "loadStart");
|
|
241
|
+
|
|
242
|
+
expect(queryByTestId("ActivityIndicator")).toBeTruthy();
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("Context thumbnail changes", () => {
|
|
248
|
+
it("shows loading indicator when context thumbnail changes", async () => {
|
|
249
|
+
const mockContextWithThumbnail = {
|
|
250
|
+
useCreateThumbnail: () => ({
|
|
251
|
+
thumbnail: "https://example.com/context-thumbnail.jpg",
|
|
252
|
+
error: false,
|
|
253
|
+
}),
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const { getByTestId, queryByTestId, rerender } = render(
|
|
257
|
+
<AtlantisFormatFileContext.Provider value={mockContextValue}>
|
|
258
|
+
<MediaView {...defaultProps} />
|
|
259
|
+
</AtlantisFormatFileContext.Provider>,
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
// First load completes
|
|
263
|
+
const image1 = getByTestId("test-image");
|
|
264
|
+
fireEvent(image1, "loadStart");
|
|
265
|
+
fireEvent(image1, "loadEnd");
|
|
266
|
+
expect(queryByTestId("ActivityIndicator")).toBeNull();
|
|
267
|
+
|
|
268
|
+
// Context provides a new thumbnail
|
|
269
|
+
rerender(
|
|
270
|
+
<AtlantisFormatFileContext.Provider value={mockContextWithThumbnail}>
|
|
271
|
+
<MediaView {...defaultProps} />
|
|
272
|
+
</AtlantisFormatFileContext.Provider>,
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
await waitFor(() => {
|
|
276
|
+
const image2 = getByTestId("test-image");
|
|
277
|
+
fireEvent(image2, "loadStart");
|
|
278
|
+
|
|
279
|
+
expect(queryByTestId("ActivityIndicator")).toBeTruthy();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState } from "react";
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
2
|
import { ImageBackground, View } from "react-native";
|
|
3
3
|
import { useStyles } from "./MediaView.style";
|
|
4
4
|
import type { FormattedFile } from "../../types";
|
|
@@ -32,6 +32,12 @@ export function MediaView({
|
|
|
32
32
|
const { useCreateThumbnail } = useAtlantisFormatFileContext();
|
|
33
33
|
const { thumbnail, error } = useCreateThumbnail(file);
|
|
34
34
|
const [isLoading, setIsLoading] = useState(false);
|
|
35
|
+
/**
|
|
36
|
+
* Tracks whether onLoadEnd has fired to prevent race conditions.
|
|
37
|
+
* ImageBackground can fire onLoadEnd before onLoadStart when loading cached images,
|
|
38
|
+
* which would cause isLoading to get stuck at true, showing an infinite spinner.
|
|
39
|
+
*/
|
|
40
|
+
const hasLoadedRef = useRef(false);
|
|
35
41
|
|
|
36
42
|
const a11yLabel = computeA11yLabel({
|
|
37
43
|
accessibilityLabel,
|
|
@@ -40,10 +46,25 @@ export function MediaView({
|
|
|
40
46
|
t,
|
|
41
47
|
});
|
|
42
48
|
|
|
43
|
-
const hasError = showError || error
|
|
44
|
-
|
|
49
|
+
const hasError = showError || error,
|
|
50
|
+
uri = thumbnail || file.thumbnailUrl || file.source,
|
|
51
|
+
styles = useStyles();
|
|
45
52
|
|
|
46
|
-
const
|
|
53
|
+
const handleLoadStart = () => {
|
|
54
|
+
if (!hasLoadedRef.current) {
|
|
55
|
+
setIsLoading(true);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const handleLoadEnd = () => {
|
|
60
|
+
hasLoadedRef.current = true;
|
|
61
|
+
setIsLoading(false);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
hasLoadedRef.current = false;
|
|
66
|
+
setIsLoading(false);
|
|
67
|
+
}, [uri]);
|
|
47
68
|
|
|
48
69
|
return (
|
|
49
70
|
<View accessible={true} accessibilityLabel={a11yLabel}>
|
|
@@ -55,8 +76,8 @@ export function MediaView({
|
|
|
55
76
|
resizeMode={styleInGrid ? "cover" : "contain"}
|
|
56
77
|
source={{ uri }}
|
|
57
78
|
testID={"test-image"}
|
|
58
|
-
onLoadStart={
|
|
59
|
-
onLoadEnd={
|
|
79
|
+
onLoadStart={handleLoadStart}
|
|
80
|
+
onLoadEnd={handleLoadEnd}
|
|
60
81
|
>
|
|
61
82
|
<Overlay
|
|
62
83
|
isLoading={isLoading}
|
|
@@ -8,9 +8,11 @@ import type { InputFieldWrapperProps } from "../InputFieldWrapper";
|
|
|
8
8
|
import { FormField } from "../FormField";
|
|
9
9
|
import { InputPressable } from "../InputPressable";
|
|
10
10
|
import { useAtlantisI18n } from "../hooks/useAtlantisI18n";
|
|
11
|
+
import type { InputPressableProps } from "../InputPressable/InputPressable";
|
|
11
12
|
|
|
12
13
|
interface BaseInputDateProps
|
|
13
|
-
extends Pick<InputFieldWrapperProps, "invalid" | "disabled" | "placeholder"
|
|
14
|
+
extends Pick<InputFieldWrapperProps, "invalid" | "disabled" | "placeholder">,
|
|
15
|
+
Pick<InputPressableProps, "showMiniLabel"> {
|
|
14
16
|
/**
|
|
15
17
|
* Defaulted to "always" so user can clear the dates whenever there's a value.
|
|
16
18
|
*/
|
|
@@ -145,6 +147,7 @@ function InternalInputDate({
|
|
|
145
147
|
minDate,
|
|
146
148
|
placeholder,
|
|
147
149
|
value,
|
|
150
|
+
showMiniLabel = true,
|
|
148
151
|
name,
|
|
149
152
|
onChange,
|
|
150
153
|
accessibilityLabel,
|
|
@@ -174,6 +177,7 @@ function InternalInputDate({
|
|
|
174
177
|
return (
|
|
175
178
|
<>
|
|
176
179
|
<InputPressable
|
|
180
|
+
showMiniLabel={showMiniLabel}
|
|
177
181
|
focused={showPicker}
|
|
178
182
|
clearable={canClearDate}
|
|
179
183
|
disabled={disabled}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import type { RenderAPI } from "@testing-library/react-native";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
fireEvent,
|
|
5
|
+
render,
|
|
6
|
+
renderHook,
|
|
7
|
+
screen,
|
|
8
|
+
} from "@testing-library/react-native";
|
|
4
9
|
import type { ViewStyle } from "react-native";
|
|
5
10
|
import { Text } from "react-native";
|
|
6
11
|
import type { InputFieldWrapperProps } from ".";
|
|
@@ -311,4 +316,46 @@ describe("InputFieldWrapper", () => {
|
|
|
311
316
|
expect(queryByTestId(INPUT_FIELD_WRAPPER_SPINNER_TEST_ID)).toBeNull();
|
|
312
317
|
});
|
|
313
318
|
});
|
|
319
|
+
|
|
320
|
+
describe("placeholderMode", () => {
|
|
321
|
+
it("renders the placeholder in its normal position", () => {
|
|
322
|
+
renderInputFieldWrapper({
|
|
323
|
+
placeholder: "placeholder",
|
|
324
|
+
placeholderMode: "normal",
|
|
325
|
+
});
|
|
326
|
+
const placeholder = screen.getByText("placeholder", {
|
|
327
|
+
includeHiddenElements: true,
|
|
328
|
+
});
|
|
329
|
+
expect(placeholder).toBeDefined();
|
|
330
|
+
expect(placeholder.props.style).toContainEqual(
|
|
331
|
+
typographyStyles.defaultSize,
|
|
332
|
+
);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("renders the placeholder in its mini label position", () => {
|
|
336
|
+
renderInputFieldWrapper({
|
|
337
|
+
placeholder: "placeholder",
|
|
338
|
+
placeholderMode: "mini",
|
|
339
|
+
});
|
|
340
|
+
const placeholder = screen.getByText("placeholder", {
|
|
341
|
+
includeHiddenElements: true,
|
|
342
|
+
});
|
|
343
|
+
expect(placeholder).toBeDefined();
|
|
344
|
+
expect(placeholder.props.style).toContainEqual(
|
|
345
|
+
typographyStyles.smallSize,
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("does not render the placeholder", () => {
|
|
350
|
+
renderInputFieldWrapper({
|
|
351
|
+
placeholder: "placeholder",
|
|
352
|
+
placeholderMode: "hidden",
|
|
353
|
+
});
|
|
354
|
+
expect(
|
|
355
|
+
screen.queryByText("placeholder", {
|
|
356
|
+
includeHiddenElements: true,
|
|
357
|
+
}),
|
|
358
|
+
).toBeNull();
|
|
359
|
+
});
|
|
360
|
+
});
|
|
314
361
|
});
|