mirror of
https://github.com/teableio/teable.git
synced 2026-01-20 20:52:53 +08:00
* feat: add BaseNode and BaseNodeFolder models with migration * feat: add tree component in ui-lib * feat: implement BaseNode and BaseNodeFolder functionality with CRUD operations and event handling * feat: enhance migration script * feat: add support for user last visit tracking and resource deletion events * feat: implement permission management for BaseNode with role-based access control * refactor: PinService to optimize resource fetching and enhance code readability * fix: router * feat: base import/export/duplicae support base node * test: add unit tests for BaseNodeService methods including SQL generation and edge cases * feat: implement folder depth validation and enhance node movement logic in BaseNodeService * feat: integrate performance caching for base node list * refactor: remove unused routes from BasePageRouter * feat: enhance dashboard renaming functionality with improved state management and keyboard shortcuts * refactor: simplify BaseNodeTree component by removing unnecessary separator and enhancing drop logic * feat: enhance QuickAction search * fix: sorting for nodes in BaseImportService to ensure proper parent-child relationships * fix: delete folder and pin list * feat: add permanent delete functionality for base nodes and enhance delete logic in BaseNodeService * feat: enhance error handling in BaseNodeService and BaseNodeFolderService with localized messages * refactor: rename hooks and reorganize imports in base node feature * refactor: remove console log and clean up imports in PinItem component * fix: pin sql * fix: e2e * fix: sharedb presence handling * fix: e2e * refactor: optimize database transactions in BaseNodeService * fix: improve URL generation in BaseNode components * refactor: remove unnecessary permission decorator and adjust layout in BaseNodeTree component * feat: add validation for folder depth when moving nodes * fix: refine anchorId logic in BaseNodeTree component for improved node positioning * fix: adjust emoji picker size in BaseNodeTree component for better UI consistency * fix: enhance expanded when create * feat: implement auto-scroll functionality during drag in BaseNodeTree component * fix: update TreeItemLabel and TreeDragLine styles for improved visual consistency * fix: enhance canDrop logic in BaseNodeTree for improved item drop validation * refactor: add resourceMeta in baseNodeSchema * fix: e2e * refactor: update folder creation and update endpoints to return structured response objects * fix: e2e * feat: add disallowDashboard setting and deprecation banner in dashboard components * fix: type check * feat: add loading state to BaseNodeContext and integrate skeleton loading in BaseNodeTree * feat: enhance BaseNode service and UI to include defaultViewId in resourceMeta * refactor: simplify URL construction in getNodeUrl and streamline table navigation in BaseNodeTree * refactor: improve styling and structure in BaseNodeTree for better responsiveness and accessibility * feat: add workflow state render * fix: sync dataLoader returned undefined error * refactor: update styling in BaseNodeTree for improved layout and consistency * refactor: remove setEditingNodeId when create and duplicate * refactor: extract table creation logic for improved readability * refactor: update dropdown menu width and enhance delete confirmation title with resource type * fix: common noun i18n * feat: introduce useBaseNodeContext hook for improved context management in BaseNode components * refactor: update useBaseNode for enhanced context management * refactor: enhance BaseNodeTree component with edit mode support and improved local storage handling * feat: add onUpdateError callback to useBaseNodeCrud and BaseNodeTree for improved error handling * refactor: improved UI consistency * refactor: improve menu invalidation * refactor: remove permanent delete functionality from UI components * refactor: permission handling by consolidating base node actions * feat: implement base node event handling with create, update, and delete events * refactor: base node event * refactor: remove table iist in ssr * feat: enhance BaseNodeTree with highlight * feat: update BaseNodeTree to expand parent nodes on selection * refactor: replace nativeEnum with enum * fix: enhance router visits check * refactor: remove unused user last visit mutation from BaseNodeTree
261 lines
9.1 KiB
TypeScript
261 lines
9.1 KiB
TypeScript
/* eslint-disable sonarjs/no-duplicate-string */
|
|
import type { INestApplication } from '@nestjs/common';
|
|
import { getRandomString } from '@teable/core';
|
|
import { PrismaService } from '@teable/db-main-prisma';
|
|
import {
|
|
createBaseNodeFolder,
|
|
updateBaseNodeFolder,
|
|
deleteBaseNodeFolder,
|
|
createBaseNode,
|
|
BaseNodeResourceType,
|
|
deleteBaseNode,
|
|
} from '@teable/openapi';
|
|
import { getError } from './utils/get-error';
|
|
import { initApp } from './utils/init-app';
|
|
|
|
describe('BaseNodeFolderController (e2e) /api/base/:baseId/node/folder', () => {
|
|
let app: INestApplication;
|
|
const baseId = globalThis.testConfig.baseId;
|
|
const folderNameToDelete = 'Folder To Delete';
|
|
const whitespaceOnlyName = ' ';
|
|
const originalFolderName = 'Original Folder';
|
|
let prisma: PrismaService;
|
|
|
|
beforeAll(async () => {
|
|
const appCtx = await initApp();
|
|
app = appCtx.app;
|
|
prisma = app.get(PrismaService);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
});
|
|
|
|
describe('POST /api/base/:baseId/node/folder - Create folder', () => {
|
|
it('should create a folder successfully', async () => {
|
|
const ro = { name: 'Test Folder' };
|
|
const response = await createBaseNodeFolder(baseId, ro);
|
|
|
|
expect(response.data).toBeDefined();
|
|
expect(response.data.name).toContain('Test Folder');
|
|
expect(response.data.id).toBeDefined();
|
|
|
|
// Cleanup
|
|
await deleteBaseNodeFolder(baseId, response.data.id);
|
|
});
|
|
|
|
it('should create multiple folders with same name (auto unique)', async () => {
|
|
const ro = { name: 'Duplicate Folder' };
|
|
const response1 = await createBaseNodeFolder(baseId, ro);
|
|
const response2 = await createBaseNodeFolder(baseId, ro);
|
|
|
|
expect(response1.data.name).toContain('Duplicate Folder');
|
|
expect(response2.data.name).toContain('Duplicate Folder');
|
|
expect(response1.data.name).not.toBe(response2.data.name);
|
|
expect(response1.data.id).not.toBe(response2.data.id);
|
|
|
|
// Cleanup
|
|
await deleteBaseNodeFolder(baseId, response1.data.id);
|
|
await deleteBaseNodeFolder(baseId, response2.data.id);
|
|
});
|
|
|
|
it('should trim folder name', async () => {
|
|
const ro = { name: ' Trimmed Folder ' };
|
|
const response = await createBaseNodeFolder(baseId, ro);
|
|
|
|
expect(response.data.name).toContain('Trimmed Folder');
|
|
|
|
// Cleanup
|
|
await deleteBaseNodeFolder(baseId, response.data.id);
|
|
});
|
|
|
|
it('should fail with empty name', async () => {
|
|
const ro = { name: '' };
|
|
const error = await getError(() => createBaseNodeFolder(baseId, ro));
|
|
|
|
expect(error?.status).toBe(400);
|
|
});
|
|
|
|
it('should fail with whitespace only name', async () => {
|
|
const ro = { name: whitespaceOnlyName };
|
|
const error = await getError(() => createBaseNodeFolder(baseId, ro));
|
|
|
|
expect(error?.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe('PATCH /api/base/:baseId/node/folder/:folderId - Update folder', () => {
|
|
let folderId: string;
|
|
|
|
beforeEach(async () => {
|
|
const response = await createBaseNodeFolder(baseId, { name: originalFolderName });
|
|
folderId = response.data.id;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
try {
|
|
await deleteBaseNodeFolder(baseId, folderId);
|
|
} catch (e) {
|
|
// Folder might already be deleted in some tests
|
|
}
|
|
});
|
|
|
|
it('should rename folder successfully', async () => {
|
|
const updateRo = { name: 'Renamed Folder' };
|
|
const response = await updateBaseNodeFolder(baseId, folderId, updateRo);
|
|
|
|
expect(response.data).toBeDefined();
|
|
expect(response.data.name).toBe('Renamed Folder');
|
|
expect(response.data.id).toBe(folderId);
|
|
});
|
|
|
|
it('should trim folder name when renaming', async () => {
|
|
const updateRo = { name: ' Trimmed Renamed ' };
|
|
const response = await updateBaseNodeFolder(baseId, folderId, updateRo);
|
|
|
|
expect(response.data.name).toBe('Trimmed Renamed');
|
|
});
|
|
|
|
it('should fail when renaming to existing folder name', async () => {
|
|
// Create another folder
|
|
const anotherFolder = await createBaseNodeFolder(baseId, { name: 'Existing Folder' });
|
|
|
|
// Try to rename original folder to existing name
|
|
const updateRo = { name: 'Existing Folder' };
|
|
const error = await getError(() => updateBaseNodeFolder(baseId, folderId, updateRo));
|
|
|
|
expect(error?.status).toBe(400);
|
|
expect(error?.message).toContain('Folder name already exists');
|
|
|
|
// Cleanup
|
|
await deleteBaseNodeFolder(baseId, anotherFolder.data.id);
|
|
});
|
|
|
|
it('should allow renaming folder to same name', async () => {
|
|
const updateRo = { name: originalFolderName };
|
|
const response = await updateBaseNodeFolder(baseId, folderId, updateRo);
|
|
|
|
expect(response.data.name).toBe(originalFolderName);
|
|
});
|
|
|
|
it('should fail with empty name', async () => {
|
|
const updateRo = { name: '' };
|
|
const error = await getError(() => updateBaseNodeFolder(baseId, folderId, updateRo));
|
|
|
|
expect(error?.status).toBe(400);
|
|
});
|
|
|
|
it('should fail with whitespace only name', async () => {
|
|
const updateRo = { name: whitespaceOnlyName };
|
|
const error = await getError(() => updateBaseNodeFolder(baseId, folderId, updateRo));
|
|
|
|
expect(error?.status).toBe(400);
|
|
});
|
|
|
|
it('should fail when updating non-existent folder', async () => {
|
|
const nonExistentId = 'non-existent-folder-id';
|
|
const updateRo = { name: 'New Name' };
|
|
const error = await getError(() => updateBaseNodeFolder(baseId, nonExistentId, updateRo));
|
|
|
|
expect(error?.status).toBeGreaterThanOrEqual(400);
|
|
});
|
|
});
|
|
|
|
describe('DELETE /api/base/:baseId/node/folder/:folderId - Delete folder', () => {
|
|
it('should delete empty folder successfully', async () => {
|
|
// Create a folder
|
|
const folder = await createBaseNodeFolder(baseId, { name: folderNameToDelete });
|
|
const folderId = folder.data.id;
|
|
|
|
const findFolder = await prisma.baseNodeFolder.findFirst({
|
|
where: { id: folderId },
|
|
});
|
|
expect(findFolder).toBeDefined();
|
|
|
|
// Delete the folder
|
|
await deleteBaseNodeFolder(baseId, folderId);
|
|
|
|
const findFolderAfterDelete = await prisma.baseNodeFolder.findFirst({
|
|
where: { id: folderId },
|
|
});
|
|
expect(findFolderAfterDelete).toBeNull();
|
|
|
|
// Verify folder is deleted
|
|
const error = await getError(() => deleteBaseNodeFolder(baseId, folderId));
|
|
expect(error?.status).toBeGreaterThanOrEqual(400);
|
|
});
|
|
|
|
it('should fail when deleting folder with children', async () => {
|
|
// Create a parent folder
|
|
const parentFolder = await createBaseNode(baseId, {
|
|
resourceType: BaseNodeResourceType.Folder,
|
|
name: 'Parent Folder',
|
|
}).then((res) => res.data);
|
|
|
|
// Create a child folder inside the parent folder using createBaseNode
|
|
const childFolder = await createBaseNode(baseId, {
|
|
resourceType: BaseNodeResourceType.Folder,
|
|
parentId: parentFolder.id,
|
|
name: 'Child Folder',
|
|
}).then((res) => res.data);
|
|
|
|
// Try to delete the parent folder
|
|
const error = await getError(() => deleteBaseNode(baseId, parentFolder.id));
|
|
|
|
expect(error?.status).toBe(400);
|
|
expect(error?.message).toContain('Cannot delete folder because it is not empty');
|
|
|
|
// Cleanup - need to delete the folder manually after removing children
|
|
await deleteBaseNode(baseId, childFolder.id);
|
|
await deleteBaseNode(baseId, parentFolder.id);
|
|
|
|
const findFolderAfterDelete = await prisma.baseNodeFolder.findFirst({
|
|
where: { id: { in: [parentFolder.id, childFolder.id] } },
|
|
});
|
|
expect(findFolderAfterDelete).toBeNull();
|
|
});
|
|
|
|
it('should fail when deleting non-existent folder', async () => {
|
|
const nonExistentId = 'non-existent-folder-id';
|
|
const error = await getError(() => deleteBaseNodeFolder(baseId, nonExistentId));
|
|
|
|
expect(error?.status).toBeGreaterThanOrEqual(400);
|
|
});
|
|
|
|
it('should handle deletion of already deleted folder', async () => {
|
|
// Create and delete a folder
|
|
const folder = await createBaseNodeFolder(baseId, { name: 'Temp Folder' });
|
|
const folderId = folder.data.id;
|
|
await deleteBaseNodeFolder(baseId, folderId);
|
|
|
|
// Try to delete again
|
|
const error = await getError(() => deleteBaseNodeFolder(baseId, folderId));
|
|
expect(error?.status).toBeGreaterThanOrEqual(400);
|
|
});
|
|
});
|
|
|
|
describe('Integration tests', () => {
|
|
it('should create, update and delete folder in sequence', async () => {
|
|
// Create
|
|
const createResponse = await createBaseNodeFolder(baseId, { name: 'Integration Folder' });
|
|
expect(createResponse.data.name).toContain('Integration Folder');
|
|
const folderId = createResponse.data.id;
|
|
|
|
// Update
|
|
const newName = getRandomString(10);
|
|
const updateResponse = await updateBaseNodeFolder(baseId, folderId, {
|
|
name: newName,
|
|
});
|
|
expect(updateResponse.data.name).toContain(newName);
|
|
|
|
// Delete
|
|
await deleteBaseNodeFolder(baseId, folderId);
|
|
|
|
const findFolderAfterDelete = await prisma.baseNodeFolder.findFirst({
|
|
where: { id: folderId },
|
|
});
|
|
expect(findFolderAfterDelete).toBeNull();
|
|
});
|
|
});
|
|
});
|