wearebraid / vue-formulate

⚡️ The easiest way to build forms with Vue.
https://vueformulate.com
MIT License
2.25k stars 245 forks source link

File input with "multiple" attribute doesn't upload newly added files after server returns error response #405

Open apotheosis91 opened 3 years ago

apotheosis91 commented 3 years ago

Describe the bug Using a File Input with "multiple" attribute and custom uploader, as shown in the documentation.

If the server responds with an error when you select a file, then when you try to upload another file, it appears in the list, but the actual upload is not performed.

To Reproduce Steps to reproduce the behavior:

  1. Go to reproduction page
  2. Click on the file input and select one file. The "server" rejects the file and an error is shown as expected.
  3. Click on the + Add File button and select another file.
  4. The file appears in the list, but the actual upload is not performed.
  5. [Optional] Repeat step 3 and see the result as in the step 4.

Reproduction https://codepen.io/apotheosis91/pen/xxgGQKb

Expected behavior Newly added files after the server responds with an error should be uploaded.

stefanaz commented 3 years ago

Having the same one. @apotheosis91 did you find some solution here?

apotheosis91 commented 3 years ago

@stefanaz, No, I had to create a custom formulate input, with dropzone.js under the hood.

vicenterusso commented 3 years ago

@stefanaz, No, I had to create a custom formulate input, with dropzone.js under the hood.

Mind to share? I will be implementind upload in the next few days and will probably face that same issue

apotheosis91 commented 3 years ago

@vicenterusso, Sure, here's what I did: 1) Create FormulateDropzone.vue component with the following content: (I also changed the default dropzone styles and markup, but I omit that part to make the code clearer)

<template>
    <div ref="dz" :id="context.attributes.id" class="dropzone">
        <div class="dz-message">
            <div v-if="!hasFiles">
                {{ dropzoneLabel }}
            </div>
        </div>
    </div>
</template>

<script>
import Dropzone from 'dropzone';
import 'dropzone/dist/min/dropzone.min.css';

Dropzone.autoDiscover = false;

export default {
    name: "FormulateDropzone",
    props: {
        context: {
            type: Object,
            required: true
        },
        initialFiles: {
            type: Array,
            default: () => []
        },
        maxFiles: {
            type: Number,
            default: null
        },
        maxFilesize: {
            type: Number,
            default: null
        },
        acceptedFiles: {
            type: String,
            default: null
        },
        dropzoneOptions: {
            type: Object,
            default: () => ({})
        },
        dropzoneLabel: {
            type: String,
            default: 'Drop files here or click to upload'
        }
    },
    mounted() {
        this.context.model = this.context.model || [];

        this.dropzone = new Dropzone(this.$refs.dz, this.options);

        this.dropzone
            .on('addedfile', this.onFileAdded)
            .on('removedfile', this.onFileRemoved)
            .on('success', this.onFileUploaded);

        // Display initial files
        // @see https://github.com/dropzone/dropzone/wiki/faq#how-to-show-files-already-stored-on-server
        this.initialFiles.forEach((item) => {
            const mockFile = {
                name: item.filename,
                size: item.size,
                mediaId: item.id,
                isMock: true
            };
            this.dropzone.displayExistingFile(mockFile, item.url, null, null, false);
        });

        // Dropzone's maxFiles option doesn't take into account initial mock files, so we need to reduce it (dropzone.js hack)
        if (this.options.maxFiles !== null) {
            this.dropzone.options.maxFiles -= this.initialFiles.length;
        }

    },
    beforeDestroy() {
        if (this.dropzone) {
            this.dropzone.destroy();
        }
    },
    computed: {
        options() {
            const defaults = {
                url: '/api/admin/uploads',
                method: 'post',
                paramName: 'file',
                headers: {
                    // 'X-XSRF-TOKEN': Cookies.get('XSRF-TOKEN')
                },
                addRemoveLinks: true,
                maxFiles: this.maxFiles,
                maxFilesize: this.maxFilesize,     // in MB
                acceptedFiles: this.acceptedFiles, // e.g. 'image/jpeg,image/png'
                error: this.onError,               // replace error handler here, to customize server error response handling
                dictMaxFilesExceeded: `Maximum allowed number of files: ${this.maxFiles}` // for displaying correct maxFiles count (dropzone.js hack)
            };

            return {...defaults, ...this.dropzoneOptions};
        },
        hasFiles() {
            return this.filesCount > 0;
        }
    },
    data() {
        return {
            filesCount: 0
        }
    },
    methods: {
        onFileAdded(file) {
            this.filesCount++;
        },
        onFileUploaded(file, response) {
            // Handle the case when the file is successfully uploaded to the server here.

            // In my case, the server returns id of the upload in the database.
            // I store it in the mediaId property and then write the required data to the VueFormulate context.model
            file.mediaId = response.id;

            this.context.model.push({id: response.id, action: 'add'});
        },
        onFileRemoved(file) {
            // Handle the case when the file is removed from the dropzone here.

            // In my case, I write the required data to the VueFormulate context.model
            // and then handle deletions on server side, after submitting the form with dropzone.
            if (file.mediaId) {
                this.context.model.push({id: file.mediaId, action: 'delete'});
            }

            this.filesCount--;

            // When removing initial file, we need to correct maxFiles option (dropzone.js hack)
            if (file.isMock && this.dropzone.options.maxFiles !== null) {
                this.dropzone.options.maxFiles++;
            }
        },
        onError(file, message, xhr) {
            // Handle Errors here.

            // I've modified the standard dropzone's error handler:
            // https://github.com/dropzone/dropzone/blob/9dfbd74fd245736dc4051f34a43e9ac7126f764e/src/options.js#L677
            // And when an error occurs, I remove the file preview from the dropzone
            // and show error notifications using euvl/vue-notification
            if (file.previewElement) {
                file.previewElement.classList.add("dz-error");

                // If the error was due to the XMLHttpRequest the xhr parameter will be present.
                if (typeof message === 'string') {
                    message = [message];
                } else if (xhr) {
                    message = this.parseUploadErrors(xhr);
                } else {
                    message = ['Error :('];
                }

                for (let node of file.previewElement.querySelectorAll("[data-dz-errormessage]")) {
                    node.textContent = message.join('; ');
                }

                if (this.$notify && typeof this.$notify === 'function') {
                    this.dropzone.removeFile(file);

                    message.forEach((value) => {
                        this.$notify({
                            group: 'app',
                            type: 'error',
                            title: 'Error',
                            text: value
                        });
                    });
                }
            }
        },
        parseUploadErrors(error) {
            // Parse server error response here and return array with error messages

            return ['Error. Something wrong happened.'];
        }
    }
}
</script>

2) Register component in VueFormulate. In the file where you instantiate Vue add the following:

import FormulateDropzone from "./components/FormulateDropzone";

Vue.component('FormulateDropzone', FormulateDropzone);

Vue.use(VueFormulate, {
    library: {
        dropzone: {
            classification: 'custom',
            component: 'FormulateDropzone',
            slotProps: {
                component: ['initialFiles', 'maxFiles', 'maxFilesize', 'acceptedFiles', 'dropzoneOptions', 'dropzoneLabel']
            }
        }
    },
});

3) Use it like any other FormulateInput components:

<formulate-form
    v-model="formValues"
    @submit="submitHandler"
>
    <formulate-input
        type="dropzone"
        name="images"
        label="Images"
        help="Add some images"
        dropzone-label="Click to upload files or drop them here"
        :max-files="5"
        :max-filesize="10"
        accepted-files="image/jpeg,image/png"
        :initial-files="[{id: 1, filename: 'file.jpg', size: '12345', url: 'path/to/file.jpg'}]"
    ></formulate-input>

    <!--  Other inputs ... -->

</formulate-form>
stefanaz commented 3 years ago

@vicenterusso thanks! That is very nice one.

vicenterusso commented 3 years ago

Thank you @apotheosis91 !