diff --git a/docs/user-docs/logging.md b/docs/user-docs/logging.md index bafd383bd..f1c5ca4d8 100644 --- a/docs/user-docs/logging.md +++ b/docs/user-docs/logging.md @@ -213,10 +213,13 @@ Where, - `pcol`: Based on `pcol` stack node `type`. - `related`, `related-inline`: Based on `related` stack node `type`. - `related-link-picker`: Used for the association link picker. + - `related-unlink-picker`: Used for the association unlink picker. - `facet`: Based on `facet` stack node `type`. - `facet-picker`: Used for facet picker. - `fk`: Based on `fk` stack node `type`. - `fk-picker`: Used for foreign key picker. + - `fk-bulk-picker`: Used for foreign key picker when the selections fill the same foreign key field in multiple recordedit forms + - `fk-dropdown`: Used for foreign key inputs when they are a dropdown. - `saved-query-entity`: Used for saved query create popup. - `saved-query-picker`: Used for saved query apply picker. - `annotation-set`: Annotation list displayed on the viewer app. diff --git a/src/assets/scss/_input-switch.scss b/src/assets/scss/_input-switch.scss index d099de5b3..c3c89bc90 100644 --- a/src/assets/scss/_input-switch.scss +++ b/src/assets/scss/_input-switch.scss @@ -191,6 +191,7 @@ background: map-get(variables.$color-map, 'disabled-background'); opacity: 0.55; } + .spinner-border-sm { top: 8px; position: absolute; @@ -214,19 +215,21 @@ } // highlight the recordedit input for visual clarity - .dropdown.show { + .dropdown.show { + /** - * Select the input groups that are not disabled. react-bootstrap tends to toggle the `show` class for Dropdown toggles with a custom element type. + * Select the input groups that are not disabled. react-bootstrap tends to toggle the `show` class for Dropdown toggles with a custom element type. * this check avoid unexpected highlighting of disabled elements. */ >.chaise-input-group:not([aria-disabled="true"]), - .dropdown-menu{ + .dropdown-menu { border: 2px solid map-get(variables.$color-map, 'primary'); border-radius: 6px; } } - .responsive-dropdown-menu, .dropdown-menu { + .responsive-dropdown-menu, + .dropdown-menu { // make sure the menu expands the whole row width: auto; min-width: 100%; @@ -245,11 +248,11 @@ // remove rounded borders for search input components when in a dropdown .search-row .chaise-search-box { - .chaise-input-group-prepend > .chaise-input-group-text { + .chaise-input-group-prepend>.chaise-input-group-text { border-bottom-left-radius: 0; } - .chaise-input-group-append > .chaise-search-btn { + .chaise-input-group-append>.chaise-search-btn { border-bottom-right-radius: 0; } } @@ -264,6 +267,18 @@ .dropdown-item { cursor: pointer; + &.disabled, + &:disabled { + pointer-events: unset; + cursor: unset; + + // bootstrap stylesheets have :hover defined before :disabled so disabled always takes precedence + // redefine with the same hover color to override bootstrap order + &:hover { + background-color: map-get(variables.$color-map, 'recordedit-dropdown-hover'); + } + } + label { padding-left: 31px; cursor: inherit; @@ -328,7 +343,19 @@ &.input-switch-error-danger { color: map-get(variables.$color-map, 'input-error-message-danger'); } + &.input-switch-error-warning { color: map-get(variables.$color-map, 'input-error-message-warning'); } } + +// used for foreignkey-dropdown-field +// !important needs to be used here since bootstrap attaches styles directly to the component which override stylesheet styles +.tooltip.reposition-li-tooltip { + left: 10px !important; + + .tooltip-arrow { + left: 40px !important; + transform: unset !important; + } +} diff --git a/src/assets/scss/_inputs.scss b/src/assets/scss/_inputs.scss index 6e61828c5..7bc048df9 100644 --- a/src/assets/scss/_inputs.scss +++ b/src/assets/scss/_inputs.scss @@ -237,7 +237,7 @@ cursor: not-allowed; // We are adding this to fix the issue in firefox where the form is not clickable if its disabled pointer-events: none; - + // make sure the input is taking the input-disabled styles and not the default bootstrap/browser ones // (using * so it selects input, textarea, or any other HTML element that we might have *::placeholder, *:disabled { @@ -320,7 +320,7 @@ .chaise-input-group-text-sm { height: variables.$btn-height-sm; min-height: variables.$btn-height-sm; - padding: variables.$btn-padding-y variables.$btn-padding-x; + padding: variables.$btn-padding-sm-y variables.$btn-padding-sm-x; } .chaise-input-control-sm { diff --git a/src/assets/scss/_recordedit.scss b/src/assets/scss/_recordedit.scss index 5677cc87b..0e4881786 100644 --- a/src/assets/scss/_recordedit.scss +++ b/src/assets/scss/_recordedit.scss @@ -18,9 +18,18 @@ width: auto; margin-left: auto; - .add-rows-input { - width: 50px; - padding-left: 5px; + .chaise-input-group { + width: auto; + + .add-rows-input { + width: 45px; + padding-left: 5px; + padding-right: 5px; + } + } + + #recordedit-add-more { + margin-left: 10px; } } } @@ -404,61 +413,4 @@ padding-top: 10px; padding-bottom: 20px; } - - /****************** Upload modal css *****************/ - - // .progress-percent { - // font-weight: bold; - // margin-top: -25px; - // margin-left: 50%; - // } - - // .inner-progress-percent { - // margin-top: -18px; - // font-size: 12px; - // } - - // table.upload-table { - - // } - - // table.upload-table > tbody { - // border: none; - // } - - // table.upload-table > tbody > tr > td:first-child { - // padding-left: 20px; - - // } - - // table.upload-table > tbody > tr > td:last-child { - // width: 40%; - // } - - // table.upload-table > tbody > tr > td:nth-child(2) { - // padding-left: 0px; - // padding-right: 0px; - // } - - // table.upload-table > tbody > tr:last-child > td { - // border-bottom: 1px solid $disabled-background-color; - // background-color: white; - // } - - // table.upload-table > tbody > tr:first-child > td { - // padding-left:0px; - // padding-top: 15px; - // border-bottom: 2px solid gainsboro; - // font-weight: 600; - // } - - // table.upload-table .progress { - // height: 20px; - // } - - // .upload-progress-bar { - // width: 0%; - // background-color: #8cacc7 !important; - // } - } diff --git a/src/assets/scss/_recordset-table.scss b/src/assets/scss/_recordset-table.scss index bbc4b17d9..7c9038931 100644 --- a/src/assets/scss/_recordset-table.scss +++ b/src/assets/scss/_recordset-table.scss @@ -21,37 +21,18 @@ display: table-row-group; vertical-align: middle; - > tr > td.disabled-cell { - background-color: map-get(variables.$color-map, 'table-disabled-background'); - } - /* table hover */ > tr:hover { background-color: map-get(variables.$color-map, 'table-highlight-background') !important; - /* match color from row highlight for disabled cells */ - > td.disabled-cell { - background-color: map-get(variables.$color-map, 'table-highlight-background') !important; - } - .hover-show { visibility: visible; } } - /* odd disabled cells need to be darker */ - > tr:nth-child(odd) > td.disabled-cell { - background-color: map-get(variables.$color-map, 'table-striped-disabled-background'); - } - /* odd row hover for striped table */ > tr:nth-child(odd):hover { background-color: map-get(variables.$color-map, 'table-striped-highlight-background') !important; - - /* odd disabled cells need to match odd row hover for striped table above */ - > td.disabled-cell { - background-color: map-get(variables.$color-map, 'table-striped-highlight-background') !important; - } } } @@ -194,7 +175,27 @@ } .chaise-btn.select-action-button { - border-radius: 10px; + border-radius: 12px; + + &.chaise-btn-secondary { + border-width: 2px; + } + + &[disabled] { + min-width: 12px; + width: 12px; + height: 12px; + margin-top: 5px; + } + + // change size of single select icon to "fill" the whole button + .fa-regular.fa-circle, .fa-circle-check { + font-size: 1.6rem; + // added here instead of to the button as padding-top to only affect these buttons/icons + margin-top: 1px; + background-color: map-get(variables.$color-map, 'white') !important; + border-radius: inherit; + } } // make the button smaller (only visible for primary buttons. for others won't make a difference in UI and that's fine) diff --git a/src/assets/scss/_variables.scss b/src/assets/scss/_variables.scss index e7d91cb49..94b25a949 100644 --- a/src/assets/scss/_variables.scss +++ b/src/assets/scss/_variables.scss @@ -14,6 +14,8 @@ $btn-height-sm: 24px; $btn-border-width: 1px; $btn-padding-y: 4px; $btn-padding-x: 10px; +$btn-padding-sm-y: 2px; +$btn-padding-sm-x: 5px; // input $input-remove-width: 18px; diff --git a/src/assets/scss/helpers.scss b/src/assets/scss/helpers.scss index 33cc1b703..264bd6b34 100644 --- a/src/assets/scss/helpers.scss +++ b/src/assets/scss/helpers.scss @@ -88,8 +88,8 @@ -moz-user-select: none; -ms-user-select: none; white-space: nowrap; - /** - * Fixing button spacing issue. Fix- center aligning the buttons. Adding line-height will make the children of button to + /** + * Fixing button spacing issue. Fix- center aligning the buttons. Adding line-height will make the children of button to * use this property to override what bootstrap is defining. */ display: inline-flex; @@ -124,7 +124,7 @@ height: variables.$btn-height-sm; min-width: variables.$btn-height-sm; font-size: 1rem; - padding: 2px 5px; + padding: variables.$btn-padding-sm-y variables.$btn-padding-sm-x; } // can be used to write media-queries diff --git a/src/assets/scss/maps/_color-map.scss b/src/assets/scss/maps/_color-map.scss index 60494cfd8..cfcdd99dc 100644 --- a/src/assets/scss/maps/_color-map.scss +++ b/src/assets/scss/maps/_color-map.scss @@ -33,6 +33,7 @@ $color-map: ( 'primary': #4674a7, 'primary-hover': #428bca, 'recordedit-border': #888888, + 'recordedit-dropdown-hover': #e9ecef, // same color as bootstrap dropdown-item hover 'recordedit-highlighted-row': #f7f0cf, 'recordedit-column-permission-warning': rgb(180, 95, 6), 'spinner': #6e6e6e, diff --git a/src/components/input-switch/foreignkey-dropdown-field.tsx b/src/components/input-switch/foreignkey-dropdown-field.tsx index 9794b58c2..a6a03c845 100644 --- a/src/components/input-switch/foreignkey-dropdown-field.tsx +++ b/src/components/input-switch/foreignkey-dropdown-field.tsx @@ -1,19 +1,21 @@ // components import ClearInputBtn from '@isrd-isi-edu/chaise/src/components/clear-input-btn'; +import DisplayValue from '@isrd-isi-edu/chaise/src/components/display-value'; import Dropdown from 'react-bootstrap/Dropdown'; import InputField, { InputFieldProps } from '@isrd-isi-edu/chaise/src/components/input-switch/input-field'; -import DisplayValue from '@isrd-isi-edu/chaise/src/components/display-value'; import SearchInput from '@isrd-isi-edu/chaise/src/components/search-input'; import Spinner from 'react-bootstrap/Spinner'; +import ChaiseTooltip from '@isrd-isi-edu/chaise/src/components/tooltip'; // hooks import useError from '@isrd-isi-edu/chaise/src/hooks/error'; -import { useLayoutEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useFormContext } from 'react-hook-form'; // models -import { appModes, RecordeditColumnModel, RecordeditForeignkeyCallbacks } from '@isrd-isi-edu/chaise/src/models/recordedit'; import { LogActions, LogReloadCauses, LogStackPaths, LogStackTypes } from '@isrd-isi-edu/chaise/src/models/log'; +import { DisabledRow, DisabledRowType, SelectedRow } from '@isrd-isi-edu/chaise/src/models/recordset'; +import { appModes, RecordeditColumnModel, RecordeditForeignkeyCallbacks } from '@isrd-isi-edu/chaise/src/models/recordedit'; // services import { ConfigService } from '@isrd-isi-edu/chaise/src/services/config'; @@ -21,14 +23,12 @@ import { LogService } from '@isrd-isi-edu/chaise/src/services/log'; // utils import { RECORDSET_DEFAULT_PAGE_SIZE } from '@isrd-isi-edu/chaise/src/utils/constants'; -import { isStringAndNotEmpty } from '@isrd-isi-edu/chaise/src/utils/type-utils'; -import { makeSafeIdAttr } from '@isrd-isi-edu/chaise/src/utils/string-utils'; import { - callOnChangeAfterSelection, - clearForeignKeyData, - createForeignKeyReference, - validateForeignkeyValue + callOnChangeAfterSelection, clearForeignKeyData, createForeignKeyReference, + disabledRowTooltip, validateForeignkeyValue } from '@isrd-isi-edu/chaise/src/utils/recordedit-utils'; +import { makeSafeIdAttr } from '@isrd-isi-edu/chaise/src/utils/string-utils'; +import { isStringAndNotEmpty } from '@isrd-isi-edu/chaise/src/utils/type-utils'; import { windowRef } from '@isrd-isi-edu/chaise/src/utils/window-ref'; type ForeignkeyDropdownFieldProps = InputFieldProps & { @@ -119,7 +119,7 @@ const ForeignkeyDropdownField = (props: ForeignkeyDropdownFieldProps): JSX.Eleme const [dropdownReference, setDropdownReference] = useState(null); const [currentDropdownPage, setCurrentDropdownPage] = useState(null); - type DropdownRow = { tuple: any, isDisabled: boolean }; + type DropdownRow = { tuple: any, isDisabled: boolean, disabledType?: DisabledRowType }; const [dropdownRows, setDropdownRows] = useState([]); // array of page.tuples const [checkedRow, setCheckedRow] = useState(null); // ERMrest.Tuple @@ -162,14 +162,42 @@ const ForeignkeyDropdownField = (props: ForeignkeyDropdownFieldProps): JSX.Eleme }); }, [triggerDropdownChange]); + // if there is a unique association using prefill and this column is the same as the one used in the unique association for the leaf + // table of the association, update the rows in the dropdown if the selected rows change + useEffect(() => { + if ( + !props.foreignKeyCallbacks?.updateBulkForeignKeySelectedRows || + !dropdownInitialized || !props.foreignKeyCallbacks.bulkForeignKeySelectedRows + ) return; + + setShowSpinner(true); + + const currStackNode = LogService.getStackNode(LogStackTypes.FOREIGN_KEY, dropdownReference.table); + const stack = LogService.addExtraInfoToStack(LogService.getStackObject(currStackNode), { dropdown: 1 }) + + const requestCauses = [LogReloadCauses.BULK_FK_ROWS_CHANGED]; + const reloadStartTime = ConfigService.ERMrest.getElapsedTime(); + const logObj = { + action: LogService.getActionString(LogActions.RELOAD, stackPath), + stack: LogService.addCausesToStack(stack, requestCauses, reloadStartTime) + } + + populateDropdownRows(currentDropdownPage, checkedRow, pageLimit, logObj.stack, stackPath, requestCauses, reloadStartTime).then(() => { + setShowSpinner(false); + }).catch((exception: any) => { + setShowSpinner(false); + dispatchError({ error: exception }); + }); + }, [props.foreignKeyCallbacks?.bulkForeignKeySelectedRows]); + /** * populate the dropdown rows after a request is done. * this function will take care of calling the getDisabledTuples if it's defined and setting the tuples as disabled */ - const populateDropdownRows = (page: any, pageLimit: number, logStack: any, + const populateDropdownRows = (page: any, currentRow: SelectedRow | null, pageLimit: number, logStack: any, logStackPath: string, requestCauses?: any, reloadStartTime?: any): Promise => { return new Promise((resolve, reject) => { - type PType = { page: any, disabledRows?: any }; + type PType = { page: any, disabledRows?: DisabledRow[] }; let p; if (props.foreignKeyCallbacks && props.foreignKeyCallbacks.getDisabledTuples) { p = props.foreignKeyCallbacks.getDisabledTuples(page, pageLimit, logStack, logStackPath, requestCauses, reloadStartTime); @@ -178,15 +206,20 @@ const ForeignkeyDropdownField = (props: ForeignkeyDropdownFieldProps): JSX.Eleme } p.then((result: PType) => { - const disabledTuplesUniqueIDs: any = {}; + const disabledTuplesUniqueIDs: any = {}; // { uniqueId: { disabledType: DisabledRowType } } if (Array.isArray(result.disabledRows)) { - result.disabledRows.forEach((t: any) => { - disabledTuplesUniqueIDs[t.uniqueId] = 1; + result.disabledRows.forEach((t: DisabledRow) => { + disabledTuplesUniqueIDs[t.tuple.uniqueId] = { disabledType: t.disabledType }; }) } setDropdownRows(page.tuples.map((tuple: any) => { - return { isDisabled: (tuple.uniqueId in disabledTuplesUniqueIDs), tuple }; + // only disable if it's in the list AND it's not the current selection + const isDisabled = tuple.uniqueId in disabledTuplesUniqueIDs && tuple.uniqueId !== currentRow?.uniqueId + const row: DropdownRow = { tuple, isDisabled } + if (isDisabled) row.disabledType = disabledTuplesUniqueIDs[tuple.uniqueId].disabledType; + + return row; })); resolve(true); @@ -241,15 +274,19 @@ const ForeignkeyDropdownField = (props: ForeignkeyDropdownFieldProps): JSX.Eleme // we don't know which column value is used for the displayname so it's better to check react-hook-form state const displayedValue = getValues()[props.name]; + let currentRow = null; // check if we have a value set for the foreign key input if (displayedValue) { // set the checked row if it's present in the page of rows page.tuples.forEach((tuple: any) => { - if (tuple.displayname.value === displayedValue) setCheckedRow(tuple); + if (tuple.displayname.value === displayedValue) { + setCheckedRow(tuple); + currentRow = tuple; + } }); } - return populateDropdownRows(page, pageLimit, logObj.stack, stackPath); + return populateDropdownRows(page, currentRow, pageLimit, logObj.stack, stackPath); }).then(() => { setShowSpinner(false); @@ -264,9 +301,15 @@ const ForeignkeyDropdownField = (props: ForeignkeyDropdownFieldProps): JSX.Eleme * make sure the underlying raw columns as well as foreignkey data are also emptied. */ const onClear = () => { + const column = props.columnModel.column; + + if (props.foreignKeyCallbacks?.updateBulkForeignKeySelectedRows) { + props.foreignKeyCallbacks.updateBulkForeignKeySelectedRows(usedFormNumber); + } + clearForeignKeyData( props.name, - props.columnModel.column, + column, usedFormNumber, props.foreignKeyData, setValue @@ -306,7 +349,7 @@ const ForeignkeyDropdownField = (props: ForeignkeyDropdownFieldProps): JSX.Eleme searchRef.read(pageLimit, logObj).then((page: any) => { setCurrentDropdownPage(page); - return populateDropdownRows(page, pageLimit, logObj.stack, stackPath, requestCauses, reloadStartTime); + return populateDropdownRows(page, checkedRow, pageLimit, logObj.stack, stackPath, requestCauses, reloadStartTime); }).then(() => { setShowSpinner(false); }).catch((exception: any) => { @@ -373,13 +416,26 @@ const ForeignkeyDropdownField = (props: ForeignkeyDropdownFieldProps): JSX.Eleme onClearFun(e); } - const onRowSelected = (selectedRow: any, onChange: any) => { + /** + * + * @param selectedRow tuple object from ERMrestJS that represents the dropdown row + * @param onChange + */ + const onRowSelected = (selectedRow: SelectedRow, onChange: any) => { setCheckedRow(selectedRow); + + const column = props.columnModel.column; + + // if the recordedit page's table is an association table with a unique key pair, track the selected rows + if (props.foreignKeyCallbacks?.updateBulkForeignKeySelectedRows) { + props.foreignKeyCallbacks.updateBulkForeignKeySelectedRows(usedFormNumber, selectedRow); + } + callOnChangeAfterSelection( selectedRow, onChange, props.name, - props.columnModel.column, + column, usedFormNumber, props.foreignKeyData, setValue @@ -413,7 +469,7 @@ const ForeignkeyDropdownField = (props: ForeignkeyDropdownFieldProps): JSX.Eleme dropdownReference.read(newPageLimit, logObj).then((page: any) => { setCurrentDropdownPage(page); - return populateDropdownRows(page, pageLimit, logObj.stack, stackPath, requestCauses, reloadStartTime); + return populateDropdownRows(page, checkedRow, pageLimit, logObj.stack, stackPath, requestCauses, reloadStartTime); }).then(() => { setShowSpinner(false); @@ -423,9 +479,27 @@ const ForeignkeyDropdownField = (props: ForeignkeyDropdownFieldProps): JSX.Eleme }) } + const renderDropdownOptions = (onChange: any) => { if (!dropdownInitialized) return; + const renderOptionRow = (row: any) => ( + onRowSelected(row.tuple, onChange)} + disabled={row.isDisabled} + > + {row.tuple.uniqueId === checkedRow?.uniqueId && } + + + + ) + if (dropdownRows.length === 0) { // return a special row that doesn't use Dropdown.Item so it won't be selectable return ( @@ -442,24 +516,22 @@ const ForeignkeyDropdownField = (props: ForeignkeyDropdownFieldProps): JSX.Eleme ) } - return dropdownRows.map((row: DropdownRow) => { - return ( - onRowSelected(row.tuple, onChange)} - disabled={row.isDisabled} - > - {row.tuple.uniqueId === checkedRow?.uniqueId && } - - - ) - }) + + return dropdownRows.map((row: DropdownRow) => ( +
+ {row.disabledType ? + // only add tooltip if single select and disabled + +
{renderOptionRow(row)}
+
+ : renderOptionRow(row) + } +
+ )) } const rules: any = {}; diff --git a/src/components/input-switch/foreignkey-field.tsx b/src/components/input-switch/foreignkey-field.tsx index e49d307e4..5b9346ce0 100644 --- a/src/components/input-switch/foreignkey-field.tsx +++ b/src/components/input-switch/foreignkey-field.tsx @@ -26,10 +26,7 @@ import { LogService } from '@isrd-isi-edu/chaise/src/services/log'; // utils import { RECORDSET_DEFAULT_PAGE_SIZE } from '@isrd-isi-edu/chaise/src/utils/constants'; import { - callOnChangeAfterSelection, - clearForeignKeyData, - createForeignKeyReference, - validateForeignkeyValue + callOnChangeAfterSelection, clearForeignKeyData, createForeignKeyReference, validateForeignkeyValue } from '@isrd-isi-edu/chaise/src/utils/recordedit-utils'; import { makeSafeIdAttr } from '@isrd-isi-edu/chaise/src/utils/string-utils'; import { isStringAndNotEmpty } from '@isrd-isi-edu/chaise/src/utils/type-utils'; @@ -85,7 +82,6 @@ const ForeignkeyField = (props: ForeignkeyFieldProps): JSX.Element => { const { setValue, getValues } = useFormContext(); const [recordsetModalProps, setRecordsetModalProps] = useState(null); - const [showSpinner, setShowSpinner] = useState(false); const ellipsisRef = useRef(null); @@ -101,9 +97,15 @@ const ForeignkeyField = (props: ForeignkeyFieldProps): JSX.Element => { * make sure the underlying raw columns as well as foreignkey data are also emptied. */ const onClear = () => { + const column = props.columnModel.column; + + if (props.foreignKeyCallbacks?.updateBulkForeignKeySelectedRows) { + props.foreignKeyCallbacks.updateBulkForeignKeySelectedRows(usedFormNumber); + } + clearForeignKeyData( props.name, - props.columnModel.column, + column, usedFormNumber, props.foreignKeyData, setValue @@ -146,7 +148,20 @@ const ForeignkeyField = (props: ForeignkeyFieldProps): JSX.Element => { getValues ); + let currentSelectedRow; + const inputFKData = props.foreignKeyData?.current[`c_${usedFormNumber}-${props.columnModel.column.RID}`]; + if (inputFKData) { + currentSelectedRow = { + displayname: { + value: getValues(props.name), + isHTML: false + }, + uniqueId: props.columnModel.column.generateUniqueId(inputFKData) + } + } + setRecordsetModalProps({ + initialSelectedRows: currentSelectedRow ? [currentSelectedRow] : undefined, parentReference: props.parentReference, parentTuple: props.parentTuple, initialReference: ref.contextualize.compactSelectForeignKey, @@ -170,12 +185,18 @@ const ForeignkeyField = (props: ForeignkeyFieldProps): JSX.Element => { hideRecordsetModal(); const selectedRow = selectedRows[0]; + const column = props.columnModel.column; + + // if the recordedit page's table is an association table with a unique key pair, track the selected rows + if (props.foreignKeyCallbacks?.updateBulkForeignKeySelectedRows) { + props.foreignKeyCallbacks.updateBulkForeignKeySelectedRows(usedFormNumber, selectedRow); + } callOnChangeAfterSelection( selectedRow, onChange, props.name, - props.columnModel.column, + column, usedFormNumber, props.foreignKeyData, setValue diff --git a/src/components/input-switch/input-field.tsx b/src/components/input-switch/input-field.tsx index 55b846437..0a401e0a9 100644 --- a/src/components/input-switch/input-field.tsx +++ b/src/components/input-switch/input-field.tsx @@ -131,7 +131,7 @@ const InputField = ({ additionalControllerRules, }: InputFieldCompProps): JSX.Element => { - const { setValue, control, clearErrors ,trigger} = useFormContext(); + const { setValue, control, clearErrors, trigger} = useFormContext(); controllerRules = isObjectAndNotNull(controllerRules) ? controllerRules : {}; if (requiredInput) { @@ -191,6 +191,7 @@ const InputField = ({ if (handleChange && !handleChange(e)) { return; } + field.onChange(e); field.onBlur(); }; diff --git a/src/components/modals/recordset-modal.tsx b/src/components/modals/recordset-modal.tsx index 9dad5d04f..c813800d9 100644 --- a/src/components/modals/recordset-modal.tsx +++ b/src/components/modals/recordset-modal.tsx @@ -49,7 +49,7 @@ export type RecordestModalProps = { * and instead is just going to call this function. This is done this way * so we can apply the logic to disable the submit button */ - onSelectedRowsChanged?: (SelectedRow: SelectedRow[]) => boolean, + onSelectedRowsChanged?: (SelectedRow: SelectedRow[]) => boolean | string, /** * The function that will be called on submit * Note: the modal won't close on submit and if that's the expected behavior, @@ -126,7 +126,7 @@ const RecordsetModal = ({ * This will also allow us to set the state of submit button */ const [submittedRows, setSubmittedRows] = useState(() => ( - Array.isArray(recordsetProps.initialSelectedRows) ? recordsetProps.initialSelectedRows : [] + (Array.isArray(recordsetProps.initialSelectedRows) && selectMode !== RecordsetSelectMode.SINGLE_SELECT) ? recordsetProps.initialSelectedRows : [] )); /** @@ -156,8 +156,7 @@ const RecordsetModal = ({ // against it. if (submittedRows.length === 0) return; submit(); - } - else { + } else { let cannotSubmit = false; if (onSelectedRowsChanged) { cannotSubmit = onSelectedRowsChanged(submittedRows) === false; @@ -251,6 +250,16 @@ const RecordsetModal = ({ ) break; + case RecordsetDisplayMode.FK_POPUP_BULK_CREATE: + submitText = 'Continue'; + submitTooltip = ( + <> + Submit the selected records to fill in + + forms. + + ) + break; } let uiContextTitles: TitleProps[] | undefined, // the ui contexts that should be passed to recordset for the next level @@ -291,6 +300,18 @@ const RecordsetModal = ({ ); break; + case RecordsetDisplayMode.FK_POPUP_BULK_CREATE: + titleEl = ( +
+ Select a set of + + <span> + <span> for </span> + <Title reference={recordsetProps.parentReference} /> + </span> + </div> + ); + break; case RecordsetDisplayMode.PURE_BINARY_POPUP_ADD: uiContextTitles = [{ displayname: displayname }]; titleEl = ( diff --git a/src/components/record/related-table-actions.tsx b/src/components/record/related-table-actions.tsx index 57459ab0d..de9fa5fbe 100644 --- a/src/components/record/related-table-actions.tsx +++ b/src/components/record/related-table-actions.tsx @@ -17,11 +17,9 @@ import { CommentDisplayModes } from '@isrd-isi-edu/chaise/src/models/displayname import { LogActions, LogParentActions, LogReloadCauses, LogStackPaths, LogStackTypes } from '@isrd-isi-edu/chaise/src/models/log'; import { RecordRelatedModel } from '@isrd-isi-edu/chaise/src/models/record'; import { - RecordsetConfig, - RecordsetDisplayMode, - RecordsetProps, - RecordsetSelectMode, - SelectedRow, + DisabledRow, RecordsetConfig, + RecordsetDisplayMode, RecordsetProps, + RecordsetSelectMode, SelectedRow } from '@isrd-isi-edu/chaise/src/models/recordset'; // services @@ -347,9 +345,9 @@ const RelatedTableActions = ({ logStackPath: string, requestCauses?: any, reloadStartTime?: any - ): Promise<{ page: any, disabledRows?: any }> => { + ): Promise<{ page: any, disabledRows?: DisabledRow[] }> => { return new Promise((resolve, reject) => { - const disabledRows: any = []; + const disabledRows: DisabledRow[] = []; let action = LogActions.LOAD, newStack = logStack; @@ -369,9 +367,9 @@ const RelatedTableActions = ({ .then(function (newPage: any) { newPage.tuples.forEach(function (newTuple: any) { const index = page.tuples.findIndex(function (tuple: any) { - return tuple.uniqueId == newTuple.uniqueId; + return tuple.uniqueId === newTuple.uniqueId; }); - if (index > -1) disabledRows.push(page.tuples[index]); + if (index > -1) disabledRows.push({tuple: page.tuples[index] }); }); resolve({ disabledRows: disabledRows, page: page }); diff --git a/src/components/recordedit/form-container.tsx b/src/components/recordedit/form-container.tsx index 15ed9e993..b71aefd11 100644 --- a/src/components/recordedit/form-container.tsx +++ b/src/components/recordedit/form-container.tsx @@ -29,7 +29,6 @@ const FormContainer = ({ columnModels, config, forms, onSubmitValid, onSubmitInvalid, removeForm } = useRecordedit(); - const { handleSubmit } = useFormContext(); const formContainer = useRef<any>(null); @@ -77,6 +76,7 @@ const FormContainer = ({ const handleRemoveForm = (formIndex: number, formNumber: number) => { setRemoveFormIndex(formNumber); setRemoveClicked(true); + removeForm([formIndex]); }; diff --git a/src/components/recordedit/form-row.tsx b/src/components/recordedit/form-row.tsx index ec4d4eca6..3e9b685a1 100644 --- a/src/components/recordedit/form-row.tsx +++ b/src/components/recordedit/form-row.tsx @@ -13,6 +13,7 @@ import { CommentDisplayModes } from '@isrd-isi-edu/chaise/src/models/displayname // utils import { getDisabledInputValue } from '@isrd-isi-edu/chaise/src/utils/input-utils'; +import { disabledTuplesPromise } from '@isrd-isi-edu/chaise/src/utils/recordedit-utils'; import ResizeSensor from 'css-element-queries/src/ResizeSensor'; import { isObjectAndKeyDefined } from '@isrd-isi-edu/chaise/src/utils/type-utils'; import { makeSafeIdAttr } from '@isrd-isi-edu/chaise/src/utils/string-utils'; @@ -63,6 +64,8 @@ const FormRow = ({ columnPermissionErrors, foreignKeyData, waitingForForeignKeyData, + bulkForeignKeySelectedRows, + updateBulkForeignKeySelectedRows, getRecordeditLogStack, getRecordeditLogAction, showCloneSpinner, @@ -161,10 +164,10 @@ const FormRow = ({ * NOTE: it appears this useEffect is triggering after the "full repaint" even if there was a delay */ if (columnModelIndex === 0) { - // only run this on the first form row to keep track of total forms visible - if (!formsRef || !formsRef.current || !showCloneSpinner) return; + // only run this on the first form row to keep track of total forms visible + if (!formsRef || !formsRef.current || !showCloneSpinner) return; - if (formsRef.current.children.length === forms.length) setShowCloneSpinner(false); + if (formsRef.current.children.length === forms.length) setShowCloneSpinner(false); } }, [forms, removeClicked]); @@ -291,15 +294,16 @@ const FormRow = ({ }; const renderInput = (formNumber: number, formIndex?: number) => { - const colName = columnModel.column.name; - const colRID = columnModel.column.RID; + const column = columnModel.column; + const colName = column.name; + const colRID = column.RID; const isDisabled = getIsDisabled(formNumber, formNumber === MULTI_FORM_INPUT_FORM_VALUE); let placeholder = ''; let permissionError = ''; if (isDisabled) { - placeholder = getDisabledInputValue(columnModel.column); + placeholder = getDisabledInputValue(column); // TODO: extend this for edit mode // if value is empty string and we are in edit mode, use the previous value @@ -312,7 +316,27 @@ const FormRow = ({ permissionError = columnPermissionErrors[colName]; } - const safeClassNameId = `${formNumber}-${makeSafeIdAttr(columnModel.column.displayname.value)}`; + const safeClassNameId = `${formNumber}-${makeSafeIdAttr(column.displayname.value)}`; + + const tempForeignKeyCallbacks = { ...foreignKeyCallbacks }; + const bulkFKObject = reference.bulkCreateForeignKeyObject; + /** + * add foreignkey callbacks to generated input if: + * - there is a bulkCreateForeignKeyObject defined + * - there is a pair of columns that create a unique assocation that use the prefill behavior + * - the column is a foreignkey + * - and the column is the one used for associating to the leaf table of the association + */ + if (columnModel.isLeafInUniqueBulkForeignKeyCreate) { + tempForeignKeyCallbacks.getDisabledTuples = disabledTuplesPromise( + column.reference.contextualize.compactSelectBulkForeignKey, + bulkFKObject.disabledRowsFilter(), + bulkForeignKeySelectedRows + ); + + tempForeignKeyCallbacks.updateBulkForeignKeySelectedRows = updateBulkForeignKeySelectedRows; + tempForeignKeyCallbacks.bulkForeignKeySelectedRows = bulkForeignKeySelectedRows; + } return ( <> @@ -343,7 +367,7 @@ const FormRow = ({ parentLogStackPath={getRecordeditLogAction(true)} foreignKeyData={foreignKeyData} waitingForForeignKeyData={waitingForForeignKeyData} - foreignKeyCallbacks={foreignKeyCallbacks} + foreignKeyCallbacks={tempForeignKeyCallbacks} /> {typeof formIndex === 'number' && formIndex in showPermissionError && <div className={`column-permission-warning column-permission-warning-${safeClassNameId}`}>{permissionError}</div> diff --git a/src/components/recordedit/key-column.tsx b/src/components/recordedit/key-column.tsx index a8ac71a05..1701f549b 100644 --- a/src/components/recordedit/key-column.tsx +++ b/src/components/recordedit/key-column.tsx @@ -75,6 +75,12 @@ const KeyColumn = ({ const canShowMultiFormBtn = (columnIndex: number) => { const cm = columnModels[columnIndex]; + // hide the button if the foreign key values for this column are part of a unique key + // and the other part of that key is the prefiiled column that triggered the bulkForeignKey UI + if (cm.isLeafInUniqueBulkForeignKeyCreate) { + return false + } + // if we're already showing the multi form UI, then we have to show the button if (activeMultiForm === columnIndex) { return true; diff --git a/src/components/recordedit/recordedit.tsx b/src/components/recordedit/recordedit.tsx index 2a7dd94fa..0542c5a70 100644 --- a/src/components/recordedit/recordedit.tsx +++ b/src/components/recordedit/recordedit.tsx @@ -9,13 +9,14 @@ import DeleteConfirmationModal, { DeleteConfirmationModalTypes } from '@isrd-isi import FormContainer from '@isrd-isi-edu/chaise/src/components/recordedit/form-container'; import Footer from '@isrd-isi-edu/chaise/src/components/footer'; import KeyColumn from '@isrd-isi-edu/chaise/src/components/recordedit/key-column'; -import Title from '@isrd-isi-edu/chaise/src/components/title'; +import RecordsetModal from '@isrd-isi-edu/chaise/src/components/modals/recordset-modal'; import ResultsetTable from '@isrd-isi-edu/chaise/src/components/recordedit/resultset-table'; import ResultsetTableHeader from '@isrd-isi-edu/chaise/src/components/recordedit/resultset-table-header'; +import Title from '@isrd-isi-edu/chaise/src/components/title'; import UploadProgressModal from '@isrd-isi-edu/chaise/src/components/modals/upload-progress-modal'; // hooks -import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import useAlert from '@isrd-isi-edu/chaise/src/hooks/alerts'; import useAuthn from '@isrd-isi-edu/chaise/src/hooks/authn'; import useError from '@isrd-isi-edu/chaise/src/hooks/error'; @@ -24,11 +25,15 @@ import { FormProvider, useForm } from 'react-hook-form'; import ViewerAnnotationFormContainer from '@isrd-isi-edu/chaise/src/components/recordedit/viewer-annotation-form-container'; // models -import { LogActions, LogReloadCauses } from '@isrd-isi-edu/chaise/src/models/log'; +import { LogActions, LogStackPaths, LogStackTypes } from '@isrd-isi-edu/chaise/src/models/log'; import { - RecordeditConfig, RecordeditDisplayMode, - RecordeditModalOptions, RecordeditProps + appModes, RecordeditColumnModel, + RecordeditDisplayMode, RecordeditProps } from '@isrd-isi-edu/chaise/src/models/recordedit'; +import { + RecordsetConfig, RecordsetDisplayMode, + RecordsetProps, RecordsetSelectMode, SelectedRow +} from '@isrd-isi-edu/chaise/src/models/recordset'; // providers import AlertsProvider, { ChaiseAlertType } from '@isrd-isi-edu/chaise/src/providers/alerts'; @@ -39,12 +44,14 @@ import { LogService } from '@isrd-isi-edu/chaise/src/services/log'; import { ConfigService } from '@isrd-isi-edu/chaise/src/services/config'; // utils -import { attachContainerHeightSensors, attachMainContainerPaddingSensor } from '@isrd-isi-edu/chaise/src/utils/ui-utils'; -import { appModes, RecordeditColumnModel } from '@isrd-isi-edu/chaise/src/models/recordedit'; +import { RECORDEDIT_MAX_ROWS, RECORDSET_DEFAULT_PAGE_SIZE } from '@isrd-isi-edu/chaise/src/utils/constants'; +import { simpleDeepCopy } from '@isrd-isi-edu/chaise/src/utils/data-utils'; import { MESSAGE_MAP } from '@isrd-isi-edu/chaise/src/utils/message-map'; +import { + copyOrClearValue, disabledTuplesPromise, populateCreateInitialValues +} from '@isrd-isi-edu/chaise/src/utils/recordedit-utils'; +import { attachContainerHeightSensors, attachMainContainerPaddingSensor } from '@isrd-isi-edu/chaise/src/utils/ui-utils'; import { windowRef } from '@isrd-isi-edu/chaise/src/utils/window-ref'; -import { simpleDeepCopy } from '@isrd-isi-edu/chaise/src/utils/data-utils'; -import { copyOrClearValue } from '@isrd-isi-edu/chaise/src/utils/recordedit-utils'; const Recordedit = ({ appMode, @@ -78,7 +85,7 @@ const Recordedit = ({ onSubmitSuccess={onSubmitSuccess} onSubmitError={onSubmitError} > - <RecordeditInner parentContainer={parentContainer} /> + <RecordeditInner parentContainer={parentContainer} prefillRowData={prefillRowData} /> </RecordeditProvider> ); @@ -92,24 +99,34 @@ const Recordedit = ({ export type RecordeditInnerProps = { parentContainer?: HTMLElement; + prefillRowData?: any[]; } const RecordeditInner = ({ - parentContainer + parentContainer, + prefillRowData }: RecordeditInnerProps): JSX.Element => { const { validateSessionBeforeMutation } = useAuthn(); const { errors, dispatchError } = useError(); - const { addAlert } = useAlert(); + const { addTooManyFormsAlert } = useAlert(); const { - appMode, columnModels, config, foreignKeyData, initialized, modalOptions, reference, tuples, waitingForForeignKeyData, - addForm, getInitialFormValues, getPrefilledDefaultForeignKeyData, forms, MAX_ROWS_TO_ADD, removeForm, - showCloneSpinner, setShowCloneSpinner, showApplyAllSpinner, showSubmitSpinner, resultsetProps, uploadProgressModalProps, logRecordeditClientAction + appMode, columnModels, config, foreignKeyData, initialized, modalOptions, + prefillObject, bulkForeignKeySelectedRows, setBulkForeignKeySelectedRows, + reference, tuples, waitingForForeignKeyData, addForm, getInitialFormValues, + getPrefilledDefaultForeignKeyData, forms, MAX_ROWS_TO_ADD, removeForm, showCloneSpinner, setShowCloneSpinner, + showApplyAllSpinner, showSubmitSpinner, resultsetProps, uploadProgressModalProps, logRecordeditClientAction } = useRecordedit() const [formProviderInitialized, setFormProviderInitialized] = useState<boolean>(false); const [addFormsEffect, setAddFormsEffect] = useState<boolean>(false); + // the next 3 state variables are used when there is a prefill object for starting recordedit with more than one form to associate on creation + const [showBulkForeignKeyModal, setShowBulkForeignKeyModal] = useState<boolean>(false); + const [bulkForeignKeyRecordsetProps, setBulkForeignKeyRecordsetProps] = useState<RecordsetProps | null>(null); + // when initializing the page, the selections in the modal that appears first should fill the first form + const [bulkForeignKeySelectionsFillFirstForm, setBulkForeignKeySelectionsFillFirstForm] = useState<boolean>(true); + /** * The following state variable and function for modifying the state are defined here instead of the recordedit context for the reason * stated below from linked article. These properties are passed as props to the components so they only rerender when they need to instead @@ -251,7 +268,7 @@ const RecordeditInner = ({ // data is an object of key/value pairs for each piece of key information // { keycol1: val, keycol2: val2, ... } // TODO should be adjusted if we changed how we're tracking the tuples - const idx = tuples.findIndex(function (tuple: any) { + const idx = tuples.findIndex((tuple: any) => { return Object.keys(data).every(function (key) { return tuple.data[key] === data[key]; }); @@ -261,6 +278,7 @@ const RecordeditInner = ({ removedForms.push(idx); } }); + removeForm(removedForms, true); }; /** @@ -292,20 +310,31 @@ const RecordeditInner = ({ windowRef.location.reload(); }; - // once data is fetched, initialize the form data with react hook form + // once data is fetched, initialize the form data with RHF useEffect(() => { if (!initialized) return; + /** + * used to trigger recordset select view when selecting multiple foreign key values + * + * trigger the bulk foreign key modal when there are 2 foreign keys and + * we know the leaf column for the relation is visible in create mode + * + * if `bulkCreateForeignKeyObject` is defined on `reference`, we know the above is true + */ + if (reference.bulkCreateForeignKeyObject) openBulkForeignKeyModal(); + const initialValues = getInitialFormValues(forms, columnModels); methods.reset(initialValues); // in create mode, we need to fetch the foreignkey data // for prefilled and foreignkeys that have default values if (appMode === appModes.CREATE) { + // updates React hook form state with `setValue` getPrefilledDefaultForeignKeyData(initialValues, methods.setValue); } - setFormProviderInitialized(true) + setFormProviderInitialized(true); }, [initialized]); /** @@ -334,7 +363,7 @@ const RecordeditInner = ({ setAddFormsEffect(false); callAddForm(); - }, [addFormsEffect]) + }, [addFormsEffect]); const callAddForm = () => { // converts to number type. If NaN is returned, 1 is used instead @@ -350,56 +379,297 @@ const RecordeditInner = ({ // refactor so provider manages the forms const numberForms = forms.length; if ((numberFormsToAdd + numberForms) > MAX_ROWS_TO_ADD) { - const alertMessage = `Cannot add ${numberFormsToAdd} records. Please input a value between 1 and ${MAX_ROWS_TO_ADD - numberForms}, inclusive.`; - addAlert(alertMessage, ChaiseAlertType.ERROR); + // calculate the number of forms the user can still add + const numberFormsAllowed = MAX_ROWS_TO_ADD - numberForms + let alertMessage = `Cannot add ${numberFormsToAdd} records. Please input a value between 1 and ${numberFormsAllowed}, inclusive.`; + if (numberFormsAllowed === 0) alertMessage = `Cannot add ${numberFormsToAdd} records. Maximum number of forms already added.`; + addTooManyFormsAlert(alertMessage, ChaiseAlertType.ERROR); setShowCloneSpinner(false); return true; } + const newFormsObj: { tempFormValues: any, lastFormValue: number } = createNewForms(methods.getValues(), numberFormsToAdd); + + /** + * NOTE: This might be able to be optimized to use setValue for each value in the new forms instead of resetting EVERY form in react hook form + * for instance, 4 forms exist and 1 new form is added, this will call "reset" on all 5 forms + * + * Is it possible for this change to cause longer scripting time? For instance, iterating over every single cell for each new form + * could end up taking longer using setValue (and whatever happens in react-hook-form) vs no iteration and instead leaving it up to + * react-hook-form and how `methods.reset()` works + * + * A contradicting note, since each new form being added needs to render new input fields, form-row component will rerender. This + * means all input fields (already existing and new ones) will be rendered when new forms are added. Refactoring this might not change + * rendering performance at all. Maybe to prevent previous input fields from rerendering, the input-switch component should be memoized? + */ + methods.reset(newFormsObj.tempFormValues); + }; + + /** + * creates new forms by copying values from previous form + * + * @param formValues values for ALL forms + * @param numberFormsToAdd the number of forms to copy values for + * @returns an object with the new formValues and the form number for the last form + */ + const createNewForms = (formValues: any, numberFormsToAdd: number) => { // the indices used for tracking input values in react-hook-form const newFormValues: number[] = addForm(numberFormsToAdd); // the index for the data from last form being cloned const lastFormValue = newFormValues[0] - 1; - const tempFormValues: any = methods.getValues(); + let tempFormValues = { ...formValues }; // add data to tempFormValues to initailize new forms for (let i = 0; i < newFormValues.length; i++) { const formValue = newFormValues[i]; columnModels.forEach((cm: RecordeditColumnModel) => { + // don't copy the value for the leaf column for an assoication that is unique + if (cm.isLeafInUniqueBulkForeignKeyCreate) return; + copyOrClearValue(cm, tempFormValues, foreignKeyData.current, formValue, lastFormValue, false, true); }); // the code above is just copying the displayed rowname for foreignkeys, // we still need to copy the raw values - // but we cannot go basd on visible columns since some of these data might be for invisible fks. - reference.activeList.allOutBounds.forEach((col: any) => { - // copy the foreignKeyData (used for domain-filter support in foreignkey-field.tsx) - foreignKeyData.current[`c_${formValue}-${col.RID}`] = simpleDeepCopy(foreignKeyData.current[`c_${lastFormValue}-${col.RID}`]); - - // copy the raw data (submitted to ermrestjs) - col.foreignKey.colset.columns.forEach((col: any) => { - const val = tempFormValues[`c_${lastFormValue}-${col.RID}`]; - if (val === null || val === undefined) return; - tempFormValues[`c_${formValue}-${col.RID}`] = val; + // but we cannot go based on visible columns since some of this data might be for invisible fks. + tempFormValues = setOutboundForeignKeyValues(tempFormValues, formValue, lastFormValue); + } + + return { tempFormValues, lastFormValue }; + } + + /** + * set values in foreignkey data and formValues for all out foreign key columns + * + * @param formValues the existing values in the form + * @param formNumber the form number we are setting values for + * @param lastFormValue the last form number that values are copied from + * @param checkPrefill if prefill should be checked for copying + * @returns updated form values to set in react hook form + */ + const setOutboundForeignKeyValues = (formValues: any, formNumber: number, lastFormValue: number, checkPrefill?: boolean) => { + const tempFormValues = { ...formValues }; + reference.activeList.allOutBounds.forEach((col: any) => { + const bulkFKObject = reference.bulkCreateForeignKeyObject; + // don't copy the value if the column is the leaf column in a unique key for bullk foreign key create + if (bulkFKObject?.isUnique && col.name === bulkFKObject.leafColumn.name) return; + + // copy the foreignKeyData (used for domain-filter support in foreignkey-field.tsx) + foreignKeyData.current[`c_${formNumber}-${col.RID}`] = simpleDeepCopy(foreignKeyData.current[`c_${lastFormValue}-${col.RID}`]); + + if (checkPrefill) { + // check prefill object for the columns that are being prefilled to update the new forms since we aren't calling getPrefilledDefaultForeignKeyData() + if (prefillObject?.fkColumnNames.indexOf(col.name) !== -1) { + tempFormValues[`c_${formNumber}-${col.RID}`] = formValues[`c_${lastFormValue}-${col.RID}`]; + } + } + + // copy the raw data (submitted to ermrestjs) + col.foreignKey.colset.columns.forEach((col: any) => { + const val = formValues[`c_${lastFormValue}-${col.RID}`]; + if (val === null || val === undefined) return; + + tempFormValues[`c_${formNumber}-${col.RID}`] = val; + }); + }); + + return tempFormValues; + } + + // show the prefill bulk foreign key modal if we have a prefill object and bulk foreign key recordset props + const openBulkForeignKeyModal = () => { + const bulkFKObject = reference.bulkCreateForeignKeyObject; + // check for bulkCreateForeignKeyObject being defined since a malformed prefillObject should be ignored + // and the `bulkCreateForeignKeyObject` constructor will handle those malformed cases + if (!bulkFKObject) return; + + const domainRef: any = bulkFKObject.leafColumn.reference; + const andFilters: any[] = bulkFKObject.andFiltersForLeaf(); + + const modalReference = domainRef.addFacets(andFilters).contextualize.compactSelectBulkForeignKey; + + const recordsetConfig: RecordsetConfig = { + viewable: false, + editable: false, + deletable: false, + sortable: true, + selectMode: RecordsetSelectMode.MULTI_SELECT, + disableFaceting: false, + displayMode: RecordsetDisplayMode.FK_POPUP_BULK_CREATE + }; + + const stackElement = LogService.getStackNode( + LogStackTypes.FOREIGN_KEY, + domainRef.table, + { source: domainRef.compressedDataSource, entity: true, picker: 1 } + ); + + const logInfo = { + logStack: [stackElement], + logStackPath: LogService.getStackPath(null, LogStackPaths.FOREIGN_KEY_BULK_POPUP), + }; + + let getDisabledTuples; + if (bulkFKObject.isUnique) { + /** + * The existing rows in this table must be disabled so users doesn't resubmit them. + * + * set getDisabledTuples again since the selected rows could have changed since the last time the modal was opened + * selected rows can be changed by updating a single foreign key input, removing the value, or removing a form entirely + */ + getDisabledTuples = disabledTuplesPromise( + domainRef.contextualize.compactSelectBulkForeignKey, + bulkFKObject.disabledRowsFilter(), + bulkForeignKeySelectedRows + ); + } + + const pageSize = modalReference.display.defaultPageSize ? modalReference.display.defaultPageSize : RECORDSET_DEFAULT_PAGE_SIZE; + + // set recordset select view then set selected rows on "submit" + setBulkForeignKeyRecordsetProps({ + initialReference: modalReference, + initialPageLimit: pageSize, + config: recordsetConfig, + logInfo: logInfo, + parentReference: reference, + getDisabledTuples + }); + + setShowBulkForeignKeyModal(true); + } + + const onBulkForeignKeyModalRowsChanged = (rows: SelectedRow[]) => { + // return "false" to disable submit button in modal + let numForms = forms.length; + + // if we fill the first form, then reduce our calculation by 1 + // NOTE: forms.length "should" be 1 at this point before subtracting 1 + if (bulkForeignKeySelectionsFillFirstForm) numForms--; + + if (rows.length + numForms < RECORDEDIT_MAX_ROWS) { + return true; + } + + // there are too many forms trying to be added + const numberFormsAllowed = RECORDEDIT_MAX_ROWS - numForms; + let alertMessage = `Cannot select ${rows.length} records. Please input a value between 1 and ${numberFormsAllowed}, inclusive.`; + if (numberFormsAllowed === 0) alertMessage = `Cannot select ${rows.length} records. Maximum number of forms already added.`; + + return alertMessage; + } + + // user closes the modal without making any selections + const closeBulkForeignKeyCB = () => { + // if the page was loaded with a modal showing and it is dismissed, update app state variable and do nothing else + // ensure `bulkForeignKeySelectedRows` is initialized with an empty value + if (bulkForeignKeySelectionsFillFirstForm) { + setBulkForeignKeySelectionsFillFirstForm(false); + setBulkForeignKeySelectedRows([null]); + } + + setShowBulkForeignKeyModal(false); + } + + /** + * user makes selections in the multi select foreign key modal and clicks submit + * this function updates the selected rows (if the foreign keys are part of a unique key) and fills in the new forms based + * on the state of the app and the number of selected rows + * + * if the first modal is submitted after load of app page, one of the selected values will + * fill in the first form. After that, the selections will copy the last form's values or use default + * values based on what is set in the annotation (pending annotation implementation) + * + * NOTE: This should only be called if reference.bulkCreateForeignKeyObject is defined + * + * @param modalSelectedRows the selected rows from the foreign key modal + */ + const submitBulkForeignKeyCB = (modalSelectedRows: SelectedRow[]) => { + setShowBulkForeignKeyModal(false); + + // should not happen since submit button is greyed out + if (!modalSelectedRows || modalSelectedRows.length === 0) return; + + const bulkFKObject = reference.bulkCreateForeignKeyObject + if (bulkFKObject.isUnique) { + /** + * copy modalSelectedRows 2nd to preserve indexes in bulkForeignKeySelectedRows + * + * this function does 2 different things: + * - fills the first form and adds new forms + * - OR only adds new forms + * + * in both cases, the selected rows are added to the forms in the same order that + * the rows were selected in the modal. As we are adding new forms, we copy the + * values from the modalSelectedRows in the same index order + **/ + const newRows = [...bulkForeignKeySelectedRows, ...modalSelectedRows] + setBulkForeignKeySelectedRows(newRows); + } + + // recordedit has already been initialized so start adding new forms + const tempFormValues = methods.getValues(); + let initialValues = tempFormValues, + startFormNumber: number; + + if (bulkForeignKeySelectionsFillFirstForm) { + if (modalSelectedRows.length > 1) { + initialValues = createNewForms(tempFormValues, modalSelectedRows.length - 1).tempFormValues; + } + + startFormNumber = 1; + + setBulkForeignKeySelectionsFillFirstForm(false); + } else { + // use default values to fill new forms + const newFormValues: number[] = addForm(modalSelectedRows.length); + const newRowsModel = populateCreateInitialValues(columnModels, newFormValues, prefillObject, prefillRowData); + const newValues = newRowsModel.values; + + foreignKeyData.current = { + ...foreignKeyData.current, + ...newRowsModel.foreignKeyData + }; + + // NOTE: should we call getPrefilledDefaultForeignKeyData here instead of checking the prefillObject? + + startFormNumber = newFormValues[0]; + newFormValues.forEach((formNumber: number) => { + // copy values to object we want to use for RHF + Object.keys(newValues).forEach((key: string) => { + // we want to make sure we are only copying the data for newly created rows + if (key.startsWith(`c_${formNumber}-`)) { + initialValues[key] = newValues[key]; + } }); + + initialValues = setOutboundForeignKeyValues(initialValues, formNumber, startFormNumber - 1, true); }); } - /** - * NOTE: This might be able to be optimized to use setValue for each value in the new forms instead of resetting EVERY form in react hook form - * for instance, 4 forms exist and 1 new form is added, this will call "reset" on all 5 forms - * - * Is it possible for this change to cause longer scripting time? For instance, iterating over every single cell for each new form - * could end up taking longer using setValue (and whatever happens in react-hook-form) vs no iteration and instead leaving it up to - * react-hook-form and how `methods.reset()` works - * - * A contradicting note, since each new form being added needs to render new input fields, form-row component will rerender. This - * means all input fields (already existing and new ones) will be rendered when new forms are added. Refactoring this might not change - * rendering performance at all. Maybe to prevent previous input fields from rerendering, the input-switch component should be memoized? - */ - methods.reset(tempFormValues); - }; + // iterate selectedRows to fill in the fkey information + modalSelectedRows.forEach((row: SelectedRow, index: number) => { + if (foreignKeyData && foreignKeyData.current) { + foreignKeyData.current[`c_${startFormNumber + index}-${bulkFKObject.leafColumn.RID}`] = row.data; + } + + // find the raw value of the fk columns that correspond to the selected row + // since we've already added a not-null hidden filter, the values will be not-null. + bulkFKObject.leafColumn.foreignKey.colset.columns.forEach((col: any) => { + const referencedCol = bulkFKObject.leafColumn.foreignKey.mapping.get(col); + + // setFunction(`c_${formNumber}-${col.RID}`, selectedRow.data[referencedCol.name]); + initialValues[`c_${startFormNumber + index}-${col.RID}`] = row.data[referencedCol.name]; + }); + + // update "display" value + initialValues[`c_${startFormNumber + index}-${bulkFKObject.leafColumn.RID}`] = row.displayname.value; + }); + + // required to set values in all new forms in the RHF model + methods.reset(initialValues); + } const renderSpinner = () => { if (errors.length === 0 && (showDeleteSpinner || showSubmitSpinner || showCloneSpinner || showApplyAllSpinner)) { @@ -490,7 +760,7 @@ const RecordeditInner = ({ const renderBulkDeleteButton = () => { if (!canShowBulkDelete) return; - const tooltip = canEnableBulkDelete ? 'Delete the displayed set of records.': 'None of the displayed records can be deleted.'; + const tooltip = canEnableBulkDelete ? 'Delete the displayed set of records.' : 'None of the displayed records can be deleted.'; return <ChaiseTooltip placement='bottom' tooltip={tooltip}> <button id='bulk-delete-button' className='chaise-btn chaise-btn-primary' onClick={onBulkDeleteButtonClick} disabled={!canEnableBulkDelete}> <span className='chaise-btn-icon fa-regular fa-trash-alt'></span> @@ -578,6 +848,16 @@ const RecordeditInner = ({ onCancel={uploadProgressModalProps.onCancel} /> } + {showBulkForeignKeyModal && bulkForeignKeyRecordsetProps && + <RecordsetModal + modalClassName='bulk-foreign-key-popup' + recordsetProps={bulkForeignKeyRecordsetProps} + onSelectedRowsChanged={onBulkForeignKeyModalRowsChanged} + onSubmit={submitBulkForeignKeyCB} + onClose={closeBulkForeignKeyCB} + displayname={reference.bulkCreateForeignKeyObject.leafColumn.displayname} + /> + } </>); } @@ -705,42 +985,63 @@ const RecordeditInner = ({ </button> </ChaiseTooltip> : - <div className='chaise-input-group'> - <span className='chaise-input-group-prepend'> - <div className='chaise-input-group-text chaise-input-group-text-sm'>Qty</div> - </span> - <input - id='copy-rows-input' - ref={copyFormRef} - type='number' - className='chaise-input-control chaise-input-control-sm add-rows-input' - placeholder='1' - min='1' - /> - <span className='chaise-input-group-append'> + <> + <div className='chaise-input-group'> + <span className='chaise-input-group-prepend'> + <div className='chaise-input-group-text chaise-input-group-text-sm'>Qty</div> + </span> + <input + id='copy-rows-input' + ref={copyFormRef} + type='number' + className='chaise-input-control chaise-input-control-sm add-rows-input' + placeholder='1' + min='1' + max='200' + /> + <span className='chaise-input-group-append'> + <ChaiseTooltip + tooltip={ + allFormDataLoaded ? + 'Duplicate rightmost form the specified number of times.' : + 'Waiting for some columns to properly load.' + } + placement='bottom-end' + > + <button + id='copy-rows-submit' + className='chaise-btn chaise-btn-sm chaise-btn-secondary center-block' + onClick={() => { + setShowCloneSpinner(true); + setAddFormsEffect(true); + }} + type='button' + disabled={!allFormDataLoaded} + > + <span>Clone</span> + </button> + </ChaiseTooltip> + </span> + </div> + {bulkForeignKeyRecordsetProps && + // only show bulk foreign key modal button if we started with a bulk foreing key picker + // bulkForeignKeyRecordsetProps only get set if there is a `reference.bulkCreateForeignKeyObject` defined when the recordedit app loads <ChaiseTooltip - tooltip={ - allFormDataLoaded ? - 'Duplicate rightmost form the specified number of times.' : - 'Waiting for some columns to properly load.' - } + tooltip={`Select more ${reference.bulkCreateForeignKeyObject.leafColumn.displayname.value} for new forms`} placement='bottom-end' > <button - id='copy-rows-submit' - className='chaise-btn chaise-btn-sm chaise-btn-secondary center-block' - onClick={() => { - setShowCloneSpinner(true); - setAddFormsEffect(true); - }} + id='recordedit-add-more' + className='chaise-btn chaise-btn-sm chaise-btn-secondary' + onClick={openBulkForeignKeyModal} type='button' - disabled={!allFormDataLoaded} > - <span>Clone</span> + <span className='chaise-btn-icon fa-solid fa-plus' /> + <span>Add more</span> </button> </ChaiseTooltip> - </span> - </div> + } + </> } </div> </div>} diff --git a/src/components/recordset/recordset-table.tsx b/src/components/recordset/recordset-table.tsx index 80f0e1056..44e7d595e 100644 --- a/src/components/recordset/recordset-table.tsx +++ b/src/components/recordset/recordset-table.tsx @@ -14,7 +14,11 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'; // models import useRecordset from '@isrd-isi-edu/chaise/src/hooks/recordset'; import { LogActions, LogReloadCauses } from '@isrd-isi-edu/chaise/src/models/log'; -import { RecordsetConfig, RecordsetDisplayMode, RecordsetSelectMode, SelectedRow, SortColumn } from '@isrd-isi-edu/chaise/src/models/recordset'; +import { + DisabledRow, DisabledRowType, RecordsetConfig, + RecordsetDisplayMode, RecordsetSelectMode, + SelectedRow, SortColumn +} from '@isrd-isi-edu/chaise/src/models/recordset'; // utils import { CUSTOM_EVENTS } from '@isrd-isi-edu/chaise/src/utils/constants'; @@ -59,25 +63,62 @@ const RecordsetTable = ({ // used for related tables to fire an event when the content has loaded to scroll back to the top of the related table const [pagingSuccess, setPagingSuccess] = useState<boolean>(false); + type RowConfig = { + isSelected: boolean; + isDisabled: boolean; + disabledType: DisabledRowType | undefined; + } /** * capture the state of selected and disabled of rows in here so * we don't have to populate this multiple times */ - let isRowSelected = Array(page ? page.length : 0).fill(false); - if (page && page.length && Array.isArray(selectedRows) && selectedRows.length > 0) { - isRowSelected = page.tuples.map((tuple: any) => ( - // ermrestjs always returns a string for uniqueId, but internally we don't - // eslint-disable-next-line eqeqeq - selectedRows.some((obj) => obj.uniqueId == tuple.uniqueId) - )); - } - let isRowDisabled = Array(page ? page.length : 0).fill(false); - if (page && page.length && Array.isArray(disabledRows) && disabledRows.length > 0) { - isRowDisabled = page.tuples.map((tuple: any) => ( - // ermrestjs always returns a string for uniqueId, but internally we don't - // eslint-disable-next-line eqeqeq - disabledRows.some((obj) => obj.uniqueId == tuple.uniqueId) - )); + let rowDetails: RowConfig[] = Array(page ? page.length : 0).fill({ + isSelected: false, + isDisabled: false, + disabledType: undefined + }); + + const hasSelectedRows = Array.isArray(selectedRows) && selectedRows.length > 0, + hasDisabledRows = Array.isArray(disabledRows) && disabledRows.length > 0; + + if (page && page.length && (hasSelectedRows || hasDisabledRows)) { + const tempRowDetails: RowConfig[] = [] + for (let i = 0; i < page.tuples.length; i++) { + const tuple = page.tuples[i]; + const rowConfig: RowConfig = { + isSelected: false, + isDisabled: false, + disabledType: undefined + }; + // page.tuples.forEach((tuple: any, index: number) => { + if (hasSelectedRows) { + const row = selectedRows.find((obj: SelectedRow) => { + // ermrestjs always returns a string for uniqueId, but internally we don't + // eslint-disable-next-line eqeqeq + return obj.uniqueId == tuple.uniqueId + }); + + if (row) rowConfig.isSelected = true; + } + + if (hasDisabledRows) { + const row = disabledRows.find((obj: DisabledRow) => { + // ermrestjs always returns a string for uniqueId, but internally we don't + // eslint-disable-next-line eqeqeq + return obj.tuple.uniqueId == tuple.uniqueId + }); + + if (row) { + rowConfig.isDisabled = true; + rowConfig.disabledType = row.disabledType; + } + } + + tempRowDetails[i] = rowConfig; + // }); + } + + rowDetails = tempRowDetails; } /** @@ -156,8 +197,8 @@ const RecordsetTable = ({ const res: SelectedRow[] = Array.isArray(currRows) ? [...currRows] : []; if (!page) return res; page.tuples.forEach((tuple: any, index: number) => { - if (isRowDisabled[index]) return; - if (!isRowSelected[index]) { + if (rowDetails[index].isDisabled) return; + if (!rowDetails[index].isSelected) { res.push({ displayname: tuple.displayname, uniqueId: tuple.uniqueId, @@ -192,7 +233,8 @@ const RecordsetTable = ({ */ const onSelectChange = (tuple: any) => { setSelectedRows((currRows: SelectedRow[]) => { - const res: SelectedRow[] = Array.isArray(currRows) ? [...currRows] : []; + // since single select can have a row selected when the modal loads (foreign key input), we want to make sure the set is empty before adding to it + const res: SelectedRow[] = (Array.isArray(currRows) && config.selectMode !== RecordsetSelectMode.SINGLE_SELECT) ? [...currRows] : []; // see if the tuple is list of selected rows or not const rowIndex = res.findIndex((obj: SelectedRow) => obj.uniqueId === tuple.uniqueId); // if it's currently selected, then we should deselect (and vice versa) @@ -393,9 +435,10 @@ const RecordsetTable = ({ rowValues={rowValues} tuple={tuple} showActionButtons={showActionButtons} - selected={isRowSelected[index]} + selected={rowDetails[index].isSelected} onSelectChange={onSelectChange} - disabled={isRowDisabled[index]} + disabled={rowDetails[index].isDisabled} + disabledType={rowDetails[index].disabledType} />) }) } diff --git a/src/components/recordset/recordset.tsx b/src/components/recordset/recordset.tsx index c1c9f7885..88dcc6c0e 100644 --- a/src/components/recordset/recordset.tsx +++ b/src/components/recordset/recordset.tsx @@ -303,15 +303,15 @@ const RecordsetInner = ({ }) }; - const config: any = { + const requestConfig: any = { skipHTTP401Handling: true, headers: {} }; - config.headers[windowRef.ERMrest.contextHeaderName] = logObj; + requestConfig.headers[windowRef.ERMrest.contextHeaderName] = logObj; // attributegroup/CFDE:saved_query/RID;last_execution_status const updateSavedQueryUrl = windowRef.location.origin + savedQueryConfig.ermrestAGPath + '/RID;' + lastExecutedColumnName; - ConfigService.http.put(updateSavedQueryUrl, rows, config).then(() => { + ConfigService.http.put(updateSavedQueryUrl, rows, requestConfig).then(() => { // do nothing }).catch((error: any) => { $log.warn('saved query last executed time could not be updated'); diff --git a/src/components/recordset/saved-query-dropdown.tsx b/src/components/recordset/saved-query-dropdown.tsx index bd061eafb..462203a7a 100644 --- a/src/components/recordset/saved-query-dropdown.tsx +++ b/src/components/recordset/saved-query-dropdown.tsx @@ -193,6 +193,7 @@ const SavedQueryDropdown = ({ const columnModels: any[] = []; const rows: any[] = []; const tempSavedQueryReference = savedQueryReference.contextualize.entryCreate; + tempSavedQueryReference.computeBulkCreateForeignKeyObject(null); // set columns list tempSavedQueryReference.columns.forEach((col: any) => { diff --git a/src/components/recordset/table-row.tsx b/src/components/recordset/table-row.tsx index 7c9757f42..13bb40a2e 100644 --- a/src/components/recordset/table-row.tsx +++ b/src/components/recordset/table-row.tsx @@ -13,7 +13,7 @@ import useRecordset from '@isrd-isi-edu/chaise/src/hooks/recordset'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; // models -import { RecordsetConfig, RecordsetDisplayMode, RecordsetSelectMode } from '@isrd-isi-edu/chaise/src/models/recordset'; +import { DisabledRowType, RecordsetConfig, RecordsetDisplayMode, RecordsetSelectMode } from '@isrd-isi-edu/chaise/src/models/recordset'; import { LogActions, LogParentActions, LogReloadCauses, LogStackPaths, LogStackTypes } from '@isrd-isi-edu/chaise/src/models/log'; // services @@ -22,11 +22,12 @@ import $log from '@isrd-isi-edu/chaise/src/services/logger'; import { LogService } from '@isrd-isi-edu/chaise/src/services/log'; // utils -import { addQueryParamsToURL } from '@isrd-isi-edu/chaise/src/utils/uri-utils'; -import { windowRef } from '@isrd-isi-edu/chaise/src/utils/window-ref'; +import { CLASS_NAMES, CUSTOM_EVENTS } from '@isrd-isi-edu/chaise/src/utils/constants'; import { getRandomInt } from '@isrd-isi-edu/chaise/src/utils/math-utils'; +import { disabledRowTooltip } from '@isrd-isi-edu/chaise/src/utils/recordedit-utils'; import { fireCustomEvent } from '@isrd-isi-edu/chaise/src/utils/ui-utils'; -import { CLASS_NAMES, CUSTOM_EVENTS } from '@isrd-isi-edu/chaise/src/utils/constants'; +import { addQueryParamsToURL } from '@isrd-isi-edu/chaise/src/utils/uri-utils'; +import { windowRef } from '@isrd-isi-edu/chaise/src/utils/window-ref'; type TableRowProps = { config: RecordsetConfig, @@ -39,7 +40,8 @@ type TableRowProps = { showActionButtons: boolean, selected: boolean, onSelectChange: (tuple: any) => void, - disabled: boolean + disabled: boolean, + disabledType?: DisabledRowType } type ReadMoreStateProps = { @@ -56,7 +58,8 @@ const TableRow = ({ showActionButtons, selected, onSelectChange, - disabled + disabled, + disabledType }: TableRowProps): JSX.Element => { /** @@ -120,7 +123,13 @@ const TableRow = ({ * - the parent says that it should be disabled * - we're waiting for the delete request */ - const rowDisabled = disabled || waitingForDelete; + const rowDisabled = (disabled && !selected) || waitingForDelete; + let singleSelectIconTooltip = `Select${selected ? 'ed' : ''}`; + + if (rowDisabled && disabledType) { + // disabled will be a small grey button without an icon + singleSelectIconTooltip = disabledRowTooltip(disabledType); + } // TODO: logging const initializeOverflows = () => { @@ -179,7 +188,7 @@ const TableRow = ({ // attach an onload function that updates how many have loaded const imgTags = Array.from<HTMLImageElement>(rowContainer.current.querySelectorAll( `img.${CLASS_NAMES.CONTENT_LOADED}, .${CLASS_NAMES.CONTENT_LOADED} img` - )).filter(img => !img.complete); + )).filter(img => !img.complete); if (imgTags.length > numImages.current) numImages.current = imgTags.length const onImageLoad = () => { @@ -409,24 +418,21 @@ const TableRow = ({ } const renderActionButtons = () => { - switch (config.selectMode) { case RecordsetSelectMode.SINGLE_SELECT: - return ( - <ChaiseTooltip - placement='bottom-start' - tooltip='Select' + return (<ChaiseTooltip + placement='bottom-start' + tooltip={singleSelectIconTooltip} + > + <button + className={'select-action-button chaise-btn chaise-btn-secondary chaise-btn-sm icon-btn'} + type='button' + disabled={rowDisabled} + onClick={() => onSelectChange(tuple)} > - <button - className='select-action-button chaise-btn chaise-btn-primary chaise-btn-sm icon-btn' - type='button' - disabled={rowDisabled} - onClick={() => onSelectChange(tuple)} - > - <span className='chaise-btn-icon fa-solid fa-circle'></span> - </button> - </ChaiseTooltip> - ) + {selected && <span className={'chaise-btn-icon fa-solid fa-circle'}></span>} + </button> + </ChaiseTooltip>); case RecordsetSelectMode.MULTI_SELECT: return ( <div className='chaise-checkbox'> @@ -564,13 +570,11 @@ const TableRow = ({ ref={rowContainer} style={{ 'position': 'relative' }} > - {showActionButtons && - <td className={`block action-btns${rowDisabled ? ' disabled-cell' : ''}`}> - <div className='action-btns-inner-container'> - {renderActionButtons()} - </div> - </td> - } + {showActionButtons && <td className={`block action-btns${rowDisabled ? ' disabled-cell' : ''}`}> + <div className='action-btns-inner-container'> + {renderActionButtons()} + </div> + </td>} {renderCells()} </tr> {showDeleteConfirmationModal && diff --git a/src/models/log.ts b/src/models/log.ts index 3daa07d97..acfdde9cd 100644 --- a/src/models/log.ts +++ b/src/models/log.ts @@ -189,12 +189,13 @@ export enum LogStackPaths { ADD_PB_POPUP= 'related-link-picker', UNLINK_PB_POPUP= 'related-unlink-picker', FOREIGN_KEY_POPUP= 'fk-picker', + FOREIGN_KEY_BULK_POPUP= 'fk-bulk-picker', FOREIGN_KEY_DROPDOWN= 'fk-dropdown', FACET_POPUP= 'facet-picker', SAVED_QUERY_CREATE_POPUP= 'saved-query-entity', SAVED_QUERY_SELECT_POPUP= 'saved-query-picker', // these two have been added to the tables that recordedit is showing - // (but not used in logs technically since we're not showing any controls he) + // (but not used in logs technically since we're not showing any controls) RESULT_SUCCESFUL_SET= 'result-successful-set', RESULT_FAILED_SET= 'result-failed-set', RESULT_DISABLED_SET= 'result-disabled-set', @@ -225,6 +226,7 @@ export enum LogParentActions { // why we had to reload a request export enum LogReloadCauses { + BULK_FK_ROWS_CHANGED= 'bulk-foreignkey-selected-rows', // selected rows for bulk foreign key picker have changed CLEAR_ALL= 'clear-all', // clear all button CLEAR_CFACET= 'clear-cfacet', CLEAR_CUSTOM_FILTER= 'clear-custom-filter', @@ -253,5 +255,5 @@ export enum LogReloadCauses { RELATED_INLINE_DELETE= 'related-inline-delete', // a row in one of the related (inline) tables has been deleted RELATED_INLINE_UPDATE= 'related-inline-update', // a row in one of the related (inline) tables has been edited SORT= 'sort', // sort changed - SEARCH_BOX= 'search-box', // search box value changed + SEARCH_BOX= 'search-box' // search box value changed } diff --git a/src/models/recordedit.ts b/src/models/recordedit.ts index bf26b36f2..a48f14c81 100644 --- a/src/models/recordedit.ts +++ b/src/models/recordedit.ts @@ -1,5 +1,5 @@ import { - RecordsetProviderGetDisabledTuples + RecordsetProviderGetDisabledTuples, SelectedRow } from '@isrd-isi-edu/chaise/src/models/recordset'; export enum appModes { @@ -88,8 +88,27 @@ export type RecordeditModalOptions = { onClose: () => void; } +export type UpdateBulkForeignKeyRowsCallback = (formNumber: number, newRow?: SelectedRow) => void; export type RecordeditForeignkeyCallbacks = { + /** + * if defined, called before loading the foreign key picker or association modal + * + * This will disable the rows in the modal popup that are already associated with the main + * record we are associating more rows with. This will only occur when there is a prefillObject + * and the association is unique + */ getDisabledTuples?: RecordsetProviderGetDisabledTuples, + /** + * if defined, will be called after closing the modal selector + * + * This will call a function in recordedit provider to update the selected rows for the + * association popup if we have a prefillObject and the association is unique + */ + updateBulkForeignKeySelectedRows?: UpdateBulkForeignKeyRowsCallback, + /** + * if defined, will be used in foreign key & foreign key dropdown fields + */ + bulkForeignKeySelectedRows?: (SelectedRow | null)[], /** * if defined, will be used for validating the foreign key value. * @@ -129,6 +148,10 @@ export interface RecordeditColumnModel { * (used in viewer app to hide the columns) */ isHidden: boolean; + /** + * whether to trigger using the unqiue features of bulk foreign key create + */ + isLeafInUniqueBulkForeignKeyCreate: boolean; } export interface TimestampOptions { diff --git a/src/models/recordset.ts b/src/models/recordset.ts index 705b558c4..963a14757 100644 --- a/src/models/recordset.ts +++ b/src/models/recordset.ts @@ -8,9 +8,9 @@ import { TitleProps } from '@isrd-isi-edu/chaise/src/components/title'; export type RecordsetProviderGetDisabledTuples = ( page: any, pageLimit: number, logStack: any, logStackPath: string, requestCauses?: any, reloadStartTime?: any -) => Promise<{ page: any, disabledRows?: any }>; +) => Promise<{ page: any, disabledRows?: DisabledRow[] }>; -export type RecordsetProviderOnSelectedRowsChanged = (selectedRows: SelectedRow[]) => boolean +export type RecordsetProviderOnSelectedRowsChanged = (selectedRows: SelectedRow[]) => boolean | string export type RecordsetProps = { @@ -70,6 +70,7 @@ export enum RecordsetDisplayMode { POPUP = 'popup', FACET_POPUP = 'popup/facet', FK_POPUP = 'popup/foreignkey', + FK_POPUP_BULK_CREATE = 'popup/foreignkey/bulk', FK_POPUP_CREATE = 'popup/foreignkey/create', FK_POPUP_EDIT = 'popup/foreignkey/edit', PURE_BINARY_POPUP_ADD = 'popup/purebinary/add', @@ -183,6 +184,16 @@ export type SelectedRow = { // cannotBeRemoved?: boolean; } +export enum DisabledRowType { + ASSOCIATED= 'associated', // a row that is already associated + SELECTED= 'selected' // a row that is already selected in another recordedit form +} + +export type DisabledRow = { + disabledType?: DisabledRowType; + tuple: any; +} + export type RecordsetProviderAddUpdateCauses = ( /** * an array of strings that will be logged with the request diff --git a/src/pages/recordedit.tsx b/src/pages/recordedit.tsx index fa4c21b2f..1942bd24e 100644 --- a/src/pages/recordedit.tsx +++ b/src/pages/recordedit.tsx @@ -22,12 +22,13 @@ import { ConfigService, ConfigServiceSettings } from '@isrd-isi-edu/chaise/src/s import { LogService } from '@isrd-isi-edu/chaise/src/services/log'; // utils +import { APP_NAMES, ID_NAMES } from '@isrd-isi-edu/chaise/src/utils/constants'; +import { addAppContainerClasses } from '@isrd-isi-edu/chaise/src/utils/head-injector'; +import { MESSAGE_MAP } from '@isrd-isi-edu/chaise/src/utils/message-map'; +import { getPrefillObject } from '@isrd-isi-edu/chaise/src/utils/recordedit-utils'; import { isObjectAndKeyDefined } from '@isrd-isi-edu/chaise/src/utils/type-utils'; import { chaiseURItoErmrestURI, createRedirectLinkFromPath } from '@isrd-isi-edu/chaise/src/utils/uri-utils'; import { windowRef } from '@isrd-isi-edu/chaise/src/utils/window-ref'; -import { addAppContainerClasses, updateHeadTitle } from '@isrd-isi-edu/chaise/src/utils/head-injector'; -import { APP_NAMES, ID_NAMES } from '@isrd-isi-edu/chaise/src/utils/constants'; -import { MESSAGE_MAP } from '@isrd-isi-edu/chaise/src/utils/message-map'; const recordeditSettings : ConfigServiceSettings = { appName: APP_NAMES.RECORDEDIT, @@ -85,17 +86,23 @@ const RecordeditApp = (): JSX.Element => { } + let prefillObj = null; let logAppMode = LogAppModes.EDIT; if (appMode === appModes.COPY) { logAppMode = LogAppModes.CREATE_COPY; } else if (appMode === appModes.CREATE) { if (res.queryParams.invalidate && res.queryParams.prefill) { logAppMode = LogAppModes.CREATE_PRESELECT; + + prefillObj = getPrefillObject(res.queryParams); } else { logAppMode = LogAppModes.CREATE; } } + // initialize `ERMrest.BulkCreateForeignKeyObject` object on reference even if we don't have a prefillObj + reference.computeBulkCreateForeignKeyObject(prefillObj); + const logStack = [ LogService.getStackNode( LogStackTypes.SET, diff --git a/src/providers/alerts.tsx b/src/providers/alerts.tsx index e9345ce10..e65924e72 100644 --- a/src/providers/alerts.tsx +++ b/src/providers/alerts.tsx @@ -47,6 +47,8 @@ export const AlertsContext = createContext<{ removeAlert: RemoveAlertFunction, addURLLimitAlert: () => void, removeURLLimitAlert: () => void, + addTooManyFormsAlert:(message: string, type: ChaiseAlertType) => void, + removeTooManyFormsAlert:() => void, removeAllAlerts: () => void, } | // NOTE: since it can be null, to make sure the context is used properly with @@ -66,8 +68,9 @@ type AlertsProviderProps = { */ export default function AlertsProvider({ children }: AlertsProviderProps): JSX.Element { const [alerts, setAlerts] = useState<ChaiseAlert[]>([]); - // const [urlLimitAlert, setURLLimitAlert] = useState<ChaiseAlert|null>(null); + const urlLimitAlert = useRef<ChaiseAlert|null>(null); + const tooManyFormsAlert = useRef<ChaiseAlert|null>(null); /** * create add an alert @@ -108,11 +111,9 @@ export default function AlertsProvider({ children }: AlertsProviderProps): JSX.E * (we want to ensure only one alert is displayed at the time) */ const addURLLimitAlert = () => { - // if (urlLimitAlert) return; if (urlLimitAlert.current) return; - // setURLLimitAlert( - urlLimitAlert.current = addAlert(MESSAGE_MAP.URLLimitMessage, ChaiseAlertType.WARNING, () => urlLimitAlert.current = null) - // ); + + urlLimitAlert.current = addAlert(MESSAGE_MAP.URLLimitMessage, ChaiseAlertType.WARNING, () => urlLimitAlert.current = null) }; /** @@ -120,9 +121,32 @@ export default function AlertsProvider({ children }: AlertsProviderProps): JSX.E */ const removeURLLimitAlert = () => { if (!urlLimitAlert.current) return; + removeAlert(urlLimitAlert.current); urlLimitAlert.current = null; - // setURLLimitAlert(null); + } + + /** + * display the too many forms alert + * (we want to ensure only one alert is displayed at the time) + * + * @param message the alert message to show. this can differ depending on how many forms can still be added + * @param type the type of alert to show + */ + const addTooManyFormsAlert = (message: string, type: ChaiseAlertType) => { + if (tooManyFormsAlert.current) return; + + tooManyFormsAlert.current = addAlert(message, type, () => tooManyFormsAlert.current = null) + } + + /** + * remove too many forms alert + */ + const removeTooManyFormsAlert = () => { + if (!tooManyFormsAlert.current) return; + + removeAlert(tooManyFormsAlert.current); + tooManyFormsAlert.current = null; } @@ -133,7 +157,9 @@ export default function AlertsProvider({ children }: AlertsProviderProps): JSX.E removeAlert, removeAllAlerts, addURLLimitAlert, - removeURLLimitAlert + removeURLLimitAlert, + addTooManyFormsAlert, + removeTooManyFormsAlert } }, [alerts]); diff --git a/src/providers/recordedit.tsx b/src/providers/recordedit.tsx index fd6b3e4a5..d73a9b0b5 100644 --- a/src/providers/recordedit.tsx +++ b/src/providers/recordedit.tsx @@ -8,11 +8,12 @@ import useStateRef from '@isrd-isi-edu/chaise/src/hooks/state-ref'; // models import { appModes, LastChunkMap, PrefillObject, RecordeditColumnModel, - RecordeditConfig, RecordeditDisplayMode, RecordeditForeignkeyCallbacks, RecordeditModalOptions + RecordeditConfig, RecordeditDisplayMode, RecordeditForeignkeyCallbacks, + RecordeditModalOptions, UpdateBulkForeignKeyRowsCallback, UploadProgressProps } from '@isrd-isi-edu/chaise/src/models/recordedit'; import { LogActions, LogReloadCauses, LogStackPaths, LogStackTypes } from '@isrd-isi-edu/chaise/src/models/log'; import { NoRecordError } from '@isrd-isi-edu/chaise/src/models/errors'; -import { UploadProgressProps } from '@isrd-isi-edu/chaise/src/models/recordedit'; +import { SelectedRow } from '@isrd-isi-edu/chaise/src/models/recordset'; // providers import { ChaiseAlertType } from '@isrd-isi-edu/chaise/src/providers/alerts'; @@ -30,8 +31,7 @@ import { updateHeadTitle } from '@isrd-isi-edu/chaise/src/utils/head-injector'; import { MESSAGE_MAP } from '@isrd-isi-edu/chaise/src/utils/message-map'; import { URL_PATH_LENGTH_LIMIT } from '@isrd-isi-edu/chaise/src/utils/constants' import { - allForeignKeyColumnsPrefilled, - columnToColumnModel, getPrefillObject, + allForeignKeyColumnsPrefilled, columnToColumnModel, getPrefillObject, populateCreateInitialValues, populateEditInitialValues, populateSubmissionRow } from '@isrd-isi-edu/chaise/src/utils/recordedit-utils'; import { isObjectAndKeyDefined, isObjectAndNotNull } from '@isrd-isi-edu/chaise/src/utils/type-utils'; @@ -45,13 +45,19 @@ type ResultsetProps = { } export const RecordeditContext = createContext<{ - /* which mode of recordedit we are in */ + /** + * which mode of recordedit we are in + */ appMode: string, config: RecordeditConfig, modalOptions?: RecordeditModalOptions, - /* the main entity reference */ + /** + * the main entity reference + */ reference: any, - /* the tuples correspondeing to the displayed form */ + /** + * the tuples correspondeing to the displayed form + */ tuples: any, /** * the raw data of outbound foreign keys. used in foreignkey-field to support domain-filter @@ -60,27 +66,49 @@ export const RecordeditContext = createContext<{ */ foreignKeyData: any, waitingForForeignKeyData: boolean, - /* the created column models from reference.columns */ + /** + * the created column models from reference.columns + */ columnModels: RecordeditColumnModel[], - /* whether a value can be updated or not (key-value pair where key is the same structure as form values ) */ + /** + * whether a value can be updated or not (key-value pair where key is the same structure as form values ) + */ canUpdateValues: { [key: string]: boolean }; - /** precomputed column permission error that should be displayed to the users */ + /** + * precomputed column permission error that should be displayed to the users + */ columnPermissionErrors: { [columnName: string]: string }; - /* Whether the data for the main entity is fetched and the model is initialized */ + /** + * Whether the data for the main entity is fetched and the model is initialized + */ initialized: boolean, - /* Array of numbers for initalizing form data */ + /** + * Array of numbers for initalizing form data + */ forms: number[], - /* callback to add form(s) to the forms array */ + /** + * callback to add form(s) to the forms array + */ addForm: (count: number) => number[], - /* callback to remove from(s) from the forms array */ + /** + * callback to remove from(s) from the forms array + */ removeForm: (indexes: number[], skipLogging?: boolean) => void, - /* returns the initial values for all forms to display */ + /** + * returns the initial values for all forms to display + */ getInitialFormValues: (forms: number[], columnModels: RecordeditColumnModel[]) => any, - /* initiate the process of handling prefilled and default foreignkeys (in create mode) */ + /** + * initiate the process of handling prefilled and default foreignkeys (in create mode) + */ getPrefilledDefaultForeignKeyData: (initialValues: any, setValue: any) => void, - /* callback for react-hook-form to call when forms are valid */ + /** + * callback for react-hook-form to call when forms are valid + */ onSubmitValid: (data: any) => void, - /* callback for react-hook-form to call when forms are NOT valid */ + /** + * callback for react-hook-form to call when forms are NOT valid + */ onSubmitInvalid: (errors: any, e?: any) => void, /** * whether we should show the spinner indicating cloning form data @@ -98,12 +126,31 @@ export const RecordeditContext = createContext<{ showSubmitSpinner: boolean, resultsetProps?: ResultsetProps, uploadProgressModalProps?: UploadProgressProps, - /* for updating the last contiguous chunk tracking info */ + /** + * for updating the last contiguous chunk tracking info + */ setLastContiguousChunk: (arg0: any) => void, - /* useRef react hook to current value */ + /** + * useRef react hook to current value + */ lastContiguousChunkRef: any, - /* max rows allowed to add constant */ + /** + * max rows allowed to add constant + */ MAX_ROWS_TO_ADD: number, + /** + * the prefill object from cookie storage based on prefill query param + */ + prefillObject: PrefillObject | null, + /** + * the rows that are already in use in recoredit if we have a prefill object and the association is unique + */ + bulkForeignKeySelectedRows: (SelectedRow | null)[], + setBulkForeignKeySelectedRows: (val: (SelectedRow | null)[]) => void, + /** + * function for foreign key inputs to update the rows that are already in use in recoredit if we have a prefill object and the association is unique + */ + updateBulkForeignKeySelectedRows: UpdateBulkForeignKeyRowsCallback, /** * log client actions * Notes: @@ -256,6 +303,9 @@ export default function RecordeditProvider({ // an array of unique keys to for referencing each form const [forms, setForms] = useState<number[]>([1]); + const [prefillObject, setPrefillObject] = useState<PrefillObject | null>(null); + const [bulkForeignKeySelectedRows, setBulkForeignKeySelectedRows] = useState<(SelectedRow | null)[]>([]); + /** * NOTE the current assumption is that foreignKeyData is used only in * foreignkey-field.tsx for domain-filter support. @@ -276,10 +326,12 @@ export default function RecordeditProvider({ if (!reference || setupStarted.current) return; setupStarted.current = true; + // should only be available in create mode + const prefillObj = getPrefillObject(queryParams); const tempColumnModels: RecordeditColumnModel[] = []; reference.columns.forEach((column: any) => { const isHidden = Array.isArray(hiddenColumns) && hiddenColumns.indexOf(column.name) !== -1; - const cm = columnToColumnModel(column, isHidden, queryParams); + const cm = columnToColumnModel(column, isHidden, prefillObj, reference.bulkCreateForeignKeyObject); tempColumnModels.push(cm); }) setColumnModels([...tempColumnModels]); @@ -401,6 +453,8 @@ export default function RecordeditProvider({ updateHeadTitle('Create new ' + reference.displayname.value); } + if (prefillObj) setPrefillObject(prefillObj); + setInitialized(true); } else if (session) { const errMessage = MESSAGE_MAP.unauthorizedMessage + MESSAGE_MAP.reportErrorToAdmin; @@ -769,11 +823,28 @@ export default function RecordeditProvider({ return newFormValues; }; + /** + * + * @param indexes array of indexes to remove from forms array (and tuples array) + * @param skipLogging boolean to skip logging the remove action + */ const removeForm = (indexes: number[], skipLogging?: boolean) => { if (!skipLogging) { logRecordeditClientAction(LogActions.FORM_REMOVE); } + // bulkForeignKeySelectedRows is only used when there is a prefill object and there is a unique association + if (reference.bulkCreateForeignKeyObject?.isUnique) { + const tempSelectedRows = [...bulkForeignKeySelectedRows]; + + indexes.forEach((index: number) => { + // use splice to remove the element from the array and shift all array values after this element forward + tempSelectedRows.splice(index, 1); + }); + + setBulkForeignKeySelectedRows(tempSelectedRows); + } + // remove the forms based on the given indexes setForms((previous: number[]) => previous.filter(({ }, i: number) => !indexes.includes(i))); @@ -783,18 +854,42 @@ export default function RecordeditProvider({ // if reading the data for submission is done based on formValue (instead of index) this shouldn't matter } + /** + * when a single foreignkey input field value is changed or removed, removes old row from association selected rows + * and adds the new one if a new value was selected. Used when there is a prefill object and the association is unique + * + * @param formNumber the form number from forms array to remove + * @param newRow the new row to keep track of, if not defined removes the previous row + */ + const updateBulkForeignKeySelectedRows = (formNumber: number, newRow?: SelectedRow) => { + if (!reference.bulkCreateForeignKeyObject) return; + + const tempSelectedRows = [...bulkForeignKeySelectedRows]; + + // find the index in forms for the form number + const indexToChange = forms.indexOf(formNumber); + + if (newRow) { + // change the value at 'formNumber' + tempSelectedRows[indexToChange] = newRow + } else { + // remove value at form number without shifting other array values + // leaves an `empty` or `undefined` value at `indexToChange` in array + delete tempSelectedRows[indexToChange]; + } + + setBulkForeignKeySelectedRows(tempSelectedRows); + } + const getInitialFormValues = (forms: number[], columnModels: RecordeditColumnModel[]) => { let initialModel: any = { values: {} }; if (appMode === appModes.CREATE) { // NOTE: should only be 1 form for create... - initialModel = populateCreateInitialValues(columnModels, forms, queryParams, prefillRowData); + initialModel = populateCreateInitialValues(columnModels, forms, prefillObject, prefillRowData); setWaitingForForeignKeyData(initialModel.shouldWaitForForeignKeyData); shouldFetchForeignKeyData.current = initialModel.shouldWaitForForeignKeyData; - } else if (appMode === appModes.EDIT || appMode === appModes.COPY) { - - // using page.tuples here instead of forms initialModel = populateEditInitialValues(reference, columnModels, forms, tuplesRef.current, appMode); @@ -823,14 +918,12 @@ export default function RecordeditProvider({ return; } - const prefillObj = getPrefillObject(queryParams); - columnModels.forEach((colModel: RecordeditColumnModel, index: number) => { const column = colModel.column; if (!column.isForeignKey) return; // if it's a prefilled foreignkey, the value is going to be set by processPrefilledForeignKeys - if (prefillObj && prefillObj.fkColumnNames.indexOf(column.name) !== -1) { + if (prefillObject && prefillObject.fkColumnNames.indexOf(column.name) !== -1) { return; } @@ -838,8 +931,8 @@ export default function RecordeditProvider({ const defaultValue = initialValues[`c_1-${column.RID}`]; // if all the columns of the foreignkey are prefilled, use that instead of default - if (prefillObj && allForeignKeyColumnsPrefilled(column, prefillObj)) { - const defaultDisplay = column.getDefaultDisplay(prefillObj.keys); + if (prefillObject && allForeignKeyColumnsPrefilled(column, prefillObject)) { + const defaultDisplay = column.getDefaultDisplay(prefillObject.keys); // if the data is missing, ermrestjs will return null // although the previous allPrefilled should already guard against this. @@ -961,17 +1054,17 @@ export default function RecordeditProvider({ } /** - * In case of prefill and default we only have a reference to the foreignkey, - * we should do extra reads to get the actual data. - * - * NOTE for default we don't want to send the raw data to the ermrestjs request, - * that's why after fetching the data we're only changing the displayed rowname - * and the foreignKeyData, not the raw values sent to ermrestjs. - * @param formValue which form it is - * @param colRIDs the columns RIDs that will use this data - * @param fkRef the foreignkey reference that should be used for fetching data - * @param logObject - */ + * In case of prefill and default we only have a reference to the foreignkey, + * we should do extra reads to get the actual data. + * + * NOTE for default we don't want to send the raw data to the ermrestjs request, + * that's why after fetching the data we're only changing the displayed rowname + * and the foreignKeyData, not the raw values sent to ermrestjs. + * @param formValue which form it is + * @param colRIDs the columns RIDs that will use this data + * @param fkRef the foreignkey reference that should be used for fetching data + * @param logObject + */ function fetchForeignKeyData(colRIDs: string[], fkRef: any, logObject: any, setValue: any) { // NOTE since this is create mode and we're disabling the addForm, // we can assume this is the first form @@ -1094,6 +1187,12 @@ export default function RecordeditProvider({ lastContiguousChunkRef, MAX_ROWS_TO_ADD: maxRowsToAdd, + // prefill association modal + prefillObject, + bulkForeignKeySelectedRows, + setBulkForeignKeySelectedRows, + updateBulkForeignKeySelectedRows, + // log related: logRecordeditClientAction, getRecordeditLogAction, @@ -1102,7 +1201,8 @@ export default function RecordeditProvider({ }, [ // main entity: columnModels, columnPermissionErrors, initialized, reference, tuples, waitingForForeignKeyData, - forms, showCloneSpinner, showApplyAllSpinner, showSubmitSpinner, resultsetProps + forms, showCloneSpinner, showApplyAllSpinner, showSubmitSpinner, resultsetProps, + prefillObject, bulkForeignKeySelectedRows ]); return ( diff --git a/src/providers/recordset.tsx b/src/providers/recordset.tsx index 8a1b92a3e..9eeb9e3c5 100644 --- a/src/providers/recordset.tsx +++ b/src/providers/recordset.tsx @@ -6,9 +6,11 @@ import useAlert from '@isrd-isi-edu/chaise/src/hooks/alerts'; import useStateRef from '@isrd-isi-edu/chaise/src/hooks/state-ref'; // models +import { ChaiseAlert, ChaiseAlertType } from '@isrd-isi-edu/chaise/src/providers/alerts'; import { LogActions, LogStackPaths } from '@isrd-isi-edu/chaise/src/models/log'; import { FlowControlQueueInfo } from '@isrd-isi-edu/chaise/src/models/flow-control'; import { + DisabledRow, RecordsetConfig, RecordsetDisplayMode, RecordsetProviderAddUpdateCauses, RecordsetProviderFetchSecondaryRequests, @@ -25,7 +27,7 @@ import $log from '@isrd-isi-edu/chaise/src/services/logger'; import RecordsetFlowControl from '@isrd-isi-edu/chaise/src/services/recordset-flow-control'; // utils -import { RECORDSET_DEFAULT_PAGE_SIZE, URL_PATH_LENGTH_LIMIT } from '@isrd-isi-edu/chaise/src/utils/constants'; +import { RECORDEDIT_MAX_ROWS,RECORDSET_DEFAULT_PAGE_SIZE, URL_PATH_LENGTH_LIMIT } from '@isrd-isi-edu/chaise/src/utils/constants'; import { getColumnValuesFromPage } from '@isrd-isi-edu/chaise/src/utils/data-utils'; import { isObjectAndKeyDefined } from '@isrd-isi-edu/chaise/src/utils/type-utils'; import { createRedirectLinkFromPath } from '@isrd-isi-edu/chaise/src/utils/uri-utils'; @@ -126,7 +128,7 @@ export const RecordsetContext = createContext<{ /** * The rows that should be disabled */ - disabledRows: any, + disabledRows: DisabledRow[], /** * The rows that are selected */ @@ -286,7 +288,10 @@ export default function RecordsetProvider({ savedQueryConfig, }: RecordsetProviderProps): JSX.Element { const { dispatchError } = useError(); - const { addURLLimitAlert, removeURLLimitAlert } = useAlert(); + const { + addTooManyFormsAlert, removeTooManyFormsAlert, + addURLLimitAlert, removeURLLimitAlert + } = useAlert(); const [reference, setReference, referenceRef] = useStateRef<any>(initialReference); @@ -326,7 +331,7 @@ export default function RecordsetProvider({ const [totalRowCount, setTotalRowCount] = useState<number | null>(null); - const [disabledRows, setDisabledRows] = useState<any>([]); + const [disabledRows, setDisabledRows] = useState<DisabledRow[]>([]); /** * The selected rows @@ -334,6 +339,7 @@ export default function RecordsetProvider({ const [selectedRows, setStateSelectedRows] = useState<SelectedRow[]>(() => { return Array.isArray(initialSelectedRows) ? initialSelectedRows : []; }); + /** * A wrapper for the set state function to first call the onSelectedRowsChanged * @@ -352,6 +358,12 @@ export default function RecordsetProvider({ } else { removeURLLimitAlert(); } + } else if (config.displayMode === RecordsetDisplayMode.FK_POPUP_BULK_CREATE) { + if (typeof temp === 'string') { + addTooManyFormsAlert(temp, ChaiseAlertType.WARNING); + } else { + removeTooManyFormsAlert(); + } } else { return temp === false ? prevRows : res; } @@ -751,7 +763,7 @@ export default function RecordsetProvider({ } else { return { page: result.page }; } - }).then((result: { page: any, disabledRows?: any }) => { + }).then((result: { page: any, disabledRows?: DisabledRow[] }) => { if (current !== flowControl.current.queue.counter) { defer.resolve({ success: false, page: null }); return defer.promise; diff --git a/src/providers/viewer.tsx b/src/providers/viewer.tsx index efc2832ff..b7e2ef688 100644 --- a/src/providers/viewer.tsx +++ b/src/providers/viewer.tsx @@ -12,6 +12,7 @@ import { CustomError, DifferentUserConflictError, LimitedBrowserSupport, Multipl import { LogActions, LogAppModes, LogStackTypes } from '@isrd-isi-edu/chaise/src/models/log'; import { ViewerAnnotationModal } from '@isrd-isi-edu/chaise/src/models/viewer'; import { RecordeditDisplayMode, RecordeditProps, appModes } from '@isrd-isi-edu/chaise/src/models/recordedit'; +import { DisabledRow } from '@isrd-isi-edu/chaise/src/models/recordset'; // providers import { ChaiseAlertType } from '@isrd-isi-edu/chaise/src/providers/alerts'; @@ -870,7 +871,7 @@ export default function ViewerProvider({ const getAnnotatedTermDisabledTuples = ( page: any, pageLimit: number, logStack: any, logStackPath: string, requestCauses?: any, reloadStartTime?: any - ): Promise<{ page: any, disabledRows?: any }> => { + ): Promise<{ page: any, disabledRows?: DisabledRow[] }> => { return new Promise((resolve, reject) => { const annotConfig = ViewerConfigService.annotationConfig; @@ -920,7 +921,7 @@ export default function ViewerProvider({ return ref.contextualize.compactSelect.setSamePaging(page).read(pageLimit, logObj, false, true); }).then((disabeldPage: any) => { - const disabledRows: any = []; + const disabledRows: DisabledRow[] = []; disabeldPage.tuples.forEach((disabledTuple: any) => { // currently selected value should not be disabled @@ -930,7 +931,7 @@ export default function ViewerProvider({ const index = page.tuples.findIndex((tuple: any) => { return tuple.uniqueId === disabledTuple.uniqueId; }); - if (index > -1) disabledRows.push(page.tuples[index]); + if (index > -1) disabledRows.push({tuple: page.tuples[index]}); }); resolve({ page, disabledRows }); diff --git a/src/utils/message-map.ts b/src/utils/message-map.ts index 2249aca89..b93708b62 100644 --- a/src/utils/message-map.ts +++ b/src/utils/message-map.ts @@ -69,7 +69,10 @@ export const MESSAGE_MAP = { showDetails: 'Click to show more details about the filters', saveQuery: 'Click to save the current search criteria', export: 'Click to choose an export format.', - liveData: 'You are viewing snapshotted data. Click here to return to the live data catalog.' + liveData: 'You are viewing snapshotted data. Click here to return to the live data catalog.', + // tooltips for disabled rows in recordset single select modal for foreignkey-field and foreignkey-dropdown-field + selectedDisabledRow: 'This row is selected in another input in the form', + associatedDisabledRow: 'This row is already associated' }, URLLimitMessage: 'Maximum URL length reached. Cannot perform the requested action.', queryTimeoutList: '<ul class=\'show-list-style\'><li>Reduce the number of facet constraints.</li><li>Minimize the use of \'No value\' and \'All Records with Value\' filters.</li></ul>', diff --git a/src/utils/record-utils.ts b/src/utils/record-utils.ts index a41e70c58..2af2ff42e 100644 --- a/src/utils/record-utils.ts +++ b/src/utils/record-utils.ts @@ -1,7 +1,7 @@ // models -import { Displayname } from '@isrd-isi-edu/chaise/src/models/displayname'; import { LogStackPaths, LogStackTypes } from '@isrd-isi-edu/chaise/src/models/log'; import { RecordColumnModel, RecordRelatedModel } from '@isrd-isi-edu/chaise/src/models/record'; +import { PrefillObject } from '@isrd-isi-edu/chaise/src/models/recordedit'; import { RecordsetDisplayMode, RecordsetSelectMode } from '@isrd-isi-edu/chaise/src/models/recordset'; // services @@ -142,6 +142,7 @@ export function generateRelatedRecordModel(ref: any, index: number, isInline: bo ref.table, { source: ref.compressedDataSource, entity: true } ); + return { index, isInline, @@ -312,33 +313,11 @@ function canRelatedForeignKeyBePrefilled(fk: any, origFKR: any) { * @param mainTuple the main tuple * @returns */ -export function getPrefillCookieObject(ref: any, mainTuple: any): { - /** - * the displayed value in the form - */ - rowname: Displayname, - /** - * used for reading the actual foreign key data - */ - origUrl: string, - /** - * the foreignkey columns that should be prefilled - */ - fkColumnNames: string[], - /** - * raw values of the foreign key columns keyed by column name - */ - keys: { [key: string]: any }, - /** - * map of column names as keys to column RIDs as values - */ - columnNameToRID: { [key: string]: string } -} { +export function getPrefillCookieObject(ref: any, mainTuple: any): PrefillObject { let origTable; if (ref.derivedAssociationReference) { - // add association relies on the object that this returns for - // prefilling the data. + // add association relies on the object that this returns for prefilling the data. origTable = ref.derivedAssociationReference.table; } else { // we should contextualize to make sure the same table is shown in create mode diff --git a/src/utils/recordedit-utils.ts b/src/utils/recordedit-utils.ts index e870b1a1a..0a1cd5a8a 100644 --- a/src/utils/recordedit-utils.ts +++ b/src/utils/recordedit-utils.ts @@ -6,12 +6,12 @@ import { dataFormats } from '@isrd-isi-edu/chaise/src/utils/constants'; // models -import { LogStackPaths, LogStackTypes } from '@isrd-isi-edu/chaise/src/models/log'; +import { LogActions, LogStackPaths, LogStackTypes } from '@isrd-isi-edu/chaise/src/models/log'; import { - appModes, PrefillObject, RecordeditColumnModel, RecordeditForeignkeyCallbacks, - MULTI_FORM_INPUT_FORM_VALUE, TimestampOptions - -} from '@isrd-isi-edu/chaise/src/models/recordedit' + appModes, MULTI_FORM_INPUT_FORM_VALUE, PrefillObject, RecordeditColumnModel, + RecordeditForeignkeyCallbacks, TimestampOptions +} from '@isrd-isi-edu/chaise/src/models/recordedit'; +import { DisabledRow, DisabledRowType, SelectedRow } from '@isrd-isi-edu/chaise/src/models/recordset'; // services import { CookieService } from '@isrd-isi-edu/chaise/src/services/cookie'; @@ -19,19 +19,25 @@ import { LogService } from '@isrd-isi-edu/chaise/src/services/log'; import $log from '@isrd-isi-edu/chaise/src/services/logger'; // utilities +import { simpleDeepCopy } from '@isrd-isi-edu/chaise/src/utils/data-utils'; import { formatDatetime, formatFloat, formatInt, getInputType, replaceNullOrUndefined, isDisabled } from '@isrd-isi-edu/chaise/src/utils/input-utils'; +import { MESSAGE_MAP } from '@isrd-isi-edu/chaise/src/utils/message-map'; import { isNonEmptyObject, isObjectAndNotNull } from '@isrd-isi-edu/chaise/src/utils/type-utils'; -import { simpleDeepCopy } from '@isrd-isi-edu/chaise/src/utils/data-utils'; import { windowRef } from '@isrd-isi-edu/chaise/src/utils/window-ref'; /** * Create a columnModel based on the given column that can be used in a recordedit form * @param column the column object from ermrestJS */ -export function columnToColumnModel(column: any, isHidden?: boolean, queryParams?: any): RecordeditColumnModel { +export function columnToColumnModel( + column: any, + isHidden?: boolean, + prefillObject?: PrefillObject | null, + bulkFKObject?: any +): RecordeditColumnModel { const isInputDisabled: boolean = isDisabled(column); const logStackNode = LogService.getStackNode( column.isForeignKey ? LogStackTypes.FOREIGN_KEY : LogStackTypes.COLUMN, @@ -54,22 +60,26 @@ export function columnToColumnModel(column: any, isHidden?: boolean, queryParams } - const prefillObj = getPrefillObject(queryParams ? queryParams : {}); let isPrefilled = false, hasDomainFilter = false; if (column.isForeignKey) hasDomainFilter = column.hasDomainFilter; - if (prefillObj) { + let isLeafInUniqueBulkForeignKeyCreate = false; + if (prefillObject) { if (column.isForeignKey) { if ( // whether the fk is already marked as prefilled - prefillObj.fkColumnNames.indexOf(column.name) !== -1 || + prefillObject.fkColumnNames.indexOf(column.name) !== -1 || // or all the columns have the prefilled value, and therefore it should be marked as prefilled. - allForeignKeyColumnsPrefilled(column, prefillObj) + allForeignKeyColumnsPrefilled(column, prefillObject) ) { isPrefilled = true; } - } else if (column.name in prefillObj.keys) { + if (bulkFKObject?.isUnique && bulkFKObject.leafColumn.name === column.name) { + isLeafInUniqueBulkForeignKeyCreate = true + } + + } else if (column.name in prefillObject.keys) { isPrefilled = true; } } @@ -82,7 +92,8 @@ export function columnToColumnModel(column: any, isHidden?: boolean, queryParams logStackNode, // should not be used directly, take a look at getColumnModelLogStack logStackPathChild, // should not be used directly, use getColumnModelLogAction getting the action string hasDomainFilter, - isHidden: !!isHidden + isHidden: !!isHidden, + isLeafInUniqueBulkForeignKeyCreate }; } @@ -243,7 +254,7 @@ export function copyOrClearValue( export function populateCreateInitialValues( columnModels: RecordeditColumnModel[], forms: number[], - queryParams?: any, + prefillObject?: PrefillObject | null, prefillRowData?: any[] ) { const values: any = {}; @@ -251,13 +262,7 @@ export function populateCreateInitialValues( // only 1 row in the case of create if (prefillRowData) initialValues = prefillRowData[0]; - let shouldWaitForForeignKeyData = false; - - // get the prefilled values - const prefillObj = getPrefillObject(queryParams); - if (prefillObj) { - shouldWaitForForeignKeyData = true; - } + let shouldWaitForForeignKeyData = prefillObject ? true : false; // the data associated with the foreignkeys const foreignKeyData: any = {}; @@ -280,13 +285,13 @@ export function populateCreateInitialValues( } // if it's a prefilled foreignkey, the value is going to be set by processPrefilledForeignKeys - if (column.isForeignKey && prefillObj && prefillObj.fkColumnNames.indexOf(column.name) !== -1) { + if (column.isForeignKey && prefillObject && prefillObject.fkColumnNames.indexOf(column.name) !== -1) { continue; } // if the column is prefilled, get the prefilled value instead of default - if (prefillObj && column.name in prefillObj.keys) { - defaultValue = prefillObj.keys[column.name]; + if (prefillObject && column.name in prefillObject.keys) { + defaultValue = prefillObject.keys[column.name]; } const tsOptions: TimestampOptions = { outputMomentFormat: '' }; @@ -339,7 +344,7 @@ export function populateCreateInitialValues( } else if (column.isForeignKey) { // if all the columns of the foreignkey are prefilled, use that instead of default - const allPrefilled = prefillObj && allForeignKeyColumnsPrefilled(column.foreignKey, prefillObj); + const allPrefilled = prefillObject && allForeignKeyColumnsPrefilled(column.foreignKey, prefillObject); // if all the columns of the foreignkey are initialized, use that instead of default const allInitialized = isNonEmptyObject(initialValues) && column.foreignKey.colset.columns.every((col: any) => { @@ -347,7 +352,7 @@ export function populateCreateInitialValues( }); if (allPrefilled || allInitialized) { - const defaultDisplay = column.getDefaultDisplay(allPrefilled ? prefillObj.keys : initialValues); + const defaultDisplay = column.getDefaultDisplay((allPrefilled && prefillObject) ? prefillObject.keys : initialValues); // display the initial value initialModelValue = defaultDisplay.rowname.value; @@ -741,8 +746,7 @@ export function getPrefillObject(queryParams: any): null | PrefillObject { // make sure all the keys are in the object if (!( - ('keys' in cookie) && ('columnNameToRID' in cookie) && - ('fkColumnNames' in cookie) && + ('keys' in cookie) && ('columnNameToRID' in cookie) && ('fkColumnNames' in cookie) && ('origUrl' in cookie) && ('rowname' in cookie) )) { return null; @@ -783,7 +787,7 @@ export function allForeignKeyColumnsPrefilled(column: any, prefillObj: PrefillOb )); } -/* The following 3 functions are for foreignkey fields */ +/* The following 6 functions are for foreignkey and foreignkey-dropdown fields */ export function createForeignKeyReference( column: any, parentReference: any, @@ -891,3 +895,96 @@ export function validateForeignkeyValue( return foreignKeyCallbacks.onChange(column, data); } } + +export function disabledRowTooltip(disabledType: DisabledRowType): string { + let disabledTooltip = ''; + if (disabledType === DisabledRowType.ASSOCIATED) { + disabledTooltip = MESSAGE_MAP.tooltip.associatedDisabledRow; + } else if (disabledType === DisabledRowType.SELECTED) { + disabledTooltip = MESSAGE_MAP.tooltip.selectedDisabledRow; + } + + return disabledTooltip; +} + +/** + * Used to fetch the disabled tuples for a recordset modal picker used to associate rows of data + * + * @param domainRef the reference used in the modal picker that we want to disable rows for + * @param disabledRowsFilters + * @param rowsUsedInForm + * @returns a function that returns a promise + */ +export function disabledTuplesPromise(domainRef: any, disabledRowsFilters: any[], rowsUsedInForm: (SelectedRow | null)[]) { + /** + * The existing rows in this p&b association must be disabled + * so users doesn't resubmit them. + */ + return ( + page: any, + pageLimit: number, + logStack: any, + logStackPath: string, + requestCauses?: any, + reloadStartTime?: any + ): Promise<{ page: any, disabledRows?: DisabledRow[] }> => { + return new Promise((resolve, reject) => { + const disabledRows: DisabledRow[] = []; + + let action = LogActions.LOAD, + newStack = logStack; + if (Array.isArray(requestCauses) && requestCauses.length > 0) { + action = LogActions.RELOAD; + newStack = LogService.addCausesToStack(logStack, requestCauses, reloadStartTime); + } + + // using the service instead of the record one since this is called from the modal + const logObj = { + action: LogService.getActionString(action, logStackPath), + stack: newStack, + }; + + // fourth input: preserve the paging (read will remove the before if number of results is less than the limit) + domainRef + .addFacets(disabledRowsFilters) + .setSamePaging(page) + .read(pageLimit, logObj, false, true) + .then((newPage: any) => { + newPage.tuples.forEach((newTuple: any) => { + const index = page.tuples.findIndex((tuple: any) => { + return tuple.uniqueId === newTuple.uniqueId; + }); + + if (index > -1) { + disabledRows.push({ + disabledType: DisabledRowType.ASSOCIATED, + tuple: page.tuples[index] + }); + } + }); + + // iterate through the current row selections in recordedit forms + rowsUsedInForm.forEach((row: SelectedRow | null) => { + // if an input is empty, there won't be a row defined + if (!row) return; + + const index = page.tuples.findIndex((tuple: any) => { + return tuple.uniqueId === row.uniqueId; + }); + + if (index > -1) { + disabledRows.push({ + disabledType: DisabledRowType.SELECTED, + tuple: page.tuples[index] + }); + } + }); + + resolve({ disabledRows: disabledRows, page: page }); + }) + .catch((err: any) => { + reject(err); + }); + }); + }; +} diff --git a/test/e2e/data_setup/data/product/association_table_w_static_column.json b/test/e2e/data_setup/data/product/association_table_w_static_column.json new file mode 100644 index 000000000..523ec777a --- /dev/null +++ b/test/e2e/data_setup/data/product/association_table_w_static_column.json @@ -0,0 +1 @@ +[{"main_fk_col": 2004, "leaf_fk_col": 2, "static_col1": 2}] diff --git a/test/e2e/data_setup/data/product/association_table_w_static_column_dropdown.json b/test/e2e/data_setup/data/product/association_table_w_static_column_dropdown.json new file mode 100644 index 000000000..523ec777a --- /dev/null +++ b/test/e2e/data_setup/data/product/association_table_w_static_column_dropdown.json @@ -0,0 +1 @@ +[{"main_fk_col": 2004, "leaf_fk_col": 2, "static_col1": 2}] diff --git a/test/e2e/data_setup/data/product/leaf_table_for_static_columns.json b/test/e2e/data_setup/data/product/leaf_table_for_static_columns.json new file mode 100644 index 000000000..0531f886f --- /dev/null +++ b/test/e2e/data_setup/data/product/leaf_table_for_static_columns.json @@ -0,0 +1,10 @@ +[{"id": "1", "details": "Leaf 1"}, +{"id": "2", "details": "Leaf 2"}, +{"id": "3", "details": "Leaf 3"}, +{"id": "4", "details": "Leaf 4"}, +{"id": "5", "details": "Leaf 5"}, +{"id": "6", "details": "Leaf 6"}, +{"id": "7", "details": "Leaf 7"}, +{"id": "8", "details": "Leaf 8"}, +{"id": "9", "details": "Leaf 9"}, +{"id": "10", "details": "Leaf 10"}] diff --git a/test/e2e/data_setup/data/product/leaf_table_for_static_columns_dropdown.json b/test/e2e/data_setup/data/product/leaf_table_for_static_columns_dropdown.json new file mode 100644 index 000000000..0531f886f --- /dev/null +++ b/test/e2e/data_setup/data/product/leaf_table_for_static_columns_dropdown.json @@ -0,0 +1,10 @@ +[{"id": "1", "details": "Leaf 1"}, +{"id": "2", "details": "Leaf 2"}, +{"id": "3", "details": "Leaf 3"}, +{"id": "4", "details": "Leaf 4"}, +{"id": "5", "details": "Leaf 5"}, +{"id": "6", "details": "Leaf 6"}, +{"id": "7", "details": "Leaf 7"}, +{"id": "8", "details": "Leaf 8"}, +{"id": "9", "details": "Leaf 9"}, +{"id": "10", "details": "Leaf 10"}] diff --git a/test/e2e/data_setup/schema/record/product-unordered-related-tables-links.json b/test/e2e/data_setup/schema/record/product-unordered-related-tables-links.json index 65e214189..9606c5192 100644 --- a/test/e2e/data_setup/schema/record/product-unordered-related-tables-links.json +++ b/test/e2e/data_setup/schema/record/product-unordered-related-tables-links.json @@ -14,7 +14,8 @@ }, { "unique_columns": [ - "id", "extra_id" + "id", + "extra_id" ] }, { @@ -31,7 +32,12 @@ "foreign_keys": [ { "comment": null, - "names": [["product-unordered-related-tables-links", "fk_category"]], + "names": [ + [ + "product-unordered-related-tables-links", + "fk_category" + ] + ], "foreign_key_columns": [ { "table_name": "accommodation", @@ -50,7 +56,12 @@ }, { "comment": null, - "names": [["product-unordered-related-tables-links", "fk_thumbnail"]], + "names": [ + [ + "product-unordered-related-tables-links", + "fk_thumbnail" + ] + ], "foreign_key_columns": [ { "table_name": "accommodation", @@ -59,7 +70,9 @@ } ], "annotations": { - "comment": ["thumbnail"] + "comment": [ + "thumbnail" + ] }, "referenced_columns": [ { @@ -71,7 +84,12 @@ }, { "comment": null, - "names": [["product-unordered-related-tables-links", "fk_cover"]], + "names": [ + [ + "product-unordered-related-tables-links", + "fk_cover" + ] + ], "foreign_key_columns": [ { "table_name": "accommodation", @@ -80,7 +98,9 @@ } ], "annotations": { - "comment": ["thumbnail"] + "comment": [ + "thumbnail" + ] }, "referenced_columns": [ { @@ -93,21 +113,30 @@ { "comment": null, "names": [ - ["product-unordered-related-tables-links", "fk_to_accommodation_outbound1"] + [ + "product-unordered-related-tables-links", + "fk_to_accommodation_outbound1" + ] + ], + "foreign_key_columns": [ + { + "table_name": "accommodation", + "schema_name": "product-unordered-related-tables-links", + "column_name": "fk_col_outbound1" + } ], - "foreign_key_columns": [{ - "table_name": "accommodation", - "schema_name": "product-unordered-related-tables-links", - "column_name": "fk_col_outbound1" - }], "annotations": { - "comment": ["thumbnail"] + "comment": [ + "thumbnail" + ] }, - "referenced_columns": [{ - "table_name": "accommodation_outbound1", - "schema_name": "product-unordered-related-tables-links", - "column_name": "id" - }] + "referenced_columns": [ + { + "table_name": "accommodation_outbound1", + "schema_name": "product-unordered-related-tables-links", + "column_name": "id" + } + ] } ], "table_name": "accommodation", @@ -125,8 +154,8 @@ "comment": [ "hidden" ], - "tag:misd.isi.edu,2015:display" : { - "name" : "Id" + "tag:misd.isi.edu,2015:display": { + "name": "Id" } } }, @@ -154,8 +183,8 @@ "description": { "display": "Name of Accommodation" }, - "tag:misd.isi.edu,2015:display" : { - "name" : "Name of Accommodation" + "tag:misd.isi.edu,2015:display": { + "name": "Name of Accommodation" }, "facetOrder": [ "1" @@ -177,12 +206,12 @@ "description": { "display": "Website" }, - "tag:misd.isi.edu,2015:url" : { - "url" : "{cname}" + "tag:misd.isi.edu,2015:url": { + "url": "{cname}" }, - "tag:isrd.isi.edu,2016:column-display" : { + "tag:isrd.isi.edu,2016:column-display": { "*": { - "markdown_pattern" : "[Link to Website]({{website}})" + "markdown_pattern": "[Link to Website]({{website}})" } }, "tag:misd.isi.edu,2015:display": { @@ -199,7 +228,9 @@ "typename": "text" }, "annotations": { - "comment": ["top"], + "comment": [ + "top" + ], "description": { "display": "Category" }, @@ -220,7 +251,9 @@ "typename": "float4" }, "annotations": { - "comment": ["top"], + "comment": [ + "top" + ], "description": { "display": "User Rating" }, @@ -281,7 +314,9 @@ "typename": "int4" }, "annotations": { - "comment" : ["top"], + "comment": [ + "top" + ], "tag:misd.isi.edu,2015:display": { "name": "Number of Rooms" } @@ -353,14 +388,18 @@ "typename": "boolean" }, "annotations": { - "comment": ["top"], + "comment": [ + "top" + ], "description": { "display": "Luxurious" }, "tag:misd.isi.edu,2015:display": { "name": "Is Luxurious" }, - "tag:isrd.isi.edu,2016:ignore" : ["record"] + "tag:isrd.isi.edu,2016:ignore": [ + "record" + ] } }, { @@ -398,38 +437,127 @@ }, "description": { "display": "Accommodations", - "top_columns": ["title", "rating", "category", "opened_on"] + "top_columns": [ + "title", + "rating", + "category", + "opened_on" + ] }, - "tag:isrd.isi.edu,2016:visible-columns" : { - "detailed" : ["id", "title", "website", ["product-unordered-related-tables-links", "fk_category"], ["product-unordered-related-tables-links", "fk_assoc_accommodation_null_key"], ["product-unordered-related-tables-links", "accommodation_inbound_null_fk"], ["product-unordered-related-tables-links", "fk_assoc_accommodation_null_key2"], "rating", "summary", "description", "no_of_rooms", ["product-unordered-related-tables-links", "fk_cover"], ["product-unordered-related-tables-links", "fk_thumbnail"], "opened_on", "luxurious"] + "tag:isrd.isi.edu,2016:visible-columns": { + "detailed": [ + "id", + "title", + "website", + [ + "product-unordered-related-tables-links", + "fk_category" + ], + [ + "product-unordered-related-tables-links", + "fk_assoc_accommodation_null_key" + ], + [ + "product-unordered-related-tables-links", + "accommodation_inbound_null_fk" + ], + [ + "product-unordered-related-tables-links", + "fk_assoc_accommodation_null_key2" + ], + "rating", + "summary", + "description", + "no_of_rooms", + [ + "product-unordered-related-tables-links", + "fk_cover" + ], + [ + "product-unordered-related-tables-links", + "fk_thumbnail" + ], + "opened_on", + "luxurious" + ] }, "tag:isrd.isi.edu,2016:visible-foreign-keys": { "detailed": [ - ["product-unordered-related-tables-links", "fk_booking_accommodation"], - ["product-unordered-related-tables-links", "fk_schedule_accommodation"], - ["product-unordered-related-tables-links", "fk_media_accommodation"], - ["product-unordered-related-tables-links", "fk_assoc_accommodation"], - ["product-unordered-related-tables-links", "fk_accommodation_image"], - ["product-unordered-related-tables-links", "fk_assoc_mdn_accommodation"], + [ + "product-unordered-related-tables-links", + "fk_booking_accommodation" + ], + [ + "product-unordered-related-tables-links", + "fk_schedule_accommodation" + ], + [ + "product-unordered-related-tables-links", + "fk_media_accommodation" + ], + [ + "product-unordered-related-tables-links", + "fk_assoc_accommodation" + ], + [ + "product-unordered-related-tables-links", + "fk_accommodation_image" + ], + [ + "product-unordered-related-tables-links", + "fk_assoc_mdn_accommodation" + ], { "source": [ - {"inbound": ["product-unordered-related-tables-links", "fk_assoc_accommodation"]}, - {"outbound": ["product-unordered-related-tables-links", "fk_assoc_related_table"]}, - {"inbound": ["product-unordered-related-tables-links", "fk_related_table_2_to_related"]}, + { + "inbound": [ + "product-unordered-related-tables-links", + "fk_assoc_accommodation" + ] + }, + { + "outbound": [ + "product-unordered-related-tables-links", + "fk_assoc_related_table" + ] + }, + { + "inbound": [ + "product-unordered-related-tables-links", + "fk_related_table_2_to_related" + ] + }, "id" ], "display": { - "wait_for": ["entity_set_accommodation_inbound2"] + "wait_for": [ + "entity_set_accommodation_inbound2" + ] } }, - ["product-unordered-related-tables-links", "fk_table_w_agg_accommodation"], - {"source": [{"inbound": ["product-unordered-related-tables-links", "fk_table_invalid_markdown"]}, "id"]}, + [ + "product-unordered-related-tables-links", + "fk_table_w_agg_accommodation" + ], + { + "source": [ + { + "inbound": [ + "product-unordered-related-tables-links", + "fk_table_invalid_markdown" + ] + }, + "id" + ] + }, { "sourcekey": "entity_set_accommodation_inbound1", "markdown_name": "inbound related with display.wait_for entityset", "display": { "template_engine": "handlebars", - "wait_for": ["entity_set_accommodation_inbound2"], + "wait_for": [ + "entity_set_accommodation_inbound2" + ], "markdown_pattern": "{{#each $self}}{{{this.rowName}}}{{#unless @last}}, {{/unless}}{{/each}} ({{#each entity_set_accommodation_inbound2}}{{{this.rowName}}}{{#unless @last}}, {{/unless}}{{/each}})" }, "comment": "related table, has waitfor entityset and markdown_pattern (has markdown comment)", @@ -440,22 +568,40 @@ "markdown_name": "inbound related with display.wait_for agg", "display": { "template_engine": "handlebars", - "wait_for": ["cnt_d_accommodation_inbound2"], + "wait_for": [ + "cnt_d_accommodation_inbound2" + ], "markdown_pattern": "{{#each $self}}{{{this.rowName}}}{{#unless @last}}, {{/unless}}{{/each}} ({{{cnt_d_accommodation_inbound2}}})" } }, { "source": [ - {"filter": "id", "operand_pattern": "1111"}, - {"inbound": ["product-unordered-related-tables-links", "fk_booking_accommodation"]}, + { + "filter": "id", + "operand_pattern": "1111" + }, + { + "inbound": [ + "product-unordered-related-tables-links", + "fk_booking_accommodation" + ] + }, "RID" ], "markdown_name": "inbound related with filter on main table (hidden)" }, { "source": [ - {"filter": "id", "operand_pattern": "2004"}, - {"inbound": ["product-unordered-related-tables-links", "fk_booking_accommodation"]}, + { + "filter": "id", + "operand_pattern": "2004" + }, + { + "inbound": [ + "product-unordered-related-tables-links", + "fk_booking_accommodation" + ] + }, "RID" ], "markdown_name": "inbound related with filter on main table", @@ -464,12 +610,24 @@ }, { "source": [ - {"inbound": ["product-unordered-related-tables-links", "fk_booking_accommodation"]}, { - "or": [ - {"filter": "price", "operand_pattern": "90", "operator": "::lt::"}, - {"filter": "booking_date", "operator": "::null::"} - ] + "inbound": [ + "product-unordered-related-tables-links", + "fk_booking_accommodation" + ] + }, + { + "or": [ + { + "filter": "price", + "operand_pattern": "90", + "operator": "::lt::" + }, + { + "filter": "booking_date", + "operator": "::null::" + } + ] }, "RID" ], @@ -478,18 +636,44 @@ }, { "source": [ - {"filter": "id", "operand_pattern": "1111"}, - {"inbound": ["product-unordered-related-tables-links", "fk_assoc_accommodation"]}, - {"outbound": ["product-unordered-related-tables-links", "fk_assoc_related_table"]}, + { + "filter": "id", + "operand_pattern": "1111" + }, + { + "inbound": [ + "product-unordered-related-tables-links", + "fk_assoc_accommodation" + ] + }, + { + "outbound": [ + "product-unordered-related-tables-links", + "fk_assoc_related_table" + ] + }, "RID" ], "markdown_name": "association with filter on main table (hidden)" }, { "source": [ - {"filter": "id", "operand_pattern": "2004"}, - {"inbound": ["product-unordered-related-tables-links", "fk_assoc_accommodation"]}, - {"outbound": ["product-unordered-related-tables-links", "fk_assoc_related_table"]}, + { + "filter": "id", + "operand_pattern": "2004" + }, + { + "inbound": [ + "product-unordered-related-tables-links", + "fk_assoc_accommodation" + ] + }, + { + "outbound": [ + "product-unordered-related-tables-links", + "fk_assoc_related_table" + ] + }, "RID" ], "markdown_name": "association with filter on main table", @@ -497,9 +681,22 @@ }, { "source": [ - {"inbound": ["product-unordered-related-tables-links", "fk_assoc_accommodation"]}, - {"filter": "id_related", "operand_pattern": "3"}, - {"outbound": ["product-unordered-related-tables-links", "fk_assoc_related_table"]}, + { + "inbound": [ + "product-unordered-related-tables-links", + "fk_assoc_accommodation" + ] + }, + { + "filter": "id_related", + "operand_pattern": "3" + }, + { + "outbound": [ + "product-unordered-related-tables-links", + "fk_assoc_related_table" + ] + }, "RID" ], "markdown_name": "association with filter on assoc table", @@ -507,13 +704,29 @@ }, { "source": [ - {"inbound": ["product-unordered-related-tables-links", "fk_assoc_accommodation"]}, - {"outbound": ["product-unordered-related-tables-links", "fk_assoc_related_table"]}, { - "or": [ - {"filter": "facility", "operand_pattern": "Television"}, - {"filter": "facility", "operand_pattern": "Air Conditioning"} - ] + "inbound": [ + "product-unordered-related-tables-links", + "fk_assoc_accommodation" + ] + }, + { + "outbound": [ + "product-unordered-related-tables-links", + "fk_assoc_related_table" + ] + }, + { + "or": [ + { + "filter": "facility", + "operand_pattern": "Television" + }, + { + "filter": "facility", + "operand_pattern": "Air Conditioning" + } + ] }, "RID" ], @@ -522,21 +735,53 @@ }, { "source": [ - {"filter": "id", "operand_pattern": "2004"}, - {"inbound": ["product-unordered-related-tables-links", "fk_assoc_accommodation"]}, + { + "filter": "id", + "operand_pattern": "2004" + }, + { + "inbound": [ + "product-unordered-related-tables-links", + "fk_assoc_accommodation" + ] + }, { "and": [ - {"filter": "RID", "operand_pattern": "::null::"}, - {"filter": "RCB", "operand_pattern": "::null::"} + { + "filter": "RID", + "operand_pattern": "::null::" + }, + { + "filter": "RCB", + "operand_pattern": "::null::" + } ], "negate": true }, - {"outbound": ["product-unordered-related-tables-links", "fk_assoc_related_table"]}, - {"inbound": ["product-unordered-related-tables-links", "fk_related_table_2_to_related"]}, + { + "outbound": [ + "product-unordered-related-tables-links", + "fk_assoc_related_table" + ] + }, + { + "inbound": [ + "product-unordered-related-tables-links", + "fk_related_table_2_to_related" + ] + }, "id" ], "markdown_name": "path of length 3 with filters" - } + }, + [ + "product-unordered-related-tables-links", + "static_to_accommodation_fkey" + ], + [ + "product-unordered-related-tables-links", + "static_to_accommodation_w_dropdown_fkey" + ] ] }, "tag:isrd.isi.edu,2018:citation": { @@ -545,7 +790,9 @@ "year_pattern": "{{formatDate RCT 'YYYY'}}", "url_pattern": "{{website}}", "id_pattern": "{{id}}", - "wait_for": ["entity_all_outbound_outbound1_outbound1"] + "wait_for": [ + "entity_all_outbound_outbound1_outbound1" + ] }, "tag:isrd.isi.edu,2019:source-definitions": { "fkeys": true, @@ -553,32 +800,62 @@ "sources": { "entity_all_outbound_outbound1_outbound1": { "source": [ - {"outbound": ["product-unordered-related-tables-links", "fk_to_accommodation_outbound1"]}, - {"outbound": ["product-unordered-related-tables-links", "accommodation_outbound1_fk1"]}, + { + "outbound": [ + "product-unordered-related-tables-links", + "fk_to_accommodation_outbound1" + ] + }, + { + "outbound": [ + "product-unordered-related-tables-links", + "accommodation_outbound1_fk1" + ] + }, "RID" ] }, "entity_set_accommodation_inbound1": { "source": [ - {"inbound": ["product-unordered-related-tables-links", "accommodation_inbound1_fk1"]}, + { + "inbound": [ + "product-unordered-related-tables-links", + "accommodation_inbound1_fk1" + ] + }, "RID" ] }, "entity_set_accommodation_inbound2": { "source": [ - {"inbound": ["product-unordered-related-tables-links", "accommodation_inbound2_fk1"]}, + { + "inbound": [ + "product-unordered-related-tables-links", + "accommodation_inbound2_fk1" + ] + }, "RID" ] }, "entity_set_accommodation_inbound3": { "source": [ - {"inbound": ["product-unordered-related-tables-links", "accommodation_inbound3_fk1"]}, + { + "inbound": [ + "product-unordered-related-tables-links", + "accommodation_inbound3_fk1" + ] + }, "RID" ] }, "cnt_d_accommodation_inbound2": { "source": [ - {"inbound": ["product-unordered-related-tables-links", "accommodation_inbound2_fk1"]}, + { + "inbound": [ + "product-unordered-related-tables-links", + "accommodation_inbound2_fk1" + ] + }, "RID" ], "aggregate": "cnt_d" @@ -603,7 +880,12 @@ ], "foreign_keys": [ { - "names" : [["product-unordered-related-tables-links", "fk_booking_accommodation"]], + "names": [ + [ + "product-unordered-related-tables-links", + "fk_booking_accommodation" + ] + ], "comment": null, "foreign_key_columns": [ { @@ -627,7 +909,12 @@ ] }, { - "names" : [["product-unordered-related-tables-links", "fk_booking_accommodation2"]], + "names": [ + [ + "product-unordered-related-tables-links", + "fk_booking_accommodation2" + ] + ], "comment": "composite fk that should be prefilled", "foreign_key_columns": [ { @@ -655,7 +942,12 @@ ] }, { - "names" : [["product-unordered-related-tables-links", "fk_booking_accommodation3"]], + "names": [ + [ + "product-unordered-related-tables-links", + "fk_booking_accommodation3" + ] + ], "comment": "composite fk that should not be prefilled (nullok)", "foreign_key_columns": [ { @@ -683,7 +975,12 @@ ] }, { - "names" : [["product-unordered-related-tables-links", "fk_booking_accommodation4"]], + "names": [ + [ + "product-unordered-related-tables-links", + "fk_booking_accommodation4" + ] + ], "comment": "composite fk that should be prefilled", "foreign_key_columns": [ { @@ -711,7 +1008,12 @@ ] }, { - "names" : [["product-unordered-related-tables-links", "fk_booking_accommodation5"]], + "names": [ + [ + "product-unordered-related-tables-links", + "fk_booking_accommodation5" + ] + ], "foreign_key_columns": [ { "table_name": "booking", @@ -740,7 +1042,9 @@ "typename": "serial4" }, "annotations": { - "comment": ["hidden"] + "comment": [ + "hidden" + ] } }, { @@ -752,7 +1056,9 @@ "typename": "int4" }, "annotations": { - "comment": ["hidden"] + "comment": [ + "hidden" + ] } }, { @@ -767,7 +1073,9 @@ "description": { "display": "Price" }, - "facetOrder": ["4"] + "facetOrder": [ + "4" + ] } }, { @@ -782,7 +1090,9 @@ "description": { "display": "Date of Booking" }, - "facetOrder": ["5"] + "facetOrder": [ + "5" + ] } }, { @@ -821,22 +1131,101 @@ }, "tag:isrd.isi.edu,2016:visible-columns": { "entry": [ - "id", "price", "booking_date", "fk2_col", "accommodation_id", "fk3_col1", - {"source": [{"outbound": ["product-unordered-related-tables-links", "fk_booking_accommodation"]}, "RID"], "markdown_name": "fk_1"}, - {"source": [{"outbound": ["product-unordered-related-tables-links", "fk_booking_accommodation2"]}, "RID"], "markdown_name": "fk_2"}, - {"source": [{"outbound": ["product-unordered-related-tables-links", "fk_booking_accommodation3"]}, "RID"], "markdown_name": "fk_3"}, - {"source": [{"outbound": ["product-unordered-related-tables-links", "fk_booking_accommodation4"]}, "RID"], "markdown_name": "fk_4"}, - {"source": [{"outbound": ["product-unordered-related-tables-links", "fk_booking_accommodation5"]}, "RID"], "markdown_name": "fk_5"} - ], - "compact/brief": ["price", "booking_date"], + "id", + "price", + "booking_date", + "fk2_col", + "accommodation_id", + "fk3_col1", + { + "source": [ + { + "outbound": [ + "product-unordered-related-tables-links", + "fk_booking_accommodation" + ] + }, + "RID" + ], + "markdown_name": "fk_1" + }, + { + "source": [ + { + "outbound": [ + "product-unordered-related-tables-links", + "fk_booking_accommodation2" + ] + }, + "RID" + ], + "markdown_name": "fk_2" + }, + { + "source": [ + { + "outbound": [ + "product-unordered-related-tables-links", + "fk_booking_accommodation3" + ] + }, + "RID" + ], + "markdown_name": "fk_3" + }, + { + "source": [ + { + "outbound": [ + "product-unordered-related-tables-links", + "fk_booking_accommodation4" + ] + }, + "RID" + ], + "markdown_name": "fk_4" + }, + { + "source": [ + { + "outbound": [ + "product-unordered-related-tables-links", + "fk_booking_accommodation5" + ] + }, + "RID" + ], + "markdown_name": "fk_5" + } + ], + "compact/brief": [ + "price", + "booking_date" + ], "filter": { "and": [ - {"source": [{"outbound": ["product-unordered-related-tables-links", "fk_booking_accommodation"]}], "markdown_name": "Accommodations"} + { + "source": [ + { + "outbound": [ + "product-unordered-related-tables-links", + "fk_booking_accommodation" + ] + } + ], + "markdown_name": "Accommodations" + } ] } }, "tag:isrd.isi.edu,2016:table-display": { - "*": {"row_order": [{"column": "id"}]} + "*": { + "row_order": [ + { + "column": "id" + } + ] + } } } }, @@ -898,9 +1287,9 @@ "thumbnail", "download" ], - "tag:isrd.isi.edu,2016:column-display" : { + "tag:isrd.isi.edu,2016:column-display": { "*": { - "markdown_pattern" : "[{{uri}}]({{uri}})" + "markdown_pattern": "[{{uri}}]({{uri}})" } } } @@ -1014,7 +1403,12 @@ "entityCount": 0, "foreign_keys": [ { - "names" : [["product-unordered-related-tables-links", "fk_accommodation_image"]], + "names": [ + [ + "product-unordered-related-tables-links", + "fk_accommodation_image" + ] + ], "comment": null, "foreign_key_columns": [ { @@ -1094,7 +1488,13 @@ "display": "Accommodation Images" }, "tag:isrd.isi.edu,2016:visible-columns": { - "compact/brief": ["filename", "uri", "content_type", "bytes", "timestamp"] + "compact/brief": [ + "filename", + "uri", + "content_type", + "bytes", + "timestamp" + ] } } }, @@ -1145,7 +1545,9 @@ "typename": "text" }, "annotations": { - "comment": ["top"] + "comment": [ + "top" + ] } } ], @@ -1174,7 +1576,12 @@ ], "foreign_keys": [ { - "names" : [["product-unordered-related-tables-links", "fk_media_accommodation"]], + "names": [ + [ + "product-unordered-related-tables-links", + "fk_media_accommodation" + ] + ], "comment": null, "foreign_key_columns": [ { @@ -1205,7 +1612,9 @@ "typename": "serial4" }, "annotations": { - "comment": ["hidden"] + "comment": [ + "hidden" + ] } }, { @@ -1217,7 +1626,9 @@ "typename": "int4" }, "annotations": { - "comment": ["hidden"] + "comment": [ + "hidden" + ] } } ], @@ -1233,13 +1644,21 @@ "row_markdown_pattern": "{{{accommodation_id}}}" } }, - "tag:isrd.isi.edu,2016:visible-columns" : { - "*": ["id", ["product-unordered-related-tables-links", "fk_booking_accommodation"], "price", "bookin_date"] + "tag:isrd.isi.edu,2016:visible-columns": { + "*": [ + "id", + [ + "product-unordered-related-tables-links", + "fk_booking_accommodation" + ], + "price", + "bookin_date" + ] } } }, "related_table": { - "comment":"This is a related table and will have entries of facility in an accommodation", + "comment": "This is a related table and will have entries of facility in an accommodation", "kind": "table", "keys": [ { @@ -1271,11 +1690,17 @@ "annotations": { "tag:isrd.isi.edu,2016:table-display": { "compact": { - "row_order": [{"column": "id"}] + "row_order": [ + { + "column": "id" + } + ] } }, "tag:isrd.isi.edu,2016:visible-columns": { - "compact/brief": ["facility"] + "compact/brief": [ + "facility" + ] } } }, @@ -1311,16 +1736,22 @@ "annotations": { "tag:isrd.isi.edu,2016:table-display": { "compact": { - "row_order": [{"column": "id"}] + "row_order": [ + { + "column": "id" + } + ] } }, "tag:isrd.isi.edu,2016:visible-columns": { - "compact/brief": ["term"] + "compact/brief": [ + "term" + ] } } }, "related_table_markdown": { - "comment":"This is a related table with markdown annotations", + "comment": "This is a related table with markdown annotations", "kind": "table", "keys": [ { @@ -1357,51 +1788,72 @@ } }, "tag:isrd.isi.edu,2016:visible-columns": { - "compact/brief": ["facility"] + "compact/brief": [ + "facility" + ] } } }, "association_table_markdown": { - "comment":"This is an association table for markdown display", + "comment": "This is an association table for markdown display", "kind": "table", "keys": [ { "comment": null, "annotations": {}, "unique_columns": [ - "id_related", "id_base" + "id_related", + "id_base" ] } ], "foreign_keys": [ { "comment": "", - "names": [["product-unordered-related-tables-links", "fk_assoc_mdn_related_table"]], - "foreign_key_columns": [{ + "names": [ + [ + "product-unordered-related-tables-links", + "fk_assoc_mdn_related_table" + ] + ], + "foreign_key_columns": [ + { "table_name": "association_table_markdown", "schema_name": "product-unordered-related-tables-links", "column_name": "id_related" - }], - "referenced_columns": [{ + } + ], + "referenced_columns": [ + { "table_name": "related_table_markdown", "schema_name": "product-unordered-related-tables-links", "column_name": "id" - }], + } + ], "annotations": {} }, { "comment": "", - "names": [["product-unordered-related-tables-links", "fk_assoc_mdn_accommodation"]], - "foreign_key_columns": [{ + "names": [ + [ + "product-unordered-related-tables-links", + "fk_assoc_mdn_accommodation" + ] + ], + "foreign_key_columns": [ + { "table_name": "association_table_markdown", "schema_name": "product-unordered-related-tables-links", "column_name": "id_base" - }], - "referenced_columns": [{ + } + ], + "referenced_columns": [ + { "table_name": "accommodation", "schema_name": "product-unordered-related-tables-links", "column_name": "id" - }], + } + ], "annotations": { "tag:isrd.isi.edu,2016:foreign-key": { "to_name": "base table association related" @@ -1430,46 +1882,65 @@ "annotations": {} }, "association_table": { - "comment":"This is an association table to hold the association of accomodation and facility", + "comment": "This is an association table to hold the association of accomodation and facility", "kind": "table", "keys": [ { "comment": null, "annotations": {}, "unique_columns": [ - "id_related", "id_base" + "id_related", + "id_base" ] } ], "foreign_keys": [ { "comment": "", - "names": [["product-unordered-related-tables-links", "fk_assoc_related_table"]], - "foreign_key_columns": [{ + "names": [ + [ + "product-unordered-related-tables-links", + "fk_assoc_related_table" + ] + ], + "foreign_key_columns": [ + { "table_name": "association_table", "schema_name": "product-unordered-related-tables-links", "column_name": "id_related" - }], - "referenced_columns": [{ + } + ], + "referenced_columns": [ + { "table_name": "related_table", "schema_name": "product-unordered-related-tables-links", "column_name": "id" - }], + } + ], "annotations": {} }, { "comment": "", - "names": [["product-unordered-related-tables-links", "fk_assoc_accommodation"]], - "foreign_key_columns": [{ + "names": [ + [ + "product-unordered-related-tables-links", + "fk_assoc_accommodation" + ] + ], + "foreign_key_columns": [ + { "table_name": "association_table", "schema_name": "product-unordered-related-tables-links", "column_name": "id_base" - }], - "referenced_columns": [{ + } + ], + "referenced_columns": [ + { "table_name": "accommodation", "schema_name": "product-unordered-related-tables-links", "column_name": "id" - }], + } + ], "annotations": { "tag:isrd.isi.edu,2016:foreign-key": { "to_name": "base table association related" @@ -1498,7 +1969,11 @@ "annotations": { "tag:isrd.isi.edu,2016:table-display": { "compact": { - "row_order": [{"column": "id_base"}] + "row_order": [ + { + "column": "id_base" + } + ] } } } @@ -1511,37 +1986,56 @@ "comment": null, "annotations": {}, "unique_columns": [ - "id_related", "id_base" + "id_related", + "id_base" ] } ], "foreign_keys": [ { - "names": [["product-unordered-related-tables-links", "fk_assoc_related_table_null_key"]], - "foreign_key_columns": [{ + "names": [ + [ + "product-unordered-related-tables-links", + "fk_assoc_related_table_null_key" + ] + ], + "foreign_key_columns": [ + { "table_name": "association_table_null_keys", "schema_name": "product-unordered-related-tables-links", "column_name": "id_related" - }], - "referenced_columns": [{ + } + ], + "referenced_columns": [ + { "table_name": "related_table_null_key", "schema_name": "product-unordered-related-tables-links", "column_name": "id" - }], + } + ], "annotations": {} }, { - "names": [["product-unordered-related-tables-links", "fk_assoc_accommodation_null_key"]], - "foreign_key_columns": [{ + "names": [ + [ + "product-unordered-related-tables-links", + "fk_assoc_accommodation_null_key" + ] + ], + "foreign_key_columns": [ + { "table_name": "association_table_null_keys", "schema_name": "product-unordered-related-tables-links", "column_name": "id_base" - }], - "referenced_columns": [{ + } + ], + "referenced_columns": [ + { "table_name": "accommodation", "schema_name": "product-unordered-related-tables-links", "column_name": "nullable_assoc_key" - }], + } + ], "annotations": { "tag:isrd.isi.edu,2016:foreign-key": { "to_name": "base table association related" @@ -1570,7 +2064,11 @@ "annotations": { "tag:isrd.isi.edu,2016:table-display": { "compact": { - "row_order": [{"column": "id_base"}] + "row_order": [ + { + "column": "id_base" + } + ] } } } @@ -1583,37 +2081,56 @@ "comment": null, "annotations": {}, "unique_columns": [ - "id_related", "id_base" + "id_related", + "id_base" ] } ], "foreign_keys": [ { - "names": [["product-unordered-related-tables-links", "fk_assoc_related_table_null_key2"]], - "foreign_key_columns": [{ + "names": [ + [ + "product-unordered-related-tables-links", + "fk_assoc_related_table_null_key2" + ] + ], + "foreign_key_columns": [ + { "table_name": "association_table_null_keys2", "schema_name": "product-unordered-related-tables-links", "column_name": "id_related" - }], - "referenced_columns": [{ + } + ], + "referenced_columns": [ + { "table_name": "related_table_null_key", "schema_name": "product-unordered-related-tables-links", "column_name": "id" - }], + } + ], "annotations": {} }, { - "names": [["product-unordered-related-tables-links", "fk_assoc_accommodation_null_key2"]], - "foreign_key_columns": [{ + "names": [ + [ + "product-unordered-related-tables-links", + "fk_assoc_accommodation_null_key2" + ] + ], + "foreign_key_columns": [ + { "table_name": "association_table_null_keys2", "schema_name": "product-unordered-related-tables-links", "column_name": "id_base" - }], - "referenced_columns": [{ + } + ], + "referenced_columns": [ + { "table_name": "accommodation", "schema_name": "product-unordered-related-tables-links", "column_name": "nullable_assoc_key2" - }], + } + ], "annotations": { "tag:isrd.isi.edu,2016:foreign-key": { "to_name": "base table association related" @@ -1642,7 +2159,11 @@ "annotations": { "tag:isrd.isi.edu,2016:table-display": { "compact": { - "row_order": [{"column": "id_base"}] + "row_order": [ + { + "column": "id_base" + } + ] } } } @@ -1651,10 +2172,21 @@ "kind": "table", "schema_name": "product-unordered-related-tables-links", "table_name": "inbound_null_key", - "keys": [{"unique_columns": ["id"]}], + "keys": [ + { + "unique_columns": [ + "id" + ] + } + ], "foreign_keys": [ { - "names": [["product-unordered-related-tables-links", "accommodation_inbound_null_fk"]], + "names": [ + [ + "product-unordered-related-tables-links", + "accommodation_inbound_null_fk" + ] + ], "foreign_key_columns": [ { "column_name": "fk1_col", @@ -1675,16 +2207,22 @@ { "name": "id", "nullok": false, - "type": {"typename": "text"} + "type": { + "typename": "text" + } }, { "name": "name", "nullok": false, - "type": {"typename": "text"} + "type": { + "typename": "text" + } }, { "name": "fk1_col", - "type": {"typename": "text"} + "type": { + "typename": "text" + } } ] }, @@ -1705,7 +2243,12 @@ ], "foreign_keys": [ { - "names" : [["product-unordered-related-tables-links", "fk_schedule_accommodation"]], + "names": [ + [ + "product-unordered-related-tables-links", + "fk_schedule_accommodation" + ] + ], "comment": null, "foreign_key_columns": [ { @@ -1736,7 +2279,9 @@ "typename": "serial4" }, "annotations": { - "comment": ["hidden"] + "comment": [ + "hidden" + ] } }, { @@ -1748,7 +2293,9 @@ "typename": "int4" }, "annotations": { - "comment": ["hidden"] + "comment": [ + "hidden" + ] } }, { @@ -1760,7 +2307,9 @@ "typename": "text" }, "annotations": { - "comment": ["hidden"] + "comment": [ + "hidden" + ] } } ], @@ -1778,40 +2327,67 @@ "compact": "tag:isrd.isi.edu,2016:chaise:search" }, "tag:isrd.isi.edu,2016:visible-columns": { - "compact/brief": ["summary"], + "compact/brief": [ + "summary" + ], "filter": { "and": [ - {"source": [{"outbound": ["product-unordered-related-tables-links", "fk_schedule_accommodation"]}], "markdown_name": "Accommodations"} + { + "source": [ + { + "outbound": [ + "product-unordered-related-tables-links", + "fk_schedule_accommodation" + ] + } + ], + "markdown_name": "Accommodations" + } ] } } } }, "related_table_2": { - "comment":"has a foreignkey to related_table", + "comment": "has a foreignkey to related_table", "kind": "table", "table_name": "related_table_2", "schema_name": "product-unordered-related-tables-links", - "keys": [{ - "comment": null, - "annotations": {}, - "unique_columns": ["id"] - }], - "foreign_keys": [{ - "names" : [["product-unordered-related-tables-links", "fk_related_table_2_to_related"]], - "comment": null, - "foreign_key_columns": [{ - "table_name": "related_table_2", - "schema_name": "product-unordered-related-tables-links", - "column_name": "fk_to_rel" - }], - "annotations": {}, - "referenced_columns": [{ - "table_name": "related_table", - "schema_name": "product-unordered-related-tables-links", - "column_name": "id" - }] - }], + "keys": [ + { + "comment": null, + "annotations": {}, + "unique_columns": [ + "id" + ] + } + ], + "foreign_keys": [ + { + "names": [ + [ + "product-unordered-related-tables-links", + "fk_related_table_2_to_related" + ] + ], + "comment": null, + "foreign_key_columns": [ + { + "table_name": "related_table_2", + "schema_name": "product-unordered-related-tables-links", + "column_name": "fk_to_rel" + } + ], + "annotations": {}, + "referenced_columns": [ + { + "table_name": "related_table", + "schema_name": "product-unordered-related-tables-links", + "column_name": "id" + } + ] + } + ], "column_definitions": [ { "name": "id", @@ -1835,10 +2411,18 @@ ], "annotations": { "tag:isrd.isi.edu,2016:table-display": { - "*": {"row_order": [{"column": "id"}]} + "*": { + "row_order": [ + { + "column": "id" + } + ] + } }, "tag:isrd.isi.edu,2016:visible-columns": { - "compact/brief": ["col"] + "compact/brief": [ + "col" + ] } } }, @@ -1847,9 +2431,13 @@ "kind": "table", "table_name": "table_w_aggregates", "schema_name": "product-unordered-related-tables-links", - "keys": [{ - "unique_columns": ["id"] - }], + "keys": [ + { + "unique_columns": [ + "id" + ] + } + ], "column_definitions": [ { "name": "id", @@ -1873,7 +2461,12 @@ ], "foreign_keys": [ { - "names" : [["product-unordered-related-tables-links", "fk_table_w_agg_accommodation"]], + "names": [ + [ + "product-unordered-related-tables-links", + "fk_table_w_agg_accommodation" + ] + ], "comment": null, "foreign_key_columns": [ { @@ -1896,33 +2489,65 @@ "tag:isrd.isi.edu,2016:visible-columns": { "*": [ "id", - ["product-unordered-related-tables-links", "fk_table_w_agg_accommodation"], - {"source": "value", "aggregate": "max"}, - {"sourcekey": "value_min"}, - {"source": "value", "aggregate": "cnt"}, - {"source": "value", "aggregate": "cnt_d"}, + [ + "product-unordered-related-tables-links", + "fk_table_w_agg_accommodation" + ], + { + "source": "value", + "aggregate": "max" + }, + { + "sourcekey": "value_min" + }, + { + "source": "value", + "aggregate": "cnt" + }, + { + "source": "value", + "aggregate": "cnt_d" + }, { "markdown_name": "virtual column", "display": { "template_engine": "handlebars", "markdown_pattern": "virtual {{{value_min}}} with {{{outbound_to_accommodation.rowName}}}", - "wait_for": ["value_min", "outbound_to_accommodation"] + "wait_for": [ + "value_min", + "outbound_to_accommodation" + ] } } ] }, "tag:isrd.isi.edu,2016:table-display": { - "*": {"row_order": [{"column": "id"}]} + "*": { + "row_order": [ + { + "column": "id" + } + ] + } }, "tag:isrd.isi.edu,2019:source-definitions": { "fkeys": true, "columns": true, "sources": { "value_min": { - "source": "value", "aggregate": "min" + "source": "value", + "aggregate": "min" }, "outbound_to_accommodation": { - "source": [{"outbound": ["product-unordered-related-tables-links", "fk_table_w_agg_accommodation"]}, "RID"] + "source": [ + { + "outbound": [ + "product-unordered-related-tables-links", + "fk_table_w_agg_accommodation" + ] + }, + "RID" + ] } } } @@ -1932,27 +2557,41 @@ "table_name": "table_w_invalid_row_markdown_pattern", "schema_name": "product-unordered-related-tables-links", "kind": "table", - "keys": [{ - "unique_columns": ["id"] - }], - "foreign_keys": [{ - "names": [ - ["product-unordered-related-tables-links", "fk_table_invalid_markdown"] - ], - "comment": null, - "foreign_key_columns": [{ - "table_name": "table_w_invalid_row_markdown_pattern", - "schema_name": "product-unordered-related-tables-links", - "column_name": "id" - }], - "annotations": {}, - "referenced_columns": [{ - "table_name": "accommodation", - "schema_name": "product-unordered-related-tables-links", - "column_name": "id" - }] - }], - "column_definitions": [{ + "keys": [ + { + "unique_columns": [ + "id" + ] + } + ], + "foreign_keys": [ + { + "names": [ + [ + "product-unordered-related-tables-links", + "fk_table_invalid_markdown" + ] + ], + "comment": null, + "foreign_key_columns": [ + { + "table_name": "table_w_invalid_row_markdown_pattern", + "schema_name": "product-unordered-related-tables-links", + "column_name": "id" + } + ], + "annotations": {}, + "referenced_columns": [ + { + "table_name": "accommodation", + "schema_name": "product-unordered-related-tables-links", + "column_name": "id" + } + ] + } + ], + "column_definitions": [ + { "name": "id", "nullok": false, "type": { @@ -1976,7 +2615,10 @@ } }, "tag:isrd.isi.edu,2016:visible-columns": { - "*": ["id", "value"] + "*": [ + "id", + "value" + ] } } }, @@ -1985,7 +2627,11 @@ "table_name": "table_w_rowname", "schema_name": "product-unordered-related-tables-links", "keys": [ - {"unique_columns": ["id"]} + { + "unique_columns": [ + "id" + ] + } ], "column_definitions": [ { @@ -2015,10 +2661,21 @@ "kind": "table", "schema_name": "product-unordered-related-tables-links", "table_name": "accommodation_outbound1", - "keys": [{"unique_columns": ["id"]}], + "keys": [ + { + "unique_columns": [ + "id" + ] + } + ], "foreign_keys": [ { - "names": [["product-unordered-related-tables-links", "accommodation_outbound1_fk1"]], + "names": [ + [ + "product-unordered-related-tables-links", + "accommodation_outbound1_fk1" + ] + ], "foreign_key_columns": [ { "column_name": "fk1_col", @@ -2039,28 +2696,40 @@ { "name": "id", "nullok": false, - "type": {"typename": "text"} + "type": { + "typename": "text" + } }, { "name": "name", "nullok": false, - "type": {"typename": "text"} + "type": { + "typename": "text" + } }, { "name": "fk1_col", - "type": {"typename": "text"} + "type": { + "typename": "text" + } }, { "name": "fk2_col", - "type": {"typename": "text"} + "type": { + "typename": "text" + } }, { "name": "fk3_col", - "type": {"typename": "text"} + "type": { + "typename": "text" + } }, { "name": "fk4_col", - "type": {"typename": "text"} + "type": { + "typename": "text" + } } ] }, @@ -2068,22 +2737,34 @@ "kind": "table", "schema_name": "product-unordered-related-tables-links", "table_name": "accommodation_outbound1_outbound1", - "keys": [{"unique_columns": ["id"]}], + "keys": [ + { + "unique_columns": [ + "id" + ] + } + ], "foreign_keys": [], "column_definitions": [ { "name": "id", "nullok": false, - "type": {"typename": "text"} + "type": { + "typename": "text" + } }, { "name": "fk1_col", - "type": {"typename": "text"} + "type": { + "typename": "text" + } }, { "name": "name", "nullok": false, - "type": {"typename": "text"} + "type": { + "typename": "text" + } } ] }, @@ -2091,10 +2772,21 @@ "kind": "table", "schema_name": "product-unordered-related-tables-links", "table_name": "accommodation_inbound1", - "keys": [{"unique_columns": ["id"]}], + "keys": [ + { + "unique_columns": [ + "id" + ] + } + ], "foreign_keys": [ { - "names": [["product-unordered-related-tables-links", "accommodation_inbound1_fk1"]], + "names": [ + [ + "product-unordered-related-tables-links", + "accommodation_inbound1_fk1" + ] + ], "foreign_key_columns": [ { "column_name": "fk1_col", @@ -2115,16 +2807,22 @@ { "name": "id", "nullok": false, - "type": {"typename": "text"} - }, + "type": { + "typename": "text" + } + }, { "name": "name", "nullok": false, - "type": {"typename": "text"} + "type": { + "typename": "text" + } }, { "name": "fk1_col", - "type": {"typename": "int4"} + "type": { + "typename": "int4" + } } ] }, @@ -2132,10 +2830,21 @@ "kind": "table", "schema_name": "product-unordered-related-tables-links", "table_name": "accommodation_inbound2", - "keys": [{"unique_columns": ["id"]}], + "keys": [ + { + "unique_columns": [ + "id" + ] + } + ], "foreign_keys": [ { - "names": [["product-unordered-related-tables-links", "accommodation_inbound2_fk1"]], + "names": [ + [ + "product-unordered-related-tables-links", + "accommodation_inbound2_fk1" + ] + ], "foreign_key_columns": [ { "column_name": "fk1_col", @@ -2156,16 +2865,22 @@ { "name": "id", "nullok": false, - "type": {"typename": "text"} + "type": { + "typename": "text" + } }, { "name": "name", "nullok": false, - "type": {"typename": "text"} + "type": { + "typename": "text" + } }, { "name": "fk1_col", - "type": {"typename": "int4"} + "type": { + "typename": "int4" + } } ] }, @@ -2173,10 +2888,21 @@ "kind": "table", "schema_name": "product-unordered-related-tables-links", "table_name": "accommodation_inbound3", - "keys": [{"unique_columns": ["id"]}], + "keys": [ + { + "unique_columns": [ + "id" + ] + } + ], "foreign_keys": [ { - "names": [["product-unordered-related-tables-links", "accommodation_inbound3_fk1"]], + "names": [ + [ + "product-unordered-related-tables-links", + "accommodation_inbound3_fk1" + ] + ], "foreign_key_columns": [ { "column_name": "fk1_col", @@ -2197,18 +2923,317 @@ { "name": "id", "nullok": false, - "type": {"typename": "text"} + "type": { + "typename": "text" + } }, { "name": "name", "nullok": false, - "type": {"typename": "text"} + "type": { + "typename": "text" + } }, { "name": "fk1_col", - "type": {"typename": "int4"} + "type": { + "typename": "int4" + } } ] + }, + "association_table_w_static_column": { + "comment": "For testing 'add records' prefill and using bulk create foreign key features (with modal pickers for fk input)", + "kind": "table", + "schema_name": "product-unordered-related-tables-links", + "table_name": "association_table_w_static_column", + "column_definitions": [ + { + "name": "static_col1", + "type": { + "typename": "int4" + } + }, + { + "name": "main_fk_col", + "nullok": false, + "type": { + "typename": "int4" + } + }, + { + "name": "leaf_fk_col", + "nullok": false, + "type": { + "typename": "text" + } + } + ], + "keys": [ + { + "unique_columns": [ + "RID" + ] + }, + { + "unique_columns": [ + "main_fk_col", + "leaf_fk_col" + ] + } + ], + "foreign_keys": [ + { + "foreign_key_columns": [ + { + "schema_name": "product-unordered-related-tables-links", + "table_name": "association_table_w_static_column", + "column_name": "leaf_fk_col" + } + ], + "referenced_columns": [ + { + "schema_name": "product-unordered-related-tables-links", + "table_name": "leaf_table_for_static_columns", + "column_name": "id" + } + ], + "names": [ + [ + "product-unordered-related-tables-links", + "static_to_leaf_fkey" + ] + ] + }, + { + "foreign_key_columns": [ + { + "schema_name": "product-unordered-related-tables-links", + "table_name": "association_table_w_static_column", + "column_name": "main_fk_col" + } + ], + "referenced_columns": [ + { + "schema_name": "product-unordered-related-tables-links", + "table_name": "accommodation", + "column_name": "id" + } + ], + "names": [ + [ + "product-unordered-related-tables-links", + "static_to_accommodation_fkey" + ] + ] + } + ], + "annotations": { + "tag:isrd.isi.edu,2016:visible-columns": { + "entry": [ + "static_col1", + [ + "product-unordered-related-tables-links", + "static_to_accommodation_fkey" + ], + [ + "product-unordered-related-tables-links", + "static_to_leaf_fkey" + ] + ], + "compact": "entry", + "detailed": "entry" + } + } + }, + "leaf_table_for_static_columns": { + "comment": "leaf table for testing an association with static columns", + "kind": "table", + "table_name": "leaf_table_for_static_columns", + "schema_name": "product-unordered-related-tables-links", + "keys": [ + { + "unique_columns": [ + "id" + ] + } + ], + "foreign_keys": [], + "column_definitions": [ + { + "name": "id", + "type": { + "typename": "text" + } + }, + { + "name": "details", + "type": { + "typename": "text" + } + } + ], + "annotations": { + "tag:isrd.isi.edu,2016:table-display": { + "row_name": { + "row_markdown_pattern": "{{{details}}}" + } + }, + "tag:isrd.isi.edu,2016:visible-columns": { + "compact": [ + "id", + "details" + ] + } + } + }, + "association_table_w_static_column_dropdown": { + "comment": "For testing 'add records' prefill and using bulk create foreign key features (with dropdown pickers for fk input)", + "kind": "table", + "schema_name": "product-unordered-related-tables-links", + "table_name": "association_table_w_static_column_dropdown", + "column_definitions": [ + { + "name": "static_col1", + "type": { + "typename": "int4" + } + }, + { + "name": "main_fk_col", + "nullok": false, + "type": { + "typename": "int4" + } + }, + { + "name": "leaf_fk_col", + "nullok": false, + "type": { + "typename": "text" + } + } + ], + "keys": [ + { + "unique_columns": [ + "RID" + ] + }, + { + "unique_columns": [ + "main_fk_col", + "leaf_fk_col" + ] + } + ], + "foreign_keys": [ + { + "foreign_key_columns": [ + { + "schema_name": "product-unordered-related-tables-links", + "table_name": "association_table_w_static_column_dropdown", + "column_name": "leaf_fk_col" + } + ], + "referenced_columns": [ + { + "schema_name": "product-unordered-related-tables-links", + "table_name": "leaf_table_for_static_columns_dropdown", + "column_name": "id" + } + ], + "names": [ + [ + "product-unordered-related-tables-links", + "static_to_leaf_w_dropdown_fkey" + ] + ] + }, + { + "foreign_key_columns": [ + { + "schema_name": "product-unordered-related-tables-links", + "table_name": "association_table_w_static_column_dropdown", + "column_name": "main_fk_col" + } + ], + "referenced_columns": [ + { + "schema_name": "product-unordered-related-tables-links", + "table_name": "accommodation", + "column_name": "id" + } + ], + "names": [ + [ + "product-unordered-related-tables-links", + "static_to_accommodation_w_dropdown_fkey" + ] + ] + } + ], + "annotations": { + "tag:isrd.isi.edu,2016:visible-columns": { + "entry": [ + "static_col1", + [ + "product-unordered-related-tables-links", + "static_to_accommodation_w_dropdown_fkey" + ], + [ + "product-unordered-related-tables-links", + "static_to_leaf_w_dropdown_fkey" + ] + ], + "compact": "entry", + "detailed": "entry" + } + } + }, + "leaf_table_for_static_columns_dropdown": { + "comment": "leaf table for testing an association with static columns (and dropdown for fk input)", + "kind": "table", + "table_name": "leaf_table_for_static_columns_dropdown", + "schema_name": "product-unordered-related-tables-links", + "keys": [ + { + "unique_columns": [ + "id" + ] + } + ], + "foreign_keys": [], + "column_definitions": [ + { + "name": "id", + "type": { + "typename": "text" + } + }, + { + "name": "details", + "type": { + "typename": "text" + } + } + ], + "annotations": { + "tag:isrd.isi.edu,2016:table-display": { + "entry": { + "selector_ux_mode": "simple-search-dropdown" + }, + "row_name": { + "row_markdown_pattern": "{{{details}}}" + } + }, + "tag:isrd.isi.edu,2016:visible-columns": { + "compact": [ + "id", + "details" + ] + } + } } }, "comment": null, @@ -2216,7 +3241,6 @@ "tag:misd.isi.edu,2015:display": { "name": "accommodation" } - }, "schema_name": "product-unordered-related-tables-links" } diff --git a/test/e2e/locators/modal.ts b/test/e2e/locators/modal.ts index 40ee715a2..31342d4f9 100644 --- a/test/e2e/locators/modal.ts +++ b/test/e2e/locators/modal.ts @@ -37,6 +37,10 @@ export default class ModalLocators { return page.locator('.foreignkey-popup'); } + static getRecordeditBulkFKPopup(page: Page): Locator { + return page.locator('.bulk-foreign-key-popup'); + } + static getErrorModal(page: Page): Locator { return page.locator('.modal-error'); } diff --git a/test/e2e/locators/recordedit.ts b/test/e2e/locators/recordedit.ts index ce9758f75..30475949c 100644 --- a/test/e2e/locators/recordedit.ts +++ b/test/e2e/locators/recordedit.ts @@ -87,6 +87,10 @@ export default class RecordeditLocators { return container.locator('#bulk-delete-button'); } + static getAddMoreButton(container: Locator | Page): Locator { + return container.locator('#recordedit-add-more'); + } + static getRecordeditForms(container: Locator | Page): Locator { return container.locator('.recordedit-form .form-header'); } @@ -123,6 +127,7 @@ export default class RecordeditLocators { return container.locator('button.remove-form-btn'); } + // Forms are indexed starting with 1, delete row button is indexed with 0 static getDeleteRowButton(container: Locator | Page, index: number): Locator { index = index || 0; return RecordeditLocators.getAllDeleteRowButtons(container).nth(index); @@ -334,6 +339,12 @@ export default class RecordeditLocators { return container.locator(`.input-switch-container-${inputName} .dropdown-toggle`); } + static getDropdownMenuByName = (container: Locator | Page, name: string, formNumber: number) => { + formNumber = formNumber || 1; + const inputName = `c_${formNumber}-${name}`; + return container.locator(`.input-switch-container-${inputName} .dropdown-menu`); + } + // --------------- foreign key dropdown selectors ------------- // static getFkeyDropdowns(container: Locator | Page): Locator { return container.locator('.fk-dropdown'); @@ -343,6 +354,18 @@ export default class RecordeditLocators { return container.locator('.dropdown-menu.show').locator('.dropdown-select-value'); } + static getFKDropdownOptions(container: Locator | Page): Locator { + return container.locator('.dropdown-menu.show').locator('.dropdown-list li'); + } + + static getDropdownDisabledOptions(container: Locator | Page): Locator { + return container.locator('.dropdown-menu.show').locator('.dropdown-item.disabled'); + } + + static getDropdownRow(container: Locator | Page, index: number): Locator { + return this.getDropdownSelectableOptions(container).nth(index); + } + static getDropdownLoadMoreRow(container: Locator | Page): Locator { return container.locator('.dropdown-menu .load-more-row'); } diff --git a/test/e2e/specs/all-features/record/related-table.spec.ts b/test/e2e/specs/all-features/record/related-table.spec.ts index d807771ec..ba19a984e 100644 --- a/test/e2e/specs/all-features/record/related-table.spec.ts +++ b/test/e2e/specs/all-features/record/related-table.spec.ts @@ -1,4 +1,4 @@ -import { expect, Page, test } from '@playwright/test'; +import { expect, Locator, Page, test } from '@playwright/test'; import moment from 'moment'; //locators @@ -10,12 +10,13 @@ import RecordsetLocators from '@isrd-isi-edu/chaise/test/e2e/locators/recordset' //utils import { getCatalogID, getEntityRow, importACLs } from '@isrd-isi-edu/chaise/test/e2e/utils/catalog-utils'; import { APP_NAMES, RESTRICTED_USER_STORAGE_STATE } from '@isrd-isi-edu/chaise/test/e2e/utils/constants'; -import { testTooltip } from '@isrd-isi-edu/chaise/test/e2e/utils/page-utils'; +import { clickNewTabLink, testTooltip } from '@isrd-isi-edu/chaise/test/e2e/utils/page-utils'; import { - testAddAssociationTable, testAddRelatedTable, testBatchUnlinkAssociationTable, - testRelatedTablePresentation, testShareCiteModal + testAddAssociationTable, testAddRelatedTable, testAddRelatedWithForeignKeyMultiPicker, + testBatchUnlinkAssociationTable, testRelatedTablePresentation, testShareCiteModal } from '@isrd-isi-edu/chaise/test/e2e/utils/record-utils'; -import { testRecordsetTableRowValues, testTotalCount } from '@isrd-isi-edu/chaise/test/e2e/utils/recordset-utils'; +import { testInputValue } from '@isrd-isi-edu/chaise/test/e2e/utils/recordedit-utils'; +import { testModalClose, testRecordsetTableRowValues, testTotalCount } from '@isrd-isi-edu/chaise/test/e2e/utils/recordset-utils'; const testParams = { @@ -42,7 +43,9 @@ const testParams = { 'inbound related with filter on related table', // related entity with filter on related table 'association with filter on main table', 'association with filter on related table', // association with filter on related table - 'path of length 3 with filters' // path of length 3 with filters + 'path of length 3 with filters', // path of length 3 with filters + 'association_table_w_static_column', // "almost" pure and binary multi create foreig key with fk input modals + 'association_table_w_static_column_dropdown' // "almost" pure and binary multi create foreig key with fk input dropdowns ], tocHeaders: [ 'Summary', 'booking (6)', 'schedule (2)', 'media (1)', 'association_table (1)', @@ -54,7 +57,9 @@ const testParams = { 'inbound related with filter on related table (1)', 'association with filter on main table (1)', 'association with filter on related table (1)', - 'path of length 3 with filters (1)' + 'path of length 3 with filters (1)', + 'association_table_w_static_column (1)', + 'association_table_w_static_column_dropdown (1)' ], scrollToDisplayname: 'table_w_aggregates' }; @@ -831,6 +836,131 @@ test.describe('Related tables', () => { }); }); + /** + * The following 2 tests are for testing the prefill functionality when the inbound foreign key is part of a table that is "almost" pure and binary + * + * This means there are 2 foreign keys that are part of the same key (making the pair unique) and there are other columns that are not foreign keys + */ + test.describe('for a table that is almost pure and binary and the foreign keys are a unique key', async () => { + const params = { + table_name: 'association_table_w_static_column', + prefill_col: 'main_fk_col', + leaf_col: 'leaf_fk_col', + leaf_fk_name: 'leaf_fk_col', + prefill_value: 'Super 8 North Hollywood Motel', + column_names: ['static1', 'main_fk_col', 'leaf_fk_col'], + resultset_values: [['', '2004', '10'], ['', '2004', '7']], + related_table_values: [['2', 'Leaf 2'], ['', 'Leaf 10'], ['', 'Leaf 7']], + bulk_modal_title: 'Select a set of leaf_fk_col for association_table_w_static_column' + } + + test('with fk inputs as modals', async ({ page }) => { + await testAddRelatedWithForeignKeyMultiPicker(page, params, RecordeditInputType.FK_POPUP); + }); + + /** + * this test verifies the functionality when the first modal is closed and does NOT fill in the first form + * subsequent actions in add more should continue to add new forms without filling the first form + * + * This test ensures the tracking of bulkForeignKeySelectedRows is done right when the first form has no value filled in by the initial modal + */ + test('closing the initial modal and using "Add more" to add more forms with values', async ({ page }) => { + let newPage: Page, bulkFKModal: Locator; + + await test.step('should open recordedit with a modal picker showing', async () => { + const addBtn = RecordLocators.getRelatedTableAddButton(page, params.table_name); + + newPage = await clickNewTabLink(addBtn); + await RecordeditLocators.waitForRecordeditPageReady(newPage); + + bulkFKModal = ModalLocators.getRecordeditBulkFKPopup(newPage); + await expect.soft(bulkFKModal).toBeAttached(); + }); + + await test.step('modal should have 3 disabled rows', async () => { + const rows = RecordsetLocators.getRows(bulkFKModal); + await expect.soft(rows).toHaveCount(10); + await expect.soft(RecordsetLocators.getCheckedCheckboxInputs(bulkFKModal)).toHaveCount(3); + + await expect.soft(RecordsetLocators.getDisabledRows(bulkFKModal)).toHaveCount(3); + await expect.soft(rows.nth(1)).toHaveClass(/disabled-row/); // Leaf 2 + await expect.soft(rows.nth(6)).toHaveClass(/disabled-row/); // Leaf 7 + await expect.soft(rows.nth(9)).toHaveClass(/disabled-row/); // Leaf 10 + }); + + await test.step('closing the initial modal should not add any new forms AND not fill the first form', async () => { + await testModalClose(bulkFKModal); + + await expect.soft(RecordeditLocators.getRecordeditForms(newPage)).toHaveCount(1); + await testInputValue(newPage, 1, params.prefill_col, params.prefill_col, RecordeditInputType.FK_POPUP, false, params.prefill_value); + await testInputValue(newPage, 1, params.leaf_col, params.leaf_col, RecordeditInputType.FK_POPUP, false, 'Select a value'); + }); + + await test.step('clicking "add more" should have 3 rows disabled', async () => { + await RecordeditLocators.getAddMoreButton(newPage).click(); + // The same modal when the page loaded should show again + await expect.soft(bulkFKModal).toBeAttached(); + + const rows = RecordsetLocators.getRows(bulkFKModal); + await expect.soft(rows).toHaveCount(10); + await expect.soft(RecordsetLocators.getCheckedCheckboxInputs(bulkFKModal)).toHaveCount(3); + + await expect.soft(RecordsetLocators.getDisabledRows(bulkFKModal)).toHaveCount(3); + await expect.soft(rows.nth(1)).toHaveClass(/disabled-row/); // Leaf 2 + await expect.soft(rows.nth(6)).toHaveClass(/disabled-row/); // Leaf 7 + await expect.soft(rows.nth(9)).toHaveClass(/disabled-row/); // Leaf 10 + }); + + await test.step('select 2 more rows and submit the selection', async () => { + await RecordsetLocators.getRowCheckboxInput(bulkFKModal, 0).click(); + await RecordsetLocators.getRowCheckboxInput(bulkFKModal, 4).click(); + + await ModalLocators.getSubmitButton(bulkFKModal).click(); + await expect.soft(bulkFKModal).not.toBeAttached(); + + await expect.soft(RecordeditLocators.getRecordeditForms(newPage)).toHaveCount(3); + }); + + await test.step('first form should still not be filled in', async () => { + await testInputValue(newPage, 1, params.leaf_col, params.leaf_col, RecordeditInputType.FK_POPUP, false, 'Select a value'); + }); + + await test.step('2 new forms should have expected values filled in for prefill and new modal selections', async () => { + await testInputValue(newPage, 2, params.prefill_col, params.prefill_col, RecordeditInputType.FK_POPUP, false, params.prefill_value); + await testInputValue(newPage, 3, params.prefill_col, params.prefill_col, RecordeditInputType.FK_POPUP, false, params.prefill_value); + + await testInputValue(newPage, 2, params.leaf_col, params.leaf_col, RecordeditInputType.FK_POPUP, false, 'Leaf 1'); + await testInputValue(newPage, 3, params.leaf_col, params.leaf_col, RecordeditInputType.FK_POPUP, false, 'Leaf 5'); + }); + + await test.step('removing the 2nd form should have the correct rows disabled in the "Add more" modal', async () => { + await RecordeditLocators.getDeleteRowButton(newPage, 1).click(); + + await RecordeditLocators.getAddMoreButton(newPage).click(); + // The same modal when the page loaded should show again + await expect.soft(bulkFKModal).toBeAttached(); + + const rows = RecordsetLocators.getRows(bulkFKModal); + await expect.soft(rows).toHaveCount(10); + await expect.soft(RecordsetLocators.getCheckedCheckboxInputs(bulkFKModal)).toHaveCount(4); + + await expect.soft(RecordsetLocators.getDisabledRows(bulkFKModal)).toHaveCount(4); + await expect.soft(rows.nth(0)).not.toHaveClass(/disabled-row/); // Leaf 1 - the row that was just removed + await expect.soft(rows.nth(1)).toHaveClass(/disabled-row/); // Leaf 2 + await expect.soft(rows.nth(4)).toHaveClass(/disabled-row/); // Leaf 5 + await expect.soft(rows.nth(6)).toHaveClass(/disabled-row/); // Leaf 7 + await expect.soft(rows.nth(9)).toHaveClass(/disabled-row/); // Leaf 10 + }); + }); + + test('with fk inputs as dropdowns', async ({ page }) => { + params.table_name = 'association_table_w_static_column_dropdown'; + params.leaf_fk_name = 'U0KYeFQJ-lwuLEaGb2RNRg'; + params.bulk_modal_title = 'Select a set of leaf_fk_col for association_table_w_static_column_dropdown' + + await testAddRelatedWithForeignKeyMultiPicker(page, params, RecordeditInputType.FK_DROPDOWN); + }); + }); }); test.describe('Scroll to query parameter', () => { diff --git a/test/e2e/utils/page-utils.ts b/test/e2e/utils/page-utils.ts index cbdf5377b..1cfcc15d7 100644 --- a/test/e2e/utils/page-utils.ts +++ b/test/e2e/utils/page-utils.ts @@ -102,7 +102,6 @@ export async function clickAndVerifyDownload(locator: Locator, expectedFileName: */ export async function testTooltip(locator: Locator, expectedTooltip: string | RegExp, appName: APP_NAMES, isSoft?: boolean, hoverEl?: Locator) { await locator.hover(); - await locator.page().pause(); const el = PageLocators.getTooltipContainer(locator.page()); diff --git a/test/e2e/utils/record-utils.ts b/test/e2e/utils/record-utils.ts index 92a58b362..a9fa0766a 100644 --- a/test/e2e/utils/record-utils.ts +++ b/test/e2e/utils/record-utils.ts @@ -6,14 +6,16 @@ import RecordLocators from '@isrd-isi-edu/chaise/test/e2e/locators/record'; import RecordeditLocators, { RecordeditInputType } from '@isrd-isi-edu/chaise/test/e2e/locators/recordedit'; import RecordsetLocators from '@isrd-isi-edu/chaise/test/e2e/locators/recordset'; +// utils import { EntityRowColumnValues, getCatalogID, getEntityRow } from '@isrd-isi-edu/chaise/test/e2e/utils/catalog-utils'; import { APP_NAMES, PW_PROJECT_NAMES } from '@isrd-isi-edu/chaise/test/e2e/utils/constants'; import { clickAndVerifyDownload, clickNewTabLink, getClipboardContent, manuallyTriggerFocus, testTooltip } from '@isrd-isi-edu/chaise/test/e2e/utils/page-utils'; +import { clearInputValue, testInputValue, testSubmission } from '@isrd-isi-edu/chaise/test/e2e/utils/recordedit-utils'; import { - RecordsetColValue, RecordsetRowValue, + RecordsetColValue, RecordsetRowValue, testModalClose, testRecordsetTableRowValues, testTotalCount } from '@isrd-isi-edu/chaise/test/e2e/utils/recordset-utils'; @@ -760,3 +762,251 @@ export const testDeleteConfirm = async (page: Page, btn: Locator, confirmText: s await ModalLocators.getOkButton(modal).click(); await expect.soft(modal).not.toBeAttached(); } + +type AddRecordsForeignKeyMultiParams = { + table_name: string, + prefill_col: string, + leaf_col: string, + leaf_fk_name: string, + prefill_value: string, + column_names: string[], + resultset_values: RecordsetRowValue[], + related_table_values: RecordsetRowValue[], + bulk_modal_title: string +} + +/** + * Function to test foreign key multi picker when there is a prefill query param from record app in recordedit + * can test both modal and dropdown foreign key input types + * + * @param params params for the test + * @param inputType RecordeditInputType for popup or dropdown + */ +export const testAddRelatedWithForeignKeyMultiPicker = async ( + page: Page, + params: AddRecordsForeignKeyMultiParams, + inputType: RecordeditInputType.FK_DROPDOWN | RecordeditInputType.FK_POPUP +) => { + let newPage: Page, bulkFKModal: Locator, fkInputModal: Locator, fkInputDropdown: Locator, dropdownMenu: Locator; + + const isModal = inputType === RecordeditInputType.FK_POPUP; + + await test.step('should open recordedit with a modal picker showing', async () => { + const addBtn = RecordLocators.getRelatedTableAddButton(page, params.table_name); + + newPage = await clickNewTabLink(addBtn); + await RecordeditLocators.waitForRecordeditPageReady(newPage); + + bulkFKModal = ModalLocators.getRecordeditBulkFKPopup(newPage); + await expect.soft(bulkFKModal).toBeAttached(); + await expect.soft(ModalLocators.getModalTitle(bulkFKModal)).toHaveText(params.bulk_modal_title); + }); + + await test.step('modal should have 1 row selected and disabled', async () => { + const rows = RecordsetLocators.getRows(bulkFKModal); + await expect.soft(rows).toHaveCount(10); + await expect.soft(RecordsetLocators.getCheckedCheckboxInputs(bulkFKModal)).toHaveCount(1); + + await expect.soft(RecordsetLocators.getDisabledRows(bulkFKModal)).toHaveCount(1); + await expect.soft(rows.nth(1)).toHaveClass(/disabled-row/); + }); + + await test.step('select 2 rows and submit the selection', async () => { + await RecordsetLocators.getRowCheckboxInput(bulkFKModal, 3).click(); + await RecordsetLocators.getRowCheckboxInput(bulkFKModal, 4).click(); + + await ModalLocators.getSubmitButton(bulkFKModal).click(); + await expect.soft(bulkFKModal).not.toBeAttached(); + + await expect.soft(RecordeditLocators.getRecordeditForms(newPage)).toHaveCount(2); + }); + + await test.step('2 forms should have expected values filled in for prefill and modal selections', async () => { + await testInputValue(newPage, 1, params.prefill_col, params.prefill_col, inputType, false, params.prefill_value); + await testInputValue(newPage, 2, params.prefill_col, params.prefill_col, inputType, false, params.prefill_value); + + await testInputValue(newPage, 1, params.leaf_col, params.leaf_col, inputType, false, 'Leaf 4'); + await testInputValue(newPage, 2, params.leaf_col, params.leaf_col, inputType, false, 'Leaf 5'); + }); + + await test.step('clicking a foreign key input should show a modal/dropdown with 2 rows disabled', async () => { + let rows; + if (isModal) { + fkInputModal = ModalLocators.getForeignKeyPopup(newPage); + await RecordeditLocators.getForeignKeyInputButton(newPage, params.leaf_fk_name, 1).click(); + + await expect.soft(fkInputModal).toBeAttached(); + await expect.soft(RecordsetLocators.getDisabledRows(fkInputModal)).toHaveCount(2); + + rows = RecordsetLocators.getRows(fkInputModal); + } else { + fkInputDropdown = RecordeditLocators.getDropdownElementByName(newPage, params.leaf_fk_name, 1); + dropdownMenu = RecordeditLocators.getDropdownMenuByName(newPage, params.leaf_fk_name, 1); + await fkInputDropdown.click(); + + await expect.soft(dropdownMenu).toBeVisible(); + await expect.soft(RecordeditLocators.getDropdownDisabledOptions(newPage)).toHaveCount(2); + + rows = RecordeditLocators.getFKDropdownOptions(newPage); + } + + await expect.soft(rows).toHaveCount(10); + await expect.soft(rows.nth(1)).toHaveClass(/disabled/); + await expect.soft(rows.nth(4)).toHaveClass(/disabled/); + }); + + await test.step('test tooltips for disabled rows and already selected row', async () => { + let associatedSelector, otherInputSelector, app; + if (isModal) { + associatedSelector = RecordsetLocators.getRowSelectButton(fkInputModal, 1); + otherInputSelector = RecordsetLocators.getRowSelectButton(fkInputModal, 4) + app = APP_NAMES.RECORDSET; + + // test row tooltip for row that is already selected for this input + await testTooltip(RecordsetLocators.getRowSelectButton(fkInputModal, 3), 'Selected', app, true); + } else { + associatedSelector = RecordeditLocators.getDropdownRow(newPage, 1); + otherInputSelector = RecordeditLocators.getDropdownRow(newPage, 4); + app = APP_NAMES.RECORDEDIT; + + // no tooltip on selected row in fk dropdown + } + + // test row tooltip that is associated when catalog created + await testTooltip(associatedSelector, 'This row is already associated', app, true); + + // test row tooltip for row that is selected for another input + await testTooltip(otherInputSelector, 'This row is selected in another input in the form', app, true); + }); + + await test.step('selecting a row should update the input we selected a value for', async () => { + if (isModal) { + await RecordsetLocators.getRowSelectButton(fkInputModal, 9).click(); + await expect.soft(fkInputModal).not.toBeAttached(); + } else { + await RecordeditLocators.getDropdownRow(newPage, 9).click(); + await expect.soft(dropdownMenu).not.toBeVisible(); + } + + await expect.soft(RecordeditLocators.getForeignKeyInputDisplay(newPage, params.leaf_col, 1)).not.toHaveText('Leaf 4'); + await testInputValue(newPage, 1, params.leaf_col, params.leaf_col, inputType, false, 'Leaf 10'); + + // make sure other input didn't change + await testInputValue(newPage, 2, params.leaf_col, params.leaf_col, inputType, false, 'Leaf 5'); + }); + + await test.step('clicking "add more" should have 3 rows disabled', async () => { + await RecordeditLocators.getAddMoreButton(newPage).click(); + // The same modal when the page loaded should show again + await expect.soft(bulkFKModal).toBeAttached(); + + const rows = RecordsetLocators.getRows(bulkFKModal); + await expect.soft(rows).toHaveCount(10); + await expect.soft(RecordsetLocators.getCheckedCheckboxInputs(bulkFKModal)).toHaveCount(3); + await expect.soft(RecordsetLocators.getDisabledRows(bulkFKModal)).toHaveCount(3); + + await expect.soft(rows.nth(1)).toHaveClass(/disabled-row/); + await expect.soft(rows.nth(4)).toHaveClass(/disabled-row/); + await expect.soft(rows.nth(9)).toHaveClass(/disabled-row/); + }); + + await test.step('select 2 more rows and submit the selection', async () => { + await RecordsetLocators.getRowCheckboxInput(bulkFKModal, 0).click(); + await RecordsetLocators.getRowCheckboxInput(bulkFKModal, 6).click(); + + await ModalLocators.getSubmitButton(bulkFKModal).click(); + await expect.soft(bulkFKModal).not.toBeAttached(); + + await expect.soft(RecordeditLocators.getRecordeditForms(newPage)).toHaveCount(4); + }); + + await test.step('2 new forms should have expected values filled in for prefill and new modal selections', async () => { + await testInputValue(newPage, 3, params.prefill_col, params.prefill_col, inputType, false, params.prefill_value); + await testInputValue(newPage, 4, params.prefill_col, params.prefill_col, inputType, false, params.prefill_value); + + await testInputValue(newPage, 3, params.leaf_col, params.leaf_col, inputType, false, 'Leaf 1'); + await testInputValue(newPage, 4, params.leaf_col, params.leaf_col, inputType, false, 'Leaf 7'); + }); + + await test.step('clicking a different foreign key input should show a modal with 4 rows disabled', async () => { + let rows; + if (isModal) { + await RecordeditLocators.getForeignKeyInputButton(newPage, params.leaf_fk_name, 3).click(); + + await expect.soft(fkInputModal).toBeAttached(); + await expect.soft(RecordsetLocators.getDisabledRows(fkInputModal)).toHaveCount(4); + + rows = RecordsetLocators.getRows(fkInputModal); + } else { + fkInputDropdown = RecordeditLocators.getDropdownElementByName(newPage, params.leaf_fk_name, 3); + dropdownMenu = RecordeditLocators.getDropdownMenuByName(newPage, params.leaf_fk_name, 3); + await fkInputDropdown.click(); + + await expect.soft(dropdownMenu).toBeVisible(); + await expect.soft(RecordeditLocators.getDropdownDisabledOptions(newPage)).toHaveCount(4); + + rows = RecordeditLocators.getFKDropdownOptions(newPage); + } + + await expect.soft(rows).toHaveCount(10); + await expect.soft(rows.nth(1)).toHaveClass(/disabled/); + await expect.soft(rows.nth(4)).toHaveClass(/disabled/); + await expect.soft(rows.nth(6)).toHaveClass(/disabled/); + await expect.soft(rows.nth(9)).toHaveClass(/disabled/); + + if (isModal) { + await testModalClose(fkInputModal); + } else { + await fkInputDropdown.click(); + await expect.soft(dropdownMenu).not.toBeVisible(); + } + }); + + await test.step('clicking x for an input should clear the value and update disabled rows in "add more"', async () => { + // clear the value in the 2nd form + await clearInputValue(newPage, 2, params.leaf_col, params.leaf_col, inputType); + await testInputValue(newPage, 2, params.leaf_col, params.leaf_col, inputType, false, 'Select a value'); + + await RecordeditLocators.getAddMoreButton(newPage).click(); + await expect.soft(bulkFKModal).toBeAttached(); + + const rows = RecordsetLocators.getRows(bulkFKModal); + await expect.soft(rows).toHaveCount(10); + await expect.soft(RecordsetLocators.getCheckedCheckboxInputs(bulkFKModal)).toHaveCount(4); + await expect.soft(RecordsetLocators.getDisabledRows(bulkFKModal)).toHaveCount(4); + + await testModalClose(bulkFKModal); + }); + + await test.step('remove the cleared input form and another form, then verify rows disabled in "add more" once more', async () => { + // remove in reverse order + await RecordeditLocators.getDeleteRowButton(newPage, 2).click(); + await RecordeditLocators.getDeleteRowButton(newPage, 1).click(); + + await RecordeditLocators.getAddMoreButton(newPage).click(); + await expect.soft(bulkFKModal).toBeAttached(); + + const rows = RecordsetLocators.getRows(bulkFKModal); + await expect.soft(rows).toHaveCount(10); + await expect.soft(RecordsetLocators.getCheckedCheckboxInputs(bulkFKModal)).toHaveCount(3); + await expect.soft(RecordsetLocators.getDisabledRows(bulkFKModal)).toHaveCount(3); + + await testModalClose(bulkFKModal); + }); + + await test.step('submit the data and test submission table', async () => { + await testSubmission(newPage, { + tableDisplayname: params.table_name, + resultColumnNames: params.column_names, + resultRowValues: params.resultset_values + }); + }); + + await test.step('close the tab and record app should update the related table with the new rows', async () => { + await newPage.close(); + await manuallyTriggerFocus(page); + + const prefillTable = RecordLocators.getRelatedTableContainer(page, params.table_name); + await testRecordsetTableRowValues(prefillTable, params.related_table_values, true); + }); +} diff --git a/test/e2e/utils/recordedit-utils.ts b/test/e2e/utils/recordedit-utils.ts index fd07ac9ac..66757011c 100644 --- a/test/e2e/utils/recordedit-utils.ts +++ b/test/e2e/utils/recordedit-utils.ts @@ -146,6 +146,7 @@ export const clearInputValue = async ( page: Page, formNumber: number, name: string, displayname: string, inputType: RecordeditInputType, ) => { switch (inputType) { + case RecordeditInputType.FK_DROPDOWN: case RecordeditInputType.FK_POPUP: const fkBtn = RecordeditLocators.getForeignKeyInputClear(page, displayname, formNumber); await fkBtn.click(); @@ -606,8 +607,25 @@ const _testInputValidationAndExtraFeatures = async ( case RecordeditInputType.FK_POPUP: const displayedValue = RecordeditLocators.getForeignKeyInputDisplay(page, displayname, formNumber); + const rsModal = ModalLocators.getForeignKeyPopup(page); if (typeof existingValue === 'string') { + // before clearing the value, ensure the selected row has the right tooltip in the fk input modal first + await test.step('check the tooltip of the selected row in the modal before clearing the value', async () => { + await RecordeditLocators.getForeignKeyInputButton(page, displayname, formNumber).click(); + await expect.soft(rsModal).toBeVisible(); + + // In the multi edit spec, we have 2 forms + // in the 1st form, the 1st row is selected + // in the 2nd form, the 3rd row is selected + const selectedRowIndex = formNumber === 1 ? 0 : 2; + await testTooltip(RecordsetLocators.getRowSelectButton(rsModal, selectedRowIndex), 'Selected', APP_NAMES.RECORDSET, true); + + await ModalLocators.getCloseBtn(rsModal).click(); + await expect.soft(rsModal).not.toBeAttached(); + }); + + // value should be unchanged from previous test since the modal was closed with no selection made await test.step('clicking the "x" should remove the value in the foreign key field.', async () => { await expect.soft(displayedValue).toHaveText(existingValue); await RecordeditLocators.getForeignKeyInputClear(page, displayname, formNumber).click(); @@ -616,7 +634,6 @@ const _testInputValidationAndExtraFeatures = async ( } await test.step('popup selector', async () => { - const rsModal = ModalLocators.getForeignKeyPopup(page); await test.step('should have the proper title.', async () => { await RecordeditLocators.getForeignKeyInputButton(page, displayname, formNumber).click(); await expect.soft(rsModal).toBeVisible();