diff --git a/.changeset/grumpy-eagles-mix.md b/.changeset/grumpy-eagles-mix.md new file mode 100644 index 000000000..0a812b04b --- /dev/null +++ b/.changeset/grumpy-eagles-mix.md @@ -0,0 +1,19 @@ +--- +"@lightsparkdev/core": minor +--- + +- **Test script** + - Renamed `test` to `test-cmd` and made `test` an alias that accepts arbitrary Jest patterns. +- **Requester** + - Switched `wsClient` to a lazily initialized `Promise` resolved in a new `initWsClient` method. + - Moved `autoBind` into constructor and improved cleanup/cancellation logic in `subscribe()`. +- **New tests** + - Added `Requester.test.ts` covering query execution, error handling, subscriptions, and signing logic. +- **Errors utility** + - Enhanced `errorToJSON` to enumerate non-enumerable props and optionally stringify nested objects. +- **LocalStorage utility** + - Made `getLocalStorageBoolean` return `true`/`false`/`null`; `getLocalStorageConfigItem` now defaults missing keys to `false`. +- **Type guards** + - Tightened `isObject` signature, added `isRecord`, and removed a duplicate in `types.ts`. +- **Static assets** + - Replaced the monolithic SVG logo with an optimized, higher-resolution `lightspark-logo.svg`. diff --git a/.changeset/small-lies-perform.md b/.changeset/small-lies-perform.md new file mode 100644 index 000000000..489af78e9 --- /dev/null +++ b/.changeset/small-lies-perform.md @@ -0,0 +1,36 @@ +--- +"@lightsparkdev/ui": patch +--- + +- **BirthdayInput** + - Dropped internal validity state; added `formatDateToText` hint formatter and “must be before today” validation. +- **TextInput** + - Introduced `success` & `contentError` props, `hideNonErrorsIfBlurred`, configurable `iconOffsets`, `iconStrokeWidths`. +- **PhoneInput** + - Added `onFocus` callback. +- **QRCode** + - Swapped in new `LogoMark` asset (vs. `FramedLogoOnCircle`), adjusted image sizing. +- **Drawer & Modal** + - Added `alignBottom` and `disableTouchMove` flags to support bottom-aligned, non-dismissable drawers. +- **Button & Checkbox** + - New kinds: `green37`, `gray99`, `white21`. + - Added margin props (`mb`, `ml`, `mr`) for fine-grained spacing. +- **CardForm** + - Major refactor: dozens of new props (`aboveHeaderContent`, `graphicHeader`, `centeredContent`, `paddingX`, `contentMarginTop`, `formButtonTopMargin`, `selectMarginTop`, `smDontAdjustWidth`, etc.). + - Extracted `CardFormHeadline`/`CenteredHeader`, wrapped forms in a `Flex` when using graphic headers. +- **InputSubtext** + - Now supports rich React-node `content`, distinguishes error vs. success styling, and respects “hide if blurred” logic. +- **Toasts** + - Added optional `type` (`error` | `success` | `info`) to color toast backgrounds. +- **Hooks** + - **`useFields`**: validator signature now `(value, fields?)`; added `matchesField` and `clabe` validators; smarter merge to prevent unnecessary rerenders. + - **`useQueryParamBooleans`**: new hook for parsing multiple boolean query parameters at once. +- **Icons** + - Introduced dozens of new icons (e.g. `LogoMark`, `LightningBoltOutline`, `NonagonCheckmark`, `NubankLogo`) plus a full “central” icon set under `icons/central/`. + - Standardized on a `PathProps` interface for configurable strokes. +- **Styles & Theme** + - **Colors**: added `gray35`, `gray7`, `white21`, `green37`, `blue32`, `linkLight`. + - **Layout helper**: `buildStandardContentInset` gains an `smDontAdjustWidth` opt-out. + - Tweaked `smHeaderLogoMarginLeft` (30px → 20px). + - **Button themes**: added `white21`, `linkLight`, and `tertiary` kinds. + - **CardForm theme**: now allows zero padding for new layout options. diff --git a/apps/examples/oauth-app/index.html b/apps/examples/oauth-app/index.html index 917765e92..4238dd647 100644 --- a/apps/examples/oauth-app/index.html +++ b/apps/examples/oauth-app/index.html @@ -2,18 +2,21 @@ - - + Lightspark OAuth Demo - - - - Lightspark OAuth Demo + + + + + + + + diff --git a/apps/examples/oauth-app/public/android-chrome-192x192.png b/apps/examples/oauth-app/public/android-chrome-192x192.png new file mode 100644 index 000000000..b2a3d0218 Binary files /dev/null and b/apps/examples/oauth-app/public/android-chrome-192x192.png differ diff --git a/apps/examples/oauth-app/public/android-chrome-512x512.png b/apps/examples/oauth-app/public/android-chrome-512x512.png new file mode 100644 index 000000000..ac0048c4f Binary files /dev/null and b/apps/examples/oauth-app/public/android-chrome-512x512.png differ diff --git a/apps/examples/oauth-app/public/apple-touch-icon.png b/apps/examples/oauth-app/public/apple-touch-icon.png new file mode 100644 index 000000000..38cd2bb4d Binary files /dev/null and b/apps/examples/oauth-app/public/apple-touch-icon.png differ diff --git a/apps/examples/oauth-app/public/favicon-16x16.png b/apps/examples/oauth-app/public/favicon-16x16.png new file mode 100644 index 000000000..1140dbc80 Binary files /dev/null and b/apps/examples/oauth-app/public/favicon-16x16.png differ diff --git a/apps/examples/oauth-app/public/favicon-32x32.png b/apps/examples/oauth-app/public/favicon-32x32.png new file mode 100644 index 000000000..13db9919e Binary files /dev/null and b/apps/examples/oauth-app/public/favicon-32x32.png differ diff --git a/apps/examples/oauth-app/public/favicon-48x48.png b/apps/examples/oauth-app/public/favicon-48x48.png new file mode 100644 index 000000000..495863938 Binary files /dev/null and b/apps/examples/oauth-app/public/favicon-48x48.png differ diff --git a/apps/examples/oauth-app/public/favicon.ico b/apps/examples/oauth-app/public/favicon.ico index edecb5b07..39e5a82c4 100644 Binary files a/apps/examples/oauth-app/public/favicon.ico and b/apps/examples/oauth-app/public/favicon.ico differ diff --git a/apps/examples/oauth-app/public/logo192.png b/apps/examples/oauth-app/public/logo192.png deleted file mode 100644 index fc44b0a37..000000000 Binary files a/apps/examples/oauth-app/public/logo192.png and /dev/null differ diff --git a/apps/examples/oauth-app/public/logo512.png b/apps/examples/oauth-app/public/logo512.png deleted file mode 100644 index a4e47a654..000000000 Binary files a/apps/examples/oauth-app/public/logo512.png and /dev/null differ diff --git a/apps/examples/oauth-app/public/manifest.json b/apps/examples/oauth-app/public/manifest.json index 080d6c77a..e8e03397e 100644 --- a/apps/examples/oauth-app/public/manifest.json +++ b/apps/examples/oauth-app/public/manifest.json @@ -1,21 +1,36 @@ { - "short_name": "React App", - "name": "Create React App Sample", + "short_name": "Lightspark OAuth App", + "name": "Lightspark OAuth App", "icons": [ { - "src": "favicon.ico", - "sizes": "64x64 32x32 24x24 16x16", - "type": "image/x-icon" + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "/apple-touch-icon.png", + "sizes": "180x180", + "type": "image/png" + }, + { + "src": "/favicon-32x32.png", + "sizes": "32x32", + "type": "image/png" }, { - "src": "logo192.png", - "type": "image/png", - "sizes": "192x192" + "src": "/favicon-16x16.png", + "sizes": "16x16", + "type": "image/png" }, { - "src": "logo512.png", - "type": "image/png", - "sizes": "512x512" + "src": "/favicon.ico", + "sizes": "16x16 32x32 48x48", + "type": "image/x-icon" } ], "start_url": ".", diff --git a/apps/examples/ui-test-app/index.html b/apps/examples/ui-test-app/index.html index 297ea69dd..88548ef34 100644 --- a/apps/examples/ui-test-app/index.html +++ b/apps/examples/ui-test-app/index.html @@ -2,24 +2,21 @@ - - - + Lightspark UI Test App - - + + + + - + - Lightspark + diff --git a/apps/examples/ui-test-app/public/android-chrome-192x192.png b/apps/examples/ui-test-app/public/android-chrome-192x192.png new file mode 100644 index 000000000..b2a3d0218 Binary files /dev/null and b/apps/examples/ui-test-app/public/android-chrome-192x192.png differ diff --git a/apps/examples/ui-test-app/public/android-chrome-512x512.png b/apps/examples/ui-test-app/public/android-chrome-512x512.png new file mode 100644 index 000000000..ac0048c4f Binary files /dev/null and b/apps/examples/ui-test-app/public/android-chrome-512x512.png differ diff --git a/apps/examples/ui-test-app/public/apple-touch-icon.png b/apps/examples/ui-test-app/public/apple-touch-icon.png new file mode 100644 index 000000000..38cd2bb4d Binary files /dev/null and b/apps/examples/ui-test-app/public/apple-touch-icon.png differ diff --git a/apps/examples/ui-test-app/public/favicon-16.ico b/apps/examples/ui-test-app/public/favicon-16.ico deleted file mode 100644 index a87e750ee..000000000 Binary files a/apps/examples/ui-test-app/public/favicon-16.ico and /dev/null differ diff --git a/apps/examples/ui-test-app/public/favicon-16x16.png b/apps/examples/ui-test-app/public/favicon-16x16.png new file mode 100644 index 000000000..1140dbc80 Binary files /dev/null and b/apps/examples/ui-test-app/public/favicon-16x16.png differ diff --git a/apps/examples/ui-test-app/public/favicon-32.ico b/apps/examples/ui-test-app/public/favicon-32.ico deleted file mode 100644 index edecb5b07..000000000 Binary files a/apps/examples/ui-test-app/public/favicon-32.ico and /dev/null differ diff --git a/apps/examples/ui-test-app/public/favicon-32x32.png b/apps/examples/ui-test-app/public/favicon-32x32.png new file mode 100644 index 000000000..13db9919e Binary files /dev/null and b/apps/examples/ui-test-app/public/favicon-32x32.png differ diff --git a/apps/examples/ui-test-app/public/favicon.ico b/apps/examples/ui-test-app/public/favicon.ico new file mode 100644 index 000000000..39e5a82c4 Binary files /dev/null and b/apps/examples/ui-test-app/public/favicon.ico differ diff --git a/apps/examples/ui-test-app/public/logo192.png b/apps/examples/ui-test-app/public/logo192.png deleted file mode 100644 index c31b513a7..000000000 Binary files a/apps/examples/ui-test-app/public/logo192.png and /dev/null differ diff --git a/apps/examples/ui-test-app/public/logo512.png b/apps/examples/ui-test-app/public/logo512.png deleted file mode 100644 index 230379bde..000000000 Binary files a/apps/examples/ui-test-app/public/logo512.png and /dev/null differ diff --git a/apps/examples/ui-test-app/public/manifest.json b/apps/examples/ui-test-app/public/manifest.json index a931ae928..71c6059ef 100644 --- a/apps/examples/ui-test-app/public/manifest.json +++ b/apps/examples/ui-test-app/public/manifest.json @@ -1,21 +1,36 @@ { - "short_name": "Lightspark", - "name": "Lightspark App", + "short_name": "Lightspark UI Test App", + "name": "Lightspark UI Test App", "icons": [ { - "src": "favicon.ico", - "sizes": "64x64 32x32 24x24 16x16", - "type": "image/x-icon" + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "/apple-touch-icon.png", + "sizes": "180x180", + "type": "image/png" + }, + { + "src": "/favicon-32x32.png", + "sizes": "32x32", + "type": "image/png" }, { - "src": "logo192.png", - "type": "image/png", - "sizes": "192x192" + "src": "/favicon-16x16.png", + "sizes": "16x16", + "type": "image/png" }, { - "src": "logo512.png", - "type": "image/png", - "sizes": "512x512" + "src": "/favicon.ico", + "sizes": "16x16 32x32 48x48", + "type": "image/x-icon" } ], "start_url": ".", diff --git a/apps/examples/ui-test-app/src/tests/toReactNodes.test.tsx b/apps/examples/ui-test-app/src/tests/toReactNodes.test.tsx index 90e7c9eb6..12f21fe7f 100644 --- a/apps/examples/ui-test-app/src/tests/toReactNodes.test.tsx +++ b/apps/examples/ui-test-app/src/tests/toReactNodes.test.tsx @@ -276,7 +276,7 @@ describe("toReactNodes", () => { to: TestAppRoutes.PageOne, }, }; - const iconNode = { icon: { name: "LogoBolt" as const } }; + const iconNode = { icon: { name: "LogoMark" as const } }; const nextLinkNode = { nextLink: { text: "Some next link node that should have typography applied", @@ -330,7 +330,7 @@ describe("toReactNodes", () => { typography: { type: "Display", size: "Medium" } as const, }, }; - const iconNode = { icon: { name: "LogoBolt" } as const }; + const iconNode = { icon: { name: "LogoMark" } as const }; const externalLinkNode = { link: { text: "Some external link node that should have typography applied", diff --git a/packages/core/package.json b/packages/core/package.json index ad23f07fe..9a8c863ff 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -53,7 +53,8 @@ "lint": "eslint .", "package:checks": "yarn publint && yarn attw --pack .", "postversion": "yarn build", - "test": "node --experimental-vm-modules $(yarn bin jest) --no-cache --runInBand --bail src/**/tests/**/*.test.ts", + "test-cmd": "node --experimental-vm-modules $(yarn bin jest) --no-cache --runInBand --bail", + "test": "yarn test-cmd -- src/**/tests/**/*.test.ts", "types:watch": "tsc-absolute --watch", "types": "tsc" }, diff --git a/packages/core/src/requester/Requester.ts b/packages/core/src/requester/Requester.ts index cedabadc2..8af108ffb 100644 --- a/packages/core/src/requester/Requester.ts +++ b/packages/core/src/requester/Requester.ts @@ -5,7 +5,6 @@ import dayjs from "dayjs"; import utc from "dayjs/plugin/utc.js"; import type { Client as WsClient } from "graphql-ws"; import { createClient } from "graphql-ws"; -import NodeWebSocket from "ws"; import { Observable } from "zen-observable-ts"; import type Query from "./Query.js"; @@ -33,7 +32,8 @@ type BodyData = { }; class Requester { - private readonly wsClient: WsClient; + private wsClient: Promise; + private resolveWsClient: ((value: WsClient) => void) | null = null; constructor( private readonly nodeKeyCache: NodeKeyCache, private readonly schemaEndpoint: string, @@ -44,22 +44,43 @@ class Requester { private readonly signingKey?: SigningKey, private readonly fetchImpl: typeof fetch = fetch, ) { + this.wsClient = new Promise((resolve) => { + this.resolveWsClient = resolve; + }); + void this.initWsClient(baseUrl, authProvider); + autoBind(this); + } + + private async initWsClient(baseUrl: string, authProvider: AuthProvider) { + if (!this.resolveWsClient) { + /* If resolveWsClient is null assume already initialized: */ + return this.wsClient; + } + let websocketImpl; - if (typeof WebSocket === "undefined" && typeof window === "undefined") { - websocketImpl = NodeWebSocket; + if (isNode && typeof WebSocket === "undefined") { + const wsModule = await import("ws"); + websocketImpl = wsModule.default; } let websocketProtocol = "wss"; if (baseUrl.startsWith("http://")) { websocketProtocol = "ws"; } - this.wsClient = createClient({ + + const wsClient = createClient({ url: `${websocketProtocol}://${this.stripProtocol(this.baseUrl)}/${ this.schemaEndpoint }`, connectionParams: () => authProvider.addWsConnectionParams({}), webSocketImpl: websocketImpl, }); - autoBind(this); + + if (this.resolveWsClient) { + this.resolveWsClient(wsClient); + this.resolveWsClient = null; + } + + return wsClient; } public async executeQuery(query: Query): Promise { @@ -106,11 +127,31 @@ class Requester { return new Observable<{ data: T }>((observer) => { logger.trace(`Requester.subscribe observer`, observer); - return this.wsClient.subscribe(bodyData, { - next: (data) => observer.next(data as { data: T }), - error: (err) => observer.error(err), - complete: () => observer.complete(), - }); + + let cleanup: (() => void) | null = null; + let canceled = false; + + void (async () => { + try { + const wsClient = await this.wsClient; + if (!canceled) { + cleanup = wsClient.subscribe(bodyData, { + next: (data) => observer.next(data as { data: T }), + error: (err) => observer.error(err), + complete: () => observer.complete(), + }); + } + } catch (err) { + observer.error(err); + } + })(); + + return () => { + canceled = true; + if (cleanup) { + cleanup(); + } + }; }); } diff --git a/packages/core/src/requester/tests/Requester.test.ts b/packages/core/src/requester/tests/Requester.test.ts new file mode 100644 index 000000000..cc64bb593 --- /dev/null +++ b/packages/core/src/requester/tests/Requester.test.ts @@ -0,0 +1,347 @@ +import { beforeEach, jest } from "@jest/globals"; + +import type { Client as WsClient } from "graphql-ws"; +import type AuthProvider from "../../auth/AuthProvider.js"; +import type { CryptoInterface } from "../../crypto/crypto.js"; +import type NodeKeyCache from "../../crypto/NodeKeyCache.js"; +import type { SigningKey } from "../../crypto/SigningKey.js"; +import { SigningKeyType } from "../../crypto/types.js"; +import LightsparkException from "../../LightsparkException.js"; +import type Query from "../Query.js"; + +/* Mocking ESM modules (when running node with --experimental-vm-modules) + requires unstable_mockModule, see https://bit.ly/433nRV1 */ +await jest.unstable_mockModule("graphql-ws", () => ({ + __esModule: true, + createClient: jest.fn(), +})); +/* Since Requester uses graphql-ws we need a dynamic import after the above mock */ +const { Requester } = await import("../index.js"); + +describe("Requester", () => { + const schemaEndpoint = "graphql"; + const sdkUserAgent = "test-agent"; + const baseUrl = "https://api.example.com"; + + let nodeKeyCache: NodeKeyCache; + let authProvider: AuthProvider; + let signingKey: SigningKey; + let cryptoImpl: CryptoInterface; + let fetchImpl: typeof fetch; + + beforeEach(() => { + nodeKeyCache = { + getKey: jest.fn(), + hasKey: jest.fn(), + } as unknown as NodeKeyCache; + + authProvider = { + addAuthHeaders: jest.fn(async (headers: Record) => ({ + ...headers, + "X-Test": "1", + })), + isAuthorized: jest.fn(async () => true), + addWsConnectionParams: jest.fn( + async (params: Record) => ({ + ...params, + ws: true, + }), + ), + } satisfies AuthProvider; + + signingKey = { + type: SigningKeyType.RSASigningKey, + sign: jest.fn(async (data: Uint8Array) => new Uint8Array([1, 2, 3])), + } satisfies SigningKey; + + cryptoImpl = { + decryptSecretWithNodePassword: jest.fn(async () => new ArrayBuffer(0)), + generateSigningKeyPair: jest.fn(async () => ({ + publicKey: "", + privateKey: "", + })), + serializeSigningKey: jest.fn(async () => new ArrayBuffer(0)), + getNonce: jest.fn(async () => 123), + sign: jest.fn(async () => new ArrayBuffer(0)), + importPrivateSigningKey: jest.fn(async () => ""), + } satisfies CryptoInterface; + + fetchImpl = jest.fn( + async () => + ({ + ok: true, + json: async () => ({ data: { foo: "bar" }, errors: undefined }), + statusText: "OK", + }) as Response, + ); + }); + + it("constructs without error", () => { + expect( + () => + new Requester( + nodeKeyCache, + schemaEndpoint, + sdkUserAgent, + authProvider, + baseUrl, + cryptoImpl, + signingKey, + fetchImpl, + ), + ).not.toThrow(); + }); + + describe("executeQuery", () => { + it("calls makeRawRequest and returns constructed object", async () => { + const requester = new Requester( + nodeKeyCache, + schemaEndpoint, + sdkUserAgent, + authProvider, + baseUrl, + cryptoImpl, + signingKey, + fetchImpl, + ); + const query: Query<{ foo: string }> = { + queryPayload: "query TestQuery { foo }", + variables: { a: 1 }, + constructObject: (rawData) => ({ + foo: (rawData as { foo: string }).foo, + }), + }; + jest.spyOn(requester, "makeRawRequest").mockResolvedValue({ foo: "bar" }); + const result = await requester.executeQuery(query); + expect(result).toEqual({ foo: "bar" }); + }); + }); + + describe("makeRawRequest", () => { + it("makes a successful request and returns data", async () => { + const requester = new Requester( + nodeKeyCache, + schemaEndpoint, + sdkUserAgent, + authProvider, + baseUrl, + cryptoImpl, + signingKey, + fetchImpl, + ); + const result = await requester.makeRawRequest("query TestQuery { foo }", { + a: 1, + }); + expect(result).toEqual({ foo: "bar" }); + expect(fetchImpl).toHaveBeenCalled(); + }); + + it("throws on invalid query", async () => { + const requester = new Requester( + nodeKeyCache, + schemaEndpoint, + sdkUserAgent, + authProvider, + baseUrl, + cryptoImpl, + signingKey, + fetchImpl, + ); + await expect(requester.makeRawRequest("invalid", {})).rejects.toThrow( + LightsparkException, + ); + }); + + it("throws on subscription query", async () => { + const requester = new Requester( + nodeKeyCache, + schemaEndpoint, + sdkUserAgent, + authProvider, + baseUrl, + cryptoImpl, + signingKey, + fetchImpl, + ); + await expect( + requester.makeRawRequest("subscription TestSub { foo }", {}), + ).rejects.toThrow(LightsparkException); + }); + + it("throws on failed response", async () => { + fetchImpl = jest.fn( + async () => + ({ + ok: false, + statusText: "Bad Request", + json: async () => ({ + errors: [ + { message: "fail", extensions: { error_name: "TestError" } }, + ], + }), + }) as Response, + ); + const requester = new Requester( + nodeKeyCache, + schemaEndpoint, + sdkUserAgent, + authProvider, + baseUrl, + cryptoImpl, + signingKey, + fetchImpl, + ); + await expect( + requester.makeRawRequest("query TestQuery { foo }", {}), + ).rejects.toThrow(LightsparkException); + }); + + it("throws if response has no data and errors", async () => { + fetchImpl = jest.fn( + async () => + ({ + ok: true, + json: async () => ({ + data: undefined, + errors: [ + { message: "fail", extensions: { error_name: "TestError" } }, + ], + }), + statusText: "OK", + }) as Response, + ); + const requester = new Requester( + nodeKeyCache, + schemaEndpoint, + sdkUserAgent, + authProvider, + baseUrl, + cryptoImpl, + signingKey, + fetchImpl, + ); + await expect( + requester.makeRawRequest("query TestQuery { foo }", {}), + ).rejects.toThrow(LightsparkException); + }); + }); + + describe("subscribe", () => { + it("throws on mutation query", () => { + const requester = new Requester( + nodeKeyCache, + schemaEndpoint, + sdkUserAgent, + authProvider, + baseUrl, + cryptoImpl, + signingKey, + fetchImpl, + ); + expect(() => + requester.subscribe("mutation TestMutation { foo }"), + ).toThrow(LightsparkException); + }); + + it("throws on invalid query", () => { + const requester = new Requester( + nodeKeyCache, + schemaEndpoint, + sdkUserAgent, + authProvider, + baseUrl, + cryptoImpl, + signingKey, + fetchImpl, + ); + expect(() => requester.subscribe("invalid")).toThrow(LightsparkException); + }); + + it("returns an Observable for a valid subscription", async () => { + // Mock wsClient and its subscribe method + const wsClient = { + subscribe: jest.fn( + ( + _body, + handlers: { next?: (data: unknown) => void; complete?: () => void }, + ) => { + setTimeout(() => { + handlers.next?.({ data: { foo: "bar" } }); + handlers.complete?.(); + }, 10); + return jest.fn(); + }, + ), + } as unknown as WsClient; + + const { createClient } = await import("graphql-ws"); + (createClient as jest.Mock).mockReturnValue(wsClient); + + const requester = new Requester( + nodeKeyCache, + schemaEndpoint, + sdkUserAgent, + authProvider, + baseUrl, + cryptoImpl, + signingKey, + fetchImpl, + ); + + const observable = requester.subscribe<{ foo: string }>( + "subscription TestSub { foo }", + ); + + const results: { foo: string }[] = []; + await new Promise((resolve) => { + observable.subscribe({ + next: (data) => { + results.push(data.data); + }, + complete: () => { + expect(results).toEqual([{ foo: "bar" }]); + resolve(); + }, + }); + }); + + expect(wsClient.subscribe).toHaveBeenCalled(); + }); + }); + + describe("signing logic", () => { + it("adds signing headers if signingNodeId is provided", async () => { + (nodeKeyCache.getKey as jest.Mock).mockReturnValue(signingKey); + const requester = new Requester( + nodeKeyCache, + schemaEndpoint, + sdkUserAgent, + authProvider, + baseUrl, + cryptoImpl, + undefined, + fetchImpl, + ); + const spy = jest.spyOn(signingKey, "sign"); + await requester.makeRawRequest("query TestQuery { foo }", {}, "node123"); + expect(spy).toHaveBeenCalled(); + }); + + it("throws if signingKey is missing", async () => { + (nodeKeyCache.getKey as jest.Mock).mockReturnValue(undefined); + const requester = new Requester( + nodeKeyCache, + schemaEndpoint, + sdkUserAgent, + authProvider, + baseUrl, + cryptoImpl, + undefined, + fetchImpl, + ); + await expect( + requester.makeRawRequest("query TestQuery { foo }", {}, "node123"), + ).rejects.toThrow(); + }); + }); +}); diff --git a/packages/core/src/utils/errors.ts b/packages/core/src/utils/errors.ts index c34145410..63ccb454c 100644 --- a/packages/core/src/utils/errors.ts +++ b/packages/core/src/utils/errors.ts @@ -1,3 +1,4 @@ +import { isObject } from "./typeGuards.js"; import { type JSONType } from "./types.js"; export const isError = (e: unknown): e is Error => { @@ -35,15 +36,35 @@ export const isErrorMsg = (e: unknown, msg: string) => { return false; }; -export function errorToJSON(err: unknown) { +/* Make non-enumerable properties like message and stack enumerable so they + can be handled by JSON.stringify */ +function normalizeObject(obj: unknown): Record { + const normalized: Record = {}; + if (isObject(obj)) { + const props = Object.getOwnPropertyNames(obj); + for (const prop of props) { + const objRecord = obj as Record; + normalized[prop] = objRecord[prop]; + } + } + return normalized; +} + +export function errorToJSON( + err: unknown, + /* Enable stringifying non-primitives globally for subpaths. + Useful for enforcing single level error objects: */ + stringifyObjects = false, +) { if (!err) { return null; } - if ( - typeof err === "object" && - "toJSON" in err && - typeof err.toJSON === "function" - ) { + + /* Objects can add standard toJSON method to determine JSON.stringify output, https://mzl.la/3Gks9zu: */ + if (isObject(err) && "toJSON" in err && typeof err.toJSON === "function") { + if (stringifyObjects === true) { + return objectToJSON(err.toJSON()); + } return err.toJSON() as JSONType; } @@ -57,7 +78,28 @@ export function errorToJSON(err: unknown) { return { message: err.message }; } + return objectToJSON(err); +} + +function objectToJSON(obj: unknown) { + const normalizedObj = normalizeObject(obj); return JSON.parse( - JSON.stringify(err, Object.getOwnPropertyNames(err)), + JSON.stringify(normalizedObj, (key, value: unknown) => { + /* Initial call passes the top level object with empty key: */ + if (key === "") { + return value; + } + + const objProps = Object.getOwnPropertyNames(normalizedObj); + if (!objProps.includes(key)) { + return undefined; + } + + if (isObject(value)) { + return JSON.stringify(value); + } + + return value; + }), ) as JSONType; } diff --git a/packages/core/src/utils/localStorage.ts b/packages/core/src/utils/localStorage.ts index ea9866035..110b4309d 100644 --- a/packages/core/src/utils/localStorage.ts +++ b/packages/core/src/utils/localStorage.ts @@ -1,15 +1,31 @@ import { type ConfigKeys } from "../constants/index.js"; export function getLocalStorageConfigItem(key: ConfigKeys) { - return getLocalStorageBoolean(key); + const localStorageBoolean = getLocalStorageBoolean(key); + // If config not set, just default to false + if (localStorageBoolean == null) { + return false; + } + + return localStorageBoolean; } export function getLocalStorageBoolean(key: string) { /* localStorage is not available in all contexts, use try/catch: */ try { - return localStorage.getItem(key) === "1"; + if (localStorage.getItem(key) === "1") { + return true; + } + // Key is not set + else if (localStorage.getItem(key) == null) { + return null; + } + // Key is set but not "1" + else { + return false; + } } catch (e) { - return false; + return null; } } diff --git a/packages/core/src/utils/typeGuards.ts b/packages/core/src/utils/typeGuards.ts index a70ac4eca..f31f9c6a0 100644 --- a/packages/core/src/utils/typeGuards.ts +++ b/packages/core/src/utils/typeGuards.ts @@ -2,7 +2,16 @@ export function isUint8Array(value: unknown): value is Uint8Array { return value instanceof Uint8Array; } -export function isObject(value: unknown): value is Record { +export function isObject(value: unknown): value is object { const type = typeof value; return value != null && (type == "object" || type == "function"); } + +export function isRecord(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.prototype.toString.call(value) === "[object Object]" + ); +} diff --git a/packages/core/src/utils/types.ts b/packages/core/src/utils/types.ts index d16d84175..c9d52cf87 100644 --- a/packages/core/src/utils/types.ts +++ b/packages/core/src/utils/types.ts @@ -59,7 +59,3 @@ export type Complete = { [P in keyof T]-?: NonNullable }; export type RequiredKeys = { [K in keyof T]-?: Record extends Pick ? never : K; }[keyof T]; - -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/packages/static/images/lightspark-logo.svg b/packages/static/images/lightspark-logo.svg index da641c776..387d07084 100644 --- a/packages/static/images/lightspark-logo.svg +++ b/packages/static/images/lightspark-logo.svg @@ -1,55 +1,14 @@ - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + diff --git a/packages/ui/src/components/BirthdayInput.tsx b/packages/ui/src/components/BirthdayInput.tsx index 6ae76f1e1..d1ee3650d 100644 --- a/packages/ui/src/components/BirthdayInput.tsx +++ b/packages/ui/src/components/BirthdayInput.tsx @@ -1,8 +1,6 @@ import { TextInput } from "@lightsparkdev/ui/src/components"; -import { InputSubtext } from "@lightsparkdev/ui/src/styles/fields"; import dayjs from "dayjs"; import customParseFormat from "dayjs/plugin/customParseFormat.js"; -import { useState } from "react"; dayjs.extend(customParseFormat); @@ -34,6 +32,7 @@ export function formatBirthday( ? dayjs(birthdayStr).startOf("day").format("YYYY-MM-DD") : undefined; } + /** * Valides a date string. the required format is MM/DD/YYYY, or the components can be passed in separately. * @param dayOrDate - The day or date to check @@ -52,7 +51,51 @@ export function isValidBirthday( } else { birthdayStr = dayOrDate; } - return dayjs(birthdayStr, "MM/DD/YYYY", true).isValid(); + + const date = dayjs(birthdayStr, "MM/DD/YYYY", true); + if (!date.isValid()) { + return false; + } + + const today = dayjs().startOf("day"); + return date.isBefore(today); +} + +export function formatDateToText(dateStr: string): string { + if (!dateStr.trim()) return ""; + + const parts = dateStr.split("/"); + const month = parts[0]; + const day = parts[1]; + const year = parts[2]; + + if (month && !day && !year) { + if (month.length === 2) { + const monthNum = parseInt(month); + if (monthNum >= 1 && monthNum <= 12) { + return dayjs() + .month(monthNum - 1) + .format("MMMM"); + } + } + } + + if (month && day && !year) { + const monthNum = parseInt(month); + const dayNum = parseInt(day); + + if (monthNum >= 1 && monthNum <= 12) { + const testDate = dayjs() + .month(monthNum - 1) + .date(dayNum); + if (testDate.isValid() && testDate.date() === dayNum) { + return `${testDate.format("MMMM")} ${day}`; + } + } + } + + const date = dayjs(dateStr, "MM/DD/YYYY", true); + return date.isValid() ? date.format("MMMM D, YYYY") : ""; } export function BirthdayInput({ @@ -60,8 +103,6 @@ export function BirthdayInput({ setDate, invalidBirthdayError, }: BirthdayInputProps) { - const [birthdayValid, setBirthdayValid] = useState(isValidBirthday(date)); - const birthdayFieldBlurred = Boolean(date.trim()); const handleChange = (newValue: string): void => { @@ -78,29 +119,28 @@ export function BirthdayInput({ if (value.length > 10) { value = value.slice(0, 10); } - setBirthdayValid(isValidBirthday(value)); - setDate(value); }; + const isCompleteDate = date.length === 10; + const isInvalid = isCompleteDate && !isValidBirthday(date); + return ( <> - ); diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx index 85865fbe2..e9f3cd35f 100644 --- a/packages/ui/src/components/Button.tsx +++ b/packages/ui/src/components/Button.tsx @@ -45,6 +45,7 @@ export const buttonKinds = [ "ghost", "transparent", "green33", + "green37", "purple55", "blue43", "blue39", @@ -57,6 +58,8 @@ export const buttonKinds = [ "roundIcon", "gray", "grayGradient", + "gray99", + "white21", ] as const; export type ButtonKind = (typeof buttonKinds)[number]; @@ -92,6 +95,7 @@ export type ButtonProps = { onBlur?: ((event: FocusEvent) => void) | undefined; mt?: number | "auto"; ml?: number | "auto"; + mb?: number | "auto"; fullWidth?: boolean | undefined; type?: "button" | "submit"; newTab?: boolean; @@ -269,6 +273,7 @@ export const Button = forwardRef< disabled = false, mt = 0, ml = 0, + mb = 0, type = "button", newTab = false, zIndex = undefined, @@ -376,6 +381,11 @@ export const Button = forwardRef< ? `${ml}px` : "auto" : undefined, + marginBottom: mb + ? typeof mb === "number" + ? `${mb}px` + : "auto" + : undefined, }, newTab, borderRadius, diff --git a/packages/ui/src/components/CardForm/CardForm.tsx b/packages/ui/src/components/CardForm/CardForm.tsx index 581fcd7f2..8e1c36404 100644 --- a/packages/ui/src/components/CardForm/CardForm.tsx +++ b/packages/ui/src/components/CardForm/CardForm.tsx @@ -2,7 +2,7 @@ import type { Theme } from "@emotion/react"; import { css, useTheme } from "@emotion/react"; import styled from "@emotion/styled"; import type { ComponentProps, FormEvent, ReactNode } from "react"; -import { useCallback } from "react"; +import { Fragment, useCallback } from "react"; import { Link } from "../../router.js"; import { bp } from "../../styles/breakpoints.js"; import { colors } from "../../styles/colors.js"; @@ -47,6 +47,7 @@ import { StyledButtonRowButton } from "../ButtonRow.js"; import { Checkbox, type CheckboxProps } from "../Checkbox.js"; import { ClipboardTextField } from "../ClipboardTextField.js"; import { StyledFileInput } from "../FileInput.js"; +import { Flex } from "../Flex.js"; import { LoadingWrapper } from "../Loading.js"; import { StyledSelect } from "../Select.js"; import { type TextIconAligner } from "../TextIconAligner.js"; @@ -60,8 +61,10 @@ type CardFormProps = { children?: ReactNode; disabled?: boolean; topContent?: ReactNode; + aboveHeaderContent?: ReactNode; title?: string; titleSize?: TokenSizeKey; + titleMarginLeft?: number | undefined; titleRightIcon?: | ComponentProps["rightIcon"] | undefined; @@ -76,9 +79,16 @@ type CardFormProps = { shadow?: CardFormShadow; paddingTop?: CardFormPaddingTop | undefined; paddingBottom?: CardFormPaddingBottom | undefined; + paddingX?: CardFormPaddingX | undefined; belowFormContent?: ToReactNodesArgs | undefined; belowFormContentGap?: BelowCardFormContentGap | undefined; forceMarginAfterSubtitle?: boolean; + contentMarginTop?: number | undefined; + graphicHeader?: React.ReactNode; + centeredContent?: boolean; + formButtonTopMargin?: number | undefined; + selectMarginTop?: number | undefined; + smDontAdjustWidth?: boolean | undefined; }; type ResolvePropsArgs = { @@ -87,6 +97,7 @@ type ResolvePropsArgs = { textAlign?: CardFormTextAlign | undefined; paddingTop?: CardFormPaddingTop | undefined; paddingBottom?: CardFormPaddingBottom | undefined; + paddingX?: CardFormPaddingX | undefined; }; function resolveProps(args: ResolvePropsArgs, theme: Theme) { @@ -96,13 +107,20 @@ function resolveProps(args: ResolvePropsArgs, theme: Theme) { "paddingTop", theme, ); + const paddingBottom = resolveCardFormProp( args.paddingBottom, args.kind, "paddingBottom", theme, ); - const paddingX = resolveCardFormProp(undefined, args.kind, "paddingX", theme); + + const paddingX = resolveCardFormProp( + args.paddingX, + args.kind, + "paddingX", + theme, + ); const textAlign = resolveCardFormProp( args.textAlign, args.kind, @@ -175,8 +193,10 @@ export function CardForm({ children, disabled, topContent = null, + aboveHeaderContent, title, titleSize = "Large", + titleMarginLeft, description, full = false, onSubmit, @@ -190,10 +210,17 @@ export function CardForm({ textAlign: textAlignProp, paddingTop: paddingTopProp, paddingBottom: paddingBottomProp, + paddingX: paddingXProp, belowFormContent, belowFormContentGap = 0, forceMarginAfterSubtitle = true, afterTitleMargin = 40, + graphicHeader, + centeredContent = false, + contentMarginTop, + formButtonTopMargin, + selectMarginTop, + smDontAdjustWidth = false, }: CardFormProps) { const theme = useTheme(); const { @@ -216,6 +243,7 @@ export function CardForm({ shadow: shadowProp, paddingTop: paddingTopProp, paddingBottom: paddingBottomProp, + paddingX: paddingXProp, }, theme, ); @@ -247,28 +275,44 @@ export function CardForm({ const CardFormContentTarget = full ? CardFormContentFull : CardFormContent; + const headerData = [ + aboveHeaderContent ? ( + {aboveHeaderContent} + ) : null, + title ? ( + + + + ) : null, + formattedDescription ? ( + + {formattedDescription} + + ) : null, + ]; + const content = ( {topContent} - {title && ( - - - - )} - {formattedDescription && ( - {formattedDescription} + {centeredContent ? ( + {headerData} + ) : ( + headerData )} {children} @@ -288,31 +332,48 @@ export function CardForm({ smBackgroundColor, smBorderWidth, forceMarginAfterSubtitle, + smDontAdjustWidth, }; const Container = full ? CardFormContentFull : CardFormContainer; return ( - + {hasChildForm ? ( {content} ) : ( - - {content} - + + {graphicHeader && graphicHeader} + + {content} + + + )} + {belowFormContentNodes && ( + + {belowFormContentNodes} + )} - - {belowFormContentNodes} - ); } +const CenteredHeader = styled.div` + display: flex; + flex-direction: column; + flex-grow: 1; + height: 100%; + justify-content: center; +`; + const CardFormContainer = styled.div` display: flex; flex-direction: column; @@ -324,11 +385,12 @@ const CardFormContent = styled.div` align-self: center; `; -const CardFormContentFull = styled.div` +const CardFormContentFull = styled.div<{ paddingBottom?: number | undefined }>` display: flex; flex-direction: column; align-self: center; height: 100%; + padding-bottom: ${({ paddingBottom }) => paddingBottom ?? 0}px; `; type BelowCardFormContentProps = { @@ -406,10 +468,15 @@ const StyledCardFormDataFieldValue = styled.div` `; export const CardFormFullWidth = styled.div``; -export const CardFormFullTopContent = styled.div` - ${bp.minSm(` - border-radius: 16px 16px 0 0; + +type CardFormFullTopContentProps = { + borderRadius?: string; +}; +export const CardFormFullTopContent = styled.div` + ${({ borderRadius = "16px 16px 0 0" }) => + bp.minSm(` overflow: hidden; + border-radius: ${borderRadius}; `)} `; @@ -423,6 +490,8 @@ type CardFormInsetProps = { paddingX: number; paddingTop: number; paddingBottom: number; + smDontAdjustWidth?: boolean; + graphicHeader?: boolean; }; const formInset = ({ @@ -430,6 +499,8 @@ const formInset = ({ paddingX, paddingTop, paddingBottom, + smDontAdjustWidth, + graphicHeader, }: CardFormInsetProps) => css` margin-left: auto; margin-right: auto; @@ -444,18 +515,24 @@ const formInset = ({ `)} ${bp.sm(` - padding: 0; + padding: ${graphicHeader ? "24px" : "0"}; `)} - ${standardContentInset.smCSS} + ${graphicHeader || smDontAdjustWidth + ? `width: 100%;` + : standardContentInset.smCSS} & ${CardFormFullWidth}, & ${CardFormFullTopContent} { - ${bp.sm(` + ${smDontAdjustWidth + ? "" + : bp.sm(` width: calc(100% + ${standardContentInset.smPx * 2}px); margin-left: -${standardContentInset.smPx}px; `)} - ${bp.minSm(` + ${smDontAdjustWidth + ? "" + : bp.minSm(` width: calc(100% + ${paddingX * 2}px); margin-left: -${paddingX}px; `)} @@ -482,6 +559,10 @@ type StyledCardFormStyleProps = { smBackgroundColor: CardFormBackgroundColor; smBorderWidth: CardFormBorderWidth; forceMarginAfterSubtitle: boolean | undefined; + graphicHeader?: boolean | undefined; + formButtonTopMargin?: number | undefined; + selectMarginTop?: number | undefined; + smDontAdjustWidth?: boolean | undefined; }; const StyledCardFormStyle = ({ @@ -499,9 +580,29 @@ const StyledCardFormStyle = ({ smBackgroundColor, smBorderWidth, forceMarginAfterSubtitle = true, + graphicHeader, + formButtonTopMargin = 32, + selectMarginTop = inputSpacingPx, + smDontAdjustWidth = false, }: StyledCardFormStyleProps & { theme: Theme }) => { return css` - ${formInset({ wide, paddingX, paddingTop, paddingBottom })} + ${graphicHeader + ? formInset({ + wide, + paddingX, + paddingTop, + paddingBottom, + graphicHeader, + smDontAdjustWidth, + }) + : formInset({ + wide, + paddingX, + paddingTop, + paddingBottom, + smDontAdjustWidth, + })} + ${shadow === "soft" ? standardCardShadow : shadow === "hard" @@ -522,7 +623,11 @@ const StyledCardFormStyle = ({ `)} ${bp.minSm(` - border-radius: ${borderRadius}px; + border-radius: ${ + graphicHeader + ? `0 0 ${borderRadius}px ${borderRadius}px` + : `${borderRadius}px` + }; `)} ${CardHeadline}, ${CardFormSubtitle} { @@ -542,7 +647,7 @@ const StyledCardFormStyle = ({ } ${StyledSelect}, ${ToggleContainer} { - margin-top: ${inputSpacingPx}px; + margin-top: ${selectMarginTop}px; } ${CardFormFieldLabel} { @@ -578,7 +683,9 @@ const StyledCardFormStyle = ({ } & > ${ButtonSelector()}, & > ${select(CardFormNearButtonColumn)} { - ${formButtonTopMarginStyle} + ${formButtonTopMargin !== undefined + ? `margin-top: ${formButtonTopMargin}px;` + : formButtonTopMarginStyle} } } `; @@ -593,10 +700,15 @@ const StyledCardFormDiv = const CardHeadline = styled.div<{ hasTopContent: boolean; afterTitleMargin: number; + contentMarginTop?: number | undefined; + titleMarginLeft?: number | undefined; }>` padding: 0 ${Spacing.px.xs}; - ${({ hasTopContent }) => (hasTopContent ? "margin-top: 24px;" : "")} + ${({ hasTopContent, contentMarginTop }) => + hasTopContent || contentMarginTop !== undefined + ? `margin-top: ${contentMarginTop ?? 32}px;` + : ""} & + *:not(${CardFormSubtitle.toString()}) { margin-top: ${({ afterTitleMargin }) => afterTitleMargin}px; @@ -605,6 +717,9 @@ const CardHeadline = styled.div<{ & + ${CardFormSubtitle} { margin-top: 12px; } + + ${({ titleMarginLeft }) => + titleMarginLeft !== undefined && `margin-left: ${titleMarginLeft}px;`} `; type CardFormTextWithLinkProps = { @@ -698,8 +813,8 @@ function resolveCardFormProp( return ( /** props may be unset for a given kind but theme defaults always exist, * so this will always resolve a value: */ - prop || - theme.cardForm.kinds[kind]?.[defaultKey] || - theme.cardForm[defaultKey] + prop !== undefined + ? prop + : theme.cardForm.kinds[kind]?.[defaultKey] || theme.cardForm[defaultKey] ); } diff --git a/packages/ui/src/components/CardForm/CardFormHeadline.tsx b/packages/ui/src/components/CardForm/CardFormHeadline.tsx new file mode 100644 index 000000000..74c303d5c --- /dev/null +++ b/packages/ui/src/components/CardForm/CardFormHeadline.tsx @@ -0,0 +1,27 @@ +import styled from "@emotion/styled"; +import { type ReactNode } from "react"; + +export type CardFormHeaderProps = { + children?: ReactNode; + bundle?: boolean; + grow?: boolean; +}; + +export function CardFormHeader({ + children, + bundle = false, +}: CardFormHeaderProps) { + if (!bundle) { + return children; + } + + return {children}; +} + +const Wrapper = styled.div<{ grow?: boolean }>` + display: flex; + flex-direction: column; + ${({ grow }) => grow && `flex-grow: 1;`} + height: 100%; + justify-content: center; +`; diff --git a/packages/ui/src/components/Checkbox.tsx b/packages/ui/src/components/Checkbox.tsx index 3d09264a1..d3b651bd5 100644 --- a/packages/ui/src/components/Checkbox.tsx +++ b/packages/ui/src/components/Checkbox.tsx @@ -13,6 +13,9 @@ export type CheckboxProps = { id?: string; label?: ToReactNodesArgs | undefined; mt?: number; + mb?: number; + ml?: number; + mr?: number; alignItems?: "center" | "flex-start"; disabled?: boolean; typography?: PartialSimpleTypographyProps; @@ -24,6 +27,9 @@ export function Checkbox({ id, label, mt = 0, + mb = 0, + ml = 0, + mr = 0, alignItems = "center", disabled = false, typography: typographyProp, @@ -41,7 +47,7 @@ export function Checkbox({ const content = toReactNodes(nodesWithTypography); return ( - + ` export const CheckboxContainer = styled.span<{ mt: number; + mb: number; + ml: number; + mr: number; alignItems: string; }>` display: flex; ${({ mt }) => (mt === 0 ? "" : `margin-top: ${mt}px;`)} + ${({ mb }) => (mb === 0 ? "" : `margin-bottom: ${mb}px;`)} + ${({ ml }) => (ml === 0 ? "" : `margin-left: ${ml}px;`)} + ${({ mr }) => (mr === 0 ? "" : `margin-right: ${mr}px;`)} align-items: ${({ alignItems }) => alignItems}; & + & { diff --git a/packages/ui/src/components/DataManagerTable/EnumFilter.tsx b/packages/ui/src/components/DataManagerTable/EnumFilter.tsx index 65f75f6cf..31e149ab6 100644 --- a/packages/ui/src/components/DataManagerTable/EnumFilter.tsx +++ b/packages/ui/src/components/DataManagerTable/EnumFilter.tsx @@ -70,7 +70,7 @@ export const EnumFilter = ({