Back

100% Statements 21/21
92% Branches 23/25
100% Functions 6/6
100% Lines 21/21

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221                                                                                          77x   77x 3x     74x                                               79x 79x 79x 79x 79x           79x   79x 2x 2x   2x                       1x                 1x           2x       79x 1x                                       78x   78x                                                                                                                                                      
import apiFetch from '@wordpress/api-fetch';
import {
	Button,
	CheckboxControl,
	RadioControl,
	TextControl,
	__experimentalVStack as VStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis
} from '@wordpress/components';
import { useState, useCallback } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { Link, Notice } from '@wordpress/ui';
 
export type GoogleSheetsData = {
	enabled?: boolean;
	spreadsheetId?: string;
	spreadsheetUrl?: string;
	columns?: string[];
	userId?: number;
};
 
type Props = {
	googleSheetsData: GoogleSheetsData;
	setGoogleSheetsData: ( data: GoogleSheetsData ) => void;
	formPostId: number;
	formTitle: string;
};
 
type SetupResponse = {
	spreadsheetId: string;
	spreadsheetUrl: string;
	columns: string[];
	userId: number;
};
 
/**
 * Whether a string looks like a Google Sheets document reference.
 *
 * Mirrors Google_Drive::extract_sheet_id() on the server, which is what actually
 * decides. This is only here to keep the user from submitting something the
 * server will certainly reject.
 *
 * @param reference - A pasted spreadsheet URL or bare ID.
 * @return Whether it looks like a spreadsheet reference.
 */
export function looksLikeSpreadsheetReference( reference: string ): boolean {
	const trimmed = ( reference || '' ).trim();
 
	if ( ! trimmed ) {
		return false;
	}
 
	return (
		/\/spreadsheets\/d\/[a-zA-Z0-9_-]+/.test( trimmed ) || /^[a-zA-Z0-9_-]{10,}$/.test( trimmed )
	);
}
 
/**
 * Per-form controls for syncing responses to a Google Spreadsheet.
 *
 * Rendered only once the site has a Google Drive connection; before that the
 * card shows its connect button instead.
 *
 * @param props                     - Component props.
 * @param props.googleSheetsData    - The form's current sync configuration.
 * @param props.setGoogleSheetsData - Persists a change to that configuration.
 * @param props.formPostId          - The post the form is edited in, used to scope backfill and read field labels.
 * @param props.formTitle           - The form's title, used to name a new spreadsheet.
 * @return The sync controls.
 */
export default function GoogleSheetsSyncControls( {
	googleSheetsData,
	setGoogleSheetsData,
	formPostId,
	formTitle,
}: Props ) {
	const [ mode, setMode ] = useState( 'create' );
	const [ spreadsheetUrl, setSpreadsheetUrl ] = useState( '' );
	const [ backfill, setBackfill ] = useState( true );
	const [ isSettingUp, setIsSettingUp ] = useState( false );
	const [ error, setError ] = useState( '' );
 
	// Both the header toggle and the server-side read require `enabled` as well,
	// so keying only off the spreadsheet would leave the card claiming responses
	// are being added after syncing has been switched off - an easy state to
	// reach, since toggling off keeps the spreadsheet.
	const isConfigured = !! googleSheetsData?.spreadsheetId && !! googleSheetsData?.enabled;
 
	const handleSetup = useCallback( () => {
		setError( '' );
		setIsSettingUp( true );
 
		apiFetch< SetupResponse >( {
			path: '/wp/v2/feedback/integrations/google-sheets/setup',
			method: 'POST',
			data: {
				form_post_id: formPostId,
				title: formTitle,
				mode,
				spreadsheet_url: mode === 'existing' ? spreadsheetUrl : '',
				backfill,
			},
		} )
			.then( ( result: SetupResponse ) => {
				setGoogleSheetsData( {
					enabled: true,
					spreadsheetId: result.spreadsheetId,
					spreadsheetUrl: result.spreadsheetUrl,
					columns: result.columns,
					userId: result.userId,
				} );
			} )
			.catch( ( requestError: { message?: string } ) => {
				setError(
					requestError?.message ||
						__( 'The spreadsheet could not be set up. Please try again.', 'jetpack-forms' )
				);
			} )
			.finally( () => {
				setIsSettingUp( false );
			} );
	}, [ formPostId, formTitle, mode, spreadsheetUrl, backfill, setGoogleSheetsData ] );
 
	if ( isConfigured ) {
		return (
			<VStack spacing="3" className="jp-forms__gsheets-sync">
				<p className="integration-card__description">
					{ __(
						'New responses to this form are added to your spreadsheet as they arrive.',
						'jetpack-forms'
					) }
				</p>
				{ googleSheetsData.spreadsheetUrl && (
					<div>
						<Link openInNewTab href={ googleSheetsData.spreadsheetUrl }>
							{ __( 'Open spreadsheet', 'jetpack-forms' ) }
						</Link>
					</div>
				) }
			</VStack>
		);
	}
 
	const canSubmit =
		! isSettingUp && ( mode === 'create' || looksLikeSpreadsheetReference( spreadsheetUrl ) );
 
	return (
		<VStack spacing="4" className="jp-forms__gsheets-sync">
			<p className="integration-card__description">
				{ __(
					'Send each new response to a Google Spreadsheet, as well as keeping it here.',
					'jetpack-forms'
				) }
			</p>
 
			<VStack spacing="3">
				<RadioControl
					label={ __( 'Spreadsheet', 'jetpack-forms' ) }
					selected={ mode }
					options={ [
						{ label: __( 'Create a new spreadsheet', 'jetpack-forms' ), value: 'create' },
						{ label: __( 'Use an existing spreadsheet', 'jetpack-forms' ), value: 'existing' },
					] }
					onChange={ setMode }
				/>
 
				{ /* Indented so it reads as belonging to the option above it rather
				     than as a further choice of its own. */ }
				{ mode === 'existing' && (
					<div className="jp-forms__gsheets-sync__suboption">
						<TextControl
							label={ __( 'Spreadsheet link', 'jetpack-forms' ) }
							help={ __(
								'Paste the link to a Google Sheet you own. Its existing content is left untouched — new responses are added below it.',
								'jetpack-forms'
							) }
							value={ spreadsheetUrl }
							onChange={ setSpreadsheetUrl }
							__nextHasNoMarginBottom={ true }
							__next40pxDefaultSize={ true }
						/>
					</div>
				) }
			</VStack>
 
			{ /* Separated from the radio group so it does not read as a third
			     option there. Only offered for a new spreadsheet: an existing one
			     is left untouched, so there is nowhere to seed. */ }
			{ mode === 'create' && (
				<CheckboxControl
					label={ __( 'Include responses you already have', 'jetpack-forms' ) }
					help={ __(
						'Copies the responses collected so far into the spreadsheet before syncing begins.',
						'jetpack-forms'
					) }
					checked={ backfill }
					onChange={ setBackfill }
					__nextHasNoMarginBottom={ true }
				/>
			) }
 
			{ error && (
				<Notice.Root intent="error">
					<Notice.Description>{ error }</Notice.Description>
				</Notice.Root>
			) }
 
			<div>
				<Button
					variant="primary"
					onClick={ handleSetup }
					disabled={ ! canSubmit }
					isBusy={ isSettingUp }
					__next40pxDefaultSize={ true }
				>
					{ __( 'Set up syncing', 'jetpack-forms' ) }
				</Button>
			</div>
		</VStack>
	);
}