mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(core) Enable Record Cards
Summary: Adds remaining functionality, fixes, and polish to Record Cards and removes their feature flag, enabling them by default. Test Plan: Tests deferred; will be included in a follow-up diff. Reviewers: jarek, paulfitz Reviewed By: jarek Subscribers: paulfitz, jarek Differential Revision: https://phab.getgrist.com/D4121
This commit is contained in:
		
							parent
							
								
									84329404a4
								
							
						
					
					
						commit
						707a8c7b32
					
				| @ -274,7 +274,9 @@ BaseView.prototype.deleteRecords = function(source) { | ||||
|     buildConfirmDelete(selectedCell, onSave, rowIds.length <= 1); | ||||
|   } else { | ||||
|     onSave().then(() => { | ||||
|       reportUndo(this.gristDoc, `You deleted ${rowIds.length} row${rowIds.length > 1 ? 's' : ''}.`); | ||||
|       if (!this.isDisposed()) { | ||||
|         reportUndo(this.gristDoc, `You deleted ${rowIds.length} row${rowIds.length > 1 ? 's' : ''}.`); | ||||
|       } | ||||
|       return true; | ||||
|     }); | ||||
|   } | ||||
|  | ||||
| @ -3,7 +3,6 @@ import {GristDoc} from 'app/client/components/GristDoc'; | ||||
| import {copyToClipboard} from 'app/client/lib/clipboardUtils'; | ||||
| import {setTestState} from 'app/client/lib/testState'; | ||||
| import {TableRec} from 'app/client/models/DocModel'; | ||||
| import {RECORD_CARDS} from 'app/client/models/features'; | ||||
| import {docListHeader, docMenuTrigger} from 'app/client/ui/DocMenuCss'; | ||||
| import {duplicateTable, DuplicateTableResponse} from 'app/client/ui/DuplicateTable'; | ||||
| import {hoverTooltip, showTransientTooltip} from 'app/client/ui/tooltips'; | ||||
| @ -99,13 +98,14 @@ export class DataTables extends Disposable { | ||||
|                   hoverTooltip( | ||||
|                     dom.domComputed(use => use(use(tableRec.recordCardViewSection).disabled) | ||||
|                       ? t('Record Card Disabled') | ||||
|                       : t('Record Card')), | ||||
|                       : t('Edit Record Card')), | ||||
|                     {key: DATA_TABLES_TOOLTIP_KEY, closeOnClick: false} | ||||
|                   ), | ||||
|                   dom.hide(!RECORD_CARDS()), | ||||
|                   dom.hide(this._gristDoc.isReadonly), | ||||
|                   // Make the button invisible to maintain consistent alignment with non-summary tables.
 | ||||
|                   dom.style('visibility', u => u(tableRec.summarySourceTable) === 0 ? 'visible' : 'hidden'), | ||||
|                   cssRecordCardButton.cls('-disabled', use => use(use(tableRec.recordCardViewSection).disabled)), | ||||
|                   testId('table-record-card'), | ||||
|                 ), | ||||
|                 cssDotsButton( | ||||
|                   testId('table-menu'), | ||||
| @ -120,7 +120,8 @@ export class DataTables extends Disposable { | ||||
|                   throw new Error(`Table ${tableRec.tableId.peek()} doesn't have a raw view section.`); | ||||
|                 } | ||||
|                 this._gristDoc.viewModel.activeSectionId(sectionId); | ||||
|               }) | ||||
|               }), | ||||
|               cssTable.cls('-readonly', this._gristDoc.isReadonly), | ||||
|             ); | ||||
|           }) | ||||
|         ), | ||||
| @ -132,7 +133,8 @@ export class DataTables extends Disposable { | ||||
|     return dom.domComputed((use) => { | ||||
|       const rawViewSectionRef = use(fromKo(table.rawViewSectionRef)); | ||||
|       const isSummaryTable = use(table.summarySourceTable) !== 0; | ||||
|       if (!rawViewSectionRef || isSummaryTable) { | ||||
|       const isReadonly = use(this._gristDoc.isReadonly); | ||||
|       if (!rawViewSectionRef || isSummaryTable || isReadonly) { | ||||
|         // Some very old documents might not have a rawViewSection, and raw summary
 | ||||
|         // tables can't currently be renamed.
 | ||||
|         const tableName = [ | ||||
| @ -185,7 +187,7 @@ export class DataTables extends Disposable { | ||||
|         )), | ||||
|         testId('menu-remove-table'), | ||||
|       ), | ||||
|       dom.maybe(use => RECORD_CARDS() && use(table.summarySourceTable) === 0, () => [ | ||||
|       dom.maybe(use => use(table.summarySourceTable) === 0, () => [ | ||||
|         menuDivider(), | ||||
|         menuItem( | ||||
|           () => this._editRecordCard(table), | ||||
| @ -308,6 +310,10 @@ const cssTable = styled('div', ` | ||||
|   &:hover { | ||||
|     border-color: ${css.theme.rawDataTableBorderHover}; | ||||
|   } | ||||
|   &-readonly { | ||||
|     /* Row count column is hidden when document is read-only. */ | ||||
|     grid-template-columns: 16px auto 56px; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| const cssTableIcon = styled('div', ` | ||||
|  | ||||
| @ -33,6 +33,7 @@ function DetailView(gristDoc, viewSectionModel) { | ||||
| 
 | ||||
|   this.viewFields = gristDoc.docModel.viewFields; | ||||
|   this._isSingle = (this.viewSection.parentKey.peek() === 'single'); | ||||
|   this._isExternalSectionPopup = gristDoc.externalSectionId.get() === this.viewSection.id(); | ||||
| 
 | ||||
|   //--------------------------------------------------
 | ||||
|   // Create and attach the DOM for the view.
 | ||||
| @ -191,7 +192,9 @@ DetailView.prototype.deleteRows = async function(rowIds) { | ||||
|   try { | ||||
|     await BaseView.prototype.deleteRows.call(this, rowIds); | ||||
|   } finally { | ||||
|     this.cursor.rowIndex(index); | ||||
|     if (!this.isDisposed()) { | ||||
|       this.cursor.rowIndex(index); | ||||
|     } | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| @ -365,7 +368,13 @@ DetailView.prototype.buildTitleControls = function() { | ||||
|   // the controls can be confusing in this case.
 | ||||
|   // Note that the controls should still be visible with a filter link.
 | ||||
|   const showControls = ko.computed(() => { | ||||
|     if (!this._isSingle || this.recordLayout.layoutEditor()) { return false; } | ||||
|     if ( | ||||
|       !this._isSingle|| | ||||
|       this.recordLayout.layoutEditor() || | ||||
|       this._isExternalSectionPopup | ||||
|     ) { | ||||
|       return false; | ||||
|     } | ||||
|     const linkingState = this.viewSection.linkingState(); | ||||
|     return !(linkingState && Boolean(linkingState.cursorPos)); | ||||
|   }); | ||||
|  | ||||
| @ -56,7 +56,6 @@ const {CombinedStyle} = require("app/client/models/Styles"); | ||||
| const {buildRenameColumn} = require('app/client/ui/ColumnTitle'); | ||||
| const {makeT} = require('app/client/lib/localization'); | ||||
| const {reportError} = require('app/client/models/AppModel'); | ||||
| const {RECORD_CARDS} = require('app/client/models/features'); | ||||
| const {urlState} = require('app/client/models/gristUrlState'); | ||||
| 
 | ||||
| const t = makeT('GridView'); | ||||
| @ -375,13 +374,15 @@ GridView.gridCommands = { | ||||
|     this.viewSection.rawNumFrozen.setAndSave(action.numFrozen); | ||||
|   }, | ||||
|   viewAsCard() { | ||||
|     if (!RECORD_CARDS()) { return; } | ||||
|     if (this._isRecordCardDisabled()) { return; } | ||||
| 
 | ||||
|     const selectedRows = this.selectedRows(); | ||||
|     if (selectedRows.length !== 1) { return; } | ||||
| 
 | ||||
|     const colRef = this.viewSection.viewFields().at(this.cursor.fieldIndex()).column().id(); | ||||
|     const rowId = selectedRows[0]; | ||||
|     const sectionId = this.viewSection.tableRecordCard().id(); | ||||
|     const anchorUrlState = {hash: {rowId, sectionId, recordCard: true}}; | ||||
|     const anchorUrlState = {hash: {colRef, rowId, sectionId, recordCard: true}}; | ||||
|     urlState().pushUrl(anchorUrlState, {replace: true}).catch(reportError); | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| @ -209,6 +209,8 @@ export class GristDoc extends DisposableWithEvents { | ||||
|   private _showBackgroundVideoPlayer: Observable<boolean> = Observable.create(this, false); | ||||
|   private _backgroundVideoPlayerHolder: Holder<YouTubePlayer> = Holder.create(this); | ||||
|   private _disableAutoStartingTours: boolean = false; | ||||
|   private _isShowingPopupSection = false; | ||||
|   private _prevSectionId: number | null = null; | ||||
| 
 | ||||
|   constructor( | ||||
|     public readonly app: App, | ||||
| @ -565,6 +567,13 @@ export class GristDoc extends DisposableWithEvents { | ||||
|         commands.allCommands.viewTabFocus.run(); | ||||
|       } | ||||
|     })); | ||||
| 
 | ||||
|     this.autoDispose(this._popupSectionOptions.addListener((popupOptions) => { | ||||
|       if (!popupOptions) { | ||||
|         this._isShowingPopupSection = false; | ||||
|         this._prevSectionId = null; | ||||
|       } | ||||
|     })); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
| @ -616,10 +625,16 @@ export class GristDoc extends DisposableWithEvents { | ||||
|             // In case the section is removed, close the popup.
 | ||||
|             content.viewSection.autoDispose({dispose: content.close}); | ||||
| 
 | ||||
|             const {recordCard} = content.hash; | ||||
|             const {recordCard, rowId} = content.hash; | ||||
|             if (recordCard) { | ||||
|               if (!rowId || rowId === 'new') { | ||||
|                 // Should be unreachable, but just to be sure (and to satisfy type checking)...
 | ||||
|                 throw new Error('Unable to open Record Card: undefined row id'); | ||||
|               } | ||||
| 
 | ||||
|               return dom.create(RecordCardPopup, { | ||||
|                 gristDoc: this, | ||||
|                 rowId, | ||||
|                 viewSection: content.viewSection, | ||||
|                 onClose: content.close, | ||||
|               }); | ||||
| @ -629,7 +644,15 @@ export class GristDoc extends DisposableWithEvents { | ||||
|           }) : | ||||
|           dom.create((owner) => { | ||||
|             this.viewLayout = ViewLayout.create(owner, this, content); | ||||
|             this.viewLayout.maximized.addListener(n => this.maximizedSectionId.set(n)); | ||||
|             this.viewLayout.maximized.addListener(sectionId => { | ||||
|               this.maximizedSectionId.set(sectionId); | ||||
| 
 | ||||
|               if (sectionId === null && !this._isShowingPopupSection) { | ||||
|                 // If we didn't navigate to another section in the popup, move focus
 | ||||
|                 // back to the previous section.
 | ||||
|                 this._focusPreviousSection(); | ||||
|               } | ||||
|             }); | ||||
|             owner.onDispose(() => this.viewLayout = null); | ||||
|             return this.viewLayout; | ||||
|           }) | ||||
| @ -1290,11 +1313,11 @@ export class GristDoc extends DisposableWithEvents { | ||||
|     if (!hash.sectionId) { | ||||
|       return; | ||||
|     } | ||||
|     if (!this._prevSectionId) { | ||||
|       this._prevSectionId = this.viewModel.activeSection.peek().id(); | ||||
|     } | ||||
|     // We might open popup either for a section in this view or some other section (like Raw Data Page).
 | ||||
|     if (this.viewModel.viewSections.peek().peek().some(s => s.id.peek() === hash.sectionId)) { | ||||
|       if (this.viewLayout) { | ||||
|         this.viewLayout.previousSectionId = this.viewModel.activeSectionId.peek(); | ||||
|       } | ||||
|       this.viewModel.activeSectionId(hash.sectionId); | ||||
|       // If the anchor link is valid, set the cursor.
 | ||||
|       if (hash.colRef && hash.rowId) { | ||||
| @ -1308,10 +1331,10 @@ export class GristDoc extends DisposableWithEvents { | ||||
|       this.viewLayout?.maximized.set(hash.sectionId); | ||||
|       return; | ||||
|     } | ||||
|     this._isShowingPopupSection = true; | ||||
|     // We will borrow active viewModel and will trick him into believing that
 | ||||
|     // the section from the link is his viewSection and it is active. Fortunately
 | ||||
|     // he doesn't care. After popup is closed, we will restore the original.
 | ||||
|     const prevSection = this.viewModel.activeSection.peek(); | ||||
|     this.viewModel.activeSectionId(hash.sectionId); | ||||
|     // Now we have view section we want to show in the popup.
 | ||||
|     const popupSection = this.viewModel.activeSection.peek(); | ||||
| @ -1329,20 +1352,17 @@ export class GristDoc extends DisposableWithEvents { | ||||
|         if (!this._popupSectionOptions.get()) { | ||||
|           return; | ||||
|         } | ||||
|         if (popupSection !== prevSection) { | ||||
|         if (popupSection.id() !== this._prevSectionId) { | ||||
|           // We need to blur the popup section. Otherwise it will automatically be opened
 | ||||
|           // on raw data view. Note: raw data and record card sections don't have parent views;
 | ||||
|           // they use the empty row model as a parent (which feels like a hack).
 | ||||
|           if (!popupSection.isDisposed()) { | ||||
|             popupSection.hasFocus(false); | ||||
|           } | ||||
|           // We need to restore active viewSection for a view that we borrowed.
 | ||||
|           // When this popup was opened we tricked active view by setting its activeViewSection
 | ||||
|           // to our viewSection (which might be a completely diffrent section or a raw data section) not
 | ||||
|           // connected to this view.
 | ||||
|           if (!prevSection.isDisposed()) { | ||||
|             prevSection.hasFocus(true); | ||||
|           } | ||||
|           // to our viewSection (which might be a completely different section or a raw data section) not
 | ||||
|           // connected to this view. We need to return focus back to the previous section.
 | ||||
|           this._focusPreviousSection(); | ||||
|         } | ||||
|         // Clearing popup section data will close this popup.
 | ||||
|         this._popupSectionOptions.set(null); | ||||
| @ -1401,6 +1421,19 @@ export class GristDoc extends DisposableWithEvents { | ||||
|     this._showBackgroundVideoPlayer.set(false); | ||||
|   } | ||||
| 
 | ||||
|   private _focusPreviousSection() { | ||||
|     const prevSectionId = this._prevSectionId; | ||||
|     if (!prevSectionId) { return; } | ||||
| 
 | ||||
|     if ( | ||||
|       this.viewModel.viewSections.peek().all().some(s => | ||||
|         !s.isDisposed() && s.id.peek() === prevSectionId) | ||||
|     ) { | ||||
|       this.viewModel.activeSectionId(prevSectionId); | ||||
|     } | ||||
|     this._prevSectionId = null; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Waits for a view to be ready | ||||
|    */ | ||||
|  | ||||
| @ -2,22 +2,27 @@ import {buildViewSectionDom} from 'app/client/components/buildViewSectionDom'; | ||||
| import * as commands from 'app/client/components/commands'; | ||||
| import {GristDoc} from 'app/client/components/GristDoc'; | ||||
| import {cssCloseButton, cssOverlay} from 'app/client/components/RawDataPage'; | ||||
| import {ViewSectionRec} from 'app/client/models/DocModel'; | ||||
| import {ViewSectionHelper} from 'app/client/components/ViewLayout'; | ||||
| import {ViewSectionRec} from 'app/client/models/DocModel'; | ||||
| import {ChangeType, RowList} from 'app/client/models/rowset'; | ||||
| import {theme} from 'app/client/ui2018/cssVars'; | ||||
| import {Disposable, dom, makeTestId, styled} from 'grainjs'; | ||||
| import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; | ||||
| import {dom, makeTestId, styled} from 'grainjs'; | ||||
| 
 | ||||
| const testId = makeTestId('test-record-card-popup-'); | ||||
| 
 | ||||
| interface RecordCardPopupOptions { | ||||
|   gristDoc: GristDoc; | ||||
|   rowId: number; | ||||
|   viewSection: ViewSectionRec; | ||||
|   onClose(): void; | ||||
| } | ||||
| 
 | ||||
| export class RecordCardPopup extends Disposable { | ||||
| export class RecordCardPopup extends DisposableWithEvents { | ||||
|   private _gristDoc = this._options.gristDoc; | ||||
|   private _rowId = this._options.rowId; | ||||
|   private _viewSection = this._options.viewSection; | ||||
|   private _tableModel = this._gristDoc.getTableModel(this._viewSection.table().tableId()); | ||||
|   private _handleClose = this._options.onClose; | ||||
| 
 | ||||
|   constructor(private _options: RecordCardPopupOptions) { | ||||
| @ -26,6 +31,11 @@ export class RecordCardPopup extends Disposable { | ||||
|       cancel: () => { this._handleClose(); }, | ||||
|     }; | ||||
|     this.autoDispose(commands.createGroup(commandGroup, this, true)); | ||||
| 
 | ||||
|     // Close the popup if the underlying row is removed.
 | ||||
|     const onRowChange = this._onRowChange.bind(this); | ||||
|     this._tableModel.on('rowChange', onRowChange); | ||||
|     this.onDispose(() => this._tableModel.off('rowChange', onRowChange)); | ||||
|   } | ||||
| 
 | ||||
|   public buildDom() { | ||||
| @ -39,7 +49,6 @@ export class RecordCardPopup extends Disposable { | ||||
|           draggable: false, | ||||
|           focusable: false, | ||||
|           renamable: false, | ||||
|           hideTitleControls: true, | ||||
|         }), | ||||
|       ), | ||||
|       cssCloseButton('CrossBig', | ||||
| @ -49,6 +58,12 @@ export class RecordCardPopup extends Disposable { | ||||
|       dom.on('click', (ev, elem) => void (ev.target === elem ? this._handleClose() : null)), | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   private _onRowChange(type: ChangeType, rows: RowList) { | ||||
|     if (type === 'remove' && [...rows].includes(this._rowId)) { | ||||
|       this._handleClose(); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| const cssSectionWrapper = styled('div', ` | ||||
|  | ||||
| @ -84,7 +84,6 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { | ||||
|   public viewModel: ViewRec; | ||||
|   public layoutSpec: ko.Computed<BoxSpec>; | ||||
|   public maximized: Observable<number|null>; | ||||
|   public previousSectionId = 0; // Used to restore focus after a maximized section is closed.
 | ||||
|   public isResizing = Observable.create(this, false); | ||||
|   public layout: Layout; | ||||
|   public layoutEditor: LayoutEditor; | ||||
| @ -203,16 +202,6 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { | ||||
|       // If we are closing popup, resize all sections.
 | ||||
|       if (!sectionId) { | ||||
|         this._onResize(); | ||||
|         // Reset active section to the first one if the section is popup is collapsed.
 | ||||
|         if (prev | ||||
|           && this.viewModel.activeCollapsedSections.peek().includes(prev) | ||||
|           && this.previousSectionId) { | ||||
|         // Make sure that previous section exists still.
 | ||||
|         if (this.viewModel.viewSections.peek().all() | ||||
|                 .some(s => !s.isDisposed() && s.id.peek() === this.previousSectionId)) { | ||||
|           this.viewModel.activeSectionId(this.previousSectionId); | ||||
|         } | ||||
|       } | ||||
|       } else { | ||||
|         // Otherwise resize only active one (the one in popup).
 | ||||
|         const section = this.viewModel.activeSection.peek(); | ||||
|  | ||||
| @ -78,7 +78,6 @@ export function buildViewSectionDom(options: { | ||||
|     tableNameHidden, | ||||
|     widgetNameHidden, | ||||
|     renamable = true, | ||||
|     hideTitleControls = false, | ||||
|   } = options; | ||||
| 
 | ||||
|   // Creating normal section dom
 | ||||
| @ -110,7 +109,7 @@ export function buildViewSectionDom(options: { | ||||
|         testId('viewsection-title'), | ||||
|         cssTestClick(testId("viewsection-blank")), | ||||
|       ), | ||||
|       hideTitleControls ? null : viewInstance.buildTitleControls(), | ||||
|       viewInstance.buildTitleControls(), | ||||
|       dom('div.viewsection_buttons', | ||||
|         dom.create(viewSectionMenu, gristDoc, vs) | ||||
|       ) | ||||
|  | ||||
| @ -33,7 +33,3 @@ export function PERMITTED_CUSTOM_WIDGETS(): Observable<string[]> { | ||||
|   } | ||||
|   return G.window.PERMITTED_CUSTOM_WIDGETS; | ||||
| } | ||||
| 
 | ||||
| export function RECORD_CARDS() { | ||||
|   return Boolean(getGristConfig().experimentalPlugins); | ||||
| } | ||||
|  | ||||
| @ -1,6 +1,5 @@ | ||||
| import { allCommands } from 'app/client/components/commands'; | ||||
| import { makeT } from 'app/client/lib/localization'; | ||||
| import { RECORD_CARDS } from 'app/client/models/features'; | ||||
| import { menuDivider, menuIcon, menuItemCmd, menuItemCmdLabel } from 'app/client/ui2018/menus'; | ||||
| import { dom } from 'grainjs'; | ||||
| 
 | ||||
| @ -22,7 +21,7 @@ export function RowContextMenu({ | ||||
|   numRows | ||||
| }: IRowContextMenu) { | ||||
|   const result: Element[] = []; | ||||
|   if (RECORD_CARDS() && numRows === 1) { | ||||
|   if (numRows === 1) { | ||||
|     result.push( | ||||
|       menuItemCmd( | ||||
|         allCommands.viewAsCard, | ||||
|  | ||||
| @ -2,7 +2,6 @@ import {makeT} from 'app/client/lib/localization'; | ||||
| import {DataRowModel} from 'app/client/models/DataRowModel'; | ||||
| import {TableRec} from 'app/client/models/DocModel'; | ||||
| import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; | ||||
| import {RECORD_CARDS} from 'app/client/models/features'; | ||||
| import {urlState} from 'app/client/models/gristUrlState'; | ||||
| import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles'; | ||||
| import {hideInPrintView, testId, theme} from 'app/client/ui2018/cssVars'; | ||||
| @ -20,7 +19,7 @@ const t = makeT('Reference'); | ||||
|  * Reference - The widget for displaying references to another table's records. | ||||
|  */ | ||||
| export class Reference extends NTextBox { | ||||
|   private _refTable: Computed<TableRec | null>; | ||||
|   protected _refTable: Computed<TableRec | null>; | ||||
|   private _visibleColRef: Computed<number>; | ||||
|   private _validCols: Computed<Array<IOptionFull<number>>>; | ||||
| 
 | ||||
| @ -120,23 +119,20 @@ export class Reference extends NTextBox { | ||||
|       dom.cls('text_wrapping', this.wrapping), | ||||
|       cssRefIcon('FieldReference', | ||||
|         cssRefIcon.cls('-view-as-card', use => | ||||
|           RECORD_CARDS() && use(referenceId) !== 0 && use(formattedValue).hasRecordCard), | ||||
|           use(referenceId) !== 0 && use(formattedValue).hasRecordCard), | ||||
|         dom.on('click', async () => { | ||||
|           if (!RECORD_CARDS()) { return; } | ||||
|           if (referenceId.get() === 0 || !formattedValue.get().hasRecordCard) { return; } | ||||
| 
 | ||||
|           const rowId = referenceId.get() as UIRowId; | ||||
|           const sectionId = this._refTable.get()?.recordCardViewSectionRef(); | ||||
|           if (sectionId === undefined) { | ||||
|             throw new Error('Unable to find Record Card section'); | ||||
|             throw new Error('Unable to open Record Card: undefined section id'); | ||||
|           } | ||||
| 
 | ||||
|           const anchorUrlState = {hash: {rowId, sectionId, recordCard: true}}; | ||||
|           await urlState().pushUrl(anchorUrlState, {replace: true}); | ||||
|         }), | ||||
|         dom.on('mousedown', (ev) => { | ||||
|           if (!RECORD_CARDS()) { return; } | ||||
| 
 | ||||
|           ev.stopPropagation(); | ||||
|           ev.preventDefault(); | ||||
|         }), | ||||
|  | ||||
| @ -1,7 +1,9 @@ | ||||
| import {DataRowModel} from 'app/client/models/DataRowModel'; | ||||
| import {testId} from 'app/client/ui2018/cssVars'; | ||||
| import {urlState} from 'app/client/models/gristUrlState'; | ||||
| import {testId, theme} from 'app/client/ui2018/cssVars'; | ||||
| import {icon} from 'app/client/ui2018/icons'; | ||||
| import {isList} from 'app/common/gristTypes'; | ||||
| import {dom} from 'grainjs'; | ||||
| import {Computed, dom, styled} from 'grainjs'; | ||||
| import {cssChoiceList, cssToken} from "app/client/widgets/ChoiceListCell"; | ||||
| import {Reference} from "app/client/widgets/Reference"; | ||||
| import {choiceToken} from "app/client/widgets/ChoiceToken"; | ||||
| @ -10,24 +12,33 @@ import {choiceToken} from "app/client/widgets/ChoiceToken"; | ||||
|  * ReferenceList - The widget for displaying lists of references to another table's records. | ||||
|  */ | ||||
| export class ReferenceList extends Reference { | ||||
|   private _hasRecordCard = Computed.create(this, (use) => { | ||||
|     const table = use(this._refTable); | ||||
|     if (!table) { return false; } | ||||
| 
 | ||||
|     return !use(use(table.recordCardViewSection).disabled); | ||||
|   }); | ||||
| 
 | ||||
|   public buildDom(row: DataRowModel) { | ||||
|     return cssChoiceList( | ||||
|       dom.cls('field_clip'), | ||||
|       cssChoiceList.cls('-wrap', this.wrapping), | ||||
|       dom.style('justify-content', use => use(this.alignment) === 'right' ? 'flex-end' : use(this.alignment)), | ||||
|       dom.domComputed((use) => { | ||||
| 
 | ||||
|         if (use(row._isAddRow) || this.isDisposed() || use(this.field.displayColModel).isDisposed()) { | ||||
|           // Work around JS errors during certain changes (noticed when visibleCol field gets removed
 | ||||
|           // for a column using per-field settings).
 | ||||
|           return null; | ||||
|         } | ||||
|         const value = row.cells[use(use(this.field.displayColModel).colId)]; | ||||
|         if (!value) { | ||||
|           return null; | ||||
|         } | ||||
|         const content = use(value); | ||||
|         if (!content) { return null; } | ||||
| 
 | ||||
|         const valueObs = row.cells[use(this.field.colId)]; | ||||
|         const value = valueObs && use(valueObs); | ||||
|         if (!value) { return null; } | ||||
| 
 | ||||
|         const displayValueObs = row.cells[use(use(this.field.displayColModel).colId)]; | ||||
|         const displayValue = displayValueObs && use(displayValueObs); | ||||
|         if (!displayValue) { return null; } | ||||
| 
 | ||||
|         // TODO: Figure out what the implications of this block are for ReferenceList.
 | ||||
|         // if (isVersions(content)) {
 | ||||
|         //   // We can arrive here if the reference value is unchanged (viewed as a foreign key)
 | ||||
| @ -36,20 +47,51 @@ export class ReferenceList extends Reference { | ||||
|         //   // just showing one version of the cell.  TODO: elaborate.
 | ||||
|         //   return use(this._formatValue)(content[1].local || content[1].parent);
 | ||||
|         // }
 | ||||
|         const items = isList(content) ? content.slice(1) : [content]; | ||||
|         const values = isList(value) ? value.slice(1) : [value]; | ||||
|         const displayValues = isList(displayValue) ? displayValue.slice(1) : [displayValue]; | ||||
|         // Use field.visibleColFormatter instead of field.formatter
 | ||||
|         // because we're formatting each list element to render tokens, not the whole list.
 | ||||
|         const formatter = use(this.field.visibleColFormatter); | ||||
|         return items.map(item => formatter.formatAny(item)); | ||||
|         return values.map((referenceId, i) => { | ||||
|           return { | ||||
|             referenceId, | ||||
|             formattedValue: formatter.formatAny(displayValues[i]), | ||||
|           }; | ||||
|         }); | ||||
|       }, | ||||
|       (input) => { | ||||
|         if (!input) { | ||||
|       (values) => { | ||||
|         if (!values) { | ||||
|           return null; | ||||
|         } | ||||
|         return input.map(token => { | ||||
|           const isBlankReference = token.trim() === ''; | ||||
|         return values.map(({referenceId, formattedValue}) => { | ||||
|           const isBlankReference = formattedValue.trim() === ''; | ||||
|           return choiceToken( | ||||
|             isBlankReference ? '[Blank]' : token, | ||||
|             [ | ||||
|               cssRefIcon('FieldReference', | ||||
|                 cssRefIcon.cls('-view-as-card', use => | ||||
|                   referenceId !== 0 && use(this._hasRecordCard)), | ||||
|                 dom.on('click', async () => { | ||||
|                   if (referenceId === 0 || !this._hasRecordCard.get()) { return; } | ||||
| 
 | ||||
|                   const rowId = referenceId as number; | ||||
|                   const sectionId = this._refTable.get()?.recordCardViewSectionRef(); | ||||
|                   if (sectionId === undefined) { | ||||
|                     throw new Error('Unable to open Record Card: undefined section id'); | ||||
|                   } | ||||
| 
 | ||||
|                   const anchorUrlState = {hash: {rowId, sectionId, recordCard: true}}; | ||||
|                   await urlState().pushUrl(anchorUrlState, {replace: true}); | ||||
|                 }), | ||||
|                 dom.on('mousedown', (ev) => { | ||||
|                   ev.stopPropagation(); | ||||
|                   ev.preventDefault(); | ||||
|                 }), | ||||
|               ), | ||||
|               cssLabel(isBlankReference ? '[Blank]' : formattedValue, | ||||
|                 testId('ref-list-cell-token-label'), | ||||
|               ), | ||||
|               dom.cls(cssRefIconAndLabel.className), | ||||
|             ], | ||||
|             { | ||||
|               blank: isBlankReference, | ||||
|             }, | ||||
| @ -61,3 +103,26 @@ export class ReferenceList extends Reference { | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| const cssRefIcon = styled(icon, ` | ||||
|   --icon-color: ${theme.lightText}; | ||||
|   flex-shrink: 0; | ||||
| 
 | ||||
|   &-view-as-card { | ||||
|     cursor: pointer; | ||||
|   } | ||||
|   &-view-as-card:hover { | ||||
|     --icon-color: ${theme.controlFg}; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| const cssRefIconAndLabel = styled('div', ` | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
| `);
 | ||||
| 
 | ||||
| const cssLabel = styled('div', ` | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   white-space: nowrap; | ||||
| `);
 | ||||
|  | ||||
| @ -5,7 +5,6 @@ import {Session} from 'test/nbrowser/gristUtils'; | ||||
| 
 | ||||
| describe('ReferenceList', function() { | ||||
|   this.timeout(60000); | ||||
|   setupTestSuite(); | ||||
|   let session: Session; | ||||
|   const cleanup = setupTestSuite({team: true}); | ||||
| 
 | ||||
| @ -79,6 +78,7 @@ describe('ReferenceList', function() { | ||||
|       await gu.sendKeys(Key.ARROW_DOWN, Key.ENTER, 'The Avengers', Key.ENTER, Key.ENTER); | ||||
| 
 | ||||
|       // Check that the cells are rendered correctly.
 | ||||
|       await gu.resizeColumn({col: 'Favorite Film'}, 100); | ||||
|       assert.deepEqual(await gu.getVisibleGridCells('Favorite Film', [1, 2, 3, 4, 5, 6]), | ||||
|         [ | ||||
|           'Forrest Gump\nAlien', | ||||
| @ -273,6 +273,7 @@ describe('ReferenceList', function() { | ||||
|       await driver.find('.test-fbuilder-ref-col-select').click(); | ||||
|       await driver.findContent('.test-select-row', /Name/).click(); | ||||
|       await gu.waitForServer(); | ||||
|       await gu.resizeColumn({col: 'A'}, 100); | ||||
|       assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]), | ||||
|         ['Roger\nTom', 'Tom', 'Sydney\nBill\nEvan', '', '', '']); | ||||
| 
 | ||||
| @ -311,7 +312,7 @@ describe('ReferenceList', function() { | ||||
|       const cell = gu.getCell({col: 'A', rowNum: 1}); | ||||
|       await server.pauseUntil(async () => { | ||||
|         assert.equal(await cell.getText(), 'Friends[1]\nFriends[2]'); | ||||
|         await cell.click(); | ||||
|         await gu.clickReferenceListCell(cell); | ||||
|         await gu.sendKeys('5'); | ||||
|         // Check that the autocomplete has no items yet.
 | ||||
|         assert.isEmpty(await driver.findAll('.test-autocomplete .test-ref-editor-new-item')); | ||||
| @ -324,7 +325,7 @@ describe('ReferenceList', function() { | ||||
|       assert.equal(await cell.getText(), 'Friends[1]\nFriends[2]'); | ||||
| 
 | ||||
|       // Once server is responsive, a valid value should not offer a "new item".
 | ||||
|       await cell.click(); | ||||
|       await gu.clickReferenceListCell(cell); | ||||
|       await gu.sendKeys('5'); | ||||
|       await driver.findWait('.test-ref-editor-item', 500); | ||||
|       assert.isFalse(await driver.find('.test-ref-editor-new-item').isPresent()); | ||||
| @ -750,7 +751,8 @@ describe('ReferenceList', function() { | ||||
|     }); | ||||
| 
 | ||||
|     it('should update choices as user types into textbox', async function() { | ||||
|       let cell = await gu.getCell({section: 'References', col: 'Schools', rowNum: 1}).doClick(); | ||||
|       let cell = await gu.getCell({section: 'References', col: 'Schools', rowNum: 1}); | ||||
|       await gu.clickReferenceListCell(cell); | ||||
|       assert.equal(await cell.getText(), 'TECHNOLOGY, ARTS AND SCIENCES STUDIO'); | ||||
|       await driver.sendKeys('TECHNOLOGY, ARTS AND SCIENCES STUDIO'); | ||||
|       assert.deepEqual(await getACOptions(3), [ | ||||
| @ -759,7 +761,8 @@ describe('ReferenceList', function() { | ||||
|         'SCHOOL OF SCIENCE AND TECHNOLOGY', | ||||
|       ]); | ||||
|       await driver.sendKeys(Key.ESCAPE); | ||||
|       cell = await gu.getCell({section: 'References', col: 'Schools', rowNum: 2}).doClick(); | ||||
|       cell = await gu.getCell({section: 'References', col: 'Schools', rowNum: 2}); | ||||
|       await gu.clickReferenceListCell(cell); | ||||
|       await driver.sendKeys('stuy'); | ||||
|       assert.deepEqual(await getACOptions(3), [ | ||||
|         'STUYVESANT HIGH SCHOOL', | ||||
| @ -790,7 +793,8 @@ describe('ReferenceList', function() { | ||||
|     it('should highlight matching parts of items', async function() { | ||||
|       await driver.sendKeys(Key.HOME); | ||||
| 
 | ||||
|       let cell = await gu.getCell({section: 'References', col: 'Colors', rowNum: 2}).doClick(); | ||||
|       let cell = await gu.getCell({section: 'References', col: 'Colors', rowNum: 2}); | ||||
|       await gu.clickReferenceListCell(cell); | ||||
|       assert.equal(await cell.getText(), 'Red'); | ||||
|       await driver.sendKeys(Key.ENTER, 'Red'); | ||||
|       await driver.findWait('.test-ref-editor-item', 1000); | ||||
| @ -802,7 +806,8 @@ describe('ReferenceList', function() { | ||||
|         ['Re']); | ||||
|       await driver.sendKeys(Key.ESCAPE); | ||||
| 
 | ||||
|       cell = await gu.getCell({section: 'References', col: 'Schools', rowNum: 1}).doClick(); | ||||
|       cell = await gu.getCell({section: 'References', col: 'Schools', rowNum: 1}); | ||||
|       await gu.clickReferenceListCell(cell); | ||||
|       await driver.sendKeys('br tech'); | ||||
|       assert.deepEqual( | ||||
|         await driver.findContentWait('.test-ref-editor-item', /BROOKLYN TECH/, 1000).findAll('span', e => e.getText()), | ||||
| @ -819,19 +824,20 @@ describe('ReferenceList', function() { | ||||
|     it('should reflect changes to the target column', async function() { | ||||
|       await driver.sendKeys(Key.HOME); | ||||
| 
 | ||||
|       const cell = await gu.getCell({section: 'References', col: 'Colors', rowNum: 4}).doClick(); | ||||
|       const cell = await gu.getCell({section: 'References', col: 'Colors', rowNum: 4}); | ||||
|       await gu.clickReferenceListCell(cell); | ||||
|       assert.equal(await cell.getText(), ''); | ||||
|       await driver.sendKeys(Key.ENTER); | ||||
|       assert.deepEqual(await getACOptions(2), ['Alice Blue', 'Añil']); | ||||
|       await driver.sendKeys(Key.ESCAPE); | ||||
| 
 | ||||
|       // Change a color
 | ||||
|       await gu.getCell({section: 'Colors', col: 'Color Name', rowNum: 1}).doClick(); | ||||
|       await driver.sendKeys('HAZELNUT', Key.ENTER); | ||||
|       await gu.clickReferenceListCell(await gu.getCell({section: 'Colors', col: 'Color Name', rowNum: 1})); | ||||
|       await driver.sendKeys('HAZELNUT', Key.ENTER, Key.ENTER); | ||||
|       await gu.waitForServer(); | ||||
| 
 | ||||
|       // See that the old value is gone from the autocomplete, and the new one is present.
 | ||||
|       await cell.click(); | ||||
|       await gu.clickReferenceListCell(cell); | ||||
|       await driver.sendKeys(Key.ENTER); | ||||
|       assert.deepEqual(await getACOptions(2), ['Añil', 'Aqua']); | ||||
|       await driver.sendKeys('H'); | ||||
| @ -839,11 +845,11 @@ describe('ReferenceList', function() { | ||||
|       await driver.sendKeys(Key.ESCAPE); | ||||
| 
 | ||||
|       // Delete a row.
 | ||||
|       await gu.getCell({section: 'Colors', col: 'Color Name', rowNum: 1}).doClick(); | ||||
|       await gu.clickReferenceListCell(await gu.getCell({section: 'Colors', col: 'Color Name', rowNum: 1})); | ||||
|       await gu.removeRow(1); | ||||
| 
 | ||||
|       // See that the value is gone from the autocomplete.
 | ||||
|       await cell.click(); | ||||
|       await gu.clickReferenceListCell(cell); | ||||
|       await driver.sendKeys('H'); | ||||
|       assert.deepEqual(await getACOptions(2), ['Honeydew', 'Hot Pink']); | ||||
|       await driver.sendKeys(Key.ESCAPE); | ||||
| @ -856,7 +862,7 @@ describe('ReferenceList', function() { | ||||
|       await gu.waitForServer(); | ||||
| 
 | ||||
|       // See that the new value is visible in the autocomplete.
 | ||||
|       await cell.click(); | ||||
|       await gu.clickReferenceListCell(cell); | ||||
|       await driver.sendKeys('H'); | ||||
|       assert.deepEqual(await getACOptions(2), ['HELIOTROPE', 'Honeydew']); | ||||
|       await driver.sendKeys(Key.BACK_SPACE); | ||||
| @ -866,7 +872,7 @@ describe('ReferenceList', function() { | ||||
|       // Undo all the changes.
 | ||||
|       await gu.undo(4); | ||||
| 
 | ||||
|       await cell.click(); | ||||
|       await gu.clickReferenceListCell(cell); | ||||
|       await driver.sendKeys('H'); | ||||
|       assert.deepEqual(await getACOptions(2), ['Honeydew', 'Hot Pink']); | ||||
|       await driver.sendKeys(Key.BACK_SPACE); | ||||
|  | ||||
| @ -183,7 +183,12 @@ async function checkSelectingRecords(selectBy: string, sourceData: string[][], n | ||||
|   await gu.waitForServer(); | ||||
| 
 | ||||
|   const selectByTable = selectBy.split(' ')[0]; | ||||
|   await gu.getCell({section: selectByTable, col: 0, rowNum: 3}).click(); | ||||
|   const cell = await gu.getCell({section: selectByTable, col: 0, rowNum: 3}); | ||||
|   if (selectByTable === 'REFLISTS') { | ||||
|     await gu.clickReferenceListCell(cell); | ||||
|   } else { | ||||
|     await cell.click(); | ||||
|   } | ||||
| 
 | ||||
|   let numSourceRows = 0; | ||||
| 
 | ||||
| @ -207,7 +212,12 @@ async function checkSelectingRecords(selectBy: string, sourceData: string[][], n | ||||
|   } | ||||
| 
 | ||||
|   for (let i = 0; i < sourceData.length; i++) { | ||||
|     await gu.getCell({section: selectByTable, col: 0, rowNum: i + 1}).click(); | ||||
|     const cell = await gu.getCell({section: selectByTable, col: 0, rowNum: i + 1}); | ||||
|     if (selectByTable === 'REFLISTS') { | ||||
|       await gu.clickReferenceListCell(cell); | ||||
|     } else { | ||||
|       await cell.click(); | ||||
|     } | ||||
|     await checkSourceGroup(i); | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -572,6 +572,19 @@ export async function rightClick(cell: WebElement) { | ||||
|   await driver.withActions((actions) => actions.contextClick(cell)); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Clicks a Reference List cell, taking care not to click the icon (which can | ||||
|  * cause an unexpected Record Card popup to appear). | ||||
|  */ | ||||
| export async function clickReferenceListCell(cell: WebElement) { | ||||
|   const tokens = await cell.findAll('.test-ref-list-cell-token-label'); | ||||
|   if (tokens.length > 0) { | ||||
|     await tokens[0].click(); | ||||
|   } else { | ||||
|     await cell.click(); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Gets the selector position in the Grid view section (or null if not present). | ||||
|  * Selector is the black box around the row number. | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user