Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add pop-up with filename to EntityUpload #955

Merged
merged 3 commits into from
Mar 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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