Commit 4414cca7 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '326362-corpus-upload' into 'master'

Implement corpus upload modal

See merge request gitlab-org/gitlab!58959
parents b4821264 57ea5553
<script>
import { GlButton, GlSprintf } from '@gitlab/ui';
import { GlButton, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { s__ } from '~/locale';
import { s__, __ } from '~/locale';
import addCorpusMutation from '../graphql/mutations/add_corpus.mutation.graphql';
import resetCorpus from '../graphql/mutations/reset_corpus.mutation.graphql';
import getCorpusesQuery from '../graphql/queries/get_corpuses.query.graphql';
import CorpusUploadForm from './corpus_upload_form.vue';
export default {
components: {
GlButton,
GlSprintf,
GlButton,
GlModal,
CorpusUploadForm,
},
directives: {
GlModalDirective,
},
i18n: {
totalSize: s__('CorpusManagement|Total Size: %{totalSize}'),
newUpload: s__('CorpusManagement|New upload'),
newCorpus: s__('CorpusMnagement|New corpus'),
},
inject: ['projectFullPath', 'corpusHelpPath'],
apollo: {
states: {
query: getCorpusesQuery,
variables() {
return {
projectPath: this.projectFullPath,
...this.cursor,
};
},
update(data) {
return data;
},
},
},
modal: {
actionCancel: {
text: __('Cancel'),
},
modalId: 'corpus-upload-modal',
},
props: {
totalSize: {
......@@ -14,18 +49,39 @@ export default {
required: true,
},
},
i18n: {
totalSize: s__('CorpusManagement|Total Size: %{totalSize}'),
newCorpus: s__('CorpusManagement|New corpus'),
},
computed: {
formattedFileSize() {
return numberToHumanSize(this.totalSize);
},
isUploaded() {
return this.states?.uploadState.progress === 100;
},
variant() {
return this.isUploaded ? 'confirm' : 'default';
},
actionPrimaryProps() {
return {
text: __('Add'),
attributes: {
'data-testid': 'modal-confirm',
disabled: !this.isUploaded,
variant: this.variant,
},
};
},
},
methods: {
newCorpus() {
this.$emit('newcorpus');
addCorpus() {
this.$apollo.mutate({
mutation: addCorpusMutation,
variables: { name: this.$options.i18n.newCorpus, projectPath: this.projectFullPath },
});
},
resetCorpus() {
this.$apollo.mutate({
mutation: resetCorpus,
variables: { name: '', projectPath: this.projectFullPath },
});
},
},
};
......@@ -42,8 +98,20 @@ export default {
</gl-sprintf>
</div>
<gl-button class="gl-mr-5" category="primary" variant="confirm" @click="newCorpus">
<gl-button v-gl-modal-directive="$options.modal.modalId" class="gl-mr-5" variant="confirm">
{{ this.$options.i18n.newCorpus }}
</gl-button>
<gl-modal
:modal-id="$options.modal.modalId"
:title="$options.i18n.newCorpus"
size="sm"
:action-primary="actionPrimaryProps"
:action-cancel="$options.modal.actionCancel"
@primary="addCorpus"
@canceled="resetCorpus"
>
<corpus-upload-form />
</gl-modal>
</div>
</template>
<script>
import {
GlForm,
GlFormInput,
GlFormInputGroup,
GlButton,
GlLoadingIcon,
GlFormGroup,
} from '@gitlab/ui';
import { s__, __, sprintf } from '~/locale';
import { VALID_CORPUS_MIMETYPE } from '../constants';
import resetCorpus from '../graphql/mutations/reset_corpus.mutation.graphql';
import uploadCorpus from '../graphql/mutations/upload_corpus.mutation.graphql';
import getCorpusesQuery from '../graphql/queries/get_corpuses.query.graphql';
export default {
components: {
GlForm,
GlFormGroup,
GlFormInput,
GlLoadingIcon,
GlFormInputGroup,
GlButton,
},
inject: ['projectFullPath'],
i18n: {
corpusName: s__('CorpusManagement|Corpus name'),
uploadButtonText: __('Choose File...'),
uploadMessage: s__(
'CorpusManagement|New corpus needs to be a upload in *.zip format. Maximum 10Gib',
),
},
apollo: {
states: {
query: getCorpusesQuery,
variables() {
return {
projectPath: this.projectFullPath,
};
},
update(data) {
return data;
},
error() {
this.states = null;
},
},
},
data() {
return {
attachmentName: '',
corpusName: '',
files: [],
uploadTimeout: null,
};
},
computed: {
hasAttachment() {
return Boolean(this.attachmentName);
},
isShowingAttachmentName() {
return this.hasAttachment && !this.isLoading;
},
isShowingAttachmentCancel() {
return !this.isUploaded && !this.isUploading;
},
isUploading() {
return this.states?.uploadState.isUploading;
},
isUploaded() {
return this.states?.uploadState.progress === 100;
},
showUploadButton() {
return this.hasAttachment && !this.isUploading && !this.isUploaded;
},
showFilePickerButton() {
return !this.isUploaded;
},
progress() {
return this.states?.uploadState.progress;
},
progressText() {
return sprintf(__('Attaching File - %{progress}'), { progress: `${this.progress}%` });
},
},
beforeDestroy() {
this.resetAttachment();
this.cancelUpload();
},
methods: {
clearName() {
this.corpusName = '';
},
resetAttachment() {
this.$refs.fileUpload.value = null;
this.attachmentName = '';
this.files = [];
},
cancelUpload() {
clearTimeout(this.uploadTimeout);
this.$apollo.mutate({
mutation: resetCorpus,
variables: { name: this.corpusName, projectPath: this.projectFullPath },
});
},
openFileUpload() {
this.$refs.fileUpload.click();
},
beginFileUpload() {
// const component = this;
// Simulate incrementing file upload progress
return this.$apollo
.mutate({
mutation: uploadCorpus,
variables: { name: this.corpusName, projectPath: this.projectFullPath },
})
.then(({ data }) => {
if (data.uploadCorpus < 100) {
this.uploadTimeout = setTimeout(() => {
this.beginFileUpload();
}, 500);
}
});
},
onFileUploadChange(e) {
this.attachmentName = e.target.files[0].name;
this.files = e.target.files;
},
},
VALID_CORPUS_MIMETYPE,
};
</script>
<template>
<gl-form>
<gl-form-group :label="$options.i18n.corpusName" label-size="sm" label-for="corpus-name">
<gl-form-input-group>
<gl-form-input
id="corpus-name"
ref="input"
v-model="corpusName"
data-testid="corpus-name"
/>
<gl-button
variant="default"
category="tertiary"
size="small"
name="clear"
title="title"
icon="clear"
:aria-label="__(`Clear`)"
@click="clearName"
/>
</gl-form-input-group>
</gl-form-group>
<gl-form-group :label="$options.i18n.corpusName" label-size="sm" label-for="corpus-file">
<gl-button
v-if="showFilePickerButton"
data-testid="upload-attachment-button"
:disabled="isUploading"
@click="openFileUpload"
>
{{ this.$options.i18n.uploadButtonText }}
</gl-button>
<span v-if="isShowingAttachmentName" class="gl-ml-3">
{{ attachmentName }}
<gl-button
v-if="isShowingAttachmentCancel"
size="small"
icon="close"
category="tertiary"
@click="resetAttachment"
/>
</span>
<input
ref="fileUpload"
type="file"
name="corpus_file"
:accept="$options.VALID_CORPUS_MIMETYPE.mimetype"
class="gl-display-none"
@change="onFileUploadChange"
/>
</gl-form-group>
<span>{{ this.$options.i18n.uploadMessage }}</span>
<gl-button
v-if="showUploadButton"
data-testid="upload-corpus"
class="gl-mt-2"
variant="success"
@click="beginFileUpload"
>
{{ __('Upload file') }}
</gl-button>
<div v-if="isUploading" data-testid="upload-status" class="gl-mt-2">
<gl-loading-icon inline size="sm" />
{{ progressText }}
<gl-button size="small" @click="cancelUpload"> {{ __('Cancel') }} </gl-button>
</div>
</gl-form>
</template>
export const MAX_LIST_COUNT = 25;
export const VALID_CORPUS_MIMETYPE = {
mimetype: 'application/zip',
};
mutation addCorpus($projectPath: ID!, $name: String!) {
addCorpus(projectPath: $projectPath, name: $name) @client {
errors
}
}
mutation resetCorpus($projectPath: ID!, $name: String!) {
resetCorpus(projectPath: $projectPath, name: $name) @client {
errors
}
}
mutation uploadCorpus($projectPath: ID!, $name: String!) {
uploadCorpus(projectPath: $projectPath, name: $name) @client {
errors
}
}
......@@ -3,4 +3,8 @@ query getCorpuses($projectPath: ID!) {
data
totalSize
}
uploadState(projectPath: $projectPath) @client {
isUploading
progress
}
}
import produce from 'immer';
import getCorpusesQuery from 'ee/security_configuration/corpus_management/graphql/queries/get_corpuses.query.graphql';
import { corpuses } from 'ee_jest/security_configuration/corpus_management/mock_data';
import getCorpusesQuery from '../queries/get_corpuses.query.graphql';
export default {
Query: {
......@@ -13,8 +13,44 @@ export default {
__typename: 'MockedPackages',
};
},
/* eslint-disable no-unused-vars */
uploadState(_, { projectPath }) {
return {
isUploading: false,
progress: 0,
__typename: 'UploadState',
};
},
},
Mutation: {
addCorpus: (_, { name, projectPath }, { cache }) => {
const sourceData = cache.readQuery({
query: getCorpusesQuery,
variables: { projectPath },
});
const data = produce(sourceData, (draftState) => {
draftState.uploadState.isUploading = false;
draftState.uploadState.progress = 0;
draftState.mockedPackages.data = [
...draftState.mockedPackages.data,
{
name,
lastUpdated: new Date().toString(),
lastUsed: new Date().toString(),
latestJobPath: '',
target: '',
downloadPath: 'farias-gl/go-fuzzing-example/-/jobs/959593462/artifacts/download',
size: 4e8,
__typename: 'CorpusData',
},
];
draftState.mockedPackages.totalSize += 4e8;
});
cache.writeQuery({ query: getCorpusesQuery, data, variables: { projectPath } });
},
deleteCorpus: (_, { name, projectPath }, { cache }) => {
const sourceData = cache.readQuery({
query: getCorpusesQuery,
......@@ -33,6 +69,40 @@ export default {
}, 0);
});
cache.writeQuery({ query: getCorpusesQuery, data, variables: { projectPath } });
},
uploadCorpus: (_, { name, projectPath }, { cache }) => {
const sourceData = cache.readQuery({
query: getCorpusesQuery,
variables: { projectPath },
});
const data = produce(sourceData, (draftState) => {
const { uploadState } = draftState;
uploadState.isUploading = true;
// Simulate incrementing file upload progress
uploadState.progress += 10;
if (uploadState.progress >= 100) {
uploadState.isUploading = false;
}
});
cache.writeQuery({ query: getCorpusesQuery, data, variables: { projectPath } });
return data.uploadState.progress;
},
resetCorpus: (_, { name, projectPath }, { cache }) => {
const sourceData = cache.readQuery({
query: getCorpusesQuery,
variables: { projectPath },
});
const data = produce(sourceData, (draftState) => {
const { uploadState } = draftState;
uploadState.isUploading = false;
uploadState.progress = 0;
});
cache.writeQuery({ query: getCorpusesQuery, data, variables: { projectPath } });
},
},
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Corpus upload modal corpus modal uploading state does show the upload progress 1`] = `
<div
class="gl-mt-2"
data-testid="upload-status"
>
<span
class="gl-spinner-container"
>
<span
aria-label="Loading"
class="align-text-bottom gl-spinner gl-spinner-dark gl-spinner-sm"
/>
</span>
Attaching File - 25%
<button
class="btn btn-default btn-sm gl-button"
type="button"
>
<!---->
<!---->
<span
class="gl-button-text"
>
Cancel
</span>
</button>
</div>
`;
......@@ -17,12 +17,27 @@ exports[`Corpus Upload component renders header 1`] = `
category="primary"
class="gl-mr-5"
icon=""
role="button"
size="medium"
tabindex="0"
variant="confirm"
>
New corpus
</gl-button-stub>
<gl-modal-stub
actioncancel="[object Object]"
actionprimary="[object Object]"
dismisslabel="Close"
modalclass=""
modalid="corpus-upload-modal"
size="sm"
title="New corpus"
titletag="h4"
>
<corpus-upload-form-stub />
</gl-modal-stub>
</div>
`;
import { createLocalVue, mount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import CorpusUploadForm from 'ee/security_configuration/corpus_management/components/corpus_upload_form.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
const TEST_PROJECT_FULL_PATH = '/namespace/project';
const localVue = createLocalVue();
localVue.use(VueApollo);
let mockTotalSize;
let mockData;
let mockIsUploading;
let mockProgress;
const mockResolver = {
Query: {
/* eslint-disable no-unused-vars */
mockedPackages(_, { projectPath }) {
return {
totalSize: mockTotalSize(),
data: mockData(),
__typename: 'MockedPackages',
};
},
/* eslint-disable no-unused-vars */
uploadState(_, { projectPath }) {
return {
isUploading: mockIsUploading(),
progress: mockProgress(),
__typename: 'UploadState',
};
},
},
};
describe('Corpus upload modal', () => {
let wrapper;
const findCorpusName = () => wrapper.find('[data-testid="corpus-name"]');
const findUploadAttachment = () => wrapper.find('[data-testid="upload-attachment-button"]');
const findUploadCorpus = () => wrapper.find('[data-testid="upload-corpus"]');
const findUploadStatus = () => wrapper.find('[data-testid="upload-status"]');
const createMockApolloProvider = (resolverMock) => {
return createMockApollo([], resolverMock);
};
const createComponent = (resolverMock, options = {}) => {
wrapper = mount(CorpusUploadForm, {
localVue,
apolloProvider: createMockApolloProvider(resolverMock),
provide: {
projectFullPath: TEST_PROJECT_FULL_PATH,
},
...options,
});
};
beforeEach(() => {
mockTotalSize = jest.fn();
mockData = jest.fn();
mockIsUploading = jest.fn();
mockProgress = jest.fn();
});
afterEach(() => {
wrapper.destroy();
});
describe('corpus modal', () => {
describe('initial state', () => {
beforeEach(() => {
const data = () => {
return {
attachmentName: '',
corpusName: '',
files: [],
uploadTimeout: null,
};
};
mockTotalSize.mockResolvedValue(0);
mockData.mockResolvedValue([]);
mockIsUploading.mockResolvedValue(false);
mockProgress.mockResolvedValue(0);
createComponent(mockResolver, { data });
});
it('shows empty name field', () => {
expect(findCorpusName().element.value).toBe('');
});
it('shows the choose file button', () => {
expect(findUploadAttachment().exists()).toBe(true);
});
it('does not show the upload corpus button', () => {
expect(findUploadCorpus().exists()).toBe(false);
});
it('does not show the upload progress', () => {
expect(findUploadStatus().exists()).toBe(false);
});
});
describe('file selected state', () => {
const attachmentName = 'corpus.zip';
const corpusName = 'User entered name';
beforeEach(() => {
const data = () => {
return {
attachmentName,
corpusName,
files: [attachmentName],
uploadTimeout: null,
};
};
mockTotalSize.mockResolvedValue(0);
mockData.mockResolvedValue([]);
mockIsUploading.mockResolvedValue(false);
mockProgress.mockResolvedValue(0);
createComponent(mockResolver, { data });
});
it('shows name field', () => {
expect(findCorpusName().element.value).toBe(corpusName);
});
it('shows the choose file button', () => {
expect(findUploadAttachment().exists()).toBe(true);
});
it('shows the upload corpus button', () => {
expect(findUploadCorpus().exists()).toBe(true);
});
it('does not show the upload progress', () => {
expect(findUploadStatus().exists()).toBe(false);
});
});
describe('uploading state', () => {
const attachmentName = 'corpus.zip';
const corpusName = 'User entered name';
beforeEach(async () => {
const data = () => {
return {
attachmentName,
corpusName,
files: [attachmentName],
uploadTimeout: null,
};
};
mockTotalSize.mockResolvedValue(0);
mockData.mockResolvedValue([]);
mockIsUploading.mockResolvedValue(true);
mockProgress.mockResolvedValue(25);
createComponent(mockResolver, { data });
await waitForPromises();
});
it('shows name field', () => {
expect(findCorpusName().element.value).toBe(corpusName);
});
it('shows the choose file button as disabled', () => {
expect(findUploadAttachment().exists()).toBe(true);
expect(findUploadAttachment().attributes('disabled')).toBe('disabled');
});
it('does not show the upload corpus button', () => {
expect(findUploadCorpus().exists()).toBe(false);
});
it('does show the upload progress', () => {
expect(findUploadStatus().exists()).toBe(true);
expect(findUploadStatus().element).toMatchSnapshot();
});
});
describe('file uploaded state', () => {
const attachmentName = 'corpus.zip';
const corpusName = 'User entered name';
beforeEach(async () => {
const data = () => {
return {
attachmentName,
corpusName,
files: [attachmentName],
uploadTimeout: null,
};
};
mockTotalSize.mockResolvedValue(0);
mockData.mockResolvedValue([]);
mockIsUploading.mockResolvedValue(false);
mockProgress.mockResolvedValue(100);
createComponent(mockResolver, { data });
await waitForPromises();
});
it('shows name field', () => {
expect(findCorpusName().element.value).toBe(corpusName);
});
it('does not show the choose file button', () => {
expect(findUploadAttachment().exists()).toBe(false);
});
it('does not show the upload corpus button', () => {
expect(findUploadCorpus().exists()).toBe(false);
});
it('does not show the upload progress', () => {
expect(findUploadStatus().exists()).toBe(false);
});
});
});
});
......@@ -2,6 +2,9 @@ import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import CorpusUpload from 'ee/security_configuration/corpus_management/components/corpus_upload.vue';
const TEST_PROJECT_FULL_PATH = '/namespace/project';
const TEST_CORPUS_HELP_PATH = '/docs/corpus-management';
describe('Corpus Upload', () => {
let wrapper;
......@@ -9,6 +12,10 @@ describe('Corpus Upload', () => {
const defaultProps = { totalSize: 4e8 };
wrapper = mountFn(CorpusUpload, {
propsData: defaultProps,
provide: {
projectFullPath: TEST_PROJECT_FULL_PATH,
corpusHelpPath: TEST_CORPUS_HELP_PATH,
},
...options,
});
};
......@@ -25,12 +32,5 @@ describe('Corpus Upload', () => {
expect(wrapper.findComponent(GlButton).exists()).toBe(true);
expect(wrapper.element).toMatchSnapshot();
});
it('calls the `uploadCorpus` callback on `new corpus` button click', async () => {
createComponent({ stubs: { GlButton } });
await wrapper.findComponent(GlButton).trigger('click');
expect(wrapper.emitted().newcorpus).toEqual([[]]);
});
});
});
......@@ -4474,6 +4474,9 @@ msgstr ""
msgid "Attach a file by drag &amp; drop or %{upload_link}"
msgstr ""
msgid "Attaching File - %{progress}"
msgstr ""
msgid "Attaching a file"
msgid_plural "Attaching %d files"
msgstr[0] ""
......@@ -6245,6 +6248,9 @@ msgstr ""
msgid "Chinese language support using"
msgstr ""
msgid "Choose File..."
msgstr ""
msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request."
msgstr ""
......@@ -8938,7 +8944,10 @@ msgstr ""
msgid "CorpusManagement|Latest Job:"
msgstr ""
msgid "CorpusManagement|New corpus"
msgid "CorpusManagement|New corpus needs to be a upload in *.zip format. Maximum 10Gib"
msgstr ""
msgid "CorpusManagement|New upload"
msgstr ""
msgid "CorpusManagement|Not Set"
......@@ -8950,6 +8959,9 @@ msgstr ""
msgid "CorpusManagement|Total Size: %{totalSize}"
msgstr ""
msgid "CorpusMnagement|New corpus"
msgstr ""
msgid "Could not add admins as members"
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment