diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c4a71bb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: + - "**" + pull_request: + branches: + - master + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + name: Quality Checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: voidzero-dev/setup-vp@v1 + with: + cache: true + + - name: Lint & Format & Type Check + run: vp check + + - name: Test + run: vp test + + - name: Build + run: pnpm build diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml new file mode 100644 index 0000000..1d0b3ae --- /dev/null +++ b/.github/workflows/deploy-pages.yml @@ -0,0 +1,50 @@ +name: Deploy to GitHub Pages + +on: + release: + types: [published] + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: voidzero-dev/setup-vp@v1 + with: + cache: true + + - name: Build packages + run: pnpm build + + - name: Assemble site + run: | + mkdir -p _site/dist _site/examples + cp packages/js/dist/custom-widget.js _site/dist/ + cp packages/js/dist/custom-widget.min.js _site/dist/ + cp packages/js/dist/custom-widget.css _site/dist/ + cp packages/js/dist/custom-widget.min.css _site/dist/ + cp packages/js/examples/*.html _site/examples/ + + - uses: actions/upload-pages-artifact@v3 + + deploy: + name: Deploy + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 025b3ee..434468d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ out/ yarn.lock .cache !.github/** +docs/ # pnpm .pnpm-store/ diff --git a/.vite-hooks/pre-commit b/.vite-hooks/pre-commit new file mode 100755 index 0000000..85fb65b --- /dev/null +++ b/.vite-hooks/pre-commit @@ -0,0 +1 @@ +vp staged diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..adfdf83 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,88 @@ + + +# Using Vite+, the Unified Toolchain for the Web + +This project is using Vite+, a unified toolchain built on top of Vite, Rolldown, Vitest, tsdown, Oxlint, Oxfmt, and Vite Task. Vite+ wraps runtime management, package management, and frontend tooling in a single global CLI called `vp`. Vite+ is distinct from Vite, but it invokes Vite through `vp dev` and `vp build`. + +## Vite+ Workflow + +`vp` is a global binary that handles the full development lifecycle. Run `vp help` to print a list of commands and `vp --help` for information about a specific command. + +### Start + +- create - Create a new project from a template +- migrate - Migrate an existing project to Vite+ +- config - Configure hooks and agent integration +- staged - Run linters on staged files +- install (`i`) - Install dependencies +- env - Manage Node.js versions + +### Develop + +- dev - Run the development server +- check - Run format, lint, and TypeScript type checks +- lint - Lint code +- fmt - Format code +- test - Run tests + +### Execute + +- run - Run monorepo tasks +- exec - Execute a command from local `node_modules/.bin` +- dlx - Execute a package binary without installing it as a dependency +- cache - Manage the task cache + +### Build + +- build - Build for production +- pack - Build libraries +- preview - Preview production build + +### Manage Dependencies + +Vite+ automatically detects and wraps the underlying package manager such as pnpm, npm, or Yarn through the `packageManager` field in `package.json` or package manager-specific lockfiles. + +- add - Add packages to dependencies +- remove (`rm`, `un`, `uninstall`) - Remove packages from dependencies +- update (`up`) - Update packages to latest versions +- dedupe - Deduplicate dependencies +- outdated - Check for outdated packages +- list (`ls`) - List installed packages +- why (`explain`) - Show why a package is installed +- info (`view`, `show`) - View package information from the registry +- link (`ln`) / unlink - Manage local package links +- pm - Forward a command to the package manager + +### Maintain + +- upgrade - Update `vp` itself to the latest version + +These commands map to their corresponding tools. For example, `vp dev --port 3000` runs Vite's dev server and works the same as Vite. `vp test` runs JavaScript tests through the bundled Vitest. The version of all tools can be checked using `vp --version`. This is useful when researching documentation, features, and bugs. + +## Common Pitfalls + +- **Using the package manager directly:** Do not use pnpm, npm, or Yarn directly. Vite+ can handle all package manager operations. +- **Always use Vite commands to run tools:** Don't attempt to run `vp vitest` or `vp oxlint`. They do not exist. Use `vp test` and `vp lint` instead. +- **Running scripts:** Vite+ built-in commands (`vp dev`, `vp build`, `vp test`, etc.) always run the Vite+ built-in tool, not any `package.json` script of the same name. To run a custom script that shares a name with a built-in command, use `vp run ``` @@ -63,7 +63,9 @@ function Dashboard() {

{widget?.label}

diff --git a/biome.json b/biome.json deleted file mode 100644 index 476a336..0000000 --- a/biome.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.0.x/schema.json", - "formatter": { - "enabled": true, - "indentStyle": "space", - "indentWidth": 2, - "lineWidth": 120, - "lineEnding": "lf" - }, - "javascript": { - "formatter": { - "quoteStyle": "double", - "trailingCommas": "es5", - "semicolons": "always", - "arrowParentheses": "always", - "bracketSpacing": true - } - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "correctness": { - "useExhaustiveDependencies": "error", - "noUnusedImports": "error" - }, - "style": { - "noUnusedTemplateLiteral": "warn" - }, - "suspicious": { - "noDoubleEquals": "error" - } - } - }, - "assist": { - "actions": { - "source": { - "organizeImports": "on" - } - } - }, - "files": { - "includes": ["packages/*/src/**", "packages/*/tests/**", "!**/dist", "!**/node_modules", "!**/coverage"] - } -} diff --git a/package.json b/package.json index 2abe2f0..0e68570 100644 --- a/package.json +++ b/package.json @@ -2,19 +2,35 @@ "name": "@tago-io/custom-widget-monorepo", "private": true, "description": "TagoIO Custom Widget SDK monorepo", - "author": "Tago LLC", "license": "Apache-2.0", + "author": "Tago LLC", "repository": "tago-io/custom-widget", - "packageManager": "pnpm@10.13.1", + "type": "module", "scripts": { "build": "turbo run build", "test": "turbo run test", "check:types": "turbo run check:types", "lint": "turbo run lint", + "format": "turbo run format", + "check": "vp check", "changeset": "changeset", "version-packages": "changeset version", - "release": "turbo run build && changeset publish" + "release": "turbo run build && changeset publish", + "prepare": "vp config" + }, + "devDependencies": { + "@changesets/cli": "~2.30.0", + "jsdom": "29.0.0", + "turbo": "2.8.19", + "typescript": "5.9.3", + "vite": "catalog:", + "vite-plus": "catalog:", + "vitest": "catalog:" }, + "engines": { + "node": ">=20.0.0" + }, + "packageManager": "pnpm@10.13.1", "pnpm": { "onlyBuiltDependencies": [ "esbuild" @@ -22,15 +38,5 @@ "ignoredBuiltDependencies": [ "esbuild" ] - }, - "devDependencies": { - "@biomejs/biome": "2.4.8", - "@changesets/cli": "~2.30.0", - "@vitest/coverage-v8": "4.1.0", - "jsdom": "29.0.0", - "turbo": "2.8.19", - "typescript": "5.9.3", - "vite": "8.0.0", - "vitest": "4.1.0" } } diff --git a/packages/core/README.md b/packages/core/README.md index b8033cb..8e7a69e 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -20,8 +20,8 @@ The central state manager. Handles communication with the TagoIO platform via po import { WidgetStore } from "@tago-io/custom-widget-core"; const store = new WidgetStore({ - realtimeStrategy: "merge", // "replace" | "append" | "merge" - realtimeMaxRecords: 1000, // max records for "append" strategy + realtimeStrategy: "merge", // "replace" | "append" | "merge" + realtimeMaxRecords: 1000, // max records for "append" strategy allowedOrigins: ["https://admin.tago.io"], // optional origin validation readyOptions: { header: { color: "#333" } }, }); diff --git a/packages/core/package.json b/packages/core/package.json index 3202f2b..c3d2d27 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -2,7 +2,14 @@ "name": "@tago-io/custom-widget-core", "version": "0.1.0", "description": "Framework-agnostic core for TagoIO Custom Widget SDKs", + "license": "Apache-2.0", + "author": "Tago LLC", + "repository": "tago-io/custom-widget", + "files": [ + "dist" + ], "type": "module", + "sideEffects": false, "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -18,15 +25,12 @@ } } }, - "sideEffects": false, - "files": ["dist"], - "author": "Tago LLC", - "license": "Apache-2.0", - "repository": "tago-io/custom-widget", "scripts": { "build": "tsup", - "test": "vitest run", - "check:types": "tsc --noEmit" + "test": "vp test run", + "check:types": "tsc --noEmit", + "lint": "vp lint src/", + "format": "vp fmt src/" }, "devDependencies": { "tsup": "8.5.0" diff --git a/packages/core/src/bridge/message-bridge.ts b/packages/core/src/bridge/message-bridge.ts index 16a9fd1..9680b87 100644 --- a/packages/core/src/bridge/message-bridge.ts +++ b/packages/core/src/bridge/message-bridge.ts @@ -35,7 +35,7 @@ export class MessageBridge { if (!data) return; if (data.status !== undefined && data.key) { - if (data.status === true) { + if (data.status) { this.pool.resolve(data.key, data as unknown as TData); } else { this.pool.reject(data.key, data as unknown as TError); @@ -43,7 +43,11 @@ export class MessageBridge { } for (const handler of this.handlers) { - handler(data); + try { + handler(data); + } catch (err) { + console.error("[TagoIO Widget] Message handler threw an error:", err); + } } } @@ -82,4 +86,8 @@ export class MessageBridge { get pendingCount(): number { return this.pool.size; } + + get isDestroyed(): boolean { + return this.destroyed; + } } diff --git a/packages/core/src/store/realtime-strategies.ts b/packages/core/src/store/realtime-strategies.ts index bc830c1..9213549 100644 --- a/packages/core/src/store/realtime-strategies.ts +++ b/packages/core/src/store/realtime-strategies.ts @@ -78,16 +78,16 @@ export function mergeStrategy(existing: TRealtimeData[], incoming: TRealtimeData function mergeRecords(existing: TDataRecord[], incoming: TDataRecord[]): TDataRecord[] { if (incoming.length === 0) return existing; - const existingById = new Map(); - for (const record of existing) { - existingById.set(record.id, record); + const incomingById = new Map(); + for (const record of incoming) { + incomingById.set(record.id, record); } let changed = false; const result: TDataRecord[] = []; for (const existingRecord of existing) { - const incomingMatch = incoming.find((r) => r.id === existingRecord.id); + const incomingMatch = incomingById.get(existingRecord.id); if (incomingMatch) { if (recordsEqual(existingRecord, incomingMatch)) { result.push(existingRecord); @@ -95,16 +95,20 @@ function mergeRecords(existing: TDataRecord[], incoming: TDataRecord[]): TDataRe result.push(incomingMatch); changed = true; } + incomingById.delete(existingRecord.id); } else { - result.push(existingRecord); + // Record no longer in incoming — it was deleted + changed = true; } } - for (const incomingRecord of incoming) { - if (!existingById.has(incomingRecord.id)) { - result.push(incomingRecord); - changed = true; - } + for (const [, record] of incomingById) { + result.push(record); + changed = true; + } + + if (changed) { + result.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime()); } return changed ? result : existing; diff --git a/packages/core/src/store/widget-store.ts b/packages/core/src/store/widget-store.ts index 1cb5be7..9bc22e7 100644 --- a/packages/core/src/store/widget-store.ts +++ b/packages/core/src/store/widget-store.ts @@ -33,16 +33,16 @@ export class WidgetStore { private strategy: RealtimeStrategy; private maxRecords: number; private readyOptions: TReadyOptions; + private bridgeOptions: { allowedOrigins?: string[] }; private unsubBridge: (() => void) | null = null; constructor(options: StoreOptions = {}) { this.strategy = options.realtimeStrategy ?? "merge"; this.maxRecords = options.realtimeMaxRecords ?? 1000; this.readyOptions = options.readyOptions ?? {}; + this.bridgeOptions = { allowedOrigins: options.allowedOrigins }; - this.bridge = new MessageBridge({ - allowedOrigins: options.allowedOrigins, - }); + this.bridge = new MessageBridge(this.bridgeOptions); if (typeof window !== "undefined") { this.unsubBridge = this.bridge.onMessage(this.handleInbound); @@ -50,30 +50,34 @@ export class WidgetStore { } private handleInbound = (data: InboundMessage): void => { + const partial: Partial = {}; + if (data.userInformation) { - this.updateState({ userInformation: data.userInformation }); + partial.userInformation = data.userInformation; } if (data.blueprintDevices) { - this.updateState({ blueprintDevices: data.blueprintDevices }); + partial.blueprintDevices = data.blueprintDevices; } if (data.widget) { - this.updateState({ - widget: data.widget, - isReady: true, - }); - } - - if (data.realtime) { - this.updateRealtime(data.realtime); + partial.widget = data.widget; + partial.isReady = true; } if (data.status === false) { const error = data as unknown as TError; - this.updateState({ - errors: [...this.state.errors, error], - }); + partial.errors = [...this.state.errors, error]; + } + + if (Object.keys(partial).length > 0) { + this.state = { ...this.state, ...partial }; + } + + if (data.realtime) { + this.updateRealtime(data.realtime); + } else if (Object.keys(partial).length > 0) { + this.emit(); } }; @@ -130,6 +134,15 @@ export class WidgetStore { initialize(): void { if (this.initialized) return; this.initialized = true; + + // Recreate bridge if it was destroyed (e.g. React StrictMode remount) + if (this.bridge.isDestroyed) { + this.bridge = new MessageBridge(this.bridgeOptions); + if (typeof window !== "undefined") { + this.unsubBridge = this.bridge.onMessage(this.handleInbound); + } + } + this.bridge.send({ loaded: true, ...this.readyOptions }); } @@ -150,9 +163,9 @@ export class WidgetStore { return this.bridge.sendWithResponse({ variables: vars, method: "edit" }); } - deleteData(records: TDataRecordInput | TDataRecordInput[]): Promise { + deleteData(records: string | string[]): Promise { const vars = Array.isArray(records) ? records : [records]; - return this.bridge.sendWithResponse({ variables: vars, method: "delete" }); + return this.bridge.sendWithResponse({ variables: vars as unknown as TDataRecordInput[], method: "delete" }); } editResourceData(records: TDataRecordInput | TDataRecordInput[]): Promise { @@ -168,6 +181,10 @@ export class WidgetStore { this.bridge.send({ method: "close-modal" }); } + runAnalysis(scope?: unknown): void { + this.bridge.send({ method: "run-analysis", scope }); + } + clearErrors(): void { this.updateState({ errors: [] }); } diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 350f47a..257b77b 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -1,4 +1,4 @@ -export type TMethod = "delete" | "edit" | "edit-resource" | "send" | "open-link" | "close-modal"; +export type TMethod = "delete" | "edit" | "edit-resource" | "send" | "open-link" | "close-modal" | "run-analysis"; /** GeoJSON Point location format. */ export type TLocationGeoJSON = { @@ -27,10 +27,20 @@ export type TMetadata = { [key: string]: any; }; +export type TUserPreferences = { + timezone?: string; + language?: string; + date_format?: string; + time_format?: string; + decimal_separator?: string; +}; + export type TUserInformation = { token: string | null; language: string | null; runURL: string | null; + custom_preferences?: Record; + preferences?: TUserPreferences; }; export type TDashboardBlueprintDevice = { @@ -188,6 +198,7 @@ export type TMessage = { options?: TReadyOptions; url?: string; method?: TMethod; + scope?: unknown; }; export type WidgetState = { diff --git a/packages/core/tests/bridge/generate-id.test.ts b/packages/core/tests/bridge/generate-id.test.ts index 2cecc46..76d1b65 100644 --- a/packages/core/tests/bridge/generate-id.test.ts +++ b/packages/core/tests/bridge/generate-id.test.ts @@ -1,3 +1,5 @@ +import { describe, expect, it } from "vite-plus/test"; + import { generateId } from "../../src/bridge/generate-id.js"; describe("generateId", () => { diff --git a/packages/core/tests/bridge/message-bridge.test.ts b/packages/core/tests/bridge/message-bridge.test.ts index 32cd6bc..037bd98 100644 --- a/packages/core/tests/bridge/message-bridge.test.ts +++ b/packages/core/tests/bridge/message-bridge.test.ts @@ -1,3 +1,5 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + import { MessageBridge } from "../../src/bridge/message-bridge.js"; describe("MessageBridge", () => { @@ -105,6 +107,26 @@ describe("MessageBridge", () => { }); }); + describe("handler error isolation", () => { + it("continues calling remaining handlers if one throws", () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const handler1 = vi.fn(() => { + throw new Error("handler1 failed"); + }); + const handler2 = vi.fn(); + + bridge.onMessage(handler1); + bridge.onMessage(handler2); + + window.dispatchEvent(new MessageEvent("message", { data: { widget: {} } })); + + expect(handler1).toHaveBeenCalled(); + expect(handler2).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith("[TagoIO Widget] Message handler threw an error:", expect.any(Error)); + consoleSpy.mockRestore(); + }); + }); + describe("sendWithResponse", () => { it("resolves when a matching success response arrives", async () => { const promise = bridge.sendWithResponse({ variables: [] }); diff --git a/packages/core/tests/bridge/request-pool.test.ts b/packages/core/tests/bridge/request-pool.test.ts index dc306aa..8cee0a8 100644 --- a/packages/core/tests/bridge/request-pool.test.ts +++ b/packages/core/tests/bridge/request-pool.test.ts @@ -1,3 +1,5 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + import { RequestPool } from "../../src/bridge/request-pool.js"; import type { TData, TError } from "../../src/types/index.js"; diff --git a/packages/core/tests/store/realtime-strategies.test.ts b/packages/core/tests/store/realtime-strategies.test.ts index 44f6e88..ea07398 100644 --- a/packages/core/tests/store/realtime-strategies.test.ts +++ b/packages/core/tests/store/realtime-strategies.test.ts @@ -1,5 +1,7 @@ -import type { TDataRecord, TRealtimeData } from "../../src/types/index.js"; +import { describe, expect, it } from "vite-plus/test"; + import { appendStrategy, mergeStrategy, replaceStrategy } from "../../src/store/realtime-strategies.js"; +import type { TDataRecord, TRealtimeData } from "../../src/types/index.js"; const record = (id: string, variable: string, value: string | number, time = "2024-01-01T00:00:00Z"): TDataRecord => ({ id, @@ -80,11 +82,22 @@ describe("mergeStrategy", () => { const r2 = record("2", "humidity", 60); const existing = [block(["temp", "humidity"], "d1", [r1, r2])]; const r1Updated = record("1", "temp", 25); - const incoming = [block(["temp", "humidity"], "d1", [r1Updated])]; + const incoming = [block(["temp", "humidity"], "d1", [r1Updated, r2])]; const result = mergeStrategy(existing, incoming); - expect(result[0].result![1]).toBe(r2); expect(result[0].result![0]).toBe(r1Updated); + expect(result[0].result![1]).toBe(r2); + }); + + it("removes records not present in incoming (deleted)", () => { + const r1 = record("1", "temp", 20); + const r2 = record("2", "temp", 30); + const existing = [block(["temp"], "d1", [r1, r2])]; + const incoming = [block(["temp"], "d1", [r2])]; + + const result = mergeStrategy(existing, incoming); + expect(result[0].result).toHaveLength(1); + expect(result[0].result![0]).toBe(r2); }); it("adds new blocks that don't exist in existing", () => { @@ -105,6 +118,29 @@ describe("mergeStrategy", () => { expect(result[1]).toBe(existingBlock); }); + it("sorts merged records by time descending", () => { + const existing = [ + block(["temp"], "d1", [ + record("1", "temp", 20, "2024-01-01T00:00:00Z"), + record("2", "temp", 25, "2024-01-03T00:00:00Z"), + ]), + ]; + const incoming = [ + block(["temp"], "d1", [ + record("1", "temp", 22, "2024-01-01T00:00:00Z"), + record("2", "temp", 25, "2024-01-03T00:00:00Z"), + record("3", "temp", 30, "2024-01-02T00:00:00Z"), + ]), + ]; + + const result = mergeStrategy(existing, incoming); + const records = result[0].result!; + expect(records).toHaveLength(3); + expect(records[0].id).toBe("2"); // Jan 3 (newest) + expect(records[1].id).toBe("3"); // Jan 2 + expect(records[2].id).toBe("1"); // Jan 1 (oldest) + }); + it("returns same reference when nothing changed", () => { const r1 = record("1", "temp", 20); const existing = [block(["temp"], "d1", [r1])]; diff --git a/packages/core/tests/store/widget-store.test.ts b/packages/core/tests/store/widget-store.test.ts index 62284da..884593c 100644 --- a/packages/core/tests/store/widget-store.test.ts +++ b/packages/core/tests/store/widget-store.test.ts @@ -1,3 +1,5 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + import { WidgetStore } from "../../src/store/widget-store.js"; import type { TRealtimeData, TUserInformation, TWidget, TBlueprintDevicesSyncData } from "../../src/types/index.js"; @@ -71,6 +73,23 @@ describe("WidgetStore", () => { store.initialize(); expect(mockPostMessage).toHaveBeenCalledTimes(1); }); + + it("recreates bridge after destroy and reinitialize (StrictMode)", () => { + store.initialize(); + expect(mockPostMessage).toHaveBeenCalledTimes(1); + + store.destroy(); + + store.initialize(); + expect(mockPostMessage).toHaveBeenCalledTimes(2); + + // Verify new bridge receives messages + const listener = vi.fn(); + store.subscribe(listener); + window.dispatchEvent(new MessageEvent("message", { data: { widget: mockWidget } })); + expect(listener).toHaveBeenCalled(); + expect(store.getSnapshot().widget).toEqual(mockWidget); + }); }); describe("subscription", () => { @@ -135,6 +154,22 @@ describe("WidgetStore", () => { expect(state.errors).toHaveLength(1); expect(state.errors[0].message).toBe("Something failed"); }); + + it("emits only once for a combined widget + userInformation message", () => { + const listener = vi.fn(); + store.subscribe(listener); + + window.dispatchEvent( + new MessageEvent("message", { + data: { widget: mockWidget, userInformation: mockUserInfo }, + }) + ); + + expect(listener).toHaveBeenCalledTimes(1); + const state = store.getSnapshot(); + expect(state.widget).toEqual(mockWidget); + expect(state.userInformation).toEqual(mockUserInfo); + }); }); describe("structural sharing", () => { @@ -204,10 +239,11 @@ describe("WidgetStore", () => { expect(sentMessage.method).toBe("edit"); }); - it("deleteData sends with method delete", () => { - store.deleteData({ variable: "temp", value: 42 }).catch(() => {}); + it("deleteData sends string payload with method delete", () => { + store.deleteData("rec-1:dev-1").catch(() => {}); const sentMessage = mockPostMessage.mock.calls[0][0]; expect(sentMessage.method).toBe("delete"); + expect(sentMessage.variables).toEqual(["rec-1:dev-1"]); }); it("editResourceData sends with method edit-resource", () => { diff --git a/packages/core/tests/utils/auto-fill-records.test.ts b/packages/core/tests/utils/auto-fill-records.test.ts index af17c14..fc884bb 100644 --- a/packages/core/tests/utils/auto-fill-records.test.ts +++ b/packages/core/tests/utils/auto-fill-records.test.ts @@ -1,5 +1,7 @@ -import { autoFillRecords } from "../../src/utils/auto-fill-records.js"; +import { describe, expect, it } from "vite-plus/test"; + import type { TDataRecordInput, TWidgetVariable } from "../../src/types/index.js"; +import { autoFillRecords } from "../../src/utils/auto-fill-records.js"; const widgetVars: TWidgetVariable[] = [ { variable: "temp", origin: { id: "dev1", bucket: "bucket1" } }, diff --git a/packages/core/tests/utils/format.test.ts b/packages/core/tests/utils/format.test.ts index 66192a0..9555b77 100644 --- a/packages/core/tests/utils/format.test.ts +++ b/packages/core/tests/utils/format.test.ts @@ -1,5 +1,7 @@ -import { formatValue } from "../../src/utils/format-value.js"; +import { describe, expect, it } from "vite-plus/test"; + import { formatDate } from "../../src/utils/format-date.js"; +import { formatValue } from "../../src/utils/format-value.js"; describe("formatValue", () => { it("formats a number", () => { diff --git a/packages/core/tests/utils/group-by.test.ts b/packages/core/tests/utils/group-by.test.ts index b58fa67..fb4198f 100644 --- a/packages/core/tests/utils/group-by.test.ts +++ b/packages/core/tests/utils/group-by.test.ts @@ -1,3 +1,5 @@ +import { describe, expect, it } from "vite-plus/test"; + import type { TDataRecord } from "../../src/types/index.js"; import { getLatestByVariable } from "../../src/utils/get-latest-by-variable.js"; import { groupByDevice } from "../../src/utils/group-by-device.js"; diff --git a/packages/core/tests/utils/shallow-equal.test.ts b/packages/core/tests/utils/shallow-equal.test.ts index a806357..1ec8858 100644 --- a/packages/core/tests/utils/shallow-equal.test.ts +++ b/packages/core/tests/utils/shallow-equal.test.ts @@ -1,3 +1,5 @@ +import { describe, expect, it } from "vite-plus/test"; + import { shallowEqual } from "../../src/utils/shallow-equal.js"; describe("shallowEqual", () => { diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts deleted file mode 100644 index 332d400..0000000 --- a/packages/core/vitest.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - globals: true, - environment: "jsdom", - include: ["tests/**/*.test.ts"], - coverage: { - provider: "v8", - include: ["src/**/*.ts"], - exclude: ["src/**/index.ts"], - }, - }, -}); diff --git a/packages/js/README.md b/packages/js/README.md index 27952e5..6848f0c 100644 --- a/packages/js/README.md +++ b/packages/js/README.md @@ -11,20 +11,21 @@ Include the SDK directly in your HTML file: ```html - + My Custom Widget - - - + + + + - + ``` @@ -53,16 +54,16 @@ Every custom widget follows the same basic pattern: ```javascript // 1. Set up your callbacks first -window.TagoIO.onStart(function(widget) { - console.log('Widget config:', widget); +window.TagoIO.onStart(function (widget) { + console.log("Widget config:", widget); }); -window.TagoIO.onRealtime(function(data) { - console.log('New data:', data); +window.TagoIO.onRealtime(function (data) { + console.log("New data:", data); }); -window.TagoIO.onError(function(error) { - console.error('Error:', error); +window.TagoIO.onError(function (error) { + console.error("Error:", error); }); // 2. Signal that your widget is ready @@ -75,52 +76,52 @@ The SDK exposes everything through the global `window.TagoIO` object. ### Initialization -| Function | Direction | Description | -|----------|-----------|-------------| -| `TagoIO.ready(options)` | You send | Tells TagoIO your widget has finished loading. **Must be called** to activate your widget. | -| `TagoIO.onStart(callback)` | You receive | Called when TagoIO starts your widget. Provides configuration, variables, and settings. | -| `TagoIO.onError(callback)` | You receive | Called when API errors or widget issues occur. | +| Function | Direction | Description | +| -------------------------- | ----------- | ------------------------------------------------------------------------------------------ | +| `TagoIO.ready(options)` | You send | Tells TagoIO your widget has finished loading. **Must be called** to activate your widget. | +| `TagoIO.onStart(callback)` | You receive | Called when TagoIO starts your widget. Provides configuration, variables, and settings. | +| `TagoIO.onError(callback)` | You receive | Called when API errors or widget issues occur. | ### Data Operations > **Note**: These operations only work with data that is within the Custom Widget's configured settings and permissions. -| Function | Direction | Description | -|----------|-----------|-------------| -| `TagoIO.sendData(data, callback)` | You send | Send data to TagoIO devices | -| `TagoIO.editData(data, callback)` | You send | Edit existing device data | -| `TagoIO.deleteData(data, callback)` | You send | Delete device data | -| `TagoIO.editResourceData(data, callback)` | You send | Edit platform resources | +| Function | Direction | Description | +| ----------------------------------------- | --------- | --------------------------- | +| `TagoIO.sendData(data, callback)` | You send | Send data to TagoIO devices | +| `TagoIO.editData(data, callback)` | You send | Edit existing device data | +| `TagoIO.deleteData(data, callback)` | You send | Delete device data | +| `TagoIO.editResourceData(data, callback)` | You send | Edit platform resources | ### Real-time Events -| Function | Direction | Description | -|----------|-----------|-------------| -| `TagoIO.onRealtime(callback)` | You receive | Called when new data arrives from connected devices | -| `TagoIO.onSyncUserInformation(callback)` | You receive | Called when user context or authentication updates | -| `TagoIO.onSyncBlueprintDevices(callback)` | You receive | Called when blueprint device configurations change | +| Function | Direction | Description | +| ----------------------------------------- | ----------- | --------------------------------------------------- | +| `TagoIO.onRealtime(callback)` | You receive | Called when new data arrives from connected devices | +| `TagoIO.onSyncUserInformation(callback)` | You receive | Called when user context or authentication updates | +| `TagoIO.onSyncBlueprintDevices(callback)` | You receive | Called when blueprint device configurations change | ### Utilities -| Function | Description | -|----------|-------------| -| `TagoIO.openLink(url)` | Navigate to other dashboards or external links | -| `TagoIO.closeModal()` | Close widget modal (for header button widgets) | -| `TagoIO.autoFill` | Boolean flag to enable/disable automatic device/bucket ID filling | +| Function | Description | +| ---------------------- | ----------------------------------------------------------------- | +| `TagoIO.openLink(url)` | Navigate to other dashboards or external links | +| `TagoIO.closeModal()` | Close widget modal (for header button widgets) | +| `TagoIO.autoFill` | Boolean flag to enable/disable automatic device/bucket ID filling | ## Working with Real-time Data ```javascript -window.TagoIO.onRealtime(function(data) { - data.forEach(function(dataGroup) { - if (dataGroup.result) { - dataGroup.result.forEach(function(dataPoint) { - console.log('Variable:', dataPoint.variable); - console.log('Value:', dataPoint.value); - console.log('Time:', dataPoint.time); - }); - } - }); +window.TagoIO.onRealtime(function (data) { + data.forEach(function (dataGroup) { + if (dataGroup.result) { + dataGroup.result.forEach(function (dataPoint) { + console.log("Variable:", dataPoint.variable); + console.log("Value:", dataPoint.value); + console.log("Time:", dataPoint.time); + }); + } + }); }); ``` @@ -128,36 +129,36 @@ window.TagoIO.onRealtime(function(data) { ```javascript async function sendSensorData() { - try { - const result = await window.TagoIO.sendData({ - variable: 'temperature', - value: 25.5, - unit: '°C', - time: new Date().toISOString() - }); - console.log('Data sent successfully:', result); - } catch (error) { - console.error('Failed to send data:', error); - } + try { + const result = await window.TagoIO.sendData({ + variable: "temperature", + value: 25.5, + unit: "°C", + time: new Date().toISOString(), + }); + console.log("Data sent successfully:", result); + } catch (error) { + console.error("Failed to send data:", error); + } } ``` ## Blueprint Devices ```javascript -window.TagoIO.onSyncBlueprintDevices(function(blueprintData) { - console.log('Blueprint settings:', blueprintData.settings); - console.log('Selected devices:', blueprintData.selected); +window.TagoIO.onSyncBlueprintDevices(function (blueprintData) { + console.log("Blueprint settings:", blueprintData.settings); + console.log("Selected devices:", blueprintData.selected); }); ``` ## User Information ```javascript -window.TagoIO.onSyncUserInformation(function(userInfo) { - console.log('User language:', userInfo.language); - console.log('Has token:', !!userInfo.token); - console.log('Run URL:', userInfo.runURL); +window.TagoIO.onSyncUserInformation(function (userInfo) { + console.log("User language:", userInfo.language); + console.log("Has token:", !!userInfo.token); + console.log("Run URL:", userInfo.runURL); }); ``` @@ -165,7 +166,7 @@ window.TagoIO.onSyncUserInformation(function(userInfo) { ```javascript // Navigate to another dashboard -window.TagoIO.openLink('https://admin.tago.io/dashboards/info/dashboard-id'); +window.TagoIO.openLink("https://admin.tago.io/dashboards/info/dashboard-id"); // Close modal (for header button widgets) window.TagoIO.closeModal(); @@ -179,9 +180,9 @@ The SDK includes TypeScript definitions. For TypeScript projects: import "@tago-io/custom-widget"; window.TagoIO.onStart((widget: TWidget) => { - widget.display.variables.forEach((variable: TWidgetVariable) => { - console.log(`Variable: ${variable.variable}, Device: ${variable.origin.id}`); - }); + widget.display.variables.forEach((variable: TWidgetVariable) => { + console.log(`Variable: ${variable.variable}, Device: ${variable.origin.id}`); + }); }); ``` @@ -190,9 +191,9 @@ window.TagoIO.onStart((widget: TWidget) => { Always handle errors so your widget doesn't break silently: ```javascript -window.TagoIO.onError(function(error) { - console.error('Widget error:', error); - // Show something to the user so they know what happened +window.TagoIO.onError(function (error) { + console.error("Widget error:", error); + // Show something to the user so they know what happened }); ``` @@ -207,15 +208,15 @@ window.TagoIO.onError(function(error) { This package includes 7 HTML examples in the [`examples/`](./examples/) folder: -| Example | What it shows | -|---------|---------------| -| `basic-widget.html` | Minimal setup and widget lifecycle | -| `read-data.html` | Displaying real-time device data | +| Example | What it shows | +| -------------------- | ----------------------------------------- | +| `basic-widget.html` | Minimal setup and widget lifecycle | +| `read-data.html` | Displaying real-time device data | | `read-resource.html` | Accessing user info and blueprint devices | -| `read-entity.html` | Complex structured data handling | -| `send-data.html` | Sending data back to devices | -| `time-interval.html` | Time-based data analysis | -| `custom-units.html` | Unit conversions and formatting | +| `read-entity.html` | Complex structured data handling | +| `send-data.html` | Sending data back to devices | +| `time-interval.html` | Time-based data analysis | +| `custom-units.html` | Unit conversions and formatting | ## External Project Examples diff --git a/packages/js/dist/custom-widget.js b/packages/js/dist/custom-widget.js index d28d837..14fb231 100644 --- a/packages/js/dist/custom-widget.js +++ b/packages/js/dist/custom-widget.js @@ -60,7 +60,7 @@ const data = event.data; if (!data) return; if (data.status !== void 0 && data.key) { - if (data.status === true) { + if (data.status) { this.pool.resolve(data.key, data); } else { this.pool.reject(data.key, data); @@ -100,6 +100,9 @@ get pendingCount() { return this.pool.size; } + get isDestroyed() { + return this.destroyed; + } }; function replaceStrategy(_existing, incoming) { return incoming; @@ -150,14 +153,14 @@ } function mergeRecords(existing, incoming) { if (incoming.length === 0) return existing; - const existingById = /* @__PURE__ */ new Map(); - for (const record of existing) { - existingById.set(record.id, record); + const incomingById = /* @__PURE__ */ new Map(); + for (const record of incoming) { + incomingById.set(record.id, record); } let changed = false; const result = []; for (const existingRecord of existing) { - const incomingMatch = incoming.find((r) => r.id === existingRecord.id); + const incomingMatch = incomingById.get(existingRecord.id); if (incomingMatch) { if (recordsEqual(existingRecord, incomingMatch)) { result.push(existingRecord); @@ -165,16 +168,18 @@ result.push(incomingMatch); changed = true; } + incomingById.delete(existingRecord.id); } else { - result.push(existingRecord); - } - } - for (const incomingRecord of incoming) { - if (!existingById.has(incomingRecord.id)) { - result.push(incomingRecord); changed = true; } } + for (const [, record] of incomingById) { + result.push(record); + changed = true; + } + if (changed) { + result.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime()); + } return changed ? result : existing; } function realtimeBlockKey(block) { @@ -237,9 +242,8 @@ this.strategy = options.realtimeStrategy ?? "merge"; this.maxRecords = options.realtimeMaxRecords ?? 1e3; this.readyOptions = options.readyOptions ?? {}; - this.bridge = new MessageBridge({ - allowedOrigins: options.allowedOrigins - }); + this.bridgeOptions = { allowedOrigins: options.allowedOrigins }; + this.bridge = new MessageBridge(this.bridgeOptions); if (typeof window !== "undefined") { this.unsubBridge = this.bridge.onMessage(this.handleInbound); } @@ -277,6 +281,12 @@ initialize() { if (this.initialized) return; this.initialized = true; + if (this.bridge.isDestroyed) { + this.bridge = new MessageBridge(this.bridgeOptions); + if (typeof window !== "undefined") { + this.unsubBridge = this.bridge.onMessage(this.handleInbound); + } + } this.bridge.send({ loaded: true, ...this.readyOptions }); } destroy() { @@ -307,6 +317,9 @@ closeModal() { this.bridge.send({ method: "close-modal" }); } + runAnalysis(scope) { + this.bridge.send({ method: "run-analysis", scope }); + } clearErrors() { this.updateState({ errors: [] }); } @@ -426,11 +439,27 @@ }; var deleteData = (variables, callback) => { const vars = Array.isArray(variables) ? variables : [variables]; - return wrapMutation(store.deleteData.bind(store), vars, callback); + const promise = store.deleteData(vars); + if (callback) { + promise.then( + (data) => callback(data), + (error) => callback(null, error) + ); + return void 0; + } + return promise; }; var editResourceData = (variables, callback) => { const vars = Array.isArray(variables) ? variables : [variables]; - return wrapMutation(store.editResourceData.bind(store), vars, callback); + const promise = store.editResourceData(vars); + if (callback) { + promise.then( + (data) => callback(data), + (error) => callback(null, error) + ); + return void 0; + } + return promise; }; var openLink = (url) => { store.openLink(url); @@ -438,6 +467,9 @@ var closeModal = () => { store.closeModal(); }; + var runAnalysis = (scope) => { + store.runAnalysis(scope); + }; window.TagoIO.ready = onReady; window.TagoIO.onStart = onStart; window.TagoIO.onRealtime = onRealtime; @@ -450,4 +482,5 @@ window.TagoIO.editResourceData = editResourceData; window.TagoIO.openLink = openLink; window.TagoIO.closeModal = closeModal; + window.TagoIO.runAnalysis = runAnalysis; })(); diff --git a/packages/js/dist/custom-widget.min.js b/packages/js/dist/custom-widget.min.js index cf502e3..f57448f 100644 --- a/packages/js/dist/custom-widget.min.js +++ b/packages/js/dist/custom-widget.min.js @@ -1 +1 @@ -"use strict";(()=>{function k(){return typeof crypto<"u"&&typeof crypto.randomUUID=="function"?crypto.randomUUID():Math.random().toString(36).slice(2)+Date.now().toString(36)}var R=class{constructor(){this.pending=new Map}add(e,t){this.pending.set(e,t)}resolve(e,t){let a=this.pending.get(e);return a?(this.pending.delete(e),a.resolve(t),!0):!1}reject(e,t){let a=this.pending.get(e);return a?(this.pending.delete(e),a.reject(t),!0):!1}rejectAll(e){for(let[,t]of this.pending)t.reject(e);this.pending.clear()}get size(){return this.pending.size}has(e){return this.pending.has(e)}},S=class{constructor(e={}){this.handlers=new Set,this.pool=new R,this.destroyed=!1,this.allowedOrigins=e.allowedOrigins?new Set(e.allowedOrigins):null,this.boundReceive=this.handleMessage.bind(this),typeof window<"u"&&window.addEventListener("message",this.boundReceive,!1)}handleMessage(e){if(this.destroyed||this.allowedOrigins&&!this.allowedOrigins.has(e.origin))return;let t=e.data;if(t){t.status!==void 0&&t.key&&(t.status===!0?this.pool.resolve(t.key,t):this.pool.reject(t.key,t));for(let a of this.handlers)a(t)}}send(e){this.destroyed||typeof window>"u"||window.parent.postMessage(e,"*")}sendWithResponse(e){let t=k(),a={...e,key:t};return new Promise((n,r)=>{this.pool.add(t,{resolve:n,reject:r}),this.send(a)})}onMessage(e){return this.handlers.add(e),()=>{this.handlers.delete(e)}}destroy(){this.destroyed=!0,typeof window<"u"&&window.removeEventListener("message",this.boundReceive,!1),this.pool.rejectAll(new Error("MessageBridge destroyed")),this.handlers.clear()}get pendingCount(){return this.pool.size}};function I(e,t){return t}function O(e,t,a){let n=[...e,...t];return n.length<=a?n:n.slice(n.length-a)}function C(e,t){return e.id===t.id&&e.value===t.value&&e.time===t.time&&e.variable===t.variable}function E(e,t){if(e.length===0)return t;if(t.length===0)return e;let a=new Map;for(let s of e){let l=D(s);a.set(l,s)}let n=!1,r=[],o=new Set;for(let s of t){let l=D(s);o.add(l);let c=a.get(l);if(!c){r.push(s),n=!0;continue}let m=B(c.result??[],s.result??[]);m!==c.result?(r.push({...s,result:m}),n=!0):r.push(c)}for(let[s,l]of a)o.has(s)||r.push(l);return n||r.length!==e.length?r:e}function B(e,t){if(t.length===0)return e;let a=new Map;for(let o of e)a.set(o.id,o);let n=!1,r=[];for(let o of e){let s=t.find(l=>l.id===o.id);s?C(o,s)?r.push(o):(r.push(s),n=!0):r.push(o)}for(let o of t)a.has(o.id)||(r.push(o),n=!0);return n?r:e}function D(e){let t=e.data?.variable?.join(",")??"",a=e.data?.origin??"";return`${t}|${a}`}var b={widget:null,realtimeData:[],userInformation:null,blueprintDevices:null,errors:[],isReady:!1,realtimeEventCount:0,lastRealtimeAt:null},A={...b},y=class{constructor(e={}){this.state={...b},this.listeners=new Set,this.initialized=!1,this.unsubBridge=null,this.handleInbound=t=>{if(t.userInformation&&this.updateState({userInformation:t.userInformation}),t.blueprintDevices&&this.updateState({blueprintDevices:t.blueprintDevices}),t.widget&&this.updateState({widget:t.widget,isReady:!0}),t.realtime&&this.updateRealtime(t.realtime),t.status===!1){let a=t;this.updateState({errors:[...this.state.errors,a]})}},this.subscribe=t=>(this.listeners.add(t),()=>{this.listeners.delete(t)}),this.getSnapshot=()=>this.state,this.getServerSnapshot=()=>A,this.strategy=e.realtimeStrategy??"merge",this.maxRecords=e.realtimeMaxRecords??1e3,this.readyOptions=e.readyOptions??{},this.bridge=new S({allowedOrigins:e.allowedOrigins}),typeof window<"u"&&(this.unsubBridge=this.bridge.onMessage(this.handleInbound))}updateRealtime(e){let t;switch(this.strategy){case"replace":t=I(this.state.realtimeData,e);break;case"append":t=O(this.state.realtimeData,e,this.maxRecords);break;default:t=E(this.state.realtimeData,e);break}this.state={...this.state,realtimeData:t,realtimeEventCount:this.state.realtimeEventCount+1,lastRealtimeAt:Date.now()},this.emit()}updateState(e){this.state={...this.state,...e},this.emit()}emit(){for(let e of this.listeners)e()}initialize(){this.initialized||(this.initialized=!0,this.bridge.send({loaded:!0,...this.readyOptions}))}destroy(){this.unsubBridge?.(),this.bridge.destroy(),this.listeners.clear(),this.initialized=!1}sendData(e){let t=Array.isArray(e)?e:[e];return this.bridge.sendWithResponse({variables:t})}editData(e){let t=Array.isArray(e)?e:[e];return this.bridge.sendWithResponse({variables:t,method:"edit"})}deleteData(e){let t=Array.isArray(e)?e:[e];return this.bridge.sendWithResponse({variables:t,method:"delete"})}editResourceData(e){let t=Array.isArray(e)?e:[e];return this.bridge.sendWithResponse({variables:t,method:"edit-resource"})}openLink(e){this.bridge.send({method:"open-link",url:e})}closeModal(){this.bridge.send({method:"close-modal"})}clearErrors(){this.updateState({errors:[]})}clearRealtimeData(){this.updateState({realtimeData:[],realtimeEventCount:0,lastRealtimeAt:null})}getBridge(){return this.bridge}};function w(e,t){if(!e||!t)return[];let a=[];for(let n of e)for(let r of t)n.variable===r.variable&&a.push({device:r.origin.id,origin:r.origin.id,...r.origin.bucket&&{bucket:r.origin.bucket},...n});return a}var i=new y;window.TagoIO={};window.TagoIO.autoFill=!0;var f=null,h=null,g=null,T=null,p=null,d=i.getSnapshot();i.subscribe(()=>{let e=i.getSnapshot();if(e.userInformation&&e.userInformation!==d.userInformation&&T&&T(e.userInformation),e.blueprintDevices&&e.blueprintDevices!==d.blueprintDevices&&p&&p(e.blueprintDevices),e.widget&&e.widget!==d.widget&&h&&h(e.widget),e.realtimeData!==d.realtimeData&&e.realtimeData.length>0&&f&&f(e.realtimeData),e.errors.length>d.errors.length&&g){let t=e.errors[e.errors.length-1];g(t)}d=e});var M=e=>{i.getBridge().send({loaded:!0,...e})},W=e=>{h=e},U=e=>{f=e},j=e=>{g=e},P=e=>{T=e},F=e=>{p=e};function L(){return i.getSnapshot().widget?.display?.variables??[]}function v(e){let t=Array.isArray(e)?e:[e];if(window.TagoIO.autoFill)return console.info("AutoFill is enabled, the bucket and origin id will be automatically generated based on the variables of the widget, this option can be disabled by setting window.TagoIO.autoFill = false."),w(t,L());for(let a of t)(!a.bucket||!a.origin)&&console.error("AutoFill is disabled, the data must contain a bucket and origin key!");return t}function u(e,t,a){let n=e.call(i,t);if(a){n.then(r=>a(r),r=>a(null,r));return}return n}var N=(e,t)=>{let a=v(e);return u(i.sendData.bind(i),a,t)},z=(e,t)=>{let a=v(e);return u(i.editData.bind(i),a,t)},V=(e,t)=>{let a=Array.isArray(e)?e:[e];return u(i.deleteData.bind(i),a,t)},q=(e,t)=>{let a=Array.isArray(e)?e:[e];return u(i.editResourceData.bind(i),a,t)},x=e=>{i.openLink(e)},K=()=>{i.closeModal()};window.TagoIO.ready=M;window.TagoIO.onStart=W;window.TagoIO.onRealtime=U;window.TagoIO.onError=j;window.TagoIO.onSyncUserInformation=P;window.TagoIO.onSyncBlueprintDevices=F;window.TagoIO.sendData=N;window.TagoIO.editData=z;window.TagoIO.deleteData=V;window.TagoIO.editResourceData=q;window.TagoIO.openLink=x;window.TagoIO.closeModal=K;})(); +"use strict";(()=>{function k(){return typeof crypto<"u"&&typeof crypto.randomUUID=="function"?crypto.randomUUID():Math.random().toString(36).slice(2)+Date.now().toString(36)}var R=class{constructor(){this.pending=new Map}add(e,t){this.pending.set(e,t)}resolve(e,t){let n=this.pending.get(e);return n?(this.pending.delete(e),n.resolve(t),!0):!1}reject(e,t){let n=this.pending.get(e);return n?(this.pending.delete(e),n.reject(t),!0):!1}rejectAll(e){for(let[,t]of this.pending)t.reject(e);this.pending.clear()}get size(){return this.pending.size}has(e){return this.pending.has(e)}},m=class{constructor(e={}){this.handlers=new Set,this.pool=new R,this.destroyed=!1,this.allowedOrigins=e.allowedOrigins?new Set(e.allowedOrigins):null,this.boundReceive=this.handleMessage.bind(this),typeof window<"u"&&window.addEventListener("message",this.boundReceive,!1)}handleMessage(e){if(this.destroyed||this.allowedOrigins&&!this.allowedOrigins.has(e.origin))return;let t=e.data;if(t){t.status!==void 0&&t.key&&(t.status?this.pool.resolve(t.key,t):this.pool.reject(t.key,t));for(let n of this.handlers)n(t)}}send(e){this.destroyed||typeof window>"u"||window.parent.postMessage(e,"*")}sendWithResponse(e){let t=k(),n={...e,key:t};return new Promise((a,r)=>{this.pool.add(t,{resolve:a,reject:r}),this.send(n)})}onMessage(e){return this.handlers.add(e),()=>{this.handlers.delete(e)}}destroy(){this.destroyed=!0,typeof window<"u"&&window.removeEventListener("message",this.boundReceive,!1),this.pool.rejectAll(new Error("MessageBridge destroyed")),this.handlers.clear()}get pendingCount(){return this.pool.size}get isDestroyed(){return this.destroyed}};function I(e,t){return t}function O(e,t,n){let a=[...e,...t];return a.length<=n?a:a.slice(a.length-n)}function C(e,t){return e.id===t.id&&e.value===t.value&&e.time===t.time&&e.variable===t.variable}function E(e,t){if(e.length===0)return t;if(t.length===0)return e;let n=new Map;for(let i of e){let l=D(i);n.set(l,i)}let a=!1,r=[],s=new Set;for(let i of t){let l=D(i);s.add(l);let c=n.get(l);if(!c){r.push(i),a=!0;continue}let p=A(c.result??[],i.result??[]);p!==c.result?(r.push({...i,result:p}),a=!0):r.push(c)}for(let[i,l]of n)s.has(i)||r.push(l);return a||r.length!==e.length?r:e}function A(e,t){if(t.length===0)return e;let n=new Map;for(let s of t)n.set(s.id,s);let a=!1,r=[];for(let s of e){let i=n.get(s.id);i?(C(s,i)?r.push(s):(r.push(i),a=!0),n.delete(s.id)):a=!0}for(let[,s]of n)r.push(s),a=!0;return a&&r.sort((s,i)=>new Date(i.time).getTime()-new Date(s.time).getTime()),a?r:e}function D(e){let t=e.data?.variable?.join(",")??"",n=e.data?.origin??"";return`${t}|${n}`}var b={widget:null,realtimeData:[],userInformation:null,blueprintDevices:null,errors:[],isReady:!1,realtimeEventCount:0,lastRealtimeAt:null},B={...b},y=class{constructor(e={}){this.state={...b},this.listeners=new Set,this.initialized=!1,this.unsubBridge=null,this.handleInbound=t=>{if(t.userInformation&&this.updateState({userInformation:t.userInformation}),t.blueprintDevices&&this.updateState({blueprintDevices:t.blueprintDevices}),t.widget&&this.updateState({widget:t.widget,isReady:!0}),t.realtime&&this.updateRealtime(t.realtime),t.status===!1){let n=t;this.updateState({errors:[...this.state.errors,n]})}},this.subscribe=t=>(this.listeners.add(t),()=>{this.listeners.delete(t)}),this.getSnapshot=()=>this.state,this.getServerSnapshot=()=>B,this.strategy=e.realtimeStrategy??"merge",this.maxRecords=e.realtimeMaxRecords??1e3,this.readyOptions=e.readyOptions??{},this.bridgeOptions={allowedOrigins:e.allowedOrigins},this.bridge=new m(this.bridgeOptions),typeof window<"u"&&(this.unsubBridge=this.bridge.onMessage(this.handleInbound))}updateRealtime(e){let t;switch(this.strategy){case"replace":t=I(this.state.realtimeData,e);break;case"append":t=O(this.state.realtimeData,e,this.maxRecords);break;default:t=E(this.state.realtimeData,e);break}this.state={...this.state,realtimeData:t,realtimeEventCount:this.state.realtimeEventCount+1,lastRealtimeAt:Date.now()},this.emit()}updateState(e){this.state={...this.state,...e},this.emit()}emit(){for(let e of this.listeners)e()}initialize(){this.initialized||(this.initialized=!0,this.bridge.isDestroyed&&(this.bridge=new m(this.bridgeOptions),typeof window<"u"&&(this.unsubBridge=this.bridge.onMessage(this.handleInbound))),this.bridge.send({loaded:!0,...this.readyOptions}))}destroy(){this.unsubBridge?.(),this.bridge.destroy(),this.listeners.clear(),this.initialized=!1}sendData(e){let t=Array.isArray(e)?e:[e];return this.bridge.sendWithResponse({variables:t})}editData(e){let t=Array.isArray(e)?e:[e];return this.bridge.sendWithResponse({variables:t,method:"edit"})}deleteData(e){let t=Array.isArray(e)?e:[e];return this.bridge.sendWithResponse({variables:t,method:"delete"})}editResourceData(e){let t=Array.isArray(e)?e:[e];return this.bridge.sendWithResponse({variables:t,method:"edit-resource"})}openLink(e){this.bridge.send({method:"open-link",url:e})}closeModal(){this.bridge.send({method:"close-modal"})}runAnalysis(e){this.bridge.send({method:"run-analysis",scope:e})}clearErrors(){this.updateState({errors:[]})}clearRealtimeData(){this.updateState({realtimeData:[],realtimeEventCount:0,lastRealtimeAt:null})}getBridge(){return this.bridge}};function w(e,t){if(!e||!t)return[];let n=[];for(let a of e)for(let r of t)a.variable===r.variable&&n.push({device:r.origin.id,origin:r.origin.id,...r.origin.bucket&&{bucket:r.origin.bucket},...a});return n}var o=new y;window.TagoIO={};window.TagoIO.autoFill=!0;var u=null,f=null,g=null,h=null,T=null,d=o.getSnapshot();o.subscribe(()=>{let e=o.getSnapshot();if(e.userInformation&&e.userInformation!==d.userInformation&&h&&h(e.userInformation),e.blueprintDevices&&e.blueprintDevices!==d.blueprintDevices&&T&&T(e.blueprintDevices),e.widget&&e.widget!==d.widget&&f&&f(e.widget),e.realtimeData!==d.realtimeData&&e.realtimeData.length>0&&u&&u(e.realtimeData),e.errors.length>d.errors.length&&g){let t=e.errors[e.errors.length-1];g(t)}d=e});var M=e=>{o.getBridge().send({loaded:!0,...e})},W=e=>{f=e},U=e=>{u=e},j=e=>{g=e},P=e=>{h=e},F=e=>{T=e};function L(){return o.getSnapshot().widget?.display?.variables??[]}function v(e){let t=Array.isArray(e)?e:[e];if(window.TagoIO.autoFill)return console.info("AutoFill is enabled, the bucket and origin id will be automatically generated based on the variables of the widget, this option can be disabled by setting window.TagoIO.autoFill = false."),w(t,L());for(let n of t)(!n.bucket||!n.origin)&&console.error("AutoFill is disabled, the data must contain a bucket and origin key!");return t}function S(e,t,n){let a=e.call(o,t);if(n){a.then(r=>n(r),r=>n(null,r));return}return a}var N=(e,t)=>{let n=v(e);return S(o.sendData.bind(o),n,t)},z=(e,t)=>{let n=v(e);return S(o.editData.bind(o),n,t)},V=(e,t)=>{let n=Array.isArray(e)?e:[e],a=o.deleteData(n);if(t){a.then(r=>t(r),r=>t(null,r));return}return a},q=(e,t)=>{let n=Array.isArray(e)?e:[e],a=o.editResourceData(n);if(t){a.then(r=>t(r),r=>t(null,r));return}return a},x=e=>{o.openLink(e)},K=()=>{o.closeModal()},_=e=>{o.runAnalysis(e)};window.TagoIO.ready=M;window.TagoIO.onStart=W;window.TagoIO.onRealtime=U;window.TagoIO.onError=j;window.TagoIO.onSyncUserInformation=P;window.TagoIO.onSyncBlueprintDevices=F;window.TagoIO.sendData=N;window.TagoIO.editData=z;window.TagoIO.deleteData=V;window.TagoIO.editResourceData=q;window.TagoIO.openLink=x;window.TagoIO.closeModal=K;window.TagoIO.runAnalysis=_;})(); diff --git a/packages/js/examples/basic-widget.html b/packages/js/examples/basic-widget.html index 9c5465c..bbaab10 100644 --- a/packages/js/examples/basic-widget.html +++ b/packages/js/examples/basic-widget.html @@ -1,4 +1,4 @@ - + diff --git a/packages/js/examples/custom-units.html b/packages/js/examples/custom-units.html index e5f82f5..332f3fc 100644 --- a/packages/js/examples/custom-units.html +++ b/packages/js/examples/custom-units.html @@ -1,4 +1,4 @@ - + diff --git a/packages/js/examples/read-data.html b/packages/js/examples/read-data.html index dd18890..20a7ed2 100644 --- a/packages/js/examples/read-data.html +++ b/packages/js/examples/read-data.html @@ -1,4 +1,4 @@ - + diff --git a/packages/js/examples/read-entity.html b/packages/js/examples/read-entity.html index 87fba11..52dd291 100644 --- a/packages/js/examples/read-entity.html +++ b/packages/js/examples/read-entity.html @@ -1,4 +1,4 @@ - + diff --git a/packages/js/examples/read-resource.html b/packages/js/examples/read-resource.html index 956817b..8792af7 100644 --- a/packages/js/examples/read-resource.html +++ b/packages/js/examples/read-resource.html @@ -1,4 +1,4 @@ - + diff --git a/packages/js/examples/send-data.html b/packages/js/examples/send-data.html index 9eeae8c..39eeae1 100644 --- a/packages/js/examples/send-data.html +++ b/packages/js/examples/send-data.html @@ -1,4 +1,4 @@ - + diff --git a/packages/js/examples/time-interval.html b/packages/js/examples/time-interval.html index 7ff58b2..0a44705 100644 --- a/packages/js/examples/time-interval.html +++ b/packages/js/examples/time-interval.html @@ -1,4 +1,4 @@ - + diff --git a/packages/js/package.json b/packages/js/package.json index 0dde69c..64daddd 100644 --- a/packages/js/package.json +++ b/packages/js/package.json @@ -1,23 +1,23 @@ { "name": "@tago-io/custom-widget", - "description": "TagoIO Toolkit to build your own widgets", "version": "2.0.0", - "type": "module", - "main": "./dist/custom-widget.js", - "author": "Tago LLC", + "description": "TagoIO Toolkit to build your own widgets", "license": "Apache-2.0", + "author": "Tago LLC", "repository": "tago-io/custom-widget", + "type": "module", + "main": "./dist/custom-widget.js", "scripts": { "build:scripts": "tsup", "build:css": "lessc ./src/css/custom-widget.less ./dist/custom-widget.css", "build:cssmin": "cleancss -o ./dist/custom-widget.min.css ./dist/custom-widget.css", "build": "pnpm run build:scripts && pnpm run build:css && pnpm run build:cssmin", - "test": "vitest run", - "coverage": "vitest run --coverage", + "test": "vp test run", + "coverage": "vp test run --coverage", "check:types": "tsc --noEmit", - "lint": "biome check src/", - "lint:fix": "biome check --write src/", - "format": "biome format --write src/" + "lint": "vp lint src/", + "lint:fix": "vp lint --fix src/", + "format": "vp fmt src/" }, "dependencies": { "@tago-io/custom-widget-core": "workspace:*" diff --git a/packages/js/src/css/custom-widget.less b/packages/js/src/css/custom-widget.less index eed8475..0c86fbc 100644 --- a/packages/js/src/css/custom-widget.less +++ b/packages/js/src/css/custom-widget.less @@ -3,7 +3,8 @@ html { -webkit-overflow-scrolling: touch; } -html, body { +html, +body { height: 100%; width: 100%; min-width: 100%; @@ -16,11 +17,13 @@ body { background-color: #fff; } -body, * { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, - Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; +body, +* { + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", + "Segoe UI Emoji", "Segoe UI Symbol"; margin: 0; - font-size: .88rem; + font-size: 0.88rem; box-sizing: border-box; } @@ -39,7 +42,9 @@ a { height: 8px; } -input, select, textarea { +input, +select, +textarea { outline-style: none; box-shadow: none; width: 100%; @@ -50,7 +55,9 @@ input, select, textarea { border-width: 1px; border-style: solid; border-color: hsla(0, 0%, 0%, 0.07); - transition: border-color 0.15s ease-in-out 0s, box-shadow 0.15s ease-in-out 0s; + transition: + border-color 0.15s ease-in-out 0s, + box-shadow 0.15s ease-in-out 0s; border-radius: 3px; color: hsl(225, 6%, 13%); background-color: hsl(0, 0%, 99%); @@ -80,27 +87,27 @@ button { padding: 8px 20px 8px 20px; border-radius: 3px; line-height: 1.1rem; - -webkit-transition: all .05s; - transition: all .05s; + -webkit-transition: all 0.05s; + transition: all 0.05s; &:hover { background-color: #2f6fa7; - border-color: hsl(208,56%,42%); - color: hsl(0,0%,100%); + border-color: hsl(208, 56%, 42%); + color: hsl(0, 0%, 100%); } &:active { - background-color: hsl(208,56%,38%); - border-color: hsl(208,56%,38%); - color: hsl(0,0%,100%); + background-color: hsl(208, 56%, 38%); + border-color: hsl(208, 56%, 38%); + color: hsl(0, 0%, 100%); } &:disabled { pointer-events: none; - opacity: .7; - color: hsl(0,0%,0%); + opacity: 0.7; + color: hsl(0, 0%, 0%); border-color: transparent; - background-color: hsl(0,0%,90%); + background-color: hsl(0, 0%, 90%); } } @@ -125,7 +132,7 @@ h5 { } h6 { - font-size: .88rem; + font-size: 0.88rem; } h1, @@ -144,7 +151,8 @@ h6 { padding-right: 3px; } - strong, a { + strong, + a { font-size: inherit; } diff --git a/packages/js/src/custom-widget.test.ts b/packages/js/src/custom-widget.test.ts index af21e1a..e6096e5 100644 --- a/packages/js/src/custom-widget.test.ts +++ b/packages/js/src/custom-widget.test.ts @@ -1,5 +1,7 @@ import type { TRealtimeData } from "@tago-io/custom-widget-core"; -import { closeModal, onError, onRealtime, onStart, sendData } from "./custom-widget"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { closeModal, onError, onRealtime, onStart, runAnalysis, sendData } from "./custom-widget"; const mockRandomUUID = vi.fn(() => "staticKey"); @@ -93,9 +95,23 @@ describe("sendData", () => { mockPostMessage.mockClear(); }); + it("throws when autoFill is disabled and records lack origin", () => { + window.TagoIO.autoFill = false; + + expect(() => { + void sendData({ id: "r1", variable: "temp", value: 42, time: "t1" } as never); + }).toThrow("origin"); + }); + it("sends data with auto-fill disabled and resolves the promise on response", async () => { window.TagoIO.autoFill = false; - const mockDataToSend = { id: "asd", variable: "some_variable", value: "new value", time: "timestamp" }; + const mockDataToSend = { + id: "asd", + variable: "some_variable", + value: "new value", + time: "timestamp", + origin: "o1", + }; const result = sendData(mockDataToSend); @@ -111,7 +127,13 @@ describe("sendData", () => { it("sends data with auto-fill disabled and invokes callback on response", () => { window.TagoIO.autoFill = false; const mockSendDataCallback = vi.fn(); - const mockDataToSend = { id: "asd", variable: "some_variable", value: "new value", time: "timestamp" }; + const mockDataToSend = { + id: "asd", + variable: "some_variable", + value: "new value", + time: "timestamp", + origin: "o1", + }; const result = sendData(mockDataToSend, mockSendDataCallback); @@ -166,3 +188,36 @@ describe("closeModal", () => { expect(mockPostMessage).toHaveBeenCalledWith({ method: "close-modal" }, "*"); }); }); + +describe("runAnalysis", () => { + const mockPostMessage = vi.fn(); + + beforeAll(() => { + window.parent.postMessage = mockPostMessage; + }); + + beforeEach(() => { + mockPostMessage.mockClear(); + }); + + it("sends run-analysis message without scope", () => { + runAnalysis(); + expect(mockPostMessage).toHaveBeenCalledWith({ method: "run-analysis", scope: undefined }, "*"); + }); + + it("sends run-analysis message with scope", () => { + const scope = [ + { + variable: "command", + value: "restart", + metadata: { + ports: [1, 2, 3], + devices: ["device-abc", "device-def"], + }, + }, + ]; + + runAnalysis(scope); + expect(mockPostMessage).toHaveBeenCalledWith({ method: "run-analysis", scope }, "*"); + }); +}); diff --git a/packages/js/src/custom-widget.ts b/packages/js/src/custom-widget.ts index 47f3ee7..0cb0207 100644 --- a/packages/js/src/custom-widget.ts +++ b/packages/js/src/custom-widget.ts @@ -35,13 +35,17 @@ type TTagoIO = { onSyncUserInformation: (callback: TUserInformationCallback) => void; onSyncBlueprintDevices: (callback: TSyncBlueprintDevicesCallback) => void; sendData: (dataToSend: TDataRecord | TDataRecord[], callback?: TSendDataCallback) => Promise | undefined; - deleteData: (dataToDelete: TDataRecord | TDataRecord[], callback?: TSendDataCallback) => Promise | undefined; + deleteData: (dataToDelete: string | string[], callback?: TSendDataCallback) => Promise | undefined; editData: (dataToEdit: TDataRecord | TDataRecord[], callback?: TSendDataCallback) => Promise | undefined; - editResourceData: (dataToEdit: TDataRecord | TDataRecord[], callback?: TSendDataCallback) => Promise | undefined; + editResourceData: ( + dataToEdit: TDataRecord | TDataRecord[], + callback?: TSendDataCallback + ) => Promise | undefined; autoFill: boolean; ready: (options: TReadyOptions) => void; openLink: (url: string) => void; closeModal: () => void; + runAnalysis: (scope?: unknown) => void; }; declare global { @@ -123,15 +127,17 @@ function prepareRecords(variables: TDataRecord | TDataRecord[]): TDataRecordInpu if (window.TagoIO.autoFill) { console.info( - "AutoFill is enabled, the bucket and origin id will be automatically generated based on the variables of the widget, this option can be disabled by setting window.TagoIO.autoFill = false." + "AutoFill is enabled, the origin id will be automatically generated based on the variables of the widget, this option can be disabled by setting window.TagoIO.autoFill = false." ); return autoFillRecords(vars, getWidgetVariables()); } - for (const v of vars) { - if (!v.bucket || !v.origin) { - console.error("AutoFill is disabled, the data must contain a bucket and origin key!"); - } + const invalid = vars.filter((v) => !v.origin); + if (invalid.length > 0) { + throw new Error( + `AutoFill is disabled. ${invalid.length} record(s) missing required "origin" field. ` + + "Either enable autoFill or provide this field." + ); } return vars; } @@ -164,14 +170,37 @@ const editData = (variables: TDataRecord | TDataRecord[], callback?: TSendDataCa return wrapMutation(store.editData.bind(store), records, callback); }; -const deleteData = (variables: TDataRecord | TDataRecord[], callback?: TSendDataCallback): Promise | undefined => { +const deleteData = (variables: string | string[], callback?: TSendDataCallback): Promise | undefined => { const vars = Array.isArray(variables) ? variables : [variables]; - return wrapMutation(store.deleteData.bind(store), vars, callback); + const promise = store.deleteData(vars); + + if (callback) { + promise.then( + (data) => callback(data), + (error) => callback(null, error as TError) + ); + return undefined; + } + + return promise; }; -const editResourceData = (variables: TDataRecord | TDataRecord[], callback?: TSendDataCallback): Promise | undefined => { +const editResourceData = ( + variables: TDataRecord | TDataRecord[], + callback?: TSendDataCallback +): Promise | undefined => { const vars = Array.isArray(variables) ? variables : [variables]; - return wrapMutation(store.editResourceData.bind(store), vars, callback); + const promise = store.editResourceData(vars); + + if (callback) { + promise.then( + (data) => callback(data), + (error) => callback(null, error as TError) + ); + return undefined; + } + + return promise; }; const openLink: TTagoIO["openLink"] = (url) => { @@ -182,6 +211,10 @@ const closeModal: TTagoIO["closeModal"] = () => { store.closeModal(); }; +const runAnalysis: TTagoIO["runAnalysis"] = (scope) => { + store.runAnalysis(scope); +}; + window.TagoIO.ready = onReady; window.TagoIO.onStart = onStart; window.TagoIO.onRealtime = onRealtime; @@ -194,6 +227,7 @@ window.TagoIO.deleteData = deleteData; window.TagoIO.editResourceData = editResourceData; window.TagoIO.openLink = openLink; window.TagoIO.closeModal = closeModal; +window.TagoIO.runAnalysis = runAnalysis; export { closeModal, @@ -206,6 +240,7 @@ export { onSyncBlueprintDevices, onSyncUserInformation, openLink, + runAnalysis, sendData, }; diff --git a/packages/js/vite.config.ts b/packages/js/vite.config.ts deleted file mode 100644 index 5258214..0000000 --- a/packages/js/vite.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - globals: true, - environment: "jsdom", - coverage: { - provider: "v8", - include: ["src/**/*.ts"], - exclude: ["src/**/*.test.ts", "src/**/*.d.ts"], - }, - }, -}); diff --git a/packages/react/README.md b/packages/react/README.md index 9302ceb..72668b3 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -36,7 +36,9 @@ function Dashboard() {

{widget?.label}

    {records.map((r) => ( -
  • {r.variable}: {r.value}
  • +
  • + {r.variable}: {r.value} +
  • ))}
@@ -48,11 +50,11 @@ function Dashboard() { ```tsx @@ -62,14 +64,14 @@ function Dashboard() { ### Receiving Data -| Hook | Description | -|------|-------------| -| `useWidget()` | Widget config, loading state, variables, IDs | +| Hook | Description | +| --------------------------- | -------------------------------------------------- | +| `useWidget()` | Widget config, loading state, variables, IDs | | `useRealtimeData(options?)` | Realtime data with optional selector for filtering | -| `useUserInformation()` | User token, language, runURL | -| `useBlueprintDevices()` | Blueprint device selections and settings | -| `useWidgetErrors()` | Error accumulation with clear | -| `useWidgetData()` | Convenience: combines widget + realtime + errors | +| `useUserInformation()` | User token, language, runURL | +| `useBlueprintDevices()` | Blueprint device selections and settings | +| `useWidgetErrors()` | Error accumulation with clear | +| `useWidgetData()` | Convenience: combines widget + realtime + errors | ### Selective Subscriptions @@ -78,18 +80,18 @@ Components only re-render when their specific data slice changes: ```tsx // Only re-renders when temperature data changes const { records } = useRealtimeData({ - selector: (data) => data.filter(d => d.data?.variable?.includes("temperature")), + selector: (data) => data.filter((d) => d.data?.variable?.includes("temperature")), }); ``` ### Mutations -| Hook | Description | -|------|-------------| -| `useSendData()` | Send data records to devices | -| `useEditData()` | Edit existing data records | -| `useDeleteData()` | Delete data records | -| `useEditResourceData()` | Edit resource-level data | +| Hook | Description | +| ----------------------- | ---------------------------- | +| `useSendData()` | Send data records to devices | +| `useEditData()` | Edit existing data records | +| `useDeleteData()` | Delete data records | +| `useEditResourceData()` | Edit resource-level data | Each returns `{ mutationFn, isMutating, error, reset }`. diff --git a/packages/react/examples/README.md b/packages/react/examples/README.md index 3663832..3c8593f 100644 --- a/packages/react/examples/README.md +++ b/packages/react/examples/README.md @@ -4,10 +4,10 @@ Simple examples showing how to use `@tago-io/custom-widget-react` in your custom Each file is a self-contained React component you can copy into your project. -| Example | What it shows | -|---------|---------------| -| [read-data.tsx](./read-data.tsx) | Display real-time data from devices | -| [send-data.tsx](./send-data.tsx) | Send data back to devices with a form | +| Example | What it shows | +| ---------------------------------------- | -------------------------------------- | +| [read-data.tsx](./read-data.tsx) | Display real-time data from devices | +| [send-data.tsx](./send-data.tsx) | Send data back to devices with a form | | [read-resource.tsx](./read-resource.tsx) | Access user info and blueprint devices | ## How to use these diff --git a/packages/react/examples/read-data.tsx b/packages/react/examples/read-data.tsx index 53d5552..4c48fbf 100644 --- a/packages/react/examples/read-data.tsx +++ b/packages/react/examples/read-data.tsx @@ -8,8 +8,8 @@ * hooks to access widget configuration and live data. */ -import React from "react"; import { TagoIOProvider, useWidget, useRealtimeData } from "@tago-io/custom-widget-react"; +import React from "react"; function App() { return ( @@ -30,9 +30,7 @@ function Dashboard() { return (

{widget?.label || "My Widget"}

-

- Data updates received: {eventCount} -

+

Data updates received: {eventCount}

{records.length === 0 ? (

No data received yet.

@@ -47,12 +45,9 @@ function Dashboard() { marginBottom: 8, }} > - {record.variable}: {record.value}{" "} - {record.unit || ""} + {record.variable}: {record.value} {record.unit || ""}
- - Time: {new Date(record.time).toLocaleString()} - + Time: {new Date(record.time).toLocaleString()} ))} diff --git a/packages/react/examples/read-resource.tsx b/packages/react/examples/read-resource.tsx index 0579321..508b105 100644 --- a/packages/react/examples/read-resource.tsx +++ b/packages/react/examples/read-resource.tsx @@ -8,13 +8,8 @@ * useBlueprintDevices gives you the blueprint device configurations and selections. */ +import { TagoIOProvider, useWidget, useUserInformation, useBlueprintDevices } from "@tago-io/custom-widget-react"; import React from "react"; -import { - TagoIOProvider, - useWidget, - useUserInformation, - useBlueprintDevices, -} from "@tago-io/custom-widget-react"; function App() { return ( @@ -47,10 +42,18 @@ function ResourceViewer() { {/* Widget Configuration */}

Widget Configuration

-

Widget ID: {widget?.id}

-

Dashboard ID: {widget?.dashboard}

-

Label: {widget?.label || "No label"}

-

Variables:

+

+ Widget ID: {widget?.id} +

+

+ Dashboard ID: {widget?.dashboard} +

+

+ Label: {widget?.label || "No label"} +

+

+ Variables: +

{variables.length > 0 ? (
    {variables.map((v) => ( @@ -67,9 +70,15 @@ function ResourceViewer() { {/* User Information */}

    User Information

    -

    Language: {language || "Not available"}

    -

    Has Token: {token ? "Yes" : "No"}

    -

    Run URL: {runURL || "Not available"}

    +

    + Language: {language || "Not available"} +

    +

    + Has Token: {token ? "Yes" : "No"} +

    +

    + Run URL: {runURL || "Not available"} +

    {/* Blueprint Devices */} @@ -89,10 +98,14 @@ function ResourceViewer() { {Object.keys(selected).length > 0 && ( <> -

    Selected devices:

    +

    + Selected devices: +

      {Object.entries(selected).map(([key, entry]) => ( -
    • {key}: {entry?.name ?? "None"}
    • +
    • + {key}: {entry?.name ?? "None"} +
    • ))}
    diff --git a/packages/react/examples/send-data.tsx b/packages/react/examples/send-data.tsx index 225c81a..d20bc28 100644 --- a/packages/react/examples/send-data.tsx +++ b/packages/react/examples/send-data.tsx @@ -8,8 +8,8 @@ * and error states so you can show feedback to the user. */ -import React, { useState } from "react"; import { TagoIOProvider, useSendData, useWidget } from "@tago-io/custom-widget-react"; +import React, { useState } from "react"; function App() { return ( @@ -55,20 +55,12 @@ function SendForm() {
    - - setVariable(e.target.value)} - style={{ padding: 5, width: 200 }} - /> + + setVariable(e.target.value)} style={{ padding: 5, width: 200 }} />
    - +
    - - setUnit(e.target.value)} - style={{ padding: 5, width: 200 }} - /> + + setUnit(e.target.value)} style={{ padding: 5, width: 200 }} />