2
0
mirror of https://github.com/frappe/books.git synced 2024-12-22 19:09:01 +00:00

feat: add Attachment type

This commit is contained in:
18alantom 2022-10-13 16:55:34 +05:30
parent 34f79d5f69
commit 71a3a45b99
10 changed files with 245 additions and 38 deletions

View File

@ -21,8 +21,7 @@ export const sqliteTypeMap: Record<string, KnexColumnType> = {
DynamicLink: 'text',
Password: 'text',
Select: 'text',
File: 'binary',
Attach: 'text',
Attachment: 'text',
AttachImage: 'text',
Color: 'text',
};

View File

@ -7,7 +7,7 @@ import { Money } from 'pesa';
import { Field, FieldTypeEnum, RawValue, TargetField } from 'schemas/types';
import { getIsNullOrUndef } from 'utils';
import { DatabaseHandler } from './dbHandler';
import { DocValue, DocValueMap, RawValueMap } from './types';
import { Attachment, DocValue, DocValueMap, RawValueMap } from './types';
/**
* # Converter
@ -71,6 +71,8 @@ export class Converter {
return toDocFloat(value, field);
case FieldTypeEnum.Check:
return toDocCheck(value, field);
case FieldTypeEnum.Attachment:
return toDocAttachment(value, field);
default:
return toDocString(value, field);
}
@ -92,6 +94,8 @@ export class Converter {
return toRawCheck(value, field);
case FieldTypeEnum.Link:
return toRawLink(value, field);
case FieldTypeEnum.Attachment:
return toRawAttachment(value, field);
default:
return toRawString(value, field);
}
@ -273,6 +277,24 @@ function toDocCheck(value: RawValue, field: Field): boolean {
throwError(value, field, 'doc');
}
function toDocAttachment(value: RawValue, field: Field): null | Attachment {
if (!value) {
return null;
}
if (typeof value !== 'string') {
console.log('being thrown doc1', typeof value, value);
throwError(value, field, 'doc');
}
try {
return JSON.parse(value) || null;
} catch {
console.log('being thrown doc2', typeof value, value);
throwError(value, field, 'doc');
}
}
function toRawCurrency(value: DocValue, fyo: Fyo, field: Field): string {
if (isPesa(value)) {
return (value as Money).store;
@ -394,6 +416,23 @@ function toRawLink(value: DocValue, field: Field): string | null {
throwError(value, field, 'raw');
}
function toRawAttachment(value: DocValue, field: Field): null | string {
if (!value) {
return null;
}
if (
(value as Attachment)?.name &&
(value as Attachment)?.data &&
(value as Attachment)?.type
) {
return JSON.stringify(value);
}
console.log('being thrown raw', typeof value, value);
throwError(value, field, 'raw');
}
function throwError<T>(value: T, field: Field, type: 'raw' | 'doc'): never {
throw new ValueError(
`invalid ${type} conversion '${value}' of type ${typeof value} found, field: ${JSON.stringify(

View File

@ -4,6 +4,7 @@ import { RawValue } from 'schemas/types';
import { AuthDemuxBase } from 'utils/auth/types';
import { DatabaseDemuxBase } from 'utils/db/types';
export type Attachment = { name: string; type: string; data: string };
export type DocValue =
| string
| number
@ -11,6 +12,7 @@ export type DocValue =
| Date
| Money
| null
| Attachment
| undefined;
export type DocValueMap = Record<string, DocValue | Doc[] | DocValueMap[]>;
export type RawValueMap = Record<string, RawValue | RawValueMap[]>;

View File

@ -77,7 +77,7 @@ export class Doc extends Observable<DocValue | Doc[]> {
}
this._setDefaults();
this._setValuesWithoutChecks(data);
this._setValuesWithoutChecks(data, true);
}
get schemaName(): string {
@ -152,7 +152,7 @@ export class Doc extends Observable<DocValue | Doc[]> {
return false;
}
_setValuesWithoutChecks(data: DocValueMap) {
_setValuesWithoutChecks(data: DocValueMap, convertToDocValue: boolean) {
for (const field of this.schema.fields) {
const fieldname = field.fieldname;
const value = data[field.fieldname];
@ -161,6 +161,8 @@ export class Doc extends Observable<DocValue | Doc[]> {
for (const row of value) {
this.push(fieldname, row);
}
} else if (value !== undefined && !convertToDocValue) {
this[fieldname] = value;
} else if (value !== undefined) {
this[fieldname] = Converter.toDocValue(
value as RawValue,
@ -578,7 +580,7 @@ export class Doc extends Observable<DocValue | Doc[]> {
async _syncValues(data: DocValueMap) {
this._clearValues();
this._setValuesWithoutChecks(data);
this._setValuesWithoutChecks(data, false);
await this._setComputedValuesFromFormulas();
this._dirty = false;
this.trigger('change', {

View File

@ -14,6 +14,7 @@ export enum FieldTypeEnum {
Currency = 'Currency',
Text = 'Text',
Color = 'Color',
Attachment = 'Attachment',
}
export type FieldType = keyof typeof FieldTypeEnum;

View File

@ -61,6 +61,7 @@
<script>
import { ipcRenderer } from 'electron';
import { fyo } from 'src/initFyo';
import { getDataURL } from 'src/utils/misc';
import { IPC_ACTIONS } from 'utils/messages';
import Base from './Base';
@ -96,21 +97,10 @@ export default {
return;
}
const dataURL = await this.getDataURL(name, data);
this.triggerChange(dataURL);
},
getDataURL(name, data) {
const extension = name.split('.').at(-1);
const blob = new Blob([data], { type: 'image/' + extension });
return new Promise((resolve) => {
const fr = new FileReader();
fr.addEventListener('loadend', () => {
resolve(fr.result);
});
fr.readAsDataURL(blob);
});
const type = 'image/' + extension;
const dataURL = await getDataURL(type, data);
this.triggerChange(dataURL);
},
},
};

View File

@ -0,0 +1,133 @@
<template>
<div :class="containerClasses" class="flex gap-2 items-center">
<label
for="attachment"
class="block whitespace-nowrap overflow-auto no-scrollbar"
:class="[inputClasses, !value ? 'text-gray-600' : 'cursor-default']"
>{{ label }}</label
>
<input
ref="fileInput"
id="attachment"
type="file"
accept="image/*,.pdf"
class="hidden"
:disabled="!!value"
@input="selectFile"
/>
<!-- Buttons -->
<div class="mr-2 flex gap-2">
<!-- Upload Button -->
<button v-if="!value" class="bg-gray-300 p-0.5 rounded" @click="upload">
<FeatherIcon name="upload" class="h-4 w-4 text-gray-600" />
</button>
<!-- Download Button -->
<button v-if="value" class="bg-gray-300 p-0.5 rounded" @click="download">
<FeatherIcon name="download" class="h-4 w-4 text-gray-600" />
</button>
<!-- Clear Button -->
<button
v-if="value && !isReadOnly"
class="bg-gray-300 p-0.5 rounded"
@click="clear"
>
<FeatherIcon name="x" class="h-4 w-4 text-gray-600" />
</button>
</div>
</div>
</template>
<script lang="ts">
import { t } from 'fyo';
import { Attachment } from 'fyo/core/types';
import { Field } from 'schemas/types';
import { convertFileToDataURL } from 'src/utils/misc';
import { defineComponent, PropType } from 'vue';
import FeatherIcon from '../FeatherIcon.vue';
import Base from './Base.vue';
export default defineComponent({
extends: Base,
props: {
df: Object as PropType<Field>,
value: { type: Object as PropType<Attachment | null>, default: null },
border: { type: Boolean, default: false },
size: String,
},
methods: {
upload() {
(this.$refs.fileInput as HTMLInputElement).click();
},
clear() {
// @ts-ignore
this.triggerChange(null);
},
download() {
if (!this.value) {
return;
}
const { name, data } = this.value;
if (!name || !data) {
return;
}
const a = document.createElement('a') as HTMLAnchorElement;
a.style.display = 'none';
a.href = data;
a.target = '_self';
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
},
async selectFile(e: Event) {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) {
return;
}
const attachment = await this.getAttachment(file);
// @ts-ignore
this.triggerChange(attachment);
},
async getAttachment(file: File | null) {
if (!file) {
return null;
}
const name = file.name;
const type = file.type;
const data = await convertFileToDataURL(file, type);
return { name, type, data };
},
},
computed: {
label() {
if (this.value) {
return this.value.name;
}
return this.df?.placeholder ?? this.df?.label ?? t`Attachment`;
},
inputReadOnlyClasses() {
if (!this.value) {
return 'text-gray-600';
} else if (this.isReadOnly) {
return 'text-gray-800 cursor-default';
}
return 'text-gray-900';
},
containerReadOnlyClasses() {
return '';
},
},
components: { FeatherIcon },
});
</script>

View File

@ -72,46 +72,66 @@ export default {
* These classes will be used by components that extend Base
*/
const classes = [
const classes = [];
classes.push(this.baseInputClasses);
if (this.textRight ?? isNumeric(this.df)) {
classes.push('text-right');
}
classes.push(this.sizeClasses);
classes.push(this.inputReadOnlyClasses);
return this.getInputClassesFromProp(classes).filter(Boolean);
},
baseInputClasses() {
return [
'text-base',
'focus:outline-none',
'w-full',
'placeholder-gray-500',
];
if (this.textRight ?? isNumeric(this.df)) {
classes.push('text-right');
}
},
sizeClasses() {
if (this.size === 'small') {
classes.push('px-2 py-1');
} else {
classes.push('px-3 py-2');
return 'px-2 py-1';
}
return 'px-3 py-2';
},
inputReadOnlyClasses() {
if (this.isReadOnly) {
classes.push('text-gray-800 cursor-default');
} else {
classes.push('text-gray-900');
return 'text-gray-800 cursor-default';
}
return this.getInputClassesFromProp(classes);
return 'text-gray-900';
},
containerClasses() {
/**
* Used to accomodate extending compoents where the input is contained in
* a div eg AutoComplete
*/
const classes = ['rounded'];
const classes = [];
classes.push(this.baseContainerClasses);
classes.push(this.containerReadOnlyClasses);
classes.push(this.borderClasses);
return classes.filter(Boolean);
},
baseContainerClasses() {
return ['rounded'];
},
containerReadOnlyClasses() {
if (!this.isReadOnly) {
classes.push('focus-within:bg-gray-100');
return 'focus-within:bg-gray-100';
}
return '';
},
borderClasses() {
if (this.border) {
classes.push('bg-gray-50 border border-gray-200');
return 'bg-gray-50 border border-gray-200';
}
return classes;
return '';
},
inputPlaceholder() {
return this.placeholder || this.df.placeholder || this.df.label;

View File

@ -1,6 +1,7 @@
<script>
import { h } from 'vue';
import AttachImage from './AttachImage.vue';
import Attachment from './Attachment.vue';
import AutoComplete from './AutoComplete.vue';
import Check from './Check.vue';
import Color from './Color.vue';
@ -28,6 +29,7 @@ const components = {
DynamicLink,
Int,
Float,
Attachment,
Currency,
Text,
};

View File

@ -110,3 +110,22 @@ export const docsPathMap: Record<string, string | undefined> = {
Settings: 'miscellaneous/settings',
ChartOfAccounts: 'miscellaneous/chart-of-accounts',
};
export async function getDataURL(type: string, data: Uint8Array) {
const blob = new Blob([data], { type });
return new Promise<string>((resolve) => {
const fr = new FileReader();
fr.addEventListener('loadend', () => {
resolve(fr.result as string);
});
fr.readAsDataURL(blob);
});
}
export async function convertFileToDataURL(file: File, type: string) {
const buffer = await file.arrayBuffer();
const array = new Uint8Array(buffer);
return await getDataURL(type, array);
}