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:
parent
34f79d5f69
commit
71a3a45b99
@ -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',
|
||||
};
|
||||
|
@ -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(
|
||||
|
@ -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[]>;
|
||||
|
@ -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', {
|
||||
|
@ -14,6 +14,7 @@ export enum FieldTypeEnum {
|
||||
Currency = 'Currency',
|
||||
Text = 'Text',
|
||||
Color = 'Color',
|
||||
Attachment = 'Attachment',
|
||||
}
|
||||
|
||||
export type FieldType = keyof typeof FieldTypeEnum;
|
||||
|
@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
133
src/components/Controls/Attachment.vue
Normal file
133
src/components/Controls/Attachment.vue
Normal 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>
|
@ -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;
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user