import { $logger } from '@/util'
import { LIMSView } from '@/modules/lims/utils/lims-utils'
import NotificationService from '@shared/services/notification/Service'
import { EmailNotification } from '@shared/services/notification/Models'
import WorksheetController from '@/util/worksheet-controller.js'
/**
Returns an object with a series of methods for operating on worksheet data.
Also contains several helper methods for interacting with ESP.
Data management priority is as follow:
1. data from ``worksheet`` Proxy model
2. data from AG-Grid
@name Client Lims Api
@param {Object} gridOptions - The Worksheet Tab's ``gridOptions`` object.
@param {Object} worksheet - The Worksheet data model, for data manipulation.
@param {Object} activeComponent - Active Worksheet(View) Vue.js component, for direct view manipulation. To support
all methods from client-lims-api, active component must implement following methods:
- ``showColumns(colIndexes)``
- ``hideColumns(colIndexes)``
- ``setFocusedCell(rowIndex, colIndex)``
@param {Object} worksheetVue - The Worksheet Vue.js component, for saving and moving between tabs.
*/
export default function (gridOptions, worksheet, activeComponent, worksheetVue) {
return {
gridOptions: gridOptions,
sampleSheetUuid: worksheet.sheetUuid,
sampleSheetName: worksheet.sheetName,
worksheet: worksheet,
activeView: activeComponent.activeView,
activeComponent: activeComponent,
selectedCellsUuids: activeComponent.selectedCellsUuids,
selectedSamplesUuids: activeComponent.selectedSamplesUuids,
worksheetVue: worksheetVue,
modal: null,
/* eslint-disable no-multi-spaces, key-spacing */
views: {
[LIMSView.Column]: [LIMSView.Column],
[LIMSView.Row]: [LIMSView.Row],
[LIMSView.Document]: [LIMSView.Document],
[LIMSView.DocumentAllTabs]: [LIMSView.DocumentAllTabs],
[LIMSView.Flex]: [LIMSView.Flex],
// two special views, not called directly but gathering other views
[LIMSView.Grid]: [LIMSView.Column, LIMSView.Row],
[LIMSView.Doc]: [LIMSView.Document, LIMSView.DocumentAllTabs]
},
errorMsgs: {
wrongView: `Function not available in a current view`,
noWorksheetVueComp: `Worksheet.vue component's not available`,
entityColRef: `First column - "Entity", cannot be referenced in model`,
noRequiredMethod: `Active component does not contain required method`,
notSharedColumn: `Selected column is not shared`
},
_entityColumnNames: null,
_cachedProtocolName: null,
/* eslint-enable */
/**
Check if function is appropriate to run in current view.
@param {String} requiredView - options:
- ``LIMSTabView.Grid``: for grid-able views (with columns and rows)
- or single view from all available ones: ``LIMSTabView``
*/
_viewOK: function (requiredView) {
/*
By default for ``requiredView`` parameter, only one view is an option, but
there are views not used directly, which gather others:
`LIMSView.Grid` allows:
- LIMSTabView.Row: standard grid view
- LIMSTabView.Column: transposed grid view
`LIMSView.Doc` allows:
- LIMSTabView.Document: document with only one tab active
- LIMSTabView.DocumentAllTabs: document with all tabs
*/
return this.views[requiredView] && this.views[requiredView].includes(this.activeView)
},
/**
Get currently active tab passed when creating ``clientLimsApi`` object.
@throws {Error} - Current view cannot be ``LIMSView.DocumentAllTabs`` to select current tab.
*/
getActiveTab: function () {
if (this.activeView === LIMSView.DocumentAllTabs) throw new Error(this.errorMsgs.wrongView)
return this.worksheetVue.activeTab
},
/**
Get aliased text as configured by the application.
@param {String} text - The text to (optionally) be replaced. Should refer to a business object.
*/
alias: function (text) {
let activeViewComponent = this.activeComponent
if (!activeViewComponent || !activeViewComponent.$alias) return
return activeViewComponent.$alias(text)
},
/**
Save the active sample sheet tab.
@param {Boolean} continueAfter - ``true`` to Save and Continue, ``false`` to save only.
@throws {Error} - ``WorksheetVue`` component is required while saving worksheet.
*/
saveSheet: function (continueAfter = false) {
if (!this.worksheetVue) throw new Error(this.errorMsgs.noWorksheetVueComp)
this.worksheetVue.saveWorksheet().then(() => {
if (continueAfter) this.worksheetVue.goNext()
})
},
/**
* Switch the active sample sheet tab WITHOUT saving the current tab.
* For "save and continue", use saveSheet(true).
* For save and stay, use saveSheet(false).
*
* @param {Number} newTabIndex - index of the new tab
* @throws {Error} - ``WorksheetVue`` component is required while switching tabs.
*/
switchTab: function (newTabIndex) {
if (!this.worksheetVue) throw new Error(this.errorMsgs.noWorksheetVueComp)
this.worksheetVue.changeTab(newTabIndex)
},
/**
Return a column of the active sheet tab.
It returns column from ``gridOptions`` object.
@param {String|Number} headerNameOrIndex - The header name or index of the desired column.
@throws {Error} - Function available only in a ag-grid view, for other views use ``getModelColumnName()``.
*/
getColumn: function (headerNameOrIndex, checkView = true) {
if (checkView && !this._viewOK(LIMSView.Grid)) throw new Error(this.errorMsgs.wrongView)
return (typeof headerNameOrIndex === 'number')
? this.gridOptions.columnDefs[headerNameOrIndex]
: this.gridOptions.columnDefs.find(colDef => colDef.headerName === headerNameOrIndex)
},
/**
Return a column of the active sheet tab.
It returns column from ``worksheet`` object.
@param {String|Number} headerNameOrIndex - The header name or index of the desired column.
*/
getModelColumn: function (headerNameOrIndex) {
const colIndex = this.getModelColumnIndex(headerNameOrIndex)
if (colIndex < 0) return null
let tab = this.getActiveTab()
return tab.columns[colIndex]
},
/**
Same as ``getModelColumn()`` but for shared data.
@param {String|Number} sharedHeaderNameOrIndex - The header name or index of the desired column.
*/
getModelSharedColumn: function (sharedHeaderNameOrIndex) {
const colIndex = this.getModelSharedColumnIndex(sharedHeaderNameOrIndex)
if (colIndex < 0) return null
let tab = this.getActiveTab()
return tab.columns[colIndex]
},
/**
Return the field of a column in the active sheet tab.
It returns column name from ``gridOptions`` object.
@param {String|Number} headerNameOrIndex - The header name or index of the desired column.
@throws {Error} - Function available only in a ag-grid view, for other views use ``getModelColumnName()``.
*/
getColumnName: function (headerNameOrIndex, checkView = true) {
if (checkView && !this._viewOK(LIMSView.Grid)) throw new Error(this.errorMsgs.wrongView)
const col = this.getColumn(headerNameOrIndex, false)
return col ? col.field : null
},
/**
Return the resource var UUID of a column in the active sheet tab.
It returns column UUID from ``worksheet`` object.
@param {String|Number} headerNameOrIndex - The header name or index of the desired column.
*/
getModelColumnName: function (headerNameOrIndex) {
const col = this.getModelColumn(headerNameOrIndex)
return col.resourceVarUuid
},
/**
Duplicate of ``getColumnName()``
*/
getColumnIndex: function (col, checkView = true) {
// note: shouldn't this function return index of column by name?
if (checkView && !this._viewOK(LIMSView.Grid)) throw new Error(this.errorMsgs.wrongView)
return this.getColumnName(col, false)
},
/**
Set the given columns as visible in the worksheet tab.
This function works only on supported views, for others it's no-op.
@param {(String|Number)[]} headerNamesOrIndices - An array of column identifiers (header names or indices) to show.
*/
showColumns: function (headerNamesOrIndices) {
if (this._viewOK(LIMSView.Column)) {
this.showColumnsTransposed(headerNamesOrIndices, false)
} else if (this._viewOK(LIMSView.Row)) {
const cols = headerNamesOrIndices.map(x => this.getColumnIndex(x, false)).filter(x => x !== null)
this.gridOptions.columnApi.setColumnsVisible(cols, true)
this.activeComponent.columnDefs.forEach(c => {
if (headerNamesOrIndices.includes(c.headerName)) {
c.hide = false
}
})
} else if (this._viewOK(LIMSView.Doc)) {
const colIndexes = headerNamesOrIndices.map(col => this.getModelColumnIndex(col))
if (typeof this.activeComponent.showColumns !== 'function') throw new Error(this.errorMsgs.noRequiredMethod)
this.activeComponent.showColumns(colIndexes)
} else {
// in other views (i.e. Flex) no-op
$logger.info('Current view does not have implemented `showColumns()` method.')
}
},
/**
Delegated from `showColumns` for dealing with a transposed table.
@param {(String|Number)[]} fieldNamesOrIndices - An array of column identifiers (header names or indices) to show.
*/
showColumnsTransposed(fieldNamesOrIndices, checkView = true) {
if (checkView && !this._viewOK(LIMSView.Column)) throw new Error(this.errorMsgs.wrongView)
this.gridOptions.api.forEachLeafNode(node => {
if (fieldNamesOrIndices.includes(node.data.headerName)) {
node.data.hide = false
}
})
this.gridOptions.api.onFilterChanged()
this.activeComponent.columnDefs.forEach(c => {
if (fieldNamesOrIndices.includes(c.headerName)) {
c.hide = false
}
})
},
/**
Set the given columns as hidden in the worksheet tab.
This function works only on supported views, for others it's no-op.
@param {(String|Number)[]} headerNamesOrIndices - An array of column identifiers (header names or indices) to hide.
*/
hideColumns: function (headerNamesOrIndices) {
if (this._viewOK(LIMSView.Column)) {
this.hideColumnsTransposed(headerNamesOrIndices, false)
} else if (this._viewOK(LIMSView.Row)) {
const cols = headerNamesOrIndices.map(x => this.getColumnIndex(x, false)).filter(x => x !== null)
this.gridOptions.columnApi.setColumnsVisible(cols, false)
this.activeComponent.columnDefs.forEach(c => {
if (headerNamesOrIndices.includes(c.headerName)) {
c.hide = true
}
})
} else if (this._viewOK(LIMSView.Doc)) {
const colIndexes = headerNamesOrIndices.map(col => this.getModelColumnIndex(col))
if (typeof this.activeComponent.hideColumns !== 'function') throw new Error(this.errorMsgs.noRequiredMethod)
this.activeComponent.hideColumns(colIndexes)
} else {
// in other views (i.e. Flex) no-op
$logger.info('Current view does not have implemented `hideColumns()` method.')
}
},
/**
Delegated from ``hideColumns()`` to deal with transposed tables.
@param {(String|Number)[]} headerNamesOrIndices - An array of column identifiers (header names or indices) to hide.
*/
hideColumnsTransposed: function (headerNamesOrIndices, checkView = true) {
if (checkView && !this._viewOK(LIMSView.Column)) throw new Error(this.errorMsgs.wrongView)
this.gridOptions.api.forEachLeafNode(node => {
if (headerNamesOrIndices.includes(node.data.headerName)) {
node.data.hide = true
}
})
this.gridOptions.api.onFilterChanged()
this.activeComponent.columnDefs.forEach(c => {
if (headerNamesOrIndices.includes(c.headerName)) {
c.hide = true
}
})
},
/**
An abstraction of the underlying call to redraw the grid.
Prevents content dependence on ag-grid specifically.
*/
redraw: function () {
if (this.gridOptions) {
this.gridOptions.api.refreshCells()
}
this.activeComponent.refreshAll()
},
/**
Forces a re-fetch of the grid data.
Note that any unsaved user data may be lost
by calling this, so use with care.
*/
refetchTab: function () {
// todo: remove that dependency after getting rid of apollo in WorksheetController
if (!this.worksheetVue) throw new Error(this.errorMsgs.noWorksheetVueComp)
this.worksheetVue.fetchWorksheet()
},
/**
Function unimplemented
*/
setCellStyle: function (rowIdx, col, styleClause) {
$logger.error(`Function 'setCellStyle()' is not implemented`)
},
/**
Return the row data at the given index or possessing the given name.
It returns row data from ``activeComponent === worksheetTab`` object.
@param {Number|String} rowIndexOrSampleName - The row index (untransposed) or name property of the desired sample.
*/
getRowData: function (rowIndexOrSampleName, checkView = true) {
if (checkView && !this._viewOK(LIMSView.Grid)) throw new Error(this.errorMsgs.wrongView)
return typeof rowIndexOrSampleName === 'number'
? this.activeComponent.rowData[rowIndexOrSampleName]
: this.activeComponent.rowData.find(row => row.name === rowIndexOrSampleName)
},
/**
Return the row data at the given index or possessing the given name.
It returns row data independently from view, using ``worksheet`` object.
@param {Number|String} rowIndexOrSampleName - The row index (untransposed) or name property of the desired sample.
*/
getModelRowData: function (rowIndexOrSampleName) {
const tab = this.getActiveTab()
return typeof rowIndexOrSampleName === 'number'
? tab.rows[rowIndexOrSampleName]
: tab.rows.find(row => row.name === rowIndexOrSampleName)
},
/**
Return the column definition at the given index or possessing the given header name.
It returns column def from ``activeComponent === worksheetTab`` object.
@param {Number|String} columnIndexOrHeaderName - The column index (untransposed) or headerName property of the desired column.
*/
getColumnDef: function (columnIndexOrHeaderName, checkView = true) {
if (checkView && !this._viewOK(LIMSView.Grid)) throw new Error(this.errorMsgs.wrongView)
return typeof columnIndexOrHeaderName === 'number'
? this.activeComponent.columnDefs[columnIndexOrHeaderName]
: this.activeComponent.columnDefs.find(col => col.headerName === columnIndexOrHeaderName)
},
/**
Return the REAL column index based on its name or index.
Real column index means it could be used for referencing to cells from ``WorksheetRow`` model object.
Such functionality is required to maintain backwards compatibility, where first column
(with index '0') were taken into account and now it's gone.
It returns column index independently from view, using ``worksheet`` object.
Example (without shared columns):
getModelColumnIndex(0) - throws an exception because "Entity" column is not referable in a model.
getModelColumnIndex(1) - returns 0, as it's first column in the model, allowing cells in rows to be referable by this index.
getModelColumnIndex(2) - returns 1, as it's second column in the model.
getModelColumnIndex("Custom") - returns index of column with name === "Custom"
For shared data use ``getModelSharedColumnIndex()``.
@param {String|Number} columnIndexOrHeaderName - The column ``name`` property or index (untranposed) of the desired column.
@return {Number} - The column index.
@throws {Error} - Entity" column is not referable in model, thus 0/'Entity' values are forbidden.
*/
getModelColumnIndex: function (columnIndexOrHeaderName) {
// eslint-disable-next-line no-throw-literal
if (columnIndexOrHeaderName === 0 || this._getEntityColumnNames().has(columnIndexOrHeaderName)) {
throw this.errorMsgs.entityColRef
}
const tab = this.getActiveTab()
// make mapping between indexes in array for columns (not shared)
// starting from 1 because there is additional, artificial 'Entity' column
let colIndex = 1
let colsMap = {}
tab.columns.forEach((col, index) => {
if (!col.shared) {
colsMap[colIndex++] = index
}
})
let retVal = -1 // calling functions may already expect a negative index rather than a null or false
if (typeof columnIndexOrHeaderName === 'number') {
retVal = colsMap[columnIndexOrHeaderName]
} else {
retVal = tab.columns.findIndex(col => {
return col.barcode === columnIndexOrHeaderName || // check barcode (fixed id) first.
col.name === columnIndexOrHeaderName
})
}
return retVal
},
/**
Same as ``getModelColumnIndex()`` but for shared data.
@param {String|Number} sharedColumnIndexOrHeaderName - The column ``name`` property or index (untranposed) of the desired column.
@return {Number} - The column index.
*/
getModelSharedColumnIndex: function (sharedColumnIndexOrHeaderName) {
const tab = this.getActiveTab()
// make mapping between indexes in array for shared columns
// starting from 0 because there is no 'Entity' column for shared data
let sharedColIndex = 0
let sharedColsMap = {}
tab.columns.forEach((col, index) => {
if (col.shared) {
sharedColsMap[sharedColIndex++] = index
}
})
return typeof sharedColumnIndexOrHeaderName === 'number'
? sharedColsMap[sharedColumnIndexOrHeaderName]
: tab.columns.findIndex(col => {
return sharedColumnIndexOrHeaderName === col.barcode ||
sharedColumnIndexOrHeaderName === col.name
})
},
/**
* Internal method to use for checking if getDataAt is attempting to get the entity name.
* For backwards compatibility with 2.4 behavior.
* @returns {Set} The set of entity type names.
* @private
*/
_getEntityColumnNames() {
if (!this._entityColumnNames || this.getActiveTab().protocolName !== this._cachedProtocolName) {
this._cachedProtocolName = this.getActiveTab().protocolName
this._entityColumnNames = new Set(
this.getActiveTab().rows
.map(x => x.sampleTypeName)
.concat(this.getActiveTab().rows.map(x => x.entityClassName))
.concat(['Entity', 'name']))
}
return this._entityColumnNames
},
/**
Return the sample or sheet uuid for reserved keys.
Otherwise return data in the cell at the give row/column.
@param {String|Number} rowIndexOrSampleName - The index (untransposed) or sample name of the desired row.
@param {String|Number} columnIndexOrHeaderName - The index (untransposed) or header name of the desired column.
@param {Object} defaultValue value to return by default if the requested row/sample or column are not found
@param {Boolean} asString For backwards compatibility with 2.4. If true (default), ensure the return value is a string.
Note:
asString=true does not coerce boolean to string because checkbox columns returned
boolean from getDataAt in 2.4, so the behavior is consistent with 2.4 at this time.
That is subject to change in the future. Also note that asString=false does not
guarantee the return value is _not_ a string at this time. It just doesn't force it
to be a string. In particular, in 3.0.x., after calling `api.setDataAt` with a string
value, calling `api.getDataData` with asString=false for that same cell may
return a string until the sheet is saved and refreshed.
*/
getDataAt: function (rowIndexOrSampleName, columnIndexOrHeaderName, defaultValue = undefined, asString = true) {
// special cases
const defaultOrRaise = (label, value) => {
if (defaultValue === undefined) {
// eslint-disable-next-line no-throw-literal
throw `Invalid ${label}: ${value}`
}
return defaultValue
}
if (['sample_sheet_uuid', 'ss_uuid'].includes(columnIndexOrHeaderName)) {
return this.sampleSheetUuid
}
let row = this.getModelRowData(rowIndexOrSampleName)
// row should be an object, so _any_ falsey value is invalid - return defaultValue.
if (!row) {
return defaultOrRaise('row or sample', rowIndexOrSampleName)
}
if (columnIndexOrHeaderName === 'sample_uuid') {
return row.sampleUuid
}
// simulate first column's name and index for backward compatibility
if (columnIndexOrHeaderName === 0 || this._getEntityColumnNames().has(columnIndexOrHeaderName)) {
return row.name
}
// if user provided 0, it means `Entity` column, but if index >0,
// we need to handle that in real model by shifting index left by 1 (done in ``getColumnModelIndex``)
let realColumnIndex = this.getModelColumnIndex(columnIndexOrHeaderName)
if (realColumnIndex < 0) {
return defaultOrRaise('column index or header name', columnIndexOrHeaderName)
}
let value = row.cells[realColumnIndex].value
// for backwards compatibility with ESP < 3.0.
// TODO: In the future, it would be nice if (a) getDataAt consistently returned
// the hydrated list (or hydrated object type, whatever the column type) instead of
// sometimes returning an object and other times a string and (b) returning string vs.
// hydrated object was a global system flag so the behavior could eased into for customer upgrades.
if (value === null || value === '' || value === undefined) {
return value
}
if (asString && typeof value !== 'string') {
if (typeof value === 'object') {
return JSON.stringify(value)
} else if (typeof value === 'boolean') {
// 2.4 returned booleans for checkbox/complete... so we will, too, regardless of asString.
return value
} else {
// This branch of logic should never be reached as all types are either strings,
// or JSON objects, except for checkboxes (booleans), now that we do not auto-convert
// numeric types. But keep here in case there's a flaw in logic or new types are introduced.
try {
return value.toString()
} catch (e) {
console.error(`Caught error trying to coerce value ${value} to string; returning as-is`, e)
}
}
}
return value
},
/**
Set a cell's data to a given value at a give row/column position.
@param {String|Number} rowIndexOrSampleName - The index (untransposed) or sample name of the desired row.
@param {String|Number} columnIndexOrHeaderName - The index (untransposed) or header name of the desired column.
@param {*} value - The value to set.
@throws {Error} - Entity" column is not referable in model, thus 0 as an column index is forbidden.
*/
setDataAt: function (rowIndexOrSampleName, columnIndexOrHeaderName, value) {
// for maintaining backward compatibility we need to shift index left by 1
// eslint-disable-next-line no-throw-literal
if (columnIndexOrHeaderName === 0) throw this.errorMsgs.entityColRef
// todo we will need to determine why the null value triggers an infinite loop. this is a workaround
if (value === undefined || value === null) {
value = ''
}
let columnIndex = this.getModelColumnIndex(columnIndexOrHeaderName)
let row = this.getModelRowData(rowIndexOrSampleName)
let cell = row.cells[columnIndex]
let column = cell.findColumn()
let currentValue = cell.value
if (typeof currentValue === 'object' || Array.isArray(currentValue)) {
currentValue = JSON.stringify(currentValue)
}
if (column.varType === 'location') {
let tab = cell.findTab()
WorksheetController.fillContainerCells(
tab,
WorksheetController.containerValueToContainerSpec(cell, value)
)
} else if (value !== currentValue) {
// Should this be WorksheetController.changeCellValue? Do we want setDataAt to be able
// to override all the logic included there?
if (column.varType === 'complete') {
const row = cell.findRow()
row.setModelValue('complete', value)
row.setModelValue('uncompletable', value)
row.setModelValue('active', !value)
}
cell.setModelValue('value', value)
}
},
/**
Similarly to setDataAt, set a cell's data to a given value at a give row/column position. The difference with setDataAt is that when doing
bulk changes sometimes it isn't needed to trigger the worksheet's model onChange handlers (cellValueChanged), because this triggers
a rerender. So for performance reasons, setModelValueSilent should be called instead.
Note on `shouldCallChangeHandler` parameter: this parameter determines whether to call the onChange handler of the target column.
Defaults to true. Calling the onChange handler may introduce some overhead. Set it to false only for the cases when the onChange handler
of the target column doesn't have a side effect (ie, target column have also a change handler that affect some other value).
@param {String|Number} rowIndexOrSampleName - The index (untransposed) or sample name of the desired row.
@param {String|Number} columnIndexOrHeaderName - The index (untransposed) or header name of the desired column.
@param {*} value - The value to set.
@param {Boolean} shouldCallChangeHandler - Either to call the onChange handler of the target column. Defaults to true. Note: calling the onChange handler may introduce some overhead.
@throws {Error} - Entity" column is not referable in model, thus 0 as an column index is forbidden.
*/
// For performance reasons,
setDataAtSilent: function (rowIndexOrSampleName, columnIndexOrHeaderName, value, shouldCallChangeHandler = true) {
// for maintaining backward compatibility we need to shift index left by 1
// eslint-disable-next-line no-throw-literal
if (columnIndexOrHeaderName === 0) throw this.errorMsgs.entityColRef
// todo we will need to determine why the null value triggers an infinite loop. this is a workaround
if (value === undefined || value === null) {
value = ''
}
let columnIndex = this.getModelColumnIndex(columnIndexOrHeaderName)
let row = this.getModelRowData(rowIndexOrSampleName)
let cell = row.cells[columnIndex]
let column = cell.findColumn()
if (column.varType === 'location') {
let tab = cell.findTab()
WorksheetController.fillContainerCells(
tab,
WorksheetController.containerValueToContainerSpec(cell, value)
)
} else {
// calls the setModelValue without tirggering the onChange listeners
cell.setModelValueSilent('value', value)
if (shouldCallChangeHandler) {
// eslint-disable-next-line
this.activeComponent?.processColumnOnChange?.(cell.resourceValUuid, value)
}
}
},
/**
Return shared data from specific column.
Shared data is the same for every sample, so we can use any row to extract it.
To preserve convention used in other components function uses first sample as an reference.
@param {String|Number} sharedColumnIndexOrHeaderName - The index (untransposed) or header name of the desired column.
@param {Object} defaultValue value to return by default if the requested row/sample or column are not found
*/
getSharedDataAt: function (sharedColumnIndexOrHeaderName, defaultValue = undefined) {
// special cases
const defaultOrRaise = (label, value) => {
if (defaultValue === undefined) {
// eslint-disable-next-line no-throw-literal
throw `Invalid ${label}: ${value}`
}
return defaultValue
}
let column = this.getModelSharedColumn(sharedColumnIndexOrHeaderName)
if (!column || !column.shared) return defaultOrRaise('column index or header name', sharedColumnIndexOrHeaderName)
// using first sample as an reference to shared data
let row = this.getModelRowData(0)
if (!row) return defaultOrRaise('row or sample', 0)
const columnIndex = this.getModelSharedColumnIndex(sharedColumnIndexOrHeaderName)
let value = row.cells[columnIndex].value
// for backwards compatibility with ESP < 3.0.
// TODO: In the future, it would be nice if (a) getDataAt consistently returned
// the hydrated list (or hydrated object type, whatever the column type) instead of
// sometimes returning an object and other times a string and (b) returning string vs.
// hydrated object was a global system flag so the behavior could eased into for customer upgrades.
if (value === null || value === '') {
return value
}
if (column.varType === 'multiselect' && typeof value !== 'string') {
return JSON.stringify(value)
}
return value
},
/**
Set shared data to specific value.
Since shared data needs to be the same for every sample we need to update them all.
@param {String|Number} sharedColumnIndexOrHeaderName - The index (untransposed) or header name of the desired column.
@param {*} value - The value to set.
@throws {Error} - Setting data for not shared columns using this function is forbidden. Use ``setDataAt()`` instead.
*/
setSharedDataAt: function (sharedColumnIndexOrHeaderName, value) {
let column = this.getModelSharedColumn(sharedColumnIndexOrHeaderName)
if (!column.shared) throw this.errorMsgs.notSharedColumn
column.findCells().forEach(cell => {
cell.setModelValue('value', value)
})
},
/**
Utility function used by API interactions. Return a cell based on row index and column index/name.
@param {Number} rowIndex - The index (untransposed) of the cell's row.
@param {String|Number} columnIndexOrHeaderName - The index (untransposed) or header name of the cell's column.
*/
getCellForDropdown: function (rowIndex, columnIndexOrHeaderName) {
let tab = this.getActiveTab()
if (tab.group) {
$logger.error('Getting dropdown source not supported for grouped protocol rows at this time.')
return null
}
const colIndex = this.getModelColumnIndex(columnIndexOrHeaderName)
if (colIndex < 0) return null
let row = tab.rows[rowIndex]
return row.cells[colIndex]
},
/**
Return a cell's dropdown options.
@param {Number} rowIndex - The index (untransposed) of the cell's row.
@param {String|Number} columnIndexOrHeaderName - The index (untransposed) or header name of the cell's column.
*/
getDropdownSource: function (rowIndex, columnIndexOrHeaderName) {
let cell = this.getCellForDropdown(rowIndex, columnIndexOrHeaderName)
if (cell && cell.dropdown) {
return cell.dropdown
}
},
/**
Set a dropdown cell's options.
@param {Number} rowIndex - The index of the cell's row.
@param {String|Number} columnIndexOrHeaderName - The index or header name of the cell's column.
@param {String[]} source - The dropdown options.
*/
setDropdownSource: function (rowIndex, columnIndexOrHeaderName, source) {
let cell = this.getCellForDropdown(rowIndex, columnIndexOrHeaderName)
if (cell && cell.dropdown) {
cell.setModelValue('dropdown', source)
}
},
/**
Focus on a cell.
@param {Number} rowIndex - Index of the cell's row.
@param {String|Number} columnIndexOrHeaderName - Index or header name of the cell's column.
*/
setFocus: function (rowIndex, columnIndexOrHeaderName) {
if (this._viewOK(LIMSView.Grid)) {
// when in grid view use 'ag-Grid' api
let colId
if (this.isProtocolTransposed()) {
// assumption: column index for a sample is 1 + the row index for the sample
// because transpose introduces a column for the header names.
colId = this.getColumn(rowIndex + 1, false)?.colId
if (!colId) {
return
}
rowIndex = this.gridOptions.rowData
.filter(transposedColumn => !transposedColumn.shared)
.findIndex(transposedColumn => transposedColumn.headerName === columnIndexOrHeaderName)
// can't use "!rowIndex" here because rowIndex can be 0, which is a valid value...
if (rowIndex === null || rowIndex === undefined) {
return
}
} else {
colId = this.getColumn(columnIndexOrHeaderName, false)?.colId
if (!colId) {
// can happen due to ajax race conditions, where callback for a request
// is processed after a user has already navigated to a different
// protocol tab.
return
}
}
this.gridOptions.api.setFocusedCell(rowIndex, colId)
} else if (this._viewOK(LIMSView.Doc)) {
// in a doc view use custom method ``setFocusedCell`` in an active component
const colIndex = this.getModelColumnIndex(columnIndexOrHeaderName)
if (typeof this.activeComponent.setFocusedCell !== 'function') throw new Error(this.errorMsgs.noRequiredMethod)
this.activeComponent.setFocusedCell(rowIndex, colIndex)
} else {
// in other views (i.e. Flex) no-op
$logger.info('Current view does not have implemented `setFocus()` method.')
}
},
/**
* @deprecated since new LIMS data model use ``getSampleCount``
*/
getRowCount: function () {
const tab = this.getActiveTab()
return tab.rows.length
},
/**
@return {Number} - samples count
*/
getSampleCount: function () {
// API smoothing b/n ag-grid and HoT: getRowCount for grouped protocols
// in HoT is # of groups. But it's # of samples for ag grid.
const tab = this.getActiveTab()
return tab.rows.length
},
/**
Return the uuid of the current worksheet.
*/
getSampleSheetUuid: function () {
return this.sampleSheetUuid
},
/**
* Return the name of the current worksheet.
*/
getSampleSheetName: function () {
return this.sampleSheetName
},
/**
Return the uuid of the sample at a given row index.
@param {Number} index - Index of the row.
*/
getSampleUuidAt: function (index) {
const tab = this.getActiveTab()
return tab.rows[index].sampleUuid
},
/**
Return the uuids of all samples in the worksheet.
*/
getSampleUuids: function () {
const tab = this.getActiveTab()
return tab.rows.map(row => row.sampleUuid)
},
/**
* Return the sample UUIDs for all selected samples, regardless of whether the sheet is transposed or grouped.
* For grouped protocols, if a group is selected, the UUIDs of all samples in the group are returned.
*/
getSelectedSamples: function () {
return this.activeComponent.selectedSamplesUuids
},
/**
* Return cells UUIDs for all selected cells.
*/
getSelectedCells: function () {
return this.activeComponent.selectedCellsUuids
},
/**
Fetch JSON data from a given url.
Must provide callbacks or it won't work.
The Promise is returned so if you want to use Promises just pass no-op callbacks.
@param {String} url - The url to request.
@param {Function} onsuccess - Success callback.
@param {Function} onerror - Failure callback.
*/
getFromESP: function (url, onsuccess, onerror) {
let api = this
return fetch(url, {
method: 'GET',
credentials: 'include',
headers: { 'Content-Type': 'application/json' }
})
.then(response => api.checkResponseStatus(response))
.then(response => api.responseToJson(response))
.then(json => onsuccess(json))
.catch(error => onerror(error))
},
/**
Post JSON data to a given url.
Must provide callbacks or it won't work.
The Promise is returned so if you want to use Promises just pass no-op callbacks.
@param {String} url - The url to POST.
@param {Object} data - The JSON payload.
@param {Function} onsuccess - Success callback.
@param {Function} onerror - Failure callback.
*/
postToESP: function (url, data, onsuccess, onerror) {
let api = this
return fetch(url, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: data
})
.then(response => api.checkResponseStatus(response))
.then(response => api.responseToJson(response))
.then(json => onsuccess(json))
.catch(error => {
onerror(error)
})
},
/**
Utility functions used for api interaction. Checks status code of http
responses.
@param {Object} response - The response returned from ``fetch()``.
*/
checkResponseStatus: function (response) {
if (response.status >= 200 && response.status <= 300) {
return Promise.resolve(response)
} else {
let err = new Error(response.statusText)
err.response = response
return Promise.reject(err)
}
},
/**
Utility function used by api interactions. Returns response as JSON.
@param {Object} response - The response returned from ``fetch()``.
*/
responseToJson: function (response) {
if (response.headers.get('content-length') === '0') {
return Promise.resolve({})
}
return response.json()
},
/**
Put JSON data to a given url.
Must provide callbacks or it won't work.
The Promise is returned so if you want to use Promises just pass no-op callbacks.
@param {String} url - The url to PUT.
@param {Object} data - The JSON payload.
@param {Function} onsuccess - Success callback.
@param {Function} onerror - Failure callback.
*/
putToESP: function (url, data, onsuccess, onerror) {
let api = this
return fetch(url, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: data
})
.then(api.checkResponseStatus)
.then(api.responseToJson)
.then(json => onsuccess(json))
.catch(error => onerror(error))
},
/**
Add a custom protocol button to a worksheet.
@param {String} name - The text to show inside the button.
*/
addProtocolButton: function (name) {
let api = this
let button = document.createElement('input')
button.api = this
button.setOnClick = function (handler) {
button.onclick = function () {
let activeViewComponent = api.activeComponent
activeViewComponent.customProtocolButtonAction = true
Promise.resolve(handler.apply(button, arguments)).then(result => {
activeViewComponent.customProtocolButtonAction = false
})
}
}
button.setAttribute('type', 'button')
button.setAttribute('value', name)
button.style.marginLeft = '4px'
button.style.background = 'var(--esp-primary-button-background-color)'
button.className = 'btn primary'
button.style.color = 'var(--esp-primary-button-text-color)'
button.style.width = 'auto'
button.style.cursor = 'pointer'
document.getElementsByClassName('action-buttons')[0].appendChild(button)
return button
},
/**
Add a link to the worksheet action buttons area.
@param {Object} config - Attributes of the ``<a>`` tag.
*/
addProtocolLink: function (config) {
let link = document.createElement('a')
for (let attr in config) {
if (attr === 'url') {
link.href = config[attr]
} else if (attr === 'name') {
link.innerText = config[attr]
} else {
link[attr] = config[attr]
}
}
link.style.paddingLeft = '15px'
document.getElementsByClassName('action-buttons')[0].appendChild(link)
return link
},
/**
Run a pipeline.
@param {String} pipelineName - Name of the pipeline to run.
@param {Object} params - Environmental context for the pipeline.
@param {Function} callback - Run when pipeline is successfully launched.
*/
runPipeline: function (pipelineName, params, callback) {
// get the pipeline name...
let api = this
let url = '/api/pipelines?name=' + pipelineName
api.getFromESP(
url,
function (json) {
if (!Array.isArray(json) || json.length === 0) {
api.showNotification('Invalid pipeline: ' + pipelineName, 'error')
return
}
url = '/api/pipeline_instances'
let data = {
'pipeline': json[0]['uuid'],
'env': params,
'instances': true
}
api.postToESP(
url, JSON.stringify(data),
callback,
function (error) {
$logger.error(error)
api.showNotification(error, 'error')
})
},
function (error) {
$logger.error(error)
api.showNotification(error, 'error')
}
)
},
/**
Poll a list of pipeline instances for status updates.
@param {Object[]} instances - Array of pipeline instances to monitor.
@param {Function} doneHandler - Called when pipeline transitions to the ``done`` state.
@param {Function} failedHandler - Called when pipeline transitions to the ``failed`` state.
*/
pollPipelineInstances: function (instances, doneHandler, failedHandler) {
let api = this
let uuids = instances.map(function (x) { return x.uuid })
let url = '/api/pipeline_instances?uuids=' + JSON.stringify(uuids)
function responseHandler(json) {
// based on lab7.pipeline.TaskState, any state other than
// done or failed indicates a pipeline still in-process.
let running = json.filter(function (x) { return x.state !== 'done' && x.state !== 'failed' })
let done = json.filter(function (x) { return x.state === 'done' })
let failed = json.filter(function (x) { return x.state === 'failed' })
if (doneHandler !== undefined && doneHandler !== null) {
done.forEach(doneHandler)
}
if (failedHandler !== undefined && failedHandler !== null) {
failed.forEach(failedHandler)
}
if (running.length > 0) {
// hard-code to a 1s interval for now.
window.setTimeout(
function () {
api.pollPipelineInstances(running, doneHandler, failedHandler)
}, 5000
)
}
}
api.getFromESP(url, responseHandler, function (error) { api.error(error) })
},
/**
Show a toast message.
@param {String} message - $toast.description.
@param {String} category - $toast.status.
@param {String} title - optional $toast.title.
*/
showNotification: function (message, category, title = undefined) {
this.activeComponent.$toast({
status: category,
title: title || (category === 'error' ? 'Error' : 'Notification'),
description: message ? message.toString() : ''
})
},
/**
Send an email notification.
@param {String} subject - Email subject.
@param {String} body - Email body.
@param {Object} recipients - Criteria for selecting recipients:
@param {String[]} recipients.roles - users with given roles assigned,
@param {String[]} recipients.workgroups - users from given workgroups,
@param {String[]} recipients.users - users with given users names,
@param {String[]} recipients.emails - email addresses directly.
@param {String} sender - Sender email address.
*/
sendEmailNotification: async function (subject, body, recipients = {
roles: [],
workgroups: [],
users: [],
emails: []
}, sender) {
try {
const recipientsEmails = await NotificationService.recipientsEmails(recipients)
if (!recipientsEmails.length) {
this.activeComponent.$toast({
status: 'info',
title: 'Email notification',
description: 'Recipients not found. Notification has not been sent.'
})
return
}
const emailNotification = new EmailNotification(subject, body, recipientsEmails, sender)
await NotificationService.emailNotification(emailNotification)
this.activeComponent.$toast({
status: 'success',
title: 'Email notification',
description: 'Email notification has been sent successfully.'
})
} catch (error) {
$logger.error(error)
this.activeComponent.$toast({
status: 'error',
title: 'Email notification',
description: 'An error occurred.'
})
}
},
/**
Ask ESP api to evaluate an expression.
@param {String} expression - Expression to evaluate.
@param {Function} success - Success callback.
@param {Function} failure - Failure callback.
*/
evalExpression: function (expression, success, failure) {
let url = '/api/expressions/eval'
let api = this
let data = {
'expression': expression
}
if (!failure) {
failure = function (error) {
api.showNotification(error, 'error')
}
}
this.postToESP(url, JSON.stringify(data),
function (result) {
success(result['result'])
},
failure)
},
/**
* @return {Boolean} - true if any samples in a protocol are complete, false otherwise.
*/
anySamplesComplete: function () {
const tab = this.getActiveTab()
return tab.rows.some(row => row.failed || row.complete)
},
/**
@return {Boolean} - true if all samples in a protocol are complete, false otherwise.
*/
allSamplesComplete: function () {
const tab = this.getActiveTab()
return tab.rows.every(row => row.failed || row.complete)
},
/**
* @return {Boolean} - true if the protocol is grouped, false otherwise.
*/
isProtocolGrouped: function () {
const tab = this.getActiveTab()
return Boolean(tab.group)
},
/**
* @return {Boolean} - true if the protocol is in "transpose" mode, false otherwise.
*/
isProtocolTransposed: function () {
return this.activeView === LIMSView.Column
},
/** @return {Boolean} - true if the active protocol is the first protocol in the workflow. **/
isProtocolFirst: function () {
const tab = this.getActiveTab()
return this.worksheet.tabs.indexOf(tab) === 0
},
/** @return {Boolean} - true if the active protocol is the last protocol in the workflow. **/
isProtocolLast: function () {
const tab = this.getActiveTab()
return this.worksheet.tabs.indexOf(tab) === (this.worksheet.tabs.length - 1)
},
/** @return {Boolean} - true if there are modified cells in the worksheet. **/
isProtocolDirty: function () {
// todo: should be changed to current protocol only
return this.worksheet.isDirty()
},
/**
* Return the experiment uuid (workflow instance uuid) for the given row.
* Row is 0-based and refers to the non-transposed row number, regardless
* of whether the experiment is transposed. For grouped protocols, it is the
* "ungrouped" row number.
*
* @param {Number} rowIndex - non-transposed row index.
* @return {String|Null} - ``experimentUuid`` of specified row, null otherwise.
*/
experimentUuidAt: function (rowIndex) {
const tab = this.getActiveTab()
return rowIndex >= 0 && rowIndex <= tab.rows.length
? tab.rows[rowIndex].experimentUuid
: null
},
/**
* @return {Array<String>} - the list of experiment uuids for every row in the grid.
*/
experimentUuids: function () {
const tab = this.getActiveTab()
return tab.rows.map(row => row.experimentUuid)
},
/**
* @return {Array<String>} - the list of WorkflowChainInstance uuids for every row in the grid.
*/
chainInstanceUuids: function () {
const tab = this.getActiveTab()
return tab.rows.map(row => row.chainInstanceUuid)
},
/**
Add a ``Submit to Next Workflow`` chain transition button to a worksheet.
The button is added to the UI ONLY if this is the last protocol in the
workflow and at least one row is part of a workflow chain instance.
@param {String} label - Text to show inside the button.
@param {Boolean} allowPartialTransition - Do not require all samples to be completed.
*/
addChainButton: function (label = 'Submit to Next Workflow', allowPartialTransition = false) {
// TODO: add support for this directly to the clientLimsApi.
// For now, do directly here, but do some app detection; this means
// these buttons won't render in admin, which I'm ok with for now.
let api = this
// Only render chain button for last protocols.
if (!this.isProtocolLast()) {
return
}
let err = function (error) {
$logger.error(error)
if (api._viewOK(LIMSView.Grid)) api.gridOptions.api.hideOverlay()
api.showNotification(error, 'error')
}
// Allow for WFC submissions if _any_ associated WorkflowInstance is
// part of a WorkflowChain.
let workflowChainInstances = Array.from(new Set(api.chainInstanceUuids())).filter(x => x != null)
if (workflowChainInstances.length === 0) {
return
}
// Check to make sure we don't already have a chain button.
// Some scenarios result in double render of this button...
let buttons = document.getElementsByClassName('chain-transition-btn')
for (const b of buttons) {
// Remove the old ones in case they contain stale references.
b.remove()
}
// Add new button.
let button = api.addProtocolButton(label)
button.classList.add('chain-transition-btn')
button.setOnClick(function () {
button.disabled = true
// TODO: The constraints may need to be relaxed somewhat to
// allow for partial sample movement, etc. But for now,
// the backend endpoint we're using runs the transition
// rules/logic on each of the samples in the samplesheet, so
// we check accordingly.
let dataOk = api.allSamplesComplete()
if (!dataOk && !allowPartialTransition) {
api.showNotification('All samples must be complete or failed before sending to the next workflow.', 'error')
button.disabled = false
return
}
if (api.isProtocolDirty() > 0) {
api.showNotification(`Save the ${api.alias('worksheet')} before sending samples to the next workflow.`, 'error')
button.disabled = false
return
}
if (api._viewOK(LIMSView.Grid)) api.gridOptions.api.showLoadingOverlay()
api.putToESP(
'/api/sample_sheets/' + api.getSampleSheetUuid() + '/transition_chains',
JSON.stringify({}),
function (data) {
button.disabled = false
if (api._viewOK(LIMSView.Grid)) api.gridOptions.api.hideOverlay()
api.showNotification('Samples in process', 'success', 'Success')
},
function (e) {
button.disabled = false
err(e)
}
)
})
},
/**
@return {String} - the name of the active protocol.
*/
getProtocolName() {
const tab = this.getActiveTab()
return tab.protocolName
},
/**
@return {String} - the name of the workflow for the active protocol
*/
getWorkflowName() {
return this.worksheet.workflowName
},
/**
Ensure that fields marked as ``required`` are populated.
@return {Object<String, Boolean>} - { success: true } if valid, { success: false } otherwise.
*/
checkRequiredFields() {
const tab = this.getActiveTab()
const requiredCellsInRequiredColumns = tab.columns.reduce((result, column, colIndex) => {
if (column.required) {
tab.rows.filter(row => {
if (!row) return
if (!row.failed && row.complete && !row.cells[colIndex].value) {
result.push(column)
}
})
}
return result
}, [])
// in case multiple rows selected, this removes duplicate columns in list
let requiredColumns = [...new Set(requiredCellsInRequiredColumns)]
// note: should we return grid columns instead for backward compatibility?
return { success: !requiredCellsInRequiredColumns.length, columns: requiredColumns }
},
/**
Display a modal.
@param {String|Array|Node} header - Header to display, either HTML string, array of HTML nodes, or a single HTML node. Null means no header.
@param {String|Array|Node} content - Content to display, either HTML string, array of HTML nodes, or a single HTML node. Null means no content.
@param {String|Array|Node} footer - Footer to display, either HTML string, array of HTML nodes, or a single HTML node. Null means no footer.
*/
showModal(header, content, footer) {
this.modal = new FASModal(header, content, footer)
this.modal.showModal()
return this.modal
},
/**
Return the active modal node, if any modal is active.
Otherwise, return null.
*/
activeModal() {
if (!this.modal || !this.modal.activeModal()) {
return null
}
return this.modal.activeModal()
},
/**
Destroy the active modal, if any modal is active.
*/
destroyModal() {
if (!this.modal) {
return
}
this.modal.destroyModal()
this.modal = null
},
/**
Fast form creation with markup consistent with the
rest of the application.
@param {Array} config - The form configuration. Consists of a list of objects.
Each object must have key "label". Additional attributes may also be specified:
* type (String): one of "select", "text", and "button". Default is "text"
* name (String): form field name/id. Default is a normalized form of "label"
with adjacent spaces collapsed, collapsed spaced converted to "-", and
the full string lower-cased.
* default (Object): The default value.
* required (boolean): Whether the field is required
* options (Array): List of options. Only used for type="select"
* click (function): Callback for onClick. Only used for type="button"
*/
makeForm(config) {
let content = []
config.forEach(conf => {
if (!conf.label) {
$logger.error(`Bad form config: ${conf}!`)
return
}
if (!conf.name) {
conf.name = conf.label.split(' ').filter(x => x).join('-').toLowerCase()
}
if (!conf.type || conf.type === 'text') {
content.push(espTextField(conf))
} else if (conf.type === 'select') {
content.push(espSelectField(conf))
} else if (conf.type === 'button') {
content.push(espButton(conf))
}
})
return content
},
/**
* Adds a button to print the contents of the grid.
*
* @param api - client validation api object
* @param label print button label
* @param beforePrint function that is called prior to printing. Allows grid adjustments for printing purposes. If not provided, a default is used that hides the "Complete" column.
* @param afterPrint function that is called after printing. Allows grid adjustments for returning to "normal" purposes. If not provided, a default is used that shows the "Complete" column.
*/
addPrintGridButton(api, label, beforePrint, afterPrint, headMarkup, beforeTableMarkup, afterTableMarkup) {
// if user is in a wrong view, render button but after clicking,
// show a notification with an error about being in a not supported view
if (!this._viewOK(LIMSView.Grid)) {
let printButton = api.addProtocolButton(label)
printButton.setOnClick(function () {
api.activeComponent.$toast({
status: 'error',
title: 'View not supported.',
description: `Current view ("${api.activeView}") does not support printing data in a grid form, switch to either "Table" or "Table-transposed" view to print.`
})
})
return
}
async function setPrinterFriendly(gridApi, eGridDiv) {
let sizes = {
outer: [eGridDiv.style.width, eGridDiv.style.height],
inner: [eGridDiv.children[0].style.width, eGridDiv.children[0].style.height]
}
eGridDiv.style.width = ''
eGridDiv.style.height = ''
eGridDiv.children[0].style.width = ''
eGridDiv.children[0].style.height = ''
gridApi.setSideBarVisible(false)
gridApi.setDomLayout('print')
// setDomLayout does some asynchronous work (even though the interface is synchronous).
// Without the timeout below cell contents can fail to render when printing.
await new Promise(resolve => setTimeout(resolve, 100))
return sizes
}
function setNormal(gridApi, eGridDiv, sizes) {
eGridDiv.style.width = sizes.outer[0]
eGridDiv.style.height = sizes.outer[1]
eGridDiv.children[0].style.width = sizes.inner[0]
eGridDiv.children[0].style.height = sizes.inner[1]
gridApi.setDomLayout(null)
gridApi.setSideBarVisible(true)
}
if (!beforePrint) {
beforePrint = function (api) {
api.hideColumns(['Complete'])
}
}
if (!afterPrint) {
afterPrint = function (api) {
api.showColumns(['Complete'])
}
}
async function startPrinting() {
let gridApi = api.gridOptions.api
let tab = document.querySelector('#worksheet')
beforePrint(api)
let sizes = await setPrinterFriendly(gridApi, tab)
setTimeout(() => {
let stylesheetMarkup = '<style>html, body, .worksheet-tab {height: unset !important;}</style>'
for (const node of [...document.querySelectorAll('link[rel="stylesheet"], style')]) {
stylesheetMarkup += node.outerHTML
}
if (headMarkup) {
stylesheetMarkup += headMarkup
}
let printHtml = tab.innerHTML
if (beforeTableMarkup) {
printHtml = beforeTableMarkup + printHtml
}
if (afterTableMarkup) {
printHtml += afterTableMarkup
}
const iframe = document.createElement('iframe')
iframe.name = 'Print Protocol Grid'
document.body.appendChild(iframe)
const frameDoc = iframe.contentDocument
const printScript = `<script>window.addEventListener('load', () => {window.print()})</script>`
frameDoc.open()
frameDoc.write(`<html><head>${stylesheetMarkup}</head><body>${printHtml}</body>${printScript}</html>`)
frameDoc.close()
// handle some safari weirdness.
// inspired by https://stackoverflow.com/questions/48524702/safari-not-allowing-a-second-window-print
// but note that safari >= 13 supports onafterprint.
if (window.hasOwnProperty('onafterprint')) {
iframe.contentWindow.onafterprint = function (x) {
setNormal(gridApi, tab, sizes)
document.body.removeChild(iframe)
afterPrint(api)
}
} else {
let mediaQueryCallback = function (mediaQueryList) {
if (!mediaQueryList.matches && iframe) {
setNormal(gridApi, tab, sizes)
document.body.removeChild(iframe)
afterPrint(api)
}
}
let mediaQueryList = iframe.contentWindow.matchMedia('print')
mediaQueryList.addListener(mediaQueryCallback)
// the code below will trigger a cleanup in case a user hits Cancel button
// in Safari's additional print confirmation dialog ("This web page is trying to print").
iframe.focus()
iframe.onfocus = function () {
return mediaQueryCallback(mediaQueryList)
}
}
}, 1000)
}
const printButton = api.addProtocolButton(label)
printButton.setOnClick(startPrinting)
}
}
}
function espTextField(config) {
let defaultValue = ''
if (config['default']) {
defaultValue = config['default']
}
let ret = document.createElement('div')
ret.className = 'form-field'
ret.innerHTML = `
<div class="form-field">
<label for="${config.name}" class="data__label">${config.label}</label>
<div class="form-field__input">
<input type="text" size="${config.size || 4}" id="${config.name}" name="${config.name}" class="form-input" value="${defaultValue}"/>
</div>
</div>
`
return ret
}
function espSelectField(config) {
if (!config.options) {
config.options = []
}
if (!config.emptyOption) {
config.emptyOption = `Select a ${config.label.toLowerCase()}`
}
let ret = document.createElement('div')
ret.className = 'form-field'
let html = `
<label for="${config.name}" class="data__label">${config.label}</label>
<div class="form-field__input">
<select id="${config.name}" class="select-input" name="${config.name}")'>`
if (!config.required) {
html += `<option value="">${config.emptyOption}</option>`
}
html += `
${config.options.map(x => '<option value="' + x + '"' + (x === config.defaultValue ? ' selected>' : '>') + x + '</option>').join('')}
</select>
</div>`
ret.innerHTML = html
return ret
}
function espButton(config) {
let ret = document.createElement('button')
ret.style['font-family'] = 'Open Sans'
ret.style['font-size'] = '13px'
ret.style['font-weight'] = '600'
ret.style['border'] = '1px solid'
ret.style['vertical-align'] = 'middle'
ret.style['cursor'] = 'pointer'
ret.style['text-align'] = 'center'
ret.style['min-height'] = '26px'
ret.style['border-radius'] = '2px'
if (config.secondary) {
ret.className = 'btn secondary'
ret.style['background-color'] = 'var(--esp-secondary-button-background-color)'
ret.style['border-color'] = 'var(--esp-secondary-button-border-color)'
ret.style['color'] = 'var(--esp-secondary-button-text-color)'
} else {
ret.className = 'btn primary'
ret.style['background-color'] = 'var(--esp-primary-button-background-color)'
ret.style['border-color'] = 'var(--esp-primary-button-border-color)'
ret.style['color'] = 'var(--esp-primary-button-text-color)'
}
ret.innerHTML = `
<div class="wrapper">
<div class="label">${config.label}</div>
</div>
`
if (config.click) {
ret.onclick = config.click
}
return ret
}
class FASModal {
header = null
content = null
footer = null
constructor(header, content, footer) {
this.header = header
this.content = content
this.footer = footer
}
showModal() {
this.overlay = this._modal_overlay()
this.container = this._modal_container()
this.overlay.appendChild(this.container)
let headerWrapper = this._modal_header(this.header)
this.container.appendChild(headerWrapper)
let contentWrapper = this._modal_content(this.content)
this.container.appendChild(contentWrapper)
let footerWrapper = this._modal_footer(this.footer)
if (footerWrapper) {
this.container.appendChild(footerWrapper)
}
let body = document.getElementsByTagName('body')[0]
body.appendChild(this.overlay)
}
// eslint-disable-next-line
_modal_overlay() {
let overlay = document.createElement('div')
overlay.className = 'fas-modal-overlay'
overlay.style['background-color'] = 'rgba(black, 0.25)'
overlay.style['height'] = '100vh'
overlay.style['width'] = '100vw'
overlay.style['position'] = 'fixed'
overlay.style['top'] = '0'
overlay.style['left'] = '0'
overlay.style['display'] = 'flex'
overlay.style['align-items'] = 'center'
overlay.style['justify-content'] = 'center'
overlay.style['pointer-events'] = 'all'
overlay.style['z-index'] = '1000'
return overlay
}
// eslint-disable-next-line
_modal_container() {
let container = document.createElement('div')
container.className = 'fas-modal-container'
container.style['background-color'] = 'var(--esp-component-background-color)'
container.style['border-radius'] = '2px'
container.style['box-shadow'] = '0px 0px 4px rgba(0, 0, 0, 0.17)'
container.style['border-top'] = '6px solid var(--esp-color-primary)'
container.style['display'] = 'flex'
container.style['flex-direction'] = 'column'
container.style['margin-top'] = '0'
container.style['min-width'] = '440px'
container.style['padding'] = '0'
container.style['position'] = 'relative'
container.style['transition'] = 'all 300ms ease-out'
return container
}
// eslint-disable-next-line
_modal_header(header) {
let headerWrap = document.createElement('div')
headerWrap.className = 'fas-header'
headerWrap.style['display'] = 'flex'
headerWrap.style['flex-direction'] = '1'
headerWrap.style['justify-content'] = 'space-between'
headerWrap.style['align-items'] = 'center'
headerWrap.style['font-weight'] = '600'
headerWrap.style['padding'] = '16px 20px'
headerWrap.style['min-height'] = '56px'
let headerCont = document.createElement('div')
headerCont.className = 'fas-header--content'
headerCont.style.flex = '1'
headerWrap.appendChild(headerCont)
if (header) {
if (typeof header === 'string') {
headerCont.innerHTML = header
} else if (Array.isArray(header)) {
header.forEach(x => {
headerCont.appendChild(x)
})
} else {
headerCont.appendChild(header)
}
}
let headerActions = document.createElement('div')
let close = document.createElement('i')
close.innerText = 'close'
close.className = 'esp-icons close'
close.onclick = this.destroyModal.bind(this)
close.style['font-size'] = '24px'
close.style['cursor'] = 'pointer'
close.style['color'] = 'var(--esp-text-color-gray)'
close.style['font-style'] = 'normal'
close.style['font-family'] = "'esp-font' !important"
headerActions.appendChild(close)
headerWrap.appendChild(headerActions)
return headerWrap
}
// eslint-disable-next-line
_modal_content(content) {
let contentWrapper = document.createElement('div')
contentWrapper.className = 'fas-modal-content'
contentWrapper.style['padding'] = '0 20px 20px'
contentWrapper.style['height'] = '100%'
contentWrapper.style['flex'] = '1'
contentWrapper.style['max-height'] = 'calc(100vh - 160px)'
contentWrapper.style['overflow'] = 'auto'
if (typeof content === 'string') {
contentWrapper.innerHTML = content
} else if (Array.isArray(content)) {
content.forEach(x => {
contentWrapper.appendChild(x)
})
} else {
contentWrapper.appendChild(content)
}
return contentWrapper
}
// eslint-disable-next-line
_modal_footer(footer) {
if (!footer) {
return null
}
let footerWrapper = document.createElement('div')
if (typeof footer === 'string') {
footerWrapper.innerHTML = footer
} else if (Array.isArray(footer)) {
footer.forEach(x => {
footerWrapper.appendChild(x)
})
} else {
footerWrapper.appendChild(footer)
}
footerWrapper.style['display'] = 'flex'
footerWrapper.style['flex-direction'] = 'row'
footerWrapper.style['justify-content'] = 'flex-end'
footerWrapper.style['align-items'] = 'center'
footerWrapper.style['padding'] = '16px 20px'
footerWrapper.style['border-top'] = '1px solid var(--esp-base-border-color)'
return footerWrapper
}
activeModal() {
return document.querySelector('.fas-modal-container')
}
destroyModal() {
let modal = this.activeModal()
if (modal && modal === this.container) {
this.overlay.parentNode.removeChild(this.overlay)
}
}
}