2
0
mirror of https://github.com/frappe/books.git synced 2025-02-08 23:18:31 +00:00

Merge pull request #484 from 18alantom/exports

feat: Exports
This commit is contained in:
Alan 2022-10-17 10:13:04 -07:00 committed by GitHub
commit 465ca089d0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 733 additions and 147 deletions

View File

@ -7,8 +7,8 @@ import {
} from '../../utils/translationHelpers'; } from '../../utils/translationHelpers';
import { ValueError } from './errors'; import { ValueError } from './errors';
type TranslationArgs = boolean | number | string; export type TranslationArgs = boolean | number | string;
type TranslationLiteral = TemplateStringsArray | TranslationArgs; export type TranslationLiteral = TemplateStringsArray | TranslationArgs;
class TranslationString { class TranslationString {
args: TranslationLiteral[]; args: TranslationLiteral[];

View File

@ -1,7 +1,10 @@
<template> <template>
<div :class="[inputClasses, containerClasses]"> <div :class="[inputClasses, containerClasses]">
<label class="flex items-center"> <label
<div class="mr-3 text-gray-600 text-sm" v-if="showLabel && !labelRight"> class="flex items-center"
:class="spaceBetween ? 'justify-between' : ''"
>
<div class="mr-3" :class="labelClasses" v-if="showLabel && !labelRight">
{{ df.label }} {{ df.label }}
</div> </div>
<div <div
@ -63,7 +66,7 @@
@focus="(e) => $emit('focus', e)" @focus="(e) => $emit('focus', e)"
/> />
</div> </div>
<div class="ml-3 text-gray-600 text-sm" v-if="showLabel && labelRight"> <div class="ml-3" :class="labelClasses" v-if="showLabel && labelRight">
{{ df.label }} {{ df.label }}
</div> </div>
</label> </label>
@ -77,10 +80,15 @@ export default {
extends: Base, extends: Base,
emits: ['focus'], emits: ['focus'],
props: { props: {
spaceBetween: {
default: false,
type: Boolean,
},
labelRight: { labelRight: {
default: true, default: true,
type: Boolean, type: Boolean,
}, },
labelClass: String,
}, },
data() { data() {
return { return {
@ -90,11 +98,13 @@ export default {
}; };
}, },
computed: { computed: {
/* labelClasses() {
inputClasses() { if (this.labelClass) {
return this.getInputClassesFromProp([]); return this.labelClass;
}
return 'text-gray-600 text-sm';
}, },
*/
checked() { checked() {
return this.value; return this.value;
}, },

View File

@ -1,111 +1,269 @@
<template> <template>
<div id="exportWizard" class="modal-body"> <div>
<div class="ml-4 col-6 text-left"> <!-- Export Wizard Header -->
<input <FormHeader :form-title="label" :form-sub-title="t`Export Wizard`" />
id="select-cbox" <hr />
@change="toggleSelect"
class="form-check-input" <!-- Export Config -->
type="checkbox" <div class="grid grid-cols-3 p-4 gap-4">
v-model="selectAllFlag" <Check
> v-if="configFields.useListFilters && Object.keys(listFilters).length"
<label class="form-check-label bold ml-2" for="select-cbox">{{ "Select/Clear All" }}</label> :df="configFields.useListFilters"
:space-between="true"
:show-label="true"
:label-right="false"
:value="useListFilters"
:border="true"
@change="(value: boolean) => (useListFilters = value)"
/>
<Select
v-if="configFields.exportFormat"
:df="configFields.exportFormat"
:value="exportFormat"
:border="true"
@change="(value: ExportFormat) => (exportFormat = value)"
/>
<Int
v-if="configFields.limit"
:df="configFields.limit"
:value="limit"
:border="true"
@change="(value: number) => (limit = value)"
/>
</div> </div>
<hr width="93%"> <hr />
<div class="row ml-4 mb-4">
<div v-for="column in columns" :key="column.id" class="form-check mt-2 col-6"> <!-- Fields Selection -->
<input :id="column.id" class="form-check-input" type="checkbox" v-model="column.checked"> <div class="max-h-80 overflow-auto custom-scroll">
<label class="form-check-label" :for="column.id">{{ column.content }}</label> <!-- Main Fields -->
<div class="p-4">
<h2 class="text-sm font-semibold text-gray-800">
{{ fyo.schemaMap[schemaName]?.label ?? schemaName }}
</h2>
<div class="grid grid-cols-3 border rounded mt-1">
<Check
v-for="ef of fields"
:label-class="
ef.fieldtype === 'Table'
? 'text-sm text-gray-600 font-semibold'
: 'text-sm text-gray-600'
"
:key="ef.fieldname"
:df="getField(ef)"
:show-label="true"
:value="ef.export"
@change="(value: boolean) => setExportFieldValue(ef, value)"
/>
</div>
</div>
<!-- Table Fields -->
<div class="p-4" v-for="efs of filteredTableFields" :key="efs.fieldname">
<h2 class="text-sm font-semibold text-gray-800">
{{ fyo.schemaMap[efs.target]?.label ?? schemaName }}
</h2>
<div class="grid grid-cols-3 border rounded mt-1">
<Check
v-for="ef of efs.fields"
:key="ef.fieldname"
:df="getField(ef)"
:show-label="true"
:value="ef.export"
@change="(value: boolean) => setExportFieldValue(ef, value, efs.target)"
/>
</div>
</div> </div>
</div> </div>
<div class="row footer-divider mb-3">
<div class="col-12" style="border-bottom:1px solid #e9ecef"></div> <!-- Export Button -->
</div> <hr />
<div class="row"> <div class="p-4 flex justify-between items-center">
<div class="col-12 text-right"> <p class="text-sm text-gray-600">
<f-button primary @click="save">{{ 'Download CSV' }}</f-button> {{ t`${numSelected} fields selected` }}
</div> </p>
<Button type="primary" @click="exportData">{{ t`Export` }}</Button>
</div> </div>
</div> </div>
</template> </template>
<script> <script lang="ts">
import FileSaver from 'file-saver' import { t } from 'fyo';
import { Field, FieldTypeEnum } from 'schemas/types';
import { fyo } from 'src/initFyo';
import {
getCsvExportData,
getExportFields,
getExportTableFields,
getJsonExportData
} from 'src/utils/export';
import { getSavePath, saveData, showExportInFolder } from 'src/utils/ipcCalls';
import { ExportField, ExportFormat, ExportTableField } from 'src/utils/types';
import { QueryFilter } from 'utils/db/types';
import { defineComponent, PropType } from 'vue';
import Button from './Button.vue';
import Check from './Controls/Check.vue';
import Int from './Controls/Int.vue';
import Select from './Controls/Select.vue';
import FormHeader from './FormHeader.vue';
export default { interface ExportWizardData {
props: ['title', 'rows', 'columnData'], useListFilters: boolean;
exportFormat: ExportFormat;
fields: ExportField[];
limit: number | null;
tableFields: ExportTableField[];
numUnfilteredEntries: number;
}
export default defineComponent({
props: {
schemaName: { type: String, required: true },
listFilters: { type: Object as PropType<QueryFilter>, default: () => {} },
pageTitle: String,
},
data() { data() {
const fields = fyo.schemaMap[this.schemaName]?.fields ?? [];
const exportFields = getExportFields(fields);
const exportTableFields = getExportTableFields(fields, fyo);
return { return {
selectAllFlag: true, limit: null,
columns: this.columnData useListFilters: true,
}; exportFormat: 'csv',
fields: exportFields,
tableFields: exportTableFields,
} as ExportWizardData;
}, },
methods: { methods: {
toggleSelect() { getField(ef: ExportField): Field {
this.columns = this.columns.map(column => { return {
return { fieldtype: 'Check',
id: column.id, label: ef.label,
content: column.content, fieldname: ef.fieldname,
checked: this.selectAllFlag };
};
});
}, },
close() { getExportField(
this.$modal.hide(); fieldname: string,
}, target?: string
checkNoneSelected(columns) { ): ExportField | undefined {
for (let column of columns) { let fields: ExportField[] | undefined;
if (column.checked) return false;
if (!target) {
fields = this.fields;
} else {
fields = this.tableFields.find((f) => f.target === target)?.fields;
} }
return true;
if (!fields) {
return undefined;
}
return fields.find((f) => f.fieldname === fieldname);
}, },
async save() { setExportFieldValue(ef: ExportField, value: boolean, target?: string) {
if (this.checkNoneSelected(this.columns)) { const field = this.getExportField(ef.fieldname, target);
alert( if (!field) {
`No columns have been selected.\n` + return;
`Please select at least one column to perform export.` }
field.export = value;
},
async exportData() {
const filters = JSON.parse(
JSON.stringify(this.useListFilters ? this.listFilters : {})
);
let data: string;
if (this.exportFormat === 'json') {
data = await getJsonExportData(
this.schemaName,
this.fields,
this.tableFields,
this.limit,
filters,
fyo
); );
} else { } else {
let selectedColumnIds = this.columns.map(column => { data = await getCsvExportData(
if (column.checked) return column.id; this.schemaName,
}); this.fields,
let selectedColumns = this.columnData.filter( this.tableFields,
column => selectedColumnIds.indexOf(column.id) != -1 this.limit,
filters,
fyo
); );
await this.exportData(this.rows, selectedColumns);
this.$modal.hide();
} }
await this.saveExportData(data);
}, },
async exportData(rows = this.rows, columns = this.columnData) { async saveExportData(data: string) {
let title = this.title; const fileName = this.getFileName();
let columnNames = columns.map(column => column.content).toString(); const { canceled, filePath } = await getSavePath(
let columnIDs = columns.map(column => column.id); fileName,
let rowData = rows.map(row => columnIDs.map(id => row[id]).toString()); this.exportFormat
let csvDataArray = [columnNames, ...rowData]; );
let csvData = csvDataArray.join('\n'); if (canceled || !filePath) {
let d = new Date(); return;
let fileName = [ }
title.replace(/\s/g, '-'),
[d.getDate(), d.getMonth(), d.getFullYear()].join('-'), await saveData(data, filePath);
`${d.getTime()}.csv` showExportInFolder(fyo.t`Export Successful`, filePath);
].join('_'); },
var blob = new Blob([csvData], { type: 'text/plain;charset=utf-8' }); getFileName() {
await FileSaver.saveAs(blob, fileName); const fileName = this.label.toLowerCase().replace(/\s/g, '-');
} const dateString = new Date().toISOString().split('T')[0];
} return `${fileName}_${dateString}`;
}; },
},
computed: {
label() {
if (this.pageTitle) {
return this.pageTitle;
}
return fyo.schemaMap?.[this.schemaName]?.label ?? '';
},
filteredTableFields() {
return this.tableFields.filter((f) => {
const ef = this.getExportField(f.fieldname);
return !!ef?.export;
});
},
numSelected() {
return (
this.filteredTableFields.reduce(
(acc, f) => f.fields.filter((f) => f.export).length + acc,
0
) +
this.fields.filter(
(f) => f.fieldtype !== FieldTypeEnum.Table && f.export
).length
);
},
configFields() {
return {
useListFilters: {
fieldtype: 'Check',
label: t`Use List Filters`,
fieldname: 'useListFilters',
},
limit: {
placeholder: 'Limit number of rows',
fieldtype: 'Int',
label: t`Limit`,
fieldname: 'limit',
},
exportFormat: {
fieldtype: 'Select',
label: t`Export Format`,
fieldname: 'exportFormat',
options: [
{ value: 'json', label: 'JSON' },
{ value: 'csv', label: 'CSV' },
],
},
};
},
},
components: { FormHeader, Check, Select, Button, Int },
});
</script> </script>
<style scoped>
.fixed-btn-width {
width: 5vw !important;
}
.bold {
font-size: 1.1rem;
font-weight: 600;
}
#select-cbox {
width: 15px;
height: 15px;
}
#exportWizard {
overflow: hidden;
}
</style>

View File

@ -0,0 +1,28 @@
<template>
<div
class="
px-4
text-xl
font-semibold
flex
justify-between
h-row-large
items-center
"
>
<h1>{{ formTitle }}</h1>
<p class="text-gray-600">
{{ formSubTitle }}
</p>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
props: {
formTitle: { type: String, default: '' },
formSubTitle: { type: String, default: '' },
},
});
</script>

View File

@ -16,7 +16,7 @@
v-if="openModal" v-if="openModal"
> >
<div <div
class="bg-white rounded-lg shadow-2xl w-form" class="bg-white rounded-lg shadow-2xl w-form border overflow-hidden"
v-bind="$attrs" v-bind="$attrs"
@click.stop @click.stop
> >
@ -25,14 +25,38 @@
</div> </div>
</template> </template>
<script> <script lang="ts">
export default { import { defineComponent } from 'vue';
export default defineComponent({
props: { props: {
openModal: { openModal: {
default: false, default: false,
type: Boolean, type: Boolean,
}, },
setCloseListener: {
default: true,
type: Boolean,
},
}, },
emits: ['closemodal'], emits: ['closemodal'],
}; watch: {
openModal(value: boolean) {
if (value) {
document.addEventListener('keyup', this.escapeEventListener);
} else {
document.removeEventListener('keyup', this.escapeEventListener);
}
},
},
methods: {
escapeEventListener(event: KeyboardEvent) {
if (event.code !== 'Escape') {
return;
}
this.$emit('closemodal');
},
},
});
</script> </script>

View File

@ -11,7 +11,7 @@
</div> </div>
<!-- Search Modal --> <!-- Search Modal -->
<Modal :open-modal="openModal" @closemodal="close"> <Modal :open-modal="openModal" @closemodal="close" :set-close-listener="false">
<!-- Search Input --> <!-- Search Input -->
<div class="p-1"> <div class="p-1">
<input <input

View File

@ -9,7 +9,7 @@
items-center items-center
mb-3 mb-3
w-96 w-96
z-10 z-30
bg-white bg-white
rounded-lg rounded-lg
" "

View File

@ -45,28 +45,14 @@
<!-- Invoice Form --> <!-- Invoice Form -->
<template #body v-if="doc"> <template #body v-if="doc">
<div <FormHeader
class=" :form-title="doc.notInserted ? t`New Entry` : doc.name"
px-4 :form-sub-title="
text-xl doc.schemaName === 'SalesInvoice'
font-semibold ? t`Sales Invoice`
flex : t`Purchase Invoice`
justify-between
h-row-large
items-center
" "
> />
<h1>
{{ doc.notInserted ? t`New Entry` : doc.name }}
</h1>
<p class="text-gray-600">
{{
doc.schemaName === 'SalesInvoice'
? t`Sales Invoice`
: t`Purchase Invoice`
}}
</p>
</div>
<hr /> <hr />
<div> <div>
@ -298,6 +284,7 @@ import FormControl from 'src/components/Controls/FormControl.vue';
import Table from 'src/components/Controls/Table.vue'; import Table from 'src/components/Controls/Table.vue';
import DropdownWithActions from 'src/components/DropdownWithActions.vue'; import DropdownWithActions from 'src/components/DropdownWithActions.vue';
import FormContainer from 'src/components/FormContainer.vue'; import FormContainer from 'src/components/FormContainer.vue';
import FormHeader from 'src/components/FormHeader.vue';
import StatusBadge from 'src/components/StatusBadge.vue'; import StatusBadge from 'src/components/StatusBadge.vue';
import { fyo } from 'src/initFyo'; import { fyo } from 'src/initFyo';
import { docsPathMap } from 'src/utils/misc'; import { docsPathMap } from 'src/utils/misc';
@ -323,6 +310,7 @@ export default {
FormContainer, FormContainer,
QuickEditForm, QuickEditForm,
ExchangeRate, ExchangeRate,
FormHeader,
}, },
provide() { provide() {
return { return {

View File

@ -24,24 +24,10 @@
<!-- Journal Entry Form --> <!-- Journal Entry Form -->
<template #body v-if="doc"> <template #body v-if="doc">
<div <FormHeader
class=" :form-title="doc.notInserted ? t`New Entry` : doc.name"
px-4 :form-sub-title="t`Journal Entry`"
text-xl />
font-semibold
flex
justify-between
h-row-large
items-center
"
>
<h1>
{{ doc.notInserted ? t`New Entry` : doc.name }}
</h1>
<p class="text-gray-600">
{{ t`Journal Entry` }}
</p>
</div>
<hr /> <hr />
<div> <div>
<div class="m-4 grid grid-cols-3 gap-y-4 gap-x-4"> <div class="m-4 grid grid-cols-3 gap-y-4 gap-x-4">
@ -148,6 +134,7 @@ import FormControl from 'src/components/Controls/FormControl.vue';
import Table from 'src/components/Controls/Table.vue'; import Table from 'src/components/Controls/Table.vue';
import DropdownWithActions from 'src/components/DropdownWithActions.vue'; import DropdownWithActions from 'src/components/DropdownWithActions.vue';
import FormContainer from 'src/components/FormContainer.vue'; import FormContainer from 'src/components/FormContainer.vue';
import FormHeader from 'src/components/FormHeader.vue';
import StatusBadge from 'src/components/StatusBadge.vue'; import StatusBadge from 'src/components/StatusBadge.vue';
import { fyo } from 'src/initFyo'; import { fyo } from 'src/initFyo';
import { docsPathMap } from 'src/utils/misc'; import { docsPathMap } from 'src/utils/misc';
@ -169,7 +156,8 @@ export default {
FormControl, FormControl,
Table, Table,
FormContainer, FormContainer,
}, FormHeader
},
provide() { provide() {
return { return {
schemaName: this.schemaName, schemaName: this.schemaName,

View File

@ -101,7 +101,7 @@ import ListCell from './ListCell';
export default defineComponent({ export default defineComponent({
name: 'List', name: 'List',
props: { listConfig: Object, filters: Object, schemaName: String }, props: { listConfig: Object, filters: Object, schemaName: String },
emits: ['makeNewDoc'], emits: ['makeNewDoc', 'updatedData'],
components: { components: {
Row, Row,
ListCell, ListCell,
@ -205,6 +205,7 @@ export default defineComponent({
orderBy, orderBy,
}) })
).map((d) => ({ ...d, schema: fyo.schemaMap[this.schemaName] })); ).map((d) => ({ ...d, schema: fyo.schemaMap[this.schemaName] }));
this.$emit('updatedData', filters);
}, },
}, },
}); });

View File

@ -1,6 +1,9 @@
<template> <template>
<div class="flex flex-col"> <div class="flex flex-col">
<PageHeader :title="title"> <PageHeader :title="title">
<Button :icon="false" @click="openExportModal = true">
{{ t`Export` }}
</Button>
<FilterDropdown <FilterDropdown
ref="filterDropdown" ref="filterDropdown"
@change="applyFilter" @change="applyFilter"
@ -22,13 +25,23 @@
:listConfig="listConfig" :listConfig="listConfig"
:filters="filters" :filters="filters"
class="flex-1 flex h-full" class="flex-1 flex h-full"
@updatedData="updatedData"
@makeNewDoc="makeNewDoc" @makeNewDoc="makeNewDoc"
/> />
<Modal :open-modal="openExportModal" @closemodal="openExportModal = false">
<ExportWizard
:schema-name="schemaName"
:title="pageTitle"
:list-filters="listFilters"
/>
</Modal>
</div> </div>
</template> </template>
<script> <script>
import Button from 'src/components/Button.vue'; import Button from 'src/components/Button.vue';
import ExportWizard from 'src/components/ExportWizard.vue';
import FilterDropdown from 'src/components/FilterDropdown.vue'; import FilterDropdown from 'src/components/FilterDropdown.vue';
import Modal from 'src/components/Modal.vue';
import PageHeader from 'src/components/PageHeader.vue'; import PageHeader from 'src/components/PageHeader.vue';
import { fyo } from 'src/initFyo'; import { fyo } from 'src/initFyo';
import { docsPathMap } from 'src/utils/misc'; import { docsPathMap } from 'src/utils/misc';
@ -47,9 +60,15 @@ export default {
List, List,
Button, Button,
FilterDropdown, FilterDropdown,
Modal,
ExportWizard,
}, },
data() { data() {
return { listConfig: undefined }; return {
listConfig: undefined,
openExportModal: false,
listFilters: {},
};
}, },
async activated() { async activated() {
if (typeof this.filters === 'object') { if (typeof this.filters === 'object') {
@ -63,6 +82,9 @@ export default {
docsPath.value = ''; docsPath.value = '';
}, },
methods: { methods: {
updatedData(listFilters) {
this.listFilters = listFilters;
},
async makeNewDoc() { async makeNewDoc() {
const doc = await fyo.doc.getNewDoc(this.schemaName, this.filters ?? {}); const doc = await fyo.doc.getNewDoc(this.schemaName, this.filters ?? {});
const path = this.getFormPath(doc.name); const path = this.getFormPath(doc.name);

10
src/shims-vue-custom.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
import { Fyo } from 'fyo';
import { TranslationLiteral } from 'fyo/utils/translation';
declare module 'vue' {
interface ComponentCustomProperties {
t: (...args: TranslationLiteral[]) => string;
fyo: Fyo;
platform: string;
}
}

334
src/utils/export.ts Normal file
View File

@ -0,0 +1,334 @@
import { Fyo } from 'fyo';
import { RawValueMap } from 'fyo/core/types';
import { Field, FieldTypeEnum, RawValue, TargetField } from 'schemas/types';
import { generateCSV } from 'utils/csvParser';
import { GetAllOptions, QueryFilter } from 'utils/db/types';
import { getMapFromList } from 'utils/index';
import { ExportField, ExportTableField } from './types';
const excludedFieldTypes = [
FieldTypeEnum.AttachImage,
FieldTypeEnum.Attachment,
];
interface CsvHeader {
label: string;
schemaName: string;
fieldname: string;
parentFieldname?: string;
}
export function getExportFields(
fields: Field[],
exclude: string[] = []
): ExportField[] {
return fields
.filter((f) => !f.computed && f.label && !exclude.includes(f.fieldname))
.map((field) => {
const { fieldname, label } = field;
const fieldtype = field.fieldtype as FieldTypeEnum;
return {
fieldname,
fieldtype,
label,
export: !excludedFieldTypes.includes(fieldtype),
};
});
}
export function getExportTableFields(
fields: Field[],
fyo: Fyo
): ExportTableField[] {
return fields
.filter((f) => f.fieldtype === FieldTypeEnum.Table)
.map((f) => {
const target = (f as TargetField).target;
const tableFields = fyo.schemaMap[target]?.fields ?? [];
const exportTableFields = getExportFields(tableFields, ['name']);
return {
fieldname: f.fieldname,
label: f.label,
target,
fields: exportTableFields,
};
})
.filter((f) => !!f.fields.length);
}
export async function getJsonExportData(
schemaName: string,
fields: ExportField[],
tableFields: ExportTableField[],
limit: number | null,
filters: QueryFilter,
fyo: Fyo
): Promise<string> {
const data = await getExportData(
schemaName,
fields,
tableFields,
limit,
filters,
fyo
);
convertParentDataToJsonExport(data.parentData, data.childTableData);
return JSON.stringify(data.parentData);
}
export async function getCsvExportData(
schemaName: string,
fields: ExportField[],
tableFields: ExportTableField[],
limit: number | null,
filters: QueryFilter,
fyo: Fyo
): Promise<string> {
const { childTableData, parentData } = await getExportData(
schemaName,
fields,
tableFields,
limit,
filters,
fyo
);
/**
* parentNameMap: Record<ParentName, Record<ParentFieldName, Rows[]>>
*/
const parentNameMap = getParentNameMap(childTableData);
const headers = getCsvHeaders(schemaName, fields, tableFields);
const rows: RawValue[][] = [];
for (const parentRow of parentData) {
const parentName = parentRow.name as string;
if (!parentName) {
continue;
}
const baseRowData = headers.parent.map(
(f) => (parentRow[f.fieldname] as RawValue) ?? ''
);
const tableFieldRowMap = parentNameMap[parentName];
if (!tableFieldRowMap || !Object.keys(tableFieldRowMap ?? {}).length) {
rows.push([baseRowData, headers.child.map((_) => '')].flat());
continue;
}
for (const tableFieldName in tableFieldRowMap) {
const tableRows = tableFieldRowMap[tableFieldName] ?? [];
for (const tableRow of tableRows) {
const tableRowData = headers.child.map((f) => {
if (f.parentFieldname !== tableFieldName) {
return '';
}
return (tableRow[f.fieldname] as RawValue) ?? '';
});
rows.push([baseRowData, tableRowData].flat());
}
}
}
const flatHeaders = [headers.parent, headers.child].flat();
const labels = flatHeaders.map((f) => f.label);
const keys = flatHeaders.map((f) => `${f.schemaName}.${f.fieldname}`);
rows.unshift(keys);
rows.unshift(labels);
return generateCSV(rows);
}
function getCsvHeaders(
schemaName: string,
fields: ExportField[],
tableFields: ExportTableField[]
) {
const headers = {
parent: [] as CsvHeader[],
child: [] as CsvHeader[],
};
for (const { label, fieldname, fieldtype, export: shouldExport } of fields) {
if (!shouldExport || fieldtype === FieldTypeEnum.Table) {
continue;
}
headers.parent.push({ schemaName, label, fieldname });
}
for (const tf of tableFields) {
if (!fields.find((f) => f.fieldname === tf.fieldname)?.export) {
continue;
}
for (const field of tf.fields) {
if (!field.export) {
continue;
}
headers.child.push({
schemaName: tf.target,
label: field.label,
fieldname: field.fieldname,
parentFieldname: tf.fieldname,
});
}
}
return headers;
}
function getParentNameMap(childTableData: Record<string, RawValueMap[]>) {
const parentNameMap: Record<string, Record<string, RawValueMap[]>> = {};
for (const key in childTableData) {
for (const row of childTableData[key]) {
const parent = row.parent as string;
if (!parent) {
continue;
}
parentNameMap[parent] ??= {};
parentNameMap[parent][key] ??= [];
parentNameMap[parent][key].push(row);
}
}
return parentNameMap;
}
async function getExportData(
schemaName: string,
fields: ExportField[],
tableFields: ExportTableField[],
limit: number | null,
filters: QueryFilter,
fyo: Fyo
) {
const parentData = await getParentData(
schemaName,
filters,
fields,
limit,
fyo
);
const parentNames = parentData.map((f) => f.name as string).filter(Boolean);
const childTableData = await getAllChildTableData(
tableFields,
fields,
parentNames,
fyo
);
return { parentData, childTableData };
}
function convertParentDataToJsonExport(
parentData: RawValueMap[],
childTableData: Record<string, RawValueMap[]>
) {
/**
* Map from List does not create copies. Map is a
* map of references, hence parentData is altered.
*/
const nameMap = getMapFromList(parentData, 'name');
for (const fieldname in childTableData) {
const data = childTableData[fieldname];
for (const row of data) {
const parent = row.parent as string | undefined;
if (!parent || !nameMap?.[parent]) {
continue;
}
nameMap[parent][fieldname] ??= [];
delete row.parent;
delete row.name;
(nameMap[parent][fieldname] as RawValueMap[]).push(row);
}
}
}
async function getParentData(
schemaName: string,
filters: QueryFilter,
fields: ExportField[],
limit: number | null,
fyo: Fyo
) {
const orderBy = !!fields.find((f) => f.fieldname === 'date')
? 'date'
: 'created';
const options: GetAllOptions = { filters, orderBy, order: 'desc' };
if (limit) {
options.limit = limit;
}
options.fields = fields
.filter((f) => f.export && f.fieldtype !== FieldTypeEnum.Table)
.map((f) => f.fieldname);
if (!options.fields.includes('name')) {
options.fields.unshift('name');
}
const data = await fyo.db.getAllRaw(schemaName, options);
convertRawPesaToFloat(data, fields);
return data;
}
async function getAllChildTableData(
tableFields: ExportTableField[],
parentFields: ExportField[],
parentNames: string[],
fyo: Fyo
) {
const childTables: Record<string, RawValueMap[]> = {};
// Getting Child Row data
for (const tf of tableFields) {
const f = parentFields.find((f) => f.fieldname === tf.fieldname);
if (!f?.export) {
continue;
}
childTables[tf.fieldname] = await getChildTableData(tf, parentNames, fyo);
}
return childTables;
}
async function getChildTableData(
exportTableField: ExportTableField,
parentNames: string[],
fyo: Fyo
) {
const exportTableFields = exportTableField.fields
.filter((f) => f.export && f.fieldtype !== FieldTypeEnum.Table)
.map((f) => f.fieldname);
if (!exportTableFields.includes('parent')) {
exportTableFields.unshift('parent');
}
const data = await fyo.db.getAllRaw(exportTableField.target, {
orderBy: 'idx',
fields: exportTableFields,
filters: { parent: ['in', parentNames] },
});
convertRawPesaToFloat(data, exportTableField.fields);
return data;
}
function convertRawPesaToFloat(data: RawValueMap[], fields: Field[]) {
const currencyFields = fields.filter(
(f) => f.fieldtype === FieldTypeEnum.Currency
);
for (const row of data) {
for (const { fieldname } of currencyFields) {
row[fieldname] = parseFloat((row[fieldname] ?? '0') as string);
}
}
}

View File

@ -1,3 +1,5 @@
import { FieldTypeEnum } from "schemas/types";
export interface MessageDialogButton { export interface MessageDialogButton {
label: string; label: string;
action: () => Promise<unknown> | unknown; action: () => Promise<unknown> | unknown;
@ -47,3 +49,20 @@ export interface SidebarItem {
schemaName?: string; schemaName?: string;
hidden?: () => boolean; hidden?: () => boolean;
} }
export interface ExportField {
fieldname: string;
fieldtype: FieldTypeEnum;
label: string;
export: boolean;
}
export interface ExportTableField {
fieldname: string;
label: string;
target: string;
fields: ExportField[];
}
export type ExportFormat = 'csv' | 'json';

View File

@ -37,6 +37,10 @@ export function getMapFromList<T, K extends keyof T>(
list: T[], list: T[],
name: K name: K
): Record<string, T> { ): Record<string, T> {
/**
* Do not convert function to use copies of T
* instead of references.
*/
const acc: Record<string, T> = {}; const acc: Record<string, T> = {};
for (const t of list) { for (const t of list) {
const key = t[name]; const key = t[name];