mirror of
https://github.com/frappe/books.git
synced 2025-01-24 07:38:25 +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',
|
DynamicLink: 'text',
|
||||||
Password: 'text',
|
Password: 'text',
|
||||||
Select: 'text',
|
Select: 'text',
|
||||||
File: 'binary',
|
Attachment: 'text',
|
||||||
Attach: 'text',
|
|
||||||
AttachImage: 'text',
|
AttachImage: 'text',
|
||||||
Color: 'text',
|
Color: 'text',
|
||||||
};
|
};
|
||||||
|
@ -7,7 +7,7 @@ import { Money } from 'pesa';
|
|||||||
import { Field, FieldTypeEnum, RawValue, TargetField } from 'schemas/types';
|
import { Field, FieldTypeEnum, RawValue, TargetField } from 'schemas/types';
|
||||||
import { getIsNullOrUndef } from 'utils';
|
import { getIsNullOrUndef } from 'utils';
|
||||||
import { DatabaseHandler } from './dbHandler';
|
import { DatabaseHandler } from './dbHandler';
|
||||||
import { DocValue, DocValueMap, RawValueMap } from './types';
|
import { Attachment, DocValue, DocValueMap, RawValueMap } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # Converter
|
* # Converter
|
||||||
@ -71,6 +71,8 @@ export class Converter {
|
|||||||
return toDocFloat(value, field);
|
return toDocFloat(value, field);
|
||||||
case FieldTypeEnum.Check:
|
case FieldTypeEnum.Check:
|
||||||
return toDocCheck(value, field);
|
return toDocCheck(value, field);
|
||||||
|
case FieldTypeEnum.Attachment:
|
||||||
|
return toDocAttachment(value, field);
|
||||||
default:
|
default:
|
||||||
return toDocString(value, field);
|
return toDocString(value, field);
|
||||||
}
|
}
|
||||||
@ -92,6 +94,8 @@ export class Converter {
|
|||||||
return toRawCheck(value, field);
|
return toRawCheck(value, field);
|
||||||
case FieldTypeEnum.Link:
|
case FieldTypeEnum.Link:
|
||||||
return toRawLink(value, field);
|
return toRawLink(value, field);
|
||||||
|
case FieldTypeEnum.Attachment:
|
||||||
|
return toRawAttachment(value, field);
|
||||||
default:
|
default:
|
||||||
return toRawString(value, field);
|
return toRawString(value, field);
|
||||||
}
|
}
|
||||||
@ -273,6 +277,24 @@ function toDocCheck(value: RawValue, field: Field): boolean {
|
|||||||
throwError(value, field, 'doc');
|
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 {
|
function toRawCurrency(value: DocValue, fyo: Fyo, field: Field): string {
|
||||||
if (isPesa(value)) {
|
if (isPesa(value)) {
|
||||||
return (value as Money).store;
|
return (value as Money).store;
|
||||||
@ -394,6 +416,23 @@ function toRawLink(value: DocValue, field: Field): string | null {
|
|||||||
throwError(value, field, 'raw');
|
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 {
|
function throwError<T>(value: T, field: Field, type: 'raw' | 'doc'): never {
|
||||||
throw new ValueError(
|
throw new ValueError(
|
||||||
`invalid ${type} conversion '${value}' of type ${typeof value} found, field: ${JSON.stringify(
|
`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 { AuthDemuxBase } from 'utils/auth/types';
|
||||||
import { DatabaseDemuxBase } from 'utils/db/types';
|
import { DatabaseDemuxBase } from 'utils/db/types';
|
||||||
|
|
||||||
|
export type Attachment = { name: string; type: string; data: string };
|
||||||
export type DocValue =
|
export type DocValue =
|
||||||
| string
|
| string
|
||||||
| number
|
| number
|
||||||
@ -11,6 +12,7 @@ export type DocValue =
|
|||||||
| Date
|
| Date
|
||||||
| Money
|
| Money
|
||||||
| null
|
| null
|
||||||
|
| Attachment
|
||||||
| undefined;
|
| undefined;
|
||||||
export type DocValueMap = Record<string, DocValue | Doc[] | DocValueMap[]>;
|
export type DocValueMap = Record<string, DocValue | Doc[] | DocValueMap[]>;
|
||||||
export type RawValueMap = Record<string, RawValue | RawValueMap[]>;
|
export type RawValueMap = Record<string, RawValue | RawValueMap[]>;
|
||||||
|
@ -77,7 +77,7 @@ export class Doc extends Observable<DocValue | Doc[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this._setDefaults();
|
this._setDefaults();
|
||||||
this._setValuesWithoutChecks(data);
|
this._setValuesWithoutChecks(data, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
get schemaName(): string {
|
get schemaName(): string {
|
||||||
@ -152,7 +152,7 @@ export class Doc extends Observable<DocValue | Doc[]> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_setValuesWithoutChecks(data: DocValueMap) {
|
_setValuesWithoutChecks(data: DocValueMap, convertToDocValue: boolean) {
|
||||||
for (const field of this.schema.fields) {
|
for (const field of this.schema.fields) {
|
||||||
const fieldname = field.fieldname;
|
const fieldname = field.fieldname;
|
||||||
const value = data[field.fieldname];
|
const value = data[field.fieldname];
|
||||||
@ -161,6 +161,8 @@ export class Doc extends Observable<DocValue | Doc[]> {
|
|||||||
for (const row of value) {
|
for (const row of value) {
|
||||||
this.push(fieldname, row);
|
this.push(fieldname, row);
|
||||||
}
|
}
|
||||||
|
} else if (value !== undefined && !convertToDocValue) {
|
||||||
|
this[fieldname] = value;
|
||||||
} else if (value !== undefined) {
|
} else if (value !== undefined) {
|
||||||
this[fieldname] = Converter.toDocValue(
|
this[fieldname] = Converter.toDocValue(
|
||||||
value as RawValue,
|
value as RawValue,
|
||||||
@ -578,7 +580,7 @@ export class Doc extends Observable<DocValue | Doc[]> {
|
|||||||
|
|
||||||
async _syncValues(data: DocValueMap) {
|
async _syncValues(data: DocValueMap) {
|
||||||
this._clearValues();
|
this._clearValues();
|
||||||
this._setValuesWithoutChecks(data);
|
this._setValuesWithoutChecks(data, false);
|
||||||
await this._setComputedValuesFromFormulas();
|
await this._setComputedValuesFromFormulas();
|
||||||
this._dirty = false;
|
this._dirty = false;
|
||||||
this.trigger('change', {
|
this.trigger('change', {
|
||||||
|
@ -14,6 +14,7 @@ export enum FieldTypeEnum {
|
|||||||
Currency = 'Currency',
|
Currency = 'Currency',
|
||||||
Text = 'Text',
|
Text = 'Text',
|
||||||
Color = 'Color',
|
Color = 'Color',
|
||||||
|
Attachment = 'Attachment',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FieldType = keyof typeof FieldTypeEnum;
|
export type FieldType = keyof typeof FieldTypeEnum;
|
||||||
|
@ -61,6 +61,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { ipcRenderer } from 'electron';
|
import { ipcRenderer } from 'electron';
|
||||||
import { fyo } from 'src/initFyo';
|
import { fyo } from 'src/initFyo';
|
||||||
|
import { getDataURL } from 'src/utils/misc';
|
||||||
import { IPC_ACTIONS } from 'utils/messages';
|
import { IPC_ACTIONS } from 'utils/messages';
|
||||||
import Base from './Base';
|
import Base from './Base';
|
||||||
|
|
||||||
@ -96,21 +97,10 @@ export default {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataURL = await this.getDataURL(name, data);
|
|
||||||
this.triggerChange(dataURL);
|
|
||||||
},
|
|
||||||
getDataURL(name, data) {
|
|
||||||
const extension = name.split('.').at(-1);
|
const extension = name.split('.').at(-1);
|
||||||
const blob = new Blob([data], { type: 'image/' + extension });
|
const type = 'image/' + extension;
|
||||||
|
const dataURL = await getDataURL(type, data);
|
||||||
return new Promise((resolve) => {
|
this.triggerChange(dataURL);
|
||||||
const fr = new FileReader();
|
|
||||||
fr.addEventListener('loadend', () => {
|
|
||||||
resolve(fr.result);
|
|
||||||
});
|
|
||||||
|
|
||||||
fr.readAsDataURL(blob);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
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
|
* 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',
|
'text-base',
|
||||||
'focus:outline-none',
|
'focus:outline-none',
|
||||||
'w-full',
|
'w-full',
|
||||||
'placeholder-gray-500',
|
'placeholder-gray-500',
|
||||||
];
|
];
|
||||||
|
},
|
||||||
if (this.textRight ?? isNumeric(this.df)) {
|
sizeClasses() {
|
||||||
classes.push('text-right');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.size === 'small') {
|
if (this.size === 'small') {
|
||||||
classes.push('px-2 py-1');
|
return 'px-2 py-1';
|
||||||
} else {
|
|
||||||
classes.push('px-3 py-2');
|
|
||||||
}
|
}
|
||||||
|
return 'px-3 py-2';
|
||||||
|
},
|
||||||
|
inputReadOnlyClasses() {
|
||||||
if (this.isReadOnly) {
|
if (this.isReadOnly) {
|
||||||
classes.push('text-gray-800 cursor-default');
|
return 'text-gray-800 cursor-default';
|
||||||
} else {
|
|
||||||
classes.push('text-gray-900');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.getInputClassesFromProp(classes);
|
return 'text-gray-900';
|
||||||
},
|
},
|
||||||
containerClasses() {
|
containerClasses() {
|
||||||
/**
|
/**
|
||||||
* Used to accomodate extending compoents where the input is contained in
|
* Used to accomodate extending compoents where the input is contained in
|
||||||
* a div eg AutoComplete
|
* 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) {
|
if (!this.isReadOnly) {
|
||||||
classes.push('focus-within:bg-gray-100');
|
return 'focus-within:bg-gray-100';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
borderClasses() {
|
||||||
if (this.border) {
|
if (this.border) {
|
||||||
classes.push('bg-gray-50 border border-gray-200');
|
return 'bg-gray-50 border border-gray-200';
|
||||||
}
|
}
|
||||||
|
|
||||||
return classes;
|
return '';
|
||||||
},
|
},
|
||||||
inputPlaceholder() {
|
inputPlaceholder() {
|
||||||
return this.placeholder || this.df.placeholder || this.df.label;
|
return this.placeholder || this.df.placeholder || this.df.label;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { h } from 'vue';
|
import { h } from 'vue';
|
||||||
import AttachImage from './AttachImage.vue';
|
import AttachImage from './AttachImage.vue';
|
||||||
|
import Attachment from './Attachment.vue';
|
||||||
import AutoComplete from './AutoComplete.vue';
|
import AutoComplete from './AutoComplete.vue';
|
||||||
import Check from './Check.vue';
|
import Check from './Check.vue';
|
||||||
import Color from './Color.vue';
|
import Color from './Color.vue';
|
||||||
@ -28,6 +29,7 @@ const components = {
|
|||||||
DynamicLink,
|
DynamicLink,
|
||||||
Int,
|
Int,
|
||||||
Float,
|
Float,
|
||||||
|
Attachment,
|
||||||
Currency,
|
Currency,
|
||||||
Text,
|
Text,
|
||||||
};
|
};
|
||||||
|
@ -110,3 +110,22 @@ export const docsPathMap: Record<string, string | undefined> = {
|
|||||||
Settings: 'miscellaneous/settings',
|
Settings: 'miscellaneous/settings',
|
||||||
ChartOfAccounts: 'miscellaneous/chart-of-accounts',
|
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…
x
Reference in New Issue
Block a user