Skip to content
Open
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
111 changes: 111 additions & 0 deletions packages/blocks/Maps/MapsBlockEdit.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { BlockEditProps } from '@plone/types';
import MapsBlockEdit from './MapsBlockEdit';

vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'blocks.maps.maps-block-input-placeholder': 'Enter map Embed Code',
'blocks.maps.google-maps-embedded-block': 'Google Maps Embedded Block',
'blocks.maps.instructions':
'Please enter the Embed Code provided by Google Maps',
'blocks.maps.code-error':
'Embed code error, please follow the instructions and try again.',
};
return translations[key] || key;
},
}),
}));

vi.mock('@plone/components/Icons', () => ({
ArrowrightIcon: (props: Record<string, unknown>) => (
<span data-testid="arrow-icon" {...props} />
),
CloseIcon: (props: Record<string, unknown>) => (
<span data-testid="close-icon" {...props} />
),
}));

const makeProps = (overrides: Partial<BlockEditProps> = {}) =>
({
block: 'test-block-id',
data: {},
selected: true,
onChangeBlock: vi.fn(),
...overrides,
}) as BlockEditProps;

describe('MapsBlockEdit', () => {
it('renders iframe when url is already set', () => {
render(
<MapsBlockEdit
{...makeProps({
data: { url: 'https://maps.google.com/?q=' } as any,
})}
/>,
);

const iframe = screen.getByTitle('Google Maps Embedded Block');
expect(iframe).toBeInTheDocument();
expect(iframe).toHaveAttribute('src', 'https://maps.google.com/?q=');
});

it('renders input mode with translated placeholder and instructions', () => {
render(<MapsBlockEdit {...makeProps()} />);

expect(
screen.getByPlaceholderText('Enter map Embed Code'),
).toBeInTheDocument();
expect(
screen.getByText('Please enter the Embed Code provided by Google Maps'),
).toBeInTheDocument();
});

it('shows validation error for invalid embed code', () => {
const onChangeBlock = vi.fn();
const props = makeProps({ onChangeBlock });
const { container } = render(<MapsBlockEdit {...props} />);
const input = container.querySelector('input') as HTMLInputElement;

fireEvent.change(input, { target: { value: 'not-an-iframe' } });
const buttons = container.querySelectorAll('button');
fireEvent.click(buttons[buttons.length - 1]);

expect(
screen.getByText(
'Embed code error, please follow the instructions and try again.',
),
).toBeInTheDocument();
expect(onChangeBlock).toHaveBeenCalledWith(
'test-block-id',
expect.objectContaining({
url: '',
}),
);
});

it('uses clear button to reset input', () => {
const { container } = render(<MapsBlockEdit {...makeProps()} />);
const input = container.querySelector('input') as HTMLInputElement;

fireEvent.change(input, { target: { value: '<iframe src="x"></iframe>' } });

const buttonsAfterTyping = container.querySelectorAll('button');
expect(buttonsAfterTyping).toHaveLength(2);
fireEvent.click(buttonsAfterTyping[0]);
expect(input.value).toBe('');
});

it('renders map overlay only when block is not selected', () => {
const { container, rerender } = render(
<MapsBlockEdit {...makeProps({ selected: false })} />,
);

expect(container.querySelector('.map-overlay')).toBeInTheDocument();

rerender(<MapsBlockEdit {...makeProps({ selected: true })} />);
expect(container.querySelector('.map-overlay')).not.toBeInTheDocument();
});
});
151 changes: 151 additions & 0 deletions packages/blocks/Maps/MapsBlockEdit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import type { BlockEditProps } from '@plone/types';
import { useState, useCallback, useMemo } from 'react';
import clsx from 'clsx';
import mapsBlockSVG from './block-maps.svg';
import { useTranslation } from 'react-i18next';
import { ArrowrightIcon, CloseIcon } from '@plone/components/Icons';

const MapsBlockEdit = (props: BlockEditProps) => {
const { t } = useTranslation();

const [url, setUrl] = useState('');
const [error, setError] = useState(null);

const { onChangeBlock, data, block, selected } = props;
const onChangeUrl = ({ target }) => {
setUrl(target.value);
};

const onSubmitUrl = useCallback(() => {
onChangeBlock(block, {
...data,
url: getSrc(url),
});
}, [onChangeBlock, block, data, url]);

const onKeyDownVariantMenuForm = useCallback(
(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
onSubmitUrl();
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
// TODO: Do something on ESC key
}
},
[onSubmitUrl],
);

const getSrc = (embed) => {
const parser = new DOMParser();
const doc = parser.parseFromString(embed, 'text/html');
const iframe = doc.getElementsByTagName('iframe');
if (iframe.length === 0) {
setError(true);
return '';
}
setError(false);
return iframe[0].src;
};

const placeholder = useMemo(
() => data.placeholder || t('blocks.maps.maps-block-input-placeholder'),
[data, t],
);

return (
<div
className={clsx(
'maps align block',
{
center: !Boolean(data.align),
},
data.align,
)}
>
{data.url ? (
<div
className={clsx('maps-inner', {
'w-full': data.align === 'full',
})}
>
<iframe
title={t('blocks.maps.google-maps-embedded-block')}
src={data.url}
className="google-map aspect-video"
frameBorder="0"
allowFullScreen
/>
</div>
) : (
<div>
<div
className={`
flex w-full flex-col items-center justify-center gap-6 bg-quanta-snow px-4 py-6
`}
>
<div className="mb-8 flex h-32 w-32 items-center justify-center bg-white p-4">
<img src={mapsBlockSVG} alt="" />
</div>
<div
className={`mb-6 flex w-[380px] max-w-full items-center bg-white px-4 py-3 shadow-sm`}
>
{' '}
<input
onKeyDown={onKeyDownVariantMenuForm}
onChange={onChangeUrl}
placeholder={placeholder}
value={url}
// Prevents propagation to the Dropzone and the opening
// of the upload browser dialog
onClick={(e) => e.stopPropagation()}
className="flex-1 outline-hidden"
/>
<div className="ml-2 flex items-center gap-3">
{url && (
<button
className={`
flex items-center justify-center transition-opacity
hover:opacity-75
`}
onClick={(e) => {
e.stopPropagation();
setUrl('');
}}
>
<CloseIcon className="size-6 text-gray-400" />
</button>
)}
<button
className={`
flex items-center justify-center transition-opacity
hover:opacity-75
`}
onClick={(e) => {
e.stopPropagation();
onSubmitUrl();
}}
>
<ArrowrightIcon className="size-6 text-gray-400" />
</button>
</div>
</div>
<div className="mt-2 mb-2 text-center text-gray-500">
<p>{t('blocks.maps.instructions')}</p>
{error && (
<div>
<p className="text-red-700">{t('blocks.maps.code-error')}</p>
</div>
)}
</div>
</div>
</div>
)}
{!selected && <div className="map-overlay" />}
</div>
);
};

export default MapsBlockEdit;
63 changes: 63 additions & 0 deletions packages/blocks/Maps/MapsBlockView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { BlockViewProps } from '@plone/types';
import MapsBlockView from './MapsBlockView';

const makeProps = (data: Record<string, unknown>) =>
({
data,
blocksConfig: {},
content: {},
extensions: {},
id: 'maps',
location: {} as BlockViewProps['location'],
history: {} as BlockViewProps['history'],
intl: {} as BlockViewProps['intl'],
properties: {},
token: '',
variation: {} as BlockViewProps['variation'],
path: '/',
className: '',
style: {},
}) as unknown as BlockViewProps;

describe('MapsBlockView', () => {
it('renders nothing when url is missing', () => {
const { container } = render(<MapsBlockView {...makeProps({})} />);

expect(container.firstChild).toBeNull();
});

it('renders iframe with center alignment by default', () => {
const props = makeProps({
url: 'https://maps.google.com/?q=',
title: 'Map Title',
});

const { container } = render(<MapsBlockView {...props} />);
const iframe = screen.getByTitle('Map Title');

expect(iframe).toBeInTheDocument();
expect(iframe).toHaveAttribute('src', 'https://maps.google.com/?q=');
expect(
container.querySelector('.maps.align.block.center'),
).toBeInTheDocument();
});

it('applies alignment classname', () => {
const props = makeProps({
url: 'https://maps.google.com/?q=cluj',
title: 'map',
align: 'right',
});

const { container } = render(<MapsBlockView {...props} />);

expect(
container.querySelector('.maps.align.block.right'),
).toBeInTheDocument();
expect(
container.querySelector('.maps.align.block.center'),
).not.toBeInTheDocument();
});
});
34 changes: 34 additions & 0 deletions packages/blocks/Maps/MapsBlockView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { BlockViewProps } from '@plone/types';
import clsx from 'clsx';

const MapsBlockView = (props: BlockViewProps) => {
const { data } = props;

return data.url ? (
<div
className={clsx(
'maps align block',
{
center: !Boolean(data.align),
},
data.align,
)}
>
<div
className={clsx('maps-inner', {
'w-full': data.align === 'full',
})}
>
<iframe
title={data?.title}
src={data.url}
className="google-map aspect-video"
frameBorder="0"
allowFullScreen
/>
</div>
</div>
) : null;
};

export default MapsBlockView;
7 changes: 7 additions & 0 deletions packages/blocks/Maps/block-maps.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 19 additions & 0 deletions packages/blocks/Maps/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React from 'react';
import { MapsSchema } from './schema';
import { WorldIcon } from '@plone/components/Icons';

const MapsBlockInfo = {
id: 'maps',
title: 'Maps',
view: React.lazy(
() => import(/* webpackChunkName: "plone-blocks" */ './MapsBlockView'),
),
edit: React.lazy(
() => import(/* webpackChunkName: "plone-blocks" */ './MapsBlockEdit'),
),
category: 'common',
blockSchema: MapsSchema,
icon: WorldIcon,
};

export default MapsBlockInfo;
Loading
Loading