Skip to content

Commit f81001f

Browse files
Merge pull request #60 from nafundi/field-keys
Field key stories
2 parents 16d1d34 + 27ffe45 commit f81001f

File tree

13 files changed

+524
-129
lines changed

13 files changed

+524
-129
lines changed

lib/bootstrap.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ https://www.apache.org/licenses/LICENSE-2.0. No part of Super Adventure,
99
including this file, may be copied, modified, propagated, or distributed
1010
except according to the terms contained in the LICENSE file.
1111
*/
12-
import 'bootstrap/js/collapse'; // eslint-disable-line import/first
13-
import 'bootstrap/js/dropdown'; // eslint-disable-line import/first
14-
import 'bootstrap/js/modal'; // eslint-disable-line import/first
15-
import 'bootstrap/js/tab'; // eslint-disable-line import/first
16-
import 'bootstrap/js/transition'; // eslint-disable-line import/first
12+
import 'bootstrap/js/transition';
13+
import 'bootstrap/js/collapse';
14+
import 'bootstrap/js/dropdown';
15+
import 'bootstrap/js/modal';
16+
import 'bootstrap/js/tooltip';
17+
import 'bootstrap/js/popover';

lib/components/app.vue

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,4 +161,17 @@ body {
161161
.panel-main {
162162
margin-top: 70px;
163163
}
164+
165+
.popover {
166+
background-color: $color-action-background;
167+
padding: 0;
168+
169+
&.left .arrow::after {
170+
border-left-color: $color-action-background;
171+
}
172+
173+
.popover-content {
174+
padding: 0;
175+
}
176+
}
164177
</style>

lib/components/field-key/list.vue

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
<!--
2+
Copyright 2017 Super Adventure Developers
3+
See the NOTICE file at the top-level directory of this distribution and at
4+
https://github.com/nafundi/super-adventure/blob/master/NOTICE.
5+
6+
This file is part of Super Adventure. It is subject to the license terms in
7+
the LICENSE file found in the top-level directory of this distribution and at
8+
https://www.apache.org/licenses/LICENSE-2.0. No part of Super Adventure,
9+
including this file, may be copied, modified, propagated, or distributed
10+
except according to the terms contained in the LICENSE file.
11+
-->
12+
<template>
13+
<div>
14+
<alert v-bind="alert" @close="alert.state = false"/>
15+
<float-row>
16+
<button type="button" class="btn btn-primary" @click="newFieldKey.state = true">
17+
<span class="icon-plus-circle"></span> Create Field Key
18+
</button>
19+
</float-row>
20+
<loading v-if="fieldKeys == null" :state="awaitingResponse"/>
21+
<p v-else-if="fieldKeys.length === 0">
22+
There are no field keys yet. You will need to create some to download
23+
forms and submit data from your device.
24+
</p>
25+
<table v-else id="field-key-list-table" class="table table-hover">
26+
<thead>
27+
<tr>
28+
<th>Nickname</th>
29+
<th>Created</th>
30+
<th>Last Used</th>
31+
<th>Auto-Configure</th>
32+
</tr>
33+
</thead>
34+
<tbody>
35+
<tr v-for="(fieldKey, index) in fieldKeys" :key="fieldKey.key"
36+
:class="highlight(fieldKey, 'id')" :data-index="index">
37+
<td>{{ fieldKey.displayName }}</td>
38+
<td>{{ fieldKey.created }}</td>
39+
<td>{{ fieldKey.lastUsed }}</td>
40+
<td>
41+
<a class="field-key-list-popover-link" role="button">See code</a>
42+
</td>
43+
</tr>
44+
</tbody>
45+
</table>
46+
47+
<field-key-new v-bind="newFieldKey" @hide="newFieldKey.state = false"
48+
@success="afterCreate"/>
49+
</div>
50+
</template>
51+
52+
<script>
53+
import Vue from 'vue';
54+
import moment from 'moment';
55+
import qrcode from 'qrcode-generator';
56+
import { deflate } from 'pako/lib/deflate';
57+
58+
import FieldKeyNew from './new.vue';
59+
import alert from '../../mixins/alert';
60+
import highlight from '../../mixins/highlight';
61+
import request from '../../mixins/request';
62+
63+
const QR_CODE_TYPE_NUMBER = 0;
64+
// This is the level used in Collect.
65+
const QR_CODE_ERROR_CORRECTION_LEVEL = 'L';
66+
const QR_CODE_CELL_SIZE = 3;
67+
const QR_CODE_MARGIN = 0;
68+
69+
class FieldKeyPresenter {
70+
constructor(fieldKey) {
71+
this._fieldKey = fieldKey;
72+
}
73+
74+
get id() { return this._fieldKey.id; }
75+
get displayName() { return this._fieldKey.displayName; }
76+
77+
get key() {
78+
if (this._key != null) return this._key;
79+
this._key = Vue.prototype.$uniqueId();
80+
return this._key;
81+
}
82+
83+
get created() {
84+
const createdAt = moment(this._fieldKey.createdAt).fromNow();
85+
const createdBy = this._fieldKey.createdBy.displayName;
86+
return `${createdAt} by ${createdBy}`;
87+
}
88+
89+
get lastUsed() {
90+
const { lastUsed } = this._fieldKey;
91+
return lastUsed != null ? moment(lastUsed).fromNow() : '';
92+
}
93+
94+
get url() {
95+
return `${window.location.origin}/api/v1/key/${this._fieldKey.token}`;
96+
}
97+
98+
get qrCodeImgHtml() {
99+
if (this._qrCodeImgHtml != null) return this._qrCodeImgHtml;
100+
const code = qrcode(QR_CODE_TYPE_NUMBER, QR_CODE_ERROR_CORRECTION_LEVEL);
101+
// Collect requires the JSON to have 'general' and 'admin' keys, even if the
102+
// associated values are empty objects.
103+
const settings = { general: { server_url: this.url }, admin: {} };
104+
const deflated = deflate(JSON.stringify(settings), { to: 'string' });
105+
code.addData(btoa(deflated));
106+
code.make();
107+
this._qrCodeImgHtml = code.createImgTag(QR_CODE_CELL_SIZE, QR_CODE_MARGIN);
108+
return this._qrCodeImgHtml;
109+
}
110+
}
111+
112+
const POPOVER_CONTENT_TEMPLATE = `
113+
<div id="field-key-list-popover-content">
114+
<div class="field-key-list-img-container"></div>
115+
<div>
116+
<a href="https://docs.opendatakit.org/collect-import-export/" target="_blank">
117+
What’s this?
118+
</a>
119+
</div>
120+
</div>
121+
`;
122+
123+
export default {
124+
name: 'FieldKeyList',
125+
components: { FieldKeyNew },
126+
mixins: [alert(), request(), highlight()],
127+
data() {
128+
return {
129+
alert: alert.blank(),
130+
requestId: null,
131+
fieldKeys: null,
132+
highlighted: null,
133+
enabledPopoverLinks: new Set(),
134+
// The <a> element whose popover is currently shown, as a jQuery object.
135+
popoverLink: null,
136+
newFieldKey: {
137+
state: false
138+
}
139+
};
140+
},
141+
watch: {
142+
alert() {
143+
this.$emit('alert');
144+
}
145+
},
146+
created() {
147+
this.fetchData();
148+
},
149+
mounted() {
150+
$('body').click(this.toggleFieldKeyListPopovers);
151+
},
152+
beforeDestroy() {
153+
$('body').off('click', this.toggleFieldKeyListPopovers);
154+
},
155+
methods: {
156+
fetchData() {
157+
this.fieldKeys = null;
158+
this.enabledPopoverLinks = new Set();
159+
const headers = { 'X-Extended-Metadata': 'true' };
160+
this
161+
.get('/field-keys', { headers })
162+
.then(fieldKeys => {
163+
this.fieldKeys = fieldKeys.map(fieldKey => new FieldKeyPresenter(fieldKey));
164+
})
165+
.catch(() => {});
166+
},
167+
popoverContent(fieldKey) {
168+
const content = $(POPOVER_CONTENT_TEMPLATE);
169+
content.find('.field-key-list-img-container').append(fieldKey.qrCodeImgHtml);
170+
return content[0].outerHTML;
171+
},
172+
enablePopover(popoverLink) {
173+
const index = popoverLink.closest('tr').data('index');
174+
if (this.enabledPopoverLinks.has(index)) return;
175+
popoverLink.popover({
176+
container: 'body',
177+
trigger: 'manual',
178+
placement: 'left',
179+
content: this.popoverContent(this.fieldKeys[index]),
180+
html: true
181+
});
182+
this.enabledPopoverLinks.add(index);
183+
},
184+
showPopover(popoverLink) {
185+
this.enablePopover(popoverLink);
186+
popoverLink.popover('show');
187+
this.popoverLink = popoverLink;
188+
},
189+
hidePopover() {
190+
if (this.popoverLink == null) return;
191+
this.popoverLink.popover('hide');
192+
this.popoverLink = null;
193+
},
194+
popoverContainsElement(element) {
195+
if (this.popoverLink == null) return false;
196+
const popover = $('#field-key-list-popover-content').closest('.popover');
197+
return element[0] === popover[0] || $.contains(popover[0], element[0]);
198+
},
199+
// This method's name should be unique, because jQuery off() uses the name
200+
// of the function passed to it.
201+
toggleFieldKeyListPopovers(event) {
202+
const target = $(event.target);
203+
if (target.hasClass('field-key-list-popover-link')) {
204+
// true if the user clicked on the link whose popover is currently shown
205+
// and false if not.
206+
const samePopover = this.popoverLink != null &&
207+
event.target === this.popoverLink[0];
208+
if (!samePopover) {
209+
this.hidePopover();
210+
this.showPopover(target);
211+
}
212+
} else if (this.popoverLink != null && !this.popoverContainsElement(target)) {
213+
this.hidePopover();
214+
}
215+
},
216+
afterCreate(fieldKey) {
217+
this.fetchData();
218+
this.alert = alert.success(`The field key “${fieldKey.displayName}” was created successfully.`);
219+
this.highlighted = fieldKey.id;
220+
}
221+
}
222+
};
223+
</script>
224+
225+
<style lang="sass">
226+
@import '../../../assets/scss/variables';
227+
228+
#field-key-list-table tbody td {
229+
vertical-align: middle;
230+
}
231+
232+
#field-key-list-popover-content {
233+
padding: 3px;
234+
235+
.field-key-list-img-container {
236+
border: 3px solid $color-subpanel-border;
237+
margin-bottom: 3px;
238+
}
239+
240+
a {
241+
color: white;
242+
}
243+
}
244+
</style>

lib/components/field-key/new.vue

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<!--
2+
Copyright 2017 Super Adventure Developers
3+
See the NOTICE file at the top-level directory of this distribution and at
4+
https://github.com/nafundi/super-adventure/blob/master/NOTICE.
5+
6+
This file is part of Super Adventure. It is subject to the license terms in
7+
the LICENSE file found in the top-level directory of this distribution and at
8+
https://www.apache.org/licenses/LICENSE-2.0. No part of Super Adventure,
9+
including this file, may be copied, modified, propagated, or distributed
10+
except according to the terms contained in the LICENSE file.
11+
-->
12+
<template>
13+
<modal :state="state" @hide="$emit('hide')" @shown="focusField" backdrop>
14+
<template slot="title">Create Field Key</template>
15+
<template slot="body">
16+
<alert v-bind="alert" @close="alert.state = false"/>
17+
<form @submit.prevent="submit">
18+
<label class="form-group">
19+
<input v-model.trim="nickname" ref="nickname" class="form-control"
20+
placeholder="Nickname *" required :disabled="awaitingResponse">
21+
<span class="form-label">Nickname *</span>
22+
</label>
23+
<div class="modal-actions">
24+
<button type="submit" class="btn btn-primary" :disabled="awaitingResponse">
25+
Create <spinner :state="awaitingResponse"/>
26+
</button>
27+
<button type="button" class="btn btn-link" @click="$emit('hide')">
28+
Close
29+
</button>
30+
</div>
31+
</form>
32+
</template>
33+
</modal>
34+
</template>
35+
36+
<script>
37+
import alert from '../../mixins/alert';
38+
import request from '../../mixins/request';
39+
40+
export default {
41+
name: 'FieldKeyNew',
42+
mixins: [alert(), request()],
43+
props: {
44+
state: {
45+
type: Boolean,
46+
default: false
47+
}
48+
},
49+
data() {
50+
return {
51+
alert: alert.blank(),
52+
requestId: null,
53+
nickname: ''
54+
};
55+
},
56+
methods: {
57+
focusField() {
58+
this.$refs.nickname.focus();
59+
},
60+
submit() {
61+
this
62+
.post('/field-keys', { displayName: this.nickname })
63+
.then(fieldKey => {
64+
this.$emit('hide');
65+
this.alert = alert.blank();
66+
this.nickname = '';
67+
this.$emit('success', fieldKey);
68+
})
69+
.catch(() => {});
70+
}
71+
}
72+
};
73+
</script>

lib/components/form/show.vue

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,11 @@ except according to the terms contained in the LICENSE file.
4141
<script>
4242
import alert from '../../mixins/alert';
4343
import request from '../../mixins/request';
44+
import tab from '../../mixins/tab';
4445

4546
export default {
4647
name: 'FormShow',
47-
mixins: [alert(), request()],
48+
mixins: [alert(), request(), tab()],
4849
data() {
4950
return {
5051
alert: alert.blank(),
@@ -76,19 +77,8 @@ export default {
7677
})
7778
.catch(() => {});
7879
},
79-
tabPath(path) {
80-
const slash = path !== '' ? '/' : '';
81-
return `/forms/${this.xmlFormId}${slash}${path}`;
82-
},
83-
tabClass(path) {
84-
return { active: this.$route.path === this.tabPath(path) };
85-
},
86-
// FormShow shows any alert passed from the previous page (the page that
87-
// navigated to FormShow). However, once a component of FormShow indicates
88-
// through an 'alert' event that it will show its own alert, FormShow hides
89-
// the alert from the previous page.
90-
hideAlert() {
91-
this.alert = alert.blank();
80+
tabPathPrefix() {
81+
return `/forms/${this.xmlFormId}`;
9282
}
9383
}
9484
};

0 commit comments

Comments
 (0)