mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-29 10:59:07 +00:00
refactor: improve gsheet import UX
This commit is contained in:
+26
-19
@@ -15,23 +15,25 @@ import Input from '../../../../../common/components/input/input/Input';
|
|||||||
import Tag from '../../../../../common/components/tag/Tag';
|
import Tag from '../../../../../common/components/tag/Tag';
|
||||||
import { openLink } from '../../../../../common/utils/linkUtils';
|
import { openLink } from '../../../../../common/utils/linkUtils';
|
||||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
|
import { extractSheetId, getPersistedSheetId, persistSheetId } from './gsheetUtils';
|
||||||
|
|
||||||
import style from './SourcesPanel.module.scss';
|
import style from './SourcesPanel.module.scss';
|
||||||
|
|
||||||
interface GSheetSetupProps {
|
interface GSheetSetupProps {
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSheetLoaded: (sheetId: string, options: SpreadsheetWorksheetOptions) => void;
|
onSheetLoaded: (sheetId: string, options: SpreadsheetWorksheetOptions) => void;
|
||||||
|
closedByUser: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GSheetSetup(props: GSheetSetupProps) {
|
export default function GSheetSetup(props: GSheetSetupProps) {
|
||||||
const { onCancel, onSheetLoaded } = props;
|
const { onCancel, onSheetLoaded, closedByUser } = props;
|
||||||
|
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [sheetId, setSheetId] = useState('');
|
const [sheetId, setSheetId] = useState(getPersistedSheetId);
|
||||||
const [authenticationStatus, setAuthenticationStatus] = useState<AuthenticationStatus>('not_authenticated');
|
const [authenticationStatus, setAuthenticationStatus] = useState<AuthenticationStatus>('not_authenticated');
|
||||||
const [authKey, setAuthKey] = useState<string | null>(null);
|
const [authKey, setAuthKey] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState<'' | 'cancel' | 'connect' | 'authenticate' | 'load-sheet'>('');
|
|
||||||
const [authLink, setAuthLink] = useState('');
|
const [authLink, setAuthLink] = useState('');
|
||||||
|
const [loading, setLoading] = useState<'' | 'cancel' | 'connect' | 'authenticate' | 'load-sheet'>('');
|
||||||
const [authError, setAuthError] = useState('');
|
const [authError, setAuthError] = useState('');
|
||||||
const [worksheetError, setWorksheetError] = useState('');
|
const [worksheetError, setWorksheetError] = useState('');
|
||||||
const pollTimeoutRef = useRef<number | null>(null);
|
const pollTimeoutRef = useRef<number | null>(null);
|
||||||
@@ -62,6 +64,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
|||||||
const loadWorksheetOptions = useCallback(
|
const loadWorksheetOptions = useCallback(
|
||||||
async (nextSheetId: string) => {
|
async (nextSheetId: string) => {
|
||||||
const worksheetOptions = await getWorksheetOptions(nextSheetId);
|
const worksheetOptions = await getWorksheetOptions(nextSheetId);
|
||||||
|
persistSheetId(nextSheetId);
|
||||||
onSheetLoaded(nextSheetId, worksheetOptions);
|
onSheetLoaded(nextSheetId, worksheetOptions);
|
||||||
setWorksheetError('');
|
setWorksheetError('');
|
||||||
},
|
},
|
||||||
@@ -71,6 +74,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
|||||||
const pollUntilAuthenticated = useCallback(
|
const pollUntilAuthenticated = useCallback(
|
||||||
async (attempts: number = 0) => {
|
async (attempts: number = 0) => {
|
||||||
clearPollTimeout();
|
clearPollTimeout();
|
||||||
|
if (closedByUser) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await verifyAuthenticationStatus();
|
const result = await verifyAuthenticationStatus();
|
||||||
@@ -82,13 +86,16 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
|||||||
pollTimeoutRef.current = window.setTimeout(() => {
|
pollTimeoutRef.current = window.setTimeout(() => {
|
||||||
pollUntilAuthenticated(attempts + 1);
|
pollUntilAuthenticated(attempts + 1);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
} else {
|
return; // Keep authKey for next poll
|
||||||
setLoading('');
|
|
||||||
}
|
}
|
||||||
|
// Polling timed out
|
||||||
|
setAuthKey(null);
|
||||||
|
setLoading('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.authenticated === 'authenticated') {
|
if (result.authenticated === 'authenticated' && result.sheetId) {
|
||||||
|
setLoading('load-sheet');
|
||||||
try {
|
try {
|
||||||
await loadWorksheetOptions(result.sheetId);
|
await loadWorksheetOptions(result.sheetId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -96,13 +103,15 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setAuthKey(null);
|
||||||
setLoading('');
|
setLoading('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setAuthError(maybeAxiosError(error));
|
setAuthError(maybeAxiosError(error));
|
||||||
|
setAuthKey(null);
|
||||||
setLoading('');
|
setLoading('');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[clearPollTimeout, loadWorksheetOptions],
|
[clearPollTimeout, loadWorksheetOptions, closedByUser],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** check if the current session has been authenticated */
|
/** check if the current session has been authenticated */
|
||||||
@@ -150,7 +159,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Requests connection to google auth
|
* Requests a device code from Google. The user can copy it before opening the browser.
|
||||||
*/
|
*/
|
||||||
const handleConnect = async () => {
|
const handleConnect = async () => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -171,7 +180,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open google auth
|
* Opens the Google verification page and starts polling for completion.
|
||||||
*/
|
*/
|
||||||
const handleAuthenticate = () => {
|
const handleAuthenticate = () => {
|
||||||
setLoading('authenticate');
|
setLoading('authenticate');
|
||||||
@@ -180,7 +189,6 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
|||||||
clearPollTimeout();
|
clearPollTimeout();
|
||||||
clearAuthFallbackTimeout();
|
clearAuthFallbackTimeout();
|
||||||
|
|
||||||
// open link and schedule a check for when the user focuses again
|
|
||||||
openLink(authLink);
|
openLink(authLink);
|
||||||
authFallbackTimeoutRef.current = window.setTimeout(() => {
|
authFallbackTimeoutRef.current = window.setTimeout(() => {
|
||||||
if (document.hasFocus()) {
|
if (document.hasFocus()) {
|
||||||
@@ -269,18 +277,19 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
|||||||
</Panel.ListGroup>
|
</Panel.ListGroup>
|
||||||
)}
|
)}
|
||||||
<Panel.ListGroup className={style.setupBlock}>
|
<Panel.ListGroup className={style.setupBlock}>
|
||||||
<Panel.Description>Enter ID of sheet to synchronise</Panel.Description>
|
<Panel.Description>Enter ID of sheet to synchronize</Panel.Description>
|
||||||
<Panel.Error>{worksheetError}</Panel.Error>
|
<Panel.Error>{worksheetError}</Panel.Error>
|
||||||
<Input
|
<Input
|
||||||
fluid
|
fluid
|
||||||
value={sheetId}
|
value={sheetId}
|
||||||
placeholder='Sheet ID'
|
placeholder='Sheet ID or Google Sheets URL'
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setWorksheetError('');
|
setWorksheetError('');
|
||||||
setSheetId(event.target.value);
|
setSheetId(extractSheetId(event.target.value));
|
||||||
}}
|
}}
|
||||||
disabled={isLoading || canAuthenticate}
|
disabled={isLoading || canAuthenticate}
|
||||||
/>
|
/>
|
||||||
|
<div className={style.setupHint}>Paste a Google Sheets URL or the sheet ID from the URL bar.</div>
|
||||||
</Panel.ListGroup>
|
</Panel.ListGroup>
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<Panel.ListGroup className={style.setupBlock}>
|
<Panel.ListGroup className={style.setupBlock}>
|
||||||
@@ -304,18 +313,16 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
|||||||
</Panel.ListGroup>
|
</Panel.ListGroup>
|
||||||
) : (
|
) : (
|
||||||
<Panel.ListGroup className={style.setupBlock}>
|
<Panel.ListGroup className={style.setupBlock}>
|
||||||
<Panel.Description>Authenticate this Ontime session with Google</Panel.Description>
|
<Panel.Description>Copy the device code, then authenticate with Google</Panel.Description>
|
||||||
<Panel.InlineElements wrap='wrap' className={style.setupActions}>
|
<Panel.InlineElements wrap='wrap' className={style.setupActions}>
|
||||||
{isAuthenticating && <span>Authenticating...</span>}
|
{isAuthenticating && <span>Authenticating...</span>}
|
||||||
<CopyTag copyValue={authKey ?? ''} disabled={!canAuthenticate}>
|
<CopyTag copyValue={authKey ?? ''}>{authKey}</CopyTag>
|
||||||
{authKey ? authKey : 'Upload files to generate Auth Key'}
|
<Button onClick={handleAuthenticate} disabled={isLoading} loading={loading === 'authenticate'}>
|
||||||
</CopyTag>
|
|
||||||
<Button onClick={handleAuthenticate} disabled={!canAuthenticate}>
|
|
||||||
<IoShieldCheckmarkOutline />
|
<IoShieldCheckmarkOutline />
|
||||||
Authenticate
|
Authenticate
|
||||||
</Button>
|
</Button>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
<div className={style.setupHint}>Open the browser prompt, complete the code flow, then come back here.</div>
|
<div className={style.setupHint}>Copy the code, then open the browser prompt to complete the flow.</div>
|
||||||
</Panel.ListGroup>
|
</Panel.ListGroup>
|
||||||
)}
|
)}
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
|
|||||||
+27
-8
@@ -37,12 +37,15 @@ type ActiveSource =
|
|||||||
kind: 'excel';
|
kind: 'excel';
|
||||||
worksheetNames: string[];
|
worksheetNames: string[];
|
||||||
initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null;
|
initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null;
|
||||||
|
closedByUser: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
kind: 'gsheet';
|
kind: 'gsheet';
|
||||||
sheetId: string;
|
sheetId: string;
|
||||||
worksheetNames: string[];
|
worksheetNames: string[];
|
||||||
initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null;
|
initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null;
|
||||||
|
title: string;
|
||||||
|
closedByUser: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SourcesPanel() {
|
export default function SourcesPanel() {
|
||||||
@@ -73,6 +76,7 @@ export default function SourcesPanel() {
|
|||||||
kind: 'excel',
|
kind: 'excel',
|
||||||
worksheetNames: worksheetOptions.worksheets,
|
worksheetNames: worksheetOptions.worksheets,
|
||||||
initialWorksheetMetadata: worksheetOptions.metadata,
|
initialWorksheetMetadata: worksheetOptions.metadata,
|
||||||
|
closedByUser: false,
|
||||||
});
|
});
|
||||||
setImportFlow('excel');
|
setImportFlow('excel');
|
||||||
setHasFile('done');
|
setHasFile('done');
|
||||||
@@ -106,7 +110,13 @@ export default function SourcesPanel() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cancelImportFlow = () => {
|
const cancelImportFlow = () => {
|
||||||
resetFlow();
|
if (activeSource && activeSource.kind === 'gsheet') {
|
||||||
|
// Return to GSheetSetup so the user can change the sheet ID or revoke auth
|
||||||
|
setActiveSource({ ...activeSource, closedByUser: true });
|
||||||
|
setError('');
|
||||||
|
} else {
|
||||||
|
resetFlow();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFinished = () => {
|
const handleFinished = () => {
|
||||||
@@ -177,15 +187,22 @@ export default function SourcesPanel() {
|
|||||||
sheetId,
|
sheetId,
|
||||||
worksheetNames: worksheetOptions.worksheets,
|
worksheetNames: worksheetOptions.worksheets,
|
||||||
initialWorksheetMetadata: worksheetOptions.metadata,
|
initialWorksheetMetadata: worksheetOptions.metadata,
|
||||||
|
title: worksheetOptions.title ?? '',
|
||||||
|
closedByUser: false,
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const closedByUser = activeSource?.closedByUser ?? false;
|
||||||
const isGSheetFlow = importFlow === 'gsheet';
|
const isGSheetFlow = importFlow === 'gsheet';
|
||||||
const showInput = importFlow === 'none';
|
const showInput = importFlow === 'none';
|
||||||
const showCompleted = importFlow === 'finished';
|
const showCompleted = importFlow === 'finished';
|
||||||
const showAuth = isGSheetFlow && activeSource === null;
|
|
||||||
const showImportWorkspace = activeSource !== null;
|
const showImportWorkspace = activeSource !== null && !closedByUser;
|
||||||
const importModalTitle = activeSource?.kind === 'excel' ? 'Import spreadsheet' : 'Synchronise with Google Sheet';
|
const importModalTitle = (() => {
|
||||||
|
if (!activeSource) return '';
|
||||||
|
if (activeSource.kind === 'excel') return 'Import spreadsheet';
|
||||||
|
return activeSource.title ? `Sync: ${activeSource.title}` : 'Synchronize with Google Sheet';
|
||||||
|
})();
|
||||||
const sourceKey = (() => {
|
const sourceKey = (() => {
|
||||||
if (!activeSource) return null;
|
if (!activeSource) return null;
|
||||||
if (activeSource.kind === 'excel') return 'excel';
|
if (activeSource.kind === 'excel') return 'excel';
|
||||||
@@ -195,7 +212,7 @@ export default function SourcesPanel() {
|
|||||||
return (
|
return (
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.Card>
|
<Panel.Card>
|
||||||
<Panel.SubHeader>Synchronise your rundown with an external source</Panel.SubHeader>
|
<Panel.SubHeader>Synchronize your rundown with an external source</Panel.SubHeader>
|
||||||
{error && <Panel.Error>{error}</Panel.Error>}
|
{error && <Panel.Error>{error}</Panel.Error>}
|
||||||
{showInput && (
|
{showInput && (
|
||||||
<div className={style.introStack}>
|
<div className={style.introStack}>
|
||||||
@@ -233,7 +250,7 @@ export default function SourcesPanel() {
|
|||||||
</section>
|
</section>
|
||||||
<section className={style.sourceCard}>
|
<section className={style.sourceCard}>
|
||||||
<div className={style.sourceHeader}>
|
<div className={style.sourceHeader}>
|
||||||
<h4 className={style.sourceTitle}>Synchronise with Google</h4>
|
<h4 className={style.sourceTitle}>Synchronize with Google</h4>
|
||||||
</div>
|
</div>
|
||||||
<p className={style.sourceDescription}>
|
<p className={style.sourceDescription}>
|
||||||
Connect a Google account once, then load any sheet by ID and keep the import flow inside Ontime.
|
Connect a Google account once, then load any sheet by ID and keep the import flow inside Ontime.
|
||||||
@@ -241,7 +258,7 @@ export default function SourcesPanel() {
|
|||||||
<div className={style.sourceMeta}>Requires Google OAuth client credentials</div>
|
<div className={style.sourceMeta}>Requires Google OAuth client credentials</div>
|
||||||
<Button variant='primary' size='large' fluid onClick={openGSheetFlow} disabled={hasFile !== 'none'}>
|
<Button variant='primary' size='large' fluid onClick={openGSheetFlow} disabled={hasFile !== 'none'}>
|
||||||
<IoCloudOutline />
|
<IoCloudOutline />
|
||||||
Synchronise with Google
|
Synchronize with Google
|
||||||
</Button>
|
</Button>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -257,7 +274,9 @@ export default function SourcesPanel() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showAuth && <GSheetSetup onCancel={cancelGSheetFlow} onSheetLoaded={handleSheetLoaded} />}
|
{isGSheetFlow && (
|
||||||
|
<GSheetSetup onCancel={cancelGSheetFlow} onSheetLoaded={handleSheetLoaded} closedByUser={closedByUser} />
|
||||||
|
)}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showImportWorkspace}
|
isOpen={showImportWorkspace}
|
||||||
title={importModalTitle}
|
title={importModalTitle}
|
||||||
|
|||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { extractSheetId } from '../gsheetUtils';
|
||||||
|
|
||||||
|
describe('extractSheetId()', () => {
|
||||||
|
it('extracts the ID from a full Google Sheets URL', () => {
|
||||||
|
const url = 'https://docs.google.com/spreadsheets/d/1aBcDeFgHiJkLmNoPqRsTuVwXyZ/edit#gid=0';
|
||||||
|
expect(extractSheetId(url)).toBe('1aBcDeFgHiJkLmNoPqRsTuVwXyZ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts the ID when the URL has no trailing path', () => {
|
||||||
|
expect(extractSheetId('https://docs.google.com/spreadsheets/d/abc123')).toBe('abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles IDs with hyphens and underscores', () => {
|
||||||
|
const url = 'https://docs.google.com/spreadsheets/d/1a-B_c2/edit';
|
||||||
|
expect(extractSheetId(url)).toBe('1a-B_c2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a raw sheet ID unchanged', () => {
|
||||||
|
expect(extractSheetId('1aBcDeFgHiJkLmNoPqRsTuVwXyZ')).toBe('1aBcDeFgHiJkLmNoPqRsTuVwXyZ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims whitespace from the input', () => {
|
||||||
|
expect(extractSheetId(' 1aBcDeFg ')).toBe('1aBcDeFg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims whitespace from a pasted URL', () => {
|
||||||
|
const url = ' https://docs.google.com/spreadsheets/d/1aBcDeFg/edit ';
|
||||||
|
expect(extractSheetId(url)).toBe('1aBcDeFg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips query params from a raw ID', () => {
|
||||||
|
expect(extractSheetId('1aBcDeFgHiJkLmNoPqRsTuVwXyZ?edit=1')).toBe('1aBcDeFgHiJkLmNoPqRsTuVwXyZ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips fragment from a raw ID', () => {
|
||||||
|
expect(extractSheetId('1aBcDeFg#gid=0')).toBe('1aBcDeFg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for empty input', () => {
|
||||||
|
expect(extractSheetId('')).toBe('');
|
||||||
|
expect(extractSheetId(' ')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { makeStageKey } from '../../../../../common/utils/localStorage';
|
||||||
|
|
||||||
|
// Matches the document ID segment from a Google Sheets URL:
|
||||||
|
// https://docs.google.com/spreadsheets/d/{ID}/edit#gid=0
|
||||||
|
// ^^^^ captured group
|
||||||
|
// IDs consist of alphanumeric chars, hyphens, and underscores.
|
||||||
|
const sheetIdPattern = /\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/;
|
||||||
|
const lastSheetIdKey = makeStageKey('gsheet:lastSheetId');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a Google Sheet ID from a full URL, or returns the raw input if it doesn't match.
|
||||||
|
*/
|
||||||
|
export function extractSheetId(input: string): string {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
const match = trimmed.match(sheetIdPattern);
|
||||||
|
if (match) return match[1];
|
||||||
|
|
||||||
|
// Strip query params (?...) and fragments (#...) in case the user
|
||||||
|
// pasted a partial URL or an ID with trailing junk like "abc123?edit=1"
|
||||||
|
return trimmed.split(/[?#]/)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPersistedSheetId(): string {
|
||||||
|
return localStorage.getItem(lastSheetIdKey) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistSheetId(sheetId: string) {
|
||||||
|
localStorage.setItem(lastSheetIdKey, sheetId);
|
||||||
|
}
|
||||||
+1
-1
@@ -16,7 +16,7 @@ export function getWarningText(warning: MappingWarning): string {
|
|||||||
case 'invalid-name':
|
case 'invalid-name':
|
||||||
return 'Column cannot be converted into an Ontime field name';
|
return 'Column cannot be converted into an Ontime field name';
|
||||||
case 'name-collision':
|
case 'name-collision':
|
||||||
return 'Column name resolves to a duplicate column';
|
return 'Column name resolves to a duplicate Ontime field';
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@ export default function SheetImportMappingPane({
|
|||||||
return (
|
return (
|
||||||
<section className={style.mappingPane}>
|
<section className={style.mappingPane}>
|
||||||
<Panel.InlineElements align='apart' className={style.mappingPaneHeader}>
|
<Panel.InlineElements align='apart' className={style.mappingPaneHeader}>
|
||||||
<span className={style.mappingPaneTitle}>Fields</span>
|
<span className={style.mappingPaneTitle}>Column mapping</span>
|
||||||
<Panel.InlineElements relation='inner' className={style.mappingPaneActions}>
|
<Panel.InlineElements relation='inner' className={style.mappingPaneActions}>
|
||||||
<Button className={style.addColumnTrigger} onClick={handleAddCustomField} disabled={isBusy}>
|
<Button className={style.addColumnTrigger} onClick={handleAddCustomField} disabled={isBusy}>
|
||||||
<IoAdd />
|
<IoAdd />
|
||||||
|
|||||||
+11
-3
@@ -58,7 +58,7 @@ describe('getImportWarnings()', () => {
|
|||||||
expect(warnings['custom.0.importName']).toStrictEqual({ kind: 'invalid-name' });
|
expect(warnings['custom.0.importName']).toStrictEqual({ kind: 'invalid-name' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('warns when a custom header resolves to an existing Ontime field name', () => {
|
it('warns when a custom header resolves to a built-in or duplicate imported field name', () => {
|
||||||
const values = createDefaultFormValues();
|
const values = createDefaultFormValues();
|
||||||
values.custom = [
|
values.custom = [
|
||||||
{ ontimeName: '', importName: 'Artist-1' },
|
{ ontimeName: '', importName: 'Artist-1' },
|
||||||
@@ -67,12 +67,20 @@ describe('getImportWarnings()', () => {
|
|||||||
{ ontimeName: '', importName: 'Presenter-1' },
|
{ ontimeName: '', importName: 'Presenter-1' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const warnings = getImportWarnings(values, ['Artist-1', 'Artist/1', 'Title!', 'Presenter-1'], ['Presenter 1']);
|
const warnings = getImportWarnings(values, ['Artist-1', 'Artist/1', 'Title!', 'Presenter-1']);
|
||||||
|
|
||||||
expect(warnings['custom.0.importName']).toBeUndefined();
|
expect(warnings['custom.0.importName']).toBeUndefined();
|
||||||
expect(warnings['custom.1.importName']).toStrictEqual({ kind: 'name-collision' });
|
expect(warnings['custom.1.importName']).toStrictEqual({ kind: 'name-collision' });
|
||||||
expect(warnings['custom.2.importName']).toStrictEqual({ kind: 'name-collision' });
|
expect(warnings['custom.2.importName']).toStrictEqual({ kind: 'name-collision' });
|
||||||
expect(warnings['custom.3.importName']).toStrictEqual({ kind: 'name-collision' });
|
expect(warnings['custom.3.importName']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not give false warnings for pre-existing custom fields', () => {
|
||||||
|
const values = createDefaultFormValues();
|
||||||
|
values.custom = [{ ontimeName: '', importName: 'Video Info' }];
|
||||||
|
|
||||||
|
const warnings = getImportWarnings(values, ['Video Info']);
|
||||||
|
expect(warnings['custom.0.importName']).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+6
-12
@@ -127,26 +127,26 @@ function isPersistedFormValues(obj: unknown): obj is ImportFormValues {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPersistedImportState(sourceKey: string): ImportFormValues {
|
export function getPersistedImportState(sourceKey: string): { values: ImportFormValues; isPersisted: boolean } {
|
||||||
const storageKey = getImportMapKey(sourceKey);
|
const storageKey = getImportMapKey(sourceKey);
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(storageKey);
|
const raw = localStorage.getItem(storageKey);
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return createDefaultFormValues();
|
return { values: createDefaultFormValues(), isPersisted: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed: unknown = JSON.parse(raw);
|
const parsed: unknown = JSON.parse(raw);
|
||||||
if (isPersistedFormValues(parsed)) {
|
if (isPersistedFormValues(parsed)) {
|
||||||
return parsed;
|
return { values: parsed, isPersisted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalid schema - delete malformed data
|
// Invalid schema - delete malformed data
|
||||||
localStorage.removeItem(storageKey);
|
localStorage.removeItem(storageKey);
|
||||||
return createDefaultFormValues();
|
return { values: createDefaultFormValues(), isPersisted: false };
|
||||||
} catch {
|
} catch {
|
||||||
// Parse error - delete corrupted data
|
// Parse error - delete corrupted data
|
||||||
localStorage.removeItem(storageKey);
|
localStorage.removeItem(storageKey);
|
||||||
return createDefaultFormValues();
|
return { values: createDefaultFormValues(), isPersisted: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,11 +156,9 @@ export function getPersistedImportState(sourceKey: string): ImportFormValues {
|
|||||||
export function getImportWarnings(
|
export function getImportWarnings(
|
||||||
values: ImportFormValues,
|
values: ImportFormValues,
|
||||||
detectedSpreadsheetColumns: string[],
|
detectedSpreadsheetColumns: string[],
|
||||||
existingCustomFieldLabels: string[] = [],
|
|
||||||
): Record<string, MappingWarning | undefined> {
|
): Record<string, MappingWarning | undefined> {
|
||||||
const normalisedHeaders = new Set(detectedSpreadsheetColumns.map(normaliseColumnName).filter(Boolean));
|
const normalisedHeaders = new Set(detectedSpreadsheetColumns.map(normaliseColumnName).filter(Boolean));
|
||||||
const builtInLabels = new Set(builtInFieldDefs.map((def) => def.label.toLowerCase()));
|
const builtInLabels = new Set(builtInFieldDefs.map((def) => def.label.toLowerCase()));
|
||||||
const existingLabels = new Set(existingCustomFieldLabels.map((label) => label.trim().toLowerCase()).filter(Boolean));
|
|
||||||
const seenColumns = new Set<string>();
|
const seenColumns = new Set<string>();
|
||||||
const seenDerivedLabels = new Set<string>();
|
const seenDerivedLabels = new Set<string>();
|
||||||
const warnings: Record<string, MappingWarning | undefined> = {};
|
const warnings: Record<string, MappingWarning | undefined> = {};
|
||||||
@@ -200,11 +198,7 @@ export function getImportWarnings(
|
|||||||
warnings[key] = { kind: 'missing' };
|
warnings[key] = { kind: 'missing' };
|
||||||
} else {
|
} else {
|
||||||
const normalisedDerivedLabel = sanitisedLabel.toLowerCase();
|
const normalisedDerivedLabel = sanitisedLabel.toLowerCase();
|
||||||
if (
|
if (builtInLabels.has(normalisedDerivedLabel) || seenDerivedLabels.has(normalisedDerivedLabel)) {
|
||||||
builtInLabels.has(normalisedDerivedLabel) ||
|
|
||||||
existingLabels.has(normalisedDerivedLabel) ||
|
|
||||||
seenDerivedLabels.has(normalisedDerivedLabel)
|
|
||||||
) {
|
|
||||||
warnings[key] = { kind: 'name-collision' };
|
warnings[key] = { kind: 'name-collision' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-11
@@ -5,7 +5,6 @@ import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
|
|||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { maybeAxiosError } from '../../../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../../../common/api/utils';
|
||||||
import useCustomFields from '../../../../../../common/hooks-query/useCustomFields';
|
|
||||||
import { formatDuration } from '../../../../../../common/utils/time';
|
import { formatDuration } from '../../../../../../common/utils/time';
|
||||||
import {
|
import {
|
||||||
type ImportFormValues,
|
type ImportFormValues,
|
||||||
@@ -118,10 +117,15 @@ export function useSheetImportForm({
|
|||||||
onApply,
|
onApply,
|
||||||
onExport,
|
onExport,
|
||||||
}: UseSheetImportFormProps) {
|
}: UseSheetImportFormProps) {
|
||||||
const initialFormValues = useMemo(() => {
|
const { initialFormValues, skipAutoPreview } = useMemo(() => {
|
||||||
const persisted = getPersistedImportState(sourceKey);
|
const { values, isPersisted } = getPersistedImportState(sourceKey);
|
||||||
const worksheet = getPreferredWorksheet(worksheetNames, persisted.worksheet, initialMetadata?.worksheet ?? '');
|
const worksheet = getPreferredWorksheet(worksheetNames, values.worksheet, initialMetadata?.worksheet ?? '');
|
||||||
return { ...persisted, worksheet };
|
// Skip auto-preview when restoring a persisted mapping that was saved for a different worksheet.
|
||||||
|
const hasWorksheetChanged = values.worksheet !== worksheet;
|
||||||
|
return {
|
||||||
|
initialFormValues: { ...values, worksheet },
|
||||||
|
skipAutoPreview: isPersisted && hasWorksheetChanged,
|
||||||
|
};
|
||||||
}, [initialMetadata?.worksheet, sourceKey, worksheetNames]);
|
}, [initialMetadata?.worksheet, sourceKey, worksheetNames]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -139,7 +143,6 @@ export function useSheetImportForm({
|
|||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({ control, name: 'custom' });
|
const { fields, append, remove } = useFieldArray({ control, name: 'custom' });
|
||||||
const values = watch();
|
const values = watch();
|
||||||
const { data: existingCustomFields } = useCustomFields();
|
|
||||||
|
|
||||||
// --- Worksheet metadata via react-query ---
|
// --- Worksheet metadata via react-query ---
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -167,16 +170,14 @@ export function useSheetImportForm({
|
|||||||
const columnLabels = buildColumnLabels(values);
|
const columnLabels = buildColumnLabels(values);
|
||||||
|
|
||||||
const [state, dispatch] = useReducer(importReducer, initialImportState);
|
const [state, dispatch] = useReducer(importReducer, initialImportState);
|
||||||
const existingCustomFieldLabels = useMemo(
|
const warnings = getImportWarnings(values, headers);
|
||||||
() => Object.values(existingCustomFields).map((field) => field.label),
|
|
||||||
[existingCustomFields],
|
|
||||||
);
|
|
||||||
const warnings = getImportWarnings(values, headers, existingCustomFieldLabels);
|
|
||||||
const warningCount = Object.values(warnings).filter(Boolean).length;
|
const warningCount = Object.values(warnings).filter(Boolean).length;
|
||||||
const previewRef = useRef<SpreadsheetPreviewResponse | null>(null);
|
const previewRef = useRef<SpreadsheetPreviewResponse | null>(null);
|
||||||
|
const autoPreviewFiredRef = useRef(false);
|
||||||
|
|
||||||
// Rehydrate the form from persisted/default state whenever the source context changes.
|
// Rehydrate the form from persisted/default state whenever the source context changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
autoPreviewFiredRef.current = false;
|
||||||
reset(initialFormValues);
|
reset(initialFormValues);
|
||||||
dispatch({ type: 'reset' });
|
dispatch({ type: 'reset' });
|
||||||
}, [initialFormValues, reset]);
|
}, [initialFormValues, reset]);
|
||||||
@@ -225,6 +226,16 @@ export function useSheetImportForm({
|
|||||||
[previewImport],
|
[previewImport],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Auto-preview on mount once metadata is ready.
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoPreviewFiredRef.current) return;
|
||||||
|
if (skipAutoPreview) return;
|
||||||
|
if (!isValid || headers.length === 0) return;
|
||||||
|
|
||||||
|
autoPreviewFiredRef.current = true;
|
||||||
|
handleSubmit(handlePreview)();
|
||||||
|
}, [skipAutoPreview, isValid, headers, handleSubmit, handlePreview]);
|
||||||
|
|
||||||
const handleApply = useCallback(async () => {
|
const handleApply = useCallback(async () => {
|
||||||
if (!state.preview) return;
|
if (!state.preview) return;
|
||||||
|
|
||||||
|
|||||||
@@ -223,10 +223,15 @@ export function hasAuth(): { authenticated: AuthenticationStatus; sheetId: strin
|
|||||||
return { authenticated: currentAuthClient ? 'authenticated' : 'not_authenticated', sheetId: currentSheetId };
|
return { authenticated: currentAuthClient ? 'authenticated' : 'not_authenticated', sheetId: currentSheetId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VerifySheetResult = {
|
||||||
|
worksheets: string[];
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates that a spreadsheet exists and returns its worksheet titles without reading cell data.
|
* Validates that a spreadsheet exists and returns its worksheet titles without reading cell data.
|
||||||
*/
|
*/
|
||||||
async function verifySheet(sheetId = currentSheetId, authClient = currentAuthClient): Promise<string[]> {
|
async function verifySheet(sheetId = currentSheetId, authClient = currentAuthClient): Promise<VerifySheetResult> {
|
||||||
if (!sheetId || !authClient) {
|
if (!sheetId || !authClient) {
|
||||||
throw new Error('Missing sheet ID or authentication');
|
throw new Error('Missing sheet ID or authentication');
|
||||||
}
|
}
|
||||||
@@ -247,7 +252,9 @@ async function verifySheet(sheetId = currentSheetId, authClient = currentAuthCli
|
|||||||
if (worksheets.length === 0) {
|
if (worksheets.length === 0) {
|
||||||
throw new Error('No worksheets found');
|
throw new Error('No worksheets found');
|
||||||
}
|
}
|
||||||
return worksheets;
|
|
||||||
|
const title = spreadsheets.data.properties?.title ?? '';
|
||||||
|
return { worksheets, title };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// attempt to catch errors caused by importing xlsx
|
// attempt to catch errors caused by importing xlsx
|
||||||
catchCommonImportXlsxError(error);
|
catchCommonImportXlsxError(error);
|
||||||
@@ -287,17 +294,18 @@ export async function handleInitialConnection(
|
|||||||
*/
|
*/
|
||||||
export async function getWorksheetOptions(
|
export async function getWorksheetOptions(
|
||||||
sheetId: string,
|
sheetId: string,
|
||||||
): Promise<{ worksheets: string[]; metadata: SpreadsheetWorksheetMetadata | null }> {
|
): Promise<{ worksheets: string[]; metadata: SpreadsheetWorksheetMetadata | null; title: string }> {
|
||||||
if (!currentAuthClient) {
|
if (!currentAuthClient) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
currentSheetId = sheetId;
|
currentSheetId = sheetId;
|
||||||
|
|
||||||
const worksheets = await verifySheet(sheetId);
|
const { worksheets, title } = await verifySheet(sheetId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
worksheets,
|
worksheets,
|
||||||
metadata: null,
|
metadata: null,
|
||||||
|
title,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ test('imports spreadsheet and applies imported rundown to editor', async ({ page
|
|||||||
await page.getByRole('button', { name: 'Toggle settings' }).click();
|
await page.getByRole('button', { name: 'Toggle settings' }).click();
|
||||||
await page.getByRole('button', { name: 'Project settings' }).click();
|
await page.getByRole('button', { name: 'Project settings' }).click();
|
||||||
await page.getByRole('button', { name: 'Import spreadsheet' }).first().click();
|
await page.getByRole('button', { name: 'Import spreadsheet' }).first().click();
|
||||||
await expect(page.getByText('Synchronise your rundown with an external source')).toBeVisible();
|
await expect(page.getByText('Synchronize your rundown with an external source')).toBeVisible();
|
||||||
|
|
||||||
// upload the spreadsheet
|
// upload the spreadsheet
|
||||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type SpreadsheetWorksheetMetadata = {
|
|||||||
export type SpreadsheetWorksheetOptions = {
|
export type SpreadsheetWorksheetOptions = {
|
||||||
worksheets: string[];
|
worksheets: string[];
|
||||||
metadata: SpreadsheetWorksheetMetadata | null;
|
metadata: SpreadsheetWorksheetMetadata | null;
|
||||||
|
title?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SpreadsheetPreviewResponse = {
|
export type SpreadsheetPreviewResponse = {
|
||||||
|
|||||||
Reference in New Issue
Block a user