mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-16 14:50:06 +08:00
merge: staging into feat/table-views
table_views migration renumbered 0274 -> 0275; staging shipped its own 0274 (file_folder_cutover_reconcile). Regenerated via drizzle-kit, chain verified.
This commit is contained in:
@@ -710,8 +710,19 @@ jobs:
|
||||
# and are always superseded by the next stable. The run-attempt
|
||||
# suffix keeps re-runs of the same workflow from colliding on the
|
||||
# tag while preserving semver ordering.
|
||||
LATEST="$(gh release list --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName' || true)"
|
||||
LATEST="${LATEST:-v0.0.0}"
|
||||
# Fail loudly if the query itself fails: silently falling back to
|
||||
# v0.0.0 would publish a channel build that sorts below the shipped
|
||||
# stable, and installed shells would never see it as an update.
|
||||
if ! LATEST="$(gh release list --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName')"; then
|
||||
echo "::error::Could not query the latest stable release."
|
||||
exit 1
|
||||
fi
|
||||
# An empty release list makes jq print "null", which ${VAR:-default}
|
||||
# does not treat as empty. Anything that is not a bare vX.Y.Z means
|
||||
# "no stable release to build on top of" — start the channel at 0.0.1.
|
||||
if [[ ! "$LATEST" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
LATEST="v0.0.0"
|
||||
fi
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "${LATEST#v}"
|
||||
TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))-${CHANNEL}.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}"
|
||||
NOTES="Automated ${CHANNEL}-channel desktop build from ${GITHUB_REF_NAME} @ ${GITHUB_SHA::7}."
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Sim Desktop (macOS)
|
||||
|
||||
A thin Electron shell around the hosted Sim web app. The renderer loads the configured origin (default `https://sim.ai`) as a normal top-level page in a bundled, pinned Chromium — rendering is identical to Chrome of that version on every machine. No UI is re-implemented and no server stack is bundled.
|
||||
A thin Electron shell around the hosted Sim web app. The renderer loads the configured origin (default `https://www.sim.ai` — the origin the server actually serves; the apex 301s there) as a normal top-level page in a bundled, pinned Chromium — rendering is identical to Chrome of that version on every machine. No UI is re-implemented and no server stack is bundled.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -44,7 +44,7 @@ e2e/ # Playwright _electron smoke suite
|
||||
```bash
|
||||
bun install # workspace root
|
||||
cd apps/desktop
|
||||
bun run dev # bundle + launch against https://sim.ai
|
||||
bun run dev # bundle + launch against https://www.sim.ai
|
||||
SIM_DESKTOP_ORIGIN=http://localhost:3000 bun run dev # against local sim
|
||||
```
|
||||
|
||||
@@ -74,7 +74,7 @@ Deviations from the original plan doc (deliberate):
|
||||
|
||||
## Provider matrix (U5 spike — keep current)
|
||||
|
||||
Host lists live in `src/main/navigation.ts` (`SYSTEM_BROWSER_IDP_HOSTS`, `IN_WINDOW_IDP_HOSTS`). Verified so far: Google + Microsoft blocked (by policy, not spike); GitHub assumed lenient. **Before GA, run the spike**: GitHub sign-in, consumer-Microsoft, a sample of integration connects (Notion, Slack, Linear, Atlassian, Box, Dropbox), SSO, and Turnstile-on-signup in a packaged build, then update the lists and this section.
|
||||
The host list lives in `src/main/navigation.ts` (`SYSTEM_BROWSER_IDP_HOSTS`) and now applies to **integration connects only** — sign-in always goes through the system-browser handoff whatever the IdP, so no provider needs to be classified as embed-tolerant for login. (The old `IN_WINDOW_IDP_HOSTS` list existed to keep GitHub sign-in in-window; embedding a login is a one-way door in a chrome-less shell and splits better-auth's OAuth state across two cookie jars, so it was removed.) Verified so far: Google + Microsoft block embedding by policy. **Before GA, run the spike** for connects: a sample of integration connects (Notion, Slack, Linear, Atlassian, Box, Dropbox) plus SSO and Turnstile-on-signup in a packaged build, then update the list and this section.
|
||||
|
||||
## Web-app coupling contract (audit on web-app changes)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ const watch = process.argv.includes('--watch')
|
||||
// non-prod environment): SIM_DESKTOP_DEFAULT_ORIGIN=https://www.dev.sim.ai.
|
||||
// Baked into the bundle so it applies to fresh installs with no settings —
|
||||
// unlike the SIM_DESKTOP_ORIGIN env var, which only affects terminal-launched
|
||||
// processes. Official builds leave it unset (default https://sim.ai).
|
||||
// processes. Official builds leave it unset (default https://www.sim.ai).
|
||||
const bakedDefaultOrigin = process.env.SIM_DESKTOP_DEFAULT_ORIGIN ?? ''
|
||||
if (
|
||||
bakedDefaultOrigin &&
|
||||
|
||||
@@ -40,9 +40,11 @@ const CHANNEL_FLAGS: Record<string, ChannelIdentity> = {
|
||||
appId: 'ai.sim.desktop.staging',
|
||||
origin: 'https://www.staging.sim.ai',
|
||||
},
|
||||
// Bare sim.ai, matching official prod builds (config.ts) — www.sim.ai
|
||||
// would land in a different cookie partition than a real install.
|
||||
'--prod': { name: 'Sim', appId: 'ai.sim.desktop', origin: 'https://sim.ai' },
|
||||
// Origin left unset, exactly as official prod builds do, so this install
|
||||
// resolves the same DEFAULT_ORIGIN (config.ts) and therefore the same cookie
|
||||
// partition as a real one. Naming an origin here re-creates the drift that
|
||||
// pinned prod to the apex while the server serves www.
|
||||
'--prod': { name: 'Sim', appId: 'ai.sim.desktop' },
|
||||
}
|
||||
|
||||
const DEFAULT_IDENTITY: ChannelIdentity = { name: 'Sim', appId: 'ai.sim.desktop' }
|
||||
|
||||
@@ -15,6 +15,13 @@ const logger = createLogger('DesktopConfig')
|
||||
* define replaces the env read at bundle time, so a packaged app never
|
||||
* consults the runtime environment for this. http is accepted only for
|
||||
* loopback origins (the localhost dev-server channel).
|
||||
*
|
||||
* This must be the origin the server actually *serves*, not a hostname that
|
||||
* redirects to it. Every environment canonicalizes the apex to `www` (the load
|
||||
* balancer 301s `sim.ai` → `www.sim.ai`), and the navigation guards compare
|
||||
* origins for exact equality — so an apex origin here silently leaves every
|
||||
* page off-origin, which reclassifies social login as an integration connect
|
||||
* and strands the sign-in in the browser with no way back.
|
||||
*/
|
||||
function isValidBakedOrigin(origin: string | undefined): origin is string {
|
||||
if (!origin) return false
|
||||
@@ -29,7 +36,7 @@ function isValidBakedOrigin(origin: string | undefined): origin is string {
|
||||
|
||||
export const DEFAULT_ORIGIN = isValidBakedOrigin(process.env.SIM_DESKTOP_DEFAULT_ORIGIN)
|
||||
? process.env.SIM_DESKTOP_DEFAULT_ORIGIN
|
||||
: 'https://sim.ai'
|
||||
: 'https://www.sim.ai'
|
||||
|
||||
/**
|
||||
* The environment a build is keyed to, derived from its baked default origin.
|
||||
|
||||
@@ -74,6 +74,7 @@ describe('buildMenuTemplate', () => {
|
||||
expect(submenu(template, 'View').map((item) => item.label ?? item.role ?? item.type)).toEqual([
|
||||
'Toggle Sidebar',
|
||||
'separator',
|
||||
'Back',
|
||||
'Reload',
|
||||
'separator',
|
||||
'Actual Size',
|
||||
|
||||
@@ -55,6 +55,21 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
|
||||
click: deps.toggleSidebar,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
/**
|
||||
* The shell has no browser chrome, so an in-window integration connect
|
||||
* that leaves the app origin (the IdP's consent page) is otherwise a
|
||||
* one-way door for anyone who decides not to finish it.
|
||||
*/
|
||||
{
|
||||
label: 'Back',
|
||||
accelerator: 'CmdOrCtrl+[',
|
||||
click: withWindow((win) => {
|
||||
const history = win.webContents.navigationHistory
|
||||
if (history.canGoBack()) {
|
||||
history.goBack()
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Reload',
|
||||
accelerator: 'CmdOrCtrl+R',
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
openExternalSafe,
|
||||
} from '@/main/navigation'
|
||||
|
||||
const APP = 'https://sim.ai'
|
||||
const APP = 'https://www.sim.ai'
|
||||
|
||||
describe('classifyNavigation', () => {
|
||||
it('keeps same-origin navigation in-app', () => {
|
||||
@@ -65,12 +65,24 @@ describe('classifyNavigation', () => {
|
||||
).toBe('idp-system-connect')
|
||||
})
|
||||
|
||||
it('keeps verified-lenient IdPs in-window from the login surface', () => {
|
||||
it('routes every sign-in to the system browser, including IdPs that tolerate embedding', () => {
|
||||
// GitHub used to be kept in-window. Embedded, it is a one-way door (no
|
||||
// browser chrome to back out of) and it splits better-auth's OAuth state
|
||||
// cookie across two user agents, which comes back `state_mismatch`.
|
||||
expect(
|
||||
classifyNavigation('https://github.com/login/oauth/authorize?client_id=x', {
|
||||
appOrigin: APP,
|
||||
currentUrl: `${APP}/login`,
|
||||
})
|
||||
).toBe('idp-system-login')
|
||||
})
|
||||
|
||||
it('keeps the same IdP in-window when it is an integration connect', () => {
|
||||
expect(
|
||||
classifyNavigation('https://github.com/login/oauth/authorize?client_id=x', {
|
||||
appOrigin: APP,
|
||||
currentUrl: `${APP}/workspace/ws1/integrations/github`,
|
||||
})
|
||||
).toBe('idp-in-window')
|
||||
})
|
||||
|
||||
|
||||
@@ -24,10 +24,10 @@ export interface NavigationContext {
|
||||
}
|
||||
|
||||
/**
|
||||
* IdP hosts that hard-block OAuth inside embedded user agents. Navigation to
|
||||
* these hosts is cancelled and rerouted: from an auth surface the app starts
|
||||
* the system-browser login handoff; from anywhere else it offers to finish the
|
||||
* integration connect in the browser (tokens land server-side either way).
|
||||
* IdP hosts that hard-block OAuth inside embedded user agents. An integration
|
||||
* connect heading for one of these is cancelled and offered as a finish-in-the-
|
||||
* browser flow instead (tokens land server-side either way). Sign-in never
|
||||
* consults this list — see {@link classifyNavigation}.
|
||||
*/
|
||||
export const SYSTEM_BROWSER_IDP_HOSTS: readonly string[] = [
|
||||
'accounts.google.com',
|
||||
@@ -38,14 +38,6 @@ export const SYSTEM_BROWSER_IDP_HOSTS: readonly string[] = [
|
||||
'sts.windows.net',
|
||||
]
|
||||
|
||||
/**
|
||||
* IdP hosts verified lenient toward embedded user agents (the U5 provider
|
||||
* matrix). Only consulted for navigations leaving an auth surface — everywhere
|
||||
* else unknown hosts already stay in-window because same-window departures
|
||||
* from workspace pages are OAuth connect flows in this app.
|
||||
*/
|
||||
export const IN_WINDOW_IDP_HOSTS: readonly string[] = ['github.com']
|
||||
|
||||
const AUTH_SURFACE_PREFIXES: readonly string[] = [
|
||||
'/login',
|
||||
'/signup',
|
||||
@@ -101,11 +93,21 @@ export function isAuthSurfacePath(pathname: string): boolean {
|
||||
* Classifies a top-level navigation (will-navigate / will-redirect).
|
||||
*
|
||||
* Same-window departures to non-origin hosts are OAuth flows in this app —
|
||||
* regular external links always go through window.open — so unknown hosts stay
|
||||
* in-window (lenient IdP assumption) except for the known embedded-blocking
|
||||
* hosts, which are rerouted through the system browser. Departures from auth
|
||||
* surfaces default to the system browser because SSO IdPs need real-browser
|
||||
* device claims.
|
||||
* regular external links always go through window.open.
|
||||
*
|
||||
* A departure from an auth surface is a *sign-in*, and sign-in always runs in
|
||||
* the system browser (RFC 8252), whatever the IdP. Embedding one is a one-way
|
||||
* door: the shell has no browser chrome, so a user who reaches the IdP and
|
||||
* decides not to continue has no way back to the app. It also splits the flow
|
||||
* across two cookie jars — better-auth binds its OAuth state cookie to the user
|
||||
* agent that started the flow, so an in-window start that finishes anywhere
|
||||
* else comes back `state_mismatch`. The handoff keeps the whole flow in one
|
||||
* jar and hands a one-time token back over the loopback.
|
||||
*
|
||||
* Everything else is an integration connect from a workspace page: those stay
|
||||
* in-window (the session cookie is already in this partition) unless the IdP
|
||||
* hard-blocks embedded user agents, which is what {@link
|
||||
* SYSTEM_BROWSER_IDP_HOSTS} enumerates.
|
||||
*/
|
||||
export function classifyNavigation(rawUrl: string, ctx: NavigationContext): MainNavigationAction {
|
||||
if (rawUrl === 'about:blank') {
|
||||
@@ -125,16 +127,13 @@ export function classifyNavigation(rawUrl: string, ctx: NavigationContext): Main
|
||||
const fromAuthSurface = current
|
||||
? current.origin === ctx.appOrigin && isAuthSurfacePath(current.pathname)
|
||||
: false
|
||||
if (fromAuthSurface) {
|
||||
return 'idp-system-login'
|
||||
}
|
||||
if (matchesHostList(url.hostname, SYSTEM_BROWSER_IDP_HOSTS)) {
|
||||
return fromAuthSurface ? 'idp-system-login' : 'idp-system-connect'
|
||||
return 'idp-system-connect'
|
||||
}
|
||||
if (matchesHostList(url.hostname, IN_WINDOW_IDP_HOSTS)) {
|
||||
return 'idp-in-window'
|
||||
}
|
||||
if (current && current.origin !== ctx.appOrigin) {
|
||||
return 'idp-in-window'
|
||||
}
|
||||
return fromAuthSurface ? 'idp-system-login' : 'idp-in-window'
|
||||
return 'idp-in-window'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,8 +3,9 @@ import { db } from '@sim/db'
|
||||
import { folder as folderTable, workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { FolderLockedError } from '@sim/platform-authz/workflow'
|
||||
import { getPostgresErrorCode } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, isNull, min } from 'drizzle-orm'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { duplicateFolderContract } from '@/lib/api/contracts'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
@@ -12,6 +13,7 @@ import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { nextFolderSortOrder } from '@/lib/folders/lifecycle'
|
||||
import { deduplicateFolderName } from '@/lib/folders/naming'
|
||||
import { toFolderApi } from '@/lib/folders/queries'
|
||||
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
|
||||
@@ -19,6 +21,30 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('FolderDuplicateAPI')
|
||||
|
||||
/**
|
||||
* Duplication only ever copies workflow folders. Named once so the scope is stated rather
|
||||
* than restated as a literal at every query — the engine reads its resourceType from config
|
||||
* for exactly this reason.
|
||||
*/
|
||||
const FOLDER_RESOURCE_TYPE = 'workflow' as const
|
||||
|
||||
/**
|
||||
* Carries the HTTP status with the failure, so the handler maps errors by type instead of by
|
||||
* comparing `error.message` to a literal — a coupling that breaks silently the moment
|
||||
* someone rewords a message.
|
||||
*/
|
||||
class FolderDuplicationError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
/** Message returned to the caller when it must differ from the logged one. */
|
||||
readonly publicMessage: string = message
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'FolderDuplicationError'
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/folders/[id]/duplicate - Duplicate a folder with all its child folders and workflows
|
||||
export const POST = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
@@ -46,13 +72,13 @@ export const POST = withRouteHandler(
|
||||
and(
|
||||
eq(folderTable.id, sourceFolderId),
|
||||
isNull(folderTable.deletedAt),
|
||||
eq(folderTable.resourceType, 'workflow')
|
||||
eq(folderTable.resourceType, FOLDER_RESOURCE_TYPE)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!sourceFolder) {
|
||||
throw new Error('Source folder not found')
|
||||
throw new FolderDuplicationError('Source folder not found', 404)
|
||||
}
|
||||
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
@@ -62,12 +88,16 @@ export const POST = withRouteHandler(
|
||||
)
|
||||
|
||||
if (!userPermission || userPermission === 'read') {
|
||||
throw new Error('Source folder not found or access denied')
|
||||
throw new FolderDuplicationError(
|
||||
'Source folder not found or access denied',
|
||||
403,
|
||||
'Access denied'
|
||||
)
|
||||
}
|
||||
|
||||
const targetWorkspaceId = workspaceId || sourceFolder.workspaceId
|
||||
if (targetWorkspaceId !== sourceFolder.workspaceId) {
|
||||
throw new Error('Cross-workspace folder duplication is not supported')
|
||||
throw new FolderDuplicationError('Cross-workspace folder duplication is not supported', 400)
|
||||
}
|
||||
|
||||
const { newFolderId, folderMapping, workflowStats } = await db.transaction(async (tx) => {
|
||||
@@ -76,58 +106,55 @@ export const POST = withRouteHandler(
|
||||
const targetParentId = parentId ?? sourceFolder.parentId
|
||||
await assertTargetParentFolderMutable(tx, targetParentId, targetWorkspaceId, sourceFolderId)
|
||||
|
||||
const folderParentCondition = targetParentId
|
||||
? eq(folderTable.parentId, targetParentId)
|
||||
: isNull(folderTable.parentId)
|
||||
const workflowParentCondition = targetParentId
|
||||
? eq(workflow.folderId, targetParentId)
|
||||
: isNull(workflow.folderId)
|
||||
|
||||
const [[folderResult], [workflowResult]] = await Promise.all([
|
||||
// Placement is the engine's rule (folders and workflows share one ordering space),
|
||||
// so it is read from there rather than recomputed here.
|
||||
const sortOrder = await nextFolderSortOrder(
|
||||
FOLDER_RESOURCE_TYPE,
|
||||
targetWorkspaceId,
|
||||
targetParentId,
|
||||
tx
|
||||
.select({ minSortOrder: min(folderTable.sortOrder) })
|
||||
.from(folderTable)
|
||||
.where(
|
||||
and(
|
||||
eq(folderTable.workspaceId, targetWorkspaceId),
|
||||
eq(folderTable.resourceType, 'workflow'),
|
||||
folderParentCondition
|
||||
)
|
||||
),
|
||||
tx
|
||||
.select({ minSortOrder: min(workflow.sortOrder) })
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.workspaceId, targetWorkspaceId), workflowParentCondition)),
|
||||
])
|
||||
)
|
||||
|
||||
const minSortOrder = [folderResult?.minSortOrder, workflowResult?.minSortOrder].reduce<
|
||||
number | null
|
||||
>((currentMin, candidate) => {
|
||||
if (candidate == null) return currentMin
|
||||
if (currentMin == null) return candidate
|
||||
return Math.min(currentMin, candidate)
|
||||
}, null)
|
||||
const sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
|
||||
const deduplicatedName = await deduplicateFolderName(
|
||||
tx,
|
||||
targetWorkspaceId,
|
||||
targetParentId,
|
||||
name,
|
||||
'workflow'
|
||||
FOLDER_RESOURCE_TYPE
|
||||
)
|
||||
|
||||
await tx.insert(folderTable).values({
|
||||
id: newFolderId,
|
||||
resourceType: 'workflow',
|
||||
userId: session.user.id,
|
||||
workspaceId: targetWorkspaceId,
|
||||
name: deduplicatedName,
|
||||
parentId: targetParentId,
|
||||
sortOrder,
|
||||
locked: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
try {
|
||||
await tx.insert(folderTable).values({
|
||||
id: newFolderId,
|
||||
resourceType: FOLDER_RESOURCE_TYPE,
|
||||
userId: session.user.id,
|
||||
workspaceId: targetWorkspaceId,
|
||||
name: deduplicatedName,
|
||||
parentId: targetParentId,
|
||||
sortOrder,
|
||||
locked: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
} catch (insertError) {
|
||||
/**
|
||||
* Scoped to THIS insert on purpose. A 23505 here is one of two real conflicts the
|
||||
* caller can act on: `newId` is client-supplied, so replaying a duplicate whose
|
||||
* response was lost hits the primary key; and `deduplicateFolderName` runs before
|
||||
* the write, so a concurrent create can still take the name in between.
|
||||
*
|
||||
* Catching 23505 across the whole handler instead would relabel any unique violation
|
||||
* raised while copying the workflows inside the folder — a different constraint, on a
|
||||
* different table — as a folder-name conflict, which is both wrong and misleading.
|
||||
* Those keep falling through to the generic 500.
|
||||
*/
|
||||
if (getPostgresErrorCode(insertError) !== '23505') throw insertError
|
||||
throw new FolderDuplicationError(
|
||||
`Folder duplication conflicted for ${sourceFolderId}`,
|
||||
409,
|
||||
'A folder with this name already exists in this location'
|
||||
)
|
||||
}
|
||||
|
||||
const folderMapping = new Map<string, string>([[sourceFolderId, newFolderId]])
|
||||
await duplicateFolderStructure(
|
||||
@@ -183,41 +210,23 @@ export const POST = withRouteHandler(
|
||||
const duplicatedFolder = await db
|
||||
.select()
|
||||
.from(folderTable)
|
||||
.where(and(eq(folderTable.id, newFolderId), eq(folderTable.resourceType, 'workflow')))
|
||||
.where(
|
||||
and(eq(folderTable.id, newFolderId), eq(folderTable.resourceType, FOLDER_RESOURCE_TYPE))
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
return NextResponse.json({ folder: toFolderApi(duplicatedFolder) }, { status: 201 })
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error instanceof FolderLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
if (error instanceof FolderLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
if (error.message === 'Source folder not found') {
|
||||
logger.warn(`[${requestId}] Source folder ${sourceFolderId} not found`)
|
||||
return NextResponse.json({ error: 'Source folder not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (error.message === 'Source folder not found or access denied') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} denied access to source folder ${sourceFolderId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (error.message === 'Cross-workspace folder duplication is not supported') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} attempted cross-workspace folder duplication for ${sourceFolderId}`
|
||||
)
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
|
||||
if (
|
||||
error.message === 'Target parent folder not found' ||
|
||||
error.message === 'Cannot duplicate folder into itself or one of its descendants'
|
||||
) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
if (error instanceof FolderDuplicationError) {
|
||||
logger.warn(`[${requestId}] Folder duplication rejected: ${error.message}`, {
|
||||
sourceFolderId,
|
||||
userId: session.user.id,
|
||||
})
|
||||
return NextResponse.json({ error: error.publicMessage }, { status: error.status })
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
@@ -250,14 +259,19 @@ async function assertTargetParentFolderMutable(
|
||||
archivedAt: folderTable.deletedAt,
|
||||
})
|
||||
.from(folderTable)
|
||||
.where(and(eq(folderTable.id, currentFolderId), eq(folderTable.resourceType, 'workflow')))
|
||||
.where(
|
||||
and(eq(folderTable.id, currentFolderId), eq(folderTable.resourceType, FOLDER_RESOURCE_TYPE))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!folder || folder.workspaceId !== targetWorkspaceId || folder.archivedAt) {
|
||||
throw new Error('Target parent folder not found')
|
||||
throw new FolderDuplicationError('Target parent folder not found', 400)
|
||||
}
|
||||
if (folder.id === sourceFolderId) {
|
||||
throw new Error('Cannot duplicate folder into itself or one of its descendants')
|
||||
throw new FolderDuplicationError(
|
||||
'Cannot duplicate folder into itself or one of its descendants',
|
||||
400
|
||||
)
|
||||
}
|
||||
if (folder.locked) {
|
||||
throw new FolderLockedError()
|
||||
@@ -284,7 +298,7 @@ async function duplicateFolderStructure(
|
||||
and(
|
||||
eq(folderTable.parentId, sourceFolderId),
|
||||
eq(folderTable.workspaceId, sourceWorkspaceId),
|
||||
eq(folderTable.resourceType, 'workflow'),
|
||||
eq(folderTable.resourceType, FOLDER_RESOURCE_TYPE),
|
||||
isNull(folderTable.deletedAt)
|
||||
)
|
||||
)
|
||||
@@ -295,7 +309,7 @@ async function duplicateFolderStructure(
|
||||
|
||||
await tx.insert(folderTable).values({
|
||||
id: newChildFolderId,
|
||||
resourceType: 'workflow',
|
||||
resourceType: FOLDER_RESOURCE_TYPE,
|
||||
userId,
|
||||
workspaceId: targetWorkspaceId,
|
||||
name: childFolder.name,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { db } from '@sim/db'
|
||||
import { folder as folderTable } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { deleteFolderContract, updateFolderContract } from '@/lib/api/contracts'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
@@ -45,10 +45,22 @@ export const PUT = withRouteHandler(
|
||||
const { resourceType } = parsed.data.query
|
||||
const { name, locked, parentId, sortOrder } = parsed.data.body
|
||||
|
||||
/**
|
||||
* `isNull(deletedAt)` is load-bearing, not tidiness: `getFolderLockStatus` skips
|
||||
* archived rows, so an archived-but-locked folder reports unlocked. Without this
|
||||
* filter, deleting a folder makes every locked subfolder under it freely renameable
|
||||
* and reparentable by any write-level member.
|
||||
*/
|
||||
const existingFolder = await db
|
||||
.select()
|
||||
.from(folderTable)
|
||||
.where(and(eq(folderTable.id, id), eq(folderTable.resourceType, resourceType)))
|
||||
.where(
|
||||
and(
|
||||
eq(folderTable.id, id),
|
||||
eq(folderTable.resourceType, resourceType),
|
||||
isNull(folderTable.deletedAt)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!existingFolder) {
|
||||
@@ -143,6 +155,12 @@ export const DELETE = withRouteHandler(
|
||||
const { id } = parsed.data.params
|
||||
const { resourceType } = parsed.data.query
|
||||
|
||||
/**
|
||||
* Deliberately NOT filtered on `deletedAt`, unlike PUT above: `deleteFolder` reuses an
|
||||
* already-archived folder's own `deletedAt` so a cascade that failed partway can be
|
||||
* retried onto the same snapshot. 404ing here would strand those stragglers. Delete is
|
||||
* also idempotent, so re-reaching an archived folder grants nothing new.
|
||||
*/
|
||||
const existingFolder = await db
|
||||
.select()
|
||||
.from(folderTable)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { folder as folderTable } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow'
|
||||
import { getPostgresErrorCode } from '@sim/utils/errors'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { reorderFoldersContract } from '@/lib/api/contracts'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
@@ -38,10 +38,24 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
|
||||
}
|
||||
|
||||
const folderIds = updates.map((u) => u.id)
|
||||
/**
|
||||
* Archived folders are excluded here for the same reason `PUT /api/folders/[id]` excludes
|
||||
* them: `getFolderLockStatus` skips archived rows, so `assertFolderMutable` below is a
|
||||
* guaranteed no-op on one — meaning a locked folder becomes freely reparentable the moment
|
||||
* its parent is deleted. Reordering an archived folder is also a correctness problem in its
|
||||
* own right: `collectArchivedSubtreeIds` walks the cascade by parent, so moving a branch out
|
||||
* of an archived subtree silently drops it from that folder's restore.
|
||||
*/
|
||||
const existingFolders = await db
|
||||
.select({ id: folderTable.id, workspaceId: folderTable.workspaceId })
|
||||
.from(folderTable)
|
||||
.where(and(inArray(folderTable.id, folderIds), eq(folderTable.resourceType, resourceType)))
|
||||
.where(
|
||||
and(
|
||||
inArray(folderTable.id, folderIds),
|
||||
eq(folderTable.resourceType, resourceType),
|
||||
isNull(folderTable.deletedAt)
|
||||
)
|
||||
)
|
||||
|
||||
const validIds = new Set(
|
||||
existingFolders.filter((f) => f.workspaceId === workspaceId).map((f) => f.id)
|
||||
@@ -138,7 +152,13 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
|
||||
await tx
|
||||
.update(folderTable)
|
||||
.set(updateData)
|
||||
.where(and(eq(folderTable.id, update.id), eq(folderTable.resourceType, resourceType)))
|
||||
.where(
|
||||
and(
|
||||
eq(folderTable.id, update.id),
|
||||
eq(folderTable.resourceType, resourceType),
|
||||
isNull(folderTable.deletedAt)
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
deleteKnowledgeBase,
|
||||
getKnowledgeBaseById,
|
||||
KnowledgeBaseConflictError,
|
||||
KnowledgeBaseFolderError,
|
||||
KnowledgeBasePermissionError,
|
||||
updateKnowledgeBase,
|
||||
} from '@/lib/knowledge/service'
|
||||
@@ -100,6 +101,7 @@ export const PUT = withRouteHandler(
|
||||
name: validatedData.name,
|
||||
description: validatedData.description,
|
||||
workspaceId: validatedData.workspaceId,
|
||||
folderId: validatedData.folderId,
|
||||
chunkingConfig: validatedData.chunkingConfig,
|
||||
},
|
||||
requestId,
|
||||
@@ -143,6 +145,9 @@ export const PUT = withRouteHandler(
|
||||
if (error instanceof KnowledgeBaseConflictError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 409 })
|
||||
}
|
||||
if (error instanceof KnowledgeBaseFolderError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
if (error instanceof KnowledgeBasePermissionError) {
|
||||
logger.warn(`[${requestId}] Forbidden knowledge base update on ${id}: ${error.message}`)
|
||||
return NextResponse.json({ error: error.message }, { status: 403 })
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
createKnowledgeBase,
|
||||
getKnowledgeBases,
|
||||
KnowledgeBaseConflictError,
|
||||
KnowledgeBaseFolderError,
|
||||
KnowledgeBasePermissionError,
|
||||
type KnowledgeBaseScope,
|
||||
} from '@/lib/knowledge/service'
|
||||
@@ -160,6 +161,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
if (createError instanceof KnowledgeBaseConflictError) {
|
||||
return NextResponse.json({ error: createError.message }, { status: 409 })
|
||||
}
|
||||
if (createError instanceof KnowledgeBaseFolderError) {
|
||||
return NextResponse.json({ error: createError.message }, { status: 400 })
|
||||
}
|
||||
if (createError instanceof KnowledgeBasePermissionError) {
|
||||
logger.warn(`[${requestId}] Forbidden knowledge base creation: ${createError.message}`)
|
||||
return NextResponse.json({ error: createError.message }, { status: 403 })
|
||||
|
||||
@@ -4,7 +4,12 @@ import { getPostgresErrorCode } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { createPinnedItemContract, listPinnedItemsContract } from '@/lib/api/contracts'
|
||||
import {
|
||||
createPinnedItemContract,
|
||||
listPinnedItemsContract,
|
||||
type PinnedItemApi,
|
||||
pinnedResourceTypeSchema,
|
||||
} from '@/lib/api/contracts'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
@@ -13,8 +18,22 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('PinnedItemsAPI')
|
||||
|
||||
function toPinnedItemApi(row: typeof pinnedItem.$inferSelect) {
|
||||
return { ...row, pinnedAt: row.pinnedAt.toISOString() }
|
||||
/**
|
||||
* Narrows a stored row to the wire shape, dropping any row whose `resourceType` this build does
|
||||
* not recognise.
|
||||
*
|
||||
* `pinned_item.resource_type` is plain `text` — deliberately, so the set of pinnable kinds can
|
||||
* grow — while the contract is a closed enum. During a rolling deploy an older pod can therefore
|
||||
* read a pin a newer one wrote. Returning it would fail response validation and take the WHOLE
|
||||
* list down rather than the single row, so the unknown kind is skipped instead.
|
||||
*
|
||||
* `filterToActiveResources` already drops these as a side effect of not having a table to look
|
||||
* them up in; this makes the guarantee explicit and compiler-checked at the wire boundary.
|
||||
*/
|
||||
function toPinnedItemApi(row: typeof pinnedItem.$inferSelect): PinnedItemApi | null {
|
||||
const resourceType = pinnedResourceTypeSchema.safeParse(row.resourceType)
|
||||
if (!resourceType.success) return null
|
||||
return { ...row, resourceType: resourceType.data, pinnedAt: row.pinnedAt.toISOString() }
|
||||
}
|
||||
|
||||
/** Lists the session user's pinned items in a workspace, optionally filtered to one `resourceType`. */
|
||||
@@ -46,7 +65,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
|
||||
const activeRows = await filterToActiveResources(rows, workspaceId)
|
||||
|
||||
return NextResponse.json({ pinnedItems: activeRows.map(toPinnedItemApi) })
|
||||
const pinnedItems = activeRows
|
||||
.map(toPinnedItemApi)
|
||||
.filter((item): item is PinnedItemApi => item !== null)
|
||||
|
||||
return NextResponse.json({ pinnedItems })
|
||||
})
|
||||
|
||||
/** Pins a resource for the session user. Idempotent from the client's perspective: re-pinning returns 409. */
|
||||
@@ -81,7 +104,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
})
|
||||
.returning()
|
||||
|
||||
return NextResponse.json({ pinnedItem: toPinnedItemApi(created) }, { status: 201 })
|
||||
/**
|
||||
* Built from the validated `resourceType` rather than round-tripping the raw row through
|
||||
* `toPinnedItemApi`: the value came from the contract enum, so narrowing here could never
|
||||
* fail, and threading a `| null` through a path that cannot produce one would only obscure
|
||||
* that.
|
||||
*/
|
||||
const pinned: PinnedItemApi = {
|
||||
...created,
|
||||
resourceType,
|
||||
pinnedAt: created.pinnedAt.toISOString(),
|
||||
}
|
||||
return NextResponse.json({ pinnedItem: pinned }, { status: 201 })
|
||||
} catch (error) {
|
||||
if (getPostgresErrorCode(error) === '23505') {
|
||||
return NextResponse.json({ error: 'This item is already pinned' }, { status: 409 })
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { hybridAuthMockFns } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockCheckAccess,
|
||||
mockDeleteTable,
|
||||
mockGetTableById,
|
||||
mockMoveTableToFolder,
|
||||
mockRenameTable,
|
||||
mockUpdateTableLocks,
|
||||
mockFindActiveFolder,
|
||||
mockGetLimits,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCheckAccess: vi.fn(),
|
||||
mockDeleteTable: vi.fn(),
|
||||
mockGetTableById: vi.fn(),
|
||||
mockMoveTableToFolder: vi.fn(),
|
||||
mockRenameTable: vi.fn(),
|
||||
mockUpdateTableLocks: vi.fn(),
|
||||
mockFindActiveFolder: vi.fn(),
|
||||
mockGetLimits: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/table', () => ({
|
||||
deleteTable: mockDeleteTable,
|
||||
getTableById: mockGetTableById,
|
||||
moveTableToFolder: mockMoveTableToFolder,
|
||||
renameTable: mockRenameTable,
|
||||
updateTableLocks: mockUpdateTableLocks,
|
||||
TableConflictError: class extends Error {},
|
||||
}))
|
||||
vi.mock('@/lib/table/billing', () => ({ getWorkspaceTableLimits: mockGetLimits }))
|
||||
vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mockFindActiveFolder }))
|
||||
vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: vi.fn() }))
|
||||
vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() }))
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
getWorkspaceWithOwner: vi.fn(),
|
||||
getUserEntityPermissions: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/app/api/table/utils', () => ({
|
||||
accessError: () => new Response('denied', { status: 403 }),
|
||||
checkAccess: mockCheckAccess,
|
||||
normalizeColumn: (column: unknown) => column,
|
||||
tableLockErrorResponse: () => null,
|
||||
}))
|
||||
|
||||
import { PATCH } from '@/app/api/table/[tableId]/route'
|
||||
|
||||
const TABLE = {
|
||||
id: 'tbl_1',
|
||||
name: 'people',
|
||||
workspaceId: 'workspace-1',
|
||||
folderId: null as string | null,
|
||||
schema: { columns: [] },
|
||||
locks: {
|
||||
schemaLocked: false,
|
||||
insertLocked: false,
|
||||
updateLocked: false,
|
||||
deleteLocked: false,
|
||||
},
|
||||
}
|
||||
|
||||
function patchRequest(body: unknown): NextRequest {
|
||||
return new NextRequest('http://localhost:3000/api/table/tbl_1', {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
const routeContext = { params: Promise.resolve({ tableId: 'tbl_1' }) }
|
||||
|
||||
describe('PATCH /api/table/[tableId] folder moves', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
authType: 'session',
|
||||
})
|
||||
mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE })
|
||||
mockGetTableById.mockResolvedValue({ ...TABLE, folderId: 'folder-1' })
|
||||
mockFindActiveFolder.mockResolvedValue({ id: 'folder-1' })
|
||||
})
|
||||
|
||||
it('moves the table into a folder in the same workspace and tree', async () => {
|
||||
const response = await PATCH(
|
||||
patchRequest({ workspaceId: 'workspace-1', folderId: 'folder-1' }),
|
||||
routeContext
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockFindActiveFolder).toHaveBeenCalledWith('folder-1', 'workspace-1', 'table')
|
||||
expect(mockMoveTableToFolder).toHaveBeenCalledWith(
|
||||
'tbl_1',
|
||||
'workspace-1',
|
||||
'folder-1',
|
||||
expect.any(String),
|
||||
'user-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('moves the table to the workspace root on an explicit null, with no folder lookup', async () => {
|
||||
mockGetTableById.mockResolvedValue({ ...TABLE, folderId: null })
|
||||
|
||||
const response = await PATCH(
|
||||
patchRequest({ workspaceId: 'workspace-1', folderId: null }),
|
||||
routeContext
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockFindActiveFolder).not.toHaveBeenCalled()
|
||||
expect(mockMoveTableToFolder).toHaveBeenCalledWith(
|
||||
'tbl_1',
|
||||
'workspace-1',
|
||||
null,
|
||||
expect.any(String),
|
||||
'user-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves placement untouched when folderId is omitted', async () => {
|
||||
await PATCH(patchRequest({ workspaceId: 'workspace-1', name: 'renamed' }), routeContext)
|
||||
|
||||
expect(mockRenameTable).toHaveBeenCalled()
|
||||
expect(mockMoveTableToFolder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a folder from another workspace or resource tree without writing', async () => {
|
||||
mockFindActiveFolder.mockResolvedValue(null)
|
||||
|
||||
const response = await PATCH(
|
||||
patchRequest({ workspaceId: 'workspace-1', folderId: 'kb-folder' }),
|
||||
routeContext
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mockMoveTableToFolder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a body with no name, folder, or lock changes', async () => {
|
||||
const response = await PATCH(patchRequest({ workspaceId: 'workspace-1' }), routeContext)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockMoveTableToFolder).not.toHaveBeenCalled()
|
||||
expect(mockRenameTable).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -7,10 +7,12 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { findActiveFolder } from '@/lib/folders/queries'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import {
|
||||
deleteTable,
|
||||
getTableById,
|
||||
moveTableToFolder,
|
||||
renameTable,
|
||||
TableConflictError,
|
||||
type TableSchema,
|
||||
@@ -80,6 +82,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab
|
||||
metadata: table.metadata ?? null,
|
||||
rowCount: table.rowCount,
|
||||
maxRows: maxRowsPerTable,
|
||||
folderId: table.folderId ?? null,
|
||||
locks: table.locks,
|
||||
createdAt:
|
||||
table.createdAt instanceof Date
|
||||
@@ -184,6 +187,33 @@ export const PATCH = withRouteHandler(
|
||||
await renameTable(tableId, validated.name, requestId, authResult.userId)
|
||||
}
|
||||
|
||||
if (validated.folderId !== undefined) {
|
||||
// Scoped to `resourceType: 'table'` so a folder id from another resource's
|
||||
// tree can't be used to file the table somewhere Tables never lists.
|
||||
if (
|
||||
validated.folderId !== null &&
|
||||
!(await findActiveFolder(validated.folderId, table.workspaceId, 'table'))
|
||||
) {
|
||||
return NextResponse.json({ error: 'Folder not found in this workspace' }, { status: 404 })
|
||||
}
|
||||
try {
|
||||
await moveTableToFolder(
|
||||
tableId,
|
||||
table.workspaceId,
|
||||
validated.folderId,
|
||||
requestId,
|
||||
authResult.userId
|
||||
)
|
||||
} catch (moveError) {
|
||||
// The move re-asserts workspace and active state, so a miss means the table was
|
||||
// archived between `checkAccess` and the write. That is a 404, not a server fault.
|
||||
if (moveError instanceof Error && moveError.message.endsWith('not found')) {
|
||||
return NextResponse.json({ error: 'Table not found' }, { status: 404 })
|
||||
}
|
||||
throw moveError
|
||||
}
|
||||
}
|
||||
|
||||
// Re-read so the response reflects both a rename and a lock change.
|
||||
const updated = await getTableById(tableId)
|
||||
if (!updated) {
|
||||
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
mockListTables,
|
||||
mockRunTableImport,
|
||||
mockRunDetached,
|
||||
mockFindActiveFolder,
|
||||
MockTableConflictError,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreateTable: vi.fn(),
|
||||
@@ -18,6 +19,7 @@ const {
|
||||
mockListTables: vi.fn(),
|
||||
mockRunTableImport: vi.fn(),
|
||||
mockRunDetached: vi.fn(),
|
||||
mockFindActiveFolder: vi.fn(),
|
||||
MockTableConflictError: class extends Error {
|
||||
readonly code = 'TABLE_EXISTS' as const
|
||||
},
|
||||
@@ -37,6 +39,7 @@ vi.mock('@/lib/table', () => ({
|
||||
TableConflictError: MockTableConflictError,
|
||||
}))
|
||||
vi.mock('@/lib/table/import-runner', () => ({ runTableImport: mockRunTableImport }))
|
||||
vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mockFindActiveFolder }))
|
||||
vi.mock('@/lib/core/utils/background', () => ({
|
||||
runDetached: mockRunDetached.mockImplementation(
|
||||
(_label: string, work: () => Promise<unknown>) => {
|
||||
@@ -75,6 +78,36 @@ describe('POST /api/table/import-async', () => {
|
||||
mockListTables.mockResolvedValue([])
|
||||
mockCreateTable.mockResolvedValue({ id: 'tbl_async', name: 'data' })
|
||||
mockRunTableImport.mockResolvedValue(undefined)
|
||||
mockFindActiveFolder.mockResolvedValue({ id: 'folder-1' })
|
||||
})
|
||||
|
||||
it('imports into the workspace root when no folder is given', async () => {
|
||||
await POST(makeRequest(validBody))
|
||||
|
||||
expect(mockFindActiveFolder).not.toHaveBeenCalled()
|
||||
expect(mockCreateTable).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ folderId: undefined }),
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
|
||||
it('creates the imported table inside the requested folder', async () => {
|
||||
await POST(makeRequest({ ...validBody, folderId: 'folder-1' }))
|
||||
|
||||
expect(mockFindActiveFolder).toHaveBeenCalledWith('folder-1', 'workspace-1', 'table')
|
||||
expect(mockCreateTable).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ folderId: 'folder-1' }),
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a folder from another workspace or resource tree', async () => {
|
||||
mockFindActiveFolder.mockResolvedValue(null)
|
||||
|
||||
const response = await POST(makeRequest({ ...validBody, folderId: 'kb-folder' }))
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mockCreateTable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates an importing table and kicks off the background import', async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { isTriggerDevEnabled } from '@/lib/core/config/env-flags'
|
||||
import { runDetached } from '@/lib/core/utils/background'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { findActiveFolder } from '@/lib/folders/queries'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import {
|
||||
createTable,
|
||||
@@ -39,7 +40,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = await parseRequest(importTableAsyncContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { workspaceId, fileKey, fileName, deleteSourceFile, timezone } = parsed.data.body
|
||||
const { workspaceId, fileKey, fileName, folderId, deleteSourceFile, timezone } = parsed.data.body
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (permission !== 'write' && permission !== 'admin') {
|
||||
@@ -51,6 +52,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
return NextResponse.json({ error: 'Invalid file key for workspace' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Scoped to `resourceType: 'table'` so a folder from another resource's tree
|
||||
// can't file the imported table where Tables never lists it.
|
||||
if (folderId && !(await findActiveFolder(folderId, workspaceId, 'table'))) {
|
||||
return NextResponse.json({ error: 'Folder not found in this workspace' }, { status: 404 })
|
||||
}
|
||||
|
||||
const ext = fileName.split('.').pop()?.toLowerCase()
|
||||
if (ext !== 'csv' && ext !== 'tsv') {
|
||||
return NextResponse.json({ error: 'Only CSV and TSV files are supported' }, { status: 400 })
|
||||
@@ -84,6 +91,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
description: `Imported from ${fileName}`,
|
||||
schema: { columns: [{ name: 'column_1', type: 'string' }] },
|
||||
workspaceId,
|
||||
folderId,
|
||||
userId,
|
||||
maxTables: planLimits.maxTables,
|
||||
jobStatus: 'running',
|
||||
|
||||
@@ -10,6 +10,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { findActiveFolder } from '@/lib/folders/queries'
|
||||
import {
|
||||
batchInsertRows,
|
||||
CSV_MAX_BATCH_SIZE,
|
||||
@@ -88,6 +89,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
let folderId: string | null = null
|
||||
if (fields.folderId) {
|
||||
const folderIdResult = csvImportFormSchema.shape.folderId.safeParse(fields.folderId)
|
||||
if (!folderIdResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(folderIdResult.error) },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
// Scoped to `resourceType: 'table'` so a folder from another resource's tree
|
||||
// can't file the imported table where Tables never lists it.
|
||||
if (!(await findActiveFolder(folderIdResult.data as string, workspaceId, 'table'))) {
|
||||
return NextResponse.json({ error: 'Folder not found in this workspace' }, { status: 404 })
|
||||
}
|
||||
folderId = folderIdResult.data as string
|
||||
}
|
||||
|
||||
let timezone = (await getUserSettings(userId)).timezone ?? 'UTC'
|
||||
if (fields.timezone) {
|
||||
const timezoneResult = ianaTimezoneSchema.safeParse(fields.timezone)
|
||||
@@ -161,6 +179,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
description: `Imported from ${file.filename}`,
|
||||
schema,
|
||||
workspaceId,
|
||||
folderId,
|
||||
userId,
|
||||
maxTables: planLimits.maxTables,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { hybridAuthMockFns, permissionsMock, permissionsMockFns } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockCreateTable, mockGetLimits, mockListTables, mockFindActiveFolder } = vi.hoisted(() => ({
|
||||
mockCreateTable: vi.fn(),
|
||||
mockGetLimits: vi.fn(),
|
||||
mockListTables: vi.fn(),
|
||||
mockFindActiveFolder: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/table', () => ({
|
||||
createTable: mockCreateTable,
|
||||
getWorkspaceTableLimits: mockGetLimits,
|
||||
listTables: mockListTables,
|
||||
}))
|
||||
vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mockFindActiveFolder }))
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
|
||||
vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() }))
|
||||
vi.mock('@sim/audit', () => ({
|
||||
AuditAction: { TABLE_CREATED: 'table.created' },
|
||||
AuditResourceType: { TABLE: 'table' },
|
||||
recordAudit: vi.fn(),
|
||||
}))
|
||||
|
||||
import { GET, POST } from '@/app/api/table/route'
|
||||
|
||||
const CREATED_TABLE = {
|
||||
id: 'tbl_1',
|
||||
name: 'people',
|
||||
description: null,
|
||||
schema: { columns: [{ name: 'name', type: 'string' }] },
|
||||
rowCount: 0,
|
||||
maxRows: 10_000,
|
||||
folderId: null as string | null,
|
||||
locks: {
|
||||
schemaLocked: false,
|
||||
insertLocked: false,
|
||||
updateLocked: false,
|
||||
deleteLocked: false,
|
||||
},
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
}
|
||||
|
||||
function postRequest(body: unknown): NextRequest {
|
||||
return new NextRequest('http://localhost:3000/api/table', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
const createBody = {
|
||||
workspaceId: 'workspace-1',
|
||||
name: 'people',
|
||||
schema: { columns: [{ name: 'name', type: 'string' }] },
|
||||
}
|
||||
|
||||
describe('POST /api/table folder assignment', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
authType: 'session',
|
||||
})
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
mockGetLimits.mockResolvedValue({ maxRowsPerTable: 1_000_000, maxTables: 50 })
|
||||
mockCreateTable.mockResolvedValue(CREATED_TABLE)
|
||||
mockFindActiveFolder.mockResolvedValue({ id: 'folder-1' })
|
||||
})
|
||||
|
||||
it('creates the table at the workspace root when no folder is given', async () => {
|
||||
const response = await POST(postRequest(createBody))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockFindActiveFolder).not.toHaveBeenCalled()
|
||||
expect(mockCreateTable).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ folderId: null }),
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
|
||||
it('creates the table inside the requested folder', async () => {
|
||||
mockCreateTable.mockResolvedValue({ ...CREATED_TABLE, folderId: 'folder-1' })
|
||||
|
||||
const response = await POST(postRequest({ ...createBody, folderId: 'folder-1' }))
|
||||
const json = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockCreateTable).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ folderId: 'folder-1' }),
|
||||
expect.any(String)
|
||||
)
|
||||
expect(json.data.table.folderId).toBe('folder-1')
|
||||
})
|
||||
|
||||
it('scopes the folder lookup to the workspace and the table folder tree', async () => {
|
||||
await POST(postRequest({ ...createBody, folderId: 'folder-1' }))
|
||||
|
||||
expect(mockFindActiveFolder).toHaveBeenCalledWith('folder-1', 'workspace-1', 'table')
|
||||
})
|
||||
|
||||
it('rejects a folder id that does not resolve in this workspace or tree', async () => {
|
||||
mockFindActiveFolder.mockResolvedValue(null)
|
||||
|
||||
const response = await POST(postRequest({ ...createBody, folderId: 'kb-folder' }))
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mockCreateTable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an empty folder id before touching the database', async () => {
|
||||
const response = await POST(postRequest({ ...createBody, folderId: '' }))
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockFindActiveFolder).not.toHaveBeenCalled()
|
||||
expect(mockCreateTable).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/table folder placement', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
authType: 'session',
|
||||
})
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read')
|
||||
})
|
||||
|
||||
it('emits each table folderId so the list can group rows by folder', async () => {
|
||||
mockListTables.mockResolvedValue([
|
||||
{ ...CREATED_TABLE, id: 'tbl_1', folderId: 'folder-1', workspaceId: 'ws', createdBy: 'u' },
|
||||
{ ...CREATED_TABLE, id: 'tbl_2', folderId: null, workspaceId: 'ws', createdBy: 'u' },
|
||||
])
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest('http://localhost:3000/api/table?workspaceId=workspace-1')
|
||||
)
|
||||
const json = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(json.data.tables.map((t: { folderId: string | null }) => t.folderId)).toEqual([
|
||||
'folder-1',
|
||||
null,
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import { isZodError, parseRequest, validationErrorResponse } from '@/lib/api/ser
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { findActiveFolder } from '@/lib/folders/queries'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import {
|
||||
createTable,
|
||||
@@ -69,6 +70,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Scoped to `resourceType: 'table'` so a folder id belonging to another
|
||||
// resource's tree can't file a table where its own surface will never list it.
|
||||
if (
|
||||
params.folderId &&
|
||||
!(await findActiveFolder(params.folderId, params.workspaceId, 'table'))
|
||||
) {
|
||||
return NextResponse.json({ error: 'Folder not found in this workspace' }, { status: 404 })
|
||||
}
|
||||
|
||||
const planLimits = await getWorkspaceTableLimits(params.workspaceId)
|
||||
|
||||
const normalizedSchema: TableSchema = {
|
||||
@@ -81,6 +91,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
description: params.description,
|
||||
schema: normalizedSchema,
|
||||
workspaceId: params.workspaceId,
|
||||
folderId: params.folderId ?? null,
|
||||
userId: authResult.userId,
|
||||
maxTables: planLimits.maxTables,
|
||||
initialRowCount: params.initialRowCount,
|
||||
@@ -127,6 +138,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
},
|
||||
rowCount: table.rowCount,
|
||||
maxRows: table.maxRows,
|
||||
folderId: table.folderId ?? null,
|
||||
locks: table.locks,
|
||||
createdAt:
|
||||
table.createdAt instanceof Date
|
||||
@@ -209,6 +221,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
maxRows: t.maxRows,
|
||||
locks: t.locks,
|
||||
workspaceId: t.workspaceId,
|
||||
folderId: t.folderId ?? null,
|
||||
createdBy: t.createdBy,
|
||||
createdAt: t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt),
|
||||
updatedAt: t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt),
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
assertFolderInWorkspace,
|
||||
assertFolderMutable,
|
||||
FolderLockedError,
|
||||
FolderNotFoundError,
|
||||
} from '@sim/platform-authz/workflow'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
@@ -63,6 +69,23 @@ export const POST = withRouteHandler(
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration 0272 dropped the FK on `workflow.folder_id`, so nothing but this check
|
||||
* stands between the caller and a folder in another workspace — or one from another
|
||||
* resource's tree. A workflow filed under an unreachable folder still executes and
|
||||
* bills, escapes the folder delete cascade, and never counts toward
|
||||
* `guardLastWorkflows`.
|
||||
*
|
||||
* Ownership before lock state, mirroring `v1/workflows/import`: `assertFolderMutable`
|
||||
* walks the ancestor chain without filtering on workspace, so checking it first would
|
||||
* let a caller distinguish a locked folder in someone else's workspace (423) from a
|
||||
* nonexistent one (404).
|
||||
*/
|
||||
if (folderId) {
|
||||
await assertFolderInWorkspace(folderId, workspaceId)
|
||||
}
|
||||
await assertFolderMutable(folderId ?? null)
|
||||
|
||||
const workflowContent =
|
||||
typeof body.workflow === 'string' ? body.workflow : JSON.stringify(body.workflow)
|
||||
|
||||
@@ -148,6 +171,12 @@ export const POST = withRouteHandler(
|
||||
|
||||
return NextResponse.json(response)
|
||||
} catch (error) {
|
||||
if (error instanceof FolderNotFoundError) {
|
||||
return badRequestResponse(error.message)
|
||||
}
|
||||
if (error instanceof FolderLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
logger.error('Admin API: Failed to import workflow', { error })
|
||||
return internalErrorResponse('Failed to import workflow')
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { folder as folderTable, workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
@@ -108,16 +108,42 @@ async function ensureImportFolder(
|
||||
if (existing) return existing.id
|
||||
|
||||
const folderId = generateId()
|
||||
await db.insert(folderTable).values({
|
||||
id: folderId,
|
||||
resourceType: 'workflow',
|
||||
name,
|
||||
userId,
|
||||
workspaceId,
|
||||
parentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
try {
|
||||
await db.insert(folderTable).values({
|
||||
id: folderId,
|
||||
resourceType: 'workflow',
|
||||
name,
|
||||
userId,
|
||||
workspaceId,
|
||||
parentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
} catch (error) {
|
||||
/**
|
||||
* The SELECT above is not serialized against this INSERT, so two imports sharing a folder
|
||||
* path race here. The name is server-chosen, not user-chosen, so the right resolution is
|
||||
* to adopt whichever folder won rather than fail the whole import with a 500 — the same
|
||||
* reuse-on-conflict `ensureWorkspaceFileFolderPath` already does.
|
||||
*/
|
||||
if (getPostgresErrorCode(error) !== '23505') throw error
|
||||
|
||||
const [concurrent] = await db
|
||||
.select({ id: folderTable.id })
|
||||
.from(folderTable)
|
||||
.where(
|
||||
and(
|
||||
eq(folderTable.workspaceId, workspaceId),
|
||||
eq(folderTable.resourceType, 'workflow'),
|
||||
eq(folderTable.name, name),
|
||||
parentId ? eq(folderTable.parentId, parentId) : isNull(folderTable.parentId),
|
||||
isNull(folderTable.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
if (!concurrent) throw error
|
||||
return concurrent.id
|
||||
}
|
||||
return folderId
|
||||
}
|
||||
|
||||
|
||||
@@ -32,11 +32,11 @@ export function buildLogFilters(filters: LogFilters): SQL<unknown> {
|
||||
const cursorDate = new Date(filters.cursor.startedAt)
|
||||
if (filters.order === 'desc') {
|
||||
conditions.push(
|
||||
sql`(${workflowExecutionLogs.startedAt}, ${workflowExecutionLogs.id}) < (${cursorDate}, ${filters.cursor.id})`
|
||||
sql`(${workflowExecutionLogs.startedAt}, ${workflowExecutionLogs.id}) < (${sql.param(cursorDate, workflowExecutionLogs.startedAt)}, ${filters.cursor.id})`
|
||||
)
|
||||
} else {
|
||||
conditions.push(
|
||||
sql`(${workflowExecutionLogs.startedAt}, ${workflowExecutionLogs.id}) > (${cursorDate}, ${filters.cursor.id})`
|
||||
sql`(${workflowExecutionLogs.startedAt}, ${workflowExecutionLogs.id}) > (${sql.param(cursorDate, workflowExecutionLogs.startedAt)}, ${filters.cursor.id})`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ElementType } from 'react'
|
||||
import type {
|
||||
BreadcrumbEditing,
|
||||
BreadcrumbItem,
|
||||
DropdownOption,
|
||||
} from '@/app/workspace/[workspaceId]/components/resource/components/resource-header'
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
|
||||
export interface FolderBreadcrumbItemsOptions {
|
||||
/** Root crumb label — the page's own name ("Knowledge Base", "Tables"). */
|
||||
rootLabel: string
|
||||
rootIcon?: ElementType
|
||||
/** Root-first ancestor chain of the open folder, from `useFolderNavigation`. */
|
||||
breadcrumbs: WorkflowFolder[]
|
||||
/** Called with the folder to open, or `null` for the workspace root. */
|
||||
onNavigate: (folderId: string | null) => void
|
||||
/** Menu attached to the open folder's crumb (rename, delete, …). */
|
||||
currentFolderActions?: DropdownOption[]
|
||||
/** Inline rename bound to the open folder's crumb. */
|
||||
currentFolderEditing?: BreadcrumbEditing
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a folder ancestor chain into the `BreadcrumbItem[]` that `Resource.Header`
|
||||
* renders.
|
||||
*
|
||||
* A plain builder rather than a component: `Resource.Header` already owns every piece of
|
||||
* breadcrumb chrome — the root-crumb "Path" popover, segment width allocation, overflow
|
||||
* tooltips, and the rule that a single-element trail renders as a plain page title. A
|
||||
* sibling crumb component would have to fork all of it, which is exactly what this shared
|
||||
* directory exists to prevent.
|
||||
*
|
||||
* The trail always starts with the root crumb, so at the workspace root the result has
|
||||
* length 1 and the header renders the page title unchanged.
|
||||
*/
|
||||
export function folderBreadcrumbItems({
|
||||
rootLabel,
|
||||
rootIcon,
|
||||
breadcrumbs,
|
||||
onNavigate,
|
||||
currentFolderActions,
|
||||
currentFolderEditing,
|
||||
}: FolderBreadcrumbItemsOptions): BreadcrumbItem[] {
|
||||
const items: BreadcrumbItem[] = [
|
||||
{ label: rootLabel, icon: rootIcon, onClick: () => onNavigate(null) },
|
||||
]
|
||||
|
||||
breadcrumbs.forEach((folder, index) => {
|
||||
const isCurrent = index === breadcrumbs.length - 1
|
||||
/** The open folder is where you already are, so its crumb is not a navigation target. */
|
||||
items.push({
|
||||
label: folder.name,
|
||||
onClick: isCurrent ? undefined : () => onNavigate(folder.id),
|
||||
dropdownItems: isCurrent && currentFolderActions?.length ? currentFolderActions : undefined,
|
||||
editing: isCurrent ? currentFolderEditing : undefined,
|
||||
})
|
||||
})
|
||||
|
||||
return items
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@sim/emcn'
|
||||
import { Duplicate, Eye, FolderInput, Pencil, Pin, Trash } from '@sim/emcn/icons'
|
||||
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders/move-options'
|
||||
import { renderMoveOptions } from '@/app/workspace/[workspaceId]/components/folders/move-options'
|
||||
|
||||
interface FolderContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
onClose: () => void
|
||||
onOpen: () => void
|
||||
onRename: () => void
|
||||
onDelete: () => void
|
||||
onCopyId?: () => void
|
||||
onMove?: (optionValue: string) => void
|
||||
onTogglePin: () => void
|
||||
/** Pin state of the right-clicked folder, driving the Pin/Unpin label. */
|
||||
pinned: boolean
|
||||
moveOptions?: MoveOptionNode[]
|
||||
canEdit: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Row context menu for a folder, shared by the resource lists built on the generic folder
|
||||
* engine — Knowledge and Tables — so a folder offers the same actions on both.
|
||||
*
|
||||
* Files is deliberately not a consumer: its rows carry multi-select and bulk actions, so a
|
||||
* folder there routes through `FileRowContextMenu` alongside the file rows it is selected
|
||||
* with. Converging the two is follow-up work.
|
||||
*
|
||||
* Mirrors the resource-row menus (`KnowledgeBaseContextMenu`, `FileRowContextMenu`): a
|
||||
* `DropdownMenu` anchored to a one-pixel fixed trigger at the cursor, non-modal so the list
|
||||
* behind it stays interactive. Folder locking is deliberately absent — it is a workflow-only
|
||||
* feature and is not extended to the other resource trees.
|
||||
*/
|
||||
export const FolderContextMenu = memo(function FolderContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
onClose,
|
||||
onOpen,
|
||||
onRename,
|
||||
onDelete,
|
||||
onCopyId,
|
||||
onMove,
|
||||
onTogglePin,
|
||||
pinned,
|
||||
moveOptions,
|
||||
canEdit,
|
||||
}: FolderContextMenuProps) {
|
||||
const hasMove = Boolean(onMove && moveOptions && moveOptions.length > 0)
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
className='pointer-events-none fixed size-px'
|
||||
style={{ left: `${position.x}px`, top: `${position.y}px` }}
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem onSelect={onOpen}>
|
||||
<Eye />
|
||||
Open
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onTogglePin}>
|
||||
<Pin />
|
||||
{pinned ? 'Unpin' : 'Pin'}
|
||||
</DropdownMenuItem>
|
||||
{onCopyId && (
|
||||
<DropdownMenuItem onSelect={onCopyId}>
|
||||
<Duplicate />
|
||||
Copy ID
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canEdit && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onRename}>
|
||||
<Pencil />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
{hasMove && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<FolderInput />
|
||||
Move to
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{renderMoveOptions(moveOptions!, onMove!)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onDelete}>
|
||||
<Trash />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
|
||||
const DEFAULT_FOLDER_NAME = 'New folder'
|
||||
|
||||
/**
|
||||
* First `"New folder"` / `"New folder (N)"` name not already taken by an active sibling
|
||||
* under `parentId`.
|
||||
*
|
||||
* The `folder` table has a partial unique index on active
|
||||
* `(workspaceId, resourceType, parentId, name)`, and a create the user did not name has no
|
||||
* way to recover from a 409 — so a "New folder" button dedups up front rather than surfacing
|
||||
* a conflict. Deliberately matches the `" (N)"` shape of `deduplicateFolderName` on the
|
||||
* server so a deduped name reads the same however it was produced. A concurrent create can
|
||||
* still win the race; the caller must handle the 409 that follows.
|
||||
*/
|
||||
export function nextUntitledFolderName(folders: WorkflowFolder[], parentId: string | null): string {
|
||||
const siblingNames = new Set(
|
||||
folders.filter((folder) => (folder.parentId ?? null) === parentId).map((folder) => folder.name)
|
||||
)
|
||||
|
||||
if (!siblingNames.has(DEFAULT_FOLDER_NAME)) return DEFAULT_FOLDER_NAME
|
||||
|
||||
let suffix = 1
|
||||
while (siblingNames.has(`${DEFAULT_FOLDER_NAME} (${suffix})`)) suffix += 1
|
||||
return `${DEFAULT_FOLDER_NAME} (${suffix})`
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* A `Resource.Table` row is addressed by a single id, but a foldered list interleaves two
|
||||
* kinds of row. Namespacing the ids lets one selection `Set`, one context-menu handler, and
|
||||
* one drop-target predicate cover both without a parallel lookup.
|
||||
*/
|
||||
const FOLDER_ROW_PREFIX = 'folder:'
|
||||
|
||||
export type FolderedRowKind = 'folder' | 'resource'
|
||||
|
||||
export interface ParsedFolderedRowId {
|
||||
kind: FolderedRowKind
|
||||
id: string
|
||||
}
|
||||
|
||||
export function folderRowId(folderId: string): string {
|
||||
return `${FOLDER_ROW_PREFIX}${folderId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource rows keep their bare id so existing handlers, deep links, and cached selections
|
||||
* that predate folders keep resolving; only folder rows carry a prefix.
|
||||
*/
|
||||
export function parseFolderedRowId(rowId: string): ParsedFolderedRowId {
|
||||
if (rowId.startsWith(FOLDER_ROW_PREFIX)) {
|
||||
return { kind: 'folder', id: rowId.slice(FOLDER_ROW_PREFIX.length) }
|
||||
}
|
||||
return { kind: 'resource', id: rowId }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Folder } from '@sim/emcn/icons'
|
||||
import { folderRowId } from '@/app/workspace/[workspaceId]/components/folders/folder-row-id'
|
||||
import type {
|
||||
ResourceCell,
|
||||
ResourceRow,
|
||||
} from '@/app/workspace/[workspaceId]/components/resource/resource'
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
|
||||
const FOLDER_ICON = <Folder className='size-[14px]' />
|
||||
|
||||
export interface FolderRowOptions {
|
||||
/**
|
||||
* Whether this folder is pinned. Folders pin under `resourceType: 'folder'`, a different
|
||||
* pin namespace from the resource they contain — a page listing both resolves two
|
||||
* `usePinnedIds` sets and passes the right one here. Drives the glyph only; the pin action
|
||||
* lives on the row's context menu.
|
||||
*/
|
||||
pinned?: boolean
|
||||
/**
|
||||
* Cells for the page's non-name columns, keyed by `ResourceColumn.id`. Folders have no
|
||||
* value for most resource-specific columns, so a page passes either a derived roll-up or
|
||||
* the em-dash placeholder.
|
||||
*/
|
||||
cells?: Record<string, ResourceCell>
|
||||
/** Column id carrying the name cell. Defaults to `'name'`. */
|
||||
nameColumnId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the canonical folder `ResourceRow` for the lists built on the generic folder engine
|
||||
* (Knowledge and Tables), so a folder looks and behaves identically on both. Files still
|
||||
* builds its own row — it carries a size roll-up and a distinct row-id scheme that also
|
||||
* namespaces file ids. The row id here is namespaced by
|
||||
* {@link folderRowId}, so the caller's click/context-menu handlers distinguish a folder from
|
||||
* a resource with `parseFolderedRowId` rather than a second lookup.
|
||||
*
|
||||
* Pinning is not a cell: it lives in the row's context menu (see `FolderContextMenu`), and
|
||||
* inline rename is layered over the built rows by the page so a keystroke in the rename
|
||||
* field rebuilds one cell instead of every row's cells.
|
||||
*/
|
||||
export function folderRow(folder: WorkflowFolder, options: FolderRowOptions = {}): ResourceRow {
|
||||
const { pinned, cells, nameColumnId = 'name' } = options
|
||||
|
||||
return {
|
||||
id: folderRowId(folder.id),
|
||||
cells: {
|
||||
...cells,
|
||||
[nameColumnId]: {
|
||||
icon: FOLDER_ICON,
|
||||
label: folder.name,
|
||||
pinned,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { folderBreadcrumbItems } from '@/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs'
|
||||
import { nextUntitledFolderName } from '@/app/workspace/[workspaceId]/components/folders/folder-naming'
|
||||
import {
|
||||
folderRowId,
|
||||
parseFolderedRowId,
|
||||
} from '@/app/workspace/[workspaceId]/components/folders/folder-row-id'
|
||||
import {
|
||||
buildDescendantIndex,
|
||||
buildMoveOptions,
|
||||
parseMoveOptionValue,
|
||||
ROOT_MOVE_OPTION_VALUE,
|
||||
} from '@/app/workspace/[workspaceId]/components/folders/move-options'
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
|
||||
function makeFolder(
|
||||
id: string,
|
||||
parentId: string | null = null,
|
||||
overrides: Partial<WorkflowFolder> = {}
|
||||
): WorkflowFolder {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
userId: 'u-1',
|
||||
workspaceId: 'ws-1',
|
||||
parentId,
|
||||
resourceType: 'knowledge_base',
|
||||
locked: false,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
deletedAt: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('folder row ids', () => {
|
||||
it('round-trips a folder id through the namespaced row id', () => {
|
||||
expect(parseFolderedRowId(folderRowId('f-1'))).toEqual({ kind: 'folder', id: 'f-1' })
|
||||
})
|
||||
|
||||
it('treats an unprefixed id as the resource, so pre-folder row ids still resolve', () => {
|
||||
expect(parseFolderedRowId('kb-1')).toEqual({ kind: 'resource', id: 'kb-1' })
|
||||
})
|
||||
|
||||
it('does not mistake a resource id that merely contains the prefix for a folder', () => {
|
||||
expect(parseFolderedRowId('kb-folder:1')).toEqual({ kind: 'resource', id: 'kb-folder:1' })
|
||||
})
|
||||
|
||||
it('keeps a folder id containing a colon intact', () => {
|
||||
expect(parseFolderedRowId(folderRowId('a:b'))).toEqual({ kind: 'folder', id: 'a:b' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('nextUntitledFolderName', () => {
|
||||
it('uses the bare name when no sibling holds it', () => {
|
||||
expect(nextUntitledFolderName([], null)).toBe('New folder')
|
||||
})
|
||||
|
||||
it('suffixes past every taken sibling name', () => {
|
||||
const folders = [
|
||||
makeFolder('a', null, { name: 'New folder' }),
|
||||
makeFolder('b', null, { name: 'New folder (1)' }),
|
||||
]
|
||||
expect(nextUntitledFolderName(folders, null)).toBe('New folder (2)')
|
||||
})
|
||||
|
||||
it('only considers siblings under the same parent', () => {
|
||||
const folders = [makeFolder('a', 'p-1', { name: 'New folder' })]
|
||||
expect(nextUntitledFolderName(folders, null)).toBe('New folder')
|
||||
expect(nextUntitledFolderName(folders, 'p-1')).toBe('New folder (1)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildDescendantIndex', () => {
|
||||
it('collects transitive descendants', () => {
|
||||
const index = buildDescendantIndex([
|
||||
makeFolder('root'),
|
||||
makeFolder('child', 'root'),
|
||||
makeFolder('grandchild', 'child'),
|
||||
makeFolder('other'),
|
||||
])
|
||||
|
||||
expect([...(index.get('root') ?? [])].sort()).toEqual(['child', 'grandchild'])
|
||||
expect([...(index.get('child') ?? [])]).toEqual(['grandchild'])
|
||||
expect([...(index.get('other') ?? [])]).toEqual([])
|
||||
})
|
||||
|
||||
it('terminates on a cycle instead of recursing forever', () => {
|
||||
const index = buildDescendantIndex([makeFolder('a', 'b'), makeFolder('b', 'a')])
|
||||
expect(index.has('a')).toBe(true)
|
||||
expect(index.has('b')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildMoveOptions', () => {
|
||||
const folders = [makeFolder('root'), makeFolder('child', 'root'), makeFolder('sibling')]
|
||||
|
||||
it('leads with the root sentinel', () => {
|
||||
const options = buildMoveOptions({ folders, rootLabel: 'Knowledge Base' })
|
||||
expect(options[0]).toEqual({
|
||||
value: ROOT_MOVE_OPTION_VALUE,
|
||||
label: 'Knowledge Base',
|
||||
children: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('nests descendants under their parent', () => {
|
||||
const options = buildMoveOptions({
|
||||
folders: [...folders, makeFolder('grandchild', 'child')],
|
||||
rootLabel: 'Knowledge Base',
|
||||
})
|
||||
const root = options.find((option) => option.value === 'root')
|
||||
expect(root?.children.map((child) => child.value)).toEqual(['child'])
|
||||
expect(root?.children[0].children.map((child) => child.value)).toEqual(['grandchild'])
|
||||
})
|
||||
|
||||
it('excludes the moved folder and its subtree, so a move cannot close a cycle', () => {
|
||||
const excluded = new Set(['root', ...(buildDescendantIndex(folders).get('root') ?? [])])
|
||||
const options = buildMoveOptions({
|
||||
folders,
|
||||
rootLabel: 'Knowledge Base',
|
||||
excludedFolderIds: excluded,
|
||||
})
|
||||
|
||||
expect(options.map((option) => option.value)).toEqual([ROOT_MOVE_OPTION_VALUE, 'sibling'])
|
||||
})
|
||||
|
||||
it('still offers the root when every folder is excluded', () => {
|
||||
const options = buildMoveOptions({
|
||||
folders,
|
||||
rootLabel: 'Knowledge Base',
|
||||
excludedFolderIds: new Set(['root', 'child', 'sibling']),
|
||||
})
|
||||
expect(options).toHaveLength(1)
|
||||
expect(options[0].value).toBe(ROOT_MOVE_OPTION_VALUE)
|
||||
})
|
||||
|
||||
it('offers every folder when nothing is excluded, as for a resource with no subtree', () => {
|
||||
const options = buildMoveOptions({ folders, rootLabel: 'Knowledge Base' })
|
||||
expect(options.map((option) => option.value)).toEqual([
|
||||
ROOT_MOVE_OPTION_VALUE,
|
||||
'root',
|
||||
'sibling',
|
||||
])
|
||||
})
|
||||
|
||||
it('orders siblings by sortOrder, then name', () => {
|
||||
const options = buildMoveOptions({
|
||||
folders: [
|
||||
makeFolder('b', null, { name: 'B', sortOrder: 1 }),
|
||||
makeFolder('a', null, { name: 'A', sortOrder: 2 }),
|
||||
],
|
||||
rootLabel: 'Knowledge Base',
|
||||
})
|
||||
expect(options.slice(1).map((option) => option.label)).toEqual(['B', 'A'])
|
||||
})
|
||||
|
||||
it('does not mutate the caller folder array while sorting', () => {
|
||||
const source = [...folders]
|
||||
buildMoveOptions({ folders: source, rootLabel: 'Knowledge Base' })
|
||||
expect(source.map((folder) => folder.id)).toEqual(['root', 'child', 'sibling'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseMoveOptionValue', () => {
|
||||
it('decodes the root sentinel to null', () => {
|
||||
expect(parseMoveOptionValue(ROOT_MOVE_OPTION_VALUE)).toBeNull()
|
||||
})
|
||||
|
||||
it('passes a folder id through unchanged', () => {
|
||||
expect(parseMoveOptionValue('folder-1')).toBe('folder-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('folderBreadcrumbItems', () => {
|
||||
it('renders the root alone when no folder is open, so the header falls back to a title', () => {
|
||||
const items = folderBreadcrumbItems({
|
||||
rootLabel: 'Knowledge Base',
|
||||
breadcrumbs: [],
|
||||
onNavigate: vi.fn(),
|
||||
})
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].label).toBe('Knowledge Base')
|
||||
})
|
||||
|
||||
it('prepends the root crumb to the ancestor chain', () => {
|
||||
const items = folderBreadcrumbItems({
|
||||
rootLabel: 'Tables',
|
||||
breadcrumbs: [
|
||||
makeFolder('root', null, { name: 'Alpha' }),
|
||||
makeFolder('leaf', 'root', { name: 'Beta' }),
|
||||
],
|
||||
onNavigate: vi.fn(),
|
||||
})
|
||||
expect(items.map((item) => item.label)).toEqual(['Tables', 'Alpha', 'Beta'])
|
||||
})
|
||||
|
||||
it('navigates to null from the root crumb and to the folder id from an ancestor', () => {
|
||||
const onNavigate = vi.fn()
|
||||
const items = folderBreadcrumbItems({
|
||||
rootLabel: 'Tables',
|
||||
breadcrumbs: [makeFolder('root'), makeFolder('leaf', 'root')],
|
||||
onNavigate,
|
||||
})
|
||||
|
||||
items[0].onClick?.()
|
||||
items[1].onClick?.()
|
||||
|
||||
expect(onNavigate).toHaveBeenNthCalledWith(1, null)
|
||||
expect(onNavigate).toHaveBeenNthCalledWith(2, 'root')
|
||||
})
|
||||
|
||||
it('attaches the rename session and actions to the current folder only', () => {
|
||||
const currentFolderEditing = {
|
||||
isEditing: true,
|
||||
value: 'x',
|
||||
onChange: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
}
|
||||
const currentFolderActions = [{ label: 'Rename', onClick: vi.fn() }]
|
||||
const items = folderBreadcrumbItems({
|
||||
rootLabel: 'Knowledge Base',
|
||||
breadcrumbs: [makeFolder('root'), makeFolder('leaf', 'root')],
|
||||
onNavigate: vi.fn(),
|
||||
currentFolderEditing,
|
||||
currentFolderActions,
|
||||
})
|
||||
|
||||
expect(items[1].onClick).toBeTypeOf('function')
|
||||
expect(items[1].editing).toBeUndefined()
|
||||
expect(items[1].dropdownItems).toBeUndefined()
|
||||
expect(items[2].onClick).toBeUndefined()
|
||||
expect(items[2].editing).toBe(currentFolderEditing)
|
||||
expect(items[2].dropdownItems).toBe(currentFolderActions)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
export type { FolderBreadcrumbItemsOptions } from './folder-breadcrumbs'
|
||||
export { folderBreadcrumbItems } from './folder-breadcrumbs'
|
||||
export { FolderContextMenu } from './folder-context-menu'
|
||||
export { nextUntitledFolderName } from './folder-naming'
|
||||
export type { FolderRowOptions } from './folder-row'
|
||||
export { folderRow } from './folder-row'
|
||||
export type { FolderedRowKind, ParsedFolderedRowId } from './folder-row-id'
|
||||
export { folderRowId, parseFolderedRowId } from './folder-row-id'
|
||||
export type { BuildMoveOptionsParams, MoveOptionNode } from './move-options'
|
||||
export {
|
||||
buildDescendantIndex,
|
||||
buildMoveOptions,
|
||||
parseMoveOptionValue,
|
||||
ROOT_MOVE_OPTION_VALUE,
|
||||
renderMoveOption,
|
||||
renderMoveOptions,
|
||||
} from './move-options'
|
||||
export { folderNavParsers, folderNavUrlKeys } from './search-params'
|
||||
export type { FolderNavigation, UseFolderNavigationOptions } from './use-folder-navigation'
|
||||
export { useFolderNavigation } from './use-folder-navigation'
|
||||
export type { UseFolderRowDragDropOptions } from './use-folder-row-drag-drop'
|
||||
export { useFolderRowDragDrop } from './use-folder-row-drag-drop'
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} from '@sim/emcn'
|
||||
import { Folder } from '@sim/emcn/icons'
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
|
||||
export interface MoveOptionNode {
|
||||
value: string
|
||||
label: string
|
||||
children: MoveOptionNode[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel for "the workspace root" in a move submenu. A folder id can never be empty, so
|
||||
* this cannot collide with a real destination.
|
||||
*/
|
||||
export const ROOT_MOVE_OPTION_VALUE = '__root__'
|
||||
|
||||
/** Decodes a selected move-option value back into a `folderId` / `parentId`. */
|
||||
export function parseMoveOptionValue(optionValue: string): string | null {
|
||||
return optionValue === ROOT_MOVE_OPTION_VALUE ? null : optionValue
|
||||
}
|
||||
|
||||
export interface BuildMoveOptionsParams {
|
||||
folders: WorkflowFolder[]
|
||||
rootLabel: string
|
||||
/**
|
||||
* Folder ids that must not appear as destinations — the folder being moved and every
|
||||
* folder beneath it, which would otherwise close a cycle. Build the subtree half with
|
||||
* {@link buildDescendantIndex}.
|
||||
*/
|
||||
excludedFolderIds?: ReadonlySet<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the folder tree offered by a "Move to" submenu, always led by the root option.
|
||||
*
|
||||
* Children are indexed by parent once so the tree walk stays linear in the number of
|
||||
* folders rather than re-filtering the whole list at every level.
|
||||
*
|
||||
* `buildSubtree` needs no cycle guard, and that is a property of the walk rather than an
|
||||
* oversight: it descends from the `null` root, and every folder inside a parent cycle has a
|
||||
* parent inside that cycle, so no cycle member is ever reachable from the root. Such folders
|
||||
* are omitted from the menu, which is the right answer — they are not valid destinations.
|
||||
*/
|
||||
export function buildMoveOptions({
|
||||
folders,
|
||||
rootLabel,
|
||||
excludedFolderIds,
|
||||
}: BuildMoveOptionsParams): MoveOptionNode[] {
|
||||
const childrenByParent = new Map<string | null, WorkflowFolder[]>()
|
||||
for (const folder of folders) {
|
||||
if (excludedFolderIds?.has(folder.id)) continue
|
||||
const parentId = folder.parentId ?? null
|
||||
const siblings = childrenByParent.get(parentId)
|
||||
if (siblings) siblings.push(folder)
|
||||
else childrenByParent.set(parentId, [folder])
|
||||
}
|
||||
|
||||
const buildSubtree = (parentId: string | null): MoveOptionNode[] =>
|
||||
[...(childrenByParent.get(parentId) ?? [])]
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
|
||||
.map((folder) => ({
|
||||
value: folder.id,
|
||||
label: folder.name,
|
||||
children: buildSubtree(folder.id),
|
||||
}))
|
||||
|
||||
return [{ value: ROOT_MOVE_OPTION_VALUE, label: rootLabel, children: [] }, ...buildSubtree(null)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexes every folder's transitive descendants once, so excluding a moved folder's subtree
|
||||
* from {@link buildMoveOptions} (and rejecting a cyclic drop target) stays O(1) per
|
||||
* candidate instead of re-walking the tree. `seen` terminates a cycle, which the DB permits
|
||||
* between constraint checks.
|
||||
*/
|
||||
export function buildDescendantIndex(folders: WorkflowFolder[]): Map<string, Set<string>> {
|
||||
const childrenByParent = new Map<string, string[]>()
|
||||
for (const folder of folders) {
|
||||
if (!folder.parentId) continue
|
||||
const children = childrenByParent.get(folder.parentId)
|
||||
if (children) children.push(folder.id)
|
||||
else childrenByParent.set(folder.parentId, [folder.id])
|
||||
}
|
||||
|
||||
const result = new Map<string, Set<string>>()
|
||||
const collect = (folderId: string, seen: Set<string>): Set<string> => {
|
||||
const cached = result.get(folderId)
|
||||
if (cached) return cached
|
||||
if (seen.has(folderId)) return new Set<string>()
|
||||
|
||||
const nextSeen = new Set(seen)
|
||||
nextSeen.add(folderId)
|
||||
const descendants = new Set<string>()
|
||||
for (const childId of childrenByParent.get(folderId) ?? []) {
|
||||
if (nextSeen.has(childId)) continue
|
||||
descendants.add(childId)
|
||||
for (const nestedId of collect(childId, nextSeen)) descendants.add(nestedId)
|
||||
}
|
||||
result.set(folderId, descendants)
|
||||
return descendants
|
||||
}
|
||||
|
||||
for (const folder of folders) collect(folder.id, new Set<string>())
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one move target. A leaf is a plain item; a folder with children becomes a submenu
|
||||
* whose own "Move here" entry leads its children, so an intermediate folder is still
|
||||
* selectable while remaining traversable.
|
||||
*/
|
||||
export function renderMoveOption(
|
||||
option: MoveOptionNode,
|
||||
onMove: (value: string) => void
|
||||
): ReactNode {
|
||||
if (option.children.length === 0) {
|
||||
return (
|
||||
<DropdownMenuItem key={option.value} onSelect={() => onMove(option.value)}>
|
||||
<Folder />
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSub key={option.value}>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Folder />
|
||||
{option.label}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onSelect={() => onMove(option.value)}>
|
||||
<Folder />
|
||||
Move here
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{option.children.map((child) => renderMoveOption(child, onMove))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a whole "Move to" submenu body: the root destination first, then the folder tree.
|
||||
* Shared so every row menu that offers a move renders the same structure.
|
||||
*/
|
||||
export function renderMoveOptions(
|
||||
options: MoveOptionNode[],
|
||||
onMove: (value: string) => void
|
||||
): ReactNode {
|
||||
if (options.length === 0) return null
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={() => onMove(options[0].value)}>
|
||||
<Folder />
|
||||
{options[0].label}
|
||||
</DropdownMenuItem>
|
||||
{options.length > 1 && <DropdownMenuSeparator />}
|
||||
{options.slice(1).map((option) => renderMoveOption(option, onMove))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { parseAsString } from 'nuqs/server'
|
||||
|
||||
/**
|
||||
* The open folder on a resource list built on the generic folder engine. Declared here rather
|
||||
* than per feature so the surfaces that use the engine share one URL key and one set of
|
||||
* semantics, and so a server `createSearchParamsCache` could read the same parser.
|
||||
*
|
||||
* Files declares its own `?folderId=` in `files/search-params.ts` — same key, same meaning, but
|
||||
* it predates this module and has not been converged.
|
||||
*
|
||||
* Deliberately nullable (no `.withDefault`): a clean URL means the workspace root, which is
|
||||
* the only sane default and keeps a shared link short.
|
||||
*/
|
||||
export const folderNavParsers = {
|
||||
folderId: parseAsString,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Opening a folder is a destination, so it lands in the browser history and Back walks out
|
||||
* of the folder. Defaults clear from the URL to keep shared links short.
|
||||
*/
|
||||
export const folderNavUrlKeys = {
|
||||
history: 'push',
|
||||
clearOnDefault: true,
|
||||
} as const
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useQueryStates } from 'nuqs'
|
||||
import type { ServedFolderResourceType } from '@/lib/api/contracts/folders'
|
||||
import {
|
||||
folderNavParsers,
|
||||
folderNavUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/components/folders/search-params'
|
||||
import { useFolders } from '@/hooks/queries/folders'
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
|
||||
export interface UseFolderNavigationOptions {
|
||||
resourceType: ServedFolderResourceType
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
export interface FolderNavigation {
|
||||
/** The open folder, or `null` at the workspace root. */
|
||||
currentFolderId: string | null
|
||||
setCurrentFolderId: (folderId: string | null) => void
|
||||
/**
|
||||
* Root-first ancestor chain of the open folder, the open folder last. Empty at the root,
|
||||
* and empty while the folder list is still loading or when the id no longer resolves (a
|
||||
* deleted folder or a stale bookmark) — callers fall back to the root listing rather than
|
||||
* rendering a broken trail.
|
||||
*/
|
||||
breadcrumbs: WorkflowFolder[]
|
||||
/** Every active folder in this resource's tree, as returned by the folders API. */
|
||||
folders: WorkflowFolder[]
|
||||
folderById: Map<string, WorkflowFolder>
|
||||
/**
|
||||
* Whether `folders`/`folderById` can be trusted to be the COMPLETE set for this workspace.
|
||||
*
|
||||
* Deliberately exposed instead of `isLoading`, which is a footgun here: it is false for a
|
||||
* disabled query (no `workspaceId`), false for an errored one, and — because `useFolders`
|
||||
* sets `keepPreviousData` — false while the previous workspace's folders are still on screen
|
||||
* during a switch. A caller deciding "this resource's `folderId` does not resolve, so treat it
|
||||
* as an orphan" off `isLoading` would dump every foldered row at the root in all three.
|
||||
*/
|
||||
foldersResolved: boolean
|
||||
}
|
||||
|
||||
const EMPTY_FOLDERS: WorkflowFolder[] = []
|
||||
|
||||
/**
|
||||
* URL-backed folder navigation for a foldered resource list. Deliberately
|
||||
* resourceType-agnostic — the Workflows, Files, Knowledge, and Tables trees are separate
|
||||
* folder hierarchies over one table, so the caller names its own tree and gets that tree's
|
||||
* folders, navigation state, and ancestor chain.
|
||||
*
|
||||
* The open folder lives in the URL rather than component state because it is shareable,
|
||||
* bookmarkable, and belongs in the back stack (see `.claude/rules/sim-url-state.md`).
|
||||
*/
|
||||
export function useFolderNavigation({
|
||||
resourceType,
|
||||
workspaceId,
|
||||
}: UseFolderNavigationOptions): FolderNavigation {
|
||||
const [{ folderId: currentFolderId }, setFolderParams] = useQueryStates(
|
||||
folderNavParsers,
|
||||
folderNavUrlKeys
|
||||
)
|
||||
|
||||
const {
|
||||
data: folders = EMPTY_FOLDERS,
|
||||
isSuccess,
|
||||
isPlaceholderData,
|
||||
} = useFolders(workspaceId, { resourceType })
|
||||
|
||||
/**
|
||||
* The folder list is only trustworthy enough to evict a `folderId` when the query has
|
||||
* actually succeeded for THIS workspace. `isLoading` alone is not that signal: it is false
|
||||
* for a disabled query (no `workspaceId`), false for an errored one, and — because
|
||||
* `useFolders` sets `keepPreviousData` — false while showing the previous workspace's
|
||||
* folders during a workspace switch. In all three the list is empty or stale, and healing
|
||||
* off it would throw away a perfectly good folder.
|
||||
*/
|
||||
const foldersResolved = isSuccess && !isPlaceholderData
|
||||
|
||||
const setCurrentFolderId = useCallback(
|
||||
(folderId: string | null) => {
|
||||
void setFolderParams({ folderId })
|
||||
},
|
||||
[setFolderParams]
|
||||
)
|
||||
|
||||
const folderById = useMemo(() => {
|
||||
const byId = new Map<string, WorkflowFolder>()
|
||||
for (const folder of folders) byId.set(folder.id, folder)
|
||||
return byId
|
||||
}, [folders])
|
||||
|
||||
/**
|
||||
* Heals a `?folderId=` that no longer resolves — a bookmark to a folder since deleted, or a
|
||||
* link from someone whose workspace it was not.
|
||||
*
|
||||
* Without this the page is a dead end rather than a mistake: the header falls back to the
|
||||
* root title while the list still filters on the dead id, so the user sees a page that looks
|
||||
* like the root but is empty and hides everything actually at the root. Worse, the create
|
||||
* and upload actions keep targeting that id, so a new resource is filed somewhere nothing
|
||||
* can reach.
|
||||
*
|
||||
* Gated on {@link FolderNavigation.foldersResolved} so an empty or stale index never evicts a
|
||||
* perfectly good id.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!foldersResolved || !currentFolderId || folderById.has(currentFolderId)) return
|
||||
/**
|
||||
* `history: 'replace'`, overriding the `push` these params default to. Opening a folder is
|
||||
* a navigation and belongs in the back stack; correcting a URL that never pointed anywhere
|
||||
* is not. Pushing here strands the user: Back returns to the dead `?folderId=`, which heals
|
||||
* and pushes again, so Back never escapes the page.
|
||||
*/
|
||||
void setFolderParams({ folderId: null }, { history: 'replace' })
|
||||
}, [foldersResolved, currentFolderId, folderById, setFolderParams])
|
||||
|
||||
const breadcrumbs = useMemo(() => {
|
||||
if (!currentFolderId) return EMPTY_FOLDERS
|
||||
|
||||
/**
|
||||
* Walks up via `parentId` rather than splitting a materialized path — the generic
|
||||
* folder table stores no path — and guards against a cycle, which the DB permits
|
||||
* between constraint checks. An unresolvable link collapses the whole trail so the
|
||||
* header falls back to the root title instead of rendering a partial path.
|
||||
*/
|
||||
const chain: WorkflowFolder[] = []
|
||||
const seen = new Set<string>()
|
||||
let cursor: string | null = currentFolderId
|
||||
|
||||
while (cursor && !seen.has(cursor)) {
|
||||
seen.add(cursor)
|
||||
const folder: WorkflowFolder | undefined = folderById.get(cursor)
|
||||
if (!folder) return EMPTY_FOLDERS
|
||||
chain.unshift(folder)
|
||||
cursor = folder.parentId
|
||||
}
|
||||
|
||||
return chain
|
||||
}, [currentFolderId, folderById])
|
||||
|
||||
return {
|
||||
currentFolderId,
|
||||
setCurrentFolderId,
|
||||
breadcrumbs,
|
||||
folders,
|
||||
folderById,
|
||||
foldersResolved,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
'use client'
|
||||
|
||||
import { type DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { parseFolderedRowId } from '@/app/workspace/[workspaceId]/components/folders/folder-row-id'
|
||||
import type { RowDragDropConfig } from '@/app/workspace/[workspaceId]/components/resource/resource'
|
||||
|
||||
/**
|
||||
* Private drag payload, namespaced so a drag started on another Sim surface (or an external
|
||||
* drag) is never mistaken for a foldered list row.
|
||||
*/
|
||||
const DRAG_ROW_MIME = 'application/x-sim-foldered-row'
|
||||
|
||||
const DRAG_GHOST_STYLE =
|
||||
'position:fixed;top:-500px;left:0;display:inline-flex;align-items:center;padding:4px 10px;background:var(--surface-active);border:1px solid var(--border);border-radius:8px;font-family:system-ui,-apple-system,sans-serif;font-size:13px;color:var(--text-body);white-space:nowrap;pointer-events:none;box-shadow:var(--shadow-medium);z-index:var(--z-toast)'
|
||||
|
||||
/** Shared empty set so an idle drag state keeps a stable identity across renders. */
|
||||
const EMPTY_ROW_IDS = new Set<string>()
|
||||
|
||||
export interface UseFolderRowDragDropOptions {
|
||||
/** Drag and drop are edits; a reader gets neither draggable rows nor drop targets. */
|
||||
canEdit: boolean
|
||||
/** Row currently being renamed inline, which must stay editable rather than draggable. */
|
||||
editingRowId: string | null
|
||||
/** Transitive descendants of each folder, from `buildDescendantIndex`. */
|
||||
descendantsByFolderId: Map<string, Set<string>>
|
||||
/** Current `parentId` of a folder in this tree, for rejecting a no-op drop. */
|
||||
getFolderParentId: (folderId: string) => string | null | undefined
|
||||
/** Current `folderId` of a resource row, for rejecting a no-op drop. */
|
||||
getResourceFolderId: (resourceId: string) => string | null | undefined
|
||||
/** Label shown in the drag ghost. */
|
||||
getRowLabel: (rowId: string) => string
|
||||
/** Reparents a folder into `targetFolderId`. */
|
||||
onMoveFolder: (folderId: string, targetFolderId: string) => void
|
||||
/** Files a resource into `targetFolderId`. */
|
||||
onMoveResource: (resourceId: string, targetFolderId: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag-a-row-onto-a-folder-row moves for a foldered resource list, shared so Knowledge and
|
||||
* Tables behave exactly like Files: only folder rows accept a drop, a folder cannot land in
|
||||
* itself or its own subtree, and a row already sitting directly in the target is a no-op.
|
||||
*
|
||||
* Single-row only, which is what the resource lists that use it support. The Files page
|
||||
* keeps its own configuration because it additionally drags multi-selections and accepts
|
||||
* external OS file drops.
|
||||
*/
|
||||
export function useFolderRowDragDrop({
|
||||
canEdit,
|
||||
editingRowId,
|
||||
descendantsByFolderId,
|
||||
getFolderParentId,
|
||||
getResourceFolderId,
|
||||
getRowLabel,
|
||||
onMoveFolder,
|
||||
onMoveResource,
|
||||
}: UseFolderRowDragDropOptions): RowDragDropConfig {
|
||||
const [activeDropTargetId, setActiveDropTargetId] = useState<string | null>(null)
|
||||
const [draggedRowIds, setDraggedRowIds] = useState<Set<string>>(() => EMPTY_ROW_IDS)
|
||||
/**
|
||||
* The in-flight drag source, mirrored outside React state because `onDragOver` fires far
|
||||
* faster than a re-render and must decide drop validity against the current source
|
||||
* synchronously.
|
||||
*/
|
||||
const draggedRowIdRef = useRef<string | null>(null)
|
||||
const dragGhostRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
const optionsRef = useRef({
|
||||
descendantsByFolderId,
|
||||
getFolderParentId,
|
||||
getResourceFolderId,
|
||||
getRowLabel,
|
||||
onMoveFolder,
|
||||
onMoveResource,
|
||||
})
|
||||
optionsRef.current = {
|
||||
descendantsByFolderId,
|
||||
getFolderParentId,
|
||||
getResourceFolderId,
|
||||
getRowLabel,
|
||||
onMoveFolder,
|
||||
onMoveResource,
|
||||
}
|
||||
|
||||
/**
|
||||
* The ghost lives on `document.body`, but the only thing that removes it is `dragend`, which
|
||||
* fires on the SOURCE ROW. `Resource.Table` is virtualized, so scrolling the source out of
|
||||
* view mid-drag unmounts that row and the event never arrives — leaving the ghost stuck on
|
||||
* the page and every row frozen at drag opacity. Clean up on unmount as the backstop.
|
||||
*/
|
||||
useEffect(
|
||||
() => () => {
|
||||
dragGhostRef.current?.remove()
|
||||
dragGhostRef.current = null
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const isInvalidDropTarget = useCallback((targetRowId: string, sourceRowId: string) => {
|
||||
const target = parseFolderedRowId(targetRowId)
|
||||
if (target.kind !== 'folder') return true
|
||||
|
||||
const source = parseFolderedRowId(sourceRowId)
|
||||
if (source.kind === 'folder') {
|
||||
if (source.id === target.id) return true
|
||||
if (optionsRef.current.descendantsByFolderId.get(source.id)?.has(target.id)) return true
|
||||
return (optionsRef.current.getFolderParentId(source.id) ?? null) === target.id
|
||||
}
|
||||
return (optionsRef.current.getResourceFolderId(source.id) ?? null) === target.id
|
||||
}, [])
|
||||
|
||||
return useMemo<RowDragDropConfig>(
|
||||
() => ({
|
||||
activeDropTargetId,
|
||||
draggedRowIds,
|
||||
isAnyDragActive: draggedRowIds.size > 0,
|
||||
isRowDraggable: (rowId) => canEdit && editingRowId !== rowId,
|
||||
isRowDropTarget: (rowId) => canEdit && parseFolderedRowId(rowId).kind === 'folder',
|
||||
onDragStart: (e: DragEvent<HTMLDivElement>, rowId) => {
|
||||
if (!canEdit || editingRowId === rowId) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
draggedRowIdRef.current = rowId
|
||||
setDraggedRowIds(new Set([rowId]))
|
||||
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData(DRAG_ROW_MIME, rowId)
|
||||
e.dataTransfer.setData('text/plain', rowId)
|
||||
|
||||
const ghost = document.createElement('div')
|
||||
ghost.style.cssText = DRAG_GHOST_STYLE
|
||||
const text = document.createElement('span')
|
||||
text.style.cssText = 'max-width:200px;overflow:hidden;text-overflow:ellipsis'
|
||||
text.textContent = optionsRef.current.getRowLabel(rowId)
|
||||
ghost.appendChild(text)
|
||||
document.body.appendChild(ghost)
|
||||
// Force a layout pass so the drag image is measurable before it is captured.
|
||||
void ghost.offsetHeight
|
||||
e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2)
|
||||
dragGhostRef.current = ghost
|
||||
},
|
||||
onDragOver: (e: DragEvent<HTMLDivElement>, rowId) => {
|
||||
const sourceRowId = draggedRowIdRef.current
|
||||
if (sourceRowId) {
|
||||
if (isInvalidDropTarget(rowId, sourceRowId)) return
|
||||
} else if (!e.dataTransfer.types.includes(DRAG_ROW_MIME)) {
|
||||
/**
|
||||
* No local source and no payload of ours — an external or foreign drag. Returning
|
||||
* without `preventDefault` leaves the browser's default handling in place, which is
|
||||
* what stops a dropped OS file from navigating the tab away from the app.
|
||||
*/
|
||||
return
|
||||
}
|
||||
/**
|
||||
* `dataTransfer.getData` is empty during dragover by design (the drag data store is
|
||||
* protected until drop), so a drag that began in another mount of this page can only be
|
||||
* recognised by its MIME type here. `onDrop` re-checks validity with the real payload.
|
||||
*/
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
/**
|
||||
* Highlight only when the source is known and was checked. Without it every folder
|
||||
* would light up as a valid target — including the dragged folder itself and its own
|
||||
* descendants — and the drop would then silently do nothing.
|
||||
*/
|
||||
if (sourceRowId) setActiveDropTargetId(rowId)
|
||||
},
|
||||
onDragLeave: (e: DragEvent<HTMLDivElement>, rowId) => {
|
||||
const relatedTarget = e.relatedTarget
|
||||
if (relatedTarget instanceof Node && e.currentTarget.contains(relatedTarget)) return
|
||||
setActiveDropTargetId((current) => (current === rowId ? null : current))
|
||||
},
|
||||
onDrop: (e: DragEvent<HTMLDivElement>, rowId) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setActiveDropTargetId(null)
|
||||
|
||||
const target = parseFolderedRowId(rowId)
|
||||
if (target.kind !== 'folder') return
|
||||
|
||||
// Prefer the dataTransfer payload over the ref so a drag that started in another
|
||||
// mount of this page still resolves to a real row id.
|
||||
const sourceRowId = e.dataTransfer.getData(DRAG_ROW_MIME) || draggedRowIdRef.current
|
||||
if (!sourceRowId || isInvalidDropTarget(rowId, sourceRowId)) return
|
||||
|
||||
const source = parseFolderedRowId(sourceRowId)
|
||||
if (source.kind === 'folder') optionsRef.current.onMoveFolder(source.id, target.id)
|
||||
else optionsRef.current.onMoveResource(source.id, target.id)
|
||||
},
|
||||
onDragEnd: () => {
|
||||
dragGhostRef.current?.remove()
|
||||
dragGhostRef.current = null
|
||||
draggedRowIdRef.current = null
|
||||
setDraggedRowIds(EMPTY_ROW_IDS)
|
||||
setActiveDropTargetId(null)
|
||||
},
|
||||
}),
|
||||
[activeDropTargetId, draggedRowIds, canEdit, editingRowId, isInvalidDropTarget]
|
||||
)
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
cn,
|
||||
Loader,
|
||||
} from '@sim/emcn'
|
||||
import { Pin } from '@sim/emcn/icons'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input'
|
||||
@@ -58,6 +59,16 @@ export interface ResourceCell {
|
||||
* resting cell exactly (same gap, weight, icon size).
|
||||
*/
|
||||
editing?: ResourceCellEditing
|
||||
/**
|
||||
* Marks the row as pinned. Renders a small non-interactive glyph after the label — pinned
|
||||
* rows sort to the top of every list, and without an indicator that ordering reads as
|
||||
* arbitrary. Pinning itself is an action on the row's context menu, deliberately not a
|
||||
* hover button here.
|
||||
*
|
||||
* Honoured only on the plain label cell: a cell rendering custom `content` owns its own
|
||||
* layout, and the rename field replaces the label entirely while it is open.
|
||||
*/
|
||||
pinned?: boolean
|
||||
}
|
||||
|
||||
export interface ResourceRow {
|
||||
@@ -488,9 +499,16 @@ interface CellContentProps {
|
||||
label: string
|
||||
content?: ReactNode
|
||||
editing?: ResourceCellEditing
|
||||
pinned?: boolean
|
||||
}
|
||||
|
||||
const CellContent = memo(function CellContent({ icon, label, content, editing }: CellContentProps) {
|
||||
const CellContent = memo(function CellContent({
|
||||
icon,
|
||||
label,
|
||||
content,
|
||||
editing,
|
||||
pinned,
|
||||
}: CellContentProps) {
|
||||
if (editing) {
|
||||
return (
|
||||
<span className={cn('flex min-w-0 items-center', chipContentGap)}>
|
||||
@@ -510,6 +528,13 @@ const CellContent = memo(function CellContent({ icon, label, content, editing }:
|
||||
<span className={cn('flex min-w-0 items-center', chipContentGap)}>
|
||||
{icon && <span className={cellIconNodeClass}>{icon}</span>}
|
||||
<FloatingOverflowText label={label} className={cn('block', chipContentLabelClass)} />
|
||||
{pinned && (
|
||||
<Pin
|
||||
className='size-[12px] shrink-0 text-[var(--text-icon)]'
|
||||
role='img'
|
||||
aria-label='Pinned'
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
@@ -681,6 +706,7 @@ const DataRow = memo(function DataRow({
|
||||
label={cell?.label || EMPTY_CELL_PLACEHOLDER}
|
||||
content={cell?.content}
|
||||
editing={cell?.editing}
|
||||
pinned={cell?.pinned}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
} from '@sim/emcn'
|
||||
import { Download } from '@sim/emcn/icons'
|
||||
import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
|
||||
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options'
|
||||
import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-options'
|
||||
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders'
|
||||
import { renderMoveOption } from '@/app/workspace/[workspaceId]/components/folders'
|
||||
|
||||
interface FilesActionBarProps {
|
||||
selectedCount: number
|
||||
|
||||
+2
-2
@@ -16,8 +16,8 @@ import {
|
||||
Pencil,
|
||||
} from '@sim/emcn'
|
||||
import { Download, Link, Pin, Trash } from '@sim/emcn/icons'
|
||||
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options'
|
||||
import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-options'
|
||||
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders'
|
||||
import { renderMoveOption } from '@/app/workspace/[workspaceId]/components/folders'
|
||||
|
||||
interface FileRowContextMenuProps {
|
||||
isOpen: boolean
|
||||
|
||||
@@ -61,6 +61,11 @@ import {
|
||||
Resource,
|
||||
timeCell,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders'
|
||||
import {
|
||||
parseMoveOptionValue,
|
||||
ROOT_MOVE_OPTION_VALUE,
|
||||
} from '@/app/workspace/[workspaceId]/components/folders'
|
||||
import { FilesActionBar } from '@/app/workspace/[workspaceId]/files/components/action-bar'
|
||||
import { DeleteConfirmModal } from '@/app/workspace/[workspaceId]/files/components/delete-confirm-modal'
|
||||
import { FileRowContextMenu } from '@/app/workspace/[workspaceId]/files/components/file-row-context-menu'
|
||||
@@ -74,7 +79,6 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/files/components/file-viewer'
|
||||
import { FilesListContextMenu } from '@/app/workspace/[workspaceId]/files/components/files-list-context-menu'
|
||||
import { ShareModal } from '@/app/workspace/[workspaceId]/files/components/share-modal'
|
||||
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options'
|
||||
import {
|
||||
filesFilterParsers,
|
||||
filesFilterUrlKeys,
|
||||
@@ -378,12 +382,34 @@ export function Files() {
|
||||
directSize.set(file.folderId, (directSize.get(file.folderId) ?? 0) + file.size)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Children indexed once rather than re-scanning `folders` per node — the roll-up visits
|
||||
* every folder, so the filter made this quadratic.
|
||||
*/
|
||||
const childrenByParent = new Map<string, WorkspaceFileFolderApi[]>()
|
||||
for (const folder of folders) {
|
||||
if (!folder.parentId) continue
|
||||
const siblings = childrenByParent.get(folder.parentId)
|
||||
if (siblings) siblings.push(folder)
|
||||
else childrenByParent.set(folder.parentId, [folder])
|
||||
}
|
||||
|
||||
const totalSize = new Map<string, number>()
|
||||
/**
|
||||
* `visiting` terminates a parent/child cycle. The optimistic folder-move write can produce
|
||||
* one in cache, and without the guard this recurses until the stack blows and takes the
|
||||
* whole page down — the same guard the shared folder helpers carry.
|
||||
*/
|
||||
const visiting = new Set<string>()
|
||||
const getTotal = (folderId: string): number => {
|
||||
if (totalSize.has(folderId)) return totalSize.get(folderId)!
|
||||
const children = folders.filter((f) => f.parentId === folderId)
|
||||
const cached = totalSize.get(folderId)
|
||||
if (cached !== undefined) return cached
|
||||
if (visiting.has(folderId)) return 0
|
||||
visiting.add(folderId)
|
||||
const size =
|
||||
(directSize.get(folderId) ?? 0) + children.reduce((s, c) => s + getTotal(c.id), 0)
|
||||
(directSize.get(folderId) ?? 0) +
|
||||
(childrenByParent.get(folderId) ?? []).reduce((sum, child) => sum + getTotal(child.id), 0)
|
||||
visiting.delete(folderId)
|
||||
totalSize.set(folderId, size)
|
||||
return size
|
||||
}
|
||||
@@ -505,6 +531,7 @@ export function Files() {
|
||||
name: {
|
||||
icon: <Folder className='size-[14px]' />,
|
||||
label: folder.name,
|
||||
pinned: pinnedFolderIds.has(folder.id),
|
||||
},
|
||||
size: {
|
||||
label:
|
||||
@@ -530,6 +557,7 @@ export function Files() {
|
||||
name: {
|
||||
icon: <Icon className='size-[14px]' />,
|
||||
label: file.name,
|
||||
pinned: pinnedFileIds.has(file.id),
|
||||
},
|
||||
size: {
|
||||
label: formatFileSize(file.size, { includeBytes: true }),
|
||||
@@ -547,7 +575,7 @@ export function Files() {
|
||||
})
|
||||
|
||||
return [...folderRows, ...fileRows]
|
||||
}, [visibleFolders, filteredFiles, membersById, folderSizeMap])
|
||||
}, [visibleFolders, filteredFiles, membersById, folderSizeMap, pinnedFolderIds, pinnedFileIds])
|
||||
|
||||
const rows: ResourceRow[] = useMemo(() => {
|
||||
if (!listRename.editingId) return baseRows
|
||||
@@ -1378,7 +1406,7 @@ export function Files() {
|
||||
|
||||
const handleContextMenuMove = useCallback(
|
||||
async (optionValue: string) => {
|
||||
const targetFolderId = optionValue === '__root__' ? null : optionValue
|
||||
const targetFolderId = parseMoveOptionValue(optionValue)
|
||||
try {
|
||||
await moveItems.mutateAsync({
|
||||
workspaceId,
|
||||
@@ -1765,7 +1793,7 @@ export function Files() {
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
|
||||
.map((f) => ({ value: f.id, label: f.name, children: buildSubtree(f.id) }))
|
||||
|
||||
return [{ value: '__root__', label: 'Files', children: [] }, ...buildSubtree(null)]
|
||||
return [{ value: ROOT_MOVE_OPTION_VALUE, label: 'Files', children: [] }, ...buildSubtree(null)]
|
||||
}, [folders, selectedFolderIds, descendantFolderIdsByFolderId])
|
||||
|
||||
const sortConfig: SortConfig = useMemo(
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} from '@sim/emcn'
|
||||
import { Folder } from '@sim/emcn/icons'
|
||||
|
||||
export interface MoveOptionNode {
|
||||
value: string
|
||||
label: string
|
||||
children: MoveOptionNode[]
|
||||
}
|
||||
|
||||
export function renderMoveOption(
|
||||
option: MoveOptionNode,
|
||||
onMove: (value: string) => void
|
||||
): ReactNode {
|
||||
if (option.children.length === 0) {
|
||||
return (
|
||||
<DropdownMenuItem key={option.value} onSelect={() => onMove(option.value)}>
|
||||
<Folder />
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<DropdownMenuSub key={option.value}>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Folder />
|
||||
{option.label}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onSelect={() => onMove(option.value)}>Move here</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{option.children.map((child) => renderMoveOption(child, onMove))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -22,12 +22,12 @@ import type {
|
||||
MothershipResourceType,
|
||||
} from '@/app/workspace/[workspaceId]/home/types'
|
||||
import { getBareIconStyle, type StyleableIcon } from '@/blocks/icon-color'
|
||||
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
|
||||
import { logKeys } from '@/hooks/queries/logs'
|
||||
import { mothershipChatKeys } from '@/hooks/queries/mothership-chats'
|
||||
import { scheduleKeys } from '@/hooks/queries/schedules'
|
||||
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
|
||||
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
|
||||
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
|
||||
import { tableKeys } from '@/hooks/queries/utils/table-keys'
|
||||
import { workspaceFileFolderKeys } from '@/hooks/queries/workspace-file-folders'
|
||||
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
|
||||
|
||||
@@ -2,8 +2,7 @@ import type { QueryClient } from '@tanstack/react-query'
|
||||
import type { FolderApi } from '@/lib/api/contracts'
|
||||
import type { ListWorkspaceFilesResponse } from '@/lib/api/contracts/workspace-files'
|
||||
import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch'
|
||||
import { FOLDER_LIST_STALE_TIME, mapFolder } from '@/hooks/queries/folders'
|
||||
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
|
||||
import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys'
|
||||
import {
|
||||
WORKSPACE_FILES_LIST_STALE_TIME,
|
||||
workspaceFilesKeys,
|
||||
|
||||
+7
@@ -37,6 +37,11 @@ const logger = createLogger('CreateBaseModal')
|
||||
interface CreateBaseModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/**
|
||||
* Folder the new base is filed under — the folder the list is currently showing, so a
|
||||
* base created inside a folder appears where the user is looking. `null` is the root.
|
||||
*/
|
||||
folderId?: string | null
|
||||
}
|
||||
|
||||
const STRATEGY_OPTIONS = [
|
||||
@@ -127,6 +132,7 @@ interface SubmitStatus {
|
||||
export const CreateBaseModal = memo(function CreateBaseModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
folderId = null,
|
||||
}: CreateBaseModalProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
@@ -262,6 +268,7 @@ export const CreateBaseModal = memo(function CreateBaseModal({
|
||||
name: data.name,
|
||||
description: data.description || undefined,
|
||||
workspaceId: workspaceId,
|
||||
folderId,
|
||||
chunkingConfig: {
|
||||
maxSize: data.maxChunkSize,
|
||||
minSize: data.minChunkSize,
|
||||
|
||||
+33
-2
@@ -6,9 +6,22 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@sim/emcn'
|
||||
import { Duplicate, Pencil, Pin, SquareArrowUpRight, TagIcon, Trash } from '@sim/emcn/icons'
|
||||
import {
|
||||
Duplicate,
|
||||
FolderInput,
|
||||
Pencil,
|
||||
Pin,
|
||||
SquareArrowUpRight,
|
||||
TagIcon,
|
||||
Trash,
|
||||
} from '@sim/emcn/icons'
|
||||
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders'
|
||||
import { renderMoveOptions } from '@/app/workspace/[workspaceId]/components/folders'
|
||||
|
||||
interface KnowledgeBaseContextMenuProps {
|
||||
isOpen: boolean
|
||||
@@ -22,6 +35,9 @@ interface KnowledgeBaseContextMenuProps {
|
||||
pinned?: boolean
|
||||
onEdit?: () => void
|
||||
onDelete?: () => void
|
||||
/** Files the base under another folder; the value is a folder id or the root sentinel. */
|
||||
onMove?: (optionValue: string) => void
|
||||
moveOptions?: MoveOptionNode[]
|
||||
showOpenInNewTab?: boolean
|
||||
showViewTags?: boolean
|
||||
showEdit?: boolean
|
||||
@@ -45,6 +61,8 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({
|
||||
pinned = false,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onMove,
|
||||
moveOptions,
|
||||
showOpenInNewTab = true,
|
||||
showViewTags = true,
|
||||
showEdit = true,
|
||||
@@ -54,7 +72,8 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({
|
||||
}: KnowledgeBaseContextMenuProps) {
|
||||
const hasNavigationSection = showOpenInNewTab && !!onOpenInNewTab
|
||||
const hasInfoSection = (showViewTags && !!onViewTags) || !!onCopyId || !!onTogglePin
|
||||
const hasEditSection = showEdit && !!onEdit
|
||||
const hasMoveSection = !disableEdit && !!onMove && !!moveOptions && moveOptions.length > 0
|
||||
const hasEditSection = (showEdit && !!onEdit) || hasMoveSection
|
||||
const hasDestructiveSection = showDelete && !!onDelete
|
||||
|
||||
return (
|
||||
@@ -116,6 +135,18 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{hasMoveSection && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<FolderInput />
|
||||
Move to
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{renderMoveOptions(moveOptions!, onMove!)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
|
||||
{hasEditSection && hasDestructiveSection && <DropdownMenuSeparator />}
|
||||
{showDelete && onDelete && (
|
||||
<DropdownMenuItem disabled={disableDelete} onSelect={onDelete}>
|
||||
|
||||
+12
-2
@@ -2,26 +2,30 @@
|
||||
|
||||
import { memo } from 'react'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@sim/emcn'
|
||||
import { Plus } from '@sim/emcn/icons'
|
||||
import { FolderPlus, Plus } from '@sim/emcn/icons'
|
||||
|
||||
interface KnowledgeListContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
onClose: () => void
|
||||
onAddKnowledgeBase?: () => void
|
||||
onAddFolder?: () => void
|
||||
disableAdd?: boolean
|
||||
disableAddFolder?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu component for the knowledge base list page.
|
||||
* Displays "Add knowledge base" option when right-clicking on empty space.
|
||||
* Displays the create actions when right-clicking on empty space.
|
||||
*/
|
||||
export const KnowledgeListContextMenu = memo(function KnowledgeListContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
onClose,
|
||||
onAddKnowledgeBase,
|
||||
onAddFolder,
|
||||
disableAdd = false,
|
||||
disableAddFolder = false,
|
||||
}: KnowledgeListContextMenuProps) {
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
|
||||
@@ -51,6 +55,12 @@ export const KnowledgeListContextMenu = memo(function KnowledgeListContextMenu({
|
||||
Add knowledge base
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onAddFolder && (
|
||||
<DropdownMenuItem disabled={disableAddFolder} onSelect={onAddFolder}>
|
||||
<FolderPlus />
|
||||
New folder
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
WHOLE_FILE_PARALLEL_UPLOADS,
|
||||
} from '@/lib/uploads/client/direct-upload'
|
||||
import { getFileContentType, isAbortError, isNetworkError } from '@/lib/uploads/utils/file-utils'
|
||||
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
|
||||
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
|
||||
|
||||
const logger = createLogger('KnowledgeUpload')
|
||||
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ChipDropdownOption } from '@sim/emcn'
|
||||
import { Button, ChipDropdown, Plus, Tooltip } from '@sim/emcn'
|
||||
import { Database } from '@sim/emcn/icons'
|
||||
import { Button, ChipConfirmModal, ChipDropdown, Plus, Tooltip, toast } from '@sim/emcn'
|
||||
import { Database, FolderPlus } from '@sim/emcn/icons'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useQueryStates } from 'nuqs'
|
||||
import type { KnowledgeBaseData } from '@/lib/knowledge/types'
|
||||
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
|
||||
import type {
|
||||
BreadcrumbItem,
|
||||
FilterTag,
|
||||
ResourceAction,
|
||||
ResourceCell,
|
||||
@@ -24,6 +26,20 @@ import {
|
||||
Resource,
|
||||
timeCell,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders'
|
||||
import {
|
||||
buildDescendantIndex,
|
||||
buildMoveOptions,
|
||||
FolderContextMenu,
|
||||
folderBreadcrumbItems,
|
||||
folderRow,
|
||||
folderRowId,
|
||||
nextUntitledFolderName,
|
||||
parseFolderedRowId,
|
||||
parseMoveOptionValue,
|
||||
useFolderNavigation,
|
||||
useFolderRowDragDrop,
|
||||
} from '@/app/workspace/[workspaceId]/components/folders'
|
||||
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
|
||||
import {
|
||||
CreateBaseModal,
|
||||
@@ -42,13 +58,16 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
|
||||
import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
|
||||
import { useCreateFolder, useDeleteFolderMutation, useUpdateFolder } from '@/hooks/queries/folders'
|
||||
import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge'
|
||||
import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items'
|
||||
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
|
||||
import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
|
||||
import { useInlineRename } from '@/hooks/use-inline-rename'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useUrlSort } from '@/hooks/use-url-sort'
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
|
||||
const logger = createLogger('Knowledge')
|
||||
|
||||
@@ -82,6 +101,9 @@ const CONTENT_FILTER_OPTIONS: ChipDropdownOption[] = [
|
||||
|
||||
const FILTER_SECTION_LABEL_CLASS = 'text-[var(--text-muted)] text-small'
|
||||
|
||||
const ROOT_BREADCRUMB_LABEL = 'Knowledge Base'
|
||||
const FOLDER_RESOURCE_TYPE = 'knowledge_base' as const
|
||||
|
||||
function connectorCell(connectorTypes?: string[]): ResourceCell {
|
||||
if (!connectorTypes || connectorTypes.length === 0) {
|
||||
return { label: EMPTY_CELL_PLACEHOLDER }
|
||||
@@ -144,18 +166,43 @@ export function Knowledge() {
|
||||
|
||||
const { knowledgeBases, error } = useKnowledgeBasesList(workspaceId)
|
||||
const { data: members } = useWorkspaceMembersQuery(workspaceId)
|
||||
/**
|
||||
* Indexed once: `ownerCell` resolves a member per row, so passing the raw array makes the
|
||||
* owner column O(rows x members) on every rebuild. Tables already does this.
|
||||
*/
|
||||
const membersById = useMemo(() => {
|
||||
const byId = new Map<string, WorkspaceMember>()
|
||||
for (const member of members ?? []) byId.set(member.userId, member)
|
||||
return byId
|
||||
}, [members])
|
||||
/**
|
||||
* Two pin lookups: a folder pins under `resourceType: 'folder'`, which is a different pin
|
||||
* namespace from the knowledge bases it contains, so one set cannot answer for both.
|
||||
*/
|
||||
const pinnedBaseIds = usePinnedIds(workspaceId, 'knowledge_base')
|
||||
const pinnedFolderIds = usePinnedIds(workspaceId, 'folder')
|
||||
const pinItem = usePinItem()
|
||||
const unpinItem = useUnpinItem()
|
||||
|
||||
if (error) {
|
||||
logger.error('Failed to load knowledge bases:', error)
|
||||
}
|
||||
useEffect(() => {
|
||||
if (error) logger.error('Failed to load knowledge bases:', error)
|
||||
}, [error])
|
||||
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId)
|
||||
const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId)
|
||||
|
||||
const { currentFolderId, setCurrentFolderId, breadcrumbs, folders, folderById, foldersResolved } =
|
||||
useFolderNavigation({
|
||||
resourceType: FOLDER_RESOURCE_TYPE,
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
const createFolder = useCreateFolder()
|
||||
const updateFolder = useUpdateFolder()
|
||||
const deleteFolder = useDeleteFolderMutation()
|
||||
|
||||
const [
|
||||
{
|
||||
search: urlSearchQuery,
|
||||
@@ -205,6 +252,16 @@ export function Knowledge() {
|
||||
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const [activeFolder, setActiveFolder] = useState<WorkflowFolder | null>(null)
|
||||
const [folderPendingDelete, setFolderPendingDelete] = useState<WorkflowFolder | null>(null)
|
||||
|
||||
const {
|
||||
isOpen: isFolderContextMenuOpen,
|
||||
position: folderContextMenuPosition,
|
||||
handleContextMenu: handleFolderCtxMenu,
|
||||
closeMenu: closeFolderContextMenu,
|
||||
} = useContextMenu()
|
||||
|
||||
const {
|
||||
isOpen: isListContextMenuOpen,
|
||||
position: listContextMenuPosition,
|
||||
@@ -222,12 +279,77 @@ export function Knowledge() {
|
||||
const isRowContextMenuOpenRef = useRef(isRowContextMenuOpen)
|
||||
isRowContextMenuOpenRef.current = isRowContextMenuOpen
|
||||
|
||||
const isFolderContextMenuOpenRef = useRef(isFolderContextMenuOpen)
|
||||
isFolderContextMenuOpenRef.current = isFolderContextMenuOpen
|
||||
|
||||
const knowledgeBasesRef = useRef(knowledgeBases)
|
||||
knowledgeBasesRef.current = knowledgeBases
|
||||
|
||||
const activeKnowledgeBaseRef = useRef(activeKnowledgeBase)
|
||||
activeKnowledgeBaseRef.current = activeKnowledgeBase
|
||||
|
||||
const activeFolderRef = useRef(activeFolder)
|
||||
activeFolderRef.current = activeFolder
|
||||
|
||||
const foldersRef = useRef(folders)
|
||||
foldersRef.current = folders
|
||||
|
||||
const currentFolderIdRef = useRef(currentFolderId)
|
||||
currentFolderIdRef.current = currentFolderId
|
||||
|
||||
/**
|
||||
* Renames both kinds of row through one multiplexed session — the row id already encodes
|
||||
* which kind it is, so the table's `editing` cell wiring stays identical for folders and
|
||||
* knowledge bases. A duplicate sibling name is a 409 from the folder API; the mutations
|
||||
* below surface it and `useInlineRename` keeps the edit session open so the user can pick
|
||||
* another name.
|
||||
*/
|
||||
const listRename = useInlineRename({
|
||||
onSave: async (rowId, name) => {
|
||||
const parsed = parseFolderedRowId(rowId)
|
||||
if (parsed.kind === 'folder') {
|
||||
try {
|
||||
return await updateFolder.mutateAsync({
|
||||
workspaceId,
|
||||
resourceType: FOLDER_RESOURCE_TYPE,
|
||||
id: parsed.id,
|
||||
updates: { name },
|
||||
})
|
||||
} catch (renameError) {
|
||||
toast.error(getErrorMessage(renameError, 'Failed to rename folder'))
|
||||
throw renameError
|
||||
}
|
||||
}
|
||||
return updateKnowledgeBaseMutation({
|
||||
knowledgeBaseId: parsed.id,
|
||||
updates: { name },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const listRenameRef = useRef(listRename)
|
||||
listRenameRef.current = listRename
|
||||
|
||||
/** Renames the open folder from its breadcrumb crumb, where it has no row to edit. */
|
||||
const breadcrumbRename = useInlineRename({
|
||||
onSave: async (folderId, name) => {
|
||||
try {
|
||||
return await updateFolder.mutateAsync({
|
||||
workspaceId,
|
||||
resourceType: FOLDER_RESOURCE_TYPE,
|
||||
id: folderId,
|
||||
updates: { name },
|
||||
})
|
||||
} catch (renameError) {
|
||||
toast.error(getErrorMessage(renameError, 'Failed to rename folder'))
|
||||
throw renameError
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const breadcrumbRenameRef = useRef(breadcrumbRename)
|
||||
breadcrumbRenameRef.current = breadcrumbRename
|
||||
|
||||
const handleContentContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
@@ -265,8 +387,56 @@ export function Knowledge() {
|
||||
[deleteKnowledgeBaseMutation]
|
||||
)
|
||||
|
||||
/**
|
||||
* Folders in the open folder, sorted independently of the bases below them.
|
||||
*
|
||||
* With no explicit sort the two blocks disagree on purpose — folders read best
|
||||
* alphabetically while bases read best most-recently-updated-first — which mirrors the
|
||||
* Files page. The resource filters (connectors/content/owner) describe properties a folder
|
||||
* does not have, so folders answer only to the search term.
|
||||
*/
|
||||
const visibleFolders = useMemo(() => {
|
||||
const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId)
|
||||
const needle = debouncedSearchQuery.trim().toLowerCase()
|
||||
const searched = needle
|
||||
? siblings.filter((folder) => folder.name.toLowerCase().includes(needle))
|
||||
: siblings
|
||||
|
||||
const col = activeSort?.column ?? 'name'
|
||||
const dir = activeSort?.direction ?? 'asc'
|
||||
return [...searched].sort((a, b) => {
|
||||
const aPinned = pinnedFolderIds.has(a.id)
|
||||
const bPinned = pinnedFolderIds.has(b.id)
|
||||
if (aPinned !== bPinned) return aPinned ? -1 : 1
|
||||
|
||||
let cmp = 0
|
||||
if (col === 'created') {
|
||||
cmp = a.createdAt.getTime() - b.createdAt.getTime()
|
||||
} else if (col === 'updated') {
|
||||
cmp = a.updatedAt.getTime() - b.updatedAt.getTime()
|
||||
} else {
|
||||
cmp = a.name.localeCompare(b.name)
|
||||
}
|
||||
return dir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [folders, currentFolderId, debouncedSearchQuery, activeSort, pinnedFolderIds])
|
||||
|
||||
const processedKBs = useMemo(() => {
|
||||
let result = filterKnowledgeBases(knowledgeBases, debouncedSearchQuery)
|
||||
/**
|
||||
* A `folderId` that no longer names an active folder — a base restored on its own out of
|
||||
* Recently Deleted while its folder stayed archived, or a cascade that failed partway —
|
||||
* would otherwise match no level at all and leave the base unreachable from every view.
|
||||
* Fall it back to the root instead — but only once `foldersResolved` says the index is the
|
||||
* complete set for THIS workspace. Gating on a loading flag instead would treat an errored
|
||||
* fetch, a disabled query, or the previous workspace's cached folders as "no such folder"
|
||||
* and drag every foldered base to the root.
|
||||
*/
|
||||
let result = filterKnowledgeBases(knowledgeBases, debouncedSearchQuery).filter((kb) => {
|
||||
const folderId = kb.folderId ?? null
|
||||
const effectiveFolderId =
|
||||
!foldersResolved || !folderId || folderById.has(folderId) ? folderId : null
|
||||
return effectiveFolderId === currentFolderId
|
||||
})
|
||||
|
||||
if (connectorFilter.length > 0) {
|
||||
result = result.filter((kb) => {
|
||||
@@ -322,8 +492,8 @@ export function Knowledge() {
|
||||
cmp = (a.connectorTypes?.length ?? 0) - (b.connectorTypes?.length ?? 0)
|
||||
break
|
||||
case 'owner':
|
||||
cmp = (members?.find((m) => m.userId === a.userId)?.name ?? '').localeCompare(
|
||||
members?.find((m) => m.userId === b.userId)?.name ?? ''
|
||||
cmp = (membersById.get(a.userId)?.name ?? '').localeCompare(
|
||||
membersById.get(b.userId)?.name ?? ''
|
||||
)
|
||||
break
|
||||
}
|
||||
@@ -331,62 +501,132 @@ export function Knowledge() {
|
||||
})
|
||||
}, [
|
||||
knowledgeBases,
|
||||
currentFolderId,
|
||||
folderById,
|
||||
foldersResolved,
|
||||
debouncedSearchQuery,
|
||||
connectorFilter,
|
||||
contentFilter,
|
||||
ownerFilter,
|
||||
activeSort,
|
||||
members,
|
||||
membersById,
|
||||
pinnedBaseIds,
|
||||
])
|
||||
|
||||
const rows: ResourceRow[] = useMemo(
|
||||
() =>
|
||||
processedKBs.map((kb) => {
|
||||
const kbWithCount = kb as KnowledgeBaseWithDocCount
|
||||
return {
|
||||
id: kb.id,
|
||||
cells: {
|
||||
name: {
|
||||
icon: KNOWLEDGE_BASE_ICON,
|
||||
label: kb.name,
|
||||
},
|
||||
documents: {
|
||||
label: String(kbWithCount.docCount || 0),
|
||||
},
|
||||
tokens: {
|
||||
label: kb.tokenCount ? kb.tokenCount.toLocaleString() : '0',
|
||||
},
|
||||
connectors: connectorCell(kb.connectorTypes),
|
||||
created: timeCell(kb.createdAt),
|
||||
owner: ownerCell(kb.userId, members),
|
||||
updated: timeCell(kb.updatedAt),
|
||||
const baseRows: ResourceRow[] = useMemo(() => {
|
||||
const folderRows = visibleFolders.map((folder) =>
|
||||
folderRow(folder, {
|
||||
pinned: pinnedFolderIds.has(folder.id),
|
||||
cells: {
|
||||
documents: { label: EMPTY_CELL_PLACEHOLDER },
|
||||
tokens: { label: EMPTY_CELL_PLACEHOLDER },
|
||||
connectors: { label: EMPTY_CELL_PLACEHOLDER },
|
||||
created: timeCell(folder.createdAt),
|
||||
owner: ownerCell(folder.userId, membersById),
|
||||
updated: timeCell(folder.updatedAt),
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const knowledgeBaseRows = processedKBs.map((kb) => {
|
||||
const kbWithCount = kb as KnowledgeBaseWithDocCount
|
||||
return {
|
||||
id: kb.id,
|
||||
cells: {
|
||||
name: {
|
||||
icon: KNOWLEDGE_BASE_ICON,
|
||||
label: kb.name,
|
||||
pinned: pinnedBaseIds.has(kb.id),
|
||||
},
|
||||
}
|
||||
}),
|
||||
[processedKBs, members]
|
||||
)
|
||||
documents: {
|
||||
label: String(kbWithCount.docCount || 0),
|
||||
},
|
||||
tokens: {
|
||||
label: kb.tokenCount ? kb.tokenCount.toLocaleString() : '0',
|
||||
},
|
||||
connectors: connectorCell(kb.connectorTypes),
|
||||
created: timeCell(kb.createdAt),
|
||||
owner: ownerCell(kb.userId, membersById),
|
||||
updated: timeCell(kb.updatedAt),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return [...folderRows, ...knowledgeBaseRows]
|
||||
}, [visibleFolders, processedKBs, membersById, pinnedFolderIds, pinnedBaseIds])
|
||||
|
||||
/**
|
||||
* Rename is layered over the built rows rather than folded into the builder above, so a
|
||||
* keystroke in the rename field does not rebuild every row's cells.
|
||||
*/
|
||||
const rows: ResourceRow[] = useMemo(() => {
|
||||
if (!listRename.editingId) return baseRows
|
||||
return baseRows.map((row) => {
|
||||
if (row.id !== listRename.editingId) return row
|
||||
return {
|
||||
...row,
|
||||
cells: {
|
||||
...row.cells,
|
||||
name: {
|
||||
...row.cells.name,
|
||||
editing: {
|
||||
value: listRename.editValue,
|
||||
onChange: listRename.setEditValue,
|
||||
onSubmit: listRename.submitRename,
|
||||
onCancel: listRename.cancelRename,
|
||||
disabled: listRename.isSaving,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [
|
||||
baseRows,
|
||||
listRename.editingId,
|
||||
listRename.editValue,
|
||||
listRename.isSaving,
|
||||
listRename.setEditValue,
|
||||
listRename.submitRename,
|
||||
listRename.cancelRename,
|
||||
])
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(rowId: string) => {
|
||||
if (isRowContextMenuOpenRef.current) return
|
||||
const kb = knowledgeBasesRef.current.find((k) => k.id === rowId)
|
||||
if (isRowContextMenuOpenRef.current || isFolderContextMenuOpenRef.current) return
|
||||
if (listRenameRef.current.editingId === rowId) return
|
||||
|
||||
const parsed = parseFolderedRowId(rowId)
|
||||
if (parsed.kind === 'folder') {
|
||||
setCurrentFolderId(parsed.id)
|
||||
return
|
||||
}
|
||||
|
||||
const kb = knowledgeBasesRef.current.find((k) => k.id === parsed.id)
|
||||
if (!kb) return
|
||||
const urlParams = new URLSearchParams({ kbName: kb.name })
|
||||
router.push(`/workspace/${workspaceId}/knowledge/${rowId}?${urlParams.toString()}`)
|
||||
router.push(`/workspace/${workspaceId}/knowledge/${parsed.id}?${urlParams.toString()}`)
|
||||
},
|
||||
[router, workspaceId]
|
||||
[router, workspaceId, setCurrentFolderId]
|
||||
)
|
||||
|
||||
const handleRowContextMenu = useCallback(
|
||||
(e: React.MouseEvent, rowId: string) => {
|
||||
const kb = knowledgeBasesRef.current.find((k) => k.id === rowId) as
|
||||
const parsed = parseFolderedRowId(rowId)
|
||||
if (parsed.kind === 'folder') {
|
||||
const folder = foldersRef.current.find((item) => item.id === parsed.id)
|
||||
if (!folder) return
|
||||
setActiveFolder(folder)
|
||||
handleFolderCtxMenu(e)
|
||||
return
|
||||
}
|
||||
|
||||
const kb = knowledgeBasesRef.current.find((k) => k.id === parsed.id) as
|
||||
| KnowledgeBaseWithDocCount
|
||||
| undefined
|
||||
setActiveKnowledgeBase(kb ?? null)
|
||||
handleRowCtxMenu(e)
|
||||
},
|
||||
[handleRowCtxMenu]
|
||||
[handleRowCtxMenu, handleFolderCtxMenu]
|
||||
)
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
@@ -429,22 +669,213 @@ export function Knowledge() {
|
||||
setIsEditModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleTogglePin = useCallback(() => {
|
||||
const kb = activeKnowledgeBaseRef.current
|
||||
if (!kb) return
|
||||
const mutation = pinnedBaseIds.has(kb.id) ? unpinItem : pinItem
|
||||
mutation.mutate({ workspaceId, resourceType: 'knowledge_base', resourceId: kb.id })
|
||||
closeRowContextMenu()
|
||||
}, [workspaceId, pinnedBaseIds, closeRowContextMenu])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
setIsDeleteModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const canEdit = userPermissions.canEdit === true
|
||||
|
||||
const handleCreateFolder = useCallback(async () => {
|
||||
if (!workspaceId) return
|
||||
const parentId = currentFolderIdRef.current
|
||||
const name = nextUntitledFolderName(foldersRef.current, parentId)
|
||||
|
||||
try {
|
||||
const folder = await createFolder.mutateAsync({
|
||||
workspaceId,
|
||||
resourceType: FOLDER_RESOURCE_TYPE,
|
||||
name,
|
||||
parentId: parentId ?? undefined,
|
||||
})
|
||||
/**
|
||||
* A live search term filters the folder list too, so a brand-new "New folder" would not
|
||||
* match it — the row never renders, the rename field never appears, and the create reads
|
||||
* as a no-op even though it succeeded. Clear the search so the thing just created is on
|
||||
* screen to be named.
|
||||
*/
|
||||
setSearchQuery('')
|
||||
// Drop straight into rename: the auto-generated name is a placeholder, and the user
|
||||
// should not have to hunt for a second action to replace it.
|
||||
listRenameRef.current.startRename(folderRowId(folder.id), folder.name)
|
||||
} catch (createError) {
|
||||
logger.error('Failed to create folder', createError)
|
||||
toast.error(getErrorMessage(createError, 'Failed to create folder'))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [workspaceId])
|
||||
|
||||
const handleRenameFolder = useCallback(() => {
|
||||
const folder = activeFolderRef.current
|
||||
if (!folder) return
|
||||
listRenameRef.current.startRename(folderRowId(folder.id), folder.name)
|
||||
}, [])
|
||||
|
||||
const handleOpenFolder = useCallback(() => {
|
||||
const folder = activeFolderRef.current
|
||||
if (folder) setCurrentFolderId(folder.id)
|
||||
}, [setCurrentFolderId])
|
||||
|
||||
const handleCopyFolderId = useCallback(() => {
|
||||
const folder = activeFolderRef.current
|
||||
if (folder) navigator.clipboard.writeText(folder.id)
|
||||
}, [])
|
||||
|
||||
const handleRequestFolderDelete = useCallback(() => {
|
||||
setFolderPendingDelete(activeFolderRef.current)
|
||||
}, [])
|
||||
|
||||
const folderPendingDeleteRef = useRef(folderPendingDelete)
|
||||
folderPendingDeleteRef.current = folderPendingDelete
|
||||
|
||||
const handleConfirmFolderDelete = useCallback(async () => {
|
||||
const folder = folderPendingDeleteRef.current
|
||||
if (!folder) return
|
||||
try {
|
||||
await deleteFolder.mutateAsync({
|
||||
workspaceId,
|
||||
resourceType: FOLDER_RESOURCE_TYPE,
|
||||
id: folder.id,
|
||||
})
|
||||
setFolderPendingDelete(null)
|
||||
setActiveFolder(null)
|
||||
// Deleting the folder you are standing in leaves the list pointed at an archived
|
||||
// folder, which renders as an empty page with a dead breadcrumb — step out to its
|
||||
// parent instead.
|
||||
if (currentFolderIdRef.current === folder.id) {
|
||||
setCurrentFolderId(folder.parentId)
|
||||
}
|
||||
} catch (deleteError) {
|
||||
logger.error('Failed to delete folder', deleteError)
|
||||
toast.error(getErrorMessage(deleteError, 'Failed to delete folder'))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [workspaceId, setCurrentFolderId])
|
||||
|
||||
const descendantsByFolderId = useMemo(() => buildDescendantIndex(folders), [folders])
|
||||
|
||||
const handleToggleBasePin = useCallback(() => {
|
||||
const kb = activeKnowledgeBaseRef.current
|
||||
if (!kb) return
|
||||
const mutation = pinnedBaseIds.has(kb.id) ? unpinItem : pinItem
|
||||
mutation.mutate({ workspaceId, resourceType: 'knowledge_base', resourceId: kb.id })
|
||||
closeRowContextMenu()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutate is stable in v5
|
||||
}, [workspaceId, pinnedBaseIds, closeRowContextMenu])
|
||||
|
||||
const handleToggleFolderPin = useCallback(() => {
|
||||
const folder = activeFolderRef.current
|
||||
if (!folder) return
|
||||
const mutation = pinnedFolderIds.has(folder.id) ? unpinItem : pinItem
|
||||
mutation.mutate({ workspaceId, resourceType: 'folder', resourceId: folder.id })
|
||||
closeFolderContextMenu()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutate is stable in v5
|
||||
}, [workspaceId, pinnedFolderIds, closeFolderContextMenu])
|
||||
|
||||
/** Move targets for the folder under the cursor: itself and its subtree are unreachable. */
|
||||
const folderMoveOptions: MoveOptionNode[] = useMemo(() => {
|
||||
if (!activeFolder) return []
|
||||
const excluded = new Set<string>([activeFolder.id])
|
||||
for (const id of descendantsByFolderId.get(activeFolder.id) ?? []) excluded.add(id)
|
||||
return buildMoveOptions({
|
||||
folders,
|
||||
rootLabel: ROOT_BREADCRUMB_LABEL,
|
||||
excludedFolderIds: excluded,
|
||||
})
|
||||
}, [folders, activeFolder, descendantsByFolderId])
|
||||
|
||||
/** Move targets for a knowledge base: every folder, since a base has no subtree. */
|
||||
const knowledgeBaseMoveOptions: MoveOptionNode[] = useMemo(
|
||||
() => buildMoveOptions({ folders, rootLabel: ROOT_BREADCRUMB_LABEL }),
|
||||
[folders]
|
||||
)
|
||||
|
||||
/** Shared by the "Move to" submenu and by dropping a folder row onto another folder. */
|
||||
const moveFolderTo = useCallback(
|
||||
async (folderId: string, parentId: string | null) => {
|
||||
try {
|
||||
await updateFolder.mutateAsync({
|
||||
workspaceId,
|
||||
resourceType: FOLDER_RESOURCE_TYPE,
|
||||
id: folderId,
|
||||
updates: { parentId },
|
||||
})
|
||||
} catch (moveError) {
|
||||
logger.error('Failed to move folder', moveError)
|
||||
toast.error(getErrorMessage(moveError, 'Failed to move folder'))
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
/** Shared by the "Move to" submenu and by dropping a base row onto a folder. */
|
||||
const moveKnowledgeBaseTo = useCallback(
|
||||
async (knowledgeBaseId: string, folderId: string | null) => {
|
||||
try {
|
||||
await updateKnowledgeBaseMutation({ knowledgeBaseId, updates: { folderId } })
|
||||
} catch (moveError) {
|
||||
logger.error('Failed to move knowledge base', moveError)
|
||||
toast.error(getErrorMessage(moveError, 'Failed to move knowledge base'))
|
||||
}
|
||||
},
|
||||
[updateKnowledgeBaseMutation]
|
||||
)
|
||||
|
||||
const handleMoveFolder = useCallback(
|
||||
async (optionValue: string) => {
|
||||
const folder = activeFolderRef.current
|
||||
if (!folder) return
|
||||
const parentId = parseMoveOptionValue(optionValue)
|
||||
// Live placement, not the snapshot taken when the menu opened — a refetch or concurrent
|
||||
// move in between would otherwise skip the write the user just chose. Matches the
|
||||
// knowledge-base move below and both Tables handlers.
|
||||
const current = foldersRef.current.find((item) => item.id === folder.id) ?? folder
|
||||
if ((current.parentId ?? null) !== parentId) await moveFolderTo(folder.id, parentId)
|
||||
closeFolderContextMenu()
|
||||
},
|
||||
[moveFolderTo, closeFolderContextMenu]
|
||||
)
|
||||
|
||||
const handleMoveKnowledgeBase = useCallback(
|
||||
async (optionValue: string) => {
|
||||
const kb = activeKnowledgeBaseRef.current
|
||||
if (!kb) return
|
||||
const folderId = parseMoveOptionValue(optionValue)
|
||||
// Re-read placement from the live list: `activeKnowledgeBase` is a snapshot from when
|
||||
// the menu opened, and a refetch since then would make the no-op check wrong.
|
||||
const current = knowledgeBasesRef.current.find((item) => item.id === kb.id) ?? kb
|
||||
if ((current.folderId ?? null) !== folderId) await moveKnowledgeBaseTo(kb.id, folderId)
|
||||
closeRowContextMenu()
|
||||
},
|
||||
[moveKnowledgeBaseTo, closeRowContextMenu]
|
||||
)
|
||||
|
||||
const rowDragDropConfig = useFolderRowDragDrop({
|
||||
canEdit,
|
||||
editingRowId: listRename.editingId,
|
||||
descendantsByFolderId,
|
||||
getFolderParentId: (folderId) => foldersRef.current.find((f) => f.id === folderId)?.parentId,
|
||||
getResourceFolderId: (knowledgeBaseId) =>
|
||||
knowledgeBasesRef.current.find((kb) => kb.id === knowledgeBaseId)?.folderId ?? null,
|
||||
getRowLabel: (rowId) => {
|
||||
const parsed = parseFolderedRowId(rowId)
|
||||
return parsed.kind === 'folder'
|
||||
? (foldersRef.current.find((f) => f.id === parsed.id)?.name ?? 'Folder')
|
||||
: (knowledgeBasesRef.current.find((kb) => kb.id === parsed.id)?.name ?? 'Knowledge base')
|
||||
},
|
||||
onMoveFolder: (folderId, targetFolderId) => void moveFolderTo(folderId, targetFolderId),
|
||||
onMoveResource: (knowledgeBaseId, targetFolderId) =>
|
||||
void moveKnowledgeBaseTo(knowledgeBaseId, targetFolderId),
|
||||
})
|
||||
|
||||
const headerActions: ResourceAction[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
text: 'New folder',
|
||||
icon: FolderPlus,
|
||||
onSelect: handleCreateFolder,
|
||||
disabled: createFolder.isPending || !canEdit,
|
||||
},
|
||||
{
|
||||
text: 'New base',
|
||||
icon: Plus,
|
||||
@@ -453,7 +884,53 @@ export function Knowledge() {
|
||||
variant: 'primary',
|
||||
},
|
||||
],
|
||||
[handleOpenCreateModal, canEdit]
|
||||
[handleOpenCreateModal, handleCreateFolder, createFolder.isPending, canEdit]
|
||||
)
|
||||
|
||||
const listBreadcrumbs: BreadcrumbItem[] = useMemo(
|
||||
() =>
|
||||
folderBreadcrumbItems({
|
||||
rootLabel: ROOT_BREADCRUMB_LABEL,
|
||||
rootIcon: Database,
|
||||
breadcrumbs,
|
||||
onNavigate: setCurrentFolderId,
|
||||
currentFolderEditing:
|
||||
breadcrumbRename.editingId && breadcrumbRename.editingId === currentFolderId
|
||||
? {
|
||||
isEditing: true,
|
||||
value: breadcrumbRename.editValue,
|
||||
onChange: breadcrumbRenameRef.current.setEditValue,
|
||||
onSubmit: breadcrumbRenameRef.current.submitRename,
|
||||
onCancel: breadcrumbRenameRef.current.cancelRename,
|
||||
disabled: breadcrumbRename.isSaving,
|
||||
}
|
||||
: undefined,
|
||||
currentFolderActions:
|
||||
canEdit && breadcrumbs.length > 0
|
||||
? [
|
||||
{
|
||||
label: 'Rename',
|
||||
onClick: () => {
|
||||
const folder = breadcrumbs[breadcrumbs.length - 1]
|
||||
breadcrumbRenameRef.current.startRename(folder.id, folder.name)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
onClick: () => setFolderPendingDelete(breadcrumbs[breadcrumbs.length - 1]),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
}),
|
||||
[
|
||||
breadcrumbs,
|
||||
currentFolderId,
|
||||
setCurrentFolderId,
|
||||
canEdit,
|
||||
breadcrumbRename.editingId,
|
||||
breadcrumbRename.editValue,
|
||||
breadcrumbRename.isSaving,
|
||||
]
|
||||
)
|
||||
|
||||
const searchConfig: SearchConfig = useMemo(
|
||||
@@ -614,7 +1091,12 @@ export function Knowledge() {
|
||||
return (
|
||||
<>
|
||||
<Resource onContextMenu={handleContentContextMenu}>
|
||||
<Resource.Header icon={Database} title='Knowledge Base' actions={headerActions} />
|
||||
<Resource.Header
|
||||
icon={Database}
|
||||
title={ROOT_BREADCRUMB_LABEL}
|
||||
breadcrumbs={listBreadcrumbs}
|
||||
actions={headerActions}
|
||||
/>
|
||||
<Resource.Options
|
||||
search={searchConfig}
|
||||
sort={sortConfig}
|
||||
@@ -624,6 +1106,7 @@ export function Knowledge() {
|
||||
<Resource.Table
|
||||
columns={COLUMNS}
|
||||
rows={rows}
|
||||
rowDragDrop={rowDragDropConfig}
|
||||
onRowClick={handleRowClick}
|
||||
onRowContextMenu={handleRowContextMenu}
|
||||
/>
|
||||
@@ -634,7 +1117,9 @@ export function Knowledge() {
|
||||
position={listContextMenuPosition}
|
||||
onClose={closeListContextMenu}
|
||||
onAddKnowledgeBase={handleOpenCreateModal}
|
||||
onAddFolder={handleCreateFolder}
|
||||
disableAdd={!canEdit}
|
||||
disableAddFolder={createFolder.isPending || !canEdit}
|
||||
/>
|
||||
|
||||
{activeKnowledgeBase && (
|
||||
@@ -645,10 +1130,12 @@ export function Knowledge() {
|
||||
onOpenInNewTab={handleOpenInNewTab}
|
||||
onViewTags={handleViewTags}
|
||||
onCopyId={handleCopyId}
|
||||
onTogglePin={handleTogglePin}
|
||||
onTogglePin={handleToggleBasePin}
|
||||
pinned={pinnedBaseIds.has(activeKnowledgeBase.id)}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onMove={handleMoveKnowledgeBase}
|
||||
moveOptions={knowledgeBaseMoveOptions}
|
||||
showOpenInNewTab
|
||||
showViewTags
|
||||
showEdit
|
||||
@@ -658,6 +1145,43 @@ export function Knowledge() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeFolder && (
|
||||
<FolderContextMenu
|
||||
isOpen={isFolderContextMenuOpen}
|
||||
position={folderContextMenuPosition}
|
||||
onClose={closeFolderContextMenu}
|
||||
onOpen={handleOpenFolder}
|
||||
onRename={handleRenameFolder}
|
||||
onDelete={handleRequestFolderDelete}
|
||||
onCopyId={handleCopyFolderId}
|
||||
onTogglePin={handleToggleFolderPin}
|
||||
pinned={pinnedFolderIds.has(activeFolder.id)}
|
||||
onMove={handleMoveFolder}
|
||||
moveOptions={folderMoveOptions}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ChipConfirmModal
|
||||
open={folderPendingDelete !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setFolderPendingDelete(null)
|
||||
}}
|
||||
srTitle='Delete folder'
|
||||
title='Delete folder'
|
||||
text={[
|
||||
'Are you sure you want to delete ',
|
||||
{ text: folderPendingDelete?.name ?? 'this folder', bold: true },
|
||||
'? This also deletes the knowledge bases and folders inside it. You can restore them from Recently Deleted in Settings.',
|
||||
]}
|
||||
confirm={{
|
||||
label: 'Delete',
|
||||
onClick: handleConfirmFolderDelete,
|
||||
pending: deleteFolder.isPending,
|
||||
pendingLabel: 'Deleting...',
|
||||
}}
|
||||
/>
|
||||
|
||||
{activeKnowledgeBase && (
|
||||
<EditKnowledgeBaseModal
|
||||
open={isEditModalOpen}
|
||||
@@ -688,7 +1212,11 @@ export function Knowledge() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreateBaseModal open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen} />
|
||||
<CreateBaseModal
|
||||
open={isCreateModalOpen}
|
||||
onOpenChange={setIsCreateModalOpen}
|
||||
folderId={currentFolderId}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { Plus } from '@sim/emcn'
|
||||
import { Database } from '@sim/emcn/icons'
|
||||
import { Database, FolderPlus } from '@sim/emcn/icons'
|
||||
import {
|
||||
type ChromeActionSpec,
|
||||
ResourceChromeFallback,
|
||||
@@ -17,7 +17,10 @@ const COLUMNS = [
|
||||
{ id: 'updated', header: 'Last Updated' },
|
||||
]
|
||||
|
||||
const ACTIONS: ChromeActionSpec[] = [{ text: 'New base', icon: Plus, variant: 'primary' }]
|
||||
const ACTIONS: ChromeActionSpec[] = [
|
||||
{ text: 'New folder', icon: FolderPlus },
|
||||
{ text: 'New base', icon: Plus, variant: 'primary' },
|
||||
]
|
||||
|
||||
export default function KnowledgeLoading() {
|
||||
return (
|
||||
|
||||
@@ -1,28 +1,47 @@
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import type { FolderApi } from '@/lib/api/contracts/folders'
|
||||
import type { KnowledgeBaseData } from '@/lib/api/contracts/knowledge'
|
||||
import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch'
|
||||
import { KNOWLEDGE_BASE_LIST_STALE_TIME, knowledgeKeys } from '@/hooks/queries/kb/knowledge'
|
||||
import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys'
|
||||
import { KNOWLEDGE_BASE_LIST_STALE_TIME, knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
|
||||
|
||||
/**
|
||||
* Prefetches the workspace's knowledge-bases list under the same query key the
|
||||
* client `useKnowledgeBasesQuery` hook uses (scope `active`), so the list paints
|
||||
* populated on first render.
|
||||
* Prefetches the workspace's knowledge-bases list AND its knowledge-base folder tree under
|
||||
* the same query keys the client `useKnowledgeBasesQuery` / `useFolders` hooks use (scope
|
||||
* `active`), so the list paints populated on first render.
|
||||
*
|
||||
* The list carries `Date` fields, so it goes through the `/api/knowledge` route
|
||||
* and caches the serialized wire shape — see {@link prefetchInternalJson}.
|
||||
* Both are needed: a base row is only placed correctly relative to the folder rows it sits
|
||||
* beside, so prefetching one without the other still flashes an ungrouped list — and a
|
||||
* `?folderId=` deep link renders an empty breadcrumb until the folders arrive.
|
||||
*
|
||||
* The list carries `Date` fields, so it goes through the `/api/knowledge` route and caches the
|
||||
* serialized wire shape — see {@link prefetchInternalJson}. Folders are mapped with the same
|
||||
* `mapFolder` the hook applies, so the hydrated entry matches a client fetch exactly.
|
||||
*/
|
||||
export async function prefetchKnowledgeBases(
|
||||
queryClient: QueryClient,
|
||||
workspaceId: string
|
||||
): Promise<void> {
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: knowledgeKeys.list(workspaceId, 'active'),
|
||||
queryFn: async () => {
|
||||
const result = await prefetchInternalJson<{ data: KnowledgeBaseData[] }>(
|
||||
`/api/knowledge?workspaceId=${workspaceId}&scope=active`
|
||||
)
|
||||
return result.data
|
||||
},
|
||||
staleTime: KNOWLEDGE_BASE_LIST_STALE_TIME,
|
||||
})
|
||||
await Promise.all([
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: knowledgeKeys.list(workspaceId, 'active'),
|
||||
queryFn: async () => {
|
||||
const result = await prefetchInternalJson<{ data: KnowledgeBaseData[] }>(
|
||||
`/api/knowledge?workspaceId=${workspaceId}&scope=active`
|
||||
)
|
||||
return result.data
|
||||
},
|
||||
staleTime: KNOWLEDGE_BASE_LIST_STALE_TIME,
|
||||
}),
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: folderKeys.list(workspaceId, 'active', 'knowledge_base'),
|
||||
queryFn: async () => {
|
||||
const { folders } = await prefetchInternalJson<{ folders?: FolderApi[] }>(
|
||||
`/api/folders?workspaceId=${workspaceId}&scope=active&resourceType=knowledge_base`
|
||||
)
|
||||
return (folders ?? []).map(mapFolder)
|
||||
},
|
||||
staleTime: FOLDER_LIST_STALE_TIME,
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ import { prefetchFilesBrowser } from '@/app/workspace/[workspaceId]/files/prefet
|
||||
import { prefetchHomeLists } from '@/app/workspace/[workspaceId]/home/prefetch'
|
||||
import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch'
|
||||
import { prefetchTables } from '@/app/workspace/[workspaceId]/tables/prefetch'
|
||||
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
|
||||
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
|
||||
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
|
||||
import { tableKeys } from '@/hooks/queries/utils/table-keys'
|
||||
import { workspaceFileFolderKeys } from '@/hooks/queries/workspace-file-folders'
|
||||
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
|
||||
|
||||
@@ -6,13 +6,12 @@ import { listWorkflowsForUser } from '@/lib/workflows/queries'
|
||||
import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
|
||||
import { listWorkspacesForViewer } from '@/lib/workspaces/list'
|
||||
import { getWorkspacePermissionsForAuthorizedViewer } from '@/lib/workspaces/permissions/utils'
|
||||
import { FOLDER_LIST_STALE_TIME, mapFolder } from '@/hooks/queries/folders'
|
||||
import {
|
||||
MOTHERSHIP_CHAT_LIST_STALE_TIME,
|
||||
mapChat,
|
||||
mothershipChatKeys,
|
||||
} from '@/hooks/queries/mothership-chats'
|
||||
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
|
||||
import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys'
|
||||
import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
|
||||
import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query'
|
||||
import {
|
||||
|
||||
+101
-2
@@ -8,6 +8,7 @@ import { formatDate } from '@sim/utils/formatting'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useQueryStates } from 'nuqs'
|
||||
import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
|
||||
import type { ServedFolderResourceType } from '@/lib/api/contracts/folders'
|
||||
import { type ColumnOption, SortDropdown } from '@/app/workspace/[workspaceId]/components'
|
||||
import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
|
||||
import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types'
|
||||
@@ -44,6 +45,8 @@ type ResourceType =
|
||||
| 'file'
|
||||
| 'folder'
|
||||
| 'workspace_folder'
|
||||
| 'knowledge_folder'
|
||||
| 'table_folder'
|
||||
| 'chat'
|
||||
|
||||
function getResourceHref(
|
||||
@@ -65,6 +68,10 @@ function getResourceHref(
|
||||
return `${base}/w`
|
||||
case 'workspace_folder':
|
||||
return `${base}/files?folderId=${id}`
|
||||
case 'knowledge_folder':
|
||||
return `${base}/knowledge?folderId=${id}`
|
||||
case 'table_folder':
|
||||
return `${base}/tables?folderId=${id}`
|
||||
case 'chat':
|
||||
return `${base}/chat/${id}`
|
||||
}
|
||||
@@ -82,6 +89,8 @@ const RESOURCE_TYPE_TO_MOTHERSHIP: Record<Exclude<ResourceType, 'all'>, Mothersh
|
||||
workflow: 'workflow',
|
||||
folder: 'folder',
|
||||
workspace_folder: 'filefolder',
|
||||
knowledge_folder: 'folder',
|
||||
table_folder: 'folder',
|
||||
table: 'table',
|
||||
knowledge: 'knowledgebase',
|
||||
file: 'file',
|
||||
@@ -115,6 +124,8 @@ const TYPE_LABEL: Record<Exclude<ResourceType, 'all'>, string> = {
|
||||
workflow: 'Workflow',
|
||||
folder: 'Folder',
|
||||
workspace_folder: 'File Folder',
|
||||
knowledge_folder: 'Knowledge Folder',
|
||||
table_folder: 'Table Folder',
|
||||
table: 'Table',
|
||||
knowledge: 'Knowledge Base',
|
||||
file: 'File',
|
||||
@@ -130,10 +141,44 @@ function ResourceIcon({ resource }: { resource: DeletedResource }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Folder trees served by the generic folders API, other than the workflow tree that owns the
|
||||
* standalone "Folders" tab. Each entry names the deleted-resource type it produces, the
|
||||
* `resourceType` its rows are read and restored under, and the tab it files beneath — a
|
||||
* foldered resource's folders belong with the resource, the way a file folder sits under
|
||||
* Files. Declared once so adding a foldered resource is one row here rather than a fourth
|
||||
* copy of the query/collect/restore triple below.
|
||||
*/
|
||||
const FOLDERED_RESOURCE_TREES = [
|
||||
{ type: 'knowledge_folder', resourceType: 'knowledge_base', tab: 'knowledge' },
|
||||
{ type: 'table_folder', resourceType: 'table', tab: 'table' },
|
||||
] as const satisfies readonly {
|
||||
type: Exclude<ResourceType, 'all'>
|
||||
resourceType: ServedFolderResourceType
|
||||
tab: ResourceType
|
||||
}[]
|
||||
|
||||
const FOLDER_TREE_TAB_BY_TYPE = new Map<ResourceType, ResourceType>(
|
||||
FOLDERED_RESOURCE_TREES.map((tree) => [tree.type, tree.tab])
|
||||
)
|
||||
|
||||
/**
|
||||
* Restore brings a workflow back but deliberately does NOT re-enable its schedules, webhooks,
|
||||
* or chats. Archive overwrites their enabled state without recording what it was, so restoring
|
||||
* to a constant would re-enable something the user had deliberately switched off — and a
|
||||
* deliberately-disabled schedule or webhook is a common state, not an edge case, so that would
|
||||
* be wrong more often than right.
|
||||
*
|
||||
* Leaving it off is the safe choice; leaving it UNSAID is not. Restoring anything that carries
|
||||
* automations says so at the moment of restore, so re-enabling is a known step rather than a
|
||||
* silent surprise.
|
||||
*/
|
||||
const PAUSED_AUTOMATION_TYPES = new Set<ResourceType>(['workflow', 'folder'])
|
||||
|
||||
function matchesActiveTab(resource: DeletedResource, activeTab: ResourceType): boolean {
|
||||
if (activeTab === 'all') return true
|
||||
if (activeTab === 'file') return resource.type === 'file' || resource.type === 'workspace_folder'
|
||||
return resource.type === activeTab
|
||||
return resource.type === activeTab || FOLDER_TREE_TAB_BY_TYPE.get(resource.type) === activeTab
|
||||
}
|
||||
|
||||
export function RecentlyDeleted() {
|
||||
@@ -170,6 +215,15 @@ export function RecentlyDeleted() {
|
||||
const activeFoldersQuery = useFolders(workspaceId)
|
||||
const tablesQuery = useTablesList(workspaceId, 'archived')
|
||||
const knowledgeQuery = useKnowledgeBasesQuery(workspaceId, { scope: 'archived' })
|
||||
/**
|
||||
* One archived-folder query per non-workflow tree, in the fixed order of
|
||||
* {@link FOLDERED_RESOURCE_TREES} so the hook call order stays stable.
|
||||
*/
|
||||
const knowledgeFoldersQuery = useFolders(workspaceId, {
|
||||
scope: 'archived',
|
||||
resourceType: 'knowledge_base',
|
||||
})
|
||||
const tableFoldersQuery = useFolders(workspaceId, { scope: 'archived', resourceType: 'table' })
|
||||
const filesQuery = useWorkspaceFiles(workspaceId, 'archived')
|
||||
const workspaceFoldersQuery = useWorkspaceFileFolders(workspaceId, 'archived')
|
||||
const chatsQuery = useMothershipChats(workspaceId, { scope: 'archived' })
|
||||
@@ -187,6 +241,8 @@ export function RecentlyDeleted() {
|
||||
foldersQuery.isLoading ||
|
||||
tablesQuery.isLoading ||
|
||||
knowledgeQuery.isLoading ||
|
||||
knowledgeFoldersQuery.isLoading ||
|
||||
tableFoldersQuery.isLoading ||
|
||||
filesQuery.isLoading ||
|
||||
workspaceFoldersQuery.isLoading ||
|
||||
chatsQuery.isLoading
|
||||
@@ -196,6 +252,8 @@ export function RecentlyDeleted() {
|
||||
foldersQuery.error ||
|
||||
tablesQuery.error ||
|
||||
knowledgeQuery.error ||
|
||||
knowledgeFoldersQuery.error ||
|
||||
tableFoldersQuery.error ||
|
||||
filesQuery.error ||
|
||||
workspaceFoldersQuery.error ||
|
||||
chatsQuery.error
|
||||
@@ -243,6 +301,30 @@ export function RecentlyDeleted() {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyed by `tree.type`, not by array position: an index-aligned pairing would silently file
|
||||
* knowledge folders under Tables (and vice versa) if the const above were ever reordered,
|
||||
* with no type error and no test to catch it.
|
||||
*/
|
||||
const folderTreeData: Record<
|
||||
(typeof FOLDERED_RESOURCE_TREES)[number]['type'],
|
||||
typeof knowledgeFoldersQuery.data
|
||||
> = {
|
||||
knowledge_folder: knowledgeFoldersQuery.data,
|
||||
table_folder: tableFoldersQuery.data,
|
||||
}
|
||||
FOLDERED_RESOURCE_TREES.forEach((tree) => {
|
||||
for (const folder of folderTreeData[tree.type] ?? []) {
|
||||
items.push({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
type: tree.type,
|
||||
deletedAt: folder.deletedAt ? new Date(folder.deletedAt) : new Date(folder.updatedAt),
|
||||
workspaceId: folder.workspaceId,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
for (const f of filesQuery.data ?? []) {
|
||||
items.push({
|
||||
id: f.id,
|
||||
@@ -280,6 +362,8 @@ export function RecentlyDeleted() {
|
||||
foldersQuery.data,
|
||||
tablesQuery.data,
|
||||
knowledgeQuery.data,
|
||||
knowledgeFoldersQuery.data,
|
||||
tableFoldersQuery.data,
|
||||
filesQuery.data,
|
||||
workspaceFoldersQuery.data,
|
||||
chatsQuery.data,
|
||||
@@ -380,6 +464,17 @@ export function RecentlyDeleted() {
|
||||
folderId: resource.id,
|
||||
})
|
||||
break
|
||||
case 'knowledge_folder':
|
||||
case 'table_folder': {
|
||||
const tree = FOLDERED_RESOURCE_TREES.find((entry) => entry.type === resource.type)
|
||||
if (!tree) break
|
||||
await restoreFolder.mutateAsync({
|
||||
folderId: resource.id,
|
||||
workspaceId: resource.workspaceId,
|
||||
resourceType: tree.resourceType,
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'chat':
|
||||
await restoreChat.mutateAsync(resource.id)
|
||||
break
|
||||
@@ -467,7 +562,11 @@ export function RecentlyDeleted() {
|
||||
</Chip>
|
||||
) : isRestored ? (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-[var(--text-muted)] text-small'>Restored</span>
|
||||
<span className='text-[var(--text-muted)] text-small'>
|
||||
{PAUSED_AUTOMATION_TYPES.has(resource.type)
|
||||
? 'Restored \u00b7 schedules and webhooks stay paused'
|
||||
: 'Restored'}
|
||||
</span>
|
||||
<Chip variant='primary' onClick={() => handleView(resource)}>
|
||||
View
|
||||
</Chip>
|
||||
|
||||
+23
-2
@@ -5,10 +5,15 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
Upload,
|
||||
} from '@sim/emcn'
|
||||
import { Database, Download, Duplicate, Pencil, Pin, Trash } from '@sim/emcn/icons'
|
||||
import { Database, Download, Duplicate, FolderInput, Pencil, Pin, Trash } from '@sim/emcn/icons'
|
||||
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders'
|
||||
import { renderMoveOptions } from '@/app/workspace/[workspaceId]/components/folders'
|
||||
|
||||
interface TableContextMenuProps {
|
||||
isOpen: boolean
|
||||
@@ -23,6 +28,9 @@ interface TableContextMenuProps {
|
||||
onRename?: () => void
|
||||
onImportCsv?: () => void
|
||||
onExportCsv?: () => void
|
||||
/** Files the table under another folder; the value is a folder id or the root sentinel. */
|
||||
onMove?: (optionValue: string) => void
|
||||
moveOptions?: MoveOptionNode[]
|
||||
disableDelete?: boolean
|
||||
disableRename?: boolean
|
||||
disableImport?: boolean
|
||||
@@ -42,6 +50,8 @@ export function TableContextMenu({
|
||||
onRename,
|
||||
onImportCsv,
|
||||
onExportCsv,
|
||||
onMove,
|
||||
moveOptions,
|
||||
disableDelete = false,
|
||||
disableRename = false,
|
||||
disableImport = false,
|
||||
@@ -93,7 +103,18 @@ export function TableContextMenu({
|
||||
Export CSV
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(onViewSchema || onRename || onImportCsv || onExportCsv) &&
|
||||
{onMove && moveOptions && moveOptions.length > 0 && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<FolderInput />
|
||||
Move to
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{renderMoveOptions(moveOptions, onMove)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{(onViewSchema || onRename || onImportCsv || onExportCsv || onMove) &&
|
||||
(onCopyId || onTogglePin || onDelete) && <DropdownMenuSeparator />}
|
||||
{onTogglePin && (
|
||||
<DropdownMenuItem onSelect={onTogglePin}>
|
||||
|
||||
+11
-1
@@ -7,15 +7,17 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
Upload,
|
||||
} from '@sim/emcn'
|
||||
import { Plus } from '@sim/emcn/icons'
|
||||
import { FolderPlus, Plus } from '@sim/emcn/icons'
|
||||
|
||||
interface TablesListContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
onClose: () => void
|
||||
onCreateTable?: () => void
|
||||
onCreateFolder?: () => void
|
||||
onUploadCsv?: () => void
|
||||
disableCreate?: boolean
|
||||
disableCreateFolder?: boolean
|
||||
disableUpload?: boolean
|
||||
}
|
||||
|
||||
@@ -24,8 +26,10 @@ export function TablesListContextMenu({
|
||||
position,
|
||||
onClose,
|
||||
onCreateTable,
|
||||
onCreateFolder,
|
||||
onUploadCsv,
|
||||
disableCreate = false,
|
||||
disableCreateFolder = false,
|
||||
disableUpload = false,
|
||||
}: TablesListContextMenuProps) {
|
||||
return (
|
||||
@@ -56,6 +60,12 @@ export function TablesListContextMenu({
|
||||
Create table
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onCreateFolder && (
|
||||
<DropdownMenuItem disabled={disableCreateFolder} onSelect={onCreateFolder}>
|
||||
<FolderPlus />
|
||||
New folder
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onUploadCsv && (
|
||||
<DropdownMenuItem disabled={disableUpload} onSelect={onUploadCsv}>
|
||||
<Upload />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { Plus, Upload } from '@sim/emcn'
|
||||
import { Table as TableIcon } from '@sim/emcn/icons'
|
||||
import { FolderPlus, Table as TableIcon } from '@sim/emcn/icons'
|
||||
import {
|
||||
type ChromeActionSpec,
|
||||
ResourceChromeFallback,
|
||||
@@ -18,6 +18,7 @@ const COLUMNS = [
|
||||
|
||||
const ACTIONS: ChromeActionSpec[] = [
|
||||
{ text: 'Import CSV', icon: Upload },
|
||||
{ text: 'New folder', icon: FolderPlus },
|
||||
{ text: 'New table', icon: Plus, variant: 'primary' },
|
||||
]
|
||||
|
||||
|
||||
@@ -1,26 +1,43 @@
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import type { FolderApi } from '@/lib/api/contracts/folders'
|
||||
import type { TableDefinition } from '@/lib/table'
|
||||
import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch'
|
||||
import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys'
|
||||
import { TABLE_LIST_STALE_TIME, tableKeys } from '@/hooks/queries/utils/table-keys'
|
||||
|
||||
/**
|
||||
* Prefetches the workspace's tables list under the same query key the client
|
||||
* `useTablesList` hook uses (scope `active`), so the list paints populated on
|
||||
* first render.
|
||||
* Prefetches the workspace's tables list and its table folder tree under the same
|
||||
* query keys the client `useTablesList` / `useFolders` hooks use (scope `active`),
|
||||
* so the list paints populated on first render. Both are needed: a table row is
|
||||
* only placed correctly relative to the folder rows it sits beside, so
|
||||
* prefetching one without the other still flashes an ungrouped list.
|
||||
*
|
||||
* Table definitions carry `Date` fields, so the list goes through the
|
||||
* `/api/table` route and caches the serialized wire shape — see
|
||||
* {@link prefetchInternalJson}.
|
||||
* {@link prefetchInternalJson}. Folders are mapped with the same `mapFolder` the
|
||||
* hook applies so the hydrated entry matches a client fetch exactly.
|
||||
*/
|
||||
export async function prefetchTables(queryClient: QueryClient, workspaceId: string): Promise<void> {
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: tableKeys.list(workspaceId, 'active'),
|
||||
queryFn: async () => {
|
||||
const response = await prefetchInternalJson<{ data: { tables: TableDefinition[] } }>(
|
||||
`/api/table?workspaceId=${workspaceId}&scope=active`
|
||||
)
|
||||
return response.data.tables
|
||||
},
|
||||
staleTime: TABLE_LIST_STALE_TIME,
|
||||
})
|
||||
await Promise.all([
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: tableKeys.list(workspaceId, 'active'),
|
||||
queryFn: async () => {
|
||||
const response = await prefetchInternalJson<{ data: { tables: TableDefinition[] } }>(
|
||||
`/api/table?workspaceId=${workspaceId}&scope=active`
|
||||
)
|
||||
return response.data.tables
|
||||
},
|
||||
staleTime: TABLE_LIST_STALE_TIME,
|
||||
}),
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: folderKeys.list(workspaceId, 'active', 'table'),
|
||||
queryFn: async () => {
|
||||
const { folders } = await prefetchInternalJson<{ folders?: FolderApi[] }>(
|
||||
`/api/folders?workspaceId=${workspaceId}&scope=active&resourceType=table`
|
||||
)
|
||||
return (folders ?? []).map(mapFolder)
|
||||
},
|
||||
staleTime: FOLDER_LIST_STALE_TIME,
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -32,6 +32,12 @@ export const tablesSortParams = createSortParams(TABLE_SORT_COLUMNS, {
|
||||
* Selecting a table navigates to the `tables/[tableId]` route (via `router`),
|
||||
* so the active table is route state, not query state, and is intentionally not
|
||||
* represented here.
|
||||
*
|
||||
* The open folder is `?folderId=`, declared once for every foldered surface in
|
||||
* `components/folders/search-params.ts` and read through `useFolderNavigation`.
|
||||
* It is deliberately not part of this map: folder navigation is a destination
|
||||
* (`history: 'push'`), while everything here is a filter write that must not
|
||||
* churn the back stack.
|
||||
*/
|
||||
export const tablesParsers = {
|
||||
search: parseAsString.withDefault(''),
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ComboboxOption } from '@sim/emcn'
|
||||
import { ChipCombobox, ChipConfirmModal, Plus, toast, Upload } from '@sim/emcn'
|
||||
import { Columns3, Rows3, Table as TableIcon } from '@sim/emcn/icons'
|
||||
import { Columns3, FolderPlus, Rows3, Table as TableIcon } from '@sim/emcn/icons'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useQueryStates } from 'nuqs'
|
||||
@@ -12,6 +13,7 @@ import type { TableDefinition } from '@/lib/table'
|
||||
import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES, generateUniqueTableName } from '@/lib/table/constants'
|
||||
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
|
||||
import type {
|
||||
DropdownOption,
|
||||
FilterTag,
|
||||
ResourceAction,
|
||||
ResourceColumn,
|
||||
@@ -19,7 +21,26 @@ import type {
|
||||
SearchConfig,
|
||||
SortConfig,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
|
||||
import {
|
||||
EMPTY_CELL_PLACEHOLDER,
|
||||
ownerCell,
|
||||
Resource,
|
||||
timeCell,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders'
|
||||
import {
|
||||
buildDescendantIndex,
|
||||
buildMoveOptions,
|
||||
FolderContextMenu,
|
||||
folderBreadcrumbItems,
|
||||
folderRow,
|
||||
folderRowId,
|
||||
nextUntitledFolderName,
|
||||
parseFolderedRowId,
|
||||
parseMoveOptionValue,
|
||||
useFolderNavigation,
|
||||
useFolderRowDragDrop,
|
||||
} from '@/app/workspace/[workspaceId]/components/folders'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import {
|
||||
ImportCsvDialog,
|
||||
@@ -33,6 +54,7 @@ import {
|
||||
tablesUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/tables/search-params'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { useCreateFolder, useDeleteFolderMutation, useUpdateFolder } from '@/hooks/queries/folders'
|
||||
import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items'
|
||||
import {
|
||||
cancelTableJob,
|
||||
@@ -40,16 +62,18 @@ import {
|
||||
useCreateTable,
|
||||
useDeleteTable,
|
||||
useImportCsvAsync,
|
||||
useMoveTable,
|
||||
useRenameTable,
|
||||
useTablesList,
|
||||
useUploadCsvToTable,
|
||||
} from '@/hooks/queries/tables'
|
||||
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
|
||||
import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
|
||||
import { useInlineRename } from '@/hooks/use-inline-rename'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useUrlSort } from '@/hooks/use-url-sort'
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
import { useImportTrayStore } from '@/stores/table/import-tray/store'
|
||||
|
||||
const logger = createLogger('Tables')
|
||||
@@ -63,6 +87,16 @@ const COLUMNS: ResourceColumn[] = [
|
||||
{ id: 'updated', header: 'Last Updated' },
|
||||
]
|
||||
|
||||
/** Root label for breadcrumbs and the "move to workspace root" destination. */
|
||||
const ROOT_LABEL = 'Tables'
|
||||
|
||||
const EMPTY_TABLES: TableDefinition[] = []
|
||||
|
||||
/** The right-clicked row, resolved to the entity it refers to. */
|
||||
type TableResourceItem =
|
||||
| { kind: 'table'; table: TableDefinition }
|
||||
| { kind: 'folder'; folder: WorkflowFolder }
|
||||
|
||||
export function Tables() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
@@ -76,29 +110,94 @@ export function Tables() {
|
||||
}, [permissionConfig.hideTablesTab, router, workspaceId])
|
||||
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const canEdit = userPermissions.canEdit === true
|
||||
|
||||
const { data: tables = [], error } = useTablesList(workspaceId)
|
||||
const { data: tables = EMPTY_TABLES, error } = useTablesList(workspaceId)
|
||||
const { data: members } = useWorkspaceMembersQuery(workspaceId)
|
||||
const pinnedTableIds = usePinnedIds(workspaceId, 'table')
|
||||
// Folder pins live in their own `resourceType` namespace, so a page listing
|
||||
// folders alongside tables resolves two sets.
|
||||
const pinnedFolderIds = usePinnedIds(workspaceId, 'folder')
|
||||
const pinItem = usePinItem()
|
||||
const unpinItem = useUnpinItem()
|
||||
|
||||
if (error) {
|
||||
logger.error('Failed to load tables:', error)
|
||||
}
|
||||
const {
|
||||
currentFolderId,
|
||||
setCurrentFolderId,
|
||||
breadcrumbs: folderChain,
|
||||
folders,
|
||||
folderById,
|
||||
foldersResolved,
|
||||
} = useFolderNavigation({
|
||||
resourceType: 'table',
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
/**
|
||||
* Logged from an effect, not the render body: a render-phase log fires again on every
|
||||
* re-render while the error persists, and on each of React's double renders in dev.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (error) logger.error('Failed to load tables:', error)
|
||||
}, [error])
|
||||
|
||||
const deleteTable = useDeleteTable(workspaceId)
|
||||
const renameTable = useRenameTable(workspaceId)
|
||||
const createTable = useCreateTable(workspaceId)
|
||||
const moveTable = useMoveTable(workspaceId)
|
||||
const uploadCsv = useUploadCsvToTable()
|
||||
const importCsvAsync = useImportCsvAsync()
|
||||
const createFolder = useCreateFolder()
|
||||
const updateFolder = useUpdateFolder()
|
||||
const deleteFolder = useDeleteFolderMutation()
|
||||
|
||||
const tableRename = useInlineRename({
|
||||
onSave: (tableId, name) => renameTable.mutateAsync({ tableId, name }),
|
||||
const membersById = useMemo(() => {
|
||||
const map = new Map<string, WorkspaceMember>()
|
||||
for (const member of members ?? []) map.set(member.userId, member)
|
||||
return map
|
||||
}, [members])
|
||||
|
||||
/**
|
||||
* One rename session multiplexed over both row kinds — the shared `Resource`
|
||||
* table has a single editing cell, so the id it carries has to resolve to
|
||||
* either a folder or a table. Both mutations toast their own failure; the hook
|
||||
* restores the original name and keeps the field open.
|
||||
*/
|
||||
const listRename = useInlineRename({
|
||||
onSave: (rowId, name) => {
|
||||
const parsed = parseFolderedRowId(rowId)
|
||||
if (parsed.kind === 'folder') {
|
||||
return updateFolder
|
||||
.mutateAsync({
|
||||
workspaceId,
|
||||
resourceType: 'table',
|
||||
id: parsed.id,
|
||||
updates: { name },
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
toast.error(getErrorMessage(err, 'Failed to rename folder'), { duration: 5000 })
|
||||
throw err
|
||||
})
|
||||
}
|
||||
return renameTable.mutateAsync({ tableId: parsed.id, name })
|
||||
},
|
||||
})
|
||||
|
||||
const breadcrumbRename = useInlineRename({
|
||||
onSave: (folderId, name) =>
|
||||
updateFolder
|
||||
.mutateAsync({ workspaceId, resourceType: 'table', id: folderId, updates: { name } })
|
||||
.catch((err: unknown) => {
|
||||
toast.error(getErrorMessage(err, 'Failed to rename folder'), { duration: 5000 })
|
||||
throw err
|
||||
}),
|
||||
})
|
||||
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [isDeleteFolderDialogOpen, setIsDeleteFolderDialogOpen] = useState(false)
|
||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false)
|
||||
const [activeTable, setActiveTable] = useState<TableDefinition | null>(null)
|
||||
const [activeFolder, setActiveFolder] = useState<WorkflowFolder | null>(null)
|
||||
|
||||
const [{ search: urlSearchTerm, rows: rowCountFilter, owner: ownerFilter }, setTableFilters] =
|
||||
useQueryStates(tablesParsers, tablesUrlKeys)
|
||||
@@ -134,6 +233,9 @@ export function Tables() {
|
||||
const uploading = uploadProgress.total > 0
|
||||
const csvInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const tablesRef = useRef(tables)
|
||||
tablesRef.current = tables
|
||||
|
||||
const {
|
||||
isOpen: isListContextMenuOpen,
|
||||
position: listContextMenuPosition,
|
||||
@@ -148,9 +250,70 @@ export function Tables() {
|
||||
closeMenu: closeRowContextMenu,
|
||||
} = useContextMenu()
|
||||
|
||||
const [contextMenuKind, setContextMenuKind] = useState<'table' | 'folder'>('table')
|
||||
|
||||
/**
|
||||
* Descendants of every folder, so a move destination that sits inside the moved folder can
|
||||
* be excluded — reparenting a folder under its own child would close a cycle (the server
|
||||
* rejects it; this keeps it out of the menu, and out of a valid drop target, entirely).
|
||||
*/
|
||||
const descendantFolderIds = useMemo(() => buildDescendantIndex(folders), [folders])
|
||||
|
||||
const visibleFolders = useMemo(() => {
|
||||
const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId)
|
||||
const needle = debouncedSearchTerm.trim().toLowerCase()
|
||||
const searched = needle
|
||||
? siblings.filter((folder) => folder.name.toLowerCase().includes(needle))
|
||||
: siblings
|
||||
|
||||
return [...searched].sort((a, b) => {
|
||||
// Pinned folders float to the top of every sort/direction — pinning is a
|
||||
// user-declared priority, not another sort key to be inverted by `desc`.
|
||||
const aPinned = pinnedFolderIds.has(a.id)
|
||||
const bPinned = pinnedFolderIds.has(b.id)
|
||||
if (aPinned !== bPinned) return aPinned ? -1 : 1
|
||||
|
||||
/**
|
||||
* Read from `activeSort`, not the raw params: `tablesSortParams` is defaulted, so
|
||||
* `sortColumn` is never null and folders would sort newest-first on a clean URL while
|
||||
* Files and Knowledge sort them A→Z. Folders also carry none of the table-specific
|
||||
* columns, so `columns`/`rows`/`owner` fall back to name rather than an arbitrary order.
|
||||
*/
|
||||
const col = activeSort?.column ?? 'name'
|
||||
const dir = activeSort?.direction ?? 'asc'
|
||||
let cmp = 0
|
||||
if (col === 'created') {
|
||||
cmp = a.createdAt.getTime() - b.createdAt.getTime()
|
||||
} else if (col === 'updated') {
|
||||
cmp = a.updatedAt.getTime() - b.updatedAt.getTime()
|
||||
} else {
|
||||
cmp = a.name.localeCompare(b.name)
|
||||
}
|
||||
return dir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [folders, currentFolderId, debouncedSearchTerm, activeSort, pinnedFolderIds])
|
||||
|
||||
const processedTables = useMemo(() => {
|
||||
// Same source as `visibleFolders` above, so the two blocks can never disagree on order.
|
||||
const sortColumn = activeSort?.column ?? 'updated'
|
||||
const sortDirection = activeSort?.direction ?? 'desc'
|
||||
const query = debouncedSearchTerm.trim().toLowerCase()
|
||||
let result = query ? tables.filter((t) => t.name.toLowerCase().includes(query)) : tables
|
||||
/**
|
||||
* A `folderId` that no longer names an active folder — restored on its own out
|
||||
* of Recently Deleted while its folder stayed archived — would otherwise match
|
||||
* no level at all and leave the table unreachable from every view. Fall it back
|
||||
* to the root instead — but only once `foldersResolved` says the index is the complete
|
||||
* set for THIS workspace. Gating on a loading flag instead would treat an errored fetch,
|
||||
* a disabled query, or the previous workspace's cached folders as "no such folder" and
|
||||
* drag every foldered table to the root.
|
||||
*/
|
||||
let result = tables.filter((t) => {
|
||||
const folderId = t.folderId ?? null
|
||||
const effectiveFolderId =
|
||||
!foldersResolved || !folderId || folderById.has(folderId) ? folderId : null
|
||||
return effectiveFolderId === currentFolderId
|
||||
})
|
||||
if (query) result = result.filter((t) => t.name.toLowerCase().includes(query))
|
||||
|
||||
if (rowCountFilter.length > 0) {
|
||||
result = result.filter((t) => {
|
||||
@@ -188,8 +351,8 @@ export function Tables() {
|
||||
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
|
||||
break
|
||||
case 'owner': {
|
||||
const aName = members?.find((m) => m.userId === a.createdBy)?.name ?? ''
|
||||
const bName = members?.find((m) => m.userId === b.createdBy)?.name ?? ''
|
||||
const aName = membersById.get(a.createdBy)?.name ?? ''
|
||||
const bName = membersById.get(b.createdBy)?.name ?? ''
|
||||
cmp = aName.localeCompare(bName)
|
||||
break
|
||||
}
|
||||
@@ -198,32 +361,44 @@ export function Tables() {
|
||||
})
|
||||
}, [
|
||||
tables,
|
||||
currentFolderId,
|
||||
folderById,
|
||||
foldersResolved,
|
||||
debouncedSearchTerm,
|
||||
rowCountFilter,
|
||||
ownerFilter,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
members,
|
||||
membersById,
|
||||
pinnedTableIds,
|
||||
])
|
||||
|
||||
const rows: ResourceRow[] = useMemo(
|
||||
() =>
|
||||
processedTables.map((table) => ({
|
||||
/**
|
||||
* Folders first, then tables — folders are containers, so keeping them above
|
||||
* the leaves survives every sort column and direction.
|
||||
*/
|
||||
const baseRows: ResourceRow[] = useMemo(() => {
|
||||
const folderRows = visibleFolders.map((folder) =>
|
||||
folderRow(folder, {
|
||||
pinned: pinnedFolderIds.has(folder.id),
|
||||
cells: {
|
||||
columns: { label: EMPTY_CELL_PLACEHOLDER },
|
||||
rows: { label: EMPTY_CELL_PLACEHOLDER },
|
||||
created: timeCell(folder.createdAt),
|
||||
owner: ownerCell(folder.userId, membersById),
|
||||
updated: timeCell(folder.updatedAt),
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const tableRows = processedTables.map(
|
||||
(table): ResourceRow => ({
|
||||
id: table.id,
|
||||
cells: {
|
||||
name: {
|
||||
icon: <TableIcon className='size-[14px]' />,
|
||||
label: table.name,
|
||||
editing:
|
||||
tableRename.editingId === table.id
|
||||
? {
|
||||
value: tableRename.editValue,
|
||||
onChange: tableRename.setEditValue,
|
||||
onSubmit: tableRename.submitRename,
|
||||
onCancel: tableRename.cancelRename,
|
||||
}
|
||||
: undefined,
|
||||
pinned: pinnedTableIds.has(table.id),
|
||||
},
|
||||
columns: {
|
||||
icon: <Columns3 className='size-[14px]' />,
|
||||
@@ -234,19 +409,110 @@ export function Tables() {
|
||||
label: String(table.rowCount),
|
||||
},
|
||||
created: timeCell(table.createdAt),
|
||||
owner: ownerCell(table.createdBy, members),
|
||||
owner: ownerCell(table.createdBy, membersById),
|
||||
updated: timeCell(table.updatedAt),
|
||||
},
|
||||
})),
|
||||
[
|
||||
processedTables,
|
||||
members,
|
||||
tableRename.editingId,
|
||||
tableRename.editValue,
|
||||
tableRename.setEditValue,
|
||||
tableRename.submitRename,
|
||||
tableRename.cancelRename,
|
||||
})
|
||||
)
|
||||
|
||||
return [...folderRows, ...tableRows]
|
||||
}, [visibleFolders, processedTables, membersById, pinnedFolderIds, pinnedTableIds])
|
||||
|
||||
/**
|
||||
* Layered on top of {@link baseRows} rather than folded into it so a keystroke
|
||||
* in the rename field rebuilds one cell instead of every row's cells.
|
||||
*/
|
||||
const rows: ResourceRow[] = useMemo(() => {
|
||||
if (!listRename.editingId) return baseRows
|
||||
return baseRows.map((row) => {
|
||||
if (row.id !== listRename.editingId) return row
|
||||
return {
|
||||
...row,
|
||||
cells: {
|
||||
...row.cells,
|
||||
name: {
|
||||
...row.cells.name,
|
||||
editing: {
|
||||
value: listRename.editValue,
|
||||
onChange: listRename.setEditValue,
|
||||
onSubmit: listRename.submitRename,
|
||||
onCancel: listRename.cancelRename,
|
||||
disabled: listRename.isSaving,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [
|
||||
baseRows,
|
||||
listRename.editingId,
|
||||
listRename.editValue,
|
||||
listRename.isSaving,
|
||||
listRename.setEditValue,
|
||||
listRename.submitRename,
|
||||
listRename.cancelRename,
|
||||
])
|
||||
|
||||
const startFolderRename = useCallback(
|
||||
(folder: WorkflowFolder) => listRename.startRename(folderRowId(folder.id), folder.name),
|
||||
[listRename.startRename]
|
||||
)
|
||||
|
||||
const currentFolderActions: DropdownOption[] | undefined = useMemo(() => {
|
||||
if (!currentFolderId) return undefined
|
||||
const folder = folderById.get(currentFolderId)
|
||||
if (!folder) return undefined
|
||||
return [
|
||||
{
|
||||
label: 'Rename',
|
||||
disabled: !canEdit,
|
||||
onClick: () => breadcrumbRename.startRename(folder.id, folder.name),
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
disabled: !canEdit,
|
||||
/**
|
||||
* The only way to delete the folder you are inside — its own row is not in the list.
|
||||
* This is what makes the step-out in `handleDeleteFolder` reachable.
|
||||
*/
|
||||
onClick: () => {
|
||||
setActiveFolder(folder)
|
||||
setIsDeleteFolderDialogOpen(true)
|
||||
},
|
||||
},
|
||||
]
|
||||
}, [currentFolderId, folderById, canEdit, breadcrumbRename.startRename])
|
||||
|
||||
const currentFolderEditing = useMemo(() => {
|
||||
if (!currentFolderId || breadcrumbRename.editingId !== currentFolderId) return undefined
|
||||
return {
|
||||
isEditing: true,
|
||||
value: breadcrumbRename.editValue,
|
||||
onChange: breadcrumbRename.setEditValue,
|
||||
onSubmit: breadcrumbRename.submitRename,
|
||||
onCancel: breadcrumbRename.cancelRename,
|
||||
disabled: breadcrumbRename.isSaving,
|
||||
}
|
||||
}, [
|
||||
currentFolderId,
|
||||
breadcrumbRename.editingId,
|
||||
breadcrumbRename.editValue,
|
||||
breadcrumbRename.isSaving,
|
||||
breadcrumbRename.setEditValue,
|
||||
breadcrumbRename.submitRename,
|
||||
breadcrumbRename.cancelRename,
|
||||
])
|
||||
|
||||
const breadcrumbs = useMemo(
|
||||
() =>
|
||||
folderBreadcrumbItems({
|
||||
breadcrumbs: folderChain,
|
||||
rootLabel: ROOT_LABEL,
|
||||
onNavigate: setCurrentFolderId,
|
||||
currentFolderActions,
|
||||
currentFolderEditing,
|
||||
}),
|
||||
[folderChain, setCurrentFolderId, currentFolderActions, currentFolderEditing]
|
||||
)
|
||||
|
||||
const searchConfig: SearchConfig = useMemo(
|
||||
@@ -291,10 +557,9 @@ export function Tables() {
|
||||
|
||||
const ownerDisplayLabel = useMemo(() => {
|
||||
if (ownerFilter.length === 0) return 'All'
|
||||
if (ownerFilter.length === 1)
|
||||
return members?.find((m) => m.userId === ownerFilter[0])?.name ?? '1 member'
|
||||
if (ownerFilter.length === 1) return membersById.get(ownerFilter[0])?.name ?? '1 member'
|
||||
return `${ownerFilter.length} members`
|
||||
}, [ownerFilter, members])
|
||||
}, [ownerFilter, membersById])
|
||||
|
||||
const memberOptions: ComboboxOption[] = useMemo(
|
||||
() =>
|
||||
@@ -381,6 +646,8 @@ export function Tables() {
|
||||
rowCountDisplayLabel,
|
||||
ownerDisplayLabel,
|
||||
hasActiveFilters,
|
||||
setRowCountFilter,
|
||||
setOwnerFilter,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -397,12 +664,12 @@ export function Tables() {
|
||||
if (ownerFilter.length > 0) {
|
||||
const label =
|
||||
ownerFilter.length === 1
|
||||
? `Owner: ${members?.find((m) => m.userId === ownerFilter[0])?.name ?? '1 member'}`
|
||||
? `Owner: ${membersById.get(ownerFilter[0])?.name ?? '1 member'}`
|
||||
: `Owner: ${ownerFilter.length} members`
|
||||
tags.push({ label, onRemove: () => setOwnerFilter([]) })
|
||||
}
|
||||
return tags
|
||||
}, [rowCountFilter, ownerFilter, members])
|
||||
}, [rowCountFilter, ownerFilter, membersById, setRowCountFilter, setOwnerFilter])
|
||||
|
||||
const handleContentContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@@ -420,28 +687,126 @@ export function Tables() {
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(rowId: string) => {
|
||||
if (!isRowContextMenuOpen) {
|
||||
router.push(`/workspace/${workspaceId}/tables/${rowId}`)
|
||||
if (isRowContextMenuOpen || listRename.editingId === rowId) return
|
||||
const parsed = parseFolderedRowId(rowId)
|
||||
if (parsed.kind === 'folder') {
|
||||
setCurrentFolderId(parsed.id)
|
||||
return
|
||||
}
|
||||
router.push(`/workspace/${workspaceId}/tables/${parsed.id}`)
|
||||
},
|
||||
[isRowContextMenuOpen, router, workspaceId]
|
||||
[isRowContextMenuOpen, listRename.editingId, router, workspaceId, setCurrentFolderId]
|
||||
)
|
||||
|
||||
const resolveRowItem = useCallback(
|
||||
(rowId: string): TableResourceItem | null => {
|
||||
const parsed = parseFolderedRowId(rowId)
|
||||
if (parsed.kind === 'folder') {
|
||||
const folder = folderById.get(parsed.id)
|
||||
return folder ? { kind: 'folder', folder } : null
|
||||
}
|
||||
const table = tables.find((t) => t.id === parsed.id)
|
||||
return table ? { kind: 'table', table } : null
|
||||
},
|
||||
[folderById, tables]
|
||||
)
|
||||
|
||||
const handleRowContextMenu = useCallback(
|
||||
(e: React.MouseEvent, rowId: string) => {
|
||||
const table = tables.find((t) => t.id === rowId) ?? null
|
||||
setActiveTable(table)
|
||||
const item = resolveRowItem(rowId)
|
||||
if (!item) return
|
||||
if (item.kind === 'folder') {
|
||||
setActiveFolder(item.folder)
|
||||
setActiveTable(null)
|
||||
setContextMenuKind('folder')
|
||||
} else {
|
||||
setActiveTable(item.table)
|
||||
setActiveFolder(null)
|
||||
setContextMenuKind('table')
|
||||
}
|
||||
handleRowCtxMenu(e)
|
||||
},
|
||||
[tables, handleRowCtxMenu]
|
||||
[resolveRowItem, handleRowCtxMenu]
|
||||
)
|
||||
|
||||
const handleTogglePin = useCallback(() => {
|
||||
if (!activeTable) return
|
||||
const mutation = pinnedTableIds.has(activeTable.id) ? unpinItem : pinItem
|
||||
mutation.mutate({ workspaceId, resourceType: 'table', resourceId: activeTable.id })
|
||||
closeRowContextMenu()
|
||||
}, [workspaceId, activeTable, pinnedTableIds, closeRowContextMenu])
|
||||
const tableMoveOptions: MoveOptionNode[] = useMemo(
|
||||
() => buildMoveOptions({ folders, rootLabel: ROOT_LABEL }),
|
||||
[folders]
|
||||
)
|
||||
|
||||
const folderMoveOptions: MoveOptionNode[] = useMemo(() => {
|
||||
if (!activeFolder) return []
|
||||
const excluded = new Set<string>([activeFolder.id])
|
||||
for (const id of descendantFolderIds.get(activeFolder.id) ?? []) excluded.add(id)
|
||||
return buildMoveOptions({ folders, rootLabel: ROOT_LABEL, excludedFolderIds: excluded })
|
||||
}, [activeFolder, folders, descendantFolderIds])
|
||||
|
||||
const handleMoveTable = useCallback(
|
||||
(optionValue: string) => {
|
||||
if (!activeTable) return
|
||||
const folderId = parseMoveOptionValue(optionValue)
|
||||
/**
|
||||
* Placement is re-read from the live list rather than trusted from `activeTable`, which
|
||||
* is a snapshot taken when the menu opened. A refetch or a concurrent move since then
|
||||
* would make the no-op check compare against a stale location and skip a write the user
|
||||
* asked for. Matches the knowledge-base move.
|
||||
*/
|
||||
const current = tablesRef.current.find((table) => table.id === activeTable.id) ?? activeTable
|
||||
if ((current.folderId ?? null) === folderId) {
|
||||
closeRowContextMenu()
|
||||
return
|
||||
}
|
||||
moveTable.mutate({ tableId: activeTable.id, folderId })
|
||||
closeRowContextMenu()
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutate is stable in v5
|
||||
[activeTable, closeRowContextMenu]
|
||||
)
|
||||
|
||||
/** Shared by the "Move to" submenu and by dropping a folder row onto another folder. */
|
||||
const moveFolderTo = useCallback(
|
||||
(folderId: string, parentId: string | null) => {
|
||||
updateFolder.mutate(
|
||||
{ workspaceId, resourceType: 'table', id: folderId, updates: { parentId } },
|
||||
{
|
||||
onError: (err) =>
|
||||
toast.error(getErrorMessage(err, 'Failed to move folder'), { duration: 5000 }),
|
||||
}
|
||||
)
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutate is stable in v5
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const handleMoveFolder = useCallback(
|
||||
(optionValue: string) => {
|
||||
if (!activeFolder) return
|
||||
const parentId = parseMoveOptionValue(optionValue)
|
||||
// Same reasoning as `handleMoveTable`: compare against the live row, not the snapshot.
|
||||
const current = folderById.get(activeFolder.id) ?? activeFolder
|
||||
if ((current.parentId ?? null) !== parentId) moveFolderTo(activeFolder.id, parentId)
|
||||
closeRowContextMenu()
|
||||
},
|
||||
[activeFolder, folderById, moveFolderTo, closeRowContextMenu]
|
||||
)
|
||||
|
||||
const rowDragDropConfig = useFolderRowDragDrop({
|
||||
canEdit,
|
||||
editingRowId: listRename.editingId,
|
||||
descendantsByFolderId: descendantFolderIds,
|
||||
getFolderParentId: (folderId) => folderById.get(folderId)?.parentId ?? null,
|
||||
getResourceFolderId: (tableId) =>
|
||||
tablesRef.current.find((table) => table.id === tableId)?.folderId ?? null,
|
||||
getRowLabel: (rowId) => {
|
||||
const parsed = parseFolderedRowId(rowId)
|
||||
return parsed.kind === 'folder'
|
||||
? (folderById.get(parsed.id)?.name ?? 'Folder')
|
||||
: (tablesRef.current.find((table) => table.id === parsed.id)?.name ?? 'Table')
|
||||
},
|
||||
onMoveFolder: (folderId, targetFolderId) => moveFolderTo(folderId, targetFolderId),
|
||||
onMoveResource: (tableId, targetFolderId) =>
|
||||
moveTable.mutate({ tableId, folderId: targetFolderId }),
|
||||
})
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!activeTable) return
|
||||
@@ -454,6 +819,51 @@ export function Tables() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleTogglePin = useCallback(() => {
|
||||
const target =
|
||||
contextMenuKind === 'folder'
|
||||
? activeFolder && { resourceType: 'folder' as const, id: activeFolder.id }
|
||||
: activeTable && { resourceType: 'table' as const, id: activeTable.id }
|
||||
if (!target) return
|
||||
const pinned =
|
||||
target.resourceType === 'folder'
|
||||
? pinnedFolderIds.has(target.id)
|
||||
: pinnedTableIds.has(target.id)
|
||||
const mutation = pinned ? unpinItem : pinItem
|
||||
mutation.mutate({ workspaceId, resourceType: target.resourceType, resourceId: target.id })
|
||||
closeRowContextMenu()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutate is stable in v5
|
||||
}, [
|
||||
workspaceId,
|
||||
contextMenuKind,
|
||||
activeFolder,
|
||||
activeTable,
|
||||
pinnedFolderIds,
|
||||
pinnedTableIds,
|
||||
closeRowContextMenu,
|
||||
])
|
||||
|
||||
const handleDeleteFolder = async () => {
|
||||
if (!activeFolder) return
|
||||
try {
|
||||
await deleteFolder.mutateAsync({
|
||||
workspaceId,
|
||||
resourceType: 'table',
|
||||
id: activeFolder.id,
|
||||
})
|
||||
// The open folder just disappeared — fall back to its parent rather than
|
||||
// leaving a `?folderId=` pointing at an archived folder.
|
||||
if (currentFolderId === activeFolder.id) {
|
||||
setCurrentFolderId(activeFolder.parentId)
|
||||
}
|
||||
setIsDeleteFolderDialogOpen(false)
|
||||
setActiveFolder(null)
|
||||
} catch (err) {
|
||||
logger.error('Failed to delete folder:', err)
|
||||
toast.error(getErrorMessage(err, 'Failed to delete folder'), { duration: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
const handleCsvChange = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const list = e.target.files
|
||||
@@ -489,6 +899,7 @@ export function Tables() {
|
||||
try {
|
||||
const result = await importCsvAsync.mutateAsync({
|
||||
workspaceId,
|
||||
folderId: currentFolderId,
|
||||
file,
|
||||
onProgress: (percent) => {
|
||||
useImportTrayStore.getState().setUploadPercent(pendingId, percent)
|
||||
@@ -519,7 +930,11 @@ export function Tables() {
|
||||
for (let i = 0; i < syncFiles.length; i++) {
|
||||
const file = syncFiles[i]
|
||||
try {
|
||||
const result = await uploadCsv.mutateAsync({ workspaceId, file })
|
||||
const result = await uploadCsv.mutateAsync({
|
||||
workspaceId,
|
||||
folderId: currentFolderId,
|
||||
file,
|
||||
})
|
||||
|
||||
if (syncFiles.length === 1 && asyncFiles.length === 0) {
|
||||
const tableId = result?.data?.table?.id
|
||||
@@ -553,7 +968,7 @@ export function Tables() {
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5
|
||||
[workspaceId, router]
|
||||
[workspaceId, currentFolderId, router]
|
||||
)
|
||||
|
||||
const handleListUploadCsv = useCallback(() => {
|
||||
@@ -574,6 +989,7 @@ export function Tables() {
|
||||
try {
|
||||
const result = await createTableAsync({
|
||||
name,
|
||||
folderId: currentFolderId,
|
||||
schema: {
|
||||
columns: [{ name: 'name', type: 'string' }],
|
||||
},
|
||||
@@ -586,7 +1002,30 @@ export function Tables() {
|
||||
} catch (err) {
|
||||
logger.error('Failed to create table:', err)
|
||||
}
|
||||
}, [tables, router, workspaceId, createTableAsync])
|
||||
}, [tables, router, workspaceId, currentFolderId, createTableAsync])
|
||||
|
||||
const createFolderAsync = createFolder.mutateAsync
|
||||
const handleCreateFolder = useCallback(async () => {
|
||||
try {
|
||||
const folder = await createFolderAsync({
|
||||
workspaceId,
|
||||
resourceType: 'table',
|
||||
name: nextUntitledFolderName(folders, currentFolderId),
|
||||
parentId: currentFolderId ?? undefined,
|
||||
})
|
||||
/**
|
||||
* A live search term filters the folder list too, so a brand-new "New folder" would not
|
||||
* match it — the row never renders, the rename field never appears, and the create reads
|
||||
* as a no-op even though it succeeded. Clear the search so the thing just created is on
|
||||
* screen to be named.
|
||||
*/
|
||||
setSearchTerm('')
|
||||
startFolderRename(folder)
|
||||
} catch (err) {
|
||||
logger.error('Failed to create folder:', err)
|
||||
toast.error(getErrorMessage(err, 'Failed to create folder'), { duration: 5000 })
|
||||
}
|
||||
}, [workspaceId, folders, currentFolderId, createFolderAsync, setSearchTerm, startFolderRename])
|
||||
|
||||
const headerActions: ResourceAction[] = useMemo(
|
||||
() => [
|
||||
@@ -594,21 +1033,29 @@ export function Tables() {
|
||||
text: uploadButtonLabel,
|
||||
icon: Upload,
|
||||
onSelect: () => csvInputRef.current?.click(),
|
||||
disabled: uploading || userPermissions.canEdit !== true,
|
||||
disabled: uploading || !canEdit,
|
||||
},
|
||||
{
|
||||
text: 'New folder',
|
||||
icon: FolderPlus,
|
||||
onSelect: handleCreateFolder,
|
||||
disabled: !canEdit || createFolder.isPending,
|
||||
},
|
||||
{
|
||||
text: 'New table',
|
||||
icon: Plus,
|
||||
onSelect: handleCreateTable,
|
||||
disabled: uploading || userPermissions.canEdit !== true || createTable.isPending,
|
||||
disabled: uploading || !canEdit || createTable.isPending,
|
||||
variant: 'primary',
|
||||
},
|
||||
],
|
||||
[
|
||||
uploadButtonLabel,
|
||||
uploading,
|
||||
userPermissions.canEdit,
|
||||
canEdit,
|
||||
handleCreateFolder,
|
||||
handleCreateTable,
|
||||
createFolder.isPending,
|
||||
createTable.isPending,
|
||||
]
|
||||
)
|
||||
@@ -623,7 +1070,8 @@ export function Tables() {
|
||||
<Resource onContextMenu={handleContentContextMenu}>
|
||||
<Resource.Header
|
||||
icon={TableIcon}
|
||||
title='Tables'
|
||||
title={ROOT_LABEL}
|
||||
breadcrumbs={breadcrumbs}
|
||||
actions={headerActions}
|
||||
aside={headerAside}
|
||||
/>
|
||||
@@ -636,6 +1084,7 @@ export function Tables() {
|
||||
<Resource.Table
|
||||
columns={COLUMNS}
|
||||
rows={rows}
|
||||
rowDragDrop={rowDragDropConfig}
|
||||
onRowClick={handleRowClick}
|
||||
onRowContextMenu={handleRowContextMenu}
|
||||
/>
|
||||
@@ -656,23 +1105,23 @@ export function Tables() {
|
||||
position={listContextMenuPosition}
|
||||
onClose={closeListContextMenu}
|
||||
onCreateTable={handleCreateTable}
|
||||
onCreateFolder={handleCreateFolder}
|
||||
onUploadCsv={handleListUploadCsv}
|
||||
disableCreate={userPermissions.canEdit !== true || createTable.isPending}
|
||||
disableUpload={uploading || userPermissions.canEdit !== true}
|
||||
disableCreate={!canEdit || createTable.isPending}
|
||||
disableCreateFolder={!canEdit || createFolder.isPending}
|
||||
disableUpload={uploading || !canEdit}
|
||||
/>
|
||||
|
||||
<TableContextMenu
|
||||
isOpen={isRowContextMenuOpen}
|
||||
isOpen={isRowContextMenuOpen && contextMenuKind === 'table'}
|
||||
position={rowContextMenuPosition}
|
||||
onClose={closeRowContextMenu}
|
||||
onCopyId={() => {
|
||||
if (activeTable) navigator.clipboard.writeText(activeTable.id)
|
||||
}}
|
||||
onTogglePin={handleTogglePin}
|
||||
pinned={activeTable ? pinnedTableIds.has(activeTable.id) : false}
|
||||
onDelete={() => setIsDeleteDialogOpen(true)}
|
||||
onRename={() => {
|
||||
if (activeTable) tableRename.startRename(activeTable.id, activeTable.name)
|
||||
if (activeTable) listRename.startRename(activeTable.id, activeTable.name)
|
||||
}}
|
||||
onImportCsv={() => setIsImportDialogOpen(true)}
|
||||
onExportCsv={async () => {
|
||||
@@ -684,9 +1133,35 @@ export function Tables() {
|
||||
toast.error('Failed to export table')
|
||||
}
|
||||
}}
|
||||
disableDelete={userPermissions.canEdit !== true}
|
||||
disableRename={userPermissions.canEdit !== true}
|
||||
disableImport={userPermissions.canEdit !== true}
|
||||
onTogglePin={handleTogglePin}
|
||||
pinned={activeTable ? pinnedTableIds.has(activeTable.id) : false}
|
||||
onMove={canEdit ? handleMoveTable : undefined}
|
||||
moveOptions={canEdit ? tableMoveOptions : undefined}
|
||||
disableDelete={!canEdit}
|
||||
disableRename={!canEdit}
|
||||
disableImport={!canEdit}
|
||||
/>
|
||||
|
||||
<FolderContextMenu
|
||||
isOpen={isRowContextMenuOpen && contextMenuKind === 'folder'}
|
||||
position={rowContextMenuPosition}
|
||||
onClose={closeRowContextMenu}
|
||||
onOpen={() => {
|
||||
if (activeFolder) setCurrentFolderId(activeFolder.id)
|
||||
closeRowContextMenu()
|
||||
}}
|
||||
onRename={() => {
|
||||
if (activeFolder) startFolderRename(activeFolder)
|
||||
}}
|
||||
onCopyId={() => {
|
||||
if (activeFolder) navigator.clipboard.writeText(activeFolder.id)
|
||||
}}
|
||||
onDelete={() => setIsDeleteFolderDialogOpen(true)}
|
||||
onTogglePin={handleTogglePin}
|
||||
pinned={activeFolder ? pinnedFolderIds.has(activeFolder.id) : false}
|
||||
onMove={canEdit ? handleMoveFolder : undefined}
|
||||
moveOptions={canEdit ? folderMoveOptions : undefined}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
|
||||
{activeTable && (
|
||||
@@ -723,6 +1198,29 @@ export function Tables() {
|
||||
pendingLabel: 'Deleting...',
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChipConfirmModal
|
||||
open={isDeleteFolderDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsDeleteFolderDialogOpen(open)
|
||||
if (!open) setActiveFolder(null)
|
||||
}}
|
||||
srTitle='Delete Folder'
|
||||
title='Delete Folder'
|
||||
text={[
|
||||
'Are you sure you want to delete ',
|
||||
{ text: activeFolder?.name ?? 'this folder', bold: true },
|
||||
'? ',
|
||||
{ text: 'Every table and subfolder inside it will be deleted too.', error: true },
|
||||
' You can restore those tables from Recently Deleted in Settings.',
|
||||
]}
|
||||
confirm={{
|
||||
label: 'Delete',
|
||||
onClick: handleDeleteFolder,
|
||||
pending: deleteFolder.isPending,
|
||||
pendingLabel: 'Deleting...',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
import { useFullscreenOriginStore } from '@/stores/fullscreen-origin'
|
||||
|
||||
/** Enterprise "Talk to sales" books time with the sales team on Cal.com. */
|
||||
const SALES_CAL_URL = 'https://cal.com/team/sim/enterprise' as const
|
||||
const SALES_CAL_URL = 'https://cal.com/team/sim/demo' as const
|
||||
|
||||
/**
|
||||
* Props for {@link Upgrade}.
|
||||
|
||||
+2
-1
@@ -13,7 +13,8 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
|
||||
import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
|
||||
import { fetchKnowledgeBase, knowledgeKeys } from '@/hooks/queries/kb/knowledge'
|
||||
import { fetchKnowledgeBase } from '@/hooks/queries/kb/knowledge'
|
||||
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
|
||||
|
||||
interface KnowledgeBaseSelectorProps {
|
||||
blockId: string
|
||||
|
||||
+13
-2
@@ -1,14 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { chipVariants, cn } from '@sim/emcn'
|
||||
import { chipVariants, cn, toast } from '@sim/emcn'
|
||||
import { Lock } from '@sim/emcn/icons'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronRight, Folder, FolderOpen, MoreHorizontal } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
|
||||
import { generateSubfolderName } from '@/lib/workspaces/naming'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
|
||||
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
|
||||
@@ -165,9 +167,17 @@ export const FolderItem = memo(function FolderItem({ workspaceId, folder }: Fold
|
||||
const handleCreateFolderInFolder = useCallback(async () => {
|
||||
if (effectiveLocked) return
|
||||
try {
|
||||
/**
|
||||
* The name has to be unique before it is sent: `folder` has a partial unique index on
|
||||
* active (workspaceId, resourceType, parentId, name), so a hardcoded 'New folder'
|
||||
* 409s on the second invocation — and the user never chose this name, so there is
|
||||
* nothing for them to correct. Mirrors the root-level create in
|
||||
* `use-folder-operations`, which already names through this helper.
|
||||
*/
|
||||
const name = await generateSubfolderName(workspaceId, folder.id)
|
||||
const result = await createFolderMutation.mutateAsync({
|
||||
workspaceId,
|
||||
name: 'New folder',
|
||||
name,
|
||||
parentId: folder.id,
|
||||
id: generateId(),
|
||||
})
|
||||
@@ -179,6 +189,7 @@ export const FolderItem = memo(function FolderItem({ workspaceId, folder }: Fold
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to create folder:', error)
|
||||
toast.error(getErrorMessage(error, 'Failed to create folder'))
|
||||
}
|
||||
}, [createFolderMutation, workspaceId, folder.id, effectiveLocked, expandFolder])
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
|
||||
import { dbChainMock, dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
@@ -261,3 +261,74 @@ describe('cleanup soft deletes', () => {
|
||||
expect(mockDeleteFileMetadata).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
interface BatchDeleteOptions {
|
||||
tableDef: unknown
|
||||
workspaceIdCol: unknown
|
||||
timestampCol: unknown
|
||||
tableName: string
|
||||
requireTimestampNotNull?: boolean
|
||||
additionalPredicate?: { type: string; column: unknown; values: unknown[] }
|
||||
}
|
||||
|
||||
/**
|
||||
* The `folder` table is shared by all four foldered resource types and is the one cleanup
|
||||
* target carrying an `additionalPredicate`. That predicate is the only thing standing
|
||||
* between this sweep and hard-deleting rows of a resource type whose cutover has not landed.
|
||||
*/
|
||||
describe('folder cleanup target', () => {
|
||||
async function runAndFindFolderTarget(): Promise<BatchDeleteOptions | undefined> {
|
||||
await runCleanupSoftDeletes(basePayload)
|
||||
const calls = mockBatchDeleteByWorkspaceAndTimestamp.mock.calls as unknown as Array<
|
||||
[BatchDeleteOptions]
|
||||
>
|
||||
return calls.find(([options]) => options.tableName === 'free/1/folder')?.[0]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
mockSelectRowsByIdChunks.mockReset().mockResolvedValue([])
|
||||
mockChunkedBatchDelete.mockReset().mockResolvedValue({ deleted: 0, failed: 0 })
|
||||
})
|
||||
|
||||
it('sweeps expired folder rows off the folder table, keyed on its soft-delete column', async () => {
|
||||
const target = await runAndFindFolderTarget()
|
||||
|
||||
expect(target).toBeDefined()
|
||||
expect(target?.tableDef).toBe(schemaMock.folder)
|
||||
expect(target?.timestampCol).toBe(schemaMock.folder.deletedAt)
|
||||
expect(target?.workspaceIdCol).toBe(schemaMock.folder.workspaceId)
|
||||
// Without this, a folder whose deletedAt is null would be swept by the retention cutoff.
|
||||
expect(target?.requireTimestampNotNull).toBe(true)
|
||||
})
|
||||
|
||||
it('narrows the folder sweep to the resource types whose cutover has landed', async () => {
|
||||
// Regression guard for "simplifying" the predicate away: `folder` is one table shared by
|
||||
// every resource type, so an unfiltered sweep would hard-delete rows belonging to a type
|
||||
// added to the enum before its own delete/restore path exists.
|
||||
const target = await runAndFindFolderTarget()
|
||||
|
||||
expect(target?.additionalPredicate).toBeDefined()
|
||||
expect(target?.additionalPredicate?.type).toBe('inArray')
|
||||
expect(target?.additionalPredicate?.column).toBe(schemaMock.folder.resourceType)
|
||||
expect(target?.additionalPredicate?.values).toEqual([
|
||||
'workflow',
|
||||
'file',
|
||||
'knowledge_base',
|
||||
'table',
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves every other cleanup target unfiltered so only folder pays for the predicate', async () => {
|
||||
await runCleanupSoftDeletes(basePayload)
|
||||
const calls = mockBatchDeleteByWorkspaceAndTimestamp.mock.calls as unknown as Array<
|
||||
[BatchDeleteOptions]
|
||||
>
|
||||
|
||||
const filtered = calls
|
||||
.filter(([options]) => options.additionalPredicate !== undefined)
|
||||
.map(([options]) => options.tableName)
|
||||
expect(filtered).toEqual(['free/1/folder'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -397,6 +397,19 @@ async function cleanupExpiredKnowledgeBases(
|
||||
)
|
||||
)
|
||||
.limit(limit),
|
||||
/**
|
||||
* Re-asserted on the DELETE because `onBatch` hard-deletes documents first, which leaves a
|
||||
* real window in which a restore can land.
|
||||
*
|
||||
* This narrows the window rather than closing it: the base row survives, but documents
|
||||
* already hard-deleted by `onBatch` do not come back, so a restore landing mid-batch
|
||||
* returns an emptied knowledge base. Closing it properly means holding a row lock across
|
||||
* select → onBatch → delete.
|
||||
*/
|
||||
deleteFilter: and(
|
||||
isNotNull(knowledgeBase.deletedAt),
|
||||
lt(knowledgeBase.deletedAt, retentionDate)
|
||||
),
|
||||
onBatch: (rows) =>
|
||||
hardDeleteKnowledgeBaseDocuments(
|
||||
rows.map(({ id }) => id),
|
||||
@@ -417,12 +430,18 @@ const CLEANUP_TARGETS = [
|
||||
softDeleteCol: folderTable.deletedAt,
|
||||
wsCol: folderTable.workspaceId,
|
||||
/**
|
||||
* `folder` is shared by all four resource types, but file folders are still written to
|
||||
* `workspace_file_folders` and this pass must not hard-delete rows it does not own. One
|
||||
* widened predicate rather than a target per type: same table, same soft-delete column,
|
||||
* same workspace scoping — splitting it would only multiply the batched scans.
|
||||
* `folder` is shared by all four resource types, every one of which now writes here.
|
||||
* The predicate is kept (rather than dropped) so a resource type added to the enum
|
||||
* before its cutover lands is not silently hard-deleted by this pass. One widened
|
||||
* predicate rather than a target per type: same table, same soft-delete column, same
|
||||
* workspace scoping — splitting it would only multiply the batched scans.
|
||||
*/
|
||||
additionalPredicate: inArray(folderTable.resourceType, ['workflow', 'knowledge_base', 'table']),
|
||||
additionalPredicate: inArray(folderTable.resourceType, [
|
||||
'workflow',
|
||||
'file',
|
||||
'knowledge_base',
|
||||
'table',
|
||||
]),
|
||||
name: 'folder',
|
||||
},
|
||||
{
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
---
|
||||
slug: best-ai-agents-for-regulated-industry-workflows-healthcare-legal-procurement
|
||||
title: 'Best AI Agents for Regulated Industry Workflows (Healthcare, Legal, Procurement)'
|
||||
description: 'Compare AI agent platforms for regulated healthcare, legal, and procurement workflows, with a focus on deployment control, access governance, and auditability.'
|
||||
date: 2026-07-29
|
||||
updated: 2026-07-29
|
||||
authors:
|
||||
- andrew
|
||||
readingTime: 6
|
||||
tags: [AI Agents, Compliance, Healthcare, Legal, Procurement, Sim]
|
||||
ogImage: /library/best-ai-agents-for-regulated-industry-workflows-healthcare-legal-procurement/cover.jpg
|
||||
canonical: https://www.sim.ai/library/best-ai-agents-for-regulated-industry-workflows-healthcare-legal-procurement
|
||||
draft: false
|
||||
faq:
|
||||
- q: "Does Sim hold HIPAA or GDPR certification?"
|
||||
a: "No. Sim's verified attestation is SOC 2 Type II on the Enterprise plan. You can build governed workflows on Sim, including self-hosted deployments, but you remain responsible for your own HIPAA or GDPR obligations."
|
||||
- q: "Do these platforms hold SOC 2?"
|
||||
a: "Review each vendor's current trust or security documentation before relying on an attestation in a compliance review. The comparison table links Sim, n8n, Zapier, Make, Gumloop, and Workato to their published security materials."
|
||||
- q: "What is governed self-hosting versus community self-host?"
|
||||
a: "Community self-hosting deploys the open-source core on your own infrastructure. Governed Enterprise self-hosting adds SSO, role-based access control, and audit logs to the deployment so a compliance team can review who did what."
|
||||
- q: "Does SOC 2 replace HIPAA or GDPR compliance?"
|
||||
a: "No. A SOC 2 report addresses a vendor's defined controls; it does not replace the separate obligations HIPAA sets for protected health information or GDPR sets for personal data."
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **Healthcare intake:** Sim is a strong starting point when patient intake and scheduling agents must run under access boundaries you control. Its [governed Enterprise self-hosting](https://docs.sim.ai/platform/enterprise) is designed for self-hosted deployments with SSO, role-based access control (RBAC), and audit logs.
|
||||
- **Legal review and discovery:** Sim and [Workato](https://www.workato.com/platform/security) are worth evaluating when contract review and document discovery require scoped access and traceability.
|
||||
- **Procurement approvals:** Sim suits multi-step approval chains where role-based sign-off and traceable decisions matter. [Zapier](https://zapier.com/security) and [Make](https://www.make.com/en/security) can be practical for lower-stakes routing tasks.
|
||||
- **Compliance posture:** Treat attestations as one input, not a shortcut to compliance. Sim's Enterprise materials describe SOC 2 Type II, SSO, RBAC, audit logs, and self-hosting; organizations still own their regulatory obligations.
|
||||
|
||||
## What AI agent platform is best for healthcare intake workflows under compliance constraints?
|
||||
|
||||
For patient intake and scheduling agents where protected health information (PHI) passes through the workflow, [Sim on its Enterprise plan](https://docs.sim.ai/platform/enterprise) is a strong starting point among platforms buyers commonly evaluate. The reason is not a healthcare badge. Governed Enterprise self-hosting, RBAC, and audit logs are the controls a compliance reviewer will want to see demonstrated before an intake agent touches real patient data.
|
||||
|
||||
Governed self-hosting matters because it determines where PHI lives and who administers the environment. With Sim's [Enterprise deployment model](https://docs.sim.ai/platform/self-hosting), teams can run the platform in infrastructure they control and apply their own network, key-management, and data-residency practices. That differs from community self-hosting, which provides the open-source deployment without the Enterprise governance layer. For a healthcare workflow, evaluate the deployment design and the governing controls before judging scheduling logic.
|
||||
|
||||
Access control and audit logs address two other review questions. RBAC scopes who can build, edit, or run an intake agent, while [audit logs and Enterprise controls](https://docs.sim.ai/platform/enterprise) provide a record of activity to review. Sim's Enterprise materials describe a SOC 2 Type II attestation. That is not a HIPAA certification, and an organization remains responsible for its own HIPAA program regardless of platform choice.
|
||||
|
||||
The workflow-automation-first tools make different deployment trade-offs. [n8n documents self-hosting options](https://docs.n8n.io/hosting/), while [Zapier's security documentation](https://zapier.com/security) and [Make's security documentation](https://www.make.com/en/security) describe their managed services. [Gumloop's security page](https://www.gumloop.com/solutions/security) describes its enterprise security approach. Compare each vendor's current deployment, identity, access, and logging documentation against the boundaries your PHI workflow requires.
|
||||
|
||||
Sim's [Apache 2.0 core](https://github.com/simstudioai/sim) is also relevant for teams that need to inspect the software they deploy. For a licensing-focused comparison of that model and n8n's terms, see [Apache 2.0 vs. fair-code](https://www.sim.ai/library/apache-2-0-vs-fair-code). For healthcare intake specifically, evaluate the deployment model and the access and audit controls first, then assess how well the agent handles scheduling logic.
|
||||
|
||||
## Which AI agents handle legal contract review and document discovery?
|
||||
|
||||
Legal contract review and discovery agents live or die on traceability. Sim and [Workato](https://www.workato.com/platform/security) are two platforms to evaluate when a workflow must document access to sensitive material. Contract review can touch privileged information, and discovery may require teams to account for who accessed a document set and when. The buying question is whether the platform records agent activity against a sensitive corpus in a way that meets the firm's review process.
|
||||
|
||||
Sim is a fit for firms that need to scope access by matter or client and maintain a record of agent activity. Its Enterprise controls describe access management and [audit logs](https://docs.sim.ai/platform/enterprise); its governed self-hosting option keeps the deployment in infrastructure the customer operates. The Apache 2.0 core also lets a technical team inspect the source it deploys rather than rely entirely on a proprietary implementation.
|
||||
|
||||
Workato is a reasonable option for enterprise legal teams that already use it as an integration layer. Its [security and governance materials](https://www.workato.com/platform/security) describe enterprise controls including access management, audit logging, and log streaming. The appropriate choice depends on the firm's deployment model, existing systems, and matter-level access requirements.
|
||||
|
||||
The lighter automation tools should be evaluated against the same controls rather than dismissed by category. [n8n's security documentation](https://docs.n8n.io/hosting/) covers its deployment options, while [Zapier](https://zapier.com/security) and [Make](https://www.make.com/en/security) publish security information for their managed offerings. For legal review and discovery, test whether the configuration can enforce the document boundaries and produce the evidence your review process requires. Security also extends to every connected tool; this guide to [MCP security](https://www.sim.ai/library/mcp-security) covers the risks of granting an agent access to internal systems.
|
||||
|
||||
## What's the best AI agent tool for procurement approval workflows?
|
||||
|
||||
Sim and [Workato](https://www.workato.com/platform/security) are leading options to evaluate for procurement approval workflows where RBAC and audit records are requirements. A vendor comparison agent that routes purchases through a manager, finance, and legal needs both scoped approver roles and a traceable record of each sign-off.
|
||||
|
||||
Approval-chain integrity depends on more than routing a request to the next person. When a high-value vendor contract clears a multi-step chain, an auditor may ask which role approved a particular step and whether that person had the authority to do so. Sim's [Enterprise access controls](https://docs.sim.ai/platform/enterprise) and audit records are intended to support this kind of review. Workato's [enterprise security materials](https://www.workato.com/platform/security) describe comparable governance controls for organizations already using its integration platform.
|
||||
|
||||
[Zapier](https://zapier.com/security) and [Make](https://www.make.com/en/security) can handle straightforward notifications and spreadsheet updates. The question is whether their current plan and configuration meet your approval policy, including role scope and decision-level evidence. For lower-value, single-approver requests, a lighter setup may be the right trade-off. For multi-tier chains governed by purchasing policy, use a platform that can demonstrate the required role boundaries and records.
|
||||
|
||||
For a broader look at where agents fit across sourcing, intake, contracts, and supplier risk, read [AI agents in procurement](https://www.sim.ai/library/ai-agents-in-procurement). The core implementation choice remains the same: start with the required governance constraint, then design the automation around it.
|
||||
|
||||
## How Sim, n8n, Zapier, Make, Gumloop, and Workato compare on governance
|
||||
|
||||
When IT or operations leaders score these platforms, they should move beyond trigger counts and ask five questions: Can we enforce single sign-on? Can we scope access by role? Does the platform record activity for an audit? Can it run in our environment? And what independent assurance does the vendor publish? The table links each platform to its own trust or security materials; confirm the current details with the vendor before using them in a compliance review.
|
||||
|
||||
Deployment model and licensing are often more decisive than a feature checklist. Sim's core uses [Apache 2.0](https://github.com/simstudioai/sim), while [n8n publishes its Sustainable Use License](https://docs.n8n.io/sustainable-use-license/). [Gumloop's security page](https://www.gumloop.com/solutions/security) describes its enterprise approach, and [Zapier](https://zapier.com/security), [Make](https://www.make.com/en/security), and [Workato](https://www.workato.com/platform/security) publish information about their managed services. For a deeper platform-selection framework, see [the best AI agent platforms comparison](https://www.sim.ai/library/best-ai-agent-platforms-2026).
|
||||
|
||||
Sim's [governed Enterprise self-hosting](https://docs.sim.ai/platform/enterprise) is distinct from its [community self-hosting documentation](https://docs.sim.ai/platform/self-hosting). Enterprise adds the documented SSO, RBAC, and audit-log capabilities to a self-hosted deployment. Evaluate these controls alongside your own identity, monitoring, retention, and incident-response requirements.
|
||||
|
||||
## Comparison table: governance features by platform
|
||||
|
||||
| Platform | Published security material | What to confirm for your workflow |
|
||||
| --- | --- | --- |
|
||||
| [Sim](https://docs.sim.ai/platform/enterprise) | Enterprise deployment and governance information | SSO, RBAC, audit logs, self-hosting, and applicable attestation |
|
||||
| [n8n](https://trust.n8n.io) | Trust center | Identity controls, audit capabilities, self-hosting edition, and assurance reports |
|
||||
| [Zapier](https://trust.zapier.com) | Trust center | Plan-level identity, access, logging, and managed-service controls |
|
||||
| [Make](https://www.make.com/en/security) | Security page | Enterprise identity, access, logging, and hosting controls |
|
||||
| [Gumloop](https://www.gumloop.com/solutions/security) | Security page | Enterprise access, logging, deployment, and assurance details |
|
||||
| [Workato](https://www.workato.com/platform/security) | Security page | Identity, audit, integration, and managed-deployment controls |
|
||||
|
||||
## Which platform fits your vertical and use case?
|
||||
|
||||
Match the platform to the governance constraint first, then check whether it can build the agent you need.
|
||||
|
||||
If self-hosting PHI or sensitive patient data is a hard requirement, evaluate [Sim's governed Enterprise self-hosting](https://docs.sim.ai/platform/enterprise) against your infrastructure and compliance controls. n8n's [self-hosting documentation](https://docs.n8n.io/hosting/) is a useful comparison point for teams prepared to operate their own instance. In either case, self-hosting changes the deployment boundary; it does not by itself satisfy a regulatory program.
|
||||
|
||||
For legal contract review and discovery, weigh Sim and Workato based on access scoping, document lineage, and your existing integration stack. For procurement approvals, use the stakes to set the bar: low-value requests may need only routing, while multi-tier approval chains need roles and records that can be reviewed later.
|
||||
|
||||
One rule holds across all three verticals: evaluate governance architecture, not badges alone. The material questions are where the data runs, how granular the access boundaries are, how activity is recorded, and whether your team can operate the resulting system. If open-source deployment is part of that decision, [open-source AI agent platforms](https://www.sim.ai/library/open-source-ai-agent-platforms) compares the architectural trade-offs.
|
||||
|
||||
See how Sim approaches compliance workflows, read the [self-hosting documentation](https://docs.sim.ai/platform/self-hosting), or talk to our team about governed Enterprise deployment.
|
||||
@@ -517,6 +517,14 @@ export async function copyForkResourceContainers(
|
||||
...definition,
|
||||
id: childTableId,
|
||||
workspaceId: childWorkspaceId,
|
||||
/**
|
||||
* Folders never transit a fork edge. `folder_id` is a global id with no workspace in
|
||||
* it, so the spread above would leave the child's table pointing at a folder owned by
|
||||
* the SOURCE workspace — invisible in the fork, and mutated from under it if the
|
||||
* source later deletes that folder (`ON DELETE SET NULL`). Forked tables land at the
|
||||
* root, like forked files already do.
|
||||
*/
|
||||
folderId: null,
|
||||
schema: remappedSchema,
|
||||
createdBy: userId,
|
||||
rowsVersion: 0,
|
||||
@@ -561,6 +569,8 @@ export async function copyForkResourceContainers(
|
||||
...base,
|
||||
id: childKbId,
|
||||
workspaceId: childWorkspaceId,
|
||||
/** Same reasoning as the table copy above: folders do not transit a fork edge. */
|
||||
folderId: null,
|
||||
userId,
|
||||
deletedAt: null,
|
||||
createdAt: now,
|
||||
|
||||
@@ -45,8 +45,9 @@ interface ResolveForkFolderMappingParams {
|
||||
* Source folder ids that will directly hold copied content (workflows); null entries
|
||||
* (root-placed content) are ignored. A source folder is copied into the target only when
|
||||
* its subtree contains at least one of these, so a fork/sync never creates folders that
|
||||
* would end up empty. Copied workspace FILES never influence this set: they live in the
|
||||
* separate `workspace_file_folders` entity and are flattened to root by the copy.
|
||||
* would end up empty. Copied workspace FILES never influence this set: their folders are a
|
||||
* separate tree (`folder` rows with `resourceType = 'file'`, which this copy only ever reads
|
||||
* as `'workflow'`) and are flattened to root by the copy.
|
||||
*/
|
||||
contentFolderIds: ReadonlyArray<string | null>
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
credential,
|
||||
customTools,
|
||||
document,
|
||||
folder as folderTable,
|
||||
knowledgeBase,
|
||||
mcpServers,
|
||||
skill,
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
workflowDeploymentVersion,
|
||||
workflowMcpServer,
|
||||
workspaceEnvironment,
|
||||
workspaceFileFolder,
|
||||
workspaceFiles,
|
||||
} from '@sim/db/schema'
|
||||
import { and, count, eq, exists, inArray, isNull, sql } from 'drizzle-orm'
|
||||
@@ -152,14 +152,15 @@ const fileCandidatesWithFolderQuery = (
|
||||
key: workspaceFiles.key,
|
||||
label: sql<string>`coalesce(${workspaceFiles.displayName}, ${workspaceFiles.originalName})`,
|
||||
folderId: workspaceFiles.folderId,
|
||||
folderName: workspaceFileFolder.name,
|
||||
folderName: folderTable.name,
|
||||
})
|
||||
.from(workspaceFiles)
|
||||
.leftJoin(
|
||||
workspaceFileFolder,
|
||||
folderTable,
|
||||
and(
|
||||
eq(workspaceFiles.folderId, workspaceFileFolder.id),
|
||||
isNull(workspaceFileFolder.deletedAt)
|
||||
eq(workspaceFiles.folderId, folderTable.id),
|
||||
eq(folderTable.resourceType, 'file'),
|
||||
isNull(folderTable.deletedAt)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import type { AllTagSlot } from '@/lib/knowledge/constants'
|
||||
import { knowledgeKeys, useTagDefinitionsQuery } from '@/hooks/queries/kb/knowledge'
|
||||
import { useTagDefinitionsQuery } from '@/hooks/queries/kb/knowledge'
|
||||
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
|
||||
|
||||
export interface TagDefinition {
|
||||
id: string
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type DocumentTagFilter,
|
||||
type KnowledgeChunksResponse,
|
||||
type KnowledgeDocumentsResponse,
|
||||
knowledgeKeys,
|
||||
serializeChunkParams,
|
||||
serializeDocumentParams,
|
||||
useDocumentQuery,
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
useKnowledgeChunksQuery,
|
||||
useKnowledgeDocumentsQuery,
|
||||
} from '@/hooks/queries/kb/knowledge'
|
||||
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 50
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ import { useQueryClient } from '@tanstack/react-query'
|
||||
import type { AllTagSlot } from '@/lib/knowledge/constants'
|
||||
import {
|
||||
type DocumentTagDefinitionInput,
|
||||
knowledgeKeys,
|
||||
useDeleteDocumentTagDefinitions,
|
||||
useDocumentTagDefinitionsQuery,
|
||||
useSaveDocumentTagDefinitions,
|
||||
} from '@/hooks/queries/kb/knowledge'
|
||||
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
|
||||
|
||||
export interface TagDefinition {
|
||||
id: string
|
||||
|
||||
@@ -71,8 +71,6 @@ describe('folder optimistic top insertion ordering', () => {
|
||||
userId: 'user-1',
|
||||
workspaceId: 'ws-1',
|
||||
parentId: 'parent-1',
|
||||
color: '#808080',
|
||||
isExpanded: false,
|
||||
sortOrder: 5,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -83,8 +81,6 @@ describe('folder optimistic top insertion ordering', () => {
|
||||
userId: 'user-1',
|
||||
workspaceId: 'ws-1',
|
||||
parentId: 'parent-2',
|
||||
color: '#808080',
|
||||
isExpanded: false,
|
||||
sortOrder: -100,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
createFolderContract,
|
||||
deleteFolderContract,
|
||||
duplicateFolderContract,
|
||||
type FolderApi,
|
||||
listFoldersContract,
|
||||
reorderFoldersContract,
|
||||
restoreFolderContract,
|
||||
@@ -14,42 +13,25 @@ import {
|
||||
updateFolderContract,
|
||||
} from '@/lib/api/contracts'
|
||||
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
|
||||
import { type FolderQueryScope, folderKeys } from '@/hooks/queries/utils/folder-keys'
|
||||
import {
|
||||
FOLDER_LIST_STALE_TIME,
|
||||
type FolderQueryScope,
|
||||
folderKeys,
|
||||
mapFolder,
|
||||
} from '@/hooks/queries/utils/folder-keys'
|
||||
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
|
||||
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
|
||||
import {
|
||||
createOptimisticMutationHandlers,
|
||||
generateTempId,
|
||||
} from '@/hooks/queries/utils/optimistic-mutation'
|
||||
import { tableKeys } from '@/hooks/queries/utils/table-keys'
|
||||
import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order'
|
||||
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
|
||||
const logger = createLogger('FolderQueries')
|
||||
|
||||
export const FOLDER_LIST_STALE_TIME = 60 * 1000
|
||||
|
||||
/**
|
||||
* Maps a wire folder row to the client `WorkflowFolder` shape (string dates →
|
||||
* `Date`). Exported so the server-side home prefetch produces
|
||||
* the exact cached value `useFolders` stores, keeping the hydrated entry in
|
||||
* sync with a client fetch.
|
||||
*/
|
||||
export function mapFolder(folder: FolderApi): WorkflowFolder {
|
||||
return {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
userId: folder.userId,
|
||||
workspaceId: folder.workspaceId,
|
||||
parentId: folder.parentId,
|
||||
resourceType: folder.resourceType,
|
||||
locked: folder.locked,
|
||||
sortOrder: folder.sortOrder,
|
||||
createdAt: new Date(folder.createdAt),
|
||||
updatedAt: new Date(folder.updatedAt),
|
||||
deletedAt: folder.deletedAt ? new Date(folder.deletedAt) : null,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFolders(
|
||||
workspaceId: string,
|
||||
scope: FolderQueryScope = 'active',
|
||||
@@ -129,6 +111,36 @@ interface DuplicateFolderVariables {
|
||||
newId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the lists that a folder delete/restore cascade rewrote.
|
||||
*
|
||||
* The cascade archives or restores the resources inside the folder subtree, so
|
||||
* the folder tree alone going stale is not enough — the resource list that
|
||||
* renders those rows has to refetch too. Each resource type owns a different
|
||||
* cache, hence the switch; a type with no list surface yet is a no-op.
|
||||
*/
|
||||
function invalidateCascadedResourceLists(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
resourceType: ServedFolderResourceType,
|
||||
workspaceId: string
|
||||
): Promise<void> | void {
|
||||
switch (resourceType) {
|
||||
case 'workflow':
|
||||
return invalidateWorkflowLists(queryClient, workspaceId, ['active', 'archived'])
|
||||
case 'table':
|
||||
return queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
|
||||
case 'knowledge_base':
|
||||
return queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() })
|
||||
/**
|
||||
* `file` has no case, and cannot reach here: `servedFolderResourceTypeSchema` does not
|
||||
* serve it. Files reads and writes its folders through
|
||||
* `/api/workspaces/[id]/files/folders/**`, which owns its own invalidation.
|
||||
*/
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates optimistic mutation handlers for folder operations
|
||||
*/
|
||||
@@ -187,9 +199,19 @@ export function useCreateFolder() {
|
||||
queryClient,
|
||||
'CreateFolder',
|
||||
(variables, tempId, previousFolders) => {
|
||||
const currentWorkflows = Object.fromEntries(
|
||||
getWorkflows(variables.workspaceId).map((w) => [w.id, w])
|
||||
)
|
||||
const resourceType = variables.resourceType ?? 'workflow'
|
||||
/**
|
||||
* Only the workflow tree interleaves folders and resources in one user-ordered list, so
|
||||
* only it derives the optimistic placement from the workflows too. The other trees are
|
||||
* ordered by the folder rows alone — mirroring `nextFolderSortOrder`, which consults a
|
||||
* resource's sort column only when the config declares one. Feeding workflow sort orders
|
||||
* into a knowledge-base or table folder would place it against an unrelated ordering
|
||||
* space and flicker until the server response replaced it.
|
||||
*/
|
||||
const currentWorkflows =
|
||||
resourceType === 'workflow'
|
||||
? Object.fromEntries(getWorkflows(variables.workspaceId).map((w) => [w.id, w]))
|
||||
: {}
|
||||
|
||||
return {
|
||||
id: tempId,
|
||||
@@ -197,7 +219,7 @@ export function useCreateFolder() {
|
||||
userId: '',
|
||||
workspaceId: variables.workspaceId,
|
||||
parentId: variables.parentId || null,
|
||||
resourceType: variables.resourceType ?? 'workflow',
|
||||
resourceType,
|
||||
locked: false,
|
||||
sortOrder:
|
||||
variables.sortOrder ??
|
||||
@@ -270,8 +292,7 @@ export function useDeleteFolderMutation() {
|
||||
onSettled: (_data, _error, variables) => {
|
||||
const resourceType = variables.resourceType ?? 'workflow'
|
||||
queryClient.invalidateQueries({ queryKey: folderKeys.resource(resourceType) })
|
||||
if (resourceType !== 'workflow') return
|
||||
return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived'])
|
||||
return invalidateCascadedResourceLists(queryClient, resourceType, variables.workspaceId)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -299,12 +320,17 @@ export function useRestoreFolder() {
|
||||
onSettled: (_data, _error, variables) => {
|
||||
const resourceType = variables.resourceType ?? 'workflow'
|
||||
queryClient.invalidateQueries({ queryKey: folderKeys.resource(resourceType) })
|
||||
if (resourceType !== 'workflow') return
|
||||
return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived'])
|
||||
return invalidateCascadedResourceLists(queryClient, resourceType, variables.workspaceId)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow-only by design, unlike the other folder mutations in this file: duplication copies
|
||||
* the workflows inside the folder, and `POST /api/folders/[id]/duplicate` has no equivalent
|
||||
* for knowledge bases or tables. The `resourceType: 'workflow'` below is that constraint, not
|
||||
* an oversight — generalizing it would optimistically insert a folder the route then refuses.
|
||||
*/
|
||||
export function useDuplicateFolderMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
triggerKnowledgeConnectorSyncContract,
|
||||
updateKnowledgeConnectorContract,
|
||||
} from '@/lib/api/contracts/knowledge'
|
||||
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
|
||||
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
|
||||
|
||||
const logger = createLogger('KnowledgeConnectorQueries')
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
type KnowledgeBaseData,
|
||||
type KnowledgeChunksResponse,
|
||||
type KnowledgeDocumentsResponse,
|
||||
type KnowledgeScope,
|
||||
listDocumentTagDefinitionsContract,
|
||||
listKnowledgeBasesContract,
|
||||
listKnowledgeChunksContract,
|
||||
@@ -47,11 +46,14 @@ import {
|
||||
} from '@/lib/api/contracts/knowledge'
|
||||
import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types'
|
||||
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
|
||||
import {
|
||||
KNOWLEDGE_BASE_LIST_STALE_TIME,
|
||||
type KnowledgeQueryScope,
|
||||
knowledgeKeys,
|
||||
} from '@/hooks/queries/utils/knowledge-keys'
|
||||
|
||||
const logger = createLogger('KnowledgeQueries')
|
||||
|
||||
type KnowledgeQueryScope = KnowledgeScope
|
||||
|
||||
export type {
|
||||
DocumentTagDefinitionData,
|
||||
DocumentTagFilter,
|
||||
@@ -61,7 +63,6 @@ export type {
|
||||
TagUsageData,
|
||||
}
|
||||
|
||||
export const KNOWLEDGE_BASE_LIST_STALE_TIME = 60 * 1000
|
||||
export const KNOWLEDGE_BASE_DETAIL_STALE_TIME = 60 * 1000
|
||||
export const KNOWLEDGE_DOCUMENT_DETAIL_STALE_TIME = 60 * 1000
|
||||
export const KNOWLEDGE_DOCUMENT_LIST_STALE_TIME = 60 * 1000
|
||||
@@ -71,30 +72,6 @@ export const KNOWLEDGE_TAG_DEFINITION_LIST_STALE_TIME = 60 * 1000
|
||||
export const KNOWLEDGE_TAG_USAGE_STALE_TIME = 60 * 1000
|
||||
export const KNOWLEDGE_DOCUMENT_TAG_DEFINITION_LIST_STALE_TIME = 60 * 1000
|
||||
|
||||
export const knowledgeKeys = {
|
||||
all: ['knowledge'] as const,
|
||||
lists: () => [...knowledgeKeys.all, 'list'] as const,
|
||||
list: (workspaceId?: string, scope: KnowledgeQueryScope = 'active') =>
|
||||
[...knowledgeKeys.lists(), workspaceId ?? 'all', scope] as const,
|
||||
details: () => [...knowledgeKeys.all, 'detail'] as const,
|
||||
detail: (knowledgeBaseId?: string) =>
|
||||
[...knowledgeKeys.details(), knowledgeBaseId ?? ''] as const,
|
||||
tagDefinitions: (knowledgeBaseId: string) =>
|
||||
[...knowledgeKeys.detail(knowledgeBaseId), 'tagDefinitions'] as const,
|
||||
tagUsage: (knowledgeBaseId: string) =>
|
||||
[...knowledgeKeys.detail(knowledgeBaseId), 'tagUsage'] as const,
|
||||
documents: (knowledgeBaseId: string, paramsKey: string) =>
|
||||
[...knowledgeKeys.detail(knowledgeBaseId), 'documents', paramsKey] as const,
|
||||
document: (knowledgeBaseId: string, documentId: string) =>
|
||||
[...knowledgeKeys.detail(knowledgeBaseId), 'document', documentId] as const,
|
||||
documentTagDefinitions: (knowledgeBaseId: string, documentId: string) =>
|
||||
[...knowledgeKeys.document(knowledgeBaseId, documentId), 'tagDefinitions'] as const,
|
||||
chunks: (knowledgeBaseId: string, documentId: string, paramsKey: string) =>
|
||||
[...knowledgeKeys.document(knowledgeBaseId, documentId), 'chunks', paramsKey] as const,
|
||||
chunkSearch: (knowledgeBaseId: string, documentId: string, searchKey: string) =>
|
||||
[...knowledgeKeys.document(knowledgeBaseId, documentId), 'search', searchKey] as const,
|
||||
}
|
||||
|
||||
export async function fetchKnowledgeBases(
|
||||
workspaceId?: string,
|
||||
scope: KnowledgeQueryScope = 'active',
|
||||
@@ -607,6 +584,8 @@ interface CreateKnowledgeBaseParams {
|
||||
name: string
|
||||
description?: string
|
||||
workspaceId: string
|
||||
/** Folder to create the knowledge base in; `null`/omitted creates it at the workspace root. */
|
||||
folderId?: string | null
|
||||
chunkingConfig: {
|
||||
maxSize: number
|
||||
minSize: number
|
||||
@@ -643,6 +622,8 @@ interface UpdateKnowledgeBaseParams {
|
||||
name?: string
|
||||
description?: string
|
||||
workspaceId?: string | null
|
||||
/** Moves the knowledge base between folders; `null` moves it to the workspace root. */
|
||||
folderId?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,7 +644,29 @@ export function useUpdateKnowledgeBase(workspaceId?: string) {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateKnowledgeBase,
|
||||
onError: (error) => {
|
||||
/**
|
||||
* A folder move re-parents a row the user is looking at, so the list is patched up
|
||||
* front — otherwise the base lingers in the folder it just left until the refetch
|
||||
* lands. Only the folder is applied optimistically; name/description edits already
|
||||
* happen behind a modal that closes on success.
|
||||
*/
|
||||
onMutate: async ({ knowledgeBaseId, updates }) => {
|
||||
if (updates.folderId === undefined) return
|
||||
await queryClient.cancelQueries({ queryKey: knowledgeKeys.lists() })
|
||||
const previous = queryClient.getQueriesData<KnowledgeBaseData[]>({
|
||||
queryKey: knowledgeKeys.lists(),
|
||||
})
|
||||
queryClient.setQueriesData<KnowledgeBaseData[]>({ queryKey: knowledgeKeys.lists() }, (old) =>
|
||||
old?.map((kb) =>
|
||||
kb.id === knowledgeBaseId ? { ...kb, folderId: updates.folderId ?? null } : kb
|
||||
)
|
||||
)
|
||||
return { previous }
|
||||
},
|
||||
onError: (error, _variables, context) => {
|
||||
for (const [key, data] of context?.previous ?? []) {
|
||||
queryClient.setQueryData(key, data)
|
||||
}
|
||||
toast.error(error.message, { duration: 5000 })
|
||||
},
|
||||
onSettled: (_data, _error, { knowledgeBaseId }) => {
|
||||
|
||||
@@ -643,6 +643,46 @@ export function useUpdateTableLocks(workspaceId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a table into a folder, or to the workspace root with `folderId: null`.
|
||||
*
|
||||
* Optimistically repoints `folderId` in the cached active list so the row leaves
|
||||
* the current folder the instant the move is issued; the list is the only surface
|
||||
* that renders folder placement, so no other cache entry needs patching.
|
||||
*/
|
||||
export function useMoveTable(workspaceId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ tableId, folderId }: { tableId: string; folderId: string | null }) => {
|
||||
return requestJson(updateTableContract, {
|
||||
params: { tableId },
|
||||
body: { workspaceId, folderId },
|
||||
})
|
||||
},
|
||||
onMutate: async ({ tableId, folderId }) => {
|
||||
const listKey = tableKeys.list(workspaceId, 'active')
|
||||
await queryClient.cancelQueries({ queryKey: listKey })
|
||||
const snapshot = queryClient.getQueryData<TableDefinition[]>(listKey)
|
||||
queryClient.setQueryData<TableDefinition[]>(listKey, (old) =>
|
||||
old?.map((table) => (table.id === tableId ? { ...table, folderId } : table))
|
||||
)
|
||||
return { snapshot }
|
||||
},
|
||||
onError: (error, _variables, context) => {
|
||||
if (context?.snapshot) {
|
||||
queryClient.setQueryData(tableKeys.list(workspaceId, 'active'), context.snapshot)
|
||||
}
|
||||
if (isValidationError(error)) return
|
||||
toast.error(error.message, { duration: 5000 })
|
||||
},
|
||||
onSettled: (_data, _error, { tableId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId), exact: true })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a table from a workspace.
|
||||
*/
|
||||
@@ -1678,6 +1718,8 @@ export function useRestoreTable() {
|
||||
|
||||
interface UploadCsvParams {
|
||||
workspaceId: string
|
||||
/** Folder to create the imported table in; omitted imports to the workspace root. */
|
||||
folderId?: string | null
|
||||
file: File
|
||||
}
|
||||
|
||||
@@ -1689,11 +1731,13 @@ export function useUploadCsvToTable() {
|
||||
const timezone = useTimezone()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, file }: UploadCsvParams) => {
|
||||
mutationFn: async ({ workspaceId, folderId, file }: UploadCsvParams) => {
|
||||
// Text fields must precede the file part: the server parses the body as a
|
||||
// stream and needs workspaceId before it reaches the (large) file.
|
||||
// stream and resolves as soon as it reaches the file, so any field appended
|
||||
// after it is never seen.
|
||||
const formData = new FormData()
|
||||
formData.append('workspaceId', workspaceId)
|
||||
if (folderId) formData.append('folderId', folderId)
|
||||
formData.append('timezone', timezone)
|
||||
formData.append('file', file)
|
||||
|
||||
@@ -1728,6 +1772,8 @@ export function useUploadCsvToTable() {
|
||||
|
||||
interface ImportCsvAsyncParams {
|
||||
workspaceId: string
|
||||
/** Folder to create the imported table in; omitted imports to the workspace root. */
|
||||
folderId?: string | null
|
||||
file: File
|
||||
onProgress?: (percent: number) => void
|
||||
}
|
||||
@@ -1760,10 +1806,10 @@ export function useImportCsvAsync() {
|
||||
const queryClient = useQueryClient()
|
||||
const timezone = useTimezone()
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, file, onProgress }: ImportCsvAsyncParams) => {
|
||||
mutationFn: async ({ workspaceId, folderId, file, onProgress }: ImportCsvAsyncParams) => {
|
||||
const fileKey = await uploadCsvToWorkspaceStorage(file, workspaceId, onProgress)
|
||||
const response = await requestJson(importTableAsyncContract, {
|
||||
body: { workspaceId, fileKey, fileName: file.name, timezone },
|
||||
body: { workspaceId, folderId, fileKey, fileName: file.name, timezone },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
|
||||
const EMPTY_FOLDERS: WorkflowFolder[] = []
|
||||
|
||||
export function getFolders(
|
||||
function getFolders(
|
||||
workspaceId: string,
|
||||
resourceType: FolderResourceType = 'workflow'
|
||||
): WorkflowFolder[] {
|
||||
|
||||
@@ -1,7 +1,35 @@
|
||||
import type { FolderResourceType } from '@/lib/api/contracts/folders'
|
||||
import type { FolderApi, FolderResourceType } from '@/lib/api/contracts/folders'
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
|
||||
export type FolderQueryScope = 'active' | 'archived'
|
||||
|
||||
export const FOLDER_LIST_STALE_TIME = 60 * 1000
|
||||
|
||||
/**
|
||||
* Maps a wire folder row to the client `WorkflowFolder` shape (string dates → `Date`).
|
||||
*
|
||||
* Lives beside the keys rather than in the hooks module so a server prefetch can hydrate a
|
||||
* folder list without importing `@/hooks/queries/folders`, which drags the contracts barrel and
|
||||
* the optimistic-mutation machinery in with it. Fields are listed explicitly, not spread, so a
|
||||
* new wire field cannot silently enter the cached shape and diverge a hydrated entry from a
|
||||
* client fetch.
|
||||
*/
|
||||
export function mapFolder(folder: FolderApi): WorkflowFolder {
|
||||
return {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
userId: folder.userId,
|
||||
workspaceId: folder.workspaceId,
|
||||
parentId: folder.parentId,
|
||||
resourceType: folder.resourceType,
|
||||
locked: folder.locked,
|
||||
sortOrder: folder.sortOrder,
|
||||
createdAt: new Date(folder.createdAt),
|
||||
updatedAt: new Date(folder.updatedAt),
|
||||
deletedAt: folder.deletedAt ? new Date(folder.deletedAt) : null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `resourceType` is part of the key, not an implicit default, because one workspace holds
|
||||
* an independent folder tree per resource. Without it the Knowledge, Tables, and Workflows
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { KnowledgeScope } from '@/lib/api/contracts/knowledge/base'
|
||||
|
||||
/**
|
||||
* React Query key factory for knowledge bases.
|
||||
*
|
||||
* Lives in this standalone (non-`'use client'`) module — like
|
||||
* {@link file://./folder-keys.ts} and {@link file://./table-keys.ts} — so a server component
|
||||
* or another query module can invalidate knowledge caches without importing
|
||||
* `@/hooks/queries/kb/knowledge`, a ~1000-line hook module that pulls the `@sim/emcn` barrel
|
||||
* in with it. That import edge is exactly the kind that lands a UI bundle in every workspace
|
||||
* route's server prefetch.
|
||||
*/
|
||||
export type KnowledgeQueryScope = KnowledgeScope
|
||||
|
||||
/** Shared with the server prefetch so a hydrated list and a client fetch never disagree. */
|
||||
export const KNOWLEDGE_BASE_LIST_STALE_TIME = 60 * 1000
|
||||
|
||||
export const knowledgeKeys = {
|
||||
all: ['knowledge'] as const,
|
||||
lists: () => [...knowledgeKeys.all, 'list'] as const,
|
||||
list: (workspaceId?: string, scope: KnowledgeQueryScope = 'active') =>
|
||||
[...knowledgeKeys.lists(), workspaceId ?? 'all', scope] as const,
|
||||
details: () => [...knowledgeKeys.all, 'detail'] as const,
|
||||
detail: (knowledgeBaseId?: string) =>
|
||||
[...knowledgeKeys.details(), knowledgeBaseId ?? ''] as const,
|
||||
tagDefinitions: (knowledgeBaseId: string) =>
|
||||
[...knowledgeKeys.detail(knowledgeBaseId), 'tagDefinitions'] as const,
|
||||
tagUsage: (knowledgeBaseId: string) =>
|
||||
[...knowledgeKeys.detail(knowledgeBaseId), 'tagUsage'] as const,
|
||||
documents: (knowledgeBaseId: string, paramsKey: string) =>
|
||||
[...knowledgeKeys.detail(knowledgeBaseId), 'documents', paramsKey] as const,
|
||||
document: (knowledgeBaseId: string, documentId: string) =>
|
||||
[...knowledgeKeys.detail(knowledgeBaseId), 'document', documentId] as const,
|
||||
documentTagDefinitions: (knowledgeBaseId: string, documentId: string) =>
|
||||
[...knowledgeKeys.document(knowledgeBaseId, documentId), 'tagDefinitions'] as const,
|
||||
chunks: (knowledgeBaseId: string, documentId: string, paramsKey: string) =>
|
||||
[...knowledgeKeys.document(knowledgeBaseId, documentId), 'chunks', paramsKey] as const,
|
||||
chunkSearch: (knowledgeBaseId: string, documentId: string, searchKey: string) =>
|
||||
[...knowledgeKeys.document(knowledgeBaseId, documentId), 'search', searchKey] as const,
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { servedFolderResourceTypeSchema } from '@/lib/api/contracts/folders'
|
||||
|
||||
/**
|
||||
* These three cases together ARE the rolling-deploy contract for the folder engine, so they
|
||||
* are pinned rather than left implicit in `.default()`.
|
||||
*/
|
||||
describe('servedFolderResourceTypeSchema', () => {
|
||||
it('defaults an omitted value to workflow, so a client that predates the field still works', () => {
|
||||
expect(servedFolderResourceTypeSchema.parse(undefined)).toBe('workflow')
|
||||
})
|
||||
|
||||
it('rejects a present-but-unrecognized value rather than coercing it to workflow', () => {
|
||||
// Coercing would file a knowledge-base folder into the workflow tree, where the
|
||||
// Knowledge page can never see it again. A 400 is the only safe answer.
|
||||
expect(servedFolderResourceTypeSchema.safeParse('bogus').success).toBe(false)
|
||||
expect(servedFolderResourceTypeSchema.safeParse('').success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts every tree the engine serves', () => {
|
||||
for (const value of ['workflow', 'knowledge_base', 'table'] as const) {
|
||||
expect(servedFolderResourceTypeSchema.parse(value)).toBe(value)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not serve file folders, which keep their own locked routes', () => {
|
||||
// File folders live in `folder` after the cutover, but Files serializes every mutation
|
||||
// behind an advisory lock and forbids `/` in names because a name is a path segment.
|
||||
// Serving them here would be a second writer with neither property.
|
||||
expect(servedFolderResourceTypeSchema.safeParse('file').success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -6,13 +6,27 @@ export const folderResourceTypeSchema = z.enum(['workflow', 'file', 'knowledge_b
|
||||
export type FolderResourceType = z.output<typeof folderResourceTypeSchema>
|
||||
|
||||
/**
|
||||
* The resource types the generic folder engine actually serves. `file` is excluded on
|
||||
* purpose: file folders still write the legacy `workspace_file_folders` table, so accepting
|
||||
* the value here would answer "you have no folders" against a surface that plainly does.
|
||||
* It gains an entry when the file cutover lands.
|
||||
* The resource types the generic folder engine SERVES over `/api/folders`. Deliberately a
|
||||
* subset of {@link folderResourceTypeSchema}, which mirrors the DB enum.
|
||||
*
|
||||
* `file` is excluded on purpose even though file folders now live in the `folder` table.
|
||||
* Files keeps its own routes (`/api/workspaces/[id]/files/folders/**`), and those serialize
|
||||
* every mutation behind the `workspace_file_folders:${workspaceId}` advisory lock that makes
|
||||
* their cycle and name checks atomic. Serving the same rows here would open a second writer
|
||||
* that bypasses that lock — and would accept names containing `/`, `\`, `.` and `..`, which
|
||||
* the file surface forbids because a file-folder name becomes a path segment. The storage
|
||||
* cutover does not require a second API, so there isn't one.
|
||||
*
|
||||
* The `.default` applies ONLY to an omitted value — that is what keeps an old client, which
|
||||
* never sends the field, working against a new pod. A value that is present but not in the
|
||||
* enum is REJECTED with a 400, never silently coerced to `'workflow'`: coercing would file a
|
||||
* knowledge-base folder into the workflow tree, where the Knowledge page can never see it
|
||||
* again. This is the deploy-ordering contract, pinned by `folders.test.ts`.
|
||||
*/
|
||||
export const servedFolderResourceTypeSchema = z
|
||||
.enum(['workflow', 'knowledge_base', 'table'])
|
||||
.enum(['workflow', 'knowledge_base', 'table'], {
|
||||
error: 'resourceType must be one of workflow, knowledge_base, table',
|
||||
})
|
||||
.default('workflow')
|
||||
|
||||
export type ServedFolderResourceType = z.output<typeof servedFolderResourceTypeSchema>
|
||||
|
||||
@@ -60,6 +60,11 @@ export const createKnowledgeBaseBodySchema = z.object({
|
||||
)
|
||||
.optional(),
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
/**
|
||||
* Folder the knowledge base is created in, from the `knowledge_base` folder tree.
|
||||
* `null` (or omitted) creates it at the workspace root.
|
||||
*/
|
||||
folderId: z.string().min(1, 'Folder ID cannot be empty').nullable().optional(),
|
||||
embeddingModel: z.literal('text-embedding-3-small').default('text-embedding-3-small'),
|
||||
embeddingDimension: z.literal(1536).default(1536),
|
||||
chunkingConfig: chunkingConfigSchema.default({
|
||||
@@ -77,6 +82,11 @@ export const updateKnowledgeBaseBodySchema = createKnowledgeBaseBodySchema
|
||||
.partial()
|
||||
.extend({
|
||||
chunkingConfig: chunkingConfigSchema.optional(),
|
||||
/**
|
||||
* Moves the knowledge base between folders. Omitted leaves the folder untouched;
|
||||
* explicit `null` moves it back to the workspace root.
|
||||
*/
|
||||
folderId: z.string().min(1, 'Folder ID cannot be empty').nullable().optional(),
|
||||
workspaceId: z.string().nullable().optional(),
|
||||
embeddingModel: z.literal('text-embedding-3-small').optional(),
|
||||
embeddingDimension: z.literal(1536).optional(),
|
||||
@@ -106,6 +116,7 @@ export const knowledgeBaseDataSchema = z
|
||||
updatedAt: wireDateSchema,
|
||||
deletedAt: nullableWireDateSchema,
|
||||
workspaceId: z.string().nullable(),
|
||||
folderId: z.string().nullable(),
|
||||
docCount: z.number().optional(),
|
||||
connectorTypes: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
@@ -6,6 +6,11 @@ import { defineRouteContract } from '@/lib/api/contracts/types'
|
||||
* Mirrors `pinnedItem.resourceType` in `packages/db/schema.ts`, which is deliberately
|
||||
* plain `text` on that table rather than a `pgEnum` — the set of pinnable kinds is
|
||||
* expected to grow, and this schema is the enforcement point.
|
||||
*
|
||||
* `folder` covers every folder tree at once: folder ids are globally unique, so one pin
|
||||
* namespace serves the file, knowledge-base, and table trees. It is separate from the
|
||||
* resource's own type, which is why a page listing folders alongside its resources resolves
|
||||
* two `usePinnedIds` sets.
|
||||
*/
|
||||
export const pinnedResourceTypeSchema = z.enum([
|
||||
'workflow',
|
||||
|
||||
@@ -59,6 +59,17 @@ export const organizationIdSchema = requiredFieldSchema('Organization ID is requ
|
||||
/** Non-empty `workflowId` field with a stable, human-readable message. */
|
||||
export const workflowIdSchema = requiredFieldSchema('Workflow ID is required')
|
||||
|
||||
/**
|
||||
* A `folder.id` value. Not `.uuid()`: the column is free-form `text` and the
|
||||
* legacy `workflow_folder` rows migrated onto it keep their original id shape.
|
||||
* Callers that also allow "no folder" chain `.nullable()` themselves so the
|
||||
* two-state and three-state spellings stay explicit at each call site.
|
||||
*/
|
||||
export const folderIdSchema = requiredFieldSchema('Folder ID is required').max(
|
||||
128,
|
||||
'Folder ID is too long'
|
||||
)
|
||||
|
||||
/**
|
||||
* A `workspace_files.id` value. The column is a free-form `text` primary key, so
|
||||
* ids come in two shapes: UUID v4 (legacy rows and the `insertFileMetadata`
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { isRecordLike } from '@sim/utils/object'
|
||||
import { z } from 'zod'
|
||||
import { requiredFieldSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives'
|
||||
import {
|
||||
folderIdSchema,
|
||||
requiredFieldSchema,
|
||||
workspaceIdSchema,
|
||||
} from '@/lib/api/contracts/primitives'
|
||||
import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
|
||||
import { ianaTimezoneSchema } from '@/lib/api/contracts/user'
|
||||
import type {
|
||||
@@ -166,6 +170,12 @@ export const createTableBodySchema = z.object({
|
||||
),
|
||||
}),
|
||||
workspaceId: workspaceIdSchema,
|
||||
/**
|
||||
* Folder to create the table in. Omitted or `null` creates it at the workspace
|
||||
* root — both spellings are accepted so a client can pass the current folder
|
||||
* through unconditionally.
|
||||
*/
|
||||
folderId: folderIdSchema.nullable().optional(),
|
||||
initialRowCount: z.number().int().min(0).max(100).optional(),
|
||||
})
|
||||
|
||||
@@ -183,21 +193,25 @@ export const tableLocksSchema = z.object({
|
||||
}) satisfies z.ZodType<TableLocks>
|
||||
|
||||
/**
|
||||
* PATCH /api/table/[tableId] body. Both fields are optional but at least one
|
||||
* must be present. `name` is a `write`-level rename; `locks` is an
|
||||
* admin-only governance change (a partial — only the toggled flags are sent).
|
||||
* PATCH /api/table/[tableId] body. Every field is optional but at least one
|
||||
* must be present. `name` is a `write`-level rename; `folderId` is a
|
||||
* `write`-level move (explicit `null` moves the table to the workspace root, and
|
||||
* omission leaves the placement untouched — the tri-state is deliberate here);
|
||||
* `locks` is an admin-only governance change (a partial — only the toggled flags
|
||||
* are sent).
|
||||
*/
|
||||
export const updateTableBodySchema = z
|
||||
.object({
|
||||
workspaceId: workspaceIdSchema,
|
||||
name: tableNameSchema.optional(),
|
||||
folderId: folderIdSchema.nullable().optional(),
|
||||
locks: tableLocksSchema.partial().optional(),
|
||||
})
|
||||
.superRefine((body, ctx) => {
|
||||
if (body.name === undefined && body.locks === undefined) {
|
||||
if (body.name === undefined && body.locks === undefined && body.folderId === undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Provide a new name or lock changes',
|
||||
message: 'Provide a new name, folder, or lock changes',
|
||||
path: ['name'],
|
||||
})
|
||||
}
|
||||
@@ -494,6 +508,8 @@ export const importTableAsyncBodySchema = z.object({
|
||||
workspaceId: workspaceIdSchema,
|
||||
fileKey: requiredFieldSchema('fileKey is required'),
|
||||
fileName: requiredFieldSchema('fileName is required'),
|
||||
/** Folder to create the imported table in; omitted or `null` imports to the workspace root. */
|
||||
folderId: folderIdSchema.nullable().optional(),
|
||||
/**
|
||||
* Whether the source object is deleted once the import is terminal. Defaults to true (the upload
|
||||
* flow stores a single-use temp object); pass false when importing an existing workspace file
|
||||
@@ -788,6 +804,8 @@ export const csvFileSchema = z
|
||||
export const csvImportFormSchema = z.object({
|
||||
file: csvFileSchema,
|
||||
workspaceId: z.string({ error: 'Workspace ID is required' }).min(1, 'Workspace ID is required'),
|
||||
/** Folder to create the imported table in; omitted imports to the workspace root. */
|
||||
folderId: folderIdSchema.optional(),
|
||||
})
|
||||
|
||||
export const csvImportModeSchema = z.enum(['append', 'replace'])
|
||||
|
||||
@@ -81,6 +81,17 @@ export interface ChunkedBatchDeleteOptions<TRow extends { id: string }> {
|
||||
selectChunk: (chunkIds: string[], limit: number) => Promise<TRow[]>
|
||||
/** Runs between SELECT and DELETE; receives the just-selected rows. */
|
||||
onBatch?: (rows: TRow[]) => Promise<void>
|
||||
/**
|
||||
* Re-asserted on the DELETE alongside the id list. A soft-delete sweep whose `onBatch` does
|
||||
* real work before the DELETE should pass the same predicate it selected on: a row restored
|
||||
* in that window would otherwise be hard-deleted — taking the children a folder restore had
|
||||
* just brought back with it. Rows that no longer match are simply not deleted and are
|
||||
* counted as failed, so the next run re-evaluates them.
|
||||
*
|
||||
* Optional because a sweep with no `onBatch` closes a far smaller window; the hand-rolled
|
||||
* targets in `cleanup-soft-deletes.ts` re-check inside their own DELETE instead.
|
||||
*/
|
||||
deleteFilter?: SQL
|
||||
batchSize?: number
|
||||
/** Max batches per workspace chunk. */
|
||||
maxBatches?: number
|
||||
@@ -112,6 +123,7 @@ export async function chunkedBatchDelete<TRow extends { id: string }>({
|
||||
tableName,
|
||||
selectChunk,
|
||||
onBatch,
|
||||
deleteFilter,
|
||||
batchSize = DEFAULT_BATCH_SIZE,
|
||||
maxBatches = DEFAULT_MAX_BATCHES_PER_TABLE,
|
||||
totalRowLimit = DEFAULT_BATCH_SIZE * DEFAULT_MAX_BATCHES_PER_TABLE,
|
||||
@@ -161,7 +173,7 @@ export async function chunkedBatchDelete<TRow extends { id: string }>({
|
||||
const ids = rows.map((r) => r.id)
|
||||
const deleted = await dbClient
|
||||
.delete(tableDef)
|
||||
.where(inArray(sql`id`, ids))
|
||||
.where(deleteFilter ? and(inArray(sql`id`, ids), deleteFilter) : inArray(sql`id`, ids))
|
||||
.returning({ id: sql`id` })
|
||||
|
||||
result.deleted += deleted.length
|
||||
@@ -226,19 +238,28 @@ export async function batchDeleteByWorkspaceAndTimestamp({
|
||||
dbClient = db,
|
||||
...rest
|
||||
}: BatchDeleteOptions): Promise<TableCleanupResult> {
|
||||
/**
|
||||
* Re-asserted on the DELETE, not just the SELECT. Every row here is soft-deleted and past
|
||||
* retention, so a restore committing between the two statements is exactly the case that must
|
||||
* not be hard-deleted — and for `folder` that would take the placement of the children the
|
||||
* restore had just brought back with it. Rebuilt rather than reused from `selectChunk` because
|
||||
* the id list already scopes the statement; only the eligibility half is re-checked.
|
||||
*/
|
||||
const eligibility = [lt(timestampCol, retentionDate)]
|
||||
if (requireTimestampNotNull) eligibility.push(isNotNull(timestampCol))
|
||||
if (additionalPredicate) eligibility.push(additionalPredicate)
|
||||
|
||||
return chunkedBatchDelete({
|
||||
tableDef,
|
||||
workspaceIds,
|
||||
tableName,
|
||||
dbClient,
|
||||
deleteFilter: and(...eligibility),
|
||||
selectChunk: (chunkIds, limit) => {
|
||||
const predicates = [inArray(workspaceIdCol, chunkIds), lt(timestampCol, retentionDate)]
|
||||
if (requireTimestampNotNull) predicates.push(isNotNull(timestampCol))
|
||||
if (additionalPredicate) predicates.push(additionalPredicate)
|
||||
return dbClient
|
||||
.select({ id: sql<string>`id` })
|
||||
.from(tableDef)
|
||||
.where(and(...predicates))
|
||||
.where(and(inArray(workspaceIdCol, chunkIds), ...eligibility))
|
||||
.limit(limit)
|
||||
},
|
||||
...rest,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workspaceFileFolder, workspaceFiles } from '@sim/db/schema'
|
||||
import { folder as folderTable, workflow, workspaceFiles } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage, toError } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
@@ -389,12 +389,13 @@ async function executeExtract(
|
||||
)
|
||||
.limit(1),
|
||||
db
|
||||
.select({ id: workspaceFileFolder.id })
|
||||
.from(workspaceFileFolder)
|
||||
.select({ id: folderTable.id })
|
||||
.from(folderTable)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceFileFolder.parentId, existingFolderId),
|
||||
isNull(workspaceFileFolder.deletedAt)
|
||||
eq(folderTable.parentId, existingFolderId),
|
||||
eq(folderTable.resourceType, 'file'),
|
||||
isNull(folderTable.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1),
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
|
||||
import { performRestoreResource, type RestorableResourceType } from '@/lib/resources/orchestration'
|
||||
|
||||
const VALID_TYPES = new Set(['workflow', 'table', 'file', 'knowledgebase', 'folder', 'file_folder'])
|
||||
const VALID_TYPES = new Set([
|
||||
'workflow',
|
||||
'table',
|
||||
'file',
|
||||
'knowledgebase',
|
||||
'folder',
|
||||
'file_folder',
|
||||
'knowledge_folder',
|
||||
'table_folder',
|
||||
])
|
||||
|
||||
export async function executeRestoreResource(
|
||||
rawParams: Record<string, unknown>,
|
||||
|
||||
@@ -390,9 +390,9 @@ async function pruneStaleReferences(
|
||||
batchSize: number,
|
||||
dbClient: LargeValueMetadataClient
|
||||
): Promise<number> {
|
||||
// `IN ()` is a syntax error; callers chunk a non-empty list, but never rely on that.
|
||||
// Empty input is a valid no-op, and `IN ()` is a syntax error whose failure the
|
||||
// cleanup job swallows — keep these total rather than relying on the caller.
|
||||
if (workspaceIds.length === 0) return 0
|
||||
|
||||
const rows = await dbClient.execute<{ count: number }>(sql`
|
||||
WITH deleted AS (
|
||||
DELETE FROM ${executionLargeValueReferences} AS ref
|
||||
@@ -434,9 +434,9 @@ async function pruneDeletedParentDependencies(
|
||||
batchSize: number,
|
||||
dbClient: LargeValueMetadataClient
|
||||
): Promise<number> {
|
||||
// `IN ()` is a syntax error; callers chunk a non-empty list, but never rely on that.
|
||||
// Empty input is a valid no-op, and `IN ()` is a syntax error whose failure the
|
||||
// cleanup job swallows — keep these total rather than relying on the caller.
|
||||
if (workspaceIds.length === 0) return 0
|
||||
|
||||
const rows = await dbClient.execute<{ count: number }>(sql`
|
||||
WITH deleted AS (
|
||||
DELETE FROM ${executionLargeValueDependencies} AS dependency
|
||||
@@ -472,9 +472,9 @@ async function pruneDeletedLargeValueTombstones(
|
||||
batchSize: number,
|
||||
dbClient: LargeValueMetadataClient
|
||||
): Promise<number> {
|
||||
// `IN ()` is a syntax error; callers chunk a non-empty list, but never rely on that.
|
||||
// Empty input is a valid no-op, and `IN ()` is a syntax error whose failure the
|
||||
// cleanup job swallows — keep these total rather than relying on the caller.
|
||||
if (workspaceIds.length === 0) return 0
|
||||
|
||||
const rows = await dbClient.execute<{ count: number }>(sql`
|
||||
WITH deleted AS (
|
||||
DELETE FROM ${executionLargeValues} AS value
|
||||
@@ -483,7 +483,7 @@ async function pruneDeletedLargeValueTombstones(
|
||||
FROM ${executionLargeValues} AS value
|
||||
WHERE value.workspace_id IN ${workspaceIds}
|
||||
AND value.deleted_at IS NOT NULL
|
||||
AND value.deleted_at < ${deletedBefore}
|
||||
AND value.deleted_at < ${sql.param(deletedBefore, executionLargeValues.deletedAt)}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM ${executionLargeValueDependencies} AS dependency
|
||||
|
||||
@@ -37,4 +37,13 @@ describe('unreferencedLargeValuePredicate SQL', () => {
|
||||
// `status = IN (...)` is a syntax error Postgres only reports at execution time.
|
||||
expect(text).not.toMatch(/=\s*IN\s*\(/)
|
||||
})
|
||||
|
||||
it('never binds an array as a parameter', () => {
|
||||
// The invariant that matters: the app pools set `fetch_types: false`, so an
|
||||
// array bound as one parameter fails at execution even though the rendered
|
||||
// SQL (`ANY($1::text[])`) is valid. Only the params can reveal it.
|
||||
for (const param of params) {
|
||||
expect(Array.isArray(param)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,12 +67,16 @@ describe('pruneLargeValueMetadata SQL', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('never binds an array as a parameter', async () => {
|
||||
it('binds only scalar parameters', async () => {
|
||||
const statements = await renderPruneStatements([...ids])
|
||||
|
||||
for (const { params } of statements) {
|
||||
for (const param of params) {
|
||||
// Arrays fail under fetch_types: false (no serializer, 22P02); Date
|
||||
// objects fail in postgres-js's unsafe path outright
|
||||
// (ERR_INVALID_ARG_TYPE) — both must be pre-encoded to strings.
|
||||
expect(Array.isArray(param)).toBe(false)
|
||||
expect(param instanceof Date).toBe(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -471,3 +471,49 @@ describe('FOLDER_RESOURCES', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Knowledge bases and tables are the resource trees this cascade newly serves. The generic
|
||||
* describes above already exercise both code paths; these pin the per-resource wiring the
|
||||
* folder engine depends on, which is exactly what silently drifts.
|
||||
*/
|
||||
describe('knowledge_base and table folder resources', () => {
|
||||
const knowledgeConfig = FOLDER_RESOURCES.knowledge_base
|
||||
const tableConfig = FOLDER_RESOURCES.table
|
||||
|
||||
it('routes both trees through their canonical archive and restore, not a bare row update', () => {
|
||||
// A knowledge base owns documents, embeddings, and storage accounting; a table owns its
|
||||
// own data partitions. Neither can be archived by stamping one column, so both must keep
|
||||
// a hook rather than falling back to the generic UPDATE.
|
||||
for (const config of [knowledgeConfig, tableConfig]) {
|
||||
expect(config.archiveChildren).toBeTypeOf('function')
|
||||
expect(config.restoreChildren).toBeTypeOf('function')
|
||||
}
|
||||
})
|
||||
|
||||
it('drives each tree off its own soft-delete column', () => {
|
||||
expect(knowledgeConfig.deletedKey).toBe('deletedAt')
|
||||
expect(knowledgeConfig.buildSoftDeleteSet(TIMESTAMP, NOW)).toEqual({
|
||||
deletedAt: TIMESTAMP,
|
||||
updatedAt: NOW,
|
||||
})
|
||||
expect(tableConfig.deletedKey).toBe('archivedAt')
|
||||
expect(tableConfig.buildSoftDeleteSet(TIMESTAMP, NOW)).toEqual({
|
||||
archivedAt: TIMESTAMP,
|
||||
updatedAt: NOW,
|
||||
})
|
||||
})
|
||||
|
||||
it('guards a table folder delete on table locks and leaves knowledge folders unguarded', () => {
|
||||
// Table locks are a governance feature that must survive a folder delete; knowledge
|
||||
// bases have no equivalent, so a guard there would be dead weight.
|
||||
expect(tableConfig.guardDelete).toBeTypeOf('function')
|
||||
expect(knowledgeConfig.guardDelete).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps manual folder sort ordering workflow-only', () => {
|
||||
// Only the workflow tree interleaves folders and rows in one user-ordered list.
|
||||
expect(knowledgeConfig.sortOrderColumn).toBeUndefined()
|
||||
expect(tableConfig.sortOrderColumn).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,7 +53,7 @@ export async function collectCascadeSubtreeIds(
|
||||
*
|
||||
* Matching on the exact `timestamp` is what stops a restore from also reviving folders
|
||||
* that were archived independently before or after — and is why this cannot reuse
|
||||
* {@link collectActiveSubtreeIds}, which by definition cannot see an archived subtree.
|
||||
* {@link collectCascadeSubtreeIds}, which by definition cannot see an archived subtree.
|
||||
*/
|
||||
export async function collectArchivedSubtreeIds(
|
||||
tx: DbOrTx,
|
||||
|
||||
@@ -283,9 +283,12 @@ async function archiveKnowledgeBaseChildren(context: CascadeChildrenContext): Pr
|
||||
async function restoreKnowledgeBaseChildren(context: CascadeChildrenContext): Promise<number> {
|
||||
const { restoreKnowledgeBase } = await import('@/lib/knowledge/service')
|
||||
const ids = await selectChildIds(FOLDER_RESOURCES.knowledge_base, context, 'archived')
|
||||
const restoringFolderIds = new Set(context.folderIds)
|
||||
|
||||
for (const id of ids) {
|
||||
await restoreKnowledgeBase(id, `folder-cascade-${context.folderIds[0]}`)
|
||||
await restoreKnowledgeBase(id, `folder-cascade-${context.folderIds[0]}`, {
|
||||
restoringFolderIds,
|
||||
})
|
||||
}
|
||||
|
||||
return ids.length
|
||||
@@ -317,9 +320,10 @@ async function archiveTableChildren(context: CascadeChildrenContext): Promise<nu
|
||||
async function restoreTableChildren(context: CascadeChildrenContext): Promise<number> {
|
||||
const { restoreTable } = await import('@/lib/table/service')
|
||||
const ids = await selectChildIds(FOLDER_RESOURCES.table, context, 'archived')
|
||||
const restoringFolderIds = new Set(context.folderIds)
|
||||
|
||||
for (const id of ids) {
|
||||
await restoreTable(id, `folder-cascade-${context.folderIds[0]}`)
|
||||
await restoreTable(id, `folder-cascade-${context.folderIds[0]}`, { restoringFolderIds })
|
||||
}
|
||||
|
||||
return ids.length
|
||||
@@ -380,6 +384,22 @@ export const FOLDER_RESOURCES: Record<FolderResourceType, FolderResourceConfig>
|
||||
buildSoftDeleteSet: (timestamp, now) =>
|
||||
({ archivedAt: timestamp, updatedAt: now }) satisfies Partial<typeof workflow.$inferInsert>,
|
||||
sortOrderColumn: workflow.sortOrder,
|
||||
/**
|
||||
* Restored in bulk rather than through a `restoreChildren` hook. `restoreFolderChildren`
|
||||
* already matches these on the archive timestamp, so a webhook or chat the user archived
|
||||
* independently stays archived — and it does so in a fixed number of statements inside the
|
||||
* restore transaction. Routing them through `restoreWorkflow` instead would add a
|
||||
* per-workflow read/transaction/read outside that transaction: ~1600 round trips for a
|
||||
* folder of 200 workflows, and a window where the workflows are active but the folder is
|
||||
* not. It would also buy nothing, since `restoreWorkflow` clears exactly these columns.
|
||||
*
|
||||
* What none of these can undo is the state `archiveWorkflow` overwrites — schedules go to
|
||||
* `status: 'disabled'` with `nextRunAt` cleared, webhooks and chats to `isActive: false`.
|
||||
* Archive does not record what those were, so restoring them to a constant would re-enable
|
||||
* a schedule the user had disabled and re-run a completed one. Re-enabling stays explicit
|
||||
* (redeploy re-activates a schedule), matching deployment state, which restore also leaves
|
||||
* off on purpose.
|
||||
*/
|
||||
restoreDependents: [
|
||||
{
|
||||
table: workflowSchedule,
|
||||
@@ -418,6 +438,14 @@ export const FOLDER_RESOURCES: Record<FolderResourceType, FolderResourceConfig>
|
||||
archiveChildren: archiveWorkflowChildren,
|
||||
guardDelete: guardLastWorkflows,
|
||||
},
|
||||
/**
|
||||
* Present to satisfy the total `Record<FolderResourceType, …>`, but UNREACHABLE at runtime:
|
||||
* `servedFolderResourceTypeSchema` does not serve `'file'`, and the Files surface goes through
|
||||
* `workspace-file-folder-manager.ts` instead. Do not treat this entry as a live path — routing
|
||||
* file folders through the generic engine would bypass the `workspace_file_folders:` advisory
|
||||
* lock that makes its check-then-write pairs atomic, and the name rules that keep a folder name
|
||||
* usable as a path segment.
|
||||
*/
|
||||
file: {
|
||||
resourceType: 'file',
|
||||
label: 'file',
|
||||
|
||||
@@ -0,0 +1,688 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
auditMock,
|
||||
dbChainMock,
|
||||
dbChainMockFns,
|
||||
queueTableRows,
|
||||
resetDbChainMock,
|
||||
schemaMock,
|
||||
} from '@sim/testing'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockArchiveFolderCascade,
|
||||
mockCollectArchivedSubtreeIds,
|
||||
mockCollectCascadeSubtreeIds,
|
||||
mockDeduplicateFolderName,
|
||||
mockGetWorkspaceWithOwner,
|
||||
mockGuardDelete,
|
||||
mockRestoreChildren,
|
||||
mockRestoreFolderChildren,
|
||||
mockRestoreFolderRows,
|
||||
mockWouldCreateFolderCycle,
|
||||
resourceConfig,
|
||||
} = vi.hoisted(() => ({
|
||||
mockArchiveFolderCascade: vi.fn(),
|
||||
mockCollectArchivedSubtreeIds: vi.fn(),
|
||||
mockCollectCascadeSubtreeIds: vi.fn(),
|
||||
mockDeduplicateFolderName: vi.fn(),
|
||||
mockGetWorkspaceWithOwner: vi.fn(),
|
||||
mockGuardDelete: vi.fn(),
|
||||
mockRestoreChildren: vi.fn(),
|
||||
mockRestoreFolderChildren: vi.fn(),
|
||||
mockRestoreFolderRows: vi.fn(),
|
||||
mockWouldCreateFolderCycle: vi.fn(),
|
||||
resourceConfig: { current: {} as Record<string, unknown> },
|
||||
}))
|
||||
|
||||
vi.mock('@sim/audit', () => auditMock)
|
||||
|
||||
vi.mock('@/lib/folders/cascade', () => ({
|
||||
archiveFolderCascade: mockArchiveFolderCascade,
|
||||
collectArchivedSubtreeIds: mockCollectArchivedSubtreeIds,
|
||||
collectCascadeSubtreeIds: mockCollectCascadeSubtreeIds,
|
||||
restoreFolderChildren: mockRestoreFolderChildren,
|
||||
restoreFolderRows: mockRestoreFolderRows,
|
||||
toCascadeCounts: (
|
||||
config: { countKey: string },
|
||||
counts: { folders: number; children: number }
|
||||
) => ({ folders: counts.folders, [config.countKey]: counts.children }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/folders/config', () => ({
|
||||
folderResourceConfig: () => resourceConfig.current,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/folders/naming', () => ({ deduplicateFolderName: mockDeduplicateFolderName }))
|
||||
|
||||
vi.mock('@/lib/folders/queries', () => ({ wouldCreateFolderCycle: mockWouldCreateFolderCycle }))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
getWorkspaceWithOwner: mockGetWorkspaceWithOwner,
|
||||
}))
|
||||
|
||||
import { createFolder, deleteFolder, restoreFolder, updateFolder } from '@/lib/folders/lifecycle'
|
||||
|
||||
const CHILD_TABLE = { name: 'child_table' }
|
||||
|
||||
/** Stand-in for the per-resource config; each test declares only the deltas it exercises. */
|
||||
function setConfig(overrides: Record<string, unknown> = {}) {
|
||||
resourceConfig.current = {
|
||||
resourceType: 'table',
|
||||
label: 'table',
|
||||
countKey: 'tables',
|
||||
table: CHILD_TABLE,
|
||||
idColumn: 'child.id',
|
||||
folderIdColumn: 'child.folderId',
|
||||
workspaceColumn: 'child.workspaceId',
|
||||
deletedColumn: 'child.archivedAt',
|
||||
deletedKey: 'archivedAt',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Shaped like what the `postgres` driver throws on the active-name partial unique index. */
|
||||
function uniqueViolation(): Error {
|
||||
return Object.assign(new Error('duplicate key value violates unique constraint'), {
|
||||
code: '23505',
|
||||
})
|
||||
}
|
||||
|
||||
const DUPLICATE_NAME_ERROR = 'A folder with this name already exists in this location'
|
||||
|
||||
const ARCHIVED_AT = new Date('2026-01-01T00:00:00.000Z')
|
||||
|
||||
function folderRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'folder-1',
|
||||
resourceType: 'table',
|
||||
name: 'Reports',
|
||||
userId: 'user-1',
|
||||
workspaceId: 'ws-1',
|
||||
parentId: null,
|
||||
locked: false,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date('2025-12-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2025-12-01T00:00:00.000Z'),
|
||||
deletedAt: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const baseCreate = {
|
||||
resourceType: 'table' as const,
|
||||
userId: 'user-1',
|
||||
workspaceId: 'ws-1',
|
||||
name: 'Reports',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
setConfig()
|
||||
mockWouldCreateFolderCycle.mockResolvedValue(false)
|
||||
mockDeduplicateFolderName.mockImplementation(
|
||||
async (_tx: unknown, _ws: string, _parent: string | null, name: string) => name
|
||||
)
|
||||
mockGetWorkspaceWithOwner.mockResolvedValue({ id: 'ws-1', archivedAt: null })
|
||||
mockCollectCascadeSubtreeIds.mockResolvedValue(['folder-1'])
|
||||
mockCollectArchivedSubtreeIds.mockResolvedValue(['folder-1'])
|
||||
mockArchiveFolderCascade.mockResolvedValue({ folders: 1, children: 0 })
|
||||
mockRestoreFolderRows.mockResolvedValue(1)
|
||||
mockRestoreFolderChildren.mockResolvedValue(0)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
describe('createFolder', () => {
|
||||
it('inserts the trimmed name and returns the created row', async () => {
|
||||
queueTableRows(schemaMock.folder, [{ minSortOrder: 5 }])
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([folderRow()])
|
||||
|
||||
const result = await createFolder({ ...baseCreate, id: 'folder-1', name: ' Reports ' })
|
||||
|
||||
expect(result).toEqual({ success: true, folder: folderRow() })
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'folder-1',
|
||||
resourceType: 'table',
|
||||
name: 'Reports',
|
||||
workspaceId: 'ws-1',
|
||||
parentId: null,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a parent that does not exist', async () => {
|
||||
queueTableRows(schemaMock.folder, [])
|
||||
|
||||
const result = await createFolder({ ...baseCreate, parentId: 'missing' })
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Parent folder not found',
|
||||
errorCode: 'validation',
|
||||
})
|
||||
expect(dbChainMockFns.values).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a parent that belongs to another workspace', async () => {
|
||||
// The parent row exists and matches the resourceType, so only the workspace check stands
|
||||
// between a caller-supplied id and filing a folder under another tenant's tree.
|
||||
queueTableRows(schemaMock.folder, [{ workspaceId: 'ws-other', archivedAt: null }])
|
||||
|
||||
const result = await createFolder({ ...baseCreate, parentId: 'parent-1' })
|
||||
|
||||
expect(result.errorCode).toBe('validation')
|
||||
expect(dbChainMockFns.values).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('scopes the parent lookup to the same resourceType so the DB trigger never has to', async () => {
|
||||
// `folder_parent_resource_type_match` enforces this too; skipping it here would surface a
|
||||
// raw trigger error as a 500 instead of a 400.
|
||||
queueTableRows(schemaMock.folder, [])
|
||||
|
||||
await createFolder({ ...baseCreate, resourceType: 'knowledge_base', parentId: 'parent-1' })
|
||||
|
||||
const [where] = dbChainMockFns.where.mock.calls[0] as [{ conditions: unknown[] }]
|
||||
expect(where.conditions).toContainEqual(expect.objectContaining({ right: 'knowledge_base' }))
|
||||
})
|
||||
|
||||
it('refuses an archived parent so a folder cannot be created inside a deleted tree', async () => {
|
||||
queueTableRows(schemaMock.folder, [{ workspaceId: 'ws-1', archivedAt: ARCHIVED_AT }])
|
||||
|
||||
const result = await createFolder({ ...baseCreate, parentId: 'parent-1' })
|
||||
|
||||
expect(result.errorCode).toBe('validation')
|
||||
expect(dbChainMockFns.values).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a folder that names itself as its own parent', async () => {
|
||||
const result = await createFolder({ ...baseCreate, id: 'folder-1', parentId: 'folder-1' })
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Folder cannot be its own parent',
|
||||
errorCode: 'validation',
|
||||
})
|
||||
// Rejected before any read, so a self-parented id never reaches the parent lookup.
|
||||
expect(dbChainMockFns.select).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('places a new folder above every existing sibling folder and resource', async () => {
|
||||
// Workflows interleave folders and rows in one ordering space, so the new folder has to
|
||||
// clear the minimum of BOTH — clearing only the folders would bury it under a workflow.
|
||||
setConfig({
|
||||
resourceType: 'workflow',
|
||||
countKey: 'workflows',
|
||||
sortOrderColumn: 'child.sortOrder',
|
||||
})
|
||||
queueTableRows(schemaMock.folder, [{ minSortOrder: 3 }])
|
||||
queueTableRows(CHILD_TABLE, [{ minSortOrder: -2 }])
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([folderRow({ sortOrder: -3 })])
|
||||
|
||||
await createFolder({ ...baseCreate, resourceType: 'workflow' })
|
||||
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(expect.objectContaining({ sortOrder: -3 }))
|
||||
})
|
||||
|
||||
it('starts at zero when the folder is the first thing in its location', async () => {
|
||||
queueTableRows(schemaMock.folder, [{ minSortOrder: null }])
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([folderRow()])
|
||||
|
||||
await createFolder(baseCreate)
|
||||
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(expect.objectContaining({ sortOrder: 0 }))
|
||||
})
|
||||
|
||||
it('honours an explicit sortOrder without querying siblings', async () => {
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([folderRow({ sortOrder: 42 })])
|
||||
|
||||
await createFolder({ ...baseCreate, sortOrder: 42 })
|
||||
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(expect.objectContaining({ sortOrder: 42 }))
|
||||
expect(dbChainMockFns.select).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns a conflict on a duplicate sibling name instead of silently renaming', async () => {
|
||||
// Create is a path where the user chose the name, so the active-name unique index must
|
||||
// surface as a 409 they can act on — deduplicating here would hand back a folder the
|
||||
// user never asked for.
|
||||
queueTableRows(schemaMock.folder, [{ minSortOrder: 0 }])
|
||||
dbChainMockFns.returning.mockRejectedValueOnce(uniqueViolation())
|
||||
|
||||
const result = await createFolder(baseCreate)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: DUPLICATE_NAME_ERROR,
|
||||
errorCode: 'conflict',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports any other insert failure as internal rather than a name conflict', async () => {
|
||||
queueTableRows(schemaMock.folder, [{ minSortOrder: 0 }])
|
||||
dbChainMockFns.returning.mockRejectedValueOnce(new Error('connection reset'))
|
||||
|
||||
const result = await createFolder(baseCreate)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
errorCode: 'internal',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateFolder', () => {
|
||||
const baseUpdate = {
|
||||
resourceType: 'table' as const,
|
||||
folderId: 'folder-1',
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'user-1',
|
||||
}
|
||||
|
||||
it('writes only the fields the caller supplied, plus a fresh updatedAt', async () => {
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([folderRow({ name: 'Renamed' })])
|
||||
|
||||
const result = await updateFolder({ ...baseUpdate, name: ' Renamed ' })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const [set] = dbChainMockFns.set.mock.calls[0] as [Record<string, unknown>]
|
||||
expect(set.name).toBe('Renamed')
|
||||
expect(set.updatedAt).toBeInstanceOf(Date)
|
||||
expect(set).not.toHaveProperty('parentId')
|
||||
expect(set).not.toHaveProperty('sortOrder')
|
||||
})
|
||||
|
||||
it('reparents a folder under a validated parent', async () => {
|
||||
queueTableRows(schemaMock.folder, [{ workspaceId: 'ws-1', archivedAt: null }])
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([folderRow({ parentId: 'parent-1' })])
|
||||
|
||||
const result = await updateFolder({ ...baseUpdate, parentId: 'parent-1' })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const [set] = dbChainMockFns.set.mock.calls[0] as [Record<string, unknown>]
|
||||
expect(set.parentId).toBe('parent-1')
|
||||
})
|
||||
|
||||
it('refuses an archived folder, so a delete cannot unlock its locked subfolders', async () => {
|
||||
/**
|
||||
* `getFolderLockStatus` skips archived rows, so an archived-but-locked folder reports
|
||||
* unlocked. Without the `deletedAt IS NULL` predicate in the UPDATE, deleting a parent
|
||||
* would make every locked subfolder under it freely renameable and reparentable.
|
||||
*/
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([])
|
||||
|
||||
const result = await updateFolder({ ...baseUpdate, name: 'Renamed' })
|
||||
|
||||
expect(result).toMatchObject({ success: false, errorCode: 'not_found' })
|
||||
expect(dbChainMockFns.set).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the parent when reparenting to the root', async () => {
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([folderRow()])
|
||||
|
||||
await updateFolder({ ...baseUpdate, parentId: null })
|
||||
|
||||
const [set] = dbChainMockFns.set.mock.calls[0] as [Record<string, unknown>]
|
||||
expect(set.parentId).toBeNull()
|
||||
})
|
||||
|
||||
it('drops a locked flag on a resource type without lock semantics', async () => {
|
||||
// The engine is reachable from the copilot tools as well as the route, so a `locked`
|
||||
// value on an unlockable tree must never reach the row.
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([folderRow()])
|
||||
|
||||
await updateFolder({ ...baseUpdate, locked: true })
|
||||
|
||||
const [set] = dbChainMockFns.set.mock.calls[0] as [Record<string, unknown>]
|
||||
expect(set).not.toHaveProperty('locked')
|
||||
})
|
||||
|
||||
it('persists a locked flag on the one resource type that supports locking', async () => {
|
||||
setConfig({ resourceType: 'workflow', countKey: 'workflows', supportsLocking: true })
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([folderRow({ locked: true })])
|
||||
|
||||
await updateFolder({ ...baseUpdate, resourceType: 'workflow', locked: true })
|
||||
|
||||
const [set] = dbChainMockFns.set.mock.calls[0] as [Record<string, unknown>]
|
||||
expect(set.locked).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses a reparent that would close a cycle', async () => {
|
||||
// A folder moved under its own descendant disappears from every tree walk, taking its
|
||||
// whole subtree with it.
|
||||
queueTableRows(schemaMock.folder, [{ workspaceId: 'ws-1', archivedAt: null }])
|
||||
mockWouldCreateFolderCycle.mockResolvedValueOnce(true)
|
||||
|
||||
const result = await updateFolder({ ...baseUpdate, parentId: 'descendant-1' })
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Cannot create circular folder reference',
|
||||
errorCode: 'validation',
|
||||
})
|
||||
expect(dbChainMockFns.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a folder that names itself as its own parent', async () => {
|
||||
const result = await updateFolder({ ...baseUpdate, parentId: 'folder-1' })
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Folder cannot be its own parent',
|
||||
errorCode: 'validation',
|
||||
})
|
||||
expect(mockWouldCreateFolderCycle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a folder outside this workspace or resourceType as not found', async () => {
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([])
|
||||
|
||||
const result = await updateFolder({ ...baseUpdate, name: 'Renamed' })
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Folder not found',
|
||||
errorCode: 'not_found',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a conflict when a rename collides with an active sibling', async () => {
|
||||
// Rename, like create, is a name the user chose — a 409 lets them pick another rather
|
||||
// than being handed "Reports (1)".
|
||||
dbChainMockFns.returning.mockRejectedValueOnce(uniqueViolation())
|
||||
|
||||
const result = await updateFolder({ ...baseUpdate, name: 'Taken' })
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: DUPLICATE_NAME_ERROR,
|
||||
errorCode: 'conflict',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteFolder', () => {
|
||||
const baseDelete = {
|
||||
resourceType: 'table' as const,
|
||||
folderId: 'folder-1',
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'user-1',
|
||||
folderName: 'Reports',
|
||||
}
|
||||
|
||||
it('reports a folder outside this workspace or resourceType as not found', async () => {
|
||||
queueTableRows(schemaMock.folder, [])
|
||||
|
||||
const result = await deleteFolder(baseDelete)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Folder not found',
|
||||
errorCode: 'not_found',
|
||||
})
|
||||
expect(mockArchiveFolderCascade).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('archives the resolved subtree and reports counts under the resource’s own key', async () => {
|
||||
queueTableRows(schemaMock.folder, [{ deletedAt: null }])
|
||||
mockCollectCascadeSubtreeIds.mockResolvedValueOnce(['folder-1', 'sub-1'])
|
||||
mockArchiveFolderCascade.mockResolvedValueOnce({ folders: 2, children: 3 })
|
||||
|
||||
const result = await deleteFolder(baseDelete)
|
||||
|
||||
expect(result).toEqual({ success: true, deletedItems: { folders: 2, tables: 3 } })
|
||||
expect(mockArchiveFolderCascade).toHaveBeenCalledWith(
|
||||
dbChainMock.db,
|
||||
resourceConfig.current,
|
||||
'ws-1',
|
||||
['folder-1', 'sub-1'],
|
||||
expect.any(Date)
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses the delete whole when the resource’s guard rejects it', async () => {
|
||||
// Tables refuse deletion while delete-locked; deleting the folder around one must not
|
||||
// become a way around that control, and must not archive half the subtree first.
|
||||
setConfig({ guardDelete: mockGuardDelete })
|
||||
mockGuardDelete.mockResolvedValueOnce({
|
||||
error: 'Cannot delete folder: table Ledger is delete-locked',
|
||||
errorCode: 'locked',
|
||||
})
|
||||
queueTableRows(schemaMock.folder, [{ deletedAt: null }])
|
||||
|
||||
const result = await deleteFolder(baseDelete)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Cannot delete folder: table Ledger is delete-locked',
|
||||
errorCode: 'locked',
|
||||
})
|
||||
expect(mockArchiveFolderCascade).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hands the guard the whole resolved subtree, not just the root', async () => {
|
||||
setConfig({ guardDelete: mockGuardDelete })
|
||||
mockGuardDelete.mockResolvedValueOnce(null)
|
||||
mockCollectCascadeSubtreeIds.mockResolvedValueOnce(['folder-1', 'sub-1'])
|
||||
queueTableRows(schemaMock.folder, [{ deletedAt: null }])
|
||||
|
||||
await deleteFolder(baseDelete)
|
||||
|
||||
expect(mockGuardDelete).toHaveBeenCalledWith({
|
||||
workspaceId: 'ws-1',
|
||||
folderIds: ['folder-1', 'sub-1'],
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses an already-archived folder’s timestamp so a retry rejoins the same snapshot', async () => {
|
||||
// A fresh stamp would be unrecoverable: the folder row keeps its original deletedAt, so
|
||||
// anything archived under the new stamp would never match on restore.
|
||||
queueTableRows(schemaMock.folder, [{ deletedAt: ARCHIVED_AT }])
|
||||
|
||||
await deleteFolder(baseDelete)
|
||||
|
||||
expect(mockCollectCascadeSubtreeIds).toHaveBeenCalledWith(
|
||||
dbChainMock.db,
|
||||
'ws-1',
|
||||
'table',
|
||||
'folder-1',
|
||||
ARCHIVED_AT
|
||||
)
|
||||
expect(mockArchiveFolderCascade).toHaveBeenCalledWith(
|
||||
dbChainMock.db,
|
||||
resourceConfig.current,
|
||||
'ws-1',
|
||||
['folder-1'],
|
||||
ARCHIVED_AT
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves the timestamp before the subtree walk, which needs it to see stragglers', async () => {
|
||||
queueTableRows(schemaMock.folder, [{ deletedAt: ARCHIVED_AT }])
|
||||
|
||||
await deleteFolder(baseDelete)
|
||||
|
||||
const [, , , , walkTimestamp] = mockCollectCascadeSubtreeIds.mock.calls[0]
|
||||
const [, , , , archiveTimestamp] = mockArchiveFolderCascade.mock.calls[0]
|
||||
expect(walkTimestamp).toBe(archiveTimestamp)
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoreFolder', () => {
|
||||
const baseRestore = {
|
||||
resourceType: 'table' as const,
|
||||
folderId: 'folder-1',
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'user-1',
|
||||
}
|
||||
|
||||
it('reports a folder outside this workspace or resourceType as not found', async () => {
|
||||
queueTableRows(schemaMock.folder, [])
|
||||
|
||||
const result = await restoreFolder(baseRestore)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Folder not found',
|
||||
errorCode: 'not_found',
|
||||
})
|
||||
})
|
||||
|
||||
it('is a no-op on a folder that was never archived', async () => {
|
||||
queueTableRows(schemaMock.folder, [folderRow({ deletedAt: null })])
|
||||
|
||||
const result = await restoreFolder(baseRestore)
|
||||
|
||||
expect(result).toEqual({ success: true, restoredItems: { folders: 0, tables: 0 } })
|
||||
expect(mockRestoreFolderRows).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to restore into an archived workspace', async () => {
|
||||
queueTableRows(schemaMock.folder, [folderRow({ deletedAt: ARCHIVED_AT })])
|
||||
mockGetWorkspaceWithOwner.mockResolvedValueOnce({ id: 'ws-1', archivedAt: ARCHIVED_AT })
|
||||
|
||||
const result = await restoreFolder(baseRestore)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Cannot restore folder into an archived workspace',
|
||||
errorCode: 'validation',
|
||||
})
|
||||
expect(mockRestoreFolderRows).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-roots a folder whose original parent is still archived', async () => {
|
||||
// Restoring it under an archived parent would revive a row that no tree walk reaches —
|
||||
// visible in no list, restorable by nothing.
|
||||
queueTableRows(schemaMock.folder, [folderRow({ deletedAt: ARCHIVED_AT, parentId: 'parent-1' })])
|
||||
queueTableRows(schemaMock.folder, [{ archivedAt: ARCHIVED_AT }])
|
||||
|
||||
const result = await restoreFolder(baseRestore)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(dbChainMockFns.set).toHaveBeenCalledWith({ parentId: null })
|
||||
// The name must then be checked against the ROOT's siblings, not the old parent's.
|
||||
expect(mockDeduplicateFolderName).toHaveBeenCalledWith(
|
||||
dbChainMock.db,
|
||||
'ws-1',
|
||||
null,
|
||||
'Reports',
|
||||
'table'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a folder under its parent when that parent came back first', async () => {
|
||||
queueTableRows(schemaMock.folder, [folderRow({ deletedAt: ARCHIVED_AT, parentId: 'parent-1' })])
|
||||
queueTableRows(schemaMock.folder, [{ archivedAt: null }])
|
||||
|
||||
await restoreFolder(baseRestore)
|
||||
|
||||
expect(dbChainMockFns.set).not.toHaveBeenCalledWith({ parentId: null })
|
||||
expect(mockDeduplicateFolderName).toHaveBeenCalledWith(
|
||||
dbChainMock.db,
|
||||
'ws-1',
|
||||
'parent-1',
|
||||
'Reports',
|
||||
'table'
|
||||
)
|
||||
})
|
||||
|
||||
it('renames rather than 409s when the folder’s name was taken while it was gone', async () => {
|
||||
// The caller cannot rename an archived folder, so a taken name would otherwise make it
|
||||
// permanently unrestorable.
|
||||
queueTableRows(schemaMock.folder, [folderRow({ deletedAt: ARCHIVED_AT })])
|
||||
mockDeduplicateFolderName.mockResolvedValueOnce('Reports (1)')
|
||||
|
||||
const result = await restoreFolder(baseRestore)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(dbChainMockFns.set).toHaveBeenCalledWith({ name: 'Reports (1)' })
|
||||
})
|
||||
|
||||
it('leaves the name alone when nothing took it', async () => {
|
||||
queueTableRows(schemaMock.folder, [folderRow({ deletedAt: ARCHIVED_AT })])
|
||||
|
||||
await restoreFolder(baseRestore)
|
||||
|
||||
expect(dbChainMockFns.set).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: expect.anything() })
|
||||
)
|
||||
})
|
||||
|
||||
it('restores only the rows archived under the folder’s own timestamp', async () => {
|
||||
queueTableRows(schemaMock.folder, [folderRow({ deletedAt: ARCHIVED_AT })])
|
||||
mockCollectArchivedSubtreeIds.mockResolvedValueOnce(['folder-1', 'sub-1'])
|
||||
mockRestoreFolderRows.mockResolvedValueOnce(2)
|
||||
mockRestoreFolderChildren.mockResolvedValueOnce(4)
|
||||
|
||||
const result = await restoreFolder(baseRestore)
|
||||
|
||||
expect(result).toEqual({ success: true, restoredItems: { folders: 2, tables: 4 } })
|
||||
expect(mockCollectArchivedSubtreeIds).toHaveBeenCalledWith(
|
||||
dbChainMock.db,
|
||||
'ws-1',
|
||||
'table',
|
||||
'folder-1',
|
||||
ARCHIVED_AT
|
||||
)
|
||||
expect(mockRestoreFolderRows).toHaveBeenCalledWith(
|
||||
dbChainMock.db,
|
||||
resourceConfig.current,
|
||||
'ws-1',
|
||||
['folder-1', 'sub-1'],
|
||||
ARCHIVED_AT,
|
||||
expect.any(Date)
|
||||
)
|
||||
})
|
||||
|
||||
it('runs a restoreChildren hook before the folder rows come back', async () => {
|
||||
// The hook opens its own transactions, so it cannot run nested — and going first is what
|
||||
// keeps a partial failure retryable: the folder stays archived until the children are back.
|
||||
setConfig({ restoreChildren: mockRestoreChildren })
|
||||
mockRestoreChildren.mockResolvedValueOnce(5)
|
||||
queueTableRows(schemaMock.folder, [folderRow({ deletedAt: ARCHIVED_AT })])
|
||||
|
||||
const result = await restoreFolder(baseRestore)
|
||||
|
||||
expect(mockRestoreChildren).toHaveBeenCalledWith({
|
||||
workspaceId: 'ws-1',
|
||||
folderIds: ['folder-1'],
|
||||
timestamp: ARCHIVED_AT,
|
||||
})
|
||||
expect(mockRestoreChildren.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockRestoreFolderRows.mock.invocationCallOrder[0]
|
||||
)
|
||||
// The hook's count wins; the generic child restore never runs for this resource.
|
||||
expect(mockRestoreFolderChildren).not.toHaveBeenCalled()
|
||||
expect(result).toEqual({ success: true, restoredItems: { folders: 1, tables: 5 } })
|
||||
})
|
||||
|
||||
it('returns a conflict when a concurrent create takes the name after the dedup check', async () => {
|
||||
// Dedup covers the restore root, but clearing deletedAt brings the row back under the
|
||||
// active-name unique index and that window is real.
|
||||
queueTableRows(schemaMock.folder, [folderRow({ deletedAt: ARCHIVED_AT })])
|
||||
mockRestoreFolderRows.mockRejectedValueOnce(uniqueViolation())
|
||||
|
||||
const result = await restoreFolder(baseRestore)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: DUPLICATE_NAME_ERROR,
|
||||
errorCode: 'conflict',
|
||||
})
|
||||
})
|
||||
|
||||
it('rethrows any non-conflict failure instead of reporting a name collision', async () => {
|
||||
queueTableRows(schemaMock.folder, [folderRow({ deletedAt: ARCHIVED_AT })])
|
||||
mockRestoreFolderRows.mockRejectedValueOnce(new Error('connection reset'))
|
||||
|
||||
await expect(restoreFolder(baseRestore)).rejects.toThrow('connection reset')
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import { getPostgresErrorCode } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, isNull, min } from 'drizzle-orm'
|
||||
import type { FolderCascadeCountsApi, FolderResourceType } from '@/lib/api/contracts/folders'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import {
|
||||
archiveFolderCascade,
|
||||
collectArchivedSubtreeIds,
|
||||
@@ -116,17 +117,18 @@ async function assertParentFolderInWorkspace(
|
||||
* their folders, so their `sortOrder` participates; knowledge bases and tables have no
|
||||
* per-row ordering and are ignored via the absent `sortOrderColumn`.
|
||||
*/
|
||||
async function nextFolderSortOrder(
|
||||
export async function nextFolderSortOrder(
|
||||
resourceType: FolderResourceType,
|
||||
workspaceId: string,
|
||||
parentId: string | null | undefined
|
||||
parentId: string | null | undefined,
|
||||
tx: DbOrTx = db
|
||||
): Promise<number> {
|
||||
const config = folderResourceConfig(resourceType)
|
||||
const folderParentCondition = parentId
|
||||
? eq(folderTable.parentId, parentId)
|
||||
: isNull(folderTable.parentId)
|
||||
|
||||
const folderMinPromise = db
|
||||
const folderMinPromise = tx
|
||||
.select({ minSortOrder: min(folderTable.sortOrder) })
|
||||
.from(folderTable)
|
||||
.where(
|
||||
@@ -138,7 +140,7 @@ async function nextFolderSortOrder(
|
||||
)
|
||||
|
||||
const childMinPromise: Promise<Array<{ minSortOrder: unknown }>> = config.sortOrderColumn
|
||||
? db
|
||||
? tx
|
||||
.select({ minSortOrder: min(config.sortOrderColumn) })
|
||||
.from(config.table)
|
||||
.where(
|
||||
@@ -274,6 +276,13 @@ export async function updateFolder(params: UpdateFolderParams): Promise<FolderMu
|
||||
if (params.parentId !== undefined) updates.parentId = params.parentId || null
|
||||
if (params.sortOrder !== undefined) updates.sortOrder = params.sortOrder
|
||||
|
||||
/**
|
||||
* `isNull(deletedAt)` closes a lock bypass, not just a tidiness gap: `getFolderLockStatus`
|
||||
* skips archived rows, so an archived-but-locked folder reports unlocked. Without this,
|
||||
* deleting a parent would make every locked subfolder beneath it freely renameable and
|
||||
* reparentable. The engine is reachable from the copilot tools as well as the route, so
|
||||
* the guard belongs here rather than only at the boundary.
|
||||
*/
|
||||
const [folder] = await db
|
||||
.update(folderTable)
|
||||
.set(updates)
|
||||
@@ -281,7 +290,8 @@ export async function updateFolder(params: UpdateFolderParams): Promise<FolderMu
|
||||
and(
|
||||
eq(folderTable.id, params.folderId),
|
||||
eq(folderTable.workspaceId, params.workspaceId),
|
||||
eq(folderTable.resourceType, params.resourceType)
|
||||
eq(folderTable.resourceType, params.resourceType),
|
||||
isNull(folderTable.deletedAt)
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
@@ -435,6 +445,11 @@ export async function restoreFolder(params: RestoreFolderParams): Promise<Restor
|
||||
// had not come back yet. In this order a failure leaves the folder archived and the whole
|
||||
// restore simply retryable; children already restored no longer match the timestamp and
|
||||
// are skipped.
|
||||
//
|
||||
// The cost is a visible intermediate state if the transaction below fails: the children are
|
||||
// active while their folders are still archived. They are not lost — the Knowledge and Tables
|
||||
// lists fall an unresolvable `folderId` back to the workspace root, so the rows stay reachable
|
||||
// — but they sit at the root until the restore is retried.
|
||||
const hookChildren = config.restoreChildren
|
||||
? await config.restoreChildren({ workspaceId, folderIds, timestamp: archivedAt })
|
||||
: null
|
||||
@@ -480,7 +495,16 @@ export async function restoreFolder(params: RestoreFolderParams): Promise<Restor
|
||||
from: folder.name,
|
||||
to: restoredName,
|
||||
})
|
||||
await tx.update(folderTable).set({ name: restoredName }).where(eq(folderTable.id, folderId))
|
||||
await tx
|
||||
.update(folderTable)
|
||||
.set({ name: restoredName })
|
||||
.where(
|
||||
and(
|
||||
eq(folderTable.id, folderId),
|
||||
eq(folderTable.workspaceId, workspaceId),
|
||||
eq(folderTable.resourceType, resourceType)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const folders = await restoreFolderRows(tx, config, workspaceId, folderIds, archivedAt, now)
|
||||
|
||||
@@ -16,7 +16,7 @@ type DbOrTx = Pick<typeof db, 'select'>
|
||||
* instead where they do (create, rename).
|
||||
*
|
||||
* The `" (N)"` shape deliberately matches both the client-side dedup in
|
||||
* `useFolderCreateWithDedup` and the backfill in migration 0272, so a deduped name reads the
|
||||
* `nextUntitledFolderName` and the backfill in migration 0272, so a deduped name reads the
|
||||
* same however it was produced.
|
||||
*/
|
||||
export async function deduplicateFolderName(
|
||||
|
||||
@@ -49,6 +49,64 @@ export async function wouldCreateFolderCycle(
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an active folder, scoped to both its workspace and its `resourceType`.
|
||||
*
|
||||
* Every foldered resource needs this before writing its `folderId` column. The FK only
|
||||
* proves the folder row exists — not that it belongs to this workspace or to this
|
||||
* resource's tree — so without the check a caller could file a knowledge base under
|
||||
* another tenant's folder, or under a table folder that the Knowledge page will never
|
||||
* render, stranding the row invisibly. The DB trigger `folder_parent_resource_type_match`
|
||||
* does not help here: it only guards folder parents.
|
||||
*
|
||||
* Returns `null` when the folder is missing, archived, in another workspace, or belongs to
|
||||
* another resource's tree — a single "not a valid destination" answer.
|
||||
*/
|
||||
export async function findActiveFolder(
|
||||
folderId: string,
|
||||
workspaceId: string,
|
||||
resourceType: FolderResourceType
|
||||
): Promise<typeof folder.$inferSelect | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(folder)
|
||||
.where(
|
||||
and(
|
||||
eq(folder.id, folderId),
|
||||
eq(folder.workspaceId, workspaceId),
|
||||
eq(folder.resourceType, resourceType),
|
||||
isNull(folder.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
return row ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a restored resource should land: its original folder when that folder is reachable,
|
||||
* otherwise the workspace root.
|
||||
*
|
||||
* `restoringFolderIds` is what makes this safe inside a folder cascade. A `restoreChildren`
|
||||
* hook runs BEFORE the folder rows are un-archived (see `restoreFolder` — that ordering is
|
||||
* what keeps a partial failure retryable), so a naive "is my folder active?" check sees the
|
||||
* folder still archived and dumps every child at the root. Passing the subtree being
|
||||
* restored tells the check to treat those folders as already back.
|
||||
*
|
||||
* Without any set — a single-resource restore out of Recently Deleted — an archived folder
|
||||
* still re-roots, because filing a row under a folder no page renders makes it unreachable.
|
||||
*/
|
||||
export async function resolveRestoredFolderId(
|
||||
folderId: string | null | undefined,
|
||||
workspaceId: string | null | undefined,
|
||||
resourceType: FolderResourceType,
|
||||
restoringFolderIds?: ReadonlySet<string>
|
||||
): Promise<string | null> {
|
||||
if (!folderId || !workspaceId) return null
|
||||
if (restoringFolderIds?.has(folderId)) return folderId
|
||||
return (await findActiveFolder(folderId, workspaceId, resourceType)) ? folderId : null
|
||||
}
|
||||
|
||||
/** Shared by `GET /api/folders` and the sidebar prefetch so the query never drifts between them. */
|
||||
export async function listFoldersForWorkspace(
|
||||
workspaceId: string,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { FolderTreeNode, WorkflowFolder } from '@/stores/folders/types'
|
||||
|
||||
export function buildFolderMap(folders: WorkflowFolder[]): Record<string, WorkflowFolder> {
|
||||
return Object.fromEntries(folders.map((folder) => [folder.id, folder]))
|
||||
}
|
||||
|
||||
export function buildFolderTree(
|
||||
folders: Record<string, WorkflowFolder>,
|
||||
workspaceId: string
|
||||
@@ -42,14 +38,26 @@ export function getChildFolders(
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Root-first ancestor chain of `folderId`, as folder objects — callers need the ids (cycle
|
||||
* checks, expanding each ancestor), not just the names, which is why this is distinct from
|
||||
* the string-returning `getFolderPath` in `@/hooks/queries/utils/folder-tree`.
|
||||
*
|
||||
* `visited` is not defensive padding. The client folder map is written optimistically by
|
||||
* `useReorderFolders`, which sets `parentId` without validating the result, so a cycle is
|
||||
* reachable in cache even though the server rejects one. Without the guard this loops
|
||||
* forever and hangs the tab.
|
||||
*/
|
||||
export function getFolderPath(
|
||||
folders: Record<string, WorkflowFolder>,
|
||||
folderId: string
|
||||
): WorkflowFolder[] {
|
||||
const path: WorkflowFolder[] = []
|
||||
const visited = new Set<string>()
|
||||
let currentId: string | null = folderId
|
||||
|
||||
while (currentId && folders[currentId]) {
|
||||
while (currentId && folders[currentId] && !visited.has(currentId)) {
|
||||
visited.add(currentId)
|
||||
const folder: WorkflowFolder = folders[currentId]
|
||||
path.unshift(folder)
|
||||
currentId = folder.parentId
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { dbChainMockFns, permissionsMock, permissionsMockFns, resetDbChainMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockApplyStorageUsageDeltasInTx,
|
||||
mockEnsureUserStatsExists,
|
||||
mockGetHighestPrioritySubscription,
|
||||
mockFindActiveFolder,
|
||||
mockMaybeNotifyStorageLimitForBillingContext,
|
||||
mockResolveStorageBillingContext,
|
||||
} = vi.hoisted(() => ({
|
||||
mockApplyStorageUsageDeltasInTx: vi.fn(),
|
||||
mockEnsureUserStatsExists: vi.fn(),
|
||||
mockGetHighestPrioritySubscription: vi.fn(),
|
||||
mockFindActiveFolder: vi.fn(),
|
||||
mockMaybeNotifyStorageLimitForBillingContext: vi.fn(),
|
||||
mockResolveStorageBillingContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
|
||||
vi.mock('@/lib/folders/queries', () => ({
|
||||
findActiveFolder: mockFindActiveFolder,
|
||||
}))
|
||||
vi.mock('@/lib/billing/storage', () => ({
|
||||
applyStorageUsageDeltasInTx: mockApplyStorageUsageDeltasInTx,
|
||||
maybeNotifyStorageLimitForBillingContext: mockMaybeNotifyStorageLimitForBillingContext,
|
||||
resolveStorageBillingContext: mockResolveStorageBillingContext,
|
||||
}))
|
||||
vi.mock('@/lib/billing/core/subscription', () => ({
|
||||
getHighestPrioritySubscription: mockGetHighestPrioritySubscription,
|
||||
}))
|
||||
vi.mock('@/lib/billing/core/usage', () => ({
|
||||
ensureUserStatsExists: mockEnsureUserStatsExists,
|
||||
}))
|
||||
|
||||
import {
|
||||
createKnowledgeBase,
|
||||
KnowledgeBaseFolderError,
|
||||
updateKnowledgeBase,
|
||||
} from '@/lib/knowledge/service'
|
||||
|
||||
const CREATE_INPUT = {
|
||||
name: 'Base',
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'u-1',
|
||||
embeddingModel: 'text-embedding-3-small',
|
||||
embeddingDimension: 1536 as const,
|
||||
chunkingConfig: { maxSize: 1024, minSize: 100, overlap: 200 },
|
||||
}
|
||||
|
||||
/**
|
||||
* `knowledge_base.folder_id` has a plain FK to `folder.id`, which proves nothing about the
|
||||
* workspace the folder belongs to or which resource tree it serves. These tests pin the
|
||||
* application-level admission that stands in for the missing constraint.
|
||||
*/
|
||||
describe('createKnowledgeBase — folder assignment', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
dbChainMockFns.limit.mockReset()
|
||||
resetDbChainMock()
|
||||
dbChainMockFns.limit.mockResolvedValue([])
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin')
|
||||
mockFindActiveFolder.mockResolvedValue({ id: 'f-1' })
|
||||
})
|
||||
|
||||
it('files the base under the requested folder', async () => {
|
||||
const created = await createKnowledgeBase({ ...CREATE_INPUT, folderId: 'f-1' }, 'req-1')
|
||||
|
||||
expect(mockFindActiveFolder).toHaveBeenCalledWith('f-1', 'ws-1', 'knowledge_base')
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ folderId: 'f-1', workspaceId: 'ws-1' })
|
||||
)
|
||||
expect(created.folderId).toBe('f-1')
|
||||
})
|
||||
|
||||
it('creates at the workspace root when no folder is given, without a folder lookup', async () => {
|
||||
const created = await createKnowledgeBase(CREATE_INPUT, 'req-1')
|
||||
|
||||
expect(mockFindActiveFolder).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(expect.objectContaining({ folderId: null }))
|
||||
expect(created.folderId).toBeNull()
|
||||
})
|
||||
|
||||
it('normalizes an explicit null folder to the workspace root', async () => {
|
||||
const created = await createKnowledgeBase({ ...CREATE_INPUT, folderId: null }, 'req-1')
|
||||
|
||||
expect(mockFindActiveFolder).not.toHaveBeenCalled()
|
||||
expect(created.folderId).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a folder that is not an active knowledge_base folder in the workspace', async () => {
|
||||
mockFindActiveFolder.mockResolvedValue(null)
|
||||
|
||||
await expect(
|
||||
createKnowledgeBase({ ...CREATE_INPUT, folderId: 'f-other-workspace' }, 'req-1')
|
||||
).rejects.toBeInstanceOf(KnowledgeBaseFolderError)
|
||||
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('checks permission before touching the folder', async () => {
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read')
|
||||
|
||||
await expect(
|
||||
createKnowledgeBase({ ...CREATE_INPUT, folderId: 'f-1' }, 'req-1')
|
||||
).rejects.toMatchObject({ code: 'KNOWLEDGE_BASE_FORBIDDEN' })
|
||||
expect(mockFindActiveFolder).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateKnowledgeBase — folder moves', () => {
|
||||
/**
|
||||
* The mocked `@sim/db` cannot satisfy the post-transaction read-back select, so a
|
||||
* successful update still rejects after the transaction body commits.
|
||||
*/
|
||||
const runIgnoringReadBack = (promise: Promise<unknown>) => promise.catch(() => undefined)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
dbChainMockFns.limit.mockReset()
|
||||
resetDbChainMock()
|
||||
dbChainMockFns.limit.mockResolvedValue([
|
||||
{ workspaceId: 'ws-1', userId: 'u-1', folderId: 'f-old' },
|
||||
])
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin')
|
||||
mockFindActiveFolder.mockResolvedValue({ id: 'f-1' })
|
||||
mockResolveStorageBillingContext.mockImplementation(async (workspaceId: string) => ({
|
||||
workspaceId,
|
||||
billedAccountUserId: `${workspaceId}-owner`,
|
||||
billingEntity: { type: 'user', id: `${workspaceId}-owner` },
|
||||
plan: 'team_25000',
|
||||
customStorageLimitGB: null,
|
||||
}))
|
||||
mockApplyStorageUsageDeltasInTx.mockResolvedValue(100)
|
||||
mockEnsureUserStatsExists.mockResolvedValue(undefined)
|
||||
mockGetHighestPrioritySubscription.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('writes the new folder against the current workspace', async () => {
|
||||
await runIgnoringReadBack(updateKnowledgeBase('kb-1', { folderId: 'f-new' }, 'req-1'))
|
||||
|
||||
expect(mockFindActiveFolder).toHaveBeenCalledWith('f-new', 'ws-1', 'knowledge_base')
|
||||
expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ folderId: 'f-new' }))
|
||||
})
|
||||
|
||||
it('moves the base to the workspace root on an explicit null', async () => {
|
||||
await runIgnoringReadBack(updateKnowledgeBase('kb-1', { folderId: null }, 'req-1'))
|
||||
|
||||
expect(mockFindActiveFolder).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ folderId: null }))
|
||||
})
|
||||
|
||||
it('rejects a folder outside the knowledge base workspace', async () => {
|
||||
mockFindActiveFolder.mockResolvedValue(null)
|
||||
|
||||
await expect(
|
||||
updateKnowledgeBase('kb-1', { folderId: 'f-foreign' }, 'req-1')
|
||||
).rejects.toBeInstanceOf(KnowledgeBaseFolderError)
|
||||
expect(dbChainMockFns.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('validates a folder move that accompanies a workspace change against the destination', async () => {
|
||||
await runIgnoringReadBack(
|
||||
updateKnowledgeBase('kb-1', { workspaceId: 'ws-2', folderId: 'f-dest' }, 'req-1', {
|
||||
actorUserId: 'u-1',
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockFindActiveFolder).toHaveBeenCalledWith('f-dest', 'ws-2', 'knowledge_base')
|
||||
})
|
||||
|
||||
it('re-roots the base when its workspace changes and no folder is named', async () => {
|
||||
await runIgnoringReadBack(
|
||||
updateKnowledgeBase('kb-1', { workspaceId: 'ws-2' }, 'req-1', { actorUserId: 'u-1' })
|
||||
)
|
||||
|
||||
expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ folderId: null }))
|
||||
})
|
||||
|
||||
it('leaves the folder alone when the workspace is unchanged', async () => {
|
||||
await runIgnoringReadBack(
|
||||
updateKnowledgeBase('kb-1', { workspaceId: 'ws-1' }, 'req-1', { actorUserId: 'u-1' })
|
||||
)
|
||||
|
||||
expect(dbChainMockFns.set).not.toHaveBeenCalledWith(expect.objectContaining({ folderId: null }))
|
||||
})
|
||||
|
||||
it('leaves the folder alone on a plain rename', async () => {
|
||||
dbChainMockFns.limit
|
||||
.mockResolvedValueOnce([{ workspaceId: 'ws-1', userId: 'u-1', folderId: 'f-old' }]) // row lock
|
||||
.mockResolvedValueOnce([]) // duplicate-name check: none
|
||||
|
||||
await runIgnoringReadBack(updateKnowledgeBase('kb-1', { name: 'Renamed' }, 'req-1'))
|
||||
|
||||
expect(mockFindActiveFolder).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.set).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ folderId: expect.anything() })
|
||||
)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user