Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 8 additions & 13 deletions apps/sim/ee/custom-blocks/components/custom-block-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ import {
Expandable,
ExpandableContent,
Label,
Loader,
Switch,
toast,
UploadPreviewButton,
} from '@sim/emcn'
import { ArrowLeft, ChevronDown, ImageUp as ImageIcon, X } from '@sim/emcn/icons'
import { ArrowLeft, ChevronDown, X } from '@sim/emcn/icons'
import { getErrorMessage } from '@sim/utils/errors'
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
import {
Expand Down Expand Up @@ -522,21 +522,16 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
<SettingRow label='Icon' labelTooltip='Square image (PNG, JPEG, or SVG). Optional.'>
<div className='flex items-center gap-4'>
<DropZone onDrop={canManageBlock ? iconUpload.handleFileDrop : () => {}}>
<button
<UploadPreviewButton
aria-label={iconUrl ? 'Change icon' : 'Upload icon'}
type='button'
onClick={iconUpload.handleThumbnailClick}
disabled={iconUpload.isUploading || !canManageBlock}
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'
loading={iconUpload.isUploading}
disabled={!canManageBlock}
>
{iconUpload.isUploading ? (
<Loader className='size-5 text-[var(--text-muted)]' animate />
) : iconUrl ? (
{iconUrl ? (
<img src={iconUrl} alt='' className='size-full object-contain p-1.5' />
) : (
<ImageIcon className='size-5 text-[var(--text-muted)]' />
)}
</button>
) : null}
</UploadPreviewButton>
</DropZone>
<div className='flex gap-2'>
<Button
Expand Down
37 changes: 13 additions & 24 deletions apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
'use client'

import { useState } from 'react'
import { Button, ChipInput, cn, Label, Loader, toast } from '@sim/emcn'
import { ImageUp as ImageIcon, X } from '@sim/emcn/icons'
import { Button, ChipInput, cn, Label, toast, UploadPreviewButton } from '@sim/emcn'
import { X } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import Image from 'next/image'
Expand Down Expand Up @@ -288,17 +288,13 @@ function WhitelabelingForm({ initialSettings, orgId, uploadWorkspaceId }: Whitel
>
<div className='flex items-center gap-4'>
<DropZone onDrop={logoUpload.handleFileDrop}>
<button
type='button'
<UploadPreviewButton
onClick={logoUpload.handleThumbnailClick}
disabled={logoUpload.isUploading}
loading={logoUpload.isUploading}
aria-label={logoUpload.previewUrl ? 'Change logo' : 'Upload logo'}
title={logoUpload.previewUrl ? 'Change logo' : 'Upload logo'}
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'
>
{logoUpload.isUploading ? (
<Loader className='size-5 text-[var(--text-muted)]' animate />
) : logoUpload.previewUrl ? (
{logoUpload.previewUrl ? (
<Image
src={logoUpload.previewUrl}
alt='Logo'
Expand All @@ -307,10 +303,8 @@ function WhitelabelingForm({ initialSettings, orgId, uploadWorkspaceId }: Whitel
className='object-contain p-1'
unoptimized
/>
) : (
<ImageIcon className='size-5 text-[var(--text-muted)]' />
)}
</button>
) : null}
</UploadPreviewButton>
</DropZone>
{logoUpload.previewUrl && (
<Button
Expand Down Expand Up @@ -339,17 +333,14 @@ function WhitelabelingForm({ initialSettings, orgId, uploadWorkspaceId }: Whitel
>
<div className='flex items-center gap-4'>
<DropZone onDrop={wordmarkUpload.handleFileDrop} className='min-w-0 flex-1'>
<button
type='button'
<UploadPreviewButton
onClick={wordmarkUpload.handleThumbnailClick}
disabled={wordmarkUpload.isUploading}
loading={wordmarkUpload.isUploading}
aria-label={wordmarkUpload.previewUrl ? 'Change wordmark' : 'Upload wordmark'}
title={wordmarkUpload.previewUrl ? 'Change wordmark' : 'Upload wordmark'}
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'
className='w-full'
>
{wordmarkUpload.isUploading ? (
<Loader className='size-5 text-[var(--text-muted)]' animate />
) : wordmarkUpload.previewUrl ? (
{wordmarkUpload.previewUrl ? (
<Image
src={wordmarkUpload.previewUrl}
alt='Wordmark'
Expand All @@ -358,10 +349,8 @@ function WhitelabelingForm({ initialSettings, orgId, uploadWorkspaceId }: Whitel
className='object-contain p-2'
unoptimized
/>
) : (
<ImageIcon className='size-5 text-[var(--text-muted)]' />
)}
</button>
) : null}
</UploadPreviewButton>
</DropZone>
{wordmarkUpload.previewUrl && (
<Button
Expand Down
4 changes: 4 additions & 0 deletions packages/emcn/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,4 +265,8 @@ export {
useFloatingTooltip,
useIsOverflowing,
} from './tooltip/tooltip'
export {
UploadPreviewButton,
type UploadPreviewButtonProps,
} from './upload-preview-button/upload-preview-button'
export { Wizard } from './wizard/wizard'
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/** @vitest-environment jsdom */
import { act, createRef, type ReactNode } from 'react'
import { UploadPreviewButton } from '@sim/emcn'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'

let root: Root | null = null
let container: HTMLDivElement | null = null

function mount(children: ReactNode) {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => root?.render(children))
}

afterEach(() => {
if (root) act(() => root?.unmount())
container?.remove()
root = null
container = null
})

describe('UploadPreviewButton', () => {
it('forwards the label, ref and upload action without submitting an enclosing form', () => {
const ref = createRef<HTMLButtonElement>()
const onClick = vi.fn()
const onSubmit = vi.fn((event) => event.preventDefault())
mount(
<form onSubmit={onSubmit}>
<UploadPreviewButton ref={ref} aria-label='Upload logo' onClick={onClick} />
</form>
)

const button = ref.current!
expect(button).toBe(container?.querySelector('button'))
expect(button.getAttribute('aria-label')).toBe('Upload logo')
expect(button.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true')
act(() => button.click())
expect(onClick).toHaveBeenCalledTimes(1)
expect(onSubmit).not.toHaveBeenCalled()
})

it('replaces the preview while busy and preserves a separate disabled restriction', () => {
const ref = createRef<HTMLButtonElement>()
const onClick = vi.fn()
const render = (loading: boolean, disabled = false) => (
<UploadPreviewButton
ref={ref}
aria-label='Change logo'
loading={loading}
disabled={disabled}
onClick={onClick}
>
<img src='/logo.svg' alt='Logo' />
</UploadPreviewButton>
)
mount(render(false))
const button = ref.current!
expect(button.querySelector('img')?.alt).toBe('Logo')

act(() => root?.render(render(true)))
expect(button.disabled).toBe(true)
expect(button.getAttribute('aria-busy')).toBe('true')
expect(button.querySelector('img')).toBeNull()
act(() => button.click())
expect(onClick).not.toHaveBeenCalled()

act(() => root?.render(render(false, true)))
expect(button.disabled).toBe(true)
expect(button.hasAttribute('aria-busy')).toBe(false)
expect(button.querySelector('img')?.alt).toBe('Logo')
act(() => button.click())
expect(onClick).not.toHaveBeenCalled()

act(() => root?.render(render(false)))
expect(button.disabled).toBe(false)
act(() => button.click())
expect(onClick).toHaveBeenCalledTimes(1)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { type ComponentPropsWithoutRef, forwardRef } from 'react'
import { ImageUp } from '../../icons/image-up'
import { Loader } from '../../icons/loader'
import { cn } from '../../lib/cn'

export interface UploadPreviewButtonProps extends Omit<ComponentPropsWithoutRef<'button'>, 'type'> {
/** Accessible name describing which image will be uploaded or replaced. */
'aria-label': string
/** Shows the shared loader and prevents another activation during upload. */
loading?: boolean
}

/**
* Image-upload preview tile. Renders the caller's image, an empty-image icon,
* or a loading indicator. File selection, validation, and uploads stay with
* the caller. The 64px square can expand through layout classes such as `w-full`.
*
* @example
* <UploadPreviewButton aria-label='Change logo' loading={uploading} onClick={selectFile}>
* {logoUrl ? <img src={logoUrl} alt='' className='size-full object-contain p-1' /> : null}
* </UploadPreviewButton>
*/
export const UploadPreviewButton = forwardRef<HTMLButtonElement, UploadPreviewButtonProps>(
({ children, className, loading = false, disabled, ...props }, ref) => (
<button
{...props}
ref={ref}
type='button'
disabled={disabled || loading}
aria-busy={loading || undefined}
className={cn(
'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',
Comment thread
BillLeoutsakosvl346 marked this conversation as resolved.
className
)}
>
{loading ? (
<Loader className='size-5 text-[var(--text-muted)]' animate />
) : (
(children ?? <ImageUp className='size-5 text-[var(--text-muted)]' />)
)}
</button>
)
)

UploadPreviewButton.displayName = 'UploadPreviewButton'
Loading