Skip to content

Commit 40ea3f8

Browse files
committed
feat(cookbooks): add portal-analytics cookbook to test analytics module
Ref #3612
1 parent 69d9f98 commit 40ea3f8

24 files changed

+1464
-0
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
log.txt

cookbooks/portal-analytics/CHANGELOG.md

Whitespace-only changes.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Cookbook Portal for Analytics
2+
3+
This cookbook demonstrates the analytics module usage in a portal.
4+
5+
It has a click collector, a collector for context change and a adapter to emit
6+
the events to a log file - via the dev-server proxy.
7+
8+
Logs will be displayed in the portal or it can be viewed in `otel-logs/log.txt`.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { defineDevServerConfig, processServices } from '@equinor/fusion-framework-cli/dev-server';
2+
import {
3+
appendFileContents,
4+
readBody,
5+
readFileContents,
6+
clearFileContents,
7+
} from './middleware/logs';
8+
9+
const LOG_FILE = './log.txt';
10+
11+
export default defineDevServerConfig(() => {
12+
return {
13+
api: {
14+
processServices: (data, args) => {
15+
const existingServices = processServices(data, args);
16+
17+
const routes = existingServices.routes || [];
18+
routes.push(
19+
{
20+
match: '/logs',
21+
middleware: async (_req, res) => {
22+
res.setHeader('Content-Type', 'text/plain');
23+
res.end(await readFileContents(LOG_FILE));
24+
},
25+
},
26+
{
27+
match: '/api/logs',
28+
middleware: async (req, res) => {
29+
const body = await readBody(req);
30+
try {
31+
appendFileContents(LOG_FILE, body);
32+
} catch (err) {
33+
res.statusCode = 500;
34+
res.end(err);
35+
}
36+
res.end(JSON.stringify({ partialSuccess: {} }));
37+
},
38+
},
39+
{
40+
match: '/api/clearlogs',
41+
middleware: async (_req, res) => {
42+
await clearFileContents(LOG_FILE);
43+
res.end();
44+
},
45+
},
46+
);
47+
48+
existingServices.routes = routes;
49+
50+
return existingServices;
51+
},
52+
},
53+
};
54+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { readFile, appendFile, writeFile } from 'node:fs/promises';
2+
import { existsSync } from 'node:fs';
3+
import { resolve } from 'node:path';
4+
import type { IncomingMessage } from 'node:http';
5+
6+
export const readFileContents = async (filePath: string): Promise<string> => {
7+
const absolutePath = resolve(filePath);
8+
try {
9+
if (!existsSync(absolutePath)) {
10+
throw new Error(`File does not exist: ${absolutePath}`);
11+
}
12+
const data = await readFile(absolutePath, 'utf-8');
13+
return data;
14+
} catch (error) {
15+
if (error instanceof Error) {
16+
throw new Error(`Failed to read file: ${error.message}`);
17+
}
18+
throw new Error('Unknown error occurred');
19+
}
20+
};
21+
22+
export const readBody = async (req: IncomingMessage): Promise<string> => {
23+
return new Promise((resolve, reject) => {
24+
if (req.method !== 'POST' || !req.headers['content-type']?.includes('application/json')) {
25+
resolve('');
26+
return;
27+
}
28+
29+
const chunks: Buffer[] = [];
30+
req.on('data', (chunk: Buffer) => chunks.push(chunk));
31+
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
32+
req.on('error', reject);
33+
});
34+
};
35+
36+
export const appendFileContents = async (filePath: string, content: string): Promise<void> => {
37+
const absolutePath = resolve(filePath);
38+
39+
try {
40+
if (!existsSync(absolutePath)) {
41+
throw new Error(`File does not exist: ${absolutePath}`);
42+
}
43+
await appendFile(absolutePath, `${content}\n`, 'utf8');
44+
} catch (error) {
45+
if (error instanceof Error) {
46+
throw new Error(`Failed to write to file: ${error.message}`);
47+
}
48+
throw new Error('Unknown error occurred');
49+
}
50+
};
51+
52+
export const clearFileContents = async (filePath: string): Promise<void> => {
53+
const absolutePath = resolve(filePath);
54+
55+
try {
56+
if (!existsSync(absolutePath)) {
57+
throw new Error(`File does not exist: ${absolutePath}`);
58+
}
59+
await writeFile(absolutePath, '', { encoding: 'utf8' });
60+
} catch (error) {
61+
if (error instanceof Error) {
62+
throw new Error(`Failed to write to file: ${error.message}`);
63+
}
64+
throw new Error('Unknown error occurred');
65+
}
66+
};
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{
2+
"name": "portal-analytics",
3+
"version": "0.1.3",
4+
"description": "",
5+
"main": "dist/index.js",
6+
"type": "module",
7+
"private": true,
8+
"scripts": {
9+
"dev": "fusion-framework-cli portal dev",
10+
"build": "fusion-framework-cli portal build",
11+
"schema": "fusion-framework-cli portal schema",
12+
"pack:portals": "fusion-framework-cli portal pack",
13+
"publish:portals": "fusion-framework-cli portal publish",
14+
"postinstall": "node postinstall.js"
15+
},
16+
"keywords": [],
17+
"author": "",
18+
"license": "ISC",
19+
"dependencies": {
20+
"@equinor/eds-core-react": "^0.49.0",
21+
"@equinor/eds-icons": "^0.22.0",
22+
"@equinor/fusion-framework": "workspace:*",
23+
"@equinor/fusion-framework-app": "workspace:*",
24+
"@equinor/fusion-framework-cli": "workspace:*",
25+
"@equinor/fusion-framework-module-analytics": "workspace:*",
26+
"@equinor/fusion-framework-module-app": "workspace:*",
27+
"@equinor/fusion-framework-module-context": "workspace:*",
28+
"@equinor/fusion-framework-module-navigation": "workspace:*",
29+
"@equinor/fusion-framework-module-service-discovery": "workspace:*",
30+
"@equinor/fusion-framework-react": "workspace:*",
31+
"@equinor/fusion-framework-react-app": "workspace:*",
32+
"@equinor/fusion-framework-react-module-context": "workspace:^",
33+
"@equinor/fusion-observable": "workspace:*",
34+
"@equinor/fusion-query": "workspace:*",
35+
"@equinor/fusion-react-context-selector": "^2.0.1",
36+
"@equinor/fusion-react-date": "^1.0.0",
37+
"@equinor/fusion-react-progress-indicator": "^0.3.0",
38+
"@equinor/fusion-react-side-sheet": "^2.0.0",
39+
"react": "^18.2.0",
40+
"react-dom": "^18.2.0",
41+
"react-router-dom": "^7.9.5",
42+
"rxjs": "^7.8.1",
43+
"styled-components": "^6.0.7",
44+
"uuid": "^13.0.0",
45+
"zod": "^4.1.12"
46+
},
47+
"devDependencies": {
48+
"@types/react": "^18.2.0",
49+
"@types/react-dom": "^18.2.0"
50+
}
51+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import fs from 'node:fs/promises';
2+
3+
const LOG_FILE = './log.txt';
4+
5+
try {
6+
await fs.access(LOG_FILE);
7+
console.log(`File exists: ${LOG_FILE}`);
8+
} catch (err) {
9+
if (err.code === 'ENOENT') {
10+
try {
11+
await fs.writeFile(LOG_FILE, '');
12+
console.log(`File created: ${LOG_FILE}`);
13+
} catch (writeErr) {
14+
console.error(`Error creating file ${writeErr.message}`);
15+
}
16+
} else {
17+
console.error(`Error checking file: ${err.message}`);
18+
}
19+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { useEffect, useId, useMemo, useRef, useState } from 'react';
2+
3+
import { Subscription } from 'rxjs';
4+
import { last } from 'rxjs/operators';
5+
6+
import { useFramework } from '@equinor/fusion-framework-react';
7+
8+
import { useObservableState } from '@equinor/fusion-observable/react';
9+
10+
import { AppManifestError } from '@equinor/fusion-framework-module-app/errors.js';
11+
12+
import { ErrorViewer } from './components/ErrorViewer';
13+
import type { AppModule } from '@equinor/fusion-framework-module-app';
14+
import { EquinorLoader } from './components/EquinorLoader';
15+
16+
/**
17+
* React Functional Component for handling current application
18+
*
19+
* this component will set the current app by provided appKey.
20+
* when the appKey changes, this component will try to initialize the referred application
21+
* and render it.
22+
*/
23+
export const AppLoader = (props: { readonly appKey: string }) => {
24+
const { appKey } = props;
25+
const fusion = useFramework<[AppModule]>();
26+
27+
/** reference of application section/container */
28+
const ref = useRef<HTMLElement>(null);
29+
const applicationContentId = useId();
30+
31+
const [loading, setLoading] = useState<boolean>(false);
32+
const [error, setError] = useState<Error | undefined>();
33+
34+
// TODO change to `useCurrentApp`
35+
/** observe and use the current selected application from framework */
36+
const { value: currentApp } = useObservableState(
37+
useMemo(() => fusion.modules.app.current$, [fusion.modules.app]),
38+
);
39+
40+
useEffect(() => {
41+
/** when appKey property change, assign it to current */
42+
fusion.modules.app.setCurrentApp(appKey);
43+
}, [appKey, fusion]);
44+
45+
useEffect(() => {
46+
/** flag that application is loading */
47+
setLoading(true);
48+
49+
/** clear previous errors */
50+
setError(undefined);
51+
52+
/** create a teardown of load */
53+
const subscription = new Subscription();
54+
55+
/** make sure that initialize is canceled and disposed if current app changes */
56+
subscription.add(
57+
currentApp
58+
?.initialize()
59+
.pipe(last())
60+
.subscribe({
61+
next: ({ manifest, script, config }) => {
62+
/** generate basename for application */
63+
const [basename] = window.location.pathname.match(/\/?apps\/[a-z|-]+(\/)?/g) ?? [''];
64+
65+
/** create a 'private' element for the application */
66+
const el = document.createElement('div');
67+
if (!ref.current) {
68+
throw Error('Missing application mounting point');
69+
}
70+
71+
ref.current.appendChild(el);
72+
73+
/** extract render callback function from javascript module */
74+
const render = script.renderApp ?? script.default;
75+
76+
/** add application teardown to current render effect teardown */
77+
subscription.add(render(el, { fusion, env: { basename, config, manifest } }));
78+
79+
/** remove app element when application unmounts */
80+
subscription.add(() => el.remove());
81+
},
82+
complete: () => {
83+
/** flag that application is no longer loading */
84+
setLoading(false);
85+
},
86+
error: (err) => {
87+
/** set error if initialization of application fails */
88+
setError(err);
89+
},
90+
}),
91+
);
92+
93+
/** teardown application when hook unmounts */
94+
return () => subscription.unsubscribe();
95+
}, [fusion, currentApp]);
96+
97+
if (error) {
98+
if (error.cause instanceof AppManifestError) {
99+
return (
100+
<div>
101+
<h2>🔥 Failed to load application manifest 🤬</h2>
102+
<h3>{error.cause.type}</h3>
103+
<ErrorViewer error={error} />
104+
</div>
105+
);
106+
}
107+
return (
108+
<div>
109+
<h2>🔥 Failed to load application 🤬</h2>
110+
<ErrorViewer error={error} />
111+
</div>
112+
);
113+
}
114+
115+
return (
116+
<section id={applicationContentId} ref={ref} style={{ display: 'contents' }}>
117+
{loading && <EquinorLoader text="Loading Application" />}
118+
</section>
119+
);
120+
};
121+
122+
export default AppLoader;

0 commit comments

Comments
 (0)