chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* React Query key factory for workspace credentials.
|
||||
*
|
||||
* Lives in this standalone (non-`'use client'`) module — like
|
||||
* {@link file://./folder-keys.ts} — so server-evaluated code (block
|
||||
* definitions, server prefetch) can import it without pulling client-reference
|
||||
* stubs from the `'use client'` `@/hooks/queries/credentials` module.
|
||||
*/
|
||||
export const workspaceCredentialKeys = {
|
||||
all: ['workspaceCredentials'] as const,
|
||||
lists: () => [...workspaceCredentialKeys.all, 'list'] as const,
|
||||
list: (workspaceId?: string, type?: string, providerId?: string) =>
|
||||
[
|
||||
...workspaceCredentialKeys.lists(),
|
||||
workspaceId ?? 'none',
|
||||
type ?? 'all',
|
||||
providerId ?? 'all',
|
||||
] as const,
|
||||
details: () => [...workspaceCredentialKeys.all, 'detail'] as const,
|
||||
detail: (credentialId?: string) =>
|
||||
[...workspaceCredentialKeys.details(), credentialId ?? 'none'] as const,
|
||||
members: (credentialId?: string) =>
|
||||
[...workspaceCredentialKeys.detail(credentialId), 'members'] as const,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const customToolsKeys = {
|
||||
all: ['customTools'] as const,
|
||||
lists: () => [...customToolsKeys.all, 'list'] as const,
|
||||
list: (workspaceId: string) => [...customToolsKeys.lists(), workspaceId] as const,
|
||||
details: () => [...customToolsKeys.all, 'detail'] as const,
|
||||
detail: (toolId: string) => [...customToolsKeys.details(), toolId] as const,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { getDeploymentVersionStateContract } from '@/lib/api/contracts/deployments'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
/**
|
||||
* Fetches the deployed state for a specific deployment version.
|
||||
*/
|
||||
export async function fetchDeploymentVersionState(
|
||||
workflowId: string,
|
||||
version: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkflowState> {
|
||||
const data = await requestJson(getDeploymentVersionStateContract, {
|
||||
params: { id: workflowId, version },
|
||||
signal,
|
||||
})
|
||||
if (!data.deployedState) {
|
||||
throw new Error('No deployed state returned')
|
||||
}
|
||||
|
||||
return data.deployedState
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockRequestJson } = vi.hoisted(() => ({
|
||||
mockRequestJson: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api/client/request', () => ({
|
||||
requestJson: mockRequestJson,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api/contracts/workflows', () => ({
|
||||
getWorkflowStateContract: { __contract: 'getWorkflowState' },
|
||||
}))
|
||||
|
||||
import { fetchWorkflowEnvelope } from '@/hooks/queries/utils/fetch-workflow-envelope'
|
||||
|
||||
describe('fetchWorkflowEnvelope', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns the unwrapped envelope from the contract response', async () => {
|
||||
const envelope = {
|
||||
id: 'wf-1',
|
||||
isDeployed: true,
|
||||
state: { blocks: {}, edges: [], loops: {}, parallels: {} },
|
||||
}
|
||||
mockRequestJson.mockResolvedValue({ data: envelope })
|
||||
|
||||
const result = await fetchWorkflowEnvelope('wf-1')
|
||||
|
||||
expect(result).toBe(envelope)
|
||||
})
|
||||
|
||||
it('forwards params.id and signal to requestJson against the contract', async () => {
|
||||
mockRequestJson.mockResolvedValue({ data: { id: 'wf-2' } })
|
||||
const controller = new AbortController()
|
||||
|
||||
await fetchWorkflowEnvelope('wf-2', controller.signal)
|
||||
|
||||
expect(mockRequestJson).toHaveBeenCalledTimes(1)
|
||||
expect(mockRequestJson).toHaveBeenCalledWith(
|
||||
{ __contract: 'getWorkflowState' },
|
||||
{ params: { id: 'wf-2' }, signal: controller.signal }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
type GetWorkflowResponseData,
|
||||
getWorkflowStateContract,
|
||||
} from '@/lib/api/contracts/workflows'
|
||||
|
||||
/**
|
||||
* Fetches the full workflow envelope (in-state slice, deployment status,
|
||||
* variables, and row metadata) for a single workflow from GET
|
||||
* `/api/workflows/[id]`.
|
||||
*
|
||||
* Single source of truth for the `workflowKeys.state(id)` cache entry: the
|
||||
* registry store hydrates it via `fetchQuery` (always-fresh, in-flight
|
||||
* deduped) and `useWorkflowState`/`useWorkflowStates` project the mapped
|
||||
* `WorkflowState` out of the same entry with `select`, so this endpoint has
|
||||
* exactly one cache entry across the store and the hooks.
|
||||
*
|
||||
* Lives in a standalone util (rather than `hooks/queries/workflows.ts`) so the
|
||||
* registry store can import it without creating a store ↔ query-hook import
|
||||
* cycle.
|
||||
*/
|
||||
export async function fetchWorkflowEnvelope(
|
||||
workflowId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<GetWorkflowResponseData> {
|
||||
const { data } = await requestJson(getWorkflowStateContract, {
|
||||
params: { id: workflowId },
|
||||
signal,
|
||||
})
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { listWorkspaceCredentialsContract, type WorkspaceCredential } from '@/lib/api/contracts'
|
||||
|
||||
/**
|
||||
* Fetches the workspace credential list.
|
||||
*
|
||||
* Lives in this standalone (non-`'use client'`) module so block definitions and
|
||||
* server prefetch can import it without pulling client-reference stubs from the
|
||||
* `'use client'` `@/hooks/queries/credentials` module.
|
||||
*/
|
||||
export async function fetchWorkspaceCredentialList(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkspaceCredential[]> {
|
||||
const data = await requestJson(listWorkspaceCredentialsContract, {
|
||||
query: { workspaceId },
|
||||
signal,
|
||||
})
|
||||
return data.credentials ?? []
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
|
||||
const EMPTY_FOLDERS: WorkflowFolder[] = []
|
||||
|
||||
export function getFolders(workspaceId: string): WorkflowFolder[] {
|
||||
return (
|
||||
getQueryClient().getQueryData<WorkflowFolder[]>(folderKeys.list(workspaceId)) ?? EMPTY_FOLDERS
|
||||
)
|
||||
}
|
||||
|
||||
export function getFolderMap(workspaceId: string): Record<string, WorkflowFolder> {
|
||||
return Object.fromEntries(getFolders(workspaceId).map((folder) => [folder.id, folder]))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type FolderQueryScope = 'active' | 'archived'
|
||||
|
||||
export const folderKeys = {
|
||||
all: ['folders'] as const,
|
||||
lists: () => [...folderKeys.all, 'list'] as const,
|
||||
list: (workspaceId: string | undefined, scope: FolderQueryScope = 'active') =>
|
||||
[...folderKeys.lists(), workspaceId ?? '', scope] as const,
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
findLockedAncestorFolder,
|
||||
getFolderPath,
|
||||
isFolderEffectivelyLocked,
|
||||
isFolderOrAncestorLocked,
|
||||
isWorkflowEffectivelyLocked,
|
||||
} from '@/hooks/queries/utils/folder-tree'
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
|
||||
function makeFolder(overrides: Partial<WorkflowFolder> & { id: string }): WorkflowFolder {
|
||||
return {
|
||||
id: overrides.id,
|
||||
name: overrides.name ?? overrides.id,
|
||||
userId: 'user-1',
|
||||
workspaceId: 'ws-1',
|
||||
parentId: overrides.parentId ?? null,
|
||||
color: '#000000',
|
||||
isExpanded: false,
|
||||
locked: overrides.locked ?? false,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
archivedAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
describe('isFolderOrAncestorLocked', () => {
|
||||
it('returns false for root', () => {
|
||||
expect(isFolderOrAncestorLocked(null, {})).toBe(false)
|
||||
expect(isFolderOrAncestorLocked(undefined, {})).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when the folder itself is locked', () => {
|
||||
const folders = { f1: makeFolder({ id: 'f1', locked: true }) }
|
||||
expect(isFolderOrAncestorLocked('f1', folders)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when an ancestor is locked', () => {
|
||||
const folders = {
|
||||
f1: makeFolder({ id: 'f1', locked: true }),
|
||||
f2: makeFolder({ id: 'f2', parentId: 'f1' }),
|
||||
f3: makeFolder({ id: 'f3', parentId: 'f2' }),
|
||||
}
|
||||
expect(isFolderOrAncestorLocked('f3', folders)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when nothing in the chain is locked', () => {
|
||||
const folders = {
|
||||
f1: makeFolder({ id: 'f1' }),
|
||||
f2: makeFolder({ id: 'f2', parentId: 'f1' }),
|
||||
}
|
||||
expect(isFolderOrAncestorLocked('f2', folders)).toBe(false)
|
||||
})
|
||||
|
||||
it('short-circuits on cycles instead of looping forever', () => {
|
||||
const folders = {
|
||||
f1: makeFolder({ id: 'f1', parentId: 'f2' }),
|
||||
f2: makeFolder({ id: 'f2', parentId: 'f1' }),
|
||||
}
|
||||
expect(isFolderOrAncestorLocked('f1', folders)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFolderPath', () => {
|
||||
it('returns null for root or unknown folders', () => {
|
||||
expect(getFolderPath(null, {})).toBeNull()
|
||||
expect(getFolderPath(undefined, {})).toBeNull()
|
||||
expect(getFolderPath('missing', {})).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the leaf folder name when at top level', () => {
|
||||
const folders = { f1: makeFolder({ id: 'f1', name: 'Engineering' }) }
|
||||
expect(getFolderPath('f1', folders)).toBe('Engineering')
|
||||
})
|
||||
|
||||
it('joins ancestor names from root to leaf', () => {
|
||||
const folders = {
|
||||
eng: makeFolder({ id: 'eng', name: 'Engineering' }),
|
||||
be: makeFolder({ id: 'be', name: 'Backend', parentId: 'eng' }),
|
||||
api: makeFolder({ id: 'api', name: 'API', parentId: 'be' }),
|
||||
}
|
||||
expect(getFolderPath('api', folders)).toBe('Engineering / Backend / API')
|
||||
})
|
||||
|
||||
it('respects custom separators', () => {
|
||||
const folders = {
|
||||
eng: makeFolder({ id: 'eng', name: 'Engineering' }),
|
||||
be: makeFolder({ id: 'be', name: 'Backend', parentId: 'eng' }),
|
||||
}
|
||||
expect(getFolderPath('be', folders, ' > ')).toBe('Engineering > Backend')
|
||||
})
|
||||
|
||||
it('returns the partial path resolved before a missing ancestor', () => {
|
||||
const folders = {
|
||||
be: makeFolder({ id: 'be', name: 'Backend', parentId: 'missing' }),
|
||||
}
|
||||
expect(getFolderPath('be', folders)).toBe('Backend')
|
||||
})
|
||||
|
||||
it('short-circuits on cycles instead of looping forever', () => {
|
||||
const folders = {
|
||||
f1: makeFolder({ id: 'f1', name: 'A', parentId: 'f2' }),
|
||||
f2: makeFolder({ id: 'f2', name: 'B', parentId: 'f1' }),
|
||||
}
|
||||
expect(getFolderPath('f1', folders)).toBe('B / A')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findLockedAncestorFolder', () => {
|
||||
it('returns null for root or unknown folders', () => {
|
||||
expect(findLockedAncestorFolder(null, {})).toBeNull()
|
||||
expect(findLockedAncestorFolder(undefined, {})).toBeNull()
|
||||
expect(findLockedAncestorFolder('missing', {})).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the folder itself when it is the locked one', () => {
|
||||
const folders = { f1: makeFolder({ id: 'f1', name: 'Engineering', locked: true }) }
|
||||
expect(findLockedAncestorFolder('f1', folders)?.name).toBe('Engineering')
|
||||
})
|
||||
|
||||
it('returns the closest locked ancestor, not the root', () => {
|
||||
const folders = {
|
||||
root: makeFolder({ id: 'root', name: 'Root', locked: true }),
|
||||
mid: makeFolder({ id: 'mid', name: 'Mid', parentId: 'root', locked: true }),
|
||||
leaf: makeFolder({ id: 'leaf', name: 'Leaf', parentId: 'mid' }),
|
||||
}
|
||||
expect(findLockedAncestorFolder('leaf', folders)?.id).toBe('mid')
|
||||
})
|
||||
|
||||
it('returns null when no folder in the chain is locked', () => {
|
||||
const folders = {
|
||||
f1: makeFolder({ id: 'f1', name: 'A' }),
|
||||
f2: makeFolder({ id: 'f2', name: 'B', parentId: 'f1' }),
|
||||
}
|
||||
expect(findLockedAncestorFolder('f2', folders)).toBeNull()
|
||||
})
|
||||
|
||||
it('short-circuits on cycles instead of looping forever', () => {
|
||||
const folders = {
|
||||
f1: makeFolder({ id: 'f1', name: 'A', parentId: 'f2' }),
|
||||
f2: makeFolder({ id: 'f2', name: 'B', parentId: 'f1' }),
|
||||
}
|
||||
expect(findLockedAncestorFolder('f1', folders)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isWorkflowEffectivelyLocked', () => {
|
||||
it('treats undefined or null workflows as unlocked', () => {
|
||||
expect(isWorkflowEffectivelyLocked(undefined, {})).toBe(false)
|
||||
expect(isWorkflowEffectivelyLocked(null, {})).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when the workflow row itself is locked', () => {
|
||||
expect(isWorkflowEffectivelyLocked({ locked: true, folderId: null }, {})).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when an ancestor folder is locked', () => {
|
||||
const folders = {
|
||||
eng: makeFolder({ id: 'eng', locked: true }),
|
||||
be: makeFolder({ id: 'be', parentId: 'eng' }),
|
||||
}
|
||||
expect(isWorkflowEffectivelyLocked({ locked: false, folderId: 'be' }, folders)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when neither row nor any ancestor is locked', () => {
|
||||
const folders = { eng: makeFolder({ id: 'eng' }) }
|
||||
expect(isWorkflowEffectivelyLocked({ locked: false, folderId: 'eng' }, folders)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for a workflow at workspace root with no row lock', () => {
|
||||
expect(isWorkflowEffectivelyLocked({ locked: false, folderId: null }, {})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isFolderEffectivelyLocked', () => {
|
||||
it('treats undefined or null folders as unlocked', () => {
|
||||
expect(isFolderEffectivelyLocked(undefined, {})).toBe(false)
|
||||
expect(isFolderEffectivelyLocked(null, {})).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when the folder itself is locked', () => {
|
||||
expect(isFolderEffectivelyLocked({ locked: true, parentId: null }, {})).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when an ancestor folder is locked', () => {
|
||||
const folders = {
|
||||
eng: makeFolder({ id: 'eng', locked: true }),
|
||||
be: makeFolder({ id: 'be', parentId: 'eng' }),
|
||||
}
|
||||
expect(isFolderEffectivelyLocked({ locked: false, parentId: 'eng' }, folders)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when neither the folder nor any ancestor is locked', () => {
|
||||
const folders = { eng: makeFolder({ id: 'eng' }) }
|
||||
expect(isFolderEffectivelyLocked({ locked: false, parentId: 'eng' }, folders)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for a root-level folder with no own lock', () => {
|
||||
expect(isFolderEffectivelyLocked({ locked: false, parentId: null }, {})).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
|
||||
/**
|
||||
* Returns true when the folder or one of its ancestors is locked. Used to
|
||||
* mirror server-side cascading folder lock policy on the client without an
|
||||
* extra round-trip.
|
||||
*/
|
||||
export function isFolderOrAncestorLocked(
|
||||
folderId: string | null | undefined,
|
||||
folders: Record<string, WorkflowFolder>
|
||||
): boolean {
|
||||
const visited = new Set<string>()
|
||||
let currentFolderId = folderId ?? null
|
||||
|
||||
while (currentFolderId) {
|
||||
if (visited.has(currentFolderId)) return false
|
||||
visited.add(currentFolderId)
|
||||
|
||||
const folder = folders[currentFolderId]
|
||||
if (!folder) return false
|
||||
if (folder.locked) return true
|
||||
|
||||
currentFolderId = folder.parentId
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the human-readable path for a folder, e.g. `'Engineering / Backend'`.
|
||||
* Returns `null` when the folder is at workspace root or unknown. Cycles or
|
||||
* missing ancestors short-circuit by returning the segments resolved so far.
|
||||
*/
|
||||
export function getFolderPath(
|
||||
folderId: string | null | undefined,
|
||||
folders: Record<string, WorkflowFolder>,
|
||||
separator = ' / '
|
||||
): string | null {
|
||||
if (!folderId) return null
|
||||
|
||||
const segments: string[] = []
|
||||
const visited = new Set<string>()
|
||||
let currentFolderId: string | null | undefined = folderId
|
||||
|
||||
while (currentFolderId) {
|
||||
if (visited.has(currentFolderId)) break
|
||||
visited.add(currentFolderId)
|
||||
const folder: WorkflowFolder | undefined = folders[currentFolderId]
|
||||
if (!folder) break
|
||||
segments.unshift(folder.name)
|
||||
currentFolderId = folder.parentId
|
||||
}
|
||||
|
||||
return segments.length > 0 ? segments.join(separator) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the closest locked ancestor folder for the given folderId, or `null`
|
||||
* when neither the folder nor any of its ancestors are locked. Cycles or
|
||||
* missing ancestors short-circuit and return `null` rather than looping.
|
||||
*/
|
||||
export function findLockedAncestorFolder(
|
||||
folderId: string | null | undefined,
|
||||
folders: Record<string, WorkflowFolder>
|
||||
): WorkflowFolder | null {
|
||||
if (!folderId) return null
|
||||
|
||||
const visited = new Set<string>()
|
||||
let currentFolderId: string | null | undefined = folderId
|
||||
|
||||
while (currentFolderId) {
|
||||
if (visited.has(currentFolderId)) return null
|
||||
visited.add(currentFolderId)
|
||||
const folder: WorkflowFolder | undefined = folders[currentFolderId]
|
||||
if (!folder) return null
|
||||
if (folder.locked) return folder
|
||||
currentFolderId = folder.parentId
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective lock state for a workflow as visible to the client. Mirrors
|
||||
* the server's `getWorkflowLockStatus(workflowId)` (in `@sim/platform-authz/workflow`)
|
||||
* but reads from cached folder data instead of issuing DB walks. Treats an
|
||||
* undefined workflow as unlocked so callers don't need to early-return.
|
||||
*/
|
||||
export function isWorkflowEffectivelyLocked(
|
||||
workflow: { locked?: boolean | null; folderId?: string | null } | null | undefined,
|
||||
folders: Record<string, WorkflowFolder>
|
||||
): boolean {
|
||||
if (!workflow) return false
|
||||
if (workflow.locked) return true
|
||||
return isFolderOrAncestorLocked(workflow.folderId, folders)
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective lock state for a folder as visible to the client. Mirrors the
|
||||
* server's `getFolderLockStatus(folderId)` (in `@sim/platform-authz/workflow`) but
|
||||
* reads from cached folder data instead of issuing DB walks. Treats an
|
||||
* undefined folder as unlocked so callers don't need to early-return.
|
||||
*/
|
||||
export function isFolderEffectivelyLocked(
|
||||
folder: { locked?: boolean | null; parentId?: string | null } | null | undefined,
|
||||
folders: Record<string, WorkflowFolder>
|
||||
): boolean {
|
||||
if (!folder) return false
|
||||
if (folder.locked) return true
|
||||
return isFolderOrAncestorLocked(folder.parentId, folders)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
|
||||
|
||||
describe('invalidateWorkflowLists', () => {
|
||||
it('invalidates scoped workflow lists and workflow selector caches', async () => {
|
||||
const queryClient = {
|
||||
invalidateQueries: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
await invalidateWorkflowLists(queryClient as any, 'ws-1', ['active', 'archived'])
|
||||
|
||||
expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(1, {
|
||||
queryKey: ['workflows', 'list', 'ws-1', 'active'],
|
||||
})
|
||||
expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(2, {
|
||||
queryKey: ['workflows', 'list', 'ws-1', 'archived'],
|
||||
})
|
||||
expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(3, {
|
||||
queryKey: ['selectors', 'sim.workflows', 'ws-1'],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys'
|
||||
import { selectorKeys } from '@/hooks/selectors/query-keys'
|
||||
|
||||
export async function invalidateWorkflowSelectors(queryClient: QueryClient, workspaceId: string) {
|
||||
await queryClient.invalidateQueries({ queryKey: selectorKeys.simWorkflowsPrefix(workspaceId) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates workflow list consumers for a single workspace.
|
||||
*/
|
||||
export async function invalidateWorkflowLists(
|
||||
queryClient: QueryClient,
|
||||
workspaceId: string,
|
||||
scopes: WorkflowQueryScope[] = ['active']
|
||||
) {
|
||||
const uniqueScopes = [...new Set(scopes)]
|
||||
|
||||
await Promise.all([
|
||||
...uniqueScopes.map((scope) =>
|
||||
queryClient.invalidateQueries({ queryKey: workflowKeys.list(workspaceId, scope) })
|
||||
),
|
||||
invalidateWorkflowSelectors(queryClient, workspaceId),
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
const logger = createLogger('OptimisticMutation')
|
||||
|
||||
export interface OptimisticMutationConfig<TData, TVariables, TItem, TContext> {
|
||||
name: string
|
||||
getQueryKey: (variables: TVariables) => readonly unknown[]
|
||||
getSnapshot: (variables: TVariables) => Record<string, TItem>
|
||||
generateTempId: (variables: TVariables) => string
|
||||
createOptimisticItem: (variables: TVariables, tempId: string) => TItem
|
||||
applyOptimisticUpdate: (tempId: string, item: TItem) => void
|
||||
replaceOptimisticEntry: (tempId: string, data: TData) => void
|
||||
rollback: (snapshot: Record<string, TItem>, variables: TVariables) => void
|
||||
onSuccessExtra?: (data: TData, variables: TVariables) => void
|
||||
}
|
||||
|
||||
export interface OptimisticMutationContext<TItem> {
|
||||
tempId: string
|
||||
previousState: Record<string, TItem>
|
||||
}
|
||||
|
||||
export function createOptimisticMutationHandlers<TData, TVariables, TItem>(
|
||||
queryClient: QueryClient,
|
||||
config: OptimisticMutationConfig<TData, TVariables, TItem, OptimisticMutationContext<TItem>>
|
||||
) {
|
||||
const {
|
||||
name,
|
||||
getQueryKey,
|
||||
getSnapshot,
|
||||
generateTempId,
|
||||
createOptimisticItem,
|
||||
applyOptimisticUpdate,
|
||||
replaceOptimisticEntry,
|
||||
rollback,
|
||||
onSuccessExtra,
|
||||
} = config
|
||||
|
||||
return {
|
||||
onMutate: async (variables: TVariables): Promise<OptimisticMutationContext<TItem>> => {
|
||||
const queryKey = getQueryKey(variables)
|
||||
await queryClient.cancelQueries({ queryKey })
|
||||
const previousState = getSnapshot(variables)
|
||||
const tempId = generateTempId(variables)
|
||||
const optimisticItem = createOptimisticItem(variables, tempId)
|
||||
applyOptimisticUpdate(tempId, optimisticItem)
|
||||
logger.info(`[${name}] Added optimistic entry: ${tempId}`)
|
||||
return { tempId, previousState }
|
||||
},
|
||||
|
||||
onSuccess: (data: TData, variables: TVariables, context: OptimisticMutationContext<TItem>) => {
|
||||
logger.info(`[${name}] Success, replacing temp entry ${context.tempId}`)
|
||||
replaceOptimisticEntry(context.tempId, data)
|
||||
onSuccessExtra?.(data, variables)
|
||||
},
|
||||
|
||||
onError: (
|
||||
error: Error,
|
||||
_variables: TVariables,
|
||||
context: OptimisticMutationContext<TItem> | undefined
|
||||
) => {
|
||||
logger.error(`[${name}] Failed:`, error)
|
||||
if (context?.previousState) {
|
||||
rollback(context.previousState, _variables)
|
||||
logger.info(`[${name}] Rolled back to previous state`)
|
||||
}
|
||||
},
|
||||
|
||||
onSettled: (_data: TData | undefined, _error: Error | null, variables: TVariables) => {
|
||||
return queryClient.invalidateQueries({ queryKey: getQueryKey(variables) })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function generateTempId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}`
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* React Query key factory for user-defined tables.
|
||||
*
|
||||
* Lives in this standalone (non-`'use client'`) module — like
|
||||
* {@link file://./folder-keys.ts} — so it can be imported from server
|
||||
* components (e.g. the tables page prefetch) without pulling in the
|
||||
* `'use client'` `@/hooks/queries/tables` module, whose exports would
|
||||
* otherwise resolve to client-reference stubs on the server.
|
||||
*/
|
||||
|
||||
export type TableQueryScope = 'active' | 'archived' | 'all'
|
||||
|
||||
export const TABLE_LIST_STALE_TIME = 30 * 1000
|
||||
|
||||
export const tableKeys = {
|
||||
all: ['tables'] as const,
|
||||
lists: () => [...tableKeys.all, 'list'] as const,
|
||||
list: (workspaceId?: string, scope: TableQueryScope = 'active') =>
|
||||
[...tableKeys.lists(), workspaceId ?? '', scope] as const,
|
||||
details: () => [...tableKeys.all, 'detail'] as const,
|
||||
detail: (tableId: string) => [...tableKeys.details(), tableId] as const,
|
||||
exportJobs: (workspaceId?: string) =>
|
||||
[...tableKeys.all, 'export-jobs', workspaceId ?? ''] as const,
|
||||
rowsRoot: (tableId: string) => [...tableKeys.detail(tableId), 'rows'] as const,
|
||||
infiniteRows: (tableId: string, paramsKey: string) =>
|
||||
[...tableKeys.rowsRoot(tableId), 'infinite', paramsKey] as const,
|
||||
rowWrites: (tableId: string) => [...tableKeys.rowsRoot(tableId), 'write'] as const,
|
||||
find: (tableId: string, paramsKey: string) =>
|
||||
[...tableKeys.rowsRoot(tableId), 'find', paramsKey] as const,
|
||||
activeDispatches: (tableId: string) =>
|
||||
[...tableKeys.detail(tableId), 'active-dispatches'] as const,
|
||||
enrichmentDetails: (tableId: string) =>
|
||||
[...tableKeys.detail(tableId), 'enrichment-detail'] as const,
|
||||
enrichmentDetail: (tableId: string, rowId: string, groupId: string) =>
|
||||
[...tableKeys.enrichmentDetails(tableId), rowId, groupId] as const,
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
countLoadedTableRows,
|
||||
getNextTableRowsPageParam,
|
||||
hasMoreTableRows,
|
||||
} from '@/hooks/queries/utils/table-rows-pagination'
|
||||
|
||||
function makePage(count: number, totalCount: number | null, startAt = 0, withOrderKey = true) {
|
||||
return {
|
||||
rows: Array.from({ length: count }, (_, i) => ({
|
||||
id: `r${startAt + i}`,
|
||||
...(withOrderKey ? { orderKey: `k${String(startAt + i).padStart(6, '0')}` } : {}),
|
||||
})),
|
||||
totalCount,
|
||||
}
|
||||
}
|
||||
|
||||
describe('countLoadedTableRows', () => {
|
||||
it('sums rows across pages', () => {
|
||||
expect(countLoadedTableRows([])).toBe(0)
|
||||
expect(countLoadedTableRows([makePage(3, 10), makePage(2, null, 3)])).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasMoreTableRows', () => {
|
||||
it('returns false with no pages', () => {
|
||||
expect(hasMoreTableRows([])).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when the last page is empty', () => {
|
||||
expect(hasMoreTableRows([makePage(1000, null), makePage(0, null, 1000)])).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when the page-0 count is covered', () => {
|
||||
expect(hasMoreTableRows([makePage(3, 3)])).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for a short page when the count says more exist', () => {
|
||||
// The regression this module exists for: a page shorter than the requested
|
||||
// size must never be read as end-of-table on its own.
|
||||
expect(hasMoreTableRows([makePage(36, 100)])).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when the count is unknown and the last page is non-empty', () => {
|
||||
expect(hasMoreTableRows([makePage(1000, null)])).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when a stale-low count is already exceeded', () => {
|
||||
expect(hasMoreTableRows([makePage(10, 5)])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNextTableRowsPageParam', () => {
|
||||
it('returns undefined when no more rows exist', () => {
|
||||
expect(getNextTableRowsPageParam([makePage(3, 3)], false)).toBeUndefined()
|
||||
expect(getNextTableRowsPageParam([makePage(1000, null), makePage(0, null)], false)).toBe(
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the keyset cursor of the last loaded row on the default order', () => {
|
||||
const pages = [makePage(1000, 2000), makePage(500, null, 1000)]
|
||||
expect(getNextTableRowsPageParam(pages, false)).toEqual({
|
||||
orderKey: 'k001499',
|
||||
id: 'r1499',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the loaded-row offset for sorted views, even after short pages', () => {
|
||||
const pages = [makePage(1000, 2000), makePage(36, null, 1000)]
|
||||
expect(getNextTableRowsPageParam(pages, true)).toBe(1036)
|
||||
})
|
||||
|
||||
it('falls back to the loaded-row offset when the last row has no order key', () => {
|
||||
const pages = [makePage(700, 2000, 0, false)]
|
||||
expect(getNextTableRowsPageParam(pages, false)).toBe(700)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { TableRowsCursor } from '@/lib/table/types'
|
||||
|
||||
/**
|
||||
* Infinite-rows page param: a keyset cursor on the default `(order_key, id)` order, or a numeric
|
||||
* offset for sorted views / legacy rows without an order key. `0` doubles as the first page.
|
||||
*/
|
||||
export type TableRowsPageParam = number | TableRowsCursor
|
||||
|
||||
interface TableRowsPageLike {
|
||||
rows: ReadonlyArray<{ id: string; orderKey?: string }>
|
||||
totalCount: number | null
|
||||
}
|
||||
|
||||
/** Rows loaded across all fetched pages. */
|
||||
export function countLoadedTableRows(pages: readonly TableRowsPageLike[]): number {
|
||||
return pages.reduce((sum, page) => sum + page.rows.length, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether more rows may exist past the fetched pages. A page is terminal only when it is
|
||||
* empty or when page 0's `COUNT(*)` is already covered — never when it is merely shorter
|
||||
* than the requested page size, so a short server page can never be misread as end-of-table.
|
||||
*
|
||||
* `totalCount` is advisory (computed in a separate transaction from the page read). A
|
||||
* stale-high count self-corrects via the empty-page rule at the cost of one extra request;
|
||||
* a stale-low count (rows deleted after page 0's COUNT) stops the drain early — accepted,
|
||||
* since the view is already stale and the run-stream/interval invalidations refetch it.
|
||||
*/
|
||||
export function hasMoreTableRows(pages: readonly TableRowsPageLike[]): boolean {
|
||||
const lastPage = pages[pages.length - 1]
|
||||
if (!lastPage || lastPage.rows.length === 0) return false
|
||||
const totalCount = pages[0].totalCount
|
||||
return totalCount == null || countLoadedTableRows(pages) < totalCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Continuation for the next page: a keyset cursor from the last loaded row on the default
|
||||
* order, else the absolute offset — the actual loaded-row count, not pages × pageSize, so
|
||||
* short pages resume without gaps.
|
||||
*/
|
||||
export function getNextTableRowsPageParam(
|
||||
pages: readonly TableRowsPageLike[],
|
||||
sorted: boolean
|
||||
): TableRowsPageParam | undefined {
|
||||
if (!hasMoreTableRows(pages)) return undefined
|
||||
const lastPage = pages[pages.length - 1]
|
||||
if (!sorted) {
|
||||
const last = lastPage.rows[lastPage.rows.length - 1]
|
||||
if (last?.orderKey) return { orderKey: last.orderKey, id: last.id }
|
||||
}
|
||||
return countLoadedTableRows(pages)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
interface SortableWorkflow {
|
||||
workspaceId?: string
|
||||
folderId?: string | null
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
interface SortableFolder {
|
||||
workspaceId?: string
|
||||
parentId?: string | null
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the insertion sort order that places a new item at the top of a
|
||||
* mixed list of folders and workflows within the same parent scope.
|
||||
*/
|
||||
export function getTopInsertionSortOrder(
|
||||
workflows: Record<string, SortableWorkflow>,
|
||||
folders: Record<string, SortableFolder>,
|
||||
workspaceId: string,
|
||||
parentId: string | null | undefined
|
||||
): number {
|
||||
const normalizedParentId = parentId ?? null
|
||||
|
||||
const siblingWorkflows = Object.values(workflows).filter(
|
||||
(workflow) =>
|
||||
workflow.workspaceId === workspaceId && (workflow.folderId ?? null) === normalizedParentId
|
||||
)
|
||||
const siblingFolders = Object.values(folders).filter(
|
||||
(folder) =>
|
||||
folder.workspaceId === workspaceId && (folder.parentId ?? null) === normalizedParentId
|
||||
)
|
||||
|
||||
const siblingOrders = [
|
||||
...siblingWorkflows.map((workflow) => workflow.sortOrder ?? 0),
|
||||
...siblingFolders.map((folder) => folder.sortOrder),
|
||||
]
|
||||
|
||||
if (siblingOrders.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.min(...siblingOrders) - 1
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* React Query key factory for the credit usage log.
|
||||
*
|
||||
* Lives in this standalone (non-`'use client'`) module — like
|
||||
* {@link file://./table-keys.ts} — so it can be imported from server
|
||||
* components without pulling in the `'use client'`
|
||||
* `@/hooks/queries/usage-logs` module, whose exports would otherwise
|
||||
* resolve to client-reference stubs on the server.
|
||||
*/
|
||||
|
||||
import type { UsageLogPeriod, UsageLogSource } from '@/lib/api/contracts/user'
|
||||
|
||||
export interface UsageLogDateRange {
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
export const usageLogKeys = {
|
||||
all: ['usage-logs'] as const,
|
||||
lists: () => [...usageLogKeys.all, 'list'] as const,
|
||||
list: (period: UsageLogPeriod, source?: UsageLogSource, dateRange?: UsageLogDateRange) =>
|
||||
[
|
||||
...usageLogKeys.lists(),
|
||||
period,
|
||||
source ?? '',
|
||||
dateRange?.startDate ?? '',
|
||||
dateRange?.endDate ?? '',
|
||||
] as const,
|
||||
summaries: () => [...usageLogKeys.all, 'summary'] as const,
|
||||
summary: (period: UsageLogPeriod) => [...usageLogKeys.summaries(), period] as const,
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getQueryDataMock } = vi.hoisted(() => ({
|
||||
getQueryDataMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/_shell/providers/get-query-client', () => ({
|
||||
getQueryClient: vi.fn(() => ({
|
||||
getQueryData: getQueryDataMock,
|
||||
})),
|
||||
}))
|
||||
|
||||
import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache'
|
||||
|
||||
describe('getWorkflows', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('reads the active workflow list from the cache', () => {
|
||||
const workflows = [{ id: 'wf-1', name: 'Workflow 1' }]
|
||||
getQueryDataMock.mockReturnValue(workflows)
|
||||
|
||||
expect(getWorkflows('ws-1')).toBe(workflows)
|
||||
expect(getQueryDataMock).toHaveBeenCalledWith(['workflows', 'list', 'ws-1', 'active'])
|
||||
})
|
||||
|
||||
it('supports alternate workflow scopes', () => {
|
||||
getQueryDataMock.mockReturnValue([])
|
||||
|
||||
getWorkflows('ws-2', 'archived')
|
||||
|
||||
expect(getQueryDataMock).toHaveBeenCalledWith(['workflows', 'list', 'ws-2', 'archived'])
|
||||
})
|
||||
|
||||
it('reads a single workflow by id from the cache', () => {
|
||||
const workflows = [{ id: 'wf-1', name: 'Workflow 1' }]
|
||||
getQueryDataMock.mockReturnValue(workflows)
|
||||
|
||||
expect(getWorkflowById('ws-1', 'wf-1')).toEqual(workflows[0])
|
||||
expect(getWorkflowById('ws-1', 'missing')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
import { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
|
||||
const EMPTY_WORKFLOWS: WorkflowMetadata[] = []
|
||||
|
||||
/**
|
||||
* Reads workflow metadata for a workspace directly from the React Query cache.
|
||||
*/
|
||||
export function getWorkflows(
|
||||
workspaceId: string,
|
||||
scope: WorkflowQueryScope = 'active'
|
||||
): WorkflowMetadata[] {
|
||||
return (
|
||||
getQueryClient().getQueryData<WorkflowMetadata[]>(workflowKeys.list(workspaceId, scope)) ??
|
||||
EMPTY_WORKFLOWS
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a single workflow by id from the React Query cache.
|
||||
*/
|
||||
export function getWorkflowById(
|
||||
workspaceId: string,
|
||||
workflowId: string,
|
||||
scope: WorkflowQueryScope = 'active'
|
||||
): WorkflowMetadata | undefined {
|
||||
return getWorkflows(workspaceId, scope).find((workflow) => workflow.id === workflowId)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export type WorkflowQueryScope = 'active' | 'archived' | 'all'
|
||||
|
||||
export const workflowKeys = {
|
||||
all: ['workflows'] as const,
|
||||
lists: () => [...workflowKeys.all, 'list'] as const,
|
||||
list: (workspaceId: string | undefined, scope: WorkflowQueryScope = 'active') =>
|
||||
[...workflowKeys.lists(), workspaceId ?? '', scope] as const,
|
||||
deploymentVersions: () => [...workflowKeys.all, 'deploymentVersion'] as const,
|
||||
deploymentVersion: (workflowId: string | undefined, version: number | undefined) =>
|
||||
[...workflowKeys.deploymentVersions(), workflowId ?? '', version ?? 0] as const,
|
||||
states: () => [...workflowKeys.all, 'state'] as const,
|
||||
state: (workflowId: string | undefined) => [...workflowKeys.states(), workflowId ?? ''] as const,
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { QueryFunctionContext } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { listWorkflowsContract, type WorkflowListItem } from '@/lib/api/contracts'
|
||||
import { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
|
||||
type WorkflowApiRow = WorkflowListItem
|
||||
|
||||
export const WORKFLOW_LIST_STALE_TIME = 60 * 1000
|
||||
|
||||
export function mapWorkflow(workflow: WorkflowApiRow): WorkflowMetadata {
|
||||
return {
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
description: workflow.description ?? undefined,
|
||||
workspaceId: workflow.workspaceId ?? undefined,
|
||||
folderId: workflow.folderId,
|
||||
sortOrder: workflow.sortOrder,
|
||||
createdAt: new Date(workflow.createdAt),
|
||||
lastModified: new Date(workflow.updatedAt),
|
||||
archivedAt: workflow.archivedAt ? new Date(workflow.archivedAt) : null,
|
||||
locked: workflow.locked,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWorkflows(
|
||||
workspaceId: string,
|
||||
scope: WorkflowQueryScope = 'active',
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkflowMetadata[]> {
|
||||
const { data } = await requestJson(listWorkflowsContract, {
|
||||
query: { workspaceId, scope },
|
||||
signal,
|
||||
})
|
||||
return data.map(mapWorkflow)
|
||||
}
|
||||
|
||||
export function getWorkflowListQueryOptions(
|
||||
workspaceId: string,
|
||||
scope: WorkflowQueryScope = 'active'
|
||||
) {
|
||||
return {
|
||||
queryKey: workflowKeys.list(workspaceId, scope),
|
||||
queryFn: ({ signal }: QueryFunctionContext) => fetchWorkflows(workspaceId, scope, signal),
|
||||
staleTime: WORKFLOW_LIST_STALE_TIME,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user