# Componente

# Introducción

Este componente renderizará un input de tipo file picker. El mismo se compone de dos partes, una de renderizado y otra de lógica (IrFilePicker Bone), esta ultima será la encargada de el manejo de los datos.

# Uso

Es importante recalcar el nombre del componente IrFilePicker a nivel de código, ya que este deberá usarse para llamar al componente para renderizarlo, ya sea individualmente o en un IrForm.

# Ejemplo

El ejemplo no funcionará correctamente debido a que no se pueden subir archivos al servidor BE desde la documentación

# Propiedades

Propiedad Tipo Default Descripción
readonly Boolean

false

El valor readonly = true, establecerá al campo como solo lectura.

value Array

Array de filenames.

required Boolean

false

En valor true, define que el componente debe ser valorizado para ser válido.

placeholder String

''

Establece el placeholder en el campo.

accept String

''

Un String que defina los tipos de archivos que se podrán seleccionar en el file picker. La documentación de los valores de access se encuentran aquí

imageCompressOptions Object

undefined

Objeto que define opciones de compresión de imágenes previa a la subida al servidor. Solo aplicará si se encuentra definida y si el archivo a subir es una imagen. El detalle de opciones se encuentra aquí

subfolder String

undefined

DEPRECADA por propiedad folder

Definen un subdirectorio donde se guardará el archivo subido.
folder String

undefined

Define un directorio donde se guardará el archivo subido. Si no existe la carpeta se creará solo la de ultimo nivel . Es decir si folder: /var/www/upload y upload no existe se creará. Pero si no existe www no. Ya que solo crea si la ultima carpeta no existe. Esta carpeta siempre será relativa a /mnt/volume_nyc1_01_ir/ y no se podrá navegar hacia arriba en las carpetas, es decir, está anulado el uso de ../

url String

'http://api.bfluid.tech/public/uploads/'

Definen la URL que se utilizará para formar los preview concatenado con el value del archivo.

preview Boolean

false

Determina si se muestra o no un preview de los archivos seleccionados.
multiple Boolean

false

Permite subir varios archivos a la vez

save-draft Object

undefined

Establece una función y argumentos de la misma. La cual se usará para hacer un submit del formulario en el estado que se encuentre cuando se suba el archivo al servidor. A la función se enviará la misma estructura que al comportamiento fcn executer de formulario que puede verse aquí

Esta propiedad espera una estructura definida de la siguiente manera:

{
  fcn: String, // Nombre de la función de BD a ejecutar para el guardado del borrador
  args: Object, // Objeto de argumentos adicionales que se deseen enviar a la función, si no existe args, no se enviarán argumentos adicionales
}
hint Object | String

''

Genera un texto de ayuda para el componente, que podrá ser o no, clickeable para navegar a un link determinado.

Propiedad con estructura de objeto con los siguientes keys:

{
  link: String,
  text: String
  openNewTab:Boolean
  classes:String
}

o simplemente un String.

En la propiedad text del objeto se determinará el texto que mostrará el hint, esta propiedad debe estar valorizada para que se muestre el hint.

En la propiedad link del objeto se determinará el link al cual navegará al clickear en el hint, si no se encuentra valorizada el hint no será clickeable, solo será texto.

En la propiedad openNewTab del objeto se determinará si al clickearse el hint con link valorizado, se navegará en una nueva pestaña. Solo es útil paracuando la propedad link del objeto está definida. Si no se encuentra definido, se navegará en la misma pestaña.

En la propiedad classes del objeto, se determinarán las clases que se aplicarán al hint, las clases serán separadas por un espacio, sirven para definir estilos. Se utilizarán principalmente clases de vuetify.

Las clases aplicables para las filas de la tabla podrán encontrarse en Vuetify en los siguientes enlaces:

linked Boolean false

Determina la actualización del formulario contenedor.

base64 Array undefined

Si está definida como array vacío, se generará el base64 de los archivos subidos por el usuario y se corresponderán en el orden del arreglo con la propiedad value.

link Array null

Si está definida como array (incluso vacío), luego de cada subida exitosa de archivos valorizará un array con las URLs completas de los archivos subidos. Estas URLs se construyen concatenando la prop url con cada filename devuelto por el servidor. (podría decirse que es la concatenacion de las props url + folder + nombre de archivo subido)

A diferencia de value, que contiene únicamente los nombres de archivo (ej: ['imagen.png']), link contiene las URLs listas para usar (ej: ['https://api.bfluid.tech/public/uploads/imagen.png']).

# Código fuente

<template>
  <ir-file-picker-bone
    v-bind="$attrs"
    v-model="localValue"
    :file="file"
    @input="changeValue"
    @input-base64="changebase64"
    @update:link="updateLinkValue"
  >
    <div
      slot-scope="{
        imgEvents,
        fileName,
        areAllImages,
        fileSetted,
        loading,
        inputEvents,
        required,
        removeEvents,
        upload
      }"
    >
      <ir-image-gallery
        v-if="fileName && fileSetted && preview && areAllImages && !loading"
        :images="getFilesLinks(fileName)"
        :img-events="imgEvents"
      />
      <div
        v-if="fileSetted && preview && !loading && url && !areAllImages"
        style="position: relative"
      >
        <template v-if="!localUrl">
          <a
            v-for="file in fileName"
            :key="file"
            :href="`${url}${file}`"
            target="_blank"
          >
            <v-btn
              class="mr-2 mb-2"
              outlined
              rounded
              color="primary"
              download
            >
              {{ filenameShort(file.split('/').pop()) }}
            </v-btn>
          </a>
        </template>
        <template v-else>
          <a
            v-for="fileIterado in fileArray"
            :key="fileIterado.name"
            :href="getFileLink(fileIterado)"
            target="_blank"
          >
            <v-btn
              class="mr-2 mb-2"
              outlined
              rounded
              color="primary"
              download
            >
              {{ filenameShort(fileIterado.name.split('/').pop()) }}
            </v-btn>
          </a>
        </template>
      </div>
      <div class="d-flex">
        <v-file-input
          :ref="irFilePickerRef"
          v-model="file.file"
          data-cy="filepicker"
          prepend-icon="mdi-paperclip"
          :loading="loading"
          :clearable="false"
          v-bind="getFilteredProps"
          :rules="
            required == true
              ? [
                (v) =>
                  (v && (v.length > 0 || fileName != '')) ||
                  'Complete este campo'
              ]
              : []
          "
          :hide-details="false"
          v-on="inputEvents"
          @change="(e) => setLocalPreview(e, upload)"
        >
          <template #message="{ message }">
            <div v-html="message" />
          </template>
        </v-file-input>
        <div
          class="d-flex tw-align-center tw-cursor-pointer"
          v-on="removeEvents"
        >
          <v-icon>mdi-delete</v-icon>
        </div>
      </div>
    </div>
  </ir-file-picker-bone>
</template>

<script>
import IrFilePickerBone from './IrFilePickerBone.vue'
import IrImageGallery from '../../IrImageGallery/IrImageGallery.vue'
import { filePickerComponentName } from '../../../constants'
import { setHintTag } from '../../../helpers/hint'
import { getBaseName } from '../../../helpers/getFilesBaseNames'
import focusAndScrollMixin from '../../../mixins/focusAndScroll'
import { filterProps } from '../../../helpers/filterProps'
export default {
  name: filePickerComponentName,
  components: {
    IrFilePickerBone,
    IrImageGallery
  },
  mixins: [focusAndScrollMixin],
  inheritAttrs: false,
  props: {
    preview: {
      type: Boolean,
      default: false
    },
    value: { type: Array, required: true },
    url: { type: String, required: false, default: undefined },
    saveDraft: { type: Object, required: false, default: undefined },
    linked: { type: Boolean, required: false, default: false },
    isFocused: {
      type: Boolean,
      default: false
    },
    hint: {
      type: [String, Object],
      default: ''
    },
    link: { type: Array, required: false, default: null }
  },
  data () {
    return {
      irFilePickerRef: filePickerComponentName,
      file: { file: [] },
      localValue: this.value,
      otherProps: { ...this.$attrs },
      localUrl: false,
      currentFiles: null
    }
  },
  computed: {
    getFilteredProps () {
      return filterProps(this.$refs[this.irFilePickerRef], this.handledAttrs, [
        'data-cy'
      ])
    },
    fileArray () {
      //Retorna siempre un arreglo de archivos para poder iterarlo
      if (Array.isArray(this.file.file)) return this.file.file
      else return [this.file.file]
    },
    handledAttrs () {
      // Elimina attrs que son válidos para el componente de vuetify, pero que son manejados por el componente IR
      const hint = setHintTag(this.otherProps, this.hint) //from hintMaker mixin
      let aux = { ...this.otherProps, ...this.$attrs }
      delete aux.hint
      if (hint) {
        aux = { ...aux, hint }
      }
      delete aux.value
      delete aux.required
      delete aux.readonly
      delete aux.preview
      delete aux.isImage
      // delete aux.hint in hintMaker mixin
      return aux
    }
  },
  watch: {
    value (isValue) {
      //convierte value para v-model de v-file-input de tipo File
      this.localValue = isValue
      let blobPreload = []
      if (Array.isArray(isValue)) {
        isValue.forEach((file) => {
          let blob = new File([], file)
          blobPreload.push(blob)
        })
      }
      this.file.file = isValue ? blobPreload : null
    }
  },
  mounted () {
    if (this.isFocused) {
      let element = this.$refs[this.irFilePickerRef].$el.querySelector('input')
      this.focusAndScroll(element)
    }
    this.localValue = this.value
    //Inicializacion de value para file input de tipo File
    let blobPreload = []
    if (Array.isArray(this.value)) {
      this.value.forEach((file) => {
        let blob = new File([], file)
        blobPreload.push(blob)
      })
    }
    this.file.file = this.value ? blobPreload : null
    this.otherProps = this.$attrs // Se asigna en mounted para inicializar que funcione hintMaker y volver a calcular handledAttrs
  },
  methods: {
    getFilesLinks (fileName) {
      //Genera los links de los archivos dependiendo si son preview locales o value obtenido de BD
      if (this.localUrl) {
        return [this.localUrl]
      } else {
        return fileName.map((file) => `${this.url}${file}`)
      }
    },
    getFileLink (file) {
      //Retorna la url local de un archivo
      return URL.createObjectURL(file)
    },
    setLocalPreview (archivos, upload) {
      // Normalizar ambos a arrays
      const currentFilesArray = Array.isArray(this.currentFiles)
        ? this.currentFiles
        : [this.currentFiles]
      const archivosArray = Array.isArray(archivos) ? archivos : [archivos]

      // Comparar si hay alguna diferencia en los nombres de archivo
      const areDifferent =
        archivosArray.length !== currentFilesArray.length ||
        archivosArray.some(
          (file, index) =>
            getBaseName(file) !== getBaseName(currentFilesArray[index])
        )

      // Si todos los archivos son iguales, no continuar
      if (!areDifferent) return

      this.currentFiles = archivosArray
      //determina la URL par los archivos locales, tanto para uno o multiples
      if (!archivos) return
      if (Array.isArray(archivos))
        this.localUrl = archivos.map((file) => URL.createObjectURL(file))
      else this.localUrl = URL.createObjectURL(archivos)
      upload()
    },
    changeValue (v) {
      if (Array.isArray(v) && v.length > 0) {
        this.$emit('input', v)
        if (this.linked === true) this.$emit('change-linked-value')
        if (typeof this.saveDraft !== 'undefined')
          this.$emit('saveDraft', this.saveDraft)
      } else {
        //on clear input se emite value array vacío
        this.file.file = null
        this.$emit('input', [])
      }
    },
    changebase64 (value) {
      this.$emit('update-prop', { prop: 'base64', value })
    },
    updateLinkValue (value) {
      if (Array.isArray(this.link)) {
        if (Array.isArray(value)) {
          const newLinks = value.map((file) => `${this.url}${file}`)
          this.$emit('update-prop', { prop: 'link', value: newLinks })
        } else {
          this.$emit('update-prop', { prop: 'link', value: [`${this.url}${value}`] })
        }
      }
    },
    filenameShort (filename) {
      //Acorta los nombre de los archivos
      let ext = filename.split('.').pop()
      if (filename.length > 10) {
        return filename.slice(0, 20) + '...' + ext
      }
    }
  }
}
</script>

# Código fuente

<script>
const props = {
  value: { type: Array, required: true },
  file: { type: Object, required: true },
  base64: { type: Array },
  required: { type: Boolean, default: false },
  subfolder: { type: String }, // sub carpeta donde se guardará el archivo subido
  folder: { type: String }, // mismo que subfolder, mejora del nombre, se mantienen ambas para retrocompatibilidad
  imageCompressOptions: { type: Object }, // Activa la compresión de imágenes a la subida
}
import {
  normalizeProps,
  assignCamelToSnake
} from '../../../helpers/propsGenerator'
import { endpointIclUpload } from '../../../../src/constants'
import imageCompression from 'browser-image-compression'

const mergedProps = normalizeProps(props)
import { filePickerComponentName } from '../../../constants'
export default {
  name: filePickerComponentName + 'Bone',
  props: mergedProps,
  data () {
    return {
      loading: false,
      fileSelected: 0,
      fileName: this.file,
      structureError: false,
      isUpdatingByUpload: false
    }
  },
  computed: {
    areAllImages () {
      //determina si todos los archivos son imágenes, para mostrar preview de galería
      if (!Array.isArray(this.fileName)) return false
      for (let file of this.fileName) {
        if (
          !new RegExp(
            /\.(jpg|jpeg|png|gif|webp|bmp|svg|ico|tiff|tif|jfif|pjpeg|pjp|avif|apng|svgz|jpe|xbm)$/i
          ).test(file)
        ) {
          return false
        }
      }
      return true
    },
    fileSetted () {
      //Si this.file.file es array con elementos o si es object pero no es array ni null, entonces se muestra la foto
      return (
        (Array.isArray(this.file.file) && this.file.file.length > 0) ||
        (typeof this.file.file === 'object' &&
          !Array.isArray(this.file.file) &&
          this.file.file !== null)
      )
    },
    checkRequired () {
      if (this.required === true) {
        return this.file.length <= 0
      } else {
        return false
      }
    }
  },
  watch: {
    file () {
      this.uploadFile()
    }
  },
  created () {
    assignCamelToSnake(mergedProps, this)
  },
  mounted () {
    this.fileName = this.value
    this.structureError = this.$propError(
      props,
      this.$props,
      filePickerComponentName
    )
    if (this.structureError) {
      this.$iclstore.commit('updateSnack', {
        text: 'Ha ocurrido un error al cargar algunos campos del formulario',
        active: true,
        color: 'warning'
      })
    }
  },
  methods: {
    /**
     * genera y retorna el string base64 de un archivo.
     * @async
     * @function getBase64
     * @param {File} file - Archivo a convertir en base64
     * @returns {Promise<string>} String base64 del archivo pasado como parámetro.
     */
    getBase64 (file) {
      return new Promise((resolve, reject) => {
        var reader = new FileReader()
        reader.onload = () => {
          let base64Str = reader.result
          if (base64Str) return resolve(base64Str)
          return reject(new Error('Se produjo un error generando base64'))
        }
        reader.onerror = () => {
          reject(new Error('Error al generar base64'))
        }
        reader.readAsDataURL(file)
      })
    },

    /**
     * Sube un archivo o varios archivos al servidor mediante una solicitud POST usando FormData. Emitiendo el evento input con el valor retornado por el BE.
     * y genera el base64 del archivo si está definida la prop base64 como array
     * @async
     * @function uploadFile
     * @returns {Promise<boolean>} True si el archivo se subió correctamente, de lo contrario, devuelve false.
     */
    async uploadFile () {
      if (this.isUpdatingByUpload) return
      this.isUpdatingByUpload = true
      this.loading = true
      let isGenerateBase64 = Array.isArray(this.base64)
      let base64Aux = []
      try {
        if (!this.file || !this.file.file || this.file.file.length === 0) {
          return true
        }
        const form = new FormData()
        if (this.subfolder) form.append('subfolder', this.subfolder)
        else if (this.folder) form.append('subfolder', this.folder)
        if (Array.isArray(this.file.file)) {
          for (let file of this.file.file) {
            // Appendea al form cada file, si se envía directamente el file da error en API y si existe la prop base64 lo genera
            const isImage = file?.type?.includes('image')
            const blobOfFile =
              isImage && this.imageCompressOptions
                ? await imageCompression(file, this.imageCompressOptions)
                : file
            const fileToUpload =
              isImage && this.imageCompressOptions
                ? new File([blobOfFile], file.name)
                : file
            form.append('files', fileToUpload)
            if (isGenerateBase64) {
              let base64 = await this.getBase64(fileToUpload) //hecho en uploadFile para iterar una unica vez en el arreglo
              base64Aux.push(base64)
            }
          }
        } else {
          const isImage = this.file?.file?.type?.includes('image')
          const blobOfFile =
            isImage && this.imageCompressOptions
              ? await imageCompression(
                  this.file.file,
                  this.imageCompressOptions
                )
              : this.file.file
          const fileToUpload =
            isImage && this.imageCompressOptions
              ? new File([blobOfFile], this.file.file.name)
              : this.file.file
          form.append('files', fileToUpload)
          if (isGenerateBase64) {
            let base64 = await this.getBase64(fileToUpload)
            base64Aux.push(base64)
          }
        }
        const upload = await this.$iclAxios.post(endpointIclUpload, form, {
          headers: { 'Content-Type': 'multipart/form-data' }
        })
        this.fileName = upload.data.filename
        if (isGenerateBase64) this.$emit('input-base64', base64Aux)
        this.$emit('input', upload.data.filename)
        this.$emit('update:link', upload.data.filename)
        if (upload) return true
        else return false
      } catch (e) {
        //now error in $uploadFile
        console.log(e)
        if (isGenerateBase64) this.$emit('input-base64', [])
        this.$emit('input', [])
        return false
      } finally {
        this.loading = false
      }
    },
    elimina () {
      this.file.file = null
      this.fileName = []
      this.$emit('input', this.fileName)
    },
    closeDialog () {
      this.file.dialogImg = false
      this.fileSelected = 0
    },
    nextFile () {
      this.fileSelected++
    },
    prevFile () {
      this.fileSelected--
    }
  },
  render () {
    if (this.structureError) {
      return
    }
    return this.$scopedSlots.default({
      inputEvents: {
        focusout: (e) => {
          this.isUpdatingByUpload = false
        }
      },
      removeEvents: {
        click: this.elimina
      },
      upload: this.uploadFile,
      fileName: this.fileName,
      fileSetted: this.fileSetted,
      file: this.file.file,
      loading: this.loading,
      checkRequired: this.checkRequired,
      required: this.required,
      areAllImages: this.areAllImages,
      fileSelected: this.fileSelected,
      nextFile: this.nextFile,
      prevFile: this.prevFile
    })
  }
}
</script>
Last Updated: 3/9/2026, 7:21:25 PM