Skip to content

Commit e321dd8

Browse files
refactor: page location file into separate components so that it's easier to manage (#10265)
1 parent 91b98a6 commit e321dd8

File tree

9 files changed

+550
-445
lines changed

9 files changed

+550
-445
lines changed

apps/google-docs/functions/agents/documentParser.agent.ts renamed to apps/google-docs/functions/agents/documentParserAgent/documentParser.agent.ts

File renamed without changes.

apps/google-docs/functions/createEntriesFromDocument.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type {
55
AppActionRequest,
66
} from '@contentful/node-apps-toolkit';
77
import { analyzeContentTypes } from './agents/contentTypeParserAgent/contentTypeParser.agent';
8-
import { createDocument } from './agents/documentParser.agent';
8+
import { createDocument } from './agents/documentParserAgent/documentParser.agent';
99
import { fetchContentTypes } from './service/contentTypeService';
1010
import { initContentfulManagementClient } from './service/initCMAClient';
1111

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export { GoogleDocUploader } from './page/GoogleDocUploader';
2+
export { DocumentFileUploader } from './page/DocumentFileUploader';
3+
export { ContentTypeSelector } from './page/ContentTypeSelector';
4+
export { DocumentPreview } from './page/DocumentPreview';
5+
export { ContentTypePickerModal, type SelectedContentType } from './page/ContentTypePickerModal';
6+
export { default as LocalhostWarning } from './LocalhostWarning';

apps/google-docs/src/components/ContentTypePickerModal.tsx renamed to apps/google-docs/src/components/page/ContentTypePickerModal.tsx

File renamed without changes.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { useState } from 'react';
2+
import {
3+
Box,
4+
Button,
5+
Flex,
6+
Heading,
7+
Note,
8+
Paragraph,
9+
Spinner,
10+
Text,
11+
} from '@contentful/f36-components';
12+
import { PageAppSDK } from '@contentful/app-sdk';
13+
import { ContentTypePickerModal, SelectedContentType } from './ContentTypePickerModal';
14+
import { getAppActionId } from '../../utils/getAppActionId';
15+
16+
interface ContentTypeSelectorProps {
17+
sdk: PageAppSDK;
18+
isDisabled?: boolean;
19+
}
20+
21+
export const ContentTypeSelector = ({ sdk, isDisabled }: ContentTypeSelectorProps) => {
22+
const [isContentTypePickerOpen, setIsContentTypePickerOpen] = useState<boolean>(false);
23+
const [isAnalyzing, setIsAnalyzing] = useState<boolean>(false);
24+
const [analysisResult, setAnalysisResult] = useState<any>(null);
25+
const [analysisError, setAnalysisError] = useState<string | null>(null);
26+
27+
const handleContentTypeSelected = async (contentTypes: SelectedContentType[]) => {
28+
const names = contentTypes.map((ct) => ct.name).join(', ');
29+
sdk.notifier.success(
30+
`Selected ${contentTypes.length} content type${contentTypes.length > 1 ? 's' : ''}: ${names}`
31+
);
32+
setIsContentTypePickerOpen(false);
33+
34+
await analyzeContentTypes(contentTypes.map((ct) => ct.id));
35+
};
36+
37+
const analyzeContentTypes = async (contentTypeIds: string[]) => {
38+
try {
39+
setIsAnalyzing(true);
40+
setAnalysisError(null);
41+
setAnalysisResult(null);
42+
43+
const appDefinitionId = sdk.ids.app;
44+
45+
if (!appDefinitionId) {
46+
throw new Error('App definition ID not found');
47+
}
48+
49+
const appActionId = await getAppActionId(sdk, 'createEntriesFromDocumentAction');
50+
51+
const result = await sdk.cma.appActionCall.createWithResult(
52+
{
53+
appDefinitionId,
54+
appActionId,
55+
},
56+
{
57+
parameters: { contentTypeIds },
58+
}
59+
);
60+
61+
if ('errors' in result && result.errors) {
62+
throw new Error(JSON.stringify(result.errors));
63+
}
64+
65+
setAnalysisResult(result.sys);
66+
} catch (error) {
67+
setAnalysisError(error instanceof Error ? error.message : 'Failed to analyze content types');
68+
} finally {
69+
setIsAnalyzing(false);
70+
}
71+
};
72+
73+
return (
74+
<>
75+
<Flex marginTop="spacingM">
76+
<Button
77+
variant="primary"
78+
onClick={() => {
79+
setIsContentTypePickerOpen(true);
80+
}}
81+
isDisabled={isDisabled || isAnalyzing}>
82+
Select Content Type
83+
</Button>
84+
</Flex>
85+
86+
{isAnalyzing && (
87+
<Box marginTop="spacingM">
88+
<Flex alignItems="center" gap="spacingS">
89+
<Text fontWeight="fontWeightMedium" fontSize="fontSizeM">
90+
Analyzing content types
91+
</Text>
92+
<Spinner />
93+
</Flex>
94+
</Box>
95+
)}
96+
97+
{analysisError && (
98+
<Box marginTop="spacingM">
99+
<Note variant="negative">Error: {analysisError}</Note>
100+
</Box>
101+
)}
102+
103+
{analysisResult && (
104+
<Box marginTop="spacingL" style={{ border: '1px solid #e5e5e5', padding: '16px' }}>
105+
<Heading as="h3" marginBottom="spacingS">
106+
Analysis Result
107+
</Heading>
108+
<Paragraph marginBottom="spacingS">
109+
Raw output from the content type analysis agent:
110+
</Paragraph>
111+
<Box
112+
style={{
113+
maxHeight: '400px',
114+
overflow: 'auto',
115+
background: '#f7f9fa',
116+
padding: '16px',
117+
borderRadius: '4px',
118+
fontFamily: 'monospace',
119+
fontSize: '12px',
120+
whiteSpace: 'pre-wrap',
121+
wordBreak: 'break-word',
122+
}}>
123+
{JSON.stringify(analysisResult.result.response, null, 2)}
124+
</Box>
125+
</Box>
126+
)}
127+
128+
<ContentTypePickerModal
129+
sdk={sdk}
130+
isOpen={isContentTypePickerOpen}
131+
onClose={() => {
132+
setIsContentTypePickerOpen(false);
133+
}}
134+
onSelect={handleContentTypeSelected}
135+
/>
136+
</>
137+
);
138+
};
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { useState, useRef } from 'react';
2+
import { Box, Button, Form, FormControl, Paragraph } from '@contentful/f36-components';
3+
import { PageAppSDK } from '@contentful/app-sdk';
4+
import mammoth from 'mammoth';
5+
6+
interface DocumentFileUploaderProps {
7+
sdk: PageAppSDK;
8+
onSuccess: (title: string, html: string | null, isDocxRendered: boolean) => void;
9+
onError: (message: string) => void;
10+
isDisabled?: boolean;
11+
previewRef?: React.RefObject<HTMLDivElement>;
12+
}
13+
14+
const convertDocxToHtml = async (file: File) => {
15+
const arrayBuffer = await file.arrayBuffer();
16+
const result = await mammoth.convertToHtml({ arrayBuffer });
17+
return result.value as string;
18+
};
19+
20+
const loadDocxPreview = async () => {
21+
if ((window as any).docx) return (window as any).docx;
22+
await new Promise<void>((resolve, reject) => {
23+
const script = document.createElement('script');
24+
script.src = 'https://unpkg.com/docx-preview@0.3.4/dist/docx-preview.min.js';
25+
script.async = true;
26+
script.onload = () => resolve();
27+
script.onerror = () => reject(new Error('Failed to load docx-preview'));
28+
document.body.appendChild(script);
29+
});
30+
const docx = (window as any).docx;
31+
if (!docx) {
32+
throw new Error('docx-preview failed to initialize');
33+
}
34+
return docx;
35+
};
36+
37+
export const DocumentFileUploader = ({
38+
sdk,
39+
onSuccess,
40+
onError,
41+
isDisabled,
42+
previewRef,
43+
}: DocumentFileUploaderProps) => {
44+
const [file, setFile] = useState<File | null>(null);
45+
const [fileError, setFileError] = useState<string | null>(null);
46+
const [isUploading, setIsUploading] = useState<boolean>(false);
47+
const [uploadProgress, setUploadProgress] = useState<number>(0);
48+
49+
const onSelectFile = (fileList: FileList | null) => {
50+
setFile(null);
51+
setFileError(null);
52+
if (!fileList || fileList.length === 0) {
53+
return;
54+
}
55+
const file = fileList[0];
56+
setFile(file);
57+
};
58+
59+
const simulateUpload = async () => {
60+
setIsUploading(true);
61+
setUploadProgress(0);
62+
// Simulate progress to 100% over ~1.5s
63+
await new Promise<void>((resolve) => {
64+
const start = Date.now();
65+
const tick = () => {
66+
const elapsed = Date.now() - start;
67+
const percent = Math.min(100, Math.round((elapsed / 1500) * 100));
68+
setUploadProgress(percent);
69+
if (percent >= 100) {
70+
resolve();
71+
} else {
72+
requestAnimationFrame(tick);
73+
}
74+
};
75+
requestAnimationFrame(tick);
76+
});
77+
setIsUploading(false);
78+
};
79+
80+
const onSubmitDoc = async () => {
81+
if (!file) {
82+
setFileError('Please choose a document file to upload.');
83+
return;
84+
}
85+
86+
try {
87+
const lower = file.name.toLowerCase();
88+
const isDocx = lower.endsWith('.docx');
89+
90+
if (isDocx && previewRef) {
91+
// Prefer higher-fidelity renderer
92+
try {
93+
const docx = await loadDocxPreview();
94+
const buf = await file.arrayBuffer();
95+
if (previewRef.current) {
96+
previewRef.current.innerHTML = '';
97+
await docx.renderAsync(buf, previewRef.current, undefined, {
98+
inWrapper: true,
99+
ignoreLastRenderedPageBreak: true,
100+
experimental: true,
101+
});
102+
onSuccess(file.name, null, true);
103+
sdk.notifier.success(`Rendered "${file.name}" successfully.`);
104+
return;
105+
}
106+
} catch {
107+
// Fallback to mammoth if docx-preview fails
108+
const html = await convertDocxToHtml(file);
109+
await simulateUpload();
110+
onSuccess(file.name, html, false);
111+
sdk.notifier.success(`Uploaded "${file.name}" successfully.`);
112+
return;
113+
}
114+
}
115+
116+
// Non-docx or unable to render with docx-preview; try mammoth fallback for best effort
117+
const html = await convertDocxToHtml(file);
118+
await simulateUpload();
119+
onSuccess(file.name, html, false);
120+
sdk.notifier.success(`Uploaded "${file.name}" successfully.`);
121+
} catch (e: unknown) {
122+
const message = e instanceof Error ? e.message : 'Failed to parse document file.';
123+
onError(message);
124+
sdk.notifier.error(message);
125+
}
126+
};
127+
128+
return (
129+
<Form>
130+
<FormControl>
131+
<FormControl.Label>Document file (.doc, .docx)</FormControl.Label>
132+
<Box marginTop="spacingS">
133+
<input type="file" accept=".doc,.docx" onChange={(e) => onSelectFile(e.target.files)} />
134+
</Box>
135+
<FormControl.HelpText>Choose a document file from your computer</FormControl.HelpText>
136+
{fileError && <FormControl.ValidationMessage>{fileError}</FormControl.ValidationMessage>}
137+
<Box marginTop="spacingS">
138+
<Button isDisabled={isDisabled || isUploading || !file} onClick={onSubmitDoc}>
139+
{isUploading ? 'Uploading...' : 'Upload Document File'}
140+
</Button>
141+
</Box>
142+
143+
{isUploading && (
144+
<Box marginTop="spacingS">
145+
<Paragraph>Uploading... {uploadProgress}%</Paragraph>
146+
<progress max={100} value={uploadProgress} style={{ width: '100%' }} />
147+
</Box>
148+
)}
149+
</FormControl>
150+
</Form>
151+
);
152+
};
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Box, Heading, Paragraph } from '@contentful/f36-components';
2+
3+
interface DocumentPreviewProps {
4+
title: string | null;
5+
html: string | null;
6+
isDocxRendered: boolean;
7+
previewRef?: React.RefObject<HTMLDivElement>;
8+
}
9+
10+
export const DocumentPreview = ({
11+
title,
12+
html,
13+
isDocxRendered,
14+
previewRef,
15+
}: DocumentPreviewProps) => {
16+
if (!title) return null;
17+
18+
if (html && !isDocxRendered) {
19+
return (
20+
<Box marginTop="spacingL" style={{ border: '1px solid #e5e5e5', padding: '16px' }}>
21+
<Heading as="h3" marginBottom="spacingS">
22+
{title}
23+
</Heading>
24+
<Paragraph marginBottom="spacingS">Preview (HTML export):</Paragraph>
25+
<Box
26+
style={{ maxHeight: '400px', overflow: 'auto', background: '#fff' }}
27+
dangerouslySetInnerHTML={{ __html: html }}
28+
/>
29+
</Box>
30+
);
31+
}
32+
33+
if (isDocxRendered && previewRef) {
34+
return (
35+
<Box marginTop="spacingL" style={{ border: '1px solid #e5e5e5', padding: '16px' }}>
36+
<Heading as="h3" marginBottom="spacingS">
37+
{title}
38+
</Heading>
39+
<Paragraph marginBottom="spacingS">Preview (DOCX high-fidelity render):</Paragraph>
40+
<Box
41+
ref={previewRef}
42+
style={{ maxHeight: '400px', overflow: 'auto', background: '#fff' }}
43+
/>
44+
</Box>
45+
);
46+
}
47+
48+
return null;
49+
};

0 commit comments

Comments
 (0)