[Plugin] Frappe/ERPNext Better Attach Control

Hello everyone,

Based on this topic, I realized that Frappe’s Attach and Attach Image controls doesn’t allow customization like the allowed file types. So I decided to make them better.

I created a plugin that makes both Attach and Attach Image controls more flexible by allowing customization through passing a JSON object in the options of the field.

Example

{"allowed_file_types": ["jpg", "png", "gif"]}

Available Options

upload_notes

Upload text to be displayed.

Example: "Only allowed to upload images and video, with maximum size of 2MB"

Default: ""

allow_multiple

Allow multiple uploads.

Default: false

max_file_size

Maximum file size (in bytes) that is allowed to be uploaded.

Example: 2048 for 2KB

Default: Value of maximum file size in Frappe's settings

allowed_file_types

Array of allowed file types (mimes) or extensions to upload.

Example: ["image/*", "video/*", ".pdf", ".doc"]

Default: Not set for all files or ["image/*"]

max_number_of_files

Maximum number of files allowed to be uploaded if multiple upload is allowed.

Example: 4

Default: Value of maximum attachments set for the doctype

crop_image_aspect_ratio

Crop aspect ratio for images (Frappe >= v14.0.0).

Example: 1 or 16/9 or 4/3

Default: 1

I hope that at least some of you find this plugin useful.

14 Likes

First thanks for creating and sharing with all.
As i am still using V12 i needed to backport so made some changes and now is working on V12.
Here are the changes:

// FROM GitHub - kid1194/frappe-better-attach-control: A small plugin for Frappe that adds the support of customizations to the attach control.
// Backported to V12
// Modifed by HeLKDYS: 18-09-2022
import {
isArray,
isDataObject,
deepCloneObject
} from ‘./utils/check.js’;
import {
to_images_list
} from ‘./utils/mime.js’;

//Helkyds change 18-09-2022
//frappe.ui.form.ControlData = class ControlData extends frappe.ui.form.ControlData{
frappe.ui.form.ControlAttach = class ControlAttach extends frappe.ui.form.ControlAttach {
constructor(opts) {
$log(‘Initializing’);
super(opts);
}
make() {
super.make();
this._parse_options();
}
async _parse_options() {
if (!this._is_better) {
this._is_better = true;
this._is_table = this.frm ? isArray(this.frm.doc[this.df.fieldname]) : false;
this._def_options = null;
this._options = null;
this._values = [];
this._allow_multiple = false;
this._max_number_of_files = 0;
}
if (!this.df.options || this._def_options === this.df.options) return;
this._def_options = this.df.options;
if (frappe.utils.is_json(this.df.options)) {
const { message: passarJSON } = await frappe.call({
method: “angola_erp.util.angola.passa_json”,
args: {“val”:this.df.options},
});
if (passarJSON){
this.df.options = passarJSON;

            if (isDataObject(this.df.options)) {
                $log('Parsing options');
                var opts = {restrictions: {}},
                keys = ['upload_notes', 'allow_multiple', 'max_file_size', 'allowed_file_types', 'max_number_of_files', 'crop_image_aspect_ratio'];
                for (var k in this.df.options) {
                    let idx = keys.indexOf(k);
                    if (idx >= 0) {
                        if (idx < 2) opts[k] = this.df.options[k];
                        else opts.restrictions[k] = this.df.options[k];
                    }
                }
                this._options = opts;
                this._allow_multiple = opts.allow_multiple || false;
                this._max_number_of_files = opts.restrictions.max_number_of_files || 0;
                if (this._allow_multiple && this._max_number_of_files && this.frm
                    && (
                        this._max_number_of_files > frappe.get_meta(this.frm.doctype).max_attachments
                        || this._max_number_of_files > (this.frm.meta.max_attachments || 0)
                    )
                ) {
                    frappe.get_meta(this.frm.doctype).max_attachments = this.frm.meta.max_attachments = this._max_number_of_files;
                }
            }
        }

    }

}
_parse_image_types(opts) {
    opts.allowed_file_types = isArray(opts.allowed_file_types)
        ? to_images_list(opts.allowed_file_types) : [];
    if (!opts.allowed_file_types.length) opts.allowed_file_types = ['image/*'];
}
make_input() {
    this._parse_options();
    $log('Making attachment button');
    let me = this;
    this.$input = $('<button class="btn btn-default btn-sm btn-attach">')
        .html(__('Attach'))
        .prependTo(this.input_area)
        .on({
            click: function() {
                me.on_attach_click();
            },
            attach_doc_image: function() {
                me.on_attach_doc_image();
            }
        });

    $log('Making attachments list item');
    this.$value = $(`
            <div class="attached-file flex justify-between align-center">
                <div class="ellipsis">
                    <i class="fa fa-paperclip"></i>
                    <a class="attached-file-link" target="_blank"></a>
                </div>
                <div>
                    <a class="btn btn-xs btn-default" data-action="reload_attachment">${__('Reload File')}</a>
                    <a class="btn btn-xs btn-default" data-action="clear_attachment">${__('Clear')}</a>
                </div>
            </div>
        `)
        .appendTo(this.input_area)
        .toggle(false);
    frappe.utils.bind_actions_with_object(this.$value, this);
    this.toggle_reload_button();

    this._setup_display();

    this.input = this.$input.get(0);
    this.set_input_attributes();
    this.has_input = true;
}
_setup_display() {
    if (!this._allow_multiple) {
         if (this._images_only) this._on_setup_display();
    } else {
        this.$value.find('.attached-file-link')
        .on('click', function(e) {
            var dialog = new frappe.ui.Dialog({
                title: me.df.label,
                primary_action_label: 'Close',
                primary_action() {
                    dialog.hide();
                }
            }),
            body = dialog.$wrapper.find('.modal-body'),
            cont = $('<div>').addClass('container-fluid').appendTo(body);
            dialog.$wrapper.addClass('modal-dialog-scrollable');
            me._values.forEach(function(v) {
                let name = v[0],
                url = v[1],
                dom = $(`
                    <div class="row">
                        <div class="col col-12 frappe-control" data-fieldtype="Attach">
                            <div class="attached-file flex justify-between align-center">
                                <div class="ellipsis">
                                    <i class="fa fa-paperclip"></i>
                                    <a class="attached-file-link" target="_blank"></a>
                                </div>
                            </div>
                        </div>
                    </div>
                `).appendTo(cont).find('.attached-file-link').html(name).attr('href', url);
                if (me._images_only) me._on_setup_display(dom, url);
            });
            dialog.show();
        });
    }
}
clear_attachment() {
    $log('Clearing attachments');
    if (this.frm) {
        this.parse_validate_and_set_in_model(null);
        this.refresh();
        var me = this,
        callback = async function() {
            await me.parse_validate_and_set_in_model(null);
            me.refresh();
            me.frm.doc.docstatus == 1 ? me.frm.save('Update') : me.frm.save();
        };
        if (this._allow_multiple) {
            let _vals = this._value_to_array(this.value);
            for (var i = 0, l = _vals.length, last = l - 1; i < l; i++) {
                this.frm.attachments.remove_attachment_by_filename(_vals[i], i === last ? callback : null);
            }
        } else {
            this.frm.attachments.remove_attachment_by_filename(this.value, callback);
        }
    } else {
        this.dataurl = null;
        this.fileobj = null;
        this.set_input(null);
        this.parse_validate_and_set_in_model(null);
        this.refresh();
    }
}
reload_attachment() {
    $log('Reloading attachments');
    super.reload_attachment();
}
on_attach_click() {
    $log('Attaching file');
    this.set_upload_options();
    this.file_uploader = new frappe.ui.FileUploader(!this._images_only ? this.upload_options : this.image_upload_options);
}
on_attach_doc_image() {
    $log('Attaching image');
    this.set_upload_options();
    if (!this.image_upload_options.restrictions.crop_image_aspect_ratio)
        this.image_upload_options.restrictions.crop_image_aspect_ratio = 1;
    this.file_uploader = new frappe.ui.FileUploader(this.image_upload_options);
}
set_upload_options() {
    this._parse_options();
    $log('Setting upload options');
    if (this.upload_options) return;
    let options = {
        allow_multiple: false,
        on_success: file => {
            this.on_upload_complete(file);
            this.toggle_reload_button();
        },
        restrictions: {}
    };
    if (this.frm) {
        options.doctype = this.frm.doctype;
        options.docname = this.frm.docname;
        options.fieldname = this.df.fieldname;
    }
    if (isDataObject(this._options)) {
        Object.assign(options, this._options);
    }
    this.upload_options = options;
    this.image_upload_options = deepCloneObject(options);
    this._parse_image_types(this.image_upload_options.restrictions);
}
async _value_to_array(value, def) {
    let val = value;
    //if (!isArray(val)) val = frappe.utils.parse_json(val) || def || [];
    //if (!isArray(val)) val = passar_JSON(val) || def || [];
    if (!isArray(val)) {
        if (frappe.utils.is_json(val)) {
            const { message: passarJSON } = await frappe.call({
                method: "angola_erp.util.angola.passa_json",
                args: {"val":val},
            });
            if (passarJSON){
                val = passarJSON;
            }
        } else {
            val = def;
        }
    } else if (def) {
        val = def;
    } else {
        val = [];
    }

    return val;
}
_append_value(value) {
    if (this._allow_multiple) {
        let _value = this._value_to_array(this.value);
        if (_value.indexOf(value) < 0) _value.push(value);
        this.value = value = JSON.stringify(_value);
    }
    return value;
}
set_value(value, force_set_value=false) {
    return super.set_value(this._append_value(value), force_set_value);
}
set_input(value, dataurl) {
    if (value) {
        let _value = this._value_to_array(value, value);
        if (isArray(_value) && _value.length) {
            if (!this._allow_multiple) this.set_input(_value[0]);
            else {
                var me = this;
                _value.forEach(function(v) {
                    me.set_input(v);
                });
            }
            return;
        }
        if (this._allow_multiple) {
            let val_len = this._value_to_array(this.value).length;
            if (this._max_number_of_files && val_len === this._max_number_of_files) {
                let err = 'The file was skipped because only {1} uploads are allowed';
                if (this.frm) err += ' for DocType "{2}"';
                frappe.throw(__(err, [this._max_number_of_files, this.frm.doctype]));
                return;
            }
            this._append_value(value);
        } else {
            this.value = value;
        }
        this.$input.toggle(false);
        // value can also be using this format: FILENAME,DATA_URL
        // Important: We have to be careful because normal filenames may also contain ","
        let file_url_parts = value.match(/^([^:]+),(.+):(.+)$/);
        let filename;
        if (file_url_parts) {
            filename = file_url_parts[1];
            dataurl = file_url_parts[2] + ':' + file_url_parts[3];
        }
        let $link = this.$value.toggle(true).find('.attached-file-link');
        if (this._allow_multiple) {
            this._values.push([filename || value, dataurl || value]);
            let file_name = this._values[0];
            if (this._values.length > 1) {
                file_name += ' ' + _('and {0} more', {0: this._values.length - 1});
            }
            $link.html(file_name);
        } else {
            $link.html(filename || value)
            .attr('href', dataurl || value);
        }
    } else {
        this.value = null;
        this.$input.toggle(true);
        this.$value.toggle(false);
    }
}
async on_upload_complete(attachment) {
    $log('Attachment uploaded');
    if (this.frm) {
        await this.parse_validate_and_set_in_model(this._append_value(attachment.file_url));
        this.frm.attachments.update_attachment(attachment);
        this.frm.doc.docstatus == 1 ? this.frm.save('Update') : this.frm.save();
    }
    this.set_value(attachment.file_url);
}
toggle_reload_button() {
    this.$value.find('[data-action="reload_attachment"]')
    .toggle(this.file_uploader && this.file_uploader.uploader.files.length > 0);
}

};

function $log(txt) {
console.log('[Better Attach Control] ’ + txt);
}

1 Like

@Helio_Jesus Great.

Can you send me the files you modified through PM so I can create a separate plugin for v12. Also, there is a major update for the plugin that is still in Beta and with your backport I might be able to push this major update to the v12 plugin…

Actually did not install the app just copied better_attach.bundle.js to better_attach.js and made the changes i shared.
Also because V12 does not have frappe.utils.parse_json i copied from frappe.utils to my custom app and named passa_json
But will upload this better_attach.js as PM so you can add and review regarding frappe.utils.parse_json.

1 Like

Looks great and very useful. Thanks @kid1194! Why not create a PR to frappe? Seems like a very generic improvement of the attachment functionality that would benefit the entire project?!

2 Likes

@bluesky I’m trying to perfect it then I will ask frappe if the want to include it or not…

But they might not include it because it bypasses the max number of attachments and the max file size set in frappe… :yum:

1 Like

Great work @kid1194 !

I second @bluesky 's thoughts. A PR to frappe will definitely be beneficial.

1 Like

Hell everyone…

Testers needed for v2 of this plugin…

V2 applies the allowed file types restriction to both, web urls and file browser, and max file size restriction to file browser…

Best regards…

https://github.com/kid1194/frappe-better-attach-control/tree/v2-beta

1 Like

Hell everyone…

Testers needed for v2-Beta1 of this plugin…

I need confirmation that there are no bugs so I can push it to the main branch and to frappe cloud marketplace…

Best regards…

https://github.com/kid1194/frappe-better-attach-control/tree/v2-Beta1

getting build error

frappe@ubuntu:~/frappe-bench$ bench build
Assets for Commit 5cedae22ba5f30ec3535654cc2202c3013b4a564 don't exist
Linking /home/frappe/frappe-bench/apps/frappe_better_attach_control/frappe_better_attach_control/public to ./assets/frappe_better_attach_control                                                                                    ✔ Application Assets Linked                                                                                       


yarn run v1.22.19
warning ../../package.json: No license field
$ node esbuild --production --run-build-command
Browserslist: caniuse-lite is outdated. Please run:
  npx browserslist@latest --update-db
  Why you should do it regularly: https://github.com/browserslist/browserslist#browsers-data-updating
✘ [ERROR] Expected ";" but found ")"

    ../frappe_better_attach_control/frappe_better_attach_control/public/js/utils/index.js:230:9:
      230 │         });
          │          ^
          ╵          ;

clean: postcss.plugin was deprecated. Migration guide:
https://evilmartians.com/chronicles/postcss-8-plugin-migration
clean: postcss.plugin was deprecated. Migration guide:
https://evilmartians.com/chronicles/postcss-8-plugin-migration
 ERROR  There were some problems during build

Error: Build failed with 1 error:
../frappe_better_attach_control/frappe_better_attach_control/public/js/utils/index.js:230:9: ERROR: Expected ";" but found ")"
    at failureErrorWithLog (/home/frappe/frappe-bench/apps/frappe/node_modules/esbuild/lib/main.js:1600:15)
    at /home/frappe/frappe-bench/apps/frappe/node_modules/esbuild/lib/main.js:1246:28
    at processTicksAndRejections (node:internal/process/task_queues:96:5)
Terminated
error Command failed with exit code 143.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
Traceback (most recent call last):
  File "/usr/lib/python3.10/runpy.py", line 196, in _run_module_as_main
    return _run_code(code, main_globals, None,
  File "/usr/lib/python3.10/runpy.py", line 86, in _run_code
    exec(code, run_globals)
  File "/home/frappe/frappe-bench/apps/frappe/frappe/utils/bench_helper.py", line 109, in <module>
    main()
  File "/home/frappe/frappe-bench/apps/frappe/frappe/utils/bench_helper.py", line 18, in main
    click.Group(commands=commands)(prog_name="bench")
  File "/home/frappe/frappe-bench/env/lib/python3.10/site-packages/click/core.py", line 829, in __call__
    return self.main(*args, **kwargs)
  File "/home/frappe/frappe-bench/env/lib/python3.10/site-packages/click/core.py", line 782, in main
    rv = self.invoke(ctx)
  File "/home/frappe/frappe-bench/env/lib/python3.10/site-packages/click/core.py", line 1259, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
  File "/home/frappe/frappe-bench/env/lib/python3.10/site-packages/click/core.py", line 1259, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
  File "/home/frappe/frappe-bench/env/lib/python3.10/site-packages/click/core.py", line 1066, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "/home/frappe/frappe-bench/env/lib/python3.10/site-packages/click/core.py", line 610, in invoke
    return callback(*args, **kwargs)
  File "/home/frappe/frappe-bench/apps/frappe/frappe/commands/utils.py", line 82, in build
    bundle(mode, apps=apps, hard_link=hard_link, verbose=verbose, skip_frappe=skip_frappe)
  File "/home/frappe/frappe-bench/apps/frappe/frappe/build.py", line 257, in bundle
    frappe.commands.popen(command, cwd=frappe_app_path, env=get_node_env(), raise_err=True)
  File "/home/frappe/frappe-bench/apps/frappe/frappe/commands/__init__.py", line 98, in popen
    raise subprocess.CalledProcessError(return_, command)
subprocess.CalledProcessError: Command 'yarn run production --run-build-command' returned non-zero exit status 143.
frappe@ubuntu:~/frappe-bench$
1 Like

@mohitchechani You are a hero…
Thanks a lot foor testing the plugin…

I have fixed it, so please update…

And please PM your github username so I can show you some appreciation by listening you as one of the contributors…

My github username is ‘chechani’

Now build was perfect…getting below error while attaching

1 Like

@mohitchechani Again, thanks a lot for helping bro…
I have made some changes to the plugin and hopefully it fixes all these errors…

Please update and then let me know how it goes…

Now getting this error

1 Like

@mohitchechani I couldn’t find the reason for such error, I have checked every code in the plugin and frappe that is attachment related but I will check again…

Is the plugin functional? Is everything working?

This is not working in web-form.

1 Like

@mohsininspire I have been trying to fix this problem but it seems that it is not a bug in the plugin but a problem caused by a frappe core script web_script.js