summaryrefslogtreecommitdiff
path: root/src/components/_sila/Global
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/_sila/Global')
-rw-r--r--src/components/_sila/Global/Alert.vue47
-rw-r--r--src/components/_sila/Global/ButtonBackToTop.vue68
-rw-r--r--src/components/_sila/Global/FormFile.vue119
-rw-r--r--src/components/_sila/Global/InfoTooltip.vue35
-rw-r--r--src/components/_sila/Global/InputPasswordToggle.vue54
-rw-r--r--src/components/_sila/Global/LoadingBar.vue93
-rw-r--r--src/components/_sila/Global/PageContainer.vue37
-rw-r--r--src/components/_sila/Global/PageSection.vue29
-rw-r--r--src/components/_sila/Global/PageTitle.vue32
-rw-r--r--src/components/_sila/Global/Search.vue83
-rw-r--r--src/components/_sila/Global/StatusIcon.vue61
-rw-r--r--src/components/_sila/Global/TableCellCount.vue35
-rw-r--r--src/components/_sila/Global/TableDateFilter.vue165
-rw-r--r--src/components/_sila/Global/TableFilter.vue114
-rw-r--r--src/components/_sila/Global/TableRowAction.vue112
-rw-r--r--src/components/_sila/Global/TableToolbar.vue130
-rw-r--r--src/components/_sila/Global/TableToolbarExport.vue36
17 files changed, 1250 insertions, 0 deletions
diff --git a/src/components/_sila/Global/Alert.vue b/src/components/_sila/Global/Alert.vue
new file mode 100644
index 00000000..e8de9e27
--- /dev/null
+++ b/src/components/_sila/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/_sila/Global/ButtonBackToTop.vue b/src/components/_sila/Global/ButtonBackToTop.vue
new file mode 100644
index 00000000..9160c7b7
--- /dev/null
+++ b/src/components/_sila/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: 24px;
+ right: 24px;
+
+ box-shadow: $box-shadow;
+ visibility: hidden;
+ opacity: 0;
+ transition: $transition-base;
+ z-index: $zindex-fixed;
+
+ @media (min-width: 1600px) {
+ left: 1485px;
+ right: auto;
+ }
+}
+.show-btn {
+ visibility: visible;
+ opacity: 1;
+}
+</style>
diff --git a/src/components/_sila/Global/FormFile.vue b/src/components/_sila/Global/FormFile.vue
new file mode 100644
index 00000000..cf713acf
--- /dev/null
+++ b/src/components/_sila/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/_sila/Global/InfoTooltip.vue b/src/components/_sila/Global/InfoTooltip.vue
new file mode 100644
index 00000000..c91109d1
--- /dev/null
+++ b/src/components/_sila/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/_sila/Global/InputPasswordToggle.vue b/src/components/_sila/Global/InputPasswordToggle.vue
new file mode 100644
index 00000000..d2c0d4a6
--- /dev/null
+++ b/src/components/_sila/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/_sila/Global/LoadingBar.vue b/src/components/_sila/Global/LoadingBar.vue
new file mode 100644
index 00000000..0e9551b5
--- /dev/null
+++ b/src/components/_sila/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/_sila/Global/PageContainer.vue b/src/components/_sila/Global/PageContainer.vue
new file mode 100644
index 00000000..ab4adb63
--- /dev/null
+++ b/src/components/_sila/Global/PageContainer.vue
@@ -0,0 +1,37 @@
+<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;
+ }
+}
+</style>
diff --git a/src/components/_sila/Global/PageSection.vue b/src/components/_sila/Global/PageSection.vue
new file mode 100644
index 00000000..dd39ddd5
--- /dev/null
+++ b/src/components/_sila/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/_sila/Global/PageTitle.vue b/src/components/_sila/Global/PageTitle.vue
new file mode 100644
index 00000000..45c75edb
--- /dev/null
+++ b/src/components/_sila/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/_sila/Global/Search.vue b/src/components/_sila/Global/Search.vue
new file mode 100644
index 00000000..ac8f9bfb
--- /dev/null
+++ b/src/components/_sila/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/_sila/Global/StatusIcon.vue b/src/components/_sila/Global/StatusIcon.vue
new file mode 100644
index 00000000..4552633e
--- /dev/null
+++ b/src/components/_sila/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/_sila/Global/TableCellCount.vue b/src/components/_sila/Global/TableCellCount.vue
new file mode 100644
index 00000000..acb4d443
--- /dev/null
+++ b/src/components/_sila/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/_sila/Global/TableDateFilter.vue b/src/components/_sila/Global/TableDateFilter.vue
new file mode 100644
index 00000000..aa10cb5c
--- /dev/null
+++ b/src/components/_sila/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/_sila/Global/TableFilter.vue b/src/components/_sila/Global/TableFilter.vue
new file mode 100644
index 00000000..7c66bea6
--- /dev/null
+++ b/src/components/_sila/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/_sila/Global/TableRowAction.vue b/src/components/_sila/Global/TableRowAction.vue
new file mode 100644
index 00000000..549f1b52
--- /dev/null
+++ b/src/components/_sila/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/_sila/Global/TableToolbar.vue b/src/components/_sila/Global/TableToolbar.vue
new file mode 100644
index 00000000..5235feae
--- /dev/null
+++ b/src/components/_sila/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/_sila/Global/TableToolbarExport.vue b/src/components/_sila/Global/TableToolbarExport.vue
new file mode 100644
index 00000000..69646ea6
--- /dev/null
+++ b/src/components/_sila/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>