mirror of
https://github.com/AppFlowy-IO/AppFlowy-Web.git
synced 2026-03-13 10:02:51 +08:00
chore: add test
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -42,3 +42,5 @@ cypress/downloads
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
**/docs/context
|
||||
453
cypress/e2e/database/filter-sort-combined.cy.ts
Normal file
453
cypress/e2e/database/filter-sort-combined.cy.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
304
cypress/e2e/database/relation-filter.cy.ts
Normal file
304
cypress/e2e/database/relation-filter.cy.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
351
cypress/e2e/database/rollup-calculations.cy.ts
Normal file
351
cypress/e2e/database/rollup-calculations.cy.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
422
cypress/support/filter-sort-helpers.ts
Normal file
422
cypress/support/filter-sort-helpers.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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')),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -22,6 +22,7 @@ function SortCondition ({ sort }: { sort: Sort }) {
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-testid="sort-condition-button"
|
||||
variant={'outline'}
|
||||
size={'sm'}
|
||||
onClick={() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user