diff --git a/.gitignore b/.gitignore
index 11b25b1cfa..4527c33510 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,7 @@ bundle-analyzer
cypress/videos/
cypress/screenshots/
+cypress/downloads/
# Editor directories and files
.vscode/*
diff --git a/packages/analytics/analytics-chart/README.md b/packages/analytics/analytics-chart/README.md
index 7cc5dddb94..9ff69456b4 100644
--- a/packages/analytics/analytics-chart/README.md
+++ b/packages/analytics/analytics-chart/README.md
@@ -30,6 +30,11 @@ yarn add @kong-ui-public/analytics-chart
### Props - AnalyticsChart
+#### `allowCsvExport`
+
+- type: boolean
+- required: `false`
+
#### `chartData`
- type: [AnalyticsExploreResult](https://github.com/Kong/public-ui-components/blob/main/packages/analytics/analytics-utilities/src/types/analytics-data.ts#L77)
diff --git a/packages/analytics/analytics-chart/fixtures/mockData.ts b/packages/analytics/analytics-chart/fixtures/mockData.ts
new file mode 100644
index 0000000000..eb36de25f4
--- /dev/null
+++ b/packages/analytics/analytics-chart/fixtures/mockData.ts
@@ -0,0 +1,568 @@
+export const emptyExploreResult = {
+ records: [],
+ meta: {
+ start: 1685559600,
+ end: 1685599200,
+ queryId: '12345',
+ dimensions: {
+ StatusCode: ['200', '300', '400', '500'],
+ },
+ metricNames: ['TotalRequests'],
+ metricUnits: {
+ TotalRequests: 'requests',
+ },
+ granularity: 3600000,
+ truncated: false,
+ limit: 10,
+ },
+}
+
+export const exploreResult = {
+ records: [
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T13:09:00.987Z',
+ event: {
+ StatusCode: '200',
+ TotalRequests: 255.49999999999997,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T13:09:00.987Z',
+ event: {
+ StatusCode: '300',
+ TotalRequests: 182.5,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T13:09:00.987Z',
+ event: {
+ StatusCode: '400',
+ TotalRequests: 182.5,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T13:09:00.987Z',
+ event: {
+ StatusCode: '500',
+ TotalRequests: 73,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T14:09:00.987Z',
+ event: {
+ StatusCode: '200',
+ TotalRequests: 412.8,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T14:09:00.987Z',
+ event: {
+ StatusCode: '300',
+ TotalRequests: 309.59999999999997,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T14:09:00.987Z',
+ event: {
+ StatusCode: '400',
+ TotalRequests: 206.4,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T14:09:00.987Z',
+ event: {
+ StatusCode: '500',
+ TotalRequests: 51.6,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T15:09:00.987Z',
+ event: {
+ StatusCode: '200',
+ TotalRequests: 867.6,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T15:09:00.987Z',
+ event: {
+ StatusCode: '300',
+ TotalRequests: 385.6,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T15:09:00.987Z',
+ event: {
+ StatusCode: '400',
+ TotalRequests: 385.6,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T15:09:00.987Z',
+ event: {
+ StatusCode: '500',
+ TotalRequests: 96.4,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T16:09:00.987Z',
+ event: {
+ StatusCode: '200',
+ TotalRequests: 982.4000000000001,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T16:09:00.987Z',
+ event: {
+ StatusCode: '300',
+ TotalRequests: 614,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T16:09:00.987Z',
+ event: {
+ StatusCode: '400',
+ TotalRequests: 614,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T16:09:00.987Z',
+ event: {
+ StatusCode: '500',
+ TotalRequests: 245.60000000000002,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T17:09:00.987Z',
+ event: {
+ StatusCode: '200',
+ TotalRequests: 1043,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T17:09:00.987Z',
+ event: {
+ StatusCode: '300',
+ TotalRequests: 745,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T17:09:00.987Z',
+ event: {
+ StatusCode: '400',
+ TotalRequests: 596,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T17:09:00.987Z',
+ event: {
+ StatusCode: '500',
+ TotalRequests: 298,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T18:09:00.987Z',
+ event: {
+ StatusCode: '200',
+ TotalRequests: 1260.8000000000002,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T18:09:00.987Z',
+ event: {
+ StatusCode: '300',
+ TotalRequests: 788,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T18:09:00.987Z',
+ event: {
+ StatusCode: '400',
+ TotalRequests: 472.79999999999995,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T18:09:00.987Z',
+ event: {
+ StatusCode: '500',
+ TotalRequests: 157.60000000000002,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T19:09:00.987Z',
+ event: {
+ StatusCode: '200',
+ TotalRequests: 1652,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T19:09:00.987Z',
+ event: {
+ StatusCode: '300',
+ TotalRequests: 1239,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T19:09:00.987Z',
+ event: {
+ StatusCode: '400',
+ TotalRequests: 619.5,
+ },
+ },
+ {
+ version: '1.0',
+ timestamp: '2023-05-30T19:09:00.987Z',
+ event: {
+ StatusCode: '500',
+ TotalRequests: 413,
+ },
+ },
+ ],
+ meta: {
+ start: 1685452140.987,
+ end: 1685473740.987,
+ queryId: '12345',
+ dimensions: {
+ StatusCode: ['200', '300', '400', '500'],
+ },
+ metricNames: ['TotalRequests'],
+ metricUnits: {
+ TotalRequests: 'requests',
+ },
+ granularity: 3600000,
+ truncated: false,
+ limit: 10,
+ },
+}
+
+export const exploreV2Result = {
+ meta: {
+ startMs: 1701158400000,
+ endMs: 1702368000000,
+ granularity: 604800000,
+ queryId: '00179418-9e55-4351-ae43-c8da0dcbed02',
+ metricNames: [
+ 'REQUEST_COUNT',
+ ],
+ truncated: false,
+ limit: 50,
+ metricUnits: {
+ REQUEST_COUNT: 'count',
+ },
+ dimensions: {
+ STATUS_CODE_GROUPED: [
+ '2XX',
+ '3XX',
+ '4XX',
+ '5XX',
+ ],
+ },
+ },
+ records: [
+ {
+ event: {
+ STATUS_CODE_GROUPED: '2XX',
+ REQUEST_COUNT: 22995565,
+ },
+ timestamp: '2023-11-28T08:00:00.000Z',
+ },
+ {
+ event: {
+ STATUS_CODE_GROUPED: '3XX',
+ REQUEST_COUNT: 7307048,
+ },
+ timestamp: '2023-11-28T08:00:00.000Z',
+ },
+ {
+ event: {
+ STATUS_CODE_GROUPED: '4XX',
+ REQUEST_COUNT: 9612003,
+ },
+ timestamp: '2023-11-28T08:00:00.000Z',
+ },
+ {
+ event: {
+ STATUS_CODE_GROUPED: '5XX',
+ REQUEST_COUNT: 6227845,
+ },
+ timestamp: '2023-11-28T08:00:00.000Z',
+ },
+ {
+ event: {
+ STATUS_CODE_GROUPED: '2XX',
+ REQUEST_COUNT: 22148124,
+ },
+ timestamp: '2023-12-05T08:00:00.000Z',
+ },
+ {
+ event: {
+ STATUS_CODE_GROUPED: '3XX',
+ REQUEST_COUNT: 7096101,
+ },
+ timestamp: '2023-12-05T08:00:00.000Z',
+ },
+ {
+ event: {
+ STATUS_CODE_GROUPED: '4XX',
+ REQUEST_COUNT: 9448612,
+ },
+ timestamp: '2023-12-05T08:00:00.000Z',
+ },
+ {
+ event: {
+ STATUS_CODE_GROUPED: '5XX',
+ REQUEST_COUNT: 6016577,
+ },
+ timestamp: '2023-12-05T08:00:00.000Z',
+ },
+ ],
+}
+
+export const dailyExploreResult = {
+ records: [
+ {
+ version: 'v1',
+ timestamp: '2023-05-25T06:00:00.000Z',
+ event: {
+ TotalRequests: 425722,
+ },
+ },
+ {
+ version: 'v1',
+ timestamp: '2023-05-26T06:00:00.000Z',
+ event: {
+ TotalRequests: 430278,
+ },
+ },
+ {
+ version: 'v1',
+ timestamp: '2023-05-27T06:00:00.000Z',
+ event: {
+ TotalRequests: 429998,
+ },
+ },
+ {
+ version: 'v1',
+ timestamp: '2023-05-28T06:00:00.000Z',
+ event: {
+ TotalRequests: 430544,
+ },
+ },
+ {
+ version: 'v1',
+ timestamp: '2023-05-29T06:00:00.000Z',
+ event: {
+ TotalRequests: 426258,
+ },
+ },
+ {
+ version: 'v1',
+ timestamp: '2023-05-30T06:00:00.000Z',
+ event: {
+ TotalRequests: 430446,
+ },
+ },
+ {
+ version: 'v1',
+ timestamp: '2023-05-31T06:00:00.000Z',
+ event: {
+ TotalRequests: 225018,
+ },
+ },
+ ],
+ meta: {
+ start: 1684994400,
+ end: 1685599200,
+ granularity: 86400000,
+ queryId: '23976f',
+ metricNames: ['TotalRequests'],
+ truncated: true,
+ limit: 50,
+ metricUnits: {
+ TotalRequests: 'count',
+ },
+ dimensions: {},
+ },
+}
+
+export const multiDimensionExploreResult = {
+ records: [
+ {
+ version: 'v1',
+ timestamp: '2023-05-31T19:00:00.000Z',
+ event: {
+ StatusCode: '200',
+ Service: 'service A',
+ TotalRequests: 17945,
+ },
+ },
+ {
+ version: 'v1',
+ timestamp: '2023-05-31T20:00:00.000Z',
+ event: {
+ StatusCode: '200',
+ Service: 'service A',
+ TotalRequests: 17940,
+ },
+ },
+ {
+ version: 'v1',
+ timestamp: '2023-05-31T21:00:00.000Z',
+ event: {
+ StatusCode: '300',
+ Service: 'service A',
+ TotalRequests: 17955,
+ },
+ },
+ {
+ version: 'v1',
+ timestamp: '2023-05-31T22:00:00.000Z',
+ event: {
+ StatusCode: '300',
+ Service: 'service A',
+ TotalRequests: 17944,
+ },
+ },
+ {
+ version: 'v1',
+ timestamp: '2023-05-31T23:00:00.000Z',
+ event: {
+ StatusCode: '500',
+ Service: 'service A',
+ TotalRequests: 17940,
+ },
+ },
+ {
+ version: 'v1',
+ timestamp: '2023-06-01T00:00:00.000Z',
+ event: {
+ StatusCode: '500',
+ Service: 'service A',
+ TotalRequests: 17925,
+ },
+ },
+ {
+ version: 'v1',
+ timestamp: '2023-06-01T01:00:00.000Z',
+ event: {
+ StatusCode: '200',
+ Service: 'service A',
+ TotalRequests: 17930,
+ },
+ },
+ {
+ version: 'v1',
+ timestamp: '2023-06-01T02:00:00.000Z',
+ event: {
+ StatusCode: '300',
+ Service: 'service B (default)',
+ TotalRequests: 17945,
+ },
+ },
+ {
+ version: 'v1',
+ timestamp: '2023-06-01T03:00:00.000Z',
+ event: {
+ StatusCode: '400',
+ Service: 'service B (default)',
+ TotalRequests: 17940,
+ },
+ },
+ {
+ version: 'v1',
+ timestamp: '2023-06-01T04:00:00.000Z',
+ event: {
+ StatusCode: '400',
+ Service: 'service B',
+ TotalRequests: 17939,
+ },
+ },
+ {
+ version: 'v1',
+ timestamp: '2023-06-01T05:00:00.000Z',
+ event: {
+ StatusCode: '400',
+ Service: 'service B',
+ TotalRequests: 17930,
+ },
+ },
+ {
+ version: 'v1',
+ timestamp: '2023-06-01T06:00:00.000Z',
+ event: {
+ StatusCode: '500',
+ Service: 'service B',
+ TotalRequests: 17950,
+ },
+ },
+ {
+ version: 'v1',
+ timestamp: '2023-06-01T07:00:00.000Z',
+ event: {
+ StatusCode: '200',
+ Service: 'service B',
+ TotalRequests: 17935,
+ },
+ },
+ {
+ version: 'v1',
+ timestamp: '2023-06-01T08:00:00.000Z',
+ event: {
+ StatusCode: '300',
+ Service: 'service B',
+ TotalRequests: 17930,
+ },
+ },
+ {
+ version: 'v1',
+ timestamp: '2023-06-01T09:00:00.000Z',
+ event: {
+ StatusCode: '400',
+ Service: 'service B',
+ TotalRequests: 17935,
+ },
+ },
+ ],
+ meta: {
+ start: 1685559600,
+ end: 1685646000,
+ granularity: 3600000,
+ queryId: 'ab6ee70c-ca00-47ef-8151-d52ca940837a',
+ metricNames: ['TotalRequests'],
+ truncated: false,
+ limit: 50,
+ metricUnits: {
+ TotalRequests: 'count',
+ },
+ dimensions: {
+ Service: ['service A', 'service B'],
+ StatusCode: ['200', '300', '400', '500'],
+ },
+ },
+}
diff --git a/packages/analytics/analytics-chart/package.json b/packages/analytics/analytics-chart/package.json
index 577d8bc8b5..98be33f4bc 100644
--- a/packages/analytics/analytics-chart/package.json
+++ b/packages/analytics/analytics-chart/package.json
@@ -30,6 +30,11 @@
"@kong/design-tokens": "^1.12.1",
"@kong/kongponents": "9.0.0-alpha.73",
"@types/uuid": "^9.0.7",
+ "file-saver": "^2.0.5",
+ "lodash.mapkeys": "^4.6.0",
+ "lodash.pick": "^4.4.0",
+ "lodash.pickby": "^4.6.0",
+ "papaparse": "^5.4.1",
"vue": "^3.3.9"
},
"scripts": {
@@ -64,6 +69,11 @@
"dependencies": {
"@kong-ui-public/analytics-utilities": "workspace:^0.11.0",
"@kong/icons": "^1.8.3",
+ "@types/file-saver": "^2.0.7",
+ "@types/lodash.mapkeys": "^4.6.9",
+ "@types/lodash.pick": "^4.4.9",
+ "@types/lodash.pickby": "^4.6.9",
+ "@types/papaparse": "^5.3.14",
"approximate-number": "^2.1.1",
"chart.js": "^4.4.0",
"chartjs-adapter-date-fns": "^3.0.0",
@@ -76,6 +86,6 @@
},
"distSizeChecker": {
"warningLimit": "1.35MB",
- "errorLimit": "1.4MB"
+ "errorLimit": "1.5MB"
}
}
diff --git a/packages/analytics/analytics-chart/sandbox/pages/BarChartDemo.vue b/packages/analytics/analytics-chart/sandbox/pages/BarChartDemo.vue
index f89f7040c5..c77697a83a 100644
--- a/packages/analytics/analytics-chart/sandbox/pages/BarChartDemo.vue
+++ b/packages/analytics/analytics-chart/sandbox/pages/BarChartDemo.vue
@@ -179,6 +179,7 @@
', () => {
- it('Renders a line chart for total requests count with satus code dimension', () => {
+ beforeEach(() => {
+ cy.viewport(1280, 800)
+ })
+
+ it('Renders a line chart for total requests count with status code dimension', () => {
cy.mount(AnalyticsChart, {
props: {
chartData: exploreResult,
@@ -723,6 +242,55 @@ describe('', () => {
cy.get('[data-testid="no-data-in-report"] .k-empty-state-message').should('contain.text', emptyStateDescription)
})
+ it('doest not render an "Export button" if the dataset is empty', () => {
+ cy.mount(AnalyticsChart, {
+ props: {
+ allowCsvExport: true,
+ chartData: emptyExploreResult,
+ chartOptions: {
+ type: ChartTypes.TIMESERIES_LINE,
+ },
+ chartTitle: 'Requests',
+ },
+ })
+ cy.getTestId('csv-export-button').should('not.exist')
+ })
+
+ it('does not render an "Export" button if chart data is present but prop is set to `false`', () => {
+ cy.mount(AnalyticsChart, {
+ props: {
+ allowCsvExport: false,
+ chartData: exploreResult,
+ chartOptions: {
+ type: ChartTypes.TIMESERIES_LINE,
+ },
+ chartTitle: 'Requests',
+ },
+ })
+ cy.getTestId('csv-export-button').should('not.exist')
+ })
+
+ it('Renders an "Export" button, and tabulated data in the modal preview', () => {
+ cy.mount(AnalyticsChart, {
+ props: {
+ allowCsvExport: true,
+ chartData: exploreResult,
+ chartOptions: {
+ type: ChartTypes.TIMESERIES_LINE,
+ },
+ chartTitle: 'Requests',
+ },
+ })
+
+ cy.getTestId('csv-export-button').should('exist')
+
+ // eslint-disable-next-line cypress/unsafe-to-chain-command
+ cy.getTestId('csv-export-button').click().then(() => {
+ cy.getTestId('csv-export-modal').should('exist')
+ cy.get('.modal-body .vitals-table').should('exist')
+ })
+ })
+
it('multi dimension bar charts have "tooltipContext"', () => {
cy.mount(AnalyticsChart, {
props: {
diff --git a/packages/analytics/analytics-chart/src/components/AnalyticsChart.vue b/packages/analytics/analytics-chart/src/components/AnalyticsChart.vue
index fce9127208..b440eec5dd 100644
--- a/packages/analytics/analytics-chart/src/components/AnalyticsChart.vue
+++ b/packages/analytics/analytics-chart/src/components/AnalyticsChart.vue
@@ -26,6 +26,26 @@
+
+
+ {{ i18n.t('csvExport.exportButton') }}
+
+
+
,
required: true,
@@ -133,7 +159,13 @@ const props = defineProps({
},
chartTitle: {
type: String,
- required: true,
+ required: false,
+ default: undefined,
+ },
+ filenamePrefix: {
+ type: String,
+ required: false,
+ default: undefined,
},
legendPosition: {
type: String as PropType<`${ChartLegendPosition}`>,
@@ -175,6 +207,7 @@ const props = defineProps({
const { i18n } = composables.useI18n()
const heightRef = ref(props.height)
+const csvFilename = computed(() => props.filenamePrefix ?? (props.chartTitle || i18n.t('csvExport.defaultFilename')))
const handleHeightUpdate = (height: number) => {
heightRef.value = `${height}px`
@@ -184,6 +217,8 @@ watch(() => props.height, (newHeight) => {
heightRef.value = newHeight
})
+const rawChartData = toRef(props, 'chartData')
+
const computedChartData = computed(() => {
return isTimeSeriesChart.value
? composables.useExploreResultToTimeDataset(
@@ -307,6 +342,17 @@ const timeSeriesGranularity = computed(() => {
return msToGranularity(props.chartData.meta.granularity.duration) || GranularityKeys.HOURLY
})
+// CSV Export Modal
+const exportModalVisible = ref(false)
+const selectedRange = composables.useChartSelectedRange(rawChartData)
+
+const setModalVisibility = (val: boolean) => {
+ exportModalVisible.value = val
+}
+const exportCsv = () => {
+ setModalVisibility(true)
+}
+
provide('showLegendValues', showLegendValues)
provide('legendPosition', toRef(props, 'legendPosition'))
@@ -328,14 +374,21 @@ provide('legendPosition', toRef(props, 'legendPosition'))
padding: $kui-space-70 $kui-space-0 $kui-space-60 $kui-space-0;
}
.chart-header {
+ align-items: center;
display: flex;
- justify-content: space-between;
+ justify-content: flex-start;
padding-bottom: $kui-space-60;
.chart-header-icons-wrapper {
display: flex;
justify-content: end;
}
+
+ .chart-export-button {
+ display: flex;
+ margin-left: auto;
+ margin-right: 0;
+ }
}
.chart-title {
diff --git a/packages/analytics/analytics-chart/src/components/CsvExportModal.cy.ts b/packages/analytics/analytics-chart/src/components/CsvExportModal.cy.ts
new file mode 100644
index 0000000000..90bb088318
--- /dev/null
+++ b/packages/analytics/analytics-chart/src/components/CsvExportModal.cy.ts
@@ -0,0 +1,83 @@
+// Cypress component test spec file
+import CsvExportModal from './CsvExportModal.vue'
+import composables from '../composables'
+import { computed } from 'vue'
+import { exploreResult, exploreV2Result, emptyExploreResult } from '../../fixtures/mockData'
+
+const DOWNLOADS_FOLDER = Cypress.config('downloadsFolder')
+const MAX_ROWS = 3
+
+describe('', () => {
+ beforeEach(() => {
+ cy.viewport(800, 800)
+ })
+
+ it('Export Modal with empty dataset', () => {
+ cy.mount(CsvExportModal, {
+ props: {
+ chartData: emptyExploreResult,
+ filename: 'Total requests',
+ selectedRange: composables.useChartSelectedRange(computed(() => emptyExploreResult)),
+ },
+ })
+
+ cy.getTestId('csv-export-modal').should('exist')
+ cy.get('.k-table-empty-state').should('be.visible')
+ cy.getTestId('csv-download-button').should('be.disabled')
+ })
+
+ it('Export Modal with v1 explore data', () => {
+ cy.mount(CsvExportModal, {
+ props: {
+ chartData: exploreResult,
+ filename: 'Total requests',
+ selectedRange: composables.useChartSelectedRange(computed(() => exploreResult)),
+ },
+ })
+
+ cy.getTestId('csv-export-modal').should('exist')
+ cy.get('.k-table-empty-state').should('not.exist')
+ cy.get('.modal-body .vitals-table').should('exist')
+ cy.get('.modal-body .vitals-table').should('exist')
+ cy.getTestId('csv-download-button').should('not.be.disabled')
+
+ // Timestamp should be naive localtime
+ cy.getTestId('csv-export-modal').find('.k-table tbody td').eq(0).invoke('text').should('match', /\d{4}-\d\d-\d\d \d\d:\d\d:\d\d/)
+
+ // Table should contain the max number of rows allowed + 1 Header row
+ const numTableRows = MAX_ROWS + 1
+
+ cy.getTestId('csv-export-modal').find('.k-table tr').should('have.length', numTableRows)
+
+ // Column headers should be as expected.
+ cy.getTestId('csv-export-modal').find('.k-table thead th').should(th => {
+ const elements = Array.from(th, e => e.innerText)
+
+ expect(elements).eql(['Timestamp', 'UtcOffset', 'StatusCode', 'TotalRequests'])
+ })
+
+ // Save to CSV and check actual contents
+ cy.getTestId('csv-download-button').click()
+
+ cy.readFile(`${DOWNLOADS_FOLDER}/total-requests-${new Date().toISOString().slice(0, 10)}.csv`).then(contents => {
+ expect(contents).contain('Timestamp,UtcOffset,StatusCode,TotalRequests')
+ expect(contents).contain(',300,1239')
+ })
+ })
+
+ it('Export Modal with v2 explore data', () => {
+ cy.mount(CsvExportModal, {
+ props: {
+ filename: 'Total requests',
+ chartData: exploreV2Result,
+ selectedRange: composables.useChartSelectedRange(computed(() => exploreV2Result)),
+ },
+ })
+
+ cy.getTestId('csv-export-modal').should('exist')
+ cy.get('.k-table-empty-state').should('not.exist')
+ cy.get('.modal-body .vitals-table').should('exist')
+ cy.get('.modal-body .vitals-table').should('exist')
+ cy.getTestId('csv-download-button').should('not.be.disabled')
+ })
+})
diff --git a/packages/analytics/analytics-chart/src/components/CsvExportModal.vue b/packages/analytics/analytics-chart/src/components/CsvExportModal.vue
new file mode 100644
index 0000000000..ed9c0fdb65
--- /dev/null
+++ b/packages/analytics/analytics-chart/src/components/CsvExportModal.vue
@@ -0,0 +1,253 @@
+
+
+
+
+
+
+
+ {{ modalDescription ? modalDescription : i18n.t('csvExport.exportDescription') }}
+
+
+ {{ i18n.t('csvExport.exportTimeRange') }}: {{ selectedRange }}
+
+
+
+
+
+
+ {{ i18n.t('csvExport.noDataRange') }}
+
+
+ {{ i18n.t('csvExport.noDataRetry') }}
+
+
+
+
+
+ {{ previewMessage }}
+
+
+
+
+
+ {{ i18n.t('csvExport.cancelButton') }}
+
+
+
+ {{ i18n.t('csvExport.downloadButton') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/analytics/analytics-chart/src/components/DownloadCsv.vue b/packages/analytics/analytics-chart/src/components/DownloadCsv.vue
new file mode 100644
index 0000000000..e8baf8ea25
--- /dev/null
+++ b/packages/analytics/analytics-chart/src/components/DownloadCsv.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/analytics/analytics-chart/src/components/SimpleChart.cy.ts b/packages/analytics/analytics-chart/src/components/SimpleChart.cy.ts
index 19c6d3e2ec..cd07fdfd5d 100644
--- a/packages/analytics/analytics-chart/src/components/SimpleChart.cy.ts
+++ b/packages/analytics/analytics-chart/src/components/SimpleChart.cy.ts
@@ -1,25 +1,7 @@
// Cypress component test spec file
import { ChartMetricDisplay, ChartLegendPosition, ChartTypesSimple } from '../enums/'
import SimpleChart from './SimpleChart.vue'
-
-const emptyExploreResult = {
- records: [],
- meta: {
- start: 1685452140.987,
- end: 1685473740.987,
- queryId: '12345',
- dimensions: {
- StatusCode: ['200', '300', '400', '500'],
- },
- metricNames: ['TotalRequests'],
- metricUnits: {
- TotalRequests: 'requests',
- },
- granularity: 3600000,
- truncated: false,
- limit: 10,
- },
-}
+import { emptyExploreResult } from '../../fixtures/mockData'
const exploreResultTruncated = {
records: [
diff --git a/packages/analytics/analytics-chart/src/components/vue-json-csv/LICENSE b/packages/analytics/analytics-chart/src/components/vue-json-csv/LICENSE
new file mode 100644
index 0000000000..8a375d05fd
--- /dev/null
+++ b/packages/analytics/analytics-chart/src/components/vue-json-csv/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2018 Antoine Aflalo
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/packages/analytics/analytics-chart/src/components/vue-json-csv/VueJsonCsv.cy.ts b/packages/analytics/analytics-chart/src/components/vue-json-csv/VueJsonCsv.cy.ts
new file mode 100644
index 0000000000..2369389443
--- /dev/null
+++ b/packages/analytics/analytics-chart/src/components/vue-json-csv/VueJsonCsv.cy.ts
@@ -0,0 +1,71 @@
+import VueJsonCsv from './VueJsonCsv.vue'
+
+const CSV_DATA = [
+ { requests: 206, tzOffset: '-08:00', timestamp: '2022-10-15 13:43:27' },
+ { requests: 381, tzOffset: '-08:00', timestamp: '2022-12-15 06:00:53' },
+ { requests: 648, tzOffset: '-08:00', timestamp: '2022-04-26 06:26:28' },
+ { requests: 925, tzOffset: '-08:00', timestamp: '2022-04-10 10:28:46' },
+ { requests: 134, tzOffset: '-08:00', timestamp: '2022-12-06 14:38:38' },
+]
+
+const LABELS = { timestamp: 'Timestamp', tzOffset: 'UtcOffset', requests: 'TotalRequests' }
+const FIELDS = ['timestamp', 'tzOffset', 'requests']
+
+describe('', () => {
+ it('loads the table data and emits export events', () => {
+ cy.mount(VueJsonCsv, {
+ props: {
+ data: CSV_DATA,
+ labels: LABELS,
+ fields: FIELDS,
+ testing: true,
+ onUpdate: cy.spy().as('onUpdateExport'),
+ },
+ })
+
+ // eslint-disable-next-line cypress/unsafe-to-chain-command
+ cy.getTestId('export-csv').click().then(() => {
+ // eslint-disable-next-line cypress/unsafe-to-chain-command
+ cy.wrap(Cypress.vueWrapper.emitted()).should('have.property', 'export-started').then((evt) => {
+ const incomingData = evt[0][0]
+ const firstRow = incomingData[0]
+
+ cy.wrap(firstRow).should('have.property', LABELS.requests)
+ cy.wrap(firstRow).should('have.property', LABELS.timestamp)
+ cy.wrap(firstRow).should('not.have.property', 'id')
+ cy.wrap(incomingData).should('have.length', CSV_DATA.length)
+ })
+
+ // eslint-disable-next-line cypress/unsafe-to-chain-command
+ cy.wrap(Cypress.vueWrapper.emitted()).should('have.property', 'export-finished').then((evt) => {
+ const exportedFilename = evt[0][0]
+
+ // No filename was provided, shoudl have defaulted to 'report-data.csv'
+ cy.wrap(exportedFilename).should('be.equal', 'report-data.csv')
+ })
+ })
+ })
+
+ it('emits event on export', () => {
+ cy.mount(VueJsonCsv, {
+ props: {
+ data: CSV_DATA,
+ labels: LABELS,
+ fields: FIELDS,
+ testing: true,
+ filename: 'custom-export.csv',
+ },
+ })
+
+ // eslint-disable-next-line cypress/unsafe-to-chain-command
+ cy.getTestId('export-csv').click().then(() => {
+ // eslint-disable-next-line cypress/unsafe-to-chain-command
+ cy.wrap(Cypress.vueWrapper.emitted()).should('have.property', 'export-finished').then((evt) => {
+ const exportedFilename = evt[0][0]
+
+ // No filename was provided, shoudl have defaulted to 'report-data.csv'
+ cy.wrap(exportedFilename).should('be.equal', 'custom-export.csv')
+ })
+ })
+ })
+})
diff --git a/packages/analytics/analytics-chart/src/components/vue-json-csv/VueJsonCsv.vue b/packages/analytics/analytics-chart/src/components/vue-json-csv/VueJsonCsv.vue
new file mode 100644
index 0000000000..ec9e4a5f03
--- /dev/null
+++ b/packages/analytics/analytics-chart/src/components/vue-json-csv/VueJsonCsv.vue
@@ -0,0 +1,175 @@
+
+
+ Download {{ filename }}
+
+
+
+
diff --git a/packages/analytics/analytics-chart/src/composables/index.ts b/packages/analytics/analytics-chart/src/composables/index.ts
index cc60ef7145..a5e0902ff8 100644
--- a/packages/analytics/analytics-chart/src/composables/index.ts
+++ b/packages/analytics/analytics-chart/src/composables/index.ts
@@ -2,6 +2,7 @@ import useI18n from './useI18n'
import useBarChartOptions from './useBarChartOptions'
import useChartJSCommon from './useChartJSCommon'
import useChartLegendValues from './useChartLegendValues'
+import useChartSelectedRange from './useChartSelectedRange'
import useDoughnutChartOptions from './useDoughnutChartOptions'
import useLinechartOptions from './useLineChartOptions'
import useExploreResultToDatasets from './useExploreResultToDatasets'
@@ -13,6 +14,7 @@ export default {
useBarChartOptions,
useChartJSCommon,
useChartLegendValues,
+ useChartSelectedRange,
useExploreResultToDatasets,
useExploreResultToTimeDataset,
useDoughnutChartOptions,
diff --git a/packages/analytics/analytics-chart/src/composables/useChartSelectedRange.ts b/packages/analytics/analytics-chart/src/composables/useChartSelectedRange.ts
new file mode 100644
index 0000000000..bef510b190
--- /dev/null
+++ b/packages/analytics/analytics-chart/src/composables/useChartSelectedRange.ts
@@ -0,0 +1,19 @@
+import type { Ref } from 'vue'
+import { computed } from 'vue'
+import type { AnalyticsExploreResult, AnalyticsExploreV2Result } from '@kong-ui-public/analytics-utilities'
+import { formatTime } from '../utils'
+
+export default function useChartSelectedRange(chartData: Ref): Ref {
+ const formattedTimeRange = computed(() => {
+ if (!chartData.value?.meta) { return '' }
+
+ const start = 'startMs' in chartData.value.meta ? chartData.value.meta.startMs : chartData.value.meta?.start * 1000 || ''
+ const end = 'endMs' in chartData.value.meta ? chartData.value.meta.endMs : chartData.value.meta?.end * 1000 || ''
+
+ return start && end
+ ? `${formatTime(start)} - ${formatTime(end, { includeTZ: true })}`
+ : ''
+ })
+
+ return formattedTimeRange
+}
diff --git a/packages/analytics/analytics-chart/src/index.ts b/packages/analytics/analytics-chart/src/index.ts
index cb56166f9f..27517783a5 100644
--- a/packages/analytics/analytics-chart/src/index.ts
+++ b/packages/analytics/analytics-chart/src/index.ts
@@ -1,8 +1,9 @@
import AnalyticsChart from './components/AnalyticsChart.vue'
import SimpleChart from './components/SimpleChart.vue'
import TopNTable from './components/TopNTable.vue'
+import CsvExportModal from './components/CsvExportModal.vue'
-export { AnalyticsChart, SimpleChart, TopNTable }
+export { AnalyticsChart, SimpleChart, TopNTable, CsvExportModal }
export * from './types'
export * from './enums'
diff --git a/packages/analytics/analytics-chart/src/locales/en.json b/packages/analytics/analytics-chart/src/locales/en.json
index 69aa03b5da..b9fc6cc9e8 100644
--- a/packages/analytics/analytics-chart/src/locales/en.json
+++ b/packages/analytics/analytics-chart/src/locales/en.json
@@ -12,6 +12,19 @@
"count/minute": "rpm",
"requests": "requests"
},
+ "csvExport" :{
+ "noDataRange": "No data in selected range",
+ "noDataRetry": "Please adjust the time period and try again",
+ "timeRangeSelected": "selected",
+ "defaultFilename": "Chart Export",
+ "exportPreview": "Export Preview",
+ "exportButton": "Export",
+ "exportTimeRange": "Time range",
+ "exportDescription": "Exports a CSV of the data represented in the chart.",
+ "previewRows": "Previewing {rowsMax} of {rowsTotal} row{plural}",
+ "downloadButton": "Export",
+ "cancelButton": "Cancel"
+ },
"legend": {
"datapointValueDisplay": "{value} {unit}"
},
diff --git a/packages/analytics/analytics-chart/src/types/chart-export.ts b/packages/analytics/analytics-chart/src/types/chart-export.ts
new file mode 100644
index 0000000000..220ff3031c
--- /dev/null
+++ b/packages/analytics/analytics-chart/src/types/chart-export.ts
@@ -0,0 +1,23 @@
+export interface CsvKeyValuePair {
+ [key: string]: string
+}
+
+export enum ValidType {
+ String = 'string',
+ Number = 'number',
+ Boolean = 'boolean',
+ Object = 'object',
+ Undefined = 'undefined',
+}
+
+export interface Header {
+ label: string
+ key: string
+}
+
+export interface TimeseriesColumn {
+ label: string;
+ key: string;
+}
+
+export type CsvData = CsvKeyValuePair[]
diff --git a/packages/analytics/analytics-chart/src/types/index.ts b/packages/analytics/analytics-chart/src/types/index.ts
index 5fc231796f..9e494dbea5 100644
--- a/packages/analytics/analytics-chart/src/types/index.ts
+++ b/packages/analytics/analytics-chart/src/types/index.ts
@@ -1,4 +1,5 @@
export * from './chart-data'
+export * from './chart-export'
export * from './chartjs-options'
export * from './explore-to-dataset-deps'
export * from './dataset-generation-types'
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d19d543aa8..1cd2edb81b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -200,6 +200,21 @@ importers:
'@kong/icons':
specifier: ^1.8.3
version: 1.8.3(vue@3.3.11)
+ '@types/file-saver':
+ specifier: ^2.0.7
+ version: 2.0.7
+ '@types/lodash.mapkeys':
+ specifier: ^4.6.9
+ version: 4.6.9
+ '@types/lodash.pick':
+ specifier: ^4.4.9
+ version: 4.4.9
+ '@types/lodash.pickby':
+ specifier: ^4.6.9
+ version: 4.6.9
+ '@types/papaparse':
+ specifier: ^5.3.14
+ version: 5.3.14
approximate-number:
specifier: ^2.1.1
version: 2.1.1
@@ -243,6 +258,21 @@ importers:
'@types/uuid':
specifier: ^9.0.7
version: 9.0.7
+ file-saver:
+ specifier: ^2.0.5
+ version: 2.0.5
+ lodash.mapkeys:
+ specifier: ^4.6.0
+ version: 4.6.0
+ lodash.pick:
+ specifier: ^4.4.0
+ version: 4.4.0
+ lodash.pickby:
+ specifier: ^4.6.0
+ version: 4.6.0
+ papaparse:
+ specifier: ^5.4.1
+ version: 5.4.1
vue:
specifier: ^3.3.9
version: 3.3.11(typescript@5.2.2)
@@ -1287,6 +1317,14 @@ packages:
js-tokens: 4.0.0
dev: true
+ /@babel/parser@7.22.11:
+ resolution: {integrity: sha512-R5zb8eJIBPJriQtbH/htEQy4k7E2dHWlD2Y2VT07JCzwYZHBxV5ZYtM0UhXSNMT74LyxuM+b1jdL7pSesXbC/g==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+ dependencies:
+ '@babel/types': 7.23.6
+ dev: true
+
/@babel/parser@7.23.4:
resolution: {integrity: sha512-vf3Xna6UEprW+7t6EtOmFpHNAuxw3xqPZghy+brsnusscJRW5BMUzzHZc5ICjULee81WeUV2jjakG09MDglJXQ==}
engines: {node: '>=6.0.0'}
@@ -1371,7 +1409,7 @@ packages:
engines: {node: '>=6.9.0'}
dependencies:
'@babel/code-frame': 7.22.10
- '@babel/parser': 7.23.4
+ '@babel/parser': 7.22.11
'@babel/types': 7.23.6
dev: true
@@ -1385,7 +1423,7 @@ packages:
'@babel/helper-function-name': 7.22.5
'@babel/helper-hoist-variables': 7.22.5
'@babel/helper-split-export-declaration': 7.22.6
- '@babel/parser': 7.23.4
+ '@babel/parser': 7.22.11
'@babel/types': 7.23.6
debug: 4.3.4(supports-color@8.1.1)
globals: 11.12.0
@@ -3601,6 +3639,10 @@ packages:
'@types/serve-static': 1.15.1
dev: true
+ /@types/file-saver@2.0.7:
+ resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==}
+ dev: false
+
/@types/flat@5.0.5:
resolution: {integrity: sha512-nPLljZQKSnac53KDUDzuzdRfGI0TDb5qPrb+SrQyN3MtdQrOnGsKniHN1iYZsJEBIVQve94Y6gNz22sgISZq+Q==}
dev: true
@@ -3661,6 +3703,24 @@ packages:
'@types/lodash': 4.14.202
dev: true
+ /@types/lodash.mapkeys@4.6.9:
+ resolution: {integrity: sha512-6/ERBCabeDI656LsV+oopLjdnJ/x1PCAE6kkkssH8e4i0K7Pw307noxHCbUc6cAVfTo9vx0Z+k3QZwy1IrUZcA==}
+ dependencies:
+ '@types/lodash': 4.14.202
+ dev: false
+
+ /@types/lodash.pick@4.4.9:
+ resolution: {integrity: sha512-hDpr96x9xHClwy1KX4/RXRejqjDFTEGbEMT3t6wYSYeFDzxmMnSKB/xHIbktRlPj8Nii2g8L5dtFDRaNFBEzUQ==}
+ dependencies:
+ '@types/lodash': 4.14.202
+ dev: false
+
+ /@types/lodash.pickby@4.6.9:
+ resolution: {integrity: sha512-SPI248FYnyd3jOxDeJq2vX2UKQnDzqacuqdeOVqwE1MPSk8gN8TA3FcHSMQWLlpBnuHgXvgKInvywbOFbidpJA==}
+ dependencies:
+ '@types/lodash': 4.14.202
+ dev: false
+
/@types/lodash@4.14.202:
resolution: {integrity: sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==}
@@ -3684,12 +3744,17 @@ packages:
resolution: {integrity: sha512-k5fggr14DwAytoA/t8rPrIz++lXK7/DqckthCmoZOKNsEbJkId4Z//BqgApXBUGrGddrigYa1oqheo/7YmW4rg==}
dependencies:
undici-types: 5.26.5
- dev: true
/@types/normalize-package-data@2.4.1:
resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==}
dev: true
+ /@types/papaparse@5.3.14:
+ resolution: {integrity: sha512-LxJ4iEFcpqc6METwp9f6BV6VVc43m6MfH0VqFosHvrUgfXiFe6ww7R3itkOQ+TCK6Y+Iv/+RnnvtRZnkc5Kc9g==}
+ dependencies:
+ '@types/node': 18.19.3
+ dev: false
+
/@types/parse-json@4.0.0:
resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==}
dev: true
@@ -5738,7 +5803,7 @@ packages:
/constantinople@4.0.1:
resolution: {integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==}
dependencies:
- '@babel/parser': 7.23.4
+ '@babel/parser': 7.22.11
'@babel/types': 7.23.6
dev: true
@@ -5912,7 +5977,6 @@ packages:
/cosmiconfig-typescript-loader@5.0.0(@types/node@18.19.3)(cosmiconfig@8.3.6)(typescript@5.2.2):
resolution: {integrity: sha512-+8cK7jRAReYkMwMiG+bxhcNKiHJDM6bR9FD/nGBXOWdMLuYawjF5cGrtLilJ+LGd3ZjCXnJjR5DkfWPoIVlqJA==}
engines: {node: '>=v16'}
- requiresBuild: true
peerDependencies:
'@types/node': '*'
cosmiconfig: '>=8.2'
@@ -6009,12 +6073,12 @@ packages:
peerDependencies:
webpack: ^5.0.0
dependencies:
- icss-utils: 5.1.0(postcss@8.4.32)
- postcss: 8.4.32
- postcss-modules-extract-imports: 3.0.0(postcss@8.4.32)
- postcss-modules-local-by-default: 4.0.3(postcss@8.4.32)
- postcss-modules-scope: 3.0.0(postcss@8.4.32)
- postcss-modules-values: 4.0.0(postcss@8.4.32)
+ icss-utils: 5.1.0(postcss@8.4.31)
+ postcss: 8.4.31
+ postcss-modules-extract-imports: 3.0.0(postcss@8.4.31)
+ postcss-modules-local-by-default: 4.0.3(postcss@8.4.31)
+ postcss-modules-scope: 3.0.0(postcss@8.4.31)
+ postcss-modules-values: 4.0.0(postcss@8.4.31)
postcss-value-parser: 4.2.0
semver: 7.5.1
webpack: 5.89.0(webpack-cli@5.1.4)
@@ -7450,6 +7514,10 @@ packages:
flat-cache: 3.1.1
dev: true
+ /file-saver@2.0.5:
+ resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==}
+ dev: true
+
/file-url@3.0.0:
resolution: {integrity: sha512-g872QGsHexznxkIAdK8UiZRe7SkE6kvylShU4Nsj8NvfvZag7S0QuQ4IgvPDkk75HxgjIVDwycFTDAgIiO4nDA==}
engines: {node: '>=8'}
@@ -8451,13 +8519,13 @@ packages:
safer-buffer: 2.1.2
dev: true
- /icss-utils@5.1.0(postcss@8.4.32):
+ /icss-utils@5.1.0(postcss@8.4.31):
resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==}
engines: {node: ^10 || ^12 || >= 14}
peerDependencies:
postcss: ^8.1.0
dependencies:
- postcss: 8.4.32
+ postcss: 8.4.31
dev: true
/ieee754@1.2.1:
@@ -9549,6 +9617,10 @@ packages:
resolution: {integrity: sha512-worNHGKLDetmcEYDvh2stPCrrQRkP20E4l0iIS7F8EvzMqBBi7ltvFN5m1HvTf1P7Jk1txKhvFcmYsCr8O2F1Q==}
dev: true
+ /lodash.mapkeys@4.6.0:
+ resolution: {integrity: sha512-0Al+hxpYvONWtg+ZqHpa/GaVzxuN3V7Xeo2p+bY06EaK/n+Y9R7nBePPN2o1LxmL0TWQSwP8LYZ008/hc9JzhA==}
+ dev: true
+
/lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
dev: true
@@ -9562,6 +9634,14 @@ packages:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
dev: true
+ /lodash.pick@4.4.0:
+ resolution: {integrity: sha512-hXt6Ul/5yWjfklSGvLQl8vM//l3FtyHZeuelpzK6mm99pNvN9yTDruNZPEJZD1oWrqo+izBmB7oUfWgcCX7s4Q==}
+ dev: true
+
+ /lodash.pickby@4.6.0:
+ resolution: {integrity: sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==}
+ dev: true
+
/lodash.snakecase@4.1.1:
resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==}
dev: true
@@ -10190,6 +10270,12 @@ packages:
dev: false
optional: true
+ /nanoid@3.3.6:
+ resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+ dev: true
+
/nanoid@3.3.7:
resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -10355,7 +10441,7 @@ packages:
engines: {node: '>=10'}
dependencies:
hosted-git-info: 4.1.0
- is-core-module: 2.13.1
+ is-core-module: 2.13.0
semver: 7.5.4
validate-npm-package-license: 3.0.4
dev: true
@@ -10365,7 +10451,7 @@ packages:
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
dependencies:
hosted-git-info: 5.2.1
- is-core-module: 2.13.1
+ is-core-module: 2.13.0
semver: 7.5.4
validate-npm-package-license: 3.0.4
dev: true
@@ -10375,7 +10461,7 @@ packages:
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
dependencies:
hosted-git-info: 6.1.1
- is-core-module: 2.13.1
+ is-core-module: 2.13.0
semver: 7.5.4
validate-npm-package-license: 3.0.4
dev: true
@@ -10914,6 +11000,10 @@ packages:
- supports-color
dev: true
+ /papaparse@5.4.1:
+ resolution: {integrity: sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==}
+ dev: true
+
/parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -10954,7 +11044,7 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
dependencies:
- '@babel/code-frame': 7.23.4
+ '@babel/code-frame': 7.22.10
error-ex: 1.3.2
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
@@ -11183,45 +11273,45 @@ packages:
resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==}
dev: true
- /postcss-modules-extract-imports@3.0.0(postcss@8.4.32):
+ /postcss-modules-extract-imports@3.0.0(postcss@8.4.31):
resolution: {integrity: sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==}
engines: {node: ^10 || ^12 || >= 14}
peerDependencies:
postcss: ^8.1.0
dependencies:
- postcss: 8.4.32
+ postcss: 8.4.31
dev: true
- /postcss-modules-local-by-default@4.0.3(postcss@8.4.32):
+ /postcss-modules-local-by-default@4.0.3(postcss@8.4.31):
resolution: {integrity: sha512-2/u2zraspoACtrbFRnTijMiQtb4GW4BvatjaG/bCjYQo8kLTdevCUlwuBHx2sCnSyrI3x3qj4ZK1j5LQBgzmwA==}
engines: {node: ^10 || ^12 || >= 14}
peerDependencies:
postcss: ^8.1.0
dependencies:
- icss-utils: 5.1.0(postcss@8.4.32)
- postcss: 8.4.32
+ icss-utils: 5.1.0(postcss@8.4.31)
+ postcss: 8.4.31
postcss-selector-parser: 6.0.13
postcss-value-parser: 4.2.0
dev: true
- /postcss-modules-scope@3.0.0(postcss@8.4.32):
+ /postcss-modules-scope@3.0.0(postcss@8.4.31):
resolution: {integrity: sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==}
engines: {node: ^10 || ^12 || >= 14}
peerDependencies:
postcss: ^8.1.0
dependencies:
- postcss: 8.4.32
+ postcss: 8.4.31
postcss-selector-parser: 6.0.13
dev: true
- /postcss-modules-values@4.0.0(postcss@8.4.32):
+ /postcss-modules-values@4.0.0(postcss@8.4.31):
resolution: {integrity: sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==}
engines: {node: ^10 || ^12 || >= 14}
peerDependencies:
postcss: ^8.1.0
dependencies:
- icss-utils: 5.1.0(postcss@8.4.32)
- postcss: 8.4.32
+ icss-utils: 5.1.0(postcss@8.4.31)
+ postcss: 8.4.31
dev: true
/postcss-resolve-nested-selector@0.1.1:
@@ -11266,6 +11356,15 @@ packages:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
dev: true
+ /postcss@8.4.31:
+ resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
+ engines: {node: ^10 || ^12 || >=14}
+ dependencies:
+ nanoid: 3.3.6
+ picocolors: 1.0.0
+ source-map-js: 1.0.2
+ dev: true
+
/postcss@8.4.32:
resolution: {integrity: sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==}
engines: {node: ^10 || ^12 || >=14}
@@ -12118,7 +12217,7 @@ packages:
resolution: {integrity: sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==}
hasBin: true
dependencies:
- is-core-module: 2.13.1
+ is-core-module: 2.13.0
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
dev: true
@@ -13920,7 +14019,6 @@ packages:
/undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
- dev: true
/undici@5.25.4:
resolution: {integrity: sha512-450yJxT29qKMf3aoudzFpIciqpx6Pji3hEWaXqXmanbXF58LTAGCKxcJjxMXWu3iG+Mudgo3ZUfDB6YDFd/dAw==}
@@ -14737,7 +14835,7 @@ packages:
resolution: {integrity: sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==}
engines: {node: '>= 10.0.0'}
dependencies:
- '@babel/parser': 7.23.4
+ '@babel/parser': 7.22.11
'@babel/types': 7.23.6
assert-never: 1.2.1
babel-walk: 3.0.0-canary-5