diff --git a/assets/src/block-editor/ColorPaletteControl/ColorPaletteControl.js b/assets/src/block-editor/ColorPaletteControl/ColorPaletteControl.js
new file mode 100644
index 0000000000..cd8c91da8a
--- /dev/null
+++ b/assets/src/block-editor/ColorPaletteControl/ColorPaletteControl.js
@@ -0,0 +1,24 @@
+import classnames from 'classnames';
+import {withInstanceId} from '@wordpress/compose';
+import {BaseControl, ColorPalette} from '@wordpress/components';
+
+function ColorPaletteControl({label, className, value, help, instanceId, onChange, options = [], ...passThroughProps}) {
+ const id = `inspector-color-palette-control-${instanceId}`;
+
+ // eslint-disable-next-line no-shadow
+ const optionsAsColors = options.map(({value, ...props}) => ({color: value, ...props}));
+
+ return options.length > 0 && (
+
+
+
+ );
+}
+
+export default withInstanceId(ColorPaletteControl);
diff --git a/assets/src/blocks/Spreadsheet/ArrowIcon.js b/assets/src/blocks/Spreadsheet/ArrowIcon.js
new file mode 100644
index 0000000000..532f2f5223
--- /dev/null
+++ b/assets/src/blocks/Spreadsheet/ArrowIcon.js
@@ -0,0 +1,3 @@
+export const ArrowIcon = () => (
+
+);
diff --git a/assets/src/blocks/Spreadsheet/HighlightMatches.js b/assets/src/blocks/Spreadsheet/HighlightMatches.js
new file mode 100644
index 0000000000..4e942c76f9
--- /dev/null
+++ b/assets/src/blocks/Spreadsheet/HighlightMatches.js
@@ -0,0 +1,15 @@
+export const HighlightMatches = (cellValue, searchText, className = 'highlighted-text') => {
+ const reg = new RegExp('(' + searchText.trim() + ')', 'gi');
+ const parts = cellValue.split(reg);
+
+ // Skips the first empty value and the intermediate parts
+ for (let i = 1; i < parts.length; i += 2) {
+ parts[i] = (
+
+ { parts[i] }
+
+ );
+ }
+
+ return <>{ parts }>;
+};
diff --git a/assets/src/blocks/Spreadsheet/SpreadsheetBlock.js b/assets/src/blocks/Spreadsheet/SpreadsheetBlock.js
new file mode 100644
index 0000000000..11f052ecf1
--- /dev/null
+++ b/assets/src/blocks/Spreadsheet/SpreadsheetBlock.js
@@ -0,0 +1,62 @@
+import {SpreadsheetEditor} from './SpreadsheetEditor';
+import {frontendRendered} from '../frontendRendered';
+import {SpreadsheetFrontend} from './SpreadsheetFrontend';
+import {renderToString} from 'react-dom/server';
+
+const BLOCK_NAME = 'planet4-blocks/spreadsheet';
+
+const attributes = {
+ url: {
+ type: 'string',
+ default: '',
+ },
+ color: {
+ type: 'string',
+ default: 'grey',
+ },
+};
+
+const CSS_VARIABLES_ATTRIBUTE = {
+ type: 'object',
+ default: {},
+};
+
+export const registerSpreadsheetBlock = () => {
+ const {registerBlockType} = wp.blocks;
+ const {RawHTML} = wp.element;
+
+ registerBlockType(BLOCK_NAME, {
+ title: 'Spreadsheet',
+ icon: 'editor-table',
+ category: 'planet4-blocks',
+ attributes,
+ edit: SpreadsheetEditor,
+ save: props => {
+ const markup = renderToString(
+
+
);
+ return {markup} ;
+ },
+ deprecated: [
+ {
+ attributes,
+ save: frontendRendered(BLOCK_NAME),
+ },
+ {
+ attributes: {
+ url: {
+ type: 'string',
+ default: '',
+ },
+ css_variables: CSS_VARIABLES_ATTRIBUTE,
+ },
+ save() {
+ return null;
+ },
+ },
+ ],
+ });
+};
diff --git a/assets/src/blocks/Spreadsheet/SpreadsheetEditor.js b/assets/src/blocks/Spreadsheet/SpreadsheetEditor.js
new file mode 100644
index 0000000000..5323c22a28
--- /dev/null
+++ b/assets/src/blocks/Spreadsheet/SpreadsheetEditor.js
@@ -0,0 +1,107 @@
+import {useState} from '@wordpress/element';
+import {InspectorControls} from '@wordpress/block-editor';
+import ColorPaletteControl from '../../block-editor/ColorPaletteControl/ColorPaletteControl';
+import {SpreadsheetFrontend} from './SpreadsheetFrontend';
+import {debounce} from '@wordpress/compose';
+import {TextControl, PanelBody} from '@wordpress/components';
+
+const {__} = wp.i18n;
+
+const colors = [
+ {name: 'blue', color: '#167f82'},
+ {name: 'green', color: '#1f4912'},
+ {name: 'grey', color: '#45494c'},
+ {name: 'gp-green', color: '#198700'},
+];
+
+export const SpreadsheetEditor = ({
+ attributes,
+ setAttributes,
+ isSelected,
+}) => {
+ const [invalidSheetId, setInvalidSheetId] = useState(false);
+ const [url, setUrl] = useState(attributes.url);
+
+ const debounceUrl = debounce(newUrl => {
+ setAttributes({url: newUrl});
+ }, 300);
+
+ const toColorName = code => colors.find(color => color.color === code)?.name || 'grey';
+
+ const toColorCode = name => colors.find(color => color.name === name)?.color || '#ececec';
+
+ const renderEdit = () => (
+ <>
+
+
+ setAttributes({color: toColorName(value)})}
+ disableCustomColors
+ clearable={false}
+ options={colors}
+ />
+ {
+ setUrl(newUrl);
+ debounceUrl(newUrl);
+ }}
+ />
+
+
+
+ {/* eslint-disable-next-line no-restricted-syntax */}
+ { __(`From Your Google Spreadsheet Table choose File -> Publish on web.
+ No need to choose the output format, any of them will work.
+ A pop-up window will show up, click on the Publish button and then OK when the confirmation message is displayed.
+ Copy the URL that is highlighted and paste it in this block.`, 'planet4-blocks-backend') }
+
+
+ {/* eslint-disable-next-line no-restricted-syntax */}
+ { __(`If you make changes to the sheet after publishing
+ then these changes do not always immediately get reflected,
+ even when "Automatically republish when changes are made" is checked.`, 'planet4-blocks-backend') }
+
+
+ {/* eslint-disable-next-line no-restricted-syntax */}
+ { __(`You can force an update by unpublishing and republishing the sheet.
+ This will not change the sheet's public url.`, 'planet4-blocks-backend') }
+
+
+
+
+
+ >
+ );
+
+ const renderView = () => (
+ <>
+ {!attributes.url ?
+
+ { __('No URL has been specified. Please edit the block and provide a Spreadsheet URL using the sidebar.', 'planet4-blocks-backend') }
+
:
+ null
+ }
+
+ {attributes.url && invalidSheetId ?
+
+ { __('The Spreadsheet URL appears to be invalid.', 'planet4-blocks-backend') }
+
:
+ null
+ }
+
+
+ >
+ );
+
+ return (
+ <>
+ {isSelected ? renderEdit() : null}
+ {renderView()}
+ >
+ );
+};
diff --git a/assets/src/blocks/Spreadsheet/SpreadsheetEditorScript.js b/assets/src/blocks/Spreadsheet/SpreadsheetEditorScript.js
new file mode 100644
index 0000000000..eb16dbd26c
--- /dev/null
+++ b/assets/src/blocks/Spreadsheet/SpreadsheetEditorScript.js
@@ -0,0 +1,3 @@
+import {registerSpreadsheetBlock} from './SpreadsheetBlock';
+
+registerSpreadsheetBlock();
diff --git a/assets/src/blocks/Spreadsheet/SpreadsheetFrontend.js b/assets/src/blocks/Spreadsheet/SpreadsheetFrontend.js
new file mode 100644
index 0000000000..91d5b23903
--- /dev/null
+++ b/assets/src/blocks/Spreadsheet/SpreadsheetFrontend.js
@@ -0,0 +1,196 @@
+import {useState, useEffect} from '@wordpress/element';
+import {ArrowIcon} from './ArrowIcon';
+import {HighlightMatches} from './HighlightMatches';
+import {fetchJson} from '../../functions/fetchJson';
+import {addQueryArgs} from '../../functions/addQueryArgs';
+
+const {apiFetch} = wp;
+const {__} = wp.i18n;
+
+const placeholderData = {
+ header: ['Lorem', 'Ipsum', 'Dolor'],
+ rows: [
+ ['Lorem', 'Ipsum', 'Dolor'],
+ ['Sit', 'Amet', 'Lorem'],
+ ['Amet', 'Ipsum', 'Sit'],
+ ],
+};
+
+export const SpreadsheetFrontend = ({
+ url,
+ color,
+ setInvalidSheetId,
+ className,
+}) => {
+ const [loading, setLoading] = useState(false);
+ const [spreadsheetData, setSpreadsheetData] = useState(null);
+ const [searchText, setSearchText] = useState('');
+ const [sortDirection, setSortDirection] = useState('asc');
+ const [sortColumnIndex, setSortColumnIndex] = useState(null);
+
+ const onHeaderClick = event => {
+ const index = parseInt(event.currentTarget.dataset.index);
+ if (index === sortColumnIndex) {
+ setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
+ } else {
+ setSortColumnIndex(index);
+ setSortDirection('asc');
+ }
+ };
+
+ const extractSheetID = urlParam => {
+ const googleSheetsPattern = /https:\/\/docs\.google\.com\/spreadsheets\/d\/e\/([\w-]+)/;
+ const matches = urlParam.match(googleSheetsPattern);
+ if (matches !== null) {
+ return matches[1];
+ }
+ return false;
+ };
+
+ useEffect(() => {
+ (async () => {
+ const sheetID = extractSheetID(url);
+
+ if (sheetID !== false) {
+ if (setInvalidSheetId) {
+ setInvalidSheetId(false);
+ }
+ setLoading(true);
+
+ const args = {
+ sheet_id: sheetID,
+ };
+
+ const baseUrl = document.body.dataset.nro;
+
+ const newSpreadsheetData = baseUrl ?
+ await fetchJson(`${baseUrl}/wp-json/${addQueryArgs('planet4/v1/get-spreadsheet-data', args)}`) :
+ await apiFetch({path: addQueryArgs('planet4/v1/get-spreadsheet-data', args)});
+
+ setLoading(false);
+ setSpreadsheetData(newSpreadsheetData);
+ } else {
+ if (setInvalidSheetId) {
+ setInvalidSheetId(true);
+ }
+ setLoading(false);
+ setSpreadsheetData(null);
+ }
+ })();
+ }, [url, setInvalidSheetId]);
+
+ const sortRows = (rows, columnIndex) => {
+ if (columnIndex === null) {
+ return rows;
+ }
+
+ // eslint-disable-next-line array-callback-return
+ const sortedRows = rows.sort((rowA, rowB) => {
+ const textCompare = rowA[columnIndex].localeCompare(rowB[columnIndex], undefined, {numeric: true});
+ if (textCompare !== 0) {
+ return textCompare;
+ }
+ });
+ if (sortDirection === 'desc') {
+ return sortedRows.reverse();
+ }
+ return sortedRows;
+ };
+
+ const filterMatchingRows = rows => {
+ const filteredRows = rows.filter(row => {
+ return row.some(cell => cell.toLowerCase().includes(searchText.toLowerCase()));
+ });
+ return filteredRows;
+ };
+
+ const getRows = () => {
+ if (spreadsheetData === null) {
+ return placeholderData.rows;
+ } else if (loading === true || loading === null) {
+ return [];
+ }
+ return spreadsheetData.rows;
+ };
+
+ const renderRows = () => {
+ const rows = sortRows(filterMatchingRows(getRows()), sortColumnIndex);
+
+ return searchText.length >= 1 && rows.length === 0 ?
+
+
+
+ { __('No data matching your search.', 'planet4-blocks') }
+
+
+ :
+ rows.map((row, rowNumber) => (
+
+ {
+ row.map((cell, cellIndex) => (
+
+ {
+ searchText.length ?
+ // eslint-disable-next-line new-cap
+ HighlightMatches(cell, searchText) :
+ cell
+ }
+
+ ))
+ }
+
+ ));
+ };
+
+ const headers = spreadsheetData ? spreadsheetData.header : placeholderData.header;
+
+ return (
+
+ setSearchText(event.target.value)}
+ placeholder={__('Search data', 'planet4-blocks')}
+ />
+
+
+
+
+ {
+ headers.map((cell, index) => (
+
+
+ {cell}
+
+
+
+ ))
+ }
+
+
+
+ {loading ?
+
+
+ {__('Loading spreadsheet data…', 'planet4-blocks')}
+
+ :
+ renderRows()
+ }
+
+
+
+
+ );
+};
+
diff --git a/assets/src/blocks/Spreadsheet/SpreadsheetScript.js b/assets/src/blocks/Spreadsheet/SpreadsheetScript.js
new file mode 100644
index 0000000000..f0b941b554
--- /dev/null
+++ b/assets/src/blocks/Spreadsheet/SpreadsheetScript.js
@@ -0,0 +1,14 @@
+import {SpreadsheetFrontend} from './SpreadsheetFrontend';
+import {createRoot} from 'react-dom/client';
+import {hydrateBlock} from '../../functions/hydrateBlock';
+
+hydrateBlock('planet4-blocks/spreadsheet', SpreadsheetFrontend);
+
+// Fallback for non migrated content. Remove after migration.
+document.querySelectorAll('[data-render="planet4-blocks/spreadsheet"]').forEach(
+ blockNode => {
+ const attributes = JSON.parse(blockNode.dataset.attributes);
+ const rootElement = createRoot(blockNode);
+ rootElement.render( );
+ }
+);
diff --git a/assets/src/scss/blocks.scss b/assets/src/scss/blocks.scss
index 8ec34be5eb..0e41e1c882 100644
--- a/assets/src/scss/blocks.scss
+++ b/assets/src/scss/blocks.scss
@@ -4,3 +4,4 @@
@import "blocks/Accordion/AccordionStyle";
@import "blocks/Cookies/CookiesStyle";
@import "blocks/Counter/CounterStyle";
+@import "blocks/Spreadsheet";
diff --git a/assets/src/scss/blocks/CarouselHeader/CarouselHeaderStyle.scss b/assets/src/scss/blocks/CarouselHeader/CarouselHeaderStyle.scss
index 255f16cc3d..896ce33c57 100644
--- a/assets/src/scss/blocks/CarouselHeader/CarouselHeaderStyle.scss
+++ b/assets/src/scss/blocks/CarouselHeader/CarouselHeaderStyle.scss
@@ -627,13 +627,13 @@ $medium-image-height: 600px;
z-index: 3000;
@include large-and-up {
- >.container {
+ > .container {
margin-inline-start: 80px;
}
}
@include x-large-and-up {
- >.container {
+ > .container {
margin-inline-start: auto;
}
}
diff --git a/assets/src/scss/blocks/Spreadsheet.scss b/assets/src/scss/blocks/Spreadsheet.scss
new file mode 100644
index 0000000000..e50c29c527
--- /dev/null
+++ b/assets/src/scss/blocks/Spreadsheet.scss
@@ -0,0 +1,160 @@
+.block-spreadsheet {
+ display: inline-block;
+ margin-top: $sp-1;
+ margin-bottom: $sp-2;
+
+ @include large-and-up {
+ margin-top: $sp-2;
+ margin-bottom: $sp-4;
+ }
+}
+
+.table-wrapper {
+ overflow: auto;
+
+ max-height: 320px;
+ margin-top: $sp-3;
+
+ @include medium-and-up {
+ max-height: 520px;
+ margin-top: $sp-2;
+ }
+
+ @include large-and-up {
+ max-height: 575px;
+ }
+}
+
+table.spreadsheet-table {
+ min-width: 100%;
+ margin-bottom: 0;
+
+ @include mobile-only {
+ overflow: visible;
+ }
+
+ tr {
+ background-color: var(--grey-100);
+
+ &:nth-child(odd) {
+ background-color: var(--grey-200);
+ }
+
+ td {
+ color: var(--color-text-body);
+ padding: 5px $sp-2x;
+ font-size: var(--font-size-s--font-family-tertiary);
+ white-space: pre-wrap;
+
+ @include x-large-and-up {
+ font-size: var(--font-size-s--font-family-tertiary);
+ }
+
+ .highlighted-text {
+ font-weight: bold;
+ }
+ }
+ }
+
+ th {
+ background-color: var(--grey-800);
+
+ button {
+ background-color: inherit;
+ color: inherit;
+ border: none;
+ }
+
+ color: var(--white);
+ cursor: pointer;
+ font-size: var(--font-size-xxs--font-family-primary);
+ position: sticky;
+ top: 0;
+
+ svg {
+ width: $sp-2;
+ margin-left: $sp-1;
+ // Compensate for a small baseline misalignment
+ margin-top: -2px;
+ // Use opacity instead of `display: none` to avoid jumpyness on headers
+ opacity: 0;
+ transition: opacity .3s, transform .4s;
+ }
+ }
+}
+
+table.spreadsheet-table.is-color-green {
+ th {
+ background-color: var(--p4-dark-green-800);
+ }
+
+ tr {
+ background-color: var(--beige-100);
+ }
+
+ tr:nth-child(odd) {
+ background-color: var(--beige-200);
+ }
+}
+
+table.spreadsheet-table.is-color-blue {
+ th {
+ background-color: var(--blue-green-800);
+ }
+
+ tr {
+ background-color: var(--beige-100);
+ }
+
+ tr:nth-child(odd) {
+ background-color: var(--beige-200);
+ }
+}
+
+table.spreadsheet-table.is-color-gp-green {
+ th {
+ background-color: var(--gp-green-800);
+ }
+
+ tr {
+ background-color: var(--beige-100);
+ }
+
+ tr:nth-child(odd) {
+ background: var(--beige-200);
+ }
+}
+
+table.spreadsheet-table.spreadsheet-sorted-by {
+ svg {
+ opacity: 1;
+ }
+
+ &.sort-desc {
+ svg {
+ transform: rotate(180deg);
+ }
+ }
+}
+
+.spreadsheet-loading {
+ padding: $sp-6;
+ text-align: center;
+}
+
+.spreadsheet-search {
+ width: 350px;
+ max-width: 100%;
+}
+
+.spreadsheet-empty-message {
+ padding: $sp-2;
+ font-size: var(--font-size-s--font-family-primary);
+ line-height: 1.375rem;
+
+ @include medium-and-up {
+ font-size: var(--font-size-m--font-family-primary);
+ line-height: var(--line-height-m--font-family-primary);
+ margin: 0 0 $sp-3 0;
+ }
+}
diff --git a/src/Blocks/Spreadsheet.php b/src/Blocks/Spreadsheet.php
new file mode 100644
index 0000000000..e5a949af77
--- /dev/null
+++ b/src/Blocks/Spreadsheet.php
@@ -0,0 +1,187 @@
+register_spreadsheet_block();
+ }
+
+ /**
+ * Register block
+ */
+ public function register_spreadsheet_block(): void
+ {
+ register_block_type(
+ self::get_full_block_name(),
+ [
+ 'editor_script' => 'planet4-blocks-theme-editor-script',
+ 'attributes' => [
+ 'url' => [
+ 'type' => 'string',
+ 'default' => '',
+ ],
+ 'color' => [
+ 'type' => 'string',
+ 'default' => 'grey',
+ ],
+ ],
+ ]
+ );
+
+ add_action('enqueue_block_editor_assets', [ self::class, 'enqueue_editor_assets' ]);
+ add_action('wp_enqueue_scripts', [ self::class, 'enqueue_frontend_assets' ]);
+ add_action('rest_api_init', [ self::class, 'spreadsheet_endpoint' ]);
+ }
+
+ /**
+ * Required by the `BaseBlock` class.
+ *
+ * @param array $fields Unused, required by the abstract function.
+ *
+ * @return array Array.
+ * @phpcs:disable SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter
+ */
+ public function prepare_data(array $fields): array
+ {
+ return [];
+ }
+
+ /**
+ * Fetch a Google sheet by its ID.
+ *
+ * @param string|null $sheet_id The ID of the Google sheet.
+ * @param bool $skip_cache Should the sheet be fetched from cache.
+ * @return array|null The sheet or null if nothing was found.
+ */
+ public static function get_sheet(?string $sheet_id, bool $skip_cache): ?array
+ {
+ if (! $sheet_id) {
+ return null;
+ }
+
+ $cache_key = "spreadsheet_${sheet_id}";
+
+ if (! $skip_cache) {
+ $from_cache = wp_cache_get($cache_key);
+
+ if (false !== $from_cache) {
+ return $from_cache;
+ }
+ }
+
+ $url = "https://docs.google.com/spreadsheets/d/e/${sheet_id}/pub?output=csv";
+
+ $headers = get_headers($url);
+
+ // Handle 500, 404 errors.
+ if (! $headers || strpos($headers[0], '500') || strpos($headers[0], '404')) {
+ return null;
+ }
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fopen
+ $handle = fopen($url, 'rb');
+
+ if (false === $handle) {
+ return null;
+ }
+
+ $rows = [];
+ while (
+ // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.CodeAnalysis.AssignmentInCondition
+ ( $data = fgetcsv($handle, 1000, ',') ) !== false
+ ) {
+ if (count($rows) > self::MAX_ROWS) {
+ break;
+ }
+
+ $rows[] = $data;
+ }
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fclose
+ fclose($handle);
+
+ if (0 === count($rows)) {
+ $sheet = null;
+ } else {
+ $sheet = [
+ 'header' => $rows[0],
+ 'rows' => array_slice($rows, 1),
+ ];
+ }
+
+ wp_cache_add($cache_key, $sheet, null, self::CACHE_LIFETIME);
+
+ return $sheet;
+ }
+
+ /**
+ * Register endpoint for Spreadsheet Block
+ */
+ public static function spreadsheet_endpoint(): void
+ {
+ /**
+ * Endpoint to retrieve a Spreadsheet data and cache it.
+ */
+ register_rest_route(
+ self::REST_NAMESPACE,
+ '/get-spreadsheet-data',
+ [
+ [
+ 'permission_callback' => static function () {
+ return true;
+ },
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => static function () {
+ $sheet_id = filter_input(
+ INPUT_GET,
+ 'sheet_id',
+ FILTER_VALIDATE_REGEXP,
+ [
+ 'options' => [
+ 'regexp' => '/[\w\d\-]+/',
+ ],
+ ]
+ );
+
+ $sheet_data = self::get_sheet($sheet_id, false);
+
+ return rest_ensure_response($sheet_data);
+ },
+ ],
+ ]
+ );
+ }
+}
diff --git a/src/Loader.php b/src/Loader.php
index 3c796ec8d5..18cf3ca491 100644
--- a/src/Loader.php
+++ b/src/Loader.php
@@ -161,6 +161,7 @@ public static function add_blocks(): void
new Blocks\Counter();//NOSONAR
new Blocks\Gallery();//NOSONAR
new Blocks\GuestBook();//NOSONAR
+ new Blocks\Spreadsheet();//NOSONAR
if (!BetaBlocks::is_active()) {
return;
diff --git a/webpack.config.js b/webpack.config.js
index 55ef80a196..a0b13516f6 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -47,6 +47,8 @@ module.exports = {
CookiesEditorScript: './assets/src/blocks/Cookies/CookiesEditorScript.js',
CounterScript: './assets/src/blocks/Counter/CounterScript.js',
CounterEditorScript: './assets/src/blocks/Counter/CounterEditorScript.js',
+ SpreadsheetScript: './assets/src/blocks/Spreadsheet/SpreadsheetScript.js',
+ SpreadsheetEditorScript: './assets/src/blocks/Spreadsheet/SpreadsheetEditorScript.js',
},
output: {
filename: '[name].js',