diff --git a/src/assets/scss/app.scss b/src/assets/scss/app.scss index 7ddafe256..0f5260398 100644 --- a/src/assets/scss/app.scss +++ b/src/assets/scss/app.scss @@ -574,26 +574,6 @@ select { } } -.table-frozen { - float: left; - width: auto; -} - -.table-container { - // Placing the margin here rather than on the table so that the horizontal - // scrollbar appears immediately below the table, above the margin. - margin-bottom: $margin-bottom-table; - overflow-x: auto; - - .table { - margin-bottom: 0; - } -} - -.table-actions { - margin-bottom: 20px; -} - .empty-table-message { color: #555; font-size: 15px; @@ -825,3 +805,11 @@ becomes more complicated. } } } + + + +//////////////////////////////////////////////////////////////////////////////// +// markRowsChanged() + +[data-mark-rows-changed="false"] { transition: background-color 0.6s 6s; } +[data-mark-rows-changed="true"] { background-color: #faf1cd; } diff --git a/src/components/entity/data-row.vue b/src/components/entity/data-row.vue index fa156b6b7..35f5c592f 100644 --- a/src/components/entity/data-row.vue +++ b/src/components/entity/data-row.vue @@ -10,8 +10,8 @@ including this file, may be copied, modified, propagated, or distributed except according to the terms contained in the LICENSE file. --> @@ -69,5 +96,27 @@ const { entityPath } = useRoutes(); @include text-overflow-ellipsis; max-width: 250px; } + + .col-content { + align-items: flex-start; + display: flex; + } + .updated-at { + margin-right: 21px; + + &:empty { width: 75px; } + } + .updates { + color: #777; + margin-left: auto; + width: 41px; + + .icon-pencil { margin-right: 5px; } + } + .col-content .icon-angle-right { + color: $color-accent-primary; + font-size: 20px; + margin-top: -1px; + } } diff --git a/src/components/entity/table.vue b/src/components/entity/table.vue index 4e3623193..8055d438c 100644 --- a/src/components/entity/table.vue +++ b/src/components/entity/table.vue @@ -10,85 +10,72 @@ including this file, may be copied, modified, propagated, or distributed except according to the terms contained in the LICENSE file. --> + + + +{ + "en": { + "header": { + // This is the text of a column header of a table of Entities. The column + // shows when each Entity was last updated, as well as actions that can be + // taken on the Entity. + "updatedAtAndActions": "Last Updated / Actions" + } + } +} + diff --git a/src/components/entity/update.vue b/src/components/entity/update.vue index c50c9fec4..6b4ae4e92 100644 --- a/src/components/entity/update.vue +++ b/src/components/entity/update.vue @@ -59,7 +59,7 @@ export default { }; + diff --git a/src/util/dom.js b/src/util/dom.js index 1930b3730..a6f5b56d3 100644 --- a/src/util/dom.js +++ b/src/util/dom.js @@ -48,3 +48,13 @@ export const requiredLabel = (text, required) => { const star = required ? ' *' : ''; return `${text}${star}`; }; + +export const markRowsChanged = (trs) => { + for (const tr of trs) tr.dataset.markRowsChanged = 'true'; + // Toggling data-mark-rows-changed from 'true' to 'false' will trigger a CSS + // transition: see app.scss. The CSS specifies the duration of the transition. + setTimeout(() => { + for (const tr of trs) tr.dataset.markRowsChanged = 'false'; + }); +}; +export const markRowChanged = (tr) => { markRowsChanged([tr]); }; diff --git a/test/components/entity/list.spec.js b/test/components/entity/list.spec.js index 86d844e0f..bee22359b 100644 --- a/test/components/entity/list.spec.js +++ b/test/components/entity/list.spec.js @@ -1,6 +1,8 @@ +import DateTime from '../../../src/components/date-time.vue'; import EntityDataRow from '../../../src/components/entity/data-row.vue'; import EntityList from '../../../src/components/entity/list.vue'; import EntityMetadataRow from '../../../src/components/entity/metadata-row.vue'; +import EntityUpdate from '../../../src/components/entity/update.vue'; import testData from '../../data'; import { load } from '../../util/http'; @@ -61,4 +63,143 @@ describe('EntityList', () => { .respondWithData(testData.entityOData); }); }); + + describe('update', () => { + it('toggles the Modal', () => { + testData.extendedEntities.createPast(1); + return load('/projects/1/datasets/trees/entities', { root: false }) + .testModalToggles({ + modal: EntityUpdate, + show: '.entity-metadata-row .update-button', + hide: ['.btn-link'] + }); + }); + + it('passes the correct entity to the modal', async () => { + testData.extendedEntities + .createPast(1, { uuid: 'e1' }) + .createPast(1, { uuid: 'e2' }); + const component = await load('/projects/1/datasets/trees/entities', { + root: false + }); + const modal = component.getComponent(EntityUpdate); + should.not.exist(modal.props().entity); + const buttons = component.findAll('.entity-metadata-row .update-button'); + buttons.length.should.equal(2); + await buttons[0].trigger('click'); + modal.props().entity.uuid.should.equal('e2'); + await modal.get('.btn-link').trigger('click'); + await buttons[1].trigger('click'); + modal.props().entity.uuid.should.equal('e1'); + }); + + it('passes a REST-format entity to the modal', async () => { + testData.extendedDatasets.createPast(1, { + properties: [ + { name: 'height' }, + { name: 'circumference.cm', odataName: 'circumference_cm' } + ] + }); + testData.extendedEntities.createPast(1, { + uuid: 'abc', + label: 'My Entity', + data: { height: '1', 'circumference.cm': '2' } + }); + const component = await load('/projects/1/datasets/trees/entities', { + root: false + }); + await component.get('.entity-metadata-row .update-button').trigger('click'); + component.getComponent(EntityUpdate).props().entity.should.eql({ + uuid: 'abc', + currentVersion: { + label: 'My Entity', + data: Object.assign(Object.create(null), { + height: '1', + 'circumference.cm': '2' + }) + } + }); + }); + + it('does not show the modal during a refresh of the table', () => { + testData.extendedEntities.createPast(1); + return load('/projects/1/datasets/trees/entities', { root: false }) + .complete() + .request(component => + component.get('#entity-list-refresh-button').trigger('click')) + .beforeEachResponse(async (component) => { + await component.get('.entity-metadata-row .update-button').trigger('click'); + component.getComponent(EntityUpdate).props().state.should.be.false(); + }) + .respondWithData(testData.entityOData) + .afterResponse(component => { + component.getComponent(EntityUpdate).props().state.should.be.false(); + }); + }); + + describe('after a successful response', () => { + const submit = () => { + testData.extendedDatasets.createPast(1, { + properties: [ + { name: 'height' }, + { name: 'circumference.cm', odataName: 'circumference_cm' } + ] + }); + testData.extendedEntities.createPast(1, { uuid: 'e1' }); + testData.extendedEntities.createPast(1, { + uuid: 'e2', + label: 'My Entity', + data: { height: '1', 'circumference.cm': '2' } + }); + testData.extendedEntities.createPast(1, { uuid: 'e3' }); + return load('/projects/1/datasets/trees/entities', { root: false }) + .complete() + .request(async (component) => { + await component.get('.entity-metadata-row:nth-child(2) .update-button').trigger('click'); + const form = component.get('#entity-update form'); + const textareas = form.findAll('textarea'); + textareas.length.should.equal(3); + await textareas[0].setValue('Updated Entity'); + await textareas[1].setValue('3'); + await textareas[2].setValue('4'); + return form.trigger('submit'); + }) + .respondWithData(() => { + const { currentVersion } = testData.extendedEntities.get(1); + testData.extendedEntities.update(1, { + currentVersion: { + ...currentVersion, + label: 'Updated Entity', + data: { height: '3', 'circumference.cm': '4' } + } + }); + return testData.standardEntities.get(1); + }); + }; + + it('hides the modal', async () => { + const component = await submit(); + component.getComponent(EntityUpdate).props().state.should.be.false(); + }); + + it('shows a success alert', async () => { + const component = await submit(); + component.should.alert('success'); + }); + + it('updates the EntityDataRow', async () => { + const component = await submit(); + const tds = component.findAll('.entity-data-row:nth-child(2) td'); + tds.map(td => td.text()).should.eql(['3', '4', 'Updated Entity', 'e2']); + }); + + it('updates the EntityMetadataRow', async () => { + const component = await submit(); + const td = component.get('.entity-metadata-row:nth-child(2) td:last-child'); + should.exist(td.getComponent(DateTime).props().iso); + td.get('.updates').text().should.equal('1'); + td.get('.update-button').attributes('aria-label').should.equal('Edit (1)'); + }); + }); + }); }); diff --git a/test/components/entity/metadata-row.spec.js b/test/components/entity/metadata-row.spec.js index e218f8c53..f3bb3d672 100644 --- a/test/components/entity/metadata-row.spec.js +++ b/test/components/entity/metadata-row.spec.js @@ -3,6 +3,8 @@ import EntityMetadataRow from '../../../src/components/entity/metadata-row.vue'; import DateTime from '../../../src/components/date-time.vue'; import testData from '../../data'; +import { load } from '../../util/http'; +import { mockLogin } from '../../util/session'; import { mockRouter } from '../../util/router'; import { mount } from '../../util/lifecycle'; @@ -18,7 +20,7 @@ const mountComponent = (props = undefined) => { }, props: mergedProps, container: { - router: mockRouter('/projects/1/datasets/trees/entities/e') + router: mockRouter('/projects/1/datasets/trees/entities') } }); }; @@ -48,4 +50,53 @@ describe('EntityMetadataRow', () => { const { createdAt } = testData.extendedEntities.createPast(1).last(); mountComponent().getComponent(DateTime).props().iso.should.equal(createdAt); }); + + describe('last updated date', () => { + it('shows the date', () => { + const { updatedAt } = testData.extendedEntities.createPast(1) + .update(-1, { updates: 1 }); + should.exist(updatedAt); + const dateTimes = mountComponent().findAllComponents(DateTime); + dateTimes.length.should.equal(2); + dateTimes[1].classes('updated-at').should.be.true(); + dateTimes[1].props().iso.should.equal(updatedAt); + }); + + it('does not show a date if there has not been an update', () => { + testData.extendedEntities.createPast(1); + mountComponent().get('.updated-at').text().should.equal(''); + }); + }); + + describe('update count', () => { + it('shows the count if there has been an update', () => { + testData.extendedEntities.createPast(1).update(-1, { updates: 1000 }); + mountComponent().get('.updates').text().should.equal('1,000'); + }); + + it('does not show the count if there has not been an update', () => { + testData.extendedEntities.createPast(1); + mountComponent().get('.updates').text().should.equal(''); + }); + }); + + it('renders the edit button correctly', async () => { + testData.extendedEntities.createPast(1).update(-1, { updates: 1000 }); + const button = mountComponent({ canUpdate: true }).get('.update-button'); + button.attributes('aria-label').should.equal('Edit (1,000)'); + await button.should.have.tooltip('Edit (1,000)'); + }); + + it('renders the More button correctly', async () => { + mockLogin(); + testData.extendedDatasets.createPast(1, { name: 'รก', entities: 1 }); + testData.extendedEntities.createPast(1, { uuid: 'e' }); + // Using load() rather than mountComponent() because RouterLinkStub doesn't + // use the slot. + const app = await load('/projects/1/datasets/%C3%A1/entities'); + const btn = app.get('.entity-metadata-row .more-button'); + btn.element.tagName.should.equal('A'); + btn.attributes('target').should.equal('_blank'); + btn.attributes('href').should.equal('/projects/1/datasets/%C3%A1/entities/e'); + }); }); diff --git a/test/components/entity/table.spec.js b/test/components/entity/table.spec.js index 9acf9403c..d8cc0c955 100644 --- a/test/components/entity/table.spec.js +++ b/test/components/entity/table.spec.js @@ -6,6 +6,7 @@ import useProject from '../../../src/request-data/project'; import useEntities from '../../../src/request-data/entities'; import testData from '../../data'; +import { mockLogin } from '../../util/session'; import { mockRouter } from '../../util/router'; import { mount } from '../../util/lifecycle'; import { testRequestData } from '../../util/request-data'; @@ -19,7 +20,7 @@ const mountComponent = (props = undefined) => mount(EntityTable, { ...props }, container: { - router: mockRouter('/projects/1/datasets/trees/entities/e'), + router: mockRouter('/projects/1/datasets/trees/entities'), requestData: testRequestData([useProject, useEntities], { project: testData.extendedProjects.last(), odataEntities: testData.entityOData() @@ -31,12 +32,12 @@ const headers = (table) => table.findAll('th').map(th => th.text()); describe('EntityTable', () => { describe('metadata headers', () => { - it('renders the correct headers for a form', () => { + it('renders the correct headers', () => { testData.extendedDatasets.createPast(1); testData.extendedEntities.createPast(1); const component = mountComponent(); - const table = component.get('#entity-table-metadata'); - headers(table).should.eql(['', 'Created by', 'Created at']); + const table = component.get('.table-freeze-frozen'); + headers(table).should.eql(['', 'Created by', 'Created at', 'Last Updated / Actions']); }); }); @@ -48,7 +49,7 @@ describe('EntityTable', () => { }); testData.extendedEntities.createPast(1); - const table = mountComponent().get('#entity-table-data'); + const table = mountComponent().get('.table-freeze-scrolling'); headers(table).should.eql(['p1', 'p2', 'Label', 'Entity ID']); }); }); @@ -69,4 +70,23 @@ describe('EntityTable', () => { const rows = component.findAllComponents(EntityMetadataRow); rows.map(row => row.props().rowNumber).should.eql([3, 2, 1]); }); + + describe('visibility of edit buttons', () => { + it('renders the button for a sitewide administrator', () => { + mockLogin(); + testData.extendedEntities.createPast(1); + const row = mountComponent().getComponent(EntityMetadataRow); + row.props().canUpdate.should.be.true(); + row.find('.update-button').exists().should.be.true(); + }); + + it('does not render the button for a project viewer', () => { + mockLogin({ role: 'none' }); + testData.extendedProjects.createPast(1, { role: 'viewer', datasets: 1 }); + testData.extendedEntities.createPast(1); + const row = mountComponent().getComponent(EntityMetadataRow); + row.props().canUpdate.should.be.false(); + row.find('.update-button').exists().should.be.false(); + }); + }); }); diff --git a/test/components/entity/update.spec.js b/test/components/entity/update.spec.js index 2ac92d607..f4e422fda 100644 --- a/test/components/entity/update.spec.js +++ b/test/components/entity/update.spec.js @@ -83,6 +83,7 @@ describe('EntityUpdate', () => { const textareas = modal.findAll('textarea'); textareas.map(({ element }) => element.style.height).should.eql(['', '']); await modal.setProps({ state: true }); + await modal.vm.$nextTick(); const heights = textareas.map(({ element }) => element.style.height); heights[0].should.not.equal(''); heights[1].should.not.equal(''); diff --git a/test/components/entity/update/row.spec.js b/test/components/entity/update/row.spec.js index 01c675aed..1c5609e87 100644 --- a/test/components/entity/update/row.spec.js +++ b/test/components/entity/update/row.spec.js @@ -137,6 +137,7 @@ describe('EntityUpdateRow', () => { attachTo: document.body }); await modal.vm.$nextTick(); + await modal.vm.$nextTick(); const minHeights = modal.findAllComponents(TextareaAutosize) .map(textarea => textarea.props().minHeight); should.exist(minHeights[0]); diff --git a/test/components/submission/metadata-row.spec.js b/test/components/submission/metadata-row.spec.js index d88c4e5f5..658e38bbc 100644 --- a/test/components/submission/metadata-row.spec.js +++ b/test/components/submission/metadata-row.spec.js @@ -1,9 +1,7 @@ -import sinon from 'sinon'; import { RouterLinkStub } from '@vue/test-utils'; import DateTime from '../../../src/components/date-time.vue'; import SubmissionMetadataRow from '../../../src/components/submission/metadata-row.vue'; -import SubmissionTable from '../../../src/components/submission/table.vue'; import SubmissionUpdateReviewState from '../../../src/components/submission/update-review-state.vue'; import testData from '../../data'; @@ -133,7 +131,7 @@ describe('SubmissionMetadataRow', () => { it('does not show the count if there has not been an edit', () => { testData.extendedSubmissions.createPast(1, { edits: 0 }); - mountComponent().find('.edits').exists().should.be.false(); + mountComponent().get('.edits').text().should.equal(''); }); }); @@ -190,18 +188,6 @@ describe('SubmissionMetadataRow', () => { submission.__id.should.equal('foo'); submission.__system.submitterId.should.equal('1'); }); - - it('calls SubmissionTable.methods.afterReview()', () => { - const afterReview = sinon.fake(); - return submit() - .beforeAnyResponse(component => { - const table = component.getComponent(SubmissionTable); - sinon.replace(table.vm, 'afterReview', afterReview); - }) - .afterResponse(() => { - afterReview.called.should.be.true(); - }); - }); }); }); diff --git a/test/components/submission/table.spec.js b/test/components/submission/table.spec.js index 487d8a418..2279e0ac5 100644 --- a/test/components/submission/table.spec.js +++ b/test/components/submission/table.spec.js @@ -49,7 +49,7 @@ describe('SubmissionTable', () => { const component = mountComponent({ props: { draft: false } }); - const table = component.get('#submission-table-metadata'); + const table = component.get('.table-freeze-frozen'); headers(table).should.eql(['', 'Submitted by', 'Submitted at', 'State and actions']); }); @@ -59,7 +59,7 @@ describe('SubmissionTable', () => { const component = mountComponent({ props: { draft: true } }); - const table = component.get('#submission-table-metadata'); + const table = component.get('.table-freeze-frozen'); headers(table).should.eql(['', 'Submitted at']); }); }); @@ -71,7 +71,7 @@ describe('SubmissionTable', () => { submissions: 1 }); testData.extendedSubmissions.createPast(1); - const table = mountComponent().get('#submission-table-data'); + const table = mountComponent().get('.table-freeze-scrolling'); headers(table).should.eql(['s1', 's2', 'Instance ID']); }); @@ -83,7 +83,7 @@ describe('SubmissionTable', () => { testData.extendedForms.createPast(1, { fields, submissions: 1 }); testData.extendedSubmissions.createPast(1); const component = mountComponent(); - const table = component.get('#submission-table-data'); + const table = component.get('.table-freeze-scrolling'); headers(table).should.eql(['g-s', 'Instance ID']); }); }); @@ -132,53 +132,4 @@ describe('SubmissionTable', () => { row.props().canUpdate.should.be.false(); }); }); - - describe('visibility of actions', () => { - it('shows actions if user hovers over a SubmissionDataRow', async () => { - testData.extendedForms.createPast(1, { submissions: 2 }); - testData.extendedSubmissions.createPast(2); - const component = mountComponent(); - await component.getComponent(SubmissionDataRow).trigger('mouseover'); - const metadataRows = component.findAllComponents(SubmissionMetadataRow); - metadataRows[0].classes('data-hover').should.be.true(); - metadataRows[1].classes('data-hover').should.be.false(); - }); - - it('toggles actions if user hovers over a new SubmissionDataRow', async () => { - testData.extendedForms.createPast(1, { submissions: 2 }); - testData.extendedSubmissions.createPast(2); - const component = mountComponent(); - const dataRows = component.findAllComponents(SubmissionDataRow); - await dataRows[0].trigger('mouseover'); - await dataRows[1].trigger('mouseover'); - const metadataRows = component.findAllComponents(SubmissionMetadataRow); - metadataRows[0].classes('data-hover').should.be.false(); - metadataRows[1].classes('data-hover').should.be.true(); - }); - - it('hides the actions if the cursor leaves the table', async () => { - testData.extendedForms.createPast(1, { submissions: 2 }); - testData.extendedSubmissions.createPast(2); - const component = mountComponent(); - await component.getComponent(SubmissionDataRow).trigger('mouseover'); - await component.get('#submission-table-data tbody').trigger('mouseleave'); - const metadataRow = component.getComponent(SubmissionMetadataRow); - metadataRow.classes('data-hover').should.be.false(); - }); - - it('adds a class for the actions trigger', async () => { - testData.extendedSubmissions.createPast(1); - const component = mountComponent({ attachTo: document.body }); - const tbody = component.get('#submission-table-metadata tbody'); - tbody.classes('submission-table-actions-trigger-hover').should.be.true(); - const btn = tbody.findAll('.btn'); - await btn[0].trigger('focusin'); - tbody.classes('submission-table-actions-trigger-focus').should.be.true(); - await component.getComponent(SubmissionMetadataRow).trigger('mousemove'); - tbody.classes('submission-table-actions-trigger-hover').should.be.true(); - await btn[1].trigger('focusin'); - await component.getComponent(SubmissionDataRow).trigger('mousemove'); - tbody.classes('submission-table-actions-trigger-hover').should.be.true(); - }); - }); }); diff --git a/test/components/table-freeze.spec.js b/test/components/table-freeze.spec.js new file mode 100644 index 000000000..ad31ac442 --- /dev/null +++ b/test/components/table-freeze.spec.js @@ -0,0 +1,73 @@ +import TableFreeze from '../../src/components/table-freeze.vue'; + +import { mergeMountOptions, mount } from '../util/lifecycle'; + +const mountComponent = (options) => + mount(TableFreeze, mergeMountOptions(options, { + props: { + data: [{ id: 1, name: 'foo' }, { id: 1, name: 'bar' }], + keyProp: 'id' + }, + slots: { + 'head-frozen': 'id', + 'head-scrolling': 'name', + 'data-frozen': '{{ params.id }}', + 'data-scrolling': '{{ params.name }}' + } + })); + +describe('TableFreeze', () => { + describe('visibility of actions', () => { + const slots = { + 'head-frozen': 'Actions', + 'data-frozen': ` + + Hover or focus to see actions +
+ +
+ + ` + }; + + it('shows actions if user hovers over a scrolling row', async () => { + const component = mountComponent({ slots }); + await component.get('.table-freeze-scrolling td').trigger('mouseover'); + const frozenRows = component.findAll('.table-freeze-frozen tbody tr'); + frozenRows[0].classes('scrolling-hover').should.be.true(); + frozenRows[1].classes('scrolling-hover').should.be.false(); + }); + + it('toggles actions if user hovers over a new scrolling row', async () => { + const component = mountComponent({ slots }); + const scrollingRows = component.findAll('.table-freeze-scrolling tbody tr'); + await scrollingRows[0].trigger('mouseover'); + await scrollingRows[1].trigger('mouseover'); + const frozenRows = component.findAll('.table-freeze-frozen tbody tr'); + frozenRows[0].classes('scrolling-hover').should.be.false(); + frozenRows[1].classes('scrolling-hover').should.be.true(); + }); + + it('hides the actions if the cursor leaves the table', async () => { + const component = mountComponent({ slots }); + await component.get('.table-freeze-scrolling td').trigger('mouseover'); + await component.get('.table-freeze-scrolling tbody').trigger('mouseleave'); + const frozenRow = component.get('.table-freeze-frozen tbody tr'); + frozenRow.classes('scrolling-hover').should.be.false(); + }); + + it('adds a class for the actions trigger', async () => { + const component = mountComponent({ slots, attachTo: document.body }); + const tbody = component.get('.table-freeze-frozen tbody'); + tbody.classes('actions-trigger-hover').should.be.true(); + const btn = tbody.findAll('.btn'); + await btn[0].trigger('focusin'); + tbody.classes('actions-trigger-focus').should.be.true(); + await tbody.get('td').trigger('mousemove'); + tbody.classes('actions-trigger-hover').should.be.true(); + await btn[1].trigger('focusin'); + await component.get('.table-freeze-scrolling td').trigger('mousemove'); + tbody.classes('actions-trigger-hover').should.be.true(); + }); + }); +}); diff --git a/test/data/entities.js b/test/data/entities.js index 11344df8e..097ba0de8 100644 --- a/test/data/entities.js +++ b/test/data/entities.js @@ -23,6 +23,8 @@ export const extendedEntities = dataStore({ uuid = faker.random.uuid(), label = faker.random.word(), + updates = 0, + updatedAt = null, ...options }) => { if (extendedDatasets.size === 0) { @@ -43,10 +45,11 @@ export const extendedEntities = dataStore({ return { uuid, currentVersion: { label, data, current: true }, + updates, creatorId: creator.id, creator: toActor(creator), createdAt, - updatedAt: null + updatedAt }; }, sort: comparator((entity1, entity2) => @@ -69,6 +72,7 @@ export const entityOData = (top = 250, skip = 0) => { label: entity.currentVersion.label, __id: entity.uuid, __system: { + updates: entity.updates, creatorId: entity.creator.id.toString(), creatorName: entity.creator.displayName, createdAt: entity.createdAt, diff --git a/test/unit/dom.js b/test/unit/dom.js deleted file mode 100644 index 07fd618df..000000000 --- a/test/unit/dom.js +++ /dev/null @@ -1,36 +0,0 @@ -import { px, requiredLabel, styleBox } from '../../src/util/dom'; - -describe('util/dom', () => { - describe('px()', () => { - it("appends 'px' to a number", () => { - px(1).should.equal('1px'); - }); - }); - - describe('styleBox()', () => { - it('converts px styles to numbers', () => { - const box = styleBox({ - paddingTop: '1px', - borderRightWidth: '2px', - marginBottom: '3.14px' - }); - box.paddingTop.should.equal(1); - box.borderRight.should.equal(2); - box.marginBottom.should.equal(3.14); - }); - - it('returns 0 for an empty style property', () => { - styleBox({ paddingTop: '' }).paddingTop.should.equal(0); - }); - }); - - describe('requiredLabel()', () => { - it('appends * to the text if required is true', () => { - requiredLabel('My Label', true).should.equal('My Label *'); - }); - - it('does not append * if required is false', () => { - requiredLabel('My Label', false).should.equal('My Label'); - }); - }); -}); diff --git a/test/unit/dom.spec.js b/test/unit/dom.spec.js new file mode 100644 index 000000000..1a1bf5b1f --- /dev/null +++ b/test/unit/dom.spec.js @@ -0,0 +1,74 @@ +import { markRowChanged, markRowsChanged, px, requiredLabel, styleBox } from '../../src/util/dom'; + +import { mount } from '../util/lifecycle'; +import { wait } from '../util/util'; + +describe('util/dom', () => { + describe('px()', () => { + it("appends 'px' to a number", () => { + px(1).should.equal('1px'); + }); + }); + + describe('styleBox()', () => { + it('converts px styles to numbers', () => { + const box = styleBox({ + paddingTop: '1px', + borderRightWidth: '2px', + marginBottom: '3.14px' + }); + box.paddingTop.should.equal(1); + box.borderRight.should.equal(2); + box.marginBottom.should.equal(3.14); + }); + + it('returns 0 for an empty style property', () => { + styleBox({ paddingTop: '' }).paddingTop.should.equal(0); + }); + }); + + describe('requiredLabel()', () => { + it('appends * to the text if required is true', () => { + requiredLabel('My Label', true).should.equal('My Label *'); + }); + + it('does not append * if required is false', () => { + requiredLabel('My Label', false).should.equal('My Label'); + }); + }); + + describe('markRowChanged(), markRowsChanged()', () => { + it('toggles data-mark-rows-changed for a single row', async () => { + const table = mount({ + template: ` + + + +
foo
` + }); + const row = table.get('tr').element; + markRowChanged(row); + row.dataset.markRowsChanged.should.equal('true'); + await wait(); + row.dataset.markRowsChanged.should.equal('false'); + }); + + it('toggles data-mark-rows-changed for multiple rows', async () => { + const table = mount({ + template: ` + + + + +
foo
bar
` + }); + const rows = table.findAll('tr').map(row => row.element); + markRowsChanged(rows); + rows[0].dataset.markRowsChanged.should.equal('true'); + rows[1].dataset.markRowsChanged.should.equal('true'); + await wait(); + rows[0].dataset.markRowsChanged.should.equal('false'); + rows[1].dataset.markRowsChanged.should.equal('false'); + }); + }); +}); diff --git a/transifex/strings_en.json b/transifex/strings_en.json index e133d2b87..3f8576237 100644 --- a/transifex/strings_en.json +++ b/transifex/strings_en.json @@ -1531,6 +1531,14 @@ } } }, + "EntityTable": { + "header": { + "updatedAtAndActions": { + "string": "Last Updated / Actions", + "developer_comment": "This is the text of a column header of a table of Entities. The column shows when each Entity was last updated, as well as actions that can be taken on the Entity." + } + } + }, "EntityUpdate": { "title": { "string": "Update {label}",