diff options
Diffstat (limited to 'src/components/_ibs/Global')
-rw-r--r-- | src/components/_ibs/Global/Alert.vue | 47 | ||||
-rw-r--r-- | src/components/_ibs/Global/ButtonBackToTop.vue | 68 | ||||
-rw-r--r-- | src/components/_ibs/Global/FormFile.vue | 119 | ||||
-rw-r--r-- | src/components/_ibs/Global/InfoTooltip.vue | 35 | ||||
-rw-r--r-- | src/components/_ibs/Global/InputPasswordToggle.vue | 54 | ||||
-rw-r--r-- | src/components/_ibs/Global/LoadingBar.vue | 93 | ||||
-rw-r--r-- | src/components/_ibs/Global/PageContainer.vue | 38 | ||||
-rw-r--r-- | src/components/_ibs/Global/PageSection.vue | 29 | ||||
-rw-r--r-- | src/components/_ibs/Global/PageTitle.vue | 32 | ||||
-rw-r--r-- | src/components/_ibs/Global/Search.vue | 83 | ||||
-rw-r--r-- | src/components/_ibs/Global/StatusIcon.vue | 61 | ||||
-rw-r--r-- | src/components/_ibs/Global/TableCellCount.vue | 35 | ||||
-rw-r--r-- | src/components/_ibs/Global/TableDateFilter.vue | 165 | ||||
-rw-r--r-- | src/components/_ibs/Global/TableFilter.vue | 114 | ||||
-rw-r--r-- | src/components/_ibs/Global/TableRowAction.vue | 112 | ||||
-rw-r--r-- | src/components/_ibs/Global/TableToolbar.vue | 130 | ||||
-rw-r--r-- | src/components/_ibs/Global/TableToolbarExport.vue | 36 |
17 files changed, 1251 insertions, 0 deletions
diff --git a/src/components/_ibs/Global/Alert.vue b/src/components/_ibs/Global/Alert.vue new file mode 100644 index 00000000..e8de9e27 --- /dev/null +++ b/src/components/_ibs/Global/Alert.vue @@ -0,0 +1,47 @@ +<template> + <b-alert :show="show" :variant="variant" :class="{ small }"> + <div + v-if=" + variant == 'info' || + variant == 'success' || + variant == 'warning' || + variant == 'danger' + " + class="alert-icon" + > + <status-icon :status="variant" /> + </div> + <div class="alert-content"> + <div class="alert-msg"> + <slot /> + </div> + </div> + <div class="alert-action"> + <slot name="action"></slot> + </div> + </b-alert> +</template> + +<script> +import StatusIcon from '@/components/Global/StatusIcon'; +import { BAlert } from 'bootstrap-vue'; + +export default { + name: 'Alert', + components: { + BAlert: BAlert, + StatusIcon: StatusIcon, + }, + props: { + show: { + type: Boolean, + default: true, + }, + variant: { + type: String, + default: '', + }, + small: Boolean, + }, +}; +</script> diff --git a/src/components/_ibs/Global/ButtonBackToTop.vue b/src/components/_ibs/Global/ButtonBackToTop.vue new file mode 100644 index 00000000..26c3688b --- /dev/null +++ b/src/components/_ibs/Global/ButtonBackToTop.vue @@ -0,0 +1,68 @@ +<template> + <b-button + id="scrollToTopBtn" + class="btn-top btn-icon-only" + :class="{ 'show-btn': showButton }" + variant="secondary" + :title="$t('global.ariaLabel.scrollToTop')" + @click="scrollToTop" + > + <icon-up-to-top /> + <span class="sr-only">{{ $t('global.ariaLabel.scrollToTop') }}</span> + </b-button> +</template> + +<script> +import UpToTop24 from '@carbon/icons-vue/es/up-to-top/24'; + +import { debounce } from 'lodash'; + +export default { + name: 'BackToTop', + components: { IconUpToTop: UpToTop24 }, + data() { + return { + showButton: false, + }; + }, + created() { + window.addEventListener('scroll', debounce(this.handleScroll, 200)); + }, + methods: { + handleScroll() { + document.documentElement.scrollTop > 500 + ? (this.showButton = true) + : (this.showButton = false); + }, + scrollToTop() { + document.documentElement.scrollTo({ + top: 0, + behavior: 'smooth', + }); + }, + }, +}; +</script> + +<style lang="scss" scoped> +.btn-top { + position: fixed; + bottom: $spacer; + right: $spacer * 2; + + box-shadow: $box-shadow; + visibility: hidden; + opacity: 0; + transition: $transition-base; + z-index: $zindex-fixed; + + @include media-breakpoint-up($responsive-layout-bp) { + left: auto; + right: $spacer * 3; + } +} +.show-btn { + visibility: visible; + opacity: 1; +} +</style> diff --git a/src/components/_ibs/Global/FormFile.vue b/src/components/_ibs/Global/FormFile.vue new file mode 100644 index 00000000..cf713acf --- /dev/null +++ b/src/components/_ibs/Global/FormFile.vue @@ -0,0 +1,119 @@ +<template> + <div class="custom-form-file-container"> + <label> + <b-form-file + :id="id" + v-model="file" + :accept="accept" + :disabled="disabled" + :state="state" + plain + @input="$emit('input', file)" + > + </b-form-file> + <span + class="add-file-btn btn" + :class="{ + disabled, + 'btn-secondary': isSecondary, + 'btn-primary': !isSecondary, + }" + > + {{ $t('global.fileUpload.browseText') }} + </span> + <slot name="invalid"></slot> + </label> + <div v-if="file" class="clear-selected-file px-3 py-2 mt-2"> + {{ file ? file.name : '' }} + <b-button + variant="light" + class="px-2 ml-auto" + :disabled="disabled" + @click="file = null" + ><icon-close :title="$t('global.fileUpload.clearSelectedFile')" /><span + class="sr-only" + >{{ $t('global.fileUpload.clearSelectedFile') }}</span + > + </b-button> + </div> + </div> +</template> + +<script> +import { BFormFile } from 'bootstrap-vue'; +import IconClose from '@carbon/icons-vue/es/close/20'; + +export default { + name: 'FormFile', + components: { BFormFile, IconClose }, + props: { + id: { + type: String, + default: '', + }, + disabled: { + type: Boolean, + default: false, + }, + accept: { + type: String, + default: '', + }, + state: { + type: Boolean, + default: true, + }, + variant: { + type: String, + default: 'secondary', + }, + }, + data() { + return { + file: null, + }; + }, + computed: { + isSecondary() { + return this.variant === 'secondary'; + }, + }, +}; +</script> + +<style lang="scss" scoped> +.form-control-file { + opacity: 0; + height: 0; + &:focus + span { + box-shadow: inset 0 0 0 3px theme-color('primary'), inset 0 0 0 5px $white; + } +} + +// Get mouse pointer on complete element +.add-file-btn { + position: relative; + &.disabled { + border-color: gray('400'); + background-color: gray('400'); + color: gray('600'); + box-shadow: none !important; + } +} + +.clear-selected-file { + display: flex; + align-items: center; + background-color: theme-color('light'); + .btn { + width: 36px; + height: 36px; + display: flex; + align-items: center; + + &:focus { + box-shadow: inset 0 0 0 2px theme-color('primary'); + } + } +} +</style> diff --git a/src/components/_ibs/Global/InfoTooltip.vue b/src/components/_ibs/Global/InfoTooltip.vue new file mode 100644 index 00000000..c91109d1 --- /dev/null +++ b/src/components/_ibs/Global/InfoTooltip.vue @@ -0,0 +1,35 @@ +<template> + <b-button + v-b-tooltip + variant="link" + class="btn-tooltip btn-icon-only" + :title="title" + > + <icon-tooltip /> + <span class="sr-only">{{ $t('global.ariaLabel.tooltip') }}</span> + </b-button> +</template> + +<script> +import IconTooltip from '@carbon/icons-vue/es/information/16'; + +export default { + components: { IconTooltip }, + props: { + title: { + type: String, + default: '', + }, + }, +}; +</script> + +<style lang="scss" scoped> +.btn-tooltip { + padding: 0; + line-height: 1em; + svg { + vertical-align: baseline; + } +} +</style> diff --git a/src/components/_ibs/Global/InputPasswordToggle.vue b/src/components/_ibs/Global/InputPasswordToggle.vue new file mode 100644 index 00000000..d2c0d4a6 --- /dev/null +++ b/src/components/_ibs/Global/InputPasswordToggle.vue @@ -0,0 +1,54 @@ +<template> + <div class="input-password-toggle-container"> + <slot></slot> + <b-button + :title="togglePasswordLabel" + variant="link" + class="input-action-btn btn-icon-only" + :class="{ isVisible: isVisible }" + @click="toggleVisibility" + > + <icon-view-off v-if="isVisible" /> + <icon-view v-else /> + <span class="sr-only">{{ togglePasswordLabel }}</span> + </b-button> + </div> +</template> + +<script> +import IconView from '@carbon/icons-vue/es/view/20'; +import IconViewOff from '@carbon/icons-vue/es/view--off/20'; + +export default { + name: 'InputPasswordToggle', + components: { IconView, IconViewOff }, + data() { + return { + isVisible: false, + togglePasswordLabel: this.$t('global.ariaLabel.showPassword'), + }; + }, + methods: { + toggleVisibility() { + const firstChild = this.$children[0]; + const inputEl = firstChild ? firstChild.$el : null; + + this.isVisible = !this.isVisible; + + if (inputEl && inputEl.nodeName === 'INPUT') { + inputEl.type = this.isVisible ? 'text' : 'password'; + } + + this.isVisible + ? (this.togglePasswordLabel = this.$t('global.ariaLabel.hidePassword')) + : (this.togglePasswordLabel = this.$t('global.ariaLabel.showPassword')); + }, + }, +}; +</script> + +<style lang="scss" scoped> +.input-password-toggle-container { + position: relative; +} +</style> diff --git a/src/components/_ibs/Global/LoadingBar.vue b/src/components/_ibs/Global/LoadingBar.vue new file mode 100644 index 00000000..0e9551b5 --- /dev/null +++ b/src/components/_ibs/Global/LoadingBar.vue @@ -0,0 +1,93 @@ +<template> + <transition name="fade"> + <b-progress v-if="!isLoadingComplete"> + <b-progress-bar + striped + animated + :value="loadingIndicatorValue" + :aria-label="$t('global.ariaLabel.progressBar')" + /> + </b-progress> + </transition> +</template> + +<script> +export default { + data() { + return { + loadingIndicatorValue: 0, + isLoadingComplete: false, + loadingIntervalId: null, + timeoutId: null, + }; + }, + created() { + this.$root.$on('loader-start', () => { + this.startLoadingInterval(); + }); + this.$root.$on('loader-end', () => { + this.endLoadingInterval(); + }); + this.$root.$on('loader-hide', () => { + this.hideLoadingBar(); + }); + }, + methods: { + startLoadingInterval() { + this.clearLoadingInterval(); + this.clearTimeout(); + this.loadingIndicatorValue = 0; + this.isLoadingComplete = false; + this.loadingIntervalId = setInterval(() => { + this.loadingIndicatorValue += 1; + if (this.loadingIndicatorValue > 100) this.clearLoadingInterval(); + }, 100); + }, + endLoadingInterval() { + this.clearLoadingInterval(); + this.clearTimeout(); + this.loadingIndicatorValue = 100; + this.timeoutId = setTimeout(() => { + // Let animation complete before hiding + // the loading bar + this.isLoadingComplete = true; + }, 1000); + }, + hideLoadingBar() { + this.clearLoadingInterval(); + this.clearTimeout(); + this.loadingIndicatorValue = 0; + this.isLoadingComplete = true; + }, + clearLoadingInterval() { + if (this.loadingIntervalId) clearInterval(this.loadingIntervalId); + this.loadingIntervalId = null; + }, + clearTimeout() { + if (this.timeoutId) clearTimeout(this.timeoutId); + this.timeoutId = null; + }, + }, +}; +</script> + +<style lang="scss" scoped> +.progress { + position: absolute; + left: 0; + right: 0; + bottom: -0.4rem; + opacity: 1; + transition: opacity $duration--moderate-01 $standard-easing--productive; + height: 0.4rem; + + &.fade-enter, // Remove this vue2 based only class when switching to vue3 + &.fade-enter-from, // This is vue3 based only class modified from 'fade-enter' + &.fade-leave-to { + opacity: 0; + } +} +.progress-bar { + background-color: $loading-color; +} +</style> diff --git a/src/components/_ibs/Global/PageContainer.vue b/src/components/_ibs/Global/PageContainer.vue new file mode 100644 index 00000000..f598be7b --- /dev/null +++ b/src/components/_ibs/Global/PageContainer.vue @@ -0,0 +1,38 @@ +<template> + <main id="main-content" class="page-container"> + <slot /> + </main> +</template> + +<script> +import JumpLinkMixin from '@/components/Mixins/JumpLinkMixin'; +export default { + name: 'PageContainer', + mixins: [JumpLinkMixin], + created() { + this.$root.$on('skip-navigation', () => { + this.setFocus(this.$el); + }); + }, +}; +</script> +<style lang="scss" scoped> +main { + width: 100%; + height: 100%; + padding-top: $spacer * 1.5; + padding-bottom: $spacer * 3; + padding-left: $spacer; + padding-right: $spacer; + + &:focus-visible { + box-shadow: inset 0 0 0 2px theme-color('primary'); + outline: none; + } + + @include media-breakpoint-up($responsive-layout-bp) { + padding-left: $spacer * 2; + padding-right: $spacer * 2; + } +} +</style> diff --git a/src/components/_ibs/Global/PageSection.vue b/src/components/_ibs/Global/PageSection.vue new file mode 100644 index 00000000..dd39ddd5 --- /dev/null +++ b/src/components/_ibs/Global/PageSection.vue @@ -0,0 +1,29 @@ +<template> + <div class="page-section"> + <h2 v-if="sectionTitle">{{ sectionTitle }}</h2> + <slot /> + </div> +</template> + +<script> +export default { + name: 'PageSection', + props: { + sectionTitle: { + type: String, + default: '', + }, + }, +}; +</script> + +<style lang="scss" scoped> +.page-section { + margin-bottom: $spacer * 4; +} + +h2 { + @include font-size($h3-font-size); + margin-bottom: $spacer; +} +</style> diff --git a/src/components/_ibs/Global/PageTitle.vue b/src/components/_ibs/Global/PageTitle.vue new file mode 100644 index 00000000..45c75edb --- /dev/null +++ b/src/components/_ibs/Global/PageTitle.vue @@ -0,0 +1,32 @@ +<template> + <div class="page-title"> + <h1>{{ title }}</h1> + <p v-if="description">{{ description }}</p> + </div> +</template> + +<script> +export default { + name: 'PageTitle', + props: { + description: { + type: String, + default: '', + }, + }, + data() { + return { + title: this.$route.meta.title, + }; + }, +}; +</script> + +<style lang="scss" scoped> +.page-title { + margin-bottom: $spacer * 2; +} +p { + max-width: 72ch; +} +</style> diff --git a/src/components/_ibs/Global/Search.vue b/src/components/_ibs/Global/Search.vue new file mode 100644 index 00000000..ac8f9bfb --- /dev/null +++ b/src/components/_ibs/Global/Search.vue @@ -0,0 +1,83 @@ +<template> + <div class="search-global"> + <b-form-group + :label="$t('global.form.search')" + :label-for="`searchInput-${_uid}`" + label-class="invisible" + class="mb-2" + > + <b-input-group size="md" class="align-items-center"> + <b-input-group-prepend> + <icon-search class="search-icon" /> + </b-input-group-prepend> + <b-form-input + :id="`searchInput-${_uid}`" + ref="searchInput" + v-model="filter" + class="search-input" + type="text" + :aria-label="$t('global.form.search')" + :placeholder="placeholder" + @input="onChangeInput" + > + </b-form-input> + <b-button + v-if="filter" + variant="link" + class="btn-icon-only input-action-btn" + :title="$t('global.ariaLabel.clearSearch')" + @click="onClearSearch" + > + <icon-close /> + <span class="sr-only">{{ $t('global.ariaLabel.clearSearch') }}</span> + </b-button> + </b-input-group> + </b-form-group> + </div> +</template> + +<script> +import IconSearch from '@carbon/icons-vue/es/search/16'; +import IconClose from '@carbon/icons-vue/es/close/20'; + +export default { + name: 'Search', + components: { IconSearch, IconClose }, + props: { + placeholder: { + type: String, + default: function () { + return this.$t('global.form.search'); + }, + }, + }, + data() { + return { + filter: null, + }; + }, + methods: { + onChangeInput() { + this.$emit('change-search', this.filter); + }, + onClearSearch() { + this.filter = ''; + this.$emit('clear-search'); + this.$refs.searchInput.focus(); + }, + }, +}; +</script> + +<style lang="scss" scoped> +.search-input { + padding-left: ($spacer * 2); +} +.search-icon { + position: absolute; + left: 10px; + top: 12px; + z-index: 4; + stroke: gray('400'); +} +</style> diff --git a/src/components/_ibs/Global/StatusIcon.vue b/src/components/_ibs/Global/StatusIcon.vue new file mode 100644 index 00000000..4552633e --- /dev/null +++ b/src/components/_ibs/Global/StatusIcon.vue @@ -0,0 +1,61 @@ +<template> + <span :class="['status-icon', status]"> + <icon-info v-if="status === 'info'" /> + <icon-success v-else-if="status === 'success'" /> + <icon-warning v-else-if="status === 'warning'" /> + <icon-danger v-else-if="status === 'danger'" /> + <icon-secondary v-else /> + </span> +</template> + +<script> +import IconInfo from '@carbon/icons-vue/es/information--filled/20'; +import IconCheckmark from '@carbon/icons-vue/es/checkmark--filled/20'; +import IconWarning from '@carbon/icons-vue/es/warning--filled/20'; +import IconError from '@carbon/icons-vue/es/error--filled/20'; +import IconMisuse from '@carbon/icons-vue/es/misuse/20'; + +export default { + name: 'StatusIcon', + components: { + IconInfo: IconInfo, + iconSuccess: IconCheckmark, + iconDanger: IconMisuse, + iconSecondary: IconError, + iconWarning: IconWarning, + }, + props: { + status: { + type: String, + default: '', + }, + }, +}; +</script> + +<style lang="scss" scoped> +.status-icon { + vertical-align: text-bottom; + + &.info { + color: theme-color('info'); + } + &.success { + color: theme-color('success'); + } + &.danger { + color: theme-color('danger'); + } + &.secondary { + color: gray('600'); + transform: rotate(-45deg); + } + &.warning { + color: theme-color('warning'); + } + + svg { + fill: currentColor; + } +} +</style> diff --git a/src/components/_ibs/Global/TableCellCount.vue b/src/components/_ibs/Global/TableCellCount.vue new file mode 100644 index 00000000..acb4d443 --- /dev/null +++ b/src/components/_ibs/Global/TableCellCount.vue @@ -0,0 +1,35 @@ +<template> + <div class="mt-2"> + <p v-if="!filterActive"> + {{ $t('global.table.items', { count: totalNumberOfCells }) }} + </p> + <p v-else> + {{ + $t('global.table.selectedItems', { + count: totalNumberOfCells, + filterCount: filteredItemsCount, + }) + }} + </p> + </div> +</template> + +<script> +export default { + props: { + filteredItemsCount: { + type: Number, + required: true, + }, + totalNumberOfCells: { + type: Number, + required: true, + }, + }, + computed: { + filterActive() { + return this.filteredItemsCount !== this.totalNumberOfCells; + }, + }, +}; +</script> diff --git a/src/components/_ibs/Global/TableDateFilter.vue b/src/components/_ibs/Global/TableDateFilter.vue new file mode 100644 index 00000000..aa10cb5c --- /dev/null +++ b/src/components/_ibs/Global/TableDateFilter.vue @@ -0,0 +1,165 @@ +<template> + <b-row class="mb-2"> + <b-col class="d-sm-flex"> + <b-form-group + :label="$t('global.table.fromDate')" + label-for="input-from-date" + class="mr-3 my-0 w-100" + > + <b-input-group> + <b-form-input + id="input-from-date" + v-model="fromDate" + placeholder="YYYY-MM-DD" + :state="getValidationState($v.fromDate)" + class="form-control-with-button mb-3 mb-md-0" + @blur="$v.fromDate.$touch()" + /> + <b-form-invalid-feedback role="alert"> + <template v-if="!$v.fromDate.pattern"> + {{ $t('global.form.invalidFormat') }} + </template> + <template v-if="!$v.fromDate.maxDate"> + {{ $t('global.form.dateMustBeBefore', { date: toDate }) }} + </template> + </b-form-invalid-feedback> + <b-form-datepicker + v-model="fromDate" + class="btn-datepicker btn-icon-only" + button-only + right + :max="toDate" + :hide-header="true" + :locale="locale" + :label-help=" + $t('global.calendar.useCursorKeysToNavigateCalendarDates') + " + :title="$t('global.calendar.selectDate')" + button-variant="link" + aria-controls="input-from-date" + > + <template #button-content> + <icon-calendar /> + <span class="sr-only"> + {{ $t('global.calendar.selectDate') }} + </span> + </template> + </b-form-datepicker> + </b-input-group> + </b-form-group> + <b-form-group + :label="$t('global.table.toDate')" + label-for="input-to-date" + class="my-0 w-100" + > + <b-input-group> + <b-form-input + id="input-to-date" + v-model="toDate" + placeholder="YYYY-MM-DD" + :state="getValidationState($v.toDate)" + class="form-control-with-button" + @blur="$v.toDate.$touch()" + /> + <b-form-invalid-feedback role="alert"> + <template v-if="!$v.toDate.pattern"> + {{ $t('global.form.invalidFormat') }} + </template> + <template v-if="!$v.toDate.minDate"> + {{ $t('global.form.dateMustBeAfter', { date: fromDate }) }} + </template> + </b-form-invalid-feedback> + <b-form-datepicker + v-model="toDate" + class="btn-datepicker btn-icon-only" + button-only + right + :min="fromDate" + :hide-header="true" + :locale="locale" + :label-help=" + $t('global.calendar.useCursorKeysToNavigateCalendarDates') + " + :title="$t('global.calendar.selectDate')" + button-variant="link" + aria-controls="input-to-date" + > + <template #button-content> + <icon-calendar /> + <span class="sr-only"> + {{ $t('global.calendar.selectDate') }} + </span> + </template> + </b-form-datepicker> + </b-input-group> + </b-form-group> + </b-col> + </b-row> +</template> + +<script> +import IconCalendar from '@carbon/icons-vue/es/calendar/20'; +import { helpers } from 'vuelidate/lib/validators'; + +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; + +const isoDateRegex = /([12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]))/; + +export default { + components: { IconCalendar }, + mixins: [VuelidateMixin], + data() { + return { + fromDate: '', + toDate: '', + offsetToDate: '', + locale: this.$store.getters['global/languagePreference'], + }; + }, + validations() { + return { + fromDate: { + pattern: helpers.regex('pattern', isoDateRegex), + maxDate: (value) => { + if (!this.toDate) return true; + const date = new Date(value); + const maxDate = new Date(this.toDate); + if (date.getTime() > maxDate.getTime()) return false; + return true; + }, + }, + toDate: { + pattern: helpers.regex('pattern', isoDateRegex), + minDate: (value) => { + if (!this.fromDate) return true; + const date = new Date(value); + const minDate = new Date(this.fromDate); + if (date.getTime() < minDate.getTime()) return false; + return true; + }, + }, + }; + }, + watch: { + fromDate() { + this.emitChange(); + }, + toDate(newVal) { + // Offset the end date to end of day to make sure all + // entries from selected end date are included in filter + this.offsetToDate = new Date(newVal).setUTCHours(23, 59, 59, 999); + this.emitChange(); + }, + }, + methods: { + emitChange() { + if (this.$v.$invalid) return; + this.$v.$reset(); //reset to re-validate on blur + this.$emit('change', { + fromDate: this.fromDate ? new Date(this.fromDate) : null, + toDate: this.toDate ? new Date(this.offsetToDate) : null, + }); + }, + }, +}; +</script> diff --git a/src/components/_ibs/Global/TableFilter.vue b/src/components/_ibs/Global/TableFilter.vue new file mode 100644 index 00000000..7c66bea6 --- /dev/null +++ b/src/components/_ibs/Global/TableFilter.vue @@ -0,0 +1,114 @@ +<template> + <div class="table-filter d-inline-block"> + <p class="d-inline-block mb-0"> + <b-badge v-for="(tag, index) in tags" :key="index" pill> + {{ tag }} + <b-button-close + :disabled="dropdownVisible" + :aria-hidden="true" + @click="removeTag(tag)" + /> + </b-badge> + </p> + <b-dropdown + variant="link" + no-caret + right + data-test-id="tableFilter-dropdown-options" + @hide="dropdownVisible = false" + @show="dropdownVisible = true" + > + <template #button-content> + <icon-filter /> + {{ $t('global.action.filter') }} + </template> + <b-dropdown-form> + <b-form-group + v-for="(filter, index) of filters" + :key="index" + :label="filter.label" + > + <b-form-checkbox-group v-model="tags"> + <b-form-checkbox + v-for="value in filter.values" + :key="value" + :value="value" + :data-test-id="`tableFilter-checkbox-${value}`" + > + <b-dropdown-item> + {{ value }} + </b-dropdown-item> + </b-form-checkbox> + </b-form-checkbox-group> + </b-form-group> + </b-dropdown-form> + <b-dropdown-item-button + variant="primary" + data-test-id="tableFilter-button-clearAll" + @click="clearAllTags" + > + {{ $t('global.action.clearAll') }} + </b-dropdown-item-button> + </b-dropdown> + </div> +</template> + +<script> +import IconFilter from '@carbon/icons-vue/es/settings--adjust/20'; + +export default { + name: 'TableFilter', + components: { IconFilter }, + props: { + filters: { + type: Array, + default: () => [], + validator: (prop) => { + return prop.every( + (filter) => 'label' in filter && 'values' in filter && 'key' in filter + ); + }, + }, + }, + data() { + return { + dropdownVisible: false, + tags: [], + }; + }, + watch: { + tags: { + handler() { + this.emitChange(); + }, + deep: true, + }, + }, + methods: { + removeTag(removedTag) { + this.tags = this.tags.filter((tag) => tag !== removedTag); + }, + clearAllTags() { + this.tags = []; + }, + emitChange() { + const activeFilters = this.filters.map(({ key, values }) => { + const activeValues = values.filter( + (value) => this.tags.indexOf(value) !== -1 + ); + return { + key, + values: activeValues, + }; + }); + this.$emit('filter-change', { activeFilters }); + }, + }, +}; +</script> + +<style lang="scss" scoped> +.badge { + margin-right: $spacer / 2; +} +</style> diff --git a/src/components/_ibs/Global/TableRowAction.vue b/src/components/_ibs/Global/TableRowAction.vue new file mode 100644 index 00000000..549f1b52 --- /dev/null +++ b/src/components/_ibs/Global/TableRowAction.vue @@ -0,0 +1,112 @@ +<template> + <span> + <b-link + v-if="value === 'export'" + class="align-bottom btn-icon-only py-0 btn-link" + :download="download" + :href="href" + :title="title" + > + <slot name="icon"> + {{ $t('global.action.export') }} + </slot> + <span v-if="btnIconOnly" class="sr-only">{{ title }}</span> + </b-link> + <b-link + v-else-if=" + value === 'download' && downloadInNewTab && downloadLocation !== '' + " + class="align-bottom btn-icon-only py-0 btn-link" + target="_blank" + :href="downloadLocation" + :title="title" + > + <slot name="icon" /> + <span class="sr-only"> + {{ $t('global.action.download') }} + </span> + </b-link> + <b-link + v-else-if="value === 'download' && downloadLocation !== ''" + class="align-bottom btn-icon-only py-0 btn-link" + :download="exportName" + :href="downloadLocation" + :title="title" + > + <slot name="icon" /> + <span class="sr-only"> + {{ $t('global.action.download') }} + </span> + </b-link> + <b-button + v-else-if="showButton" + variant="link" + :class="{ 'btn-icon-only': btnIconOnly }" + :disabled="!enabled" + :title="btnIconOnly ? title : !title" + @click="$emit('click-table-action', value)" + > + <slot name="icon"> + {{ title }} + </slot> + <span v-if="btnIconOnly" class="sr-only">{{ title }}</span> + </b-button> + </span> +</template> + +<script> +import { omit } from 'lodash'; + +export default { + name: 'TableRowAction', + props: { + value: { + type: String, + required: true, + }, + enabled: { + type: Boolean, + default: true, + }, + title: { + type: String, + default: null, + }, + rowData: { + type: Object, + default: () => {}, + }, + exportName: { + type: String, + default: 'export', + }, + downloadLocation: { + type: String, + default: '', + }, + btnIconOnly: { + type: Boolean, + default: true, + }, + downloadInNewTab: { + type: Boolean, + default: false, + }, + showButton: { + type: Boolean, + default: true, + }, + }, + computed: { + dataForExport() { + return JSON.stringify(omit(this.rowData, 'actions')); + }, + download() { + return `${this.exportName}.json`; + }, + href() { + return `data:text/json;charset=utf-8,${this.dataForExport}`; + }, + }, +}; +</script> diff --git a/src/components/_ibs/Global/TableToolbar.vue b/src/components/_ibs/Global/TableToolbar.vue new file mode 100644 index 00000000..5235feae --- /dev/null +++ b/src/components/_ibs/Global/TableToolbar.vue @@ -0,0 +1,130 @@ +<template> + <transition name="slide"> + <div v-if="isToolbarActive" class="toolbar-container"> + <div class="toolbar-content"> + <p class="toolbar-selected"> + {{ selectedItemsCount }} {{ $t('global.action.selected') }} + </p> + <div class="toolbar-actions d-flex"> + <slot name="toolbar-buttons"></slot> + <b-button + v-for="(action, index) in actions" + :key="index" + :data-test-id="`table-button-${action.value}Selected`" + variant="primary" + class="d-block" + @click="$emit('batch-action', action.value)" + > + {{ action.label }} + </b-button> + <b-button + variant="secondary" + class="d-block" + @click="$emit('clear-selected')" + > + {{ $t('global.action.cancel') }} + </b-button> + </div> + </div> + </div> + </transition> +</template> + +<script> +export default { + name: 'TableToolbar', + props: { + selectedItemsCount: { + type: Number, + required: true, + }, + actions: { + type: Array, + default: () => [], + validator: (prop) => { + return prop.every((action) => { + return ( + Object.prototype.hasOwnProperty.call(action, 'value') && + Object.prototype.hasOwnProperty.call(action, 'label') + ); + }); + }, + }, + }, + data() { + return { + isToolbarActive: false, + }; + }, + watch: { + selectedItemsCount: function (selectedItemsCount) { + if (selectedItemsCount > 0) { + this.isToolbarActive = true; + } else { + this.isToolbarActive = false; + } + }, + }, +}; +</script> + +<style lang="scss" scoped> +$toolbar-height: 46px; + +.toolbar-container { + width: 100%; + position: relative; + z-index: $zindex-dropdown + 1; +} + +.toolbar-content { + height: $toolbar-height; + background-color: theme-color('primary'); + color: $white; + position: absolute; + left: 0; + right: 0; + top: -$toolbar-height; + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.toolbar-selected { + line-height: $toolbar-height; + margin: 0; + padding: 0 $spacer; +} + +// Using v-deep to style export slot child-element +// depricated and vue-js 3 +.toolbar-actions ::v-deep .btn { + position: relative; + &:after { + content: ''; + position: absolute; + left: 0; + height: 1.5rem; + width: 1px; + background: rgba($white, 0.6); + } + &:last-child, + &:first-child { + &:after { + width: 0; + } + } +} + +.slide-enter-active { + transition: transform $duration--moderate-02 $entrance-easing--productive; +} +.slide-leave-active { + transition: transform $duration--moderate-02 $exit-easing--productive; +} +.slide-enter, // Remove this vue2 based only class when switching to vue3 +.slide-enter-from, // This is vue3 based only class modified from 'slide-enter' +.slide-leave-to { + transform: translateY($toolbar-height); +} +</style> diff --git a/src/components/_ibs/Global/TableToolbarExport.vue b/src/components/_ibs/Global/TableToolbarExport.vue new file mode 100644 index 00000000..69646ea6 --- /dev/null +++ b/src/components/_ibs/Global/TableToolbarExport.vue @@ -0,0 +1,36 @@ +<template> + <b-button + class="d-flex align-items-center" + variant="primary" + :download="download" + :href="href" + > + {{ $t('global.action.export') }} + </b-button> +</template> + +<script> +export default { + props: { + data: { + type: Array, + default: () => [], + }, + fileName: { + type: String, + default: 'data', + }, + }, + computed: { + dataForExport() { + return JSON.stringify(this.data); + }, + download() { + return `${this.fileName}.json`; + }, + href() { + return `data:text/json;charset=utf-8,${this.dataForExport}`; + }, + }, +}; +</script> |