I was trying to pass a custom css class to tailwindCSS through a template literal. However, being the component rendered on Storybook, this doesn't seem to work due to some conflict.
Instead, I'd like to pass a computed property, but I'm not sure how to write it in the most scalable way, since maybe there will be more css properties in the future.
This doesn't work:
const textSize = computed(() => { return `text-${props.textSize}`}
This works but it's pretty ugly:
const textSize = computed(() => {
return props.textSize === "xs"
? "text-xs"
: props.textSize === "sm"
? "text-sm"
: props.textSize === "base"
? "text-base"
: props.textSize === "lg"
? "text-lg"
: props.textSize === "xl"
? "text-xl"
: props.textSize === "2xl"
? "text-2xl"
: "text-3xl"
})
The whole component:
<template>
<div :class="[`h-full w-full p-2`, !props.isDisabled || `opacity-30`]">
<div
:class="[
'flex relative items-center h-full w-full',
props.checkBoxType === 'reverse-between' ? 'justify-between' : 'justify-start',
props.direction === 'vertical' ? 'flex-col justify-start gap-2' : 'items-center',
]"
>
<input
type="checkbox"
v-model="checked"
:class="[
'absolute z-50 order-1 w-6 h-6 opacity-0',
props.checkBoxType === 'reverse-between' && ' right-0',
props.checkBoxType === 'standard' ? 'order-0' : 'order-1',
props.direction === 'vertical' && 'top-0 right-4',
]"
:disabled="props.isDisabled"
/>
<div
:class="[
'bg-white w-6 h-6 flex flex-shrink-0 justify-center items-center p-1 border-2 border-gray',
props.checkBoxType === 'standard' && props.direction === 'horizontal' && 'order-0 mr-2',
props.checkBoxType === 'reverse-between' && props.direction === 'horizontal' && 'order-1 ml-2',
props.direction === 'vertical' && 'mr-0',
inputShape === 'square' ? 'rounded' : 'rounded-full',
props.isToggleEnabled || checked ? 'border-3' : 'border-1',
]"
>
<div
:class="[
'bg-gray w-3 h-3',
props.inputShape === 'circle' ? 'rounded-full' : 'rounded-sm',
props.isToggleEnabled || checked ? 'visible' : 'hidden',
]"
></div>
</div>
<div
:class="[
'select-none font-base flex justify-center items-center',
props.checkBoxType === 'standard' ? 'order-1' : 'order-0',
props.direction === 'vertical' && 'text-center',
]"
>
<span :class="[checked || isToggleEnabled ? 'font-bold' : 'font-normal', 'text-gray', textSize]">{{
props.label
}}</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { defineProps, ref, computed } from "vue"
const checked = ref(false)
const textSize = computed(() => {
return props.textSize === "xs"
? "text-xs"
: props.textSize === "sm"
? "text-sm"
: props.textSize === "base"
? "text-base"
: props.textSize === "lg"
? "text-lg"
: props.textSize === "xl"
? "text-xl"
: props.textSize === "2xl"
? "text-2xl"
: "text-3xl"
})
const props = defineProps({
/**
* Sets the label for the input element
*/
label: {
type: String,
default: "example",
},
/**
* Changes the layout between text and checkbox button
*/
checkBoxType: {
default: "standard",
},
/**
* Sets the shape of the checkbox as squared or circled
*/
inputShape: {
default: "square",
},
/**
* Sets the direction of the component as horizontal or vertical
*/
direction: {
default: "horizontal",
},
/**
* Sets the text size
*/
textSize: {
// type: String as PropType<FontSize>,
type: String,
default: "base",
},
/**
* Toggles or untoggles the checkbox
*/
isToggleEnabled: {
type: Boolean,
},
/**
* If false, the whole component is greyed out
*/
isDisabled: {
type: Boolean,
default: false,
},
})
</script>
As #kissu mentioned in his comment Interpolation of class names is indeed not feasible in Tailwind , so you need to create a clean object with prop value as field and the class name as value :
const textSizes = {
'xs':'text-xs',
'sm':'text-sm',
'base':'text-base',
'lg':'text-lg',
'xl':'text-xl',
'2xl':'text-2xl',
'3xl':'text-3xl',
}
const textSize = computed(() => textSizes[props.textSize])
Related
In my application I have a list of documents that I display in a table, each document has a specific type, which is specified in json file as enum values.
I can already display all the documents without any problems, but now I have tried to create an input where the user can choose from the list of enum values, and when doing so only the documents with the selected enum type will be shown in the table.
This does sorta work, but the problem is that I have somehow created an infinite update loop, which causes the application to randomly stop working.
This is the template. I am using a custom made template component, but that is not related to the issue.
<template>
<b-container>
<div #dragover.prevent.stop #drop.prevent.stop="onDropEvent">
<b-card-header header-tag="header" class="p-1" role="tab">
<b-button-toolbar justify>
<b-button
:class="showCollapse ? 'collapsed' : null"
#click="showCollapse = !showCollapse"
variant="outline-info"
class="flex-grow-1"
>
<span class="float-left">{{ folderName }}</span>
<span class="float-right">
{{
search ?
$t('documentCountFiltered', {filtered: filteredDocuments.length, count: documents.length}) :
$tc('documentCount', documents.length)
}}
</span>
</b-button>
<template v-if="!inherited && mode !== 'READ_ONLY'">
<label class="document-uploader btn btn-sm btn-outline-primary ml-2">
<span>
<i class="fas fa-fw fa-file-upload"></i>
{{ $t('uploadFiles') }}
</span>
<input type="file" multiple #change="selectFiles">
</label>
<b-dropdown
v-if="mode !== 'READ_ONLY' && mode !== 'RESTRICTED'"
size="sm" variant="outline-primary" class="ml-2 template-dropdown" no-caret right>
<template slot="button-content">
<i class="fas fa-fw fa-file-medical"></i> {{ $t('createDocument') }}
</template>
<b-dropdown-header v-if="companyTemplates.length !== 0" id="dropdown-header-templates">
{{ $t('companyTemplates') }}
</b-dropdown-header>
<b-dropdown-item v-if="companyTemplates.length !== 0" v-for="template of companyTemplates"
#click="emitWordDocumentCreateSelectEvent(template)">
{{ template.name }} <i v-if="template.freeTextEnabled" class="fa-fw fas fa-paragraph"></i>
</b-dropdown-item>
<b-dropdown-divider
v-if="companyTemplates.length !== 0 && companyFormBuilderTemplates.length > 0"></b-dropdown-divider>
<b-dropdown-header v-if="companyFormBuilderTemplates.length > 0" id="dropdown-header-inheritedDocuments">
{{ $t('formBuilderTemplates') }}
</b-dropdown-header>
<b-dropdown-item
v-for="template of companyFormBuilderTemplates"
#click="emitDocumentCreateSelectEvent(template)">
{{ template.name }}
</b-dropdown-item>
<b-dropdown-divider
v-if="globalTemplates.length !== 0"></b-dropdown-divider>
<b-dropdown-header v-if="globalTemplates.length > 0" id="dropdown-header-globalDocuments">
{{ $t('globalTemplates') }}
</b-dropdown-header>
<b-dropdown-item
v-for="template of globalTemplates"
#click="emitGlobalDocumentCreateSelectEvent(template)">
{{ template.name }}
</b-dropdown-item>
</b-dropdown>
</template>
</b-button-toolbar>
</b-card-header>
<b-form-group class="mt-4">
<w-b-form-select v-model="filteredDocumentType">
<template>
<b-form-select-option :value="null" disabled>-- {{ $t('selectDocumentByType') }} --</b-form-select-option>
</template>
<b-form-select-option :value="'all'">({{ $t('all') }})</b-form-select-option>
<option v-for="documentType in availableDocumentTypes" :key="documentType" :value="documentType">
{{ $t('model.document.types.' + documentType) }}
</option>
</w-b-form-select>
</b-form-group>
<b-collapse v-model="showCollapse">
<common-table
:fields="fields"
:items="filteredDocuments"
primary-key="id"
sort-by="creationDateTime"
sort-desc
>
<template slot="head(select)">
<check-all-checkbox :list="filteredDocuments" #change="handleTag(documents, $event)" property="selected"/>
</template>
<template slot="cell(select)" slot-scope="data">
<w-b-form-checkbox v-model="data.item.selected" v-if="data.item.selected != null"
#change="handleTag([data.item], $event)" data-test-id="check-box"/>
</template>
<template slot="cell(name)" slot-scope="data">
<div class="d-flex">
<div style="flex: 2 0 0">
<b-dropdown :text="data.item.name" variant="link" toggle-class="name-cell">
<b-dropdown-item #click="viewFile(data.item)" v-if="data.item.type != 'EMAIL'"><i
class="fas fa-eye"></i> {{ $t('showDocument') }}
</b-dropdown-item>
<b-dropdown-item #click="downloadFile(data.item)"><i class="fas fa-file-download"></i> {{
$t('download')
}}
</b-dropdown-item>
</b-dropdown>
</div>
<div style="flex: 1 0 0" v-if="data.item.uploading">
<b-progress :animated="!data.item.error" striped class="h-100">
<b-progress-bar
:value="100"
:variant="data.item.error ? 'danger' : 'primary'"
:label="data.item.error ? 'Error' : 'Uploading...'"
/>
</b-progress>
</div>
</div>
</template>
</common-table>
</b-collapse>
<p-d-f-j-s-viewer ref="pdf-viewer"/>
</div>
</b-container>
</template>
My script
<script>
import CheckAllCheckbox from '#/components/CheckAllCheckbox';
import CommonTable from '#/components/common/CommonTable';
import CommonInput from '#/components/common/CommonInput';
import {applianceService} from '#/services/appliance';
import PDFJSViewer from '#/components/PDFJSViewer';
import axios from '#/config/axios';
import {documentService} from '#/services/document';
import documentTypes from '#/models/document/type';
import {propertyFacilityService} from '#/services/property-facility';
import CommonCollapsible from '#/components/common/CommonCollapsible';
export default {
props: {
documents: Array,
documentOwnerType: String,
companyTemplates: Array,
companyFormBuilderTemplates: Array,
globalTemplates: Array,
inherited: Boolean,
startCollapsed: {
type: Boolean,
default: false
},
mode: String,
search: String,
applianceId: String,
propertyFacilityId: String,
noteId:String
},
components: {
CheckAllCheckbox,
CommonTable,
CommonCollapsible,
CommonInput,
PDFJSViewer
},
data() {
return {
documentTypes,
showCollapse: true,
filteredDocumentType: null
};
},
computed: {
filteredDocuments() {
console.log(this.filteredDocumentType)
console.log(this.documents)
if (this.filteredDocumentType === null || this.filteredDocumentType === 'all') {
return (this.documents ?? []).filter(document =>
(document.name.toUpperCase().includes(this.search.toUpperCase()) ||
this.$t(`model.document.types.${document.type}`).toUpperCase().includes(this.search.toUpperCase())))
}
else {
return (this.documents ?? []).filter(document =>
(document.name.toUpperCase().includes(this.search.toUpperCase()) ||
this.$t(`model.document.types.${document.type}`).toUpperCase().includes(this.search.toUpperCase())) &&
document.type === this.filteredDocumentType)
}
},
folderName() {
if (this.inherited) {
return this.$t('sharedDocuments');
} else if (this.documentOwnerType === 'COMPANY') {
return this.$t('companyDocuments');
} else {
return this.$t('documents');
}
},
availableDocumentTypes() {
return this.documentTypes.sort((a, b) => this.getDocumentTypeText(a).localeCompare(this.getDocumentTypeText(b)));
},
fields() {
return [
{
key: 'select',
thStyle: 'width: 1%'
},
{
key: 'name',
label: this.$t('name'),
sortable: true
},
{
key: 'inherited',
label: this.$t('inherited'),
formatter: inherited => this.$t(inherited ? 'yes' : 'no'),
sortable: true,
hide: this.inherited
},
{
key: 'sharedOnTC',
label: this.$t('sharedOnTC'),
formatter: sharedOnTC => this.$t(sharedOnTC ? 'yes' : 'no'),
sortable: true,
hide: this.sharedOnTC
},
{
key: 'type',
label: this.$t('type'),
formatter: type => this.$t('model.document.types.' + type),
sortable: true,
sortByFormatted: true
},
{
key: 'tags',
label: this.$t('tags'),
formatter: tags => tags.join(', '),
sortable: true,
sortByFormatted: true
},
{
key: 'signed',
label: this.$t('signed'),
formatter: sharedOnTC => this.$t(sharedOnTC ? 'yes' : 'no'),
sortable: true,
sortByFormatted: true
},
{
key: 'creationDateTime',
label: this.$t('created'),
sortable: true,
template: {type: 'date', format: 'L LT'}
},
{
key: 'changedDateTime',
label: this.$t('changed'),
sortable: true,
template: {type: 'date', format: 'L LT'}
},
{
key: 'actions',
hide: this.inherited || this.mode === 'READ_ONLY',
template: {
type: 'actions',
cell: [
{
icon: 'fa-edit',
tooltip: this.$t('edit'),
if: this.mode !== 'RESTRICTED',
disabled: data => data.item.uploading || !data.item.documentTemplateId || data.item.signed,
action: data => this.emitDocumentEditTemplateSelectEvent(data.item)
},
{
icon: 'fa-cog',
tooltip: this.$t('documentSettings'),
disabled: data => data.item.uploading,
action: data => this.emitDocumentEditSelectEvent(data.item)
},
{
icon: 'fa-trash',
variant: 'outline-danger',
disabled: data => data.item.uploading,
action: data => this.emitDocumentDeleteSelectEvent(data.item)
}
]
}
}
].filter(field => !field.hide);
}
},
methods: {
getDocumentTypeText(type) {
return this.$t(`model.document.types.${type}`);
},
selectFiles(event) {
this.emitFileUploadEvent(event.target.files);
event.target.value = '';
},
onDropEvent(event) {
this.emitFileUploadEvent(event.dataTransfer.files);
},
downloadFile(document) {
documentService.downloadDocument(document.id)
.catch(error => {
console.error(error);
});
},
viewFile(document) {
documentService.getPublicDownloadToken(document.id).then(result => {
let fileName = `${axios.defaults.baseURL}/file/public/${result.data}/download`;
this.$refs['pdf-viewer'].show(fileName);
}).catch(error => {
console.error(error);
});
},
emitGlobalDocumentCreateSelectEvent(template) {
this.$emit('document-global-create-select', template);
},
emitDocumentCreateSelectEvent(template) {
this.$emit('document-create-select', template);
},
emitWordDocumentCreateSelectEvent(template) {
this.$emit('document-word-create-select', template);
},
emitDocumentEditSelectEvent(document) {
documentService.getDocument(document.id).then(result => {
document = result.data;
this.$emit('document-edit-select', document);
});
},
emitDocumentEditTemplateSelectEvent(document) {
this.$emit('document-edit-template-select', document);
},
emitDocumentDeleteSelectEvent(document) {
this.$emit('document-delete-select', document);
},
emitFileUploadEvent(files) {
if (files && files.length) {
this.$emit('file-upload', [...files]);
}
},
handleTag(documents, selected) {
if (this.applianceId) {
applianceService.updateDocuments(this.applianceId, {
documentIds: documents.map(doc => doc.id), selected: selected
}).then(({data: documents}) => {
documents.forEach(doc => {
this.documents.splice(this.documents.findIndex(d => d.id === doc.id), 1,
Object.assign(doc, {selected: selected}));
});
});
}
if (this.propertyFacilityId) {
propertyFacilityService.updateDocuments(this.propertyFacilityId, {
documentIds: documents.map(doc => doc.id), selected: selected
}).then(({data: documents}) => {
documents.forEach(doc => {
this.documents.splice(this.documents.findIndex(d => d.id === doc.id), 1,
Object.assign(doc, {selected: selected}));
});
});
}
}
},
created() {
this.showCollapse = !this.startCollapsed;
}
};
</script>
I have component of ViewControls, where have switchControls with which I can get the index of the clicked button. For example, i clicked first btn, and snippetValue will be 0, and so on.
<template>
<SwitchControls v-model="snippetValue">
<button>
<i class="icon list" />
</button>
<button>
<i class="icon grid" />
</button>
</SwitchControls>
</template>
Question, how can i bind to a snippetValue (0 - 'list', 1 - 'grid') and emit params
emits: [ "update:modelValue" ],
setup (props, { emit }) {
const snippetValue = ref(0)
const currentValue = computed({
get: () => props.modelValue,
set: (val: number) => {
if (val === 0) {
emit("update:modelValue", "list")
}
else {
emit("update:modelValue", "grid")
}
},
})
},
parent.vue
<ViewControls v-model="snippetVariant"/>
Try to bind the v-model directive from SwitchControls directly with computed property currentValue :
<template>
<SwitchControls v-model="currentValue">
<button>
<i class="icon list" />
</button>
<button>
<i class="icon grid" />
</button>
</SwitchControls>
</template>
emits: [ "update:modelValue" ],
setup (props, { emit }) {
const currentValue = computed({
get: () => props.modelValue,
set: (val: number) => {
emit("update:modelValue", val === 0 ? "list" : "grid")
},
})
},
Learning Vue so this may be a basic question. I have a component that renders a textarea input:
<textarea
v-if="$attrs.type === 'textarea'"
v-bind="$attrs"
:value="value"
#input="inputChange"
:class="[
error ? 'text-red-500' : 'text-gray-600',
textareaAutoResized ? 'h-32' : '',
inputClass,
]"
></textarea>
My inputChange method works well:
methods: {
inputChange(evt) {
let value = evt.target.value
this.$emit('input', value)
if (this.$attrs.type === 'textarea' && this.textareaAutoResized) {
evt.target.style.height = "auto";
let newHeight = evt.target.scrollHeight;
evt.target.style.height = `${newHeight}px`;
}
},
},
But I cannot get the textarea to auto size on page load based on initial content. How do I access the element on mounted? Thanks!
mounted() {
if (this.$attrs.type === 'textarea' && this.textareaAutoResized) {
this.$el.style.height = "auto";
let newHeight = this.$el.scrollHeight;
this.$el.style.height = `${newHeight}px`;
}
},
You can use $ref on your <textarea>:
<textarea
ref="textarea"
v-if="$attrs.type === 'textarea'"
v-bind="$attrs"
:value="value"
#input="inputChange"
:class="[
error ? 'text-red-500' : 'text-gray-600',
textareaAutoResized ? 'h-32' : '',
inputClass,
]"
></textarea>
and get it on mounted:
mounted() {
if (this.$attrs.type === 'textarea' && this.textareaAutoResized) {
this.$refs.textarea.style.height = "auto";
let newHeight = this.$refs.textarea.scrollHeight;
this.$refs.textarea.style.height = `${newHeight}px`;
}
},
Here is the code that I use.
<template>
<div class="button-layout" :style="`margin: ${margin}; text-align: ${align};`">
<component
:is="buttonComponent"
v-for="(button, index) in buttons.filter(btn => btn.url)"
:key="button.label"
:label="button.label"
v-scroll-to="button.url"
:style="`margin-left: ${index === 0 ? '' : space};`" />
<component
:is="buttonComponent"
v-for="(button, index) in buttons.filter(btn => !btn.url)"
:key="button.label"
:label="button.label"
:type="button.type"
:style="`margin-left: ${index === 0 ? '' : space};`" />
</div>
</template>
<script>
export default {
name: "ButtonLayout",
components: { },
props: {
button: String,
margin: String,
align: String,
space: String,
buttons: Array
},
computed: {
buttonComponent() {
return () => import(`./button/${this.button}`)
}
}
};
</script>
I can use these two list of object structures and it works fine.
[
{ url: '#video', label: lang.video },
{ url: '#info', label: lang.info }
]
[
{ type: 'reset', label: lang.clear },
{ type: 'submit', label: lang.send }
]
As I don't like repeating my code, I tried to add dynamically the attribute type and v-scroll-to based on the first object on the list, however, it doesn't work. What would be the best way to achieve it? (See the code below)
<template>
<div class="button-layout" :style="`margin: ${margin}; text-align: ${align};`">
<component
:is="buttonComponent"
v-for="(button, index) in buttons"
:key="button.label"
:label="button.label"
v-bind:[optionalDirective.directive]="button[optionalDirective.key]"
:style="`margin-left: ${index === 0 ? '' : space};`" />
</div>
</template>
<script>
export default {
name: "ButtonLayout",
components: { },
props: {
button: String,
margin: String,
align: String,
space: String,
buttons: Array
},
computed: {
buttonComponent() {
return () => import(`./button/${this.button}`)
},
optionalDirective(){
if(this.buttons[0].url) {
return {
directive: 'v-scroll-to',
key: 'url'
}
} else {
return {
directive: 'type',
key: 'type'
}
}
}
}
};
</script>
You can pass an object to v-bind and it will create html attributes based on the keys of the object.
Something like this should work
<component
:is="buttonComponent"
v-for="(button, index) in buttons"
:key="button.label"
:label="button.label"
v-bind="{[button.url ? 'v-scroll-to' : 'type' ] : (button.url || button.type} }"
:style="`margin-left: ${index === 0 ? '' : space};`" />
Or you could declare a new method that returns the desired object
methods: {
buttonDirective (button) {
if (button.url) {
return {
'v-scroll-to': button.url
}
} else {
return {
'type': button.type
}
}
}
}
and then call it in component
<component
:is="buttonComponent"
v-for="(button, index) in buttons"
:key="button.label"
:label="button.label"
v-bind="buttonDirective(button)"
:style="`margin-left: ${index === 0 ? '' : space};`" />
As v-scroll-to cannot be bound to v-bind, I found a little work around that actually solve my issue and avoid to duplicate the code twice. I bind the #click to a method that inspect the url value and $scollTo if needed, and it solve my problem.
<template>
<div class="button-layout" :style="`margin: ${margin}; text-align: ${align};`">
<component
:is="buttonComponent"
v-for="(button, index) in buttons"
:key="button.label"
:label="button.label"
v-bind="type(button.type)"
#click="scrollTo(button.url)"
:style="`margin-left: ${index === 0 ? '' : space};`"
/>
</div>
</template>
<script>
export default {
name: "ButtonLayout",
components: {},
props: {
button: String,
margin: String,
align: String,
space: String,
buttons: Array
},
methods: {
type(type) {
return type ? { type } : {}
},
scrollTo(url) {
if (url) this.$scrollTo(url)
}
},
computed: {
buttonComponent() {
return () => import(`./button/${this.button}`);
}
}
};
</script>
I was wondering if there is any way to implement the Intelligent DataTables filter in the Bootstrap-Vue table, I have searched everywhere, but I have not found any functional solution to implement in my project.
DataTable.net smart filter image
We needed that component so we created it. Hope it helps:
SearchTable.vue
<template>
<div class="search-table h-100 justify-content-center align-items-center"
v-bind:class="{row: data.length === 0}" v-if="isMounted">
<div v-if="data.length > 0">
<div class="d-flex justify-content-between">
<!-- main search -->
<b-input-group size="xs">
<b-form-input v-model="searchInput"></b-form-input>
<b-input-group-append is-text>
<b-icon icon="search"></b-icon>
</b-input-group-append>
</b-input-group>
</div>
<div class="d-flex justify-content-between mt-2 mb-0">
<b-button-group>
<!-- dropdown -->
<b-dropdown id="col-dropdown" class="col-dropdown" no-flip text="Visibilité">
<b-dropdown-item :key="field.key" class="p-0" style="padding: 0" v-for="field in fields"
v-if="field.key !== 'action'">
<div #click.stop="onDropdownClick(field.key)"
class="checkbox-wrapper">
<b-form-checkbox
:checked="isColumnDisplayed(field.key)"
disabled
>
{{ field.label || field.key }}
</b-form-checkbox>
</div>
</b-dropdown-item>
</b-dropdown>
<b-button :variant="noneOfSearchMethodIsUsed ? '' : 'danger'" #click="cancelFilters">Enlever filtre</b-button>
<!-- dropdown action groupées -->
<slot name="groupped-actions"></slot>
</b-button-group>
<div align="right" style="display: inline-flex">
<span style="margin: 4px;">Afficher</span>
<b-form-select
v-model="perPage"
:options="perPageOptions"
size="sm"
></b-form-select>
<span style="margin: 4px;">éléments</span>
</div>
</div>
<div class="d-flex justify-content-between mt-0 mb-2">
<span style="margin-top: 5px;">{{ buildInformationLine }}</span>
<!-- pagination -->
<b-pagination
:per-page="perPage"
:total-rows="formattedData.length"
align="right"
class="my-0 mt-1"
size="sm"
v-model="currentPage"
></b-pagination>
</div>
<!-- TABLE -->
<b-table
:current-page="currentPage"
:fields="fieldsToShow"
:items="formattedData"
:per-page="perPage"
foot-clone
no-footer-sorting
primary-key="id"
:sticky-header="true"
responsive
striped
>
<!-- action col template -->
<template
v-if="!!$scopedSlots.action"
v-slot:cell(action)="row">
<slot name="action" v-bind="row.item"></slot>
</template>
<!-- html escape template -->
<template v-slot:cell()="data">
<span v-html="data.value"></span>
</template>
<!-- footer -->
<template v-slot:foot()="data">
<input :value="getFieldFromKey(data.column).searchVal"
#input="setFieldSearchValue(data.column, $event.target.value)"
v-if="getFieldFromKey(data.column).key !== 'action'"
class="w-100"
placeholder="Recherche">
</template>
</b-table>
<div class="d-flex justify-content-between mt-0">
<span style="margin-top: 5px;">{{ buildInformationLine }}</span>
<!-- pagination -->
<b-pagination
:per-page="perPage"
:total-rows="formattedData.length"
align="right"
class="my-0 mt-1"
size="sm"
v-model="currentPage"
></b-pagination>
</div>
</div>
<div v-else>
<p>Aucun résultat</p>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'
import BvTableField from '../../interfaces/BvTableField'
enum SearchFusionMethod {
Union = 'union',
Intersection = 'intersection',
}
interface FieldsInteractiveInterface extends BvTableField {
searchVal: string
stickyColumn: boolean
}
#Component
export default class SearchTable extends Vue {
// The array containing the data objects
#Prop(Array) readonly data!: any[]
// The array containing the info of each column. key must be equal to key in object data
#Prop(Array) readonly fields!: BvTableField[]
#Prop({default: SearchFusionMethod.Intersection}) readonly searchFusionMethod!: SearchFusionMethod
#Prop({default: 'highlight'}) readonly highlighterClass!: string
mainHighlighterClass: string = this.highlighterClass
#Prop({default: 'field-highlight'}) readonly fieldHighlighterClass!: string
currentPage = 1
perPage = 10
perPageOptions = [10, 25, 50, 100]
searchInput = ''
isMounted = false
// Contains the value of each column search field
fieldsInteractive: FieldsInteractiveInterface[] = []
// ---
mainHilightColor: string = 'yellow'
fieldHilightColor: string = 'orange'
get fieldsToShow(): BvTableField[] {
return this.fieldsInteractive.filter(field => {
return field.display
})
}
get noneColumnSearchFieldIsUsed(): boolean {
return this.numberOfSearchFieldsUsed === 0
}
get numberOfSearchFieldsUsed(): number {
return this.fieldsInteractive.reduce((count: number, field) => {
return count + (field.searchVal !== '' ? 1 : 0)
}, 0)
}
// (01), (10)
get exactlyOneSearchMethodIsUsed(): boolean {
return (this.searchInput !== '' && this.noneColumnSearchFieldIsUsed) || (this.searchInput === '' && !this.noneColumnSearchFieldIsUsed)
}
// (00)
get noneOfSearchMethodIsUsed(): boolean {
return (this.searchInput === '' && this.noneColumnSearchFieldIsUsed)
}
// (11)
get bothSearchMethodsAreUsed(): boolean {
return (this.searchInput !== '' && !this.noneColumnSearchFieldIsUsed)
}
get onlyMainSearchIsUsed(): boolean {
return (this.searchInput !== '' && this.noneColumnSearchFieldIsUsed)
}
get onlyFieldSearchIsUsed(): boolean {
return (this.searchInput === '' && !this.noneColumnSearchFieldIsUsed)
}
get buildInformationLine(): string {
const txt: String[] = []
txt.push("Affichage de l'élément")
txt.push(this.formattedData.length === 0 ? '0' : (((this.currentPage-1) * this.perPage)+1).toString())
txt.push('à')
txt.push((this.currentPage * this.perPage < this.formattedData.length ? this.currentPage * this.perPage : this.formattedData.length).toString())
txt.push('sur')
txt.push((this.formattedData.length).toString())
txt.push('éléments')
if (this.formattedData.length < this.data.length) {
txt.push('(filtré de')
txt.push((this.data.length).toString())
txt.push('éléments au total)')
}
return txt.join(' ')
}
// Data with
get formattedData() {
const mapped = this.data
.map((item: any) => {
const itemWithHighlight: any = {}
this.fields.forEach(field => {
itemWithHighlight[field.key] = this.replaceBySearch(field.key, item[field.key])
})
return itemWithHighlight
})
return mapped
.filter((item: any) => {
// (searchInput,columnSearchField)
// If there is no filter at all, return the row (00)
if (this.noneOfSearchMethodIsUsed) return true
let countFromMainHighlight = 0
let countFromFieldHighlight = 0
// loop through each field
for (const [key, col] of Object.entries(item)) {
if (!this.fieldsInteractive[this.fieldsInteractive.findIndex(x => x.key === key)].display) continue // Only search in displayed column
if (typeof col !== 'string') continue // only check in string values
if (this.onlyMainSearchIsUsed) {
// if only one of the search method has been used, return anything having a 'highlight' class (01), (10)
if (col.includes('fromMainSearch') || col.includes(this.fieldHighlighterClass)) {
return true
}
} else {
// if both of the search method have been used, filter according to the searchFusionMethod (11)
if (this.searchFusionMethod === SearchFusionMethod.Intersection) {
// TODO: search only in class attribute of markup (faster)
if (col.includes('fromMainSearch')) {
countFromMainHighlight++
}
if (col.includes('fromFieldSearch')) {
countFromFieldHighlight++
}
} else if (this.searchFusionMethod === SearchFusionMethod.Union) {
if (col.includes(`<span class="${this.highlighterClass}`)) {
// TODO
return true
}
}
}
}
// determine whether we keep the row
if (this.bothSearchMethodsAreUsed) {
return countFromMainHighlight > 0 && countFromFieldHighlight === this.numberOfSearchFieldsUsed
} else {
if (this.onlyMainSearchIsUsed) {
return countFromFieldHighlight > 0
} else if (this.onlyFieldSearchIsUsed) {
return countFromFieldHighlight === this.numberOfSearchFieldsUsed
}
}
})
}
isColumnDisplayed(key: string) {
const field = this.getFieldFromKey(key)
return field.display
}
setFieldSearchValue(key: string, searchVal: string) {
const index = this.fieldsInteractive.findIndex(field => field.key === key)
if (index === -1) throw new DOMException('Key not found')
Vue.set(this.fieldsInteractive, index, {
...this.fieldsInteractive[index],
searchVal: searchVal
})
// this.fieldsInteractive[index].searchVal = searchVal
}
mounted() {
// programatically add action column if slot given
if (!!this.$scopedSlots.action) {
const fieldAction = {key: 'action'}
this.fields.push(fieldAction)
}
// init column search values
this.fields.forEach(field => {
if (field.key === 'action') {
this.fieldsInteractive.unshift({
...field,
searchVal: '',
sortable: false,
display: field.display ?? true,
stickyColumn: true
})
} else {
this.fieldsInteractive.push({
...field,
searchVal: '',
sortable: field.sortable ?? true,
display: field.display ?? true,
stickyColumn: false
})
}
})
this.isMounted = true
}
onDropdownClick(key: string) {
for (const index in this.fieldsInteractive) {
if (this.fieldsInteractive[index].key === key) {
this.fieldsInteractive[index].display = !this.fieldsInteractive[index].display // toggle
return
}
}
}
private cancelFilters(): void {
this.fieldsInteractive = this.fieldsInteractive.map((field) => {
field.searchVal = ''
return field
})
this.searchInput = ''
}
private getFieldFromKey(key: string): FieldsInteractiveInterface {
const f = this.fieldsInteractive.find(field => field.key === key)
if (f === undefined) {
throw new DOMException('Key not found')
}
return f
}
private replaceBySearch(key: string, str: string | any) {
if ((this.searchInput === '' && this.noneColumnSearchFieldIsUsed)
|| str === undefined || str === null) return str
str = String(str)
// main search bar
if (this.exactlyOneSearchMethodIsUsed || this.bothSearchMethodsAreUsed) {
const regexMain: RegExp | undefined = this.searchInput !== '' ? new RegExp(`${this.searchInput}`, 'i') : undefined
const regexField: RegExp | undefined = this.getFieldFromKey(key).searchVal !== '' ? new RegExp(`${this.getFieldFromKey(key).searchVal}`, 'i') : undefined
const matchMain: string[] | null = regexMain ? (str).match(regexMain) : null
const matchField: string[] | null = regexField ? (str).match(regexField) : null
if (matchMain || matchField) {
str = this.surroundWithHilightClass(str, matchMain, matchField)
}
}
return str
}
// https://stackoverflow.com/questions/1144783/how-can-i-replace-all-occurrences-of-a-string
// replace only if not already contains a highlight class
/**
* #param str string to be surrounded
* #param findMain what is matching with main search
* #param findField what is matching with field search
*/
private surroundWithHilightClass(str: string, findMain: string[] | null, findField: string[] | null) {
const main: string | null = findMain && findMain.length > 0 ? findMain[0] : null
const field: string | null = findField && findField.length > 0 ? findField[0] : null
str = String(str)
// if a search is in another search, put two classes
if (field && main?.includes(field)) {
str = str.replace(new RegExp(main, 'g'), `<span class="${this.mainHighlighterClass} fromFieldSearch fromMainSearch">${main}</span>`)
} else if (main && field?.includes(main)) {
str = str.replace(new RegExp(field, 'g'), `<span class="${this.mainHighlighterClass} fromMainSearch fromFieldSearch">${field}</span>`)
} else {
// here we are sur the highlightning will be separated (this prevents having span in span)
if (main) {
str = str.replace(new RegExp(main, 'g'), `<span class="${this.mainHighlighterClass} fromMainSearch">${main}</span>`)
}
if (field) {
str = str.replace(new RegExp(field, 'g'), `<span class="${this.fieldHighlighterClass} fromFieldSearch">${field}</span>`)
}
}
return str
}
}
</script>
<style lang="scss">
.search-table {
div {
p {
color: gray;
text-align: center;
}
}
span.fromFieldSearch {
background-color: orange; // not defined : var(--main-highlighter-class);
}
/* Why this overrides fromFielSearch even if fromFieldSearch appear after in class order ? */
span.fromMainSearch {
background-color: yellow; // not defined : var(--field-highlighter-class);
}
span.field-highlight {
background-color: orange;
}
.col-dropdown {
.dropdown-item {
padding: 0 !important;
}
}
.checkbox-wrapper {
padding: 4px 24px;
width: 100%;
}
.custom-control-input[disabled] ~ .custom-control-label, .custom-control-input:disabled ~ .custom-control-label {
color: #000 !important;
}
.b-table-sticky-header > .table.b-table > thead > tr > th {
top: -2px !important;
}
.b-table-sticky-header {
max-height: calc(125vh - 400px) !important;
}
.b-table-sticky-header > .table.b-table > tfoot > tr > th {
position: sticky;
bottom: 0;
background-color: white;
z-index: 0;
}
th.b-table-sticky-column {
z-index: 4 !important;
}
}
</style>
The code is a bit messy but it works.
Note: we use vue class component with vuw property decorators