Skip to content
1 change: 1 addition & 0 deletions .github/workflows/deploy-catalog.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
- '.github/workflows/deploy-catalog.yaml'
- 'catalog/**'
- 'shared/**'
workflow_dispatch:

jobs:
deploy-catalog-ecr:
Expand Down
78 changes: 78 additions & 0 deletions catalog/app/containers/Admin/Settings/ThemeEditor.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import * as React from 'react'
import { afterEach, describe, it, expect, vi } from 'vitest'
import { ThemeProvider, createMuiTheme } from '@material-ui/core/styles'
import { cleanup, fireEvent, render } from '@testing-library/react'

vi.mock('constants/config', () => ({ default: {} }))

vi.mock('components/Logo', () => ({
default: ({ src }: { src: string }) => <div data-testid="logo" data-src={src} />,
}))

import { InputFile } from './ThemeEditor'

const theme = createMuiTheme()

function renderWithTheme(component: React.ReactElement) {
return render(<ThemeProvider theme={theme}>{component}</ThemeProvider>)
}

describe('containers/Admin/Settings/ThemeEditor', () => {
afterEach(cleanup)

describe('InputFile', () => {
it('renders placeholder when value is empty', () => {
const { container, queryByTestId } = renderWithTheme(
<InputFile input={{ value: '', onChange: vi.fn() }} />,
)
expect(queryByTestId('logo')).toBeNull()
expect(container.querySelector('img')).toBeNull()
})

it('renders Logo when value is a URL string', () => {
const { getByTestId, container } = renderWithTheme(
<InputFile
input={{ value: 's3://bucket/catalog/logo.png', onChange: vi.fn() }}
/>,
)
expect(getByTestId('logo').getAttribute('data-src')).toBe(
's3://bucket/catalog/logo.png',
)
expect(container.querySelector('img')).toBeNull()
})

it('creates and revokes object URL for File value', () => {
const createSpy = vi
.spyOn(URL, 'createObjectURL')
.mockReturnValue('blob:preview-url')
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})

const file = new File(['x'], 'logo.png', { type: 'image/png' })
const { container, unmount } = renderWithTheme(
<InputFile input={{ value: file, onChange: vi.fn() }} />,
)

expect(createSpy).toHaveBeenCalledWith(file)
const img = container.querySelector('img')
expect(img).not.toBeNull()
expect(img!.getAttribute('src')).toBe('blob:preview-url')

unmount()
expect(revokeSpy).toHaveBeenCalledWith('blob:preview-url')

createSpy.mockRestore()
revokeSpy.mockRestore()
})

it('updates URL value via text field', () => {
const onChange = vi.fn()
const { container } = renderWithTheme(<InputFile input={{ value: '', onChange }} />)
const textField = container.querySelector(
'input[placeholder="https://example.com/logo.png"]',
) as HTMLInputElement | null
expect(textField).not.toBeNull()
fireEvent.change(textField!, { target: { value: 'https://example.com/x.png' } })
expect(onChange).toHaveBeenCalledWith('https://example.com/x.png')
})
})
})
124 changes: 64 additions & 60 deletions catalog/app/containers/Admin/Settings/ThemeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ import Logo from 'components/Logo'
import * as CatalogSettings from 'utils/CatalogSettings'
import * as validators from 'utils/validators'

import * as Form from '../Form'

const useInputColorStyles = M.makeStyles((t) => ({
root: {
alignItems: 'flex-start',
Expand Down Expand Up @@ -86,11 +84,13 @@ function InputColor({
}

const useInputFileStyles = M.makeStyles((t) => ({
root: {
root: {},
dropzone: {
alignItems: 'center',
display: 'flex',
outline: `2px dashed ${t.palette.primary.light}`,
padding: '2px',
cursor: 'pointer',
},
note: {
flexGrow: 1,
Expand All @@ -108,16 +108,21 @@ const useInputFileStyles = M.makeStyles((t) => ({
height: '50px',
width: '50px',
},
or: {
textAlign: 'center',
margin: t.spacing(1, 0),
color: t.palette.text.secondary,
},
}))

interface InputFileProps {
input: {
value: FileWithPath | string
onChange: (value: FileWithPath) => void
onChange: (value: FileWithPath | string) => void
}
}

function InputFile({ input: { value, onChange } }: InputFileProps) {
export function InputFile({ input: { value, onChange } }: InputFileProps) {
const classes = useInputFileStyles()
const onDrop = React.useCallback(
(files: FileWithPath[]) => {
Expand All @@ -127,25 +132,44 @@ function InputFile({ input: { value, onChange } }: InputFileProps) {
)
const { getInputProps, getRootProps } = useDropzone({
maxFiles: 1,
accept: { 'image/*': [] },
onDrop,
})
const previewUrl = React.useMemo(() => {
if (!value || typeof value === 'string') return null
return URL.createObjectURL(value)
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null)
React.useEffect(() => {
if (!value || typeof value === 'string') {
setPreviewUrl(null)
return undefined
}
const url = URL.createObjectURL(value)
setPreviewUrl(url)
return () => URL.revokeObjectURL(url)
}, [value])
const isUrl = typeof value === 'string' && value.length > 0
return (
<div className={classes.root} {...getRootProps()}>
<input {...getInputProps()} />
{!!value && typeof value === 'string' && (
<Logo src={value} height="50px" width="50px" />
)}
{!!previewUrl && <img className={classes.preview} src={previewUrl} />}
{!value && (
<div className={classes.placeholder}>
<M.Icon>hide_image</M.Icon>
</div>
)}
<p className={classes.note}>Drop logo here</p>
<div className={classes.root}>
<div className={classes.dropzone} {...getRootProps()}>
<input {...getInputProps()} />
{isUrl && <Logo src={value} height="50px" width="50px" />}
{!!previewUrl && <img className={classes.preview} src={previewUrl} />}
{!value && (
<div className={classes.placeholder}>
<M.Icon>hide_image</M.Icon>
</div>
)}
<p className={classes.note}>Drop logo here</p>
</div>
<div className={classes.or}>or</div>
<M.TextField
value={isUrl ? value : ''}
onChange={(e) => onChange(e.target.value)}
placeholder="https://example.com/logo.png"
label="Logo URL"
fullWidth
size="small"
variant="outlined"
InputLabelProps={{ shrink: true }}
/>
</div>
)
}
Expand Down Expand Up @@ -306,9 +330,6 @@ export default function ThemeEditor() {
[settings, writeSettings, uploadFile],
)

// FIXME: remove when file upload would be ready
const useThirdPartyDomainForLogo = true

return (
<>
{settings?.theme || settings?.logo ? (
Expand Down Expand Up @@ -356,43 +377,26 @@ export default function ThemeEditor() {
<M.DialogTitle>Configure theme</M.DialogTitle>
<M.DialogContent>
<form onSubmit={handleSubmit}>
{useThirdPartyDomainForLogo ? (
<RF.Field
component={Form.Field}
initialValue={settings?.logo?.url || ''}
name="logoUrl"
label="Logo URL"
placeholder="e.g. https://example.com/path.jpg"
validate={validators.url as FF.FieldValidator<string>}
errors={{
url: 'Image should be valid url',
}}
disabled={submitting}
fullWidth
InputLabelProps={{ shrink: true }}
/>
) : (
<RF.Field
component={InputFile}
initialValue={settings?.logo?.url || ''}
name="logoUrl"
label="Logo URL"
placeholder="e.g. https://example.com/path.jpg"
validate={
validators.composeOr(
validators.file,
validators.url,
) as FF.FieldValidator<string>
}
errors={{
url: 'Image should be valid url',
file: 'Image should be file',
}}
disabled={submitting}
fullWidth
InputLabelProps={{ shrink: true }}
/>
)}
<RF.Field
component={InputFile}
initialValue={settings?.logo?.url || ''}
name="logoUrl"
label="Logo URL"
placeholder="e.g. https://example.com/path.jpg"
validate={
validators.composeOr(
validators.file,
validators.url,
) as FF.FieldValidator<string>
}
errors={{
url: 'Image should be valid url',
file: 'Image should be file',
}}
disabled={submitting}
fullWidth
InputLabelProps={{ shrink: true }}
/>
<M.Box pt={2} />
<RF.Field
// @ts-expect-error
Expand Down
90 changes: 90 additions & 0 deletions catalog/app/utils/CatalogSettings.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import * as React from 'react'
import { render, act } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'

vi.mock('constants/config', () => ({
default: { serviceBucket: 'test-bucket', mode: 'PRODUCT' },
}))

const putObjectMock = vi.fn<
(params: { Bucket: string; Key: string; ContentType?: string; Body: unknown }) => {
promise: () => Promise<{}>
}
>(() => ({ promise: () => Promise.resolve({}) }))
const s3Mock = { putObject: putObjectMock }

vi.mock('utils/AWS', () => ({
S3: { use: () => s3Mock },
}))

vi.mock('utils/ResourceCache', () => ({
createResource: () => ({}),
use: () => ({ patchOk: vi.fn() }),
useData: () => null,
}))

vi.mock('@sentry/react', () => ({ captureException: vi.fn() }))

import { useUploadFile } from './CatalogSettings'

function makeFile(name: string, type = 'image/png', body = 'x') {
const f = new File([body], name, { type })
// jsdom File lacks arrayBuffer in some envs; polyfill
if (!f.arrayBuffer) {
;(f as { arrayBuffer: () => Promise<ArrayBuffer> }).arrayBuffer = async () =>
new TextEncoder().encode(body).buffer as ArrayBuffer
}
return f
}

function captureHook<T>(hook: () => T): { current: T } {
const ref: { current: T } = { current: undefined as unknown as T }
function Probe() {
ref.current = hook()
return null
}
render(<Probe />)
return ref
}

describe('utils/CatalogSettings', () => {
describe('useUploadFile', () => {
it('uploads file with extension-based key and returns s3 URL', async () => {
putObjectMock.mockClear()
const ref = captureHook(() => useUploadFile())
let result: string | undefined
await act(async () => {
result = await ref.current(makeFile('brand.svg', 'image/svg+xml'))
})
expect(result).toBe('s3://test-bucket/catalog/logo.svg')
expect(putObjectMock).toHaveBeenCalledTimes(1)
const arg = putObjectMock.mock.calls[0][0]
expect(arg.Bucket).toBe('test-bucket')
expect(arg.Key).toBe('catalog/logo.svg')
expect(arg.ContentType).toBe('image/svg+xml')
expect(arg.Body).toBeInstanceOf(Uint8Array)
})

it('omits extension when filename has none', async () => {
putObjectMock.mockClear()
const ref = captureHook(() => useUploadFile())
let result: string | undefined
await act(async () => {
result = await ref.current(makeFile('logo', ''))
})
expect(result).toBe('s3://test-bucket/catalog/logo')
const arg = putObjectMock.mock.calls[0][0]
expect(arg.Key).toBe('catalog/logo')
expect(arg.ContentType).toBeUndefined()
})

it('uses last extension for multi-dot filenames', async () => {
putObjectMock.mockClear()
const ref = captureHook(() => useUploadFile())
await act(async () => {
await ref.current(makeFile('my.company.logo.png'))
})
expect(putObjectMock.mock.calls[0][0].Key).toBe('catalog/logo.png')
})
})
})
24 changes: 18 additions & 6 deletions catalog/app/utils/CatalogSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,25 @@ function format(settings: CatalogSettings) {
return JSON.stringify(settings, null, 2)
}

// FIXME: remove if decide to not use file upload for logo
export function useUploadFile() {
return React.useCallback(async (file: File) => {
// eslint-disable-next-line no-console
console.log(file)
throw new Error('This functionality is not ready yet')
}, [])
const s3 = AWS.S3.use()
return React.useCallback(
async (file: File) => {
const ext = file.name.includes('.') ? file.name.split('.').pop() : ''
const key = ext ? `catalog/logo.${ext}` : 'catalog/logo'
const buf = await file.arrayBuffer()
await s3
.putObject({
Bucket: cfg.serviceBucket,
Key: key,
Body: new Uint8Array(buf),
ContentType: file.type || undefined,
})
.promise()
return `s3://${cfg.serviceBucket}/${key}`
},
[s3],
)
}

export function useWriteSettings() {
Expand Down