diff options
author | Andrey V.Kosteltsev <AKosteltsev@IBS.RU> | 2022-07-04 23:11:28 +0300 |
---|---|---|
committer | Andrey V.Kosteltsev <AKosteltsev@IBS.RU> | 2022-07-04 23:11:28 +0300 |
commit | 3f4094d08b873e17464a51c817ea7d41177f848d (patch) | |
tree | 8880a0e7c8c0ac07ed298ce719cfab3278f2aa12 /src/components/_ibs | |
parent | f5c8dbfa6fb3812a3b3a2aafd3538fbdf8b8c668 (diff) | |
download | webui-vue-3f4094d08b873e17464a51c817ea7d41177f848d.tar.xz |
IBS: _ibs UI Theme
Diffstat (limited to 'src/components/_ibs')
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; |