From e228c109947388c3c30b83272a93b3263c01edfe Mon Sep 17 00:00:00 2001
From: Matthew White
Date: Thu, 9 Aug 2018 15:49:44 -0400
Subject: [PATCH 01/85] Add base presenter class
This should reduce boilerplate and duplicated code.
---
lib/presenters/base.js | 33 +++++++++++++++++++++++++++++++++
lib/presenters/field-key.js | 34 ++++++++++++++++------------------
lib/presenters/form.js | 36 ++++++++++++++++++++----------------
3 files changed, 69 insertions(+), 34 deletions(-)
create mode 100644 lib/presenters/base.js
diff --git a/lib/presenters/base.js b/lib/presenters/base.js
new file mode 100644
index 000000000..770aa101c
--- /dev/null
+++ b/lib/presenters/base.js
@@ -0,0 +1,33 @@
+/*
+Copyright 2017 ODK Central Developers
+See the NOTICE file at the top-level directory of this distribution and at
+https://github.com/opendatakit/central-frontend/blob/master/NOTICE.
+
+This file is part of ODK Central. It is subject to the license terms in
+the LICENSE file found in the top-level directory of this distribution and at
+https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central,
+including this file, may be copied, modified, propagated, or distributed
+except according to the terms contained in the LICENSE file.
+*/
+import Vue from 'vue';
+
+class Base {
+ get key() {
+ if (this._key != null) return this._key;
+ this._key = Vue.prototype.$uniqueId();
+ return this._key;
+ }
+}
+
+export default (props) => {
+ const klass = class extends Base {};
+
+ // Add a getter for each property of the underlying data.
+ for (const name of props) {
+ Object.defineProperty(klass.prototype, name, {
+ get() { return this._data[name]; }
+ });
+ }
+
+ return klass;
+};
diff --git a/lib/presenters/field-key.js b/lib/presenters/field-key.js
index 047028af2..d2ec037ca 100644
--- a/lib/presenters/field-key.js
+++ b/lib/presenters/field-key.js
@@ -9,38 +9,36 @@ https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central,
including this file, may be copied, modified, propagated, or distributed
except according to the terms contained in the LICENSE file.
*/
-import Vue from 'vue';
import qrcode from 'qrcode-generator';
import { deflate } from 'pako/lib/deflate';
+import Base from './base';
+
+const props = [
+ 'id',
+ 'displayName',
+ 'token',
+ 'lastUsed',
+ 'createdBy',
+ 'createdAt'
+];
+
const QR_CODE_TYPE_NUMBER = 0;
// This is the level used in Collect.
const QR_CODE_ERROR_CORRECTION_LEVEL = 'L';
const QR_CODE_CELL_SIZE = 3;
const QR_CODE_MARGIN = 0;
-export default class FieldKey {
- constructor(fieldKey) {
- this._fieldKey = fieldKey;
- }
-
- get id() { return this._fieldKey.id; }
- get displayName() { return this._fieldKey.displayName; }
- get token() { return this._fieldKey.token; }
- get lastUsed() { return this._fieldKey.lastUsed; }
- get createdBy() { return this._fieldKey.createdBy; }
- get createdAt() { return this._fieldKey.createdAt; }
-
- get key() {
- if (this._key != null) return this._key;
- this._key = Vue.prototype.$uniqueId();
- return this._key;
+export default class FieldKey extends Base(props) {
+ constructor(data) {
+ super();
+ this._data = data;
}
qrCodeHtml() {
if (this._qrCodeHtml != null) return this._qrCodeHtml;
const code = qrcode(QR_CODE_TYPE_NUMBER, QR_CODE_ERROR_CORRECTION_LEVEL);
- const url = `${window.location.origin}/api/v1/key/${this._fieldKey.token}`;
+ const url = `${window.location.origin}/api/v1/key/${this.token}`;
// Collect requires the JSON to have 'general' and 'admin' keys, even if the
// associated values are empty objects.
const settings = { general: { server_url: url }, admin: {} };
diff --git a/lib/presenters/form.js b/lib/presenters/form.js
index db961702f..5c4674254 100644
--- a/lib/presenters/form.js
+++ b/lib/presenters/form.js
@@ -9,24 +9,28 @@ https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central,
including this file, may be copied, modified, propagated, or distributed
except according to the terms contained in the LICENSE file.
*/
-export default class Form {
- constructor(form) {
- this._form = form;
- }
-
- get xmlFormId() { return this._form.xmlFormId; }
- get name() { return this._form.name; }
- get version() { return this._form.version; }
- get xml() { return this._form.xml; }
- get hash() { return this._form.hash; }
- get state() { return this._form.state; }
- get createdBy() { return this._form.createdBy; }
- get createdAt() { return this._form.createdAt; }
- get updatedAt() { return this._form.updatedAt; }
+import Base from './base';
+const props = [
+ 'xmlFormId',
+ 'name',
+ 'version',
+ 'xml',
+ 'hash',
+ 'state',
+ 'createdBy',
+ 'createdAt',
+ 'updatedAt',
// Extended metadata
- get submissions() { return this._form.submissions; }
- get lastSubmission() { return this._form.lastSubmission; }
+ 'submissions',
+ 'lastSubmission'
+];
+
+export default class Form extends Base(props) {
+ constructor(data) {
+ super();
+ this._data = data;
+ }
nameOrId() { return this.name != null ? this.name : this.xmlFormId; }
From 67418898acfd82084479713ca10aa6292d7424c9 Mon Sep 17 00:00:00 2001
From: Matthew White
Date: Thu, 9 Aug 2018 15:49:46 -0400
Subject: [PATCH 02/85] Use form presenter in FormShow
---
lib/components/form/show.vue | 5 +++--
lib/presenters/base.js | 4 ++++
2 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/lib/components/form/show.vue b/lib/components/form/show.vue
index d644761b0..17001dc29 100644
--- a/lib/components/form/show.vue
+++ b/lib/components/form/show.vue
@@ -37,6 +37,7 @@ except according to the terms contained in the LICENSE file.
diff --git a/lib/router.js b/lib/router.js
index 53fe44412..9803aad10 100644
--- a/lib/router.js
+++ b/lib/router.js
@@ -18,6 +18,7 @@ import AccountResetPassword from './components/account/reset-password.vue';
import BackupList from './components/backup/list.vue';
import FieldKeyList from './components/field-key/list.vue';
import FormList from './components/form/list.vue';
+import FormAttachmentList from './components/form/attachment/list.vue';
import FormOverview from './components/form/overview.vue';
import FormSettings from './components/form/settings.vue';
import FormShow from './components/form/show.vue';
@@ -47,6 +48,7 @@ const routes = [
component: FormShow,
children: [
{ path: '', component: FormOverview },
+ { path: 'media-files', component: FormAttachmentList },
{ path: 'submissions', component: FormSubmissions },
{ path: 'settings', component: FormSettings }
]
From 3617f56ff9418186b3b1a59317f1a40130ad890a Mon Sep 17 00:00:00 2001
From: Matthew White
Date: Thu, 9 Aug 2018 15:49:50 -0400
Subject: [PATCH 04/85] Fetch form attachments
---
lib/components/form/show.vue | 15 +++++++----
test/components/form/analyze.spec.js | 3 ++-
test/components/form/delete.spec.js | 14 ++---------
test/components/form/edit.spec.js | 12 +--------
test/components/form/new.spec.js | 1 +
test/components/form/overview.spec.js | 24 ++++++++++++------
test/components/form/settings.spec.js | 1 +
test/components/form/submissions.spec.js | 2 ++
test/data.js | 26 ++++++++++---------
test/data/form-attachments.js | 32 ++++++++++++++++++++++++
10 files changed, 81 insertions(+), 49 deletions(-)
create mode 100644 test/data/form-attachments.js
diff --git a/lib/components/form/show.vue b/lib/components/form/show.vue
index 17001dc29..3adabc379 100644
--- a/lib/components/form/show.vue
+++ b/lib/components/form/show.vue
@@ -47,7 +47,8 @@ export default {
data() {
return {
requestId: null,
- form: null
+ form: null,
+ attachments: null
};
},
computed: {
@@ -66,11 +67,15 @@ export default {
methods: {
fetchData() {
this.form = null;
+ this.attachments = null;
const headers = { 'X-Extended-Metadata': 'true' };
- this
- .get(`/forms/${this.xmlFormId}`, { headers })
- .then(({ data }) => {
- this.form = new Form(data);
+ this.requestAll([
+ this.$http.get(`/forms/${this.xmlFormId}`, { headers }),
+ this.$http.get(`/forms/${this.xmlFormId}/attachments`, { headers })
+ ])
+ .then(([form, attachments]) => {
+ this.form = new Form(form.data);
+ this.attachments = attachments.data;
})
.catch(() => {});
},
diff --git a/test/components/form/analyze.spec.js b/test/components/form/analyze.spec.js
index 3c0c2cf9c..0bddba707 100644
--- a/test/components/form/analyze.spec.js
+++ b/test/components/form/analyze.spec.js
@@ -45,9 +45,10 @@ describe('FormAnalyze', () => {
it('selects the OData URL upon click', () =>
mockRoute(submissionsPath(createFormWithSubmission()), { attachToDocument: true })
.respondWithData(() => testData.extendedForms.last())
+ .respondWithData(() => testData.extendedFormAttachments.sorted())
.respondWithData(() => testData.extendedForms.last()._schema)
.respondWithData(testData.submissionOData)
- .afterResponse(clickAnalyzeButton)
+ .afterResponses(clickAnalyzeButton)
.then(app =>
trigger.click(app.first('#form-analyze-odata-url')).then(() => app))
.then(() => {
diff --git a/test/components/form/delete.spec.js b/test/components/form/delete.spec.js
index 351f0ba17..224f414de 100644
--- a/test/components/form/delete.spec.js
+++ b/test/components/form/delete.spec.js
@@ -1,14 +1,3 @@
-/*
-Copyright 2017 ODK Central Developers
-See the NOTICE file at the top-level directory of this distribution and at
-https://github.com/opendatakit/central-frontend/blob/master/NOTICE.
-
-This file is part of ODK Central. It is subject to the license terms in
-the LICENSE file found in the top-level directory of this distribution and at
-https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central,
-including this file, may be copied, modified, propagated, or distributed
-except according to the terms contained in the LICENSE file.
-*/
import FormDelete from '../../../lib/components/form/delete.vue';
import FormSettings from '../../../lib/components/form/settings.vue';
import testData from '../../data';
@@ -50,7 +39,8 @@ describe('FormDelete', () => {
const { xmlFormId } = testData.extendedForms.first();
return mockRoute(`/forms/${xmlFormId}/settings`)
.respondWithData(() => testData.extendedForms.first())
- .afterResponse(component => {
+ .respondWithData(() => testData.extendedFormAttachments.sorted())
+ .afterResponses(component => {
app = component;
return clickDeleteButton(app);
})
diff --git a/test/components/form/edit.spec.js b/test/components/form/edit.spec.js
index 260045797..462ccaeb9 100644
--- a/test/components/form/edit.spec.js
+++ b/test/components/form/edit.spec.js
@@ -1,14 +1,3 @@
-/*
-Copyright 2017 ODK Central Developers
-See the NOTICE file at the top-level directory of this distribution and at
-https://github.com/opendatakit/central-frontend/blob/master/NOTICE.
-
-This file is part of ODK Central. It is subject to the license terms in
-the LICENSE file found in the top-level directory of this distribution and at
-https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central,
-including this file, may be copied, modified, propagated, or distributed
-except according to the terms contained in the LICENSE file.
-*/
import FormEdit from '../../../lib/components/form/edit.vue';
import Spinner from '../../../lib/components/spinner.vue';
import faker from '../../faker';
@@ -66,6 +55,7 @@ describe('FormEdit', () => {
it('shows a success message', () =>
mockRoute(settingsPath(testData.extendedForms.createPast(1).last()))
.respondWithData(() => testData.extendedForms.last())
+ .respondWithData(() => testData.extendedFormAttachments.sorted())
.complete()
.request(app => selectDifferentState(app.first(FormEdit)))
.respondWithSuccess()
diff --git a/test/components/form/new.spec.js b/test/components/form/new.spec.js
index 6b433698d..d4ffb4a5d 100644
--- a/test/components/form/new.spec.js
+++ b/test/components/form/new.spec.js
@@ -139,6 +139,7 @@ describe('FormNew', () => {
.then(clickCreateButtonInModal))
.respondWithData(() => testData.simpleForms.last()) // FormNew
.respondWithData(() => testData.extendedForms.last()) // FormShow
+ .respondWithData(() => testData.extendedFormAttachments.sorted()) // FormShow
.respondWithData(() => testData.simpleFieldKeys.sorted())); // FormOverview
it('redirects to the form overview', () => {
diff --git a/test/components/form/overview.spec.js b/test/components/form/overview.spec.js
index bc574c031..79a480428 100644
--- a/test/components/form/overview.spec.js
+++ b/test/components/form/overview.spec.js
@@ -17,8 +17,9 @@ describe('FormOverview', () => {
const path = overviewPath(testData.extendedForms.createPast(1).last());
return mockRouteThroughLogin(path)
.respondWithData(() => testData.extendedForms.last())
+ .respondWithData(() => testData.extendedFormAttachments.sorted())
.respondWithData(() => testData.simpleFieldKeys.sorted())
- .afterResponse(app => app.vm.$route.path.should.equal(path));
+ .afterResponses(app => app.vm.$route.path.should.equal(path));
});
});
@@ -34,8 +35,9 @@ describe('FormOverview', () => {
// .
beforeEach(() => mockRoute(overviewPath(testData.extendedForms.last()))
.respondWithData(() => testData.extendedForms.last())
+ .respondWithData(() => testData.extendedFormAttachments.sorted())
.respondWithData(() => testData.simpleFieldKeys.sorted())
- .afterResponse(component => {
+ .afterResponses(component => {
app = component;
}));
@@ -65,8 +67,9 @@ describe('FormOverview', () => {
});
beforeEach(() => mockRoute(overviewPath(testData.extendedForms.last()))
.respondWithData(() => testData.extendedForms.last())
+ .respondWithData(() => testData.extendedFormAttachments.sorted())
.respondWithData(() => testData.simpleFieldKeys.sorted())
- .afterResponse(component => {
+ .afterResponses(component => {
app = component;
}));
@@ -101,8 +104,9 @@ describe('FormOverview', () => {
it('no app users', () =>
mockRoute(overviewPath(testData.extendedForms.createPast(1).last()))
.respondWithData(() => testData.extendedForms.last())
+ .respondWithData(() => testData.extendedFormAttachments.sorted())
.respondWithData(() => testData.simpleFieldKeys.sorted())
- .afterResponse(app => {
+ .afterResponses(app => {
const p = app.find('.form-overview-step')[1].find('p')[1];
p.text().trim().should.containEql('You do not have any App Users on this server yet');
}));
@@ -110,11 +114,12 @@ describe('FormOverview', () => {
it('at least one app user', () =>
mockRoute(overviewPath(testData.extendedForms.createPast(1).last()))
.respondWithData(() => testData.extendedForms.last())
+ .respondWithData(() => testData.extendedFormAttachments.sorted())
.respondWithData(() => {
const count = faker.random.number({ min: 1, max: 2000 });
return testData.simpleFieldKeys.createPast(count).sorted();
})
- .afterResponse(app => {
+ .afterResponses(app => {
const p = app.find('.form-overview-step')[1].find('p')[1];
const count = testData.simpleFieldKeys.size;
p.text().trim().should.containEql(` ${count.toLocaleString()} `);
@@ -125,8 +130,9 @@ describe('FormOverview', () => {
it('is not the current step, if the form is open', () =>
mockRoute(overviewPath(testData.extendedForms.createPast(1, { isOpen: true }).last()))
.respondWithData(() => testData.extendedForms.last())
+ .respondWithData(() => testData.extendedFormAttachments.sorted())
.respondWithData(() => testData.simpleFieldKeys.sorted())
- .afterResponse(app => {
+ .afterResponses(app => {
const title = app.find('.form-overview-step')[3].first('p');
title.hasClass('text-muted').should.be.true();
}));
@@ -134,8 +140,9 @@ describe('FormOverview', () => {
it('is marked as complete, if the form is not open', () =>
mockRoute(overviewPath(testData.extendedForms.createPast(1, { isOpen: false }).last()))
.respondWithData(() => testData.extendedForms.last())
+ .respondWithData(() => testData.extendedFormAttachments.sorted())
.respondWithData(() => testData.simpleFieldKeys.sorted())
- .afterResponse(app => {
+ .afterResponses(app => {
const title = app.find('.form-overview-step')[3].first('p');
title.hasClass('text-success').should.be.true();
}));
@@ -143,8 +150,9 @@ describe('FormOverview', () => {
it('is marked as complete if the state is changed from open', () =>
mockRoute(overviewPath(testData.extendedForms.createPast(1, { isOpen: true }).last()))
.respondWithData(() => testData.extendedForms.last())
+ .respondWithData(() => testData.extendedFormAttachments.sorted())
.respondWithData(() => testData.simpleFieldKeys.sorted())
- .afterResponse(app => {
+ .afterResponses(app => {
const title = app.find('.form-overview-step')[3].first('p');
title.hasClass('text-success').should.be.false();
})
diff --git a/test/components/form/settings.spec.js b/test/components/form/settings.spec.js
index 1ff3fe6a1..705ed0c7c 100644
--- a/test/components/form/settings.spec.js
+++ b/test/components/form/settings.spec.js
@@ -15,6 +15,7 @@ describe('FormSettings', () => {
const path = settingsPath(testData.extendedForms.createPast(1).last());
return mockRouteThroughLogin(path)
.respondWithData(() => testData.extendedForms.last())
+ .respondWithData(() => testData.extendedFormAttachments.sorted())
.afterResponse(app => app.vm.$route.path.should.equal(path));
});
});
diff --git a/test/components/form/submissions.spec.js b/test/components/form/submissions.spec.js
index b86f4c55a..b240b6b44 100644
--- a/test/components/form/submissions.spec.js
+++ b/test/components/form/submissions.spec.js
@@ -22,6 +22,7 @@ describe('FormSubmissions', () => {
const path = submissionsPath(form);
return mockRouteThroughLogin(path)
.respondWithData(() => form)
+ .respondWithData(() => testData.extendedFormAttachments.sorted())
.respondWithData(() => form._schema)
.respondWithData(testData.submissionOData)
.afterResponses(app => app.vm.$route.path.should.equal(path));
@@ -321,6 +322,7 @@ describe('FormSubmissions', () => {
it(`refreshes part ${i} of table after refresh button is clicked`, () =>
mockRoute(submissionsPath(form()))
.respondWithData(form)
+ .respondWithData(() => testData.extendedFormAttachments.sorted())
.testRefreshButton({
collection: testData.extendedSubmissions,
respondWithData: [
diff --git a/test/data.js b/test/data.js
index 276d64717..6d92b71aa 100644
--- a/test/data.js
+++ b/test/data.js
@@ -1,19 +1,21 @@
-import * as administrators from './data/administrators';
-import * as backups from './data/backups';
-import * as fieldKeys from './data/fieldKeys';
-import * as forms from './data/forms';
-import * as sessions from './data/sessions';
-import * as submissions from './data/submissions';
+import * as Administrators from './data/administrators';
+import * as Backups from './data/backups';
+import * as FieldKeys from './data/fieldKeys';
+import * as FormAttachments from './data/form-attachments';
+import * as Forms from './data/forms';
+import * as Sessions from './data/sessions';
+import * as Submissions from './data/submissions';
import { resetDataStores } from './data/data-store';
const testData = Object.assign(
{},
- administrators,
- backups,
- fieldKeys,
- forms,
- sessions,
- submissions
+ Administrators,
+ Backups,
+ FieldKeys,
+ FormAttachments,
+ Forms,
+ Sessions,
+ Submissions
);
testData.reset = resetDataStores;
diff --git a/test/data/form-attachments.js b/test/data/form-attachments.js
new file mode 100644
index 000000000..2f7be9ea9
--- /dev/null
+++ b/test/data/form-attachments.js
@@ -0,0 +1,32 @@
+import faker from '../faker';
+import { dataStore } from './data-store';
+import { extendedForms } from './forms';
+
+const FORM_ATTACHMENT_TYPES = [
+ { type: 'image', fileExtension: 'jpg' },
+ { type: 'audio', fileExtension: 'mp3' },
+ { type: 'video', fileExtension: 'mp4' }
+];
+
+// eslint-disable-next-line import/prefer-default-export
+export const extendedFormAttachments = dataStore({
+ factory: ({
+ inPast,
+ lastCreatedAt,
+ exists = faker.random.boolean()
+ }) => {
+ const form = extendedForms.randomOrCreatePast();
+ const type = faker.random.arrayElement(FORM_ATTACHMENT_TYPES);
+ const { updatedAt } = faker.date.timestamps(inPast, [
+ lastCreatedAt,
+ form.createdAt
+ ]);
+ return {
+ type,
+ name: faker.system.commonFileName(type.fileExtension),
+ exists,
+ updatedAt
+ };
+ },
+ sort: 'name'
+});
From de68a272c750fe91e72f74bdcb6ebfbb5424f307 Mon Sep 17 00:00:00 2001
From: Matthew White
Date: Thu, 9 Aug 2018 15:49:51 -0400
Subject: [PATCH 05/85] Add media files tab
---
lib/components/form/attachment/list.vue | 12 +++++++++++-
lib/components/form/overview.vue | 4 ++++
lib/components/form/settings.vue | 2 ++
lib/components/form/show.vue | 15 ++++++++++++++-
lib/components/form/submissions.vue | 2 ++
5 files changed, 33 insertions(+), 2 deletions(-)
diff --git a/lib/components/form/attachment/list.vue b/lib/components/form/attachment/list.vue
index 4db374ff3..e136c79ad 100644
--- a/lib/components/form/attachment/list.vue
+++ b/lib/components/form/attachment/list.vue
@@ -15,6 +15,16 @@ except according to the terms contained in the LICENSE file.
diff --git a/lib/components/form/overview.vue b/lib/components/form/overview.vue
index 7e92aa7a8..759fda74a 100644
--- a/lib/components/form/overview.vue
+++ b/lib/components/form/overview.vue
@@ -107,6 +107,10 @@ export default {
form: {
type: Object,
required: true
+ },
+ attachments: {
+ type: Array,
+ required: true
}
},
data() {
diff --git a/lib/components/form/settings.vue b/lib/components/form/settings.vue
index 93dd0ffeb..51e4ba05b 100644
--- a/lib/components/form/settings.vue
+++ b/lib/components/form/settings.vue
@@ -45,6 +45,8 @@ export default {
name: 'FormSettings',
components: { FormEdit, FormDelete },
mixins: [modal('deleteForm')],
+ // Setting this in order to ignore the `attachments` attribute.
+ inheritAttrs: false,
props: {
form: {
type: Object,
diff --git a/lib/components/form/show.vue b/lib/components/form/show.vue
index 3adabc379..935a8b948 100644
--- a/lib/components/form/show.vue
+++ b/lib/components/form/show.vue
@@ -20,6 +20,15 @@ except according to the terms contained in the LICENSE file.
Overview
+
+
+ Media Files
+
+ {{ missingAttachments.toLocaleString() }}
+
+
+
Submissions
@@ -30,7 +39,8 @@ except according to the terms contained in the LICENSE file.
-
+
@@ -54,6 +64,9 @@ export default {
computed: {
xmlFormId() {
return this.$route.params.xmlFormId;
+ },
+ missingAttachments() {
+ return this.attachments.filter(attachment => !attachment.exists).length;
}
},
watch: {
diff --git a/lib/components/form/submissions.vue b/lib/components/form/submissions.vue
index 7d1b2c5a3..0604ab8d1 100644
--- a/lib/components/form/submissions.vue
+++ b/lib/components/form/submissions.vue
@@ -96,6 +96,8 @@ export default {
modal('analyze'),
request()
],
+ // Setting this in order to ignore the `attachments` attribute.
+ inheritAttrs: false,
props: {
form: {
type: Object,
From afa0bd205c93e0958bf43de5425b71e67a9bd69c Mon Sep 17 00:00:00 2001
From: Matthew White
Date: Thu, 9 Aug 2018 15:49:53 -0400
Subject: [PATCH 06/85] Use $pluralize() in FormOverview
$pluralize() was not in place when we implemented FormOverview. Adding
it now simplifies the component.
---
lib/components/form/overview.vue | 27 ++++++++-------------------
test/components/form/overview.spec.js | 4 ++--
2 files changed, 10 insertions(+), 21 deletions(-)
diff --git a/lib/components/form/overview.vue b/lib/components/form/overview.vue
index 759fda74a..043beb728 100644
--- a/lib/components/form/overview.vue
+++ b/lib/components/form/overview.vue
@@ -38,8 +38,10 @@ except according to the terms contained in the LICENSE file.
Nobody has submitted any data to this form yet.
- A total of {{ submissionCountString }} {{ have }} been sent to
- this server.
+ A total of {{ form.submissions.toLocaleString() }}
+ {{ $pluralize('submission', form.submissions) }}
+ {{ $pluralize('has', form.submissions) }} been sent to this
+ server.
App Users will be able to see this form on their mobile device to
download and fill out.
@@ -52,7 +54,7 @@ except according to the terms contained in the LICENSE file.
Right now, you have
- {{ fieldKeyCountString }}
+ {{ fieldKeyCount.toLocaleString() }} App Users
on this server, but you can always add more.
@@ -70,7 +72,9 @@ except according to the terms contained in the LICENSE file.
to monitor and analyze the data for quality and results.
- You can export or synchronize the {{ submissionCountString }} on
+ You can export or synchronize the
+ {{ form.submissions.toLocaleString() }}
+ {{ $pluralize('submission', form.submissions) }} on
this form to monitor and analyze them for quality and results.
You can do this with the Download and Analyze buttons on the
@@ -119,21 +123,6 @@ export default {
fieldKeyCount: null
};
},
- computed: {
- submissionCountString() {
- const count = this.form.submissions.toLocaleString();
- const s = this.form.submissions !== 1 ? 's' : '';
- return `${count} submission${s}`;
- },
- have() {
- return this.form.submissions === 1 ? 'has' : 'have';
- },
- fieldKeyCountString() {
- const count = this.fieldKeyCount.toLocaleString();
- const s = this.fieldKeyCount !== 1 ? 's' : '';
- return `${count} App User${s}`;
- }
- },
created() {
this.fetchData();
},
diff --git a/test/components/form/overview.spec.js b/test/components/form/overview.spec.js
index 79a480428..007b4b96a 100644
--- a/test/components/form/overview.spec.js
+++ b/test/components/form/overview.spec.js
@@ -82,7 +82,7 @@ describe('FormOverview', () => {
it('shows the number of submissions', () => {
const p = app.find('.form-overview-step')[1].find('p')[1];
const count = testData.extendedForms.last().submissions;
- p.text().trim().should.containEql(` ${count.toLocaleString()} `);
+ p.text().trim().should.containEql(count.toLocaleString());
});
});
@@ -95,7 +95,7 @@ describe('FormOverview', () => {
it('shows the number of submissions', () => {
const p = app.find('.form-overview-step')[2].find('p')[1];
const count = testData.extendedForms.last().submissions;
- p.text().trim().should.containEql(` ${count.toLocaleString()} `);
+ p.text().trim().should.containEql(count.toLocaleString());
});
});
});
From 6f5cc6d8fe84fd64e6879cfbb3b00359320ceb11 Mon Sep 17 00:00:00 2001
From: Matthew White
Date: Thu, 9 Aug 2018 15:49:55 -0400
Subject: [PATCH 07/85] Add FormOverviewStep component
This should help clarify the logic around the step stage and how that
affects the heading and icon text classes.
---
lib/components/form/overview-step.vue | 73 ++++++++++++++++++++++
lib/components/form/overview.vue | 89 ++++++++++-----------------
2 files changed, 105 insertions(+), 57 deletions(-)
create mode 100644 lib/components/form/overview-step.vue
diff --git a/lib/components/form/overview-step.vue b/lib/components/form/overview-step.vue
new file mode 100644
index 000000000..582fdb017
--- /dev/null
+++ b/lib/components/form/overview-step.vue
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
diff --git a/lib/components/form/overview.vue b/lib/components/form/overview.vue
index 043beb728..b62b8e642 100644
--- a/lib/components/form/overview.vue
+++ b/lib/components/form/overview.vue
@@ -15,24 +15,17 @@ except according to the terms contained in the LICENSE file.
-
-
From 41f43b559df5164feaa801b3a809d857a7a03563 Mon Sep 17 00:00:00 2001
From: Matthew White
Date: Thu, 9 Aug 2018 15:49:56 -0400
Subject: [PATCH 08/85] Add step to form overview for form attachments
---
lib/components/form/overview.vue | 22 +++++++++++++++++++---
1 file changed, 19 insertions(+), 3 deletions(-)
diff --git a/lib/components/form/overview.vue b/lib/components/form/overview.vue
index b62b8e642..021b99e16 100644
--- a/lib/components/form/overview.vue
+++ b/lib/components/form/overview.vue
@@ -24,7 +24,17 @@ except according to the terms contained in the LICENSE file.
questions.
-
+
+ Upload form media files
+
+ Your form design references files that we need in order to present
+ your form. You can upload these for distribution under the
+ Media Files
+ tab. If you change your mind or make a replace, you can always
+ replace the files.
+
+
+
Download form on survey clients and submit data
@@ -53,7 +63,7 @@ except according to the terms contained in the LICENSE file.
-
+
Evaluate and analyze submitted data"
@@ -70,7 +80,7 @@ except according to the terms contained in the LICENSE file.
Submissions tab.
-
+
Manage form retirement
As you come to the end of your data collection project, you can use
@@ -112,10 +122,16 @@ export default {
};
},
computed: {
+ // Returns true if all form attachments exist and false if not. Returns true
+ // if there are no form attachments.
+ allAttachmentsExist() {
+ return this.attachments.every(attachment => attachment.exists);
+ },
// Indicates whether each step is complete.
stepCompletion() {
return [
true,
+ this.allAttachmentsExist,
this.form.submissions !== 0,
false,
this.form.state !== 'open'
From de2623c68fbfc09a3f4d0c912efde9801ebc4207 Mon Sep 17 00:00:00 2001
From: Matthew White
Date: Thu, 9 Aug 2018 15:49:58 -0400
Subject: [PATCH 09/85] Update step indexes in form overview tests
---
lib/components/form/overview.vue | 4 ++-
test/components/form/overview.spec.js | 38 +++++++++++++--------------
2 files changed, 22 insertions(+), 20 deletions(-)
diff --git a/lib/components/form/overview.vue b/lib/components/form/overview.vue
index 021b99e16..e744b6740 100644
--- a/lib/components/form/overview.vue
+++ b/lib/components/form/overview.vue
@@ -24,7 +24,9 @@ except according to the terms contained in the LICENSE file.
questions.
-
+
+
Upload form media files
Your form design references files that we need in order to present
diff --git a/test/components/form/overview.spec.js b/test/components/form/overview.spec.js
index 007b4b96a..88be39eb9 100644
--- a/test/components/form/overview.spec.js
+++ b/test/components/form/overview.spec.js
@@ -41,21 +41,21 @@ describe('FormOverview', () => {
app = component;
}));
- describe('step 2', () => {
+ describe('step 3', () => {
it('is the current step', () => {
- const title = app.find('.form-overview-step')[1].first('p');
+ const title = app.find('.form-overview-step')[2].first('p');
title.hasClass('text-success').should.be.false();
title.hasClass('text-muted').should.be.false();
});
it('indicates that there are no submissions', () => {
- const p = app.find('.form-overview-step')[1].find('p')[1];
+ const p = app.find('.form-overview-step')[2].find('p')[1];
p.text().trim().should.containEql('Nobody has submitted any data to this form yet.');
});
});
- it('step 3 indicates that there are no submissions', () => {
- const p = app.find('.form-overview-step')[2].find('p')[1];
+ it('step 4 indicates that there are no submissions', () => {
+ const p = app.find('.form-overview-step')[3].find('p')[1];
p.text().trim().should.containEql('Once there is data for this form,');
});
});
@@ -73,41 +73,41 @@ describe('FormOverview', () => {
app = component;
}));
- describe('step 2', () => {
+ describe('step 3', () => {
it('is marked as complete', () => {
- const title = app.find('.form-overview-step')[1].first('p');
+ const title = app.find('.form-overview-step')[2].first('p');
title.hasClass('text-success').should.be.true();
});
it('shows the number of submissions', () => {
- const p = app.find('.form-overview-step')[1].find('p')[1];
+ const p = app.find('.form-overview-step')[2].find('p')[1];
const count = testData.extendedForms.last().submissions;
p.text().trim().should.containEql(count.toLocaleString());
});
});
- describe('step 3', () => {
+ describe('step 4', () => {
it('is the current step', () => {
- const title = app.find('.form-overview-step')[2].first('p');
+ const title = app.find('.form-overview-step')[3].first('p');
title.hasClass('text-success').should.be.false();
});
it('shows the number of submissions', () => {
- const p = app.find('.form-overview-step')[2].find('p')[1];
+ const p = app.find('.form-overview-step')[3].find('p')[1];
const count = testData.extendedForms.last().submissions;
p.text().trim().should.containEql(count.toLocaleString());
});
});
});
- describe('step 2 indicates the number of app users', () => {
+ describe('step 3 indicates the number of app users', () => {
it('no app users', () =>
mockRoute(overviewPath(testData.extendedForms.createPast(1).last()))
.respondWithData(() => testData.extendedForms.last())
.respondWithData(() => testData.extendedFormAttachments.sorted())
.respondWithData(() => testData.simpleFieldKeys.sorted())
.afterResponses(app => {
- const p = app.find('.form-overview-step')[1].find('p')[1];
+ const p = app.find('.form-overview-step')[2].find('p')[1];
p.text().trim().should.containEql('You do not have any App Users on this server yet');
}));
@@ -120,20 +120,20 @@ describe('FormOverview', () => {
return testData.simpleFieldKeys.createPast(count).sorted();
})
.afterResponses(app => {
- const p = app.find('.form-overview-step')[1].find('p')[1];
+ const p = app.find('.form-overview-step')[2].find('p')[1];
const count = testData.simpleFieldKeys.size;
p.text().trim().should.containEql(` ${count.toLocaleString()} `);
}));
});
- describe('step 4', () => {
+ describe('step 5', () => {
it('is not the current step, if the form is open', () =>
mockRoute(overviewPath(testData.extendedForms.createPast(1, { isOpen: true }).last()))
.respondWithData(() => testData.extendedForms.last())
.respondWithData(() => testData.extendedFormAttachments.sorted())
.respondWithData(() => testData.simpleFieldKeys.sorted())
.afterResponses(app => {
- const title = app.find('.form-overview-step')[3].first('p');
+ const title = app.find('.form-overview-step')[4].first('p');
title.hasClass('text-muted').should.be.true();
}));
@@ -143,7 +143,7 @@ describe('FormOverview', () => {
.respondWithData(() => testData.extendedFormAttachments.sorted())
.respondWithData(() => testData.simpleFieldKeys.sorted())
.afterResponses(app => {
- const title = app.find('.form-overview-step')[3].first('p');
+ const title = app.find('.form-overview-step')[4].first('p');
title.hasClass('text-success').should.be.true();
}));
@@ -153,7 +153,7 @@ describe('FormOverview', () => {
.respondWithData(() => testData.extendedFormAttachments.sorted())
.respondWithData(() => testData.simpleFieldKeys.sorted())
.afterResponses(app => {
- const title = app.find('.form-overview-step')[3].first('p');
+ const title = app.find('.form-overview-step')[4].first('p');
title.hasClass('text-success').should.be.false();
})
.route(`/forms/${testData.extendedForms.last().xmlFormId}/settings`)
@@ -166,7 +166,7 @@ describe('FormOverview', () => {
.complete()
.route(overviewPath(testData.extendedForms.last()))
.then(app => {
- const title = app.find('.form-overview-step')[3].first('p');
+ const title = app.find('.form-overview-step')[4].first('p');
title.hasClass('text-success').should.be.true();
}));
});
From 4370fe61acd32b724fc4abc243df415b4e31bcce Mon Sep 17 00:00:00 2001
From: Matthew White
Date: Thu, 9 Aug 2018 15:50:00 -0400
Subject: [PATCH 10/85] Add form overview tests for form attachments
---
test/components/form/overview.spec.js | 285 ++++++++++++++------------
1 file changed, 159 insertions(+), 126 deletions(-)
diff --git a/test/components/form/overview.spec.js b/test/components/form/overview.spec.js
index 88be39eb9..6ebbccf8d 100644
--- a/test/components/form/overview.spec.js
+++ b/test/components/form/overview.spec.js
@@ -1,4 +1,3 @@
-import faker from '../../faker';
import testData from '../../data';
import { mockLogin, mockRouteThroughLogin } from '../../session';
import { mockRoute } from '../../http';
@@ -26,149 +25,183 @@ describe('FormOverview', () => {
describe('after login', () => {
beforeEach(mockLogin);
- describe('no submissions', () => {
- let app;
- beforeEach(() => {
- testData.extendedForms.createPast(1, { hasSubmission: false });
- });
+ const loadOverview = ({
+ attachmentCount = 0,
+ allAttachmentsExist,
+ hasSubmission = false,
+ formIsOpen = true,
+ fieldKeyCount = 0
+ }) => {
+ testData.extendedForms.createPast(1, { hasSubmission, isOpen: formIsOpen });
+ if (attachmentCount !== 0) {
+ testData.extendedFormAttachments.createPast(
+ attachmentCount,
+ { exists: allAttachmentsExist }
+ );
+ }
// Using mockRoute() rather than mockHttp(), because FormOverview uses
// .
- beforeEach(() => mockRoute(overviewPath(testData.extendedForms.last()))
+ return mockRoute(overviewPath(testData.extendedForms.last()))
.respondWithData(() => testData.extendedForms.last())
.respondWithData(() => testData.extendedFormAttachments.sorted())
- .respondWithData(() => testData.simpleFieldKeys.sorted())
- .afterResponses(component => {
- app = component;
+ // Not using testData, because fieldKeyCount may be fairly large, and
+ // the component only uses the array length.
+ .respondWithData(() => new Array(fieldKeyCount));
+ };
+
+ describe('submission count', () => {
+ it('no submissions', () =>
+ loadOverview({ hasSubmission: false }).afterResponses(app => {
+ app.find('.form-overview-step')[2].find('p')[1].text().trim()
+ .should.containEql('Nobody has submitted any data to this form yet.');
+ app.find('.form-overview-step')[3].find('p')[1].text().trim()
+ .should.containEql('Once there is data for this form,');
}));
- describe('step 3', () => {
- it('is the current step', () => {
- const title = app.find('.form-overview-step')[2].first('p');
- title.hasClass('text-success').should.be.false();
- title.hasClass('text-muted').should.be.false();
- });
-
- it('indicates that there are no submissions', () => {
- const p = app.find('.form-overview-step')[2].find('p')[1];
- p.text().trim().should.containEql('Nobody has submitted any data to this form yet.');
- });
- });
-
- it('step 4 indicates that there are no submissions', () => {
- const p = app.find('.form-overview-step')[3].find('p')[1];
- p.text().trim().should.containEql('Once there is data for this form,');
- });
+ it('at least one submission', () =>
+ loadOverview({ hasSubmission: true }).afterResponses(app => {
+ const count = testData.extendedForms.last().submissions
+ .toLocaleString();
+ app.find('.form-overview-step')[2].find('p')[1].text().trim()
+ .should.containEql(count);
+ app.find('.form-overview-step')[3].find('p')[1].text().trim()
+ .should.containEql(count);
+ }));
});
- describe('form has submissions', () => {
- let app;
- beforeEach(() => {
- testData.extendedForms.createPast(1, { hasSubmission: true });
- });
- beforeEach(() => mockRoute(overviewPath(testData.extendedForms.last()))
- .respondWithData(() => testData.extendedForms.last())
- .respondWithData(() => testData.extendedFormAttachments.sorted())
- .respondWithData(() => testData.simpleFieldKeys.sorted())
- .afterResponses(component => {
- app = component;
+ describe('app user count', () => {
+ it('no app users', () =>
+ loadOverview({ fieldKeyCount: 0 }).afterResponses(app => {
+ app.find('.form-overview-step')[2].find('p')[1].text().trim()
+ .should.containEql('You do not have any App Users on this server yet');
}));
- describe('step 3', () => {
- it('is marked as complete', () => {
- const title = app.find('.form-overview-step')[2].first('p');
- title.hasClass('text-success').should.be.true();
- });
-
- it('shows the number of submissions', () => {
- const p = app.find('.form-overview-step')[2].find('p')[1];
- const count = testData.extendedForms.last().submissions;
- p.text().trim().should.containEql(count.toLocaleString());
+ it('at least one app user', () => {
+ const fieldKeyCount = 1000;
+ return loadOverview({ fieldKeyCount }).afterResponses(app => {
+ app.find('.form-overview-step')[2].find('p')[1].text().trim()
+ .should.containEql(fieldKeyCount.toLocaleString());
});
});
+ });
- describe('step 4', () => {
- it('is the current step', () => {
- const title = app.find('.form-overview-step')[3].first('p');
+ it('marks step 5 as complete if form state is changed from open', () =>
+ loadOverview({ formIsOpen: true })
+ .afterResponses(app => {
+ const title = app.find('.form-overview-step')[4].first('p');
title.hasClass('text-success').should.be.false();
- });
+ })
+ .route(`/forms/${testData.extendedForms.last().xmlFormId}/settings`)
+ .request(app => {
+ const formEdit = app.first('#form-edit');
+ const closed = formEdit.first('input[type="radio"][value="closed"]');
+ return trigger.change(closed);
+ })
+ .respondWithSuccess()
+ .complete()
+ .route(overviewPath(testData.extendedForms.last()))
+ .then(app => {
+ const title = app.find('.form-overview-step')[4].first('p');
+ title.hasClass('text-success').should.be.true();
+ }));
- it('shows the number of submissions', () => {
- const p = app.find('.form-overview-step')[3].find('p')[1];
- const count = testData.extendedForms.last().submissions;
- p.text().trim().should.containEql(count.toLocaleString());
+ describe('step stages', () => {
+ // Array of test cases
+ const cases = [
+ {
+ allAttachmentsExist: false,
+ hasSubmission: false,
+ formIsOpen: false,
+ completedSteps: [0, 4],
+ currentStep: 1
+ },
+ {
+ allAttachmentsExist: false,
+ hasSubmission: false,
+ formIsOpen: true,
+ completedSteps: [0],
+ currentStep: 1
+ },
+ {
+ allAttachmentsExist: false,
+ hasSubmission: true,
+ formIsOpen: false,
+ completedSteps: [0, 2, 4],
+ currentStep: 1
+ },
+ {
+ allAttachmentsExist: false,
+ hasSubmission: true,
+ formIsOpen: true,
+ completedSteps: [0, 2],
+ currentStep: 1
+ },
+ {
+ allAttachmentsExist: true,
+ hasSubmission: false,
+ formIsOpen: false,
+ completedSteps: [0, 1, 4],
+ currentStep: 2
+ },
+ {
+ allAttachmentsExist: true,
+ hasSubmission: false,
+ formIsOpen: true,
+ completedSteps: [0, 1],
+ currentStep: 2
+ },
+ {
+ allAttachmentsExist: true,
+ hasSubmission: true,
+ formIsOpen: false,
+ completedSteps: [0, 1, 2, 4],
+ currentStep: 3
+ },
+ {
+ allAttachmentsExist: true,
+ hasSubmission: true,
+ formIsOpen: true,
+ completedSteps: [0, 1, 2],
+ currentStep: 3
+ }
+ ];
+
+ // Tests the stages of the form overview steps for a single test case.
+ const testStepStages = ({ completedSteps, currentStep, ...loadOverviewArgs }) =>
+ loadOverview(loadOverviewArgs).afterResponses(app => {
+ const steps = app.find('.form-overview-step');
+ steps.length.should.equal(5);
+ for (let i = 0; i < steps.length; i += 1) {
+ const heading = steps[i].first('.form-overview-step-heading');
+ const icon = heading.first('.icon-check-circle');
+ if (completedSteps.includes(i)) {
+ heading.hasClass('text-success').should.be.true();
+ heading.hasClass('text-muted').should.be.false();
+ icon.hasClass('text-muted').should.be.false();
+ } else if (i === currentStep) {
+ heading.hasClass('text-success').should.be.false();
+ heading.hasClass('text-muted').should.be.false();
+ icon.hasClass('text-muted').should.be.true();
+ } else {
+ heading.hasClass('text-success').should.be.false();
+ heading.hasClass('text-muted').should.be.true();
+ icon.hasClass('text-muted').should.be.true();
+ }
+ }
});
- });
- });
-
- describe('step 3 indicates the number of app users', () => {
- it('no app users', () =>
- mockRoute(overviewPath(testData.extendedForms.createPast(1).last()))
- .respondWithData(() => testData.extendedForms.last())
- .respondWithData(() => testData.extendedFormAttachments.sorted())
- .respondWithData(() => testData.simpleFieldKeys.sorted())
- .afterResponses(app => {
- const p = app.find('.form-overview-step')[2].find('p')[1];
- p.text().trim().should.containEql('You do not have any App Users on this server yet');
- }));
- it('at least one app user', () =>
- mockRoute(overviewPath(testData.extendedForms.createPast(1).last()))
- .respondWithData(() => testData.extendedForms.last())
- .respondWithData(() => testData.extendedFormAttachments.sorted())
- .respondWithData(() => {
- const count = faker.random.number({ min: 1, max: 2000 });
- return testData.simpleFieldKeys.createPast(count).sorted();
- })
- .afterResponses(app => {
- const p = app.find('.form-overview-step')[2].find('p')[1];
- const count = testData.simpleFieldKeys.size;
- p.text().trim().should.containEql(` ${count.toLocaleString()} `);
- }));
- });
+ for (let i = 0; i < cases.length; i += 1) {
+ const testCase = cases[i];
+ describe(`case ${i}`, () => {
+ it('1 attachment', () =>
+ testStepStages({ ...cases[i], attachmentCount: 1 }));
- describe('step 5', () => {
- it('is not the current step, if the form is open', () =>
- mockRoute(overviewPath(testData.extendedForms.createPast(1, { isOpen: true }).last()))
- .respondWithData(() => testData.extendedForms.last())
- .respondWithData(() => testData.extendedFormAttachments.sorted())
- .respondWithData(() => testData.simpleFieldKeys.sorted())
- .afterResponses(app => {
- const title = app.find('.form-overview-step')[4].first('p');
- title.hasClass('text-muted').should.be.true();
- }));
-
- it('is marked as complete, if the form is not open', () =>
- mockRoute(overviewPath(testData.extendedForms.createPast(1, { isOpen: false }).last()))
- .respondWithData(() => testData.extendedForms.last())
- .respondWithData(() => testData.extendedFormAttachments.sorted())
- .respondWithData(() => testData.simpleFieldKeys.sorted())
- .afterResponses(app => {
- const title = app.find('.form-overview-step')[4].first('p');
- title.hasClass('text-success').should.be.true();
- }));
-
- it('is marked as complete if the state is changed from open', () =>
- mockRoute(overviewPath(testData.extendedForms.createPast(1, { isOpen: true }).last()))
- .respondWithData(() => testData.extendedForms.last())
- .respondWithData(() => testData.extendedFormAttachments.sorted())
- .respondWithData(() => testData.simpleFieldKeys.sorted())
- .afterResponses(app => {
- const title = app.find('.form-overview-step')[4].first('p');
- title.hasClass('text-success').should.be.false();
- })
- .route(`/forms/${testData.extendedForms.last().xmlFormId}/settings`)
- .request(app => {
- const formEdit = app.first('#form-edit');
- const closed = formEdit.first('input[type="radio"][value="closed"]');
- return trigger.change(closed).then(() => app);
- })
- .respondWithSuccess()
- .complete()
- .route(overviewPath(testData.extendedForms.last()))
- .then(app => {
- const title = app.find('.form-overview-step')[4].first('p');
- title.hasClass('text-success').should.be.true();
- }));
+ if (testCase.allAttachmentsExist) {
+ it('no attachments', () =>
+ testStepStages({ ...cases[i], attachmentCount: 0 }));
+ }
+ });
+ }
});
});
});
From c50488a94e817be919428f59ca5c40cbe148a9b7 Mon Sep 17 00:00:00 2001
From: Matthew White
Date: Thu, 9 Aug 2018 15:50:01 -0400
Subject: [PATCH 11/85] Add form attachment presenter
At the moment I am planning to use this mostly just for its `key`
getter.
---
lib/components/form/show.vue | 4 +++-
lib/presenters/form-attachment.js | 27 +++++++++++++++++++++++++++
2 files changed, 30 insertions(+), 1 deletion(-)
create mode 100644 lib/presenters/form-attachment.js
diff --git a/lib/components/form/show.vue b/lib/components/form/show.vue
index 935a8b948..448f8a59d 100644
--- a/lib/components/form/show.vue
+++ b/lib/components/form/show.vue
@@ -48,6 +48,7 @@ except according to the terms contained in the LICENSE file.
+
+
diff --git a/lib/components/form/attachment/row.vue b/lib/components/form/attachment/row.vue
new file mode 100644
index 000000000..26e9cafc8
--- /dev/null
+++ b/lib/components/form/attachment/row.vue
@@ -0,0 +1,55 @@
+
+
+
+
+ {{ attachment.type.replace(/^[a-z]/, (letter) => letter.toUpperCase()) }}
+ |
+ {{ attachment.name }} |
+
+
+ {{ updatedAt }}
+
+
+ Not yet uploaded
+
+ |
+
+
+
+
+
+
From 8488d16e064e8b12bcc848e70933662457bb86c3 Mon Sep 17 00:00:00 2001
From: Matthew White
Date: Thu, 9 Aug 2018 15:50:06 -0400
Subject: [PATCH 14/85] Add prefix to id and class attributes
---
lib/components/form/new.vue | 20 ++++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/lib/components/form/new.vue b/lib/components/form/new.vue
index 91849605a..bd4908493 100644
--- a/lib/components/form/new.vue
+++ b/lib/components/form/new.vue
@@ -21,7 +21,7 @@ except according to the terms contained in the LICENSE file.
design your form.
-