Skip to content

Commit

Permalink
Add pop-up with filename to EntityUpload (#955)
Browse files Browse the repository at this point in the history
  • Loading branch information
matthew-white committed Mar 15, 2024
1 parent 628644e commit 00d8409
Show file tree
Hide file tree
Showing 7 changed files with 278 additions and 12 deletions.
33 changes: 30 additions & 3 deletions src/components/entity/upload.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ except according to the terms contained in the LICENSE file.
<entity-upload-data-template/>
</div>
</entity-upload-file-select>
<div v-if="file != null" id="entity-upload-filename">{{ file.name }}</div>
<div class="modal-actions">
<button type="button" class="btn btn-primary"
:aria-disabled="file == null || awaitingResponse" @click="upload">
Expand All @@ -32,6 +31,12 @@ except according to the terms contained in the LICENSE file.
{{ $t('action.cancel') }}
</button>
</div>
<div v-if="file != null" id="entity-upload-popups">
<!-- TODO. Pass the actual count. -->
<entity-upload-popup :filename="file.name" :count="1"
:awaiting-response="awaitingResponse" :progress="uploadProgress"
@clear="clearFile"/>
</div>
</template>
</modal>
</template>
Expand All @@ -41,6 +46,7 @@ import { ref, watch } from 'vue';
import EntityUploadDataTemplate from './upload/data-template.vue';
import EntityUploadFileSelect from './upload/file-select.vue';
import EntityUploadPopup from './upload/popup.vue';
import Modal from '../modal.vue';
import SentenceSeparator from '../sentence-separator.vue';
import Spinner from '../spinner.vue';
Expand All @@ -62,24 +68,45 @@ const { dataset } = useRequestData();
const file = ref(null);
const selectFile = (value) => { file.value = value; };
watch(() => props.state, (state) => { if (!state) file.value = null; });
const clearFile = () => { file.value = null; };
watch(() => props.state, (state) => { if (!state) clearFile(); });
const { request, awaitingResponse } = useRequest();
const uploadProgress = ref(0);
const upload = () => {
request({
method: 'POST',
url: apiPaths.entities(dataset.projectId, dataset.name),
data: {
source: { name: file.value.name, size: file.value.size },
entities: []
}
},
onUploadProgress: (event) => { uploadProgress.value = event.progress ?? 0; }
})
// TODO. Emit the correct count.
.then(() => { emit('success', 1); })
.finally(() => { uploadProgress.value = 0; })
.catch(noop);
};
</script>
<style lang="scss">
@keyframes tocorner {
0% { transform: translate(-70px, -70px); }
100% { transform: translate(0, 0); }
}
#entity-upload-popups {
animation-duration: 2s;
animation-name: tocorner;
animation-timing-function: cubic-bezier(0.05, 0.9, 0, 1);
bottom: 70px;
position: absolute;
right: 15px;
width: 305px;
}
</style>
<i18n lang="json5">
{
"en": {
Expand Down
115 changes: 115 additions & 0 deletions src/components/entity/upload/popup.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<!--
Copyright 2024 ODK Central Developers
See the NOTICE file at the top-level directory of this distribution and at
https://github.com/getodk/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.
-->
<template>
<div id="entity-upload-popup">
<div id="entity-upload-popup-heading">
<div v-tooltip.text>{{ filename }}</div>
<button v-show="!awaitingResponse" type="button" class="close"
:aria-label="$t('action.clear')" @click="$emit('clear')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div id="entity-upload-popup-count">{{ $tcn('rowCount', count) }}</div>
<div v-show="awaitingResponse" id="entity-upload-popup-status">
<spinner :state="true" inline/><span>{{ status }}</span>
</div>
</div>
</template>

<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Spinner from '../../spinner.vue';
defineOptions({
name: 'EntityUploadPopup'
});
const props = defineProps({
filename: {
type: String,
required: true
},
count: {
type: Number,
required: true
},
awaitingResponse: Boolean,
progress: {
type: Number,
required: true
}
});
defineEmits(['clear']);
const { t, n } = useI18n();
const status = computed(() => (props.progress < 1
? t('status.sending', { percentUploaded: n(props.progress, 'percent') })
: t('status.processing')));
</script>

<style lang="scss">
@use 'sass:color';
@import '../../../assets/scss/mixins';
#entity-upload-popup {
background-color: $color-subpanel-background;
border: 2px solid $color-action-foreground;
border-radius: 6px;
outline: 5px solid #{color.change($color-action-foreground, $alpha: 0.15)};
padding: 15px;
}
#entity-upload-popup-heading {
align-items: baseline;
display: flex;
> div {
@include text-overflow-ellipsis;
font-size: 18px;
font-weight: bold;
}
.close {
flex-shrink: 0;
margin-left: 6px;
opacity: 0.5;
&:hover, &:focus { opacity: 0.2; }
}
}
#entity-upload-popup-status {
margin-bottom: 5px;
margin-top: 15px;
.spinner + span {
font-weight: bold;
margin-left: 6px;
}
}
</style>

<i18n lang="json5">
{
"en": {
"rowCount": "{count} data row found | {count} data rows found",
"status": {
// This text is shown while a file is being uploaded to the server.
"sending": "Sending file… ({percentUploaded})",
// This text is shown after a file has been uploaded to the server, but
// before the server has finished processing it.
"processing": "Processing file…"
}
}
}
</i18n>
18 changes: 12 additions & 6 deletions src/components/spinner.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,23 @@ 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.
-->

<!-- `Spinner` toggles a spinner according to its `state` prop. -->
<template>
<div :class="{ spinner: true, active: state }">
<div class="spinner" :class="{ inline, active: state }">
<div class="spinner-glyph"></div>
</div>
</template>

<script setup>
defineProps({
state: Boolean
// Determines whether the spinner is shown or not.
state: Boolean,
/* By default, a spinner is positioned in the center of its closest positioned
ancestor. However, in some cases, a spinner should not be positioned and
should be rendered inline. A spinner is sometimes rendered inline
automatically, for example, if the spinner is after a <select> element. You
can force a spinner to be rendered inline by specifying `true` for the
`inline` prop. */
inline: Boolean
});
</script>

Expand Down Expand Up @@ -53,14 +59,14 @@ $spinner-width: 3px;
transition-delay: 0.15s;
}
select + & {
select + &, &.inline {
display: inline-block;
left: 0;
margin-left: 7px;
position: relative;
top: 0;
vertical-align: text-top;
}
select + & { margin-left: 7px; }
}
.spinner-glyph {
height: $spinner-size;
Expand Down
19 changes: 16 additions & 3 deletions test/components/entity/upload.spec.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import EntityUpload from '../../../src/components/entity/upload.vue';
import EntityUploadPopup from '../../../src/components/entity/upload/popup.vue';
import OdataLoadingMessage from '../../../src/components/odata-loading-message.vue';

import testData from '../../data';
Expand Down Expand Up @@ -47,10 +48,12 @@ describe('EntityUpload', () => {
testData.extendedDatasets.createPast(1);
});

it('shows the filename', async () => {
it('shows the pop-up', async () => {
const modal = mountComponent();
await setFiles(modal.get('input'), [csv()]);
modal.get('#entity-upload-filename').text().should.equal('my_data.csv');
const popup = modal.getComponent(EntityUploadPopup);
popup.props().filename.should.equal('my_data.csv');
popup.props().count.should.equal(1);
});

it('hides the drop zone', async () => {
Expand All @@ -69,12 +72,22 @@ describe('EntityUpload', () => {
button.attributes('aria-disabled').should.equal('false');
});

it('resets after the clear button is clicked', async () => {
const modal = mountComponent();
await setFiles(modal.get('input'), [csv()]);
await modal.get('#entity-upload-popup .close').trigger('click');
modal.findComponent(EntityUploadPopup).exists().should.be.false();
modal.get('#entity-upload-file-select').should.be.visible();
const button = modal.get('.modal-actions .btn-primary');
button.attributes('aria-disabled').should.equal('true');
});

it('resets after the modal is hidden', async () => {
const modal = mountComponent();
await setFiles(modal.get('input'), [csv()]);
await modal.setProps({ state: false });
await modal.setProps({ state: true });
modal.find('#entity-upload-filename').exists().should.be.false();
modal.findComponent(EntityUploadPopup).exists().should.be.false();
modal.get('#entity-upload-file-select').should.be.visible();
const button = modal.get('.modal-actions .btn-primary');
button.attributes('aria-disabled').should.equal('true');
Expand Down
71 changes: 71 additions & 0 deletions test/components/entity/upload/popup.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import EntityUploadPopup from '../../../../src/components/entity/upload/popup.vue';

import { mergeMountOptions, mount } from '../../../util/lifecycle';

const mountComponent = (options = undefined) =>
mount(EntityUploadPopup, mergeMountOptions(options, {
props: { filename: 'my_data.csv', count: 1, progress: 0 }
}));

describe('EntityUploadPopup', () => {
it('shows the filename', async () => {
const div = mountComponent().get('#entity-upload-popup-heading div');
div.text().should.equal('my_data.csv');
await div.should.have.textTooltip();
});

describe('clear button', () => {
it('emits a clear event if it is clicked', async () => {
const component = mountComponent();
await component.get('.close').trigger('click');
component.emitted().clear.should.eql([[]]);
});

it('is hidden if the awaitingResponse prop is true', () => {
const component = mountComponent({
props: { awaitingResponse: true }
});
component.get('.close').should.be.hidden();
});
});

it('shows the count', () => {
const component = mountComponent({
props: { count: 1000 }
});
const text = component.get('#entity-upload-popup-count').text();
text.should.equal('1,000 data rows found');
});

describe('request status', () => {
it('does not show a status if there is no request', () => {
const component = mountComponent({
props: { awaitingResponse: false }
});
component.get('#entity-upload-popup-status').should.be.hidden();
});

it('shows the status during a request', () => {
const component = mountComponent({
props: { awaitingResponse: true }
});
component.get('#entity-upload-popup-status').should.be.visible();
});

it('shows the upload progress', () => {
const component = mountComponent({
props: { awaitingResponse: true, progress: 0.5 }
});
const text = component.get('#entity-upload-popup-status').text();
text.should.equal('Sending file… (50%)');
});

it('changes the status once all data has been sent', () => {
const component = mountComponent({
props: { awaitingResponse: true, progress: 1 }
});
const text = component.get('#entity-upload-popup-status').text();
text.should.equal('Processing file…');
});
});
});
19 changes: 19 additions & 0 deletions test/components/spinner.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Spinner from '../../src/components/spinner.vue';

import { mount } from '../util/lifecycle';

describe('Spinner', () => {
it('adds the correct class if the state prop is true', () => {
const spinner = mount(Spinner, {
props: { state: true }
});
spinner.classes('active').should.be.true();
});

it('adds the correct class if the inline prop is true', () => {
const spinner = mount(Spinner, {
props: { inline: true }
});
spinner.classes('inline').should.be.true();
});
});
15 changes: 15 additions & 0 deletions transifex/strings_en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1982,6 +1982,21 @@
}
}
},
"EntityUploadPopup": {
"rowCount": {
"string": "{count, plural, one {{count} data row found} other {{count} data rows found}}"
},
"status": {
"sending": {
"string": "Sending file… ({percentUploaded})",
"developer_comment": "This text is shown while a file is being uploaded to the server."
},
"processing": {
"string": "Processing file…",
"developer_comment": "This text is shown after a file has been uploaded to the server, but before the server has finished processing it."
}
}
},
"EntityVersionLink": {
"submission": {
"string": "Submission {instanceName}",
Expand Down

0 comments on commit 00d8409

Please sign in to comment.