chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
|
||||
/**
|
||||
* An in-flight client upload, shown optimistically before its server import row exists or the
|
||||
* table list has refreshed. Keyed by `uploadId`: a `pending_*` id (creating a new table, no row
|
||||
* yet) or the target tableId (append/replace into an existing table).
|
||||
*/
|
||||
export interface ImportUpload {
|
||||
uploadId: string
|
||||
workspaceId: string
|
||||
title: string
|
||||
/** Byte-based upload percent from the client XHR. */
|
||||
percent?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-only state for the import tray. The importing/terminal rows themselves are derived from
|
||||
* the table list (React Query) — this store holds only what the server doesn't: optimistic uploads,
|
||||
* which terminal completions to surface this session, canceled ids, and the menu's open state.
|
||||
*/
|
||||
interface ImportTrayState {
|
||||
uploads: Record<string, ImportUpload>
|
||||
/** Terminal (`ready`/`failed`) table ids to surface as a card this session. */
|
||||
notified: Record<string, true>
|
||||
/** Ids (upload or table) canceled so callbacks/derivation don't resurrect them. */
|
||||
canceledIds: Record<string, true>
|
||||
/**
|
||||
* Server-listed rows the user dismissed (export jobIds). Unlike `notified` — an allow-list for
|
||||
* table-derived terminals — export terminals are listed by the server for a visibility window,
|
||||
* so dismissal needs a deny-list.
|
||||
*/
|
||||
dismissedIds: Record<string, true>
|
||||
menuOpen: boolean
|
||||
|
||||
startUpload: (upload: ImportUpload) => void
|
||||
setUploadPercent: (uploadId: string, percent: number) => void
|
||||
endUpload: (uploadId: string) => void
|
||||
/** Surface a terminal completion as a tray card. */
|
||||
notify: (tableId: string) => void
|
||||
/** Remove a terminal card (manual dismiss or auto-clear). */
|
||||
dismiss: (tableId: string) => void
|
||||
/** Hide a server-listed job row (export) for the rest of the session. */
|
||||
dismissJob: (jobId: string) => void
|
||||
/** Flag an id canceled and drop any optimistic upload for it. */
|
||||
cancel: (id: string) => void
|
||||
isCanceled: (id: string) => boolean
|
||||
/** Returns whether the id was canceled and clears the flag (one-shot, for the kickoff handler). */
|
||||
consumeCanceled: (id: string) => boolean
|
||||
setMenuOpen: (open: boolean) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
uploads: {} as Record<string, ImportUpload>,
|
||||
notified: {} as Record<string, true>,
|
||||
canceledIds: {} as Record<string, true>,
|
||||
dismissedIds: {} as Record<string, true>,
|
||||
menuOpen: false,
|
||||
}
|
||||
|
||||
export const useImportTrayStore = create<ImportTrayState>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
startUpload: (upload) =>
|
||||
set((state) => ({ uploads: { ...state.uploads, [upload.uploadId]: upload } })),
|
||||
|
||||
setUploadPercent: (uploadId, percent) =>
|
||||
set((state) => {
|
||||
const prev = state.uploads[uploadId]
|
||||
if (!prev) return state
|
||||
return { uploads: { ...state.uploads, [uploadId]: { ...prev, percent } } }
|
||||
}),
|
||||
|
||||
endUpload: (uploadId) =>
|
||||
set((state) => {
|
||||
if (!state.uploads[uploadId]) return state
|
||||
const { [uploadId]: _removed, ...rest } = state.uploads
|
||||
return { uploads: rest }
|
||||
}),
|
||||
|
||||
notify: (tableId) => set((state) => ({ notified: { ...state.notified, [tableId]: true } })),
|
||||
|
||||
dismiss: (tableId) =>
|
||||
set((state) => {
|
||||
if (!state.notified[tableId]) return state
|
||||
const { [tableId]: _removed, ...rest } = state.notified
|
||||
return { notified: rest }
|
||||
}),
|
||||
|
||||
dismissJob: (jobId) =>
|
||||
set((state) => ({ dismissedIds: { ...state.dismissedIds, [jobId]: true } })),
|
||||
|
||||
cancel: (id) =>
|
||||
set((state) => {
|
||||
const { [id]: _removed, ...uploads } = state.uploads
|
||||
return { uploads, canceledIds: { ...state.canceledIds, [id]: true } }
|
||||
}),
|
||||
|
||||
isCanceled: (id) => Boolean(get().canceledIds[id]),
|
||||
|
||||
consumeCanceled: (id) => {
|
||||
const was = Boolean(get().canceledIds[id])
|
||||
if (was) {
|
||||
set((state) => {
|
||||
const { [id]: _removed, ...rest } = state.canceledIds
|
||||
return { canceledIds: rest }
|
||||
})
|
||||
}
|
||||
return was
|
||||
},
|
||||
|
||||
setMenuOpen: (open) => set({ menuOpen: open }),
|
||||
|
||||
reset: () => set(initialState),
|
||||
}),
|
||||
{ name: 'import-tray-store' }
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Zustand store for table undo/redo stacks.
|
||||
* Ephemeral — no persistence. Stacks are keyed by tableId.
|
||||
*/
|
||||
|
||||
import { generateShortId } from '@sim/utils/id'
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import type { TableUndoAction, TableUndoStacks, TableUndoState, UndoEntry } from './types'
|
||||
|
||||
const STACK_CAPACITY = 100
|
||||
const EMPTY_STACKS: TableUndoStacks = { undo: [], redo: [] }
|
||||
|
||||
let undoRedoInProgress = false
|
||||
|
||||
function patchRowIdInEntry(entry: UndoEntry, oldRowId: string, newRowId: string): UndoEntry {
|
||||
const { action } = entry
|
||||
switch (action.type) {
|
||||
case 'update-cell':
|
||||
if (action.rowId === oldRowId) {
|
||||
return { ...entry, action: { ...action, rowId: newRowId } }
|
||||
}
|
||||
break
|
||||
case 'clear-cells': {
|
||||
const hasMatch = action.cells.some((c) => c.rowId === oldRowId)
|
||||
if (hasMatch) {
|
||||
const patched = action.cells.map((c) =>
|
||||
c.rowId === oldRowId ? { ...c, rowId: newRowId } : c
|
||||
)
|
||||
return { ...entry, action: { ...action, cells: patched } }
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'update-cells': {
|
||||
const hasMatch = action.cells.some((c) => c.rowId === oldRowId)
|
||||
if (hasMatch) {
|
||||
const patched = action.cells.map((c) =>
|
||||
c.rowId === oldRowId ? { ...c, rowId: newRowId } : c
|
||||
)
|
||||
return { ...entry, action: { ...action, cells: patched } }
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'create-row':
|
||||
if (action.rowId === oldRowId) {
|
||||
return { ...entry, action: { ...action, rowId: newRowId } }
|
||||
}
|
||||
break
|
||||
case 'create-rows': {
|
||||
const hasMatch = action.rows.some((r) => r.rowId === oldRowId)
|
||||
if (hasMatch) {
|
||||
const patched = action.rows.map((r) =>
|
||||
r.rowId === oldRowId ? { ...r, rowId: newRowId } : r
|
||||
)
|
||||
return { ...entry, action: { ...action, rows: patched } }
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'delete-rows': {
|
||||
const hasMatch = action.rows.some((r) => r.rowId === oldRowId)
|
||||
if (hasMatch) {
|
||||
const patched = action.rows.map((r) =>
|
||||
r.rowId === oldRowId ? { ...r, rowId: newRowId } : r
|
||||
)
|
||||
return { ...entry, action: { ...action, rows: patched } }
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'delete-column': {
|
||||
const hasMatch = action.cellData.some((c) => c.rowId === oldRowId)
|
||||
if (hasMatch) {
|
||||
const patched = action.cellData.map((c) =>
|
||||
c.rowId === oldRowId ? { ...c, rowId: newRowId } : c
|
||||
)
|
||||
return { ...entry, action: { ...action, cellData: patched } }
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a function without recording undo entries. Supports async functions —
|
||||
* `undoRedoInProgress` stays true until the returned Promise settles, so
|
||||
* mutations inside `executeAction` don't accidentally push new undo entries.
|
||||
*/
|
||||
export async function runWithoutRecording<T>(fn: () => T | Promise<T>): Promise<T> {
|
||||
undoRedoInProgress = true
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
undoRedoInProgress = false
|
||||
}
|
||||
}
|
||||
|
||||
export const useTableUndoStore = create<TableUndoState>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
stacks: {},
|
||||
|
||||
push: (tableId: string, action: TableUndoAction) => {
|
||||
if (undoRedoInProgress) return
|
||||
|
||||
const entry: UndoEntry = { id: generateShortId(), action, timestamp: Date.now() }
|
||||
|
||||
set((state) => {
|
||||
const current = state.stacks[tableId] ?? EMPTY_STACKS
|
||||
const undoStack = [entry, ...current.undo].slice(0, STACK_CAPACITY)
|
||||
return {
|
||||
stacks: {
|
||||
...state.stacks,
|
||||
[tableId]: { undo: undoStack, redo: [] },
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
popUndo: (tableId: string) => {
|
||||
const current = get().stacks[tableId] ?? EMPTY_STACKS
|
||||
if (current.undo.length === 0) return null
|
||||
|
||||
const [entry, ...rest] = current.undo
|
||||
set((state) => ({
|
||||
stacks: {
|
||||
...state.stacks,
|
||||
[tableId]: {
|
||||
undo: rest,
|
||||
redo: [entry, ...current.redo],
|
||||
},
|
||||
},
|
||||
}))
|
||||
return entry
|
||||
},
|
||||
|
||||
popRedo: (tableId: string) => {
|
||||
const current = get().stacks[tableId] ?? EMPTY_STACKS
|
||||
if (current.redo.length === 0) return null
|
||||
|
||||
const [entry, ...rest] = current.redo
|
||||
set((state) => ({
|
||||
stacks: {
|
||||
...state.stacks,
|
||||
[tableId]: {
|
||||
undo: [entry, ...current.undo],
|
||||
redo: rest,
|
||||
},
|
||||
},
|
||||
}))
|
||||
return entry
|
||||
},
|
||||
|
||||
patchRedoRowId: (tableId: string, oldRowId: string, newRowId: string) => {
|
||||
set((state) => {
|
||||
const stacks = state.stacks[tableId]
|
||||
if (!stacks) return state
|
||||
const patchedRedo = stacks.redo.map((entry) =>
|
||||
patchRowIdInEntry(entry, oldRowId, newRowId)
|
||||
)
|
||||
return {
|
||||
stacks: {
|
||||
...state.stacks,
|
||||
[tableId]: { ...stacks, redo: patchedRedo },
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
patchUndoRowId: (tableId: string, oldRowId: string, newRowId: string) => {
|
||||
set((state) => {
|
||||
const stacks = state.stacks[tableId]
|
||||
if (!stacks) return state
|
||||
const patchedUndo = stacks.undo.map((entry) =>
|
||||
patchRowIdInEntry(entry, oldRowId, newRowId)
|
||||
)
|
||||
return {
|
||||
stacks: {
|
||||
...state.stacks,
|
||||
[tableId]: { ...stacks, undo: patchedUndo },
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
clear: (tableId: string) => {
|
||||
set((state) => {
|
||||
const { [tableId]: _, ...rest } = state.stacks
|
||||
return { stacks: rest }
|
||||
})
|
||||
},
|
||||
}),
|
||||
{ name: 'table-undo-store' }
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Type definitions for table undo/redo actions.
|
||||
*/
|
||||
|
||||
import type { ColumnDefinition } from '@/lib/table'
|
||||
|
||||
export interface DeletedRowSnapshot {
|
||||
rowId: string
|
||||
data: Record<string, unknown>
|
||||
position: number
|
||||
/** Fractional order key, when present — restore re-inserts at this exact key. */
|
||||
orderKey?: string
|
||||
}
|
||||
|
||||
export type TableUndoAction =
|
||||
| {
|
||||
type: 'update-cell'
|
||||
rowId: string
|
||||
columnName: string
|
||||
previousValue: unknown
|
||||
newValue: unknown
|
||||
}
|
||||
| { type: 'clear-cells'; cells: Array<{ rowId: string; data: Record<string, unknown> }> }
|
||||
| {
|
||||
type: 'update-cells'
|
||||
cells: Array<{
|
||||
rowId: string
|
||||
oldData: Record<string, unknown>
|
||||
newData: Record<string, unknown>
|
||||
}>
|
||||
}
|
||||
| {
|
||||
type: 'create-row'
|
||||
rowId: string
|
||||
orderKey?: string
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
| {
|
||||
type: 'create-rows'
|
||||
rows: Array<{
|
||||
rowId: string
|
||||
orderKey?: string
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
}
|
||||
| { type: 'delete-rows'; rows: DeletedRowSnapshot[] }
|
||||
// `columnName` is the display name (for re-create); `columnId` is the stable
|
||||
// storage key used for the delete/update lookup and id-keyed metadata cleanup.
|
||||
| { type: 'create-column'; columnName: string; columnId?: string; position: number }
|
||||
| {
|
||||
type: 'delete-column'
|
||||
columnName: string
|
||||
columnId?: string
|
||||
columnType: ColumnDefinition['type']
|
||||
columnPosition: number
|
||||
columnUnique: boolean
|
||||
columnRequired: boolean
|
||||
cellData: Array<{ rowId: string; value: unknown }>
|
||||
previousOrder: string[] | null
|
||||
previousWidth: number | null
|
||||
previousPinnedColumns: string[] | null
|
||||
}
|
||||
// `oldName`/`newName` are display names; `columnId` is the stable lookup key.
|
||||
| { type: 'rename-column'; oldName: string; newName: string; columnId?: string }
|
||||
| {
|
||||
type: 'update-column-type'
|
||||
columnName: string
|
||||
previousType: ColumnDefinition['type']
|
||||
newType: ColumnDefinition['type']
|
||||
}
|
||||
| {
|
||||
type: 'toggle-column-constraint'
|
||||
columnName: string
|
||||
constraint: 'unique' | 'required'
|
||||
previousValue: boolean
|
||||
newValue: boolean
|
||||
}
|
||||
| { type: 'rename-table'; tableId: string; previousName: string; newName: string }
|
||||
| { type: 'reorder-columns'; previousOrder: string[]; newOrder: string[] }
|
||||
|
||||
export interface UndoEntry {
|
||||
id: string
|
||||
action: TableUndoAction
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface TableUndoStacks {
|
||||
undo: UndoEntry[]
|
||||
redo: UndoEntry[]
|
||||
}
|
||||
|
||||
export interface TableUndoState {
|
||||
stacks: Record<string, TableUndoStacks>
|
||||
push: (tableId: string, action: TableUndoAction) => void
|
||||
popUndo: (tableId: string) => UndoEntry | null
|
||||
popRedo: (tableId: string) => UndoEntry | null
|
||||
patchRedoRowId: (tableId: string, oldRowId: string, newRowId: string) => void
|
||||
patchUndoRowId: (tableId: string, oldRowId: string, newRowId: string) => void
|
||||
clear: (tableId: string) => void
|
||||
}
|
||||
Reference in New Issue
Block a user