diff --git a/inc/config/auth/google-auth/google-auth.php b/inc/config/auth/google-auth/google-auth.php
new file mode 100644
index 00000000..c345473b
--- /dev/null
+++ b/inc/config/auth/google-auth/google-auth.php
@@ -0,0 +1,149 @@
+token_uri;
+
+ return self::get_token_using_jwt( $jwt, $token_uri );
+ }
+
+ private static function get_token_using_jwt( string $jwt, string $token_uri ): WP_Error|string {
+ $response = wp_remote_post(
+ $token_uri,
+ [
+ 'body' => [
+ 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
+ 'assertion' => $jwt,
+ ],
+ ]
+ );
+
+ if ( is_wp_error( $response ) ) {
+ return new WP_Error(
+ 'google_auth_error',
+ __( 'Failed to retrieve access token', 'remote-data-blocks' )
+ );
+ }
+
+ $response_body = wp_remote_retrieve_body( $response );
+ $response_data = json_decode( $response_body, true );
+
+ if ( ! isset( $response_data['access_token'] ) ) {
+ return new WP_Error(
+ 'google_auth_error',
+ __( 'Invalid response from Google Auth', 'remote-data-blocks' )
+ );
+ }
+
+ return $response_data['access_token'];
+ }
+
+ private static function generate_jwt(
+ GoogleServiceAccountKey $service_account_key,
+ string $scope
+ ): string {
+ $header = self::generate_jwt_header();
+ $payload = self::generate_jwt_payload(
+ $service_account_key->client_email,
+ $service_account_key->token_uri,
+ $scope
+ );
+
+ $base64_url_header = base64_encode( wp_json_encode( $header ) );
+ $base64_url_payload = base64_encode( wp_json_encode( $payload ) );
+
+ $signature = self::generate_jwt_signature(
+ $base64_url_header,
+ $base64_url_payload,
+ $service_account_key->private_key
+ );
+ $base64_url_signature = base64_encode( $signature );
+
+ return $base64_url_header . '.' . $base64_url_payload . '.' . $base64_url_signature;
+ }
+
+ private static function generate_jwt_signature(
+ string $base64_url_header,
+ string $base64_url_payload,
+ string $private_key
+ ): string {
+ $signature_input = $base64_url_header . '.' . $base64_url_payload;
+
+ openssl_sign( $signature_input, $signature, $private_key, 'sha256' );
+ return $signature;
+ }
+
+ private static function generate_jwt_header(): array {
+ $header = [
+ 'alg' => 'RS256',
+ 'typ' => 'JWT',
+ ];
+
+ return $header;
+ }
+
+ private static function generate_jwt_payload(
+ string $client_email,
+ string $token_uri,
+ string $scope
+ ): array {
+ $now = time();
+ $expiry = $now + self::TOKEN_EXPIRY_SECONDS;
+
+ $payload = [
+ 'iss' => $client_email,
+ 'scope' => $scope,
+ 'aud' => $token_uri,
+ 'exp' => $expiry,
+ 'iat' => $now,
+ ];
+
+ return $payload;
+ }
+}
diff --git a/inc/config/auth/google-service-account-key/google-service-account-key.php b/inc/config/auth/google-service-account-key/google-service-account-key.php
new file mode 100644
index 00000000..bcb684fa
--- /dev/null
+++ b/inc/config/auth/google-service-account-key/google-service-account-key.php
@@ -0,0 +1,86 @@
+type = $raw_service_account_key['type'];
+ $this->project_id = $raw_service_account_key['project_id'];
+ $this->private_key_id = $raw_service_account_key['private_key_id'];
+ $this->private_key = $raw_service_account_key['private_key'];
+ $this->client_email = $raw_service_account_key['client_email'];
+ $this->client_id = $raw_service_account_key['client_id'];
+ $this->auth_uri = $raw_service_account_key['auth_uri'];
+ $this->token_uri = $raw_service_account_key['token_uri'];
+ $this->auth_provider_x509_cert_url = $raw_service_account_key['auth_provider_x509_cert_url'];
+ $this->client_x509_cert_url = $raw_service_account_key['client_x509_cert_url'];
+ $this->universe_domain = $raw_service_account_key['universe_domain'];
+ }
+
+ public static function from_array( array $raw_service_account_key ): WP_Error|GoogleServiceAccountKey {
+ $validation_error = self::validate( $raw_service_account_key );
+ if ( is_wp_error( $validation_error ) ) {
+ return new WP_Error(
+ 'invalid_service_account_key',
+ __( 'Invalid service account key:', 'remote-data-blocks' ) . ' ' . $validation_error->get_error_message(),
+ $validation_error
+ );
+ }
+
+ return new self( $raw_service_account_key );
+ }
+}
diff --git a/inc/plugin-settings/plugin-settings.php b/inc/plugin-settings/plugin-settings.php
index fd2d761b..f5bdb24a 100644
--- a/inc/plugin-settings/plugin-settings.php
+++ b/inc/plugin-settings/plugin-settings.php
@@ -3,6 +3,7 @@
namespace RemoteDataBlocks;
use RemoteDataBlocks\REST\DatasourceController;
+use RemoteDataBlocks\REST\AuthController;
use function wp_get_environment_type;
use function wp_is_development_mode;
@@ -37,6 +38,9 @@ public static function settings_page_content() {
public static function init_rest_routes() {
$controller = new DatasourceController();
$controller->register_routes();
+
+ $auth_controller = new AuthController();
+ $auth_controller->register_routes();
}
public static function enqueue_settings_assets( $admin_page ) {
diff --git a/inc/rest/auth-controller/auth-controller.php b/inc/rest/auth-controller/auth-controller.php
new file mode 100644
index 00000000..76dc2b5c
--- /dev/null
+++ b/inc/rest/auth-controller/auth-controller.php
@@ -0,0 +1,62 @@
+namespace = REMOTE_DATA_BLOCKS__REST_NAMESPACE;
+ $this->rest_base = 'auth';
+ }
+
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/google/token',
+ [
+ 'methods' => 'POST',
+ 'callback' => [ $this, 'get_google_auth_token' ],
+ 'permission_callback' => [ $this, 'get_google_auth_token_permissions_check' ],
+ ]
+ );
+ }
+
+ public function get_google_auth_token( WP_REST_Request $request ) {
+ $params = $request->get_json_params();
+ $credentials = $params['credentials'] ?? null;
+ $scopes = $params['scopes'] ?? [];
+ $type = $params['type'] ?? null;
+
+ if ( ! $credentials || ! $type || ! $scopes ) {
+ return new \WP_Error(
+ 'missing_parameters',
+ __( 'Credentials, type and scopes are required.', 'remote-data-blocks' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ if ( 'service_account' === $type ) {
+ $token = GoogleAuth::generate_token_from_service_account_key( $credentials, $scopes );
+ if ( is_wp_error( $token ) ) {
+ return rest_ensure_response( $token );
+ }
+ return rest_ensure_response( [ 'token' => $token ] );
+ }
+
+ return new \WP_Error(
+ 'invalid_type',
+ __( 'Invalid type. Supported types: service_account', 'remote-data-blocks' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ public function get_google_auth_token_permissions_check( $request ) {
+ return current_user_can( 'manage_options' );
+ }
+}
diff --git a/inc/rest/datasource-crud/datasource-crud.php b/inc/rest/datasource-crud/datasource-crud.php
index b41ddd31..11d446d9 100644
--- a/inc/rest/datasource-crud/datasource-crud.php
+++ b/inc/rest/datasource-crud/datasource-crud.php
@@ -2,11 +2,12 @@
namespace RemoteDataBlocks\REST;
+use RemoteDataBlocks\Config\Auth\GoogleServiceAccountKey;
use WP_Error;
class DatasourceCRUD {
const CONFIG_OPTION_NAME = 'remote_data_blocks_config';
- const DATA_SOURCE_TYPES = [ 'airtable', 'shopify' ];
+ const DATA_SOURCE_TYPES = [ 'airtable', 'shopify', 'google-sheets' ];
public static function is_uuid4( string $maybe_uuid ) {
return preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', $maybe_uuid );
@@ -94,6 +95,44 @@ public static function validate_shopify_source( $source ) {
];
}
+ public static function validate_google_sheets_source( $source ) {
+ $service_account_key = GoogleServiceAccountKey::from_array( $source->credentials );
+ if ( is_wp_error( $service_account_key ) ) {
+ return $service_account_key;
+ }
+
+ // Validate spreadsheet is not empty and is an object with id and name fields with string values
+ if ( empty( $source->spreadsheet ) ) {
+ return new WP_Error( 'missing_spreadsheet', __( 'Missing spreadsheet.', 'remote-data-blocks' ) );
+ }
+
+ if ( empty( $source->spreadsheet['id'] ) || empty( $source->spreadsheet['name'] ) ) {
+ return new WP_Error( 'invalid_spreadsheet', __( 'Invalid spreadsheet. Must have id and name fields.', 'remote-data-blocks' ) );
+ }
+
+ // Validate sheet is not empty and is an object with id integer and name string fields
+ if ( empty( $source->sheet ) ) {
+ return new WP_Error( 'missing_sheet', __( 'Missing sheet.', 'remote-data-blocks' ) );
+ }
+
+ if ( ! isset( $source->sheet['id'] ) || ! is_int( $source->sheet['id'] ) ) {
+ return new WP_Error( 'invalid_sheet', __( 'Invalid sheet. Must have id field with integer value.', 'remote-data-blocks' ) );
+ }
+
+ if ( empty( $source->sheet['name'] ) ) {
+ return new WP_Error( 'missing_sheet_name', __( 'Missing sheet name.', 'remote-data-blocks' ) );
+ }
+
+ return (object) [
+ 'uuid' => $source->uuid,
+ 'service' => 'google-sheets',
+ 'credentials' => $service_account_key,
+ 'spreadsheet' => $source->spreadsheet,
+ 'sheet' => $source->sheet,
+ 'slug' => sanitize_text_field( $source->slug ),
+ ];
+ }
+
public static function validate_source( $source ) {
if ( ! is_object( $source ) ) {
return new WP_Error( 'invalid_data_source', __( 'Invalid data source.', 'remote-data-blocks' ) );
@@ -123,6 +162,8 @@ public static function validate_source( $source ) {
return self::validate_airtable_source( $source );
case 'shopify':
return self::validate_shopify_source( $source );
+ case 'google-sheets':
+ return self::validate_google_sheets_source( $source );
default:
return new WP_Error( 'unsupported_data_source', __( 'Unsupported data source.', 'remote-data-blocks' ) );
}
diff --git a/src/data-sources/AddDataSourceModal.tsx b/src/data-sources/AddDataSourceModal.tsx
index ceb04375..cbc0726c 100644
--- a/src/data-sources/AddDataSourceModal.tsx
+++ b/src/data-sources/AddDataSourceModal.tsx
@@ -55,6 +55,7 @@ const AddDataSourceModal = ( { onSubmit }: AddDataSourceModalProps ) => {
{ label: __( 'Choose a Service' ), value: '' },
{ label: 'Airtable', value: 'airtable' },
{ label: 'Shopify', value: 'shopify' },
+ { label: 'Google Sheets', value: 'google-sheets' },
] }
onChange={ value => setSelectedService( value as DataSourceType ) }
/>
diff --git a/src/data-sources/DataSourceList.tsx b/src/data-sources/DataSourceList.tsx
index 5fedf350..38dfb667 100644
--- a/src/data-sources/DataSourceList.tsx
+++ b/src/data-sources/DataSourceList.tsx
@@ -14,10 +14,11 @@ import { __ } from '@wordpress/i18n';
import { Tag } from '@/components/tag';
import AddDataSourceModal from '@/data-sources/AddDataSourceModal';
+import { SUPPORTED_SERVICES } from '@/data-sources/constants';
import { useDataSources } from '@/data-sources/hooks/useDataSources';
import { DataSourceConfig, DataSourceType } from '@/data-sources/types';
import { useSettingsContext } from '@/settings/hooks/useSettingsNav';
-import { toTitleCase } from '@/utils/string';
+import { slugToTitleCase } from '@/utils/string';
const DataSourceList = () => {
const { dataSources, loadingDataSources, deleteDataSource, fetchDataSources } = useDataSources();
@@ -60,7 +61,7 @@ const DataSourceList = () => {
}
const getValidDataSources = () => {
- return dataSources.filter( source => [ 'airtable', 'shopify' ].includes( source.service ) );
+ return dataSources.filter( source => SUPPORTED_SERVICES.includes( source.service ) );
};
const renderDataSourceMeta = ( source: DataSourceConfig ) => {
@@ -81,6 +82,19 @@ const DataSourceList = () => {
);
}
+ if ( source.service === 'google-sheets' ) {
+ return (
+ <>
+
+
+ >
+ );
+ }
+
return null;
};
@@ -114,7 +128,7 @@ const DataSourceList = () => {
{ slug }
- { toTitleCase( service ) }
+ { slugToTitleCase( service ) }
|
{ renderDataSourceMeta( source ) }
diff --git a/src/data-sources/DataSourceSettings.tsx b/src/data-sources/DataSourceSettings.tsx
index eba0419c..d8f83a8d 100644
--- a/src/data-sources/DataSourceSettings.tsx
+++ b/src/data-sources/DataSourceSettings.tsx
@@ -1,6 +1,7 @@
import { __ } from '@wordpress/i18n';
import { AirtableSettings } from '@/data-sources/airtable/AirtableSettings';
+import { GoogleSheetsSettings } from '@/data-sources/google-sheets/GoogleSheetsSettings';
import { useDataSources } from '@/data-sources/hooks/useDataSources';
import { ShopifySettings } from '@/data-sources/shopify/ShopifySettings';
import { useSettingsContext } from '@/settings/hooks/useSettingsNav';
@@ -24,6 +25,12 @@ const DataSourceEditSettings = ( { uuid }: DataSourceEditSettings ) => {
if ( 'shopify' === dataSource.service ) {
return ;
}
+
+ if ( 'google-sheets' === dataSource.service ) {
+ return ;
+ }
+
+ return <>{ __( 'Service not (yet) supported.', 'remote-data-blocks' ) }>;
};
const DataSourceSettings = () => {
@@ -37,6 +44,9 @@ const DataSourceSettings = () => {
if ( 'shopify' === service ) {
return ;
}
+ if ( 'google-sheets' === service ) {
+ return ;
+ }
return <>{ __( 'Service not (yet) supported.', 'remote-data-blocks' ) }>;
}
diff --git a/src/data-sources/airtable/AirtableSettings.tsx b/src/data-sources/airtable/AirtableSettings.tsx
index 94277a29..e10c0c87 100644
--- a/src/data-sources/airtable/AirtableSettings.tsx
+++ b/src/data-sources/airtable/AirtableSettings.tsx
@@ -24,7 +24,7 @@ import { AirtableConfig } from '@/data-sources/types';
import { useForm } from '@/hooks/useForm';
import PasswordInputControl from '@/settings/PasswordInputControl';
import { useSettingsContext } from '@/settings/hooks/useSettingsNav';
-import { IdName } from '@/types/common';
+import { StringIdName } from '@/types/common';
import { SelectOption } from '@/types/input';
export interface AirtableSettingsProps {
@@ -122,7 +122,7 @@ export const AirtableSettings = ( {
) => {
if ( extra?.event ) {
const { id } = extra.event.target;
- let newValue: IdName | null = null;
+ let newValue: StringIdName | null = null;
if ( id === 'base' ) {
const selectedBase = bases?.find( base => base.id === value );
newValue = { id: value, name: selectedBase?.name ?? '' };
diff --git a/src/data-sources/api/auth.ts b/src/data-sources/api/auth.ts
new file mode 100644
index 00000000..d0503ab5
--- /dev/null
+++ b/src/data-sources/api/auth.ts
@@ -0,0 +1,23 @@
+import apiFetch from '@wordpress/api-fetch';
+
+import { REST_BASE_AUTH } from '@/data-sources/constants';
+import { GoogleServiceAccountKey } from '@/types/google';
+
+export async function getGoogleAuthTokenFromServiceAccount(
+ serviceAccountKey: GoogleServiceAccountKey,
+ scopes: string[]
+): Promise< string > {
+ const requestBody = {
+ type: serviceAccountKey.type,
+ scopes,
+ credentials: serviceAccountKey,
+ };
+
+ const response = await apiFetch< { token: string } >( {
+ path: `${ REST_BASE_AUTH }/google/token`,
+ method: 'POST',
+ data: requestBody,
+ } );
+
+ return response.token;
+}
diff --git a/src/data-sources/api/google.ts b/src/data-sources/api/google.ts
new file mode 100644
index 00000000..1b5d8cf6
--- /dev/null
+++ b/src/data-sources/api/google.ts
@@ -0,0 +1,69 @@
+import { __, sprintf } from '@wordpress/i18n';
+
+import { GoogleSpreadsheet, GoogleDriveFileList, GoogleDriveFile } from '@/types/google';
+import { SelectOption } from '@/types/input';
+
+export class GoogleApi {
+ private static SHEETS_BASE_URL = 'https://sheets.googleapis.com/v4';
+ private static DRIVE_BASE_URL = 'https://www.googleapis.com/drive/v3';
+
+ constructor( private token: string | null ) {}
+
+ private getAuthHeaders() {
+ if ( ! this.token ) {
+ throw new Error( 'No token provided' );
+ }
+
+ return {
+ Authorization: `Bearer ${ this.token }`,
+ };
+ }
+
+ private async fetchApi< T >( url: string, options: RequestInit = {} ): Promise< T > {
+ const response = await fetch( url, {
+ ...options,
+ headers: {
+ ...( options.headers ?? {} ),
+ ...this.getAuthHeaders(),
+ },
+ } );
+
+ if ( ! response.ok ) {
+ const errorText = `${ response.status } - ${ await response.text() }`;
+ throw new Error( `[Google API] ${ sprintf( __( 'Error: %s' ), errorText ) }` );
+ }
+
+ return response.json() as Promise< T >;
+ }
+
+ private async getSpreadsheetList(): Promise< GoogleDriveFile[] > {
+ const spreadsheetsMimeType = 'application/vnd.google-apps.spreadsheet';
+ const query = `mimeType='${ spreadsheetsMimeType }'`;
+ const url = `${ GoogleApi.DRIVE_BASE_URL }/files?q=${ encodeURIComponent( query ) }`;
+ const result = await this.fetchApi< GoogleDriveFileList >( url );
+
+ return result.files ?? [];
+ }
+
+ public async getSpreadsheetsOptions(): Promise< SelectOption[] > {
+ const spreadsheets = await this.getSpreadsheetList();
+ return spreadsheets.map( spreadsheet => ( {
+ label: spreadsheet.name,
+ value: spreadsheet.id,
+ } ) );
+ }
+
+ public async getSpreadsheet( spreadsheetId: string ): Promise< GoogleSpreadsheet > {
+ const url = `${ GoogleApi.SHEETS_BASE_URL }/spreadsheets/${ spreadsheetId }`;
+ const result = await this.fetchApi< GoogleSpreadsheet >( url );
+ return result;
+ }
+
+ public async getSheetsOptions( spreadsheetId: string ): Promise< SelectOption[] > {
+ const spreadsheet = await this.getSpreadsheet( spreadsheetId );
+ return spreadsheet.sheets.map( sheet => ( {
+ label: sheet.properties.title,
+ value: sheet.properties.sheetId.toString(),
+ } ) );
+ }
+}
diff --git a/src/data-sources/constants.ts b/src/data-sources/constants.ts
index e51f1013..67294388 100644
--- a/src/data-sources/constants.ts
+++ b/src/data-sources/constants.ts
@@ -1,4 +1,12 @@
-export const SUPPORTED_SERVICES = [ 'airtable', 'shopify' ] as const;
+export const SUPPORTED_SERVICES = [ 'airtable', 'shopify', 'google-sheets' ] as const;
export const OPTIONS_PAGE_SLUG = 'remote-data-blocks-settings';
export const REST_BASE = '/remote-data-blocks/v1';
export const REST_BASE_DATA_SOURCES = `${ REST_BASE }/data-sources`;
+export const REST_BASE_AUTH = `${ REST_BASE }/auth`;
+/**
+ * Google API scopes for Google Sheets and Google Drive (to list spreadsheets)
+ */
+export const GOOGLE_SHEETS_API_SCOPES = [
+ 'https://www.googleapis.com/auth/drive.readonly',
+ 'https://www.googleapis.com/auth/spreadsheets.readonly',
+];
diff --git a/src/data-sources/google-sheets/GoogleSheetsSettings.tsx b/src/data-sources/google-sheets/GoogleSheetsSettings.tsx
new file mode 100644
index 00000000..3554917d
--- /dev/null
+++ b/src/data-sources/google-sheets/GoogleSheetsSettings.tsx
@@ -0,0 +1,329 @@
+import {
+ Button,
+ ButtonGroup,
+ TextareaControl,
+ __experimentalHeading as Heading,
+ SelectControl,
+ Panel,
+ PanelBody,
+ PanelRow,
+} from '@wordpress/components';
+import { useEffect, useMemo, useState } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import { ChangeEvent } from 'react';
+
+import { SlugInput } from '@/data-sources/SlugInput';
+import { GOOGLE_SHEETS_API_SCOPES } from '@/data-sources/constants';
+import { GoogleSheetsFormState } from '@/data-sources/google-sheets/types';
+import { useDataSources } from '@/data-sources/hooks/useDataSources';
+import {
+ useGoogleSpreadsheetsOptions,
+ useGoogleSheetsOptions,
+} from '@/data-sources/hooks/useGoogleApi';
+import { useGoogleAuth } from '@/data-sources/hooks/useGoogleAuth';
+import { GoogleSheetsConfig } from '@/data-sources/types';
+import { useForm, ValidationRules } from '@/hooks/useForm';
+import { useSettingsContext } from '@/settings/hooks/useSettingsNav';
+import { StringIdName } from '@/types/common';
+import { GoogleServiceAccountKey } from '@/types/google';
+import { SelectOption } from '@/types/input';
+
+export interface GoogleSheetsSettingsProps {
+ mode: 'add' | 'edit';
+ uuid?: string;
+ config?: GoogleSheetsConfig;
+}
+
+const initialState: GoogleSheetsFormState = {
+ slug: '',
+ spreadsheet: null,
+ sheet: null,
+ credentials: '',
+};
+
+const getInitialStateFromConfig = ( config?: GoogleSheetsConfig ): GoogleSheetsFormState => {
+ if ( ! config ) {
+ return initialState;
+ }
+
+ return {
+ slug: config.slug,
+ spreadsheet: config.spreadsheet,
+ sheet: config.sheet
+ ? {
+ id: config.sheet.id.toString(),
+ name: config.sheet.name,
+ }
+ : null,
+ credentials: JSON.stringify( config.credentials ),
+ };
+};
+
+const defaultSelectOption: SelectOption = {
+ label: '',
+ value: '',
+};
+
+const validationRules: ValidationRules< GoogleSheetsFormState > = {
+ credentials: ( state: GoogleSheetsFormState ) => {
+ if ( ! state.credentials ) {
+ return __(
+ 'Please provide credentials JSON for the service account to connect to Google Sheets.',
+ 'remote-data-blocks'
+ );
+ }
+
+ try {
+ JSON.parse( state.credentials );
+ } catch ( error ) {
+ return __( 'Credentials are not valid JSON', 'remote-data-blocks' );
+ }
+ return null;
+ },
+};
+
+export const GoogleSheetsSettings = ( {
+ mode,
+ uuid: uuidFromProps,
+ config,
+}: GoogleSheetsSettingsProps ) => {
+ const { goToMainScreen } = useSettingsContext();
+ const { updateDataSource, addDataSource, slugConflicts, loadingSlugConflicts } =
+ useDataSources( false );
+
+ const { state, errors, handleOnChange } = useForm< GoogleSheetsFormState >( {
+ initialValues: getInitialStateFromConfig( config ),
+ validationRules,
+ } );
+
+ const [ spreadsheetOptions, setSpreadsheetOptions ] = useState< SelectOption[] >( [
+ defaultSelectOption,
+ ] );
+ const [ sheetOptions, setSheetOptions ] = useState< SelectOption[] >( [ defaultSelectOption ] );
+
+ const { fetchingToken, token, tokenError } = useGoogleAuth(
+ state.credentials,
+ GOOGLE_SHEETS_API_SCOPES
+ );
+ const { spreadsheets, isLoadingSpreadsheets, errorSpreadsheets } =
+ useGoogleSpreadsheetsOptions( token );
+ const { sheets, isLoadingSheets, errorSheets } = useGoogleSheetsOptions(
+ token,
+ state.spreadsheet?.id ?? ''
+ );
+
+ const handleSaveError = ( error: unknown ) => {
+ console.error( error );
+ };
+
+ const onSaveClick = () => {
+ if ( ! state.spreadsheet || ! state.sheet || ! state.credentials ) {
+ // TODO: Error handling
+ return;
+ }
+
+ const data: GoogleSheetsConfig = {
+ uuid: uuidFromProps ?? '',
+ service: 'google-sheets',
+ slug: state.slug,
+ spreadsheet: state.spreadsheet,
+ sheet: {
+ id: parseInt( state.sheet.id, 10 ),
+ name: state.sheet.name,
+ },
+ credentials: JSON.parse( state.credentials ) as GoogleServiceAccountKey,
+ };
+
+ if ( mode === 'add' ) {
+ void addDataSource( data ).then( goToMainScreen ).catch( handleSaveError );
+ }
+ void updateDataSource( data ).then( goToMainScreen ).catch( handleSaveError );
+ };
+
+ const onCredentialsChange = ( nextValue: string ) => {
+ handleOnChange( 'credentials', nextValue );
+ };
+
+ const onSelectChange = (
+ value: string,
+ extra?: { event?: ChangeEvent< HTMLSelectElement > }
+ ) => {
+ if ( extra?.event ) {
+ const { id } = extra.event.target;
+ let newValue: StringIdName | null = null;
+ if ( id === 'spreadsheet' ) {
+ const selectedSpreadsheet = spreadsheets?.find(
+ spreadsheet => spreadsheet.value === value
+ );
+ newValue = { id: value, name: selectedSpreadsheet?.label ?? '' };
+ } else if ( id === 'sheet' ) {
+ const selectedSheet = sheets?.find( sheet => sheet.value === value );
+ newValue = { id: value, name: selectedSheet?.label ?? '' };
+ }
+ handleOnChange( id, newValue );
+ }
+ };
+
+ /**
+ * Handle the slug change. Only accepts valid slugs which only contain alphanumeric characters and dashes.
+ * @param slug The slug to set.
+ */
+ const onSlugChange = ( slug: string | undefined ) => {
+ handleOnChange( 'slug', slug ?? '' );
+ };
+
+ const credentialsHelpText = useMemo( () => {
+ if ( fetchingToken ) {
+ return __( 'Checking credentials...', 'remote-data-blocks' );
+ } else if ( errors.credentials ) {
+ return errors.credentials;
+ } else if ( tokenError ) {
+ const errorMessage = tokenError.message ?? __( 'Unknown error', 'remote-data-blocks' );
+ return (
+ __( 'Failed to generate token using provided credentials: ', 'remote-data-blocks' ) +
+ ' ' +
+ errorMessage
+ );
+ } else if ( token ) {
+ return sprintf(
+ __( 'Credentials are valid. Token generated successfully.', 'remote-data-blocks' ),
+ token
+ );
+ }
+ return __(
+ 'Please provide credentials JSON to connect to Google Sheets.',
+ 'remote-data-blocks'
+ );
+ }, [ fetchingToken, token, tokenError, errors.credentials ] );
+
+ const shouldAllowSubmit = useMemo( () => {
+ return (
+ ! state.spreadsheet ||
+ ! state.sheet ||
+ ! state.credentials ||
+ loadingSlugConflicts ||
+ slugConflicts
+ );
+ }, [ state.spreadsheet, state.sheet, state.credentials, loadingSlugConflicts, slugConflicts ] );
+
+ const spreadsheetHelpText = useMemo( () => {
+ if ( token ) {
+ if ( errorSpreadsheets ) {
+ const errorMessage =
+ errorSpreadsheets?.message ?? __( 'Unknown error', 'remote-data-blocks' );
+ return __( 'Failed to fetch spreadsheets.', 'remote-data-blocks' ) + ' ' + errorMessage;
+ } else if ( isLoadingSpreadsheets ) {
+ return __( 'Fetching spreadsheets...', 'remote-data-blocks' );
+ } else if ( spreadsheets ) {
+ if ( state.spreadsheet ) {
+ const selectedSpreadsheet = spreadsheets.find(
+ spreadsheet => spreadsheet.value === state.spreadsheet?.id
+ );
+ return sprintf(
+ __( 'Selected spreadsheet: %s | id: %s', 'remote-data-blocks' ),
+ selectedSpreadsheet?.label ?? '',
+ selectedSpreadsheet?.value ?? ''
+ );
+ }
+ if ( spreadsheets.length ) {
+ return '';
+ }
+ return __( 'No spreadsheets found', 'remote-data-blocks' );
+ }
+ return '';
+ }
+ }, [ token, errorSpreadsheets, isLoadingSpreadsheets, state.spreadsheet, spreadsheets ] );
+
+ const sheetHelpText = useMemo( () => {
+ if ( token ) {
+ if ( errorSheets ) {
+ const errorMessage = errorSheets?.message ?? __( 'Unknown error', 'remote-data-blocks' );
+ return __( 'Failed to fetch sheets.', 'remote-data-blocks' ) + ' ' + errorMessage;
+ } else if ( isLoadingSheets ) {
+ return __( 'Fetching sheets...', 'remote-data-blocks' );
+ } else if ( sheets ) {
+ if ( state.sheet ) {
+ const selectedSheet = sheets.find( sheet => sheet.value === state.sheet?.id );
+ return sprintf(
+ __( 'Selected sheet: %s | id: %s', 'remote-data-blocks' ),
+ selectedSheet?.label ?? '',
+ selectedSheet?.value ?? ''
+ );
+ }
+ if ( sheets.length ) {
+ return '';
+ }
+ return __( 'No sheets found', 'remote-data-blocks' );
+ }
+ return '';
+ }
+ }, [ token, errorSheets, isLoadingSheets, state.sheet, sheets ] );
+
+ useEffect( () => {
+ setSpreadsheetOptions( [
+ defaultSelectOption,
+ ...( spreadsheets ?? [] ).map( ( { label, value } ) => ( { label, value } ) ),
+ ] );
+ }, [ spreadsheets ] );
+
+ useEffect( () => {
+ setSheetOptions( [
+ defaultSelectOption,
+ ...( sheets ?? [] ).map( ( { label, value } ) => ( { label, value } ) ),
+ ] );
+ }, [ sheets ] );
+
+ return (
+
+
+
+ { mode === 'add'
+ ? __( 'Add a new Google Sheets Data Source' )
+ : __( 'Edit Google Sheets Data Source' ) }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/data-sources/google-sheets/types.ts b/src/data-sources/google-sheets/types.ts
new file mode 100644
index 00000000..645b505e
--- /dev/null
+++ b/src/data-sources/google-sheets/types.ts
@@ -0,0 +1,10 @@
+import { GoogleSheetsConfig } from '@/data-sources/types';
+import { StringIdName } from '@/types/common';
+
+export type GoogleSheetsFormState = NullableKeys<
+ Omit< GoogleSheetsConfig, 'service' | 'uuid' | 'sheet' | 'credentials' >,
+ 'spreadsheet'
+> & {
+ sheet: StringIdName | null;
+ credentials: string;
+};
diff --git a/src/data-sources/hooks/useGoogleApi.tsx b/src/data-sources/hooks/useGoogleApi.tsx
new file mode 100644
index 00000000..1158aa42
--- /dev/null
+++ b/src/data-sources/hooks/useGoogleApi.tsx
@@ -0,0 +1,51 @@
+import { useDebounce } from '@wordpress/compose';
+import { useEffect, useMemo, useCallback } from '@wordpress/element';
+
+import { GoogleApi } from '@/data-sources/api/google';
+import { useQuery } from '@/hooks/useQuery';
+
+export const useGoogleSpreadsheetsOptions = ( token: string | null ) => {
+ const api = useMemo( () => new GoogleApi( token ), [ token ] );
+
+ const queryFn = useCallback( async () => {
+ if ( ! token ) {
+ return null;
+ }
+ return api.getSpreadsheetsOptions();
+ }, [ api, token ] );
+
+ const {
+ data: spreadsheets,
+ isLoading: isLoadingSpreadsheets,
+ error: errorSpreadsheets,
+ refetch: refetchSpreadsheets,
+ } = useQuery( queryFn, { manualFetchOnly: true } );
+
+ const debouncedFetchSpreadsheets = useDebounce( refetchSpreadsheets, 500 );
+ useEffect( debouncedFetchSpreadsheets, [ token, debouncedFetchSpreadsheets ] );
+
+ return { spreadsheets, isLoadingSpreadsheets, errorSpreadsheets, refetchSpreadsheets };
+};
+
+export const useGoogleSheetsOptions = ( token: string | null, spreadsheetId: string ) => {
+ const api = useMemo( () => new GoogleApi( token ), [ token ] );
+
+ const queryFn = useCallback( async () => {
+ if ( ! token || ! spreadsheetId ) {
+ return null;
+ }
+ return api.getSheetsOptions( spreadsheetId );
+ }, [ api, token, spreadsheetId ] );
+
+ const {
+ data: sheets,
+ isLoading: isLoadingSheets,
+ error: errorSheets,
+ refetch: refetchSheets,
+ } = useQuery( queryFn, { manualFetchOnly: true } );
+
+ const debouncedFetchSheets = useDebounce( refetchSheets, 500 );
+ useEffect( debouncedFetchSheets, [ token, debouncedFetchSheets ] );
+
+ return { sheets, isLoadingSheets, errorSheets, refetchSheets };
+};
diff --git a/src/data-sources/hooks/useGoogleAuth.tsx b/src/data-sources/hooks/useGoogleAuth.tsx
new file mode 100644
index 00000000..f2918162
--- /dev/null
+++ b/src/data-sources/hooks/useGoogleAuth.tsx
@@ -0,0 +1,33 @@
+import { useDebounce } from '@wordpress/compose';
+import { useEffect, useCallback, useMemo } from '@wordpress/element';
+
+import { getGoogleAuthTokenFromServiceAccount } from '@/data-sources/api/auth';
+import { useQuery } from '@/hooks/useQuery';
+import { GoogleServiceAccountKey } from '@/types/google';
+import { safeParseJSON } from '@/utils/string';
+
+export const useGoogleAuth = ( serviceAccountKeyString: string, scopes: string[] ) => {
+ const serviceAccountKey = useMemo( () => {
+ return safeParseJSON< GoogleServiceAccountKey >( serviceAccountKeyString );
+ }, [ serviceAccountKeyString ] );
+
+ const queryFn = useCallback( async () => {
+ if ( ! serviceAccountKey ) {
+ return null;
+ }
+ return getGoogleAuthTokenFromServiceAccount( serviceAccountKey, scopes );
+ }, [ serviceAccountKey, scopes ] );
+
+ const {
+ data: token,
+ isLoading: fetchingToken,
+ error: tokenError,
+ refetch: fetchToken,
+ } = useQuery( queryFn, { manualFetchOnly: true } );
+
+ const debouncedFetchToken = useDebounce( fetchToken, 500 );
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ useEffect( debouncedFetchToken, [ serviceAccountKeyString ] );
+
+ return { token, fetchingToken, fetchToken, tokenError };
+};
diff --git a/src/data-sources/types.ts b/src/data-sources/types.ts
index f3c1ead8..c8da2556 100644
--- a/src/data-sources/types.ts
+++ b/src/data-sources/types.ts
@@ -1,5 +1,6 @@
import { SUPPORTED_SERVICES } from '@/data-sources/constants';
-import { IdName } from '@/types/common';
+import { NumberIdName, StringIdName } from '@/types/common';
+import { GoogleServiceAccountKey } from '@/types/google';
export type DataSourceType = ( typeof SUPPORTED_SERVICES )[ number ];
@@ -25,8 +26,8 @@ export interface DataSourceQuery {
export interface AirtableConfig extends BaseDataSourceConfig {
service: 'airtable';
token: string;
- base: IdName;
- table: IdName;
+ base: StringIdName;
+ table: StringIdName;
}
export interface ShopifyConfig extends BaseDataSourceConfig {
@@ -35,4 +36,11 @@ export interface ShopifyConfig extends BaseDataSourceConfig {
token: string;
}
-export type DataSourceConfig = AirtableConfig | ShopifyConfig;
+export interface GoogleSheetsConfig extends BaseDataSourceConfig {
+ service: 'google-sheets';
+ credentials: GoogleServiceAccountKey;
+ spreadsheet: StringIdName;
+ sheet: NumberIdName;
+}
+
+export type DataSourceConfig = AirtableConfig | ShopifyConfig | GoogleSheetsConfig;
diff --git a/src/hooks/useForm.tsx b/src/hooks/useForm.tsx
index 59e10aba..0059073a 100644
--- a/src/hooks/useForm.tsx
+++ b/src/hooks/useForm.tsx
@@ -2,25 +2,18 @@ import { useReducer, useState } from '@wordpress/element';
import { isNonEmptyObj, constructObjectWithValues } from '@/utils/object';
-type ValidationRuleFn = ( v: unknown ) => string | null;
-
-const executeValidationRules = (
- rules: ValidationRuleFn[],
- // eslint-disable-next-line
- value: any
-): string | null => {
- let error: string | null = null;
- if ( rules ) {
- rules.some( ( rule ): boolean => {
- const err: string | null = rule( value );
- if ( err ) {
- error = err;
- return true;
- }
- return false;
- } );
+export type ValidationRuleFn< T > = ( v: T ) => string | null;
+
+export type ValidationRules< T > = {
+ [ P in keyof T ]?: ValidationRuleFn< T >;
+};
+
+const executeValidationRules = < T, >( rule: ValidationRuleFn< T >, value: T ): string | null => {
+ const error = rule( value );
+ if ( error ) {
+ return error;
}
- return error;
+ return null;
};
interface ExecuteAllValidationRules {
@@ -28,9 +21,9 @@ interface ExecuteAllValidationRules {
hasError: boolean;
}
-const executeAllValidationRules = (
- validationRules: { [ x: string ]: ValidationRuleFn[] },
- values: { [ x: string ]: unknown }
+const executeAllValidationRules = < T, >(
+ validationRules: ValidationRules< T >,
+ values: T
): ExecuteAllValidationRules => {
const errorsMap = new Map< string, string | null >();
let hasError = false;
@@ -38,7 +31,7 @@ const executeAllValidationRules = (
Object.entries( validationRules ).forEach( ( [ ruleId, rule ] ) => {
if ( Object.prototype.hasOwnProperty.call( values, ruleId ) ) {
// eslint-disable-next-line security/detect-object-injection
- const error = executeValidationRules( rule, values[ ruleId ] );
+ const error = executeValidationRules< T >( rule as ValidationRuleFn< T >, values );
errorsMap.set( ruleId, error );
if ( ! hasError && error !== null ) {
@@ -67,7 +60,7 @@ export interface ValidationFnResponse {
interface UseFormProps< T > {
initialValues: T;
- validationRules?: { [ x: string ]: ValidationRuleFn[] };
+ validationRules?: ValidationRules< T >;
submit?: ( state: T, resetForm: () => void ) => void;
submitValidationFn?: ( state: T ) => ValidationFnResponse;
}
@@ -89,7 +82,7 @@ const reducer = < T, >( state: T, action: FormAction< T > ): T => {
export const useForm = < T extends Record< string, unknown > >( {
initialValues,
- validationRules = {},
+ validationRules = {} as ValidationRules< T >,
submit,
submitValidationFn,
}: UseFormProps< T > ): UseForm< T > => {
@@ -120,7 +113,7 @@ export const useForm = < T extends Record< string, unknown > >( {
setErrors( {
...errors,
[ id ]: executeValidationRules(
- validationRules[ id as keyof typeof validationRules ] ?? [],
+ validationRules[ id as keyof typeof validationRules ] ?? ( () => null ),
{ ...state, [ id ]: value }
),
} );
@@ -132,7 +125,7 @@ export const useForm = < T extends Record< string, unknown > >( {
setErrors( {
...errors,
[ id ]: executeValidationRules(
- validationRules[ id as keyof typeof validationRules ] ?? [],
+ validationRules[ id as keyof typeof validationRules ] ?? ( () => null ),
state
),
} );
diff --git a/src/settings/index.scss b/src/settings/index.scss
index dc03351c..4765bb75 100644
--- a/src/settings/index.scss
+++ b/src/settings/index.scss
@@ -17,6 +17,10 @@
min-width: 290px;
}
+ .components-textarea-control__input {
+ min-width: 332px;
+ }
+
.components-select-control__input {
min-width: 332px;
}
diff --git a/src/types/common.ts b/src/types/common.ts
index c2c8ad31..e0857e23 100644
--- a/src/types/common.ts
+++ b/src/types/common.ts
@@ -1,4 +1,9 @@
-export interface IdName {
+export interface StringIdName {
name: string;
id: string;
}
+
+export interface NumberIdName {
+ name: string;
+ id: number;
+}
diff --git a/src/types/google.ts b/src/types/google.ts
new file mode 100644
index 00000000..e3b5b9e0
--- /dev/null
+++ b/src/types/google.ts
@@ -0,0 +1,76 @@
+// Google Drive
+export interface GoogleDriveFile {
+ kind: 'drive#file';
+ mimeType: string;
+ id: string;
+ name: string;
+}
+
+export interface GoogleDriveFileList {
+ kind: 'drive#fileList';
+ incompleteSearch: boolean;
+ files: GoogleDriveFile[];
+}
+
+// Spreadsheet related interfaces and types
+
+export interface GoogleSpreadsheet {
+ spreadsheetId: string;
+ properties: GoogleSpreadsheetProperties;
+ sheets: GoogleSheet[];
+ spreadsheetUrl: string;
+}
+
+interface GoogleSpreadsheetProperties {
+ title: string;
+ locale: string;
+ timeZone: string;
+ autoRecalc?: RecalculationInterval;
+ defaultFormat?: Record< string, unknown >;
+ iterativeCalculationSettings?: Record< string, unknown >;
+ spreadsheetTheme?: Record< string, unknown >;
+}
+
+type RecalculationInterval = 'RECALCULATION_INTERVAL_UNSPECIFIED' | 'ON_CHANGE' | 'MINUTE' | 'HOUR';
+
+// Sheet related interfaces
+
+interface GoogleSheet {
+ properties: GoogleSheetProperties;
+}
+
+interface GoogleSheetProperties {
+ sheetId: number;
+ title: string;
+ index: number;
+ sheetType: SheetType;
+ gridProperties?: GridProperties;
+ hidden?: boolean;
+ tabColor?: Record< string, unknown >;
+ rightToLeft?: boolean;
+ dataSourceSheetProperties?: Record< string, unknown >;
+}
+
+type SheetType = 'SHEET_TYPE_UNSPECIFIED' | 'GRID' | 'OBJECT' | 'DATA_SOURCE';
+
+interface GridProperties {
+ rowCount: number;
+ columnCount: number;
+ frozenRowCount?: number;
+ frozenColumnCount?: number;
+ hideGridlines?: boolean;
+ rowGroupControlAfter?: boolean;
+ columnGroupControlAfter?: boolean;
+}
+
+// Service Account
+export interface GoogleServiceAccountKey {
+ [ key: string ]: string;
+ type: 'service_account';
+ project_id: string;
+ private_key: string;
+ client_email: string;
+ token_uri: string;
+}
+
+export type GoogleCredentials = GoogleServiceAccountKey;
diff --git a/src/utils/string.ts b/src/utils/string.ts
index 094c01ea..177aaf12 100644
--- a/src/utils/string.ts
+++ b/src/utils/string.ts
@@ -27,3 +27,32 @@ export function toTitleCase( str: string ): string {
return txt.charAt( 0 ).toUpperCase() + txt.substring( 1 ).toLowerCase();
} );
}
+
+export const slugToTitleCase = ( slug: string ): string => {
+ return slug.replace( /-/g, ' ' ).replace( /\b\w/g, char => char.toUpperCase() );
+};
+
+/**
+ * Casts a string to JSON
+ * @param value string to cast
+ * @returns parsed JSON or null
+ */
+export function safeParseJSON< T = unknown >( value: unknown ): T | null {
+ if ( 'undefined' === typeof value || null === value ) {
+ return null;
+ }
+
+ if ( 'string' === typeof value && value.trim().length === 0 ) {
+ return null;
+ }
+
+ if ( 'string' === typeof value ) {
+ try {
+ return JSON.parse( value ) as T;
+ } catch ( error ) {
+ return null;
+ }
+ }
+
+ return null;
+}
|