From 5f91977cf2abfa1b056ce2a130f1ec627b968938 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 4 Jan 2026 10:33:19 +0800 Subject: [PATCH] chore: add test --- .gitignore | 2 + .../e2e/database/filter-sort-combined.cy.ts | 453 ++++++++++++++++++ cypress/e2e/database/relation-filter.cy.ts | 304 ++++++++++++ .../e2e/database/rollup-calculations.cy.ts | 351 ++++++++++++++ cypress/support/filter-sort-helpers.ts | 422 ++++++++++++++++ cypress/support/selectors.ts | 46 +- .../filters/filter-menu/FieldMenuTitle.tsx | 1 + .../components/sorts/ConditionMenu.tsx | 1 + .../database/components/sorts/Sort.tsx | 1 + .../components/sorts/SortCondition.tsx | 1 + .../database/components/sorts/Sorts.tsx | 2 + 11 files changed, 1571 insertions(+), 13 deletions(-) create mode 100644 cypress/e2e/database/filter-sort-combined.cy.ts create mode 100644 cypress/e2e/database/relation-filter.cy.ts create mode 100644 cypress/e2e/database/rollup-calculations.cy.ts create mode 100644 cypress/support/filter-sort-helpers.ts diff --git a/.gitignore b/.gitignore index 0e9b7f8a..41292912 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,5 @@ cypress/downloads *storybook.log storybook-static + +**/docs/context \ No newline at end of file diff --git a/cypress/e2e/database/filter-sort-combined.cy.ts b/cypress/e2e/database/filter-sort-combined.cy.ts new file mode 100644 index 00000000..2009b125 --- /dev/null +++ b/cypress/e2e/database/filter-sort-combined.cy.ts @@ -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('@nameFieldId').then((nameFieldId) => { + typeTextIntoCell(nameFieldId, 0, 'A'); + typeTextIntoCell(nameFieldId, 1, 'B'); + typeTextIntoCell(nameFieldId, 2, 'C'); + }); + + // Fill in the Number column + cy.get('@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('@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('@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('@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('@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('@numberFieldId').then((fieldId) => { + verifyCellValues(fieldId, ['30', '20', '10']); + }); + + closePopover(); + + assertFilterExists(); + assertSortExists(); + }); + }); +}); diff --git a/cypress/e2e/database/relation-filter.cy.ts b/cypress/e2e/database/relation-filter.cy.ts new file mode 100644 index 00000000..2d61881b --- /dev/null +++ b/cypress/e2e/database/relation-filter.cy.ts @@ -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 => { + 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 + }); + }); +}); diff --git a/cypress/e2e/database/rollup-calculations.cy.ts b/cypress/e2e/database/rollup-calculations.cy.ts new file mode 100644 index 00000000..e723679d --- /dev/null +++ b/cypress/e2e/database/rollup-calculations.cy.ts @@ -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 => { + 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 + }); + }); +}); diff --git a/cypress/support/filter-sort-helpers.ts b/cypress/support/filter-sort-helpers.ts new file mode 100644 index 00000000..71ee0aea --- /dev/null +++ b/cypress/support/filter-sort-helpers.ts @@ -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 => { + 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 => { + 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 => { + 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); + }); + }); + }); +}; diff --git a/cypress/support/selectors.ts b/cypress/support/selectors.ts index 88337f7b..449601a2 100644 --- a/cypress/support/selectors.ts +++ b/cypress/support/selectors.ts @@ -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')), }; /** diff --git a/src/components/database/components/filters/filter-menu/FieldMenuTitle.tsx b/src/components/database/components/filters/filter-menu/FieldMenuTitle.tsx index 8c506eb9..cb149d39 100644 --- a/src/components/database/components/filters/filter-menu/FieldMenuTitle.tsx +++ b/src/components/database/components/filters/filter-menu/FieldMenuTitle.tsx @@ -31,6 +31,7 @@ function FieldMenuTitle ({ filterId, fieldId, renderConditionSelect }: { {readOnly ? null :