chore: add test

This commit is contained in:
Nathan
2026-01-04 10:33:19 +08:00
parent cf40d9c751
commit 5f91977cf2
11 changed files with 1571 additions and 13 deletions

2
.gitignore vendored
View File

@@ -42,3 +42,5 @@ cypress/downloads
*storybook.log
storybook-static
**/docs/context

View File

@@ -0,0 +1,453 @@
/**
* Filter and Sort Combined Operations Tests
*
* These tests verify that filter and sort operations work correctly together,
* mirroring the desktop Flutter integration tests from grid_filter_and_sort_test.dart.
*
* Data Structure (mirrors v020.afdb):
* - Grid with Name (text) and Number fields
* - 3 rows with values: Name (A, B, C) and Number (30, 10, 20)
*
* Focus: Single-grid operations with text/number fields to ensure stability.
*/
import { FieldType, waitForReactUpdate, DatabaseFilterSelectors, DatabaseGridSelectors } from '../../support/selectors';
import {
generateRandomEmail,
setupFilterSortTest,
loginAndCreateGrid,
addNewProperty,
typeTextIntoCell,
getLastFieldId,
getFieldIdByName,
getCellsForField,
assertFilterExists,
assertNoFilters,
assertSortExists,
assertNoSorts,
assertRowCount,
} from '../../support/filter-sort-helpers';
/**
* Helper to create a text filter with "Contains" condition (default)
*/
const createTextFilter = (fieldName: string, filterValue?: string) => {
cy.log(`Creating text filter for field: ${fieldName}`);
DatabaseFilterSelectors.filterButton().click({ force: true });
waitForReactUpdate(500);
cy.get('.appflowy-scroller')
.contains(fieldName)
.click({ force: true });
waitForReactUpdate(800);
if (filterValue) {
DatabaseFilterSelectors.filterInput()
.should('be.visible')
.clear()
.type(filterValue, { delay: 30 });
waitForReactUpdate(500);
}
};
/**
* Helper to create a sort on a field
*/
const createFieldSort = (fieldName: string) => {
cy.log(`Creating sort for field: ${fieldName}`);
DatabaseFilterSelectors.sortButton().click({ force: true });
waitForReactUpdate(500);
cy.get('.appflowy-scroller')
.contains(fieldName)
.click({ force: true });
waitForReactUpdate(800);
};
/**
* Helper to open an existing filter's menu
*/
const openExistingFilter = () => {
DatabaseFilterSelectors.filterCondition().click({ force: true });
waitForReactUpdate(500);
};
/**
* Helper to delete the current filter
*/
const deleteFilter = () => {
DatabaseFilterSelectors.deleteFilterButton().click({ force: true });
waitForReactUpdate(500);
};
/**
* Helper to open the sort menu
*/
const openSortMenu = () => {
DatabaseFilterSelectors.sortCondition().click({ force: true });
waitForReactUpdate(500);
};
/**
* Helper to delete the current sort
*/
const deleteSort = () => {
DatabaseFilterSelectors.deleteSortButton().click({ force: true });
waitForReactUpdate(500);
};
/**
* Close any open popover
*/
const closePopover = () => {
cy.get('body').type('{esc}');
waitForReactUpdate(300);
};
/**
* Helper to set up test data matching v020 fixture pattern:
* - Name field: A, B, C
* - Number field: values passed as parameter
*/
const setupTestData = (numberValues: string[]) => {
// Get the Name field ID (first/primary field)
getFieldIdByName('Name').as('nameFieldId');
// Add a Number field
addNewProperty(FieldType.Number);
getLastFieldId().as('numberFieldId');
// Fill in the Name column with A, B, C (like v020 fixture)
cy.get<string>('@nameFieldId').then((nameFieldId) => {
typeTextIntoCell(nameFieldId, 0, 'A');
typeTextIntoCell(nameFieldId, 1, 'B');
typeTextIntoCell(nameFieldId, 2, 'C');
});
// Fill in the Number column
cy.get<string>('@numberFieldId').then((numberFieldId) => {
numberValues.forEach((value, index) => {
typeTextIntoCell(numberFieldId, index, value);
});
});
// Wait for all values to be saved
waitForReactUpdate(1000);
};
/**
* Helper to get cell values for a field with retry support
*/
const verifyCellValues = (fieldId: string, expectedValues: string[]) => {
// Use retrying assertion to wait for values
getCellsForField(fieldId).should(($cells) => {
const values: string[] = [];
$cells.each((_i, el) => values.push((el.textContent || '').trim()));
expect(values.length).to.be.at.least(expectedValues.length);
expectedValues.forEach((expected, i) => {
expect(values[i], `Cell at index ${i}`).to.equal(expected);
});
});
};
/**
* Helper to verify the first cell value (most common case for sort verification)
*/
const verifyFirstCellValue = (fieldId: string, expectedValue: string) => {
getCellsForField(fieldId).first().should(($cell) => {
const value = ($cell.text() || '').trim();
expect(value).to.equal(expectedValue);
});
};
describe('Filter and Sort Combined Operations', () => {
beforeEach(() => {
setupFilterSortTest();
});
describe('Sequential filter and sort operations', () => {
/**
* Test: delete sort with active filter
* Desktop equivalent: grid_filter_and_sort_test.dart - "delete sort with active filter"
*
* Creates a filter, then a sort, then deletes the sort.
* Verifies the filter remains active after sort deletion.
*/
it('should delete sort while filter remains active', () => {
const testEmail = generateRandomEmail();
loginAndCreateGrid(testEmail);
// Set up test data matching v020 fixture
setupTestData(['30', '10', '20']);
// Create a text filter on the Name column - filter for names containing "A" or "B" or "C"
// Use empty filter value to match all (or use a common character)
createTextFilter('Name', 'A');
closePopover();
// Verify filter exists
assertFilterExists();
// Create a sort on the Number field
createFieldSort('Number');
closePopover();
// Verify sort exists
assertSortExists();
// Delete the sort
openSortMenu();
deleteSort();
// Verify sort is gone but filter remains
assertNoSorts();
assertFilterExists();
});
/**
* Test: delete filter with active sort
* Desktop equivalent: grid_filter_and_sort_test.dart - "delete filter with active sort"
*/
it('should delete filter while sort remains active', () => {
const testEmail = generateRandomEmail();
loginAndCreateGrid(testEmail);
// Set up test data
setupTestData(['30', '10', '20']);
// Create sort first
createFieldSort('Number');
closePopover();
assertSortExists();
// Create filter - use a filter that matches at least one row
createTextFilter('Name', 'A');
closePopover();
assertFilterExists();
// Delete the filter
openExistingFilter();
deleteFilter();
// Verify filter is gone but sort remains
assertNoFilters();
assertSortExists();
});
/**
* Test: apply filter then sort
* Desktop equivalent: grid_filter_and_sort_test.dart - "apply filter then sort"
*
* First applies a filter, then adds a sort.
* Verifies both remain active and row count is unchanged.
*/
it('should apply filter first then add sort', () => {
const testEmail = generateRandomEmail();
loginAndCreateGrid(testEmail);
// Set up test data
setupTestData(['100', '50', '75']);
// Create a text filter on Name - match all rows by using empty string (Contains is default)
// Or use a character that appears in all names
createTextFilter('Name'); // Empty filter matches all
closePopover();
// All 3 rows should still be visible
assertRowCount(3);
assertFilterExists();
// Now add a sort on Number
createFieldSort('Number');
closePopover();
// Both filter and sort should be active
assertFilterExists();
assertSortExists();
// Row count should still be 3 (filter unchanged by adding sort)
assertRowCount(3);
});
/**
* Test: apply sort then filter
* Desktop equivalent: grid_filter_and_sort_test.dart - "apply sort then filter"
*/
it('should apply sort first then add filter', () => {
const testEmail = generateRandomEmail();
loginAndCreateGrid(testEmail);
// Set up test data
setupTestData(['30', '10', '20']);
// Create sort first
createFieldSort('Number');
waitForReactUpdate(1000);
// Verify ascending order (default): 10, 20, 30
cy.get<string>('@numberFieldId').then((fieldId) => {
verifyFirstCellValue(fieldId, '10');
});
closePopover();
assertSortExists();
// Now add a text filter - empty filter keeps all rows
createTextFilter('Name');
closePopover();
// All rows should still be visible
assertRowCount(3);
// Both should be active
assertFilterExists();
assertSortExists();
});
});
describe('Modifying conditions', () => {
/**
* Test: filter with sort maintains row count
* Desktop equivalent: grid_filter_and_sort_test.dart - "filter with sort maintains row count"
*/
it('should maintain row count when adding sort to filtered view', () => {
const testEmail = generateRandomEmail();
loginAndCreateGrid(testEmail);
// Set up test data
setupTestData(['100', '25', '50']);
// Create filter on Name - empty filter keeps all
createTextFilter('Name');
closePopover();
// Should show all 3 rows
assertRowCount(3);
// Add sort - should not change row count
createFieldSort('Number');
closePopover();
assertRowCount(3);
});
/**
* Test: change sort direction with active filter
* Desktop equivalent: grid_filter_and_sort_test.dart - "change sort direction with active filter"
*/
it('should change sort direction without affecting filter', () => {
const testEmail = generateRandomEmail();
loginAndCreateGrid(testEmail);
// Set up test data
setupTestData(['30', '10', '20']);
// Create filter - empty filter keeps all rows
createTextFilter('Name');
closePopover();
assertRowCount(3);
// Create sort ascending
createFieldSort('Number');
waitForReactUpdate(1000);
// Verify ascending order
cy.get<string>('@numberFieldId').then((fieldId) => {
verifyFirstCellValue(fieldId, '10');
});
// Change to descending - first open the sort menu
openSortMenu();
waitForReactUpdate(500);
// Now click the sort condition button to access the direction options
DatabaseFilterSelectors.sortConditionButton().click({ force: true });
waitForReactUpdate(300);
DatabaseFilterSelectors.sortConditionDesc().click({ force: true });
waitForReactUpdate(1000);
// Verify descending order
cy.get<string>('@numberFieldId').then((fieldId) => {
verifyFirstCellValue(fieldId, '30');
});
closePopover();
// Filter should still be active
assertFilterExists();
assertRowCount(3);
});
});
describe('Combined filter and sort verification', () => {
/**
* Test: verify sorted order with filter active
* Desktop equivalent: grid_filter_and_sort_test.dart - "text filter with number sort"
*/
it('should correctly sort numbers in ascending order with filter active', () => {
const testEmail = generateRandomEmail();
loginAndCreateGrid(testEmail);
// Set up test data
setupTestData(['30', '10', '20']);
// Create filter first - empty filter keeps all
createTextFilter('Name');
closePopover();
// Create sort
createFieldSort('Number');
waitForReactUpdate(1000);
// Verify sorted order: 10, 20, 30
cy.get<string>('@numberFieldId').then((fieldId) => {
verifyCellValues(fieldId, ['10', '20', '30']);
});
closePopover();
assertFilterExists();
assertSortExists();
});
/**
* Test: verify descending sort with filter active
*/
it('should correctly sort numbers in descending order with filter active', () => {
const testEmail = generateRandomEmail();
loginAndCreateGrid(testEmail);
// Set up test data
setupTestData(['30', '10', '20']);
// Create filter - empty filter keeps all
createTextFilter('Name');
closePopover();
assertRowCount(3);
// Create sort
createFieldSort('Number');
waitForReactUpdate(1000);
// Change to descending - first open the sort menu
openSortMenu();
waitForReactUpdate(500);
DatabaseFilterSelectors.sortConditionButton().click({ force: true });
waitForReactUpdate(300);
DatabaseFilterSelectors.sortConditionDesc().click({ force: true });
waitForReactUpdate(1000);
// Verify descending order: 30, 20, 10
cy.get<string>('@numberFieldId').then((fieldId) => {
verifyCellValues(fieldId, ['30', '20', '10']);
});
closePopover();
assertFilterExists();
assertSortExists();
});
});
});

View File

@@ -0,0 +1,304 @@
/**
* Relation Field Filtering Tests
*
* These tests verify that relation field filtering works correctly,
* mirroring the desktop Flutter integration tests from grid_relation_filter_test.dart.
*
* Based on desktop test: 'relation filter supports all conditions'
*
* Note: These tests require the APPFLOWY_ENABLE_RELATION_ROLLUP_EDIT=true environment flag.
* Multi-database tests are skipped due to view sync timing issues that make them flaky.
*/
import {
AddPageSelectors,
DatabaseFilterSelectors,
DatabaseGridSelectors,
GridFieldSelectors,
PropertyMenuSelectors,
FieldType,
waitForReactUpdate,
} from '../../support/selectors';
import { AuthTestUtils } from '../../support/auth-utils';
import { generateRandomEmail } from '../../support/test-config';
const isRelationRollupEditEnabled = Cypress.env('APPFLOWY_ENABLE_RELATION_ROLLUP_EDIT') === 'true';
const describeIfEnabled = isRelationRollupEditEnabled ? describe : describe.skip;
/**
* Helper: Login and create a new grid
*/
const loginAndCreateGrid = (email: string) => {
cy.visit('/login', { failOnStatusCode: false });
cy.wait(1500);
const authUtils = new AuthTestUtils();
return authUtils.signInWithTestUrl(email).then(() => {
cy.url({ timeout: 30000 }).should('include', '/app');
cy.wait(4000);
AddPageSelectors.inlineAddButton().first().click({ force: true });
waitForReactUpdate(800);
AddPageSelectors.addGridButton().should('exist').click({ force: true });
cy.wait(7000);
DatabaseGridSelectors.grid().should('exist');
DatabaseGridSelectors.cells().should('have.length.greaterThan', 0);
});
};
/**
* Helper: Add a relation field
*/
const addRelationField = () => {
PropertyMenuSelectors.newPropertyButton().first().scrollIntoView().click({ force: true });
waitForReactUpdate(1200);
// Wait for dropdown to open
cy.get('[data-radix-popper-content-wrapper]', { timeout: 10000 }).should('be.visible');
PropertyMenuSelectors.propertyTypeTrigger().first().click({ force: true });
waitForReactUpdate(600);
PropertyMenuSelectors.propertyTypeOption(FieldType.Relation).scrollIntoView().click({ force: true });
waitForReactUpdate(800);
cy.get('body').type('{esc}{esc}');
waitForReactUpdate(500);
};
/**
* Helper: Get the relation field ID (assumes it's the last field)
*/
const getRelationFieldId = (): Cypress.Chainable<string> => {
return GridFieldSelectors.allFieldHeaders()
.last()
.invoke('attr', 'data-testid')
.then((testId) => {
return testId?.replace('grid-field-header-', '') || '';
});
};
/**
* Helper: Create a filter on relation field
*/
const createRelationFilter = () => {
cy.log('Creating filter on Relation field');
// Click filter button
DatabaseFilterSelectors.filterButton().click({ force: true });
waitForReactUpdate(500);
// Select the Relation field from the picker
cy.get('.appflowy-scroller')
.contains('Relation')
.click({ force: true });
waitForReactUpdate(800);
};
/**
* Helper: Select a filter condition from the dropdown
*/
const selectFilterCondition = (conditionText: string) => {
cy.log(`Selecting filter condition: ${conditionText}`);
// Find the text filter container and click its condition dropdown
cy.get('[data-testid="text-filter"]')
.find('button')
.first()
.click({ force: true });
waitForReactUpdate(300);
// Select from dropdown - use case-insensitive matching
cy.get('[role="menuitem"]')
.contains(new RegExp(conditionText, 'i'))
.click({ force: true });
waitForReactUpdate(500);
};
/**
* Helper: Close filter menu
*/
const closeFilterMenu = () => {
cy.get('body').type('{esc}');
waitForReactUpdate(300);
};
/**
* Helper: Assert row count
*/
const assertRowCount = (expectedCount: number) => {
cy.log(`Asserting row count: ${expectedCount}`);
DatabaseGridSelectors.dataRows().should('have.length', expectedCount);
};
/**
* Helper: Assert filter exists
*/
const assertFilterExists = () => {
DatabaseFilterSelectors.filterCondition().should('exist').and('be.visible');
};
describeIfEnabled('Relation Field Filtering', () => {
beforeEach(() => {
cy.on('uncaught:exception', (err) => {
if (
err.message.includes('Minified React error') ||
err.message.includes('View not found') ||
err.message.includes('No workspace or service found')
) {
return false;
}
return true;
});
cy.viewport(1280, 900);
});
describe('Relation filter conditions (single-grid)', () => {
/**
* Test: Filter by RelationIsNotEmpty
*
* Creates a grid with a Relation field and tests filtering by "is not empty".
* Since we can't easily link rows in a single-grid test, we test that:
* 1. The filter can be created
* 2. The "is not empty" condition is available
* 3. With no linked relations, all rows should be filtered out
*/
it('should filter by RelationIsNotEmpty condition', () => {
const testEmail = generateRandomEmail();
loginAndCreateGrid(testEmail);
// Add relation field
addRelationField();
// Create filter on relation field
createRelationFilter();
// Select "is not empty" condition
selectFilterCondition('is not empty');
closeFilterMenu();
// Since no relations are linked, all rows should be filtered out
assertFilterExists();
// With "is not empty" filter and no relations, should show 0 rows
// (3 default rows all have empty relation cells)
assertRowCount(0);
});
/**
* Test: Filter by RelationIsEmpty
*
* Creates a grid with a Relation field and tests filtering by "is empty".
* Since no rows have linked relations, all rows should pass this filter.
*/
it('should filter by RelationIsEmpty condition', () => {
const testEmail = generateRandomEmail();
loginAndCreateGrid(testEmail);
// Add relation field
addRelationField();
// Create filter on relation field
createRelationFilter();
// Select "is empty" condition
selectFilterCondition('is empty');
closeFilterMenu();
// All rows should pass (no relations linked)
assertFilterExists();
// With "is empty" filter and no relations, should show all 3 rows
assertRowCount(3);
});
/**
* Test: Filter by RelationContains (text search)
*
* Note: Relation field filtering uses text-based filtering
* (searching for relation content by text).
* This tests that the contains filter works on the relation field.
*/
it('should support RelationContains filter with text search', () => {
const testEmail = generateRandomEmail();
loginAndCreateGrid(testEmail);
// Add relation field
addRelationField();
// Create filter on relation field
createRelationFilter();
// Select "contains" condition
selectFilterCondition('contains');
// Set a filter value (won't match anything since no relations are linked)
DatabaseFilterSelectors.filterInput()
.should('be.visible')
.clear()
.type('NonExistentRow', { delay: 30 });
closeFilterMenu();
// No rows should match (no relations contain this text)
assertFilterExists();
assertRowCount(0);
});
/**
* Test: Filter by RelationDoesNotContain
*
* Tests the "does not contain" filter on relation fields.
* Since no rows have linked relations with the search text,
* all rows should pass this filter.
*/
it('should support RelationDoesNotContain filter', () => {
const testEmail = generateRandomEmail();
loginAndCreateGrid(testEmail);
// Add relation field
addRelationField();
// Create filter on relation field
createRelationFilter();
// Select "does not contain" condition
selectFilterCondition('does not contain');
// Set a filter value
DatabaseFilterSelectors.filterInput()
.should('be.visible')
.clear()
.type('SomeText', { delay: 30 });
closeFilterMenu();
// All rows should match (no relations contain this text)
assertFilterExists();
assertRowCount(3);
});
});
// Multi-database tests are skipped due to view sync timing issues
describe.skip('Relation filter with linked data', () => {
/**
* Test: Filter linked rows by name
*
* This test would:
* 1. Create two databases
* 2. Link rows from source to target
* 3. Filter by relation content
*
* SKIPPED: Multi-database tests are flaky due to view sync timing issues
*/
it('should filter linked rows by name', () => {
// Implementation would require stable multi-database setup
});
/**
* Test: Handle deleted linked records
*
* SKIPPED: Multi-database tests are flaky due to view sync timing issues
*/
it('should handle deleted linked records gracefully', () => {
// Implementation would require stable multi-database setup
});
});
});

View File

@@ -0,0 +1,351 @@
/**
* Rollup Field Calculation Tests
*
* These tests verify rollup field filtering and sorting functionality,
* mirroring aspects of desktop Flutter integration tests from:
* - database_rollup_real_case_test.dart
* - grid_filter_and_sort_test.dart
*
* Note: These tests require the APPFLOWY_ENABLE_RELATION_ROLLUP_EDIT=true environment flag.
* Multi-database tests are skipped due to view sync timing issues that make them flaky.
* These tests focus on single-grid scenarios to test filter/sort UI with rollup fields.
*/
import {
AddPageSelectors,
DatabaseFilterSelectors,
DatabaseGridSelectors,
GridFieldSelectors,
PropertyMenuSelectors,
FieldType,
byTestId,
waitForReactUpdate,
} from '../../support/selectors';
import { AuthTestUtils } from '../../support/auth-utils';
import { generateRandomEmail } from '../../support/test-config';
const waitForAppReady = () => {
cy.get(`${byTestId('inline-add-page')}, ${byTestId('new-page-button')}`, { timeout: 20000 }).should('be.visible');
};
const isRelationRollupEditEnabled = Cypress.env('APPFLOWY_ENABLE_RELATION_ROLLUP_EDIT') === 'true';
const describeIfEnabled = isRelationRollupEditEnabled ? describe : describe.skip;
/**
* Helper: Login and create a new grid
*/
const loginAndCreateGrid = (email: string) => {
cy.visit('/login', { failOnStatusCode: false });
cy.wait(1500);
const authUtils = new AuthTestUtils();
return authUtils.signInWithTestUrl(email).then(() => {
cy.url({ timeout: 30000 }).should('include', '/app');
cy.wait(4000);
AddPageSelectors.inlineAddButton().first().click({ force: true });
waitForReactUpdate(800);
AddPageSelectors.addGridButton().should('exist').click({ force: true });
cy.wait(7000);
DatabaseGridSelectors.grid().should('exist');
DatabaseGridSelectors.cells().should('have.length.greaterThan', 0);
});
};
/**
* Helper: Add a relation field
*/
const addRelationField = () => {
PropertyMenuSelectors.newPropertyButton().first().scrollIntoView().click({ force: true });
waitForReactUpdate(1200);
cy.get('[data-radix-popper-content-wrapper]', { timeout: 10000 }).should('be.visible');
PropertyMenuSelectors.propertyTypeTrigger().first().click({ force: true });
waitForReactUpdate(600);
PropertyMenuSelectors.propertyTypeOption(FieldType.Relation).scrollIntoView().click({ force: true });
waitForReactUpdate(800);
cy.get('body').type('{esc}{esc}');
waitForReactUpdate(500);
};
/**
* Helper: Add a rollup field
*/
const addRollupField = () => {
PropertyMenuSelectors.newPropertyButton().first().scrollIntoView().click({ force: true });
waitForReactUpdate(1200);
cy.get('[data-radix-popper-content-wrapper]', { timeout: 10000 }).should('be.visible');
PropertyMenuSelectors.propertyTypeTrigger().first().click({ force: true });
waitForReactUpdate(600);
PropertyMenuSelectors.propertyTypeOption(FieldType.Rollup).scrollIntoView().click({ force: true });
waitForReactUpdate(800);
// Don't close the menu - leave it open for configuration
};
/**
* Helper: Get the rollup field ID (assumes it's the last field)
*/
const getRollupFieldId = (): Cypress.Chainable<string> => {
return GridFieldSelectors.allFieldHeaders()
.last()
.invoke('attr', 'data-testid')
.then((testId) => {
return testId?.replace('grid-field-header-', '') || '';
});
};
/**
* Helper: Assert row count
*/
const assertRowCount = (expectedCount: number) => {
cy.log(`Asserting row count: ${expectedCount}`);
DatabaseGridSelectors.dataRows().should('have.length', expectedCount);
};
/**
* Helper: Assert filter exists
*/
const assertFilterExists = () => {
DatabaseFilterSelectors.filterCondition().should('exist').and('be.visible');
};
/**
* Helper: Assert sort exists
*/
const assertSortExists = () => {
DatabaseFilterSelectors.sortCondition().should('exist').and('be.visible');
};
describeIfEnabled('Rollup Field Calculations', () => {
beforeEach(() => {
cy.on('uncaught:exception', (err) => {
if (
err.message.includes('Minified React error') ||
err.message.includes('View not found') ||
err.message.includes('No workspace or service found')
) {
return false;
}
return true;
});
cy.viewport(1280, 900);
});
describe('Rollup field setup (single-grid)', () => {
/**
* Test: Rollup field can be created alongside relation field
*
* This test verifies the basic setup flow for rollup fields:
* 1. Create a grid
* 2. Add a Relation field (prerequisite for rollup)
* 3. Add a Rollup field
* 4. Verify rollup configuration options are available
*/
it('should create rollup field with relation field', () => {
const testEmail = generateRandomEmail();
loginAndCreateGrid(testEmail);
// Add relation field first (required for rollup)
addRelationField();
// Add rollup field
addRollupField();
// Verify rollup configuration is shown
cy.get('[data-radix-popper-content-wrapper]', { timeout: 10000 })
.should('be.visible')
.within(() => {
cy.contains('Relation').should('exist');
cy.contains('Property').should('exist');
cy.contains('Calculation').should('exist');
cy.contains('Show as').should('exist');
});
// Close the menu
cy.get('body').type('{esc}{esc}');
waitForReactUpdate(500);
// Verify both fields exist in the grid
GridFieldSelectors.allFieldHeaders().should('have.length.at.least', 3); // Name + Relation + Rollup
});
/**
* Test: Rollup field shows "Select relation field" when unconfigured
*
* This verifies the default state of an unconfigured rollup field.
*/
it('should show unconfigured state for new rollup field', () => {
const testEmail = generateRandomEmail();
loginAndCreateGrid(testEmail);
// Add relation field first
addRelationField();
// Add rollup field
addRollupField();
// Verify default "Select relation field" prompt is shown
cy.get('[data-radix-popper-content-wrapper]', { timeout: 10000 })
.should('be.visible')
.within(() => {
cy.contains('Select relation field').should('exist');
});
});
});
describe('Rollup filtering (single-grid, UI only)', () => {
/**
* Test: Filter button works with rollup field
*
* This tests that the filter UI properly recognizes rollup fields.
* Note: Without linked data, this tests the UI flow rather than actual filtering.
*/
it('should show rollup field in filter field picker', () => {
const testEmail = generateRandomEmail();
loginAndCreateGrid(testEmail);
// Add relation and rollup fields
addRelationField();
addRollupField();
cy.get('body').type('{esc}{esc}');
waitForReactUpdate(500);
// Click filter button
DatabaseFilterSelectors.filterButton().click({ force: true });
waitForReactUpdate(500);
// Check if Rollup field appears in the picker
cy.get('.appflowy-scroller', { timeout: 5000 })
.should('be.visible')
.within(() => {
cy.contains('Rollup').should('exist');
});
});
/**
* Test: Create filter on rollup field
*
* Tests creating a filter on a rollup field using text-based filtering.
*/
it('should create filter on rollup field', () => {
const testEmail = generateRandomEmail();
loginAndCreateGrid(testEmail);
// Add relation and rollup fields
addRelationField();
addRollupField();
cy.get('body').type('{esc}{esc}');
waitForReactUpdate(500);
// Create filter on rollup field
DatabaseFilterSelectors.filterButton().click({ force: true });
waitForReactUpdate(500);
// Select Rollup field
cy.get('.appflowy-scroller')
.contains('Rollup')
.click({ force: true });
waitForReactUpdate(800);
// Filter menu should open - rollup uses text filter
cy.get('[data-testid="text-filter"]').should('be.visible');
// Close filter menu
cy.get('body').type('{esc}');
waitForReactUpdate(300);
// Verify filter was created
assertFilterExists();
});
});
describe('Rollup sorting (single-grid, UI only)', () => {
/**
* Test: Sort button recognizes sortable rollup fields
*
* Note: Only rollup fields with "Calculated" display (numeric) can be sorted.
* List-type rollups cannot be sorted.
*/
it('should allow sorting on numeric rollup field', () => {
const testEmail = generateRandomEmail();
loginAndCreateGrid(testEmail);
// Add relation and rollup fields
addRelationField();
addRollupField();
cy.get('body').type('{esc}{esc}');
waitForReactUpdate(500);
// Click sort button
DatabaseFilterSelectors.sortButton().click({ force: true });
waitForReactUpdate(500);
// Check if Rollup field appears in the sort picker
// Note: Rollup must be configured with "Calculated" display to be sortable
cy.get('.appflowy-scroller', { timeout: 5000 }).should('be.visible');
// The rollup field should appear in the list since it defaults to "Count" + "Calculated"
cy.get('.appflowy-scroller').then(($scroller) => {
const hasRollup = $scroller.text().includes('Rollup');
if (hasRollup) {
cy.log('[INFO] Rollup field is sortable (default Count + Calculated)');
cy.get('.appflowy-scroller').contains('Rollup').click({ force: true });
waitForReactUpdate(800);
assertSortExists();
} else {
cy.log('[INFO] Rollup field not sortable - may need configuration');
}
});
});
});
// Multi-database tests are skipped due to view sync timing issues
describe.skip('Rollup with linked data', () => {
/**
* Test: Display rollup count from related rows
*
* SKIPPED: Multi-database tests are flaky due to view sync timing issues
*/
it('should display rollup count from related rows', () => {
// Implementation would require stable multi-database setup
});
/**
* Test: Filter by rollup field value
*
* SKIPPED: Multi-database tests are flaky due to view sync timing issues
*/
it('should filter by rollup field value', () => {
// Implementation would require linked data
});
/**
* Test: Sort by numeric rollup field
*
* SKIPPED: Multi-database tests are flaky due to view sync timing issues
*/
it('should sort by numeric rollup field', () => {
// Implementation would require linked data
});
/**
* Test: Update rollup when related data changes
*
* SKIPPED: Multi-database tests are flaky due to view sync timing issues
*/
it('should update rollup when related data changes', () => {
// Implementation would require linked data
});
/**
* Test: Update rollup when relation is removed
*
* SKIPPED: Multi-database tests are flaky due to view sync timing issues
*/
it('should update rollup when relation is removed', () => {
// Implementation would require linked data
});
});
});

View File

@@ -0,0 +1,422 @@
/**
* Shared helpers for filter and sort E2E tests.
* These helpers handle common filter/sort operations to avoid code duplication.
*/
import 'cypress-real-events';
import { AuthTestUtils } from './auth-utils';
import {
AddPageSelectors,
DatabaseFilterSelectors,
DatabaseGridSelectors,
GridFieldSelectors,
PropertyMenuSelectors,
waitForReactUpdate,
FieldType,
} from './selectors';
import { generateRandomEmail } from './test-config';
// Re-export for convenience
export { generateRandomEmail, FieldType };
/**
* Common beforeEach setup for filter/sort tests
*/
export const setupFilterSortTest = () => {
cy.on('uncaught:exception', (err) => {
if (
err.message.includes('Minified React error') ||
err.message.includes('View not found') ||
err.message.includes('No workspace or service found')
) {
return false;
}
return true;
});
// Use a taller viewport for dropdown visibility
cy.viewport(1280, 900);
};
/**
* Login and create a new grid for testing
*/
export const loginAndCreateGrid = (email: string) => {
cy.visit('/login', { failOnStatusCode: false });
cy.wait(1500);
const authUtils = new AuthTestUtils();
return authUtils.signInWithTestUrl(email).then(() => {
cy.url({ timeout: 30000 }).should('include', '/app');
cy.wait(4000);
// Create a new grid
AddPageSelectors.inlineAddButton().first().click({ force: true });
waitForReactUpdate(800);
AddPageSelectors.addGridButton().should('exist').click({ force: true });
cy.wait(7000);
DatabaseGridSelectors.grid().should('exist');
DatabaseGridSelectors.cells().should('have.length.greaterThan', 0);
});
};
/**
* Helper to extract fieldId from a field header's data-testid
* Format: grid-field-header-{fieldId}
*/
export const getLastFieldId = (): Cypress.Chainable<string> => {
return GridFieldSelectors.allFieldHeaders()
.last()
.invoke('attr', 'data-testid')
.then((testId) => {
return testId?.replace('grid-field-header-', '') || '';
});
};
/**
* Get field ID by field name
*/
export const getFieldIdByName = (name: string): Cypress.Chainable<string> => {
return GridFieldSelectors.allFieldHeaders()
.contains(name)
.closest('[data-testid^="grid-field-header-"]')
.invoke('attr', 'data-testid')
.then((testId) => {
return testId?.replace('grid-field-header-', '') || '';
});
};
/**
* Helper to get all cells for a specific field (column)
*/
export const getCellsForField = (fieldId: string) => {
return DatabaseGridSelectors.cellsForField(fieldId);
};
/**
* Helper to get the clickable row cell wrapper for a field (column) - DATA ROWS ONLY
*/
export const getDataRowCellsForField = (fieldId: string) => {
return DatabaseGridSelectors.dataRowCellsForField(fieldId);
};
/**
* Add a new property/field of the specified type
*/
export const addNewProperty = (fieldType: number) => {
PropertyMenuSelectors.newPropertyButton().first().scrollIntoView().click({ force: true });
waitForReactUpdate(1200);
PropertyMenuSelectors.propertyTypeTrigger().first().realHover();
waitForReactUpdate(600);
PropertyMenuSelectors.propertyTypeOption(fieldType).scrollIntoView().click({ force: true });
waitForReactUpdate(800);
cy.get('body').type('{esc}');
waitForReactUpdate(500);
};
/**
* Type text into a cell and save it.
* Uses Enter to save (works for both text and number cells).
*/
export const typeTextIntoCell = (fieldId: string, cellIndex: number, text: string): void => {
cy.log(`typeTextIntoCell: field=${fieldId}, dataRowIndex=${cellIndex}, text=${text}`);
DatabaseGridSelectors.dataRowCellsForField(fieldId)
.eq(cellIndex)
.should('be.visible')
.scrollIntoView()
.click()
.click();
cy.get('textarea:visible', { timeout: 8000 })
.should('exist')
.first()
.clear()
.type(text, { delay: 30 })
.type('{enter}'); // Use Enter to save the value (works for text and number cells)
cy.wait(500);
};
/**
* Click a checkbox cell to toggle it
*/
export const clickCheckboxCell = (fieldId: string, cellIndex: number): void => {
cy.log(`clickCheckboxCell: field=${fieldId}, dataRowIndex=${cellIndex}`);
DatabaseGridSelectors.dataRowCellsForField(fieldId)
.eq(cellIndex)
.should('be.visible')
.scrollIntoView()
.click({ force: true });
waitForReactUpdate(500);
};
// ============== FILTER HELPERS ==============
/**
* Create a filter by clicking the filter button and selecting a field.
* This opens the PropertiesMenu, selects the field, then the filter menu opens.
*/
export const createFilter = (fieldName: string) => {
cy.log(`Creating filter for field: ${fieldName}`);
// Click filter button to show field picker
DatabaseFilterSelectors.filterButton().click({ force: true });
waitForReactUpdate(500);
// Select the field from the picker - find by field name in the popover
cy.get('.appflowy-scroller')
.contains(fieldName)
.click({ force: true });
waitForReactUpdate(800);
};
/**
* Add another filter when filters already exist
*/
export const addAnotherFilter = (fieldName: string) => {
cy.log(`Adding another filter for field: ${fieldName}`);
// Click the add filter button
DatabaseFilterSelectors.addFilterButton().click({ force: true });
waitForReactUpdate(500);
// Select the field from the picker
cy.get('.appflowy-scroller')
.contains(fieldName)
.click({ force: true });
waitForReactUpdate(800);
};
/**
* Open an existing filter's menu by clicking on its condition pill
*/
export const openFilterMenu = (index = 0) => {
cy.log(`Opening filter menu at index: ${index}`);
DatabaseFilterSelectors.filterCondition().eq(index).click({ force: true });
waitForReactUpdate(500);
};
/**
* Change the filter condition by selecting from the dropdown.
* Assumes the filter menu is already open.
* @param conditionText - The visible text of the condition (e.g., "contains", "is empty")
*/
export const selectFilterCondition = (conditionText: string) => {
cy.log(`Selecting filter condition: ${conditionText}`);
// Find the condition dropdown trigger and click it
cy.get('[data-testid="text-filter"]')
.find('button')
.first()
.click({ force: true });
waitForReactUpdate(300);
// Select the condition from dropdown
cy.get('[role="menuitem"]')
.contains(conditionText)
.click({ force: true });
waitForReactUpdate(500);
};
/**
* Set the filter value in the text input.
* Assumes the filter menu is already open.
*/
export const setFilterValue = (value: string) => {
cy.log(`Setting filter value: ${value}`);
DatabaseFilterSelectors.filterInput()
.should('be.visible')
.clear()
.type(value, { delay: 30 });
waitForReactUpdate(500);
};
/**
* Delete a filter by clicking the delete button in the filter menu.
* Assumes the filter menu is already open.
*/
export const deleteCurrentFilter = () => {
cy.log('Deleting current filter');
DatabaseFilterSelectors.deleteFilterButton().click({ force: true });
waitForReactUpdate(500);
};
/**
* Close the filter menu by pressing Escape
*/
export const closeFilterMenu = () => {
cy.get('body').type('{esc}');
waitForReactUpdate(300);
};
// ============== SORT HELPERS ==============
/**
* Create a sort by clicking the sort button and selecting a field.
*/
export const createSort = (fieldName: string) => {
cy.log(`Creating sort for field: ${fieldName}`);
// Click sort button to show field picker
DatabaseFilterSelectors.sortButton().click({ force: true });
waitForReactUpdate(500);
// Select the field from the picker
cy.get('.appflowy-scroller')
.contains(fieldName)
.click({ force: true });
waitForReactUpdate(800);
};
/**
* Add another sort when sorts already exist
*/
export const addAnotherSort = (fieldName: string) => {
cy.log(`Adding another sort for field: ${fieldName}`);
// Click the sort condition to open the menu
DatabaseFilterSelectors.sortCondition().click({ force: true });
waitForReactUpdate(500);
// Click the add sort button
DatabaseFilterSelectors.addSortButton().click({ force: true });
waitForReactUpdate(500);
// Select the field from the picker
cy.get('.appflowy-scroller')
.contains(fieldName)
.click({ force: true });
waitForReactUpdate(800);
};
/**
* Open the sort menu by clicking on the sort condition pill
*/
export const openSortMenu = () => {
cy.log('Opening sort menu');
DatabaseFilterSelectors.sortCondition().click({ force: true });
waitForReactUpdate(500);
};
/**
* Change the sort direction to ascending
*/
export const setSortAscending = () => {
cy.log('Setting sort to ascending');
DatabaseFilterSelectors.sortConditionButton().click({ force: true });
waitForReactUpdate(300);
DatabaseFilterSelectors.sortConditionAsc().click({ force: true });
waitForReactUpdate(500);
};
/**
* Change the sort direction to descending
*/
export const setSortDescending = () => {
cy.log('Setting sort to descending');
DatabaseFilterSelectors.sortConditionButton().click({ force: true });
waitForReactUpdate(300);
DatabaseFilterSelectors.sortConditionDesc().click({ force: true });
waitForReactUpdate(500);
};
/**
* Delete the current sort by clicking the delete button
*/
export const deleteCurrentSort = () => {
cy.log('Deleting current sort');
DatabaseFilterSelectors.deleteSortButton().click({ force: true });
waitForReactUpdate(500);
};
/**
* Delete all sorts
*/
export const deleteAllSorts = () => {
cy.log('Deleting all sorts');
// Open sort menu first
DatabaseFilterSelectors.sortCondition().click({ force: true });
waitForReactUpdate(300);
DatabaseFilterSelectors.deleteAllSortsButton().click({ force: true });
waitForReactUpdate(500);
};
/**
* Close the sort menu by pressing Escape
*/
export const closeSortMenu = () => {
cy.get('body').type('{esc}');
waitForReactUpdate(300);
};
// ============== ASSERTION HELPERS ==============
/**
* Assert the number of visible data rows in the grid
*/
export const assertRowCount = (expectedCount: number) => {
cy.log(`Asserting row count: ${expectedCount}`);
DatabaseGridSelectors.dataRows().should('have.length', expectedCount);
};
/**
* Assert that a filter condition pill is visible
*/
export const assertFilterExists = () => {
DatabaseFilterSelectors.filterCondition().should('exist').and('be.visible');
};
/**
* Assert that no filter condition pills are visible
*/
export const assertNoFilters = () => {
DatabaseFilterSelectors.filterCondition().should('not.exist');
};
/**
* Assert that a sort condition pill is visible
*/
export const assertSortExists = () => {
DatabaseFilterSelectors.sortCondition().should('exist').and('be.visible');
};
/**
* Assert that no sort condition pills are visible
*/
export const assertNoSorts = () => {
DatabaseFilterSelectors.sortCondition().should('not.exist');
};
/**
* Get the text content of cells in a column and return as array
*/
export const getCellValues = (fieldId: string): Cypress.Chainable<string[]> => {
return getCellsForField(fieldId).then(($cells) => {
const values: string[] = [];
$cells.each((_i, el) => values.push((el.textContent || '').trim()));
return values;
});
};
/**
* Assert that the rows are sorted in a specific order based on primary column text
*/
export const assertRowOrder = (expectedOrder: string[]) => {
cy.log(`Asserting row order: ${expectedOrder.join(', ')}`);
// Get the primary column (first field) values
GridFieldSelectors.allFieldHeaders()
.first()
.invoke('attr', 'data-testid')
.then((testId) => {
const fieldId = testId?.replace('grid-field-header-', '') || '';
getCellsForField(fieldId).then(($cells) => {
const actualValues: string[] = [];
$cells.each((_i, el) => actualValues.push((el.textContent || '').trim()));
expectedOrder.forEach((expected, index) => {
expect(actualValues[index]).to.include(expected);
});
});
});
};

View File

@@ -452,31 +452,51 @@ export const DatabaseViewSelectors = {
* Database Filter & Sort selectors
*/
export const DatabaseFilterSelectors = {
// Filter button (opens filter menu)
// Filter button (opens filter menu - shows field picker when no filters exist)
filterButton: () => cy.get(byTestId('database-actions-filter')),
// Add filter button (plus button in DatabaseConditions area to add new filter condition)
// Add filter button (plus button in filter area to add another filter condition)
addFilterButton: () => cy.get(byTestId('database-add-filter-button')),
// Sort button
// Sort button (opens sort menu - shows field picker when no sorts exist)
sortButton: () => cy.get(byTestId('database-actions-sort')),
// Filter condition row
// Filter condition pill (clickable to open filter menu)
filterCondition: () => cy.get(byTestId('database-filter-condition')),
// Sort condition row
// Sort condition pill (clickable to open sort menu)
sortCondition: () => cy.get(byTestId('database-sort-condition')),
// Remove filter button (inside condition)
removeFilterButton: () =>
cy
.get(
'button[aria-label*="remove"], button[aria-label*="delete"], button:contains("×"), svg[class*="close"], svg[class*="x"]'
)
.first(),
// Individual sort condition row
sortConditionRow: () => cy.get(byTestId('sort-condition')),
// Filter input
// Delete filter button (inside filter menu)
deleteFilterButton: () => cy.get(byTestId('delete-filter-button')),
// Delete sort button (inside sort row)
deleteSortButton: () => cy.get(byTestId('delete-sort-button')),
// Add sort button (inside sort menu)
addSortButton: () => cy.get(byTestId('add-sort-button')),
// Delete all sorts button
deleteAllSortsButton: () => cy.get(byTestId('delete-all-sorts-button')),
// Sort condition button (shows Ascending/Descending)
sortConditionButton: () => cy.get(byTestId('sort-condition-button')),
// Sort direction options
sortConditionAsc: () => cy.get(byTestId('sort-condition-asc')),
sortConditionDesc: () => cy.get(byTestId('sort-condition-desc')),
// Text filter container
textFilter: () => cy.get(byTestId('text-filter')),
// Filter input for text filters
filterInput: () => cy.get(byTestId('text-filter-input')),
// Date filter container
dateFilter: () => cy.get(byTestId('date-filter')),
};
/**

View File

@@ -31,6 +31,7 @@ function FieldMenuTitle ({ filterId, fieldId, renderConditionSelect }: {
<Tooltip>
<TooltipTrigger asChild>
<Button
data-testid="delete-filter-button"
size={'icon-sm'}
onClick={(e) => {
e.stopPropagation();

View File

@@ -53,6 +53,7 @@ function ConditionMenu ({
{conditions.map(condition => (
<DropdownMenuItem
key={condition.id}
data-testid={`sort-condition-${condition.id === SortCondition.Ascending ? 'asc' : 'desc'}`}
className={selected === condition.id ? 'bg-accent' : ''}
onSelect={() => {
updateSort({

View File

@@ -32,6 +32,7 @@ function Sort ({ sortId }: { sortId: string }) {
</Button>
<SortCondition sort={sort} />
{readOnly ? null : <Button
data-testid="delete-sort-button"
size={'icon-sm'}
onClick={(e) => {
e.stopPropagation();

View File

@@ -22,6 +22,7 @@ function SortCondition ({ sort }: { sort: Sort }) {
return (
<Button
data-testid="sort-condition-button"
variant={'outline'}
size={'sm'}
onClick={() => {

View File

@@ -96,6 +96,7 @@ export function Sorts () {
onOpenChange={setOpenPropertiesMenu}
>
<Button
data-testid="add-sort-button"
size={'sm'}
onClick={() => setOpenPropertiesMenu(!openPropertiesMenu)}
variant={'ghost'}
@@ -108,6 +109,7 @@ export function Sorts () {
</div>
<Button
data-testid="delete-all-sorts-button"
size={'sm'}
onClick={() => {
deleteAllSorts();