diff --git a/client/build.sh b/client/build.sh index d2f19c776..15e59c5d7 100755 --- a/client/build.sh +++ b/client/build.sh @@ -145,6 +145,7 @@ uglifyjs \ 'SM/Library.js' \ 'SM/StigRevision.js' \ 'SM/Inventory.js' \ +'SM/AssetSelection.js' \ 'library.js' \ 'userAdmin.js' \ 'collectionAdmin.js' \ diff --git a/client/src/css/dark-mode.css b/client/src/css/dark-mode.css index f0c1248d2..abbe5b3e6 100644 --- a/client/src/css/dark-mode.css +++ b/client/src/css/dark-mode.css @@ -1365,18 +1365,6 @@ td.sort-asc .x-grid3-hd-inner, td.sort-desc .x-grid3-hd-inner, td.x-grid3-hd-men background-color: #181a1b } -.x-dd-drop-nodrop .x-dd-drop-icon { - background-image: url("../ext/resources/images/default/dd/drop-no.gif") -} - -.x-dd-drop-ok .x-dd-drop-icon { - background-image: url("../ext/resources/images/default/dd/drop-yes.gif") -} - -.x-dd-drop-ok-add .x-dd-drop-icon { - background-image: url("../ext/resources/images/default/dd/drop-add.gif") -} - .x-view-selector { background-color: #2f3335; border-color: #52585c @@ -3184,3 +3172,10 @@ embed[type="application/pdf"] { .sm-diff-del { background-color: hsl(0deg 71% 25%) } +.sm-round-panel .x-panel-header.sm-selections-panel-header { + background-color: #425722; +} +.sm-grabbing *, .sm-grabbing .sm-grid3-draggable .x-grid3-row-selected *, .sm-grabbing .sm-grid3-draggable .x-grid3-row-selected { + cursor: url("../img/drag-drop-dark.svg"), grabbing; +} + \ No newline at end of file diff --git a/client/src/css/stigman.css b/client/src/css/stigman.css index ea44ca65a..15de738e5 100644 --- a/client/src/css/stigman.css +++ b/client/src/css/stigman.css @@ -330,6 +330,9 @@ margin-left: 3px; white-space: nowrap } +.sm-jumbo-sprite { + font-size: 12px; +} .sm-diff-sprite { background-color: hsl(207deg 100% 84%); color: #000; @@ -2134,3 +2137,67 @@ td.x-grid3-hd-over .x-grid3-hd-inner { margin-top: 0px; margin-bottom: 0px; } +.sm-add-assignment-icon { + background-image: url(../img/add-assignment.svg); + background-size: 32px 32px; + background-repeat: no-repeat; +} +.x-item-disabled .sm-add-assignment-icon, .x-item-disabled .sm-remove-assignment-icon { + filter: grayscale(1); +} +.sm-remove-assignment-icon { + background-image: url(../img/remove-assignment.svg); + background-size: 32px 32px; + background-repeat: no-repeat; +} +.x-dd-drop-nodrop .x-dd-drop-icon { + background-image: url("../img/remove.svg"); + background-size: 16px 16px; +} +.x-dd-drop-ok .x-dd-drop-icon { + background-image: url("../img/add.svg"); + background-size: 16px 16px; +} + +.x-dd-drop-ok-add .x-dd-drop-icon { + background-image: url("../img/add.svg"); + background-size: 16px 16px; +} +.sm-round-panel .x-panel-header.sm-selections-panel-header { + background-color: #c3deab; +} +.sm-fieldset-title-with-icon { + background-repeat: no-repeat; + background-size: 14px 14px; + padding-left: 20px; +} +.sm-stig-information-title { + background-image: url(../img/shield-green-check.svg); + background-repeat: no-repeat; + background-size: 14px 14x; + padding-left: 20px; + +} +.sm-asset-assignments-title { + background-image: url(../img/target.svg); + background-repeat: no-repeat; + background-size: 14px 14px; + padding-left: 20px; +} +.sm-label-title { + background-image: url(../img/label.svg); + background-repeat: no-repeat; + background-size: 14px 14px; + padding-left: 20px; +} + +.sm-grid3-draggable .x-grid3-row-selected .x-grid3-td-checker *{ + cursor: default; +} + +.sm-grid3-draggable .x-grid3-row-selected *, .sm-grid3-draggable .x-grid3-row-selected { + cursor: grab; +} +.sm-grabbing *, .sm-grabbing .sm-grid3-draggable .x-grid3-row-selected *, .sm-grabbing .sm-grid3-draggable .x-grid3-row-selected { + cursor: url("../img/drag-drop-light.svg"), grabbing; +} diff --git a/client/src/img/add-assignment.svg b/client/src/img/add-assignment.svg new file mode 100644 index 000000000..347ab6371 --- /dev/null +++ b/client/src/img/add-assignment.svg @@ -0,0 +1,41 @@ + + + + + + + + diff --git a/client/src/img/drag-drop-dark.svg b/client/src/img/drag-drop-dark.svg new file mode 100644 index 000000000..6d538fb5f --- /dev/null +++ b/client/src/img/drag-drop-dark.svg @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/client/src/img/drag-drop-light.svg b/client/src/img/drag-drop-light.svg new file mode 100644 index 000000000..d6be65be4 --- /dev/null +++ b/client/src/img/drag-drop-light.svg @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/client/src/img/remove-assignment.svg b/client/src/img/remove-assignment.svg new file mode 100644 index 000000000..d2393238e --- /dev/null +++ b/client/src/img/remove-assignment.svg @@ -0,0 +1,41 @@ + + + + + + + + diff --git a/client/src/img/whatsnew/2023-10-31-new-label-interface-filters-columns-crop.png b/client/src/img/whatsnew/2023-10-31-new-label-interface-filters-columns-crop.png new file mode 100644 index 000000000..7b60b6480 Binary files /dev/null and b/client/src/img/whatsnew/2023-10-31-new-label-interface-filters-columns-crop.png differ diff --git a/client/src/img/whatsnew/2023-10-31-new-label-interface-w-arrow.png b/client/src/img/whatsnew/2023-10-31-new-label-interface-w-arrow.png new file mode 100644 index 000000000..fe6aa4139 Binary files /dev/null and b/client/src/img/whatsnew/2023-10-31-new-label-interface-w-arrow.png differ diff --git a/client/src/img/whatsnew/2023-10-31-new-label-interface-with-popup-crop.png b/client/src/img/whatsnew/2023-10-31-new-label-interface-with-popup-crop.png new file mode 100644 index 000000000..f359ca55f Binary files /dev/null and b/client/src/img/whatsnew/2023-10-31-new-label-interface-with-popup-crop.png differ diff --git a/client/src/js/SM/AssetSelection.js b/client/src/js/SM/AssetSelection.js new file mode 100644 index 000000000..826584b48 --- /dev/null +++ b/client/src/js/SM/AssetSelection.js @@ -0,0 +1,395 @@ +Ext.ns('SM.AssetSelection') + +SM.AssetSelection.GridPanel = Ext.extend(Ext.grid.GridPanel, { + initComponent: function () { + const _this = this + const fields = Ext.data.Record.create([ + { name: 'assetId', type: 'string' }, + { name: 'name', type: 'string' }, + { name: 'ip', type: 'string' }, + { name: 'fqdn', type: 'string' }, + { name: 'mac', type: 'string' }, + 'labelIds', + { name: 'benchmarkIds', convert: (v, r) => r.stigs.map(stig => stig.benchmarkId) }, + { name: 'collection' } + ]) + const sm = new Ext.grid.CheckboxSelectionModel({ + singleSelect: false, + checkOnly: false, + listeners: { + selectionchange: function (sm) { + SM.SetCheckboxSelModelHeaderState(sm) + } + } + }) + const columns = [ + sm, + { + header: "Asset", + width: 150, + dataIndex: 'name', + sortable: true, + filter: { type: 'string' } + }, + { + header: "Labels", + width: 120, + dataIndex: 'labelIds', + sortable: false, + filter: { + type: 'values', + collectionId: this.collectionId, + renderer: SM.ColumnFilters.Renderers.labels + }, + renderer: function (value) { + const labels = [] + for (const labelId of value) { + const label = SM.Cache.CollectionMap.get(_this.collectionId).labelMap.get(labelId) + if (label) labels.push(label) + } + labels.sort((a, b) => a.name.localeCompare(b.name)) + return SM.Collection.LabelArrayTpl.apply(labels) + } + }, + { + header: "STIGs", + width: 50, + align: 'center', + dataIndex: 'benchmarkIds', + sortable: true, + hidden: false, + filter: { type: 'values' }, + renderer: function (value, metadata, record) { + let qtipWidth = 230 + if (value.length > 0) { + let longest = value?.reduce( + function (a, b) { + return a.length > b.length ? a : b; + } + ) + qtipWidth = longest.length * 8 + } + metadata.attr = ` ext:qwidth=${qtipWidth} ext:qtip="${record.data.name} STIGs
${value.join('
')}"` + return `${value.length}` + } + }, + { + header: "FQDN", + width: 100, + dataIndex: 'fqdn', + sortable: true, + hidden: true, + renderer: SM.styledEmptyRenderer, + filter: { type: 'string' } + }, + { + header: "IP", + width: 100, + dataIndex: 'ip', + hidden: true, + sortable: true, + renderer: SM.styledEmptyRenderer + }, + { + header: "MAC", + hidden: true, + width: 110, + dataIndex: 'mac', + sortable: true, + renderer: SM.styledEmptyRenderer, + filter: { type: 'string' } + }, + + ] + const store = new Ext.data.JsonStore({ + fields, + idProperty: 'assetId', + sortInfo: { + field: 'name', + direction: 'ASC' + }, + }) + const totalTextCmp = new SM.RowCountTextItem({ + store, + noun: 'asset', + iconCls: 'sm-asset-icon' + }) + const config = { + store, + columns, + sm, + enableDragDrop: true, + ddText : '{0} selected Asset{1}', + bodyCssClass: 'sm-grid3-draggable', + ddGroup: `SM.AssetSelection.GridPanel-${this.role}`, + border: true, + loadMask: false, + stripeRows: true, + view: new SM.ColumnFilters.GridViewBuffered({ + forceFit: true, + emptyText: 'No Assets to display', + listeners: { + filterschanged: function (view, item, value) { + store.filter(view.getFilterFns()) + } + } + }), + bbar: new Ext.Toolbar({ + items: [ + { + xtype: 'exportbutton', + grid: this, + hasMenu: false, + gridBasename: 'Assets (grid)', + storeBasename: 'Assets (store)', + iconCls: 'sm-export-icon', + text: 'CSV' + }, + { + xtype: 'tbfill' + }, + { + xtype: 'tbseparator' + }, + totalTextCmp + ] + }) + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this); + } +}) + +SM.AssetSelection.SelectingPanel = Ext.extend(Ext.Panel, { + initComponent: function () { + const _this = this + function setupDragZone (grid) { + const gridDragZone = grid.getView().dragZone + const originalGetDragData = gridDragZone.getDragData + gridDragZone.getDragData = function (e) { + const t = Ext.lib.Event.getTarget(e) + if (t.className === 'x-grid3-row-checker') { + return false + } + return originalGetDragData.call(gridDragZone, e) + } + + const originalStartDrag = gridDragZone.startDrag + gridDragZone.startDrag = function (x, y) { + Ext.getBody().addClass('sm-grabbing') + return originalStartDrag.call(gridDragZone, x, y) + } + + const originalOnDragDrop = gridDragZone.onDragDrop + gridDragZone.onDragDrop = function (e, id) { + Ext.getBody().removeClass('sm-grabbing') + return originalOnDragDrop.call(gridDragZone, e, id) + } + + const originalOnInvalidDrop = gridDragZone.onInvalidDrop + gridDragZone.onInvalidDrop = function (e, id) { + Ext.getBody().removeClass('sm-grabbing') + return originalOnInvalidDrop.call(gridDragZone, e) + } + + } + const availableGrid = new SM.AssetSelection.GridPanel({ + title: 'Available', + headerCssClass: 'sm-available-panel-header', + role: 'available', + collectionId: this.collectionId, + flex: 1, + listeners: { + render: function (grid) { + setupDragZone(grid) + const gridDropTargetEl = grid.getView().scroller.dom; + const gridDropTarget = new Ext.dd.DropTarget(gridDropTargetEl, { + ddGroup: selectionsGrid.ddGroup, + notifyDrop: function (ddSource, e, data) { + const selectedRecords = ddSource.dragData.selections; + changeSelectedAssets(selectionsGrid, selectedRecords, availableGrid) + return true + } + }) + }, + + } + }) + const selectionsGrid = new SM.AssetSelection.GridPanel({ + title: this.selectionsGridTitle || 'Assigned', + headerCssClass: 'sm-selections-panel-header', + role: 'selections', + collectionId: this.collectionId, + flex: 1, + listeners: { + render: function (grid) { + setupDragZone(grid) + const gridDropTargetEl = grid.getView().scroller.dom; + const gridDropTarget = new Ext.dd.DropTarget(gridDropTargetEl, { + ddGroup: availableGrid.ddGroup, + notifyDrop: function (ddSource, e, data) { + const selectedRecords = ddSource.dragData.selections; + changeSelectedAssets(availableGrid, selectedRecords, selectionsGrid) + return true + } + }) + } + } + }) + availableGrid.getSelectionModel().on('selectionchange', handleSelections, selectionsGrid) + selectionsGrid.getSelectionModel().on('selectionchange', handleSelections, availableGrid) + + const addBtn = new Ext.Button({ + iconCls: 'sm-add-assignment-icon', + margins: "0 10 10 10", + disabled: true, + handler: function (btn) { + const selectedRecords = availableGrid.getSelectionModel().getSelections() + changeSelectedAssets(availableGrid, selectedRecords, selectionsGrid) + btn.disable() + } + }) + const removeBtn = new Ext.Button({ + iconCls: 'sm-remove-assignment-icon', + margins: "0 10 10 10", + disabled: true, + handler: function (btn) { + const selectedRecords = selectionsGrid.getSelectionModel().getSelections() + changeSelectedAssets(selectionsGrid, selectedRecords, availableGrid) + btn.disable() + } + }) + const buttonPanel = new Ext.Panel({ + bodyStyle: 'background-color:transparent;border:none', + width: 60, + layout: { + type: 'vbox', + pack: 'center', + align: 'center', + padding: "10 10 10 10" + }, + items: [ + addBtn, + removeBtn, + { xtype: 'panel', border: false, html: 'or drag' } + ] + }) + + function handleSelections() { + const sm = this.getSelectionModel() + if (sm.getSelected()) { + sm.suspendEvents() + sm.clearSelections() + sm.resumeEvents() + SM.SetCheckboxSelModelHeaderState(sm) + } + addBtn.setDisabled(this.role === 'available') + removeBtn.setDisabled(this.role === 'selections') + } + + async function initPanel({ benchmarkId, labelId }) { + const promises = [ + Ext.Ajax.requestPromise({ + responseType: 'json', + url: `${STIGMAN.Env.apiBase}/assets`, + params: { + collectionId: _this.collectionId, + projection: ['stigs'] + }, + method: 'GET' + }) + ] + if (benchmarkId) { + promises.push(Ext.Ajax.requestPromise({ + responseType: 'json', + url: `${STIGMAN.Env.apiBase}/collections/${_this.collectionId}/stigs/${benchmarkId}/assets`, + method: 'GET' + })) + _this.trackedProperty = { dataProperty: 'benchmarkIds', value: benchmarkId } + } + else if (labelId) { + promises.push(Ext.Ajax.requestPromise({ + responseType: 'json', + url: `${STIGMAN.Env.apiBase}/collections/${_this.collectionId}/labels/${labelId}/assets`, + method: 'GET' + })) + _this.trackedProperty = { dataProperty: 'labelIds', value: labelId } + } + const [apiAvailableAssets, apiAssignedAssets = []] = await Promise.all(promises) + const assignedAssetIds = apiAssignedAssets.map(apiAsset => apiAsset.assetId) + _this.originalAssetIds = assignedAssetIds + const availableAssets = [] + const assignedAssets = [] + apiAvailableAssets.reduce((accumulator, asset) => { + const property = assignedAssetIds.includes(asset.assetId) ? 'assignedAssets' : 'availableAssets' + accumulator[property].push(asset) + return accumulator + }, { availableAssets, assignedAssets }) + + availableGrid.store.loadData(availableAssets) + selectionsGrid.store.loadData(assignedAssets) + } + + function changeSelectedAssets(srcGrid, records, dstGrid) { + srcGrid.store.suspendEvents() + dstGrid.store.suspendEvents() + srcGrid.store.remove(records) + dstGrid.store.add(records) + for (const record of records) { + if (srcGrid.role === 'available') { + record.data[_this.trackedProperty.dataProperty].push(_this.trackedProperty.value) + record.commit() + } + else { + record.data[_this.trackedProperty.dataProperty] = record.data[_this.trackedProperty.dataProperty].filter(i => i !== _this.trackedProperty.value) + record.commit() + } + } + const { field, direction } = dstGrid.store.getSortState() + dstGrid.store.sort(field, direction) + srcGrid.store.resumeEvents() + dstGrid.store.resumeEvents() + srcGrid.store.fireEvent('datachanged', srcGrid.store) + dstGrid.store.fireEvent('datachanged', dstGrid.store) + srcGrid.store.fireEvent('update', srcGrid.store) + dstGrid.store.fireEvent('update', dstGrid.store) + dstGrid.store.filter(dstGrid.getView().getFilterFns()) + + dstGrid.getSelectionModel().selectRecords(records) + dstGrid.getView().focusRow(dstGrid.store.indexOfId(records[0].data.assetId)) + _this.fireEvent('assetselectionschanged') + } + + function getValue() { + const records = selectionsGrid.store.snapshot?.items ?? selectionsGrid.store.getRange() + return records.map(record => record.data.assetId) + } + + const config = { + layout: 'hbox', + layoutConfig: { + align: 'stretch' + }, + name: 'assets', + border: false, + items: [ + availableGrid, + buttonPanel, + selectionsGrid + ], + availableGrid, + selectionsGrid, + initPanel, + getValue, + // need fns below so Ext handles us like a form field + setValue: () => { }, + markInvalid: function () { }, + clearInvalid: function () { }, + isValid: () => true, + getName: () => this.name, + validate: () => true + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this); + } +}) \ No newline at end of file diff --git a/client/src/js/SM/CollectionForm.js b/client/src/js/SM/CollectionForm.js index 57cc6f3b7..e81ac23dc 100644 --- a/client/src/js/SM/CollectionForm.js +++ b/client/src/js/SM/CollectionForm.js @@ -1769,7 +1769,7 @@ SM.getContrastYIQ = function (hexcolor){ return (yiq >= 128) ? '#080808' : '#f7f7f7'; } -SM.Collection.LabelSpriteHtml = ` @@ -2341,29 +2341,19 @@ SM.Collection.LabelsMenu = Ext.extend(Ext.menu.Menu, { SM.Collection.LabelAssetsForm = Ext.extend(Ext.form.FormPanel, { initComponent: function () { - let me = this - this.assetsGrid = new SM.StigAssetsGrid({ - name: 'assets', - collectionId: this.collectionId, - isValid: () => { - // override of SM.StigAssetsGrid - return true - }, - }) - this.assetsGrid.getSelectionModel().addListener('rowselect', function (sm, rowIndex, record) { - if (!record.data.labelIds.includes(me.labelId)) { - record.data.labelIds.push(me.labelId) - record.commit() - } - }) - this.assetsGrid.getSelectionModel().addListener('rowdeselect', function (sm, rowIndex, record) { - record.data.labelIds = record.data.labelIds.filter( i => i !== me.labelId) - record.commit() - }) + let _this = this if (! this.collectionId) { throw ('missing property collectionId') } - const labelSpan = SM.Collection.LabelTpl.apply(SM.Cache.CollectionMap.get(this.collectionId).labelMap.get(this.labelId)) + const assetSelectionPanel = new SM.AssetSelection.SelectingPanel({ + name: 'assets', + collectionId: this.collectionId, + isFormField: true, + selectionsGridTitle: 'Tagged' + }) + const labelData = {...SM.Cache.CollectionMap.get(this.collectionId).labelMap.get(this.labelId)} + labelData.extraCls = 'sm-jumbo-sprite' + const labelSpan = SM.Collection.LabelTpl.apply(labelData) const labelField = new Ext.form.DisplayField({ fieldLabel: 'Label', hideLabel: true, @@ -2380,28 +2370,29 @@ SM.Collection.LabelAssetsForm = Ext.extend(Ext.form.FormPanel, { items: [ { xtype: 'fieldset', - title: 'Label', + title: 'Label', items: [ labelField ] }, { xtype: 'fieldset', - title: 'Tagged Assets', + title: 'Tagged Assets', anchor: "100% -70", layout: 'fit', items: [ - this.assetsGrid + assetSelectionPanel ] } ], buttons: [{ text: this.btnText || 'Save', - collectionId: me.collectionId, + collectionId: _this.collectionId, formBind: true, handler: this.btnHandler || function () {} - }] + }], + assetSelectionPanel } Ext.apply(this, Ext.apply(this.initialConfig, config)) @@ -2410,11 +2401,15 @@ SM.Collection.LabelAssetsForm = Ext.extend(Ext.form.FormPanel, { }, initPanel: async function () { try { - await this.assetsGrid.store.loadPromise() + this.el.mask('') + await this.assetSelectionPanel.initPanel({labelId: this.labelId}) } catch (e) { SM.Error.handleError(e) } + finally { + this.el.unmask() + } } }) @@ -2422,21 +2417,19 @@ SM.Collection.showLabelAssetsWindow = async function ( collectionId, labelId ) { try { let labelAssetsFormPanel = new SM.Collection.LabelAssetsForm({ collectionId, - labelId: labelId, + labelId, btnHandler: async function( btn ){ try { - if (labelAssetsFormPanel.getForm().isValid()) { - let values = labelAssetsFormPanel.getForm().getFieldValues(false, true) // dirtyOnly=false, getDisabled=true - let result = await Ext.Ajax.requestPromise({ - url: `${STIGMAN.Env.apiBase}/collections/${collectionId}/labels/${labelId}/assets`, - method: 'PUT', - headers: { 'Content-Type': 'application/json;charset=utf-8' }, - jsonData: values.assets - }) - const apiLabelAssets = JSON.parse(result.response.responseText) - SM.Dispatcher.fireEvent('labelassetschanged', collectionId, labelId, apiLabelAssets) - appwindow.close() - } + let values = labelAssetsFormPanel.getForm().getFieldValues(false, true) // dirtyOnly=false, getDisabled=true + let result = await Ext.Ajax.requestPromise({ + url: `${STIGMAN.Env.apiBase}/collections/${collectionId}/labels/${labelId}/assets`, + method: 'PUT', + headers: { 'Content-Type': 'application/json;charset=utf-8' }, + jsonData: values.assets + }) + const apiLabelAssets = JSON.parse(result.response.responseText) + SM.Dispatcher.fireEvent('labelassetschanged', collectionId, labelId, apiLabelAssets) + appwindow.close() } catch (e) { SM.Error.handleError(e) @@ -2447,13 +2440,19 @@ SM.Collection.showLabelAssetsWindow = async function ( collectionId, labelId ) { /******************************************************/ // Form window /******************************************************/ + const height = Ext.getBody().getHeight() - 80 + const width = Math.min(Math.floor(Ext.getBody().getWidth() * 0.75), 1280) var appwindow = new Ext.Window({ - title: 'Tagged Assets, Label ID ' + labelId, + title: 'Tagged Assets', + resizable: true, cls: 'sm-dialog-window sm-round-panel', modal: true, hidden: true, - width: 510, - height:660, + width, + height, + minWidth: 810, + minHeight: 460, + maximizable: true, layout: 'fit', plain:true, bodyStyle:'padding:10px;', @@ -2461,23 +2460,10 @@ SM.Collection.showLabelAssetsWindow = async function ( collectionId, labelId ) { items: labelAssetsFormPanel }); - appwindow.render(Ext.getBody()) + appwindow.show(Ext.getBody()) await labelAssetsFormPanel.initPanel() // Load asset grid store - let result = await Ext.Ajax.requestPromise({ - url: `${STIGMAN.Env.apiBase}/assets`, - method: 'GET', - params: { - collectionId, - labelId - } - }) - const apiLabelAssets = JSON.parse(result.response.responseText) - labelAssetsFormPanel.getForm().setValues({ - labelId, - assets: apiLabelAssets - }) - + appwindow.show(document.body); } catch (e) { diff --git a/client/src/js/SM/CollectionStig.js b/client/src/js/SM/CollectionStig.js index 046eceddc..85d61a634 100644 --- a/client/src/js/SM/CollectionStig.js +++ b/client/src/js/SM/CollectionStig.js @@ -290,226 +290,6 @@ SM.CollectionStigsGrid = Ext.extend(Ext.grid.GridPanel, { }) Ext.reg('sm-collection-stigs-grid', SM.CollectionStigsGrid) -/** - * @class SM.StigAssetsGrid - * @extends Ext.grid.GridPanel - * GridPanel class that displays STIG -> Assets data - * @constructor - * Create a GridPanel with associated components (store, column model, view, selection model) - * @param {Object} config The config object - * @xtype sm-asset-stig-grid - */ -SM.StigAssetsGrid = Ext.extend(Ext.grid.GridPanel, { - filterStore: function () { - let selectionsOnly = ! this.getTopToolbar().button.pressed - let value = this.getTopToolbar().filterField.getValue() - let sm = this.getSelectionModel() - //var selectionsOnly = ! btnPressed; - if (! value || value === '') { - if (selectionsOnly) { - this.store.filterBy(sm.isSelected, sm); - } else { - this.store.clearFilter() - } - } else { - if (selectionsOnly) { - this.store.filter([ - { - property:'name', - value:value, - anyMatch:true, - caseSensitive:false - },{ - fn: sm.isSelected, - scope: sm - } - ]); - } else { - this.store.filter({property:'name',value:value,anyMatch:true,caseSensitive:false}); - } - } - }, - initComponent: function() { - const _this = this - let fields = Ext.data.Record.create([ - {name:'assetId',type:'string'}, - {name:'name',type:'string'}, - {name:'labelIds'}, - {name:'collection'} - ]) - let store = new Ext.data.JsonStore({ - url: `${STIGMAN.Env.apiBase}/assets`, - baseParams: { - collectionId: this.collectionId - }, - grid: this, - // reader: reader, - autoLoad: false, - restful: true, - encode: false, - idProperty: 'assetId', - sortInfo: { - field: 'name', - direction: 'ASC' - }, - fields: fields, - listeners: { - load: function (store,records) { - store.grid.filterStore.call(store.grid) - } - } - }) - const totalTextCmp = new SM.RowCountTextItem ({ - store: store, - noun: 'asset', - iconCls: 'sm-asset-icon' - }) - let sm = new Ext.grid.CheckboxSelectionModel({ - checkOnly: false, - onRefresh: function() { - // override to render selections properly after a grid refresh - var ds = this.grid.store, index; - var s = this.getSelections(); - for(var i = 0, len = s.length; i < len; i++){ - var r = s[i]; - if((index = ds.indexOfId(r.id)) != -1){ - this.grid.view.addRowClass(index, this.grid.view.selectedRowClass); - } - } - } - }) - let config = { - isFormField: true, - editingCls: 'red-panel', - editing: false, - layout: 'fit', - store: store, - listeners: { - viewready: function(grid) { - // One final refresh to style filtered rows that are also selected - grid.view.refresh() - }, - }, - columns: [ - sm, - { - header: "Asset" - ,width: 150 - ,dataIndex:'name' - ,sortable: true - }, - { - header: "Labels", - width: 120, - dataIndex: 'labelIds', - sortable: false, - filter: { - type: 'values', - collectionId: _this.collectionId, - renderer: SM.ColumnFilters.Renderers.labels - }, - renderer: function (value, metadata, record) { - const labels = [] - for (const labelId of value) { - const label = SM.Cache.CollectionMap.get(_this.collectionId).labelMap.get(labelId) - if (label) labels.push(label) - } - labels.sort((a,b) => a.name.localeCompare(b.name)) - metadata.attr = 'style="white-space:normal;"' - return SM.Collection.LabelArrayTpl.apply(labels) - } - } - ], - border: true, - loadMask: false, - stripeRows: true, - sm: sm, - view: new SM.ColumnFilters.GridView({ - forceFit: true, - emptyText: 'No Assets to display', - selectedRowClass: 'x-grid3-row-selected-checkonly', - listeners: { - // beforerefresh: function (view) { - // view.grid.getEl().mask('Refreshing...') - // }, - refresh: function (view) { - view.grid.getEl().unmask() - } - } - }), - onEditChange: function (editing) { - this.view.selectedRowClass = editing ? 'x-grid3-row-selected' : 'x-grid3-row-selected-checkonly' - this.view.refresh() - this.editing = editing - this.fireEvent('mouseover') - }, - tbar: new SM.SelectingGridToolbar({ - filterFn: this.filterStore, - triggerEmptyText: 'Name filter...', - btnText: 'Assign Assets', - gridId: this.id - }), - bbar: new Ext.Toolbar({ - items: [ - { - xtype: 'tbbutton', - grid: this, - iconCls: 'icon-refresh', - tooltip: 'Reload this grid', - width: 20, - handler: function(btn){ - btn.grid.store.reload(); - } - },{ - xtype: 'tbseparator' - },{ - xtype: 'exportbutton', - hasMenu: false, - gridBasename: 'Assets (grid)', - storeBasename: 'Assets (store)', - iconCls: 'sm-export-icon', - text: 'CSV' - },{ - xtype: 'tbfill' - },{ - xtype: 'tbseparator' - }, - totalTextCmp - ] - }), - getValue: function() { - return JSON.parse(encodeSm(sm,'assetId')) - }, - setValue: function (apiAssets) { - const sm = this.getSelectionModel() - const assetIds = apiAssets.map(o => o.assetId) - const selectedRecords = [] - for( let i=0; i < assetIds.length; i++ ) { - let record = store.getById( assetIds[i] ) - selectedRecords.push(record) - } - this.store.clearFilter(true) - const origSilent = sm.silent - sm.silent = true - sm.selectRecords(selectedRecords) - sm.silent = origSilent - this.filterStore.call(this) - this.originalAssetIds = assetIds - }, - markInvalid: function() {}, - clearInvalid: function() {}, - getName: () => this.name, - validate: () => true - } - Ext.apply(this, Ext.apply(this.initialConfig, config)) - SM.StigAssetsGrid.superclass.initComponent.call(this); - }, - isValid: function () { - return this.getSelectionModel().getCount() > 0 - } -}) -Ext.reg('sm-stig-assets-grid', SM.StigAssetsGrid) - SM.StigRevisionComboBox = Ext.extend(SM.Global.HelperComboBox, { initComponent: function () { const _this = this @@ -542,30 +322,49 @@ SM.CollectionStigProperties = Ext.extend(Ext.form.FormPanel, { if (! this.collectionId) { throw ('missing property collectionId') } - const stigAssetsGrid = new SM.StigAssetsGrid({ + const assetSelectionPanel = new SM.AssetSelection.SelectingPanel({ name: 'assets', - benchmarkId: this.benchmarkId, - collectionId: this.collectionId + collectionId: this.collectionId, + isFormField: true, + listeners: { + assetselectionschanged: setButtonState + } }) - stigAssetsGrid.getSelectionModel().addListener('selectionchange', setButtonState) const stigField = new SM.StigSelectionField({ name: 'benchmarkId', submitValue: false, fieldLabel: 'BenchmarkId', hideTrigger: false, - anchor: '100%', - // width: 350, + width: 350, autoLoad: false, allowBlank: false, filteringStore: this.stigFilteringStore, initialBenchmarkId: this.benchmarkId, fireSelectOnSetValue: true, + enableKeyEvents: true, + valid: false, listeners: { select: function (combo, record, index) { const revisions = [['latest', 'Most recent revision'], ...record.data.revisions.map( rev => [rev.revisionStr, `${rev.revisionStr} (${rev.benchmarkDate})`])] revisionComboBox.store.loadData(revisions) revisionComboBox.setValue(record.data.benchmarkId === _this.benchmarkId ? _this.defaultRevisionStr : 'latest') + assetSelectionPanel.trackedProperty = { dataProperty: 'benchmarkIds', value: record.data.benchmarkId } + stigField.valid = true + setButtonState() + }, + invalid: function (field) { + field.valid = false setButtonState() + }, + valid: function (field) { + field.valid = true + setButtonState() + }, + blur: function (field) { + this.setValue(this.getRawValue()) + }, + render: function (field) { + field.el.dom.addEventListener('blur', () => field.fireEvent('blur')) } } }) @@ -586,12 +385,18 @@ SM.CollectionStigProperties = Ext.extend(Ext.form.FormPanel, { }) function setButtonState () { - const currentAssetIds = stigAssetsGrid.getValue() - const currentBenchmarkId = stigField.getValue() + if (!stigField.valid) { + assetFieldSet.disable() + saveBtn.disable() + return + } + assetFieldSet.enable() + const currentBenchmarkId = stigField.getRawValue() const currentRevisionStr = revisionComboBox.getValue() - const originalAssetIds = stigAssetsGrid.originalAssetIds + const currentAssetIds = assetSelectionPanel.getValue() + const originalAssetIds = assetSelectionPanel.originalAssetIds - if (!currentAssetIds.length || currentBenchmarkId === '' || currentRevisionStr === '') { + if (!currentAssetIds.length) { saveBtn.disable() return } @@ -602,6 +407,12 @@ SM.CollectionStigProperties = Ext.extend(Ext.form.FormPanel, { saveBtn.setDisabled(revisionUnchanged && assetsUnchanged) } + const assetFieldSet = new Ext.form.FieldSet({ + title: 'Asset assignments', + anchor: "100% -95", + layout: 'fit', + items: [assetSelectionPanel] + }) let config = { baseCls: 'x-plain', // height: 400, @@ -611,25 +422,18 @@ SM.CollectionStigProperties = Ext.extend(Ext.form.FormPanel, { items: [ { xtype: 'fieldset', - title: 'STIG information', + title: 'STIG information', items: [ stigField, revisionComboBox ] }, - { - xtype: 'fieldset', - title: 'Asset Assignments', - anchor: "100% -95", - layout: 'fit', - items: [stigAssetsGrid] - } - + assetFieldSet ], buttons: [saveBtn], stigField, revisionComboBox, - stigAssetsGrid + assetSelectionPanel } Ext.apply(this, Ext.apply(this.initialConfig, config)) @@ -641,21 +445,10 @@ SM.CollectionStigProperties = Ext.extend(Ext.form.FormPanel, { this.el.mask('') const promises = [ this.stigField.store.loadPromise(), - this.stigAssetsGrid.store.loadPromise() + this.assetSelectionPanel.initPanel({benchmarkId}) ] - if (benchmarkId) { - promises.push(Ext.Ajax.requestPromise({ - responseType: 'json', - url: `${STIGMAN.Env.apiBase}/collections/${collectionId}/stigs/${benchmarkId}/assets`, - method: 'GET' - })) - } - const results = await Promise.all(promises) - - this.getForm().setValues({ - benchmarkId, - assets: results[2] || [] - }) + await Promise.all(promises) + this.getForm().setValues({benchmarkId}) } finally { this.el.unmask() @@ -674,26 +467,24 @@ async function showCollectionStigProps( benchmarkId, defaultRevisionStr, parentG stigFilteringStore: parentGrid.store, btnHandler: async function( btn ){ try { - if (stigPropsFormPanel.getForm().isValid()) { - stigPropsFormPanel.el.mask('Updating') - const values = stigPropsFormPanel.getForm().getFieldValues(false, true) // dirtyOnly=false, getDisabled=true - const jsonData = {} - if (values.defaultRevisionStr) { - jsonData.defaultRevisionStr = values.defaultRevisionStr - } - if (values.assets) { - jsonData.assetIds = values.assets - } - let result = await Ext.Ajax.requestPromise({ - url: `${STIGMAN.Env.apiBase}/collections/${btn.collectionId}/stigs/${values.benchmarkId}`, - method: 'POST', - headers: { 'Content-Type': 'application/json;charset=utf-8' }, - jsonData - }) - const apiStigAssets = JSON.parse(result.response.responseText) - SM.Dispatcher.fireEvent('stigassetschanged', btn.collectionId, values.benchmarkId, apiStigAssets) - appwindow.close() + stigPropsFormPanel.el.mask('Updating') + const values = stigPropsFormPanel.getForm().getFieldValues(false, true) // dirtyOnly=false, getDisabled=true + const jsonData = {} + if (values.defaultRevisionStr) { + jsonData.defaultRevisionStr = values.defaultRevisionStr + } + if (values.assets) { + jsonData.assetIds = values.assets } + let result = await Ext.Ajax.requestPromise({ + url: `${STIGMAN.Env.apiBase}/collections/${btn.collectionId}/stigs/${values.benchmarkId}`, + method: 'POST', + headers: { 'Content-Type': 'application/json;charset=utf-8' }, + jsonData + }) + const apiStigAssets = JSON.parse(result.response.responseText) + SM.Dispatcher.fireEvent('stigassetschanged', btn.collectionId, values.benchmarkId, apiStigAssets) + appwindow.close() } catch (e) { SM.Error.handleError(e) @@ -707,13 +498,19 @@ async function showCollectionStigProps( benchmarkId, defaultRevisionStr, parentG /******************************************************/ // Form window /******************************************************/ + const height = Ext.getBody().getHeight() - 80 + const width = Math.min(Math.floor(Ext.getBody().getWidth() * 0.75), 1280) appwindow = new Ext.Window({ title: 'STIG Assignments', + resizable: true, cls: 'sm-dialog-window sm-round-panel', modal: true, hidden: true, - width: 510, - height:660, + width, + height, + minWidth: 810, + minHeight: 460, + maximizable: true, layout: 'fit', plain:true, bodyStyle:'padding:10px;', diff --git a/client/src/js/SM/WhatsNew.js b/client/src/js/SM/WhatsNew.js index 81d210fde..f0316fe8a 100644 --- a/client/src/js/SM/WhatsNew.js +++ b/client/src/js/SM/WhatsNew.js @@ -1,6 +1,25 @@ Ext.ns('SM.WhatsNew') SM.WhatsNew.Sources = [ + { + date: '2023-10-31', + header: `New Interfaces for Managing Asset Labels and STIG Assignments`, + body: ` +

+ Managing a Collection's Asset Labels and STIG Assignments should now be a more streamlined and informative experience. Just drag and drop Assets between the two panels to add or remove the selected Label or STIG: + +

+ +

The new interface also provides additional information about your Assets to help find what you're looking for. Hover over the Asset's name to see its currently assigned STIGs:

+ +

+ +

Click on a column header to filter on that column's data, or to add or remove columns of Asset information:

+ +

+ + ` + }, { date: '2023-09-26', header: `Export Results to Another Collection`, diff --git a/client/src/js/overrides.js b/client/src/js/overrides.js index 0b150207a..39c458bd8 100644 --- a/client/src/js/overrides.js +++ b/client/src/js/overrides.js @@ -644,7 +644,17 @@ Ext.override(Ext.form.ComboBox, { Ext.form.ComboBox.superclass.setValue.call(this, text); this.value = v; return this; - } + }, + onSelect : function(record, index){ + if(this.fireEvent('beforeselect', this, record, index) !== false){ + this.setValue(record.data[this.valueField || this.displayField]); + this.collapse(); + if (!this.fireSelectOnSetValue) { + this.fireEvent('select', this, record, index); + } + } + }, + }); // END enable comboBox setValue() to fire the select event diff --git a/client/src/js/resources.js b/client/src/js/resources.js index 6a3e9ba7e..a6418344e 100644 --- a/client/src/js/resources.js +++ b/client/src/js/resources.js @@ -59,6 +59,7 @@ const scripts = [ 'js/SM/Library.js', 'js/SM/StigRevision.js', 'js/SM/Inventory.js', + 'js/SM/AssetSelection.js', 'js/library.js', 'js/userAdmin.js', 'js/collectionAdmin.js', diff --git a/docs/assets/images/collection-manage-revision-pinning.png b/docs/assets/images/collection-manage-revision-pinning.png index 224c20604..709b16e6d 100644 Binary files a/docs/assets/images/collection-manage-revision-pinning.png and b/docs/assets/images/collection-manage-revision-pinning.png differ diff --git a/docs/assets/images/collection-manage-tag-assets-assign-label-modal.png b/docs/assets/images/collection-manage-tag-assets-assign-label-modal.png deleted file mode 100644 index 08eb898e9..000000000 Binary files a/docs/assets/images/collection-manage-tag-assets-assign-label-modal.png and /dev/null differ diff --git a/docs/assets/images/collection-manage-tag-assets-modal.png b/docs/assets/images/collection-manage-tag-assets-modal.png index 586a1af1c..008557a9b 100644 Binary files a/docs/assets/images/collection-manage-tag-assets-modal.png and b/docs/assets/images/collection-manage-tag-assets-modal.png differ diff --git a/docs/assets/images/stig-assignments.png b/docs/assets/images/stig-assignments.png index c5c17b517..9b2fd8c97 100644 Binary files a/docs/assets/images/stig-assignments.png and b/docs/assets/images/stig-assignments.png differ diff --git a/docs/assets/images/stigs-panel.png b/docs/assets/images/stigs-panel.png index fb92249c6..d34578465 100644 Binary files a/docs/assets/images/stigs-panel.png and b/docs/assets/images/stigs-panel.png differ diff --git a/docs/user-guide/user-guide.rst b/docs/user-guide/user-guide.rst index 6a28550dd..c92e6a37e 100644 --- a/docs/user-guide/user-guide.rst +++ b/docs/user-guide/user-guide.rst @@ -1058,19 +1058,12 @@ Double-click an existing label to edit it. ------------------------------- -When a Label is selected in Label tab of the Collection Properties Panel, the "Tag Assets..." button is enabled. Click the "Tag Assets..." button to view the Assets that are tagged with that label. Click the "Assign Assets" button on this screen to tag new Assets with that label. +When a Label is selected in Label tab of the Collection Properties Panel, the "Tag Assets..." button is enabled. Click the "Tag Assets..." button to view and tag Assets with the selected Label. Hover over the Asset's name to see its currently assigned STIGs. Click on a column header to filter on that column's data, or to add or remove columns of Asset information. .. thumbnail:: /assets/images/collection-manage-tag-assets-modal.png :width: 50% :show_caption: True - :title: View the Assets tagged with a Particular Label - -| - -.. thumbnail:: /assets/images/collection-manage-tag-assets-assign-label-modal.png - :width: 50% - :show_caption: True - :title: Tag new Assets with a Label + :title: View and tag Assets with the selected Label | @@ -1307,7 +1300,7 @@ This panel lists all the STIGs that have been assigned to at least one Asset in Assign STIG ~~~~~~~~~~~~~~~~~~~~~~ -Select Assign STIG to add a new STIG to the Collection. A popup will allow you to select a STIG that is not yet assigned to an Asset. Click the Assign STIG button on this popup to select Assets that should have this STIG assigned to them. +Select Assign STIG to add a new STIG to the Collection. A popup will allow you to view any Assets that are assigned the selected STIG, and to assign that STIG to new Assets. Hover over the Asset's name to see its currently assigned STIGs. Click on a column header to filter on that column's data, or to add or remove columns of Asset information. .. thumbnail:: /assets/images/stig-assignments.png :width: 50%