diff --git a/app/client/components/BaseView.js b/app/client/components/BaseView.js index a90e38d7..391eb3ed 100644 --- a/app/client/components/BaseView.js +++ b/app/client/components/BaseView.js @@ -147,6 +147,8 @@ function BaseView(gristDoc, viewSectionModel, options) { } })); + this.isLinkSource = this.autoDispose(ko.pureComputed(() => this.viewSection.linkedSections().all().length > 0)); + // Indicated whether editing the section should be disabled given the current linking state. this.disableEditing = this.autoDispose(ko.computed(() => { const linking = this.viewSection.linkingState(); diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js index ec7e906e..0ad54d9c 100644 --- a/app/client/components/GridView.js +++ b/app/client/components/GridView.js @@ -1226,6 +1226,8 @@ GridView.prototype.buildDom = function() { dom.autoDispose(fontUnderline), dom.autoDispose(fontStrikethrough), + kd.toggleClass('link_selector_row', () => self.isLinkSource() && isRowActive()), + // rowid dom dom('div.gridview_data_row_num', kd.style("width", ROW_NUMBER_WIDTH + 'px'), diff --git a/app/client/components/viewCommon.css b/app/client/components/viewCommon.css index f37e5d63..3f1077c9 100644 --- a/app/client/components/viewCommon.css +++ b/app/client/components/viewCommon.css @@ -172,15 +172,14 @@ top: 0px; width: 100%; height: 100%; - /* one pixel outline around the cell, and one inside the cell */ - outline: 1px solid var(--grist-theme-cursor-inactive, var(--grist-color-inactive-cursor)); - box-shadow: inset 0 0 0 1px var(--grist-theme-cursor-inactive, var(--grist-color-inactive-cursor)); pointer-events: none; } .active_cursor { + /* one pixel outline around the cell, and one inside the cell */ outline: 1px solid var(--grist-theme-cursor, var(--grist-color-cursor)); box-shadow: inset 0 0 0 1px var(--grist-theme-cursor, var(--grist-color-cursor)); + z-index: 1; } } @@ -207,6 +206,22 @@ background-color: var(--grist-theme-table-header-selected-bg, var(--grist-color-medium-grey-opaque)); } +.link_selector_row > .gridview_data_row_num { + color: var(--grist-theme-left-panel-active-page-fg, white); + background-color: var(--grist-theme-left-panel-active-page-bg, var(--grist-color-dark-bg)); +} + +.link_selector_row > .record::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background-color: var(--grist-theme-selection, var(--grist-color-selection)); + /* z-index should be higher than '.record .field.frozen' (10) to show for frozen columns, + * but lower than '.gridview_stick-top' (20) to stay under column headers. */ + z-index: 15; +} + .gridview_data_row_info.linked_dst::before { position: absolute; content: '\25B8'; diff --git a/app/client/models/SearchModel.ts b/app/client/models/SearchModel.ts index a5163313..f3525c67 100644 --- a/app/client/models/SearchModel.ts +++ b/app/client/models/SearchModel.ts @@ -314,14 +314,18 @@ class FinderImpl implements IFinder { private _initNewSectionShown() { this._initNewSectionCommon(); const viewInstance = this._sectionStepper.value.viewInstance.peek()!; - this._rowStepper.array = viewInstance.sortedRows.getKoArray().peek() as number[]; + const skip = ['chart'].includes(this._sectionStepper.value.parentKey.peek()); + this._rowStepper.array = skip ? [] : viewInstance.sortedRows.getKoArray().peek() as number[]; } private async _initNewSectionAny() { const tableModel = this._initNewSectionCommon(); const viewInstance = this._sectionStepper.value.viewInstance.peek(); - if (viewInstance) { + const skip = ['chart'].includes(this._sectionStepper.value.parentKey.peek()); + if (skip) { + this._rowStepper.array = []; + } else if (viewInstance) { this._rowStepper.array = viewInstance.sortedRows.getKoArray().peek() as number[]; } else { // If we are searching through another page (not currently loaded), we will NOT have a diff --git a/app/client/models/entities/ViewRec.ts b/app/client/models/entities/ViewRec.ts index b4f48d9c..29f5cff4 100644 --- a/app/client/models/entities/ViewRec.ts +++ b/app/client/models/entities/ViewRec.ts @@ -59,13 +59,15 @@ export function createViewRec(this: ViewRec, docModel: DocModel): void { const collapsed = new Set(this.activeCollapsedSections()); const visible = all.filter(x => !collapsed.has(x.id())); - return visible.length > 0 ? visible[0].getRowId() : 0; + // Default to the first leaf from layoutSpec (which corresponds to the top-left section), or + // fall back to the first item in the list if anything goes wrong (previous behavior). + const firstLeaf = getFirstLeaf(this.layoutSpecObj.peek()); + return visible.find(s => s.getRowId() === firstLeaf) ? firstLeaf as number : + (visible[0]?.getRowId() || 0); }); this.activeSection = refRecord(docModel.viewSections, this.activeSectionId); - - // If the active section is removed, set the next active section to be the default. this._isActiveSectionGone = this.autoDispose(ko.computed(() => this.activeSection()._isDeleted())); this.autoDispose(this._isActiveSectionGone.subscribe(gone => { @@ -74,3 +76,10 @@ export function createViewRec(this: ViewRec, docModel: DocModel): void { } })); } + +function getFirstLeaf(layoutSpec: BoxSpec|undefined): BoxSpec['leaf'] { + while (layoutSpec?.children?.length) { + layoutSpec = layoutSpec.children[0]; + } + return layoutSpec?.leaf; +} diff --git a/app/client/models/entities/ViewSectionRec.ts b/app/client/models/entities/ViewSectionRec.ts index 6c0956b4..cfe0bca4 100644 --- a/app/client/models/entities/ViewSectionRec.ts +++ b/app/client/models/entities/ViewSectionRec.ts @@ -35,6 +35,9 @@ import defaults = require('lodash/defaults'); export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleOwner { viewFields: ko.Computed>; + // List of sections linked from this one, i.e. for whom this one is the selector or link source. + linkedSections: ko.Computed>; + // All table columns associated with this view section, excluding hidden helper columns. columns: ko.Computed; @@ -273,6 +276,7 @@ export interface Filter { export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): void { this.viewFields = recordSet(this, docModel.viewFields, 'parentId', {sortBy: 'parentPos'}); + this.linkedSections = recordSet(this, docModel.viewSections, 'linkSrcSectionRef'); // All table columns associated with this view section, excluding any hidden helper columns. this.columns = this.autoDispose(ko.pureComputed(() => this.table().columns().all().filter(c => !c.isHiddenCol()))); diff --git a/app/client/ui/RightPanel.ts b/app/client/ui/RightPanel.ts index 3524ea45..48695f2d 100644 --- a/app/client/ui/RightPanel.ts +++ b/app/client/ui/RightPanel.ts @@ -549,9 +549,7 @@ export class RightPanel extends Disposable { ]), domComputed((use) => { - const activeSectionRef = activeSection.getRowId(); - const allViewSections = use(use(viewModel.viewSections).getObservable()); - const selectorFor = allViewSections.filter((sec) => use(sec.linkSrcSectionRef) === activeSectionRef); + const selectorFor = use(use(activeSection.linkedSections).getObservable()); // TODO: sections should be listed following the order of appearance in the view layout (ie: // left/right - top/bottom); return selectorFor.length ? [ diff --git a/test/fixtures/docs/Class Enrollment.grist b/test/fixtures/docs/Class Enrollment.grist new file mode 100644 index 00000000..153bbc45 Binary files /dev/null and b/test/fixtures/docs/Class Enrollment.grist differ diff --git a/test/nbrowser/LinkingSelector.ts b/test/nbrowser/LinkingSelector.ts new file mode 100644 index 00000000..a3761f73 --- /dev/null +++ b/test/nbrowser/LinkingSelector.ts @@ -0,0 +1,96 @@ +import {assert, WebElement} from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import {setupTestSuite} from 'test/nbrowser/testUtils'; + +describe('LinkingSelector', function() { + this.timeout(20000); + + const cleanup = setupTestSuite({team: true}); + let session: gu.Session; + + afterEach(() => gu.checkForErrors()); + + before(async function() { + session = await gu.session().login(); + const doc = await session.tempDoc(cleanup, 'Class Enrollment.grist', {load: false}); + await session.loadDoc(`/doc/${doc.id}/p/7`); + }); + + interface CursorSelectorInfo { + linkSelector: false | number; + cursor: false | {rowNum: number, col: number}; + } + + async function getCursorSelectorInfo(section: WebElement): Promise { + const hasCursor = await section.find('.active_cursor').isPresent(); + const hasSelector = await section.find('.link_selector_row').isPresent(); + return { + linkSelector: hasSelector && Number(await section.find('.link_selector_row .gridview_data_row_num').getText()), + cursor: hasCursor && await gu.getCursorPosition(section), + }; + } + + it('should mark selected row used for linking', async function() { + const families = gu.getSection('FAMILIES'); + const students = gu.getSection('STUDENTS'); + const enrollments = gu.getSection('ENROLLMENTS'); + + // Initially FAMILIES first row should be selected and marked as selector. + assert.deepEqual(await getCursorSelectorInfo(families), {linkSelector: 1, cursor: {rowNum: 1, col: 0}}); + assert.deepEqual(await gu.getActiveCell().getText(), 'Fin'); + + // STUDENTS shows appropriate records. + assert.deepEqual(await gu.getVisibleGridCells({section: students, col: 'First_Name', rowNums: [1, 2, 3, 4]}), + ['Brockie', 'Care', 'Alfonso', '']); + + // STUDENTS also has a selector row, but no active cursor. + assert.deepEqual(await getCursorSelectorInfo(students), {linkSelector: 1, cursor: false}); + assert.deepEqual(await getCursorSelectorInfo(enrollments), {linkSelector: false, cursor: false}); + + // Select a different Family + await gu.getCell({section: families, rowNum: 3, col: 'First_Name'}).click(); + assert.deepEqual(await gu.getActiveCell().getText(), 'Pat'); + assert.deepEqual(await getCursorSelectorInfo(families), {linkSelector: 3, cursor: {rowNum: 3, col: 0}}); + + // STUDENTS shows new values, has a new selector row + assert.deepEqual(await gu.getVisibleGridCells({section: students, col: 'First_Name', rowNums: [1, 2, 3]}), + ['Mordy', 'Noam', '']); + assert.deepEqual(await getCursorSelectorInfo(students), {linkSelector: 1, cursor: false}); + + // STUDENTS Card shows appropriate value + assert.deepEqual(await gu.getVisibleDetailCells( + {section: 'STUDENTS Card', cols: ['First_Name', 'Policy_Number'], rowNums: [1]}), + ['Mordy', '468617']); + + // Select another student + await gu.getCell({section: students, rowNum: 2, col: 'Last_Name'}).click(); + assert.deepEqual(await getCursorSelectorInfo(students), {linkSelector: 2, cursor: {rowNum: 2, col: 1}}); + assert.deepEqual(await gu.getVisibleDetailCells( + {section: 'STUDENTS Card', cols: ['First_Name', 'Policy_Number'], rowNums: [1]}), + ['Noam', '663208']); + + // There is no longer a cursor in FAMILIES, but still a link-selector. + assert.deepEqual(await getCursorSelectorInfo(families), {linkSelector: 3, cursor: false}); + + // Enrollments is linked to the selected student, but still shows no cursor or selector. + assert.deepEqual(await getCursorSelectorInfo(enrollments), {linkSelector: false, cursor: false}); + assert.deepEqual(await gu.getVisibleGridCells({section: enrollments, col: 'Class', rowNums: [1, 2, 3]}), + ['2019F-Yoga', '2019S-Yoga', '']); + + // Click into an enrollment; it will become the only section with a cursor. + await gu.getCell({section: enrollments, rowNum: 2, col: 'Status'}).click(); + assert.deepEqual(await getCursorSelectorInfo(enrollments), {linkSelector: false, cursor: {rowNum: 2, col: 2}}); + assert.deepEqual(await getCursorSelectorInfo(students), {linkSelector: 2, cursor: false}); + assert.deepEqual(await getCursorSelectorInfo(families), {linkSelector: 3, cursor: false}); + }); + + it('should show correct state on reload after cursors are positioned', async function() { + await gu.reloadDoc(); + const families = gu.getSection('FAMILIES'); + const students = gu.getSection('STUDENTS'); + const enrollments = gu.getSection('ENROLLMENTS'); + assert.deepEqual(await getCursorSelectorInfo(enrollments), {linkSelector: false, cursor: {rowNum: 2, col: 2}}); + assert.deepEqual(await getCursorSelectorInfo(students), {linkSelector: 2, cursor: false}); + assert.deepEqual(await getCursorSelectorInfo(families), {linkSelector: 3, cursor: false}); + }); +}); diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index ed44cb4e..d8c8423b 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -358,7 +358,7 @@ export async function getVisibleGridCellsFast(colOrOptions: any, rowNums?: numbe * If rowNums are not shown (for single-card view), use rowNum of 1. */ export async function getVisibleDetailCells(col: number|string, rows: number[], section?: string): Promise; -export async function getVisibleDetailCells(options: IColSelect): Promise; +export async function getVisibleDetailCells(options: IColSelect|IColsSelect): Promise; export async function getVisibleDetailCells( colOrOptions: number|string|IColSelect|IColsSelect, _rowNums?: number[], _section?: string ): Promise { diff --git a/test/nbrowser/testUtils.ts b/test/nbrowser/testUtils.ts index 0483d2d3..020f253f 100644 --- a/test/nbrowser/testUtils.ts +++ b/test/nbrowser/testUtils.ts @@ -25,6 +25,10 @@ import {server} from 'test/nbrowser/testServer'; export {server}; setOptionsModifyFunc(({chromeOpts, firefoxOpts}) => { + if (process.env.TEST_CHROME_BINARY_PATH) { + chromeOpts.setChromeBinaryPath(process.env.TEST_CHROME_BINARY_PATH); + } + // Set "kiosk" printing that saves to PDF without offering any dialogs. This applies to regular // (non-headless) Chrome. On headless Chrome, no dialog or output occurs regardless. chromeOpts.addArguments("--kiosk-printing");