Skip to content

Commit bb263f8

Browse files
BillLeoutsakosvl346Bill Leoutsakos
andauthored
refactor(ui): share image upload preview tiles (#8015)
Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
1 parent 14994dc commit bb263f8

5 files changed

Lines changed: 152 additions & 37 deletions

File tree

apps/sim/ee/custom-blocks/components/custom-block-detail.tsx

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ import {
1414
Expandable,
1515
ExpandableContent,
1616
Label,
17-
Loader,
1817
Switch,
1918
toast,
19+
UploadPreviewButton,
2020
} from '@sim/emcn'
21-
import { ArrowLeft, ChevronDown, ImageUp as ImageIcon, X } from '@sim/emcn/icons'
21+
import { ArrowLeft, ChevronDown, X } from '@sim/emcn/icons'
2222
import { getErrorMessage } from '@sim/utils/errors'
2323
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
2424
import {
@@ -522,21 +522,16 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
522522
<SettingRow label='Icon' labelTooltip='Square image (PNG, JPEG, or SVG). Optional.'>
523523
<div className='flex items-center gap-4'>
524524
<DropZone onDrop={canManageBlock ? iconUpload.handleFileDrop : () => {}}>
525-
<button
525+
<UploadPreviewButton
526526
aria-label={iconUrl ? 'Change icon' : 'Upload icon'}
527-
type='button'
528527
onClick={iconUpload.handleThumbnailClick}
529-
disabled={iconUpload.isUploading || !canManageBlock}
530-
className='group relative flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-2)] transition-colors hover:bg-[var(--surface-3)] disabled:opacity-50'
528+
loading={iconUpload.isUploading}
529+
disabled={!canManageBlock}
531530
>
532-
{iconUpload.isUploading ? (
533-
<Loader className='size-5 text-[var(--text-muted)]' animate />
534-
) : iconUrl ? (
531+
{iconUrl ? (
535532
<img src={iconUrl} alt='' className='size-full object-contain p-1.5' />
536-
) : (
537-
<ImageIcon className='size-5 text-[var(--text-muted)]' />
538-
)}
539-
</button>
533+
) : null}
534+
</UploadPreviewButton>
540535
</DropZone>
541536
<div className='flex gap-2'>
542537
<Button

apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
'use client'
22

33
import { useState } from 'react'
4-
import { Button, ChipInput, cn, Label, Loader, toast } from '@sim/emcn'
5-
import { ImageUp as ImageIcon, X } from '@sim/emcn/icons'
4+
import { Button, ChipInput, cn, Label, toast, UploadPreviewButton } from '@sim/emcn'
5+
import { X } from '@sim/emcn/icons'
66
import { createLogger } from '@sim/logger'
77
import { getErrorMessage, toError } from '@sim/utils/errors'
88
import Image from 'next/image'
@@ -288,17 +288,13 @@ function WhitelabelingForm({ initialSettings, orgId, uploadWorkspaceId }: Whitel
288288
>
289289
<div className='flex items-center gap-4'>
290290
<DropZone onDrop={logoUpload.handleFileDrop}>
291-
<button
292-
type='button'
291+
<UploadPreviewButton
293292
onClick={logoUpload.handleThumbnailClick}
294-
disabled={logoUpload.isUploading}
293+
loading={logoUpload.isUploading}
295294
aria-label={logoUpload.previewUrl ? 'Change logo' : 'Upload logo'}
296295
title={logoUpload.previewUrl ? 'Change logo' : 'Upload logo'}
297-
className='group relative flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-1)] bg-[var(--surface-2)] transition-colors hover:bg-[var(--surface-3)] disabled:opacity-50'
298296
>
299-
{logoUpload.isUploading ? (
300-
<Loader className='size-5 text-[var(--text-muted)]' animate />
301-
) : logoUpload.previewUrl ? (
297+
{logoUpload.previewUrl ? (
302298
<Image
303299
src={logoUpload.previewUrl}
304300
alt='Logo'
@@ -307,10 +303,8 @@ function WhitelabelingForm({ initialSettings, orgId, uploadWorkspaceId }: Whitel
307303
className='object-contain p-1'
308304
unoptimized
309305
/>
310-
) : (
311-
<ImageIcon className='size-5 text-[var(--text-muted)]' />
312-
)}
313-
</button>
306+
) : null}
307+
</UploadPreviewButton>
314308
</DropZone>
315309
{logoUpload.previewUrl && (
316310
<Button
@@ -339,17 +333,14 @@ function WhitelabelingForm({ initialSettings, orgId, uploadWorkspaceId }: Whitel
339333
>
340334
<div className='flex items-center gap-4'>
341335
<DropZone onDrop={wordmarkUpload.handleFileDrop} className='min-w-0 flex-1'>
342-
<button
343-
type='button'
336+
<UploadPreviewButton
344337
onClick={wordmarkUpload.handleThumbnailClick}
345-
disabled={wordmarkUpload.isUploading}
338+
loading={wordmarkUpload.isUploading}
346339
aria-label={wordmarkUpload.previewUrl ? 'Change wordmark' : 'Upload wordmark'}
347340
title={wordmarkUpload.previewUrl ? 'Change wordmark' : 'Upload wordmark'}
348-
className='group relative flex h-16 w-full items-center justify-center overflow-hidden rounded-xl border border-[var(--border-1)] bg-[var(--surface-2)] transition-colors hover:bg-[var(--surface-3)] disabled:opacity-50'
341+
className='w-full'
349342
>
350-
{wordmarkUpload.isUploading ? (
351-
<Loader className='size-5 text-[var(--text-muted)]' animate />
352-
) : wordmarkUpload.previewUrl ? (
343+
{wordmarkUpload.previewUrl ? (
353344
<Image
354345
src={wordmarkUpload.previewUrl}
355346
alt='Wordmark'
@@ -358,10 +349,8 @@ function WhitelabelingForm({ initialSettings, orgId, uploadWorkspaceId }: Whitel
358349
className='object-contain p-2'
359350
unoptimized
360351
/>
361-
) : (
362-
<ImageIcon className='size-5 text-[var(--text-muted)]' />
363-
)}
364-
</button>
352+
) : null}
353+
</UploadPreviewButton>
365354
</DropZone>
366355
{wordmarkUpload.previewUrl && (
367356
<Button

packages/emcn/src/components/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,4 +265,8 @@ export {
265265
useFloatingTooltip,
266266
useIsOverflowing,
267267
} from './tooltip/tooltip'
268+
export {
269+
UploadPreviewButton,
270+
type UploadPreviewButtonProps,
271+
} from './upload-preview-button/upload-preview-button'
268272
export { Wizard } from './wizard/wizard'
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/** @vitest-environment jsdom */
2+
import { act, createRef, type ReactNode } from 'react'
3+
import { UploadPreviewButton } from '@sim/emcn'
4+
import { createRoot, type Root } from 'react-dom/client'
5+
import { afterEach, describe, expect, it, vi } from 'vitest'
6+
7+
let root: Root | null = null
8+
let container: HTMLDivElement | null = null
9+
10+
function mount(children: ReactNode) {
11+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
12+
container = document.createElement('div')
13+
document.body.appendChild(container)
14+
root = createRoot(container)
15+
act(() => root?.render(children))
16+
}
17+
18+
afterEach(() => {
19+
if (root) act(() => root?.unmount())
20+
container?.remove()
21+
root = null
22+
container = null
23+
})
24+
25+
describe('UploadPreviewButton', () => {
26+
it('forwards the label, ref and upload action without submitting an enclosing form', () => {
27+
const ref = createRef<HTMLButtonElement>()
28+
const onClick = vi.fn()
29+
const onSubmit = vi.fn((event) => event.preventDefault())
30+
mount(
31+
<form onSubmit={onSubmit}>
32+
<UploadPreviewButton ref={ref} aria-label='Upload logo' onClick={onClick} />
33+
</form>
34+
)
35+
36+
const button = ref.current!
37+
expect(button).toBe(container?.querySelector('button'))
38+
expect(button.getAttribute('aria-label')).toBe('Upload logo')
39+
expect(button.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true')
40+
act(() => button.click())
41+
expect(onClick).toHaveBeenCalledTimes(1)
42+
expect(onSubmit).not.toHaveBeenCalled()
43+
})
44+
45+
it('replaces the preview while busy and preserves a separate disabled restriction', () => {
46+
const ref = createRef<HTMLButtonElement>()
47+
const onClick = vi.fn()
48+
const render = (loading: boolean, disabled = false) => (
49+
<UploadPreviewButton
50+
ref={ref}
51+
aria-label='Change logo'
52+
loading={loading}
53+
disabled={disabled}
54+
onClick={onClick}
55+
>
56+
<img src='/logo.svg' alt='Logo' />
57+
</UploadPreviewButton>
58+
)
59+
mount(render(false))
60+
const button = ref.current!
61+
expect(button.querySelector('img')?.alt).toBe('Logo')
62+
63+
act(() => root?.render(render(true)))
64+
expect(button.disabled).toBe(true)
65+
expect(button.getAttribute('aria-busy')).toBe('true')
66+
expect(button.querySelector('img')).toBeNull()
67+
act(() => button.click())
68+
expect(onClick).not.toHaveBeenCalled()
69+
70+
act(() => root?.render(render(false, true)))
71+
expect(button.disabled).toBe(true)
72+
expect(button.hasAttribute('aria-busy')).toBe(false)
73+
expect(button.querySelector('img')?.alt).toBe('Logo')
74+
act(() => button.click())
75+
expect(onClick).not.toHaveBeenCalled()
76+
77+
act(() => root?.render(render(false)))
78+
expect(button.disabled).toBe(false)
79+
act(() => button.click())
80+
expect(onClick).toHaveBeenCalledTimes(1)
81+
})
82+
})
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { type ComponentPropsWithoutRef, forwardRef } from 'react'
2+
import { ImageUp } from '../../icons/image-up'
3+
import { Loader } from '../../icons/loader'
4+
import { cn } from '../../lib/cn'
5+
6+
export interface UploadPreviewButtonProps extends Omit<ComponentPropsWithoutRef<'button'>, 'type'> {
7+
/** Accessible name describing which image will be uploaded or replaced. */
8+
'aria-label': string
9+
/** Shows the shared loader and prevents another activation during upload. */
10+
loading?: boolean
11+
}
12+
13+
/**
14+
* Image-upload preview tile. Renders the caller's image, an empty-image icon,
15+
* or a loading indicator. File selection, validation, and uploads stay with
16+
* the caller. The 64px square can expand through layout classes such as `w-full`.
17+
*
18+
* @example
19+
* <UploadPreviewButton aria-label='Change logo' loading={uploading} onClick={selectFile}>
20+
* {logoUrl ? <img src={logoUrl} alt='' className='size-full object-contain p-1' /> : null}
21+
* </UploadPreviewButton>
22+
*/
23+
export const UploadPreviewButton = forwardRef<HTMLButtonElement, UploadPreviewButtonProps>(
24+
({ children, className, loading = false, disabled, ...props }, ref) => (
25+
<button
26+
{...props}
27+
ref={ref}
28+
type='button'
29+
disabled={disabled || loading}
30+
aria-busy={loading || undefined}
31+
className={cn(
32+
'group relative flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-2)] transition-colors hover:bg-[var(--surface-3)] disabled:opacity-50',
33+
className
34+
)}
35+
>
36+
{loading ? (
37+
<Loader className='size-5 text-[var(--text-muted)]' animate />
38+
) : (
39+
(children ?? <ImageUp className='size-5 text-[var(--text-muted)]' />)
40+
)}
41+
</button>
42+
)
43+
)
44+
45+
UploadPreviewButton.displayName = 'UploadPreviewButton'

0 commit comments

Comments
 (0)