docs(changeset): feat(error-manager): implement centralized error reporting system

This commit is contained in:
2026-02-07 21:11:48 +02:00
parent 7bf94f306a
commit d7b1eb26f3
18 changed files with 806 additions and 187 deletions

View File

@@ -0,0 +1,5 @@
---
'@warkypublic/oranguru': patch
---
feat(error-manager): implement centralized error reporting system

View File

@@ -53,6 +53,7 @@
"@changesets/cli": "^2.29.8", "@changesets/cli": "^2.29.8",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@microsoft/api-extractor": "^7.56.0", "@microsoft/api-extractor": "^7.56.0",
"@sentry/react": "^10.38.0",
"@storybook/react-vite": "^10.2.3", "@storybook/react-vite": "^10.2.3",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",

67
pnpm-lock.yaml generated
View File

@@ -72,6 +72,9 @@ importers:
'@microsoft/api-extractor': '@microsoft/api-extractor':
specifier: ^7.56.0 specifier: ^7.56.0
version: 7.56.0(@types/node@25.2.0) version: 7.56.0(@types/node@25.2.0)
'@sentry/react':
specifier: ^10.38.0
version: 10.38.0(react@19.2.4)
'@storybook/react-vite': '@storybook/react-vite':
specifier: ^10.2.3 specifier: ^10.2.3
version: 10.2.3(esbuild@0.27.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.50.2)(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.0)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.6))) version: 10.2.3(esbuild@0.27.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.50.2)(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.0)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.6)))
@@ -964,6 +967,36 @@ packages:
'@rushstack/ts-command-line@5.1.7': '@rushstack/ts-command-line@5.1.7':
resolution: {integrity: sha512-Ugwl6flarZcL2nqH5IXFYk3UR3mBVDsVFlCQW/Oaqidvdb/5Ota6b/Z3JXWIdqV3rOR2/JrYoAHanWF5rgenXA==} resolution: {integrity: sha512-Ugwl6flarZcL2nqH5IXFYk3UR3mBVDsVFlCQW/Oaqidvdb/5Ota6b/Z3JXWIdqV3rOR2/JrYoAHanWF5rgenXA==}
'@sentry-internal/browser-utils@10.38.0':
resolution: {integrity: sha512-UOJtYmdcxHCcV0NPfXFff/a95iXl/E0EhuQ1y0uE0BuZDMupWSF5t2BgC4HaE5Aw3RTjDF3XkSHWoIF6ohy7eA==}
engines: {node: '>=18'}
'@sentry-internal/feedback@10.38.0':
resolution: {integrity: sha512-JXneg9zRftyfy1Fyfc39bBlF/Qd8g4UDublFFkVvdc1S6JQPlK+P6q22DKz3Pc8w3ySby+xlIq/eTu9Pzqi4KA==}
engines: {node: '>=18'}
'@sentry-internal/replay-canvas@10.38.0':
resolution: {integrity: sha512-OXWM9jEqNYh4VTvrMu7v+z1anz+QKQ/fZXIZdsO7JTT2lGNZe58UUMeoq386M+Saxen8F9SUH7yTORy/8KI5qw==}
engines: {node: '>=18'}
'@sentry-internal/replay@10.38.0':
resolution: {integrity: sha512-YWIkL6/dnaiQyFiZXJ/nN+NXGv/15z45ia86bE/TMq01CubX/DUOilgsFz0pk2v/pg3tp/U2MskLO9Hz0cnqeg==}
engines: {node: '>=18'}
'@sentry/browser@10.38.0':
resolution: {integrity: sha512-3phzp1YX4wcQr9mocGWKbjv0jwtuoDBv7+Y6Yfrys/kwyaL84mDLjjQhRf4gL5SX7JdYkhBp4WaiNlR0UC4kTA==}
engines: {node: '>=18'}
'@sentry/core@10.38.0':
resolution: {integrity: sha512-1pubWDZE5y5HZEPMAZERP4fVl2NH3Ihp1A+vMoVkb3Qc66Diqj1WierAnStlZP7tCx0TBa0dK85GTW/ZFYyB9g==}
engines: {node: '>=18'}
'@sentry/react@10.38.0':
resolution: {integrity: sha512-3UiKo6QsqTyPGUt0XWRY9KLaxc/cs6Kz4vlldBSOXEL6qPDL/EfpwNJT61osRo81VFWu8pKu7ZY2bvLPryrnBQ==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.14.0 || 17.x || 18.x || 19.x
'@sinclair/typebox@0.27.8': '@sinclair/typebox@0.27.8':
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
@@ -4780,6 +4813,40 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@types/node' - '@types/node'
'@sentry-internal/browser-utils@10.38.0':
dependencies:
'@sentry/core': 10.38.0
'@sentry-internal/feedback@10.38.0':
dependencies:
'@sentry/core': 10.38.0
'@sentry-internal/replay-canvas@10.38.0':
dependencies:
'@sentry-internal/replay': 10.38.0
'@sentry/core': 10.38.0
'@sentry-internal/replay@10.38.0':
dependencies:
'@sentry-internal/browser-utils': 10.38.0
'@sentry/core': 10.38.0
'@sentry/browser@10.38.0':
dependencies:
'@sentry-internal/browser-utils': 10.38.0
'@sentry-internal/feedback': 10.38.0
'@sentry-internal/replay': 10.38.0
'@sentry-internal/replay-canvas': 10.38.0
'@sentry/core': 10.38.0
'@sentry/core@10.38.0': {}
'@sentry/react@10.38.0(react@19.2.4)':
dependencies:
'@sentry/browser': 10.38.0
'@sentry/core': 10.38.0
react: 19.2.4
'@sinclair/typebox@0.27.8': {} '@sinclair/typebox@0.27.8': {}
'@standard-schema/spec@1.0.0': {} '@standard-schema/spec@1.0.0': {}

View File

@@ -1,5 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { type PropsWithChildren } from 'react'; import React, { type PropsWithChildren } from 'react';
import errorManager from './ErrorManager';
interface ErrorBoundaryProps extends PropsWithChildren { interface ErrorBoundaryProps extends PropsWithChildren {
namespace?: string; namespace?: string;
onReportClick?: () => void; onReportClick?: () => void;
@@ -43,7 +46,12 @@ export class ReactBasicErrorBoundary extends React.PureComponent<
errorInfo, errorInfo,
try: false, try: false,
}); });
// You can also log error messages to an error reporting service here
// Report error to error manager (Sentry, custom API, etc.)
errorManager.reportError(error, errorInfo, {
componentStack: errorInfo?.componentStack,
namespace: this.props.namespace,
});
} }
render() { render() {

View File

@@ -1,7 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Button, Code, Collapse, Group, Paper, rem, Text } from '@mantine/core'; import { Button, Code, Collapse, Group, Paper, rem, Text } from '@mantine/core';
import { IconExclamationCircle } from '@tabler/icons-react'; import { IconExclamationCircle } from '@tabler/icons-react';
import React, { type PropsWithChildren } from 'react'; import React, { type PropsWithChildren } from 'react';
import errorManager from './ErrorManager';
let ErrorBoundaryOptions = { let ErrorBoundaryOptions = {
disabled: false, disabled: false,
onError: undefined, onError: undefined,
@@ -68,7 +71,12 @@ export class ReactErrorBoundary extends React.Component<ErrorBoundaryProps, Erro
if (typeof GetErrorBoundaryOptions()?.onError === 'function') { if (typeof GetErrorBoundaryOptions()?.onError === 'function') {
GetErrorBoundaryOptions()?.onError?.(error, errorInfo); GetErrorBoundaryOptions()?.onError?.(error, errorInfo);
} }
// You can also log error messages to an error reporting service here
// Report error to error manager (Sentry, custom API, etc.)
errorManager.reportError(error, errorInfo, {
componentStack: errorInfo?.componentStack,
namespace: this.props.namespace,
});
} }
render() { render() {
@@ -202,6 +210,19 @@ export class ReactErrorBoundary extends React.Component<ErrorBoundaryProps, Erro
})); }));
return; return;
} }
// Manually report error if user clicks report button
if (this.state.error && this.state.errorInfo) {
await errorManager.reportError(this.state.error, this.state.errorInfo, {
componentStack: this.state.errorInfo?.componentStack,
namespace: this.props.namespace,
tags: { reportedBy: 'user' },
});
this.setState(() => ({
reported: true,
}));
}
} }
reset() { reset() {

View File

@@ -0,0 +1,166 @@
# ErrorManager
Centralized error reporting for ErrorBoundary components.
## Setup
### Sentry Integration
```typescript
import { errorManager } from './ErrorBoundary';
errorManager.configure({
enabled: true,
sentry: {
dsn: 'https://your-sentry-dsn@sentry.io/project-id',
environment: 'production',
release: '1.0.0',
sampleRate: 1.0,
ignoreErrors: ['ResizeObserver loop limit exceeded'],
},
});
```
### Custom API Integration
```typescript
errorManager.configure({
enabled: true,
customAPI: {
endpoint: 'https://api.yourapp.com/errors',
method: 'POST',
headers: {
'Authorization': 'Bearer token',
},
transformPayload: (report) => ({
message: report.error.message,
stack: report.error.stack,
level: report.severity,
timestamp: report.timestamp,
}),
},
});
```
### Custom Reporter
```typescript
errorManager.configure({
enabled: true,
reporters: [
{
name: 'CustomLogger',
isEnabled: () => true,
captureError: async (report) => {
console.error('Error:', report.error);
},
},
],
});
```
### Multiple Reporters
```typescript
errorManager.configure({
enabled: true,
sentry: { dsn: 'your-dsn' },
customAPI: { endpoint: 'your-endpoint' },
reporters: [customReporter],
});
```
## Hooks
### beforeReport
```typescript
errorManager.configure({
beforeReport: (report) => {
// Filter errors
if (report.error.message.includes('ResizeObserver')) {
return null; // Skip reporting
}
// Enrich with user data
report.context = {
...report.context,
user: { id: getCurrentUserId() },
tags: { feature: 'checkout' },
};
return report;
},
});
```
### onReportSuccess / onReportFailure
```typescript
errorManager.configure({
onReportSuccess: (report) => {
console.log('Error reported successfully');
},
onReportFailure: (error, report) => {
console.error('Failed to report error:', error);
},
});
```
## Manual Reporting
```typescript
try {
riskyOperation();
} catch (error) {
await errorManager.reportError(error as Error, null, {
namespace: 'checkout',
tags: { step: 'payment' },
extra: { orderId: '123' },
});
}
```
## Disable/Enable
```typescript
// Disable reporting
errorManager.configure({ enabled: false });
// Enable reporting
errorManager.configure({ enabled: true });
```
## ErrorBoundary Integration
Automatic - errors caught by `ReactErrorBoundary` or `ReactBasicErrorBoundary` are automatically reported.
Manual report button in `ReactErrorBoundary` UI also sends to ErrorManager.
## Install Sentry (optional)
```bash
npm install @sentry/react
```
## Types
```typescript
type ErrorSeverity = 'fatal' | 'error' | 'warning' | 'info' | 'debug';
interface ErrorContext {
namespace?: string;
componentStack?: string;
user?: Record<string, any>;
tags?: Record<string, string>;
extra?: Record<string, any>;
}
interface ErrorReport {
error: Error;
errorInfo?: any;
severity?: ErrorSeverity;
context?: ErrorContext;
timestamp?: number;
}
```

View File

@@ -0,0 +1,194 @@
import type {
CustomAPIConfig,
ErrorManagerConfig,
ErrorReport,
ErrorReporter,
SentryConfig,
} from './ErrorManager.types';
class ErrorManager {
private config: ErrorManagerConfig = { enabled: true };
private reporters: ErrorReporter[] = [];
private sentryInstance: any = null;
configure(config: ErrorManagerConfig) {
this.config = { ...this.config, ...config };
this.reporters = [];
if (config.sentry) {
this.setupSentry(config.sentry);
}
if (config.customAPI) {
this.setupCustomAPI(config.customAPI);
}
if (config.reporters) {
this.reporters.push(...config.reporters);
}
}
destroy() {
this.reporters = [];
this.sentryInstance = null;
this.config = { enabled: true };
}
getReporters(): ErrorReporter[] {
return [...this.reporters];
}
isEnabled(): boolean {
return Boolean(this.config.enabled);
}
async reportError(
error: Error,
errorInfo?: any,
context?: ErrorReport['context']
): Promise<void> {
if (!this.config.enabled) {
return;
}
let report: ErrorReport = {
context,
error,
errorInfo,
severity: 'error',
timestamp: Date.now(),
};
if (this.config.beforeReport) {
const modifiedReport = this.config.beforeReport(report);
if (!modifiedReport) {
return;
}
report = modifiedReport;
}
const reportPromises = this.reporters
.filter((reporter) => reporter.isEnabled())
.map(async (reporter) => {
try {
await reporter.captureError(report);
} catch (error) {
console.error(`Error reporter "${reporter.name}" failed:`, error);
if (this.config.onReportFailure) {
this.config.onReportFailure(error as Error, report);
}
}
});
try {
await Promise.all(reportPromises);
if (this.config.onReportSuccess) {
this.config.onReportSuccess(report);
}
} catch (error) {
console.error('Error reporting failed:', error);
}
}
private setupCustomAPI(config: CustomAPIConfig) {
const customAPIReporter: ErrorReporter = {
captureError: async (report: ErrorReport) => {
try {
const payload = config.transformPayload
? config.transformPayload(report)
: {
context: report.context,
message: report.error.message,
severity: report.severity || 'error',
stack: report.error.stack,
timestamp: report.timestamp || Date.now(),
};
const response = await fetch(config.endpoint, {
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json',
...config.headers,
},
method: config.method || 'POST',
});
if (!response.ok) {
throw new Error(`API request failed: ${response.statusText}`);
}
} catch (error) {
console.error('Failed to send error to custom API:', error);
}
},
isEnabled: () => Boolean(config.endpoint),
name: 'CustomAPI',
};
this.reporters.push(customAPIReporter);
}
private setupSentry(config: SentryConfig) {
const sentryReporter: ErrorReporter = {
captureError: async (report: ErrorReport) => {
if (!this.sentryInstance) {
try {
const Sentry = await import('@sentry/react');
Sentry.init({
beforeSend: config.beforeSend,
dsn: config.dsn,
environment: config.environment,
ignoreErrors: config.ignoreErrors,
release: config.release,
tracesSampleRate: config.sampleRate || 1.0,
});
this.sentryInstance = Sentry;
} catch (error) {
console.error('Failed to initialize Sentry:', error);
return;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.sentryInstance.withScope((scope: any) => {
if (report.severity) {
scope.setLevel(report.severity);
}
if (report.context?.namespace) {
scope.setTag('namespace', report.context.namespace);
}
if (report.context?.tags) {
Object.entries(report.context.tags).forEach(([key, value]) => {
scope.setTag(key, value);
});
}
if (report.context?.user) {
scope.setUser(report.context.user);
}
if (report.context?.extra) {
scope.setExtras(report.context.extra);
}
if (report.context?.componentStack) {
scope.setContext('react', {
componentStack: report.context.componentStack,
});
}
this.sentryInstance.captureException(report.error);
});
},
isEnabled: () => Boolean(this.sentryInstance),
name: 'Sentry',
};
this.reporters.push(sentryReporter);
}
}
export const errorManager = new ErrorManager();
export default errorManager;

View File

@@ -0,0 +1,50 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export interface CustomAPIConfig {
endpoint: string;
headers?: Record<string, string>;
method?: 'POST' | 'PUT';
transformPayload?: (report: ErrorReport) => any;
}
export interface ErrorContext {
componentStack?: string;
extra?: Record<string, any>;
namespace?: string;
tags?: Record<string, string>;
user?: Record<string, any>;
}
export interface ErrorManagerConfig {
beforeReport?: (report: ErrorReport) => ErrorReport | null;
customAPI?: CustomAPIConfig;
enabled?: boolean;
onReportFailure?: (error: Error, report: ErrorReport) => void;
onReportSuccess?: (report: ErrorReport) => void;
reporters?: ErrorReporter[];
sentry?: SentryConfig;
}
export interface ErrorReport {
context?: ErrorContext;
error: Error;
errorInfo?: any;
severity?: ErrorSeverity;
timestamp?: number;
}
export interface ErrorReporter {
captureError: (report: ErrorReport) => Promise<void> | void;
isEnabled: () => boolean;
name: string;
}
export type ErrorSeverity = 'debug' | 'error' | 'fatal' | 'info' | 'warning';
export interface SentryConfig {
beforeSend?: (event: any) => any | null;
dsn: string;
environment?: string;
ignoreErrors?: string[];
release?: string;
sampleRate?: number;
}

View File

@@ -1,2 +1,4 @@
export { default as ReactBasicErrorBoundary } from './BasicErrorBoundary'; export { default as ReactBasicErrorBoundary } from './BasicErrorBoundary';
export { default as ReactErrorBoundary } from './ErrorBoundary'; export { default as ReactErrorBoundary } from './ErrorBoundary';
export { default as errorManager } from './ErrorManager';
export * from './ErrorManager.types';

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { FormerAPICallType } from './Former.types'; import type { FormerAPICallType } from './Former.types';
interface ResolveSpecRequest { interface ResolveSpecRequest {
@@ -65,7 +66,7 @@ function FormerResolveSpecAPI(options: {
} }
const data = await response.json(); const data = await response.json();
return data as any; return data as unknown;
}; };
} }

View File

@@ -6,7 +6,7 @@ import { fn } from 'storybook/test';
import { FormTest } from './example'; import { FormTest } from './example';
const Renderable = (props: any) => { const Renderable = (props: unknown) => {
return ( return (
<Box h="100%" mih="400px" miw="400px" w="100%"> <Box h="100%" mih="400px" miw="400px" w="100%">
<FormTest {...props} /> <FormTest {...props} />

View File

@@ -5,7 +5,6 @@ import { useStoreWithEqualityFn } from 'zustand/traditional';
import { createStore } from 'zustand/vanilla'; import { createStore } from 'zustand/vanilla';
import type { import type {
AppState,
BarState, BarState,
ExtractState, ExtractState,
GlobalState, GlobalState,
@@ -21,10 +20,6 @@ import type {
import { loadStorage, saveStorage } from './GlobalStateStore.utils'; import { loadStorage, saveStorage } from './GlobalStateStore.utils';
const initialState: GlobalState = { const initialState: GlobalState = {
app: {
controls: {},
environment: 'production',
},
layout: { layout: {
bottomBar: { open: false }, bottomBar: { open: false },
leftBar: { open: false }, leftBar: { open: false },
@@ -35,10 +30,14 @@ const initialState: GlobalState = {
menu: [], menu: [],
}, },
owner: { owner: {
guid: '',
id: 0, id: 0,
name: '', name: '',
}, },
program: { program: {
controls: {},
environment: 'production',
guid: '',
name: '', name: '',
slug: '', slug: '',
}, },
@@ -47,8 +46,10 @@ const initialState: GlobalState = {
authToken: '', authToken: '',
connected: true, connected: true,
loading: false, loading: false,
loggedIn: false,
}, },
user: { user: {
guid: '',
username: '', username: '',
}, },
}; };
@@ -140,13 +141,6 @@ const createNavigationSlice = (set: SetState) => ({
})), })),
}); });
const createAppSlice = (set: SetState) => ({
setApp: (updates: Partial<AppState>) =>
set((state: GlobalState) => ({
app: { ...state.app, ...updates },
})),
});
const createComplexActions = (set: SetState, get: GetState) => ({ const createComplexActions = (set: SetState, get: GetState) => ({
fetchData: async (url?: string) => { fetchData: async (url?: string) => {
try { try {
@@ -164,9 +158,9 @@ const createComplexActions = (set: SetState, get: GetState) => ({
set((state: GlobalState) => ({ set((state: GlobalState) => ({
...state, ...state,
...result, ...result,
app: { program: {
...state.app, ...state.program,
...result?.app, ...result?.program,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}, },
session: { session: {
@@ -188,25 +182,101 @@ const createComplexActions = (set: SetState, get: GetState) => ({
} }
}, },
isLoggedIn: (): boolean => {
const session = get().session;
if (!session.loggedIn || !session.authToken) {
return false;
}
if (session.expiryDate && new Date(session.expiryDate) < new Date()) {
return false;
}
return true;
},
login: async (authToken?: string) => { login: async (authToken?: string) => {
try {
set((state: GlobalState) => ({ set((state: GlobalState) => ({
session: { session: {
...state.session, ...state.session,
authToken: authToken ?? '', authToken: authToken ?? '',
expiryDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
loading: true,
loggedIn: true,
}, },
})); }));
await get().fetchData(); await get().fetchData();
const currentState = get();
const result = await currentState.onLogin?.(currentState);
if (result) {
set((state: GlobalState) => ({
...state,
owner: result.owner ? { ...state.owner, ...result.owner } : state.owner,
program: result.program ? { ...state.program, ...result.program } : state.program,
session: result.session ? { ...state.session, ...result.session } : state.session,
user: result.user ? { ...state.user, ...result.user } : state.user,
}));
}
} catch (e) {
set((state: GlobalState) => ({
session: {
...state.session,
connected: false,
error: `Login Exception: ${String(e)}`,
loading: false,
loggedIn: false,
},
}));
} finally {
set((state: GlobalState) => ({
session: {
...state.session,
loading: false,
},
}));
}
}, },
logout: async () => { logout: async () => {
try {
set((state: GlobalState) => ({ set((state: GlobalState) => ({
...initialState, ...initialState,
session: { session: {
...initialState.session, ...initialState.session,
apiURL: state.session.apiURL, apiURL: state.session.apiURL,
expiryDate: undefined,
loading: true,
loggedIn: false,
}, },
})); }));
await get().fetchData(); await get().fetchData();
const currentState = get();
const result = await currentState.onLogout?.(currentState);
if (result) {
set((state: GlobalState) => ({
...state,
owner: result.owner ? { ...state.owner, ...result.owner } : state.owner,
program: result.program ? { ...state.program, ...result.program } : state.program,
session: result.session ? { ...state.session, ...result.session } : state.session,
user: result.user ? { ...state.user, ...result.user } : state.user,
}));
}
} catch (e) {
set((state: GlobalState) => ({
session: {
...state.session,
connected: false,
error: `Logout Exception: ${String(e)}`,
loading: false,
},
}));
} finally {
set((state: GlobalState) => ({
session: {
...state.session,
loading: false,
},
}));
}
}, },
}); });
@@ -218,7 +288,6 @@ const GlobalStateStore = createStore<GlobalStateStoreType>((set, get) => ({
...createUserSlice(set), ...createUserSlice(set),
...createLayoutSlice(set), ...createLayoutSlice(set),
...createNavigationSlice(set), ...createNavigationSlice(set),
...createAppSlice(set),
...createComplexActions(set, get), ...createComplexActions(set, get),
})); }));
@@ -267,6 +336,10 @@ const getAuthToken = (): string => {
return GlobalStateStore.getState().session.authToken; return GlobalStateStore.getState().session.authToken;
}; };
const isLoggedIn = (): boolean => {
return GlobalStateStore.getState().isLoggedIn();
};
const setAuthToken = (token: string) => { const setAuthToken = (token: string) => {
GlobalStateStore.getState().setAuthToken(token); GlobalStateStore.getState().setAuthToken(token);
}; };
@@ -275,4 +348,4 @@ const GetGlobalState = (): GlobalStateStoreType => {
return GlobalStateStore.getState(); return GlobalStateStore.getState();
} }
export { getApiURL, getAuthToken, GetGlobalState, GlobalStateStore, setApiURL, setAuthToken, useGlobalStateStore }; export { getApiURL, getAuthToken, GetGlobalState, GlobalStateStore, isLoggedIn, setApiURL, setAuthToken, useGlobalStateStore };

View File

@@ -1,10 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
interface AppState {
controls?: Record<string, any>;
environment: 'development' | 'production';
globals?: Record<string, any>;
updatedAt?: string;
}
interface BarState { interface BarState {
collapsed?: boolean; collapsed?: boolean;
@@ -14,7 +8,6 @@ interface BarState {
pinned?: boolean; pinned?: boolean;
render?: () => React.ReactNode; render?: () => React.ReactNode;
size?: number; size?: number;
} }
type DatabaseDetail = { type DatabaseDetail = {
@@ -25,7 +18,6 @@ type DatabaseDetail = {
type ExtractState<S> = S extends { getState: () => infer X } ? X : never; type ExtractState<S> = S extends { getState: () => infer X } ? X : never;
interface GlobalState { interface GlobalState {
app: AppState;
layout: LayoutState; layout: LayoutState;
navigation: NavigationState; navigation: NavigationState;
owner: OwnerState; owner: OwnerState;
@@ -38,15 +30,19 @@ interface GlobalStateActions {
// Complex actions // Complex actions
fetchData: (url?: string) => Promise<void>; fetchData: (url?: string) => Promise<void>;
isLoggedIn: () => boolean;
login: (authToken?: string) => Promise<void>; login: (authToken?: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
// Callback for custom fetch logic // Callbacks for custom logic
onFetchSession?: (state: GlobalState) => Promise<Partial<GlobalState>>; onFetchSession?: (state: GlobalState) => Promise<Partial<GlobalState>>;
onLogin?: (
state: GlobalState
) => Promise<Pick<GlobalState, 'owner' | 'program' | 'session' | 'user'> | void>;
onLogout?: (
state: GlobalState
) => Promise<Pick<GlobalState, 'owner' | 'program' | 'session' | 'user'> | void>;
setApiURL: (url: string) => void; setApiURL: (url: string) => void;
// App actions
setApp: (updates: Partial<AppState>) => void;
setAuthToken: (token: string) => void; setAuthToken: (token: string) => void;
setBottomBar: (updates: Partial<BarState>) => void; setBottomBar: (updates: Partial<BarState>) => void;
setCurrentPage: (page: PageInfo) => void; setCurrentPage: (page: PageInfo) => void;
@@ -101,6 +97,7 @@ interface NavigationState {
} }
interface OwnerState { interface OwnerState {
guid?: string;
id: number; id: number;
logo?: string; logo?: string;
name: string; name: string;
@@ -118,24 +115,19 @@ type PageInfo = {
interface ProgramState { interface ProgramState {
backendVersion?: string; backendVersion?: string;
bigLogo?: string; bigLogo?: string;
controls?: Record<string, any>;
database?: DatabaseDetail; database?: DatabaseDetail;
databaseVersion?: string; databaseVersion?: string;
description?: string; description?: string;
environment: 'development' | 'production';
globals?: Record<string, any>;
guid?: string;
logo?: string; logo?: string;
meta?: Record<string, any>; meta?: Record<string, any>;
name: string; name: string;
slug: string; slug: string;
tags?: string[]; tags?: string[];
version?: string; updatedAt?: string;
}
interface ProgramWrapperProps {
apiURL?: string;
children: React.ReactNode | React.ReactNode[];
debugMode?: boolean;
fallback?: React.ReactNode | React.ReactNode[];
renderFallback?: boolean;
testMode?: boolean;
version?: string; version?: string;
} }
@@ -144,8 +136,10 @@ interface SessionState {
authToken: string; authToken: string;
connected: boolean; connected: boolean;
error?: string; error?: string;
expiryDate?: string;
isSecurity?: boolean; isSecurity?: boolean;
loading: boolean; loading: boolean;
loggedIn: boolean;
meta?: Record<string, any>; meta?: Record<string, any>;
parameters?: Record<string, any>; parameters?: Record<string, any>;
} }
@@ -169,7 +163,6 @@ interface UserState {
} }
export type { export type {
AppState,
BarState, BarState,
ExtractState, ExtractState,
GlobalState, GlobalState,
@@ -181,7 +174,6 @@ export type {
OwnerState, OwnerState,
PageInfo, PageInfo,
ProgramState, ProgramState,
ProgramWrapperProps,
SessionState, SessionState,
ThemeSettings, ThemeSettings,
UserState, UserState,

View File

@@ -1,10 +1,17 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef } from 'react'; import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from 'react';
import type { GlobalStateStoreType } from './GlobalStateStore.types'; import type { GlobalState, GlobalStateStoreType, ProgramState } from './GlobalStateStore.types';
import { GetGlobalState, GlobalStateStore } from './GlobalStateStore'; import { GetGlobalState, GlobalStateStore } from './GlobalStateStore';
interface GlobalStateStoreContextValue { interface GlobalStateStoreContextValue {
fetchData: (url?: string) => Promise<void>; fetchData: (url?: string) => Promise<void>;
getState: () => GlobalStateStoreType; getState: () => GlobalStateStoreType;
@@ -13,12 +20,15 @@ interface GlobalStateStoreContextValue {
const GlobalStateStoreContext = createContext<GlobalStateStoreContextValue | null>(null); const GlobalStateStoreContext = createContext<GlobalStateStoreContextValue | null>(null);
interface GlobalStateStoreProviderProps { interface GlobalStateStoreProviderProps {
apiURL?: string; apiURL?: string;
autoFetch?: boolean; autoFetch?: boolean;
children: ReactNode; children: ReactNode;
fetchOnMount?: boolean; fetchOnMount?: boolean;
onFetchSession?: (state: GlobalState) => Promise<Partial<GlobalState>>;
onLogin?: (state: GlobalState) => Promise<void>;
onLogout?: (state: GlobalState) => Promise<void>;
program?: Partial<ProgramState>;
throttleMs?: number; throttleMs?: number;
} }
@@ -27,13 +37,16 @@ export function GlobalStateStoreProvider({
autoFetch = true, autoFetch = true,
children, children,
fetchOnMount = true, fetchOnMount = true,
onFetchSession,
onLogin,
onLogout,
program,
throttleMs = 0, throttleMs = 0,
}: GlobalStateStoreProviderProps) { }: GlobalStateStoreProviderProps) {
const lastFetchTime = useRef<number>(0); const lastFetchTime = useRef<number>(0);
const fetchInProgress = useRef<boolean>(false); const fetchInProgress = useRef<boolean>(false);
const mounted = useRef<boolean>(false); const mounted = useRef<boolean>(false);
const throttledFetch = useCallback( const throttledFetch = useCallback(
async (url?: string) => { async (url?: string) => {
const now = Date.now(); const now = Date.now();
@@ -68,6 +81,30 @@ export function GlobalStateStoreProvider({
} }
}, [apiURL]); }, [apiURL]);
useEffect(() => {
if (program) {
GlobalStateStore.getState().setProgram(program);
}
}, [program]);
useEffect(() => {
if (onFetchSession) {
GlobalStateStore.setState({ onFetchSession });
}
}, [onFetchSession]);
useEffect(() => {
if (onLogin) {
GlobalStateStore.setState({ onLogin });
}
}, [onLogin]);
useEffect(() => {
if (onLogout) {
GlobalStateStore.setState({ onLogout });
}
}, [onLogout]);
useEffect(() => { useEffect(() => {
if (!mounted.current) { if (!mounted.current) {
mounted.current = true; mounted.current = true;
@@ -88,12 +125,8 @@ export function GlobalStateStoreProvider({
}; };
}, [throttledFetch, refetch]); }, [throttledFetch, refetch]);
return ( return (
<GlobalStateStoreContext.Provider value={context}> <GlobalStateStoreContext.Provider value={context}>{children}</GlobalStateStoreContext.Provider>
{children}
</GlobalStateStoreContext.Provider>
); );
} }

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Button, Checkbox, Divider, Group, Stack, TagsInput, TextInput } from '@mantine/core'; import { Button, Checkbox, Divider, Group, Stack, TagsInput, TextInput } from '@mantine/core';
import { useLocalStorage } from '@mantine/hooks'; import { useLocalStorage } from '@mantine/hooks';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
@@ -22,9 +23,20 @@ export const GridlerGoAPIExampleEventlog = () => {
const [selectRow, setSelectRow] = useState<string | undefined>(''); const [selectRow, setSelectRow] = useState<string | undefined>('');
const [values, setValues] = useState<Array<Record<string, any>>>([]); const [values, setValues] = useState<Array<Record<string, any>>>([]);
const [search, setSearch] = useState<string>(''); const [search, setSearch] = useState<string>('');
const [formProps, setFormProps] = useState<{ onChange?: any; onClose?: any; opened: boolean; request: any; title?: string; values: any; } | null>({ const [formProps, setFormProps] = useState<{
onChange: (_request: string, data: any) => { ref.current?.refresh({ value: data }); }, onChange?: any;
onClose: () => { setFormProps((cv) => ({ ...cv, opened: false, request: null, values: null })) }, onClose?: any;
opened: boolean;
request: any;
title?: string;
values: any;
} | null>({
onChange: (_request: string, data: any) => {
ref.current?.refresh({ value: data });
},
onClose: () => {
setFormProps((cv) => ({ ...cv, opened: false, request: null, values: null }));
},
opened: false, opened: false,
request: null, request: null,
values: null, values: null,
@@ -33,7 +45,8 @@ export const GridlerGoAPIExampleEventlog = () => {
const columns: GridlerColumns = [ const columns: GridlerColumns = [
{ {
Cell: (row) => { Cell: (row) => {
const process = `${row?.cql2?.length > 0 const process = `${
row?.cql2?.length > 0
? '🔖' ? '🔖'
: row?.cql1?.length > 0 : row?.cql1?.length > 0
? '📕' ? '📕'
@@ -140,14 +153,14 @@ export const GridlerGoAPIExampleEventlog = () => {
descriptionField={'process'} descriptionField={'process'}
onRequestForm={(request, data) => { onRequestForm={(request, data) => {
setFormProps((cv) => { setFormProps((cv) => {
return {...cv, opened: true, request: request as any, values: data as any} return { ...cv, opened: true, request: request as any, values: data as any };
}) });
}} }}
/> />
</Gridler> </Gridler>
<FormerDialog <FormerDialog
former={{ former={{
request: formProps?.request ?? "insert", request: formProps?.request ?? 'insert',
values: formProps?.values, values: formProps?.values,
}} }}
onClose={formProps?.onClose} onClose={formProps?.onClose}
@@ -158,7 +171,7 @@ export const GridlerGoAPIExampleEventlog = () => {
<TextInputCtrl label="Process Name" name="process" /> <TextInputCtrl label="Process Name" name="process" />
<NumberInputCtrl label="Sequence" name="sequence" /> <NumberInputCtrl label="Sequence" name="sequence" />
<InlineWrapper label="Type" promptWidth={200}> <InlineWrapper label="Type" promptWidth={200}>
<NativeSelectCtrl data={["trigger","function","view"]} name="type"/> <NativeSelectCtrl data={['trigger', 'function', 'view']} name="type" />
</InlineWrapper> </InlineWrapper>
</Stack> </Stack>
</FormerDialog> </FormerDialog>

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
//@ts-nocheck //@ts-nocheck
import type { Meta, StoryObj } from '@storybook/react-vite'; import type { Meta, StoryObj } from '@storybook/react-vite';

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
//@ts-nocheck //@ts-nocheck
import type { Meta, StoryObj } from '@storybook/react-vite'; import type { Meta, StoryObj } from '@storybook/react-vite';

View File

@@ -1,30 +1,29 @@
import {b64EncodeUnicode} from '@warkypublic/artemis-kit/base64' /* eslint-disable @typescript-eslint/no-explicit-any */
const TOKEN_KEY = 'gridler_golang_restapi_v2_token' import { b64EncodeUnicode } from '@warkypublic/artemis-kit/base64';
const TOKEN_KEY = 'gridler_golang_restapi_v2_token';
export type APIOptionsType = { export type APIOptionsType = {
autocreate?: boolean autocreate?: boolean;
autoref?: boolean autoref?: boolean;
baseurl?: string baseurl?: string;
getAPIProvider?: () => { provider: string; providerKey: string } getAPIProvider?: () => { provider: string; providerKey: string };
getAuthToken?: () => string getAuthToken?: () => string;
operations?: Array<FetchAPIOperation> operations?: Array<FetchAPIOperation>;
postfix?: string postfix?: string;
prefix?: string prefix?: string;
requestTimeoutSec?: number requestTimeoutSec?: number;
} };
export interface APIResponse { export interface APIResponse {
errmsg: string errmsg: string;
payload?: any payload?: any;
retval: number retval: number;
} }
export interface FetchAPIOperation { export interface FetchAPIOperation {
name?: string name?: string;
op?: string op?: string;
type: GoAPIHeaderTypes //x-fieldfilter type: GoAPIHeaderTypes; //x-fieldfilter
value: string value: string;
} }
/** /**
* @description Types for the Go Rest API headers * @description Types for the Go Rest API headers
@@ -72,28 +71,24 @@ export type GoAPIEnum =
| 'simpleapi' | 'simpleapi'
| 'skipcache' | 'skipcache'
| 'skipcount' | 'skipcount'
| 'sort' | 'sort';
export type GoAPIHeaderKeys = `x-${GoAPIEnum}`;
export type GoAPIHeaderKeys = `x-${GoAPIEnum}` export type GoAPIHeaderTypes = GoAPIEnum & string;
export type GoAPIHeaderTypes = GoAPIEnum & string
export interface GoAPIOperation { export interface GoAPIOperation {
name?: string name?: string;
op?: string op?: string;
type: GoAPIHeaderTypes //x-fieldfilter type: GoAPIHeaderTypes; //x-fieldfilter
value: string value: string;
} }
export interface MetaData { export interface MetaData {
limit?: number limit?: number;
offset?: number offset?: number;
total?: number total?: number;
} }
/** /**
* Builds an array of objects by encoding specific values and setting headers. * Builds an array of objects by encoding specific values and setting headers.
* *
@@ -105,50 +100,49 @@ const buildGoAPIOperation = (
ops: Array<FetchAPIOperation>, ops: Array<FetchAPIOperation>,
headers?: Headers headers?: Headers
): Array<FetchAPIOperation> => { ): Array<FetchAPIOperation> => {
const newops = [...ops.filter((i) => i !== undefined && i.type !== undefined)] const newops = [...ops.filter((i) => i !== undefined && i.type !== undefined)];
for (let i = 0; i < newops.length; i++) { for (let i = 0; i < newops.length; i++) {
if (!newops[i].name || newops[i].name === '') { if (!newops[i].name || newops[i].name === '') {
newops[i].name = '' newops[i].name = '';
} }
if (newops[i].type === 'files' && !newops[i].value.startsWith('__')) { if (newops[i].type === 'files' && !newops[i].value.startsWith('__')) {
newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__` newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`;
} }
if (newops[i].type === 'advsql' && !newops[i].value.startsWith('__')) { if (newops[i].type === 'advsql' && !newops[i].value.startsWith('__')) {
newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__` newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`;
} }
if (newops[i].type === 'custom-sql-or' && !newops[i].value.startsWith('__')) { if (newops[i].type === 'custom-sql-or' && !newops[i].value.startsWith('__')) {
newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__` newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`;
} }
if (newops[i].type === 'custom-sql-join' && !newops[i].value.startsWith('__')) { if (newops[i].type === 'custom-sql-join' && !newops[i].value.startsWith('__')) {
newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__` newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`;
} }
if (newops[i].type === 'not-select-fields' && !newops[i].value.startsWith('__')) { if (newops[i].type === 'not-select-fields' && !newops[i].value.startsWith('__')) {
newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__` newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`;
} }
if (newops[i].type === 'custom-sql-w' && !newops[i].value.startsWith('__')) { if (newops[i].type === 'custom-sql-w' && !newops[i].value.startsWith('__')) {
newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__` newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`;
} }
if (newops[i].type === 'select-fields' && !newops[i].value.startsWith('__')) { if (newops[i].type === 'select-fields' && !newops[i].value.startsWith('__')) {
newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__` newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`;
} }
if (newops[i].type === 'cql-sel' && !newops[i].value.startsWith('__')) { if (newops[i].type === 'cql-sel' && !newops[i].value.startsWith('__')) {
newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__` newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`;
} }
if (headers) { if (headers) {
if (!newops || newops.length === 0) { if (!newops || newops.length === 0) {
headers.set(`x-limit`, '10') headers.set(`x-limit`, '10');
} }
if (newops[i].type === 'association_autoupdate') { if (newops[i].type === 'association_autoupdate') {
headers.set(`association_autoupdate`, newops[i].value ?? '1') headers.set(`association_autoupdate`, newops[i].value ?? '1');
} }
if (newops[i].type === 'association_autocreate') { if (newops[i].type === 'association_autocreate') {
headers.set(`association_autocreate`, newops[i].value ?? '1') headers.set(`association_autocreate`, newops[i].value ?? '1');
} }
if ( if (
newops[i].type === 'searchop' || newops[i].type === 'searchop' ||
@@ -158,20 +152,20 @@ const buildGoAPIOperation = (
headers.set( headers.set(
encodeURIComponent(`x-${newops[i].type}-${newops[i].op}-${newops[i].name}`), encodeURIComponent(`x-${newops[i].type}-${newops[i].op}-${newops[i].name}`),
String(newops[i].value) String(newops[i].value)
) );
} else { } else {
headers.set( headers.set(
encodeURIComponent( encodeURIComponent(
`x-${newops[i].type}${newops[i].name && newops[i].name !== '' ? '-' + newops[i].name : ''}` `x-${newops[i].type}${newops[i].name && newops[i].name !== '' ? '-' + newops[i].name : ''}`
), ),
String(newops[i].value) String(newops[i].value)
) );
} }
} }
} }
return newops return newops;
} };
/** /**
* Retrieves the headers from an array of FetchAPIOperation objects and returns them as an object. * Retrieves the headers from an array of FetchAPIOperation objects and returns them as an object.
@@ -183,77 +177,75 @@ const GoAPIHeaders = (
ops: Array<FetchAPIOperation>, ops: Array<FetchAPIOperation>,
headers?: Headers headers?: Headers
): { [key: string]: string } => { ): { [key: string]: string } => {
const head = new Headers() const head = new Headers();
const headerlist: Record<string,string> = {} const headerlist: Record<string, string> = {};
const authToken = getAuthToken?.() const authToken = getAuthToken?.();
if (authToken && authToken !== '') { if (authToken && authToken !== '') {
head.set('Authorization', `Token ${authToken}`);
head.set('Authorization', `Token ${authToken}`)
} else { } else {
const token = getAuthToken() const token = getAuthToken();
if (token) { if (token) {
head.set('Authorization', `Token ${token}`) head.set('Authorization', `Token ${token}`);
} }
} }
if (headers) { if (headers) {
headers.forEach((v, k) => { headers.forEach((v, k) => {
head.set(k, v) head.set(k, v);
}) });
} }
const distinctOperations: Array<FetchAPIOperation> = [] const distinctOperations: Array<FetchAPIOperation> = [];
for (const value of ops?.filter((val) => !!val) ?? []) { for (const value of ops?.filter((val) => !!val) ?? []) {
const index = distinctOperations.findIndex( const index = distinctOperations.findIndex(
(searchValue) => searchValue.name === value.name && searchValue.type === value.type (searchValue) => searchValue.name === value.name && searchValue.type === value.type
) );
if (index === -1) { if (index === -1) {
distinctOperations.push(value) distinctOperations.push(value);
} else { } else {
distinctOperations[index] = value distinctOperations[index] = value;
} }
} }
buildGoAPIOperation(distinctOperations, head) buildGoAPIOperation(distinctOperations, head);
head?.forEach((v, k) => { head?.forEach((v, k) => {
headerlist[k] = v headerlist[k] = v;
}) });
if (headers) { if (headers) {
for (const key of Object.keys(headerlist)) { for (const key of Object.keys(headerlist)) {
headers.set(key, headerlist[key]) headers.set(key, headerlist[key]);
} }
} }
return headerlist return headerlist;
} };
const callbacks = { const callbacks = {
getAuthToken: () => { getAuthToken: () => {
if (localStorage) { if (localStorage) {
const token = localStorage.getItem(TOKEN_KEY) const token = localStorage.getItem(TOKEN_KEY);
if (token) { if (token) {
return token return token;
}
}
return undefined
} }
} }
return undefined;
},
};
/** /**
* Retrieves the authentication token from local storage. * Retrieves the authentication token from local storage.
* *
* @return {string | undefined} The authentication token if found, otherwise undefined * @return {string | undefined} The authentication token if found, otherwise undefined
*/ */
const getAuthToken = () => callbacks?.getAuthToken?.() const getAuthToken = () => callbacks?.getAuthToken?.();
const setAuthTokenCallback = (cb: () => string) => { const setAuthTokenCallback = (cb: () => string) => {
callbacks.getAuthToken = cb callbacks.getAuthToken = cb;
return callbacks.getAuthToken return callbacks.getAuthToken;
} };
/** /**
* Sets the authentication token in the local storage. * Sets the authentication token in the local storage.
@@ -262,9 +254,8 @@ const setAuthTokenCallback = (cb: ()=> string) => {
*/ */
const setAuthToken = (token: string) => { const setAuthToken = (token: string) => {
if (localStorage) { if (localStorage) {
localStorage.setItem(TOKEN_KEY, token) localStorage.setItem(TOKEN_KEY, token);
}
} }
};
export { buildGoAPIOperation, getAuthToken, GoAPIHeaders, setAuthToken, setAuthTokenCallback };
export {buildGoAPIOperation,getAuthToken,GoAPIHeaders,setAuthToken,setAuthTokenCallback}