Skip to content

Commit bbb7916

Browse files
committed
feat: add EditImageFormField component
1 parent d945dd8 commit bbb7916

File tree

6 files changed

+224
-0
lines changed

6 files changed

+224
-0
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
.edit-image-form-field {
2+
&__figure-container {
3+
grid-template-columns: repeat(12, 1fr);
4+
grid-template-rows: repeat(12, 1fr);
5+
6+
.base-image {
7+
grid-column: 1 / span 12;
8+
grid-row: 1 / span 12;
9+
}
10+
11+
.edit-image-icon {
12+
grid-column: 8 / span 4;
13+
grid-row: 8 / span 4;
14+
}
15+
}
16+
17+
&__fallback-image {
18+
background: rgba(57, 137, 217, 0.20);
19+
}
20+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { SyntheticEvent, useState } from 'react'
2+
import { showError } from '@Common/Helper'
3+
import { CustomInput } from '@Common/CustomInput'
4+
import { ButtonWithLoader, ImageWithFallback } from '@Shared/Components'
5+
import { validateIfImageExist, validateURL } from '@Shared/validations'
6+
import { ReactComponent as ICPencil } from '@Icons/ic-pencil.svg'
7+
import { EditImageFormFieldProps, FallbackImageProps } from './types'
8+
import { DEFAULT_IMAGE_DIMENSIONS, DEFAULT_MAX_IMAGE_SIZE } from './constants'
9+
import './EditImageFormField.scss'
10+
11+
// NOTE: If want to make image dimensions configurable please change its dimensions as well for icon-dim-48.
12+
const FallbackImage = ({ showEditIcon, defaultIcon }: FallbackImageProps) => (
13+
<div
14+
className={`flex dc__align-self-start dc__no-shrink br-4 edit-image-form-field__fallback-image icon-dim-48 ${showEditIcon ? 'base-image' : ''}`}
15+
>
16+
{defaultIcon}
17+
</div>
18+
)
19+
20+
// NOTE: Have to replace component in UpsertTenantModal with EditImageFormField when prioritized.
21+
const EditImageFormField = ({
22+
defaultIcon,
23+
errorMessage,
24+
handleError,
25+
url,
26+
handleURLChange,
27+
ariaLabelPrefix,
28+
dataTestIdPrefix,
29+
altText,
30+
}: EditImageFormFieldProps) => {
31+
const [lastPreviewedURL, setLastPreviewedURL] = useState<string>(url)
32+
const [isEditing, setIsEditing] = useState<boolean>(false)
33+
const [isLoading, setIsLoading] = useState<boolean>(false)
34+
35+
const handleEnableEditing = () => {
36+
setIsEditing(true)
37+
}
38+
39+
const handleLastPreviewedURLChange = (newURL: string) => {
40+
setLastPreviewedURL(newURL)
41+
}
42+
43+
const handleReset = (newURL?: string) => {
44+
handleLastPreviewedURLChange(newURL ?? lastPreviewedURL)
45+
handleError('')
46+
setIsEditing(false)
47+
setIsLoading(false)
48+
}
49+
50+
const handleSuccess = () => {
51+
handleReset(url)
52+
}
53+
54+
const handleCancel = () => {
55+
handleReset()
56+
}
57+
58+
const handleChange = (event: SyntheticEvent) => {
59+
const { value } = event.target as HTMLInputElement
60+
handleURLChange(value)
61+
handleError(validateURL(value, false).message)
62+
}
63+
64+
const handlePreviewImage = async () => {
65+
setIsLoading(true)
66+
try {
67+
const response = await fetch(url, { mode: 'cors' })
68+
if (!response.ok) {
69+
throw new Error('Invalid network response')
70+
}
71+
72+
const blob = await response.blob()
73+
if (blob.size > DEFAULT_MAX_IMAGE_SIZE) {
74+
throw new Error(`Please add an image smaller than ${DEFAULT_MAX_IMAGE_SIZE}`)
75+
}
76+
77+
const src = URL.createObjectURL(blob)
78+
const imageValidation = await validateIfImageExist(src)
79+
URL.revokeObjectURL(src)
80+
81+
if (!imageValidation.isValid) {
82+
throw new Error(imageValidation.message)
83+
}
84+
85+
handleSuccess()
86+
} catch (error) {
87+
handleError(error.message || 'Failed to load image')
88+
showError(error)
89+
} finally {
90+
setIsLoading(false)
91+
}
92+
}
93+
94+
const handleKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => {
95+
if (event.key === 'Enter') {
96+
await handlePreviewImage()
97+
}
98+
}
99+
100+
const renderImage = (showEditIcon: boolean, rootClassName?: string) => (
101+
<ImageWithFallback
102+
imageProps={{
103+
alt: altText,
104+
src: lastPreviewedURL,
105+
height: DEFAULT_IMAGE_DIMENSIONS.height,
106+
width: DEFAULT_IMAGE_DIMENSIONS.width,
107+
className: `br-4 dc__no-shrink ${rootClassName || ''}`,
108+
}}
109+
fallbackImage={<FallbackImage defaultIcon={defaultIcon} showEditIcon={showEditIcon} />}
110+
/>
111+
)
112+
113+
if (!isEditing) {
114+
return (
115+
<button
116+
className="dc__no-background dc__no-border dc__outline-none-imp display-grid edit-image-form-field__figure-container p-0 icon-dim-72 dc__no-shrink"
117+
type="button"
118+
data-testid={`${dataTestIdPrefix}-button`}
119+
aria-label={`${ariaLabelPrefix} image`}
120+
onClick={handleEnableEditing}
121+
>
122+
{renderImage(true, 'base-image')}
123+
124+
<div className="flex p-4 br-4 bcn-0 dc__border edit-image-icon dc__zi-1 bcn-0 dc__hover-n50 icon-dim-24">
125+
<ICPencil className="dc__no-shrink icon-dim-16" />
126+
</div>
127+
</button>
128+
)
129+
}
130+
131+
return (
132+
<div className="flexbox dc__gap-20">
133+
{renderImage(false)}
134+
135+
<div className="flexbox-col dc__gap-16 flex-grow-1">
136+
<div className="flexbox-col dc__gap-6 w-100 dc__align-start">
137+
<CustomInput
138+
name={`${ariaLabelPrefix} url input`}
139+
label="Image URL"
140+
labelClassName="m-0 dc__required-field fs-13 fw-4 lh-20 cn-7"
141+
placeholder="Enter image url"
142+
value={url}
143+
onChange={handleChange}
144+
error={errorMessage}
145+
inputWrapClassName="w-100"
146+
dataTestid={`${dataTestIdPrefix}-input`}
147+
onKeyDown={handleKeyDown}
148+
/>
149+
</div>
150+
151+
<div className="flexbox dc__gap-8">
152+
<ButtonWithLoader
153+
isLoading={isLoading}
154+
rootClassName="cta h-28 flex"
155+
type="button"
156+
disabled={isLoading}
157+
data-testid={`${dataTestIdPrefix}-preview`}
158+
onClick={handlePreviewImage}
159+
>
160+
Preview
161+
</ButtonWithLoader>
162+
163+
<button
164+
className="cta secondary h-28 flex"
165+
data-testid={`${dataTestIdPrefix}-cancel`}
166+
type="button"
167+
disabled={isLoading}
168+
onClick={handleCancel}
169+
>
170+
Cancel
171+
</button>
172+
</div>
173+
</div>
174+
</div>
175+
)
176+
}
177+
178+
export default EditImageFormField
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const DEFAULT_MAX_IMAGE_SIZE = 2 * 1024 * 1024
2+
export const DEFAULT_IMAGE_DIMENSIONS = {
3+
width: 72,
4+
height: 72,
5+
} as const
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as EditImageFormField } from './EditImageFormField'
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { ReactNode } from 'react'
2+
3+
export interface EditImageFormFieldProps {
4+
defaultIcon: ReactNode
5+
errorMessage: string
6+
handleError: (error: string) => void
7+
url: string
8+
handleURLChange: (url: string) => void
9+
altText: string
10+
ariaLabelPrefix: string
11+
dataTestIdPrefix: string
12+
}
13+
14+
export interface FallbackImageProps extends Pick<EditImageFormFieldProps, 'defaultIcon'> {
15+
/**
16+
* @default - false
17+
*/
18+
showEditIcon?: boolean
19+
}

src/Shared/Components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,4 @@ export * from './IframeContainer'
4949
export * from './Plugin'
5050
export * from './KeyValueTable'
5151
export * from './SelectPicker'
52+
export * from './EditImageFormField'

0 commit comments

Comments
 (0)