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) => ( + + )) + } + + + + {loading ? + + + : + renderRows() + } + +
+ +
+
{__('Loading spreadsheet data…', 'planet4-blocks')}
+
+
+
+ ); +}; + 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',