Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 24 additions & 18 deletions apps/blocks/src/blocks/feature-packs/data/data-console-module.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { DatabaseIcon } from 'lucide-react';
import type { SheetsConfig, SheetsExecuteFn } from '@constructive-io/sheets';

import { DATA_FEATURE_PACK } from '../../../feature-packs';
import { createConsoleIdentityKey } from '../../console-runtime';
import { createConsoleIdentityKey, type IdentityScopedConsoleTransport } from '../../console-runtime';
import { normalizeConsoleKitError } from '../../console-kit/console-kit-runtime';
import type { ConstructiveCapabilityContribution } from '../../console-kit/constructive/constructive-capabilities';
import type {
Expand Down Expand Up @@ -42,33 +42,37 @@ function documentSource(document: unknown): string {
return String(document);
}

function createSheetsExecutor(transport?: IdentityScopedConsoleTransport | null): SheetsExecuteFn | undefined {
if (!transport) return undefined;
return async <T,>(document: unknown, variables?: Record<string, unknown>) => {
const result = await transport.execute<T>({
document: documentSource(document),
variables
});
if (result.ok) return result.data;
const first = result.errors[0];
const error = new Error(first?.message || 'The GraphQL operation failed.') as Error & { code?: string };
const code = first?.extensions?.code;
if (typeof code === 'string') error.code = code;
throw error;
};
}

function DataConsoleFeature({
adapterProps,
config,
runtime,
onError
}: ConsoleKitFeatureComponentProps) {
const dataEndpoint = runtime.endpoints.data;
const storageEndpoint = runtime.endpoints.storage;
const scopedTransport = runtime.transportFor('data');
const execute = React.useMemo<SheetsExecuteFn | undefined>(() => {
if (!scopedTransport) return undefined;
return async <T,>(document: unknown, variables?: Record<string, unknown>) => {
const result = await scopedTransport.execute<T>({
document: documentSource(document),
variables
});
if (result.ok) return result.data;
const first = result.errors[0];
const error = new Error(
first?.message || 'The GraphQL operation failed.'
) as Error & { code?: string };
const code = first?.extensions?.code;
if (typeof code === 'string') error.code = code;
throw error;
};
}, [scopedTransport]);
const storageTransport = runtime.transportFor('storage');
const execute = React.useMemo(() => createSheetsExecutor(scopedTransport), [scopedTransport]);
const executeStorage = React.useMemo(() => createSheetsExecutor(storageTransport), [storageTransport]);
const sheetsConfig = React.useMemo<SheetsConfig>(() => ({
endpoint: dataEndpoint?.url ?? '',
storage: storageEndpoint?.url && executeStorage ? { endpoint: storageEndpoint.url, execute: executeStorage } : undefined,
databaseId: config.databaseId,
auth: {
mode: 'embedded',
Expand Down Expand Up @@ -96,7 +100,9 @@ function DataConsoleFeature({
config.databaseId,
config.queryClient,
dataEndpoint?.url,
storageEndpoint?.url,
execute,
executeStorage,
onError,
runtime.session
]);
Expand Down
1 change: 1 addition & 0 deletions packages/blocks-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
],
"scripts": {
"build": "tsup",
"prepack": "pnpm build",
"dev": "tsup --watch",
"lint:types": "tsc --noEmit",
"test": "vitest run",
Expand Down
1 change: 1 addition & 0 deletions packages/data/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
],
"scripts": {
"build": "tsup",
"prepack": "pnpm build",
"dev": "tsup --watch",
"lint:types": "tsc --noEmit",
"test": "vitest run",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export function cellTypeToBackendType(cellType: CellType): string {

// Media types - use PostgreSQL DOMAINs where available
// DOMAINs: image (jsonb), upload (jsonb) - defined in pgpm-types extension
// These DOMAINs enforce { url, mime } structure with URL validation
// These domains accept a JSON object containing a URL, file ID, or storage key.
image: 'image', // PostgreSQL DOMAIN over jsonb
file: 'text', // No DOMAIN available, uses base type
video: 'text', // No DOMAIN available, uses base type
Expand Down
11 changes: 10 additions & 1 deletion packages/sheets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,10 +171,19 @@ The `SheetsConfig` object passed to `<SheetsProvider>`:
| `databaseId` | `string` | No | Multi-tenant database scope |
| `fieldTypeOverrides` | `Record<string, string>` | No | Override detected field types |
| `execute` | `SheetsExecuteFn` | No | Custom GraphQL executor |
| `executeUpload` | `SheetsUploadFn` | No | Custom file upload handler |
| `storage` | `{ endpoint: string; execute?: SheetsExecuteFn }` | No | Storage endpoint for presigned uploads and previews |
| `executeUpload` | `SheetsUploadFn` | No | Host uploader returning `{ file: SheetsFileValue; url?: string }`. Takes precedence over the storage uploader |
| `upload` | `{ bucketKey?: string }` | No | Optional explicit bucket override. Sheets requests the tenant's public default when omitted |
| `queryClient` | `QueryClient` | No | Bring-your-own TanStack Query client |
| `onAuthError` | `() => void` | No | Callback on 401/UNAUTHENTICATED |

Sheets writes media cell values through the data endpoint. Image and upload
cells store the durable file projection, and attachment cells store its public
URL as text. The optional top-level upload result URL can expire. Hosts with
neither storage nor an injected uploader render media cells read-only. See
[Host integration](docs/HOST_INTEGRATION.md#media-uploads) for the migration
from the previous upload API.

## Components

| Component | Description |
Expand Down
6 changes: 4 additions & 2 deletions packages/sheets/docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ The range band is a `pointer-events-none` overlay (`data-part-id="sheets-range-b

## 4. Editing ✅

Inline editing routes through a single `resolveEditIntent`: inline toggle (boolean), portal overlay editor, or no-op (readonly). 12 editors (text, number, date, image, relation, geometry, interval, json, array, inet, tsvector, url, upload) re-hosted in a React portal `OverlayManager`.
Inline editing routes through a single `resolveEditIntent`: inline toggle (boolean), portal overlay editor, or no-op (readonly). Editors run in a React portal `OverlayManager`. The media editor handles image, file, audio, video, upload, and attachment cells.

| Aspect | Behavior |
|--------|----------|
Expand All @@ -79,7 +79,9 @@ Inline editing routes through a single `resolveEditIntent`: inline toggle (boole
| Optimistic | The cell flips instantly; reverts on server reject |
| Drafts | Client-side draft rows with per-row submit, validation styling, relation/FK normalization |

Consumer editors keeping cancel-on-outside (their internal Save/validation/focus): json, url, inet, array, tsvector, interval, date, geometry, relation, upload. *Commit-on-blur for those is a planned follow-up.*
Consumer editors keeping cancel-on-outside (their internal Save/validation/focus): json, url, inet, array, tsvector, interval, date, geometry, relation, and media. *Commit-on-blur for those is a planned follow-up.*

Media uploads use the host storage endpoint, and cell updates use the data endpoint. Image and upload cells store the file projection and resolve signed previews as needed. Attachment cells store a public URL. Failed draft uploads can retry against the created row, and clearing a cell preserves its old file row.

**Deferred:** in-editor `Tab`/`Enter` commit-and-advance (Tab across a row while editing).

Expand Down
31 changes: 28 additions & 3 deletions packages/sheets/docs/HOST_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,36 @@ interface SheetsAuthEmbedded {
1. **HTTP 401** — `config.onAuthError?.()` before `throw createError.unauthorized(...)`
2. **GraphQL UNAUTHENTICATED** — `onAuthError?.()` inside `parseGraphQLResponse` before returning the error

Both paths also apply to upload requests (`createSheetsUpload`).
Both paths also apply to GraphQL requests made by the default storage executor.

### Propagation

`SheetsProvider` passes the full `config` object to `createSheetsExecute` and `createSheetsUpload`. No additional wiring is needed in the provider — adding `onAuthError` to your config is sufficient.
`SheetsProvider` uses the host's token getter and `onAuthError` to build the
default data and storage executors. If the host injects an executor or
uploader, it owns auth handling for requests made through that override.

## Media uploads

Configure the data endpoint as `endpoint` and the tenant's storage endpoint as
`storage: { endpoint, execute? }`. Upload discovery and signed preview lookups
use storage, and the cell update uses the data endpoint. An injected
`executeUpload` takes precedence for uploads. Without either an injected uploader
or storage, the media editor is read-only.

Sheets requests the public default bucket by omitting `bucketKey` and sending
`isPublic: true`. A host can set `upload.bucketKey` to select a particular
bucket. Image and upload fields store the file projection
`{ id, key, bucket_id, mime, size, filename, url? }`. The editor resolves a
signed preview when the projection lacks a public URL. Attachment fields store
only `file.url` as text and require a public bucket with a public URL prefix.

When migrating a host uploader, return `{ file: SheetsFileValue, url?: string }`.
The optional top-level `url` is for immediate download and may expire. Put a
durable public URL on `file.url` when the storage service provides one.
`executeFieldUpload(target, file, transport)` now takes the table, field, and
typed row identifier in `target`, and the data executor, uploader, and adapter
in `transport`. It returns the saved cell value and accepts `null` to clear.
Use `useSheetsFieldUpload` in place of `useSheetsImageUpload`.

## Auth Error Flow

Expand All @@ -58,7 +83,7 @@ Both paths also apply to upload requests (`createSheetsUpload`).
┌──────────────────────────────┐
│ SheetsProvider │
│ → createSheetsExecute(cfg) │
│ → createSheetsUpload(cfg) │
│ → storage.execute │
└──────────┬───────────────────┘
│ request fails (401 / UNAUTHENTICATED)
▼
Expand Down
2 changes: 1 addition & 1 deletion packages/sheets/docs/OVERLAY_EDITORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ Import from `./overlay-presets`. Don't hardcode width classes — use the preset
|--------|----------|-------------|
| `sm` | Single input (date, inet, interval) | 320-480px |
| `md` | Tag chips, lists (array, relation, tsvector) | 400-560px |
| `lg` | Rich editors (JSON, image) | 480-720px |
| `lg` | Rich editors (JSON, media uploads) | 480-720px |
| `xl` | Full-width (geometry/map) | 600px-90vw |

### Always set `displayName` on wrapper components
Expand Down
7 changes: 3 additions & 4 deletions packages/sheets/docs/SHEETS_IMPROVEMENT_ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ The rest of the roadmap (backend adapter, typed row model, component API, headle
**Critical weaknesses (release blockers in bold):**
- **No real extension surface** — the advertised one is dead; the live ones are closed (§4, P0).
- **No failure UX** — `<DataEditor>` renders unconditionally; the `error` from the data hooks is destructured but **never rendered** (`sheets.tsx:198`), **zero error boundaries** across 113 files, no empty/loading/error states.
- **Data-loss bug** — image draft-submit→upload removes the draft from the store _before_ the upload (`image-editor.tsx:243-263`); an upload failure orphans a server row and drops the draft.
- **Draft upload retry** — the media editor retains its staged file and the created row key so a failed upload can retry after draft removal.
- **Backend shape hardcoded everywhere** — `_meta` doc, `buildPostGraphile*`, Relay `{nodes}` parsing, PG filter dialect, relation inflection; ~17 files import `@constructive-io/data` directly. Sheets is a PostGraphile client, not a generic grid.
- **951-line god-component** (`sheets.tsx`) fuses two data modes + drafts + optimistic updates + theming + JSX; no headless core.
- **Soundness holes at the exact extension seams** — `combinedRows as any[]` (~15×), `__isDraft` magic keys (43 sites), `(cell.data as any)`, the `@ts-expect-error` in `extractCellValue` (`sheets.utils.ts:696`).
Expand Down Expand Up @@ -64,7 +64,7 @@ Effort: **S**≤1d · **M**≈2-4d · **L**≈1-2wk · **XL**≈3wk+. Impact:
| 0.3 | **ESM-scoped code-splitting + lazy heavy editors.** _Verified: `splitting:true` with `format:['esm','cjs']` **fails** esbuild ("splitting only works with esm"). Split `tsup.config.ts` into two configs — ESM with `splitting:true`, CJS non-split. `React.lazy` the 3 heavy editors (geometry→leaflet, json, image) in `editor-registry.ts`; light editors stay eager._ | M | ★★ | ✅ |
| 0.4 | **Fix the vendor build.** Drop phantom `noExternal: ['pluralize','sonner']` (not deps); add real dep `'inflekt'`; drop `leaflet`/`react-leaflet` from `noExternal` (already optional peers — removes the ~270KB vendor double-bundle); add the missing `build:vendor` script so CI exercises it (`tsup.vendor.config.ts`). | S | ★★ | ✅ |
| 0.5 | **Render real error / empty / loading states.** Surface `error` through `SheetsInner`'s memo (`use-load-grid.ts` already returns `{isError,error}`; `sheets.tsx:169` only pulls `hasCompletedInitialLoad`) and branch before `<DataEditor>`. Ship default state components; gate loading on initial load only. _BC note: changes default DOM on error/empty — document it._ | M | ★★★ | ⚠️ behavior |
| 0.6 | **Fix the orphaned-row data-loss path** (`image-editor.tsx:260-263`): on post-submit upload failure, call `onDraftUploadComplete?.()`, flip the editor out of draft mode, capture the created id, and retain the file so a single retry uploads to the now-real row (don't re-enter `onSubmitDraft`). | M | ★★ | ✅ |
| 0.6 | **Keep draft uploads retryable.** Retain the editor and staged file after row creation so retry writes to the created row. | M | ★★ | ✅ |
| 0.7 | **Injectable logger + `onError` on `SheetsConfig`** replacing the 11 raw `console.*`. Define **one** config delta (`logger`, `onError`) — consolidate with the error-boundary and architecture proposals to avoid a fractured config. Default `error→console.error`, `debug→no-op`. | S | ★ | ✅ |
| 0.8 | **Error boundary** around `SheetsInner` and each overlay editor (`wrapEditor`), routed through `config.onError`; editor boundary calls `onFinishedEditing(undefined)` on reset so a bad editor can't wedge the overlay. Export `SheetsErrorBoundary`. | M | ★★ | ⚠️ swallows |
| 0.9 | **Doc fix:** `EMBEDDING.md:166` `onSelect`→`onTableChange` (verified real prop). | S | ★ | ✅ |
Expand Down Expand Up @@ -150,11 +150,10 @@ export interface SheetsBackendAdapter {
createRow/updateRow/deleteRow(...): Promise<...>;
buildWhere(field, operator, value): Record<string,unknown> | null; // default emits PostGraphile dialect
resolveOrderBy(field, dir, table): unknown;
uploadFile?(file, target, execute): Promise<{ url: string }>;
}
// SheetsConfig: adapter?: SheetsBackendAdapter
```
*Land incrementally* (`fetchMeta` → `listRows` → mutations) behind the default adapter; add data-hook tests (currently zero) before moving each method. Fold the **redundant-count removal (0.1) into `listRows`** and **route the upload GraphQL patch step through `execute`** (the hand-built `fetch` in `use-sheets-upload.ts` bypasses the canonical error seam) so the adapter boundary doesn't leak.
*Land incrementally* (`fetchMeta` → `listRows` → mutations) behind the default adapter; add data-hook tests (currently zero) before moving each method. Fold the **redundant-count removal (0.1) into `listRows`** and **route the media cell patch through the adapter**. The storage transport uploads the file and resolves previews, while the data adapter persists the projection.

**2.2 — One resolution authority · M · ★★ · BC**
Make `mapToFrontendCellType(meta, cfg?)` the single resolver with injectable `aliasOverrides`/`scalarOverrides`/`resolve`. Route `createColumnSchemaFromMeta` and the forms layer through it (they diverge today). *Correction:* the divergence currently has **no visible effect** — `createColumnSchemaFromMeta`'s output (`tableSchema`) has **zero live consumers**; the grid already uses the full cascade. So this is **cleanup + an injection hook**, not a live-bug fix — either route it through the cascade or delete the dead `tableSchema`. Move Constructive-specific scalars (`Relation`/`Interval`/`GeoJSON`) into the PostGraphile adapter's default `scalarOverrides`.
Expand Down
1 change: 1 addition & 0 deletions packages/sheets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
],
"scripts": {
"build": "rm -rf dist && tsup && cp src/styles.css dist/styles.css",
"prepack": "pnpm build",
"build:registry": "tsx scripts/build-registry.ts",
"check:registry": "tsx scripts/build-registry.ts --check",
"build:vendor": "tsup --config tsup.vendor.config.ts",
Expand Down
Loading
Loading