Commit 151e980c authored by Jacques Erasmus's avatar Jacques Erasmus

Merge branch 'client-side-csv-viewer' into 'master'

Add client-side blob viewer for CSV files

See merge request gitlab-org/gitlab!81292
parents ab1491be ae39be9a
...@@ -63,6 +63,9 @@ export default { ...@@ -63,6 +63,9 @@ export default {
isEmpty() { isEmpty() {
return this.blob.rawSize === 0; return this.blob.rawSize === 0;
}, },
blobSwitcherDocIcon() {
return this.blob.richViewer?.fileType === 'csv' ? 'table' : 'document';
},
}, },
watch: { watch: {
viewer(newVal, oldVal) { viewer(newVal, oldVal) {
...@@ -90,7 +93,7 @@ export default { ...@@ -90,7 +93,7 @@ export default {
</div> </div>
<div class="gl-sm-display-flex file-actions"> <div class="gl-sm-display-flex file-actions">
<viewer-switcher v-if="showViewerSwitcher" v-model="viewer" /> <viewer-switcher v-if="showViewerSwitcher" v-model="viewer" :doc-icon="blobSwitcherDocIcon" />
<slot name="actions"></slot> <slot name="actions"></slot>
......
...@@ -21,6 +21,11 @@ export default { ...@@ -21,6 +21,11 @@ export default {
default: SIMPLE_BLOB_VIEWER, default: SIMPLE_BLOB_VIEWER,
required: false, required: false,
}, },
docIcon: {
type: String,
default: 'document',
required: false,
},
}, },
computed: { computed: {
isSimpleViewer() { isSimpleViewer() {
...@@ -62,7 +67,7 @@ export default { ...@@ -62,7 +67,7 @@ export default {
:aria-label="$options.RICH_BLOB_VIEWER_TITLE" :aria-label="$options.RICH_BLOB_VIEWER_TITLE"
:title="$options.RICH_BLOB_VIEWER_TITLE" :title="$options.RICH_BLOB_VIEWER_TITLE"
:selected="isRichViewer" :selected="isRichViewer"
icon="document" :icon="docIcon"
category="primary" category="primary"
variant="default" variant="default"
class="js-blob-viewer-switch-btn" class="js-blob-viewer-switch-btn"
......
...@@ -14,6 +14,11 @@ export default { ...@@ -14,6 +14,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
remoteFile: {
type: Boolean,
required: false,
default: false,
},
}, },
data() { data() {
return { return {
...@@ -23,14 +28,29 @@ export default { ...@@ -23,14 +28,29 @@ export default {
}; };
}, },
mounted() { mounted() {
const parsed = Papa.parse(this.csv, { skipEmptyLines: true }); if (!this.remoteFile) {
this.items = parsed.data; const parsed = Papa.parse(this.csv, { skipEmptyLines: true });
this.handleParsedData(parsed);
if (parsed.errors.length) { } else {
this.papaParseErrors = parsed.errors; Papa.parse(this.csv, {
download: true,
skipEmptyLines: true,
complete: (parsed) => {
this.handleParsedData(parsed);
},
});
} }
},
methods: {
handleParsedData(parsed) {
this.items = parsed.data;
this.loading = false; if (parsed.errors.length) {
this.papaParseErrors = parsed.errors;
}
this.loading = false;
},
}, },
}; };
</script> </script>
......
<script>
import CsvViewer from '~/blob/csv/csv_viewer.vue';
export default {
components: {
CsvViewer,
},
props: {
blob: {
type: Object,
required: true,
},
},
data() {
return {
url: this.blob.rawPath,
};
},
};
</script>
<template>
<div>
<csv-viewer :csv="url" remote-file data-testid="csv" />
</div>
</template>
const viewers = { const viewers = {
csv: () => import('./csv_viewer.vue'),
download: () => import('./download_viewer.vue'), download: () => import('./download_viewer.vue'),
image: () => import('./image_viewer.vue'), image: () => import('./image_viewer.vue'),
video: () => import('./video_viewer.vue'), video: () => import('./video_viewer.vue'),
......
...@@ -21,6 +21,7 @@ exports[`Blob Header Default Actions rendering matches the snapshot 1`] = ` ...@@ -21,6 +21,7 @@ exports[`Blob Header Default Actions rendering matches the snapshot 1`] = `
class="gl-sm-display-flex file-actions" class="gl-sm-display-flex file-actions"
> >
<viewer-switcher-stub <viewer-switcher-stub
docicon="document"
value="simple" value="simple"
/> />
......
...@@ -159,5 +159,20 @@ describe('Blob Header Default Actions', () => { ...@@ -159,5 +159,20 @@ describe('Blob Header Default Actions', () => {
await nextTick(); await nextTick();
expect(wrapper.vm.$emit).not.toHaveBeenCalled(); expect(wrapper.vm.$emit).not.toHaveBeenCalled();
}); });
it('sets different icons depending on the blob file type', async () => {
factory();
expect(wrapper.vm.blobSwitcherDocIcon).toBe('document');
await wrapper.setProps({
blob: {
...Blob,
richViewer: {
...Blob.richViewer,
fileType: 'csv',
},
},
});
expect(wrapper.vm.blobSwitcherDocIcon).toBe('table');
});
}); });
}); });
...@@ -2,6 +2,7 @@ import { GlLoadingIcon, GlTable } from '@gitlab/ui'; ...@@ -2,6 +2,7 @@ import { GlLoadingIcon, GlTable } from '@gitlab/ui';
import { getAllByRole } from '@testing-library/dom'; import { getAllByRole } from '@testing-library/dom';
import { shallowMount, mount } from '@vue/test-utils'; import { shallowMount, mount } from '@vue/test-utils';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import Papa from 'papaparse';
import CsvViewer from '~/blob/csv/csv_viewer.vue'; import CsvViewer from '~/blob/csv/csv_viewer.vue';
import PapaParseAlert from '~/vue_shared/components/papa_parse_alert.vue'; import PapaParseAlert from '~/vue_shared/components/papa_parse_alert.vue';
...@@ -11,10 +12,15 @@ const brokenCsv = '{\n "json": 1,\n "key": [1, 2, 3]\n}'; ...@@ -11,10 +12,15 @@ const brokenCsv = '{\n "json": 1,\n "key": [1, 2, 3]\n}';
describe('app/assets/javascripts/blob/csv/csv_viewer.vue', () => { describe('app/assets/javascripts/blob/csv/csv_viewer.vue', () => {
let wrapper; let wrapper;
const createComponent = ({ csv = validCsv, mountFunction = shallowMount } = {}) => { const createComponent = ({
csv = validCsv,
remoteFile = false,
mountFunction = shallowMount,
} = {}) => {
wrapper = mountFunction(CsvViewer, { wrapper = mountFunction(CsvViewer, {
propsData: { propsData: {
csv, csv,
remoteFile,
}, },
}); });
}; };
...@@ -73,4 +79,22 @@ describe('app/assets/javascripts/blob/csv/csv_viewer.vue', () => { ...@@ -73,4 +79,22 @@ describe('app/assets/javascripts/blob/csv/csv_viewer.vue', () => {
expect(getAllByRole(wrapper.element, 'row', { name: /Three/i })).toHaveLength(1); expect(getAllByRole(wrapper.element, 'row', { name: /Three/i })).toHaveLength(1);
}); });
}); });
describe('when csv prop is path and indicates a remote file', () => {
it('should render call parse with download flag true', async () => {
const path = 'path/to/remote/file.csv';
jest.spyOn(Papa, 'parse').mockImplementation((_, { complete }) => {
complete({ data: validCsv.split(','), errors: [] });
});
createComponent({ csv: path, remoteFile: true });
expect(Papa.parse).toHaveBeenCalledWith(path, {
download: true,
skipEmptyLines: true,
complete: expect.any(Function),
});
await nextTick;
expect(wrapper.vm.items).toEqual(validCsv.split(','));
});
});
}); });
import { shallowMount } from '@vue/test-utils';
import CsvViewer from '~/repository/components/blob_viewers/csv_viewer.vue';
describe('CSV Viewer', () => {
let wrapper;
const DEFAULT_BLOB_DATA = {
rawPath: 'some/file.csv',
name: 'file.csv',
};
const createComponent = () => {
wrapper = shallowMount(CsvViewer, {
propsData: { blob: DEFAULT_BLOB_DATA },
stubs: ['CsvViewer'],
});
};
const findCsvViewerComp = () => wrapper.find('[data-testid="csv"]');
it('renders a Source Editor component', () => {
createComponent();
expect(findCsvViewerComp().exists()).toBe(true);
expect(findCsvViewerComp().props('remoteFile')).toBeTruthy();
expect(findCsvViewerComp().props('csv')).toBe(DEFAULT_BLOB_DATA.rawPath);
});
});
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