From d7b1eb26f307e88820adb79d0d54983e7cd990ac Mon Sep 17 00:00:00 2001 From: Hein Date: Sat, 7 Feb 2026 21:11:48 +0200 Subject: [PATCH] docs(changeset): feat(error-manager): implement centralized error reporting system --- .changeset/social-lamps-learn.md | 5 + package.json | 1 + pnpm-lock.yaml | 67 ++++++ src/ErrorBoundary/BasicErrorBoundary.tsx | 10 +- src/ErrorBoundary/ErrorBoundary.tsx | 23 ++- src/ErrorBoundary/ErrorManager.README.md | 166 +++++++++++++++ src/ErrorBoundary/ErrorManager.ts | 194 ++++++++++++++++++ src/ErrorBoundary/ErrorManager.types.ts | 50 +++++ src/ErrorBoundary/index.ts | 2 + src/Former/FormerResolveSpecAPI.ts | 3 +- src/Former/stories/Former.goapi.stories.tsx | 2 +- src/GlobalStateStore/GlobalStateStore.ts | 137 ++++++++++--- .../GlobalStateStore.types.ts | 40 ++-- .../GlobalStateStoreWrapper.tsx | 53 ++++- src/Gridler/stories/Examples.goapi.tsx | 63 +++--- src/Gridler/stories/Gridler.goapi.stories.tsx | 1 + .../stories/Gridler.localdata.stories.tsx | 1 + src/Gridler/utils/golang-restapi-v2/index.ts | 175 ++++++++-------- 18 files changed, 806 insertions(+), 187 deletions(-) create mode 100644 .changeset/social-lamps-learn.md create mode 100644 src/ErrorBoundary/ErrorManager.README.md create mode 100644 src/ErrorBoundary/ErrorManager.ts create mode 100644 src/ErrorBoundary/ErrorManager.types.ts diff --git a/.changeset/social-lamps-learn.md b/.changeset/social-lamps-learn.md new file mode 100644 index 0000000..97af93e --- /dev/null +++ b/.changeset/social-lamps-learn.md @@ -0,0 +1,5 @@ +--- +'@warkypublic/oranguru': patch +--- + +feat(error-manager): implement centralized error reporting system diff --git a/package.json b/package.json index 4b45944..0b69ab0 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@changesets/cli": "^2.29.8", "@eslint/js": "^9.39.2", "@microsoft/api-extractor": "^7.56.0", + "@sentry/react": "^10.38.0", "@storybook/react-vite": "^10.2.3", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd9e8d9..d73af39 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: '@microsoft/api-extractor': specifier: ^7.56.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': 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))) @@ -964,6 +967,36 @@ packages: '@rushstack/ts-command-line@5.1.7': 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': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -4780,6 +4813,40 @@ snapshots: transitivePeerDependencies: - '@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': {} '@standard-schema/spec@1.0.0': {} diff --git a/src/ErrorBoundary/BasicErrorBoundary.tsx b/src/ErrorBoundary/BasicErrorBoundary.tsx index 80e76f1..6aee076 100644 --- a/src/ErrorBoundary/BasicErrorBoundary.tsx +++ b/src/ErrorBoundary/BasicErrorBoundary.tsx @@ -1,5 +1,8 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import React, { type PropsWithChildren } from 'react'; +import errorManager from './ErrorManager'; + interface ErrorBoundaryProps extends PropsWithChildren { namespace?: string; onReportClick?: () => void; @@ -43,7 +46,12 @@ export class ReactBasicErrorBoundary extends React.PureComponent< errorInfo, 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() { diff --git a/src/ErrorBoundary/ErrorBoundary.tsx b/src/ErrorBoundary/ErrorBoundary.tsx index 0ee6251..2b5d246 100644 --- a/src/ErrorBoundary/ErrorBoundary.tsx +++ b/src/ErrorBoundary/ErrorBoundary.tsx @@ -1,7 +1,10 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { Button, Code, Collapse, Group, Paper, rem, Text } from '@mantine/core'; import { IconExclamationCircle } from '@tabler/icons-react'; import React, { type PropsWithChildren } from 'react'; +import errorManager from './ErrorManager'; + let ErrorBoundaryOptions = { disabled: false, onError: undefined, @@ -68,7 +71,12 @@ export class ReactErrorBoundary extends React.Component ({ + reported: true, + })); + } } reset() { diff --git a/src/ErrorBoundary/ErrorManager.README.md b/src/ErrorBoundary/ErrorManager.README.md new file mode 100644 index 0000000..8a917e2 --- /dev/null +++ b/src/ErrorBoundary/ErrorManager.README.md @@ -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; + tags?: Record; + extra?: Record; +} + +interface ErrorReport { + error: Error; + errorInfo?: any; + severity?: ErrorSeverity; + context?: ErrorContext; + timestamp?: number; +} +``` diff --git a/src/ErrorBoundary/ErrorManager.ts b/src/ErrorBoundary/ErrorManager.ts new file mode 100644 index 0000000..52d64eb --- /dev/null +++ b/src/ErrorBoundary/ErrorManager.ts @@ -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 { + 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; diff --git a/src/ErrorBoundary/ErrorManager.types.ts b/src/ErrorBoundary/ErrorManager.types.ts new file mode 100644 index 0000000..e3f3948 --- /dev/null +++ b/src/ErrorBoundary/ErrorManager.types.ts @@ -0,0 +1,50 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +export interface CustomAPIConfig { + endpoint: string; + headers?: Record; + method?: 'POST' | 'PUT'; + transformPayload?: (report: ErrorReport) => any; +} + +export interface ErrorContext { + componentStack?: string; + extra?: Record; + namespace?: string; + tags?: Record; + user?: Record; +} + +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; + 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; +} diff --git a/src/ErrorBoundary/index.ts b/src/ErrorBoundary/index.ts index 9d56e81..2ef5b40 100644 --- a/src/ErrorBoundary/index.ts +++ b/src/ErrorBoundary/index.ts @@ -1,2 +1,4 @@ export { default as ReactBasicErrorBoundary } from './BasicErrorBoundary'; export { default as ReactErrorBoundary } from './ErrorBoundary'; +export { default as errorManager } from './ErrorManager'; +export * from './ErrorManager.types'; diff --git a/src/Former/FormerResolveSpecAPI.ts b/src/Former/FormerResolveSpecAPI.ts index 1c501d4..d55ed6d 100644 --- a/src/Former/FormerResolveSpecAPI.ts +++ b/src/Former/FormerResolveSpecAPI.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import type { FormerAPICallType } from './Former.types'; interface ResolveSpecRequest { @@ -65,7 +66,7 @@ function FormerResolveSpecAPI(options: { } const data = await response.json(); - return data as any; + return data as unknown; }; } diff --git a/src/Former/stories/Former.goapi.stories.tsx b/src/Former/stories/Former.goapi.stories.tsx index 15ce215..5e3f242 100644 --- a/src/Former/stories/Former.goapi.stories.tsx +++ b/src/Former/stories/Former.goapi.stories.tsx @@ -6,7 +6,7 @@ import { fn } from 'storybook/test'; import { FormTest } from './example'; -const Renderable = (props: any) => { +const Renderable = (props: unknown) => { return ( diff --git a/src/GlobalStateStore/GlobalStateStore.ts b/src/GlobalStateStore/GlobalStateStore.ts index e423344..f476f82 100644 --- a/src/GlobalStateStore/GlobalStateStore.ts +++ b/src/GlobalStateStore/GlobalStateStore.ts @@ -5,7 +5,6 @@ import { useStoreWithEqualityFn } from 'zustand/traditional'; import { createStore } from 'zustand/vanilla'; import type { - AppState, BarState, ExtractState, GlobalState, @@ -21,10 +20,6 @@ import type { import { loadStorage, saveStorage } from './GlobalStateStore.utils'; const initialState: GlobalState = { - app: { - controls: {}, - environment: 'production', - }, layout: { bottomBar: { open: false }, leftBar: { open: false }, @@ -35,10 +30,14 @@ const initialState: GlobalState = { menu: [], }, owner: { + guid: '', id: 0, name: '', }, program: { + controls: {}, + environment: 'production', + guid: '', name: '', slug: '', }, @@ -47,8 +46,10 @@ const initialState: GlobalState = { authToken: '', connected: true, loading: false, + loggedIn: false, }, user: { + guid: '', username: '', }, }; @@ -140,13 +141,6 @@ const createNavigationSlice = (set: SetState) => ({ })), }); -const createAppSlice = (set: SetState) => ({ - setApp: (updates: Partial) => - set((state: GlobalState) => ({ - app: { ...state.app, ...updates }, - })), -}); - const createComplexActions = (set: SetState, get: GetState) => ({ fetchData: async (url?: string) => { try { @@ -164,9 +158,9 @@ const createComplexActions = (set: SetState, get: GetState) => ({ set((state: GlobalState) => ({ ...state, ...result, - app: { - ...state.app, - ...result?.app, + program: { + ...state.program, + ...result?.program, updatedAt: new Date().toISOString(), }, 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) => { - set((state: GlobalState) => ({ - session: { - ...state.session, - authToken: authToken ?? '', - }, - })); - await get().fetchData(); + try { + set((state: GlobalState) => ({ + session: { + ...state.session, + authToken: authToken ?? '', + expiryDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + loading: true, + loggedIn: true, + }, + })); + 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 () => { - set((state: GlobalState) => ({ - ...initialState, - session: { - ...initialState.session, - apiURL: state.session.apiURL, - }, - })); - await get().fetchData(); + try { + set((state: GlobalState) => ({ + ...initialState, + session: { + ...initialState.session, + apiURL: state.session.apiURL, + expiryDate: undefined, + loading: true, + loggedIn: false, + }, + })); + 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((set, get) => ({ ...createUserSlice(set), ...createLayoutSlice(set), ...createNavigationSlice(set), - ...createAppSlice(set), ...createComplexActions(set, get), })); @@ -267,6 +336,10 @@ const getAuthToken = (): string => { return GlobalStateStore.getState().session.authToken; }; +const isLoggedIn = (): boolean => { + return GlobalStateStore.getState().isLoggedIn(); +}; + const setAuthToken = (token: string) => { GlobalStateStore.getState().setAuthToken(token); }; @@ -275,4 +348,4 @@ const GetGlobalState = (): GlobalStateStoreType => { return GlobalStateStore.getState(); } -export { getApiURL, getAuthToken, GetGlobalState, GlobalStateStore, setApiURL, setAuthToken, useGlobalStateStore }; +export { getApiURL, getAuthToken, GetGlobalState, GlobalStateStore, isLoggedIn, setApiURL, setAuthToken, useGlobalStateStore }; diff --git a/src/GlobalStateStore/GlobalStateStore.types.ts b/src/GlobalStateStore/GlobalStateStore.types.ts index 67210d5..395a0a4 100644 --- a/src/GlobalStateStore/GlobalStateStore.types.ts +++ b/src/GlobalStateStore/GlobalStateStore.types.ts @@ -1,10 +1,4 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -interface AppState { - controls?: Record; - environment: 'development' | 'production'; - globals?: Record; - updatedAt?: string; -} interface BarState { collapsed?: boolean; @@ -14,7 +8,6 @@ interface BarState { pinned?: boolean; render?: () => React.ReactNode; size?: number; - } type DatabaseDetail = { @@ -25,7 +18,6 @@ type DatabaseDetail = { type ExtractState = S extends { getState: () => infer X } ? X : never; interface GlobalState { - app: AppState; layout: LayoutState; navigation: NavigationState; owner: OwnerState; @@ -38,15 +30,19 @@ interface GlobalStateActions { // Complex actions fetchData: (url?: string) => Promise; + isLoggedIn: () => boolean; login: (authToken?: string) => Promise; logout: () => Promise; - // Callback for custom fetch logic + // Callbacks for custom logic onFetchSession?: (state: GlobalState) => Promise>; + onLogin?: ( + state: GlobalState + ) => Promise | void>; + onLogout?: ( + state: GlobalState + ) => Promise | void>; setApiURL: (url: string) => void; - // App actions - setApp: (updates: Partial) => void; - setAuthToken: (token: string) => void; setBottomBar: (updates: Partial) => void; setCurrentPage: (page: PageInfo) => void; @@ -101,6 +97,7 @@ interface NavigationState { } interface OwnerState { + guid?: string; id: number; logo?: string; name: string; @@ -118,24 +115,19 @@ type PageInfo = { interface ProgramState { backendVersion?: string; bigLogo?: string; + controls?: Record; database?: DatabaseDetail; databaseVersion?: string; description?: string; + environment: 'development' | 'production'; + globals?: Record; + guid?: string; logo?: string; meta?: Record; name: string; slug: string; tags?: string[]; - version?: string; -} - -interface ProgramWrapperProps { - apiURL?: string; - children: React.ReactNode | React.ReactNode[]; - debugMode?: boolean; - fallback?: React.ReactNode | React.ReactNode[]; - renderFallback?: boolean; - testMode?: boolean; + updatedAt?: string; version?: string; } @@ -144,8 +136,10 @@ interface SessionState { authToken: string; connected: boolean; error?: string; + expiryDate?: string; isSecurity?: boolean; loading: boolean; + loggedIn: boolean; meta?: Record; parameters?: Record; } @@ -169,7 +163,6 @@ interface UserState { } export type { - AppState, BarState, ExtractState, GlobalState, @@ -181,7 +174,6 @@ export type { OwnerState, PageInfo, ProgramState, - ProgramWrapperProps, SessionState, ThemeSettings, UserState, diff --git a/src/GlobalStateStore/GlobalStateStoreWrapper.tsx b/src/GlobalStateStore/GlobalStateStoreWrapper.tsx index c98313e..528cfed 100644 --- a/src/GlobalStateStore/GlobalStateStoreWrapper.tsx +++ b/src/GlobalStateStore/GlobalStateStoreWrapper.tsx @@ -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'; - interface GlobalStateStoreContextValue { fetchData: (url?: string) => Promise; getState: () => GlobalStateStoreType; @@ -13,12 +20,15 @@ interface GlobalStateStoreContextValue { const GlobalStateStoreContext = createContext(null); - interface GlobalStateStoreProviderProps { apiURL?: string; autoFetch?: boolean; children: ReactNode; fetchOnMount?: boolean; + onFetchSession?: (state: GlobalState) => Promise>; + onLogin?: (state: GlobalState) => Promise; + onLogout?: (state: GlobalState) => Promise; + program?: Partial; throttleMs?: number; } @@ -27,13 +37,16 @@ export function GlobalStateStoreProvider({ autoFetch = true, children, fetchOnMount = true, + onFetchSession, + onLogin, + onLogout, + program, throttleMs = 0, }: GlobalStateStoreProviderProps) { const lastFetchTime = useRef(0); const fetchInProgress = useRef(false); const mounted = useRef(false); - const throttledFetch = useCallback( async (url?: string) => { const now = Date.now(); @@ -68,6 +81,30 @@ export function GlobalStateStoreProvider({ } }, [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(() => { if (!mounted.current) { mounted.current = true; @@ -88,12 +125,8 @@ export function GlobalStateStoreProvider({ }; }, [throttledFetch, refetch]); - - return ( - - {children} - + {children} ); } diff --git a/src/Gridler/stories/Examples.goapi.tsx b/src/Gridler/stories/Examples.goapi.tsx index 2afd441..69770e6 100644 --- a/src/Gridler/stories/Examples.goapi.tsx +++ b/src/Gridler/stories/Examples.goapi.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { Button, Checkbox, Divider, Group, Stack, TagsInput, TextInput } from '@mantine/core'; import { useLocalStorage } from '@mantine/hooks'; import { useRef, useState } from 'react'; @@ -22,9 +23,20 @@ export const GridlerGoAPIExampleEventlog = () => { const [selectRow, setSelectRow] = useState(''); const [values, setValues] = useState>>([]); const [search, setSearch] = useState(''); - const [formProps, setFormProps] = useState<{ onChange?: any; 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 })) }, + const [formProps, setFormProps] = useState<{ + onChange?: any; + 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, request: null, values: null, @@ -33,7 +45,8 @@ export const GridlerGoAPIExampleEventlog = () => { const columns: GridlerColumns = [ { Cell: (row) => { - const process = `${row?.cql2?.length > 0 + const process = `${ + row?.cql2?.length > 0 ? '🔖' : row?.cql1?.length > 0 ? '📕' @@ -42,7 +55,7 @@ export const GridlerGoAPIExampleEventlog = () => { : row?.status === 2 ? '🔒' : '⚙️' - } ${String(row?.id_process ?? '0')}`; + } ${String(row?.id_process ?? '0')}`; return { data: process, @@ -139,28 +152,28 @@ export const GridlerGoAPIExampleEventlog = () => { changeOnActiveClick={true} descriptionField={'process'} onRequestForm={(request, data) => { - setFormProps((cv)=> { - return {...cv, opened: true, request: request as any, values: data as any} - }) + setFormProps((cv) => { + return { ...cv, opened: true, request: request as any, values: data as any }; + }); }} /> - - - - - - - - + + + + + + + + @@ -222,6 +235,6 @@ export const GridlerGoAPIExampleEventlog = () => { Goto 2050 - + ); }; diff --git a/src/Gridler/stories/Gridler.goapi.stories.tsx b/src/Gridler/stories/Gridler.goapi.stories.tsx index 046e9aa..beda29b 100644 --- a/src/Gridler/stories/Gridler.goapi.stories.tsx +++ b/src/Gridler/stories/Gridler.goapi.stories.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ //@ts-nocheck import type { Meta, StoryObj } from '@storybook/react-vite'; diff --git a/src/Gridler/stories/Gridler.localdata.stories.tsx b/src/Gridler/stories/Gridler.localdata.stories.tsx index 0d70ade..7dfd971 100644 --- a/src/Gridler/stories/Gridler.localdata.stories.tsx +++ b/src/Gridler/stories/Gridler.localdata.stories.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ //@ts-nocheck import type { Meta, StoryObj } from '@storybook/react-vite'; diff --git a/src/Gridler/utils/golang-restapi-v2/index.ts b/src/Gridler/utils/golang-restapi-v2/index.ts index b4c09ec..0b14f42 100644 --- a/src/Gridler/utils/golang-restapi-v2/index.ts +++ b/src/Gridler/utils/golang-restapi-v2/index.ts @@ -1,35 +1,34 @@ -import {b64EncodeUnicode} from '@warkypublic/artemis-kit/base64' -const TOKEN_KEY = 'gridler_golang_restapi_v2_token' +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { b64EncodeUnicode } from '@warkypublic/artemis-kit/base64'; +const TOKEN_KEY = 'gridler_golang_restapi_v2_token'; export type APIOptionsType = { - autocreate?: boolean - autoref?: boolean - baseurl?: string - getAPIProvider?: () => { provider: string; providerKey: string } - getAuthToken?: () => string - operations?: Array - postfix?: string - prefix?: string - requestTimeoutSec?: number -} - - + autocreate?: boolean; + autoref?: boolean; + baseurl?: string; + getAPIProvider?: () => { provider: string; providerKey: string }; + getAuthToken?: () => string; + operations?: Array; + postfix?: string; + prefix?: string; + requestTimeoutSec?: number; +}; export interface APIResponse { - errmsg: string - payload?: any - retval: number + errmsg: string; + payload?: any; + retval: number; } export interface FetchAPIOperation { - name?: string - op?: string - type: GoAPIHeaderTypes //x-fieldfilter - value: string + name?: string; + op?: string; + type: GoAPIHeaderTypes; //x-fieldfilter + value: string; } /** * @description Types for the Go Rest API headers * @typedef {String} GoAPIEnum -*/ + */ export type GoAPIEnum = | 'advsql' | 'api-key' @@ -42,7 +41,7 @@ export type GoAPIEnum = | 'association_autoupdate' | 'association-update' | 'cql-sel' - | 'cursor-backward'// For x cursor-backward header + | 'cursor-backward' // For x cursor-backward header | 'cursor-forward' // For x cursor-forward header | 'custom-sql-join' | 'custom-sql-or' @@ -72,28 +71,24 @@ export type GoAPIEnum = | 'simpleapi' | 'skipcache' | '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 { - name?: string - op?: string - type: GoAPIHeaderTypes //x-fieldfilter - value: string + name?: string; + op?: string; + type: GoAPIHeaderTypes; //x-fieldfilter + value: string; } export interface MetaData { - limit?: number - offset?: number - total?: number + limit?: number; + offset?: number; + total?: number; } - /** * Builds an array of objects by encoding specific values and setting headers. * @@ -105,50 +100,49 @@ const buildGoAPIOperation = ( ops: Array, headers?: Headers ): Array => { - 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++) { if (!newops[i].name || newops[i].name === '') { - newops[i].name = '' + newops[i].name = ''; } 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('__')) { - 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('__')) { - 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('__')) { - 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('__')) { - 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('__')) { - newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__` + newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`; } 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('__')) { - newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__` + newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`; } if (headers) { if (!newops || newops.length === 0) { - headers.set(`x-limit`, '10') + headers.set(`x-limit`, '10'); } 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') { - headers.set(`association_autocreate`, newops[i].value ?? '1') + headers.set(`association_autocreate`, newops[i].value ?? '1'); } if ( newops[i].type === 'searchop' || @@ -158,20 +152,20 @@ const buildGoAPIOperation = ( headers.set( encodeURIComponent(`x-${newops[i].type}-${newops[i].op}-${newops[i].name}`), String(newops[i].value) - ) + ); } else { headers.set( encodeURIComponent( `x-${newops[i].type}${newops[i].name && newops[i].name !== '' ? '-' + newops[i].name : ''}` ), String(newops[i].value) - ) + ); } } } - return newops -} + return newops; +}; /** * Retrieves the headers from an array of FetchAPIOperation objects and returns them as an object. @@ -183,77 +177,75 @@ const GoAPIHeaders = ( ops: Array, headers?: Headers ): { [key: string]: string } => { - const head = new Headers() - const headerlist: Record = {} + const head = new Headers(); + const headerlist: Record = {}; - const authToken = getAuthToken?.() + const authToken = getAuthToken?.(); if (authToken && authToken !== '') { - - head.set('Authorization', `Token ${authToken}`) + head.set('Authorization', `Token ${authToken}`); } else { - const token = getAuthToken() + const token = getAuthToken(); if (token) { - head.set('Authorization', `Token ${token}`) + head.set('Authorization', `Token ${token}`); } } - if (headers) { headers.forEach((v, k) => { - head.set(k, v) - }) + head.set(k, v); + }); } - const distinctOperations: Array = [] + const distinctOperations: Array = []; for (const value of ops?.filter((val) => !!val) ?? []) { const index = distinctOperations.findIndex( (searchValue) => searchValue.name === value.name && searchValue.type === value.type - ) + ); if (index === -1) { - distinctOperations.push(value) + distinctOperations.push(value); } else { - distinctOperations[index] = value + distinctOperations[index] = value; } } - buildGoAPIOperation(distinctOperations, head) + buildGoAPIOperation(distinctOperations, head); head?.forEach((v, k) => { - headerlist[k] = v - }) + headerlist[k] = v; + }); if (headers) { for (const key of Object.keys(headerlist)) { - headers.set(key, headerlist[key]) + headers.set(key, headerlist[key]); } } - return headerlist -} + return headerlist; +}; const callbacks = { - getAuthToken: () => { + getAuthToken: () => { if (localStorage) { - const token = localStorage.getItem(TOKEN_KEY) - if (token) { - return token - } + const token = localStorage.getItem(TOKEN_KEY); + if (token) { + return token; + } } - return undefined - } -} + return undefined; + }, +}; /** * Retrieves the authentication token from local storage. * * @return {string | undefined} The authentication token if found, otherwise undefined */ -const getAuthToken = () => callbacks?.getAuthToken?.() +const getAuthToken = () => callbacks?.getAuthToken?.(); -const setAuthTokenCallback = (cb: ()=> string) => { - callbacks.getAuthToken = cb - return callbacks.getAuthToken -} +const setAuthTokenCallback = (cb: () => string) => { + callbacks.getAuthToken = cb; + return callbacks.getAuthToken; +}; /** * Sets the authentication token in the local storage. @@ -262,9 +254,8 @@ const setAuthTokenCallback = (cb: ()=> string) => { */ const setAuthToken = (token: string) => { if (localStorage) { - localStorage.setItem(TOKEN_KEY, token) + localStorage.setItem(TOKEN_KEY, token); } -} +}; - -export {buildGoAPIOperation,getAuthToken,GoAPIHeaders,setAuthToken,setAuthTokenCallback} \ No newline at end of file +export { buildGoAPIOperation, getAuthToken, GoAPIHeaders, setAuthToken, setAuthTokenCallback };