summaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
authorAndrey V.Kosteltsev <AKosteltsev@IBS.RU>2022-07-04 23:11:28 +0300
committerAndrey V.Kosteltsev <AKosteltsev@IBS.RU>2022-07-04 23:11:28 +0300
commit3f4094d08b873e17464a51c817ea7d41177f848d (patch)
tree8880a0e7c8c0ac07ed298ce719cfab3278f2aa12 /src/components
parentf5c8dbfa6fb3812a3b3a2aafd3538fbdf8b8c668 (diff)
downloadwebui-vue-3f4094d08b873e17464a51c817ea7d41177f848d.tar.xz
IBS: _ibs UI Theme
Diffstat (limited to 'src/components')
-rw-r--r--src/components/_ibs/AppHeader/AppHeader.vue395
-rw-r--r--src/components/_ibs/AppHeader/index.js2
-rw-r--r--src/components/_ibs/AppNavigation/AppNavigation.vue255
-rw-r--r--src/components/_ibs/AppNavigation/AppNavigationMixin.js182
-rw-r--r--src/components/_ibs/AppNavigation/index.js2
-rw-r--r--src/components/_ibs/Global/Alert.vue47
-rw-r--r--src/components/_ibs/Global/ButtonBackToTop.vue68
-rw-r--r--src/components/_ibs/Global/FormFile.vue119
-rw-r--r--src/components/_ibs/Global/InfoTooltip.vue35
-rw-r--r--src/components/_ibs/Global/InputPasswordToggle.vue54
-rw-r--r--src/components/_ibs/Global/LoadingBar.vue93
-rw-r--r--src/components/_ibs/Global/PageContainer.vue38
-rw-r--r--src/components/_ibs/Global/PageSection.vue29
-rw-r--r--src/components/_ibs/Global/PageTitle.vue32
-rw-r--r--src/components/_ibs/Global/Search.vue83
-rw-r--r--src/components/_ibs/Global/StatusIcon.vue61
-rw-r--r--src/components/_ibs/Global/TableCellCount.vue35
-rw-r--r--src/components/_ibs/Global/TableDateFilter.vue165
-rw-r--r--src/components/_ibs/Global/TableFilter.vue114
-rw-r--r--src/components/_ibs/Global/TableRowAction.vue112
-rw-r--r--src/components/_ibs/Global/TableToolbar.vue130
-rw-r--r--src/components/_ibs/Global/TableToolbarExport.vue36
-rw-r--r--src/components/_ibs/Mixins/BVPaginationMixin.js34
-rw-r--r--src/components/_ibs/Mixins/BVTableSelectableMixin.js41
-rw-r--r--src/components/_ibs/Mixins/BVToastMixin.js115
-rw-r--r--src/components/_ibs/Mixins/DataFormatterMixin.js30
-rw-r--r--src/components/_ibs/Mixins/JumpLinkMixin.js27
-rw-r--r--src/components/_ibs/Mixins/LoadingBarMixin.js19
-rw-r--r--src/components/_ibs/Mixins/LocalTimezoneLabelMixin.js14
-rw-r--r--src/components/_ibs/Mixins/SearchFilterMixin.js14
-rw-r--r--src/components/_ibs/Mixins/TableFilterMixin.js58
-rw-r--r--src/components/_ibs/Mixins/TableRowExpandMixin.js15
-rw-r--r--src/components/_ibs/Mixins/TableSortMixin.js11
-rw-r--r--src/components/_ibs/Mixins/VuelidateMixin.js10
34 files changed, 2475 insertions, 0 deletions
diff --git a/src/components/_ibs/AppHeader/AppHeader.vue b/src/components/_ibs/AppHeader/AppHeader.vue
new file mode 100644
index 00000000..1c5dbd3a
--- /dev/null
+++ b/src/components/_ibs/AppHeader/AppHeader.vue
@@ -0,0 +1,395 @@
+<template>
+ <div>
+ <header id="page-header">
+ <a
+ class="link-skip-nav btn btn-light"
+ href="#main-content"
+ @click="setFocus"
+ >
+ {{ $t('appHeader.skipToContent') }}
+ </a>
+
+ <b-navbar type="dark" :aria-label="$t('appHeader.applicationHeader')">
+ <!-- Left aligned nav items -->
+ <b-button
+ id="app-header-trigger"
+ class="nav-trigger"
+ aria-hidden="true"
+ type="button"
+ variant="link"
+ :class="{ open: isNavigationOpen }"
+ @click="toggleNavigation"
+ >
+ <icon-close
+ v-if="isNavigationOpen"
+ :title="$t('appHeader.titleHideNavigation')"
+ />
+ <icon-menu
+ v-if="!isNavigationOpen"
+ :title="$t('appHeader.titleShowNavigation')"
+ />
+ </b-button>
+ <b-navbar-nav>
+ <b-navbar-brand
+ class="mr-0"
+ to="/"
+ data-test-id="appHeader-container-overview"
+ >
+ <img
+ class="header-logo"
+ src="@/assets/images/_ibs/logo-header.svg"
+ :alt="altLogo"
+ />
+ </b-navbar-brand>
+ <div v-if="isNavTagPresent" :key="routerKey" class="pl-2 nav-tags">
+ <span>|</span>
+ <span class="pl-3 asset-tag">{{ assetTag }}</span>
+ <span class="pl-3">{{ modelType }}</span>
+ <span class="pl-3">{{ serialNumber }}</span>
+ </div>
+ </b-navbar-nav>
+ <!-- Right aligned nav items -->
+ <b-navbar-nav class="ml-auto helper-menu">
+ <b-nav-item
+ to="/logs/event-logs"
+ data-test-id="appHeader-container-health"
+ >
+ <status-icon :status="healthStatusIcon" />
+ {{ $t('appHeader.health') }}
+ </b-nav-item>
+ <b-nav-item
+ to="/operations/server-power-operations"
+ data-test-id="appHeader-container-power"
+ >
+ <status-icon :status="serverStatusIcon" />
+ {{ $t('appHeader.power') }}
+ </b-nav-item>
+ <!-- Using LI elements instead of b-nav-item to support semantic button elements -->
+ <li class="nav-item">
+ <b-button
+ id="app-header-refresh"
+ variant="link"
+ data-test-id="appHeader-button-refresh"
+ @click="refresh"
+ >
+ <icon-renew :title="$t('appHeader.titleRefresh')" />
+ <span class="responsive-text">{{ $t('appHeader.refresh') }}</span>
+ </b-button>
+ </li>
+ <li class="nav-item">
+ <b-dropdown
+ id="app-header-user"
+ variant="link"
+ right
+ data-test-id="appHeader-container-user"
+ >
+ <template #button-content>
+ <icon-avatar :title="$t('appHeader.titleProfile')" />
+ <span class="responsive-text">{{ username }}</span>
+ </template>
+ <b-dropdown-item
+ to="/profile-settings"
+ data-test-id="appHeader-link-profile"
+ >{{ $t('appHeader.profileSettings') }}
+ </b-dropdown-item>
+ <b-dropdown-item
+ data-test-id="appHeader-link-logout"
+ @click="logout"
+ >
+ {{ $t('appHeader.logOut') }}
+ </b-dropdown-item>
+ </b-dropdown>
+ </li>
+ </b-navbar-nav>
+ </b-navbar>
+ </header>
+ <loading-bar />
+ </div>
+</template>
+
+<script>
+import BVToastMixin from '@/components/Mixins/BVToastMixin';
+import IconAvatar from '@carbon/icons-vue/es/user--avatar/20';
+import IconClose from '@carbon/icons-vue/es/close/20';
+import IconMenu from '@carbon/icons-vue/es/menu/20';
+import IconRenew from '@carbon/icons-vue/es/renew/20';
+import StatusIcon from '@/components/Global/StatusIcon';
+import LoadingBar from '@/components/Global/LoadingBar';
+
+export default {
+ name: 'AppHeader',
+ components: {
+ IconAvatar,
+ IconClose,
+ IconMenu,
+ IconRenew,
+ StatusIcon,
+ LoadingBar,
+ },
+ mixins: [BVToastMixin],
+ props: {
+ routerKey: {
+ type: Number,
+ default: 0,
+ },
+ },
+ data() {
+ return {
+ isNavigationOpen: false,
+ altLogo: process.env.VUE_APP_COMPANY_NAME || 'Built on OpenBMC',
+ };
+ },
+ computed: {
+ isNavTagPresent() {
+ return this.assetTag || this.modelType || this.serialNumber;
+ },
+ assetTag() {
+ return this.$store.getters['global/assetTag'];
+ },
+ modelType() {
+ return this.$store.getters['global/modelType'];
+ },
+ serialNumber() {
+ return this.$store.getters['global/serialNumber'];
+ },
+ isAuthorized() {
+ return this.$store.getters['global/isAuthorized'];
+ },
+ serverStatus() {
+ return this.$store.getters['global/serverStatus'];
+ },
+ healthStatus() {
+ return this.$store.getters['eventLog/healthStatus'];
+ },
+ serverStatusIcon() {
+ switch (this.serverStatus) {
+ case 'on':
+ return 'success';
+ case 'error':
+ return 'danger';
+ case 'diagnosticMode':
+ return 'warning';
+ case 'off':
+ default:
+ return 'secondary';
+ }
+ },
+ healthStatusIcon() {
+ switch (this.healthStatus) {
+ case 'OK':
+ return 'success';
+ case 'Warning':
+ return 'warning';
+ case 'Critical':
+ return 'danger';
+ default:
+ return 'secondary';
+ }
+ },
+ username() {
+ return this.$store.getters['global/username'];
+ },
+ },
+ watch: {
+ isAuthorized(value) {
+ if (value === false) {
+ this.errorToast(this.$t('global.toast.unAuthDescription'), {
+ title: this.$t('global.toast.unAuthTitle'),
+ });
+ }
+ },
+ },
+ created() {
+ // Reset auth state to check if user is authenticated based
+ // on available browser cookies
+ this.$store.dispatch('authentication/resetStoreState');
+ this.getSystemInfo();
+ this.getEvents();
+ },
+ mounted() {
+ this.$root.$on(
+ 'change-is-navigation-open',
+ (isNavigationOpen) => (this.isNavigationOpen = isNavigationOpen)
+ );
+ },
+ methods: {
+ getSystemInfo() {
+ this.$store.dispatch('global/getSystemInfo');
+ },
+ getEvents() {
+ this.$store.dispatch('eventLog/getEventLogData');
+ },
+ refresh() {
+ this.$emit('refresh');
+ },
+ logout() {
+ this.$store.dispatch('authentication/logout');
+ },
+ toggleNavigation() {
+ this.$root.$emit('toggle-navigation');
+ },
+ setFocus(event) {
+ event.preventDefault();
+ this.$root.$emit('skip-navigation');
+ },
+ },
+};
+</script>
+
+<style lang="scss">
+@mixin focus-box-shadow($padding-color: $navbar-color, $outline-color: $white) {
+ box-shadow: inset 0 0 0 3px $padding-color, inset 0 0 0 5px $outline-color;
+}
+.app-header {
+ .link-skip-nav {
+ position: absolute;
+ top: -60px;
+ left: 0.5rem;
+ z-index: $zindex-popover;
+ transition: $duration--moderate-01 $exit-easing--expressive;
+ &:focus {
+ top: 0.5rem;
+ transition-timing-function: $entrance-easing--expressive;
+ }
+ }
+ .navbar-text,
+ .nav-link,
+ .btn-link {
+ color: color('white') !important;
+ fill: currentColor;
+ padding: 0.68rem 1rem !important;
+
+ &:hover {
+ background-color: theme-color-level(light, 10);
+ }
+ &:active {
+ background-color: theme-color-level(light, 9);
+ }
+ &:focus {
+ @include focus-box-shadow;
+ outline: 0;
+ }
+ }
+
+ .nav-item {
+ fill: theme-color('light');
+ overflow-x: hidden;
+ white-space: nowrap;
+
+ .nav-link {
+ margin-top: -2px;
+ }
+ }
+
+ .nav-item:last-of-type {
+ overflow-x: visible;
+ }
+
+ .navbar {
+ padding: 0;
+ background-color: $navbar-color;
+ @include media-breakpoint-up($responsive-layout-bp) {
+ height: $header-height;
+ }
+
+ .helper-menu {
+ @include media-breakpoint-down(sm) {
+ background-color: $navbar-color;
+ width: 100%;
+ justify-content: flex-end;
+
+ .nav-link,
+ .btn {
+ padding: $spacer / 1.125 $spacer / 2;
+ }
+
+ .nav-link:focus,
+ .btn:focus {
+ @include focus-box-shadow($gray-800);
+ }
+ }
+
+ .responsive-text {
+ @include media-breakpoint-down(xs) {
+ @include sr-only;
+ }
+ }
+ }
+ }
+
+ .navbar-nav {
+ @include media-breakpoint-up($responsive-layout-bp) {
+ padding: 0 $spacer;
+ }
+ align-items: center;
+
+ .navbar-brand,
+ .nav-link {
+ transition: $focus-transition;
+ }
+ .nav-tags {
+ color: theme-color-level(light, 3);
+ @include media-breakpoint-down(xs) {
+ @include sr-only;
+ }
+ .asset-tag {
+ @include media-breakpoint-down($responsive-layout-bp) {
+ @include sr-only;
+ }
+ }
+ }
+ }
+
+ .nav-trigger {
+ fill: theme-color('light');
+ width: $header-height * 1.5;
+ height: $header-height;
+ transition: none;
+ display: inline-flex;
+ flex: 0 0 20px;
+ align-items: center;
+
+ svg {
+ margin: 0;
+ }
+
+ &:hover {
+ fill: theme-color('light');
+ background-color: theme-color-level(light, 10);
+ }
+
+ &.open {
+ background-color: gray('800');
+ }
+
+ @include media-breakpoint-up($responsive-layout-bp) {
+ display: none;
+ }
+ }
+
+ .dropdown-menu {
+ margin-top: 0;
+
+ @include media-breakpoint-only(md) {
+ margin-top: 4px;
+ }
+ }
+
+ .navbar-expand {
+ flex-flow: row wrap;
+ @include media-breakpoint-down(sm) {
+ flex-flow: wrap;
+ }
+ }
+}
+
+.navbar-brand {
+ padding: $spacer/2;
+ height: $header-height;
+ line-height: 1;
+ &:focus {
+ box-shadow: inset 0 0 0 3px $navbar-color, inset 0 0 0 5px color('white');
+ outline: 0;
+ }
+}
+</style>
diff --git a/src/components/_ibs/AppHeader/index.js b/src/components/_ibs/AppHeader/index.js
new file mode 100644
index 00000000..e180e80f
--- /dev/null
+++ b/src/components/_ibs/AppHeader/index.js
@@ -0,0 +1,2 @@
+import AppHeader from './AppHeader';
+export default AppHeader;
diff --git a/src/components/_ibs/AppNavigation/AppNavigation.vue b/src/components/_ibs/AppNavigation/AppNavigation.vue
new file mode 100644
index 00000000..acfabe76
--- /dev/null
+++ b/src/components/_ibs/AppNavigation/AppNavigation.vue
@@ -0,0 +1,255 @@
+<template>
+ <div>
+ <div class="nav-container" :class="{ open: isNavigationOpen }">
+ <nav ref="nav" :aria-label="$t('appNavigation.primaryNavigation')">
+ <b-nav vertical class="mb-4">
+ <template v-for="(navItem, index) in navigationItems">
+ <!-- Navigation items with no children -->
+ <b-nav-item
+ v-if="!navItem.children"
+ :key="index"
+ :to="navItem.route"
+ :data-test-id="`nav-item-${navItem.id}`"
+ >
+ <component :is="navItem.icon" />
+ {{ navItem.label }}
+ </b-nav-item>
+
+ <!-- Navigation items with children -->
+ <li v-else :key="index" class="nav-item">
+ <b-button
+ v-b-toggle="`${navItem.id}`"
+ variant="link"
+ :data-test-id="`nav-button-${navItem.id}`"
+ >
+ <component :is="navItem.icon" />
+ {{ navItem.label }}
+ <icon-expand class="icon-expand" />
+ </b-button>
+ <b-collapse :id="navItem.id" tag="ul" class="nav-item__nav">
+ <li class="nav-item">
+ <router-link
+ v-for="(subNavItem, i) of navItem.children"
+ :key="i"
+ :to="subNavItem.route"
+ :data-test-id="`nav-item-${subNavItem.id}`"
+ class="nav-link"
+ >
+ {{ subNavItem.label }}
+ </router-link>
+ </li>
+ </b-collapse>
+ </li>
+ </template>
+ </b-nav>
+ </nav>
+ </div>
+ <transition name="fade">
+ <div
+ v-if="isNavigationOpen"
+ id="nav-overlay"
+ class="nav-overlay"
+ @click="toggleIsOpen"
+ ></div>
+ </transition>
+ </div>
+</template>
+
+<script>
+//Do not change Mixin import.
+//Exact match alias set to support
+//dotenv customizations.
+import AppNavigationMixin from './AppNavigationMixin';
+
+export default {
+ name: 'AppNavigation',
+ mixins: [AppNavigationMixin],
+ data() {
+ return {
+ isNavigationOpen: false,
+ };
+ },
+ watch: {
+ $route: function () {
+ this.isNavigationOpen = false;
+ },
+ isNavigationOpen: function (isNavigationOpen) {
+ this.$root.$emit('change-is-navigation-open', isNavigationOpen);
+ },
+ },
+ mounted() {
+ this.$root.$on('toggle-navigation', () => this.toggleIsOpen());
+ },
+ methods: {
+ toggleIsOpen() {
+ this.isNavigationOpen = !this.isNavigationOpen;
+ },
+ },
+};
+</script>
+
+<style scoped lang="scss">
+svg {
+ fill: currentColor;
+ height: 1.2rem;
+ width: 1.2rem;
+ margin-left: 0 !important; //!important overriding button specificity
+ vertical-align: text-bottom;
+ &:not(.icon-expand) {
+ margin-right: $spacer;
+ }
+}
+
+.nav {
+ padding-top: $spacer / 4;
+ @include media-breakpoint-up($responsive-layout-bp) {
+ padding-top: $spacer;
+ }
+}
+
+.nav-item__nav {
+ list-style: none;
+ padding-left: 0;
+ margin-left: 0;
+
+ .nav-item {
+ outline: none;
+ }
+
+ .nav-link {
+ padding-left: $spacer * 4;
+ outline: none;
+
+ &:not(.nav-link--current) {
+ font-weight: normal;
+ }
+ }
+}
+
+.btn-link {
+ display: inline-block;
+ width: 100%;
+ text-align: left;
+ text-decoration: none !important;
+ border-radius: 0;
+
+ &.collapsed {
+ .icon-expand {
+ transform: rotate(180deg);
+ }
+ }
+}
+
+.icon-expand {
+ float: right;
+ margin-top: $spacer / 4;
+}
+
+.btn-link,
+.nav-link {
+ position: relative;
+ font-weight: $headings-font-weight;
+ padding-left: $spacer; // defining consistent padding for links and buttons
+ padding-right: $spacer;
+ color: theme-color('secondary');
+
+ &:hover {
+ background-color: theme-color-level(dark, -10.5);
+ color: theme-color('dark');
+ }
+
+ &:focus {
+ background-color: theme-color-level(light, 0);
+ box-shadow: inset 0 0 0 2px theme-color('primary');
+ color: theme-color('dark');
+ outline: 0;
+ }
+
+ &:active {
+ background-color: theme-color('secondary');
+ color: $white;
+ }
+}
+
+.nav-link--current {
+ font-weight: $headings-font-weight;
+ background-color: theme-color('secondary');
+ color: theme-color('light');
+ cursor: default;
+ box-shadow: none;
+
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 4px;
+ background-color: theme-color('primary');
+ }
+
+ &:hover,
+ &:focus {
+ background-color: theme-color('secondary');
+ color: theme-color('light');
+ }
+}
+
+.nav-container {
+ position: fixed;
+ width: $navigation-width;
+ top: $header-height;
+ bottom: 0;
+ left: 0;
+ z-index: $zindex-fixed;
+ overflow-y: auto;
+ background-color: theme-color('light');
+ transform: translateX(-$navigation-width);
+ transition: transform $exit-easing--productive $duration--moderate-02;
+ border-right: 1px solid theme-color-level('light', 2.85);
+
+ @include media-breakpoint-down(md) {
+ z-index: $zindex-fixed + 2;
+ }
+
+ &.open,
+ &:focus-within {
+ transform: translateX(0);
+ transition-timing-function: $entrance-easing--productive;
+ }
+
+ @include media-breakpoint-up($responsive-layout-bp) {
+ transition-duration: $duration--fast-01;
+ transform: translateX(0);
+ }
+}
+
+.nav-overlay {
+ position: fixed;
+ top: $header-height;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: $zindex-fixed + 1;
+ background-color: $black;
+ opacity: 0.5;
+
+ &.fade-enter-active {
+ transition: opacity $duration--moderate-02 $entrance-easing--productive;
+ }
+
+ &.fade-leave-active {
+ transition: opacity $duration--fast-02 $exit-easing--productive;
+ }
+
+ &.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;
+ }
+
+ @include media-breakpoint-up($responsive-layout-bp) {
+ display: none;
+ }
+}
+</style>
diff --git a/src/components/_ibs/AppNavigation/AppNavigationMixin.js b/src/components/_ibs/AppNavigation/AppNavigationMixin.js
new file mode 100644
index 00000000..bbbbb1ee
--- /dev/null
+++ b/src/components/_ibs/AppNavigation/AppNavigationMixin.js
@@ -0,0 +1,182 @@
+import IconDashboard from '@carbon/icons-vue/es/dashboard/16';
+import IconTextLinkAnalysis from '@carbon/icons-vue/es/text-link--analysis/16';
+import IconDataCheck from '@carbon/icons-vue/es/data--check/16';
+import IconSettingsAdjust from '@carbon/icons-vue/es/settings--adjust/16';
+import IconSettings from '@carbon/icons-vue/es/settings/16';
+import IconSecurity from '@carbon/icons-vue/es/security/16';
+import IconChevronUp from '@carbon/icons-vue/es/chevron--up/16';
+import IconDataBase from '@carbon/icons-vue/es/data--base--alt/16';
+
+const AppNavigationMixin = {
+ components: {
+ iconOverview: IconDashboard,
+ iconLogs: IconTextLinkAnalysis,
+ iconHealth: IconDataCheck,
+ iconControl: IconSettingsAdjust,
+ iconSettings: IconSettings,
+ iconSecurityAndAccess: IconSecurity,
+ iconExpand: IconChevronUp,
+ iconResourceManagement: IconDataBase,
+ },
+ data() {
+ return {
+ navigationItems: [
+ {
+ id: 'overview',
+ label: this.$t('appNavigation.overview'),
+ route: '/',
+ icon: 'iconOverview',
+ },
+ {
+ id: 'logs',
+ label: this.$t('appNavigation.logs'),
+ icon: 'iconLogs',
+ children: [
+ {
+ id: 'event-logs',
+ label: this.$t('appNavigation.eventLogs'),
+ route: '/logs/event-logs',
+ },
+ {
+ id: 'post-code-logs',
+ label: this.$t('appNavigation.postCodeLogs'),
+ route: '/logs/post-code-logs',
+ },
+ ],
+ },
+ {
+ id: 'hardware-status',
+ label: this.$t('appNavigation.hardwareStatus'),
+ icon: 'iconHealth',
+ children: [
+ {
+ id: 'inventory',
+ label: this.$t('appNavigation.inventory'),
+ route: '/hardware-status/inventory',
+ },
+ {
+ id: 'sensors',
+ label: this.$t('appNavigation.sensors'),
+ route: '/hardware-status/sensors',
+ },
+ ],
+ },
+ {
+ id: 'operations',
+ label: this.$t('appNavigation.operations'),
+ icon: 'iconControl',
+ children: [
+ {
+ id: 'factory-reset',
+ label: this.$t('appNavigation.factoryReset'),
+ route: '/operations/factory-reset',
+ },
+ {
+ id: 'kvm',
+ label: this.$t('appNavigation.kvm'),
+ route: '/operations/kvm',
+ },
+ {
+ id: 'key-clear',
+ label: this.$t('appNavigation.keyClear'),
+ route: '/operations/key-clear',
+ },
+ {
+ id: 'firmware',
+ label: this.$t('appNavigation.firmware'),
+ route: '/operations/firmware',
+ },
+ {
+ id: 'reboot-bmc',
+ label: this.$t('appNavigation.rebootBmc'),
+ route: '/operations/reboot-bmc',
+ },
+ {
+ id: 'serial-over-lan',
+ label: this.$t('appNavigation.serialOverLan'),
+ route: '/operations/serial-over-lan',
+ },
+ {
+ id: 'server-power-operations',
+ label: this.$t('appNavigation.serverPowerOperations'),
+ route: '/operations/server-power-operations',
+ },
+ {
+ id: 'virtual-media',
+ label: this.$t('appNavigation.virtualMedia'),
+ route: '/operations/virtual-media',
+ },
+ ],
+ },
+ {
+ id: 'settings',
+ label: this.$t('appNavigation.settings'),
+ icon: 'iconSettings',
+ children: [
+ {
+ id: 'date-time',
+ label: this.$t('appNavigation.dateTime'),
+ route: '/settings/date-time',
+ },
+ {
+ id: 'network',
+ label: this.$t('appNavigation.network'),
+ route: '/settings/network',
+ },
+ {
+ id: 'power-restore-policy',
+ label: this.$t('appNavigation.powerRestorePolicy'),
+ route: '/settings/power-restore-policy',
+ },
+ ],
+ },
+ {
+ id: 'security-and-access',
+ label: this.$t('appNavigation.securityAndAccess'),
+ icon: 'iconSecurityAndAccess',
+ children: [
+ {
+ id: 'sessions',
+ label: this.$t('appNavigation.sessions'),
+ route: '/security-and-access/sessions',
+ },
+ {
+ id: 'ldap',
+ label: this.$t('appNavigation.ldap'),
+ route: '/security-and-access/ldap',
+ },
+ {
+ id: 'user-management',
+ label: this.$t('appNavigation.userManagement'),
+ route: '/security-and-access/user-management',
+ },
+ {
+ id: 'policies',
+ label: this.$t('appNavigation.policies'),
+ route: '/security-and-access/policies',
+ },
+ {
+ id: 'certificates',
+ label: this.$t('appNavigation.certificates'),
+ route: '/security-and-access/certificates',
+ },
+ ],
+ },
+ {
+ id: 'resource-management',
+ label: this.$t('appNavigation.resourceManagement'),
+ icon: 'iconResourceManagement',
+ children: [
+ {
+ id: 'power',
+ label: this.$t('appNavigation.power'),
+ route: '/resource-management/power',
+ },
+ ],
+ },
+ ],
+ };
+ },
+};
+
+export default AppNavigationMixin;
diff --git a/src/components/_ibs/AppNavigation/index.js b/src/components/_ibs/AppNavigation/index.js
new file mode 100644
index 00000000..88fe8eb6
--- /dev/null
+++ b/src/components/_ibs/AppNavigation/index.js
@@ -0,0 +1,2 @@
+import AppNavigation from './AppNavigation';
+export default AppNavigation;
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>
diff --git a/src/components/_ibs/Mixins/BVPaginationMixin.js b/src/components/_ibs/Mixins/BVPaginationMixin.js
new file mode 100644
index 00000000..4ccf6f2c
--- /dev/null
+++ b/src/components/_ibs/Mixins/BVPaginationMixin.js
@@ -0,0 +1,34 @@
+import i18n from '@/i18n';
+export const currentPage = 1;
+export const perPage = 20;
+export const itemsPerPageOptions = [
+ {
+ value: 10,
+ text: '10',
+ },
+ {
+ value: 20,
+ text: '20',
+ },
+ {
+ value: 30,
+ text: '30',
+ },
+ {
+ value: 40,
+ text: '40',
+ },
+ {
+ value: 0,
+ text: i18n.t('global.table.viewAll'),
+ },
+];
+const BVPaginationMixin = {
+ methods: {
+ getTotalRowCount(count) {
+ return this.perPage === 0 ? 0 : count;
+ },
+ },
+};
+
+export default BVPaginationMixin;
diff --git a/src/components/_ibs/Mixins/BVTableSelectableMixin.js b/src/components/_ibs/Mixins/BVTableSelectableMixin.js
new file mode 100644
index 00000000..b4f0b953
--- /dev/null
+++ b/src/components/_ibs/Mixins/BVTableSelectableMixin.js
@@ -0,0 +1,41 @@
+export const selectedRows = [];
+export const tableHeaderCheckboxModel = false;
+export const tableHeaderCheckboxIndeterminate = false;
+
+const BVTableSelectableMixin = {
+ methods: {
+ clearSelectedRows(tableRef) {
+ if (tableRef) tableRef.clearSelected();
+ },
+ toggleSelectRow(tableRef, rowIndex) {
+ if (tableRef && rowIndex !== undefined) {
+ tableRef.isRowSelected(rowIndex)
+ ? tableRef.unselectRow(rowIndex)
+ : tableRef.selectRow(rowIndex);
+ }
+ },
+ onRowSelected(selectedRows, totalRowsCount) {
+ if (selectedRows && totalRowsCount !== undefined) {
+ this.selectedRows = selectedRows;
+ if (selectedRows.length === 0) {
+ this.tableHeaderCheckboxIndeterminate = false;
+ this.tableHeaderCheckboxModel = false;
+ } else if (selectedRows.length === totalRowsCount) {
+ this.tableHeaderCheckboxIndeterminate = false;
+ this.tableHeaderCheckboxModel = true;
+ } else {
+ this.tableHeaderCheckboxIndeterminate = true;
+ this.tableHeaderCheckboxModel = true;
+ }
+ }
+ },
+ onChangeHeaderCheckbox(tableRef) {
+ if (tableRef) {
+ if (this.tableHeaderCheckboxModel) tableRef.selectAllRows();
+ else tableRef.clearSelected();
+ }
+ },
+ },
+};
+
+export default BVTableSelectableMixin;
diff --git a/src/components/_ibs/Mixins/BVToastMixin.js b/src/components/_ibs/Mixins/BVToastMixin.js
new file mode 100644
index 00000000..a04ef438
--- /dev/null
+++ b/src/components/_ibs/Mixins/BVToastMixin.js
@@ -0,0 +1,115 @@
+import StatusIcon from '../Global/StatusIcon';
+
+const BVToastMixin = {
+ components: {
+ StatusIcon,
+ },
+ methods: {
+ $_BVToastMixin_createTitle(title, status) {
+ const statusIcon = this.$createElement('StatusIcon', {
+ props: { status },
+ });
+ const titleWithIcon = this.$createElement(
+ 'strong',
+ { class: 'toast-icon' },
+ [statusIcon, title]
+ );
+ return titleWithIcon;
+ },
+ $_BVToastMixin_createBody(messageBody) {
+ if (Array.isArray(messageBody)) {
+ return messageBody.map((message) =>
+ this.$createElement('p', { class: 'mb-0' }, message)
+ );
+ } else {
+ return [this.$createElement('p', { class: 'mb-0' }, messageBody)];
+ }
+ },
+ $_BVToastMixin_createTimestamp() {
+ const timestamp = this.$options.filters.formatTime(new Date());
+ return this.$createElement('p', { class: 'mt-3 mb-0' }, timestamp);
+ },
+ $_BVToastMixin_createRefreshAction() {
+ return this.$createElement(
+ 'BLink',
+ {
+ class: 'd-inline-block mt-3',
+ on: {
+ click: () => {
+ this.$root.$emit('refresh-application');
+ },
+ },
+ },
+ this.$t('global.action.refresh')
+ );
+ },
+ $_BVToastMixin_initToast(body, title, variant) {
+ this.$root.$bvToast.toast(body, {
+ title,
+ variant,
+ autoHideDelay: 10000, //auto hide in milliseconds
+ noAutoHide: variant !== 'success',
+ isStatus: true,
+ solid: true,
+ });
+ },
+ successToast(
+ message,
+ {
+ title: t = this.$t('global.status.success'),
+ timestamp,
+ refreshAction,
+ } = {}
+ ) {
+ const body = this.$_BVToastMixin_createBody(message);
+ const title = this.$_BVToastMixin_createTitle(t, 'success');
+ if (refreshAction) body.push(this.$_BVToastMixin_createRefreshAction());
+ if (timestamp) body.push(this.$_BVToastMixin_createTimestamp());
+ this.$_BVToastMixin_initToast(body, title, 'success');
+ },
+ errorToast(
+ message,
+ {
+ title: t = this.$t('global.status.error'),
+ timestamp,
+ refreshAction,
+ } = {}
+ ) {
+ const body = this.$_BVToastMixin_createBody(message);
+ const title = this.$_BVToastMixin_createTitle(t, 'danger');
+ if (refreshAction) body.push(this.$_BVToastMixin_createRefreshAction());
+ if (timestamp) body.push(this.$_BVToastMixin_createTimestamp());
+ this.$_BVToastMixin_initToast(body, title, 'danger');
+ },
+ warningToast(
+ message,
+ {
+ title: t = this.$t('global.status.warning'),
+ timestamp,
+ refreshAction,
+ } = {}
+ ) {
+ const body = this.$_BVToastMixin_createBody(message);
+ const title = this.$_BVToastMixin_createTitle(t, 'warning');
+ if (refreshAction) body.push(this.$_BVToastMixin_createRefreshAction());
+ if (timestamp) body.push(this.$_BVToastMixin_createTimestamp());
+ this.$_BVToastMixin_initToast(body, title, 'warning');
+ },
+ infoToast(
+ message,
+ {
+ title: t = this.$t('global.status.informational'),
+ timestamp,
+ refreshAction,
+ } = {}
+ ) {
+ const body = this.$_BVToastMixin_createBody(message);
+ const title = this.$_BVToastMixin_createTitle(t, 'info');
+ if (refreshAction) body.push(this.$_BVToastMixin_createRefreshAction());
+ if (timestamp) body.push(this.$_BVToastMixin_createTimestamp());
+ this.$_BVToastMixin_initToast(body, title, 'info');
+ },
+ },
+};
+
+export default BVToastMixin;
diff --git a/src/components/_ibs/Mixins/DataFormatterMixin.js b/src/components/_ibs/Mixins/DataFormatterMixin.js
new file mode 100644
index 00000000..5ce79327
--- /dev/null
+++ b/src/components/_ibs/Mixins/DataFormatterMixin.js
@@ -0,0 +1,30 @@
+const DataFormatterMixin = {
+ methods: {
+ dataFormatter(value) {
+ if (value === undefined || value === null || value === '') {
+ return '--';
+ } else if (typeof value === 'number') {
+ return parseFloat(value.toFixed(3));
+ } else {
+ return value;
+ }
+ },
+ statusIcon(status) {
+ switch (status) {
+ case 'OK':
+ return 'success';
+ case 'Warning':
+ return 'warning';
+ case 'Critical':
+ return 'danger';
+ default:
+ return '';
+ }
+ },
+ dataFormatterArray(value) {
+ return value.join(', ');
+ },
+ },
+};
+
+export default DataFormatterMixin;
diff --git a/src/components/_ibs/Mixins/JumpLinkMixin.js b/src/components/_ibs/Mixins/JumpLinkMixin.js
new file mode 100644
index 00000000..b038527b
--- /dev/null
+++ b/src/components/_ibs/Mixins/JumpLinkMixin.js
@@ -0,0 +1,27 @@
+const JumpLinkMixin = {
+ methods: {
+ setFocus(element) {
+ element.setAttribute('tabindex', '-1');
+ element.focus();
+ // Reason: https://axesslab.com/skip-links/#update-3-a-comment-from-gov-uk
+ element.removeAttribute('tabindex');
+ },
+ scrollToOffset(event) {
+ // Select element to scroll to
+ const ref = event.target.getAttribute('data-ref');
+ const element = this.$refs[ref].$el;
+
+ // Set focus and tabindex on selected element
+ this.setFocus(element);
+
+ // Set scroll offset below header
+ const offset = element.offsetTop - 50;
+ window.scroll({
+ top: offset,
+ behavior: 'smooth',
+ });
+ },
+ },
+};
+
+export default JumpLinkMixin;
diff --git a/src/components/_ibs/Mixins/LoadingBarMixin.js b/src/components/_ibs/Mixins/LoadingBarMixin.js
new file mode 100644
index 00000000..d1152703
--- /dev/null
+++ b/src/components/_ibs/Mixins/LoadingBarMixin.js
@@ -0,0 +1,19 @@
+export const loading = true;
+
+const LoadingBarMixin = {
+ methods: {
+ startLoader() {
+ this.$root.$emit('loader-start');
+ this.loading = true;
+ },
+ endLoader() {
+ this.$root.$emit('loader-end');
+ this.loading = false;
+ },
+ hideLoader() {
+ this.$root.$emit('loader-hide');
+ },
+ },
+};
+
+export default LoadingBarMixin;
diff --git a/src/components/_ibs/Mixins/LocalTimezoneLabelMixin.js b/src/components/_ibs/Mixins/LocalTimezoneLabelMixin.js
new file mode 100644
index 00000000..6b4141c6
--- /dev/null
+++ b/src/components/_ibs/Mixins/LocalTimezoneLabelMixin.js
@@ -0,0 +1,14 @@
+import { format } from 'date-fns-tz';
+
+const LocalTimezoneLabelMixin = {
+ methods: {
+ localOffset() {
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ const shortTz = this.$options.filters.shortTimeZone(new Date());
+ const pattern = `'${shortTz}' O`;
+ return format(new Date(), pattern, { timezone }).replace('GMT', 'UTC');
+ },
+ },
+};
+
+export default LocalTimezoneLabelMixin;
diff --git a/src/components/_ibs/Mixins/SearchFilterMixin.js b/src/components/_ibs/Mixins/SearchFilterMixin.js
new file mode 100644
index 00000000..a4819e26
--- /dev/null
+++ b/src/components/_ibs/Mixins/SearchFilterMixin.js
@@ -0,0 +1,14 @@
+export const searchFilter = null;
+
+const SearchFilterMixin = {
+ methods: {
+ onChangeSearchInput(searchValue) {
+ this.searchFilter = searchValue;
+ },
+ onClearSearchInput() {
+ this.searchFilter = null;
+ },
+ },
+};
+
+export default SearchFilterMixin;
diff --git a/src/components/_ibs/Mixins/TableFilterMixin.js b/src/components/_ibs/Mixins/TableFilterMixin.js
new file mode 100644
index 00000000..7a2cc540
--- /dev/null
+++ b/src/components/_ibs/Mixins/TableFilterMixin.js
@@ -0,0 +1,58 @@
+import { includes } from 'lodash';
+
+const TableFilterMixin = {
+ methods: {
+ getFilteredTableData(tableData = [], filters = []) {
+ const filterItems = filters.reduce((arr, filter) => {
+ return [...arr, ...filter.values];
+ }, []);
+ // If no filters are active, then return all table data
+ if (filterItems.length === 0) return tableData;
+
+ // Check if row property value is included in list of
+ // active filters
+ return tableData.filter((row) => {
+ let returnRow = false;
+ for (const { key, values } of filters) {
+ const rowProperty = row[key];
+ if (rowProperty && includes(values, rowProperty)) {
+ returnRow = true;
+ break;
+ }
+ }
+ return returnRow;
+ });
+ },
+ getFilteredTableDataByDate(
+ tableData = [],
+ startDate,
+ endDate,
+ propertyKey = 'date'
+ ) {
+ if (!startDate && !endDate) return tableData;
+ let startDateInMs = startDate ? startDate.getTime() : 0;
+ let endDateInMs = endDate ? endDate.getTime() : Number.POSITIVE_INFINITY;
+
+ const isUtcDisplay = this.$store.getters['global/isUtcDisplay'];
+
+ //Offset preference selected
+ if (!isUtcDisplay) {
+ startDateInMs = startDate
+ ? startDate.getTime() + startDate.getTimezoneOffset() * 60000
+ : 0;
+ endDateInMs = endDate
+ ? endDate.getTime() + endDate.getTimezoneOffset() * 60000
+ : Number.POSITIVE_INFINITY;
+ }
+
+ return tableData.filter((row) => {
+ const date = row[propertyKey];
+ if (!(date instanceof Date)) return;
+ const dateInMs = date.getTime();
+ if (dateInMs >= startDateInMs && dateInMs <= endDateInMs) return row;
+ });
+ },
+ },
+};
+
+export default TableFilterMixin;
diff --git a/src/components/_ibs/Mixins/TableRowExpandMixin.js b/src/components/_ibs/Mixins/TableRowExpandMixin.js
new file mode 100644
index 00000000..7f815a46
--- /dev/null
+++ b/src/components/_ibs/Mixins/TableRowExpandMixin.js
@@ -0,0 +1,15 @@
+import i18n from '@/i18n';
+export const expandRowLabel = i18n.t('global.table.expandTableRow');
+
+const TableRowExpandMixin = {
+ methods: {
+ toggleRowDetails(row) {
+ row.toggleDetails();
+ row.detailsShowing
+ ? (this.expandRowLabel = this.$t('global.table.expandTableRow'))
+ : (this.expandRowLabel = this.$t('global.table.collapseTableRow'));
+ },
+ },
+};
+
+export default TableRowExpandMixin;
diff --git a/src/components/_ibs/Mixins/TableSortMixin.js b/src/components/_ibs/Mixins/TableSortMixin.js
new file mode 100644
index 00000000..c0997350
--- /dev/null
+++ b/src/components/_ibs/Mixins/TableSortMixin.js
@@ -0,0 +1,11 @@
+const STATUS = ['OK', 'Warning', 'Critical'];
+
+const TableSortMixin = {
+ methods: {
+ sortStatus(a, b, key) {
+ return STATUS.indexOf(a[key]) - STATUS.indexOf(b[key]);
+ },
+ },
+};
+
+export default TableSortMixin;
diff --git a/src/components/_ibs/Mixins/VuelidateMixin.js b/src/components/_ibs/Mixins/VuelidateMixin.js
new file mode 100644
index 00000000..fec85251
--- /dev/null
+++ b/src/components/_ibs/Mixins/VuelidateMixin.js
@@ -0,0 +1,10 @@
+const VuelidateMixin = {
+ methods: {
+ getValidationState(model) {
+ const { $dirty, $error } = model;
+ return $dirty ? !$error : null;
+ },
+ },
+};
+
+export default VuelidateMixin;