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; +}